memoir-cli 3.12.0 → 3.15.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/README.md +128 -137
- package/bin/memoir-work.js +9 -0
- package/bin/memoir.js +50 -8
- package/docs/AUDIT-REMEDIATION.md +55 -0
- package/docs/CASE_TAPE_AMNESIA.md +39 -0
- package/docs/HANDOFF-SECURITY-AUDIT.md +106 -0
- package/docs/LOCAL-HANDOFF-VALIDATION.md +129 -0
- package/docs/MCP-V2-MIGRATION.md +17 -0
- package/docs/PROJECT-HANDOFF.md +301 -0
- package/docs/PROJECT-MAP-TRIAL.md +149 -0
- package/docs/PROJECT-VIEW-DEBUG.md +66 -0
- package/docs/PROJECT-VIEW-VALIDATION.md +136 -0
- package/docs/RELEASE-3.14-VALIDATION.md +36 -0
- package/docs/RELIABILITY-ROLLOUT.md +57 -0
- package/docs/RETRIEVAL-INDEX.md +45 -0
- package/docs/RETRIEVAL-RESULTS.md +26 -0
- package/docs/SPEC.md +684 -0
- package/evals/CONTINUITY-PROTOCOL.md +45 -0
- package/evals/cases.json +200 -0
- package/evals/results/retrieval-2026-09-05.json +5333 -0
- package/evals/retrieval-performance.mjs +99 -0
- package/evals/run.mjs +87 -0
- package/package.json +13 -5
- package/src/adapters/index.js +13 -6
- package/src/adapters/restore.js +83 -36
- package/src/cloud/storage.js +130 -93
- package/src/commands/activate.js +18 -7
- package/src/commands/cloud.js +55 -4
- package/src/commands/consolidate.js +49 -10
- package/src/commands/diff.js +2 -2
- package/src/commands/doctor.js +3 -3
- package/src/commands/push.js +156 -161
- package/src/commands/recall.js +1 -1
- package/src/commands/restore.js +32 -44
- package/src/commands/resume.js +15 -164
- package/src/commands/session.js +51 -9
- package/src/commands/snapshot.js +6 -7
- package/src/commands/status.js +23 -1
- package/src/commands/upgrade.js +11 -9
- package/src/commands/validate.js +3 -0
- package/src/commands/view.js +2 -2
- package/src/commands/why.js +4 -3
- package/src/config.js +9 -40
- package/src/context/capture.js +126 -32
- package/src/context/handoffs.js +72 -0
- package/src/events/summary.js +122 -0
- package/src/integrations/setup.js +88 -0
- package/src/mcp.js +105 -152
- package/src/memory/lexical-index.js +65 -0
- package/src/memory/repository.js +16 -0
- package/src/memory/scope.js +65 -0
- package/src/memory/search.js +165 -70
- package/src/memory/store.js +141 -0
- package/src/providers/index.js +182 -51
- package/src/providers/restore.js +5 -1
- package/src/security/encryption.js +34 -60
- package/src/security/files.js +155 -0
- package/src/session/brief.js +47 -0
- package/src/session/inject.js +12 -6
- package/src/session/lock.js +39 -118
- package/src/session/migrations.js +6 -0
- package/src/session/render.js +34 -4
- package/src/session/state.js +200 -33
- package/src/work/cli.js +64 -0
- package/src/work/errors.js +8 -0
- package/src/work/server.js +28 -0
- package/src/work/setup.js +96 -0
- package/src/work/store.js +340 -0
- package/src/work/ui/app.js +398 -0
- package/src/work/ui/index.html +45 -0
- package/src/work/ui/style.css +248 -0
- package/src/work/view.js +93 -0
- package/src/workspace/tracker.js +84 -332
- package/supabase/migrations/202609050001_backup_versions.sql +50 -0
package/src/commands/doctor.js
CHANGED
|
@@ -5,7 +5,7 @@ import boxen from 'boxen';
|
|
|
5
5
|
import ora from 'ora';
|
|
6
6
|
import gradient from 'gradient-string';
|
|
7
7
|
import os from 'os';
|
|
8
|
-
import { execSync } from 'child_process';
|
|
8
|
+
import { execSync, execFileSync } from 'child_process';
|
|
9
9
|
import { getConfig } from '../config.js';
|
|
10
10
|
import { adapters } from '../adapters/index.js';
|
|
11
11
|
import { scanForSecrets as scanTextForSecrets } from '../security/scanner.js';
|
|
@@ -100,7 +100,7 @@ export async function doctorCommand(options = {}) {
|
|
|
100
100
|
if (config?.provider === 'git' && gitInstalled && config.gitRepo) {
|
|
101
101
|
spinner.text = 'Testing remote connectivity...';
|
|
102
102
|
try {
|
|
103
|
-
|
|
103
|
+
execFileSync('git', ['ls-remote', config.gitRepo, 'HEAD'], { stdio: 'pipe', timeout: 10000 });
|
|
104
104
|
lines.push(pass(`Remote: ${chalk.gray(config.gitRepo)} reachable`));
|
|
105
105
|
} catch {
|
|
106
106
|
lines.push(fail(`Remote: cannot reach ${chalk.gray(config.gitRepo)}`));
|
|
@@ -185,7 +185,7 @@ export async function doctorCommand(options = {}) {
|
|
|
185
185
|
lines.push(chalk.bold.white(' Last Sync'));
|
|
186
186
|
try {
|
|
187
187
|
const tmpDir = path.join(os.tmpdir(), 'memoir-doctor-' + Date.now());
|
|
188
|
-
|
|
188
|
+
execFileSync('git', ['clone', '--depth', '1', '--', config.gitRepo, tmpDir], { stdio: 'pipe', timeout: 15000 });
|
|
189
189
|
const lastCommit = execSync('git log -1 --format=%cr', { cwd: tmpDir, stdio: 'pipe' }).toString().trim();
|
|
190
190
|
const lastMsg = execSync('git log -1 --format=%s', { cwd: tmpDir, stdio: 'pipe' }).toString().trim();
|
|
191
191
|
await fs.remove(tmpDir);
|
package/src/commands/push.js
CHANGED
|
@@ -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
|
-
//
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if (await fs.pathExists(
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
}
|
|
83
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
//
|
|
195
|
-
|
|
196
|
-
await
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
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
|
|
@@ -250,15 +242,22 @@ export async function pushCommand(options = {}) {
|
|
|
250
242
|
// pinned block. Emit no why rather than a fake one.
|
|
251
243
|
const ctx = String(d.context || '').trim();
|
|
252
244
|
const restates = !ctx || ctx.toLowerCase() === text.trim().toLowerCase();
|
|
253
|
-
await addNote(text, { why: restates ? undefined : `auto-captured: ${ctx.slice(0, 80)}` });
|
|
245
|
+
await addNote(text, { project: parsed.cwd, why: restates ? undefined : `auto-captured: ${ctx.slice(0, 80)}` });
|
|
254
246
|
}
|
|
255
|
-
// 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.
|
|
256
251
|
const filesList = Array.from(parsed.filesWritten || []).slice(0, 10);
|
|
257
252
|
const durationMin = (parsed.firstTimestamp && parsed.lastTimestamp)
|
|
258
253
|
? Math.floor((new Date(parsed.lastTimestamp) - new Date(parsed.firstTimestamp)) / 60000)
|
|
259
254
|
: null;
|
|
260
|
-
const
|
|
261
|
-
|
|
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 });
|
|
262
261
|
// Re-render into every detected tool so the pinned block reflects
|
|
263
262
|
// what was just auto-captured from the .jsonl
|
|
264
263
|
try {
|
|
@@ -302,60 +301,48 @@ export async function pushCommand(options = {}) {
|
|
|
302
301
|
let workspaceManifest = null;
|
|
303
302
|
spinner.text = chalk.gray('Scanning workspace...');
|
|
304
303
|
try {
|
|
305
|
-
workspaceManifest = await scanWorkspace(stagingDir, spinner);
|
|
306
|
-
} catch {
|
|
307
|
-
|
|
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);
|
|
308
307
|
}
|
|
309
308
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
//
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
if (await fs.pathExists(sessionPaths.session)) {
|
|
332
|
-
const { status, session: remote } = await fetchRemoteSessionBestEffort(config);
|
|
333
|
-
if (status === 'unreadable') {
|
|
334
|
-
// A remote session exists and we could not read it (encrypted,
|
|
335
|
-
// slow, corrupt). Staging ours anyway would mirror-overwrite the
|
|
336
|
-
// one copy we couldn't merge — the exact clobber this guard
|
|
337
|
-
// exists to prevent. Leave session.json out of the staging dir
|
|
338
|
-
// and tell the sync to leave the remote copy alone.
|
|
339
|
-
preserveRemoteSession = true;
|
|
340
|
-
try { appendEvent('sync_degraded', { reason: 'remote_session_unreadable' }); } catch {}
|
|
341
|
-
} else {
|
|
342
|
-
// Read AND merge AND write inside one lock. Reading outside it and
|
|
343
|
-
// locking only the write is a check-then-act: a concurrent MCP
|
|
344
|
-
// memoir_note in that window is silently dropped. This is the most
|
|
345
|
-
// reachable instance of that bug — it sits on the autopush path.
|
|
346
|
-
let merged;
|
|
347
|
-
await withSessionLock(sessionPaths.sessionLock, async () => {
|
|
348
|
-
const local = await readSession();
|
|
349
|
-
merged = remote ? mergeSessions(local, remote) : local;
|
|
350
|
-
if (remote) await writeSession(merged);
|
|
351
|
-
});
|
|
352
|
-
await fs.writeFile(path.join(stagingDir, 'session.json'), JSON.stringify(merged, null, 2));
|
|
353
|
-
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;
|
|
354
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));
|
|
355
336
|
}
|
|
356
|
-
} catch {
|
|
357
|
-
// Best-effort — don't fail the push over this
|
|
358
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;
|
|
359
346
|
|
|
360
347
|
// Count what was found
|
|
361
348
|
const found = [];
|
|
@@ -377,13 +364,8 @@ export async function pushCommand(options = {}) {
|
|
|
377
364
|
// • --redact → strip secrets in place, then upload (sanitized)
|
|
378
365
|
// • otherwise → WARN and continue
|
|
379
366
|
// • background autopush → stay silent and continue
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
// detached `autopush` Stop-hook path (stdio:'ignore', MEMOIR_AUTOPUSH=1, no
|
|
383
|
-
// TTY) would hit on any false-positive match — is a worse failure than
|
|
384
|
-
// backing up. A future `--strict` flag could fail-closed for the
|
|
385
|
-
// encrypt-off / shared-destination case. Wrapped so a scanner error can
|
|
386
|
-
// 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.
|
|
387
369
|
const background = process.env.MEMOIR_AUTOPUSH === '1';
|
|
388
370
|
try {
|
|
389
371
|
const { findings } = await scanStagedFiles(stagingDir, { redact: options.redact === true });
|
|
@@ -416,7 +398,7 @@ export async function pushCommand(options = {}) {
|
|
|
416
398
|
// Encrypt if enabled (or ask on first push if not configured)
|
|
417
399
|
let uploadDir = stagingDir;
|
|
418
400
|
let encrypted = false;
|
|
419
|
-
let shouldEncrypt = config.encrypt;
|
|
401
|
+
let shouldEncrypt = prior.encrypted || config.encrypt;
|
|
420
402
|
|
|
421
403
|
if (shouldEncrypt === undefined) {
|
|
422
404
|
if (background || !process.stdin.isTTY) {
|
|
@@ -426,7 +408,8 @@ export async function pushCommand(options = {}) {
|
|
|
426
408
|
// otherwise push unencrypted THIS ONCE without persisting the
|
|
427
409
|
// choice — a backup beats no backup, and the next interactive push
|
|
428
410
|
// still gets the real question (default Yes).
|
|
429
|
-
|
|
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;
|
|
430
413
|
if (!shouldEncrypt) {
|
|
431
414
|
config.encrypt = undefined; // do not let the fallthrough persist "off"
|
|
432
415
|
}
|
|
@@ -464,17 +447,7 @@ export async function pushCommand(options = {}) {
|
|
|
464
447
|
if (shouldEncrypt) {
|
|
465
448
|
// Headless pushes can supply the passphrase via env; interactive
|
|
466
449
|
// pushes are asked as before.
|
|
467
|
-
|
|
468
|
-
if (!passphrase || passphrase.length < 6) {
|
|
469
|
-
spinner.stop();
|
|
470
|
-
({ passphrase } = await inquirer.prompt([{
|
|
471
|
-
type: 'password',
|
|
472
|
-
name: 'passphrase',
|
|
473
|
-
message: '🔒 Encryption passphrase:',
|
|
474
|
-
mask: '*',
|
|
475
|
-
validate: (input) => input.length >= 6 ? true : 'Passphrase must be at least 6 characters'
|
|
476
|
-
}]));
|
|
477
|
-
}
|
|
450
|
+
const passphrase = await getPassphrase();
|
|
478
451
|
spinner.start(chalk.gray('Deriving encryption key...'));
|
|
479
452
|
|
|
480
453
|
encryptedDir = path.join(os.tmpdir(), `memoir-encrypted-${Date.now()}`);
|
|
@@ -487,6 +460,19 @@ export async function pushCommand(options = {}) {
|
|
|
487
460
|
const token = await createVerifyToken(passphrase);
|
|
488
461
|
await fs.writeFile(path.join(encryptedDir, 'verify.enc'), token);
|
|
489
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
|
+
|
|
490
476
|
uploadDir = encryptedDir;
|
|
491
477
|
encrypted = true;
|
|
492
478
|
}
|
|
@@ -494,9 +480,13 @@ export async function pushCommand(options = {}) {
|
|
|
494
480
|
spinner.text = chalk.gray('Uploading to ' + (config.provider === 'git' ? 'GitHub' : 'local storage') + '...');
|
|
495
481
|
|
|
496
482
|
if (config.provider === 'local' || config.provider.includes('local')) {
|
|
497
|
-
await syncToLocal(config, uploadDir, spinner);
|
|
483
|
+
await syncToLocal(config, uploadDir, spinner, { verifiedReplacement: encrypted, lockHeld: options.localLockHeld });
|
|
498
484
|
} else if (config.provider === 'git' || config.provider.includes('git')) {
|
|
499
|
-
await syncToGit(config, uploadDir, spinner,
|
|
485
|
+
await syncToGit(config, uploadDir, spinner, {
|
|
486
|
+
cloneDir: remoteCloneDir,
|
|
487
|
+
additive: !encrypted,
|
|
488
|
+
});
|
|
489
|
+
remoteCloneDir = null; // syncToGit removed it
|
|
500
490
|
} else {
|
|
501
491
|
spinner.fail(chalk.red(`Unknown provider: ${config.provider}`));
|
|
502
492
|
return;
|
|
@@ -543,7 +533,7 @@ export async function pushCommand(options = {}) {
|
|
|
543
533
|
let workspaceLine = '';
|
|
544
534
|
if (workspaceManifest && workspaceManifest.projects.length > 0) {
|
|
545
535
|
const gitCount = workspaceManifest.projects.filter(p => p.type === 'git' && p.gitRemote).length;
|
|
546
|
-
const bundleCount = workspaceManifest.projects.filter(p => p.bundleFile).length;
|
|
536
|
+
const bundleCount = workspaceManifest.projects.filter(p => p.bundleFile || p.type === 'files').length;
|
|
547
537
|
const parts = [];
|
|
548
538
|
if (gitCount > 0) parts.push(`${gitCount} git`);
|
|
549
539
|
if (bundleCount > 0) parts.push(`${bundleCount} bundled`);
|
|
@@ -567,11 +557,16 @@ export async function pushCommand(options = {}) {
|
|
|
567
557
|
}
|
|
568
558
|
} catch (error) {
|
|
569
559
|
spinner.fail(chalk.red('Sync failed: ') + error.message);
|
|
560
|
+
throw error;
|
|
570
561
|
} finally {
|
|
562
|
+
if (remotePlainDir) await fs.remove(remotePlainDir).catch(() => {});
|
|
571
563
|
await fs.remove(stagingDir);
|
|
572
564
|
// Clean up encrypted dir if it was created
|
|
573
565
|
if (encryptedDir) {
|
|
574
566
|
await fs.remove(encryptedDir).catch(() => {});
|
|
575
567
|
}
|
|
568
|
+
if (remoteCloneDir) {
|
|
569
|
+
await fs.remove(remoteCloneDir).catch(() => {});
|
|
570
|
+
}
|
|
576
571
|
}
|
|
577
572
|
}
|
package/src/commands/recall.js
CHANGED
|
@@ -15,7 +15,7 @@ export async function recallCommand(query, options = {}) {
|
|
|
15
15
|
}
|
|
16
16
|
const limit = Math.max(1, Math.min(50, parseInt(options.limit, 10) || 10));
|
|
17
17
|
const t0 = Date.now();
|
|
18
|
-
const res = await searchMemories(q, { limit });
|
|
18
|
+
const res = await searchMemories(q, { limit, project: options.project });
|
|
19
19
|
const ms = Date.now() - t0;
|
|
20
20
|
|
|
21
21
|
if (options.json) {
|