@llmnesia/mcp 0.2.11 → 0.2.12

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.
@@ -19,11 +19,13 @@
19
19
  // without ~/.llmnesia/config.json this host is exactly as read-only as it
20
20
  // has always been for plain session indexing.
21
21
  //
22
- // Protocol v4 (extension → host). v1 was Claude-Code-only: `list` carried no
22
+ // Protocol v5 (extension → host). v1 was Claude-Code-only: `list` carried no
23
23
  // `source` and `get` took a bare sessionId. Both remain accepted, so an
24
24
  // extension older than this host keeps working; an extension NEWER than the
25
25
  // host detects v1 from the pong and syncs Claude Code only.
26
26
  //
27
+ // v5 adds per-source listing health and putSourceHealth metadata updates,
28
+ // keeping diagnostics separate from the conversation inbox.
27
29
  // v4 adds ZCode to `list`/`get` (the first source backed by a SQLite database
28
30
  // rather than per-session files; `get` materializes the transcript as JSONL).
29
31
  // The version is informational in both directions, as below: a v3 host never
@@ -34,7 +36,7 @@
34
36
  // treats that as "nothing to fetch", so the version is informational: hosts are
35
37
  // installed out of band and an extension upgraded ahead of its host is the
36
38
  // ordinary case, not a fault.
37
- // { cmd: "ping" } → { type: "pong", version: 4 }
39
+ // { cmd: "ping" } → { type: "pong", version: 5 }
38
40
  // { cmd: "list" } → { type: "list", sessions:
39
41
  // [{source, sessionId, mtime, project?, title?}] }
40
42
  // { cmd: "get", source, sessionId } → one or more { type: "chunk", sessionId, seq, total, data }
@@ -59,10 +61,10 @@ import { chmod, mkdir, readdir, readFile, rename, stat, writeFile } from "node:f
59
61
  import { homedir } from "node:os";
60
62
  import { join } from "node:path";
61
63
 
62
- // 4 adds ZCode (see the protocol comment above). Do not make it a gate: hosts
64
+ // 5 adds per-source listing health (see the protocol comment above). Do not make it a gate: hosts
63
65
  // are installed out of band and a version check would strand every user who
64
66
  // upgraded the extension first.
65
- const PROTOCOL_VERSION = 4;
67
+ const PROTOCOL_VERSION = 5;
66
68
  const PROJECTS_DIR = join(homedir(), ".claude", "projects");
67
69
  // ZCode keeps every session in one SQLite database (WAL mode) — the first
68
70
  // source with no per-session file. The host opens it READ-ONLY and materializes
