@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 +40 -0
- package/dist/index.js +17 -12
- package/dist/kernel.js +409 -37
- package/package.json +3 -2
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
|
|
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
|
|
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
|
-
|
|
1106
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
5828
|
-
const fileImports =
|
|
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
|
-
|
|
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}.
|
|
8002
|
-
`
|
|
8003
|
-
`
|
|
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:
|
|
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) =>
|
|
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: [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
15300
|
+
admission,
|
|
14973
15301
|
suggested_review_action: suggestedAction(classifyPacket(projectDir, result.packet), result.packet.status),
|
|
14974
15302
|
};
|
|
14975
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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",
|