@kage-core/kage-graph-mcp 2.4.0 → 2.5.1

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
@@ -114,6 +114,7 @@ Usage:
114
114
  kage recall "<query>" --project <dir> [--json] [--explain] [--embeddings] [--docs] [--max-context-tokens <n>] [--structural-hops <n>]
115
115
  kage docs-search "<query>" --project <dir> [--limit <n>] [--json] search this repo's own committed docs (README, docs/**, *.md)
116
116
  kage file-context --project <dir> --path <file> [--json]
117
+ kage prompt-context --project <dir> --query "<task>" [--json] recall + savings receipt for an ambient prompt hook
117
118
  kage observe --project <dir> --event <json>
118
119
  kage sessions --project <dir> [--json]
119
120
  kage replay --project <dir> [--session <id>] [--limit <n>] [--json]
@@ -314,9 +315,22 @@ async function main() {
314
315
  console.log(" npx -y kage-graph-mcp scan --project /path/to/repo");
315
316
  process.exit(2);
316
317
  }
318
+ // scan is read-only by promise ("nothing generated"): if the repo has no Kage memory
319
+ // yet — e.g. a one-off scan of a repo you don't own — don't leave a .agent_memory/
320
+ // tree behind. Remove what the graph build created, but only if it wasn't there before.
321
+ const hadMemory = (0, node_fs_1.existsSync)((0, node_path_1.join)(scanTarget, ".agent_memory"));
322
+ const cleanupScanArtifacts = () => {
323
+ if (hadMemory)
324
+ return;
325
+ try {
326
+ (0, node_fs_1.rmSync)((0, node_path_1.join)(scanTarget, ".agent_memory"), { recursive: true, force: true });
327
+ }
328
+ catch { }
329
+ };
317
330
  const result = (0, kernel_js_1.truthReport)(scanTarget);
318
331
  if (args.includes("--json")) {
319
332
  console.log(JSON.stringify(result, null, 2));
333
+ cleanupScanArtifacts();
320
334
  return;
321
335
  }
