memoir-cli 3.11.2 → 3.11.3

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.11.2",
3
+ "version": "3.11.3",
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",
@@ -211,7 +211,17 @@ export async function pushCommand(options = {}) {
211
211
  // received the raw unfiltered list while only the session.json sink
212
212
  // below filtered, so junk could reach session-decisions.md even after
213
213
  // being rejected from session.json. Both sinks now agree on what's junk.
214
- const qualityDecisions = parsed.decisions.filter(d => isQuality(String(d.value || '').trim()));
214
+ // Gate on the string each sink actually PERSISTS, not on d.value.
215
+ // For rename/tech captures d.value is a single whitespace-free
216
+ // token, so isQuality's words>=3 rule rejected 100% of them —
217
+ // two of the three advertised capture categories were dead code
218
+ // while persistDecisions would have written the clean d.context.
219
+ const decisionText = (d) => {
220
+ const v = String(d.value || '').trim();
221
+ const c = String(d.context || '').trim();
222
+ return (d.type === 'rename' || d.type === 'tech') && c ? c : v;
223
+ };
224
+ const qualityDecisions = parsed.decisions.filter(d => isQuality(decisionText(d)));
215
225
 
216
226
  // Persist decisions to Claude's memory so they survive across sessions
217
227
  let decisionCount = 0;
@@ -230,7 +240,7 @@ export async function pushCommand(options = {}) {
230
240
  current.current.decisions.map(d => (d.text || '').trim().toLowerCase())
231
241
  );
232
242
  for (const d of qualityDecisions.slice(0, 10)) {
233
- const text = String(d.value || '').trim();
243
+ const text = decisionText(d);
234
244
  if (existingTexts.has(text.toLowerCase())) continue;
235
245
  await addNote(text, { why: d.context ? `auto-captured: ${d.context.slice(0, 80)}` : undefined });
236
246
  }
@@ -321,15 +331,16 @@ export async function pushCommand(options = {}) {
321
331
  preserveRemoteSession = true;
322
332
  try { appendEvent('sync_degraded', { reason: 'remote_session_unreadable' }); } catch {}
323
333
  } else {
324
- const local = await readSession();
325
- const merged = remote ? mergeSessions(local, remote) : local;
326
- if (remote) {
327
- // Persist the merge locally too, inside the same lock every other
328
- // session.json read-modify-write cycle uses.
329
- await withSessionLock(sessionPaths.sessionLock, async () => {
330
- await writeSession(merged);
331
- });
332
- }
334
+ // Read AND merge AND write inside one lock. Reading outside it and
335
+ // locking only the write is a check-then-act: a concurrent MCP
336
+ // memoir_note in that window is silently dropped. This is the most
337
+ // reachable instance of that bug it sits on the autopush path.
338
+ let merged;
339
+ await withSessionLock(sessionPaths.sessionLock, async () => {
340
+ const local = await readSession();
341
+ merged = remote ? mergeSessions(local, remote) : local;
342
+ if (remote) await writeSession(merged);
343
+ });
333
344
  await fs.writeFile(path.join(stagingDir, 'session.json'), JSON.stringify(merged, null, 2));
334
345
  sessionIncluded = true;
335
346
  }
@@ -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 { withSessionLock } from '../session/lock.js';
18
19
  import { migrateSessionData } from '../session/migrations.js';
19
20
  import { renderSession } from '../session/render.js';
20
21
  import { injectInto, detectAvailableTargets } from '../session/inject.js';
@@ -136,10 +137,17 @@ export async function restoreCommand(options = {}) {
136
137
  // ever touches it. Symmetric with the push-side fix in push.js.
137
138
  const rawRemote = JSON.parse(await fs.readFile(remoteSessionPath, 'utf8'));
138
139
  const { state: remote } = migrateSessionData(rawRemote);
139
- const local = await readSession();
140
- const beforeMachines = Object.keys(local.machines || {}).length;
141
- const merged = mergeSessions(local, remote);
142
- await writeSession(merged);
140
+ // Read+merge+write inside ONE lock, like every state.js mutator.
141
+ // Reading outside the lock and locking only the write is a
142
+ // check-then-act: a concurrent MCP memoir_note landing in the window
143
+ // is silently discarded by our merge of the stale copy.
144
+ let merged, beforeMachines;
145
+ await withSessionLock(sessionPaths.sessionLock, async () => {
146
+ const local = await readSession();
147
+ beforeMachines = Object.keys(local.machines || {}).length;
148
+ merged = mergeSessions(local, remote);
149
+ await writeSession(merged);
150
+ });
143
151
  // Re-render + inject into every detected tool so the pinned block
144
152
  // reflects the merged state right away across Claude/Cursor/Windsurf/Gemini
145
153
  try {
@@ -24,6 +24,7 @@ import {
24
24
  getMachineId,
25
25
  paths,
26
26
  } from '../session/state.js';
27
+ import { withSessionLock } from '../session/lock.js';
27
28
  import { renderSession } from '../session/render.js';
28
29
  import { injectInto, detectAvailableTargets } from '../session/inject.js';
29
30
 
@@ -180,9 +181,13 @@ export async function sessionShowCommand() {
180
181
  }
181
182
 
182
183
  export async function sessionClearCommand() {
183
- const state = await readSession();
184
- state.current = { goals: [], next_actions: [], open_questions: [], decisions: [] };
185
- await writeSession(state);
184
+ // Took no lock at all — a concurrent MCP write between the read and the
185
+ // write was silently lost, and worse, could resurrect what was cleared.
186
+ await withSessionLock(paths.sessionLock, async () => {
187
+ const state = await readSession();
188
+ state.current = { goals: [], next_actions: [], open_questions: [], decisions: [] };
189
+ await writeSession(state);
190
+ });
186
191
  await refreshPinned();
187
192
  console.log('\n' + chalk.green(' ✓ Current session cleared.') + chalk.gray(' History retained.\n'));
188
193
  }
@@ -225,7 +225,11 @@ function extractDecisions(userMessages, assistantTexts) {
225
225
  { regex: /(?:switch|migrate|move)\s+(?:from\s+\S+\s+)?to\s+([A-Z][a-zA-Z0-9_./-]+)/gi, type: 'tech' },
226
226
  // Architecture / design — require an explicit decision verb and a capitalized
227
227
  // target. Bare "pick/choose" caught conversational fragments as decisions.
228
- { regex: /(?:decided|settled|going|chose|chosen)\s+(?:to\s+(?:go\s+with|use)|with|on)\s+([A-Z][\w .\/+-]{3,50}?)(?:\.|$|,|\n)/g, type: 'design' },
228
+ // 'going' dropped from the bare alternation: "going on Monday to the
229
+ // office" minted a decision (live proof: "going on PostDash" in the real
230
+ // store). "going to go with/use" is still covered by the to-clause.
231
+ { regex: /(?:decided|settled|chose|chosen)\s+(?:to\s+(?:go\s+with|use)|with|on)\s+([A-Z][\w .\/+-]{3,50}?)(?:\.|$|,|\n)/g, type: 'design' },
232
+ { regex: /going\s+to\s+(?:go\s+with|use)\s+([A-Z][\w .\/+-]{3,50}?)(?:\.|$|,|\n)/g, type: 'design' },
229
233
  // Stack choices — require a capitalized, tech-looking value, not a prose
230
234
  // fragment ("backend is just throwing it away" used to leak through).
231
235
  { regex: /(?:stack|framework|database|backend|frontend|hosting|infra)\s+(?:is|will be|should be)\s+([A-Z][\w .\/+-]{2,40}?)(?:\.|$|,|\n)/g, type: 'stack' },
@@ -23,6 +23,27 @@ export async function syncToLocal(config, stagingDir, spinner) {
23
23
  await fs.ensureDir(resolvedDest);
24
24
 
25
25
  await fs.copy(stagingDir, resolvedDest);
26
+
27
+ // Prune orphaned encrypted blobs. Each encrypted push derives a fresh salt
28
+ // and therefore fresh HMAC filenames, so without this every push leaves the
29
+ // previous push's data/*.enc behind forever and localPath grows without
30
+ // bound. Only runs for a full encrypted sync (manifest.enc present in what
31
+ // we just wrote) — `memoir snapshot` also calls syncToLocal with a staging
32
+ // dir of a single handoff file, and blanket-emptying the destination there
33
+ // would delete the user's backup.
34
+ try {
35
+ const stagedManifest = path.join(stagingDir, 'manifest.enc');
36
+ const destData = path.join(resolvedDest, 'data');
37
+ if (await fs.pathExists(stagedManifest) && await fs.pathExists(destData)) {
38
+ const keep = new Set(await fs.readdir(path.join(stagingDir, 'data')).catch(() => []));
39
+ for (const f of await fs.readdir(destData)) {
40
+ if (!keep.has(f)) await fs.remove(path.join(destData, f)).catch(() => {});
41
+ }
42
+ }
43
+ } catch {
44
+ // Pruning is housekeeping — never fail a completed backup over it.
45
+ }
46
+
26
47
  spinner.succeed(chalk.green('Sync complete! ') + chalk.gray(`(Saved to ${resolvedDest})`));
27
48
  await appendEvent('sync_pushed', { provider: 'local' });
28
49
  }
@@ -31,7 +31,7 @@ const SECRET_PATTERNS = [
31
31
 
32
32
  // Generic secrets in env/config patterns
33
33
  { regex: /(?:^|[\s;])(?:export\s+)?(?:API_KEY|SECRET_KEY|AUTH_TOKEN|ACCESS_TOKEN|PRIVATE_KEY|DB_PASSWORD|DATABASE_URL|JWT_SECRET|ENCRYPTION_KEY|MASTER_KEY)\s*=\s*["']?([^\s'"]{8,})/gmi, label: 'Environment variable secret' },
34
- { regex: /(?:password|passwd|pwd)\s*[:=]\s*["']?([^\s'"]{6,})/gi, label: 'Password' },
34
+ { regex: /(?:password|passwd|pwd)\s*[:=]\s*["']?([^\s'"]{6,})/gi, label: 'Password', minLength: 6 },
35
35
 
36
36
  // Private keys
37
37
  { regex: /(-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----)/g, label: 'Private key' },
@@ -55,10 +55,18 @@ export function scanForSecrets(text) {
55
55
  let match;
56
56
  while ((match = pattern.regex.exec(text)) !== null) {
57
57
  const secret = match[1] || match[0];
58
- // Skip very short matches (likely false positives)
59
- if (secret.length < 8) continue;
58
+ // Per-pattern floor. A global 8 threw away 6-7 char matches that the
59
+ // Password pattern ({6,}) was written to catch: `password: s3cr3t`
60
+ // survived verbatim into the handoff and the backup while the scan
61
+ // reported "no secrets detected" — a silent miss is worse than a
62
+ // false positive in a tool that promises redaction.
63
+ if (secret.length < (pattern.minLength ?? 8)) continue;
60
64
 
61
- const redacted = secret.slice(0, 4) + '****' + secret.slice(-4);
65
+ // For short secrets, slice(0,4)+slice(-4) can reproduce the whole
66
+ // thing (a 6-char secret would show 4+4 of 6 characters).
67
+ const redacted = secret.length >= 12
68
+ ? secret.slice(0, 4) + '****' + secret.slice(-4)
69
+ : secret.slice(0, 2) + '****';
62
70
  findings.push({
63
71
  label: pattern.label,
64
72
  match: secret,
@@ -68,7 +68,29 @@ export async function withSessionLock(lockPath, fn) {
68
68
  try {
69
69
  const stat = fs.statSync(lockPath);
70
70
  if (Date.now() - stat.mtimeMs > STALE_MS) {
71
- try { fs.unlinkSync(lockPath); } catch {}
71
+ // Steal by rename, not unlink: two processes racing an unlink can
72
+ // both "win" and both proceed. rename() is atomic, so exactly one
73
+ // wins and the loser simply retries.
74
+ let stolen = false;
75
+ try {
76
+ const graveyard = `${lockPath}.stale-${process.pid}-${Date.now()}`;
77
+ fs.renameSync(lockPath, graveyard);
78
+ stolen = true;
79
+ // The rename is only there to make the steal atomic; the file
80
+ // itself is debris. Remove it immediately — best-effort, and
81
+ // harmless to leave behind if this fails.
82
+ try { fs.unlinkSync(graveyard); } catch {}
83
+ } catch {}
84
+ if (stolen) {
85
+ continue; // we removed it; retry the acquire immediately
86
+ }
87
+ // Could not remove it (read-only dir, permissions). Fall through
88
+ // to the deadline + backoff below instead of spinning forever.
89
+ if (Date.now() - start > MAX_WAIT_MS) {
90
+ fd = null;
91
+ break;
92
+ }
93
+ await sleep(RETRY_DELAY_MS);
72
94
  continue;
73
95
  }
74
96
  } catch {
@@ -95,8 +117,20 @@ export async function withSessionLock(lockPath, fn) {
95
117
  return await fn();
96
118
  } finally {
97
119
  if (fd !== null) {
120
+ // Only unlink if the file at lockPath is still OURS. If our lock was
121
+ // stolen as stale and another process now holds a NEW file at the same
122
+ // path, unlinking by path would delete the current holder's lock and
123
+ // let a third process in. Compare inode via the fd we still hold.
124
+ let ours = false;
125
+ try {
126
+ const byFd = fs.fstatSync(fd);
127
+ const byPath = fs.statSync(lockPath);
128
+ ours = byFd.ino === byPath.ino && byFd.dev === byPath.dev;
129
+ } catch {
130
+ ours = false; // path gone or unreadable — nothing safe to remove
131
+ }
98
132
  try { fs.closeSync(fd); } catch {}
99
- try { fs.unlinkSync(lockPath); } catch {}
133
+ if (ours) { try { fs.unlinkSync(lockPath); } catch {} }
100
134
  }
101
135
  }
102
136
  }
@@ -389,9 +389,16 @@ function unionByText(a = [], b = [], dateField, cap) {
389
389
  }
390
390
  }
391
391
 
392
- return Array.from(byText.values())
393
- .sort((x, y) => new Date(y[dateField] || 0) - new Date(x[dateField] || 0))
394
- .slice(0, cap);
392
+ // Partition before capping. Tombstones keep their original (recent) date,
393
+ // so a plain sort+slice let them win cap slots and silently evict real
394
+ // entries on merge. They must SURVIVE the merge (removing them
395
+ // reintroduces the resurrection the sticky-tombstone rule fixed) but must
396
+ // not count against the visible budget.
397
+ const all = Array.from(byText.values())
398
+ .sort((x, y) => new Date(y[dateField] || 0) - new Date(x[dateField] || 0));
399
+ const visible = all.filter((i) => !i.hidden).slice(0, cap);
400
+ const tombstones = all.filter((i) => i.hidden).slice(0, cap);
401
+ return [...visible, ...tombstones];
395
402
  }
396
403
 
397
404
  function unionTombstones(a = [], b = []) {