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
@@ -3,7 +3,8 @@ import path from 'path';
3
3
  import os from 'os';
4
4
  import crypto from 'crypto';
5
5
  import matter from 'gray-matter';
6
- import { writeNode, atomicWrite, walkMd } from './vault.mjs';
6
+ import { writeNode, walkMd } from './vault.mjs';
7
+ import { updateNodeInPlace } from './validated-write.mjs';
7
8
  import { getNodes } from './vault-cache.mjs';
8
9
  import { logger } from './logger.mjs';
9
10
  import { agentDir, brainDir } from './config.mjs';
@@ -13,68 +14,58 @@ import { loadQueue } from './research-queue.mjs';
13
14
  * Write a daily dream-cycle summary to memory-vault/daily/YYYY-MM-DD.md.
14
15
  * SSSS node natively; Obsidian Daily Notes plugin reads these files directly.
15
16
  */
16
- function writeDailyNote(vaultDir, summaryLines) {
17
+ async function writeDailyNote(vaultDir, summaryLines) {
17
18
  const today = new Date().toISOString().slice(0, 10);
18
19
  const now = new Date().toISOString();
19
- const dailyDir = path.join(vaultDir, 'daily');
20
- if (!fs.existsSync(dailyDir)) fs.mkdirSync(dailyDir, { recursive: true });
21
-
22
- const filePath = path.join(dailyDir, `${today}.md`);
20
+ const filePath = path.join(vaultDir, 'daily', `${today}.md`);
21
+ const runBlock = [
22
+ `## Dream Cycle \u2013 ${now}`,
23
+ '',
24
+ ...summaryLines.map((l) => `- ${l}`),
25
+ ].join('\n');
23
26
 
27
+ // Append to an existing note rather than replacing it: several dream cycles
28
+ // can run in one day and each one's summary is worth keeping.
24
29
  if (fs.existsSync(filePath)) {
25
- // Append this run's summary to the existing note
26
- const existing = fs.readFileSync(filePath, 'utf8');
27
- const runBlock = `\n## Dream Cycle – ${now}\n\n${summaryLines.map(l => `- ${l}`).join('\n')}\n`;
28
- atomicWrite(filePath, existing.trimEnd() + runBlock);
29
- return;
30
+ return updateNodeInPlace(filePath, (data, body) => {
31
+ data.updated = now;
32
+ data.last_accessed = now;
33
+ return { data, body: `${body.trimEnd()}\n\n${runBlock}\n` };
34
+ }, { vaultDir, agentRole: 'system' });
30
35
  }
31
36
 
32
- const frontmatter = [
33
- '---',
34
- 'type: memory',
35
- `slug: "daily-${today}"`,
36
- 'category: daily',
37
- `title: "Daily Note: ${today}"`,
38
- 'status: active',
39
- 'confidence: 1.0',
40
- 'importance: 2',
41
- `created: "${now}"`,
42
- `updated: "${now}"`,
43
- `last_accessed: "${now}"`,
44
- 'source:',
45
- ' type: dream-cycle',
46
- ` session_id: "dream-${today}"`,
47
- ' evidence_count: 1',
48
- 'supersedes: []',
49
- 'superseded_by: null',
50
- 'contradicts: []',
51
- 'tags: [daily, dream-cycle]',
52
- 'related: []',
53
- 'routes_to_skills: []',
54
- 'sentiment_polarity: descriptive',
55
- 'sentiment_target: system',
56
- 'modality: should',
57
- 'subject: system',
58
- 'predicate: ran',
59
- 'object: dream-cycle',
60
- 'decay:',
61
- ' half_life_days: 30',
62
- ' access_count: 0',
63
- 'schema_version: 2',
64
- '---',
65
- ''
66
- ].join('\n');
67
-
68
- const body = [
69
- `# Daily Note: ${today}`,
70
- '',
71
- `## Dream Cycle – ${now}`,
72
- '',
73
- ...summaryLines.map(l => `- ${l}`),
74
- ''
75
- ].join('\n');
76
-
77
- atomicWrite(filePath, frontmatter + body);
37
+ // The frontmatter here used to be a hand-assembled YAML string, which is why
38
+ // every daily note predates SSSS 0.9 universal frontmatter: a literal block
39
+ // cannot pick up new required fields. prepareNodeForContract fills them.
40
+ return writeNode({
41
+ type: 'memory',
42
+ slug: `daily-${today}`,
43
+ category: 'daily',
44
+ title: `Daily Note: ${today}`,
45
+ description: `Dream-cycle summary for ${today}.`,
46
+ status: 'active',
47
+ confidence: 1.0,
48
+ importance: 2,
49
+ created: now,
50
+ updated: now,
51
+ last_accessed: now,
52
+ source: { type: 'dream-cycle', session_id: `dream-${today}`, evidence_count: 1 },
53
+ supersedes: [],
54
+ superseded_by: null,
55
+ contradicts: [],
56
+ tags: ['daily', 'dream-cycle'],
57
+ related: [],
58
+ routes_to_skills: [],
59
+ sentiment_polarity: 'descriptive',
60
+ sentiment_target: 'system',
61
+ modality: 'should',
62
+ subject: 'system',
63
+ predicate: 'ran',
64
+ object: 'dream-cycle',
65
+ decay: { half_life_days: 30, access_count: 0 },
66
+ schema_version: 2,
67
+ body: [`# Daily Note: ${today}`, '', runBlock, ''].join('\n'),
68
+ }, vaultDir, { path: `daily/${today}.md`, agentRole: 'system' });
78
69
  }
79
70
  import { detectConflicts, quarantineConflict } from './steering.mjs';
80
71
  import { compileSurface } from './surface.mjs';
@@ -587,7 +578,7 @@ export async function runDreamCycle({
587
578
  // Write daily note summary (native SSSS node; Obsidian Daily Notes reads it directly)
588
579
  try {
589
580
  const existingNodes = getNodes(vaultDir);
590
- writeDailyNote(vaultDir, [
581
+ await writeDailyNote(vaultDir, [
591
582
  `Modified vault files scanned: ${modified.length}`,
592
583
  `REM candidates: ${candidates.length} (promoted ${promotedCount}, conflicted ${conflictedCount})`,
593
584
  `Active nodes: ${existingNodes.filter(n => n.status === 'active').length}`,
@@ -24,6 +24,23 @@ export const KNOWN_SECRET_KEYS = [];
24
24
  const SECRET_NAME_RE =
25
25
  /(?:^|_)(API[_-]?KEY|API[_-]?TOKEN|ACCESS[_-]?TOKEN|ACCESS[_-]?KEY|SECRET[_-]?KEY|PRIVATE[_-]?KEY|AUTH[_-]?TOKEN|CLIENT[_-]?SECRET|WEBHOOK[_-]?SECRET|PASSWORD|PASSWD|TOKEN|SECRET|KEY)$/i;
26
26
 
27
+ /**
28
+ * The secrets store's own master password must never be imported into the
29
+ * store, and neither may anything else that controls access to it.
30
+ *
31
+ * TR_SECRETS_PASSWORD is exported into every shell by ~/.zshenv (from the
32
+ * macOS Keychain), so it shows up in the env scan like any other credential —
33
+ * and `secret import-env --all`, the command the help text recommends, would
34
+ * happily write it in. That is the key to the box stored inside the box: it
35
+ * cannot be read back without the value it holds, and it puts the one
36
+ * credential that protects everything else next to what it protects.
37
+ */
38
+ const STORE_CONTROL_KEYS = new Set([
39
+ 'TR_SECRETS_PASSWORD',
40
+ 'TR_SECRETS_KEY_CACHE',
41
+ 'TR_REMOTE_VAULT_TOKEN',
42
+ ]);
43
+
27
44
  /** Obvious non-secrets even if they match loosely */
28
45
  const SKIP_KEYS = new Set([
29
46
  'PATH',
@@ -174,6 +191,7 @@ export function parseEnvText(text) {
174
191
  */
175
192
  export function isCandidateKey(key) {
176
193
  if (!key || typeof key !== 'string') return false;
194
+ if (STORE_CONTROL_KEYS.has(key.toUpperCase())) return false;
177
195
  if (SKIP_KEYS.has(key) || SKIP_KEYS.has(key.toUpperCase())) return false;
178
196
  // skip pure PATH-like / numeric shell junk
179
197
  if (key.length < 4 || key.length > 128) return false;
@@ -3,7 +3,7 @@ import path from 'path';
3
3
  import matter from 'gray-matter';
4
4
  import crypto from 'crypto';
5
5
  import { callLocalRuntime, cleanAndParseJSON } from './runtime.mjs';
6
- import { atomicWrite, safeStringify } from './vault.mjs';
6
+ import { atomicWrite, safeStringify, writeNode } from './vault.mjs';
7
7
  import { getNodes } from './vault-cache.mjs';
8
8
  import { logger } from './logger.mjs';
9
9
  import { brainDir } from './config.mjs';
@@ -130,9 +130,8 @@ export async function runInferenceTask(slugs, { vaultDir, inboxDir, runtimeConfi
130
130
 
131
131
  // Write merge proposals
132
132
  if (mergeCandidates.length > 0) {
133
- const proposalsDir = path.join(vaultDir, 'proposals');
134
133
  for (const candidate of mergeCandidates) {
135
- writeMergeProposal(proposalsDir, candidate);
134
+ await writeMergeProposal(vaultDir, candidate);
136
135
  }
137
136
  }
138
137
 
@@ -259,30 +258,29 @@ function writeContradictionRecord(conflictsDir, contradiction) {
259
258
  );
260
259
  }
261
260
 
262
- function writeMergeProposal(proposalsDir, candidate) {
263
- if (!fs.existsSync(proposalsDir)) {
264
- fs.mkdirSync(proposalsDir, { recursive: true });
265
- }
266
-
261
+ async function writeMergeProposal(vaultDir, candidate) {
267
262
  const slug = `merge-proposal-${crypto.createHash('md5').update(candidate.node_a + candidate.node_b).digest('hex').slice(0, 8)}`;
268
263
  const now = new Date().toISOString();
264
+ const summary = `Merge ${candidate.node_a} and ${candidate.node_b}`;
269
265
 
270
- const data = {
266
+ // proposals/ lives inside the vault, so this is a canonical write and has to
267
+ // clear the contract. The old atomicWrite emitted no title/description/
268
+ // timestamp, so every merge proposal ever written fails SSSS 0.9 §4.2.
269
+ return writeNode({
271
270
  type: 'proposal',
272
271
  slug,
273
- category: 'memory-cleanup',
272
+ title: summary,
273
+ description: candidate.reason || 'Nodes appear to express the same concept differently.',
274
+ timestamp: now,
274
275
  status: 'draft',
275
276
  proposed_by: 'inference-engine',
276
277
  proposed_at: now,
277
278
  target_path: `${candidate.node_a},${candidate.node_b}`,
278
- summary: `Merge ${candidate.node_a} and ${candidate.node_b}`,
279
+ summary,
279
280
  rationale: candidate.reason || 'Nodes appear to express the same concept differently.',
280
- };
281
-
282
- atomicWrite(
283
- path.join(proposalsDir, `${slug}.md`),
284
- safeStringify('', data),
285
- );
281
+ proposal_topic: 'memory-cleanup',
282
+ body: '',
283
+ }, vaultDir, { agentRole: 'system' });
286
284
  }
287
285
 
288
286
  // ─── Memory Synthesizer ─────────────────────────────────────────────────────────
@@ -20,7 +20,11 @@ export async function runMigration(vaultDir, fromVersion, toVersion, migrationFn
20
20
 
21
21
  // 1. Safety Snapshot
22
22
  logger.info('migration', 'Creating pre-migration safety snapshot...');
23
- const snapshotId = await createSnapshot(vaultDir);
23
+ const snapshot = createSnapshot(`pre-migration-v${fromVersion}-to-v${toVersion}`, vaultDir);
24
+ if (!snapshot.success) {
25
+ throw new Error(`Refusing to migrate without a safety snapshot: ${snapshot.error}`);
26
+ }
27
+ const snapshotId = snapshot.snapshot_id;
24
28
  logger.info('migration', `Safety snapshot created: ${snapshotId}`);
25
29
 
26
30
  const migrationId = `mig_${crypto.randomBytes(6).toString('hex')}`;
@@ -498,6 +498,7 @@ export function generateLiveIndex(vaultDir) {
498
498
  }
499
499
  indexContent += '\n';
500
500
  }
501
+ // ssss-raw-write: OKF bundle manifest, not a memory node (excluded from node walks).
501
502
  fs.writeFileSync(path.join(vaultDir, 'index.md'), indexContent, 'utf8');
502
503
  }
503
504
 
@@ -538,6 +539,7 @@ export function generateLiveLog(vaultDir) {
538
539
  } else {
539
540
  logContent += 'No update history available.\n';
540
541
  }
542
+ // ssss-raw-write: OKF bundle changelog, not a memory node (excluded from node walks).
541
543
  fs.writeFileSync(path.join(vaultDir, 'log.md'), logContent, 'utf8');
542
544
  }
543
545
 
@@ -4,6 +4,7 @@ import matter from 'gray-matter';
4
4
  import crypto from 'crypto';
5
5
  import { callLocalRuntime, loadRuntimeConfig } from './runtime.mjs';
6
6
  import { atomicWrite, safeStringify } from './vault.mjs';
7
+ import { updateNodeInPlace } from './validated-write.mjs';
7
8
  import { getNodes } from './vault-cache.mjs';
8
9
  import { logger } from './logger.mjs';
9
10
  import { brainDir } from './config.mjs';
@@ -379,34 +380,37 @@ async function escalateViolatedRules(violations, nodes, vaultDir) {
379
380
  if (!node || !node._filepath) continue;
380
381
 
381
382
  try {
382
- const raw = fs.readFileSync(node._filepath, 'utf8');
383
- const { data, content } = matter(raw);
384
-
385
- // Track violation count
386
- const violationCount = (data.x_violation_count || 0) + 1;
387
- data.x_violation_count = violationCount;
388
-
389
- // Bump importance (max 5)
390
- if (data.importance < 5) {
391
- data.importance = Math.min(5, data.importance + 1);
392
- }
393
-
394
- // Bump confidence (max 1.0)
395
- if (data.confidence < 1.0) {
396
- data.confidence = Math.min(1.0, data.confidence + 0.05);
397
- }
398
-
399
- // Auto-escalate to priority: absolute after 3+ violations
400
- if (violationCount >= 3 && data.priority !== 'absolute') {
401
- data.priority = 'absolute';
402
- logger.info({
403
- subsystem: 'compliance-auditor',
404
- message: `ESCALATED rule "${data.slug}" to priority: absolute after ${violationCount} violations.`,
383
+ const result = await updateNodeInPlace(node._filepath, (data) => {
384
+ // Track violation count
385
+ const violationCount = (data.x_violation_count || 0) + 1;
386
+ data.x_violation_count = violationCount;
387
+
388
+ // Bump importance (max 5)
389
+ if (data.importance < 5) {
390
+ data.importance = Math.min(5, data.importance + 1);
391
+ }
392
+
393
+ // Bump confidence (max 1.0)
394
+ if (data.confidence < 1.0) {
395
+ data.confidence = Math.min(1.0, data.confidence + 0.05);
396
+ }
397
+
398
+ // Auto-escalate to priority: absolute after 3+ violations
399
+ if (violationCount >= 3 && data.priority !== 'absolute') {
400
+ data.priority = 'absolute';
401
+ logger.info({
402
+ subsystem: 'compliance-auditor',
403
+ message: `ESCALATED rule "${data.slug}" to priority: absolute after ${violationCount} violations.`,
404
+ });
405
+ }
406
+
407
+ data.updated = new Date().toISOString();
408
+ }, { vaultDir });
409
+ if (!result.success) {
410
+ logger.debug('post-mortem', `escalate skipped for ${v.rule_slug}`, {
411
+ errors: result.validation?.errors || [result.error],
405
412
  });
406
413
  }
407
-
408
- data.updated = new Date().toISOString();
409
- atomicWrite(node._filepath, safeStringify(content, data));
410
414
  } catch {
411
415
  // Skip if we can't update
412
416
  }
@@ -2,6 +2,7 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import matter from 'gray-matter';
4
4
  import { loadNodes, writeNode, atomicWrite } from './vault.mjs';
5
+ import { updateNodeInPlace } from './validated-write.mjs';
5
6
  import { proposalKey } from './optimizer.mjs';
6
7
  import { logger } from './logger.mjs';
7
8
 
@@ -192,12 +193,14 @@ export function getProposal(vaultDir, id) {
192
193
  /**
193
194
  * Move a proposal to a new status, preserving every other frontmatter field.
194
195
  *
195
- * Writes the file directly rather than via writeNode(): the proposal already
196
- * passed the contract on creation, and a status transition must not be able to
197
- * fail on an unrelated schema change months later — that would strand the
198
- * proposal in a state no command can clear.
196
+ * Goes through the contract first so a transition also repairs frontmatter that
197
+ * predates the current schema. It falls back to a direct write when validation
198
+ * fails, because a status change must never be blocked by an unrelated schema
199
+ * change months later — that would strand the proposal in a state no command
200
+ * can clear, which is the failure this function was originally written to
201
+ * avoid. The fallback is logged so the invalid node still gets surfaced.
199
202
  */
200
- export function setProposalStatus(vaultDir, id, status, extra = {}) {
203
+ export async function setProposalStatus(vaultDir, id, status, extra = {}) {
201
204
  if (!PROPOSAL_STATUSES.includes(status)) {
202
205
  throw new Error(`Invalid proposal status: ${status}`);
203
206
  }
@@ -207,7 +210,20 @@ export function setProposalStatus(vaultDir, id, status, extra = {}) {
207
210
  const raw = fs.readFileSync(proposal._filePath, 'utf8');
208
211
  const { data, content } = matter(raw);
209
212
  const next = { ...data, ...extra, status, status_changed_at: new Date().toISOString() };
210
- atomicWrite(proposal._filePath, matter.stringify(content, next));
213
+
214
+ const result = await updateNodeInPlace(proposal._filePath, (nodeData) => {
215
+ Object.assign(nodeData, next);
216
+ }, { vaultDir });
217
+
218
+ if (!result.success) {
219
+ logger.warn('proposal-applier',
220
+ `Proposal ${id} failed contract validation; writing status transition directly`, {
221
+ errors: result.validation?.errors || [result.error],
222
+ });
223
+ // ssss-raw-write: deliberate fallback — see setProposalStatus doc comment.
224
+ atomicWrite(proposal._filePath, matter.stringify(content, next));
225
+ }
226
+
211
227
  return { ...next, _filePath: proposal._filePath };
212
228
  }
213
229
 
@@ -236,7 +252,7 @@ function writeUndoSnapshot(vaultDir, proposalId, filePaths) {
236
252
  * Restore a proposal's snapshot and return the proposal to `accepted`.
237
253
  * @returns {{ reverted: number, deleted: number }}
238
254
  */
239
- export function revertProposal(vaultDir, id) {
255
+ export async function revertProposal(vaultDir, id) {
240
256
  const snapshotPath = path.join(undoDir(vaultDir), `${id}.json`);
241
257
  if (!fs.existsSync(snapshotPath)) {
242
258
  throw new Error(`No undo snapshot for proposal ${id} — cannot revert.`);
@@ -260,7 +276,7 @@ export function revertProposal(vaultDir, id) {
260
276
  // reverting into it means the next dream cycle re-applies the very thing that
261
277
  // was just undone — an undo/redo loop with no human in it. A revert is a
262
278
  // human saying "not this"; it must land somewhere only a human can move it out of.
263
- setProposalStatus(vaultDir, id, 'draft', {
279
+ await setProposalStatus(vaultDir, id, 'draft', {
264
280
  reverted_at: new Date().toISOString(),
265
281
  review_reason: 'Previously applied and reverted; re-apply only after confirming the merge is correct.',
266
282
  });
@@ -420,11 +436,11 @@ export async function applyProposal(vaultDir, id, opts = {}) {
420
436
  if (dryRun) return { ...result, dryRun: true };
421
437
 
422
438
  if (result.ok) {
423
- setProposalStatus(vaultDir, id, 'applied', { applied_at: new Date().toISOString(), applied_by: actor });
439
+ await setProposalStatus(vaultDir, id, 'applied', { applied_at: new Date().toISOString(), applied_by: actor });
424
440
  } else if (result.supersede) {
425
441
  // The world moved on — the proposal was never wrong, it is just no longer
426
442
  // actionable. Marking it `rejected` would misreport the gate's accuracy.
427
- setProposalStatus(vaultDir, id, 'superseded', { superseded_reason: result.reason });
443
+ await setProposalStatus(vaultDir, id, 'superseded', { superseded_reason: result.reason });
428
444
  }
429
445
 
430
446
  appendProposalAudit(vaultDir, {
@@ -18,6 +18,7 @@
18
18
  import fs from 'node:fs';
19
19
  import path from 'node:path';
20
20
  import os from 'node:os';
21
+ import { writeNodeValidatedAsync } from './validated-write.mjs';
21
22
  import crypto from 'node:crypto';
22
23
  import { logger } from './logger.mjs';
23
24
 
@@ -130,19 +131,37 @@ function extractTitle(content, filename) {
130
131
 
131
132
  /**
132
133
  * Write a single repo-synced node to the vault.
133
- * Uses SSSS-compatible frontmatter format.
134
+ *
135
+ * Goes through the SSSS Core Contract rather than serializing frontmatter by
136
+ * hand. The hand-rolled version emitted neither `timestamp` (SSSS 0.9 §4.2
137
+ * universal) nor `schema_version`, so every node this ingested failed package
138
+ * validation — visible in a freshly initialized brain, where `init` runs this
139
+ * and the resulting facts/ node was the only invalid one it produced.
140
+ * prepareNodeForContract() fills both, and the kernel rejects anything still
141
+ * malformed instead of writing it.
134
142
  */
135
- function writeRepoNode(slug, title, content, repoName, relPath, vaultDir) {
143
+ async function writeRepoNode(slug, title, content, repoName, relPath, vaultDir) {
136
144
  const category = 'facts';
137
- const categoryDir = path.join(vaultDir, category);
138
- if (!fs.existsSync(categoryDir)) {
139
- fs.mkdirSync(categoryDir, { recursive: true });
145
+ const now = new Date().toISOString();
146
+
147
+ // Preserve the original creation time across re-ingests so decay and
148
+ // chronology survive a content change.
149
+ let created = now;
150
+ const nodePath = path.join(vaultDir, category, `${slug}.md`);
151
+ if (fs.existsSync(nodePath)) {
152
+ try {
153
+ const createdMatch = fs
154
+ .readFileSync(nodePath, 'utf8')
155
+ .match(/^created:\s*"?([^"\n]+)"?$/m);
156
+ if (createdMatch) created = createdMatch[1];
157
+ } catch { /* fall back to now */ }
140
158
  }
141
159
 
142
- const now = new Date().toISOString();
143
160
  const node = {
161
+ type: 'memory',
144
162
  slug,
145
163
  title: title.substring(0, 200),
164
+ description: `Auto-ingested from ${repoName}/${relPath}`,
146
165
  category,
147
166
  status: 'active',
148
167
  importance: 2,
@@ -157,46 +176,39 @@ function writeRepoNode(slug, title, content, repoName, relPath, vaultDir) {
157
176
  subject: 'repo',
158
177
  predicate: 'documents',
159
178
  object: repoName,
160
- source: `repo-sync:${repoName}:${relPath}`,
161
- decay: 0,
162
- created: now,
179
+ // MemoryNodeSchema declares both of these as OBJECTS. The hand-rolled
180
+ // writer emitted `source` as a string and `decay` as a number, and because
181
+ // it wrote with raw writeFileSync nothing ever checked — the malformed
182
+ // shape only surfaced once this went through the contract.
183
+ source: {
184
+ type: 'repo-sync',
185
+ session_id: `${repoName}:${relPath}`,
186
+ agent: 'repo-sync',
187
+ evidence_count: 1,
188
+ },
189
+ decay: {
190
+ half_life_days: 365,
191
+ access_count: 0,
192
+ },
193
+ created,
163
194
  updated: now,
164
195
  last_accessed: now,
165
- type: 'memory',
166
- description: `Auto-ingested from ${repoName}/${relPath}`,
196
+ body: content,
167
197
  };
168
198
 
169
- // Build frontmatter
170
- const lines = ['---'];
171
- for (const [key, value] of Object.entries(node)) {
172
- lines.push(`${key}: ${JSON.stringify(value)}`);
199
+ const result = await writeNodeValidatedAsync(node, vaultDir);
200
+ if (!result.success) {
201
+ const reason = (result.validation?.errors || []).join('; ') || result.message || 'unknown';
202
+ throw new Error(`Rejected by SSSS contract: ${reason}`);
173
203
  }
174
- lines.push('---');
175
- lines.push(content);
176
-
177
- const filePath = path.join(categoryDir, `${slug}.md`);
178
-
179
- // If an existing file exists, preserve the created timestamp
180
- if (fs.existsSync(filePath)) {
181
- try {
182
- const existing = fs.readFileSync(filePath, 'utf8');
183
- const createdMatch = existing.match(/^created:\s*"?([^"\n]+)"?$/m);
184
- if (createdMatch) {
185
- const idx = lines.findIndex(l => l.startsWith('created:'));
186
- if (idx >= 0) lines[idx] = `created: ${JSON.stringify(createdMatch[1])}`;
187
- }
188
- } catch { /* use new timestamp */ }
189
- }
190
-
191
- fs.writeFileSync(filePath, lines.join('\n'));
192
- return filePath;
204
+ return nodePath;
193
205
  }
194
206
 
195
207
  /**
196
208
  * Sync a single repo: scan, hash-diff, ingest new/changed files.
197
209
  * Returns { repo, ingested, skipped, errors, deleted }.
198
210
  */
199
- function syncRepo(project) {
211
+ async function syncRepo(project) {
200
212
  const repoPath = project.path;
201
213
  const repoName = project.name || path.basename(repoPath);
202
214
  const vaultDir = path.join(project.brainDir, 'memory-vault');
@@ -236,7 +248,7 @@ function syncRepo(project) {
236
248
  const content = fs.readFileSync(filePath, 'utf8');
237
249
  const slug = slugFromRelPath(repoName, relPath);
238
250
  const title = extractTitle(content, path.basename(filePath));
239
- writeRepoNode(slug, title, content, repoName, relPath, vaultDir);
251
+ await writeRepoNode(slug, title, content, repoName, relPath, vaultDir);
240
252
  ingested++;
241
253
  } catch (err) {
242
254
  logger.warn('repo-sync', `Error ingesting ${repoName}/${relPath}: ${err.message}`);
@@ -274,7 +286,7 @@ function syncRepo(project) {
274
286
  *
275
287
  * @returns {{ repos: Array, totalIngested: number, totalSkipped: number }}
276
288
  */
277
- export function syncAllRepos() {
289
+ export async function syncAllRepos() {
278
290
  const registry = loadProjectRegistry();
279
291
  const results = [];
280
292
  let totalIngested = 0;
@@ -285,7 +297,7 @@ export function syncAllRepos() {
285
297
  if (!fs.existsSync(project.path)) continue;
286
298
 
287
299
  try {
288
- const result = syncRepo(project);
300
+ const result = await syncRepo(project);
289
301
  results.push(result);
290
302
  totalIngested += result.ingested;
291
303
  totalSkipped += result.skipped;
@@ -307,7 +319,7 @@ export function syncAllRepos() {
307
319
  /**
308
320
  * Sync a single repo by path (used during init).
309
321
  */
310
- export function syncSingleRepo(projectPath, brainDir) {
322
+ export async function syncSingleRepo(projectPath, brainDir) {
311
323
  const project = {
312
324
  name: path.basename(projectPath),
313
325
  path: projectPath,
@@ -183,6 +183,7 @@ export function persistTaskToDisk(task, queueDir) {
183
183
  }
184
184
 
185
185
  const raw = safeStringify(body || '', frontmatter);
186
+ // ssss-raw-write: scheduler/queue task envelope, not a vault node.
186
187
  atomicWrite(filepath, raw);
187
188
  task._filepath = filepath;
188
189
  return filepath;
@@ -23,9 +23,32 @@ export const MemoryNodeSchema = z.object({
23
23
  slug: z.string(),
24
24
  category: z.string(),
25
25
  title: z.string(),
26
+ // SSSS 0.9 universal frontmatter (§4.2) is `type`/`title`/`description`/
27
+ // `timestamp`. `timestamp` was not declared here at all, so nothing in this
28
+ // schema could notice its absence — which is how 743 of the 912 nodes in the
29
+ // global vault ended up without one, and how all four scaffold seeds shipped
30
+ // failing the package validator.
31
+ //
32
+ // Kept `.optional()` deliberately: writeNodeValidated() fills both fields
33
+ // before this schema ever runs, so new writes are conformant, while the
34
+ // historical nodes that predate that backfill still parse on READ. Tighten to
35
+ // required only once those are migrated — otherwise recall breaks on the vault
36
+ // it is meant to read.
26
37
  description: z.string().optional(),
38
+ // ssssDatetime(), not z.string(): YAML parses an unquoted ISO timestamp into a
39
+ // Date, so a plain string check rejects the very frontmatter the vault writes.
40
+ // The preprocessor normalizes Date -> ISO string, which is why every other
41
+ // datetime field in this file uses it.
42
+ timestamp: ssssDatetime().optional(),
27
43
  resource: z.string().optional(),
28
- status: z.enum(['active', 'superseded', 'deprecated', 'draft']),
44
+ // `archived` is a first-class Total Recall status, not a legacy artifact:
45
+ // repo-sync rewrites ingested nodes to it, clarity-rewriter sets it when a
46
+ // correction deletes a fact, fact-seeker and remember use it, and dream and
47
+ // the API both read it. The enum simply never listed it, so 574 nodes — the
48
+ // most common status in the vault — failed validation against a rule the
49
+ // host's own writers contradict. SSSS 0.9 puts no enum on memory.status at
50
+ // all, so this is a host restriction, not a spec requirement.
51
+ status: z.enum(['active', 'superseded', 'deprecated', 'draft', 'archived']),
29
52
  confidence: z.number().min(0).max(1).optional(),
30
53
  importance: z.number().int().min(1).max(5).optional(),
31
54
  created: ssssDatetime().optional(),
@@ -138,7 +138,7 @@ export async function semanticSearch(query, {
138
138
  // "the embedding API failed" and "the index for this brain was never built"
139
139
  // are completely different problems with completely different fixes, and
140
140
  // this used to report both as the former. That mislabelling cost real time —
141
- // festech.live's project brain had 2602 vault nodes and 0 embeddings, and
141
+ // one project brain had 2602 vault nodes and 0 embeddings, and
142
142
  // every recall against it printed "Embedding API unreachable or
143
143
  // unconfigured. Configure GOOGLE_API_KEY or OPENAI_API_KEY", which sent the
144
144
  // reader off to check credentials that were working perfectly.