@kage-core/kage-graph-mcp 2.3.3 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -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,29 @@ 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
+ if (result.value_receipt) {
1423
+ const r = result.value_receipt;
1424
+ const plural = result.results.length === 1 ? "y" : "ies";
1425
+ out += `\n\n_↳ Kage recalled ${result.results.length} verified memor${plural} · ~${(0, kernel_js_1.formatTokenCount)(r.tokens_saved)} tokens saved this recall · ${r.stale_withheld} stale withheld._`;
1426
+ }
1427
+ console.log(out);
1428
+ return;
1429
+ }
1391
1430
  if (command === "memory-access") {
1392
1431
  const result = (0, kernel_js_1.kageMemoryAccess)(projectArg(args));
1393
1432
  if (args.includes("--json")) {
package/dist/index.js CHANGED
@@ -114,6 +114,7 @@ function listTools() {
114
114
  // separate deferred schemas. Cuts session start from 4 schema loads to 1.
115
115
  name: "kage_context",
116
116
  description: "Primary kage entry point. Validates memory health, recalls relevant packets, and queries both the code graph and knowledge graph — all in one call. Call this at the start of every task; it answers caller/usage questions from the code graph too, so you rarely need a separate graph tool.",
117
+ annotations: { title: "Recall verified memory and query the code/knowledge graph", readOnlyHint: true },
117
118
  inputSchema: {
118
119
  type: "object",
119
120
  properties: {
@@ -218,10 +219,11 @@ function listTools() {
218
219
  {
219
220
  name: "kage_risk",
220
221
  description: "Assess modification risk for files using Kage's code graph plus local git history: dependents, impact surface, churn, ownership, co-change partners, and test gaps. Use before editing hotspot or shared files.",
222
+ annotations: { title: "Assess file modification risk", readOnlyHint: true },
221
223
  inputSchema: {
222
224
  type: "object",
223
225
  properties: {
224
- project_dir: { type: "string" },
226
+ project_dir: { type: "string", description: "Absolute path to the repository root." },
225
227
  targets: { type: "array", items: { type: "string" }, description: "File paths to assess" },
226
228
  changed_files: { type: "array", items: { type: "string" }, description: "Optional PR/branch changed files. If targets is omitted, these are assessed." },
227
229
  },
@@ -231,10 +233,11 @@ function listTools() {
231
233
  {
232
234
  name: "kage_dependency_path",
233
235
  description: "Find how two files are connected in Kage's source-derived code graph. Reports direct dependency direction, reverse impact direction, or undirected graph connection.",
236
+ annotations: { title: "Trace the dependency path between two files", readOnlyHint: true },
234
237
  inputSchema: {
235
238
  type: "object",
236
239
  properties: {
237
- project_dir: { type: "string" },
240
+ project_dir: { type: "string", description: "Absolute path to the repository root." },
238
241
  from: { type: "string", description: "Source file path or unique suffix" },
239
242
  to: { type: "string", description: "Target file path or unique suffix" },
240
243
  },
@@ -353,6 +356,7 @@ function listTools() {
353
356
  {
354
357
  name: "kage_decisions",
355
358
  description: "Summarize the repo's 'why' memory at a glance: the decisions, gotchas, runbooks, conventions, and code explanations Kage has captured, plus which high-traffic code paths still have no decision memory. Use it to brief yourself on a repo before changing it, or to audit where institutional knowledge is thin or going stale. Read-only: returns grouped entries with titles, types, cited file paths, and call-outs for weak, stale, or undocumented hot paths. Does not modify any memory.",
359
+ annotations: { title: "Summarize the repo's decision memory", readOnlyHint: true },
356
360
  inputSchema: {
357
361
  type: "object",
358
362
  properties: {
@@ -386,12 +390,13 @@ function listTools() {
386
390
  {
387
391
  name: "kage_docs_search",
388
392
  description: "Search this repo's OWN committed documentation (README, docs/**, *.md, common doc dirs — including any framework/API docs checked into the repo). BM25 over heading-anchored chunks from .agent_memory/indexes/docs-index.json. Returns ranked doc hits with doc_path, heading, line, and snippet. This indexes only files on disk in the project, never the internet.",
393
+ annotations: { title: "Search the repo's own committed docs", readOnlyHint: true },
389
394
  inputSchema: {
390
395
  type: "object",
391
396
  properties: {
392
- query: { type: "string" },
393
- project_dir: { type: "string" },
394
- limit: { type: "number" },
397
+ query: { type: "string", description: "Search terms to match against the repo's documentation." },
398
+ project_dir: { type: "string", description: "Absolute path to the repository root." },
399
+ limit: { type: "number", description: "Max ranked doc hits to return (default 5)." },
395
400
  },
396
401
  required: ["query", "project_dir"],
397
402
  },
@@ -489,10 +494,11 @@ function listTools() {
489
494
  {
490
495
  name: "kage_refresh",
491
496
  description: "Rebuild repo indexes, code graph, memory graph, metrics, and stale-memory metadata. Agents should run this after meaningful file/content changes before PR checks; push-only or same-tree commits do not need another refresh. On non-default git branches metadata-only packet rewrites are skipped (quiet refresh) to avoid merge conflicts; pass force to persist them anyway.",
497
+ annotations: { title: "Rebuild Kage indexes and graphs", readOnlyHint: false, idempotentHint: true },
492
498
  inputSchema: {
493
499
  type: "object",
494
500
  properties: {
495
- project_dir: { type: "string" },
501
+ project_dir: { type: "string", description: "Absolute path to the repository root." },
496
502
  force: { type: "boolean", description: "Persist packet metadata rewrites even on a non-default branch" },
497
503
  },
498
504
  required: ["project_dir"],
@@ -521,10 +527,11 @@ function listTools() {
521
527
  {
522
528
  name: "kage_pr_check",
523
529
  description: "Check whether repo memory, code graph, memory graph, and stale-memory state are ready for merge. Leads with a human summary of team memories invalidated by the current change — relay it to the developer.",
530
+ annotations: { title: "Check memory readiness for merge", readOnlyHint: true },
524
531
  inputSchema: {
525
532
  type: "object",
526
533
  properties: {
527
- project_dir: { type: "string" },
534
+ project_dir: { type: "string", description: "Absolute path to the repository root." },
528
535
  },
529
536
  required: ["project_dir"],
530
537
  },
@@ -613,6 +620,7 @@ function listTools() {
613
620
  {
614
621
  name: "kage_supersede",
615
622
  description: "Replace one repo-local memory packet with a newer one that corrects or obsoletes it. Marks the old packet superseded, links it to the replacement, and writes bidirectional lineage edges so the history stays traceable. Use this instead of deleting when new knowledge updates an old fact, or to resolve a contradiction surfaced by kage_conflicts. Mutates both packets on disk: the superseded packet is withheld from recall but kept for lineage.",
623
+ annotations: { title: "Supersede a memory packet with a newer one", readOnlyHint: false, destructiveHint: false, idempotentHint: true },
616
624
  inputSchema: {
617
625
  type: "object",
618
626
  properties: {
@@ -638,12 +646,13 @@ function listTools() {
638
646
  {
639
647
  name: "kage_skills",
640
648
  description: "Codify durable, verified repo memory (runbooks, workflows, actionable decisions) into git-native SKILL.md files under .claude/skills/ that every teammate's agent auto-loads. Only grounded, non-stale packets become skills. Pass dry_run to preview without writing. dir overrides the output directory.",
649
+ annotations: { title: "Codify verified memory into agent skills", readOnlyHint: false, idempotentHint: true },
641
650
  inputSchema: {
642
651
  type: "object",
643
652
  properties: {
644
- project_dir: { type: "string" },
645
- dry_run: { type: "boolean" },
646
- dir: { type: "string" },
653
+ project_dir: { type: "string", description: "Absolute path to the repository root." },
654
+ dry_run: { type: "boolean", description: "Preview which skills would be written without creating any files." },
655
+ dir: { type: "string", description: "Override the output directory (default .claude/skills/)." },
647
656
  },
648
657
  required: ["project_dir"],
649
658
  },
@@ -736,6 +745,7 @@ function listTools() {
736
745
  {
737
746
  name: "kage_learn",
738
747
  description: "Capture a durable, reusable learning from the current session as a verified repo-local memory packet (committed under .agent_memory/, shared with the team via git). Use it the moment you discover something a future session should know: a decision and its rationale, a bug's root cause and fix, a convention, or a setup step. Prefer it over diff-based proposals when you already know what was learned. The write is rejected if every cited path is missing from the repo (set allow_missing_paths for a file you are about to create), and secrets/PII are scanned out before writing. Returns the new packet id plus any contradiction warnings against existing memory.",
748
+ annotations: { title: "Capture a verified learning to repo memory", readOnlyHint: false },
739
749
  inputSchema: {
740
750
  type: "object",
741
751
  properties: {
@@ -873,6 +883,7 @@ function listTools() {
873
883
  {
874
884
  name: "kage_feedback",
875
885
  description: "Record how useful a recalled repo-local memory packet was, which tunes Kage's trust and future recall. 'helpful' reinforces the packet, 'wrong' flags it as disputed, and 'stale' marks it for re-verification and withholds it from recall until refreshed. Use it right after a recalled packet helped you, misled you, or no longer matched the code. Mutates the packet's quality signals on disk.",
886
+ annotations: { title: "Rate a recalled memory packet", readOnlyHint: false },
876
887
  inputSchema: {
877
888
  type: "object",
878
889
  properties: {
@@ -1050,10 +1061,11 @@ async function callTool(name, args) {
1050
1061
  const validationText = validation.ok
1051
1062
  ? "Memory healthy."
1052
1063
  : `Warnings: ${validation.warnings.join("; ")}`;
1053
- // 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.
1054
1068
  const recallResult = (0, kernel_js_1.recall)(projectDir, query, limit, false);
1055
- // graph facts on top of recall
1056
- const graphResult = (0, kernel_js_1.queryGraph)(projectDir, query, 5);
1057
1069
  const explicitTargets = [...arrayArg(args?.targets), ...filePathHints(query)];
1058
1070
  const changedFiles = arrayArg(args?.changed_files);
1059
1071
  const riskResult = explicitTargets.length || changedFiles.length ? (0, kernel_js_1.kageRisk)(projectDir, explicitTargets, changedFiles) : null;
@@ -1076,23 +1088,27 @@ async function callTool(name, args) {
1076
1088
  const learningLedger = typeof args?.session_id === "string" && args.session_id.trim()
1077
1089
  ? (0, kernel_js_1.kageSessionLearningLedger)(projectDir, { sessionId: args.session_id, limit: 20 })
1078
1090
  : null;
1079
- const sections = [
1091
+ const body = [
1080
1092
  recallResult.context_block,
1081
1093
  teammateBrief.context_block,
1082
1094
  learningLedger ? learningLedger.context_block : "",
1083
- graphResult.context_block ? `\n## Graph Facts\n${graphResult.context_block}` : "",
1084
1095
  riskResult ? riskContextBlock(riskResult) : "",
1085
1096
  dependencyResult ? `\n## Dependency Path\n${dependencyResult.summary}${dependencyResult.path.length ? `\nPath: ${dependencyResult.path.join(" -> ")}` : ""}` : "",
1086
1097
  reconciliation.unresolved_count ? `\n## Memory Reconciliation\n${reconciliation.agent_instruction}` : "",
1087
1098
  `\n_${validationText}_`,
1088
- // Visible receipt: surface what the harness saved today so agents relay it.
1089
- (() => {
1090
- const gains = (0, kernel_js_1.valueSummary)(projectDir).today;
1091
- return `\n\nGains: ~${(0, kernel_js_1.formatTokenCount)(gains.tokens_saved)} tokens saved this session · stale memories withheld: ${gains.stale_withheld}`;
1092
- })(),
1093
1099
  ].filter(Boolean).join("");
1094
- return {
1095
- 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}` }],
1096
1112
  };
1097
1113
  }
1098
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);
@@ -8018,7 +8124,7 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
8018
8124
  ]),
8019
8125
  "",
8020
8126
  graphContext.edges.length ? "## Related Graph Facts" : "",
8021
- ...graphContext.edges.slice(0, 5).map((edge, index) => `${index + 1}. ${edge.fact} (evidence: ${edge.evidence.join(", ")})`),
8127
+ ...graphContext.edges.slice(0, 5).map((edge, index) => `${index + 1}. ${clampInline(edge.fact)} (evidence: ${clampInline(edge.evidence.join(", "), 200)})`),
8022
8128
  ...(suppressed.length
8023
8129
  ? [
8024
8130
  "",
@@ -9133,6 +9239,13 @@ function kageCleanupCandidates(projectDir) {
9133
9239
  }
9134
9240
  const TRUTH_REPORT_MAX_FINDINGS = 16;
9135
9241
  const TRUTH_REPORT_AI_ERA_DAYS = 120;
9242
+ // A same-name, same-signature symbol spread across more than this many directories is a
9243
+ // framework/language convention (e.g. Go's per-package addKnownTypes / AddToScheme), not
9244
+ // copy-paste worth surfacing. Real, actionable duplication is a handful of sites.
9245
+ const TRUTH_DUPLICATE_MAX_DIRS = 8;
9246
+ // A directory whose last segment is an API version (v1, v2, v1beta1, v2alpha3, ...). The
9247
+ // same type redefined across sibling version packages is API versioning, not duplication.
9248
+ const TRUTH_VERSION_DIR = /(^|\/)v\d+((alpha|beta)\d+)?$/i;
9136
9249
  // Symbol names too generic to mean "two teams built the same thing".
9137
9250
  const TRUTH_COMMON_SYMBOL_NAMES = new Set([
9138
9251
  "main", "init", "run", "setup", "start", "stop", "open", "close", "create", "destroy",
@@ -9146,10 +9259,24 @@ const TRUTH_DUPLICATE_NAME_DENYLIST = new Set([
9146
9259
  "decorator", "wrapper", "inner", "callback", "wrapped", "fn", "cb", "noop",
9147
9260
  "predicate", "comparator", "getter", "setter", "factory", "visit",
9148
9261
  ]);
9262
+ // Machine-generated code (protobuf, conversion/deepcopy codegen, mocks, minified bundles)
9263
+ // is not where a human's undocumented knowledge lives — flagging it as a hotspot, duplicate,
9264
+ // or ghost export is pure noise. Detected by the conventional names generators emit.
9265
+ function isGeneratedPath(path) {
9266
+ const p = path.toLowerCase();
9267
+ return /(^|[/._-])(zz_generated|generated|autogen|codegen)[._-]/.test(p)
9268
+ || /(^|\/)(generated|__generated__)\//.test(p)
9269
+ || /\.pb\.(go|cc|h|ts|js|py|rb|dart|swift)$/.test(p)
9270
+ || /[._](pb2|pb2_grpc)\.py$/.test(p)
9271
+ || /\.(gen|g|freezed)\.[^.]+$/.test(p)
9272
+ || /(^|\/)(mock_[^/]*|wire_gen)\.[^.]+$/.test(p)
9273
+ || /\.min\.(js|css)$/.test(p);
9274
+ }
9149
9275
  function truthExcludedPath(path) {
9150
9276
  return /(^|\/)(tests?|__tests__|specs?|examples?|fixtures?|benchmarks?|mocks?|__mocks__|vendor|node_modules|dist|build)\//i.test(path)
9151
9277
  || /\.(test|spec)\.[^.]+$/i.test(path)
9152
- || /(^|\/)test[^/]*\.[^.]+$/i.test(path);
9278
+ || /(^|\/)test[^/]*\.[^.]+$/i.test(path)
9279
+ || isGeneratedPath(path);
9153
9280
  }
9154
9281
  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
9282
  function truthDocPathCandidates(line) {
@@ -9167,6 +9294,117 @@ function truthDocPathCandidates(line) {
9167
9294
  && !candidate.includes("node_modules")
9168
9295
  && !candidate.startsWith(".agent_memory"));
9169
9296
  }
9297
+ const TRUTH_DECL_KEYWORDS = new Set([
9298
+ "function", "func", "fn", "def", "class", "struct", "trait", "interface",
9299
+ "type", "enum", "const", "let", "var", "impl", "module", "object",
9300
+ ]);
9301
+ // Render "<path>:<line> <kind> <signature>" without doubling the declaration keyword.
9302
+ // The captured signature often already declares the construct ("export function abort(...)",
9303
+ // "pub fn abort(...)", "class Foo"), so blindly prefixing the kind yields
9304
+ // "function export function abort". Only add the kind when the signature carries no
9305
+ // leading declaration keyword of its own.
9306
+ function truthSymbolEvidence(path, line, kind, signature) {
9307
+ const sig = signature.slice(0, 80).trim();
9308
+ const leadTokens = sig.toLowerCase().split(/[^a-z]+/, 4);
9309
+ const selfDescribing = leadTokens.some((tok) => TRUTH_DECL_KEYWORDS.has(tok))
9310
+ || sig.toLowerCase().startsWith(`${kind.toLowerCase()} `);
9311
+ return `${path}:${line} ${selfDescribing ? sig : `${kind} ${sig}`}`;
9312
+ }
9313
+ // Real test coverage beats the import-reachability heuristic. When a standard coverage
9314
+ // report exists we read measured line coverage; KAGE_COVERAGE_MIN (default 0.5) is the
9315
+ // fraction below which a hot file counts as under-tested.
9316
+ const COVERAGE_TESTED_MIN = (() => {
9317
+ const raw = Number(process.env.KAGE_COVERAGE_MIN);
9318
+ return Number.isFinite(raw) && raw > 0 && raw <= 1 ? raw : 0.5;
9319
+ })();
9320
+ function parseLcovCoverage(text) {
9321
+ const map = new Map();
9322
+ let file = null;
9323
+ let hit = 0;
9324
+ let total = 0;
9325
+ for (const raw of text.split(/\r?\n/)) {
9326
+ const line = raw.trim();
9327
+ if (line.startsWith("SF:")) {
9328
+ file = line.slice(3).trim();
9329
+ hit = 0;
9330
+ total = 0;
9331
+ }
9332
+ else if (line.startsWith("DA:")) {
9333
+ const count = Number(line.slice(3).split(",")[1] ?? 0) || 0;
9334
+ total += 1;
9335
+ if (count > 0)
9336
+ hit += 1;
9337
+ }
9338
+ else if (line.startsWith("LH:")) {
9339
+ hit = Number(line.slice(3)) || hit;
9340
+ }
9341
+ else if (line.startsWith("LF:")) {
9342
+ total = Number(line.slice(3)) || total;
9343
+ }
9344
+ else if (line === "end_of_record" && file) {
9345
+ map.set(file, { hit, total });
9346
+ file = null;
9347
+ }
9348
+ }
9349
+ return map;
9350
+ }
9351
+ function parseIstanbulCoverage(text) {
9352
+ const map = new Map();
9353
+ let json;
9354
+ try {
9355
+ json = JSON.parse(text);
9356
+ }
9357
+ catch {
9358
+ return map;
9359
+ }
9360
+ for (const [key, entry] of Object.entries(json)) {
9361
+ if (!entry || typeof entry !== "object")
9362
+ continue;
9363
+ const counts = Object.values(entry.s ?? {});
9364
+ if (!counts.length)
9365
+ continue;
9366
+ const hit = counts.filter((value) => Number(value) > 0).length;
9367
+ map.set(entry.path ?? key, { hit, total: counts.length });
9368
+ }
9369
+ return map;
9370
+ }
9371
+ function readCoverageReport(projectDir) {
9372
+ const candidates = ["coverage/lcov.info", "lcov.info", "coverage/coverage-final.json", "coverage-final.json"];
9373
+ const root = projectDir.replace(/\\/g, "/").replace(/\/+$/, "");
9374
+ for (const rel of candidates) {
9375
+ const abs = (0, node_path_1.join)(projectDir, rel);
9376
+ if (!(0, node_fs_1.existsSync)(abs))
9377
+ continue;
9378
+ const text = safeReadText(abs);
9379
+ if (!text)
9380
+ continue;
9381
+ const parsed = rel.endsWith(".info") ? parseLcovCoverage(text) : parseIstanbulCoverage(text);
9382
+ if (!parsed.size)
9383
+ continue;
9384
+ const byPath = new Map();
9385
+ for (const [rawPath, value] of parsed) {
9386
+ let p = rawPath.replace(/\\/g, "/");
9387
+ if (p.startsWith(`${root}/`))
9388
+ p = p.slice(root.length + 1);
9389
+ p = p.replace(/^\.\//, "").replace(/^\/+/, "");
9390
+ byPath.set(p, value);
9391
+ }
9392
+ return { source: rel, byPath, entries: [...byPath.entries()] };
9393
+ }
9394
+ return null;
9395
+ }
9396
+ // Coverage paths can carry an absolute or CI-machine prefix; fall back to suffix match.
9397
+ function coverageFor(report, path) {
9398
+ const direct = report.byPath.get(path);
9399
+ if (direct)
9400
+ return direct;
9401
+ const suffix = `/${path}`;
9402
+ for (const [key, value] of report.entries) {
9403
+ if (key.endsWith(suffix) || path.endsWith(`/${key}`))
9404
+ return value;
9405
+ }
9406
+ return null;
9407
+ }
9170
9408
  function truthReport(projectDir) {
9171
9409
  const graph = readCurrentCodeGraph(projectDir) ?? buildCodeGraph(projectDir);
9172
9410
  const warnings = [];
@@ -9183,12 +9421,13 @@ function truthReport(projectDir) {
9183
9421
  const fileCommits = new Map();
9184
9422
  const fileNewestEpoch = new Map();
9185
9423
  if (hasGit) {
9186
- const raw = readGit(projectDir, ["log", "--no-renames", "--format=__KAGE_SCAN__%x1f%ae%x1f%ct", "--name-only"]) ?? "";
9424
+ const raw = readGit(projectDir, ["log", `--max-count=${TRUTH_REPORT_MAX_COMMITS}`, "--no-renames", "--format=__KAGE_SCAN__%x1f%ae%x1f%ct", "--name-only"]) ?? "";
9187
9425
  // Resolve the repo->project prefix once; gitPathToProjectRelative spawns git per call,
9188
9426
  // which is far too slow for a full-history name-only walk.
9189
9427
  const projectPrefix = readGit(projectDir, ["rev-parse", "--show-prefix"])?.replace(/\\/g, "/").replace(/\/+$/, "") ?? "";
9190
9428
  let author = "";
9191
9429
  let epoch = 0;
9430
+ let commitsSeen = 0;
9192
9431
  for (const rawLine of raw.split(/\r?\n/)) {
9193
9432
  const line = rawLine.trim();
9194
9433
  if (!line)
@@ -9197,6 +9436,7 @@ function truthReport(projectDir) {
9197
9436
  const parts = line.split("\x1f");
9198
9437
  author = (parts[1] ?? "").toLowerCase();
9199
9438
  epoch = Number(parts[2] ?? 0) || 0;
9439
+ commitsSeen += 1;
9200
9440
  continue;
9201
9441
  }
9202
9442
  if (!author)
@@ -9213,6 +9453,9 @@ function truthReport(projectDir) {
9213
9453
  if (!fileNewestEpoch.has(path))
9214
9454
  fileNewestEpoch.set(path, epoch);
9215
9455
  }
9456
+ if (commitsSeen >= TRUTH_REPORT_MAX_COMMITS) {
9457
+ warnings.push(`Git history is large; churn, bus-factor, and recency are computed from the most recent ${TRUTH_REPORT_MAX_COMMITS} commits.`);
9458
+ }
9216
9459
  }
9217
9460
  // Centrality: import + call edges touching the file.
9218
9461
  const centrality = new Map();
@@ -9255,6 +9498,12 @@ function truthReport(projectDir) {
9255
9498
  const paths = new Set(members.map((member) => member.path));
9256
9499
  if (dirs.size < 2 || paths.size < 2)
9257
9500
  continue;
9501
+ if (dirs.size > TRUTH_DUPLICATE_MAX_DIRS)
9502
+ continue;
9503
+ // Skip the same symbol redefined across sibling API-version packages (v1/v1beta1/...).
9504
+ const versionedCount = members.filter((member) => TRUTH_VERSION_DIR.test((0, node_path_1.dirname)(member.path))).length;
9505
+ if (versionedCount >= 2 && members.length - versionedCount <= 1)
9506
+ continue;
9258
9507
  const signatureCounts = new Map();
9259
9508
  for (const member of members) {
9260
9509
  const normalized = member.signature.replace(/\s+/g, "");
@@ -9271,7 +9520,7 @@ function truthReport(projectDir) {
9271
9520
  kind: "duplicate_cluster",
9272
9521
  title: `${members[0].name} — ${paths.size} implementations across ${dirs.size} directories${recent ? " [recently changed]" : ""}`,
9273
9522
  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)}`),
9523
+ evidence: members.slice(0, 5).map((member) => truthSymbolEvidence(member.path, member.line, member.kind, member.signature)),
9275
9524
  surprise: Math.min(100, 45 + paths.size * 8 + (signatureMatch ? 15 : 0) + (recent ? 20 : 0)),
9276
9525
  });
9277
9526
  }
@@ -9325,7 +9574,7 @@ function truthReport(projectDir) {
9325
9574
  kind: "ghost_export",
9326
9575
  title: `${symbol.name} — exported, never called`,
9327
9576
  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)}`],
9577
+ evidence: [truthSymbolEvidence(symbol.path, symbol.line, symbol.kind, symbol.signature)],
9329
9578
  surprise: Math.min(100, 35 + Math.min(30, fileCentrality)),
9330
9579
  }];
9331
9580
  });
@@ -9445,8 +9694,9 @@ function truthReport(projectDir) {
9445
9694
  testedPaths.add(edge.to_path);
9446
9695
  }
9447
9696
  }
9697
+ const coverage = readCoverageReport(projectDir);
9448
9698
  const untestedFindings = [];
9449
- if (hasTests) {
9699
+ if (hasTests || coverage) {
9450
9700
  for (const file of sourceFiles) {
9451
9701
  if (isEntrypointLike(file.path))
9452
9702
  continue;
@@ -9456,7 +9706,26 @@ function truthReport(projectDir) {
9456
9706
  continue;
9457
9707
  if (hasGit && commits < 2)
9458
9708
  continue;
9459
- if (testedPaths.has(file.path))
9709
+ // Prefer measured line coverage when the file is in the report; only fall back to
9710
+ // the import-reachability heuristic for files the report doesn't cover.
9711
+ if (coverage) {
9712
+ const cov = coverageFor(coverage, file.path);
9713
+ if (cov && cov.total > 0) {
9714
+ const pct = cov.hit / cov.total;
9715
+ if (pct >= COVERAGE_TESTED_MIN)
9716
+ continue;
9717
+ const pctLabel = Math.round(pct * 100);
9718
+ untestedFindings.push({
9719
+ kind: "untested_hot",
9720
+ title: `${file.path} — undertested hot path`,
9721
+ 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.`,
9722
+ evidence: [`${file.path}:1 ${pctLabel}% line coverage (${cov.hit}/${cov.total} lines), centrality ${fileCentrality}`],
9723
+ surprise: Math.min(100, 30 + Math.min(45, fileCentrality * 3) + (hasGit ? Math.min(15, commits) : 0) + Math.round((1 - pct) * 12)),
9724
+ });
9725
+ continue;
9726
+ }
9727
+ }
9728
+ if (!hasTests || testedPaths.has(file.path))
9460
9729
  continue;
9461
9730
  untestedFindings.push({
9462
9731
  kind: "untested_hot",
@@ -9467,6 +9736,12 @@ function truthReport(projectDir) {
9467
9736
  });
9468
9737
  }
9469
9738
  }
9739
+ if (coverage) {
9740
+ warnings.push(`Test coverage measured from ${coverage.source} (${coverage.byPath.size} files); files outside it fall back to static test-import heuristics.`);
9741
+ }
9742
+ else if (untestedFindings.length) {
9743
+ 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.`);
9744
+ }
9470
9745
  untestedFindings.sort((a, b) => b.surprise - a.surprise || a.title.localeCompare(b.title));
9471
9746
  // 1f. Complexity hotspots: very large source files many things depend on —
9472
9747
  // where knowledge concentrates and onboarding stalls.
@@ -9624,6 +9899,13 @@ function truthReport(projectDir) {
9624
9899
  ...(docLines.length ? [[docLieFindings.length, `${docLieFindings.length} doc lie${docLieFindings.length === 1 ? "" : "s"}`]] : []),
9625
9900
  ];
9626
9901
  const headlineParts = headlineCandidates.filter(([count]) => count > 0).map(([, label]) => label);
9902
+ // If the file-count ceiling kicked in, say so plainly — a silent "scanned 25,000 files"
9903
+ // on a 60k-file monorepo reads as "covered everything" when it didn't.
9904
+ const cappedFiles = readCodeIndexManifest(projectDir).ignored_summary?.["exceeded_file_cap"] ?? 0;
9905
+ if (cappedFiles > 0) {
9906
+ const totalIndexable = graph.files.length + cappedFiles;
9907
+ warnings.push(`Large repo: scanned ${graph.files.length.toLocaleString()} of ${totalIndexable.toLocaleString()} indexable files (capped). Set KAGE_MAX_SCAN_FILES higher to scan more.`);
9908
+ }
9627
9909
  return {
9628
9910
  schema_version: 1,
9629
9911
  project_dir: projectDir,
@@ -11868,7 +12150,9 @@ function queryGraph(projectDir, query, limit = 10, graph) {
11868
12150
  const temporalPenalty = edge.invalidated_at ? -4 : 0;
11869
12151
  return { edge, score: textScore + graphScore + evidenceScore + temporalPenalty };
11870
12152
  })
11871
- .filter((entry) => entry.score > 0)
12153
+ // Serialized transcript / tool-output / file-content dumps are capture noise, not
12154
+ // facts. Keep them out of the graph context so one raw edge can't dominate the output.
12155
+ .filter((entry) => entry.score > 0 && !isSerializedDumpBody(entry.edge.fact))
11872
12156
  .sort((a, b) => b.score - a.score || a.edge.fact.localeCompare(b.edge.fact))
11873
12157
  .slice(0, limit)
11874
12158
  .map((entry) => entry.edge);
@@ -11880,7 +12164,8 @@ function queryGraph(projectDir, query, limit = 10, graph) {
11880
12164
  `Query: ${query}`,
11881
12165
  "",
11882
12166
  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(", ")}`),
12167
+ // Clamp every field: a fact is a one-liner, never a document.
12168
+ ...edges.map((edge, index) => `${index + 1}. ${clampInline(edge.fact)}\n Relation: ${clampInline(edge.relation, 80)}\n Evidence: ${clampInline(edge.evidence.join(", "), 200)}`),
11884
12169
  ];
11885
12170
  return {
11886
12171
  query,
@@ -13366,6 +13651,18 @@ function capture(input) {
13366
13651
  if (!exports.MEMORY_TYPES.includes(type)) {
13367
13652
  return { ok: false, errors: [`Invalid memory type: ${type}`] };
13368
13653
  }
13654
+ // Reject raw transcript / serialized tool-output / file-content dumps at the source.
13655
+ // This fires on EVERY capture path, not just strictCitations: the auto-distill /
13656
+ // observation pipeline (distillSession) calls capture()/learn() without strictCitations,
13657
+ // which is exactly how the 300KB dumps got in and then bloated recall and the graph.
13658
+ // Title OR body trips it — a massaged title (e.g. a shell-prompt paste) won't sneak a
13659
+ // dump past the title check. Recall + graph filtering is the safety net; this is the gate.
13660
+ if (isSerializedDumpTitle(input.title) || isSerializedDumpBody(input.body)) {
13661
+ return {
13662
+ ok: false,
13663
+ 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."],
13664
+ };
13665
+ }
13369
13666
  const scanFindings = scanSensitiveText([input.title, input.summary ?? "", input.body].join("\n"));
13370
13667
  if (scanFindings.length) {
13371
13668
  return {
@@ -13880,14 +14177,7 @@ except Exception:
13880
14177
  print((d.get("prompt") or d.get("user_prompt") or d.get("message") or "")[:1000])
13881
14178
  ' 2>/dev/null || echo "")"
13882
14179
  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)"
14180
+ CONTEXT="$(kage prompt-context --project "$CWD" --query "$QUERY" 2>/dev/null || true)"
13891
14181
  if [[ -n "$CONTEXT" ]]; then
13892
14182
  KAGE_CONTEXT="$CONTEXT" python3 -c 'import json, os
13893
14183
  print(json.dumps({"additionalContext": os.environ.get("KAGE_CONTEXT", "")}))
@@ -14276,6 +14566,18 @@ function observe(projectDir, event) {
14276
14566
  summary: event.summary === undefined ? undefined : stripPrivateSpans(event.summary),
14277
14567
  command: event.command === undefined ? undefined : stripPrivateSpans(event.command),
14278
14568
  };
14569
+ // Cap free-text fields so a giant pasted command or tool-output dump can't bloat the
14570
+ // observation log (and the resume digest distilled from it). The gist is enough for
14571
+ // distillation; the full payload is not durable memory.
14572
+ const capObservationField = (value, max) => value === undefined ? undefined
14573
+ : value.length > max ? `${value.slice(0, max).trimEnd()}… [+${value.length - max} chars truncated]`
14574
+ : value;
14575
+ event = {
14576
+ ...event,
14577
+ command: capObservationField(event.command, 600),
14578
+ summary: capObservationField(event.summary, 600),
14579
+ text: capObservationField(event.text, 4000),
14580
+ };
14279
14581
  const allowed = ["session_start", "user_prompt", "tool_use", "tool_result", "file_change", "command_result", "test_result", "session_end"];
14280
14582
  if (!allowed.includes(event.type))
14281
14583
  return { ok: false, stored: false, duplicate: false, errors: [`Invalid observation type: ${event.type}`] };
@@ -14453,6 +14755,9 @@ function reusableFileObservation(event) {
14453
14755
  const text = `${event.summary ?? ""}\n${event.text ?? ""}`.trim();
14454
14756
  if (!text)
14455
14757
  return "";
14758
+ // A captured file diff / file content blob is not a learning — skip it outright.
14759
+ if (isSerializedDumpBody(text))
14760
+ return "";
14456
14761
  const lower = text.toLowerCase();
14457
14762
  const generic = [
14458
14763
  "file changed",
@@ -14493,7 +14798,7 @@ function reusableFileObservation(event) {
14493
14798
  "bug",
14494
14799
  "test",
14495
14800
  ];
14496
- return durableSignals.some((signal) => lower.includes(signal)) ? text : "";
14801
+ return durableSignals.some((signal) => lower.includes(signal)) ? clampInline(text, 1200) : "";
14497
14802
  }
14498
14803
  function normalizeCommandText(command) {
14499
14804
  return command.trim().replace(/\s+/g, " ").replace(/[).,;]+$/, "");
@@ -14513,6 +14818,9 @@ function reusableCommandObservation(event, knownCommands) {
14513
14818
  if (!command)
14514
14819
  return null;
14515
14820
  const summary = `${event.summary ?? ""}\n${event.text ?? ""}`.trim();
14821
+ // A raw stdout/stderr dump is not a reusable command learning — skip it.
14822
+ if (isSerializedDumpBody(summary))
14823
+ return null;
14516
14824
  const lower = summary.toLowerCase();
14517
14825
  const known = knownCommands.has(command);
14518
14826
  const commandLooksUseful = /^(npm|pnpm|yarn|bun|npx|node|vitest|jest|pytest|cargo|go test|make|uv|ruff|mypy|tsc)\b/.test(command);
@@ -14542,13 +14850,16 @@ function reusableCommandObservation(event, knownCommands) {
14542
14850
  return null;
14543
14851
  if (!known && !hasDurableSignal && !hasSpecialArgs && event.exit_code === 0)
14544
14852
  return null;
14545
- const learning = summary || `Use ${command}.`;
14853
+ const learning = clampInline(summary, 1200) || `Use ${command}.`;
14546
14854
  return { command, learning };
14547
14855
  }
14548
14856
  function reusablePromptObservation(event) {
14549
14857
  const text = `${event.summary ?? ""}\n${event.text ?? ""}`.trim();
14550
14858
  if (!text)
14551
14859
  return "";
14860
+ // A pasted transcript / tool-notification block is not an intent learning — skip it.
14861
+ if (isSerializedDumpBody(text))
14862
+ return "";
14552
14863
  const lower = text.toLowerCase();
14553
14864
  const durableSignals = [
14554
14865
  "remember",
@@ -15166,11 +15477,16 @@ function kageResume(projectDir) {
15166
15477
  block.push(...candidate);
15167
15478
  });
15168
15479
  }
15480
+ // Lead the SessionStart injection with the team's pinned, always-on repo memory (the
15481
+ // curated high-signal facts), not just the recent-timeline digest — parity with recall's
15482
+ // context block, so a new session starts already holding the key knowledge, not only a
15483
+ // usage policy.
15484
+ const pinnedBlock = renderPinnedRepoContext(readContextSlots(projectDir));
15169
15485
  return {
15170
15486
  schema_version: 1,
15171
15487
  project_dir: projectDir,
15172
15488
  generated_at: nowIso(),
15173
- has_content: hasContent,
15489
+ has_content: hasContent || Boolean(pinnedBlock),
15174
15490
  last_session: lastSession,
15175
15491
  last_change_memory: lastChangeMemory,
15176
15492
  pending_auto_distilled: pendingAutoDistilled,
@@ -15178,7 +15494,7 @@ function kageResume(projectDir) {
15178
15494
  ...(pendingAutoDistilled ? { review_command: `kage review --project ${projectDir}` } : {}),
15179
15495
  reconciliation: { unresolved_count: reconciliation.unresolved_count, items: reconciliationItems },
15180
15496
  recent_memory: recentMemory,
15181
- context_block: block.join("\n"),
15497
+ context_block: [pinnedBlock, block.join("\n")].filter((part) => part && part.trim()).join("\n\n"),
15182
15498
  };
15183
15499
  }
15184
15500
  function createDiffChangeMemory(projectDir, summary) {
@@ -15220,7 +15536,9 @@ function createDiffChangeMemory(projectDir, summary) {
15220
15536
  "",
15221
15537
  "Diff summary:",
15222
15538
  "```text",
15223
- summary.diff_stat.trim(),
15539
+ // Clamp the diff stat: a huge diff would otherwise produce a dump-sized change-memory
15540
+ // body. This path builds the packet directly (not via capture()), so bound it here.
15541
+ clampBlock(summary.diff_stat, 4000),
15224
15542
  "```",
15225
15543
  "",
15226
15544
  "How to verify:",
@@ -15895,7 +16213,8 @@ function recallFromPackets(query, packets, limit, label) {
15895
16213
  "",
15896
16214
  packet.summary,
15897
16215
  "",
15898
- packet.body,
16216
+ // Diagnostic sample only — clamp the body so an oversized packet can't bloat output.
16217
+ clampBlock(packet.body, 1500),
15899
16218
  ].join("\n");
15900
16219
  });
15901
16220
  return {
@@ -17093,6 +17412,15 @@ function capturePersonal(input) {
17093
17412
  if (!exports.MEMORY_TYPES.includes(type)) {
17094
17413
  return { ok: false, errors: [`Invalid memory type: ${type}`] };
17095
17414
  }
17415
+ // Same dump guard as repo capture(): a raw transcript / tool-output / file-content dump
17416
+ // is never a durable learning, and personal memory syncs to a remote, so junk here is
17417
+ // worse, not better.
17418
+ if (isSerializedDumpTitle(input.title) || isSerializedDumpBody(input.body)) {
17419
+ return {
17420
+ ok: false,
17421
+ 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."],
17422
+ };
17423
+ }
17096
17424
  // Personal memory syncs to a remote, so the secret scan matters MORE here, not less.
17097
17425
  const scanFindings = scanSensitiveText([input.title, input.summary ?? "", input.body].join("\n"));
17098
17426
  if (scanFindings.length) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kage-core/kage-graph-mcp",
3
- "version": "2.3.3",
3
+ "version": "2.5.0",
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",