memoir-cli 3.8.1 → 3.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  # memoir
4
4
 
5
- **Sync AI memory across every coding tool. Zero config.**
5
+ **Sync AI memory across every tool and every machine — end-to-end encrypted. Free.**
6
6
 
7
7
  [![npm version](https://img.shields.io/npm/v/memoir-cli.svg?style=flat-square&color=7c6ef0)](https://npmjs.org/package/memoir-cli)
8
8
  [![npm downloads](https://img.shields.io/npm/dm/memoir-cli.svg?style=flat-square&color=7c6ef0)](https://npmjs.org/package/memoir-cli)
@@ -15,13 +15,15 @@
15
15
  npx memoir-cli
16
16
  ```
17
17
 
18
- One command. No install, no config, no API keys. Your AI now has persistent memory across sessions, tools, and machines. Works with Claude Code, Cursor, Windsurf, Gemini CLI, GitHub Copilot, and 6 more tools.
18
+ One command. No install, no config, no API keys. Claude Code on your Mac, Cursor on your laptop, Copilot at the office — **one memory follows you** across every tool and every machine, encrypted with a key only you hold. memoir's servers literally can't read it.
19
19
 
20
20
  ---
21
21
 
22
22
  ## What it does
23
23
 
24
- memoir is an [MCP memory server](https://modelcontextprotocol.io) that gives your AI tools persistent memory. Your AI can search, save, and recall context automatically like a Claude Code backup that works everywhere.
24
+ Your coding tools are starting to remember you Claude Code, Cursor, and Copilot all ship built-in memory now. But that memory is **trapped: one tool, one machine, stored in plaintext.** Switch from Cursor to Claude Code, or open a different laptop, and your AI is a stranger again.
25
+
26
+ memoir is the [MCP memory server](https://modelcontextprotocol.io) that breaks it out. **One memory, shared across every tool and synced to every machine — encrypted client-side, so even memoir's servers can't read it.** Your AI searches, saves, and recalls context automatically, everywhere you work.
25
27
 
26
28
  ```
27
29
  you: how does auth work in this project?
@@ -36,6 +38,20 @@ claude: Based on your previous sessions: this project uses JWT auth
36
38
 
37
39
  No re-explaining. memoir remembered.
38
40
 
41
+ ## How it's different
42
+
43
+ Native memory and the other memory tools each give you *part* of this. memoir is the only one that gives you all of it:
44
+
45
+ | | Cross-tool | Cross-machine sync | Zero-knowledge encrypted |
46
+ |---|:---:|:---:|:---:|
47
+ | **memoir** | ✅ | ✅ **free** | ✅ |
48
+ | Claude Code / Cursor native | ❌ one tool | ❌ one machine | ❌ |
49
+ | claude-mem | ✅ | ❌ local only | ❌ |
50
+ | basic-memory | ✅ | 💲 paid cloud | ❌ |
51
+ | mem0 / OpenMemory | ✅ | 💲 paid cloud | ❌ |
52
+
53
+ Native memory is locked to one tool on one machine. The others keep your memory in plaintext, or put cross-machine sync behind a paywall. memoir is the only one that does all three — every tool, every machine, encrypted under a key only you hold — for free. <sub>(Based on public docs, June 2026.)</sub>
54
+
39
55
  ## Quick start
40
56
 
41
57
  ```bash
@@ -67,7 +83,7 @@ Your AI gets 14 memory tools:
67
83
 
68
84
  Your AI forgets everything between sessions. You re-explain your codebase, your conventions, your decisions — every time.
69
85
 
70
- memoir fixes this by giving your AI a shared memory layer that works across **every tool you use**. Tell Claude something once. Cursor knows it too. Sync AI memory between tools, back it up to the cloud, restore it on any machine. And when your memories pile up, `memoir consolidate` cleans house finds duplicates, flags stale context, and optionally uses AI to merge and prune.
86
+ memoir fixes that. Tell Claude something once and Cursor knows it too your memory syncs between tools, backs up to the cloud, and restores on any machine. When it piles up, `memoir consolidate` cleans house: finds duplicates, flags stale context, and can use AI to merge and prune.
71
87
 
72
88
  **11 tools supported:** Claude Code, Cursor, Windsurf, Gemini CLI, GitHub Copilot, OpenAI Codex, ChatGPT, Aider, Zed, Cline, Continue.dev.
73
89
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "memoir-cli",
3
- "version": "3.8.1",
3
+ "version": "3.10.0",
4
4
  "mcpName": "io.github.camgitt/memoir",
5
- "description": "MCP server that gives Claude, Cursor, and Gemini long-term memory across sessions. Your AI remembers your codebase, decisions, and preferencesacross tools and machines.",
5
+ "description": "Private, portable AI memory: synced across every coding tool and machine, end-to-end encrypted, free. One memory for Claude Code, Cursor, Copilot, Gemini + more MCP-native, zero-knowledge, open source.",
6
6
  "main": "src/index.js",
7
7
  "type": "module",
8
8
  "bin": {
@@ -30,7 +30,7 @@
30
30
  "start": "node bin/memoir.js",
31
31
  "test": "node run-tests.mjs",
32
32
  "test:legacy": "bash test-local.sh",
33
- "prepublishOnly": "npm test",
33
+ "prepublishOnly": "node scripts/check-clean-for-publish.mjs && npm test",
34
34
  "postinstall": "node -e \"try{const c='\\x1b[36m',r='\\x1b[0m',g='\\x1b[90m';console.log('\\n '+c+'memoir'+r+' installed.\\n Run '+c+'memoir activate'+r+' in any project to give your AI long-term memory.\\n '+g+'https://memoir.sh'+r+'\\n')}catch{}\""
35
35
  },
36
36
  "keywords": [
@@ -73,7 +73,7 @@
73
73
  "author": "camgitt",
74
74
  "license": "MIT",
75
75
  "dependencies": {
76
- "@modelcontextprotocol/sdk": "^1.28.0",
76
+ "@modelcontextprotocol/sdk": "^1.29.0",
77
77
  "boxen": "^7.1.1",
78
78
  "chalk": "^5.3.0",
79
79
  "commander": "^12.0.0",
@@ -3,6 +3,7 @@ import fs from 'fs-extra';
3
3
  import path from 'path';
4
4
  import os from 'os';
5
5
  import inquirer from 'inquirer';
6
+ import { detectAvailableTargets } from '../session/inject.js';
6
7
 
7
8
  // The instruction files each AI tool reads, in priority order
8
9
  const INSTRUCTION_FILES = [
@@ -65,6 +66,25 @@ async function injectBlock(filePath) {
65
66
  }
66
67
  }
67
68
 
69
+ /**
70
+ * Auto-activate recall GLOBALLY: ensure the memoir instruction block exists in
71
+ * each installed tool's user-global config (e.g. ~/.claude/CLAUDE.md), so the AI
72
+ * is told to use memoir_recall/remember in EVERY project — no per-project step.
73
+ * Idempotent and additive (never clobbers existing content). Opt out by setting
74
+ * MEMOIR_NO_AUTO_ACTIVATE. Called from the SessionStart hook (auto-refresh).
75
+ */
76
+ export async function ensureRecallInstruction() {
77
+ if (process.env.MEMOIR_NO_AUTO_ACTIVATE) return { skipped: true, added: 0 };
78
+ let added = 0;
79
+ for (const target of Object.values(detectAvailableTargets())) {
80
+ try {
81
+ const res = await injectBlock(target);
82
+ if (res === 'appended' || res === 'created') added++;
83
+ } catch {}
84
+ }
85
+ return { added };
86
+ }
87
+
68
88
  /**
69
89
  * Remove memoir block from a file
70
90
  */
@@ -9,6 +9,9 @@
9
9
  import { readSession } from '../session/state.js';
10
10
  import { renderSession } from '../session/render.js';
11
11
  import { injectInto, detectAvailableTargets } from '../session/inject.js';
12
+ import { ensureRecallInstruction } from './activate.js';
13
+ import { tidyIndex } from './tidy.js';
14
+ import { resolveHomeMemoryDir } from '../context/capture.js';
12
15
 
13
16
  export async function autoRefreshCommand(options = {}) {
14
17
  const verbose = !!options.verbose;
@@ -24,6 +27,30 @@ export async function autoRefreshCommand(options = {}) {
24
27
  if (verbose) console.error(`memoir auto-refresh: ${tool} failed: ${err.message}`);
25
28
  }
26
29
  }
30
+ // Ensure recall is globally active (idempotent) so the AI uses memoir in
31
+ // every project without a manual `memoir activate`.
32
+ try {
33
+ const r = await ensureRecallInstruction();
34
+ if (verbose && r.added) console.log(`memoir auto-refresh: enabled recall in ${r.added} global config(s)`);
35
+ } catch (err) {
36
+ if (verbose) console.error(`memoir auto-refresh: ensureRecallInstruction failed: ${err.message}`);
37
+ }
38
+ // Lean-memory: keep the loaded index under budget so the AI loads ALL of it
39
+ // and wastes no context on bloat. Over-budget-only, archive-not-delete,
40
+ // opt out with MEMOIR_NO_AUTO_TIDY.
41
+ if (!process.env.MEMOIR_NO_AUTO_TIDY) {
42
+ try {
43
+ const dir = resolveHomeMemoryDir();
44
+ if (dir) {
45
+ const t = await tidyIndex(dir, { stamp: 'auto' });
46
+ if (verbose && t.archived?.length) {
47
+ console.log(`memoir auto-refresh: tidied index → archived ${t.archived.length} section(s), now ${t.newLineCount} lines`);
48
+ }
49
+ }
50
+ } catch (err) {
51
+ if (verbose) console.error(`memoir auto-refresh: tidy failed: ${err.message}`);
52
+ }
53
+ }
27
54
  } catch (err) {
28
55
  if (verbose) console.error(`memoir auto-refresh: ${err.message}`);
29
56
  // Never fail the hook — session start must proceed.
@@ -13,9 +13,11 @@ import fs from 'fs-extra';
13
13
  import path from 'path';
14
14
  import os from 'os';
15
15
  import { spawn } from 'child_process';
16
+ import { withSessionLock } from '../session/lock.js';
16
17
 
17
18
  const home = os.homedir();
18
19
  const STAMP_FILE = path.join(home, '.config', 'memoir', 'last-autopush.timestamp');
20
+ const STAMP_LOCK_FILE = path.join(home, '.config', 'memoir', 'last-autopush.timestamp.lock');
19
21
  const DEBOUNCE_SECONDS_DEFAULT = 30;
20
22
 
21
23
  export async function autopushCommand(options = {}) {
@@ -26,23 +28,35 @@ export async function autopushCommand(options = {}) {
26
28
  await fs.ensureDir(path.dirname(STAMP_FILE));
27
29
  } catch {}
28
30
 
29
- const now = Date.now();
30
- let last = 0;
31
- try {
32
- const raw = await fs.readFile(STAMP_FILE, 'utf8');
33
- last = parseInt(raw.trim(), 10) || 0;
34
- } catch {}
31
+ // The debounce check ("read timestamp, compare elapsed, write new
32
+ // timestamp") is itself an unlocked check-then-act — two Stop hooks firing
33
+ // in the same window could both pass the debounce gate and both spawn a
34
+ // detached `memoir push`, racing each other against the git remote. Wrap
35
+ // the whole read+compare+stamp cycle in the same lock primitive
36
+ // state.js's mutators use (a dedicated lock file — this stamp is an
37
+ // unrelated concern from session.json itself).
38
+ const shouldRun = await withSessionLock(STAMP_LOCK_FILE, async () => {
39
+ const now = Date.now();
40
+ let last = 0;
41
+ try {
42
+ const raw = await fs.readFile(STAMP_FILE, 'utf8');
43
+ last = parseInt(raw.trim(), 10) || 0;
44
+ } catch {}
35
45
 
36
- const elapsed = (now - last) / 1000;
37
- if (last && elapsed < debounce) {
38
- if (verbose) console.log(`memoir autopush: skipped (${Math.floor(elapsed)}s since last, debounce=${debounce}s)`);
39
- return;
40
- }
46
+ const elapsed = (now - last) / 1000;
47
+ if (last && elapsed < debounce) {
48
+ if (verbose) console.log(`memoir autopush: skipped (${Math.floor(elapsed)}s since last, debounce=${debounce}s)`);
49
+ return false;
50
+ }
41
51
 
42
- // Stamp BEFORE spawning so rapid repeat calls don't all race through.
43
- try {
44
- await fs.writeFile(STAMP_FILE, String(now));
45
- } catch {}
52
+ // Stamp BEFORE spawning so rapid repeat calls don't all race through.
53
+ try {
54
+ await fs.writeFile(STAMP_FILE, String(now));
55
+ } catch {}
56
+ return true;
57
+ });
58
+
59
+ if (!shouldRun) return;
46
60
 
47
61
  // Detach a background push. Parent exits immediately so Claude isn't blocked.
48
62
  const memoirBin = process.argv[1]; // path to this same memoir CLI
@@ -5,20 +5,72 @@ import os from 'os';
5
5
  import ora from 'ora';
6
6
  import boxen from 'boxen';
7
7
  import gradient from 'gradient-string';
8
+ import { execFileSync } from 'child_process';
8
9
  import { getConfig, autoSetup } from '../config.js';
9
10
  import { extractMemories, adapters } from '../adapters/index.js';
10
11
  import { syncToLocal, syncToGit } from '../providers/index.js';
11
12
  import inquirer from 'inquirer';
12
- import { findClaudeSessions, parseSession, generateContextHandoff, shouldIgnoreProject, persistDecisions } from '../context/capture.js';
13
+ import { findClaudeSessions, parseSession, generateContextHandoff, shouldIgnoreProject, persistDecisions, isQuality } from '../context/capture.js';
13
14
  import { scanForSecrets, printSecurityReport } from '../security/scanner.js';
14
15
  import { encryptDirectory, createVerifyToken } from '../security/encryption.js';
15
16
  import { getRawConfig, saveConfig, migrateConfigToV2 } from '../config.js';
16
17
  import { scanWorkspace } from '../workspace/tracker.js';
17
18
  import { promptActivate } from './activate.js';
18
- import { paths as sessionPaths, readSession, addNote, recordSessionEnd } from '../session/state.js';
19
+ import { paths as sessionPaths, readSession, writeSession, mergeSessions, addNote, recordSessionEnd } from '../session/state.js';
20
+ import { migrateSessionData } from '../session/migrations.js';
21
+ import { withSessionLock } from '../session/lock.js';
19
22
  import { renderSession } from '../session/render.js';
20
23
  import { injectInto, detectAvailableTargets } from '../session/inject.js';
21
24
 
25
+ // Best-effort fetch of the CURRENT remote session.json, so push.js can merge
26
+ // before overwrite instead of blindly clobbering it (see below). Returns the
27
+ // remote session state (already migrated to SCHEMA_VERSION) or null if the
28
+ // remote is unreachable, this is the very first push (nothing there yet), or
29
+ // the remote backup is encrypted (best-effort only — we deliberately don't
30
+ // force an extra decrypt passphrase prompt mid-push; falls back to
31
+ // local-only in that case, exactly like an unreachable remote).
32
+ async function fetchRemoteSessionBestEffort(config) {
33
+ try {
34
+ if (config.provider === 'local' || config.provider?.includes?.('local')) {
35
+ const resolvedDest = (config.localPath || '').replace(/^~/, os.homedir());
36
+ if (!resolvedDest) return null;
37
+ if (await fs.pathExists(path.join(resolvedDest, 'manifest.enc'))) return null; // encrypted
38
+ const remotePath = path.join(resolvedDest, 'session.json');
39
+ if (!(await fs.pathExists(remotePath))) return null;
40
+ const raw = JSON.parse(await fs.readFile(remotePath, 'utf8'));
41
+ const { state } = migrateSessionData(raw);
42
+ return state;
43
+ }
44
+
45
+ if (config.provider === 'git' || config.provider?.includes?.('git')) {
46
+ const repoUrl = config.gitRepo;
47
+ if (!repoUrl) return null;
48
+ const peekDir = path.join(os.tmpdir(), `memoir-push-peek-${Date.now()}`);
49
+ await fs.ensureDir(peekDir);
50
+ try {
51
+ try {
52
+ execFileSync('git', ['clone', '--depth', '1', repoUrl, '.'], { cwd: peekDir, stdio: 'ignore', timeout: 30000 });
53
+ } catch {
54
+ // Unreachable, or this is the very first push (repo doesn't exist
55
+ // yet / is empty) — fall back to local-only.
56
+ return null;
57
+ }
58
+ if (await fs.pathExists(path.join(peekDir, 'manifest.enc'))) return null; // encrypted
59
+ const remotePath = path.join(peekDir, 'session.json');
60
+ if (!(await fs.pathExists(remotePath))) return null;
61
+ const raw = JSON.parse(await fs.readFile(remotePath, 'utf8'));
62
+ const { state } = migrateSessionData(raw);
63
+ return state;
64
+ } finally {
65
+ await fs.remove(peekDir).catch(() => {});
66
+ }
67
+ }
68
+ } catch {
69
+ // Never let a merge-fetch failure block the push.
70
+ }
71
+ return null;
72
+ }
73
+
22
74
  // Recursively scan every staged file (the REAL tool memory/config files about
23
75
  // to be uploaded — CLAUDE.md, .cursorrules, settings.json, project configs,
24
76
  // etc.) for secrets. When `redact` is true, rewrite each offending file in
@@ -134,11 +186,20 @@ export async function pushCommand(options = {}) {
134
186
  await fs.writeFile(path.join(localHandoffDir, `${timestamp}-claude.md`), clean);
135
187
  await fs.writeFile(path.join(localHandoffDir, 'latest.md'), clean);
136
188
 
189
+ // Quality filter: auto-extracted decisions come from regex patterns
190
+ // that sometimes catch table cells, prose fragments, or truncated
191
+ // pasted-spec snippets. Run the SAME filter over parsed.decisions
192
+ // ONCE, before either persistence sink — previously persistDecisions()
193
+ // received the raw unfiltered list while only the session.json sink
194
+ // below filtered, so junk could reach session-decisions.md even after
195
+ // being rejected from session.json. Both sinks now agree on what's junk.
196
+ const qualityDecisions = parsed.decisions.filter(d => isQuality(String(d.value || '').trim()));
197
+
137
198
  // Persist decisions to Claude's memory so they survive across sessions
138
199
  let decisionCount = 0;
139
- if (parsed.decisions.length > 0) {
200
+ if (qualityDecisions.length > 0) {
140
201
  try {
141
- decisionCount = persistDecisions(parsed.decisions);
202
+ decisionCount = persistDecisions(qualityDecisions);
142
203
  } catch {}
143
204
  }
144
205
 
@@ -150,23 +211,8 @@ export async function pushCommand(options = {}) {
150
211
  const existingTexts = new Set(
151
212
  current.current.decisions.map(d => (d.text || '').trim().toLowerCase())
152
213
  );
153
- // Quality filter: auto-extracted decisions come from regex patterns
154
- // that sometimes catch table cells or prose fragments. Keep only
155
- // substantive-looking entries.
156
- const isQuality = (text) => {
157
- if (!text) return false;
158
- if (text.length < 15) return false; // too short to be a real decision
159
- if (text.length > 200) return false; // probably a snippet, not a decision
160
- if (/\|/.test(text)) return false; // markdown table fragment
161
- if (/[_*`]{3,}/.test(text)) return false; // markdown formatting leaked in
162
- if (!/[a-zA-Z]/.test(text)) return false; // no actual words
163
- const words = text.split(/\s+/).length;
164
- if (words < 3) return false; // less than 3 words isn't a decision
165
- return true;
166
- };
167
- for (const d of parsed.decisions.slice(0, 10)) {
214
+ for (const d of qualityDecisions.slice(0, 10)) {
168
215
  const text = String(d.value || '').trim();
169
- if (!isQuality(text)) continue;
170
216
  if (existingTexts.has(text.toLowerCase())) continue;
171
217
  await addNote(text, { why: d.context ? `auto-captured: ${d.context.slice(0, 80)}` : undefined });
172
218
  }
@@ -225,11 +271,38 @@ export async function pushCommand(options = {}) {
225
271
  // Workspace scan is best-effort
226
272
  }
227
273
 
228
- // Include session.json (continuity state) so it syncs across machines
274
+ // Include session.json (continuity state) so it syncs across machines.
275
+ //
276
+ // MERGE-BEFORE-OVERWRITE: this used to be a blind fs.copy() of the LOCAL
277
+ // session.json, and syncToGit/syncToLocal do a full-mirror overwrite of
278
+ // the remote (clone-or-init, delete every tracked file, copy the local
279
+ // staging dir wholesale over it, commit, push). Any machine that pushed
280
+ // without having restored first would silently and completely destroy
281
+ // whatever ANY OTHER machine had added to the remote in the interim —
282
+ // goals, next-actions, decisions, everything. Not an edge case: it's the
283
+ // default behavior of the most common operation in the tool (autopush
284
+ // fires after every single Claude Code response).
285
+ //
286
+ // Best-effort fetch the current remote session.json first, migrate it,
287
+ // and merge with mergeSessions (the same newest-timestamp-wins
288
+ // union-by-text function restore.js already uses) BEFORE writing the
289
+ // result to both the staging dir (for upload) and back to the local
290
+ // session.json (so this machine also gains whatever the remote had that
291
+ // it didn't) — symmetric with restore.js instead of a blind overwrite.
229
292
  let sessionIncluded = false;
230
293
  try {
231
294
  if (await fs.pathExists(sessionPaths.session)) {
232
- await fs.copy(sessionPaths.session, path.join(stagingDir, 'session.json'));
295
+ const remote = await fetchRemoteSessionBestEffort(config);
296
+ const local = await readSession();
297
+ const merged = remote ? mergeSessions(local, remote) : local;
298
+ if (remote) {
299
+ // Persist the merge locally too, inside the same lock every other
300
+ // session.json read-modify-write cycle uses.
301
+ await withSessionLock(sessionPaths.sessionLock, async () => {
302
+ await writeSession(merged);
303
+ });
304
+ }
305
+ await fs.writeFile(path.join(stagingDir, 'session.json'), JSON.stringify(merged, null, 2));
233
306
  sessionIncluded = true;
234
307
  }
235
308
  } catch {
@@ -15,6 +15,7 @@ import { getSession } from '../cloud/auth.js';
15
15
  import { unbundleToDir } from '../cloud/storage.js';
16
16
  import { SUPABASE_URL, SUPABASE_ANON_KEY, STORAGE_BUCKET } from '../cloud/constants.js';
17
17
  import { readSession, writeSession, mergeSessions, paths as sessionPaths } from '../session/state.js';
18
+ import { migrateSessionData } from '../session/migrations.js';
18
19
  import { renderSession } from '../session/render.js';
19
20
  import { injectInto, detectAvailableTargets } from '../session/inject.js';
20
21
 
@@ -128,7 +129,13 @@ export async function restoreCommand(options = {}) {
128
129
  try {
129
130
  const remoteSessionPath = path.join(stagingDir, 'session.json');
130
131
  if (await fs.pathExists(remoteSessionPath)) {
131
- const remote = JSON.parse(await fs.readFile(remoteSessionPath, 'utf8'));
132
+ // Route the remote backup through the same migrate-on-load path as
133
+ // the local file (migrateSessionData — pure, no I/O) rather than a
134
+ // raw JSON.parse, so an old-schema file from a lagging machine gets
135
+ // migrated up (or a too-new one safely degraded) BEFORE mergeSessions
136
+ // ever touches it. Symmetric with the push-side fix in push.js.
137
+ const rawRemote = JSON.parse(await fs.readFile(remoteSessionPath, 'utf8'));
138
+ const { state: remote } = migrateSessionData(rawRemote);
132
139
  const local = await readSession();
133
140
  const beforeMachines = Object.keys(local.machines || {}).length;
134
141
  const merged = mergeSessions(local, remote);
@@ -0,0 +1,152 @@
1
+ // Lean-memory: keep the loaded memory index (MEMORY.md) under a line budget so
2
+ // the AI loads ALL of it (Claude Code reads only ~200 lines) and wastes no
3
+ // context on bloat. When over budget, the fattest *inline* sections are moved
4
+ // into a dated archive file and replaced with one-line pointers.
5
+ //
6
+ // Guarantees: archive-not-delete (nothing lost), never touches the critical
7
+ // behavior-rules section or the preamble, idempotent, dry-run capable,
8
+ // code-fence aware, content-deduped, atomic writes, graceful on errors.
9
+
10
+ import fs from 'fs-extra';
11
+ import path from 'path';
12
+ import { appendEvent } from '../events/log.js';
13
+
14
+ export const DEFAULT_BUDGET = 180; // Claude loads ~200 lines of MEMORY.md; leave headroom.
15
+
16
+ // Split into ## sections — but a "## " INSIDE a fenced code block (``` or ~~~)
17
+ // is content, not a header, so we never split there (would orphan content +
18
+ // leave an unclosed fence = invalid markdown + data loss).
19
+ function splitSections(text) {
20
+ const sections = [];
21
+ let cur = { header: '(preamble)', lines: [] };
22
+ let fence = null; // active fence marker while inside a code block
23
+ for (const line of text.split('\n')) {
24
+ const t = line.trimStart();
25
+ const m = t.match(/^(```|~~~)/);
26
+ if (m) {
27
+ if (!fence) fence = m[1];
28
+ else if (t.startsWith(fence)) fence = null;
29
+ }
30
+ if (!fence && /^##\s/.test(line)) {
31
+ sections.push(cur);
32
+ cur = { header: line.replace(/^##\s+/, '').trim(), lines: [line] };
33
+ } else {
34
+ cur.lines.push(line);
35
+ }
36
+ }
37
+ sections.push(cur);
38
+ return sections;
39
+ }
40
+
41
+ // A line is a lightweight pointer (not inline content to archive) if it's a
42
+ // clean `- [text](file)` OR any link to one of our archive files.
43
+ function isPointer(t) {
44
+ if (/^- \[[^\]]+\]\([^)]+\)/.test(t)) return true;
45
+ if (/\[[^\]]*\]\(memory_index_archive_[^)]*\)/.test(t)) return true;
46
+ return false;
47
+ }
48
+
49
+ function inlineWeight(section) {
50
+ return section.lines.filter(l => {
51
+ const t = l.trim();
52
+ if (!t) return false;
53
+ if (/^#{2,3}\s/.test(t)) return false;
54
+ if (isPointer(t)) return false;
55
+ return true;
56
+ }).length;
57
+ }
58
+
59
+ const PROTECTED = (header) => /critical behavior rules/i.test(header) || header === '(preamble)';
60
+
61
+ // Informational-only schema marker for MEMORY.md itself (distinct from — and
62
+ // unrelated to — session.json's SCHEMA_VERSION). No enforcement/refusal
63
+ // logic: this file is human-edited markdown, so a strict gate would hurt UX,
64
+ // not help it. Appended as a single HTML comment line (invisible when
65
+ // rendered) only when tidyIndex actually rewrites the file, and only once —
66
+ // idempotent, never duplicated on repeat runs. Counted in newLineCount like
67
+ // any other line, so it never causes a silent budget overshoot.
68
+ const MEMORY_SCHEMA_MARKER = '<!-- memoir:schemaVersion 1 -->';
69
+
70
+ async function atomicWrite(filePath, content) {
71
+ const tmp = `${filePath}.tmp-${process.pid}`;
72
+ await fs.writeFile(tmp, content);
73
+ await fs.move(tmp, filePath, { overwrite: true });
74
+ }
75
+
76
+ /**
77
+ * Tidy MEMORY.md down under `budgetLines`.
78
+ * @returns { overBudget, lineCount, newLineCount?, budgetLines, archived[], archiveFile?, dryRun? } | { ok:false, reason }
79
+ */
80
+ export async function tidyIndex(memoryDir, { budgetLines = DEFAULT_BUDGET, dryRun = false, stamp = 'archive' } = {}) {
81
+ const mdPath = path.join(memoryDir, 'MEMORY.md');
82
+ let text;
83
+ try {
84
+ if (!await fs.pathExists(mdPath)) return { ok: false, reason: 'no MEMORY.md' };
85
+ text = await fs.readFile(mdPath, 'utf8');
86
+ } catch (err) {
87
+ return { ok: false, reason: `read failed: ${err.code || err.message}` };
88
+ }
89
+
90
+ const lineCount = text.split('\n').length;
91
+ if (lineCount <= budgetLines) return { overBudget: false, lineCount, budgetLines, archived: [] };
92
+
93
+ const sections = splitSections(text);
94
+ const archiveFile = `memory_index_archive_${stamp}.md`;
95
+ const archivePath = path.join(memoryDir, archiveFile);
96
+
97
+ // Read the prior archive ONCE so we can content-dedup (no re-append bloat).
98
+ let priorArchive = '';
99
+ try { if (await fs.pathExists(archivePath)) priorArchive = await fs.readFile(archivePath, 'utf8'); } catch {}
100
+
101
+ // Fattest inline sections first; skip empty headers (would make `- []()`) and
102
+ // protected sections.
103
+ const candidates = sections
104
+ .map((s, i) => ({ i, s, weight: inlineWeight(s) }))
105
+ .filter(c => c.weight >= 6 && c.s.header.trim().length > 0 && !PROTECTED(c.s.header))
106
+ .sort((a, b) => b.weight - a.weight);
107
+
108
+ const removeIdx = new Map();
109
+ const archived = [];
110
+ let toAppend = '';
111
+ let projected = lineCount;
112
+ for (const c of candidates) {
113
+ if (projected <= budgetLines) break;
114
+ const body = c.s.lines.join('\n');
115
+ const key = body.trim();
116
+ // Only append content not already archived — dedup prevents bloat; the
117
+ // section is still safely in the archive so removing it from MEMORY.md is
118
+ // never a loss.
119
+ if (key && !priorArchive.includes(key) && !toAppend.includes(key)) {
120
+ toAppend += body + '\n\n';
121
+ }
122
+ removeIdx.set(c.i, `- [${c.s.header}](${archiveFile}) — moved out of the index ${stamp} (full detail in file)`);
123
+ archived.push({ section: c.s.header, lines: c.s.lines.length });
124
+ projected -= (c.s.lines.length - 1);
125
+ }
126
+
127
+ if (!archived.length) return { overBudget: true, lineCount, budgetLines, archived: [], note: 'over budget but no fat inline sections found' };
128
+ if (dryRun) return { overBudget: true, lineCount, projectedLines: projected, budgetLines, wouldArchive: archived, dryRun: true };
129
+
130
+ const out = [];
131
+ for (let i = 0; i < sections.length; i++) {
132
+ if (removeIdx.has(i)) out.push(removeIdx.get(i));
133
+ else out.push(...sections[i].lines);
134
+ }
135
+
136
+ if (!out.some(l => l.includes('memoir:schemaVersion'))) {
137
+ out.push(MEMORY_SCHEMA_MARKER);
138
+ }
139
+
140
+ const fm = `---\nname: Memory index archive (${stamp})\ndescription: Fat inline sections moved out of MEMORY.md to keep the loaded index under ${budgetLines} lines. Nothing deleted; pointers remain in MEMORY.md.\nmetadata:\n type: reference\n---\n`;
141
+ const base = priorArchive || fm;
142
+ if (toAppend) await atomicWrite(archivePath, base.trimEnd() + '\n\n' + toAppend.trimEnd() + '\n');
143
+ await atomicWrite(mdPath, out.join('\n'));
144
+
145
+ // Only reached when tidyIndex actually changed something (both earlier
146
+ // no-op paths — under budget, or over budget with nothing archivable —
147
+ // return before this point, and dryRun never writes). The event should
148
+ // mean "something happened," not "this function was called."
149
+ await appendEvent('tidy_ran', { archived_count: archived.length, from_lines: lineCount, to_lines: out.length });
150
+
151
+ return { overBudget: true, lineCount, newLineCount: out.length, budgetLines, archived, archiveFile };
152
+ }
@@ -8,16 +8,30 @@ import { readSession } from '../session/state.js';
8
8
 
9
9
  function searchDecisions(decisions, query) {
10
10
  if (!query) return decisions;
11
- const q = String(query).toLowerCase();
12
- return decisions.filter(d => {
13
- const haystack = [d.text, d.why, d.rejected].filter(Boolean).join(' ').toLowerCase();
14
- return haystack.includes(q);
15
- });
11
+ // Tokenize the query and match decisions containing any term, ranked by how
12
+ // many terms hit (recency breaks ties). A single whole-phrase substring match
13
+ // silently missed multi-word queries like "memoir positioning" even when every
14
+ // word was present — which is exactly how the MCP memoir_why tool queries.
15
+ const terms = String(query).toLowerCase().split(/\s+/).filter(Boolean);
16
+ if (!terms.length) return decisions;
17
+ return decisions
18
+ .map(d => {
19
+ const haystack = [d.text, d.why, d.rejected].filter(Boolean).join(' ').toLowerCase();
20
+ const score = terms.reduce((s, t) => s + (haystack.includes(t) ? 1 : 0), 0);
21
+ return { d, score };
22
+ })
23
+ .filter(x => x.score > 0)
24
+ .sort((a, b) => b.score - a.score || String(b.d.date || '').localeCompare(String(a.d.date || '')))
25
+ .map(x => x.d);
16
26
  }
17
27
 
18
28
  export async function whyCommand(query) {
19
29
  const state = await readSession();
20
- const decisions = state.current?.decisions || [];
30
+ // hidden:true is a tombstone (distinct from the live `rejected` field) —
31
+ // see scripts/cleanup-junk-decisions-2026-07.mjs. Excluded here so
32
+ // tombstoned junk isn't fully discoverable via `memoir why` even after
33
+ // being hidden from the pinned block.
34
+ const decisions = (state.current?.decisions || []).filter(d => !d?.hidden);
21
35
  const matches = searchDecisions(decisions, query);
22
36
 
23
37
  if (matches.length === 0) {
@@ -44,7 +58,10 @@ export async function whyCommand(query) {
44
58
  console.log('\n' + lines.join('\n'));
45
59
  }
46
60
 
47
- // Exported for MCP tool
61
+ // Exported for MCP tool (memoir_why in mcp.js). Same hidden:true tombstone
62
+ // filter as whyCommand above — kept independent rather than relying solely
63
+ // on the caller, so this stays correct even if mcp.js's call chain changes.
48
64
  export function findDecisions(state, query) {
49
- return searchDecisions(state.current?.decisions || [], query);
65
+ const decisions = (state.current?.decisions || []).filter(d => !d?.hidden);
66
+ return searchDecisions(decisions, query);
50
67
  }