@llmnesia/mcp 0.2.8 → 0.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -182,24 +182,90 @@ quarantined input.
182
182
 
183
183
  ## Tools
184
184
 
185
- Once registered, your MCP client gains seven tools over the corpus:
185
+ Once registered, your MCP client gains eight tools over the corpus:
186
186
 
187
187
  | Tool | Purpose |
188
188
  | --- | --- |
189
189
  | `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. |
190
- | `get_conversation` | Fetch the full transcript of one conversation by its id (the `id` field from search results). |
190
+ | `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
191
  | `list_conversations` | List conversations by chronology, including oldest-first for questions about the user's earliest chat. |
192
192
  | `list_recent` | List the most recently updated conversations with title, summary, and platform. |
193
+ | `conversation_inventory` | Paginated, read-only inventory of every conversation as compact metadata: id, platform, title, and a versioned content fingerprint. Built for services that periodically process the corpus; see below. |
193
194
  | `corpus_status` | Report when the corpus last received data and how many conversations it holds. Use this when an expected conversation cannot be found, to tell a genuinely missing chat apart from a corpus that has stopped receiving updates. |
194
195
  | `save_conversation` | Add or update a conversation in the corpus, so MCP is also an ingestion route. |
195
196
  | `save_memory` | Record a durable fact (a decision, preference, or correction) worth carrying into later chats. Stored turned off until the user reviews and enables it. |
196
197
 
198
+ ### Conversation inventory
199
+
200
+ `conversation_inventory` exists for programs that periodically process the
201
+ whole corpus, not for interactive questions. Each item is compact metadata:
202
+ the stable `id`, `platform`, `title`, and a versioned content `fingerprint`.
203
+ It never returns transcripts.
204
+
205
+ Call it with no arguments to start a scan, then pass each page's `nextCursor`
206
+ back as `cursor` until a page comes back without one. Pages hold up to 100
207
+ items (`limit` can go lower):
208
+
209
+ ```json
210
+ {
211
+ "count": 100,
212
+ "total": 250,
213
+ "scanStartedAt": 1769000000000,
214
+ "items": [
215
+ { "id": "claude:rust-borrow", "platform": "claude",
216
+ "title": "Debugging a Rust borrow checker error",
217
+ "fingerprint": "fp1:9af3c0d7…" }
218
+ ],
219
+ "nextCursor": "inv1.eyJzIjoi…"
220
+ }
221
+ ```
222
+
223
+ Every page of a scan reads the same snapshot, captured (sorted by id) when the
224
+ scan started. Conversations that arrive or change while you are paging never
225
+ appear in, disappear from, or change values within an in-flight scan; start a
226
+ new scan (omit `cursor`) to see them. Snapshots are bounded: at most eight are
227
+ held at once (the oldest is evicted) and each expires after about 15 minutes.
228
+ Cursors are opaque, they do not survive an MCP server restart, and an expired
229
+ or unknown cursor returns a clear error telling the caller to begin a new
230
+ scan.
231
+
232
+ The fingerprint is `fp1:` followed by a SHA-256 hex digest. It covers exactly:
233
+
234
+ - the conversation title, and the user's local alias (`localTitle`) when set;
235
+ - every message in `msgIndex` order: its index, role, stored `text`, and
236
+ `formattedText` when present (because `get_conversation` prefers it, a
237
+ backfill that adds it changes what a caller would receive).
238
+
239
+ It deliberately excludes every timestamp, including the provider's
240
+ `updatedAt`: historical imports can retain old source dates, and a genuinely
241
+ edited conversation can arrive with its source dates unchanged. URLs, pin
242
+ state, account and model identifiers, summaries, and other derived fields are
243
+ excluded too. An identical re-ingest therefore never changes the fingerprint,
244
+ while an edited message or a rename always does, even when nothing else about
245
+ the record moved. The fingerprint is stored in the derived search index
246
+ (index format v5; opening an older index triggers a rebuild that computes
247
+ fingerprints for the existing corpus), so a scan never decompresses a single
248
+ transcript.
249
+
250
+ A periodic-processing loop:
251
+
252
+ 1. Scan all pages of `conversation_inventory`, collecting each `id` and
253
+ `fingerprint`.
254
+ 2. Compare with the previous run: an id not seen before is new; a changed
255
+ fingerprint means that conversation's content changed. Everything else can
256
+ be skipped.
257
+ 3. Call `get_conversation` only for the new or changed ids. Its response
258
+ carries `contentFingerprint`, the fingerprint of the transcript it returns.
259
+ If that differs from the inventory entry, the conversation changed after
260
+ your snapshot was taken and the transcript you just fetched is the current
261
+ content.
262
+
197
263
  ### Read-only mode (`LLMNESIA_READ_ONLY`)
198
264
 
199
265
  The Claude Desktop extension (`.mcpb`) ships with `LLMNESIA_READ_ONLY=1`, which
200
266
  disables the two write tools (`save_conversation` and `save_memory`) so that
201
267
  ordinary history searches run without a per-call approval prompt. The general
202
- npm install exposes all seven tools.
268
+ npm install exposes all eight tools.
203
269
 
204
270
  Search and list results carry a `corpus` block saying when the corpus last
205
271
  received data — check it before concluding a chat does not exist.
@@ -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-RJJRIPHA.js";
6
+ } from "./chunk-YT6GNKKG.js";
7
7
 
