@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 +40 -9
- package/dist/{chunk-ZYE6VY6A.js → chunk-GHKXNM7R.js} +1 -1
- package/dist/{chunk-7ISC4REX.js → chunk-QIRYUQIU.js} +241 -72
- package/dist/{cli-RQMFEW5J.js → cli-CMCIFSWH.js} +49 -32
- package/dist/cli.js +2 -2
- package/dist/llmnesia-claude-host.mjs +93 -13
- package/dist/llmnesia.mcpb +0 -0
- package/dist/mcp.js +301 -230
- package/package.json +1 -1
- package/server.json +2 -2
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": "
|
|
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
|
-
|
|
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
|
|
237
|
-
|
|
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
|
|
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
|
-
|
|
260
|
-
your snapshot was taken and the transcript
|
|
261
|
-
|
|
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
|
|
@@ -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-
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
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 = "
|
|
1155
|
+
var FINGERPRINT_SCHEME = "fp2";
|
|
1017
1156
|
function fingerprintConversation(record) {
|
|
1018
1157
|
const conversation = record.conversation;
|
|
1019
1158
|
const payload = {
|
|
1020
|
-
v:
|
|
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
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
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
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
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,
|