memoir-cli 3.11.3 → 3.14.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.
Files changed (76) hide show
  1. package/README.md +129 -124
  2. package/bin/memoir-work.js +9 -0
  3. package/bin/memoir.js +72 -8
  4. package/docs/AUDIT-REMEDIATION.md +55 -0
  5. package/docs/CASE_TAPE_AMNESIA.md +39 -0
  6. package/docs/HANDOFF-SECURITY-AUDIT.md +106 -0
  7. package/docs/LOCAL-HANDOFF-VALIDATION.md +129 -0
  8. package/docs/MCP-V2-MIGRATION.md +17 -0
  9. package/docs/PROJECT-HANDOFF.md +255 -0
  10. package/docs/PROJECT-VIEW-DEBUG.md +66 -0
  11. package/docs/PROJECT-VIEW-VALIDATION.md +136 -0
  12. package/docs/RELEASE-3.14-VALIDATION.md +36 -0
  13. package/docs/RELIABILITY-ROLLOUT.md +57 -0
  14. package/docs/RETRIEVAL-INDEX.md +45 -0
  15. package/docs/RETRIEVAL-RESULTS.md +26 -0
  16. package/docs/SPEC.md +684 -0
  17. package/evals/CONTINUITY-PROTOCOL.md +45 -0
  18. package/evals/cases.json +200 -0
  19. package/evals/results/retrieval-2026-09-05.json +5333 -0
  20. package/evals/retrieval-performance.mjs +99 -0
  21. package/evals/run.mjs +87 -0
  22. package/package.json +13 -5
  23. package/src/adapters/index.js +13 -6
  24. package/src/adapters/restore.js +83 -36
  25. package/src/cloud/auth.js +12 -15
  26. package/src/cloud/constants.js +6 -2
  27. package/src/cloud/storage.js +130 -93
  28. package/src/commands/activate.js +43 -9
  29. package/src/commands/cloud.js +56 -5
  30. package/src/commands/consolidate.js +49 -10
  31. package/src/commands/diff.js +2 -2
  32. package/src/commands/doctor.js +3 -3
  33. package/src/commands/forget.js +100 -0
  34. package/src/commands/push.js +164 -161
  35. package/src/commands/recall.js +42 -0
  36. package/src/commands/restore.js +32 -44
  37. package/src/commands/resume.js +15 -164
  38. package/src/commands/session.js +51 -9
  39. package/src/commands/snapshot.js +6 -7
  40. package/src/commands/status.js +23 -1
  41. package/src/commands/upgrade.js +13 -11
  42. package/src/commands/validate.js +16 -0
  43. package/src/commands/view.js +2 -2
  44. package/src/commands/why.js +4 -3
  45. package/src/config.js +9 -40
  46. package/src/context/capture.js +135 -33
  47. package/src/context/handoffs.js +72 -0
  48. package/src/events/summary.js +122 -0
  49. package/src/integrations/setup.js +88 -0
  50. package/src/mcp.js +151 -283
  51. package/src/memory/lexical-index.js +65 -0
  52. package/src/memory/repository.js +16 -0
  53. package/src/memory/scope.js +65 -0
  54. package/src/memory/search.js +598 -0
  55. package/src/memory/store.js +141 -0
  56. package/src/providers/index.js +182 -51
  57. package/src/providers/restore.js +5 -1
  58. package/src/security/encryption.js +34 -60
  59. package/src/security/files.js +155 -0
  60. package/src/session/brief.js +47 -0
  61. package/src/session/inject.js +12 -6
  62. package/src/session/lock.js +39 -118
  63. package/src/session/migrations.js +6 -0
  64. package/src/session/render.js +34 -4
  65. package/src/session/state.js +305 -34
  66. package/src/work/cli.js +64 -0
  67. package/src/work/errors.js +8 -0
  68. package/src/work/server.js +28 -0
  69. package/src/work/setup.js +96 -0
  70. package/src/work/store.js +340 -0
  71. package/src/work/ui/app.js +205 -0
  72. package/src/work/ui/index.html +30 -0
  73. package/src/work/ui/style.css +3 -0
  74. package/src/work/view.js +93 -0
  75. package/src/workspace/tracker.js +84 -332
  76. package/supabase/migrations/202609050001_backup_versions.sql +50 -0
