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
@@ -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
  */
@@ -69,7 +90,6 @@ export function parseSession(sessionPath, maxSizeMB = 10) {
69
90
  }
70
91
 
71
92
  function parseLines(lines) {
72
- const assistantTexts = [];
73
93
  const result = {
74
94
  sessionId: null,
75
95
  slug: null,
@@ -96,25 +116,41 @@ function parseLines(lines) {
96
116
  if (!result.firstTimestamp && obj.timestamp) result.firstTimestamp = obj.timestamp;
97
117
  if (obj.timestamp) result.lastTimestamp = obj.timestamp;
98
118
 
99
- // User messages — redact secrets
100
- if (obj.type === 'user' && obj.message?.content) {
101
- const content = typeof obj.message.content === 'string' ? obj.message.content : '';
102
- 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)) {
103
124
  result.userMessages.push(redactSecrets(content));
104
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
+ }
105
143
  }
106
144
 
107
- // 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.
108
152
  if (obj.type === 'assistant' && Array.isArray(obj.message?.content)) {
109
153
  for (const block of obj.message.content) {
110
- if (block.type === 'text' && block.text) {
111
- // Capture assistant text for decision extraction (limit size)
112
- // Redacted like every other untrusted input (user :95, bash :125,
113
- // errors :138) — captured decisions flow into session.json, CLAUDE.md
114
- // and the git backup, none of which get a later secret scan.
115
- if (block.text.length < 2000) assistantTexts.push(redactSecrets(block.text));
116
- continue;
117
- }
118
154
  if (block.type !== 'tool_use') continue;
119
155
  const name = block.name;
120
156
  const input = block.input || {};
@@ -140,24 +176,14 @@ function parseLines(lines) {
140
176
  }
141
177
  }
142
178
 
143
- // Errors from tool results
144
- if (obj.type === 'tool_result' && obj.message?.content) {
145
- const content = typeof obj.message.content === 'string' ? obj.message.content : '';
146
- if (content.includes('Error') || content.includes('error') || content.includes('FAIL')) {
147
- const errorLine = content.split('\n').find(l => /error|fail/i.test(l));
148
- if (errorLine && errorLine.length < 200) {
149
- result.errors.push(redactSecrets(errorLine.trim()));
150
- }
151
- }
152
- }
153
179
  }
154
180
 
155
181
  result.filesWritten = [...result.filesWritten];
156
182
  result.filesRead = [...result.filesRead];
157
183
  result.errors = [...new Set(result.errors)].slice(0, 10);
158
184
 
159
- // Extract decisions from user + assistant messages
160
- result.decisions = extractDecisions(result.userMessages, assistantTexts);
185
+ // Extract decisions from the user's messages only (see above).
186
+ result.decisions = extractDecisions(result.userMessages);
161
187
 
162
188
  return result;
163
189
  }
@@ -218,9 +244,9 @@ export function isQuality(text) {
218
244
  * Extract durable decisions from session conversation.
219
245
  * These are things like renames, tech choices, preferences — stuff that should persist.
220
246
  */
221
- function extractDecisions(userMessages, assistantTexts) {
247
+ function extractDecisions(userMessages) {
222
248
  const decisions = [];
223
- const allText = [...userMessages, ...assistantTexts].join('\n');
249
+ const allText = userMessages.join('\n');
224
250
 
225
251
  // Patterns that indicate a decision was made
226
252
  const patterns = [
@@ -248,6 +274,12 @@ function extractDecisions(userMessages, assistantTexts) {
248
274
  while ((match = regex.exec(allText)) !== null) {
249
275
  const value = match[1].trim().replace(/["']+$/, '');
250
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;
251
283
  if (value.length > 2 && value.length < 80) {
252
284
  // Avoid duplicates
253
285
  const existing = decisions.find(d => d.value.toLowerCase() === value.toLowerCase());
@@ -466,6 +498,64 @@ export function promoteMemoriesToGlobal() {
466
498
  return promoted;
467
499
  }
468
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
+
469
559
  /**
470
560
  * Generate a concise handoff markdown from parsed session
471
561
  * This is what gets injected into the AI tool on the other machine
@@ -493,10 +583,13 @@ export function generateContextHandoff(parsed) {
493
583
  return fp;
494
584
  };
495
585
 
496
- // 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.
497
588
  const meaningful = parsed.userMessages
498
- .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()))
499
- .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);
500
593
 
501
594
  // Build a concise, actionable handoff
502
595
  let md = `---
@@ -511,11 +604,12 @@ type: project
511
604
  > Session: ${duration} | Branch: \`${parsed.gitBranch || 'unknown'}\` | Project: \`${cwd}\`
512
605
 
513
606
  ## What I was working on
514
- ${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_'}
515
608
 
516
609
  ## Files I changed
517
610
  ${parsed.filesWritten.length > 0
518
611
  ? parsed.filesWritten.slice(0, 15).map(f => `- \`${shorten(f)}\``).join('\n')
612
+ + (parsed.filesWritten.length > 15 ? `\n- …and ${parsed.filesWritten.length - 15} more` : '')
519
613
  : '_None_'}
520
614
  `;
521
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
+ }
@@ -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
+ }