@kage-core/kage-graph-mcp 2.1.0 → 2.2.1

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.AUTO_DISTILL_TAG = 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,6 +65,7 @@ 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;
@@ -104,6 +105,14 @@ exports.kageRisk = kageRisk;
104
105
  exports.kageDependencyPath = kageDependencyPath;
105
106
  exports.kageCleanupCandidates = kageCleanupCandidates;
106
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;
107
116
  exports.kageReviewerSuggestions = kageReviewerSuggestions;
108
117
  exports.kageContributors = kageContributors;
109
118
  exports.kageContextSlots = kageContextSlots;
@@ -137,6 +146,7 @@ exports.setupAgent = setupAgent;
137
146
  exports.setupDoctor = setupDoctor;
138
147
  exports.verifyAgentActivation = verifyAgentActivation;
139
148
  exports.observe = observe;
149
+ exports.observationSignalScore = observationSignalScore;
140
150
  exports.kageSessionCaptureReport = kageSessionCaptureReport;
141
151
  exports.kageSessionReplay = kageSessionReplay;
142
152
  exports.kageSessionLearningLedger = kageSessionLearningLedger;
@@ -158,6 +168,8 @@ exports.validateProject = validateProject;
158
168
  exports.initProject = initProject;
159
169
  exports.doctorProject = doctorProject;
160
170
  exports.splitConflictSides = splitConflictSides;
171
+ exports.mergePacketFiles = mergePacketFiles;
172
+ exports.ensurePacketMergeAttributes = ensurePacketMergeAttributes;
161
173
  exports.repairProject = repairProject;
162
174
  exports.remediationFor = remediationFor;
163
175
  exports.approvePending = approvePending;
@@ -166,6 +178,16 @@ exports.changelog = changelog;
166
178
  exports.supersedeMemory = supersedeMemory;
167
179
  exports.kageMemoryLineage = kageMemoryLineage;
168
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;
169
191
  const node_crypto_1 = require("node:crypto");
170
192
  const node_child_process_1 = require("node:child_process");
171
193
  const node_fs_1 = require("node:fs");
@@ -981,7 +1003,7 @@ function valueLedgerPath(projectDir) {
981
1003
  function emptyValueLedger() {
982
1004
  return {
983
1005
  schema_version: VALUE_LEDGER_SCHEMA_VERSION,
984
- 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 },
985
1007
  events: [],
986
1008
  };
987
1009
  }
