memoir-cli 3.12.0 → 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 (73) hide show
  1. package/README.md +128 -137
  2. package/bin/memoir-work.js +9 -0
  3. package/bin/memoir.js +50 -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/storage.js +130 -93
  26. package/src/commands/activate.js +18 -7
  27. package/src/commands/cloud.js +55 -4
  28. package/src/commands/consolidate.js +49 -10
  29. package/src/commands/diff.js +2 -2
  30. package/src/commands/doctor.js +3 -3
  31. package/src/commands/push.js +156 -161
  32. package/src/commands/recall.js +1 -1
  33. package/src/commands/restore.js +32 -44
  34. package/src/commands/resume.js +15 -164
  35. package/src/commands/session.js +51 -9
  36. package/src/commands/snapshot.js +6 -7
  37. package/src/commands/status.js +23 -1
  38. package/src/commands/upgrade.js +11 -9
  39. package/src/commands/validate.js +3 -0
  40. package/src/commands/view.js +2 -2
  41. package/src/commands/why.js +4 -3
  42. package/src/config.js +9 -40
  43. package/src/context/capture.js +126 -32
  44. package/src/context/handoffs.js +72 -0
  45. package/src/events/summary.js +122 -0
  46. package/src/integrations/setup.js +88 -0
  47. package/src/mcp.js +105 -152
  48. package/src/memory/lexical-index.js +65 -0
  49. package/src/memory/repository.js +16 -0
  50. package/src/memory/scope.js +65 -0
  51. package/src/memory/search.js +165 -70
  52. package/src/memory/store.js +141 -0
  53. package/src/providers/index.js +182 -51
  54. package/src/providers/restore.js +5 -1
  55. package/src/security/encryption.js +34 -60
  56. package/src/security/files.js +155 -0
  57. package/src/session/brief.js +47 -0
  58. package/src/session/inject.js +12 -6
  59. package/src/session/lock.js +39 -118
  60. package/src/session/migrations.js +6 -0
  61. package/src/session/render.js +34 -4
  62. package/src/session/state.js +200 -33
  63. package/src/work/cli.js +64 -0
  64. package/src/work/errors.js +8 -0
  65. package/src/work/server.js +28 -0
  66. package/src/work/setup.js +96 -0
  67. package/src/work/store.js +340 -0
  68. package/src/work/ui/app.js +205 -0
  69. package/src/work/ui/index.html +30 -0
  70. package/src/work/ui/style.css +3 -0
  71. package/src/work/view.js +93 -0
  72. package/src/workspace/tracker.js +84 -332
  73. package/supabase/migrations/202609050001_backup_versions.sql +50 -0
package/src/mcp.js CHANGED
@@ -24,11 +24,17 @@ 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';
30
31
  import { matchDecisions, hideDecision } from './session/state.js';
31
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';
32
38
  import { capture as track } from './telemetry.js';
33
39
  import { createRequire } from 'module';
34
40
 
@@ -81,9 +87,17 @@ const _registerTool = server.tool.bind(server);
81
87
  server.tool = (name, ...rest) => {
82
88
  const handler = rest[rest.length - 1];
83
89
  if (typeof handler === 'function') {
84
- rest[rest.length - 1] = (...args) => {
85
- try { track('mcp_tool_used', { tool: name }); } catch {}
86
- 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
+ }
87
101
  };
88
102
  }
89
103
  return _registerTool(name, ...rest);
