@kage-core/kage-graph-mcp 1.1.38 → 1.2.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/cli.js CHANGED
@@ -29,6 +29,8 @@ Usage:
29
29
  kage hook uninstall --project <dir> [--json]
30
30
  kage refresh --project <dir> [--full] [--json]
31
31
  kage gc --project <dir> [--dry-run] [--force] [--json]
32
+ kage compact --project <dir> [--dry-run] [--json]
33
+ kage verify --project <dir> [--id <packet-id>] [--json]
32
34
  kage pr summarize --project <dir> [--json]
33
35
  kage pr check --project <dir> [--json]
34
36
  kage upgrade [--dry-run]
@@ -74,14 +76,14 @@ Usage:
74
76
  kage graph "<query>" --project <dir> [--json]
75
77
  kage graph-registry --project <dir> [--json]
76
78
  kage embeddings build --project <dir> [--model Xenova/all-MiniLM-L6-v2] [--json]
77
- kage recall "<query>" --project <dir> [--json] [--explain] [--embeddings]
79
+ kage recall "<query>" --project <dir> [--json] [--explain] [--embeddings] [--max-context-tokens <n>] [--structural-hops <n>]
78
80
  kage observe --project <dir> --event <json>
79
81
  kage sessions --project <dir> [--json]
80
82
  kage replay --project <dir> [--session <id>] [--limit <n>] [--json]
81
83
  kage distill --project <dir> --session <id>
82
- kage learn --project <dir> --learning <text> [--title <title>] [--type <type>] [--evidence <text>] [--verified-by <text>] [--tags a,b] [--paths a,b]
84
+ kage learn --project <dir> --learning <text> [--title <title>] [--type <type>] [--evidence <text>] [--verified-by <text>] [--tags a,b] [--paths a,b] [--graph-nodes a,b] [--allow-missing-paths]
83
85
  kage feedback --project <dir> --packet <packet-id> --kind helpful|wrong|stale
84
- kage capture --project <dir> --title <title> --body <body> [--type <type>] [--summary <summary>] [--tags a,b] [--paths a,b] [--stack a,b]
86
+ kage capture --project <dir> --title <title> --body <body> [--type <type>] [--summary <summary>] [--tags a,b] [--paths a,b] [--stack a,b] [--graph-nodes a,b] [--allow-missing-paths]
85
87
  kage propose --project <dir> --from-diff
86
88
  kage review-artifact --project <dir>
87
89
  kage promote --project <dir> --public <packet-id>
@@ -404,6 +406,65 @@ async function main() {
404
406
  }
405
407
  return;
406
408
  }
409
+ if (command === "compact") {
410
+ const project = projectArg(args);
411
+ const dryRun = args.includes("--dry-run");
412
+ const result = (0, kernel_js_1.compactProject)(project, { dryRun });
413
+ if (args.includes("--json")) {
414
+ console.log(JSON.stringify(result, null, 2));
415
+ return;
416
+ }
417
+ const label = dryRun ? " [dry-run]" : "";
418
+ console.log(`Kage compact${label} — scanned ${result.total_scanned} packets`);
419
+ if (result.pruned_citations.length) {
420
+ console.log(`\nPruned dead citations (${result.pruned_citations.length}):`);
421
+ for (const p of result.pruned_citations)
422
+ console.log(` ✂ ${p.title} — removed ${p.removed_paths.join(", ")}`);
423
+ }
424
+ if (result.deprecated.length) {
425
+ console.log(`\nDeprecated stale (${result.deprecated.length}):`);
426
+ for (const p of result.deprecated)
427
+ console.log(` ✗ ${p.title} — ${p.reason}`);
428
+ }
429
+ if (result.duplicate_clusters.length) {
430
+ console.log(`\nDuplicate clusters to merge (${result.duplicate_clusters.length}) — review with kage_compact / kage supersede:`);
431
+ for (const cluster of result.duplicate_clusters) {
432
+ console.log(` ~${cluster.score} similarity:`);
433
+ for (const member of cluster.packets)
434
+ console.log(` • ${member.title} (${member.id})`);
435
+ }
436
+ }
437
+ if (!result.pruned_citations.length && !result.deprecated.length && !result.duplicate_clusters.length) {
438
+ console.log("Nothing to compact — memory is clean.");
439
+ }
440
+ return;
441
+ }
442
+ if (command === "verify") {
443
+ const project = projectArg(args);
444
+ const idFlag = args.indexOf("--id");
445
+ const id = idFlag >= 0 ? args[idFlag + 1] : undefined;
446
+ const result = (0, kernel_js_1.verifyCitations)(project, { id });
447
+ if (args.includes("--json")) {
448
+ console.log(JSON.stringify(result, null, 2));
449
+ if (!result.ok)
450
+ process.exit(2);
451
+ return;
452
+ }
453
+ if (!result.ok) {
454
+ console.log(`Verification failed:\n${result.errors.map((error) => ` - ${error}`).join("\n")}`);
455
+ process.exit(2);
456
+ }
457
+ console.log(`Kage verify — ${result.checked} packet(s): ${result.valid} valid, ${result.stale} stale, ${result.ungrounded} ungrounded`);
458
+ for (const entry of result.packets.filter((p) => p.stale || !p.grounded)) {
459
+ const flags = [entry.stale ? `stale:${entry.stale_severity}` : "", entry.grounded ? "" : "ungrounded"].filter(Boolean).join(", ");
460
+ console.log(` ⚠ ${entry.title} [${flags}]`);
461
+ if (entry.missing_paths.length)
462
+ console.log(` missing: ${entry.missing_paths.join(", ")}`);
463
+ for (const reason of entry.stale_reasons)
464
+ console.log(` - ${reason}`);
465
+ }
466
+ return;
467
+ }
407
468
  if (command === "refresh") {
408
469
  const result = (0, kernel_js_1.refreshProject)(projectArg(args), { full: args.includes("--full") });
409
470
  if (args.includes("--json")) {
@@ -1377,12 +1438,17 @@ async function main() {
1377
1438
  tags: listArg(takeArg(args, "--tags")),
1378
1439
  paths: listArg(takeArg(args, "--paths")),
1379
1440
  stack: listArg(takeArg(args, "--stack")),
1441
+ graphNodes: listArg(takeArg(args, "--graph-nodes")),
1442
+ allowMissingPaths: args.includes("--allow-missing-paths"),
1443
+ strictCitations: true,
1380
1444
  });
1381
1445
  if (!result.ok) {
1382
1446
  console.error(`Learning capture blocked:\n${result.errors.map((error) => ` - ${error}`).join("\n")}`);
1383
1447
  process.exit(2);
1384
1448
  }
1385
1449
  console.log(`Captured session learning: ${result.path}`);
1450
+ if (result.warnings?.length)
1451
+ console.log(`Warnings:\n${result.warnings.map((warning) => ` - ${warning}`).join("\n")}`);
1386
1452
  console.log("Repo-local memory is written immediately. Promotion to org/global still requires explicit review.");
1387
1453
  return;
1388
1454
  }
