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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -24,6 +24,7 @@ Core commands:
24
24
  kage verify --project <dir> check memory citations against code
25
25
  kage setup <agent> --project <dir> --write wire your agent (claude-code, codex, cursor, ...)
26
26
  kage doctor --project <dir> health check
27
+ kage repair --project <dir> fix what doctor finds (indexes, broken packets, wiring)
27
28
  kage viewer --project <dir> local dashboard
28
29
 
29
30
  Run 'kage help --all' for the full command list (lifecycle, CI, benchmarks, daemon, workspace).`;
@@ -37,6 +38,7 @@ Usage:
37
38
  kage init --project <dir> [--with-policy]
38
39
  kage policy --project <dir>
39
40
  kage doctor --project <dir>
41
+ kage repair --project <dir> [--json]
40
42
  kage setup list
41
43
  kage setup <agent> --project <dir> [--write] [--json]
42
44
  kage setup doctor --project <dir> [--json]
@@ -107,7 +109,8 @@ Usage:
107
109
  kage observe --project <dir> --event <json>
108
110
  kage sessions --project <dir> [--json]
109
111
  kage replay --project <dir> [--session <id>] [--limit <n>] [--json]
110
- kage distill --project <dir> --session <id>
112
+ kage distill --project <dir> --session <id> [--auto] [--json]
113
+ kage resume --project <dir> [--json]
111
114
  kage learn --project <dir> --learning <text> [--title <title>] [--type <type>] [--evidence <text>] [--verified-by <text>] [--tags a,b] [--paths a,b] [--graph-nodes a,b] [--allow-missing-paths]
112
115
  kage feedback --project <dir> --packet <packet-id> --kind helpful|wrong|stale
113
116
  kage capture --project <dir> --title <title> --body <body> [--type <type>] [--summary <summary>] [--tags a,b] [--paths a,b] [--stack a,b] [--graph-nodes a,b] [--allow-missing-paths]
@@ -408,7 +411,36 @@ async function main() {
408
411
  console.log(`Warnings:\n${result.validation.warnings.map((warning) => ` - ${warning}`).join("\n")}`);
409
412
  console.log("\nRecall smoke test:\n");
410
413
  console.log(result.sampleRecall);
411
- if (!result.validation.ok)
414
+ if (!result.validation.ok) {
415
+ console.log("\nSomething broken? kage repair --project .");
416
+ process.exit(2);
417
+ }
418
+ return;
419
+ }
420
+ if (command === "repair") {
421
+ const result = (0, kernel_js_1.repairProject)(projectArg(args));
422
+ if (args.includes("--json")) {
423
+ console.log(JSON.stringify(result, null, 2));
424
+ if (!result.ok || !result.validation.ok)
425
+ process.exit(2);
426
+ return;
427
+ }
428
+ console.log(`Kage repair — ${result.project_dir}\n`);
429
+ const areaLabel = { packets: "Memory", indexes: "Indexes", locks: "Locks", agents: "Agents" };
430
+ for (const action of result.actions) {
431
+ const mark = action.status === "fixed" ? "✓" : action.status === "failed" ? "✗" : "•";
432
+ console.log(` ${(areaLabel[action.area] ?? action.area).padEnd(12)}${mark} ${action.target} — ${action.detail}`);
433
+ }
434
+ console.log(`\n${result.fixed} fixed, ${result.skipped} already healthy, ${result.failed} failed`);
435
+ if (result.removed_packets.length) {
436
+ console.log(`\nRemoved ${result.removed_packets.length} unrecoverable packet(s) — backups kept in .agent_memory/backup/:`);
437
+ for (const removed of result.removed_packets)
438
+ console.log(` ${removed}`);
439
+ }
440
+ console.log(result.validation.ok ? "Validation: passed" : "Validation: still failing");
441
+ if (result.validation.errors.length)
442
+ console.log(`Errors:\n${result.validation.errors.map((error) => ` - ${error}`).join("\n")}`);
443
+ if (!result.ok || !result.validation.ok)
412
444
  process.exit(2);
413
445
  return;
414
446
  }
@@ -1899,9 +1931,18 @@ async function main() {
1899
1931
  const sessionId = takeArg(args, "--session");
1900
1932
  if (!sessionId)
1901
1933
  usage();
1902
- const result = (0, kernel_js_1.distillSession)(projectArg(args), sessionId);
1934
+ const project = projectArg(args);
1935
+ const auto = args.includes("--auto");
1936
+ const result = (0, kernel_js_1.distillSession)(project, sessionId, { auto });
1903
1937
  if (args.includes("--json"))
1904
1938
  console.log(JSON.stringify(result, null, 2));
1939
+ else if (auto) {
1940
+ // Auto mode is quiet: no output for empty or already-captured sessions; one line otherwise.
1941
+ const drafted = result.candidates.filter((candidate) => candidate.ok).length;
1942
+ if (!result.skipped_reason && drafted > 0) {
1943
+ console.log(`Auto-distilled ${drafted} pending draft${drafted === 1 ? "" : "s"} from session ${sessionId}. Review with: kage review --project ${project}`);
1944
+ }
1945
+ }
1905
1946
  else {
1906
1947
  console.log(`Distilled session: ${sessionId}`);
1907
1948
  console.log(`Observations: ${result.observations}`);
@@ -1909,10 +1950,21 @@ async function main() {
1909
1950
  if (result.errors.length)
1910
1951
  console.log(`Errors:\n${result.errors.map((error) => ` - ${error}`).join("\n")}`);
1911
1952
  }
1912
- if (!result.ok)
1953
+ if (!result.ok && !auto)
1913
1954
  process.exit(2);
1914
1955
  return;
1915
1956
  }
1957
+ if (command === "resume") {
1958
+ const result = (0, kernel_js_1.kageResume)(projectArg(args));
1959
+ if (args.includes("--json")) {
1960
+ console.log(JSON.stringify(result, null, 2));
1961
+ return;
1962
+ }
1963
+ // Prints nothing when there is no prior session data, so hooks can append output verbatim.
1964
+ if (result.has_content && result.context_block)
1965
+ console.log(result.context_block);
1966
+ return;
1967
+ }
1916
1968
  if (command === "feedback") {
1917
1969
  const id = takeArg(args, "--packet");
1918
1970
  const kind = takeArg(args, "--kind");
@@ -2004,7 +2056,10 @@ async function main() {
2004
2056
  }
2005
2057
  usage();
2006
2058
  }
2059
+ // Remediation-first failure: lead with the message, follow with exactly ONE
2060
+ // copy-pasteable next command. Exit code stays 1, same as before.
2007
2061
  main().catch((error) => {
2008
- console.error(error);
2062
+ console.error(error instanceof Error ? error.message : String(error));
2063
+ console.error(`\nTry:\n ${(0, kernel_js_1.remediationFor)(error)}`);
2009
2064
  process.exit(1);
2010
2065
  });
package/dist/daemon.js CHANGED
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.viewerStaticHeaders = viewerStaticHeaders;
4
4
  exports.viewerRedirectLocation = viewerRedirectLocation;
5
5
  exports.viewerReportPaths = viewerReportPaths;
6
+ exports.startLiveFeed = startLiveFeed;
6
7
  exports.viewerUrl = viewerUrl;
7
8
  exports.viewerBenchmarkReport = viewerBenchmarkReport;
8
9
  exports.daemonContextReport = daemonContextReport;
@@ -105,6 +106,152 @@ function viewerReportPaths(projectRoot) {
105
106
  value: (0, node_path_1.join)(reportsDir, "value.json"),
106
107
  };
107
108
  }
109
+ const LIVE_FEED_HEARTBEAT_MS = 25_000;
110
+ const LIVE_FEED_DEBOUNCE_MS = 100;
111
+ function readPacketTitle(filePath) {
112
+ try {
113
+ const parsed = JSON.parse((0, node_fs_1.readFileSync)(filePath, "utf8"));
114
+ return typeof parsed.title === "string" && parsed.title ? parsed.title : undefined;
115
+ }
116
+ catch {
117
+ return undefined;
118
+ }
119
+ }
120
+ // Streams memory/value activity to viewer clients over SSE (GET /kage/events).
121
+ // The engine already writes packets and the value ledger to .agent_memory/, so a
122
+ // filesystem watch is the lightest possible event source — no queue, no new deps.
123
+ function startLiveFeed(projectRoot, options = {}) {
124
+ const heartbeatMs = options.heartbeatMs ?? LIVE_FEED_HEARTBEAT_MS;
125
+ const debounceMs = options.debounceMs ?? LIVE_FEED_DEBOUNCE_MS;
126
+ const packetsDir = (0, node_path_1.join)(projectRoot, ".agent_memory", "packets");
127
+ const reportsDir = (0, node_path_1.join)(projectRoot, ".agent_memory", "reports");
128
+ const valuePath = (0, node_path_1.join)(reportsDir, "value.json");
129
+ const clients = new Set();
130
+ const watchers = [];
131
+ const timers = new Map();
132
+ const knownPackets = new Set();
133
+ try {
134
+ (0, node_fs_1.mkdirSync)(packetsDir, { recursive: true });
135
+ for (const name of (0, node_fs_1.readdirSync)(packetsDir))
136
+ knownPackets.add(name);
137
+ }
138
+ catch {
139
+ // packets dir unavailable: packet events simply won't fire
140
+ }
141
+ function readValueEvents() {
142
+ try {
143
+ const parsed = JSON.parse((0, node_fs_1.readFileSync)(valuePath, "utf8"));
144
+ return Array.isArray(parsed.events) ? parsed.events : [];
145
+ }
146
+ catch {
147
+ return [];
148
+ }
149
+ }
150
+ let seenValueEvents = readValueEvents().length;
151
+ function broadcast(event) {
152
+ const payload = `data: ${JSON.stringify(event)}\n\n`;
153
+ for (const res of clients)
154
+ res.write(payload);
155
+ }
156
+ function onPacketChange(name) {
157
+ const filePath = (0, node_path_1.join)(packetsDir, name);
158
+ if (!(0, node_fs_1.existsSync)(filePath)) {
159
+ knownPackets.delete(name);
160
+ return;
161
+ }
162
+ const isNew = !knownPackets.has(name);
163
+ knownPackets.add(name);
164
+ broadcast({
165
+ type: isNew ? "packet_written" : "packet_updated",
166
+ title: readPacketTitle(filePath) ?? name.replace(/\.json$/, ""),
167
+ path: (0, node_path_1.join)(".agent_memory", "packets", name),
168
+ ts: new Date().toISOString(),
169
+ });
170
+ }
171
+ function onValueChange() {
172
+ const events = readValueEvents();
173
+ if (events.length < seenValueEvents)
174
+ seenValueEvents = 0; // ledger trimmed or rewritten
175
+ for (const event of events.slice(seenValueEvents)) {
176
+ broadcast({
177
+ type: "value_event",
178
+ title: typeof event.packet_title === "string" ? event.packet_title : undefined,
179
+ path: (0, node_path_1.join)(".agent_memory", "reports", "value.json"),
180
+ event,
181
+ ts: typeof event.at === "string" ? event.at : new Date().toISOString(),
182
+ });
183
+ }
184
+ seenValueEvents = events.length;
185
+ }
186
+ // fs.watch fires bursts of duplicate events for one logical write; collapse
187
+ // them per file with a short debounce before reading and broadcasting.
188
+ function debounced(key, run) {
189
+ const existing = timers.get(key);
190
+ if (existing)
191
+ clearTimeout(existing);
192
+ timers.set(key, setTimeout(() => {
193
+ timers.delete(key);
194
+ try {
195
+ run();
196
+ }
197
+ catch {
198
+ // keep the feed alive even if a read races a write
199
+ }
200
+ }, debounceMs));
201
+ }
202
+ try {
203
+ watchers.push((0, node_fs_1.watch)(packetsDir, (_event, filename) => {
204
+ const name = String(filename ?? "");
205
+ if (!name.endsWith(".json"))
206
+ return;
207
+ debounced(`packet:${name}`, () => onPacketChange(name));
208
+ }));
209
+ }
210
+ catch {
211
+ // packets dir missing: no packet events
212
+ }
213
+ try {
214
+ (0, node_fs_1.mkdirSync)(reportsDir, { recursive: true });
215
+ watchers.push((0, node_fs_1.watch)(reportsDir, (_event, filename) => {
216
+ if (String(filename ?? "") !== "value.json")
217
+ return;
218
+ debounced("value", onValueChange);
219
+ }));
220
+ }
221
+ catch {
222
+ // reports dir missing: no value events
223
+ }
224
+ const heartbeat = setInterval(() => {
225
+ for (const res of clients)
226
+ res.write(`: heartbeat ${Date.now()}\n\n`);
227
+ }, heartbeatMs);
228
+ heartbeat.unref();
229
+ function handleRequest(req, res) {
230
+ res.writeHead(200, {
231
+ "content-type": "text/event-stream; charset=utf-8",
232
+ "cache-control": "no-cache, no-transform",
233
+ connection: "keep-alive",
234
+ "x-accel-buffering": "no",
235
+ });
236
+ res.write(": connected\n\n");
237
+ clients.add(res);
238
+ req.on("close", () => {
239
+ clients.delete(res);
240
+ });
241
+ }
242
+ function close() {
243
+ clearInterval(heartbeat);
244
+ for (const timer of timers.values())
245
+ clearTimeout(timer);
246
+ timers.clear();
247
+ for (const watcher of watchers)
248
+ watcher.close();
249
+ for (const res of clients)
250
+ res.end();
251
+ clients.clear();
252
+ }
253
+ return { handleRequest, broadcast, clientCount: () => clients.size, close };
254
+ }
108
255
  function viewerUrl(host, port, projectRoot) {
109
256
  const query = Object.entries(viewerReportPaths(projectRoot))
110
257
  .map(([name, path]) => `${name}=${encodeURIComponent(path)}`)
@@ -633,8 +780,13 @@ async function startViewer(projectDir, options = {}) {
633
780
  // non-fatal: viewer will show 404 for reports if generation fails
634
781
  }
635
782
  const url = viewerUrl(host, port, projectRoot);
783
+ const liveFeed = startLiveFeed(projectRoot);
636
784
  const server = (0, node_http_1.createServer)((req, res) => {
637
785
  const requestUrl = new URL(req.url ?? "/", `http://${host}:${port}`);