8
8
  // src/config.ts
9
9
  import { readFileSync } from "fs";
@@ -47,7 +47,7 @@ function expandHome(p) {
47
47
  import { join as join2 } from "path";
48
48
  import { readdir } from "fs/promises";
49
49
  import { createHash } from "crypto";
50
- var INDEX_FORMAT_VERSION = 4;
50
+ var INDEX_FORMAT_VERSION = 5;
51
51
  function indexDbFilename(version) {
52
52
  return version === 1 ? "llmnesia.db" : `llmnesia-v${version}.db`;
53
53
  }
@@ -210,6 +210,13 @@ function canonicalConversationDoc(c) {
210
210
  assignIfDefined(out, "origin", c.origin);
211
211
  assignIfDefined(out, "accountId", c.accountId);
212
212
  assignIfDefined(out, "accountLabel", c.accountLabel);
213
+ if (c.providerProject) {
214
+ out.providerProject = {
215
+ projectId: c.providerProject.projectId,
216
+ source: c.providerProject.source,
217
+ observedAt: c.providerProject.observedAt
218
+ };
219
+ }
213
220
  if (c.models && c.models.length > 0) out.models = [...c.models];
214
221
  assignIfDefined(out, "modelMetadataUpdatedAt", c.modelMetadataUpdatedAt);
215
222
  if (c.summary) {
@@ -940,6 +947,26 @@ function accumulate(total, report) {
940
947
  total.malformedLines += report.malformedLines;
941
948
  }
942
949
 
950
+ // src/fingerprint.ts
951
+ import { createHash as createHash2 } from "crypto";
952
+ var FINGERPRINT_SCHEME = "fp1";
953
+ function fingerprintConversation(record) {
954
+ const conversation = record.conversation;
955
+ const payload = {
956
+ v: 1,
957
+ title: conversation.title,
958
+ localTitle: conversation.localTitle ?? null,
959
+ messages: [...record.messages].sort((a, b) => a.msgIndex - b.msgIndex).map((message) => ({
960
+ i: message.msgIndex,
961
+ r: message.role,
962
+ t: message.text,
963
+ f: message.formattedText ?? null
964
+ }))
965
+ };
966
+ const digest = createHash2("sha256").update(JSON.stringify(payload), "utf8").digest("hex");
967
+ return `${FINGERPRINT_SCHEME}:${digest}`;
968
+ }
969
+
943
970
  // src/searchIndex.ts
944
971
  import { promises as fs3, chmodSync, closeSync, openSync } from "fs";
945
972
  import { createRequire } from "module";
@@ -1017,7 +1044,8 @@ CREATE TABLE IF NOT EXISTS conversations (
1017
1044
  pinned INTEGER NOT NULL,
1018
1045
  accountId TEXT,
1019
1046
  accountLabel TEXT,
1020
- models TEXT
1047
+ models TEXT,
1048
+ fingerprint TEXT NOT NULL
1021
1049
  );
1022
1050
  CREATE INDEX IF NOT EXISTS idx_conversations_platform ON conversations(platform);
1023
1051
  CREATE INDEX IF NOT EXISTS idx_conversations_createdAt ON conversations(createdAt);
@@ -1091,8 +1119,8 @@ var SearchIndex = class {
1091
1119
  deleteConversation: this.db.prepare("DELETE FROM conversations WHERE docId = ?"),
1092
1120
  insertConversation: this.db.prepare(
1093
1121
  `INSERT INTO conversations
1094
- (docId, platform, title, url, createdAt, updatedAt, messageCount, preview, pinned, accountId, accountLabel, models)
1095
- VALUES (@docId, @platform, @title, @url, @createdAt, @updatedAt, @messageCount, @preview, @pinned, @accountId, @accountLabel, @models)`
1122
+ (docId, platform, title, url, createdAt, updatedAt, messageCount, preview, pinned, accountId, accountLabel, models, fingerprint)
1123
+ VALUES (@docId, @platform, @title, @url, @createdAt, @updatedAt, @messageCount, @preview, @pinned, @accountId, @accountLabel, @models, @fingerprint)`
1096
1124
  ),
1097
1125
  deleteMemoryFts: this.db.prepare("DELETE FROM memories_fts WHERE memoryId = ?"),
1098
1126
  insertMemoryFts: this.db.prepare(
@@ -1149,6 +1177,17 @@ var SearchIndex = class {
1149
1177
  return entry;
1150
1178
  });
1151
1179
  }
1180
+ /**
1181
+ * The full conversation inventory as compact metadata, sorted by stable
1182
+ * docId. One statement, so the caller's snapshot is a consistent cut of the
1183
+ * index (new conversations and edits that commit after this read belong to
1184
+ * the next scan), and no transcript is ever decompressed: fingerprints are
1185
+ * computed at upsert/rebuild time and stored in the index.
1186
+ */
1187
+ inventoryRows() {
1188
+ const rows = this.db.prepare("SELECT docId, platform, title, fingerprint FROM conversations ORDER BY docId ASC").all();
1189
+ return rows.map((r) => ({ id: r.docId, platform: r.platform, title: r.title, fingerprint: r.fingerprint }));
1190
+ }
1152
1191
  /**
1153
1192
  * Run `fn` inside a transaction, rolling back if it throws. node:sqlite has
1154
1193
  * no transaction() wrapper, so this is the one place BEGIN/COMMIT/ROLLBACK
@@ -1238,7 +1277,8 @@ var SearchIndex = class {
1238
1277
  pinned: conversation.pinned ? 1 : 0,
1239
1278
  accountId: conversation.accountId ?? null,
1240
1279
  accountLabel: conversation.accountLabel ?? null,
1241
- models: conversation.models?.length ? JSON.stringify(conversation.models) : null
1280
+ models: conversation.models?.length ? JSON.stringify(conversation.models) : null,
1281
+ fingerprint: fingerprintConversation(rec)
1242
1282
  });
1243
1283
  }
1244
1284
  insertFtsRow(docId, msgIndex, role, text) {
@@ -2110,6 +2150,7 @@ export {
2110
2150
  ingestFile,
2111
2151
  pendingInboxFiles,
2112
2152
  drainInbox,
2153
+ fingerprintConversation,
2113
2154
  SearchIndex,
2114
2155
  openSearchIndex,
2115
2156
  deleteIndexDb,
@@ -38,7 +38,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
38
38
  ));
39
39
 
40
40
  // src/version.ts
41
- var VERSION = true ? "0.2.8" : "0.0.0-dev";
41
+ var VERSION = true ? "0.2.9" : "0.0.0-dev";
42
42
 
43
43
  export {
44
44
  __require,
@@ -17,13 +17,13 @@ import {
17
17
  runtimeBinDir,
18
18
  staleIndexFiles,
19
19
  uninstallRuntime
20
- } from "./chunk-FIH7ZNYH.js";
20
+ } from "./chunk-BWEDXPNM.js";
21
21
  import {
22
22
  VERSION,
23
23
  __commonJS,
24
24
  __require,
25
25
  __toESM
26
- } from "./chunk-RJJRIPHA.js";
26
+ } from "./chunk-YT6GNKKG.js";
27
27
 
28
28
  // ../node_modules/isexe/windows.js
29
29
  var require_windows = __commonJS({
@@ -543,6 +543,7 @@ var STORE_EXTENSION_ID = "leekfgbdojiaabifbjbbgiiclannjdkf";
543
543
  var here = dirname(fileURLToPath(import.meta.url));
544
544
  var home = homedir();
545
545
  var CLAUDE_PROJECTS_DIR = join(home, ".claude", "projects");
546
+ var ZCODE_DB = join(home, ".zcode", "cli", "db", "db.sqlite");
546
547
  var osPlatform = process.env.LLMNESIA_FAKE_PLATFORM || platform();
547
548
  function expandHome(p) {
548
549
  if (p === "~") {
@@ -708,7 +709,7 @@ async function installNativeHost({ ids = [], corpus, codexHome, force = false, q
708
709
  log("\u2713 Helper installed");
709
710
  const manifestBase = {
710
711
  name: HOST_NAME,
711
- description: "LLMnesia \u2014 reads local Claude Code and Codex sessions",
712
+ description: "LLMnesia: reads local Claude Code, Codex, and ZCode sessions",
712
713
  type: "stdio",
713
714
  allowed_origins: allowedOrigins
714
715
  };
@@ -742,9 +743,10 @@ async function installNativeHost({ ids = [], corpus, codexHome, force = false, q
742
743
  log(` Conversation data: ${corpusRoot}`);
743
744
  log(` Codex data: ${resolvedCodexHome}`);
744
745
  }
745
- const [hasClaudeCode, hasCodex] = await Promise.all([
746
+ const [hasClaudeCode, hasCodex, hasZcode] = await Promise.all([
746
747
  exists(CLAUDE_PROJECTS_DIR),
747
- exists(join(resolvedCodexHome, "sessions"))
748
+ exists(join(resolvedCodexHome, "sessions")),
749
+ exists(ZCODE_DB)
748
750
  ]);
749
751
  log("");
750
752
  log(
@@ -753,6 +755,9 @@ async function installNativeHost({ ids = [], corpus, codexHome, force = false, q
753
755
  log(
754
756
  hasCodex ? `\u2713 Found Codex sessions at ${resolvedCodexHome}` : `\u2013 No Codex sessions found yet (fine if you don't use Codex).`
755
757
  );
758
+ log(
759
+ hasZcode ? `\u2713 Found ZCode sessions at ${ZCODE_DB}` : `\u2013 No ZCode sessions found yet (fine if you don't use ZCode).`
760
+ );
756
761
  const extraIdCount = allIds.length - 1;
757
762
  if (extraIdCount > 0) {
758
763
  log(`\u2713 Also authorized ${extraIdCount} unpacked extension ${extraIdCount === 1 ? "build" : "builds"}`);
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-RJJRIPHA.js";
6
+ } from "./chunk-YT6GNKKG.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-D2IVH5S2.js");
144
+ const { main } = await import("./cli-KHY7ADCU.js");
145
145
  return main();
146
146
  }
147
147
  bootstrap().then((code) => {
@@ -1,34 +1,40 @@
1
1
  #!/usr/bin/env node
2
2
  // LLMnesia native messaging host — reads local coding-agent sessions (Claude
3
- // Code from ~/.claude/projects, Codex from the configured Codex home) and
4
- // streams them to the LLMnesia browser extension, and writes corpus sync deltas
5
- // the extension sends back into the LLMnesia corpus's inbox/ directory (see
6
- // packages/../companion/src/paths.ts).
3
+ // Code from ~/.claude/projects, Codex from the configured Codex home, ZCode
4
+ // from ~/.zcode/cli/db/db.sqlite) and streams them to the LLMnesia browser
5
+ // extension, and writes corpus sync deltas the extension sends back into the
6
+ // LLMnesia corpus's inbox/ directory (see packages/../companion/src/paths.ts).
7
7
  //
8
- // It reads .jsonl session files under those two roots, and writes only inside
9
- // the corpus root named in its own config (~/.llmnesia/config.json) — never a
10
- // path the caller supplies. It makes no network calls. The browser launches it
11
- // on demand (see the installed com.llmnesia.claude_code.json manifest) and
12
- // talks to it over stdio using Chrome's native-messaging framing: a
13
- // little-endian uint32 byte-length prefix followed by that many bytes of UTF-8
14
- // JSON, in both directions.
8
+ // It reads .jsonl session files under the first two roots, materializes ZCode
9
+ // sessions out of its SQLite database, and writes only inside the corpus root
10
+ // named in its own config (~/.llmnesia/config.json) — never a path the caller
11
+ // supplies. It makes no network calls. The browser launches it on demand (see
12
+ // the installed com.llmnesia.claude_code.json manifest) and talks to it over
13
+ // stdio using Chrome's native-messaging framing: a little-endian uint32
14
+ // byte-length prefix followed by that many bytes of UTF-8 JSON, in both
15
+ // directions.
15
16
  //
16
17
  // The write path only ever activates for a user who has explicitly run the
17
18
  // installer with --corpus (see install.mjs) to opt into the MCP companion;
18
19
  // without ~/.llmnesia/config.json this host is exactly as read-only as it
19
20
  // has always been for plain session indexing.
20
21
  //
21
- // Protocol v3 (extension → host). v1 was Claude-Code-only: `list` carried no
22
+ // Protocol v4 (extension → host). v1 was Claude-Code-only: `list` carried no
22
23
  // `source` and `get` took a bare sessionId. Both remain accepted, so an
23
24
  // extension older than this host keeps working; an extension NEWER than the
24
25
  // host detects v1 from the pong and syncs Claude Code only.
25
26
  //
27
+ // v4 adds ZCode to `list`/`get` (the first source backed by a SQLite database
28
+ // rather than per-session files; `get` materializes the transcript as JSONL).
29
+ // The version is informational in both directions, as below: a v3 host never
30
+ // lists zcode, and an extension gates the ZCode row on the reported version.
31
+ //
26
32
  // v3 adds `listMemories`, the RC-03 reverse channel. A v2 host answers it with
27
33
  // `{ type: "error", error: "unknown command: listMemories" }` and the extension
28
34
  // treats that as "nothing to fetch", so the version is informational: hosts are
29
35
  // installed out of band and an extension upgraded ahead of its host is the
30
36
  // ordinary case, not a fault.
31
- // { cmd: "ping" } → { type: "pong", version: 3 }
37
+ // { cmd: "ping" } → { type: "pong", version: 4 }
32
38
  // { cmd: "list" } → { type: "list", sessions:
33
39
  // [{source, sessionId, mtime, project?, title?}] }
34
40
  // { cmd: "get", source, sessionId } → one or more { type: "chunk", sessionId, seq, total, data }
@@ -53,14 +59,17 @@ import { chmod, mkdir, readdir, readFile, rename, stat, writeFile } from "node:f
53
59
  import { homedir } from "node:os";
54
60
  import { join } from "node:path";
55
61
 
56
- // 3 adds `listMemories` (RC-03). A v2 host answers that command with
57
- // `{ type: "error", error: "unknown command: listMemories" }`, which the
58
- // extension treats as "this host is too old, there is nothing to fetch" — so
59
- // bumping this is informational, not a gate. Do not make it a gate: hosts are
60
- // installed out of band and a version check would strand every user who
62
+ // 4 adds ZCode (see the protocol comment above). Do not make it a gate: hosts
63
+ // are installed out of band and a version check would strand every user who
61
64
  // upgraded the extension first.
62
- const PROTOCOL_VERSION = 3;
65
+ const PROTOCOL_VERSION = 4;
63
66
  const PROJECTS_DIR = join(homedir(), ".claude", "projects");
67
+ // ZCode keeps every session in one SQLite database (WAL mode) — the first
68
+ // source with no per-session file. The host opens it READ-ONLY and materializes
69
+ // a session into the JSONL shape extension/src/background/zcode_import.ts
70
+ // parses. node:sqlite is imported lazily so a Node without it (older than
71
+ // 22.13) keeps serving every other source: ZCode just lists zero sessions.
72
+ const ZCODE_DB = join(homedir(), ".zcode", "cli", "db", "db.sqlite");
64
73
  const CONFIG_FILE = join(homedir(), ".llmnesia", "config.json");
65
74
  // Codex's home, resolved at install time (--codex-home > CODEX_HOME > ~/.codex)
66
75
  // and persisted into config.json. The env var is read here too so a host
@@ -235,6 +244,192 @@ async function listCodexSessions(codexHome) {
235
244
  return sessions;
236
245
  }
237
246
 
247
+ // --- ZCode (SQLite source) -------------------------------------------------
248
+
249
+ let sqliteModule; // undefined = not yet imported, null = unavailable on this Node
250
+ async function getSqlite() {
251
+ if (sqliteModule === undefined) {
252
+ try {
253
+ sqliteModule = await import("node:sqlite");
254
+ } catch {
255
+ sqliteModule = null; // Node without node:sqlite (< 22.13): degrade quietly
256
+ }
257
+ }
258
+ return sqliteModule;
259
+ }
260
+
261
+ async function openZcodeDb() {
262
+ const sqlite = await getSqlite();
263
+ if (!sqlite) {
264
+ return null;
265
+ }
266
+ try {
267
+ // readOnly: the database is ZCode's live WAL database, often being written
268
+ // as we read it. Opening read-only fails cleanly when the file is absent.
269
+ return new sqlite.DatabaseSync(ZCODE_DB, { readOnly: true });
270
+ } catch {
271
+ return null; // no ZCode install, or the db is momentarily unreadable
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Interactive ZCode sessions. Subagent runs (`task_type = 'subagent_child'`)
277
+ * are the harness's internal workers, not chats the user searched for, so they
278
+ * stay out of the listing. `title_source = 'default'` marks a placeholder
279
+ * title ("New Session"); only real titles are attached, so the extension's
280
+ * derived "<project> · <first prompt>" still wins when ZCode has no name.
281
+ */
282
+ async function listZcodeSessions() {
283
+ const db = await openZcodeDb();
284
+ if (!db) {
285
+ return [];
286
+ }
287
+ try {
288
+ const rows = db
289
+ .prepare(
290
+ `SELECT id, title, title_source, time_updated, task_type
291
+ FROM session
292
+ WHERE task_type = 'interactive' OR task_type IS NULL`
293
+ )
294
+ .all();
295
+ const sessions = [];
296
+ for (const row of rows) {
297
+ const updated = Math.floor(Number(row.time_updated));
298
+ if (!Number.isFinite(updated) || updated <= 0) {
299
+ continue;
300
+ }
301
+ const session = {
302
+ source: "zcode",
303
+ sessionId: String(row.id),
304
+ mtime: updated,
305
+ path: ZCODE_DB
306
+ };
307
+ if (typeof row.title === "string" && row.title && row.title_source !== "default") {
308
+ session.title = row.title;
309
+ }
310
+ sessions.push(session);
311
+ }
312
+ return sessions;
313
+ } catch {
314
+ return []; // unreadable or mid-migration database — ZCode contributes nothing
315
+ } finally {
316
+ db.close();
317
+ }
318
+ }
319
+
320
+ /**
321
+ * Build one ZCode session's transcript as JSONL for the extension parser: a
322
+ * `session` header line naming the project directory, then one `message` line
323
+ * per user/assistant prose turn. Only text parts survive; parts flagged
324
+ * `synthetic` (ZCode-injected bookkeeping such as `<task-notification>`) are
325
+ * dropped here and defensively re-checked in the parser. Multi-part messages
326
+ * are joined with a blank line, mirroring how the file-based parsers treat
327
+ * text blocks.
328
+ *
329
+ * Returns null when the session id is unknown (or node:sqlite is unavailable —
330
+ * a session that could not be listed cannot be fetched either).
331
+ */
332
+ async function materializeZcodeSession(sessionId) {
333
+ const db = await openZcodeDb();
334
+ if (!db) {
335
+ return null;
336
+ }
337
+ try {
338
+ const sessionRow = db.prepare("SELECT directory FROM session WHERE id = ?").get(sessionId);
339
+ if (!sessionRow) {
340
+ return null;
341
+ }
342
+ const lines = [];
343
+ const header = { type: "session" };
344
+ if (typeof sessionRow.directory === "string" && sessionRow.directory) {
345
+ header.directory = sessionRow.directory;
346
+ }
347
+ lines.push(JSON.stringify(header));
348
+
349
+ const rows = db
350
+ .prepare(
351
+ `SELECT m.id, m.time_created, m.data AS message_data, p.data AS part_data
352
+ FROM part p JOIN message m ON m.id = p.message_id
353
+ WHERE m.session_id = ?
354
+ ORDER BY m.sequence, p.sequence`
355
+ )
356
+ .all(sessionId);
357
+
358
+ // Rows arrive grouped by message (ORDER BY m.sequence, p.sequence), so a
359
+ // message's parts are contiguous: accumulate one outgoing line per group.
360
+ let currentId = null;
361
+ let current = null;
362
+ const flush = () => {
363
+ if (!current) {
364
+ return;
365
+ }
366
+ const text = current.texts.join("\n\n").trim();
367
+ if (text) {
368
+ const line = { type: "message", role: current.role, text };
369
+ if (current.timestamp !== undefined) {
370
+ line.timestamp = current.timestamp;
371
+ }
372
+ if (current.role === "assistant" && current.model) {
373
+ line.model = current.model;
374
+ }
375
+ lines.push(JSON.stringify(line));
376
+ }
377
+ current = null;
378
+ };
379
+ for (const row of rows) {
380
+ if (row.id !== currentId) {
381
+ flush();
382
+ currentId = row.id;
383
+ let meta = null;
384
+ try {
385
+ meta = JSON.parse(row.message_data);
386
+ } catch {
387
+ meta = null;
388
+ }
389
+ const role = meta && meta.role === "assistant" ? "assistant" : meta && meta.role === "user" ? "user" : null;
390
+ if (!role) {
391
+ current = null; // unknown shape — drop the whole message, keep the rest
392
+ continue;
393
+ }
394
+ const dataTs = meta && meta.time && typeof meta.time.created === "number" ? meta.time.created : undefined;
395
+ current = {
396
+ role,
397
+ texts: [],
398
+ timestamp:
399
+ dataTs !== undefined
400
+ ? dataTs
401
+ : typeof row.time_created === "number"
402
+ ? row.time_created
403
+ : undefined,
404
+ model: role === "assistant" && typeof meta.modelID === "string" && meta.modelID ? meta.modelID : null
405
+ };
406
+ }
407
+ if (!current) {
408
+ continue;
409
+ }
410
+ let part = null;
411
+ try {
412
+ part = JSON.parse(row.part_data);
413
+ } catch {
414
+ part = null;
415
+ }
416
+ if (!part || part.type !== "text" || part.synthetic === true || typeof part.text !== "string") {
417
+ continue;
418
+ }
419
+ const text = part.text.trim();
420
+ if (text) {
421
+ current.texts.push(text);
422
+ }
423
+ }
424
+ flush();
425
+ return lines.join("\n");
426
+ } catch {
427
+ return null;
428
+ } finally {
429
+ db.close();
430
+ }
431
+ }
432
+
238
433
  /**
239
434
  * Every session across every source. Sources are listed independently and
240
435
  * failures are contained per source (each helper swallows its own errors), so
@@ -244,11 +439,12 @@ async function listCodexSessions(codexHome) {
244
439
  async function listSessions() {
245
440
  const config = await loadConfig();
246
441
  const codexHome = config?.codexHome || DEFAULT_CODEX_HOME;
247
- const [claude, codex] = await Promise.all([
442
+ const [claude, codex, zcode] = await Promise.all([
248
443
  listClaudeCodeSessions().catch(() => []),
249
- listCodexSessions(codexHome).catch(() => [])
444
+ listCodexSessions(codexHome).catch(() => []),
445
+ listZcodeSessions().catch(() => [])
250
446
  ]);
251
- return [...claude, ...codex];
447
+ return [...claude, ...codex, ...zcode];
252
448
  }
253
449
 
254
450
  async function loadConfig() {
@@ -469,18 +665,31 @@ async function handle(message) {
469
665
  return;
470
666
  }
471
667
  if (message.cmd === "get") {
472
- const sessions = await listSessions();
473
668
  // Session ids are only unique WITHIN a source, so a v2 caller names both.
474
669
  // A v1 caller sends no source; everything it knows about is Claude Code, so
475
670
  // that's what an absent source resolves to — never a bare id match that
476
671
  // could hand back the wrong source's file.
477
672
  const wantSource = typeof message.source === "string" ? message.source : "claude_code";
478
- const match = sessions.find((s) => s.source === wantSource && s.sessionId === message.sessionId);
479
- if (!match) {
480
- send({ type: "session", sessionId: message.sessionId, notFound: true });
481
- return;
673
+ let text;
674
+ if (wantSource === "zcode") {
675
+ // No file to read: the transcript is materialized out of the database.
676
+ // The session id is only ever a bound query parameter, never a path
677
+ // component, so the trust boundary holds unchanged.
678
+ const materialized = await materializeZcodeSession(String(message.sessionId ?? ""));
679
+ if (materialized === null) {
680
+ send({ type: "session", sessionId: message.sessionId, notFound: true });
681
+ return;
682
+ }
683
+ text = materialized;
684
+ } else {
685
+ const sessions = await listSessions();
686
+ const match = sessions.find((s) => s.source === wantSource && s.sessionId === message.sessionId);
687
+ if (!match) {
688
+ send({ type: "session", sessionId: message.sessionId, notFound: true });
689
+ return;
690
+ }
691
+ text = await readFile(match.path, "utf8");
482
692
  }
483
- const text = await readFile(match.path, "utf8");
484
693
  const total = Math.max(1, Math.ceil(text.length / CHUNK_CHARS));
485
694
  for (let i = 0, seq = 0; seq < total; i += CHUNK_CHARS, seq++) {
486
695
  send({
Binary file
package/dist/mcp.js CHANGED
@@ -6,19 +6,20 @@ import {
6
6
  Corpus,
7
7
  describeOutcome,
8
8
  drainInbox,
9
+ fingerprintConversation,
9
10
  openSearchIndex,
10
11
  pendingInboxFiles,
11
12
  readNativeHostViewerExtensionId,
12
13
  resolveCorpusDir,
13
14
  runAutomaticMaintenance,
14
15
  scheduleSelfUpdate
15
- } from "./chunk-FIH7ZNYH.js";
16
+ } from "./chunk-BWEDXPNM.js";
16
17
  import {
17
18
  VERSION,
18
19
  __commonJS,
19
20
  __export,
20
21
  __toESM
21
- } from "./chunk-RJJRIPHA.js";
22
+ } from "./chunk-YT6GNKKG.js";
22
23
 
23
24
  // ../node_modules/ajv/dist/compile/codegen/code.js
24
25
  var require_code = __commonJS({
@@ -21141,6 +21142,109 @@ import { realpathSync } from "fs";
21141
21142
  import { fileURLToPath } from "url";
21142
21143
  import path from "path";
21143
21144
 
21145
+ // src/inventory.ts
21146
+ import { randomBytes } from "crypto";
21147
+ var InventoryCursorError = class extends Error {
21148
+ };
21149
+ var DEFAULT_INVENTORY_TTL_MS = 15 * 6e4;
21150
+ var MAX_INVENTORY_SNAPSHOTS = 8;
21151
+ var CURSOR_PREFIX = "inv1.";
21152
+ var DEFAULT_PAGE_LIMIT = 100;
21153
+ var MAX_PAGE_LIMIT = 100;
21154
+ var CURSOR_ERROR_MESSAGE = "The inventory cursor cannot be served: it is malformed, it has expired, it was evicted by newer scans, or the MCP server that created it was restarted. Begin a new scan by calling conversation_inventory again without a cursor.";
21155
+ var InventoryScanner = class {
21156
+ constructor(loadRows, options = {}) {
21157
+ this.loadRows = loadRows;
21158
+ this.options = options;
21159
+ }
21160
+ loadRows;
21161
+ options;
21162
+ snapshots = /* @__PURE__ */ new Map();
21163
+ page(cursor, limit) {
21164
+ const pageSize = Math.min(MAX_PAGE_LIMIT, Math.max(1, limit ?? DEFAULT_PAGE_LIMIT));
21165
+ if (cursor === void 0 || cursor === "") {
21166
+ return this.startScan(pageSize);
21167
+ }
21168
+ const { snapshotId, offset } = decodeCursor(cursor);
21169
+ const snapshot = this.snapshots.get(snapshotId);
21170
+ if (!snapshot || Date.now() - snapshot.createdAt > this.ttlMs()) {
21171
+ this.snapshots.delete(snapshotId);
21172
+ throw new InventoryCursorError(CURSOR_ERROR_MESSAGE);
21173
+ }
21174
+ return this.slice(snapshot, offset, pageSize);
21175
+ }
21176
+ /** Drop all snapshots (called when the owning service closes). */
21177
+ clear() {
21178
+ this.snapshots.clear();
21179
+ }
21180
+ startScan(pageSize) {
21181
+ const rows = this.loadRows();
21182
+ const now = Date.now();
21183
+ if (rows.length === 0) {
21184
+ return { items: [], total: 0, scanStartedAt: now, nextCursor: null };
21185
+ }
21186
+ const snapshot = {
21187
+ id: randomBytes(9).toString("base64url"),
21188
+ createdAt: now,
21189
+ rows
21190
+ };
21191
+ this.snapshots.set(snapshot.id, snapshot);
21192
+ this.evict(now);
21193
+ return this.slice(snapshot, 0, pageSize);
21194
+ }
21195
+ slice(snapshot, offset, pageSize) {
21196
+ const items = snapshot.rows.slice(offset, offset + pageSize);
21197
+ const end = offset + items.length;
21198
+ return {
21199
+ items,
21200
+ total: snapshot.rows.length,
21201
+ scanStartedAt: snapshot.createdAt,
21202
+ nextCursor: end < snapshot.rows.length ? encodeCursor(snapshot.id, end) : null
21203
+ };
21204
+ }
21205
+ ttlMs() {
21206
+ return this.options.ttlMs ?? DEFAULT_INVENTORY_TTL_MS;
21207
+ }
21208
+ evict(now) {
21209
+ const max = Math.max(1, this.options.maxSnapshots ?? MAX_INVENTORY_SNAPSHOTS);
21210
+ for (const [id, snapshot] of this.snapshots) {
21211
+ if (Date.now() - snapshot.createdAt > this.ttlMs()) {
21212
+ this.snapshots.delete(id);
21213
+ }
21214
+ }
21215
+ while (this.snapshots.size > max) {
21216
+ let oldestId;
21217
+ let oldestAt = now + 1;
21218
+ for (const [id, snapshot] of this.snapshots) {
21219
+ if (snapshot.createdAt < oldestAt) {
21220
+ oldestAt = snapshot.createdAt;
21221
+ oldestId = id;
21222
+ }
21223
+ }
21224
+ if (oldestId === void 0) break;
21225
+ this.snapshots.delete(oldestId);
21226
+ }
21227
+ }
21228
+ };
21229
+ function encodeCursor(snapshotId, offset) {
21230
+ const payload = Buffer.from(JSON.stringify({ s: snapshotId, o: offset }), "utf8");
21231
+ return `${CURSOR_PREFIX}${payload.toString("base64url")}`;
21232
+ }
21233
+ function decodeCursor(cursor) {
21234
+ if (cursor.startsWith(CURSOR_PREFIX)) {
21235
+ try {
21236
+ const parsed = JSON.parse(
21237
+ Buffer.from(cursor.slice(CURSOR_PREFIX.length), "base64url").toString("utf8")
21238
+ );
21239
+ if (typeof parsed?.s === "string" && parsed.s.length > 0 && typeof parsed?.o === "number" && Number.isInteger(parsed.o) && parsed.o >= 0) {
21240
+ return { snapshotId: parsed.s, offset: parsed.o };
21241
+ }
21242
+ } catch {
21243
+ }
21244
+ }
21245
+ throw new InventoryCursorError(CURSOR_ERROR_MESSAGE);
21246
+ }
21247
+
21144
21248
  // src/saveSchema.ts
21145
21249
  import { createHash } from "crypto";
21146
21250
  var saveMessageSchema = external_exports.object({
@@ -21413,6 +21517,8 @@ var CompanionService = class _CompanionService {
21413
21517
  // Serialize drains so concurrent reads queue behind one ingest rather than
21414
21518
  // racing to consume the same file (mirrors Corpus.enqueueWrite).
21415
21519
  refreshChain = Promise.resolve();
21520
+ /** Holds the bounded, in-memory snapshots behind inventoryPage. */
21521
+ inventoryScanner = new InventoryScanner(() => this.index.inventoryRows());
21416
21522
  /** Memories written since this process started; the per-session cap counts these. */
21417
21523
  memoriesWritten = 0;
21418
21524
  activeOperations = 0;
@@ -21530,6 +21636,23 @@ var CompanionService = class _CompanionService {
21530
21636
  getConversation(docId) {
21531
21637
  return this.corpus.get(docId);
21532
21638
  }
21639
+ /**
21640
+ * One page of the paginated conversation inventory (metadata only, never
21641
+ * transcripts). Passing no cursor starts a new scan: the inbox is drained so
21642
+ * the snapshot reflects the corpus as of now, then a consistent docId-sorted
21643
+ * snapshot is captured and held in memory. Passing a previous page's
21644
+ * nextCursor serves later pages from that same snapshot, so conversations
21645
+ * that arrive or change mid-scan cannot shuffle or rewrite earlier pages;
21646
+ * resume pages deliberately skip the drain, which would only cost latency.
21647
+ * Throws InventoryCursorError for a cursor that has expired or outlived the
21648
+ * process; the caller should begin a new scan.
21649
+ */
21650
+ async inventoryPage(options = {}) {
21651
+ if (!options.cursor) {
21652
+ await this.refresh();
21653
+ }
21654
+ return this.inventoryScanner.page(options.cursor, options.limit);
21655
+ }
21533
21656
  /** Recent conversations enriched with their cached summary when available. */
21534
21657
  async listRecent(n) {
21535
21658
  return this.listConversations(n, "recent");
@@ -21623,6 +21746,7 @@ var CompanionService = class _CompanionService {
21623
21746
  this.closed = true;
21624
21747
  if (this.maintenanceTimer) clearTimeout(this.maintenanceTimer);
21625
21748
  this.maintenanceTimer = void 0;
21749
+ this.inventoryScanner.clear();
21626
21750
  this.index.close();
21627
21751
  }
21628
21752
  };
@@ -21847,6 +21971,12 @@ async function runServe() {
21847
21971
  return errorResult(`No conversation found with id '${args.id}'.`);
21848
21972
  }
21849
21973
  return sourcedJsonResult({
21974
+ // The current fingerprint of the transcript being returned, matching
21975
+ // the `fingerprint` a conversation_inventory entry carries for the
21976
+ // same content. A caller diffing against an inventory snapshot taken
21977
+ // earlier can detect a change made after that snapshot by comparing
21978
+ // the two values.
21979
+ contentFingerprint: fingerprintConversation(record2),
21850
21980
  conversation: {
21851
21981
  ...record2.conversation,
21852
21982
  ...sourceLinks(record2.conversation.docId, record2.conversation.url)
@@ -21946,6 +22076,43 @@ async function runServe() {
21946
22076
  });
21947
22077
  })
21948
22078
  );
22079
+ server.registerTool(
22080
+ "conversation_inventory",
22081
+ {
22082
+ title: "Conversation inventory",
22083
+ description: "Read-only, paginated inventory of every conversation in the corpus as compact metadata: stable id, platform, title, and a versioned content fingerprint. Never returns transcripts. Built for services that periodically process the corpus: call it with no arguments to start a scan, then pass each page's nextCursor back as cursor until a page comes back without one. All pages of a scan reflect the same consistent snapshot taken when the scan started, so conversations that arrive or change mid-scan never shuffle the results; start a new scan (omit cursor) to see them. Compare ids and fingerprints against the previous scan and call get_conversation only for new or changed ids. Cursors expire and do not survive a server restart; on a cursor error, simply begin a new scan.",
22084
+ inputSchema: {
22085
+ cursor: external_exports.string().optional().describe("Opaque nextCursor from the previous page. Omit to start a new scan."),
22086
+ limit: external_exports.number().int().min(1).max(100).optional().describe("Items per page (default 100).")
22087
+ },
22088
+ annotations: {
22089
+ readOnlyHint: true,
22090
+ destructiveHint: false,
22091
+ idempotentHint: true,
22092
+ openWorldHint: false
22093
+ }
22094
+ },
22095
+ async (args) => withService(async (service2) => {
22096
+ try {
22097
+ const page = await service2.inventoryPage({ cursor: args.cursor, limit: args.limit });
22098
+ const payload = {
22099
+ count: page.items.length,
22100
+ total: page.total,
22101
+ scanStartedAt: page.scanStartedAt,
22102
+ items: page.items
22103
+ };
22104
+ if (page.nextCursor) {
22105
+ payload.nextCursor = page.nextCursor;
22106
+ }
22107
+ return jsonResult(payload);
22108
+ } catch (err) {
22109
+ if (err instanceof InventoryCursorError) {
22110
+ return errorResult(err.message);
22111
+ }
22112
+ throw err;
22113
+ }
22114
+ })
22115
+ );
21949
22116
  server.registerTool(
21950
22117
  "corpus_status",
21951
22118
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llmnesia/mcp",
3
- "version": "0.2.8",
3
+ "version": "0.2.9",
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.8",
6
+ "version": "0.2.9",
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.8",
12
+ "version": "0.2.9",
13
13
  "transport": {
14
14
  "type": "stdio"
15
15
  }