@@ -1565,9 +1631,11 @@ async function main() {
1565
1631
  const query = firstPositional(args);
1566
1632
  if (!query)
1567
1633
  usage();
1634
+ const maxContextTokens = args.includes("--max-context-tokens") ? numberArg(args, "--max-context-tokens", 0) : undefined;
1635
+ const structuralHops = args.includes("--structural-hops") ? numberArg(args, "--structural-hops", 2) : undefined;
1568
1636
  const result = args.includes("--embeddings")
1569
1637
  ? await (0, kernel_js_1.recallWithEmbeddings)(projectArg(args), query, 5, args.includes("--explain"))
1570
- : (0, kernel_js_1.recall)(projectArg(args), query, 5, args.includes("--explain"));
1638
+ : (0, kernel_js_1.recall)(projectArg(args), query, 5, args.includes("--explain"), { maxContextTokens, structuralHops });
1571
1639
  if (args.includes("--json"))
1572
1640
  console.log(JSON.stringify(result, null, 2));
1573
1641
  else
@@ -1711,6 +1779,9 @@ async function main() {
1711
1779
  tags: listArg(takeArg(args, "--tags")),
1712
1780
  paths: listArg(takeArg(args, "--paths")),
1713
1781
  stack: listArg(takeArg(args, "--stack")),
1782
+ graphNodes: listArg(takeArg(args, "--graph-nodes")),
1783
+ allowMissingPaths: args.includes("--allow-missing-paths"),
1784
+ strictCitations: true,
1714
1785
  };
1715
1786
  const result = (0, kernel_js_1.capture)(input);
1716
1787
  if (!result.ok) {
@@ -1718,6 +1789,8 @@ async function main() {
1718
1789
  process.exit(2);
1719
1790
  }
1720
1791
  console.log(`Captured repo-local packet: ${result.path}`);
1792
+ if (result.warnings?.length)
1793
+ console.log(`Warnings:\n${result.warnings.map((warning) => ` - ${warning}`).join("\n")}`);
1721
1794
  console.log("Repo-local memory is written immediately. Promotion to org/global still requires explicit review.");
1722
1795
  return;
1723
1796
  }
package/dist/index.js CHANGED
@@ -152,6 +152,8 @@ function listTools() {
152
152
  explain: { type: "boolean" },
153
153
  embeddings: { type: "boolean" },
154
154
  json: { type: "boolean" },
155
+ max_context_tokens: { type: "number" },
156
+ structural_hops: { type: "number", description: "If >0, append a bounded N-hop code-graph blast radius seeded from the recalled memory's files." },
155
157
  },
156
158
  required: ["query", "project_dir"],
157
159
  },
@@ -668,7 +670,7 @@ function listTools() {
668
670
  },