@@ -0,0 +1,88 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { fileURLToPath } from 'url';
5
+ import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
6
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
7
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
8
+ import { readSafeFile, writeSafeFile } from '../security/files.js';
9
+ import { withSessionLock } from '../session/lock.js';
10
+
11
+ const serverPath = fileURLToPath(new URL('../mcp.js', import.meta.url));
12
+ const clients = {
13
+ claude: { marker: '.claude', config: '.mcp.json' },
14
+ codex: { marker: '.codex', config: '.codex/config.toml' },
15
+ cursor: { marker: '.cursor', config: '.cursor/mcp.json' },
16
+ };
17
+
18
+ export async function verifyServer(project) {
19
+ const transport = new StdioClientTransport({
20
+ command: process.execPath,
21
+ args: [serverPath],
22
+ env: { ...process.env, MEMOIR_PROJECT_ROOT: project, DO_NOT_TRACK: '1' },
23
+ stderr: 'pipe',
24
+ });
25
+ const client = new Client({ name: 'memoir-setup-check', version: '1.0.0' });
26
+ try {
27
+ await client.connect(transport, { timeout: 10000 });
28
+ const { tools } = await client.listTools();
29
+ for (const name of ['memoir_remember', 'memoir_recall', 'memoir_session']) {
30
+ if (!tools.some(t => t.name === name)) throw new Error('Server is missing required tool: ' + name);
31
+ }
32
+ return true;
33
+ } finally { await client.close(); }
34
+ }
35
+
36
+ // Project configuration supplies an explicit scope even for clients that
37
+ // launch all stdio servers with a home-directory working directory.
38
+ export async function setupIntegrations({ project = process.cwd(), tool = 'auto', check = true } = {}) {
39
+ project = await fs.realpath(path.resolve(project));
40
+ const selected = tool === 'all' ? Object.keys(clients) : tool === 'auto'
41
+ ? Object.keys(clients).filter(name => fs.existsSync(path.join(os.homedir(), clients[name].marker)) || fs.existsSync(path.join(project, clients[name].config)))
42
+ : tool.split(',').map(s => s.trim());
43
+ if (selected.some(name => !clients[name])) throw new Error('Supported clients: claude, codex, cursor, all, auto');
44
+ if (check && selected.length) await verifyServer(project);
45
+ const results = [];
46
+ for (const name of selected) {
47
+ const relative = clients[name].config;
48
+ const entry = { command: process.execPath, args: [serverPath], env: { MEMOIR_PROJECT_ROOT: project } };
49
+ await withSessionLock(path.join(project, '.memoir-setup.lock'), async () => {
50
+ let original = '';
51
+ try { original = (await readSafeFile(project, relative)).toString('utf8'); }
52
+ catch (err) { if (err.code !== 'ENOENT') throw err; }
53
+ const toml = name === 'codex';
54
+ const parsed = original.trim() ? (toml ? parseToml(original) : JSON.parse(original)) : {};
55
+ const key = toml ? 'mcp_servers' : 'mcpServers';
56
+ const existing = parsed[key]?.memoir;
57
+ if (existing) {
58
+ const matches = existing.command === entry.command && JSON.stringify(existing.args) === JSON.stringify(entry.args) && existing.env?.MEMOIR_PROJECT_ROOT === project;
59
+ results.push({ tool: name, path: path.join(project, relative), status: matches ? 'ready' : 'existing-configuration', verified: matches && check });
60
+ return;
61
+ }
62
+ let updated;
63
+ if (toml) {
64
+ // Preserve comments and unrelated formatting. Reject configurations
65
+ // whose inline table cannot be extended rather than rewriting them.
66
+ updated = original.trimEnd() + '\n\n' + stringifyToml({ mcp_servers: { memoir: entry } });
67
+ parseToml(updated);
68
+ } else {
69
+ if (parsed[key] != null && (typeof parsed[key] !== 'object' || Array.isArray(parsed[key]))) throw new Error('Invalid MCP server configuration');
70
+ parsed[key] = { ...(parsed[key] || {}), memoir: entry };
71
+ updated = JSON.stringify(parsed, null, 2) + '\n';
72
+ }
73
+ if (original) await writeSafeFile(project, relative + '.memoir-backup', original);
74
+ await writeSafeFile(project, relative, updated);
75
+ results.push({ tool: name, path: path.join(project, relative), status: 'configured', verified: check });
76
+ });
77
+ }
78
+ return results;
79
+ }
80
+
81
+ export async function setupCommand(options = {}) {
82
+ const results = await setupIntegrations(options);
83
+ if (!results.length) console.log('No supported clients detected. Use memoir setup --tool claude,codex,cursor to select them.');
84
+ for (const result of results) console.log(result.tool + ': ' + result.status + ' — ' + result.path);
85
+ if (results.some(r => r.status === 'existing-configuration')) console.log('Existing memoir entries were preserved. Review their command and project scope before using them.');
86
+ if (results.length) console.log('The Memoir server passed its startup check. Restart the client and approve/trust its project MCP configuration when prompted.');
87
+ return results;
88
+ }
package/src/mcp.js CHANGED
@@ -24,122 +24,27 @@ import {
24
24
  addQuestion,
25
25
  getMachineId,
26
26
  } from './session/state.js';
