@llmnesia/mcp 0.1.15 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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. See [`docs/CLAUDE_DESKTOP_RELEASE_PLAN.md`](../docs/CLAUDE_DESKTOP_RELEASE_PLAN.md)
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 six tools over the corpus:
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-YYNCL7SQ.js";
6
+ } from "./chunk-ACIWPSVH.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, "llmnesia.db");
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 is missing or stale — caller rebuilds. */
798
+ /** True when the stored format version was missing or stale — caller rebuilds. */
641
799
  needsRebuild() {
642
- const stored = this.getMeta("format_version");
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) => ({ ...r, pinned: r.pinned === 1 }));
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
- hits.push({
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 readdir(sourceDir)) {
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,
@@ -38,7 +38,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
38
38
  ));
39
39
 
40
40
  // src/version.ts
41
- var VERSION = true ? "0.1.15" : "0.0.0-dev";
41
+ var VERSION = true ? "0.2.0" : "0.0.0-dev";
42
42
 
43
43
  export {
44
44
  __require,
@@ -12,14 +12,15 @@ import {
12
12
  openSearchIndex,
13
13
  resolveCorpusDir,
14
14
  runtimeBinDir,
15
+ staleIndexFiles,
15
16
  uninstallRuntime
16
- } from "./chunk-C7ZUT5N7.js";
17
+ } from "./chunk-53KPHHH2.js";
17
18
  import {
18
19
  VERSION,
19
20
  __commonJS,
20
21
  __require,
21
22
  __toESM
22
- } from "./chunk-YYNCL7SQ.js";
23
+ } from "./chunk-ACIWPSVH.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-YYNCL7SQ.js";
6
+ } from "./chunk-ACIWPSVH.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-BQZPGQQX.js");
144
+ const { main } = await import("./cli-VB4QKSGH.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 v2 (extension → host). v1 was Claude-Code-only: `list` carried no
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
- // { cmd: "ping" } → { type: "pong", version: 2 }
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
- const PROTOCOL_VERSION = 2;
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) });
Binary file
package/dist/mcp.js CHANGED
@@ -9,13 +9,13 @@ import {
9
9
  pendingInboxFiles,
10
10
  resolveCorpusDir,
11
11
  scheduleSelfUpdate
12
- } from "./chunk-C7ZUT5N7.js";
12
+ } from "./chunk-53KPHHH2.js";
13
13
  import {
14
14
  VERSION,
15
15
  __commonJS,
16
16
  __export,
17
17
  __toESM
18
- } from "./chunk-YYNCL7SQ.js";
18
+ } from "./chunk-ACIWPSVH.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(corpus, index, options.onDrainError ?? noop);
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
  }