786
+ if (req.method === "GET" && requestUrl.pathname === "/kage/events") {
787
+ liveFeed.handleRequest(req, res);
788
+ return;
789
+ }
638
790
  let filePath = null;
639
791
  const redirectLocation = viewerRedirectLocation(requestUrl.pathname, requestUrl.search, new URL(url).search);
640
792
  if (redirectLocation) {
@@ -673,6 +825,7 @@ async function startViewer(projectDir, options = {}) {
673
825
  await new Promise((resolveListen) => server.listen(port, host, resolveListen));
674
826
  console.log(`Kage viewer listening on ${url}`);
675
827
  process.on("SIGTERM", () => {
828
+ liveFeed.close();
676
829
  server.close(() => process.exit(0));
677
830
  });
678
831
  return { ok: true, project_dir: projectRoot, host, port, url };
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.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;
@@ -70,6 +70,7 @@ exports.kageMemoryReconciliation = kageMemoryReconciliation;
70
70
  exports.evaluateMemoryAdmission = evaluateMemoryAdmission;
71
71
  exports.validatePacket = validatePacket;
72
72
  exports.scanSensitiveText = scanSensitiveText;
73
+ exports.stripPrivateSpans = stripPrivateSpans;
73
74
  exports.catalogDomainNodeCount = catalogDomainNodeCount;
74
75
  exports.ensureMemoryDirs = ensureMemoryDirs;
75
76
  exports.loadApprovedPackets = loadApprovedPackets;
@@ -140,6 +141,7 @@ exports.kageSessionCaptureReport = kageSessionCaptureReport;
140
141
  exports.kageSessionReplay = kageSessionReplay;
141
142
  exports.kageSessionLearningLedger = kageSessionLearningLedger;
142
143
  exports.distillSession = distillSession;
144
+ exports.kageResume = kageResume;
143
145
  exports.proposeFromDiff = proposeFromDiff;
144
146
  exports.buildBranchOverlay = buildBranchOverlay;
145
147
  exports.createReviewArtifact = createReviewArtifact;
@@ -155,6 +157,9 @@ exports.recordFeedback = recordFeedback;
155
157
  exports.validateProject = validateProject;
156
158
  exports.initProject = initProject;
157
159
  exports.doctorProject = doctorProject;
160
+ exports.splitConflictSides = splitConflictSides;
161
+ exports.repairProject = repairProject;
162
+ exports.remediationFor = remediationFor;
158
163
  exports.approvePending = approvePending;
159
164
  exports.rejectPending = rejectPending;
160
165
  exports.changelog = changelog;
@@ -1784,6 +1789,28 @@ function scanSensitiveText(text) {
1784
1789
  ];
1785
1790
  return patterns.filter(([, pattern]) => pattern.test(text)).map(([name]) => name);
1786
1791
  }
1792
+ // Privacy tags: anything wrapped in <private>...</private> is redacted to
1793
+ // "[private]" BEFORE a packet or observation is written, so the content never
1794
+ // reaches disk. Matching is case-insensitive and spans newlines; an unclosed
1795
+ // <private> redacts to the end of the string so a malformed tag cannot leak.
1796
+ const PRIVATE_SPAN_PATTERN = /<private>[\s\S]*?(?:<\/private>|$)/gi;
1797
+ function stripPrivateSpans(text) {
1798
+ if (!text || text.toLowerCase().indexOf("<private>") === -1)
1799
+ return text;
1800
+ return text.replace(PRIVATE_SPAN_PATTERN, "[private]");
1801
+ }
1802
+ function stripPrivateFromContext(context) {
1803
+ const sanitized = { ...context };
1804
+ for (const key of ["fact", "why", "trigger", "action", "verification", "risk_if_forgotten", "stale_when"]) {
1805
+ const value = sanitized[key];
1806
+ if (typeof value === "string")
1807
+ sanitized[key] = stripPrivateSpans(value);
1808
+ }
1809
+ if (sanitized.rejected_alternatives) {
1810
+ sanitized.rejected_alternatives = sanitized.rejected_alternatives.map((entry) => stripPrivateSpans(entry));
1811
+ }
1812
+ return sanitized;
1813
+ }
1787
1814
  function catalogDomainNodeCount(domain) {
1788
1815
  return domain.nodes ?? domain.node_count ?? 0;
1789
1816
  }
@@ -1861,8 +1888,11 @@ function loadApprovedPackets(projectDir) {
1861
1888
  function loadPendingPackets(projectDir) {
1862
1889
  return loadPacketsFromDir(pendingDir(projectDir));
1863
1890
  }
1891
+ // Hook-driven auto-distilled drafts carry this tag so they are distinguishable from
1892
+ // agent-reviewed memory and never surface in recall until a human or agent approves them.
1893
+ exports.AUTO_DISTILL_TAG = "auto-distill";
1864
1894
  function recallablePendingPackets(projectDir) {
1865
- return loadPendingPackets(projectDir).filter((packet) => !packet.tags.includes("diff-proposal"));
1895
+ return loadPendingPackets(projectDir).filter((packet) => !packet.tags.includes("diff-proposal") && !packet.tags.includes(exports.AUTO_DISTILL_TAG));
1866
1896
  }
1867
1897
  function writePacket(projectDir, packet, statusDir) {
1868
1898
  const dir = statusDir === "packets" ? packetsDir(projectDir) : pendingDir(projectDir);
@@ -11951,6 +11981,15 @@ function hasStructuredEngineeringContext(packet) {
11951
11981
  return Boolean(context.why || context.verification || context.risk_if_forgotten || context.stale_when || context.trigger || context.action);
11952
11982
  }
11953
11983
  function learn(input) {
11984
+ // Redact <private> spans before deriving the title/summary so private text
11985
+ // never leaks into derived fields; capture() re-applies the same sanitizer.
11986
+ input = {
11987
+ ...input,
11988
+ learning: stripPrivateSpans(input.learning),
11989
+ title: input.title === undefined ? undefined : stripPrivateSpans(input.title),
11990
+ evidence: input.evidence === undefined ? undefined : stripPrivateSpans(input.evidence),
11991
+ verifiedBy: input.verifiedBy === undefined ? undefined : stripPrivateSpans(input.verifiedBy),
11992
+ };
11954
11993
  const type = inferLearningType(input);
11955
11994
  const title = input.title?.trim() || titleFromLearning(input.learning);
11956
11995
  const body = [
@@ -11971,10 +12010,20 @@ function learn(input) {
11971
12010
  allowMissingPaths: input.allowMissingPaths,
11972
12011
  strictCitations: input.strictCitations,
11973
12012
  graphNodes: input.graphNodes,
12013
+ pendingReview: input.pendingReview,
11974
12014
  });
11975
12015
  }
11976
12016
  function capture(input) {
11977
12017
  ensureMemoryDirs(input.projectDir);
12018
+ // Privacy tags: redact <private> spans from every text field before any
12019
+ // validation, scanning, or storage — private content must never hit disk.
12020
+ input = {
12021
+ ...input,
12022
+ title: stripPrivateSpans(input.title),
12023
+ summary: input.summary === undefined ? undefined : stripPrivateSpans(input.summary),
12024
+ body: stripPrivateSpans(input.body),
12025
+ context: input.context ? stripPrivateFromContext(input.context) : input.context,
12026
+ };
11978
12027
  const type = input.type ?? "reference";
11979
12028
  if (!exports.MEMORY_TYPES.includes(type)) {
11980
12029
  return { ok: false, errors: [`Invalid memory type: ${type}`] };
@@ -12027,7 +12076,7 @@ function capture(input) {
12027
12076
  scope: "repo",
12028
12077
  visibility: "team",
12029
12078
  sensitivity: "internal",
12030
- status: "approved",
12079
+ status: input.pendingReview ? "pending" : "approved",
12031
12080
  confidence: DEFAULT_CONFIDENCE,
12032
12081
  tags: input.tags ?? [],
12033
12082
  paths: groundedPaths,
@@ -12068,7 +12117,7 @@ function capture(input) {
12068
12117
  ...packet.quality,
12069
12118
  ...evaluateMemoryQuality(input.projectDir, packet),
12070
12119
  };
12071
- const path = writePacket(input.projectDir, packet, "packets");
12120
+ const path = writePacket(input.projectDir, packet, input.pendingReview ? "pending" : "packets");
12072
12121
  recordMemoryAudit(input.projectDir, "capture", [packet], {
12073
12122
  type: packet.type,
12074
12123
  status: packet.status,
@@ -12297,6 +12346,16 @@ Before finishing a task that changed files: kage_pr_summarize or kage_propose_fr
12297
12346
  If recalled memory helped: kage_feedback helpful. If wrong or stale: kage_feedback wrong or stale."
12298
12347
  fi
12299
12348
 
12349
+ # Session continuity: append a compact "previously…" digest when prior session data exists.
12350
+ if command -v kage >/dev/null 2>&1; then
12351
+ PREVIOUSLY="$(kage resume --project "$CWD" 2>/dev/null || true)"
12352
+ if [[ -n "$PREVIOUSLY" ]]; then
12353
+ POLICY="$POLICY
12354
+
12355
+ $PREVIOUSLY"
12356
+ fi
12357
+ fi
12358
+
12300
12359
  KAGE_MSG="$POLICY" python3 -c "import json,os; print(json.dumps({'systemMessage': os.environ['KAGE_MSG']}))"
12301
12360
  `;
12302
12361
  const stopHookScript = `#!/usr/bin/env bash
@@ -12333,6 +12392,20 @@ print(d.get("agent_instruction") or "Kage memory reconciliation required before
12333
12392
  fi
12334
12393
  fi
12335
12394
 
12395
+ # Automatic capture fallback: if this session recorded observations but produced no new
12396
+ # memory packets, quietly distill them into pending drafts for later review. Best-effort;
12397
+ # kage distill --auto is silent on empty or already-captured sessions and never blocks.
12398
+ SESSION="$(printf "%s" "$PAYLOAD" | python3 -c 'import json, sys
12399
+ try:
12400
+ d = json.load(sys.stdin)
12401
+ except Exception:
12402
+ d = {}
12403
+ print(d.get("session_id") or d.get("sessionId") or "")
12404
+ ' 2>/dev/null || echo "")"
12405
+ if [[ -n "$SESSION" && -d "$CWD/.agent_memory/observations" ]]; then
12406
+ kage distill --project "$CWD" --session "$SESSION" --auto --json >/dev/null 2>&1 || true
12407
+ fi
12408
+
12336
12409
  exit 0
12337
12410
  `;
12338
12411
  const observeHookScript = `#!/usr/bin/env bash
@@ -12742,6 +12815,14 @@ function observationHash(projectDir, event) {
12742
12815
  }
12743
12816
  function observe(projectDir, event) {
12744
12817
  ensureMemoryDirs(projectDir);
12818
+ // Privacy tags: redact <private> spans from free-text fields before hashing,
12819
+ // scanning, or persisting the observation record.
12820
+ event = {
12821
+ ...event,
12822
+ text: event.text === undefined ? undefined : stripPrivateSpans(event.text),
12823
+ summary: event.summary === undefined ? undefined : stripPrivateSpans(event.summary),
12824
+ command: event.command === undefined ? undefined : stripPrivateSpans(event.command),
12825
+ };
12745
12826
  const allowed = ["session_start", "user_prompt", "tool_use", "tool_result", "file_change", "command_result", "test_result", "session_end"];
12746
12827
  if (!allowed.includes(event.type))
12747
12828
  return { ok: false, stored: false, duplicate: false, errors: [`Invalid observation type: ${event.type}`] };
@@ -13244,8 +13325,32 @@ function kageSessionLearningLedger(projectDir, options = {}) {
13244
13325
  context_block: learningLedgerContextBlock(reportWithoutBlock),
13245
13326
  };
13246
13327
  }
13247
- function distillSession(projectDir, sessionId) {
13328
+ // Mechanical packets (branch change memory, prior auto-distilled drafts) never count as the
13329
+ // agent having captured memory; only deliberate captures/learns/distills suppress the
13330
+ // Stop-hook auto-distill fallback.
13331
+ function sessionAlreadyCaptured(projectDir, sessionId, observations) {
13332
+ const firstAt = observations[0]?.timestamp ?? "";
13333
+ const mechanicalTags = ["diff-proposal", "change-memory", exports.AUTO_DISTILL_TAG];
13334
+ return [...loadApprovedPackets(projectDir), ...loadPendingPackets(projectDir)].some((packet) => {
13335
+ if (packet.source_refs.some((ref) => ref.kind === "observation_session" && ref.session_id === sessionId))
13336
+ return true;
13337
+ if (packet.type === "repo_map" || packet.quality?.reviewer === "kage-indexer")
13338
+ return false; // generated by indexing
13339
+ if (packet.tags.some((tag) => mechanicalTags.includes(tag)))
13340
+ return false;
13341
+ return Boolean(firstAt) && packet.created_at >= firstAt;
13342
+ });
13343
+ }
13344
+ function distillSession(projectDir, sessionId, options = {}) {
13345
+ const auto = Boolean(options.auto);
13346
+ const mode = auto ? "auto" : "manual";
13248
13347
  const observations = loadObservations(projectDir, sessionId);
13348
+ if (auto && observations.length === 0) {
13349
+ return { ok: true, session_id: sessionId, observations: 0, candidates: [], errors: [], mode, skipped_reason: "no_observations" };
13350
+ }
13351
+ if (auto && sessionAlreadyCaptured(projectDir, sessionId, observations)) {
13352
+ return { ok: true, session_id: sessionId, observations: observations.length, candidates: [], errors: [], mode, skipped_reason: "session_already_captured" };
13353
+ }
13249
13354
  const candidates = [];
13250
13355
  const errors = [];
13251
13356
  const observationIds = observations.map((event) => event.id);
@@ -13262,13 +13367,14 @@ function distillSession(projectDir, sessionId) {
13262
13367
  ];
13263
13368
  result.packet.quality = {
13264
13369
  ...result.packet.quality,
13265
- distillation: "automatic_observation_candidate",
13370
+ distillation: auto ? "auto_distill" : "automatic_observation_candidate",
13266
13371
  admission: evaluateMemoryAdmission(projectDir, result.packet),
13267
13372
  suggested_review_action: suggestedAction(classifyPacket(projectDir, result.packet), result.packet.status),
13268
13373
  };
13269
13374
  writeJson(result.path, result.packet);
13270
13375
  return result;
13271
13376
  };
13377
+ const autoTags = auto ? [exports.AUTO_DISTILL_TAG] : [];
13272
13378
  const commandEvents = observations.filter((event) => event.type === "command_result" && event.command);
13273
13379
  const fileEvents = observations.filter((event) => event.type === "file_change" && event.path);
13274
13380
  const promptEvents = observations.filter((event) => event.type === "user_prompt" && (event.text || event.summary));
@@ -13284,8 +13390,9 @@ function distillSession(projectDir, sessionId) {
13284
13390
  summary: `Observed commands: ${commands.slice(0, 3).join(", ")}`,
13285
13391
  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
13392
  type: "runbook",
13287
- tags: ["observed-session", "commands", "runbook"],
13393
+ tags: ["observed-session", "commands", "runbook", ...autoTags],
13288
13394
  paths: unique(meaningfulCommandEvents.map((item) => item.event.path).filter(Boolean)),
13395
+ pendingReview: auto,
13289
13396
  })));
13290
13397
  }
13291
13398
  const meaningfulFileEvents = fileEvents
@@ -13300,8 +13407,9 @@ function distillSession(projectDir, sessionId) {
13300
13407
  summary: lead,
13301
13408
  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
13409
  type: "workflow",
13303
- tags: ["observed-session", "workflow"],
13410
+ tags: ["observed-session", "workflow", ...autoTags],
13304
13411
  paths,
13412
+ pendingReview: auto,
13305
13413
  })));
13306
13414
  }
13307
13415
  if (promptEvents.length) {
@@ -13312,13 +13420,94 @@ function distillSession(projectDir, sessionId) {
13312
13420
  title: titleFromLearning(text),
13313
13421
  learning: text,
13314
13422
  evidence: `Observation session: ${sessionId}`,
13315
- tags: ["observed-session", "intent"],
13423
+ tags: ["observed-session", "intent", ...autoTags],
13424
+ pendingReview: auto,
13316
13425
  })));
