@llmnesia/mcp 0.2.10 → 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.
package/README.md CHANGED
@@ -160,6 +160,15 @@ the canonical conversation and active index have accepted it. Malformed input
160
160
  is kept under `inbox/quarantine/` for inspection. Older releases archived every
161
161
  successful payload and may also have left versioned indexes behind.
162
162
 
163
+ Valid blank-text turns are retained with their message metadata. If an older
164
+ importer quarantined a backup containing those turns, close connected MCP
165
+ clients and run `llmnesia-mcp retry-quarantine`. The command validates each
166
+ entire file before retrying, updates the active index, and moves successfully
167
+ recovered originals into `inbox/recovered/` without deleting their contents.
168
+ Files with unresolved malformed records remain quarantined. The warning clears
169
+ on the next MCP read once no pending or quarantined files remain. This does not
170
+ require rebuilding local history.
171
+
163
172
  No maintenance command is required. After an upgrade, the MCP service waits
164
173
  through a three-day compatibility window, then reclaims the historical files
165
174
  automatically while the service is ready and idle. Work is single-process,
@@ -187,6 +196,7 @@ Once registered, your MCP client gains eight tools over the corpus:
187
196
  | Tool | Purpose |
188
197
  | --- | --- |
189
198
  | `search_conversations` | Full-text search across every past conversation. Returns ranked matches with highlighted snippets. Supports `platform`, `title`, `date_from`/`date_to` filters and a `match_mode` (`any` / `all` / `phrase`) for when a wrong hit matters. |
199
+ | `get_conversation_image` | Read a retained local raster image using the message and image index returned by `get_conversation`. No private provider URL fetching or OCR. |
190
200
  | `get_conversation` | Fetch the full transcript of one conversation by its id (the `id` field from search results). The response carries `contentFingerprint`, the current fingerprint of the returned transcript (see below). |
191
201
  | `list_conversations` | List conversations by chronology, including oldest-first for questions about the user's earliest chat. |
192
202
  | `list_recent` | List the most recently updated conversations with title, summary, and platform. |
@@ -214,7 +224,7 @@ items (`limit` can go lower):
214
224
  "items": [
215
225
  { "id": "claude:rust-borrow", "platform": "claude",
216
226
  "title": "Debugging a Rust borrow checker error",
217
- "fingerprint": "fp1:9af3c0d7…" }
227
+ "fingerprint": "fp2:9af3c0d7…" }
218
228
  ],
219
229
  "nextCursor": "inv1.eyJzIjoi…"
220
230
  }
@@ -229,12 +239,15 @@ Cursors are opaque, they do not survive an MCP server restart, and an expired
229
239
  or unknown cursor returns a clear error telling the caller to begin a new
230
240
  scan.
231
241
 
232
- The fingerprint is `fp1:` followed by a SHA-256 hex digest. It covers exactly:
242
+ Images are now included in the fingerprint. Clients comparing an older `fp1` inventory should refresh it once. Local raster copies are bounded to 2 MiB per image and 8 MiB per captured conversation. Vault sync retains URL metadata only. Protected or expired images without a retained copy need recapture or a supported bundled export.
243
+
244
+ The fingerprint is `fp2:` followed by a SHA-256 hex digest. It covers exactly:
233
245
 
234
246
  - the conversation title, and the user's local alias (`localTitle`) when set;
235
247
  - every message in `msgIndex` order: its index, role, stored `text`, and
236
- `formattedText` when present (because `get_conversation` prefers it, a
237
- backfill that adds it changes what a caller would receive).
248
+ `formattedText` when present;
249
+ - image references, metadata and retained local bytes. Image-only changes are
250
+ visible to an inventory client even when transcript text is unchanged.
238
251
 
239
252
  It deliberately excludes every timestamp, including the provider's
240
253
  `updatedAt`: historical imports can retain old source dates, and a genuinely
@@ -322,6 +335,7 @@ llmnesia-mcp uninstall # remove manifests, config, client entries
322
335
  llmnesia-mcp init # create an empty corpus
323
336
  llmnesia-mcp ingest <…> # ingest an NDJSON backup file
324
337
  llmnesia-mcp drain # ingest everything in <corpus>/inbox
338
+ llmnesia-mcp retry-quarantine # repair eligible files; retain original backups
325
339
  llmnesia-mcp watch # ingest new inbox deltas live (Ctrl+C to stop)
326
340
  llmnesia-mcp stats # corpus summary
327
341
  llmnesia-mcp compact --dry-run # exact read-only storage/savings report
@@ -3,7 +3,45 @@ import { createRequire as __llmnesiaCreateRequire } from "node:module";
3
3
  const require = __llmnesiaCreateRequire(import.meta.url);
4
4
  import {
5
5
  VERSION
6
- } from "./chunk-GLCLSBT6.js";
6
+ } from "./chunk-SEBISBN5.js";
7
+
8
+ // src/images.ts
9
+ var MAX_BYTES = 2 * 1024 * 1024;
10
+ function decodeImage(value) {
11
+ if (typeof value !== "string" || value.length > Math.ceil(MAX_BYTES * 4 / 3) + 80) return null;
12
+ const match = /^data:(image\/(?:png|jpeg|webp|gif));base64,([A-Za-z0-9+/]+={0,2})$/.exec(value);
13
+ if (!match || match[2].length % 4 !== 0) return null;
14
+ const bytes = Buffer.from(match[2], "base64");
15
+ if (!bytes.length || bytes.length > MAX_BYTES || bytes.toString("base64") !== match[2]) return null;
16
+ const head = bytes.toString("latin1", 0, 12);
17
+ const valid = match[1] === "image/png" ? head.startsWith("\x89PNG\r\n\n") : match[1] === "image/jpeg" ? head.startsWith("\xFF\xD8\xFF") : match[1] === "image/gif" ? /^GIF8[79]a/.test(head) : head.startsWith("RIFF") && head.slice(8, 12) === "WEBP";
18
+ if (!valid) return null;
19
+ return { mimeType: match[1], data: match[2] };
20
+ }
21
+ function normalizeImages(value) {
22
+ if (!Array.isArray(value)) return [];
23
+ return value.slice(0, 12).flatMap((raw) => {
24
+ if (!raw || typeof raw !== "object") return [];
25
+ const row = raw;
26
+ const dataUrl = decodeImage(row.dataUrl) ? row.dataUrl : void 0;
27
+ let url = "";
28
+ try {
29
+ if (typeof row.url === "string" && row.url.length <= 8e3) {
30
+ const parsed = new URL(row.url);
31
+ if (["http:", "https:"].includes(parsed.protocol)) url = parsed.href;
32
+ }
33
+ } catch {
34
+ }
35
+ if (!url && !dataUrl) return [];
36
+ return [{
37
+ url,
38
+ ...dataUrl ? { dataUrl } : {},
39
+ ...typeof row.alt === "string" ? { alt: row.alt.slice(0, 500) } : {},
40
+ ...typeof row.width === "number" && row.width > 0 && row.width <= 2e4 ? { width: row.width } : {},
41
+ ...typeof row.height === "number" && row.height > 0 && row.height <= 2e4 ? { height: row.height } : {}
42
+ }];
43
+ });
44
+ }
7
45
 
8
46
  // src/config.ts
9
47
  import { readFileSync } from "fs";
@@ -147,6 +185,31 @@ import { dirname, join as join3 } from "path";
147
185
  import { promisify } from "util";
