@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/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
- function refreshPacketStaleness(projectDir) {
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 changed = pruned !== null
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: scored.length ? recallTokensSaved(projectDir, result.results, result.context_block) : 0,
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: [{ matcher: "", hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/observe.sh", timeout: 5 }] }],
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
- function distillSession(projectDir, sessionId) {
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
- distillation: "automatic_observation_candidate",
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 commandEvents = observations.filter((event) => event.type === "command_result" && event.command);
13273
- const fileEvents = observations.filter((event) => event.type === "file_change" && event.path);
13274
- const promptEvents = observations.filter((event) => event.type === "user_prompt" && (event.text || event.summary));
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 { ok: errors.length === 0, session_id: sessionId, observations: observations.length, candidates, errors };
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/. Agent-policy files (AGENTS.md,
14200
- // CLAUDE.md) and .claude/settings.json are repo-visible and reviewable, so
14201
- // writing them requires explicit opt-in (`kage init --with-policy` or `kage policy`).
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 approvePending(projectDir, id) {
14238
- const pendingFiles = walkFiles(pendingDir(projectDir), (path) => path.endsWith(".json"));
14239
- for (const path of pendingFiles) {
14240
- const packet = readJson(path);
14241
- if (packet.id === id) {
14242
- packet.status = "approved";
14243
- packet.updated_at = nowIso();
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
+ }