13317
13426
  }
13318
13427
  for (const result of candidates)
13319
13428
  if (!result.ok)
13320
13429
  errors.push(...result.errors);
13321
- return { ok: errors.length === 0, session_id: sessionId, observations: observations.length, candidates, errors };
13430
+ return { ok: errors.length === 0, session_id: sessionId, observations: observations.length, candidates, errors, mode };
13431
+ }
13432
+ // Session continuity: a compact "previously…" digest the SessionStart hook injects so a new
13433
+ // session starts with last session's context instead of cold. Empty when there is no prior data.
13434
+ function kageResume(projectDir) {
13435
+ ensureMemoryDirs(projectDir);
13436
+ const approved = loadApprovedPackets(projectDir);
13437
+ const pending = loadPendingPackets(projectDir);
13438
+ const observations = loadObservations(projectDir);
13439
+ const bySession = new Map();
13440
+ for (const observation of observations) {
13441
+ const rows = bySession.get(observation.session_id) ?? [];
13442
+ rows.push(observation);
13443
+ bySession.set(observation.session_id, rows);
13444
+ }
13445
+ const latestRows = Array.from(bySession.values())
13446
+ .sort((a, b) => (b.at(-1)?.timestamp ?? "").localeCompare(a.at(-1)?.timestamp ?? ""))[0];
13447
+ const lastSession = latestRows?.length
13448
+ ? (() => {
13449
+ const sessionId = latestRows[0].session_id;
13450
+ const distilledTitles = [...approved, ...pending]
13451
+ .filter((packet) => packet.source_refs.some((ref) => ref.kind === "observation_session" && ref.session_id === sessionId))
13452
+ .map((packet) => packet.title);
13453
+ return {
13454
+ session_id: sessionId,
13455
+ first_at: latestRows[0]?.timestamp ?? "",
13456
+ last_at: latestRows.at(-1)?.timestamp ?? "",
13457
+ observations: latestRows.length,
13458
+ paths: unique(latestRows.map((event) => event.path).filter(Boolean)).slice(0, 6),
13459
+ commands: unique(latestRows.map((event) => event.command).filter(Boolean)).slice(0, 3),
13460
+ distilled_titles: unique(distilledTitles).slice(0, 3),
13461
+ };
13462
+ })()
13463
+ : undefined;
13464
+ const changeMemory = approved
13465
+ .filter((packet) => packet.tags.includes("change-memory"))
13466
+ .sort((a, b) => b.updated_at.localeCompare(a.updated_at))[0];
13467
+ const lastChangeMemory = changeMemory
13468
+ ? { id: changeMemory.id, title: changeMemory.title, summary: changeMemory.summary, updated_at: changeMemory.updated_at }
13469
+ : undefined;
13470
+ const pendingAutoDistilled = pending.filter((packet) => packet.tags.includes(exports.AUTO_DISTILL_TAG)).length;
13471
+ const reconciliation = kageMemoryReconciliation(projectDir, { limit: 5 });
13472
+ const reconciliationItems = reconciliation.items.map((item) => ({ packet_id: item.packet_id, title: item.title }));
13473
+ const hasContent = Boolean(lastSession || lastChangeMemory || pendingAutoDistilled || reconciliation.unresolved_count);
13474
+ const lines = [];
13475
+ if (hasContent) {
13476
+ lines.push("# Previously (Kage)");
13477
+ if (lastSession) {
13478
+ lines.push(`Last session ${lastSession.session_id} (${lastSession.observations} observation${lastSession.observations === 1 ? "" : "s"}, ended ${lastSession.last_at}).`);
13479
+ if (lastSession.paths.length)
13480
+ lines.push(`Worked on: ${lastSession.paths.join(", ")}`);
13481
+ if (lastSession.commands.length)
13482
+ lines.push(`Commands: ${lastSession.commands.join("; ")}`);
13483
+ if (lastSession.distilled_titles.length)
13484
+ lines.push(`Learned: ${lastSession.distilled_titles.join("; ")}`);
13485
+ }
13486
+ if (lastChangeMemory) {
13487
+ lines.push(`Change memory: ${lastChangeMemory.title} — ${lastChangeMemory.summary}`);
13488
+ }
13489
+ if (pendingAutoDistilled) {
13490
+ lines.push(`Pending: ${pendingAutoDistilled} auto-distilled draft${pendingAutoDistilled === 1 ? "" : "s"} awaiting review — run: kage review --project ${projectDir}`);
13491
+ }
13492
+ if (reconciliation.unresolved_count) {
13493
+ lines.push(`Reconcile: ${reconciliation.unresolved_count} linked memory item${reconciliation.unresolved_count === 1 ? "" : "s"} need update — run: kage reconcile --project ${projectDir}`);
13494
+ for (const item of reconciliationItems.slice(0, 3))
13495
+ lines.push(` - ${item.packet_id}: ${item.title}`);
13496
+ }
13497
+ }
13498
+ return {
13499
+ schema_version: 1,
13500
+ project_dir: projectDir,
13501
+ generated_at: nowIso(),
13502
+ has_content: hasContent,
13503
+ last_session: lastSession,
13504
+ last_change_memory: lastChangeMemory,
13505
+ pending_auto_distilled: pendingAutoDistilled,
13506
+ pending_total: pending.length,
13507
+ ...(pendingAutoDistilled ? { review_command: `kage review --project ${projectDir}` } : {}),
13508
+ reconciliation: { unresolved_count: reconciliation.unresolved_count, items: reconciliationItems },
13509
+ context_block: lines.slice(0, 15).join("\n"),
13510
+ };
13322
13511
  }
