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
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import crypto from 'crypto';
|
|
5
|
+
import { parseFrontmatter } from '../commands/validate.js';
|
|
6
|
+
import { memoryFilename, readSafeFile, writeSafeFile, listSafeFiles, safePath, MAX_FILE_BYTES } from '../security/files.js';
|
|
7
|
+
import { withSessionLock } from '../session/lock.js';
|
|
8
|
+
import { projectIdentity } from './scope.js';
|
|
9
|
+
|
|
10
|
+
export const memoryRoot = path.join(os.homedir(), '.config', 'memoir', 'memories');
|
|
11
|
+
|
|
12
|
+
export async function rememberMemory({ filename, content, project, scope = 'project', tool = 'memoir', aliases = [], tags = [] }) {
|
|
13
|
+
const name = memoryFilename(filename);
|
|
14
|
+
if (Buffer.byteLength(content) > MAX_FILE_BYTES) throw new Error('Memory exceeds the size limit');
|
|
15
|
+
const projectId = scope === 'shared' ? 'shared' : projectIdentity(project);
|
|
16
|
+
const id = crypto.createHash('sha256').update(projectId + '\0' + name).digest('hex');
|
|
17
|
+
return withSessionLock(path.join(memoryRoot, '.write.lock'), async () => {
|
|
18
|
+
const rel = id + '.md';
|
|
19
|
+
let prior = null;
|
|
20
|
+
try { prior = parseFrontmatter((await readSafeFile(memoryRoot, rel)).toString('utf8')); }
|
|
21
|
+
catch (err) { if (err.code !== 'ENOENT') throw err; }
|
|
22
|
+
if (prior?.fields.hidden === true) throw new Error('This memory identity was forgotten. Choose a new filename for a new memory.');
|
|
23
|
+
const parsed = parseFrontmatter(content);
|
|
24
|
+
if (parsed.error) throw new Error(parsed.error);
|
|
25
|
+
const now = new Date().toISOString();
|
|
26
|
+
const fields = {
|
|
27
|
+
...parsed.fields,
|
|
28
|
+
id, name: parsed.fields.name || name.replace(/\.md$/, ''),
|
|
29
|
+
type: parsed.fields.type || 'fact',
|
|
30
|
+
project: projectId, source_tool: tool, updated: now,
|
|
31
|
+
created: prior?.fields.created || now,
|
|
32
|
+
revision: Number(prior?.fields.revision || 0) + 1,
|
|
33
|
+
verification: 'unverified',
|
|
34
|
+
aliases: [...new Set([...(Array.isArray(parsed.fields.aliases) ? parsed.fields.aliases : []), ...aliases])],
|
|
35
|
+
tags: [...new Set([...(Array.isArray(parsed.fields.tags) ? parsed.fields.tags : []), ...tags])],
|
|
36
|
+
};
|
|
37
|
+
const lines = ['---'];
|
|
38
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
39
|
+
if (!/^[a-z][a-z0-9_]*$/i.test(key)) continue;
|
|
40
|
+
if (Array.isArray(value)) lines.push(key + ':', ...value.map(v => ' - ' + JSON.stringify(String(v))));
|
|
41
|
+
else if (value != null && typeof value !== 'object') lines.push(key + ': ' + JSON.stringify(value));
|
|
42
|
+
}
|
|
43
|
+
const rendered = lines.concat(['---', parsed.body]).join('\n');
|
|
44
|
+
// Keep previous revisions separate from the searchable current record.
|
|
45
|
+
if (prior) await writeSafeFile(memoryRoot, 'history/' + id + '/' + crypto.randomUUID() + '.md', await readSafeFile(memoryRoot, rel));
|
|
46
|
+
await writeSafeFile(memoryRoot, rel, rendered);
|
|
47
|
+
return { id, revision: fields.revision, project: projectId, path: rel };
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function readStoredMemories() {
|
|
52
|
+
if (!await fs.pathExists(memoryRoot)) return [];
|
|
53
|
+
const docs = [];
|
|
54
|
+
for (const entry of await fs.readdir(memoryRoot, { withFileTypes: true })) {
|
|
55
|
+
if (!entry.isFile() || !/^[a-f0-9]{64}\.md$/.test(entry.name)) continue;
|
|
56
|
+
docs.push({ path: entry.name, absPath: path.join(memoryRoot, entry.name), content: (await readSafeFile(memoryRoot, entry.name)).toString('utf8'), tool: 'Memoir' });
|
|
57
|
+
}
|
|
58
|
+
return docs;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function stageMemories(dest) {
|
|
62
|
+
if (!await fs.pathExists(memoryRoot)) return 0;
|
|
63
|
+
let count = 0;
|
|
64
|
+
for (const rel of await listSafeFiles(memoryRoot)) {
|
|
65
|
+
if (rel.endsWith('.lock') || rel.includes('.memoir-write-')) continue;
|
|
66
|
+
await writeSafeFile(dest, 'memoir-memories/' + rel, await readSafeFile(memoryRoot, rel));
|
|
67
|
+
count++;
|
|
68
|
+
}
|
|
69
|
+
return count;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function restoreStoredMemories(source) {
|
|
73
|
+
const dir = path.join(source, 'memoir-memories');
|
|
74
|
+
if (!await fs.pathExists(dir)) return 0;
|
|
75
|
+
return withSessionLock(path.join(memoryRoot, '.write.lock'), async () => {
|
|
76
|
+
const entries = [], purged = new Set();
|
|
77
|
+
// Parse and validate the entire incoming set before modifying records.
|
|
78
|
+
for (const rel of await listSafeFiles(dir)) {
|
|
79
|
+
if (!/^(?:[a-f0-9]{64}\.md|history\/[a-f0-9]{64}\/[a-f0-9-]+\.md)$/.test(rel)) throw new Error('Invalid canonical memory path');
|
|
80
|
+
const id = rel.startsWith('history/') ? rel.split('/')[1] : rel.slice(0, -3);
|
|
81
|
+
const incoming = await readSafeFile(dir, rel);
|
|
82
|
+
const parsed = parseFrontmatter(incoming.toString());
|
|
83
|
+
if (parsed.error || parsed.fields.id !== id || !/^(?:shared|(?:git|local):[a-f0-9]{32})$/.test(parsed.fields.project || '')) throw new Error('Invalid canonical memory record');
|
|
84
|
+
await safePath(memoryRoot, rel);
|
|
85
|
+
let local;
|
|
86
|
+
try { local = await readSafeFile(memoryRoot, rel); } catch (err) { if (err.code !== 'ENOENT') throw err; }
|
|
87
|
+
if (!rel.startsWith('history/') && (parsed.fields.purged === true || (local && parseFrontmatter(local.toString()).fields.purged === true))) purged.add(id);
|
|
88
|
+
entries.push({ rel, id, incoming, parsed, local });
|
|
89
|
+
}
|
|
90
|
+
// A stale backup might contain history without its current record.
|
|
91
|
+
for (const entry of await readStoredMemories()) {
|
|
92
|
+
const fields = parseFrontmatter(entry.content).fields;
|
|
93
|
+
if (fields.purged === true) purged.add(fields.id);
|
|
94
|
+
}
|
|
95
|
+
let count = 0;
|
|
96
|
+
for (const { rel, id, incoming, parsed, local } of entries) {
|
|
97
|
+
if (rel.startsWith('history/') && purged.has(id)) continue;
|
|
98
|
+
if (local && !local.equals(incoming) && !rel.startsWith('history/')) {
|
|
99
|
+
const a = parseFrontmatter(local.toString()).fields, b = parsed.fields;
|
|
100
|
+
const rank = fields => fields.purged === true ? 2 : fields.hidden === true ? 1 : 0;
|
|
101
|
+
const digest = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
|
|
102
|
+
const incomingWins = rank(b) > rank(a) || (rank(b) === rank(a) &&
|
|
103
|
+
(String(b.updated || '') > String(a.updated || '') ||
|
|
104
|
+
(String(b.updated || '') === String(a.updated || '') && digest(incoming) > digest(local))));
|
|
105
|
+
if (!purged.has(id)) await writeSafeFile(memoryRoot, 'history/' + id + '/' + crypto.randomUUID() + '.md', incomingWins ? local : incoming);
|
|
106
|
+
if (!incomingWins) continue;
|
|
107
|
+
}
|
|
108
|
+
await writeSafeFile(memoryRoot, rel, incoming);
|
|
109
|
+
count++;
|
|
110
|
+
}
|
|
111
|
+
for (const id of purged) await fs.remove(path.join(memoryRoot, 'history', id));
|
|
112
|
+
return count;
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Hiding is durable and syncable. Purge affects this device's current record
|
|
117
|
+
// and its local revision history, not older remote snapshots or Git history.
|
|
118
|
+
export async function forgetStoredMemory(id, { purge = false, project } = {}) {
|
|
119
|
+
if (!/^[a-f0-9]{64}$/.test(id)) throw new Error('Invalid memory ID');
|
|
120
|
+
return withSessionLock(path.join(memoryRoot, '.write.lock'), async () => {
|
|
121
|
+
const rel = id + '.md';
|
|
122
|
+
const raw = await readSafeFile(memoryRoot, rel);
|
|
123
|
+
const { fields, body } = parseFrontmatter(raw.toString());
|
|
124
|
+
const { visibleMemory } = await import('./scope.js');
|
|
125
|
+
if (!visibleMemory(fields, { project })) throw new Error('Memory is hidden or outside this project');
|
|
126
|
+
const metadata = purge ? { id, project: fields.project, type: fields.type || 'fact' } : fields;
|
|
127
|
+
metadata.hidden = true;
|
|
128
|
+
if (purge) metadata.purged = true;
|
|
129
|
+
metadata.hidden_at = new Date().toISOString();
|
|
130
|
+
metadata.updated = metadata.hidden_at;
|
|
131
|
+
metadata.revision = Number(fields.revision || 0) + 1;
|
|
132
|
+
const lines = ['---'];
|
|
133
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
134
|
+
if (Array.isArray(value)) lines.push(key + ':', ...value.map(v => ' - ' + JSON.stringify(v)));
|
|
135
|
+
else if (value != null && typeof value !== 'object') lines.push(key + ': ' + JSON.stringify(value));
|
|
136
|
+
}
|
|
137
|
+
await writeSafeFile(memoryRoot, rel, lines.concat(['---', purge ? '[purged]' : body]).join('\n'));
|
|
138
|
+
if (purge) await fs.remove(path.join(memoryRoot, 'history', id));
|
|
139
|
+
return { id, hidden: true, purged: purge };
|
|
140
|
+
});
|
|
141
|
+
}
|
package/src/providers/index.js
CHANGED
|
@@ -4,6 +4,8 @@ import os from 'os';
|
|
|
4
4
|
import chalk from 'chalk';
|
|
5
5
|
import { execFileSync } from 'child_process';
|
|
6
6
|
import { appendEvent } from '../events/log.js';
|
|
7
|
+
import { listSafeFiles, readSafeFile, writeSafeFile, restoreFileSet } from '../security/files.js';
|
|
8
|
+
import { withSessionLock } from '../session/lock.js';
|
|
7
9
|
|
|
8
10
|
function sanitizeUrl(url) {
|
|
9
11
|
// Reject URLs with shell metacharacters
|
|
@@ -13,99 +15,228 @@ function sanitizeUrl(url) {
|
|
|
13
15
|
return url;
|
|
14
16
|
}
|
|
15
17
|
|
|
16
|
-
export async function
|
|
17
|
-
const
|
|
18
|
-
|
|
18
|
+
export async function withLocalBackupLock(config, fn) {
|
|
19
|
+
const dest = path.resolve(config.localPath.replace(/^~/, os.homedir()));
|
|
20
|
+
return withSessionLock(dest + '.memoir-lock', fn, { maxWaitMs: 5000 });
|
|
21
|
+
}
|
|
19
22
|
|
|
20
|
-
|
|
23
|
+
export async function syncToLocal(config, stagingDir, spinner, options = {}) {
|
|
24
|
+
if (!config.localPath) throw new Error('Local path is not configured.');
|
|
25
|
+
const dest = path.resolve(config.localPath.replace(/^~/, os.homedir()));
|
|
26
|
+
if (dest === path.parse(dest).root || dest === path.resolve(os.homedir()) || path.resolve(stagingDir).startsWith(dest + path.sep)) throw new Error('Choose a dedicated backup directory');
|
|
27
|
+
const sync = async () => {
|
|
28
|
+
const stat = await fs.lstat(dest).catch(err => { if (err.code !== 'ENOENT') throw err; return null; });
|
|
29
|
+
if (stat?.isSymbolicLink()) throw new Error('Backup destination must not be a symlink');
|
|
30
|
+
const encrypted = await fs.pathExists(path.join(stagingDir, 'manifest.enc'));
|
|
31
|
+
const exists = await fs.pathExists(dest);
|
|
32
|
+
const wasEncrypted = exists && await fs.pathExists(path.join(dest, 'manifest.enc'));
|
|
33
|
+
if (encrypted) {
|
|
34
|
+
if (exists && !wasEncrypted && (await fs.readdir(dest)).length && !options.verifiedReplacement) throw new Error('Use memoir push to verify and migrate this plaintext backup before replacing it.');
|
|
35
|
+
const parent = path.dirname(dest);
|
|
36
|
+
await fs.ensureDir(parent);
|
|
37
|
+
const next = await fs.mkdtemp(path.join(parent, '.memoir-sync-'));
|
|
38
|
+
const old = next + '-previous';
|
|
39
|
+
try {
|
|
40
|
+
await listSafeFiles(stagingDir);
|
|
41
|
+
await fs.copy(stagingDir, next);
|
|
42
|
+
if (exists) await fs.rename(dest, old);
|
|
43
|
+
try { await fs.rename(next, dest); }
|
|
44
|
+
catch (err) { if (exists) await fs.rename(old, dest); throw err; }
|
|
45
|
+
if (exists) await fs.remove(old);
|
|
46
|
+
} finally { await fs.remove(next).catch(() => {}); }
|
|
47
|
+
} else {
|
|
48
|
+
if (wasEncrypted) throw new Error('Cannot append plaintext to an encrypted backup. Use memoir push with encryption enabled.');
|
|
49
|
+
const files = [];
|
|
50
|
+
for (const rel of await listSafeFiles(stagingDir)) files.push({ path: rel, content: await readSafeFile(stagingDir, rel) });
|
|
51
|
+
await restoreFileSet(dest, files);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
if (options.lockHeld) await sync();
|
|
55
|
+
else await withLocalBackupLock(config, sync);
|
|
56
|
+
spinner.succeed(chalk.green('Sync complete! ') + chalk.gray('(Saved to ' + dest + ')'));
|
|
57
|
+
await appendEvent('sync_pushed', { provider: 'local' });
|
|
58
|
+
}
|
|
21
59
|
|
|
22
|
-
|
|
23
|
-
|
|
60
|
+
// ── Git sync ─────────────────────────────────────────────────────
|
|
61
|
+
//
|
|
62
|
+
// One cheap clone per push. `--depth 1 --filter=blob:none --no-checkout`
|
|
63
|
+
// fetches commits and trees only — no file contents, no working tree. The
|
|
64
|
+
// remote of the author's own store is 720MB with ~40MB of workspace bundles
|
|
65
|
+
// at HEAD; the old full shallow clone, done TWICE per push (the merge peek in
|
|
66
|
+
// push.js and then this function), pulled all of it through on every Stop
|
|
67
|
+
// hook and a quarter of pushes died on the 60s timeout (183 sync_failed in
|
|
68
|
+
// 15 days, none with a reason). Blobs are now fetched on demand only for the
|
|
69
|
+
// files a caller actually reads (`git checkout HEAD -- session.json`).
|
|
70
|
+
// Remotes that don't support filters (plain file:// paths, old hosts) print
|
|
71
|
+
// a warning and fall back to a normal shallow clone — never worse than before.
|
|
72
|
+
//
|
|
73
|
+
// Mirror semantics are unchanged and come for free: after a --no-checkout
|
|
74
|
+
// clone the index is empty and every remote file is a staged deletion until
|
|
75
|
+
// something re-adds it, so `copy staging → git add -A` yields exactly
|
|
76
|
+
// "staging + the files the caller asked to preserve".
|
|
77
|
+
|
|
78
|
+
export const CLONE_TIMEOUT_MS = 60000;
|
|
79
|
+
const GIT_QUIET = ['ignore', 'ignore', 'pipe']; // stderr kept for classifyGitError
|
|
80
|
+
|
|
81
|
+
export function cloneForSync(repoUrl, dir, { timeout = CLONE_TIMEOUT_MS } = {}) {
|
|
82
|
+
execFileSync('git', ['clone', '--depth', '1', '--filter=blob:none', '--no-checkout', repoUrl, '.'], {
|
|
83
|
+
cwd: dir, stdio: GIT_QUIET, timeout,
|
|
84
|
+
});
|
|
85
|
+
// Memoir writes main; read that same branch even when remote HEAD is master.
|
|
86
|
+
const main = execFileSync('git', ['ls-remote', '--heads', 'origin', 'refs/heads/main'], {
|
|
87
|
+
cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout,
|
|
88
|
+
}).trim();
|
|
89
|
+
if (main) {
|
|
90
|
+
execFileSync('git', ['fetch', '--depth', '1', 'origin', 'refs/heads/main'], { cwd: dir, stdio: GIT_QUIET, timeout });
|
|
91
|
+
execFileSync('git', ['update-ref', 'refs/heads/main', 'FETCH_HEAD'], { cwd: dir, stdio: GIT_QUIET, timeout });
|
|
92
|
+
execFileSync('git', ['symbolic-ref', 'HEAD', 'refs/heads/main'], { cwd: dir, stdio: GIT_QUIET, timeout });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
24
95
|
|
|
25
|
-
|
|
96
|
+
// Does the remote HEAD carry `file`? Reads the tree only — no blob fetch.
|
|
97
|
+
export function remoteHasFile(dir, file) {
|
|
98
|
+
try {
|
|
99
|
+
const out = execFileSync('git', ['ls-tree', '--name-only', 'HEAD', '--', file], {
|
|
100
|
+
cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10000,
|
|
101
|
+
});
|
|
102
|
+
return out.trim().length > 0;
|
|
103
|
+
} catch {
|
|
104
|
+
return false; // unborn HEAD (empty remote) or not a repo
|
|
105
|
+
}
|
|
106
|
+
}
|
|
26
107
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
// previous push's data/*.enc behind forever and localPath grows without
|
|
30
|
-
// bound. Only runs for a full encrypted sync (manifest.enc present in what
|
|
31
|
-
// we just wrote) — `memoir snapshot` also calls syncToLocal with a staging
|
|
32
|
-
// dir of a single handoff file, and blanket-emptying the destination there
|
|
33
|
-
// would delete the user's backup.
|
|
108
|
+
// Materialise one remote file into the working tree (fetches its blob).
|
|
109
|
+
export function checkoutFromRemote(dir, file) {
|
|
34
110
|
try {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (await fs.pathExists(stagedManifest) && await fs.pathExists(destData)) {
|
|
38
|
-
const keep = new Set(await fs.readdir(path.join(stagingDir, 'data')).catch(() => []));
|
|
39
|
-
for (const f of await fs.readdir(destData)) {
|
|
40
|
-
if (!keep.has(f)) await fs.remove(path.join(destData, f)).catch(() => {});
|
|
41
|
-
}
|
|
42
|
-
}
|
|
111
|
+
execFileSync('git', ['checkout', 'HEAD', '--', file], { cwd: dir, stdio: 'ignore', timeout: 30000 });
|
|
112
|
+
return true;
|
|
43
113
|
} catch {
|
|
44
|
-
|
|
114
|
+
return false;
|
|
45
115
|
}
|
|
116
|
+
}
|
|
46
117
|
|
|
47
|
-
|
|
48
|
-
|
|
118
|
+
// A short enum for the event log — never the raw stderr (it can carry the
|
|
119
|
+
// repo URL, a username, a local path). Enough to tell "the network is down"
|
|
120
|
+
// from "two pushes raced" from "the token expired" when reading events.jsonl.
|
|
121
|
+
export function classifyGitError(err) {
|
|
122
|
+
const text = `${err?.message || ''}\n${err?.stderr || ''}`.toLowerCase();
|
|
123
|
+
if (err?.code === 'ETIMEDOUT' || err?.signal === 'SIGTERM' || /timed? ?out/.test(text)) return 'timeout';
|
|
124
|
+
if (/non-fast-forward|fetch first|\[rejected\]/.test(text)) return 'non_fast_forward';
|
|
125
|
+
// Match HTTP diagnostics, not a coincidental 403 in a path or timestamp.
|
|
126
|
+
if (/authentication failed|could not read username|could not read password|permission denied|terminal prompts disabled|invalid credentials|requested url returned error: 403\b|http(?:\/[\d.]+)?(?: error| status(?: code)?)?[: ]+403\b/.test(text)) return 'auth';
|
|
127
|
+
if (/could not resolve host|unable to access|connection (?:refused|reset|timed)|network is unreachable|early eof|remote end hung up/.test(text)) return 'network';
|
|
128
|
+
if (/repository not found|does not appear to be a git repository|does not exist|no such file/.test(text)) return 'not_found';
|
|
129
|
+
if (/invalid characters/.test(text)) return 'bad_url';
|
|
130
|
+
return 'unknown';
|
|
49
131
|
}
|
|
50
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Push `stagingDir` to the git remote as the new HEAD:main.
|
|
135
|
+
*
|
|
136
|
+
* options:
|
|
137
|
+
* cloneDir — a directory already prepared by cloneForSync (push.js's merge
|
|
138
|
+
* peek). Reused instead of cloning again; removed when done.
|
|
139
|
+
* preserve — remote files to keep even though staging lacks them
|
|
140
|
+
* (an unreadable session.json the caller declined to overwrite).
|
|
141
|
+
* additive — overlay staging onto the remote tree instead of mirroring it
|
|
142
|
+
* (`memoir snapshot` uploads ONE handoff file; mirroring would
|
|
143
|
+
* have wiped every other file in the backup).
|
|
144
|
+
*/
|
|
51
145
|
export async function syncToGit(config, stagingDir, spinner, options = {}) {
|
|
52
146
|
const repoUrl = sanitizeUrl(config.gitRepo);
|
|
53
147
|
if (!repoUrl) throw new Error('Git repository is not configured.');
|
|
54
148
|
|
|
55
149
|
spinner.text = `Authenticating and syncing with Git remote: ${chalk.cyan(repoUrl)}`;
|
|
56
150
|
|
|
57
|
-
const
|
|
151
|
+
const reuse = Boolean(options.cloneDir) && await fs.pathExists(path.join(options.cloneDir, '.git'));
|
|
152
|
+
const gitDir = reuse ? options.cloneDir : path.join(os.tmpdir(), `memoir-git-${Date.now()}`);
|
|
58
153
|
await fs.ensureDir(gitDir);
|
|
154
|
+
const started = Date.now();
|
|
59
155
|
|
|
60
156
|
try {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
|
|
157
|
+
if (!reuse) {
|
|
158
|
+
try {
|
|
159
|
+
cloneForSync(repoUrl, gitDir);
|
|
160
|
+
} catch (err) {
|
|
161
|
+
// Cloning an EMPTY remote succeeds (git warns and continues), so a
|
|
162
|
+
// failure here is a real one. Unreachable / unauthorised / timed out
|
|
163
|
+
// must surface as such — the old unconditional `git init` fallback
|
|
164
|
+
// turned every one of them into a later, misleading
|
|
165
|
+
// "non-fast-forward" or "credentials" failure. Only a not_found /
|
|
166
|
+
// unknown failure (a host that refuses to clone an empty repo) still
|
|
167
|
+
// falls back to init so a first push can create the history.
|
|
168
|
+
const reason = classifyGitError(err);
|
|
169
|
+
if (reason === 'timeout' || reason === 'auth' || reason === 'network') throw err;
|
|
170
|
+
execFileSync('git', ['init'], { cwd: gitDir, stdio: 'ignore' });
|
|
171
|
+
execFileSync('git', ['branch', '-m', 'main'], { cwd: gitDir, stdio: 'ignore' });
|
|
70
172
|
}
|
|
71
|
-
} catch {
|
|
72
|
-
execFileSync('git', ['init'], { cwd: gitDir, stdio: 'ignore' });
|
|
73
|
-
execFileSync('git', ['branch', '-m', 'main'], { cwd: gitDir, stdio: 'ignore' });
|
|
74
173
|
}
|
|
75
174
|
|
|
76
|
-
|
|
175
|
+
// Files the caller wants kept although staging lacks them: check them
|
|
176
|
+
// out (one blob each) so `git add -A` below sees them in the tree.
|
|
177
|
+
const preserved = [];
|
|
178
|
+
for (const p of options.preserve || []) {
|
|
179
|
+
if (!checkoutFromRemote(gitDir, p)) throw new Error('Could not preserve remote file');
|
|
180
|
+
preserved.push({ path: p, content: await readSafeFile(gitDir, p) });
|
|
181
|
+
}
|
|
182
|
+
if (!options.additive) {
|
|
183
|
+
// The merge peek may have materialized plaintext. A complete encrypted
|
|
184
|
+
// replacement must remove it from the current tree before adding blobs.
|
|
185
|
+
for (const entry of await fs.readdir(gitDir)) {
|
|
186
|
+
if (entry !== '.git') await fs.remove(path.join(gitDir, entry));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
77
189
|
|
|
78
|
-
|
|
190
|
+
|
|
191
|
+
if (options.additive) {
|
|
192
|
+
// Populate the index from HEAD (trees only, no blobs) so everything
|
|
193
|
+
// the remote already has stays in the commit; then add ONLY what
|
|
194
|
+
// staging brought. `git add -A` over an empty working tree would
|
|
195
|
+
// stage every other remote file as deleted.
|
|
196
|
+
try { execFileSync('git', ['read-tree', 'HEAD'], { cwd: gitDir, stdio: 'ignore', timeout: 30000 }); } catch {}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const stagedFiles = await listSafeFiles(stagingDir);
|
|
200
|
+
await fs.copy(stagingDir, gitDir);
|
|
201
|
+
for (const entry of preserved) await writeSafeFile(gitDir, entry.path, entry.content);
|
|
202
|
+
|
|
203
|
+
if (options.additive) {
|
|
204
|
+
if (stagedFiles.length) execFileSync('git', ['add', '--pathspec-from-file=-', '--pathspec-file-nul'], {
|
|
205
|
+
cwd: gitDir, input: Buffer.from(stagedFiles.join('\0') + '\0'), stdio: ['pipe', 'ignore', 'pipe'], timeout: 30000,
|
|
206
|
+
});
|
|
207
|
+
} else {
|
|
208
|
+
execFileSync('git', ['add', '-A'], { cwd: gitDir, stdio: 'ignore', timeout: 30000 });
|
|
209
|
+
}
|
|
79
210
|
execFileSync('git', ['config', 'user.name', 'memoir'], { cwd: gitDir, stdio: 'ignore', timeout: 5000 });
|
|
80
211
|
execFileSync('git', ['config', 'user.email', 'bot@memoir.dev'], { cwd: gitDir, stdio: 'ignore', timeout: 5000 });
|
|
81
212
|
|
|
82
213
|
const timestamp = new Date().toISOString().split('T')[0];
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
} catch {
|
|
214
|
+
const changed = execFileSync('git', ['diff', '--cached', '--name-only'], { cwd: gitDir, encoding: 'utf8', timeout: 30000 });
|
|
215
|
+
if (!changed.trim()) {
|
|
86
216
|
spinner.succeed(chalk.green('Already up to date! ') + chalk.gray('No changes to push.'));
|
|
87
217
|
return;
|
|
88
218
|
}
|
|
219
|
+
execFileSync('git', ['commit', '-q', '-m', 'memoir backup ' + timestamp], { cwd: gitDir, stdio: GIT_QUIET, timeout: 30000 });
|
|
89
220
|
|
|
90
221
|
spinner.text = `Pushing data to ${chalk.cyan(repoUrl)}...`;
|
|
91
222
|
// HEAD:main pushes whatever branch the clone checked out (a master-
|
|
92
223
|
// default remote used to make `push main` fail silently under autopush
|
|
93
224
|
// with a misleading credentials error, while doctor reported green).
|
|
94
|
-
execFileSync('git', ['push', repoUrl, 'HEAD:main'], { cwd: gitDir, stdio:
|
|
225
|
+
execFileSync('git', ['push', '-q', repoUrl, 'HEAD:main'], { cwd: gitDir, stdio: GIT_QUIET, timeout: 120000 });
|
|
95
226
|
|
|
96
227
|
spinner.succeed(chalk.green('Sync complete! ') + chalk.gray('(Uploaded securely to GitHub)'));
|
|
97
|
-
await appendEvent('sync_pushed', { provider: 'git' });
|
|
228
|
+
await appendEvent('sync_pushed', { provider: 'git', ms: Date.now() - started, reused_clone: reuse });
|
|
98
229
|
} catch (err) {
|
|
99
230
|
// Makes a silently-swallowed push failure (a non-fast-forward rejection
|
|
100
231
|
// from two racing pushes, a network error, bad credentials, etc.)
|
|
101
232
|
// visible in the event log instead of vanishing into the detached
|
|
102
|
-
// autopush child's ignored stdio.
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
await appendEvent('sync_failed', { provider: 'git' });
|
|
233
|
+
// autopush child's ignored stdio. `reason` is a short enum, never the
|
|
234
|
+
// raw error text/repo URL — those can contain usernames/paths.
|
|
235
|
+
const reason = classifyGitError(err);
|
|
236
|
+
await appendEvent('sync_failed', { provider: 'git', reason, ms: Date.now() - started });
|
|
106
237
|
if (err.message.includes('invalid characters')) throw err;
|
|
107
|
-
throw new Error(
|
|
238
|
+
throw new Error(`Failed to push to git repository (${reason}). Ensure your credentials are configured and the repository exists.`);
|
|
108
239
|
} finally {
|
|
109
|
-
await fs.remove(gitDir);
|
|
240
|
+
await fs.remove(gitDir).catch(() => {});
|
|
110
241
|
}
|
|
111
242
|
}
|
package/src/providers/restore.js
CHANGED
|
@@ -3,6 +3,7 @@ import fs from 'fs-extra';
|
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import os from 'os';
|
|
5
5
|
import { execFileSync } from 'child_process';
|
|
6
|
+
import { cloneForSync, checkoutFromRemote } from './index.js';
|
|
6
7
|
import { restoreMemories } from '../adapters/restore.js';
|
|
7
8
|
|
|
8
9
|
export async function fetchFromLocal(config, stagingDir, spinner, onlyFilter = null, autoYes = false) {
|
|
@@ -18,6 +19,7 @@ export async function fetchFromLocal(config, stagingDir, spinner, onlyFilter = n
|
|
|
18
19
|
spinner.text = `Fetching data from local directory: ${chalk.cyan(resolvedSource)}`;
|
|
19
20
|
await fs.copy(resolvedSource, stagingDir);
|
|
20
21
|
|
|
22
|
+
if (await fs.pathExists(path.join(stagingDir, 'manifest.enc'))) return false;
|
|
21
23
|
return await restoreMemories(stagingDir, spinner, onlyFilter, autoYes);
|
|
22
24
|
}
|
|
23
25
|
|
|
@@ -28,10 +30,12 @@ export async function fetchFromGit(config, stagingDir, spinner, onlyFilter = nul
|
|
|
28
30
|
spinner.text = `Cloning memory from Git remote: ${chalk.cyan(repoUrl)}`;
|
|
29
31
|
|
|
30
32
|
try {
|
|
31
|
-
|
|
33
|
+
cloneForSync(repoUrl, stagingDir);
|
|
34
|
+
if (!checkoutFromRemote(stagingDir, '.')) throw new Error('Could not read backup main branch');
|
|
32
35
|
} catch (err) {
|
|
33
36
|
throw new Error('Failed to pull from git repository. Ensure your SSH keys are configured and the repository is accessible.');
|
|
34
37
|
}
|
|
35
38
|
|
|
39
|
+
if (await fs.pathExists(path.join(stagingDir, 'manifest.enc'))) return false;
|
|
36
40
|
return await restoreMemories(stagingDir, spinner, onlyFilter, autoYes);
|
|
37
41
|
}
|
|
@@ -2,6 +2,7 @@ import crypto from 'crypto';
|
|
|
2
2
|
import { promisify } from 'util';
|
|
3
3
|
import fs from 'fs-extra';
|
|
4
4
|
import path from 'path';
|
|
5
|
+
import { listSafeFiles, readSafeFile, restoreFileSet, relativeFile, MAX_SNAPSHOT_FILES, MAX_SNAPSHOT_BYTES, MAX_FILE_BYTES } from './files.js';
|
|
5
6
|
|
|
6
7
|
const scryptAsync = promisify(crypto.scrypt);
|
|
7
8
|
|
|
@@ -50,6 +51,7 @@ export async function encryptBuffer(plaintext, passphrase) {
|
|
|
50
51
|
* Decrypt a buffer. Throws on wrong passphrase or tampered data.
|
|
51
52
|
*/
|
|
52
53
|
export async function decryptBuffer(data, passphrase) {
|
|
54
|
+
if (!Buffer.isBuffer(data) || data.length < 68) throw new Error('Truncated encrypted file');
|
|
53
55
|
const magic = data.subarray(0, 8);
|
|
54
56
|
if (!magic.equals(MAGIC)) {
|
|
55
57
|
throw new Error('Not a memoir-encrypted file (bad header)');
|
|
@@ -88,23 +90,16 @@ export async function encryptDirectory(srcDir, destDir, passphrase, spinner = nu
|
|
|
88
90
|
// Phase 2: Index files
|
|
89
91
|
if (spinner) spinner.text = 'Indexing files...';
|
|
90
92
|
const fileList = [];
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (entry.isDirectory()) {
|
|
97
|
-
await index(fullPath, relPath);
|
|
98
|
-
} else {
|
|
99
|
-
const stat = await fs.stat(fullPath);
|
|
100
|
-
fileList.push({ fullPath, relPath, size: stat.size });
|
|
101
|
-
}
|
|
102
|
-
}
|
|
93
|
+
for (const relPath of await listSafeFiles(srcDir)) {
|
|
94
|
+
const fullPath = path.join(srcDir, relPath);
|
|
95
|
+
const stat = await fs.stat(fullPath);
|
|
96
|
+
if (stat.size > MAX_FILE_BYTES) throw new Error('Snapshot file size limit exceeded');
|
|
97
|
+
fileList.push({ fullPath, relPath, size: stat.size });
|
|
103
98
|
}
|
|
104
|
-
await index(srcDir);
|
|
105
99
|
|
|
106
100
|
const totalFiles = fileList.length;
|
|
107
101
|
const totalBytes = fileList.reduce((sum, f) => sum + f.size, 0);
|
|
102
|
+
if (totalBytes > MAX_SNAPSHOT_BYTES) throw new Error('Snapshot size limit exceeded');
|
|
108
103
|
|
|
109
104
|
// Phase 3: Encrypt files
|
|
110
105
|
const manifest = {};
|
|
@@ -118,9 +113,10 @@ export async function encryptDirectory(srcDir, destDir, passphrase, spinner = nu
|
|
|
118
113
|
.digest('hex')
|
|
119
114
|
.slice(0, 24);
|
|
120
115
|
|
|
121
|
-
const plaintext = await
|
|
116
|
+
const plaintext = await readSafeFile(srcDir, relPath);
|
|
122
117
|
const iv = crypto.randomBytes(IV_LENGTH);
|
|
123
118
|
const cipher = crypto.createCipheriv(ALGORITHM, key, iv, { authTagLength: TAG_LENGTH });
|
|
119
|
+
cipher.setAAD(Buffer.from(relPath, 'utf8'));
|
|
124
120
|
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
125
121
|
const tag = cipher.getAuthTag();
|
|
126
122
|
|
|
@@ -143,7 +139,7 @@ export async function encryptDirectory(srcDir, destDir, passphrase, spinner = nu
|
|
|
143
139
|
|
|
144
140
|
// Phase 4: Encrypt manifest
|
|
145
141
|
if (spinner) spinner.text = 'Encrypting file manifest...';
|
|
146
|
-
const manifestJson = Buffer.from(JSON.stringify(manifest));
|
|
142
|
+
const manifestJson = Buffer.from(JSON.stringify({ version: 2, files: manifest }));
|
|
147
143
|
const manifestEncrypted = await encryptBuffer(manifestJson, passphrase);
|
|
148
144
|
await fs.writeFile(path.join(destDir, 'manifest.enc'), manifestEncrypted);
|
|
149
145
|
|
|
@@ -166,53 +162,31 @@ function formatBytes(bytes) {
|
|
|
166
162
|
* Decrypt an encrypted directory back to plaintext.
|
|
167
163
|
*/
|
|
168
164
|
export async function decryptDirectory(encDir, destDir, passphrase, spinner = null) {
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
// Phase 2: Derive key
|
|
178
|
-
if (spinner) spinner.text = 'Deriving decryption key (scrypt)...';
|
|
179
|
-
const salt = await fs.readFile(path.join(encDir, 'salt'));
|
|
165
|
+
const manifestData = await readSafeFile(encDir, 'manifest.enc');
|
|
166
|
+
const decoded = JSON.parse((await decryptBuffer(manifestData, passphrase)).toString('utf8'));
|
|
167
|
+
const version = decoded.version === 2 ? 2 : 1;
|
|
168
|
+
const manifest = version === 2 ? decoded.files : decoded;
|
|
169
|
+
if (!manifest || Array.isArray(manifest) || typeof manifest !== 'object' || Object.keys(manifest).length > MAX_SNAPSHOT_FILES) throw new Error('Invalid encrypted manifest');
|
|
170
|
+
const salt = await readSafeFile(encDir, 'salt');
|
|
171
|
+
if (salt.length !== SALT_LENGTH) throw new Error('Invalid encryption salt');
|
|
180
172
|
const { key } = await deriveKey(passphrase, salt);
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
const ciphertext = data.subarray(IV_LENGTH + TAG_LENGTH);
|
|
196
|
-
|
|
197
|
-
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, { authTagLength: TAG_LENGTH });
|
|
198
|
-
decipher.setAuthTag(tag);
|
|
199
|
-
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
200
|
-
|
|
201
|
-
const outPath = path.join(destDir, relPath);
|
|
202
|
-
await fs.ensureDir(path.dirname(outPath));
|
|
203
|
-
await fs.writeFile(outPath, decrypted);
|
|
204
|
-
count++;
|
|
205
|
-
bytesProcessed += decrypted.length;
|
|
206
|
-
|
|
207
|
-
if (spinner) {
|
|
208
|
-
const pct = Math.round((count / totalFiles) * 100);
|
|
209
|
-
spinner.text = `Decrypting ${count}/${totalFiles} files — ${formatBytes(bytesProcessed)} [${pct}%]`;
|
|
210
|
-
}
|
|
173
|
+
const files = [];
|
|
174
|
+
let bytes = 0;
|
|
175
|
+
for (const [hashedName, storedPath] of Object.entries(manifest)) {
|
|
176
|
+
if (!/^[a-f0-9]{24}$/.test(hashedName)) throw new Error('Invalid encrypted blob name');
|
|
177
|
+
const relPath = relativeFile(storedPath);
|
|
178
|
+
const data = await readSafeFile(encDir, 'data/' + hashedName + '.enc', { maxBytes: MAX_FILE_BYTES + IV_LENGTH + TAG_LENGTH });
|
|
179
|
+
if (data.length < IV_LENGTH + TAG_LENGTH) throw new Error('Truncated encrypted blob');
|
|
180
|
+
const decipher = crypto.createDecipheriv(ALGORITHM, key, data.subarray(0, IV_LENGTH), { authTagLength: TAG_LENGTH });
|
|
181
|
+
if (version === 2) decipher.setAAD(Buffer.from(storedPath, 'utf8'));
|
|
182
|
+
decipher.setAuthTag(data.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH));
|
|
183
|
+
const content = Buffer.concat([decipher.update(data.subarray(IV_LENGTH + TAG_LENGTH)), decipher.final()]);
|
|
184
|
+
bytes += content.length;
|
|
185
|
+
if (bytes > MAX_SNAPSHOT_BYTES) throw new Error('Snapshot size limit exceeded');
|
|
186
|
+
files.push({ path: relPath, content });
|
|
211
187
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
if (spinner) spinner.text = `Decrypted ${count} files (${formatBytes(bytesProcessed)}) in ${elapsed}s`;
|
|
215
|
-
|
|
188
|
+
const count = await restoreFileSet(destDir, files);
|
|
189
|
+
if (spinner) spinner.text = 'Decrypted and verified ' + count + ' files (' + formatBytes(bytes) + ')';
|
|
216
190
|
return count;
|
|
217
191
|
}
|
|
218
192
|
|