total-recall-brain 3.23.2 → 3.24.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/bin/total-recall.mjs +1 -0
- package/frontend/dist/assets/{index-c6GHGS_j.js → index-CtCDaZYZ.js} +1 -1
- package/frontend/dist/index.html +1 -1
- package/frontend/src/pages/SecretsPage.tsx +111 -52
- package/package.json +3 -2
- package/scaffold/.agent/skills/total-recall/SKILL.md +199 -7
- package/scaffold/.agent/skills/total-recall/memory-vault/concepts/cli-help-reference.md +13 -4
- package/scaffold/.agent/skills/total-recall/memory-vault/invariants/operating-instructions.md +2 -0
- package/scaffold/.agent/skills/total-recall/references/api-reference.md +1 -1
- package/scaffold/.agent/skills/total-recall/references/cli-reference.md +1 -2
- package/scaffold/.agent/skills/total-recall/research-queue.jsonl +7 -0
- package/scaffold/.agent/skills/total-recall/skills-registry/index.yaml +2461 -6
- package/src/cli/backfill.mjs +176 -0
- package/src/cli/init.mjs +1 -1
- package/src/cli/proposals.mjs +3 -3
- package/src/cli/secret.mjs +84 -0
- package/src/core/clarity-rewriter.mjs +25 -39
- package/src/core/conclusion-writer.mjs +20 -10
- package/src/core/crypto.mjs +86 -16
- package/src/core/daemon-loop.mjs +2 -2
- package/src/core/dream.mjs +49 -58
- package/src/core/env-import.mjs +18 -0
- package/src/core/inference-engine.mjs +15 -17
- package/src/core/migrate.mjs +5 -1
- package/src/core/okf-adapter.mjs +2 -0
- package/src/core/post-mortem.mjs +30 -26
- package/src/core/proposal-applier.mjs +26 -10
- package/src/core/repo-sync.mjs +51 -39
- package/src/core/scheduler.mjs +1 -0
- package/src/core/schema.mjs +24 -1
- package/src/core/search.mjs +1 -1
- package/src/core/secrets-env-diff.mjs +132 -0
- package/src/core/secrets-rekey.mjs +0 -0
- package/src/core/snapshot.mjs +32 -9
- package/src/core/ssss-kernel-bridge.mjs +46 -6
- package/src/core/task-envelope.mjs +1 -0
- package/src/core/validated-write.mjs +68 -5
- package/src/core/vault-backfill.mjs +259 -0
- package/src/core/vault.mjs +22 -4
- package/src/server/auth.mjs +42 -8
- package/src/server/health-coverage.mjs +74 -0
- package/src/server/index.mjs +60 -36
- package/src/server/routes/brains.mjs +1 -1
- package/src/server/routes/proposals.mjs +4 -4
- package/src/server/routes/secrets.mjs +1 -1
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { analyzeVault, backfillVault } from '../core/vault-backfill.mjs';
|
|
4
|
+
import { brainDir, resolveBrainLayer } from '../core/config.mjs';
|
|
5
|
+
|
|
6
|
+
function usage() {
|
|
7
|
+
console.log(`
|
|
8
|
+
total-recall backfill [options]
|
|
9
|
+
|
|
10
|
+
Bring historical vault nodes up to the current SSSS contract.
|
|
11
|
+
|
|
12
|
+
Most nodes in a long-lived vault predate the schema they are validated
|
|
13
|
+
against. This reports exactly which fields the contract would add, and only
|
|
14
|
+
writes when you ask it to.
|
|
15
|
+
|
|
16
|
+
Options:
|
|
17
|
+
--apply Repair the vault (default is a dry run)
|
|
18
|
+
--vault <path> Vault to inspect (default: the active brain's vault)
|
|
19
|
+
--all Every registered project vault plus the global vault
|
|
20
|
+
--limit <n> Repair at most n nodes (use to rehearse on a small batch)
|
|
21
|
+
--no-snapshot Skip the pre-apply snapshot (not recommended)
|
|
22
|
+
--verbose List each node instead of only the summary
|
|
23
|
+
|
|
24
|
+
Examples:
|
|
25
|
+
total-recall backfill
|
|
26
|
+
total-recall backfill --apply --limit 25
|
|
27
|
+
total-recall backfill --all --apply
|
|
28
|
+
`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function summarize(report, verbose) {
|
|
32
|
+
const pct = report.total ? ((report.valid / report.total) * 100).toFixed(1) : '100.0';
|
|
33
|
+
console.log(`\n ${report.vaultDir.replace(process.env.HOME || '~', '~')}`);
|
|
34
|
+
console.log(` ${report.valid}/${report.total} valid on disk (${pct}%) · ${report.invalid} need repair`);
|
|
35
|
+
if (report.invalid) {
|
|
36
|
+
console.log(` ${report.repairable} of those this run can fix; ${report.unfixable.length} it cannot.`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (report.unreadable.length) {
|
|
40
|
+
console.log(` ${report.unreadable.length} unreadable (left untouched):`);
|
|
41
|
+
for (const u of report.unreadable.slice(0, 5)) console.log(` ${u}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const fields = Object.entries(report.fieldCounts).sort((a, b) => b[1] - a[1]);
|
|
45
|
+
if (fields.length) {
|
|
46
|
+
console.log(' Fields the contract would add:');
|
|
47
|
+
for (const [field, count] of fields.slice(0, 12)) {
|
|
48
|
+
console.log(` ${String(count).padStart(6)} ${field}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const shapes = Object.entries(report.shapeRepairs || {}).sort((a, b) => b[1] - a[1]);
|
|
53
|
+
if (shapes.length) {
|
|
54
|
+
console.log(' Wrong-typed fields it would repair in place:');
|
|
55
|
+
for (const [field, count] of shapes) {
|
|
56
|
+
console.log(` ${String(count).padStart(6)} ${field}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (report.unfixable?.length) {
|
|
61
|
+
console.log(` Cannot be expressed by the contract (left untouched):`);
|
|
62
|
+
for (const u of report.unfixable.slice(0, 8)) {
|
|
63
|
+
console.log(` ${u.file.replace(process.env.HOME || '~', '~')}`);
|
|
64
|
+
console.log(` ${u.errors.join('; ').slice(0, 150)}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const errors = Object.entries(report.errorCounts).sort((a, b) => b[1] - a[1]);
|
|
69
|
+
if (errors.length) {
|
|
70
|
+
console.log(' Why they fail today:');
|
|
71
|
+
for (const [err, count] of errors.slice(0, 8)) {
|
|
72
|
+
console.log(` ${String(count).padStart(6)} ${err}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (verbose) {
|
|
77
|
+
for (const node of report.nodes.slice(0, 200)) {
|
|
78
|
+
console.log(` ${node.file}`);
|
|
79
|
+
console.log(` + ${Object.keys(node.added).join(', ') || '(none)'}`);
|
|
80
|
+
const changed = Object.keys(node.changed);
|
|
81
|
+
if (changed.length) console.log(` ~ ${changed.join(', ')}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function collectVaults(args) {
|
|
87
|
+
const explicit = args.indexOf('--vault');
|
|
88
|
+
if (explicit !== -1 && args[explicit + 1]) {
|
|
89
|
+
return [path.resolve(args[explicit + 1])];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!args.includes('--all')) {
|
|
93
|
+
return [path.join(resolveBrainLayer().brainDir, 'memory-vault')];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const vaults = [path.join(brainDir, 'memory-vault')];
|
|
97
|
+
const registryPath = path.join(brainDir, 'config', 'project-registry.json');
|
|
98
|
+
if (fs.existsSync(registryPath)) {
|
|
99
|
+
try {
|
|
100
|
+
const registry = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
|
|
101
|
+
const entries = Array.isArray(registry) ? registry : (registry.projects || []);
|
|
102
|
+
for (const entry of entries) {
|
|
103
|
+
const root = typeof entry === 'string' ? entry : (entry.path || entry.root);
|
|
104
|
+
if (!root) continue;
|
|
105
|
+
const candidate = path.join(root, '.agent', 'skills', 'total-recall', 'memory-vault');
|
|
106
|
+
if (fs.existsSync(candidate) && !vaults.includes(candidate)) vaults.push(candidate);
|
|
107
|
+
}
|
|
108
|
+
} catch (err) {
|
|
109
|
+
console.error(` ! Could not read project registry: ${err.message}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return vaults;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export default async function backfill(args = []) {
|
|
116
|
+
if (args.includes('--help') || args.includes('-h')) return usage();
|
|
117
|
+
|
|
118
|
+
const apply = args.includes('--apply');
|
|
119
|
+
const verbose = args.includes('--verbose');
|
|
120
|
+
const snapshot = !args.includes('--no-snapshot');
|
|
121
|
+
const limitIndex = args.indexOf('--limit');
|
|
122
|
+
const limit = limitIndex !== -1 && args[limitIndex + 1]
|
|
123
|
+
? parseInt(args[limitIndex + 1], 10)
|
|
124
|
+
: Infinity;
|
|
125
|
+
|
|
126
|
+
const vaults = collectVaults(args);
|
|
127
|
+
console.log(apply
|
|
128
|
+
? `\n Backfilling ${vaults.length} vault(s) through the SSSS Core Contract.`
|
|
129
|
+
: `\n Dry run over ${vaults.length} vault(s) — nothing will be written.`);
|
|
130
|
+
|
|
131
|
+
let totalNodes = 0;
|
|
132
|
+
let totalValid = 0;
|
|
133
|
+
let totalRepaired = 0;
|
|
134
|
+
const allFailed = [];
|
|
135
|
+
|
|
136
|
+
for (const vaultDir of vaults) {
|
|
137
|
+
if (!fs.existsSync(vaultDir)) {
|
|
138
|
+
console.log(`\n (skipped, not found) ${vaultDir}`);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (!apply) {
|
|
143
|
+
const report = await analyzeVault(vaultDir);
|
|
144
|
+
summarize(report, verbose);
|
|
145
|
+
totalNodes += report.total;
|
|
146
|
+
totalValid += report.valid;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const result = await backfillVault(vaultDir, { snapshot, limit });
|
|
151
|
+
summarize(result, verbose);
|
|
152
|
+
if (result.snapshotId) {
|
|
153
|
+
console.log(` snapshot: ${result.snapshotId} (total-recall snapshot rollback ${result.snapshotId})`);
|
|
154
|
+
}
|
|
155
|
+
console.log(` repaired: ${result.repaired}`);
|
|
156
|
+
if (result.failed.length) {
|
|
157
|
+
console.log(` still failing: ${result.failed.length} (left untouched)`);
|
|
158
|
+
for (const f of result.failed.slice(0, 5)) {
|
|
159
|
+
console.log(` ${f.file}: ${f.errors.join('; ').slice(0, 140)}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
totalNodes += result.total;
|
|
163
|
+
totalValid += result.valid;
|
|
164
|
+
totalRepaired += result.repaired;
|
|
165
|
+
allFailed.push(...result.failed);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const pct = totalNodes ? ((totalValid / totalNodes) * 100).toFixed(1) : '100.0';
|
|
169
|
+
console.log(`\n ${totalValid}/${totalNodes} valid before this run (${pct}%)`);
|
|
170
|
+
if (apply) {
|
|
171
|
+
console.log(` ${totalRepaired} repaired, ${allFailed.length} still failing.`);
|
|
172
|
+
console.log(' Re-run without --apply to confirm the new state.\n');
|
|
173
|
+
} else {
|
|
174
|
+
console.log(' Re-run with --apply to repair. A snapshot is taken first.\n');
|
|
175
|
+
}
|
|
176
|
+
}
|
package/src/cli/init.mjs
CHANGED
|
@@ -851,7 +851,7 @@ export default async function init(args) {
|
|
|
851
851
|
// Auto-sync all .md files from the repo into the vault
|
|
852
852
|
try {
|
|
853
853
|
const { syncSingleRepo } = await import('../core/repo-sync.mjs');
|
|
854
|
-
const syncResult = syncSingleRepo(cwd, brainDir);
|
|
854
|
+
const syncResult = await syncSingleRepo(cwd, brainDir);
|
|
855
855
|
if (syncResult.ingested > 0) {
|
|
856
856
|
logOk(`Auto-synced ${syncResult.ingested} files from repo into Total Recall`);
|
|
857
857
|
}
|
package/src/cli/proposals.mjs
CHANGED
|
@@ -143,7 +143,7 @@ export default async function proposals(args) {
|
|
|
143
143
|
case 'reject': {
|
|
144
144
|
if (!id) { console.error(' ❌ Usage: proposals reject <id> [reason]'); process.exitCode = 1; return; }
|
|
145
145
|
const reason = remainingArgs.slice(2).filter(a => !a.startsWith('--')).join(' ') || 'Rejected by user.';
|
|
146
|
-
setProposalStatus(vaultDir, id, 'rejected', { rejection_reason: reason, reviewed_by: 'user' });
|
|
146
|
+
await setProposalStatus(vaultDir, id, 'rejected', { rejection_reason: reason, reviewed_by: 'user' });
|
|
147
147
|
console.log(`\n ✗ Rejected ${id}: ${reason}\n`);
|
|
148
148
|
return;
|
|
149
149
|
}
|
|
@@ -172,7 +172,7 @@ export default async function proposals(args) {
|
|
|
172
172
|
return;
|
|
173
173
|
}
|
|
174
174
|
for (const p of open) {
|
|
175
|
-
setProposalStatus(vaultDir, p.proposal_id, 'superseded', { superseded_reason: reason });
|
|
175
|
+
await setProposalStatus(vaultDir, p.proposal_id, 'superseded', { superseded_reason: reason });
|
|
176
176
|
}
|
|
177
177
|
console.log(`\n ~ Superseded ${open.length} "${topic}" proposal(s).\n`);
|
|
178
178
|
return;
|
|
@@ -181,7 +181,7 @@ export default async function proposals(args) {
|
|
|
181
181
|
case 'revert': {
|
|
182
182
|
if (!id) { console.error(' ❌ Usage: proposals revert <id>'); process.exitCode = 1; return; }
|
|
183
183
|
try {
|
|
184
|
-
const { reverted, deleted } = revertProposal(vaultDir, id);
|
|
184
|
+
const { reverted, deleted } = await revertProposal(vaultDir, id);
|
|
185
185
|
console.log(`\n ✓ Reverted ${id} — ${reverted} file(s) restored, ${deleted} removed.\n`);
|
|
186
186
|
} catch (err) {
|
|
187
187
|
console.error(` ❌ ${err.message}`);
|
package/src/cli/secret.mjs
CHANGED
|
@@ -202,6 +202,11 @@ function printHelp() {
|
|
|
202
202
|
remove <name> Delete a target
|
|
203
203
|
deploy <name> Push the SSOT projection to that target now
|
|
204
204
|
[--dry-run] [--keys k1,k2]
|
|
205
|
+
diff-env Compare repo .env files against the store (no values printed)
|
|
206
|
+
--path <dir> Single repo (default: every project-registry path)
|
|
207
|
+
--filename .env File to compare (default .env; server/<name> also checked)
|
|
208
|
+
--strict Exit 1 on any drift or unadopted key
|
|
209
|
+
--layer project Compare against the project-local store (default: global)
|
|
205
210
|
import-env One-time migrate: scan local .env → store (not the steady-state path)
|
|
206
211
|
--all / --file / --overwrite / --dry-run
|
|
207
212
|
catalog Full catalog: keys, providers, usage, rotation, cost, tracking
|
|
@@ -465,6 +470,9 @@ function parseArgs(args) {
|
|
|
465
470
|
case '--path':
|
|
466
471
|
out.path = args[++i];
|
|
467
472
|
break;
|
|
473
|
+
case '--layer':
|
|
474
|
+
out.layer = args[++i];
|
|
475
|
+
break;
|
|
468
476
|
case '--all-projects':
|
|
469
477
|
out.allProjects = true;
|
|
470
478
|
break;
|
|
@@ -1424,6 +1432,82 @@ export default async function secretCli(argv) {
|
|
|
1424
1432
|
return;
|
|
1425
1433
|
}
|
|
1426
1434
|
|
|
1435
|
+
if (opts.command === 'diff-env') {
|
|
1436
|
+
const { diffEnvAgainstStore, resolveEnvFiles } = await import('../core/secrets-env-diff.mjs');
|
|
1437
|
+
const { loadProjectRegistry } = await import('../core/secrets-env-export.mjs');
|
|
1438
|
+
const { globalBrainDir } = await import('../core/config.mjs');
|
|
1439
|
+
|
|
1440
|
+
// Compare against the GLOBAL store, not whichever layer the cwd resolves
|
|
1441
|
+
// to. A repo with its own .agent/ shadows the global store, so running
|
|
1442
|
+
// this from inside one would diff .env files against a project-local
|
|
1443
|
+
// store and report drift that is really just two different stores —
|
|
1444
|
+
// which is how the real drift stayed invisible. --layer project opts in.
|
|
1445
|
+
const compareBrainDir = opts.layer === 'project' ? brainDir : globalBrainDir;
|
|
1446
|
+
|
|
1447
|
+
const roots = opts.path
|
|
1448
|
+
? [path.resolve(opts.path)]
|
|
1449
|
+
: loadProjectRegistry()
|
|
1450
|
+
.map((entry) => (typeof entry === 'string' ? entry : entry.path || entry.root))
|
|
1451
|
+
.filter(Boolean);
|
|
1452
|
+
|
|
1453
|
+
const files = resolveEnvFiles(roots, opts.filename ? [opts.filename] : ['.env']);
|
|
1454
|
+
if (!files.length) {
|
|
1455
|
+
console.log('\n No .env files found for the configured projects.\n');
|
|
1456
|
+
return;
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
const { totals, rows, storeKeyCount } = await diffEnvAgainstStore(compareBrainDir, files);
|
|
1460
|
+
|
|
1461
|
+
console.log(`\n Secrets store vs .env — ${files.length} file(s), ${storeKeyCount} keys in store`);
|
|
1462
|
+
console.log(` store: ${compareBrainDir.replace(os.homedir(), '~')}\n`);
|
|
1463
|
+
console.log(` identical to store : ${totals.match}`);
|
|
1464
|
+
console.log(` DRIFTED : ${totals.drift}`);
|
|
1465
|
+
console.log(` name collisions : ${totals.collision}`);
|
|
1466
|
+
console.log(` only in .env : ${totals.only_env}`);
|
|
1467
|
+
console.log(` placeholders : ${totals.placeholder}`);
|
|
1468
|
+
|
|
1469
|
+
const drifted = rows.filter((r) => r.status === 'drift');
|
|
1470
|
+
if (drifted.length) {
|
|
1471
|
+
console.log('\n Drift — the same key holds different values in the two places.');
|
|
1472
|
+
console.log(' Equal lengths mean the same credential format, i.e. a rotation that');
|
|
1473
|
+
console.log(' landed on one side only. Decide which side is live before exporting.\n');
|
|
1474
|
+
for (const r of drifted) {
|
|
1475
|
+
const shape = r.sameShape ? 'same shape' : 'different shape';
|
|
1476
|
+
console.log(` ${r.key.padEnd(34)} env=${String(r.envLength).padStart(3)}ch store=${String(r.storeLength).padStart(3)}ch (${shape})`);
|
|
1477
|
+
console.log(` ${r.file.replace(os.homedir(), '~')}`);
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
const collisions = rows.filter((r) => r.status === 'collision');
|
|
1482
|
+
if (collisions.length) {
|
|
1483
|
+
console.log('\n Name collisions — the store entry is bound to a different repo, so');
|
|
1484
|
+
console.log(' these are two products\' credentials sharing a variable name, not');
|
|
1485
|
+
console.log(' staleness. Exporting would overwrite one product with the other\'s.\n');
|
|
1486
|
+
const seen = new Set();
|
|
1487
|
+
for (const r of collisions) {
|
|
1488
|
+
const line = `${r.key}|${r.binding}|${r.envRepo}`;
|
|
1489
|
+
if (seen.has(line)) continue;
|
|
1490
|
+
seen.add(line);
|
|
1491
|
+
console.log(` ${r.key.padEnd(30)} store bound to '${r.binding}' · .env belongs to '${r.envRepo}'`);
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
const onlyEnv = rows.filter((r) => r.status === 'only_env');
|
|
1496
|
+
if (onlyEnv.length) {
|
|
1497
|
+
console.log('\n Present in .env but absent from the store (import to adopt):\n');
|
|
1498
|
+
for (const r of onlyEnv) {
|
|
1499
|
+
console.log(` ${r.key.padEnd(34)} ${r.file.replace(os.homedir(), '~')}`);
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
console.log('');
|
|
1504
|
+
if (opts.strict && (totals.drift > 0 || totals.only_env > 0)) {
|
|
1505
|
+
console.error(' diff-env --strict: store is not the source of truth for every key.');
|
|
1506
|
+
process.exit(1);
|
|
1507
|
+
}
|
|
1508
|
+
return;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1427
1511
|
if (opts.command === 'import-env') {
|
|
1428
1512
|
const existing = await listSecretsMeta(brainDir);
|
|
1429
1513
|
const existingSet = new Set(existing.map((e) => e.key));
|
|
@@ -4,7 +4,8 @@ import matter from 'gray-matter';
|
|
|
4
4
|
import crypto from 'crypto';
|
|
5
5
|
import { callLocalRuntime, cleanAndParseJSON } from './runtime.mjs';
|
|
6
6
|
import { atomicWrite, safeStringify } from './vault.mjs';
|
|
7
|
-
import {
|
|
7
|
+
import { updateNodeInPlace } from './validated-write.mjs';
|
|
8
|
+
import { getNodes } from './vault-cache.mjs';
|
|
8
9
|
import { logger } from './logger.mjs';
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -567,18 +568,6 @@ export async function runCutoffAudit({ vaultDir, queueDir, runtimeConfig }) {
|
|
|
567
568
|
return { audited: batch.length, flagged, critical };
|
|
568
569
|
}
|
|
569
570
|
|
|
570
|
-
/** Resolve vault dir from node file path (…/memory-vault/category/slug.md). */
|
|
571
|
-
function vaultDirFromNodeFile(filePath) {
|
|
572
|
-
if (!filePath) return null;
|
|
573
|
-
let cur = path.dirname(filePath);
|
|
574
|
-
while (cur && cur !== path.dirname(cur)) {
|
|
575
|
-
if (path.basename(cur) === 'memory-vault') return cur;
|
|
576
|
-
cur = path.dirname(cur);
|
|
577
|
-
}
|
|
578
|
-
// Fallback: parent of category dir
|
|
579
|
-
return path.dirname(path.dirname(filePath));
|
|
580
|
-
}
|
|
581
|
-
|
|
582
571
|
function nodeFilePath(node) {
|
|
583
572
|
return node?._filepath || node?._filePath || null;
|
|
584
573
|
}
|
|
@@ -587,12 +576,11 @@ async function stampVerified(node) {
|
|
|
587
576
|
const fp = nodeFilePath(node);
|
|
588
577
|
if (!fp) return;
|
|
589
578
|
try {
|
|
590
|
-
const
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
invalidate(vaultDirFromNodeFile(fp));
|
|
579
|
+
const result = await updateNodeInPlace(fp, (data) => {
|
|
580
|
+
data.x_cutoff_verified_at = new Date().toISOString();
|
|
581
|
+
data.x_cutoff_risk = 'none';
|
|
582
|
+
});
|
|
583
|
+
if (!result.success) throw new Error((result.validation?.errors || [result.error]).join('; '));
|
|
596
584
|
} catch (err) {
|
|
597
585
|
logger.debug('clarity-rewriter: stampCutoffRiskNone failed', { err: err.message });
|
|
598
586
|
}
|
|
@@ -602,16 +590,15 @@ async function stampCutoffRisk(node, riskLevel, claimCount) {
|
|
|
602
590
|
const fp = nodeFilePath(node);
|
|
603
591
|
if (!fp) return;
|
|
604
592
|
try {
|
|
605
|
-
const
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
invalidate(vaultDirFromNodeFile(fp));
|
|
593
|
+
const result = await updateNodeInPlace(fp, (data) => {
|
|
594
|
+
data.x_cutoff_risk = riskLevel;
|
|
595
|
+
data.x_cutoff_claim_count = claimCount;
|
|
596
|
+
data.x_cutoff_flagged_at = new Date().toISOString();
|
|
597
|
+
// Lower confidence to reflect that claims are unverified
|
|
598
|
+
const confidencePenalty = riskLevel === 'critical' ? 0.3 : riskLevel === 'high' ? 0.2 : 0.1;
|
|
599
|
+
data.confidence = Math.max(0.1, (data.confidence || 0.5) - confidencePenalty);
|
|
600
|
+
});
|
|
601
|
+
if (!result.success) throw new Error((result.validation?.errors || [result.error]).join('; '));
|
|
615
602
|
} catch (err) {
|
|
616
603
|
logger.debug('clarity-rewriter: stampCutoffRisk failed', { err: err.message });
|
|
617
604
|
}
|
|
@@ -777,16 +764,15 @@ async function stampNodeForCorrection(node, correctionSlug, disposition) {
|
|
|
777
764
|
const fp = nodeFilePath(node);
|
|
778
765
|
if (!fp) return;
|
|
779
766
|
try {
|
|
780
|
-
const
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
invalidate(vaultDirFromNodeFile(fp));
|
|
767
|
+
const result = await updateNodeInPlace(fp, (data) => {
|
|
768
|
+
data.status = disposition === 'deleted' ? 'archived' : 'superseded';
|
|
769
|
+
data.superseded_by = correctionSlug;
|
|
770
|
+
data.x_correction_applied_at = new Date().toISOString();
|
|
771
|
+
// Lower confidence significantly — this fact has been proven wrong
|
|
772
|
+
data.confidence = Math.max(0.05, (data.confidence || 0.5) * 0.3);
|
|
773
|
+
data.updated = new Date().toISOString();
|
|
774
|
+
});
|
|
775
|
+
if (!result.success) throw new Error((result.validation?.errors || [result.error]).join('; '));
|
|
790
776
|
} catch (err) {
|
|
791
777
|
logger.debug('clarity-rewriter: stampNodeForCorrection failed', { err: err.message });
|
|
792
778
|
}
|
|
@@ -2,8 +2,8 @@ import fs from 'fs';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import matter from 'gray-matter';
|
|
4
4
|
import { callLocalRuntime } from './runtime.mjs';
|
|
5
|
-
import { atomicWrite, safeStringify } from './vault.mjs';
|
|
6
|
-
import { getNodes
|
|
5
|
+
import { atomicWrite, safeStringify, writeNode } from './vault.mjs';
|
|
6
|
+
import { getNodes } from './vault-cache.mjs';
|
|
7
7
|
import { logger } from './logger.mjs';
|
|
8
8
|
|
|
9
9
|
/**
|
|
@@ -157,15 +157,21 @@ async function promoteToVault(draftNode, confidenceAdj, vaultDir) {
|
|
|
157
157
|
data.updated = new Date().toISOString();
|
|
158
158
|
data.x_promoted_at = new Date().toISOString();
|
|
159
159
|
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
160
|
+
// This is the doorway every research-derived fact walks through on its way
|
|
161
|
+
// into the vault, and it used to be a raw atomicWrite — so a draft whose
|
|
162
|
+
// frontmatter came out of an LLM entered the vault with whatever fields it
|
|
163
|
+
// happened to have. Promotion is a canonical write and has to clear the
|
|
164
|
+
// contract; the inbox copy is only removed once it does.
|
|
165
|
+
const result = await writeNode({ ...data, body: content }, vaultDir, { agentRole: 'system' })
|
|
166
|
+
.catch((err) => ({ success: false, error: err.message }));
|
|
165
167
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
168
|
+
if (result && result.success === false) {
|
|
169
|
+
logger.warn({
|
|
170
|
+
subsystem: 'conclusion-writer',
|
|
171
|
+
message: `Promotion rejected by contract for ${data.slug}: ${result.error}`,
|
|
172
|
+
});
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
169
175
|
|
|
170
176
|
// Remove from inbox
|
|
171
177
|
try { fs.unlinkSync(draftNode._filepath); } catch { /* ignore */ }
|
|
@@ -181,6 +187,8 @@ async function applyRevision(draftNode, confidenceAdj, revisionInstructions) {
|
|
|
181
187
|
data.x_revision_requested = revisionInstructions;
|
|
182
188
|
data.updated = new Date().toISOString();
|
|
183
189
|
|
|
190
|
+
// ssss-raw-write: draftNode is an inbox draft, not a vault node — it is
|
|
191
|
+
// validated on promotion, not here.
|
|
184
192
|
atomicWrite(draftNode._filepath, safeStringify(content, data));
|
|
185
193
|
}
|
|
186
194
|
|
|
@@ -198,6 +206,8 @@ async function quarantineDraft(draftNode, reason, quarantineDir) {
|
|
|
198
206
|
data.x_quarantined_at = new Date().toISOString();
|
|
199
207
|
|
|
200
208
|
const targetPath = path.join(quarantineDir, `${data.slug}.md`);
|
|
209
|
+
// ssss-raw-write: quarantine is a holding pen outside the vault — a rejected
|
|
210
|
+
// draft must be parkable precisely when it cannot pass validation.
|
|
201
211
|
atomicWrite(targetPath, safeStringify(content, data));
|
|
202
212
|
|
|
203
213
|
try { fs.unlinkSync(draftNode._filepath); } catch { /* ignore */ }
|
package/src/core/crypto.mjs
CHANGED
|
@@ -8,28 +8,98 @@ const ALGORITHM = 'aes-256-gcm';
|
|
|
8
8
|
|
|
9
9
|
const scryptAsync = promisify(crypto.scrypt);
|
|
10
10
|
|
|
11
|
+
const SCRYPT_PARAMS = {
|
|
12
|
+
N: 2 ** 16, // CPU/memory cost: 65536
|
|
13
|
+
r: 8,
|
|
14
|
+
p: 1,
|
|
15
|
+
maxmem: 128 * 1024 * 1024
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Derived-key cache.
|
|
20
|
+
*
|
|
21
|
+
* The KDF above is deliberately expensive (~64 MB, measured 190-240 ms per
|
|
22
|
+
* derivation). That cost is correct ONCE — it is what makes the store
|
|
23
|
+
* brute-force resistant — but it was being paid on every single read:
|
|
24
|
+
* loadSecrets() re-derives per call, and listSecretsMeta / getSecretsCatalog /
|
|
25
|
+
* getSharedValueHealth each call loadSecrets, so one Secrets page load paid it
|
|
26
|
+
* three or more times.
|
|
27
|
+
*
|
|
28
|
+
* The cache is keyed by BOTH the salt and a digest of the password, so:
|
|
29
|
+
* - re-encrypting the store (fresh random salt on every save) misses, and
|
|
30
|
+
* - rotating the master password misses.
|
|
31
|
+
* Neither can serve a stale key. Entries are bounded and evicted oldest-first.
|
|
32
|
+
*
|
|
33
|
+
* A cached key is strictly less sensitive than what the caller already holds:
|
|
34
|
+
* the password itself is in this process, and the decrypted secrets are about
|
|
35
|
+
* to be. Set TR_SECRETS_KEY_CACHE=0 to disable.
|
|
36
|
+
*/
|
|
37
|
+
const derivedKeyCache = new Map();
|
|
38
|
+
const MAX_CACHED_KEYS = 8;
|
|
39
|
+
|
|
40
|
+
function keyCacheEnabled() {
|
|
41
|
+
return process.env.TR_SECRETS_KEY_CACHE !== '0';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function keyCacheId(password, salt) {
|
|
45
|
+
const pwDigest = crypto.createHash('sha256').update(String(password)).digest();
|
|
46
|
+
return crypto.createHash('sha256').update(salt).update(pwDigest).digest('base64');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function cacheGet(id) {
|
|
50
|
+
if (!derivedKeyCache.has(id)) return null;
|
|
51
|
+
// Refresh recency: delete + re-set moves the entry to the tail.
|
|
52
|
+
const key = derivedKeyCache.get(id);
|
|
53
|
+
derivedKeyCache.delete(id);
|
|
54
|
+
derivedKeyCache.set(id, key);
|
|
55
|
+
return key;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function cacheSet(id, key) {
|
|
59
|
+
derivedKeyCache.set(id, key);
|
|
60
|
+
while (derivedKeyCache.size > MAX_CACHED_KEYS) {
|
|
61
|
+
derivedKeyCache.delete(derivedKeyCache.keys().next().value);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Drop every cached key — call after a master-password rotation. */
|
|
66
|
+
export function clearDerivedKeyCache() {
|
|
67
|
+
derivedKeyCache.clear();
|
|
68
|
+
}
|
|
69
|
+
|
|
11
70
|
/**
|
|
12
71
|
* Derives a 32-byte encryption key from a master password using scrypt
|
|
13
72
|
* (Node built-in — no native dependency required).
|
|
14
73
|
*
|
|
15
74
|
* Parameters chosen for ~64 MB of memory, matching OWASP scrypt guidance.
|
|
75
|
+
*
|
|
76
|
+
* @param {object} [opts]
|
|
77
|
+
* @param {boolean} [opts.cache=true] - false on the encrypt path: every save
|
|
78
|
+
* generates a fresh random salt that is never derived against again, so
|
|
79
|
+
* caching it would only evict keys that ARE reused.
|
|
16
80
|
*/
|
|
17
|
-
export async function deriveKey(password, salt) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
81
|
+
export async function deriveKey(password, salt, opts = {}) {
|
|
82
|
+
const useCache = opts.cache !== false && keyCacheEnabled();
|
|
83
|
+
const id = useCache ? keyCacheId(password, salt) : null;
|
|
84
|
+
if (id) {
|
|
85
|
+
const hit = cacheGet(id);
|
|
86
|
+
if (hit) return hit;
|
|
87
|
+
}
|
|
88
|
+
const key = await scryptAsync(password, salt, 32, SCRYPT_PARAMS);
|
|
89
|
+
if (id) cacheSet(id, key);
|
|
90
|
+
return key;
|
|
24
91
|
}
|
|
25
92
|
|
|
26
|
-
export function deriveKeySync(password, salt) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
93
|
+
export function deriveKeySync(password, salt, opts = {}) {
|
|
94
|
+
const useCache = opts.cache !== false && keyCacheEnabled();
|
|
95
|
+
const id = useCache ? keyCacheId(password, salt) : null;
|
|
96
|
+
if (id) {
|
|
97
|
+
const hit = cacheGet(id);
|
|
98
|
+
if (hit) return hit;
|
|
99
|
+
}
|
|
100
|
+
const key = crypto.scryptSync(password, salt, 32, SCRYPT_PARAMS);
|
|
101
|
+
if (id) cacheSet(id, key);
|
|
102
|
+
return key;
|
|
33
103
|
}
|
|
34
104
|
|
|
35
105
|
/**
|
|
@@ -38,7 +108,7 @@ export function deriveKeySync(password, salt) {
|
|
|
38
108
|
*/
|
|
39
109
|
export async function encryptSecrets(secretsObj, password) {
|
|
40
110
|
const salt = crypto.randomBytes(16);
|
|
41
|
-
const key = await deriveKey(password, salt);
|
|
111
|
+
const key = await deriveKey(password, salt, { cache: false });
|
|
42
112
|
const iv = crypto.randomBytes(12);
|
|
43
113
|
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
|
44
114
|
|
|
@@ -52,7 +122,7 @@ export async function encryptSecrets(secretsObj, password) {
|
|
|
52
122
|
|
|
53
123
|
export function encryptSecretsSync(secretsObj, password) {
|
|
54
124
|
const salt = crypto.randomBytes(16);
|
|
55
|
-
const key = deriveKeySync(password, salt);
|
|
125
|
+
const key = deriveKeySync(password, salt, { cache: false });
|
|
56
126
|
const iv = crypto.randomBytes(12);
|
|
57
127
|
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
|
58
128
|
|
package/src/core/daemon-loop.mjs
CHANGED
|
@@ -241,7 +241,7 @@ async function main() {
|
|
|
241
241
|
|
|
242
242
|
// Initial repo sync — auto-ingest all .md files from registered repos
|
|
243
243
|
try {
|
|
244
|
-
const syncResult = syncAllRepos();
|
|
244
|
+
const syncResult = await syncAllRepos();
|
|
245
245
|
logger.info({
|
|
246
246
|
subsystem: 'daemon-loop',
|
|
247
247
|
message: `Boot repo-sync: ${syncResult.totalIngested} new files across ${syncResult.repos.length} repos`,
|
|
@@ -401,7 +401,7 @@ async function main() {
|
|
|
401
401
|
|
|
402
402
|
// Periodic repo sync — pick up any new/changed .md files in registered repos
|
|
403
403
|
try {
|
|
404
|
-
const syncResult = syncAllRepos();
|
|
404
|
+
const syncResult = await syncAllRepos();
|
|
405
405
|
if (syncResult.totalIngested > 0) {
|
|
406
406
|
logger.info({
|
|
407
407
|
subsystem: 'daemon-loop',
|