@kage-core/kage-graph-mcp 3.1.0 → 3.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/check.js +612 -0
- package/dist/cli.js +44 -1
- package/dist/index.js +32 -7
- package/dist/kernel.js +461 -169
- package/dist/metrics-math.js +71 -0
- package/dist/okf.js +6 -3
- package/package.json +2 -2
- package/viewer/console.js +3 -11
- package/viewer/index.html +1 -3
package/dist/kernel.js
CHANGED
|
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.PACKET_MERGE_DRIVER_CONFIG = exports.PACKET_MERGE_ATTRIBUTE_LINE = exports.AUTO_DISTILL_SIGNAL_THRESHOLD = exports.AUTO_DISTILL_TAG = exports.VALUE_DOLLARS_PER_MILLION_TOKENS = exports.SETUP_AGENTS = exports.MEMORY_TYPES = exports.PACKET_SCHEMA_VERSION = void 0;
|
|
36
|
+
exports.PACKET_MERGE_DRIVER_CONFIG = exports.PACKET_MERGE_ATTRIBUTE_LINE = exports.AUTO_DISTILL_SIGNAL_THRESHOLD = exports.KAGE_HOOKS_VERSION = exports.AUTO_DISTILL_TAG = exports.VALUE_DOLLARS_PER_MILLION_TOKENS = exports.SETUP_AGENTS = exports.MEMORY_TYPES = exports.PACKET_SCHEMA_VERSION = void 0;
|
|
37
37
|
exports.memoryRoot = memoryRoot;
|
|
38
38
|
exports.packetsDir = packetsDir;
|
|
39
39
|
exports.pendingDir = pendingDir;
|
|
@@ -97,11 +97,13 @@ exports.indexProject = indexProject;
|
|
|
97
97
|
exports.refreshProject = refreshProject;
|
|
98
98
|
exports.gcProject = gcProject;
|
|
99
99
|
exports.kageSuppressedMemory = kageSuppressedMemory;
|
|
100
|
+
exports.packetVerificationLabel = packetVerificationLabel;
|
|
100
101
|
exports.verifyCitations = verifyCitations;
|
|
101
102
|
exports.compactProject = compactProject;
|
|
102
103
|
exports.installAgentPolicy = installAgentPolicy;
|
|
103
104
|
exports.createDenseEmbeddingProvider = createDenseEmbeddingProvider;
|
|
104
105
|
exports.buildEmbeddingIndex = buildEmbeddingIndex;
|
|
106
|
+
exports.recallRecencyScore = recallRecencyScore;
|
|
105
107
|
exports.isUngroundedConversationalCapture = isUngroundedConversationalCapture;
|
|
106
108
|
exports.recall = recall;
|
|
107
109
|
exports.recallWithEmbeddings = recallWithEmbeddings;
|
|
@@ -154,6 +156,7 @@ exports.setupAgent = setupAgent;
|
|
|
154
156
|
exports.generatePluginHooks = generatePluginHooks;
|
|
155
157
|
exports.setupDoctor = setupDoctor;
|
|
156
158
|
exports.verifyAgentActivation = verifyAgentActivation;
|
|
159
|
+
exports.pruneObservations = pruneObservations;
|
|
157
160
|
exports.observe = observe;
|
|
158
161
|
exports.observationSignalScore = observationSignalScore;
|
|
159
162
|
exports.kageSessionCaptureReport = kageSessionCaptureReport;
|
|
@@ -209,6 +212,7 @@ const node_worker_threads_1 = require("node:worker_threads");
|
|
|
209
212
|
const ts = __importStar(require("typescript"));
|
|
210
213
|
const index_js_1 = require("./registry/index.js");
|
|
211
214
|
const okf_js_1 = require("./okf.js");
|
|
215
|
+
const metrics_math_js_1 = require("./metrics-math.js");
|
|
212
216
|
exports.PACKET_SCHEMA_VERSION = 2;
|
|
213
217
|
exports.MEMORY_TYPES = [
|
|
214
218
|
"repo_map",
|
|
@@ -293,13 +297,10 @@ Do this without waiting for the user to ask. Kage should feel like ambient repo
|
|
|
293
297
|
If Kage appears installed but no Kage tools are available, report that the active
|
|
294
298
|
agent session has not loaded the MCP server and ask the user to restart the
|
|
295
299
|
agent. After restart, call \`kage_verify_agent\` to prove the harness is live.
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
this session, stale memories withheld). When it is non-trivial, relay it to the
|
|
301
|
-
user in your own words — Kage's value is otherwise invisible, and a user who never
|
|
302
|
-
sees it churns. Repeat only what the tool actually reported; never fabricate numbers.
|
|
300
|
+
Until then, fall back to the memory directly: read the packet files under
|
|
301
|
+
\`.agent_memory/packets/\` — each is a self-describing OKF markdown document
|
|
302
|
+
(verification status in \`x-kage-*\` frontmatter; treat anything not marked
|
|
303
|
+
verified as unconfirmed). No tools are required to read them.
|
|
303
304
|
|
|
304
305
|
## Automatic Capture
|
|
305
306
|
|
|
@@ -375,25 +376,32 @@ For normal coding tasks:
|
|
|
375
376
|
For quick factual questions, \`kage_context\` alone is enough. For status or demo requests, call \`kage_metrics\`.
|
|
376
377
|
${AGENTS_POLICY_END}
|
|
377
378
|
`;
|
|
379
|
+
// Hooks pass raw user prompts as recall queries ("what is X? can we replace it"),
|
|
380
|
+
// so interrogatives, pronouns, and auxiliaries must be stopwords too — otherwise
|
|
381
|
+
// filler words collect BM25/vector/graph credit and drown the query's rare,
|
|
382
|
+
// high-IDF terms (a packet literally titled with the queried term lost to
|
|
383
|
+
// packets matching only "what"/"can"/"we").
|
|
378
384
|
const STOPWORDS = new Set([
|
|
379
|
-
"a",
|
|
380
|
-
"
|
|
381
|
-
"
|
|
382
|
-
"
|
|
383
|
-
"
|
|
384
|
-
"
|
|
385
|
-
"
|
|
386
|
-
"
|
|
387
|
-
"
|
|
388
|
-
"
|
|
389
|
-
"
|
|
390
|
-
"
|
|
391
|
-
"of",
|
|
392
|
-
"
|
|
393
|
-
"
|
|
394
|
-
"the",
|
|
395
|
-
"
|
|
396
|
-
"
|
|
385
|
+
"a", "about", "again", "also", "an", "and", "are", "as", "at",
|
|
386
|
+
"be", "been", "being", "but", "by",
|
|
387
|
+
"can", "could",
|
|
388
|
+
"did", "do", "does",
|
|
389
|
+
"else",
|
|
390
|
+
"for", "from",
|
|
391
|
+
"had", "has", "have", "having", "he", "her", "hers", "here", "him", "his", "how",
|
|
392
|
+
"i", "if", "in", "into", "is", "it", "its",
|
|
393
|
+
"just",
|
|
394
|
+
"let", "lets",
|
|
395
|
+
"may", "me", "might", "mine", "my",
|
|
396
|
+
"no", "not",
|
|
397
|
+
"of", "on", "once", "or", "our", "ours", "over",
|
|
398
|
+
"please",
|
|
399
|
+
"shall", "she", "should", "so",
|
|
400
|
+
"than", "that", "the", "their", "theirs", "them", "then", "there", "these", "they", "this", "those", "to", "too",
|
|
401
|
+
"under", "us",
|
|
402
|
+
"very",
|
|
403
|
+
"was", "we", "were", "what", "when", "where", "which", "who", "whom", "whose", "why", "will", "would", "with",
|
|
404
|
+
"you", "your", "yours",
|
|
397
405
|
]);
|
|
398
406
|
function memoryRoot(projectDir) {
|
|
399
407
|
return (0, node_path_1.join)(projectDir, ".agent_memory");
|
|
@@ -1272,7 +1280,7 @@ function kageFileContext(projectDir, filePath) {
|
|
|
1272
1280
|
const lines = [
|
|
1273
1281
|
`# Kage File Context: ${rel}`,
|
|
1274
1282
|
...verified.flatMap((packet, index) => [
|
|
1275
|
-
`${index + 1}. [${packet.type} |
|
|
1283
|
+
`${index + 1}. [${packet.type} | ${packetVerificationLabel(packet)}] ${packet.title}`,
|
|
1276
1284
|
` ${packet.summary}`,
|
|
1277
1285
|
]),
|
|
1278
1286
|
`_${verified.length} verified memor${verified.length === 1 ? "y" : "ies"} citing this file (citations checked, not stale)._`,
|
|
@@ -1287,6 +1295,9 @@ function kageFileContext(projectDir, filePath) {
|
|
|
1287
1295
|
}));
|
|
1288
1296
|
const replay = replayTokensSaved(verified, result.context_block);
|
|
1289
1297
|
recordValueEvent(projectDir, { kind: "recall_served", tokens_saved: replay, replay_tokens: replay });
|
|
1298
|
+
// File-context serves are real uses — the uses_30d counter read 0 forever
|
|
1299
|
+
// because only recall() counted.
|
|
1300
|
+
recordRecallAccess(projectDir, verified.map((packet) => ({ packet })));
|
|
1290
1301
|
return result;
|
|
1291
1302
|
}
|
|
1292
1303
|
const AUDIT_ACTIVITY_KIND = {
|
|
@@ -1679,6 +1690,32 @@ function identifierTokens(text) {
|
|
|
1679
1690
|
out.add(match[0].toLowerCase());
|
|
1680
1691
|
return out;
|
|
1681
1692
|
}
|
|
1693
|
+
// Anchor candidates must be written AS CODE in the memory text — camelCase,
|
|
1694
|
+
// snake_case, `backticked`, or called() — never plain prose words. Treating
|
|
1695
|
+
// every word as a candidate anchored 90% of packets to tokens like "verified"
|
|
1696
|
+
// and "when" that happened to collide with incidental symbols in the file.
|
|
1697
|
+
function codeAnchorTokens(text) {
|
|
1698
|
+
const out = new Set();
|
|
1699
|
+
const add = (token) => {
|
|
1700
|
+
if (token.length >= 6)
|
|
1701
|
+
out.add(token.toLowerCase());
|
|
1702
|
+
};
|
|
1703
|
+
for (const match of text.matchAll(/`([^`\n]+)`/g)) {
|
|
1704
|
+
for (const token of match[1].matchAll(/[A-Za-z_$][A-Za-z0-9_$]*/g))
|
|
1705
|
+
add(token[0]);
|
|
1706
|
+
}
|
|
1707
|
+
for (const match of text.matchAll(/\b[a-z][a-z0-9]*[A-Z][A-Za-z0-9]*\b/g))
|
|
1708
|
+
add(match[0]);
|
|
1709
|
+
for (const match of text.matchAll(/\b[A-Za-z0-9]+_[A-Za-z0-9_]+\b/g))
|
|
1710
|
+
add(match[0]);
|
|
1711
|
+
for (const match of text.matchAll(/\b([A-Za-z_$][A-Za-z0-9_$]{2,})\(\)/g))
|
|
1712
|
+
add(match[1]);
|
|
1713
|
+
return out;
|
|
1714
|
+
}
|
|
1715
|
+
// Symbol kinds that make meaningful anchors. Constants are allowed only when
|
|
1716
|
+
// the name itself is code-shaped (contains an underscore after lowercasing) —
|
|
1717
|
+
// a constant literally named "verified" is a prose-word collision, not a handle.
|
|
1718
|
+
const ANCHOR_SYMBOL_KINDS = new Set(["function", "class", "method", "interface", "type", "enum"]);
|
|
1682
1719
|
// current-file symbol span hashes, keyed by `${nameLower}\0${kind}` -> [sha256...].
|
|
1683
1720
|
// Cached by mtime+size: extraction only runs when a file actually changed.
|
|
1684
1721
|
const anchorSymbolCache = new Map();
|
|
@@ -1740,7 +1777,7 @@ function fileSymbolSpanHashes(projectDir, path) {
|
|
|
1740
1777
|
// title+summary+body) is supplied, anchor each TS/JS file to the symbols the
|
|
1741
1778
|
// memory actually names, so unrelated edits in the same file do not mark it stale.
|
|
1742
1779
|
function memoryPathFingerprints(projectDir, paths, anchorText) {
|
|
1743
|
-
const idents = anchorText ?
|
|
1780
|
+
const idents = anchorText ? codeAnchorTokens(anchorText) : null;
|
|
1744
1781
|
const fingerprints = [];
|
|
1745
1782
|
for (const path of unique(paths).filter(fingerprintableMemoryPath)) {
|
|
1746
1783
|
const fingerprint = memoryPathFingerprint(projectDir, path);
|
|
@@ -1762,6 +1799,8 @@ function memoryPathFingerprints(projectDir, paths, anchorText) {
|
|
|
1762
1799
|
const [name, kind] = key.split("\0");
|
|
1763
1800
|
if (!idents.has(name) || spanCountByName.get(name) !== 1)
|
|
1764
1801
|
continue;
|
|
1802
|
+
if (!ANCHOR_SYMBOL_KINDS.has(kind) && !name.includes("_"))
|
|
1803
|
+
continue;
|
|
1765
1804
|
symbols.push({ name, kind, sha256: hashes[0] });
|
|
1766
1805
|
}
|
|
1767
1806
|
if (symbols.length) {
|
|
@@ -6949,6 +6988,10 @@ function refreshPacketStaleness(projectDir, options = {}) {
|
|
|
6949
6988
|
let updated = 0;
|
|
6950
6989
|
const fingerprintCache = new Map();
|
|
6951
6990
|
const ignorePatterns = readKageIgnore(projectDir);
|
|
6991
|
+
// Usage telemetry reconciliation: the live counters accumulate in the
|
|
6992
|
+
// machine-local memory-access report; refresh copies them onto the packet so
|
|
6993
|
+
// the committed store carries real usage instead of a hardcoded zero.
|
|
6994
|
+
const accessEntries = readMemoryAccessEntries(projectDir);
|
|
6952
6995
|
for (const entry of loadPacketEntriesFromDir(packetsDir(projectDir))) {
|
|
6953
6996
|
// Drop any .kageignore'd grounding (presentation layers etc.) from the stored packet
|
|
6954
6997
|
// so memory is never anchored to non-knowledge files.
|
|
@@ -6972,6 +7015,15 @@ function refreshPacketStaleness(projectDir, options = {}) {
|
|
|
6972
7015
|
const { stale: _stale, stale_reasons: _staleReasons, suggested_action: _suggestedAction, ...rest } = oldQuality;
|
|
6973
7016
|
nextQuality = rest;
|
|
6974
7017
|
}
|
|
7018
|
+
const access = accessEntries.get(packet.id);
|
|
7019
|
+
if (access && (access.uses_30d !== nextQuality.uses_30d || access.total_uses !== nextQuality.total_uses)) {
|
|
7020
|
+
nextQuality = {
|
|
7021
|
+
...nextQuality,
|
|
7022
|
+
uses_30d: access.uses_30d,
|
|
7023
|
+
total_uses: access.total_uses,
|
|
7024
|
+
...(access.last_accessed_at ? { last_accessed_at: access.last_accessed_at } : {}),
|
|
7025
|
+
};
|
|
7026
|
+
}
|
|
6975
7027
|
const nextFreshness = oldFreshness;
|
|
6976
7028
|
const contentChanged = pruned !== null;
|
|
6977
7029
|
const changed = contentChanged
|
|
@@ -6982,7 +7034,10 @@ function refreshPacketStaleness(projectDir, options = {}) {
|
|
|
6982
7034
|
...packet,
|
|
6983
7035
|
freshness: nextFreshness,
|
|
6984
7036
|
quality: nextQuality,
|
|
6985
|
-
updated_at
|
|
7037
|
+
// updated_at is a CONTENT timestamp. Metadata rewrites (stale flags,
|
|
7038
|
+
// usage counters) bumping it made dead packets look fresh forever:
|
|
7039
|
+
// recency scoring lied and gc retention could never age them out.
|
|
7040
|
+
updated_at: contentChanged ? nowIso() : packet.updated_at,
|
|
6986
7041
|
});
|
|
6987
7042
|
updated += 1;
|
|
6988
7043
|
}
|
|
@@ -7010,6 +7065,7 @@ function refreshProject(projectDir, options = {}) {
|
|
|
7010
7065
|
}
|
|
7011
7066
|
const validation = validateProject(projectDir);
|
|
7012
7067
|
const metrics = kageMetricsShallow(projectDir, { codeGraph, knowledgeGraph, validation });
|
|
7068
|
+
pruneObservations(projectDir);
|
|
7013
7069
|
ensureDir(reportsDir(projectDir));
|
|
7014
7070
|
writeJson((0, node_path_1.join)(reportsDir(projectDir), "context-slots.json"), kageContextSlots(projectDir));
|
|
7015
7071
|
writeJson((0, node_path_1.join)(reportsDir(projectDir), "handoff.json"), kageMemoryHandoff(projectDir));
|
|
@@ -7052,6 +7108,8 @@ function refreshProject(projectDir, options = {}) {
|
|
|
7052
7108
|
next_actions: nextActions,
|
|
7053
7109
|
};
|
|
7054
7110
|
}
|
|
7111
|
+
// How long deprecated/superseded packets stay on disk before gc deletes them.
|
|
7112
|
+
const GC_DEAD_PACKET_RETENTION_DAYS = positiveIntEnv("KAGE_GC_RETENTION_DAYS", 30);
|
|
7055
7113
|
function gcProject(projectDir, options = {}) {
|
|
7056
7114
|
ensureMemoryDirs(projectDir);
|
|
7057
7115
|
const packetEntries = loadPacketEntriesFromDir(packetsDir(projectDir));
|
|
@@ -7059,8 +7117,20 @@ function gcProject(projectDir, options = {}) {
|
|
|
7059
7117
|
const deleted = [];
|
|
7060
7118
|
const skipped = [];
|
|
7061
7119
|
for (const { path, packet } of packetEntries) {
|
|
7062
|
-
if (packet.status === "deprecated") {
|
|
7063
|
-
|
|
7120
|
+
if (packet.status === "deprecated" || packet.status === "superseded") {
|
|
7121
|
+
// Dead packets used to be immortal — 32% of the store was deprecated
|
|
7122
|
+
// weight every teammate cloned forever. Retain briefly for undo, then
|
|
7123
|
+
// delete; the audit trail keeps the tombstone.
|
|
7124
|
+
const stamp = Date.parse(packet.updated_at || packet.created_at || "");
|
|
7125
|
+
const expired = Number.isFinite(stamp) && Date.now() - stamp > GC_DEAD_PACKET_RETENTION_DAYS * 86_400_000;
|
|
7126
|
+
if (expired) {
|
|
7127
|
+
if (!options.dryRun)
|
|
7128
|
+
(0, node_fs_1.unlinkSync)(path);
|
|
7129
|
+
deleted.push({ id: packet.id, title: packet.title });
|
|
7130
|
+
}
|
|
7131
|
+
else {
|
|
7132
|
+
skipped.push({ id: packet.id, title: packet.title, reason: `${packet.status} — retained ${GC_DEAD_PACKET_RETENTION_DAYS}d before deletion` });
|
|
7133
|
+
}
|
|
7064
7134
|
continue;
|
|
7065
7135
|
}
|
|
7066
7136
|
// Serialized transcript / tool-output / file-content dumps and ungrounded conversational
|
|
@@ -7140,6 +7210,18 @@ function kageSuppressedMemory(projectDir) {
|
|
|
7140
7210
|
.filter((entry) => entry !== null);
|
|
7141
7211
|
return { schema_version: 1, generated_at: nowIso(), count: items.length, items };
|
|
7142
7212
|
}
|
|
7213
|
+
// A packet is "verified" only when something actually checked the claim: an
|
|
7214
|
+
// evidence-backed reverification. Capture at birth is provenance, not
|
|
7215
|
+
// verification — packets are born unverified and must earn the label.
|
|
7216
|
+
function packetVerificationLabel(packet) {
|
|
7217
|
+
const quality = (packet.quality ?? {});
|
|
7218
|
+
if (quality.stale === true)
|
|
7219
|
+
return "stale";
|
|
7220
|
+
const freshness = (packet.freshness ?? {});
|
|
7221
|
+
const verification = freshness.verification;
|
|
7222
|
+
const checked = typeof verification === "string" && verification.length > 0 && verification !== "repo_local_agent_capture";
|
|
7223
|
+
return checked && freshness.last_verified_at ? "verified" : "unverified";
|
|
7224
|
+
}
|
|
7143
7225
|
function verifyCitations(projectDir, options = {}) {
|
|
7144
7226
|
ensureMemoryDirs(projectDir);
|
|
7145
7227
|
const approved = loadApprovedPackets(projectDir);
|
|
@@ -7166,13 +7248,17 @@ function verifyCitations(projectDir, options = {}) {
|
|
|
7166
7248
|
stale_reasons: reasons,
|
|
7167
7249
|
};
|
|
7168
7250
|
});
|
|
7251
|
+
const hardStale = packets.filter((entry) => entry.stale_severity === "hard").length;
|
|
7252
|
+
const ungrounded = packets.filter((entry) => !entry.grounded).length;
|
|
7169
7253
|
return {
|
|
7170
|
-
ok
|
|
7254
|
+
// ok used to be hardcoded true, which made `kage verify` a check that
|
|
7255
|
+
// cannot fail. It fails now: hard-stale or ungrounded memory is a defect.
|
|
7256
|
+
ok: hardStale === 0 && ungrounded === 0,
|
|
7171
7257
|
project_dir: projectDir,
|
|
7172
7258
|
checked: packets.length,
|
|
7173
7259
|
valid: packets.filter((entry) => !entry.stale && entry.grounded).length,
|
|
7174
7260
|
stale: packets.filter((entry) => entry.stale).length,
|
|
7175
|
-
ungrounded
|
|
7261
|
+
ungrounded,
|
|
7176
7262
|
packets,
|
|
7177
7263
|
errors: [],
|
|
7178
7264
|
};
|
|
@@ -7925,16 +8011,19 @@ function recallGraphLookup(graph) {
|
|
|
7925
8011
|
}
|
|
7926
8012
|
return { packetEntityByPacketId, edgesByEntityId };
|
|
7927
8013
|
}
|
|
7928
|
-
function recallBreakdown(projectDir, terms, packet, textScore, temporalScore = 0, semanticScore = 0, vectorScore = 0, usageScore = 0, graph = buildKnowledgeGraph(projectDir), lookup = recallGraphLookup(graph)) {
|
|
8014
|
+
function recallBreakdown(projectDir, terms, packet, textScore, temporalScore = 0, semanticScore = 0, vectorScore = 0, usageScore = 0, recencyScore = 0, identifierScore = 0, graph = buildKnowledgeGraph(projectDir), lookup = recallGraphLookup(graph)) {
|
|
7929
8015
|
const packetEntityId = lookup.packetEntityByPacketId.get(packet.id);
|
|
7930
8016
|
const rawGraphScore = packetEntityId
|
|
7931
8017
|
? (lookup.edgesByEntityId.get(packetEntityId) ?? []).reduce((sum, edge) => sum + scoreText(terms, edge.fact), 0)
|
|
7932
8018
|
: 0;
|
|
8019
|
+
// Graph prior at parity with lexical evidence, log1p-damped: raw edge sums
|
|
8020
|
+
// grow with graph density, not with relevance — an old release note with 40
|
|
8021
|
+
// edges must not outscore the packet whose title matches the query.
|
|
7933
8022
|
const graphCap = packet.type === "reference"
|
|
7934
8023
|
? 0
|
|
7935
|
-
: (textScore > 0 ? textScore
|
|
8024
|
+
: (textScore > 0 ? Math.min(textScore, 8) : 4);
|
|
7936
8025
|
const graphWeight = packet.type === "reference" ? 0 : 0.45;
|
|
7937
|
-
const graphScore = Math.min(rawGraphScore * graphWeight, graphCap);
|
|
8026
|
+
const graphScore = Math.min(Math.log1p(rawGraphScore * graphWeight) * 3, graphCap);
|
|
7938
8027
|
const pathTypeTag = scoreText(terms, `${packet.type} ${packet.tags.join(" ")} ${packet.paths.join(" ")}`, [packet.type, ...packet.tags, ...packet.paths]);
|
|
7939
8028
|
const intent = recallIntentBoost(terms, packet);
|
|
7940
8029
|
const freshness = packet.status === "approved" ? 2 : packet.status === "pending" ? 0 : -5;
|
|
@@ -7943,12 +8032,20 @@ function recallBreakdown(projectDir, terms, packet, textScore, temporalScore = 0
|
|
|
7943
8032
|
const vector = Number(vectorScore.toFixed(2));
|
|
7944
8033
|
const usage = Number(usageScore.toFixed(2));
|
|
7945
8034
|
const pathTypeTagWeight = packet.type === "reference" ? 0.2 : 0.8;
|
|
7946
|
-
//
|
|
7947
|
-
// lexical/semantic/graph/intent match to the top — that is what
|
|
7948
|
-
// off-domain junk
|
|
7949
|
-
|
|
8035
|
+
// Priors (usage, quality, freshness) must only AMPLIFY genuine relevance, never float a
|
|
8036
|
+
// packet that has no lexical/semantic/graph/tag/intent match to the top — that is what
|
|
8037
|
+
// produced confident off-domain junk: a hot, high-quality packet ranked above the one
|
|
8038
|
+
// packet whose title literally contained the queried term, because its unconditional
|
|
8039
|
+
// quality+freshness (~12 pts) beat a weak-but-real lexical match.
|
|
8040
|
+
const coreRelevance = textScore + graphScore + intent + vector + identifierScore;
|
|
8041
|
+
const matchSignal = coreRelevance + pathTypeTag;
|
|
7950
8042
|
const effectiveUsage = coreRelevance > 0 ? usage : 0;
|
|
7951
|
-
const
|
|
8043
|
+
const effectiveQuality = matchSignal > 0 ? quality : 0;
|
|
8044
|
+
const effectiveFreshness = matchSignal > 0 ? freshness : Math.min(freshness, 0);
|
|
8045
|
+
// Recency amplifies matches and sinks aged changelog-shaped memory; like
|
|
8046
|
+
// freshness, the positive side never floats a non-match.
|
|
8047
|
+
const effectiveRecency = matchSignal > 0 ? recencyScore : Math.min(recencyScore, 0);
|
|
8048
|
+
const final = Number((textScore + graphScore + pathTypeTag * pathTypeTagWeight + intent + vector + identifierScore + effectiveUsage + effectiveFreshness + effectiveRecency + effectiveQuality + feedback).toFixed(2));
|
|
7952
8049
|
return {
|
|
7953
8050
|
bm25: textScore,
|
|
7954
8051
|
text: textScore,
|
|
@@ -7960,11 +8057,31 @@ function recallBreakdown(projectDir, terms, packet, textScore, temporalScore = 0
|
|
|
7960
8057
|
vector,
|
|
7961
8058
|
usage,
|
|
7962
8059
|
freshness,
|
|
8060
|
+
recency: Number(effectiveRecency.toFixed(2)),
|
|
8061
|
+
identifier: Number(identifierScore.toFixed(2)),
|
|
7963
8062
|
quality: Number(quality.toFixed(2)),
|
|
7964
8063
|
feedback,
|
|
7965
8064
|
final,
|
|
7966
8065
|
};
|
|
7967
8066
|
}
|
|
8067
|
+
// Changelog-shaped memory (decisions, fixes, change summaries) ages fast — a
|
|
8068
|
+
// four-month-old release note outranking the current runbook was the headline
|
|
8069
|
+
// ranking bug. Evergreen types (runbooks, conventions, gotchas) keep the boost
|
|
8070
|
+
// window but never take the penalty.
|
|
8071
|
+
const RECENCY_FAST_TYPES = new Set(["decision", "bug_fix", "workflow", "reference", "issue_context"]);
|
|
8072
|
+
function recallRecencyScore(packet) {
|
|
8073
|
+
const stamp = Date.parse(packet.updated_at || packet.created_at || "");
|
|
8074
|
+
if (!Number.isFinite(stamp))
|
|
8075
|
+
return 0;
|
|
8076
|
+
const days = (Date.now() - stamp) / 86_400_000;
|
|
8077
|
+
if (days <= 14)
|
|
8078
|
+
return 3;
|
|
8079
|
+
if (days <= 60)
|
|
8080
|
+
return 1;
|
|
8081
|
+
if (days <= 120)
|
|
8082
|
+
return 0;
|
|
8083
|
+
return RECENCY_FAST_TYPES.has(packet.type) ? -3 : 0;
|
|
8084
|
+
}
|
|
7968
8085
|
function recallDiversitySource(packet) {
|
|
7969
8086
|
for (const ref of packet.source_refs) {
|
|
7970
8087
|
if (ref.kind === "observation_session" && typeof ref.session_id === "string" && ref.session_id.trim()) {
|
|
@@ -8006,6 +8123,11 @@ function diversifyRecallEntries(entries, limit, maxPerSource = 3) {
|
|
|
8006
8123
|
function isSerializedDumpTitle(title) {
|
|
8007
8124
|
const t = (title ?? "").trimStart();
|
|
8008
8125
|
return /^(workflow|runbook)\s*:?\s*[{[]/i.test(t)
|
|
8126
|
+
// "Runbook: Tool failed: {...}" evaded the brace check above because prose
|
|
8127
|
+
// sits between the label and the payload; braces early in a title are a
|
|
8128
|
+
// dump signature regardless of what precedes them.
|
|
8129
|
+
|| /^(workflow|runbook)\s*:.{0,40}[{[]/i.test(t)
|
|
8130
|
+
|| /^(workflow|runbook)\s*:\s*tool failed/i.test(t)
|
|
8009
8131
|
|| t.startsWith('{"')
|
|
8010
8132
|
|| /^<(task-notification|div|svg|html)\b/i.test(t)
|
|
8011
8133
|
|| /\btool_use_id\b|toolu_[A-Za-z0-9]{10}/.test(title)
|
|
@@ -8160,6 +8282,22 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
|
|
|
8160
8282
|
const referenceBodyScores = scoreReferenceBodyBm25(terms, approvedPackets);
|
|
8161
8283
|
const accessEntries = readMemoryAccessEntries(projectDir, approvedPackets);
|
|
8162
8284
|
const graphLookup = recallGraphLookup(knowledgeGraph);
|
|
8285
|
+
// Terse identifier queries ("recallBreakdown") often share no prose with the
|
|
8286
|
+
// packet that documents them; ground them through the code graph instead — a
|
|
8287
|
+
// packet citing the file that defines the queried identifier is evidence.
|
|
8288
|
+
const identifierTerms = unique((query.match(/[A-Za-z_][A-Za-z0-9_]{5,}/g) ?? []).filter((token) => /[a-z][A-Z]|_/.test(token)));
|
|
8289
|
+
let identifierFiles = null;
|
|
8290
|
+
if (identifierTerms.length) {
|
|
8291
|
+
try {
|
|
8292
|
+
const wanted = new Set(identifierTerms.map((token) => token.toLowerCase()));
|
|
8293
|
+
identifierFiles = new Set(buildCodeGraph(projectDir).symbols
|
|
8294
|
+
.filter((symbol) => wanted.has(symbol.name.toLowerCase()))
|
|
8295
|
+
.map((symbol) => symbol.path));
|
|
8296
|
+
}
|
|
8297
|
+
catch {
|
|
8298
|
+
identifierFiles = null;
|
|
8299
|
+
}
|
|
8300
|
+
}
|
|
8163
8301
|
const rankedScored = approvedPackets
|
|
8164
8302
|
.map((packet) => {
|
|
8165
8303
|
const base = baseScores.get(packet.id) ?? { score: 0, why: [] };
|
|
@@ -8170,8 +8308,10 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
|
|
|
8170
8308
|
const lexicalScore = base.score + temporal.score + semantic.score;
|
|
8171
8309
|
const textScore = packet.type === "reference" ? Math.max(lexicalScore, referenceBodyScore) : lexicalScore;
|
|
8172
8310
|
const usageScore = memoryAccessScore(accessEntries.get(packet.id));
|
|
8173
|
-
const
|
|
8174
|
-
const
|
|
8311
|
+
const identifierScore = identifierFiles && identifierFiles.size && packet.paths.some((path) => identifierFiles.has(path)) ? 6 : 0;
|
|
8312
|
+
const recencyScore = recallRecencyScore(packet);
|
|
8313
|
+
const score_breakdown = recallBreakdown(projectDir, terms, packet, textScore, temporal.score, semantic.score, vector.score, usageScore, recencyScore, identifierScore, knowledgeGraph, graphLookup);
|
|
8314
|
+
const relevance = textScore + score_breakdown.graph + score_breakdown.path_type_tag + score_breakdown.intent + score_breakdown.vector + score_breakdown.identifier;
|
|
8175
8315
|
const why = [
|
|
8176
8316
|
...base.why,
|
|
8177
8317
|
...temporal.why.map((item) => `temporal:${item}`),
|
|
@@ -8179,6 +8319,7 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
|
|
|
8179
8319
|
...(semantic.score > 0 ? expansion.semanticLabels.map((label) => `semantic-concept:${label}`) : []),
|
|
8180
8320
|
...vector.why,
|
|
8181
8321
|
...(usageScore > 0 ? [`usage:${accessEntries.get(packet.id)?.uses_30d ?? 0} recalls in 30d`] : []),
|
|
8322
|
+
...(identifierScore > 0 ? ["identifier: query names a symbol defined in a cited file"] : []),
|
|
8182
8323
|
];
|
|
8183
8324
|
return { packet, score: score_breakdown.final, relevance, why_matched: unique(why).slice(0, 12), score_breakdown };
|
|
8184
8325
|
})
|
|
@@ -8262,7 +8403,7 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
|
|
|
8262
8403
|
pendingScored.length ? "## Working Memory (Pending Review)" : "",
|
|
8263
8404
|
...pendingScored.flatMap((entry, index) => [
|
|
8264
8405
|
"",
|
|
8265
|
-
`${index + 1}. [${entry.packet.type} | pending |
|
|
8406
|
+
`${index + 1}. [${entry.packet.type} | pending | unreviewed draft] ${entry.packet.title}`,
|
|
8266
8407
|
` Summary: ${entry.packet.summary}`,
|
|
8267
8408
|
` Why matched: ${entry.why_matched.join(", ") || "text relevance"}`,
|
|
8268
8409
|
` Source: pending packet; unapproved local/session memory`,
|
|
@@ -8285,7 +8426,7 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
|
|
|
8285
8426
|
"_Cross-machine personal store (~/.kage/memory). Lower trust than repo memory: not repo-reviewed — verify before relying on it. Repo memory above takes precedence on conflict._",
|
|
8286
8427
|
...personalEntries.flatMap((entry, index) => [
|
|
8287
8428
|
"",
|
|
8288
|
-
`${index + 1}. [personal] [${entry.packet.type} |
|
|
8429
|
+
`${index + 1}. [personal] [${entry.packet.type} | ${packetVerificationLabel(entry.packet)}] ${entry.packet.title}`,
|
|
8289
8430
|
` [personal] Summary: ${entry.packet.summary}`,
|
|
8290
8431
|
` [personal] Why matched: ${entry.why_matched.join(", ") || "text relevance"}`,
|
|
8291
8432
|
` [personal] Verification: ${entry.unverifiable ? "unverifiable (citation-free personal note)" : "citations re-verified against this checkout"}`,
|
|
@@ -10072,9 +10213,9 @@ function truthReport(projectDir) {
|
|
|
10072
10213
|
findings,
|
|
10073
10214
|
warnings,
|
|
10074
10215
|
next_actions: [
|
|
10075
|
-
"
|
|
10076
|
-
"
|
|
10077
|
-
"
|
|
10216
|
+
"kage check --project . verify CLAUDE.md/AGENTS.md/docs claims against this code — counted, not estimated",
|
|
10217
|
+
"kage check --init-ci gate every PR: fail only when a diff breaks a documented claim",
|
|
10218
|
+
"npx -y @kage-core/kage-graph-mcp install wire repo memory + agents (Claude Code, Codex, Cursor, ...)",
|
|
10078
10219
|
],
|
|
10079
10220
|
};
|
|
10080
10221
|
}
|
|
@@ -12987,16 +13128,16 @@ function benchmarkCodingMemoryQuality(options = {}) {
|
|
|
12987
13128
|
retrieved,
|
|
12988
13129
|
latency_ms: latencyMs,
|
|
12989
13130
|
context_tokens: estimateTokens(recalled.context_block),
|
|
12990
|
-
recall: Object.fromEntries(metricsK.map((k) => [`at_${k}`, roundDecimal(codingRecallAt(retrieved, relevant, k) * 100, 2)])),
|
|
12991
|
-
precision_at_5_percent: roundDecimal(codingPrecisionAt(retrieved, relevant, 5) * 100, 2),
|
|
12992
|
-
ndcg_at_10: roundDecimal(codingNdcgAt(retrieved, relevant, 10), 4),
|
|
12993
|
-
mrr: roundDecimal(codingMrr(retrieved, relevant), 4),
|
|
13131
|
+
recall: Object.fromEntries(metricsK.map((k) => [`at_${k}`, (0, metrics_math_js_1.roundDecimal)((0, metrics_math_js_1.codingRecallAt)(retrieved, relevant, k) * 100, 2)])),
|
|
13132
|
+
precision_at_5_percent: (0, metrics_math_js_1.roundDecimal)((0, metrics_math_js_1.codingPrecisionAt)(retrieved, relevant, 5) * 100, 2),
|
|
13133
|
+
ndcg_at_10: (0, metrics_math_js_1.roundDecimal)((0, metrics_math_js_1.codingNdcgAt)(retrieved, relevant, 10), 4),
|
|
13134
|
+
mrr: (0, metrics_math_js_1.roundDecimal)((0, metrics_math_js_1.codingMrr)(retrieved, relevant), 4),
|
|
12994
13135
|
};
|
|
12995
13136
|
});
|
|
12996
13137
|
const sourceDiversity = codingMemorySourceDiversityProbe((0, node_path_1.join)(runDir, "source-diversity"));
|
|
12997
13138
|
const allMemoryTokens = estimateTokens(loadApprovedPackets(projectDir).map(packetText).join("\n\n"));
|
|
12998
|
-
const averageContextTokens = Math.round(averageNumber(perQuery.map((item) => item.context_tokens)));
|
|
12999
|
-
const recallByK = Object.fromEntries(metricsK.map((k) => [`recall_at_${k}_percent`, roundDecimal(averageNumber(perQuery.map((item) => item.recall[`at_${k}`] ?? 0)), 2)]));
|
|
13139
|
+
const averageContextTokens = Math.round((0, metrics_math_js_1.averageNumber)(perQuery.map((item) => item.context_tokens)));
|
|
13140
|
+
const recallByK = Object.fromEntries(metricsK.map((k) => [`recall_at_${k}_percent`, (0, metrics_math_js_1.roundDecimal)((0, metrics_math_js_1.averageNumber)(perQuery.map((item) => item.recall[`at_${k}`] ?? 0)), 2)]));
|
|
13000
13141
|
const summary = {
|
|
13001
13142
|
benchmark: "Kage coding memory quality",
|
|
13002
13143
|
retrieval_mode: "kage-recall-default",
|
|
@@ -13006,14 +13147,14 @@ function benchmarkCodingMemoryQuality(options = {}) {
|
|
|
13006
13147
|
refresh_ms: refreshMs,
|
|
13007
13148
|
...recallByK,
|
|
13008
13149
|
recall_at_k_percent: Number(recallByK[`recall_at_${topK}_percent`] ?? 0),
|
|
13009
|
-
precision_at_5_percent: roundDecimal(averageNumber(perQuery.map((item) => item.precision_at_5_percent)), 2),
|
|
13010
|
-
ndcg_at_10: roundDecimal(averageNumber(perQuery.map((item) => item.ndcg_at_10)), 4),
|
|
13011
|
-
mrr: roundDecimal(averageNumber(perQuery.map((item) => item.mrr)), 4),
|
|
13012
|
-
median_latency_ms: percentileNumber(perQuery.map((item) => item.latency_ms), 0.5),
|
|
13013
|
-
p95_latency_ms: percentileNumber(perQuery.map((item) => item.latency_ms), 0.95),
|
|
13150
|
+
precision_at_5_percent: (0, metrics_math_js_1.roundDecimal)((0, metrics_math_js_1.averageNumber)(perQuery.map((item) => item.precision_at_5_percent)), 2),
|
|
13151
|
+
ndcg_at_10: (0, metrics_math_js_1.roundDecimal)((0, metrics_math_js_1.averageNumber)(perQuery.map((item) => item.ndcg_at_10)), 4),
|
|
13152
|
+
mrr: (0, metrics_math_js_1.roundDecimal)((0, metrics_math_js_1.averageNumber)(perQuery.map((item) => item.mrr)), 4),
|
|
13153
|
+
median_latency_ms: (0, metrics_math_js_1.percentileNumber)(perQuery.map((item) => item.latency_ms), 0.5),
|
|
13154
|
+
p95_latency_ms: (0, metrics_math_js_1.percentileNumber)(perQuery.map((item) => item.latency_ms), 0.95),
|
|
13014
13155
|
all_memory_tokens: allMemoryTokens,
|
|
13015
13156
|
average_context_tokens: averageContextTokens,
|
|
13016
|
-
context_reduction_percent: roundDecimal(((allMemoryTokens - averageContextTokens) / Math.max(1, allMemoryTokens)) * 100, 2),
|
|
13157
|
+
context_reduction_percent: (0, metrics_math_js_1.roundDecimal)(((allMemoryTokens - averageContextTokens) / Math.max(1, allMemoryTokens)) * 100, 2),
|
|
13017
13158
|
source_diversity_pass: sourceDiversity.pass,
|
|
13018
13159
|
source_diversity_unique_sources: sourceDiversity.unique_sources,
|
|
13019
13160
|
source_diversity_max_results_from_one_source: sourceDiversity.max_results_from_one_source,
|
|
@@ -13027,7 +13168,7 @@ function benchmarkCodingMemoryQuality(options = {}) {
|
|
|
13027
13168
|
queries: queries.length,
|
|
13028
13169
|
packets_per_topic: packetsPerTopic,
|
|
13029
13170
|
distractors_per_topic: distractorsPerTopic,
|
|
13030
|
-
categories: countByKey(queries, (item) => item.category),
|
|
13171
|
+
categories: (0, metrics_math_js_1.countByKey)(queries, (item) => item.category),
|
|
13031
13172
|
},
|
|
13032
13173
|
top_k: topK,
|
|
13033
13174
|
metrics_k: metricsK,
|
|
@@ -13120,7 +13261,7 @@ function codingMemorySourceDiversityProbe(projectDir) {
|
|
|
13120
13261
|
source,
|
|
13121
13262
|
};
|
|
13122
13263
|
});
|
|
13123
|
-
const sourceCounts = countByKey(retrieved, (item) => item.source);
|
|
13264
|
+
const sourceCounts = (0, metrics_math_js_1.countByKey)(retrieved, (item) => item.source);
|
|
13124
13265
|
const maxResultsFromOneSource = Math.max(0, ...Object.values(sourceCounts));
|
|
13125
13266
|
const independentRank = retrieved.find((item) => item.source === "session:independent-session")?.rank ?? null;
|
|
13126
13267
|
return {
|
|
@@ -13178,16 +13319,16 @@ function benchmarkMemoryScale(options = {}) {
|
|
|
13178
13319
|
};
|
|
13179
13320
|
});
|
|
13180
13321
|
const allMemoryTokens = estimateTokens(loadApprovedPackets(projectDir).map(packetText).join("\n\n"));
|
|
13181
|
-
const averageContextTokens = Math.round(averageNumber(queries.map((item) => item.context_tokens)));
|
|
13322
|
+
const averageContextTokens = Math.round((0, metrics_math_js_1.averageNumber)(queries.map((item) => item.context_tokens)));
|
|
13182
13323
|
results.push({
|
|
13183
13324
|
packets: size,
|
|
13184
13325
|
refresh_ms: refreshMs,
|
|
13185
|
-
recall_hit_rate_percent: roundDecimal((queries.filter((item) => item.hit).length / queries.length) * 100, 2),
|
|
13186
|
-
median_recall_latency_ms: percentileNumber(queries.map((item) => item.latency_ms), 0.5),
|
|
13187
|
-
p95_recall_latency_ms: percentileNumber(queries.map((item) => item.latency_ms), 0.95),
|
|
13326
|
+
recall_hit_rate_percent: (0, metrics_math_js_1.roundDecimal)((queries.filter((item) => item.hit).length / queries.length) * 100, 2),
|
|
13327
|
+
median_recall_latency_ms: (0, metrics_math_js_1.percentileNumber)(queries.map((item) => item.latency_ms), 0.5),
|
|
13328
|
+
p95_recall_latency_ms: (0, metrics_math_js_1.percentileNumber)(queries.map((item) => item.latency_ms), 0.95),
|
|
13188
13329
|
all_memory_tokens: allMemoryTokens,
|
|
13189
13330
|
average_context_tokens: averageContextTokens,
|
|
13190
|
-
context_reduction_percent: roundDecimal(((allMemoryTokens - averageContextTokens) / Math.max(1, allMemoryTokens)) * 100, 2),
|
|
13331
|
+
context_reduction_percent: (0, metrics_math_js_1.roundDecimal)(((allMemoryTokens - averageContextTokens) / Math.max(1, allMemoryTokens)) * 100, 2),
|
|
13191
13332
|
queries,
|
|
13192
13333
|
});
|
|
13193
13334
|
}
|
|
@@ -13319,7 +13460,7 @@ function codingObservation(index, item, target, variant) {
|
|
|
13319
13460
|
topic: item.id,
|
|
13320
13461
|
target,
|
|
13321
13462
|
category: item.category,
|
|
13322
|
-
title: `${titleCase(item.query)} repo memory ${variant + 1}`,
|
|
13463
|
+
title: `${(0, metrics_math_js_1.titleCase)(item.query)} repo memory ${variant + 1}`,
|
|
13323
13464
|
summary: `Reusable learning about ${item.query}: ${item.lesson}`,
|
|
13324
13465
|
body: [
|
|
13325
13466
|
`During a real agent session, this durable repo learning was captured for ${item.query}.`,
|
|
@@ -13404,32 +13545,11 @@ function codingQualityByCategory(perQuery, metricsK) {
|
|
|
13404
13545
|
return Array.from(groups.entries()).map(([category, rows]) => ({
|
|
13405
13546
|
category,
|
|
13406
13547
|
queries: rows.length,
|
|
13407
|
-
...Object.fromEntries(metricsK.map((k) => [`recall_at_${k}_percent`, roundDecimal(averageNumber(rows.map((item) => item.recall[`at_${k}`] ?? 0)), 2)])),
|
|
13408
|
-
ndcg_at_10: roundDecimal(averageNumber(rows.map((item) => item.ndcg_at_10)), 4),
|
|
13409
|
-
mrr: roundDecimal(averageNumber(rows.map((item) => item.mrr)), 4),
|
|
13548
|
+
...Object.fromEntries(metricsK.map((k) => [`recall_at_${k}_percent`, (0, metrics_math_js_1.roundDecimal)((0, metrics_math_js_1.averageNumber)(rows.map((item) => item.recall[`at_${k}`] ?? 0)), 2)])),
|
|
13549
|
+
ndcg_at_10: (0, metrics_math_js_1.roundDecimal)((0, metrics_math_js_1.averageNumber)(rows.map((item) => item.ndcg_at_10)), 4),
|
|
13550
|
+
mrr: (0, metrics_math_js_1.roundDecimal)((0, metrics_math_js_1.averageNumber)(rows.map((item) => item.mrr)), 4),
|
|
13410
13551
|
}));
|
|
13411
13552
|
}
|
|
13412
|
-
function codingRecallAt(retrieved, relevant, k) {
|
|
13413
|
-
if (!relevant.size)
|
|
13414
|
-
return 0;
|
|
13415
|
-
return retrieved.slice(0, k).filter((item) => relevant.has(item.packet_id)).length / relevant.size;
|
|
13416
|
-
}
|
|
13417
|
-
function codingPrecisionAt(retrieved, relevant, k) {
|
|
13418
|
-
const rows = retrieved.slice(0, k);
|
|
13419
|
-
return rows.length ? rows.filter((item) => relevant.has(item.packet_id)).length / rows.length : 0;
|
|
13420
|
-
}
|
|
13421
|
-
function codingNdcgAt(retrieved, relevant, k) {
|
|
13422
|
-
const dcg = retrieved.slice(0, k).reduce((sum, item, index) => sum + (relevant.has(item.packet_id) ? 1 / Math.log2(index + 2) : 0), 0);
|
|
13423
|
-
const idealHits = Math.min(relevant.size, k);
|
|
13424
|
-
let ideal = 0;
|
|
13425
|
-
for (let index = 0; index < idealHits; index += 1)
|
|
13426
|
-
ideal += 1 / Math.log2(index + 2);
|
|
13427
|
-
return ideal ? dcg / ideal : 0;
|
|
13428
|
-
}
|
|
13429
|
-
function codingMrr(retrieved, relevant) {
|
|
13430
|
-
const index = retrieved.findIndex((item) => relevant.has(item.packet_id));
|
|
13431
|
-
return index >= 0 ? 1 / (index + 1) : 0;
|
|
13432
|
-
}
|
|
13433
13553
|
function codingTypeForCategory(category) {
|
|
13434
13554
|
if (category === "runbook")
|
|
13435
13555
|
return "runbook";
|
|
@@ -13442,31 +13562,6 @@ function codingTypeForCategory(category) {
|
|
|
13442
13562
|
function codingFileForTopic(topic, variant) {
|
|
13443
13563
|
return `src/${slugify(topic)}-${variant % 3}.ts`;
|
|
13444
13564
|
}
|
|
13445
|
-
function averageNumber(values) {
|
|
13446
|
-
return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : 0;
|
|
13447
|
-
}
|
|
13448
|
-
function percentileNumber(values, p) {
|
|
13449
|
-
if (!values.length)
|
|
13450
|
-
return 0;
|
|
13451
|
-
const sorted = values.slice().sort((a, b) => a - b);
|
|
13452
|
-
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * p) - 1));
|
|
13453
|
-
return sorted[index];
|
|
13454
|
-
}
|
|
13455
|
-
function roundDecimal(value, digits = 2) {
|
|
13456
|
-
const factor = 10 ** digits;
|
|
13457
|
-
return Math.round(value * factor) / factor;
|
|
13458
|
-
}
|
|
13459
|
-
function countByKey(rows, fn) {
|
|
13460
|
-
const counts = {};
|
|
13461
|
-
for (const row of rows) {
|
|
13462
|
-
const key = fn(row);
|
|
13463
|
-
counts[key] = (counts[key] ?? 0) + 1;
|
|
13464
|
-
}
|
|
13465
|
-
return counts;
|
|
13466
|
-
}
|
|
13467
|
-
function titleCase(value) {
|
|
13468
|
-
return value.replace(/\b[a-z]/g, (match) => match.toUpperCase());
|
|
13469
|
-
}
|
|
13470
13565
|
function baselineDiscoveryFiles(projectDir, task) {
|
|
13471
13566
|
const terms = tokenize(task);
|
|
13472
13567
|
const graph = buildCodeGraph(projectDir);
|
|
@@ -14131,6 +14226,24 @@ function setupAgent(agent, projectDir, options = {}) {
|
|
|
14131
14226
|
const path = (0, node_path_1.join)(home, ".claude.json");
|
|
14132
14227
|
const server = { type: "stdio", command: serverCommand, args: serverArgs, alwaysLoad: true };
|
|
14133
14228
|
const hookDir = (0, node_path_1.join)(home, ".claude", "kage", "hooks");
|
|
14229
|
+
// The hooks used to die on `command -v kage || exit 0`: an npx install puts
|
|
14230
|
+
// nothing on PATH, so every ambient hook silently no-oped for new users.
|
|
14231
|
+
// Resolve the CLI the same way the MCP server config does — PATH first
|
|
14232
|
+
// (fast), then the install-time cli.js (guarded by -f so npx cache pruning
|
|
14233
|
+
// degrades gracefully), then the package runner. The loop never silently dies.
|
|
14234
|
+
// portableHooks (plugin generation): the scripts are committed and shared,
|
|
14235
|
+
// so no machine-specific path may be baked in — PATH then package runner.
|
|
14236
|
+
const hookCliPath = options.portableHooks ? "" : (0, node_path_1.join)((0, node_path_1.dirname)(serverPath), "cli.js");
|
|
14237
|
+
const hookKageResolve = `# kage-hooks-v${exports.KAGE_HOOKS_VERSION}
|
|
14238
|
+
# Resolve the kage CLI: repo-local, PATH${hookCliPath ? ", baked install path" : ""}, then the package runner.
|
|
14239
|
+
export PATH="$CWD/node_modules/.bin:$PATH"
|
|
14240
|
+
if command -v kage >/dev/null 2>&1; then
|
|
14241
|
+
:
|
|
14242
|
+
${hookCliPath ? `elif [[ -f "${hookCliPath}" ]] && command -v node >/dev/null 2>&1; then
|
|
14243
|
+
kage() { node "${hookCliPath}" "$@"; }
|
|
14244
|
+
` : ""}else
|
|
14245
|
+
kage() { npx -y --package=@kage-core/kage-graph-mcp kage "$@"; }
|
|
14246
|
+
fi`;
|
|
14134
14247
|
const hookScript = `#!/usr/bin/env bash
|
|
14135
14248
|
# Kage SessionStart hook — injects full memory policy as a system message.
|
|
14136
14249
|
# Silent if Kage is not initialized in the current project.
|
|
@@ -14163,6 +14276,7 @@ Before finishing a task that changed files: kage_pr_summarize or kage_propose_fr
|
|
|
14163
14276
|
If recalled memory helped: kage_feedback helpful. If wrong or stale: kage_feedback wrong or stale."
|
|
14164
14277
|
fi
|
|
14165
14278
|
|
|
14279
|
+
${hookKageResolve}
|
|
14166
14280
|
# Session continuity: append a compact "previously…" digest when prior session data exists.
|
|
14167
14281
|
if command -v kage >/dev/null 2>&1; then
|
|
14168
14282
|
PREVIOUSLY="$(kage resume --project "$CWD" 2>/dev/null || true)"
|
|
@@ -14184,8 +14298,7 @@ PAYLOAD="$(cat || true)"
|
|
|
14184
14298
|
CWD="$(printf "%s" "$PAYLOAD" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('cwd',''))" 2>/dev/null || echo "")"
|
|
14185
14299
|
|
|
14186
14300
|
[[ -d "$CWD/.agent_memory" ]] || exit 0
|
|
14187
|
-
|
|
14188
|
-
export PATH="$CWD/node_modules/.bin:$PATH"
|
|
14301
|
+
${hookKageResolve}
|
|
14189
14302
|
command -v kage >/dev/null 2>&1 || exit 0
|
|
14190
14303
|
|
|
14191
14304
|
if git -C "$CWD" status --porcelain -uall >/dev/null 2>&1 && [[ -n "$(git -C "$CWD" status --porcelain -uall)" ]]; then
|
|
@@ -14242,8 +14355,7 @@ print(d.get("cwd") or os.environ.get("CLAUDE_PROJECT_DIR") or "")
|
|
|
14242
14355
|
' 2>/dev/null || echo "")"
|
|
14243
14356
|
|
|
14244
14357
|
[[ -d "$CWD/.agent_memory" ]] || exit 0
|
|
14245
|
-
|
|
14246
|
-
export PATH="$CWD/node_modules/.bin:$PATH"
|
|
14358
|
+
${hookKageResolve}
|
|
14247
14359
|
command -v kage >/dev/null 2>&1 || exit 0
|
|
14248
14360
|
|
|
14249
14361
|
EVENT="$(PAYLOAD="$PAYLOAD" python3 -c 'import json, os
|
|
@@ -14293,17 +14405,53 @@ tool_response = d.get("tool_response") or d.get("toolResponse") or d.get("result
|
|
|
14293
14405
|
prompt = first(d.get("prompt"), d.get("user_prompt"), d.get("message"))
|
|
14294
14406
|
path = ""
|
|
14295
14407
|
command = ""
|
|
14408
|
+
new_text = ""
|
|
14409
|
+
old_text = ""
|
|
14296
14410
|
if isinstance(tool_input, dict):
|
|
14297
14411
|
path = first(tool_input.get("file_path"), tool_input.get("path"), tool_input.get("notebook_path"))
|
|
14298
14412
|
command = first(tool_input.get("command"))
|
|
14413
|
+
new_text = first(tool_input.get("new_string"), tool_input.get("content"), tool_input.get("new_source"))
|
|
14414
|
+
old_text = first(tool_input.get("old_string"))
|
|
14415
|
+
if not new_text and isinstance(tool_input.get("edits"), list):
|
|
14416
|
+
new_text = " ".join(e.get("new_string") or "" for e in tool_input["edits"] if isinstance(e, dict))[:1200]
|
|
14417
|
+
|
|
14418
|
+
def prose(value, limit=1200, tail=False):
|
|
14419
|
+
# Plain-text extraction. Serialized dicts read as noise to the signal
|
|
14420
|
+
# scorer (jsonNoiseText), so pull the human-readable field instead of
|
|
14421
|
+
# json.dumps-ing the payload — otherwise every tool observation scores 0.
|
|
14422
|
+
if isinstance(value, dict):
|
|
14423
|
+
for key in ("stdout", "stderr", "output", "error", "message", "content", "text"):
|
|
14424
|
+
candidate = value.get(key)
|
|
14425
|
+
if isinstance(candidate, str) and candidate.strip():
|
|
14426
|
+
flat = " ".join(candidate.split())
|
|
14427
|
+
return flat[-limit:] if tail else flat[:limit]
|
|
14428
|
+
flat = " ".join(str(v) for v in value.values() if isinstance(v, str) and v.strip())
|
|
14429
|
+
flat = " ".join(flat.split())
|
|
14430
|
+
return flat[:limit]
|
|
14431
|
+
if value is None:
|
|
14432
|
+
return ""
|
|
14433
|
+
flat = " ".join(str(value).split())
|
|
14434
|
+
return flat[-limit:] if tail else flat[:limit]
|
|
14299
14435
|
|
|
14300
14436
|
if event_name == "UserPromptSubmit":
|
|
14301
14437
|
payload = {"type": "user_prompt", "text": prompt, "summary": compact(prompt, 240)}
|
|
14302
14438
|
elif event_name == "PostToolUseFailure":
|
|
14303
|
-
|
|
14439
|
+
err = prose(tool_response or d, 900, tail=True)
|
|
14440
|
+
line = (command or tool or "tool") + " failed: " + err
|
|
14441
|
+
payload = {"type": "command_result" if command else "tool_result", "tool": tool, "path": path, "command": command, "summary": line[:320], "text": line}
|
|
14304
14442
|
elif event_name == "PostToolUse":
|
|
14305
|
-
|
|
14306
|
-
|
|
14443
|
+
if path and (new_text or old_text):
|
|
14444
|
+
# The edit content is where fixes and conventions live; tool_response
|
|
14445
|
+
# only says "success" and must never displace it.
|
|
14446
|
+
change = ("changed " + path + ": " + old_text[:160] + " -> " + new_text[:480]) if old_text else ("wrote " + path + ": " + new_text[:600])
|
|
14447
|
+
payload = {"type": "file_change", "tool": tool, "path": path, "summary": change[:320], "text": change}
|
|
14448
|
+
elif command:
|
|
14449
|
+
out = prose(tool_response, 900, tail=True)
|
|
14450
|
+
line = command + (": " + out if out else " completed")
|
|
14451
|
+
payload = {"type": "command_result", "tool": tool, "path": path, "command": command, "summary": line[:320], "text": line}
|
|
14452
|
+
else:
|
|
14453
|
+
body = prose(tool_response) or prose(tool_input)
|
|
14454
|
+
payload = {"type": "file_change" if path else "tool_use", "tool": tool, "path": path, "command": command, "summary": ((tool + ": ") if tool else "") + body[:300], "text": body}
|
|
14307
14455
|
elif event_name == "PreCompact":
|
|
14308
14456
|
payload = {"type": "session_end", "summary": "Claude Code is compacting context; distill durable observations before compaction."}
|
|
14309
14457
|
elif event_name == "SessionEnd":
|
|
@@ -14324,7 +14472,9 @@ if [[ -n "$OBSERVATION" ]]; then
|
|
|
14324
14472
|
fi
|
|
14325
14473
|
|
|
14326
14474
|
if [[ "$EVENT" == "PreCompact" || "$EVENT" == "SessionEnd" || "$EVENT" == "SubagentStop" ]]; then
|
|
14327
|
-
|
|
14475
|
+
# --auto is load-bearing: it is the gated path (signal filter, dedupe, pending
|
|
14476
|
+
# review). Without it, distill writes unfiltered packets stamped approved.
|
|
14477
|
+
kage distill --auto --project "$CWD" --session "$SESSION" --json >/dev/null 2>&1 || true
|
|
14328
14478
|
fi
|
|
14329
14479
|
|
|
14330
14480
|
if [[ "$EVENT" == "UserPromptSubmit" ]]; then
|
|
@@ -14363,8 +14513,7 @@ print(d.get("cwd") or os.environ.get("CLAUDE_PROJECT_DIR") or "")
|
|
|
14363
14513
|
' 2>/dev/null || echo "")"
|
|
14364
14514
|
|
|
14365
14515
|
[[ -d "$CWD/.agent_memory" ]] || exit 0
|
|
14366
|
-
|
|
14367
|
-
export PATH="$CWD/node_modules/.bin:$PATH"
|
|
14516
|
+
${hookKageResolve}
|
|
14368
14517
|
command -v kage >/dev/null 2>&1 || exit 0
|
|
14369
14518
|
|
|
14370
14519
|
FILE_PATH="$(PAYLOAD="$PAYLOAD" python3 -c 'import json, os
|
|
@@ -14505,7 +14654,7 @@ function generatePluginHooks(pluginDir) {
|
|
|
14505
14654
|
const tmpHome = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "kage-plugin-home-"));
|
|
14506
14655
|
const tmpProject = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "kage-plugin-proj-"));
|
|
14507
14656
|
try {
|
|
14508
|
-
setupAgent("claude-code", tmpProject, { write: true, homeDir: tmpHome });
|
|
14657
|
+
setupAgent("claude-code", tmpProject, { write: true, homeDir: tmpHome, portableHooks: true });
|
|
14509
14658
|
const srcHookDir = (0, node_path_1.join)(tmpHome, ".claude", "kage", "hooks");
|
|
14510
14659
|
const settings = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(tmpHome, ".claude", "settings.json"), "utf8"));
|
|
14511
14660
|
const hooksOut = (0, node_path_1.join)(pluginDir, "hooks");
|
|
@@ -14636,6 +14785,10 @@ function configMentionsKage(path) {
|
|
|
14636
14785
|
return /\bkage\b/.test(text) && /(mcp|mcpServers|mcp_servers)/i.test(text);
|
|
14637
14786
|
}
|
|
14638
14787
|
const CLAUDE_AMBIENT_HOOK_EVENTS = ["SessionStart", "UserPromptSubmit", "PostToolUse", "PostToolUseFailure", "PreCompact", "Stop", "SessionEnd"];
|
|
14788
|
+
// Bump whenever a hook template changes behavior. Installed scripts carry the
|
|
14789
|
+
// stamp; doctor/verify report a mismatch so fixes actually reach existing
|
|
14790
|
+
// installs instead of only new setups.
|
|
14791
|
+
exports.KAGE_HOOKS_VERSION = 2;
|
|
14639
14792
|
function claudeHookEventConfigured(settings, event) {
|
|
14640
14793
|
const hooks = settings.hooks && typeof settings.hooks === "object" && !Array.isArray(settings.hooks)
|
|
14641
14794
|
? settings.hooks
|
|
@@ -14662,16 +14815,27 @@ function claudeAmbientHookSummary(homeDir) {
|
|
|
14662
14815
|
}
|
|
14663
14816
|
const installed = CLAUDE_AMBIENT_HOOK_EVENTS.filter((event) => claudeHookEventConfigured(settings, event));
|
|
14664
14817
|
const missing = CLAUDE_AMBIENT_HOOK_EVENTS.filter((event) => !installed.includes(event));
|
|
14818
|
+
const outdated = [];
|
|
14665
14819
|
for (const scriptPath of scriptPaths) {
|
|
14666
|
-
if (!(0, node_fs_1.existsSync)(scriptPath))
|
|
14820
|
+
if (!(0, node_fs_1.existsSync)(scriptPath)) {
|
|
14667
14821
|
missing.push((0, node_path_1.basename)(scriptPath));
|
|
14822
|
+
continue;
|
|
14823
|
+
}
|
|
14824
|
+
// A present-but-stale script is worse than a missing one: it runs old
|
|
14825
|
+
// behavior silently. Unstamped scripts predate versioning (v1).
|
|
14826
|
+
const text = safeReadText(scriptPath) ?? "";
|
|
14827
|
+
const stamp = text.match(/^# kage-hooks-v(\d+)$/m);
|
|
14828
|
+
const version = stamp ? Number(stamp[1]) : 1;
|
|
14829
|
+
if (version < exports.KAGE_HOOKS_VERSION)
|
|
14830
|
+
outdated.push((0, node_path_1.basename)(scriptPath));
|
|
14668
14831
|
}
|
|
14669
14832
|
return {
|
|
14670
14833
|
required: [...CLAUDE_AMBIENT_HOOK_EVENTS],
|
|
14671
14834
|
installed,
|
|
14672
14835
|
missing: unique(missing),
|
|
14836
|
+
outdated,
|
|
14673
14837
|
script_paths: scriptPaths,
|
|
14674
|
-
ready: missing.length === 0,
|
|
14838
|
+
ready: missing.length === 0 && outdated.length === 0,
|
|
14675
14839
|
};
|
|
14676
14840
|
}
|
|
14677
14841
|
function verifyAgentActivation(agent, projectDir, options = {}) {
|
|
@@ -14693,7 +14857,7 @@ function verifyAgentActivation(agent, projectDir, options = {}) {
|
|
|
14693
14857
|
const mcpToolReachable = Boolean(options.mcpToolReachable);
|
|
14694
14858
|
const hookSummary = agent === "claude-code"
|
|
14695
14859
|
? claudeAmbientHookSummary(options.homeDir ?? process.env.HOME ?? "~")
|
|
14696
|
-
: { required: [], installed: [], missing: [], script_paths: [], ready: true };
|
|
14860
|
+
: { required: [], installed: [], missing: [], outdated: [], script_paths: [], ready: true };
|
|
14697
14861
|
const ambientHooksPresent = hookSummary.ready;
|
|
14698
14862
|
const warnings = [];
|
|
14699
14863
|
const nextSteps = [];
|
|
@@ -14750,6 +14914,36 @@ function verifyAgentActivation(agent, projectDir, options = {}) {
|
|
|
14750
14914
|
function observationPath(projectDir, id) {
|
|
14751
14915
|
return (0, node_path_1.join)(observationsDir(projectDir), `${id}.json`);
|
|
14752
14916
|
}
|
|
14917
|
+
// Observations are session-scoped raw signal: distill consumes them at session end and
|
|
14918
|
+
// the resume digest only reads the recent window. Without retention the directory grows
|
|
14919
|
+
// forever (measured: 12k files / 48MB in two months of dogfooding), so refresh prunes
|
|
14920
|
+
// records older than the retention window. 0 disables pruning.
|
|
14921
|
+
const OBSERVATION_RETENTION_DAYS = (() => {
|
|
14922
|
+
const raw = Number(process.env.KAGE_OBSERVATION_RETENTION_DAYS ?? "30");
|
|
14923
|
+
return Number.isFinite(raw) && raw >= 0 ? raw : 30;
|
|
14924
|
+
})();
|
|
14925
|
+
function pruneObservations(projectDir, maxAgeDays = OBSERVATION_RETENTION_DAYS) {
|
|
14926
|
+
if (maxAgeDays <= 0)
|
|
14927
|
+
return { pruned: 0 };
|
|
14928
|
+
const dir = observationsDir(projectDir);
|
|
14929
|
+
if (!(0, node_fs_1.existsSync)(dir))
|
|
14930
|
+
return { pruned: 0 };
|
|
14931
|
+
const cutoff = Date.now() - maxAgeDays * 86_400_000;
|
|
14932
|
+
let pruned = 0;
|
|
14933
|
+
for (const name of (0, node_fs_1.readdirSync)(dir)) {
|
|
14934
|
+
if (!name.endsWith(".json"))
|
|
14935
|
+
continue;
|
|
14936
|
+
const path = (0, node_path_1.join)(dir, name);
|
|
14937
|
+
try {
|
|
14938
|
+
if ((0, node_fs_1.statSync)(path).mtimeMs < cutoff) {
|
|
14939
|
+
(0, node_fs_1.unlinkSync)(path);
|
|
14940
|
+
pruned += 1;
|
|
14941
|
+
}
|
|
14942
|
+
}
|
|
14943
|
+
catch { /* concurrent removal — skip */ }
|
|
14944
|
+
}
|
|
14945
|
+
return { pruned };
|
|
14946
|
+
}
|
|
14753
14947
|
function observationHash(projectDir, event) {
|
|
14754
14948
|
const bucket = event.timestamp ? new Date(event.timestamp).toISOString().slice(0, 16) : nowIso().slice(0, 16);
|
|
14755
14949
|
return (0, node_crypto_1.createHash)("sha256")
|
|
@@ -15098,6 +15292,15 @@ function reusablePromptObservation(event) {
|
|
|
15098
15292
|
"always",
|
|
15099
15293
|
"never",
|
|
15100
15294
|
"prefer",
|
|
15295
|
+
// Debugging intent is the highest-signal prompt there is: the session that
|
|
15296
|
+
// follows usually contains the root cause and the fix.
|
|
15297
|
+
"fail",
|
|
15298
|
+
"fix",
|
|
15299
|
+
"error",
|
|
15300
|
+
"broken",
|
|
15301
|
+
"regression",
|
|
15302
|
+
"doesn't work",
|
|
15303
|
+
"not working",
|
|
15101
15304
|
"avoid",
|
|
15102
15305
|
];
|
|
15103
15306
|
if (!durableSignals.some((signal) => lower.includes(signal)))
|
|
@@ -15462,10 +15665,13 @@ function distillSession(projectDir, sessionId, options = {}) {
|
|
|
15462
15665
|
const auto = Boolean(options.auto);
|
|
15463
15666
|
const mode = auto ? "auto" : "manual";
|
|
15464
15667
|
const observations = loadObservations(projectDir, sessionId);
|
|
15465
|
-
if (
|
|
15668
|
+
if (observations.length === 0) {
|
|
15466
15669
|
return { ok: true, session_id: sessionId, observations: 0, candidates: [], errors: [], mode, skipped_reason: "no_observations", skipped_low_signal: 0 };
|
|
15467
15670
|
}
|
|
15468
|
-
|
|
15671
|
+
// Dedupe guards both modes: SessionEnd + PreCompact + SubagentStop can all
|
|
15672
|
+
// fire for one session, and re-distilling the same material wrote duplicate
|
|
15673
|
+
// packets for years.
|
|
15674
|
+
if (sessionAlreadyCaptured(projectDir, sessionId, observations)) {
|
|
15469
15675
|
return { ok: true, session_id: sessionId, observations: observations.length, candidates: [], errors: [], mode, skipped_reason: "session_already_captured", skipped_low_signal: 0 };
|
|
15470
15676
|
}
|
|
15471
15677
|
const candidates = [];
|
|
@@ -15482,7 +15688,9 @@ function distillSession(projectDir, sessionId, options = {}) {
|
|
|
15482
15688
|
{
|
|
15483
15689
|
kind: "observation_session",
|
|
15484
15690
|
session_id: sessionId,
|
|
15485
|
-
|
|
15691
|
+
// A sample is enough provenance; full arrays made single packets 112KB
|
|
15692
|
+
// and every teammate clones them.
|
|
15693
|
+
observation_ids: observationIds.slice(0, 20),
|
|
15486
15694
|
observation_count: observations.length,
|
|
15487
15695
|
},
|
|
15488
15696
|
];
|
|
@@ -15527,13 +15735,12 @@ function distillSession(projectDir, sessionId, options = {}) {
|
|
|
15527
15735
|
return result;
|
|
15528
15736
|
};
|
|
15529
15737
|
const autoTags = auto ? [exports.AUTO_DISTILL_TAG] : [];
|
|
15530
|
-
//
|
|
15738
|
+
// Distill quality gate: drafts may only be seeded by observations scoring at
|
|
15531
15739
|
// least AUTO_DISTILL_SIGNAL_THRESHOLD. Events tagged low_signal at ingestion skip
|
|
15532
|
-
// cheaply; untagged (older) records are scored here.
|
|
15740
|
+
// cheaply; untagged (older) records are scored here. Both modes are gated —
|
|
15741
|
+
// ungated manual distill is how 116KB dumps got stamped approved+verified.
|
|
15533
15742
|
let skippedLowSignal = 0;
|
|
15534
15743
|
const signalGate = (events) => {
|
|
15535
|
-
if (!auto)
|
|
15536
|
-
return events;
|
|
15537
15744
|
return events.filter((event) => {
|
|
15538
15745
|
const lowSignal = event.low_signal === true
|
|
15539
15746
|
|| (event.low_signal === undefined && observationSignalScore(event) < exports.AUTO_DISTILL_SIGNAL_THRESHOLD);
|
|
@@ -15545,6 +15752,31 @@ function distillSession(projectDir, sessionId, options = {}) {
|
|
|
15545
15752
|
const commandEvents = signalGate(observations.filter((event) => event.type === "command_result" && event.command));
|
|
15546
15753
|
const fileEvents = signalGate(observations.filter((event) => event.type === "file_change" && event.path));
|
|
15547
15754
|
const promptEvents = signalGate(observations.filter((event) => event.type === "user_prompt" && (event.text || event.summary)));
|
|
15755
|
+
// A fail→pass pair on the same command is the strongest evidence a session
|
|
15756
|
+
// produced a real fix. Stamp it on the drafts: it is true, it is checkable
|
|
15757
|
+
// from the observations, and it lets grounded fixes cross the promote bar.
|
|
15758
|
+
// Scanned pre-gate: the failing run often carries error text the gate keeps,
|
|
15759
|
+
// but the passing run can be terse.
|
|
15760
|
+
const failThenPassed = (() => {
|
|
15761
|
+
const failedAt = new Map();
|
|
15762
|
+
const proven = [];
|
|
15763
|
+
observations.forEach((event, index) => {
|
|
15764
|
+
if (event.type !== "command_result" || !event.command)
|
|
15765
|
+
return;
|
|
15766
|
+
const cmd = normalizeCommandText(event.command);
|
|
15767
|
+
const failed = typeof event.exit_code === "number"
|
|
15768
|
+
? event.exit_code !== 0
|
|
15769
|
+
: /\bfail(ed|ure|ing)?\b|\berror\b/i.test(`${event.summary ?? ""} ${event.text ?? ""}`);
|
|
15770
|
+
if (failed)
|
|
15771
|
+
failedAt.set(cmd, index);
|
|
15772
|
+
else if (failedAt.has(cmd) && failedAt.get(cmd) < index && !proven.includes(cmd))
|
|
15773
|
+
proven.push(cmd);
|
|
15774
|
+
});
|
|
15775
|
+
return proven;
|
|
15776
|
+
})();
|
|
15777
|
+
const verificationLine = failThenPassed.length
|
|
15778
|
+
? `\n\nVerified: \`${failThenPassed[0]}\` failed then passed after the change — reproduced in session ${sessionId}.`
|
|
15779
|
+
: "";
|
|
15548
15780
|
const meaningfulCommandEvents = commandEvents
|
|
15549
15781
|
.map((event) => ({ event, reusable: reusableCommandObservation(event, knownRepoCommands(projectDir)) }))
|
|
15550
15782
|
.filter((item) => Boolean(item.reusable));
|
|
@@ -15555,11 +15787,13 @@ function distillSession(projectDir, sessionId, options = {}) {
|
|
|
15555
15787
|
projectDir,
|
|
15556
15788
|
title: `Runbook: ${lead}`,
|
|
15557
15789
|
summary: `Observed commands: ${commands.slice(0, 3).join(", ")}`,
|
|
15558
|
-
body: `Reusable command observation distilled from session ${sessionId}:\n\n${meaningfulCommandEvents.map((item) => `- ${item.reusable.command}: ${item.reusable.learning}`).join("\n")}\n\nReview before approving as a durable runbook.`,
|
|
15790
|
+
body: `Reusable command observation distilled from session ${sessionId}:\n\n${meaningfulCommandEvents.map((item) => `- ${item.reusable.command}: ${item.reusable.learning}`).join("\n")}${verificationLine}\n\nReview before approving as a durable runbook.`,
|
|
15559
15791
|
type: "runbook",
|
|
15560
15792
|
tags: ["observed-session", "commands", "runbook", ...autoTags],
|
|
15561
15793
|
paths: unique(meaningfulCommandEvents.map((item) => item.event.path).filter(Boolean)),
|
|
15562
|
-
|
|
15794
|
+
// Distilled drafts are born pending in every mode; only the grounded
|
|
15795
|
+
// high-signal auto-promotion path may lift them to approved.
|
|
15796
|
+
pendingReview: true,
|
|
15563
15797
|
})));
|
|
15564
15798
|
}
|
|
15565
15799
|
const meaningfulFileEvents = fileEvents
|
|
@@ -15572,23 +15806,25 @@ function distillSession(projectDir, sessionId, options = {}) {
|
|
|
15572
15806
|
projectDir,
|
|
15573
15807
|
title: `Workflow: ${lead}`,
|
|
15574
15808
|
summary: lead,
|
|
15575
|
-
body: `Reusable file observation distilled from session ${sessionId}:\n\n${meaningfulFileEvents.map((item) => `- ${item.event.path}: ${item.learning}`).join("\n")}\n\nReview before approving as durable repo memory.`,
|
|
15809
|
+
body: `Reusable file observation distilled from session ${sessionId}:\n\n${meaningfulFileEvents.map((item) => `- ${item.event.path}: ${item.learning}`).join("\n")}${verificationLine}\n\nReview before approving as durable repo memory.`,
|
|
15576
15810
|
type: "workflow",
|
|
15577
15811
|
tags: ["observed-session", "workflow", ...autoTags],
|
|
15578
15812
|
paths,
|
|
15579
|
-
pendingReview:
|
|
15813
|
+
pendingReview: true,
|
|
15580
15814
|
})));
|
|
15581
15815
|
}
|
|
15582
15816
|
if (promptEvents.length) {
|
|
15583
|
-
|
|
15817
|
+
// Prompt-derived text is the least grounded input: clamp per prompt and
|
|
15818
|
+
// joined, and always land it in the pending inbox.
|
|
15819
|
+
const text = promptEvents.map((event) => clampInline(reusablePromptObservation(event), 500)).filter(Boolean).join("\n").trim().slice(0, 4000);
|
|
15584
15820
|
if (text)
|
|
15585
15821
|
candidates.push(annotate(learn({
|
|
15586
15822
|
projectDir,
|
|
15587
15823
|
title: titleFromLearning(text),
|
|
15588
|
-
learning: text
|
|
15824
|
+
learning: `${text}${verificationLine}`,
|
|
15589
15825
|
evidence: `Observation session: ${sessionId}`,
|
|
15590
15826
|
tags: ["observed-session", "intent", ...autoTags],
|
|
15591
|
-
pendingReview:
|
|
15827
|
+
pendingReview: true,
|
|
15592
15828
|
})));
|
|
15593
15829
|
}
|
|
15594
15830
|
for (const result of candidates)
|
|
@@ -15744,7 +15980,17 @@ function createDiffChangeMemory(projectDir, summary) {
|
|
|
15744
15980
|
const verifyCommands = npmScriptCommands(projectDir)
|
|
15745
15981
|
.filter((command) => /(test|check|lint|build|type|verify)/i.test(command))
|
|
15746
15982
|
.slice(0, 8);
|
|
15747
|
-
|
|
15983
|
+
// Change-memory carries the substance of a change, not Kage's own
|
|
15984
|
+
// bookkeeping: memory files, git plumbing, and Kage-written policy files are
|
|
15985
|
+
// excluded — git already stores those diffs, and lists of packet filenames
|
|
15986
|
+
// were the whole body of the worst change-memory packets.
|
|
15987
|
+
const kagePolicyFiles = new Set(["CLAUDE.md", "AGENTS.md"].filter((name) => (safeReadText((0, node_path_1.join)(projectDir, name)) ?? "").includes("KAGE_MEMORY_POLICY")));
|
|
15988
|
+
const meaningfulChanged = summary.changed_files.filter((file) => !file.startsWith(".agent_memory/")
|
|
15989
|
+
&& file !== ".gitattributes"
|
|
15990
|
+
&& !kagePolicyFiles.has(file));
|
|
15991
|
+
const listedChanged = meaningfulChanged.length ? meaningfulChanged : summary.changed_files;
|
|
15992
|
+
const changedList = listedChanged.slice(0, 25).map((file) => `- ${file}`).join("\n")
|
|
15993
|
+
+ (listedChanged.length > 25 ? `\n- … ${listedChanged.length - 25} more` : "");
|
|
15748
15994
|
const verifyList = verifyCommands.length
|
|
15749
15995
|
? verifyCommands.map((command) => `- ${command}`).join("\n")
|
|
15750
15996
|
: "- Add the exact test, build, or manual verification command when you refine this memory.";
|
|
@@ -15760,7 +16006,7 @@ function createDiffChangeMemory(projectDir, summary) {
|
|
|
15760
16006
|
"```text",
|
|
15761
16007
|
// Clamp the diff stat: a huge diff would otherwise produce a dump-sized change-memory
|
|
15762
16008
|
// body. This path builds the packet directly (not via capture()), so bound it here.
|
|
15763
|
-
clampBlock(summary.diff_stat,
|
|
16009
|
+
clampBlock(summary.diff_stat, 1500),
|
|
15764
16010
|
"```",
|
|
15765
16011
|
"",
|
|
15766
16012
|
"How to verify:",
|
|
@@ -15779,7 +16025,7 @@ function createDiffChangeMemory(projectDir, summary) {
|
|
|
15779
16025
|
schema_version: exports.PACKET_SCHEMA_VERSION,
|
|
15780
16026
|
id: stableId,
|
|
15781
16027
|
title,
|
|
15782
|
-
summary: `Repo-local context for ${
|
|
16028
|
+
summary: `Repo-local context for ${listedChanged.length} changed repo path${listedChanged.length === 1 ? "" : "s"} on ${branch}.`,
|
|
15783
16029
|
body,
|
|
15784
16030
|
type: "workflow",
|
|
15785
16031
|
scope: "repo",
|
|
@@ -15788,7 +16034,7 @@ function createDiffChangeMemory(projectDir, summary) {
|
|
|
15788
16034
|
status: "approved",
|
|
15789
16035
|
confidence: 0.62,
|
|
15790
16036
|
tags: unique(["change-memory", "diff-proposal", "repo-local", branch ? `branch:${slugify(branch)}` : "branch:detached"]),
|
|
15791
|
-
paths:
|
|
16037
|
+
paths: listedChanged.slice(0, 40),
|
|
15792
16038
|
stack: inferStack(projectDir),
|
|
15793
16039
|
source_refs: [
|
|
15794
16040
|
{
|
|
@@ -15796,12 +16042,12 @@ function createDiffChangeMemory(projectDir, summary) {
|
|
|
15796
16042
|
branch,
|
|
15797
16043
|
head,
|
|
15798
16044
|
merge_base: summary.merge_base,
|
|
15799
|
-
changed_files: summary.changed_files,
|
|
16045
|
+
changed_files: summary.changed_files.slice(0, 100),
|
|
15800
16046
|
summary_path: (0, node_path_1.join)(reviewDir(projectDir), `branch-summary-${slugify(branch)}.json`),
|
|
15801
16047
|
},
|
|
15802
16048
|
],
|
|
15803
16049
|
context: {
|
|
15804
|
-
fact: `Current branch ${branch} changes ${
|
|
16050
|
+
fact: `Current branch ${branch} changes ${listedChanged.length} repo path${listedChanged.length === 1 ? "" : "s"}.`,
|
|
15805
16051
|
why: "Branch change memory gives future agents durable context from the git diff when they continue, review, or verify this work.",
|
|
15806
16052
|
trigger: "Recall when asking what changed on this branch, preparing a PR review, or resuming this work.",
|
|
15807
16053
|
action: "Use the changed file list and diff summary as orientation, then inspect the actual diff and source files before making further edits.",
|
|
@@ -16733,24 +16979,30 @@ function mergePacketFiles(oursPath, basePath, theirsPath) {
|
|
|
16733
16979
|
const raw = safeReadText(path);
|
|
16734
16980
|
if (raw === null)
|
|
16735
16981
|
return null;
|
|
16736
|
-
|
|
16737
|
-
|
|
16738
|
-
|
|
16739
|
-
|
|
16740
|
-
|
|
16741
|
-
|
|
16742
|
-
|
|
16743
|
-
|
|
16982
|
+
// Sniff content, never the extension: git merge temp files may keep or
|
|
16983
|
+
// drop the original extension depending on the flow, and .md packet files
|
|
16984
|
+
// have held both raw JSON and OKF frontmatter. Routing raw-JSON .md files
|
|
16985
|
+
// to the OKF parser made every sync-bot race a manual conflict.
|
|
16986
|
+
if (raw.trimStart().startsWith("{")) {
|
|
16987
|
+
try {
|
|
16988
|
+
const parsed = JSON.parse(raw);
|
|
16989
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
16990
|
+
return { raw, packet: parsed };
|
|
16991
|
+
}
|
|
16992
|
+
}
|
|
16993
|
+
catch {
|
|
16994
|
+
// fall through to the other parsers
|
|
16744
16995
|
}
|
|
16745
16996
|
}
|
|
16746
|
-
|
|
16747
|
-
|
|
16748
|
-
|
|
16749
|
-
|
|
16750
|
-
|
|
16751
|
-
|
|
16752
|
-
|
|
16753
|
-
|
|
16997
|
+
const okf = (0, okf_js_1.okfConceptToPacket)(raw);
|
|
16998
|
+
if (okf)
|
|
16999
|
+
return { raw, packet: okf };
|
|
17000
|
+
// A side that carries committed conflict markers (the exact failure mode
|
|
17001
|
+
// this driver exists to end) can often be recovered with repair's
|
|
17002
|
+
// conflict-splitting logic before giving up on it.
|
|
17003
|
+
const recovered = resolveConflictedPacket(raw);
|
|
17004
|
+
if (recovered)
|
|
17005
|
+
return { raw: `${JSON.stringify(recovered, null, 2)}\n`, packet: recovered };
|
|
16754
17006
|
return null;
|
|
16755
17007
|
};
|
|
16756
17008
|
const ours = readSide(oursPath);
|
|
@@ -17297,7 +17549,7 @@ function generateSkills(projectDir, options = {}) {
|
|
|
17297
17549
|
// supersede churn when code changed but the memory's claim did not. Refuses
|
|
17298
17550
|
// when ALL cited evidence is gone — that memory needs supersede or stale, not
|
|
17299
17551
|
// a rubber stamp.
|
|
17300
|
-
function reverifyMemory(projectDir, packetId) {
|
|
17552
|
+
function reverifyMemory(projectDir, packetId, options = {}) {
|
|
17301
17553
|
ensureMemoryDirs(projectDir);
|
|
17302
17554
|
const result = {
|
|
17303
17555
|
ok: false,
|
|
@@ -17305,6 +17557,7 @@ function reverifyMemory(projectDir, packetId) {
|
|
|
17305
17557
|
packet_id: packetId,
|
|
17306
17558
|
refreshed_paths: [],
|
|
17307
17559
|
missing_paths: [],
|
|
17560
|
+
changed_paths: [],
|
|
17308
17561
|
was_stale: false,
|
|
17309
17562
|
errors: [],
|
|
17310
17563
|
};
|
|
@@ -17329,13 +17582,52 @@ function reverifyMemory(projectDir, packetId) {
|
|
|
17329
17582
|
const presentPaths = citedPaths.filter((path) => !result.missing_paths.includes(path));
|
|
17330
17583
|
const now = nowIso();
|
|
17331
17584
|
const freshness = { ...(packet.freshness ?? {}) };
|
|
17332
|
-
|
|
17585
|
+
const nextPrints = memoryPathFingerprints(projectDir, presentPaths, `${packet.title}\n${packet.summary}\n${packet.body}`);
|
|
17586
|
+
// Evidence gate: when cited code changed since the stored fingerprints, a
|
|
17587
|
+
// bare re-stamp would launder a possibly-false claim back to "verified".
|
|
17588
|
+
// Byte-identical files may refresh freely; changed files demand evidence.
|
|
17589
|
+
const storedShas = new Map(packetStoredPathFingerprints(packet).map((print) => [print.path, print.sha256]));
|
|
17590
|
+
const changedPaths = nextPrints
|
|
17591
|
+
.filter((print) => storedShas.has(print.path) && storedShas.get(print.path) !== print.sha256)
|
|
17592
|
+
.map((print) => print.path);
|
|
17593
|
+
result.changed_paths = changedPaths;
|
|
17594
|
+
const evidence = (options.evidence ?? "").trim();
|
|
17595
|
+
const verifiedBy = (options.verifiedBy ?? "").trim();
|
|
17596
|
+
if (changedPaths.length && !evidence && !verifiedBy) {
|
|
17597
|
+
result.errors.push(`Cited code changed since the last verification (${changedPaths.join(", ")}). `
|
|
17598
|
+
+ "Re-stamping without evidence would mark an unchecked claim verified: rerun with "
|
|
17599
|
+
+ "--evidence \"<what you checked>\" or --verified-by \"<command/test that proved it>\", "
|
|
17600
|
+
+ "or supersede the packet if the claim no longer holds.");
|
|
17601
|
+
return result;
|
|
17602
|
+
}
|
|
17603
|
+
freshness.path_fingerprints = nextPrints;
|
|
17333
17604
|
freshness.last_verified_at = now;
|
|
17605
|
+
// Only an evidence-backed recheck upgrades verification; a clean fingerprint
|
|
17606
|
+
// refresh keeps whatever verification the packet already had.
|
|
17607
|
+
if (evidence || verifiedBy)
|
|
17608
|
+
freshness.verification = "evidence_reverification";
|
|
17334
17609
|
const { stale: _stale, stale_reasons: _staleReasons, suggested_action: _suggestedAction, ...nextQuality } = quality;
|
|
17610
|
+
const sourceRefs = changedPaths.length
|
|
17611
|
+
? [
|
|
17612
|
+
...(packet.source_refs ?? []),
|
|
17613
|
+
{
|
|
17614
|
+
kind: "reverification",
|
|
17615
|
+
at: now,
|
|
17616
|
+
...(verifiedBy ? { verified_by: verifiedBy } : {}),
|
|
17617
|
+
...(evidence ? { evidence } : {}),
|
|
17618
|
+
changed_paths: changedPaths.map((path) => ({
|
|
17619
|
+
path,
|
|
17620
|
+
prior_sha256: storedShas.get(path),
|
|
17621
|
+
sha256: nextPrints.find((print) => print.path === path)?.sha256,
|
|
17622
|
+
})),
|
|
17623
|
+
},
|
|
17624
|
+
]
|
|
17625
|
+
: packet.source_refs;
|
|
17335
17626
|
writeJson(entry.path, {
|
|
17336
17627
|
...packet,
|
|
17337
17628
|
paths: presentPaths.length ? presentPaths : packet.paths,
|
|
17338
17629
|
freshness,
|
|
17630
|
+
source_refs: sourceRefs,
|
|
17339
17631
|
quality: { ...nextQuality, reverified_at: now },
|
|
17340
17632
|
updated_at: now,
|
|
17341
17633
|
});
|