@@ -21443,6 +21627,7 @@ async function runServe() {
21443
21627
  const root = resolveCorpusDir(process.env.LLMNESIA_CORPUS_DIR);
21444
21628
  const service = new LazyResource(
21445
21629
  () => CompanionService.open(root, {
21630
+ memoryWriteCap: resolveMemoryWriteCap(),
21446
21631
  // stdout is the MCP JSON-RPC channel, so diagnostics go to stderr.
21447
21632
  onDrainError: (error2) => process.stderr.write(
21448
21633
  `llmnesia: inbox drain failed, serving the existing index: ${error2 instanceof Error ? error2.message : String(error2)}
@@ -21503,18 +21688,23 @@ async function runServe() {
21503
21688
  return jsonResult({
21504
21689
  count: hits.length,
21505
21690
  corpus: corpusField(status),
21506
- results: hits.map((h) => ({
21507
- id: h.docId,
21508
- title: h.title,
21509
- platform: h.platform,
21510
- url: h.url,
21511
- createdAt: h.createdAt,
21512
- score: Number(h.score.toFixed(4)),
21513
- snippet: h.snippet,
21514
- matchedIn: h.matchRole === "title" ? "title" : `${h.matchRole} turn ${h.matchMsgIndex}`,
21515
- pinned: h.pinned,
21516
- messageCount: h.messageCount
21517
- }))
21691
+ results: hits.map((h) => {
21692
+ const entry = {
21693
+ id: h.docId,
21694
+ title: h.title,
21695
+ platform: h.platform,
21696
+ url: h.url,
21697
+ createdAt: h.createdAt,
21698
+ score: Number(h.score.toFixed(4)),
21699
+ snippet: h.snippet,
21700
+ matchedIn: h.matchRole === "title" ? "title" : `${h.matchRole} turn ${h.matchMsgIndex}`,
21701
+ pinned: h.pinned,
21702
+ messageCount: h.messageCount
21703
+ };
21704
+ if (h.accountId) entry.accountId = h.accountId;
21705
+ if (h.accountLabel) entry.accountLabel = h.accountLabel;
21706
+ return entry;
21707
+ })
21518
21708
  });
21519
21709
  })
21520
21710
  );
@@ -21570,15 +21760,20 @@ async function runServe() {
21570
21760
  return jsonResult({
21571
21761
  count: entries.length,
21572
21762
  corpus: corpusField(status),
21573
- conversations: entries.map((e) => ({
21574
- id: e.docId,
21575
- title: e.title,
21576
- platform: e.platform,
21577
- updatedAt: e.updatedAt,
21578
- messageCount: e.messageCount,
21579
- pinned: e.pinned,
21580
- summary: e.summary ?? e.preview
21581
- }))
21763
+ conversations: entries.map((e) => {
21764
+ const entry = {
21765
+ id: e.docId,
21766
+ title: e.title,
21767
+ platform: e.platform,
21768
+ updatedAt: e.updatedAt,
21769
+ messageCount: e.messageCount,
21770
+ pinned: e.pinned,
21771
+ summary: e.summary ?? e.preview
21772
+ };
21773
+ if (e.accountId) entry.accountId = e.accountId;
21774
+ if (e.accountLabel) entry.accountLabel = e.accountLabel;
21775
+ return entry;
21776
+ })
21582
21777
  });
21583
21778
  })
21584
21779
  );
@@ -21605,16 +21800,21 @@ async function runServe() {
21605
21800
  count: entries.length,
21606
21801
  sort: args.sort ?? "recent",
21607
21802
  corpus: corpusField(status),
21608
- conversations: entries.map((e) => ({
21609
- id: e.docId,
21610
- title: e.title,
21611
- platform: e.platform,
21612
- createdAt: e.createdAt,
21613
- updatedAt: e.updatedAt,
21614
- messageCount: e.messageCount,
21615
- pinned: e.pinned,
21616
- summary: e.summary ?? e.preview
21617
- }))
21803
+ conversations: entries.map((e) => {
21804
+ const entry = {
21805
+ id: e.docId,
21806
+ title: e.title,
21807
+ platform: e.platform,
21808
+ createdAt: e.createdAt,
21809
+ updatedAt: e.updatedAt,
21810
+ messageCount: e.messageCount,
21811
+ pinned: e.pinned,
21812
+ summary: e.summary ?? e.preview
21813
+ };
21814
+ if (e.accountId) entry.accountId = e.accountId;
21815
+ if (e.accountLabel) entry.accountLabel = e.accountLabel;
21816
+ return entry;
21817
+ })
21618
21818
  });
21619
21819
  })
21620
21820
  );
@@ -21659,6 +21859,31 @@ async function runServe() {
21659
21859
  }
21660
21860
  })
21661
21861
  );
21862
+ server.registerTool(
21863
+ "save_memory",
21864
+ {
21865
+ title: "Save memory",
21866
+ 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.",
21867
+ inputSchema: saveMemorySchema.shape,
21868
+ annotations: {
21869
+ readOnlyHint: false,
21870
+ destructiveHint: false,
21871
+ idempotentHint: true,
21872
+ openWorldHint: false
21873
+ }
21874
+ },
21875
+ async (args) => withService(async (service2) => {
21876
+ try {
21877
+ const result = await service2.saveMemory(args);
21878
+ return jsonResult(result);
21879
+ } catch (err) {
21880
+ if (err instanceof MemoryWriteError) {
21881
+ return errorResult(err.message);
21882
+ }
21883
+ return errorResult(`save_memory failed: ${err instanceof Error ? err.message : String(err)}`);
21884
+ }
21885
+ })
21886
+ );
21662
21887
  }
21663
21888
  server.server.oninitialized = () => {
21664
21889
  service.start();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llmnesia/mcp",
3
- "version": "0.1.15",
3
+ "version": "0.2.0",
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.15",
6
+ "version": "0.2.0",
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.15",
12
+ "version": "0.2.0",
13
13
  "transport": {
14
14
  "type": "stdio"
15
15
  }