Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ All notable changes to this project will be documented in this file.
- Project Hub Overview now opens with a compact Context card above the atlas instead of a header button. It links to Context when the Wiki index is fresh and to Health otherwise, so a stale or unavailable index no longer leads to a page that cannot load.

### Fixed
- `mex impact` no longer drops grounding records first when the output budget runs out. They are admitted right after the target's definitions, ahead of transitive callers and source ranges, so a target with many callers still returns its knowledge links at the default budget; the response is still marked `truncated` when callers are cut. The accepted cost: on a target with many groundings, a few caller or source records that used to fit now give way to them (#225).
- `mex graph status` no longer prints placeholder zeros as measurements when immutable inspection is skipped. While a stranded `graph.db-wal`, an unreadable sidecar, a containment failure or a failed invariant audit blocks the read, the text output now says `Last successful index: not inspected`, `Sources: not inspected` and `Parse health: not inspected` instead of `never` and `0 ok`, and `--json` carries an additive `inspected: false` so a consumer can tell an uninspected store from one that genuinely parsed nothing. The same applies when a `GRAPH_SNAPSHOT_CONTENT_MISMATCH` stops the source comparison before the index timestamps are read, and to the `mex check` fallback status when the loader itself throws. `parseHealth` and `changes` keep their shape (#204).
- Agent population failures retain the real copyable manual prompt for retry or manual continuation. Integration pointer notes are visible as non-blocking guidance.
- Setup and Overview share the computer's contact preference so completing or skipping the invitation does not immediately trigger another request.
Expand Down
24 changes: 14 additions & 10 deletions src/graph/cli-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,20 @@ export function runImpact(
}
}
const ordered = [...impacted.values()].sort((a, b) => a.depth - b.depth || a.node.id.localeCompare(b.node.id));

// Grounding is keyed by node, so an excluded node must not reappear
// through it. Nodes omitted only by the returned-node cap still count.
const affectedIds = [...new Set([...roots.map((node) => node.id), ...impacted.keys()])]
.filter((id) => !isDriftedFile(session, session.graph.getNode(id)?.filePath));
// Knowledge links are admitted before callers and source (#225): they are
// tiny and no other command returns them, so budget pressure cuts the
// callers other commands can reproduce instead. Output order is unchanged.
const groundingRecords: Rec[] = [];
for (const grounding of groundedFiles(session.db, affectedIds)) {
const record: Rec = { type: "grounding", node: grounding.node_id, file: grounding.scaffold_file };
if (ledger.tryAdd(record)) groundingRecords.push(record); else truncated = true;
}

for (const entry of ordered) {
if (emittedNodes.length >= opts.maxNodes) { truncated = true; break; }
const fact = factFor(session, entry.node.id, opts.detail, opts.fingerprint);
Expand All @@ -147,16 +161,6 @@ export function runImpact(

const sourceRecords = planSource(session, ledger, emittedNodes, rootDir, opts);

// Grounding is keyed by node, so an excluded node must not reappear
// through it. Nodes omitted only by the returned-node cap still count.
const affectedIds = [...new Set([...roots.map((node) => node.id), ...impacted.keys()])]
.filter((id) => !isDriftedFile(session, session.graph.getNode(id)?.filePath));
const groundingRecords: Rec[] = [];
for (const grounding of groundedFiles(session.db, affectedIds)) {
const record: Rec = { type: "grounding", node: grounding.node_id, file: grounding.scaffold_file };
if (ledger.tryAdd(record)) groundingRecords.push(record); else truncated = true;
}

emitAll(write, meta, [
...configDriftRecords(session),
...headRecords, ...factRecords, ...sourceRecords, ...groundingRecords,
Expand Down
51 changes: 51 additions & 0 deletions test/graph-cli-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,57 @@ describe("agent graph commands", () => {
expect(fixture.close).toHaveBeenCalledOnce();
});

it("impact keeps grounding records when callers exhaust the default budget", () => {
const leaf = node("function:leaf", "leaf");
const callers = Array.from({ length: 60 }, (_, i) => node(`function:caller${i}`, `caller${i}`, `src/caller${i}.ts`, 4));
const nodes = [leaf, ...callers];
const graph: GraphEngine = {
build: vi.fn(), sync: vi.fn(), close: vi.fn(),
getNode: (id) => nodes.find((entry) => entry.id === id) ?? null,
searchNodes: (query) => nodes.filter((entry) => entry.name.toLowerCase() === query.toLowerCase()),
getCallers: (id) => id === leaf.id ? callers : [],
getCallees: () => [],
getIncoming: () => [],
getOutgoing: () => [],
getIndexedFiles: () => [],
};
const groundings = Array.from({ length: 6 }, (_, i) => ({
scaffold_file: `.mex/context/architecture-${i}.md`, node_id: i < 3 ? leaf.id : callers[i].id,
}));
const db = {
prepare: (sql: string) => ({
run: vi.fn(), get: vi.fn(), iterate: vi.fn(),
all: () => sql.includes("FROM nodes") ? [] : groundings,
}),
exec: vi.fn(), pragma: vi.fn(), transaction: <T>(fn: () => T) => fn(), close: vi.fn(), open: true,
};
const output: string[] = [];
runImpact("leaf", "/repo", { open: () => ({ graph, db, close: vi.fn() }), write: (line) => output.push(line) }, { maxNodes: 100 });
const rows = output.map((line) => JSON.parse(line));
expect(rows.at(-1)).toMatchObject({ type: "summary", truncated: true });
expect(rows.filter((row) => row.type === "caller").length).toBeLessThan(callers.length);
expect(rows.filter((row) => row.type === "grounding")).toEqual(
groundings.map((g) => ({ type: "grounding", node: g.node_id, file: g.scaffold_file })),
);
expect(rows.findIndex((row) => row.type === "grounding")).toBeLessThan(rows.findIndex((row) => row.type === "summary"));
});

it("impact stays well-formed and truncated when even grounding cannot fit", () => {
// The smallest accepted budget holds the protocol framing and nothing else.
const probe = deps();
runImpact("leaf", "/repo", probe.deps, { maxOutputTokens: 1 });
const floor = Number(/use at least (\d+)/.exec(JSON.parse(probe.output[0]!).message)?.[1]);
expect(floor).toBeGreaterThan(1);

const fixture = deps();
runImpact("leaf", "/repo", fixture.deps, { maxOutputTokens: floor });
const rows = fixture.output.map((line) => JSON.parse(line));
expect(rows[0]).toMatchObject({ type: "meta" });
expect(rows.at(-1)).toMatchObject({ type: "summary", truncated: true });
expect(rows.at(-1).estimatedOutputTokens).toBeLessThanOrEqual(floor);
expect(rows.filter((row) => row.type === "grounding")).toEqual([]);
});

it("impact accepts a file and reports each node it defines", () => {
const fixture = deps();
runImpact("src/a.ts", "/repo", fixture.deps);
Expand Down