memoir-cli 3.11.1 → 3.11.2

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
@@ -15,15 +15,17 @@
15
15
  npx memoir-cli
16
16
  ```
17
17
 
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.
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. Cloud sync is end-to-end encrypted with a key only you hold memoir's servers can't read what you sync.
19
19
 
20
20
  ---
21
21
 
22
22
  ## What it does
23
23
 
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.
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, one vendor's format.** Switch from Cursor to Claude Code, or open a different laptop, and your AI is a stranger again.
25
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.
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 — E2E-encrypted in the cloud, plain readable markdown on your disk.** Your AI searches, saves, and recalls context automatically, everywhere you work.
27
+
28
+ It's built on an **open, published format** — [the memoir format, v0.1](docs/SPEC.md) — so your AI's accumulated context is never trapped in this tool either. Six entry types, normative merge semantics, JSON Schemas, and a validator (`npx memoir-cli validate`). Any tool can implement it; [critique welcome](https://github.com/camgitt/memoir/issues).
27
29
 
28
30
  ```
29
31
  you: how does auth work in this project?
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memoir-cli",
3
- "version": "3.11.1",
3
+ "version": "3.11.2",
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",
@@ -10,6 +10,7 @@ import { getConfig, autoSetup } from '../config.js';
10
10
  import { extractMemories, adapters } from '../adapters/index.js';
11
11
  import { syncToLocal, syncToGit } from '../providers/index.js';
12
12
  import inquirer from 'inquirer';
13
+ import { appendEvent } from '../events/log.js';
13
14
  import { findClaudeSessions, parseSession, generateContextHandoff, shouldIgnoreProject, persistDecisions, isQuality } from '../context/capture.js';
14
15
  import { scanForSecrets, printSecurityReport } from '../security/scanner.js';
15
16
  import { encryptDirectory, createVerifyToken } from '../security/encryption.js';
@@ -25,42 +26,59 @@ import { injectInto, detectAvailableTargets } from '../session/inject.js';
25
26
  // Best-effort fetch of the CURRENT remote session.json, so push.js can merge
26
27
  // before overwrite instead of blindly clobbering it (see below). Returns the
27
28
  // 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).
