Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ greplica doctor [--check-embeddings]
greplica graph read
greplica graph context "<query>" [--debug]
greplica graph audit anchors
greplica graph gc [--dry-run]
greplica graph view [--out <file>] [--no-open]
greplica graph export <dir>
greplica transcript bundle --platform codex|claude|copilot --file <path> [--file <path>...] --out <bundle.md>
Expand All @@ -133,6 +134,7 @@ greplica proposal apply <proposal.json>

- `greplica graph context "<query>"` - returns Markdown for agent use. Add `--debug` for the full retrieval payload with ranking signals.
- `greplica graph read` - prints the current graph view: all components, flows, claims, sources, and edges in scope.
- `greplica graph gc [--dry-run]` - prunes stale memory from this repo's graph: components/claims whose code anchor file no longer exists, orphaned claims/flows, and dangling edges. `--dry-run` reports what would be pruned without touching the database.
- `greplica graph view` to visualise the current memory in a local HTML, opens in your default browser. Use `--out` to choose where the file is written; by default it goes to a temp path.
- `greplica transcript bundle` - converts one or more Codex, Claude Code, or GitHub Copilot CLI JSONL transcripts into a sanitized Markdown bundle for `greplica-fast-session-bootstrap`.
- `greplica doctor` - verifies installation and diagnoses configuration failures. Not a required preflight before every command.
Expand Down
33 changes: 32 additions & 1 deletion apps/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { isatty } from "node:tty";
import { basename, dirname, join, resolve } from "node:path";
import { tmpdir } from "node:os";
import { createLocalKnowledgeGraphService, KnowledgeGraphService } from "../../libs/knowledge-graph/service.js";
import type { ClaimAnchorAuditResult, RepoRef } from "../../libs/knowledge-graph/service.js";
import type { ClaimAnchorAuditResult, GcReport, RepoRef } from "../../libs/knowledge-graph/service.js";
import { envVarSource, loadRepoEnv, type LoadedRepoEnv } from "../../libs/env/load-local-env.js";
import {
ensureGreplicaConfig,
Expand Down Expand Up @@ -104,6 +104,13 @@ const cliCommands = [
handler: runGraphAuditAnchorsCommand,
showInTopLevelHelp: true,
},
{
key: "graphGc",
path: ["graph", "gc"],
usage: "graph gc [--dry-run]",
handler: runGraphGcCommand,
showInTopLevelHelp: true,
},
{
key: "graphExport",
path: ["graph", "export"],
Expand Down Expand Up @@ -278,6 +285,30 @@ async function runGraphAuditAnchorsCommand(_args: string[]): Promise<void> {
if (anchorAuditIssueCount(result) > 0) process.exitCode = 1;
}

async function runGraphGcCommand(args: string[]): Promise<void> {
const dryRun = args.includes("--dry-run");
if (args.some((arg) => arg !== "--dry-run")) throw new Error(usage("graphGc"));
const { repo, service } = createCommandContext();
const report = await service.gcGraph(repo, { dryRun });
printGcReport(report);
}

function printGcReport(report: GcReport): void {
console.log(report.dry_run ? "Graph gc (dry run)" : "Graph gc");
console.log("");
printAuditSection("Stale components", report.stale_components, (issue) => `${issue.id} -> ${issue.anchor}`);
printAuditSection("Stale claims", report.stale_claims, (issue) => `${issue.id} -> ${issue.anchor}`);
printAuditSection("Orphaned claims", report.orphaned_claims, (id) => id);
printAuditSection("Orphaned flows", report.orphaned_flows, (id) => id);
printAuditSection("Dangling edges", report.dangling_edges, (edge) => `${edge.id} (${edge.from} -> ${edge.to})`);

const { components, flows, claims, edges } = report.pruned;
const total = components + flows + claims + edges;
const verb = report.dry_run ? "Would prune" : "Pruned";
console.log(`${verb}: ${components} components, ${flows} flows, ${claims} claims, ${edges} edges.`);
if (report.dry_run && total > 0) console.log("Run `greplica graph gc` to prune.");
}

function runGraphExportCommand(args: string[]): void {
const outputDir = requireFile(args[0], usage("graphExport"));
const { repo, service } = createCommandContext();
Expand Down
133 changes: 133 additions & 0 deletions libs/knowledge-graph/graph-gc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import type { Edge } from "./edge.js";

export type GcSubjectType = "component" | "flow" | "claim" | "edge";

export interface GcSubjectRef {
type: GcSubjectType;
id: string;
}

export interface GcAnchorIssue {
id: string;
anchor: string;
}

export interface GcDanglingEdge {
id: string;
from: string;
to: string;
}

export interface GcPrunedCounts {
components: number;
flows: number;
claims: number;
edges: number;
}

export interface GcReport {
dry_run: boolean;
stale_components: GcAnchorIssue[];
stale_claims: GcAnchorIssue[];
orphaned_claims: string[];
orphaned_flows: string[];
dangling_edges: GcDanglingEdge[];
pruned: GcPrunedCounts;
}

export interface GcPlanInput {
components: { id: string }[];
flows: { id: string }[];
claims: { id: string }[];
edges: Edge[];
/** "type:id" keys for edge endpoints whose object row still exists. */
existingKeys: ReadonlySet<string>;
staleComponentIds: readonly string[];
staleClaimIds: readonly string[];
}

export interface GcPlan {
/** components/flows/claims to prune. */
subjects: GcSubjectRef[];
/** edge ids to prune (dangling + edges attached to a pruned subject). */
edges: string[];
orphaned_claims: string[];
orphaned_flows: string[];
dangling_edges: GcDanglingEdge[];
pruned: GcPrunedCounts;
}

function subjectKey(type: string, id: string): string {
return `${type}:${id}`;
}

/**
* Compute what `graph gc` should remove from a single repo's active graph.
*
* Detection passes:
* - stale anchors: components/claims whose code anchor file no longer exists
* (ids supplied by the caller, which owns filesystem access);
* - orphaned claims/flows: no edge references them;
* - dangling edges: an endpoint object row no longer exists.
*
* Pruning a subject cascades to every edge that touches it, so a stale claim's
* `about`/`evidenced_by` edges are removed in the same pass.
*/
export function planGc(input: GcPlanInput): GcPlan {
const referenced = new Set<string>();
for (const edge of input.edges) {
referenced.add(subjectKey(edge.from_type, edge.from_id));
referenced.add(subjectKey(edge.to_type, edge.to_id));
}

const orphanedClaims = input.claims.filter((claim) => !referenced.has(subjectKey("claim", claim.id))).map((claim) => claim.id);
const orphanedFlows = input.flows.filter((flow) => !referenced.has(subjectKey("flow", flow.id))).map((flow) => flow.id);

const danglingEdges: GcDanglingEdge[] = input.edges
.filter(
(edge) =>
!input.existingKeys.has(subjectKey(edge.from_type, edge.from_id)) ||
!input.existingKeys.has(subjectKey(edge.to_type, edge.to_id)),
)
.map((edge) => ({
id: edge.id,
from: subjectKey(edge.from_type, edge.from_id),
to: subjectKey(edge.to_type, edge.to_id),
}));

const subjectKeys = new Set<string>();
const subjects: GcSubjectRef[] = [];
const addSubject = (type: GcSubjectType, id: string): void => {
const key = subjectKey(type, id);
if (subjectKeys.has(key)) return;
subjectKeys.add(key);
subjects.push({ type, id });
};
for (const id of input.staleComponentIds) addSubject("component", id);
for (const id of input.staleClaimIds) addSubject("claim", id);
for (const id of orphanedClaims) addSubject("claim", id);
for (const id of orphanedFlows) addSubject("flow", id);

const edgeIds = new Set<string>(danglingEdges.map((edge) => edge.id));
for (const edge of input.edges) {
if (subjectKeys.has(subjectKey(edge.from_type, edge.from_id)) || subjectKeys.has(subjectKey(edge.to_type, edge.to_id))) {
edgeIds.add(edge.id);
}
}

const pruned: GcPrunedCounts = {
components: subjects.filter((subject) => subject.type === "component").length,
flows: subjects.filter((subject) => subject.type === "flow").length,
claims: subjects.filter((subject) => subject.type === "claim").length,
edges: edgeIds.size,
};

return {
subjects,
edges: [...edgeIds],
orphaned_claims: orphanedClaims,
orphaned_flows: orphanedFlows,
dangling_edges: danglingEdges,
pruned,
};
}
51 changes: 51 additions & 0 deletions libs/knowledge-graph/service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { normalizeProposal } from "./proposal.js";
import { validateProposal, type ProposalValidationResult } from "./validate-proposal.js";
import { planGc, type GcReport } from "./graph-gc.js";
import type { Claim } from "./claim.js";
import type { Edge } from "./edge.js";
import type { Component, Flow, GraphObjectType, Source } from "./schema.js";
Expand All @@ -15,6 +18,7 @@ import { SqliteRepository as SqliteKnowledgeGraphRepository } from "../storage/s

export type { GraphContextResult } from "./graph-context/types.js";
export type { ClaimAnchorAuditResult } from "./code-anchors/types.js";
export type { GcReport } from "./graph-gc.js";

export interface RepoRef {
repo_root?: string;
Expand Down Expand Up @@ -125,6 +129,39 @@ export class KnowledgeGraphService {
return auditClaimCodeAnchors(input.repo_root, this.repository.readGraphView(initialized.repo_id).claims);
}

async gcGraph(input: RepoRef, options: { dryRun: boolean }): Promise<GcReport> {
const initialized = this.requireRepo(input);
const graph = this.repository.readGcGraph(initialized.repo_id);

const audit = await auditClaimCodeAnchors(input.repo_root, graph.claims);
const staleClaims = audit.missing_files.map((issue) => ({ id: issue.claim_id, anchor: formatAnchor(issue.anchor) }));
const staleComponents = staleComponentAnchors(input.repo_root, graph.components);

const plan = planGc({
components: graph.components,
flows: graph.flows,
claims: graph.claims,
edges: graph.edges,
existingKeys: graph.existingKeys,
staleComponentIds: staleComponents.map((component) => component.id),
staleClaimIds: staleClaims.map((claim) => claim.id),
});

if (!options.dryRun) {
this.repository.applyGc(initialized.repo_id, { subjects: plan.subjects, edges: plan.edges });
}

return {
dry_run: options.dryRun,
stale_components: staleComponents,
stale_claims: staleClaims,
orphaned_claims: plan.orphaned_claims,
orphaned_flows: plan.orphaned_flows,
dangling_edges: plan.dangling_edges,
pruned: plan.pruned,
};
}

async validateProposal(input: RepoRef, proposal: unknown): Promise<ProposalValidationResult> {
const initialized = this.requireRepo(input);
const subjectLookup = this.subjectLookup(initialized.repo_id);
Expand Down Expand Up @@ -205,6 +242,20 @@ function formatAnchor(anchor: { file: string; symbol?: string } | undefined): st
return anchor.symbol === undefined ? anchor.file : `${anchor.file}#${anchor.symbol}`;
}

function staleComponentAnchors(repoRoot: string | undefined, components: Component[]): { id: string; anchor: string }[] {
if (repoRoot === undefined) return [];
const root = resolve(repoRoot);
const stale: { id: string; anchor: string }[] = [];
for (const component of components) {
if (component.code_anchor === undefined || component.code_anchor.length === 0) continue;
const file = component.code_anchor.split("#")[0];
const absolute = resolve(root, file);
if (absolute !== root && !absolute.startsWith(`${root}/`)) continue;
if (!existsSync(absolute)) stale.push({ id: component.id, anchor: component.code_anchor });
}
return stale;
}

export function createLocalKnowledgeGraphService(
config: GraphContextConfig = graphContextConfig,
): KnowledgeGraphService {
Expand Down
75 changes: 75 additions & 0 deletions libs/storage/sqlite/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { MemoryCommitProposal } from "../../knowledge-graph/proposal.js";
import type { Component, Flow, GraphObjectType, Source } from "../../knowledge-graph/schema.js";
import type { Claim } from "../../knowledge-graph/claim.js";
import type { GraphScope, GraphScopeKind } from "../../knowledge-graph/scope.js";
import type { GcSubjectRef } from "../../knowledge-graph/graph-gc.js";

export interface RepoRecord {
id: string;
Expand Down Expand Up @@ -259,6 +260,80 @@ export class SqliteRepository {
};
}

/**
* Raw view for `graph gc`: the repo's active components/flows/claims plus ALL
* of its edges (unfiltered, so dangling ones are visible) and the set of edge
* endpoints whose object row still exists.
*/
readGcGraph(repoId: string): {
components: Component[];
flows: Flow[];
claims: Claim[];
edges: Edge[];
existingKeys: Set<string>;
} {
const scopeIds = this.currentScopeIds(repoId);
const memberships = this.membershipsForScopes(scopeIds);
const edges = this.loadEdges(selectIds(memberships, "edge"));
const active = activeSubjectKeys(memberships, edges);

const existingKeys = new Set<string>();
for (const edge of edges) {
for (const endpoint of [
{ type: edge.from_type, id: edge.from_id },
{ type: edge.to_type, id: edge.to_id },
]) {
if (this.subjectExists(endpoint.type, endpoint.id)) existingKeys.add(subjectKey(endpoint.type, endpoint.id));
}
}

return {
components: this.loadComponents(selectActiveIds(memberships, active, "component")),
flows: this.loadFlows(selectActiveIds(memberships, active, "flow")),
claims: this.loadClaims(selectActiveIds(memberships, active, "claim")),
edges,
existingKeys,
};
}

/**
* Prune a `graph gc` plan for one repo in a single transaction. Object rows
* are global across repos (issue #103), so we only unlink this repo's
* memberships and repo-scoped embeddings, then delete the shared row when no
* membership anywhere still references it.
*/
applyGc(repoId: string, plan: { subjects: GcSubjectRef[]; edges: string[] }): void {
const scopeIds = this.currentScopeIds(repoId);
if (scopeIds.length === 0) return;
const refs: GcSubjectRef[] = [...plan.subjects, ...plan.edges.map((id) => ({ type: "edge" as const, id }))];
if (refs.length === 0) return;

const deleteMembership = this.db.prepare(
`DELETE FROM graph_memberships WHERE scope_id IN (${placeholders(scopeIds)}) AND subject_type = ? AND subject_id = ?`,
);
const deleteEmbedding = this.db.prepare(
"DELETE FROM graph_object_embeddings WHERE repo_id = ? AND object_type = ? AND object_id = ?",
);
const membershipRemains = this.db.prepare(
"SELECT 1 FROM graph_memberships WHERE subject_type = ? AND subject_id = ? LIMIT 1",
);
const deleteRow: Record<GcSubjectRef["type"], Database.Statement> = {
component: this.db.prepare("DELETE FROM components WHERE id = ?"),
flow: this.db.prepare("DELETE FROM flows WHERE id = ?"),
claim: this.db.prepare("DELETE FROM claims WHERE id = ?"),
edge: this.db.prepare("DELETE FROM edges WHERE id = ?"),
};

const write = this.db.transaction((items: GcSubjectRef[]) => {
for (const ref of items) {
deleteMembership.run(...scopeIds, ref.type, ref.id);
if (ref.type !== "edge") deleteEmbedding.run(repoId, ref.type, ref.id);
if (membershipRemains.get(ref.type, ref.id) === undefined) deleteRow[ref.type].run(ref.id);
}
});
write(refs);
}

createMemoryCommit(input: CreateMemoryCommitInput): MemoryCommit {
const parent = this.db
.prepare("SELECT id FROM memory_commits WHERE scope_id = ? ORDER BY created_at DESC LIMIT 1")
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"smoke:openhands": "npm run build && node scripts/smoke-openhands-install.mjs",
"smoke:copilot": "npm run build && node scripts/smoke-copilot-install.mjs",
"smoke:opencode": "npm run build && node scripts/smoke-opencode-install.mjs",
"test": "npm run build && node scripts/check-transcript-bundle.js && node scripts/check-repo-context.js && node scripts/check-install-options.js && node scripts/check-graph-view.js && node scripts/check-proposal-validate.js && node scripts/check-bm25-tokenizer.js",
"test": "npm run build && node scripts/check-transcript-bundle.js && node scripts/check-repo-context.js && node scripts/check-install-options.js && node scripts/check-graph-view.js && node scripts/check-graph-gc.js && node scripts/check-proposal-validate.js",
"test:transcript-bundle": "npm run build && node scripts/check-transcript-bundle.js",
"test:repo-context": "npm run build && node scripts/check-repo-context.js",
"eval:bootstrap-current": "npm run build && node dist/evals/cases/bootstrap-current-repo-at-8038fe8/run.js",
Expand Down
Loading