@@ -1009,6 +1031,7 @@ function readValueLedger(projectDir) {
1009
1031
  schema_version: VALUE_LEDGER_SCHEMA_VERSION,
1010
1032
  totals: {
1011
1033
  tokens_saved: nonNegativeCount(totals.tokens_saved),
1034
+ replay_tokens: nonNegativeCount(totals.replay_tokens),
1012
1035
  stale_withheld: nonNegativeCount(totals.stale_withheld),
1013
1036
  stale_caught: nonNegativeCount(totals.stale_caught),
1014
1037
  recalls: nonNegativeCount(totals.recalls),
@@ -1031,7 +1054,9 @@ function recordValueEvents(projectDir, events) {
1031
1054
  const record = { at, kind: event.kind };
1032
1055
  if (event.kind === "recall_served") {
1033
1056
  record.tokens_saved = nonNegativeCount(event.tokens_saved);
1057
+ record.replay_tokens = nonNegativeCount(event.replay_tokens);
1034
1058
  ledger.totals.tokens_saved += record.tokens_saved;
1059
+ ledger.totals.replay_tokens += record.replay_tokens;
1035
1060
  ledger.totals.recalls += 1;
1036
1061
  }
1037
1062
  else if (event.kind === "stale_withheld") {
@@ -1068,7 +1093,7 @@ function estimatedTokenDollars(tokensSaved) {
1068
1093
  return Number(((tokensSaved / 1_000_000) * VALUE_DOLLARS_PER_MILLION_TOKENS).toFixed(2));
1069
1094
  }
1070
1095
  function summarizeValueWindow(events, cutoff) {
1071
- 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 };
1072
1097
  for (const event of events) {
1073
1098
  const at = Date.parse(event.at);
1074
1099
  if (!Number.isFinite(at) || at < cutoff)
@@ -1076,6 +1101,7 @@ function summarizeValueWindow(events, cutoff) {
1076
1101
  if (event.kind === "recall_served") {
1077
1102
  window.recalls += 1;
1078
1103
  window.tokens_saved += nonNegativeCount(event.tokens_saved);
1104
+ window.replay_tokens += nonNegativeCount(event.replay_tokens);
1079
1105
  }
1080
1106
  else if (event.kind === "stale_withheld") {
1081
1107
  window.stale_withheld += 1;
@@ -1128,6 +1154,89 @@ function recallTokensSaved(projectDir, results, contextBlock) {
1128
1154
  }
1129
1155
  return Math.max(0, Math.floor(sourceBytes / 4) - Math.floor(contextBlock.length / 4));
1130
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
+ }
1131
1240
  const AUDIT_ACTIVITY_KIND = {
1132
1241
  capture: "capture", approve: "capture", supersede: "supersede", deprecate: "deprecate",
1133
1242
  update: "update", promote: "promote", feedback: "feedback",
@@ -2232,6 +2341,28 @@ function safeReadText(path) {
2232
2341
  function gitBranch(projectDir) {
2233
2342
  return readGit(projectDir, ["branch", "--show-current"]) || readGit(projectDir, ["rev-parse", "--short", "HEAD"]);
2234
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
+ }
2235
2366
  function gitHead(projectDir) {
2236
2367
  return readGit(projectDir, ["rev-parse", "HEAD"]);
2237
2368
  }
@@ -6082,7 +6213,12 @@ function staleFinding(packet, reasons) {
6082
6213
  suggested_action: staleSuggestedAction(reasons),
6083
6214
  };
6084
6215
  }
6085
- 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 = {}) {
6086
6222
  const findings = [];
6087
6223
  let updated = 0;
6088
6224
  const fingerprintCache = new Map();
@@ -6111,10 +6247,11 @@ function refreshPacketStaleness(projectDir) {
6111
6247
  nextQuality = rest;
6112
6248
  }
6113
6249
  const nextFreshness = oldFreshness;
6114
- const changed = pruned !== null
6250
+ const contentChanged = pruned !== null;
6251
+ const changed = contentChanged
6115
6252
  || JSON.stringify(oldQuality) !== JSON.stringify(nextQuality)
6116
6253
  || JSON.stringify(oldFreshness) !== JSON.stringify(nextFreshness);
6117
- if (changed) {
6254
+ if (changed && (!options.quiet || contentChanged)) {
6118
6255
  writeJson(entry.path, {
6119
6256
  ...packet,
6120
6257
  freshness: nextFreshness,
@@ -6127,11 +6264,17 @@ function refreshPacketStaleness(projectDir) {
6127
6264
  return { findings, updated };
6128
6265
  }
6129
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);
6130
6273
  const detailedIndex = indexProjectDetailed(projectDir, { full: options.full });
6131
6274
  const index = detailedIndex.result;
6132
6275
  let codeGraph = detailedIndex.codeGraph;
6133
6276
  let knowledgeGraph = detailedIndex.knowledgeGraph;
6134
- const stale = refreshPacketStaleness(projectDir);
6277
+ const stale = refreshPacketStaleness(projectDir, { quiet });
6135
6278
  let indexes = index.indexes;
6136
6279
  if (stale.updated > 0) {
6137
6280
  const rebuilt = buildGraphIndexes(projectDir, { forceCodeGraph: options.full });
@@ -6145,6 +6288,9 @@ function refreshProject(projectDir, options = {}) {
6145
6288
  writeJson((0, node_path_1.join)(reportsDir(projectDir), "context-slots.json"), kageContextSlots(projectDir));
6146
6289
  writeJson((0, node_path_1.join)(reportsDir(projectDir), "handoff.json"), kageMemoryHandoff(projectDir));
6147
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
+ }
6148
6294
  if (stale.findings.length)
6149
6295
  nextActions.push("Update, verify, or supersede stale repo memories before relying on them.");
6150
6296
  if (!validation.ok)
@@ -6157,6 +6303,7 @@ function refreshProject(projectDir, options = {}) {
6157
6303
  ok: validation.ok,
6158
6304
  project_dir: projectDir,
6159
6305
  generated_at: nowIso(),
6306
+ quiet_refresh: quiet,
6160
6307
  index,
6161
6308
  validation,
6162
6309
  metrics,
@@ -7202,6 +7349,11 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
7202
7349
  return true;
7203
7350
  })
7204
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);
7205
7357
  const graphContext = queryGraph(projectDir, query, 5, knowledgeGraph);
7206
7358
  const codeContext = queryCodeGraph(projectDir, query, 5, codeGraph);
7207
7359
  const pinnedContext = renderPinnedRepoContext(readContextSlots(projectDir));
@@ -7250,6 +7402,20 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
7250
7402
  ...(suppressed.length
7251
7403
  ? ["", `_${suppressed.length} stale memory packet(s) excluded from recall. Run kage verify for details._`]
7252
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
+ : []),
7253
7419
  ];
7254
7420
  const assembledBlock = lines.join("\n");
7255
7421
  const result = {
@@ -7257,6 +7423,7 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
7257
7423
  context_block: inputs.maxContextTokens ? boundContextBlock(assembledBlock, inputs.maxContextTokens) : assembledBlock,
7258
7424
  results: scored,
7259
7425
  suppressed: suppressed.length ? suppressed : undefined,
7426
+ personal: personalEntries.length ? personalEntries : undefined,
7260
7427
  explanations: explain
7261
7428
  ? scored.map((entry) => ({
7262
7429
  packet_id: entry.packet.id,
@@ -7267,15 +7434,20 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
7267
7434
  }))
7268
7435
  : undefined,
7269
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;
7270
7441
  result.value_receipt = {
7271
- tokens_saved: scored.length ? recallTokensSaved(projectDir, result.results, result.context_block) : 0,
7442
+ tokens_saved: Math.max(readVsSourceTokens, replayTokens),
7272
7443
  stale_withheld: suppressed.length,
7444
+ ...(replayTokens > 0 ? { replay_tokens: replayTokens } : {}),
7273
7445
  };
7274
7446
  if (inputs.trackAccess !== false) {
7275
7447
  recordRecallAccess(projectDir, result.results);
7276
7448
  recordValueEvents(projectDir, [
7277
7449
  ...suppressed.map((entry) => ({ kind: "stale_withheld", packet_title: entry.title })),
7278
- ...(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 }] : []),
7279
7451
  ]);
7280
7452
  }
7281
7453
  return result;
@@ -8717,6 +8889,276 @@ function truthReport(projectDir) {
8717
8889
  ],
8718
8890
  };
8719
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
+ }
8720
9162
  function kageReviewerSuggestions(projectDir, targets = [], changedFiles = []) {
8721
9163
  const graph = readCurrentCodeGraph(projectDir) ?? buildCodeGraph(projectDir);
8722
9164
  const graphPaths = new Set(graph.files.map((file) => file.path));
@@ -11997,6 +12439,20 @@ function learn(input) {
11997
12439
  input.evidence ? `\nEvidence: ${input.evidence.trim()}` : "",
11998
12440
  input.verifiedBy ? `\nVerified by: ${input.verifiedBy.trim()}` : "",
11999
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
+ }
12000
12456
  return capture({
12001
12457
  projectDir: input.projectDir,
12002
12458
  title,
@@ -12011,6 +12467,7 @@ function learn(input) {
12011
12467
  strictCitations: input.strictCitations,
12012
12468
  graphNodes: input.graphNodes,
12013
12469
  pendingReview: input.pendingReview,
12470
+ discoveryTokens: input.discoveryTokens,
12014
12471
  });
12015
12472
  }
12016
12473
  function capture(input) {
@@ -12104,6 +12561,12 @@ function capture(input) {
12104
12561
  reports_stale: 0,
12105
12562
  review_boundary: "git_or_pr",
12106
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 }),
12107
12570
  },
12108
12571
  created_at: createdAt,
12109
12572
  updated_at: createdAt,
@@ -12531,6 +12994,71 @@ print(json.dumps({"additionalContext": os.environ.get("KAGE_CONTEXT", "")}))
12531
12994
  fi
12532
12995
  fi
12533
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
+
12534
13062
  exit 0
12535
13063
  `;
12536
13064
  const settingsPath = (0, node_path_1.join)(home, ".claude", "settings.json");
@@ -12538,7 +13066,11 @@ exit 0
12538
13066
  hooks: {
12539
13067
  SessionStart: [{ matcher: "", hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/session-start.sh", timeout: 5 }] }],
12540
13068
  UserPromptSubmit: [{ hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/observe.sh", timeout: 12 }] }],
12541
- 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
+ ],
12542
13074
  PostToolUse: [{ matcher: "", hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/observe.sh", timeout: 5 }] }],
12543
13075
  PostToolUseFailure: [{ matcher: "", hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/observe.sh", timeout: 5 }] }],
12544
13076
  PreCompact: [{ hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/observe.sh", timeout: 20 }] }],
@@ -12550,7 +13082,7 @@ exit 0
12550
13082
  setSnippet(path, JSON.stringify({ mcpServers: { kage: server } }, null, 2), [
12551
13083
  "Add the MCP server to ~/.claude.json, then restart Claude Code.",
12552
13084
  "alwaysLoad: true makes Kage tools immediately visible without requiring ToolSearch.",
12553
- `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.`,
12554
13086
  "Run `kage init --project <repo>` inside each repo to install the ambient memory policy.",
12555
13087
  ], true);
12556
13088
  if (options.write) {
@@ -12559,6 +13091,7 @@ exit 0
12559
13091
  (0, node_fs_1.mkdirSync)(hookDir, { recursive: true });
12560
13092
  (0, node_fs_1.writeFileSync)((0, node_path_1.join)(hookDir, "session-start.sh"), hookScript, { mode: 0o755 });
12561
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 });
12562
13095
  (0, node_fs_1.writeFileSync)((0, node_path_1.join)(hookDir, "stop.sh"), stopHookScript, { mode: 0o755 });
12563
13096
  upsertJsonSettings(settingsPath, hookEntry);
12564
13097
  result.wrote = true;
@@ -12699,7 +13232,7 @@ function claudeHookEventConfigured(settings, event) {
12699
13232
  function claudeAmbientHookSummary(homeDir) {
12700
13233
  const settingsPath = (0, node_path_1.join)(homeDir, ".claude", "settings.json");
12701
13234
  const hookDir = (0, node_path_1.join)(homeDir, ".claude", "kage", "hooks");
12702
- 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")];
12703
13236
  let settings = {};
12704
13237
  if ((0, node_fs_1.existsSync)(settingsPath)) {
12705
13238
  const parsed = readJson(settingsPath);
@@ -12835,6 +13368,9 @@ function observe(projectDir, event) {
12835
13368
  if ((0, node_fs_1.existsSync)(path))
12836
13369
  return { ok: true, stored: false, duplicate: true, path, errors: [] };
12837
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;
12838
13374
  const record = {
12839
13375
  ...event,
12840
13376
  schema_version: 1,
@@ -12844,6 +13380,7 @@ function observe(projectDir, event) {
12844
13380
  session_id: event.session_id || "default",
12845
13381
  timestamp,
12846
13382
  stored_at: nowIso(),
13383
+ ...(lowSignal ? { low_signal: true } : {}),
12847
13384
  };
12848
13385
  writeJson(path, record);
12849
13386
  return { ok: true, stored: true, duplicate: false, record, path, errors: [] };
@@ -12855,6 +13392,143 @@ function loadObservations(projectDir, sessionId) {
12855
13392
  .filter((record) => !sessionId || record.session_id === sessionId)
12856
13393
  .sort((a, b) => a.timestamp.localeCompare(b.timestamp));
12857
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
+ }
12858
13532
  function reusableFileObservation(event) {
12859
13533
  const text = `${event.summary ?? ""}\n${event.text ?? ""}`.trim();
12860
13534
  if (!text)
@@ -13346,14 +14020,18 @@ function distillSession(projectDir, sessionId, options = {}) {
13346
14020
  const mode = auto ? "auto" : "manual";
13347
14021
  const observations = loadObservations(projectDir, sessionId);
13348
14022
  if (auto && observations.length === 0) {
13349
- return { ok: true, session_id: sessionId, observations: 0, candidates: [], errors: [], mode, skipped_reason: "no_observations" };
14023
+ return { ok: true, session_id: sessionId, observations: 0, candidates: [], errors: [], mode, skipped_reason: "no_observations", skipped_low_signal: 0 };
13350
14024
  }
13351
14025
  if (auto && sessionAlreadyCaptured(projectDir, sessionId, observations)) {
13352
- return { ok: true, session_id: sessionId, observations: observations.length, candidates: [], errors: [], mode, skipped_reason: "session_already_captured" };
14026
+ return { ok: true, session_id: sessionId, observations: observations.length, candidates: [], errors: [], mode, skipped_reason: "session_already_captured", skipped_low_signal: 0 };
13353
14027
  }
13354
14028
  const candidates = [];
13355
14029
  const errors = [];
13356
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);
13357
14035
  const annotate = (result) => {
13358
14036
  if (!result.ok || !result.packet || !result.path)
13359
14037
  return result;
@@ -13367,6 +14045,9 @@ function distillSession(projectDir, sessionId, options = {}) {
13367
14045
  ];
13368
14046
  result.packet.quality = {
13369
14047
  ...result.packet.quality,
14048
+ ...(sessionDiscoveryTokens > 0
14049
+ ? { discovery_tokens: sessionDiscoveryTokens, discovery_tokens_estimated: true }
14050
+ : {}),
13370
14051
  distillation: auto ? "auto_distill" : "automatic_observation_candidate",
13371
14052
  admission: evaluateMemoryAdmission(projectDir, result.packet),
13372
14053
  suggested_review_action: suggestedAction(classifyPacket(projectDir, result.packet), result.packet.status),
@@ -13375,9 +14056,24 @@ function distillSession(projectDir, sessionId, options = {}) {
13375
14056
  return result;
13376
14057
  };
13377
14058
  const autoTags = auto ? [exports.AUTO_DISTILL_TAG] : [];
13378
- const commandEvents = observations.filter((event) => event.type === "command_result" && event.command);
13379
- const fileEvents = observations.filter((event) => event.type === "file_change" && event.path);
13380
- const promptEvents = observations.filter((event) => event.type === "user_prompt" && (event.text || event.summary));
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)));
13381
14077
  const meaningfulCommandEvents = commandEvents
13382
14078
  .map((event) => ({ event, reusable: reusableCommandObservation(event, knownRepoCommands(projectDir)) }))
13383
14079
  .filter((item) => Boolean(item.reusable));
@@ -13427,10 +14123,38 @@ function distillSession(projectDir, sessionId, options = {}) {
13427
14123
  for (const result of candidates)
13428
14124
  if (!result.ok)
13429
14125
  errors.push(...result.errors);
13430
- return { ok: errors.length === 0, session_id: sessionId, observations: observations.length, candidates, errors, mode };
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
+ };
13431
14135
  }
13432
14136
  // Session continuity: a compact "previously…" digest the SessionStart hook injects so a new
13433
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;
13434
14158
  function kageResume(projectDir) {
13435
14159
  ensureMemoryDirs(projectDir);
13436
14160
  const approved = loadApprovedPackets(projectDir);
@@ -13470,7 +14194,18 @@ function kageResume(projectDir) {
13470
14194
  const pendingAutoDistilled = pending.filter((packet) => packet.tags.includes(exports.AUTO_DISTILL_TAG)).length;
13471
14195
  const reconciliation = kageMemoryReconciliation(projectDir, { limit: 5 });
13472
14196
  const reconciliationItems = reconciliation.items.map((item) => ({ packet_id: item.packet_id, title: item.title }));
13473
- const hasContent = Boolean(lastSession || lastChangeMemory || pendingAutoDistilled || reconciliation.unresolved_count);
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;
13474
14209
  const lines = [];
13475
14210
  if (hasContent) {
13476
14211
  lines.push("# Previously (Kage)");
@@ -13495,6 +14230,22 @@ function kageResume(projectDir) {
13495
14230
  lines.push(` - ${item.packet_id}: ${item.title}`);
13496
14231
  }
13497
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
+ }
13498
14249
  return {
13499
14250
  schema_version: 1,
13500
14251
  project_dir: projectDir,
@@ -13506,7 +14257,8 @@ function kageResume(projectDir) {
13506
14257
  pending_total: pending.length,
13507
14258
  ...(pendingAutoDistilled ? { review_command: `kage review --project ${projectDir}` } : {}),
13508
14259
  reconciliation: { unresolved_count: reconciliation.unresolved_count, items: reconciliationItems },
13509
- context_block: lines.slice(0, 15).join("\n"),
14260
+ recent_memory: recentMemory,
14261
+ context_block: block.join("\n"),
13510
14262
  };
13511
14263
  }
13512
14264
  function createDiffChangeMemory(projectDir, summary) {
@@ -14385,18 +15137,21 @@ function installClaudeSettings(projectDir) {
14385
15137
  writeJson(settingsPath, settings);
14386
15138
  }
14387
15139
  function initProject(projectDir, options = {}) {
14388
- // Default init touches ONLY .agent_memory/. Agent-policy files (AGENTS.md,
14389
- // CLAUDE.md) and .claude/settings.json are repo-visible and reviewable, so
14390
- // 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`).
14391
15145
  const policyInstalled = options.policy === true;
14392
15146
  if (policyInstalled) {
14393
15147
  installAgentPolicy(projectDir);
14394
15148
  installClaudeSettings(projectDir);
14395
15149
  }
15150
+ const gitAttributes = ensurePacketMergeAttributes(projectDir);
14396
15151
  const index = indexProject(projectDir, { graphs: false });
14397
15152
  const validation = validateProject(projectDir);
14398
15153
  const sampleRecall = recallFromPackets("how do I run tests", loadApprovedPackets(projectDir), 5, "Repo Memory");
14399
- return { index, validation, sampleRecall, policyInstalled };
15154
+ return { index, validation, sampleRecall, policyInstalled, gitAttributes };
14400
15155
  }
14401
15156
  function doctorProject(projectDir) {
14402
15157
  ensureMemoryDirs(projectDir);
@@ -14490,6 +15245,82 @@ function resolveConflictedPacket(content) {
14490
15245
  candidates.sort((a, b) => packetRecency(b).localeCompare(packetRecency(a)));
14491
15246
  return candidates[0];
14492
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
+ }
14493
15324
  function repairProject(projectDir, options = {}) {
14494
15325
  ensureMemoryDirs(projectDir);
14495
15326
  const actions = [];
@@ -15045,3 +15876,525 @@ function kageMemoryTimeline(projectDir, days = 14) {
15045
15876
  recommendations,
15046
15877
  };
15047
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, [...syncIdentityArgs(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, [...syncIdentityArgs(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, [...syncIdentityArgs(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
+ let push = runSyncGit(memoryDir, ["push", "-u", "origin", "HEAD"]);
16271
+ if (!push.ok && /non-fast-forward|behind|fetch first/i.test(push.stderr)) {
16272
+ // Some git versions leave the local branch behind the remote after the
16273
+ // convergence rebase (observed on CI's git, not on macOS). A second
16274
+ // fetch + rebase fast-forwards a behind branch, after which push succeeds.
16275
+ runSyncGit(memoryDir, ["fetch", "origin"]);
16276
+ const retryBranch = syncRemoteDefaultBranch(memoryDir) ?? result.branch;
16277
+ if (retryBranch && runSyncGit(memoryDir, ["rev-parse", "--verify", "--quiet", `origin/${retryBranch}`]).ok) {
16278
+ runSyncGit(memoryDir, ["branch", "-M", retryBranch]);
16279
+ const retryRebase = rebaseOntoUpstream(memoryDir, `origin/${retryBranch}`);
16280
+ if (retryRebase.ok)
16281
+ push = runSyncGit(memoryDir, ["push", "-u", "origin", "HEAD"]);
16282
+ }
16283
+ }
16284
+ if (!push.ok) {
16285
+ const state = runSyncGit(memoryDir, ["log", "--oneline", "--all", "--decorate", "-n", "8"]).stdout;
16286
+ result.errors.push(`git push failed: ${push.stderr}${state ? `
16287
+ repo state:
16288
+ ${state}` : ""}`);
16289
+ return result;
16290
+ }
16291
+ result.pushed = true;
16292
+ result.ok = true;
16293
+ return result;
16294
+ }
16295
+ function syncStatus() {
16296
+ const memoryDir = personalMemoryDir();
16297
+ const result = {
16298
+ ok: false,
16299
+ memory_dir: memoryDir,
16300
+ remote: null,
16301
+ branch: null,
16302
+ ahead: 0,
16303
+ behind: 0,
16304
+ dirty: false,
16305
+ warnings: [],
16306
+ errors: [],
16307
+ };
16308
+ if (!(0, node_fs_1.existsSync)((0, node_path_1.join)(memoryDir, ".git"))) {
16309
+ result.errors.push(`Personal memory store is not set up for sync. ${SYNC_SETUP_HINT}`);
16310
+ return result;
16311
+ }
16312
+ const remote = runSyncGit(memoryDir, ["remote", "get-url", "origin"]);
16313
+ if (!remote.ok) {
16314
+ result.errors.push(`No sync remote configured. ${SYNC_SETUP_HINT}`);
16315
+ return result;
16316
+ }
16317
+ result.remote = remote.stdout;
16318
+ // Status is read-only on the network: fetch only, never pull/push.
16319
+ const fetch = runSyncGit(memoryDir, ["fetch", "origin"]);
16320
+ if (!fetch.ok)
16321
+ result.warnings.push(`git fetch failed (showing last-known remote state): ${fetch.stderr}`);
16322
+ result.branch = runSyncGit(memoryDir, ["rev-parse", "--abbrev-ref", "HEAD"]).stdout || null;
16323
+ const upstream = result.branch ? `origin/${result.branch}` : null;
16324
+ if (upstream && runSyncGit(memoryDir, ["rev-parse", "--verify", "--quiet", upstream]).ok) {
16325
+ const counts = runSyncGit(memoryDir, ["rev-list", "--left-right", "--count", `HEAD...${upstream}`]);
16326
+ if (counts.ok) {
16327
+ const [ahead, behind] = counts.stdout.split(/\s+/).map((value) => Number(value));
16328
+ result.ahead = Number.isFinite(ahead) ? ahead : 0;
16329
+ result.behind = Number.isFinite(behind) ? behind : 0;
16330
+ }
16331
+ }
16332
+ result.dirty = Boolean(runSyncGit(memoryDir, ["status", "--porcelain"]).stdout);
16333
+ result.ok = true;
16334
+ return result;
16335
+ }
16336
+ function syncPersonal() {
16337
+ const memoryDir = personalMemoryDir();
16338
+ const result = {
16339
+ ok: false,
16340
+ memory_dir: memoryDir,
16341
+ remote: null,
16342
+ pushed: 0,
16343
+ pulled: 0,
16344
+ resolved: 0,
16345
+ conflict_backups: [],
16346
+ errors: [],
16347
+ };
16348
+ if (!(0, node_fs_1.existsSync)((0, node_path_1.join)(memoryDir, ".git"))) {
16349
+ result.errors.push(`Personal memory store is not set up for sync. ${SYNC_SETUP_HINT}`);
16350
+ return result;
16351
+ }
16352
+ const remote = runSyncGit(memoryDir, ["remote", "get-url", "origin"]);
16353
+ if (!remote.ok) {
16354
+ result.errors.push(`No sync remote configured. ${SYNC_SETUP_HINT}`);
16355
+ return result;
16356
+ }
16357
+ result.remote = remote.stdout;
16358
+ // 1. Commit local packet changes.
16359
+ runSyncGit(memoryDir, ["add", "-A"]);
16360
+ if (runSyncGit(memoryDir, ["status", "--porcelain"]).stdout) {
16361
+ const commit = runSyncGit(memoryDir, [...syncIdentityArgs(memoryDir), "commit", "-m", `kage sync ${nowIso()}`]);
16362
+ if (!commit.ok) {
16363
+ result.errors.push(`git commit failed: ${commit.stderr}`);
16364
+ return result;
16365
+ }
16366
+ }
16367
+ // 2. Pull --rebase (split into fetch + rebase so the receipt can count what came in).
16368
+ const fetch = runSyncGit(memoryDir, ["fetch", "origin"]);
16369
+ if (!fetch.ok) {
16370
+ result.errors.push(`git fetch failed: ${fetch.stderr}`);
16371
+ return result;
16372
+ }
16373
+ const branch = runSyncGit(memoryDir, ["rev-parse", "--abbrev-ref", "HEAD"]).stdout;
16374
+ const upstream = `origin/${branch}`;
16375
+ const hasUpstream = Boolean(branch) && runSyncGit(memoryDir, ["rev-parse", "--verify", "--quiet", upstream]).ok;
16376
+ if (hasUpstream) {
16377
+ // Their side of the merge base: packet files this sync will bring in.
16378
+ const incoming = runSyncGit(memoryDir, ["diff", "--name-only", `HEAD...${upstream}`]);
16379
+ result.pulled = countSyncPacketFiles(incoming.stdout);
16380
+ const rebase = rebaseOntoUpstream(memoryDir, upstream);
16381
+ result.resolved = rebase.resolved;
16382
+ result.conflict_backups = rebase.conflictBackups;
16383
+ if (!rebase.ok) {
16384
+ result.errors.push(rebase.error ?? "git rebase failed");
16385
+ return result;
16386
+ }
16387
+ }
16388
+ // 3. Push (first push sets the upstream).
16389
+ const outgoing = hasUpstream
16390
+ ? runSyncGit(memoryDir, ["diff", "--name-only", `${upstream}..HEAD`])
16391
+ : runSyncGit(memoryDir, ["ls-tree", "-r", "--name-only", "HEAD"]);
16392
+ result.pushed = countSyncPacketFiles(outgoing.stdout);
16393
+ const push = runSyncGit(memoryDir, ["push", "-u", "origin", "HEAD"]);
16394
+ if (!push.ok) {
16395
+ result.errors.push(`git push failed: ${push.stderr}`);
16396
+ return result;
16397
+ }
16398
+ result.ok = true;
16399
+ return result;
16400
+ }