@kage-core/kage-graph-mcp 1.1.38 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/kernel.js CHANGED
@@ -83,6 +83,9 @@ exports.buildIndexes = buildIndexes;
83
83
  exports.indexProject = indexProject;
84
84
  exports.refreshProject = refreshProject;
85
85
  exports.gcProject = gcProject;
86
+ exports.kageSuppressedMemory = kageSuppressedMemory;
87
+ exports.verifyCitations = verifyCitations;
88
+ exports.compactProject = compactProject;
86
89
  exports.installAgentPolicy = installAgentPolicy;
87
90
  exports.createDenseEmbeddingProvider = createDenseEmbeddingProvider;
88
91
  exports.buildEmbeddingIndex = buildEmbeddingIndex;
@@ -112,6 +115,8 @@ exports.kageMetrics = kageMetrics;
112
115
  exports.auditProject = auditProject;
113
116
  exports.memoryInbox = memoryInbox;
114
117
  exports.qualityReport = qualityReport;
118
+ exports.benchmarkTrust = benchmarkTrust;
119
+ exports.runDemo = runDemo;
115
120
  exports.benchmarkProject = benchmarkProject;
116
121
  exports.benchmarkCodingMemoryQuality = benchmarkCodingMemoryQuality;
117
122
  exports.benchmarkMemoryScale = benchmarkMemoryScale;
@@ -180,6 +185,27 @@ exports.MEMORY_TYPES = [
180
185
  "negative_result",
181
186
  "constraint",
182
187
  ];