27
+ import { appendEvent } from './events/log.js';
27
28
  import { renderSession } from './session/render.js';
28
29
  import { injectInto, detectAvailableTargets } from './session/inject.js';
29
30
  import { findDecisions } from './commands/why.js';
31
+ import { matchDecisions, hideDecision } from './session/state.js';
32
+ import { readMemoryFiles, searchMemories, formatRecallResults, withFrontmatterLists } from './memory/search.js';
33
+ import { buildResumeBrief, formatResumeBrief } from './session/brief.js';
34
+ import { rememberMemory, memoryRoot, forgetStoredMemory, readStoredMemories } from './memory/store.js';
35
+ import { memoryFilename, relativeFile, readSafeFile } from './security/files.js';
36
+ import { visibleMemory, sessionView } from './memory/scope.js';
37
+ import { parseFrontmatter } from './commands/validate.js';
30
38
  import { capture as track } from './telemetry.js';
39
+ import { createRequire } from 'module';
31
40
 
32
41
  const home = os.homedir();
42
+ const { version: VERSION } = createRequire(import.meta.url)('../package.json');
33
43
 
34
44
  // ── 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
- }
45
+ // readMemoryFiles / searchMemories live in ./memory/search.js (cached,
46
+ // field-weighted, passage-returning) so the CLI's `memoir recall` and tests
47
+ // share one implementation with this server.
143
48
 