29
+ // Tri-state, because the difference is destructive: 'none' means nothing is
30
+ // there (safe to write ours), 'ok' carries the remote session for merging,
31
+ // and 'unreadable' means A REMOTE EXISTS BUT WE CANNOT READ IT — encrypted,
32
+ // slow clone, corrupt JSON. On 'unreadable' the caller MUST NOT stage
33
+ // session.json at all, so the remote copy survives the mirror sweep.
34
+ // The old boolean version returned null for 'unreadable', which collapsed
35
+ // to merged = local and silently clobbered the other machine's state —
36
+ // worst on encrypted remotes, where the "protection" was a complete no-op.
32
37
  async function fetchRemoteSessionBestEffort(config) {
33
38
  try {
34
39
  if (config.provider === 'local' || config.provider?.includes?.('local')) {
35
40
  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
41
+ if (!resolvedDest) return { status: 'none', session: null };
42
+ if (await fs.pathExists(path.join(resolvedDest, 'manifest.enc'))) return { status: 'unreadable', session: null }; // encrypted
38
43
  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;
44
+ if (!(await fs.pathExists(remotePath))) return { status: 'none', session: null };
45
+ try {
46
+ const raw = JSON.parse(await fs.readFile(remotePath, 'utf8'));
47
+ const { state } = migrateSessionData(raw);
48
+ return { status: 'ok', session: state };
49
+ } catch {
50
+ return { status: 'unreadable', session: null }; // exists but corrupt
51
+ }
43
52
  }
44
53
 
45
54
  if (config.provider === 'git' || config.provider?.includes?.('git')) {
46
55
  const repoUrl = config.gitRepo;
47
- if (!repoUrl) return null;
56
+ if (!repoUrl) return { status: 'none', session: null };
48
57
  const peekDir = path.join(os.tmpdir(), `memoir-push-peek-${Date.now()}`);
49
58
  await fs.ensureDir(peekDir);
50
59
  try {
51
60
  try {
52
- execFileSync('git', ['clone', '--depth', '1', repoUrl, '.'], { cwd: peekDir, stdio: 'ignore', timeout: 30000 });
61
+ // Same budget as the real sync clone the old 30s peek against a
62
+ // 60s sync meant a 35-second clone failed the peek but succeeded
63
+ // the mirror, deterministically wiping the remote session.
64
+ execFileSync('git', ['clone', '--depth', '1', repoUrl, '.'], { cwd: peekDir, stdio: 'ignore', timeout: 120000 });
53
65
  } 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;
66
+ // Unreachable or first push. If the LATER sync clone succeeds
67
+ // where this one failed, treating it as 'none' would clobber —
68
+ // but with equal timeouts that window is a genuine remote flap,
69
+ // and 'unreadable' here would wedge first-time pushes forever.
70
+ return { status: 'none', session: null };
57
71
  }
58
- if (await fs.pathExists(path.join(peekDir, 'manifest.enc'))) return null; // encrypted
72
+ if (await fs.pathExists(path.join(peekDir, 'manifest.enc'))) return { status: 'unreadable', session: null }; // encrypted
59
73
  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;
74
+ if (!(await fs.pathExists(remotePath))) return { status: 'none', session: null };
75
+ try {
76
+ const raw = JSON.parse(await fs.readFile(remotePath, 'utf8'));
77
+ const { state } = migrateSessionData(raw);
78
+ return { status: 'ok', session: state };
79
+ } catch {
80
+ return { status: 'unreadable', session: null };
81
+ }
64
82
  } finally {
65
83
  await fs.remove(peekDir).catch(() => {});
66
84
  }
@@ -68,7 +86,7 @@ async function fetchRemoteSessionBestEffort(config) {
68
86
  } catch {
69
87
  // Never let a merge-fetch failure block the push.
70
88
  }
71
- return null;
89
+ return { status: 'none', session: null };
72
90
  }
73
91
 
74
92
  // Recursively scan every staged file (the REAL tool memory/config files about
@@ -290,20 +308,31 @@ export async function pushCommand(options = {}) {
290
308
  // session.json (so this machine also gains whatever the remote had that
291
309
  // it didn't) — symmetric with restore.js instead of a blind overwrite.
292
310
  let sessionIncluded = false;
311
+ let preserveRemoteSession = false;
293
312
  try {
294
313
  if (await fs.pathExists(sessionPaths.session)) {
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
- });
314
+ const { status, session: remote } = await fetchRemoteSessionBestEffort(config);
315
+ if (status === 'unreadable') {
316
+ // A remote session exists and we could not read it (encrypted,
317
+ // slow, corrupt). Staging ours anyway would mirror-overwrite the
318
+ // one copy we couldn't merge the exact clobber this guard
319
+ // exists to prevent. Leave session.json out of the staging dir
320
+ // and tell the sync to leave the remote copy alone.
321
+ preserveRemoteSession = true;
322
+ try { appendEvent('sync_degraded', { reason: 'remote_session_unreadable' }); } catch {}
323
+ } 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
+ }
333
+ await fs.writeFile(path.join(stagingDir, 'session.json'), JSON.stringify(merged, null, 2));
334
+ sessionIncluded = true;
304
335
  }
305
- await fs.writeFile(path.join(stagingDir, 'session.json'), JSON.stringify(merged, null, 2));
306
- sessionIncluded = true;
307
336
  }
