total-recall-brain 3.23.2 → 3.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/total-recall.mjs +1 -0
- package/frontend/dist/assets/{index-c6GHGS_j.js → index-CtCDaZYZ.js} +1 -1
- package/frontend/dist/index.html +1 -1
- package/frontend/src/pages/SecretsPage.tsx +111 -52
- package/package.json +3 -2
- package/scaffold/.agent/skills/total-recall/SKILL.md +199 -7
- package/scaffold/.agent/skills/total-recall/memory-vault/concepts/cli-help-reference.md +13 -4
- package/scaffold/.agent/skills/total-recall/memory-vault/invariants/operating-instructions.md +2 -0
- package/scaffold/.agent/skills/total-recall/references/api-reference.md +1 -1
- package/scaffold/.agent/skills/total-recall/references/cli-reference.md +1 -2
- package/scaffold/.agent/skills/total-recall/research-queue.jsonl +7 -0
- package/scaffold/.agent/skills/total-recall/skills-registry/index.yaml +2461 -6
- package/src/cli/backfill.mjs +176 -0
- package/src/cli/init.mjs +1 -1
- package/src/cli/proposals.mjs +3 -3
- package/src/cli/secret.mjs +84 -0
- package/src/core/clarity-rewriter.mjs +25 -39
- package/src/core/conclusion-writer.mjs +20 -10
- package/src/core/crypto.mjs +86 -16
- package/src/core/daemon-loop.mjs +2 -2
- package/src/core/dream.mjs +49 -58
- package/src/core/env-import.mjs +18 -0
- package/src/core/inference-engine.mjs +15 -17
- package/src/core/migrate.mjs +5 -1
- package/src/core/okf-adapter.mjs +2 -0
- package/src/core/post-mortem.mjs +30 -26
- package/src/core/proposal-applier.mjs +26 -10
- package/src/core/repo-sync.mjs +51 -39
- package/src/core/scheduler.mjs +1 -0
- package/src/core/schema.mjs +24 -1
- package/src/core/search.mjs +1 -1
- package/src/core/secrets-env-diff.mjs +132 -0
- package/src/core/secrets-rekey.mjs +0 -0
- package/src/core/snapshot.mjs +32 -9
- package/src/core/ssss-kernel-bridge.mjs +46 -6
- package/src/core/task-envelope.mjs +1 -0
- package/src/core/validated-write.mjs +68 -5
- package/src/core/vault-backfill.mjs +259 -0
- package/src/core/vault.mjs +22 -4
- package/src/server/auth.mjs +42 -8
- package/src/server/health-coverage.mjs +74 -0
- package/src/server/index.mjs +60 -36
- package/src/server/routes/brains.mjs +1 -1
- package/src/server/routes/proposals.mjs +4 -4
- package/src/server/routes/secrets.mjs +1 -1
package/src/core/dream.mjs
CHANGED
|
@@ -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,
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
'
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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}`,
|
package/src/core/env-import.mjs
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
279
|
+
summary,
|
|
279
280
|
rationale: candidate.reason || 'Nodes appear to express the same concept differently.',
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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 ─────────────────────────────────────────────────────────
|
package/src/core/migrate.mjs
CHANGED
|
@@ -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
|
|
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')}`;
|
package/src/core/okf-adapter.mjs
CHANGED
|
@@ -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
|
|
package/src/core/post-mortem.mjs
CHANGED
|
@@ -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
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
* proposal in a state no command
|
|
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
|
-
|
|
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, {
|
package/src/core/repo-sync.mjs
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
161
|
-
decay
|
|
162
|
-
|
|
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
|
-
|
|
166
|
-
description: `Auto-ingested from ${repoName}/${relPath}`,
|
|
196
|
+
body: content,
|
|
167
197
|
};
|
|
168
198
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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,
|
package/src/core/scheduler.mjs
CHANGED
|
@@ -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;
|
package/src/core/schema.mjs
CHANGED
|
@@ -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
|
-
|
|
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(),
|
package/src/core/search.mjs
CHANGED
|
@@ -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
|
-
//
|
|
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.
|