188
+ // Bounded context assembly (PRD Feature 2: "inject only the relevant rule + structural
189
+ // map, dropping the rest"). Opt-in: when a token budget is set, keep the highest-priority
190
+ // sections (preamble + code graph + memory come first in the block) and drop trailing
191
+ // lower-priority sections until the estimate fits. Default (no budget) is unchanged.
192
+ function boundContextBlock(block, budget) {
193
+ if (!Number.isFinite(budget) || budget <= 0 || estimateTokens(block) <= budget)
194
+ return block;
195
+ const parts = block.split(/\n(?=## )/);
196
+ const kept = [parts[0]];
197
+ let dropped = 0;
198
+ for (const section of parts.slice(1)) {
199
+ if (estimateTokens([...kept, section].join("\n")) <= budget)
200
+ kept.push(section);
201
+ else
202
+ dropped += 1;
203
+ }
204
+ const result = kept.join("\n");
205
+ return dropped
206
+ ? `${result}\n\n_Context trimmed to ~${budget} tokens; ${dropped} lower-priority section(s) dropped._`
207
+ : result;
208
+ }
183
209
  const graphMemoryCache = new Map();
184
210
  exports.SETUP_AGENTS = [
185
211
  "codex",
@@ -1119,6 +1145,52 @@ function staleMemoryReasons(projectDir, packet, fingerprintCache) {
1119
1145
  }
1120
1146
  return unique(reasons);
1121
1147
  }
1148
+ // Classifies stale reasons into severity. "hard" reasons (deprecated status, user
1149
+ // reported stale, ttl expired, all citations deleted) mean the memory should be
1150
+ // excluded from recall; "soft" reasons (some citations missing, a linked file
1151
+ // changed) mean keep-but-flag — the memory may just need review, not suppression.
1152
+ function staleSeverity(reasons) {
1153
+ if (!reasons.length)
1154
+ return "none";
1155
+ const hard = reasons.some((reason) => reason.startsWith("packet status is") ||
1156
+ reason.startsWith("user or agent reported") ||
1157
+ reason.startsWith("freshness ttl expired") ||
1158
+ reason.startsWith("all referenced paths are missing"));
1159
+ return hard ? "hard" : "soft";
1160
+ }
1161
+ // Decides whether a packet should be excluded from the recall payload (PRD Feature 3:
1162
+ // "deleted or heavily refactored since the timestamp"). Distinct from staleMemoryReasons:
1163
+ // a citation that NEVER existed (no stored fingerprint) is an ungrounded write — guarded
1164
+ // at capture time — not recall-time staleness, so it does NOT trigger exclusion here.
1165
+ // Returns a reason string when the memory is hard-stale, otherwise null.
1166
+ function recallHardStaleReason(projectDir, packet, cache) {
1167
+ if (packet.status === "deprecated" || packet.status === "superseded")
1168
+ return `packet status is ${packet.status}`;
1169
+ const quality = (packet.quality ?? {});
1170
+ if (Number(quality.reports_stale ?? 0) > 0)
1171
+ return "user or agent reported this memory stale";
1172
+ const freshness = (packet.freshness ?? {});
1173
+ const ttlDays = Number(freshness.ttl_days ?? freshness.ttlDays ?? 0);
1174
+ const verifiedAt = Date.parse(String(freshness.last_verified_at ?? packet.updated_at ?? packet.created_at));
1175
+ if (Number.isFinite(ttlDays) && ttlDays > 0 && Number.isFinite(verifiedAt)) {
1176
+ const ageDays = (Date.now() - verifiedAt) / (1000 * 60 * 60 * 24);
1177
+ if (ageDays > ttlDays)
1178
+ return `freshness ttl expired (${Math.floor(ageDays)}d old, ttl ${ttlDays}d)`;
1179
+ }
1180
+ // Only paths that existed at capture get a stored fingerprint; if every one of them is
1181
+ // now gone, the memory's evidence was deleted out from under it.
1182
+ const stored = packetStoredPathFingerprints(packet);
1183
+ if (stored.length) {
1184
+ const deleted = stored.filter((fingerprint) => {
1185
+ const current = memoryPathFingerprint(projectDir, fingerprint.path, cache);
1186
+ return current === null;
1187
+ });
1188
+ if (deleted.length === stored.length) {
1189
+ return `all cited files deleted since capture: ${deleted.slice(0, 4).map((fingerprint) => fingerprint.path).join(", ")}`;
1190
+ }
1191
+ }
1192
+ return null;
1193
+ }
1122
1194
  function changedPathsFromStaleReasons(reasons) {
1123
1195
  return unique(reasons.flatMap((reason) => {
1124
1196
  const match = reason.match(/^linked path changed since memory was verified: (.+)$/);
@@ -1523,13 +1595,28 @@ function walkFiles(root, predicate) {
1523
1595
  }
1524
1596
  return out.sort();
1525
1597
  }
1598
+ // Tolerant packet read: a single corrupt or merge-conflicted packet (e.g. an
1599
+ // unresolved `<<<<<<<` from a teammate's git merge) must not take down all of
1600
+ // recall/verify/compact. Skip the bad file with a warning and keep going.
1601
+ function tryReadPacket(path) {
1602
+ try {
1603
+ return readJson(path);
1604
+ }
1605
+ catch (error) {
1606
+ process.stderr.write(`kage: skipping unreadable memory packet ${path}: ${error.message}\n`);
1607
+ return null;
1608
+ }
1609
+ }
1526
1610
  function loadPacketsFromDir(dir) {
1527
1611
  if (!(0, node_fs_1.existsSync)(dir))
1528
1612
  return [];
1529
1613
  return (0, node_fs_1.readdirSync)(dir)
1530
1614
  .filter((name) => name.endsWith(".json"))
1531
1615
  .sort()
1532
- .map((name) => readJson((0, node_path_1.join)(dir, name)));
1616
+ .flatMap((name) => {
1617
+ const packet = tryReadPacket((0, node_path_1.join)(dir, name));
1618
+ return packet ? [packet] : [];
1619
+ });
1533
1620
  }
1534
1621
  function loadPacketEntriesFromDir(dir) {
1535
1622
  if (!(0, node_fs_1.existsSync)(dir))
@@ -1537,9 +1624,10 @@ function loadPacketEntriesFromDir(dir) {
1537
1624
  return (0, node_fs_1.readdirSync)(dir)
1538
1625
  .filter((name) => name.endsWith(".json"))
1539
1626
  .sort()
1540
- .map((name) => {
1627
+ .flatMap((name) => {
1541
1628
  const path = (0, node_path_1.join)(dir, name);
1542
- return { path, packet: readJson(path) };
1629
+ const packet = tryReadPacket(path);
1630
+ return packet ? [{ path, packet }] : [];
1543
1631
  });
1544
1632
  }
1545
1633
  function loadApprovedPackets(projectDir) {
@@ -5510,6 +5598,127 @@ function gcProject(projectDir, options = {}) {
5510
5598
  total_scanned: packetEntries.length,
5511
5599
  };
5512
5600
  }
5601
+ // The memory recall is actively WITHHOLDING from agents right now (hard-stale:
5602
+ // cited files deleted, ttl expired, or reported stale). This is the human-facing
5603
+ // counterpart to the silent recall-time exclusion — surfaced, never hidden.
5604
+ function kageSuppressedMemory(projectDir) {
5605
+ ensureMemoryDirs(projectDir);
5606
+ const cache = new Map();
5607
+ const items = loadApprovedPackets(projectDir)
5608
+ .map((packet) => {
5609
+ const reason = recallHardStaleReason(projectDir, packet, cache);
5610
+ return reason ? { id: packet.id, title: packet.title, type: packet.type, reason, paths: packet.paths } : null;
5611
+ })
5612
+ .filter((entry) => entry !== null);
5613
+ return { schema_version: 1, generated_at: nowIso(), count: items.length, items };
5614
+ }
5615
+ function verifyCitations(projectDir, options = {}) {
5616
+ ensureMemoryDirs(projectDir);
5617
+ const approved = loadApprovedPackets(projectDir);
5618
+ const targets = options.id ? approved.filter((packet) => packet.id === options.id) : approved;
5619
+ if (options.id && !targets.length) {
5620
+ return { ok: false, project_dir: projectDir, checked: 0, valid: 0, stale: 0, ungrounded: 0, packets: [], errors: [`Approved packet not found: ${options.id}`] };
5621
+ }
5622
+ const cache = new Map();
5623
+ const packets = targets.map((packet) => {
5624
+ const meaningful = packet.paths.filter((path) => meaningfulMemoryPath(path) && !shouldSkipRepoMemoryPath(path));
5625
+ const missing = meaningful.filter((path) => !pathExistsInRepo(projectDir, path));
5626
+ const reasons = staleMemoryReasons(projectDir, packet, cache);
5627
+ const severity = staleSeverity(reasons);
5628
+ const grounded = packetGroundingWarnings(projectDir, packet, "packet").length === 0;
5629
+ return {
5630
+ id: packet.id,
5631
+ title: packet.title,
5632
+ status: packet.status,
5633
+ paths: packet.paths,
5634
+ missing_paths: missing,
5635
+ grounded,
5636
+ stale: severity !== "none",
5637
+ stale_severity: severity,
5638
+ stale_reasons: reasons,
5639
+ };
5640
+ });
5641
+ return {
5642
+ ok: true,
5643
+ project_dir: projectDir,
5644
+ checked: packets.length,
5645
+ valid: packets.filter((entry) => !entry.stale && entry.grounded).length,
5646
+ stale: packets.filter((entry) => entry.stale).length,
5647
+ ungrounded: packets.filter((entry) => !entry.grounded).length,
5648
+ packets,
5649
+ errors: [],
5650
+ };
5651
+ }
5652
+ // Deterministic memory consolidation (no hosted LLM — preserves the no-API-key promise):
5653
+ // 1. prune dead citations from packets and refresh their path fingerprints,
5654
+ // 2. deprecate hard-stale packets (delegating to the same severity rules as recall/gc),
5655
+ // 3. surface near-duplicate clusters for an agent to merge via kage_supersede.
5656
+ function compactProject(projectDir, options = {}) {
5657
+ ensureMemoryDirs(projectDir);
5658
+ const dryRun = options.dryRun === true;
5659
+ const entries = loadPacketEntriesFromDir(packetsDir(projectDir));
5660
+ const prunedCitations = [];
5661
+ const deprecated = [];
5662
+ const cache = new Map();
5663
+ for (const { path, packet } of entries) {
5664
+ if (packet.status === "deprecated" || packet.status === "superseded")
5665
+ continue;
5666
+ const hardReason = recallHardStaleReason(projectDir, packet, cache);
5667
+ if (hardReason) {
5668
+ deprecated.push({ id: packet.id, title: packet.title, reason: hardReason });
5669
+ if (!dryRun)
5670
+ writeJson(path, { ...packet, status: "deprecated", updated_at: nowIso() });
5671
+ continue;
5672
+ }
5673
+ const meaningful = packet.paths.filter((p) => meaningfulMemoryPath(p) && !shouldSkipRepoMemoryPath(p));
5674
+ const missing = meaningful.filter((p) => !pathExistsInRepo(projectDir, p));
5675
+ if (missing.length) {
5676
+ const keptPaths = packet.paths.filter((p) => !missing.includes(p));
5677
+ prunedCitations.push({ id: packet.id, title: packet.title, removed_paths: missing });
5678
+ if (!dryRun) {
5679
+ writeJson(path, {
5680
+ ...packet,
5681
+ paths: keptPaths,
5682
+ freshness: {
5683
+ ...(packet.freshness ?? {}),
5684
+ path_fingerprints: memoryPathFingerprints(projectDir, keptPaths),
5685
+ last_verified_at: nowIso(),
5686
+ },
5687
+ updated_at: nowIso(),
5688
+ });
5689
+ }
5690
+ }
5691
+ }
5692
+ // Cluster near-duplicate approved packets (report only — merging is an agent decision).
5693
+ const context = memoryQualityContext(projectDir);
5694
+ const approved = context.packets.filter((packet) => packet.status === "approved");
5695
+ const seen = new Set();
5696
+ const clusters = [];
5697
+ for (const packet of approved) {
5698
+ if (seen.has(packet.id))
5699
+ continue;
5700
+ const dupes = duplicateCandidatesWithContext(packet, context, 0.6).filter((dupe) => dupe.status === "approved");
5701
+ if (!dupes.length)
5702
+ continue;
5703
+ const members = [{ id: packet.id, title: packet.title }, ...dupes.map((dupe) => ({ id: dupe.id, title: dupe.title }))];
5704
+ members.forEach((member) => seen.add(member.id));
5705
+ clusters.push({ score: Math.max(...dupes.map((dupe) => dupe.score)), packets: members });
5706
+ }
5707
+ if (!dryRun && (prunedCitations.length || deprecated.length)) {
5708
+ const rebuilt = buildGraphIndexes(projectDir);
5709
+ writeJson((0, node_path_1.join)(memoryRoot(projectDir), "metrics.json"), kageMetricsShallow(projectDir, rebuilt));
5710
+ }
5711
+ return {
5712
+ ok: true,
5713
+ project_dir: projectDir,
5714
+ dry_run: dryRun,
5715
+ pruned_citations: prunedCitations,
5716
+ deprecated,
5717
+ duplicate_clusters: clusters,
5718
+ total_scanned: entries.length,
5719
+ errors: [],
5720
+ };
5721
+ }
5513
5722
  function installAgentPolicy(projectDir) {
5514
5723
  const agentsPath = (0, node_path_1.join)(projectDir, "AGENTS.md");
5515
5724
  const claudePath = (0, node_path_1.join)(projectDir, "CLAUDE.md");
@@ -6277,7 +6486,23 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
6277
6486
  })()
6278
6487
  : recallQueryExpansion(query);
6279
6488
  const terms = expansion.terms;
6280
- const approvedPackets = loadApprovedPackets(projectDir);
6489
+ const allApprovedPackets = loadApprovedPackets(projectDir);
6490
+ const includeStale = inputs.includeStale === true;
6491
+ const staleFingerprintCache = new Map();
6492
+ const suppressed = [];
6493
+ // Just-in-time staleness gate: hard-stale memory (deleted citations, expired ttl,
6494
+ // reported stale) is excluded from the recall payload so the agent never sees it
6495
+ // as valid. Suppression is recorded (not silent) so `kage verify` can explain it.
6496
+ const approvedPackets = includeStale
6497
+ ? allApprovedPackets
6498
+ : allApprovedPackets.filter((packet) => {
6499
+ const reason = recallHardStaleReason(projectDir, packet, staleFingerprintCache);
6500
+ if (reason) {
6501
+ suppressed.push({ id: packet.id, title: packet.title, reason });
6502
+ return false;
6503
+ }
6504
+ return true;
6505
+ });
6281
6506
  const baseScores = scorePacketsBm25(expansion.baseTerms, approvedPackets);
6282
6507
  const temporalScores = scorePacketsBm25(expansion.temporalTerms, approvedPackets);
6283
6508
  const semanticScores = scorePacketsBm25(expansion.semanticTerms, approvedPackets);
@@ -6335,6 +6560,13 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
6335
6560
  const graphContext = queryGraph(projectDir, query, 5, knowledgeGraph);
6336
6561
  const codeContext = queryCodeGraph(projectDir, query, 5, codeGraph);
6337
6562
  const pinnedContext = renderPinnedRepoContext(readContextSlots(projectDir));
6563
+ // PRD Feature 2: traverse the code graph outward from the recalled memory's files
6564
+ // (the semantic entry point) to assemble a bounded structural blast radius. Opt-in
6565
+ // via inputs.structuralHops so default recall output is unchanged.
6566
+ const structuralHops = inputs.structuralHops ?? 0;
6567
+ const blastRadius = structuralHops > 0
6568
+ ? structuralBlastRadius(codeGraph, unique(scored.flatMap((entry) => entry.packet.paths).filter((path) => meaningfulMemoryPath(path))), structuralHops)
6569
+ : [];
6338
6570
  const lines = [
6339
6571
  `# Kage Context`,
6340
6572
  "",
@@ -6347,6 +6579,9 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
6347
6579
  ...codeContext.tests.slice(0, 3).map((test, index) => `${index + 1}. [test] ${test.title} in ${test.test_path}:${test.line}${test.covers_symbol ? ` covers ${test.covers_symbol}` : ""}`),
6348
6580
  ...(!codeContext.symbols.length && !codeContext.routes.length && !codeContext.tests.length ? codeContext.files.slice(0, 3).map((file, index) => `${index + 1}. [file] ${file.path} (${file.kind})`) : []),
6349
6581
  "",
6582
+ ...(blastRadius.length
6583
+ ? [`## Structural Blast Radius (${structuralHops}-hop)`, ...blastRadius.map((path, index) => `${index + 1}. ${path}`), ""]
6584
+ : []),
6350
6585
  scored.length ? "## Relevant Memory" : "No relevant repo memory found.",
6351
6586
  ...scored.flatMap((entry, index) => [
6352
6587
  "",
@@ -6367,11 +6602,16 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
6367
6602
  "",
6368
6603
  graphContext.edges.length ? "## Related Graph Facts" : "",
6369
6604
  ...graphContext.edges.slice(0, 5).map((edge, index) => `${index + 1}. ${edge.fact} (evidence: ${edge.evidence.join(", ")})`),
6605
+ ...(suppressed.length
6606
+ ? ["", `_${suppressed.length} stale memory packet(s) excluded from recall. Run kage verify for details._`]
6607
+ : []),
6370
6608
  ];
6609
+ const assembledBlock = lines.join("\n");
6371
6610
  const result = {
6372
6611
  query,
6373
- context_block: lines.join("\n"),
6612
+ context_block: inputs.maxContextTokens ? boundContextBlock(assembledBlock, inputs.maxContextTokens) : assembledBlock,
6374
6613
  results: scored,
6614
+ suppressed: suppressed.length ? suppressed : undefined,
6375
6615
  explanations: explain
6376
6616
  ? scored.map((entry) => ({
6377
6617
  packet_id: entry.packet.id,
@@ -6830,6 +7070,45 @@ function codeDependents(graph) {
6830
7070
  }
6831
7071
  return dependents;
6832
7072
  }
7073
+ // Bounded N-hop structural traversal from a set of seed files (the files the recalled
7074
+ // memory is about) — the PRD's "structural blast radius". Walks both import directions
7075
+ // (who-depends-on and what-it-depends-on), excludes the seeds, and ranks by how many
7076
+ // files depend on each node. Pure graph traversal; no full-repo scan.
7077
+ function structuralBlastRadius(graph, seedPaths, hops, limit = 8) {
7078
+ const seeds = seedPaths.filter(Boolean);
7079
+ if (!seeds.length || hops <= 0)
7080
+ return [];
7081
+ const dependents = codeDependents(graph);
7082
+ const dependencies = new Map();
7083
+ for (const edge of graph.imports) {
7084
+ if (!edge.from_path || !edge.to_path)
7085
+ continue;
7086
+ const list = dependencies.get(edge.from_path) ?? new Set();
7087
+ list.add(edge.to_path);
7088
+ dependencies.set(edge.from_path, list);
7089
+ }
7090
+ const seedSet = new Set(seeds);
7091
+ const visited = new Set();
7092
+ let frontier = new Set(seeds);
7093
+ for (let depth = 0; depth < hops; depth += 1) {
7094
+ const next = new Set();
7095
+ for (const node of frontier) {
7096
+ for (const neighbor of [...(dependents.get(node) ?? []), ...(dependencies.get(node) ?? [])]) {
7097
+ if (seedSet.has(neighbor) || visited.has(neighbor))
7098
+ continue;
7099
+ visited.add(neighbor);
7100
+ next.add(neighbor);
7101
+ }
7102
+ }
7103
+ frontier = next;
7104
+ }
7105
+ const score = new Map();
7106
+ for (const [path, incoming] of dependents.entries())
7107
+ score.set(path, incoming.size);
7108
+ return [...visited]
7109
+ .sort((a, b) => (score.get(b) ?? 0) - (score.get(a) ?? 0) || a.localeCompare(b))
7110
+ .slice(0, limit);
7111
+ }
6833
7112
  function impactSurface(target, dependents, graph) {
6834
7113
  const visited = new Set();
6835
7114
  let frontier = new Set([target]);
@@ -9574,6 +9853,152 @@ function qualityReport(projectDir) {
9574
9853
  packets: rows,
9575
9854
  };
9576
9855
  }
9856
+ // The Trust Benchmark measures what retrieval benchmarks cannot: whether the memory
9857
+ // system can be TRUSTED — does it refuse to store hallucinated citations, does it
9858
+ // withhold memory whose evidence was deleted, and is live repo memory actually grounded.
9859
+ // Controlled gates run in an isolated sandbox; the grounding gate runs on the real repo.
9860
+ function benchmarkTrust(projectDir) {
9861
+ const runDir = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "kage-trust-"));
9862
+ const sandbox = (0, node_path_1.join)(runDir, "project");
9863
+ try {
9864
+ ensureMemoryDirs(sandbox);
9865
+ (0, node_fs_1.mkdirSync)((0, node_path_1.join)(sandbox, "src"), { recursive: true });
9866
+ // Gate 1 — Hallucinated-citation rejection: a strict capture whose every cited path
9867
+ // is missing must be rejected. (No competitor validates citations at write time.)
9868
+ const hallucinationAttempts = 8;
9869
+ let rejected = 0;
9870
+ for (let i = 0; i < hallucinationAttempts; i += 1) {
9871
+ const result = capture({
9872
+ projectDir: sandbox,
9873
+ title: `Hallucinated rule ${i}`,
9874
+ body: `Use the helper in src/ghost-${i}.ts for retry handling.`,
9875
+ type: "decision",
9876
+ paths: [`src/ghost-${i}.ts`],
9877
+ strictCitations: true,
9878
+ });
9879
+ if (!result.ok)
9880
+ rejected += 1;
9881
+ }
9882
+ // Gate 2 — Stale-memory exclusion: memory grounded in real files at capture time
9883
+ // must be withheld from recall once those files are deleted (the "deleted since
9884
+ // capture" signal). We only count memories that were recallable BEFORE deletion.
9885
+ const staleAttempts = 8;
9886
+ const recallableBefore = [];
9887
+ for (let i = 0; i < staleAttempts; i += 1) {
9888
+ (0, node_fs_1.writeFileSync)((0, node_path_1.join)(sandbox, "src", `widget-${i}.ts`), `export const widget${i} = ${i};\n`, "utf8");
9889
+ capture({
9890
+ projectDir: sandbox,
9891
+ title: `Widget ${i} retry invariant`,
9892
+ body: `Widget ${i} retries use idempotency token zeta${i} in src/widget-${i}.ts to avoid duplicate charges.`,
9893
+ type: "decision",
9894
+ paths: [`src/widget-${i}.ts`],
9895
+ });
9896
+ }
9897
+ for (let i = 0; i < staleAttempts; i += 1) {
9898
+ const before = recall(sandbox, `widget ${i} retry idempotency token zeta${i}`, 5, false, { trackAccess: false });
9899
+ recallableBefore[i] = before.results.some((entry) => entry.packet.title === `Widget ${i} retry invariant`);
9900
+ }
9901
+ for (let i = 0; i < staleAttempts; i += 1) {
9902
+ (0, node_fs_1.rmSync)((0, node_path_1.join)(sandbox, "src", `widget-${i}.ts`), { force: true });
9903
+ }
9904
+ let recallableCount = 0;
9905
+ let excludedAfter = 0;
9906
+ for (let i = 0; i < staleAttempts; i += 1) {
9907
+ if (!recallableBefore[i])
9908
+ continue;
9909
+ recallableCount += 1;
9910
+ const after = recall(sandbox, `widget ${i} retry idempotency token zeta${i}`, 5, false, { trackAccess: false });
9911
+ const surfaced = after.results.some((entry) => entry.packet.title === `Widget ${i} retry invariant`);
9912
+ if (!surfaced)
9913
+ excludedAfter += 1;
9914
+ }
9915
+ // Gate 3 — Live grounding: how much of the real repo's approved memory is grounded
9916
+ // (cited files exist) and not stale.
9917
+ const verify = verifyCitations(projectDir);
9918
+ const liveChecked = verify.checked;
9919
+ const grounded = verify.packets.filter((entry) => entry.grounded && !entry.stale).length;
9920
+ const hallucinationRate = percent(rejected, hallucinationAttempts);
9921
+ const staleRate = percent(excludedAfter, recallableCount || staleAttempts);
9922
+ const liveGroundingRate = liveChecked > 0 ? percent(grounded, liveChecked) : 100;
9923
+ const wrongAdvicePrevented = percent(rejected + excludedAfter, hallucinationAttempts + (recallableCount || staleAttempts));
9924
+ const gates = [
9925
+ { name: "hallucinated_citation_rejection", target: 100, actual: hallucinationRate, unit: "percent", pass: hallucinationRate >= 100 },
9926
+ { name: "stale_memory_exclusion", target: 100, actual: staleRate, unit: "percent", pass: staleRate >= 100 },
9927
+ { name: "live_grounding_rate", target: 80, actual: liveGroundingRate, unit: "percent", pass: liveGroundingRate >= 80 },
9928
+ ];
9929
+ const trustScore = Math.round((hallucinationRate + staleRate + liveGroundingRate) / 3);
9930
+ return {
9931
+ schema_version: 1,
9932
+ generated_at: nowIso(),
9933
+ ok: gates.every((gate) => gate.pass),
9934
+ trust_score: trustScore,
9935
+ gates,
9936
+ metrics: {
9937
+ hallucinated_citation_rejection_rate: hallucinationRate,
9938
+ stale_memory_exclusion_rate: staleRate,
9939
+ live_grounding_rate: liveGroundingRate,
9940
+ wrong_advice_prevented_rate: wrongAdvicePrevented,
9941
+ },
9942
+ detail: {
9943
+ hallucination: { attempted: hallucinationAttempts, rejected },
9944
+ staleness: { recallable_before: recallableCount, excluded_after: excludedAfter },
9945
+ live_memory: { checked: liveChecked, grounded, stale: verify.stale },
9946
+ },
9947
+ };
9948
+ }
9949
+ finally {
9950
+ (0, node_fs_1.rmSync)(runDir, { recursive: true, force: true });
9951
+ }
9952
+ }
9953
+ // `kage demo`: a self-contained 60-second proof of the trust wedge. Seeds a tiny
9954
+ // repo with grounded memory, then shows Kage (1) reject a hallucinated citation,
9955
+ // (2) withhold a memory whose cited file was deleted, and (3) recall only grounded
9956
+ // memory — the three things that make agent memory trustworthy.
9957
+ function runDemo(demoDir) {
9958
+ (0, node_fs_1.rmSync)(demoDir, { recursive: true, force: true });
9959
+ (0, node_fs_1.mkdirSync)((0, node_path_1.join)(demoDir, "src"), { recursive: true });
9960
+ (0, node_fs_1.writeFileSync)((0, node_path_1.join)(demoDir, "src", "auth.ts"), "export function validateToken() { return true; }\n", "utf8");
9961
+ (0, node_fs_1.writeFileSync)((0, node_path_1.join)(demoDir, "src", "payments.ts"), "export function charge() { return 'ok'; }\n", "utf8");
9962
+ (0, node_fs_1.writeFileSync)((0, node_path_1.join)(demoDir, "src", "legacy-retry.ts"), "export function retry() { return 1; }\n", "utf8");
9963
+ ensureMemoryDirs(demoDir);
9964
+ const captured = [];
9965
+ for (const m of [
9966
+ { title: "Auth uses jose, not jsonwebtoken", body: "Validate tokens with jose in src/auth.ts; jsonwebtoken was removed.", paths: ["src/auth.ts"] },
9967
+ { title: "Payments must be idempotent", body: "charge() in src/payments.ts must be idempotent to avoid double charges.", paths: ["src/payments.ts"] },
9968
+ { title: "Legacy retry helper is the fallback", body: "Old retry logic lives in src/legacy-retry.ts and is used as a fallback.", paths: ["src/legacy-retry.ts"] },
9969
+ ]) {
9970
+ const r = capture({ projectDir: demoDir, title: m.title, body: m.body, type: "decision", paths: m.paths });
9971
+ if (r.ok && r.packet)
9972
+ captured.push(r.packet.title);
9973
+ }
9974
+ // (2) delete a cited file → that memory becomes stale and is withheld from recall.
9975
+ (0, node_fs_1.unlinkSync)((0, node_path_1.join)(demoDir, "src", "legacy-retry.ts"));
9976
+ // (1) a hallucinated citation is rejected at write time.
9977
+ const hallucinated = capture({
9978
+ projectDir: demoDir,
9979
+ title: "Use the helper in src/ghost.ts",
9980
+ body: "Retry handling lives in src/ghost.ts.",
9981
+ type: "decision",
9982
+ paths: ["src/ghost.ts"],
9983
+ strictCitations: true,
9984
+ });
9985
+ const rejected = hallucinated.ok ? null : { title: "Use the helper in src/ghost.ts", error: hallucinated.errors[0] ?? "rejected" };
9986
+ // (3) recall surfaces grounded memory; the stale one is withheld.
9987
+ const recall = recallWithVectorScores(demoDir, "auth token payments retry idempotency", 5, false, { trackAccess: false });
9988
+ const recalled = recall.results.map((entry) => entry.packet.title);
9989
+ const withheld = kageSuppressedMemory(demoDir).items.map((item) => ({ title: item.title, reason: item.reason }));
9990
+ const trust = benchmarkTrust(demoDir);
9991
+ return {
9992
+ ok: true,
9993
+ project_dir: demoDir,
9994
+ captured,
9995
+ rejected_hallucination: rejected,
9996
+ recalled,
9997
+ withheld,
9998
+ trust_score: trust.trust_score,
9999
+ viewer_command: `kage viewer --project ${demoDir}`,
10000
+ };
10001
+ }
9577
10002
  function benchmarkProject(projectDir, inputs = {}) {
9578
10003
  ensureMemoryDirs(projectDir);
9579
10004
  const built = inputs.codeGraph && inputs.knowledgeGraph ? null : currentOrBuildGraphs(projectDir);
@@ -10453,6 +10878,9 @@ function learn(input) {
10453
10878
  paths: input.paths,
10454
10879
  stack: input.stack,
10455
10880
  context: input.context,
10881
+ allowMissingPaths: input.allowMissingPaths,
10882
+ strictCitations: input.strictCitations,
10883
+ graphNodes: input.graphNodes,
10456
10884
  });
10457
10885
  }
10458
10886
  function capture(input) {
@@ -10468,7 +10896,34 @@ function capture(input) {
10468
10896
  errors: [`Sensitive content blocked: ${unique(scanFindings).join(", ")}`],
10469
10897
  };
10470
10898
  }
10899
+ const warnings = [];
10900
+ const meaningfulPaths = (input.paths ?? [])
10901
+ .filter((path) => path && meaningfulMemoryPath(path) && !shouldSkipRepoMemoryPath(path));
10902
+ const missingPaths = meaningfulPaths.filter((path) => !pathExistsInRepo(input.projectDir, path));
10903
+ // Citation validation. Strict mode (agent-facing record_memory tools / CLI) rejects a
10904
+ // write whose every cited path is missing — the PRD's "reject if citations don't exist".
10905
+ // The core library stays permissive (warn-only) for programmatic callers and migrations.
10906
+ if (input.strictCitations && meaningfulPaths.length && missingPaths.length === meaningfulPaths.length && !input.allowMissingPaths) {
10907
+ return {
10908
+ ok: false,
10909
+ errors: [
10910
+ `Citation validation failed: none of the referenced paths exist in this repo: ${missingPaths.join(", ")}. ` +
10911
+ `Fix the paths, or pass allow_missing_paths to record anyway (e.g. for a file you are about to create).`,
10912
+ ],
10913
+ warnings: [],
10914
+ };
10915
+ }
10916
+ if (missingPaths.length) {
10917
+ warnings.push(`Some referenced paths do not exist in this repo: ${missingPaths.join(", ")}`);
10918
+ }
10471
10919
  const createdAt = nowIso();
10920
+ // Agent-asserted links to code-graph nodes (PRD `graph_nodes`): the agent recording the
10921
+ // memory knows which symbol/route/file the rule is about, so let it declare them instead
10922
+ // of relying solely on background derivation.
10923
+ const graphEdges = unique(input.graphNodes ?? [])
10924
+ .map((node) => node.trim())
10925
+ .filter(Boolean)
10926
+ .map((node) => ({ relation: "references_code", to: node, evidence: "agent_capture", created_at: createdAt }));
10472
10927
  const packet = {
10473
10928
  schema_version: exports.PACKET_SCHEMA_VERSION,
10474
10929
  id: makePacketId(input.projectDir, type, input.title, String(Date.now())),
@@ -10510,10 +10965,12 @@ function capture(input) {
10510
10965
  },
10511
10966
  created_at: createdAt,
10512
10967
  updated_at: createdAt,
10968
+ author_branch: gitBranch(input.projectDir),
10513
10969
  };
10970
+ packet.edges = graphEdges;
10514
10971
  const validation = validatePacket(packet);
10515
10972
  if (!validation.ok)
10516
- return { ok: false, errors: validation.errors };
10973
+ return { ok: false, errors: validation.errors, warnings };
10517
10974
  packet.quality = {
10518
10975
  ...packet.quality,
10519
10976
  ...evaluateMemoryQuality(input.projectDir, packet),
@@ -10525,7 +10982,7 @@ function capture(input) {
10525
10982
  path: (0, node_path_1.relative)(input.projectDir, path),
10526
10983
  source_kind: packet.source_refs[0]?.kind ?? "explicit_capture",
10527
10984
  });
10528
- return { ok: true, packet, path, errors: [] };
10985
+ return { ok: true, packet, path, errors: [], warnings };
10529
10986
  }
10530
10987
  function createPublicCandidate(projectDir, id) {
10531
10988
  ensureMemoryDirs(projectDir);
@@ -10854,6 +11311,10 @@ elif event_name == "PreCompact":
10854
11311
  payload = {"type": "session_end", "summary": "Claude Code is compacting context; distill durable observations before compaction."}
10855
11312
  elif event_name == "SessionEnd":
10856
11313
  payload = {"type": "session_end", "summary": "Claude Code session ended; distill durable observations for teammate handoff."}
11314
+ elif event_name == "SubagentStop":
11315
+ payload = {"type": "session_end", "summary": "Subagent finished; distill durable observations from the subagent run."}
11316
+ elif event_name == "PreToolUse":
11317
+ payload = {"type": "tool_use", "tool": tool, "path": path, "command": command, "summary": "About to run: " + compact(command or tool or d, 200)}
10857
11318
  else:
10858
11319
  payload = {"type": "tool_use", "tool": tool, "path": path, "command": command, "summary": compact(d, 320), "text": compact(d)}
10859
11320
 
@@ -10865,7 +11326,7 @@ if [[ -n "$OBSERVATION" ]]; then
10865
11326
  kage observe --project "$CWD" --event "$OBSERVATION" --json >/dev/null 2>&1 || true
10866
11327
  fi
10867
11328
 
10868
- if [[ "$EVENT" == "PreCompact" || "$EVENT" == "SessionEnd" ]]; then
11329
+ if [[ "$EVENT" == "PreCompact" || "$EVENT" == "SessionEnd" || "$EVENT" == "SubagentStop" ]]; then
10869
11330
  kage distill --project "$CWD" --session "$SESSION" --json >/dev/null 2>&1 || true
10870
11331
  fi
10871
11332
 
@@ -10901,11 +11362,13 @@ exit 0
10901
11362
  hooks: {
10902
11363
  SessionStart: [{ matcher: "", hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/session-start.sh", timeout: 5 }] }],
10903
11364
  UserPromptSubmit: [{ hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/observe.sh", timeout: 12 }] }],
11365
+ PreToolUse: [{ matcher: "", hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/observe.sh", timeout: 5 }] }],
10904
11366
  PostToolUse: [{ matcher: "", hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/observe.sh", timeout: 5 }] }],
10905
11367
  PostToolUseFailure: [{ matcher: "", hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/observe.sh", timeout: 5 }] }],
10906
11368
  PreCompact: [{ hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/observe.sh", timeout: 20 }] }],
10907
11369
  Stop: [{ matcher: "", hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/stop.sh", timeout: 20 }] }],
10908
11370
  SessionEnd: [{ hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/observe.sh", timeout: 20 }] }],
11371
+ SubagentStop: [{ matcher: "", hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/observe.sh", timeout: 20 }] }],
10909
11372
  },
10910
11373
  };
10911
11374
  setSnippet(path, JSON.stringify({ mcpServers: { kage: server } }, null, 2), [
@@ -12110,12 +12573,65 @@ function regexpEscape(value) {
12110
12573
  function shellQuote(value) {
12111
12574
  return `'${value.replace(/'/g, "'\"'\"'")}'`;
12112
12575
  }
12113
- function gitHookPath(projectDir) {
12114
- const raw = readGit(projectDir, ["rev-parse", "--git-path", "hooks/post-commit"]);
12576
+ function gitHookPath(projectDir, hookName = "post-commit") {
12577
+ const raw = readGit(projectDir, ["rev-parse", "--git-path", `hooks/${hookName}`]);
12115
12578
  if (!raw)
12116
12579
  return null;
12117
12580
  return (0, node_path_1.resolve)(projectDir, raw);
12118
12581
  }
12582
+ // Hooks that fire after history changes underfoot (git pull / merge / checkout):
12583
+ // re-index repo memory so newly pulled teammate packets are immediately recallable.
12584
+ const KAGE_SYNC_HOOKS = ["post-merge", "post-checkout"];
12585
+ function kageSyncHookBlock(projectDir) {
12586
+ const project = shellQuote((0, node_path_1.resolve)(projectDir));
12587
+ return [
12588
+ KAGE_POST_COMMIT_HOOK_START,
12589
+ "# Kage sync hook: re-index repo memory after pull/merge/checkout.",
12590
+ "# Set KAGE_SKIP_HOOK=1 to bypass, or KAGE_BIN=/path/to/kage to override.",
12591
+ "if [ \"${KAGE_SKIP_HOOK:-0}\" != \"1\" ]; then",
12592
+ " KAGE_BIN=\"${KAGE_BIN:-kage}\"",
12593
+ " if command -v \"$KAGE_BIN\" >/dev/null 2>&1; then",
12594
+ " (",
12595
+ ` "$KAGE_BIN" index --project ${project} --json >/dev/null 2>&1 || true`,
12596
+ " ) &",
12597
+ " fi",
12598
+ "fi",
12599
+ KAGE_POST_COMMIT_HOOK_END,
12600
+ ].join("\n");
12601
+ }
12602
+ function installSyncHooks(projectDir) {
12603
+ const installed = [];
12604
+ for (const hookName of KAGE_SYNC_HOOKS) {
12605
+ const hookPath = gitHookPath(projectDir, hookName);
12606
+ if (!hookPath)
12607
+ continue;
12608
+ ensureDir((0, node_path_1.dirname)(hookPath));
12609
+ const existing = safeReadText(hookPath) ?? "";
12610
+ const base = stripKageHookBlock(existing);
12611
+ const prefix = base.trim() ? base.trimEnd() : "#!/bin/sh";
12612
+ const next = `${prefix}\n\n${kageSyncHookBlock(projectDir)}\n`;
12613
+ if (existing !== next)
12614
+ (0, node_fs_1.writeFileSync)(hookPath, next, "utf8");
12615
+ (0, node_fs_1.chmodSync)(hookPath, 0o755);
12616
+ installed.push(hookPath);
12617
+ }
12618
+ return installed;
12619
+ }
12620
+ function uninstallSyncHooks(projectDir) {
12621
+ const removed = [];
12622
+ for (const hookName of KAGE_SYNC_HOOKS) {
12623
+ const hookPath = gitHookPath(projectDir, hookName);
12624
+ if (!hookPath)
12625
+ continue;
12626
+ const existing = safeReadText(hookPath) ?? "";
12627
+ if (!hasKageHookBlock(existing))
12628
+ continue;
12629
+ (0, node_fs_1.writeFileSync)(hookPath, stripKageHookBlock(existing), "utf8");
12630
+ (0, node_fs_1.chmodSync)(hookPath, 0o755);
12631
+ removed.push(hookPath);
12632
+ }
12633
+ return removed;
12634
+ }
12119
12635
  function hasKageHookBlock(content) {
12120
12636
  return content.includes(KAGE_POST_COMMIT_HOOK_START) && content.includes(KAGE_POST_COMMIT_HOOK_END);
12121
12637
  }
@@ -12191,20 +12707,24 @@ function kageHookInstall(projectDir) {
12191
12707
  const base = stripKageHookBlock(existing);
12192
12708
  const prefix = base.trim() ? base.trimEnd() : "#!/bin/sh";
12193
12709
  const next = `${prefix}\n\n${kagePostCommitHookBlock(projectDir)}\n`;
12194
- const changed = existing !== next;
12195
- if (changed)
12710
+ const commitChanged = existing !== next;
12711
+ if (commitChanged)
12196
12712
  (0, node_fs_1.writeFileSync)(hookPath, next, "utf8");
12197
12713
  (0, node_fs_1.chmodSync)(hookPath, 0o755);
12714
+ const syncHooks = installSyncHooks(projectDir);
12198
12715
  return {
12199
12716
  ok: true,
12200
12717
  action: "install",
12201
12718
  project_dir: projectDir,
12202
12719
  hook_path: hookPath,
12203
12720
  installed: true,
12204
- changed,
12205
- message: changed ? "Installed Kage post-commit hook." : "Kage post-commit hook is already current.",
12721
+ changed: commitChanged,
12722
+ message: commitChanged
12723
+ ? "Installed Kage post-commit hook and pull/merge sync hooks."
12724
+ : "Kage post-commit and sync hooks are already current.",
12206
12725
  errors: [],
12207
12726
  warnings: [],
12727
+ additional_hooks: syncHooks,
12208
12728
  };
12209
12729
  }
12210
12730
  function kageHookUninstall(projectDir) {
@@ -12240,6 +12760,7 @@ function kageHookUninstall(projectDir) {
12240
12760
  const next = stripKageHookBlock(existing);
12241
12761
  (0, node_fs_1.writeFileSync)(hookPath, next, "utf8");
12242
12762
  (0, node_fs_1.chmodSync)(hookPath, 0o755);
12763
+ const removedSyncHooks = uninstallSyncHooks(projectDir);
12243
12764
  return {
12244
12765
  ok: true,
12245
12766
  action: "uninstall",
@@ -12247,9 +12768,10 @@ function kageHookUninstall(projectDir) {
12247
12768
  hook_path: hookPath,
12248
12769
  installed: false,
12249
12770
  changed: true,
12250
- message: "Removed Kage post-commit hook.",
12771
+ message: "Removed Kage post-commit and sync hooks.",
12251
12772
  errors: [],
12252
12773
  warnings: [],
12774
+ additional_hooks: removedSyncHooks,
12253
12775
  };
12254
12776
  }
12255
12777
  function exportPublicBundle(projectDir) {