memoir-cli 3.11.3 → 3.14.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.
Files changed (76) hide show
  1. package/README.md +129 -124
  2. package/bin/memoir-work.js +9 -0
  3. package/bin/memoir.js +72 -8
  4. package/docs/AUDIT-REMEDIATION.md +55 -0
  5. package/docs/CASE_TAPE_AMNESIA.md +39 -0
  6. package/docs/HANDOFF-SECURITY-AUDIT.md +106 -0
  7. package/docs/LOCAL-HANDOFF-VALIDATION.md +129 -0
  8. package/docs/MCP-V2-MIGRATION.md +17 -0
  9. package/docs/PROJECT-HANDOFF.md +255 -0
  10. package/docs/PROJECT-VIEW-DEBUG.md +66 -0
  11. package/docs/PROJECT-VIEW-VALIDATION.md +136 -0
  12. package/docs/RELEASE-3.14-VALIDATION.md +36 -0
  13. package/docs/RELIABILITY-ROLLOUT.md +57 -0
  14. package/docs/RETRIEVAL-INDEX.md +45 -0
  15. package/docs/RETRIEVAL-RESULTS.md +26 -0
  16. package/docs/SPEC.md +684 -0
  17. package/evals/CONTINUITY-PROTOCOL.md +45 -0
  18. package/evals/cases.json +200 -0
  19. package/evals/results/retrieval-2026-09-05.json +5333 -0
  20. package/evals/retrieval-performance.mjs +99 -0
  21. package/evals/run.mjs +87 -0
  22. package/package.json +13 -5
  23. package/src/adapters/index.js +13 -6
  24. package/src/adapters/restore.js +83 -36
  25. package/src/cloud/auth.js +12 -15
  26. package/src/cloud/constants.js +6 -2
  27. package/src/cloud/storage.js +130 -93
  28. package/src/commands/activate.js +43 -9
  29. package/src/commands/cloud.js +56 -5
  30. package/src/commands/consolidate.js +49 -10
  31. package/src/commands/diff.js +2 -2
  32. package/src/commands/doctor.js +3 -3
  33. package/src/commands/forget.js +100 -0
  34. package/src/commands/push.js +164 -161
  35. package/src/commands/recall.js +42 -0
  36. package/src/commands/restore.js +32 -44
  37. package/src/commands/resume.js +15 -164
  38. package/src/commands/session.js +51 -9
  39. package/src/commands/snapshot.js +6 -7
  40. package/src/commands/status.js +23 -1
  41. package/src/commands/upgrade.js +13 -11
  42. package/src/commands/validate.js +16 -0
  43. package/src/commands/view.js +2 -2
  44. package/src/commands/why.js +4 -3
  45. package/src/config.js +9 -40
  46. package/src/context/capture.js +135 -33
  47. package/src/context/handoffs.js +72 -0
  48. package/src/events/summary.js +122 -0
  49. package/src/integrations/setup.js +88 -0
  50. package/src/mcp.js +151 -283
  51. package/src/memory/lexical-index.js +65 -0
  52. package/src/memory/repository.js +16 -0
  53. package/src/memory/scope.js +65 -0
  54. package/src/memory/search.js +598 -0
  55. package/src/memory/store.js +141 -0
  56. package/src/providers/index.js +182 -51
  57. package/src/providers/restore.js +5 -1
  58. package/src/security/encryption.js +34 -60
  59. package/src/security/files.js +155 -0
  60. package/src/session/brief.js +47 -0
  61. package/src/session/inject.js +12 -6
  62. package/src/session/lock.js +39 -118
  63. package/src/session/migrations.js +6 -0
  64. package/src/session/render.js +34 -4
  65. package/src/session/state.js +305 -34
  66. package/src/work/cli.js +64 -0
  67. package/src/work/errors.js +8 -0
  68. package/src/work/server.js +28 -0
  69. package/src/work/setup.js +96 -0
  70. package/src/work/store.js +340 -0
  71. package/src/work/ui/app.js +205 -0
  72. package/src/work/ui/index.html +30 -0
  73. package/src/work/ui/style.css +3 -0
  74. package/src/work/view.js +93 -0
  75. package/src/workspace/tracker.js +84 -332
  76. package/supabase/migrations/202609050001_backup_versions.sql +50 -0
