memoir-cli 3.9.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memoir-cli",
3
- "version": "3.9.0",
3
+ "version": "3.10.0",
4
4
  "mcpName": "io.github.camgitt/memoir",
5
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",
@@ -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",
@@ -4,18 +4,12 @@ import path from 'path';
4
4
  import os from 'os';
5
5
  import chalk from 'chalk';
6
6
  import { shouldIgnoreProject } from '../context/capture.js';
7
- import { vscodeUserDir, vscodeGlobalStorage } from '../utils/platform.js';
8
7
 
9
8
  const home = os.homedir();
10
9
 
11
10
  const isWin = process.platform === 'win32';
12
11
  const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
13
12
 
14
- // VS Code-family config dirs — resolved per-OS (incl. Linux) via platform.js.
15
- const cursorUserDir = vscodeUserDir('Cursor');
16
- const windsurfUserDir = vscodeUserDir('Windsurf');
17
- const clineStorageDir = vscodeGlobalStorage('saoudrizwan.claude-dev');
18
-
19
13
  export const adapters = [
20
14
  {
21
15
  name: 'Gemini CLI',
@@ -84,9 +78,13 @@ export const adapters = [
84
78
  {
85
79
  name: 'Cursor',
86
80
  icon: '⚡',
87
- source: cursorUserDir,
81
+ source: isWin
82
+ ? path.join(appData, 'Cursor', 'User')
83
+ : path.join(home, 'Library', 'Application Support', 'Cursor', 'User'),
88
84
  filter: (src) => {
89
- const cursorDir = cursorUserDir;
85
+ const cursorDir = isWin
86
+ ? path.join(appData, 'Cursor', 'User')
87
+ : path.join(home, 'Library', 'Application Support', 'Cursor', 'User');
90
88
  const rel = path.relative(cursorDir, src);
91
89
  if (src === cursorDir) return true;
92
90
  const basename = path.basename(src);
@@ -119,9 +117,13 @@ export const adapters = [
119
117
  {
120
118
  name: 'Windsurf',
121
119
  icon: '🏄',
122
- source: windsurfUserDir,
120
+ source: isWin
121
+ ? path.join(appData, 'Windsurf', 'User')
122
+ : path.join(home, 'Library', 'Application Support', 'Windsurf', 'User'),
123
123
  filter: (src) => {
124
- const windsurfDir = windsurfUserDir;
124
+ const windsurfDir = isWin
125
+ ? path.join(appData, 'Windsurf', 'User')
126
+ : path.join(home, 'Library', 'Application Support', 'Windsurf', 'User');
125
127
  const rel = path.relative(windsurfDir, src);
126
128
  if (src === windsurfDir) return true;
127
129
  const basename = path.basename(src);
@@ -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,25 +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
- if (/\?/.test(text)) return false; // questions aren't decisions
164
- if (/^(it|this|that|these|those|we|i|they|you|some|there|here|just|back|now|also)\b/i.test(text)) return false; // fragment
165
- const words = text.split(/\s+/).length;
166
- if (words < 3) return false; // less than 3 words isn't a decision
167
- return true;
168
- };
169
- for (const d of parsed.decisions.slice(0, 10)) {
214
+ for (const d of qualityDecisions.slice(0, 10)) {
170
215
  const text = String(d.value || '').trim();
171
- if (!isQuality(text)) continue;
172
216
  if (existingTexts.has(text.toLowerCase())) continue;
173
217
  await addNote(text, { why: d.context ? `auto-captured: ${d.context.slice(0, 80)}` : undefined });
174
218
  }
@@ -227,11 +271,38 @@ export async function pushCommand(options = {}) {
227
271
  // Workspace scan is best-effort
228
272
  }
229
273
 
230
- // 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.
231
292
  let sessionIncluded = false;
232
293
  try {
233
294
  if (await fs.pathExists(sessionPaths.session)) {
234
- 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));
235
306
  sessionIncluded = true;
236
307
  }
237
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);
@@ -9,6 +9,7 @@
9
9
 
10
10
  import fs from 'fs-extra';
11
11
  import path from 'path';
12
+ import { appendEvent } from '../events/log.js';
12
13
 
13
14
  export const DEFAULT_BUDGET = 180; // Claude loads ~200 lines of MEMORY.md; leave headroom.
14
15
 
@@ -57,6 +58,15 @@ function inlineWeight(section) {
57
58
 
58
59
  const PROTECTED = (header) => /critical behavior rules/i.test(header) || header === '(preamble)';
59
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
+
60
70
  async function atomicWrite(filePath, content) {
61
71
  const tmp = `${filePath}.tmp-${process.pid}`;
62
72
  await fs.writeFile(tmp, content);
@@ -123,10 +133,20 @@ export async function tidyIndex(memoryDir, { budgetLines = DEFAULT_BUDGET, dryRu
123
133
  else out.push(...sections[i].lines);
124
134
  }
125
135
 
136
+ if (!out.some(l => l.includes('memoir:schemaVersion'))) {
137
+ out.push(MEMORY_SCHEMA_MARKER);
138
+ }
139
+
126
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`;
127
141
  const base = priorArchive || fm;
128
142
  if (toAppend) await atomicWrite(archivePath, base.trimEnd() + '\n\n' + toAppend.trimEnd() + '\n');
129
143
  await atomicWrite(mdPath, out.join('\n'));
130
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
+
131
151
  return { overBudget: true, lineCount, newLineCount: out.length, budgetLines, archived, archiveFile };
132
152
  }
@@ -27,7 +27,11 @@ function searchDecisions(decisions, query) {
27
27
 
28
28
  export async function whyCommand(query) {
29
29
  const state = await readSession();
30
- 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);
31
35
  const matches = searchDecisions(decisions, query);
32
36
 
33
37
  if (matches.length === 0) {
@@ -54,7 +58,10 @@ export async function whyCommand(query) {
54
58
  console.log('\n' + lines.join('\n'));
55
59
  }
56
60
 
57
- // 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.
58
64
  export function findDecisions(state, query) {
59
- return searchDecisions(state.current?.decisions || [], query);
65
+ const decisions = (state.current?.decisions || []).filter(d => !d?.hidden);
66
+ return searchDecisions(decisions, query);
60
67
  }
@@ -161,6 +161,48 @@ function looksLikeFragment(v) {
161
161
  return false;
162
162
  }
163
163
 
164
+ // Quality gate for auto-extracted decisions before they're written to EITHER
165
+ // persistence sink (session-decisions.md via persistDecisions, or session.json
166
+ // via addNote — see push.js, which now runs this once over parsed.decisions
167
+ // before both call sites). Auto-extraction is regex-based and occasionally
168
+ // produces prose fragments, markdown-table cells, or truncated pasted-spec
169
+ // snippets — this rejects the shapes that look like junk rather than a real
170
+ // decision.
171
+ //
172
+ // 2026-07: two real junk decisions made it into a live session.json because
173
+ // the user-note regex (see extractDecisions below) matched mid-paragraph
174
+ // inside long pasted spec/prompt text, and one of them was a hard 150-char
175
+ // truncation with an unbalanced closing paren. The regex is now anchored to
176
+ // message/line start (see below), which independently prevents both from
177
+ // matching at all — these two extra checks are defense-in-depth for the
178
+ // other, unanchored pattern branches (rename/tech/design/stack) that can
179
+ // still match mid-message.
180
+ export function isQuality(text) {
181
+ if (!text) return false;
182
+ if (text.length < 15) return false; // too short to be a real decision
183
+ if (text.length > 200) return false; // probably a snippet, not a decision
184
+ if (/\|/.test(text)) return false; // markdown table fragment
185
+ if (/[_*`]{3,}/.test(text)) return false; // markdown formatting leaked in
186
+ if (!/[a-zA-Z]/.test(text)) return false; // no actual words
187
+ if (looksLikeFragment(text)) return false; // question, or pronoun/filler-start fragment
188
+ const words = text.split(/\s+/).length;
189
+ if (words < 3) return false; // less than 3 words isn't a decision
190
+
191
+ // Unbalanced parens/brackets — a hallmark of a regex capture that got cut
192
+ // off mid-parenthetical (real junk: "...only gain is Y)" with no opener,
193
+ // because the opening "(" was in the text BEFORE the capture started).
194
+ const opens = (text.match(/[(\[]/g) || []).length;
195
+ const closes = (text.match(/[)\]]/g) || []).length;
196
+ if (opens !== closes) return false;
197
+
198
+ // Suspiciously long AND doesn't end in sentence-ending punctuation or a
199
+ // closing quote — another truncation signature (a capture cut off mid-word
200
+ // or mid-sentence by a regex length cap rather than ending naturally).
201
+ if (text.length >= 140 && !/[.!?"')\]]$/.test(text)) return false;
202
+
203
+ return true;
204
+ }
205
+
164
206
  /**
165
207
  * Extract durable decisions from session conversation.
166
208
  * These are things like renames, tech choices, preferences — stuff that should persist.
@@ -201,12 +243,37 @@ function extractDecisions(userMessages, assistantTexts) {
201
243
  }
202
244
  }
203
245
 
204
- // Look for explicit "remember this" instructions from the user
246
+ // Look for explicit "remember this" instructions from the user.
247
+ //
248
+ // Anchored to message/line start ((?:^|\n) immediately before optional
249
+ // indentation and an optional "please") — unlike the pattern branches
250
+ // above, this used to match ANYWHERE in the message, which meant a phrase
251
+ // like "note that" or "keep in mind that" appearing mid-sentence inside a
252
+ // long pasted spec/prompt got misread as an explicit remember-instruction.
253
+ // Requiring it to start the message (or a line within it) means only a
254
+ // genuine top-of-message instruction matches, not incidental prose deep in
255
+ // pasted content.
256
+ //
257
+ // Only scanned within the first ~500 chars of the message: a short
258
+ // "Remember that X." followed by a long paste in the SAME turn must still
259
+ // be captured (the instruction is still at message start), but a trigger
260
+ // phrase that only occurs later/mid-document in a long paste is excluded
261
+ // — it was never an instruction to begin with.
262
+ const USER_NOTE_RE = /(?:^|\n)[ \t]*(?:please\s+)?(?:remember (?:that|this)|note that|keep in mind that|from now on)[:\s]+(.{10,150})/i;
205
263
  for (const msg of userMessages) {
206
- // Only match when user is clearly asking to remember something
207
- const rememberMatch = msg.match(/(?:remember (?:that|this)|note that|keep in mind that|from now on)[:\s]+(.{10,150})/i);
264
+ const scope = msg.slice(0, 500);
265
+ const rememberMatch = scope.match(USER_NOTE_RE);
208
266
  if (rememberMatch) {
209
- decisions.push({ type: 'user-note', value: rememberMatch[1].trim(), context: msg.slice(0, 120) });
267
+ const capturedRaw = rememberMatch[1];
268
+ // The capture group is capped at 150 chars. Hitting that cap exactly is
269
+ // a truncation signature — real junk in the wild was a parenthetical
270
+ // cut off mid-thought with an unbalanced closing paren. Reject rather
271
+ // than keep a truncated tail.
272
+ const hitCap = capturedRaw.length === 150;
273
+ const value = capturedRaw.trim();
274
+ if (!hitCap && !looksLikeFragment(value)) {
275
+ decisions.push({ type: 'user-note', value, context: msg.slice(0, 120) });
276
+ }
210
277
  }
211
278
  }
212
279
 
@@ -231,6 +298,18 @@ export function resolveHomeMemoryDir(claudeSource) {
231
298
  return path.join(projectsDir, homeKey, 'memory');
232
299
  }
233
300
 
301
+ // Atomic write (sync): write to a pid-scoped tmp file, then rename over the
302
+ // target. Matches the tmp-then-rename idiom used elsewhere in this codebase
303
+ // (state.js's writeSession, inject.js's injectInto) — prevents a torn/partial
304
+ // file if the process crashes mid-write. persistDecisions stays synchronous
305
+ // (its one caller in push.js doesn't await it), so this uses the sync
306
+ // fs-extra APIs rather than switching the whole call chain to async.
307
+ function writeFileAtomicSync(targetPath, content) {
308
+ const tmp = `${targetPath}.tmp-${process.pid}`;
309
+ fs.writeFileSync(tmp, content);
310
+ fs.moveSync(tmp, targetPath, { overwrite: true });
311
+ }
312
+
234
313
  /**
235
314
  * Write extracted decisions to Claude's persistent memory.
236
315
  * This ensures decisions survive across sessions and machines.
@@ -277,10 +356,10 @@ type: project
277
356
 
278
357
  # Decisions from coding sessions
279
358
  ${section}`;
280
- fs.writeFileSync(decisionsFile, content);
359
+ writeFileAtomicSync(decisionsFile, content);
281
360
  } else {
282
361
  // Append to existing
283
- fs.writeFileSync(decisionsFile, existing.trimEnd() + '\n' + section);
362
+ writeFileAtomicSync(decisionsFile, existing.trimEnd() + '\n' + section);
284
363
  }
285
364
 
286
365
  // Ensure MEMORY.md references the decisions file
@@ -288,7 +367,7 @@ ${section}`;
288
367
  const memoryMd = fs.readFileSync(memoryMdPath, 'utf8');
289
368
  if (!memoryMd.includes('session-decisions.md')) {
290
369
  const addition = `\n- [Session Decisions](session-decisions.md) — project renames, tech choices, architecture decisions from coding sessions\n`;
291
- fs.writeFileSync(memoryMdPath, memoryMd.trimEnd() + addition);
370
+ writeFileAtomicSync(memoryMdPath, memoryMd.trimEnd() + addition);
292
371
  }
293
372
  }
294
373
 
@@ -0,0 +1,113 @@
1
+ // Additive, size-bounded, crash-safe JSONL event log.
2
+ //
3
+ // Target: ~/.config/memoir/events.jsonl — one JSON object per line:
4
+ // { ts, type, machine_id, ...minimal metadata }
5
+ //
6
+ // PRIVACY: this must never become a second, unfiltered copy of sensitive
7
+ // user data. NEVER log raw decision/note/goal TEXT content — only counts,
8
+ // ids, booleans, and short enum-like type strings. Every call site in this
9
+ // codebase that calls appendEvent() is expected to honor that; review any
10
+ // new call site against it.
11
+ //
12
+ // CRASH-SAFE: pure fs.appendFileSync (O_APPEND) for the actual write —
13
+ // never a read-modify-write on this file, so a crash mid-write can at worst
14
+ // leave a truncated LAST line, never corrupt earlier ones.
15
+ //
16
+ // SIZE-BOUNDED: rotates at MAX_BYTES — events.jsonl -> .1 -> .2, oldest
17
+ // generation beyond MAX_ROTATIONS is deleted.
18
+ //
19
+ // LOCKED ROTATE-THEN-APPEND: the size-check-and-maybe-rotate is itself a
20
+ // check-then-act sequence that would race under concurrent processes
21
+ // exactly like the session.json bug Commit 4 fixed (two processes both see
22
+ // "under the cap," both append, one rotates mid-write, etc.). Rather than
23
+ // threading through whichever *other* lock happens to be held at each of
24
+ // the 6+ call sites (some of which, like sync_pushed/sync_failed, have no
25
+ // adjacent lock at all), this uses ONE small dedicated lock
26
+ // (events.jsonl.lock) around every rotate-then-append, uniformly. Simpler
27
+ // and always-safe, at the cost of a little lock contention on an
28
+ // infrequent, cheap operation — a deliberate simplification over coupling
29
+ // to each caller's own lock.
30
+ //
31
+ // NEVER BREAKS THE CALLER: appendEvent() catches everything internally and
32
+ // never throws. The primary operation it's logging (writeSession,
33
+ // injectInto, push, etc.) must always succeed or fail on its own merits,
34
+ // never because event logging failed.
35
+
36
+ import fs from 'fs-extra';
37
+ import path from 'path';
38
+ import os from 'os';
39
+ import { getMachineId } from '../session/state.js';
40
+ import { withSessionLock } from '../session/lock.js';
41
+
42
+ const CONFIG_DIR = process.platform === 'win32'
43
+ ? path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'memoir')
44
+ : path.join(os.homedir(), '.config', 'memoir');
45
+ const EVENTS_PATH = path.join(CONFIG_DIR, 'events.jsonl');
46
+ const EVENTS_LOCK_PATH = path.join(CONFIG_DIR, 'events.jsonl.lock');
47
+
48
+ const MAX_BYTES = 5 * 1024 * 1024; // 5MB
49
+ const MAX_ROTATIONS = 2; // keep events.jsonl.1 and .2; older generations are dropped
50
+
51
+ let cachedMachineId = null;
52
+ async function machineId() {
53
+ if (cachedMachineId) return cachedMachineId;
54
+ try {
55
+ const { id } = await getMachineId();
56
+ cachedMachineId = id;
57
+ } catch {
58
+ cachedMachineId = 'unknown';
59
+ }
60
+ return cachedMachineId;
61
+ }
62
+
63
+ // Rotate events.jsonl -> .1 -> .2, drop anything beyond MAX_ROTATIONS. Must
64
+ // only be called from within the events lock (see appendEvent) — this
65
+ // function itself does no locking.
66
+ function rotateIfNeeded() {
67
+ try {
68
+ if (!fs.existsSync(EVENTS_PATH)) return;
69
+ const stat = fs.statSync(EVENTS_PATH);
70
+ if (stat.size < MAX_BYTES) return;
71
+
72
+ // Shift existing generations up (.1 -> .2 -> dropped), oldest first.
73
+ for (let i = MAX_ROTATIONS; i >= 1; i--) {
74
+ const src = `${EVENTS_PATH}.${i}`;
75
+ if (!fs.existsSync(src)) continue;
76
+ if (i === MAX_ROTATIONS) {
77
+ fs.removeSync(src); // oldest generation, drop it
78
+ } else {
79
+ fs.moveSync(src, `${EVENTS_PATH}.${i + 1}`, { overwrite: true });
80
+ }
81
+ }
82
+ fs.moveSync(EVENTS_PATH, `${EVENTS_PATH}.1`, { overwrite: true });
83
+ } catch {
84
+ // Rotation failure must never block an append.
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Append one JSON event line. See file header for the privacy contract
90
+ * (counts/ids/booleans/enum-strings only, never raw content) and the
91
+ * crash-safety / size-bound / locking guarantees.
92
+ *
93
+ * Always safe to call and await — never throws, never rejects.
94
+ */
95
+ export async function appendEvent(type, payload = {}) {
96
+ try {
97
+ await fs.ensureDir(CONFIG_DIR);
98
+ const id = await machineId();
99
+ const line = JSON.stringify({ ts: new Date().toISOString(), type, machine_id: id, ...payload }) + '\n';
100
+
101
+ await withSessionLock(EVENTS_LOCK_PATH, async () => {
102
+ rotateIfNeeded();
103
+ fs.appendFileSync(EVENTS_PATH, line);
104
+ });
105
+ } catch {
106
+ // Never break the caller.
107
+ }
108
+ }
109
+
110
+ export const paths = {
111
+ events: EVENTS_PATH,
112
+ eventsLock: EVENTS_LOCK_PATH,
113
+ };