@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.
- package/LICENSE +21 -0
- package/README.md +153 -0
- package/THIRD_PARTY_NOTICES +205 -0
- package/dist/chunk-OBJD6H4A.js +883 -0
- package/dist/chunk-OBJD6H4A.js.map +1 -0
- package/dist/cli.js +2812 -0
- package/dist/cli.js.map +1 -0
- package/dist/llmnesia-claude-host.mjs +251 -0
- package/dist/mcp.js +21537 -0
- package/dist/mcp.js.map +1 -0
- package/package.json +67 -0
- package/server.json +18 -0
|
@@ -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
|
+
});
|