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.
- package/README.md +129 -124
- package/bin/memoir-work.js +9 -0
- package/bin/memoir.js +72 -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 +255 -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/auth.js +12 -15
- package/src/cloud/constants.js +6 -2
- package/src/cloud/storage.js +130 -93
- package/src/commands/activate.js +43 -9
- package/src/commands/cloud.js +56 -5
- package/src/commands/consolidate.js +49 -10
- package/src/commands/diff.js +2 -2
- package/src/commands/doctor.js +3 -3
- package/src/commands/forget.js +100 -0
- package/src/commands/push.js +164 -161
- package/src/commands/recall.js +42 -0
- 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 +13 -11
- package/src/commands/validate.js +16 -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 +135 -33
- 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 +151 -283
- 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 +598 -0
- 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 +305 -34
- 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 +205 -0
- package/src/work/ui/index.html +30 -0
- package/src/work/ui/style.css +3 -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/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
|
|
@@ -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
|
-
|
|
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
|
|
253
|
-
|
|
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
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
//
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
//
|
|
373
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
+
}
|