@kage-core/kage-graph-mcp 1.1.13 → 1.1.15

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
@@ -40,6 +40,7 @@ exports.pendingDir = pendingDir;
40
40
  exports.publicCandidatesDir = publicCandidatesDir;
41
41
  exports.indexesDir = indexesDir;
42
42
  exports.graphDir = graphDir;
43
+ exports.graphRegistryDir = graphRegistryDir;
43
44
  exports.codeGraphDir = codeGraphDir;
44
45
  exports.branchesDir = branchesDir;
45
46
  exports.reviewDir = reviewDir;
@@ -62,6 +63,7 @@ exports.catalogDomainNodeCount = catalogDomainNodeCount;
62
63
  exports.ensureMemoryDirs = ensureMemoryDirs;
63
64
  exports.loadApprovedPackets = loadApprovedPackets;
64
65
  exports.loadPendingPackets = loadPendingPackets;
66
+ exports.writeLspSymbolIndex = writeLspSymbolIndex;
65
67
  exports.buildCodeGraph = buildCodeGraph;
66
68
  exports.buildKnowledgeGraph = buildKnowledgeGraph;
67
69
  exports.buildIndexes = buildIndexes;
@@ -74,6 +76,8 @@ exports.queryCodeGraph = queryCodeGraph;
74
76
  exports.queryGraph = queryGraph;
75
77
  exports.graphMermaid = graphMermaid;
76
78
  exports.kageMetrics = kageMetrics;
79
+ exports.auditProject = auditProject;
80
+ exports.memoryInbox = memoryInbox;
77
81
  exports.qualityReport = qualityReport;
78
82
  exports.benchmarkProject = benchmarkProject;
79
83
  exports.benchmarkTaskComparison = benchmarkTaskComparison;
@@ -119,11 +123,16 @@ exports.MEMORY_TYPES = [
119
123
  "runbook",
120
124
  "bug_fix",
121
125
  "decision",
126
+ "rationale",
122
127
  "convention",
123
128
  "workflow",
124
129
  "gotcha",
125
130
  "reference",
126
131
  "policy",
132
+ "issue_context",
133
+ "code_explanation",
134
+ "negative_result",
135
+ "constraint",
127
136
  ];
