memoir-cli 3.11.2 → 3.12.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/src/mcp.js CHANGED
@@ -27,119 +27,18 @@ import {
27
27
  import { renderSession } from './session/render.js';
28
28
  import { injectInto, detectAvailableTargets } from './session/inject.js';
29
29
  import { findDecisions } from './commands/why.js';
30
+ import { matchDecisions, hideDecision } from './session/state.js';
31
+ import { readMemoryFiles, searchMemories, formatRecallResults, withFrontmatterLists } from './memory/search.js';
30
32
  import { capture as track } from './telemetry.js';
33
+ import { createRequire } from 'module';
31
34
 
32
35
  const home = os.homedir();
36
+ const { version: VERSION } = createRequire(import.meta.url)('../package.json');
33
37
 
34
38
  // ── Helpers ──────────────────────────────────────────────────────────────────
35
-
36
- /**
37
- * Read all memory files from a tool adapter's source directory
38
- */
39
- async function readMemoryFiles(adapter) {
40
- const files = [];
41
-
42
- if (adapter.customExtract) {
43
- for (const file of adapter.files) {
44
- const filePath = path.join(adapter.source, file);
45
- if (await fs.pathExists(filePath)) {
46
- try {
47
- const content = await fs.readFile(filePath, 'utf8');
48
- files.push({ path: file, content, tool: adapter.name });
49
- } catch {}
50
- }
51
- }
52
- return files;
53
- }
54
-
55
- if (!(await fs.pathExists(adapter.source))) return files;
56
-
57
- const walk = async (dir, prefix = '') => {
58
- let entries;
59
- try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return; }
60
-
61
- for (const entry of entries) {
62
- const fullPath = path.join(dir, entry.name);
63
- const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
64
-
65
- if (entry.isDirectory()) {
66
- if (adapter.filter(fullPath)) {
67
- await walk(fullPath, relPath);
68
- }
69
- } else if (entry.name.endsWith('.md') || entry.name.endsWith('.json') || entry.name.endsWith('.yml') || entry.name.endsWith('.yaml')) {
70
- if (adapter.filter(fullPath)) {
71
- try {
72
- const content = await fs.readFile(fullPath, 'utf8');
73
- files.push({ path: relPath, content, tool: adapter.name });
74
- } catch {}
75
- }
76
- }
77
- }
78
- };
79
-
80
- await walk(adapter.source);
81
- return files;
82
- }
83
-
84
- /**
85
- * Search across all memory files for a query (case-insensitive keyword match)
86
- */
87
- async function searchMemories(query) {
88
- const results = [];
89
- const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
90
-
91
- for (const adapter of adapters) {
92
- const files = await readMemoryFiles(adapter);
93
- for (const file of files) {
94
- const lower = file.content.toLowerCase();
95
- const score = terms.reduce((s, t) => s + (lower.includes(t) ? 1 : 0), 0);
96
- if (score > 0) {
97
- results.push({ ...file, score, relevance: score / terms.length });
98
- }
99
- }
100
- }
101
-
102
- // Also search per-project AI config files
103
- const projectFiles = ['CLAUDE.md', 'GEMINI.md', 'CHATGPT.md', '.cursorrules', '.windsurfrules', '.clinerules'];
104
- const skipDirs = new Set(['node_modules', '.git', '.next', '.vercel', 'dist', 'build', '__pycache__', '.venv', 'venv', '.cache', 'Library', '.Trash', 'Applications', 'Downloads']);
105
-
106
- const scanProjects = async (dir, depth = 0) => {
107
- if (depth > 3) return;
108
- let entries;
109
- try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return; }
110
-
111
- for (const file of projectFiles) {
112
- const filePath = path.join(dir, file);
113
- if (await fs.pathExists(filePath)) {
114
- try {
115
- const content = await fs.readFile(filePath, 'utf8');
116
- const lower = content.toLowerCase();
117
- const score = terms.reduce((s, t) => s + (lower.includes(t) ? 1 : 0), 0);
118
- if (score > 0) {
119
- results.push({
120
- path: `${path.basename(dir)}/${file}`,
121
- content,
122
- tool: `Project: ${path.basename(dir)}`,
123
- score,
124
- relevance: score / terms.length
125
- });
126
- }
127
- } catch {}
128
- }
129
- }
130
-
131
- for (const entry of entries) {
132
- if (!entry.isDirectory()) continue;
133
- if (entry.name.startsWith('.') && entry.name !== '.github') continue;
134
- if (skipDirs.has(entry.name)) continue;
135
- await scanProjects(path.join(dir, entry.name), depth + 1);
136
- }
137
- };
138
-
139
- await scanProjects(home);
140
-
141
- return results.sort((a, b) => b.score - a.score);
142
- }
39
+ // readMemoryFiles / searchMemories live in ./memory/search.js (cached,
40
+ // field-weighted, passage-returning) so the CLI's `memoir recall` and tests
41
+ // share one implementation with this server.
143
42
 