148
186
  import { gzip as gzipCallback, gunzip as gunzipCallback, gunzipSync } from "zlib";
149
187
 
188
+ // src/sourceHealth.ts
189
+ function parseSourceHealth(value) {
190
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
191
+ const row = value;
192
+ if (!["claude_code", "codex", "zcode"].includes(String(row.source))) return null;
193
+ if (row.connection !== "connected" && row.connection !== "disconnected") return null;
194
+ if (!["ok", "partial", "missing", "error", "unknown"].includes(String(row.state))) return null;
195
+ const time = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
196
+ if (!time(row.observedAt) || !time(row.lastAttemptAt)) return null;
197
+ if (row.observedAt > Date.now() + 3e5 || row.lastAttemptAt > row.observedAt) return null;
198
+ if (row.lastSuccessfulSyncAt !== null && (!time(row.lastSuccessfulSyncAt) || row.lastSuccessfulSyncAt > row.observedAt)) return null;
199
+ if (row.lastCaptureAt !== null && (!time(row.lastCaptureAt) || row.lastCaptureAt > row.observedAt)) return null;
200
+ if (!time(row.failedSessions)) return null;
201
+ return {
202
+ source: row.source,
203
+ observedAt: row.observedAt,
204
+ connection: row.connection,
205
+ state: row.state,
206
+ lastAttemptAt: row.lastAttemptAt,
207
+ lastSuccessfulSyncAt: row.lastSuccessfulSyncAt,
208
+ lastCaptureAt: row.lastCaptureAt,
209
+ failedSessions: row.failedSessions
210
+ };
211
+ }
212
+
150
213
  // src/serialize.ts
