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,132 @@
1
+ /**
2
+ * Compare repo .env files against the secrets store.
3
+ *
4
+ * The store is meant to be the SSOT and .env files its projections, but nothing
5
+ * ever checked that they agreed. Drift is silent and consequential: the same
6
+ * key can hold a rotated credential in one place and a retired one in the
7
+ * other, and whichever the process happens to read decides whether a service
8
+ * works. Length-equal drift is the dangerous kind — same provider, same format,
9
+ * different key — because it looks like a match in every summary that counts
10
+ * keys rather than comparing values.
11
+ *
12
+ * Reports only shapes and lengths. Secret values never leave this module.
13
+ */
14
+
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import os from 'node:os';
18
+ import { loadSecrets, listSecretsMeta } from './secrets-store.mjs';
19
+ import { parseEnvText, isCandidateKey } from './env-import.mjs';
20
+
21
+ /** Values that are obviously templates rather than credentials. */
22
+ const PLACEHOLDER = /^(your[_-]?|xxx+|changeme|<|placeholder|todo|sk-your|example)/i;
23
+
24
+ export function isPlaceholderValue(value) {
25
+ const v = String(value || '').trim();
26
+ if (!v) return true;
27
+ if (PLACEHOLDER.test(v)) return true;
28
+ // `..._HERE`, `PUT_KEY_HERE` and friends
29
+ return /(_|-)?here$/i.test(v);
30
+ }
31
+
32
+ /**
33
+ * Classify one key across the two sources.
34
+ * @returns {'match'|'drift'|'only_env'|'only_store'|'placeholder'}
35
+ */
36
+ export function classify(envValue, storeValue, keyInStore) {
37
+ if (envValue !== undefined && isPlaceholderValue(envValue)) return 'placeholder';
38
+ if (envValue === undefined) return 'only_store';
39
+ if (!keyInStore) return 'only_env';
40
+ return String(storeValue) === String(envValue) ? 'match' : 'drift';
41
+ }
42
+
43
+ /**
44
+ * Locate the .env files worth comparing, from the project registry plus any
45
+ * explicit paths. Never hardcodes a repo: registry entries and caller input only.
46
+ */
47
+ export function resolveEnvFiles(roots = [], filenames = ['.env']) {
48
+ const files = [];
49
+ for (const root of roots) {
50
+ for (const name of filenames) {
51
+ const candidate = path.join(root, name);
52
+ if (fs.existsSync(candidate)) files.push(candidate);
53
+ // Sub-app env files (e.g. a server/ workspace) drift independently.
54
+ const nested = path.join(root, 'server', name);
55
+ if (fs.existsSync(nested)) files.push(nested);
56
+ }
57
+ }
58
+ return files;
59
+ }
60
+
61
+ /**
62
+ * Diff every candidate secret in the given .env files against the store.
63
+ */
64
+ /**
65
+ * Which repo a .env belongs to, for comparison against a secret's binding.
66
+ * Walks up to the directory holding the file, ignoring a nested app dir.
67
+ */
68
+ export function repoOfEnvFile(file) {
69
+ const parts = path.resolve(file).split(path.sep);
70
+ const idx = parts.lastIndexOf('Github');
71
+ if (idx !== -1 && parts[idx + 1]) return parts[idx + 1];
72
+ return path.basename(path.dirname(path.resolve(file)));
73
+ }
74
+
75
+ export async function diffEnvAgainstStore(brainDir, envFiles) {
76
+ const store = await loadSecrets(brainDir);
77
+ const meta = await listSecretsMeta(brainDir).catch(() => []);
78
+ const bindingOf = new Map(meta.map((m) => [m.key, m.repos || m.repo || null]));
79
+ const rows = [];
80
+ const totals = { match: 0, drift: 0, only_env: 0, placeholder: 0, collision: 0 };
81
+
82
+ for (const file of envFiles) {
83
+ let parsed;
84
+ try {
85
+ parsed = parseEnvText(fs.readFileSync(file, 'utf8'));
86
+ } catch (err) {
87
+ rows.push({ key: '(unreadable)', file, status: 'error', detail: err.message });
88
+ continue;
89
+ }
90
+
91
+ for (const [key, value] of Object.entries(parsed)) {
92
+ if (!isCandidateKey(key)) continue;
93
+ let status = classify(value, store[key], key in store);
94
+ if (status === 'only_store') continue;
95
+
96
+ // A store entry is bound to exactly one repo. When the .env belongs to a
97
+ // different repo, the two values are not the same credential wearing one
98
+ // name — they are two products' credentials colliding on a shared
99
+ // variable name (STRIPE_SECRET_KEY, MAILCOW_API_KEY). Reporting that as
100
+ // drift invites "fixing" it by overwriting a live key with another
101
+ // product's.
102
+ const binding = bindingOf.get(key) || null;
103
+ const envRepo = repoOfEnvFile(file);
104
+ const crossProduct = Boolean(
105
+ status === 'drift' && binding && !String(binding).split(',').includes(envRepo),
106
+ );
107
+ if (crossProduct) status = 'collision';
108
+
109
+ totals[status] += 1;
110
+ if (status === 'match') continue;
111
+ rows.push({
112
+ key,
113
+ file,
114
+ status,
115
+ binding,
116
+ envRepo,
117
+ envLength: String(value ?? '').length,
118
+ storeLength: key in store ? String(store[key]).length : null,
119
+ // Equal lengths mean the same credential format — a rotation that
120
+ // landed on one side only, rather than two unrelated values.
121
+ sameShape: key in store && String(store[key]).length === String(value ?? '').length,
122
+ });
123
+ }
124
+ }
125
+
126
+ rows.sort((a, b) => (a.status === b.status ? a.key.localeCompare(b.key) : a.status.localeCompare(b.status)));
127
+ return { totals, rows, storeKeyCount: Object.keys(store).filter((k) => !k.startsWith('__')).length };
128
+ }
129
+
130
+ export function defaultBrainDir() {
131
+ return path.join(os.homedir(), '.agent', 'skills', 'total-recall');
132
+ }
Binary file
@@ -22,14 +22,19 @@ export function getSnapshotsDir() {
22
22
  }
