@kage-core/kage-graph-mcp 2.0.2 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +183 -11
- package/dist/daemon.js +153 -0
- package/dist/index.js +28 -2
- package/dist/kernel.js +1834 -35
- package/package.json +1 -1
- package/viewer/console.js +61 -0
- package/viewer/index.html +10 -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.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.AUTO_DISTILL_TAG = 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;
|
|
@@ -65,11 +65,13 @@ exports.kageMemoryLifecycle = kageMemoryLifecycle;
|
|
|
65
65
|
exports.recordValueEvent = recordValueEvent;
|
|
66
66
|
exports.valueSummary = valueSummary;
|
|
67
67
|
exports.formatTokenCount = formatTokenCount;
|
|
68
|
+
exports.kageFileContext = kageFileContext;
|
|
68
69
|
exports.kageActivity = kageActivity;
|
|
69
70
|
exports.kageMemoryReconciliation = kageMemoryReconciliation;
|
|
70
71
|
exports.evaluateMemoryAdmission = evaluateMemoryAdmission;
|
|
71
72
|
exports.validatePacket = validatePacket;
|
|
72
73
|
exports.scanSensitiveText = scanSensitiveText;
|
|
74
|
+
exports.stripPrivateSpans = stripPrivateSpans;
|
|
73
75
|
exports.catalogDomainNodeCount = catalogDomainNodeCount;
|
|
74
76
|
exports.ensureMemoryDirs = ensureMemoryDirs;
|
|
75
77
|
exports.loadApprovedPackets = loadApprovedPackets;
|
|
@@ -103,6 +105,14 @@ exports.kageRisk = kageRisk;
|
|
|
103
105
|
exports.kageDependencyPath = kageDependencyPath;
|
|
104
106
|
exports.kageCleanupCandidates = kageCleanupCandidates;
|
|
105
107
|
exports.truthReport = truthReport;
|
|
108
|
+
exports.defaultClaudeMemStorePath = defaultClaudeMemStorePath;
|
|
109
|
+
exports.claudeMemProjectKey = claudeMemProjectKey;
|
|
110
|
+
exports.parseClaudeMemFileList = parseClaudeMemFileList;
|
|
111
|
+
exports.readClaudeMemObservations = readClaudeMemObservations;
|
|
112
|
+
exports.buildClaudeMemChangeSignal = buildClaudeMemChangeSignal;
|
|
113
|
+
exports.classifyClaudeMemObservations = classifyClaudeMemObservations;
|
|
114
|
+
exports.auditClaudeMemStore = auditClaudeMemStore;
|
|
115
|
+
exports.renderClaudeMemAuditReceipt = renderClaudeMemAuditReceipt;
|
|
106
116
|
exports.kageReviewerSuggestions = kageReviewerSuggestions;
|
|
107
117
|
exports.kageContributors = kageContributors;
|
|
108
118
|
exports.kageContextSlots = kageContextSlots;
|
|
@@ -136,10 +146,12 @@ exports.setupAgent = setupAgent;
|
|
|
136
146
|
exports.setupDoctor = setupDoctor;
|
|
137
147
|
exports.verifyAgentActivation = verifyAgentActivation;
|
|
138
148
|
exports.observe = observe;
|
|
149
|
+
exports.observationSignalScore = observationSignalScore;
|
|
139
150
|
exports.kageSessionCaptureReport = kageSessionCaptureReport;
|
|
140
151
|
exports.kageSessionReplay = kageSessionReplay;
|
|
141
152
|
exports.kageSessionLearningLedger = kageSessionLearningLedger;
|
|
142
153
|
exports.distillSession = distillSession;
|
|
154
|
+
exports.kageResume = kageResume;
|
|
143
155
|
exports.proposeFromDiff = proposeFromDiff;
|
|
144
156
|
exports.buildBranchOverlay = buildBranchOverlay;
|
|
145
157
|
exports.createReviewArtifact = createReviewArtifact;
|
|
@@ -155,12 +167,27 @@ exports.recordFeedback = recordFeedback;
|
|
|
155
167
|
exports.validateProject = validateProject;
|
|
156
168
|
exports.initProject = initProject;
|
|
157
169
|
exports.doctorProject = doctorProject;
|
|
170
|
+
exports.splitConflictSides = splitConflictSides;
|
|
171
|
+
exports.mergePacketFiles = mergePacketFiles;
|
|
172
|
+
exports.ensurePacketMergeAttributes = ensurePacketMergeAttributes;
|
|
173
|
+
exports.repairProject = repairProject;
|
|
174
|
+
exports.remediationFor = remediationFor;
|
|
158
175
|
exports.approvePending = approvePending;
|
|
159
176
|
exports.rejectPending = rejectPending;
|
|
160
177
|
exports.changelog = changelog;
|
|
161
178
|
exports.supersedeMemory = supersedeMemory;
|
|
162
179
|
exports.kageMemoryLineage = kageMemoryLineage;
|
|
163
180
|
exports.kageMemoryTimeline = kageMemoryTimeline;
|
|
181
|
+
exports.kageHomeDir = kageHomeDir;
|
|
182
|
+
exports.personalMemoryDir = personalMemoryDir;
|
|
183
|
+
exports.personalPacketsDir = personalPacketsDir;
|
|
184
|
+
exports.personalConflictsDir = personalConflictsDir;
|
|
185
|
+
exports.loadPersonalPackets = loadPersonalPackets;
|
|
186
|
+
exports.learnPersonal = learnPersonal;
|
|
187
|
+
exports.capturePersonal = capturePersonal;
|
|
188
|
+
exports.syncSetup = syncSetup;
|
|
189
|
+
exports.syncStatus = syncStatus;
|
|
190
|
+
exports.syncPersonal = syncPersonal;
|
|
164
191
|
const node_crypto_1 = require("node:crypto");
|
|
165
192
|
const node_child_process_1 = require("node:child_process");
|
|
166
193
|
const node_fs_1 = require("node:fs");
|
|
@@ -976,7 +1003,7 @@ function valueLedgerPath(projectDir) {
|
|
|
976
1003
|
function emptyValueLedger() {
|
|
977
1004
|
return {
|
|
978
1005
|
schema_version: VALUE_LEDGER_SCHEMA_VERSION,
|
|
979
|
-
totals: { tokens_saved: 0, stale_withheld: 0, stale_caught: 0, recalls: 0, caller_answers: 0 },
|
|
1006
|
+
totals: { tokens_saved: 0, replay_tokens: 0, stale_withheld: 0, stale_caught: 0, recalls: 0, caller_answers: 0 },
|
|
980
1007
|
events: [],
|
|
981
1008
|
};
|
|
982
1009
|
}
|
|
@@ -1004,6 +1031,7 @@ function readValueLedger(projectDir) {
|
|
|
1004
1031
|
schema_version: VALUE_LEDGER_SCHEMA_VERSION,
|
|
1005
1032
|
totals: {
|
|
1006
1033
|
tokens_saved: nonNegativeCount(totals.tokens_saved),
|
|
1034
|
+
replay_tokens: nonNegativeCount(totals.replay_tokens),
|
|
1007
1035
|
stale_withheld: nonNegativeCount(totals.stale_withheld),
|
|
1008
1036
|
stale_caught: nonNegativeCount(totals.stale_caught),
|
|
1009
1037
|
recalls: nonNegativeCount(totals.recalls),
|
|
@@ -1026,7 +1054,9 @@ function recordValueEvents(projectDir, events) {
|
|
|
1026
1054
|
const record = { at, kind: event.kind };
|
|
1027
1055
|
if (event.kind === "recall_served") {
|
|
1028
1056
|
record.tokens_saved = nonNegativeCount(event.tokens_saved);
|
|
1057
|
+
record.replay_tokens = nonNegativeCount(event.replay_tokens);
|
|
1029
1058
|
ledger.totals.tokens_saved += record.tokens_saved;
|
|
1059
|
+
ledger.totals.replay_tokens += record.replay_tokens;
|
|
1030
1060
|
ledger.totals.recalls += 1;
|
|
1031
1061
|
}
|
|
1032
1062
|
else if (event.kind === "stale_withheld") {
|
|
@@ -1063,7 +1093,7 @@ function estimatedTokenDollars(tokensSaved) {
|
|
|
1063
1093
|
return Number(((tokensSaved / 1_000_000) * VALUE_DOLLARS_PER_MILLION_TOKENS).toFixed(2));
|
|
1064
1094
|
}
|
|
1065
1095
|
function summarizeValueWindow(events, cutoff) {
|
|
1066
|
-
const window = { tokens_saved: 0, stale_withheld: 0, stale_caught: 0, recalls: 0, caller_answers: 0 };
|
|
1096
|
+
const window = { tokens_saved: 0, replay_tokens: 0, stale_withheld: 0, stale_caught: 0, recalls: 0, caller_answers: 0 };
|
|
1067
1097
|
for (const event of events) {
|
|
1068
1098
|
const at = Date.parse(event.at);
|
|
1069
1099
|
if (!Number.isFinite(at) || at < cutoff)
|
|
@@ -1071,6 +1101,7 @@ function summarizeValueWindow(events, cutoff) {
|
|
|
1071
1101
|
if (event.kind === "recall_served") {
|
|
1072
1102
|
window.recalls += 1;
|
|
1073
1103
|
window.tokens_saved += nonNegativeCount(event.tokens_saved);
|
|
1104
|
+
window.replay_tokens += nonNegativeCount(event.replay_tokens);
|
|
1074
1105
|
}
|
|
1075
1106
|
else if (event.kind === "stale_withheld") {
|
|
1076
1107
|
window.stale_withheld += 1;
|
|
@@ -1123,6 +1154,89 @@ function recallTokensSaved(projectDir, results, contextBlock) {
|
|
|
1123
1154
|
}
|
|
1124
1155
|
return Math.max(0, Math.floor(sourceBytes / 4) - Math.floor(contextBlock.length / 4));
|
|
1125
1156
|
}
|
|
1157
|
+
// Conservative per-type defaults for discovery_tokens — the approximate exploration +
|
|
1158
|
+
// reasoning tokens an agent typically burns to produce knowledge of this type — used
|
|
1159
|
+
// when a capture does not report its actual discovery cost. Deliberately low so
|
|
1160
|
+
// knowledge-replay receipts under-claim rather than over-claim.
|
|
1161
|
+
const DEFAULT_DISCOVERY_TOKENS = {
|
|
1162
|
+
bug_fix: 8000,
|
|
1163
|
+
gotcha: 8000,
|
|
1164
|
+
decision: 4000,
|
|
1165
|
+
};
|
|
1166
|
+
const DEFAULT_DISCOVERY_TOKENS_FALLBACK = 2000;
|
|
1167
|
+
function defaultDiscoveryTokens(type) {
|
|
1168
|
+
return DEFAULT_DISCOVERY_TOKENS[type] ?? DEFAULT_DISCOVERY_TOKENS_FALLBACK;
|
|
1169
|
+
}
|
|
1170
|
+
function packetDiscoveryTokens(packet) {
|
|
1171
|
+
const value = Number(packet.quality?.discovery_tokens);
|
|
1172
|
+
return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
|
|
1173
|
+
}
|
|
1174
|
+
// Knowledge replay value: the tokens originally spent discovering the served packets'
|
|
1175
|
+
// knowledge minus the compressed cost of re-reading them as context. Floored at zero.
|
|
1176
|
+
// Recall receipts take the max of this and the read-vs-source estimate, so reported
|
|
1177
|
+
// savings never drop below the pre-discovery_tokens behavior and never go negative.
|
|
1178
|
+
function replayTokensSaved(packets, contextBlock) {
|
|
1179
|
+
const discovery = packets.reduce((sum, packet) => sum + packetDiscoveryTokens(packet), 0);
|
|
1180
|
+
if (discovery <= 0)
|
|
1181
|
+
return 0;
|
|
1182
|
+
return Math.max(0, discovery - estimateTokens(contextBlock));
|
|
1183
|
+
}
|
|
1184
|
+
const FILE_CONTEXT_PACKET_CAP = 3;
|
|
1185
|
+
// PreToolUse(Read) injection: verified memory at the moment of relevance. Returns at
|
|
1186
|
+
// most three currently-verified packets that cite the file an agent is about to read,
|
|
1187
|
+
// as a compact context block. Reuses the same staleness machinery as recall — a packet
|
|
1188
|
+
// with ANY stale reason (deprecated/superseded, reported stale, ttl expired, missing or
|
|
1189
|
+
// changed citations) is never injected, so the block only ever carries verified memory.
|
|
1190
|
+
function kageFileContext(projectDir, filePath) {
|
|
1191
|
+
const result = {
|
|
1192
|
+
schema_version: 1,
|
|
1193
|
+
project_dir: (0, node_path_1.resolve)(projectDir),
|
|
1194
|
+
path: "",
|
|
1195
|
+
packets: [],
|
|
1196
|
+
context_block: "",
|
|
1197
|
+
};
|
|
1198
|
+
if (!filePath || !filePath.trim())
|
|
1199
|
+
return result;
|
|
1200
|
+
let rel = filePath.trim().replace(/\\/g, "/");
|
|
1201
|
+
if ((0, node_path_1.isAbsolute)(rel))
|
|
1202
|
+
rel = (0, node_path_1.relative)((0, node_path_1.resolve)(projectDir), rel).replace(/\\/g, "/");
|
|
1203
|
+
rel = rel.replace(/^\.\//, "").replace(/^\/+/, "");
|
|
1204
|
+
result.path = rel;
|
|
1205
|
+
// Files outside the project (or an uninitialized project) never produce context.
|
|
1206
|
+
if (!rel || rel.startsWith("..") || !(0, node_fs_1.existsSync)(memoryRoot(projectDir)))
|
|
1207
|
+
return result;
|
|
1208
|
+
const fingerprintCache = new Map();
|
|
1209
|
+
const qualityScore = (packet) => {
|
|
1210
|
+
const score = Number(packet.quality?.score);
|
|
1211
|
+
return Number.isFinite(score) ? score : 0;
|
|
1212
|
+
};
|
|
1213
|
+
const verified = loadApprovedPackets(projectDir)
|
|
1214
|
+
.filter((packet) => packetPathSet(packet).has(rel))
|
|
1215
|
+
.filter((packet) => staleMemoryReasons(projectDir, packet, fingerprintCache).length === 0)
|
|
1216
|
+
.sort((a, b) => qualityScore(b) - qualityScore(a) || b.updated_at.localeCompare(a.updated_at) || a.title.localeCompare(b.title))
|
|
1217
|
+
.slice(0, FILE_CONTEXT_PACKET_CAP);
|
|
1218
|
+
if (!verified.length)
|
|
1219
|
+
return result;
|
|
1220
|
+
const lines = [
|
|
1221
|
+
`# Kage File Context: ${rel}`,
|
|
1222
|
+
...verified.flatMap((packet, index) => [
|
|
1223
|
+
`${index + 1}. [${packet.type} | confidence ${packet.confidence.toFixed(2)}] ${packet.title}`,
|
|
1224
|
+
` ${packet.summary}`,
|
|
1225
|
+
]),
|
|
1226
|
+
`_${verified.length} verified memor${verified.length === 1 ? "y" : "ies"} citing this file (citations checked, not stale)._`,
|
|
1227
|
+
];
|
|
1228
|
+
result.context_block = lines.join("\n");
|
|
1229
|
+
result.packets = verified.map((packet) => ({
|
|
1230
|
+
id: packet.id,
|
|
1231
|
+
title: packet.title,
|
|
1232
|
+
type: packet.type,
|
|
1233
|
+
summary: packet.summary,
|
|
1234
|
+
confidence: packet.confidence,
|
|
1235
|
+
}));
|
|
1236
|
+
const replay = replayTokensSaved(verified, result.context_block);
|
|
1237
|
+
recordValueEvent(projectDir, { kind: "recall_served", tokens_saved: replay, replay_tokens: replay });
|
|
1238
|
+
return result;
|
|
1239
|
+
}
|
|
1126
1240
|
const AUDIT_ACTIVITY_KIND = {
|
|
1127
1241
|
capture: "capture", approve: "capture", supersede: "supersede", deprecate: "deprecate",
|
|
1128
1242
|
update: "update", promote: "promote", feedback: "feedback",
|
|
@@ -1784,6 +1898,28 @@ function scanSensitiveText(text) {
|
|
|
1784
1898
|
];
|
|
1785
1899
|
return patterns.filter(([, pattern]) => pattern.test(text)).map(([name]) => name);
|
|
1786
1900
|
}
|
|
1901
|
+
// Privacy tags: anything wrapped in <private>...</private> is redacted to
|
|
1902
|
+
// "[private]" BEFORE a packet or observation is written, so the content never
|
|
1903
|
+
// reaches disk. Matching is case-insensitive and spans newlines; an unclosed
|
|
1904
|
+
// <private> redacts to the end of the string so a malformed tag cannot leak.
|
|
1905
|
+
const PRIVATE_SPAN_PATTERN = /<private>[\s\S]*?(?:<\/private>|$)/gi;
|
|
1906
|
+
function stripPrivateSpans(text) {
|
|
1907
|
+
if (!text || text.toLowerCase().indexOf("<private>") === -1)
|
|
1908
|
+
return text;
|
|
1909
|
+
return text.replace(PRIVATE_SPAN_PATTERN, "[private]");
|
|
1910
|
+
}
|
|
1911
|
+
function stripPrivateFromContext(context) {
|
|
1912
|
+
const sanitized = { ...context };
|
|
1913
|
+
for (const key of ["fact", "why", "trigger", "action", "verification", "risk_if_forgotten", "stale_when"]) {
|
|
1914
|
+
const value = sanitized[key];
|
|
1915
|
+
if (typeof value === "string")
|
|
1916
|
+
sanitized[key] = stripPrivateSpans(value);
|
|
1917
|
+
}
|
|
1918
|
+
if (sanitized.rejected_alternatives) {
|
|
1919
|
+
sanitized.rejected_alternatives = sanitized.rejected_alternatives.map((entry) => stripPrivateSpans(entry));
|
|
1920
|
+
}
|
|
1921
|
+
return sanitized;
|
|
1922
|
+
}
|
|
1787
1923
|
function catalogDomainNodeCount(domain) {
|
|
1788
1924
|
return domain.nodes ?? domain.node_count ?? 0;
|
|
1789
1925
|
}
|
|
@@ -1861,8 +1997,11 @@ function loadApprovedPackets(projectDir) {
|
|
|
1861
1997
|
function loadPendingPackets(projectDir) {
|
|
1862
1998
|
return loadPacketsFromDir(pendingDir(projectDir));
|
|
1863
1999
|
}
|
|
2000
|
+
// Hook-driven auto-distilled drafts carry this tag so they are distinguishable from
|
|
2001
|
+
// agent-reviewed memory and never surface in recall until a human or agent approves them.
|
|
2002
|
+
exports.AUTO_DISTILL_TAG = "auto-distill";
|
|
1864
2003
|
function recallablePendingPackets(projectDir) {
|
|
1865
|
-
return loadPendingPackets(projectDir).filter((packet) => !packet.tags.includes("diff-proposal"));
|
|
2004
|
+
return loadPendingPackets(projectDir).filter((packet) => !packet.tags.includes("diff-proposal") && !packet.tags.includes(exports.AUTO_DISTILL_TAG));
|
|
1866
2005
|
}
|
|
1867
2006
|
function writePacket(projectDir, packet, statusDir) {
|
|
1868
2007
|
const dir = statusDir === "packets" ? packetsDir(projectDir) : pendingDir(projectDir);
|
|
@@ -2202,6 +2341,28 @@ function safeReadText(path) {
|
|
|
2202
2341
|
function gitBranch(projectDir) {
|
|
2203
2342
|
return readGit(projectDir, ["branch", "--show-current"]) || readGit(projectDir, ["rev-parse", "--short", "HEAD"]);
|
|
2204
2343
|
}
|
|
2344
|
+
function gitDefaultBranch(projectDir) {
|
|
2345
|
+
// Prefer the remote's view of the default branch (origin/HEAD), then fall
|
|
2346
|
+
// back to whichever of master/main exists locally.
|
|
2347
|
+
const originHead = readGit(projectDir, ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]);
|
|
2348
|
+
if (originHead)
|
|
2349
|
+
return originHead.replace(/^origin\//, "");
|
|
2350
|
+
for (const candidate of ["master", "main"]) {
|
|
2351
|
+
if (readGit(projectDir, ["rev-parse", "--verify", "--quiet", `refs/heads/${candidate}`]) !== null)
|
|
2352
|
+
return candidate;
|
|
2353
|
+
}
|
|
2354
|
+
return null;
|
|
2355
|
+
}
|
|
2356
|
+
// True only when we are confident the working tree is on a non-default branch.
|
|
2357
|
+
// Unknown states (not a git repo, detached HEAD, undeterminable default branch)
|
|
2358
|
+
// return false so refresh keeps its full-rewrite behavior.
|
|
2359
|
+
function onNonDefaultBranch(projectDir) {
|
|
2360
|
+
const current = readGit(projectDir, ["branch", "--show-current"]);
|
|
2361
|
+
if (!current)
|
|
2362
|
+
return false;
|
|
2363
|
+
const defaultBranch = gitDefaultBranch(projectDir);
|
|
2364
|
+
return defaultBranch !== null && current !== defaultBranch;
|
|
2365
|
+
}
|
|
2205
2366
|
function gitHead(projectDir) {
|
|
2206
2367
|
return readGit(projectDir, ["rev-parse", "HEAD"]);
|
|
2207
2368
|
}
|
|
@@ -6052,7 +6213,12 @@ function staleFinding(packet, reasons) {
|
|
|
6052
6213
|
suggested_action: staleSuggestedAction(reasons),
|
|
6053
6214
|
};
|
|
6054
6215
|
}
|
|
6055
|
-
|
|
6216
|
+
// quiet: compute staleness fully (findings still drive recall withholding) but
|
|
6217
|
+
// skip pure-metadata rewrites (stale flags / updated_at recomputation) on disk.
|
|
6218
|
+
// Used on non-default git branches so concurrent branches stop conflicting on
|
|
6219
|
+
// cosmetic packet churn. Content changes (pruned grounding paths, i.e. the
|
|
6220
|
+
// citation set changed) are still persisted even in quiet mode.
|
|
6221
|
+
function refreshPacketStaleness(projectDir, options = {}) {
|
|
6056
6222
|
const findings = [];
|
|
6057
6223
|
let updated = 0;
|
|
6058
6224
|
const fingerprintCache = new Map();
|
|
@@ -6081,10 +6247,11 @@ function refreshPacketStaleness(projectDir) {
|
|
|
6081
6247
|
nextQuality = rest;
|
|
6082
6248
|
}
|
|
6083
6249
|
const nextFreshness = oldFreshness;
|
|
6084
|
-
const
|
|
6250
|
+
const contentChanged = pruned !== null;
|
|
6251
|
+
const changed = contentChanged
|
|
6085
6252
|
|| JSON.stringify(oldQuality) !== JSON.stringify(nextQuality)
|
|
6086
6253
|
|| JSON.stringify(oldFreshness) !== JSON.stringify(nextFreshness);
|
|
6087
|
-
if (changed) {
|
|
6254
|
+
if (changed && (!options.quiet || contentChanged)) {
|
|
6088
6255
|
writeJson(entry.path, {
|
|
6089
6256
|
...packet,
|
|
6090
6257
|
freshness: nextFreshness,
|
|
@@ -6097,11 +6264,17 @@ function refreshPacketStaleness(projectDir) {
|
|
|
6097
6264
|
return { findings, updated };
|
|
6098
6265
|
}
|
|
6099
6266
|
function refreshProject(projectDir, options = {}) {
|
|
6267
|
+
// Quiet-refresh on non-default branches: staleness is still computed (and
|
|
6268
|
+
// recall withholding still works — it recomputes staleness in memory), but
|
|
6269
|
+
// metadata-only packet rewrites are not persisted, so concurrent branches
|
|
6270
|
+
// stop generating merge conflicts on .agent_memory/packets/*.json.
|
|
6271
|
+
// --force restores full rewrites anywhere.
|
|
6272
|
+
const quiet = !options.force && onNonDefaultBranch(projectDir);
|
|
6100
6273
|
const detailedIndex = indexProjectDetailed(projectDir, { full: options.full });
|
|
6101
6274
|
const index = detailedIndex.result;
|
|
6102
6275
|
let codeGraph = detailedIndex.codeGraph;
|
|
6103
6276
|
let knowledgeGraph = detailedIndex.knowledgeGraph;
|
|
6104
|
-
const stale = refreshPacketStaleness(projectDir);
|
|
6277
|
+
const stale = refreshPacketStaleness(projectDir, { quiet });
|
|
6105
6278
|
let indexes = index.indexes;
|
|
6106
6279
|
if (stale.updated > 0) {
|
|
6107
6280
|
const rebuilt = buildGraphIndexes(projectDir, { forceCodeGraph: options.full });
|
|
@@ -6115,6 +6288,9 @@ function refreshProject(projectDir, options = {}) {
|
|
|
6115
6288
|
writeJson((0, node_path_1.join)(reportsDir(projectDir), "context-slots.json"), kageContextSlots(projectDir));
|
|
6116
6289
|
writeJson((0, node_path_1.join)(reportsDir(projectDir), "handoff.json"), kageMemoryHandoff(projectDir));
|
|
6117
6290
|
const nextActions = [];
|
|
6291
|
+
if (quiet && stale.findings.length) {
|
|
6292
|
+
nextActions.push("Quiet refresh (non-default branch): stale flags were computed in memory but not written to packet files. Run `kage refresh --force` to persist them.");
|
|
6293
|
+
}
|
|
6118
6294
|
if (stale.findings.length)
|
|
6119
6295
|
nextActions.push("Update, verify, or supersede stale repo memories before relying on them.");
|
|
6120
6296
|
if (!validation.ok)
|
|
@@ -6127,6 +6303,7 @@ function refreshProject(projectDir, options = {}) {
|
|
|
6127
6303
|
ok: validation.ok,
|
|
6128
6304
|
project_dir: projectDir,
|
|
6129
6305
|
generated_at: nowIso(),
|
|
6306
|
+
quiet_refresh: quiet,
|
|
6130
6307
|
index,
|
|
6131
6308
|
validation,
|
|
6132
6309
|
metrics,
|
|
@@ -7172,6 +7349,11 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
|
|
|
7172
7349
|
return true;
|
|
7173
7350
|
})
|
|
7174
7351
|
.slice(0, 3);
|
|
7352
|
+
// Personal memory (~/.kage/memory): a clearly separated, lower-trust section
|
|
7353
|
+
// appended AFTER every repo section — repo memory always ranks first. Cited
|
|
7354
|
+
// personal packets are re-verified against this checkout (hard-stale ones are
|
|
7355
|
+
// withheld, same as repo memory); citation-free ones are labeled unverifiable.
|
|
7356
|
+
const personalEntries = personalRecallEntries(projectDir, terms, 3);
|
|
7175
7357
|
const graphContext = queryGraph(projectDir, query, 5, knowledgeGraph);
|
|
7176
7358
|
const codeContext = queryCodeGraph(projectDir, query, 5, codeGraph);
|
|
7177
7359
|
const pinnedContext = renderPinnedRepoContext(readContextSlots(projectDir));
|
|
@@ -7220,6 +7402,20 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
|
|
|
7220
7402
|
...(suppressed.length
|
|
7221
7403
|
? ["", `_${suppressed.length} stale memory packet(s) excluded from recall. Run kage verify for details._`]
|
|
7222
7404
|
: []),
|
|
7405
|
+
...(personalEntries.length
|
|
7406
|
+
? [
|
|
7407
|
+
"",
|
|
7408
|
+
"## Personal Memory",
|
|
7409
|
+
"_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._",
|
|
7410
|
+
...personalEntries.flatMap((entry, index) => [
|
|
7411
|
+
"",
|
|
7412
|
+
`${index + 1}. [personal] [${entry.packet.type} | confidence ${entry.packet.confidence.toFixed(2)}] ${entry.packet.title}`,
|
|
7413
|
+
` [personal] Summary: ${entry.packet.summary}`,
|
|
7414
|
+
` [personal] Why matched: ${entry.why_matched.join(", ") || "text relevance"}`,
|
|
7415
|
+
` [personal] Verification: ${entry.unverifiable ? "unverifiable (citation-free personal note)" : "citations re-verified against this checkout"}`,
|
|
7416
|
+
]),
|
|
7417
|
+
]
|
|
7418
|
+
: []),
|
|
7223
7419
|
];
|
|
7224
7420
|
const assembledBlock = lines.join("\n");
|
|
7225
7421
|
const result = {
|
|
@@ -7227,6 +7423,7 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
|
|
|
7227
7423
|
context_block: inputs.maxContextTokens ? boundContextBlock(assembledBlock, inputs.maxContextTokens) : assembledBlock,
|
|
7228
7424
|
results: scored,
|
|
7229
7425
|
suppressed: suppressed.length ? suppressed : undefined,
|
|
7426
|
+
personal: personalEntries.length ? personalEntries : undefined,
|
|
7230
7427
|
explanations: explain
|
|
7231
7428
|
? scored.map((entry) => ({
|
|
7232
7429
|
packet_id: entry.packet.id,
|
|
@@ -7237,15 +7434,20 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
|
|
|
7237
7434
|
}))
|
|
7238
7435
|
: undefined,
|
|
7239
7436
|
};
|
|
7437
|
+
// Per-recall savings: never less than the read-vs-source estimate (prior behavior),
|
|
7438
|
+
// raised to the knowledge-replay value when served packets carry discovery_tokens.
|
|
7439
|
+
const readVsSourceTokens = scored.length ? recallTokensSaved(projectDir, result.results, result.context_block) : 0;
|
|
7440
|
+
const replayTokens = scored.length ? replayTokensSaved(scored.map((entry) => entry.packet), result.context_block) : 0;
|
|
7240
7441
|
result.value_receipt = {
|
|
7241
|
-
tokens_saved:
|
|
7442
|
+
tokens_saved: Math.max(readVsSourceTokens, replayTokens),
|
|
7242
7443
|
stale_withheld: suppressed.length,
|
|
7444
|
+
...(replayTokens > 0 ? { replay_tokens: replayTokens } : {}),
|
|
7243
7445
|
};
|
|
7244
7446
|
if (inputs.trackAccess !== false) {
|
|
7245
7447
|
recordRecallAccess(projectDir, result.results);
|
|
7246
7448
|
recordValueEvents(projectDir, [
|
|
7247
7449
|
...suppressed.map((entry) => ({ kind: "stale_withheld", packet_title: entry.title })),
|
|
7248
|
-
...(scored.length ? [{ kind: "recall_served", tokens_saved: result.value_receipt.tokens_saved }] : []),
|
|
7450
|
+
...(scored.length ? [{ kind: "recall_served", tokens_saved: result.value_receipt.tokens_saved, replay_tokens: replayTokens }] : []),
|
|
7249
7451
|
]);
|
|
7250
7452
|
}
|
|
7251
7453
|
return result;
|
|
@@ -8687,6 +8889,276 @@ function truthReport(projectDir) {
|
|
|
8687
8889
|
],
|
|
8688
8890
|
};
|
|
8689
8891
|
}
|
|
8892
|
+
function defaultClaudeMemStorePath() {
|
|
8893
|
+
const dataDir = process.env.CLAUDE_MEM_DATA_DIR || (0, node_path_1.join)((0, node_os_1.homedir)(), ".claude-mem");
|
|
8894
|
+
return (0, node_path_1.join)(dataDir, "claude-mem.db");
|
|
8895
|
+
}
|
|
8896
|
+
function claudeMemProjectKey(projectDir) {
|
|
8897
|
+
// claude-mem derives the project name from the git repo root basename so it
|
|
8898
|
+
// stays stable across subdirectories and worktrees; mirror that here.
|
|
8899
|
+
const repoRoot = readGit(projectDir, ["rev-parse", "--show-toplevel"]);
|
|
8900
|
+
return (0, node_path_1.basename)(repoRoot || (0, node_path_1.resolve)(projectDir));
|
|
8901
|
+
}
|
|
8902
|
+
// Mirrors claude-mem's own lenient parseFileList: JSON array of strings, with
|
|
8903
|
+
// non-JSON values treated as a single raw path.
|
|
8904
|
+
function parseClaudeMemFileList(value) {
|
|
8905
|
+
if (!value)
|
|
8906
|
+
return [];
|
|
8907
|
+
let parsed;
|
|
8908
|
+
try {
|
|
8909
|
+
parsed = JSON.parse(value);
|
|
8910
|
+
}
|
|
8911
|
+
catch {
|
|
8912
|
+
parsed = [value];
|
|
8913
|
+
}
|
|
8914
|
+
const list = Array.isArray(parsed) ? parsed : [parsed];
|
|
8915
|
+
return list
|
|
8916
|
+
.map((item) => String(item).trim())
|
|
8917
|
+
.filter((item) => item.length > 0 && !/^\[.*\]$/.test(item));
|
|
8918
|
+
}
|
|
8919
|
+
// Read-only SELECT against a claude-mem store. Prefers the built-in node:sqlite
|
|
8920
|
+
// module (Node 22+); falls back to the sqlite3 CLI with -json output. The
|
|
8921
|
+
// project key is inlined with quote-escaping for the CLI path because the CLI
|
|
8922
|
+
// has no parameter binding.
|
|
8923
|
+
function queryClaudeMemStore(storePath, sqlFor, projectKey) {
|
|
8924
|
+
let nodeSqliteError = null;
|
|
8925
|
+
try {
|
|
8926
|
+
// Guarded require: node:sqlite only exists on Node 22.5+, and this package
|
|
8927
|
+
// supports Node 18+. Compiled output is CJS, so require is available.
|
|
8928
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
8929
|
+
const { DatabaseSync } = require("node:sqlite");
|
|
8930
|
+
const db = new DatabaseSync(storePath, { readOnly: true });
|
|
8931
|
+
try {
|
|
8932
|
+
const statement = db.prepare(sqlFor("?"));
|
|
8933
|
+
const rows = (projectKey === null ? statement.all() : statement.all(projectKey));
|
|
8934
|
+
return { ok: true, mechanism: "node:sqlite", rows };
|
|
8935
|
+
}
|
|
8936
|
+
finally {
|
|
8937
|
+
db.close();
|
|
8938
|
+
}
|
|
8939
|
+
}
|
|
8940
|
+
catch (error) {
|
|
8941
|
+
nodeSqliteError = error instanceof Error ? error.message : String(error);
|
|
8942
|
+
}
|
|
8943
|
+
try {
|
|
8944
|
+
const escaped = projectKey === null ? "''" : `'${projectKey.replace(/'/g, "''")}'`;
|
|
8945
|
+
const out = (0, node_child_process_1.execFileSync)("sqlite3", ["-readonly", "-json", storePath, sqlFor(escaped)], {
|
|
8946
|
+
encoding: "utf8",
|
|
8947
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
8948
|
+
});
|
|
8949
|
+
const trimmed = out.trim();
|
|
8950
|
+
return { ok: true, mechanism: "sqlite3-cli", rows: trimmed ? JSON.parse(trimmed) : [] };
|
|
8951
|
+
}
|
|
8952
|
+
catch {
|
|
8953
|
+
return {
|
|
8954
|
+
ok: false,
|
|
8955
|
+
error: [
|
|
8956
|
+
`Cannot read the claude-mem store at ${storePath}.`,
|
|
8957
|
+
"Kage needs one of:",
|
|
8958
|
+
" - Node 22+ (ships the built-in node:sqlite module), or",
|
|
8959
|
+
" - the `sqlite3` command-line tool on PATH (with -json support, sqlite 3.33+).",
|
|
8960
|
+
`node:sqlite said: ${nodeSqliteError ?? "unavailable"}`,
|
|
8961
|
+
"The store is opened read-only either way — Kage never writes to it.",
|
|
8962
|
+
].join("\n"),
|
|
8963
|
+
};
|
|
8964
|
+
}
|
|
8965
|
+
}
|
|
8966
|
+
function readClaudeMemObservations(storePath, projectKey) {
|
|
8967
|
+
if (!(0, node_fs_1.existsSync)(storePath)) {
|
|
8968
|
+
return {
|
|
8969
|
+
ok: false,
|
|
8970
|
+
error: `No claude-mem store found at ${storePath}. Pass --store <path> if yours lives elsewhere (claude-mem default: ~/.claude-mem/claude-mem.db, or $CLAUDE_MEM_DATA_DIR/claude-mem.db).`,
|
|
8971
|
+
};
|
|
8972
|
+
}
|
|
8973
|
+
const result = queryClaudeMemStore(storePath, (key) => `SELECT id, project, type, title, subtitle, files_read, files_modified, created_at, created_at_epoch FROM observations WHERE project = ${key} ORDER BY created_at_epoch ASC`, projectKey);
|
|
8974
|
+
if (!result.ok)
|
|
8975
|
+
return result;
|
|
8976
|
+
return { ok: true, mechanism: result.mechanism, rows: result.rows };
|
|
8977
|
+
}
|
|
8978
|
+
function readClaudeMemProjectNames(storePath) {
|
|
8979
|
+
const result = queryClaudeMemStore(storePath, () => "SELECT DISTINCT project FROM observations ORDER BY project", null);
|
|
8980
|
+
if (!result.ok)
|
|
8981
|
+
return [];
|
|
8982
|
+
return result.rows.map((row) => String(row.project ?? "")).filter(Boolean);
|
|
8983
|
+
}
|
|
8984
|
+
// claude-mem stores created_at_epoch in milliseconds; tolerate seconds too.
|
|
8985
|
+
function claudeMemObservationEpochSeconds(row) {
|
|
8986
|
+
const epoch = Number(row.created_at_epoch);
|
|
8987
|
+
if (Number.isFinite(epoch) && epoch > 0)
|
|
8988
|
+
return epoch > 1e12 ? Math.floor(epoch / 1000) : Math.floor(epoch);
|
|
8989
|
+
if (row.created_at) {
|
|
8990
|
+
const parsed = Date.parse(row.created_at);
|
|
8991
|
+
if (Number.isFinite(parsed))
|
|
8992
|
+
return Math.floor(parsed / 1000);
|
|
8993
|
+
}
|
|
8994
|
+
return null;
|
|
8995
|
+
}
|
|
8996
|
+
// One git pass over the whole history (same approach as truthReport): the
|
|
8997
|
+
// newest commit touching each path is its last-change signal. mtime is the
|
|
8998
|
+
// fallback for untracked or out-of-repo paths — fresh checkouts reset mtimes,
|
|
8999
|
+
// so git wins whenever it knows the path.
|
|
9000
|
+
function buildClaudeMemChangeSignal(projectDir) {
|
|
9001
|
+
const absProject = (0, node_path_1.resolve)(projectDir);
|
|
9002
|
+
const newestEpochByPath = new Map();
|
|
9003
|
+
if (gitHead(projectDir)) {
|
|
9004
|
+
const raw = readGit(projectDir, ["log", "--no-renames", "--format=__KAGE_CM__%x1f%ct", "--name-only"]) ?? "";
|
|
9005
|
+
const projectPrefix = gitProjectPrefix(projectDir) ?? "";
|
|
9006
|
+
let epoch = 0;
|
|
9007
|
+
for (const rawLine of raw.split(/\r?\n/)) {
|
|
9008
|
+
const line = rawLine.trim();
|
|
9009
|
+
if (!line)
|
|
9010
|
+
continue;
|
|
9011
|
+
if (line.startsWith("__KAGE_CM__")) {
|
|
9012
|
+
epoch = Number(line.split("\x1f")[1] ?? 0) || 0;
|
|
9013
|
+
continue;
|
|
9014
|
+
}
|
|
9015
|
+
const normalized = line.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
9016
|
+
const path = projectPrefix && normalized.startsWith(`${projectPrefix}/`) ? normalized.slice(projectPrefix.length + 1) : normalized;
|
|
9017
|
+
// git log is newest-first: first sighting is the newest commit for the path.
|
|
9018
|
+
if (!newestEpochByPath.has(path))
|
|
9019
|
+
newestEpochByPath.set(path, epoch);
|
|
9020
|
+
}
|
|
9021
|
+
}
|
|
9022
|
+
const resolveCited = (citedPath) => {
|
|
9023
|
+
const cleaned = citedPath.replace(/\\/g, "/");
|
|
9024
|
+
if (/^(?:[A-Za-z]:)?\//.test(cleaned) || cleaned.startsWith("~/")) {
|
|
9025
|
+
const abs = (0, node_path_1.resolve)(cleaned.startsWith("~/") ? (0, node_path_1.join)((0, node_os_1.homedir)(), cleaned.slice(2)) : cleaned);
|
|
9026
|
+
const rel = abs === absProject ? "" : abs.startsWith(absProject + node_path_1.sep) ? (0, node_path_1.relative)(absProject, abs).replace(/\\/g, "/") : null;
|
|
9027
|
+
return { abs, rel };
|
|
9028
|
+
}
|
|
9029
|
+
const rel = cleaned.replace(/^\.\//, "");
|
|
9030
|
+
return { abs: (0, node_path_1.join)(absProject, rel), rel };
|
|
9031
|
+
};
|
|
9032
|
+
return {
|
|
9033
|
+
exists: (citedPath) => (0, node_fs_1.existsSync)(resolveCited(citedPath).abs),
|
|
9034
|
+
lastChangedEpoch: (citedPath) => {
|
|
9035
|
+
const { abs, rel } = resolveCited(citedPath);
|
|
9036
|
+
if (rel !== null) {
|
|
9037
|
+
const fromGit = newestEpochByPath.get(rel);
|
|
9038
|
+
if (fromGit !== undefined)
|
|
9039
|
+
return fromGit;
|
|
9040
|
+
}
|
|
9041
|
+
const stat = safeStat(abs);
|
|
9042
|
+
return stat ? Math.floor(stat.mtimeMs / 1000) : null;
|
|
9043
|
+
},
|
|
9044
|
+
};
|
|
9045
|
+
}
|
|
9046
|
+
// Pure classifier over parsed rows: VERIFIED (all cited paths exist and are
|
|
9047
|
+
// unchanged since capture), DRIFTED (a cited path changed after capture), GONE
|
|
9048
|
+
// (a cited path no longer exists), UNCITED (no file citations at all).
|
|
9049
|
+
// GONE outranks DRIFTED outranks VERIFIED when citations disagree.
|
|
9050
|
+
function classifyClaudeMemObservations(rows, signal, nowEpochSeconds = Math.floor(Date.now() / 1000)) {
|
|
9051
|
+
return rows.map((row) => {
|
|
9052
|
+
const cited = unique([...parseClaudeMemFileList(row.files_read), ...parseClaudeMemFileList(row.files_modified)]);
|
|
9053
|
+
const obsEpoch = claudeMemObservationEpochSeconds(row);
|
|
9054
|
+
const ageDays = obsEpoch === null ? null : Math.max(0, Math.floor((nowEpochSeconds - obsEpoch) / 86400));
|
|
9055
|
+
const title = (row.title ?? "").trim() || (row.subtitle ?? "").trim() || `observation #${row.id}`;
|
|
9056
|
+
if (!cited.length) {
|
|
9057
|
+
return { id: row.id, type: row.type ?? null, title, status: "uncited", created_at: row.created_at ?? null, age_days: ageDays, citations: [] };
|
|
9058
|
+
}
|
|
9059
|
+
const citations = cited.map((path) => {
|
|
9060
|
+
if (!signal.exists(path))
|
|
9061
|
+
return { path, status: "gone", changed_at: null };
|
|
9062
|
+
const changedEpoch = signal.lastChangedEpoch(path);
|
|
9063
|
+
if (changedEpoch !== null && obsEpoch !== null && changedEpoch > obsEpoch) {
|
|
9064
|
+
return { path, status: "drifted", changed_at: new Date(changedEpoch * 1000).toISOString() };
|
|
9065
|
+
}
|
|
9066
|
+
return { path, status: "verified", changed_at: null };
|
|
9067
|
+
});
|
|
9068
|
+
const status = citations.some((c) => c.status === "gone")
|
|
9069
|
+
? "gone"
|
|
9070
|
+
: citations.some((c) => c.status === "drifted")
|
|
9071
|
+
? "drifted"
|
|
9072
|
+
: "verified";
|
|
9073
|
+
return { id: row.id, type: row.type ?? null, title, status, created_at: row.created_at ?? null, age_days: ageDays, citations };
|
|
9074
|
+
});
|
|
9075
|
+
}
|
|
9076
|
+
function auditClaudeMemStore(projectDir, options = {}) {
|
|
9077
|
+
const storePath = options.storePath ?? defaultClaudeMemStorePath();
|
|
9078
|
+
const projectKey = claudeMemProjectKey(projectDir);
|
|
9079
|
+
const read = readClaudeMemObservations(storePath, projectKey);
|
|
9080
|
+
if (!read.ok)
|
|
9081
|
+
return { ok: false, error: read.error };
|
|
9082
|
+
const warnings = [];
|
|
9083
|
+
if (!read.rows.length) {
|
|
9084
|
+
const others = readClaudeMemProjectNames(storePath);
|
|
9085
|
+
warnings.push(others.length
|
|
9086
|
+
? `No observations for project "${projectKey}". The store has: ${others.slice(0, 12).join(", ")}${others.length > 12 ? ", …" : ""}. Run from the matching directory or pass --project.`
|
|
9087
|
+
: "The store has no observations at all.");
|
|
9088
|
+
}
|
|
9089
|
+
if (!gitHead(projectDir)) {
|
|
9090
|
+
warnings.push("Git history is unavailable for this project; change detection falls back to file mtimes, which fresh checkouts reset.");
|
|
9091
|
+
}
|
|
9092
|
+
const nowEpoch = Math.floor(Date.now() / 1000);
|
|
9093
|
+
const entries = classifyClaudeMemObservations(read.rows, buildClaudeMemChangeSignal(projectDir), nowEpoch);
|
|
9094
|
+
const count = (status) => entries.filter((entry) => entry.status === status).length;
|
|
9095
|
+
const epochs = read.rows.map((row) => claudeMemObservationEpochSeconds(row)).filter((epoch) => epoch !== null);
|
|
9096
|
+
const spanDays = epochs.length ? Math.max(1, Math.ceil((Math.max(...epochs) - Math.min(...epochs)) / 86400)) : 0;
|
|
9097
|
+
const worstOffenders = entries
|
|
9098
|
+
.filter((entry) => entry.status === "drifted" || entry.status === "gone")
|
|
9099
|
+
.sort((a, b) => (b.age_days ?? 0) - (a.age_days ?? 0) || a.id - b.id)
|
|
9100
|
+
.slice(0, 5)
|
|
9101
|
+
.map((entry) => {
|
|
9102
|
+
const offending = entry.citations.find((c) => c.status === entry.status) ?? entry.citations[0];
|
|
9103
|
+
const whatChanged = offending.status === "gone"
|
|
9104
|
+
? "file no longer exists"
|
|
9105
|
+
: `changed ${offending.changed_at?.slice(0, 10) ?? "after capture"}${entry.created_at ? ` (captured ${entry.created_at.slice(0, 10)})` : ""}`;
|
|
9106
|
+
return { id: entry.id, title: entry.title, status: entry.status, age_days: entry.age_days, path: offending.path, what_changed: whatChanged };
|
|
9107
|
+
});
|
|
9108
|
+
return {
|
|
9109
|
+
ok: true,
|
|
9110
|
+
report: {
|
|
9111
|
+
schema_version: 1,
|
|
9112
|
+
store_path: storePath,
|
|
9113
|
+
project_dir: projectDir,
|
|
9114
|
+
project_key: projectKey,
|
|
9115
|
+
generated_at: nowIso(),
|
|
9116
|
+
reader: read.mechanism,
|
|
9117
|
+
totals: {
|
|
9118
|
+
observations: entries.length,
|
|
9119
|
+
verified: count("verified"),
|
|
9120
|
+
drifted: count("drifted"),
|
|
9121
|
+
gone: count("gone"),
|
|
9122
|
+
uncited: count("uncited"),
|
|
9123
|
+
},
|
|
9124
|
+
span_days: spanDays,
|
|
9125
|
+
worst_offenders: worstOffenders,
|
|
9126
|
+
observations: entries,
|
|
9127
|
+
warnings,
|
|
9128
|
+
},
|
|
9129
|
+
};
|
|
9130
|
+
}
|
|
9131
|
+
function renderClaudeMemAuditReceipt(report) {
|
|
9132
|
+
const lines = [];
|
|
9133
|
+
lines.push(`Kage audit — claude-mem store for ${report.project_key}`);
|
|
9134
|
+
const total = report.totals.observations;
|
|
9135
|
+
lines.push(`${total} observation${total === 1 ? "" : "s"} · captured over ${report.span_days} day${report.span_days === 1 ? "" : "s"}`);
|
|
9136
|
+
if (total > 0) {
|
|
9137
|
+
const pct = (n) => `${Math.round((n / total) * 100)}%`;
|
|
9138
|
+
const row = (label, n, note) => `■ ${label.padEnd(10)} ${String(n).padStart(3)} (${pct(n)}) ${note}`;
|
|
9139
|
+
lines.push(row("VERIFIED", report.totals.verified, "still match your code"));
|
|
9140
|
+
lines.push(row("DRIFTED", report.totals.drifted, "cite files that changed since capture — may be stale"));
|
|
9141
|
+
lines.push(row("GONE", report.totals.gone, "cite files that no longer exist"));
|
|
9142
|
+
lines.push(row("UNCITED", report.totals.uncited, "no file citations — unverifiable by construction"));
|
|
9143
|
+
}
|
|
9144
|
+
if (report.worst_offenders.length) {
|
|
9145
|
+
lines.push("");
|
|
9146
|
+
lines.push("Worst offenders:");
|
|
9147
|
+
for (const offender of report.worst_offenders) {
|
|
9148
|
+
lines.push(` • "${offender.title}" — ${offender.status.toUpperCase()}${offender.age_days !== null ? `, ${offender.age_days}d old` : ""}`);
|
|
9149
|
+
lines.push(` ${offender.path} — ${offender.what_changed}`);
|
|
9150
|
+
}
|
|
9151
|
+
}
|
|
9152
|
+
if (report.warnings.length) {
|
|
9153
|
+
lines.push("");
|
|
9154
|
+
for (const warning of report.warnings)
|
|
9155
|
+
lines.push(`Warning: ${warning}`);
|
|
9156
|
+
}
|
|
9157
|
+
lines.push("");
|
|
9158
|
+
lines.push("claude-mem remembers everything. Kage tells you what's still true.");
|
|
9159
|
+
lines.push("Import coming soon — https://kage-core.github.io/Kage/");
|
|
9160
|
+
return lines.join("\n");
|
|
9161
|
+
}
|
|
8690
9162
|
function kageReviewerSuggestions(projectDir, targets = [], changedFiles = []) {
|
|
8691
9163
|
const graph = readCurrentCodeGraph(projectDir) ?? buildCodeGraph(projectDir);
|
|
8692
9164
|
const graphPaths = new Set(graph.files.map((file) => file.path));
|
|
@@ -11951,6 +12423,15 @@ function hasStructuredEngineeringContext(packet) {
|
|
|
11951
12423
|
return Boolean(context.why || context.verification || context.risk_if_forgotten || context.stale_when || context.trigger || context.action);
|
|
11952
12424
|
}
|
|
11953
12425
|
function learn(input) {
|
|
12426
|
+
// Redact <private> spans before deriving the title/summary so private text
|
|
12427
|
+
// never leaks into derived fields; capture() re-applies the same sanitizer.
|
|
12428
|
+
input = {
|
|
12429
|
+
...input,
|
|
12430
|
+
learning: stripPrivateSpans(input.learning),
|
|
12431
|
+
title: input.title === undefined ? undefined : stripPrivateSpans(input.title),
|
|
12432
|
+
evidence: input.evidence === undefined ? undefined : stripPrivateSpans(input.evidence),
|
|
12433
|
+
verifiedBy: input.verifiedBy === undefined ? undefined : stripPrivateSpans(input.verifiedBy),
|
|
12434
|
+
};
|
|
11954
12435
|
const type = inferLearningType(input);
|
|
11955
12436
|
const title = input.title?.trim() || titleFromLearning(input.learning);
|
|
11956
12437
|
const body = [
|
|
@@ -11958,6 +12439,20 @@ function learn(input) {
|
|
|
11958
12439
|
input.evidence ? `\nEvidence: ${input.evidence.trim()}` : "",
|
|
11959
12440
|
input.verifiedBy ? `\nVerified by: ${input.verifiedBy.trim()}` : "",
|
|
11960
12441
|
].join("").trim();
|
|
12442
|
+
// Strict (agent/CLI) repo learnings must be grounded: a learning with no cited
|
|
12443
|
+
// paths at all is rejected. Citation-free notes are allowed only in the
|
|
12444
|
+
// personal store (`kage learn --personal` / learnPersonal), where recall
|
|
12445
|
+
// labels them unverifiable instead of trusting them as repo facts.
|
|
12446
|
+
if (input.strictCitations && !(input.paths ?? []).filter(Boolean).length && !input.allowMissingPaths) {
|
|
12447
|
+
return {
|
|
12448
|
+
ok: false,
|
|
12449
|
+
errors: [
|
|
12450
|
+
"Citation required: repo learnings must cite at least one path (--paths) so the memory stays verifiable. " +
|
|
12451
|
+
"Pass allow_missing_paths for a file you are about to create, or use kage learn --personal for a cross-repo, citation-free personal note.",
|
|
12452
|
+
],
|
|
12453
|
+
warnings: [],
|
|
12454
|
+
};
|
|
12455
|
+
}
|
|
11961
12456
|
return capture({
|
|
11962
12457
|
projectDir: input.projectDir,
|
|
11963
12458
|
title,
|
|
@@ -11971,10 +12466,21 @@ function learn(input) {
|
|
|
11971
12466
|
allowMissingPaths: input.allowMissingPaths,
|
|
11972
12467
|
strictCitations: input.strictCitations,
|
|
11973
12468
|
graphNodes: input.graphNodes,
|
|
12469
|
+
pendingReview: input.pendingReview,
|
|
12470
|
+
discoveryTokens: input.discoveryTokens,
|
|
11974
12471
|
});
|
|
11975
12472
|
}
|
|
11976
12473
|
function capture(input) {
|
|
11977
12474
|
ensureMemoryDirs(input.projectDir);
|
|
12475
|
+
// Privacy tags: redact <private> spans from every text field before any
|
|
12476
|
+
// validation, scanning, or storage — private content must never hit disk.
|
|
12477
|
+
input = {
|
|
12478
|
+
...input,
|
|
12479
|
+
title: stripPrivateSpans(input.title),
|
|
12480
|
+
summary: input.summary === undefined ? undefined : stripPrivateSpans(input.summary),
|
|
12481
|
+
body: stripPrivateSpans(input.body),
|
|
12482
|
+
context: input.context ? stripPrivateFromContext(input.context) : input.context,
|
|
12483
|
+
};
|
|
11978
12484
|
const type = input.type ?? "reference";
|
|
11979
12485
|
if (!exports.MEMORY_TYPES.includes(type)) {
|
|
11980
12486
|
return { ok: false, errors: [`Invalid memory type: ${type}`] };
|
|
@@ -12027,7 +12533,7 @@ function capture(input) {
|
|
|
12027
12533
|
scope: "repo",
|
|
12028
12534
|
visibility: "team",
|
|
12029
12535
|
sensitivity: "internal",
|
|
12030
|
-
status: "approved",
|
|
12536
|
+
status: input.pendingReview ? "pending" : "approved",
|
|
12031
12537
|
confidence: DEFAULT_CONFIDENCE,
|
|
12032
12538
|
tags: input.tags ?? [],
|
|
12033
12539
|
paths: groundedPaths,
|
|
@@ -12055,6 +12561,12 @@ function capture(input) {
|
|
|
12055
12561
|
reports_stale: 0,
|
|
12056
12562
|
review_boundary: "git_or_pr",
|
|
12057
12563
|
promotion_requires_review: true,
|
|
12564
|
+
// Discovery cost receipt: what producing this knowledge cost (exploration +
|
|
12565
|
+
// reasoning tokens). Caller-reported when available; otherwise a conservative
|
|
12566
|
+
// per-type default, flagged estimated so receipts can qualify the claim.
|
|
12567
|
+
...(Number.isFinite(Number(input.discoveryTokens)) && Number(input.discoveryTokens) > 0
|
|
12568
|
+
? { discovery_tokens: Math.floor(Number(input.discoveryTokens)), discovery_tokens_estimated: false }
|
|
12569
|
+
: { discovery_tokens: defaultDiscoveryTokens(type), discovery_tokens_estimated: true }),
|
|
12058
12570
|
},
|
|
12059
12571
|
created_at: createdAt,
|
|
12060
12572
|
updated_at: createdAt,
|
|
@@ -12068,7 +12580,7 @@ function capture(input) {
|
|
|
12068
12580
|
...packet.quality,
|
|
12069
12581
|
...evaluateMemoryQuality(input.projectDir, packet),
|
|
12070
12582
|
};
|
|
12071
|
-
const path = writePacket(input.projectDir, packet, "packets");
|
|
12583
|
+
const path = writePacket(input.projectDir, packet, input.pendingReview ? "pending" : "packets");
|
|
12072
12584
|
recordMemoryAudit(input.projectDir, "capture", [packet], {
|
|
12073
12585
|
type: packet.type,
|
|
12074
12586
|
status: packet.status,
|
|
@@ -12297,6 +12809,16 @@ Before finishing a task that changed files: kage_pr_summarize or kage_propose_fr
|
|
|
12297
12809
|
If recalled memory helped: kage_feedback helpful. If wrong or stale: kage_feedback wrong or stale."
|
|
12298
12810
|
fi
|
|
12299
12811
|
|
|
12812
|
+
# Session continuity: append a compact "previously…" digest when prior session data exists.
|
|
12813
|
+
if command -v kage >/dev/null 2>&1; then
|
|
12814
|
+
PREVIOUSLY="$(kage resume --project "$CWD" 2>/dev/null || true)"
|
|
12815
|
+
if [[ -n "$PREVIOUSLY" ]]; then
|
|
12816
|
+
POLICY="$POLICY
|
|
12817
|
+
|
|
12818
|
+
$PREVIOUSLY"
|
|
12819
|
+
fi
|
|
12820
|
+
fi
|
|
12821
|
+
|
|
12300
12822
|
KAGE_MSG="$POLICY" python3 -c "import json,os; print(json.dumps({'systemMessage': os.environ['KAGE_MSG']}))"
|
|
12301
12823
|
`;
|
|
12302
12824
|
const stopHookScript = `#!/usr/bin/env bash
|
|
@@ -12333,6 +12855,20 @@ print(d.get("agent_instruction") or "Kage memory reconciliation required before
|
|
|
12333
12855
|
fi
|
|
12334
12856
|
fi
|
|
12335
12857
|
|
|
12858
|
+
# Automatic capture fallback: if this session recorded observations but produced no new
|
|
12859
|
+
# memory packets, quietly distill them into pending drafts for later review. Best-effort;
|
|
12860
|
+
# kage distill --auto is silent on empty or already-captured sessions and never blocks.
|
|
12861
|
+
SESSION="$(printf "%s" "$PAYLOAD" | python3 -c 'import json, sys
|
|
12862
|
+
try:
|
|
12863
|
+
d = json.load(sys.stdin)
|
|
12864
|
+
except Exception:
|
|
12865
|
+
d = {}
|
|
12866
|
+
print(d.get("session_id") or d.get("sessionId") or "")
|
|
12867
|
+
' 2>/dev/null || echo "")"
|
|
12868
|
+
if [[ -n "$SESSION" && -d "$CWD/.agent_memory/observations" ]]; then
|
|
12869
|
+
kage distill --project "$CWD" --session "$SESSION" --auto --json >/dev/null 2>&1 || true
|
|
12870
|
+
fi
|
|
12871
|
+
|
|
12336
12872
|
exit 0
|
|
12337
12873
|
`;
|
|
12338
12874
|
const observeHookScript = `#!/usr/bin/env bash
|
|
@@ -12458,6 +12994,71 @@ print(json.dumps({"additionalContext": os.environ.get("KAGE_CONTEXT", "")}))
|
|
|
12458
12994
|
fi
|
|
12459
12995
|
fi
|
|
12460
12996
|
|
|
12997
|
+
exit 0
|
|
12998
|
+
`;
|
|
12999
|
+
const readContextHookScript = `#!/usr/bin/env bash
|
|
13000
|
+
# Kage PreToolUse(Read) hook — injects verified file-linked memory right before the agent reads a file.
|
|
13001
|
+
# Only currently-verified packets (citations checked, not stale) are ever injected.
|
|
13002
|
+
# Silent if Kage is not initialized in the current project. Never blocks the Read.
|
|
13003
|
+
set -euo pipefail
|
|
13004
|
+
|
|
13005
|
+
PAYLOAD="$(cat || true)"
|
|
13006
|
+
CWD="$(PAYLOAD="$PAYLOAD" python3 -c 'import json, os
|
|
13007
|
+
try:
|
|
13008
|
+
d = json.loads(os.environ.get("PAYLOAD") or "{}")
|
|
13009
|
+
except Exception:
|
|
13010
|
+
d = {}
|
|
13011
|
+
print(d.get("cwd") or os.environ.get("CLAUDE_PROJECT_DIR") or "")
|
|
13012
|
+
' 2>/dev/null || echo "")"
|
|
13013
|
+
|
|
13014
|
+
[[ -d "$CWD/.agent_memory" ]] || exit 0
|
|
13015
|
+
command -v kage >/dev/null 2>&1 || exit 0
|
|
13016
|
+
|
|
13017
|
+
FILE_PATH="$(PAYLOAD="$PAYLOAD" python3 -c 'import json, os
|
|
13018
|
+
try:
|
|
13019
|
+
d = json.loads(os.environ.get("PAYLOAD") or "{}")
|
|
13020
|
+
except Exception:
|
|
13021
|
+
d = {}
|
|
13022
|
+
tool_input = d.get("tool_input") or d.get("toolInput") or {}
|
|
13023
|
+
path = tool_input.get("file_path") if isinstance(tool_input, dict) else ""
|
|
13024
|
+
print(path or "")
|
|
13025
|
+
' 2>/dev/null || echo "")"
|
|
13026
|
+
[[ -n "$FILE_PATH" ]] || exit 0
|
|
13027
|
+
[[ "$FILE_PATH" = /* ]] || FILE_PATH="$CWD/$FILE_PATH"
|
|
13028
|
+
|
|
13029
|
+
# Skip files outside the project: memory is repo-scoped.
|
|
13030
|
+
case "$FILE_PATH" in
|
|
13031
|
+
"$CWD"/*) ;;
|
|
13032
|
+
*) exit 0 ;;
|
|
13033
|
+
esac
|
|
13034
|
+
|
|
13035
|
+
SESSION="$(PAYLOAD="$PAYLOAD" python3 -c 'import json, os
|
|
13036
|
+
try:
|
|
13037
|
+
d = json.loads(os.environ.get("PAYLOAD") or "{}")
|
|
13038
|
+
except Exception:
|
|
13039
|
+
d = {}
|
|
13040
|
+
print(d.get("session_id") or d.get("sessionId") or "default")
|
|
13041
|
+
' 2>/dev/null || echo "default")"
|
|
13042
|
+
|
|
13043
|
+
# Dedup: inject at most once per file per session via a tiny /tmp state file
|
|
13044
|
+
# keyed by session_id+path. Failure to track must never block the Read.
|
|
13045
|
+
STATE_DIR="/tmp/kage-read-context"
|
|
13046
|
+
mkdir -p "$STATE_DIR" 2>/dev/null || true
|
|
13047
|
+
KEY="$(printf "%s|%s" "$SESSION" "$FILE_PATH" | python3 -c 'import hashlib, sys
|
|
13048
|
+
print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest()[:24])
|
|
13049
|
+
' 2>/dev/null || echo "")"
|
|
13050
|
+
if [[ -n "$KEY" && -d "$STATE_DIR" ]]; then
|
|
13051
|
+
[[ -e "$STATE_DIR/$KEY" ]] && exit 0
|
|
13052
|
+
: > "$STATE_DIR/$KEY" 2>/dev/null || true
|
|
13053
|
+
fi
|
|
13054
|
+
|
|
13055
|
+
CONTEXT="$(kage file-context --project "$CWD" --path "$FILE_PATH" 2>/dev/null || true)"
|
|
13056
|
+
if [[ -n "$CONTEXT" ]]; then
|
|
13057
|
+
KAGE_CONTEXT="$CONTEXT" python3 -c 'import json, os
|
|
13058
|
+
print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "additionalContext": os.environ.get("KAGE_CONTEXT", "")}}))
|
|
13059
|
+
' 2>/dev/null || true
|
|
13060
|
+
fi
|
|
13061
|
+
|
|
12461
13062
|
exit 0
|
|
12462
13063
|
`;
|
|
12463
13064
|
const settingsPath = (0, node_path_1.join)(home, ".claude", "settings.json");
|
|
@@ -12465,7 +13066,11 @@ exit 0
|
|
|
12465
13066
|
hooks: {
|
|
12466
13067
|
SessionStart: [{ matcher: "", hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/session-start.sh", timeout: 5 }] }],
|
|
12467
13068
|
UserPromptSubmit: [{ hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/observe.sh", timeout: 12 }] }],
|
|
12468
|
-
PreToolUse: [
|
|
13069
|
+
PreToolUse: [
|
|
13070
|
+
{ matcher: "", hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/observe.sh", timeout: 5 }] },
|
|
13071
|
+
// Verified memory at the moment of relevance: short timeout, never blocks the Read.
|
|
13072
|
+
{ matcher: "Read", hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/kage-read-context.sh", timeout: 6 }] },
|
|
13073
|
+
],
|
|
12469
13074
|
PostToolUse: [{ matcher: "", hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/observe.sh", timeout: 5 }] }],
|
|
12470
13075
|
PostToolUseFailure: [{ matcher: "", hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/observe.sh", timeout: 5 }] }],
|
|
12471
13076
|
PreCompact: [{ hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/observe.sh", timeout: 20 }] }],
|
|
@@ -12477,7 +13082,7 @@ exit 0
|
|
|
12477
13082
|
setSnippet(path, JSON.stringify({ mcpServers: { kage: server } }, null, 2), [
|
|
12478
13083
|
"Add the MCP server to ~/.claude.json, then restart Claude Code.",
|
|
12479
13084
|
"alwaysLoad: true makes Kage tools immediately visible without requiring ToolSearch.",
|
|
12480
|
-
`Also create ${hookDir}/session-start.sh, observe.sh, and stop.sh with the hook scripts and add SessionStart/UserPromptSubmit/PostToolUse/PostToolUseFailure/PreCompact/Stop/SessionEnd hooks to ~/.claude/settings.json.`,
|
|
13085
|
+
`Also create ${hookDir}/session-start.sh, observe.sh, kage-read-context.sh, and stop.sh with the hook scripts and add SessionStart/UserPromptSubmit/PreToolUse/PostToolUse/PostToolUseFailure/PreCompact/Stop/SessionEnd hooks to ~/.claude/settings.json.`,
|
|
12481
13086
|
"Run `kage init --project <repo>` inside each repo to install the ambient memory policy.",
|
|
12482
13087
|
], true);
|
|
12483
13088
|
if (options.write) {
|
|
@@ -12486,6 +13091,7 @@ exit 0
|
|
|
12486
13091
|
(0, node_fs_1.mkdirSync)(hookDir, { recursive: true });
|
|
12487
13092
|
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(hookDir, "session-start.sh"), hookScript, { mode: 0o755 });
|
|
12488
13093
|
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(hookDir, "observe.sh"), observeHookScript, { mode: 0o755 });
|
|
13094
|
+
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(hookDir, "kage-read-context.sh"), readContextHookScript, { mode: 0o755 });
|
|
12489
13095
|
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(hookDir, "stop.sh"), stopHookScript, { mode: 0o755 });
|
|
12490
13096
|
upsertJsonSettings(settingsPath, hookEntry);
|
|
12491
13097
|
result.wrote = true;
|
|
@@ -12626,7 +13232,7 @@ function claudeHookEventConfigured(settings, event) {
|
|
|
12626
13232
|
function claudeAmbientHookSummary(homeDir) {
|
|
12627
13233
|
const settingsPath = (0, node_path_1.join)(homeDir, ".claude", "settings.json");
|
|
12628
13234
|
const hookDir = (0, node_path_1.join)(homeDir, ".claude", "kage", "hooks");
|
|
12629
|
-
const scriptPaths = [(0, node_path_1.join)(hookDir, "session-start.sh"), (0, node_path_1.join)(hookDir, "observe.sh"), (0, node_path_1.join)(hookDir, "stop.sh")];
|
|
13235
|
+
const scriptPaths = [(0, node_path_1.join)(hookDir, "session-start.sh"), (0, node_path_1.join)(hookDir, "observe.sh"), (0, node_path_1.join)(hookDir, "kage-read-context.sh"), (0, node_path_1.join)(hookDir, "stop.sh")];
|
|
12630
13236
|
let settings = {};
|
|
12631
13237
|
if ((0, node_fs_1.existsSync)(settingsPath)) {
|
|
12632
13238
|
const parsed = readJson(settingsPath);
|
|
@@ -12742,6 +13348,14 @@ function observationHash(projectDir, event) {
|
|
|
12742
13348
|
}
|
|
12743
13349
|
function observe(projectDir, event) {
|
|
12744
13350
|
ensureMemoryDirs(projectDir);
|
|
13351
|
+
// Privacy tags: redact <private> spans from free-text fields before hashing,
|
|
13352
|
+
// scanning, or persisting the observation record.
|
|
13353
|
+
event = {
|
|
13354
|
+
...event,
|
|
13355
|
+
text: event.text === undefined ? undefined : stripPrivateSpans(event.text),
|
|
13356
|
+
summary: event.summary === undefined ? undefined : stripPrivateSpans(event.summary),
|
|
13357
|
+
command: event.command === undefined ? undefined : stripPrivateSpans(event.command),
|
|
13358
|
+
};
|
|
12745
13359
|
const allowed = ["session_start", "user_prompt", "tool_use", "tool_result", "file_change", "command_result", "test_result", "session_end"];
|
|
12746
13360
|
if (!allowed.includes(event.type))
|
|
12747
13361
|
return { ok: false, stored: false, duplicate: false, errors: [`Invalid observation type: ${event.type}`] };
|
|
@@ -12754,6 +13368,9 @@ function observe(projectDir, event) {
|
|
|
12754
13368
|
if ((0, node_fs_1.existsSync)(path))
|
|
12755
13369
|
return { ok: true, stored: false, duplicate: true, path, errors: [] };
|
|
12756
13370
|
const timestamp = event.timestamp ? new Date(event.timestamp).toISOString() : nowIso();
|
|
13371
|
+
// Tag low-signal events at ingestion (manual learn/capture is never gated, but
|
|
13372
|
+
// auto-distill skips tagged events cheaply without rescoring).
|
|
13373
|
+
const lowSignal = observationSignalScore(event) < exports.AUTO_DISTILL_SIGNAL_THRESHOLD;
|
|
12757
13374
|
const record = {
|
|
12758
13375
|
...event,
|
|
12759
13376
|
schema_version: 1,
|
|
@@ -12763,6 +13380,7 @@ function observe(projectDir, event) {
|
|
|
12763
13380
|
session_id: event.session_id || "default",
|
|
12764
13381
|
timestamp,
|
|
12765
13382
|
stored_at: nowIso(),
|
|
13383
|
+
...(lowSignal ? { low_signal: true } : {}),
|
|
12766
13384
|
};
|
|
12767
13385
|
writeJson(path, record);
|
|
12768
13386
|
return { ok: true, stored: true, duplicate: false, record, path, errors: [] };
|
|
@@ -12774,6 +13392,143 @@ function loadObservations(projectDir, sessionId) {
|
|
|
12774
13392
|
.filter((record) => !sessionId || record.session_id === sessionId)
|
|
12775
13393
|
.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
12776
13394
|
}
|
|
13395
|
+
// Auto-distill quality gate. Observations must score at least this (0..1) on
|
|
13396
|
+
// observationSignalScore before they may seed an auto-distilled draft. 0.4 was picked
|
|
13397
|
+
// so genuine learnings clear it comfortably (causal prose plus a path, command, or
|
|
13398
|
+
// code identifier lands around 0.45-0.65) while machine noise hard-rejects to 0:
|
|
13399
|
+
// raw JSON payloads, hook/system envelopes, flag-token dumps, sub-50-char fragments,
|
|
13400
|
+
// and echoes of Kage's own demo/receipt output. Manual `kage learn`/`kage capture`
|
|
13401
|
+
// and manual `kage distill` are never gated — explicit intent outranks the heuristic.
|
|
13402
|
+
exports.AUTO_DISTILL_SIGNAL_THRESHOLD = 0.4;
|
|
13403
|
+
// Markers of hook/system plumbing payloads that sometimes leak into observation text
|
|
13404
|
+
// (e.g. a raw <task-notification> block stored as a "user prompt").
|
|
13405
|
+
const HOOK_PAYLOAD_MARKERS = [
|
|
13406
|
+
"task-notification",
|
|
13407
|
+
"task_notification",
|
|
13408
|
+
"tool-use-id",
|
|
13409
|
+
"tool_use_id",
|
|
13410
|
+
"toolu_",
|
|
13411
|
+
"system-reminder",
|
|
13412
|
+
"system_reminder",
|
|
13413
|
+
"hookspecificoutput",
|
|
13414
|
+
"hook_event_name",
|
|
13415
|
+
"stop_hook_active",
|
|
13416
|
+
"sessionstart hook",
|
|
13417
|
+
];
|
|
13418
|
+
// Markers of Kage's own output being echoed back as "memory" (truth-report headers,
|
|
13419
|
+
// demo proof lines, value-receipt fields). Storing our own output is feedback noise.
|
|
13420
|
+
const KAGE_ECHO_MARKERS = [
|
|
13421
|
+
"truth report",
|
|
13422
|
+
"hallucinated citations never enter storage",
|
|
13423
|
+
"hallucinated citation",
|
|
13424
|
+
"value_receipt",
|
|
13425
|
+
"stale_withheld",
|
|
13426
|
+
"# previously (kage)",
|
|
13427
|
+
];
|
|
13428
|
+
function jsonNoiseText(text) {
|
|
13429
|
+
const trimmed = text.trim();
|
|
13430
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
13431
|
+
try {
|
|
13432
|
+
JSON.parse(trimmed);
|
|
13433
|
+
return true;
|
|
13434
|
+
}
|
|
13435
|
+
catch {
|
|
13436
|
+
// Not parseable; fall through to the density heuristics.
|
|
13437
|
+
}
|
|
13438
|
+
}
|
|
13439
|
+
// Three or more quoted-key patterns reads as serialized data, not prose.
|
|
13440
|
+
if ((text.match(/"[\w$.-]+"\s*:/g) ?? []).length >= 3)
|
|
13441
|
+
return true;
|
|
13442
|
+
const dense = text.replace(/\s+/g, "");
|
|
13443
|
+
if (!dense.length)
|
|
13444
|
+
return true;
|
|
13445
|
+
const jsonPunctuation = (text.match(/[{}[\]":,]/g) ?? []).length;
|
|
13446
|
+
return jsonPunctuation / dense.length > 0.5;
|
|
13447
|
+
}
|
|
13448
|
+
function flagTokenNoise(lower) {
|
|
13449
|
+
// Dumps like "interrupted false isImage false noOutputExpected false" or
|
|
13450
|
+
// "x=false y=true": several key/boolean pairs making up a large share of the text.
|
|
13451
|
+
const pairs = (lower.match(/\b[a-z][\w-]*\s*[=:]?\s*(true|false|null|undefined)\b/g) ?? []).length;
|
|
13452
|
+
if (pairs < 2)
|
|
13453
|
+
return false;
|
|
13454
|
+
const words = lower.split(/\s+/).filter(Boolean).length;
|
|
13455
|
+
return words > 0 && (pairs * 2) / words >= 0.4;
|
|
13456
|
+
}
|
|
13457
|
+
// Imperative/causal vocabulary that marks durable, future-facing knowledge.
|
|
13458
|
+
const SIGNAL_CAUSAL_MARKERS = [
|
|
13459
|
+
"fixed",
|
|
13460
|
+
"because",
|
|
13461
|
+
"use ",
|
|
13462
|
+
"instead",
|
|
13463
|
+
"run ",
|
|
13464
|
+
"must",
|
|
13465
|
+
"should",
|
|
13466
|
+
"requires",
|
|
13467
|
+
"prefer",
|
|
13468
|
+
"avoid",
|
|
13469
|
+
"fail",
|
|
13470
|
+
"caused",
|
|
13471
|
+
"root cause",
|
|
13472
|
+
"why",
|
|
13473
|
+
"decision",
|
|
13474
|
+
"convention",
|
|
13475
|
+
"gotcha",
|
|
13476
|
+
"workaround",
|
|
13477
|
+
"hypothesis",
|
|
13478
|
+
"issue",
|
|
13479
|
+
"explains",
|
|
13480
|
+
"invariant",
|
|
13481
|
+
"maps ",
|
|
13482
|
+
"after changing",
|
|
13483
|
+
"when changing",
|
|
13484
|
+
"never",
|
|
13485
|
+
"always",
|
|
13486
|
+
];
|
|
13487
|
+
/**
|
|
13488
|
+
* Pure 0..1 signal score for an observation: how likely its text is durable,
|
|
13489
|
+
* human-meaningful repo knowledge rather than machine noise. Hard rejects (0):
|
|
13490
|
+
* raw JSON / key-value noise, hook or system payloads, echoes of Kage's own
|
|
13491
|
+
* output, flag-token dumps, and fragments under 50 chars. Positive signal:
|
|
13492
|
+
* imperative/causal language, file path citations, code identifiers, commands.
|
|
13493
|
+
*/
|
|
13494
|
+
function observationSignalScore(observation) {
|
|
13495
|
+
const prose = [observation.summary, observation.text].filter(Boolean).join("\n").trim();
|
|
13496
|
+
const combined = [prose, observation.command].filter(Boolean).join("\n").trim();
|
|
13497
|
+
if (combined.length < 50)
|
|
13498
|
+
return 0;
|
|
13499
|
+
const lower = combined.toLowerCase();
|
|
13500
|
+
if (HOOK_PAYLOAD_MARKERS.some((marker) => lower.includes(marker)))
|
|
13501
|
+
return 0;
|
|
13502
|
+
if (KAGE_ECHO_MARKERS.some((marker) => lower.includes(marker)))
|
|
13503
|
+
return 0;
|
|
13504
|
+
if (jsonNoiseText(combined))
|
|
13505
|
+
return 0;
|
|
13506
|
+
if (flagTokenNoise(lower))
|
|
13507
|
+
return 0;
|
|
13508
|
+
let score = 0;
|
|
13509
|
+
const causalHits = SIGNAL_CAUSAL_MARKERS.filter((marker) => lower.includes(marker)).length;
|
|
13510
|
+
if (causalHits > 0)
|
|
13511
|
+
score += Math.min(0.35, 0.25 + (causalHits - 1) * 0.05);
|
|
13512
|
+
const citesPath = Boolean(observation.path)
|
|
13513
|
+
|| /\b[\w.-]+\/[\w./-]+\b/.test(prose)
|
|
13514
|
+
|| /\b[\w-]+\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|rb|json|ya?ml|toml|md|sh|css|html|sql)\b/i.test(prose);
|
|
13515
|
+
if (citesPath)
|
|
13516
|
+
score += 0.2;
|
|
13517
|
+
const codeIdentifier = /\b[a-z][a-z0-9]*[A-Z]\w*\b/.test(prose) // camelCase
|
|
13518
|
+
|| /\b[a-z0-9]+_[a-z0-9_]+\b/.test(prose) // snake_case
|
|
13519
|
+
|| /`[^`]+`/.test(prose)
|
|
13520
|
+
|| /\b\w+\(\)/.test(prose);
|
|
13521
|
+
if (codeIdentifier)
|
|
13522
|
+
score += 0.15;
|
|
13523
|
+
const commandLine = Boolean(observation.command)
|
|
13524
|
+
|| /(^|\s)(npm|pnpm|yarn|npx|node|git|cargo|make|pytest|go|tsc|kage)\s+[\w.-]/.test(prose);
|
|
13525
|
+
if (commandLine)
|
|
13526
|
+
score += 0.15;
|
|
13527
|
+
const words = combined.split(/\s+/).filter(Boolean).length;
|
|
13528
|
+
if (combined.length >= 80 && words >= 10)
|
|
13529
|
+
score += 0.1;
|
|
13530
|
+
return Math.min(1, score);
|
|
13531
|
+
}
|
|
12777
13532
|
function reusableFileObservation(event) {
|
|
12778
13533
|
const text = `${event.summary ?? ""}\n${event.text ?? ""}`.trim();
|
|
12779
13534
|
if (!text)
|
|
@@ -13244,11 +13999,39 @@ function kageSessionLearningLedger(projectDir, options = {}) {
|
|
|
13244
13999
|
context_block: learningLedgerContextBlock(reportWithoutBlock),
|
|
13245
14000
|
};
|
|
13246
14001
|
}
|
|
13247
|
-
|
|
14002
|
+
// Mechanical packets (branch change memory, prior auto-distilled drafts) never count as the
|
|
14003
|
+
// agent having captured memory; only deliberate captures/learns/distills suppress the
|
|
14004
|
+
// Stop-hook auto-distill fallback.
|
|
14005
|
+
function sessionAlreadyCaptured(projectDir, sessionId, observations) {
|
|
14006
|
+
const firstAt = observations[0]?.timestamp ?? "";
|
|
14007
|
+
const mechanicalTags = ["diff-proposal", "change-memory", exports.AUTO_DISTILL_TAG];
|
|
14008
|
+
return [...loadApprovedPackets(projectDir), ...loadPendingPackets(projectDir)].some((packet) => {
|
|
14009
|
+
if (packet.source_refs.some((ref) => ref.kind === "observation_session" && ref.session_id === sessionId))
|
|
14010
|
+
return true;
|
|
14011
|
+
if (packet.type === "repo_map" || packet.quality?.reviewer === "kage-indexer")
|
|
14012
|
+
return false; // generated by indexing
|
|
14013
|
+
if (packet.tags.some((tag) => mechanicalTags.includes(tag)))
|
|
14014
|
+
return false;
|
|
14015
|
+
return Boolean(firstAt) && packet.created_at >= firstAt;
|
|
14016
|
+
});
|
|
14017
|
+
}
|
|
14018
|
+
function distillSession(projectDir, sessionId, options = {}) {
|
|
14019
|
+
const auto = Boolean(options.auto);
|
|
14020
|
+
const mode = auto ? "auto" : "manual";
|
|
13248
14021
|
const observations = loadObservations(projectDir, sessionId);
|
|
14022
|
+
if (auto && observations.length === 0) {
|
|
14023
|
+
return { ok: true, session_id: sessionId, observations: 0, candidates: [], errors: [], mode, skipped_reason: "no_observations", skipped_low_signal: 0 };
|
|
14024
|
+
}
|
|
14025
|
+
if (auto && sessionAlreadyCaptured(projectDir, sessionId, observations)) {
|
|
14026
|
+
return { ok: true, session_id: sessionId, observations: observations.length, candidates: [], errors: [], mode, skipped_reason: "session_already_captured", skipped_low_signal: 0 };
|
|
14027
|
+
}
|
|
13249
14028
|
const candidates = [];
|
|
13250
14029
|
const errors = [];
|
|
13251
14030
|
const observationIds = observations.map((event) => event.id);
|
|
14031
|
+
// Discovery cost of distilled knowledge: the token estimate of the session material
|
|
14032
|
+
// that produced it. Still an estimate (the agent's reasoning tokens are unknown),
|
|
14033
|
+
// so it stays flagged discovery_tokens_estimated.
|
|
14034
|
+
const sessionDiscoveryTokens = observations.reduce((sum, event) => sum + estimateTokens([event.summary, event.text, event.command, event.path].filter(Boolean).join(" ")), 0);
|
|
13252
14035
|
const annotate = (result) => {
|
|
13253
14036
|
if (!result.ok || !result.packet || !result.path)
|
|
13254
14037
|
return result;
|
|
@@ -13262,16 +14045,35 @@ function distillSession(projectDir, sessionId) {
|
|
|
13262
14045
|
];
|
|
13263
14046
|
result.packet.quality = {
|
|
13264
14047
|
...result.packet.quality,
|
|
13265
|
-
|
|
14048
|
+
...(sessionDiscoveryTokens > 0
|
|
14049
|
+
? { discovery_tokens: sessionDiscoveryTokens, discovery_tokens_estimated: true }
|
|
14050
|
+
: {}),
|
|
14051
|
+
distillation: auto ? "auto_distill" : "automatic_observation_candidate",
|
|
13266
14052
|
admission: evaluateMemoryAdmission(projectDir, result.packet),
|
|
13267
14053
|
suggested_review_action: suggestedAction(classifyPacket(projectDir, result.packet), result.packet.status),
|
|
13268
14054
|
};
|
|
13269
14055
|
writeJson(result.path, result.packet);
|
|
13270
14056
|
return result;
|
|
13271
14057
|
};
|
|
13272
|
-
const
|
|
13273
|
-
|
|
13274
|
-
|
|
14058
|
+
const autoTags = auto ? [exports.AUTO_DISTILL_TAG] : [];
|
|
14059
|
+
// Auto-distill quality gate: drafts may only be seeded by observations scoring at
|
|
14060
|
+
// least AUTO_DISTILL_SIGNAL_THRESHOLD. Events tagged low_signal at ingestion skip
|
|
14061
|
+
// cheaply; untagged (older) records are scored here. Manual distill is not gated.
|
|
14062
|
+
let skippedLowSignal = 0;
|
|
14063
|
+
const signalGate = (events) => {
|
|
14064
|
+
if (!auto)
|
|
14065
|
+
return events;
|
|
14066
|
+
return events.filter((event) => {
|
|
14067
|
+
const lowSignal = event.low_signal === true
|
|
14068
|
+
|| (event.low_signal === undefined && observationSignalScore(event) < exports.AUTO_DISTILL_SIGNAL_THRESHOLD);
|
|
14069
|
+
if (lowSignal)
|
|
14070
|
+
skippedLowSignal += 1;
|
|
14071
|
+
return !lowSignal;
|
|
14072
|
+
});
|
|
14073
|
+
};
|
|
14074
|
+
const commandEvents = signalGate(observations.filter((event) => event.type === "command_result" && event.command));
|
|
14075
|
+
const fileEvents = signalGate(observations.filter((event) => event.type === "file_change" && event.path));
|
|
14076
|
+
const promptEvents = signalGate(observations.filter((event) => event.type === "user_prompt" && (event.text || event.summary)));
|
|
13275
14077
|
const meaningfulCommandEvents = commandEvents
|
|
13276
14078
|
.map((event) => ({ event, reusable: reusableCommandObservation(event, knownRepoCommands(projectDir)) }))
|
|
13277
14079
|
.filter((item) => Boolean(item.reusable));
|
|
@@ -13284,8 +14086,9 @@ function distillSession(projectDir, sessionId) {
|
|
|
13284
14086
|
summary: `Observed commands: ${commands.slice(0, 3).join(", ")}`,
|
|
13285
14087
|
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.`,
|
|
13286
14088
|
type: "runbook",
|
|
13287
|
-
tags: ["observed-session", "commands", "runbook"],
|
|
14089
|
+
tags: ["observed-session", "commands", "runbook", ...autoTags],
|
|
13288
14090
|
paths: unique(meaningfulCommandEvents.map((item) => item.event.path).filter(Boolean)),
|
|
14091
|
+
pendingReview: auto,
|
|
13289
14092
|
})));
|
|
13290
14093
|
}
|
|
13291
14094
|
const meaningfulFileEvents = fileEvents
|
|
@@ -13300,8 +14103,9 @@ function distillSession(projectDir, sessionId) {
|
|
|
13300
14103
|
summary: lead,
|
|
13301
14104
|
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.`,
|
|
13302
14105
|
type: "workflow",
|
|
13303
|
-
tags: ["observed-session", "workflow"],
|
|
14106
|
+
tags: ["observed-session", "workflow", ...autoTags],
|
|
13304
14107
|
paths,
|
|
14108
|
+
pendingReview: auto,
|
|
13305
14109
|
})));
|
|
13306
14110
|
}
|
|
13307
14111
|
if (promptEvents.length) {
|
|
@@ -13312,13 +14116,150 @@ function distillSession(projectDir, sessionId) {
|
|
|
13312
14116
|
title: titleFromLearning(text),
|
|
13313
14117
|
learning: text,
|
|
13314
14118
|
evidence: `Observation session: ${sessionId}`,
|
|
13315
|
-
tags: ["observed-session", "intent"],
|
|
14119
|
+
tags: ["observed-session", "intent", ...autoTags],
|
|
14120
|
+
pendingReview: auto,
|
|
13316
14121
|
})));
|
|
13317
14122
|
}
|
|
13318
14123
|
for (const result of candidates)
|
|
13319
14124
|
if (!result.ok)
|
|
13320
14125
|
errors.push(...result.errors);
|
|
13321
|
-
return {
|
|
14126
|
+
return {
|
|
14127
|
+
ok: errors.length === 0,
|
|
14128
|
+
session_id: sessionId,
|
|
14129
|
+
observations: observations.length,
|
|
14130
|
+
candidates,
|
|
14131
|
+
errors,
|
|
14132
|
+
mode,
|
|
14133
|
+
...(auto ? { skipped_low_signal: skippedLowSignal } : {}),
|
|
14134
|
+
};
|
|
14135
|
+
}
|
|
14136
|
+
// Session continuity: a compact "previously…" digest the SessionStart hook injects so a new
|
|
14137
|
+
// session starts with last session's context instead of cold. Empty when there is no prior data.
|
|
14138
|
+
function humanPacketAge(iso) {
|
|
14139
|
+
const then = Date.parse(iso);
|
|
14140
|
+
if (!Number.isFinite(then))
|
|
14141
|
+
return "age unknown";
|
|
14142
|
+
const minutes = Math.max(0, Math.floor((Date.now() - then) / 60000));
|
|
14143
|
+
if (minutes < 60)
|
|
14144
|
+
return `${minutes}m ago`;
|
|
14145
|
+
const hours = Math.floor(minutes / 60);
|
|
14146
|
+
if (hours < 48)
|
|
14147
|
+
return `${hours}h ago`;
|
|
14148
|
+
const days = Math.floor(hours / 24);
|
|
14149
|
+
if (days < 60)
|
|
14150
|
+
return `${days}d ago`;
|
|
14151
|
+
return `${Math.floor(days / 30)}mo ago`;
|
|
14152
|
+
}
|
|
14153
|
+
// Timeline-as-index: resume shows a compact one-line-per-packet index of recent
|
|
14154
|
+
// memory instead of full packets — the agent recalls details on demand.
|
|
14155
|
+
const RESUME_TIMELINE_LIMIT = 15;
|
|
14156
|
+
const RESUME_TIMELINE_DETAILED = 3;
|
|
14157
|
+
const RESUME_CONTEXT_TOKEN_BUDGET = 800;
|
|
14158
|
+
function kageResume(projectDir) {
|
|
14159
|
+
ensureMemoryDirs(projectDir);
|
|
14160
|
+
const approved = loadApprovedPackets(projectDir);
|
|
14161
|
+
const pending = loadPendingPackets(projectDir);
|
|
14162
|
+
const observations = loadObservations(projectDir);
|
|
14163
|
+
const bySession = new Map();
|
|
14164
|
+
for (const observation of observations) {
|
|
14165
|
+
const rows = bySession.get(observation.session_id) ?? [];
|
|
14166
|
+
rows.push(observation);
|
|
14167
|
+
bySession.set(observation.session_id, rows);
|
|
14168
|
+
}
|
|
14169
|
+
const latestRows = Array.from(bySession.values())
|
|
14170
|
+
.sort((a, b) => (b.at(-1)?.timestamp ?? "").localeCompare(a.at(-1)?.timestamp ?? ""))[0];
|
|
14171
|
+
const lastSession = latestRows?.length
|
|
14172
|
+
? (() => {
|
|
14173
|
+
const sessionId = latestRows[0].session_id;
|
|
14174
|
+
const distilledTitles = [...approved, ...pending]
|
|
14175
|
+
.filter((packet) => packet.source_refs.some((ref) => ref.kind === "observation_session" && ref.session_id === sessionId))
|
|
14176
|
+
.map((packet) => packet.title);
|
|
14177
|
+
return {
|
|
14178
|
+
session_id: sessionId,
|
|
14179
|
+
first_at: latestRows[0]?.timestamp ?? "",
|
|
14180
|
+
last_at: latestRows.at(-1)?.timestamp ?? "",
|
|
14181
|
+
observations: latestRows.length,
|
|
14182
|
+
paths: unique(latestRows.map((event) => event.path).filter(Boolean)).slice(0, 6),
|
|
14183
|
+
commands: unique(latestRows.map((event) => event.command).filter(Boolean)).slice(0, 3),
|
|
14184
|
+
distilled_titles: unique(distilledTitles).slice(0, 3),
|
|
14185
|
+
};
|
|
14186
|
+
})()
|
|
14187
|
+
: undefined;
|
|
14188
|
+
const changeMemory = approved
|
|
14189
|
+
.filter((packet) => packet.tags.includes("change-memory"))
|
|
14190
|
+
.sort((a, b) => b.updated_at.localeCompare(a.updated_at))[0];
|
|
14191
|
+
const lastChangeMemory = changeMemory
|
|
14192
|
+
? { id: changeMemory.id, title: changeMemory.title, summary: changeMemory.summary, updated_at: changeMemory.updated_at }
|
|
14193
|
+
: undefined;
|
|
14194
|
+
const pendingAutoDistilled = pending.filter((packet) => packet.tags.includes(exports.AUTO_DISTILL_TAG)).length;
|
|
14195
|
+
const reconciliation = kageMemoryReconciliation(projectDir, { limit: 5 });
|
|
14196
|
+
const reconciliationItems = reconciliation.items.map((item) => ({ packet_id: item.packet_id, title: item.title }));
|
|
14197
|
+
const recentPackets = [...approved, ...pending]
|
|
14198
|
+
.sort((a, b) => packetRecency(b).localeCompare(packetRecency(a)))
|
|
14199
|
+
.slice(0, RESUME_TIMELINE_LIMIT);
|
|
14200
|
+
const recentMemory = recentPackets.map((packet) => ({
|
|
14201
|
+
id: packet.id,
|
|
14202
|
+
type: packet.type,
|
|
14203
|
+
title: packet.title,
|
|
14204
|
+
updated_at: packetRecency(packet),
|
|
14205
|
+
age: humanPacketAge(packetRecency(packet)),
|
|
14206
|
+
}));
|
|
14207
|
+
const hasSessionContent = Boolean(lastSession || lastChangeMemory || pendingAutoDistilled || reconciliation.unresolved_count);
|
|
14208
|
+
const hasContent = hasSessionContent || recentMemory.length > 0;
|
|
14209
|
+
const lines = [];
|
|
14210
|
+
if (hasContent) {
|
|
14211
|
+
lines.push("# Previously (Kage)");
|
|
14212
|
+
if (lastSession) {
|
|
14213
|
+
lines.push(`Last session ${lastSession.session_id} (${lastSession.observations} observation${lastSession.observations === 1 ? "" : "s"}, ended ${lastSession.last_at}).`);
|
|
14214
|
+
if (lastSession.paths.length)
|
|
14215
|
+
lines.push(`Worked on: ${lastSession.paths.join(", ")}`);
|
|
14216
|
+
if (lastSession.commands.length)
|
|
14217
|
+
lines.push(`Commands: ${lastSession.commands.join("; ")}`);
|
|
14218
|
+
if (lastSession.distilled_titles.length)
|
|
14219
|
+
lines.push(`Learned: ${lastSession.distilled_titles.join("; ")}`);
|
|
14220
|
+
}
|
|
14221
|
+
if (lastChangeMemory) {
|
|
14222
|
+
lines.push(`Change memory: ${lastChangeMemory.title} — ${lastChangeMemory.summary}`);
|
|
14223
|
+
}
|
|
14224
|
+
if (pendingAutoDistilled) {
|
|
14225
|
+
lines.push(`Pending: ${pendingAutoDistilled} auto-distilled draft${pendingAutoDistilled === 1 ? "" : "s"} awaiting review — run: kage review --project ${projectDir}`);
|
|
14226
|
+
}
|
|
14227
|
+
if (reconciliation.unresolved_count) {
|
|
14228
|
+
lines.push(`Reconcile: ${reconciliation.unresolved_count} linked memory item${reconciliation.unresolved_count === 1 ? "" : "s"} need update — run: kage reconcile --project ${projectDir}`);
|
|
14229
|
+
for (const item of reconciliationItems.slice(0, 3))
|
|
14230
|
+
lines.push(` - ${item.packet_id}: ${item.title}`);
|
|
14231
|
+
}
|
|
14232
|
+
}
|
|
14233
|
+
// Compact timeline index: one line per recent packet, full detail (summary)
|
|
14234
|
+
// only for the newest few, hard-capped to the resume token budget.
|
|
14235
|
+
const block = lines.slice(0, 15);
|
|
14236
|
+
if (recentPackets.length) {
|
|
14237
|
+
block.push("", "## Recent memory");
|
|
14238
|
+
recentPackets.forEach((packet, position) => {
|
|
14239
|
+
const entry = `[${packet.id.slice(0, 12)}] ${packet.type} ${packet.title} (${humanPacketAge(packetRecency(packet))})`;
|
|
14240
|
+
const candidate = [entry];
|
|
14241
|
+
if (position < RESUME_TIMELINE_DETAILED && packet.summary) {
|
|
14242
|
+
const summary = packet.summary.replace(/\s+/g, " ").trim();
|
|
14243
|
+
candidate.push(` ${summary.length > 160 ? `${summary.slice(0, 157)}...` : summary}`);
|
|
14244
|
+
}
|
|
14245
|
+
if (estimateTokens([...block, ...candidate].join("\n")) <= RESUME_CONTEXT_TOKEN_BUDGET)
|
|
14246
|
+
block.push(...candidate);
|
|
14247
|
+
});
|
|
14248
|
+
}
|
|
14249
|
+
return {
|
|
14250
|
+
schema_version: 1,
|
|
14251
|
+
project_dir: projectDir,
|
|
14252
|
+
generated_at: nowIso(),
|
|
14253
|
+
has_content: hasContent,
|
|
14254
|
+
last_session: lastSession,
|
|
14255
|
+
last_change_memory: lastChangeMemory,
|
|
14256
|
+
pending_auto_distilled: pendingAutoDistilled,
|
|
14257
|
+
pending_total: pending.length,
|
|
14258
|
+
...(pendingAutoDistilled ? { review_command: `kage review --project ${projectDir}` } : {}),
|
|
14259
|
+
reconciliation: { unresolved_count: reconciliation.unresolved_count, items: reconciliationItems },
|
|
14260
|
+
recent_memory: recentMemory,
|
|
14261
|
+
context_block: block.join("\n"),
|
|
14262
|
+
};
|
|
13322
14263
|
}
|
|
13323
14264
|
function createDiffChangeMemory(projectDir, summary) {
|
|
13324
14265
|
const branch = summary.branch ?? "detached";
|
|
@@ -14196,18 +15137,21 @@ function installClaudeSettings(projectDir) {
|
|
|
14196
15137
|
writeJson(settingsPath, settings);
|
|
14197
15138
|
}
|
|
14198
15139
|
function initProject(projectDir, options = {}) {
|
|
14199
|
-
// Default init touches ONLY .agent_memory
|
|
14200
|
-
//
|
|
14201
|
-
//
|
|
15140
|
+
// Default init touches ONLY .agent_memory/ plus a one-line .gitattributes
|
|
15141
|
+
// entry wiring the kage-packet merge driver (idempotent; ends hand-resolved
|
|
15142
|
+
// packet JSON conflicts). Agent-policy files (AGENTS.md, CLAUDE.md) and
|
|
15143
|
+
// .claude/settings.json are repo-visible and reviewable, so writing them
|
|
15144
|
+
// requires explicit opt-in (`kage init --with-policy` or `kage policy`).
|
|
14202
15145
|
const policyInstalled = options.policy === true;
|
|
14203
15146
|
if (policyInstalled) {
|
|
14204
15147
|
installAgentPolicy(projectDir);
|
|
14205
15148
|
installClaudeSettings(projectDir);
|
|
14206
15149
|
}
|
|
15150
|
+
const gitAttributes = ensurePacketMergeAttributes(projectDir);
|
|
14207
15151
|
const index = indexProject(projectDir, { graphs: false });
|
|
14208
15152
|
const validation = validateProject(projectDir);
|
|
14209
15153
|
const sampleRecall = recallFromPackets("how do I run tests", loadApprovedPackets(projectDir), 5, "Repo Memory");
|
|
14210
|
-
return { index, validation, sampleRecall, policyInstalled };
|
|
15154
|
+
return { index, validation, sampleRecall, policyInstalled, gitAttributes };
|
|
14211
15155
|
}
|
|
14212
15156
|
function doctorProject(projectDir) {
|
|
14213
15157
|
ensureMemoryDirs(projectDir);
|
|
@@ -14234,13 +15178,362 @@ function doctorProject(projectDir) {
|
|
|
14234
15178
|
sampleRecall: sampleRecall.context_block,
|
|
14235
15179
|
};
|
|
14236
15180
|
}
|
|
14237
|
-
function
|
|
14238
|
-
|
|
14239
|
-
|
|
14240
|
-
|
|
14241
|
-
|
|
14242
|
-
|
|
14243
|
-
|
|
15181
|
+
function repairBackupDir(projectDir) {
|
|
15182
|
+
return (0, node_path_1.join)(memoryRoot(projectDir), "backup");
|
|
15183
|
+
}
|
|
15184
|
+
// Split a git merge-conflicted file into its two sides. Returns null when no
|
|
15185
|
+
// complete conflict block is present. diff3-style base sections (`|||||||`)
|
|
15186
|
+
// belong to neither side and are dropped.
|
|
15187
|
+
function splitConflictSides(content) {
|
|
15188
|
+
let section = "both";
|
|
15189
|
+
let conflicts = 0;
|
|
15190
|
+
const ours = [];
|
|
15191
|
+
const theirs = [];
|
|
15192
|
+
for (const line of content.split("\n")) {
|
|
15193
|
+
if (section === "both" && /^<{7}(\s|$)/.test(line)) {
|
|
15194
|
+
section = "ours";
|
|
15195
|
+
conflicts += 1;
|
|
15196
|
+
continue;
|
|
15197
|
+
}
|
|
15198
|
+
if (section === "ours" && /^\|{7}(\s|$)/.test(line)) {
|
|
15199
|
+
section = "base";
|
|
15200
|
+
continue;
|
|
15201
|
+
}
|
|
15202
|
+
if ((section === "ours" || section === "base") && /^={7}$/.test(line.trimEnd())) {
|
|
15203
|
+
section = "theirs";
|
|
15204
|
+
continue;
|
|
15205
|
+
}
|
|
15206
|
+
if (section === "theirs" && /^>{7}(\s|$)/.test(line)) {
|
|
15207
|
+
section = "both";
|
|
15208
|
+
continue;
|
|
15209
|
+
}
|
|
15210
|
+
if (section === "both") {
|
|
15211
|
+
ours.push(line);
|
|
15212
|
+
theirs.push(line);
|
|
15213
|
+
}
|
|
15214
|
+
else if (section === "ours")
|
|
15215
|
+
ours.push(line);
|
|
15216
|
+
else if (section === "theirs")
|
|
15217
|
+
theirs.push(line);
|
|
15218
|
+
}
|
|
15219
|
+
if (!conflicts || section !== "both")
|
|
15220
|
+
return null;
|
|
15221
|
+
return { ours: ours.join("\n"), theirs: theirs.join("\n") };
|
|
15222
|
+
}
|
|
15223
|
+
function packetRecency(packet) {
|
|
15224
|
+
return String(packet.updated_at ?? packet.created_at ?? "");
|
|
15225
|
+
}
|
|
15226
|
+
// Auto-resolve a merge-conflicted packet by keeping the newest side — but only
|
|
15227
|
+
// when that side parses as JSON. Anything less certain stays a removal.
|
|
15228
|
+
function resolveConflictedPacket(content) {
|
|
15229
|
+
const sides = splitConflictSides(content);
|
|
15230
|
+
if (!sides)
|
|
15231
|
+
return null;
|
|
15232
|
+
const candidates = [];
|
|
15233
|
+
for (const side of [sides.ours, sides.theirs]) {
|
|
15234
|
+
try {
|
|
15235
|
+
const parsed = JSON.parse(side);
|
|
15236
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
|
|
15237
|
+
candidates.push(parsed);
|
|
15238
|
+
}
|
|
15239
|
+
catch {
|
|
15240
|
+
// This side does not parse; the other side may still win.
|
|
15241
|
+
}
|
|
15242
|
+
}
|
|
15243
|
+
if (!candidates.length)
|
|
15244
|
+
return null;
|
|
15245
|
+
candidates.sort((a, b) => packetRecency(b).localeCompare(packetRecency(a)));
|
|
15246
|
+
return candidates[0];
|
|
15247
|
+
}
|
|
15248
|
+
// ---------------------------------------------------------------------------
|
|
15249
|
+
// Packet merge driver. `kage merge-packet <ours> <base> <theirs>` follows the
|
|
15250
|
+
// git merge-driver convention (%A %O %B: write the result to the ours path,
|
|
15251
|
+
// exit 0 on success, non-zero to leave the conflict). v1 policy is whole-file
|
|
15252
|
+
// newest-wins by updated_at — packets are single facts, so field-level merges
|
|
15253
|
+
// buy little over taking the most recently verified side.
|
|
15254
|
+
exports.PACKET_MERGE_ATTRIBUTE_LINE = ".agent_memory/packets/*.json merge=kage-packet";
|
|
15255
|
+
exports.PACKET_MERGE_DRIVER_CONFIG = 'git config merge.kage-packet.driver "npx -y @kage-core/kage-graph-mcp merge-packet %A %O %B"';
|
|
15256
|
+
function mergePacketFiles(oursPath, basePath, theirsPath) {
|
|
15257
|
+
void basePath; // Reserved for a future field-level three-way merge.
|
|
15258
|
+
const readSide = (path) => {
|
|
15259
|
+
const raw = safeReadText(path);
|
|
15260
|
+
if (raw === null)
|
|
15261
|
+
return null;
|
|
15262
|
+
try {
|
|
15263
|
+
const parsed = JSON.parse(raw);
|
|
15264
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
15265
|
+
return { raw, packet: parsed };
|
|
15266
|
+
}
|
|
15267
|
+
}
|
|
15268
|
+
catch {
|
|
15269
|
+
// A side that carries committed conflict markers (the exact failure mode
|
|
15270
|
+
// this driver exists to end) can often be recovered with repair's
|
|
15271
|
+
// conflict-splitting logic before giving up on it.
|
|
15272
|
+
const recovered = resolveConflictedPacket(raw);
|
|
15273
|
+
if (recovered)
|
|
15274
|
+
return { raw: `${JSON.stringify(recovered, null, 2)}\n`, packet: recovered };
|
|
15275
|
+
}
|
|
15276
|
+
return null;
|
|
15277
|
+
};
|
|
15278
|
+
const ours = readSide(oursPath);
|
|
15279
|
+
const theirs = readSide(theirsPath);
|
|
15280
|
+
if (!ours && !theirs) {
|
|
15281
|
+
return { ok: false, winner: null, detail: "kage merge-packet: neither side parses as packet JSON; leaving the conflict for manual resolution." };
|
|
15282
|
+
}
|
|
15283
|
+
let winner;
|
|
15284
|
+
if (ours && theirs) {
|
|
15285
|
+
winner = packetRecency(theirs.packet).localeCompare(packetRecency(ours.packet)) > 0 ? "theirs" : "ours";
|
|
15286
|
+
}
|
|
15287
|
+
else {
|
|
15288
|
+
winner = ours ? "ours" : "theirs";
|
|
15289
|
+
}
|
|
15290
|
+
const winning = winner === "ours" ? ours : theirs;
|
|
15291
|
+
try {
|
|
15292
|
+
(0, node_fs_1.writeFileSync)(oursPath, winning.raw, "utf8");
|
|
15293
|
+
}
|
|
15294
|
+
catch (error) {
|
|
15295
|
+
return { ok: false, winner: null, detail: `kage merge-packet: failed to write merge result: ${error instanceof Error ? error.message : String(error)}` };
|
|
15296
|
+
}
|
|
15297
|
+
const recency = packetRecency(winning.packet);
|
|
15298
|
+
return {
|
|
15299
|
+
ok: true,
|
|
15300
|
+
winner,
|
|
15301
|
+
detail: `kage merge-packet: kept ${winner} side (newest updated_at${recency ? ` ${recency}` : ""}).`,
|
|
15302
|
+
};
|
|
15303
|
+
}
|
|
15304
|
+
// Idempotently wire .gitattributes so packet JSON uses the kage-packet merge
|
|
15305
|
+
// driver. Re-runs never duplicate the line; a stale driver value on the same
|
|
15306
|
+
// pattern is replaced in place.
|
|
15307
|
+
function ensurePacketMergeAttributes(projectDir) {
|
|
15308
|
+
const path = (0, node_path_1.join)(projectDir, ".gitattributes");
|
|
15309
|
+
const existing = safeReadText(path) ?? "";
|
|
15310
|
+
const lines = existing.split(/\r?\n/);
|
|
15311
|
+
const pattern = /^\.agent_memory\/packets\/\*\.json\s+merge=/;
|
|
15312
|
+
const index = lines.findIndex((line) => pattern.test(line.trim()));
|
|
15313
|
+
if (index !== -1) {
|
|
15314
|
+
if (lines[index].trim() === exports.PACKET_MERGE_ATTRIBUTE_LINE)
|
|
15315
|
+
return { path, changed: false };
|
|
15316
|
+
lines[index] = exports.PACKET_MERGE_ATTRIBUTE_LINE;
|
|
15317
|
+
(0, node_fs_1.writeFileSync)(path, `${lines.join("\n").replace(/\n+$/, "")}\n`, "utf8");
|
|
15318
|
+
return { path, changed: true };
|
|
15319
|
+
}
|
|
15320
|
+
const prefix = existing.length ? (existing.endsWith("\n") ? existing : `${existing}\n`) : "";
|
|
15321
|
+
(0, node_fs_1.writeFileSync)(path, `${prefix}${exports.PACKET_MERGE_ATTRIBUTE_LINE}\n`, "utf8");
|
|
15322
|
+
return { path, changed: true };
|
|
15323
|
+
}
|
|
15324
|
+
function repairProject(projectDir, options = {}) {
|
|
15325
|
+
ensureMemoryDirs(projectDir);
|
|
15326
|
+
const actions = [];
|
|
15327
|
+
const removedPackets = [];
|
|
15328
|
+
let packetsTouched = false;
|
|
15329
|
+
// 1. Unparseable packet JSON (merge conflicts, torn writes, hand edits).
|
|
15330
|
+
// Always back up the broken original before changing anything.
|
|
15331
|
+
let brokenFound = 0;
|
|
15332
|
+
const packetDirs = [
|
|
15333
|
+
[packetsDir(projectDir), "packets"],
|
|
15334
|
+
[pendingDir(projectDir), "pending"],
|
|
15335
|
+
[publicCandidatesDir(projectDir), "public-candidates"],
|
|
15336
|
+
];
|
|
15337
|
+
for (const [dir, label] of packetDirs) {
|
|
15338
|
+
for (const path of walkFiles(dir, (candidate) => candidate.endsWith(".json"))) {
|
|
15339
|
+
const target = `${label}/${(0, node_path_1.basename)(path)}`;
|
|
15340
|
+
let raw;
|
|
15341
|
+
try {
|
|
15342
|
+
raw = (0, node_fs_1.readFileSync)(path, "utf8");
|
|
15343
|
+
}
|
|
15344
|
+
catch (error) {
|
|
15345
|
+
actions.push({ area: "packets", target, status: "failed", detail: error instanceof Error ? error.message : String(error) });
|
|
15346
|
+
continue;
|
|
15347
|
+
}
|
|
15348
|
+
try {
|
|
15349
|
+
JSON.parse(raw);
|
|
15350
|
+
continue; // healthy packet
|
|
15351
|
+
}
|
|
15352
|
+
catch {
|
|
15353
|
+
// fall through to repair
|
|
15354
|
+
}
|
|
15355
|
+
brokenFound += 1;
|
|
15356
|
+
try {
|
|
15357
|
+
ensureDir(repairBackupDir(projectDir));
|
|
15358
|
+
const backupPath = (0, node_path_1.join)(repairBackupDir(projectDir), `${(0, node_path_1.basename)(path)}.broken`);
|
|
15359
|
+
(0, node_fs_1.writeFileSync)(backupPath, raw, "utf8");
|
|
15360
|
+
const resolved = resolveConflictedPacket(raw);
|
|
15361
|
+
if (resolved) {
|
|
15362
|
+
writeJson(path, resolved);
|
|
15363
|
+
packetsTouched = true;
|
|
15364
|
+
actions.push({
|
|
15365
|
+
area: "packets",
|
|
15366
|
+
target,
|
|
15367
|
+
status: "fixed",
|
|
15368
|
+
detail: `merge conflict auto-resolved, kept newest side — original saved to ${(0, node_path_1.relative)(projectDir, backupPath)}`,
|
|
15369
|
+
});
|
|
15370
|
+
}
|
|
15371
|
+
else {
|
|
15372
|
+
(0, node_fs_1.unlinkSync)(path);
|
|
15373
|
+
packetsTouched = true;
|
|
15374
|
+
removedPackets.push(target);
|
|
15375
|
+
actions.push({
|
|
15376
|
+
area: "packets",
|
|
15377
|
+
target,
|
|
15378
|
+
status: "fixed",
|
|
15379
|
+
detail: `REMOVED unparseable packet — original preserved at ${(0, node_path_1.relative)(projectDir, backupPath)}; restore it by hand if it mattered`,
|
|
15380
|
+
});
|
|
15381
|
+
}
|
|
15382
|
+
}
|
|
15383
|
+
catch (error) {
|
|
15384
|
+
actions.push({ area: "packets", target, status: "failed", detail: error instanceof Error ? error.message : String(error) });
|
|
15385
|
+
}
|
|
15386
|
+
}
|
|
15387
|
+
}
|
|
15388
|
+
if (!brokenFound) {
|
|
15389
|
+
actions.push({ area: "packets", target: "memory packets", status: "skipped", detail: "all packet files parse cleanly" });
|
|
15390
|
+
}
|
|
15391
|
+
// 2. Stale lock/temp files left behind by crashed writers, plus a daemon
|
|
15392
|
+
// status file whose pid is no longer running.
|
|
15393
|
+
let lockFindings = 0;
|
|
15394
|
+
for (const path of walkFiles(memoryRoot(projectDir), (candidate) => candidate.endsWith(".tmp") || candidate.endsWith(".lock"))) {
|
|
15395
|
+
lockFindings += 1;
|
|
15396
|
+
try {
|
|
15397
|
+
(0, node_fs_1.unlinkSync)(path);
|
|
15398
|
+
actions.push({ area: "locks", target: (0, node_path_1.relative)(projectDir, path), status: "fixed", detail: "removed leftover temp/lock file" });
|
|
15399
|
+
}
|
|
15400
|
+
catch (error) {
|
|
15401
|
+
actions.push({ area: "locks", target: (0, node_path_1.relative)(projectDir, path), status: "failed", detail: error instanceof Error ? error.message : String(error) });
|
|
15402
|
+
}
|
|
15403
|
+
}
|
|
15404
|
+
const daemonStatusPath = (0, node_path_1.join)(daemonDir(projectDir), "status.json");
|
|
15405
|
+
if ((0, node_fs_1.existsSync)(daemonStatusPath)) {
|
|
15406
|
+
let stale = true;
|
|
15407
|
+
let pidLabel = "unknown";
|
|
15408
|
+
try {
|
|
15409
|
+
const status = readJson(daemonStatusPath);
|
|
15410
|
+
if (typeof status.pid === "number") {
|
|
15411
|
+
pidLabel = String(status.pid);
|
|
15412
|
+
try {
|
|
15413
|
+
process.kill(status.pid, 0);
|
|
15414
|
+
stale = false;
|
|
15415
|
+
}
|
|
15416
|
+
catch {
|
|
15417
|
+
stale = true;
|
|
15418
|
+
}
|
|
15419
|
+
}
|
|
15420
|
+
}
|
|
15421
|
+
catch {
|
|
15422
|
+
stale = true; // unreadable status file is stale by definition
|
|
15423
|
+
}
|
|
15424
|
+
if (stale) {
|
|
15425
|
+
lockFindings += 1;
|
|
15426
|
+
try {
|
|
15427
|
+
(0, node_fs_1.unlinkSync)(daemonStatusPath);
|
|
15428
|
+
actions.push({ area: "locks", target: (0, node_path_1.relative)(projectDir, daemonStatusPath), status: "fixed", detail: `removed stale daemon status (pid ${pidLabel} is not running)` });
|
|
15429
|
+
}
|
|
15430
|
+
catch (error) {
|
|
15431
|
+
actions.push({ area: "locks", target: (0, node_path_1.relative)(projectDir, daemonStatusPath), status: "failed", detail: error instanceof Error ? error.message : String(error) });
|
|
15432
|
+
}
|
|
15433
|
+
}
|
|
15434
|
+
else {
|
|
15435
|
+
lockFindings += 1;
|
|
15436
|
+
actions.push({ area: "locks", target: (0, node_path_1.relative)(projectDir, daemonStatusPath), status: "skipped", detail: `daemon pid ${pidLabel} is alive — left alone` });
|
|
15437
|
+
}
|
|
15438
|
+
}
|
|
15439
|
+
if (!lockFindings) {
|
|
15440
|
+
actions.push({ area: "locks", target: "lock/temp files", status: "skipped", detail: "no stale lock or temp files" });
|
|
15441
|
+
}
|
|
15442
|
+
// 3. Missing or stale indexes — rebuild. Packet surgery above also forces a
|
|
15443
|
+
// rebuild so the catalog never disagrees with what is on disk.
|
|
15444
|
+
const expectedIndexes = ["catalog.json", "by-path.json", "by-tag.json", "by-type.json", "vector-local.json", "graph.json", "code-graph.json"];
|
|
15445
|
+
const missingIndexes = expectedIndexes.filter((name) => !(0, node_fs_1.existsSync)((0, node_path_1.join)(indexesDir(projectDir), name)));
|
|
15446
|
+
let staleCatalog = false;
|
|
15447
|
+
const catalogPath = (0, node_path_1.join)(indexesDir(projectDir), "catalog.json");
|
|
15448
|
+
if ((0, node_fs_1.existsSync)(catalogPath)) {
|
|
15449
|
+
try {
|
|
15450
|
+
const catalog = readJson(catalogPath);
|
|
15451
|
+
staleCatalog = catalog.packet_count !== loadPacketsFromDir(packetsDir(projectDir)).length;
|
|
15452
|
+
}
|
|
15453
|
+
catch {
|
|
15454
|
+
staleCatalog = true;
|
|
15455
|
+
}
|
|
15456
|
+
}
|
|
15457
|
+
if (missingIndexes.length || staleCatalog || packetsTouched) {
|
|
15458
|
+
try {
|
|
15459
|
+
const rebuilt = indexProject(projectDir);
|
|
15460
|
+
const reason = missingIndexes.length
|
|
15461
|
+
? `${missingIndexes.length} missing: ${missingIndexes.join(", ")}`
|
|
15462
|
+
: staleCatalog
|
|
15463
|
+
? "catalog was out of date"
|
|
15464
|
+
: "packets changed during repair";
|
|
15465
|
+
actions.push({ area: "indexes", target: "indexes + graphs", status: "fixed", detail: `rebuilt ${rebuilt.indexes.length} indexes (${reason})` });
|
|
15466
|
+
}
|
|
15467
|
+
catch (error) {
|
|
15468
|
+
actions.push({ area: "indexes", target: "indexes + graphs", status: "failed", detail: error instanceof Error ? error.message : String(error) });
|
|
15469
|
+
}
|
|
15470
|
+
}
|
|
15471
|
+
else {
|
|
15472
|
+
actions.push({ area: "indexes", target: "indexes + graphs", status: "skipped", detail: "present and current" });
|
|
15473
|
+
}
|
|
15474
|
+
// 4. Agent wiring drift — re-run the write path ONLY for agents that are
|
|
15475
|
+
// already configured (config file exists) but whose hook scripts went
|
|
15476
|
+
// missing. Repair never wires new agents.
|
|
15477
|
+
try {
|
|
15478
|
+
const doctor = setupDoctor(projectDir, { homeDir: options.homeDir, serverPath: options.serverPath });
|
|
15479
|
+
let drifted = 0;
|
|
15480
|
+
for (const item of doctor) {
|
|
15481
|
+
// "Already configured" means the agent's config file exists AND already
|
|
15482
|
+
// mentions the Kage MCP server. A bare config (every Claude Code user
|
|
15483
|
+
// has ~/.claude.json) is NOT configured — repair never wires new agents.
|
|
15484
|
+
const configured = Boolean(item.config_path && (0, node_fs_1.existsSync)(item.config_path)) && configMentionsKage(item.config_path);
|
|
15485
|
+
if (!configured)
|
|
15486
|
+
continue;
|
|
15487
|
+
if (!item.hook_summary || item.hook_summary.ready)
|
|
15488
|
+
continue;
|
|
15489
|
+
drifted += 1;
|
|
15490
|
+
try {
|
|
15491
|
+
const rewired = setupAgent(item.agent, projectDir, { write: true, homeDir: options.homeDir, serverPath: options.serverPath });
|
|
15492
|
+
actions.push({
|
|
15493
|
+
area: "agents",
|
|
15494
|
+
target: item.agent,
|
|
15495
|
+
status: rewired.wrote ? "fixed" : "failed",
|
|
15496
|
+
detail: rewired.wrote
|
|
15497
|
+
? `re-ran setup, restored missing hooks (${item.hook_summary.missing.join(", ")})`
|
|
15498
|
+
: `setup did not write — run: kage setup ${item.agent} --project . --write`,
|
|
15499
|
+
});
|
|
15500
|
+
}
|
|
15501
|
+
catch (error) {
|
|
15502
|
+
actions.push({ area: "agents", target: item.agent, status: "failed", detail: error instanceof Error ? error.message : String(error) });
|
|
15503
|
+
}
|
|
15504
|
+
}
|
|
15505
|
+
if (!drifted) {
|
|
15506
|
+
actions.push({ area: "agents", target: "agent wiring", status: "skipped", detail: "configured agents look intact" });
|
|
15507
|
+
}
|
|
15508
|
+
}
|
|
15509
|
+
catch (error) {
|
|
15510
|
+
actions.push({ area: "agents", target: "agent wiring", status: "failed", detail: error instanceof Error ? error.message : String(error) });
|
|
15511
|
+
}
|
|
15512
|
+
const validation = validateProject(projectDir);
|
|
15513
|
+
const fixed = actions.filter((action) => action.status === "fixed").length;
|
|
15514
|
+
const skipped = actions.filter((action) => action.status === "skipped").length;
|
|
15515
|
+
const failed = actions.filter((action) => action.status === "failed").length;
|
|
15516
|
+
return { project_dir: projectDir, ok: failed === 0, actions, fixed, skipped, failed, removed_packets: removedPackets, validation };
|
|
15517
|
+
}
|
|
15518
|
+
// Map a CLI failure to ONE copy-pasteable next command. Pure on purpose:
|
|
15519
|
+
// remediation must be unit-testable without throwing real errors.
|
|
15520
|
+
function remediationFor(error) {
|
|
15521
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
15522
|
+
if (/ENOENT/i.test(text) && /\.agent_memory/.test(text))
|
|
15523
|
+
return "kage init --project .";
|
|
15524
|
+
if (/Unexpected token|Unexpected end of JSON|is not valid JSON|JSON\.parse|in JSON at position/i.test(text))
|
|
15525
|
+
return "kage repair --project .";
|
|
15526
|
+
if (/\bindex(es)?\b|\bgraph\b/i.test(text))
|
|
15527
|
+
return "kage index --project .";
|
|
15528
|
+
return "kage doctor --project .";
|
|
15529
|
+
}
|
|
15530
|
+
function approvePending(projectDir, id) {
|
|
15531
|
+
const pendingFiles = walkFiles(pendingDir(projectDir), (path) => path.endsWith(".json"));
|
|
15532
|
+
for (const path of pendingFiles) {
|
|
15533
|
+
const packet = readJson(path);
|
|
15534
|
+
if (packet.id === id) {
|
|
15535
|
+
packet.status = "approved";
|
|
15536
|
+
packet.updated_at = nowIso();
|
|
14244
15537
|
const target = (0, node_path_1.join)(packetsDir(projectDir), packetFileName(packet));
|
|
14245
15538
|
writeJson(target, packet);
|
|
14246
15539
|
(0, node_fs_1.renameSync)(path, `${path}.approved`);
|
|
@@ -14583,3 +15876,509 @@ function kageMemoryTimeline(projectDir, days = 14) {
|
|
|
14583
15876
|
recommendations,
|
|
14584
15877
|
};
|
|
14585
15878
|
}
|
|
15879
|
+
// ---------------------------------------------------------------------------
|
|
15880
|
+
// Personal memory (~/.kage/memory) + kage sync — cross-machine continuity
|
|
15881
|
+
// (docs/CLOUD.md v1). Repo memory follows the repo through git; personal
|
|
15882
|
+
// memory follows the PERSON: packets live in the user's home store and sync
|
|
15883
|
+
// through the user's own private git remote. Trust rules stay structural:
|
|
15884
|
+
// personal packets may cite the current project's files (validated and
|
|
15885
|
+
// fingerprinted exactly like repo memory, re-verified against the local
|
|
15886
|
+
// checkout on every recall, in any clone) or carry no citations at all —
|
|
15887
|
+
// allowed ONLY here, and labeled unverifiable on recall. Personal packets
|
|
15888
|
+
// never enter repo flows (pr-check, stale-catch, refresh, access tracking).
|
|
15889
|
+
// ---------------------------------------------------------------------------
|
|
15890
|
+
function kageHomeDir() {
|
|
15891
|
+
const override = process.env.KAGE_HOME?.trim();
|
|
15892
|
+
return override ? (0, node_path_1.resolve)(override) : (0, node_path_1.join)((0, node_os_1.homedir)(), ".kage");
|
|
15893
|
+
}
|
|
15894
|
+
function personalMemoryDir() {
|
|
15895
|
+
return (0, node_path_1.join)(kageHomeDir(), "memory");
|
|
15896
|
+
}
|
|
15897
|
+
function personalPacketsDir() {
|
|
15898
|
+
return (0, node_path_1.join)(personalMemoryDir(), "packets");
|
|
15899
|
+
}
|
|
15900
|
+
function personalConflictsDir() {
|
|
15901
|
+
return (0, node_path_1.join)(personalMemoryDir(), "conflicts");
|
|
15902
|
+
}
|
|
15903
|
+
function loadPersonalPackets() {
|
|
15904
|
+
return loadPacketsFromDir(personalPacketsDir()).filter((packet) => packet.status === "approved");
|
|
15905
|
+
}
|
|
15906
|
+
function makePersonalPacketId(type, title, suffix) {
|
|
15907
|
+
return `personal:${type}:${slugify(`${title}-${suffix}`)}`;
|
|
15908
|
+
}
|
|
15909
|
+
function learnPersonal(input) {
|
|
15910
|
+
// Same privacy guarantee as repo learn: <private> spans never reach disk.
|
|
15911
|
+
input = {
|
|
15912
|
+
...input,
|
|
15913
|
+
learning: stripPrivateSpans(input.learning),
|
|
15914
|
+
title: input.title === undefined ? undefined : stripPrivateSpans(input.title),
|
|
15915
|
+
evidence: input.evidence === undefined ? undefined : stripPrivateSpans(input.evidence),
|
|
15916
|
+
verifiedBy: input.verifiedBy === undefined ? undefined : stripPrivateSpans(input.verifiedBy),
|
|
15917
|
+
};
|
|
15918
|
+
const type = inferLearningType(input);
|
|
15919
|
+
const title = input.title?.trim() || titleFromLearning(input.learning);
|
|
15920
|
+
const body = [
|
|
15921
|
+
input.learning.trim(),
|
|
15922
|
+
input.evidence ? `\nEvidence: ${input.evidence.trim()}` : "",
|
|
15923
|
+
input.verifiedBy ? `\nVerified by: ${input.verifiedBy.trim()}` : "",
|
|
15924
|
+
].join("").trim();
|
|
15925
|
+
return capturePersonal({
|
|
15926
|
+
projectDir: input.projectDir,
|
|
15927
|
+
title,
|
|
15928
|
+
summary: summarize(input.learning),
|
|
15929
|
+
body,
|
|
15930
|
+
type,
|
|
15931
|
+
tags: unique(["personal", ...(input.tags ?? [])]),
|
|
15932
|
+
paths: input.paths,
|
|
15933
|
+
stack: input.stack,
|
|
15934
|
+
context: input.context,
|
|
15935
|
+
allowMissingPaths: input.allowMissingPaths,
|
|
15936
|
+
discoveryTokens: input.discoveryTokens,
|
|
15937
|
+
});
|
|
15938
|
+
}
|
|
15939
|
+
function capturePersonal(input) {
|
|
15940
|
+
input = {
|
|
15941
|
+
...input,
|
|
15942
|
+
title: stripPrivateSpans(input.title),
|
|
15943
|
+
summary: input.summary === undefined ? undefined : stripPrivateSpans(input.summary),
|
|
15944
|
+
body: stripPrivateSpans(input.body),
|
|
15945
|
+
context: input.context ? stripPrivateFromContext(input.context) : input.context,
|
|
15946
|
+
};
|
|
15947
|
+
const type = input.type ?? "reference";
|
|
15948
|
+
if (!exports.MEMORY_TYPES.includes(type)) {
|
|
15949
|
+
return { ok: false, errors: [`Invalid memory type: ${type}`] };
|
|
15950
|
+
}
|
|
15951
|
+
// Personal memory syncs to a remote, so the secret scan matters MORE here, not less.
|
|
15952
|
+
const scanFindings = scanSensitiveText([input.title, input.summary ?? "", input.body].join("\n"));
|
|
15953
|
+
if (scanFindings.length) {
|
|
15954
|
+
return { ok: false, errors: [`Sensitive content blocked: ${unique(scanFindings).join(", ")}`] };
|
|
15955
|
+
}
|
|
15956
|
+
const warnings = [];
|
|
15957
|
+
const citedPaths = (input.paths ?? []).filter((path) => path && !isGroundingIgnored(input.projectDir, path));
|
|
15958
|
+
const meaningfulPaths = citedPaths.filter((path) => meaningfulMemoryPath(path) && !shouldSkipRepoMemoryPath(path));
|
|
15959
|
+
const missingPaths = meaningfulPaths.filter((path) => !pathExistsInRepo(input.projectDir, path));
|
|
15960
|
+
// Cited personal packets follow the SAME write-time rule as repo memory: a packet
|
|
15961
|
+
// whose every cited path is missing from the current project is a hallucinated
|
|
15962
|
+
// citation and is rejected. The citation-FREE case below is the only personal escape.
|
|
15963
|
+
if (meaningfulPaths.length && missingPaths.length === meaningfulPaths.length && !input.allowMissingPaths) {
|
|
15964
|
+
return {
|
|
15965
|
+
ok: false,
|
|
15966
|
+
errors: [
|
|
15967
|
+
`Citation validation failed: none of the referenced paths exist in this project: ${missingPaths.join(", ")}. ` +
|
|
15968
|
+
`Fix the paths, drop them for a citation-free personal note, or pass allow_missing_paths.`,
|
|
15969
|
+
],
|
|
15970
|
+
warnings: [],
|
|
15971
|
+
};
|
|
15972
|
+
}
|
|
15973
|
+
if (missingPaths.length) {
|
|
15974
|
+
warnings.push(`Some referenced paths do not exist in this project: ${missingPaths.join(", ")}`);
|
|
15975
|
+
}
|
|
15976
|
+
const createdAt = nowIso();
|
|
15977
|
+
const fingerprints = memoryPathFingerprints(input.projectDir, citedPaths);
|
|
15978
|
+
// Citation-free personal packets are allowed but structurally second-class:
|
|
15979
|
+
// marked unverifiable at write time so recall can label them as such.
|
|
15980
|
+
const unverifiable = fingerprints.length === 0;
|
|
15981
|
+
if (unverifiable) {
|
|
15982
|
+
warnings.push("Personal packet has no verifiable citations; recall will label it unverifiable.");
|
|
15983
|
+
}
|
|
15984
|
+
const packet = {
|
|
15985
|
+
schema_version: exports.PACKET_SCHEMA_VERSION,
|
|
15986
|
+
id: makePersonalPacketId(type, input.title, String(Date.now())),
|
|
15987
|
+
title: input.title.trim(),
|
|
15988
|
+
summary: (input.summary?.trim() || summarize(input.body)),
|
|
15989
|
+
body: input.body.trim(),
|
|
15990
|
+
type,
|
|
15991
|
+
scope: "personal",
|
|
15992
|
+
visibility: "private",
|
|
15993
|
+
sensitivity: "internal",
|
|
15994
|
+
status: "approved",
|
|
15995
|
+
confidence: DEFAULT_CONFIDENCE,
|
|
15996
|
+
tags: input.tags ?? [],
|
|
15997
|
+
paths: citedPaths,
|
|
15998
|
+
stack: input.stack ?? [],
|
|
15999
|
+
source_refs: [
|
|
16000
|
+
{
|
|
16001
|
+
kind: "personal_capture",
|
|
16002
|
+
captured_at: createdAt,
|
|
16003
|
+
project: repoDisplayName(input.projectDir),
|
|
16004
|
+
},
|
|
16005
|
+
],
|
|
16006
|
+
context: inferEngineeringContext({ title: input.title, body: input.body, context: input.context }),
|
|
16007
|
+
freshness: {
|
|
16008
|
+
ttl_days: 365,
|
|
16009
|
+
last_verified_at: createdAt,
|
|
16010
|
+
path_fingerprints: fingerprints,
|
|
16011
|
+
path_fingerprint_policy: "source_hash_staleness",
|
|
16012
|
+
verification: unverifiable ? "unverifiable_personal" : "personal_capture_cited",
|
|
16013
|
+
},
|
|
16014
|
+
edges: [],
|
|
16015
|
+
quality: {
|
|
16016
|
+
reviewer: "personal",
|
|
16017
|
+
votes_up: 0,
|
|
16018
|
+
votes_down: 0,
|
|
16019
|
+
uses_30d: 0,
|
|
16020
|
+
reports_stale: 0,
|
|
16021
|
+
unverifiable,
|
|
16022
|
+
review_boundary: "personal_store",
|
|
16023
|
+
promotion_requires_review: true,
|
|
16024
|
+
...(Number.isFinite(Number(input.discoveryTokens)) && Number(input.discoveryTokens) > 0
|
|
16025
|
+
? { discovery_tokens: Math.floor(Number(input.discoveryTokens)), discovery_tokens_estimated: false }
|
|
16026
|
+
: { discovery_tokens: defaultDiscoveryTokens(type), discovery_tokens_estimated: true }),
|
|
16027
|
+
},
|
|
16028
|
+
created_at: createdAt,
|
|
16029
|
+
updated_at: createdAt,
|
|
16030
|
+
author_branch: null,
|
|
16031
|
+
};
|
|
16032
|
+
const validation = validatePacket(packet);
|
|
16033
|
+
if (!validation.ok)
|
|
16034
|
+
return { ok: false, errors: validation.errors, warnings };
|
|
16035
|
+
ensureDir(personalPacketsDir());
|
|
16036
|
+
const path = (0, node_path_1.join)(personalPacketsDir(), packetFileName(packet));
|
|
16037
|
+
writeJson(path, packet);
|
|
16038
|
+
return { ok: true, packet, path, errors: [], warnings };
|
|
16039
|
+
}
|
|
16040
|
+
// Personal-memory candidates for a repo recall. Cited packets are re-verified
|
|
16041
|
+
// against THIS checkout (relative paths + content fingerprints), so the same
|
|
16042
|
+
// packet that recalls fine in one clone is withheld in a repo where its
|
|
16043
|
+
// evidence does not exist — the docs/CLOUD.md "verified sync" rule.
|
|
16044
|
+
function personalRecallEntries(projectDir, terms, limit = 3) {
|
|
16045
|
+
const packets = loadPersonalPackets();
|
|
16046
|
+
if (!packets.length)
|
|
16047
|
+
return [];
|
|
16048
|
+
const cache = new Map();
|
|
16049
|
+
const eligible = packets.filter((packet) => recallHardStaleReason(projectDir, packet, cache) === null);
|
|
16050
|
+
if (!eligible.length)
|
|
16051
|
+
return [];
|
|
16052
|
+
const scores = scorePacketsBm25(terms, eligible);
|
|
16053
|
+
return eligible
|
|
16054
|
+
.map((packet) => {
|
|
16055
|
+
const { score, why } = scores.get(packet.id) ?? { score: 0, why: [] };
|
|
16056
|
+
return {
|
|
16057
|
+
packet,
|
|
16058
|
+
score,
|
|
16059
|
+
why_matched: why,
|
|
16060
|
+
unverifiable: packetStoredPathFingerprints(packet).length === 0,
|
|
16061
|
+
};
|
|
16062
|
+
})
|
|
16063
|
+
.filter((entry) => entry.score > 0)
|
|
16064
|
+
.sort((a, b) => b.score - a.score || a.packet.title.localeCompare(b.packet.title))
|
|
16065
|
+
.slice(0, Math.max(1, limit));
|
|
16066
|
+
}
|
|
16067
|
+
function runSyncGit(cwd, args) {
|
|
16068
|
+
try {
|
|
16069
|
+
const stdout = (0, node_child_process_1.execFileSync)("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
16070
|
+
return { ok: true, stdout: String(stdout ?? "").trim(), stderr: "" };
|
|
16071
|
+
}
|
|
16072
|
+
catch (error) {
|
|
16073
|
+
const failed = error;
|
|
16074
|
+
return {
|
|
16075
|
+
ok: false,
|
|
16076
|
+
stdout: String(failed.stdout ?? "").trim(),
|
|
16077
|
+
stderr: String(failed.stderr ?? failed.message ?? "git command failed").trim(),
|
|
16078
|
+
};
|
|
16079
|
+
}
|
|
16080
|
+
}
|
|
16081
|
+
// Commits must work on machines without a global git identity (fresh CI boxes,
|
|
16082
|
+
// brand-new laptops); fall back to a kage-sync identity instead of failing.
|
|
16083
|
+
function syncIdentityArgs(memoryDir) {
|
|
16084
|
+
const email = runSyncGit(memoryDir, ["config", "user.email"]);
|
|
16085
|
+
return email.ok && email.stdout ? [] : ["-c", "user.name=kage-sync", "-c", "user.email=kage-sync@localhost"];
|
|
16086
|
+
}
|
|
16087
|
+
const SYNC_SETUP_HINT = "Run: kage sync setup --remote <git-url>";
|
|
16088
|
+
function syncPacketFile(file) {
|
|
16089
|
+
return file.startsWith("packets/") && file.endsWith(".json");
|
|
16090
|
+
}
|
|
16091
|
+
function countSyncPacketFiles(namesOutput) {
|
|
16092
|
+
return namesOutput
|
|
16093
|
+
.split("\n")
|
|
16094
|
+
.map((line) => line.trim())
|
|
16095
|
+
.filter(Boolean)
|
|
16096
|
+
.filter(syncPacketFile)
|
|
16097
|
+
.length;
|
|
16098
|
+
}
|
|
16099
|
+
function syncRemoteDefaultBranch(memoryDir) {
|
|
16100
|
+
// "ref: refs/heads/<branch>\tHEAD" — empty remotes return nothing.
|
|
16101
|
+
const result = runSyncGit(memoryDir, ["ls-remote", "--symref", "origin", "HEAD"]);
|
|
16102
|
+
if (!result.ok)
|
|
16103
|
+
return null;
|
|
16104
|
+
const match = result.stdout.match(/^ref:\s+refs\/heads\/(\S+)\s+HEAD/m);
|
|
16105
|
+
return match?.[1] ?? null;
|
|
16106
|
+
}
|
|
16107
|
+
// Auto-resolve a rebase conflict on a packet file: newest updated_at wins
|
|
16108
|
+
// (same policy as the kage-packet merge driver), the losing version is
|
|
16109
|
+
// preserved under conflicts/<name>.<unix-ts>.json so no data is ever lost,
|
|
16110
|
+
// and the worktree file is rewritten as clean JSON — never conflict markers.
|
|
16111
|
+
function resolvePacketSyncConflict(memoryDir, file) {
|
|
16112
|
+
// During a rebase, stage 2 ("ours") is the upstream side already in the new
|
|
16113
|
+
// history; stage 3 ("theirs") is the local commit being replayed.
|
|
16114
|
+
const readStage = (stage) => {
|
|
16115
|
+
const show = runSyncGit(memoryDir, ["show", `:${stage}:${file}`]);
|
|
16116
|
+
if (!show.ok)
|
|
16117
|
+
return null;
|
|
16118
|
+
try {
|
|
16119
|
+
const parsed = JSON.parse(show.stdout);
|
|
16120
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
|
|
16121
|
+
return parsed;
|
|
16122
|
+
}
|
|
16123
|
+
catch {
|
|
16124
|
+
// An unparsable side loses to a parsable one.
|
|
16125
|
+
}
|
|
16126
|
+
return null;
|
|
16127
|
+
};
|
|
16128
|
+
const upstreamSide = readStage(2);
|
|
16129
|
+
const localSide = readStage(3);
|
|
16130
|
+
if (!upstreamSide && !localSide) {
|
|
16131
|
+
return { ok: false, error: `kage sync: neither side of ${file} parses as packet JSON; resolve manually in ${memoryDir}.` };
|
|
16132
|
+
}
|
|
16133
|
+
const localWins = Boolean(localSide && (!upstreamSide || packetRecency(localSide).localeCompare(packetRecency(upstreamSide)) > 0));
|
|
16134
|
+
const winner = localWins ? localSide : upstreamSide;
|
|
16135
|
+
const loser = localWins ? upstreamSide : localSide;
|
|
16136
|
+
writeJson((0, node_path_1.join)(memoryDir, file), winner);
|
|
16137
|
+
let backupPath;
|
|
16138
|
+
if (loser) {
|
|
16139
|
+
ensureDir(personalConflictsDir());
|
|
16140
|
+
const base = (0, node_path_1.basename)(file, ".json");
|
|
16141
|
+
let candidate = (0, node_path_1.join)(personalConflictsDir(), `${base}.${Math.floor(Date.now() / 1000)}.json`);
|
|
16142
|
+
let counter = 1;
|
|
16143
|
+
while ((0, node_fs_1.existsSync)(candidate)) {
|
|
16144
|
+
candidate = (0, node_path_1.join)(personalConflictsDir(), `${base}.${Math.floor(Date.now() / 1000)}-${counter}.json`);
|
|
16145
|
+
counter += 1;
|
|
16146
|
+
}
|
|
16147
|
+
writeJson(candidate, loser);
|
|
16148
|
+
backupPath = candidate;
|
|
16149
|
+
}
|
|
16150
|
+
return { ok: true, backupPath };
|
|
16151
|
+
}
|
|
16152
|
+
function rebaseOntoUpstream(memoryDir, upstream) {
|
|
16153
|
+
const conflictBackups = [];
|
|
16154
|
+
let resolved = 0;
|
|
16155
|
+
let step = runSyncGit(memoryDir, ["-c", "core.editor=true", "rebase", upstream]);
|
|
16156
|
+
while (!step.ok) {
|
|
16157
|
+
const conflicted = runSyncGit(memoryDir, ["diff", "--name-only", "--diff-filter=U"])
|
|
16158
|
+
.stdout.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
16159
|
+
if (!conflicted.length) {
|
|
16160
|
+
// The resolved commit became empty (we kept the upstream side wholesale).
|
|
16161
|
+
const skip = runSyncGit(memoryDir, ["-c", "core.editor=true", "rebase", "--skip"]);
|
|
16162
|
+
if (skip.ok) {
|
|
16163
|
+
step = skip;
|
|
16164
|
+
continue;
|
|
16165
|
+
}
|
|
16166
|
+
runSyncGit(memoryDir, ["rebase", "--abort"]);
|
|
16167
|
+
return { ok: false, resolved, conflictBackups, error: `git rebase failed: ${step.stderr || skip.stderr}` };
|
|
16168
|
+
}
|
|
16169
|
+
for (const file of conflicted) {
|
|
16170
|
+
if (!syncPacketFile(file)) {
|
|
16171
|
+
runSyncGit(memoryDir, ["rebase", "--abort"]);
|
|
16172
|
+
return { ok: false, resolved, conflictBackups, error: `kage sync only auto-resolves packets/*.json conflicts; ${file} needs manual resolution in ${memoryDir}.` };
|
|
16173
|
+
}
|
|
16174
|
+
const resolution = resolvePacketSyncConflict(memoryDir, file);
|
|
16175
|
+
if (!resolution.ok) {
|
|
16176
|
+
runSyncGit(memoryDir, ["rebase", "--abort"]);
|
|
16177
|
+
return { ok: false, resolved, conflictBackups, error: resolution.error };
|
|
16178
|
+
}
|
|
16179
|
+
if (resolution.backupPath)
|
|
16180
|
+
conflictBackups.push(resolution.backupPath);
|
|
16181
|
+
resolved += 1;
|
|
16182
|
+
const toAdd = [file, ...(resolution.backupPath ? [(0, node_path_1.relative)(memoryDir, resolution.backupPath)] : [])];
|
|
16183
|
+
runSyncGit(memoryDir, ["add", "--", ...toAdd]);
|
|
16184
|
+
}
|
|
16185
|
+
step = runSyncGit(memoryDir, ["-c", "core.editor=true", "rebase", "--continue"]);
|
|
16186
|
+
}
|
|
16187
|
+
return { ok: true, resolved, conflictBackups };
|
|
16188
|
+
}
|
|
16189
|
+
function syncSetup(remoteUrl) {
|
|
16190
|
+
const memoryDir = personalMemoryDir();
|
|
16191
|
+
ensureDir(personalPacketsDir());
|
|
16192
|
+
const result = {
|
|
16193
|
+
ok: false,
|
|
16194
|
+
memory_dir: memoryDir,
|
|
16195
|
+
remote: remoteUrl,
|
|
16196
|
+
initialized: false,
|
|
16197
|
+
remote_updated: false,
|
|
16198
|
+
branch: null,
|
|
16199
|
+
pushed: false,
|
|
16200
|
+
errors: [],
|
|
16201
|
+
};
|
|
16202
|
+
if (!(0, node_fs_1.existsSync)((0, node_path_1.join)(memoryDir, ".git"))) {
|
|
16203
|
+
const init = runSyncGit(memoryDir, ["init"]);
|
|
16204
|
+
if (!init.ok) {
|
|
16205
|
+
result.errors.push(`git init failed: ${init.stderr}`);
|
|
16206
|
+
return result;
|
|
16207
|
+
}
|
|
16208
|
+
result.initialized = true;
|
|
16209
|
+
}
|
|
16210
|
+
// Keep the packets dir trackable even before the first capture.
|
|
16211
|
+
const keep = (0, node_path_1.join)(personalPacketsDir(), ".gitkeep");
|
|
16212
|
+
if (!(0, node_fs_1.existsSync)(keep))
|
|
16213
|
+
(0, node_fs_1.writeFileSync)(keep, "", "utf8");
|
|
16214
|
+
const currentRemote = runSyncGit(memoryDir, ["remote", "get-url", "origin"]);
|
|
16215
|
+
if (!currentRemote.ok) {
|
|
16216
|
+
const added = runSyncGit(memoryDir, ["remote", "add", "origin", remoteUrl]);
|
|
16217
|
+
if (!added.ok) {
|
|
16218
|
+
result.errors.push(`git remote add failed: ${added.stderr}`);
|
|
16219
|
+
return result;
|
|
16220
|
+
}
|
|
16221
|
+
result.remote_updated = true;
|
|
16222
|
+
}
|
|
16223
|
+
else if (currentRemote.stdout !== remoteUrl) {
|
|
16224
|
+
const updated = runSyncGit(memoryDir, ["remote", "set-url", "origin", remoteUrl]);
|
|
16225
|
+
if (!updated.ok) {
|
|
16226
|
+
result.errors.push(`git remote set-url failed: ${updated.stderr}`);
|
|
16227
|
+
return result;
|
|
16228
|
+
}
|
|
16229
|
+
result.remote_updated = true;
|
|
16230
|
+
}
|
|
16231
|
+
// Commit local state before aligning with the remote.
|
|
16232
|
+
runSyncGit(memoryDir, ["add", "-A"]);
|
|
16233
|
+
const status = runSyncGit(memoryDir, ["status", "--porcelain"]);
|
|
16234
|
+
const hasHead = runSyncGit(memoryDir, ["rev-parse", "--verify", "--quiet", "HEAD"]).ok;
|
|
16235
|
+
if (status.stdout || !hasHead) {
|
|
16236
|
+
const commit = runSyncGit(memoryDir, [
|
|
16237
|
+
...syncIdentityArgs(memoryDir),
|
|
16238
|
+
"commit",
|
|
16239
|
+
"-m",
|
|
16240
|
+
"kage sync setup",
|
|
16241
|
+
...(status.stdout ? [] : ["--allow-empty"]),
|
|
16242
|
+
]);
|
|
16243
|
+
if (!commit.ok) {
|
|
16244
|
+
result.errors.push(`git commit failed: ${commit.stderr}`);
|
|
16245
|
+
return result;
|
|
16246
|
+
}
|
|
16247
|
+
}
|
|
16248
|
+
const fetch = runSyncGit(memoryDir, ["fetch", "origin"]);
|
|
16249
|
+
if (!fetch.ok) {
|
|
16250
|
+
result.errors.push(`git fetch failed: ${fetch.stderr}`);
|
|
16251
|
+
return result;
|
|
16252
|
+
}
|
|
16253
|
+
// A second machine pointing at an existing remote must converge on the
|
|
16254
|
+
// remote's branch instead of pushing a parallel one: rename the local branch
|
|
16255
|
+
// to match and rebase local commits (the setup commit) on top.
|
|
16256
|
+
const remoteBranch = syncRemoteDefaultBranch(memoryDir);
|
|
16257
|
+
if (remoteBranch && runSyncGit(memoryDir, ["rev-parse", "--verify", "--quiet", `origin/${remoteBranch}`]).ok) {
|
|
16258
|
+
const renamed = runSyncGit(memoryDir, ["branch", "-M", remoteBranch]);
|
|
16259
|
+
if (!renamed.ok) {
|
|
16260
|
+
result.errors.push(`git branch -M failed: ${renamed.stderr}`);
|
|
16261
|
+
return result;
|
|
16262
|
+
}
|
|
16263
|
+
const rebase = rebaseOntoUpstream(memoryDir, `origin/${remoteBranch}`);
|
|
16264
|
+
if (!rebase.ok) {
|
|
16265
|
+
result.errors.push(rebase.error ?? "git rebase failed");
|
|
16266
|
+
return result;
|
|
16267
|
+
}
|
|
16268
|
+
}
|
|
16269
|
+
result.branch = runSyncGit(memoryDir, ["rev-parse", "--abbrev-ref", "HEAD"]).stdout || null;
|
|
16270
|
+
const push = runSyncGit(memoryDir, ["push", "-u", "origin", "HEAD"]);
|
|
16271
|
+
if (!push.ok) {
|
|
16272
|
+
result.errors.push(`git push failed: ${push.stderr}`);
|
|
16273
|
+
return result;
|
|
16274
|
+
}
|
|
16275
|
+
result.pushed = true;
|
|
16276
|
+
result.ok = true;
|
|
16277
|
+
return result;
|
|
16278
|
+
}
|
|
16279
|
+
function syncStatus() {
|
|
16280
|
+
const memoryDir = personalMemoryDir();
|
|
16281
|
+
const result = {
|
|
16282
|
+
ok: false,
|
|
16283
|
+
memory_dir: memoryDir,
|
|
16284
|
+
remote: null,
|
|
16285
|
+
branch: null,
|
|
16286
|
+
ahead: 0,
|
|
16287
|
+
behind: 0,
|
|
16288
|
+
dirty: false,
|
|
16289
|
+
warnings: [],
|
|
16290
|
+
errors: [],
|
|
16291
|
+
};
|
|
16292
|
+
if (!(0, node_fs_1.existsSync)((0, node_path_1.join)(memoryDir, ".git"))) {
|
|
16293
|
+
result.errors.push(`Personal memory store is not set up for sync. ${SYNC_SETUP_HINT}`);
|
|
16294
|
+
return result;
|
|
16295
|
+
}
|
|
16296
|
+
const remote = runSyncGit(memoryDir, ["remote", "get-url", "origin"]);
|
|
16297
|
+
if (!remote.ok) {
|
|
16298
|
+
result.errors.push(`No sync remote configured. ${SYNC_SETUP_HINT}`);
|
|
16299
|
+
return result;
|
|
16300
|
+
}
|
|
16301
|
+
result.remote = remote.stdout;
|
|
16302
|
+
// Status is read-only on the network: fetch only, never pull/push.
|
|
16303
|
+
const fetch = runSyncGit(memoryDir, ["fetch", "origin"]);
|
|
16304
|
+
if (!fetch.ok)
|
|
16305
|
+
result.warnings.push(`git fetch failed (showing last-known remote state): ${fetch.stderr}`);
|
|
16306
|
+
result.branch = runSyncGit(memoryDir, ["rev-parse", "--abbrev-ref", "HEAD"]).stdout || null;
|
|
16307
|
+
const upstream = result.branch ? `origin/${result.branch}` : null;
|
|
16308
|
+
if (upstream && runSyncGit(memoryDir, ["rev-parse", "--verify", "--quiet", upstream]).ok) {
|
|
16309
|
+
const counts = runSyncGit(memoryDir, ["rev-list", "--left-right", "--count", `HEAD...${upstream}`]);
|
|
16310
|
+
if (counts.ok) {
|
|
16311
|
+
const [ahead, behind] = counts.stdout.split(/\s+/).map((value) => Number(value));
|
|
16312
|
+
result.ahead = Number.isFinite(ahead) ? ahead : 0;
|
|
16313
|
+
result.behind = Number.isFinite(behind) ? behind : 0;
|
|
16314
|
+
}
|
|
16315
|
+
}
|
|
16316
|
+
result.dirty = Boolean(runSyncGit(memoryDir, ["status", "--porcelain"]).stdout);
|
|
16317
|
+
result.ok = true;
|
|
16318
|
+
return result;
|
|
16319
|
+
}
|
|
16320
|
+
function syncPersonal() {
|
|
16321
|
+
const memoryDir = personalMemoryDir();
|
|
16322
|
+
const result = {
|
|
16323
|
+
ok: false,
|
|
16324
|
+
memory_dir: memoryDir,
|
|
16325
|
+
remote: null,
|
|
16326
|
+
pushed: 0,
|
|
16327
|
+
pulled: 0,
|
|
16328
|
+
resolved: 0,
|
|
16329
|
+
conflict_backups: [],
|
|
16330
|
+
errors: [],
|
|
16331
|
+
};
|
|
16332
|
+
if (!(0, node_fs_1.existsSync)((0, node_path_1.join)(memoryDir, ".git"))) {
|
|
16333
|
+
result.errors.push(`Personal memory store is not set up for sync. ${SYNC_SETUP_HINT}`);
|
|
16334
|
+
return result;
|
|
16335
|
+
}
|
|
16336
|
+
const remote = runSyncGit(memoryDir, ["remote", "get-url", "origin"]);
|
|
16337
|
+
if (!remote.ok) {
|
|
16338
|
+
result.errors.push(`No sync remote configured. ${SYNC_SETUP_HINT}`);
|
|
16339
|
+
return result;
|
|
16340
|
+
}
|
|
16341
|
+
result.remote = remote.stdout;
|
|
16342
|
+
// 1. Commit local packet changes.
|
|
16343
|
+
runSyncGit(memoryDir, ["add", "-A"]);
|
|
16344
|
+
if (runSyncGit(memoryDir, ["status", "--porcelain"]).stdout) {
|
|
16345
|
+
const commit = runSyncGit(memoryDir, [...syncIdentityArgs(memoryDir), "commit", "-m", `kage sync ${nowIso()}`]);
|
|
16346
|
+
if (!commit.ok) {
|
|
16347
|
+
result.errors.push(`git commit failed: ${commit.stderr}`);
|
|
16348
|
+
return result;
|
|
16349
|
+
}
|
|
16350
|
+
}
|
|
16351
|
+
// 2. Pull --rebase (split into fetch + rebase so the receipt can count what came in).
|
|
16352
|
+
const fetch = runSyncGit(memoryDir, ["fetch", "origin"]);
|
|
16353
|
+
if (!fetch.ok) {
|
|
16354
|
+
result.errors.push(`git fetch failed: ${fetch.stderr}`);
|
|
16355
|
+
return result;
|
|
16356
|
+
}
|
|
16357
|
+
const branch = runSyncGit(memoryDir, ["rev-parse", "--abbrev-ref", "HEAD"]).stdout;
|
|
16358
|
+
const upstream = `origin/${branch}`;
|
|
16359
|
+
const hasUpstream = Boolean(branch) && runSyncGit(memoryDir, ["rev-parse", "--verify", "--quiet", upstream]).ok;
|
|
16360
|
+
if (hasUpstream) {
|
|
16361
|
+
// Their side of the merge base: packet files this sync will bring in.
|
|
16362
|
+
const incoming = runSyncGit(memoryDir, ["diff", "--name-only", `HEAD...${upstream}`]);
|
|
16363
|
+
result.pulled = countSyncPacketFiles(incoming.stdout);
|
|
16364
|
+
const rebase = rebaseOntoUpstream(memoryDir, upstream);
|
|
16365
|
+
result.resolved = rebase.resolved;
|
|
16366
|
+
result.conflict_backups = rebase.conflictBackups;
|
|
16367
|
+
if (!rebase.ok) {
|
|
16368
|
+
result.errors.push(rebase.error ?? "git rebase failed");
|
|
16369
|
+
return result;
|
|
16370
|
+
}
|
|
16371
|
+
}
|
|
16372
|
+
// 3. Push (first push sets the upstream).
|
|
16373
|
+
const outgoing = hasUpstream
|
|
16374
|
+
? runSyncGit(memoryDir, ["diff", "--name-only", `${upstream}..HEAD`])
|
|
16375
|
+
: runSyncGit(memoryDir, ["ls-tree", "-r", "--name-only", "HEAD"]);
|
|
16376
|
+
result.pushed = countSyncPacketFiles(outgoing.stdout);
|
|
16377
|
+
const push = runSyncGit(memoryDir, ["push", "-u", "origin", "HEAD"]);
|
|
16378
|
+
if (!push.ok) {
|
|
16379
|
+
result.errors.push(`git push failed: ${push.stderr}`);
|
|
16380
|
+
return result;
|
|
16381
|
+
}
|
|
16382
|
+
result.ok = true;
|
|
16383
|
+
return result;
|
|
16384
|
+
}
|