@kage-core/kage-graph-mcp 2.2.6 → 2.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/cli.js CHANGED
@@ -71,12 +71,14 @@ Usage:
71
71
  kage slots set --project <dir> --label <label> --content <text> [--description <text>] [--paths a,b] [--tags a,b] [--size-limit <n>] [--unpinned] [--json]
72
72
  kage slots delete --project <dir> --label <label> [--json]
73
73
  kage handoff --project <dir> [--json]
74
+ kage layers --project <dir> [--json]
74
75
  kage lifecycle --project <dir> [--json]
75
76
  kage reverify --project <dir> --packet <id> [--json]
76
77
  kage reconcile --project <dir> [--session <id>] [--json]
77
78
  kage timeline --project <dir> [--days <n>] [--json]
78
79
  kage lineage --project <dir> [--json]
79
80
  kage supersede --project <dir> --packet <old-id> --replacement <new-id> [--reason <text>] [--json]
81
+ kage conflicts --project <dir> [--json]
80
82
  kage contributors --project <dir> [--json]
81
83
  kage profile --project <dir> [--json]
82
84
  kage xray --project <dir> [--json]
@@ -108,7 +110,8 @@ Usage:
108
110
  kage graph "<query>" --project <dir> [--json]
109
111
  kage graph-registry --project <dir> [--json]
110
112
  kage embeddings build --project <dir> [--model Xenova/all-MiniLM-L6-v2] [--json]
111
- kage recall "<query>" --project <dir> [--json] [--explain] [--embeddings] [--max-context-tokens <n>] [--structural-hops <n>]
113
+ kage recall "<query>" --project <dir> [--json] [--explain] [--embeddings] [--docs] [--max-context-tokens <n>] [--structural-hops <n>]
114
+ kage docs-search "<query>" --project <dir> [--limit <n>] [--json] search this repo's own committed docs (README, docs/**, *.md)
112
115
  kage file-context --project <dir> --path <file> [--json]
113
116
  kage observe --project <dir> --event <json>
114
117
  kage sessions --project <dir> [--json]
@@ -166,6 +169,15 @@ function numberArg(args, name, fallback) {
166
169
  const value = takeArg(args, name);
167
170
  return value ? Number(value) : fallback;
168
171
  }
172
+ function printContradictionWarning(contradictions) {
173
+ if (!contradictions?.length)
174
+ return;
175
+ console.log(`\n⚠ This contradicts ${contradictions.length} existing memor${contradictions.length === 1 ? "y" : "ies"}:`);
176
+ for (const item of contradictions) {
177
+ console.log(` - ${item.packet_id} (${item.title}): ${item.reason}`);
178
+ }
179
+ console.log(" Resolve with kage supersede --packet <old> --replacement <new>, or keep both intentionally.");
180
+ }
169
181
  function firstPositional(args) {
170
182
  return args.find((arg, index) => index > 0 && !arg.startsWith("--") && !args[index - 1]?.startsWith("--"));
171
183
  }
@@ -1363,6 +1375,21 @@ async function main() {
1363
1375
  }
1364
1376
  return;
1365
1377
  }