@@ -135,97 +149,39 @@ server.tool(
135
149
 
136
150
  server.tool(
137
151
  'memoir_recall',
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.',
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.',
139
153
  {
140
154
  query: z.string().describe('Search query — keywords or topic to find in memories. Multi-word queries rank files that match every word highest.'),
141
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).'),
142
158
  },
143
- async ({ query, limit }) => {
144
- const res = await searchMemories(query, { limit: limit || 10 });
159
+ async ({ query, limit, project, budget }) => {
160
+ const res = await searchMemories(query, { limit: limit || 10, project, budget });
145
161
  return { content: [{ type: 'text', text: formatRecallResults(query, res) }] };
146
162
  }
147
163
  );
148
164
 
149
165
  server.tool(
150
166
  'memoir_remember',
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.',
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.',
152
168
  {
153
169
  content: z.string().describe('The memory content to save (markdown, ideally with --- frontmatter: type, name, description)'),
154
170
  filename: z.string().describe('Filename for the memory (e.g. "auth-setup.md", "project-goals.md")'),
155
171
  aliases: z.array(z.string()).optional().describe('Other names/phrasings this memory should be findable under. Written into frontmatter `aliases:`. Strongly recommended.'),
156
172
  tags: z.array(z.string()).optional().describe('Topic tags. Written into frontmatter `tags:`.'),
157
- tool: z.string().optional().describe('Which AI tool to save to: "claude", "gemini", "cursor", etc. Defaults to claude.'),
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.'),
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.'),
159
176
  },
160
- async ({ content, filename, aliases, tags, tool, project }) => {
161
- content = withFrontmatterLists(content, { aliases, tags });
162
- // Project-level memory
163
- if (project) {
164
- const projectDir = project.startsWith('/') ? project : path.join(home, project);
165
- if (!(await fs.pathExists(projectDir))) {
166
- return { content: [{ type: 'text', text: `Project directory not found: ${projectDir}` }] };
167
- }
168
-
169
- // Default to CLAUDE.md for project-level memories.
170
- // Guards mirror the global branch below and memoir_read above:
171
- // model-supplied filename must be a bare markdown name — no
172
- // separators, no traversal — and must resolve inside the project
173
- // dir. Without this, filename:".zshrc" appends to a shell rc
174
- // (code execution on next shell) and "package.json" corrupts
175
- // real files.
176
- let targetFile = filename || 'CLAUDE.md';
177
- if (!targetFile.endsWith('.md')) targetFile += '.md';
178
- if (targetFile.includes('/') || targetFile.includes('\\') || targetFile.includes('..')) {
179
- return { content: [{ type: 'text', text: `Invalid filename: ${filename} (must be a bare .md name)` }] };
180
- }
181
- const projBase = path.resolve(projectDir);
182
- const targetPath = path.resolve(projBase, targetFile);
183
- if (!targetPath.startsWith(projBase + path.sep)) {
184
- return { content: [{ type: 'text', text: `Invalid filename: ${filename}` }] };
185
- }
186
-
187
- // Append to existing file or create new
188
- if (await fs.pathExists(targetPath)) {
189
- const existing = await fs.readFile(targetPath, 'utf8');
190
- await fs.writeFile(targetPath, existing + '\n\n' + content);
191
- } else {
192
- await fs.writeFile(targetPath, content);
193
- }
194
-
195
- return {
196
- content: [{ type: 'text', text: `Saved to ${targetPath}` }]
197
- };
198
- }
199
-
200
- // Global tool memory
201
- const toolKey = (tool || 'claude').toLowerCase();
202
-
203
- // Find the right directory for the tool
204
- let targetDir;
205
- if (toolKey === 'claude') {
206
- // Save to Claude's memory system
207
- const claudeMemDir = path.join(home, '.claude', 'projects', '-Users-' + path.basename(home), 'memory');
208
- await fs.ensureDir(claudeMemDir);
209
- targetDir = claudeMemDir;
210
- } else if (toolKey === 'gemini') {
211
- targetDir = path.join(home, '.gemini');
212
- } else if (toolKey === 'cursor') {
213
- const cursorDir = process.platform === 'win32'
214
- ? path.join(process.env.APPDATA || '', 'Cursor', 'User', 'rules')
215
- : path.join(home, 'Library', 'Application Support', 'Cursor', 'User', 'rules');
216
- await fs.ensureDir(cursorDir);
217
- targetDir = cursorDir;
218
- } else {
219
- 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 }] };
220
184
  }