@@ -134,12 +136,13 @@ function send(message) {
134
136
  // One entry per session: { source, sessionId, mtime, project?, title?, path }.
135
137
  // `path` is stripped before anything goes on the wire.
136
138
 
137
- async function listClaudeCodeSessions() {
139
+ async function listClaudeCodeSessions(issues) {
138
140
  const sessions = [];
139
141
  let projectDirs;
140
142
  try {
141
143
  projectDirs = await readdir(PROJECTS_DIR, { withFileTypes: true });
142
144
  } catch {
145
+ if (issues) issues.failed += 1;
143
146
  return sessions; // no ~/.claude/projects yet
144
147
  }
145
148
  for (const entry of projectDirs) {
@@ -151,6 +154,7 @@ async function listClaudeCodeSessions() {
151
154
  try {
152
155
  files = await readdir(dir);
153
156
  } catch {
157
+ if (issues) issues.failed += 1;
154
158
  continue;
155
159
  }
156
160
  for (const file of files) {
@@ -168,6 +172,7 @@ async function listClaudeCodeSessions() {
168
172
  path
169
173
  });
170
174
  } catch {
175
+ if (issues) issues.failed += 1;
171
176
  // Skip unreadable files.
172
177
  }
173
178
  }
@@ -186,18 +191,19 @@ function codexSessionId(filename) {
186
191
  return match ? match[1].toLowerCase() : base;
187
192
  }
188
193
 
189
- async function walkCodexDir(dir, depth, out) {
194
+ async function walkCodexDir(dir, depth, out, issues) {
190
195
  let entries;
191
196
  try {
192
197
  entries = await readdir(dir, { withFileTypes: true });
193
198
  } catch {
199
+ if (issues) issues.failed += 1;
194
200
  return out; // missing or unreadable — nothing to add
195
201
  }
196
202
  for (const entry of entries) {
197
203
  const path = join(dir, entry.name);
198
204
  if (entry.isDirectory()) {
199
205
  if (depth > 0) {
200
- await walkCodexDir(path, depth - 1, out);
206
+ await walkCodexDir(path, depth - 1, out, issues);
201
207
  }
202
208
  continue;
203
209
  }
@@ -213,6 +219,7 @@ async function walkCodexDir(dir, depth, out) {
213
219
  path
214
220
  });
215
221
  } catch {
222
+ if (issues) issues.failed += 1;
216
223
  // Skip unreadable files.
217
224
  }
218
225
  }
@@ -249,8 +256,8 @@ async function readCodexThreadNames(codexHome) {
249
256
  return titles;
250
257
  }
251
258
 
252
- async function listCodexSessions(codexHome) {
253
- const sessions = await walkCodexDir(join(codexHome, "sessions"), CODEX_MAX_DEPTH, []);
259
+ async function listCodexSessions(codexHome, issues) {
260
+ const sessions = await walkCodexDir(join(codexHome, "sessions"), CODEX_MAX_DEPTH, [], issues);
254
261
  if (sessions.length === 0) {
255
262
  return sessions;
256
263
  }
@@ -299,9 +306,10 @@ async function openZcodeDb() {
299
306
  * title ("New Session"); only real titles are attached, so the extension's
300
307
  * derived "<project> · <first prompt>" still wins when ZCode has no name.
301
308
  */
302
- async function listZcodeSessions() {
309
+ async function listZcodeSessions(issues) {
303
310
  const db = await openZcodeDb();
304
311
  if (!db) {
312
+ if (issues) issues.failed += 1;
305
313
  return [];
306
314
  }
307
315
  try {
@@ -331,6 +339,7 @@ async function listZcodeSessions() {
331
339
  }
332
340
  return sessions;
333
341
  } catch {
342
+ if (issues) issues.failed += 1;
334
343
  return []; // unreadable or mid-migration database — ZCode contributes nothing
335
344
  } finally {
336
345
  db.close();
@@ -467,6 +476,36 @@ async function listSessions() {
467
476
  return [...claude, ...codex, ...zcode];
468
477
  }
469
478
 
479
+ // A missing root and an unreadable source must not both appear as a healthy,
480
+ // empty scan. Paths and transcript data never enter these diagnostics.
481
+ async function listSessionsWithHealth() {
482
+ const config = await loadConfig();
483
+ const codexHome = config?.codexHome || DEFAULT_CODEX_HOME;
484
+ const sources = [
485
+ ["claude_code", PROJECTS_DIR, (issues) => listClaudeCodeSessions(issues)],
486
+ ["codex", join(codexHome, "sessions"), (issues) => listCodexSessions(codexHome, issues)],
487
+ ["zcode", ZCODE_DB, (issues) => listZcodeSessions(issues)]
488
+ ];
489
+ const sessions = [];
490
+ const sourceStatus = {};
491
+ for (const [source, path, list] of sources) {
492
+ try {
493
+ await stat(path);
494
+ } catch (error) {
495
+ sourceStatus[source] = { state: error?.code === "ENOENT" ? "missing" : "error" };
496
+ continue;
497
+ }
498
+ const issues = { failed: 0 };
499
+ try {
500
+ sessions.push(...await list(issues));
501
+ sourceStatus[source] = { state: issues.failed > 0 ? "partial" : "ok" };
502
+ } catch {
503
+ sourceStatus[source] = { state: "error" };
504
+ }
505
+ }
506
+ return { sessions, sourceStatus };
507
+ }
508
+
470
509
  async function loadConfig() {
471
510
  try {
472
511
  const raw = await readFile(CONFIG_FILE, "utf8");
@@ -648,6 +687,43 @@ async function readCorpusMemories(corpusRoot) {
648
687
  return { memories, skipped };
649
688
  }
650
689
 
690
+ // A separate metadata channel keeps health heartbeats out of the conversation
691
+ // inbox. Older MCP readers ignore this file and keep their true data age.
692
+ function sourceHealthRow(value) {
693
+ if (!value || typeof value !== "object") return null;
694
+ const { source, observedAt, connection, state, lastAttemptAt, lastSuccessfulSyncAt, lastCaptureAt, failedSessions } = value;
695
+ const time = (v) => Number.isSafeInteger(v) && v >= 0;
696
+ if (!["claude_code", "codex", "zcode"].includes(source) || !["connected", "disconnected"].includes(connection) ||
697
+ !["ok", "partial", "missing", "error", "unknown"].includes(state) || !time(observedAt) ||
698
+ observedAt > Date.now() + 300000 || !time(lastAttemptAt) || lastAttemptAt > observedAt ||
699
+ (lastSuccessfulSyncAt !== null && (!time(lastSuccessfulSyncAt) || lastSuccessfulSyncAt > observedAt)) ||
700
+ (lastCaptureAt !== null && (!time(lastCaptureAt) || lastCaptureAt > observedAt)) || !time(failedSessions)) return null;
701
+ return { source, observedAt, connection, state, lastAttemptAt, lastSuccessfulSyncAt, lastCaptureAt, failedSessions };
702
+ }
703
+
704
+ async function handleSourceHealth(message) {
705
+ const config = await loadConfig();
706
+ if (!config?.corpusRoot || !Array.isArray(message.rows) || message.rows.length > 3) {
707
+ send({ type: "error", error: "source health: invalid rows or no corpus configured" }); return;
708
+ }
709
+ const rows = message.rows.map(sourceHealthRow);
710
+ if (rows.some((row) => !row)) {
711
+ send({ type: "error", error: "source health: invalid report" }); return;
712
+ }
713
+ const path = join(config.corpusRoot, "source-health-v1.json");
714
+ let previous = [];
715
+ try { previous = JSON.parse(await readFile(path, "utf8")); } catch { /* First report. */ }
716
+ const current = new Map((Array.isArray(previous) ? previous : []).map(sourceHealthRow).filter(Boolean).map((row) => [row.source, row]));
717
+ for (const row of rows) {
718
+ if ((current.get(row.source)?.observedAt ?? -1) < row.observedAt) current.set(row.source, row);
719
+ }
720
+ await mkdir(config.corpusRoot, { recursive: true, mode: 0o700 });
721
+ const temp = `${path}.${process.pid}.tmp`;
722
+ await writeFile(temp, JSON.stringify([...current.values()]) + "\n", { mode: FILE_MODE });
723
+ await rename(temp, path);
724
+ send({ type: "sourceHealthAck" });
725
+ }
726
+
651
727
  async function handle(message) {
652
728
  if (!message || typeof message !== "object") {
653
729
  return;
@@ -662,7 +738,11 @@ async function handle(message) {
662
738
  send({ type: "corpusInfo", present: false });
663
739
  return;
664
740
  }
665
- send({ type: "corpusInfo", present: true, root: config.corpusRoot });
741
+ send({ type: "corpusInfo", present: true, root: config.corpusRoot, sourceHealthSupported: true });
742
+ return;
743
+ }
744
+ if (message.cmd === "putSourceHealth") {
745
+ await handleSourceHealth(message);
666
746
  return;
667
747
  }
668
748
  if (message.cmd === "putChunk") {
@@ -680,8 +760,8 @@ async function handle(message) {
680
760
  return;
681
761
  }
682
762
  if (message.cmd === "list") {
683
- const sessions = await listSessions();
684
- send({ type: "list", sessions: sessions.map(({ path, ...rest }) => rest) });
763
+ const { sessions, sourceStatus } = await listSessionsWithHealth();
764
+ send({ type: "list", sessions: sessions.map(({ path, ...rest }) => rest), sourceStatus });
685
765
  return;
686
766
  }
687
767
  if (message.cmd === "get") {
Binary file