13323
13512
  function createDiffChangeMemory(projectDir, summary) {
13324
13513
  const branch = summary.branch ?? "detached";
@@ -14234,6 +14423,279 @@ function doctorProject(projectDir) {
14234
14423
  sampleRecall: sampleRecall.context_block,
14235
14424
  };
14236
14425
  }
14426
+ function repairBackupDir(projectDir) {
14427
+ return (0, node_path_1.join)(memoryRoot(projectDir), "backup");
14428
+ }
14429
+ // Split a git merge-conflicted file into its two sides. Returns null when no
14430
+ // complete conflict block is present. diff3-style base sections (`|||||||`)
14431
+ // belong to neither side and are dropped.
14432
+ function splitConflictSides(content) {
14433
+ let section = "both";
14434
+ let conflicts = 0;
14435
+ const ours = [];
14436
+ const theirs = [];
14437
+ for (const line of content.split("\n")) {
14438
+ if (section === "both" && /^<{7}(\s|$)/.test(line)) {
14439
+ section = "ours";
14440
+ conflicts += 1;
14441
+ continue;
14442
+ }
14443
+ if (section === "ours" && /^\|{7}(\s|$)/.test(line)) {
14444
+ section = "base";
14445
+ continue;
14446
+ }
14447
+ if ((section === "ours" || section === "base") && /^={7}$/.test(line.trimEnd())) {
14448
+ section = "theirs";
14449
+ continue;
14450
+ }
14451
+ if (section === "theirs" && /^>{7}(\s|$)/.test(line)) {
14452
+ section = "both";
14453
+ continue;
14454
+ }
14455
+ if (section === "both") {
14456
+ ours.push(line);
14457
+ theirs.push(line);
14458
+ }
14459
+ else if (section === "ours")
14460
+ ours.push(line);
14461
+ else if (section === "theirs")
14462
+ theirs.push(line);
14463
+ }
14464
+ if (!conflicts || section !== "both")
14465
+ return null;
14466
+ return { ours: ours.join("\n"), theirs: theirs.join("\n") };
14467
+ }
14468
+ function packetRecency(packet) {
14469
+ return String(packet.updated_at ?? packet.created_at ?? "");
14470
+ }
14471
+ // Auto-resolve a merge-conflicted packet by keeping the newest side — but only
14472
+ // when that side parses as JSON. Anything less certain stays a removal.
14473
+ function resolveConflictedPacket(content) {
14474
+ const sides = splitConflictSides(content);
14475
+ if (!sides)
14476
+ return null;
14477
+ const candidates = [];
14478
+ for (const side of [sides.ours, sides.theirs]) {
14479
+ try {
14480
+ const parsed = JSON.parse(side);
14481
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
14482
+ candidates.push(parsed);
14483
+ }
14484
+ catch {
14485
+ // This side does not parse; the other side may still win.
14486
+ }
14487
+ }
14488
+ if (!candidates.length)
14489
+ return null;
14490
+ candidates.sort((a, b) => packetRecency(b).localeCompare(packetRecency(a)));
14491
+ return candidates[0];
14492
+ }
14493
+ function repairProject(projectDir, options = {}) {
14494
+ ensureMemoryDirs(projectDir);
14495
+ const actions = [];
14496
+ const removedPackets = [];
14497
+ let packetsTouched = false;
14498
+ // 1. Unparseable packet JSON (merge conflicts, torn writes, hand edits).
14499
+ // Always back up the broken original before changing anything.
14500
+ let brokenFound = 0;
14501
+ const packetDirs = [
14502
+ [packetsDir(projectDir), "packets"],
14503
+ [pendingDir(projectDir), "pending"],
14504
+ [publicCandidatesDir(projectDir), "public-candidates"],
14505
+ ];
14506
+ for (const [dir, label] of packetDirs) {
14507
+ for (const path of walkFiles(dir, (candidate) => candidate.endsWith(".json"))) {
14508
+ const target = `${label}/${(0, node_path_1.basename)(path)}`;
14509
+ let raw;
14510
+ try {
14511
+ raw = (0, node_fs_1.readFileSync)(path, "utf8");
14512
+ }
14513
+ catch (error) {
14514
+ actions.push({ area: "packets", target, status: "failed", detail: error instanceof Error ? error.message : String(error) });
14515
+ continue;
14516
+ }
14517
+ try {
14518
+ JSON.parse(raw);
14519
+ continue; // healthy packet
14520
+ }
14521
+ catch {
14522
+ // fall through to repair
14523
+ }
14524
+ brokenFound += 1;
14525
+ try {
14526
+ ensureDir(repairBackupDir(projectDir));
14527
+ const backupPath = (0, node_path_1.join)(repairBackupDir(projectDir), `${(0, node_path_1.basename)(path)}.broken`);
14528
+ (0, node_fs_1.writeFileSync)(backupPath, raw, "utf8");
14529
+ const resolved = resolveConflictedPacket(raw);
14530
+ if (resolved) {
14531
+ writeJson(path, resolved);
14532
+ packetsTouched = true;
14533
+ actions.push({
14534
+ area: "packets",
14535
+ target,
14536
+ status: "fixed",
14537
+ detail: `merge conflict auto-resolved, kept newest side — original saved to ${(0, node_path_1.relative)(projectDir, backupPath)}`,
14538
+ });
14539
+ }
14540
+ else {
14541
+ (0, node_fs_1.unlinkSync)(path);
14542
+ packetsTouched = true;
14543
+ removedPackets.push(target);
14544
+ actions.push({
14545
+ area: "packets",
14546
+ target,
14547
+ status: "fixed",
14548
+ detail: `REMOVED unparseable packet — original preserved at ${(0, node_path_1.relative)(projectDir, backupPath)}; restore it by hand if it mattered`,
14549
+ });
14550
+ }
14551
+ }
14552
+ catch (error) {
14553
+ actions.push({ area: "packets", target, status: "failed", detail: error instanceof Error ? error.message : String(error) });
14554
+ }
14555
+ }
14556
+ }
14557
+ if (!brokenFound) {
14558
+ actions.push({ area: "packets", target: "memory packets", status: "skipped", detail: "all packet files parse cleanly" });
14559
+ }
14560
+ // 2. Stale lock/temp files left behind by crashed writers, plus a daemon
14561
+ // status file whose pid is no longer running.
14562
+ let lockFindings = 0;
14563
+ for (const path of walkFiles(memoryRoot(projectDir), (candidate) => candidate.endsWith(".tmp") || candidate.endsWith(".lock"))) {
14564
+ lockFindings += 1;
14565
+ try {
14566
+ (0, node_fs_1.unlinkSync)(path);
14567
+ actions.push({ area: "locks", target: (0, node_path_1.relative)(projectDir, path), status: "fixed", detail: "removed leftover temp/lock file" });
14568
+ }
14569
+ catch (error) {
14570
+ actions.push({ area: "locks", target: (0, node_path_1.relative)(projectDir, path), status: "failed", detail: error instanceof Error ? error.message : String(error) });
14571
+ }
14572
+ }
14573
+ const daemonStatusPath = (0, node_path_1.join)(daemonDir(projectDir), "status.json");
14574
+ if ((0, node_fs_1.existsSync)(daemonStatusPath)) {
14575
+ let stale = true;
14576
+ let pidLabel = "unknown";
14577
+ try {
14578
+ const status = readJson(daemonStatusPath);
14579
+ if (typeof status.pid === "number") {
14580
+ pidLabel = String(status.pid);
14581
+ try {
14582
+ process.kill(status.pid, 0);
14583
+ stale = false;
14584
+ }
14585
+ catch {
14586
+ stale = true;
14587
+ }
14588
+ }
14589
+ }
14590
+ catch {
14591
+ stale = true; // unreadable status file is stale by definition
14592
+ }
14593
+ if (stale) {
14594
+ lockFindings += 1;
14595
+ try {
14596
+ (0, node_fs_1.unlinkSync)(daemonStatusPath);
14597
+ actions.push({ area: "locks", target: (0, node_path_1.relative)(projectDir, daemonStatusPath), status: "fixed", detail: `removed stale daemon status (pid ${pidLabel} is not running)` });
14598
+ }
14599
+ catch (error) {
14600
+ actions.push({ area: "locks", target: (0, node_path_1.relative)(projectDir, daemonStatusPath), status: "failed", detail: error instanceof Error ? error.message : String(error) });
14601
+ }
14602
+ }
14603
+ else {
14604
+ lockFindings += 1;
14605
+ actions.push({ area: "locks", target: (0, node_path_1.relative)(projectDir, daemonStatusPath), status: "skipped", detail: `daemon pid ${pidLabel} is alive — left alone` });
14606
+ }
14607
+ }
14608
+ if (!lockFindings) {
14609
+ actions.push({ area: "locks", target: "lock/temp files", status: "skipped", detail: "no stale lock or temp files" });
14610
+ }
14611
+ // 3. Missing or stale indexes — rebuild. Packet surgery above also forces a
14612
+ // rebuild so the catalog never disagrees with what is on disk.
14613
+ const expectedIndexes = ["catalog.json", "by-path.json", "by-tag.json", "by-type.json", "vector-local.json", "graph.json", "code-graph.json"];
14614
+ const missingIndexes = expectedIndexes.filter((name) => !(0, node_fs_1.existsSync)((0, node_path_1.join)(indexesDir(projectDir), name)));
14615
+ let staleCatalog = false;
14616
+ const catalogPath = (0, node_path_1.join)(indexesDir(projectDir), "catalog.json");
14617
+ if ((0, node_fs_1.existsSync)(catalogPath)) {
14618
+ try {
14619
+ const catalog = readJson(catalogPath);
14620
+ staleCatalog = catalog.packet_count !== loadPacketsFromDir(packetsDir(projectDir)).length;
14621
+ }
14622
+ catch {
14623
+ staleCatalog = true;
14624
+ }
14625
+ }
14626
+ if (missingIndexes.length || staleCatalog || packetsTouched) {
14627
+ try {
14628
+ const rebuilt = indexProject(projectDir);
14629
+ const reason = missingIndexes.length
14630
+ ? `${missingIndexes.length} missing: ${missingIndexes.join(", ")}`
14631
+ : staleCatalog
14632
+ ? "catalog was out of date"
14633
+ : "packets changed during repair";
14634
+ actions.push({ area: "indexes", target: "indexes + graphs", status: "fixed", detail: `rebuilt ${rebuilt.indexes.length} indexes (${reason})` });
14635
+ }
14636
+ catch (error) {
14637
+ actions.push({ area: "indexes", target: "indexes + graphs", status: "failed", detail: error instanceof Error ? error.message : String(error) });
14638
+ }
14639
+ }
14640
+ else {
14641
+ actions.push({ area: "indexes", target: "indexes + graphs", status: "skipped", detail: "present and current" });
14642
+ }
14643
+ // 4. Agent wiring drift — re-run the write path ONLY for agents that are
14644
+ // already configured (config file exists) but whose hook scripts went
14645
+ // missing. Repair never wires new agents.
14646
+ try {
14647
+ const doctor = setupDoctor(projectDir, { homeDir: options.homeDir, serverPath: options.serverPath });
14648
+ let drifted = 0;
14649
+ for (const item of doctor) {
14650
+ // "Already configured" means the agent's config file exists AND already
14651
+ // mentions the Kage MCP server. A bare config (every Claude Code user
14652
+ // has ~/.claude.json) is NOT configured — repair never wires new agents.
14653
+ const configured = Boolean(item.config_path && (0, node_fs_1.existsSync)(item.config_path)) && configMentionsKage(item.config_path);
14654
+ if (!configured)
14655
+ continue;
14656
+ if (!item.hook_summary || item.hook_summary.ready)
14657
+ continue;
14658
+ drifted += 1;
14659
+ try {
14660
+ const rewired = setupAgent(item.agent, projectDir, { write: true, homeDir: options.homeDir, serverPath: options.serverPath });
14661
+ actions.push({
14662
+ area: "agents",
14663
+ target: item.agent,
14664
+ status: rewired.wrote ? "fixed" : "failed",
14665
+ detail: rewired.wrote
14666
+ ? `re-ran setup, restored missing hooks (${item.hook_summary.missing.join(", ")})`
14667
+ : `setup did not write — run: kage setup ${item.agent} --project . --write`,
14668
+ });
14669
+ }
14670
+ catch (error) {
14671
+ actions.push({ area: "agents", target: item.agent, status: "failed", detail: error instanceof Error ? error.message : String(error) });
14672
+ }
14673
+ }
14674
+ if (!drifted) {
14675
+ actions.push({ area: "agents", target: "agent wiring", status: "skipped", detail: "configured agents look intact" });
14676
+ }
14677
+ }
14678
+ catch (error) {
14679
+ actions.push({ area: "agents", target: "agent wiring", status: "failed", detail: error instanceof Error ? error.message : String(error) });
14680
+ }
14681
+ const validation = validateProject(projectDir);
14682
+ const fixed = actions.filter((action) => action.status === "fixed").length;
14683
+ const skipped = actions.filter((action) => action.status === "skipped").length;
14684
+ const failed = actions.filter((action) => action.status === "failed").length;
14685
+ return { project_dir: projectDir, ok: failed === 0, actions, fixed, skipped, failed, removed_packets: removedPackets, validation };
14686
+ }
14687
+ // Map a CLI failure to ONE copy-pasteable next command. Pure on purpose:
14688
+ // remediation must be unit-testable without throwing real errors.
14689
+ function remediationFor(error) {
14690
+ const text = error instanceof Error ? error.message : String(error);
14691
+ if (/ENOENT/i.test(text) && /\.agent_memory/.test(text))
14692
+ return "kage init --project .";
14693
+ if (/Unexpected token|Unexpected end of JSON|is not valid JSON|JSON\.parse|in JSON at position/i.test(text))
14694
+ return "kage repair --project .";
14695
+ if (/\bindex(es)?\b|\bgraph\b/i.test(text))
14696
+ return "kage index --project .";
14697
+ return "kage doctor --project .";
14698
+ }
14237
14699
  function approvePending(projectDir, id) {
14238
14700
  const pendingFiles = walkFiles(pendingDir(projectDir), (path) => path.endsWith(".json"));
14239
14701
  for (const path of pendingFiles) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kage-core/kage-graph-mcp",
3
- "version": "2.0.2",
3
+ "version": "2.1.0",
4
4
  "description": "Local-first repo memory, code graph, and recall MCP server for coding agents",
5
5
  "main": "dist/index.js",
6
6
  "files": [
package/viewer/console.js CHANGED
@@ -780,4 +780,65 @@
780
780
  fitView();
781
781
  cancelAnimationFrame(G.raf); tick();
782
782
  }
783
+
784
+ // ---- live feed (SSE from the local viewer daemon) ----
785
+ // Connects to /kage/events when this page is served by `kage viewer`. The
786
+ // hosted demo on GitHub Pages has no daemon: the stream never opens, so the
787
+ // panel stays hidden and the dashboard is otherwise unchanged.
788
+ var LIVE_CAP = 30;
789
+ var LIVE_LABEL = {
790
+ packet_written: ["+", "Memory written"],
791
+ packet_updated: ["✎", "Memory updated"],
792
+ recall_served: ["✓", "Recall served"],
793
+ stale_withheld: ["⊘", "Stale memory withheld"],
794
+ caller_answered: ["◆", "Graph answer served"],
795
+ stale_caught: ["⊘", "Stale memory caught"],
796
+ };
797
+ function liveRow(e) {
798
+ var kind = e.type === "value_event" ? ((e.event && e.event.kind) || "value_event") : e.type;
799
+ var meta = LIVE_LABEL[kind] || ["•", String(kind).replace(/_/g, " ")];
800
+ var row = el("div", "vev fresh " + kind);
801
+ row.appendChild(el("span", "vi", meta[0]));
802
+ var mid = el("div", "vt");
803
+ mid.appendChild(document.createTextNode(meta[1]));
804
+ if (kind === "recall_served" && e.event && e.event.tokens_saved > 0) {
805
+ mid.appendChild(document.createTextNode(" — saved "));
806
+ mid.appendChild(el("b", null, "~" + fmt(e.event.tokens_saved) + " tokens"));
807
+ } else if (e.title) {
808
+ mid.appendChild(document.createTextNode(" — "));
809
+ mid.appendChild(el("b", null, e.title));
810
+ }
811
+ row.appendChild(mid);
812
+ row.appendChild(el("span", "when", relTime(e.ts) || "just now"));
813
+ return row;
814
+ }
815
+ function initLive() {
816
+ if (typeof window.EventSource === "undefined") return;
817
+ var panel = document.getElementById("livePanel"), feed = document.getElementById("liveFeed");
818
+ if (!panel || !feed) return;
819
+ var es;
820
+ try { es = new EventSource("/kage/events"); } catch (err) { return; }
821
+ var opened = false;
822
+ es.onopen = function () {
823
+ opened = true;
824
+ panel.hidden = false;
825
+ if (!feed.childNodes.length) feed.appendChild(el("div", "empty", "Connected. New memories and value events stream in here live."));
826
+ };
827
+ es.onerror = function () {
828
+ // Never connected (no daemon behind this page): close and hide quietly.
829
+ // After a successful open, EventSource reconnects on its own — keep the panel.
830
+ if (!opened) { es.close(); panel.hidden = true; }
831
+ };
832
+ es.onmessage = function (msg) {
833
+ var e;
834
+ try { e = JSON.parse(msg.data); } catch (err) { return; }
835
+ if (!e || !e.type) return;
836
+ var placeholder = feed.querySelector(".empty");
837
+ if (placeholder) feed.removeChild(placeholder);
838
+ feed.className = "vfeed";
839
+ feed.insertBefore(liveRow(e), feed.firstChild);
840
+ while (feed.childNodes.length > LIVE_CAP) feed.removeChild(feed.lastChild);
841
+ };
842
+ }
843
+ initLive();
783
844
  })();
