@kage-core/kage-graph-mcp 2.3.0 → 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 +95 -15
- package/dist/index.js +56 -31
- package/dist/kernel.js +621 -45
- package/package.json +2 -2
- package/viewer/console.js +69 -2
- package/viewer/index.html +12 -3
package/dist/kernel.js
CHANGED
|
@@ -110,6 +110,8 @@ exports.kageRisk = kageRisk;
|
|
|
110
110
|
exports.kageDependencyPath = kageDependencyPath;
|
|
111
111
|
exports.kageCleanupCandidates = kageCleanupCandidates;
|
|
112
112
|
exports.truthReport = truthReport;
|
|
113
|
+
exports.truthScorecardSvg = truthScorecardSvg;
|
|
114
|
+
exports.truthScorecardMarkdown = truthScorecardMarkdown;
|
|
113
115
|
exports.defaultClaudeMemStorePath = defaultClaudeMemStorePath;
|
|
114
116
|
exports.claudeMemProjectKey = claudeMemProjectKey;
|
|
115
117
|
exports.parseClaudeMemFileList = parseClaudeMemFileList;
|
|
@@ -180,6 +182,7 @@ exports.remediationFor = remediationFor;
|
|
|
180
182
|
exports.approvePending = approvePending;
|
|
181
183
|
exports.rejectPending = rejectPending;
|
|
182
184
|
exports.changelog = changelog;
|
|
185
|
+
exports.generateSkills = generateSkills;
|
|
183
186
|
exports.reverifyMemory = reverifyMemory;
|
|
184
187
|
exports.supersedeMemory = supersedeMemory;
|
|
185
188
|
exports.kageMemoryLineage = kageMemoryLineage;
|
|
@@ -284,6 +287,13 @@ If Kage appears installed but no Kage tools are available, report that the activ
|
|
|
284
287
|
agent session has not loaded the MCP server and ask the user to restart the
|
|
285
288
|
agent. After restart, call \`kage_verify_agent\` to prove the harness is live.
|
|
286
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
|
+
|
|
287
297
|
## Automatic Capture
|
|
288
298
|
|
|
289
299
|
When you learn something reusable, create repo-local memory with \`kage_learn\`.
|
|
@@ -1422,17 +1432,26 @@ function replacementCueCount(text) {
|
|
|
1422
1432
|
count += 1;
|
|
1423
1433
|
return count;
|
|
1424
1434
|
}
|
|
1425
|
-
//
|
|
1426
|
-
//
|
|
1427
|
-
//
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
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)));
|
|
1436
1455
|
}
|
|
1437
1456
|
// Opposing-polarity signal between two same-subject bodies. Conservative: a
|
|
1438
1457
|
// contradiction needs one side to assert a claim and the other to negate or
|
|
@@ -1460,13 +1479,16 @@ function opposingPolarity(aText, bText) {
|
|
|
1460
1479
|
// candidate and (optionally) the existing pool, performs no writes. Returns the
|
|
1461
1480
|
// conflicting approved packets with the shared paths and a human reason.
|
|
1462
1481
|
function detectContradictions(projectDir, candidate, opts = {}) {
|
|
1463
|
-
|
|
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;
|
|
1464
1486
|
const candidatePaths = new Set(candidate.paths.filter((path) => meaningfulMemoryPath(path) && !shouldSkipRepoMemoryPath(path)));
|
|
1465
1487
|
if (!candidatePaths.size)
|
|
1466
1488
|
return [];
|
|
1467
1489
|
const existing = (opts.existing ?? loadApprovedPackets(projectDir)).filter((packet) => packet.id !== candidate.id && packet.status === "approved");
|
|
1468
1490
|
const candidateText = normalizedClaimText(candidate);
|
|
1469
|
-
const candidateSubject =
|
|
1491
|
+
const candidateSubject = distinctiveSubjectTokens(candidate);
|
|
1470
1492
|
const findings = [];
|
|
1471
1493
|
for (const packet of existing) {
|
|
1472
1494
|
// (a) shared cited path — the structural anchor that makes a contradiction
|
|
@@ -1474,12 +1496,15 @@ function detectContradictions(projectDir, candidate, opts = {}) {
|
|
|
1474
1496
|
const sharedPaths = packet.paths.filter((path) => candidatePaths.has(path));
|
|
1475
1497
|
if (!sharedPaths.length)
|
|
1476
1498
|
continue;
|
|
1477
|
-
// (b) same subject
|
|
1478
|
-
//
|
|
1479
|
-
//
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
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)
|
|
1483
1508
|
continue;
|
|
1484
1509
|
// (c) opposing polarity — the claim itself is flipped/redirected.
|
|
1485
1510
|
const reason = opposingPolarity(candidateText, normalizedClaimText(packet));
|
|
@@ -1606,15 +1631,144 @@ function memoryPathFingerprint(projectDir, path, cache) {
|
|
|
1606
1631
|
return null;
|
|
1607
1632
|
}
|
|
1608
1633
|
}
|
|
1609
|
-
|
|
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;
|
|
1610
1712
|
const fingerprints = [];
|
|
1611
1713
|
for (const path of unique(paths).filter(fingerprintableMemoryPath)) {
|
|
1612
1714
|
const fingerprint = memoryPathFingerprint(projectDir, path);
|
|
1613
|
-
if (fingerprint)
|
|
1614
|
-
|
|
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);
|
|
1615
1742
|
}
|
|
1616
1743
|
return fingerprints;
|
|
1617
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
|
+
}
|
|
1618
1772
|
function packetStoredPathFingerprints(packet) {
|
|
1619
1773
|
const raw = (packet.freshness ?? {}).path_fingerprints;
|
|
1620
1774
|
if (!Array.isArray(raw))
|
|
@@ -1628,9 +1782,31 @@ function packetStoredPathFingerprints(packet) {
|
|
|
1628
1782
|
const size = Number(record.size ?? 0);
|
|
1629
1783
|
if (!path || !sha256 || !Number.isFinite(size) || !fingerprintableMemoryPath(path))
|
|
1630
1784
|
return [];
|
|
1631
|
-
|
|
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 } : {}) }];
|
|
1632
1799
|
});
|
|
1633
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
|
+
}
|
|
1634
1810
|
function staleMemoryReasons(projectDir, packet, fingerprintCache) {
|
|
1635
1811
|
const reasons = [];
|
|
1636
1812
|
const quality = (packet.quality ?? {});
|
|
@@ -1660,11 +1836,9 @@ function staleMemoryReasons(projectDir, packet, fingerprintCache) {
|
|
|
1660
1836
|
const storedFingerprints = packetStoredPathFingerprints(packet);
|
|
1661
1837
|
const changedPaths = storedFingerprints
|
|
1662
1838
|
.filter((fingerprint) => !isGroundingIgnored(projectDir, fingerprint.path))
|
|
1839
|
+
.filter((fingerprint) => !isAppendOnlyLedgerPath(fingerprint.path))
|
|
1663
1840
|
.filter((fingerprint) => (0, node_fs_1.existsSync)((0, node_path_1.join)(projectDir, fingerprint.path)))
|
|
1664
|
-
.filter((fingerprint) =>
|
|
1665
|
-
const current = memoryPathFingerprint(projectDir, fingerprint.path, fingerprintCache);
|
|
1666
|
-
return current !== null && current.sha256 !== fingerprint.sha256;
|
|
1667
|
-
})
|
|
1841
|
+
.filter((fingerprint) => fingerprintPathContentChanged(projectDir, fingerprint, fingerprintCache))
|
|
1668
1842
|
.map((fingerprint) => fingerprint.path);
|
|
1669
1843
|
if (changedPaths.length) {
|
|
1670
1844
|
reasons.push(`linked path changed since memory was verified: ${changedPaths.slice(0, 4).join(", ")}`);
|
|
@@ -1718,6 +1892,22 @@ function recallHardStaleReason(projectDir, packet, cache) {
|
|
|
1718
1892
|
}
|
|
1719
1893
|
return null;
|
|
1720
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
|
+
}
|
|
1721
1911
|
function changedPathsFromStaleReasons(reasons) {
|
|
1722
1912
|
return unique(reasons.flatMap((reason) => {
|
|
1723
1913
|
const match = reason.match(/^linked path changed since memory was verified: (.+)$/);
|
|
@@ -6821,7 +7011,7 @@ function kageSuppressedMemory(projectDir) {
|
|
|
6821
7011
|
const cache = new Map();
|
|
6822
7012
|
const items = loadApprovedPackets(projectDir)
|
|
6823
7013
|
.map((packet) => {
|
|
6824
|
-
const reason =
|
|
7014
|
+
const reason = recallStaleReason(projectDir, packet, cache);
|
|
6825
7015
|
return reason ? { id: packet.id, title: packet.title, type: packet.type, reason, paths: packet.paths } : null;
|
|
6826
7016
|
})
|
|
6827
7017
|
.filter((entry) => entry !== null);
|
|
@@ -6896,7 +7086,7 @@ function compactProject(projectDir, options = {}) {
|
|
|
6896
7086
|
paths: keptPaths,
|
|
6897
7087
|
freshness: {
|
|
6898
7088
|
...(packet.freshness ?? {}),
|
|
6899
|
-
path_fingerprints: memoryPathFingerprints(projectDir, keptPaths),
|
|
7089
|
+
path_fingerprints: memoryPathFingerprints(projectDir, keptPaths, `${packet.title}\n${packet.summary}\n${packet.body}`),
|
|
6900
7090
|
last_verified_at: nowIso(),
|
|
6901
7091
|
},
|
|
6902
7092
|
updated_at: nowIso(),
|
|
@@ -7711,7 +7901,7 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
|
|
|
7711
7901
|
const approvedPackets = includeStale
|
|
7712
7902
|
? allApprovedPackets
|
|
7713
7903
|
: allApprovedPackets.filter((packet) => {
|
|
7714
|
-
const reason =
|
|
7904
|
+
const reason = recallStaleReason(projectDir, packet, staleFingerprintCache);
|
|
7715
7905
|
if (reason) {
|
|
7716
7906
|
suppressed.push({ id: packet.id, title: packet.title, reason });
|
|
7717
7907
|
return false;
|
|
@@ -7830,7 +8020,12 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
|
|
|
7830
8020
|
graphContext.edges.length ? "## Related Graph Facts" : "",
|
|
7831
8021
|
...graphContext.edges.slice(0, 5).map((edge, index) => `${index + 1}. ${edge.fact} (evidence: ${edge.evidence.join(", ")})`),
|
|
7832
8022
|
...(suppressed.length
|
|
7833
|
-
? [
|
|
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
|
+
]
|
|
7834
8029
|
: []),
|
|
7835
8030
|
...(personalEntries.length
|
|
7836
8031
|
? [
|
|
@@ -8936,7 +9131,7 @@ function kageCleanupCandidates(projectDir) {
|
|
|
8936
9131
|
summary: `${candidates.length} conservative cleanup candidate(s), ${skippedEntryPoints.length} entrypoint-like source file(s) skipped, ${skippedRuntimeReferences.length} runtime reference(s) skipped.`,
|
|
8937
9132
|
};
|
|
8938
9133
|
}
|
|
8939
|
-
const TRUTH_REPORT_MAX_FINDINGS =
|
|
9134
|
+
const TRUTH_REPORT_MAX_FINDINGS = 16;
|
|
8940
9135
|
const TRUTH_REPORT_AI_ERA_DAYS = 120;
|
|
8941
9136
|
// Symbol names too generic to mean "two teams built the same thing".
|
|
8942
9137
|
const TRUTH_COMMON_SYMBOL_NAMES = new Set([
|
|
@@ -9220,6 +9415,106 @@ function truthReport(projectDir) {
|
|
|
9220
9415
|
}
|
|
9221
9416
|
}
|
|
9222
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));
|
|
9223
9518
|
// 2. Doc-truth: checkable claims in README/docs vs reality.
|
|
9224
9519
|
const docLieFindings = [];
|
|
9225
9520
|
if (docLines.length) {
|
|
@@ -9306,19 +9601,29 @@ function truthReport(projectDir) {
|
|
|
9306
9601
|
docLieFindings.sort((a, b) => b.surprise - a.surprise || a.title.localeCompare(b.title));
|
|
9307
9602
|
// Cap per category so one noisy category cannot drown the report, then rank globally.
|
|
9308
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),
|
|
9309
9609
|
...duplicateFindings.slice(0, 4),
|
|
9310
9610
|
...ghostFindings.slice(0, 4),
|
|
9311
|
-
...busFindings.slice(0, 4),
|
|
9312
|
-
...voidFindings.slice(0, 4),
|
|
9313
9611
|
...docLieFindings.slice(0, 4),
|
|
9314
9612
|
].sort((a, b) => b.surprise - a.surprise || a.title.localeCompare(b.title)).slice(0, TRUTH_REPORT_MAX_FINDINGS);
|
|
9315
|
-
|
|
9316
|
-
|
|
9317
|
-
|
|
9318
|
-
|
|
9319
|
-
`${voidFindings.length} knowledge void${voidFindings.length === 1 ? "" : "s"}
|
|
9320
|
-
|
|
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"}`]] : []),
|
|
9321
9625
|
];
|
|
9626
|
+
const headlineParts = headlineCandidates.filter(([count]) => count > 0).map(([, label]) => label);
|
|
9322
9627
|
return {
|
|
9323
9628
|
schema_version: 1,
|
|
9324
9629
|
project_dir: projectDir,
|
|
@@ -9330,6 +9635,9 @@ function truthReport(projectDir) {
|
|
|
9330
9635
|
ghost_exports: ghostFindings.length,
|
|
9331
9636
|
bus_factor_files: busFindings.length,
|
|
9332
9637
|
knowledge_voids: voidFindings.length,
|
|
9638
|
+
untested_hot_paths: untestedFindings.length,
|
|
9639
|
+
complexity_hotspots: complexityFindings.length,
|
|
9640
|
+
debt_markers: debtFindings.length,
|
|
9333
9641
|
doc_lies: docLieFindings.length,
|
|
9334
9642
|
docs_scanned: docFiles.length,
|
|
9335
9643
|
},
|
|
@@ -9343,6 +9651,118 @@ function truthReport(projectDir) {
|
|
|
9343
9651
|
],
|
|
9344
9652
|
};
|
|
9345
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
|
+
}
|
|
9346
9766
|
function defaultClaudeMemStorePath() {
|
|
9347
9767
|
const dataDir = process.env.CLAUDE_MEM_DATA_DIR || (0, node_path_1.join)((0, node_os_1.homedir)(), ".claude-mem");
|
|
9348
9768
|
return (0, node_path_1.join)(dataDir, "claude-mem.db");
|
|
@@ -13009,7 +13429,7 @@ function capture(input) {
|
|
|
13009
13429
|
freshness: {
|
|
13010
13430
|
ttl_days: 365,
|
|
13011
13431
|
last_verified_at: createdAt,
|
|
13012
|
-
path_fingerprints: memoryPathFingerprints(input.projectDir, groundedPaths),
|
|
13432
|
+
path_fingerprints: memoryPathFingerprints(input.projectDir, groundedPaths, `${input.title}\n${input.summary ?? ""}\n${input.body}`),
|
|
13013
13433
|
path_fingerprint_policy: "source_hash_staleness",
|
|
13014
13434
|
verification: "repo_local_agent_capture",
|
|
13015
13435
|
},
|
|
@@ -13308,6 +13728,8 @@ PAYLOAD="$(cat || true)"
|
|
|
13308
13728
|
CWD="$(printf "%s" "$PAYLOAD" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('cwd',''))" 2>/dev/null || echo "")"
|
|
13309
13729
|
|
|
13310
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"
|
|
13311
13733
|
command -v kage >/dev/null 2>&1 || exit 0
|
|
13312
13734
|
|
|
13313
13735
|
if git -C "$CWD" status --porcelain -uall >/dev/null 2>&1 && [[ -n "$(git -C "$CWD" status --porcelain -uall)" ]]; then
|
|
@@ -13364,6 +13786,8 @@ print(d.get("cwd") or os.environ.get("CLAUDE_PROJECT_DIR") or "")
|
|
|
13364
13786
|
' 2>/dev/null || echo "")"
|
|
13365
13787
|
|
|
13366
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"
|
|
13367
13791
|
command -v kage >/dev/null 2>&1 || exit 0
|
|
13368
13792
|
|
|
13369
13793
|
EVENT="$(PAYLOAD="$PAYLOAD" python3 -c 'import json, os
|
|
@@ -13490,6 +13914,8 @@ print(d.get("cwd") or os.environ.get("CLAUDE_PROJECT_DIR") or "")
|
|
|
13490
13914
|
' 2>/dev/null || echo "")"
|
|
13491
13915
|
|
|
13492
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"
|
|
13493
13919
|
command -v kage >/dev/null 2>&1 || exit 0
|
|
13494
13920
|
|
|
13495
13921
|
FILE_PATH="$(PAYLOAD="$PAYLOAD" python3 -c 'import json, os
|
|
@@ -13539,6 +13965,15 @@ fi
|
|
|
13539
13965
|
|
|
13540
13966
|
exit 0
|
|
13541
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.");
|
|
13542
13977
|
const settingsPath = (0, node_path_1.join)(home, ".claude", "settings.json");
|
|
13543
13978
|
const hookEntry = {
|
|
13544
13979
|
hooks: {
|
|
@@ -13548,6 +13983,9 @@ exit 0
|
|
|
13548
13983
|
{ matcher: "", hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/observe.sh", timeout: 5 }] },
|
|
13549
13984
|
// Verified memory at the moment of relevance: short timeout, never blocks the Read.
|
|
13550
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 }] },
|
|
13551
13989
|
],
|
|
13552
13990
|
PostToolUse: [{ matcher: "", hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/observe.sh", timeout: 5 }] }],
|
|
13553
13991
|
PostToolUseFailure: [{ matcher: "", hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/observe.sh", timeout: 5 }] }],
|
|
@@ -13560,7 +13998,7 @@ exit 0
|
|
|
13560
13998
|
setSnippet(path, JSON.stringify({ mcpServers: { kage: server } }, null, 2), [
|
|
13561
13999
|
"Add the MCP server to ~/.claude.json, then restart Claude Code.",
|
|
13562
14000
|
"alwaysLoad: true makes Kage tools immediately visible without requiring ToolSearch.",
|
|
13563
|
-
`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.`,
|
|
13564
14002
|
"Run `kage init --project <repo>` inside each repo to install the ambient memory policy.",
|
|
13565
14003
|
], true);
|
|
13566
14004
|
if (options.write) {
|
|
@@ -13570,6 +14008,7 @@ exit 0
|
|
|
13570
14008
|
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(hookDir, "session-start.sh"), hookScript, { mode: 0o755 });
|
|
13571
14009
|
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(hookDir, "observe.sh"), observeHookScript, { mode: 0o755 });
|
|
13572
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 });
|
|
13573
14012
|
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(hookDir, "stop.sh"), stopHookScript, { mode: 0o755 });
|
|
13574
14013
|
upsertJsonSettings(settingsPath, hookEntry);
|
|
13575
14014
|
result.wrote = true;
|
|
@@ -15059,7 +15498,9 @@ function staleCatch(projectDir, changedFiles) {
|
|
|
15059
15498
|
if (current === null) {
|
|
15060
15499
|
invalidated.push({ packet_id: packet.id, packet_title: packet.title, cited_path: stored.path, reason: "cited file was deleted" });
|
|
15061
15500
|
}
|
|
15062
|
-
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.
|
|
15063
15504
|
invalidated.push({ packet_id: packet.id, packet_title: packet.title, cited_path: stored.path, reason: "content changed since this memory was verified" });
|
|
15064
15505
|
}
|
|
15065
15506
|
}
|
|
@@ -16166,6 +16607,141 @@ function packetSupersessionReason(packet) {
|
|
|
16166
16607
|
const evidence = edge ? packetEdgeValue(edge, "evidence") : "";
|
|
16167
16608
|
return evidence || "This memory was superseded by newer repo knowledge.";
|
|
16168
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
|
+
}
|
|
16169
16745
|
// Re-verify a still-true packet in place: re-check cited paths, refresh
|
|
16170
16746
|
// fingerprints and last_verified_at, and clear stale flags. The alternative to
|
|
16171
16747
|
// supersede churn when code changed but the memory's claim did not. Refuses
|
|
@@ -16203,7 +16779,7 @@ function reverifyMemory(projectDir, packetId) {
|
|
|
16203
16779
|
const presentPaths = citedPaths.filter((path) => !result.missing_paths.includes(path));
|
|
16204
16780
|
const now = nowIso();
|
|
16205
16781
|
const freshness = { ...(packet.freshness ?? {}) };
|
|
16206
|
-
freshness.path_fingerprints = memoryPathFingerprints(projectDir, presentPaths);
|
|
16782
|
+
freshness.path_fingerprints = memoryPathFingerprints(projectDir, presentPaths, `${packet.title}\n${packet.summary}\n${packet.body}`);
|
|
16207
16783
|
freshness.last_verified_at = now;
|
|
16208
16784
|
const { stale: _stale, stale_reasons: _staleReasons, suggested_action: _suggestedAction, ...nextQuality } = quality;
|
|
16209
16785
|
writeJson(entry.path, {
|
|
@@ -16615,7 +17191,7 @@ function personalRecallEntries(projectDir, terms, limit = 3) {
|
|
|
16615
17191
|
if (!packets.length)
|
|
16616
17192
|
return [];
|
|
16617
17193
|
const cache = new Map();
|
|
16618
|
-
const eligible = packets.filter((packet) =>
|
|
17194
|
+
const eligible = packets.filter((packet) => recallStaleReason(projectDir, packet, cache) === null);
|
|
16619
17195
|
if (!eligible.length)
|
|
16620
17196
|
return [];
|
|
16621
17197
|
const scores = scorePacketsBm25(terms, eligible);
|