@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 +18 -4
- package/dist/{chunk-XRA6OJC5.js → chunk-3QDA3XWA.js} +260 -54
- package/dist/{chunk-GLCLSBT6.js → chunk-SEBISBN5.js} +1 -1
- package/dist/{cli-2IIJ77ZQ.js → cli-IF63HSCG.js} +19 -2
- package/dist/cli.js +2 -2
- package/dist/llmnesia-claude-host.mjs +93 -13
- package/dist/llmnesia.mcpb +0 -0
- package/dist/mcp.js +123 -19
- 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,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
|
-
|
|
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
|
|
237
|
-
|
|
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-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
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 = "
|
|
1155
|
+
var FINGERPRINT_SCHEME = "fp2";
|
|
968
1156
|
function fingerprintConversation(record) {
|
|
969
1157
|
const conversation = record.conversation;
|
|
970
1158
|
const payload = {
|
|
971
|
-
v:
|
|
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
|
|
1160
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1467
|
-
|
|
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,
|
|
@@ -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-
|
|
21
|
+
} from "./chunk-3QDA3XWA.js";
|
|
21
22
|
import {
|
|
22
23
|
VERSION,
|
|
23
24
|
__commonJS,
|
|
24
25
|
__require,
|
|
25
26
|
__toESM
|
|
26
|
-
} from "./chunk-
|
|
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-
|
|
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-
|
|
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
|
|
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:
|
|
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
|
-
//
|
|
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 =
|
|
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
|
|
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") {
|
package/dist/llmnesia.mcpb
CHANGED
|
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-
|
|
18
|
+
} from "./chunk-3QDA3XWA.js";
|
|
18
19
|
import {
|
|
19
20
|
VERSION,
|
|
20
21
|
__commonJS,
|
|
21
22
|
__export,
|
|
22
23
|
__toESM
|
|
23
|
-
} from "./chunk-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
21622
|
-
|
|
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
|
-
|
|
21625
|
-
|
|
21626
|
-
|
|
21627
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
12
|
+
"version": "0.2.12",
|
|
13
13
|
"transport": {
|
|
14
14
|
"type": "stdio"
|
|
15
15
|
}
|