package/viewer/index.html CHANGED
@@ -95,6 +95,14 @@
95
95
  .receipt .r-line b.warn { color: var(--warn); } .receipt .r-line b.gain { color: var(--gain); }
96
96
  .receipt .r-foot { border-top: 1px dashed var(--line-strong); padding: 11px 24px; color: var(--faint); font: 400 11px/1.5 var(--mono); }
97
97
 
98
+ /* live feed (SSE from the local daemon; hidden on the hosted demo) */
99
+ .livedot { display: inline-block; width: 8px; height: 8px; border-radius: 99px; background: var(--gain); margin-right: 9px; animation: livepulse 2s ease infinite; }
100
+ @keyframes livepulse { 0%, 100% { box-shadow: 0 0 0 0 var(--gain-soft); } 50% { box-shadow: 0 0 0 6px var(--gain-soft); } }
101
+ .vev.fresh { animation: levin 1.2s ease; }
102
+ @keyframes levin { from { background: var(--gain-soft); } to { background: transparent; } }
103
+ .vev.packet_written .vi, .vev.packet_updated .vi { color: var(--memory); }
104
+ .vev.stale_caught .vi { color: var(--warn); }
105
+
98
106
  /* gains timeline */
99
107
  .vfeed { display: grid; }
100
108
  .vev { display: grid; grid-template-columns: 20px 1fr auto; gap: 0 13px; align-items: baseline; padding: 11px 4px; border-top: 1px solid var(--line); }
@@ -298,6 +306,7 @@
298
306
  <section class="section active" id="section-gains">
299
307
  <div class="receipt" id="gainsHero"></div>
300
308
  <div class="tiles" id="gainsTiles"></div>
309
+ <div class="panel" id="livePanel" hidden><h2><span class="livedot" aria-hidden="true"></span>Live <span class="sub">— memories and value events as they happen</span></h2><div id="liveFeed"></div></div>
301
310
  <div class="panel"><h2>Value timeline <span class="sub">— every saved recall and withheld stale memory, as it happened</span></h2><div id="gainsTimeline"></div></div>
302
311
  </section>
303
312
 
@@ -361,6 +370,6 @@
361
370
  </div>
362
371
  <div class="drawer-backdrop" id="detailBackdrop"></div>
363
372
  <aside class="drawer" id="detail" aria-hidden="true"></aside>
364
- <script src="./console.js?v=16"></script>
373
+ <script src="./console.js?v=17"></script>
365
374
  </body>
366
375
  </html>