128
137
  exports.SETUP_AGENTS = [
129
138
  "codex",
@@ -174,10 +183,13 @@ Capture examples:
174
183
  - A bug cause and verified fix.
175
184
  - A convention future agents should follow.
176
185
  - A decision and its rationale.
186
+ - Why code, architecture, product, or release behavior ended up this way.
187
+ - A non-obvious issue state, failed approach, or code explanation.
177
188
  - A gotcha that caused rediscovery or wasted time.
178
189
  - A path-specific workflow or dependency relationship.
179
190
 
180
- Keep captures concise and future-facing. Do not store raw transcripts.
191
+ Keep captures concise, source-backed, and useful for future understanding,
192
+ decisions, debugging, explanation, or action. Do not store raw transcripts.
181
193
 
182
194
  ## End-Of-Task Proposal
183
195
 
@@ -264,6 +276,9 @@ function indexesDir(projectDir) {
264
276
  function graphDir(projectDir) {
265
277
  return (0, node_path_1.join)(memoryRoot(projectDir), "graph");
266
278
  }
279
+ function graphRegistryDir(projectDir) {
280
+ return (0, node_path_1.join)(memoryRoot(projectDir), "graph_registry");
281
+ }
267
282
  function codeGraphDir(projectDir) {
268
283
  return (0, node_path_1.join)(memoryRoot(projectDir), "code_graph");
269
284
  }
@@ -526,7 +541,7 @@ function evaluateMemoryQuality(projectDir, packet) {
526
541
  const bodyTokens = tokenize(packet.body);
527
542
  const hasEvidence = packet.source_refs.length > 0;
528
543
  const hasPaths = packet.paths.length > 0;
529
- const highValueType = ["runbook", "bug_fix", "decision", "convention", "workflow", "gotcha", "policy"].includes(packet.type);
544
+ const highValueType = ["runbook", "bug_fix", "decision", "rationale", "convention", "workflow", "gotcha", "policy", "issue_context", "code_explanation", "negative_result", "constraint"].includes(packet.type);
530
545
  if (highValueType) {
531
546
  score += 14;
532
547
  reasons.push("high-value memory type");
@@ -589,7 +604,7 @@ function evaluateMemoryAdmission(projectDir, packet) {
589
604
  const risks = [];
590
605
  const text = `${packet.title}\n${packet.summary}\n${packet.body}`.toLowerCase();
591
606
  let score = 0;
592
- if (["runbook", "bug_fix", "decision", "convention", "workflow", "gotcha", "policy"].includes(packet.type)) {
607
+ if (["runbook", "bug_fix", "decision", "rationale", "convention", "workflow", "gotcha", "policy", "issue_context", "code_explanation", "negative_result", "constraint"].includes(packet.type)) {
593
608
  score += 18;
594
609
  reasons.push("durable memory type");
595
610
  }
@@ -601,9 +616,9 @@ function evaluateMemoryAdmission(projectDir, packet) {
601
616
  score += 12;
602
617
  reasons.push("repo scoped or path grounded");
603
618
  }
604
- if (/(when|after|before|because|requires|must|avoid|prefer|use this|run this|root cause|decision|convention|gotcha|workaround|fix|policy)/i.test(packet.body)) {
619
+ if (/(when|after|before|because|requires|must|avoid|prefer|use this|run this|root cause|rationale|decision|convention|gotcha|workaround|fix|policy|issue|hypothesis|unresolved|explains?|data flow|invariant|coupling|constraint)/i.test(packet.body)) {
605
620
  score += 18;
606
- reasons.push("has future trigger or rationale");
621
+ reasons.push("has durable trigger, rationale, issue context, or explanation");
607
622
  }
608
623
  if (/(verified by|evidence:|test passed|reproduced|root cause)/i.test(packet.body)) {
609
624
  score += 10;
@@ -896,7 +911,12 @@ const NOISE_PATH_PREFIXES = [
896
911
  ".pub-cache/",
897
912
  "elm-stuff/",
898
913
  ];
914
+ function isReviewableMemoryPath(filePath) {
915
+ return /^\.agent_memory\/(?:packets|pending)\/[^/]+\.json$/.test(filePath);
916
+ }
899
917
  function isNoisePath(filePath) {
918
+ if (isReviewableMemoryPath(filePath))
919
+ return false;
900
920
  return NOISE_PATH_PREFIXES.some((prefix) => filePath.startsWith(prefix));
901
921
  }
902
922
  function parsePorcelainStatus(status) {
@@ -911,7 +931,26 @@ function parsePorcelainPath(line) {
911
931
  const raw = line.length > 2 && line[2] === " " ? line.slice(3) : line.slice(2);
912
932
  return raw.trim();
913
933
  }
934
+ function branchDiffStat(projectDir, changedFiles) {
935
+ const diffStats = [
936
+ readGit(projectDir, ["diff", "--stat"]),
937
+ readGit(projectDir, ["diff", "--cached", "--stat"]),
938
+ ].filter(Boolean).join("\n").trim();
939
+ const untracked = new Set((readGit(projectDir, ["ls-files", "--others", "--exclude-standard"]) ?? "")
940
+ .split(/\r?\n/)
941
+ .map((path) => path.trim())
942
+ .filter(Boolean)
943
+ .filter((path) => changedFiles.includes(path)));
944
+ const untrackedStats = [...untracked]
945
+ .filter((file) => !diffStats.includes(file))
946
+ .map((file) => `${file} | untracked`)
947
+ .join("\n");
948
+ return [diffStats, untrackedStats].filter(Boolean).join("\n").trim()
949
+ || changedFiles.map((file) => `${file} | changed`).join("\n");
950
+ }
914
951
  function shouldSkipRepoMemoryPath(relativePath) {
952
+ if (isReviewableMemoryPath(relativePath))
953
+ return false;
915
954
  return isNoisePath(relativePath) || shouldSkipCodePath(relativePath);
916
955
  }
917
956
  function migrateLegacyMarkdown(projectDir) {
@@ -986,6 +1025,15 @@ function createRepoOverviewPacket(projectDir) {
986
1025
  ...((0, node_fs_1.existsSync)(packagePath) ? [{ kind: "file", path: "package.json" }] : []),
987
1026
  ...((0, node_fs_1.existsSync)(readmePath) ? [{ kind: "file", path: "README.md" }] : []),
988
1027
  ],
1028
+ context: {
1029
+ fact: "Generated repo overview summarizes package metadata and the README as a navigation aid for agent startup.",
1030
+ why: "Agents need fast repo orientation before deeper recall or code graph queries, but generated overview memory should stay separate from human rationale.",
1031
+ trigger: "Recall when an agent needs first-pass repo purpose, scripts, stack, or README context.",
1032
+ action: "Use this as orientation only, then inspect source-backed memory and code graph facts for implementation decisions.",
1033
+ verification: "Generated from package.json and README.md when present.",
1034
+ risk_if_forgotten: "Agents may waste context rediscovering basic repo purpose or treat generated overview text as deeper semantic memory.",
1035
+ stale_when: "package.json or README.md changes enough that the generated overview no longer matches the repo.",
1036
+ },
989
1037
  freshness: {
990
1038
  ttl_days: 90,
991
1039
  last_verified_at: createdAt.slice(0, 10),
@@ -1068,6 +1116,15 @@ function createRepoStructurePacket(projectDir) {
1068
1116
  paths: existing.filter((entry) => pathExistsInRepo(projectDir, entry)),
1069
1117
  stack: [],
1070
1118
  source_refs: existing.map((path) => ({ kind: "file", path })),
1119
+ context: {
1120
+ fact: "Generated repo structure summarizes top-level files, workflows, and test files as a navigation aid.",
1121
+ why: "Agents need a quick map of repo entry points before choosing which files, workflows, or tests to inspect.",
1122
+ trigger: "Recall when orienting to this repo's layout, CI workflows, or test locations.",
1123
+ action: "Use this as a starting map and verify details against the current filesystem or code graph before editing.",
1124
+ verification: "Generated from files present in the repository.",
1125
+ risk_if_forgotten: "Agents may miss important entry points such as AGENTS.md, workflows, or MCP tests during initial orientation.",
1126
+ stale_when: "Top-level repo structure, workflow files, or test files change.",
1127
+ },
1071
1128
  freshness: {
1072
1129
  ttl_days: 30,
1073
1130
  last_verified_at: createdAt.slice(0, 10),
@@ -1102,7 +1159,7 @@ function upsertGeneratedPacket(projectDir, packet) {
1102
1159
  if (existing && existing.quality?.reviewer !== "kage-indexer")
1103
1160
  return;
1104
1161
  if (existing) {
1105
- const comparableFields = ["title", "summary", "body", "tags", "paths", "stack", "source_refs", "freshness"];
1162
+ const comparableFields = ["title", "summary", "body", "tags", "paths", "stack", "source_refs", "context", "freshness"];
1106
1163
  const same = comparableFields.every((field) => JSON.stringify(existing[field]) === JSON.stringify(packet[field]));
1107
1164
  if (same)
1108
1165
  return;
@@ -1170,6 +1227,9 @@ function packetGroundingWarnings(projectDir, packet, source) {
1170
1227
  const hasGroundedSource = packet.source_refs.some((ref) => {
1171
1228
  if (typeof ref.path === "string")
1172
1229
  return !shouldSkipRepoMemoryPath(ref.path) && pathExistsInRepo(projectDir, ref.path);
1230
+ if (Array.isArray(ref.changed_files)) {
1231
+ return ref.changed_files.some((path) => typeof path === "string" && !shouldSkipRepoMemoryPath(path) && pathExistsInRepo(projectDir, path));
1232
+ }
1173
1233
  if (typeof ref.kind === "string" && ["explicit_capture", "local_public_candidate"].includes(ref.kind))
1174
1234
  return true;
1175
1235
  return typeof ref.url === "string";
@@ -1894,6 +1954,54 @@ function parseLspDocumentSymbols(projectDir, path) {
1894
1954
  }
1895
1955
  return { symbols, imports: [], calls: [] };
1896
1956
  }
1957
+ function writeLspSymbolIndex(projectDir) {
1958
+ ensureMemoryDirs(projectDir);
1959
+ const outDir = (0, node_path_1.join)(memoryRoot(projectDir), "code_index");
1960
+ ensureDir(outDir);
1961
+ const outPath = (0, node_path_1.join)(outDir, "lsp-symbols.json");
1962
+ const documents = [];
1963
+ let symbolCount = 0;
1964
+ const errors = [];
1965
+ for (const absolutePath of listCodeFiles(projectDir)) {
1966
+ const rel = (0, node_path_1.relative)(projectDir, absolutePath).replace(/\\/g, "/");
1967
+ if (!TS_AST_EXTENSIONS.has(extensionOf(rel)))
1968
+ continue;
1969
+ try {
1970
+ const content = (0, node_fs_1.readFileSync)(absolutePath, "utf8");
1971
+ const symbols = extractSymbols(rel, content).map((symbol) => ({
1972
+ name: symbol.name,
1973
+ kind: symbol.kind,
1974
+ detail: symbol.signature,
1975
+ range: {
1976
+ start: { line: Math.max(0, symbol.line - 1), character: 0 },
1977
+ end: { line: Math.max(0, (symbol.end_line ?? symbol.line) - 1), character: 0 },
1978
+ },
1979
+ }));
1980
+ if (!symbols.length)
1981
+ continue;
1982
+ symbolCount += symbols.length;
1983
+ documents.push({ path: rel, symbols });
1984
+ }
1985
+ catch (error) {
1986
+ errors.push(`${rel}: ${error instanceof Error ? error.message : String(error)}`);
1987
+ }
1988
+ }
1989
+ writeJson(outPath, {
1990
+ schema_version: 1,
1991
+ generator: "kage-lsp-symbol-index",
1992
+ generated_at: nowIso(),
1993
+ documents,
1994
+ });
1995
+ return {
1996
+ ok: errors.length === 0,
1997
+ project_dir: projectDir,
1998
+ path: outPath,
1999
+ parser: "lsp",
2000
+ documents: documents.length,
2001
+ symbols: symbolCount,
2002
+ errors,
2003
+ };
2004
+ }
1897
2005
  function parseLsif(projectDir, path) {
1898
2006
  const docs = new Map();
1899
2007
  const ranges = new Map();
@@ -2030,9 +2138,14 @@ function buildCodeGraph(projectDir) {
2030
2138
  const addSymbol = (symbol) => {
2031
2139
  if (!fileByPath.has(symbol.path))
2032
2140
  return;
2033
- if (symbols.some((existing) => existing.id === symbol.id))
2034
- return;
2035
2141
  const file = fileByPath.get(symbol.path);
2142
+ const existing = symbols.find((candidate) => candidate.id === symbol.id);
2143
+ if (existing) {
2144
+ existing.parser = strongerParser(existing.parser, symbol.parser);
2145
+ if (file)
2146
+ file.parser = strongerParser(file.parser, symbol.parser);
2147
+ return;
2148
+ }
2036
2149
  if (file)
2037
2150
  file.parser = strongerParser(file.parser, symbol.parser);
2038
2151
  symbols.push(symbol);
@@ -2105,6 +2218,7 @@ function buildKnowledgeGraph(projectDir) {
2105
2218
  const episodes = [];
2106
2219
  const repoEntityId = graphEntityId("repo", repoKey(projectDir));
2107
2220
  const generatedFrom = packets.map((packet) => packet.updated_at).sort().at(-1) ?? null;
2221
+ const codeGraph = buildCodeGraph(projectDir);
2108
2222
  addEntity(entities, {
2109
2223
  id: repoEntityId,
2110
2224
  type: "repo",
@@ -2270,6 +2384,118 @@ function buildKnowledgeGraph(projectDir) {
2270
2384
  evidence: [episodeId],
2271
2385
  });
2272
2386
  }
2387
+ const context = engineeringContextFor(packet);
2388
+ if (context.verification) {
2389
+ const command = normalizeCommandText(context.verification);
2390
+ if (command) {
2391
+ const commandId = graphEntityId("command", command);
2392
+ addEntity(entities, {
2393
+ id: commandId,
2394
+ type: "command",
2395
+ name: command,
2396
+ summary: `Verification command from structured memory context.`,
2397
+ first_seen_at: packet.created_at,
2398
+ last_seen_at: packet.updated_at,
2399
+ evidence: [episodeId],
2400
+ });
2401
+ addEdge(edges, {
2402
+ from: memoryId,
2403
+ to: commandId,
2404
+ relation: "verified_by",
2405
+ fact: `"${packet.title}" is verified by "${command}".`,
2406
+ confidence: packet.confidence,
2407
+ valid_from: packet.updated_at,
2408
+ invalidated_at: null,
2409
+ branch,
2410
+ commit: head,
2411
+ evidence: [episodeId],
2412
+ });
2413
+ }
2414
+ }
2415
+ const packetTextLower = `${packet.title}\n${packet.summary}\n${packet.body}`.toLowerCase();
2416
+ const packetPathSet = new Set(packet.paths);
2417
+ const symbolRelation = packet.type === "bug_fix"
2418
+ ? "fixes_symbol"
2419
+ : packet.type === "decision" || packet.type === "rationale" || packet.type === "constraint"
2420
+ ? "informs_symbol"
2421
+ : "explains_symbol";
2422
+ for (const symbol of codeGraph.symbols.filter((symbol) => packetPathSet.has(symbol.path))) {
2423
+ if (packet.type !== "code_explanation" && !packetTextLower.includes(symbol.name.toLowerCase()))
2424
+ continue;
2425
+ const symbolEntityId = graphEntityId("symbol", symbol.id);
2426
+ addEntity(entities, {
2427
+ id: symbolEntityId,
2428
+ type: "symbol",
2429
+ name: symbol.name,
2430
+ aliases: [symbol.id, symbol.path],
2431
+ summary: `${symbol.kind} in ${symbol.path}:${symbol.line}`,
2432
+ first_seen_at: packet.created_at,
2433
+ last_seen_at: packet.updated_at,
2434
+ evidence: [episodeId],
2435
+ });
2436
+ addEdge(edges, {
2437
+ from: memoryId,
2438
+ to: symbolEntityId,
2439
+ relation: symbolRelation,
2440
+ fact: `"${packet.title}" ${symbolRelation.replace(/_/g, " ")} ${symbol.name} in ${symbol.path}.`,
2441
+ confidence: packet.confidence,
2442
+ valid_from: packet.updated_at,
2443
+ invalidated_at: null,
2444
+ branch,
2445
+ commit: head,
2446
+ evidence: [episodeId],
2447
+ });
2448
+ }
2449
+ for (const route of codeGraph.routes.filter((route) => packetPathSet.has(route.file_path) && packetTextLower.includes(route.path.toLowerCase()))) {
2450
+ const routeEntityId = graphEntityId("route", route.id);
2451
+ addEntity(entities, {
2452
+ id: routeEntityId,
2453
+ type: "route",
2454
+ name: `${route.method} ${route.path}`,
2455
+ aliases: [route.id, route.file_path],
2456
+ summary: `${route.framework} route in ${route.file_path}:${route.line}`,
2457
+ first_seen_at: packet.created_at,
2458
+ last_seen_at: packet.updated_at,
2459
+ evidence: [episodeId],
2460
+ });
2461
+ addEdge(edges, {
2462
+ from: memoryId,
2463
+ to: routeEntityId,
2464
+ relation: "applies_to_route",
2465
+ fact: `"${packet.title}" applies to route ${route.method} ${route.path}.`,
2466
+ confidence: packet.confidence,
2467
+ valid_from: packet.updated_at,
2468
+ invalidated_at: null,
2469
+ branch,
2470
+ commit: head,
2471
+ evidence: [episodeId],
2472
+ });
2473
+ }
2474
+ for (const test of codeGraph.tests.filter((test) => packetPathSet.has(test.test_path) || Boolean(test.covers_path && packetPathSet.has(test.covers_path)))) {
2475
+ const testEntityId = graphEntityId("test", test.test_symbol);
2476
+ addEntity(entities, {
2477
+ id: testEntityId,
2478
+ type: "test",
2479
+ name: test.title,
2480
+ aliases: [test.test_symbol, test.test_path],
2481
+ summary: `Test in ${test.test_path}:${test.line}${test.covers_symbol ? ` covers ${test.covers_symbol}` : ""}`,
2482
+ first_seen_at: packet.created_at,
2483
+ last_seen_at: packet.updated_at,
2484
+ evidence: [episodeId],
2485
+ });
2486
+ addEdge(edges, {
2487
+ from: memoryId,
2488
+ to: testEntityId,
2489
+ relation: "verified_by_test",
2490
+ fact: `"${packet.title}" is related to test "${test.title}".`,
2491
+ confidence: packet.confidence,
2492
+ valid_from: packet.updated_at,
2493
+ invalidated_at: null,
2494
+ branch,
2495
+ commit: head,
2496
+ evidence: [episodeId],
2497
+ });
2498
+ }
2273
2499
  }
2274
2500
  const manifestCommands = npmScriptCommands(projectDir);
2275
2501
  if (manifestCommands.length) {
@@ -2702,38 +2928,148 @@ function scorePacket(queryTerms, packet) {
2702
2928
  score += 1;
2703
2929
  return { score, why: unique(why).slice(0, 8) };
2704
2930
  }
2931
+ const BM25_K1 = 1.2;
2932
+ const BM25_B = 0.75;
2933
+ const BM25_FIELD_WEIGHTS = {
2934
+ title: 4,
2935
+ summary: 2.4,
2936
+ tag: 2.8,
2937
+ path: 2.4,
2938
+ type: 1.8,
2939
+ body: 1,
2940
+ };
2941
+ function lexicalStem(term) {
2942
+ if (term.length > 5 && term.endsWith("ing"))
2943
+ return term.slice(0, -3);
2944
+ if (term.length > 4 && term.endsWith("ies"))
2945
+ return `${term.slice(0, -3)}y`;
2946
+ if (term.length > 4 && term.endsWith("es"))
2947
+ return term.slice(0, -2);
2948
+ if (term.length > 3 && term.endsWith("s"))
2949
+ return term.slice(0, -1);
2950
+ return term;
2951
+ }
2952
+ function expandQueryTerms(terms) {
2953
+ return unique(terms.flatMap((term) => unique([term, lexicalStem(term)].filter(Boolean))));
2954
+ }
2955
+ function bm25Document(packet) {
2956
+ const termFrequency = new Map();
2957
+ const fieldHits = new Map();
2958
+ let length = 0;
2959
+ const addField = (field, text) => {
2960
+ const weight = BM25_FIELD_WEIGHTS[field];
2961
+ for (const token of tokenize(text)) {
2962
+ termFrequency.set(token, (termFrequency.get(token) ?? 0) + weight);
2963
+ if (!fieldHits.has(token))
2964
+ fieldHits.set(token, new Set());
2965
+ fieldHits.get(token).add(field);
2966
+ length += weight;
2967
+ }
2968
+ };
2969
+ addField("title", packet.title);
2970
+ addField("summary", packet.summary);
2971
+ addField("tag", packet.tags.join(" "));
2972
+ addField("path", packet.paths.join(" "));
2973
+ addField("type", packet.type);
2974
+ addField("body", packet.body);
2975
+ return { packet, termFrequency, fieldHits, length: Math.max(1, length) };
2976
+ }
2977
+ function scorePacketsBm25(queryTerms, packets) {
2978
+ const terms = expandQueryTerms(queryTerms);
2979
+ const documents = packets.map(bm25Document);
2980
+ const result = new Map();
2981
+ if (!terms.length || !documents.length)
2982
+ return result;
2983
+ const averageLength = documents.reduce((sum, document) => sum + document.length, 0) / documents.length || 1;
2984
+ const documentFrequency = new Map();
2985
+ for (const term of terms) {
2986
+ documentFrequency.set(term, documents.filter((document) => document.termFrequency.has(term)).length);
2987
+ }
2988
+ for (const document of documents) {
2989
+ let score = 0;
2990
+ const why = [];
2991
+ for (const term of terms) {
2992
+ const termFrequency = document.termFrequency.get(term) ?? 0;
2993
+ if (termFrequency <= 0)
2994
+ continue;
2995
+ const df = documentFrequency.get(term) ?? 0;
2996
+ const idf = Math.log(1 + (documents.length - df + 0.5) / (df + 0.5));
2997
+ const denominator = termFrequency + BM25_K1 * (1 - BM25_B + BM25_B * (document.length / averageLength));
2998
+ score += idf * ((termFrequency * (BM25_K1 + 1)) / denominator);
2999
+ const fields = Array.from(document.fieldHits.get(term) ?? []).sort();
3000
+ if (fields.length)
3001
+ why.push(`bm25:${fields.join("+")}:${term}`);
3002
+ }
3003
+ if (score > 0)
3004
+ result.set(document.packet.id, { score: Number(score.toFixed(2)), why: unique(why).slice(0, 8) });
3005
+ }
3006
+ return result;
3007
+ }
3008
+ function recallIntentBoost(queryTerms, packet) {
3009
+ const terms = new Set(expandQueryTerms(queryTerms));
3010
+ const commandIntent = ["run", "test", "tests", "build", "command", "commands"].some((term) => terms.has(term));
3011
+ const debugIntent = ["bug", "fix", "error", "fail", "debug"].some((term) => terms.has(term));
3012
+ const gotchaIntent = terms.has("gotcha");
3013
+ const decisionIntent = terms.has("decision");
3014
+ const packetText = `${packet.title}\n${packet.summary}\n${packet.body}\n${packet.tags.join(" ")}`;
3015
+ const hasCommandEvidence = /\b(?:npm|pnpm|yarn|bun|node|python|pytest|vitest|cargo|go)\s+(?:run\s+)?(?:test|tests|build|dev|start)\b|package\.json|scripts?/i.test(packetText);
3016
+ let score = 0;
3017
+ if (commandIntent) {
3018
+ if (packet.type === "runbook")
3019
+ score += hasCommandEvidence ? 22 : 8;
3020
+ if (packet.type === "repo_map" && hasCommandEvidence)
3021
+ score += 34;
3022
+ if (!["runbook", "repo_map", "workflow"].includes(packet.type) && !debugIntent)
3023
+ score -= 8;
3024
+ if (packet.type === "decision" && /release|verified by|passed|published/i.test(`${packet.title}\n${packet.body}`))
3025
+ score -= 3;
3026
+ }
3027
+ if (debugIntent && packet.type === "bug_fix")
3028
+ score += 10;
3029
+ if (gotchaIntent)
3030
+ score += packet.type === "gotcha" ? 18 : -4;
3031
+ if (decisionIntent)
3032
+ score += packet.type === "decision" ? 12 : 0;
3033
+ return score;
3034
+ }
2705
3035
  function recallBreakdown(projectDir, terms, packet, textScore) {
2706
3036
  const graph = buildKnowledgeGraph(projectDir);
2707
3037
  const packetEntityId = graph.entities.find((entity) => entity.type === "memory" && entity.aliases.includes(packet.id))?.id;
2708
- const graphScore = packetEntityId
3038
+ const rawGraphScore = packetEntityId
2709
3039
  ? graph.edges.filter((edge) => edge.from === packetEntityId || edge.to === packetEntityId).reduce((sum, edge) => sum + scoreText(terms, edge.fact), 0)
2710
3040
  : 0;
3041
+ const graphScore = Math.min(rawGraphScore * 0.45, textScore > 0 ? textScore * 1.5 + 12 : 8);
2711
3042
  const pathTypeTag = scoreText(terms, `${packet.type} ${packet.tags.join(" ")} ${packet.paths.join(" ")}`, [packet.type, ...packet.tags, ...packet.paths]);
3043
+ const intent = recallIntentBoost(terms, packet);
2712
3044
  const freshness = packet.status === "approved" ? 2 : packet.status === "pending" ? 0 : -5;
2713
3045
  const quality = Number(packet.quality.score ?? evaluateMemoryQuality(projectDir, packet).score) / 10;
2714
3046
  const feedback = packetFeedbackScore(packet);
2715
3047
  const vector = 0;
2716
- const final = Number((textScore + graphScore * 0.45 + pathTypeTag * 0.8 + vector + freshness + quality + feedback).toFixed(2));
2717
- return { text: textScore, graph: graphScore, path_type_tag: pathTypeTag, vector, freshness, quality: Number(quality.toFixed(2)), feedback, final };
3048
+ const final = Number((textScore + graphScore + pathTypeTag * 0.8 + intent + vector + freshness + quality + feedback).toFixed(2));
3049
+ return { bm25: textScore, text: textScore, graph: Number(graphScore.toFixed(2)), path_type_tag: pathTypeTag, intent, vector, freshness, quality: Number(quality.toFixed(2)), feedback, final };
2718
3050
  }
2719
3051
  function recall(projectDir, query, limit = 5, explain = false) {
2720
3052
  indexProject(projectDir);
2721
3053
  const terms = tokenize(query);
2722
- const scored = loadApprovedPackets(projectDir)
3054
+ const approvedPackets = loadApprovedPackets(projectDir);
3055
+ const lexicalScores = scorePacketsBm25(terms, approvedPackets);
3056
+ const scored = approvedPackets
2723
3057
  .map((packet) => {
2724
- const { score, why } = scorePacket(terms, packet);
3058
+ const { score, why } = lexicalScores.get(packet.id) ?? { score: 0, why: [] };
2725
3059
  const score_breakdown = recallBreakdown(projectDir, terms, packet, score);
2726
- const relevance = score + score_breakdown.graph + score_breakdown.path_type_tag + score_breakdown.vector;
2727
- return { packet, score: explain ? score_breakdown.final : score, relevance, why_matched: why, score_breakdown };
3060
+ const relevance = score + score_breakdown.graph + score_breakdown.path_type_tag + score_breakdown.intent + score_breakdown.vector;
3061
+ return { packet, score: score_breakdown.final, relevance, why_matched: why, score_breakdown };
2728
3062
  })
2729
3063
  .filter((entry) => entry.relevance > 0)
2730
3064
  .sort((a, b) => b.score - a.score || a.packet.title.localeCompare(b.packet.title))
2731
3065
  .slice(0, limit)
2732
3066
  .map(({ relevance, ...entry }) => entry);
2733
3067
  const pendingSeen = new Set();
2734
- const pendingScored = recallablePendingPackets(projectDir)
3068
+ const pendingPackets = recallablePendingPackets(projectDir);
3069
+ const pendingLexicalScores = scorePacketsBm25(terms, pendingPackets);
3070
+ const pendingScored = pendingPackets
2735
3071
  .map((packet) => {
2736
- const { score, why } = scorePacket(terms, packet);
3072
+ const { score, why } = pendingLexicalScores.get(packet.id) ?? { score: 0, why: [] };
2737
3073
  return { packet, score, why_matched: why };
2738
3074
  })
2739
3075
  .filter((entry) => entry.score > 0)
@@ -2788,7 +3124,7 @@ function recall(projectDir, query, limit = 5, explain = false) {
2788
3124
  ? scored.map((entry) => ({
2789
3125
  packet_id: entry.packet.id,
2790
3126
  title: entry.packet.title,
2791
- provider: "text",
3127
+ provider: "bm25",
2792
3128
  score_breakdown: entry.score_breakdown,
2793
3129
  why_matched: entry.why_matched,
2794
3130
  }))
@@ -3039,6 +3375,206 @@ function kageMetrics(projectDir) {
3039
3375
  },
3040
3376
  };
3041
3377
  }
3378
+ function auditProject(projectDir) {
3379
+ ensureMemoryDirs(projectDir);
3380
+ const validation = validateProject(projectDir);
3381
+ const quality = qualityReport(projectDir);
3382
+ const codeGraph = buildCodeGraph(projectDir);
3383
+ const knowledgeGraph = buildKnowledgeGraph(projectDir);
3384
+ const approved = loadApprovedPackets(projectDir);
3385
+ const pending = loadPendingPackets(projectDir);
3386
+ const structuredPackets = approved.filter(hasStructuredEngineeringContext);
3387
+ const preciseParsers = ["scip", "lsif", "lsp"];
3388
+ const astParsers = ["typescript-ast", "tree-sitter"];
3389
+ const indexableFiles = codeGraph.files.filter((file) => file.parser !== "metadata").length;
3390
+ const preciseFiles = codeGraph.files.filter((file) => preciseParsers.includes(file.parser)).length;
3391
+ const astFiles = codeGraph.files.filter((file) => astParsers.includes(file.parser)).length;
3392
+ const fallbackFiles = codeGraph.files.filter((file) => file.parser === "generic-static" || file.parser === "metadata").length;
3393
+ const memoryCodeEdges = knowledgeGraph.edges.filter((edge) => ["explains_symbol", "informs_symbol", "fixes_symbol", "applies_to_route", "verified_by_test"].includes(edge.relation)).length;
3394
+ const stalePackets = quality.totals.stale;
3395
+ const duplicateCandidatesTotal = quality.totals.duplicate;
3396
+ const structuredCoverage = percent(structuredPackets.length, approved.length);
3397
+ const preciseCoverage = percent(preciseFiles, indexableFiles);
3398
+ const memoryCodeCoverage = percent(Math.min(memoryCodeEdges, approved.length), approved.length);
3399
+ const recommendations = [];
3400
+ if (structuredPackets.length < approved.length) {
3401
+ recommendations.push("Add structured context fields to high-value memories: why, verification, risk_if_forgotten, and stale_when.");
3402
+ }
3403
+ if (pending.length) {
3404
+ recommendations.push("Review pending memory inbox packets and approve, reject, merge, or supersede them before handoff.");
3405
+ }
3406
+ if (stalePackets) {
3407
+ recommendations.push("Run kage gc --dry-run and update or deprecate stale memory before trusting recall.");
3408
+ }
3409
+ if (duplicateCandidatesTotal) {
3410
+ recommendations.push("Merge or supersede duplicate memory packets so agents do not receive conflicting context.");
3411
+ }
3412
+ if (preciseFiles < indexableFiles) {
3413
+ recommendations.push("Add or extend SCIP/LSIF/LSP index artifacts in CI for remaining source files; keep AST/static extraction as fallback.");
3414
+ }
3415
+ if (!memoryCodeEdges && approved.length && codeGraph.symbols.length) {
3416
+ recommendations.push("Link memory packets to symbols, routes, and tests with code_explanation, bug_fix, decision, and verification context.");
3417
+ }
3418
+ if (!validation.ok) {
3419
+ recommendations.push("Fix validation errors before relying on Kage in PR or agent-start workflows.");
3420
+ }
3421
+ const trustScore = Math.max(0, Math.min(100, Math.round((validation.ok ? 25 : 0) +
3422
+ quality.useful_memory_ratio_percent * 0.25 +
3423
+ structuredCoverage * 0.2 +
3424
+ memoryCodeCoverage * 0.15 +
3425
+ Math.max(0, 15 - pending.length * 3 - stalePackets * 5 - duplicateCandidatesTotal * 4))));
3426
+ return {
3427
+ schema_version: 1,
3428
+ project_dir: projectDir,
3429
+ generated_at: nowIso(),
3430
+ ok: validation.ok && stalePackets === 0 && duplicateCandidatesTotal === 0,
3431
+ trust_score: trustScore,
3432
+ checks: {
3433
+ validation,
3434
+ memory_inbox: {
3435
+ approved_packets: approved.length,
3436
+ pending_packets: pending.length,
3437
+ stale_packets: stalePackets,
3438
+ duplicate_candidates: duplicateCandidatesTotal,
3439
+ },
3440
+ structured_memory: {
3441
+ total_packets: approved.length,
3442
+ structured_packets: structuredPackets.length,
3443
+ coverage_percent: structuredCoverage,
3444
+ missing_context_packet_ids: approved.filter((packet) => !structuredPackets.includes(packet)).map((packet) => packet.id),
3445
+ },
3446
+ code_graph: {
3447
+ files: codeGraph.files.length,
3448
+ precise_files: preciseFiles,
3449
+ ast_files: astFiles,
3450
+ fallback_files: fallbackFiles,
3451
+ precise_coverage_percent: preciseCoverage,
3452
+ indexer_coverage_percent: percent(codeGraph.files.filter((file) => file.parser !== "metadata").length, indexableFiles),
3453
+ },
3454
+ graph_links: {
3455
+ memory_code_edges: memoryCodeEdges,
3456
+ evidence_coverage_percent: percent(knowledgeGraph.edges.filter((edge) => edge.evidence.length > 0).length, knowledgeGraph.edges.length),
3457
+ },
3458
+ },
3459
+ recommendations,
3460
+ };
3461
+ }
3462
+ function memoryInbox(projectDir) {
3463
+ ensureMemoryDirs(projectDir);
3464
+ const validation = validateProject(projectDir);
3465
+ const quality = qualityReport(projectDir);
3466
+ const approved = loadApprovedPackets(projectDir);
3467
+ const pending = loadPendingPackets(projectDir);
3468
+ const items = [];
3469
+ for (const packet of pending) {
3470
+ const qualityDetails = evaluateMemoryQuality(projectDir, packet);
3471
+ items.push({
3472
+ kind: "pending",
3473
+ severity: "warning",
3474
+ packet_id: packet.id,
3475
+ title: packet.title,
3476
+ type: packet.type,
3477
+ status: packet.status,
3478
+ paths: packet.paths,
3479
+ summary: packet.summary,
3480
+ reasons: [
3481
+ ...(qualityDetails.risks ?? []),
3482
+ `quality score ${qualityDetails.score}/100`,
3483
+ ],
3484
+ action: "Approve, reject, merge, or keep pending after reviewing source refs and sensitivity.",
3485
+ });
3486
+ }
3487
+ for (const packet of approved) {
3488
+ const reasons = staleMemoryReasons(projectDir, packet);
3489
+ if (reasons.length) {
3490
+ items.push({
3491
+ kind: "stale",
3492
+ severity: "blocker",
3493
+ packet_id: packet.id,
3494
+ title: packet.title,
3495
+ type: packet.type,
3496
+ status: packet.status,
3497
+ paths: packet.paths,
3498
+ summary: packet.summary,
3499
+ reasons,
3500
+ action: `${staleSuggestedAction(reasons)} this packet before trusting recall.`,
3501
+ });
3502
+ }
3503
+ }
3504
+ for (const packet of approved.filter((packet) => !hasStructuredEngineeringContext(packet))) {
3505
+ items.push({
3506
+ kind: "missing_context",
3507
+ severity: "info",
3508
+ packet_id: packet.id,
3509
+ title: packet.title,
3510
+ type: packet.type,
3511
+ status: packet.status,
3512
+ paths: packet.paths,
3513
+ summary: packet.summary,
3514
+ reasons: ["missing explicit why, verification, risk, stale condition, trigger, or action"],
3515
+ action: "Add structured context if this packet carries durable rationale, bug, issue, or code explanation.",
3516
+ });
3517
+ }
3518
+ for (const packet of quality.packets.filter((packet) => packet.classification === "duplicate")) {
3519
+ const source = [...approved, ...pending].find((candidate) => candidate.id === packet.id);
3520
+ items.push({
3521
+ kind: "duplicate",
3522
+ severity: "warning",
3523
+ packet_id: packet.id,
3524
+ title: packet.title,
3525
+ type: packet.type,
3526
+ status: packet.status,
3527
+ paths: source?.paths,
3528
+ summary: source?.summary ?? packet.title,
3529
+ reasons: packet.risks.length ? packet.risks : ["duplicate candidate detected by quality report"],
3530
+ action: "Merge, supersede, or deprecate overlapping memory before handoff.",
3531
+ });
3532
+ }
3533
+ for (const error of validation.errors) {
3534
+ items.push({
3535
+ kind: "validation_error",
3536
+ severity: "blocker",
3537
+ summary: error,
3538
+ reasons: [error],
3539
+ action: "Fix validation errors before relying on Kage in agent or PR workflows.",
3540
+ });
3541
+ }
3542
+ for (const warning of validation.warnings) {
3543
+ items.push({
3544
+ kind: "validation_warning",
3545
+ severity: "warning",
3546
+ summary: warning,
3547
+ reasons: [warning],
3548
+ action: "Review grounding, indexes, generated artifacts, or packet quality.",
3549
+ });
3550
+ }
3551
+ const counts = {
3552
+ approved: approved.length,
3553
+ pending: pending.length,
3554
+ stale: items.filter((item) => item.kind === "stale").length,
3555
+ duplicates: items.filter((item) => item.kind === "duplicate").length,
3556
+ missing_context: items.filter((item) => item.kind === "missing_context").length,
3557
+ validation_errors: validation.errors.length,
3558
+ validation_warnings: validation.warnings.length,
3559
+ };
3560
+ const recommendations = unique([
3561
+ ...(counts.pending ? ["Review pending memory packets before handoff."] : []),
3562
+ ...(counts.stale ? ["Update, verify, supersede, or deprecate stale memory packets."] : []),
3563
+ ...(counts.duplicates ? ["Merge or supersede duplicate memory packets."] : []),
3564
+ ...(counts.missing_context ? ["Add structured why, verification, risk, and stale_when context to high-value packets."] : []),
3565
+ ...(counts.validation_errors ? ["Fix validation errors before trusting recall."] : []),
3566
+ ...(counts.validation_warnings ? ["Review validation warnings so memory remains source-grounded."] : []),
3567
+ ]);
3568
+ return {
3569
+ schema_version: 1,
3570
+ project_dir: projectDir,
3571
+ generated_at: nowIso(),
3572
+ ok: counts.pending === 0 && counts.stale === 0 && counts.duplicates === 0 && counts.validation_errors === 0,
3573
+ counts,
3574
+ items,
3575
+ recommendations,
3576
+ };
3577
+ }
3042
3578
  function qualityReport(projectDir) {
3043
3579
  ensureMemoryDirs(projectDir);
3044
3580
  const packets = [...loadPacketsFromDir(packetsDir(projectDir)), ...loadPacketsFromDir(pendingDir(projectDir))];
@@ -3116,17 +3652,57 @@ function benchmarkProject(projectDir) {
3116
3652
  const metrics = kageMetricsShallow(projectDir);
3117
3653
  const quality = qualityReport(projectDir);
3118
3654
  const typeCoverage = quality.memory_type_coverage;
3655
+ const recallHitRate = percent(scenarios.filter((scenario) => scenario.hit).length, scenarios.length);
3656
+ const codeFlowCoverage = metrics.code_graph.files > 0 && metrics.code_graph.symbols > 0 ? 100 : 0;
3657
+ const gates = [
3658
+ {
3659
+ name: "recall_hit_rate",
3660
+ target: 60,
3661
+ actual: recallHitRate,
3662
+ unit: "percent",
3663
+ pass: recallHitRate >= 60,
3664
+ required: true,
3665
+ },
3666
+ {
3667
+ name: "evidence_coverage",
3668
+ target: 80,
3669
+ actual: quality.evidence_coverage_percent,
3670
+ unit: "percent",
3671
+ pass: quality.evidence_coverage_percent >= 80,
3672
+ required: true,
3673
+ },
3674
+ {
3675
+ name: "useful_memory_ratio",
3676
+ target: 70,
3677
+ actual: quality.useful_memory_ratio_percent,
3678
+ unit: "percent",
3679
+ pass: quality.useful_memory_ratio_percent >= 70,
3680
+ required: true,
3681
+ },
3682
+ {
3683
+ name: "code_flow_coverage",
3684
+ target: 100,
3685
+ actual: codeFlowCoverage,
3686
+ unit: "percent",
3687
+ pass: codeFlowCoverage >= 100,
3688
+ required: true,
3689
+ },
3690
+ ];
3691
+ const gateScore = Math.round(gates.reduce((sum, gate) => sum + Math.min(100, Math.round((gate.actual / Math.max(1, gate.target)) * 100)), 0) / gates.length);
3119
3692
  return {
3120
3693
  schema_version: 1,
3121
3694
  project_dir: projectDir,
3122
3695
  generated_at: nowIso(),
3696
+ ok: gates.filter((gate) => gate.required).every((gate) => gate.pass),
3697
+ overall_score: gateScore,
3698
+ gates,
3123
3699
  scenarios,
3124
3700
  pain_metrics: {
3125
3701
  setup_runbook_coverage_percent: typeCoverage.runbook ? 100 : 0,
3126
3702
  bug_fix_coverage_percent: typeCoverage.bug_fix ? 100 : 0,
3127
3703
  decision_coverage_percent: typeCoverage.decision ? 100 : 0,
3128
- code_flow_coverage_percent: metrics.code_graph.files > 0 && metrics.code_graph.symbols > 0 ? 100 : 0,
3129
- recall_hit_rate_percent: percent(scenarios.filter((scenario) => scenario.hit).length, scenarios.length),
3704
+ code_flow_coverage_percent: codeFlowCoverage,
3705
+ recall_hit_rate_percent: recallHitRate,
3130
3706
  estimated_rediscovery_avoided: scenarios.filter((scenario) => scenario.hit).length,
3131
3707
  estimated_tokens_saved: metrics.savings.estimated_tokens_saved_per_recall,
3132
3708
  time_to_first_use_seconds: metrics.harness.policy_installed ? 30 : 90,
@@ -3296,10 +3872,20 @@ function inferLearningType(input) {
3296
3872
  if (input.type)
3297
3873
  return input.type;
3298
3874
  const text = `${input.title ?? ""} ${input.learning}`.toLowerCase();
3875
+ if (/(issue context|issue|hypothesis|blocked|unresolved|attempted fix)/.test(text))
3876
+ return "issue_context";
3299
3877
  if (/(bug|fix|error|fail|failure|broken|regression)/.test(text))
3300
3878
  return "bug_fix";
3301
- if (/(decided|decision|rationale|tradeoff|chose|choose)/.test(text))
3879
+ if (/(code explanation|explains|data flow|invariant|coupling|module purpose)/.test(text))
3880
+ return "code_explanation";
3881
+ if (/(constraint|external requirement|legal|compliance|performance budget)/.test(text))
3882
+ return "constraint";
3883
+ if (/(negative result|tried|failed because|rejected)/.test(text))
3884
+ return "negative_result";
3885
+ if (/(decided|decision|tradeoff|chose|choose)/.test(text))
3302
3886
  return "decision";
3887
+ if (/(why|rationale|because)/.test(text))
3888
+ return "rationale";
3303
3889
  if (/(run|command|setup|install|build|test|deploy)/.test(text))
3304
3890
  return "runbook";
3305
3891
  if (/(convention|always|prefer|avoid|pattern)/.test(text))
@@ -3312,6 +3898,58 @@ function titleFromLearning(learning) {
3312
3898
  const sentence = learning.split(/[.!?]\s+/)[0]?.trim() || learning.trim();
3313
3899
  return sentence.slice(0, 90) || "Session learning";
3314
3900
  }
3901
+ const MEMORY_CONTEXT_FIELD_LABELS = [
3902
+ "Fact",
3903
+ "Decision",
3904
+ "Why",
3905
+ "Rationale",
3906
+ "Because",
3907
+ "When",
3908
+ "Trigger",
3909
+ "Action",
3910
+ "Do",
3911
+ "Use",
3912
+ "Verified by",
3913
+ "Verification",
3914
+ "Evidence",
3915
+ "Risk if forgotten",
3916
+ "Risk",
3917
+ "Stale when",
3918
+ "Invalid when",
3919
+ "Revisit when",
3920
+ "Rejected alternatives",
3921
+ ];
3922
+ function labeledMemoryField(text, labels) {
3923
+ const escaped = labels.map((label) => label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
3924
+ const allLabels = MEMORY_CONTEXT_FIELD_LABELS.map((label) => label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
3925
+ const match = text.match(new RegExp(`(?:^|\\n|\\b)(?:${escaped})\\s*:\\s*([\\s\\S]*?)(?=(?:\\s|\\n)+(?:${allLabels})\\s*:|$)`, "i"));
3926
+ return match?.[1]?.trim().replace(/\s+$/, "");
3927
+ }
3928
+ function inferEngineeringContext(input) {
3929
+ const body = input.body.trim();
3930
+ const firstParagraph = body
3931
+ .split(/\n\s*\n/)
3932
+ .find((part) => !/^\s*(why|verified by|verification|risk if forgotten|stale when|trigger|action|rejected alternatives)\s*:/i.test(part))
3933
+ ?.trim();
3934
+ const context = {
3935
+ fact: input.context?.fact ?? labeledMemoryField(body, ["Fact", "Decision"]) ?? firstParagraph ?? input.title,
3936
+ why: input.context?.why ?? labeledMemoryField(body, ["Why", "Rationale", "Because"]),
3937
+ trigger: input.context?.trigger ?? labeledMemoryField(body, ["When", "Trigger"]),
3938
+ action: input.context?.action ?? labeledMemoryField(body, ["Action", "Do", "Use"]),
3939
+ verification: input.context?.verification ?? labeledMemoryField(body, ["Verified by", "Verification", "Evidence"]),
3940
+ risk_if_forgotten: input.context?.risk_if_forgotten ?? labeledMemoryField(body, ["Risk if forgotten", "Risk"]),
3941
+ stale_when: input.context?.stale_when ?? labeledMemoryField(body, ["Stale when", "Invalid when", "Revisit when"]),
3942
+ rejected_alternatives: input.context?.rejected_alternatives,
3943
+ };
3944
+ return Object.fromEntries(Object.entries(context).filter(([, value]) => Array.isArray(value) ? value.length : Boolean(value)));
3945
+ }
3946
+ function engineeringContextFor(packet) {
3947
+ return inferEngineeringContext({ title: packet.title, body: packet.body, context: packet.context });
3948
+ }
3949
+ function hasStructuredEngineeringContext(packet) {
3950
+ const context = engineeringContextFor(packet);
3951
+ return Boolean(context.why || context.verification || context.risk_if_forgotten || context.stale_when || context.trigger || context.action);
3952
+ }
3315
3953
  function learn(input) {
3316
3954
  const type = inferLearningType(input);
3317
3955
  const title = input.title?.trim() || titleFromLearning(input.learning);
@@ -3329,6 +3967,7 @@ function learn(input) {
3329
3967
  tags: unique(["session-learning", ...(input.tags ?? [])]),
3330
3968
  paths: input.paths,
3331
3969
  stack: input.stack,
3970
+ context: input.context,
3332
3971
  });
3333
3972
  }
3334
3973
  function capture(input) {
@@ -3366,6 +4005,7 @@ function capture(input) {
3366
4005
  captured_at: createdAt,
3367
4006
  },
3368
4007
  ],
4008
+ context: inferEngineeringContext({ title: input.title, body: input.body, context: input.context }),
3369
4009
  freshness: {
3370
4010
  ttl_days: 365,
3371
4011
  last_verified_at: createdAt,
@@ -3903,6 +4543,15 @@ function reusableFileObservation(event) {
3903
4543
  "dispatch",
3904
4544
  "convention",
3905
4545
  "decision",
4546
+ "rationale",
4547
+ "root cause",
4548
+ "issue",
4549
+ "hypothesis",
4550
+ "unresolved",
4551
+ "code explanation",
4552
+ "explains",
4553
+ "data flow",
4554
+ "invariant",
3906
4555
  "gotcha",
3907
4556
  "workflow",
3908
4557
  "runbook",
@@ -3974,9 +4623,20 @@ function reusablePromptObservation(event) {
3974
4623
  "convention",
3975
4624
  "policy",
3976
4625
  "gotcha",
4626
+ "bug",
4627
+ "issue",
4628
+ "issue context",
4629
+ "hypothesis",
4630
+ "unresolved",
4631
+ "rationale",
4632
+ "why:",
4633
+ "because",
4634
+ "code explanation",
4635
+ "explains",
4636
+ "data flow",
4637
+ "root cause",
3977
4638
  "runbook",
3978
4639
  "workflow",
3979
- "root cause",
3980
4640
  "use this",
3981
4641
  "always",
3982
4642
  "never",
@@ -3985,7 +4645,7 @@ function reusablePromptObservation(event) {
3985
4645
  ];
3986
4646
  if (!durableSignals.some((signal) => lower.includes(signal)))
3987
4647
  return "";
3988
- if (/^(fix|build|create|implement|update|continue|show me|what is|why is|can you)\b/i.test(text) && !/(decision|convention|policy|gotcha|remember|prefer|avoid)/i.test(text))
4648
+ if (/^(fix|build|create|implement|update|continue|show me|what is|why is|can you)\b/i.test(text) && !/(decision|convention|policy|gotcha|remember|prefer|avoid|bug|issue|hypothesis|rationale|because|root cause|code explanation|explains)/i.test(text))
3989
4649
  return "";
3990
4650
  return text;
3991
4651
  }
@@ -4112,7 +4772,8 @@ function createDiffChangeMemory(projectDir, summary) {
4112
4772
  "",
4113
4773
  "Improve this packet when more context is known:",
4114
4774
  "- The actual feature, fix, or refactor rationale.",
4115
- "- The package, API, command, or architectural pattern future agents should reuse.",
4775
+ "- Why the change was made, including relevant bugs, issues, decisions, and code explanations.",
4776
+ "- The package, API, command, or architectural pattern future agents should understand, verify, or reuse.",
4116
4777
  "- Any gotchas, follow-up risks, or branch-specific assumptions.",
4117
4778
  "",
4118
4779
  "Promote beyond this repo only after explicit org/global review.",
@@ -4143,6 +4804,15 @@ function createDiffChangeMemory(projectDir, summary) {
4143
4804
  summary_path: (0, node_path_1.join)(reviewDir(projectDir), `branch-summary-${slugify(branch)}.json`),
4144
4805
  },
4145
4806
  ],
4807
+ context: {
4808
+ fact: `Current branch ${branch} changes ${summary.changed_files.length} repo path${summary.changed_files.length === 1 ? "" : "s"}.`,
4809
+ why: "Branch change memory gives future agents durable context from the git diff when they continue, review, or verify this work.",
4810
+ trigger: "Recall when asking what changed on this branch, preparing a PR review, or resuming this work.",
4811
+ action: "Use the changed file list and diff summary as orientation, then inspect the actual diff and source files before making further edits.",
4812
+ verification: "Generated from git diff and refreshed by kage pr summarize or kage propose --from-diff.",
4813
+ risk_if_forgotten: "Future agents may repeat orientation work, miss branch-specific assumptions, or ignore files touched by this change.",
4814
+ stale_when: "The branch diff changes substantially, the branch is merged, or a newer change-memory packet supersedes it.",
4815
+ },
4146
4816
  freshness: {
4147
4817
  last_verified_at: now,
4148
4818
  ttl_days: 180,
@@ -4176,7 +4846,7 @@ function proposeFromDiff(projectDir) {
4176
4846
  const changedFiles = parsePorcelainStatus(status);
4177
4847
  if (changedFiles.length === 0)
4178
4848
  return { ok: false, changedFiles: [], errors: ["No changed files found."] };
4179
- const stat = readGit(projectDir, ["diff", "--stat"]) || "Untracked or staged files changed; inspect git status for details.";
4849
+ const stat = branchDiffStat(projectDir, changedFiles);
4180
4850
  const branch = gitBranch(projectDir);
4181
4851
  const summary = {
4182
4852
  schema_version: 1,
@@ -4435,9 +5105,10 @@ function loadOrgInboxPackets(projectDir, org) {
4435
5105
  }
4436
5106
  function recallFromPackets(query, packets, limit, label) {
4437
5107
  const terms = tokenize(query);
5108
+ const lexicalScores = scorePacketsBm25(terms, packets);
4438
5109
  const scored = packets
4439
5110
  .map((packet) => {
4440
- const { score, why } = scorePacket(terms, packet);
5111
+ const { score, why } = lexicalScores.get(packet.id) ?? { score: 0, why: [] };
4441
5112
  return { packet, score, why_matched: why };
4442
5113
  })
4443
5114
  .filter((result) => result.score > 0)