@@ -1,3 +1,5 @@
1
+ import { projectIdentity } from '../memory/scope.js';
2
+ import { restoreStoredMemories, stageMemories } from '../memory/store.js';
1
3
  import chalk from 'chalk';
2
4
  import fs from 'fs-extra';
3
5
  import path from 'path';
@@ -8,85 +10,70 @@ import gradient from 'gradient-string';
8
10
  import { execFileSync } from 'child_process';
9
11
  import { getConfig, autoSetup } from '../config.js';
10
12
  import { extractMemories, adapters } from '../adapters/index.js';
11
- import { syncToLocal, syncToGit } from '../providers/index.js';
13
+ import { syncToLocal, syncToGit, withLocalBackupLock, cloneForSync, remoteHasFile, checkoutFromRemote } from '../providers/index.js';
12
14
  import inquirer from 'inquirer';
13
15
  import { appendEvent } from '../events/log.js';
14
- import { findClaudeSessions, parseSession, generateContextHandoff, shouldIgnoreProject, persistDecisions, isQuality } from '../context/capture.js';
16
+ import { findClaudeSessions, parseSession, generateContextHandoff, shouldIgnoreProject, persistDecisions, isQuality, enrichWithGit } from '../context/capture.js';
17
+ import { saveHandoff, handoffFilename } from '../context/handoffs.js';
15
18
  import { scanForSecrets, printSecurityReport } from '../security/scanner.js';
16
- import { encryptDirectory, createVerifyToken } from '../security/encryption.js';
19
+ import { encryptDirectory, decryptDirectory, createVerifyToken } from '../security/encryption.js';
17
20
  import { getRawConfig, saveConfig, migrateConfigToV2 } from '../config.js';
18
21
  import { scanWorkspace } from '../workspace/tracker.js';
19
22
  import { promptActivate } from './activate.js';
20
23
  import { paths as sessionPaths, readSession, writeSession, mergeSessions, addNote, recordSessionEnd } from '../session/state.js';
21
24
  import { migrateSessionData } from '../session/migrations.js';
22
25
  import { withSessionLock } from '../session/lock.js';
26
+ import { listSafeFiles, readSafeFile, writeSafeFile } from '../security/files.js';
23
27
  import { renderSession } from '../session/render.js';
24
28
  import { injectInto, detectAvailableTargets } from '../session/inject.js';
25
29
 
