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,99 @@
|
|
|
1
|
+
// Synthetic filesystem benchmark. No models, network, or real user memories.
|
|
2
|
+
// Run unchanged against a prior checkout with --repo to compare implementations.
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import crypto from 'node:crypto';
|
|
7
|
+
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
8
|
+
import { performance } from 'node:perf_hooks';
|
|
9
|
+
import { execFileSync } from 'node:child_process';
|
|
10
|
+
|
|
11
|
+
const args = process.argv.slice(2);
|
|
12
|
+
const option = (key, fallback) => { const i = args.indexOf('--' + key); return i < 0 ? fallback : args[i + 1]; };
|
|
13
|
+
const repo = path.resolve(option('repo', fileURLToPath(new URL('..', import.meta.url))));
|
|
14
|
+
const sizes = option('sizes', '1000,10000').split(',').map(Number);
|
|
15
|
+
const samples = Number(option('samples', '120'));
|
|
16
|
+
const kinds = option('kinds', 'adapter,canonical').split(',');
|
|
17
|
+
if (!sizes.every(n => Number.isInteger(n) && n > 0 && n <= 50000) || !Number.isInteger(samples) || samples < 2 || samples > 1000 || !kinds.every(k => ['adapter', 'canonical'].includes(k))) throw new Error('Invalid benchmark options');
|
|
18
|
+
const scratch = await fs.mkdtemp(path.join(os.tmpdir(), 'memoir-retrieval-bench-'));
|
|
19
|
+
const realHomedir = os.homedir;
|
|
20
|
+
// Only this process sees the fixture home; no system environment is repurposed.
|
|
21
|
+
os.homedir = () => scratch;
|
|
22
|
+
process.env.DO_NOT_TRACK = '1';
|
|
23
|
+
globalThis.fetch = async () => { throw new Error('Network prohibited in evaluation'); };
|
|
24
|
+
const fixtureVersion = 'coding-retrieval-v1';
|
|
25
|
+
const prose = [
|
|
26
|
+
'The migration must create the new table before updating the reader. Keep the old reader available until the verification passes.',
|
|
27
|
+
'The rejected approach used a timer to hide the race. A transaction with an idempotency key prevents duplicate work.',
|
|
28
|
+
'The last session tested rollback against a disposable local database. That result describes the saved checkout, not every later commit.',
|
|
29
|
+
'An unsuccessful retry reused the old session token. Fetch the current token and confirm the request belongs to this project.',
|
|
30
|
+
'Record evidence from the test output and keep the pending check separate from a verified result.',
|
|
31
|
+
'Résumé: vérifier la migration et conserver les preuves. 数据库迁移需要验证。認証の更新を確認する。',
|
|
32
|
+
].join('\n');
|
|
33
|
+
const queries = [
|
|
34
|
+
'amberkingfisher recovery', 'migration reader', 'transaction idempotency', 'rejected timer',
|
|
35
|
+
'rollback disposable', 'session token', 'verification evidence', 'migration vérifi',
|
|
36
|
+
'数据库迁移', '認証', 'zzunknownnothing', 'component42',
|
|
37
|
+
];
|
|
38
|
+
const results = [];
|
|
39
|
+
try {
|
|
40
|
+
const { adapters } = await import(pathToFileURL(path.join(repo, 'src/adapters/index.js')));
|
|
41
|
+
const { searchMemories, clearSearchCache } = await import(pathToFileURL(path.join(repo, 'src/memory/search.js')));
|
|
42
|
+
const { projectIdentity } = await import(pathToFileURL(path.join(repo, 'src/memory/scope.js')));
|
|
43
|
+
const { memoryRoot } = await import(pathToFileURL(path.join(repo, 'src/memory/store.js')));
|
|
44
|
+
const project = path.join(scratch, 'project');
|
|
45
|
+
await fs.mkdir(project);
|
|
46
|
+
const projectId = projectIdentity(project);
|
|
47
|
+
const source = path.join(scratch, 'adapter');
|
|
48
|
+
for (const kind of kinds) for (const count of sizes) {
|
|
49
|
+
await fs.rm(source, { recursive: true, force: true });
|
|
50
|
+
await fs.rm(memoryRoot, { recursive: true, force: true });
|
|
51
|
+
const dest = kind === 'canonical' ? memoryRoot : source;
|
|
52
|
+
await fs.mkdir(dest, { recursive: true });
|
|
53
|
+
adapters.splice(0, adapters.length, ...(kind === 'adapter' ? [{ name: 'Benchmark', source, filter: () => true }] : []));
|
|
54
|
+
const digest = crypto.createHash('sha256');
|
|
55
|
+
let bytes = 0;
|
|
56
|
+
for (let offset = 0; offset < count; offset += 32) {
|
|
57
|
+
const writes = [];
|
|
58
|
+
for (let i = offset; i < Math.min(count, offset + 32); i++) {
|
|
59
|
+
const id = crypto.createHash('sha256').update('record-' + i).digest('hex');
|
|
60
|
+
const filename = kind === 'canonical' ? id + '.md' : 'record-' + i + '.md';
|
|
61
|
+
const content = ['---', 'id: ' + id, 'name: component' + i, 'project: ' + projectId, 'type: decision', 'description: Component migration and recovery constraints', '---', '# Component ' + i, prose, i === count - 1 ? 'amberkingfisher recovery uses the verified snapshot.' : 'Routine component operations require a current smoke check.'].join('\n');
|
|
62
|
+
// Exclude machine-specific project IDs from the portable fixture hash.
|
|
63
|
+
digest.update(filename + '\0' + content.replace(projectId, '<project>'));
|
|
64
|
+
bytes += Buffer.byteLength(content);
|
|
65
|
+
writes.push(fs.writeFile(path.join(dest, filename), content));
|
|
66
|
+
}
|
|
67
|
+
await Promise.all(writes);
|
|
68
|
+
}
|
|
69
|
+
clearSearchCache();
|
|
70
|
+
const all = [];
|
|
71
|
+
for (let i = 0; i <= samples; i++) {
|
|
72
|
+
const query = i === 0 ? queries[0] : queries[(i - 1) % queries.length];
|
|
73
|
+
const start = performance.now();
|
|
74
|
+
const r = await searchMemories(query, { root: project, project, limit: 5, budget: 1800 });
|
|
75
|
+
const ms = performance.now() - start;
|
|
76
|
+
if (query === queries[0] && !r.results.some(x => x.passage.includes('verified snapshot'))) throw new Error('Required result missing');
|
|
77
|
+
if (query === 'zzunknownnothing' && r.results.length) throw new Error('No-evidence query returned a result');
|
|
78
|
+
all.push({ query, ms, total: r.total, results: r.results.length });
|
|
79
|
+
}
|
|
80
|
+
const warm = all.slice(1).map(r => r.ms).sort((a, b) => a - b);
|
|
81
|
+
const row = { kind, records: count, bytes, fixture_sha256: digest.digest('hex'), cold_ms: all[0].ms, warm_samples: samples, warm_median_ms: warm[Math.floor(warm.length / 2)], warm_p95_ms: warm[Math.ceil(warm.length * .95) - 1], rss_bytes: process.memoryUsage().rss, queries: all };
|
|
82
|
+
results.push(row);
|
|
83
|
+
console.error(JSON.stringify({ kind, count, cold_ms: row.cold_ms, median_ms: row.warm_median_ms, p95_ms: row.warm_p95_ms }));
|
|
84
|
+
}
|
|
85
|
+
let commit = null;
|
|
86
|
+
try { commit = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim(); } catch {}
|
|
87
|
+
const sourceFiles = {};
|
|
88
|
+
for (const relative of ['src/memory/search.js', 'src/memory/scope.js', 'src/memory/lexical-index.js', 'src/security/files.js']) {
|
|
89
|
+
try { sourceFiles[relative] = crypto.createHash('sha256').update(await fs.readFile(path.join(repo, relative))).digest('hex'); }
|
|
90
|
+
catch (err) { if (err.code !== 'ENOENT') throw err; sourceFiles[relative] = null; }
|
|
91
|
+
}
|
|
92
|
+
const report = { label: 'Synthetic retrieval latency; not a coding utility or SOTA evaluation', fixture_version: fixtureVersion, harness_sha256: crypto.createHash('sha256').update(await fs.readFile(fileURLToPath(import.meta.url))).digest('hex'), source_commit: commit, source_file_sha256: sourceFiles, measured_at: new Date().toISOString(), node: process.version, platform: process.platform, arch: process.arch, cpu: os.cpus()[0]?.model, protocol: 'In-process public search API, including filesystem discovery/validation, parsing, ranking, and passage generation. One cold query then a fixed mixed query cycle. OS file cache is not flushed. RSS is process-wide. Sources are synthetic. File hashes identify the measured implementation even if the checkout has uncommitted edits.', results };
|
|
93
|
+
const json = JSON.stringify(report, null, 2) + '\n';
|
|
94
|
+
if (option('output', null)) await fs.writeFile(path.resolve(option('output')), json);
|
|
95
|
+
else console.log(json);
|
|
96
|
+
} finally {
|
|
97
|
+
os.homedir = realHomedir;
|
|
98
|
+
await fs.rm(scratch, { recursive: true, force: true });
|
|
99
|
+
}
|
package/evals/run.mjs
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Development regression evaluation, not a held-out SOTA benchmark.
|
|
2
|
+
// All storage is synthetic; no model/network calls or user memory are used.
|
|
3
|
+
import fs from 'fs-extra';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import crypto from 'node:crypto';
|
|
7
|
+
import { performance } from 'node:perf_hooks';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
const fixturePath = fileURLToPath(new URL('./cases.json', import.meta.url));
|
|
11
|
+
const fixtureRaw = await fs.readFile(fixturePath);
|
|
12
|
+
const fixture = JSON.parse(fixtureRaw);
|
|
13
|
+
const scratch = await fs.mkdtemp(path.join(os.tmpdir(), 'memoir-evaluation-'));
|
|
14
|
+
process.env.HOME = scratch;
|
|
15
|
+
process.env.USERPROFILE = scratch;
|
|
16
|
+
process.env.APPDATA = path.join(scratch, 'AppData', 'Roaming');
|
|
17
|
+
process.env.DO_NOT_TRACK = '1';
|
|
18
|
+
process.env.CI = '1';
|
|
19
|
+
process.env.GIT_CONFIG_NOSYSTEM = '1';
|
|
20
|
+
process.env.GIT_CONFIG_GLOBAL = path.join(scratch, 'gitconfig');
|
|
21
|
+
globalThis.fetch = async () => { throw new Error('Network prohibited during evaluation'); };
|
|
22
|
+
try {
|
|
23
|
+
const projects = { alpha: path.join(scratch, 'alpha'), beta: path.join(scratch, 'beta') };
|
|
24
|
+
await Promise.all(Object.values(projects).map(dir => fs.ensureDir(dir)));
|
|
25
|
+
process.env.MEMOIR_PROJECT_ROOT = projects.alpha;
|
|
26
|
+
const { rememberMemory, readStoredMemories } = await import('../src/memory/store.js');
|
|
27
|
+
const { searchMemories } = await import('../src/memory/search.js');
|
|
28
|
+
const ids = new Map();
|
|
29
|
+
for (const record of fixture.records) {
|
|
30
|
+
const metadata = { ...(record.metadata || {}), name: record.key };
|
|
31
|
+
const content = ['---', ...Object.entries(metadata).map(([key, value]) => key + ': ' + JSON.stringify(value)), '---', record.text].join('\n');
|
|
32
|
+
const saved = await rememberMemory({
|
|
33
|
+
filename: record.key + '.md', content,
|
|
34
|
+
project: projects[record.project || 'alpha'],
|
|
35
|
+
scope: record.project === 'shared' ? 'shared' : 'project',
|
|
36
|
+
aliases: record.aliases || [],
|
|
37
|
+
});
|
|
38
|
+
ids.set(saved.id, record.key);
|
|
39
|
+
}
|
|
40
|
+
const raw = await readStoredMemories();
|
|
41
|
+
const rows = [];
|
|
42
|
+
for (const test of fixture.cases) {
|
|
43
|
+
const started = performance.now();
|
|
44
|
+
const output = await searchMemories(test.query, { project: projects[test.project || 'alpha'], limit: 5, budget: 1800 });
|
|
45
|
+
const milliseconds = performance.now() - started;
|
|
46
|
+
const reference = await searchMemories(test.query, { project: projects[test.project || 'alpha'], limit: 5, budget: 1800, engine: 'scan' });
|
|
47
|
+
const retrieved = output.results.map(r => ids.get(r.id)).filter(Boolean);
|
|
48
|
+
const substring = raw.filter(doc => test.query.trim() && doc.content.toLowerCase().includes(test.query.toLowerCase()))
|
|
49
|
+
.slice(0, 5).map(doc => ids.get(path.basename(doc.path, '.md')));
|
|
50
|
+
rows.push({ name: test.name, expected: test.expected, forbidden: test.forbidden || [], retrieved, substring, milliseconds,
|
|
51
|
+
scan_reference: reference.results.map(r => ids.get(r.id)).filter(Boolean),
|
|
52
|
+
matches_scan_output: JSON.stringify(output) === JSON.stringify(reference),
|
|
53
|
+
passage_characters: output.results.reduce((sum, r) => sum + r.passage.length, 0) });
|
|
54
|
+
}
|
|
55
|
+
const metrics = field => {
|
|
56
|
+
const positive = rows.filter(r => r.expected.length);
|
|
57
|
+
const hits = positive.map(r => r.expected.filter(id => r[field].includes(id)).length / r.expected.length);
|
|
58
|
+
const rr = positive.map(r => { const rank = r[field].findIndex(id => r.expected.includes(id)); return rank < 0 ? 0 : 1 / (rank + 1); });
|
|
59
|
+
const negatives = rows.filter(r => !r.expected.length);
|
|
60
|
+
return {
|
|
61
|
+
recall_at_5: hits.reduce((a,b) => a+b, 0) / positive.length,
|
|
62
|
+
mean_reciprocal_rank: rr.reduce((a,b) => a+b, 0) / positive.length,
|
|
63
|
+
correct_abstentions: negatives.filter(r => r[field].length === 0).length,
|
|
64
|
+
abstention_cases: negatives.length,
|
|
65
|
+
forbidden_results: rows.reduce((sum, r) => sum + r[field].filter(id => r.forbidden.includes(id)).length, 0),
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
const latencies = rows.map(r => r.milliseconds).sort((a,b) => a-b);
|
|
69
|
+
const report = {
|
|
70
|
+
label: 'Development fixture evaluation; created after implementation, not held out',
|
|
71
|
+
fixture_sha256: crypto.createHash('sha256').update(fixtureRaw).digest('hex'),
|
|
72
|
+
runtime: process.version, platform: process.platform, architecture: process.arch,
|
|
73
|
+
records: fixture.records.length, cases: rows.length,
|
|
74
|
+
memoir: metrics('retrieved'),
|
|
75
|
+
scoped_scan_reference: metrics('scan_reference'),
|
|
76
|
+
indexed_scan_agreement_cases: rows.filter(r => r.matches_scan_output).length,
|
|
77
|
+
scan_reference_limit: 'Exhaustive scoring using the same source reader, visibility rules, and ranking formula. Measures index equivalence, not competitive quality. Use retrieval-performance.mjs for latency.',
|
|
78
|
+
unscoped_substring_control: metrics('substring'),
|
|
79
|
+
control_limit: 'Simple substring control, not the previous released ranking engine or a competing product',
|
|
80
|
+
latency_ms: { median: latencies[Math.floor(latencies.length / 2)], p95: latencies[Math.ceil(latencies.length * .95)-1] },
|
|
81
|
+
rows,
|
|
82
|
+
};
|
|
83
|
+
const json = JSON.stringify(report, null, 2) + '\n';
|
|
84
|
+
if (process.argv[2]) await fs.outputFile(path.resolve(process.argv[2]), json);
|
|
85
|
+
console.log(json);
|
|
86
|
+
if (report.memoir.forbidden_results || report.memoir.recall_at_5 < 1 || report.memoir.correct_abstentions !== report.memoir.abstention_cases || report.indexed_scan_agreement_cases !== rows.length) process.exitCode = 1;
|
|
87
|
+
} finally { await fs.remove(scratch); }
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memoir-cli",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.14.0",
|
|
4
4
|
"mcpName": "io.github.camgitt/memoir",
|
|
5
|
-
"description": "
|
|
5
|
+
"description": "Portable, project-scoped memory and session handoffs for coding agents. Readable files, MCP recall, and optional user-passphrase encrypted backups.",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"bin": {
|
|
@@ -14,7 +14,10 @@
|
|
|
14
14
|
"bin/",
|
|
15
15
|
"src/",
|
|
16
16
|
"README.md",
|
|
17
|
-
"LICENSE"
|
|
17
|
+
"LICENSE",
|
|
18
|
+
"docs/",
|
|
19
|
+
"supabase/migrations/",
|
|
20
|
+
"evals/"
|
|
18
21
|
],
|
|
19
22
|
"engines": {
|
|
20
23
|
"node": ">=18"
|
|
@@ -32,7 +35,10 @@
|
|
|
32
35
|
"test": "node run-tests.mjs",
|
|
33
36
|
"test:legacy": "bash test-local.sh",
|
|
34
37
|
"prepublishOnly": "node scripts/check-clean-for-publish.mjs && npm test",
|
|
35
|
-
"
|
|
38
|
+
"version": "node -e \"const f=require('fs');const p=require('./package.json');const s=JSON.parse(f.readFileSync('server.json','utf8'));s.version=p.version;s.packages.forEach(x=>x.version=p.version);f.writeFileSync('server.json',JSON.stringify(s,null,2)+'\\n')\" && git add server.json",
|
|
39
|
+
"eval": "node evals/run.mjs",
|
|
40
|
+
"bench:retrieval": "node evals/retrieval-performance.mjs",
|
|
41
|
+
"test:packed": "node scripts/test-packed.mjs"
|
|
36
42
|
},
|
|
37
43
|
"keywords": [
|
|
38
44
|
"mcp",
|
|
@@ -81,6 +87,8 @@
|
|
|
81
87
|
"fs-extra": "^11.2.0",
|
|
82
88
|
"gradient-string": "^3.0.0",
|
|
83
89
|
"inquirer": "^9.2.15",
|
|
84
|
-
"ora": "^7.0.1"
|
|
90
|
+
"ora": "^7.0.1",
|
|
91
|
+
"smol-toml": "^1.8.0",
|
|
92
|
+
"zod": "4.5.4"
|
|
85
93
|
}
|
|
86
94
|
}
|
package/src/adapters/index.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { projectIdentity } from '../memory/scope.js';
|
|
2
|
+
import { readSafeFile, writeSafeFile } from '../security/files.js';
|
|
3
|
+
import { stageMemories } from '../memory/store.js';
|
|
1
4
|
import fs from 'fs-extra';
|
|
2
5
|
import nodeFs from 'node:fs';
|
|
3
6
|
import path from 'path';
|
|
@@ -80,7 +83,7 @@ export const adapters = [
|
|
|
80
83
|
if (src === codexDir) return true;
|
|
81
84
|
const basename = path.basename(src);
|
|
82
85
|
// Only sync config files
|
|
83
|
-
const allowed = ['config.json', 'settings.json', 'instructions.md'];
|
|
86
|
+
const allowed = ['config.toml', 'config.json', 'settings.json', 'instructions.md', 'AGENTS.md'];
|
|
84
87
|
return allowed.includes(basename) && !rel.includes(path.sep);
|
|
85
88
|
}
|
|
86
89
|
},
|
|
@@ -241,7 +244,7 @@ function formatSize(bytes) {
|
|
|
241
244
|
}
|
|
242
245
|
|
|
243
246
|
export async function extractMemories(stagingDir, spinner, onlyFilter = null) {
|
|
244
|
-
let foundAny =
|
|
247
|
+
let foundAny = (await stageMemories(stagingDir)) > 0;
|
|
245
248
|
const results = [];
|
|
246
249
|
|
|
247
250
|
for (const adapter of adapters) {
|
|
@@ -264,7 +267,7 @@ export async function extractMemories(stagingDir, spinner, onlyFilter = null) {
|
|
|
264
267
|
await fs.ensureDir(dest);
|
|
265
268
|
foundFile = true;
|
|
266
269
|
}
|
|
267
|
-
await
|
|
270
|
+
await writeSafeFile(dest, file, await readSafeFile(adapter.source, file));
|
|
268
271
|
fileCount++;
|
|
269
272
|
}
|
|
270
273
|
}
|
|
@@ -278,7 +281,7 @@ export async function extractMemories(stagingDir, spinner, onlyFilter = null) {
|
|
|
278
281
|
spinner.text = `${adapter.icon} Scanning ${chalk.cyan(adapter.name)}...`;
|
|
279
282
|
const dest = path.join(stagingDir, adapter.name.toLowerCase().replace(/ /g, '-'));
|
|
280
283
|
await fs.ensureDir(dest);
|
|
281
|
-
await fs.copy(adapter.source, dest, { filter: adapter.filter });
|
|
284
|
+
await fs.copy(adapter.source, dest, { filter: (src, dest) => !fs.lstatSync(src).isSymbolicLink() && adapter.filter(src, dest) });
|
|
282
285
|
|
|
283
286
|
const fileCount = await countFiles(dest);
|
|
284
287
|
const size = await dirSize(dest);
|
|
@@ -309,6 +312,7 @@ export async function extractMemories(stagingDir, spinner, onlyFilter = null) {
|
|
|
309
312
|
let projectCount = 0;
|
|
310
313
|
let projectFileCount = 0;
|
|
311
314
|
const projectNames = [];
|
|
315
|
+
const projectManifest = {};
|
|
312
316
|
|
|
313
317
|
// Walk home dir up to 3 levels deep looking for project markers
|
|
314
318
|
const scanDir = async (dir, depth = 0) => {
|
|
@@ -329,7 +333,9 @@ export async function extractMemories(stagingDir, spinner, onlyFilter = null) {
|
|
|
329
333
|
|
|
330
334
|
if (foundFiles.length > 0 && dir !== home && !shouldIgnoreProject(dir)) {
|
|
331
335
|
// This is a project with AI configs
|
|
332
|
-
const
|
|
336
|
+
const identity = projectIdentity(dir);
|
|
337
|
+
const projectName = path.basename(dir) + '-' + identity.split(':')[1].slice(0, 12);
|
|
338
|
+
projectManifest[projectName] = { identity, name: path.basename(dir), relative_path: path.relative(home, dir).replace(/\\/g, '/') };
|
|
333
339
|
const projectDestDir = path.join(projectsDest, projectName);
|
|
334
340
|
await fs.ensureDir(projectDestDir);
|
|
335
341
|
|
|
@@ -337,7 +343,7 @@ export async function extractMemories(stagingDir, spinner, onlyFilter = null) {
|
|
|
337
343
|
const src = path.join(dir, file);
|
|
338
344
|
const dest = path.join(projectDestDir, file);
|
|
339
345
|
await fs.ensureDir(path.dirname(dest));
|
|
340
|
-
await
|
|
346
|
+
await writeSafeFile(projectDestDir, file, await readSafeFile(dir, file));
|
|
341
347
|
projectFileCount++;
|
|
342
348
|
}
|
|
343
349
|
|
|
@@ -357,6 +363,7 @@ export async function extractMemories(stagingDir, spinner, onlyFilter = null) {
|
|
|
357
363
|
await scanDir(home);
|
|
358
364
|
|
|
359
365
|
if (projectCount > 0) {
|
|
366
|
+
await writeSafeFile(stagingDir, 'projects.json', JSON.stringify(projectManifest, null, 2));
|
|
360
367
|
const size = await dirSize(projectsDest);
|
|
361
368
|
foundAny = true;
|
|
362
369
|
results.push({
|
package/src/adapters/restore.js
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import { parseFrontmatter } from '../commands/validate.js';
|
|
2
|
+
import { visibleMemory } from '../memory/scope.js';
|
|
3
|
+
import { migrateSessionData } from '../session/migrations.js';
|
|
4
|
+
import { readSafeFile, writeSafeFile, safePath, relativeFile, listSafeFiles } from '../security/files.js';
|
|
5
|
+
import { projectIdentity } from '../memory/scope.js';
|
|
6
|
+
import { restoreStoredMemories } from '../memory/store.js';
|
|
1
7
|
import chalk from 'chalk';
|
|
2
8
|
import fs from 'fs-extra';
|
|
3
9
|
import path from 'path';
|
|
@@ -15,7 +21,7 @@ export function detectLocalHomeKey(adapterSource) {
|
|
|
15
21
|
|
|
16
22
|
const entries = fs.readdirSync(localProjectsDir)
|
|
17
23
|
.filter(e => !e.startsWith('.'))
|
|
18
|
-
.filter(e => fs.
|
|
24
|
+
.filter(e => fs.lstatSync(path.join(localProjectsDir, e)).isDirectory());
|
|
19
25
|
if (entries.length === 0) return null;
|
|
20
26
|
|
|
21
27
|
// Prefer the key that matches this machine's homedir encoding.
|
|
@@ -65,7 +71,7 @@ function remapProjectPaths(backupDir, adapterSource) {
|
|
|
65
71
|
if (!fs.existsSync(projectsDir)) return [];
|
|
66
72
|
|
|
67
73
|
const backupEntries = fs.readdirSync(projectsDir)
|
|
68
|
-
.filter(e => fs.
|
|
74
|
+
.filter(e => fs.lstatSync(path.join(projectsDir, e)).isDirectory());
|
|
69
75
|
if (backupEntries.length === 0) return [];
|
|
70
76
|
|
|
71
77
|
// Step 1: Detect the local home key from existing Claude dirs
|
|
@@ -221,11 +227,11 @@ async function mergeMemoryDirs(src, dest) {
|
|
|
221
227
|
const srcStat = await fs.stat(srcPath);
|
|
222
228
|
const destStat = await fs.stat(destPath);
|
|
223
229
|
if (srcStat.mtimeMs > destStat.mtimeMs) {
|
|
224
|
-
await
|
|
230
|
+
await writeSafeFile(dest, entry.name, await readSafeFile(src, entry.name));
|
|
225
231
|
}
|
|
226
232
|
} else {
|
|
227
233
|
// File only exists on foreign machine — always copy it
|
|
228
|
-
await
|
|
234
|
+
await writeSafeFile(dest, entry.name, await readSafeFile(src, entry.name));
|
|
229
235
|
}
|
|
230
236
|
}
|
|
231
237
|
}
|
|
@@ -246,7 +252,7 @@ async function reconcileMemoryIndexes(claudeSource) {
|
|
|
246
252
|
const memoryMdPath = path.join(memDir, 'MEMORY.md');
|
|
247
253
|
let memoryMd = '';
|
|
248
254
|
if (fs.existsSync(memoryMdPath)) {
|
|
249
|
-
memoryMd =
|
|
255
|
+
memoryMd = (await readSafeFile(claudeSource, path.relative(claudeSource, memoryMdPath))).toString('utf8');
|
|
250
256
|
}
|
|
251
257
|
|
|
252
258
|
// Find all .md files in this memory dir
|
|
@@ -265,7 +271,8 @@ async function reconcileMemoryIndexes(claudeSource) {
|
|
|
265
271
|
// Read each unreferenced file to get its name/description from frontmatter
|
|
266
272
|
let additions = '\n\n## Synced from another machine\n';
|
|
267
273
|
for (const file of unreferenced) {
|
|
268
|
-
const content =
|
|
274
|
+
const content = (await readSafeFile(claudeSource, path.relative(claudeSource, path.join(memDir, file)))).toString('utf8');
|
|
275
|
+
if (!visibleMemory(parseFrontmatter(content).fields, { allProjects: true })) continue;
|
|
269
276
|
// Try to extract name from frontmatter
|
|
270
277
|
const nameMatch = content.match(/^name:\s*(.+)/m);
|
|
271
278
|
const descMatch = content.match(/^description:\s*(.+)/m);
|
|
@@ -281,32 +288,36 @@ async function reconcileMemoryIndexes(claudeSource) {
|
|
|
281
288
|
// Remove old "Synced from another machine" section if it exists, then re-add
|
|
282
289
|
memoryMd = memoryMd.replace(/\n\n## Synced from another machine\n[\s\S]*$/, '');
|
|
283
290
|
memoryMd = memoryMd.trimEnd() + additions;
|
|
284
|
-
|
|
291
|
+
await writeSafeFile(claudeSource, path.relative(claudeSource, memoryMdPath), memoryMd);
|
|
285
292
|
}
|
|
286
293
|
}
|
|
287
294
|
|
|
288
|
-
async function syncFiles(src, dest, changes) {
|
|
295
|
+
async function syncFiles(src, dest, changes, adapter = null, base = dest) {
|
|
289
296
|
const entries = await fs.readdir(src, { withFileTypes: true });
|
|
290
297
|
for (const entry of entries) {
|
|
291
298
|
const srcPath = path.join(src, entry.name);
|
|
292
299
|
const destPath = path.join(dest, entry.name);
|
|
293
300
|
|
|
301
|
+
if (entry.isSymbolicLink()) throw new Error('Backup contains a symlink');
|
|
302
|
+
if (adapter && !adapter.filter(destPath)) throw new Error('Backup contains a file excluded by the adapter');
|
|
294
303
|
if (entry.isDirectory()) {
|
|
295
|
-
|
|
296
|
-
|
|
304
|
+
// Validate an eventual child before creating directories to reject
|
|
305
|
+
// symlink parents as well as symlink files.
|
|
306
|
+
await safePath(base, path.relative(base, destPath) + '/.memoir-check', { createParents: true });
|
|
307
|
+
await syncFiles(srcPath, destPath, changes, adapter, base);
|
|
297
308
|
} else {
|
|
298
309
|
if (await fs.pathExists(destPath)) {
|
|
299
310
|
// Compare modification times — update if backup is newer
|
|
300
311
|
const srcStat = await fs.stat(srcPath);
|
|
301
312
|
const destStat = await fs.stat(destPath);
|
|
302
313
|
if (srcStat.mtimeMs > destStat.mtimeMs) {
|
|
303
|
-
await
|
|
314
|
+
await writeSafeFile(dest, entry.name, await readSafeFile(src, entry.name));
|
|
304
315
|
changes.updated.push(destPath);
|
|
305
316
|
} else {
|
|
306
317
|
changes.skipped.push(destPath);
|
|
307
318
|
}
|
|
308
319
|
} else {
|
|
309
|
-
await
|
|
320
|
+
await writeSafeFile(dest, entry.name, await readSafeFile(src, entry.name));
|
|
310
321
|
changes.added.push(destPath);
|
|
311
322
|
}
|
|
312
323
|
}
|
|
@@ -314,7 +325,32 @@ async function syncFiles(src, dest, changes) {
|
|
|
314
325
|
}
|
|
315
326
|
|
|
316
327
|
export async function restoreMemories(sourceDir, spinner, onlyFilter = null, autoYes = false) {
|
|
317
|
-
|
|
328
|
+
if (await fs.pathExists(path.join(sourceDir, 'session.json'))) {
|
|
329
|
+
const raw = JSON.parse((await readSafeFile(sourceDir, 'session.json')).toString('utf8'));
|
|
330
|
+
if (migrateSessionData(raw).future) throw new Error('Backup uses a newer session schema. Upgrade Memoir first.');
|
|
331
|
+
}
|
|
332
|
+
for (const adapter of adapters) {
|
|
333
|
+
if (onlyFilter && !onlyFilter.some(f => adapter.name.toLowerCase().includes(f))) continue;
|
|
334
|
+
const dir = path.join(sourceDir, adapter.name.toLowerCase().replace(/ /g, '-'));
|
|
335
|
+
if (!await fs.pathExists(dir)) continue;
|
|
336
|
+
for (const rel of await listSafeFiles(dir)) {
|
|
337
|
+
const allowed = adapter.customExtract ? adapter.files.includes(rel) : adapter.filter(path.join(adapter.source, rel));
|
|
338
|
+
if (!allowed) throw new Error('Backup contains an excluded file for ' + adapter.name);
|
|
339
|
+
if (await fs.pathExists(adapter.source)) await safePath(adapter.source, rel);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
const importedProjects = path.join(sourceDir, 'projects');
|
|
343
|
+
if (await fs.pathExists(importedProjects)) {
|
|
344
|
+
const allowed = new Set(['CLAUDE.md','GEMINI.md','CHATGPT.md','AGENTS.md','.cursorrules','.windsurfrules','.aider.conf.yml','.clinerules','.github/copilot-instructions.md']);
|
|
345
|
+
for (const rel of await listSafeFiles(importedProjects)) {
|
|
346
|
+
if (!allowed.has(rel.split('/').slice(1).join('/'))) throw new Error('Backup contains an unsupported project file');
|
|
347
|
+
}
|
|
348
|
+
if (await fs.pathExists(path.join(sourceDir, 'projects.json'))) {
|
|
349
|
+
const manifest = JSON.parse((await readSafeFile(sourceDir, 'projects.json')).toString());
|
|
350
|
+
for (const metadata of Object.values(manifest)) relativeFile(metadata.relative_path);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
let restoredAny = (await restoreStoredMemories(sourceDir)) > 0;
|
|
318
354
|
const allResults = [];
|
|
319
355
|
|
|
320
356
|
for (const adapter of adapters) {
|
|
@@ -393,25 +429,26 @@ export async function restoreMemories(sourceDir, spinner, onlyFilter = null, aut
|
|
|
393
429
|
if (adapter.customExtract) {
|
|
394
430
|
const files = await fs.readdir(backupDir);
|
|
395
431
|
for (const file of files) {
|
|
432
|
+
if (!adapter.files.includes(file)) throw new Error('Backup contains an excluded file');
|
|
396
433
|
const destFile = path.join(adapter.source, file);
|
|
397
434
|
if (await fs.pathExists(destFile)) {
|
|
398
435
|
const srcStat = await fs.stat(path.join(backupDir, file));
|
|
399
436
|
const destStat = await fs.stat(destFile);
|
|
400
437
|
if (srcStat.mtimeMs > destStat.mtimeMs) {
|
|
401
|
-
await
|
|
438
|
+
await writeSafeFile(adapter.source, file, await readSafeFile(backupDir, file));
|
|
402
439
|
changes.updated.push(destFile);
|
|
403
440
|
} else {
|
|
404
441
|
changes.skipped.push(destFile);
|
|
405
442
|
}
|
|
406
443
|
} else {
|
|
407
|
-
await
|
|
444
|
+
await writeSafeFile(adapter.source, file, await readSafeFile(backupDir, file));
|
|
408
445
|
changes.added.push(destFile);
|
|
409
446
|
}
|
|
410
447
|
}
|
|
411
448
|
} else {
|
|
412
449
|
spinner.text = `Restoring ${chalk.cyan(adapter.name)} to ${adapter.source}...`;
|
|
413
450
|
await fs.ensureDir(adapter.source);
|
|
414
|
-
await syncFiles(backupDir, adapter.source, changes);
|
|
451
|
+
await syncFiles(backupDir, adapter.source, changes, adapter);
|
|
415
452
|
}
|
|
416
453
|
|
|
417
454
|
// After syncing, reconcile MEMORY.md files
|
|
@@ -458,6 +495,8 @@ export async function restoreMemories(sourceDir, spinner, onlyFilter = null, aut
|
|
|
458
495
|
const projectsDir = path.join(sourceDir, 'projects');
|
|
459
496
|
if (await fs.pathExists(projectsDir)) {
|
|
460
497
|
const projectEntries = await fs.readdir(projectsDir, { withFileTypes: true });
|
|
498
|
+
const manifestPath = path.join(sourceDir, 'projects.json');
|
|
499
|
+
const projectManifest = await fs.pathExists(manifestPath) ? JSON.parse((await readSafeFile(sourceDir, 'projects.json')).toString()) : {};
|
|
461
500
|
const projectDirs = projectEntries.filter(e => e.isDirectory() && e.name !== '.git');
|
|
462
501
|
|
|
463
502
|
if (projectDirs.length > 0) {
|
|
@@ -474,26 +513,29 @@ export async function restoreMemories(sourceDir, spinner, onlyFilter = null, aut
|
|
|
474
513
|
|
|
475
514
|
// Search for project on local machine (up to 3 levels deep)
|
|
476
515
|
let localProjDir = null;
|
|
477
|
-
const
|
|
478
|
-
|
|
479
|
-
const
|
|
480
|
-
|
|
516
|
+
const metadata = projectManifest[proj.name];
|
|
517
|
+
if (metadata) {
|
|
518
|
+
const relative = relativeFile(metadata.relative_path);
|
|
519
|
+
const candidate = path.join(home, relative);
|
|
520
|
+
if (await fs.pathExists(candidate) && projectIdentity(candidate) === metadata.identity) {
|
|
521
|
+
await safePath(home, relative + '/.memoir-check');
|
|
481
522
|
localProjDir = candidate;
|
|
482
|
-
break;
|
|
483
523
|
}
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
524
|
+
}
|
|
525
|
+
if (!metadata) {
|
|
526
|
+
const candidates = [];
|
|
527
|
+
const direct = path.join(home, proj.name);
|
|
528
|
+
if (await fs.pathExists(direct)) candidates.push(direct);
|
|
529
|
+
for (const e of await fs.readdir(home, { withFileTypes: true })) {
|
|
530
|
+
if (!e.isDirectory() || e.name.startsWith('.')) continue;
|
|
531
|
+
const deeper = path.join(home, e.name, proj.name);
|
|
532
|
+
if (await fs.pathExists(deeper)) candidates.push(deeper);
|
|
533
|
+
}
|
|
534
|
+
if (candidates.length === 1) {
|
|
535
|
+
await safePath(home, path.relative(home, candidates[0]) + '/.memoir-check');
|
|
536
|
+
localProjDir = candidates[0];
|
|
537
|
+
}
|
|
538
|
+
if (candidates.length > 1) console.log(chalk.yellow(' Ambiguous legacy project name; skipped ' + proj.name));
|
|
497
539
|
}
|
|
498
540
|
|
|
499
541
|
if (!localProjDir) {
|
|
@@ -517,6 +559,11 @@ export async function restoreMemories(sourceDir, spinner, onlyFilter = null, aut
|
|
|
517
559
|
}
|
|
518
560
|
|
|
519
561
|
if (confirm) {
|
|
562
|
+
const allowedProjectFiles = new Set(['CLAUDE.md','GEMINI.md','CHATGPT.md','AGENTS.md','.cursorrules','.windsurfrules','.aider.conf.yml','.clinerules','.github/copilot-instructions.md']);
|
|
563
|
+
for (const rel of await listSafeFiles(backupProjDir)) {
|
|
564
|
+
if (!allowedProjectFiles.has(rel)) throw new Error('Backup contains an unsupported project file');
|
|
565
|
+
await safePath(localProjDir, rel);
|
|
566
|
+
}
|
|
520
567
|
for (const file of files) {
|
|
521
568
|
const src = path.join(backupProjDir, file);
|
|
522
569
|
const dest = path.join(localProjDir, file);
|
|
@@ -530,14 +577,14 @@ export async function restoreMemories(sourceDir, spinner, onlyFilter = null, aut
|
|
|
530
577
|
const srcStat = await fs.stat(src);
|
|
531
578
|
const destStat = await fs.stat(dest);
|
|
532
579
|
if (srcStat.mtimeMs > destStat.mtimeMs) {
|
|
533
|
-
await
|
|
580
|
+
await writeSafeFile(localProjDir, file, await readSafeFile(backupProjDir, file));
|
|
534
581
|
console.log(chalk.yellow(` ↻ ${file}`) + chalk.gray(` (updated)`));
|
|
535
582
|
} else {
|
|
536
583
|
console.log(chalk.gray(` = ${file} (up to date)`));
|
|
537
584
|
}
|
|
538
585
|
} else {
|
|
539
586
|
await fs.ensureDir(path.dirname(dest));
|
|
540
|
-
await
|
|
587
|
+
await writeSafeFile(localProjDir, file, await readSafeFile(backupProjDir, file));
|
|
541
588
|
console.log(chalk.green(` + ${file}`) + chalk.gray(` (new)`));
|
|
542
589
|
}
|
|
543
590
|
totalRestored++;
|
package/src/cloud/auth.js
CHANGED
|
@@ -22,16 +22,18 @@ async function supaFetch(endpoint, options = {}) {
|
|
|
22
22
|
return res;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
// GoTrue's REST API takes the post-email landing URL as a `redirect_to`
|
|
26
|
+
// QUERY PARAM. `options.emailRedirectTo` / `options.redirectTo` in the JSON
|
|
27
|
+
// body is supabase-js's client shape — sent raw it is silently ignored and
|
|
28
|
+
// the email links fall back to the project's Site URL. Both URLs below must
|
|
29
|
+
// also be on the Auth → URL Configuration → Redirect URLs allow-list.
|
|
30
|
+
const CONFIRMED_URL = 'https://memoir.sh/confirmed';
|
|
31
|
+
const RESET_URL = 'https://memoir.sh/reset-password';
|
|
32
|
+
|
|
25
33
|
export async function signUp(email, password) {
|
|
26
|
-
const res = await supaFetch(
|
|
34
|
+
const res = await supaFetch(`/auth/v1/signup?redirect_to=${encodeURIComponent(CONFIRMED_URL)}`, {
|
|
27
35
|
method: 'POST',
|
|
28
|
-
body: JSON.stringify({
|
|
29
|
-
email,
|
|
30
|
-
password,
|
|
31
|
-
options: {
|
|
32
|
-
emailRedirectTo: 'https://memoir.sh/confirmed',
|
|
33
|
-
},
|
|
34
|
-
}),
|
|
36
|
+
body: JSON.stringify({ email, password }),
|
|
35
37
|
});
|
|
36
38
|
const data = await res.json();
|
|
37
39
|
if (!res.ok) throw new Error(data.error_description || data.msg || 'Sign up failed');
|
|
@@ -116,14 +118,9 @@ export async function getSubscription(session) {
|
|
|
116
118
|
}
|
|
117
119
|
|
|
118
120
|
export async function resetPassword(email) {
|
|
119
|
-
const res = await supaFetch(
|
|
121
|
+
const res = await supaFetch(`/auth/v1/recover?redirect_to=${encodeURIComponent(RESET_URL)}`, {
|
|
120
122
|
method: 'POST',
|
|
121
|
-
body: JSON.stringify({
|
|
122
|
-
email,
|
|
123
|
-
options: {
|
|
124
|
-
redirectTo: 'https://memoir.sh/reset-password',
|
|
125
|
-
},
|
|
126
|
-
}),
|
|
123
|
+
body: JSON.stringify({ email }),
|
|
127
124
|
});
|
|
128
125
|
if (!res.ok) {
|
|
129
126
|
const data = await res.json();
|
package/src/cloud/constants.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
export const SUPABASE_URL = process.env.MEMOIR_SUPABASE_URL || 'https://oqrkxytbahfwjhcbyzrx.supabase.co';
|
|
2
2
|
export const SUPABASE_ANON_KEY = process.env.MEMOIR_SUPABASE_ANON_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9xcmt4eXRiYWhmd2poY2J5enJ4Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzMyMTQ4MzMsImV4cCI6MjA4ODc5MDgzM30.jOKOi73OJgIgi1zj0VOIQkGp0xqS3ee4gfCjpdqCnvM';
|
|
3
3
|
export const STORAGE_BUCKET = 'memoir-backups';
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
// Cloud backup retention (cleanupOldBackups prunes beyond these, oldest first).
|
|
5
|
+
// Through 3.11 these were FREE=100 / PRO=50 — Pro pruned twice as aggressively
|
|
6
|
+
// as free on a destructive path, while the upsell copy promised Pro more.
|
|
7
|
+
// Free is a safety net; Pro is "full version history" (memoir.sh pricing).
|
|
8
|
+
export const MAX_BACKUPS_FREE = 10;
|
|
9
|
+
export const MAX_BACKUPS_PRO = 100;
|