memoir-cli
Advanced tools
+1
-1
| { | ||
| "name": "memoir-cli", | ||
| "version": "3.11.1", | ||
| "version": "3.11.2", | ||
| "mcpName": "io.github.camgitt/memoir", | ||
@@ -5,0 +5,0 @@ "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.", |
+5
-3
@@ -18,3 +18,3 @@ <div align="center"> | ||
| 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. | ||
| 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. | ||
@@ -25,6 +25,8 @@ --- | ||
| 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. | ||
| 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. | ||
| 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. | ||
| 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. | ||
| 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). | ||
| ``` | ||
@@ -31,0 +33,0 @@ you: how does auth work in this project? |
+62
-33
@@ -13,2 +13,3 @@ import chalk from 'chalk'; | ||
| import inquirer from 'inquirer'; | ||
| import { appendEvent } from '../events/log.js'; | ||
| import { findClaudeSessions, parseSession, generateContextHandoff, shouldIgnoreProject, persistDecisions, isQuality } from '../context/capture.js'; | ||
@@ -29,6 +30,10 @@ import { scanForSecrets, printSecurityReport } from '../security/scanner.js'; | ||
| // remote session state (already migrated to SCHEMA_VERSION) or null if the | ||
| // remote is unreachable, this is the very first push (nothing there yet), or | ||
| // the remote backup is encrypted (best-effort only — we deliberately don't | ||
| // force an extra decrypt passphrase prompt mid-push; falls back to | ||
| // local-only in that case, exactly like an unreachable remote). | ||
| // Tri-state, because the difference is destructive: 'none' means nothing is | ||
| // there (safe to write ours), 'ok' carries the remote session for merging, | ||
| // and 'unreadable' means A REMOTE EXISTS BUT WE CANNOT READ IT — encrypted, | ||
| // slow clone, corrupt JSON. On 'unreadable' the caller MUST NOT stage | ||
| // session.json at all, so the remote copy survives the mirror sweep. | ||
| // The old boolean version returned null for 'unreadable', which collapsed | ||
| // to merged = local and silently clobbered the other machine's state — | ||
| // worst on encrypted remotes, where the "protection" was a complete no-op. | ||
| async function fetchRemoteSessionBestEffort(config) { | ||
@@ -38,9 +43,13 @@ try { | ||
| const resolvedDest = (config.localPath || '').replace(/^~/, os.homedir()); | ||
| if (!resolvedDest) return null; | ||
| if (await fs.pathExists(path.join(resolvedDest, 'manifest.enc'))) return null; // encrypted | ||
| if (!resolvedDest) return { status: 'none', session: null }; | ||
| if (await fs.pathExists(path.join(resolvedDest, 'manifest.enc'))) return { status: 'unreadable', session: null }; // encrypted | ||
| const remotePath = path.join(resolvedDest, 'session.json'); | ||
| if (!(await fs.pathExists(remotePath))) return null; | ||
| const raw = JSON.parse(await fs.readFile(remotePath, 'utf8')); | ||
| const { state } = migrateSessionData(raw); | ||
| return state; | ||
| if (!(await fs.pathExists(remotePath))) return { status: 'none', session: null }; | ||
| try { | ||
| const raw = JSON.parse(await fs.readFile(remotePath, 'utf8')); | ||
| const { state } = migrateSessionData(raw); | ||
| return { status: 'ok', session: state }; | ||
| } catch { | ||
| return { status: 'unreadable', session: null }; // exists but corrupt | ||
| } | ||
| } | ||
@@ -50,3 +59,3 @@ | ||
| const repoUrl = config.gitRepo; | ||
| if (!repoUrl) return null; | ||
| if (!repoUrl) return { status: 'none', session: null }; | ||
| const peekDir = path.join(os.tmpdir(), `memoir-push-peek-${Date.now()}`); | ||
@@ -56,14 +65,23 @@ await fs.ensureDir(peekDir); | ||
| try { | ||
| execFileSync('git', ['clone', '--depth', '1', repoUrl, '.'], { cwd: peekDir, stdio: 'ignore', timeout: 30000 }); | ||
| // Same budget as the real sync clone — the old 30s peek against a | ||
| // 60s sync meant a 35-second clone failed the peek but succeeded | ||
| // the mirror, deterministically wiping the remote session. | ||
| execFileSync('git', ['clone', '--depth', '1', repoUrl, '.'], { cwd: peekDir, stdio: 'ignore', timeout: 120000 }); | ||
| } catch { | ||
| // Unreachable, or this is the very first push (repo doesn't exist | ||
| // yet / is empty) — fall back to local-only. | ||
| return null; | ||
| // Unreachable or first push. If the LATER sync clone succeeds | ||
| // where this one failed, treating it as 'none' would clobber — | ||
| // but with equal timeouts that window is a genuine remote flap, | ||
| // and 'unreadable' here would wedge first-time pushes forever. | ||
| return { status: 'none', session: null }; | ||
| } | ||
| if (await fs.pathExists(path.join(peekDir, 'manifest.enc'))) return null; // encrypted | ||
| if (await fs.pathExists(path.join(peekDir, 'manifest.enc'))) return { status: 'unreadable', session: null }; // encrypted | ||
| const remotePath = path.join(peekDir, 'session.json'); | ||
| if (!(await fs.pathExists(remotePath))) return null; | ||
| const raw = JSON.parse(await fs.readFile(remotePath, 'utf8')); | ||
| const { state } = migrateSessionData(raw); | ||
| return state; | ||
| if (!(await fs.pathExists(remotePath))) return { status: 'none', session: null }; | ||
| try { | ||
| const raw = JSON.parse(await fs.readFile(remotePath, 'utf8')); | ||
| const { state } = migrateSessionData(raw); | ||
| return { status: 'ok', session: state }; | ||
| } catch { | ||
| return { status: 'unreadable', session: null }; | ||
| } | ||
| } finally { | ||
@@ -76,3 +94,3 @@ await fs.remove(peekDir).catch(() => {}); | ||
| } | ||
| return null; | ||
| return { status: 'none', session: null }; | ||
| } | ||
@@ -299,16 +317,27 @@ | ||
| let sessionIncluded = false; | ||
| let preserveRemoteSession = false; | ||
| try { | ||
| if (await fs.pathExists(sessionPaths.session)) { | ||
| const remote = await fetchRemoteSessionBestEffort(config); | ||
| const local = await readSession(); | ||
| const merged = remote ? mergeSessions(local, remote) : local; | ||
| if (remote) { | ||
| // Persist the merge locally too, inside the same lock every other | ||
| // session.json read-modify-write cycle uses. | ||
| await withSessionLock(sessionPaths.sessionLock, async () => { | ||
| await writeSession(merged); | ||
| }); | ||
| const { status, session: remote } = await fetchRemoteSessionBestEffort(config); | ||
| if (status === 'unreadable') { | ||
| // A remote session exists and we could not read it (encrypted, | ||
| // slow, corrupt). Staging ours anyway would mirror-overwrite the | ||
| // one copy we couldn't merge — the exact clobber this guard | ||
| // exists to prevent. Leave session.json out of the staging dir | ||
| // and tell the sync to leave the remote copy alone. | ||
| preserveRemoteSession = true; | ||
| try { appendEvent('sync_degraded', { reason: 'remote_session_unreadable' }); } catch {} | ||
| } else { | ||
| const local = await readSession(); | ||
| const merged = remote ? mergeSessions(local, remote) : local; | ||
| if (remote) { | ||
| // Persist the merge locally too, inside the same lock every other | ||
| // session.json read-modify-write cycle uses. | ||
| await withSessionLock(sessionPaths.sessionLock, async () => { | ||
| await writeSession(merged); | ||
| }); | ||
| } | ||
| await fs.writeFile(path.join(stagingDir, 'session.json'), JSON.stringify(merged, null, 2)); | ||
| sessionIncluded = true; | ||
| } | ||
| await fs.writeFile(path.join(stagingDir, 'session.json'), JSON.stringify(merged, null, 2)); | ||
| sessionIncluded = true; | ||
| } | ||
@@ -458,3 +487,3 @@ } catch { | ||
| } else if (config.provider === 'git' || config.provider.includes('git')) { | ||
| await syncToGit(config, uploadDir, spinner); | ||
| await syncToGit(config, uploadDir, spinner, preserveRemoteSession ? { preserve: ['session.json'] } : {}); | ||
| } else { | ||
@@ -461,0 +490,0 @@ spinner.fail(chalk.red(`Unknown provider: ${config.provider}`)); |
@@ -104,3 +104,6 @@ import fs from 'fs-extra'; | ||
| // Capture assistant text for decision extraction (limit size) | ||
| if (block.text.length < 2000) assistantTexts.push(block.text); | ||
| // Redacted like every other untrusted input (user :95, bash :125, | ||
| // errors :138) — captured decisions flow into session.json, CLAUDE.md | ||
| // and the git backup, none of which get a later secret scan. | ||
| if (block.text.length < 2000) assistantTexts.push(redactSecrets(block.text)); | ||
| continue; | ||
@@ -107,0 +110,0 @@ } |
+25
-4
@@ -286,5 +286,19 @@ #!/usr/bin/env node | ||
| // Default to CLAUDE.md for project-level memories | ||
| const targetFile = filename || 'CLAUDE.md'; | ||
| const targetPath = path.join(projectDir, targetFile); | ||
| // Default to CLAUDE.md for project-level memories. | ||
| // Guards mirror the global branch below and memoir_read above: | ||
| // model-supplied filename must be a bare markdown name — no | ||
| // separators, no traversal — and must resolve inside the project | ||
| // dir. Without this, filename:".zshrc" appends to a shell rc | ||
| // (code execution on next shell) and "package.json" corrupts | ||
| // real files. | ||
| let targetFile = filename || 'CLAUDE.md'; | ||
| if (!targetFile.endsWith('.md')) targetFile += '.md'; | ||
| if (targetFile.includes('/') || targetFile.includes('\\') || targetFile.includes('..')) { | ||
| return { content: [{ type: 'text', text: `Invalid filename: ${filename} (must be a bare .md name)` }] }; | ||
| } | ||
| const projBase = path.resolve(projectDir); | ||
| const targetPath = path.resolve(projBase, targetFile); | ||
| if (!targetPath.startsWith(projBase + path.sep)) { | ||
| return { content: [{ type: 'text', text: `Invalid filename: ${filename}` }] }; | ||
| } | ||
@@ -398,3 +412,10 @@ // Append to existing file or create new | ||
| const fullPath = path.join(adapter.source, filepath); | ||
| // Containment: filepath comes from the model, and the model reads | ||
| // attacker-influenceable text all day. Without this, "../.ssh/id_rsa" | ||
| // resolves outside the adapter dir and the file is returned verbatim. | ||
| const base = path.resolve(adapter.source); | ||
| const fullPath = path.resolve(base, filepath); | ||
| if (fullPath !== base && !fullPath.startsWith(base + path.sep)) { | ||
| return { content: [{ type: 'text', text: `Invalid path: ${filepath} (must stay inside ${adapter.name}'s directory)` }] }; | ||
| } | ||
@@ -401,0 +422,0 @@ if (!(await fs.pathExists(fullPath))) { |
@@ -30,3 +30,3 @@ import fs from 'fs-extra'; | ||
| export async function syncToGit(config, stagingDir, spinner) { | ||
| export async function syncToGit(config, stagingDir, spinner, options = {}) { | ||
| const repoUrl = sanitizeUrl(config.gitRepo); | ||
@@ -43,5 +43,9 @@ if (!repoUrl) throw new Error('Git repository is not configured.'); | ||
| execFileSync('git', ['clone', '--depth', '1', repoUrl, '.'], { cwd: gitDir, stdio: 'ignore', timeout: 60000 }); | ||
| const preserve = new Set(options.preserve || []); | ||
| const files = await fs.readdir(gitDir); | ||
| for (const f of files) { | ||
| if (f !== '.git') await fs.remove(path.join(gitDir, f)); | ||
| // preserve: files the caller knows exist remotely but could not | ||
| // merge (unreadable session.json) — deleting them here would be | ||
| // the mirror-clobber the push guard just declined to commit. | ||
| if (f !== '.git' && !preserve.has(f)) await fs.remove(path.join(gitDir, f)); | ||
| } | ||
@@ -68,3 +72,6 @@ } catch { | ||
| spinner.text = `Pushing data to ${chalk.cyan(repoUrl)}...`; | ||
| execFileSync('git', ['push', repoUrl, 'main'], { cwd: gitDir, stdio: 'ignore', timeout: 120000 }); | ||
| // HEAD:main pushes whatever branch the clone checked out (a master- | ||
| // default remote used to make `push main` fail silently under autopush | ||
| // with a misleading credentials error, while doctor reported green). | ||
| execFileSync('git', ['push', repoUrl, 'HEAD:main'], { cwd: gitDir, stdio: 'ignore', timeout: 120000 }); | ||
@@ -71,0 +78,0 @@ spinner.succeed(chalk.green('Sync complete! ') + chalk.gray('(Uploaded securely to GitHub)')); |
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 3 instances
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 4 instances
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
445861
1.01%10434
0.58%169
1.2%52
-1.89%