@kage-core/kage-graph-mcp 2.2.7 → 2.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +174 -16
- package/dist/index.js +98 -31
- package/dist/kernel.js +1103 -34
- package/package.json +2 -2
- package/viewer/console.js +69 -2
- package/viewer/index.html +12 -3
package/dist/kernel.js
CHANGED
|
@@ -67,6 +67,8 @@ exports.valueSummary = valueSummary;
|
|
|
67
67
|
exports.formatTokenCount = formatTokenCount;
|
|
68
68
|
exports.kageFileContext = kageFileContext;
|
|
69
69
|
exports.kageActivity = kageActivity;
|
|
70
|
+
exports.detectContradictions = detectContradictions;
|
|
71
|
+
exports.kageConflicts = kageConflicts;
|
|
70
72
|
exports.kageMemoryReconciliation = kageMemoryReconciliation;
|
|
71
73
|
exports.evaluateMemoryAdmission = evaluateMemoryAdmission;
|
|
72
74
|
exports.validatePacket = validatePacket;
|
|
@@ -87,6 +89,9 @@ exports.writeLspSymbolIndex = writeLspSymbolIndex;
|
|
|
87
89
|
exports.writeCodeIndex = writeCodeIndex;
|
|
88
90
|
exports.buildCodeGraph = buildCodeGraph;
|
|
89
91
|
exports.buildKnowledgeGraph = buildKnowledgeGraph;
|
|
92
|
+
exports.buildDocsIndex = buildDocsIndex;
|
|
93
|
+
exports.searchDocs = searchDocs;
|
|
94
|
+
exports.docsRecallSection = docsRecallSection;
|
|
90
95
|
exports.buildIndexes = buildIndexes;
|
|
91
96
|
exports.indexProject = indexProject;
|
|
92
97
|
exports.refreshProject = refreshProject;
|
|
@@ -105,6 +110,8 @@ exports.kageRisk = kageRisk;
|
|
|
105
110
|
exports.kageDependencyPath = kageDependencyPath;
|
|
106
111
|
exports.kageCleanupCandidates = kageCleanupCandidates;
|
|
107
112
|
exports.truthReport = truthReport;
|
|
113
|
+
exports.truthScorecardSvg = truthScorecardSvg;
|
|
114
|
+
exports.truthScorecardMarkdown = truthScorecardMarkdown;
|
|
108
115
|
exports.defaultClaudeMemStorePath = defaultClaudeMemStorePath;
|
|
109
116
|
exports.claudeMemProjectKey = claudeMemProjectKey;
|
|
110
117
|
exports.parseClaudeMemFileList = parseClaudeMemFileList;
|
|
@@ -175,6 +182,7 @@ exports.remediationFor = remediationFor;
|
|
|
175
182
|
exports.approvePending = approvePending;
|
|
176
183
|
exports.rejectPending = rejectPending;
|
|
177
184
|
exports.changelog = changelog;
|
|
185
|
+
exports.generateSkills = generateSkills;
|
|
178
186
|
exports.reverifyMemory = reverifyMemory;
|
|
179
187
|
exports.supersedeMemory = supersedeMemory;
|
|
180
188
|
exports.kageMemoryLineage = kageMemoryLineage;
|
|
@@ -189,6 +197,7 @@ exports.capturePersonal = capturePersonal;
|
|
|
189
197
|
exports.syncSetup = syncSetup;
|
|
190
198
|
exports.syncStatus = syncStatus;
|
|
191
199
|
exports.syncPersonal = syncPersonal;
|
|
200
|
+
exports.kageLayers = kageLayers;
|
|
192
201
|
const node_crypto_1 = require("node:crypto");
|
|
193
202
|
const node_child_process_1 = require("node:child_process");
|
|
194
203
|
const node_fs_1 = require("node:fs");
|
|
@@ -249,6 +258,9 @@ exports.SETUP_AGENTS = [
|
|
|
249
258
|
"kilo-code",
|
|
250
259
|
"claude-desktop",
|
|
251
260
|
"aider",
|
|
261
|
+
"openclaw",
|
|
262
|
+
"copilot",
|
|
263
|
+
"hermes",
|
|
252
264
|
"generic-mcp",
|
|
253
265
|
];
|
|
254
266
|
const DEFAULT_CONFIDENCE = 0.7;
|
|
@@ -275,6 +287,13 @@ If Kage appears installed but no Kage tools are available, report that the activ
|
|
|
275
287
|
agent session has not loaded the MCP server and ask the user to restart the
|
|
276
288
|
agent. After restart, call \`kage_verify_agent\` to prove the harness is live.
|
|
277
289
|
|
|
290
|
+
## Show the Value
|
|
291
|
+
|
|
292
|
+
\`kage_context\` and \`kage_recall\` return a one-line gains receipt (tokens/$ saved
|
|
293
|
+
this session, stale memories withheld). When it is non-trivial, relay it to the
|
|
294
|
+
user in your own words — Kage's value is otherwise invisible, and a user who never
|
|
295
|
+
sees it churns. Repeat only what the tool actually reported; never fabricate numbers.
|
|
296
|
+
|
|
278
297
|
## Automatic Capture
|
|
279
298
|
|
|
280
299
|
When you learn something reusable, create repo-local memory with \`kage_learn\`.
|
|
@@ -1345,6 +1364,199 @@ function duplicateCandidatesWithContext(packet, context, threshold = 0.58) {
|
|
|
1345
1364
|
status: entry.packet.status,
|
|
1346
1365
|
}));
|
|
1347
1366
|
}
|
|
1367
|
+
// Negation cues: a body that flips a claim. "do not use", "is not", "never",
|
|
1368
|
+
// "should not", "no longer", "stop using", "avoid", "instead of".
|
|
1369
|
+
const CONTRADICTION_NEGATION_CUES = [
|
|
1370
|
+
"do not",
|
|
1371
|
+
"don't",
|
|
1372
|
+
"does not",
|
|
1373
|
+
"doesn't",
|
|
1374
|
+
"is not",
|
|
1375
|
+
"isn't",
|
|
1376
|
+
"are not",
|
|
1377
|
+
"aren't",
|
|
1378
|
+
"was not",
|
|
1379
|
+
"should not",
|
|
1380
|
+
"shouldn't",
|
|
1381
|
+
"must not",
|
|
1382
|
+
"cannot",
|
|
1383
|
+
"can't",
|
|
1384
|
+
"never",
|
|
1385
|
+
"no longer",
|
|
1386
|
+
"not idempotent",
|
|
1387
|
+
"not safe",
|
|
1388
|
+
"not thread-safe",
|
|
1389
|
+
"not supported",
|
|
1390
|
+
"not required",
|
|
1391
|
+
"not needed",
|
|
1392
|
+
"stop using",
|
|
1393
|
+
"avoid",
|
|
1394
|
+
"deprecated",
|
|
1395
|
+
"removed",
|
|
1396
|
+
"disable",
|
|
1397
|
+
"disabled",
|
|
1398
|
+
];
|
|
1399
|
+
// Replacement cues: a body that points the claim at a different answer.
|
|
1400
|
+
// "use Y instead", "replaced by", "switched to", "now use", "migrate to".
|
|
1401
|
+
const CONTRADICTION_REPLACEMENT_CUES = [
|
|
1402
|
+
"instead",
|
|
1403
|
+
"instead of",
|
|
1404
|
+
"replaced by",
|
|
1405
|
+
"replaced with",
|
|
1406
|
+
"replace with",
|
|
1407
|
+
"switched to",
|
|
1408
|
+
"switch to",
|
|
1409
|
+
"migrated to",
|
|
1410
|
+
"migrate to",
|
|
1411
|
+
"now use",
|
|
1412
|
+
"now uses",
|
|
1413
|
+
"moved to",
|
|
1414
|
+
"superseded by",
|
|
1415
|
+
"in favor of",
|
|
1416
|
+
"rather than",
|
|
1417
|
+
];
|
|
1418
|
+
function normalizedClaimText(packet) {
|
|
1419
|
+
return `${packet.title}\n${packet.summary}\n${packet.body}`.toLowerCase();
|
|
1420
|
+
}
|
|
1421
|
+
function negationCueCount(text) {
|
|
1422
|
+
let count = 0;
|
|
1423
|
+
for (const cue of CONTRADICTION_NEGATION_CUES)
|
|
1424
|
+
if (text.includes(cue))
|
|
1425
|
+
count += 1;
|
|
1426
|
+
return count;
|
|
1427
|
+
}
|
|
1428
|
+
function replacementCueCount(text) {
|
|
1429
|
+
let count = 0;
|
|
1430
|
+
for (const cue of CONTRADICTION_REPLACEMENT_CUES)
|
|
1431
|
+
if (text.includes(cue))
|
|
1432
|
+
count += 1;
|
|
1433
|
+
return count;
|
|
1434
|
+
}
|
|
1435
|
+
// Tokens too generic to mean "these two packets are about the same subject".
|
|
1436
|
+
// In a memory repo about an agent-memory tool, words like "kage", "memory",
|
|
1437
|
+
// "agent", "code", "now", "use" appear in nearly every decision packet — so two
|
|
1438
|
+
// unrelated decisions that merely both touch mcp/index.ts would share several of
|
|
1439
|
+
// them and (under the old subjectOverlap<2 bypass) be flagged as contradictions.
|
|
1440
|
+
// The SUBJECT must be the DISTINCTIVE vocabulary, not the house style.
|
|
1441
|
+
const CONTRADICTION_GENERIC_SUBJECT = new Set([
|
|
1442
|
+
...STOPWORDS,
|
|
1443
|
+
"use", "uses", "used", "using", "via", "per", "now", "new", "when", "then",
|
|
1444
|
+
"this", "that", "these", "those", "not", "but", "also", "into", "from", "its",
|
|
1445
|
+
"kage", "mcp", "memory", "memories", "agent", "agents", "packet", "packets",
|
|
1446
|
+
"code", "graph", "repo", "repository", "file", "files", "path", "paths",
|
|
1447
|
+
"recall", "capture", "captured", "decision", "decisions", "must", "should",
|
|
1448
|
+
"report", "reports", "result", "results", "support", "supports", "supported",
|
|
1449
|
+
]);
|
|
1450
|
+
// Distinctive subject vocabulary of a packet: title+summary tokens with generic
|
|
1451
|
+
// / house-style words removed. Two packets share a subject only when their
|
|
1452
|
+
// DISTINCTIVE tokens overlap, not when they both say "kage memory decision".
|
|
1453
|
+
function distinctiveSubjectTokens(packet) {
|
|
1454
|
+
return new Set([...tokenSet(`${packet.title}\n${packet.summary}`)].filter((token) => !CONTRADICTION_GENERIC_SUBJECT.has(token)));
|
|
1455
|
+
}
|
|
1456
|
+
// Opposing-polarity signal between two same-subject bodies. Conservative: a
|
|
1457
|
+
// contradiction needs one side to assert a claim and the other to negate or
|
|
1458
|
+
// replace it — NOT both sides carrying identical framing (that's a duplicate).
|
|
1459
|
+
function opposingPolarity(aText, bText) {
|
|
1460
|
+
const aNeg = negationCueCount(aText);
|
|
1461
|
+
const bNeg = negationCueCount(bText);
|
|
1462
|
+
const aRepl = replacementCueCount(aText);
|
|
1463
|
+
const bRepl = replacementCueCount(bText);
|
|
1464
|
+
// (1) Negation asymmetry: one side negates the claim, the other does not.
|
|
1465
|
+
// Two equally-hedged notes (both negate, or neither negates) are not flagged.
|
|
1466
|
+
if (aNeg === 0 && bNeg >= 1)
|
|
1467
|
+
return "one memory negates a claim the other asserts";
|
|
1468
|
+
if (bNeg === 0 && aNeg >= 1)
|
|
1469
|
+
return "one memory negates a claim the other asserts";
|
|
1470
|
+
// (2) Replacement asymmetry: one side says "use Y instead", the other still
|
|
1471
|
+
// asserts the original (no replacement framing). Same-subject + redirect.
|
|
1472
|
+
if (aRepl === 0 && bRepl >= 1)
|
|
1473
|
+
return "one memory replaces the approach the other still recommends";
|
|
1474
|
+
if (bRepl === 0 && aRepl >= 1)
|
|
1475
|
+
return "one memory replaces the approach the other still recommends";
|
|
1476
|
+
return null;
|
|
1477
|
+
}
|
|
1478
|
+
// Detect existing approved packets that contradict `candidate`. Pure: takes the
|
|
1479
|
+
// candidate and (optionally) the existing pool, performs no writes. Returns the
|
|
1480
|
+
// conflicting approved packets with the shared paths and a human reason.
|
|
1481
|
+
function detectContradictions(projectDir, candidate, opts = {}) {
|
|
1482
|
+
// Precision over recall: two packets sharing a path is NOT enough — they must
|
|
1483
|
+
// be about the same distinctive subject. 0.5 (was 0.34) plus the generic-token
|
|
1484
|
+
// filter is what collapses the false-positive storm (786 pairs -> ~real).
|
|
1485
|
+
const subjectThreshold = opts.subjectThreshold ?? 0.5;
|
|
1486
|
+
const candidatePaths = new Set(candidate.paths.filter((path) => meaningfulMemoryPath(path) && !shouldSkipRepoMemoryPath(path)));
|
|
1487
|
+
if (!candidatePaths.size)
|
|
1488
|
+
return [];
|
|
1489
|
+
const existing = (opts.existing ?? loadApprovedPackets(projectDir)).filter((packet) => packet.id !== candidate.id && packet.status === "approved");
|
|
1490
|
+
const candidateText = normalizedClaimText(candidate);
|
|
1491
|
+
const candidateSubject = distinctiveSubjectTokens(candidate);
|
|
1492
|
+
const findings = [];
|
|
1493
|
+
for (const packet of existing) {
|
|
1494
|
+
// (a) shared cited path — the structural anchor that makes a contradiction
|
|
1495
|
+
// about the SAME thing rather than two unrelated facts.
|
|
1496
|
+
const sharedPaths = packet.paths.filter((path) => candidatePaths.has(path));
|
|
1497
|
+
if (!sharedPaths.length)
|
|
1498
|
+
continue;
|
|
1499
|
+
// (b) same DISTINCTIVE subject. A genuine contradiction is the SAME claim
|
|
1500
|
+
// negated ("use X" vs "do not use X"), so the two packets must be near
|
|
1501
|
+
// paraphrases of each other's subject — measured over distinctive tokens
|
|
1502
|
+
// (jaccard). Two decisions that merely touch adjacent code in the same
|
|
1503
|
+
// file share a path and a few tokens but are NOT paraphrases, so they no
|
|
1504
|
+
// longer qualify. No token-count bypass: that was the false-positive door.
|
|
1505
|
+
const packetSubject = distinctiveSubjectTokens(packet);
|
|
1506
|
+
const subjectSimilarity = jaccard(candidateSubject, packetSubject);
|
|
1507
|
+
if (subjectSimilarity < subjectThreshold)
|
|
1508
|
+
continue;
|
|
1509
|
+
// (c) opposing polarity — the claim itself is flipped/redirected.
|
|
1510
|
+
const reason = opposingPolarity(candidateText, normalizedClaimText(packet));
|
|
1511
|
+
if (!reason)
|
|
1512
|
+
continue;
|
|
1513
|
+
findings.push({
|
|
1514
|
+
packet_id: packet.id,
|
|
1515
|
+
title: packet.title,
|
|
1516
|
+
shared_paths: sharedPaths,
|
|
1517
|
+
reason: `${reason} (shared path: ${sharedPaths.join(", ")})`,
|
|
1518
|
+
});
|
|
1519
|
+
}
|
|
1520
|
+
return findings
|
|
1521
|
+
.sort((a, b) => a.title.localeCompare(b.title))
|
|
1522
|
+
.slice(0, 5);
|
|
1523
|
+
}
|
|
1524
|
+
// Repo-wide pairwise contradiction scan across approved packets. Reuses
|
|
1525
|
+
// detectContradictions per packet against the already-loaded pool so the same
|
|
1526
|
+
// conservative heuristic governs both write-time surfacing and the audit view.
|
|
1527
|
+
function kageConflicts(projectDir) {
|
|
1528
|
+
ensureMemoryDirs(projectDir);
|
|
1529
|
+
const approved = loadApprovedPackets(projectDir);
|
|
1530
|
+
const byId = new Map(approved.map((packet) => [packet.id, packet]));
|
|
1531
|
+
const pairs = [];
|
|
1532
|
+
const seen = new Set();
|
|
1533
|
+
for (const packet of approved) {
|
|
1534
|
+
const findings = detectContradictions(projectDir, packet, { existing: approved });
|
|
1535
|
+
for (const finding of findings) {
|
|
1536
|
+
const ordered = [packet.id, finding.packet_id].sort();
|
|
1537
|
+
const key = ordered.join("\0");
|
|
1538
|
+
if (seen.has(key))
|
|
1539
|
+
continue;
|
|
1540
|
+
seen.add(key);
|
|
1541
|
+
const firstPacket = byId.get(ordered[0]);
|
|
1542
|
+
const secondPacket = byId.get(ordered[1]);
|
|
1543
|
+
pairs.push({
|
|
1544
|
+
a: { id: firstPacket.id, title: firstPacket.title },
|
|
1545
|
+
b: { id: secondPacket.id, title: secondPacket.title },
|
|
1546
|
+
shared_paths: finding.shared_paths,
|
|
1547
|
+
reason: finding.reason,
|
|
1548
|
+
});
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
pairs.sort((x, y) => x.a.title.localeCompare(y.a.title) || x.b.title.localeCompare(y.b.title));
|
|
1552
|
+
return {
|
|
1553
|
+
ok: true,
|
|
1554
|
+
project_dir: projectDir,
|
|
1555
|
+
generated_at: nowIso(),
|
|
1556
|
+
count: pairs.length,
|
|
1557
|
+
pairs,
|
|
1558
|
+
};
|
|
1559
|
+
}
|
|
1348
1560
|
function packetFeedbackScore(packet) {
|
|
1349
1561
|
const quality = (packet.quality ?? {});
|
|
1350
1562
|
return Number(quality.votes_up ?? 0) * 2 - Number(quality.votes_down ?? 0) * 3 - Number(quality.reports_stale ?? 0) * 4;
|
|
@@ -1419,15 +1631,144 @@ function memoryPathFingerprint(projectDir, path, cache) {
|
|
|
1419
1631
|
return null;
|
|
1420
1632
|
}
|
|
1421
1633
|
}
|
|
1422
|
-
|
|
1634
|
+
// Symbol-anchoring is only attempted where extractSymbols (the TS/JS parser) is
|
|
1635
|
+
// reliable. Other languages fall back to whole-file fingerprints — no granularity
|
|
1636
|
+
// benefit yet, but no regression either.
|
|
1637
|
+
const ANCHOR_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
|
|
1638
|
+
function pathSupportsSymbolAnchors(path) {
|
|
1639
|
+
return ANCHOR_EXTENSIONS.has((0, node_path_1.extname)(path).toLowerCase());
|
|
1640
|
+
}
|
|
1641
|
+
// Whole identifiers mentioned in a packet's text — the names of the symbols the
|
|
1642
|
+
// memory is talking about. camelCase / snake_case kept intact (not sub-tokenized)
|
|
1643
|
+
// so "detectContradictions" matches the symbol, not "detect" + "contradictions".
|
|
1644
|
+
function identifierTokens(text) {
|
|
1645
|
+
const out = new Set();
|
|
1646
|
+
for (const match of text.matchAll(/[A-Za-z_$][A-Za-z0-9_$]*/g))
|
|
1647
|
+
out.add(match[0].toLowerCase());
|
|
1648
|
+
return out;
|
|
1649
|
+
}
|
|
1650
|
+
// current-file symbol span hashes, keyed by `${nameLower}\0${kind}` -> [sha256...].
|
|
1651
|
+
// Cached by mtime+size: extraction only runs when a file actually changed.
|
|
1652
|
+
const anchorSymbolCache = new Map();
|
|
1653
|
+
function symbolSpanHashesFromText(path, text) {
|
|
1654
|
+
const byKey = new Map();
|
|
1655
|
+
let symbols;
|
|
1656
|
+
try {
|
|
1657
|
+
symbols = extractSymbols(path, text);
|
|
1658
|
+
}
|
|
1659
|
+
catch {
|
|
1660
|
+
return byKey;
|
|
1661
|
+
}
|
|
1662
|
+
const lines = text.split(/\r?\n/);
|
|
1663
|
+
for (const symbol of symbols) {
|
|
1664
|
+
if (symbol.name.length < 4)
|
|
1665
|
+
continue;
|
|
1666
|
+
if (symbol.end_line == null || symbol.line < 1 || symbol.end_line < symbol.line)
|
|
1667
|
+
continue;
|
|
1668
|
+
const span = lines.slice(symbol.line - 1, symbol.end_line).join("\n");
|
|
1669
|
+
if (!span.trim())
|
|
1670
|
+
continue;
|
|
1671
|
+
const key = `${symbol.name.toLowerCase()}\0${symbol.kind}`;
|
|
1672
|
+
const list = byKey.get(key) ?? [];
|
|
1673
|
+
list.push(sha256Hex(Buffer.from(span, "utf8")));
|
|
1674
|
+
byKey.set(key, list);
|
|
1675
|
+
}
|
|
1676
|
+
return byKey;
|
|
1677
|
+
}
|
|
1678
|
+
function fileSymbolSpanHashes(projectDir, path) {
|
|
1679
|
+
const normalized = path.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
1680
|
+
if (!pathSupportsSymbolAnchors(normalized))
|
|
1681
|
+
return null;
|
|
1682
|
+
const absolutePath = (0, node_path_1.join)(projectDir, normalized);
|
|
1683
|
+
let stats;
|
|
1684
|
+
try {
|
|
1685
|
+
stats = (0, node_fs_1.statSync)(absolutePath);
|
|
1686
|
+
if (!stats.isFile())
|
|
1687
|
+
return null;
|
|
1688
|
+
}
|
|
1689
|
+
catch {
|
|
1690
|
+
return null;
|
|
1691
|
+
}
|
|
1692
|
+
const cacheKey = `${projectDir}\0${normalized}`;
|
|
1693
|
+
const warm = anchorSymbolCache.get(cacheKey);
|
|
1694
|
+
if (warm && warm.mtimeMs === stats.mtimeMs && warm.size === stats.size)
|
|
1695
|
+
return warm.byKey;
|
|
1696
|
+
let text;
|
|
1697
|
+
try {
|
|
1698
|
+
text = (0, node_fs_1.readFileSync)(absolutePath, "utf8");
|
|
1699
|
+
}
|
|
1700
|
+
catch {
|
|
1701
|
+
return null;
|
|
1702
|
+
}
|
|
1703
|
+
const byKey = symbolSpanHashesFromText(normalized, text);
|
|
1704
|
+
anchorSymbolCache.set(cacheKey, { mtimeMs: stats.mtimeMs, size: stats.size, byKey });
|
|
1705
|
+
return byKey;
|
|
1706
|
+
}
|
|
1707
|
+
// Compute fingerprints for a packet's cited paths. When anchorText (the packet's
|
|
1708
|
+
// title+summary+body) is supplied, anchor each TS/JS file to the symbols the
|
|
1709
|
+
// memory actually names, so unrelated edits in the same file do not mark it stale.
|
|
1710
|
+
function memoryPathFingerprints(projectDir, paths, anchorText) {
|
|
1711
|
+
const idents = anchorText ? identifierTokens(anchorText) : null;
|
|
1423
1712
|
const fingerprints = [];
|
|
1424
1713
|
for (const path of unique(paths).filter(fingerprintableMemoryPath)) {
|
|
1425
1714
|
const fingerprint = memoryPathFingerprint(projectDir, path);
|
|
1426
|
-
if (fingerprint)
|
|
1427
|
-
|
|
1715
|
+
if (!fingerprint)
|
|
1716
|
+
continue;
|
|
1717
|
+
if (idents && pathSupportsSymbolAnchors(path)) {
|
|
1718
|
+
const byKey = fileSymbolSpanHashes(projectDir, path);
|
|
1719
|
+
if (byKey) {
|
|
1720
|
+
// extractSymbols recurses into bodies, so a generic local like `match` can
|
|
1721
|
+
// appear many times. Only anchor names that resolve to EXACTLY ONE symbol
|
|
1722
|
+
// span in the file — an unambiguous handle on the thing the memory means.
|
|
1723
|
+
const spanCountByName = new Map();
|
|
1724
|
+
for (const [key, hashes] of byKey) {
|
|
1725
|
+
const name = key.slice(0, key.indexOf("\0"));
|
|
1726
|
+
spanCountByName.set(name, (spanCountByName.get(name) ?? 0) + hashes.length);
|
|
1727
|
+
}
|
|
1728
|
+
const symbols = [];
|
|
1729
|
+
for (const [key, hashes] of byKey) {
|
|
1730
|
+
const [name, kind] = key.split("\0");
|
|
1731
|
+
if (!idents.has(name) || spanCountByName.get(name) !== 1)
|
|
1732
|
+
continue;
|
|
1733
|
+
symbols.push({ name, kind, sha256: hashes[0] });
|
|
1734
|
+
}
|
|
1735
|
+
if (symbols.length) {
|
|
1736
|
+
fingerprints.push({ ...fingerprint, symbols: symbols.slice(0, 32) });
|
|
1737
|
+
continue;
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
fingerprints.push(fingerprint);
|
|
1428
1742
|
}
|
|
1429
1743
|
return fingerprints;
|
|
1430
1744
|
}
|
|
1745
|
+
// Has the content a memory depends on changed since capture? Whole-file identical
|
|
1746
|
+
// is always "no". When the file differs but the memory is anchored to specific
|
|
1747
|
+
// symbols, it is "changed" only if one of those symbols was edited or removed —
|
|
1748
|
+
// edits elsewhere in the same file do not invalidate it. Unanchored fingerprints
|
|
1749
|
+
// (non-TS files, prose memories) keep the whole-file policy.
|
|
1750
|
+
function fingerprintPathContentChanged(projectDir, stored, cache) {
|
|
1751
|
+
const current = memoryPathFingerprint(projectDir, stored.path, cache);
|
|
1752
|
+
if (current === null)
|
|
1753
|
+
return false; // missing path is handled by the dedicated check
|
|
1754
|
+
if (current.sha256 === stored.sha256)
|
|
1755
|
+
return false; // whole file byte-identical
|
|
1756
|
+
if (stored.symbols && stored.symbols.length) {
|
|
1757
|
+
const byKey = fileSymbolSpanHashes(projectDir, stored.path);
|
|
1758
|
+
if (byKey) {
|
|
1759
|
+
for (const symbol of stored.symbols) {
|
|
1760
|
+
const currentHashes = byKey.get(`${symbol.name.toLowerCase()}\0${symbol.kind}`);
|
|
1761
|
+
if (!currentHashes || !currentHashes.length)
|
|
1762
|
+
return true; // anchored symbol gone
|
|
1763
|
+
if (!currentHashes.includes(symbol.sha256))
|
|
1764
|
+
return true; // anchored symbol edited
|
|
1765
|
+
}
|
|
1766
|
+
return false; // every anchored symbol is byte-identical; the rest of the file is irrelevant
|
|
1767
|
+
}
|
|
1768
|
+
// Parser unavailable for this file now — fall through to the whole-file signal.
|
|
1769
|
+
}
|
|
1770
|
+
return true;
|
|
1771
|
+
}
|
|
1431
1772
|
function packetStoredPathFingerprints(packet) {
|
|
1432
1773
|
const raw = (packet.freshness ?? {}).path_fingerprints;
|
|
1433
1774
|
if (!Array.isArray(raw))
|
|
@@ -1441,9 +1782,31 @@ function packetStoredPathFingerprints(packet) {
|
|
|
1441
1782
|
const size = Number(record.size ?? 0);
|
|
1442
1783
|
if (!path || !sha256 || !Number.isFinite(size) || !fingerprintableMemoryPath(path))
|
|
1443
1784
|
return [];
|
|
1444
|
-
|
|
1785
|
+
const symbols = Array.isArray(record.symbols)
|
|
1786
|
+
? record.symbols.flatMap((entry) => {
|
|
1787
|
+
if (!entry || typeof entry !== "object")
|
|
1788
|
+
return [];
|
|
1789
|
+
const sym = entry;
|
|
1790
|
+
const name = typeof sym.name === "string" ? sym.name : "";
|
|
1791
|
+
const kind = typeof sym.kind === "string" ? sym.kind : "";
|
|
1792
|
+
const symSha = typeof sym.sha256 === "string" ? sym.sha256 : "";
|
|
1793
|
+
if (!name || !symSha)
|
|
1794
|
+
return [];
|
|
1795
|
+
return [{ name, kind, sha256: symSha }];
|
|
1796
|
+
})
|
|
1797
|
+
: undefined;
|
|
1798
|
+
return [{ path, sha256, size, ...(symbols && symbols.length ? { symbols } : {}) }];
|
|
1445
1799
|
});
|
|
1446
1800
|
}
|
|
1801
|
+
// Append-only "ledger" docs (CHANGELOG, HISTORY) churn on nearly every commit.
|
|
1802
|
+
// Their content hash changes constantly without invalidating a memory's claim, so
|
|
1803
|
+
// they are excluded from content-change (soft-stale) detection — you can still
|
|
1804
|
+
// cite one, but appending an entry must not mark the memory stale. Deletion still
|
|
1805
|
+
// counts as staleness; this only suppresses the noisy "linked path changed" reason.
|
|
1806
|
+
const APPEND_ONLY_LEDGER_RE = /(^|\/)(change[-_ ]?log|history)(\.[a-z0-9]+)?$/i;
|
|
1807
|
+
function isAppendOnlyLedgerPath(path) {
|
|
1808
|
+
return APPEND_ONLY_LEDGER_RE.test(normalizeRelPath(path));
|
|
1809
|
+
}
|
|
1447
1810
|
function staleMemoryReasons(projectDir, packet, fingerprintCache) {
|
|
1448
1811
|
const reasons = [];
|
|
1449
1812
|
const quality = (packet.quality ?? {});
|
|
@@ -1473,11 +1836,9 @@ function staleMemoryReasons(projectDir, packet, fingerprintCache) {
|
|
|
1473
1836
|
const storedFingerprints = packetStoredPathFingerprints(packet);
|
|
1474
1837
|
const changedPaths = storedFingerprints
|
|
1475
1838
|
.filter((fingerprint) => !isGroundingIgnored(projectDir, fingerprint.path))
|
|
1839
|
+
.filter((fingerprint) => !isAppendOnlyLedgerPath(fingerprint.path))
|
|
1476
1840
|
.filter((fingerprint) => (0, node_fs_1.existsSync)((0, node_path_1.join)(projectDir, fingerprint.path)))
|
|
1477
|
-
.filter((fingerprint) =>
|
|
1478
|
-
const current = memoryPathFingerprint(projectDir, fingerprint.path, fingerprintCache);
|
|
1479
|
-
return current !== null && current.sha256 !== fingerprint.sha256;
|
|
1480
|
-
})
|
|
1841
|
+
.filter((fingerprint) => fingerprintPathContentChanged(projectDir, fingerprint, fingerprintCache))
|
|
1481
1842
|
.map((fingerprint) => fingerprint.path);
|
|
1482
1843
|
if (changedPaths.length) {
|
|
1483
1844
|
reasons.push(`linked path changed since memory was verified: ${changedPaths.slice(0, 4).join(", ")}`);
|
|
@@ -1531,6 +1892,22 @@ function recallHardStaleReason(projectDir, packet, cache) {
|
|
|
1531
1892
|
}
|
|
1532
1893
|
return null;
|
|
1533
1894
|
}
|
|
1895
|
+
// Strict recall gate: hard-stale (recallHardStaleReason) PLUS soft-stale — a cited
|
|
1896
|
+
// file whose content changed under the memory, or partially-deleted citations.
|
|
1897
|
+
// Used to keep content-changed memory out of recall, skills, and the suppressed
|
|
1898
|
+
// report, so the agent never acts on a claim the code has moved past. NOT used by
|
|
1899
|
+
// compaction: a merely-changed packet needs reverify, not auto-deprecation.
|
|
1900
|
+
function recallStaleReason(projectDir, packet, cache) {
|
|
1901
|
+
const hard = recallHardStaleReason(projectDir, packet, cache);
|
|
1902
|
+
if (hard)
|
|
1903
|
+
return hard;
|
|
1904
|
+
// Strict recall (task #39): also withhold memory whose cited file CONTENT changed
|
|
1905
|
+
// (the fingerprint moved under it). Partial-missing citations stay served-but-
|
|
1906
|
+
// flagged — only the content-drift case is suppressed here.
|
|
1907
|
+
const changed = staleMemoryReasons(projectDir, packet, cache)
|
|
1908
|
+
.find((reason) => reason.startsWith("linked path changed since memory was verified"));
|
|
1909
|
+
return changed ?? null;
|
|
1910
|
+
}
|
|
1534
1911
|
function changedPathsFromStaleReasons(reasons) {
|
|
1535
1912
|
return unique(reasons.flatMap((reason) => {
|
|
1536
1913
|
const match = reason.match(/^linked path changed since memory was verified: (.+)$/);
|
|
@@ -5984,6 +6361,213 @@ function hydrateKnowledgeGraphArtifact(projectDir, artifact) {
|
|
|
5984
6361
|
edges: readJson(edgesPath),
|
|
5985
6362
|
};
|
|
5986
6363
|
}
|
|
6364
|
+
// --- Docs search index ---------------------------------------------------
|
|
6365
|
+
// Searchable index over the REPO's OWN committed documentation (project README,
|
|
6366
|
+
// docs/**, *.md, and common doc dirs — plus any framework/API docs that are
|
|
6367
|
+
// checked into this repo). It indexes nothing from the internet; only files
|
|
6368
|
+
// that exist on disk in this project. Recall can therefore answer from docs,
|
|
6369
|
+
// not just learned memory packets and code.
|
|
6370
|
+
const DOCS_INDEX_SCHEMA_VERSION = 1;
|
|
6371
|
+
// Heading-anchored chunks are capped so a single long section can't dominate the
|
|
6372
|
+
// BM25 length normalization or bloat the artifact.
|
|
6373
|
+
const DOCS_CHUNK_MAX_CHARS = 1600;
|
|
6374
|
+
// Directory names that commonly hold prose documentation, beyond the root README
|
|
6375
|
+
// and a top-level docs/ dir. These are matched as path segments.
|
|
6376
|
+
const DOCS_DIR_NAMES = new Set(["docs", "doc", "documentation", "guides", "guide", "wiki", "manual", "handbook"]);
|
|
6377
|
+
const DOCS_EXTENSIONS = new Set([".md", ".mdx", ".markdown", ".rst", ".txt"]);
|
|
6378
|
+
function isDocFile(relativePath) {
|
|
6379
|
+
if (shouldSkipCodePath(relativePath))
|
|
6380
|
+
return false;
|
|
6381
|
+
if (isNoisePath(relativePath))
|
|
6382
|
+
return false;
|
|
6383
|
+
const segments = relativePath.split("/");
|
|
6384
|
+
const name = segments[segments.length - 1];
|
|
6385
|
+
if (!DOCS_EXTENSIONS.has(extensionOf(name)))
|
|
6386
|
+
return false;
|
|
6387
|
+
// A markdown/rst/txt file qualifies if it is the root README, sits under a
|
|
6388
|
+
// recognised doc directory, or is any committed *.md anywhere in the tree.
|
|
6389
|
+
if (/^readme\b/i.test(name))
|
|
6390
|
+
return true;
|
|
6391
|
+
if (segments.slice(0, -1).some((part) => DOCS_DIR_NAMES.has(part.toLowerCase())))
|
|
6392
|
+
return true;
|
|
6393
|
+
const extension = extensionOf(name);
|
|
6394
|
+
return extension === ".md" || extension === ".mdx" || extension === ".markdown";
|
|
6395
|
+
}
|
|
6396
|
+
function discoverDocFiles(projectDir) {
|
|
6397
|
+
const all = walkFiles(projectDir, (absolute) => {
|
|
6398
|
+
const relativePath = (0, node_path_1.relative)(projectDir, absolute).replace(/\\/g, "/");
|
|
6399
|
+
return isDocFile(relativePath);
|
|
6400
|
+
});
|
|
6401
|
+
return all.map((absolute) => (0, node_path_1.relative)(projectDir, absolute).replace(/\\/g, "/")).sort();
|
|
6402
|
+
}
|
|
6403
|
+
function headingAnchor(heading) {
|
|
6404
|
+
return heading
|
|
6405
|
+
.toLowerCase()
|
|
6406
|
+
.replace(/[^\w\s-]/g, "")
|
|
6407
|
+
.trim()
|
|
6408
|
+
.replace(/\s+/g, "-");
|
|
6409
|
+
}
|
|
6410
|
+
// Split a doc into heading-anchored chunks. Each chunk carries the heading path
|
|
6411
|
+
// (e.g. "Setup > Install") plus the body beneath it, capped at DOCS_CHUNK_MAX_CHARS.
|
|
6412
|
+
function chunkDoc(docPath, text) {
|
|
6413
|
+
const lines = text.split(/\r?\n/);
|
|
6414
|
+
const chunks = [];
|
|
6415
|
+
const headingStack = [];
|
|
6416
|
+
let bodyLines = [];
|
|
6417
|
+
let chunkStartLine = 1;
|
|
6418
|
+
let inFence = false;
|
|
6419
|
+
const headingPath = () => headingStack.map((entry) => entry.title).join(" > ");
|
|
6420
|
+
const flush = (startLine) => {
|
|
6421
|
+
const body = bodyLines.join("\n").trim();
|
|
6422
|
+
bodyLines = [];
|
|
6423
|
+
if (!body)
|
|
6424
|
+
return;
|
|
6425
|
+
const heading = headingPath() || (0, node_path_1.basename)(docPath);
|
|
6426
|
+
for (let offset = 0; offset < body.length; offset += DOCS_CHUNK_MAX_CHARS) {
|
|
6427
|
+
const slice = body.slice(offset, offset + DOCS_CHUNK_MAX_CHARS);
|
|
6428
|
+
chunks.push({
|
|
6429
|
+
doc_path: docPath,
|
|
6430
|
+
heading,
|
|
6431
|
+
anchor: headingAnchor(headingStack[headingStack.length - 1]?.title ?? heading),
|
|
6432
|
+
text: slice,
|
|
6433
|
+
line: startLine,
|
|
6434
|
+
});
|
|
6435
|
+
}
|
|
6436
|
+
};
|
|
6437
|
+
lines.forEach((line, index) => {
|
|
6438
|
+
const fenceMatch = line.match(/^\s*(?:```|~~~)/);
|
|
6439
|
+
if (fenceMatch)
|
|
6440
|
+
inFence = !inFence;
|
|
6441
|
+
const headingMatch = inFence ? null : line.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
|
|
6442
|
+
if (headingMatch) {
|
|
6443
|
+
flush(chunkStartLine);
|
|
6444
|
+
const level = headingMatch[1].length;
|
|
6445
|
+
const title = headingMatch[2].trim();
|
|
6446
|
+
while (headingStack.length && headingStack[headingStack.length - 1].level >= level)
|
|
6447
|
+
headingStack.pop();
|
|
6448
|
+
headingStack.push({ level, title });
|
|
6449
|
+
chunkStartLine = index + 1;
|
|
6450
|
+
return;
|
|
6451
|
+
}
|
|
6452
|
+
bodyLines.push(line);
|
|
6453
|
+
});
|
|
6454
|
+
flush(chunkStartLine);
|
|
6455
|
+
return chunks;
|
|
6456
|
+
}
|
|
6457
|
+
function buildDocsIndex(projectDir) {
|
|
6458
|
+
const docFiles = discoverDocFiles(projectDir);
|
|
6459
|
+
const chunks = [];
|
|
6460
|
+
for (const docPath of docFiles) {
|
|
6461
|
+
const text = safeReadText((0, node_path_1.join)(projectDir, docPath));
|
|
6462
|
+
if (!text)
|
|
6463
|
+
continue;
|
|
6464
|
+
chunks.push(...chunkDoc(docPath, text));
|
|
6465
|
+
}
|
|
6466
|
+
const artifact = {
|
|
6467
|
+
schema_version: DOCS_INDEX_SCHEMA_VERSION,
|
|
6468
|
+
generated_at: nowIso(),
|
|
6469
|
+
source: "repo-docs",
|
|
6470
|
+
doc_count: docFiles.length,
|
|
6471
|
+
chunk_count: chunks.length,
|
|
6472
|
+
chunks,
|
|
6473
|
+
};
|
|
6474
|
+
writeJson((0, node_path_1.join)(indexesDir(projectDir), "docs-index.json"), artifact);
|
|
6475
|
+
return artifact;
|
|
6476
|
+
}
|
|
6477
|
+
function readDocsIndex(projectDir) {
|
|
6478
|
+
const path = (0, node_path_1.join)(indexesDir(projectDir), "docs-index.json");
|
|
6479
|
+
if (!(0, node_fs_1.existsSync)(path))
|
|
6480
|
+
return null;
|
|
6481
|
+
try {
|
|
6482
|
+
const artifact = readJson(path);
|
|
6483
|
+
if (!Array.isArray(artifact?.chunks))
|
|
6484
|
+
return null;
|
|
6485
|
+
return artifact;
|
|
6486
|
+
}
|
|
6487
|
+
catch {
|
|
6488
|
+
return null;
|
|
6489
|
+
}
|
|
6490
|
+
}
|
|
6491
|
+
// BM25 over doc chunks, reusing the same lexical scorer (tokenize + stemming +
|
|
6492
|
+
// IDF) as packet recall. Heading text is weighted above body, mirroring how the
|
|
6493
|
+
// packet scorer weights titles.
|
|
6494
|
+
function scoreDocsBm25(queryTerms, chunks) {
|
|
6495
|
+
const terms = expandQueryTerms(queryTerms);
|
|
6496
|
+
const result = new Map();
|
|
6497
|
+
if (!terms.length || !chunks.length)
|
|
6498
|
+
return result;
|
|
6499
|
+
const HEADING_WEIGHT = 3;
|
|
6500
|
+
const documents = chunks.map((chunk) => {
|
|
6501
|
+
const termFrequency = new Map();
|
|
6502
|
+
let length = 0;
|
|
6503
|
+
const add = (textValue, weight) => {
|
|
6504
|
+
for (const token of tokenize(textValue)) {
|
|
6505
|
+
termFrequency.set(token, (termFrequency.get(token) ?? 0) + weight);
|
|
6506
|
+
length += weight;
|
|
6507
|
+
}
|
|
6508
|
+
};
|
|
6509
|
+
add(chunk.heading, HEADING_WEIGHT);
|
|
6510
|
+
add(chunk.text, 1);
|
|
6511
|
+
return { termFrequency, length: Math.max(1, length) };
|
|
6512
|
+
});
|
|
6513
|
+
const averageLength = documents.reduce((sum, document) => sum + document.length, 0) / documents.length || 1;
|
|
6514
|
+
const documentFrequency = new Map();
|
|
6515
|
+
for (const term of terms) {
|
|
6516
|
+
documentFrequency.set(term, documents.filter((document) => document.termFrequency.has(term)).length);
|
|
6517
|
+
}
|
|
6518
|
+
documents.forEach((document, index) => {
|
|
6519
|
+
let score = 0;
|
|
6520
|
+
for (const term of terms) {
|
|
6521
|
+
const termFrequency = document.termFrequency.get(term) ?? 0;
|
|
6522
|
+
if (termFrequency <= 0)
|
|
6523
|
+
continue;
|
|
6524
|
+
const df = documentFrequency.get(term) ?? 0;
|
|
6525
|
+
const idf = Math.log(1 + (documents.length - df + 0.5) / (df + 0.5));
|
|
6526
|
+
const denominator = termFrequency + BM25_K1 * (1 - BM25_B + BM25_B * (document.length / averageLength));
|
|
6527
|
+
score += idf * ((termFrequency * (BM25_K1 + 1)) / denominator);
|
|
6528
|
+
}
|
|
6529
|
+
if (score > 0)
|
|
6530
|
+
result.set(index, Number(score.toFixed(2)));
|
|
6531
|
+
});
|
|
6532
|
+
return result;
|
|
6533
|
+
}
|
|
6534
|
+
function docsSnippet(text) {
|
|
6535
|
+
const collapsed = text.replace(/\s+/g, " ").trim();
|
|
6536
|
+
return collapsed.length > 200 ? `${collapsed.slice(0, 197)}...` : collapsed;
|
|
6537
|
+
}
|
|
6538
|
+
function searchDocs(projectDir, query, limit = 5) {
|
|
6539
|
+
// Use the persisted index when present; rebuild on the fly otherwise so the
|
|
6540
|
+
// search works even before the first refresh.
|
|
6541
|
+
const artifact = readDocsIndex(projectDir) ?? buildDocsIndex(projectDir);
|
|
6542
|
+
const chunks = artifact.chunks;
|
|
6543
|
+
const scores = scoreDocsBm25(tokenize(query), chunks);
|
|
6544
|
+
return [...scores.entries()]
|
|
6545
|
+
.sort((a, b) => b[1] - a[1] || chunks[a[0]].doc_path.localeCompare(chunks[b[0]].doc_path) || chunks[a[0]].line - chunks[b[0]].line)
|
|
6546
|
+
.slice(0, Math.max(0, limit))
|
|
6547
|
+
.map(([index, score]) => {
|
|
6548
|
+
const chunk = chunks[index];
|
|
6549
|
+
return {
|
|
6550
|
+
doc_path: chunk.doc_path,
|
|
6551
|
+
heading: chunk.heading,
|
|
6552
|
+
line: chunk.line,
|
|
6553
|
+
snippet: docsSnippet(chunk.text),
|
|
6554
|
+
score,
|
|
6555
|
+
};
|
|
6556
|
+
});
|
|
6557
|
+
}
|
|
6558
|
+
// Renders a "Docs" section appended to recall output. Honest framing: these are
|
|
6559
|
+
// the repo's own committed docs, not the internet.
|
|
6560
|
+
function docsRecallSection(projectDir, query, limit = 3) {
|
|
6561
|
+
const hits = searchDocs(projectDir, query, limit);
|
|
6562
|
+
if (!hits.length)
|
|
6563
|
+
return null;
|
|
6564
|
+
const lines = ["Docs (from this repo's own committed documentation):"];
|
|
6565
|
+
for (const hit of hits) {
|
|
6566
|
+
lines.push(`- ${hit.doc_path}:${hit.line} — ${hit.heading}`);
|
|
6567
|
+
lines.push(` ${hit.snippet}`);
|
|
6568
|
+
}
|
|
6569
|
+
return lines.join("\n");
|
|
6570
|
+
}
|
|
5987
6571
|
function buildPacketIndexes(projectDir) {
|
|
5988
6572
|
ensureMemoryDirs(projectDir);
|
|
5989
6573
|
const packets = loadPacketsFromDir(packetsDir(projectDir)).sort((a, b) => a.id.localeCompare(b.id));
|
|
@@ -6029,6 +6613,10 @@ function buildPacketIndexes(projectDir) {
|
|
|
6029
6613
|
writeJson(written[2], byTag);
|
|
6030
6614
|
writeJson(written[3], byType);
|
|
6031
6615
|
written.push(writeSparseVectorIndex(projectDir, packets));
|
|
6616
|
+
// Docs search index over the repo's own committed documentation. Built here so
|
|
6617
|
+
// it stays current through both indexProject and refreshProject.
|
|
6618
|
+
buildDocsIndex(projectDir);
|
|
6619
|
+
written.push((0, node_path_1.join)(indexesDir(projectDir), "docs-index.json"));
|
|
6032
6620
|
return written;
|
|
6033
6621
|
}
|
|
6034
6622
|
function readCurrentCodeGraph(projectDir, expectedInputHash) {
|
|
@@ -6130,6 +6718,7 @@ function currentOrBuildGraphs(projectDir) {
|
|
|
6130
6718
|
(0, node_path_1.join)(indexesDir(projectDir), "by-tag.json"),
|
|
6131
6719
|
(0, node_path_1.join)(indexesDir(projectDir), "by-type.json"),
|
|
6132
6720
|
(0, node_path_1.join)(indexesDir(projectDir), "vector-local.json"),
|
|
6721
|
+
(0, node_path_1.join)(indexesDir(projectDir), "docs-index.json"),
|
|
6133
6722
|
(0, node_path_1.join)(indexesDir(projectDir), "structural.json"),
|
|
6134
6723
|
(0, node_path_1.join)(indexesDir(projectDir), "graph.json"),
|
|
6135
6724
|
(0, node_path_1.join)(indexesDir(projectDir), "code-graph.json"),
|
|
@@ -6422,7 +7011,7 @@ function kageSuppressedMemory(projectDir) {
|
|
|
6422
7011
|
const cache = new Map();
|
|
6423
7012
|
const items = loadApprovedPackets(projectDir)
|
|
6424
7013
|
.map((packet) => {
|
|
6425
|
-
const reason =
|
|
7014
|
+
const reason = recallStaleReason(projectDir, packet, cache);
|
|
6426
7015
|
return reason ? { id: packet.id, title: packet.title, type: packet.type, reason, paths: packet.paths } : null;
|
|
6427
7016
|
})
|
|
6428
7017
|
.filter((entry) => entry !== null);
|
|
@@ -6497,7 +7086,7 @@ function compactProject(projectDir, options = {}) {
|
|
|
6497
7086
|
paths: keptPaths,
|
|
6498
7087
|
freshness: {
|
|
6499
7088
|
...(packet.freshness ?? {}),
|
|
6500
|
-
path_fingerprints: memoryPathFingerprints(projectDir, keptPaths),
|
|
7089
|
+
path_fingerprints: memoryPathFingerprints(projectDir, keptPaths, `${packet.title}\n${packet.summary}\n${packet.body}`),
|
|
6501
7090
|
last_verified_at: nowIso(),
|
|
6502
7091
|
},
|
|
6503
7092
|
updated_at: nowIso(),
|
|
@@ -7312,7 +7901,7 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
|
|
|
7312
7901
|
const approvedPackets = includeStale
|
|
7313
7902
|
? allApprovedPackets
|
|
7314
7903
|
: allApprovedPackets.filter((packet) => {
|
|
7315
|
-
const reason =
|
|
7904
|
+
const reason = recallStaleReason(projectDir, packet, staleFingerprintCache);
|
|
7316
7905
|
if (reason) {
|
|
7317
7906
|
suppressed.push({ id: packet.id, title: packet.title, reason });
|
|
7318
7907
|
return false;
|
|
@@ -7404,13 +7993,20 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
|
|
|
7404
7993
|
? [`## Structural Blast Radius (${structuralHops}-hop)`, ...blastRadius.map((path, index) => `${index + 1}. ${path}`), ""]
|
|
7405
7994
|
: []),
|
|
7406
7995
|
scored.length ? "## Relevant Memory" : "No relevant repo memory found.",
|
|
7407
|
-
...scored.flatMap((entry, index) =>
|
|
7408
|
-
|
|
7409
|
-
|
|
7410
|
-
|
|
7411
|
-
|
|
7412
|
-
|
|
7413
|
-
|
|
7996
|
+
...scored.flatMap((entry, index) => {
|
|
7997
|
+
const contradicts = (entry.packet.quality ?? {}).contradicts;
|
|
7998
|
+
const contested = Array.isArray(contradicts) && contradicts.length > 0;
|
|
7999
|
+
return [
|
|
8000
|
+
"",
|
|
8001
|
+
`${index + 1}. [${entry.packet.type} | ${entry.packet.scope} | confidence ${entry.packet.confidence.toFixed(2)}] ${entry.packet.title}`,
|
|
8002
|
+
` Summary: ${entry.packet.summary}`,
|
|
8003
|
+
` Why matched: ${entry.why_matched.join(", ") || "text relevance"}`,
|
|
8004
|
+
` Source: ${sourceLabel(entry.packet)}`,
|
|
8005
|
+
...(contested
|
|
8006
|
+
? [` ⚠ Contested: this memory contradicts ${contradicts.length} other packet(s) (${contradicts.join(", ")}). Resolve with kage conflicts / kage supersede before relying on it.`]
|
|
8007
|
+
: []),
|
|
8008
|
+
];
|
|
8009
|
+
}),
|
|
7414
8010
|
"",
|
|
7415
8011
|
pendingScored.length ? "## Working Memory (Pending Review)" : "",
|
|
7416
8012
|
...pendingScored.flatMap((entry, index) => [
|
|
@@ -7424,7 +8020,12 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
|
|
|
7424
8020
|
graphContext.edges.length ? "## Related Graph Facts" : "",
|
|
7425
8021
|
...graphContext.edges.slice(0, 5).map((edge, index) => `${index + 1}. ${edge.fact} (evidence: ${edge.evidence.join(", ")})`),
|
|
7426
8022
|
...(suppressed.length
|
|
7427
|
-
? [
|
|
8023
|
+
? [
|
|
8024
|
+
"",
|
|
8025
|
+
"## Withheld (stale — not served)",
|
|
8026
|
+
`_${suppressed.length} memory packet(s) excluded from recall because the cited code moved under them. The claim may still hold — reverify to restore, or supersede if it changed. Do not act on these as-is._`,
|
|
8027
|
+
...suppressed.slice(0, 5).map((s) => `- ${s.title} — ${s.reason} (kage reverify --packet ${s.id})`),
|
|
8028
|
+
]
|
|
7428
8029
|
: []),
|
|
7429
8030
|
...(personalEntries.length
|
|
7430
8031
|
? [
|
|
@@ -8530,7 +9131,7 @@ function kageCleanupCandidates(projectDir) {
|
|
|
8530
9131
|
summary: `${candidates.length} conservative cleanup candidate(s), ${skippedEntryPoints.length} entrypoint-like source file(s) skipped, ${skippedRuntimeReferences.length} runtime reference(s) skipped.`,
|
|
8531
9132
|
};
|
|
8532
9133
|
}
|
|
8533
|
-
const TRUTH_REPORT_MAX_FINDINGS =
|
|
9134
|
+
const TRUTH_REPORT_MAX_FINDINGS = 16;
|
|
8534
9135
|
const TRUTH_REPORT_AI_ERA_DAYS = 120;
|
|
8535
9136
|
// Symbol names too generic to mean "two teams built the same thing".
|
|
8536
9137
|
const TRUTH_COMMON_SYMBOL_NAMES = new Set([
|
|
@@ -8814,6 +9415,106 @@ function truthReport(projectDir) {
|
|
|
8814
9415
|
}
|
|
8815
9416
|
}
|
|
8816
9417
|
voidFindings.sort((a, b) => b.surprise - a.surprise || a.title.localeCompare(b.title));
|
|
9418
|
+
// 1e. Untested hot paths: central, churned source files no test exercises.
|
|
9419
|
+
// Build the set of source paths some test covers — directly (covers_path /
|
|
9420
|
+
// covers_symbol) or indirectly (a test file imports it). When the repo has no
|
|
9421
|
+
// tests at all, "untested" is the baseline, not a finding, so we skip it.
|
|
9422
|
+
const hasTests = graph.tests.length > 0 || graph.files.some((file) => file.kind === "test");
|
|
9423
|
+
const testedPaths = new Set();
|
|
9424
|
+
if (hasTests) {
|
|
9425
|
+
const symbolPathById = new Map(graph.symbols.map((symbol) => [symbol.id, symbol.path]));
|
|
9426
|
+
const symbolPathsByName = new Map();
|
|
9427
|
+
for (const symbol of graph.symbols) {
|
|
9428
|
+
const list = symbolPathsByName.get(symbol.name) ?? [];
|
|
9429
|
+
list.push(symbol.path);
|
|
9430
|
+
symbolPathsByName.set(symbol.name, list);
|
|
9431
|
+
}
|
|
9432
|
+
for (const test of graph.tests) {
|
|
9433
|
+
if (test.covers_path)
|
|
9434
|
+
testedPaths.add(test.covers_path.replace(/\\/g, "/").replace(/^\/+/, ""));
|
|
9435
|
+
if (test.covers_symbol) {
|
|
9436
|
+
const byId = symbolPathById.get(test.covers_symbol);
|
|
9437
|
+
if (byId)
|
|
9438
|
+
testedPaths.add(byId);
|
|
9439
|
+
for (const path of symbolPathsByName.get(test.covers_symbol) ?? [])
|
|
9440
|
+
testedPaths.add(path);
|
|
9441
|
+
}
|
|
9442
|
+
}
|
|
9443
|
+
for (const edge of graph.imports) {
|
|
9444
|
+
if (edge.to_path && fileByPath.get(edge.from_path)?.kind === "test")
|
|
9445
|
+
testedPaths.add(edge.to_path);
|
|
9446
|
+
}
|
|
9447
|
+
}
|
|
9448
|
+
const untestedFindings = [];
|
|
9449
|
+
if (hasTests) {
|
|
9450
|
+
for (const file of sourceFiles) {
|
|
9451
|
+
if (isEntrypointLike(file.path))
|
|
9452
|
+
continue;
|
|
9453
|
+
const fileCentrality = centrality.get(file.path) ?? 0;
|
|
9454
|
+
const commits = fileCommits.get(file.path) ?? 0;
|
|
9455
|
+
if (fileCentrality < 5)
|
|
9456
|
+
continue;
|
|
9457
|
+
if (hasGit && commits < 2)
|
|
9458
|
+
continue;
|
|
9459
|
+
if (testedPaths.has(file.path))
|
|
9460
|
+
continue;
|
|
9461
|
+
untestedFindings.push({
|
|
9462
|
+
kind: "untested_hot",
|
|
9463
|
+
title: `${file.path} — untested hot path`,
|
|
9464
|
+
detail: `${fileCentrality} other file(s)/call(s) depend on it${commits ? ` and it has changed ${commits} time(s)` : ""}, yet no test imports or directly targets it. Coverage gaps on a hub file like this are where regressions hide.`,
|
|
9465
|
+
evidence: [`${file.path}:1 centrality ${fileCentrality}, tests directly covering it: 0`],
|
|
9466
|
+
surprise: Math.min(100, 30 + Math.min(45, fileCentrality * 3) + (hasGit ? Math.min(15, commits) : 0)),
|
|
9467
|
+
});
|
|
9468
|
+
}
|
|
9469
|
+
}
|
|
9470
|
+
untestedFindings.sort((a, b) => b.surprise - a.surprise || a.title.localeCompare(b.title));
|
|
9471
|
+
// 1f. Complexity hotspots: very large source files many things depend on —
|
|
9472
|
+
// where knowledge concentrates and onboarding stalls.
|
|
9473
|
+
const complexityFindings = [];
|
|
9474
|
+
for (const file of sourceFiles) {
|
|
9475
|
+
const fileCentrality = centrality.get(file.path) ?? 0;
|
|
9476
|
+
if (file.line_count < 400)
|
|
9477
|
+
continue;
|
|
9478
|
+
if (fileCentrality < 3 && file.line_count < 800)
|
|
9479
|
+
continue;
|
|
9480
|
+
complexityFindings.push({
|
|
9481
|
+
kind: "complexity_hotspot",
|
|
9482
|
+
title: `${file.path} — ${file.line_count} lines, ${fileCentrality} dependent(s)`,
|
|
9483
|
+
detail: `A ${file.line_count}-line file that ${fileCentrality} other file(s) depend on. The biggest, most-connected files are exactly where undocumented knowledge piles up.`,
|
|
9484
|
+
evidence: [`${file.path}:1 ${file.line_count} lines, centrality ${fileCentrality}`],
|
|
9485
|
+
surprise: Math.min(100, 25 + Math.min(40, Math.round(file.line_count / 40)) + Math.min(25, fileCentrality * 2)),
|
|
9486
|
+
});
|
|
9487
|
+
}
|
|
9488
|
+
complexityFindings.sort((a, b) => b.surprise - a.surprise || a.title.localeCompare(b.title));
|
|
9489
|
+
// 1g. Known debt: TODO/FIXME/HACK/deprecation markers left in code. Each is a
|
|
9490
|
+
// decision deferred and undocumented. Scan the most-connected files first and
|
|
9491
|
+
// bound the walk so a huge repo stays fast.
|
|
9492
|
+
const debtFindings = [];
|
|
9493
|
+
const DEBT_RE = /(?:^|[^A-Za-z0-9_])(TODO|FIXME|HACK|XXX|@deprecated|@todo)(?:[^A-Za-z0-9_]|$)/gi;
|
|
9494
|
+
const debtScanTargets = [...sourceFiles]
|
|
9495
|
+
.sort((a, b) => (centrality.get(b.path) ?? 0) - (centrality.get(a.path) ?? 0))
|
|
9496
|
+
.slice(0, 400);
|
|
9497
|
+
for (const file of debtScanTargets) {
|
|
9498
|
+
const text = truthFileText(file.path);
|
|
9499
|
+
if (!text)
|
|
9500
|
+
continue;
|
|
9501
|
+
const matches = text.match(DEBT_RE);
|
|
9502
|
+
const count = matches ? matches.length : 0;
|
|
9503
|
+
if (count < 1)
|
|
9504
|
+
continue;
|
|
9505
|
+
const fileCentrality = centrality.get(file.path) ?? 0;
|
|
9506
|
+
// A lone marker in a leaf file is noise; require either repetition or reach.
|
|
9507
|
+
if (count < 2 && fileCentrality < 2)
|
|
9508
|
+
continue;
|
|
9509
|
+
debtFindings.push({
|
|
9510
|
+
kind: "debt_marker",
|
|
9511
|
+
title: `${file.path} — ${count} unresolved debt marker${count === 1 ? "" : "s"}`,
|
|
9512
|
+
detail: `TODO/FIXME/HACK/deprecation note(s) left in code${fileCentrality ? `, in a file ${fileCentrality} other(s) depend on` : ""}. Each is a decision deferred and undocumented.`,
|
|
9513
|
+
evidence: [`${file.path}:1 ${count} marker(s), centrality ${fileCentrality}`],
|
|
9514
|
+
surprise: Math.min(100, 20 + Math.min(35, count * 6) + Math.min(20, fileCentrality * 2)),
|
|
9515
|
+
});
|
|
9516
|
+
}
|
|
9517
|
+
debtFindings.sort((a, b) => b.surprise - a.surprise || a.title.localeCompare(b.title));
|
|
8817
9518
|
// 2. Doc-truth: checkable claims in README/docs vs reality.
|
|
8818
9519
|
const docLieFindings = [];
|
|
8819
9520
|
if (docLines.length) {
|
|
@@ -8900,19 +9601,29 @@ function truthReport(projectDir) {
|
|
|
8900
9601
|
docLieFindings.sort((a, b) => b.surprise - a.surprise || a.title.localeCompare(b.title));
|
|
8901
9602
|
// Cap per category so one noisy category cannot drown the report, then rank globally.
|
|
8902
9603
|
const findings = [
|
|
9604
|
+
...voidFindings.slice(0, 4),
|
|
9605
|
+
...untestedFindings.slice(0, 4),
|
|
9606
|
+
...complexityFindings.slice(0, 4),
|
|
9607
|
+
...debtFindings.slice(0, 4),
|
|
9608
|
+
...busFindings.slice(0, 4),
|
|
8903
9609
|
...duplicateFindings.slice(0, 4),
|
|
8904
9610
|
...ghostFindings.slice(0, 4),
|
|
8905
|
-
...busFindings.slice(0, 4),
|
|
8906
|
-
...voidFindings.slice(0, 4),
|
|
8907
9611
|
...docLieFindings.slice(0, 4),
|
|
8908
9612
|
].sort((a, b) => b.surprise - a.surprise || a.title.localeCompare(b.title)).slice(0, TRUTH_REPORT_MAX_FINDINGS);
|
|
8909
|
-
|
|
8910
|
-
|
|
8911
|
-
|
|
8912
|
-
|
|
8913
|
-
`${voidFindings.length} knowledge void${voidFindings.length === 1 ? "" : "s"}
|
|
8914
|
-
|
|
9613
|
+
// Lead the headline with what we actually found; categories that came back
|
|
9614
|
+
// clean are reported separately (see CLI "Clean:" line) so zeros never read
|
|
9615
|
+
// as "scan found nothing".
|
|
9616
|
+
const headlineCandidates = [
|
|
9617
|
+
[voidFindings.length, `${voidFindings.length} knowledge void${voidFindings.length === 1 ? "" : "s"}`],
|
|
9618
|
+
[untestedFindings.length, `${untestedFindings.length} untested hot path${untestedFindings.length === 1 ? "" : "s"}`],
|
|
9619
|
+
[complexityFindings.length, `${complexityFindings.length} complexity hotspot${complexityFindings.length === 1 ? "" : "s"}`],
|
|
9620
|
+
[debtFindings.length, `${debtFindings.length} debt marker file${debtFindings.length === 1 ? "" : "s"}`],
|
|
9621
|
+
[busFindings.length, `${busFindings.length} bus-factor-1 hot file${busFindings.length === 1 ? "" : "s"}`],
|
|
9622
|
+
[duplicateFindings.length, `${duplicateFindings.length} duplicate cluster${duplicateFindings.length === 1 ? "" : "s"}`],
|
|
9623
|
+
[ghostFindings.length, `${ghostFindings.length} ghost export${ghostFindings.length === 1 ? "" : "s"}`],
|
|
9624
|
+
...(docLines.length ? [[docLieFindings.length, `${docLieFindings.length} doc lie${docLieFindings.length === 1 ? "" : "s"}`]] : []),
|
|
8915
9625
|
];
|
|
9626
|
+
const headlineParts = headlineCandidates.filter(([count]) => count > 0).map(([, label]) => label);
|
|
8916
9627
|
return {
|
|
8917
9628
|
schema_version: 1,
|
|
8918
9629
|
project_dir: projectDir,
|
|
@@ -8924,6 +9635,9 @@ function truthReport(projectDir) {
|
|
|
8924
9635
|
ghost_exports: ghostFindings.length,
|
|
8925
9636
|
bus_factor_files: busFindings.length,
|
|
8926
9637
|
knowledge_voids: voidFindings.length,
|
|
9638
|
+
untested_hot_paths: untestedFindings.length,
|
|
9639
|
+
complexity_hotspots: complexityFindings.length,
|
|
9640
|
+
debt_markers: debtFindings.length,
|
|
8927
9641
|
doc_lies: docLieFindings.length,
|
|
8928
9642
|
docs_scanned: docFiles.length,
|
|
8929
9643
|
},
|
|
@@ -8937,6 +9651,118 @@ function truthReport(projectDir) {
|
|
|
8937
9651
|
],
|
|
8938
9652
|
};
|
|
8939
9653
|
}
|
|
9654
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
9655
|
+
// Shareable Truth Report scorecard. `kage scan --scorecard` turns a TruthReport
|
|
9656
|
+
// into a screenshot-able SVG (and a Markdown variant) so the 60-second scan
|
|
9657
|
+
// becomes something people post — a repo scorecard, not just terminal output.
|
|
9658
|
+
// This is the top-of-funnel artifact: a stranger runs it on any repo, gets a
|
|
9659
|
+
// shareable card, and the memory loop is what they install afterwards.
|
|
9660
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
9661
|
+
const SCORECARD_CARDS = [
|
|
9662
|
+
{ key: "knowledge_voids", label: "KNOWLEDGE VOIDS" },
|
|
9663
|
+
{ key: "untested_hot_paths", label: "UNTESTED HOT PATHS" },
|
|
9664
|
+
{ key: "complexity_hotspots", label: "COMPLEXITY HOTSPOTS" },
|
|
9665
|
+
{ key: "debt_markers", label: "KNOWN DEBT" },
|
|
9666
|
+
{ key: "bus_factor_files", label: "BUS-FACTOR-1 FILES" },
|
|
9667
|
+
{ key: "duplicate_clusters", label: "DUPLICATE IMPLS" },
|
|
9668
|
+
{ key: "ghost_exports", label: "GHOST EXPORTS" },
|
|
9669
|
+
{ key: "doc_lies", label: "DOC LIES" },
|
|
9670
|
+
];
|
|
9671
|
+
function scorecardRepoName(projectDir) {
|
|
9672
|
+
const parts = projectDir.split(/[\\/]+/).filter(Boolean);
|
|
9673
|
+
return parts.length ? parts[parts.length - 1] : projectDir;
|
|
9674
|
+
}
|
|
9675
|
+
function svgEscape(value) {
|
|
9676
|
+
return value
|
|
9677
|
+
.replace(/&/g, "&")
|
|
9678
|
+
.replace(/</g, "<")
|
|
9679
|
+
.replace(/>/g, ">")
|
|
9680
|
+
.replace(/"/g, """);
|
|
9681
|
+
}
|
|
9682
|
+
// 0 findings is good (green); a few is worth a look (amber); a pile is red.
|
|
9683
|
+
function scorecardColor(count) {
|
|
9684
|
+
if (count === 0)
|
|
9685
|
+
return "#1a7f37";
|
|
9686
|
+
if (count < 5)
|
|
9687
|
+
return "#bc4c00";
|
|
9688
|
+
return "#cf222e";
|
|
9689
|
+
}
|
|
9690
|
+
function truthScorecardSvg(report) {
|
|
9691
|
+
const repo = scorecardRepoName(report.project_dir);
|
|
9692
|
+
const totalFindings = SCORECARD_CARDS.reduce((sum, card) => sum + (report.totals[card.key] || 0), 0);
|
|
9693
|
+
const width = 820;
|
|
9694
|
+
const pad = 24;
|
|
9695
|
+
const gap = 16;
|
|
9696
|
+
const cols = 4;
|
|
9697
|
+
const cardW = Math.round((width - pad * 2 - gap * (cols - 1)) / cols);
|
|
9698
|
+
const cardH = 96;
|
|
9699
|
+
const gridTop = 104;
|
|
9700
|
+
const rows = Math.ceil(SCORECARD_CARDS.length / cols);
|
|
9701
|
+
const footerTop = gridTop + rows * cardH + (rows - 1) * gap + 20;
|
|
9702
|
+
const height = footerTop + 56;
|
|
9703
|
+
const subtitle = `${repo} · ${report.totals.files_scanned} files · ${report.totals.symbols_scanned} symbols scanned`;
|
|
9704
|
+
const headline = report.headline
|
|
9705
|
+
? report.headline.length > 92
|
|
9706
|
+
? `${report.headline.slice(0, 89)}…`
|
|
9707
|
+
: report.headline
|
|
9708
|
+
: totalFindings === 0
|
|
9709
|
+
? "No surprising findings — this repo's knowledge is well distributed."
|
|
9710
|
+
: `${totalFindings} knowledge risk${totalFindings === 1 ? "" : "s"} surfaced`;
|
|
9711
|
+
const cards = SCORECARD_CARDS.map((card, i) => {
|
|
9712
|
+
const count = report.totals[card.key] || 0;
|
|
9713
|
+
const x = pad + (i % cols) * (cardW + gap);
|
|
9714
|
+
const y = gridTop + Math.floor(i / cols) * (cardH + gap);
|
|
9715
|
+
const cx = x + cardW / 2;
|
|
9716
|
+
return [
|
|
9717
|
+
` <g>`,
|
|
9718
|
+
` <rect x="${x}" y="${y}" width="${cardW}" height="${cardH}" rx="12" fill="#ffffff" stroke="#d0d7de" stroke-width="1"/>`,
|
|
9719
|
+
` <text x="${cx}" y="${y + 50}" text-anchor="middle" font-size="34" font-weight="700" fill="${scorecardColor(count)}">${count}</text>`,
|
|
9720
|
+
` <text x="${cx}" y="${y + 76}" text-anchor="middle" font-size="10.5" font-weight="600" letter-spacing="0.8" fill="#57606a">${svgEscape(card.label)}</text>`,
|
|
9721
|
+
` </g>`,
|
|
9722
|
+
].join("\n");
|
|
9723
|
+
}).join("\n");
|
|
9724
|
+
return [
|
|
9725
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif">`,
|
|
9726
|
+
` <rect x="0" y="0" width="${width}" height="${height}" rx="16" fill="#0d1117"/>`,
|
|
9727
|
+
` <text x="${pad}" y="46" font-size="22" font-weight="700" fill="#41ff8f">Kage Truth Report</text>`,
|
|
9728
|
+
` <text x="${pad}" y="72" font-size="13" fill="#8b949e">${svgEscape(subtitle)}</text>`,
|
|
9729
|
+
` <text x="${pad}" y="${gridTop - 14}" font-size="13" font-weight="600" fill="#c9d1d9">${svgEscape(headline)}</text>`,
|
|
9730
|
+
cards,
|
|
9731
|
+
` <text x="${pad}" y="${footerTop + 22}" font-size="12.5" fill="#8b949e">Run it on your repo:</text>`,
|
|
9732
|
+
` <text x="${pad + 130}" y="${footerTop + 22}" font-size="12.5" font-weight="600" fill="#41ff8f">npx -y @kage-core/kage-graph-mcp install</text>`,
|
|
9733
|
+
` <text x="${width - pad}" y="${footerTop + 22}" text-anchor="end" font-size="12.5" fill="#57606a">kage-core.com</text>`,
|
|
9734
|
+
`</svg>`,
|
|
9735
|
+
].join("\n");
|
|
9736
|
+
}
|
|
9737
|
+
function truthScorecardMarkdown(report) {
|
|
9738
|
+
const repo = scorecardRepoName(report.project_dir);
|
|
9739
|
+
const totalFindings = SCORECARD_CARDS.reduce((sum, card) => sum + (report.totals[card.key] || 0), 0);
|
|
9740
|
+
const rows = SCORECARD_CARDS.map((card) => {
|
|
9741
|
+
const count = report.totals[card.key] || 0;
|
|
9742
|
+
const mark = count === 0 ? "✅" : count < 5 ? "⚠️" : "🔴";
|
|
9743
|
+
const label = card.label.toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase()).replace(/\bImpls\b/, "Implementations");
|
|
9744
|
+
return `| ${label} | ${count} ${mark} |`;
|
|
9745
|
+
}).join("\n");
|
|
9746
|
+
const headline = report.headline || (totalFindings === 0
|
|
9747
|
+
? "No surprising findings — this repo's knowledge is well distributed."
|
|
9748
|
+
: `${totalFindings} knowledge risk${totalFindings === 1 ? "" : "s"} surfaced`);
|
|
9749
|
+
return [
|
|
9750
|
+
`## Kage Truth Report — ${repo}`,
|
|
9751
|
+
``,
|
|
9752
|
+
`Scanned ${report.totals.files_scanned} files, ${report.totals.symbols_scanned} symbols.`,
|
|
9753
|
+
``,
|
|
9754
|
+
`> ${headline}`,
|
|
9755
|
+
``,
|
|
9756
|
+
`| Signal | Count |`,
|
|
9757
|
+
`| --- | --- |`,
|
|
9758
|
+
rows,
|
|
9759
|
+
``,
|
|
9760
|
+
`Each signal is a place an agent loses time re-learning what your team already knows — [what these mean](https://github.com/kage-core/Kage/blob/master/docs/scorecard-metrics.md).`,
|
|
9761
|
+
``,
|
|
9762
|
+
`Run it on your repo: \`npx -y @kage-core/kage-graph-mcp install\` · [kage-core.com](https://kage-core.com)`,
|
|
9763
|
+
``,
|
|
9764
|
+
].join("\n");
|
|
9765
|
+
}
|
|
8940
9766
|
function defaultClaudeMemStorePath() {
|
|
8941
9767
|
const dataDir = process.env.CLAUDE_MEM_DATA_DIR || (0, node_path_1.join)((0, node_os_1.homedir)(), ".claude-mem");
|
|
8942
9768
|
return (0, node_path_1.join)(dataDir, "claude-mem.db");
|
|
@@ -12521,6 +13347,7 @@ function learn(input) {
|
|
|
12521
13347
|
strictCitations: input.strictCitations,
|
|
12522
13348
|
graphNodes: input.graphNodes,
|
|
12523
13349
|
pendingReview: input.pendingReview,
|
|
13350
|
+
strictContradictions: input.strictContradictions,
|
|
12524
13351
|
discoveryTokens: input.discoveryTokens,
|
|
12525
13352
|
});
|
|
12526
13353
|
}
|
|
@@ -12602,7 +13429,7 @@ function capture(input) {
|
|
|
12602
13429
|
freshness: {
|
|
12603
13430
|
ttl_days: 365,
|
|
12604
13431
|
last_verified_at: createdAt,
|
|
12605
|
-
path_fingerprints: memoryPathFingerprints(input.projectDir, groundedPaths),
|
|
13432
|
+
path_fingerprints: memoryPathFingerprints(input.projectDir, groundedPaths, `${input.title}\n${input.summary ?? ""}\n${input.body}`),
|
|
12606
13433
|
path_fingerprint_policy: "source_hash_staleness",
|
|
12607
13434
|
verification: "repo_local_agent_capture",
|
|
12608
13435
|
},
|
|
@@ -12630,9 +13457,26 @@ function capture(input) {
|
|
|
12630
13457
|
const validation = validatePacket(packet);
|
|
12631
13458
|
if (!validation.ok)
|
|
12632
13459
|
return { ok: false, errors: validation.errors, warnings };
|
|
13460
|
+
// Memory-vs-memory contradiction detection: surface (and optionally refuse)
|
|
13461
|
+
// a capture that directly opposes an existing approved packet on a shared
|
|
13462
|
+
// path, instead of silently storing two conflicting facts.
|
|
13463
|
+
const contradictions = detectContradictions(input.projectDir, packet);
|
|
13464
|
+
if (contradictions.length && input.strictContradictions) {
|
|
13465
|
+
return {
|
|
13466
|
+
ok: false,
|
|
13467
|
+
errors: [
|
|
13468
|
+
`Contradiction blocked: this memory contradicts ${contradictions.length} existing approved ` +
|
|
13469
|
+
`packet(s): ${contradictions.map((c) => `${c.packet_id} (${c.title})`).join("; ")}. ` +
|
|
13470
|
+
`Resolve with kage supersede --packet <old> --replacement <new>, or drop --strict-contradictions to keep both.`,
|
|
13471
|
+
],
|
|
13472
|
+
warnings,
|
|
13473
|
+
contradictions,
|
|
13474
|
+
};
|
|
13475
|
+
}
|
|
12633
13476
|
packet.quality = {
|
|
12634
13477
|
...packet.quality,
|
|
12635
13478
|
...evaluateMemoryQuality(input.projectDir, packet),
|
|
13479
|
+
...(contradictions.length ? { contradicts: contradictions.map((c) => c.packet_id) } : {}),
|
|
12636
13480
|
};
|
|
12637
13481
|
const path = writePacket(input.projectDir, packet, input.pendingReview ? "pending" : "packets");
|
|
12638
13482
|
recordMemoryAudit(input.projectDir, "capture", [packet], {
|
|
@@ -12641,7 +13485,7 @@ function capture(input) {
|
|
|
12641
13485
|
path: (0, node_path_1.relative)(input.projectDir, path),
|
|
12642
13486
|
source_kind: packet.source_refs[0]?.kind ?? "explicit_capture",
|
|
12643
13487
|
});
|
|
12644
|
-
return { ok: true, packet, path, errors: [], warnings };
|
|
13488
|
+
return { ok: true, packet, path, errors: [], warnings, ...(contradictions.length ? { contradictions } : {}) };
|
|
12645
13489
|
}
|
|
12646
13490
|
function createPublicCandidate(projectDir, id) {
|
|
12647
13491
|
ensureMemoryDirs(projectDir);
|
|
@@ -12884,6 +13728,8 @@ PAYLOAD="$(cat || true)"
|
|
|
12884
13728
|
CWD="$(printf "%s" "$PAYLOAD" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('cwd',''))" 2>/dev/null || echo "")"
|
|
12885
13729
|
|
|
12886
13730
|
[[ -d "$CWD/.agent_memory" ]] || exit 0
|
|
13731
|
+
# Resolve a repo-local install too, so hooks work without a global kage on PATH.
|
|
13732
|
+
export PATH="$CWD/node_modules/.bin:$PATH"
|
|
12887
13733
|
command -v kage >/dev/null 2>&1 || exit 0
|
|
12888
13734
|
|
|
12889
13735
|
if git -C "$CWD" status --porcelain -uall >/dev/null 2>&1 && [[ -n "$(git -C "$CWD" status --porcelain -uall)" ]]; then
|
|
@@ -12940,6 +13786,8 @@ print(d.get("cwd") or os.environ.get("CLAUDE_PROJECT_DIR") or "")
|
|
|
12940
13786
|
' 2>/dev/null || echo "")"
|
|
12941
13787
|
|
|
12942
13788
|
[[ -d "$CWD/.agent_memory" ]] || exit 0
|
|
13789
|
+
# Resolve a repo-local install too, so hooks work without a global kage on PATH.
|
|
13790
|
+
export PATH="$CWD/node_modules/.bin:$PATH"
|
|
12943
13791
|
command -v kage >/dev/null 2>&1 || exit 0
|
|
12944
13792
|
|
|
12945
13793
|
EVENT="$(PAYLOAD="$PAYLOAD" python3 -c 'import json, os
|
|
@@ -13066,6 +13914,8 @@ print(d.get("cwd") or os.environ.get("CLAUDE_PROJECT_DIR") or "")
|
|
|
13066
13914
|
' 2>/dev/null || echo "")"
|
|
13067
13915
|
|
|
13068
13916
|
[[ -d "$CWD/.agent_memory" ]] || exit 0
|
|
13917
|
+
# Resolve a repo-local install too, so hooks work without a global kage on PATH.
|
|
13918
|
+
export PATH="$CWD/node_modules/.bin:$PATH"
|
|
13069
13919
|
command -v kage >/dev/null 2>&1 || exit 0
|
|
13070
13920
|
|
|
13071
13921
|
FILE_PATH="$(PAYLOAD="$PAYLOAD" python3 -c 'import json, os
|
|
@@ -13115,6 +13965,15 @@ fi
|
|
|
13115
13965
|
|
|
13116
13966
|
exit 0
|
|
13117
13967
|
`;
|
|
13968
|
+
// PreToolUse(Edit/Write) — the enforcement counterpart to the Read hook. Before
|
|
13969
|
+
// the agent MODIFIES a file, inject the verified memory about it (and surface what
|
|
13970
|
+
// Kage is withholding as stale), so "recall before you edit" is not optional. CLI
|
|
13971
|
+
// only (kage file-context), so it works even when the MCP server is not loaded.
|
|
13972
|
+
const editContextHookScript = readContextHookScript
|
|
13973
|
+
.replace("PreToolUse(Read) hook — injects verified file-linked memory right before the agent reads a file.", "PreToolUse(Edit/Write) hook — injects verified file-linked memory right before the agent edits a file, so recall precedes every change.")
|
|
13974
|
+
.replace("Never blocks the Read.", "Never blocks the edit.")
|
|
13975
|
+
.replace('STATE_DIR="/tmp/kage-read-context"', 'STATE_DIR="/tmp/kage-edit-context"')
|
|
13976
|
+
.replace("never block the Read.", "never block the edit.");
|
|
13118
13977
|
const settingsPath = (0, node_path_1.join)(home, ".claude", "settings.json");
|
|
13119
13978
|
const hookEntry = {
|
|
13120
13979
|
hooks: {
|
|
@@ -13124,6 +13983,9 @@ exit 0
|
|
|
13124
13983
|
{ matcher: "", hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/observe.sh", timeout: 5 }] },
|
|
13125
13984
|
// Verified memory at the moment of relevance: short timeout, never blocks the Read.
|
|
13126
13985
|
{ matcher: "Read", hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/kage-read-context.sh", timeout: 6 }] },
|
|
13986
|
+
// Enforcement: recall before an edit. Injects verified memory + withheld-stale
|
|
13987
|
+
// for the file the agent is about to change. Never blocks the edit.
|
|
13988
|
+
{ matcher: "Edit|Write|MultiEdit", hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/kage-edit-context.sh", timeout: 6 }] },
|
|
13127
13989
|
],
|
|
13128
13990
|
PostToolUse: [{ matcher: "", hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/observe.sh", timeout: 5 }] }],
|
|
13129
13991
|
PostToolUseFailure: [{ matcher: "", hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/observe.sh", timeout: 5 }] }],
|
|
@@ -13136,7 +13998,7 @@ exit 0
|
|
|
13136
13998
|
setSnippet(path, JSON.stringify({ mcpServers: { kage: server } }, null, 2), [
|
|
13137
13999
|
"Add the MCP server to ~/.claude.json, then restart Claude Code.",
|
|
13138
14000
|
"alwaysLoad: true makes Kage tools immediately visible without requiring ToolSearch.",
|
|
13139
|
-
`Also create ${hookDir}/session-start.sh, observe.sh, kage-read-context.sh, and stop.sh with the hook scripts and add SessionStart/UserPromptSubmit/PreToolUse/PostToolUse/PostToolUseFailure/PreCompact/Stop/SessionEnd hooks to ~/.claude/settings.json.`,
|
|
14001
|
+
`Also create ${hookDir}/session-start.sh, observe.sh, kage-read-context.sh, kage-edit-context.sh, and stop.sh with the hook scripts and add SessionStart/UserPromptSubmit/PreToolUse/PostToolUse/PostToolUseFailure/PreCompact/Stop/SessionEnd hooks to ~/.claude/settings.json.`,
|
|
13140
14002
|
"Run `kage init --project <repo>` inside each repo to install the ambient memory policy.",
|
|
13141
14003
|
], true);
|
|
13142
14004
|
if (options.write) {
|
|
@@ -13146,6 +14008,7 @@ exit 0
|
|
|
13146
14008
|
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(hookDir, "session-start.sh"), hookScript, { mode: 0o755 });
|
|
13147
14009
|
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(hookDir, "observe.sh"), observeHookScript, { mode: 0o755 });
|
|
13148
14010
|
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(hookDir, "kage-read-context.sh"), readContextHookScript, { mode: 0o755 });
|
|
14011
|
+
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(hookDir, "kage-edit-context.sh"), editContextHookScript, { mode: 0o755 });
|
|
13149
14012
|
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(hookDir, "stop.sh"), stopHookScript, { mode: 0o755 });
|
|
13150
14013
|
upsertJsonSettings(settingsPath, hookEntry);
|
|
13151
14014
|
result.wrote = true;
|
|
@@ -13175,6 +14038,9 @@ exit 0
|
|
|
13175
14038
|
"roo-code": (0, node_path_1.join)(home, ".roo", "mcp_settings.json"),
|
|
13176
14039
|
"kilo-code": (0, node_path_1.join)(home, ".kilo", "mcp_settings.json"),
|
|
13177
14040
|
"claude-desktop": (0, node_path_1.join)(home, ".config", "claude", "claude_desktop_config.json"),
|
|
14041
|
+
openclaw: (0, node_path_1.join)(home, ".openclaw", "mcp.json"),
|
|
14042
|
+
copilot: (0, node_path_1.join)(home, ".config", "github-copilot", "mcp.json"),
|
|
14043
|
+
hermes: (0, node_path_1.join)(home, ".hermes", "mcp.json"),
|
|
13178
14044
|
"generic-mcp": "",
|
|
13179
14045
|
};
|
|
13180
14046
|
setSnippet(paths[agent] || null, universal, [`Merge this MCP stdio config into ${agent}'s MCP settings.`, "Restart the agent after updating config."]);
|
|
@@ -14632,7 +15498,9 @@ function staleCatch(projectDir, changedFiles) {
|
|
|
14632
15498
|
if (current === null) {
|
|
14633
15499
|
invalidated.push({ packet_id: packet.id, packet_title: packet.title, cited_path: stored.path, reason: "cited file was deleted" });
|
|
14634
15500
|
}
|
|
14635
|
-
else if (
|
|
15501
|
+
else if (fingerprintPathContentChanged(projectDir, stored, fpCache)) {
|
|
15502
|
+
// Anchored memories are only invalidated when the symbols they cite change,
|
|
15503
|
+
// not when something unrelated in the same file moves.
|
|
14636
15504
|
invalidated.push({ packet_id: packet.id, packet_title: packet.title, cited_path: stored.path, reason: "content changed since this memory was verified" });
|
|
14637
15505
|
}
|
|
14638
15506
|
}
|
|
@@ -14705,6 +15573,12 @@ function prCheck(projectDir) {
|
|
|
14705
15573
|
if (reconciliation.unresolved_count > 0) {
|
|
14706
15574
|
warnings.push(`${reconciliation.unresolved_count} memory reconciliation item(s) may need update after recent code changes (review on handoff; not blocking).`);
|
|
14707
15575
|
}
|
|
15576
|
+
// Surface unresolved memory-vs-memory contradictions (not a hard fail —
|
|
15577
|
+
// resolving them is an agent/human decision via supersede or keep-both).
|
|
15578
|
+
const conflicts = kageConflicts(projectDir);
|
|
15579
|
+
if (conflicts.count > 0) {
|
|
15580
|
+
warnings.push(`${conflicts.count} memory contradiction pair(s) unresolved — run kage conflicts, then kage supersede the wrong one (not blocking).`);
|
|
15581
|
+
}
|
|
14708
15582
|
if (!codeGraphCurrent || !memoryGraphCurrent) {
|
|
14709
15583
|
errors.push("Generated graph artifacts are missing or not current for this working tree content.");
|
|
14710
15584
|
requiredActions.push("Run kage refresh --project <dir> before merge.");
|
|
@@ -15733,6 +16607,141 @@ function packetSupersessionReason(packet) {
|
|
|
15733
16607
|
const evidence = edge ? packetEdgeValue(edge, "evidence") : "";
|
|
15734
16608
|
return evidence || "This memory was superseded by newer repo knowledge.";
|
|
15735
16609
|
}
|
|
16610
|
+
// The whole value of generated skills is that the team shares them via git. If
|
|
16611
|
+
// the output dir is git-ignored, they silently won't be — so we surface it.
|
|
16612
|
+
function pathIsGitIgnored(projectDir, relPath) {
|
|
16613
|
+
try {
|
|
16614
|
+
(0, node_child_process_1.execFileSync)("git", ["-C", projectDir, "check-ignore", "-q", relPath], { stdio: "ignore" });
|
|
16615
|
+
return true; // exit 0 = path is ignored
|
|
16616
|
+
}
|
|
16617
|
+
catch {
|
|
16618
|
+
return false; // non-zero = not ignored (or not a git repo)
|
|
16619
|
+
}
|
|
16620
|
+
}
|
|
16621
|
+
// A SKILL.md is a reusable *procedure*. Runbooks and workflows are procedures by
|
|
16622
|
+
// nature; rationale-only decisions and per-diff change-memory are not skills.
|
|
16623
|
+
const SKILL_ELIGIBLE_TYPES = ["runbook", "workflow"];
|
|
16624
|
+
// Auto-distilled junk sometimes lands a raw hook/tool payload as a "runbook".
|
|
16625
|
+
// Those must never become skills the agent loads as instructions.
|
|
16626
|
+
const SKILL_PAYLOAD_MARKERS = [
|
|
16627
|
+
"task-notification", "tool-use-id", "toolu_", "hookspecificoutput",
|
|
16628
|
+
"nooutputexpected", "isimage", "stop_hook_active", '"interrupted"', "interrupted:",
|
|
16629
|
+
];
|
|
16630
|
+
function looksLikePayloadMemory(packet) {
|
|
16631
|
+
const haystack = `${packet.title} ${packet.summary} ${packet.body}`.toLowerCase();
|
|
16632
|
+
return SKILL_PAYLOAD_MARKERS.some((marker) => haystack.includes(marker));
|
|
16633
|
+
}
|
|
16634
|
+
function packetSkillBody(packet) {
|
|
16635
|
+
const ctx = packet.context ?? {};
|
|
16636
|
+
const lines = [`# ${packet.title}`, ""];
|
|
16637
|
+
if (packet.summary)
|
|
16638
|
+
lines.push(packet.summary, "");
|
|
16639
|
+
if (ctx.fact && ctx.fact !== packet.summary)
|
|
16640
|
+
lines.push(ctx.fact, "");
|
|
16641
|
+
if (ctx.why)
|
|
16642
|
+
lines.push(`**Why it matters:** ${ctx.why}`, "");
|
|
16643
|
+
if (ctx.trigger)
|
|
16644
|
+
lines.push(`**Use this when:** ${ctx.trigger}`, "");
|
|
16645
|
+
if (ctx.action)
|
|
16646
|
+
lines.push("## What to do", "", ctx.action, "");
|
|
16647
|
+
else if (packet.body && packet.body !== packet.summary)
|
|
16648
|
+
lines.push("## Detail", "", packet.body.trim(), "");
|
|
16649
|
+
if (ctx.verification)
|
|
16650
|
+
lines.push("## Verify", "", ctx.verification, "");
|
|
16651
|
+
if (ctx.risk_if_forgotten)
|
|
16652
|
+
lines.push(`**If you skip this:** ${ctx.risk_if_forgotten}`, "");
|
|
16653
|
+
const citedPaths = (packet.paths ?? []).filter((p) => meaningfulMemoryPath(p));
|
|
16654
|
+
if (citedPaths.length) {
|
|
16655
|
+
lines.push("## Grounded in", "");
|
|
16656
|
+
for (const p of citedPaths.slice(0, 12))
|
|
16657
|
+
lines.push(`- \`${p}\``);
|
|
16658
|
+
lines.push("");
|
|
16659
|
+
}
|
|
16660
|
+
lines.push("---", `_Generated by \`kage skills\` from verified repo memory (packet \`${packet.id}\`). Checked against the code it cites; regenerate with \`kage skills\`._`);
|
|
16661
|
+
return lines.join("\n");
|
|
16662
|
+
}
|
|
16663
|
+
function skillDescription(packet) {
|
|
16664
|
+
const ctx = packet.context ?? {};
|
|
16665
|
+
const base = (packet.summary || ctx.fact || packet.title).replace(/\s+/g, " ").trim();
|
|
16666
|
+
const when = ctx.trigger ? ` Use when: ${ctx.trigger.replace(/\s+/g, " ").trim()}` : "";
|
|
16667
|
+
const full = `${base}${when}`;
|
|
16668
|
+
return full.length > 480 ? `${full.slice(0, 477)}...` : full;
|
|
16669
|
+
}
|
|
16670
|
+
// A packet earns a skill only if it is a genuine, self-contained procedure:
|
|
16671
|
+
// right type, not a per-diff change-memory packet, not a leaked payload, and
|
|
16672
|
+
// carrying an actual action or a substantive body.
|
|
16673
|
+
function isSkillWorthy(packet) {
|
|
16674
|
+
if (!SKILL_ELIGIBLE_TYPES.includes(packet.type))
|
|
16675
|
+
return false;
|
|
16676
|
+
if (isGeneratedChangeMemory(packet))
|
|
16677
|
+
return false;
|
|
16678
|
+
if (looksLikePayloadMemory(packet))
|
|
16679
|
+
return false;
|
|
16680
|
+
const ctx = packet.context ?? {};
|
|
16681
|
+
const hasProcedure = Boolean(ctx.action) || (packet.body ?? "").trim().length >= 120;
|
|
16682
|
+
return hasProcedure;
|
|
16683
|
+
}
|
|
16684
|
+
function generateSkills(projectDir, options = {}) {
|
|
16685
|
+
ensureMemoryDirs(projectDir);
|
|
16686
|
+
const dryRun = options.dryRun === true;
|
|
16687
|
+
const relDir = options.dir ?? (0, node_path_1.join)(".claude", "skills");
|
|
16688
|
+
const result = {
|
|
16689
|
+
ok: true,
|
|
16690
|
+
project_dir: projectDir,
|
|
16691
|
+
dir: relDir,
|
|
16692
|
+
dry_run: dryRun,
|
|
16693
|
+
generated: [],
|
|
16694
|
+
skipped: [],
|
|
16695
|
+
total_eligible: 0,
|
|
16696
|
+
git_ignored: pathIsGitIgnored(projectDir, relDir),
|
|
16697
|
+
errors: [],
|
|
16698
|
+
};
|
|
16699
|
+
const cache = new Map();
|
|
16700
|
+
const candidates = loadApprovedPackets(projectDir)
|
|
16701
|
+
.filter((packet) => SKILL_ELIGIBLE_TYPES.includes(packet.type))
|
|
16702
|
+
.sort((a, b) => a.title.localeCompare(b.title));
|
|
16703
|
+
const usedSlugs = new Set();
|
|
16704
|
+
for (const packet of candidates) {
|
|
16705
|
+
if (!isSkillWorthy(packet)) {
|
|
16706
|
+
result.skipped.push({ title: packet.title, reason: "no actionable procedure (rationale-only)" });
|
|
16707
|
+
continue;
|
|
16708
|
+
}
|
|
16709
|
+
const staleReason = recallStaleReason(projectDir, packet, cache);
|
|
16710
|
+
if (staleReason) {
|
|
16711
|
+
result.skipped.push({ title: packet.title, reason: `not grounded: ${staleReason}` });
|
|
16712
|
+
continue;
|
|
16713
|
+
}
|
|
16714
|
+
result.total_eligible += 1;
|
|
16715
|
+
let slug = slugify(packet.title).slice(0, 60).replace(/-+$/, "") || `skill-${result.total_eligible}`;
|
|
16716
|
+
while (usedSlugs.has(slug))
|
|
16717
|
+
slug = `${slug}-2`;
|
|
16718
|
+
usedSlugs.add(slug);
|
|
16719
|
+
const skillDir = (0, node_path_1.join)(projectDir, relDir, slug);
|
|
16720
|
+
const skillPath = (0, node_path_1.join)(skillDir, "SKILL.md");
|
|
16721
|
+
const content = [
|
|
16722
|
+
"---",
|
|
16723
|
+
`name: ${slug}`,
|
|
16724
|
+
`description: ${skillDescription(packet).replace(/\n/g, " ")}`,
|
|
16725
|
+
"---",
|
|
16726
|
+
"",
|
|
16727
|
+
packetSkillBody(packet),
|
|
16728
|
+
"",
|
|
16729
|
+
].join("\n");
|
|
16730
|
+
if (!dryRun) {
|
|
16731
|
+
try {
|
|
16732
|
+
(0, node_fs_1.mkdirSync)(skillDir, { recursive: true });
|
|
16733
|
+
(0, node_fs_1.writeFileSync)(skillPath, content, "utf8");
|
|
16734
|
+
}
|
|
16735
|
+
catch (error) {
|
|
16736
|
+
result.errors.push(`failed to write ${slug}: ${error.message}`);
|
|
16737
|
+
continue;
|
|
16738
|
+
}
|
|
16739
|
+
}
|
|
16740
|
+
result.generated.push({ slug, title: packet.title, path: (0, node_path_1.join)(relDir, slug, "SKILL.md"), packet_id: packet.id, type: packet.type });
|
|
16741
|
+
}
|
|
16742
|
+
result.ok = result.errors.length === 0;
|
|
16743
|
+
return result;
|
|
16744
|
+
}
|
|
15736
16745
|
// Re-verify a still-true packet in place: re-check cited paths, refresh
|
|
15737
16746
|
// fingerprints and last_verified_at, and clear stale flags. The alternative to
|
|
15738
16747
|
// supersede churn when code changed but the memory's claim did not. Refuses
|
|
@@ -15770,7 +16779,7 @@ function reverifyMemory(projectDir, packetId) {
|
|
|
15770
16779
|
const presentPaths = citedPaths.filter((path) => !result.missing_paths.includes(path));
|
|
15771
16780
|
const now = nowIso();
|
|
15772
16781
|
const freshness = { ...(packet.freshness ?? {}) };
|
|
15773
|
-
freshness.path_fingerprints = memoryPathFingerprints(projectDir, presentPaths);
|
|
16782
|
+
freshness.path_fingerprints = memoryPathFingerprints(projectDir, presentPaths, `${packet.title}\n${packet.summary}\n${packet.body}`);
|
|
15774
16783
|
freshness.last_verified_at = now;
|
|
15775
16784
|
const { stale: _stale, stale_reasons: _staleReasons, suggested_action: _suggestedAction, ...nextQuality } = quality;
|
|
15776
16785
|
writeJson(entry.path, {
|
|
@@ -15840,8 +16849,35 @@ function supersedeMemory(projectDir, oldPacketId, replacementPacketId, reason =
|
|
|
15840
16849
|
upsertPacketEdge(oldPacket, "superseded_by", replacementPacket.id, trimmedReason, at);
|
|
15841
16850
|
replacementPacket.updated_at = at;
|
|
15842
16851
|
upsertPacketEdge(replacementPacket, "supersedes", oldPacket.id, trimmedReason, at);
|
|
16852
|
+
// Superseding resolves any recorded contradiction involving the retired
|
|
16853
|
+
// packet: drop the old id from every other packet's quality.contradicts, and
|
|
16854
|
+
// clear the old packet's own contradicts list (it is no longer live memory).
|
|
16855
|
+
const clearContradiction = (packet) => {
|
|
16856
|
+
const quality = (packet.quality ?? {});
|
|
16857
|
+
const existing = Array.isArray(quality.contradicts) ? quality.contradicts : [];
|
|
16858
|
+
if (!existing.length)
|
|
16859
|
+
return false;
|
|
16860
|
+
const next = existing.filter((id) => id !== oldPacket.id && id !== replacementPacket.id);
|
|
16861
|
+
if (next.length === existing.length)
|
|
16862
|
+
return false;
|
|
16863
|
+
if (next.length)
|
|
16864
|
+
quality.contradicts = next;
|
|
16865
|
+
else
|
|
16866
|
+
delete quality.contradicts;
|
|
16867
|
+
packet.quality = quality;
|
|
16868
|
+
packet.updated_at = at;
|
|
16869
|
+
return true;
|
|
16870
|
+
};
|
|
16871
|
+
clearContradiction(oldPacket);
|
|
16872
|
+
clearContradiction(replacementPacket);
|
|
15843
16873
|
writeJson(oldEntry.path, oldPacket);
|
|
15844
16874
|
writeJson(replacementEntry.path, replacementPacket);
|
|
16875
|
+
for (const entry of entries) {
|
|
16876
|
+
if (entry.packet.id === oldPacket.id || entry.packet.id === replacementPacket.id)
|
|
16877
|
+
continue;
|
|
16878
|
+
if (clearContradiction(entry.packet))
|
|
16879
|
+
writeJson(entry.path, entry.packet);
|
|
16880
|
+
}
|
|
15845
16881
|
recordMemoryAudit(projectDir, "supersede", [oldPacket, replacementPacket], {
|
|
15846
16882
|
old_packet_id: oldPacket.id,
|
|
15847
16883
|
replacement_packet_id: replacementPacket.id,
|
|
@@ -16155,7 +17191,7 @@ function personalRecallEntries(projectDir, terms, limit = 3) {
|
|
|
16155
17191
|
if (!packets.length)
|
|
16156
17192
|
return [];
|
|
16157
17193
|
const cache = new Map();
|
|
16158
|
-
const eligible = packets.filter((packet) =>
|
|
17194
|
+
const eligible = packets.filter((packet) => recallStaleReason(projectDir, packet, cache) === null);
|
|
16159
17195
|
if (!eligible.length)
|
|
16160
17196
|
return [];
|
|
16161
17197
|
const scores = scorePacketsBm25(terms, eligible);
|
|
@@ -16507,3 +17543,36 @@ function syncPersonal() {
|
|
|
16507
17543
|
result.ok = true;
|
|
16508
17544
|
return result;
|
|
16509
17545
|
}
|
|
17546
|
+
// Kage's memory is hierarchical by construction:
|
|
17547
|
+
// L0 raw — session observations captured by hooks (unreviewed signal)
|
|
17548
|
+
// L1 reviewed — verified memory packets (the trusted store)
|
|
17549
|
+
// L2 synthesis — generated overviews: repo maps, project profile, branch/change summaries
|
|
17550
|
+
// This surfaces that existing structure rather than inventing a field.
|
|
17551
|
+
function kageLayers(projectDir) {
|
|
17552
|
+
const L2_TYPES = new Set(["repo_map"]);
|
|
17553
|
+
const isSynthesis = (p) => {
|
|
17554
|
+
const q = (p.quality ?? {});
|
|
17555
|
+
return L2_TYPES.has(p.type) || q.candidate_kind === "change_memory";
|
|
17556
|
+
};
|
|
17557
|
+
const entries = (0, node_fs_1.existsSync)(packetsDir(projectDir)) ? loadPacketEntriesFromDir(packetsDir(projectDir)) : [];
|
|
17558
|
+
const l2 = entries.filter((e) => isSynthesis(e.packet));
|
|
17559
|
+
const l1 = entries.filter((e) => !isSynthesis(e.packet));
|
|
17560
|
+
let observationCount = 0;
|
|
17561
|
+
const obsDir = observationsDir(projectDir);
|
|
17562
|
+
if ((0, node_fs_1.existsSync)(obsDir)) {
|
|
17563
|
+
try {
|
|
17564
|
+
observationCount = (0, node_fs_1.readdirSync)(obsDir).filter((f) => f.endsWith(".jsonl") || f.endsWith(".json")).length;
|
|
17565
|
+
}
|
|
17566
|
+
catch {
|
|
17567
|
+
observationCount = 0;
|
|
17568
|
+
}
|
|
17569
|
+
}
|
|
17570
|
+
return {
|
|
17571
|
+
project_dir: projectDir,
|
|
17572
|
+
layers: [
|
|
17573
|
+
{ layer: "L0", label: "Raw observations", description: "Session signal captured by hooks; unreviewed, feeds auto-distill.", count: observationCount, examples: [] },
|
|
17574
|
+
{ layer: "L1", label: "Reviewed memory", description: "Verified packets — cited, fingerprinted, the trusted recall store.", count: l1.length, examples: l1.slice(0, 3).map((e) => e.packet.title) },
|
|
17575
|
+
{ layer: "L2", label: "Synthesis", description: "Generated overviews: repo maps and branch/change summaries.", count: l2.length, examples: l2.slice(0, 3).map((e) => e.packet.title) },
|
|
17576
|
+
],
|
|
17577
|
+
};
|
|
17578
|
+
}
|