322
336
  if (args.includes("--scorecard")) {
@@ -329,6 +343,7 @@ async function main() {
329
343
  console.log(`Scorecard written to ${outPath}`);
330
344
  console.log("Share it: embed the SVG in a README, screenshot it, or post it.\n");
331
345
  console.log((0, kernel_js_1.truthScorecardMarkdown)(result));
346
+ cleanupScanArtifacts();
332
347
  return;
333
348
  }
334
349
  console.log(`Kage Truth Report — ${result.project_dir}`);
@@ -381,6 +396,7 @@ async function main() {
381
396
  console.log(result.findings.length ? "Fix the void:" : "Next:");
382
397
  for (const action of result.next_actions)
383
398
  console.log(` ${action}`);
399
+ cleanupScanArtifacts();
384
400
  return;
385
401
  }
386
402
  if (command === "demo") {
@@ -1388,6 +1404,30 @@ async function main() {
1388
1404
  // No verified packets cite this file: print nothing so hooks can gate on empty output.
1389
1405
  return;
1390
1406
  }
1407
+ if (command === "prompt-context") {
1408
+ // Top-of-task recall for an ambient UserPromptSubmit hook: recall on the user's prompt
1409
+ // and emit the memory PLUS a one-line savings receipt (what was recalled + tokens saved).
1410
+ // Silent when nothing relevant is found, so hooks can gate on empty output.
1411
+ const query = takeArg(args, "--query") ?? firstPositional(args);
1412
+ if (!query)
1413
+ usage();
1414
+ const result = (0, kernel_js_1.recall)(projectArg(args), query, 5, false, {});
1415
+ if (args.includes("--json")) {
1416
+ console.log(JSON.stringify(result, null, 2));
1417
+ return;
1418
+ }
1419
+ if (!result.results.length)
1420
+ return;
1421
+ let out = result.context_block;
1422
+ // Lead with felt behavior, not a vanity/gameable token number: tell the agent these are
1423
+ // verified team memories to follow. (The tokens/$ ledger lives in `kage gains` for anyone
1424
+ // who wants it.) Keep only the trust-relevant stale-withheld signal here.
1425
+ const plural = result.results.length === 1 ? "y" : "ies";
1426
+ const withheld = result.value_receipt?.stale_withheld ?? 0;
1427
+ out += `\n\n_${result.results.length} verified team memor${plural} above — follow them.${withheld ? ` ${withheld} stale memor${withheld === 1 ? "y" : "ies"} withheld (code changed under them).` : ""}_`;
1428
+ console.log(out);
1429
+ return;
1430
+ }
1391
1431
  if (command === "memory-access") {
1392
1432
  const result = (0, kernel_js_1.kageMemoryAccess)(projectArg(args));
1393
1433
  if (args.includes("--json")) {
package/dist/index.js CHANGED
@@ -1061,10 +1061,11 @@ async function callTool(name, args) {
1061
1061
  const validationText = validation.ok
1062
1062
  ? "Memory healthy."
1063
1063
  : `Warnings: ${validation.warnings.join("; ")}`;
1064
- // recall (memory + code graph + knowledge graph combined)
1064
+ // recall already includes the code graph + knowledge-graph facts (its "## Related Graph
1065
+ // Facts" section). We deliberately do NOT query the graph a second time here: doing so
1066
+ // emitted a near-duplicate dump of the same edges which, with no size cap, blew
1067
+ // kage_context past 270k chars and overflowed the response.
1065
1068
  const recallResult = (0, kernel_js_1.recall)(projectDir, query, limit, false);
1066
- // graph facts on top of recall
1067
- const graphResult = (0, kernel_js_1.queryGraph)(projectDir, query, 5);
1068
1069
  const explicitTargets = [...arrayArg(args?.targets), ...filePathHints(query)];
1069
1070
  const changedFiles = arrayArg(args?.changed_files);
1070
1071
  const riskResult = explicitTargets.length || changedFiles.length ? (0, kernel_js_1.kageRisk)(projectDir, explicitTargets, changedFiles) : null;
@@ -1087,23 +1088,27 @@ async function callTool(name, args) {
1087
1088
  const learningLedger = typeof args?.session_id === "string" && args.session_id.trim()
1088
1089
  ? (0, kernel_js_1.kageSessionLearningLedger)(projectDir, { sessionId: args.session_id, limit: 20 })
1089
1090
  : null;
1090
- const sections = [
1091
+ const body = [
1091
1092
  recallResult.context_block,
1092
1093
  teammateBrief.context_block,
1093
1094
  learningLedger ? learningLedger.context_block : "",
1094
- graphResult.context_block ? `\n## Graph Facts\n${graphResult.context_block}` : "",
1095
1095
  riskResult ? riskContextBlock(riskResult) : "",
1096
1096
  dependencyResult ? `\n## Dependency Path\n${dependencyResult.summary}${dependencyResult.path.length ? `\nPath: ${dependencyResult.path.join(" -> ")}` : ""}` : "",
1097
1097
  reconciliation.unresolved_count ? `\n## Memory Reconciliation\n${reconciliation.agent_instruction}` : "",
1098
1098
  `\n_${validationText}_`,
1099
- // Visible receipt: surface what the harness saved today so agents relay it.
1100
- (() => {
1101
- const gains = (0, kernel_js_1.valueSummary)(projectDir).today;
1102
- return `\n\nGains: ~${(0, kernel_js_1.formatTokenCount)(gains.tokens_saved)} tokens saved this session · stale memories withheld: ${gains.stale_withheld}`;
1103
- })(),
1104
1099
  ].filter(Boolean).join("");
1105
- return {
1106
- content: [{ type: "text", text: sections }],
1100
+ // Visible receipt: surface what the harness saved today so agents relay it. Kept
1101
+ // outside the size cap so it always survives.
1102
+ const gains = (0, kernel_js_1.valueSummary)(projectDir).today;
1103
+ const gainsLine = `\n\nGains: ~${(0, kernel_js_1.formatTokenCount)(gains.tokens_saved)} tokens saved this session · stale memories withheld: ${gains.stale_withheld}`;
1104
+ // Backstop: per-field clamping + graph dedup keep this compact in practice, but never
1105
+ // let a pathological repo overflow the MCP response again. ~24k chars ≈ 6k tokens.
1106
+ const MAX_CONTEXT_CHARS = 24000;
1107
+ const cappedBody = body.length > MAX_CONTEXT_CHARS
1108
+ ? `${body.slice(0, MAX_CONTEXT_CHARS)}\n\n_…kage_context truncated to keep the response within limits; narrow your query for more specific memory._`
1109
+ : body;
1110
+ return {
1111
+ content: [{ type: "text", text: `${cappedBody}${gainsLine}` }],
1107
1112
  };
1108
1113
  }
1109
1114
  if (name === "kage_recall") {
package/dist/kernel.js CHANGED
@@ -3202,6 +3202,14 @@ const MAX_CODE_GRAPH_CALLS_PER_FILE = positiveIntEnv("KAGE_MAX_CODE_GRAPH_CALLS_
3202
3202
  const MAX_STRUCTURAL_EXTRACT_FILE_BYTES = positiveIntEnv("KAGE_MAX_STRUCTURAL_EXTRACT_FILE_BYTES", MAX_CODE_FILE_BYTES);
3203
3203
  const MAX_STRUCTURAL_WORKERS = positiveIntEnv("KAGE_STRUCTURAL_WORKERS", Math.max(1, Math.min(8, (0, node_os_1.availableParallelism)() - 1)));
3204
3204
  const MIN_STRUCTURAL_PARALLEL_FILES = positiveIntEnv("KAGE_STRUCTURAL_PARALLEL_MIN_FILES", 64);
3205
+ // Hard ceiling on indexable files a single scan will parse, so a very large monorepo
3206
+ // degrades to a bounded sample instead of an unbounded (and effectively quadratic) parse.
3207
+ // The skipped count is recorded in the scan's ignoredSummary as "exceeded_file_cap".
3208
+ const MAX_SCAN_FILES = positiveIntEnv("KAGE_MAX_SCAN_FILES", 25000);
3209
+ // Bound the single full-history git-log pass in truthReport to the most recent N commits.
3210
+ // Covers virtually every repo fully while preventing an unbounded log + buffer on a
3211
+ // deep-history monorepo (Linux/Chromium-class).
3212
+ const TRUTH_REPORT_MAX_COMMITS = positiveIntEnv("KAGE_SCAN_MAX_COMMITS", 8000);
3205
3213
  const CONFIG_NAMES = new Set([
3206
3214
  "package.json",
3207
3215
  "pyproject.toml",
@@ -3724,6 +3732,10 @@ function scanStructuralFiles(projectDir) {
3724
3732
  ignore("unsupported_file_type");
3725
3733
  continue;
3726
3734
  }
3735
+ if (files.length >= MAX_SCAN_FILES) {
3736
+ ignore("exceeded_file_cap");
3737
+ continue;
3738
+ }
3727
3739
  files.push(absolutePath);
3728
3740
  }
3729
3741
  };
@@ -3736,11 +3748,27 @@ function scanStructuralFiles(projectDir) {
3736
3748
  function countBufferLines(buffer) {
3737
3749
  if (buffer.length === 0)
3738
3750
  return 0;
3739
- let lines = 1;
3751
+ // Count newline bytes (matches `wc -l` for newline-terminated files) and add one for a
3752
+ // final line without a trailing newline, so the count is not inflated by +1.
3753
+ let lines = 0;
3740
3754
  for (const byte of buffer) {
3741
3755
  if (byte === 10)
3742
3756
  lines += 1;
3743
3757
  }
3758
+ if (buffer[buffer.length - 1] !== 10)
3759
+ lines += 1;
3760
+ return lines;
3761
+ }
3762
+ function countTextLines(text) {
3763
+ if (text.length === 0)
3764
+ return 0;
3765
+ let lines = 0;
3766
+ for (let i = 0; i < text.length; i += 1) {
3767
+ if (text.charCodeAt(i) === 10)
3768
+ lines += 1;
3769
+ }
3770
+ if (text.charCodeAt(text.length - 1) !== 10)
3771
+ lines += 1;
3744
3772
  return lines;
3745
3773
  }
3746
3774
  function structuralConcepts(rel, symbols) {
@@ -4011,7 +4039,7 @@ function buildStructuralFile(projectDir, absolutePath, knownFiles, prior) {
4011
4039
  language,
4012
4040
  kind: codeFileKind(rel),
4013
4041
  size_bytes: stats.size,
4014
- line_count: content ? content.split(/\r?\n/).length : countBufferLines(buffer ?? (0, node_fs_1.readFileSync)(absolutePath)),
4042
+ line_count: content ? countTextLines(content) : countBufferLines(buffer ?? (0, node_fs_1.readFileSync)(absolutePath)),
4015
4043
  hash: hash.slice(0, 16),
4016
4044
  mtime_ms: stats.mtimeMs,
4017
4045
  extraction: entry.extraction,
@@ -5786,11 +5814,15 @@ function buildCodeGraph(projectDir, options = {}) {
5786
5814
  if (file)
5787
5815
  file.parser = strongerParser(file.parser, symbol.parser);
5788
5816
  }
5817
+ const symbolById = new Map();
5818
+ for (const symbol of symbols)
5819
+ if (!symbolById.has(symbol.id))
5820
+ symbolById.set(symbol.id, symbol);
5789
5821
  const addSymbol = (symbol) => {
5790
5822
  if (!fileByPath.has(symbol.path))
5791
5823
  return;
5792
5824
  const file = fileByPath.get(symbol.path);
5793
- const existing = symbols.find((candidate) => candidate.id === symbol.id);
5825
+ const existing = symbolById.get(symbol.id);
5794
5826
  if (existing) {
5795
5827
  existing.parser = strongerParser(existing.parser, symbol.parser);
5796
5828
  if (file)
@@ -5800,6 +5832,7 @@ function buildCodeGraph(projectDir, options = {}) {
5800
5832
  if (file)
5801
5833
  file.parser = strongerParser(file.parser, symbol.parser);
5802
5834
  symbols.push(symbol);
5835
+ symbolById.set(symbol.id, symbol);
5803
5836
  };
5804
5837
  for (const symbol of externalFacts.symbols)
5805
5838
  addSymbol(symbol);
@@ -5818,14 +5851,29 @@ function buildCodeGraph(projectDir, options = {}) {
5818
5851
  list.push(symbol);
5819
5852
  symbolByName.set(symbol.name, list);
5820
5853
  }
5854
+ // Index symbols and imports by file once, so the per-file loop below is an O(1) lookup
5855
+ // instead of an O(files × symbols) / O(files × imports) scan over the global arrays —
5856
+ // the dominant cost on large monorepos.
5857
+ const symbolsByPath = new Map();
5858
+ for (const symbol of symbols) {
5859
+ const list = symbolsByPath.get(symbol.path) ?? [];
5860
+ list.push(symbol);
5861
+ symbolsByPath.set(symbol.path, list);
5862
+ }
5863
+ const importsByFromPath = new Map();
5864
+ for (const item of imports) {
5865
+ const list = importsByFromPath.get(item.from_path) ?? [];
5866
+ list.push(item);
5867
+ importsByFromPath.set(item.from_path, list);
5868
+ }
5821
5869
  const calls = [];
5822
5870
  const routes = [];
5823
5871
  const tests = [];
5824
5872
  for (const [rel, content] of contents) {
5825
5873
  if (calls.length >= MAX_CODE_GRAPH_CALLS)
5826
5874
  break;
5827
- const fileSymbols = symbols.filter((symbol) => symbol.path === rel);
5828
- const fileImports = imports.filter((item) => item.from_path === rel);
5875
+ const fileSymbols = symbolsByPath.get(rel) ?? [];
5876
+ const fileImports = importsByFromPath.get(rel) ?? [];
5829
5877
  const importedNames = new Map();
5830
5878
  for (const item of fileImports) {
5831
5879
  for (const importedName of item.imported) {
@@ -6950,6 +6998,15 @@ function gcProject(projectDir, options = {}) {
6950
6998
  skipped.push({ id: packet.id, title: packet.title, reason: "already deprecated" });
6951
6999
  continue;
6952
7000
  }
7001
+ // Serialized transcript / tool-output / file-content dumps carry no durable knowledge
7002
+ // and bloat recall + the graph. Always delete them (deprecating would leave the blob on
7003
+ // disk) — this also reclaims legacy dumps written before the capture-time guard existed.
7004
+ if (isSerializedDumpTitle(packet.title) || isSerializedDumpBody(packet.body)) {
7005
+ if (!options.dryRun)
7006
+ (0, node_fs_1.unlinkSync)(path);
7007
+ deleted.push({ id: packet.id, title: packet.title });
7008
+ continue;
7009
+ }
6953
7010
  const reasons = staleMemoryReasons(projectDir, packet);
6954
7011
  if (!reasons.length) {
6955
7012
  skipped.push({ id: packet.id, title: packet.title, reason: "healthy" });
@@ -7820,7 +7877,12 @@ function recallBreakdown(projectDir, terms, packet, textScore, temporalScore = 0
7820
7877
  const vector = Number(vectorScore.toFixed(2));
7821
7878
  const usage = Number(usageScore.toFixed(2));
7822
7879
  const pathTypeTagWeight = packet.type === "reference" ? 0.2 : 0.8;
7823
- const final = Number((textScore + graphScore + pathTypeTag * pathTypeTagWeight + intent + vector + usage + freshness + quality + feedback).toFixed(2));
7880
+ // Popularity (usage) must only AMPLIFY genuine relevance, never float a packet that has no
7881
+ // lexical/semantic/graph/intent match to the top — that is what produced confident
7882
+ // off-domain junk (a hot packet ranked #1 for a query it shared no terms with).
7883
+ const coreRelevance = textScore + graphScore + intent + vector;
7884
+ const effectiveUsage = coreRelevance > 0 ? usage : 0;
7885
+ const final = Number((textScore + graphScore + pathTypeTag * pathTypeTagWeight + intent + vector + effectiveUsage + freshness + quality + feedback).toFixed(2));
7824
7886
  return {
7825
7887
  bm25: textScore,
7826
7888
  text: textScore,
@@ -7872,6 +7934,50 @@ function diversifyRecallEntries(entries, limit, maxPerSource = 3) {
7872
7934
  }
7873
7935
  return selected.slice(0, limit);
7874
7936
  }
7937
+ // Raw transcript / serialized tool-output packets are capture noise, not knowledge. Keep
7938
+ // them out of recall so they can never outrank real memory. This is the recall-side safety
7939
+ // net; the durable fixes are a capture-time guard and pruning the existing ones (`kage prune`).
7940
+ function isSerializedDumpTitle(title) {
7941
+ const t = (title ?? "").trimStart();
7942
+ return /^(workflow|runbook)\s*:?\s*[{[]/i.test(t)
7943
+ || t.startsWith('{"')
7944
+ || /^<(task-notification|div|svg|html)\b/i.test(t)
7945
+ || /\btool_use_id\b|toolu_[A-Za-z0-9]{10}/.test(title);
7946
+ }
7947
+ // Durable-learning size ceiling. A memory packet body is a distilled insight, not a
7948
+ // document; anything past this is almost certainly a raw transcript, file-content, or
7949
+ // tool-output dump. Env-overridable for unusual repos.
7950
+ const MAX_PACKET_BODY_CHARS = positiveIntEnv("KAGE_MAX_PACKET_BODY_CHARS", 16000);
7951
+ // Body-level counterpart to isSerializedDumpTitle: catches raw transcript, serialized
7952
+ // tool-output, or file-content dumps that arrive as a packet/edge body even when the
7953
+ // title was massaged into something innocuous (e.g. a shell-prompt paste). The byte cap
7954
+ // alone catches the rest — a 300KB "learning" is never knowledge.
7955
+ function isSerializedDumpBody(body) {
7956
+ const t = (body ?? "").trimStart();
7957
+ if (!t)
7958
+ return false;
7959
+ if (t.length > MAX_PACKET_BODY_CHARS)
7960
+ return true;
7961
+ return isSerializedDumpTitle(t)
7962
+ || /<task-notification\b|<tool-use-id\b|"hookSpecificOutput"|"isImage"\s*:|"noOutputExpected"\s*:|"interrupted"\s*:\s*(true|false)/i.test(t.slice(0, 4000));
7963
+ }
7964
+ // Collapse whitespace and hard-cap a value rendered inline in a context block, so one
7965
+ // oversized field (e.g. a graph fact whose body is a raw transcript) can never blow up
7966
+ // the assembled output — the 270k-char overflow that motivated this guard.
7967
+ function clampInline(text, max = 280) {
7968
+ const oneLine = (text ?? "").replace(/\s+/g, " ").trim();
7969
+ if (oneLine.length <= max)
7970
+ return oneLine;
7971
+ return `${oneLine.slice(0, max)}… [+${oneLine.length - max} chars truncated]`;
7972
+ }
7973
+ // Like clampInline but preserves newlines — for multi-line blocks (git diff stats, packet
7974
+ // bodies shown in diagnostics) where line structure carries meaning.
7975
+ function clampBlock(text, max) {
7976
+ const t = (text ?? "").trim();
7977
+ if (t.length <= max)
7978
+ return t;
7979
+ return `${t.slice(0, max)}\n… [+${t.length - max} chars truncated]`;
7980
+ }
7875
7981
  function recallWithVectorScores(projectDir, query, limit = 5, explain = false, inputs = {}, externalVectorScores) {
7876
7982
  const current = inputs.codeGraph && inputs.knowledgeGraph ? null : readCurrentGraphs(projectDir);
7877
7983
  const detailedIndex = inputs.codeGraph && inputs.knowledgeGraph || current ? null : indexProjectDetailed(projectDir);
@@ -7940,7 +8046,7 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
7940
8046
  ];
7941
8047
  return { packet, score: score_breakdown.final, relevance, why_matched: unique(why).slice(0, 12), score_breakdown };
7942
8048
  })
7943
- .filter((entry) => entry.relevance > 0)
8049
+ .filter((entry) => entry.relevance > 0 && !isSerializedDumpTitle(entry.packet.title))
7944
8050
  .sort((a, b) => b.score - a.score || a.packet.title.localeCompare(b.packet.title));
7945
8051
  const scored = diversifyRecallEntries(rankedScored, limit)
7946
8052
  .map(({ relevance, ...entry }) => entry);
@@ -7996,14 +8102,23 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
7996
8102
  ...scored.flatMap((entry, index) => {
7997
8103
  const contradicts = (entry.packet.quality ?? {}).contradicts;
7998
8104
  const contested = Array.isArray(contradicts) && contradicts.length > 0;
8105
+ // Felt format: lead with an imperative "Team memory:" claim the agent should follow,
8106
+ // dated and cited to file — not machinery (confidence/why-matched/source read as vanity
8107
+ // and noise). The behavior change is the value, not the metadata.
8108
+ const when = (entry.packet.created_at || entry.packet.updated_at || "").slice(0, 10);
8109
+ const verb = entry.packet.type === "decision" ? "decided"
8110
+ : entry.packet.type === "bug_fix" ? "fixed"
8111
+ : entry.packet.type === "convention" ? "convention since"
8112
+ : "noted";
8113
+ const cited = entry.packet.paths.slice(0, 3).join(", ");
8114
+ const meta = `${verb}${when ? ` ${when}` : ""}${cited ? ` · ${cited}` : ""}`;
7999
8115
  return [
8000
8116
  "",
8001
- `${index + 1}. [${entry.packet.type} | ${entry.packet.scope} | confidence ${entry.packet.confidence.toFixed(2)}] ${entry.packet.title}`,
8002
- ` Summary: ${entry.packet.summary}`,
8003
- ` Why matched: ${entry.why_matched.join(", ") || "text relevance"}`,
8004
- ` Source: ${sourceLabel(entry.packet)}`,
8117
+ `${index + 1}. Team memory: ${entry.packet.title}`,
8118
+ ` ${entry.packet.summary}`,
8119
+ ...(meta.trim() ? [` (${meta})`] : []),
8005
8120
  ...(contested
8006
- ? [` ⚠ Contested: this memory contradicts ${contradicts.length} other packet(s) (${contradicts.join(", ")}). Resolve with kage conflicts / kage supersede before relying on it.`]
8121
+ ? [` ⚠ Contested: contradicts ${contradicts.length} other packet(s) (${contradicts.join(", ")}) resolve with kage conflicts / kage supersede before relying on it.`]
8007
8122
  : []),
8008
8123
  ];
8009
8124
  }),
@@ -8018,7 +8133,7 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
8018
8133
  ]),
8019
8134
  "",
8020
8135
  graphContext.edges.length ? "## Related Graph Facts" : "",
8021
- ...graphContext.edges.slice(0, 5).map((edge, index) => `${index + 1}. ${edge.fact} (evidence: ${edge.evidence.join(", ")})`),
8136
+ ...graphContext.edges.slice(0, 5).map((edge, index) => `${index + 1}. ${clampInline(edge.fact)} (evidence: ${clampInline(edge.evidence.join(", "), 200)})`),
8022
8137
  ...(suppressed.length
8023
8138
  ? [
8024
8139
  "",
@@ -9133,6 +9248,13 @@ function kageCleanupCandidates(projectDir) {
9133
9248
  }
9134
9249
  const TRUTH_REPORT_MAX_FINDINGS = 16;
9135
9250
  const TRUTH_REPORT_AI_ERA_DAYS = 120;
9251
+ // A same-name, same-signature symbol spread across more than this many directories is a
9252
+ // framework/language convention (e.g. Go's per-package addKnownTypes / AddToScheme), not
9253
+ // copy-paste worth surfacing. Real, actionable duplication is a handful of sites.
9254
+ const TRUTH_DUPLICATE_MAX_DIRS = 8;
9255
+ // A directory whose last segment is an API version (v1, v2, v1beta1, v2alpha3, ...). The
9256
+ // same type redefined across sibling version packages is API versioning, not duplication.
9257
+ const TRUTH_VERSION_DIR = /(^|\/)v\d+((alpha|beta)\d+)?$/i;
9136
9258
  // Symbol names too generic to mean "two teams built the same thing".
9137
9259
  const TRUTH_COMMON_SYMBOL_NAMES = new Set([
9138
9260
  "main", "init", "run", "setup", "start", "stop", "open", "close", "create", "destroy",
@@ -9146,10 +9268,24 @@ const TRUTH_DUPLICATE_NAME_DENYLIST = new Set([
9146
9268
  "decorator", "wrapper", "inner", "callback", "wrapped", "fn", "cb", "noop",
9147
9269
  "predicate", "comparator", "getter", "setter", "factory", "visit",
9148
9270
  ]);
9271
+ // Machine-generated code (protobuf, conversion/deepcopy codegen, mocks, minified bundles)
9272
+ // is not where a human's undocumented knowledge lives — flagging it as a hotspot, duplicate,
9273
+ // or ghost export is pure noise. Detected by the conventional names generators emit.
9274
+ function isGeneratedPath(path) {
9275
+ const p = path.toLowerCase();
9276
+ return /(^|[/._-])(zz_generated|generated|autogen|codegen)[._-]/.test(p)
9277
+ || /(^|\/)(generated|__generated__)\//.test(p)
9278
+ || /\.pb\.(go|cc|h|ts|js|py|rb|dart|swift)$/.test(p)
9279
+ || /[._](pb2|pb2_grpc)\.py$/.test(p)
9280
+ || /\.(gen|g|freezed)\.[^.]+$/.test(p)
9281
+ || /(^|\/)(mock_[^/]*|wire_gen)\.[^.]+$/.test(p)
9282
+ || /\.min\.(js|css)$/.test(p);
9283
+ }
9149
9284
  function truthExcludedPath(path) {
9150
9285
  return /(^|\/)(tests?|__tests__|specs?|examples?|fixtures?|benchmarks?|mocks?|__mocks__|vendor|node_modules|dist|build)\//i.test(path)
9151
9286
  || /\.(test|spec)\.[^.]+$/i.test(path)
9152
- || /(^|\/)test[^/]*\.[^.]+$/i.test(path);
9287
+ || /(^|\/)test[^/]*\.[^.]+$/i.test(path)
9288
+ || isGeneratedPath(path);
9153
9289
  }
9154
9290
  const TRUTH_DOC_PATH_EXTENSIONS = "ts|tsx|js|jsx|mjs|cjs|json|md|yml|yaml|toml|py|rb|go|rs|java|kt|sh|bash|css|scss|html|sql|proto|graphql|c|h|cpp|hpp|cs|txt";
9155
9291
  function truthDocPathCandidates(line) {
@@ -9167,6 +9303,117 @@ function truthDocPathCandidates(line) {
9167
9303
  && !candidate.includes("node_modules")
9168
9304
  && !candidate.startsWith(".agent_memory"));
9169
9305
  }
9306
+ const TRUTH_DECL_KEYWORDS = new Set([
9307
+ "function", "func", "fn", "def", "class", "struct", "trait", "interface",
9308
+ "type", "enum", "const", "let", "var", "impl", "module", "object",
9309
+ ]);
9310
+ // Render "<path>:<line> <kind> <signature>" without doubling the declaration keyword.
9311
+ // The captured signature often already declares the construct ("export function abort(...)",
9312
+ // "pub fn abort(...)", "class Foo"), so blindly prefixing the kind yields
9313
+ // "function export function abort". Only add the kind when the signature carries no
9314
+ // leading declaration keyword of its own.
9315
+ function truthSymbolEvidence(path, line, kind, signature) {
9316
+ const sig = signature.slice(0, 80).trim();
9317
+ const leadTokens = sig.toLowerCase().split(/[^a-z]+/, 4);
9318
+ const selfDescribing = leadTokens.some((tok) => TRUTH_DECL_KEYWORDS.has(tok))
9319
+ || sig.toLowerCase().startsWith(`${kind.toLowerCase()} `);
9320
+ return `${path}:${line} ${selfDescribing ? sig : `${kind} ${sig}`}`;
9321
+ }
9322
+ // Real test coverage beats the import-reachability heuristic. When a standard coverage
9323
+ // report exists we read measured line coverage; KAGE_COVERAGE_MIN (default 0.5) is the
9324
+ // fraction below which a hot file counts as under-tested.
9325
+ const COVERAGE_TESTED_MIN = (() => {
9326
+ const raw = Number(process.env.KAGE_COVERAGE_MIN);
9327
+ return Number.isFinite(raw) && raw > 0 && raw <= 1 ? raw : 0.5;
9328
+ })();
9329
+ function parseLcovCoverage(text) {
9330
+ const map = new Map();
9331
+ let file = null;
9332
+ let hit = 0;
9333
+ let total = 0;
9334
+ for (const raw of text.split(/\r?\n/)) {
9335
+ const line = raw.trim();
9336
+ if (line.startsWith("SF:")) {
9337
+ file = line.slice(3).trim();
9338
+ hit = 0;
9339
+ total = 0;
9340
+ }
9341
+ else if (line.startsWith("DA:")) {
9342
+ const count = Number(line.slice(3).split(",")[1] ?? 0) || 0;
9343
+ total += 1;
9344
+ if (count > 0)
9345
+ hit += 1;
9346
+ }
9347
+ else if (line.startsWith("LH:")) {
9348
+ hit = Number(line.slice(3)) || hit;
9349
+ }
9350
+ else if (line.startsWith("LF:")) {
9351
+ total = Number(line.slice(3)) || total;
9352
+ }
9353
+ else if (line === "end_of_record" && file) {
9354
+ map.set(file, { hit, total });
9355
+ file = null;
9356
+ }
9357
+ }
9358
+ return map;
9359
+ }
9360
+ function parseIstanbulCoverage(text) {
9361
+ const map = new Map();
9362
+ let json;
9363
+ try {
9364
+ json = JSON.parse(text);
9365
+ }
9366
+ catch {
9367
+ return map;
9368
+ }
9369
+ for (const [key, entry] of Object.entries(json)) {
9370
+ if (!entry || typeof entry !== "object")
9371
+ continue;
9372
+ const counts = Object.values(entry.s ?? {});
9373
+ if (!counts.length)
9374
+ continue;
9375
+ const hit = counts.filter((value) => Number(value) > 0).length;
9376
+ map.set(entry.path ?? key, { hit, total: counts.length });
9377
+ }
9378
+ return map;
9379
+ }
9380
+ function readCoverageReport(projectDir) {
9381
+ const candidates = ["coverage/lcov.info", "lcov.info", "coverage/coverage-final.json", "coverage-final.json"];
9382
+ const root = projectDir.replace(/\\/g, "/").replace(/\/+$/, "");
9383
+ for (const rel of candidates) {
9384
+ const abs = (0, node_path_1.join)(projectDir, rel);
9385
+ if (!(0, node_fs_1.existsSync)(abs))
9386
+ continue;
9387
+ const text = safeReadText(abs);
9388
+ if (!text)
9389
+ continue;
9390
+ const parsed = rel.endsWith(".info") ? parseLcovCoverage(text) : parseIstanbulCoverage(text);
9391
+ if (!parsed.size)
9392
+ continue;
9393
+ const byPath = new Map();
9394
+ for (const [rawPath, value] of parsed) {
9395
+ let p = rawPath.replace(/\\/g, "/");
9396
+ if (p.startsWith(`${root}/`))
9397
+ p = p.slice(root.length + 1);
9398
+ p = p.replace(/^\.\//, "").replace(/^\/+/, "");
9399
+ byPath.set(p, value);
9400
+ }
9401
+ return { source: rel, byPath, entries: [...byPath.entries()] };
9402
+ }
9403
+ return null;
9404
+ }
9405
+ // Coverage paths can carry an absolute or CI-machine prefix; fall back to suffix match.
9406
+ function coverageFor(report, path) {
9407
+ const direct = report.byPath.get(path);
9408
+ if (direct)
9409
+ return direct;
9410
+ const suffix = `/${path}`;
9411
+ for (const [key, value] of report.entries) {
9412
+ if (key.endsWith(suffix) || path.endsWith(`/${key}`))
9413
+ return value;
9414
+ }
9415
+ return null;
9416
+ }
9170
9417
  function truthReport(projectDir) {
9171
9418
  const graph = readCurrentCodeGraph(projectDir) ?? buildCodeGraph(projectDir);
9172
9419
  const warnings = [];
@@ -9183,12 +9430,13 @@ function truthReport(projectDir) {
9183
9430
  const fileCommits = new Map();
9184
9431
  const fileNewestEpoch = new Map();
9185
9432
  if (hasGit) {
9186
- const raw = readGit(projectDir, ["log", "--no-renames", "--format=__KAGE_SCAN__%x1f%ae%x1f%ct", "--name-only"]) ?? "";
9433
+ const raw = readGit(projectDir, ["log", `--max-count=${TRUTH_REPORT_MAX_COMMITS}`, "--no-renames", "--format=__KAGE_SCAN__%x1f%ae%x1f%ct", "--name-only"]) ?? "";
9187
9434
  // Resolve the repo->project prefix once; gitPathToProjectRelative spawns git per call,
9188
9435
  // which is far too slow for a full-history name-only walk.
9189
9436
  const projectPrefix = readGit(projectDir, ["rev-parse", "--show-prefix"])?.replace(/\\/g, "/").replace(/\/+$/, "") ?? "";
9190
9437
  let author = "";
9191
9438
  let epoch = 0;
9439
+ let commitsSeen = 0;
9192
9440
  for (const rawLine of raw.split(/\r?\n/)) {
9193
9441
  const line = rawLine.trim();
9194
9442
  if (!line)
@@ -9197,6 +9445,7 @@ function truthReport(projectDir) {
9197
9445
  const parts = line.split("\x1f");
9198
9446
  author = (parts[1] ?? "").toLowerCase();
9199
9447
  epoch = Number(parts[2] ?? 0) || 0;
9448
+ commitsSeen += 1;
9200
9449
  continue;
9201
9450
  }
9202
9451
  if (!author)
@@ -9213,6 +9462,9 @@ function truthReport(projectDir) {
9213
9462
  if (!fileNewestEpoch.has(path))
9214
9463
  fileNewestEpoch.set(path, epoch);
9215
9464
  }
9465
+ if (commitsSeen >= TRUTH_REPORT_MAX_COMMITS) {
9466
+ warnings.push(`Git history is large; churn, bus-factor, and recency are computed from the most recent ${TRUTH_REPORT_MAX_COMMITS} commits.`);
9467
+ }
9216
9468
  }
9217
9469
  // Centrality: import + call edges touching the file.
9218
9470
  const centrality = new Map();
@@ -9255,6 +9507,12 @@ function truthReport(projectDir) {
9255
9507
  const paths = new Set(members.map((member) => member.path));
9256
9508
  if (dirs.size < 2 || paths.size < 2)
9257
9509
  continue;
9510
+ if (dirs.size > TRUTH_DUPLICATE_MAX_DIRS)
9511
+ continue;
9512
+ // Skip the same symbol redefined across sibling API-version packages (v1/v1beta1/...).
9513
+ const versionedCount = members.filter((member) => TRUTH_VERSION_DIR.test((0, node_path_1.dirname)(member.path))).length;
9514
+ if (versionedCount >= 2 && members.length - versionedCount <= 1)
9515
+ continue;
9258
9516
  const signatureCounts = new Map();
9259
9517
  for (const member of members) {
9260
9518
  const normalized = member.signature.replace(/\s+/g, "");
@@ -9271,7 +9529,7 @@ function truthReport(projectDir) {
9271
9529
  kind: "duplicate_cluster",
9272
9530
  title: `${members[0].name} — ${paths.size} implementations across ${dirs.size} directories${recent ? " [recently changed]" : ""}`,
9273
9531
  detail: "Same name and near-identical signature in unrelated directories — likely parallel implementations of the same idea, worth a look.",
9274
- evidence: members.slice(0, 5).map((member) => `${member.path}:${member.line} ${member.kind} ${member.signature.slice(0, 80)}`),
9532
+ evidence: members.slice(0, 5).map((member) => truthSymbolEvidence(member.path, member.line, member.kind, member.signature)),
9275
9533
  surprise: Math.min(100, 45 + paths.size * 8 + (signatureMatch ? 15 : 0) + (recent ? 20 : 0)),
9276
9534
  });
9277
9535
  }
@@ -9325,7 +9583,7 @@ function truthReport(projectDir) {
9325
9583
  kind: "ghost_export",
9326
9584
  title: `${symbol.name} — exported, never called`,
9327
9585
  detail: "No call edge, no import, and the name appears in no other file. Dead code, or knowledge nobody wired in.",
9328
- evidence: [`${symbol.path}:${symbol.line} ${symbol.kind} ${symbol.signature.slice(0, 80)}`],
9586
+ evidence: [truthSymbolEvidence(symbol.path, symbol.line, symbol.kind, symbol.signature)],
9329
9587
  surprise: Math.min(100, 35 + Math.min(30, fileCentrality)),
9330
9588
  }];
9331
9589
  });
@@ -9445,8 +9703,9 @@ function truthReport(projectDir) {
9445
9703
  testedPaths.add(edge.to_path);
9446
9704
  }
9447
9705
  }
9706
+ const coverage = readCoverageReport(projectDir);
9448
9707
  const untestedFindings = [];
9449
- if (hasTests) {
9708
+ if (hasTests || coverage) {
9450
9709
  for (const file of sourceFiles) {
9451
9710
  if (isEntrypointLike(file.path))
9452
9711
  continue;
@@ -9456,7 +9715,26 @@ function truthReport(projectDir) {
9456
9715
  continue;
9457
9716
  if (hasGit && commits < 2)
9458
9717
  continue;
9459
- if (testedPaths.has(file.path))
9718
+ // Prefer measured line coverage when the file is in the report; only fall back to
9719
+ // the import-reachability heuristic for files the report doesn't cover.
9720
+ if (coverage) {
9721
+ const cov = coverageFor(coverage, file.path);
9722
+ if (cov && cov.total > 0) {
9723
+ const pct = cov.hit / cov.total;
9724
+ if (pct >= COVERAGE_TESTED_MIN)
9725
+ continue;
9726
+ const pctLabel = Math.round(pct * 100);
9727
+ untestedFindings.push({
9728
+ kind: "untested_hot",
9729
+ title: `${file.path} — undertested hot path`,
9730
+ detail: `Only ${pctLabel}% line coverage (${cov.hit}/${cov.total} lines, measured from ${coverage.source}) on a file ${fileCentrality} other(s) depend on${commits ? `, changed ${commits} time(s)` : ""}. Thinly-covered hub files are where regressions hide.`,
9731
+ evidence: [`${file.path}:1 ${pctLabel}% line coverage (${cov.hit}/${cov.total} lines), centrality ${fileCentrality}`],
9732
+ surprise: Math.min(100, 30 + Math.min(45, fileCentrality * 3) + (hasGit ? Math.min(15, commits) : 0) + Math.round((1 - pct) * 12)),
9733
+ });
9734
+ continue;
9735
+ }
9736
+ }
9737
+ if (!hasTests || testedPaths.has(file.path))
9460
9738
  continue;
9461
9739
  untestedFindings.push({
9462
9740
  kind: "untested_hot",
@@ -9467,6 +9745,12 @@ function truthReport(projectDir) {
9467
9745
  });
9468
9746
  }
9469
9747
  }
9748
+ if (coverage) {
9749
+ warnings.push(`Test coverage measured from ${coverage.source} (${coverage.byPath.size} files); files outside it fall back to static test-import heuristics.`);
9750
+ }
9751
+ else if (untestedFindings.length) {
9752
+ warnings.push(`Untested findings are heuristic (no coverage report found): they flag hot files no test imports directly, not measured line coverage. Generate coverage/lcov.info for exact results.`);
9753
+ }
9470
9754
  untestedFindings.sort((a, b) => b.surprise - a.surprise || a.title.localeCompare(b.title));
9471
9755
  // 1f. Complexity hotspots: very large source files many things depend on —
9472
9756
  // where knowledge concentrates and onboarding stalls.
@@ -9624,6 +9908,13 @@ function truthReport(projectDir) {
9624
9908
  ...(docLines.length ? [[docLieFindings.length, `${docLieFindings.length} doc lie${docLieFindings.length === 1 ? "" : "s"}`]] : []),
9625
9909
  ];
9626
9910
  const headlineParts = headlineCandidates.filter(([count]) => count > 0).map(([, label]) => label);
9911
+ // If the file-count ceiling kicked in, say so plainly — a silent "scanned 25,000 files"
9912
+ // on a 60k-file monorepo reads as "covered everything" when it didn't.
9913
+ const cappedFiles = readCodeIndexManifest(projectDir).ignored_summary?.["exceeded_file_cap"] ?? 0;
9914
+ if (cappedFiles > 0) {
9915
+ const totalIndexable = graph.files.length + cappedFiles;
9916
+ warnings.push(`Large repo: scanned ${graph.files.length.toLocaleString()} of ${totalIndexable.toLocaleString()} indexable files (capped). Set KAGE_MAX_SCAN_FILES higher to scan more.`);
9917
+ }
9627
9918
  return {
9628
9919
  schema_version: 1,
9629
9920
  project_dir: projectDir,
@@ -11868,7 +12159,9 @@ function queryGraph(projectDir, query, limit = 10, graph) {
11868
12159
  const temporalPenalty = edge.invalidated_at ? -4 : 0;
11869
12160
  return { edge, score: textScore + graphScore + evidenceScore + temporalPenalty };
11870
12161
  })
11871
- .filter((entry) => entry.score > 0)
12162
+ // Serialized transcript / tool-output / file-content dumps are capture noise, not
12163
+ // facts. Keep them out of the graph context so one raw edge can't dominate the output.
12164
+ .filter((entry) => entry.score > 0 && !isSerializedDumpBody(entry.edge.fact))
11872
12165
  .sort((a, b) => b.score - a.score || a.edge.fact.localeCompare(b.edge.fact))
11873
12166
  .slice(0, limit)
11874
12167
  .map((entry) => entry.edge);
@@ -11880,7 +12173,8 @@ function queryGraph(projectDir, query, limit = 10, graph) {
11880
12173
  `Query: ${query}`,
11881
12174
  "",
11882
12175
  edges.length ? "## Facts" : "No related graph facts found.",
11883
- ...edges.map((edge, index) => `${index + 1}. ${edge.fact}\n Relation: ${edge.relation}\n Evidence: ${edge.evidence.join(", ")}`),
12176
+ // Clamp every field: a fact is a one-liner, never a document.
12177
+ ...edges.map((edge, index) => `${index + 1}. ${clampInline(edge.fact)}\n Relation: ${clampInline(edge.relation, 80)}\n Evidence: ${clampInline(edge.evidence.join(", "), 200)}`),
11884
12178
  ];
11885
12179
  return {
11886
12180
  query,
@@ -13366,6 +13660,18 @@ function capture(input) {
13366
13660
  if (!exports.MEMORY_TYPES.includes(type)) {
13367
13661
  return { ok: false, errors: [`Invalid memory type: ${type}`] };
13368
13662
  }
13663
+ // Reject raw transcript / serialized tool-output / file-content dumps at the source.
13664
+ // This fires on EVERY capture path, not just strictCitations: the auto-distill /
13665
+ // observation pipeline (distillSession) calls capture()/learn() without strictCitations,
13666
+ // which is exactly how the 300KB dumps got in and then bloated recall and the graph.
13667
+ // Title OR body trips it — a massaged title (e.g. a shell-prompt paste) won't sneak a
13668
+ // dump past the title check. Recall + graph filtering is the safety net; this is the gate.
13669
+ if (isSerializedDumpTitle(input.title) || isSerializedDumpBody(input.body)) {
13670
+ return {
13671
+ ok: false,
13672
+ errors: ["Capture blocked: this looks like a raw transcript, serialized tool output, or file-content dump, not a durable learning. Summarize the insight in a short, human-readable title and a concise body."],
13673
+ };
13674
+ }
13369
13675
  const scanFindings = scanSensitiveText([input.title, input.summary ?? "", input.body].join("\n"));
13370
13676
  if (scanFindings.length) {
13371
13677
  return {
@@ -13880,14 +14186,7 @@ except Exception:
13880
14186
  print((d.get("prompt") or d.get("user_prompt") or d.get("message") or "")[:1000])
13881
14187
  ' 2>/dev/null || echo "")"
13882
14188
  if [[ -n "$QUERY" ]]; then
13883
- CONTEXT="$(kage recall "$QUERY" --project "$CWD" --json 2>/dev/null | python3 -c 'import json, sys
13884
- try:
13885
- d = json.load(sys.stdin)
13886
- except Exception:
13887
- d = {}
13888
- text = d.get("context_block") or ""
13889
- print(text[:6000] if d.get("results") else "")
13890
- ' 2>/dev/null || true)"
14189
+ CONTEXT="$(kage prompt-context --project "$CWD" --query "$QUERY" 2>/dev/null || true)"
13891
14190
  if [[ -n "$CONTEXT" ]]; then
13892
14191
  KAGE_CONTEXT="$CONTEXT" python3 -c 'import json, os
13893
14192
  print(json.dumps({"additionalContext": os.environ.get("KAGE_CONTEXT", "")}))
@@ -14276,6 +14575,18 @@ function observe(projectDir, event) {
14276
14575
  summary: event.summary === undefined ? undefined : stripPrivateSpans(event.summary),
14277
14576
  command: event.command === undefined ? undefined : stripPrivateSpans(event.command),
14278
14577
  };
14578
+ // Cap free-text fields so a giant pasted command or tool-output dump can't bloat the
14579
+ // observation log (and the resume digest distilled from it). The gist is enough for
14580
+ // distillation; the full payload is not durable memory.
14581
+ const capObservationField = (value, max) => value === undefined ? undefined
14582
+ : value.length > max ? `${value.slice(0, max).trimEnd()}… [+${value.length - max} chars truncated]`
14583
+ : value;
14584
+ event = {
14585
+ ...event,
14586
+ command: capObservationField(event.command, 600),
14587
+ summary: capObservationField(event.summary, 600),
14588
+ text: capObservationField(event.text, 4000),
14589
+ };
14279
14590
  const allowed = ["session_start", "user_prompt", "tool_use", "tool_result", "file_change", "command_result", "test_result", "session_end"];
14280
14591
  if (!allowed.includes(event.type))
14281
14592
  return { ok: false, stored: false, duplicate: false, errors: [`Invalid observation type: ${event.type}`] };
@@ -14320,6 +14631,13 @@ function loadObservations(projectDir, sessionId) {
14320
14631
  // and echoes of Kage's own demo/receipt output. Manual `kage learn`/`kage capture`
14321
14632
  // and manual `kage distill` are never gated — explicit intent outranks the heuristic.
14322
14633
  exports.AUTO_DISTILL_SIGNAL_THRESHOLD = 0.4;
14634
+ // Auto-promote gate: a distilled draft jumps straight to trusted (approved, recallable)
14635
+ // memory — instead of waiting in the pending inbox — only when it is clearly-good AND
14636
+ // code-grounded AND not a duplicate. Everything else still goes to review. This is what
14637
+ // makes the capture flywheel actually spin; KAGE_AUTO_PROMOTE=0 disables it. Grounding keeps
14638
+ // the verification wedge intact: a promoted memory is still checked against the code, just
14639
+ // not gated on a human.
14640
+ const AUTO_PROMOTE_ENABLED = process.env.KAGE_AUTO_PROMOTE !== "0";
14323
14641
  // Markers of hook/system plumbing payloads that sometimes leak into observation text
14324
14642
  // (e.g. a raw <task-notification> block stored as a "user prompt").
14325
14643
  const HOOK_PAYLOAD_MARKERS = [
@@ -14453,6 +14771,9 @@ function reusableFileObservation(event) {
14453
14771
  const text = `${event.summary ?? ""}\n${event.text ?? ""}`.trim();
14454
14772
  if (!text)
14455
14773
  return "";
14774
+ // A captured file diff / file content blob is not a learning — skip it outright.
14775
+ if (isSerializedDumpBody(text))
14776
+ return "";
14456
14777
  const lower = text.toLowerCase();
14457
14778
  const generic = [
14458
14779
  "file changed",
@@ -14493,7 +14814,7 @@ function reusableFileObservation(event) {
14493
14814
  "bug",
14494
14815
  "test",
14495
14816
  ];
14496
- return durableSignals.some((signal) => lower.includes(signal)) ? text : "";
14817
+ return durableSignals.some((signal) => lower.includes(signal)) ? clampInline(text, 1200) : "";
14497
14818
  }
14498
14819
  function normalizeCommandText(command) {
14499
14820
  return command.trim().replace(/\s+/g, " ").replace(/[).,;]+$/, "");
@@ -14513,6 +14834,9 @@ function reusableCommandObservation(event, knownCommands) {
14513
14834
  if (!command)
14514
14835
  return null;
14515
14836
  const summary = `${event.summary ?? ""}\n${event.text ?? ""}`.trim();
14837
+ // A raw stdout/stderr dump is not a reusable command learning — skip it.
14838
+ if (isSerializedDumpBody(summary))
14839
+ return null;
14516
14840
  const lower = summary.toLowerCase();
14517
14841
  const known = knownCommands.has(command);
14518
14842
  const commandLooksUseful = /^(npm|pnpm|yarn|bun|npx|node|vitest|jest|pytest|cargo|go test|make|uv|ruff|mypy|tsc)\b/.test(command);
@@ -14542,13 +14866,16 @@ function reusableCommandObservation(event, knownCommands) {
14542
14866
  return null;
14543
14867
  if (!known && !hasDurableSignal && !hasSpecialArgs && event.exit_code === 0)
14544
14868
  return null;
14545
- const learning = summary || `Use ${command}.`;
14869
+ const learning = clampInline(summary, 1200) || `Use ${command}.`;
14546
14870
  return { command, learning };
14547
14871
  }
14548
14872
  function reusablePromptObservation(event) {
14549
14873
  const text = `${event.summary ?? ""}\n${event.text ?? ""}`.trim();
14550
14874
  if (!text)
14551
14875
  return "";
14876
+ // A pasted transcript / tool-notification block is not an intent learning — skip it.
14877
+ if (isSerializedDumpBody(text))
14878
+ return "";
14552
14879
  const lower = text.toLowerCase();
14553
14880
  const durableSignals = [
14554
14881
  "remember",
@@ -14963,16 +15290,44 @@ function distillSession(projectDir, sessionId, options = {}) {
14963
15290
  observation_count: observations.length,
14964
15291
  },
14965
15292
  ];
15293
+ const admission = evaluateMemoryAdmission(projectDir, result.packet);
14966
15294
  result.packet.quality = {
14967
15295
  ...result.packet.quality,
14968
15296
  ...(sessionDiscoveryTokens > 0
14969
15297
  ? { discovery_tokens: sessionDiscoveryTokens, discovery_tokens_estimated: true }
14970
15298
  : {}),
14971
15299
  distillation: auto ? "auto_distill" : "automatic_observation_candidate",
14972
- admission: evaluateMemoryAdmission(projectDir, result.packet),
15300
+ admission,
14973
15301
  suggested_review_action: suggestedAction(classifyPacket(projectDir, result.packet), result.packet.status),
14974
15302
  };
14975
- writeJson(result.path, result.packet);
15303
+ // Auto-promote the clearly-good, code-grounded, non-duplicate drafts straight to trusted
15304
+ // recall so the flywheel spins without manual review; borderline / ungrounded / path-less
15305
+ // drafts stay in the pending inbox for a quick review.
15306
+ const groundedHighSignal = AUTO_PROMOTE_ENABLED
15307
+ && auto
15308
+ && result.packet.status === "pending"
15309
+ && admission.admit
15310
+ && admission.class === "high_signal"
15311
+ && result.packet.paths.length > 0
15312
+ && result.packet.paths.every((path) => pathExistsInRepo(projectDir, path))
15313
+ && duplicateCandidates(projectDir, result.packet).length === 0
15314
+ && detectContradictions(projectDir, result.packet).length === 0;
15315
+ if (groundedHighSignal) {
15316
+ result.packet.status = "approved";
15317
+ result.packet.tags = unique([...result.packet.tags, "auto-promoted"]);
15318
+ result.packet.quality.suggested_review_action = suggestedAction(classifyPacket(projectDir, result.packet), "approved");
15319
+ const promotedPath = writePacket(projectDir, result.packet, "packets");
15320
+ if (result.path && result.path !== promotedPath) {
15321
+ try {
15322
+ (0, node_fs_1.unlinkSync)(result.path);
15323
+ }
15324
+ catch { }
15325
+ }
15326
+ result.path = promotedPath;
15327
+ }
15328
+ else {
15329
+ writeJson(result.path, result.packet);
15330
+ }
14976
15331
  return result;
14977
15332
  };
14978
15333
  const autoTags = auto ? [exports.AUTO_DISTILL_TAG] : [];
@@ -15166,11 +15521,16 @@ function kageResume(projectDir) {
15166
15521
  block.push(...candidate);
15167
15522
  });
15168
15523
  }
15524
+ // Lead the SessionStart injection with the team's pinned, always-on repo memory (the
15525
+ // curated high-signal facts), not just the recent-timeline digest — parity with recall's
15526
+ // context block, so a new session starts already holding the key knowledge, not only a
15527
+ // usage policy.
15528
+ const pinnedBlock = renderPinnedRepoContext(readContextSlots(projectDir));
15169
15529
  return {
15170
15530
  schema_version: 1,
15171
15531
  project_dir: projectDir,
15172
15532
  generated_at: nowIso(),
15173
- has_content: hasContent,
15533
+ has_content: hasContent || Boolean(pinnedBlock),
15174
15534
  last_session: lastSession,
15175
15535
  last_change_memory: lastChangeMemory,
15176
15536
  pending_auto_distilled: pendingAutoDistilled,
@@ -15178,7 +15538,7 @@ function kageResume(projectDir) {
15178
15538
  ...(pendingAutoDistilled ? { review_command: `kage review --project ${projectDir}` } : {}),
15179
15539
  reconciliation: { unresolved_count: reconciliation.unresolved_count, items: reconciliationItems },
15180
15540
  recent_memory: recentMemory,
15181
- context_block: block.join("\n"),
15541
+ context_block: [pinnedBlock, block.join("\n")].filter((part) => part && part.trim()).join("\n\n"),
15182
15542
  };
15183
15543
  }
15184
15544
  function createDiffChangeMemory(projectDir, summary) {
@@ -15220,7 +15580,9 @@ function createDiffChangeMemory(projectDir, summary) {
15220
15580
  "",
15221
15581
  "Diff summary:",
15222
15582
  "```text",
15223
- summary.diff_stat.trim(),
15583
+ // Clamp the diff stat: a huge diff would otherwise produce a dump-sized change-memory
15584
+ // body. This path builds the packet directly (not via capture()), so bound it here.
15585
+ clampBlock(summary.diff_stat, 4000),
15224
15586
  "```",
15225
15587
  "",
15226
15588
  "How to verify:",
@@ -15895,7 +16257,8 @@ function recallFromPackets(query, packets, limit, label) {
15895
16257
  "",
15896
16258
  packet.summary,
15897
16259
  "",
15898
- packet.body,
16260
+ // Diagnostic sample only — clamp the body so an oversized packet can't bloat output.
16261
+ clampBlock(packet.body, 1500),
15899
16262
  ].join("\n");
15900
16263
  });
15901
16264
  return {
@@ -17093,6 +17456,15 @@ function capturePersonal(input) {
17093
17456
  if (!exports.MEMORY_TYPES.includes(type)) {
17094
17457
  return { ok: false, errors: [`Invalid memory type: ${type}`] };
17095
17458
  }
17459
+ // Same dump guard as repo capture(): a raw transcript / tool-output / file-content dump
17460
+ // is never a durable learning, and personal memory syncs to a remote, so junk here is
17461
+ // worse, not better.
17462
+ if (isSerializedDumpTitle(input.title) || isSerializedDumpBody(input.body)) {
17463
+ return {
17464
+ ok: false,
17465
+ errors: ["Capture blocked: this looks like a raw transcript, serialized tool output, or file-content dump, not a durable learning. Summarize the insight in a short, human-readable title and a concise body."],
17466
+ };
17467
+ }
17096
17468
  // Personal memory syncs to a remote, so the secret scan matters MORE here, not less.
17097
17469
  const scanFindings = scanSensitiveText([input.title, input.summary ?? "", input.body].join("\n"));
17098
17470
  if (scanFindings.length) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kage-core/kage-graph-mcp",
3
- "version": "2.4.0",
3
+ "version": "2.5.1",
4
4
  "description": "Team memory for coding agents: captures the decisions, runbooks, and bug fixes that get lost, verified against your code and shared via git. MCP server, zero deps, no account.",
5
5
  "main": "dist/index.js",
6
6
  "files": [
@@ -22,7 +22,8 @@
22
22
  "build": "tsc",
23
23
  "start": "node dist/index.js",
24
24
  "dev": "ts-node index.ts",
25
- "test": "npm run build && node --test dist/**/*.test.js"
25
+ "test": "npm run build && node --test dist/**/*.test.js && npm run test:dogfood",
26
+ "test:dogfood": "node --test ../evals/agent-trajectory/replay.test.mjs"
26
27
  },
27
28
  "keywords": [
28
29
  "mcp",