@kage-core/kage-graph-mcp 1.1.38 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +137 -4
- package/dist/daemon.js +5 -1
- package/dist/index.js +85 -14
- package/dist/kernel.js +537 -15
- package/package.json +1 -1
- package/viewer/app.js +93 -4
- package/viewer/index.html +8 -6
- package/viewer/styles.css +99 -2
package/dist/kernel.js
CHANGED
|
@@ -83,6 +83,9 @@ exports.buildIndexes = buildIndexes;
|
|
|
83
83
|
exports.indexProject = indexProject;
|
|
84
84
|
exports.refreshProject = refreshProject;
|
|
85
85
|
exports.gcProject = gcProject;
|
|
86
|
+
exports.kageSuppressedMemory = kageSuppressedMemory;
|
|
87
|
+
exports.verifyCitations = verifyCitations;
|
|
88
|
+
exports.compactProject = compactProject;
|
|
86
89
|
exports.installAgentPolicy = installAgentPolicy;
|
|
87
90
|
exports.createDenseEmbeddingProvider = createDenseEmbeddingProvider;
|
|
88
91
|
exports.buildEmbeddingIndex = buildEmbeddingIndex;
|
|
@@ -112,6 +115,8 @@ exports.kageMetrics = kageMetrics;
|
|
|
112
115
|
exports.auditProject = auditProject;
|
|
113
116
|
exports.memoryInbox = memoryInbox;
|
|
114
117
|
exports.qualityReport = qualityReport;
|
|
118
|
+
exports.benchmarkTrust = benchmarkTrust;
|
|
119
|
+
exports.runDemo = runDemo;
|
|
115
120
|
exports.benchmarkProject = benchmarkProject;
|
|
116
121
|
exports.benchmarkCodingMemoryQuality = benchmarkCodingMemoryQuality;
|
|
117
122
|
exports.benchmarkMemoryScale = benchmarkMemoryScale;
|
|
@@ -180,6 +185,27 @@ exports.MEMORY_TYPES = [
|
|
|
180
185
|
"negative_result",
|
|
181
186
|
"constraint",
|
|
182
187
|
];
|
|
188
|
+
// Bounded context assembly (PRD Feature 2: "inject only the relevant rule + structural
|
|
189
|
+
// map, dropping the rest"). Opt-in: when a token budget is set, keep the highest-priority
|
|
190
|
+
// sections (preamble + code graph + memory come first in the block) and drop trailing
|
|
191
|
+
// lower-priority sections until the estimate fits. Default (no budget) is unchanged.
|
|
192
|
+
function boundContextBlock(block, budget) {
|
|
193
|
+
if (!Number.isFinite(budget) || budget <= 0 || estimateTokens(block) <= budget)
|
|
194
|
+
return block;
|
|
195
|
+
const parts = block.split(/\n(?=## )/);
|
|
196
|
+
const kept = [parts[0]];
|
|
197
|
+
let dropped = 0;
|
|
198
|
+
for (const section of parts.slice(1)) {
|
|
199
|
+
if (estimateTokens([...kept, section].join("\n")) <= budget)
|
|
200
|
+
kept.push(section);
|
|
201
|
+
else
|
|
202
|
+
dropped += 1;
|
|
203
|
+
}
|
|
204
|
+
const result = kept.join("\n");
|
|
205
|
+
return dropped
|
|
206
|
+
? `${result}\n\n_Context trimmed to ~${budget} tokens; ${dropped} lower-priority section(s) dropped._`
|
|
207
|
+
: result;
|
|
208
|
+
}
|
|
183
209
|
const graphMemoryCache = new Map();
|
|
184
210
|
exports.SETUP_AGENTS = [
|
|
185
211
|
"codex",
|
|
@@ -1119,6 +1145,52 @@ function staleMemoryReasons(projectDir, packet, fingerprintCache) {
|
|
|
1119
1145
|
}
|
|
1120
1146
|
return unique(reasons);
|
|
1121
1147
|
}
|
|
1148
|
+
// Classifies stale reasons into severity. "hard" reasons (deprecated status, user
|
|
1149
|
+
// reported stale, ttl expired, all citations deleted) mean the memory should be
|
|
1150
|
+
// excluded from recall; "soft" reasons (some citations missing, a linked file
|
|
1151
|
+
// changed) mean keep-but-flag — the memory may just need review, not suppression.
|
|
1152
|
+
function staleSeverity(reasons) {
|
|
1153
|
+
if (!reasons.length)
|
|
1154
|
+
return "none";
|
|
1155
|
+
const hard = reasons.some((reason) => reason.startsWith("packet status is") ||
|
|
1156
|
+
reason.startsWith("user or agent reported") ||
|
|
1157
|
+
reason.startsWith("freshness ttl expired") ||
|
|
1158
|
+
reason.startsWith("all referenced paths are missing"));
|
|
1159
|
+
return hard ? "hard" : "soft";
|
|
1160
|
+
}
|
|
1161
|
+
// Decides whether a packet should be excluded from the recall payload (PRD Feature 3:
|
|
1162
|
+
// "deleted or heavily refactored since the timestamp"). Distinct from staleMemoryReasons:
|
|
1163
|
+
// a citation that NEVER existed (no stored fingerprint) is an ungrounded write — guarded
|
|
1164
|
+
// at capture time — not recall-time staleness, so it does NOT trigger exclusion here.
|
|
1165
|
+
// Returns a reason string when the memory is hard-stale, otherwise null.
|
|
1166
|
+
function recallHardStaleReason(projectDir, packet, cache) {
|
|
1167
|
+
if (packet.status === "deprecated" || packet.status === "superseded")
|
|
1168
|
+
return `packet status is ${packet.status}`;
|
|
1169
|
+
const quality = (packet.quality ?? {});
|
|
1170
|
+
if (Number(quality.reports_stale ?? 0) > 0)
|
|
1171
|
+
return "user or agent reported this memory stale";
|
|
1172
|
+
const freshness = (packet.freshness ?? {});
|
|
1173
|
+
const ttlDays = Number(freshness.ttl_days ?? freshness.ttlDays ?? 0);
|
|
1174
|
+
const verifiedAt = Date.parse(String(freshness.last_verified_at ?? packet.updated_at ?? packet.created_at));
|
|
1175
|
+
if (Number.isFinite(ttlDays) && ttlDays > 0 && Number.isFinite(verifiedAt)) {
|
|
1176
|
+
const ageDays = (Date.now() - verifiedAt) / (1000 * 60 * 60 * 24);
|
|
1177
|
+
if (ageDays > ttlDays)
|
|
1178
|
+
return `freshness ttl expired (${Math.floor(ageDays)}d old, ttl ${ttlDays}d)`;
|
|
1179
|
+
}
|
|
1180
|
+
// Only paths that existed at capture get a stored fingerprint; if every one of them is
|
|
1181
|
+
// now gone, the memory's evidence was deleted out from under it.
|
|
1182
|
+
const stored = packetStoredPathFingerprints(packet);
|
|
1183
|
+
if (stored.length) {
|
|
1184
|
+
const deleted = stored.filter((fingerprint) => {
|
|
1185
|
+
const current = memoryPathFingerprint(projectDir, fingerprint.path, cache);
|
|
1186
|
+
return current === null;
|
|
1187
|
+
});
|
|
1188
|
+
if (deleted.length === stored.length) {
|
|
1189
|
+
return `all cited files deleted since capture: ${deleted.slice(0, 4).map((fingerprint) => fingerprint.path).join(", ")}`;
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
return null;
|
|
1193
|
+
}
|
|
1122
1194
|
function changedPathsFromStaleReasons(reasons) {
|
|
1123
1195
|
return unique(reasons.flatMap((reason) => {
|
|
1124
1196
|
const match = reason.match(/^linked path changed since memory was verified: (.+)$/);
|
|
@@ -1523,13 +1595,28 @@ function walkFiles(root, predicate) {
|
|
|
1523
1595
|
}
|
|
1524
1596
|
return out.sort();
|
|
1525
1597
|
}
|
|
1598
|
+
// Tolerant packet read: a single corrupt or merge-conflicted packet (e.g. an
|
|
1599
|
+
// unresolved `<<<<<<<` from a teammate's git merge) must not take down all of
|
|
1600
|
+
// recall/verify/compact. Skip the bad file with a warning and keep going.
|
|
1601
|
+
function tryReadPacket(path) {
|
|
1602
|
+
try {
|
|
1603
|
+
return readJson(path);
|
|
1604
|
+
}
|
|
1605
|
+
catch (error) {
|
|
1606
|
+
process.stderr.write(`kage: skipping unreadable memory packet ${path}: ${error.message}\n`);
|
|
1607
|
+
return null;
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1526
1610
|
function loadPacketsFromDir(dir) {
|
|
1527
1611
|
if (!(0, node_fs_1.existsSync)(dir))
|
|
1528
1612
|
return [];
|
|
1529
1613
|
return (0, node_fs_1.readdirSync)(dir)
|
|
1530
1614
|
.filter((name) => name.endsWith(".json"))
|
|
1531
1615
|
.sort()
|
|
1532
|
-
.
|
|
1616
|
+
.flatMap((name) => {
|
|
1617
|
+
const packet = tryReadPacket((0, node_path_1.join)(dir, name));
|
|
1618
|
+
return packet ? [packet] : [];
|
|
1619
|
+
});
|
|
1533
1620
|
}
|
|
1534
1621
|
function loadPacketEntriesFromDir(dir) {
|
|
1535
1622
|
if (!(0, node_fs_1.existsSync)(dir))
|
|
@@ -1537,9 +1624,10 @@ function loadPacketEntriesFromDir(dir) {
|
|
|
1537
1624
|
return (0, node_fs_1.readdirSync)(dir)
|
|
1538
1625
|
.filter((name) => name.endsWith(".json"))
|
|
1539
1626
|
.sort()
|
|
1540
|
-
.
|
|
1627
|
+
.flatMap((name) => {
|
|
1541
1628
|
const path = (0, node_path_1.join)(dir, name);
|
|
1542
|
-
|
|
1629
|
+
const packet = tryReadPacket(path);
|
|
1630
|
+
return packet ? [{ path, packet }] : [];
|
|
1543
1631
|
});
|
|
1544
1632
|
}
|
|
1545
1633
|
function loadApprovedPackets(projectDir) {
|
|
@@ -5510,6 +5598,127 @@ function gcProject(projectDir, options = {}) {
|
|
|
5510
5598
|
total_scanned: packetEntries.length,
|
|
5511
5599
|
};
|
|
5512
5600
|
}
|
|
5601
|
+
// The memory recall is actively WITHHOLDING from agents right now (hard-stale:
|
|
5602
|
+
// cited files deleted, ttl expired, or reported stale). This is the human-facing
|
|
5603
|
+
// counterpart to the silent recall-time exclusion — surfaced, never hidden.
|
|
5604
|
+
function kageSuppressedMemory(projectDir) {
|
|
5605
|
+
ensureMemoryDirs(projectDir);
|
|
5606
|
+
const cache = new Map();
|
|
5607
|
+
const items = loadApprovedPackets(projectDir)
|
|
5608
|
+
.map((packet) => {
|
|
5609
|
+
const reason = recallHardStaleReason(projectDir, packet, cache);
|
|
5610
|
+
return reason ? { id: packet.id, title: packet.title, type: packet.type, reason, paths: packet.paths } : null;
|
|
5611
|
+
})
|
|
5612
|
+
.filter((entry) => entry !== null);
|
|
5613
|
+
return { schema_version: 1, generated_at: nowIso(), count: items.length, items };
|
|
5614
|
+
}
|
|
5615
|
+
function verifyCitations(projectDir, options = {}) {
|
|
5616
|
+
ensureMemoryDirs(projectDir);
|
|
5617
|
+
const approved = loadApprovedPackets(projectDir);
|
|
5618
|
+
const targets = options.id ? approved.filter((packet) => packet.id === options.id) : approved;
|
|
5619
|
+
if (options.id && !targets.length) {
|
|
5620
|
+
return { ok: false, project_dir: projectDir, checked: 0, valid: 0, stale: 0, ungrounded: 0, packets: [], errors: [`Approved packet not found: ${options.id}`] };
|
|
5621
|
+
}
|
|
5622
|
+
const cache = new Map();
|
|
5623
|
+
const packets = targets.map((packet) => {
|
|
5624
|
+
const meaningful = packet.paths.filter((path) => meaningfulMemoryPath(path) && !shouldSkipRepoMemoryPath(path));
|
|
5625
|
+
const missing = meaningful.filter((path) => !pathExistsInRepo(projectDir, path));
|
|
5626
|
+
const reasons = staleMemoryReasons(projectDir, packet, cache);
|
|
5627
|
+
const severity = staleSeverity(reasons);
|
|
5628
|
+
const grounded = packetGroundingWarnings(projectDir, packet, "packet").length === 0;
|
|
5629
|
+
return {
|
|
5630
|
+
id: packet.id,
|
|
5631
|
+
title: packet.title,
|
|
5632
|
+
status: packet.status,
|
|
5633
|
+
paths: packet.paths,
|
|
5634
|
+
missing_paths: missing,
|
|
5635
|
+
grounded,
|
|
5636
|
+
stale: severity !== "none",
|
|
5637
|
+
stale_severity: severity,
|
|
5638
|
+
stale_reasons: reasons,
|
|
5639
|
+
};
|
|
5640
|
+
});
|
|
5641
|
+
return {
|
|
5642
|
+
ok: true,
|
|
5643
|
+
project_dir: projectDir,
|
|
5644
|
+
checked: packets.length,
|
|
5645
|
+
valid: packets.filter((entry) => !entry.stale && entry.grounded).length,
|
|
5646
|
+
stale: packets.filter((entry) => entry.stale).length,
|
|
5647
|
+
ungrounded: packets.filter((entry) => !entry.grounded).length,
|
|
5648
|
+
packets,
|
|
5649
|
+
errors: [],
|
|
5650
|
+
};
|
|
5651
|
+
}
|
|
5652
|
+
// Deterministic memory consolidation (no hosted LLM — preserves the no-API-key promise):
|
|
5653
|
+
// 1. prune dead citations from packets and refresh their path fingerprints,
|
|
5654
|
+
// 2. deprecate hard-stale packets (delegating to the same severity rules as recall/gc),
|
|
5655
|
+
// 3. surface near-duplicate clusters for an agent to merge via kage_supersede.
|
|
5656
|
+
function compactProject(projectDir, options = {}) {
|
|
5657
|
+
ensureMemoryDirs(projectDir);
|
|
5658
|
+
const dryRun = options.dryRun === true;
|
|
5659
|
+
const entries = loadPacketEntriesFromDir(packetsDir(projectDir));
|
|
5660
|
+
const prunedCitations = [];
|
|
5661
|
+
const deprecated = [];
|
|
5662
|
+
const cache = new Map();
|
|
5663
|
+
for (const { path, packet } of entries) {
|
|
5664
|
+
if (packet.status === "deprecated" || packet.status === "superseded")
|
|
5665
|
+
continue;
|
|
5666
|
+
const hardReason = recallHardStaleReason(projectDir, packet, cache);
|
|
5667
|
+
if (hardReason) {
|
|
5668
|
+
deprecated.push({ id: packet.id, title: packet.title, reason: hardReason });
|
|
5669
|
+
if (!dryRun)
|
|
5670
|
+
writeJson(path, { ...packet, status: "deprecated", updated_at: nowIso() });
|
|
5671
|
+
continue;
|
|
5672
|
+
}
|
|
5673
|
+
const meaningful = packet.paths.filter((p) => meaningfulMemoryPath(p) && !shouldSkipRepoMemoryPath(p));
|
|
5674
|
+
const missing = meaningful.filter((p) => !pathExistsInRepo(projectDir, p));
|
|
5675
|
+
if (missing.length) {
|
|
5676
|
+
const keptPaths = packet.paths.filter((p) => !missing.includes(p));
|
|
5677
|
+
prunedCitations.push({ id: packet.id, title: packet.title, removed_paths: missing });
|
|
5678
|
+
if (!dryRun) {
|
|
5679
|
+
writeJson(path, {
|
|
5680
|
+
...packet,
|
|
5681
|
+
paths: keptPaths,
|
|
5682
|
+
freshness: {
|
|
5683
|
+
...(packet.freshness ?? {}),
|
|
5684
|
+
path_fingerprints: memoryPathFingerprints(projectDir, keptPaths),
|
|
5685
|
+
last_verified_at: nowIso(),
|
|
5686
|
+
},
|
|
5687
|
+
updated_at: nowIso(),
|
|
5688
|
+
});
|
|
5689
|
+
}
|
|
5690
|
+
}
|
|
5691
|
+
}
|
|
5692
|
+
// Cluster near-duplicate approved packets (report only — merging is an agent decision).
|
|
5693
|
+
const context = memoryQualityContext(projectDir);
|
|
5694
|
+
const approved = context.packets.filter((packet) => packet.status === "approved");
|
|
5695
|
+
const seen = new Set();
|
|
5696
|
+
const clusters = [];
|
|
5697
|
+
for (const packet of approved) {
|
|
5698
|
+
if (seen.has(packet.id))
|
|
5699
|
+
continue;
|
|
5700
|
+
const dupes = duplicateCandidatesWithContext(packet, context, 0.6).filter((dupe) => dupe.status === "approved");
|
|
5701
|
+
if (!dupes.length)
|
|
5702
|
+
continue;
|
|
5703
|
+
const members = [{ id: packet.id, title: packet.title }, ...dupes.map((dupe) => ({ id: dupe.id, title: dupe.title }))];
|
|
5704
|
+
members.forEach((member) => seen.add(member.id));
|
|
5705
|
+
clusters.push({ score: Math.max(...dupes.map((dupe) => dupe.score)), packets: members });
|
|
5706
|
+
}
|
|
5707
|
+
if (!dryRun && (prunedCitations.length || deprecated.length)) {
|
|
5708
|
+
const rebuilt = buildGraphIndexes(projectDir);
|
|
5709
|
+
writeJson((0, node_path_1.join)(memoryRoot(projectDir), "metrics.json"), kageMetricsShallow(projectDir, rebuilt));
|
|
5710
|
+
}
|
|
5711
|
+
return {
|
|
5712
|
+
ok: true,
|
|
5713
|
+
project_dir: projectDir,
|
|
5714
|
+
dry_run: dryRun,
|
|
5715
|
+
pruned_citations: prunedCitations,
|
|
5716
|
+
deprecated,
|
|
5717
|
+
duplicate_clusters: clusters,
|
|
5718
|
+
total_scanned: entries.length,
|
|
5719
|
+
errors: [],
|
|
5720
|
+
};
|
|
5721
|
+
}
|
|
5513
5722
|
function installAgentPolicy(projectDir) {
|
|
5514
5723
|
const agentsPath = (0, node_path_1.join)(projectDir, "AGENTS.md");
|
|
5515
5724
|
const claudePath = (0, node_path_1.join)(projectDir, "CLAUDE.md");
|
|
@@ -6277,7 +6486,23 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
|
|
|
6277
6486
|
})()
|
|
6278
6487
|
: recallQueryExpansion(query);
|
|
6279
6488
|
const terms = expansion.terms;
|
|
6280
|
-
const
|
|
6489
|
+
const allApprovedPackets = loadApprovedPackets(projectDir);
|
|
6490
|
+
const includeStale = inputs.includeStale === true;
|
|
6491
|
+
const staleFingerprintCache = new Map();
|
|
6492
|
+
const suppressed = [];
|
|
6493
|
+
// Just-in-time staleness gate: hard-stale memory (deleted citations, expired ttl,
|
|
6494
|
+
// reported stale) is excluded from the recall payload so the agent never sees it
|
|
6495
|
+
// as valid. Suppression is recorded (not silent) so `kage verify` can explain it.
|
|
6496
|
+
const approvedPackets = includeStale
|
|
6497
|
+
? allApprovedPackets
|
|
6498
|
+
: allApprovedPackets.filter((packet) => {
|
|
6499
|
+
const reason = recallHardStaleReason(projectDir, packet, staleFingerprintCache);
|
|
6500
|
+
if (reason) {
|
|
6501
|
+
suppressed.push({ id: packet.id, title: packet.title, reason });
|
|
6502
|
+
return false;
|
|
6503
|
+
}
|
|
6504
|
+
return true;
|
|
6505
|
+
});
|
|
6281
6506
|
const baseScores = scorePacketsBm25(expansion.baseTerms, approvedPackets);
|
|
6282
6507
|
const temporalScores = scorePacketsBm25(expansion.temporalTerms, approvedPackets);
|
|
6283
6508
|
const semanticScores = scorePacketsBm25(expansion.semanticTerms, approvedPackets);
|
|
@@ -6335,6 +6560,13 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
|
|
|
6335
6560
|
const graphContext = queryGraph(projectDir, query, 5, knowledgeGraph);
|
|
6336
6561
|
const codeContext = queryCodeGraph(projectDir, query, 5, codeGraph);
|
|
6337
6562
|
const pinnedContext = renderPinnedRepoContext(readContextSlots(projectDir));
|
|
6563
|
+
// PRD Feature 2: traverse the code graph outward from the recalled memory's files
|
|
6564
|
+
// (the semantic entry point) to assemble a bounded structural blast radius. Opt-in
|
|
6565
|
+
// via inputs.structuralHops so default recall output is unchanged.
|
|
6566
|
+
const structuralHops = inputs.structuralHops ?? 0;
|
|
6567
|
+
const blastRadius = structuralHops > 0
|
|
6568
|
+
? structuralBlastRadius(codeGraph, unique(scored.flatMap((entry) => entry.packet.paths).filter((path) => meaningfulMemoryPath(path))), structuralHops)
|
|
6569
|
+
: [];
|
|
6338
6570
|
const lines = [
|
|
6339
6571
|
`# Kage Context`,
|
|
6340
6572
|
"",
|
|
@@ -6347,6 +6579,9 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
|
|
|
6347
6579
|
...codeContext.tests.slice(0, 3).map((test, index) => `${index + 1}. [test] ${test.title} in ${test.test_path}:${test.line}${test.covers_symbol ? ` covers ${test.covers_symbol}` : ""}`),
|
|
6348
6580
|
...(!codeContext.symbols.length && !codeContext.routes.length && !codeContext.tests.length ? codeContext.files.slice(0, 3).map((file, index) => `${index + 1}. [file] ${file.path} (${file.kind})`) : []),
|
|
6349
6581
|
"",
|
|
6582
|
+
...(blastRadius.length
|
|
6583
|
+
? [`## Structural Blast Radius (${structuralHops}-hop)`, ...blastRadius.map((path, index) => `${index + 1}. ${path}`), ""]
|
|
6584
|
+
: []),
|
|
6350
6585
|
scored.length ? "## Relevant Memory" : "No relevant repo memory found.",
|
|
6351
6586
|
...scored.flatMap((entry, index) => [
|
|
6352
6587
|
"",
|
|
@@ -6367,11 +6602,16 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
|
|
|
6367
6602
|
"",
|
|
6368
6603
|
graphContext.edges.length ? "## Related Graph Facts" : "",
|
|
6369
6604
|
...graphContext.edges.slice(0, 5).map((edge, index) => `${index + 1}. ${edge.fact} (evidence: ${edge.evidence.join(", ")})`),
|
|
6605
|
+
...(suppressed.length
|
|
6606
|
+
? ["", `_${suppressed.length} stale memory packet(s) excluded from recall. Run kage verify for details._`]
|
|
6607
|
+
: []),
|
|
6370
6608
|
];
|
|
6609
|
+
const assembledBlock = lines.join("\n");
|
|
6371
6610
|
const result = {
|
|
6372
6611
|
query,
|
|
6373
|
-
context_block:
|
|
6612
|
+
context_block: inputs.maxContextTokens ? boundContextBlock(assembledBlock, inputs.maxContextTokens) : assembledBlock,
|
|
6374
6613
|
results: scored,
|
|
6614
|
+
suppressed: suppressed.length ? suppressed : undefined,
|
|
6375
6615
|
explanations: explain
|
|
6376
6616
|
? scored.map((entry) => ({
|
|
6377
6617
|
packet_id: entry.packet.id,
|
|
@@ -6830,6 +7070,45 @@ function codeDependents(graph) {
|
|
|
6830
7070
|
}
|
|
6831
7071
|
return dependents;
|
|
6832
7072
|
}
|
|
7073
|
+
// Bounded N-hop structural traversal from a set of seed files (the files the recalled
|
|
7074
|
+
// memory is about) — the PRD's "structural blast radius". Walks both import directions
|
|
7075
|
+
// (who-depends-on and what-it-depends-on), excludes the seeds, and ranks by how many
|
|
7076
|
+
// files depend on each node. Pure graph traversal; no full-repo scan.
|
|
7077
|
+
function structuralBlastRadius(graph, seedPaths, hops, limit = 8) {
|
|
7078
|
+
const seeds = seedPaths.filter(Boolean);
|
|
7079
|
+
if (!seeds.length || hops <= 0)
|
|
7080
|
+
return [];
|
|
7081
|
+
const dependents = codeDependents(graph);
|
|
7082
|
+
const dependencies = new Map();
|
|
7083
|
+
for (const edge of graph.imports) {
|
|
7084
|
+
if (!edge.from_path || !edge.to_path)
|
|
7085
|
+
continue;
|
|
7086
|
+
const list = dependencies.get(edge.from_path) ?? new Set();
|
|
7087
|
+
list.add(edge.to_path);
|
|
7088
|
+
dependencies.set(edge.from_path, list);
|
|
7089
|
+
}
|
|
7090
|
+
const seedSet = new Set(seeds);
|
|
7091
|
+
const visited = new Set();
|
|
7092
|
+
let frontier = new Set(seeds);
|
|
7093
|
+
for (let depth = 0; depth < hops; depth += 1) {
|
|
7094
|
+
const next = new Set();
|
|
7095
|
+
for (const node of frontier) {
|
|
7096
|
+
for (const neighbor of [...(dependents.get(node) ?? []), ...(dependencies.get(node) ?? [])]) {
|
|
7097
|
+
if (seedSet.has(neighbor) || visited.has(neighbor))
|
|
7098
|
+
continue;
|
|
7099
|
+
visited.add(neighbor);
|
|
7100
|
+
next.add(neighbor);
|
|
7101
|
+
}
|
|
7102
|
+
}
|
|
7103
|
+
frontier = next;
|
|
7104
|
+
}
|
|
7105
|
+
const score = new Map();
|
|
7106
|
+
for (const [path, incoming] of dependents.entries())
|
|
7107
|
+
score.set(path, incoming.size);
|
|
7108
|
+
return [...visited]
|
|
7109
|
+
.sort((a, b) => (score.get(b) ?? 0) - (score.get(a) ?? 0) || a.localeCompare(b))
|
|
7110
|
+
.slice(0, limit);
|
|
7111
|
+
}
|
|
6833
7112
|
function impactSurface(target, dependents, graph) {
|
|
6834
7113
|
const visited = new Set();
|
|
6835
7114
|
let frontier = new Set([target]);
|
|
@@ -9574,6 +9853,152 @@ function qualityReport(projectDir) {
|
|
|
9574
9853
|
packets: rows,
|
|
9575
9854
|
};
|
|
9576
9855
|
}
|
|
9856
|
+
// The Trust Benchmark measures what retrieval benchmarks cannot: whether the memory
|
|
9857
|
+
// system can be TRUSTED — does it refuse to store hallucinated citations, does it
|
|
9858
|
+
// withhold memory whose evidence was deleted, and is live repo memory actually grounded.
|
|
9859
|
+
// Controlled gates run in an isolated sandbox; the grounding gate runs on the real repo.
|
|
9860
|
+
function benchmarkTrust(projectDir) {
|
|
9861
|
+
const runDir = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "kage-trust-"));
|
|
9862
|
+
const sandbox = (0, node_path_1.join)(runDir, "project");
|
|
9863
|
+
try {
|
|
9864
|
+
ensureMemoryDirs(sandbox);
|
|
9865
|
+
(0, node_fs_1.mkdirSync)((0, node_path_1.join)(sandbox, "src"), { recursive: true });
|
|
9866
|
+
// Gate 1 — Hallucinated-citation rejection: a strict capture whose every cited path
|
|
9867
|
+
// is missing must be rejected. (No competitor validates citations at write time.)
|
|
9868
|
+
const hallucinationAttempts = 8;
|
|
9869
|
+
let rejected = 0;
|
|
9870
|
+
for (let i = 0; i < hallucinationAttempts; i += 1) {
|
|
9871
|
+
const result = capture({
|
|
9872
|
+
projectDir: sandbox,
|
|
9873
|
+
title: `Hallucinated rule ${i}`,
|
|
9874
|
+
body: `Use the helper in src/ghost-${i}.ts for retry handling.`,
|
|
9875
|
+
type: "decision",
|
|
9876
|
+
paths: [`src/ghost-${i}.ts`],
|
|
9877
|
+
strictCitations: true,
|
|
9878
|
+
});
|
|
9879
|
+
if (!result.ok)
|
|
9880
|
+
rejected += 1;
|
|
9881
|
+
}
|
|
9882
|
+
// Gate 2 — Stale-memory exclusion: memory grounded in real files at capture time
|
|
9883
|
+
// must be withheld from recall once those files are deleted (the "deleted since
|
|
9884
|
+
// capture" signal). We only count memories that were recallable BEFORE deletion.
|
|
9885
|
+
const staleAttempts = 8;
|
|
9886
|
+
const recallableBefore = [];
|
|
9887
|
+
for (let i = 0; i < staleAttempts; i += 1) {
|
|
9888
|
+
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(sandbox, "src", `widget-${i}.ts`), `export const widget${i} = ${i};\n`, "utf8");
|
|
9889
|
+
capture({
|
|
9890
|
+
projectDir: sandbox,
|
|
9891
|
+
title: `Widget ${i} retry invariant`,
|
|
9892
|
+
body: `Widget ${i} retries use idempotency token zeta${i} in src/widget-${i}.ts to avoid duplicate charges.`,
|
|
9893
|
+
type: "decision",
|
|
9894
|
+
paths: [`src/widget-${i}.ts`],
|
|
9895
|
+
});
|
|
9896
|
+
}
|
|
9897
|
+
for (let i = 0; i < staleAttempts; i += 1) {
|
|
9898
|
+
const before = recall(sandbox, `widget ${i} retry idempotency token zeta${i}`, 5, false, { trackAccess: false });
|
|
9899
|
+
recallableBefore[i] = before.results.some((entry) => entry.packet.title === `Widget ${i} retry invariant`);
|
|
9900
|
+
}
|
|
9901
|
+
for (let i = 0; i < staleAttempts; i += 1) {
|
|
9902
|
+
(0, node_fs_1.rmSync)((0, node_path_1.join)(sandbox, "src", `widget-${i}.ts`), { force: true });
|
|
9903
|
+
}
|
|
9904
|
+
let recallableCount = 0;
|
|
9905
|
+
let excludedAfter = 0;
|
|
9906
|
+
for (let i = 0; i < staleAttempts; i += 1) {
|
|
9907
|
+
if (!recallableBefore[i])
|
|
9908
|
+
continue;
|
|
9909
|
+
recallableCount += 1;
|
|
9910
|
+
const after = recall(sandbox, `widget ${i} retry idempotency token zeta${i}`, 5, false, { trackAccess: false });
|
|
9911
|
+
const surfaced = after.results.some((entry) => entry.packet.title === `Widget ${i} retry invariant`);
|
|
9912
|
+
if (!surfaced)
|
|
9913
|
+
excludedAfter += 1;
|
|
9914
|
+
}
|
|
9915
|
+
// Gate 3 — Live grounding: how much of the real repo's approved memory is grounded
|
|
9916
|
+
// (cited files exist) and not stale.
|
|
9917
|
+
const verify = verifyCitations(projectDir);
|
|
9918
|
+
const liveChecked = verify.checked;
|
|
9919
|
+
const grounded = verify.packets.filter((entry) => entry.grounded && !entry.stale).length;
|
|
9920
|
+
const hallucinationRate = percent(rejected, hallucinationAttempts);
|
|
9921
|
+
const staleRate = percent(excludedAfter, recallableCount || staleAttempts);
|
|
9922
|
+
const liveGroundingRate = liveChecked > 0 ? percent(grounded, liveChecked) : 100;
|
|
9923
|
+
const wrongAdvicePrevented = percent(rejected + excludedAfter, hallucinationAttempts + (recallableCount || staleAttempts));
|
|
9924
|
+
const gates = [
|
|
9925
|
+
{ name: "hallucinated_citation_rejection", target: 100, actual: hallucinationRate, unit: "percent", pass: hallucinationRate >= 100 },
|
|
9926
|
+
{ name: "stale_memory_exclusion", target: 100, actual: staleRate, unit: "percent", pass: staleRate >= 100 },
|
|
9927
|
+
{ name: "live_grounding_rate", target: 80, actual: liveGroundingRate, unit: "percent", pass: liveGroundingRate >= 80 },
|
|
9928
|
+
];
|
|
9929
|
+
const trustScore = Math.round((hallucinationRate + staleRate + liveGroundingRate) / 3);
|
|
9930
|
+
return {
|
|
9931
|
+
schema_version: 1,
|
|
9932
|
+
generated_at: nowIso(),
|
|
9933
|
+
ok: gates.every((gate) => gate.pass),
|
|
9934
|
+
trust_score: trustScore,
|
|
9935
|
+
gates,
|
|
9936
|
+
metrics: {
|
|
9937
|
+
hallucinated_citation_rejection_rate: hallucinationRate,
|
|
9938
|
+
stale_memory_exclusion_rate: staleRate,
|
|
9939
|
+
live_grounding_rate: liveGroundingRate,
|
|
9940
|
+
wrong_advice_prevented_rate: wrongAdvicePrevented,
|
|
9941
|
+
},
|
|
9942
|
+
detail: {
|
|
9943
|
+
hallucination: { attempted: hallucinationAttempts, rejected },
|
|
9944
|
+
staleness: { recallable_before: recallableCount, excluded_after: excludedAfter },
|
|
9945
|
+
live_memory: { checked: liveChecked, grounded, stale: verify.stale },
|
|
9946
|
+
},
|
|
9947
|
+
};
|
|
9948
|
+
}
|
|
9949
|
+
finally {
|
|
9950
|
+
(0, node_fs_1.rmSync)(runDir, { recursive: true, force: true });
|
|
9951
|
+
}
|
|
9952
|
+
}
|
|
9953
|
+
// `kage demo`: a self-contained 60-second proof of the trust wedge. Seeds a tiny
|
|
9954
|
+
// repo with grounded memory, then shows Kage (1) reject a hallucinated citation,
|
|
9955
|
+
// (2) withhold a memory whose cited file was deleted, and (3) recall only grounded
|
|
9956
|
+
// memory — the three things that make agent memory trustworthy.
|
|
9957
|
+
function runDemo(demoDir) {
|
|
9958
|
+
(0, node_fs_1.rmSync)(demoDir, { recursive: true, force: true });
|
|
9959
|
+
(0, node_fs_1.mkdirSync)((0, node_path_1.join)(demoDir, "src"), { recursive: true });
|
|
9960
|
+
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(demoDir, "src", "auth.ts"), "export function validateToken() { return true; }\n", "utf8");
|
|
9961
|
+
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(demoDir, "src", "payments.ts"), "export function charge() { return 'ok'; }\n", "utf8");
|
|
9962
|
+
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(demoDir, "src", "legacy-retry.ts"), "export function retry() { return 1; }\n", "utf8");
|
|
9963
|
+
ensureMemoryDirs(demoDir);
|
|
9964
|
+
const captured = [];
|
|
9965
|
+
for (const m of [
|
|
9966
|
+
{ title: "Auth uses jose, not jsonwebtoken", body: "Validate tokens with jose in src/auth.ts; jsonwebtoken was removed.", paths: ["src/auth.ts"] },
|
|
9967
|
+
{ title: "Payments must be idempotent", body: "charge() in src/payments.ts must be idempotent to avoid double charges.", paths: ["src/payments.ts"] },
|
|
9968
|
+
{ title: "Legacy retry helper is the fallback", body: "Old retry logic lives in src/legacy-retry.ts and is used as a fallback.", paths: ["src/legacy-retry.ts"] },
|
|
9969
|
+
]) {
|
|
9970
|
+
const r = capture({ projectDir: demoDir, title: m.title, body: m.body, type: "decision", paths: m.paths });
|
|
9971
|
+
if (r.ok && r.packet)
|
|
9972
|
+
captured.push(r.packet.title);
|
|
9973
|
+
}
|
|
9974
|
+
// (2) delete a cited file → that memory becomes stale and is withheld from recall.
|
|
9975
|
+
(0, node_fs_1.unlinkSync)((0, node_path_1.join)(demoDir, "src", "legacy-retry.ts"));
|
|
9976
|
+
// (1) a hallucinated citation is rejected at write time.
|
|
9977
|
+
const hallucinated = capture({
|
|
9978
|
+
projectDir: demoDir,
|
|
9979
|
+
title: "Use the helper in src/ghost.ts",
|
|
9980
|
+
body: "Retry handling lives in src/ghost.ts.",
|
|
9981
|
+
type: "decision",
|
|
9982
|
+
paths: ["src/ghost.ts"],
|
|
9983
|
+
strictCitations: true,
|
|
9984
|
+
});
|
|
9985
|
+
const rejected = hallucinated.ok ? null : { title: "Use the helper in src/ghost.ts", error: hallucinated.errors[0] ?? "rejected" };
|
|
9986
|
+
// (3) recall surfaces grounded memory; the stale one is withheld.
|
|
9987
|
+
const recall = recallWithVectorScores(demoDir, "auth token payments retry idempotency", 5, false, { trackAccess: false });
|
|
9988
|
+
const recalled = recall.results.map((entry) => entry.packet.title);
|
|
9989
|
+
const withheld = kageSuppressedMemory(demoDir).items.map((item) => ({ title: item.title, reason: item.reason }));
|
|
9990
|
+
const trust = benchmarkTrust(demoDir);
|
|
9991
|
+
return {
|
|
9992
|
+
ok: true,
|
|
9993
|
+
project_dir: demoDir,
|
|
9994
|
+
captured,
|
|
9995
|
+
rejected_hallucination: rejected,
|
|
9996
|
+
recalled,
|
|
9997
|
+
withheld,
|
|
9998
|
+
trust_score: trust.trust_score,
|
|
9999
|
+
viewer_command: `kage viewer --project ${demoDir}`,
|
|
10000
|
+
};
|
|
10001
|
+
}
|
|
9577
10002
|
function benchmarkProject(projectDir, inputs = {}) {
|
|
9578
10003
|
ensureMemoryDirs(projectDir);
|
|
9579
10004
|
const built = inputs.codeGraph && inputs.knowledgeGraph ? null : currentOrBuildGraphs(projectDir);
|
|
@@ -10453,6 +10878,9 @@ function learn(input) {
|
|
|
10453
10878
|
paths: input.paths,
|
|
10454
10879
|
stack: input.stack,
|
|
10455
10880
|
context: input.context,
|
|
10881
|
+
allowMissingPaths: input.allowMissingPaths,
|
|
10882
|
+
strictCitations: input.strictCitations,
|
|
10883
|
+
graphNodes: input.graphNodes,
|
|
10456
10884
|
});
|
|
10457
10885
|
}
|
|
10458
10886
|
function capture(input) {
|
|
@@ -10468,7 +10896,34 @@ function capture(input) {
|
|
|
10468
10896
|
errors: [`Sensitive content blocked: ${unique(scanFindings).join(", ")}`],
|
|
10469
10897
|
};
|
|
10470
10898
|
}
|
|
10899
|
+
const warnings = [];
|
|
10900
|
+
const meaningfulPaths = (input.paths ?? [])
|
|
10901
|
+
.filter((path) => path && meaningfulMemoryPath(path) && !shouldSkipRepoMemoryPath(path));
|
|
10902
|
+
const missingPaths = meaningfulPaths.filter((path) => !pathExistsInRepo(input.projectDir, path));
|
|
10903
|
+
// Citation validation. Strict mode (agent-facing record_memory tools / CLI) rejects a
|
|
10904
|
+
// write whose every cited path is missing — the PRD's "reject if citations don't exist".
|
|
10905
|
+
// The core library stays permissive (warn-only) for programmatic callers and migrations.
|
|
10906
|
+
if (input.strictCitations && meaningfulPaths.length && missingPaths.length === meaningfulPaths.length && !input.allowMissingPaths) {
|
|
10907
|
+
return {
|
|
10908
|
+
ok: false,
|
|
10909
|
+
errors: [
|
|
10910
|
+
`Citation validation failed: none of the referenced paths exist in this repo: ${missingPaths.join(", ")}. ` +
|
|
10911
|
+
`Fix the paths, or pass allow_missing_paths to record anyway (e.g. for a file you are about to create).`,
|
|
10912
|
+
],
|
|
10913
|
+
warnings: [],
|
|
10914
|
+
};
|
|
10915
|
+
}
|
|
10916
|
+
if (missingPaths.length) {
|
|
10917
|
+
warnings.push(`Some referenced paths do not exist in this repo: ${missingPaths.join(", ")}`);
|
|
10918
|
+
}
|
|
10471
10919
|
const createdAt = nowIso();
|
|
10920
|
+
// Agent-asserted links to code-graph nodes (PRD `graph_nodes`): the agent recording the
|
|
10921
|
+
// memory knows which symbol/route/file the rule is about, so let it declare them instead
|
|
10922
|
+
// of relying solely on background derivation.
|
|
10923
|
+
const graphEdges = unique(input.graphNodes ?? [])
|
|
10924
|
+
.map((node) => node.trim())
|
|
10925
|
+
.filter(Boolean)
|
|
10926
|
+
.map((node) => ({ relation: "references_code", to: node, evidence: "agent_capture", created_at: createdAt }));
|
|
10472
10927
|
const packet = {
|
|
10473
10928
|
schema_version: exports.PACKET_SCHEMA_VERSION,
|
|
10474
10929
|
id: makePacketId(input.projectDir, type, input.title, String(Date.now())),
|
|
@@ -10510,10 +10965,12 @@ function capture(input) {
|
|
|
10510
10965
|
},
|
|
10511
10966
|
created_at: createdAt,
|
|
10512
10967
|
updated_at: createdAt,
|
|
10968
|
+
author_branch: gitBranch(input.projectDir),
|
|
10513
10969
|
};
|
|
10970
|
+
packet.edges = graphEdges;
|
|
10514
10971
|
const validation = validatePacket(packet);
|
|
10515
10972
|
if (!validation.ok)
|
|
10516
|
-
return { ok: false, errors: validation.errors };
|
|
10973
|
+
return { ok: false, errors: validation.errors, warnings };
|
|
10517
10974
|
packet.quality = {
|
|
10518
10975
|
...packet.quality,
|
|
10519
10976
|
...evaluateMemoryQuality(input.projectDir, packet),
|
|
@@ -10525,7 +10982,7 @@ function capture(input) {
|
|
|
10525
10982
|
path: (0, node_path_1.relative)(input.projectDir, path),
|
|
10526
10983
|
source_kind: packet.source_refs[0]?.kind ?? "explicit_capture",
|
|
10527
10984
|
});
|
|
10528
|
-
return { ok: true, packet, path, errors: [] };
|
|
10985
|
+
return { ok: true, packet, path, errors: [], warnings };
|
|
10529
10986
|
}
|
|
10530
10987
|
function createPublicCandidate(projectDir, id) {
|
|
10531
10988
|
ensureMemoryDirs(projectDir);
|
|
@@ -10854,6 +11311,10 @@ elif event_name == "PreCompact":
|
|
|
10854
11311
|
payload = {"type": "session_end", "summary": "Claude Code is compacting context; distill durable observations before compaction."}
|
|
10855
11312
|
elif event_name == "SessionEnd":
|
|
10856
11313
|
payload = {"type": "session_end", "summary": "Claude Code session ended; distill durable observations for teammate handoff."}
|
|
11314
|
+
elif event_name == "SubagentStop":
|
|
11315
|
+
payload = {"type": "session_end", "summary": "Subagent finished; distill durable observations from the subagent run."}
|
|
11316
|
+
elif event_name == "PreToolUse":
|
|
11317
|
+
payload = {"type": "tool_use", "tool": tool, "path": path, "command": command, "summary": "About to run: " + compact(command or tool or d, 200)}
|
|
10857
11318
|
else:
|
|
10858
11319
|
payload = {"type": "tool_use", "tool": tool, "path": path, "command": command, "summary": compact(d, 320), "text": compact(d)}
|
|
10859
11320
|
|
|
@@ -10865,7 +11326,7 @@ if [[ -n "$OBSERVATION" ]]; then
|
|
|
10865
11326
|
kage observe --project "$CWD" --event "$OBSERVATION" --json >/dev/null 2>&1 || true
|
|
10866
11327
|
fi
|
|
10867
11328
|
|
|
10868
|
-
if [[ "$EVENT" == "PreCompact" || "$EVENT" == "SessionEnd" ]]; then
|
|
11329
|
+
if [[ "$EVENT" == "PreCompact" || "$EVENT" == "SessionEnd" || "$EVENT" == "SubagentStop" ]]; then
|
|
10869
11330
|
kage distill --project "$CWD" --session "$SESSION" --json >/dev/null 2>&1 || true
|
|
10870
11331
|
fi
|
|
10871
11332
|
|
|
@@ -10901,11 +11362,13 @@ exit 0
|
|
|
10901
11362
|
hooks: {
|
|
10902
11363
|
SessionStart: [{ matcher: "", hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/session-start.sh", timeout: 5 }] }],
|
|
10903
11364
|
UserPromptSubmit: [{ hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/observe.sh", timeout: 12 }] }],
|
|
11365
|
+
PreToolUse: [{ matcher: "", hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/observe.sh", timeout: 5 }] }],
|
|
10904
11366
|
PostToolUse: [{ matcher: "", hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/observe.sh", timeout: 5 }] }],
|
|
10905
11367
|
PostToolUseFailure: [{ matcher: "", hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/observe.sh", timeout: 5 }] }],
|
|
10906
11368
|
PreCompact: [{ hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/observe.sh", timeout: 20 }] }],
|
|
10907
11369
|
Stop: [{ matcher: "", hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/stop.sh", timeout: 20 }] }],
|
|
10908
11370
|
SessionEnd: [{ hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/observe.sh", timeout: 20 }] }],
|
|
11371
|
+
SubagentStop: [{ matcher: "", hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/observe.sh", timeout: 20 }] }],
|
|
10909
11372
|
},
|
|
10910
11373
|
};
|
|
10911
11374
|
setSnippet(path, JSON.stringify({ mcpServers: { kage: server } }, null, 2), [
|
|
@@ -12110,12 +12573,65 @@ function regexpEscape(value) {
|
|
|
12110
12573
|
function shellQuote(value) {
|
|
12111
12574
|
return `'${value.replace(/'/g, "'\"'\"'")}'`;
|
|
12112
12575
|
}
|
|
12113
|
-
function gitHookPath(projectDir) {
|
|
12114
|
-
const raw = readGit(projectDir, ["rev-parse", "--git-path",
|
|
12576
|
+
function gitHookPath(projectDir, hookName = "post-commit") {
|
|
12577
|
+
const raw = readGit(projectDir, ["rev-parse", "--git-path", `hooks/${hookName}`]);
|
|
12115
12578
|
if (!raw)
|
|
12116
12579
|
return null;
|
|
12117
12580
|
return (0, node_path_1.resolve)(projectDir, raw);
|
|
12118
12581
|
}
|
|
12582
|
+
// Hooks that fire after history changes underfoot (git pull / merge / checkout):
|
|
12583
|
+
// re-index repo memory so newly pulled teammate packets are immediately recallable.
|
|
12584
|
+
const KAGE_SYNC_HOOKS = ["post-merge", "post-checkout"];
|
|
12585
|
+
function kageSyncHookBlock(projectDir) {
|
|
12586
|
+
const project = shellQuote((0, node_path_1.resolve)(projectDir));
|
|
12587
|
+
return [
|
|
12588
|
+
KAGE_POST_COMMIT_HOOK_START,
|
|
12589
|
+
"# Kage sync hook: re-index repo memory after pull/merge/checkout.",
|
|
12590
|
+
"# Set KAGE_SKIP_HOOK=1 to bypass, or KAGE_BIN=/path/to/kage to override.",
|
|
12591
|
+
"if [ \"${KAGE_SKIP_HOOK:-0}\" != \"1\" ]; then",
|
|
12592
|
+
" KAGE_BIN=\"${KAGE_BIN:-kage}\"",
|
|
12593
|
+
" if command -v \"$KAGE_BIN\" >/dev/null 2>&1; then",
|
|
12594
|
+
" (",
|
|
12595
|
+
` "$KAGE_BIN" index --project ${project} --json >/dev/null 2>&1 || true`,
|
|
12596
|
+
" ) &",
|
|
12597
|
+
" fi",
|
|
12598
|
+
"fi",
|
|
12599
|
+
KAGE_POST_COMMIT_HOOK_END,
|
|
12600
|
+
].join("\n");
|
|
12601
|
+
}
|
|
12602
|
+
function installSyncHooks(projectDir) {
|
|
12603
|
+
const installed = [];
|
|
12604
|
+
for (const hookName of KAGE_SYNC_HOOKS) {
|
|
12605
|
+
const hookPath = gitHookPath(projectDir, hookName);
|
|
12606
|
+
if (!hookPath)
|
|
12607
|
+
continue;
|
|
12608
|
+
ensureDir((0, node_path_1.dirname)(hookPath));
|
|
12609
|
+
const existing = safeReadText(hookPath) ?? "";
|
|
12610
|
+
const base = stripKageHookBlock(existing);
|
|
12611
|
+
const prefix = base.trim() ? base.trimEnd() : "#!/bin/sh";
|
|
12612
|
+
const next = `${prefix}\n\n${kageSyncHookBlock(projectDir)}\n`;
|
|
12613
|
+
if (existing !== next)
|
|
12614
|
+
(0, node_fs_1.writeFileSync)(hookPath, next, "utf8");
|
|
12615
|
+
(0, node_fs_1.chmodSync)(hookPath, 0o755);
|
|
12616
|
+
installed.push(hookPath);
|
|
12617
|
+
}
|
|
12618
|
+
return installed;
|
|
12619
|
+
}
|
|
12620
|
+
function uninstallSyncHooks(projectDir) {
|
|
12621
|
+
const removed = [];
|
|
12622
|
+
for (const hookName of KAGE_SYNC_HOOKS) {
|
|
12623
|
+
const hookPath = gitHookPath(projectDir, hookName);
|
|
12624
|
+
if (!hookPath)
|
|
12625
|
+
continue;
|
|
12626
|
+
const existing = safeReadText(hookPath) ?? "";
|
|
12627
|
+
if (!hasKageHookBlock(existing))
|
|
12628
|
+
continue;
|
|
12629
|
+
(0, node_fs_1.writeFileSync)(hookPath, stripKageHookBlock(existing), "utf8");
|
|
12630
|
+
(0, node_fs_1.chmodSync)(hookPath, 0o755);
|
|
12631
|
+
removed.push(hookPath);
|
|
12632
|
+
}
|
|
12633
|
+
return removed;
|
|
12634
|
+
}
|
|
12119
12635
|
function hasKageHookBlock(content) {
|
|
12120
12636
|
return content.includes(KAGE_POST_COMMIT_HOOK_START) && content.includes(KAGE_POST_COMMIT_HOOK_END);
|
|
12121
12637
|
}
|
|
@@ -12191,20 +12707,24 @@ function kageHookInstall(projectDir) {
|
|
|
12191
12707
|
const base = stripKageHookBlock(existing);
|
|
12192
12708
|
const prefix = base.trim() ? base.trimEnd() : "#!/bin/sh";
|
|
12193
12709
|
const next = `${prefix}\n\n${kagePostCommitHookBlock(projectDir)}\n`;
|
|
12194
|
-
const
|
|
12195
|
-
if (
|
|
12710
|
+
const commitChanged = existing !== next;
|
|
12711
|
+
if (commitChanged)
|
|
12196
12712
|
(0, node_fs_1.writeFileSync)(hookPath, next, "utf8");
|
|
12197
12713
|
(0, node_fs_1.chmodSync)(hookPath, 0o755);
|
|
12714
|
+
const syncHooks = installSyncHooks(projectDir);
|
|
12198
12715
|
return {
|
|
12199
12716
|
ok: true,
|
|
12200
12717
|
action: "install",
|
|
12201
12718
|
project_dir: projectDir,
|
|
12202
12719
|
hook_path: hookPath,
|
|
12203
12720
|
installed: true,
|
|
12204
|
-
changed,
|
|
12205
|
-
message:
|
|
12721
|
+
changed: commitChanged,
|
|
12722
|
+
message: commitChanged
|
|
12723
|
+
? "Installed Kage post-commit hook and pull/merge sync hooks."
|
|
12724
|
+
: "Kage post-commit and sync hooks are already current.",
|
|
12206
12725
|
errors: [],
|
|
12207
12726
|
warnings: [],
|
|
12727
|
+
additional_hooks: syncHooks,
|
|
12208
12728
|
};
|
|
12209
12729
|
}
|
|
12210
12730
|
function kageHookUninstall(projectDir) {
|
|
@@ -12240,6 +12760,7 @@ function kageHookUninstall(projectDir) {
|
|
|
12240
12760
|
const next = stripKageHookBlock(existing);
|
|
12241
12761
|
(0, node_fs_1.writeFileSync)(hookPath, next, "utf8");
|
|
12242
12762
|
(0, node_fs_1.chmodSync)(hookPath, 0o755);
|
|
12763
|
+
const removedSyncHooks = uninstallSyncHooks(projectDir);
|
|
12243
12764
|
return {
|
|
12244
12765
|
ok: true,
|
|
12245
12766
|
action: "uninstall",
|
|
@@ -12247,9 +12768,10 @@ function kageHookUninstall(projectDir) {
|
|
|
12247
12768
|
hook_path: hookPath,
|
|
12248
12769
|
installed: false,
|
|
12249
12770
|
changed: true,
|
|
12250
|
-
message: "Removed Kage post-commit
|
|
12771
|
+
message: "Removed Kage post-commit and sync hooks.",
|
|
12251
12772
|
errors: [],
|
|
12252
12773
|
warnings: [],
|
|
12774
|
+
additional_hooks: removedSyncHooks,
|
|
12253
12775
|
};
|
|
12254
12776
|
}
|
|
12255
12777
|
function exportPublicBundle(projectDir) {
|