1378
+ if (command === "layers" || command === "memory-layers") {
1379
+ const result = (0, kernel_js_1.kageLayers)(projectArg(args));
1380
+ if (args.includes("--json")) {
1381
+ console.log(JSON.stringify(result, null, 2));
1382
+ return;
1383
+ }
1384
+ console.log("Kage memory layers");
1385
+ for (const layer of result.layers) {
1386
+ console.log(` ${layer.layer} ${layer.label} (${layer.count})`);
1387
+ console.log(` ${layer.description}`);
1388
+ for (const ex of layer.examples)
1389
+ console.log(` · ${ex}`);
1390
+ }
1391
+ return;
1392
+ }
1366
1393
  if (command === "lifecycle" || command === "memory-lifecycle") {
1367
1394
  const result = (0, kernel_js_1.kageMemoryLifecycle)(projectArg(args));
1368
1395
  if (args.includes("--json")) {
@@ -1604,6 +1631,27 @@ async function main() {
1604
1631
  console.log(`Warnings:\n${result.warnings.map((warning) => ` - ${warning}`).join("\n")}`);
1605
1632
  return;
1606
1633
  }
1634
+ if (command === "conflicts") {
1635
+ const result = (0, kernel_js_1.kageConflicts)(projectArg(args));
1636
+ if (args.includes("--json")) {
1637
+ console.log(JSON.stringify(result, null, 2));
1638
+ return;
1639
+ }
1640
+ if (!result.count) {
1641
+ console.log("Memory conflicts: none — no contradicting packet pairs found.");
1642
+ return;
1643
+ }
1644
+ console.log(`Memory conflicts: ${result.count} contradicting packet pair${result.count === 1 ? "" : "s"}`);
1645
+ for (const pair of result.pairs) {
1646
+ console.log(`\n ⚠ ${pair.a.title} <--> ${pair.b.title}`);
1647
+ console.log(` ${pair.a.id}`);
1648
+ console.log(` ${pair.b.id}`);
1649
+ console.log(` shared paths: ${pair.shared_paths.join(", ")}`);
1650
+ console.log(` ${pair.reason}`);
1651
+ }
1652
+ console.log("\nResolve each with: kage supersede --packet <old> --replacement <new>");
1653
+ return;
1654
+ }
1607
1655
  if (command === "module-health") {
1608
1656
  const result = (0, kernel_js_1.kageModuleHealth)(projectArg(args));
1609
1657
  if (args.includes("--json")) {
@@ -1918,6 +1966,7 @@ async function main() {
1918
1966
  graphNodes: listArg(takeArg(args, "--graph-nodes")),
1919
1967
  allowMissingPaths: args.includes("--allow-missing-paths"),
1920
1968
  strictCitations: true,
1969
+ strictContradictions: args.includes("--strict-contradictions"),
1921
1970
  discoveryTokens: args.includes("--discovery-tokens") ? numberArg(args, "--discovery-tokens", 0) : undefined,
1922
1971
  });
1923
1972
  if (!result.ok) {
@@ -1925,6 +1974,7 @@ async function main() {
1925
1974
  process.exit(2);
1926
1975
  }
1927
1976
  console.log(`Captured ${personal ? "personal" : "session"} learning: ${result.path}`);
1977
+ printContradictionWarning(result.contradictions);
1928
1978
  if (result.warnings?.length)
1929
1979
  console.log(`Warnings:\n${result.warnings.map((warning) => ` - ${warning}`).join("\n")}`);
1930
1980
  console.log(personal
@@ -2003,6 +2053,11 @@ async function main() {
2003
2053
  const result = args.includes("--embeddings")
2004
2054
  ? await (0, kernel_js_1.recallWithEmbeddings)(projectArg(args), query, 5, args.includes("--explain"))
2005
2055
  : (0, kernel_js_1.recall)(projectArg(args), query, 5, args.includes("--explain"), { maxContextTokens, structuralHops });
2056
+ if (args.includes("--docs")) {
2057
+ const docsSection = (0, kernel_js_1.docsRecallSection)(projectArg(args), query, 3);
2058
+ if (docsSection)
2059
+ result.context_block = `${result.context_block}\n\n${docsSection}`;
2060
+ }
2006
2061
  if (args.includes("--json"))
2007
2062
  console.log(JSON.stringify(result, null, 2));
2008
2063
  else {
@@ -2034,6 +2089,27 @@ async function main() {
2034
2089
  console.log(`Packets: ${result.packet_count}`);
2035
2090
  return;
2036
2091
  }
2092
+ if (command === "docs-search") {
2093
+ const query = firstPositional(args);
2094
+ if (!query)
2095
+ usage();
2096
+ const limit = numberArg(args, "--limit", 5);
2097
+ const hits = (0, kernel_js_1.searchDocs)(projectArg(args), query, limit);
2098
+ if (args.includes("--json")) {
2099
+ console.log(JSON.stringify({ query, source: "repo-docs", hits }, null, 2));
2100
+ return;
2101
+ }
2102
+ if (!hits.length) {
2103
+ console.log("No matching docs found in this repo's own committed documentation.");
2104
+ return;
2105
+ }
2106
+ console.log(`Docs search (this repo's own committed documentation) — "${query}":`);
2107
+ for (const hit of hits) {
2108
+ console.log(`\n${hit.doc_path}:${hit.line} [${hit.score}] ${hit.heading}`);
2109
+ console.log(` ${hit.snippet}`);
2110
+ }
2111
+ return;
2112
+ }
2037
2113
  if (command === "observe") {
2038
2114
  const event = takeArg(args, "--event");
2039
2115
  if (!event)
@@ -2173,6 +2249,7 @@ async function main() {
2173
2249
  graphNodes: listArg(takeArg(args, "--graph-nodes")),
2174
2250
  allowMissingPaths: args.includes("--allow-missing-paths"),
2175
2251
  strictCitations: true,
2252
+ strictContradictions: args.includes("--strict-contradictions"),
2176
2253
  };
2177
2254
  const result = (0, kernel_js_1.capture)(input);
2178
2255
  if (!result.ok) {
@@ -2180,6 +2257,7 @@ async function main() {
2180
2257
  process.exit(2);
2181
2258
  }
2182
2259
  console.log(`Captured repo-local packet: ${result.path}`);
2260
+ printContradictionWarning(result.contradictions);
2183
2261
  if (result.warnings?.length)
2184
2262
  console.log(`Warnings:\n${result.warnings.map((warning) => ` - ${warning}`).join("\n")}`);
2185
2263
  console.log("Repo-local memory is written immediately. Promotion to org/global still requires explicit review.");
package/dist/index.js CHANGED
@@ -160,6 +160,7 @@ function listTools() {
160
160
  limit: { type: "number" },
161
161
  explain: { type: "boolean" },
162
162
  embeddings: { type: "boolean" },
163
+ docs: { type: "boolean", description: "If true, append a Docs section (<=3 hits) drawn from this repo's own committed documentation." },
163
164
  json: { type: "boolean" },
164
165
  max_context_tokens: { type: "number" },
165
166
  structural_hops: { type: "number", description: "If >0, append a bounded N-hop code-graph blast radius seeded from the recalled memory's files." },
@@ -374,6 +375,19 @@ function listTools() {
374
375
  required: ["project_dir"],
375
376
  },
376
377
  },
378
+ {
379
+ name: "kage_docs_search",
380
+ description: "Search this repo's OWN committed documentation (README, docs/**, *.md, common doc dirs — including any framework/API docs checked into the repo). BM25 over heading-anchored chunks from .agent_memory/indexes/docs-index.json. Returns ranked doc hits with doc_path, heading, line, and snippet. This indexes only files on disk in the project, never the internet.",
381
+ inputSchema: {
382
+ type: "object",
383
+ properties: {
384
+ query: { type: "string" },
385
+ project_dir: { type: "string" },
386
+ limit: { type: "number" },
387
+ },
388
+ required: ["query", "project_dir"],
389
+ },
390
+ },
377
391
  {
378
392
  name: "kage_metrics",
379
393
  description: "Return concise Kage adoption and quality metrics: code graph counts, language/parser coverage, memory graph evidence coverage, pending/approved packets, validation state, and readiness score.",
@@ -602,6 +616,17 @@ function listTools() {
602
616
  required: ["project_dir", "packet_id", "replacement_packet_id"],
603
617
  },
604
618
  },
619
+ {
620
+ name: "kage_conflicts",
621
+ description: "List repo-local memory packet pairs that contradict each other (same cited path, same subject, opposing claim). Resolve each with kage_supersede, or keep both intentionally.",
622
+ inputSchema: {
623
+ type: "object",
624
+ properties: {
625
+ project_dir: { type: "string" },
626
+ },
627
+ required: ["project_dir"],
628
+ },
629
+ },
605
630
  {
606
631
  name: "kage_benchmark",
607
632
  description: "Return Kage proof metrics, or set mode=memory_quality / memory_scale for synthetic memory retrieval benchmarks.",
@@ -1052,10 +1077,21 @@ async function callTool(name, args) {
1052
1077
  const result = args?.embeddings
1053
1078
  ? await (0, kernel_js_1.recallWithEmbeddings)(String(args?.project_dir ?? ""), String(args?.query ?? ""), Number(args?.limit ?? 5), Boolean(args?.explain))
1054
1079
  : (0, kernel_js_1.recall)(String(args?.project_dir ?? ""), String(args?.query ?? ""), Number(args?.limit ?? 5), Boolean(args?.explain), { maxContextTokens, structuralHops });
1080
+ if (args?.docs) {
1081
+ const docsSection = (0, kernel_js_1.docsRecallSection)(String(args?.project_dir ?? ""), String(args?.query ?? ""), 3);
1082
+ if (docsSection)
1083
+ result.context_block = `${result.context_block}\n\n${docsSection}`;
1084
+ }
1055
1085
  return {
1056
1086
  content: [{ type: "text", text: args?.json || args?.explain ? JSON.stringify(result, null, 2) : result.context_block }],
1057
1087
  };
1058
1088
  }
1089
+ if (name === "kage_docs_search") {
1090
+ const hits = (0, kernel_js_1.searchDocs)(String(args?.project_dir ?? ""), String(args?.query ?? ""), Number(args?.limit ?? 5));
1091
+ return {
1092
+ content: [{ type: "text", text: JSON.stringify({ query: String(args?.query ?? ""), source: "repo-docs", hits }, null, 2) }],
1093
+ };
1094
+ }
1059
1095
  if (name === "kage_graph") {
1060
1096
  const result = (0, kernel_js_1.queryGraph)(String(args?.project_dir ?? ""), String(args?.query ?? ""), Number(args?.limit ?? 10));
1061
1097
  return {
@@ -1224,6 +1260,12 @@ async function callTool(name, args) {
1224
1260
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
1225
1261
  };
1226
1262
  }
1263
+ if (name === "kage_conflicts") {
1264
+ const result = (0, kernel_js_1.kageConflicts)(String(args?.project_dir ?? ""));
1265
+ return {
1266
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
1267
+ };
1268
+ }
1227
1269
  if (name === "kage_module_health") {
1228
1270
  const result = (0, kernel_js_1.kageModuleHealth)(String(args?.project_dir ?? ""));
1229
1271
  return {
package/dist/kernel.js CHANGED
@@ -67,6 +67,8 @@ exports.valueSummary = valueSummary;
67
67
  exports.formatTokenCount = formatTokenCount;
68
68
  exports.kageFileContext = kageFileContext;
69
69
  exports.kageActivity = kageActivity;
70
+ exports.detectContradictions = detectContradictions;
71
+ exports.kageConflicts = kageConflicts;
70
72
  exports.kageMemoryReconciliation = kageMemoryReconciliation;
71
73
  exports.evaluateMemoryAdmission = evaluateMemoryAdmission;
72
74
  exports.validatePacket = validatePacket;
@@ -87,6 +89,9 @@ exports.writeLspSymbolIndex = writeLspSymbolIndex;
87
89
  exports.writeCodeIndex = writeCodeIndex;
88
90
  exports.buildCodeGraph = buildCodeGraph;
89
91
  exports.buildKnowledgeGraph = buildKnowledgeGraph;
92
+ exports.buildDocsIndex = buildDocsIndex;
93
+ exports.searchDocs = searchDocs;
94
+ exports.docsRecallSection = docsRecallSection;
90
95
  exports.buildIndexes = buildIndexes;
91
96
  exports.indexProject = indexProject;
92
97
  exports.refreshProject = refreshProject;
@@ -189,6 +194,7 @@ exports.capturePersonal = capturePersonal;
189
194
  exports.syncSetup = syncSetup;
190
195
  exports.syncStatus = syncStatus;
191
196
  exports.syncPersonal = syncPersonal;
197
+ exports.kageLayers = kageLayers;
192
198
  const node_crypto_1 = require("node:crypto");
193
199
  const node_child_process_1 = require("node:child_process");
194
200
  const node_fs_1 = require("node:fs");
@@ -249,6 +255,9 @@ exports.SETUP_AGENTS = [
249
255
  "kilo-code",
250
256
  "claude-desktop",
251
257
  "aider",
258
+ "openclaw",
259
+ "copilot",
260
+ "hermes",
252
261
  "generic-mcp",
253
262
  ];
254
263
  const DEFAULT_CONFIDENCE = 0.7;
@@ -1345,6 +1354,184 @@ function duplicateCandidatesWithContext(packet, context, threshold = 0.58) {
1345
1354
  status: entry.packet.status,
1346
1355
  }));
1347
1356
  }
1357
+ // Negation cues: a body that flips a claim. "do not use", "is not", "never",
1358
+ // "should not", "no longer", "stop using", "avoid", "instead of".
1359
+ const CONTRADICTION_NEGATION_CUES = [
1360
+ "do not",
1361
+ "don't",
1362
+ "does not",
1363
+ "doesn't",
1364
+ "is not",
1365
+ "isn't",
1366
+ "are not",
1367
+ "aren't",
1368
+ "was not",
1369
+ "should not",
1370
+ "shouldn't",
1371
+ "must not",
1372
+ "cannot",
1373
+ "can't",
1374
+ "never",
1375
+ "no longer",
1376
+ "not idempotent",
1377
+ "not safe",
1378
+ "not thread-safe",
1379
+ "not supported",
1380
+ "not required",
1381
+ "not needed",
1382
+ "stop using",
1383
+ "avoid",
1384
+ "deprecated",
1385
+ "removed",
1386
+ "disable",
1387
+ "disabled",
1388
+ ];
1389
+ // Replacement cues: a body that points the claim at a different answer.
1390
+ // "use Y instead", "replaced by", "switched to", "now use", "migrate to".
1391
+ const CONTRADICTION_REPLACEMENT_CUES = [
1392
+ "instead",
1393
+ "instead of",
1394
+ "replaced by",
1395
+ "replaced with",
1396
+ "replace with",
1397
+ "switched to",
1398
+ "switch to",
1399
+ "migrated to",
1400
+ "migrate to",
1401
+ "now use",
1402
+ "now uses",
1403
+ "moved to",
1404
+ "superseded by",
1405
+ "in favor of",
1406
+ "rather than",
1407
+ ];
1408
+ function normalizedClaimText(packet) {
1409
+ return `${packet.title}\n${packet.summary}\n${packet.body}`.toLowerCase();
1410
+ }
1411
+ function negationCueCount(text) {
1412
+ let count = 0;
1413
+ for (const cue of CONTRADICTION_NEGATION_CUES)
1414
+ if (text.includes(cue))
1415
+ count += 1;
1416
+ return count;
1417
+ }
1418
+ function replacementCueCount(text) {
1419
+ let count = 0;
1420
+ for (const cue of CONTRADICTION_REPLACEMENT_CUES)
1421
+ if (text.includes(cue))
1422
+ count += 1;
1423
+ return count;
1424
+ }
1425
+ // The subject is the shared vocabulary that BOTH packets are talking about. We
1426
+ // require the two packets to agree on a subject (token overlap in title+summary)
1427
+ // but DISAGREE on polarity around it.
1428
+ function sharedSubjectTokens(a, b) {
1429
+ const aSubject = tokenSet(`${a.title}\n${a.summary}`);
1430
+ const bSubject = tokenSet(`${b.title}\n${b.summary}`);
1431
+ const shared = new Set();
1432
+ for (const token of aSubject)
1433
+ if (bSubject.has(token))
1434
+ shared.add(token);
1435
+ return shared;
1436
+ }
1437
+ // Opposing-polarity signal between two same-subject bodies. Conservative: a
1438
+ // contradiction needs one side to assert a claim and the other to negate or
1439
+ // replace it — NOT both sides carrying identical framing (that's a duplicate).
1440
+ function opposingPolarity(aText, bText) {
1441
+ const aNeg = negationCueCount(aText);
1442
+ const bNeg = negationCueCount(bText);
1443
+ const aRepl = replacementCueCount(aText);
1444
+ const bRepl = replacementCueCount(bText);
1445
+ // (1) Negation asymmetry: one side negates the claim, the other does not.
1446
+ // Two equally-hedged notes (both negate, or neither negates) are not flagged.
1447
+ if (aNeg === 0 && bNeg >= 1)
1448
+ return "one memory negates a claim the other asserts";
1449
+ if (bNeg === 0 && aNeg >= 1)
1450
+ return "one memory negates a claim the other asserts";
1451
+ // (2) Replacement asymmetry: one side says "use Y instead", the other still
1452
+ // asserts the original (no replacement framing). Same-subject + redirect.
1453
+ if (aRepl === 0 && bRepl >= 1)
1454
+ return "one memory replaces the approach the other still recommends";
1455
+ if (bRepl === 0 && aRepl >= 1)
1456
+ return "one memory replaces the approach the other still recommends";
1457
+ return null;
1458
+ }
1459
+ // Detect existing approved packets that contradict `candidate`. Pure: takes the
1460
+ // candidate and (optionally) the existing pool, performs no writes. Returns the
1461
+ // conflicting approved packets with the shared paths and a human reason.
1462
+ function detectContradictions(projectDir, candidate, opts = {}) {
1463
+ const subjectThreshold = opts.subjectThreshold ?? 0.34;
1464
+ const candidatePaths = new Set(candidate.paths.filter((path) => meaningfulMemoryPath(path) && !shouldSkipRepoMemoryPath(path)));
1465
+ if (!candidatePaths.size)
1466
+ return [];
1467
+ const existing = (opts.existing ?? loadApprovedPackets(projectDir)).filter((packet) => packet.id !== candidate.id && packet.status === "approved");
1468
+ const candidateText = normalizedClaimText(candidate);
1469
+ const candidateSubject = tokenSet(`${candidate.title}\n${candidate.summary}`);
1470
+ const findings = [];
1471
+ for (const packet of existing) {
1472
+ // (a) shared cited path — the structural anchor that makes a contradiction
1473
+ // about the SAME thing rather than two unrelated facts.
1474
+ const sharedPaths = packet.paths.filter((path) => candidatePaths.has(path));
1475
+ if (!sharedPaths.length)
1476
+ continue;
1477
+ // (b) same subject — high title/summary similarity. Reuses the duplicate
1478
+ // similarity machinery (jaccard over token sets) so detection rides on
1479
+ // the same proven scorer rather than a new bespoke metric.
1480
+ const subjectSimilarity = jaccard(candidateSubject, tokenSet(`${packet.title}\n${packet.summary}`));
1481
+ const subjectOverlap = sharedSubjectTokens(candidate, packet).size;
1482
+ if (subjectSimilarity < subjectThreshold && subjectOverlap < 2)
1483
+ continue;
1484
+ // (c) opposing polarity — the claim itself is flipped/redirected.
1485
+ const reason = opposingPolarity(candidateText, normalizedClaimText(packet));
1486
+ if (!reason)
1487
+ continue;
1488
+ findings.push({
1489
+ packet_id: packet.id,
1490
+ title: packet.title,
1491
+ shared_paths: sharedPaths,
1492
+ reason: `${reason} (shared path: ${sharedPaths.join(", ")})`,
1493
+ });
1494
+ }
1495
+ return findings
1496
+ .sort((a, b) => a.title.localeCompare(b.title))
1497
+ .slice(0, 5);
1498
+ }
1499
+ // Repo-wide pairwise contradiction scan across approved packets. Reuses
1500
+ // detectContradictions per packet against the already-loaded pool so the same
1501
+ // conservative heuristic governs both write-time surfacing and the audit view.
1502
+ function kageConflicts(projectDir) {
1503
+ ensureMemoryDirs(projectDir);
1504
+ const approved = loadApprovedPackets(projectDir);
1505
+ const byId = new Map(approved.map((packet) => [packet.id, packet]));
1506
+ const pairs = [];
1507
+ const seen = new Set();
1508
+ for (const packet of approved) {
1509
+ const findings = detectContradictions(projectDir, packet, { existing: approved });
1510
+ for (const finding of findings) {
1511
+ const ordered = [packet.id, finding.packet_id].sort();
1512
+ const key = ordered.join("\0");
1513
+ if (seen.has(key))
1514
+ continue;
1515
+ seen.add(key);
1516
+ const firstPacket = byId.get(ordered[0]);
1517
+ const secondPacket = byId.get(ordered[1]);
1518
+ pairs.push({
1519
+ a: { id: firstPacket.id, title: firstPacket.title },
1520
+ b: { id: secondPacket.id, title: secondPacket.title },
1521
+ shared_paths: finding.shared_paths,
1522
+ reason: finding.reason,
1523
+ });
1524
+ }
1525
+ }
1526
+ pairs.sort((x, y) => x.a.title.localeCompare(y.a.title) || x.b.title.localeCompare(y.b.title));
1527
+ return {
1528
+ ok: true,
1529
+ project_dir: projectDir,
1530
+ generated_at: nowIso(),
1531
+ count: pairs.length,
1532
+ pairs,
1533
+ };
1534
+ }
1348
1535
  function packetFeedbackScore(packet) {
1349
1536
  const quality = (packet.quality ?? {});
1350
1537
  return Number(quality.votes_up ?? 0) * 2 - Number(quality.votes_down ?? 0) * 3 - Number(quality.reports_stale ?? 0) * 4;
@@ -5984,6 +6171,213 @@ function hydrateKnowledgeGraphArtifact(projectDir, artifact) {
5984
6171
  edges: readJson(edgesPath),
5985
6172
  };
5986
6173
  }
6174
+ // --- Docs search index ---------------------------------------------------
6175
+ // Searchable index over the REPO's OWN committed documentation (project README,
6176
+ // docs/**, *.md, and common doc dirs — plus any framework/API docs that are
6177
+ // checked into this repo). It indexes nothing from the internet; only files
6178
+ // that exist on disk in this project. Recall can therefore answer from docs,
6179
+ // not just learned memory packets and code.
6180
+ const DOCS_INDEX_SCHEMA_VERSION = 1;
6181
+ // Heading-anchored chunks are capped so a single long section can't dominate the
6182
+ // BM25 length normalization or bloat the artifact.
6183
+ const DOCS_CHUNK_MAX_CHARS = 1600;
6184
+ // Directory names that commonly hold prose documentation, beyond the root README
6185
+ // and a top-level docs/ dir. These are matched as path segments.
6186
+ const DOCS_DIR_NAMES = new Set(["docs", "doc", "documentation", "guides", "guide", "wiki", "manual", "handbook"]);
6187
+ const DOCS_EXTENSIONS = new Set([".md", ".mdx", ".markdown", ".rst", ".txt"]);
6188
+ function isDocFile(relativePath) {
6189
+ if (shouldSkipCodePath(relativePath))
6190
+ return false;
6191
+ if (isNoisePath(relativePath))
6192
+ return false;
6193
+ const segments = relativePath.split("/");
6194
+ const name = segments[segments.length - 1];
6195
+ if (!DOCS_EXTENSIONS.has(extensionOf(name)))
6196
+ return false;
6197
+ // A markdown/rst/txt file qualifies if it is the root README, sits under a
6198
+ // recognised doc directory, or is any committed *.md anywhere in the tree.
6199
+ if (/^readme\b/i.test(name))
6200
+ return true;
6201
+ if (segments.slice(0, -1).some((part) => DOCS_DIR_NAMES.has(part.toLowerCase())))
6202
+ return true;
6203
+ const extension = extensionOf(name);
6204
+ return extension === ".md" || extension === ".mdx" || extension === ".markdown";
6205
+ }
6206
+ function discoverDocFiles(projectDir) {
6207
+ const all = walkFiles(projectDir, (absolute) => {
6208
+ const relativePath = (0, node_path_1.relative)(projectDir, absolute).replace(/\\/g, "/");
6209
+ return isDocFile(relativePath);
6210
+ });
6211
+ return all.map((absolute) => (0, node_path_1.relative)(projectDir, absolute).replace(/\\/g, "/")).sort();
6212
+ }
6213
+ function headingAnchor(heading) {
6214
+ return heading
6215
+ .toLowerCase()
6216
+ .replace(/[^\w\s-]/g, "")
6217
+ .trim()
6218
+ .replace(/\s+/g, "-");
6219
+ }
6220
+ // Split a doc into heading-anchored chunks. Each chunk carries the heading path
6221
+ // (e.g. "Setup > Install") plus the body beneath it, capped at DOCS_CHUNK_MAX_CHARS.
6222
+ function chunkDoc(docPath, text) {
6223
+ const lines = text.split(/\r?\n/);
6224
+ const chunks = [];
6225
+ const headingStack = [];
6226
+ let bodyLines = [];
6227
+ let chunkStartLine = 1;
6228
+ let inFence = false;
6229
+ const headingPath = () => headingStack.map((entry) => entry.title).join(" > ");
6230
+ const flush = (startLine) => {
6231
+ const body = bodyLines.join("\n").trim();
6232
+ bodyLines = [];
6233
+ if (!body)
6234
+ return;
6235
+ const heading = headingPath() || (0, node_path_1.basename)(docPath);
6236
+ for (let offset = 0; offset < body.length; offset += DOCS_CHUNK_MAX_CHARS) {
6237
+ const slice = body.slice(offset, offset + DOCS_CHUNK_MAX_CHARS);
6238
+ chunks.push({
6239
+ doc_path: docPath,
6240
+ heading,
6241
+ anchor: headingAnchor(headingStack[headingStack.length - 1]?.title ?? heading),
6242
+ text: slice,
6243
+ line: startLine,
6244
+ });
6245
+ }
6246
+ };
6247
+ lines.forEach((line, index) => {
6248
+ const fenceMatch = line.match(/^\s*(?:```|~~~)/);
6249
+ if (fenceMatch)
6250
+ inFence = !inFence;
6251
+ const headingMatch = inFence ? null : line.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
6252
+ if (headingMatch) {
6253
+ flush(chunkStartLine);
6254
+ const level = headingMatch[1].length;
6255
+ const title = headingMatch[2].trim();
6256
+ while (headingStack.length && headingStack[headingStack.length - 1].level >= level)
6257
+ headingStack.pop();
6258
+ headingStack.push({ level, title });
6259
+ chunkStartLine = index + 1;
6260
+ return;
6261
+ }
6262
+ bodyLines.push(line);
6263
+ });
6264
+ flush(chunkStartLine);
6265
+ return chunks;
6266
+ }
6267
+ function buildDocsIndex(projectDir) {
6268
+ const docFiles = discoverDocFiles(projectDir);
6269
+ const chunks = [];
6270
+ for (const docPath of docFiles) {
6271
+ const text = safeReadText((0, node_path_1.join)(projectDir, docPath));
6272
+ if (!text)
6273
+ continue;
6274
+ chunks.push(...chunkDoc(docPath, text));
6275
+ }
6276
+ const artifact = {
6277
+ schema_version: DOCS_INDEX_SCHEMA_VERSION,
6278
+ generated_at: nowIso(),
6279
+ source: "repo-docs",
6280
+ doc_count: docFiles.length,
6281
+ chunk_count: chunks.length,
6282
+ chunks,
6283
+ };
6284
+ writeJson((0, node_path_1.join)(indexesDir(projectDir), "docs-index.json"), artifact);
6285
+ return artifact;
6286
+ }
6287
+ function readDocsIndex(projectDir) {
6288
+ const path = (0, node_path_1.join)(indexesDir(projectDir), "docs-index.json");
6289
+ if (!(0, node_fs_1.existsSync)(path))
6290
+ return null;
6291
+ try {
6292
+ const artifact = readJson(path);
6293
+ if (!Array.isArray(artifact?.chunks))
6294
+ return null;
6295
+ return artifact;
6296
+ }
6297
+ catch {
6298
+ return null;
6299
+ }
6300
+ }
6301
+ // BM25 over doc chunks, reusing the same lexical scorer (tokenize + stemming +
6302
+ // IDF) as packet recall. Heading text is weighted above body, mirroring how the
6303
+ // packet scorer weights titles.
6304
+ function scoreDocsBm25(queryTerms, chunks) {
6305
+ const terms = expandQueryTerms(queryTerms);
6306
+ const result = new Map();
6307
+ if (!terms.length || !chunks.length)
6308
+ return result;
6309
+ const HEADING_WEIGHT = 3;
6310
+ const documents = chunks.map((chunk) => {
6311
+ const termFrequency = new Map();
6312
+ let length = 0;
6313
+ const add = (textValue, weight) => {
6314
+ for (const token of tokenize(textValue)) {
6315
+ termFrequency.set(token, (termFrequency.get(token) ?? 0) + weight);
6316
+ length += weight;
6317
+ }
6318
+ };
6319
+ add(chunk.heading, HEADING_WEIGHT);
6320
+ add(chunk.text, 1);
6321
+ return { termFrequency, length: Math.max(1, length) };
6322
+ });
6323
+ const averageLength = documents.reduce((sum, document) => sum + document.length, 0) / documents.length || 1;
6324
+ const documentFrequency = new Map();
6325
+ for (const term of terms) {
6326
+ documentFrequency.set(term, documents.filter((document) => document.termFrequency.has(term)).length);
6327
+ }
6328
+ documents.forEach((document, index) => {
6329
+ let score = 0;
6330
+ for (const term of terms) {
6331
+ const termFrequency = document.termFrequency.get(term) ?? 0;
6332
+ if (termFrequency <= 0)
6333
+ continue;
6334
+ const df = documentFrequency.get(term) ?? 0;
6335
+ const idf = Math.log(1 + (documents.length - df + 0.5) / (df + 0.5));
6336
+ const denominator = termFrequency + BM25_K1 * (1 - BM25_B + BM25_B * (document.length / averageLength));
6337
+ score += idf * ((termFrequency * (BM25_K1 + 1)) / denominator);
6338
+ }
6339
+ if (score > 0)
6340
+ result.set(index, Number(score.toFixed(2)));
6341
+ });
6342
+ return result;
6343
+ }
6344
+ function docsSnippet(text) {
6345
+ const collapsed = text.replace(/\s+/g, " ").trim();
6346
+ return collapsed.length > 200 ? `${collapsed.slice(0, 197)}...` : collapsed;
6347
+ }
6348
+ function searchDocs(projectDir, query, limit = 5) {
6349
+ // Use the persisted index when present; rebuild on the fly otherwise so the
6350
+ // search works even before the first refresh.
6351
+ const artifact = readDocsIndex(projectDir) ?? buildDocsIndex(projectDir);
6352
+ const chunks = artifact.chunks;
6353
+ const scores = scoreDocsBm25(tokenize(query), chunks);
6354
+ return [...scores.entries()]
6355
+ .sort((a, b) => b[1] - a[1] || chunks[a[0]].doc_path.localeCompare(chunks[b[0]].doc_path) || chunks[a[0]].line - chunks[b[0]].line)
6356
+ .slice(0, Math.max(0, limit))
6357
+ .map(([index, score]) => {
6358
+ const chunk = chunks[index];
6359
+ return {
6360
+ doc_path: chunk.doc_path,
6361
+ heading: chunk.heading,
6362
+ line: chunk.line,
6363
+ snippet: docsSnippet(chunk.text),
6364
+ score,
6365
+ };
6366
+ });
6367
+ }
6368
+ // Renders a "Docs" section appended to recall output. Honest framing: these are
6369
+ // the repo's own committed docs, not the internet.
6370
+ function docsRecallSection(projectDir, query, limit = 3) {
6371
+ const hits = searchDocs(projectDir, query, limit);
6372
+ if (!hits.length)
6373
+ return null;
6374
+ const lines = ["Docs (from this repo's own committed documentation):"];
6375
+ for (const hit of hits) {
6376
+ lines.push(`- ${hit.doc_path}:${hit.line} — ${hit.heading}`);
6377
+ lines.push(` ${hit.snippet}`);
6378
+ }
6379
+ return lines.join("\n");
6380
+ }
5987
6381
  function buildPacketIndexes(projectDir) {
5988
6382
  ensureMemoryDirs(projectDir);
5989
6383
  const packets = loadPacketsFromDir(packetsDir(projectDir)).sort((a, b) => a.id.localeCompare(b.id));
@@ -6029,6 +6423,10 @@ function buildPacketIndexes(projectDir) {
6029
6423
  writeJson(written[2], byTag);
6030
6424
  writeJson(written[3], byType);
6031
6425
  written.push(writeSparseVectorIndex(projectDir, packets));
6426
+ // Docs search index over the repo's own committed documentation. Built here so
6427
+ // it stays current through both indexProject and refreshProject.
6428
+ buildDocsIndex(projectDir);
6429
+ written.push((0, node_path_1.join)(indexesDir(projectDir), "docs-index.json"));
6032
6430
  return written;
6033
6431
  }
6034
6432
  function readCurrentCodeGraph(projectDir, expectedInputHash) {
@@ -6130,6 +6528,7 @@ function currentOrBuildGraphs(projectDir) {
6130
6528
  (0, node_path_1.join)(indexesDir(projectDir), "by-tag.json"),
6131
6529
  (0, node_path_1.join)(indexesDir(projectDir), "by-type.json"),
6132
6530
  (0, node_path_1.join)(indexesDir(projectDir), "vector-local.json"),
6531
+ (0, node_path_1.join)(indexesDir(projectDir), "docs-index.json"),
6133
6532
  (0, node_path_1.join)(indexesDir(projectDir), "structural.json"),
6134
6533
  (0, node_path_1.join)(indexesDir(projectDir), "graph.json"),
6135
6534
  (0, node_path_1.join)(indexesDir(projectDir), "code-graph.json"),
@@ -7404,13 +7803,20 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
7404
7803
  ? [`## Structural Blast Radius (${structuralHops}-hop)`, ...blastRadius.map((path, index) => `${index + 1}. ${path}`), ""]
7405
7804
  : []),
7406
7805
  scored.length ? "## Relevant Memory" : "No relevant repo memory found.",
7407
- ...scored.flatMap((entry, index) => [
7408
- "",
7409
- `${index + 1}. [${entry.packet.type} | ${entry.packet.scope} | confidence ${entry.packet.confidence.toFixed(2)}] ${entry.packet.title}`,
7410
- ` Summary: ${entry.packet.summary}`,
7411
- ` Why matched: ${entry.why_matched.join(", ") || "text relevance"}`,
7412
- ` Source: ${sourceLabel(entry.packet)}`,
7413
- ]),
7806
+ ...scored.flatMap((entry, index) => {
7807
+ const contradicts = (entry.packet.quality ?? {}).contradicts;
7808
+ const contested = Array.isArray(contradicts) && contradicts.length > 0;
7809
+ return [
7810
+ "",
7811
+ `${index + 1}. [${entry.packet.type} | ${entry.packet.scope} | confidence ${entry.packet.confidence.toFixed(2)}] ${entry.packet.title}`,
7812
+ ` Summary: ${entry.packet.summary}`,
7813
+ ` Why matched: ${entry.why_matched.join(", ") || "text relevance"}`,
7814
+ ` Source: ${sourceLabel(entry.packet)}`,
7815
+ ...(contested
7816
+ ? [` ⚠ Contested: this memory contradicts ${contradicts.length} other packet(s) (${contradicts.join(", ")}). Resolve with kage conflicts / kage supersede before relying on it.`]
7817
+ : []),
7818
+ ];
7819
+ }),
7414
7820
  "",
7415
7821
  pendingScored.length ? "## Working Memory (Pending Review)" : "",
7416
7822
  ...pendingScored.flatMap((entry, index) => [
@@ -8089,9 +8495,15 @@ function kageRisk(projectDir, targets = [], changedFiles = []) {
8089
8495
  const graph = readCurrentCodeGraph(projectDir) ?? buildCodeGraph(projectDir);
8090
8496
  const graphPaths = new Set(graph.files.map((file) => file.path));
8091
8497
  const dependents = codeDependents(graph);
8092
- const resolvedTargets = unique((targets.length ? targets : changedFiles.length ? changedFiles : gitChangedFiles(projectDir))
8498
+ const explicitTargets = targets.length > 0;
8499
+ const resolvedTargets = unique((explicitTargets ? targets : changedFiles.length ? changedFiles : gitChangedFiles(projectDir))
8093
8500
  .map((path) => gitPathToProjectRelative(projectDir, path) ?? path)
8094
- .filter((path) => path && !isNoisePath(path)));
8501
+ .filter((path) => path && !isNoisePath(path))
8502
+ // Risk is a CODE assessment. When inferring targets from the working tree,
8503
+ // keep only files the code graph actually knows about — memory packets,
8504
+ // dotfiles, and docs have no dependents/hotspot signal and are pure noise
8505
+ // here. Explicitly-named targets are always honored.
8506
+ .filter((path) => explicitTargets || graphPaths.has(path)));
8095
8507
  const warnings = [];
8096
8508
  if (!gitHead(projectDir))
8097
8509
  warnings.push("Git history is unavailable, so churn, ownership, and co-change signals may be empty.");
@@ -9204,9 +9616,15 @@ function renderClaudeMemAuditReceipt(report) {
9204
9616
  function kageReviewerSuggestions(projectDir, targets = [], changedFiles = []) {
9205
9617
  const graph = readCurrentCodeGraph(projectDir) ?? buildCodeGraph(projectDir);
9206
9618
  const graphPaths = new Set(graph.files.map((file) => file.path));
9207
- const resolvedTargets = unique((targets.length ? targets : changedFiles.length ? changedFiles : gitChangedFiles(projectDir))
9619
+ const explicitTargets = targets.length > 0;
9620
+ const resolvedTargets = unique((explicitTargets ? targets : changedFiles.length ? changedFiles : gitChangedFiles(projectDir))
9208
9621
  .map((path) => gitPathToProjectRelative(projectDir, path) ?? path)
9209
- .filter((path) => path && !isNoisePath(path)));
9622
+ .filter((path) => path && !isNoisePath(path))
9623
+ // Risk is a CODE assessment. When inferring targets from the working tree,
9624
+ // keep only files the code graph actually knows about — memory packets,
9625
+ // dotfiles, and docs have no dependents/hotspot signal and are pure noise
9626
+ // here. Explicitly-named targets are always honored.
9627
+ .filter((path) => explicitTargets || graphPaths.has(path)));
9210
9628
  const warnings = [];
9211
9629
  if (!gitHead(projectDir))
9212
9630
  warnings.push("Git history is unavailable, so reviewer suggestions cannot be computed.");
@@ -12509,6 +12927,7 @@ function learn(input) {
12509
12927
  strictCitations: input.strictCitations,
12510
12928
  graphNodes: input.graphNodes,
12511
12929
  pendingReview: input.pendingReview,
12930
+ strictContradictions: input.strictContradictions,
12512
12931
  discoveryTokens: input.discoveryTokens,
12513
12932
  });
12514
12933
  }
@@ -12618,9 +13037,26 @@ function capture(input) {
12618
13037
  const validation = validatePacket(packet);
12619
13038
  if (!validation.ok)
12620
13039
  return { ok: false, errors: validation.errors, warnings };
13040
+ // Memory-vs-memory contradiction detection: surface (and optionally refuse)
13041
+ // a capture that directly opposes an existing approved packet on a shared
13042
+ // path, instead of silently storing two conflicting facts.
13043
+ const contradictions = detectContradictions(input.projectDir, packet);
13044
+ if (contradictions.length && input.strictContradictions) {
13045
+ return {
13046
+ ok: false,
13047
+ errors: [
13048
+ `Contradiction blocked: this memory contradicts ${contradictions.length} existing approved ` +
13049
+ `packet(s): ${contradictions.map((c) => `${c.packet_id} (${c.title})`).join("; ")}. ` +
13050
+ `Resolve with kage supersede --packet <old> --replacement <new>, or drop --strict-contradictions to keep both.`,
13051
+ ],
13052
+ warnings,
13053
+ contradictions,
13054
+ };
13055
+ }
12621
13056
  packet.quality = {
12622
13057
  ...packet.quality,
12623
13058
  ...evaluateMemoryQuality(input.projectDir, packet),
13059
+ ...(contradictions.length ? { contradicts: contradictions.map((c) => c.packet_id) } : {}),
12624
13060
  };
12625
13061
  const path = writePacket(input.projectDir, packet, input.pendingReview ? "pending" : "packets");
12626
13062
  recordMemoryAudit(input.projectDir, "capture", [packet], {
@@ -12629,7 +13065,7 @@ function capture(input) {
12629
13065
  path: (0, node_path_1.relative)(input.projectDir, path),
12630
13066
  source_kind: packet.source_refs[0]?.kind ?? "explicit_capture",
12631
13067
  });
12632
- return { ok: true, packet, path, errors: [], warnings };
13068
+ return { ok: true, packet, path, errors: [], warnings, ...(contradictions.length ? { contradictions } : {}) };
12633
13069
  }
12634
13070
  function createPublicCandidate(projectDir, id) {
12635
13071
  ensureMemoryDirs(projectDir);
@@ -13163,6 +13599,9 @@ exit 0
13163
13599
  "roo-code": (0, node_path_1.join)(home, ".roo", "mcp_settings.json"),
13164
13600
  "kilo-code": (0, node_path_1.join)(home, ".kilo", "mcp_settings.json"),
13165
13601
  "claude-desktop": (0, node_path_1.join)(home, ".config", "claude", "claude_desktop_config.json"),
13602
+ openclaw: (0, node_path_1.join)(home, ".openclaw", "mcp.json"),
13603
+ copilot: (0, node_path_1.join)(home, ".config", "github-copilot", "mcp.json"),
13604
+ hermes: (0, node_path_1.join)(home, ".hermes", "mcp.json"),
13166
13605
  "generic-mcp": "",
13167
13606
  };
13168
13607
  setSnippet(paths[agent] || null, universal, [`Merge this MCP stdio config into ${agent}'s MCP settings.`, "Restart the agent after updating config."]);
@@ -14693,6 +15132,12 @@ function prCheck(projectDir) {
14693
15132
  if (reconciliation.unresolved_count > 0) {
14694
15133
  warnings.push(`${reconciliation.unresolved_count} memory reconciliation item(s) may need update after recent code changes (review on handoff; not blocking).`);
14695
15134
  }
15135
+ // Surface unresolved memory-vs-memory contradictions (not a hard fail —
15136
+ // resolving them is an agent/human decision via supersede or keep-both).
15137
+ const conflicts = kageConflicts(projectDir);
15138
+ if (conflicts.count > 0) {
15139
+ warnings.push(`${conflicts.count} memory contradiction pair(s) unresolved — run kage conflicts, then kage supersede the wrong one (not blocking).`);
15140
+ }
14696
15141
  if (!codeGraphCurrent || !memoryGraphCurrent) {
14697
15142
  errors.push("Generated graph artifacts are missing or not current for this working tree content.");
14698
15143
  requiredActions.push("Run kage refresh --project <dir> before merge.");
@@ -15828,8 +16273,35 @@ function supersedeMemory(projectDir, oldPacketId, replacementPacketId, reason =
15828
16273
  upsertPacketEdge(oldPacket, "superseded_by", replacementPacket.id, trimmedReason, at);
15829
16274
  replacementPacket.updated_at = at;
15830
16275
  upsertPacketEdge(replacementPacket, "supersedes", oldPacket.id, trimmedReason, at);
16276
+ // Superseding resolves any recorded contradiction involving the retired
16277
+ // packet: drop the old id from every other packet's quality.contradicts, and
16278
+ // clear the old packet's own contradicts list (it is no longer live memory).
16279
+ const clearContradiction = (packet) => {
16280
+ const quality = (packet.quality ?? {});
16281
+ const existing = Array.isArray(quality.contradicts) ? quality.contradicts : [];
16282
+ if (!existing.length)
16283
+ return false;
16284
+ const next = existing.filter((id) => id !== oldPacket.id && id !== replacementPacket.id);
16285
+ if (next.length === existing.length)
16286
+ return false;
16287
+ if (next.length)
16288
+ quality.contradicts = next;
16289
+ else
16290
+ delete quality.contradicts;
16291
+ packet.quality = quality;
16292
+ packet.updated_at = at;
16293
+ return true;
16294
+ };
16295
+ clearContradiction(oldPacket);
16296
+ clearContradiction(replacementPacket);
15831
16297
  writeJson(oldEntry.path, oldPacket);
15832
16298
  writeJson(replacementEntry.path, replacementPacket);
16299
+ for (const entry of entries) {
16300
+ if (entry.packet.id === oldPacket.id || entry.packet.id === replacementPacket.id)
16301
+ continue;
16302
+ if (clearContradiction(entry.packet))
16303
+ writeJson(entry.path, entry.packet);
16304
+ }
15833
16305
  recordMemoryAudit(projectDir, "supersede", [oldPacket, replacementPacket], {
15834
16306
  old_packet_id: oldPacket.id,
15835
16307
  replacement_packet_id: replacementPacket.id,
@@ -16495,3 +16967,36 @@ function syncPersonal() {
16495
16967
  result.ok = true;
16496
16968
  return result;
16497
16969
  }
16970
+ // Kage's memory is hierarchical by construction:
16971
+ // L0 raw — session observations captured by hooks (unreviewed signal)
16972
+ // L1 reviewed — verified memory packets (the trusted store)
16973
+ // L2 synthesis — generated overviews: repo maps, project profile, branch/change summaries
16974
+ // This surfaces that existing structure rather than inventing a field.
16975
+ function kageLayers(projectDir) {
16976
+ const L2_TYPES = new Set(["repo_map"]);
16977
+ const isSynthesis = (p) => {
16978
+ const q = (p.quality ?? {});
16979
+ return L2_TYPES.has(p.type) || q.candidate_kind === "change_memory";
16980
+ };
16981
+ const entries = (0, node_fs_1.existsSync)(packetsDir(projectDir)) ? loadPacketEntriesFromDir(packetsDir(projectDir)) : [];
16982
+ const l2 = entries.filter((e) => isSynthesis(e.packet));
16983
+ const l1 = entries.filter((e) => !isSynthesis(e.packet));
16984
+ let observationCount = 0;
16985
+ const obsDir = observationsDir(projectDir);
16986
+ if ((0, node_fs_1.existsSync)(obsDir)) {
16987
+ try {
16988
+ observationCount = (0, node_fs_1.readdirSync)(obsDir).filter((f) => f.endsWith(".jsonl") || f.endsWith(".json")).length;
16989
+ }
16990
+ catch {
16991
+ observationCount = 0;
16992
+ }
16993
+ }
16994
+ return {
16995
+ project_dir: projectDir,
16996
+ layers: [
16997
+ { layer: "L0", label: "Raw observations", description: "Session signal captured by hooks; unreviewed, feeds auto-distill.", count: observationCount, examples: [] },
16998
+ { layer: "L1", label: "Reviewed memory", description: "Verified packets — cited, fingerprinted, the trusted recall store.", count: l1.length, examples: l1.slice(0, 3).map((e) => e.packet.title) },
16999
+ { layer: "L2", label: "Synthesis", description: "Generated overviews: repo maps and branch/change summaries.", count: l2.length, examples: l2.slice(0, 3).map((e) => e.packet.title) },
17000
+ ],
17001
+ };
17002
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kage-core/kage-graph-mcp",
3
- "version": "2.2.6",
3
+ "version": "2.3.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": [