@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 +39 -0
- package/dist/index.js +38 -22
- package/dist/kernel.js +358 -30
- 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,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
|
|
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
|
|
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
|
-
|
|
1095
|
-
|
|
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
|
-
|
|
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);
|
|
@@ -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) =>
|
|
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: [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
+
"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",
|