221
-
222
- if (!filename.endsWith('.md')) filename += '.md';
223
- const targetPath = path.join(targetDir, filename);
224
- await fs.writeFile(targetPath, content);
225
-
226
- return {
227
- content: [{ type: 'text', text: `Memory saved to ${targetPath}` }]
228
- };
229
185
  }
230
186
  );
231
187
 
@@ -237,6 +193,11 @@ server.tool(
237
193
  },
238
194
  async ({ tool }) => {
239
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
+ }
240
201
 
241
202
  for (const adapter of adapters) {
242
203
  if (tool) {
@@ -246,6 +207,7 @@ server.tool(
246
207
 
247
208
  const files = await readMemoryFiles(adapter);
248
209
  for (const f of files) {
210
+ if (!visibleMemory(f)) continue;
249
211
  allFiles.push({ tool: adapter.name, icon: adapter.icon, path: f.path, size: f.content.length });
250
212
  }
251
213
  }
@@ -282,35 +244,35 @@ server.tool(
282
244
  {
283
245
  tool: z.string().describe('Tool name: "claude", "gemini", "cursor", etc.'),
284
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.'),
285
248
  },
286
- async ({ tool, filepath }) => {
287
- const toolKey = tool.toLowerCase();
288
- const adapter = adapters.find(a => a.name.toLowerCase().includes(toolKey));
289
-
290
- if (!adapter) {
291
- return { content: [{ type: 'text', text: `Unknown tool: ${tool}. Available: ${adapters.map(a => a.name).join(', ')}` }] };
292
- }
293
-
294
- // Containment: filepath comes from the model, and the model reads
295
- // attacker-influenceable text all day. Without this, "../.ssh/id_rsa"
296
- // resolves outside the adapter dir and the file is returned verbatim.
297
- const base = path.resolve(adapter.source);
298
- const fullPath = path.resolve(base, filepath);
299
- if (fullPath !== base && !fullPath.startsWith(base + path.sep)) {
300
- return { content: [{ type: 'text', text: `Invalid path: ${filepath} (must stay inside ${adapter.name}'s directory)` }] };
301
- }
302
-
303
- if (!(await fs.pathExists(fullPath))) {
304
- return { content: [{ type: 'text', text: `File not found: ${filepath} in ${adapter.name}` }] };
305
- }
306
-
249
+ async ({ tool, filepath, project }) => {
307
250
  try {
308
- const content = await fs.readFile(fullPath, 'utf8');
309
- return {
310
- content: [{ type: 'text', text: `── ${adapter.name} / ${filepath} ──\n\n${content}` }]
311
- };
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 }] };
312
274
  } catch (err) {
313
- return { content: [{ type: 'text', text: `Error reading file: ${err.message}` }] };
275
+ return { isError: true, content: [{ type: 'text', text: err.message }] };
314
276
  }
315
277
  }
316
278
  );
@@ -342,47 +304,19 @@ server.tool(
342
304
  'memoir_consolidate',
343
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.',
344
306
  {
345
- 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.'),
346
308
  },
347
309
  async ({ smart }) => {
348
- // Collect all memory files
349
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
+ }
350
316
  for (const adapter of adapters) {
351
- const files = [];
352
- if (adapter.customExtract) {
353
- for (const file of adapter.files) {
354
- const filePath = path.join(adapter.source, file);
355
- if (await fs.pathExists(filePath)) {
356
- try {
357
- const content = await fs.readFile(filePath, 'utf8');
358
- const stat = await fs.stat(filePath);
359
- files.push({ path: file, fullPath: filePath, content, tool: adapter.name, icon: adapter.icon, mtime: stat.mtimeMs, size: content.length });
360
- } catch {}
361
- }
362
- }
363
- } else if (await fs.pathExists(adapter.source)) {
364
- const walk = async (dir, prefix = '') => {
365
- let entries;
366
- try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return; }
367
- for (const entry of entries) {
368
- const fullPath = path.join(dir, entry.name);
369
- const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
370
- if (entry.isDirectory()) {
371
- if (adapter.filter(fullPath)) await walk(fullPath, relPath);
372
- } else if (/\.(md|json|yml|yaml)$/.test(entry.name)) {
373
- if (adapter.filter(fullPath)) {
374
- try {
375
- const content = await fs.readFile(fullPath, 'utf8');
376
- const stat = await fs.stat(fullPath);
377
- files.push({ path: relPath, fullPath, content, tool: adapter.name, icon: adapter.icon, mtime: stat.mtimeMs, size: content.length });
378
- } catch {}
379
- }
380
- }
381
- }
382
- };
383
- 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 });
384
319
  }