26
- // Best-effort fetch of the CURRENT remote session.json, so push.js can merge
27
- // before overwrite instead of blindly clobbering it (see below). Returns the
28
- // remote session state (already migrated to SCHEMA_VERSION) or null if the
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.
37
- async function fetchRemoteSessionBestEffort(config) {
30
+ // A failed read never authorizes replacing an existing snapshot. Encrypted
31
+ // snapshots are authenticated before merging, using the same user-held key.
32
+ async function fetchRemoteSessionBestEffort(config, getPassphrase) {
33
+ let cloneDir = null;
34
+ let plainDir = null;
38
35
  try {
39
- if (config.provider === 'local' || config.provider?.includes?.('local')) {
40
- const resolvedDest = (config.localPath || '').replace(/^~/, os.homedir());
41
- if (!resolvedDest) return { status: 'none', session: null };
42
- if (await fs.pathExists(path.join(resolvedDest, 'manifest.enc'))) return { status: 'unreadable', session: null }; // encrypted
43
- const remotePath = path.join(resolvedDest, 'session.json');
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
- }
52
- }
53
-
54
- if (config.provider === 'git' || config.provider?.includes?.('git')) {
55
- const repoUrl = config.gitRepo;
56
- if (!repoUrl) return { status: 'none', session: null };
57
- const peekDir = path.join(os.tmpdir(), `memoir-push-peek-${Date.now()}`);
58
- await fs.ensureDir(peekDir);
59
- try {
60
- try {
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 });
65
- } catch {
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 };
71
- }
72
- if (await fs.pathExists(path.join(peekDir, 'manifest.enc'))) return { status: 'unreadable', session: null }; // encrypted
73
- const remotePath = path.join(peekDir, 'session.json');
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 };
36
+ let source;
37
+ if (config.provider?.includes('local')) {
38
+ source = (config.localPath || '').replace(/^~/, os.homedir());
39
+ if (!source || !await fs.pathExists(source)) return { session: null };
40
+ } else if (config.provider?.includes('git')) {
41
+ cloneDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memoir-push-peek-'));
42
+ cloneForSync(config.gitRepo, cloneDir, { timeout: 120000 });
43
+ source = cloneDir;
44
+ // Materialize the complete tree before merging a snapshot. Correctness
45
+ // comes before the old optimization that omitted encrypted session data.
46
+ if (remoteHasFile(cloneDir, 'manifest.enc') || config.encrypt !== false) {
47
+ if (remoteHasFile(cloneDir, '.')) {
48
+ if (!checkoutFromRemote(cloneDir, '.')) throw new Error('Could not read prior backup');
81
49
  }
82
- } finally {
83
- await fs.remove(peekDir).catch(() => {});
50
+ } else if (remoteHasFile(cloneDir, 'session.json') && !checkoutFromRemote(cloneDir, 'session.json')) {
51
+ throw new Error('Could not read prior session');
84
52
  }
53
+ } else { throw new Error('Unsupported backup provider'); }
54
+
55
+ if (cloneDir && remoteHasFile(cloneDir, 'projects.json') && !checkoutFromRemote(cloneDir, 'projects.json')) throw new Error('Could not read prior project mapping');
56
+ if (cloneDir && remoteHasFile(cloneDir, 'memoir-memories') && !checkoutFromRemote(cloneDir, 'memoir-memories')) throw new Error('Could not read prior memory records');
57
+ const encrypted = await fs.pathExists(path.join(source, 'manifest.enc'));
58
+ if (encrypted) {
59
+ if (config.encrypt === false) throw new Error('The destination is encrypted. Restore it before choosing a new plaintext destination.');
60
+ plainDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memoir-prior-'));
61
+ await decryptDirectory(source, plainDir, await getPassphrase());
62
+ source = plainDir;
63
+ }
64
+ const file = path.join(source, 'session.json');
65
+ let session = null;
66
+ if (await fs.pathExists(file)) {
67
+ const migrated = migrateSessionData(JSON.parse((await readSafeFile(source, 'session.json')).toString('utf8')));
68
+ if (migrated.future) throw new Error('Backup uses a newer session schema; upgrade Memoir first.');
69
+ session = migrated.state;
85
70
  }
86
- } catch {
87
- // Never let a merge-fetch failure block the push.
71
+ return { session, cloneDir, plainDir, source, encrypted };
72
+ } catch (err) {
73
+ if (cloneDir) await fs.remove(cloneDir).catch(() => {});
74
+ if (plainDir) await fs.remove(plainDir).catch(() => {});
75
+ throw new Error('Previous backup could not be verified; it was left unchanged. ' + err.message);
88
76
  }
89
- return { status: 'none', session: null };
90
77
  }
91
78
 
92
79
  // Recursively scan every staged file (the REAL tool memory/config files about
