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
@@ -45,6 +45,9 @@ const DATE_FIELDS = ['created', 'updated', 'date', 'set_on', 'added', 'done_at',
45
45
 
46
46
  function parseScalar(raw) {
47
47
  let v = String(raw).trim();
48
+ if (v.startsWith('"') && v.endsWith('"')) {
49
+ try { return JSON.parse(v); } catch {}
50
+ }
48
51
  if (
49
52
  (v.startsWith('"') && v.endsWith('"') && v.length >= 2) ||
50
53
  (v.startsWith("'") && v.endsWith("'") && v.length >= 2)
@@ -277,6 +280,19 @@ export function validateSessionObject(obj) {
277
280
  if (d && d.hidden === true && !isIsoDateString(d.hidden_at)) {
278
281
  errors.push(`current.decisions[${i}] hidden: true without a valid hidden_at (SPEC.md 5.3.1)`);
279
282
  }
283
+ // Purged form: text_hash only makes sense on a hidden tombstone whose
284
+ // text is the [purged] literal; a hash on a live decision is not an identity.
285
+ if (d && d.text_hash != null) {
286
+ if (!/^[0-9a-f]{64}$/.test(String(d.text_hash))) {
287
+ errors.push(`current.decisions[${i}] text_hash must be lowercase hex SHA-256 (SPEC.md 5.3.1)`);
288
+ }
289
+ if (d.hidden !== true) {
290
+ errors.push(`current.decisions[${i}] text_hash without hidden: true — purge implies hide (SPEC.md 5.3.1)`);
291
+ }
292
+ if (d.text !== '[purged]') {
293
+ warnings.push(`current.decisions[${i}] carries text_hash but text is not "[purged]" — the redacted text may still be present`);
294
+ }
295
+ }
280
296
  });
281
297
  // completed_actions is optional (absent = empty), but when present its
282
298
  // tombstones must be well-formed or the temporal merge rule breaks.
@@ -5,7 +5,7 @@ import os from 'os';
5
5
  import ora from 'ora';
6
6
  import boxen from 'boxen';
7
7
  import inquirer from 'inquirer';
8
- import { execSync } from 'child_process';
8
+ import { execSync, execFileSync } from 'child_process';
9
9
  import { getConfig } from '../config.js';
10
10
  import { adapters } from '../adapters/index.js';
11
11
 
@@ -49,7 +49,7 @@ export async function viewCommand(options = {}) {
49
49
 
50
50
  try {
51
51
  if (config.provider === 'git') {
52
- execSync(`git clone --depth 1 ${config.gitRepo} .`, { cwd: stagingDir, stdio: 'ignore' });
52
+ execFileSync('git', ['clone', '--depth', '1', '--', config.gitRepo, '.'], { cwd: stagingDir, stdio: 'ignore' });
53
53
  } else {
54
54
  const resolvedSource = config.localPath.replace(/^~/, os.homedir());
55
55
  await fs.copy(resolvedSource, stagingDir);
@@ -4,7 +4,8 @@
4
4
 
5
5
  import chalk from 'chalk';
6
6
  import boxen from 'boxen';
7
- import { readSession } from '../session/state.js';
7
+ import { visibleMemory } from '../memory/scope.js';
8
+ import { readSession, allDecisions } from '../session/state.js';
8
9
 
9
10
  function searchDecisions(decisions, query) {
10
11
  if (!query) return decisions;
@@ -31,7 +32,7 @@ export async function whyCommand(query) {
31
32
  // see scripts/cleanup-junk-decisions-2026-07.mjs. Excluded here so
32
33
  // tombstoned junk isn't fully discoverable via `memoir why` even after
33
34
  // being hidden from the pinned block.
34
- const decisions = (state.current?.decisions || []).filter(d => !d?.hidden);
35
+ const decisions = allDecisions(state).filter(d => visibleMemory(d));
35
36
  const matches = searchDecisions(decisions, query);
36
37
 
37
38
  if (matches.length === 0) {
@@ -62,6 +63,6 @@ export async function whyCommand(query) {
62
63
  // filter as whyCommand above — kept independent rather than relying solely
63
64
  // on the caller, so this stays correct even if mcp.js's call chain changes.
64
65
  export function findDecisions(state, query) {
65
- const decisions = (state.current?.decisions || []).filter(d => !d?.hidden);
66
+ const decisions = allDecisions(state).filter(d => visibleMemory(d));
66
67
  return searchDecisions(decisions, query);
67
68
  }
package/src/config.js CHANGED
@@ -115,48 +115,17 @@ export async function deleteProfile(name) {
115
115
  await saveConfig(raw);
116
116
  }
117
117
 
118
- // Zero-config auto-setup: detect GitHub user, create repo, save config, return it
118
+ // First use stays local. Remote destinations require explicit configuration;
119
+ // no account lookup, repository creation, or upload happens as a side effect.
119
120
  export async function autoSetup() {
120
- // Try gh CLI first, then git config
121
- let username = '';
122
- try {
123
- username = execFileSync('gh', ['api', 'user', '--jq', '.login'], { encoding: 'utf8', timeout: 5000 }).trim();
124
- } catch {
125
- try {
126
- username = execFileSync('git', ['config', '--global', 'user.name'], { encoding: 'utf8' }).trim();
127
- } catch {}
128
- }
129
-
130
- if (!username) return null; // Can't auto-setup without a username
131
-
132
- const repo = 'ai-memory';
133
- const gitRepo = `https://github.com/${username}/${repo}.git`;
134
-
135
- // Try to create the repo if it doesn't exist (best-effort)
136
- try {
137
- execFileSync('gh', ['repo', 'view', `${username}/${repo}`], { stdio: 'ignore', timeout: 5000 });
138
- } catch {
139
- try {
140
- execFileSync('gh', ['repo', 'create', `${username}/${repo}`, '--private', '--description', 'AI memory backup (memoir-cli)'], { stdio: 'ignore', timeout: 10000 });
141
- } catch {
142
- // If gh isn't available, user will need to create repo manually — that's fine, syncToGit will handle it
143
- }
144
- }
145
-
146
- const config = {
147
- version: 2,
148
- activeProfile: 'default',
149
- profiles: {
150
- default: {
151
- provider: 'git',
152
- gitRepo,
153
- encrypt: false // Skip encryption for zero-config — user can enable later with `memoir encrypt`
154
- }
155
- }
121
+ if (await fs.pathExists(CONFIG_FILE)) throw new Error('An existing configuration could not be resolved. Check the selected profile or repair the configuration; it was not overwritten.');
122
+ const profile = {
123
+ provider: 'local',
124
+ localPath: path.join(CONFIG_DIR, 'backups'),
125
+ encrypt: Boolean(process.env.MEMOIR_PASSPHRASE),
156
126
  };
157
-
158
- await saveConfig(config);
159
- return config.profiles.default;
127
+ await saveConfig({ version: 2, activeProfile: 'default', profiles: { default: profile } });
128
+ return profile;
160
129
  }
161
130
 
162
131
  export async function getGeminiApiKey() {
@@ -1,10 +1,31 @@
1
1
  import fs from 'fs-extra';
2
2
  import path from 'path';
3
3
  import os from 'os';
4
+ import { execFileSync } from 'child_process';
4
5
  import { scanForSecrets, redactSecrets } from '../security/scanner.js';
5
6
 
6
7
  const home = os.homedir();
7
8
 
9
+ // Terminal colour codes leak into transcripts through <local-command-stdout>
10
+ // blocks (a `/model` switch prints "\x1b[2m…"). Strip before anything is
11
+ // persisted — a handoff is read by a human and a model, not a terminal.
12
+ const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
13
+ export function stripAnsi(s) {
14
+ return String(s || '').replace(ANSI_RE, '');
15
+ }
16
+
17
+ // Claude Code delivers its own machinery as `user` turns: <command-name> /
18
+ // <command-args> / <local-command-stdout> around slash commands, the
19
+ // <local-command-caveat> (isMeta) that precedes them, <task-notification>s
20
+ // from background agents, hook output. A person's message never starts
21
+ // with an XML-ish tag; these always do. Live proof: the author's 2026-09-04
22
+ // handoff listed "/model", its caveat and its ANSI-coloured stdout as the
23
+ // three things he "was working on".
24
+ const MACHINERY_RE = /^\s*<[a-z][\w-]*(?:\s[^>]*)?>/i;
25
+ export function isMachineryMessage(s) {
26
+ return MACHINERY_RE.test(String(s || ''));
27
+ }
28
+
8
29
  /**
9
30
  * Find all Claude session files, sorted newest first
10
31
  */
@@ -19,8 +40,16 @@ export function findClaudeSessions() {
19
40
  for (const entry of entries) {
20
41
  const full = path.join(dir, entry.name);
21
42
  if (entry.isDirectory()) {
43
+ // Skip Claude Code's per-session side directories. `subagents/`
44
+ // holds agent-*.jsonl transcripts whose FIRST user message is the
45
+ // orchestrator's prompt ("You are a software architect. Note that
46
+ // ...") — the filename check below never caught them (the file is
47
+ // agent-<id>.jsonl, not *subagent*), so USER_NOTE_RE minted
48
+ // decisions out of system prompts. Live proof: three of the
49
+ // author's own pinned decisions were subagent-prompt fragments.
50
+ if (entry.name === 'subagents' || entry.name === 'workflows' || entry.name === 'tool-results') continue;
22
51
  scanDir(full);
23
- } else if (entry.name.endsWith('.jsonl') && !entry.name.includes('subagent')) {
52
+ } else if (entry.name.endsWith('.jsonl') && !entry.name.includes('subagent') && !entry.name.startsWith('agent-')) {
24
53
  try {
25
54
  const stat = fs.statSync(full);
26
55
  // Skip files older than 7 days for performance
@@ -61,7 +90,6 @@ export function parseSession(sessionPath, maxSizeMB = 10) {
61
90
  }
62
91
 
63
92
  function parseLines(lines) {
64
- const assistantTexts = [];
65
93
  const result = {
66
94
  sessionId: null,
67
95
  slug: null,
@@ -88,25 +116,41 @@ function parseLines(lines) {
88
116
  if (!result.firstTimestamp && obj.timestamp) result.firstTimestamp = obj.timestamp;
89
117
  if (obj.timestamp) result.lastTimestamp = obj.timestamp;
90
118
 
91
- // User messages — redact secrets
92
- if (obj.type === 'user' && obj.message?.content) {
93
- const content = typeof obj.message.content === 'string' ? obj.message.content : '';
94
- if (content.length > 3 && !content.startsWith('<task-notification>')) {
119
+ // User messages — redact secrets. Skip the tool's own machinery (see
120
+ // isMachineryMessage) and isMeta turns; strip terminal colour codes.
121
+ if (obj.type === 'user' && obj.message?.content && !obj.isMeta) {
122
+ const content = typeof obj.message.content === 'string' ? stripAnsi(obj.message.content) : '';
123
+ if (content.length > 3 && !isMachineryMessage(content)) {
95
124
  result.userMessages.push(redactSecrets(content));
96
125
  }
126
+ // Tool results ride inside USER turns as tool_result blocks (there is
127
+ // no top-level type:"tool_result" in a Claude transcript — the old
128
+ // branch that looked for one never matched, so "Issues I ran into"
129
+ // was always empty). Only blocks the tool flagged is_error count,
130
+ // and only when they carry a line that names the failure — a bare
131
+ // "Exit code 1" from a grep with no matches is not an issue.
132
+ if (Array.isArray(obj.message.content)) {
133
+ for (const block of obj.message.content) {
134
+ if (!block || block.type !== 'tool_result' || !block.is_error) continue;
135
+ const text = typeof block.content === 'string'
136
+ ? block.content
137
+ : (Array.isArray(block.content) ? block.content.map((c) => c?.text || '').join('\n') : '');
138
+ const line = stripAnsi(text).split('\n').map((l) => l.trim())
139
+ .find((l) => l && l.length < 200 && /error|fail|exception|cannot|not found|denied|refused|traceback|fatal|unexpected/i.test(l));
140
+ if (line) result.errors.push(redactSecrets(line));
141
+ }
142
+ }
97
143
  }
98
144
 
99
- // Tool uses and text from assistant
145
+ // Tool uses from assistant turns. Assistant PROSE is deliberately not
146
+ // collected: it used to feed extractDecisions, so the model's own
147
+ // "let's use Redis for caching" minted a decision the user never made
148
+ // (agent-memory-atlas issue #7, and two content-free rows in the
149
+ // author's store on 2026-09-04). Decisions come from the user's words
150
+ // or from explicit memoir_note / `memoir note` — never inferred from
151
+ // what the assistant said.
100
152
  if (obj.type === 'assistant' && Array.isArray(obj.message?.content)) {
101
153
  for (const block of obj.message.content) {
102
- if (block.type === 'text' && block.text) {
103
- // Capture assistant text for decision extraction (limit size)
104
- // Redacted like every other untrusted input (user :95, bash :125,
105
- // errors :138) — captured decisions flow into session.json, CLAUDE.md
106
- // and the git backup, none of which get a later secret scan.
107
- if (block.text.length < 2000) assistantTexts.push(redactSecrets(block.text));
108
- continue;
109
- }
110
154
  if (block.type !== 'tool_use') continue;
111
155
  const name = block.name;
112
156
  const input = block.input || {};
@@ -132,24 +176,14 @@ function parseLines(lines) {
132
176
  }
133
177
  }
134
178
 
135
- // Errors from tool results
136
- if (obj.type === 'tool_result' && obj.message?.content) {
137
- const content = typeof obj.message.content === 'string' ? obj.message.content : '';
138
- if (content.includes('Error') || content.includes('error') || content.includes('FAIL')) {
139
- const errorLine = content.split('\n').find(l => /error|fail/i.test(l));
140
- if (errorLine && errorLine.length < 200) {
141
- result.errors.push(redactSecrets(errorLine.trim()));
142
- }
143
- }
144
- }
145
179
  }
146
180
 
147
181
  result.filesWritten = [...result.filesWritten];
148
182
  result.filesRead = [...result.filesRead];
149
183
  result.errors = [...new Set(result.errors)].slice(0, 10);
150
184
 
151
- // Extract decisions from user + assistant messages
152
- result.decisions = extractDecisions(result.userMessages, assistantTexts);
185
+ // Extract decisions from the user's messages only (see above).
186
+ result.decisions = extractDecisions(result.userMessages);
153
187
 
154
188
  return result;
155
189
  }
@@ -210,9 +244,9 @@ export function isQuality(text) {
210
244
  * Extract durable decisions from session conversation.
211
245
  * These are things like renames, tech choices, preferences — stuff that should persist.
212
246
  */
213
- function extractDecisions(userMessages, assistantTexts) {
247
+ function extractDecisions(userMessages) {
214
248
  const decisions = [];
215
- const allText = [...userMessages, ...assistantTexts].join('\n');
249
+ const allText = userMessages.join('\n');
216
250
 
217
251
  // Patterns that indicate a decision was made
218
252
  const patterns = [
@@ -240,6 +274,12 @@ function extractDecisions(userMessages, assistantTexts) {
240
274
  while ((match = regex.exec(allText)) !== null) {
241
275
  const value = match[1].trim().replace(/["']+$/, '');
242
276
  if (looksLikeFragment(value)) continue;
277
+ // The keyword half of the rename/tech patterns is case-insensitive
278
+ // (`i` flag) but the captured TARGET must be a proper noun — a product,
279
+ // a library, a name. Without this check the flag also lower-cased the
280
+ // [A-Z] anchor: "the name is settled" minted the value "settled" and
281
+ // "rename the app from …" minted "from" (both real rows, 2026-09-04).
282
+ if (!/^[A-Z]/.test(value)) continue;
243
283
  if (value.length > 2 && value.length < 80) {
244
284
  // Avoid duplicates
245
285
  const existing = decisions.find(d => d.value.toLowerCase() === value.toLowerCase());
@@ -458,6 +498,64 @@ export function promoteMemoriesToGlobal() {
458
498
  return promoted;
459
499
  }
460
500
 
501
+ /**
502
+ * Files changed in the session's repository according to git: every file in
503
+ * a commit made since the session started, plus the current uncommitted
504
+ * changes. The transcript only knows about Edit/Write tool calls — a session
505
+ * that shipped through Bash heredocs, subagents or `git commit` reports
506
+ * "Files I changed: None" (27 of the author's last 40 handoffs, including a
507
+ * 15-hour session that shipped a product rename). Git is the honest source.
508
+ * Best effort: no repo, no git binary, or a slow repo → [].
509
+ */
510
+ export function gitChangedFiles(cwd, sinceIso, { timeoutMs = 4000, cap = 200 } = {}) {
511
+ if (!cwd) return [];
512
+ const run = (args) => execFileSync('git', ['-C', cwd, ...args], {
513
+ encoding: 'utf8',
514
+ stdio: ['ignore', 'pipe', 'ignore'],
515
+ timeout: timeoutMs,
516
+ });
517
+ let root;
518
+ try { root = run(['rev-parse', '--show-toplevel']).trim(); } catch { return []; }
519
+ if (!root) return [];
520
+
521
+ const files = new Set();
522
+ try {
523
+ const args = ['log', '--name-only', '--format=', '--no-merges', '-n', '200'];
524
+ if (sinceIso) args.push(`--since=${sinceIso}`);
525
+ for (const line of run(args).split('\n')) {
526
+ const f = line.trim();
527
+ if (f) files.add(f);
528
+ }
529
+ } catch {}
530
+ try {
531
+ for (const line of run(['status', '--porcelain', '--untracked-files=normal']).split('\n')) {
532
+ if (line.length < 4) continue;
533
+ let f = line.slice(3).trim();
534
+ const arrow = f.indexOf(' -> ');
535
+ if (arrow >= 0) f = f.slice(arrow + 4);
536
+ f = f.replace(/^"|"$/g, '');
537
+ if (f && !f.endsWith('/')) files.add(f);
538
+ }
539
+ } catch {}
540
+ return [...files].slice(0, cap).map((f) => path.join(root, f));
541
+ }
542
+
543
+ /**
544
+ * Merge git's view of what changed into parsed.filesWritten (in place).
545
+ * Called by push/snapshot after parseSession; kept separate so parseSession
546
+ * stays a pure function of the transcript (and stays testable without git).
547
+ */
548
+ export function enrichWithGit(parsed) {
549
+ try {
550
+ const fromGit = gitChangedFiles(parsed.cwd, parsed.firstTimestamp);
551
+ if (fromGit.length) {
552
+ parsed.filesWritten = [...new Set([...(parsed.filesWritten || []), ...fromGit])];
553
+ parsed.gitFiles = fromGit.length;
554
+ }
555
+ } catch {}
556
+ return parsed;
557
+ }
558
+
461
559
  /**
462
560
  * Generate a concise handoff markdown from parsed session
463
561
  * This is what gets injected into the AI tool on the other machine
@@ -485,10 +583,13 @@ export function generateContextHandoff(parsed) {
485
583
  return fp;
486
584
  };
487
585
 
488
- // Filter meaningful user messages
586
+ // Filter meaningful user messages. The LAST few are what "continue where
587
+ // I left off" needs — the first eight of a fifteen-hour session are stale.
489
588
  const meaningful = parsed.userMessages
490
- .filter(m => m.length > 10 && !/^(ok|yes|no|sure|yea|yeah|yep|nah|nope|thanks|ty|thx|good|great|nice|cool|done|hmm)$/i.test(m.trim()))
491
- .map(m => m.length > 150 ? m.slice(0, 150) + '...' : m);
589
+ .filter(m => m.length > 10 && !isMachineryMessage(m) && !/^(ok|yes|no|sure|yea|yeah|yep|nah|nope|thanks|ty|thx|good|great|nice|cool|done|hmm)$/i.test(m.trim()))
590
+ .map(m => stripAnsi(m).replace(/\s+/g, ' ').trim())
591
+ .map(m => m.length > 150 ? m.slice(0, 150) + '...' : m)
592
+ .slice(-8);
492
593
 
493
594
  // Build a concise, actionable handoff
494
595
  let md = `---
@@ -503,11 +604,12 @@ type: project
503
604
  > Session: ${duration} | Branch: \`${parsed.gitBranch || 'unknown'}\` | Project: \`${cwd}\`
504
605
 
505
606
  ## What I was working on
506
- ${meaningful.length > 0 ? meaningful.slice(0, 8).map(m => `- ${m}`).join('\n') : '_No significant messages captured_'}
607
+ ${meaningful.length > 0 ? meaningful.map(m => `- ${m}`).join('\n') : '_No significant messages captured_'}
507
608
 
508
609
  ## Files I changed
509
610
  ${parsed.filesWritten.length > 0
510
611
  ? parsed.filesWritten.slice(0, 15).map(f => `- \`${shorten(f)}\``).join('\n')
612
+ + (parsed.filesWritten.length > 15 ? `\n- …and ${parsed.filesWritten.length - 15} more` : '')
511
613
  : '_None_'}
512
614
  `;
513
615
 
@@ -0,0 +1,72 @@
1
+ // Handoff files: ~/.config/memoir/handoffs/<timestamp>-claude.md + latest.md.
2
+ //
3
+ // One is written on EVERY autopush (the Stop hook fires after each response),
4
+ // so without a bound the directory grows by ~50 files a day forever — 3,944
5
+ // files / 15MB on the author's machine before this module existed, none of
6
+ // them ever read (only `memoir resume` reads, and it reads latest.md).
7
+ // Keep a window: the newest HANDOFF_KEEP_MAX files that are also younger
8
+ // than HANDOFF_KEEP_DAYS. latest.md is always kept. Pruning is best-effort
9
+ // and never fails the write that triggered it.
10
+
11
+ import fs from 'fs-extra';
12
+ import path from 'path';
13
+ import os from 'os';
14
+
15
+ export const HANDOFF_KEEP_DAYS = 14;
16
+ export const HANDOFF_KEEP_MAX = 200;
17
+
18
+ // Resolved at call time (not module load) so tests can shim $HOME.
19
+ export function localHandoffDir() {
20
+ return path.join(os.homedir(), '.config', 'memoir', 'handoffs');
21
+ }
22
+
23
+ export function handoffFilename(now = new Date()) {
24
+ return `${now.toISOString().replace(/[:.]/g, '-').slice(0, 19)}-claude.md`;
25
+ }
26
+
27
+ /**
28
+ * Write `content` as a timestamped handoff + latest.md into every dir in
29
+ * `dirs` (default: the local handoff dir), then prune the local dir.
30
+ * Returns the filename used, so callers that also stage a copy for upload
31
+ * use the same name locally and remotely.
32
+ */
33
+ export async function saveHandoff(content, { dirs, filename } = {}) {
34
+ const name = filename || handoffFilename();
35
+ const targets = dirs && dirs.length ? dirs : [localHandoffDir()];
36
+ for (const dir of targets) {
37
+ await fs.ensureDir(dir);
38
+ await fs.writeFile(path.join(dir, name), content);
39
+ await fs.writeFile(path.join(dir, 'latest.md'), content);
40
+ }
41
+ try { await pruneHandoffs(localHandoffDir()); } catch {}
42
+ return name;
43
+ }
44
+
45
+ /**
46
+ * Delete handoff files beyond the window. Returns how many were removed.
47
+ * Order is by mtime, newest first; a file survives only if it is within the
48
+ * first `keepMax` AND younger than `keepDays`.
49
+ */
50
+ export async function pruneHandoffs(dir, { keepDays = HANDOFF_KEEP_DAYS, keepMax = HANDOFF_KEEP_MAX, now = Date.now() } = {}) {
51
+ let names;
52
+ try { names = await fs.readdir(dir); } catch { return 0; }
53
+ const entries = [];
54
+ for (const name of names) {
55
+ if (name === 'latest.md' || !name.endsWith('.md')) continue;
56
+ try {
57
+ const st = await fs.stat(path.join(dir, name));
58
+ entries.push({ name, mtime: st.mtimeMs });
59
+ } catch {}
60
+ }
61
+ entries.sort((a, b) => b.mtime - a.mtime);
62
+ const cutoff = now - keepDays * 24 * 60 * 60 * 1000;
63
+ let removed = 0;
64
+ for (let i = 0; i < entries.length; i++) {
65
+ if (i < keepMax && entries[i].mtime >= cutoff) continue;
66
+ try {
67
+ await fs.remove(path.join(dir, entries[i].name));
68
+ removed++;
69
+ } catch {}
70
+ }
71
+ return removed;
72
+ }
@@ -0,0 +1,122 @@
1
+ // "Is memoir actually being used?" — a pure summary over events.jsonl lines.
2
+ //
3
+ // The audit that motivated it (2026-09-04) had to grep Claude transcripts to
4
+ // learn that the store took 75 writes for every 10 reads in two weeks: the
5
+ // log recorded every write and sync but not one recall. Reads are logged
6
+ // since 3.13.0 (mcp_tool_used / cli_command, names only), so this can now
7
+ // answer from the log alone. Pure and synchronous: hand it lines, get counts.
8
+
9
+ const MCP_READS = new Set(['memoir_recall', 'memoir_why', 'memoir_read']);
10
+ const MCP_WRITES = new Set(['memoir_remember', 'memoir_note', 'memoir_add_next', 'memoir_complete_next', 'memoir_set_goal', 'memoir_forget', 'memoir_consolidate']);
11
+ const CLI_READS = new Set(['recall', 'why']);
12
+ const CLI_WRITES = new Set(['note', 'next', 'goal', 'done', 'forget', 'consolidate']);
13
+
14
+ export function emptySummary() {
15
+ return {
16
+ events: 0,
17
+ reads: 0,
18
+ writes: 0,
19
+ by_tool: {}, // { memoir_recall: 9, ... } and CLI commands as 'cli:recall'
20
+ decisions_captured: 0,
21
+ next_completed: 0,
22
+ next_parked: 0,
23
+ goals_completed: 0,
24
+ sync_pushed: 0,
25
+ sync_failed: 0,
26
+ sync_fail_reasons: {},
27
+ sync_ms: [], // durations of successful pushes that recorded one
28
+ first_ts: null,
29
+ last_ts: null,
30
+ };
31
+ }
32
+
33
+ /**
34
+ * @param {string[]|string} lines events.jsonl content or its lines
35
+ * @param {{ sinceMs?: number, now?: number }} opts window (default: everything)
36
+ */
37
+ export function summarizeEvents(lines, { sinceMs = 0, now = Date.now() } = {}) {
38
+ const s = emptySummary();
39
+ const cutoff = sinceMs > 0 ? now - sinceMs : 0;
40
+ const arr = Array.isArray(lines) ? lines : String(lines || '').split('\n');
41
+ for (const raw of arr) {
42
+ const line = raw.trim();
43
+ if (!line) continue;
44
+ let e;
45
+ try { e = JSON.parse(line); } catch { continue; }
46
+ const t = Date.parse(e.ts || '');
47
+ if (cutoff && (!Number.isFinite(t) || t < cutoff)) continue;
48
+ s.events++;
49
+ if (Number.isFinite(t)) {
50
+ if (!s.first_ts || t < Date.parse(s.first_ts)) s.first_ts = e.ts;
51
+ if (!s.last_ts || t > Date.parse(s.last_ts)) s.last_ts = e.ts;
52
+ }
53
+ switch (e.type) {
54
+ case 'mcp_tool_used': {
55
+ const tool = String(e.tool || '');
56
+ s.by_tool[tool] = (s.by_tool[tool] || 0) + 1;
57
+ if (MCP_READS.has(tool)) s.reads++;
58
+ else if (MCP_WRITES.has(tool)) s.writes++;
59
+ break;
60
+ }
61
+ case 'cli_command': {
62
+ const cmd = String(e.command || '');
63
+ const key = `cli:${cmd}`;
64
+ s.by_tool[key] = (s.by_tool[key] || 0) + 1;
65
+ if (CLI_READS.has(cmd)) s.reads++;
66
+ else if (CLI_WRITES.has(cmd)) s.writes++;
67
+ break;
68
+ }
69
+ case 'decision_captured': s.decisions_captured++; break;
70
+ case 'next_completed': s.next_completed++; break;
71
+ case 'next_parked': s.next_parked += Number(e.count) || 1; break;
72
+ case 'goal_completed': s.goals_completed++; break;
73
+ case 'sync_pushed':
74
+ s.sync_pushed++;
75
+ if (Number.isFinite(e.ms)) s.sync_ms.push(e.ms);
76
+ break;
77
+ case 'sync_failed': {
78
+ s.sync_failed++;
79
+ const r = String(e.reason || 'unrecorded');
80
+ s.sync_fail_reasons[r] = (s.sync_fail_reasons[r] || 0) + 1;
81
+ break;
82
+ }
83
+ default: break;
84
+ }
85
+ }
86
+ return s;
87
+ }
88
+
89
+ export function median(nums) {
90
+ if (!nums.length) return null;
91
+ const a = [...nums].sort((x, y) => x - y);
92
+ const mid = Math.floor(a.length / 2);
93
+ return a.length % 2 ? a[mid] : Math.round((a[mid - 1] + a[mid]) / 2);
94
+ }
95
+
96
+ /** Plain-text lines for `memoir status` (no colour; the caller styles). */
97
+ export function formatSummaryLines(s, { days = 7 } = {}) {
98
+ const out = [];
99
+ // Only tools that read or write memory make the top list — `push`,
100
+ // `autopush`, `status` are plumbing, not usage.
101
+ const isMemoryTool = (k) => k.startsWith('cli:') ? (CLI_READS.has(k.slice(4)) || CLI_WRITES.has(k.slice(4))) : (MCP_READS.has(k) || MCP_WRITES.has(k));
102
+ const top = Object.entries(s.by_tool).filter(([k]) => isMemoryTool(k)).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 4)
103
+ .map(([k, v]) => `${k.replace(/^memoir_/, '').replace(/^cli:/, 'cli ')} ${v}`).join(', ');
104
+ if (s.reads === 0 && s.writes === 0) {
105
+ out.push(`Last ${days} days: no memory reads or writes recorded${s.events ? '' : ' (reads are logged since 3.13.0)'}`);
106
+ } else {
107
+ out.push(`Memory read ${s.reads}× · written ${s.writes}×${top ? ` (${top})` : ''}`);
108
+ }
109
+ if (s.sync_pushed || s.sync_failed) {
110
+ const reasons = Object.entries(s.sync_fail_reasons).sort((a, b) => b[1] - a[1]).slice(0, 3)
111
+ .map(([k, v]) => `${k} ${v}`).join(', ');
112
+ const med = median(s.sync_ms);
113
+ out.push(`Sync ${s.sync_pushed} ok / ${s.sync_failed} failed${reasons ? ` (${reasons})` : ''}${med != null ? ` · median ${(med / 1000).toFixed(1)}s` : ''}`);
114
+ }
115
+ const bits = [];
116
+ if (s.decisions_captured) bits.push(`${s.decisions_captured} decision${s.decisions_captured === 1 ? '' : 's'} captured`);
117
+ if (s.next_completed) bits.push(`${s.next_completed} next-action${s.next_completed === 1 ? '' : 's'} completed`);
118
+ if (s.next_parked) bits.push(`${s.next_parked} parked`);
119
+ if (s.goals_completed) bits.push(`${s.goals_completed} goal${s.goals_completed === 1 ? '' : 's'} retired`);
120
+ if (bits.length) out.push(bits.join(' · '));
121
+ return out;
122
+ }