@llmnesia/mcp 0.1.15 → 0.2.1
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 +10 -3
- package/dist/{chunk-YYNCL7SQ.js → chunk-GLKXD34K.js} +1 -1
- package/dist/{chunk-C7ZUT5N7.js → chunk-PEDBH2VJ.js} +230 -23
- package/dist/{cli-BQZPGQQX.js → cli-6PUR5L35.js} +14 -2
- package/dist/cli.js +2 -2
- package/dist/llmnesia-claude-host.mjs +90 -3
- package/dist/llmnesia.mcpb +0 -0
- package/dist/mcp.js +275 -44
- package/package.json +1 -1
- package/server.json +2 -2
package/README.md
CHANGED
|
@@ -78,8 +78,7 @@ search, list, and read the local corpus but cannot modify it.
|
|
|
78
78
|
The future Claude Directory listing is a convenience distribution route, not a
|
|
79
79
|
dependency for the general LLMnesia release. It will replace this one manual
|
|
80
80
|
Claude step with Claude's own in-app install button if Anthropic selects the
|
|
81
|
-
extension.
|
|
82
|
-
for the release and directory-submission plan.
|
|
81
|
+
extension.
|
|
83
82
|
|
|
84
83
|
The `install` command is the approval to perform those setup steps. It does not
|
|
85
84
|
interrupt setup with a separate prompt for every detected app, and it never
|
|
@@ -128,7 +127,7 @@ If nothing has written to the corpus yet, search results will be empty.
|
|
|
128
127
|
|
|
129
128
|
## Tools
|
|
130
129
|
|
|
131
|
-
Once registered, your MCP client gains
|
|
130
|
+
Once registered, your MCP client gains seven tools over the corpus:
|
|
132
131
|
|
|
133
132
|
| Tool | Purpose |
|
|
134
133
|
| --- | --- |
|
|
@@ -138,6 +137,14 @@ Once registered, your MCP client gains six tools over the corpus:
|
|
|
138
137
|
| `list_recent` | List the most recently updated conversations with title, summary, and platform. |
|
|
139
138
|
| `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. |
|
|
140
139
|
| `save_conversation` | Add or update a conversation in the corpus, so MCP is also an ingestion route. |
|
|
140
|
+
| `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. |
|
|
141
|
+
|
|
142
|
+
### Read-only mode (`LLMNESIA_READ_ONLY`)
|
|
143
|
+
|
|
144
|
+
The Claude Desktop extension (`.mcpb`) ships with `LLMNESIA_READ_ONLY=1`, which
|
|
145
|
+
disables the two write tools (`save_conversation` and `save_memory`) so that
|
|
146
|
+
ordinary history searches run without a per-call approval prompt. The general
|
|
147
|
+
npm install exposes all seven tools.
|
|
141
148
|
|
|
142
149
|
Search and list results carry a `corpus` block saying when the corpus last
|
|
143
150
|
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-GLKXD34K.js";
|
|
7
7
|
|
|
8
8
|
// src/config.ts
|
|
9
9
|
import { readFileSync } from "fs";
|
|
@@ -35,13 +35,26 @@ function expandHome(p) {
|
|
|
35
35
|
return resolve(p);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
// src/corpus.ts
|
|
39
|
-
import { promises as fs } from "fs";
|
|
40
|
-
import { dirname, join as join3 } from "path";
|
|
41
|
-
|
|
42
38
|
// src/paths.ts
|
|
43
39
|
import { join as join2 } from "path";
|
|
40
|
+
import { readdir } from "fs/promises";
|
|
44
41
|
import { createHash } from "crypto";
|
|
42
|
+
var INDEX_FORMAT_VERSION = 2;
|
|
43
|
+
function indexDbFilename(version) {
|
|
44
|
+
return version === 1 ? "llmnesia.db" : `llmnesia-v${version}.db`;
|
|
45
|
+
}
|
|
46
|
+
async function staleIndexFiles(dir) {
|
|
47
|
+
const keep = indexDbFilename(INDEX_FORMAT_VERSION);
|
|
48
|
+
let entries;
|
|
49
|
+
try {
|
|
50
|
+
entries = await readdir(dir);
|
|
51
|
+
} catch {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
return entries.filter(
|
|
55
|
+
(name) => /^llmnesia(-v\d+)?\.db$/.test(name) && name !== keep
|
|
56
|
+
);
|
|
57
|
+
}
|
|
45
58
|
var CorpusPaths = class {
|
|
46
59
|
constructor(root) {
|
|
47
60
|
this.root = root;
|
|
@@ -59,11 +72,14 @@ var CorpusPaths = class {
|
|
|
59
72
|
get conversationsDir() {
|
|
60
73
|
return join2(this.root, "conversations");
|
|
61
74
|
}
|
|
75
|
+
get memoriesDir() {
|
|
76
|
+
return join2(this.root, "memories");
|
|
77
|
+
}
|
|
62
78
|
get indexDir() {
|
|
63
79
|
return join2(this.root, "index");
|
|
64
80
|
}
|
|
65
81
|
get indexDbFile() {
|
|
66
|
-
return join2(this.indexDir,
|
|
82
|
+
return join2(this.indexDir, indexDbFilename(INDEX_FORMAT_VERSION));
|
|
67
83
|
}
|
|
68
84
|
/** Directory holding a platform's conversation JSON files. */
|
|
69
85
|
platformDir(platform) {
|
|
@@ -73,6 +89,14 @@ var CorpusPaths = class {
|
|
|
73
89
|
conversationFile(platform, docId) {
|
|
74
90
|
return join2(this.platformDir(platform), `${slugifyDocId(docId)}.json`);
|
|
75
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* Absolute path to the JSON file backing a given memoryId. Memories are flat
|
|
94
|
+
* rather than partitioned by platform: they are user-curated and few, and a
|
|
95
|
+
* memory is not owned by any one platform the way a conversation is.
|
|
96
|
+
*/
|
|
97
|
+
memoryFile(memoryId) {
|
|
98
|
+
return join2(this.memoriesDir, `${slugifyDocId(memoryId)}.json`);
|
|
99
|
+
}
|
|
76
100
|
};
|
|
77
101
|
var SLUG_MAX = 120;
|
|
78
102
|
function slugifyDocId(docId) {
|
|
@@ -89,6 +113,10 @@ function safeSegment(value) {
|
|
|
89
113
|
return cleaned.length > 0 ? cleaned : "unknown";
|
|
90
114
|
}
|
|
91
115
|
|
|
116
|
+
// src/corpus.ts
|
|
117
|
+
import { promises as fs } from "fs";
|
|
118
|
+
import { dirname, join as join3 } from "path";
|
|
119
|
+
|
|
92
120
|
// src/serialize.ts
|
|
93
121
|
function canonicalizeConversation(record) {
|
|
94
122
|
return {
|
|
@@ -96,6 +124,31 @@ function canonicalizeConversation(record) {
|
|
|
96
124
|
messages: [...record.messages].sort((a, b) => a.msgIndex - b.msgIndex).map(canonicalMessageDoc)
|
|
97
125
|
};
|
|
98
126
|
}
|
|
127
|
+
function canonicalizeMemory(memory) {
|
|
128
|
+
const out = {
|
|
129
|
+
memoryId: memory.memoryId,
|
|
130
|
+
kind: memory.kind,
|
|
131
|
+
label: memory.label,
|
|
132
|
+
text: memory.text,
|
|
133
|
+
source: memory.source
|
|
134
|
+
};
|
|
135
|
+
assignIfDefined(out, "sourceDocId", memory.sourceDocId);
|
|
136
|
+
assignIfDefined(out, "sourcePlatform", memory.sourcePlatform);
|
|
137
|
+
assignIfDefined(out, "sourceUrl", memory.sourceUrl);
|
|
138
|
+
assignIfDefined(out, "sourceTitle", memory.sourceTitle);
|
|
139
|
+
assignIfDefined(out, "sourceMsgStart", memory.sourceMsgStart);
|
|
140
|
+
assignIfDefined(out, "sourceMsgEnd", memory.sourceMsgEnd);
|
|
141
|
+
out.pinned = memory.pinned;
|
|
142
|
+
out.enabled = memory.enabled;
|
|
143
|
+
if (memory.tags && memory.tags.length > 0) {
|
|
144
|
+
out.tags = [...memory.tags].sort();
|
|
145
|
+
}
|
|
146
|
+
out.createdAt = memory.createdAt;
|
|
147
|
+
out.updatedAt = memory.updatedAt;
|
|
148
|
+
assignIfDefined(out, "lastUsedAt", memory.lastUsedAt);
|
|
149
|
+
assignIfDefined(out, "useCount", memory.useCount);
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
99
152
|
function canonicalConversationDoc(c) {
|
|
100
153
|
const out = {
|
|
101
154
|
docId: c.docId,
|
|
@@ -171,10 +224,12 @@ var Corpus = class {
|
|
|
171
224
|
/** Create the directory skeleton and meta.json if absent. Idempotent. */
|
|
172
225
|
async init() {
|
|
173
226
|
await fs.mkdir(this.paths.conversationsDir, { recursive: true, mode: DIR_MODE });
|
|
227
|
+
await fs.mkdir(this.paths.memoriesDir, { recursive: true, mode: DIR_MODE });
|
|
174
228
|
await fs.mkdir(this.paths.processedDir, { recursive: true, mode: DIR_MODE });
|
|
175
229
|
await fs.mkdir(this.paths.indexDir, { recursive: true, mode: DIR_MODE });
|
|
176
230
|
await hardenDir(this.paths.root);
|
|
177
231
|
await hardenDir(this.paths.conversationsDir);
|
|
232
|
+
await hardenDir(this.paths.memoriesDir);
|
|
178
233
|
await hardenDir(this.paths.inboxDir);
|
|
179
234
|
await hardenDir(this.paths.processedDir);
|
|
180
235
|
await hardenDir(this.paths.indexDir);
|
|
@@ -257,6 +312,60 @@ var Corpus = class {
|
|
|
257
312
|
}
|
|
258
313
|
return null;
|
|
259
314
|
}
|
|
315
|
+
/**
|
|
316
|
+
* Write an agent-authored memory, keyed on memoryId.
|
|
317
|
+
*
|
|
318
|
+
* The semantics differ from {@link upsert} on purpose. A memoryId is a
|
|
319
|
+
* content hash, so an existing file at that id already holds this exact
|
|
320
|
+
* memory: re-writing it would only churn the timestamps, and it would stamp
|
|
321
|
+
* `enabled: false` back over a memory the user had reviewed and turned on.
|
|
322
|
+
* So an existing record wins and the write is skipped.
|
|
323
|
+
*
|
|
324
|
+
* A record whose `source` is not "agent" is refused outright rather than
|
|
325
|
+
* skipped: a captured conversation or a memory the user wrote by hand must be
|
|
326
|
+
* untouchable from the agent write path, and the caller should hear about it
|
|
327
|
+
* rather than believe its write landed.
|
|
328
|
+
*/
|
|
329
|
+
async upsertMemory(memory) {
|
|
330
|
+
return this.enqueueWrite(async () => {
|
|
331
|
+
const canonical = canonicalizeMemory(memory);
|
|
332
|
+
const file = this.paths.memoryFile(canonical.memoryId);
|
|
333
|
+
const existing = await readJsonIfExists(file);
|
|
334
|
+
if (existing) {
|
|
335
|
+
if (existing.source !== "agent") {
|
|
336
|
+
return { memoryId: canonical.memoryId, outcome: "refused" };
|
|
337
|
+
}
|
|
338
|
+
return { memoryId: canonical.memoryId, outcome: "skipped" };
|
|
339
|
+
}
|
|
340
|
+
await fs.mkdir(dirname(file), { recursive: true, mode: DIR_MODE });
|
|
341
|
+
await writeFileAtomic(file, `${JSON.stringify(canonical, null, 2)}
|
|
342
|
+
`);
|
|
343
|
+
return { memoryId: canonical.memoryId, outcome: "created" };
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
/** Read a memory by memoryId, or null when it has never been written. */
|
|
347
|
+
async getMemory(memoryId) {
|
|
348
|
+
const record = await readJsonIfExists(this.paths.memoryFile(memoryId));
|
|
349
|
+
return record && record.memoryId === memoryId ? record : null;
|
|
350
|
+
}
|
|
351
|
+
/** Async-iterate every stored memory (used by the index rebuild). */
|
|
352
|
+
async *iterateMemories() {
|
|
353
|
+
let files;
|
|
354
|
+
try {
|
|
355
|
+
files = await fs.readdir(this.paths.memoriesDir);
|
|
356
|
+
} catch {
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
for (const name of files.sort()) {
|
|
360
|
+
if (!name.endsWith(".json")) {
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
const record = await readJsonIfExists(join3(this.paths.memoriesDir, name));
|
|
364
|
+
if (record?.memoryId) {
|
|
365
|
+
yield record;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
260
369
|
/** Platform subdirectory names under conversations/. */
|
|
261
370
|
async listPlatforms() {
|
|
262
371
|
try {
|
|
@@ -581,11 +690,22 @@ function hardenDbFile(dbPath) {
|
|
|
581
690
|
} catch {
|
|
582
691
|
}
|
|
583
692
|
}
|
|
584
|
-
var INDEX_FORMAT_VERSION = 1;
|
|
585
693
|
var TITLE_ROW_INDEX = -1;
|
|
586
694
|
var TITLE_ROLE = "title";
|
|
587
695
|
var DEFAULT_LIMIT = 10;
|
|
588
696
|
var SNIPPET_TOKENS = 12;
|
|
697
|
+
var META_SQL = `
|
|
698
|
+
CREATE TABLE IF NOT EXISTS index_meta (
|
|
699
|
+
key TEXT PRIMARY KEY,
|
|
700
|
+
value TEXT NOT NULL
|
|
701
|
+
);
|
|
702
|
+
`;
|
|
703
|
+
var DROP_DERIVED_SQL = `
|
|
704
|
+
DROP TABLE IF EXISTS conversations;
|
|
705
|
+
DROP TABLE IF EXISTS messages_fts;
|
|
706
|
+
DROP TABLE IF EXISTS memories;
|
|
707
|
+
DROP TABLE IF EXISTS memories_fts;
|
|
708
|
+
`;
|
|
589
709
|
var SCHEMA_SQL = `
|
|
590
710
|
CREATE TABLE IF NOT EXISTS index_meta (
|
|
591
711
|
key TEXT PRIMARY KEY,
|
|
@@ -601,7 +721,9 @@ CREATE TABLE IF NOT EXISTS conversations (
|
|
|
601
721
|
updatedAt INTEGER NOT NULL,
|
|
602
722
|
messageCount INTEGER NOT NULL,
|
|
603
723
|
preview TEXT NOT NULL,
|
|
604
|
-
pinned INTEGER NOT NULL
|
|
724
|
+
pinned INTEGER NOT NULL,
|
|
725
|
+
accountId TEXT,
|
|
726
|
+
accountLabel TEXT
|
|
605
727
|
);
|
|
606
728
|
CREATE INDEX IF NOT EXISTS idx_conversations_platform ON conversations(platform);
|
|
607
729
|
CREATE INDEX IF NOT EXISTS idx_conversations_createdAt ON conversations(createdAt);
|
|
@@ -613,15 +735,41 @@ CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
|
|
|
613
735
|
text,
|
|
614
736
|
tokenize = 'porter unicode61'
|
|
615
737
|
);
|
|
738
|
+
|
|
739
|
+
CREATE TABLE IF NOT EXISTS memories (
|
|
740
|
+
memoryId TEXT PRIMARY KEY,
|
|
741
|
+
kind TEXT NOT NULL,
|
|
742
|
+
label TEXT NOT NULL,
|
|
743
|
+
text TEXT NOT NULL,
|
|
744
|
+
source TEXT NOT NULL,
|
|
745
|
+
pinned INTEGER NOT NULL,
|
|
746
|
+
enabled INTEGER NOT NULL,
|
|
747
|
+
createdAt INTEGER NOT NULL,
|
|
748
|
+
updatedAt INTEGER NOT NULL
|
|
749
|
+
);
|
|
750
|
+
|
|
751
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
|
|
752
|
+
memoryId UNINDEXED,
|
|
753
|
+
label,
|
|
754
|
+
text,
|
|
755
|
+
tokenize = 'porter unicode61'
|
|
756
|
+
);
|
|
616
757
|
`;
|
|
617
758
|
var SearchIndex = class {
|
|
618
759
|
db;
|
|
619
760
|
stmts;
|
|
761
|
+
/** Whether the on-disk index was a stale format version when it was opened. */
|
|
762
|
+
staleOnOpen;
|
|
620
763
|
constructor(dbPath) {
|
|
621
764
|
hardenDbFile(dbPath);
|
|
622
765
|
this.db = new (loadSqlite()).DatabaseSync(dbPath);
|
|
623
766
|
this.db.exec("PRAGMA journal_mode = WAL");
|
|
624
767
|
this.db.exec("PRAGMA foreign_keys = OFF");
|
|
768
|
+
this.db.exec(META_SQL);
|
|
769
|
+
this.staleOnOpen = this.getMeta("format_version") !== String(INDEX_FORMAT_VERSION);
|
|
770
|
+
if (this.staleOnOpen) {
|
|
771
|
+
this.db.exec(DROP_DERIVED_SQL);
|
|
772
|
+
}
|
|
625
773
|
this.db.exec(SCHEMA_SQL);
|
|
626
774
|
this.setMeta("format_version", String(INDEX_FORMAT_VERSION));
|
|
627
775
|
this.stmts = {
|
|
@@ -632,21 +780,35 @@ var SearchIndex = class {
|
|
|
632
780
|
deleteConversation: this.db.prepare("DELETE FROM conversations WHERE docId = ?"),
|
|
633
781
|
insertConversation: this.db.prepare(
|
|
634
782
|
`INSERT INTO conversations
|
|
635
|
-
(docId, platform, title, url, createdAt, updatedAt, messageCount, preview, pinned)
|
|
636
|
-
VALUES (@docId, @platform, @title, @url, @createdAt, @updatedAt, @messageCount, @preview, @pinned)`
|
|
783
|
+
(docId, platform, title, url, createdAt, updatedAt, messageCount, preview, pinned, accountId, accountLabel)
|
|
784
|
+
VALUES (@docId, @platform, @title, @url, @createdAt, @updatedAt, @messageCount, @preview, @pinned, @accountId, @accountLabel)`
|
|
785
|
+
),
|
|
786
|
+
deleteMemoryFts: this.db.prepare("DELETE FROM memories_fts WHERE memoryId = ?"),
|
|
787
|
+
insertMemoryFts: this.db.prepare(
|
|
788
|
+
"INSERT INTO memories_fts (memoryId, label, text) VALUES (?, ?, ?)"
|
|
789
|
+
),
|
|
790
|
+
deleteMemory: this.db.prepare("DELETE FROM memories WHERE memoryId = ?"),
|
|
791
|
+
insertMemory: this.db.prepare(
|
|
792
|
+
`INSERT INTO memories
|
|
793
|
+
(memoryId, kind, label, text, source, pinned, enabled, createdAt, updatedAt)
|
|
794
|
+
VALUES (@memoryId, @kind, @label, @text, @source, @pinned, @enabled, @createdAt, @updatedAt)`
|
|
637
795
|
)
|
|
638
796
|
};
|
|
639
797
|
}
|
|
640
|
-
/** True when the stored format version
|
|
798
|
+
/** True when the stored format version was missing or stale — caller rebuilds. */
|
|
641
799
|
needsRebuild() {
|
|
642
|
-
|
|
643
|
-
return stored !== String(INDEX_FORMAT_VERSION);
|
|
800
|
+
return this.staleOnOpen;
|
|
644
801
|
}
|
|
645
802
|
/** Number of indexed conversations. */
|
|
646
803
|
count() {
|
|
647
804
|
const row = this.db.prepare("SELECT COUNT(*) AS n FROM conversations").get();
|
|
648
805
|
return row.n;
|
|
649
806
|
}
|
|
807
|
+
/** Number of indexed memories. */
|
|
808
|
+
countMemories() {
|
|
809
|
+
const row = this.db.prepare("SELECT COUNT(*) AS n FROM memories").get();
|
|
810
|
+
return row.n;
|
|
811
|
+
}
|
|
650
812
|
/** Most-recently-updated conversations (metadata only), newest first. */
|
|
651
813
|
listRecent(n) {
|
|
652
814
|
return this.listConversations(n, "recent");
|
|
@@ -656,10 +818,17 @@ var SearchIndex = class {
|
|
|
656
818
|
const limit = Math.max(1, n);
|
|
657
819
|
const orderBy = sort === "oldest" ? "createdAt ASC, updatedAt ASC" : sort === "newest" ? "createdAt DESC, updatedAt DESC" : "updatedAt DESC, createdAt DESC";
|
|
658
820
|
const rows = this.db.prepare(
|
|
659
|
-
`SELECT docId, platform, title, url, createdAt, updatedAt, messageCount, preview, pinned
|
|
821
|
+
`SELECT docId, platform, title, url, createdAt, updatedAt, messageCount, preview, pinned,
|
|
822
|
+
accountId, accountLabel
|
|
660
823
|
FROM conversations ORDER BY ${orderBy} LIMIT ?`
|
|
661
824
|
).all(limit);
|
|
662
|
-
return rows.map((r) =>
|
|
825
|
+
return rows.map((r) => {
|
|
826
|
+
const { accountId, accountLabel, ...rest } = r;
|
|
827
|
+
const entry = { ...rest, pinned: r.pinned === 1 };
|
|
828
|
+
if (accountId) entry.accountId = accountId;
|
|
829
|
+
if (accountLabel) entry.accountLabel = accountLabel;
|
|
830
|
+
return entry;
|
|
831
|
+
});
|
|
663
832
|
}
|
|
664
833
|
/**
|
|
665
834
|
* Run `fn` inside a transaction, rolling back if it throws. node:sqlite has
|
|
@@ -681,6 +850,9 @@ var SearchIndex = class {
|
|
|
681
850
|
upsertConversation(record) {
|
|
682
851
|
this.transaction(() => this.writeRecord(record));
|
|
683
852
|
}
|
|
853
|
+
upsertMemory(memory) {
|
|
854
|
+
this.transaction(() => this.writeMemory(memory));
|
|
855
|
+
}
|
|
684
856
|
removeConversation(docId) {
|
|
685
857
|
this.transaction(() => {
|
|
686
858
|
this.stmts.deleteFts.run(docId);
|
|
@@ -694,19 +866,25 @@ var SearchIndex = class {
|
|
|
694
866
|
*/
|
|
695
867
|
async rebuildFrom(corpus) {
|
|
696
868
|
this.db.exec("DELETE FROM messages_fts; DELETE FROM conversations;");
|
|
869
|
+
this.db.exec("DELETE FROM memories_fts; DELETE FROM memories;");
|
|
697
870
|
let conversations = 0;
|
|
871
|
+
let memories = 0;
|
|
698
872
|
this.db.exec("BEGIN");
|
|
699
873
|
try {
|
|
700
874
|
for await (const record of corpus.iterate()) {
|
|
701
875
|
this.writeRecord(record);
|
|
702
876
|
conversations += 1;
|
|
703
877
|
}
|
|
878
|
+
for await (const memory of corpus.iterateMemories()) {
|
|
879
|
+
this.writeMemory(memory);
|
|
880
|
+
memories += 1;
|
|
881
|
+
}
|
|
704
882
|
this.db.exec("COMMIT");
|
|
705
883
|
} catch (err) {
|
|
706
884
|
this.db.exec("ROLLBACK");
|
|
707
885
|
throw err;
|
|
708
886
|
}
|
|
709
|
-
return { conversations };
|
|
887
|
+
return { conversations, memories };
|
|
710
888
|
}
|
|
711
889
|
// Replace a conversation's index rows. Caller supplies the transaction (the
|
|
712
890
|
// transaction() helper for single upserts, or rebuildFrom's manual one).
|
|
@@ -732,7 +910,32 @@ var SearchIndex = class {
|
|
|
732
910
|
updatedAt: conversation.updatedAt ?? conversation.contentUpdatedAt ?? 0,
|
|
733
911
|
messageCount: conversation.messageCount ?? messages.length,
|
|
734
912
|
preview: conversation.preview ?? "",
|
|
735
|
-
pinned: conversation.pinned ? 1 : 0
|
|
913
|
+
pinned: conversation.pinned ? 1 : 0,
|
|
914
|
+
accountId: conversation.accountId ?? null,
|
|
915
|
+
accountLabel: conversation.accountLabel ?? null
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
// Replace a memory's index rows. Caller supplies the transaction, as with
|
|
919
|
+
// writeRecord. Memories live in their own tables rather than in messages_fts:
|
|
920
|
+
// a memory is not a turn in a conversation, and mixing them would let a
|
|
921
|
+
// stored note answer a question about what the user actually said.
|
|
922
|
+
writeMemory(memory) {
|
|
923
|
+
this.stmts.deleteMemoryFts.run(memory.memoryId);
|
|
924
|
+
this.stmts.deleteMemory.run(memory.memoryId);
|
|
925
|
+
const searchable = [memory.label, memory.text].filter(Boolean).join("\n");
|
|
926
|
+
if (searchable.trim()) {
|
|
927
|
+
this.stmts.insertMemoryFts.run(memory.memoryId, memory.label, memory.text);
|
|
928
|
+
}
|
|
929
|
+
this.stmts.insertMemory.run({
|
|
930
|
+
memoryId: memory.memoryId,
|
|
931
|
+
kind: memory.kind,
|
|
932
|
+
label: memory.label,
|
|
933
|
+
text: memory.text,
|
|
934
|
+
source: memory.source,
|
|
935
|
+
pinned: memory.pinned ? 1 : 0,
|
|
936
|
+
enabled: memory.enabled ? 1 : 0,
|
|
937
|
+
createdAt: memory.createdAt,
|
|
938
|
+
updatedAt: memory.updatedAt
|
|
736
939
|
});
|
|
737
940
|
}
|
|
738
941
|
search(query, filters = {}) {
|
|
@@ -763,7 +966,7 @@ var SearchIndex = class {
|
|
|
763
966
|
const sql = `
|
|
764
967
|
SELECT
|
|
765
968
|
c.docId, c.platform, c.title, c.url, c.createdAt, c.updatedAt,
|
|
766
|
-
c.messageCount, c.preview, c.pinned,
|
|
969
|
+
c.messageCount, c.preview, c.pinned, c.accountId, c.accountLabel,
|
|
767
970
|
m.msgIndex AS msgIndex, m.role AS role,
|
|
768
971
|
snippet(messages_fts, 3, '[', ']', '\u2026', ${SNIPPET_TOKENS}) AS snippet,
|
|
769
972
|
bm25(messages_fts) AS bm25
|
|
@@ -781,7 +984,7 @@ var SearchIndex = class {
|
|
|
781
984
|
continue;
|
|
782
985
|
}
|
|
783
986
|
seen.add(row.docId);
|
|
784
|
-
|
|
987
|
+
const hit = {
|
|
785
988
|
docId: row.docId,
|
|
786
989
|
platform: row.platform,
|
|
787
990
|
title: row.title,
|
|
@@ -796,7 +999,10 @@ var SearchIndex = class {
|
|
|
796
999
|
snippet: row.snippet,
|
|
797
1000
|
matchRole: normalizeMatchRole(row.role),
|
|
798
1001
|
matchMsgIndex: row.msgIndex
|
|
799
|
-
}
|
|
1002
|
+
};
|
|
1003
|
+
if (row.accountId) hit.accountId = row.accountId;
|
|
1004
|
+
if (row.accountLabel) hit.accountLabel = row.accountLabel;
|
|
1005
|
+
hits.push(hit);
|
|
800
1006
|
if (hits.length >= limit) {
|
|
801
1007
|
break;
|
|
802
1008
|
}
|
|
@@ -817,7 +1023,7 @@ var SearchIndex = class {
|
|
|
817
1023
|
async function openSearchIndex(corpus, options = {}) {
|
|
818
1024
|
await corpus.init();
|
|
819
1025
|
const index = new SearchIndex(corpus.paths.indexDbFile);
|
|
820
|
-
if (options.autoBuild !== false && (index.needsRebuild() || index.count() === 0)) {
|
|
1026
|
+
if (options.autoBuild !== false && (index.needsRebuild() || index.count() === 0 && index.countMemories() === 0)) {
|
|
821
1027
|
await index.rebuildFrom(corpus);
|
|
822
1028
|
}
|
|
823
1029
|
return index;
|
|
@@ -848,7 +1054,7 @@ function normalizeMatchRole(role) {
|
|
|
848
1054
|
}
|
|
849
1055
|
|
|
850
1056
|
// src/runtimeInstall.ts
|
|
851
|
-
import { cp, mkdir, readdir, rename, rm, writeFile } from "fs/promises";
|
|
1057
|
+
import { cp, mkdir, readdir as readdir2, rename, rm, writeFile } from "fs/promises";
|
|
852
1058
|
import { existsSync } from "fs";
|
|
853
1059
|
import { homedir as homedir2 } from "os";
|
|
854
1060
|
import { dirname as dirname2, join as join5 } from "path";
|
|
@@ -879,7 +1085,7 @@ async function installRuntime(options = {}) {
|
|
|
879
1085
|
await rm(backupDir, { recursive: true, force: true });
|
|
880
1086
|
await mkdir(stagingDir, { recursive: true });
|
|
881
1087
|
try {
|
|
882
|
-
for (const name of await
|
|
1088
|
+
for (const name of await readdir2(sourceDir)) {
|
|
883
1089
|
await cp(join5(sourceDir, name), join5(stagingDir, name), { recursive: true });
|
|
884
1090
|
}
|
|
885
1091
|
await writeFile(
|
|
@@ -1138,6 +1344,7 @@ function describeOutcome(outcome) {
|
|
|
1138
1344
|
|
|
1139
1345
|
export {
|
|
1140
1346
|
resolveCorpusDir,
|
|
1347
|
+
staleIndexFiles,
|
|
1141
1348
|
Corpus,
|
|
1142
1349
|
ingestFile,
|
|
1143
1350
|
pendingInboxFiles,
|
|
@@ -12,14 +12,15 @@ import {
|
|
|
12
12
|
openSearchIndex,
|
|
13
13
|
resolveCorpusDir,
|
|
14
14
|
runtimeBinDir,
|
|
15
|
+
staleIndexFiles,
|
|
15
16
|
uninstallRuntime
|
|
16
|
-
} from "./chunk-
|
|
17
|
+
} from "./chunk-PEDBH2VJ.js";
|
|
17
18
|
import {
|
|
18
19
|
VERSION,
|
|
19
20
|
__commonJS,
|
|
20
21
|
__require,
|
|
21
22
|
__toESM
|
|
22
|
-
} from "./chunk-
|
|
23
|
+
} from "./chunk-GLKXD34K.js";
|
|
23
24
|
|
|
24
25
|
// ../node_modules/isexe/windows.js
|
|
25
26
|
var require_windows = __commonJS({
|
|
@@ -524,6 +525,7 @@ var require_cross_spawn = __commonJS({
|
|
|
524
525
|
|
|
525
526
|
// src/cli.ts
|
|
526
527
|
import { createInterface } from "readline/promises";
|
|
528
|
+
import { join as join6 } from "path";
|
|
527
529
|
|
|
528
530
|
// ../packages/native-host/install.mjs
|
|
529
531
|
import { execFile } from "child_process";
|
|
@@ -4032,6 +4034,16 @@ ${platforms}
|
|
|
4032
4034
|
} finally {
|
|
4033
4035
|
index.close();
|
|
4034
4036
|
}
|
|
4037
|
+
const stale = await staleIndexFiles(corpus.paths.indexDir);
|
|
4038
|
+
for (const name of stale) {
|
|
4039
|
+
await deleteIndexDb(join6(corpus.paths.indexDir, name));
|
|
4040
|
+
}
|
|
4041
|
+
if (stale.length > 0) {
|
|
4042
|
+
process.stdout.write(
|
|
4043
|
+
`Removed ${stale.length} index file(s) from older versions: ${stale.join(", ")}
|
|
4044
|
+
`
|
|
4045
|
+
);
|
|
4046
|
+
}
|
|
4035
4047
|
return 0;
|
|
4036
4048
|
}
|
|
4037
4049
|
case "search": {
|
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-GLKXD34K.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-6PUR5L35.js");
|
|
145
145
|
return main();
|
|
146
146
|
}
|
|
147
147
|
bootstrap().then((code) => {
|
|
@@ -18,11 +18,17 @@
|
|
|
18
18
|
// without ~/.llmnesia/config.json this host is exactly as read-only as it
|
|
19
19
|
// has always been for plain session indexing.
|
|
20
20
|
//
|
|
21
|
-
// Protocol
|
|
21
|
+
// Protocol v3 (extension → host). v1 was Claude-Code-only: `list` carried no
|
|
22
22
|
// `source` and `get` took a bare sessionId. Both remain accepted, so an
|
|
23
23
|
// extension older than this host keeps working; an extension NEWER than the
|
|
24
24
|
// host detects v1 from the pong and syncs Claude Code only.
|
|
25
|
-
//
|
|
25
|
+
//
|
|
26
|
+
// v3 adds `listMemories`, the RC-03 reverse channel. A v2 host answers it with
|
|
27
|
+
// `{ type: "error", error: "unknown command: listMemories" }` and the extension
|
|
28
|
+
// treats that as "nothing to fetch", so the version is informational: hosts are
|
|
29
|
+
// installed out of band and an extension upgraded ahead of its host is the
|
|
30
|
+
// ordinary case, not a fault.
|
|
31
|
+
// { cmd: "ping" } → { type: "pong", version: 3 }
|
|
26
32
|
// { cmd: "list" } → { type: "list", sessions:
|
|
27
33
|
// [{source, sessionId, mtime, project?, title?}] }
|
|
28
34
|
// { cmd: "get", source, sessionId } → one or more { type: "chunk", sessionId, seq, total, data }
|
|
@@ -33,6 +39,11 @@
|
|
|
33
39
|
// seq, total, data } → { type: "chunkAck", seq }
|
|
34
40
|
// (writes <root>/inbox/<filename> once
|
|
35
41
|
// every chunk 0..total-1 has arrived)
|
|
42
|
+
// { cmd: "listMemories" } → { type: "memories", present,
|
|
43
|
+
// memories: [MemoryDoc], skipped }
|
|
44
|
+
// (reads <root>/memories/*.json; the
|
|
45
|
+
// only read path that touches the
|
|
46
|
+
// corpus rather than a session root)
|
|
36
47
|
//
|
|
37
48
|
// The extension never names a path in either direction: it asks for a
|
|
38
49
|
// (source, sessionId) pair and the host resolves it against its own roots. That
|
|
@@ -42,7 +53,13 @@ import { chmod, mkdir, readdir, readFile, rename, stat, writeFile } from "node:f
|
|
|
42
53
|
import { homedir } from "node:os";
|
|
43
54
|
import { join } from "node:path";
|
|
44
55
|
|
|
45
|
-
|
|
56
|
+
// 3 adds `listMemories` (RC-03). A v2 host answers that command with
|
|
57
|
+
// `{ type: "error", error: "unknown command: listMemories" }`, which the
|
|
58
|
+
// extension treats as "this host is too old, there is nothing to fetch" — so
|
|
59
|
+
// bumping this is informational, not a gate. Do not make it a gate: hosts are
|
|
60
|
+
// installed out of band and a version check would strand every user who
|
|
61
|
+
// upgraded the extension first.
|
|
62
|
+
const PROTOCOL_VERSION = 3;
|
|
46
63
|
const PROJECTS_DIR = join(homedir(), ".claude", "projects");
|
|
47
64
|
const CONFIG_FILE = join(homedir(), ".llmnesia", "config.json");
|
|
48
65
|
// Codex's home, resolved at install time (--codex-home > CODEX_HOME > ~/.codex)
|
|
@@ -69,6 +86,12 @@ const MAX_PUT_TRANSFER_CHARS = 64 * 1024 * 1024;
|
|
|
69
86
|
const MAX_PENDING_WRITE_CHARS = 64 * 1024 * 1024;
|
|
70
87
|
const MAX_PENDING_WRITES = 8;
|
|
71
88
|
const PENDING_WRITE_TTL_MS = 5 * 60 * 1000;
|
|
89
|
+
// RC-03 reverse channel. Memories are one small JSON object each and exist to
|
|
90
|
+
// be reviewed by a person, so a corpus holding thousands of them is a bug
|
|
91
|
+
// upstream, not a case to stream. Capping the reply keeps it inside native
|
|
92
|
+
// messaging's ~1 MB per-message ceiling without a chunking protocol.
|
|
93
|
+
const MAX_MEMORY_FILE_CHARS = 128 * 1024;
|
|
94
|
+
const MAX_MEMORIES_PER_REPLY = 200;
|
|
72
95
|
|
|
73
96
|
function send(message) {
|
|
74
97
|
const body = Buffer.from(JSON.stringify(message), "utf8");
|
|
@@ -355,6 +378,60 @@ async function handlePutChunk(message) {
|
|
|
355
378
|
send({ type: "chunkAck", seq });
|
|
356
379
|
}
|
|
357
380
|
|
|
381
|
+
/**
|
|
382
|
+
* RC-03: read agent-written memories out of `<corpusRoot>/memories/*.json`.
|
|
383
|
+
*
|
|
384
|
+
* This is a different read source from `list`/`get`, which serve session
|
|
385
|
+
* transcripts from ~/.claude/projects and ~/.codex and never touch the corpus.
|
|
386
|
+
* The layout mirrors companion/src/corpus.ts `iterateMemories`: one JSON object
|
|
387
|
+
* per file, named by a slug of its memoryId.
|
|
388
|
+
*
|
|
389
|
+
* Tolerant by design, matching the companion's own reader: a single unreadable
|
|
390
|
+
* or malformed file must not deny the user every other memory waiting for
|
|
391
|
+
* review. Skipped files are counted so the extension can say something honest
|
|
392
|
+
* rather than silently under-reporting.
|
|
393
|
+
*/
|
|
394
|
+
async function readCorpusMemories(corpusRoot) {
|
|
395
|
+
const dir = join(corpusRoot, "memories");
|
|
396
|
+
let names;
|
|
397
|
+
try {
|
|
398
|
+
names = await readdir(dir);
|
|
399
|
+
} catch {
|
|
400
|
+
// No memories directory yet. Not an error: nothing has written one.
|
|
401
|
+
return { memories: [], skipped: 0 };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const memories = [];
|
|
405
|
+
let skipped = 0;
|
|
406
|
+
for (const name of names.sort()) {
|
|
407
|
+
if (!name.endsWith(".json")) {
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
if (memories.length >= MAX_MEMORIES_PER_REPLY) {
|
|
411
|
+
skipped += 1;
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
try {
|
|
415
|
+
const raw = await readFile(join(dir, name), "utf8");
|
|
416
|
+
if (raw.length > MAX_MEMORY_FILE_CHARS) {
|
|
417
|
+
skipped += 1;
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
const record = JSON.parse(raw);
|
|
421
|
+
// memoryId is the identity the extension dedupes on. Without it the
|
|
422
|
+
// record cannot be ingested safely, so drop it rather than invent one.
|
|
423
|
+
if (record && typeof record === "object" && typeof record.memoryId === "string" && record.memoryId) {
|
|
424
|
+
memories.push(record);
|
|
425
|
+
} else {
|
|
426
|
+
skipped += 1;
|
|
427
|
+
}
|
|
428
|
+
} catch {
|
|
429
|
+
skipped += 1;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return { memories, skipped };
|
|
433
|
+
}
|
|
434
|
+
|
|
358
435
|
async function handle(message) {
|
|
359
436
|
if (!message || typeof message !== "object") {
|
|
360
437
|
return;
|
|
@@ -376,6 +453,16 @@ async function handle(message) {
|
|
|
376
453
|
await handlePutChunk(message);
|
|
377
454
|
return;
|
|
378
455
|
}
|
|
456
|
+
if (message.cmd === "listMemories") {
|
|
457
|
+
const config = await loadConfig();
|
|
458
|
+
if (!config?.corpusRoot) {
|
|
459
|
+
send({ type: "memories", present: false, memories: [], skipped: 0 });
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
const { memories, skipped } = await readCorpusMemories(config.corpusRoot);
|
|
463
|
+
send({ type: "memories", present: true, memories, skipped });
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
379
466
|
if (message.cmd === "list") {
|
|
380
467
|
const sessions = await listSessions();
|
|
381
468
|
send({ type: "list", sessions: sessions.map(({ path, ...rest }) => rest) });
|
package/dist/llmnesia.mcpb
CHANGED
|
Binary file
|
package/dist/mcp.js
CHANGED
|
@@ -9,13 +9,13 @@ import {
|
|
|
9
9
|
pendingInboxFiles,
|
|
10
10
|
resolveCorpusDir,
|
|
11
11
|
scheduleSelfUpdate
|
|
12
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-PEDBH2VJ.js";
|
|
13
13
|
import {
|
|
14
14
|
VERSION,
|
|
15
15
|
__commonJS,
|
|
16
16
|
__export,
|
|
17
17
|
__toESM
|
|
18
|
-
} from "./chunk-
|
|
18
|
+
} from "./chunk-GLKXD34K.js";
|
|
19
19
|
|
|
20
20
|
// ../node_modules/ajv/dist/compile/codegen/code.js
|
|
21
21
|
var require_code = __commonJS({
|
|
@@ -21248,24 +21248,176 @@ function sha1(value) {
|
|
|
21248
21248
|
return createHash("sha1").update(value).digest("hex");
|
|
21249
21249
|
}
|
|
21250
21250
|
|
|
21251
|
+
// src/memory.ts
|
|
21252
|
+
import { createHash as createHash2 } from "crypto";
|
|
21253
|
+
var MEMORY_KINDS = ["fact", "preference", "project", "profile"];
|
|
21254
|
+
var DEFAULT_MEMORY_WRITE_CAP = 10;
|
|
21255
|
+
var LABEL_MAX = 120;
|
|
21256
|
+
var TEXT_MAX = 2e3;
|
|
21257
|
+
var TAGS_MAX = 8;
|
|
21258
|
+
var TAG_MAX = 40;
|
|
21259
|
+
var MemoryWriteError = class extends Error {
|
|
21260
|
+
};
|
|
21261
|
+
var MemoryValidationError = class extends MemoryWriteError {
|
|
21262
|
+
};
|
|
21263
|
+
var MemoryWriteCapError = class extends MemoryWriteError {
|
|
21264
|
+
};
|
|
21265
|
+
var MemoryOverwriteRefusedError = class extends MemoryWriteError {
|
|
21266
|
+
};
|
|
21267
|
+
var saveMemorySchema = external_exports.object({
|
|
21268
|
+
kind: external_exports.enum(MEMORY_KINDS).describe(
|
|
21269
|
+
"What sort of memory this is. 'fact' for a durable truth, 'preference' for how the user wants things done, 'project' for context about ongoing work, 'profile' for who the user is."
|
|
21270
|
+
),
|
|
21271
|
+
label: external_exports.string().describe("A short title for the review list, a few words."),
|
|
21272
|
+
text: external_exports.string().describe("The memory itself: one idea, stated so it still makes sense months from now."),
|
|
21273
|
+
tags: external_exports.array(external_exports.string()).optional().describe("Optional short tags for grouping."),
|
|
21274
|
+
sourceDocId: external_exports.string().optional().describe("The id of the conversation this came from, when it came from one."),
|
|
21275
|
+
sourcePlatform: external_exports.string().optional().describe("The platform of the source conversation."),
|
|
21276
|
+
sourceUrl: external_exports.string().optional().describe("Link back to where this came from."),
|
|
21277
|
+
sourceTitle: external_exports.string().optional().describe("Title of the source conversation."),
|
|
21278
|
+
sourceMsgStart: external_exports.number().int().optional().describe("First message index this came from."),
|
|
21279
|
+
sourceMsgEnd: external_exports.number().int().optional().describe("Last message index this came from.")
|
|
21280
|
+
});
|
|
21281
|
+
function parseSaveMemoryInput(payload) {
|
|
21282
|
+
const parsed = saveMemorySchema.safeParse(payload);
|
|
21283
|
+
if (!parsed.success) {
|
|
21284
|
+
throw new MemoryValidationError(describeIssues(parsed.error));
|
|
21285
|
+
}
|
|
21286
|
+
const input = parsed.data;
|
|
21287
|
+
const problems = [];
|
|
21288
|
+
const label = collapse(input.label);
|
|
21289
|
+
const text = collapse(input.text);
|
|
21290
|
+
if (!label) {
|
|
21291
|
+
problems.push("label must not be empty: give the memory a short title, a few words");
|
|
21292
|
+
} else if (label.length > LABEL_MAX) {
|
|
21293
|
+
problems.push(`label is ${label.length} characters; shorten it to ${LABEL_MAX} or fewer`);
|
|
21294
|
+
}
|
|
21295
|
+
if (!text) {
|
|
21296
|
+
problems.push("text must not be empty: this is the memory itself, so it has to say something");
|
|
21297
|
+
} else if (text.length > TEXT_MAX) {
|
|
21298
|
+
problems.push(
|
|
21299
|
+
`text is ${text.length} characters; a memory holds one idea, so shorten it to ${TEXT_MAX} or fewer, or split it into separate memories`
|
|
21300
|
+
);
|
|
21301
|
+
}
|
|
21302
|
+
const tags = normalizeTags(input.tags);
|
|
21303
|
+
if (tags.length > TAGS_MAX) {
|
|
21304
|
+
problems.push(`there are ${tags.length} tags; keep it to ${TAGS_MAX} or fewer`);
|
|
21305
|
+
}
|
|
21306
|
+
if (tags.some((tag) => tag.length > TAG_MAX)) {
|
|
21307
|
+
problems.push(`each tag must be ${TAG_MAX} characters or fewer`);
|
|
21308
|
+
}
|
|
21309
|
+
if (problems.length > 0) {
|
|
21310
|
+
throw new MemoryValidationError(formatProblems(problems));
|
|
21311
|
+
}
|
|
21312
|
+
const cleaned = { ...input, kind: input.kind, label, text };
|
|
21313
|
+
if (tags.length > 0) {
|
|
21314
|
+
cleaned.tags = tags;
|
|
21315
|
+
} else {
|
|
21316
|
+
delete cleaned.tags;
|
|
21317
|
+
}
|
|
21318
|
+
return cleaned;
|
|
21319
|
+
}
|
|
21320
|
+
function normalizeMemoryInput(input, now = Date.now()) {
|
|
21321
|
+
const memory = {
|
|
21322
|
+
memoryId: memoryIdFor(input),
|
|
21323
|
+
kind: input.kind,
|
|
21324
|
+
label: input.label,
|
|
21325
|
+
text: input.text,
|
|
21326
|
+
source: "agent",
|
|
21327
|
+
pinned: false,
|
|
21328
|
+
enabled: false,
|
|
21329
|
+
createdAt: now,
|
|
21330
|
+
updatedAt: now
|
|
21331
|
+
};
|
|
21332
|
+
if (input.sourceDocId) memory.sourceDocId = input.sourceDocId;
|
|
21333
|
+
if (input.sourcePlatform) memory.sourcePlatform = input.sourcePlatform;
|
|
21334
|
+
if (input.sourceUrl) memory.sourceUrl = input.sourceUrl;
|
|
21335
|
+
if (input.sourceTitle) memory.sourceTitle = input.sourceTitle;
|
|
21336
|
+
if (input.sourceMsgStart !== void 0) memory.sourceMsgStart = input.sourceMsgStart;
|
|
21337
|
+
if (input.sourceMsgEnd !== void 0) memory.sourceMsgEnd = input.sourceMsgEnd;
|
|
21338
|
+
if (input.tags && input.tags.length > 0) memory.tags = input.tags;
|
|
21339
|
+
return memory;
|
|
21340
|
+
}
|
|
21341
|
+
function memoryIdFor(input) {
|
|
21342
|
+
const basis = [input.kind, normalizeForHash(input.label), normalizeForHash(input.text)].join("\n");
|
|
21343
|
+
return `agent-${createHash2("sha256").update(basis).digest("hex").slice(0, 32)}`;
|
|
21344
|
+
}
|
|
21345
|
+
function resolveMemoryWriteCap(raw = process.env.LLMNESIA_MEMORY_WRITE_CAP) {
|
|
21346
|
+
if (raw === void 0 || raw.trim() === "") {
|
|
21347
|
+
return DEFAULT_MEMORY_WRITE_CAP;
|
|
21348
|
+
}
|
|
21349
|
+
const parsed = Number(raw);
|
|
21350
|
+
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
21351
|
+
return DEFAULT_MEMORY_WRITE_CAP;
|
|
21352
|
+
}
|
|
21353
|
+
return parsed;
|
|
21354
|
+
}
|
|
21355
|
+
function normalizeTags(tags) {
|
|
21356
|
+
if (!tags) {
|
|
21357
|
+
return [];
|
|
21358
|
+
}
|
|
21359
|
+
const seen = /* @__PURE__ */ new Set();
|
|
21360
|
+
for (const tag of tags) {
|
|
21361
|
+
const cleaned = collapse(tag);
|
|
21362
|
+
if (cleaned) {
|
|
21363
|
+
seen.add(cleaned);
|
|
21364
|
+
}
|
|
21365
|
+
}
|
|
21366
|
+
return [...seen];
|
|
21367
|
+
}
|
|
21368
|
+
function collapse(value) {
|
|
21369
|
+
return value.replace(/\s+/g, " ").trim();
|
|
21370
|
+
}
|
|
21371
|
+
function normalizeForHash(value) {
|
|
21372
|
+
return collapse(value).toLowerCase();
|
|
21373
|
+
}
|
|
21374
|
+
function formatProblems(problems) {
|
|
21375
|
+
return `save_memory could not accept this memory: ${problems.join("; ")}.`;
|
|
21376
|
+
}
|
|
21377
|
+
function describeIssues(error2) {
|
|
21378
|
+
const problems = error2.issues.map((issue2) => {
|
|
21379
|
+
const field = issue2.path.length > 0 ? issue2.path.join(".") : "payload";
|
|
21380
|
+
if (issue2.code === "invalid_type" && issue2.received === "undefined") {
|
|
21381
|
+
return `${field} is required`;
|
|
21382
|
+
}
|
|
21383
|
+
if (issue2.code === "invalid_type") {
|
|
21384
|
+
return `${field} must be a ${issue2.expected}, not a ${issue2.received}`;
|
|
21385
|
+
}
|
|
21386
|
+
if (issue2.code === "invalid_enum_value") {
|
|
21387
|
+
return `${field} must be one of ${MEMORY_KINDS.join(", ")}`;
|
|
21388
|
+
}
|
|
21389
|
+
return `${field}: ${issue2.message.toLowerCase()}`;
|
|
21390
|
+
});
|
|
21391
|
+
return formatProblems([...new Set(problems)]);
|
|
21392
|
+
}
|
|
21393
|
+
|
|
21251
21394
|
// src/service.ts
|
|
21252
21395
|
var STALE_AFTER_HOURS = 48;
|
|
21253
21396
|
var CompanionService = class _CompanionService {
|
|
21254
|
-
constructor(corpus, index, onDrainError) {
|
|
21397
|
+
constructor(corpus, index, onDrainError, memoryWriteCap) {
|
|
21255
21398
|
this.corpus = corpus;
|
|
21256
21399
|
this.index = index;
|
|
21257
21400
|
this.onDrainError = onDrainError;
|
|
21401
|
+
this.memoryWriteCap = memoryWriteCap;
|
|
21258
21402
|
}
|
|
21259
21403
|
corpus;
|
|
21260
21404
|
index;
|
|
21261
21405
|
onDrainError;
|
|
21406
|
+
memoryWriteCap;
|
|
21262
21407
|
// Serialize drains so concurrent reads queue behind one ingest rather than
|
|
21263
21408
|
// racing to consume the same file (mirrors Corpus.enqueueWrite).
|
|
21264
21409
|
refreshChain = Promise.resolve();
|
|
21410
|
+
/** Memories written since this process started; the per-session cap counts these. */
|
|
21411
|
+
memoriesWritten = 0;
|
|
21265
21412
|
static async open(root, options = {}) {
|
|
21266
21413
|
const corpus = new Corpus(root);
|
|
21267
21414
|
const index = await openSearchIndex(corpus);
|
|
21268
|
-
const service = new _CompanionService(
|
|
21415
|
+
const service = new _CompanionService(
|
|
21416
|
+
corpus,
|
|
21417
|
+
index,
|
|
21418
|
+
options.onDrainError ?? noop,
|
|
21419
|
+
options.memoryWriteCap ?? DEFAULT_MEMORY_WRITE_CAP
|
|
21420
|
+
);
|
|
21269
21421
|
await service.refresh();
|
|
21270
21422
|
return service;
|
|
21271
21423
|
}
|
|
@@ -21362,6 +21514,38 @@ var CompanionService = class _CompanionService {
|
|
|
21362
21514
|
}
|
|
21363
21515
|
return { docId: result.docId, outcome: result.outcome, messageCount: record2.messages.length };
|
|
21364
21516
|
}
|
|
21517
|
+
/**
|
|
21518
|
+
* Validate, normalize, persist an agent-written memory, and index it.
|
|
21519
|
+
*
|
|
21520
|
+
* Throws for anything the caller should hear about and act on: a payload that
|
|
21521
|
+
* is not a memory, a spent write budget, or an id already held by a record
|
|
21522
|
+
* this path may not touch. A memory that was already stored is not an error,
|
|
21523
|
+
* it is the dedupe working.
|
|
21524
|
+
*/
|
|
21525
|
+
async saveMemory(payload) {
|
|
21526
|
+
const input = parseSaveMemoryInput(payload);
|
|
21527
|
+
const memory = normalizeMemoryInput(input);
|
|
21528
|
+
if (this.memoriesWritten >= this.memoryWriteCap) {
|
|
21529
|
+
throw new MemoryWriteCapError(
|
|
21530
|
+
`save_memory has already stored ${this.memoryWriteCap} memories in this session, which is the limit. Every stored memory competes for the same review queue and prompt budget, so keep the rest for the next session and record only the single most durable conclusion if one is left.`
|
|
21531
|
+
);
|
|
21532
|
+
}
|
|
21533
|
+
const result = await this.corpus.upsertMemory(memory);
|
|
21534
|
+
if (result.outcome === "refused") {
|
|
21535
|
+
throw new MemoryOverwriteRefusedError(
|
|
21536
|
+
`save_memory will not overwrite the existing record ${result.memoryId}, which was not written by an agent. Captured conversations and memories the user wrote themselves can only be changed by the user.`
|
|
21537
|
+
);
|
|
21538
|
+
}
|
|
21539
|
+
if (result.outcome === "created") {
|
|
21540
|
+
this.memoriesWritten += 1;
|
|
21541
|
+
this.index.upsertMemory(memory);
|
|
21542
|
+
}
|
|
21543
|
+
return {
|
|
21544
|
+
memoryId: result.memoryId,
|
|
21545
|
+
outcome: result.outcome,
|
|
21546
|
+
remainingWrites: Math.max(0, this.memoryWriteCap - this.memoriesWritten)
|
|
21547
|
+
};
|
|
21548
|
+
}
|
|
21365
21549
|
close() {
|
|
21366
21550
|
this.index.close();
|
|
21367
21551
|
}
|
|
@@ -21418,6 +21602,10 @@ var LazyResource = class {
|
|
|
21418
21602
|
function jsonResult(payload) {
|
|
21419
21603
|
return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
|
|
21420
21604
|
}
|
|
21605
|
+
var SOURCE_LINK_INSTRUCTION = "When answering from these conversations, always include a Sources section with a clickable Markdown link to every original chat you relied on whose `url` is non-empty. Do this automatically; do not wait for the user to ask for links.";
|
|
21606
|
+
function sourcedJsonResult(payload) {
|
|
21607
|
+
return jsonResult({ responseInstruction: SOURCE_LINK_INSTRUCTION, ...payload });
|
|
21608
|
+
}
|
|
21421
21609
|
function errorResult(message) {
|
|
21422
21610
|
return { content: [{ type: "text", text: message }], isError: true };
|
|
21423
21611
|
}
|
|
@@ -21443,6 +21631,7 @@ async function runServe() {
|
|
|
21443
21631
|
const root = resolveCorpusDir(process.env.LLMNESIA_CORPUS_DIR);
|
|
21444
21632
|
const service = new LazyResource(
|
|
21445
21633
|
() => CompanionService.open(root, {
|
|
21634
|
+
memoryWriteCap: resolveMemoryWriteCap(),
|
|
21446
21635
|
// stdout is the MCP JSON-RPC channel, so diagnostics go to stderr.
|
|
21447
21636
|
onDrainError: (error2) => process.stderr.write(
|
|
21448
21637
|
`llmnesia: inbox drain failed, serving the existing index: ${error2 instanceof Error ? error2.message : String(error2)}
|
|
@@ -21463,14 +21652,14 @@ async function runServe() {
|
|
|
21463
21652
|
const server = new McpServer(
|
|
21464
21653
|
{ name: "llmnesia", version: VERSION },
|
|
21465
21654
|
{
|
|
21466
|
-
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."
|
|
21655
|
+
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. Whenever you answer using LLMnesia, always include a Sources section with clickable links to the original chats you used when their `url` fields are non-empty; never wait for the user to ask."
|
|
21467
21656
|
}
|
|
21468
21657
|
);
|
|
21469
21658
|
server.registerTool(
|
|
21470
21659
|
"search_conversations",
|
|
21471
21660
|
{
|
|
21472
21661
|
title: "Search conversations",
|
|
21473
|
-
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.",
|
|
21662
|
+
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. Every result includes its original-chat URL when available; always link the chats used in the answer.",
|
|
21474
21663
|
inputSchema: {
|
|
21475
21664
|
query: external_exports.string().describe("Search terms (natural words; FTS operators are not needed)."),
|
|
21476
21665
|
match_mode: external_exports.enum(["any", "all", "phrase"]).optional().describe(
|
|
@@ -21500,21 +21689,26 @@ async function runServe() {
|
|
|
21500
21689
|
};
|
|
21501
21690
|
const hits = await service2.search(args.query, filters);
|
|
21502
21691
|
const status = await service2.corpusStatus({ refresh: false });
|
|
21503
|
-
return
|
|
21692
|
+
return sourcedJsonResult({
|
|
21504
21693
|
count: hits.length,
|
|
21505
21694
|
corpus: corpusField(status),
|
|
21506
|
-
results: hits.map((h) =>
|
|
21507
|
-
|
|
21508
|
-
|
|
21509
|
-
|
|
21510
|
-
|
|
21511
|
-
|
|
21512
|
-
|
|
21513
|
-
|
|
21514
|
-
|
|
21515
|
-
|
|
21516
|
-
|
|
21517
|
-
|
|
21695
|
+
results: hits.map((h) => {
|
|
21696
|
+
const entry = {
|
|
21697
|
+
id: h.docId,
|
|
21698
|
+
title: h.title,
|
|
21699
|
+
platform: h.platform,
|
|
21700
|
+
url: h.url,
|
|
21701
|
+
createdAt: h.createdAt,
|
|
21702
|
+
score: Number(h.score.toFixed(4)),
|
|
21703
|
+
snippet: h.snippet,
|
|
21704
|
+
matchedIn: h.matchRole === "title" ? "title" : `${h.matchRole} turn ${h.matchMsgIndex}`,
|
|
21705
|
+
pinned: h.pinned,
|
|
21706
|
+
messageCount: h.messageCount
|
|
21707
|
+
};
|
|
21708
|
+
if (h.accountId) entry.accountId = h.accountId;
|
|
21709
|
+
if (h.accountLabel) entry.accountLabel = h.accountLabel;
|
|
21710
|
+
return entry;
|
|
21711
|
+
})
|
|
21518
21712
|
});
|
|
21519
21713
|
})
|
|
21520
21714
|
);
|
|
@@ -21522,7 +21716,7 @@ async function runServe() {
|
|
|
21522
21716
|
"get_conversation",
|
|
21523
21717
|
{
|
|
21524
21718
|
title: "Get conversation",
|
|
21525
|
-
description: "Return the full transcript of one conversation by its id (the `id` from search results).",
|
|
21719
|
+
description: "Return the full transcript of one conversation by its id (the `id` from search results). The conversation includes its original-chat URL when available; always link it in an answer based on this transcript.",
|
|
21526
21720
|
inputSchema: {
|
|
21527
21721
|
id: external_exports.string().describe("The conversation id (docId), e.g. 'claude:abc-123'.")
|
|
21528
21722
|
},
|
|
@@ -21538,7 +21732,7 @@ async function runServe() {
|
|
|
21538
21732
|
if (!record2) {
|
|
21539
21733
|
return errorResult(`No conversation found with id '${args.id}'.`);
|
|
21540
21734
|
}
|
|
21541
|
-
return
|
|
21735
|
+
return sourcedJsonResult({
|
|
21542
21736
|
conversation: record2.conversation,
|
|
21543
21737
|
messages: record2.messages.map((m) => ({
|
|
21544
21738
|
index: m.msgIndex,
|
|
@@ -21553,7 +21747,7 @@ async function runServe() {
|
|
|
21553
21747
|
"list_recent",
|
|
21554
21748
|
{
|
|
21555
21749
|
title: "List recent conversations",
|
|
21556
|
-
description: "List the user's most recently updated conversations (title + summary), newest first.",
|
|
21750
|
+
description: "List the user's most recently updated conversations (title + summary + original-chat URL), newest first. Always link the chats used in the answer.",
|
|
21557
21751
|
inputSchema: {
|
|
21558
21752
|
limit: external_exports.number().int().min(1).max(100).optional().describe("How many to return (default 10).")
|
|
21559
21753
|
},
|
|
@@ -21567,18 +21761,24 @@ async function runServe() {
|
|
|
21567
21761
|
async (args) => withService(async (service2) => {
|
|
21568
21762
|
const entries = await service2.listRecent(args.limit ?? 10);
|
|
21569
21763
|
const status = await service2.corpusStatus({ refresh: false });
|
|
21570
|
-
return
|
|
21764
|
+
return sourcedJsonResult({
|
|
21571
21765
|
count: entries.length,
|
|
21572
21766
|
corpus: corpusField(status),
|
|
21573
|
-
conversations: entries.map((e) =>
|
|
21574
|
-
|
|
21575
|
-
|
|
21576
|
-
|
|
21577
|
-
|
|
21578
|
-
|
|
21579
|
-
|
|
21580
|
-
|
|
21581
|
-
|
|
21767
|
+
conversations: entries.map((e) => {
|
|
21768
|
+
const entry = {
|
|
21769
|
+
id: e.docId,
|
|
21770
|
+
title: e.title,
|
|
21771
|
+
platform: e.platform,
|
|
21772
|
+
url: e.url,
|
|
21773
|
+
updatedAt: e.updatedAt,
|
|
21774
|
+
messageCount: e.messageCount,
|
|
21775
|
+
pinned: e.pinned,
|
|
21776
|
+
summary: e.summary ?? e.preview
|
|
21777
|
+
};
|
|
21778
|
+
if (e.accountId) entry.accountId = e.accountId;
|
|
21779
|
+
if (e.accountLabel) entry.accountLabel = e.accountLabel;
|
|
21780
|
+
return entry;
|
|
21781
|
+
})
|
|
21582
21782
|
});
|
|
21583
21783
|
})
|
|
21584
21784
|
);
|
|
@@ -21586,7 +21786,7 @@ async function runServe() {
|
|
|
21586
21786
|
"list_conversations",
|
|
21587
21787
|
{
|
|
21588
21788
|
title: "List conversations by date",
|
|
21589
|
-
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.",
|
|
21789
|
+
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. Entries include original-chat URLs when available; always link the chats used in the answer.",
|
|
21590
21790
|
inputSchema: {
|
|
21591
21791
|
sort: external_exports.enum(["recent", "oldest", "newest"]).optional().describe("Chronology: recent (default, updated), oldest (created), or newest (created)."),
|
|
21592
21792
|
limit: external_exports.number().int().min(1).max(100).optional().describe("How many to return (default 10).")
|
|
@@ -21601,20 +21801,26 @@ async function runServe() {
|
|
|
21601
21801
|
async (args) => withService(async (service2) => {
|
|
21602
21802
|
const entries = await service2.listConversations(args.limit ?? 10, args.sort ?? "recent");
|
|
21603
21803
|
const status = await service2.corpusStatus({ refresh: false });
|
|
21604
|
-
return
|
|
21804
|
+
return sourcedJsonResult({
|
|
21605
21805
|
count: entries.length,
|
|
21606
21806
|
sort: args.sort ?? "recent",
|
|
21607
21807
|
corpus: corpusField(status),
|
|
21608
|
-
conversations: entries.map((e) =>
|
|
21609
|
-
|
|
21610
|
-
|
|
21611
|
-
|
|
21612
|
-
|
|
21613
|
-
|
|
21614
|
-
|
|
21615
|
-
|
|
21616
|
-
|
|
21617
|
-
|
|
21808
|
+
conversations: entries.map((e) => {
|
|
21809
|
+
const entry = {
|
|
21810
|
+
id: e.docId,
|
|
21811
|
+
title: e.title,
|
|
21812
|
+
platform: e.platform,
|
|
21813
|
+
url: e.url,
|
|
21814
|
+
createdAt: e.createdAt,
|
|
21815
|
+
updatedAt: e.updatedAt,
|
|
21816
|
+
messageCount: e.messageCount,
|
|
21817
|
+
pinned: e.pinned,
|
|
21818
|
+
summary: e.summary ?? e.preview
|
|
21819
|
+
};
|
|
21820
|
+
if (e.accountId) entry.accountId = e.accountId;
|
|
21821
|
+
if (e.accountLabel) entry.accountLabel = e.accountLabel;
|
|
21822
|
+
return entry;
|
|
21823
|
+
})
|
|
21618
21824
|
});
|
|
21619
21825
|
})
|
|
21620
21826
|
);
|
|
@@ -21659,6 +21865,31 @@ async function runServe() {
|
|
|
21659
21865
|
}
|
|
21660
21866
|
})
|
|
21661
21867
|
);
|
|
21868
|
+
server.registerTool(
|
|
21869
|
+
"save_memory",
|
|
21870
|
+
{
|
|
21871
|
+
title: "Save memory",
|
|
21872
|
+
description: "Record one durable thing worth carrying into the user's later chats, on any platform. Write a memory when this session reached a conclusion worth keeping, when a decision was made and the reasoning behind it matters later, when an older conversation revealed a lasting fact or preference about the user or their work, or when something now known supersedes or corrects what the corpus already holds. State it so it still makes sense months from now, on its own, without this conversation to explain it. Do not use this to summarise a conversation, to save someone else's conversation, or to restate something already captured; use save_conversation for a transcript. Memories are stored turned off and stay invisible until the user reviews and enables them, so write few and write them well. Identity is a hash of the content, so saving the same memory twice stores it once.",
|
|
21873
|
+
inputSchema: saveMemorySchema.shape,
|
|
21874
|
+
annotations: {
|
|
21875
|
+
readOnlyHint: false,
|
|
21876
|
+
destructiveHint: false,
|
|
21877
|
+
idempotentHint: true,
|
|
21878
|
+
openWorldHint: false
|
|
21879
|
+
}
|
|
21880
|
+
},
|
|
21881
|
+
async (args) => withService(async (service2) => {
|
|
21882
|
+
try {
|
|
21883
|
+
const result = await service2.saveMemory(args);
|
|
21884
|
+
return jsonResult(result);
|
|
21885
|
+
} catch (err) {
|
|
21886
|
+
if (err instanceof MemoryWriteError) {
|
|
21887
|
+
return errorResult(err.message);
|
|
21888
|
+
}
|
|
21889
|
+
return errorResult(`save_memory failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
21890
|
+
}
|
|
21891
|
+
})
|
|
21892
|
+
);
|
|
21662
21893
|
}
|
|
21663
21894
|
server.server.oninitialized = () => {
|
|
21664
21895
|
service.start();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@llmnesia/mcp",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
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.1
|
|
6
|
+
"version": "0.2.1",
|
|
7
7
|
"packages": [
|
|
8
8
|
{
|
|
9
9
|
"registryType": "npm",
|
|
10
10
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
11
11
|
"identifier": "@llmnesia/mcp",
|
|
12
|
-
"version": "0.1
|
|
12
|
+
"version": "0.2.1",
|
|
13
13
|
"transport": {
|
|
14
14
|
"type": "stdio"
|
|
15
15
|
}
|