385
- allFiles.push(...files);
386
320
  }
387
321
 
388
322
  if (allFiles.length === 0) {
@@ -473,36 +407,39 @@ async function refreshPinnedBlock() {
473
407
 
474
408
  server.tool(
475
409
  'memoir_set_goal',
476
- '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.',
477
411
  { text: z.string().describe('The goal, one short sentence') },
478
412
  async ({ text }) => {
479
- await addGoal(text);
413
+ const state = await addGoal(text);
480
414
  await refreshPinnedBlock();
481
- 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.` : ''}` }] };
482
417
  }
483
418
  );
484
419
 
485
420
  server.tool(
486
421
  'memoir_add_next',
487
- '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.',
488
423
  { text: z.string().describe('The action, one short imperative sentence') },
489
424
  async ({ text }) => {
490
- await addNext(text);
425
+ const state = await addNext(text);
491
426
  await refreshPinnedBlock();
492
- 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('; ')}` : ''}` }] };
493
429
  }
494
430
  );
495
431
 
496
432
  server.tool(
497
433
  'memoir_complete_next',
498
- '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.',
499
435
  { match: z.string().describe('Substring to match against existing next actions') },
500
436
  async ({ match }) => {
437
+ const count = (st) => st.current.next_actions.length + (st.current.parked_actions || []).length;
501
438
  const before = await readSession();
502
- const beforeCount = before.current.next_actions.length;
439
+ const beforeCount = count(before);
503
440
  await completeNext(match);
504
441
  const after = await readSession();
505
- const removed = beforeCount - after.current.next_actions.length;
442
+ const removed = beforeCount - count(after);
506
443
  await refreshPinnedBlock();
507
444
  return {
508
445
  content: [{
@@ -549,12 +486,12 @@ server.tool(
549
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.',
550
487
  {},
551
488
  async () => {
552
- const state = await readSession();
489
+ const state = sessionView(await readSession());
553
490
  const machine = await getMachineId();
554
491
  const goals = state.current.goals.map(g => `- ${g.text}`).join('\n') || '(none)';
555
492
  const nexts = state.current.next_actions.map(n => `- [ ] ${n.text}`).join('\n') || '(none)';
556
493
  const questions = state.current.open_questions.map(q => `- ${q.text}`).join('\n') || '(none)';
557
- 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 => {
558
495
  let line = `- ${d.text}`;
559
496
  if (d.why) line += ` — *${d.why}*`;
560
497
  return line;
@@ -626,10 +563,16 @@ server.tool(
626
563
  'memoir_forget',
627
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).',
628
565
  {
629
- text: z.string().describe('The decision text, or a substring unique to it'),
566
+ text: z.string().describe('A canonical memory ID, or decision text/substr uniquely identifying a decision'),
630
567
  purge: z.boolean().optional().describe('Also redact the text/why/rejected in place, keeping only a hash. For leaked secrets. Default false.'),
631
568
  },
632
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
+ }
633
576
  const state = await readSession();
634
577
  const matches = matchDecisions(state, text);
635
578
  if (matches.length === 0) {
@@ -650,7 +593,17 @@ server.tool(
650
593
  try { await injectInto(target, rendered); } catch {}
651
594
  }
652
595
  } catch {}
653
- return { content: [{ type: 'text', text: `${res.purged ? 'Forgotten and purged' : 'Forgotten'}: "${matches[0].text}". The tombstone propagates on the next push.` }] };
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) }] };
654
607
  }
655
608
  );
656
609
 
@@ -0,0 +1,65 @@
1
+ // Rebuildable, process-local postings. Canonical files remain authoritative;
2
+ // nothing is persisted or trusted across a filesystem refresh or scope change.
3
+ export class LexicalIndex {
4
+ constructor() { this.clear(); }
5
+
6
+ clear() {
7
+ this.documents = new Set();
8
+ this.postings = new Map();
9
+ this.tokensByDocument = new Map();
10
+ this.vocabulary = null;
11
+ }
12
+
13
+ sync(documents) {
14
+ const current = new Set(documents);
15
+ for (const doc of this.documents) {
16
+ if (current.has(doc)) continue;
17
+ for (const token of this.tokensByDocument.get(doc)) {
18
+ const posting = this.postings.get(token);
19
+ posting.delete(doc);
20
+ if (!posting.size) { this.postings.delete(token); this.vocabulary = null; }
21
+ }
22
+ this.tokensByDocument.delete(doc);
23
+ }
24
+ for (const doc of current) {
25
+ if (this.documents.has(doc)) continue;
26
+ const tokens = new Set(Object.values(doc.tf).flatMap(field => [...field.keys()]));
27
+ this.tokensByDocument.set(doc, tokens);
28
+ for (const token of tokens) {
29
+ if (!this.postings.has(token)) { this.postings.set(token, new Set()); this.vocabulary = null; }
30
+ this.postings.get(token).add(doc);
31
+ }
32
+ }
33
+ this.documents = current;
34
+ }
35
+
36
+ lookup(terms) {
37
+ if (!this.vocabulary) this.vocabulary = [...this.postings.keys()].sort();
38
+ const documents = new Set();
39
+ const matches = new Map();
40
+ for (const term of terms) {
41
+ const tokens = new Map();
42
+ if (this.postings.has(term)) tokens.set(term, 1);
43
+ if (term.length >= 4) {
44
+ // Binary seek to query-prefix matches; no full vocabulary scan.
45
+ let lo = 0, hi = this.vocabulary.length;
46
+ while (lo < hi) {
47
+ const mid = (lo + hi) >>> 1;
48
+ if (this.vocabulary[mid] < term) lo = mid + 1; else hi = mid;
49
+ }
50
+ for (let i = lo; i < this.vocabulary.length && this.vocabulary[i].startsWith(term); i++) {
51
+ const token = this.vocabulary[i];
52
+ if (token !== term) tokens.set(token, .6);
53
+ }
54
+ // The reference scorer also allows a document token to prefix the query.
55
+ for (let n = 4; n < term.length; n++) {
56
+ const token = term.slice(0, n);
57
+ if (this.postings.has(token)) tokens.set(token, .6);
58
+ }
59
+ }
60
+ matches.set(term, tokens);
61
+ for (const token of tokens.keys()) for (const doc of this.postings.get(token)) documents.add(doc);
62
+ }
63
+ return { documents, matches };
64
+ }
65
+ }
@@ -0,0 +1,16 @@
1
+ import { execFileSync } from 'node:child_process';
2
+
3
+ export function repositoryState(project) {
4
+ const run = args => execFileSync('git', args, { cwd: project, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 3000 }).trim();
5
+ try {
6
+ return {
7
+ root: run(['rev-parse', '--show-toplevel']),
8
+ head: run(['rev-parse', 'HEAD']),
9
+ branch: run(['branch', '--show-current']) || '(detached)',
10
+ // git status can execute fsmonitor hooks and clean filters from project
11
+ // configuration. Reading a memory record must not run that code. File
12
+ // hashes provide scoped check freshness; report overall dirtiness unknown.
13
+ dirty: null,
14
+ };
15
+ } catch { return { root: project, head: null, branch: null, dirty: null }; }
16
+ }
@@ -0,0 +1,65 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import crypto from 'crypto';
5
+ import { execFileSync } from 'child_process';
6
+
7
+ const cache = new Map();
8
+ export function projectIdentity(project = process.env.MEMOIR_PROJECT_ROOT || process.cwd()) {
9
+ if (project === 'shared') return 'shared';
10
+ if (/^(git|local):[a-f0-9]{32}$/.test(project)) return project;
11
+ let absolute = path.resolve(project.replace(/^~/, os.homedir()));
12
+ try { absolute = fs.realpathSync(absolute); } catch {}
13
+ const old = cache.get(absolute);
14
+ if (old && Date.now() - old.at < 60_000) return old.id;
15
+ let home = os.homedir();
16
+ try { home = fs.realpathSync(home); } catch {}
17
+ let key = path.relative(home, absolute).replace(/\\/g, '/');
18
+ let kind = 'local';
19
+ try {
20
+ const remote = execFileSync('git', ['config', '--get', 'remote.origin.url'], { cwd: absolute, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000 }).trim();
21
+ if (remote) {
22
+ // Normalize common SSH/HTTPS spellings; never persist embedded credentials.
23
+ key = remote.replace(/^git@([^:]+):/, '$1/').replace(/^https?:\/\/(?:[^/@]+@)?/, '').replace(/^ssh:\/\/git@/, '').replace(/\.git\/?$/, '').replace(/\/$/, '');
24
+ kind = 'git';
25
+ }
26
+ } catch {}
27
+ const id = kind + ':' + crypto.createHash('sha256').update(key).digest('hex').slice(0, 32);
28
+ cache.set(absolute, { id, at: Date.now() });
29
+ return id;
30
+ }
31
+
32
+ // Resolve project paths once per query, not once for every stored record.
33
+ // A new predicate is created on each query so time-based validity is current.
34
+ export function memoryVisibility({ project = process.env.MEMOIR_PROJECT_ROOT || process.cwd(), allProjects = false, now = Date.now() } = {}) {
35
+ let activeId, currentKey, sharedKey;
36
+ const identities = new Map();
37
+ return item => {
38
+ if (!item || item.hidden === true || item.hidden === 'true' || item.deleted === true || ['deleted', 'hidden', 'superseded'].includes(item.status) || item.superseded_by) return false;
39
+ if (item.valid_from && Date.parse(item.valid_from) > now) return false;
40
+ if (item.valid_until && Date.parse(item.valid_until) <= now) return false;
41
+ if (!allProjects && item.claudeProjectKey) {
42
+ currentKey ??= path.resolve(project.replace(/^~/, os.homedir())).replace(/[\\/:]/g, '-');
43
+ sharedKey ??= os.homedir().replace(/[\\/:]/g, '-');
44
+ if (item.claudeProjectKey !== currentKey && item.claudeProjectKey !== sharedKey) return false;
45
+ }
46
+ if (allProjects || !item.project || item.project === 'shared') return true;
47
+ activeId ??= projectIdentity(project);
48
+ const key = String(item.project);
49
+ if (!identities.has(key)) identities.set(key, projectIdentity(key));
50
+ return identities.get(key) === activeId;
51
+ };
52
+ }
53
+
54
+ export function visibleMemory(item, options = {}) {
55
+ return memoryVisibility(options)(item);
56
+ }
57
+
58
+ export function sessionView(state, options = {}) {
59
+ const current = { ...(state?.current || {}) };
60
+ for (const key of ['goals', 'next_actions', 'parked_actions', 'open_questions', 'decisions']) {
61
+ const archived = { goals: 'archived_goals', decisions: 'archived_decisions', open_questions: 'archived_questions' }[key];
62
+ current[key] = [...(current[key] || []), ...(archived ? current[archived] || [] : [])].filter(item => visibleMemory(item, options));
63
+ }
64
+ return { ...state, current, history: (state?.history || []).filter(item => visibleMemory(item, options)) };
65
+ }