@llmnesia/mcp 0.2.8 → 0.2.10
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 +69 -3
- package/dist/{chunk-RJJRIPHA.js → chunk-GLCLSBT6.js} +1 -1
- package/dist/{chunk-FIH7ZNYH.js → chunk-XRA6OJC5.js} +111 -18
- package/dist/{cli-D2IVH5S2.js → cli-2IIJ77ZQ.js} +10 -5
- package/dist/cli.js +2 -2
- package/dist/llmnesia-claude-host.mjs +290 -35
- package/dist/llmnesia.mcpb +0 -0
- package/dist/mcp.js +186 -12
- package/package.json +1 -1
- package/server.json +2 -2
package/README.md
CHANGED
|
@@ -182,24 +182,90 @@ quarantined input.
|
|
|
182
182
|
|
|
183
183
|
## Tools
|
|
184
184
|
|
|
185
|
-
Once registered, your MCP client gains
|
|
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
|
|
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-
|
|
6
|
+
} from "./chunk-GLCLSBT6.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 =
|
|
50
|
+
var INDEX_FORMAT_VERSION = 7;
|
|
51
51
|
function indexDbFilename(version) {
|
|
52
52
|
return version === 1 ? "llmnesia.db" : `llmnesia-v${version}.db`;
|
|
53
53
|
}
|
|
@@ -210,6 +210,20 @@ 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
|
+
}
|
|
220
|
+
if (c.providerWorkspace) {
|
|
221
|
+
out.providerWorkspace = {
|
|
222
|
+
workspaceId: c.providerWorkspace.workspaceId,
|
|
223
|
+
source: c.providerWorkspace.source,
|
|
224
|
+
observedAt: c.providerWorkspace.observedAt
|
|
225
|
+
};
|
|
226
|
+
}
|
|
213
227
|
if (c.models && c.models.length > 0) out.models = [...c.models];
|
|
214
228
|
assignIfDefined(out, "modelMetadataUpdatedAt", c.modelMetadataUpdatedAt);
|
|
215
229
|
if (c.summary) {
|
|
@@ -714,6 +728,17 @@ var EMPTY_REPORT = {
|
|
|
714
728
|
messages: 0,
|
|
715
729
|
malformedLines: 0
|
|
716
730
|
};
|
|
731
|
+
async function upsertIndexedConversation(corpus, record, index) {
|
|
732
|
+
const result = await corpus.upsert(record);
|
|
733
|
+
if (index) {
|
|
734
|
+
const saved = await corpus.getFromPlatform(record.conversation.platform, result.docId);
|
|
735
|
+
if (!saved) {
|
|
736
|
+
throw new Error(`Saved conversation '${result.docId}' could not be read for indexing`);
|
|
737
|
+
}
|
|
738
|
+
index.upsertConversation(saved);
|
|
739
|
+
}
|
|
740
|
+
return result;
|
|
741
|
+
}
|
|
717
742
|
async function ingestFile(corpus, file, index) {
|
|
718
743
|
const report = { ...EMPTY_REPORT };
|
|
719
744
|
let current = null;
|
|
@@ -723,11 +748,8 @@ async function ingestFile(corpus, file, index) {
|
|
|
723
748
|
return;
|
|
724
749
|
}
|
|
725
750
|
const record = { conversation: current, messages: buffered };
|
|
726
|
-
const result = await corpus
|
|
751
|
+
const result = await upsertIndexedConversation(corpus, record, index);
|
|
727
752
|
tally(report, result);
|
|
728
|
-
if (index && result.outcome !== "skipped") {
|
|
729
|
-
index.upsertConversation(record);
|
|
730
|
-
}
|
|
731
753
|
report.conversations += 1;
|
|
732
754
|
report.messages += buffered.length;
|
|
733
755
|
current = null;
|
|
@@ -764,9 +786,9 @@ async function ingestFile(corpus, file, index) {
|
|
|
764
786
|
}
|
|
765
787
|
continue;
|
|
766
788
|
}
|
|
767
|
-
if (type === "message"
|
|
789
|
+
if (type === "message") {
|
|
768
790
|
const docId = typeof record.docId === "string" ? record.docId : "";
|
|
769
|
-
if (docId !== current.docId) {
|
|
791
|
+
if (!current || docId !== current.docId) {
|
|
770
792
|
report.malformedLines += 1;
|
|
771
793
|
continue;
|
|
772
794
|
}
|
|
@@ -940,6 +962,26 @@ function accumulate(total, report) {
|
|
|
940
962
|
total.malformedLines += report.malformedLines;
|
|
941
963
|
}
|
|
942
964
|
|
|
965
|
+
// src/fingerprint.ts
|
|
966
|
+
import { createHash as createHash2 } from "crypto";
|
|
967
|
+
var FINGERPRINT_SCHEME = "fp1";
|
|
968
|
+
function fingerprintConversation(record) {
|
|
969
|
+
const conversation = record.conversation;
|
|
970
|
+
const payload = {
|
|
971
|
+
v: 1,
|
|
972
|
+
title: conversation.title,
|
|
973
|
+
localTitle: conversation.localTitle ?? null,
|
|
974
|
+
messages: [...record.messages].sort((a, b) => a.msgIndex - b.msgIndex).map((message) => ({
|
|
975
|
+
i: message.msgIndex,
|
|
976
|
+
r: message.role,
|
|
977
|
+
t: message.text,
|
|
978
|
+
f: message.formattedText ?? null
|
|
979
|
+
}))
|
|
980
|
+
};
|
|
981
|
+
const digest = createHash2("sha256").update(JSON.stringify(payload), "utf8").digest("hex");
|
|
982
|
+
return `${FINGERPRINT_SCHEME}:${digest}`;
|
|
983
|
+
}
|
|
984
|
+
|
|
943
985
|
// src/searchIndex.ts
|
|
944
986
|
import { promises as fs3, chmodSync, closeSync, openSync } from "fs";
|
|
945
987
|
import { createRequire } from "module";
|
|
@@ -1017,7 +1059,10 @@ CREATE TABLE IF NOT EXISTS conversations (
|
|
|
1017
1059
|
pinned INTEGER NOT NULL,
|
|
1018
1060
|
accountId TEXT,
|
|
1019
1061
|
accountLabel TEXT,
|
|
1020
|
-
|
|
1062
|
+
providerProject TEXT,
|
|
1063
|
+
providerWorkspace TEXT,
|
|
1064
|
+
models TEXT,
|
|
1065
|
+
fingerprint TEXT NOT NULL
|
|
1021
1066
|
);
|
|
1022
1067
|
CREATE INDEX IF NOT EXISTS idx_conversations_platform ON conversations(platform);
|
|
1023
1068
|
CREATE INDEX IF NOT EXISTS idx_conversations_createdAt ON conversations(createdAt);
|
|
@@ -1085,14 +1130,14 @@ var SearchIndex = class {
|
|
|
1085
1130
|
deleteFts: this.db.prepare("DELETE FROM messages_fts WHERE rowid = ?"),
|
|
1086
1131
|
deleteMessageRows: this.db.prepare("DELETE FROM message_rows WHERE docId = ?"),
|
|
1087
1132
|
insertMessageRow: this.db.prepare(
|
|
1088
|
-
"INSERT INTO message_rows (docId, msgIndex, role) VALUES (?, ?, ?)"
|
|
1133
|
+
"INSERT INTO message_rows (docId, msgIndex, role) VALUES (?, ?, ?) RETURNING rowid"
|
|
1089
1134
|
),
|
|
1090
1135
|
insertFts: this.db.prepare("INSERT INTO messages_fts (rowid, text) VALUES (?, ?)"),
|
|
1091
1136
|
deleteConversation: this.db.prepare("DELETE FROM conversations WHERE docId = ?"),
|
|
1092
1137
|
insertConversation: this.db.prepare(
|
|
1093
1138
|
`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)`
|
|
1139
|
+
(docId, platform, title, url, createdAt, updatedAt, messageCount, preview, pinned, accountId, accountLabel, providerProject, providerWorkspace, models, fingerprint)
|
|
1140
|
+
VALUES (@docId, @platform, @title, @url, @createdAt, @updatedAt, @messageCount, @preview, @pinned, @accountId, @accountLabel, @providerProject, @providerWorkspace, @models, @fingerprint)`
|
|
1096
1141
|
),
|
|
1097
1142
|
deleteMemoryFts: this.db.prepare("DELETE FROM memories_fts WHERE memoryId = ?"),
|
|
1098
1143
|
insertMemoryFts: this.db.prepare(
|
|
@@ -1111,6 +1156,8 @@ var SearchIndex = class {
|
|
|
1111
1156
|
FROM temp.snippet_fts WHERE snippet_fts MATCH ?`
|
|
1112
1157
|
)
|
|
1113
1158
|
};
|
|
1159
|
+
this.stmts.selectFtsRows.setReadBigInts(true);
|
|
1160
|
+
this.stmts.insertMessageRow.setReadBigInts(true);
|
|
1114
1161
|
}
|
|
1115
1162
|
/** True when the stored format version was missing or stale — caller rebuilds. */
|
|
1116
1163
|
needsRebuild() {
|
|
@@ -1136,19 +1183,34 @@ var SearchIndex = class {
|
|
|
1136
1183
|
const orderBy = sort === "oldest" ? "createdAt ASC, updatedAt ASC" : sort === "newest" ? "createdAt DESC, updatedAt DESC" : "updatedAt DESC, createdAt DESC";
|
|
1137
1184
|
const rows = this.db.prepare(
|
|
1138
1185
|
`SELECT docId, platform, title, url, createdAt, updatedAt, messageCount, preview, pinned,
|
|
1139
|
-
accountId, accountLabel, models
|
|
1186
|
+
accountId, accountLabel, providerProject, providerWorkspace, models
|
|
1140
1187
|
FROM conversations ORDER BY ${orderBy} LIMIT ?`
|
|
1141
1188
|
).all(limit);
|
|
1142
1189
|
return rows.map((r) => {
|
|
1143
|
-
const { accountId, accountLabel, models, ...rest } = r;
|
|
1190
|
+
const { accountId, accountLabel, providerProject, providerWorkspace, models, ...rest } = r;
|
|
1144
1191
|
const entry = { ...rest, pinned: r.pinned === 1 };
|
|
1145
1192
|
if (accountId) entry.accountId = accountId;
|
|
1146
1193
|
if (accountLabel) entry.accountLabel = accountLabel;
|
|
1194
|
+
const project = parseObservation(providerProject);
|
|
1195
|
+
if (project) entry.providerProject = project;
|
|
1196
|
+
const workspace = parseObservation(providerWorkspace);
|
|
1197
|
+
if (workspace) entry.providerWorkspace = workspace;
|
|
1147
1198
|
const parsedModels = parseModels(models);
|
|
1148
1199
|
if (parsedModels.length > 0) entry.models = parsedModels;
|
|
1149
1200
|
return entry;
|
|
1150
1201
|
});
|
|
1151
1202
|
}
|
|
1203
|
+
/**
|
|
1204
|
+
* The full conversation inventory as compact metadata, sorted by stable
|
|
1205
|
+
* docId. One statement, so the caller's snapshot is a consistent cut of the
|
|
1206
|
+
* index (new conversations and edits that commit after this read belong to
|
|
1207
|
+
* the next scan), and no transcript is ever decompressed: fingerprints are
|
|
1208
|
+
* computed at upsert/rebuild time and stored in the index.
|
|
1209
|
+
*/
|
|
1210
|
+
inventoryRows() {
|
|
1211
|
+
const rows = this.db.prepare("SELECT docId, platform, title, fingerprint FROM conversations ORDER BY docId ASC").all();
|
|
1212
|
+
return rows.map((r) => ({ id: r.docId, platform: r.platform, title: r.title, fingerprint: r.fingerprint }));
|
|
1213
|
+
}
|
|
1152
1214
|
/**
|
|
1153
1215
|
* Run `fn` inside a transaction, rolling back if it throws. node:sqlite has
|
|
1154
1216
|
* no transaction() wrapper, so this is the one place BEGIN/COMMIT/ROLLBACK
|
|
@@ -1238,12 +1300,15 @@ var SearchIndex = class {
|
|
|
1238
1300
|
pinned: conversation.pinned ? 1 : 0,
|
|
1239
1301
|
accountId: conversation.accountId ?? null,
|
|
1240
1302
|
accountLabel: conversation.accountLabel ?? null,
|
|
1241
|
-
|
|
1303
|
+
providerProject: observationJson(conversation.providerProject),
|
|
1304
|
+
providerWorkspace: observationJson(conversation.providerWorkspace),
|
|
1305
|
+
models: conversation.models?.length ? JSON.stringify(conversation.models) : null,
|
|
1306
|
+
fingerprint: fingerprintConversation(rec)
|
|
1242
1307
|
});
|
|
1243
1308
|
}
|
|
1244
1309
|
insertFtsRow(docId, msgIndex, role, text) {
|
|
1245
|
-
const inserted = this.stmts.insertMessageRow.
|
|
1246
|
-
this.stmts.insertFts.run(inserted.
|
|
1310
|
+
const inserted = this.stmts.insertMessageRow.get(docId, msgIndex, role);
|
|
1311
|
+
this.stmts.insertFts.run(inserted.rowid, text);
|
|
1247
1312
|
}
|
|
1248
1313
|
deleteFtsRows(docId) {
|
|
1249
1314
|
const rows = this.stmts.selectFtsRows.all(docId);
|
|
@@ -1299,11 +1364,20 @@ var SearchIndex = class {
|
|
|
1299
1364
|
clauses.push("c.createdAt <= @dateTo");
|
|
1300
1365
|
params.dateTo = filters.dateTo;
|
|
1301
1366
|
}
|
|
1367
|
+
if (filters.providerProjectId) {
|
|
1368
|
+
clauses.push("json_extract(c.providerProject, '$.projectId') = @providerProjectId");
|
|
1369
|
+
params.providerProjectId = filters.providerProjectId;
|
|
1370
|
+
}
|
|
1371
|
+
if (filters.providerWorkspaceId) {
|
|
1372
|
+
clauses.push("json_extract(c.providerWorkspace, '$.workspaceId') = @providerWorkspaceId");
|
|
1373
|
+
params.providerWorkspaceId = filters.providerWorkspaceId;
|
|
1374
|
+
}
|
|
1302
1375
|
const scanCap = Math.min(2e3, Math.max(200, limit * 40));
|
|
1303
1376
|
const sql = `
|
|
1304
1377
|
SELECT
|
|
1305
1378
|
c.docId, c.platform, c.title, c.url, c.createdAt, c.updatedAt,
|
|
1306
|
-
c.messageCount, c.preview, c.pinned, c.accountId, c.accountLabel,
|
|
1379
|
+
c.messageCount, c.preview, c.pinned, c.accountId, c.accountLabel,
|
|
1380
|
+
c.providerProject, c.providerWorkspace, c.models,
|
|
1307
1381
|
m.msgIndex AS msgIndex, m.role AS role,
|
|
1308
1382
|
bm25(messages_fts) AS bm25
|
|
1309
1383
|
FROM messages_fts
|
|
@@ -1339,6 +1413,10 @@ var SearchIndex = class {
|
|
|
1339
1413
|
};
|
|
1340
1414
|
if (row.accountId) hit.accountId = row.accountId;
|
|
1341
1415
|
if (row.accountLabel) hit.accountLabel = row.accountLabel;
|
|
1416
|
+
const project = parseObservation(row.providerProject);
|
|
1417
|
+
if (project) hit.providerProject = project;
|
|
1418
|
+
const workspace = parseObservation(row.providerWorkspace);
|
|
1419
|
+
if (workspace) hit.providerWorkspace = workspace;
|
|
1342
1420
|
const models = parseModels(row.models);
|
|
1343
1421
|
if (models.length > 0) hit.models = models;
|
|
1344
1422
|
hits.push(hit);
|
|
@@ -1434,6 +1512,19 @@ function parseModels(value) {
|
|
|
1434
1512
|
return [];
|
|
1435
1513
|
}
|
|
1436
1514
|
}
|
|
1515
|
+
function observationJson(observation) {
|
|
1516
|
+
if (!observation) return null;
|
|
1517
|
+
return JSON.stringify(observation);
|
|
1518
|
+
}
|
|
1519
|
+
function parseObservation(value) {
|
|
1520
|
+
if (!value) return void 0;
|
|
1521
|
+
try {
|
|
1522
|
+
const parsed = JSON.parse(value);
|
|
1523
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
|
|
1524
|
+
} catch {
|
|
1525
|
+
return void 0;
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1437
1528
|
|
|
1438
1529
|
// src/storageMaintenance.ts
|
|
1439
1530
|
import { promises as fs4 } from "fs";
|
|
@@ -2107,9 +2198,11 @@ export {
|
|
|
2107
2198
|
resolveCorpusDir,
|
|
2108
2199
|
staleIndexFiles,
|
|
2109
2200
|
Corpus,
|
|
2201
|
+
upsertIndexedConversation,
|
|
2110
2202
|
ingestFile,
|
|
2111
2203
|
pendingInboxFiles,
|
|
2112
2204
|
drainInbox,
|
|
2205
|
+
fingerprintConversation,
|
|
2113
2206
|
SearchIndex,
|
|
2114
2207
|
openSearchIndex,
|
|
2115
2208
|
deleteIndexDb,
|
|
@@ -17,13 +17,13 @@ import {
|
|
|
17
17
|
runtimeBinDir,
|
|
18
18
|
staleIndexFiles,
|
|
19
19
|
uninstallRuntime
|
|
20
|
-
} from "./chunk-
|
|
20
|
+
} from "./chunk-XRA6OJC5.js";
|
|
21
21
|
import {
|
|
22
22
|
VERSION,
|
|
23
23
|
__commonJS,
|
|
24
24
|
__require,
|
|
25
25
|
__toESM
|
|
26
|
-
} from "./chunk-
|
|
26
|
+
} from "./chunk-GLCLSBT6.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
|
|
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-
|
|
6
|
+
} from "./chunk-GLCLSBT6.js";
|
|
7
7
|
|
|
8
8
|
// src/nodeDownload.ts
|
|
9
9
|
var NODE_DOWNLOAD_URLS = {
|
|
@@ -141,7 +141,7 @@ async function bootstrap() {
|
|
|
141
141
|
await guideUnsupportedNodeVersion(process.versions.node);
|
|
142
142
|
return 1;
|
|
143
143
|
}
|
|
144
|
-
const { main } = await import("./cli-
|
|
144
|
+
const { main } = await import("./cli-2IIJ77ZQ.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
|
|
4
|
-
// streams them to the LLMnesia browser
|
|
5
|
-
// the extension sends back into the
|
|
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
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// JSON, in both
|
|
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
|
|
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:
|
|
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
|
-
//
|
|
57
|
-
//
|
|
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 =
|
|
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
|
|
@@ -69,9 +78,29 @@ const DEFAULT_CODEX_HOME = process.env.CODEX_HOME || join(homedir(), ".codex");
|
|
|
69
78
|
// Codex nests rollouts as sessions/<year>/<month>/<day>/*.jsonl. Bounded so a
|
|
70
79
|
// misconfigured codexHome can't turn into an unbounded filesystem walk.
|
|
71
80
|
const CODEX_MAX_DEPTH = 3;
|
|
72
|
-
// Conservative chunk size
|
|
73
|
-
//
|
|
74
|
-
|
|
81
|
+
// Conservative chunk size. Native messaging caps a single message at 1 MB,
|
|
82
|
+
// and the wire size of a chunk is the JSON-ESCAPED UTF-8 length of `data`
|
|
83
|
+
// (an astral character is 4 raw bytes; a literal backslash doubles), so
|
|
84
|
+
// chunks are budgeted by measuring exactly that as they are built: a chunk
|
|
85
|
+
// can never push the framed message over the ceiling no matter what the
|
|
86
|
+
// transcript contains.
|
|
87
|
+
const MAX_CHUNK_ESCAPED_BYTES = 256 * 1024;
|
|
88
|
+
// JSON-escaped UTF-8 byte length of one UTF-16 code unit of `text` as
|
|
89
|
+
// JSON.stringify would emit it: quotes/backslashes and the short escapes
|
|
90
|
+
// double, other control characters become \u00XX (6 bytes), a lone surrogate
|
|
91
|
+
// becomes \udXXX, BMP non-ASCII passes through at its UTF-8 width, and a
|
|
92
|
+
// surrogate PAIR is one 4-byte astral character.
|
|
93
|
+
function escapedUnitBytes(unit, nextUnit) {
|
|
94
|
+
if (unit >= 0xd800 && unit <= 0xdbff) {
|
|
95
|
+
return nextUnit >= 0xdc00 && nextUnit <= 0xdfff ? 4 : 6; // astral char or lone high surrogate
|
|
96
|
+
}
|
|
97
|
+
if (unit >= 0xdc00 && unit <= 0xdfff) return 6; // lone low surrogate
|
|
98
|
+
if (unit === 0x22 || unit === 0x5c) return 2; // " and \
|
|
99
|
+
if (unit < 0x20) return [0x08, 0x09, 0x0a, 0x0c, 0x0d].includes(unit) ? 2 : 6;
|
|
100
|
+
if (unit < 0x80) return 1;
|
|
101
|
+
if (unit < 0x800) return 2;
|
|
102
|
+
return 3;
|
|
103
|
+
}
|
|
75
104
|
// Matches companion/src/corpus.ts DIR_MODE/FILE_MODE — the corpus holds
|
|
76
105
|
// plaintext conversation history, so writes here should be no more readable
|
|
77
106
|
// than the browser profile it was copied out of.
|
|
@@ -235,6 +264,192 @@ async function listCodexSessions(codexHome) {
|
|
|
235
264
|
return sessions;
|
|
236
265
|
}
|
|
237
266
|
|
|
267
|
+
// --- ZCode (SQLite source) -------------------------------------------------
|
|
268
|
+
|
|
269
|
+
let sqliteModule; // undefined = not yet imported, null = unavailable on this Node
|
|
270
|
+
async function getSqlite() {
|
|
271
|
+
if (sqliteModule === undefined) {
|
|
272
|
+
try {
|
|
273
|
+
sqliteModule = await import("node:sqlite");
|
|
274
|
+
} catch {
|
|
275
|
+
sqliteModule = null; // Node without node:sqlite (< 22.13): degrade quietly
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return sqliteModule;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function openZcodeDb() {
|
|
282
|
+
const sqlite = await getSqlite();
|
|
283
|
+
if (!sqlite) {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
try {
|
|
287
|
+
// readOnly: the database is ZCode's live WAL database, often being written
|
|
288
|
+
// as we read it. Opening read-only fails cleanly when the file is absent.
|
|
289
|
+
return new sqlite.DatabaseSync(ZCODE_DB, { readOnly: true });
|
|
290
|
+
} catch {
|
|
291
|
+
return null; // no ZCode install, or the db is momentarily unreadable
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Interactive ZCode sessions. Subagent runs (`task_type = 'subagent_child'`)
|
|
297
|
+
* are the harness's internal workers, not chats the user searched for, so they
|
|
298
|
+
* stay out of the listing. `title_source = 'default'` marks a placeholder
|
|
299
|
+
* title ("New Session"); only real titles are attached, so the extension's
|
|
300
|
+
* derived "<project> · <first prompt>" still wins when ZCode has no name.
|
|
301
|
+
*/
|
|
302
|
+
async function listZcodeSessions() {
|
|
303
|
+
const db = await openZcodeDb();
|
|
304
|
+
if (!db) {
|
|
305
|
+
return [];
|
|
306
|
+
}
|
|
307
|
+
try {
|
|
308
|
+
const rows = db
|
|
309
|
+
.prepare(
|
|
310
|
+
`SELECT id, title, title_source, time_updated, task_type
|
|
311
|
+
FROM session
|
|
312
|
+
WHERE task_type = 'interactive' OR task_type IS NULL`
|
|
313
|
+
)
|
|
314
|
+
.all();
|
|
315
|
+
const sessions = [];
|
|
316
|
+
for (const row of rows) {
|
|
317
|
+
const updated = Math.floor(Number(row.time_updated));
|
|
318
|
+
if (!Number.isFinite(updated) || updated <= 0) {
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
const session = {
|
|
322
|
+
source: "zcode",
|
|
323
|
+
sessionId: String(row.id),
|
|
324
|
+
mtime: updated,
|
|
325
|
+
path: ZCODE_DB
|
|
326
|
+
};
|
|
327
|
+
if (typeof row.title === "string" && row.title && row.title_source !== "default") {
|
|
328
|
+
session.title = row.title;
|
|
329
|
+
}
|
|
330
|
+
sessions.push(session);
|
|
331
|
+
}
|
|
332
|
+
return sessions;
|
|
333
|
+
} catch {
|
|
334
|
+
return []; // unreadable or mid-migration database — ZCode contributes nothing
|
|
335
|
+
} finally {
|
|
336
|
+
db.close();
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Build one ZCode session's transcript as JSONL for the extension parser: a
|
|
342
|
+
* `session` header line naming the project directory, then one `message` line
|
|
343
|
+
* per user/assistant prose turn. Only text parts survive; parts flagged
|
|
344
|
+
* `synthetic` (ZCode-injected bookkeeping such as `<task-notification>`) are
|
|
345
|
+
* dropped here and defensively re-checked in the parser. Multi-part messages
|
|
346
|
+
* are joined with a blank line, mirroring how the file-based parsers treat
|
|
347
|
+
* text blocks.
|
|
348
|
+
*
|
|
349
|
+
* Returns null when the session id is unknown (or node:sqlite is unavailable —
|
|
350
|
+
* a session that could not be listed cannot be fetched either).
|
|
351
|
+
*/
|
|
352
|
+
async function materializeZcodeSession(sessionId) {
|
|
353
|
+
const db = await openZcodeDb();
|
|
354
|
+
if (!db) {
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
try {
|
|
358
|
+
const sessionRow = db.prepare("SELECT directory FROM session WHERE id = ?").get(sessionId);
|
|
359
|
+
if (!sessionRow) {
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
const lines = [];
|
|
363
|
+
const header = { type: "session" };
|
|
364
|
+
if (typeof sessionRow.directory === "string" && sessionRow.directory) {
|
|
365
|
+
header.directory = sessionRow.directory;
|
|
366
|
+
}
|
|
367
|
+
lines.push(JSON.stringify(header));
|
|
368
|
+
|
|
369
|
+
const rows = db
|
|
370
|
+
.prepare(
|
|
371
|
+
`SELECT m.id, m.time_created, m.data AS message_data, p.data AS part_data
|
|
372
|
+
FROM part p JOIN message m ON m.id = p.message_id
|
|
373
|
+
WHERE m.session_id = ?
|
|
374
|
+
ORDER BY m.sequence, p.sequence`
|
|
375
|
+
)
|
|
376
|
+
.all(sessionId);
|
|
377
|
+
|
|
378
|
+
// Rows arrive grouped by message (ORDER BY m.sequence, p.sequence), so a
|
|
379
|
+
// message's parts are contiguous: accumulate one outgoing line per group.
|
|
380
|
+
let currentId = null;
|
|
381
|
+
let current = null;
|
|
382
|
+
const flush = () => {
|
|
383
|
+
if (!current) {
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
const text = current.texts.join("\n\n").trim();
|
|
387
|
+
if (text) {
|
|
388
|
+
const line = { type: "message", role: current.role, text };
|
|
389
|
+
if (current.timestamp !== undefined) {
|
|
390
|
+
line.timestamp = current.timestamp;
|
|
391
|
+
}
|
|
392
|
+
if (current.role === "assistant" && current.model) {
|
|
393
|
+
line.model = current.model;
|
|
394
|
+
}
|
|
395
|
+
lines.push(JSON.stringify(line));
|
|
396
|
+
}
|
|
397
|
+
current = null;
|
|
398
|
+
};
|
|
399
|
+
for (const row of rows) {
|
|
400
|
+
if (row.id !== currentId) {
|
|
401
|
+
flush();
|
|
402
|
+
currentId = row.id;
|
|
403
|
+
let meta = null;
|
|
404
|
+
try {
|
|
405
|
+
meta = JSON.parse(row.message_data);
|
|
406
|
+
} catch {
|
|
407
|
+
meta = null;
|
|
408
|
+
}
|
|
409
|
+
const role = meta && meta.role === "assistant" ? "assistant" : meta && meta.role === "user" ? "user" : null;
|
|
410
|
+
if (!role) {
|
|
411
|
+
current = null; // unknown shape — drop the whole message, keep the rest
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
const dataTs = meta && meta.time && typeof meta.time.created === "number" ? meta.time.created : undefined;
|
|
415
|
+
current = {
|
|
416
|
+
role,
|
|
417
|
+
texts: [],
|
|
418
|
+
timestamp:
|
|
419
|
+
dataTs !== undefined
|
|
420
|
+
? dataTs
|
|
421
|
+
: typeof row.time_created === "number"
|
|
422
|
+
? row.time_created
|
|
423
|
+
: undefined,
|
|
424
|
+
model: role === "assistant" && typeof meta.modelID === "string" && meta.modelID ? meta.modelID : null
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
if (!current) {
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
let part = null;
|
|
431
|
+
try {
|
|
432
|
+
part = JSON.parse(row.part_data);
|
|
433
|
+
} catch {
|
|
434
|
+
part = null;
|
|
435
|
+
}
|
|
436
|
+
if (!part || part.type !== "text" || part.synthetic === true || typeof part.text !== "string") {
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
const text = part.text.trim();
|
|
440
|
+
if (text) {
|
|
441
|
+
current.texts.push(text);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
flush();
|
|
445
|
+
return lines.join("\n");
|
|
446
|
+
} catch {
|
|
447
|
+
return null;
|
|
448
|
+
} finally {
|
|
449
|
+
db.close();
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
238
453
|
/**
|
|
239
454
|
* Every session across every source. Sources are listed independently and
|
|
240
455
|
* failures are contained per source (each helper swallows its own errors), so
|
|
@@ -244,11 +459,12 @@ async function listCodexSessions(codexHome) {
|
|
|
244
459
|
async function listSessions() {
|
|
245
460
|
const config = await loadConfig();
|
|
246
461
|
const codexHome = config?.codexHome || DEFAULT_CODEX_HOME;
|
|
247
|
-
const [claude, codex] = await Promise.all([
|
|
462
|
+
const [claude, codex, zcode] = await Promise.all([
|
|
248
463
|
listClaudeCodeSessions().catch(() => []),
|
|
249
|
-
listCodexSessions(codexHome).catch(() => [])
|
|
464
|
+
listCodexSessions(codexHome).catch(() => []),
|
|
465
|
+
listZcodeSessions().catch(() => [])
|
|
250
466
|
]);
|
|
251
|
-
return [...claude, ...codex];
|
|
467
|
+
return [...claude, ...codex, ...zcode];
|
|
252
468
|
}
|
|
253
469
|
|
|
254
470
|
async function loadConfig() {
|
|
@@ -469,26 +685,56 @@ async function handle(message) {
|
|
|
469
685
|
return;
|
|
470
686
|
}
|
|
471
687
|
if (message.cmd === "get") {
|
|
472
|
-
const sessions = await listSessions();
|
|
473
688
|
// Session ids are only unique WITHIN a source, so a v2 caller names both.
|
|
474
689
|
// A v1 caller sends no source; everything it knows about is Claude Code, so
|
|
475
690
|
// that's what an absent source resolves to — never a bare id match that
|
|
476
691
|
// could hand back the wrong source's file.
|
|
477
692
|
const wantSource = typeof message.source === "string" ? message.source : "claude_code";
|
|
478
|
-
|
|
479
|
-
if (
|
|
480
|
-
|
|
481
|
-
|
|
693
|
+
let text;
|
|
694
|
+
if (wantSource === "zcode") {
|
|
695
|
+
// No file to read: the transcript is materialized out of the database.
|
|
696
|
+
// The session id is only ever a bound query parameter, never a path
|
|
697
|
+
// component, so the trust boundary holds unchanged.
|
|
698
|
+
const materialized = await materializeZcodeSession(String(message.sessionId ?? ""));
|
|
699
|
+
if (materialized === null) {
|
|
700
|
+
send({ type: "session", sessionId: message.sessionId, notFound: true });
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
text = materialized;
|
|
704
|
+
} else {
|
|
705
|
+
const sessions = await listSessions();
|
|
706
|
+
const match = sessions.find((s) => s.source === wantSource && s.sessionId === message.sessionId);
|
|
707
|
+
if (!match) {
|
|
708
|
+
send({ type: "session", sessionId: message.sessionId, notFound: true });
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
text = await readFile(match.path, "utf8");
|
|
712
|
+
}
|
|
713
|
+
const chunks = [];
|
|
714
|
+
let chunk = "";
|
|
715
|
+
let chunkBytes = 0;
|
|
716
|
+
for (let i = 0; i < text.length; i++) {
|
|
717
|
+
const unit = text.charCodeAt(i);
|
|
718
|
+
const size = escapedUnitBytes(unit, i + 1 < text.length ? text.charCodeAt(i + 1) : NaN);
|
|
719
|
+
if (chunk && chunkBytes + size > MAX_CHUNK_ESCAPED_BYTES) {
|
|
720
|
+
chunks.push(chunk);
|
|
721
|
+
chunk = "";
|
|
722
|
+
chunkBytes = 0;
|
|
723
|
+
}
|
|
724
|
+
chunk += text[i];
|
|
725
|
+
chunkBytes += size;
|
|
726
|
+
}
|
|
727
|
+
if (chunk) {
|
|
728
|
+
chunks.push(chunk);
|
|
482
729
|
}
|
|
483
|
-
const
|
|
484
|
-
|
|
485
|
-
for (let i = 0, seq = 0; seq < total; i += CHUNK_CHARS, seq++) {
|
|
730
|
+
const total = Math.max(1, chunks.length);
|
|
731
|
+
for (let seq = 0; seq < total; seq++) {
|
|
486
732
|
send({
|
|
487
733
|
type: "chunk",
|
|
488
734
|
sessionId: message.sessionId,
|
|
489
735
|
seq,
|
|
490
736
|
total,
|
|
491
|
-
data:
|
|
737
|
+
data: chunks[seq] ?? ""
|
|
492
738
|
});
|
|
493
739
|
}
|
|
494
740
|
send({ type: "sessionDone", sessionId: message.sessionId });
|
|
@@ -519,7 +765,16 @@ process.stdin.on("data", (data) => {
|
|
|
519
765
|
// Serialize handling so chunk ordering per session is preserved.
|
|
520
766
|
processing = processing
|
|
521
767
|
.then(() => handle(message))
|
|
522
|
-
.catch((error) =>
|
|
768
|
+
.catch((error) =>
|
|
769
|
+
// Attribute the failure to the command's session when it names one, so
|
|
770
|
+
// the extension's fetch loop can drop a STALE error that arrives after
|
|
771
|
+
// it gave up on that session instead of failing the next one.
|
|
772
|
+
send({
|
|
773
|
+
type: "error",
|
|
774
|
+
...(typeof message.sessionId === "string" ? { sessionId: message.sessionId } : {}),
|
|
775
|
+
error: String((error && error.message) || error)
|
|
776
|
+
})
|
|
777
|
+
);
|
|
523
778
|
}
|
|
524
779
|
});
|
|
525
780
|
|
package/dist/llmnesia.mcpb
CHANGED
|
Binary file
|
package/dist/mcp.js
CHANGED
|
@@ -6,19 +6,21 @@ 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
|
-
scheduleSelfUpdate
|
|
15
|
-
|
|
15
|
+
scheduleSelfUpdate,
|
|
16
|
+
upsertIndexedConversation
|
|
17
|
+
} from "./chunk-XRA6OJC5.js";
|
|
16
18
|
import {
|
|
17
19
|
VERSION,
|
|
18
20
|
__commonJS,
|
|
19
21
|
__export,
|
|
20
22
|
__toESM
|
|
21
|
-
} from "./chunk-
|
|
23
|
+
} from "./chunk-GLCLSBT6.js";
|
|
22
24
|
|
|
23
25
|
// ../node_modules/ajv/dist/compile/codegen/code.js
|
|
24
26
|
var require_code = __commonJS({
|
|
@@ -21141,6 +21143,109 @@ import { realpathSync } from "fs";
|
|
|
21141
21143
|
import { fileURLToPath } from "url";
|
|
21142
21144
|
import path from "path";
|
|
21143
21145
|
|
|
21146
|
+
// src/inventory.ts
|
|
21147
|
+
import { randomBytes } from "crypto";
|
|
21148
|
+
var InventoryCursorError = class extends Error {
|
|
21149
|
+
};
|
|
21150
|
+
var DEFAULT_INVENTORY_TTL_MS = 15 * 6e4;
|
|
21151
|
+
var MAX_INVENTORY_SNAPSHOTS = 8;
|
|
21152
|
+
var CURSOR_PREFIX = "inv1.";
|
|
21153
|
+
var DEFAULT_PAGE_LIMIT = 100;
|
|
21154
|
+
var MAX_PAGE_LIMIT = 100;
|
|
21155
|
+
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.";
|
|
21156
|
+
var InventoryScanner = class {
|
|
21157
|
+
constructor(loadRows, options = {}) {
|
|
21158
|
+
this.loadRows = loadRows;
|
|
21159
|
+
this.options = options;
|
|
21160
|
+
}
|
|
21161
|
+
loadRows;
|
|
21162
|
+
options;
|
|
21163
|
+
snapshots = /* @__PURE__ */ new Map();
|
|
21164
|
+
page(cursor, limit) {
|
|
21165
|
+
const pageSize = Math.min(MAX_PAGE_LIMIT, Math.max(1, limit ?? DEFAULT_PAGE_LIMIT));
|
|
21166
|
+
if (cursor === void 0 || cursor === "") {
|
|
21167
|
+
return this.startScan(pageSize);
|
|
21168
|
+
}
|
|
21169
|
+
const { snapshotId, offset } = decodeCursor(cursor);
|
|
21170
|
+
const snapshot = this.snapshots.get(snapshotId);
|
|
21171
|
+
if (!snapshot || Date.now() - snapshot.createdAt > this.ttlMs()) {
|
|
21172
|
+
this.snapshots.delete(snapshotId);
|
|
21173
|
+
throw new InventoryCursorError(CURSOR_ERROR_MESSAGE);
|
|
21174
|
+
}
|
|
21175
|
+
return this.slice(snapshot, offset, pageSize);
|
|
21176
|
+
}
|
|
21177
|
+
/** Drop all snapshots (called when the owning service closes). */
|
|
21178
|
+
clear() {
|
|
21179
|
+
this.snapshots.clear();
|
|
21180
|
+
}
|
|
21181
|
+
startScan(pageSize) {
|
|
21182
|
+
const rows = this.loadRows();
|
|
21183
|
+
const now = Date.now();
|
|
21184
|
+
if (rows.length === 0) {
|
|
21185
|
+
return { items: [], total: 0, scanStartedAt: now, nextCursor: null };
|
|
21186
|
+
}
|
|
21187
|
+
const snapshot = {
|
|
21188
|
+
id: randomBytes(9).toString("base64url"),
|
|
21189
|
+
createdAt: now,
|
|
21190
|
+
rows
|
|
21191
|
+
};
|
|
21192
|
+
this.snapshots.set(snapshot.id, snapshot);
|
|
21193
|
+
this.evict(now);
|
|
21194
|
+
return this.slice(snapshot, 0, pageSize);
|
|
21195
|
+
}
|
|
21196
|
+
slice(snapshot, offset, pageSize) {
|
|
21197
|
+
const items = snapshot.rows.slice(offset, offset + pageSize);
|
|
21198
|
+
const end = offset + items.length;
|
|
21199
|
+
return {
|
|
21200
|
+
items,
|
|
21201
|
+
total: snapshot.rows.length,
|
|
21202
|
+
scanStartedAt: snapshot.createdAt,
|
|
21203
|
+
nextCursor: end < snapshot.rows.length ? encodeCursor(snapshot.id, end) : null
|
|
21204
|
+
};
|
|
21205
|
+
}
|
|
21206
|
+
ttlMs() {
|
|
21207
|
+
return this.options.ttlMs ?? DEFAULT_INVENTORY_TTL_MS;
|
|
21208
|
+
}
|
|
21209
|
+
evict(now) {
|
|
21210
|
+
const max = Math.max(1, this.options.maxSnapshots ?? MAX_INVENTORY_SNAPSHOTS);
|
|
21211
|
+
for (const [id, snapshot] of this.snapshots) {
|
|
21212
|
+
if (Date.now() - snapshot.createdAt > this.ttlMs()) {
|
|
21213
|
+
this.snapshots.delete(id);
|
|
21214
|
+
}
|
|
21215
|
+
}
|
|
21216
|
+
while (this.snapshots.size > max) {
|
|
21217
|
+
let oldestId;
|
|
21218
|
+
let oldestAt = now + 1;
|
|
21219
|
+
for (const [id, snapshot] of this.snapshots) {
|
|
21220
|
+
if (snapshot.createdAt < oldestAt) {
|
|
21221
|
+
oldestAt = snapshot.createdAt;
|
|
21222
|
+
oldestId = id;
|
|
21223
|
+
}
|
|
21224
|
+
}
|
|
21225
|
+
if (oldestId === void 0) break;
|
|
21226
|
+
this.snapshots.delete(oldestId);
|
|
21227
|
+
}
|
|
21228
|
+
}
|
|
21229
|
+
};
|
|
21230
|
+
function encodeCursor(snapshotId, offset) {
|
|
21231
|
+
const payload = Buffer.from(JSON.stringify({ s: snapshotId, o: offset }), "utf8");
|
|
21232
|
+
return `${CURSOR_PREFIX}${payload.toString("base64url")}`;
|
|
21233
|
+
}
|
|
21234
|
+
function decodeCursor(cursor) {
|
|
21235
|
+
if (cursor.startsWith(CURSOR_PREFIX)) {
|
|
21236
|
+
try {
|
|
21237
|
+
const parsed = JSON.parse(
|
|
21238
|
+
Buffer.from(cursor.slice(CURSOR_PREFIX.length), "base64url").toString("utf8")
|
|
21239
|
+
);
|
|
21240
|
+
if (typeof parsed?.s === "string" && parsed.s.length > 0 && typeof parsed?.o === "number" && Number.isInteger(parsed.o) && parsed.o >= 0) {
|
|
21241
|
+
return { snapshotId: parsed.s, offset: parsed.o };
|
|
21242
|
+
}
|
|
21243
|
+
} catch {
|
|
21244
|
+
}
|
|
21245
|
+
}
|
|
21246
|
+
throw new InventoryCursorError(CURSOR_ERROR_MESSAGE);
|
|
21247
|
+
}
|
|
21248
|
+
|
|
21144
21249
|
// src/saveSchema.ts
|
|
21145
21250
|
import { createHash } from "crypto";
|
|
21146
21251
|
var saveMessageSchema = external_exports.object({
|
|
@@ -21413,6 +21518,8 @@ var CompanionService = class _CompanionService {
|
|
|
21413
21518
|
// Serialize drains so concurrent reads queue behind one ingest rather than
|
|
21414
21519
|
// racing to consume the same file (mirrors Corpus.enqueueWrite).
|
|
21415
21520
|
refreshChain = Promise.resolve();
|
|
21521
|
+
/** Holds the bounded, in-memory snapshots behind inventoryPage. */
|
|
21522
|
+
inventoryScanner = new InventoryScanner(() => this.index.inventoryRows());
|
|
21416
21523
|
/** Memories written since this process started; the per-session cap counts these. */
|
|
21417
21524
|
memoriesWritten = 0;
|
|
21418
21525
|
activeOperations = 0;
|
|
@@ -21530,6 +21637,23 @@ var CompanionService = class _CompanionService {
|
|
|
21530
21637
|
getConversation(docId) {
|
|
21531
21638
|
return this.corpus.get(docId);
|
|
21532
21639
|
}
|
|
21640
|
+
/**
|
|
21641
|
+
* One page of the paginated conversation inventory (metadata only, never
|
|
21642
|
+
* transcripts). Passing no cursor starts a new scan: the inbox is drained so
|
|
21643
|
+
* the snapshot reflects the corpus as of now, then a consistent docId-sorted
|
|
21644
|
+
* snapshot is captured and held in memory. Passing a previous page's
|
|
21645
|
+
* nextCursor serves later pages from that same snapshot, so conversations
|
|
21646
|
+
* that arrive or change mid-scan cannot shuffle or rewrite earlier pages;
|
|
21647
|
+
* resume pages deliberately skip the drain, which would only cost latency.
|
|
21648
|
+
* Throws InventoryCursorError for a cursor that has expired or outlived the
|
|
21649
|
+
* process; the caller should begin a new scan.
|
|
21650
|
+
*/
|
|
21651
|
+
async inventoryPage(options = {}) {
|
|
21652
|
+
if (!options.cursor) {
|
|
21653
|
+
await this.refresh();
|
|
21654
|
+
}
|
|
21655
|
+
return this.inventoryScanner.page(options.cursor, options.limit);
|
|
21656
|
+
}
|
|
21533
21657
|
/** Recent conversations enriched with their cached summary when available. */
|
|
21534
21658
|
async listRecent(n) {
|
|
21535
21659
|
return this.listConversations(n, "recent");
|
|
@@ -21581,10 +21705,7 @@ var CompanionService = class _CompanionService {
|
|
|
21581
21705
|
async saveConversation(payload) {
|
|
21582
21706
|
const input = saveConversationSchema.parse(payload);
|
|
21583
21707
|
const record2 = normalizeSaveInput(input);
|
|
21584
|
-
const result = await this.corpus.
|
|
21585
|
-
if (result.outcome !== "skipped") {
|
|
21586
|
-
this.index.upsertConversation(record2);
|
|
21587
|
-
}
|
|
21708
|
+
const result = await upsertIndexedConversation(this.corpus, record2, this.index);
|
|
21588
21709
|
return { docId: result.docId, outcome: result.outcome, messageCount: record2.messages.length };
|
|
21589
21710
|
}
|
|
21590
21711
|
/**
|
|
@@ -21623,6 +21744,7 @@ var CompanionService = class _CompanionService {
|
|
|
21623
21744
|
this.closed = true;
|
|
21624
21745
|
if (this.maintenanceTimer) clearTimeout(this.maintenanceTimer);
|
|
21625
21746
|
this.maintenanceTimer = void 0;
|
|
21747
|
+
this.inventoryScanner.clear();
|
|
21626
21748
|
this.index.close();
|
|
21627
21749
|
}
|
|
21628
21750
|
};
|
|
@@ -21686,16 +21808,15 @@ function viewerExtensionId() {
|
|
|
21686
21808
|
const configured = process.env.LLMNESIA_VIEWER_EXTENSION_ID?.trim() ?? readNativeHostViewerExtensionId();
|
|
21687
21809
|
return configured && EXTENSION_ID_RE.test(configured) ? configured : STORE_EXTENSION_ID;
|
|
21688
21810
|
}
|
|
21689
|
-
function viewerSourceUrl(docId
|
|
21690
|
-
const fragment = new URLSearchParams({ docId
|
|
21811
|
+
function viewerSourceUrl(docId) {
|
|
21812
|
+
const fragment = new URLSearchParams({ docId });
|
|
21691
21813
|
return `${VIEWER_HANDOFF_URL}#${fragment.toString()}`;
|
|
21692
21814
|
}
|
|
21693
21815
|
function sourceLinks(docId, originalUrl) {
|
|
21694
|
-
const
|
|
21695
|
-
const localViewerUrl = `chrome-extension://${extensionId}/viewer.html?docId=${encodeURIComponent(docId)}`;
|
|
21816
|
+
const localViewerUrl = `chrome-extension://${viewerExtensionId()}/viewer.html?docId=${encodeURIComponent(docId)}`;
|
|
21696
21817
|
return originalUrl ? { viewerUrl: localViewerUrl, sourceUrl: originalUrl, sourceKind: "original" } : {
|
|
21697
21818
|
viewerUrl: localViewerUrl,
|
|
21698
|
-
sourceUrl: viewerSourceUrl(docId
|
|
21819
|
+
sourceUrl: viewerSourceUrl(docId),
|
|
21699
21820
|
sourceKind: "viewer"
|
|
21700
21821
|
};
|
|
21701
21822
|
}
|
|
@@ -21778,6 +21899,8 @@ async function runServe() {
|
|
|
21778
21899
|
"How to combine query terms. 'any' (default) ORs them: highest recall, but common words in the query match almost everything, so results cannot be counted or used to prove something was discussed. 'all' requires every term in the same turn. 'phrase' requires the exact wording, adjacent and in order. Use 'all' or 'phrase' for existence and frequency questions, and strip filler words from the query first."
|
|
21779
21900
|
),
|
|
21780
21901
|
platform: external_exports.string().optional().describe("Restrict to one platform, e.g. 'claude', 'chatgpt', 'gemini'."),
|
|
21902
|
+
project_id: external_exports.string().optional().describe("Only conversations observed inside this ChatGPT Project (g-p-...)."),
|
|
21903
|
+
workspace_id: external_exports.string().optional().describe("Only conversations observed under this provider workspace (a Claude organization id)."),
|
|
21781
21904
|
title: external_exports.string().optional().describe("Only conversations whose title contains this substring."),
|
|
21782
21905
|
date_from: external_exports.string().optional().describe("Only conversations created on/after this date (ISO or epoch ms)."),
|
|
21783
21906
|
date_to: external_exports.string().optional().describe("Only conversations created on/before this date (ISO or epoch ms)."),
|
|
@@ -21793,6 +21916,8 @@ async function runServe() {
|
|
|
21793
21916
|
async (args) => withService(async (service2) => {
|
|
21794
21917
|
const filters = {
|
|
21795
21918
|
platform: args.platform,
|
|
21919
|
+
providerProjectId: args.project_id,
|
|
21920
|
+
providerWorkspaceId: args.workspace_id,
|
|
21796
21921
|
title: args.title,
|
|
21797
21922
|
dateFrom: parseDate(args.date_from),
|
|
21798
21923
|
dateTo: parseDate(args.date_to),
|
|
@@ -21820,6 +21945,8 @@ async function runServe() {
|
|
|
21820
21945
|
};
|
|
21821
21946
|
if (h.accountId) entry.accountId = h.accountId;
|
|
21822
21947
|
if (h.accountLabel) entry.accountLabel = h.accountLabel;
|
|
21948
|
+
if (h.providerProject) entry.providerProject = h.providerProject;
|
|
21949
|
+
if (h.providerWorkspace) entry.providerWorkspace = h.providerWorkspace;
|
|
21823
21950
|
if (h.models?.length) entry.models = h.models;
|
|
21824
21951
|
return entry;
|
|
21825
21952
|
})
|
|
@@ -21847,6 +21974,12 @@ async function runServe() {
|
|
|
21847
21974
|
return errorResult(`No conversation found with id '${args.id}'.`);
|
|
21848
21975
|
}
|
|
21849
21976
|
return sourcedJsonResult({
|
|
21977
|
+
// The current fingerprint of the transcript being returned, matching
|
|
21978
|
+
// the `fingerprint` a conversation_inventory entry carries for the
|
|
21979
|
+
// same content. A caller diffing against an inventory snapshot taken
|
|
21980
|
+
// earlier can detect a change made after that snapshot by comparing
|
|
21981
|
+
// the two values.
|
|
21982
|
+
contentFingerprint: fingerprintConversation(record2),
|
|
21850
21983
|
conversation: {
|
|
21851
21984
|
...record2.conversation,
|
|
21852
21985
|
...sourceLinks(record2.conversation.docId, record2.conversation.url)
|
|
@@ -21896,6 +22029,8 @@ async function runServe() {
|
|
|
21896
22029
|
};
|
|
21897
22030
|
if (e.accountId) entry.accountId = e.accountId;
|
|
21898
22031
|
if (e.accountLabel) entry.accountLabel = e.accountLabel;
|
|
22032
|
+
if (e.providerProject) entry.providerProject = e.providerProject;
|
|
22033
|
+
if (e.providerWorkspace) entry.providerWorkspace = e.providerWorkspace;
|
|
21899
22034
|
if (e.models?.length) entry.models = e.models;
|
|
21900
22035
|
return entry;
|
|
21901
22036
|
})
|
|
@@ -21940,12 +22075,51 @@ async function runServe() {
|
|
|
21940
22075
|
};
|
|
21941
22076
|
if (e.accountId) entry.accountId = e.accountId;
|
|
21942
22077
|
if (e.accountLabel) entry.accountLabel = e.accountLabel;
|
|
22078
|
+
if (e.providerProject) entry.providerProject = e.providerProject;
|
|
22079
|
+
if (e.providerWorkspace) entry.providerWorkspace = e.providerWorkspace;
|
|
21943
22080
|
if (e.models?.length) entry.models = e.models;
|
|
21944
22081
|
return entry;
|
|
21945
22082
|
})
|
|
21946
22083
|
});
|
|
21947
22084
|
})
|
|
21948
22085
|
);
|
|
22086
|
+
server.registerTool(
|
|
22087
|
+
"conversation_inventory",
|
|
22088
|
+
{
|
|
22089
|
+
title: "Conversation inventory",
|
|
22090
|
+
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.",
|
|
22091
|
+
inputSchema: {
|
|
22092
|
+
cursor: external_exports.string().optional().describe("Opaque nextCursor from the previous page. Omit to start a new scan."),
|
|
22093
|
+
limit: external_exports.number().int().min(1).max(100).optional().describe("Items per page (default 100).")
|
|
22094
|
+
},
|
|
22095
|
+
annotations: {
|
|
22096
|
+
readOnlyHint: true,
|
|
22097
|
+
destructiveHint: false,
|
|
22098
|
+
idempotentHint: true,
|
|
22099
|
+
openWorldHint: false
|
|
22100
|
+
}
|
|
22101
|
+
},
|
|
22102
|
+
async (args) => withService(async (service2) => {
|
|
22103
|
+
try {
|
|
22104
|
+
const page = await service2.inventoryPage({ cursor: args.cursor, limit: args.limit });
|
|
22105
|
+
const payload = {
|
|
22106
|
+
count: page.items.length,
|
|
22107
|
+
total: page.total,
|
|
22108
|
+
scanStartedAt: page.scanStartedAt,
|
|
22109
|
+
items: page.items
|
|
22110
|
+
};
|
|
22111
|
+
if (page.nextCursor) {
|
|
22112
|
+
payload.nextCursor = page.nextCursor;
|
|
22113
|
+
}
|
|
22114
|
+
return jsonResult(payload);
|
|
22115
|
+
} catch (err) {
|
|
22116
|
+
if (err instanceof InventoryCursorError) {
|
|
22117
|
+
return errorResult(err.message);
|
|
22118
|
+
}
|
|
22119
|
+
throw err;
|
|
22120
|
+
}
|
|
22121
|
+
})
|
|
22122
|
+
);
|
|
21949
22123
|
server.registerTool(
|
|
21950
22124
|
"corpus_status",
|
|
21951
22125
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@llmnesia/mcp",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.10",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"comment:mcpName": "The MCP Registry's ownership marker: it must equal server.json's `name`, and the namespace must match how you authenticate with mcp-publisher. `com.llmnesia/...` requires DNS auth (a TXT record on llmnesia.com) — see docs/MCP_INSTALLER_BOARD.md.",
|
package/server.json
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
"name": "com.llmnesia/mcp",
|
|
4
4
|
"title": "LLMnesia",
|
|
5
5
|
"description": "Search your AI chat history from Claude, Cursor, Codex, and other MCP clients.",
|
|
6
|
-
"version": "0.2.
|
|
6
|
+
"version": "0.2.10",
|
|
7
7
|
"packages": [
|
|
8
8
|
{
|
|
9
9
|
"registryType": "npm",
|
|
10
10
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
11
11
|
"identifier": "@llmnesia/mcp",
|
|
12
|
-
"version": "0.2.
|
|
12
|
+
"version": "0.2.10",
|
|
13
13
|
"transport": {
|
|
14
14
|
"type": "stdio"
|
|
15
15
|
}
|