@llmnesia/mcp 0.1.2 → 0.1.5

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/dist/cli.js CHANGED
@@ -3,7 +3,19 @@ import { createRequire as __llmnesiaCreateRequire } from "node:module";
3
3
  const require = __llmnesiaCreateRequire(import.meta.url);
4
4
  import {
5
5
  VERSION
6
- } from "./chunk-BQM5Q4XX.js";
6
+ } from "./chunk-YVBAQ5VA.js";
7
+
8
+ // src/nodeDownload.ts
9
+ var NODE_DOWNLOAD_URLS = {
10
+ darwin: "https://www.llmnesia.com/download/node/macos",
11
+ win32: "https://www.llmnesia.com/download/node/windows",
12
+ other: "https://nodejs.org/en/download"
13
+ };
14
+ function nodeDownloadUrl(platform = process.platform) {
15
+ if (platform === "darwin") return NODE_DOWNLOAD_URLS.darwin;
16
+ if (platform === "win32") return NODE_DOWNLOAD_URLS.win32;
17
+ return NODE_DOWNLOAD_URLS.other;
18
+ }
7
19
 
8
20
  // src/nodeVersion.ts
9
21
  var MINIMUM_NODE_VERSION = "22.13.0";
@@ -24,15 +36,98 @@ function supportsNodeVersion(version) {
24
36
  }
25
37
  return true;
26
38
  }