23
23
 
24
24
  /**
25
- * Create a point-in-time snapshot of the memory vault.
25
+ * Create a point-in-time snapshot of a memory vault.
26
+ *
27
+ * `vaultDir` is explicit because this used to always snapshot the global brain
28
+ * vault no matter which vault the caller was about to modify — so a migration
29
+ * against a project vault took its safety snapshot of an unrelated vault and
30
+ * the thing being changed had no backup at all.
26
31
  *
27
32
  * @param {string} reason - The reason for the snapshot (e.g. 'pre-dream', 'manual')
33
+ * @param {string} [vaultDir] - Vault to snapshot; defaults to the global brain vault
28
34
  * @returns {{ success: boolean, snapshot_id: string, path: string, error?: string }}
29
35
  */
30
- export function createSnapshot(reason = 'manual') {
36
+ export function createSnapshot(reason = 'manual', vaultDir = path.join(brainDir, 'memory-vault')) {
31
37
  try {
32
- const vaultDir = path.join(brainDir, 'memory-vault');
33
38
  const snapshotsDir = getSnapshotsDir();
34
39
 
35
40
  if (!fs.existsSync(vaultDir)) {
@@ -48,6 +53,7 @@ export function createSnapshot(reason = 'manual') {
48
53
  snapshot_id: snapshotId,
49
54
  created_at: new Date().toISOString(),
50
55
  reason,
56
+ vault_dir: vaultDir,
51
57
  };
52
58
  fs.writeFileSync(metaPath, JSON.stringify(metadata, null, 2));
53
59
 
@@ -91,14 +97,20 @@ export function listSnapshots() {
91
97
  }
92
98
 
93
99
  /**
94
- * Rollback the memory vault to a specific snapshot.
100
+ * Rollback a memory vault to a specific snapshot.
101
+ *
102
+ * Restores into the vault the snapshot was actually taken from, recorded in its
103
+ * metadata. This used to always restore into the global brain vault, so rolling
104
+ * back a project snapshot would have deleted the global vault and unpacked
105
+ * another project's nodes over it — destroying good data while leaving the
106
+ * vault in trouble untouched. Snapshots predating the recorded field came from
107
+ * the global vault, which is the fallback.
95
108
  *
96
109
  * @param {string} snapshotId - The ID of the snapshot to restore.
97
110
  * @returns {{ success: boolean, error?: string }}
98
111
  */
99
112
  export function rollbackVault(snapshotId) {
100
113
  try {
101
- const vaultDir = path.join(brainDir, 'memory-vault');
102
114
  const snapshotsDir = getSnapshotsDir();
103
115
 
104
116
  const tarball = path.join(snapshotsDir, `vault-${snapshotId}.tar.gz`);
@@ -106,8 +118,19 @@ export function rollbackVault(snapshotId) {
106
118
  return { success: false, error: `Snapshot ${snapshotId} not found.` };
107
119
  }
108
120
 
109
- // Safety measure: take a backup of the CURRENT state before destroying it
110
- const preRollback = createSnapshot(`pre-rollback-to-${snapshotId}`);
121
+ let vaultDir = path.join(brainDir, 'memory-vault');
122
+ const metaPath = path.join(snapshotsDir, `vault-${snapshotId}.json`);
123
+ if (fs.existsSync(metaPath)) {
124
+ try {
125
+ const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
126
+ if (meta.vault_dir) vaultDir = meta.vault_dir;
127
+ } catch (err) {
128
+ return { success: false, error: `Snapshot ${snapshotId} metadata is unreadable: ${err.message}` };
129
+ }
130
+ }
131
+
132
+ // Safety measure: back up the CURRENT state of that same vault first
133
+ const preRollback = createSnapshot(`pre-rollback-to-${snapshotId}`, vaultDir);
111
134
  if (!preRollback.success) {
112
135
  return { success: false, error: `Failed to create pre-rollback safety snapshot: ${preRollback.error}` };
113
136
  }
@@ -124,8 +147,8 @@ export function rollbackVault(snapshotId) {
124
147
  throw new Error(`tar extraction failed: ${result.stderr.toString()}`);
125
148
  }
126
149
 
127
- logger.info('snapshot', `Rolled back to snapshot ${snapshotId}`);
128
- return { success: true };
150
+ logger.info('snapshot', `Rolled back ${vaultDir} to snapshot ${snapshotId}`);
151
+ return { success: true, vault_dir: vaultDir };
129
152
  } catch (err) {
130
153
  logger.error('snapshot', `Rollback failed: ${err.message}`);
131
154
  return { success: false, error: err.message };
@@ -499,9 +499,27 @@ export function scanDirectCanonicalWrites(rootDir = path.resolve('src'), options
499
499
  }
500
500
 
501
501
  /**
502
- * Modules that write memory-like markdown without going through Operation Contract.
503
- * Heuristic: atomicWrite/writeNode call sites outside the approved contract surface.
502
+ * Modules that write vault nodes without going through the Operation Contract.
503
+ *
504
+ * The previous heuristic was wrong in both directions. It flagged any use of
505
+ * `writeNode(`, which is the *correct* path — vault.writeNode delegates to
506
+ * writeNodeValidatedAsync — so eleven compliant modules showed up as
507
+ * violations. And it only matched `atomicWrite(...".md")`, so it missed every
508
+ * bypass whose target is a variable: dream.writeDailyNote hand-assembled a YAML
509
+ * string and wrote it to `filePath`, which is exactly the write this gate
510
+ * exists to catch.
511
+ *
512
+ * What actually constitutes a bypass is the write target, not the call name: a
513
+ * raw write into the vault (memory-vault, vaultDir, a node's own _filepath)
514
+ * skips validation. Raw writes into the staging areas — memory-inbox/ and
515
+ * scheduler/queue/ — are legitimate; those files are not vault nodes and are
516
+ * promoted through the contract later.
504
517
  */
518
+ const RAW_WRITE_CALL = /\b(?:atomicWrite|fs\.writeFileSync|writeFileSync)\s*\(/;
519
+ const VAULT_TARGET = /\b(?:vaultDir|vaultRoot|memory-vault|_filepath|_filePath|proposalsDir|dailyDir)\b/;
520
+ const RAW_WRITE_WAIVER = /ssss-raw-write:/;
521
+ const STAGING_TARGET = /\b(?:inboxDir|queueDir|conflictsDir|quarantineDir|INBOX_DIR|QUEUE_DIR|agendaFile|snapshotPath)\b/;
522
+
505
523
  export function listUnapprovedCanonicalWriters(rootDir = path.resolve('src/core')) {
506
524
  const approved = new Set(DIRECT_WRITE_APPROVED.map((p) => path.normalize(p)));
507
525
  const writers = [];
@@ -515,13 +533,35 @@ export function listUnapprovedCanonicalWriters(rootDir = path.resolve('src/core'
515
533
  if (!entry.name.endsWith('.mjs')) continue;
516
534
  const relative = path.relative(process.cwd(), absolute).split(path.sep).join('/');
517
535
  if (approved.has(relative) || approved.has(path.normalize(relative))) continue;
518
- const text = fs.readFileSync(absolute, 'utf8');
519
- if (/\bwriteNode\s*\(/.test(text) || /\batomicWrite\s*\([^)]*\.md/.test(text)) {
520
- writers.push(relative);
536
+
537
+ const lines = fs.readFileSync(absolute, 'utf8').split('\n');
538
+ const offenses = [];
539
+ for (let i = 0; i < lines.length; i += 1) {
540
+ if (!RAW_WRITE_CALL.test(lines[i])) continue;
541
+ // A call can wrap onto the next line; check the target on both.
542
+ const window = `${lines[i]}\n${lines[i + 1] || ''}`;
543
+ if (STAGING_TARGET.test(window)) continue;
544
+ if (!VAULT_TARGET.test(window)) continue;
545
+ // Waivers are per call site, never per file: a file allowlisted once
546
+ // for a legitimate reason silently absolves every raw write added to it
547
+ // later, which is how these bypasses stayed invisible.
548
+ //
549
+ // Only the comment block *immediately* above counts. A fixed look-back
550
+ // window has the same leak in miniature — one waived write would cover
551
+ // an unwaived one a line or two below it.
552
+ let waived = false;
553
+ for (let j = i - 1; j >= 0; j -= 1) {
554
+ const above = lines[j].trim();
555
+ if (!above.startsWith('//') && !above.startsWith('*') && !above.startsWith('/*')) break;
556
+ if (RAW_WRITE_WAIVER.test(above)) { waived = true; break; }
557
+ }
558
+ if (waived) continue;
559
+ offenses.push({ line: i + 1, source: lines[i].trim() });
521
560
  }
561
+ if (offenses.length) writers.push({ file: relative, offenses });
522
562
  }
523
563
  }
524
- return writers.sort();
564
+ return writers.sort((a, b) => a.file.localeCompare(b.file));
525
565
  }
526
566
 
527
567
  export function resolveHostDefinition(type) {
@@ -201,6 +201,7 @@ export function persistEnvelope(envelope, queueDir) {
201
201
  for (const key of Object.keys(frontmatter)) {
202
202
  if (frontmatter[key] === undefined) delete frontmatter[key];
203
203
  }
204
+ // ssss-raw-write: scheduler/queue task envelope, not a vault node.
204
205
  atomicWrite(filepath, safeStringify(body || task.intent || '', frontmatter));
205
206
  task._filepath = filepath;
206
207
  return task;
@@ -8,12 +8,13 @@
8
8
  */
9
9
 
10
10
  import crypto from 'node:crypto';
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import matter from 'gray-matter';
11
14
  import { processOperationAsync } from './operation-validator.mjs';
12
- import { safeStringify } from './vault.mjs';
15
+ import { safeStringify, isSafeVaultName } from './vault.mjs';
13
16
  import { invalidate } from './vault-cache.mjs';
14
17
 
15
- const SAFE_NAME = /^[a-zA-Z0-9_-]+$/;
16
-
17
18
  /**
18
19
  * Normalize a vault node so package universal frontmatter + TR memory v2 pass.
19
20
  */
@@ -107,10 +108,10 @@ function buildEnvelope(node, options = {}) {
107
108
  const prepared = prepareNodeForContract(node);
108
109
  const { body, _filePath, _layer, _filepath, ...frontmatter } = prepared;
109
110
 
110
- if (!SAFE_NAME.test(String(frontmatter.slug))) {
111
+ if (!isSafeVaultName(frontmatter.slug)) {
111
112
  throw new Error(`Invalid slug: ${frontmatter.slug}`);
112
113
  }
113
- if (!SAFE_NAME.test(String(frontmatter.category))) {
114
+ if (!isSafeVaultName(frontmatter.category)) {
114
115
  throw new Error(`Invalid category: ${frontmatter.category}`);
115
116
  }
116
117
 
@@ -166,3 +167,65 @@ export async function writeNodeValidatedAsync(node, vaultDir, options = {}) {
166
167
  export function validateNode(node, vaultDir) {
167
168
  return writeNodeValidatedAsync(node, vaultDir, { dryRun: true });
168
169
  }
170
+
171
+ /**
172
+ * Resolve the vault root that owns a node file.
173
+ *
174
+ * Walks up looking for a `memory-vault` directory. Falls back to the
175
+ * grandparent (i.e. the parent of the category folder) so scoped roots and
176
+ * test fixtures that do not use the canonical name still work.
177
+ */
178
+ export function resolveVaultDir(filePath) {
179
+ if (!filePath) return null;
180
+ let cur = path.dirname(path.resolve(filePath));
181
+ while (cur && cur !== path.dirname(cur)) {
182
+ if (path.basename(cur) === 'memory-vault') return cur;
183
+ cur = path.dirname(cur);
184
+ }
185
+ return path.dirname(path.dirname(path.resolve(filePath)));
186
+ }
187
+
188
+ /**
189
+ * Read a node off disk, mutate its frontmatter, and write it back through the
190
+ * Core Contract.
191
+ *
192
+ * Stampers (confidence decay, cutoff-risk flags, supersede markers, priority
193
+ * escalation) used to read → mutate → atomicWrite, which skipped validation
194
+ * entirely: a stamper could lower `confidence` below its floor or drop a
195
+ * required field and nothing would notice until a much later read failed.
196
+ *
197
+ * The on-disk location is preserved via an explicit path override. Without it
198
+ * the envelope would recompute `<category>/<slug>.md`, and any node whose
199
+ * folder is outside the closed memory-category enum (daily/, corrections/,
200
+ * queries/, system/) would be rewritten to a new location while the original
201
+ * was left behind as an orphan.
202
+ *
203
+ * @param {string} filePath Absolute path to an existing node file
204
+ * @param {(data: object, body: string) => (object|void)} mutate
205
+ * Mutates frontmatter in place, or returns `{ data, body }` to replace.
206
+ * @param {object} [options] Passed through to writeNodeValidatedAsync
207
+ * @returns {Promise<object>} Operation response, or `{ success: false }`
208
+ */
209
+ export async function updateNodeInPlace(filePath, mutate, options = {}) {
210
+ if (!filePath || !fs.existsSync(filePath)) {
211
+ return { success: false, error: `Node file not found: ${filePath}` };
212
+ }
213
+
214
+ const raw = fs.readFileSync(filePath, 'utf8');
215
+ const parsed = matter(raw);
216
+ const data = { ...parsed.data };
217
+ let body = parsed.content;
218
+
219
+ const replacement = mutate(data, body);
220
+ const nextData = replacement?.data || data;
221
+ if (replacement && typeof replacement.body === 'string') body = replacement.body;
222
+
223
+ const vaultDir = options.vaultDir || resolveVaultDir(filePath);
224
+ const relativePath = path.relative(vaultDir, path.resolve(filePath))
225
+ .split(path.sep).join('/');
226
+
227
+ return writeNodeValidatedAsync({ ...nextData, body }, vaultDir, {
228
+ ...options,
229
+ path: relativePath,
230
+ });
231
+ }
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Vault backfill — bring historical nodes up to the current SSSS contract.
3
+ *
4
+ * Most nodes in a long-lived vault predate the schema they are now validated
5
+ * against: SSSS 0.9 §4.2 made `description` and `timestamp` universally
6
+ * required, and for a long stretch nothing enforced it — several subsystems
7
+ * wrote nodes with raw filesystem writes that never reached the Core Contract.
8
+ *
9
+ * This walks every node, asks the contract what it would take to make it valid,
10
+ * and reports that. Applying is opt-in and takes a snapshot of the vault being
11
+ * changed first, because this rewrites the user's own memory at scale.
12
+ *
13
+ * Deliberately walks the filesystem rather than using getNodes(): getNodes()
14
+ * returns only `type: memory` documents, so proposals, migrations and every
15
+ * other vault primitive would be silently skipped by exactly the pass meant to
16
+ * repair them.
17
+ */
18
+
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+ import matter from 'gray-matter';
22
+ import { prepareNodeForContract, validateNode, updateNodeInPlace } from './validated-write.mjs';
23
+ import { createSnapshot } from './snapshot.mjs';
24
+ import { logger } from './logger.mjs';
25
+
26
+ // index.md / log.md are OKF bundle artifacts written alongside the nodes, not
27
+ // nodes themselves — okf-adapter excludes them from its own walks too.
28
+ const NON_NODE_FILES = new Set(['index.md', 'log.md']);
29
+
30
+ // memory-vault/queries/ holds Obsidian Dataview dashboards (`type: query`,
31
+ // no slug, no category, a ```dataview block for a body). They are saved views
32
+ // over the vault, not entries in it — `query` is not an SSSS primitive and
33
+ // never will be. Repairing them would mean inventing a slug and category for a
34
+ // file that should not have either.
35
+ const NON_NODE_DIRS = new Set(['queries']);
36
+
37
+ export function walkVaultNodes(dir, acc = []) {
38
+ if (!fs.existsSync(dir)) return acc;
39
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
40
+ const full = path.join(dir, entry.name);
41
+ if (entry.isDirectory()) {
42
+ if (!entry.name.startsWith('.') && !NON_NODE_DIRS.has(entry.name)) walkVaultNodes(full, acc);
43
+ } else if (entry.name.endsWith('.md') && !NON_NODE_FILES.has(entry.name)) {
44
+ acc.push(full);
45
+ }
46
+ }
47
+ return acc;
48
+ }
49
+
50
+
51
+ /**
52
+ * Repair known-bad *shapes* left by writers that bypassed the contract.
53
+ *
54
+ * prepareNodeForContract only fills fields that are missing; it never rewrites
55
+ * a value that is present, which is the right default — but it means a field
56
+ * stored with the wrong type stays wrong forever. These are the specific
57
+ * corruptions found in the vault, each repaired so no information is lost:
58
+ * the old value is carried into the correct structure rather than replaced by
59
+ * a default.
60
+ *
61
+ * Anything not listed here is left alone. A node this cannot express is
62
+ * reported, never guessed at.
63
+ */
64
+ export function normalizeLegacyShapes(data) {
65
+ const out = { ...data };
66
+ const repairs = [];
67
+
68
+ // repo-sync wrote `source` as "repo-sync:<repo>:<path>" (or a bare label) and
69
+ // `decay` as the number 0. Both are objects in MemoryNodeSchema.
70
+ if (typeof out.source === 'string') {
71
+ const raw = out.source;
72
+ const match = /^repo-sync:(.+?):(.+)$/.exec(raw);
73
+ out.source = match
74
+ ? { type: 'repo-sync', session_id: `${match[1]}:${match[2]}`, agent: 'repo-sync', evidence_count: 1 }
75
+ : { type: raw, session_id: raw, evidence_count: 1 };
76
+ repairs.push('source');
77
+ }
78
+
79
+ if (typeof out.decay === 'number') {
80
+ // The number was an access count in the old shape, never a half-life.
81
+ out.decay = { half_life_days: 365, access_count: out.decay };
82
+ repairs.push('decay');
83
+ }
84
+
85
+ // YAML parses an unquoted ISO timestamp into a Date. Most write paths
86
+ // stringify first; the ones that did not left Date objects on disk.
87
+ for (const key of ['timestamp', 'created', 'updated', 'last_accessed', 'x_temporal_context']) {
88
+ if (out[key] instanceof Date) {
89
+ out[key] = out[key].toISOString();
90
+ repairs.push(key);
91
+ }
92
+ }
93
+
94
+ if (Array.isArray(out.x_citations)) {
95
+ out.x_citations = out.x_citations.map((citation) => {
96
+ if (!citation || typeof citation !== 'object') return citation;
97
+ if (citation.published instanceof Date) {
98
+ repairs.push('x_citations.published');
99
+ return { ...citation, published: citation.published.toISOString() };
100
+ }
101
+ return citation;
102
+ });
103
+ }
104
+
105
+ return { data: out, repairs };
106
+ }
107
+
108
+ /**
109
+ * Field-level diff between a node as stored and as the contract would have it.
110
+ * Reports only additions and changes; nothing is ever dropped.
111
+ */
112
+ export function diffNode(original) {
113
+ const prepared = prepareNodeForContract(normalizeLegacyShapes(original).data);
114
+ const added = {};
115
+ const changed = {};
116
+ for (const [key, value] of Object.entries(prepared)) {
117
+ if (key === 'body') continue;
118
+ if (!(key in original) || original[key] === undefined || original[key] === null) {
119
+ added[key] = value;
120
+ } else if (JSON.stringify(original[key]) !== JSON.stringify(value)) {
121
+ changed[key] = { from: original[key], to: value };
122
+ }
123
+ }
124
+ return { added, changed };
125
+ }
126
+
127
+
128
+ async function validate(data, body, vaultDir) {
129
+ try {
130
+ const result = await validateNode({ ...data, body }, vaultDir);
131
+ if (result.success) return { success: true, errors: [] };
132
+ return { success: false, errors: result.validation?.errors || [result.error || 'unknown'] };
133
+ } catch (err) {
134
+ // A slug or category outside the safe-name allowlist throws before the
135
+ // envelope is even built; that is a finding, not a crash.
136
+ return { success: false, errors: [err.message] };
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Inspect a vault without writing anything.
142
+ *
143
+ * @returns {Promise<{vaultDir: string, total: number, valid: number,
144
+ * invalid: number, unreadable: string[], fieldCounts: object,
145
+ * errorCounts: object, nodes: object[]}>}
146
+ */
147
+ export async function analyzeVault(vaultDir) {
148
+ const files = walkVaultNodes(vaultDir);
149
+ const report = {
150
+ vaultDir,
151
+ total: files.length,
152
+ valid: 0,
153
+ invalid: 0,
154
+ repairable: 0,
155
+ unfixable: [],
156
+ unreadable: [],
157
+ fieldCounts: {},
158
+ shapeRepairs: {},
159
+ errorCounts: {},
160
+ nodes: [],
161
+ };
162
+
163
+ for (const file of files) {
164
+ let parsed;
165
+ try {
166
+ parsed = matter(fs.readFileSync(file, 'utf8'));
167
+ } catch (err) {
168
+ report.unreadable.push(`${file}: ${err.message}`);
169
+ continue;
170
+ }
171
+
172
+ const original = parsed.data || {};
173
+
174
+ // Validate the node exactly as it sits on disk. Validating the repaired
175
+ // form here would report a clean vault while the files are still broken —
176
+ // and leave nothing for --apply to do.
177
+ const asStored = await validate(original, parsed.content, vaultDir);
178
+ if (asStored.success) {
179
+ report.valid += 1;
180
+ continue;
181
+ }
182
+
183
+ report.invalid += 1;
184
+ for (const err of asStored.errors) {
185
+ const key = String(err).split('\n')[0].slice(0, 120);
186
+ report.errorCounts[key] = (report.errorCounts[key] || 0) + 1;
187
+ }
188
+
189
+ // Would this run actually fix it? A node that still fails after repair is
190
+ // reported separately rather than counted as work in progress.
191
+ const { data: normalized, repairs } = normalizeLegacyShapes(original);
192
+ const afterRepair = await validate(normalized, parsed.content, vaultDir);
193
+ if (afterRepair.success) report.repairable += 1;
194
+ else {
195
+ report.unfixable.push({ file, errors: afterRepair.errors });
196
+ continue;
197
+ }
198
+
199
+ const { added, changed } = diffNode(original);
200
+ for (const key of Object.keys(added)) {
201
+ report.fieldCounts[key] = (report.fieldCounts[key] || 0) + 1;
202
+ }
203
+ for (const key of repairs) {
204
+ report.shapeRepairs[key] = (report.shapeRepairs[key] || 0) + 1;
205
+ }
206
+ report.nodes.push({ file, added, changed, repairs });
207
+ }
208
+
209
+ return report;
210
+ }
211
+
212
+ /**
213
+ * Repair a vault in place through the Core Contract.
214
+ *
215
+ * Takes a snapshot of *this* vault before the first write. Nodes that still
216
+ * fail after preparation are left exactly as they are and reported — a node the
217
+ * contract cannot express is a node a blind rewrite would corrupt.
218
+ */
219
+ export async function backfillVault(vaultDir, options = {}) {
220
+ const { snapshot = true, limit = Infinity, onProgress } = options;
221
+ const analysis = await analyzeVault(vaultDir);
222
+
223
+ if (analysis.invalid === 0) {
224
+ return { ...analysis, snapshotId: null, repaired: 0, failed: [] };
225
+ }
226
+
227
+ let snapshotId = null;
228
+ if (snapshot) {
229
+ const snap = createSnapshot('pre-backfill', vaultDir);
230
+ if (!snap.success) {
231
+ throw new Error(`Refusing to backfill without a snapshot: ${snap.error}`);
232
+ }
233
+ snapshotId = snap.snapshot_id;
234
+ logger.info('vault-backfill', `Safety snapshot ${snapshotId} for ${vaultDir}`);
235
+ }
236
+
237
+ let repaired = 0;
238
+ const failed = [];
239
+ const targets = analysis.nodes.slice(0, limit);
240
+
241
+ for (const [index, target] of targets.entries()) {
242
+ try {
243
+ // prepareNodeForContract inside the write path supplies missing fields;
244
+ // the normalizer repairs fields stored with the wrong type. Neither
245
+ // invents content — both preserve what the old value carried.
246
+ const result = await updateNodeInPlace(target.file, (data) => {
247
+ const { data: normalized } = normalizeLegacyShapes(data);
248
+ for (const key of Object.keys(normalized)) data[key] = normalized[key];
249
+ }, { vaultDir });
250
+ if (result.success) repaired += 1;
251
+ else failed.push({ file: target.file, errors: result.validation?.errors || [result.error] });
252
+ } catch (err) {
253
+ failed.push({ file: target.file, errors: [err.message] });
254
+ }
255
+ if (onProgress && index % 100 === 0) onProgress(index + 1, targets.length);
256
+ }
257
+
258
+ return { ...analysis, snapshotId, repaired, failed };
259
+ }