144
43
  /**
145
44
  * Get list of detected tools with status
@@ -164,7 +63,7 @@ async function getDetectedTools() {
164
63
 
165
64
  const server = new McpServer({
166
65
  name: 'memoir',
167
- version: '3.2.0',
66
+ version: VERSION,
168
67
  }, {
169
68
  capabilities: {
170
69
  tools: {},
@@ -236,46 +135,30 @@ server.tool(
236
135
 
237
136
  server.tool(
238
137
  'memoir_recall',
239
- 'Search across all AI tool memories, project configs, and session context for relevant information. Use this to find what you know about a topic, project, or tool.',
240
- { query: z.string().describe('Search query — keywords or topic to find in memories') },
241
- async ({ query }) => {
242
- const results = await searchMemories(query);
243
-
244
- if (results.length === 0) {
245
- return {
246
- content: [{ type: 'text', text: `No memories found matching "${query}".` }]
247
- };
248
- }
249
-
250
- // Return top 10 results with content
251
- const top = results.slice(0, 10);
252
- const output = top.map((r, i) => {
253
- const preview = r.content.length > 500 ? r.content.slice(0, 500) + '...' : r.content;
254
- return [
255
- `── ${i + 1}. ${r.tool} / ${r.path} (relevance: ${Math.round(r.relevance * 100)}%) ──`,
256
- preview,
257
- ].join('\n');
258
- }).join('\n\n');
259
-
260
- return {
261
- content: [{
262
- type: 'text',
263
- text: `Found ${results.length} memories matching "${query}":\n\n${output}`
264
- }]
265
- };
138
+ 'Search across all AI tool memories, project configs, and session context for relevant information. Returns the matched passages (not file headers) from the best files, ranked by how well each file covers all your terms — aliases, names, and descriptions weigh more than body prose. Use this before answering questions about a project, a past decision, or a tool. Use memoir_read to see a whole file.',
139
+ {
140
+ query: z.string().describe('Search query keywords or topic to find in memories. Multi-word queries rank files that match every word highest.'),
141
+ limit: z.number().int().min(1).max(30).optional().describe('Max results to return (default 10)'),
142
+ },
143
+ async ({ query, limit }) => {
144
+ const res = await searchMemories(query, { limit: limit || 10 });
145
+ return { content: [{ type: 'text', text: formatRecallResults(query, res) }] };
266
146
  }
267
147
  );
268
148
 
269
149
  server.tool(
270
150
  'memoir_remember',
271
- 'Save a memory to a specific AI tool\'s memory files. Use this to persist important context, decisions, or facts for future sessions.',
151
+ 'Save a memory to a specific AI tool\'s memory files. Use this to persist important context, decisions, or facts for future sessions. Give the file frontmatter (type, name, description) and ALWAYS pass aliases — the other names, nicknames, or phrasings someone might search for this under (e.g. a "vertical swipe feed" surface should carry aliases like "tiktok", "reels", "/tape"). Recall weights aliases heaviest; a memory without them can only be found by the exact words it happens to use.',
272
152
  {
273
- content: z.string().describe('The memory content to save (markdown format)'),
153
+ content: z.string().describe('The memory content to save (markdown, ideally with --- frontmatter: type, name, description)'),
274
154
  filename: z.string().describe('Filename for the memory (e.g. "auth-setup.md", "project-goals.md")'),
155
+ aliases: z.array(z.string()).optional().describe('Other names/phrasings this memory should be findable under. Written into frontmatter `aliases:`. Strongly recommended.'),
156
+ tags: z.array(z.string()).optional().describe('Topic tags. Written into frontmatter `tags:`.'),
275
157
  tool: z.string().optional().describe('Which AI tool to save to: "claude", "gemini", "cursor", etc. Defaults to claude.'),
276
158
  project: z.string().optional().describe('Project directory path to save a project-level memory (e.g. CLAUDE.md). If provided, saves to that project directory instead of global tool config.'),
277
159
  },
278
- async ({ content, filename, tool, project }) => {
160
+ async ({ content, filename, aliases, tags, tool, project }) => {
161
+ content = withFrontmatterLists(content, { aliases, tags });
279
162
  // Project-level memory
280
163
  if (project) {
281
164
  const projectDir = project.startsWith('/') ? project : path.join(home, project);
@@ -739,6 +622,38 @@ server.tool(
739
622
  }
740
623
  );
741
624
 
625
+ server.tool(
626
+ 'memoir_forget',
627
+ 'Forget a recorded decision — permanently hides it from the pinned block, memoir_why, and every synced machine (an absolute tombstone; there is no un-forget). Use when the user says a decision is wrong, obsolete, or was captured by mistake, or when a secret leaked into a decision. Refuses to act if the text matches more than one decision — call again with a more specific string. Pass purge=true to also redact the text in place (for secrets).',
628
+ {
629
+ text: z.string().describe('The decision text, or a substring unique to it'),
630
+ purge: z.boolean().optional().describe('Also redact the text/why/rejected in place, keeping only a hash. For leaked secrets. Default false.'),
631
+ },
632
+ async ({ text, purge }) => {
633
+ const state = await readSession();
634
+ const matches = matchDecisions(state, text);
635
+ if (matches.length === 0) {
636
+ return { content: [{ type: 'text', text: `No visible decision matches "${text}". Nothing forgotten.` }] };
637
+ }
638
+ if (matches.length > 1) {
639
+ const list = matches.map(d => `● ${d.text}`).join('\n');
640
+ return { content: [{ type: 'text', text: `"${text}" matches ${matches.length} decisions — forgetting is permanent, so nothing was changed. Call again with a string unique to one of:\n\n${list}` }] };
641
+ }
642
+ const res = await hideDecision(matches[0].text, { purge: !!purge });
643
+ if (!res.hidden) {
644
+ return { content: [{ type: 'text', text: `Nothing changed — the decision may already have been forgotten.` }] };
645
+ }
646
+ // Re-render the pinned block so the next session no longer loads it.
647
+ try {
648
+ const rendered = renderSession(res.state);
649
+ for (const target of Object.values(detectAvailableTargets())) {
650
+ try { await injectInto(target, rendered); } catch {}
651
+ }
652
+ } catch {}
653
+ return { content: [{ type: 'text', text: `${res.purged ? 'Forgotten and purged' : 'Forgotten'}: "${matches[0].text}". The tombstone propagates on the next push.` }] };
654
+ }
655
+ );
656
+
742
657
  // ── Resources ────────────────────────────────────────────────────────────────
743
658
 
744
659
  // Expose detected tools as browsable resources