151
214
  function canonicalizeConversation(record) {
152
215
  return {
@@ -244,6 +307,8 @@ function canonicalMessageDoc(m) {
244
307
  role: m.role,
245
308
  text: m.text
246
309
  };
310
+ const images = normalizeImages(m.images);
311
+ if (images.length) out.images = images;
247
312
  assignIfDefined(out, "formattedText", m.formattedText);
248
313
  assignIfDefined(out, "model", m.model);
249
314
  out.createdAt = m.createdAt;
@@ -403,7 +468,7 @@ var Corpus = class {
403
468
  return this.enqueueWrite(async () => {
404
469
  const canonical = canonicalizeMemory(memory);
405
470
  const file = this.paths.memoryFile(canonical.memoryId);
406
- const existing = await readJsonIfExists(file);
471
+ const existing = await readMemoryIfExists(file);
407
472
  if (existing) {
408
473
  if (existing.source !== "agent") {
409
474
  return { memoryId: canonical.memoryId, outcome: "refused" };
@@ -418,44 +483,48 @@ var Corpus = class {
418
483
  }
419
484
  /** Read a memory by memoryId, or null when it has never been written. */
420
485
  async getMemory(memoryId) {
421
- const record = await readJsonIfExists(this.paths.memoryFile(memoryId));
486
+ const record = await readMemoryIfExists(this.paths.memoryFile(memoryId));
422
487
  return record && record.memoryId === memoryId ? record : null;
423
488
  }
424
489
  /** Async-iterate every stored memory (used by the index rebuild). */
425
- async *iterateMemories() {
490
+ async *iterateMemories(options = {}) {
426
491
  let files;
427
492
  try {
428
493
  files = await fs.readdir(this.paths.memoriesDir);
429
- } catch {
494
+ } catch (error) {
495
+ if (options.strict) throw error;
430
496
  return;
431
497
  }
432
498
  for (const name of files.sort()) {
433
499
  if (!name.endsWith(".json")) {
434
500
  continue;
435
501
  }
436
- const record = await readJsonIfExists(join3(this.paths.memoriesDir, name));
502
+ const file = join3(this.paths.memoriesDir, name);
503
+ const record = options.strict ? await readRequiredJson(file, isMemoryRecord) : await readJsonIfExists(file);
437
504
  if (record?.memoryId) {
438
505
  yield record;
439
506
  }
440
507
  }
441
508
  }
442
509
  /** Platform subdirectory names under conversations/. */
443
- async listPlatforms() {
510
+ async listPlatforms(options = {}) {
444
511
  try {
445
512
  const entries = await fs.readdir(this.paths.conversationsDir, { withFileTypes: true });
446
513
  return entries.filter((e) => e.isDirectory()).map((e) => e.name);
447
- } catch {
514
+ } catch (error) {
515
+ if (options.strict) throw error;
448
516
  return [];
449
517
  }
450
518
  }
451
519
  /** Async-iterate every stored conversation (used by reindex + stats). */
452
- async *iterate() {
453
- for (const platform of await this.listPlatforms()) {
520
+ async *iterate(options = {}) {
521
+ for (const platform of await this.listPlatforms(options)) {
454
522
  const dir = this.paths.platformDir(platform);
455
523
  let files;
456
524
  try {
457
525
  files = await fs.readdir(dir);
458
- } catch {
526
+ } catch (error) {
527
+ if (options.strict) throw error;
459
528
  continue;
460
529
  }
461
530
  const stems = /* @__PURE__ */ new Set();
@@ -466,7 +535,8 @@ var Corpus = class {
466
535
  for (const stem of [...stems].sort()) {
467
536
  const record = await readBestConversationFiles(
468
537
  join3(dir, `${stem}.json.gz`),
469
- join3(dir, `${stem}.json`)
538
+ join3(dir, `${stem}.json`),
539
+ options.strict
470
540
  );
471
541
  if (record) {
472
542
  yield record;
@@ -581,14 +651,46 @@ var Corpus = class {
581
651
  return { conversationCount, messageCount, byPlatform };
582
652
  }
583
653
  /** Refresh meta.json's conversationCount / lastIngestAt after an ingest run. */
584
- async touchIngestMeta(receipt) {
654
+ async touchIngestMeta(receipt, sources = {}) {
585
655
  const { conversationCount } = await this.stats();
656
+ const previous = (await this.readMeta())?.sourceLastIngestAt ?? {};
657
+ const sourceLastIngestAt = { ...previous };
658
+ for (const [source, at] of Object.entries(sources)) {
659
+ sourceLastIngestAt[source] = Math.max(previous[source] ?? 0, at);
660
+ }
586
661
  await this.writeMeta({
587
662
  lastIngestAt: Date.now(),
588
663
  conversationCount,
664
+ sourceLastIngestAt,
589
665
  ...receipt ? { lastIngestReceipt: receipt } : {}
590
666
  });
591
667
  }
668
+ async readSourceHealth() {
669
+ try {
670
+ const rows = JSON.parse(await fs.readFile(join3(this.paths.root, "source-health-v1.json"), "utf8"));
671
+ return Array.isArray(rows) ? rows.flatMap((value) => {
672
+ const parsed = parseSourceHealth(value);
673
+ return parsed ? [parsed] : [];
674
+ }) : [];
675
+ } catch {
676
+ return [];
677
+ }
678
+ }
679
+ async recordSourceHealth(value) {
680
+ const row = parseSourceHealth(value);
681
+ if (!row) return false;
682
+ await this.enqueueWrite(async () => {
683
+ const current = new Map((await this.readSourceHealth()).map((entry) => [entry.source, entry]));
684
+ if ((current.get(row.source)?.observedAt ?? -1) >= row.observedAt) return;
685
+ current.set(row.source, row);
686
+ await writeFileAtomic(
687
+ join3(this.paths.root, "source-health-v1.json"),
688
+ `${JSON.stringify([...current.values()])}
689
+ `
690
+ );
691
+ });
692
+ return true;
693
+ }
592
694
  enqueueWrite(fn) {
593
695
  const run = this.writeChain.then(fn, fn);
594
696
  this.writeChain = run.then(
@@ -629,6 +731,31 @@ async function readJsonIfExists(file) {
629
731
  return null;
630
732
  }
631
733
  }
734
+ function isConversationRecord(value) {
735
+ if (!value || typeof value !== "object") return false;
736
+ const record = value;
737
+ const c = record.conversation;
738
+ return !!c && typeof c.docId === "string" && !!c.docId && typeof c.platform === "string" && typeof c.title === "string" && typeof c.url === "string" && Number.isFinite(c.createdAt) && Array.isArray(record.messages) && record.messages.every((m) => m && typeof m.text === "string" && typeof m.role === "string" && Number.isFinite(m.msgIndex));
739
+ }
740
+ function isMemoryRecord(value) {
741
+ if (!value || typeof value !== "object") return false;
742
+ const m = value;
743
+ return typeof m.memoryId === "string" && !!m.memoryId && typeof m.kind === "string" && typeof m.label === "string" && typeof m.text === "string" && typeof m.source === "string" && typeof m.enabled === "boolean" && typeof m.pinned === "boolean" && Number.isFinite(m.createdAt) && Number.isFinite(m.updatedAt);
744
+ }
745
+ async function readRequiredJson(file, validate, compressed = false) {
746
+ const raw = await fs.readFile(file);
747
+ const value = JSON.parse((compressed ? await gunzip(raw) : raw).toString("utf8"));
748
+ if (!validate(value)) throw new Error(`Invalid canonical record at ${file}`);
749
+ return value;
750
+ }
751
+ async function readMemoryIfExists(file) {
752
+ try {
753
+ return await readRequiredJson(file, isMemoryRecord);
754
+ } catch (error) {
755
+ if (error.code === "ENOENT") return null;
756
+ throw error;
757
+ }
758
+ }
632
759
  function serializeConversation(record) {
633
760
  return `${JSON.stringify(canonicalizeConversation(record), null, 2)}
634
761
  `;
@@ -644,7 +771,19 @@ async function readCompressedConversationIfExists(file) {
644
771
  return null;
645
772
  }
646
773
  }
647
- async function readBestConversationFiles(compressedFile, legacyFile) {
774
+ async function readBestConversationFiles(compressedFile, legacyFile, strict = false) {
775
+ if (strict) {
776
+ const copies = await Promise.allSettled([
777
+ readRequiredJson(compressedFile, isConversationRecord, true),
778
+ readRequiredJson(legacyFile, isConversationRecord)
779
+ ]);
780
+ const valid = copies.flatMap((result) => result.status === "fulfilled" ? [result.value] : []);
781
+ if (valid.length > 0) return pickNewerConversation(valid[0], valid[1] ?? null);
782
+ throw new Error(
783
+ `Cannot rebuild the index: no readable conversation at ${compressedFile} or ${legacyFile}`,
784
+ { cause: copies.find((result) => result.status === "rejected") }
785
+ );
786
+ }
648
787
  const [compressed, legacy] = await Promise.all([
649
788
  readCompressedConversationIfExists(compressedFile),
650
789
  readPlainConversationIfExists(legacyFile)
@@ -665,6 +804,8 @@ function readBestConversationFilesSync(compressedFile, legacyFile) {
665
804
  return pickNewerConversation(compressed, legacy);
666
805
  }
667
806
  function pickNewerConversation(a, b) {
807
+ if (!isConversationRecord(a)) a = null;
808
+ if (!isConversationRecord(b)) b = null;
668
809
  if (!a) return b;
669
810
  if (!b) return a;
670
811
  const aChanged = a.conversation.corpusChangedAt ?? a.conversation.updatedAt ?? 0;
@@ -735,11 +876,15 @@ async function upsertIndexedConversation(corpus, record, index) {
735
876
  if (!saved) {
736
877
  throw new Error(`Saved conversation '${result.docId}' could not be read for indexing`);
737
878
  }
738
- index.upsertConversation(saved);
879
+ index.upsertConversation(saved, () => {
880
+ const current = corpus.getFromPlatformSync(record.conversation.platform, result.docId);
881
+ if (!current) throw new Error(`Saved conversation '${result.docId}' could not be read for indexing`);
882
+ return current;
883
+ });
739
884
  }
740
885
  return result;
741
886
  }
742
- async function ingestFile(corpus, file, index) {
887
+ async function ingestFile(corpus, file, index, options = {}) {
743
888
  const report = { ...EMPTY_REPORT };
744
889
  let current = null;
745
890
  let buffered = [];
@@ -748,7 +893,11 @@ async function ingestFile(corpus, file, index) {
748
893
  return;
749
894
  }
750
895
  const record = { conversation: current, messages: buffered };
751
- const result = await upsertIndexedConversation(corpus, record, index);
896
+ const result = options.validateOnly ? { docId: current.docId, outcome: "skipped" } : await upsertIndexedConversation(corpus, record, index);
897
+ if (result.outcome !== "skipped") {
898
+ report.sourceLastIngestAt ??= {};
899
+ report.sourceLastIngestAt[record.conversation.platform] = Date.now();
900
+ }
752
901
  tally(report, result);
753
902
  report.conversations += 1;
754
903
  report.messages += buffered.length;
@@ -777,6 +926,12 @@ async function ingestFile(corpus, file, index) {
777
926
  if (type === "meta") {
778
927
  continue;
779
928
  }
929
+ if (type === "source_health") {
930
+ await flush();
931
+ const valid = options.validateOnly ? Boolean(parseSourceHealth(record.health)) : await corpus.recordSourceHealth(record.health);
932
+ if (!valid) report.malformedLines += 1;
933
+ continue;
934
+ }
780
935
  if (type === "conversation") {
781
936
  await flush();
782
937
  current = coerceConversation(record.conversation);
@@ -798,7 +953,9 @@ async function ingestFile(corpus, file, index) {
798
953
  } else {
799
954
  report.malformedLines += 1;
800
955
  }
956
+ continue;
801
957
  }
958
+ report.malformedLines += 1;
802
959
  }
803
960
  await flush();
804
961
  return report;
@@ -806,9 +963,34 @@ async function ingestFile(corpus, file, index) {
806
963
  async function pendingInboxFiles(corpus) {
807
964
  try {
808
965
  return (await fs2.readdir(corpus.paths.inboxDir, { withFileTypes: true })).filter((e) => e.isFile() && isNdjson(e.name)).map((e) => e.name).sort();
809
- } catch {
810
- return [];
966
+ } catch (error) {
967
+ if (error.code === "ENOENT") return [];
968
+ throw error;
969
+ }
970
+ }
971
+ async function retryQuarantine(corpus, index) {
972
+ await corpus.init();
973
+ const entries = (await fs2.readdir(corpus.paths.quarantineDir, { withFileTypes: true })).filter((entry) => entry.isFile() && isNdjson(entry.name)).map((entry) => entry.name).sort();
974
+ const result = { files: entries.length, recovered: 0, remaining: 0, failed: 0, archiveDir: null };
975
+ for (const name of entries) {
976
+ const src = join4(corpus.paths.quarantineDir, name);
977
+ try {
978
+ if ((await ingestFile(corpus, src, void 0, { validateOnly: true })).malformedLines > 0) continue;
979
+ const report = await ingestFile(corpus, src, index);
980
+ if (report.malformedLines > 0) continue;
981
+ if (!result.archiveDir) {
982
+ const recoveredDir = join4(corpus.paths.inboxDir, "recovered");
983
+ await fs2.mkdir(recoveredDir, { recursive: true, mode: 448 });
984
+ result.archiveDir = await fs2.mkdtemp(join4(recoveredDir, "quarantine-"));
985
+ }
986
+ await fs2.rename(src, join4(result.archiveDir, name));
987
+ result.recovered += 1;
988
+ } catch {
989
+ result.failed += 1;
990
+ }
811
991
  }
992
+ result.remaining = (await fs2.readdir(corpus.paths.quarantineDir, { withFileTypes: true })).filter((entry) => entry.isFile()).length;
993
+ return result;
812
994
  }
813
995
  async function drainInbox(corpus, index, options = {}) {
814
996
  await corpus.init();
@@ -845,7 +1027,7 @@ async function drainInbox(corpus, index, options = {}) {
845
1027
  }
846
1028
  }
847
1029
  }
848
- if (total.files > 0) {
1030
+ if (total.conversations > 0) {
849
1031
  await corpus.touchIngestMeta({
850
1032
  files: total.files,
851
1033
  conversations: total.conversations,
@@ -853,7 +1035,7 @@ async function drainInbox(corpus, index, options = {}) {
853
1035
  malformedLines: total.malformedLines,
854
1036
  discarded: total.discarded,
855
1037
  quarantined: total.quarantined
856
- });
1038
+ }, total.sourceLastIngestAt);
857
1039
  }
858
1040
  return total;
859
1041
  }
@@ -898,12 +1080,12 @@ function coerceConversation(value) {
898
1080
  return null;
899
1081
  }
900
1082
  const c = value;
901
- if (typeof c.docId !== "string" || !c.docId) {
1083
+ if (typeof c.docId !== "string" || !c.docId || typeof c.platform !== "string" || !c.platform || typeof c.title !== "string" || typeof c.url !== "string" || typeof c.createdAt !== "number" || !Number.isFinite(c.createdAt)) {
902
1084
  return null;
903
1085
  }
904
1086
  const conversation = { ...c };
905
- if (typeof conversation.updatedAt !== "number") {
906
- conversation.updatedAt = typeof conversation.contentUpdatedAt === "number" ? conversation.contentUpdatedAt : 0;
1087
+ if (typeof conversation.updatedAt !== "number" || !Number.isFinite(conversation.updatedAt)) {
1088
+ conversation.updatedAt = typeof conversation.contentUpdatedAt === "number" && Number.isFinite(conversation.contentUpdatedAt) ? conversation.contentUpdatedAt : 0;
907
1089
  }
908
1090
  return conversation;
909
1091
  }
@@ -912,11 +1094,12 @@ function coerceMessage(docId, value) {
912
1094
  return null;
913
1095
  }
914
1096
  const m = value;
915
- const text = typeof m.text === "string" ? m.text : "";
916
- if (!text.trim()) {
917
- return null;
918
- }
919
- const msgIndex = typeof m.msgIndex === "number" ? m.msgIndex : 0;
1097
+ if (!Number.isSafeInteger(m.msgIndex) || m.msgIndex < 0) return null;
1098
+ const formattedText = typeof m.formattedText === "string" ? m.formattedText : "";
1099
+ const text = typeof m.text === "string" && m.text.trim() ? m.text : formattedText.trim() ? formattedText : typeof m.text === "string" ? m.text : "";
1100
+ const images = normalizeImages(m.images);
1101
+ if (typeof m.text !== "string" && !formattedText.trim() && !images.length) return null;
1102
+ const msgIndex = m.msgIndex;
920
1103
  const message = {
921
1104
  // The backup line omits msgKey; reconstruct the extension's convention.
922
1105
  msgKey: `${docId}:${msgIndex}`,
@@ -924,6 +1107,7 @@ function coerceMessage(docId, value) {
924
1107
  msgIndex,
925
1108
  role: coerceRole(m.role),
926
1109
  text,
1110
+ ...images.length ? { images } : {},
927
1111
  createdAt: typeof m.createdAt === "number" ? m.createdAt : 0,
928
1112
  updatedAt: typeof m.updatedAt === "number" ? m.updatedAt : 0,
929
1113
  contentHash: typeof m.contentHash === "string" ? m.contentHash : ""
@@ -954,6 +1138,10 @@ function tally(report, result) {
954
1138
  }
955
1139
  }
956
1140
  function accumulate(total, report) {
1141
+ for (const [source, at] of Object.entries(report.sourceLastIngestAt ?? {})) {
1142
+ total.sourceLastIngestAt ??= {};
1143
+ total.sourceLastIngestAt[source] = Math.max(total.sourceLastIngestAt[source] ?? 0, at);
1144
+ }
957
1145
  total.conversations += report.conversations;
958
1146
  total.created += report.created;
959
1147
  total.updated += report.updated;
@@ -964,18 +1152,19 @@ function accumulate(total, report) {
964
1152
 
965
1153
  // src/fingerprint.ts
966
1154
  import { createHash as createHash2 } from "crypto";
967
- var FINGERPRINT_SCHEME = "fp1";
1155
+ var FINGERPRINT_SCHEME = "fp2";
968
1156
  function fingerprintConversation(record) {
969
1157
  const conversation = record.conversation;
970
1158
  const payload = {
971
- v: 1,
1159
+ v: 2,
972
1160
  title: conversation.title,
973
1161
  localTitle: conversation.localTitle ?? null,
974
1162
  messages: [...record.messages].sort((a, b) => a.msgIndex - b.msgIndex).map((message) => ({
975
1163
  i: message.msgIndex,
976
1164
  r: message.role,
977
1165
  t: message.text,
978
- f: message.formattedText ?? null
1166
+ f: message.formattedText ?? null,
1167
+ images: message.images ?? []
979
1168
  }))
980
1169
  };
981
1170
  const digest = createHash2("sha256").update(JSON.stringify(payload), "utf8").digest("hex");
@@ -1156,8 +1345,9 @@ var SearchIndex = class {
1156
1345
  FROM temp.snippet_fts WHERE snippet_fts MATCH ?`
1157
1346
  )
1158
1347
  };
1159
- this.stmts.selectFtsRows.setReadBigInts(true);
1160
- this.stmts.insertMessageRow.setReadBigInts(true);
1348
+ for (const statement of Object.values(this.stmts)) {
1349
+ statement.setReadBigInts(true);
1350
+ }
1161
1351
  }
1162
1352
  /** True when the stored format version was missing or stale — caller rebuilds. */
1163
1353
  needsRebuild() {
@@ -1168,6 +1358,10 @@ var SearchIndex = class {
1168
1358
  const row = this.db.prepare("SELECT COUNT(*) AS n FROM conversations").get();
1169
1359
  return row.n;
1170
1360
  }
1361
+ sourceSummary() {
1362
+ return this.db.prepare(`SELECT platform AS source, COUNT(*) AS conversationCount,
1363
+ MAX(updatedAt) AS newestConversationAt FROM conversations GROUP BY platform`).all();
1364
+ }
1171
1365
  /** Number of indexed memories. */
1172
1366
  countMemories() {
1173
1367
  const row = this.db.prepare("SELECT COUNT(*) AS n FROM memories").get();
@@ -1218,7 +1412,7 @@ var SearchIndex = class {
1218
1412
  * because its body is async and cannot be expressed as a sync callback.
1219
1413
  */
1220
1414
  transaction(fn) {
1221
- this.db.exec("BEGIN");
1415
+ this.db.exec("BEGIN IMMEDIATE");
1222
1416
  try {
1223
1417
  const result = fn();
1224
1418
  this.db.exec("COMMIT");
@@ -1228,8 +1422,8 @@ var SearchIndex = class {
1228
1422
  throw err;
1229
1423
  }
1230
1424
  }
1231
- upsertConversation(record) {
1232
- this.transaction(() => this.writeRecord(record));
1425
+ upsertConversation(record, readCurrent) {
1426
+ this.transaction(() => this.writeRecord(readCurrent ? readCurrent() : record));
1233
1427
  }
1234
1428
  upsertMemory(memory) {
1235
1429
  this.transaction(() => this.writeMemory(memory));
@@ -1246,18 +1440,17 @@ var SearchIndex = class {
1246
1440
  * reads, which the sync {@link transaction} helper cannot hold.
1247
1441
  */
1248
1442
  async rebuildFrom(corpus) {
1249
- this.corpus = corpus;
1250
- this.db.exec("DELETE FROM messages_fts; DELETE FROM message_rows; DELETE FROM conversations;");
1251
- this.db.exec("DELETE FROM memories_fts; DELETE FROM memories;");
1252
1443
  let conversations = 0;
1253
1444
  let memories = 0;
1254
- this.db.exec("BEGIN");
1445
+ this.db.exec("BEGIN IMMEDIATE");
1255
1446
  try {
1256
- for await (const record of corpus.iterate()) {
1447
+ this.db.exec("DELETE FROM messages_fts; DELETE FROM message_rows; DELETE FROM conversations;");
1448
+ this.db.exec("DELETE FROM memories_fts; DELETE FROM memories;");
1449
+ for await (const record of corpus.iterate({ strict: true })) {
1257
1450
  this.writeRecord(record);
1258
1451
  conversations += 1;
1259
1452
  }
1260
- for await (const memory of corpus.iterateMemories()) {
1453
+ for await (const memory of corpus.iterateMemories({ strict: true })) {
1261
1454
  this.writeMemory(memory);
1262
1455
  memories += 1;
1263
1456
  }
@@ -1266,6 +1459,7 @@ var SearchIndex = class {
1266
1459
  this.db.exec("ROLLBACK");
1267
1460
  throw err;
1268
1461
  }
1462
+ this.corpus = corpus;
1269
1463
  return { conversations, memories };
1270
1464
  }
1271
1465
  // Replace a conversation's index rows. Caller supplies the transaction (the
@@ -1372,7 +1566,6 @@ var SearchIndex = class {
1372
1566
  clauses.push("json_extract(c.providerWorkspace, '$.workspaceId') = @providerWorkspaceId");
1373
1567
  params.providerWorkspaceId = filters.providerWorkspaceId;
1374
1568
  }
1375
- const scanCap = Math.min(2e3, Math.max(200, limit * 40));
1376
1569
  const sql = `
1377
1570
  SELECT
1378
1571
  c.docId, c.platform, c.title, c.url, c.createdAt, c.updatedAt,
@@ -1385,16 +1578,18 @@ var SearchIndex = class {
1385
1578
  JOIN conversations c ON c.docId = m.docId
1386
1579
  WHERE ${clauses.join(" AND ")}
1387
1580
  ORDER BY bm25
1388
- LIMIT @scanCap
1389
1581
  `;
1390
- const rows = this.db.prepare(sql).all({ ...params, scanCap });
1391
1582
  const seen = /* @__PURE__ */ new Set();
1583
+ const rows = [];
1584
+ for (const value of this.db.prepare(sql).iterate(params)) {
1585
+ const row = value;
1586
+ if (seen.has(row.docId)) continue;
1587
+ seen.add(row.docId);
1588
+ rows.push(row);
1589
+ if (rows.length >= limit) break;
1590
+ }
1392
1591
  const hits = [];
1393
1592
  for (const row of rows) {
1394
- if (seen.has(row.docId)) {
1395
- continue;
1396
- }
1397
- seen.add(row.docId);
1398
1593
  const hit = {
1399
1594
  docId: row.docId,
1400
1595
  platform: row.platform,
@@ -1457,16 +1652,25 @@ var SearchIndex = class {
1457
1652
  return row ? row.value : null;
1458
1653
  }
1459
1654
  setMeta(key, value) {
1460
- this.db.prepare("INSERT INTO index_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(key, value);
1655
+ const statement = this.db.prepare(
1656
+ "INSERT INTO index_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value"
1657
+ );
1658
+ statement.setReadBigInts(true);
1659
+ statement.run(key, value);
1461
1660
  }
1462
1661
  };
1463
1662
  async function openSearchIndex(corpus, options = {}) {
1464
1663
  await corpus.init();
1465
1664
  const index = new SearchIndex(corpus.paths.indexDbFile, corpus);
1466
- if (options.autoBuild !== false && (index.needsRebuild() || index.count() === 0 && index.countMemories() === 0)) {
1467
- await index.rebuildFrom(corpus);
1665
+ try {
1666
+ if (options.autoBuild !== false && (index.needsRebuild() || index.count() === 0 && index.countMemories() === 0)) {
1667
+ await index.rebuildFrom(corpus);
1668
+ }
1669
+ return index;
1670
+ } catch (error) {
1671
+ index.close();
1672
+ throw error;
1468
1673
  }
1469
- return index;
1470
1674
  }
1471
1675
  async function deleteIndexDb(dbFile) {
1472
1676
  for (const suffix of ["", "-wal", "-shm"]) {
@@ -2194,6 +2398,7 @@ function describeOutcome(outcome) {
2194
2398
  }
2195
2399
 
2196
2400
  export {
2401
+ decodeImage,
2197
2402
  readNativeHostViewerExtensionId,
2198
2403
  resolveCorpusDir,
2199
2404
  staleIndexFiles,
@@ -2201,6 +2406,7 @@ export {
2201
2406
  upsertIndexedConversation,
2202
2407
  ingestFile,
2203
2408
  pendingInboxFiles,
2409
+ retryQuarantine,
2204
2410
  drainInbox,
2205
2411
  fingerprintConversation,
2206
2412
  SearchIndex,
@@ -38,7 +38,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
38
38
  ));
39
39
 
40
40
  // src/version.ts
41
- var VERSION = true ? "0.2.10" : "0.0.0-dev";
41
+ var VERSION = true ? "0.2.12" : "0.0.0-dev";
42
42
 
43
43
  export {
44
44
  __require,
@@ -14,16 +14,17 @@ import {
14
14
  openSearchIndex,
15
15
  readAutomaticMaintenanceState,
16
16
  resolveCorpusDir,
17
+ retryQuarantine,
17
18
  runtimeBinDir,
18
19
  staleIndexFiles,
19
20
  uninstallRuntime
20
- } from "./chunk-XRA6OJC5.js";
21
+ } from "./chunk-3QDA3XWA.js";
21
22
  import {
22
23
  VERSION,
23
24
  __commonJS,
24
25
  __require,
25
26
  __toESM
26
- } from "./chunk-GLCLSBT6.js";
27
+ } from "./chunk-SEBISBN5.js";
27
28
 
28
29
  // ../node_modules/isexe/windows.js
29
30
  var require_windows = __commonJS({
@@ -3829,6 +3830,8 @@ Power-user / debugging:
3829
3830
  ingest <file...> Ingest one or more NDJSON backup files into the corpus
3830
3831
  drain Ingest every file in <corpus>/inbox; discard clean
3831
3832
  payloads and quarantine malformed ones
3833
+ retry-quarantine Retry quarantined files; retain repaired originals in
3834
+ <corpus>/inbox/recovered (close MCP clients first)
3832
3835
  watch Watch <corpus>/inbox and ingest new deltas live
3833
3836
  stats Print corpus counts (conversations, messages, platforms)
3834
3837
  compact Losslessly compress conversations and remove redundant
@@ -4058,6 +4061,20 @@ async function main() {
4058
4061
  }
4059
4062
  return 0;
4060
4063
  }
4064
+ case "retry-quarantine": {
4065
+ const index = await openSearchIndex(corpus);
4066
+ try {
4067
+ const report = await retryQuarantine(corpus, index);
4068
+ process.stdout.write(
4069
+ `Quarantine recovery: ${report.recovered} recovered, ${report.remaining} remaining, ${report.failed} failed.
4070
+ ` + (report.archiveDir ? `Original files retained at ${report.archiveDir}
4071
+ ` : "")
4072
+ );
4073
+ return report.remaining > 0 || report.failed > 0 ? 1 : 0;
4074
+ } finally {
4075
+ index.close();
4076
+ }
4077
+ }
4061
4078
  case "watch": {
4062
4079
  process.stdout.write(`Watching ${corpus.paths.inboxDir} for new deltas (Ctrl+C to stop)\u2026
4063
4080
  `);
package/dist/cli.js CHANGED
@@ -3,7 +3,7 @@ import { createRequire as __llmnesiaCreateRequire } from "node:module";
3
3
  const require = __llmnesiaCreateRequire(import.meta.url);
4
4
  import {
5
5
  VERSION
6
- } from "./chunk-GLCLSBT6.js";
6
+ } from "./chunk-SEBISBN5.js";
7
7
 
8
8
  // src/nodeDownload.ts
9
9
  var NODE_DOWNLOAD_URLS = {
@@ -141,7 +141,7 @@ async function bootstrap() {
141
141
  await guideUnsupportedNodeVersion(process.versions.node);
142
142
  return 1;
143
143
  }
144
- const { main } = await import("./cli-2IIJ77ZQ.js");
144
+ const { main } = await import("./cli-IF63HSCG.js");
145
145
  return main();
146
146
  }
147
147
  bootstrap().then((code) => {
@@ -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
package/dist/mcp.js CHANGED
@@ -4,6 +4,7 @@ const require = __llmnesiaCreateRequire(import.meta.url);
4
4
  import {
5
5
  AUTO_MAINTENANCE_RECHECK_MS,
6
6
  Corpus,
7
+ decodeImage,
7
8
  describeOutcome,
8
9
  drainInbox,
9
10
  fingerprintConversation,
@@ -14,13 +15,13 @@ import {
14
15
  runAutomaticMaintenance,
15
16
  scheduleSelfUpdate,
16
17
  upsertIndexedConversation
17
- } from "./chunk-XRA6OJC5.js";
18
+ } from "./chunk-3QDA3XWA.js";
18
19
  import {
19
20
  VERSION,
20
21
  __commonJS,
21
22
  __export,
22
23
  __toESM
23
- } from "./chunk-GLCLSBT6.js";
24
+ } from "./chunk-SEBISBN5.js";
24
25
 
25
26
  // ../node_modules/ajv/dist/compile/codegen/code.js
26
27
  var require_code = __commonJS({
@@ -6891,12 +6892,12 @@ var require_dist = __commonJS({
6891
6892
  throw new Error(`Unknown format "${name}"`);
6892
6893
  return f;
6893
6894
  };
6894
- function addFormats(ajv, list, fs, exportName) {
6895
+ function addFormats(ajv, list, fs2, exportName) {
6895
6896
  var _a;
6896
6897
  var _b;
6897
6898
  (_a = (_b = ajv.opts.code).formats) !== null && _a !== void 0 ? _a : _b.formats = (0, codegen_1._)`require("ajv-formats/dist/formats").${exportName}`;
6898
6899
  for (const f of list)
6899
- ajv.addFormat(f, fs[f]);
6900
+ ajv.addFormat(f, fs2[f]);
6900
6901
  }
6901
6902
  module.exports = exports = formatsPlugin;
6902
6903
  Object.defineProperty(exports, "__esModule", { value: true });
@@ -21143,6 +21144,9 @@ import { realpathSync } from "fs";
21143
21144
  import { fileURLToPath } from "url";
21144
21145
  import path from "path";
21145
21146
 
21147
+ // src/service.ts
21148
+ import { promises as fs } from "fs";
21149
+
21146
21150
  // src/inventory.ts
21147
21151
  import { randomBytes } from "crypto";
21148
21152
  var InventoryCursorError = class extends Error {
@@ -21501,6 +21505,7 @@ function describeIssues(error2) {
21501
21505
 
21502
21506
  // src/service.ts
21503
21507
  var STALE_AFTER_HOURS = 48;
21508
+ var LOCAL_HEALTH_STALE_MS = 45 * 6e4;
21504
21509
  var AUTO_MAINTENANCE_START_DELAY_MS = 6e4;
21505
21510
  var AUTO_MAINTENANCE_IDLE_MS = 3e4;
21506
21511
  var AUTO_MAINTENANCE_BUSY_RETRY_MS = 3e4;
@@ -21522,6 +21527,8 @@ var CompanionService = class _CompanionService {
21522
21527
  inventoryScanner = new InventoryScanner(() => this.index.inventoryRows());
21523
21528
  /** Memories written since this process started; the per-session cap counts these. */
21524
21529
  memoriesWritten = 0;
21530
+ memoryWriteChain = Promise.resolve();
21531
+ indexSync;
21525
21532
  activeOperations = 0;
21526
21533
  lastOperationAt = Date.now();
21527
21534
  maintenanceTimer;
@@ -21617,16 +21624,27 @@ var CompanionService = class _CompanionService {
21617
21624
  return run;
21618
21625
  }
21619
21626
  async drainPending() {
21627
+ let pendingFiles = null;
21628
+ let quarantinedFiles = this.indexSync?.quarantinedFiles ?? 0;
21620
21629
  try {
21621
- if ((await pendingInboxFiles(this.corpus)).length === 0) {
21622
- return;
21630
+ pendingFiles = (await pendingInboxFiles(this.corpus)).length;
21631
+ let failed = 0;
21632
+ if (pendingFiles > 0) {
21633
+ const report = await drainInbox(this.corpus, this.index, {
21634
+ onFileError: (name, error2) => this.onDrainError(new Error(`inbox file ${name} could not be ingested`, { cause: error2 }))
21635
+ });
21636
+ failed = report.failed;
21637
+ pendingFiles = (await pendingInboxFiles(this.corpus)).length;
21623
21638
  }
21624
- await drainInbox(this.corpus, this.index, {
21625
- // Per-file failures no longer abort the drain, so surface them here or
21626
- // they would pass silently — the outer catch only sees a fatal drain.
21627
- onFileError: (name, error2) => this.onDrainError(new Error(`inbox file ${name} could not be ingested`, { cause: error2 }))
21628
- });
21639
+ try {
21640
+ quarantinedFiles = (await fs.readdir(this.corpus.paths.quarantineDir, { withFileTypes: true })).filter((entry) => entry.isFile()).length;
21641
+ } catch (error2) {
21642
+ if (error2.code !== "ENOENT") throw error2;
21643
+ quarantinedFiles = 0;
21644
+ }
21645
+ this.indexSync = failed > 0 || pendingFiles > 0 || quarantinedFiles > 0 ? { state: "error", pendingFiles, quarantinedFiles, lastFailureAt: Date.now() } : void 0;
21629
21646
  } catch (error2) {
21647
+ this.indexSync = { state: "error", pendingFiles, quarantinedFiles, lastFailureAt: Date.now() };
21630
21648
  this.onDrainError(error2);
21631
21649
  }
21632
21650
  }
@@ -21634,7 +21652,8 @@ var CompanionService = class _CompanionService {
21634
21652
  await this.refresh();
21635
21653
  return this.index.search(query, filters);
21636
21654
  }
21637
- getConversation(docId) {
21655
+ async getConversation(docId) {
21656
+ await this.refresh();
21638
21657
  return this.corpus.get(docId);
21639
21658
  }
21640
21659
  /**
@@ -21680,6 +21699,44 @@ var CompanionService = class _CompanionService {
21680
21699
  await this.refresh();
21681
21700
  }
21682
21701
  const meta = await this.corpus.readMeta();
21702
+ const health = new Map((await this.corpus.readSourceHealth()).map((row) => [row.source, row]));
21703
+ const sources = {};
21704
+ const sourceWarnings = {};
21705
+ for (const row of this.index.sourceSummary()) {
21706
+ sources[row.source] = {
21707
+ conversationCount: row.conversationCount,
21708
+ newestConversationAt: row.newestConversationAt,
21709
+ lastIngestAt: meta?.sourceLastIngestAt?.[row.source] ?? null
21710
+ };
21711
+ }
21712
+ for (const source of ["claude_code", "codex", "zcode"]) {
21713
+ const row = health.get(source);
21714
+ const summary = sources[source] ??= { conversationCount: 0, newestConversationAt: null, lastIngestAt: null };
21715
+ const stale = row ? Date.now() - row.observedAt > LOCAL_HEALTH_STALE_MS : false;
21716
+ summary.localSync = {
21717
+ connection: !row || stale ? "unknown" : row.connection,
21718
+ state: row?.state ?? "unknown",
21719
+ lastStatusAt: row?.observedAt ?? null,
21720
+ lastAttemptAt: row?.lastAttemptAt ?? null,
21721
+ lastSuccessfulSyncAt: row?.lastSuccessfulSyncAt ?? null,
21722
+ lastCaptureAt: row?.lastCaptureAt ?? null,
21723
+ failedSessions: row?.failedSessions ?? 0,
21724
+ freshness: !row ? "unknown" : stale ? "stale" : "fresh"
21725
+ };
21726
+ if (!row && summary.conversationCount > 0) {
21727
+ sourceWarnings[source] = "No local-session sync diagnostics are available. Recent browser capture does not prove this source is current; update the extension and helper.";
21728
+ } else if (stale && (summary.conversationCount > 0 || row?.lastSuccessfulSyncAt != null)) {
21729
+ sourceWarnings[source] = "Local-session sync diagnostics have not arrived for over 45 minutes. The browser or helper may be offline; recent browser data does not prove this source is current.";
21730
+ } else if (row?.connection === "disconnected") {
21731
+ sourceWarnings[source] = "The local-session helper was disconnected at its last check. Check the helper and native messaging permission; automatic sync can resume when access is restored.";
21732
+ } else if (row && (row.state === "partial" || row.state === "error")) {
21733
+ sourceWarnings[source] = "The latest local-session scan had errors; this source may be missing sessions.";
21734
+ } else if (row?.state === "missing" && (summary.conversationCount > 0 || row.lastCaptureAt !== null)) {
21735
+ sourceWarnings[source] = "The helper can no longer find this source's session storage.";
21736
+ } else if (row?.state === "unknown" && summary.conversationCount > 0) {
21737
+ sourceWarnings[source] = "The helper is reachable but cannot confirm source health. Update the helper for per-source diagnostics.";
21738
+ }
21739
+ }
21683
21740
  const lastIngestAt = meta?.lastIngestAt ?? null;
21684
21741
  const hoursSinceIngest = lastIngestAt === null ? null : Math.round((Date.now() - lastIngestAt) / 36e5 * 10) / 10;
21685
21742
  const status = {
@@ -21690,7 +21747,8 @@ var CompanionService = class _CompanionService {
21690
21747
  conversationCount: this.index.count(),
21691
21748
  lastIngestAt,
21692
21749
  hoursSinceIngest,
21693
- newestConversationAt: this.index.listRecent(1)[0]?.updatedAt ?? null
21750
+ newestConversationAt: this.index.listRecent(1)[0]?.updatedAt ?? null,
21751
+ sources
21694
21752
  };
21695
21753
  if (hoursSinceIngest === null && status.conversationCount === 0) {
21696
21754
  status.staleWarning = "This corpus is empty and has never received browser data, so searches here will return nothing until setup finishes. The browser extension is what fills it; the connector on its own cannot. If the extension is not installed, get it from https://llmnesia.com. If it is installed, check that it is version 0.3.0 or newer, since older versions have no desktop connection at all; that both steps under Full settings \u2192 Connect desktop AI are complete, including `npx -y @llmnesia/mcp@latest install`; and that the browser has been fully quit and reopened since automatic sync was enabled.";
@@ -21699,6 +21757,17 @@ var CompanionService = class _CompanionService {
21699
21757
  } else if (hoursSinceIngest >= STALE_AFTER_HOURS) {
21700
21758
  status.staleWarning = `No browser data has reached this corpus in ${hoursSinceIngest}h. Either the user has not chatted since then, or the extension to corpus sync is broken, so results may be missing recent conversations.`;
21701
21759
  }
21760
+ if (Object.keys(sourceWarnings).length > 0) {
21761
+ status.sourceWarnings = sourceWarnings;
21762
+ status.staleWarning = [status.staleWarning, ...Object.entries(sourceWarnings).map(([source, warning]) => `${source}: ${warning}`)].filter(Boolean).join("\n");
21763
+ }
21764
+ if (this.indexSync) {
21765
+ status.indexSync = { ...this.indexSync };
21766
+ status.staleWarning = [
21767
+ status.staleWarning,
21768
+ `Local indexing is incomplete (${this.indexSync.pendingFiles ?? "unknown"} queued files, ${this.indexSync.quarantinedFiles} quarantined files). Search and inventory may omit recent changes. Queued files will be retried on the next read; quarantined files need repair.`
21769
+ ].filter(Boolean).join("\n");
21770
+ }
21702
21771
  return status;
21703
21772
  }
21704
21773
  /** Validate, normalize, persist to the corpus, and index — the write path. */
@@ -21716,10 +21785,16 @@ var CompanionService = class _CompanionService {
21716
21785
  * this path may not touch. A memory that was already stored is not an error,
21717
21786
  * it is the dedupe working.
21718
21787
  */
21719
- async saveMemory(payload) {
21788
+ saveMemory(payload) {
21789
+ const run = this.memoryWriteChain.then(() => this.saveMemorySerial(payload));
21790
+ this.memoryWriteChain = run.then(() => void 0, () => void 0);
21791
+ return run;
21792
+ }
21793
+ async saveMemorySerial(payload) {
21720
21794
  const input = parseSaveMemoryInput(payload);
21721
21795
  const memory = normalizeMemoryInput(input);
21722
- if (this.memoriesWritten >= this.memoryWriteCap) {
21796
+ const existing = await this.corpus.getMemory(memory.memoryId);
21797
+ if (!existing && this.memoriesWritten >= this.memoryWriteCap) {
21723
21798
  throw new MemoryWriteCapError(
21724
21799
  `save_memory has already stored ${this.memoryWriteCap} memories in this session, which is the limit. Every stored memory competes for the same review queue and prompt budget, so keep the rest for the next session and record only the single most durable conclusion if one is left.`
21725
21800
  );
@@ -21732,8 +21807,10 @@ var CompanionService = class _CompanionService {
21732
21807
  }
21733
21808
  if (result.outcome === "created") {
21734
21809
  this.memoriesWritten += 1;
21735
- this.index.upsertMemory(memory);
21736
21810
  }
21811
+ const saved = await this.corpus.getMemory(result.memoryId);
21812
+ if (!saved) throw new Error(`Saved memory '${result.memoryId}' could not be read for indexing`);
21813
+ this.index.upsertMemory(saved);
21737
21814
  return {
21738
21815
  memoryId: result.memoryId,
21739
21816
  outcome: result.outcome,
@@ -21831,6 +21908,7 @@ function corpusField(status) {
21831
21908
  lastIngestAt: status.lastIngestAt,
21832
21909
  hoursSinceIngest: status.hoursSinceIngest,
21833
21910
  conversationCount: status.conversationCount,
21911
+ ...status.indexSync ? { indexSync: status.indexSync } : {},
21834
21912
  ...status.staleWarning ? { staleWarning: status.staleWarning } : {}
21835
21913
  };
21836
21914
  }
@@ -21957,7 +22035,7 @@ async function runServe() {
21957
22035
  "get_conversation",
21958
22036
  {
21959
22037
  title: "Get conversation",
21960
- description: "Return the full transcript of one conversation by its id (the `id` from search results). The conversation includes `sourceUrl`: its original-chat URL when available, otherwise a clickable HTTPS hand-off to its local LLMnesia Viewer. Always link it in an answer based on this transcript.",
22038
+ description: "Return the full transcript of one conversation by its id (the `id` from search results). The conversation includes `sourceUrl`: its original-chat URL when available, otherwise a clickable HTTPS hand-off to its local LLMnesia Viewer. Always link it in an answer based on this transcript. Image metadata includes imageIndex and locallyAvailable; use get_conversation_image for retained bytes.",
21961
22039
  inputSchema: {
21962
22040
  id: external_exports.string().describe("The conversation id (docId), e.g. 'claude:abc-123'.")
21963
22041
  },
@@ -21989,11 +22067,36 @@ async function runServe() {
21989
22067
  role: m.role,
21990
22068
  ...m.model ? { model: m.model } : {},
21991
22069
  // Prefer the block-formatted copy; fall back to the search-collapsed text.
21992
- text: m.formattedText ?? m.text
22070
+ text: m.formattedText ?? m.text,
22071
+ ...m.images?.length ? { images: m.images.map((image, index) => ({
22072
+ imageIndex: index,
22073
+ url: image.url,
22074
+ ...image.alt ? { alt: image.alt } : {},
22075
+ locallyAvailable: decodeImage(image.dataUrl) !== null,
22076
+ ...image.width ? { width: image.width } : {},
22077
+ ...image.height ? { height: image.height } : {}
22078
+ })) } : {}
21993
22079
  }))
21994
22080
  });
21995
22081
  })
21996
22082
  );
22083
+ server.registerTool(
22084
+ "get_conversation_image",
22085
+ {
22086
+ title: "Get conversation image",
22087
+ description: "Return a retained local image from a conversation turn. Use the message index and imageIndex from get_conversation. Reads saved raster bytes only; no provider fetch or OCR.",
22088
+ inputSchema: { id: external_exports.string(), messageIndex: external_exports.number().int().nonnegative(), imageIndex: external_exports.number().int().nonnegative() },
22089
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
22090
+ },
22091
+ async (args) => withService(async (service2) => {
22092
+ const record2 = await service2.getConversation(args.id);
22093
+ const image = record2?.messages.find((message) => message.msgIndex === args.messageIndex)?.images?.[args.imageIndex];
22094
+ if (!image) return errorResult("No image found at that conversation/message/image index.");
22095
+ const decoded = decodeImage(image.dataUrl);
22096
+ if (!decoded) return errorResult("Only a provider URL was captured for this image. Reopen the original chat or import an export containing the image to retain a local copy. MCP does not fetch private provider URLs.");
22097
+ return { content: [{ type: "image", ...decoded }] };
22098
+ })
22099
+ );
21997
22100
  server.registerTool(
21998
22101
  "list_recent",
21999
22102
  {
@@ -22103,6 +22206,7 @@ async function runServe() {
22103
22206
  try {
22104
22207
  const page = await service2.inventoryPage({ cursor: args.cursor, limit: args.limit });
22105
22208
  const payload = {
22209
+ corpus: corpusField(await service2.corpusStatus({ refresh: false })),
22106
22210
  count: page.items.length,
22107
22211
  total: page.total,
22108
22212
  scanStartedAt: page.scanStartedAt,
@@ -22124,7 +22228,7 @@ async function runServe() {
22124
22228
  "corpus_status",
22125
22229
  {
22126
22230
  title: "Corpus status",
22127
- description: "Report how current the local corpus is: when browser data last reached it, how many conversations it holds, and the newest conversation it knows about. Use this when a conversation the user is sure they had cannot be found, to tell a genuinely missing chat apart from a corpus that has stopped receiving updates from the browser extension.",
22231
+ description: "Report how current the local corpus is: when browser data last reached it, how many conversations it holds, and the newest conversation it knows about. The sources field includes per-source arrival times and local-session sync health. Check sourceWarnings: recent browser data must not be treated as proof that local agent sessions are current. Use this when a conversation the user is sure they had cannot be found, to tell a genuinely missing chat apart from a corpus that has stopped receiving updates from the browser extension.",
22128
22232
  inputSchema: {},
22129
22233
  annotations: {
22130
22234
  readOnlyHint: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llmnesia/mcp",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "comment:mcpName": "The MCP Registry's ownership marker: it must equal server.json's `name`, and the namespace must match how you authenticate with mcp-publisher. `com.llmnesia/...` requires DNS auth (a TXT record on llmnesia.com) — see docs/MCP_INSTALLER_BOARD.md.",
package/server.json CHANGED
@@ -3,13 +3,13 @@
3
3
  "name": "com.llmnesia/mcp",
4
4
  "title": "LLMnesia",
5
5
  "description": "Search your AI chat history from Claude, Cursor, Codex, and other MCP clients.",
6
- "version": "0.2.10",
6
+ "version": "0.2.12",
7
7
  "packages": [
8
8
  {
9
9
  "registryType": "npm",
10
10
  "registryBaseUrl": "https://registry.npmjs.org",
11
11
  "identifier": "@llmnesia/mcp",
12
- "version": "0.2.10",
12
+ "version": "0.2.12",
13
13
  "transport": {
14
14
  "type": "stdio"
15
15
  }