669
671
  {
670
672
  name: "kage_learn",
671
- description: "Capture an actual reusable learning from the current session as repo-local memory. Prefer this over diff proposal when the agent knows what was learned.",
673
+ description: "Capture an actual reusable learning from the current session as repo-local memory. Prefer this over diff proposal when the agent knows what was learned. Capture is rejected if every referenced path is missing from the repo; set allow_missing_paths to record anyway (e.g. a file you are about to create).",
672
674
  inputSchema: {
673
675
  type: "object",
674
676
  properties: {
@@ -681,13 +683,15 @@ function listTools() {
681
683
  tags: { type: "array", items: { type: "string" } },
682
684
  paths: { type: "array", items: { type: "string" } },
683
685
  stack: { type: "array", items: { type: "string" } },
686
+ graph_nodes: { type: "array", items: { type: "string" } },
687
+ allow_missing_paths: { type: "boolean" },
684
688
  },
685
689
  required: ["project_dir", "learning"],
686
690
  },
687
691
  },
688
692
  {
689
693
  name: "kage_capture",
690
- description: "Create a repo-local Kage memory packet immediately. Org/global promotion still requires explicit human review.",
694
+ description: "Create a repo-local Kage memory packet immediately. Org/global promotion still requires explicit human review. Capture is rejected if every referenced path is missing from the repo; set allow_missing_paths to record anyway.",
691
695
  inputSchema: {
692
696
  type: "object",
693
697
  properties: {
@@ -699,10 +703,36 @@ function listTools() {
699
703
  tags: { type: "array", items: { type: "string" } },
700
704
  paths: { type: "array", items: { type: "string" } },
701
705
  stack: { type: "array", items: { type: "string" } },
706
+ graph_nodes: { type: "array", items: { type: "string" }, description: "Code-graph node references (symbol/route/file) this memory is about." },
707
+ allow_missing_paths: { type: "boolean" },
702
708
  },
703
709
  required: ["project_dir", "title", "body"],
704
710
  },
705
711
  },
712
+ {
713
+ name: "kage_verify_citations",
714
+ description: "Verify that a memory packet's cited file paths still exist and that the memory is not stale, before trusting it. Pass an id to check one packet, or omit to audit all approved repo memory. Returns grounding and staleness for each.",
715
+ inputSchema: {
716
+ type: "object",
717
+ properties: {
718
+ project_dir: { type: "string" },
719
+ id: { type: "string" },
720
+ },
721
+ required: ["project_dir"],
722
+ },
723
+ },
724
+ {
725
+ name: "kage_compact",
726
+ description: "Consolidate repo memory: prune dead citations, deprecate hard-stale packets, and surface near-duplicate clusters to merge (via kage_supersede). Defaults to a dry run; pass dry_run=false to apply pruning/deprecation. Duplicate merging stays an agent decision — no hosted LLM is used.",
727
+ inputSchema: {
728
+ type: "object",
729
+ properties: {
730
+ project_dir: { type: "string" },
731
+ dry_run: { type: "boolean" },
732
+ },
733
+ required: ["project_dir"],
734
+ },
735
+ },
706
736
  {
707
737
  name: "kage_observe",
708
738
  description: "Store an automatic local observation event from an agent session. Observations are privacy-scanned, deduplicated, and never published automatically.",
@@ -1092,9 +1122,11 @@ async function callTool(name, args) {
1092
1122
  };
1093
1123
  }
1094
1124
  if (name === "kage_recall") {
1125
+ const maxContextTokens = typeof args?.max_context_tokens === "number" ? args.max_context_tokens : undefined;
1126
+ const structuralHops = typeof args?.structural_hops === "number" ? args.structural_hops : undefined;
1095
1127
  const result = args?.embeddings
1096
1128
  ? await (0, kernel_js_1.recallWithEmbeddings)(String(args?.project_dir ?? ""), String(args?.query ?? ""), Number(args?.limit ?? 5), Boolean(args?.explain))
1097
- : (0, kernel_js_1.recall)(String(args?.project_dir ?? ""), String(args?.query ?? ""), Number(args?.limit ?? 5), Boolean(args?.explain));
1129
+ : (0, kernel_js_1.recall)(String(args?.project_dir ?? ""), String(args?.query ?? ""), Number(args?.limit ?? 5), Boolean(args?.explain), { maxContextTokens, structuralHops });
1098
1130
  return {
1099
1131
  content: [{ type: "text", text: args?.json || args?.explain ? JSON.stringify(result, null, 2) : result.context_block }],
1100
1132
  };
@@ -1403,13 +1435,17 @@ async function callTool(name, args) {
1403
1435
  tags: arrayArg(args?.tags),
1404
1436
  paths: arrayArg(args?.paths),
1405
1437
  stack: arrayArg(args?.stack),
1438
+ graphNodes: arrayArg(args?.graph_nodes),
1439
+ allowMissingPaths: Boolean(args?.allow_missing_paths),
1440
+ strictCitations: true,
1406
1441
  });
1442
+ const learnWarnings = result.warnings?.length ? `\nWarnings:\n${result.warnings.map((warning) => `- ${warning}`).join("\n")}` : "";
1407
1443
  return {
1408
1444
  content: [
1409
1445
  {
1410
1446
  type: "text",
1411
1447
  text: result.ok
1412
- ? `Captured session learning: ${result.path}\nRepo-local memory is written immediately. Org/global promotion still requires explicit review.`
1448
+ ? `Captured session learning: ${result.path}\nRepo-local memory is written immediately. Org/global promotion still requires explicit review.${learnWarnings}`
1413
1449
  : `Learning capture blocked:\n${result.errors.map((error) => `- ${error}`).join("\n")}`,
1414
1450
  },
1415
1451
  ],
@@ -1426,19 +1462,41 @@ async function callTool(name, args) {
1426
1462
  tags: arrayArg(args?.tags),
1427
1463
  paths: arrayArg(args?.paths),
1428
1464
  stack: arrayArg(args?.stack),
1465
+ graphNodes: arrayArg(args?.graph_nodes),
1466
+ allowMissingPaths: Boolean(args?.allow_missing_paths),
1467
+ strictCitations: true,
1429
1468
  });
1469
+ const captureWarnings = result.warnings?.length ? `\nWarnings:\n${result.warnings.map((warning) => `- ${warning}`).join("\n")}` : "";
1430
1470
  return {
1431
1471
  content: [
1432
1472
  {
1433
1473
  type: "text",
1434
1474
  text: result.ok
1435
- ? `Captured repo-local packet: ${result.path}\nOrg/global promotion still requires explicit review.`
1475
+ ? `Captured repo-local packet: ${result.path}\nOrg/global promotion still requires explicit review.${captureWarnings}`
1436
1476
  : `Capture blocked:\n${result.errors.map((error) => `- ${error}`).join("\n")}`,
1437
1477
  },
1438
1478
  ],
1439
1479
  isError: !result.ok,
1440
1480
  };
1441
1481
  }
1482
+ if (name === "kage_verify_citations") {
1483
+ const result = (0, kernel_js_1.verifyCitations)(String(args?.project_dir ?? ""), {
1484
+ id: args?.id ? String(args.id) : undefined,
1485
+ });
1486
+ return {
1487
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
1488
+ isError: !result.ok,
1489
+ };
1490
+ }
1491
+ if (name === "kage_compact") {
1492
+ const result = (0, kernel_js_1.compactProject)(String(args?.project_dir ?? ""), {
1493
+ dryRun: args?.dry_run === undefined ? true : Boolean(args.dry_run),
1494
+ });
1495
+ return {
1496
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
1497
+ isError: !result.ok,
1498
+ };
1499
+ }
1442
1500
  if (name === "kage_observe") {
1443
1501
  const projectDir = String(args?.project_dir ?? "");
1444
1502
  const event = { ...args };
package/dist/kernel.js CHANGED
@@ -83,6 +83,8 @@ exports.buildIndexes = buildIndexes;
83
83
  exports.indexProject = indexProject;
84
84
  exports.refreshProject = refreshProject;
85
85
  exports.gcProject = gcProject;
86
+ exports.verifyCitations = verifyCitations;
87
+ exports.compactProject = compactProject;
86
88
  exports.installAgentPolicy = installAgentPolicy;
87
89
  exports.createDenseEmbeddingProvider = createDenseEmbeddingProvider;
88
90
  exports.buildEmbeddingIndex = buildEmbeddingIndex;
@@ -180,6 +182,27 @@ exports.MEMORY_TYPES = [
180
182
  "negative_result",
181
183
  "constraint",
182
184
  ];
185
+ // Bounded context assembly (PRD Feature 2: "inject only the relevant rule + structural
186
+ // map, dropping the rest"). Opt-in: when a token budget is set, keep the highest-priority
187
+ // sections (preamble + code graph + memory come first in the block) and drop trailing
188
+ // lower-priority sections until the estimate fits. Default (no budget) is unchanged.
189
+ function boundContextBlock(block, budget) {
190
+ if (!Number.isFinite(budget) || budget <= 0 || estimateTokens(block) <= budget)
191
+ return block;
192
+ const parts = block.split(/\n(?=## )/);
193
+ const kept = [parts[0]];
194
+ let dropped = 0;
195
+ for (const section of parts.slice(1)) {
196
+ if (estimateTokens([...kept, section].join("\n")) <= budget)
197
+ kept.push(section);
198
+ else
199
+ dropped += 1;
200
+ }
201
+ const result = kept.join("\n");
202
+ return dropped
203
+ ? `${result}\n\n_Context trimmed to ~${budget} tokens; ${dropped} lower-priority section(s) dropped._`
204
+ : result;
205
+ }
183
206
  const graphMemoryCache = new Map();
184
207
  exports.SETUP_AGENTS = [
185
208
  "codex",
@@ -1119,6 +1142,52 @@ function staleMemoryReasons(projectDir, packet, fingerprintCache) {
1119
1142
  }
1120
1143
  return unique(reasons);
1121
1144
  }
1145
+ // Classifies stale reasons into severity. "hard" reasons (deprecated status, user
1146
+ // reported stale, ttl expired, all citations deleted) mean the memory should be
1147
+ // excluded from recall; "soft" reasons (some citations missing, a linked file
1148
+ // changed) mean keep-but-flag — the memory may just need review, not suppression.
1149
+ function staleSeverity(reasons) {
1150
+ if (!reasons.length)
1151
+ return "none";
1152
+ const hard = reasons.some((reason) => reason.startsWith("packet status is") ||
1153
+ reason.startsWith("user or agent reported") ||
1154
+ reason.startsWith("freshness ttl expired") ||
1155
+ reason.startsWith("all referenced paths are missing"));
1156
+ return hard ? "hard" : "soft";
1157
+ }
1158
+ // Decides whether a packet should be excluded from the recall payload (PRD Feature 3:
1159
+ // "deleted or heavily refactored since the timestamp"). Distinct from staleMemoryReasons:
1160
+ // a citation that NEVER existed (no stored fingerprint) is an ungrounded write — guarded
1161
+ // at capture time — not recall-time staleness, so it does NOT trigger exclusion here.
1162
+ // Returns a reason string when the memory is hard-stale, otherwise null.
1163
+ function recallHardStaleReason(projectDir, packet, cache) {
1164
+ if (packet.status === "deprecated" || packet.status === "superseded")
1165
+ return `packet status is ${packet.status}`;
1166
+ const quality = (packet.quality ?? {});
1167
+ if (Number(quality.reports_stale ?? 0) > 0)
1168
+ return "user or agent reported this memory stale";
1169
+ const freshness = (packet.freshness ?? {});
1170
+ const ttlDays = Number(freshness.ttl_days ?? freshness.ttlDays ?? 0);
1171
+ const verifiedAt = Date.parse(String(freshness.last_verified_at ?? packet.updated_at ?? packet.created_at));
1172
+ if (Number.isFinite(ttlDays) && ttlDays > 0 && Number.isFinite(verifiedAt)) {
1173
+ const ageDays = (Date.now() - verifiedAt) / (1000 * 60 * 60 * 24);
1174
+ if (ageDays > ttlDays)
1175
+ return `freshness ttl expired (${Math.floor(ageDays)}d old, ttl ${ttlDays}d)`;
1176
+ }
1177
+ // Only paths that existed at capture get a stored fingerprint; if every one of them is
1178
+ // now gone, the memory's evidence was deleted out from under it.
1179
+ const stored = packetStoredPathFingerprints(packet);
1180
+ if (stored.length) {
1181
+ const deleted = stored.filter((fingerprint) => {
1182
+ const current = memoryPathFingerprint(projectDir, fingerprint.path, cache);
1183
+ return current === null;
1184
+ });
1185
+ if (deleted.length === stored.length) {
1186
+ return `all cited files deleted since capture: ${deleted.slice(0, 4).map((fingerprint) => fingerprint.path).join(", ")}`;
1187
+ }
1188
+ }
1189
+ return null;
1190
+ }
1122
1191
  function changedPathsFromStaleReasons(reasons) {
1123
1192
  return unique(reasons.flatMap((reason) => {
1124
1193
  const match = reason.match(/^linked path changed since memory was verified: (.+)$/);
@@ -1523,13 +1592,28 @@ function walkFiles(root, predicate) {
1523
1592
  }
1524
1593
  return out.sort();
1525
1594
  }
1595
+ // Tolerant packet read: a single corrupt or merge-conflicted packet (e.g. an
1596
+ // unresolved `<<<<<<<` from a teammate's git merge) must not take down all of
1597
+ // recall/verify/compact. Skip the bad file with a warning and keep going.
1598
+ function tryReadPacket(path) {
1599
+ try {
1600
+ return readJson(path);
1601
+ }
1602
+ catch (error) {
1603
+ process.stderr.write(`kage: skipping unreadable memory packet ${path}: ${error.message}\n`);
1604
+ return null;
1605
+ }
1606
+ }
1526
1607
  function loadPacketsFromDir(dir) {
1527
1608
  if (!(0, node_fs_1.existsSync)(dir))
1528
1609
  return [];
1529
1610
  return (0, node_fs_1.readdirSync)(dir)
1530
1611
  .filter((name) => name.endsWith(".json"))
1531
1612
  .sort()
1532
- .map((name) => readJson((0, node_path_1.join)(dir, name)));
1613
+ .flatMap((name) => {
1614
+ const packet = tryReadPacket((0, node_path_1.join)(dir, name));
1615
+ return packet ? [packet] : [];
1616
+ });
1533
1617
  }
1534
1618
  function loadPacketEntriesFromDir(dir) {
1535
1619
  if (!(0, node_fs_1.existsSync)(dir))
@@ -1537,9 +1621,10 @@ function loadPacketEntriesFromDir(dir) {
1537
1621
  return (0, node_fs_1.readdirSync)(dir)
1538
1622
  .filter((name) => name.endsWith(".json"))
1539
1623
  .sort()
1540
- .map((name) => {
1624
+ .flatMap((name) => {
1541
1625
  const path = (0, node_path_1.join)(dir, name);
1542
- return { path, packet: readJson(path) };
1626
+ const packet = tryReadPacket(path);
1627
+ return packet ? [{ path, packet }] : [];
1543
1628
  });
1544
1629
  }
1545
1630
  function loadApprovedPackets(projectDir) {
@@ -5510,6 +5595,115 @@ function gcProject(projectDir, options = {}) {
5510
5595
  total_scanned: packetEntries.length,
5511
5596
  };
5512
5597
  }
5598
+ // On-demand citation/freshness check the agent can call before trusting a memory.
5599
+ // Pass an id to verify one packet, or omit to audit all approved memory.
5600
+ function verifyCitations(projectDir, options = {}) {
5601
+ ensureMemoryDirs(projectDir);
5602
+ const approved = loadApprovedPackets(projectDir);
5603
+ const targets = options.id ? approved.filter((packet) => packet.id === options.id) : approved;
5604
+ if (options.id && !targets.length) {
5605
+ return { ok: false, project_dir: projectDir, checked: 0, valid: 0, stale: 0, ungrounded: 0, packets: [], errors: [`Approved packet not found: ${options.id}`] };
5606
+ }
5607
+ const cache = new Map();
5608
+ const packets = targets.map((packet) => {
5609
+ const meaningful = packet.paths.filter((path) => meaningfulMemoryPath(path) && !shouldSkipRepoMemoryPath(path));
5610
+ const missing = meaningful.filter((path) => !pathExistsInRepo(projectDir, path));
5611
+ const reasons = staleMemoryReasons(projectDir, packet, cache);
5612
+ const severity = staleSeverity(reasons);
5613
+ const grounded = packetGroundingWarnings(projectDir, packet, "packet").length === 0;
5614
+ return {
5615
+ id: packet.id,
5616
+ title: packet.title,
5617
+ status: packet.status,
5618
+ paths: packet.paths,
5619
+ missing_paths: missing,
5620
+ grounded,
5621
+ stale: severity !== "none",
5622
+ stale_severity: severity,
5623
+ stale_reasons: reasons,
5624
+ };
5625
+ });
5626
+ return {
5627
+ ok: true,
5628
+ project_dir: projectDir,
5629
+ checked: packets.length,
5630
+ valid: packets.filter((entry) => !entry.stale && entry.grounded).length,
5631
+ stale: packets.filter((entry) => entry.stale).length,
5632
+ ungrounded: packets.filter((entry) => !entry.grounded).length,
5633
+ packets,
5634
+ errors: [],
5635
+ };
5636
+ }
5637
+ // Deterministic memory consolidation (no hosted LLM — preserves the no-API-key promise):
5638
+ // 1. prune dead citations from packets and refresh their path fingerprints,
5639
+ // 2. deprecate hard-stale packets (delegating to the same severity rules as recall/gc),
5640
+ // 3. surface near-duplicate clusters for an agent to merge via kage_supersede.
5641
+ function compactProject(projectDir, options = {}) {
5642
+ ensureMemoryDirs(projectDir);
5643
+ const dryRun = options.dryRun === true;
5644
+ const entries = loadPacketEntriesFromDir(packetsDir(projectDir));
5645
+ const prunedCitations = [];
5646
+ const deprecated = [];
5647
+ const cache = new Map();
5648
+ for (const { path, packet } of entries) {
5649
+ if (packet.status === "deprecated" || packet.status === "superseded")
5650
+ continue;
5651
+ const hardReason = recallHardStaleReason(projectDir, packet, cache);
5652
+ if (hardReason) {
5653
+ deprecated.push({ id: packet.id, title: packet.title, reason: hardReason });
5654
+ if (!dryRun)
5655
+ writeJson(path, { ...packet, status: "deprecated", updated_at: nowIso() });
5656
+ continue;
5657
+ }
5658
+ const meaningful = packet.paths.filter((p) => meaningfulMemoryPath(p) && !shouldSkipRepoMemoryPath(p));
5659
+ const missing = meaningful.filter((p) => !pathExistsInRepo(projectDir, p));
5660
+ if (missing.length) {
5661
+ const keptPaths = packet.paths.filter((p) => !missing.includes(p));
5662
+ prunedCitations.push({ id: packet.id, title: packet.title, removed_paths: missing });
5663
+ if (!dryRun) {
5664
+ writeJson(path, {
5665
+ ...packet,
5666
+ paths: keptPaths,
5667
+ freshness: {
5668
+ ...(packet.freshness ?? {}),
5669
+ path_fingerprints: memoryPathFingerprints(projectDir, keptPaths),
5670
+ last_verified_at: nowIso(),
5671
+ },
5672
+ updated_at: nowIso(),
5673
+ });
5674
+ }
5675
+ }
5676
+ }
5677
+ // Cluster near-duplicate approved packets (report only — merging is an agent decision).
5678
+ const context = memoryQualityContext(projectDir);
5679
+ const approved = context.packets.filter((packet) => packet.status === "approved");
5680
+ const seen = new Set();
5681
+ const clusters = [];
5682
+ for (const packet of approved) {
5683
+ if (seen.has(packet.id))
5684
+ continue;
5685
+ const dupes = duplicateCandidatesWithContext(packet, context, 0.6).filter((dupe) => dupe.status === "approved");
5686
+ if (!dupes.length)
5687
+ continue;
5688
+ const members = [{ id: packet.id, title: packet.title }, ...dupes.map((dupe) => ({ id: dupe.id, title: dupe.title }))];
5689
+ members.forEach((member) => seen.add(member.id));
5690
+ clusters.push({ score: Math.max(...dupes.map((dupe) => dupe.score)), packets: members });
5691
+ }
5692
+ if (!dryRun && (prunedCitations.length || deprecated.length)) {
5693
+ const rebuilt = buildGraphIndexes(projectDir);
5694
+ writeJson((0, node_path_1.join)(memoryRoot(projectDir), "metrics.json"), kageMetricsShallow(projectDir, rebuilt));
5695
+ }
5696
+ return {
5697
+ ok: true,
5698
+ project_dir: projectDir,
5699
+ dry_run: dryRun,
5700
+ pruned_citations: prunedCitations,
5701
+ deprecated,
5702
+ duplicate_clusters: clusters,
5703
+ total_scanned: entries.length,
5704
+ errors: [],
5705
+ };
5706
+ }
5513
5707
  function installAgentPolicy(projectDir) {
5514
5708
  const agentsPath = (0, node_path_1.join)(projectDir, "AGENTS.md");
5515
5709
  const claudePath = (0, node_path_1.join)(projectDir, "CLAUDE.md");
@@ -6277,7 +6471,23 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
6277
6471
  })()
6278
6472
  : recallQueryExpansion(query);
6279
6473
  const terms = expansion.terms;
6280
- const approvedPackets = loadApprovedPackets(projectDir);
6474
+ const allApprovedPackets = loadApprovedPackets(projectDir);
6475
+ const includeStale = inputs.includeStale === true;
6476
+ const staleFingerprintCache = new Map();
6477
+ const suppressed = [];
6478
+ // Just-in-time staleness gate: hard-stale memory (deleted citations, expired ttl,
6479
+ // reported stale) is excluded from the recall payload so the agent never sees it
6480
+ // as valid. Suppression is recorded (not silent) so `kage verify` can explain it.
6481
+ const approvedPackets = includeStale
6482
+ ? allApprovedPackets
6483
+ : allApprovedPackets.filter((packet) => {
6484
+ const reason = recallHardStaleReason(projectDir, packet, staleFingerprintCache);
6485
+ if (reason) {
6486
+ suppressed.push({ id: packet.id, title: packet.title, reason });
6487
+ return false;
6488
+ }
6489
+ return true;
6490
+ });
6281
6491
  const baseScores = scorePacketsBm25(expansion.baseTerms, approvedPackets);
6282
6492
  const temporalScores = scorePacketsBm25(expansion.temporalTerms, approvedPackets);
6283
6493
  const semanticScores = scorePacketsBm25(expansion.semanticTerms, approvedPackets);
@@ -6335,6 +6545,13 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
6335
6545
  const graphContext = queryGraph(projectDir, query, 5, knowledgeGraph);
6336
6546
  const codeContext = queryCodeGraph(projectDir, query, 5, codeGraph);
6337
6547
  const pinnedContext = renderPinnedRepoContext(readContextSlots(projectDir));
6548
+ // PRD Feature 2: traverse the code graph outward from the recalled memory's files
6549
+ // (the semantic entry point) to assemble a bounded structural blast radius. Opt-in
6550
+ // via inputs.structuralHops so default recall output is unchanged.
6551
+ const structuralHops = inputs.structuralHops ?? 0;
6552
+ const blastRadius = structuralHops > 0
6553
+ ? structuralBlastRadius(codeGraph, unique(scored.flatMap((entry) => entry.packet.paths).filter((path) => meaningfulMemoryPath(path))), structuralHops)
6554
+ : [];
6338
6555
  const lines = [
6339
6556
  `# Kage Context`,
6340
6557
  "",
@@ -6347,6 +6564,9 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
6347
6564
  ...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
6565
  ...(!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
6566
  "",
6567
+ ...(blastRadius.length
6568
+ ? [`## Structural Blast Radius (${structuralHops}-hop)`, ...blastRadius.map((path, index) => `${index + 1}. ${path}`), ""]
6569
+ : []),
6350
6570
  scored.length ? "## Relevant Memory" : "No relevant repo memory found.",
6351
6571
  ...scored.flatMap((entry, index) => [
6352
6572
  "",
@@ -6367,11 +6587,16 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
6367
6587
  "",
6368
6588
  graphContext.edges.length ? "## Related Graph Facts" : "",
6369
6589
  ...graphContext.edges.slice(0, 5).map((edge, index) => `${index + 1}. ${edge.fact} (evidence: ${edge.evidence.join(", ")})`),
6590
+ ...(suppressed.length
6591
+ ? ["", `_${suppressed.length} stale memory packet(s) excluded from recall. Run kage verify for details._`]
6592
+ : []),
6370
6593
  ];
6594
+ const assembledBlock = lines.join("\n");
6371
6595
  const result = {
6372
6596
  query,
6373
- context_block: lines.join("\n"),
6597
+ context_block: inputs.maxContextTokens ? boundContextBlock(assembledBlock, inputs.maxContextTokens) : assembledBlock,
6374
6598
  results: scored,
6599
+ suppressed: suppressed.length ? suppressed : undefined,
6375
6600
  explanations: explain
6376
6601
  ? scored.map((entry) => ({
6377
6602
  packet_id: entry.packet.id,
@@ -6830,6 +7055,45 @@ function codeDependents(graph) {
6830
7055
  }
6831
7056
  return dependents;
6832
7057
  }
7058
+ // Bounded N-hop structural traversal from a set of seed files (the files the recalled
7059
+ // memory is about) — the PRD's "structural blast radius". Walks both import directions
7060
+ // (who-depends-on and what-it-depends-on), excludes the seeds, and ranks by how many
7061
+ // files depend on each node. Pure graph traversal; no full-repo scan.
7062
+ function structuralBlastRadius(graph, seedPaths, hops, limit = 8) {
7063
+ const seeds = seedPaths.filter(Boolean);
7064
+ if (!seeds.length || hops <= 0)
7065
+ return [];
7066
+ const dependents = codeDependents(graph);
7067
+ const dependencies = new Map();
7068
+ for (const edge of graph.imports) {
7069
+ if (!edge.from_path || !edge.to_path)
7070
+ continue;
7071
+ const list = dependencies.get(edge.from_path) ?? new Set();
7072
+ list.add(edge.to_path);
7073
+ dependencies.set(edge.from_path, list);
7074
+ }
7075
+ const seedSet = new Set(seeds);
7076
+ const visited = new Set();
7077
+ let frontier = new Set(seeds);
7078
+ for (let depth = 0; depth < hops; depth += 1) {
7079
+ const next = new Set();
7080
+ for (const node of frontier) {
7081
+ for (const neighbor of [...(dependents.get(node) ?? []), ...(dependencies.get(node) ?? [])]) {
7082
+ if (seedSet.has(neighbor) || visited.has(neighbor))
7083
+ continue;
7084
+ visited.add(neighbor);
7085
+ next.add(neighbor);
7086
+ }
7087
+ }
7088
+ frontier = next;
7089
+ }
7090
+ const score = new Map();
7091
+ for (const [path, incoming] of dependents.entries())
7092
+ score.set(path, incoming.size);
7093
+ return [...visited]
7094
+ .sort((a, b) => (score.get(b) ?? 0) - (score.get(a) ?? 0) || a.localeCompare(b))
7095
+ .slice(0, limit);
7096
+ }
6833
7097
  function impactSurface(target, dependents, graph) {
6834
7098
  const visited = new Set();
6835
7099
  let frontier = new Set([target]);
@@ -10453,6 +10717,9 @@ function learn(input) {
10453
10717
  paths: input.paths,
10454
10718
  stack: input.stack,
10455
10719
  context: input.context,
10720
+ allowMissingPaths: input.allowMissingPaths,
10721
+ strictCitations: input.strictCitations,
10722
+ graphNodes: input.graphNodes,
10456
10723
  });
10457
10724
  }
10458
10725
  function capture(input) {
@@ -10468,7 +10735,34 @@ function capture(input) {
10468
10735
  errors: [`Sensitive content blocked: ${unique(scanFindings).join(", ")}`],
10469
10736
  };
10470
10737
  }
10738
+ const warnings = [];
10739
+ const meaningfulPaths = (input.paths ?? [])
10740
+ .filter((path) => path && meaningfulMemoryPath(path) && !shouldSkipRepoMemoryPath(path));
10741
+ const missingPaths = meaningfulPaths.filter((path) => !pathExistsInRepo(input.projectDir, path));
10742
+ // Citation validation. Strict mode (agent-facing record_memory tools / CLI) rejects a
10743
+ // write whose every cited path is missing — the PRD's "reject if citations don't exist".
10744
+ // The core library stays permissive (warn-only) for programmatic callers and migrations.
10745
+ if (input.strictCitations && meaningfulPaths.length && missingPaths.length === meaningfulPaths.length && !input.allowMissingPaths) {
10746
+ return {
10747
+ ok: false,
10748
+ errors: [
10749
+ `Citation validation failed: none of the referenced paths exist in this repo: ${missingPaths.join(", ")}. ` +
10750
+ `Fix the paths, or pass allow_missing_paths to record anyway (e.g. for a file you are about to create).`,
10751
+ ],
10752
+ warnings: [],
10753
+ };
10754
+ }
10755
+ if (missingPaths.length) {
10756
+ warnings.push(`Some referenced paths do not exist in this repo: ${missingPaths.join(", ")}`);
10757
+ }
10471
10758
  const createdAt = nowIso();
10759
+ // Agent-asserted links to code-graph nodes (PRD `graph_nodes`): the agent recording the
10760
+ // memory knows which symbol/route/file the rule is about, so let it declare them instead
10761
+ // of relying solely on background derivation.
10762
+ const graphEdges = unique(input.graphNodes ?? [])
10763
+ .map((node) => node.trim())
10764
+ .filter(Boolean)
10765
+ .map((node) => ({ relation: "references_code", to: node, evidence: "agent_capture", created_at: createdAt }));
10472
10766
  const packet = {
10473
10767
  schema_version: exports.PACKET_SCHEMA_VERSION,
10474
10768
  id: makePacketId(input.projectDir, type, input.title, String(Date.now())),
@@ -10510,10 +10804,12 @@ function capture(input) {
10510
10804
  },
10511
10805
  created_at: createdAt,
10512
10806
  updated_at: createdAt,
10807
+ author_branch: gitBranch(input.projectDir),
10513
10808
  };
10809
+ packet.edges = graphEdges;
10514
10810
  const validation = validatePacket(packet);
10515
10811
  if (!validation.ok)
10516
- return { ok: false, errors: validation.errors };
10812
+ return { ok: false, errors: validation.errors, warnings };
10517
10813
  packet.quality = {
10518
10814
  ...packet.quality,
10519
10815
  ...evaluateMemoryQuality(input.projectDir, packet),
@@ -10525,7 +10821,7 @@ function capture(input) {
10525
10821
  path: (0, node_path_1.relative)(input.projectDir, path),
10526
10822
  source_kind: packet.source_refs[0]?.kind ?? "explicit_capture",
10527
10823
  });
10528
- return { ok: true, packet, path, errors: [] };
10824
+ return { ok: true, packet, path, errors: [], warnings };
10529
10825
  }
10530
10826
  function createPublicCandidate(projectDir, id) {
10531
10827
  ensureMemoryDirs(projectDir);
@@ -12110,12 +12406,65 @@ function regexpEscape(value) {
12110
12406
  function shellQuote(value) {
12111
12407
  return `'${value.replace(/'/g, "'\"'\"'")}'`;
12112
12408
  }
12113
- function gitHookPath(projectDir) {
12114
- const raw = readGit(projectDir, ["rev-parse", "--git-path", "hooks/post-commit"]);
12409
+ function gitHookPath(projectDir, hookName = "post-commit") {
12410
+ const raw = readGit(projectDir, ["rev-parse", "--git-path", `hooks/${hookName}`]);
12115
12411
  if (!raw)
12116
12412
  return null;
12117
12413
  return (0, node_path_1.resolve)(projectDir, raw);
12118
12414
  }
12415
+ // Hooks that fire after history changes underfoot (git pull / merge / checkout):
12416
+ // re-index repo memory so newly pulled teammate packets are immediately recallable.
12417
+ const KAGE_SYNC_HOOKS = ["post-merge", "post-checkout"];
12418
+ function kageSyncHookBlock(projectDir) {
12419
+ const project = shellQuote((0, node_path_1.resolve)(projectDir));
12420
+ return [
12421
+ KAGE_POST_COMMIT_HOOK_START,
12422
+ "# Kage sync hook: re-index repo memory after pull/merge/checkout.",
12423
+ "# Set KAGE_SKIP_HOOK=1 to bypass, or KAGE_BIN=/path/to/kage to override.",
12424
+ "if [ \"${KAGE_SKIP_HOOK:-0}\" != \"1\" ]; then",
12425
+ " KAGE_BIN=\"${KAGE_BIN:-kage}\"",
12426
+ " if command -v \"$KAGE_BIN\" >/dev/null 2>&1; then",
12427
+ " (",
12428
+ ` "$KAGE_BIN" index --project ${project} --json >/dev/null 2>&1 || true`,
12429
+ " ) &",
12430
+ " fi",
12431
+ "fi",
12432
+ KAGE_POST_COMMIT_HOOK_END,
12433
+ ].join("\n");
12434
+ }
12435
+ function installSyncHooks(projectDir) {
12436
+ const installed = [];
12437
+ for (const hookName of KAGE_SYNC_HOOKS) {
12438
+ const hookPath = gitHookPath(projectDir, hookName);
12439
+ if (!hookPath)
12440
+ continue;
12441
+ ensureDir((0, node_path_1.dirname)(hookPath));
12442
+ const existing = safeReadText(hookPath) ?? "";
12443
+ const base = stripKageHookBlock(existing);
12444
+ const prefix = base.trim() ? base.trimEnd() : "#!/bin/sh";
12445
+ const next = `${prefix}\n\n${kageSyncHookBlock(projectDir)}\n`;
12446
+ if (existing !== next)
12447
+ (0, node_fs_1.writeFileSync)(hookPath, next, "utf8");
12448
+ (0, node_fs_1.chmodSync)(hookPath, 0o755);
12449
+ installed.push(hookPath);
12450
+ }
12451
+ return installed;
12452
+ }
12453
+ function uninstallSyncHooks(projectDir) {
12454
+ const removed = [];
12455
+ for (const hookName of KAGE_SYNC_HOOKS) {
12456
+ const hookPath = gitHookPath(projectDir, hookName);
12457
+ if (!hookPath)
12458
+ continue;
12459
+ const existing = safeReadText(hookPath) ?? "";
12460
+ if (!hasKageHookBlock(existing))
12461
+ continue;
12462
+ (0, node_fs_1.writeFileSync)(hookPath, stripKageHookBlock(existing), "utf8");
12463
+ (0, node_fs_1.chmodSync)(hookPath, 0o755);
12464
+ removed.push(hookPath);
12465
+ }
12466
+ return removed;
12467
+ }
12119
12468
  function hasKageHookBlock(content) {
12120
12469
  return content.includes(KAGE_POST_COMMIT_HOOK_START) && content.includes(KAGE_POST_COMMIT_HOOK_END);
12121
12470
  }
@@ -12191,20 +12540,24 @@ function kageHookInstall(projectDir) {
12191
12540
  const base = stripKageHookBlock(existing);
12192
12541
  const prefix = base.trim() ? base.trimEnd() : "#!/bin/sh";
12193
12542
  const next = `${prefix}\n\n${kagePostCommitHookBlock(projectDir)}\n`;
12194
- const changed = existing !== next;
12195
- if (changed)
12543
+ const commitChanged = existing !== next;
12544
+ if (commitChanged)
12196
12545
  (0, node_fs_1.writeFileSync)(hookPath, next, "utf8");
12197
12546
  (0, node_fs_1.chmodSync)(hookPath, 0o755);
12547
+ const syncHooks = installSyncHooks(projectDir);
12198
12548
  return {
12199
12549
  ok: true,
12200
12550
  action: "install",
12201
12551
  project_dir: projectDir,
12202
12552
  hook_path: hookPath,
12203
12553
  installed: true,
12204
- changed,
12205
- message: changed ? "Installed Kage post-commit hook." : "Kage post-commit hook is already current.",
12554
+ changed: commitChanged,
12555
+ message: commitChanged
12556
+ ? "Installed Kage post-commit hook and pull/merge sync hooks."
12557
+ : "Kage post-commit and sync hooks are already current.",
12206
12558
  errors: [],
12207
12559
  warnings: [],
12560
+ additional_hooks: syncHooks,
12208
12561
  };
12209
12562
  }
12210
12563
  function kageHookUninstall(projectDir) {
@@ -12240,6 +12593,7 @@ function kageHookUninstall(projectDir) {
12240
12593
  const next = stripKageHookBlock(existing);
12241
12594
  (0, node_fs_1.writeFileSync)(hookPath, next, "utf8");
12242
12595
  (0, node_fs_1.chmodSync)(hookPath, 0o755);
12596
+ const removedSyncHooks = uninstallSyncHooks(projectDir);
12243
12597
  return {
12244
12598
  ok: true,
12245
12599
  action: "uninstall",
@@ -12247,9 +12601,10 @@ function kageHookUninstall(projectDir) {
12247
12601
  hook_path: hookPath,
12248
12602
  installed: false,
12249
12603
  changed: true,
12250
- message: "Removed Kage post-commit hook.",
12604
+ message: "Removed Kage post-commit and sync hooks.",
12251
12605
  errors: [],
12252
12606
  warnings: [],
12607
+ additional_hooks: removedSyncHooks,
12253
12608
  };
12254
12609
  }
12255
12610
  function exportPublicBundle(projectDir) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kage-core/kage-graph-mcp",
3
- "version": "1.1.38",
3
+ "version": "1.2.0",
4
4
  "description": "Local-first repo memory, code graph, and recall MCP server for coding agents",
5
5
  "main": "dist/index.js",
6
6
  "files": [