total-recall-brain 3.23.3 → 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/routes/brains.mjs +1 -1
- package/src/server/routes/proposals.mjs +4 -4
- 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
|
package/src/core/snapshot.mjs
CHANGED
|
@@ -22,14 +22,19 @@ export function getSnapshotsDir() {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
|
-
* Create a point-in-time snapshot of
|
|
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
|
|
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
|
-
|
|
110
|
-
const
|
|
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
|
|
503
|
-
*
|
|
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
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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 (!
|
|
111
|
+
if (!isSafeVaultName(frontmatter.slug)) {
|
|
111
112
|
throw new Error(`Invalid slug: ${frontmatter.slug}`);
|
|
112
113
|
}
|
|
113
|
-
if (!
|
|
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
|
+
}
|