308
337
  } catch {
309
338
  // Best-effort — don't fail the push over this
@@ -448,7 +477,7 @@ export async function pushCommand(options = {}) {
448
477
  if (config.provider === 'local' || config.provider.includes('local')) {
449
478
  await syncToLocal(config, uploadDir, spinner);
450
479
  } else if (config.provider === 'git' || config.provider.includes('git')) {
451
- await syncToGit(config, uploadDir, spinner);
480
+ await syncToGit(config, uploadDir, spinner, preserveRemoteSession ? { preserve: ['session.json'] } : {});
452
481
  } else {
453
482
  spinner.fail(chalk.red(`Unknown provider: ${config.provider}`));
454
483
  return;
@@ -101,7 +101,10 @@ function parseLines(lines) {
101
101
  for (const block of obj.message.content) {
102
102
  if (block.type === 'text' && block.text) {
103
103
  // Capture assistant text for decision extraction (limit size)
104
- if (block.text.length < 2000) assistantTexts.push(block.text);
104
+ // Redacted like every other untrusted input (user :95, bash :125,
105
+ // errors :138) — captured decisions flow into session.json, CLAUDE.md
106
+ // and the git backup, none of which get a later secret scan.
107
+ if (block.text.length < 2000) assistantTexts.push(redactSecrets(block.text));
105
108
  continue;
106
109
  }
107
110
  if (block.type !== 'tool_use') continue;
package/src/mcp.js CHANGED
@@ -283,9 +283,23 @@ server.tool(
283
283
  return { content: [{ type: 'text', text: `Project directory not found: ${projectDir}` }] };
284
284
  }
285
285
 
286
- // Default to CLAUDE.md for project-level memories
287
- const targetFile = filename || 'CLAUDE.md';
288
- const targetPath = path.join(projectDir, targetFile);
286
+ // Default to CLAUDE.md for project-level memories.
287
+ // Guards mirror the global branch below and memoir_read above:
288
+ // model-supplied filename must be a bare markdown name — no
289
+ // separators, no traversal — and must resolve inside the project
290
+ // dir. Without this, filename:".zshrc" appends to a shell rc
291
+ // (code execution on next shell) and "package.json" corrupts
292
+ // real files.
293
+ let targetFile = filename || 'CLAUDE.md';
294
+ if (!targetFile.endsWith('.md')) targetFile += '.md';
295
+ if (targetFile.includes('/') || targetFile.includes('\\') || targetFile.includes('..')) {
296
+ return { content: [{ type: 'text', text: `Invalid filename: ${filename} (must be a bare .md name)` }] };
297
+ }
298
+ const projBase = path.resolve(projectDir);
299
+ const targetPath = path.resolve(projBase, targetFile);
300
+ if (!targetPath.startsWith(projBase + path.sep)) {
301
+ return { content: [{ type: 'text', text: `Invalid filename: ${filename}` }] };
302
+ }
289
303
 
290
304
  // Append to existing file or create new
291
305
  if (await fs.pathExists(targetPath)) {
@@ -394,7 +408,14 @@ server.tool(
394
408
  return { content: [{ type: 'text', text: `Unknown tool: ${tool}. Available: ${adapters.map(a => a.name).join(', ')}` }] };
395
409
  }
396
410
 
397
- const fullPath = path.join(adapter.source, filepath);
411
+ // Containment: filepath comes from the model, and the model reads
412
+ // attacker-influenceable text all day. Without this, "../.ssh/id_rsa"
413
+ // resolves outside the adapter dir and the file is returned verbatim.
414
+ const base = path.resolve(adapter.source);
415
+ const fullPath = path.resolve(base, filepath);
416
+ if (fullPath !== base && !fullPath.startsWith(base + path.sep)) {
417
+ return { content: [{ type: 'text', text: `Invalid path: ${filepath} (must stay inside ${adapter.name}'s directory)` }] };
418
+ }
398
419
 
399
420
  if (!(await fs.pathExists(fullPath))) {
400
421
  return { content: [{ type: 'text', text: `File not found: ${filepath} in ${adapter.name}` }] };
@@ -27,7 +27,7 @@ export async function syncToLocal(config, stagingDir, spinner) {
27
27
  await appendEvent('sync_pushed', { provider: 'local' });
28
28
  }
29
29
 
30
- export async function syncToGit(config, stagingDir, spinner) {
30
+ export async function syncToGit(config, stagingDir, spinner, options = {}) {
31
31
  const repoUrl = sanitizeUrl(config.gitRepo);
32
32
  if (!repoUrl) throw new Error('Git repository is not configured.');
33
33
 
@@ -39,9 +39,13 @@ export async function syncToGit(config, stagingDir, spinner) {
39
39
  try {
40
40
  try {
41
41
  execFileSync('git', ['clone', '--depth', '1', repoUrl, '.'], { cwd: gitDir, stdio: 'ignore', timeout: 60000 });
42
+ const preserve = new Set(options.preserve || []);
42
43
  const files = await fs.readdir(gitDir);
43
44
  for (const f of files) {
44
- if (f !== '.git') await fs.remove(path.join(gitDir, f));
45
+ // preserve: files the caller knows exist remotely but could not
46
+ // merge (unreadable session.json) — deleting them here would be
47
+ // the mirror-clobber the push guard just declined to commit.
48
+ if (f !== '.git' && !preserve.has(f)) await fs.remove(path.join(gitDir, f));
45
49
  }
46
50
  } catch {
47
51
  execFileSync('git', ['init'], { cwd: gitDir, stdio: 'ignore' });
@@ -63,7 +67,10 @@ export async function syncToGit(config, stagingDir, spinner) {
63
67
  }
64
68
 
65
69
  spinner.text = `Pushing data to ${chalk.cyan(repoUrl)}...`;
66
- execFileSync('git', ['push', repoUrl, 'main'], { cwd: gitDir, stdio: 'ignore', timeout: 120000 });
70
+ // HEAD:main pushes whatever branch the clone checked out (a master-
71
+ // default remote used to make `push main` fail silently under autopush
72
+ // with a misleading credentials error, while doctor reported green).
73
+ execFileSync('git', ['push', repoUrl, 'HEAD:main'], { cwd: gitDir, stdio: 'ignore', timeout: 120000 });
67
74
 
68
75
  spinner.succeed(chalk.green('Sync complete! ') + chalk.gray('(Uploaded securely to GitHub)'));
69
76
  await appendEvent('sync_pushed', { provider: 'git' });