@@ -143,7 +130,7 @@ export async function pushCommand(options = {}) {
143
130
  const setupSpinner = ora({ text: chalk.gray('Setting up memoir automatically...'), spinner: 'dots' }).start();
144
131
  config = await autoSetup();
145
132
  if (config) {
146
- setupSpinner.succeed(chalk.green('Auto-configured') + chalk.gray(` → ${config.gitRepo}`));
133
+ setupSpinner.succeed(chalk.green('Auto-configured') + chalk.gray(` → ${config.gitRepo || config.localPath}`));
147
134
  } else {
148
135
  setupSpinner.fail(chalk.red('Could not detect GitHub username'));
149
136
  console.log('\n' + boxen(
@@ -154,6 +141,11 @@ export async function pushCommand(options = {}) {
154
141
  }
155
142
  }
156
143
 
144
+ // Serialize the complete read/merge/write cycle, including encrypted reads.
145
+ if (config.provider?.includes('local') && !options.localLockHeld) {
146
+ return withLocalBackupLock(config, () => pushCommand({ ...options, localLockHeld: true }));
147
+ }
148
+
157
149
  console.log();
158
150
  const spinner = ora({ text: chalk.gray('Scanning for AI tools...'), spinner: 'dots' }).start();
159
151
 
@@ -161,6 +153,18 @@ export async function pushCommand(options = {}) {
161
153
  await fs.ensureDir(stagingDir);
162
154
 
163
155
  let encryptedDir = null;
156
+ let remoteCloneDir = null;
157
+ let remotePlainDir = null;
158
+ let backupPassphrase = process.env.MEMOIR_PASSPHRASE || '';
159
+ const getPassphrase = async () => {
160
+ if (backupPassphrase.length >= 6) return backupPassphrase;
161
+ if (!process.stdin.isTTY) throw new Error('Encrypted backup requires MEMOIR_PASSPHRASE; no files were uploaded.');
162
+ spinner.stop();
163
+ const answer = await inquirer.prompt([{ type: 'password', name: 'passphrase', message: 'Encryption passphrase:', mask: '*', validate: value => value.length >= 6 || 'Use at least 6 characters' }]);
164
+ backupPassphrase = answer.passphrase;
165
+ spinner.start();
166
+ return backupPassphrase;
167
+ };
164
168
 
165
169
  try {
166
170
  // Profile-level tool filter (config.only) merged with CLI --only flag
@@ -168,7 +172,7 @@ export async function pushCommand(options = {}) {
168
172
  const onlyFilter = onlyRaw ? onlyRaw.split(',').map(t => t.trim().toLowerCase()) : null;
169
173
  const foundAny = await extractMemories(stagingDir, spinner, onlyFilter);
170
174
 
171
- if (!foundAny) {
175
+ if (!foundAny && !await fs.pathExists(sessionPaths.session)) {
172
176
  spinner.stop();
173
177
  console.log('\n' + boxen(
174
178
  chalk.yellow('No AI tools detected on this machine.\n\n') +
@@ -185,24 +189,18 @@ export async function pushCommand(options = {}) {
185
189
  try {
186
190
  const sessions = findClaudeSessions();
187
191
  if (sessions.length > 0) {
188
- const parsed = parseSession(sessions[0].path);
189
- if (parsed.userMessages.length > 0) {
192
+ const parsed = enrichWithGit(parseSession(sessions[0].path));
193
+ if (parsed.cwd && projectIdentity(parsed.cwd) === projectIdentity() && parsed.userMessages.length > 0) {
190
194
  // Scan the generated handoff for any remaining secrets
191
195
  const handoff = generateContextHandoff(parsed);
192
196
  const { found, clean } = scanForSecrets(handoff);
193
197
 
194
- // Save handoff to staging dir
195
- const handoffDir = path.join(stagingDir, 'handoffs');
196
- await fs.ensureDir(handoffDir);
197
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
198
- await fs.writeFile(path.join(handoffDir, `${timestamp}-claude.md`), clean);
199
- await fs.writeFile(path.join(handoffDir, 'latest.md'), clean);
200
-
201
- // Also save locally for memoir resume
202
- const localHandoffDir = path.join(os.homedir(), '.config', 'memoir', 'handoffs');
203
- await fs.ensureDir(localHandoffDir);
204
- await fs.writeFile(path.join(localHandoffDir, `${timestamp}-claude.md`), clean);
205
- await fs.writeFile(path.join(localHandoffDir, 'latest.md'), clean);
198
+ // Staged copy for upload + local copy for `memoir resume`, same
199
+ // filename; the local dir is pruned to a bounded window.
200
+ await saveHandoff(clean, {
201
+ dirs: [path.join(stagingDir, 'handoffs'), path.join(os.homedir(), '.config', 'memoir', 'handoffs')],
202
+ filename: handoffFilename(),
203
+ });
206
204
 
207
205
  // Quality filter: auto-extracted decisions come from regex patterns
208
206
  // that sometimes catch table cells, prose fragments, or truncated
@@ -223,13 +221,7 @@ export async function pushCommand(options = {}) {
223
221
  };
224
222
  const qualityDecisions = parsed.decisions.filter(d => isQuality(decisionText(d)));
225
223
 
226
- // Persist decisions to Claude's memory so they survive across sessions
227
- let decisionCount = 0;
228
- if (qualityDecisions.length > 0) {
229
- try {
230
- decisionCount = persistDecisions(qualityDecisions);
231
- } catch {}
232
- }
224
+ let decisionCount = qualityDecisions.length;
233
225
 
234
226
  // Also feed structured decisions into session.json so they appear in
235
227
  // the pinned block and sync cross-machine. Dedupe against anything
@@ -242,15 +234,30 @@ export async function pushCommand(options = {}) {
242
234
  for (const d of qualityDecisions.slice(0, 10)) {
243
235
  const text = decisionText(d);
244
236
  if (existingTexts.has(text.toLowerCase())) continue;
245
- await addNote(text, { why: d.context ? `auto-captured: ${d.context.slice(0, 80)}` : undefined });
237
+ // A `why` that merely restates the text is not a rationale —
238
+ // for rename/tech captures decisionText() IS d.context, so the
239
+ // old line produced `why: "auto-captured: switch to Sonnet"`
240
+ // under text "switch to Sonnet". Content-free, and it made
241
+ // auto-captures indistinguishable from real reasoning in the
242
+ // pinned block. Emit no why rather than a fake one.
243
+ const ctx = String(d.context || '').trim();
244
+ const restates = !ctx || ctx.toLowerCase() === text.trim().toLowerCase();
245
+ await addNote(text, { project: parsed.cwd, why: restates ? undefined : `auto-captured: ${ctx.slice(0, 80)}` });
246
246
  }
247
- // Record a session summary in history for "recent sessions" section
247
+ // Record a session summary in history for "recent sessions" section.
248
+ // Project + branch + the last thing the user asked for — the old
249
+ // summary was the transcript's random slug ("Worked on
250
+ // calm-bubbling-liskov"), which told the next session nothing.
248
251
  const filesList = Array.from(parsed.filesWritten || []).slice(0, 10);
249
252
  const durationMin = (parsed.firstTimestamp && parsed.lastTimestamp)
250
253
  ? Math.floor((new Date(parsed.lastTimestamp) - new Date(parsed.firstTimestamp)) / 60000)
251
254
  : null;
252
- const summary = parsed.slug ? `Worked on ${parsed.slug}` : `${filesList.length} file(s) touched`;
253
- await recordSessionEnd({ summary, filesTouched: filesList, durationMin });
255
+ const lastAsk = [...parsed.userMessages].reverse().find((m) => m.length > 10) || '';
256
+ const project = parsed.cwd ? path.basename(parsed.cwd) : (parsed.slug || 'session');
257
+ const branch = parsed.gitBranch && parsed.gitBranch !== 'HEAD' ? ` (${parsed.gitBranch})` : '';
258
+ const ask = lastAsk.replace(/\s+/g, ' ').trim().slice(0, 90);
259
+ const summary = ask ? `${project}${branch}: ${ask}` : `${project}${branch}`;
260
+ await recordSessionEnd({ summary, filesTouched: filesList, durationMin, sessionId: parsed.sessionId || null, project: parsed.cwd });
254
261
  // Re-render into every detected tool so the pinned block reflects
255
262
  // what was just auto-captured from the .jsonl
256
263
  try {
@@ -294,60 +301,48 @@ export async function pushCommand(options = {}) {
294
301
  let workspaceManifest = null;
295
302
  spinner.text = chalk.gray('Scanning workspace...');
296
303
  try {
297
- workspaceManifest = await scanWorkspace(stagingDir, spinner);
298
- } catch {
299
- // Workspace scan is best-effort
304
+ if (options.workspace === true || config.workspace === true) workspaceManifest = await scanWorkspace(stagingDir, spinner);
305
+ } catch (err) {
306
+ throw new Error('Workspace capture failed: ' + err.message);
300
307
  }
301
308
 
302
- // Include session.json (continuity state) so it syncs across machines.
303
- //
304
- // MERGE-BEFORE-OVERWRITE: this used to be a blind fs.copy() of the LOCAL
305
- // session.json, and syncToGit/syncToLocal do a full-mirror overwrite of
306
- // the remote (clone-or-init, delete every tracked file, copy the local
307
- // staging dir wholesale over it, commit, push). Any machine that pushed
308
- // without having restored first would silently and completely destroy
309
- // whatever ANY OTHER machine had added to the remote in the interim —
310
- // goals, next-actions, decisions, everything. Not an edge case: it's the
311
- // default behavior of the most common operation in the tool (autopush
312
- // fires after every single Claude Code response).
313
- //
314
- // Best-effort fetch the current remote session.json first, migrate it,
315
- // and merge with mergeSessions (the same newest-timestamp-wins
316
- // union-by-text function restore.js already uses) BEFORE writing the
317
- // result to both the staging dir (for upload) and back to the local
318
- // session.json (so this machine also gains whatever the remote had that
319
- // it didn't) symmetric with restore.js instead of a blind overwrite.
320
- let sessionIncluded = false;
321
- let preserveRemoteSession = false;
322
- try {
323
- if (await fs.pathExists(sessionPaths.session)) {
324
- const { status, session: remote } = await fetchRemoteSessionBestEffort(config);
325
- if (status === 'unreadable') {
326
- // A remote session exists and we could not read it (encrypted,
327
- // slow, corrupt). Staging ours anyway would mirror-overwrite the
328
- // one copy we couldn't merge — the exact clobber this guard
329
- // exists to prevent. Leave session.json out of the staging dir
330
- // and tell the sync to leave the remote copy alone.
331
- preserveRemoteSession = true;
332
- try { appendEvent('sync_degraded', { reason: 'remote_session_unreadable' }); } catch {}
333
- } else {
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
- });
344
- await fs.writeFile(path.join(stagingDir, 'session.json'), JSON.stringify(merged, null, 2));
345
- sessionIncluded = true;
309
+ const prior = await fetchRemoteSessionBestEffort(config, getPassphrase);
310
+ remoteCloneDir = prior.cloneDir || null;
311
+ remotePlainDir = prior.plainDir || null;
312
+ if (prior.source) await restoreStoredMemories(prior.source);
313
+ // Keep mappings for projects that exist only on another machine.
314
+ if (prior.source && await fs.pathExists(path.join(prior.source, 'projects.json'))) {
315
+ const remoteProjects = JSON.parse((await readSafeFile(prior.source, 'projects.json')).toString());
316
+ let localProjects = {};
317
+ if (await fs.pathExists(path.join(stagingDir, 'projects.json'))) localProjects = JSON.parse((await readSafeFile(stagingDir, 'projects.json')).toString());
318
+ await writeSafeFile(stagingDir, 'projects.json', JSON.stringify({ ...remoteProjects, ...localProjects }, null, 2));
319
+ }
320
+
321
+ await stageMemories(stagingDir);
322
+ // Re-encryption always starts with a complete prior snapshot. Overlay
323
+ // current files, preserve files absent on this machine, merge session below.
324
+ if ((config.encrypt !== false || prior.encrypted) && prior.source) {
325
+ const priorFiles = await listSafeFiles(prior.source).catch(async err => {
326
+ // A checked-out Git tree contains .git, which is transport metadata.
327
+ if (remoteCloneDir === prior.source) {
328
+ const files = execFileSync('git', ['ls-files', '-z'], { cwd: prior.source, encoding: 'utf8' }).split('\0').filter(Boolean);
329
+ return files;
346
330
  }
331
+ throw err;
332
+ });
333
+ for (const rel of priorFiles) {
334
+ if (rel.startsWith('memoir-memories/')) continue; // Already reconciled, including purge tombstones.
335
+ if (!await fs.pathExists(path.join(stagingDir, rel))) await writeSafeFile(stagingDir, rel, await readSafeFile(prior.source, rel));
347
336
  }
348
- } catch {
349
- // Best-effort — don't fail the push over this
350
337
  }
338
+ let merged;
339
+ await withSessionLock(sessionPaths.sessionLock, async () => {
340
+ const local = await readSession();
341
+ merged = prior.session ? mergeSessions(local, prior.session) : local;
342
+ await writeSession(merged);
343
+ });
344
+ await writeSafeFile(stagingDir, 'session.json', JSON.stringify(merged, null, 2));
345
+ const sessionIncluded = true;
351
346
 
352
347
  // Count what was found
353
348
  const found = [];
@@ -369,13 +364,8 @@ export async function pushCommand(options = {}) {
369
364
  // • --redact → strip secrets in place, then upload (sanitized)
370
365
  // • otherwise → WARN and continue
371
366
  // • background autopush → stay silent and continue
372
- // We deliberately do NOT hard-block. This is a zero-knowledge encrypted
373
- // backup of the user's OWN files; silently refusing to back up — which the
374
- // detached `autopush` Stop-hook path (stdio:'ignore', MEMOIR_AUTOPUSH=1, no
375
- // TTY) would hit on any false-positive match — is a worse failure than
376
- // backing up. A future `--strict` flag could fail-closed for the
377
- // encrypt-off / shared-destination case. Wrapped so a scanner error can
378
- // never break the push.
367
+ // Redaction is explicit and heuristic. Plaintext backups can contain
368
+ // secrets; a warning does not promise that every secret was detected.
379
369
  const background = process.env.MEMOIR_AUTOPUSH === '1';
380
370
  try {
381
371
  const { findings } = await scanStagedFiles(stagingDir, { redact: options.redact === true });
@@ -408,7 +398,7 @@ export async function pushCommand(options = {}) {
408
398
  // Encrypt if enabled (or ask on first push if not configured)
409
399
  let uploadDir = stagingDir;
410
400
  let encrypted = false;
411
- let shouldEncrypt = config.encrypt;
401
+ let shouldEncrypt = prior.encrypted || config.encrypt;
412
402
 
413
403
  if (shouldEncrypt === undefined) {
414
404
  if (background || !process.stdin.isTTY) {
@@ -418,7 +408,8 @@ export async function pushCommand(options = {}) {
418
408
  // otherwise push unencrypted THIS ONCE without persisting the
419
409
  // choice — a backup beats no backup, and the next interactive push
420
410
  // still gets the real question (default Yes).
421
- shouldEncrypt = Boolean(process.env.MEMOIR_PASSPHRASE);
411
+ if (!process.env.MEMOIR_PASSPHRASE) throw new Error('Choose encryption explicitly with memoir init before sending this backup, or set MEMOIR_PASSPHRASE.');
412
+ shouldEncrypt = true;
422
413
  if (!shouldEncrypt) {
423
414
  config.encrypt = undefined; // do not let the fallthrough persist "off"
424
415
  }
@@ -456,17 +447,7 @@ export async function pushCommand(options = {}) {
456
447
  if (shouldEncrypt) {
457
448
  // Headless pushes can supply the passphrase via env; interactive
458
449
  // pushes are asked as before.
459
- let passphrase = process.env.MEMOIR_PASSPHRASE || '';
460
- if (!passphrase || passphrase.length < 6) {
461
- spinner.stop();
462
- ({ passphrase } = await inquirer.prompt([{
463
- type: 'password',
464
- name: 'passphrase',
465
- message: '🔒 Encryption passphrase:',
466
- mask: '*',
467
- validate: (input) => input.length >= 6 ? true : 'Passphrase must be at least 6 characters'
468
- }]));
469
- }
450
+ const passphrase = await getPassphrase();
470
451
  spinner.start(chalk.gray('Deriving encryption key...'));
471
452
 
472
453
  encryptedDir = path.join(os.tmpdir(), `memoir-encrypted-${Date.now()}`);
@@ -479,6 +460,19 @@ export async function pushCommand(options = {}) {
479
460
  const token = await createVerifyToken(passphrase);
480
461
  await fs.writeFile(path.join(encryptedDir, 'verify.enc'), token);
481
462
 
463
+ if (prior.source && !prior.encrypted) {
464
+ const verified = await fs.mkdtemp(path.join(os.tmpdir(), 'memoir-encryption-check-'));
465
+ try {
466
+ await decryptDirectory(encryptedDir, verified, passphrase);
467
+ const expected = (await listSafeFiles(stagingDir)).sort();
468
+ const actual = (await listSafeFiles(verified)).sort();
469
+ if (JSON.stringify(expected) !== JSON.stringify(actual)) throw new Error('Encryption migration verification failed');
470
+ for (const rel of expected) {
471
+ if (!(await readSafeFile(stagingDir, rel)).equals(await readSafeFile(verified, rel))) throw new Error('Encryption migration content mismatch');
472
+ }
473
+ } finally { await fs.remove(verified); }
474
+ }
475
+
482
476
  uploadDir = encryptedDir;
483
477
  encrypted = true;
484
478
  }
@@ -486,9 +480,13 @@ export async function pushCommand(options = {}) {
486
480
  spinner.text = chalk.gray('Uploading to ' + (config.provider === 'git' ? 'GitHub' : 'local storage') + '...');
487
481
 
488
482
  if (config.provider === 'local' || config.provider.includes('local')) {
489
- await syncToLocal(config, uploadDir, spinner);
483
+ await syncToLocal(config, uploadDir, spinner, { verifiedReplacement: encrypted, lockHeld: options.localLockHeld });
490
484
  } else if (config.provider === 'git' || config.provider.includes('git')) {
491
- await syncToGit(config, uploadDir, spinner, preserveRemoteSession ? { preserve: ['session.json'] } : {});
485
+ await syncToGit(config, uploadDir, spinner, {
486
+ cloneDir: remoteCloneDir,
487
+ additive: !encrypted,
488
+ });
489
+ remoteCloneDir = null; // syncToGit removed it
492
490
  } else {
493
491
  spinner.fail(chalk.red(`Unknown provider: ${config.provider}`));
494
492
  return;
@@ -535,7 +533,7 @@ export async function pushCommand(options = {}) {
535
533
  let workspaceLine = '';
536
534
  if (workspaceManifest && workspaceManifest.projects.length > 0) {
537
535
  const gitCount = workspaceManifest.projects.filter(p => p.type === 'git' && p.gitRemote).length;
538
- const bundleCount = workspaceManifest.projects.filter(p => p.bundleFile).length;
536
+ const bundleCount = workspaceManifest.projects.filter(p => p.bundleFile || p.type === 'files').length;
539
537
  const parts = [];
540
538
  if (gitCount > 0) parts.push(`${gitCount} git`);
541
539
  if (bundleCount > 0) parts.push(`${bundleCount} bundled`);
@@ -559,11 +557,16 @@ export async function pushCommand(options = {}) {
559
557
  }
560
558
  } catch (error) {
561
559
  spinner.fail(chalk.red('Sync failed: ') + error.message);
560
+ throw error;
562
561
  } finally {
562
+ if (remotePlainDir) await fs.remove(remotePlainDir).catch(() => {});
563
563
  await fs.remove(stagingDir);
564
564
  // Clean up encrypted dir if it was created
565
565
  if (encryptedDir) {
566
566
  await fs.remove(encryptedDir).catch(() => {});
567
567
  }
568
+ if (remoteCloneDir) {
569
+ await fs.remove(remoteCloneDir).catch(() => {});
570
+ }
568
571
  }
569
572
  }
@@ -0,0 +1,42 @@
1
+ // `memoir recall <query>` — the same search the memoir_recall MCP tool runs,
2
+ // from the terminal. Exists so a human can see exactly what their AI would
3
+ // be handed for a question ("what would it see if it asked about X?"),
4
+ // which is the fastest way to notice a memory that was never written, or
5
+ // one that needs an `aliases:` line to be findable.
6
+
7
+ import chalk from 'chalk';
8
+ import { searchMemories } from '../memory/search.js';
9
+
10
+ export async function recallCommand(query, options = {}) {
11
+ const q = String(query || '').trim();
12
+ if (!q) {
13
+ console.log(chalk.yellow('\nUsage: ') + chalk.cyan('memoir recall "what you want to find" [--limit N] [--json]\n'));
14
+ return;
15
+ }
16
+ const limit = Math.max(1, Math.min(50, parseInt(options.limit, 10) || 10));
17
+ const t0 = Date.now();
18
+ const res = await searchMemories(q, { limit, project: options.project });
19
+ const ms = Date.now() - t0;
20
+
21
+ if (options.json) {
22
+ console.log(JSON.stringify({ query: q, terms: res.terms, total: res.total, results: res.results }, null, 2));
23
+ return;
24
+ }
25
+
26
+ if (!res.results.length) {
27
+ console.log('\n' + chalk.yellow(` No memories match "${q}"`) +
28
+ (res.terms.length ? chalk.gray(` (searched: ${res.terms.join(', ')})`) : '') + '\n');
29
+ return;
30
+ }
31
+
32
+ console.log('\n' + chalk.cyan.bold(` ${res.total} match${res.total === 1 ? '' : 'es'} for "${q}"`) +
33
+ chalk.gray(` · top ${res.results.length} · ${ms}ms · terms: ${res.terms.join(', ')}`) + '\n');
34
+ res.results.forEach((r, i) => {
35
+ const cov = r.matched < res.terms.length ? chalk.gray(` · ${r.matched}/${res.terms.length} terms`) : '';
36
+ console.log(chalk.green(` ${i + 1}. `) + chalk.white.bold(`${r.tool} / ${r.path}`) + cov);
37
+ const meta = [r.type, r.description].filter(Boolean).join(' · ');
38
+ if (meta) console.log(chalk.gray(` ${meta}`));
39
+ for (const line of r.passage.split('\n')) console.log(chalk.white(' ' + line));
40
+ console.log('');
41
+ });
42
+ }