27
- function unsupportedNodeMessage(version) {
28
- return `LLMnesia MCP requires Node.js ${MINIMUM_NODE_VERSION} or newer, but this command is running on Node.js ${version}.
29
39
 
30
- Install the current Node.js LTS release from https://nodejs.org/ and then run:
31
- npx -y @llmnesia/mcp@latest install
32
-
33
- npx cannot install Node.js for you. If \`npx\` itself is not found, Node.js is not installed yet.
40
+ // src/nodeUpgrade.ts
41
+ import { spawn } from "child_process";
42
+ import { createInterface } from "readline/promises";
43
+ var INSTALL_COMMAND = "npx -y @llmnesia/mcp@latest install";
44
+ function nodeDownloadOpenCommand(platform) {
45
+ const downloadUrl = nodeDownloadUrl(platform);
46
+ switch (platform) {
47
+ case "darwin":
48
+ return { command: "open", args: [downloadUrl] };
49
+ case "win32":
50
+ return { command: "cmd.exe", args: ["/d", "/s", "/c", "start", "", downloadUrl] };
51
+ case "linux":
52
+ return { command: "xdg-open", args: [downloadUrl] };
53
+ default:
54
+ return null;
55
+ }
56
+ }
57
+ async function openNodeDownloadPage(platform = process.platform) {
58
+ const spec = nodeDownloadOpenCommand(platform);
59
+ if (!spec) {
60
+ return false;
61
+ }
62
+ return new Promise((resolve) => {
63
+ let settled = false;
64
+ const finish = (opened) => {
65
+ if (!settled) {
66
+ settled = true;
67
+ resolve(opened);
68
+ }
69
+ };
70
+ try {
71
+ const child = spawn(spec.command, spec.args, {
72
+ detached: true,
73
+ stdio: "ignore",
74
+ windowsHide: true
75
+ });
76
+ child.once("error", () => finish(false));
77
+ child.once("spawn", () => {
78
+ child.unref();
79
+ finish(true);
80
+ });
81
+ } catch {
82
+ finish(false);
83
+ }
84
+ });
85
+ }
86
+ async function askInTerminal(prompt) {
87
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
88
+ try {
89
+ return await rl.question(prompt);
90
+ } finally {
91
+ rl.close();
92
+ }
93
+ }
94
+ function nextSteps(opened, platform) {
95
+ const downloadUrl = nodeDownloadUrl(platform);
96
+ return (opened ? `Opened ${downloadUrl}
97
+ ` : `Download the current Node.js LTS release: ${downloadUrl}
98
+ `) + `After installing it, reopen Terminal or PowerShell and run:
99
+ ${INSTALL_COMMAND}
34
100
  `;
35
101
  }
102
+ async function guideUnsupportedNodeVersion(version, options = {}) {
103
+ const interactive = options.interactive ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
104
+ const platform = options.platform ?? process.platform;
105
+ const ask = options.ask ?? askInTerminal;
106
+ const openDownload = options.openDownload ?? openNodeDownloadPage;
107
+ const writeOut = options.writeOut ?? ((text) => process.stdout.write(text));
108
+ const writeErr = options.writeErr ?? ((text) => process.stderr.write(text));
109
+ writeErr(
110
+ `LLMnesia MCP requires Node.js ${MINIMUM_NODE_VERSION} or newer, but this command is running on Node.js ${version}.
111
+ `
112
+ );
113
+ if (!interactive) {
114
+ writeOut(nextSteps(false, platform));
115
+ return;
116
+ }
117
+ let answer = "";
118
+ try {
119
+ answer = await ask("Open the Node.js installer now? [Y/n] ");
120
+ } catch {
121
+ writeOut(nextSteps(false, platform));
122
+ return;
123
+ }
124
+ if (answer.trim() !== "" && !/^y(es)?$/i.test(answer.trim())) {
125
+ writeOut(nextSteps(false, platform));
126
+ return;
127
+ }
128
+ const opened = await openDownload(platform);
129
+ writeOut(nextSteps(opened, platform));
130
+ }
36
131
 
37
132
  // src/bootstrap.ts
38
133
  async function bootstrap() {
@@ -43,10 +138,10 @@ async function bootstrap() {
43
138
  return 0;
44
139
  }
45
140
  if (!supportsNodeVersion(process.versions.node)) {
46
- process.stderr.write(unsupportedNodeMessage(process.versions.node));
141
+ await guideUnsupportedNodeVersion(process.versions.node);
47
142
  return 1;
48
143
  }
49
- const { main } = await import("./cli-CNIF4XT3.js");
144
+ const { main } = await import("./cli-TVE63AWC.js");
50
145
  return main();
51
146
  }
52
147
  bootstrap().then((code) => {
@@ -1,26 +1,31 @@
1
1
  #!/usr/bin/env node
2
- // LLMnesia native messaging host — reads local Claude Code sessions from
3
- // ~/.claude/projects and streams them to the LLMnesia browser extension, and
4
- // writes corpus sync deltas the extension sends back into the LLMnesia
5
- // corpus's inbox/ directory (see packages/../companion/src/paths.ts).
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).
6
7
  //
7
- // It reads .jsonl session files under ~/.claude/projects, and writes only
8
- // inside the corpus root named in its own config (~/.llmnesia/config.json) —
9
- // never a path the caller supplies. It makes no network calls. The browser
10
- // launches it on demand (see the installed com.llmnesia.claude_code.json
11
- // manifest) and talks to it over stdio using Chrome's native-messaging
12
- // framing: a little-endian uint32 byte-length prefix followed by that many
13
- // bytes of UTF-8 JSON, in both directions.
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.
14
15
  //
15
16
  // The write path only ever activates for a user who has explicitly run the
16
17
  // installer with --corpus (see install.mjs) to opt into the MCP companion;
17
18
  // without ~/.llmnesia/config.json this host is exactly as read-only as it
18
- // has always been for plain Claude Code indexing.
19
+ // has always been for plain session indexing.
19
20
  //
20
- // Protocol (extension → host):
21
- // { cmd: "ping" } → { type: "pong", version }
22
- // { cmd: "list" } → { type: "list", sessions: [{sessionId, mtime, project}] }
23
- // { cmd: "get", sessionId } → one or more { type: "chunk", sessionId, seq, total, data }
21
+ // Protocol v2 (extension → host). v1 was Claude-Code-only: `list` carried no
22
+ // `source` and `get` took a bare sessionId. Both remain accepted, so an
23
+ // extension older than this host keeps working; an extension NEWER than the
24
+ // host detects v1 from the pong and syncs Claude Code only.
25
+ // { cmd: "ping" } → { type: "pong", version: 2 }
26
+ // { cmd: "list" } → { type: "list", sessions:
27
+ // [{source, sessionId, mtime, project?, title?}] }
28
+ // { cmd: "get", source, sessionId } → one or more { type: "chunk", sessionId, seq, total, data }
24
29
  // then { type: "sessionDone", sessionId }
25
30
  // (or { type: "session", sessionId, notFound: true })
26
31
  // { cmd: "corpusInfo" } → { type: "corpusInfo", present, root? }
@@ -28,13 +33,25 @@
28
33
  // seq, total, data } → { type: "chunkAck", seq }
29
34
  // (writes <root>/inbox/<filename> once
30
35
  // every chunk 0..total-1 has arrived)
36
+ //
37
+ // The extension never names a path in either direction: it asks for a
38
+ // (source, sessionId) pair and the host resolves it against its own roots. That
39
+ // is what keeps a compromised or buggy caller from reading arbitrary files.
31
40
 
32
41
  import { chmod, mkdir, readdir, readFile, rename, stat, writeFile } from "node:fs/promises";
33
42
  import { homedir } from "node:os";
34
43
  import { join } from "node:path";
35
44
 
45
+ const PROTOCOL_VERSION = 2;
36
46
  const PROJECTS_DIR = join(homedir(), ".claude", "projects");
37
47
  const CONFIG_FILE = join(homedir(), ".llmnesia", "config.json");
48
+ // Codex's home, resolved at install time (--codex-home > CODEX_HOME > ~/.codex)
49
+ // and persisted into config.json. The env var is read here too so a host
50
+ // installed before Codex support still honours a non-default CODEX_HOME.
51
+ const DEFAULT_CODEX_HOME = process.env.CODEX_HOME || join(homedir(), ".codex");
52
+ // Codex nests rollouts as sessions/<year>/<month>/<day>/*.jsonl. Bounded so a
53
+ // misconfigured codexHome can't turn into an unbounded filesystem walk.
54
+ const CODEX_MAX_DEPTH = 3;
38
55
  // Conservative chunk size (chars). After JSON-escaping + any multibyte chars the
39
56
  // on-wire message stays well under native messaging's 1 MB per-message ceiling.
40
57
  const CHUNK_CHARS = 256 * 1024;
@@ -43,6 +60,15 @@ const CHUNK_CHARS = 256 * 1024;
43
60
  // than the browser profile it was copied out of.
44
61
  const DIR_MODE = 0o700;
45
62
  const FILE_MODE = 0o600;
63
+ // Defend the host process from malformed or compromised callers. The extension
64
+ // currently sends 256 KiB chunks and waits for each ack, so these limits leave
65
+ // ample headroom without permitting unbounded arrays or abandoned transfers.
66
+ const MAX_PUT_CHUNK_CHARS = 512 * 1024;
67
+ const MAX_PUT_CHUNKS = 256;
68
+ const MAX_PUT_TRANSFER_CHARS = 64 * 1024 * 1024;
69
+ const MAX_PENDING_WRITE_CHARS = 64 * 1024 * 1024;
70
+ const MAX_PENDING_WRITES = 8;
71
+ const PENDING_WRITE_TTL_MS = 5 * 60 * 1000;
46
72
 
47
73
  function send(message) {
48
74
  const body = Buffer.from(JSON.stringify(message), "utf8");
@@ -52,7 +78,11 @@ function send(message) {
52
78
  process.stdout.write(body);
53
79
  }
54
80
 
55
- async function listSessions() {
81
+ // --- session discovery ----------------------------------------------------
82
+ // One entry per session: { source, sessionId, mtime, project?, title?, path }.
83
+ // `path` is stripped before anything goes on the wire.
84
+
85
+ async function listClaudeCodeSessions() {
56
86
  const sessions = [];
57
87
  let projectDirs;
58
88
  try {
@@ -79,6 +109,7 @@ async function listSessions() {
79
109
  try {
80
110
  const info = await stat(path);
81
111
  sessions.push({
112
+ source: "claude_code",
82
113
  sessionId: file.replace(/\.jsonl$/, ""),
83
114
  mtime: Math.floor(info.mtimeMs),
84
115
  project: entry.name,
@@ -92,14 +123,128 @@ async function listSessions() {
92
123
  return sessions;
93
124
  }
94
125
 
126
+ // Codex names rollouts `rollout-<ISO timestamp>-<uuid>.jsonl`; the trailing
127
+ // UUID is the thread id (it matches session_meta.payload.id). Mirrors
128
+ // codexSessionIdFromFilename in the extension.
129
+ const CODEX_UUID_RE = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
130
+
131
+ function codexSessionId(filename) {
132
+ const base = filename.replace(/\.jsonl$/i, "");
133
+ const match = CODEX_UUID_RE.exec(base);
134
+ return match ? match[1].toLowerCase() : base;
135
+ }
136
+
137
+ async function walkCodexDir(dir, depth, out) {
138
+ let entries;
139
+ try {
140
+ entries = await readdir(dir, { withFileTypes: true });
141
+ } catch {
142
+ return out; // missing or unreadable — nothing to add
143
+ }
144
+ for (const entry of entries) {
145
+ const path = join(dir, entry.name);
146
+ if (entry.isDirectory()) {
147
+ if (depth > 0) {
148
+ await walkCodexDir(path, depth - 1, out);
149
+ }
150
+ continue;
151
+ }
152
+ if (!entry.name.endsWith(".jsonl")) {
153
+ continue;
154
+ }
155
+ try {
156
+ const info = await stat(path);
157
+ out.push({
158
+ source: "codex",
159
+ sessionId: codexSessionId(entry.name),
160
+ mtime: Math.floor(info.mtimeMs),
161
+ path
162
+ });
163
+ } catch {
164
+ // Skip unreadable files.
165
+ }
166
+ }
167
+ return out;
168
+ }
169
+
170
+ // Codex's user-facing thread names, so search results read as the titles the
171
+ // user sees in the app rather than a derived "<project> · <first prompt>".
172
+ async function readCodexThreadNames(codexHome) {
173
+ const titles = new Map();
174
+ let raw;
175
+ try {
176
+ raw = await readFile(join(codexHome, "session_index.jsonl"), "utf8");
177
+ } catch {
178
+ return titles; // absent is normal — the extension falls back to a derived title
179
+ }
180
+ for (const line of raw.split("\n")) {
181
+ const trimmed = line.trim();
182
+ if (!trimmed) {
183
+ continue;
184
+ }
185
+ try {
186
+ const row = JSON.parse(trimmed);
187
+ if (row && typeof row.id === "string" && typeof row.thread_name === "string") {
188
+ const name = row.thread_name.trim();
189
+ if (name) {
190
+ titles.set(row.id.toLowerCase(), name);
191
+ }
192
+ }
193
+ } catch {
194
+ // Skip malformed lines (including a truncated final one).
195
+ }
196
+ }
197
+ return titles;
198
+ }
199
+
200
+ async function listCodexSessions(codexHome) {
201
+ const sessions = await walkCodexDir(join(codexHome, "sessions"), CODEX_MAX_DEPTH, []);
202
+ if (sessions.length === 0) {
203
+ return sessions;
204
+ }
205
+ const titles = await readCodexThreadNames(codexHome);
206
+ for (const session of sessions) {
207
+ const title = titles.get(session.sessionId);
208
+ if (title) {
209
+ session.title = title;
210
+ }
211
+ }
212
+ return sessions;
213
+ }
214
+
215
+ /**
216
+ * Every session across every source. Sources are listed independently and
217
+ * failures are contained per source (each helper swallows its own errors), so
218
+ * an absent or unreadable Codex home can never stop Claude Code from syncing,
219
+ * or vice versa.
220
+ */
221
+ async function listSessions() {
222
+ const config = await loadConfig();
223
+ const codexHome = config?.codexHome || DEFAULT_CODEX_HOME;
224
+ const [claude, codex] = await Promise.all([
225
+ listClaudeCodeSessions().catch(() => []),
226
+ listCodexSessions(codexHome).catch(() => [])
227
+ ]);
228
+ return [...claude, ...codex];
229
+ }
230
+
95
231
  async function loadConfig() {
96
232
  try {
97
233
  const raw = await readFile(CONFIG_FILE, "utf8");
98
234
  const parsed = JSON.parse(raw);
99
- if (parsed && typeof parsed.corpusRoot === "string" && parsed.corpusRoot.length > 0) {
100
- return { corpusRoot: parsed.corpusRoot };
235
+ if (!parsed || typeof parsed !== "object") {
236
+ return null;
101
237
  }
102
- return null;
238
+ const corpusRoot =
239
+ typeof parsed.corpusRoot === "string" && parsed.corpusRoot.length > 0 ? parsed.corpusRoot : null;
240
+ const codexHome =
241
+ typeof parsed.codexHome === "string" && parsed.codexHome.length > 0 ? parsed.codexHome : null;
242
+ // A config that names only codexHome is still useful for listing, but the
243
+ // corpus write path requires corpusRoot — callers check that field.
244
+ if (!corpusRoot && !codexHome) {
245
+ return null;
246
+ }
247
+ return { corpusRoot, codexHome };
103
248
  } catch {
104
249
  return null; // no config yet, or unreadable/invalid — treat as "not installed"
105
250
  }
@@ -121,14 +266,22 @@ function sanitizeFilename(name) {
121
266
  return name;
122
267
  }
123
268
 
124
- // filename → { chunks: string[], receivedSeqs: Set<number>, total } while a
125
- // multi-chunk putChunk transfer is in flight. Completeness is tracked via
126
- // receivedSeqs.size rather than scanning chunks for holes — `new Array(total)`
127
- // is sparse, and Array#some skips unset indices entirely, so a hole-scan would
128
- // silently call a partial transfer "complete" the moment the last-received
129
- // (not last-index) chunk lands.
269
+ // filename → { chunks: Map<number, string>, total, receivedChars, updatedAt }
270
+ // while a multi-chunk putChunk transfer is in flight. A Map avoids allocating
271
+ // from caller-controlled `total`, and lets duplicate chunks replace their old
272
+ // contribution without inflating the byte/character budget.
130
273
  const pendingWrites = new Map();
131
274
 
275
+ function prunePendingWrites(now) {
276
+ for (const [filename, entry] of pendingWrites) {
277
+ if (now - entry.updatedAt > PENDING_WRITE_TTL_MS) {
278
+ pendingWrites.delete(filename);
279
+ }
280
+ }
281
+ }
282
+
283
+ setInterval(() => prunePendingWrites(Date.now()), 60 * 1000).unref();
284
+
132
285
  async function handlePutChunk(message) {
133
286
  const { filename, seq, total, data } = message;
134
287
  const safeName = sanitizeFilename(filename);
@@ -136,25 +289,56 @@ async function handlePutChunk(message) {
136
289
  send({ type: "error", error: `putChunk: rejected filename ${JSON.stringify(filename)}` });
137
290
  return;
138
291
  }
139
- if (!Number.isInteger(seq) || !Number.isInteger(total) || seq < 0 || total < 1 || seq >= total) {
292
+ if (
293
+ !Number.isInteger(seq) ||
294
+ !Number.isInteger(total) ||
295
+ seq < 0 ||
296
+ total < 1 ||
297
+ total > MAX_PUT_CHUNKS ||
298
+ seq >= total
299
+ ) {
140
300
  send({ type: "error", error: `putChunk: invalid seq/total for ${safeName}` });
141
301
  return;
142
302
  }
303
+ if (typeof data !== "string" || data.length > MAX_PUT_CHUNK_CHARS) {
304
+ send({ type: "error", error: `putChunk: invalid or oversized data for ${safeName}` });
305
+ return;
306
+ }
143
307
  const config = await loadConfig();
144
- if (!config) {
308
+ if (!config?.corpusRoot) {
145
309
  send({ type: "error", error: "putChunk: no corpus configured (run the installer)" });
146
310
  return;
147
311
  }
148
312
 
313
+ const now = Date.now();
314
+ prunePendingWrites(now);
149
315
  let entry = pendingWrites.get(safeName);
150
316
  if (!entry || entry.total !== total) {
151
- entry = { chunks: new Array(total).fill(""), receivedSeqs: new Set(), total };
317
+ if (!entry && pendingWrites.size >= MAX_PENDING_WRITES) {
318
+ send({ type: "error", error: "putChunk: too many transfers in progress" });
319
+ return;
320
+ }
321
+ entry = { chunks: new Map(), total, receivedChars: 0, updatedAt: now };
152
322
  pendingWrites.set(safeName, entry);
153
323
  }
154
- entry.chunks[seq] = typeof data === "string" ? data : "";
155
- entry.receivedSeqs.add(seq);
324
+ const previous = entry.chunks.get(seq);
325
+ const nextReceivedChars = entry.receivedChars - (previous?.length ?? 0) + data.length;
326
+ if (nextReceivedChars > MAX_PUT_TRANSFER_CHARS) {
327
+ pendingWrites.delete(safeName);
328
+ send({ type: "error", error: `putChunk: transfer too large for ${safeName}` });
329
+ return;
330
+ }
331
+ const pendingChars = [...pendingWrites.values()].reduce((sum, pending) => sum + pending.receivedChars, 0);
332
+ if (pendingChars - (previous?.length ?? 0) + data.length > MAX_PENDING_WRITE_CHARS) {
333
+ pendingWrites.delete(safeName);
334
+ send({ type: "error", error: "putChunk: pending transfer budget exceeded" });
335
+ return;
336
+ }
337
+ entry.chunks.set(seq, data);
338
+ entry.receivedChars = nextReceivedChars;
339
+ entry.updatedAt = now;
156
340
 
157
- if (entry.receivedSeqs.size < total) {
341
+ if (entry.chunks.size < total) {
158
342
  send({ type: "chunkAck", seq });
159
343
  return;
160
344
  }
@@ -165,7 +349,8 @@ async function handlePutChunk(message) {
165
349
  await chmod(inboxDir, DIR_MODE).catch(() => {}); // mkdir's mode is masked by umask
166
350
  const tmpPath = join(inboxDir, `${safeName}.part-${process.pid}`);
167
351
  const finalPath = join(inboxDir, safeName);
168
- await writeFile(tmpPath, entry.chunks.join(""), { encoding: "utf8", mode: FILE_MODE });
352
+ const text = Array.from({ length: total }, (_, index) => entry.chunks.get(index) ?? "").join("");
353
+ await writeFile(tmpPath, text, { encoding: "utf8", mode: FILE_MODE });
169
354
  await rename(tmpPath, finalPath);
170
355
  send({ type: "chunkAck", seq });
171
356
  }
@@ -175,12 +360,12 @@ async function handle(message) {
175
360
  return;
176
361
  }
177
362
  if (message.cmd === "ping") {
178
- send({ type: "pong", version: 1 });
363
+ send({ type: "pong", version: PROTOCOL_VERSION });
179
364
  return;
180
365
  }
181
366
  if (message.cmd === "corpusInfo") {
182
367
  const config = await loadConfig();
183
- if (!config) {
368
+ if (!config?.corpusRoot) {
184
369
  send({ type: "corpusInfo", present: false });
185
370
  return;
186
371
  }
@@ -198,7 +383,12 @@ async function handle(message) {
198
383
  }
199
384
  if (message.cmd === "get") {
200
385
  const sessions = await listSessions();
201
- const match = sessions.find((s) => s.sessionId === message.sessionId);
386
+ // Session ids are only unique WITHIN a source, so a v2 caller names both.
387
+ // A v1 caller sends no source; everything it knows about is Claude Code, so
388
+ // that's what an absent source resolves to — never a bare id match that
389
+ // could hand back the wrong source's file.
390
+ const wantSource = typeof message.source === "string" ? message.source : "claude_code";
391
+ const match = sessions.find((s) => s.source === wantSource && s.sessionId === message.sessionId);
202
392
  if (!match) {
203
393
  send({ type: "session", sessionId: message.sessionId, notFound: true });
204
394
  return;
package/dist/mcp.js CHANGED
@@ -7,13 +7,13 @@ import {
7
7
  openSearchIndex,
8
8
  pendingInboxFiles,
9
9
  resolveCorpusDir
10
- } from "./chunk-7BW63ZTP.js";
10
+ } from "./chunk-3JQ7CGCP.js";
11
11
  import {
12
12
  VERSION,
13
13
  __commonJS,
14
14
  __export,
15
15
  __toESM
16
- } from "./chunk-BQM5Q4XX.js";
16
+ } from "./chunk-YVBAQ5VA.js";
17
17
 
18
18
  // ../node_modules/ajv/dist/compile/codegen/code.js
19
19
  var require_code = __commonJS({
@@ -21300,8 +21300,12 @@ var CompanionService = class _CompanionService {
21300
21300
  }
21301
21301
  /** Recent conversations enriched with their cached summary when available. */
21302
21302
  async listRecent(n) {
21303
+ return this.listConversations(n, "recent");
21304
+ }
21305
+ /** Conversations in the requested chronology, enriched with cached summaries. */
21306
+ async listConversations(n, sort) {
21303
21307
  await this.refresh();
21304
- const recent = this.index.listRecent(n);
21308
+ const recent = this.index.listConversations(n, sort);
21305
21309
  const entries = [];
21306
21310
  for (const row of recent) {
21307
21311
  const record2 = await this.corpus.get(row.docId);
@@ -21393,14 +21397,14 @@ async function runServe() {
21393
21397
  const server = new McpServer(
21394
21398
  { name: "llmnesia", version: VERSION },
21395
21399
  {
21396
- instructions: "Search and retrieve the user's own past AI conversations (ChatGPT, Claude, Gemini, etc.) from their local LLMnesia corpus. Use search_conversations to find relevant prior chats before answering from scratch, get_conversation to read a full transcript, and list_recent to see what they worked on lately. Search and list results carry a `corpus` block saying when the corpus last received data; if a chat the user expects is missing, check corpus_status before concluding it does not exist."
21400
+ instructions: "LLMnesia is the user's searchable archive of past AI chats. Whenever the user asks about 'my chats', 'my conversations', or anything they previously said, discussed, decided, or worked on, use LLMnesia before built-in task/thread lists, local file scans, or memory, unless they explicitly ask only about Codex tasks. Use list_conversations with sort='oldest' for their earliest chat, search_conversations to find relevant prior chats, get_conversation to read a full transcript, and list_recent to see what they worked on lately. Search and list results carry a `corpus` block saying when the corpus last received data; if a chat the user expects is missing, check corpus_status before concluding it does not exist."
21397
21401
  }
21398
21402
  );
21399
21403
  server.registerTool(
21400
21404
  "search_conversations",
21401
21405
  {
21402
21406
  title: "Search conversations",
21403
- description: "Full-text search across the user's past AI conversations. Returns matching conversations with a highlighted snippet, ranked by relevance. Defaults to recall-first matching (any term); pass match_mode to answer questions where a wrong hit matters, such as whether or how often something was discussed.",
21407
+ description: "Full-text search across the user's past AI conversations. Call this whenever the user asks what they said, thought, discussed, decided, or worked on previously \u2014 even if the question doesn't say 'search' \u2014 rather than answering from conversation memory alone or claiming no record exists. Returns matching conversations with a highlighted snippet, ranked by relevance. Defaults to recall-first matching (any term); pass match_mode to answer questions where a wrong hit matters, such as whether or how often something was discussed.",
21404
21408
  inputSchema: {
21405
21409
  query: external_exports.string().describe("Search terms (natural words; FTS operators are not needed)."),
21406
21410
  match_mode: external_exports.enum(["any", "all", "phrase"]).optional().describe(
@@ -21411,6 +21415,12 @@ async function runServe() {
21411
21415
  date_from: external_exports.string().optional().describe("Only conversations created on/after this date (ISO or epoch ms)."),
21412
21416
  date_to: external_exports.string().optional().describe("Only conversations created on/before this date (ISO or epoch ms)."),
21413
21417
  limit: external_exports.number().int().min(1).max(50).optional().describe("Max results (default 10).")
21418
+ },
21419
+ annotations: {
21420
+ readOnlyHint: true,
21421
+ destructiveHint: false,
21422
+ idempotentHint: true,
21423
+ openWorldHint: false
21414
21424
  }
21415
21425
  },
21416
21426
  async (args) => {
@@ -21449,6 +21459,12 @@ async function runServe() {
21449
21459
  description: "Return the full transcript of one conversation by its id (the `id` from search results).",
21450
21460
  inputSchema: {
21451
21461
  id: external_exports.string().describe("The conversation id (docId), e.g. 'claude:abc-123'.")
21462
+ },
21463
+ annotations: {
21464
+ readOnlyHint: true,
21465
+ destructiveHint: false,
21466
+ idempotentHint: true,
21467
+ openWorldHint: false
21452
21468
  }
21453
21469
  },
21454
21470
  async (args) => {
@@ -21474,6 +21490,12 @@ async function runServe() {
21474
21490
  description: "List the user's most recently updated conversations (title + summary), newest first.",
21475
21491
  inputSchema: {
21476
21492
  limit: external_exports.number().int().min(1).max(100).optional().describe("How many to return (default 10).")
21493
+ },
21494
+ annotations: {
21495
+ readOnlyHint: true,
21496
+ destructiveHint: false,
21497
+ idempotentHint: true,
21498
+ openWorldHint: false
21477
21499
  }
21478
21500
  },
21479
21501
  async (args) => {
@@ -21494,12 +21516,54 @@ async function runServe() {
21494
21516
  });
21495
21517
  }
21496
21518
  );
21519
+ server.registerTool(
21520
+ "list_conversations",
21521
+ {
21522
+ title: "List conversations by date",
21523
+ description: "List conversation metadata in chronological order. Use sort='oldest' for the user's earliest or oldest chat, sort='newest' for most recently created chats, and sort='recent' for most recently updated chats. This is the complete corpus chronology, so do not scan LLMnesia's private files with shell commands.",
21524
+ inputSchema: {
21525
+ sort: external_exports.enum(["recent", "oldest", "newest"]).optional().describe("Chronology: recent (default, updated), oldest (created), or newest (created)."),
21526
+ limit: external_exports.number().int().min(1).max(100).optional().describe("How many to return (default 10).")
21527
+ },
21528
+ annotations: {
21529
+ readOnlyHint: true,
21530
+ destructiveHint: false,
21531
+ idempotentHint: true,
21532
+ openWorldHint: false
21533
+ }
21534
+ },
21535
+ async (args) => {
21536
+ const entries = await service.listConversations(args.limit ?? 10, args.sort ?? "recent");
21537
+ const status = await service.corpusStatus({ refresh: false });
21538
+ return jsonResult({
21539
+ count: entries.length,
21540
+ sort: args.sort ?? "recent",
21541
+ corpus: corpusField(status),
21542
+ conversations: entries.map((e) => ({
21543
+ id: e.docId,
21544
+ title: e.title,
21545
+ platform: e.platform,
21546
+ createdAt: e.createdAt,
21547
+ updatedAt: e.updatedAt,
21548
+ messageCount: e.messageCount,
21549
+ pinned: e.pinned,
21550
+ summary: e.summary ?? e.preview
21551
+ }))
21552
+ });
21553
+ }
21554
+ );
21497
21555
  server.registerTool(
21498
21556
  "corpus_status",
21499
21557
  {
21500
21558
  title: "Corpus status",
21501
21559
  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.",
21502
- inputSchema: {}
21560
+ inputSchema: {},
21561
+ annotations: {
21562
+ readOnlyHint: true,
21563
+ destructiveHint: false,
21564
+ idempotentHint: true,
21565
+ openWorldHint: false
21566
+ }
21503
21567
  },
21504
21568
  async () => {
21505
21569
  const status = await service.corpusStatus();
@@ -21511,7 +21575,13 @@ async function runServe() {
21511
21575
  {
21512
21576
  title: "Save conversation",
21513
21577
  description: "Add or update a conversation in the user's local corpus. Provide platform, title, and the messages; identity is keyed on docId (or platform:sourceId), so re-saving the same conversation updates it in place.",
21514
- inputSchema: saveConversationSchema.shape
21578
+ inputSchema: saveConversationSchema.shape,
21579
+ annotations: {
21580
+ readOnlyHint: false,
21581
+ destructiveHint: false,
21582
+ idempotentHint: false,
21583
+ openWorldHint: false
21584
+ }
21515
21585
  },
21516
21586
  async (args) => {
21517
21587
  try {