@llmnesia/mcp 0.2.11 → 0.2.13

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,32 @@ 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 included in the `fp2` fingerprint. MCP 0.2.13 automatically rebuilds
243
+ the derived index as format v8 on first use, recomputing every inventory
244
+ fingerprint from the same canonical records used by retrieval. This repairs
245
+ mixed `fp1`/`fp2` inventories left by earlier versions. The canonical transcripts
246
+ are unchanged, and later opens reuse the rebuilt index. Stop all MCP clients
247
+ before updating and restart them afterward; the first read can take longer
248
+ while rebuilding. Clients starting together wait for the index to become ready.
249
+ An interrupted initial rebuild is retried on the next open. No extension
250
+ history rebuild or backup reimport is needed.
251
+
252
+ Consumer migration: begin a new complete inventory scan without an old cursor.
253
+ Replace the saved `fp1` baseline with the new `fp2` values and reverify those
254
+ records. Different scheme prefixes are not comparable content revisions.
255
+ Existing `fp2` digests retain their definition and values for unchanged content.
256
+ Future incompatible fingerprint changes require a new scheme prefix, an index
257
+ migration and a documented consumer migration before release.
258
+
259
+ 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.
260
+
261
+ The fingerprint is `fp2:` followed by a SHA-256 hex digest. It covers exactly:
233
262
 
234
263
  - the conversation title, and the user's local alias (`localTitle`) when set;
235
264
  - 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).
265
+ `formattedText` when present;
266
+ - image references, metadata and retained local bytes. Image-only changes are
267
+ visible to an inventory client even when transcript text is unchanged.
238
268
 
239
269
  It deliberately excludes every timestamp, including the provider's
240
270
  `updatedAt`: historical imports can retain old source dates, and a genuinely
@@ -243,8 +273,8 @@ state, account and model identifiers, summaries, and other derived fields are
243
273
  excluded too. An identical re-ingest therefore never changes the fingerprint,
244
274
  while an edited message or a rename always does, even when nothing else about
245
275
  the record moved. The fingerprint is stored in the derived search index
246
- (index format v5; opening an older index triggers a rebuild that computes
247
- fingerprints for the existing corpus), so a scan never decompresses a single
276
+ (index format v8; opening an older index triggers a rebuild that computes
277
+ `fp2` fingerprints for the existing corpus), so a scan never decompresses a single
248
278
  transcript.
249
279
 
250
280
  A periodic-processing loop:
@@ -256,9 +286,9 @@ A periodic-processing loop:
256
286
  be skipped.
257
287
  3. Call `get_conversation` only for the new or changed ids. Its response
258
288
  carries `contentFingerprint`, the fingerprint of the transcript it returns.
259
- If that differs from the inventory entry, the conversation changed after
260
- your snapshot was taken and the transcript you just fetched is the current
261
- content.
289
+ Within the same scheme, a different digest means the conversation changed
290
+ after your snapshot was taken and the fetched transcript is current. A
291
+ different scheme prefix requires the baseline migration described above.
262
292
 
263
293
  ### Read-only mode (`LLMNESIA_READ_ONLY`)
264
294
 
@@ -322,6 +352,7 @@ llmnesia-mcp uninstall # remove manifests, config, client entries
322
352
  llmnesia-mcp init # create an empty corpus
323
353
  llmnesia-mcp ingest <…> # ingest an NDJSON backup file
324
354
  llmnesia-mcp drain # ingest everything in <corpus>/inbox
355
+ llmnesia-mcp retry-quarantine # repair eligible files; retain original backups
325
356
  llmnesia-mcp watch # ingest new inbox deltas live (Ctrl+C to stop)
326
357
  llmnesia-mcp stats # corpus summary
327
358
  llmnesia-mcp compact --dry-run # exact read-only storage/savings report
@@ -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.11" : "0.0.0-dev";
41
+ var VERSION = true ? "0.2.13" : "0.0.0-dev";
42
42
 
