@llmnesia/mcp 0.1.0

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.
@@ -0,0 +1,251 @@
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).
6
+ //
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.
14
+ //
15
+ // The write path only ever activates for a user who has explicitly run the
16
+ // installer with --corpus (see install.mjs) to opt into the MCP companion;
17
+ // without ~/.llmnesia/config.json this host is exactly as read-only as it
18
+ // has always been for plain Claude Code indexing.
19
+ //
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 }
24
+ // then { type: "sessionDone", sessionId }
25
+ // (or { type: "session", sessionId, notFound: true })
26
+ // { cmd: "corpusInfo" } → { type: "corpusInfo", present, root? }
27
+ // { cmd: "putChunk", filename,
28
+ // seq, total, data } → { type: "chunkAck", seq }
29
+ // (writes <root>/inbox/<filename> once
30
+ // every chunk 0..total-1 has arrived)
31
+
32
+ import { chmod, mkdir, readdir, readFile, rename, stat, writeFile } from "node:fs/promises";
33
+ import { homedir } from "node:os";
34
+ import { join } from "node:path";
35
+
36
+ const PROJECTS_DIR = join(homedir(), ".claude", "projects");
37
+ const CONFIG_FILE = join(homedir(), ".llmnesia", "config.json");
38
+ // Conservative chunk size (chars). After JSON-escaping + any multibyte chars the
39
+ // on-wire message stays well under native messaging's 1 MB per-message ceiling.
40
+ const CHUNK_CHARS = 256 * 1024;
41
+ // Matches companion/src/corpus.ts DIR_MODE/FILE_MODE — the corpus holds
42
+ // plaintext conversation history, so writes here should be no more readable
43
+ // than the browser profile it was copied out of.
44
+ const DIR_MODE = 0o700;
45
+ const FILE_MODE = 0o600;
46
+
47
+ function send(message) {
48
+ const body = Buffer.from(JSON.stringify(message), "utf8");
49
+ const header = Buffer.alloc(4);
50
+ header.writeUInt32LE(body.length, 0);
51
+ process.stdout.write(header);
52
+ process.stdout.write(body);
53
+ }
54
+
55
+ async function listSessions() {
56
+ const sessions = [];
57
+ let projectDirs;
58
+ try {
59
+ projectDirs = await readdir(PROJECTS_DIR, { withFileTypes: true });
60
+ } catch {
61
+ return sessions; // no ~/.claude/projects yet
62
+ }
63
+ for (const entry of projectDirs) {
64
+ if (!entry.isDirectory()) {
65
+ continue;
66
+ }
67
+ const dir = join(PROJECTS_DIR, entry.name);
68
+ let files;
69
+ try {
70
+ files = await readdir(dir);
71
+ } catch {
72
+ continue;
73
+ }
74
+ for (const file of files) {
75
+ if (!file.endsWith(".jsonl")) {
76
+ continue;
77
+ }
78
+ const path = join(dir, file);
79
+ try {
80
+ const info = await stat(path);
81
+ sessions.push({
82
+ sessionId: file.replace(/\.jsonl$/, ""),
83
+ mtime: Math.floor(info.mtimeMs),
84
+ project: entry.name,
85
+ path
86
+ });
87
+ } catch {
88
+ // Skip unreadable files.
89
+ }
90
+ }
91
+ }
92
+ return sessions;
93
+ }
94
+
95
+ async function loadConfig() {
96
+ try {
97
+ const raw = await readFile(CONFIG_FILE, "utf8");
98
+ const parsed = JSON.parse(raw);
99
+ if (parsed && typeof parsed.corpusRoot === "string" && parsed.corpusRoot.length > 0) {
100
+ return { corpusRoot: parsed.corpusRoot };
101
+ }
102
+ return null;
103
+ } catch {
104
+ return null; // no config yet, or unreadable/invalid — treat as "not installed"
105
+ }
106
+ }
107
+
108
+ // A filename must be a bare basename: no separators, no "..", nothing that
109
+ // could walk outside the corpus's inbox/ directory. The host is the trust
110
+ // boundary here — the extension is not trusted to name a write target.
111
+ function sanitizeFilename(name) {
112
+ if (typeof name !== "string" || name.length === 0) {
113
+ return null;
114
+ }
115
+ if (name === "." || name === "..") {
116
+ return null;
117
+ }
118
+ if (name.includes("/") || name.includes("\\") || name.includes("..")) {
119
+ return null;
120
+ }
121
+ return name;
122
+ }
123
+
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.
130
+ const pendingWrites = new Map();
131
+
132
+ async function handlePutChunk(message) {
133
+ const { filename, seq, total, data } = message;
134
+ const safeName = sanitizeFilename(filename);
135
+ if (!safeName) {
136
+ send({ type: "error", error: `putChunk: rejected filename ${JSON.stringify(filename)}` });
137
+ return;
138
+ }
139
+ if (!Number.isInteger(seq) || !Number.isInteger(total) || seq < 0 || total < 1 || seq >= total) {
140
+ send({ type: "error", error: `putChunk: invalid seq/total for ${safeName}` });
141
+ return;
142
+ }
143
+ const config = await loadConfig();
144
+ if (!config) {
145
+ send({ type: "error", error: "putChunk: no corpus configured (run the installer)" });
146
+ return;
147
+ }
148
+
149
+ let entry = pendingWrites.get(safeName);
150
+ if (!entry || entry.total !== total) {
151
+ entry = { chunks: new Array(total).fill(""), receivedSeqs: new Set(), total };
152
+ pendingWrites.set(safeName, entry);
153
+ }
154
+ entry.chunks[seq] = typeof data === "string" ? data : "";
155
+ entry.receivedSeqs.add(seq);
156
+
157
+ if (entry.receivedSeqs.size < total) {
158
+ send({ type: "chunkAck", seq });
159
+ return;
160
+ }
161
+
162
+ pendingWrites.delete(safeName);
163
+ const inboxDir = join(config.corpusRoot, "inbox");
164
+ await mkdir(inboxDir, { recursive: true, mode: DIR_MODE });
165
+ await chmod(inboxDir, DIR_MODE).catch(() => {}); // mkdir's mode is masked by umask
166
+ const tmpPath = join(inboxDir, `${safeName}.part-${process.pid}`);
167
+ const finalPath = join(inboxDir, safeName);
168
+ await writeFile(tmpPath, entry.chunks.join(""), { encoding: "utf8", mode: FILE_MODE });
169
+ await rename(tmpPath, finalPath);
170
+ send({ type: "chunkAck", seq });
171
+ }
172
+
173
+ async function handle(message) {
174
+ if (!message || typeof message !== "object") {
175
+ return;
176
+ }
177
+ if (message.cmd === "ping") {
178
+ send({ type: "pong", version: 1 });
179
+ return;
180
+ }
181
+ if (message.cmd === "corpusInfo") {
182
+ const config = await loadConfig();
183
+ if (!config) {
184
+ send({ type: "corpusInfo", present: false });
185
+ return;
186
+ }
187
+ send({ type: "corpusInfo", present: true, root: config.corpusRoot });
188
+ return;
189
+ }
190
+ if (message.cmd === "putChunk") {
191
+ await handlePutChunk(message);
192
+ return;
193
+ }
194
+ if (message.cmd === "list") {
195
+ const sessions = await listSessions();
196
+ send({ type: "list", sessions: sessions.map(({ path, ...rest }) => rest) });
197
+ return;
198
+ }
199
+ if (message.cmd === "get") {
200
+ const sessions = await listSessions();
201
+ const match = sessions.find((s) => s.sessionId === message.sessionId);
202
+ if (!match) {
203
+ send({ type: "session", sessionId: message.sessionId, notFound: true });
204
+ return;
205
+ }
206
+ const text = await readFile(match.path, "utf8");
207
+ const total = Math.max(1, Math.ceil(text.length / CHUNK_CHARS));
208
+ for (let i = 0, seq = 0; seq < total; i += CHUNK_CHARS, seq++) {
209
+ send({
210
+ type: "chunk",
211
+ sessionId: message.sessionId,
212
+ seq,
213
+ total,
214
+ data: text.slice(i, i + CHUNK_CHARS)
215
+ });
216
+ }
217
+ send({ type: "sessionDone", sessionId: message.sessionId });
218
+ return;
219
+ }
220
+ send({ type: "error", error: `unknown command: ${String(message.cmd)}` });
221
+ }
222
+
223
+ // --- native-messaging stdin framing --------------------------------------
224
+ let inbox = Buffer.alloc(0);
225
+ let processing = Promise.resolve();
226
+
227
+ process.stdin.on("data", (data) => {
228
+ inbox = Buffer.concat([inbox, data]);
229
+ while (inbox.length >= 4) {
230
+ const length = inbox.readUInt32LE(0);
231
+ if (inbox.length < 4 + length) {
232
+ break;
233
+ }
234
+ const payload = inbox.subarray(4, 4 + length).toString("utf8");
235
+ inbox = inbox.subarray(4 + length);
236
+ let message;
237
+ try {
238
+ message = JSON.parse(payload);
239
+ } catch {
240
+ continue;
241
+ }
242
+ // Serialize handling so chunk ordering per session is preserved.
243
+ processing = processing
244
+ .then(() => handle(message))
245
+ .catch((error) => send({ type: "error", error: String((error && error.message) || error) }));
246
+ }
247
+ });
248
+
249
+ process.stdin.on("end", () => {
250
+ processing.finally(() => process.exit(0));
251
+ });