@kage-core/kage-graph-mcp 3.2.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 +372 -79
- package/dist/okf.js +6 -3
- package/package.json +1 -1
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;
|
|
@@ -295,13 +297,10 @@ Do this without waiting for the user to ask. Kage should feel like ambient repo
|
|
|
295
297
|
If Kage appears installed but no Kage tools are available, report that the active
|
|
296
298
|
agent session has not loaded the MCP server and ask the user to restart the
|
|
297
299
|
agent. After restart, call \`kage_verify_agent\` to prove the harness is live.
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
this session, stale memories withheld). When it is non-trivial, relay it to the
|
|
303
|
-
user in your own words — Kage's value is otherwise invisible, and a user who never
|
|
304
|
-
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.
|
|
305
304
|
|
|
306
305
|
## Automatic Capture
|
|
307
306
|
|
|
@@ -1281,7 +1280,7 @@ function kageFileContext(projectDir, filePath) {
|
|
|
1281
1280
|
const lines = [
|
|
1282
1281
|
`# Kage File Context: ${rel}`,
|
|
1283
1282
|
...verified.flatMap((packet, index) => [
|
|
1284
|
-
`${index + 1}. [${packet.type} |
|
|
1283
|
+
`${index + 1}. [${packet.type} | ${packetVerificationLabel(packet)}] ${packet.title}`,
|
|
1285
1284
|
` ${packet.summary}`,
|
|
1286
1285
|
]),
|
|
1287
1286
|
`_${verified.length} verified memor${verified.length === 1 ? "y" : "ies"} citing this file (citations checked, not stale)._`,
|
|
@@ -1296,6 +1295,9 @@ function kageFileContext(projectDir, filePath) {
|
|
|
1296
1295
|
}));
|
|
1297
1296
|
const replay = replayTokensSaved(verified, result.context_block);
|
|
1298
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 })));
|
|
1299
1301
|
return result;
|
|
1300
1302
|
}
|
|
1301
1303
|
const AUDIT_ACTIVITY_KIND = {
|
|
@@ -1688,6 +1690,32 @@ function identifierTokens(text) {
|
|
|
1688
1690
|
out.add(match[0].toLowerCase());
|
|
1689
1691
|
return out;
|
|
1690
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"]);
|
|
1691
1719
|
// current-file symbol span hashes, keyed by `${nameLower}\0${kind}` -> [sha256...].
|
|
1692
1720
|
// Cached by mtime+size: extraction only runs when a file actually changed.
|
|
1693
1721
|
const anchorSymbolCache = new Map();
|
|
@@ -1749,7 +1777,7 @@ function fileSymbolSpanHashes(projectDir, path) {
|
|
|
1749
1777
|
// title+summary+body) is supplied, anchor each TS/JS file to the symbols the
|
|
1750
1778
|
// memory actually names, so unrelated edits in the same file do not mark it stale.
|
|
1751
1779
|
function memoryPathFingerprints(projectDir, paths, anchorText) {
|
|
1752
|
-
const idents = anchorText ?
|
|
1780
|
+
const idents = anchorText ? codeAnchorTokens(anchorText) : null;
|
|
1753
1781
|
const fingerprints = [];
|
|
1754
1782
|
for (const path of unique(paths).filter(fingerprintableMemoryPath)) {
|
|
1755
1783
|
const fingerprint = memoryPathFingerprint(projectDir, path);
|
|
@@ -1771,6 +1799,8 @@ function memoryPathFingerprints(projectDir, paths, anchorText) {
|
|
|
1771
1799
|
const [name, kind] = key.split("\0");
|
|
1772
1800
|
if (!idents.has(name) || spanCountByName.get(name) !== 1)
|
|
1773
1801
|
continue;
|
|
1802
|
+
if (!ANCHOR_SYMBOL_KINDS.has(kind) && !name.includes("_"))
|
|
1803
|
+
continue;
|
|
1774
1804
|
symbols.push({ name, kind, sha256: hashes[0] });
|
|
1775
1805
|
}
|
|
1776
1806
|
if (symbols.length) {
|
|
@@ -6958,6 +6988,10 @@ function refreshPacketStaleness(projectDir, options = {}) {
|
|
|
6958
6988
|
let updated = 0;
|
|
6959
6989
|
const fingerprintCache = new Map();
|
|
6960
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);
|
|
6961
6995
|
for (const entry of loadPacketEntriesFromDir(packetsDir(projectDir))) {
|
|
6962
6996
|
// Drop any .kageignore'd grounding (presentation layers etc.) from the stored packet
|
|
6963
6997
|
// so memory is never anchored to non-knowledge files.
|
|
@@ -6981,6 +7015,15 @@ function refreshPacketStaleness(projectDir, options = {}) {
|
|
|
6981
7015
|
const { stale: _stale, stale_reasons: _staleReasons, suggested_action: _suggestedAction, ...rest } = oldQuality;
|
|
6982
7016
|
nextQuality = rest;
|
|
6983
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
|
+
}
|
|
6984
7027
|
const nextFreshness = oldFreshness;
|
|
6985
7028
|
const contentChanged = pruned !== null;
|
|
6986
7029
|
const changed = contentChanged
|
|
@@ -6991,7 +7034,10 @@ function refreshPacketStaleness(projectDir, options = {}) {
|
|
|
6991
7034
|
...packet,
|
|
6992
7035
|
freshness: nextFreshness,
|
|
6993
7036
|
quality: nextQuality,
|
|
6994
|
-
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,
|
|
6995
7041
|
});
|
|
6996
7042
|
updated += 1;
|
|
6997
7043
|
}
|
|
@@ -7062,6 +7108,8 @@ function refreshProject(projectDir, options = {}) {
|
|
|
7062
7108
|
next_actions: nextActions,
|
|
7063
7109
|
};
|
|
7064
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);
|
|
7065
7113
|
function gcProject(projectDir, options = {}) {
|
|
7066
7114
|
ensureMemoryDirs(projectDir);
|
|
7067
7115
|
const packetEntries = loadPacketEntriesFromDir(packetsDir(projectDir));
|
|
@@ -7069,8 +7117,20 @@ function gcProject(projectDir, options = {}) {
|
|
|
7069
7117
|
const deleted = [];
|
|
7070
7118
|
const skipped = [];
|
|
7071
7119
|
for (const { path, packet } of packetEntries) {
|
|
7072
|
-
if (packet.status === "deprecated") {
|
|
7073
|
-
|
|
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
|
+
}
|
|
7074
7134
|
continue;
|
|
7075
7135
|
}
|
|
7076
7136
|
// Serialized transcript / tool-output / file-content dumps and ungrounded conversational
|
|
@@ -7150,6 +7210,18 @@ function kageSuppressedMemory(projectDir) {
|
|
|
7150
7210
|
.filter((entry) => entry !== null);
|
|
7151
7211
|
return { schema_version: 1, generated_at: nowIso(), count: items.length, items };
|
|
7152
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
|
+
}
|
|
7153
7225
|
function verifyCitations(projectDir, options = {}) {
|
|
7154
7226
|
ensureMemoryDirs(projectDir);
|
|
7155
7227
|
const approved = loadApprovedPackets(projectDir);
|
|
@@ -7176,13 +7248,17 @@ function verifyCitations(projectDir, options = {}) {
|
|
|
7176
7248
|
stale_reasons: reasons,
|
|
7177
7249
|
};
|
|
7178
7250
|
});
|
|
7251
|
+
const hardStale = packets.filter((entry) => entry.stale_severity === "hard").length;
|
|
7252
|
+
const ungrounded = packets.filter((entry) => !entry.grounded).length;
|
|
7179
7253
|
return {
|
|
7180
|
-
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,
|
|
7181
7257
|
project_dir: projectDir,
|
|
7182
7258
|
checked: packets.length,
|
|
7183
7259
|
valid: packets.filter((entry) => !entry.stale && entry.grounded).length,
|
|
7184
7260
|
stale: packets.filter((entry) => entry.stale).length,
|
|
7185
|
-
ungrounded
|
|
7261
|
+
ungrounded,
|
|
7186
7262
|
packets,
|
|
7187
7263
|
errors: [],
|
|
7188
7264
|
};
|
|
@@ -7935,16 +8011,19 @@ function recallGraphLookup(graph) {
|
|
|
7935
8011
|
}
|
|
7936
8012
|
return { packetEntityByPacketId, edgesByEntityId };
|
|
7937
8013
|
}
|
|
7938
|
-
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)) {
|
|
7939
8015
|
const packetEntityId = lookup.packetEntityByPacketId.get(packet.id);
|
|
7940
8016
|
const rawGraphScore = packetEntityId
|
|
7941
8017
|
? (lookup.edgesByEntityId.get(packetEntityId) ?? []).reduce((sum, edge) => sum + scoreText(terms, edge.fact), 0)
|
|
7942
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.
|
|
7943
8022
|
const graphCap = packet.type === "reference"
|
|
7944
8023
|
? 0
|
|
7945
|
-
: (textScore > 0 ? textScore
|
|
8024
|
+
: (textScore > 0 ? Math.min(textScore, 8) : 4);
|
|
7946
8025
|
const graphWeight = packet.type === "reference" ? 0 : 0.45;
|
|
7947
|
-
const graphScore = Math.min(rawGraphScore * graphWeight, graphCap);
|
|
8026
|
+
const graphScore = Math.min(Math.log1p(rawGraphScore * graphWeight) * 3, graphCap);
|
|
7948
8027
|
const pathTypeTag = scoreText(terms, `${packet.type} ${packet.tags.join(" ")} ${packet.paths.join(" ")}`, [packet.type, ...packet.tags, ...packet.paths]);
|
|
7949
8028
|
const intent = recallIntentBoost(terms, packet);
|
|
7950
8029
|
const freshness = packet.status === "approved" ? 2 : packet.status === "pending" ? 0 : -5;
|
|
@@ -7958,12 +8037,15 @@ function recallBreakdown(projectDir, terms, packet, textScore, temporalScore = 0
|
|
|
7958
8037
|
// produced confident off-domain junk: a hot, high-quality packet ranked above the one
|
|
7959
8038
|
// packet whose title literally contained the queried term, because its unconditional
|
|
7960
8039
|
// quality+freshness (~12 pts) beat a weak-but-real lexical match.
|
|
7961
|
-
const coreRelevance = textScore + graphScore + intent + vector;
|
|
8040
|
+
const coreRelevance = textScore + graphScore + intent + vector + identifierScore;
|
|
7962
8041
|
const matchSignal = coreRelevance + pathTypeTag;
|
|
7963
8042
|
const effectiveUsage = coreRelevance > 0 ? usage : 0;
|
|
7964
8043
|
const effectiveQuality = matchSignal > 0 ? quality : 0;
|
|
7965
8044
|
const effectiveFreshness = matchSignal > 0 ? freshness : Math.min(freshness, 0);
|
|
7966
|
-
|
|
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));
|
|
7967
8049
|
return {
|
|
7968
8050
|
bm25: textScore,
|
|
7969
8051
|
text: textScore,
|
|
@@ -7975,11 +8057,31 @@ function recallBreakdown(projectDir, terms, packet, textScore, temporalScore = 0
|
|
|
7975
8057
|
vector,
|
|
7976
8058
|
usage,
|
|
7977
8059
|
freshness,
|
|
8060
|
+
recency: Number(effectiveRecency.toFixed(2)),
|
|
8061
|
+
identifier: Number(identifierScore.toFixed(2)),
|
|
7978
8062
|
quality: Number(quality.toFixed(2)),
|
|
7979
8063
|
feedback,
|
|
7980
8064
|
final,
|
|
7981
8065
|
};
|
|
7982
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
|
+
}
|
|
7983
8085
|
function recallDiversitySource(packet) {
|
|
7984
8086
|
for (const ref of packet.source_refs) {
|
|
7985
8087
|
if (ref.kind === "observation_session" && typeof ref.session_id === "string" && ref.session_id.trim()) {
|
|
@@ -8021,6 +8123,11 @@ function diversifyRecallEntries(entries, limit, maxPerSource = 3) {
|
|
|
8021
8123
|
function isSerializedDumpTitle(title) {
|
|
8022
8124
|
const t = (title ?? "").trimStart();
|
|
8023
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)
|
|
8024
8131
|
|| t.startsWith('{"')
|
|
8025
8132
|
|| /^<(task-notification|div|svg|html)\b/i.test(t)
|
|
8026
8133
|
|| /\btool_use_id\b|toolu_[A-Za-z0-9]{10}/.test(title)
|
|
@@ -8175,6 +8282,22 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
|
|
|
8175
8282
|
const referenceBodyScores = scoreReferenceBodyBm25(terms, approvedPackets);
|
|
8176
8283
|
const accessEntries = readMemoryAccessEntries(projectDir, approvedPackets);
|
|
8177
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
|
+
}
|
|
8178
8301
|
const rankedScored = approvedPackets
|
|
8179
8302
|
.map((packet) => {
|
|
8180
8303
|
const base = baseScores.get(packet.id) ?? { score: 0, why: [] };
|
|
@@ -8185,8 +8308,10 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
|
|
|
8185
8308
|
const lexicalScore = base.score + temporal.score + semantic.score;
|
|
8186
8309
|
const textScore = packet.type === "reference" ? Math.max(lexicalScore, referenceBodyScore) : lexicalScore;
|
|
8187
8310
|
const usageScore = memoryAccessScore(accessEntries.get(packet.id));
|
|
8188
|
-
const
|
|
8189
|
-
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;
|
|
8190
8315
|
const why = [
|
|
8191
8316
|
...base.why,
|
|
8192
8317
|
...temporal.why.map((item) => `temporal:${item}`),
|
|
@@ -8194,6 +8319,7 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
|
|
|
8194
8319
|
...(semantic.score > 0 ? expansion.semanticLabels.map((label) => `semantic-concept:${label}`) : []),
|
|
8195
8320
|
...vector.why,
|
|
8196
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"] : []),
|
|
8197
8323
|
];
|
|
8198
8324
|
return { packet, score: score_breakdown.final, relevance, why_matched: unique(why).slice(0, 12), score_breakdown };
|
|
8199
8325
|
})
|
|
@@ -8277,7 +8403,7 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
|
|
|
8277
8403
|
pendingScored.length ? "## Working Memory (Pending Review)" : "",
|
|
8278
8404
|
...pendingScored.flatMap((entry, index) => [
|
|
8279
8405
|
"",
|
|
8280
|
-
`${index + 1}. [${entry.packet.type} | pending |
|
|
8406
|
+
`${index + 1}. [${entry.packet.type} | pending | unreviewed draft] ${entry.packet.title}`,
|
|
8281
8407
|
` Summary: ${entry.packet.summary}`,
|
|
8282
8408
|
` Why matched: ${entry.why_matched.join(", ") || "text relevance"}`,
|
|
8283
8409
|
` Source: pending packet; unapproved local/session memory`,
|
|
@@ -8300,7 +8426,7 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
|
|
|
8300
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._",
|
|
8301
8427
|
...personalEntries.flatMap((entry, index) => [
|
|
8302
8428
|
"",
|
|
8303
|
-
`${index + 1}. [personal] [${entry.packet.type} |
|
|
8429
|
+
`${index + 1}. [personal] [${entry.packet.type} | ${packetVerificationLabel(entry.packet)}] ${entry.packet.title}`,
|
|
8304
8430
|
` [personal] Summary: ${entry.packet.summary}`,
|
|
8305
8431
|
` [personal] Why matched: ${entry.why_matched.join(", ") || "text relevance"}`,
|
|
8306
8432
|
` [personal] Verification: ${entry.unverifiable ? "unverifiable (citation-free personal note)" : "citations re-verified against this checkout"}`,
|
|
@@ -10087,9 +10213,9 @@ function truthReport(projectDir) {
|
|
|
10087
10213
|
findings,
|
|
10088
10214
|
warnings,
|
|
10089
10215
|
next_actions: [
|
|
10090
|
-
"
|
|
10091
|
-
"
|
|
10092
|
-
"
|
|
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, ...)",
|
|
10093
10219
|
],
|
|
10094
10220
|
};
|
|
10095
10221
|
}
|
|
@@ -14100,6 +14226,24 @@ function setupAgent(agent, projectDir, options = {}) {
|
|
|
14100
14226
|
const path = (0, node_path_1.join)(home, ".claude.json");
|
|
14101
14227
|
const server = { type: "stdio", command: serverCommand, args: serverArgs, alwaysLoad: true };
|
|
14102
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`;
|
|
14103
14247
|
const hookScript = `#!/usr/bin/env bash
|
|
14104
14248
|
# Kage SessionStart hook — injects full memory policy as a system message.
|
|
14105
14249
|
# Silent if Kage is not initialized in the current project.
|
|
@@ -14132,6 +14276,7 @@ Before finishing a task that changed files: kage_pr_summarize or kage_propose_fr
|
|
|
14132
14276
|
If recalled memory helped: kage_feedback helpful. If wrong or stale: kage_feedback wrong or stale."
|
|
14133
14277
|
fi
|
|
14134
14278
|
|
|
14279
|
+
${hookKageResolve}
|
|
14135
14280
|
# Session continuity: append a compact "previously…" digest when prior session data exists.
|
|
14136
14281
|
if command -v kage >/dev/null 2>&1; then
|
|
14137
14282
|
PREVIOUSLY="$(kage resume --project "$CWD" 2>/dev/null || true)"
|
|
@@ -14153,8 +14298,7 @@ PAYLOAD="$(cat || true)"
|
|
|
14153
14298
|
CWD="$(printf "%s" "$PAYLOAD" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('cwd',''))" 2>/dev/null || echo "")"
|
|
14154
14299
|
|
|
14155
14300
|
[[ -d "$CWD/.agent_memory" ]] || exit 0
|
|
14156
|
-
|
|
14157
|
-
export PATH="$CWD/node_modules/.bin:$PATH"
|
|
14301
|
+
${hookKageResolve}
|
|
14158
14302
|
command -v kage >/dev/null 2>&1 || exit 0
|
|
14159
14303
|
|
|
14160
14304
|
if git -C "$CWD" status --porcelain -uall >/dev/null 2>&1 && [[ -n "$(git -C "$CWD" status --porcelain -uall)" ]]; then
|
|
@@ -14211,8 +14355,7 @@ print(d.get("cwd") or os.environ.get("CLAUDE_PROJECT_DIR") or "")
|
|
|
14211
14355
|
' 2>/dev/null || echo "")"
|
|
14212
14356
|
|
|
14213
14357
|
[[ -d "$CWD/.agent_memory" ]] || exit 0
|
|
14214
|
-
|
|
14215
|
-
export PATH="$CWD/node_modules/.bin:$PATH"
|
|
14358
|
+
${hookKageResolve}
|
|
14216
14359
|
command -v kage >/dev/null 2>&1 || exit 0
|
|
14217
14360
|
|
|
14218
14361
|
EVENT="$(PAYLOAD="$PAYLOAD" python3 -c 'import json, os
|
|
@@ -14262,17 +14405,53 @@ tool_response = d.get("tool_response") or d.get("toolResponse") or d.get("result
|
|
|
14262
14405
|
prompt = first(d.get("prompt"), d.get("user_prompt"), d.get("message"))
|
|
14263
14406
|
path = ""
|
|
14264
14407
|
command = ""
|
|
14408
|
+
new_text = ""
|
|
14409
|
+
old_text = ""
|
|
14265
14410
|
if isinstance(tool_input, dict):
|
|
14266
14411
|
path = first(tool_input.get("file_path"), tool_input.get("path"), tool_input.get("notebook_path"))
|
|
14267
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]
|
|
14268
14435
|
|
|
14269
14436
|
if event_name == "UserPromptSubmit":
|
|
14270
14437
|
payload = {"type": "user_prompt", "text": prompt, "summary": compact(prompt, 240)}
|
|
14271
14438
|
elif event_name == "PostToolUseFailure":
|
|
14272
|
-
|
|
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}
|
|
14273
14442
|
elif event_name == "PostToolUse":
|
|
14274
|
-
|
|
14275
|
-
|
|
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}
|
|
14276
14455
|
elif event_name == "PreCompact":
|
|
14277
14456
|
payload = {"type": "session_end", "summary": "Claude Code is compacting context; distill durable observations before compaction."}
|
|
14278
14457
|
elif event_name == "SessionEnd":
|
|
@@ -14293,7 +14472,9 @@ if [[ -n "$OBSERVATION" ]]; then
|
|
|
14293
14472
|
fi
|
|
14294
14473
|
|
|
14295
14474
|
if [[ "$EVENT" == "PreCompact" || "$EVENT" == "SessionEnd" || "$EVENT" == "SubagentStop" ]]; then
|
|
14296
|
-
|
|
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
|
|
14297
14478
|
fi
|
|
14298
14479
|
|
|
14299
14480
|
if [[ "$EVENT" == "UserPromptSubmit" ]]; then
|
|
@@ -14332,8 +14513,7 @@ print(d.get("cwd") or os.environ.get("CLAUDE_PROJECT_DIR") or "")
|
|
|
14332
14513
|
' 2>/dev/null || echo "")"
|
|
14333
14514
|
|
|
14334
14515
|
[[ -d "$CWD/.agent_memory" ]] || exit 0
|
|
14335
|
-
|
|
14336
|
-
export PATH="$CWD/node_modules/.bin:$PATH"
|
|
14516
|
+
${hookKageResolve}
|
|
14337
14517
|
command -v kage >/dev/null 2>&1 || exit 0
|
|
14338
14518
|
|
|
14339
14519
|
FILE_PATH="$(PAYLOAD="$PAYLOAD" python3 -c 'import json, os
|
|
@@ -14474,7 +14654,7 @@ function generatePluginHooks(pluginDir) {
|
|
|
14474
14654
|
const tmpHome = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "kage-plugin-home-"));
|
|
14475
14655
|
const tmpProject = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "kage-plugin-proj-"));
|
|
14476
14656
|
try {
|
|
14477
|
-
setupAgent("claude-code", tmpProject, { write: true, homeDir: tmpHome });
|
|
14657
|
+
setupAgent("claude-code", tmpProject, { write: true, homeDir: tmpHome, portableHooks: true });
|
|
14478
14658
|
const srcHookDir = (0, node_path_1.join)(tmpHome, ".claude", "kage", "hooks");
|
|
14479
14659
|
const settings = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(tmpHome, ".claude", "settings.json"), "utf8"));
|
|
14480
14660
|
const hooksOut = (0, node_path_1.join)(pluginDir, "hooks");
|
|
@@ -14605,6 +14785,10 @@ function configMentionsKage(path) {
|
|
|
14605
14785
|
return /\bkage\b/.test(text) && /(mcp|mcpServers|mcp_servers)/i.test(text);
|
|
14606
14786
|
}
|
|
14607
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;
|
|
14608
14792
|
function claudeHookEventConfigured(settings, event) {
|
|
14609
14793
|
const hooks = settings.hooks && typeof settings.hooks === "object" && !Array.isArray(settings.hooks)
|
|
14610
14794
|
? settings.hooks
|
|
@@ -14631,16 +14815,27 @@ function claudeAmbientHookSummary(homeDir) {
|
|
|
14631
14815
|
}
|
|
14632
14816
|
const installed = CLAUDE_AMBIENT_HOOK_EVENTS.filter((event) => claudeHookEventConfigured(settings, event));
|
|
14633
14817
|
const missing = CLAUDE_AMBIENT_HOOK_EVENTS.filter((event) => !installed.includes(event));
|
|
14818
|
+
const outdated = [];
|
|
14634
14819
|
for (const scriptPath of scriptPaths) {
|
|
14635
|
-
if (!(0, node_fs_1.existsSync)(scriptPath))
|
|
14820
|
+
if (!(0, node_fs_1.existsSync)(scriptPath)) {
|
|
14636
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));
|
|
14637
14831
|
}
|
|
14638
14832
|
return {
|
|
14639
14833
|
required: [...CLAUDE_AMBIENT_HOOK_EVENTS],
|
|
14640
14834
|
installed,
|
|
14641
14835
|
missing: unique(missing),
|
|
14836
|
+
outdated,
|
|
14642
14837
|
script_paths: scriptPaths,
|
|
14643
|
-
ready: missing.length === 0,
|
|
14838
|
+
ready: missing.length === 0 && outdated.length === 0,
|
|
14644
14839
|
};
|
|
14645
14840
|
}
|
|
14646
14841
|
function verifyAgentActivation(agent, projectDir, options = {}) {
|
|
@@ -14662,7 +14857,7 @@ function verifyAgentActivation(agent, projectDir, options = {}) {
|
|
|
14662
14857
|
const mcpToolReachable = Boolean(options.mcpToolReachable);
|
|
14663
14858
|
const hookSummary = agent === "claude-code"
|
|
14664
14859
|
? claudeAmbientHookSummary(options.homeDir ?? process.env.HOME ?? "~")
|
|
14665
|
-
: { required: [], installed: [], missing: [], script_paths: [], ready: true };
|
|
14860
|
+
: { required: [], installed: [], missing: [], outdated: [], script_paths: [], ready: true };
|
|
14666
14861
|
const ambientHooksPresent = hookSummary.ready;
|
|
14667
14862
|
const warnings = [];
|
|
14668
14863
|
const nextSteps = [];
|
|
@@ -15097,6 +15292,15 @@ function reusablePromptObservation(event) {
|
|
|
15097
15292
|
"always",
|
|
15098
15293
|
"never",
|
|
15099
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",
|
|
15100
15304
|
"avoid",
|
|
15101
15305
|
];
|
|
15102
15306
|
if (!durableSignals.some((signal) => lower.includes(signal)))
|
|
@@ -15461,10 +15665,13 @@ function distillSession(projectDir, sessionId, options = {}) {
|
|
|
15461
15665
|
const auto = Boolean(options.auto);
|
|
15462
15666
|
const mode = auto ? "auto" : "manual";
|
|
15463
15667
|
const observations = loadObservations(projectDir, sessionId);
|
|
15464
|
-
if (
|
|
15668
|
+
if (observations.length === 0) {
|
|
15465
15669
|
return { ok: true, session_id: sessionId, observations: 0, candidates: [], errors: [], mode, skipped_reason: "no_observations", skipped_low_signal: 0 };
|
|
15466
15670
|
}
|
|
15467
|
-
|
|
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)) {
|
|
15468
15675
|
return { ok: true, session_id: sessionId, observations: observations.length, candidates: [], errors: [], mode, skipped_reason: "session_already_captured", skipped_low_signal: 0 };
|
|
15469
15676
|
}
|
|
15470
15677
|
const candidates = [];
|
|
@@ -15481,7 +15688,9 @@ function distillSession(projectDir, sessionId, options = {}) {
|
|
|
15481
15688
|
{
|
|
15482
15689
|
kind: "observation_session",
|
|
15483
15690
|
session_id: sessionId,
|
|
15484
|
-
|
|
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),
|
|
15485
15694
|
observation_count: observations.length,
|
|
15486
15695
|
},
|
|
15487
15696
|
];
|
|
@@ -15526,13 +15735,12 @@ function distillSession(projectDir, sessionId, options = {}) {
|
|
|
15526
15735
|
return result;
|
|
15527
15736
|
};
|
|
15528
15737
|
const autoTags = auto ? [exports.AUTO_DISTILL_TAG] : [];
|
|
15529
|
-
//
|
|
15738
|
+
// Distill quality gate: drafts may only be seeded by observations scoring at
|
|
15530
15739
|
// least AUTO_DISTILL_SIGNAL_THRESHOLD. Events tagged low_signal at ingestion skip
|
|
15531
|
-
// 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.
|
|
15532
15742
|
let skippedLowSignal = 0;
|
|
15533
15743
|
const signalGate = (events) => {
|
|
15534
|
-
if (!auto)
|
|
15535
|
-
return events;
|
|
15536
15744
|
return events.filter((event) => {
|
|
15537
15745
|
const lowSignal = event.low_signal === true
|
|
15538
15746
|
|| (event.low_signal === undefined && observationSignalScore(event) < exports.AUTO_DISTILL_SIGNAL_THRESHOLD);
|
|
@@ -15544,6 +15752,31 @@ function distillSession(projectDir, sessionId, options = {}) {
|
|
|
15544
15752
|
const commandEvents = signalGate(observations.filter((event) => event.type === "command_result" && event.command));
|
|
15545
15753
|
const fileEvents = signalGate(observations.filter((event) => event.type === "file_change" && event.path));
|
|
15546
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
|
+
: "";
|
|
15547
15780
|
const meaningfulCommandEvents = commandEvents
|
|
15548
15781
|
.map((event) => ({ event, reusable: reusableCommandObservation(event, knownRepoCommands(projectDir)) }))
|
|
15549
15782
|
.filter((item) => Boolean(item.reusable));
|
|
@@ -15554,11 +15787,13 @@ function distillSession(projectDir, sessionId, options = {}) {
|
|
|
15554
15787
|
projectDir,
|
|
15555
15788
|
title: `Runbook: ${lead}`,
|
|
15556
15789
|
summary: `Observed commands: ${commands.slice(0, 3).join(", ")}`,
|
|
15557
|
-
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.`,
|
|
15558
15791
|
type: "runbook",
|
|
15559
15792
|
tags: ["observed-session", "commands", "runbook", ...autoTags],
|
|
15560
15793
|
paths: unique(meaningfulCommandEvents.map((item) => item.event.path).filter(Boolean)),
|
|
15561
|
-
|
|
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,
|
|
15562
15797
|
})));
|
|
15563
15798
|
}
|
|
15564
15799
|
const meaningfulFileEvents = fileEvents
|
|
@@ -15571,23 +15806,25 @@ function distillSession(projectDir, sessionId, options = {}) {
|
|
|
15571
15806
|
projectDir,
|
|
15572
15807
|
title: `Workflow: ${lead}`,
|
|
15573
15808
|
summary: lead,
|
|
15574
|
-
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.`,
|
|
15575
15810
|
type: "workflow",
|
|
15576
15811
|
tags: ["observed-session", "workflow", ...autoTags],
|
|
15577
15812
|
paths,
|
|
15578
|
-
pendingReview:
|
|
15813
|
+
pendingReview: true,
|
|
15579
15814
|
})));
|
|
15580
15815
|
}
|
|
15581
15816
|
if (promptEvents.length) {
|
|
15582
|
-
|
|
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);
|
|
15583
15820
|
if (text)
|
|
15584
15821
|
candidates.push(annotate(learn({
|
|
15585
15822
|
projectDir,
|
|
15586
15823
|
title: titleFromLearning(text),
|
|
15587
|
-
learning: text
|
|
15824
|
+
learning: `${text}${verificationLine}`,
|
|
15588
15825
|
evidence: `Observation session: ${sessionId}`,
|
|
15589
15826
|
tags: ["observed-session", "intent", ...autoTags],
|
|
15590
|
-
pendingReview:
|
|
15827
|
+
pendingReview: true,
|
|
15591
15828
|
})));
|
|
15592
15829
|
}
|
|
15593
15830
|
for (const result of candidates)
|
|
@@ -15743,7 +15980,17 @@ function createDiffChangeMemory(projectDir, summary) {
|
|
|
15743
15980
|
const verifyCommands = npmScriptCommands(projectDir)
|
|
15744
15981
|
.filter((command) => /(test|check|lint|build|type|verify)/i.test(command))
|
|
15745
15982
|
.slice(0, 8);
|
|
15746
|
-
|
|
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` : "");
|
|
15747
15994
|
const verifyList = verifyCommands.length
|
|
15748
15995
|
? verifyCommands.map((command) => `- ${command}`).join("\n")
|
|
15749
15996
|
: "- Add the exact test, build, or manual verification command when you refine this memory.";
|
|
@@ -15759,7 +16006,7 @@ function createDiffChangeMemory(projectDir, summary) {
|
|
|
15759
16006
|
"```text",
|
|
15760
16007
|
// Clamp the diff stat: a huge diff would otherwise produce a dump-sized change-memory
|
|
15761
16008
|
// body. This path builds the packet directly (not via capture()), so bound it here.
|
|
15762
|
-
clampBlock(summary.diff_stat,
|
|
16009
|
+
clampBlock(summary.diff_stat, 1500),
|
|
15763
16010
|
"```",
|
|
15764
16011
|
"",
|
|
15765
16012
|
"How to verify:",
|
|
@@ -15778,7 +16025,7 @@ function createDiffChangeMemory(projectDir, summary) {
|
|
|
15778
16025
|
schema_version: exports.PACKET_SCHEMA_VERSION,
|
|
15779
16026
|
id: stableId,
|
|
15780
16027
|
title,
|
|
15781
|
-
summary: `Repo-local context for ${
|
|
16028
|
+
summary: `Repo-local context for ${listedChanged.length} changed repo path${listedChanged.length === 1 ? "" : "s"} on ${branch}.`,
|
|
15782
16029
|
body,
|
|
15783
16030
|
type: "workflow",
|
|
15784
16031
|
scope: "repo",
|
|
@@ -15787,7 +16034,7 @@ function createDiffChangeMemory(projectDir, summary) {
|
|
|
15787
16034
|
status: "approved",
|
|
15788
16035
|
confidence: 0.62,
|
|
15789
16036
|
tags: unique(["change-memory", "diff-proposal", "repo-local", branch ? `branch:${slugify(branch)}` : "branch:detached"]),
|
|
15790
|
-
paths:
|
|
16037
|
+
paths: listedChanged.slice(0, 40),
|
|
15791
16038
|
stack: inferStack(projectDir),
|
|
15792
16039
|
source_refs: [
|
|
15793
16040
|
{
|
|
@@ -15795,12 +16042,12 @@ function createDiffChangeMemory(projectDir, summary) {
|
|
|
15795
16042
|
branch,
|
|
15796
16043
|
head,
|
|
15797
16044
|
merge_base: summary.merge_base,
|
|
15798
|
-
changed_files: summary.changed_files,
|
|
16045
|
+
changed_files: summary.changed_files.slice(0, 100),
|
|
15799
16046
|
summary_path: (0, node_path_1.join)(reviewDir(projectDir), `branch-summary-${slugify(branch)}.json`),
|
|
15800
16047
|
},
|
|
15801
16048
|
],
|
|
15802
16049
|
context: {
|
|
15803
|
-
fact: `Current branch ${branch} changes ${
|
|
16050
|
+
fact: `Current branch ${branch} changes ${listedChanged.length} repo path${listedChanged.length === 1 ? "" : "s"}.`,
|
|
15804
16051
|
why: "Branch change memory gives future agents durable context from the git diff when they continue, review, or verify this work.",
|
|
15805
16052
|
trigger: "Recall when asking what changed on this branch, preparing a PR review, or resuming this work.",
|
|
15806
16053
|
action: "Use the changed file list and diff summary as orientation, then inspect the actual diff and source files before making further edits.",
|
|
@@ -16732,24 +16979,30 @@ function mergePacketFiles(oursPath, basePath, theirsPath) {
|
|
|
16732
16979
|
const raw = safeReadText(path);
|
|
16733
16980
|
if (raw === null)
|
|
16734
16981
|
return null;
|
|
16735
|
-
|
|
16736
|
-
|
|
16737
|
-
|
|
16738
|
-
|
|
16739
|
-
|
|
16740
|
-
|
|
16741
|
-
|
|
16742
|
-
|
|
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
|
|
16743
16995
|
}
|
|
16744
16996
|
}
|
|
16745
|
-
|
|
16746
|
-
|
|
16747
|
-
|
|
16748
|
-
|
|
16749
|
-
|
|
16750
|
-
|
|
16751
|
-
|
|
16752
|
-
|
|
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 };
|
|
16753
17006
|
return null;
|
|
16754
17007
|
};
|
|
16755
17008
|
const ours = readSide(oursPath);
|
|
@@ -17296,7 +17549,7 @@ function generateSkills(projectDir, options = {}) {
|
|
|
17296
17549
|
// supersede churn when code changed but the memory's claim did not. Refuses
|
|
17297
17550
|
// when ALL cited evidence is gone — that memory needs supersede or stale, not
|
|
17298
17551
|
// a rubber stamp.
|
|
17299
|
-
function reverifyMemory(projectDir, packetId) {
|
|
17552
|
+
function reverifyMemory(projectDir, packetId, options = {}) {
|
|
17300
17553
|
ensureMemoryDirs(projectDir);
|
|
17301
17554
|
const result = {
|
|
17302
17555
|
ok: false,
|
|
@@ -17304,6 +17557,7 @@ function reverifyMemory(projectDir, packetId) {
|
|
|
17304
17557
|
packet_id: packetId,
|
|
17305
17558
|
refreshed_paths: [],
|
|
17306
17559
|
missing_paths: [],
|
|
17560
|
+
changed_paths: [],
|
|
17307
17561
|
was_stale: false,
|
|
17308
17562
|
errors: [],
|
|
17309
17563
|
};
|
|
@@ -17328,13 +17582,52 @@ function reverifyMemory(projectDir, packetId) {
|
|
|
17328
17582
|
const presentPaths = citedPaths.filter((path) => !result.missing_paths.includes(path));
|
|
17329
17583
|
const now = nowIso();
|
|
17330
17584
|
const freshness = { ...(packet.freshness ?? {}) };
|
|
17331
|
-
|
|
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;
|
|
17332
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";
|
|
17333
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;
|
|
17334
17626
|
writeJson(entry.path, {
|
|
17335
17627
|
...packet,
|
|
17336
17628
|
paths: presentPaths.length ? presentPaths : packet.paths,
|
|
17337
17629
|
freshness,
|
|
17630
|
+
source_refs: sourceRefs,
|
|
17338
17631
|
quality: { ...nextQuality, reverified_at: now },
|
|
17339
17632
|
updated_at: now,
|
|
17340
17633
|
});
|