43
43
  export {
44
44
  __require,
@@ -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-ZYE6VY6A.js";
6
+ } from "./chunk-GHKXNM7R.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";
@@ -47,7 +85,7 @@ function expandHome(p) {
47
85
  import { join as join2 } from "path";
48
86
  import { readdir } from "fs/promises";
49
87
  import { createHash } from "crypto";
50
- var INDEX_FORMAT_VERSION = 7;
88
+ var INDEX_FORMAT_VERSION = 8;
51
89
  function indexDbFilename(version) {
52
90
  return version === 1 ? "llmnesia.db" : `llmnesia-v${version}.db`;
53
91
  }
@@ -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;
@@ -586,14 +651,46 @@ var Corpus = class {
586
651
  return { conversationCount, messageCount, byPlatform };
587
652
  }
588
653
  /** Refresh meta.json's conversationCount / lastIngestAt after an ingest run. */
589
- async touchIngestMeta(receipt) {
654
+ async touchIngestMeta(receipt, sources = {}) {
590
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
+ }
591
661
  await this.writeMeta({
592
662
  lastIngestAt: Date.now(),
593
663
  conversationCount,
664
+ sourceLastIngestAt,
594
665
  ...receipt ? { lastIngestReceipt: receipt } : {}
595
666
  });
596
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
+ }
597
694
  enqueueWrite(fn) {
598
695
  const run = this.writeChain.then(fn, fn);
599
696
  this.writeChain = run.then(
@@ -787,7 +884,7 @@ async function upsertIndexedConversation(corpus, record, index) {
787
884
  }
788
885
  return result;
789
886
  }
790
- async function ingestFile(corpus, file, index) {
887
+ async function ingestFile(corpus, file, index, options = {}) {
791
888
  const report = { ...EMPTY_REPORT };
792
889
  let current = null;
793
890
  let buffered = [];
@@ -796,7 +893,11 @@ async function ingestFile(corpus, file, index) {
796
893
  return;
797
894
  }
798
895
  const record = { conversation: current, messages: buffered };
799
- 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
+ }
800
901
  tally(report, result);
801
902
  report.conversations += 1;
802
903
  report.messages += buffered.length;
@@ -825,6 +926,12 @@ async function ingestFile(corpus, file, index) {
825
926
  if (type === "meta") {
826
927
  continue;
827
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
+ }
828
935
  if (type === "conversation") {
829
936
  await flush();
830
937
  current = coerceConversation(record.conversation);
@@ -846,7 +953,9 @@ async function ingestFile(corpus, file, index) {
846
953
  } else {
847
954
  report.malformedLines += 1;
848
955
  }
956
+ continue;
849
957
  }
958
+ report.malformedLines += 1;
850
959
  }
851
960
  await flush();
852
961
  return report;
@@ -859,6 +968,30 @@ async function pendingInboxFiles(corpus) {
859
968
  throw error;
860
969
  }
861
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
+ }
991
+ }
992
+ result.remaining = (await fs2.readdir(corpus.paths.quarantineDir, { withFileTypes: true })).filter((entry) => entry.isFile()).length;
993
+ return result;
994
+ }
862
995
  async function drainInbox(corpus, index, options = {}) {
863
996
  await corpus.init();
864
997
  const inbox = corpus.paths.inboxDir;
@@ -894,7 +1027,7 @@ async function drainInbox(corpus, index, options = {}) {
894
1027
  }
895
1028
  }
896
1029
  }
897
- if (total.files > 0) {
1030
+ if (total.conversations > 0) {
898
1031
  await corpus.touchIngestMeta({
899
1032
  files: total.files,
900
1033
  conversations: total.conversations,
@@ -902,7 +1035,7 @@ async function drainInbox(corpus, index, options = {}) {
902
1035
  malformedLines: total.malformedLines,
903
1036
  discarded: total.discarded,
904
1037
  quarantined: total.quarantined
905
- });
1038
+ }, total.sourceLastIngestAt);
906
1039
  }
907
1040
  return total;
908
1041
  }
@@ -947,12 +1080,12 @@ function coerceConversation(value) {
947
1080
  return null;
948
1081
  }
949
1082
  const c = value;
950
- 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)) {
951
1084
  return null;
952
1085
  }
953
1086
  const conversation = { ...c };
954
- if (typeof conversation.updatedAt !== "number") {
955
- 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;
956
1089
  }
957
1090
  return conversation;
958
1091
  }
@@ -961,11 +1094,12 @@ function coerceMessage(docId, value) {
961
1094
  return null;
962
1095
  }
963
1096
  const m = value;
