@kage-core/kage-graph-mcp 1.3.0 → 2.0.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 +171 -137
- package/dist/daemon.js +78 -55
- package/dist/index.js +24 -182
- package/dist/kernel.js +1236 -297
- package/dist/structural-worker.js +17 -13
- package/package.json +4 -2
- package/viewer/console.js +783 -0
- package/viewer/index.html +338 -281
- package/viewer/app.js +0 -6782
- package/viewer/data.html +0 -296
- package/viewer/graph.html +0 -296
- package/viewer/intel.html +0 -296
- package/viewer/memory.html +0 -367
- package/viewer/owners.html +0 -296
- package/viewer/review.html +0 -307
- package/viewer/styles.css +0 -2878
package/dist/kernel.js
CHANGED
|
@@ -62,6 +62,10 @@ exports.makePacketId = makePacketId;
|
|
|
62
62
|
exports.parseFrontmatter = parseFrontmatter;
|
|
63
63
|
exports.kageMemoryAccess = kageMemoryAccess;
|
|
64
64
|
exports.kageMemoryLifecycle = kageMemoryLifecycle;
|
|
65
|
+
exports.recordValueEvent = recordValueEvent;
|
|
66
|
+
exports.valueSummary = valueSummary;
|
|
67
|
+
exports.formatTokenCount = formatTokenCount;
|
|
68
|
+
exports.kageActivity = kageActivity;
|
|
65
69
|
exports.kageMemoryReconciliation = kageMemoryReconciliation;
|
|
66
70
|
exports.evaluateMemoryAdmission = evaluateMemoryAdmission;
|
|
67
71
|
exports.validatePacket = validatePacket;
|
|
@@ -75,6 +79,8 @@ exports.kageMemoryAudit = kageMemoryAudit;
|
|
|
75
79
|
exports.kageMemoryHandoff = kageMemoryHandoff;
|
|
76
80
|
exports.buildStructuralFileForWorker = buildStructuralFileForWorker;
|
|
77
81
|
exports.buildStructuralIndex = buildStructuralIndex;
|
|
82
|
+
exports.treeSitterLanguagesForPaths = treeSitterLanguagesForPaths;
|
|
83
|
+
exports.ensureTreeSitterLanguages = ensureTreeSitterLanguages;
|
|
78
84
|
exports.writeLspSymbolIndex = writeLspSymbolIndex;
|
|
79
85
|
exports.writeCodeIndex = writeCodeIndex;
|
|
80
86
|
exports.buildCodeGraph = buildCodeGraph;
|
|
@@ -96,6 +102,7 @@ exports.kageTeammateBrief = kageTeammateBrief;
|
|
|
96
102
|
exports.kageRisk = kageRisk;
|
|
97
103
|
exports.kageDependencyPath = kageDependencyPath;
|
|
98
104
|
exports.kageCleanupCandidates = kageCleanupCandidates;
|
|
105
|
+
exports.truthReport = truthReport;
|
|
99
106
|
exports.kageReviewerSuggestions = kageReviewerSuggestions;
|
|
100
107
|
exports.kageContributors = kageContributors;
|
|
101
108
|
exports.kageContextSlots = kageContextSlots;
|
|
@@ -137,19 +144,13 @@ exports.proposeFromDiff = proposeFromDiff;
|
|
|
137
144
|
exports.buildBranchOverlay = buildBranchOverlay;
|
|
138
145
|
exports.createReviewArtifact = createReviewArtifact;
|
|
139
146
|
exports.prSummarize = prSummarize;
|
|
147
|
+
exports.staleCatch = staleCatch;
|
|
148
|
+
exports.formatStaleCatch = formatStaleCatch;
|
|
140
149
|
exports.prCheck = prCheck;
|
|
141
150
|
exports.kageHookStatus = kageHookStatus;
|
|
142
151
|
exports.kageHookInstall = kageHookInstall;
|
|
143
152
|
exports.kageHookUninstall = kageHookUninstall;
|
|
144
153
|
exports.exportPublicBundle = exportPublicBundle;
|
|
145
|
-
exports.orgStatus = orgStatus;
|
|
146
|
-
exports.orgUploadPacket = orgUploadPacket;
|
|
147
|
-
exports.orgReviewPacket = orgReviewPacket;
|
|
148
|
-
exports.orgRecall = orgRecall;
|
|
149
|
-
exports.layeredRecall = layeredRecall;
|
|
150
|
-
exports.exportOrgRegistry = exportOrgRegistry;
|
|
151
|
-
exports.buildMarketplace = buildMarketplace;
|
|
152
|
-
exports.buildGlobalCdnBundle = buildGlobalCdnBundle;
|
|
153
154
|
exports.recordFeedback = recordFeedback;
|
|
154
155
|
exports.validateProject = validateProject;
|
|
155
156
|
exports.initProject = initProject;
|
|
@@ -873,6 +874,8 @@ function kageMemoryLifecycle(projectDir) {
|
|
|
873
874
|
const item = {
|
|
874
875
|
packet_id: packet.id,
|
|
875
876
|
title: packet.title,
|
|
877
|
+
summary: packet.summary ?? "",
|
|
878
|
+
body: packet.body ?? "",
|
|
876
879
|
type: packet.type,
|
|
877
880
|
status: packet.status,
|
|
878
881
|
health: action.health,
|
|
@@ -957,6 +960,217 @@ function recordRecallAccess(projectDir, results) {
|
|
|
957
960
|
// Recall should never fail because local access telemetry could not be updated.
|
|
958
961
|
}
|
|
959
962
|
}
|
|
963
|
+
// ---------------------------------------------------------------------------
|
|
964
|
+
// Value ledger: persistent per-repo receipts of what the harness actually saved
|
|
965
|
+
// or blocked — tokens not spent re-reading cited source, hard-stale memories
|
|
966
|
+
// withheld from recall, caller-intent questions answered from the call graph.
|
|
967
|
+
// Read by `kage gains` and the receipt lines appended to recall/context output.
|
|
968
|
+
// ---------------------------------------------------------------------------
|
|
969
|
+
const VALUE_LEDGER_SCHEMA_VERSION = 1;
|
|
970
|
+
const VALUE_LEDGER_EVENT_CAP = 5000;
|
|
971
|
+
// Rough Sonnet-class input price used for the dollar estimate: $15 per 1M tokens.
|
|
972
|
+
const VALUE_DOLLARS_PER_MILLION_TOKENS = 15;
|
|
973
|
+
function valueLedgerPath(projectDir) {
|
|
974
|
+
return (0, node_path_1.join)(reportsDir(projectDir), "value.json");
|
|
975
|
+
}
|
|
976
|
+
function emptyValueLedger() {
|
|
977
|
+
return {
|
|
978
|
+
schema_version: VALUE_LEDGER_SCHEMA_VERSION,
|
|
979
|
+
totals: { tokens_saved: 0, stale_withheld: 0, stale_caught: 0, recalls: 0, caller_answers: 0 },
|
|
980
|
+
events: [],
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
function nonNegativeCount(value) {
|
|
984
|
+
const parsed = Number(value);
|
|
985
|
+
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 0;
|
|
986
|
+
}
|
|
987
|
+
function readValueLedger(projectDir) {
|
|
988
|
+
const path = valueLedgerPath(projectDir);
|
|
989
|
+
if (!(0, node_fs_1.existsSync)(path))
|
|
990
|
+
return emptyValueLedger();
|
|
991
|
+
try {
|
|
992
|
+
const raw = readJson(path);
|
|
993
|
+
const totals = (raw.totals ?? {});
|
|
994
|
+
const events = (Array.isArray(raw.events) ? raw.events : [])
|
|
995
|
+
.filter((event) => {
|
|
996
|
+
const candidate = event;
|
|
997
|
+
return Boolean(candidate)
|
|
998
|
+
&& typeof candidate?.at === "string"
|
|
999
|
+
&& Number.isFinite(Date.parse(candidate.at))
|
|
1000
|
+
&& (candidate.kind === "recall_served" || candidate.kind === "stale_withheld" || candidate.kind === "stale_caught" || candidate.kind === "caller_answered");
|
|
1001
|
+
})
|
|
1002
|
+
.slice(-VALUE_LEDGER_EVENT_CAP);
|
|
1003
|
+
return {
|
|
1004
|
+
schema_version: VALUE_LEDGER_SCHEMA_VERSION,
|
|
1005
|
+
totals: {
|
|
1006
|
+
tokens_saved: nonNegativeCount(totals.tokens_saved),
|
|
1007
|
+
stale_withheld: nonNegativeCount(totals.stale_withheld),
|
|
1008
|
+
stale_caught: nonNegativeCount(totals.stale_caught),
|
|
1009
|
+
recalls: nonNegativeCount(totals.recalls),
|
|
1010
|
+
caller_answers: nonNegativeCount(totals.caller_answers),
|
|
1011
|
+
},
|
|
1012
|
+
events,
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
catch {
|
|
1016
|
+
return emptyValueLedger();
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
function recordValueEvents(projectDir, events) {
|
|
1020
|
+
if (!events.length)
|
|
1021
|
+
return;
|
|
1022
|
+
try {
|
|
1023
|
+
const ledger = readValueLedger(projectDir);
|
|
1024
|
+
const at = nowIso();
|
|
1025
|
+
for (const event of events) {
|
|
1026
|
+
const record = { at, kind: event.kind };
|
|
1027
|
+
if (event.kind === "recall_served") {
|
|
1028
|
+
record.tokens_saved = nonNegativeCount(event.tokens_saved);
|
|
1029
|
+
ledger.totals.tokens_saved += record.tokens_saved;
|
|
1030
|
+
ledger.totals.recalls += 1;
|
|
1031
|
+
}
|
|
1032
|
+
else if (event.kind === "stale_withheld") {
|
|
1033
|
+
record.packet_title = event.packet_title;
|
|
1034
|
+
ledger.totals.stale_withheld += 1;
|
|
1035
|
+
}
|
|
1036
|
+
else if (event.kind === "stale_caught") {
|
|
1037
|
+
record.packet_title = event.packet_title;
|
|
1038
|
+
ledger.totals.stale_caught += 1;
|
|
1039
|
+
}
|
|
1040
|
+
else {
|
|
1041
|
+
ledger.totals.caller_answers += 1;
|
|
1042
|
+
}
|
|
1043
|
+
ledger.events.push(record);
|
|
1044
|
+
}
|
|
1045
|
+
if (ledger.events.length > VALUE_LEDGER_EVENT_CAP)
|
|
1046
|
+
ledger.events = ledger.events.slice(-VALUE_LEDGER_EVENT_CAP);
|
|
1047
|
+
// Atomic read-modify-write: write to a temp file then rename so a crashed or
|
|
1048
|
+
// concurrent writer can never leave a torn value.json behind.
|
|
1049
|
+
const path = valueLedgerPath(projectDir);
|
|
1050
|
+
ensureDir((0, node_path_1.dirname)(path));
|
|
1051
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
1052
|
+
(0, node_fs_1.writeFileSync)(tmp, `${JSON.stringify(ledger, null, 2)}\n`, "utf8");
|
|
1053
|
+
(0, node_fs_1.renameSync)(tmp, path);
|
|
1054
|
+
}
|
|
1055
|
+
catch {
|
|
1056
|
+
// Value telemetry must never break recall or code-graph queries.
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
function recordValueEvent(projectDir, event) {
|
|
1060
|
+
recordValueEvents(projectDir, [event]);
|
|
1061
|
+
}
|
|
1062
|
+
function estimatedTokenDollars(tokensSaved) {
|
|
1063
|
+
return Number(((tokensSaved / 1_000_000) * VALUE_DOLLARS_PER_MILLION_TOKENS).toFixed(2));
|
|
1064
|
+
}
|
|
1065
|
+
function summarizeValueWindow(events, cutoff) {
|
|
1066
|
+
const window = { tokens_saved: 0, stale_withheld: 0, stale_caught: 0, recalls: 0, caller_answers: 0 };
|
|
1067
|
+
for (const event of events) {
|
|
1068
|
+
const at = Date.parse(event.at);
|
|
1069
|
+
if (!Number.isFinite(at) || at < cutoff)
|
|
1070
|
+
continue;
|
|
1071
|
+
if (event.kind === "recall_served") {
|
|
1072
|
+
window.recalls += 1;
|
|
1073
|
+
window.tokens_saved += nonNegativeCount(event.tokens_saved);
|
|
1074
|
+
}
|
|
1075
|
+
else if (event.kind === "stale_withheld") {
|
|
1076
|
+
window.stale_withheld += 1;
|
|
1077
|
+
}
|
|
1078
|
+
else if (event.kind === "stale_caught") {
|
|
1079
|
+
window.stale_caught += 1;
|
|
1080
|
+
}
|
|
1081
|
+
else {
|
|
1082
|
+
window.caller_answers += 1;
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
return { ...window, estimated_dollars: estimatedTokenDollars(window.tokens_saved) };
|
|
1086
|
+
}
|
|
1087
|
+
function valueSummary(projectDir) {
|
|
1088
|
+
const ledger = readValueLedger(projectDir);
|
|
1089
|
+
const todayStart = new Date();
|
|
1090
|
+
todayStart.setHours(0, 0, 0, 0);
|
|
1091
|
+
return {
|
|
1092
|
+
schema_version: VALUE_LEDGER_SCHEMA_VERSION,
|
|
1093
|
+
project_dir: (0, node_path_1.resolve)(projectDir),
|
|
1094
|
+
today: summarizeValueWindow(ledger.events, todayStart.getTime()),
|
|
1095
|
+
last_7d: summarizeValueWindow(ledger.events, Date.now() - 7 * 86_400_000),
|
|
1096
|
+
all_time: { ...ledger.totals, estimated_dollars: estimatedTokenDollars(ledger.totals.tokens_saved) },
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
// Human display for ledger token counts: 412 -> "412", 412_345 -> "412K", 4_120_000 -> "4.1M".
|
|
1100
|
+
function formatTokenCount(tokens) {
|
|
1101
|
+
const count = Math.max(0, Math.round(tokens));
|
|
1102
|
+
if (count >= 1_000_000)
|
|
1103
|
+
return `${(count / 1_000_000).toFixed(1)}M`;
|
|
1104
|
+
if (count >= 1_000)
|
|
1105
|
+
return `${Math.round(count / 1_000)}K`;
|
|
1106
|
+
return String(count);
|
|
1107
|
+
}
|
|
1108
|
+
// Receipt math: tokens an agent would have spent reading the cited source files
|
|
1109
|
+
// of the served packets (bytes / 4) minus the tokens the recall context block
|
|
1110
|
+
// itself costs (length / 4). Floored at zero — a recall never "costs" savings.
|
|
1111
|
+
function recallTokensSaved(projectDir, results, contextBlock) {
|
|
1112
|
+
const paths = unique(results.flatMap((entry) => entry.packet.paths).filter((path) => meaningfulMemoryPath(path)));
|
|
1113
|
+
let sourceBytes = 0;
|
|
1114
|
+
for (const path of paths) {
|
|
1115
|
+
try {
|
|
1116
|
+
const stats = (0, node_fs_1.statSync)((0, node_path_1.join)(projectDir, path));
|
|
1117
|
+
if (stats.isFile())
|
|
1118
|
+
sourceBytes += stats.size;
|
|
1119
|
+
}
|
|
1120
|
+
catch {
|
|
1121
|
+
// Missing cited files save nothing.
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
return Math.max(0, Math.floor(sourceBytes / 4) - Math.floor(contextBlock.length / 4));
|
|
1125
|
+
}
|
|
1126
|
+
const AUDIT_ACTIVITY_KIND = {
|
|
1127
|
+
capture: "capture", approve: "capture", supersede: "supersede", deprecate: "deprecate",
|
|
1128
|
+
update: "update", promote: "promote", feedback: "feedback",
|
|
1129
|
+
};
|
|
1130
|
+
function kageActivity(projectDir, options = {}) {
|
|
1131
|
+
const limit = options.limit ?? 80;
|
|
1132
|
+
const events = [];
|
|
1133
|
+
let recalls = 0;
|
|
1134
|
+
readMemoryAccessEntries(projectDir).forEach((entry) => {
|
|
1135
|
+
(entry.recent ?? []).forEach((r) => {
|
|
1136
|
+
if (!r || !r.at)
|
|
1137
|
+
return;
|
|
1138
|
+
recalls += 1;
|
|
1139
|
+
events.push({ at: r.at, kind: "recall", title: entry.title, detail: `recalled · rank ${r.rank}` });
|
|
1140
|
+
});
|
|
1141
|
+
});
|
|
1142
|
+
let captures = 0;
|
|
1143
|
+
for (const audit of loadMemoryAuditEntries(projectDir)) {
|
|
1144
|
+
const kind = AUDIT_ACTIVITY_KIND[audit.operation] ?? "other";
|
|
1145
|
+
if (kind === "capture")
|
|
1146
|
+
captures += 1;
|
|
1147
|
+
const extra = audit.packet_titles.length > 1 ? ` (+${audit.packet_titles.length - 1} more)` : "";
|
|
1148
|
+
events.push({ at: audit.timestamp, kind, title: (audit.packet_titles[0] ?? audit.operation) + extra, detail: audit.operation, actor: audit.actor });
|
|
1149
|
+
}
|
|
1150
|
+
events.sort((a, b) => (Date.parse(b.at) || 0) - (Date.parse(a.at) || 0));
|
|
1151
|
+
const dayMap = new Map();
|
|
1152
|
+
events.forEach((e) => { if (e.kind === "recall") {
|
|
1153
|
+
const d = e.at.slice(0, 10);
|
|
1154
|
+
dayMap.set(d, (dayMap.get(d) ?? 0) + 1);
|
|
1155
|
+
} });
|
|
1156
|
+
// Zero-fill the last 14 calendar days so the chart reads as a timeline, not a lone bar.
|
|
1157
|
+
const daily = [];
|
|
1158
|
+
for (let i = 13; i >= 0; i -= 1) {
|
|
1159
|
+
const day = new Date(Date.now() - i * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
|
1160
|
+
daily.push({ day, recalls: dayMap.get(day) ?? 0 });
|
|
1161
|
+
}
|
|
1162
|
+
const cutoff7 = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
|
1163
|
+
const recalls7d = events.filter((e) => e.kind === "recall" && (Date.parse(e.at) || 0) >= cutoff7).length;
|
|
1164
|
+
return {
|
|
1165
|
+
schema_version: 1,
|
|
1166
|
+
project_dir: projectDir,
|
|
1167
|
+
generated_at: nowIso(),
|
|
1168
|
+
window_days: ACCESS_WINDOW_DAYS,
|
|
1169
|
+
totals: { events: events.length, recalls, captures, recalls_7d: recalls7d },
|
|
1170
|
+
daily,
|
|
1171
|
+
events: events.slice(0, limit),
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
960
1174
|
function isGeneratedChangeMemory(packet) {
|
|
961
1175
|
return packet.type === "workflow"
|
|
962
1176
|
&& packet.tags.includes("change-memory")
|
|
@@ -1053,6 +1267,10 @@ function fingerprintableMemoryPath(path) {
|
|
|
1053
1267
|
const normalized = path.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
1054
1268
|
return meaningfulMemoryPath(normalized) && !normalized.startsWith(".agent_memory/");
|
|
1055
1269
|
}
|
|
1270
|
+
// Process-level fingerprint cache validated by mtime+size: staleness checks run on
|
|
1271
|
+
// every recall, and re-hashing every grounded file dominated recall latency on
|
|
1272
|
+
// repos with many packets. Content changes always re-hash (mtime/size moves).
|
|
1273
|
+
const fingerprintProcessCache = new Map();
|
|
1056
1274
|
function memoryPathFingerprint(projectDir, path, cache) {
|
|
1057
1275
|
const normalized = path.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
1058
1276
|
if (!fingerprintableMemoryPath(normalized))
|
|
@@ -1067,11 +1285,17 @@ function memoryPathFingerprint(projectDir, path, cache) {
|
|
|
1067
1285
|
cache?.set(cacheKey, null);
|
|
1068
1286
|
return null;
|
|
1069
1287
|
}
|
|
1288
|
+
const warm = fingerprintProcessCache.get(cacheKey);
|
|
1289
|
+
if (warm && warm.mtimeMs === stats.mtimeMs && warm.size === stats.size) {
|
|
1290
|
+
cache?.set(cacheKey, warm.fingerprint);
|
|
1291
|
+
return warm.fingerprint;
|
|
1292
|
+
}
|
|
1070
1293
|
const fingerprint = {
|
|
1071
1294
|
path: normalized,
|
|
1072
1295
|
sha256: sha256Hex((0, node_fs_1.readFileSync)(absolutePath)),
|
|
1073
1296
|
size: stats.size,
|
|
1074
1297
|
};
|
|
1298
|
+
fingerprintProcessCache.set(cacheKey, { mtimeMs: stats.mtimeMs, size: stats.size, fingerprint });
|
|
1075
1299
|
cache?.set(cacheKey, fingerprint);
|
|
1076
1300
|
return fingerprint;
|
|
1077
1301
|
}
|
|
@@ -1122,7 +1346,7 @@ function staleMemoryReasons(projectDir, packet, fingerprintCache) {
|
|
|
1122
1346
|
if (ageDays > ttlDays)
|
|
1123
1347
|
reasons.push(`freshness ttl expired (${Math.floor(ageDays)}d old, ttl ${ttlDays}d)`);
|
|
1124
1348
|
}
|
|
1125
|
-
const paths = packet.paths.filter(meaningfulMemoryPath);
|
|
1349
|
+
const paths = packet.paths.filter((path) => meaningfulMemoryPath(path) && !isGroundingIgnored(projectDir, path));
|
|
1126
1350
|
const missingPaths = paths.filter((path) => !(0, node_fs_1.existsSync)((0, node_path_1.join)(projectDir, path)));
|
|
1127
1351
|
if (paths.length > 0 && missingPaths.length === paths.length) {
|
|
1128
1352
|
reasons.push(`all referenced paths are missing: ${missingPaths.slice(0, 4).join(", ")}`);
|
|
@@ -1133,6 +1357,7 @@ function staleMemoryReasons(projectDir, packet, fingerprintCache) {
|
|
|
1133
1357
|
if (freshness.path_fingerprint_policy === "source_hash_staleness") {
|
|
1134
1358
|
const storedFingerprints = packetStoredPathFingerprints(packet);
|
|
1135
1359
|
const changedPaths = storedFingerprints
|
|
1360
|
+
.filter((fingerprint) => !isGroundingIgnored(projectDir, fingerprint.path))
|
|
1136
1361
|
.filter((fingerprint) => (0, node_fs_1.existsSync)((0, node_path_1.join)(projectDir, fingerprint.path)))
|
|
1137
1362
|
.filter((fingerprint) => {
|
|
1138
1363
|
const current = memoryPathFingerprint(projectDir, fingerprint.path, fingerprintCache);
|
|
@@ -2341,7 +2566,7 @@ function pathExistsInRepo(projectDir, packetPath) {
|
|
|
2341
2566
|
}
|
|
2342
2567
|
function packetGroundingWarnings(projectDir, packet, source) {
|
|
2343
2568
|
const warnings = [];
|
|
2344
|
-
const meaningfulPaths = packet.paths.filter((path) => path && path !== "root" && !shouldSkipRepoMemoryPath(path));
|
|
2569
|
+
const meaningfulPaths = packet.paths.filter((path) => path && path !== "root" && !shouldSkipRepoMemoryPath(path) && !isGroundingIgnored(projectDir, path));
|
|
2345
2570
|
const missingPaths = meaningfulPaths.filter((path) => !pathExistsInRepo(projectDir, path));
|
|
2346
2571
|
if (meaningfulPaths.length && missingPaths.length === meaningfulPaths.length) {
|
|
2347
2572
|
warnings.push(`${source}: none of the referenced paths exist in this repo: ${missingPaths.join(", ")}`);
|
|
@@ -2455,6 +2680,10 @@ function shouldSkipCodePath(relativePath) {
|
|
|
2455
2680
|
.some((part) => [
|
|
2456
2681
|
".git",
|
|
2457
2682
|
".agent_memory",
|
|
2683
|
+
// Agent working dirs: .claude holds settings AND worktrees/ (parallel agent
|
|
2684
|
+
// checkouts) — indexing them pollutes the code graph with phantom duplicates.
|
|
2685
|
+
".claude",
|
|
2686
|
+
".codex",
|
|
2458
2687
|
"node_modules",
|
|
2459
2688
|
"vendor",
|
|
2460
2689
|
".venv",
|
|
@@ -2738,7 +2967,9 @@ function structuralFileCacheDir(projectDir) {
|
|
|
2738
2967
|
function structuralPackedFileCachePath(projectDir) {
|
|
2739
2968
|
return (0, node_path_1.join)(structuralIndexDir(projectDir), "file-cache.json");
|
|
2740
2969
|
}
|
|
2741
|
-
|
|
2970
|
+
// Bump whenever symbol/call extraction changes, or cached per-file results
|
|
2971
|
+
// keep serving pre-change output and upgrades silently never land.
|
|
2972
|
+
const STRUCTURAL_EXTRACTOR_VERSION = 4; // v4: tree-sitter symbols for python/go/rust/java/ruby
|
|
2742
2973
|
function structuralFileCachePath(projectDir, rel, hash) {
|
|
2743
2974
|
return (0, node_path_1.join)(structuralFileCacheDir(projectDir), `v${STRUCTURAL_EXTRACTOR_VERSION}-${slugify(rel)}-${hash}.json`);
|
|
2744
2975
|
}
|
|
@@ -2805,6 +3036,54 @@ function readKageIgnore(projectDir) {
|
|
|
2805
3036
|
.map((line) => line.trim())
|
|
2806
3037
|
.filter((line) => line.length > 0 && !line.startsWith("#"));
|
|
2807
3038
|
}
|
|
3039
|
+
// A repo can declare non-knowledge paths (e.g. a presentation/visualization layer)
|
|
3040
|
+
// in .kageignore. Those paths must not count as memory grounding: memory should never
|
|
3041
|
+
// be anchored to, or marked stale by, files the repo says are not knowledge-bearing.
|
|
3042
|
+
function normalizeRelPath(path) {
|
|
3043
|
+
return String(path).replace(/\\/g, "/").replace(/^\/+/, "");
|
|
3044
|
+
}
|
|
3045
|
+
function isGroundingIgnored(projectDir, path) {
|
|
3046
|
+
const patterns = readKageIgnore(projectDir);
|
|
3047
|
+
if (!patterns.length)
|
|
3048
|
+
return false;
|
|
3049
|
+
return isKageIgnored(normalizeRelPath(path), patterns);
|
|
3050
|
+
}
|
|
3051
|
+
// Strip .kageignore'd paths from a packet's grounding (paths, source refs, and
|
|
3052
|
+
// path fingerprints). Returns a new packet if anything changed, else null.
|
|
3053
|
+
function prunePacketGroundingPaths(packet, patterns) {
|
|
3054
|
+
if (!patterns.length)
|
|
3055
|
+
return null;
|
|
3056
|
+
const ignored = (p) => typeof p === "string" && isKageIgnored(normalizeRelPath(p), patterns);
|
|
3057
|
+
let changed = false;
|
|
3058
|
+
const paths = packet.paths.filter((p) => (ignored(p) ? ((changed = true), false) : true));
|
|
3059
|
+
const sourceRefs = packet.source_refs.map((ref) => {
|
|
3060
|
+
const next = { ...ref };
|
|
3061
|
+
if (ignored(next.path)) {
|
|
3062
|
+
delete next.path;
|
|
3063
|
+
changed = true;
|
|
3064
|
+
}
|
|
3065
|
+
if (Array.isArray(next.changed_files)) {
|
|
3066
|
+
const kept = next.changed_files.filter((f) => !ignored(f));
|
|
3067
|
+
if (kept.length !== next.changed_files.length) {
|
|
3068
|
+
next.changed_files = kept;
|
|
3069
|
+
changed = true;
|
|
3070
|
+
}
|
|
3071
|
+
}
|
|
3072
|
+
return next;
|
|
3073
|
+
});
|
|
3074
|
+
const freshness = { ...(packet.freshness ?? {}) };
|
|
3075
|
+
if (Array.isArray(freshness.path_fingerprints)) {
|
|
3076
|
+
const fps = freshness.path_fingerprints;
|
|
3077
|
+
const kept = fps.filter((f) => !ignored(f?.path));
|
|
3078
|
+
if (kept.length !== fps.length) {
|
|
3079
|
+
freshness.path_fingerprints = kept;
|
|
3080
|
+
changed = true;
|
|
3081
|
+
}
|
|
3082
|
+
}
|
|
3083
|
+
if (!changed)
|
|
3084
|
+
return null;
|
|
3085
|
+
return { ...packet, paths, source_refs: sourceRefs, freshness };
|
|
3086
|
+
}
|
|
2808
3087
|
function wildcardPattern(pattern) {
|
|
2809
3088
|
const escaped = pattern
|
|
2810
3089
|
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
@@ -3103,6 +3382,16 @@ function writeStructuralFileCachePack(projectDir, results) {
|
|
|
3103
3382
|
packedStructuralCache.delete((0, node_path_1.resolve)(projectDir));
|
|
3104
3383
|
(0, node_fs_1.rmSync)(structuralFileCacheDir(projectDir), { recursive: true, force: true });
|
|
3105
3384
|
}
|
|
3385
|
+
// A regex-tier cache entry for a language whose tree-sitter grammar is now
|
|
3386
|
+
// loaded would pin weak symbols past the upgrade; treat it as a miss so the
|
|
3387
|
+
// file re-extracts at the stronger tier.
|
|
3388
|
+
function usableStructuralCache(rel, cached) {
|
|
3389
|
+
if (!cached)
|
|
3390
|
+
return null;
|
|
3391
|
+
if (treeSitterParserFor(rel) && cached.symbols.some((symbol) => symbol.parser === "generic-static"))
|
|
3392
|
+
return null;
|
|
3393
|
+
return cached;
|
|
3394
|
+
}
|
|
3106
3395
|
function buildStructuralFile(projectDir, absolutePath, knownFiles, prior) {
|
|
3107
3396
|
const rel = (0, node_path_1.relative)(projectDir, absolutePath).replace(/\\/g, "/");
|
|
3108
3397
|
const stats = (0, node_fs_1.statSync)(absolutePath);
|
|
@@ -3110,11 +3399,11 @@ function buildStructuralFile(projectDir, absolutePath, knownFiles, prior) {
|
|
|
3110
3399
|
const canReuseHash = priorEntry && priorEntry.size_bytes === stats.size && Math.round(priorEntry.mtime_ms) === Math.round(stats.mtimeMs);
|
|
3111
3400
|
let buffer = canReuseHash ? null : (0, node_fs_1.readFileSync)(absolutePath);
|
|
3112
3401
|
let hash = canReuseHash ? priorEntry.hash : sha256Hex(buffer ?? "");
|
|
3113
|
-
let cached = readCachedStructuralFile(projectDir, rel, hash);
|
|
3402
|
+
let cached = usableStructuralCache(rel, readCachedStructuralFile(projectDir, rel, hash));
|
|
3114
3403
|
if (!cached && !buffer) {
|
|
3115
3404
|
buffer = (0, node_fs_1.readFileSync)(absolutePath);
|
|
3116
3405
|
hash = sha256Hex(buffer);
|
|
3117
|
-
cached = readCachedStructuralFile(projectDir, rel, hash);
|
|
3406
|
+
cached = usableStructuralCache(rel, readCachedStructuralFile(projectDir, rel, hash));
|
|
3118
3407
|
}
|
|
3119
3408
|
const entry = {
|
|
3120
3409
|
path: rel,
|
|
@@ -3136,7 +3425,7 @@ function buildStructuralFile(projectDir, absolutePath, knownFiles, prior) {
|
|
|
3136
3425
|
rawImports.push(...extractImports(projectDir, rel, content, knownFiles));
|
|
3137
3426
|
}
|
|
3138
3427
|
else if (CODE_EXTENSIONS.has(extensionOf(rel))) {
|
|
3139
|
-
rawSymbols.push(...extractGenericSymbols(rel, content));
|
|
3428
|
+
rawSymbols.push(...(extractTreeSitterSymbols(rel, content) ?? extractGenericSymbols(rel, content)));
|
|
3140
3429
|
rawImports.push(...extractGenericImports(projectDir, rel, content, knownFiles));
|
|
3141
3430
|
}
|
|
3142
3431
|
}
|
|
@@ -3558,6 +3847,20 @@ function extractSymbols(path, text) {
|
|
|
3558
3847
|
addSymbol(declaration.name.text, kind, declaration, exported, node.getText(sourceFile).split(/\r?\n/)[0]);
|
|
3559
3848
|
}
|
|
3560
3849
|
}
|
|
3850
|
+
else if (ts.isExpressionStatement(node) &&
|
|
3851
|
+
ts.isBinaryExpression(node.expression) &&
|
|
3852
|
+
node.expression.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
|
|
3853
|
+
ts.isPropertyAccessExpression(node.expression.left) &&
|
|
3854
|
+
(ts.isFunctionExpression(node.expression.right) || ts.isArrowFunction(node.expression.right))) {
|
|
3855
|
+
// Method-assignment pattern: `app.use = function use(fn) {…}`, `proto.handle = (req) => {…}`.
|
|
3856
|
+
// Express/Koa-style APIs define most of their public surface this way; without this
|
|
3857
|
+
// branch those symbols are invisible to the code graph.
|
|
3858
|
+
const left = node.expression.left;
|
|
3859
|
+
const receiver = left.expression.getText(sourceFile);
|
|
3860
|
+
const exported = receiver === "exports" || receiver === "module.exports" || receiver.endsWith(".prototype");
|
|
3861
|
+
const firstLine = `${left.getText(sourceFile)} = ${node.expression.right.getText(sourceFile).split(/\r?\n/)[0] ?? ""}`;
|
|
3862
|
+
addSymbol(left.name.text, "method", node, exported, firstLine.trim().slice(0, 180));
|
|
3863
|
+
}
|
|
3561
3864
|
else if (codeFileKind(path) === "test" && ts.isCallExpression(node)) {
|
|
3562
3865
|
const callee = propertyOrIdentifierName(node.expression);
|
|
3563
3866
|
const first = stringLiteralValue(node.arguments[0]);
|
|
@@ -3569,6 +3872,255 @@ function extractSymbols(path, text) {
|
|
|
3569
3872
|
visit(sourceFile);
|
|
3570
3873
|
return symbols.sort((a, b) => a.line - b.line || a.name.localeCompare(b.name));
|
|
3571
3874
|
}
|
|
3875
|
+
const TREE_SITTER_LANGUAGES = {
|
|
3876
|
+
python: "python",
|
|
3877
|
+
go: "go",
|
|
3878
|
+
rust: "rust",
|
|
3879
|
+
java: "java",
|
|
3880
|
+
ruby: "ruby",
|
|
3881
|
+
};
|
|
3882
|
+
const treeSitterParsers = new Map();
|
|
3883
|
+
const treeSitterFailedLanguages = new Set();
|
|
3884
|
+
// web-tree-sitter mutates its CommonJS export during init, so the pre-init
|
|
3885
|
+
// Parser class is cached once instead of re-required per language.
|
|
3886
|
+
let treeSitterRuntime = null;
|
|
3887
|
+
function treeSitterLanguagesForPaths(paths) {
|
|
3888
|
+
return [...new Set(paths.map((path) => codeLanguage(path)).filter((language) => language in TREE_SITTER_LANGUAGES))];
|
|
3889
|
+
}
|
|
3890
|
+
// web-tree-sitter only initializes asynchronously, so grammars are loaded here —
|
|
3891
|
+
// once per process, before any file loop — and per-file extraction stays
|
|
3892
|
+
// synchronous. A language whose grammar fails to load falls back to the regex
|
|
3893
|
+
// extractor instead of crashing or blocking.
|
|
3894
|
+
async function ensureTreeSitterLanguages(languages = Object.keys(TREE_SITTER_LANGUAGES)) {
|
|
3895
|
+
const wanted = languages.filter((language) => language in TREE_SITTER_LANGUAGES && !treeSitterParsers.has(language) && !treeSitterFailedLanguages.has(language));
|
|
3896
|
+
if (!wanted.length)
|
|
3897
|
+
return;
|
|
3898
|
+
if (!treeSitterRuntime) {
|
|
3899
|
+
try {
|
|
3900
|
+
const Parser = require("web-tree-sitter");
|
|
3901
|
+
await Parser.init();
|
|
3902
|
+
treeSitterRuntime = Parser;
|
|
3903
|
+
}
|
|
3904
|
+
catch {
|
|
3905
|
+
for (const language of wanted)
|
|
3906
|
+
treeSitterFailedLanguages.add(language);
|
|
3907
|
+
return;
|
|
3908
|
+
}
|
|
3909
|
+
}
|
|
3910
|
+
const runtime = treeSitterRuntime;
|
|
3911
|
+
if (!runtime)
|
|
3912
|
+
return;
|
|
3913
|
+
for (const language of wanted) {
|
|
3914
|
+
try {
|
|
3915
|
+
const grammar = await runtime.Language.load(require.resolve(`tree-sitter-wasms/out/tree-sitter-${TREE_SITTER_LANGUAGES[language]}.wasm`));
|
|
3916
|
+
const parser = new runtime();
|
|
3917
|
+
parser.setLanguage(grammar);
|
|
3918
|
+
treeSitterParsers.set(language, parser);
|
|
3919
|
+
}
|
|
3920
|
+
catch {
|
|
3921
|
+
treeSitterFailedLanguages.add(language);
|
|
3922
|
+
}
|
|
3923
|
+
}
|
|
3924
|
+
}
|
|
3925
|
+
function treeSitterParserFor(path) {
|
|
3926
|
+
if (TS_AST_EXTENSIONS.has(extensionOf(path)))
|
|
3927
|
+
return null;
|
|
3928
|
+
return treeSitterParsers.get(codeLanguage(path)) ?? null;
|
|
3929
|
+
}
|
|
3930
|
+
const TREE_SITTER_SYMBOL_NODE_TYPES = {
|
|
3931
|
+
python: ["function_definition", "class_definition", "assignment"],
|
|
3932
|
+
go: ["function_declaration", "method_declaration", "type_spec"],
|
|
3933
|
+
rust: ["function_item", "function_signature_item", "struct_item", "enum_item", "trait_item"],
|
|
3934
|
+
java: ["class_declaration", "interface_declaration", "enum_declaration", "method_declaration", "constructor_declaration"],
|
|
3935
|
+
ruby: ["method", "singleton_method", "class", "module"],
|
|
3936
|
+
};
|
|
3937
|
+
const TREE_SITTER_CALL_NODE_TYPES = {
|
|
3938
|
+
python: ["call"],
|
|
3939
|
+
go: ["call_expression"],
|
|
3940
|
+
rust: ["call_expression"],
|
|
3941
|
+
java: ["method_invocation", "object_creation_expression"],
|
|
3942
|
+
ruby: ["call"],
|
|
3943
|
+
};
|
|
3944
|
+
const TREE_SITTER_CLASS_ANCESTORS = {
|
|
3945
|
+
python: new Set(["class_definition"]),
|
|
3946
|
+
rust: new Set(["impl_item", "trait_item"]),
|
|
3947
|
+
ruby: new Set(["class", "module"]),
|
|
3948
|
+
};
|
|
3949
|
+
function treeSitterHasClassAncestor(language, node) {
|
|
3950
|
+
const types = TREE_SITTER_CLASS_ANCESTORS[language];
|
|
3951
|
+
if (!types)
|
|
3952
|
+
return false;
|
|
3953
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
3954
|
+
if (types.has(current.type))
|
|
3955
|
+
return true;
|
|
3956
|
+
}
|
|
3957
|
+
return false;
|
|
3958
|
+
}
|
|
3959
|
+
function treeSitterSymbolFromNode(language, node) {
|
|
3960
|
+
if (language === "python") {
|
|
3961
|
+
if (node.type === "assignment") {
|
|
3962
|
+
const left = node.childForFieldName("left");
|
|
3963
|
+
if (left?.type !== "identifier" || node.childForFieldName("right")?.type !== "lambda")
|
|
3964
|
+
return null;
|
|
3965
|
+
return { name: left.text, kind: "function", exported: !left.text.startsWith("_") };
|
|
3966
|
+
}
|
|
3967
|
+
const name = node.childForFieldName("name")?.text;
|
|
3968
|
+
if (!name)
|
|
3969
|
+
return null;
|
|
3970
|
+
if (node.type === "class_definition")
|
|
3971
|
+
return { name, kind: "class", exported: !name.startsWith("_") };
|
|
3972
|
+
return { name, kind: treeSitterHasClassAncestor(language, node) ? "method" : "function", exported: !name.startsWith("_") };
|
|
3973
|
+
}
|
|
3974
|
+
if (language === "go") {
|
|
3975
|
+
const name = node.childForFieldName("name")?.text;
|
|
3976
|
+
if (!name)
|
|
3977
|
+
return null;
|
|
3978
|
+
const exported = /^[A-Z]/.test(name);
|
|
3979
|
+
if (node.type === "method_declaration")
|
|
3980
|
+
return { name, kind: "method", exported };
|
|
3981
|
+
if (node.type === "type_spec") {
|
|
3982
|
+
const typeNode = node.childForFieldName("type");
|
|
3983
|
+
if (typeNode?.type !== "struct_type" && typeNode?.type !== "interface_type")
|
|
3984
|
+
return null;
|
|
3985
|
+
return { name, kind: "class", exported };
|
|
3986
|
+
}
|
|
3987
|
+
return { name, kind: "function", exported };
|
|
3988
|
+
}
|
|
3989
|
+
if (language === "rust") {
|
|
3990
|
+
const name = node.childForFieldName("name")?.text;
|
|
3991
|
+
if (!name)
|
|
3992
|
+
return null;
|
|
3993
|
+
const exported = node.namedChildren.some((child) => child.type === "visibility_modifier");
|
|
3994
|
+
if (node.type === "struct_item" || node.type === "enum_item" || node.type === "trait_item")
|
|
3995
|
+
return { name, kind: "class", exported };
|
|
3996
|
+
return { name, kind: treeSitterHasClassAncestor(language, node) ? "method" : "function", exported };
|
|
3997
|
+
}
|
|
3998
|
+
if (language === "java") {
|
|
3999
|
+
const name = node.childForFieldName("name")?.text;
|
|
4000
|
+
if (!name)
|
|
4001
|
+
return null;
|
|
4002
|
+
const exported = node.namedChildren.some((child) => child.type === "modifiers" && /\bpublic\b/.test(child.text));
|
|
4003
|
+
if (node.type === "method_declaration" || node.type === "constructor_declaration")
|
|
4004
|
+
return { name, kind: "method", exported };
|
|
4005
|
+
return { name, kind: "class", exported };
|
|
4006
|
+
}
|
|
4007
|
+
if (language === "ruby") {
|
|
4008
|
+
const name = node.childForFieldName("name")?.text;
|
|
4009
|
+
if (!name)
|
|
4010
|
+
return null;
|
|
4011
|
+
if (node.type === "class" || node.type === "module")
|
|
4012
|
+
return { name, kind: "class", exported: true };
|
|
4013
|
+
return { name, kind: treeSitterHasClassAncestor(language, node) ? "method" : "function", exported: !name.startsWith("_") };
|
|
4014
|
+
}
|
|
4015
|
+
return null;
|
|
4016
|
+
}
|
|
4017
|
+
function extractTreeSitterSymbols(path, text) {
|
|
4018
|
+
const parser = treeSitterParserFor(path);
|
|
4019
|
+
if (!parser)
|
|
4020
|
+
return null;
|
|
4021
|
+
const language = codeLanguage(path);
|
|
4022
|
+
const fileKind = codeFileKind(path);
|
|
4023
|
+
let tree;
|
|
4024
|
+
try {
|
|
4025
|
+
tree = parser.parse(text);
|
|
4026
|
+
}
|
|
4027
|
+
catch {
|
|
4028
|
+
return null;
|
|
4029
|
+
}
|
|
4030
|
+
const symbols = [];
|
|
4031
|
+
try {
|
|
4032
|
+
for (const node of tree.rootNode.descendantsOfType(TREE_SITTER_SYMBOL_NODE_TYPES[language] ?? [])) {
|
|
4033
|
+
const fact = treeSitterSymbolFromNode(language, node);
|
|
4034
|
+
if (!fact)
|
|
4035
|
+
continue;
|
|
4036
|
+
const kind = fact.kind !== "class" && fileKind === "test" && /^(test_|Test|it_|should_)/.test(fact.name) ? "test" : fact.kind;
|
|
4037
|
+
const line = node.startPosition.row + 1;
|
|
4038
|
+
symbols.push({
|
|
4039
|
+
id: symbolId(path, fact.name, kind, line),
|
|
4040
|
+
name: fact.name,
|
|
4041
|
+
kind,
|
|
4042
|
+
path,
|
|
4043
|
+
language,
|
|
4044
|
+
parser: "tree-sitter",
|
|
4045
|
+
export: fact.exported,
|
|
4046
|
+
line,
|
|
4047
|
+
end_line: node.endPosition.row + 1,
|
|
4048
|
+
signature: node.text.split("\n", 1)[0].trim().slice(0, 180),
|
|
4049
|
+
});
|
|
4050
|
+
}
|
|
4051
|
+
}
|
|
4052
|
+
finally {
|
|
4053
|
+
tree.delete();
|
|
4054
|
+
}
|
|
4055
|
+
return symbols.sort((a, b) => a.line - b.line || a.name.localeCompare(b.name));
|
|
4056
|
+
}
|
|
4057
|
+
function treeSitterCalleeName(language, node) {
|
|
4058
|
+
if (language === "java") {
|
|
4059
|
+
if (node.type === "object_creation_expression")
|
|
4060
|
+
return node.childForFieldName("type")?.text.split("<")[0].split(".").pop() ?? null;
|
|
4061
|
+
return node.childForFieldName("name")?.text ?? null;
|
|
4062
|
+
}
|
|
4063
|
+
if (language === "ruby") {
|
|
4064
|
+
const method = node.childForFieldName("method");
|
|
4065
|
+
return method?.type === "identifier" ? method.text : null;
|
|
4066
|
+
}
|
|
4067
|
+
const callee = node.childForFieldName("function");
|
|
4068
|
+
if (!callee)
|
|
4069
|
+
return null;
|
|
4070
|
+
if (callee.type === "identifier")
|
|
4071
|
+
return callee.text;
|
|
4072
|
+
if (callee.type === "attribute")
|
|
4073
|
+
return callee.childForFieldName("attribute")?.text ?? null;
|
|
4074
|
+
if (callee.type === "selector_expression" || callee.type === "field_expression")
|
|
4075
|
+
return callee.childForFieldName("field")?.text ?? null;
|
|
4076
|
+
if (callee.type === "scoped_identifier")
|
|
4077
|
+
return callee.childForFieldName("name")?.text ?? null;
|
|
4078
|
+
return null;
|
|
4079
|
+
}
|
|
4080
|
+
function extractTreeSitterCalls(path, text, symbols, symbolByName, context = EMPTY_CALL_RESOLUTION) {
|
|
4081
|
+
const parser = treeSitterParserFor(path);
|
|
4082
|
+
if (!parser)
|
|
4083
|
+
return null;
|
|
4084
|
+
const language = codeLanguage(path);
|
|
4085
|
+
let tree;
|
|
4086
|
+
try {
|
|
4087
|
+
tree = parser.parse(text);
|
|
4088
|
+
}
|
|
4089
|
+
catch {
|
|
4090
|
+
return null;
|
|
4091
|
+
}
|
|
4092
|
+
const calls = [];
|
|
4093
|
+
try {
|
|
4094
|
+
for (const node of tree.rootNode.descendantsOfType(TREE_SITTER_CALL_NODE_TYPES[language] ?? [])) {
|
|
4095
|
+
if (calls.length >= MAX_CODE_GRAPH_CALLS_PER_FILE)
|
|
4096
|
+
break;
|
|
4097
|
+
const name = treeSitterCalleeName(language, node);
|
|
4098
|
+
if (!name || !/^[A-Za-z_]\w*$/.test(name))
|
|
4099
|
+
continue;
|
|
4100
|
+
const line = node.startPosition.row + 1;
|
|
4101
|
+
const targets = symbolByName.get(name)?.filter((target) => target.path !== path || target.line !== line);
|
|
4102
|
+
if (!targets?.length)
|
|
4103
|
+
continue;
|
|
4104
|
+
const caller = symbolAtLine(symbols, path, line);
|
|
4105
|
+
for (const { target, confidence } of resolveCallTargets(name, path, targets, context, { local: 0.8, imported: 0.75, sameDir: 0.55, nameOnly: 0.32 })) {
|
|
4106
|
+
if (calls.length >= MAX_CODE_GRAPH_CALLS_PER_FILE)
|
|
4107
|
+
break;
|
|
4108
|
+
calls.push({
|
|
4109
|
+
from_symbol: caller?.id ?? null,
|
|
4110
|
+
to_symbol: target.id,
|
|
4111
|
+
path,
|
|
4112
|
+
line,
|
|
4113
|
+
confidence,
|
|
4114
|
+
resolution: "tree_sitter_name",
|
|
4115
|
+
});
|
|
4116
|
+
}
|
|
4117
|
+
}
|
|
4118
|
+
}
|
|
4119
|
+
finally {
|
|
4120
|
+
tree.delete();
|
|
4121
|
+
}
|
|
4122
|
+
return calls.sort((a, b) => a.line - b.line || a.to_symbol.localeCompare(b.to_symbol));
|
|
4123
|
+
}
|
|
3572
4124
|
function extractGenericSymbols(path, text) {
|
|
3573
4125
|
const symbols = [];
|
|
3574
4126
|
const language = codeLanguage(path);
|
|
@@ -3808,7 +4360,7 @@ function normalizeCallConfidence(value, fallback) {
|
|
|
3808
4360
|
return Number(Math.max(0, Math.min(1, numeric)).toFixed(2));
|
|
3809
4361
|
}
|
|
3810
4362
|
function normalizeCallResolution(value, fallback) {
|
|
3811
|
-
return value === "typescript_ast_name" || value === "generic_static_name" || value === "external_index" ? value : fallback;
|
|
4363
|
+
return value === "typescript_ast_name" || value === "tree_sitter_name" || value === "generic_static_name" || value === "external_index" ? value : fallback;
|
|
3812
4364
|
}
|
|
3813
4365
|
function normalizeCallEdge(call, fallback) {
|
|
3814
4366
|
if (!isRecord(call) || typeof call.to_symbol !== "string")
|
|
@@ -3822,7 +4374,34 @@ function normalizeCallEdge(call, fallback) {
|
|
|
3822
4374
|
resolution: normalizeCallResolution(call.resolution, fallback.resolution),
|
|
3823
4375
|
};
|
|
3824
4376
|
}
|
|
3825
|
-
|
|
4377
|
+
const EMPTY_CALL_RESOLUTION = { importedPaths: new Set(), importedNames: new Map(), dir: "" };
|
|
4378
|
+
// Resolve a callee name to symbol targets through scope, not just name matching:
|
|
4379
|
+
// local file → explicit import binding → imported module → same directory →
|
|
4380
|
+
// (last resort) one name-only match at low confidence. A name imported from an
|
|
4381
|
+
// external package resolves to NO repo symbol — same-name repo symbols are not
|
|
4382
|
+
// the callee, and emitting them is how bogus cross-package edges were born.
|
|
4383
|
+
function resolveCallTargets(name, path, targets, context, confidences) {
|
|
4384
|
+
const local = targets.filter((target) => target.path === path);
|
|
4385
|
+
if (local.length)
|
|
4386
|
+
return local.slice(0, 2).map((target) => ({ target, confidence: confidences.local }));
|
|
4387
|
+
if (context.importedNames.has(name)) {
|
|
4388
|
+
const toPath = context.importedNames.get(name) ?? null;
|
|
4389
|
+
if (toPath === null)
|
|
4390
|
+
return []; // imported from an external package
|
|
4391
|
+
return targets
|
|
4392
|
+
.filter((target) => target.path === toPath)
|
|
4393
|
+
.slice(0, 2)
|
|
4394
|
+
.map((target) => ({ target, confidence: confidences.imported }));
|
|
4395
|
+
}
|
|
4396
|
+
const viaImports = targets.filter((target) => context.importedPaths.has(target.path));
|
|
4397
|
+
if (viaImports.length)
|
|
4398
|
+
return viaImports.slice(0, 2).map((target) => ({ target, confidence: confidences.imported }));
|
|
4399
|
+
const sameDir = targets.filter((target) => context.dir ? target.path.startsWith(`${context.dir}/`) : !target.path.includes("/"));
|
|
4400
|
+
if (sameDir.length)
|
|
4401
|
+
return sameDir.slice(0, 1).map((target) => ({ target, confidence: confidences.sameDir }));
|
|
4402
|
+
return targets.slice(0, 1).map((target) => ({ target, confidence: confidences.nameOnly }));
|
|
4403
|
+
}
|
|
4404
|
+
function extractCalls(path, text, symbols, symbolByName, context = EMPTY_CALL_RESOLUTION) {
|
|
3826
4405
|
const sourceFile = sourceFileFor(path, text);
|
|
3827
4406
|
const calls = [];
|
|
3828
4407
|
const visit = (node) => {
|
|
@@ -3844,7 +4423,7 @@ function extractCalls(path, text, symbols, symbolByName) {
|
|
|
3844
4423
|
}
|
|
3845
4424
|
const line = lineForNode(sourceFile, node);
|
|
3846
4425
|
const caller = symbolAtLine(symbols, path, line);
|
|
3847
|
-
for (const target of targets.
|
|
4426
|
+
for (const { target, confidence } of resolveCallTargets(name, path, targets, context, { local: 0.9, imported: 0.85, sameDir: 0.6, nameOnly: 0.35 })) {
|
|
3848
4427
|
if (calls.length >= MAX_CODE_GRAPH_CALLS_PER_FILE)
|
|
3849
4428
|
break;
|
|
3850
4429
|
if (target.path === path && target.line === line)
|
|
@@ -3854,7 +4433,7 @@ function extractCalls(path, text, symbols, symbolByName) {
|
|
|
3854
4433
|
to_symbol: target.id,
|
|
3855
4434
|
path,
|
|
3856
4435
|
line,
|
|
3857
|
-
confidence
|
|
4436
|
+
confidence,
|
|
3858
4437
|
resolution: "typescript_ast_name",
|
|
3859
4438
|
});
|
|
3860
4439
|
}
|
|
@@ -3877,7 +4456,7 @@ const GENERIC_CALL_STOP_WORDS = new Set([
|
|
|
3877
4456
|
"switch",
|
|
3878
4457
|
"while",
|
|
3879
4458
|
]);
|
|
3880
|
-
function extractGenericCalls(path, text, symbols, symbolByName) {
|
|
4459
|
+
function extractGenericCalls(path, text, symbols, symbolByName, context = EMPTY_CALL_RESOLUTION) {
|
|
3881
4460
|
const calls = [];
|
|
3882
4461
|
const lines = text.split(/\r?\n/);
|
|
3883
4462
|
for (let index = 0; index < lines.length && calls.length < MAX_CODE_GRAPH_CALLS_PER_FILE; index += 1) {
|
|
@@ -3895,7 +4474,7 @@ function extractGenericCalls(path, text, symbols, symbolByName) {
|
|
|
3895
4474
|
if (!targets?.length)
|
|
3896
4475
|
continue;
|
|
3897
4476
|
const caller = symbolAtLine(symbols, path, line);
|
|
3898
|
-
for (const target of targets.
|
|
4477
|
+
for (const { target, confidence } of resolveCallTargets(name, path, targets, context, { local: 0.7, imported: 0.65, sameDir: 0.5, nameOnly: 0.3 })) {
|
|
3899
4478
|
if (calls.length >= MAX_CODE_GRAPH_CALLS_PER_FILE)
|
|
3900
4479
|
break;
|
|
3901
4480
|
calls.push({
|
|
@@ -3903,7 +4482,7 @@ function extractGenericCalls(path, text, symbols, symbolByName) {
|
|
|
3903
4482
|
to_symbol: target.id,
|
|
3904
4483
|
path,
|
|
3905
4484
|
line,
|
|
3906
|
-
confidence
|
|
4485
|
+
confidence,
|
|
3907
4486
|
resolution: "generic_static_name",
|
|
3908
4487
|
});
|
|
3909
4488
|
}
|
|
@@ -4139,8 +4718,16 @@ function fileInputEntries(projectDir, paths, kind) {
|
|
|
4139
4718
|
sha256: sha256Hex((0, node_fs_1.readFileSync)(path)),
|
|
4140
4719
|
}));
|
|
4141
4720
|
}
|
|
4721
|
+
// Bump when call/route/test derivation logic changes so existing repos rebuild
|
|
4722
|
+
// their code graph on next index — otherwise builder fixes never reach users
|
|
4723
|
+
// whose files haven't changed.
|
|
4724
|
+
const CODE_GRAPH_BUILDER_VERSION = 3; // v3: tree-sitter calls for python/go/rust/java/ruby
|
|
4725
|
+
function codeGraphBuilderVersionEntry() {
|
|
4726
|
+
return { kind: "code_graph_builder", path: "kage", sha256: String(CODE_GRAPH_BUILDER_VERSION) };
|
|
4727
|
+
}
|
|
4142
4728
|
function codeGraphInputHash(projectDir, absoluteFiles = listCodeFiles(projectDir)) {
|
|
4143
4729
|
return graphInputHash([
|
|
4730
|
+
codeGraphBuilderVersionEntry(),
|
|
4144
4731
|
...fileInputEntries(projectDir, absoluteFiles, "code_file"),
|
|
4145
4732
|
...fileInputEntries(projectDir, externalIndexFiles(projectDir).map((index) => index.path), "external_code_index"),
|
|
4146
4733
|
]);
|
|
@@ -4150,6 +4737,7 @@ function codeGraphInputHashFromStructural(projectDir, structural) {
|
|
|
4150
4737
|
}
|
|
4151
4738
|
function codeGraphInputHashFromStructuralFingerprint(projectDir, fingerprint) {
|
|
4152
4739
|
return graphInputHash([
|
|
4740
|
+
codeGraphBuilderVersionEntry(),
|
|
4153
4741
|
{ kind: "code_graph_input", path: ".agent_memory/structural/fingerprint", sha256: fingerprint },
|
|
4154
4742
|
...fileInputEntries(projectDir, externalIndexFiles(projectDir).map((index) => index.path), "external_code_index"),
|
|
4155
4743
|
]);
|
|
@@ -4175,6 +4763,7 @@ function currentCodeGraphInputHash(projectDir) {
|
|
|
4175
4763
|
}
|
|
4176
4764
|
function codeGraphStructuralFingerprint(projectDir, structural) {
|
|
4177
4765
|
const entries = [
|
|
4766
|
+
`builder:${CODE_GRAPH_BUILDER_VERSION}`,
|
|
4178
4767
|
`structural:${structural.manifest.fingerprint}`,
|
|
4179
4768
|
...externalIndexFiles(projectDir)
|
|
4180
4769
|
.map((index) => index.path)
|
|
@@ -4630,6 +5219,11 @@ function buildCodeGraph(projectDir, options = {}) {
|
|
|
4630
5219
|
writeCodeIndexManifest(projectDir, codeIndexManifestFromStructural(projectDir, structural, fingerprint, structural.manifest.cache));
|
|
4631
5220
|
const externalFacts = loadExternalCodeFacts(projectDir);
|
|
4632
5221
|
const fileByPath = new Map(files.map((file) => [file.path, file]));
|
|
5222
|
+
for (const symbol of symbols) {
|
|
5223
|
+
const file = fileByPath.get(symbol.path);
|
|
5224
|
+
if (file)
|
|
5225
|
+
file.parser = strongerParser(file.parser, symbol.parser);
|
|
5226
|
+
}
|
|
4633
5227
|
const addSymbol = (symbol) => {
|
|
4634
5228
|
if (!fileByPath.has(symbol.path))
|
|
4635
5229
|
return;
|
|
@@ -4670,9 +5264,21 @@ function buildCodeGraph(projectDir, options = {}) {
|
|
|
4670
5264
|
break;
|
|
4671
5265
|
const fileSymbols = symbols.filter((symbol) => symbol.path === rel);
|
|
4672
5266
|
const fileImports = imports.filter((item) => item.from_path === rel);
|
|
5267
|
+
const importedNames = new Map();
|
|
5268
|
+
for (const item of fileImports) {
|
|
5269
|
+
for (const importedName of item.imported) {
|
|
5270
|
+
if (!importedNames.has(importedName))
|
|
5271
|
+
importedNames.set(importedName, item.to_path);
|
|
5272
|
+
}
|
|
5273
|
+
}
|
|
5274
|
+
const resolution = {
|
|
5275
|
+
importedPaths: new Set(fileImports.map((item) => item.to_path).filter((value) => Boolean(value))),
|
|
5276
|
+
importedNames,
|
|
5277
|
+
dir: rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : "",
|
|
5278
|
+
};
|
|
4673
5279
|
const fileCalls = TS_AST_EXTENSIONS.has(extensionOf(rel))
|
|
4674
|
-
? extractCalls(rel, content, fileSymbols, symbolByName)
|
|
4675
|
-
: extractGenericCalls(rel, content, fileSymbols, symbolByName);
|
|
5280
|
+
? extractCalls(rel, content, fileSymbols, symbolByName, resolution)
|
|
5281
|
+
: extractTreeSitterCalls(rel, content, fileSymbols, symbolByName, resolution) ?? extractGenericCalls(rel, content, fileSymbols, symbolByName, resolution);
|
|
4676
5282
|
calls.push(...fileCalls.slice(0, Math.max(0, MAX_CODE_GRAPH_CALLS - calls.length)));
|
|
4677
5283
|
routes.push(...extractRoutes(rel, content, fileSymbols));
|
|
4678
5284
|
tests.push(...extractTests(rel, content, fileSymbols, fileImports));
|
|
@@ -5396,7 +6002,10 @@ function buildIndexes(projectDir) {
|
|
|
5396
6002
|
}
|
|
5397
6003
|
function indexProjectDetailed(projectDir, options = {}) {
|
|
5398
6004
|
ensureMemoryDirs(projectDir);
|
|
5399
|
-
|
|
6005
|
+
// Indexing must never write agent-policy files into the user's repo.
|
|
6006
|
+
// Policy installation is explicit: `kage policy`, `kage init --with-policy`,
|
|
6007
|
+
// or the kage_install_policy MCP tool.
|
|
6008
|
+
const existingPolicyPath = (0, node_path_1.join)(projectDir, "AGENTS.md");
|
|
5400
6009
|
const migrated = migrateLegacyMarkdown(projectDir);
|
|
5401
6010
|
const overview = createRepoOverviewPacket(projectDir);
|
|
5402
6011
|
if (overview)
|
|
@@ -5412,7 +6021,7 @@ function indexProjectDetailed(projectDir, options = {}) {
|
|
|
5412
6021
|
packets: loadPacketsFromDir(packetsDir(projectDir)).length,
|
|
5413
6022
|
migrated,
|
|
5414
6023
|
indexes: indexes.map((path) => (0, node_path_1.relative)(projectDir, path)),
|
|
5415
|
-
policyPath: (0, node_path_1.relative)(projectDir,
|
|
6024
|
+
policyPath: (0, node_fs_1.existsSync)(existingPolicyPath) ? (0, node_path_1.relative)(projectDir, existingPolicyPath) : undefined,
|
|
5416
6025
|
},
|
|
5417
6026
|
codeGraph: built?.codeGraph,
|
|
5418
6027
|
knowledgeGraph: built?.knowledgeGraph,
|
|
@@ -5447,13 +6056,18 @@ function refreshPacketStaleness(projectDir) {
|
|
|
5447
6056
|
const findings = [];
|
|
5448
6057
|
let updated = 0;
|
|
5449
6058
|
const fingerprintCache = new Map();
|
|
6059
|
+
const ignorePatterns = readKageIgnore(projectDir);
|
|
5450
6060
|
for (const entry of loadPacketEntriesFromDir(packetsDir(projectDir))) {
|
|
5451
|
-
|
|
5452
|
-
|
|
5453
|
-
const
|
|
6061
|
+
// Drop any .kageignore'd grounding (presentation layers etc.) from the stored packet
|
|
6062
|
+
// so memory is never anchored to non-knowledge files.
|
|
6063
|
+
const pruned = prunePacketGroundingPaths(entry.packet, ignorePatterns);
|
|
6064
|
+
const packet = pruned ?? entry.packet;
|
|
6065
|
+
const reasons = staleMemoryReasons(projectDir, packet, fingerprintCache);
|
|
6066
|
+
const oldQuality = (packet.quality ?? {});
|
|
6067
|
+
const oldFreshness = (packet.freshness ?? {});
|
|
5454
6068
|
let nextQuality;
|
|
5455
6069
|
if (reasons.length) {
|
|
5456
|
-
const finding = staleFinding(
|
|
6070
|
+
const finding = staleFinding(packet, reasons);
|
|
5457
6071
|
findings.push(finding);
|
|
5458
6072
|
nextQuality = {
|
|
5459
6073
|
...oldQuality,
|
|
@@ -5467,11 +6081,12 @@ function refreshPacketStaleness(projectDir) {
|
|
|
5467
6081
|
nextQuality = rest;
|
|
5468
6082
|
}
|
|
5469
6083
|
const nextFreshness = oldFreshness;
|
|
5470
|
-
const changed =
|
|
6084
|
+
const changed = pruned !== null
|
|
6085
|
+
|| JSON.stringify(oldQuality) !== JSON.stringify(nextQuality)
|
|
5471
6086
|
|| JSON.stringify(oldFreshness) !== JSON.stringify(nextFreshness);
|
|
5472
6087
|
if (changed) {
|
|
5473
6088
|
writeJson(entry.path, {
|
|
5474
|
-
...
|
|
6089
|
+
...packet,
|
|
5475
6090
|
freshness: nextFreshness,
|
|
5476
6091
|
quality: nextQuality,
|
|
5477
6092
|
updated_at: nowIso(),
|
|
@@ -6622,8 +7237,17 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
|
|
|
6622
7237
|
}))
|
|
6623
7238
|
: undefined,
|
|
6624
7239
|
};
|
|
6625
|
-
|
|
7240
|
+
result.value_receipt = {
|
|
7241
|
+
tokens_saved: scored.length ? recallTokensSaved(projectDir, result.results, result.context_block) : 0,
|
|
7242
|
+
stale_withheld: suppressed.length,
|
|
7243
|
+
};
|
|
7244
|
+
if (inputs.trackAccess !== false) {
|
|
6626
7245
|
recordRecallAccess(projectDir, result.results);
|
|
7246
|
+
recordValueEvents(projectDir, [
|
|
7247
|
+
...suppressed.map((entry) => ({ kind: "stale_withheld", packet_title: entry.title })),
|
|
7248
|
+
...(scored.length ? [{ kind: "recall_served", tokens_saved: result.value_receipt.tokens_saved }] : []),
|
|
7249
|
+
]);
|
|
7250
|
+
}
|
|
6627
7251
|
return result;
|
|
6628
7252
|
}
|
|
6629
7253
|
function recall(projectDir, query, limit = 5, explain = false, inputs = {}) {
|
|
@@ -6676,20 +7300,35 @@ function boostTermScore(boost, term) {
|
|
|
6676
7300
|
function queryCodeGraph(projectDir, query, limit = 10, graph) {
|
|
6677
7301
|
graph = graph ?? readCurrentCodeGraph(projectDir) ?? buildCodeGraph(projectDir);
|
|
6678
7302
|
const terms = tokenize(query);
|
|
7303
|
+
// Implementation queries must rank core source above tests/examples/benchmarks.
|
|
7304
|
+
// Without this, path-term matches let `test/` and `examples/` swamp `lib/` —
|
|
7305
|
+
// the exact inversion that made code-graph answers lose to grep.
|
|
7306
|
+
const testIntent = terms.some((term) => ["test", "tests", "spec", "coverage", "fixture"].includes(term));
|
|
7307
|
+
const exampleIntent = terms.some((term) => ["example", "examples", "sample", "demo"].includes(term));
|
|
7308
|
+
const pathKindWeight = (path) => {
|
|
7309
|
+
const kind = codeFileKind(path);
|
|
7310
|
+
if (kind === "test")
|
|
7311
|
+
return testIntent ? 1.15 : 0.45;
|
|
7312
|
+
if (/(^|\/)(examples?|samples?|demos?|fixtures?|benchmarks?)\//.test(path))
|
|
7313
|
+
return exampleIntent ? 1.1 : 0.5;
|
|
7314
|
+
if (kind === "source")
|
|
7315
|
+
return 1.0;
|
|
7316
|
+
return 0.85; // config/manifest/doc: useful, but below implementation
|
|
7317
|
+
};
|
|
6679
7318
|
const files = graph.files
|
|
6680
|
-
.map((file) => ({ file, score: scoreText(terms, `${file.path} ${file.kind} ${file.language} ${file.parser}`, [file.path, file.language]) }))
|
|
7319
|
+
.map((file) => ({ file, score: scoreText(terms, `${file.path} ${file.kind} ${file.language} ${file.parser}`, [file.path, file.language]) * pathKindWeight(file.path) }))
|
|
6681
7320
|
.filter((entry) => entry.score > 0)
|
|
6682
7321
|
.sort((a, b) => b.score - a.score || a.file.path.localeCompare(b.file.path))
|
|
6683
7322
|
.slice(0, limit)
|
|
6684
7323
|
.map((entry) => entry.file);
|
|
6685
7324
|
const symbols = graph.symbols
|
|
6686
|
-
.map((symbol) => ({ symbol, score: scoreText(terms, `${symbol.name} ${symbol.kind} ${symbol.path} ${symbol.language} ${symbol.signature}`, [symbol.name, symbol.path]) }))
|
|
7325
|
+
.map((symbol) => ({ symbol, score: scoreText(terms, `${symbol.name} ${symbol.kind} ${symbol.path} ${symbol.language} ${symbol.signature}`, [symbol.name, symbol.path]) * pathKindWeight(symbol.path) }))
|
|
6687
7326
|
.filter((entry) => entry.score > 0)
|
|
6688
7327
|
.sort((a, b) => b.score - a.score || a.symbol.path.localeCompare(b.symbol.path) || a.symbol.line - b.symbol.line)
|
|
6689
7328
|
.slice(0, limit)
|
|
6690
7329
|
.map((entry) => entry.symbol);
|
|
6691
7330
|
const routes = graph.routes
|
|
6692
|
-
.map((route) => ({ route, score: scoreText(terms, `route routes endpoint api handler ${route.method} ${route.path} ${route.file_path} ${route.framework}`, [route.path, route.file_path]) }))
|
|
7331
|
+
.map((route) => ({ route, score: scoreText(terms, `route routes endpoint api handler ${route.method} ${route.path} ${route.file_path} ${route.framework}`, [route.path, route.file_path]) * pathKindWeight(route.file_path) }))
|
|
6693
7332
|
.filter((entry) => entry.score > 0)
|
|
6694
7333
|
.sort((a, b) => b.score - a.score || a.route.path.localeCompare(b.route.path))
|
|
6695
7334
|
.slice(0, limit)
|
|
@@ -6716,9 +7355,57 @@ function queryCodeGraph(projectDir, query, limit = 10, graph) {
|
|
|
6716
7355
|
.slice(0, limit);
|
|
6717
7356
|
const symbolIds = new Set(symbols.map((symbol) => symbol.id));
|
|
6718
7357
|
const symbolNameById = new Map(graph.symbols.map((symbol) => [symbol.id, `${symbol.name} (${symbol.path}:${symbol.line})`]));
|
|
6719
|
-
|
|
6720
|
-
|
|
6721
|
-
|
|
7358
|
+
// Caller-intent queries ("who calls X", "which functions call X", "usages of X")
|
|
7359
|
+
// must be answered from the call-edge index — the one question a call graph is
|
|
7360
|
+
// uniquely qualified to answer. Keyword scoring alone returns definitions instead.
|
|
7361
|
+
const callerIntent = /\b(?:who|what|which)\b[^?]*\bcalls?\b|\bcallers?\s+(?:of|for)\b|\bcall\s*sites?\b|\busages?\s+of\b|\bwhere\s+is\b.+\b(?:called|invoked|used)\b/i.test(query);
|
|
7362
|
+
const intentStopwords = new Set(["call", "calls", "called", "caller", "callers", "calling", "invoked", "invoke", "usage", "usages", "site", "sites", "who", "what", "which", "where", "function", "functions", "method", "methods", "file", "files", "of", "for", "is", "the", "in", "are", "do", "does"]);
|
|
7363
|
+
let callerTargets = [];
|
|
7364
|
+
if (callerIntent) {
|
|
7365
|
+
const byName = new Map();
|
|
7366
|
+
for (const symbol of graph.symbols) {
|
|
7367
|
+
const key = symbol.name.toLowerCase();
|
|
7368
|
+
const bucket = byName.get(key);
|
|
7369
|
+
if (bucket)
|
|
7370
|
+
bucket.push(symbol);
|
|
7371
|
+
else
|
|
7372
|
+
byName.set(key, [symbol]);
|
|
7373
|
+
}
|
|
7374
|
+
const rawWords = query.match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? [];
|
|
7375
|
+
const seen = new Set();
|
|
7376
|
+
for (const word of rawWords) {
|
|
7377
|
+
const key = word.toLowerCase();
|
|
7378
|
+
if (seen.has(key) || intentStopwords.has(key))
|
|
7379
|
+
continue;
|
|
7380
|
+
const matches = byName.get(key);
|
|
7381
|
+
if (!matches)
|
|
7382
|
+
continue;
|
|
7383
|
+
seen.add(key);
|
|
7384
|
+
callerTargets.push(...matches.slice(0, 4));
|
|
7385
|
+
}
|
|
7386
|
+
callerTargets = callerTargets.slice(0, 8);
|
|
7387
|
+
}
|
|
7388
|
+
const callerTargetIds = new Set(callerTargets.map((symbol) => symbol.id));
|
|
7389
|
+
const callerTargetNames = new Set(callerTargets.map((symbol) => symbol.name.toLowerCase()));
|
|
7390
|
+
// Edges below 0.5 are name-only guesses; presenting them as callers destroys
|
|
7391
|
+
// trust in the one answer a call graph exists to give.
|
|
7392
|
+
const callerEdges = callerTargetIds.size
|
|
7393
|
+
? graph.calls
|
|
7394
|
+
.filter((call) => call.confidence >= 0.5)
|
|
7395
|
+
.filter((call) => callerTargetIds.has(call.to_symbol) || callerTargetNames.has((symbolNameById.get(call.to_symbol) ?? call.to_symbol).split(" ")[0]?.toLowerCase() ?? ""))
|
|
7396
|
+
.slice(0, 20)
|
|
7397
|
+
: [];
|
|
7398
|
+
const calls = callerEdges.length
|
|
7399
|
+
? callerEdges
|
|
7400
|
+
: graph.calls
|
|
7401
|
+
.filter((call) => call.confidence >= 0.5)
|
|
7402
|
+
.filter((call) => symbolIds.has(call.to_symbol) || Boolean(call.from_symbol && symbolIds.has(call.from_symbol)))
|
|
7403
|
+
.slice(0, limit);
|
|
7404
|
+
// Value ledger: a caller-intent query answered from the call-edge index is a
|
|
7405
|
+
// grep/agent round-trip the user did not pay for. Only repos that already opted
|
|
7406
|
+
// into Kage memory get the write — a bare code-graph query stays read-only.
|
|
7407
|
+
if (callerEdges.length && (0, node_fs_1.existsSync)(memoryRoot(projectDir)))
|
|
7408
|
+
recordValueEvent(projectDir, { kind: "caller_answered" });
|
|
6722
7409
|
const structuralIndex = readCurrentStructuralIndex(projectDir);
|
|
6723
7410
|
const graphPaths = new Set(graph.files.map((file) => file.path));
|
|
6724
7411
|
const graphSymbolIds = new Set(graph.symbols.map((symbol) => symbol.id));
|
|
@@ -6737,7 +7424,7 @@ function queryCodeGraph(projectDir, query, limit = 10, graph) {
|
|
|
6737
7424
|
? structuralIndex.symbols
|
|
6738
7425
|
.map((symbol) => ({
|
|
6739
7426
|
symbol,
|
|
6740
|
-
score: scoreText(terms, `${symbol.name} ${symbol.kind} ${symbol.path} ${symbol.language} ${symbol.parser}`, [symbol.name, symbol.path]),
|
|
7427
|
+
score: scoreText(terms, `${symbol.name} ${symbol.kind} ${symbol.path} ${symbol.language} ${symbol.parser}`, [symbol.name, symbol.path]) * pathKindWeight(symbol.path),
|
|
6741
7428
|
}))
|
|
6742
7429
|
.filter((entry) => entry.score > 0 && !graphSymbolIds.has(entry.symbol.id))
|
|
6743
7430
|
.sort((a, b) => b.score - a.score || a.symbol.path.localeCompare(b.symbol.path) || a.symbol.line - b.symbol.line)
|
|
@@ -6764,25 +7451,39 @@ function queryCodeGraph(projectDir, query, limit = 10, graph) {
|
|
|
6764
7451
|
"",
|
|
6765
7452
|
`Query: ${query}`,
|
|
6766
7453
|
"",
|
|
7454
|
+
...(callerEdges.length
|
|
7455
|
+
? [
|
|
7456
|
+
"## Callers (from the call-edge index)",
|
|
7457
|
+
...callerTargets.map((symbol) => `target: ${symbol.kind} ${symbol.name} defined in ${symbol.path}:${symbol.line}`),
|
|
7458
|
+
...callerEdges.map((call, index) => `${index + 1}. ${call.from_symbol ? symbolNameById.get(call.from_symbol) ?? call.from_symbol : call.path} calls ${symbolNameById.get(call.to_symbol) ?? call.to_symbol} at ${call.path}:${call.line}`),
|
|
7459
|
+
"",
|
|
7460
|
+
]
|
|
7461
|
+
: []),
|
|
6767
7462
|
files.length || symbols.length || routes.length || tests.length ? "## Code Facts" : "No related source-derived code facts found.",
|
|
6768
|
-
|
|
7463
|
+
// Compaction: when symbol hits are strong, supporting facts (routes/tests/files/
|
|
7464
|
+
// imports) shrink to a few lines each. The block is agent fuel — every line that
|
|
7465
|
+
// doesn't help locate the answer is tokens the agent pays for nothing.
|
|
6769
7466
|
...symbols.map((symbol, index) => `${index + 1}. [symbol] ${symbol.kind} ${symbol.name} in ${symbol.path}:${symbol.line} (${symbol.language}, ${symbol.parser})`),
|
|
6770
|
-
...
|
|
6771
|
-
...
|
|
6772
|
-
|
|
6773
|
-
structuralFiles.length || structuralSymbols.length
|
|
6774
|
-
|
|
6775
|
-
...
|
|
7467
|
+
...routes.slice(0, symbols.length >= 3 ? 3 : limit).map((route, index) => `${index + 1}. [route] ${route.method} ${route.path} in ${route.file_path}:${route.line}`),
|
|
7468
|
+
...tests.slice(0, symbols.length >= 3 ? 2 : limit).map((test, index) => `${index + 1}. [test] ${test.title} in ${test.test_path}:${test.line}${test.covers_symbol ? ` covers ${test.covers_symbol}` : ""}`),
|
|
7469
|
+
...files.slice(0, 3).map((file, index) => `${index + 1}. [file] ${file.path} (${file.kind}, ${file.language}, ${file.parser})`),
|
|
7470
|
+
structuralFiles.length || structuralSymbols.length ? "" : "",
|
|
7471
|
+
structuralFiles.length || structuralSymbols.length ? "## Structural Index" : "",
|
|
7472
|
+
...structuralSymbols.slice(0, symbols.length >= 3 ? 3 : limit).map((symbol, index) => `${index + 1}. [structural symbol] ${symbol.kind} ${symbol.name} in ${symbol.path}:${symbol.line} (${symbol.language}, ${symbol.parser})`),
|
|
7473
|
+
...structuralFiles.slice(0, 3).map((file, index) => `${index + 1}. [structural file] ${file.path} (${file.kind}, ${file.language}, ${file.extraction})`),
|
|
6776
7474
|
...structuralEdges
|
|
6777
7475
|
.filter((edge) => edge.relation === "imports")
|
|
6778
|
-
.slice(0, 5)
|
|
7476
|
+
.slice(0, symbols.length >= 3 ? 2 : 5)
|
|
6779
7477
|
.map((edge, index) => `${index + 1}. [structural import] ${edge.source_file}${edge.source_location ? `:${edge.source_location.replace(/^L/, "")}` : ""} -> ${edge.target} (${edge.confidence})`),
|
|
6780
7478
|
imports.length ? "" : "",
|
|
6781
7479
|
imports.length ? "## Imports" : "",
|
|
6782
|
-
...imports.map(({ item }, index) => `${index + 1}. ${item.from_path}:${item.line} ${item.kind} ${item.specifier}${item.to_path ? ` -> ${item.to_path}` : ""}`),
|
|
6783
|
-
|
|
6784
|
-
calls.length ? "
|
|
6785
|
-
|
|
7480
|
+
...imports.slice(0, symbols.length >= 3 ? 3 : limit).map(({ item }, index) => `${index + 1}. ${item.from_path}:${item.line} ${item.kind} ${item.specifier}${item.to_path ? ` -> ${item.to_path}` : ""}`),
|
|
7481
|
+
// When caller intent was answered above, don't repeat the same edges here.
|
|
7482
|
+
calls.length && !callerEdges.length ? "" : "",
|
|
7483
|
+
calls.length && !callerEdges.length ? "## Calls" : "",
|
|
7484
|
+
...(callerEdges.length
|
|
7485
|
+
? []
|
|
7486
|
+
: calls.map((call, index) => `${index + 1}. ${call.from_symbol ? symbolNameById.get(call.from_symbol) ?? call.from_symbol : call.path} calls ${symbolNameById.get(call.to_symbol) ?? call.to_symbol} at ${call.path}:${call.line} (${call.resolution}, confidence ${call.confidence.toFixed(2)})`)),
|
|
6786
7487
|
];
|
|
6787
7488
|
return {
|
|
6788
7489
|
query,
|
|
@@ -7597,6 +8298,377 @@ function kageCleanupCandidates(projectDir) {
|
|
|
7597
8298
|
summary: `${candidates.length} conservative cleanup candidate(s), ${skippedEntryPoints.length} entrypoint-like source file(s) skipped, ${skippedRuntimeReferences.length} runtime reference(s) skipped.`,
|
|
7598
8299
|
};
|
|
7599
8300
|
}
|
|
8301
|
+
const TRUTH_REPORT_MAX_FINDINGS = 12;
|
|
8302
|
+
const TRUTH_REPORT_AI_ERA_DAYS = 120;
|
|
8303
|
+
// Symbol names too generic to mean "two teams built the same thing".
|
|
8304
|
+
const TRUTH_COMMON_SYMBOL_NAMES = new Set([
|
|
8305
|
+
"main", "init", "run", "setup", "start", "stop", "open", "close", "create", "destroy",
|
|
8306
|
+
"constructor", "tostring", "render", "handler", "handle", "execute", "default", "index",
|
|
8307
|
+
"build", "parse", "load", "save", "update", "delete", "remove", "test", "validate",
|
|
8308
|
+
"format", "process", "next", "send", "write", "read", "config", "helper", "util",
|
|
8309
|
+
]);
|
|
8310
|
+
function truthExcludedPath(path) {
|
|
8311
|
+
return /(^|\/)(tests?|__tests__|specs?|examples?|fixtures?|benchmarks?|mocks?|__mocks__|vendor|node_modules|dist|build)\//i.test(path)
|
|
8312
|
+
|| /\.(test|spec)\.[^.]+$/i.test(path)
|
|
8313
|
+
|| /(^|\/)test[^/]*\.[^.]+$/i.test(path);
|
|
8314
|
+
}
|
|
8315
|
+
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";
|
|
8316
|
+
function truthDocPathCandidates(line) {
|
|
8317
|
+
const candidates = new Set();
|
|
8318
|
+
for (const match of line.matchAll(/`([^`\n]+)`/g)) {
|
|
8319
|
+
const token = match[1].trim();
|
|
8320
|
+
if (new RegExp(`^(?:\\./)?[\\w.-]+(?:/[\\w.-]+)+\\.(?:${TRUTH_DOC_PATH_EXTENSIONS})$`).test(token))
|
|
8321
|
+
candidates.add(token.replace(/^\.\//, ""));
|
|
8322
|
+
}
|
|
8323
|
+
for (const match of line.matchAll(new RegExp(`(?:^|[\\s("'\\[])((?:\\./)?[\\w.-]+(?:/[\\w.-]+)+\\.(?:${TRUTH_DOC_PATH_EXTENSIONS}))(?=$|[\\s)"'\\],.:;])`, "g"))) {
|
|
8324
|
+
candidates.add(match[1].replace(/^\.\//, ""));
|
|
8325
|
+
}
|
|
8326
|
+
return [...candidates].filter((candidate) => !/[*<>{}$\\]/.test(candidate)
|
|
8327
|
+
&& !candidate.startsWith("http")
|
|
8328
|
+
&& !candidate.includes("node_modules")
|
|
8329
|
+
&& !candidate.startsWith(".agent_memory"));
|
|
8330
|
+
}
|
|
8331
|
+
function truthReport(projectDir) {
|
|
8332
|
+
const graph = readCurrentCodeGraph(projectDir) ?? buildCodeGraph(projectDir);
|
|
8333
|
+
const warnings = [];
|
|
8334
|
+
const fileByPath = new Map(graph.files.map((file) => [file.path, file]));
|
|
8335
|
+
const sourceFiles = graph.files.filter((file) => file.kind === "source" && !truthExcludedPath(file.path));
|
|
8336
|
+
// Single git pass: per-file distinct authors, total commit count, newest commit epoch.
|
|
8337
|
+
const hasGit = Boolean(gitHead(projectDir));
|
|
8338
|
+
if (!hasGit)
|
|
8339
|
+
warnings.push("Git history is unavailable; bus-factor, churn, and recency signals are skipped.");
|
|
8340
|
+
const shallowGit = hasGit && readGit(projectDir, ["rev-parse", "--is-shallow-repository"]) === "true";
|
|
8341
|
+
if (shallowGit)
|
|
8342
|
+
warnings.push("Git history is shallow (partial clone); churn, bus-factor, and recency findings undercount reality.");
|
|
8343
|
+
const fileAuthors = new Map();
|
|
8344
|
+
const fileCommits = new Map();
|
|
8345
|
+
const fileNewestEpoch = new Map();
|
|
8346
|
+
if (hasGit) {
|
|
8347
|
+
const raw = readGit(projectDir, ["log", "--no-renames", "--format=__KAGE_SCAN__%x1f%ae%x1f%ct", "--name-only"]) ?? "";
|
|
8348
|
+
// Resolve the repo->project prefix once; gitPathToProjectRelative spawns git per call,
|
|
8349
|
+
// which is far too slow for a full-history name-only walk.
|
|
8350
|
+
const projectPrefix = readGit(projectDir, ["rev-parse", "--show-prefix"])?.replace(/\\/g, "/").replace(/\/+$/, "") ?? "";
|
|
8351
|
+
let author = "";
|
|
8352
|
+
let epoch = 0;
|
|
8353
|
+
for (const rawLine of raw.split(/\r?\n/)) {
|
|
8354
|
+
const line = rawLine.trim();
|
|
8355
|
+
if (!line)
|
|
8356
|
+
continue;
|
|
8357
|
+
if (line.startsWith("__KAGE_SCAN__")) {
|
|
8358
|
+
const parts = line.split("\x1f");
|
|
8359
|
+
author = (parts[1] ?? "").toLowerCase();
|
|
8360
|
+
epoch = Number(parts[2] ?? 0) || 0;
|
|
8361
|
+
continue;
|
|
8362
|
+
}
|
|
8363
|
+
if (!author)
|
|
8364
|
+
continue;
|
|
8365
|
+
const normalized = line.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
8366
|
+
const path = projectPrefix && normalized.startsWith(`${projectPrefix}/`) ? normalized.slice(projectPrefix.length + 1) : normalized;
|
|
8367
|
+
if (!fileByPath.has(path))
|
|
8368
|
+
continue;
|
|
8369
|
+
const authors = fileAuthors.get(path) ?? new Set();
|
|
8370
|
+
authors.add(author);
|
|
8371
|
+
fileAuthors.set(path, authors);
|
|
8372
|
+
fileCommits.set(path, (fileCommits.get(path) ?? 0) + 1);
|
|
8373
|
+
// git log is newest-first, so the first sighting is the newest commit touching the file.
|
|
8374
|
+
if (!fileNewestEpoch.has(path))
|
|
8375
|
+
fileNewestEpoch.set(path, epoch);
|
|
8376
|
+
}
|
|
8377
|
+
}
|
|
8378
|
+
// Centrality: import + call edges touching the file.
|
|
8379
|
+
const centrality = new Map();
|
|
8380
|
+
const bumpCentrality = (path) => {
|
|
8381
|
+
if (!path || !fileByPath.has(path))
|
|
8382
|
+
return;
|
|
8383
|
+
centrality.set(path, (centrality.get(path) ?? 0) + 1);
|
|
8384
|
+
};
|
|
8385
|
+
for (const edge of graph.imports) {
|
|
8386
|
+
bumpCentrality(edge.from_path);
|
|
8387
|
+
bumpCentrality(edge.to_path);
|
|
8388
|
+
}
|
|
8389
|
+
for (const call of graph.calls)
|
|
8390
|
+
bumpCentrality(call.path);
|
|
8391
|
+
const nowEpoch = Math.floor(Date.now() / 1000);
|
|
8392
|
+
const aiEraCutoff = nowEpoch - TRUTH_REPORT_AI_ERA_DAYS * 24 * 3600;
|
|
8393
|
+
// 1a. Duplicate implementations: same-name symbols spread across directories.
|
|
8394
|
+
const symbolsByName = new Map();
|
|
8395
|
+
for (const symbol of graph.symbols) {
|
|
8396
|
+
if (!["function", "class", "method"].includes(symbol.kind))
|
|
8397
|
+
continue;
|
|
8398
|
+
if (symbol.name.length < 4 || TRUTH_COMMON_SYMBOL_NAMES.has(symbol.name.toLowerCase()))
|
|
8399
|
+
continue;
|
|
8400
|
+
if (truthExcludedPath(symbol.path) || fileByPath.get(symbol.path)?.kind !== "source")
|
|
8401
|
+
continue;
|
|
8402
|
+
const list = symbolsByName.get(symbol.name.toLowerCase()) ?? [];
|
|
8403
|
+
list.push(symbol);
|
|
8404
|
+
symbolsByName.set(symbol.name.toLowerCase(), list);
|
|
8405
|
+
}
|
|
8406
|
+
const duplicateFindings = [];
|
|
8407
|
+
for (const [, members] of symbolsByName) {
|
|
8408
|
+
const dirs = new Set(members.map((member) => (0, node_path_1.dirname)(member.path)));
|
|
8409
|
+
const paths = new Set(members.map((member) => member.path));
|
|
8410
|
+
if (dirs.size < 2 || paths.size < 2)
|
|
8411
|
+
continue;
|
|
8412
|
+
const signatureCounts = new Map();
|
|
8413
|
+
for (const member of members) {
|
|
8414
|
+
const normalized = member.signature.replace(/\s+/g, "");
|
|
8415
|
+
signatureCounts.set(normalized, (signatureCounts.get(normalized) ?? 0) + 1);
|
|
8416
|
+
}
|
|
8417
|
+
const signatureMatch = [...signatureCounts.values()].some((count) => count >= 2);
|
|
8418
|
+
const newestEpoch = Math.max(0, ...members.map((member) => fileNewestEpoch.get(member.path) ?? 0));
|
|
8419
|
+
const recent = hasGit && newestEpoch > aiEraCutoff;
|
|
8420
|
+
duplicateFindings.push({
|
|
8421
|
+
kind: "duplicate_cluster",
|
|
8422
|
+
title: `${members[0].name} — ${paths.size} implementations across ${dirs.size} directories${recent ? " [recent, likely AI-era]" : ""}`,
|
|
8423
|
+
detail: signatureMatch
|
|
8424
|
+
? "Same name AND near-identical signature: almost certainly parallel implementations of the same idea."
|
|
8425
|
+
: "Same name in unrelated directories: agents and humans may be solving the same problem twice.",
|
|
8426
|
+
evidence: members.slice(0, 5).map((member) => `${member.path}:${member.line} ${member.kind} ${member.signature.slice(0, 80)}`),
|
|
8427
|
+
surprise: Math.min(100, 45 + paths.size * 8 + (signatureMatch ? 15 : 0) + (recent ? 20 : 0)),
|
|
8428
|
+
});
|
|
8429
|
+
}
|
|
8430
|
+
duplicateFindings.sort((a, b) => b.surprise - a.surprise || a.title.localeCompare(b.title));
|
|
8431
|
+
// 1b. Ghost knowledge: exported symbols nothing in this repo calls or imports by name.
|
|
8432
|
+
const referenced = new Set();
|
|
8433
|
+
for (const call of graph.calls)
|
|
8434
|
+
referenced.add(call.to_symbol);
|
|
8435
|
+
for (const edge of graph.imports)
|
|
8436
|
+
for (const name of edge.imported)
|
|
8437
|
+
referenced.add(name);
|
|
8438
|
+
for (const route of graph.routes)
|
|
8439
|
+
if (route.handler_symbol)
|
|
8440
|
+
referenced.add(route.handler_symbol);
|
|
8441
|
+
for (const test of graph.tests)
|
|
8442
|
+
if (test.covers_symbol)
|
|
8443
|
+
referenced.add(test.covers_symbol);
|
|
8444
|
+
const ghostCandidates = [];
|
|
8445
|
+
for (const symbol of graph.symbols) {
|
|
8446
|
+
if (!symbol.export || !["function", "class", "method"].includes(symbol.kind))
|
|
8447
|
+
continue;
|
|
8448
|
+
if (truthExcludedPath(symbol.path) || isEntrypointLike(symbol.path))
|
|
8449
|
+
continue;
|
|
8450
|
+
if (fileByPath.get(symbol.path)?.kind !== "source")
|
|
8451
|
+
continue;
|
|
8452
|
+
if (referenced.has(symbol.id) || referenced.has(symbol.name))
|
|
8453
|
+
continue;
|
|
8454
|
+
ghostCandidates.push(symbol);
|
|
8455
|
+
}
|
|
8456
|
+
// Graph edges miss dynamic/property references, so a ghost claim must survive a raw-text
|
|
8457
|
+
// check: the name may appear nowhere in the repo outside its own file.
|
|
8458
|
+
const truthTextCache = new Map();
|
|
8459
|
+
const truthFileText = (path) => {
|
|
8460
|
+
const cached = truthTextCache.get(path);
|
|
8461
|
+
if (cached !== undefined)
|
|
8462
|
+
return cached;
|
|
8463
|
+
const file = fileByPath.get(path);
|
|
8464
|
+
const text = file && file.size_bytes <= 512 * 1024 ? safeReadText((0, node_path_1.join)(projectDir, path)) ?? "" : "";
|
|
8465
|
+
truthTextCache.set(path, text);
|
|
8466
|
+
return text;
|
|
8467
|
+
};
|
|
8468
|
+
const ghostFindings = ghostCandidates.slice(0, 60).flatMap((symbol) => {
|
|
8469
|
+
const namePattern = new RegExp(`(^|[^A-Za-z0-9_$])${escapeRegex(symbol.name)}([^A-Za-z0-9_$]|$)`);
|
|
8470
|
+
const mentionedElsewhere = graph.files.some((file) => file.path !== symbol.path
|
|
8471
|
+
&& (file.kind === "source" || file.kind === "test")
|
|
8472
|
+
&& namePattern.test(truthFileText(file.path)));
|
|
8473
|
+
if (mentionedElsewhere)
|
|
8474
|
+
return [];
|
|
8475
|
+
const fileCentrality = centrality.get(symbol.path) ?? 0;
|
|
8476
|
+
return [{
|
|
8477
|
+
kind: "ghost_export",
|
|
8478
|
+
title: `${symbol.name} — exported, never called`,
|
|
8479
|
+
detail: "No call edge, no import, and the name appears in no other file. Dead code, or knowledge nobody wired in.",
|
|
8480
|
+
evidence: [`${symbol.path}:${symbol.line} ${symbol.kind} ${symbol.signature.slice(0, 80)}`],
|
|
8481
|
+
surprise: Math.min(100, 35 + Math.min(30, fileCentrality)),
|
|
8482
|
+
}];
|
|
8483
|
+
});
|
|
8484
|
+
ghostFindings.sort((a, b) => b.surprise - a.surprise || a.title.localeCompare(b.title));
|
|
8485
|
+
// 1c. Knowledge concentration: single-author files ranked by graph centrality.
|
|
8486
|
+
const busFindings = [];
|
|
8487
|
+
let singleAuthorCount = 0;
|
|
8488
|
+
if (hasGit) {
|
|
8489
|
+
for (const file of sourceFiles) {
|
|
8490
|
+
const authors = fileAuthors.get(file.path);
|
|
8491
|
+
if (!authors || authors.size !== 1)
|
|
8492
|
+
continue;
|
|
8493
|
+
singleAuthorCount += 1;
|
|
8494
|
+
const commits = fileCommits.get(file.path) ?? 0;
|
|
8495
|
+
const fileCentrality = centrality.get(file.path) ?? 0;
|
|
8496
|
+
if (commits < 2 || fileCentrality < 1)
|
|
8497
|
+
continue;
|
|
8498
|
+
busFindings.push({
|
|
8499
|
+
kind: "bus_factor",
|
|
8500
|
+
title: `${file.path} — bus factor 1`,
|
|
8501
|
+
detail: `Every one of ${commits} commit(s) came from ${[...authors][0]}. ${fileCentrality} graph edge(s) depend on a file only one person has ever touched.`,
|
|
8502
|
+
evidence: [`${file.path}:1 sole author ${[...authors][0]}, ${commits} commit(s), centrality ${fileCentrality}`],
|
|
8503
|
+
surprise: Math.min(100, 30 + Math.min(40, fileCentrality) + Math.min(20, commits)),
|
|
8504
|
+
});
|
|
8505
|
+
}
|
|
8506
|
+
if (!shallowGit && sourceFiles.length >= 5 && singleAuthorCount >= sourceFiles.length * 0.9) {
|
|
8507
|
+
warnings.push("Nearly every file has a single author; this looks like a solo-maintained repo, so bus-factor-1 is the baseline, not the exception.");
|
|
8508
|
+
}
|
|
8509
|
+
}
|
|
8510
|
+
busFindings.sort((a, b) => b.surprise - a.surprise || a.title.localeCompare(b.title));
|
|
8511
|
+
// 1d. Knowledge void: high churn x high centrality, zero memory packets, zero doc mentions.
|
|
8512
|
+
const packets = loadApprovedPackets(projectDir);
|
|
8513
|
+
const packetCovers = (path) => packets.some((packet) => packet.paths.some((cited) => {
|
|
8514
|
+
const normalized = cited.replace(/\/+$/, "");
|
|
8515
|
+
return normalized === path || path.startsWith(`${normalized}/`) || normalized.startsWith(`${path}/`);
|
|
8516
|
+
}));
|
|
8517
|
+
const docFiles = [];
|
|
8518
|
+
for (const candidate of ["README.md", "readme.md", "Readme.md"]) {
|
|
8519
|
+
if ((0, node_fs_1.existsSync)((0, node_path_1.join)(projectDir, candidate))) {
|
|
8520
|
+
docFiles.push(candidate);
|
|
8521
|
+
break;
|
|
8522
|
+
}
|
|
8523
|
+
}
|
|
8524
|
+
const docsDir = (0, node_path_1.join)(projectDir, "docs");
|
|
8525
|
+
if (safeStat(docsDir)?.isDirectory()) {
|
|
8526
|
+
for (const name of (0, node_fs_1.readdirSync)(docsDir).filter((entry) => entry.endsWith(".md")).sort().slice(0, 20)) {
|
|
8527
|
+
docFiles.push((0, node_path_1.join)("docs", name));
|
|
8528
|
+
}
|
|
8529
|
+
}
|
|
8530
|
+
const docLines = [];
|
|
8531
|
+
for (const doc of docFiles) {
|
|
8532
|
+
const text = safeReadText((0, node_path_1.join)(projectDir, doc));
|
|
8533
|
+
if (!text)
|
|
8534
|
+
continue;
|
|
8535
|
+
text.split(/\r?\n/).forEach((line, index) => docLines.push({ doc, line: index + 1, text: line }));
|
|
8536
|
+
}
|
|
8537
|
+
const docsText = docLines.map((entry) => entry.text).join("\n");
|
|
8538
|
+
const voidFindings = [];
|
|
8539
|
+
if (hasGit) {
|
|
8540
|
+
for (const file of sourceFiles) {
|
|
8541
|
+
const commits = fileCommits.get(file.path) ?? 0;
|
|
8542
|
+
const fileCentrality = centrality.get(file.path) ?? 0;
|
|
8543
|
+
if (commits < 5 || fileCentrality < 3)
|
|
8544
|
+
continue;
|
|
8545
|
+
if (packetCovers(file.path) || docsText.includes(file.path))
|
|
8546
|
+
continue;
|
|
8547
|
+
voidFindings.push({
|
|
8548
|
+
kind: "knowledge_void",
|
|
8549
|
+
title: `${file.path} — knowledge void`,
|
|
8550
|
+
detail: `${commits} commits of accumulated decisions, ${fileCentrality} graph edge(s) depending on it — and zero memory packets or doc mentions. Agents and new hires fly blind here.`,
|
|
8551
|
+
evidence: [`${file.path}:1 churn ${commits} x centrality ${fileCentrality}, memory packets citing it: 0`],
|
|
8552
|
+
surprise: Math.min(100, 25 + Math.min(45, Math.round(Math.sqrt(commits * fileCentrality))) + Math.min(20, fileCentrality)),
|
|
8553
|
+
});
|
|
8554
|
+
}
|
|
8555
|
+
}
|
|
8556
|
+
voidFindings.sort((a, b) => b.surprise - a.surprise || a.title.localeCompare(b.title));
|
|
8557
|
+
// 2. Doc-truth: checkable claims in README/docs vs reality.
|
|
8558
|
+
const docLieFindings = [];
|
|
8559
|
+
if (docLines.length) {
|
|
8560
|
+
const seenLies = new Set();
|
|
8561
|
+
const packageJsonText = safeReadText((0, node_path_1.join)(projectDir, "package.json"));
|
|
8562
|
+
let scripts = {};
|
|
8563
|
+
let binNames = [];
|
|
8564
|
+
if (packageJsonText) {
|
|
8565
|
+
try {
|
|
8566
|
+
const parsed = JSON.parse(packageJsonText);
|
|
8567
|
+
scripts = parsed.scripts ?? {};
|
|
8568
|
+
binNames = typeof parsed.bin === "string" ? [parsed.name ?? ""].filter(Boolean) : Object.keys(parsed.bin ?? {});
|
|
8569
|
+
}
|
|
8570
|
+
catch {
|
|
8571
|
+
warnings.push("package.json could not be parsed; npm-script doc checks skipped.");
|
|
8572
|
+
}
|
|
8573
|
+
}
|
|
8574
|
+
// CLI claims are only checkable when there is obvious CLI source to check them against.
|
|
8575
|
+
const cliSourceText = binNames.length
|
|
8576
|
+
? graph.files
|
|
8577
|
+
.filter((file) => file.kind === "source" && (/(^|\/)(cli|bin|commands?)[^/]*\.[^.]+$/i.test(file.path) || /(^|\/)(cli|bin|commands)\//i.test(file.path)))
|
|
8578
|
+
.slice(0, 30)
|
|
8579
|
+
.map((file) => safeReadText((0, node_path_1.join)(projectDir, file.path)) ?? "")
|
|
8580
|
+
.join("\n")
|
|
8581
|
+
: "";
|
|
8582
|
+
for (const entry of docLines) {
|
|
8583
|
+
for (const candidate of truthDocPathCandidates(entry.text)) {
|
|
8584
|
+
const key = `path:${candidate}`;
|
|
8585
|
+
if (seenLies.has(key) || (0, node_fs_1.existsSync)((0, node_path_1.join)(projectDir, candidate)))
|
|
8586
|
+
continue;
|
|
8587
|
+
seenLies.add(key);
|
|
8588
|
+
docLieFindings.push({
|
|
8589
|
+
kind: "doc_lie",
|
|
8590
|
+
title: `${entry.doc}:${entry.line} cites ${candidate} — reality: file does not exist`,
|
|
8591
|
+
detail: `"${entry.text.trim().slice(0, 100)}"`,
|
|
8592
|
+
evidence: [`${entry.doc}:${entry.line}`],
|
|
8593
|
+
surprise: 70,
|
|
8594
|
+
});
|
|
8595
|
+
}
|
|
8596
|
+
if (packageJsonText && Object.keys(scripts).length) {
|
|
8597
|
+
for (const match of entry.text.matchAll(/\bnpm run ([A-Za-z0-9:_-]+)/g)) {
|
|
8598
|
+
const script = match[1];
|
|
8599
|
+
const key = `script:${script}`;
|
|
8600
|
+
if (seenLies.has(key) || scripts[script])
|
|
8601
|
+
continue;
|
|
8602
|
+
seenLies.add(key);
|
|
8603
|
+
docLieFindings.push({
|
|
8604
|
+
kind: "doc_lie",
|
|
8605
|
+
title: `${entry.doc}:${entry.line} says \`npm run ${script}\` — reality: no "${script}" script in package.json`,
|
|
8606
|
+
detail: `"${entry.text.trim().slice(0, 100)}"`,
|
|
8607
|
+
evidence: [`${entry.doc}:${entry.line}`],
|
|
8608
|
+
surprise: 75,
|
|
8609
|
+
});
|
|
8610
|
+
}
|
|
8611
|
+
}
|
|
8612
|
+
if (cliSourceText) {
|
|
8613
|
+
for (const bin of binNames) {
|
|
8614
|
+
for (const match of entry.text.matchAll(new RegExp(`\`${bin} ([a-z][a-z0-9-]+)`, "g"))) {
|
|
8615
|
+
const subcommand = match[1];
|
|
8616
|
+
const key = `cli:${subcommand}`;
|
|
8617
|
+
if (seenLies.has(key) || cliSourceText.includes(`"${subcommand}"`) || cliSourceText.includes(`'${subcommand}'`))
|
|
8618
|
+
continue;
|
|
8619
|
+
seenLies.add(key);
|
|
8620
|
+
docLieFindings.push({
|
|
8621
|
+
kind: "doc_lie",
|
|
8622
|
+
title: `${entry.doc}:${entry.line} documents \`${bin} ${subcommand}\` — reality: no such command in CLI source`,
|
|
8623
|
+
detail: `"${entry.text.trim().slice(0, 100)}"`,
|
|
8624
|
+
evidence: [`${entry.doc}:${entry.line}`],
|
|
8625
|
+
surprise: 80,
|
|
8626
|
+
});
|
|
8627
|
+
}
|
|
8628
|
+
}
|
|
8629
|
+
}
|
|
8630
|
+
}
|
|
8631
|
+
}
|
|
8632
|
+
docLieFindings.sort((a, b) => b.surprise - a.surprise || a.title.localeCompare(b.title));
|
|
8633
|
+
// Cap per category so one noisy category cannot drown the report, then rank globally.
|
|
8634
|
+
const findings = [
|
|
8635
|
+
...duplicateFindings.slice(0, 4),
|
|
8636
|
+
...ghostFindings.slice(0, 4),
|
|
8637
|
+
...busFindings.slice(0, 4),
|
|
8638
|
+
...voidFindings.slice(0, 4),
|
|
8639
|
+
...docLieFindings.slice(0, 4),
|
|
8640
|
+
].sort((a, b) => b.surprise - a.surprise || a.title.localeCompare(b.title)).slice(0, TRUTH_REPORT_MAX_FINDINGS);
|
|
8641
|
+
const headlineParts = [
|
|
8642
|
+
`${duplicateFindings.length} duplicate cluster${duplicateFindings.length === 1 ? "" : "s"}`,
|
|
8643
|
+
`${ghostFindings.length} ghost export${ghostFindings.length === 1 ? "" : "s"}`,
|
|
8644
|
+
`${busFindings.length} bus-factor-1 hot file${busFindings.length === 1 ? "" : "s"}`,
|
|
8645
|
+
`${voidFindings.length} knowledge void${voidFindings.length === 1 ? "" : "s"}`,
|
|
8646
|
+
...(docLines.length ? [`${docLieFindings.length} doc lie${docLieFindings.length === 1 ? "" : "s"}`] : []),
|
|
8647
|
+
];
|
|
8648
|
+
return {
|
|
8649
|
+
schema_version: 1,
|
|
8650
|
+
project_dir: projectDir,
|
|
8651
|
+
generated_at: nowIso(),
|
|
8652
|
+
totals: {
|
|
8653
|
+
files_scanned: graph.files.length,
|
|
8654
|
+
symbols_scanned: graph.symbols.length,
|
|
8655
|
+
duplicate_clusters: duplicateFindings.length,
|
|
8656
|
+
ghost_exports: ghostFindings.length,
|
|
8657
|
+
bus_factor_files: busFindings.length,
|
|
8658
|
+
knowledge_voids: voidFindings.length,
|
|
8659
|
+
doc_lies: docLieFindings.length,
|
|
8660
|
+
docs_scanned: docFiles.length,
|
|
8661
|
+
},
|
|
8662
|
+
headline: headlineParts.join(" · "),
|
|
8663
|
+
findings,
|
|
8664
|
+
warnings,
|
|
8665
|
+
next_actions: [
|
|
8666
|
+
"kage init --project <dir> create repo memory so this knowledge stops living in one head",
|
|
8667
|
+
"kage learn --project <dir> --learning \"...\" --paths <file> capture what only your team knows",
|
|
8668
|
+
"kage viewer --project <dir> watch the void close",
|
|
8669
|
+
],
|
|
8670
|
+
};
|
|
8671
|
+
}
|
|
7600
8672
|
function kageReviewerSuggestions(projectDir, targets = [], changedFiles = []) {
|
|
7601
8673
|
const graph = readCurrentCodeGraph(projectDir) ?? buildCodeGraph(projectDir);
|
|
7602
8674
|
const graphPaths = new Set(graph.files.map((file) => file.path));
|
|
@@ -10897,8 +11969,11 @@ function capture(input) {
|
|
|
10897
11969
|
};
|
|
10898
11970
|
}
|
|
10899
11971
|
const warnings = [];
|
|
10900
|
-
|
|
10901
|
-
|
|
11972
|
+
// .kageignore'd paths (e.g. a presentation/visualization layer) are not knowledge-bearing,
|
|
11973
|
+
// so they never become grounding for a packet — dropped before validation and storage.
|
|
11974
|
+
const groundedPaths = (input.paths ?? []).filter((path) => path && !isGroundingIgnored(input.projectDir, path));
|
|
11975
|
+
const meaningfulPaths = groundedPaths
|
|
11976
|
+
.filter((path) => meaningfulMemoryPath(path) && !shouldSkipRepoMemoryPath(path));
|
|
10902
11977
|
const missingPaths = meaningfulPaths.filter((path) => !pathExistsInRepo(input.projectDir, path));
|
|
10903
11978
|
// Citation validation. Strict mode (agent-facing record_memory tools / CLI) rejects a
|
|
10904
11979
|
// write whose every cited path is missing — the PRD's "reject if citations don't exist".
|
|
@@ -10937,7 +12012,7 @@ function capture(input) {
|
|
|
10937
12012
|
status: "approved",
|
|
10938
12013
|
confidence: DEFAULT_CONFIDENCE,
|
|
10939
12014
|
tags: input.tags ?? [],
|
|
10940
|
-
paths:
|
|
12015
|
+
paths: groundedPaths,
|
|
10941
12016
|
stack: input.stack ?? [],
|
|
10942
12017
|
source_refs: [
|
|
10943
12018
|
{
|
|
@@ -10949,7 +12024,7 @@ function capture(input) {
|
|
|
10949
12024
|
freshness: {
|
|
10950
12025
|
ttl_days: 365,
|
|
10951
12026
|
last_verified_at: createdAt,
|
|
10952
|
-
path_fingerprints: memoryPathFingerprints(input.projectDir,
|
|
12027
|
+
path_fingerprints: memoryPathFingerprints(input.projectDir, groundedPaths),
|
|
10953
12028
|
path_fingerprint_policy: "source_hash_staleness",
|
|
10954
12029
|
verification: "repo_local_agent_capture",
|
|
10955
12030
|
},
|
|
@@ -11123,8 +12198,18 @@ function setupAgent(agent, projectDir, options = {}) {
|
|
|
11123
12198
|
if (!exports.SETUP_AGENTS.includes(agent))
|
|
11124
12199
|
throw new Error(`Unsupported agent: ${agent}`);
|
|
11125
12200
|
const serverPath = options.serverPath ?? (0, node_path_1.join)(__dirname, "index.js");
|
|
11126
|
-
|
|
11127
|
-
|
|
12201
|
+
// An npx cache path (~/.npm/_npx/<hash>/...) is ephemeral — npx prunes it and
|
|
12202
|
+
// the configured MCP server silently dies. Point such configs at the package
|
|
12203
|
+
// runner instead so they survive cache eviction.
|
|
12204
|
+
const ephemeralNpxPath = serverPath.includes(`${node_path_1.sep}_npx${node_path_1.sep}`);
|
|
12205
|
+
const serverCommand = ephemeralNpxPath ? "npx" : "node";
|
|
12206
|
+
const serverArgs = ephemeralNpxPath ? ["-y", "@kage-core/kage-graph-mcp"] : [serverPath];
|
|
12207
|
+
if (options.write) {
|
|
12208
|
+
// `setup <agent> --write` is an explicit wiring action: the harness policy
|
|
12209
|
+
// (AGENTS.md / CLAUDE.md) is part of that wiring, unlike plain init/index
|
|
12210
|
+
// which must not touch repo-visible files.
|
|
12211
|
+
installAgentPolicy(projectDir);
|
|
12212
|
+
}
|
|
11128
12213
|
const home = options.homeDir ?? process.env.HOME ?? "~";
|
|
11129
12214
|
const universal = JSON.stringify({ mcpServers: { kage: { command: serverCommand, args: serverArgs } } }, null, 2);
|
|
11130
12215
|
const result = {
|
|
@@ -11147,7 +12232,7 @@ function setupAgent(agent, projectDir, options = {}) {
|
|
|
11147
12232
|
};
|
|
11148
12233
|
if (agent === "codex") {
|
|
11149
12234
|
const path = (0, node_path_1.join)(home, ".codex", "config.toml");
|
|
11150
|
-
const config = `[mcp_servers.kage]\ncommand = "
|
|
12235
|
+
const config = `[mcp_servers.kage]\ncommand = "${serverCommand}"\nargs = [${serverArgs.map((arg) => JSON.stringify(arg)).join(", ")}]\n`;
|
|
11151
12236
|
setSnippet(path, config, ["Add this block to ~/.codex/config.toml, then restart Codex.", "Run `kage init --project <repo>` inside each repo."], true);
|
|
11152
12237
|
if (options.write) {
|
|
11153
12238
|
ensureDir((0, node_path_1.dirname)(path));
|
|
@@ -12501,6 +13586,71 @@ function prSummarize(projectDir) {
|
|
|
12501
13586
|
warnings,
|
|
12502
13587
|
};
|
|
12503
13588
|
}
|
|
13589
|
+
// Working-tree changes vs HEAD: unstaged + staged + untracked. Outside a git
|
|
13590
|
+
// repo (or before the first commit) this degrades to "nothing changed".
|
|
13591
|
+
function workingTreeChangedFiles(projectDir) {
|
|
13592
|
+
const sections = [
|
|
13593
|
+
readGit(projectDir, ["diff", "--name-only", "HEAD"]),
|
|
13594
|
+
readGit(projectDir, ["diff", "--name-only", "--cached"]),
|
|
13595
|
+
readGit(projectDir, ["ls-files", "--others", "--exclude-standard"]),
|
|
13596
|
+
];
|
|
13597
|
+
return unique(sections
|
|
13598
|
+
.filter((section) => Boolean(section))
|
|
13599
|
+
.flatMap((section) => section.split(/\r?\n/))
|
|
13600
|
+
.map((path) => path.trim())
|
|
13601
|
+
.filter(Boolean)
|
|
13602
|
+
.map((path) => gitPathToProjectRelative(projectDir, path))
|
|
13603
|
+
.filter((path) => Boolean(path)));
|
|
13604
|
+
}
|
|
13605
|
+
function staleCatch(projectDir, changedFiles) {
|
|
13606
|
+
const changed = new Set((changedFiles?.length ? changedFiles : workingTreeChangedFiles(projectDir))
|
|
13607
|
+
.map((path) => path.replace(/\\/g, "/").replace(/^\/+/, ""))
|
|
13608
|
+
.filter(Boolean));
|
|
13609
|
+
const invalidated = [];
|
|
13610
|
+
if (changed.size) {
|
|
13611
|
+
const fpCache = new Map();
|
|
13612
|
+
for (const packet of loadPacketsFromDir(packetsDir(projectDir))) {
|
|
13613
|
+
if (packet.status !== "approved" && packet.status !== "pending")
|
|
13614
|
+
continue;
|
|
13615
|
+
for (const stored of packetStoredPathFingerprints(packet)) {
|
|
13616
|
+
if (!changed.has(stored.path) || isGroundingIgnored(projectDir, stored.path))
|
|
13617
|
+
continue;
|
|
13618
|
+
const current = memoryPathFingerprint(projectDir, stored.path, fpCache);
|
|
13619
|
+
if (current === null) {
|
|
13620
|
+
invalidated.push({ packet_id: packet.id, packet_title: packet.title, cited_path: stored.path, reason: "cited file was deleted" });
|
|
13621
|
+
}
|
|
13622
|
+
else if (current.sha256 !== stored.sha256) {
|
|
13623
|
+
invalidated.push({ packet_id: packet.id, packet_title: packet.title, cited_path: stored.path, reason: "content changed since this memory was verified" });
|
|
13624
|
+
}
|
|
13625
|
+
}
|
|
13626
|
+
}
|
|
13627
|
+
}
|
|
13628
|
+
if (invalidated.length) {
|
|
13629
|
+
recordValueEvents(projectDir, invalidated.map((item) => ({ kind: "stale_caught", packet_title: item.packet_title })));
|
|
13630
|
+
}
|
|
13631
|
+
const summary = invalidated.length
|
|
13632
|
+
? `Your changes invalidated ${invalidated.length} team ${invalidated.length === 1 ? "memory" : "memories"}.`
|
|
13633
|
+
: "No team memory invalidated by this change.";
|
|
13634
|
+
return {
|
|
13635
|
+
ok: true,
|
|
13636
|
+
project_dir: (0, node_path_1.resolve)(projectDir),
|
|
13637
|
+
changed_files: [...changed].sort(),
|
|
13638
|
+
invalidated,
|
|
13639
|
+
summary,
|
|
13640
|
+
};
|
|
13641
|
+
}
|
|
13642
|
+
// Shared human rendering for the stale-catch moment (CLI pr check, kage
|
|
13643
|
+
// staleguard, and the kage_pr_check MCP tool all print the exact same lines).
|
|
13644
|
+
function formatStaleCatch(result) {
|
|
13645
|
+
if (!result.invalidated.length)
|
|
13646
|
+
return ["✓ No team memory invalidated by this change"];
|
|
13647
|
+
const count = result.invalidated.length;
|
|
13648
|
+
return [
|
|
13649
|
+
`⚠ Your changes invalidated ${count} team ${count === 1 ? "memory" : "memories"}:`,
|
|
13650
|
+
...result.invalidated.map((item) => ` • ${item.packet_title} — cites ${item.cited_path} (${item.reason})`),
|
|
13651
|
+
" fix: kage learn (update) | kage supersede --packet <id>",
|
|
13652
|
+
];
|
|
13653
|
+
}
|
|
12504
13654
|
function prCheck(projectDir) {
|
|
12505
13655
|
ensureMemoryDirs(projectDir);
|
|
12506
13656
|
const overlay = buildBranchOverlay(projectDir);
|
|
@@ -12509,11 +13659,14 @@ function prCheck(projectDir) {
|
|
|
12509
13659
|
const tree = gitTree(projectDir);
|
|
12510
13660
|
const codeInputHash = currentCodeGraphInputHash(projectDir);
|
|
12511
13661
|
const memoryInputHash = knowledgeGraphInputHash(projectDir, codeInputHash);
|
|
12512
|
-
const
|
|
13662
|
+
const fpCache = new Map();
|
|
13663
|
+
const staleEntries = loadPacketsFromDir(packetsDir(projectDir))
|
|
12513
13664
|
.filter((packet) => packet.status === "approved" || packet.status === "pending")
|
|
12514
|
-
.map((packet) => ({ packet, reasons: staleMemoryReasons(projectDir, packet) }))
|
|
12515
|
-
.filter((entry) => entry.reasons.length)
|
|
12516
|
-
|
|
13665
|
+
.map((packet) => ({ packet, reasons: staleMemoryReasons(projectDir, packet, fpCache), hard: recallHardStaleReason(projectDir, packet, fpCache) !== null }))
|
|
13666
|
+
.filter((entry) => entry.reasons.length);
|
|
13667
|
+
const stalePackets = staleEntries.map((entry) => staleFinding(entry.packet, entry.reasons));
|
|
13668
|
+
const hardStaleCount = staleEntries.filter((entry) => entry.hard).length;
|
|
13669
|
+
const softStaleCount = staleEntries.length - hardStaleCount;
|
|
12517
13670
|
const memoryPacketChanges = unique(rawStatus
|
|
12518
13671
|
.split(/\r?\n/)
|
|
12519
13672
|
.map(parsePorcelainPath)
|
|
@@ -12526,13 +13679,18 @@ function prCheck(projectDir) {
|
|
|
12526
13679
|
const errors = [...validation.errors];
|
|
12527
13680
|
const warnings = [...validation.warnings];
|
|
12528
13681
|
const requiredActions = [];
|
|
12529
|
-
|
|
12530
|
-
|
|
12531
|
-
|
|
13682
|
+
// Block only on hard-stale memory (cited files deleted, ttl expired, reported
|
|
13683
|
+
// stale). Soft-stale ("linked code changed since capture") is normal during
|
|
13684
|
+
// active development — surface it as a warning, don't fail the gate.
|
|
13685
|
+
if (hardStaleCount) {
|
|
13686
|
+
errors.push(`${hardStaleCount} memory packet(s) are hard-stale (deleted citations, expired ttl, or reported) and must be updated or superseded.`);
|
|
13687
|
+
requiredActions.push("Run kage compact (or kage gc), then update or supersede the affected packets.");
|
|
13688
|
+
}
|
|
13689
|
+
if (softStaleCount) {
|
|
13690
|
+
warnings.push(`${softStaleCount} memory packet(s) reference code that changed since capture — review with kage verify (not blocking).`);
|
|
12532
13691
|
}
|
|
12533
13692
|
if (reconciliation.unresolved_count > 0) {
|
|
12534
|
-
|
|
12535
|
-
requiredActions.push(...reconciliation.items.slice(0, 5).map((item) => item.next_action));
|
|
13693
|
+
warnings.push(`${reconciliation.unresolved_count} memory reconciliation item(s) may need update after recent code changes (review on handoff; not blocking).`);
|
|
12536
13694
|
}
|
|
12537
13695
|
if (!codeGraphCurrent || !memoryGraphCurrent) {
|
|
12538
13696
|
errors.push("Generated graph artifacts are missing or not current for this working tree content.");
|
|
@@ -12540,8 +13698,7 @@ function prCheck(projectDir) {
|
|
|
12540
13698
|
}
|
|
12541
13699
|
const distillableSessions = sessions.sessions.filter((session) => session.durable_observations > 0);
|
|
12542
13700
|
if (distillableSessions.length) {
|
|
12543
|
-
|
|
12544
|
-
requiredActions.push(...distillableSessions.slice(0, 5).map((session) => session.next_action));
|
|
13701
|
+
warnings.push(`${distillableSessions.length} distillable session learning${distillableSessions.length === 1 ? "" : "s"} pending review (run kage distill; not blocking).`);
|
|
12545
13702
|
}
|
|
12546
13703
|
if (!memoryPacketChanges.length && overlay.changed_files.some((path) => !path.startsWith(".agent_memory/"))) {
|
|
12547
13704
|
warnings.push("No repo memory packet changed for this branch. If durable knowledge was learned, run kage propose --from-diff or kage learn.");
|
|
@@ -12860,150 +14017,6 @@ function recallFromPackets(query, packets, limit, label) {
|
|
|
12860
14017
|
results: scored,
|
|
12861
14018
|
};
|
|
12862
14019
|
}
|
|
12863
|
-
function orgStatus(projectDir, org) {
|
|
12864
|
-
ensureDir(orgInboxDir(projectDir, org));
|
|
12865
|
-
ensureDir(orgPacketsDir(projectDir, org));
|
|
12866
|
-
ensureDir(orgRejectedDir(projectDir, org));
|
|
12867
|
-
return {
|
|
12868
|
-
org: slugify(org),
|
|
12869
|
-
path: orgRootDir(projectDir, org),
|
|
12870
|
-
inbox: loadOrgInboxPackets(projectDir, org).length,
|
|
12871
|
-
approved: loadOrgApprovedPackets(projectDir, org).length,
|
|
12872
|
-
rejected: loadPacketsFromDir(orgRejectedDir(projectDir, org)).length,
|
|
12873
|
-
audit_events: orgAuditCount(projectDir, org),
|
|
12874
|
-
registry_path: (0, node_fs_1.existsSync)((0, node_path_1.join)(orgRootDir(projectDir, org), "registry.json")) ? (0, node_path_1.join)(orgRootDir(projectDir, org), "registry.json") : undefined,
|
|
12875
|
-
};
|
|
12876
|
-
}
|
|
12877
|
-
function orgUploadPacket(projectDir, org, id) {
|
|
12878
|
-
ensureMemoryDirs(projectDir);
|
|
12879
|
-
ensureDir(orgInboxDir(projectDir, org));
|
|
12880
|
-
const source = loadApprovedPackets(projectDir).find((packet) => packet.id === id);
|
|
12881
|
-
if (!source)
|
|
12882
|
-
return { ok: false, errors: [`Approved packet not found: ${id}`] };
|
|
12883
|
-
if (["blocked", "confidential"].includes(source.sensitivity)) {
|
|
12884
|
-
return { ok: false, errors: [`Packet sensitivity cannot be uploaded to org memory: ${source.sensitivity}`] };
|
|
12885
|
-
}
|
|
12886
|
-
const findings = scanSensitiveText(`${source.title}\n${source.summary}\n${source.body}\n${source.paths.join("\n")}`);
|
|
12887
|
-
if (findings.length)
|
|
12888
|
-
return { ok: false, errors: [`Sensitive content blocked: ${unique(findings).join(", ")}`] };
|
|
12889
|
-
const createdAt = nowIso();
|
|
12890
|
-
const packet = {
|
|
12891
|
-
...source,
|
|
12892
|
-
id: `org:${slugify(org)}:${(0, node_crypto_1.createHash)("sha256").update(source.id).digest("hex").slice(0, 16)}:${slugify(source.title)}`,
|
|
12893
|
-
scope: "org",
|
|
12894
|
-
visibility: "org",
|
|
12895
|
-
sensitivity: source.sensitivity === "public" ? "public" : "internal",
|
|
12896
|
-
status: "pending",
|
|
12897
|
-
tags: unique([...source.tags, "org-candidate"]).sort(),
|
|
12898
|
-
source_refs: [
|
|
12899
|
-
...source.source_refs,
|
|
12900
|
-
{
|
|
12901
|
-
kind: "org_upload_candidate",
|
|
12902
|
-
source_packet_id: source.id,
|
|
12903
|
-
repo_key: repoKey(projectDir),
|
|
12904
|
-
},
|
|
12905
|
-
],
|
|
12906
|
-
quality: {
|
|
12907
|
-
...source.quality,
|
|
12908
|
-
org_review_required: true,
|
|
12909
|
-
source_packet_id: source.id,
|
|
12910
|
-
},
|
|
12911
|
-
created_at: createdAt,
|
|
12912
|
-
updated_at: createdAt,
|
|
12913
|
-
};
|
|
12914
|
-
const validation = validatePacket(packet, "org candidate");
|
|
12915
|
-
if (!validation.ok)
|
|
12916
|
-
return { ok: false, errors: validation.errors };
|
|
12917
|
-
const path = (0, node_path_1.join)(orgInboxDir(projectDir, org), packetFileName(packet));
|
|
12918
|
-
writeJson(path, packet);
|
|
12919
|
-
appendOrgAudit(projectDir, org, { action: "upload_candidate", packet_id: packet.id, source_packet_id: source.id });
|
|
12920
|
-
return { ok: true, packet, path, errors: [] };
|
|
12921
|
-
}
|
|
12922
|
-
function orgReviewPacket(projectDir, org, id, action) {
|
|
12923
|
-
ensureDir(orgInboxDir(projectDir, org));
|
|
12924
|
-
ensureDir(orgPacketsDir(projectDir, org));
|
|
12925
|
-
ensureDir(orgRejectedDir(projectDir, org));
|
|
12926
|
-
const sourcePath = walkFiles(orgInboxDir(projectDir, org), (path) => path.endsWith(".json"))
|
|
12927
|
-
.find((path) => readJson(path).id === id);
|
|
12928
|
-
if (!sourcePath)
|
|
12929
|
-
return { ok: false, errors: [`Org inbox packet not found: ${id}`] };
|
|
12930
|
-
const packet = readJson(sourcePath);
|
|
12931
|
-
packet.status = action === "approve" ? "approved" : "deprecated";
|
|
12932
|
-
packet.updated_at = nowIso();
|
|
12933
|
-
packet.quality = {
|
|
12934
|
-
...packet.quality,
|
|
12935
|
-
org_reviewed_at: packet.updated_at,
|
|
12936
|
-
org_review_action: action,
|
|
12937
|
-
};
|
|
12938
|
-
const targetDir = action === "approve" ? orgPacketsDir(projectDir, org) : orgRejectedDir(projectDir, org);
|
|
12939
|
-
const targetPath = (0, node_path_1.join)(targetDir, packetFileName(packet));
|
|
12940
|
-
writeJson(targetPath, packet);
|
|
12941
|
-
(0, node_fs_1.renameSync)(sourcePath, `${sourcePath}.reviewed`);
|
|
12942
|
-
appendOrgAudit(projectDir, org, { action: `review_${action}`, packet_id: packet.id });
|
|
12943
|
-
exportOrgRegistry(projectDir, org);
|
|
12944
|
-
return { ok: true, path: targetPath, errors: [] };
|
|
12945
|
-
}
|
|
12946
|
-
function orgRecall(projectDir, org, query, limit = 5) {
|
|
12947
|
-
return recallFromPackets(query, loadOrgApprovedPackets(projectDir, org), limit, `Org:${slugify(org)}`);
|
|
12948
|
-
}
|
|
12949
|
-
function layeredRecall(projectDir, query, options = {}) {
|
|
12950
|
-
const limit = options.limit ?? 5;
|
|
12951
|
-
const repo = recall(projectDir, query, limit, true);
|
|
12952
|
-
const org = options.org ? orgRecall(projectDir, options.org, query, limit) : undefined;
|
|
12953
|
-
const global = options.includeGlobal ? recallFromPackets(query, loadPacketsFromDir(publicCandidatesDir(projectDir)), limit, "Global") : undefined;
|
|
12954
|
-
const blocks = [
|
|
12955
|
-
"# Kage Layered Recall",
|
|
12956
|
-
"",
|
|
12957
|
-
"Priority: branch > repo local > org > global",
|
|
12958
|
-
"",
|
|
12959
|
-
repo.context_block,
|
|
12960
|
-
org ? `\n---\n\n${org.context_block}` : "",
|
|
12961
|
-
global ? `\n---\n\n${global.context_block}` : "",
|
|
12962
|
-
].filter(Boolean);
|
|
12963
|
-
return {
|
|
12964
|
-
query,
|
|
12965
|
-
priority_order: ["branch", "repo", ...(org ? ["org"] : []), ...(global ? ["global"] : [])],
|
|
12966
|
-
context_block: blocks.join("\n"),
|
|
12967
|
-
repo,
|
|
12968
|
-
...(org ? { org } : {}),
|
|
12969
|
-
...(global ? { global } : {}),
|
|
12970
|
-
};
|
|
12971
|
-
}
|
|
12972
|
-
function exportOrgRegistry(projectDir, org) {
|
|
12973
|
-
const packets = loadOrgApprovedPackets(projectDir, org);
|
|
12974
|
-
const payload = {
|
|
12975
|
-
schema_version: 1,
|
|
12976
|
-
org: slugify(org),
|
|
12977
|
-
repo_key: repoKey(projectDir),
|
|
12978
|
-
generated_at: nowIso(),
|
|
12979
|
-
metrics: {
|
|
12980
|
-
packets: packets.length,
|
|
12981
|
-
by_type: countBy(packets, (packet) => packet.type),
|
|
12982
|
-
by_repo_path: countBy(packets.flatMap((packet) => packet.paths), (path) => path),
|
|
12983
|
-
},
|
|
12984
|
-
packets: packets.map((packet) => ({
|
|
12985
|
-
id: packet.id,
|
|
12986
|
-
title: packet.title,
|
|
12987
|
-
summary: packet.summary,
|
|
12988
|
-
type: packet.type,
|
|
12989
|
-
tags: packet.tags,
|
|
12990
|
-
paths: packet.paths,
|
|
12991
|
-
source_refs: packet.source_refs,
|
|
12992
|
-
updated_at: packet.updated_at,
|
|
12993
|
-
content_sha256: (0, node_crypto_1.createHash)("sha256").update(canonicalPacketText(packet)).digest("hex"),
|
|
12994
|
-
})),
|
|
12995
|
-
};
|
|
12996
|
-
const manifest = (0, index_js_1.createSignedManifest)({
|
|
12997
|
-
kind: "org_registry",
|
|
12998
|
-
name: `${slugify(org)} org memory`,
|
|
12999
|
-
version: nowIso().slice(0, 10),
|
|
13000
|
-
keyId: `${slugify(org)}-local`,
|
|
13001
|
-
payload,
|
|
13002
|
-
});
|
|
13003
|
-
writeJson((0, node_path_1.join)(orgRootDir(projectDir, org), "registry.json"), manifest);
|
|
13004
|
-
appendOrgAudit(projectDir, org, { action: "export_registry", packets: packets.length });
|
|
13005
|
-
return orgStatus(projectDir, org);
|
|
13006
|
-
}
|
|
13007
14020
|
function canonicalPacketText(packet) {
|
|
13008
14021
|
return JSON.stringify({
|
|
13009
14022
|
title: packet.title,
|
|
@@ -13014,86 +14027,6 @@ function canonicalPacketText(packet) {
|
|
|
13014
14027
|
paths: packet.paths,
|
|
13015
14028
|
});
|
|
13016
14029
|
}
|
|
13017
|
-
function buildMarketplace(projectDir) {
|
|
13018
|
-
ensureMemoryDirs(projectDir);
|
|
13019
|
-
const packs = registryRecommendations(projectDir).map((item) => ({
|
|
13020
|
-
...item,
|
|
13021
|
-
source: "repo_metadata",
|
|
13022
|
-
}));
|
|
13023
|
-
const manifest = {
|
|
13024
|
-
schema_version: 1,
|
|
13025
|
-
project_dir: projectDir,
|
|
13026
|
-
generated_at: nowIso(),
|
|
13027
|
-
packs,
|
|
13028
|
-
install_policy: "explicit_human_approval_required",
|
|
13029
|
-
};
|
|
13030
|
-
const path = (0, node_path_1.join)(marketplaceDir(projectDir), "manifest.json");
|
|
13031
|
-
writeJson(path, manifest);
|
|
13032
|
-
const planLines = [
|
|
13033
|
-
"# Kage Marketplace Install Plan",
|
|
13034
|
-
"",
|
|
13035
|
-
"Kage never installs marketplace assets automatically. Review each pack, then install it with your agent's normal trusted setup flow.",
|
|
13036
|
-
"",
|
|
13037
|
-
...packs.flatMap((pack) => [
|
|
13038
|
-
`## ${pack.title}`,
|
|
13039
|
-
"",
|
|
13040
|
-
`- ID: \`${pack.id}\``,
|
|
13041
|
-
`- Kind: \`${pack.kind}\``,
|
|
13042
|
-
`- Trust: \`${pack.trust}\``,
|
|
13043
|
-
`- Install policy: \`${pack.install}\``,
|
|
13044
|
-
`- Matched: ${pack.matched.join(", ") || "(repo metadata)"}`,
|
|
13045
|
-
"",
|
|
13046
|
-
pack.summary,
|
|
13047
|
-
"",
|
|
13048
|
-
]),
|
|
13049
|
-
];
|
|
13050
|
-
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(marketplaceDir(projectDir), "install-plan.md"), `${planLines.join("\n").trim()}\n`, "utf8");
|
|
13051
|
-
return { ok: true, path, packs, errors: [] };
|
|
13052
|
-
}
|
|
13053
|
-
function buildGlobalCdnBundle(projectDir, org = "local") {
|
|
13054
|
-
ensureMemoryDirs(projectDir);
|
|
13055
|
-
const publicBundle = exportPublicBundle(projectDir);
|
|
13056
|
-
if (!publicBundle.ok) {
|
|
13057
|
-
return { ok: false, root: globalCdnDir(projectDir), packet_count: 0, marketplace_packs: 0, errors: publicBundle.errors };
|
|
13058
|
-
}
|
|
13059
|
-
const marketplace = buildMarketplace(projectDir);
|
|
13060
|
-
const publicManifest = readJson(publicBundle.path);
|
|
13061
|
-
const registryManifest = (0, index_js_1.generateOrgRegistryManifest)({
|
|
13062
|
-
org: slugify(org),
|
|
13063
|
-
version: nowIso().slice(0, 10),
|
|
13064
|
-
keyId: `${slugify(org)}-global-local`,
|
|
13065
|
-
bundles: [publicManifest],
|
|
13066
|
-
});
|
|
13067
|
-
const root = globalCdnDir(projectDir);
|
|
13068
|
-
const digest = registryManifest.signature.payload_sha256.slice(0, 16);
|
|
13069
|
-
const manifestPath = (0, node_path_1.join)(root, `registry.${digest}.json`);
|
|
13070
|
-
const aliasPath = (0, node_path_1.join)(root, "latest.json");
|
|
13071
|
-
writeJson(manifestPath, registryManifest);
|
|
13072
|
-
writeJson((0, node_path_1.join)(root, "registry.json"), registryManifest);
|
|
13073
|
-
writeJson((0, node_path_1.join)(root, "revocations.json"), {
|
|
13074
|
-
schema_version: 1,
|
|
13075
|
-
generated_at: nowIso(),
|
|
13076
|
-
revoked: [],
|
|
13077
|
-
});
|
|
13078
|
-
writeJson(aliasPath, {
|
|
13079
|
-
schema_version: 1,
|
|
13080
|
-
registry: (0, node_path_1.relative)(root, manifestPath),
|
|
13081
|
-
marketplace: (0, node_path_1.relative)(root, marketplace.path),
|
|
13082
|
-
payload_sha256: registryManifest.signature.payload_sha256,
|
|
13083
|
-
generated_at: registryManifest.generated_at,
|
|
13084
|
-
rollback_ready: true,
|
|
13085
|
-
});
|
|
13086
|
-
return {
|
|
13087
|
-
ok: true,
|
|
13088
|
-
root,
|
|
13089
|
-
manifest_path: manifestPath,
|
|
13090
|
-
alias_path: aliasPath,
|
|
13091
|
-
marketplace_path: marketplace.path,
|
|
13092
|
-
packet_count: registryManifest.payload.metrics.entry_count,
|
|
13093
|
-
marketplace_packs: marketplace.packs.length,
|
|
13094
|
-
errors: [],
|
|
13095
|
-
};
|
|
13096
|
-
}
|
|
13097
14030
|
function recordFeedback(projectDir, id, feedback) {
|
|
13098
14031
|
ensureMemoryDirs(projectDir);
|
|
13099
14032
|
if (!["helpful", "wrong", "stale"].includes(feedback)) {
|
|
@@ -13244,13 +14177,19 @@ function installClaudeSettings(projectDir) {
|
|
|
13244
14177
|
settings.allowedTools = merged;
|
|
13245
14178
|
writeJson(settingsPath, settings);
|
|
13246
14179
|
}
|
|
13247
|
-
function initProject(projectDir) {
|
|
13248
|
-
|
|
13249
|
-
|
|
14180
|
+
function initProject(projectDir, options = {}) {
|
|
14181
|
+
// Default init touches ONLY .agent_memory/. Agent-policy files (AGENTS.md,
|
|
14182
|
+
// CLAUDE.md) and .claude/settings.json are repo-visible and reviewable, so
|
|
14183
|
+
// writing them requires explicit opt-in (`kage init --with-policy` or `kage policy`).
|
|
14184
|
+
const policyInstalled = options.policy === true;
|
|
14185
|
+
if (policyInstalled) {
|
|
14186
|
+
installAgentPolicy(projectDir);
|
|
14187
|
+
installClaudeSettings(projectDir);
|
|
14188
|
+
}
|
|
13250
14189
|
const index = indexProject(projectDir, { graphs: false });
|
|
13251
14190
|
const validation = validateProject(projectDir);
|
|
13252
14191
|
const sampleRecall = recallFromPackets("how do I run tests", loadApprovedPackets(projectDir), 5, "Repo Memory");
|
|
13253
|
-
return { index, validation, sampleRecall };
|
|
14192
|
+
return { index, validation, sampleRecall, policyInstalled };
|
|
13254
14193
|
}
|
|
13255
14194
|
function doctorProject(projectDir) {
|
|
13256
14195
|
ensureMemoryDirs(projectDir);
|