144
49
  /**
145
50
  * Get list of detected tools with status
@@ -164,7 +69,7 @@ async function getDetectedTools() {
164
69
 
165
70
  const server = new McpServer({
166
71
  name: 'memoir',
167
- version: '3.2.0',
72
+ version: VERSION,
168
73
  }, {
169
74
  capabilities: {
170
75
  tools: {},
@@ -182,9 +87,17 @@ const _registerTool = server.tool.bind(server);
182
87
  server.tool = (name, ...rest) => {
183
88
  const handler = rest[rest.length - 1];
184
89
  if (typeof handler === 'function') {
185
- rest[rest.length - 1] = (...args) => {
186
- try { track('mcp_tool_used', { tool: name }); } catch {}
187
- return handler(...args);
90
+ rest[rest.length - 1] = async (...args) => {
91
+ const started = Date.now();
92
+ let success = false;
93
+ try {
94
+ const result = await handler(...args);
95
+ success = result?.isError !== true;
96
+ return result;
97
+ } finally {
98
+ try { track('mcp_tool_used', { tool: name, success }); } catch {}
99
+ appendEvent('mcp_tool_used', { tool: name, success, ms: Date.now() - started }).catch(() => {});
100
+ }
188
101
  };
189
102
  }
190
103
  return _registerTool(name, ...rest);
@@ -236,113 +149,39 @@ server.tool(
236
149
 
237
150
  server.tool(
238
151
  '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
- };
152
+ 'Search memories and decisions for the current or selected project, including shared memories. Hidden, deleted, superseded, and unrelated project records are excluded. 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.',
153
+ {
154
+ query: z.string().describe('Search query keywords or topic to find in memories. Multi-word queries rank files that match every word highest.'),
155
+ limit: z.number().int().min(1).max(30).optional().describe('Max results to return (default 10)'),
156
+ project: z.string().optional().describe('Project directory or identity. Defaults to the current working project; shared memories are also included.'),
157
+ budget: z.number().int().min(256).max(16000).optional().describe('Total character budget for returned evidence (default 6000).'),
158
+ },
159
+ async ({ query, limit, project, budget }) => {
160
+ const res = await searchMemories(query, { limit: limit || 10, project, budget });
161
+ return { content: [{ type: 'text', text: formatRecallResults(query, res) }] };
266
162
  }
267
163
  );
268
164
 
269
165
  server.tool(
270
166
  '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.',
167
+ 'Save a durable, project-scoped memory in Memoir. Returns a stable ID and revision. 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
168
  {
273
- content: z.string().describe('The memory content to save (markdown format)'),
169
+ content: z.string().describe('The memory content to save (markdown, ideally with --- frontmatter: type, name, description)'),
274
170
  filename: z.string().describe('Filename for the memory (e.g. "auth-setup.md", "project-goals.md")'),
275
- tool: z.string().optional().describe('Which AI tool to save to: "claude", "gemini", "cursor", etc. Defaults to claude.'),
276
- 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.'),
171
+ aliases: z.array(z.string()).optional().describe('Other names/phrasings this memory should be findable under. Written into frontmatter `aliases:`. Strongly recommended.'),
172
+ tags: z.array(z.string()).optional().describe('Topic tags. Written into frontmatter `tags:`.'),
173
+ tool: z.string().optional().describe('Originating tool, recorded as provenance. Memory is stored in Memoir and is readable across clients.'),
174
+ scope: z.enum(['project', 'shared']).optional().describe('Default project scope; shared is for deliberately reusable preferences across projects.'),
175
+ project: z.string().optional().describe('Project directory or identity for this memory. Defaults to the working project; does not modify project files.'),
277
176
  },
278
- async ({ content, filename, tool, project }) => {
279
- // Project-level memory
280
- if (project) {
281
- const projectDir = project.startsWith('/') ? project : path.join(home, project);
282
- if (!(await fs.pathExists(projectDir))) {
283
- return { content: [{ type: 'text', text: `Project directory not found: ${projectDir}` }] };
284
- }
285
-
286
- // Default to CLAUDE.md for project-level memories.
287
- // Guards mirror the global branch below and memoir_read above:
288
- // model-supplied filename must be a bare markdown name — no
289
- // separators, no traversal — and must resolve inside the project
290
- // dir. Without this, filename:".zshrc" appends to a shell rc
291
- // (code execution on next shell) and "package.json" corrupts
292
- // real files.
293
- let targetFile = filename || 'CLAUDE.md';
294
- if (!targetFile.endsWith('.md')) targetFile += '.md';
295
- if (targetFile.includes('/') || targetFile.includes('\\') || targetFile.includes('..')) {
296
- return { content: [{ type: 'text', text: `Invalid filename: ${filename} (must be a bare .md name)` }] };
297
- }
298
- const projBase = path.resolve(projectDir);
299
- const targetPath = path.resolve(projBase, targetFile);
300
- if (!targetPath.startsWith(projBase + path.sep)) {
301
- return { content: [{ type: 'text', text: `Invalid filename: ${filename}` }] };
302
- }
303
-
304
- // Append to existing file or create new
305
- if (await fs.pathExists(targetPath)) {
306
- const existing = await fs.readFile(targetPath, 'utf8');
307
- await fs.writeFile(targetPath, existing + '\n\n' + content);
308
- } else {
309
- await fs.writeFile(targetPath, content);
310
- }
311
-
312
- return {
313
- content: [{ type: 'text', text: `Saved to ${targetPath}` }]
314
- };
315
- }
316
-
317
- // Global tool memory
318
- const toolKey = (tool || 'claude').toLowerCase();
319
-
320
- // Find the right directory for the tool
321
- let targetDir;
322
- if (toolKey === 'claude') {
323
- // Save to Claude's memory system
324
- const claudeMemDir = path.join(home, '.claude', 'projects', '-Users-' + path.basename(home), 'memory');
325
- await fs.ensureDir(claudeMemDir);
326
- targetDir = claudeMemDir;
327
- } else if (toolKey === 'gemini') {
328
- targetDir = path.join(home, '.gemini');
329
- } else if (toolKey === 'cursor') {
330
- const cursorDir = process.platform === 'win32'
331
- ? path.join(process.env.APPDATA || '', 'Cursor', 'User', 'rules')
332
- : path.join(home, 'Library', 'Application Support', 'Cursor', 'User', 'rules');
333
- await fs.ensureDir(cursorDir);
334
- targetDir = cursorDir;
335
- } else {
336
- return { content: [{ type: 'text', text: `Unsupported tool for writing: ${toolKey}. Supported: claude, gemini, cursor` }] };
177
+ async ({ content, filename, aliases, tags, tool, project, scope }) => {
178
+ try {
179
+ memoryFilename(filename);
180
+ const saved = await rememberMemory({ content, filename, aliases, tags, tool, project, scope });
181
+ return { content: [{ type: 'text', text: 'Memory saved: ' + saved.id + ' (revision ' + saved.revision + ', project ' + saved.project + '). Read with tool "memoir" and filepath "' + saved.path + '".' }] };
182
+ } catch (err) {
183
+ return { isError: true, content: [{ type: 'text', text: err.message }] };
337
184
  }
338
-
339
- if (!filename.endsWith('.md')) filename += '.md';
340
- const targetPath = path.join(targetDir, filename);
341
- await fs.writeFile(targetPath, content);
342
-
343
- return {
344
- content: [{ type: 'text', text: `Memory saved to ${targetPath}` }]
345
- };
346
185
  }
347
186
  );
348
187
 
@@ -354,6 +193,11 @@ server.tool(
354
193
  },
355
194
  async ({ tool }) => {
356
195
  const allFiles = [];
196
+ if (!tool || tool.toLowerCase() === 'memoir') {
197
+ for (const file of await readStoredMemories()) {
198
+ if (visibleMemory(parseFrontmatter(file.content).fields)) allFiles.push({ tool: 'Memoir', icon: '🧠', path: file.path, size: file.content.length });
199
+ }
200
+ }
357
201
 
358
202
  for (const adapter of adapters) {
359
203
  if (tool) {
@@ -363,6 +207,7 @@ server.tool(
363
207
 
364
208
  const files = await readMemoryFiles(adapter);
365
209
  for (const f of files) {
210
+ if (!visibleMemory(f)) continue;
366
211
  allFiles.push({ tool: adapter.name, icon: adapter.icon, path: f.path, size: f.content.length });
367
212
  }
368
213
  }
@@ -399,35 +244,35 @@ server.tool(
399
244
  {
400
245
  tool: z.string().describe('Tool name: "claude", "gemini", "cursor", etc.'),
401
246
  filepath: z.string().describe('Relative file path within the tool\'s memory directory'),
247
+ project: z.string().optional().describe('Project directory or identity; defaults to the working project.'),
402
248
  },
403
- async ({ tool, filepath }) => {
404
- const toolKey = tool.toLowerCase();
405
- const adapter = adapters.find(a => a.name.toLowerCase().includes(toolKey));
406
-
407
- if (!adapter) {
408
- return { content: [{ type: 'text', text: `Unknown tool: ${tool}. Available: ${adapters.map(a => a.name).join(', ')}` }] };
409
- }
410
-
411
- // Containment: filepath comes from the model, and the model reads
412
- // attacker-influenceable text all day. Without this, "../.ssh/id_rsa"
413
- // resolves outside the adapter dir and the file is returned verbatim.
414
- const base = path.resolve(adapter.source);
415
- const fullPath = path.resolve(base, filepath);
416
- if (fullPath !== base && !fullPath.startsWith(base + path.sep)) {
417
- return { content: [{ type: 'text', text: `Invalid path: ${filepath} (must stay inside ${adapter.name}'s directory)` }] };
418
- }
419
-
420
- if (!(await fs.pathExists(fullPath))) {
421
- return { content: [{ type: 'text', text: `File not found: ${filepath} in ${adapter.name}` }] };
422
- }
423
-
249
+ async ({ tool, filepath, project }) => {
424
250
  try {
425
- const content = await fs.readFile(fullPath, 'utf8');
426
- return {
427
- content: [{ type: 'text', text: `── ${adapter.name} / ${filepath} ──\n\n${content}` }]
428
- };
251
+ const rel = relativeFile(filepath);
252
+ const toolKey = tool.toLowerCase();
253
+ let root, name;
254
+ if (toolKey === 'memoir') {
255
+ if (!/^[a-f0-9]{64}\.md$/.test(rel)) throw new Error('Use the memory ID returned by remember or recall.');
256
+ root = memoryRoot;
257
+ name = 'Memoir';
258
+ } else {
259
+ const adapter = adapters.find(a => a.name.toLowerCase().includes(toolKey));
260
+ if (!adapter) throw new Error('Unknown memory tool');
261
+ const permitted = adapter.customExtract ? adapter.files.includes(rel) : adapter.filter(path.join(adapter.source, rel));
262
+ if (!permitted) throw new Error('This file is excluded from the memory adapter');
263
+ root = adapter.source;
264
+ name = adapter.name;
265
+ }
266
+ let content = (await readSafeFile(root, rel)).toString('utf8');
267
+ const { fields } = parseFrontmatter(content);
268
+ if (toolKey.includes('claude')) fields.claudeProjectKey = rel.match(/^projects\/([^/]+)\//)?.[1];
269
+ if (!visibleMemory(fields, { project })) throw new Error('This memory is hidden, expired, superseded, or belongs to another project.');
270
+ // Generated session blocks are projections; authoritative decisions are
271
+ // retrieved from state so old projections cannot bypass a deletion.
272
+ content = content.replace(/<!--\s*memoir:session-block[^>]*-->[\s\S]*?<!--\s*\/memoir:session-block\s*-->/g, '');
273
+ return { content: [{ type: 'text', text: '── ' + name + ' / ' + rel + ' ──\n\n' + content }] };
429
274
  } catch (err) {
430
- return { content: [{ type: 'text', text: `Error reading file: ${err.message}` }] };
275
+ return { isError: true, content: [{ type: 'text', text: err.message }] };
431
276
  }
432
277
  }
433
278
  );
@@ -459,47 +304,19 @@ server.tool(
459
304
  'memoir_consolidate',
460
305
  'Analyze all AI tool memories for duplicates, stale files, contradictions, and bloat. Returns a consolidation report with actionable suggestions. Use this to help users keep their AI memory clean.',
461
306
  {
462
- smart: z.boolean().optional().describe('Use AI (Gemini Flash) for deeper analysis finds semantic duplicates, contradictions, and merge candidates. Requires GEMINI_API_KEY.'),
307
+ smart: z.boolean().optional().describe('Compatibility option. This MCP tool performs local analysis only; use memoir consolidate --smart in the CLI for external model analysis.'),
463
308
  },
464
309
  async ({ smart }) => {
465
- // Collect all memory files
466
310
  const allFiles = [];
311
+ for (const file of await readStoredMemories()) {
312
+ if (visibleMemory(parseFrontmatter(file.content).fields)) allFiles.push({
313
+ ...file, size: file.content.length, mtime: Date.parse(parseFrontmatter(file.content).fields.updated) || 0,
314
+ });
315
+ }
467
316
  for (const adapter of adapters) {
468
- const files = [];
469
- if (adapter.customExtract) {
470
- for (const file of adapter.files) {
471
- const filePath = path.join(adapter.source, file);
472
- if (await fs.pathExists(filePath)) {
473
- try {
474
- const content = await fs.readFile(filePath, 'utf8');
475
- const stat = await fs.stat(filePath);
476
- files.push({ path: file, fullPath: filePath, content, tool: adapter.name, icon: adapter.icon, mtime: stat.mtimeMs, size: content.length });
477
- } catch {}
478
- }
479
- }
480
- } else if (await fs.pathExists(adapter.source)) {
481
- const walk = async (dir, prefix = '') => {
482
- let entries;
483
- try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return; }
484
- for (const entry of entries) {
485
- const fullPath = path.join(dir, entry.name);
486
- const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
487
- if (entry.isDirectory()) {
488
- if (adapter.filter(fullPath)) await walk(fullPath, relPath);
489
- } else if (/\.(md|json|yml|yaml)$/.test(entry.name)) {
490
- if (adapter.filter(fullPath)) {
491
- try {
492
- const content = await fs.readFile(fullPath, 'utf8');
493
- const stat = await fs.stat(fullPath);
494
- files.push({ path: relPath, fullPath, content, tool: adapter.name, icon: adapter.icon, mtime: stat.mtimeMs, size: content.length });
495
- } catch {}
496
- }
497
- }
498
- }
499
- };
500
- await walk(adapter.source);
317
+ for (const doc of await readMemoryFiles(adapter)) {
318
+ if (visibleMemory(doc)) allFiles.push({ ...doc, size: doc.content.length, mtime: doc.mtimeMs });
501
319
  }
502
- allFiles.push(...files);
503
320
  }
504
321
 
505
322
  if (allFiles.length === 0) {
@@ -590,36 +407,39 @@ async function refreshPinnedBlock() {
590
407
 
591
408
  server.tool(
592
409
  'memoir_set_goal',
593
- 'Set the current goal for this session. Use when the user states what they want to work on, or when a clear focus emerges. Pinned into CLAUDE.md so future sessions see it.',
410
+ 'Set the current goal for this session. Use when the user states what they want to work on, or when a clear focus emerges. Available through the scoped session and resume tools.',
594
411
  { text: z.string().describe('The goal, one short sentence') },
595
412
  async ({ text }) => {
596
- await addGoal(text);
413
+ const state = await addGoal(text);
597
414
  await refreshPinnedBlock();
598
- return { content: [{ type: 'text', text: `Goal set: ${text}` }] };
415
+ const replaced = (state.replacedGoals || []).map((g) => `"${g.text}"`);
416
+ return { content: [{ type: 'text', text: `Goal set: ${text}${replaced.length ? `\nGoals list is full (3) — replaced: ${replaced.join('; ')}. Re-set it with memoir_set_goal if that was wrong.` : ''}` }] };
599
417
  }
600
418
  );
601
419
 
602
420
  server.tool(
603
421
  'memoir_add_next',
604
- 'Add a next action to the current session. Use when the user decides on a concrete next step, or when you finish something and the logical next move is clear.',
422
+ 'Add a next action to the current session. Use when the user decides on a concrete next step, or when you finish something and the logical next move is clear. The list holds 8; when it is full the oldest item is PARKED (still shown in the pinned block, still completable), never dropped — the response names anything parked.',
605
423
  { text: z.string().describe('The action, one short imperative sentence') },
606
424
  async ({ text }) => {
607
- await addNext(text);
425
+ const state = await addNext(text);
608
426
  await refreshPinnedBlock();
609
- return { content: [{ type: 'text', text: `Next: ${text}` }] };
427
+ const parked = (state.justParked || []).map((p) => `"${p.text}"`);
428
+ return { content: [{ type: 'text', text: `Next: ${text}${parked.length ? `\nList was full — parked (still open, still in the block): ${parked.join('; ')}` : ''}` }] };
610
429
  }
611
430
  );
612
431
 
613
432
  server.tool(
614
433
  'memoir_complete_next',
615
- 'Mark a next action as complete (removes it from the pinned list). Match by substring — pass the relevant keywords, not the whole text.',
434
+ 'Mark a next action as complete (removes it from the pinned list, parked items included). Match by substring — pass the relevant keywords, not the whole text.',
616
435
  { match: z.string().describe('Substring to match against existing next actions') },
617
436
  async ({ match }) => {
437
+ const count = (st) => st.current.next_actions.length + (st.current.parked_actions || []).length;
618
438
  const before = await readSession();
619
- const beforeCount = before.current.next_actions.length;
439
+ const beforeCount = count(before);
620
440
  await completeNext(match);
621
441
  const after = await readSession();
622
- const removed = beforeCount - after.current.next_actions.length;
442
+ const removed = beforeCount - count(after);
623
443
  await refreshPinnedBlock();
624
444
  return {
625
445
  content: [{
@@ -666,12 +486,12 @@ server.tool(
666
486
  'Show the current session state — goals, next actions, open questions, recent decisions, recent sessions across machines. Use this to catch up at the start of a session, or when you need to orient yourself on what was decided.',
667
487
  {},
668
488
  async () => {
669
- const state = await readSession();
489
+ const state = sessionView(await readSession());
670
490
  const machine = await getMachineId();
671
491
  const goals = state.current.goals.map(g => `- ${g.text}`).join('\n') || '(none)';
672
492
  const nexts = state.current.next_actions.map(n => `- [ ] ${n.text}`).join('\n') || '(none)';
673
493
  const questions = state.current.open_questions.map(q => `- ${q.text}`).join('\n') || '(none)';
674
- const decisions = state.current.decisions.slice(0, 5).map(d => {
494
+ const decisions = state.current.decisions.filter(d => visibleMemory(d)).slice(0, 5).map(d => {
675
495
  let line = `- ${d.text}`;
676
496
  if (d.why) line += ` — *${d.why}*`;
677
497
  return line;
@@ -739,6 +559,54 @@ server.tool(
739
559
  }
740
560
  );
741
561
 
562
+ server.tool(
563
+ 'memoir_forget',
564
+ '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).',
565
+ {
566
+ text: z.string().describe('A canonical memory ID, or decision text/substr uniquely identifying a decision'),
567
+ purge: z.boolean().optional().describe('Also redact the text/why/rejected in place, keeping only a hash. For leaked secrets. Default false.'),
568
+ },
569
+ async ({ text, purge }) => {
570
+ if (/^[a-f0-9]{64}$/.test(text)) {
571
+ try {
572
+ const result = await forgetStoredMemory(text, { purge: !!purge });
573
+ return { content: [{ type: 'text', text: 'Forgotten memory ' + result.id + '. The deletion will sync on push. ' + (purge ? 'Local record and revision history purged; older remote backups and Git history are not erased.' : '') }] };
574
+ } catch (err) { return { isError: true, content: [{ type: 'text', text: err.message }] }; }
575
+ }
576
+ const state = await readSession();
577
+ const matches = matchDecisions(state, text);
578
+ if (matches.length === 0) {
579
+ return { content: [{ type: 'text', text: `No visible decision matches "${text}". Nothing forgotten.` }] };
580
+ }
581
+ if (matches.length > 1) {
582
+ const list = matches.map(d => `● ${d.text}`).join('\n');
583
+ 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}` }] };
584
+ }
585
+ const res = await hideDecision(matches[0].text, { purge: !!purge });
586
+ if (!res.hidden) {
587
+ return { content: [{ type: 'text', text: `Nothing changed — the decision may already have been forgotten.` }] };
588
+ }
589
+ // Re-render the pinned block so the next session no longer loads it.
590
+ try {
591
+ const rendered = renderSession(res.state);
592
+ for (const target of Object.values(detectAvailableTargets())) {
593
+ try { await injectInto(target, rendered); } catch {}
594
+ }
595
+ } catch {}
596
+ return { content: [{ type: 'text', text: res.purged ? 'Forgotten and purged locally. The tombstone propagates on the next push; historical backups still require removal.' : 'Forgotten. The tombstone propagates on the next push.' }] };
597
+ }
598
+ );
599
+
600
+ server.tool(
601
+ 'memoir_resume',
602
+ 'Build an actionable handoff for the current project: goal, next actions, decisions with sources, open questions, and checkout drift. Saved observations never imply that current tests pass.',
603
+ { project: z.string().optional().describe('Project directory; defaults to the configured working project.') },
604
+ async ({ project }) => {
605
+ const brief = await buildResumeBrief(project);
606
+ return { content: [{ type: 'text', text: formatResumeBrief(brief) }] };
607
+ }
608
+ );
609
+
742
610
  // ── Resources ────────────────────────────────────────────────────────────────
743
611
 
744
612
  // Expose detected tools as browsable resources