964
- const text = typeof m.text === "string" ? m.text : "";
965
- if (!text.trim()) {
966
- return null;
967
- }
968
- 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;
969
1103
  const message = {
970
1104
  // The backup line omits msgKey; reconstruct the extension's convention.
971
1105
  msgKey: `${docId}:${msgIndex}`,
@@ -973,6 +1107,7 @@ function coerceMessage(docId, value) {
973
1107
  msgIndex,
974
1108
  role: coerceRole(m.role),
975
1109
  text,
1110
+ ...images.length ? { images } : {},
976
1111
  createdAt: typeof m.createdAt === "number" ? m.createdAt : 0,
977
1112
  updatedAt: typeof m.updatedAt === "number" ? m.updatedAt : 0,
978
1113
  contentHash: typeof m.contentHash === "string" ? m.contentHash : ""
@@ -1003,6 +1138,10 @@ function tally(report, result) {
1003
1138
  }
1004
1139
  }
1005
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
+ }
1006
1145
  total.conversations += report.conversations;
1007
1146
  total.created += report.created;
1008
1147
  total.updated += report.updated;
@@ -1013,18 +1152,19 @@ function accumulate(total, report) {
1013
1152
 
1014
1153
  // src/fingerprint.ts
1015
1154
  import { createHash as createHash2 } from "crypto";
1016
- var FINGERPRINT_SCHEME = "fp1";
1155
+ var FINGERPRINT_SCHEME = "fp2";
1017
1156
  function fingerprintConversation(record) {
1018
1157
  const conversation = record.conversation;
1019
1158
  const payload = {
1020
- v: 1,
1159
+ v: 2,
1021
1160
  title: conversation.title,
1022
1161
  localTitle: conversation.localTitle ?? null,
1023
1162
  messages: [...record.messages].sort((a, b) => a.msgIndex - b.msgIndex).map((message) => ({
1024
1163
  i: message.msgIndex,
1025
1164
  r: message.role,
1026
1165
  t: message.text,
1027
- f: message.formattedText ?? null
1166
+ f: message.formattedText ?? null,
1167
+ images: message.images ?? []
1028
1168
  }))
1029
1169
  };
1030
1170
  const digest = createHash2("sha256").update(JSON.stringify(payload), "utf8").digest("hex");
@@ -1077,6 +1217,8 @@ var TITLE_ROW_INDEX = -1;
1077
1217
  var TITLE_ROLE = "title";
1078
1218
  var DEFAULT_LIMIT = 10;
1079
1219
  var SNIPPET_TOKENS = 12;
1220
+ var INDEX_OPEN_BUSY_TIMEOUT_MS = 5 * 6e4;
1221
+ var INDEX_OPEN_BUSY_RETRY_MS = 50;
1080
1222
  var META_SQL = `
1081
1223
  CREATE TABLE IF NOT EXISTS index_meta (
1082
1224
  key TEXT PRIMARY KEY,
@@ -1161,52 +1303,64 @@ var SearchIndex = class {
1161
1303
  this.corpus = corpus;
1162
1304
  hardenDbFile(dbPath);
1163
1305
  this.db = new (loadSqlite()).DatabaseSync(dbPath);
1164
- this.db.exec("PRAGMA journal_mode = WAL");
1165
- this.db.exec("PRAGMA foreign_keys = OFF");
1166
- this.db.exec(META_SQL);
1167
- this.staleOnOpen = this.getMeta("format_version") !== String(INDEX_FORMAT_VERSION);
1168
- if (this.staleOnOpen) {
1169
- this.db.exec(DROP_DERIVED_SQL);
1170
- }
1171
- this.db.exec(SCHEMA_SQL);
1172
- this.db.exec("PRAGMA temp_store = MEMORY");
1173
- this.db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS temp.snippet_fts USING fts5(
1174
- text, tokenize = 'porter unicode61'
1175
- )`);
1176
- this.setMeta("format_version", String(INDEX_FORMAT_VERSION));
1177
- this.stmts = {
1178
- selectFtsRows: this.db.prepare("SELECT rowid FROM message_rows WHERE docId = ?"),
1179
- deleteFts: this.db.prepare("DELETE FROM messages_fts WHERE rowid = ?"),
1180
- deleteMessageRows: this.db.prepare("DELETE FROM message_rows WHERE docId = ?"),
1181
- insertMessageRow: this.db.prepare(
1182
- "INSERT INTO message_rows (docId, msgIndex, role) VALUES (?, ?, ?) RETURNING rowid"
1183
- ),
1184
- insertFts: this.db.prepare("INSERT INTO messages_fts (rowid, text) VALUES (?, ?)"),
1185
- deleteConversation: this.db.prepare("DELETE FROM conversations WHERE docId = ?"),
1186
- insertConversation: this.db.prepare(
1187
- `INSERT INTO conversations
1188
- (docId, platform, title, url, createdAt, updatedAt, messageCount, preview, pinned, accountId, accountLabel, providerProject, providerWorkspace, models, fingerprint)
1189
- VALUES (@docId, @platform, @title, @url, @createdAt, @updatedAt, @messageCount, @preview, @pinned, @accountId, @accountLabel, @providerProject, @providerWorkspace, @models, @fingerprint)`
1190
- ),
1191
- deleteMemoryFts: this.db.prepare("DELETE FROM memories_fts WHERE memoryId = ?"),
1192
- insertMemoryFts: this.db.prepare(
1193
- "INSERT INTO memories_fts (memoryId, label, text) VALUES (?, ?, ?)"
1194
- ),
1195
- deleteMemory: this.db.prepare("DELETE FROM memories WHERE memoryId = ?"),
1196
- insertMemory: this.db.prepare(
1197
- `INSERT INTO memories
1198
- (memoryId, kind, label, text, source, pinned, enabled, createdAt, updatedAt)
1199
- VALUES (@memoryId, @kind, @label, @text, @source, @pinned, @enabled, @createdAt, @updatedAt)`
1200
- ),
1201
- clearSnippetFts: this.db.prepare("DELETE FROM temp.snippet_fts"),
1202
- insertSnippetFts: this.db.prepare("INSERT INTO temp.snippet_fts (text) VALUES (?)"),
1203
- renderSnippet: this.db.prepare(
1204
- `SELECT snippet(snippet_fts, 0, '[', ']', '\u2026', ${SNIPPET_TOKENS}) AS snippet
1205
- FROM temp.snippet_fts WHERE snippet_fts MATCH ?`
1206
- )
1207
- };
1208
- for (const statement of Object.values(this.stmts)) {
1209
- statement.setReadBigInts(true);
1306
+ try {
1307
+ this.db.exec("PRAGMA journal_mode = WAL");
1308
+ this.db.exec("PRAGMA foreign_keys = OFF");
1309
+ this.db.exec("BEGIN IMMEDIATE");
1310
+ try {
1311
+ this.db.exec(META_SQL);
1312
+ this.staleOnOpen = this.getMeta("format_version") !== String(INDEX_FORMAT_VERSION);
1313
+ if (this.staleOnOpen) {
1314
+ this.db.exec(DROP_DERIVED_SQL);
1315
+ }
1316
+ this.db.exec(SCHEMA_SQL);
1317
+ this.setMeta("format_version", String(INDEX_FORMAT_VERSION));
1318
+ this.db.exec("COMMIT");
1319
+ } catch (error) {
1320
+ this.db.exec("ROLLBACK");
1321
+ throw error;
1322
+ }
1323
+ this.db.exec("PRAGMA temp_store = MEMORY");
1324
+ this.db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS temp.snippet_fts USING fts5(
1325
+ text, tokenize = 'porter unicode61'
1326
+ )`);
1327
+ this.stmts = {
1328
+ selectFtsRows: this.db.prepare("SELECT rowid FROM message_rows WHERE docId = ?"),
1329
+ deleteFts: this.db.prepare("DELETE FROM messages_fts WHERE rowid = ?"),
1330
+ deleteMessageRows: this.db.prepare("DELETE FROM message_rows WHERE docId = ?"),
1331
+ insertMessageRow: this.db.prepare(
1332
+ "INSERT INTO message_rows (docId, msgIndex, role) VALUES (?, ?, ?) RETURNING rowid"
1333
+ ),
1334
+ insertFts: this.db.prepare("INSERT INTO messages_fts (rowid, text) VALUES (?, ?)"),
1335
+ deleteConversation: this.db.prepare("DELETE FROM conversations WHERE docId = ?"),
1336
+ insertConversation: this.db.prepare(
1337
+ `INSERT INTO conversations
1338
+ (docId, platform, title, url, createdAt, updatedAt, messageCount, preview, pinned, accountId, accountLabel, providerProject, providerWorkspace, models, fingerprint)
1339
+ VALUES (@docId, @platform, @title, @url, @createdAt, @updatedAt, @messageCount, @preview, @pinned, @accountId, @accountLabel, @providerProject, @providerWorkspace, @models, @fingerprint)`
1340
+ ),
1341
+ deleteMemoryFts: this.db.prepare("DELETE FROM memories_fts WHERE memoryId = ?"),
1342
+ insertMemoryFts: this.db.prepare(
1343
+ "INSERT INTO memories_fts (memoryId, label, text) VALUES (?, ?, ?)"
1344
+ ),
1345
+ deleteMemory: this.db.prepare("DELETE FROM memories WHERE memoryId = ?"),
1346
+ insertMemory: this.db.prepare(
1347
+ `INSERT INTO memories
1348
+ (memoryId, kind, label, text, source, pinned, enabled, createdAt, updatedAt)
1349
+ VALUES (@memoryId, @kind, @label, @text, @source, @pinned, @enabled, @createdAt, @updatedAt)`
1350
+ ),
1351
+ clearSnippetFts: this.db.prepare("DELETE FROM temp.snippet_fts"),
1352
+ insertSnippetFts: this.db.prepare("INSERT INTO temp.snippet_fts (text) VALUES (?)"),
1353
+ renderSnippet: this.db.prepare(
1354
+ `SELECT snippet(snippet_fts, 0, '[', ']', '\u2026', ${SNIPPET_TOKENS}) AS snippet
1355
+ FROM temp.snippet_fts WHERE snippet_fts MATCH ?`
1356
+ )
1357
+ };
1358
+ for (const statement of Object.values(this.stmts)) {
1359
+ statement.setReadBigInts(true);
1360
+ }
1361
+ } catch (error) {
1362
+ this.db.close();
1363
+ throw error;
1210
1364
  }
1211
1365
  }
1212
1366
  /** True when the stored format version was missing or stale — caller rebuilds. */
@@ -1218,6 +1372,10 @@ var SearchIndex = class {
1218
1372
  const row = this.db.prepare("SELECT COUNT(*) AS n FROM conversations").get();
1219
1373
  return row.n;
1220
1374
  }
1375
+ sourceSummary() {
1376
+ return this.db.prepare(`SELECT platform AS source, COUNT(*) AS conversationCount,
1377
+ MAX(updatedAt) AS newestConversationAt FROM conversations GROUP BY platform`).all();
1378
+ }
1221
1379
  /** Number of indexed memories. */
1222
1380
  countMemories() {
1223
1381
  const row = this.db.prepare("SELECT COUNT(*) AS n FROM memories").get();
@@ -1517,15 +1675,24 @@ var SearchIndex = class {
1517
1675
  };
1518
1676
  async function openSearchIndex(corpus, options = {}) {
1519
1677
  await corpus.init();
1520
- const index = new SearchIndex(corpus.paths.indexDbFile, corpus);
1521
- try {
1522
- if (options.autoBuild !== false && (index.needsRebuild() || index.count() === 0 && index.countMemories() === 0)) {
1523
- await index.rebuildFrom(corpus);
1678
+ const deadline = Date.now() + (options.busyTimeoutMs ?? INDEX_OPEN_BUSY_TIMEOUT_MS);
1679
+ for (; ; ) {
1680
+ let index;
1681
+ try {
1682
+ index = new SearchIndex(corpus.paths.indexDbFile, corpus);
1683
+ if (options.autoBuild !== false && (index.needsRebuild() || index.count() === 0 && index.countMemories() === 0)) {
1684
+ await index.rebuildFrom(corpus);
1685
+ }
1686
+ return index;
1687
+ } catch (error) {
1688
+ index?.close();
1689
+ const code = error?.errcode;
1690
+ if (typeof code !== "number" || ![5, 6].includes(code & 255)) throw error;
1691
+ if (Date.now() >= deadline) {
1692
+ throw new Error("The local conversation index is busy. Wait for other MCP clients to finish updating it, then restart this client.", { cause: error });
1693
+ }
1694
+ await new Promise((resolve2) => setTimeout(resolve2, INDEX_OPEN_BUSY_RETRY_MS));
1524
1695
  }
1525
- return index;
1526
- } catch (error) {
1527
- index.close();
1528
- throw error;
1529
1696
  }
1530
1697
  }
1531
1698
  async function deleteIndexDb(dbFile) {
@@ -2254,6 +2421,7 @@ function describeOutcome(outcome) {
2254
2421
  }
2255
2422
 
2256
2423
  export {
2424
+ decodeImage,
2257
2425
  readNativeHostViewerExtensionId,
2258
2426
  resolveCorpusDir,
2259
2427
  staleIndexFiles,
@@ -2261,6 +2429,7 @@ export {
2261
2429
  upsertIndexedConversation,
2262
2430
  ingestFile,
2263
2431
  pendingInboxFiles,
2432
+ retryQuarantine,
2264
2433
  drainInbox,
2265
2434
  fingerprintConversation,
2266
2435
  SearchIndex,