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.
Files changed (45) hide show
  1. package/bin/total-recall.mjs +1 -0
  2. package/frontend/dist/assets/{index-c6GHGS_j.js → index-CtCDaZYZ.js} +1 -1
  3. package/frontend/dist/index.html +1 -1
  4. package/frontend/src/pages/SecretsPage.tsx +111 -52
  5. package/package.json +3 -2
  6. package/scaffold/.agent/skills/total-recall/SKILL.md +199 -7
  7. package/scaffold/.agent/skills/total-recall/memory-vault/concepts/cli-help-reference.md +13 -4
  8. package/scaffold/.agent/skills/total-recall/memory-vault/invariants/operating-instructions.md +2 -0
  9. package/scaffold/.agent/skills/total-recall/references/api-reference.md +1 -1
  10. package/scaffold/.agent/skills/total-recall/references/cli-reference.md +1 -2
  11. package/scaffold/.agent/skills/total-recall/research-queue.jsonl +7 -0
  12. package/scaffold/.agent/skills/total-recall/skills-registry/index.yaml +2461 -6
  13. package/src/cli/backfill.mjs +176 -0
  14. package/src/cli/init.mjs +1 -1
  15. package/src/cli/proposals.mjs +3 -3
  16. package/src/cli/secret.mjs +84 -0
  17. package/src/core/clarity-rewriter.mjs +25 -39
  18. package/src/core/conclusion-writer.mjs +20 -10
  19. package/src/core/crypto.mjs +86 -16
  20. package/src/core/daemon-loop.mjs +2 -2
  21. package/src/core/dream.mjs +49 -58
  22. package/src/core/env-import.mjs +18 -0
  23. package/src/core/inference-engine.mjs +15 -17
  24. package/src/core/migrate.mjs +5 -1
  25. package/src/core/okf-adapter.mjs +2 -0
  26. package/src/core/post-mortem.mjs +30 -26
  27. package/src/core/proposal-applier.mjs +26 -10
  28. package/src/core/repo-sync.mjs +51 -39
  29. package/src/core/scheduler.mjs +1 -0
  30. package/src/core/schema.mjs +24 -1
  31. package/src/core/search.mjs +1 -1
  32. package/src/core/secrets-env-diff.mjs +132 -0
  33. package/src/core/secrets-rekey.mjs +0 -0
  34. package/src/core/snapshot.mjs +32 -9
  35. package/src/core/ssss-kernel-bridge.mjs +46 -6
  36. package/src/core/task-envelope.mjs +1 -0
  37. package/src/core/validated-write.mjs +68 -5
  38. package/src/core/vault-backfill.mjs +259 -0
  39. package/src/core/vault.mjs +22 -4
  40. package/src/server/auth.mjs +42 -8
  41. package/src/server/health-coverage.mjs +74 -0
  42. package/src/server/index.mjs +60 -36
  43. package/src/server/routes/brains.mjs +1 -1
  44. package/src/server/routes/proposals.mjs +4 -4
  45. 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
  }
@@ -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}`);
@@ -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 { getNodes, invalidate } from './vault-cache.mjs';
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 raw = fs.readFileSync(fp, 'utf8');
591
- const { data, content } = matter(raw);
592
- data.x_cutoff_verified_at = new Date().toISOString();
593
- data.x_cutoff_risk = 'none';
594
- atomicWrite(fp, safeStringify(content, data));
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 raw = fs.readFileSync(fp, 'utf8');
606
- const { data, content } = matter(raw);
607
- data.x_cutoff_risk = riskLevel;
608
- data.x_cutoff_claim_count = claimCount;
609
- data.x_cutoff_flagged_at = new Date().toISOString();
610
- // Lower confidence to reflect that claims are unverified
611
- const confidencePenalty = riskLevel === 'critical' ? 0.3 : riskLevel === 'high' ? 0.2 : 0.1;
612
- data.confidence = Math.max(0.1, (data.confidence || 0.5) - confidencePenalty);
613
- atomicWrite(fp, safeStringify(content, data));
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 raw = fs.readFileSync(fp, 'utf8');
781
- const { data, content } = matter(raw);
782
- data.status = disposition === 'deleted' ? 'archived' : 'superseded';
783
- data.superseded_by = correctionSlug;
784
- data.x_correction_applied_at = new Date().toISOString();
785
- // Lower confidence significantly — this fact has been proven wrong
786
- data.confidence = Math.max(0.05, (data.confidence || 0.5) * 0.3);
787
- data.updated = new Date().toISOString();
788
- atomicWrite(fp, safeStringify(content, data));
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, invalidate } from './vault-cache.mjs';
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
- // Compute target path in vault
161
- const targetDir = path.join(vaultDir, data.category || 'concepts');
162
- if (!fs.existsSync(targetDir)) {
163
- fs.mkdirSync(targetDir, { recursive: true });
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
- const targetPath = path.join(targetDir, `${data.slug}.md`);
167
- atomicWrite(targetPath, safeStringify(content, data));
168
- invalidate(vaultDir);
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 */ }
@@ -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
- return scryptAsync(password, salt, 32, {
19
- N: 2 ** 16, // CPU/memory cost: 65536
20
- r: 8,
21
- p: 1,
22
- maxmem: 128 * 1024 * 1024
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
- return crypto.scryptSync(password, salt, 32, {
28
- N: 2 ** 16,
29
- r: 8,
30
- p: 1,
31
- maxmem: 128 * 1024 * 1024
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
 
@@ -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',