total-recall-brain 3.2.3 → 3.4.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/README.md +1 -1
- package/extension/background.js +14 -2
- package/extension/content-script.js +18 -1
- package/extension/lib/brain-client.js +86 -52
- package/extension/lib/preconfigured.js +5 -0
- package/extension/manifest.json +1 -1
- package/extension/options/options.html +1 -0
- package/extension/options/options.js +12 -6
- package/extension/popup/popup.html +1 -0
- package/extension/popup/popup.js +29 -4
- package/extension/sidepanel/sidepanel.html +1 -0
- package/extension/sidepanel/sidepanel.js +24 -6
- package/frontend/index.html +8 -0
- package/frontend/src/api.ts +1 -2
- package/frontend/src/pages/ChatPage.tsx +9 -6
- package/frontend/src/pages/IntegrationsPage.tsx +144 -3
- package/package.json +2 -2
- package/scaffold/.agent/skills/total-recall/SKILL.md +55 -6
- package/scaffold/.agent/skills/total-recall/memory-vault/invariants/operating-instructions.md +2 -2
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/agents.yml +1 -6
- package/scaffold/.agent/skills/total-recall/skills/ssss/SKILL.md +19 -0
- package/scaffold/.agent/skills/total-recall/skills/ssss/references/ssss-spec.md +33 -5
- package/src/cli/forget.mjs +18 -12
- package/src/cli/ingest/index.mjs +3 -2
- package/src/cli/init.mjs +30 -0
- package/src/cli/map.mjs +2 -2
- package/src/cli/rebuild.mjs +1 -1
- package/src/cli/remember.mjs +21 -11
- package/src/cli/setup.mjs +10 -1
- package/src/cli/share.mjs +7 -2
- package/src/core/append-log.mjs +131 -0
- package/src/core/clarity-rewriter.mjs +10 -6
- package/src/core/conclusion-writer.mjs +3 -2
- package/src/core/config.mjs +6 -0
- package/src/core/conflict-detector.mjs +4 -2
- package/src/core/context-compiler.mjs +412 -0
- package/src/core/daemon-loop.mjs +15 -0
- package/src/core/dream.mjs +4 -4
- package/src/core/drift-detector.mjs +3 -3
- package/src/core/embeddings.mjs +60 -10
- package/src/core/fact-seeker.mjs +70 -44
- package/src/core/import-rules.mjs +3 -2
- package/src/core/inference-engine.mjs +5 -4
- package/src/core/migrate.mjs +4 -3
- package/src/core/optimizer.mjs +6 -5
- package/src/core/parallel-context.mjs +288 -0
- package/src/core/post-mortem.mjs +4 -3
- package/src/core/research-queue.mjs +4 -3
- package/src/core/runtime.mjs +2 -2
- package/src/core/sandbox.mjs +56 -26
- package/src/core/scheduler.mjs +6 -5
- package/src/core/schema.mjs +2 -2
- package/src/core/search.mjs +167 -101
- package/src/core/surface.mjs +203 -53
- package/src/core/validated-write.mjs +100 -0
- package/src/core/vault-cache.mjs +46 -18
- package/src/core/vault.mjs +17 -29
- package/src/core/vector-field.mjs +659 -0
- package/src/core/vector-store.mjs +85 -0
- package/src/server/api.mjs +4 -3
- package/src/server/index.mjs +13 -61
- package/src/server/rest.mjs +238 -29
- package/src/server/route-manifest.json +344 -77
- package/src/server/routes/memory.mjs +85 -39
- package/src/server/routes/sessions.mjs +6 -2
- package/src/server/routes/share.mjs +9 -2
package/src/core/surface.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
|
-
import
|
|
3
|
+
import crypto from 'crypto';
|
|
4
|
+
import { loadSkills, atomicWrite, walkMd } from './vault.mjs';
|
|
5
|
+
import { getNodes } from './vault-cache.mjs';
|
|
4
6
|
import {
|
|
5
7
|
buildMemoryLayerIndex,
|
|
6
8
|
inferMemoryLayer,
|
|
@@ -114,7 +116,113 @@ function extractRuleContent(filePath) {
|
|
|
114
116
|
return content.trim();
|
|
115
117
|
}
|
|
116
118
|
|
|
117
|
-
|
|
119
|
+
/**
|
|
120
|
+
* Load cache of compacted rules.
|
|
121
|
+
*/
|
|
122
|
+
function loadCompactedRulesCache(derivedDir) {
|
|
123
|
+
if (!derivedDir) return {};
|
|
124
|
+
const cachePath = path.join(derivedDir, 'compacted-rules.json');
|
|
125
|
+
if (!fs.existsSync(cachePath)) return {};
|
|
126
|
+
try {
|
|
127
|
+
return JSON.parse(fs.readFileSync(cachePath, 'utf8'));
|
|
128
|
+
} catch {
|
|
129
|
+
return {};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Save cache of compacted rules.
|
|
135
|
+
*/
|
|
136
|
+
function saveCompactedRulesCache(derivedDir, cache) {
|
|
137
|
+
if (!derivedDir) return;
|
|
138
|
+
const cachePath = path.join(derivedDir, 'compacted-rules.json');
|
|
139
|
+
try {
|
|
140
|
+
fs.mkdirSync(derivedDir, { recursive: true });
|
|
141
|
+
atomicWrite(cachePath, JSON.stringify(cache, null, 2));
|
|
142
|
+
} catch (err) {
|
|
143
|
+
// Non-fatal
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Heuristically summarize a memory node.
|
|
149
|
+
*/
|
|
150
|
+
export function heuristicCompact(node) {
|
|
151
|
+
const title = (node.title || '').trim();
|
|
152
|
+
const text = (node.body || node.content || '').trim();
|
|
153
|
+
|
|
154
|
+
let summary = title;
|
|
155
|
+
if (text) {
|
|
156
|
+
const firstLine = text.split('\n').map(l => l.trim()).filter(Boolean)[0] || '';
|
|
157
|
+
if (firstLine &&
|
|
158
|
+
!title.toLowerCase().includes(firstLine.toLowerCase()) &&
|
|
159
|
+
!firstLine.toLowerCase().includes(title.toLowerCase())) {
|
|
160
|
+
const separator = /[.!?]$/.test(title) ? ' ' : ' — ';
|
|
161
|
+
summary = `${title}${separator}${firstLine}`;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
summary = summary.replace(/\s+/g, ' ');
|
|
166
|
+
if (summary.length > 180) {
|
|
167
|
+
summary = summary.substring(0, 180).trim() + '... (use recall to read more)';
|
|
168
|
+
}
|
|
169
|
+
return summary;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Compact a single memory node using LLM or heuristic fallback.
|
|
174
|
+
*/
|
|
175
|
+
async function compactNode(node, derivedDir) {
|
|
176
|
+
const title = (node.title || '').trim();
|
|
177
|
+
const body = (node.body || node.content || '').trim();
|
|
178
|
+
const fullText = `${title}\n\n${body}`;
|
|
179
|
+
const contentHash = crypto.createHash('sha256').update(fullText).digest('hex');
|
|
180
|
+
|
|
181
|
+
// Load from cache if possible
|
|
182
|
+
let cache = {};
|
|
183
|
+
if (derivedDir) {
|
|
184
|
+
cache = loadCompactedRulesCache(derivedDir);
|
|
185
|
+
if (cache[node.slug] && cache[node.slug].content_sha256 === contentHash) {
|
|
186
|
+
return cache[node.slug].compacted;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Fallback default
|
|
191
|
+
let compacted = heuristicCompact(node);
|
|
192
|
+
|
|
193
|
+
// If LLM compacting is requested
|
|
194
|
+
if (process.env.TR_LLM_COMPACT === 'true') {
|
|
195
|
+
try {
|
|
196
|
+
const { callLocalRuntime, loadRuntimeConfig } = await import('./runtime.mjs');
|
|
197
|
+
const runtimeConfig = loadRuntimeConfig();
|
|
198
|
+
|
|
199
|
+
const systemPrompt = "You are a Rule Compactor. Your task is to compress a system instruction or preference rule into a single, dense, highly actionable sentence. Preserve all key constraints, file paths, model names, or terminal commands verbatim. Return only the compacted sentence and nothing else.";
|
|
200
|
+
const userPrompt = `Rule Title: ${title}\nRule Body: ${body}`;
|
|
201
|
+
|
|
202
|
+
const response = await callLocalRuntime(userPrompt, systemPrompt, runtimeConfig);
|
|
203
|
+
const cleanResponse = response.trim().replace(/\s+/g, ' ');
|
|
204
|
+
if (cleanResponse && cleanResponse.length > 0 && cleanResponse.length < 250) {
|
|
205
|
+
compacted = cleanResponse;
|
|
206
|
+
}
|
|
207
|
+
} catch (err) {
|
|
208
|
+
// Graceful fallback to heuristic
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Save to cache
|
|
213
|
+
if (derivedDir) {
|
|
214
|
+
cache[node.slug] = {
|
|
215
|
+
compacted,
|
|
216
|
+
content_sha256: contentHash,
|
|
217
|
+
updated_at: new Date().toISOString()
|
|
218
|
+
};
|
|
219
|
+
saveCompactedRulesCache(derivedDir, cache);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return compacted;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export async function buildRulesBlock(skillsDir, nodes = [], { consumer = 'ide', derivedDir } = {}) {
|
|
118
226
|
let combined;
|
|
119
227
|
|
|
120
228
|
if (consumer === 'api') {
|
|
@@ -198,48 +306,42 @@ Show all available commands.
|
|
|
198
306
|
`;
|
|
199
307
|
}
|
|
200
308
|
|
|
201
|
-
// 1. Filter expired rules
|
|
309
|
+
// 1. Filter expired rules. Compilation must remain a pure projection step;
|
|
310
|
+
// archival is handled by explicit memory operations.
|
|
202
311
|
const now = new Date();
|
|
203
312
|
const isExpired = (n) => n.expires_at && new Date(n.expires_at) <= now;
|
|
204
313
|
|
|
205
314
|
const expiredNodes = nodes.filter(n => n.status === 'active' && isExpired(n));
|
|
206
315
|
for (const expired of expiredNodes) {
|
|
207
|
-
|
|
208
|
-
console.error(`⏰ Auto-archived expired rule: ${expired.slug}`);
|
|
209
|
-
if (vaultDir) {
|
|
210
|
-
try {
|
|
211
|
-
const nodePath = path.join(vaultDir, expired.category, `${expired.slug}.md`);
|
|
212
|
-
if (fs.existsSync(nodePath)) {
|
|
213
|
-
let content = fs.readFileSync(nodePath, 'utf8');
|
|
214
|
-
content = content.replace(/^status:\s*active$/m, 'status: deprecated');
|
|
215
|
-
atomicWrite(nodePath, content);
|
|
216
|
-
}
|
|
217
|
-
} catch { /* non-fatal: archive is best-effort */ }
|
|
218
|
-
}
|
|
316
|
+
console.error(`⏰ Expired rule omitted from surface: ${expired.slug}`);
|
|
219
317
|
}
|
|
220
318
|
|
|
221
|
-
// 2. Group active (non-expired) rules from SSSS vault nodes
|
|
319
|
+
// 2. Group active (non-expired) rules from SSSS vault nodes.
|
|
320
|
+
// Note: Only invariants, preferences, and anti-patterns are included in instructions,
|
|
321
|
+
// enforcing Category Partitioning (concepts, decisions, facts are search-only).
|
|
222
322
|
const invariants = nodes.filter(n => n.category === 'invariants' && n.status === 'active' && !isExpired(n));
|
|
223
323
|
const preferences = nodes.filter(n => n.category === 'preferences' && n.status === 'active' && !isExpired(n));
|
|
224
324
|
const corrections = nodes.filter(n => n.category === 'anti-patterns' && n.status === 'active' && !isExpired(n));
|
|
225
325
|
|
|
226
|
-
const formatNodes = (list) => {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
326
|
+
const formatNodes = async (list) => {
|
|
327
|
+
const formatted = [];
|
|
328
|
+
for (const n of list) {
|
|
329
|
+
const snippet = await compactNode(n, derivedDir);
|
|
330
|
+
formatted.push(snippet.startsWith('-') ? snippet : `- ${snippet}`);
|
|
331
|
+
}
|
|
332
|
+
return formatted.join('\n');
|
|
231
333
|
};
|
|
232
334
|
|
|
233
335
|
if (invariants.length > 0) {
|
|
234
|
-
combined += `\n\n## Invariant Rules\n\n${formatNodes(invariants)}`;
|
|
336
|
+
combined += `\n\n## Invariant Rules\n\n${await formatNodes(invariants)}`;
|
|
235
337
|
}
|
|
236
338
|
|
|
237
339
|
if (preferences.length > 0) {
|
|
238
|
-
combined += `\n\n## User Preferences\n\n${formatNodes(preferences)}`;
|
|
340
|
+
combined += `\n\n## User Preferences\n\n${await formatNodes(preferences)}`;
|
|
239
341
|
}
|
|
240
342
|
|
|
241
343
|
if (corrections.length > 0) {
|
|
242
|
-
combined += `\n\n## Corrections\n\n${formatNodes(corrections)}`;
|
|
344
|
+
combined += `\n\n## Corrections\n\n${await formatNodes(corrections)}`;
|
|
243
345
|
}
|
|
244
346
|
|
|
245
347
|
// 2. Append legacy rule sheet files if they exist
|
|
@@ -283,9 +385,9 @@ function injectDirectives(fileContent, rulesBlock) {
|
|
|
283
385
|
/**
|
|
284
386
|
* Write or update a platform instruction shim with the pointer and active rules.
|
|
285
387
|
*/
|
|
286
|
-
function writeShim(shimPath, skillsDir, nodes = [], { vaultDir } = {}) {
|
|
388
|
+
async function writeShim(shimPath, skillsDir, nodes = [], { vaultDir, derivedDir } = {}) {
|
|
287
389
|
const shimDir = path.dirname(shimPath);
|
|
288
|
-
const rulesBlock = buildRulesBlock(skillsDir, nodes, { vaultDir });
|
|
390
|
+
const rulesBlock = await buildRulesBlock(skillsDir, nodes, { vaultDir, derivedDir });
|
|
289
391
|
const baseline = 'Read and follow .agent/skills/total-recall/SKILL.md on every turn.\n';
|
|
290
392
|
const fullContent = `${baseline}\n${DIRECTIVES_BEGIN}\n${rulesBlock}\n${DIRECTIVES_END}\n`;
|
|
291
393
|
|
|
@@ -333,9 +435,7 @@ const CLIENT_SHIMS = {
|
|
|
333
435
|
};
|
|
334
436
|
|
|
335
437
|
/**
|
|
336
|
-
* Read the
|
|
337
|
-
* Returns a Set of client name strings, or null if the file is
|
|
338
|
-
* missing / empty / malformed (null signals "write all shims").
|
|
438
|
+
* Read the connected client config.
|
|
339
439
|
*/
|
|
340
440
|
function readConnectedClients(clientsPath) {
|
|
341
441
|
try {
|
|
@@ -351,22 +451,14 @@ function readConnectedClients(clientsPath) {
|
|
|
351
451
|
}
|
|
352
452
|
|
|
353
453
|
/**
|
|
354
|
-
* Compile
|
|
355
|
-
*
|
|
356
|
-
* ALL files (including INSTRUCTIONS.md) are user-owned. TR only injects
|
|
357
|
-
* content between the <!-- BEGIN/END INJECTED ACTIVE DIRECTIVES --> markers,
|
|
358
|
-
* preserving any user-written content outside the markers.
|
|
359
|
-
*
|
|
360
|
-
* Only shims for connected clients (per config/clients.json) are written.
|
|
361
|
-
* If clients.json is absent or unreadable, ALL shims are written (backward compat).
|
|
362
|
-
* INSTRUCTIONS.md is always written as the canonical source.
|
|
454
|
+
* Compile shims asynchronously.
|
|
363
455
|
*/
|
|
364
|
-
function compilePointers(instructionsFile, skillsDir, nodes = [], { vaultDir } = {}) {
|
|
456
|
+
async function compilePointers(instructionsFile, skillsDir, nodes = [], { vaultDir, derivedDir } = {}) {
|
|
365
457
|
const agentDir = path.dirname(instructionsFile);
|
|
366
458
|
const baseDir = path.basename(agentDir) === '.agent' ? path.dirname(agentDir) : agentDir;
|
|
367
459
|
|
|
368
460
|
// Always write the canonical INSTRUCTIONS.md
|
|
369
|
-
writeShim(path.join(baseDir, 'INSTRUCTIONS.md'), skillsDir, nodes, { vaultDir });
|
|
461
|
+
await writeShim(path.join(baseDir, 'INSTRUCTIONS.md'), skillsDir, nodes, { vaultDir, derivedDir });
|
|
370
462
|
|
|
371
463
|
// Determine which client shims to write
|
|
372
464
|
const clientsPath = path.join(baseDir, '.agent', 'config', 'clients.json');
|
|
@@ -376,7 +468,7 @@ function compilePointers(instructionsFile, skillsDir, nodes = [], { vaultDir } =
|
|
|
376
468
|
// No clients.json → backward compat: write ALL client shims
|
|
377
469
|
for (const files of Object.values(CLIENT_SHIMS)) {
|
|
378
470
|
for (const file of files) {
|
|
379
|
-
writeShim(path.join(baseDir, file), skillsDir, nodes, { vaultDir });
|
|
471
|
+
await writeShim(path.join(baseDir, file), skillsDir, nodes, { vaultDir, derivedDir });
|
|
380
472
|
}
|
|
381
473
|
}
|
|
382
474
|
} else {
|
|
@@ -385,7 +477,7 @@ function compilePointers(instructionsFile, skillsDir, nodes = [], { vaultDir } =
|
|
|
385
477
|
const files = CLIENT_SHIMS[client];
|
|
386
478
|
if (!files) continue;
|
|
387
479
|
for (const file of files) {
|
|
388
|
-
writeShim(path.join(baseDir, file), skillsDir, nodes, { vaultDir });
|
|
480
|
+
await writeShim(path.join(baseDir, file), skillsDir, nodes, { vaultDir, derivedDir });
|
|
389
481
|
}
|
|
390
482
|
}
|
|
391
483
|
}
|
|
@@ -394,11 +486,29 @@ function compilePointers(instructionsFile, skillsDir, nodes = [], { vaultDir } =
|
|
|
394
486
|
/**
|
|
395
487
|
* Main surface compilation entry point.
|
|
396
488
|
*/
|
|
397
|
-
export async function compileSurface({ vaultDir, skillsDir, derivedDir, instructionsFile }) {
|
|
398
|
-
const nodes =
|
|
489
|
+
export async function compileSurface({ vaultDir, skillsDir, derivedDir, instructionsFile, force = false }) {
|
|
490
|
+
const nodes = getNodes(vaultDir);
|
|
491
|
+
|
|
492
|
+
// ── Incremental compilation: vault content hash check ──
|
|
493
|
+
const hashFile = path.join(derivedDir, 'vault-hash.txt');
|
|
494
|
+
const currentHash = computeVaultHash(vaultDir);
|
|
495
|
+
|
|
496
|
+
if (!force && fs.existsSync(hashFile)) {
|
|
497
|
+
const storedHash = fs.readFileSync(hashFile, 'utf8').trim();
|
|
498
|
+
if (storedHash === currentHash) {
|
|
499
|
+
return {
|
|
500
|
+
nodesProcessed: nodes.length,
|
|
501
|
+
skillsInjected: 0,
|
|
502
|
+
semanticIndexed: 0,
|
|
503
|
+
semanticUnavailable: false,
|
|
504
|
+
skipped: true,
|
|
505
|
+
reason: 'vault-hash-unchanged'
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
}
|
|
399
509
|
|
|
400
510
|
// 1. Write pointer and active rules to all instruction shims
|
|
401
|
-
compilePointers(instructionsFile, skillsDir, nodes, { vaultDir });
|
|
511
|
+
await compilePointers(instructionsFile, skillsDir, nodes, { vaultDir, derivedDir });
|
|
402
512
|
|
|
403
513
|
// 2. Build derived indexes (powers semantic search API)
|
|
404
514
|
if (!fs.existsSync(derivedDir)) {
|
|
@@ -415,30 +525,70 @@ export async function compileSurface({ vaultDir, skillsDir, derivedDir, instruct
|
|
|
415
525
|
}));
|
|
416
526
|
atomicWrite(path.join(derivedDir, 'graph-index.jsonl'), graphIndex.map(n => JSON.stringify(n)).join('\n'));
|
|
417
527
|
atomicWrite(
|
|
418
|
-
|
|
419
|
-
|
|
528
|
+
path.join(derivedDir, 'memory-layers.jsonl'),
|
|
529
|
+
buildMemoryLayerIndex(nodes).map(n => JSON.stringify(n)).join('\n')
|
|
420
530
|
);
|
|
421
531
|
|
|
422
532
|
// 3. Build semantic embeddings index if available (fire-and-forget)
|
|
423
533
|
let semanticResult = { indexed: 0, skipped: nodes.length, unavailable: true };
|
|
424
534
|
try {
|
|
425
|
-
const {
|
|
426
|
-
|
|
427
|
-
|
|
535
|
+
const { buildEmbeddingsIndex } = await import('./embeddings.mjs');
|
|
536
|
+
const embResult = await buildEmbeddingsIndex(nodes, derivedDir);
|
|
537
|
+
semanticResult = { indexed: embResult.built, skipped: embResult.skipped, unavailable: false };
|
|
538
|
+
} catch { /* semantic index is optional */ }
|
|
428
539
|
|
|
429
|
-
// 4. Generate Obsidian Canvas
|
|
540
|
+
// 4. Generate Obsidian Canvas
|
|
430
541
|
try {
|
|
431
542
|
generateCanvas(nodes, vaultDir);
|
|
432
543
|
} catch { /* non-fatal */ }
|
|
433
544
|
|
|
545
|
+
// 5. Write vault hash + projection manifest
|
|
546
|
+
atomicWrite(hashFile, currentHash);
|
|
547
|
+
writeProjectionManifest(derivedDir, currentHash);
|
|
548
|
+
|
|
434
549
|
return {
|
|
435
550
|
nodesProcessed: nodes.length,
|
|
436
|
-
skillsInjected: 0,
|
|
551
|
+
skillsInjected: 0,
|
|
437
552
|
semanticIndexed: semanticResult.indexed,
|
|
438
553
|
semanticUnavailable: semanticResult.unavailable
|
|
439
554
|
};
|
|
440
555
|
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Compute vault content hash.
|
|
559
|
+
*/
|
|
560
|
+
function computeVaultHash(vaultDir) {
|
|
561
|
+
const files = walkMd(vaultDir).sort();
|
|
562
|
+
const hash = crypto.createHash('sha256');
|
|
563
|
+
for (const file of files) {
|
|
564
|
+
try {
|
|
565
|
+
const stat = fs.statSync(file);
|
|
566
|
+
hash.update(`${file}:${stat.mtimeMs}:${stat.size}\n`);
|
|
567
|
+
} catch { /* skip unreadable files */ }
|
|
568
|
+
}
|
|
569
|
+
return hash.digest('hex');
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Write projection manifest.
|
|
574
|
+
*/
|
|
575
|
+
function writeProjectionManifest(derivedDir, vaultHash) {
|
|
576
|
+
const manifest = {
|
|
577
|
+
type: 'projection-manifest',
|
|
578
|
+
generated_at: new Date().toISOString(),
|
|
579
|
+
vault_hash: `sha256:${vaultHash}`,
|
|
580
|
+
projections: [
|
|
581
|
+
{ file: 'graph-index.jsonl', disposable: true },
|
|
582
|
+
{ file: 'memory-layers.jsonl', disposable: true },
|
|
583
|
+
{ file: 'embeddings.jsonl', disposable: true },
|
|
584
|
+
{ file: 'vault-hash.txt', disposable: true }
|
|
585
|
+
],
|
|
586
|
+
rebuild_command: 'npx total-recall compile'
|
|
587
|
+
};
|
|
588
|
+
atomicWrite(path.join(derivedDir, 'MANIFEST.json'), JSON.stringify(manifest, null, 2));
|
|
589
|
+
}
|
|
590
|
+
|
|
441
591
|
// ─── Legacy exports (kept for backward compatibility) ───
|
|
442
592
|
export function routeNodesToSkills() { return []; }
|
|
443
593
|
export function injectSkills() {}
|
|
444
|
-
export function compileTier1(nodes, instructionsFile) { compilePointers(instructionsFile); }
|
|
594
|
+
export async function compileTier1(nodes, instructionsFile) { await compilePointers(instructionsFile); }
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/core/validated-write.mjs
|
|
3
|
+
*
|
|
4
|
+
* Validated write adapter — routes memory mutations through the SSSS §6
|
|
5
|
+
* Operation Contract (processOperation) instead of bypassing it via raw
|
|
6
|
+
* writeNode(). This ensures every write gets:
|
|
7
|
+
* - Envelope validation (Stage 1)
|
|
8
|
+
* - Idempotency deduplication (Stage 2)
|
|
9
|
+
* - Authorization checks (Stage 3)
|
|
10
|
+
* - Lease protection (Stage 4)
|
|
11
|
+
* - Schema validation (Stage 5)
|
|
12
|
+
* - Atomic commit (Stage 6)
|
|
13
|
+
* - Audit trail (Stage 7)
|
|
14
|
+
*
|
|
15
|
+
* Usage:
|
|
16
|
+
* import { writeNodeValidated } from './validated-write.mjs';
|
|
17
|
+
* const result = writeNodeValidated(node, vaultDir);
|
|
18
|
+
* if (!result.success) throw new Error(result.validation.errors.join('; '));
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import crypto from 'node:crypto';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import matter from 'gray-matter';
|
|
24
|
+
import { processOperation } from './operation-validator.mjs';
|
|
25
|
+
import { safeStringify } from './vault.mjs';
|
|
26
|
+
import { invalidate } from './vault-cache.mjs';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Write a memory node through the full §6 Operation Contract pipeline.
|
|
30
|
+
*
|
|
31
|
+
* @param {object} node - The memory node object (slug, category, title, body, etc.)
|
|
32
|
+
* @param {string} vaultDir - Absolute path to the vault directory
|
|
33
|
+
* @param {object} [options]
|
|
34
|
+
* @param {string} [options.agentRole='admin'] - Agent authorization role
|
|
35
|
+
* @param {string} [options.workspaceId='default'] - Workspace scope
|
|
36
|
+
* @param {string} [options.idempotencyKey] - Idempotency key (auto-generated if omitted)
|
|
37
|
+
* @param {string} [options.leaseStore] - Path to lease store directory
|
|
38
|
+
* @param {string} [options.eventLogDir] - Path to event log directory
|
|
39
|
+
* @param {boolean} [options.dryRun=false] - Validate without committing
|
|
40
|
+
* @returns {object} Operation response (§6.4)
|
|
41
|
+
*/
|
|
42
|
+
export function writeNodeValidated(node, vaultDir, options = {}) {
|
|
43
|
+
const {
|
|
44
|
+
agentRole = 'admin',
|
|
45
|
+
workspaceId = 'default',
|
|
46
|
+
idempotencyKey,
|
|
47
|
+
leaseStore,
|
|
48
|
+
eventLogDir,
|
|
49
|
+
dryRun = false,
|
|
50
|
+
} = options;
|
|
51
|
+
|
|
52
|
+
// Build the Markdown content from the node object
|
|
53
|
+
const { body, _filePath, ...frontmatter } = node;
|
|
54
|
+
if (!frontmatter.type) frontmatter.type = 'memory';
|
|
55
|
+
if (!frontmatter.schema_version) frontmatter.schema_version = 2;
|
|
56
|
+
if (!frontmatter.updated) frontmatter.updated = new Date().toISOString();
|
|
57
|
+
if (!frontmatter.last_accessed) frontmatter.last_accessed = new Date().toISOString();
|
|
58
|
+
|
|
59
|
+
const content = safeStringify(body || '', frontmatter);
|
|
60
|
+
|
|
61
|
+
// Build the §6.1 envelope
|
|
62
|
+
const category = frontmatter.category || 'uncategorized';
|
|
63
|
+
const slug = frontmatter.slug || 'unnamed';
|
|
64
|
+
const vfsPath = `${category}/${slug}.md`;
|
|
65
|
+
|
|
66
|
+
const envelope = {
|
|
67
|
+
type: 'operation',
|
|
68
|
+
idempotency_key: idempotencyKey || crypto.randomUUID(),
|
|
69
|
+
path: vfsPath,
|
|
70
|
+
workspace_id: workspaceId,
|
|
71
|
+
content,
|
|
72
|
+
dry_run: dryRun,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// Run the full §6.3 pipeline
|
|
76
|
+
const result = processOperation(envelope, vaultDir, {
|
|
77
|
+
agentRole,
|
|
78
|
+
leaseStore,
|
|
79
|
+
eventLogDir,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Invalidate vault cache on successful commit
|
|
83
|
+
if (result.success && !dryRun) {
|
|
84
|
+
invalidate(vaultDir);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Validate a node against the §6 pipeline without committing.
|
|
92
|
+
* Useful for pre-flight checks on user input.
|
|
93
|
+
*
|
|
94
|
+
* @param {object} node - The memory node object
|
|
95
|
+
* @param {string} vaultDir - Absolute path to the vault directory
|
|
96
|
+
* @returns {object} Validation result with errors/warnings
|
|
97
|
+
*/
|
|
98
|
+
export function validateNode(node, vaultDir) {
|
|
99
|
+
return writeNodeValidated(node, vaultDir, { dryRun: true });
|
|
100
|
+
}
|
package/src/core/vault-cache.mjs
CHANGED
|
@@ -2,35 +2,58 @@ import fs from 'fs';
|
|
|
2
2
|
import { loadNodes } from './vault.mjs';
|
|
3
3
|
import { logger } from './logger.mjs';
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Per-directory vault cache with fs.watch auto-invalidation.
|
|
7
|
+
* Supports multiple vault directories (global + project brains)
|
|
8
|
+
* without cross-contamination.
|
|
9
|
+
*/
|
|
10
|
+
const cacheMap = new Map(); // vaultDir → { nodes, watcher }
|
|
8
11
|
|
|
9
12
|
/**
|
|
10
13
|
* Retrieves memory nodes from the in-memory cache if fresh, otherwise loads them from disk.
|
|
11
14
|
* Automatically wires a file watcher on first load to track external changes.
|
|
15
|
+
* Supports multiple vault directories simultaneously (global + project).
|
|
12
16
|
*
|
|
13
17
|
* @param {string} vaultDir - Path to the memory vault directory
|
|
14
18
|
* @returns {Array} List of memory node objects
|
|
15
19
|
*/
|
|
16
20
|
export function getNodes(vaultDir) {
|
|
17
|
-
|
|
18
|
-
if (
|
|
19
|
-
logger.debug('Vault cache hit');
|
|
20
|
-
return
|
|
21
|
+
const entry = cacheMap.get(vaultDir);
|
|
22
|
+
if (entry?.nodes !== null && entry?.nodes !== undefined) {
|
|
23
|
+
logger.debug('Vault cache hit', { vaultDir });
|
|
24
|
+
return entry.nodes;
|
|
21
25
|
}
|
|
22
26
|
logger.info('Vault cache miss, loading nodes from disk', { vaultDir });
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
27
|
+
const nodes = loadNodes(vaultDir);
|
|
28
|
+
if (entry) {
|
|
29
|
+
entry.nodes = nodes;
|
|
30
|
+
} else {
|
|
31
|
+
cacheMap.set(vaultDir, { nodes, watcher: null });
|
|
32
|
+
startWatcher(vaultDir);
|
|
33
|
+
}
|
|
34
|
+
return nodes;
|
|
26
35
|
}
|
|
27
36
|
|
|
28
37
|
/**
|
|
29
|
-
* Invalidates the in-memory vault cache
|
|
38
|
+
* Invalidates the in-memory vault cache for a specific directory,
|
|
39
|
+
* or all directories if none specified.
|
|
40
|
+
* Forces the next read to reload from disk.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} [vaultDir] - Optional specific vault dir to invalidate. If omitted, invalidates all.
|
|
30
43
|
*/
|
|
31
|
-
export function invalidate() {
|
|
32
|
-
|
|
33
|
-
|
|
44
|
+
export function invalidate(vaultDir) {
|
|
45
|
+
if (vaultDir) {
|
|
46
|
+
const entry = cacheMap.get(vaultDir);
|
|
47
|
+
if (entry) {
|
|
48
|
+
logger.info('Vault cache invalidated', { vaultDir });
|
|
49
|
+
entry.nodes = null;
|
|
50
|
+
}
|
|
51
|
+
} else {
|
|
52
|
+
logger.info('Vault cache invalidated (all)');
|
|
53
|
+
for (const entry of cacheMap.values()) {
|
|
54
|
+
entry.nodes = null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
34
57
|
}
|
|
35
58
|
|
|
36
59
|
/**
|
|
@@ -40,7 +63,9 @@ export function invalidate() {
|
|
|
40
63
|
* @param {string} vaultDir - Path to the vault directory to watch
|
|
41
64
|
*/
|
|
42
65
|
function startWatcher(vaultDir) {
|
|
43
|
-
|
|
66
|
+
const entry = cacheMap.get(vaultDir);
|
|
67
|
+
if (!entry || entry.watcher) return;
|
|
68
|
+
|
|
44
69
|
if (!fs.existsSync(vaultDir)) {
|
|
45
70
|
try {
|
|
46
71
|
fs.mkdirSync(vaultDir, { recursive: true });
|
|
@@ -52,14 +77,15 @@ function startWatcher(vaultDir) {
|
|
|
52
77
|
|
|
53
78
|
logger.info('Starting filesystem watcher for vault cache', { vaultDir });
|
|
54
79
|
try {
|
|
55
|
-
watcher = fs.watch(vaultDir, { recursive: true }, (eventType, filename) => {
|
|
80
|
+
const watcher = fs.watch(vaultDir, { recursive: true }, (eventType, filename) => {
|
|
56
81
|
if (filename && filename.endsWith('.md')) {
|
|
57
82
|
logger.info(`Vault filesystem change detected (${eventType}: ${filename}), invalidating cache`);
|
|
58
|
-
invalidate();
|
|
83
|
+
invalidate(vaultDir);
|
|
59
84
|
}
|
|
60
85
|
});
|
|
61
86
|
// Unref the watcher so it does not prevent Node from exiting cleanly
|
|
62
87
|
watcher.unref();
|
|
88
|
+
entry.watcher = watcher;
|
|
63
89
|
} catch (err) {
|
|
64
90
|
logger.warn('Failed to start vault fs watcher, cache will rely on explicit invalidations', { error: err.message });
|
|
65
91
|
}
|
|
@@ -71,6 +97,8 @@ function startWatcher(vaultDir) {
|
|
|
71
97
|
* @param {string} vaultDir - Path to the vault directory to watch
|
|
72
98
|
*/
|
|
73
99
|
export function start(vaultDir) {
|
|
74
|
-
|
|
100
|
+
if (!cacheMap.has(vaultDir)) {
|
|
101
|
+
cacheMap.set(vaultDir, { nodes: null, watcher: null });
|
|
102
|
+
}
|
|
75
103
|
startWatcher(vaultDir);
|
|
76
104
|
}
|
package/src/core/vault.mjs
CHANGED
|
@@ -37,22 +37,6 @@ function stripUndefined(obj) {
|
|
|
37
37
|
* @returns {string} The serialized markdown with frontmatter
|
|
38
38
|
*/
|
|
39
39
|
export function safeStringify(content, data) {
|
|
40
|
-
if (data && data.type === 'memory') {
|
|
41
|
-
const now = new Date().toISOString();
|
|
42
|
-
if (!data.x_temporal_context) {
|
|
43
|
-
data.x_temporal_context = data.updated || data.created || now;
|
|
44
|
-
}
|
|
45
|
-
if (!data.x_citations) {
|
|
46
|
-
data.x_citations = [{
|
|
47
|
-
source: data.source?.type || 'unknown',
|
|
48
|
-
title: data.title || 'Untitled Memory',
|
|
49
|
-
url: data.source?.session_id ? `session://${data.source.session_id}` : 'unknown',
|
|
50
|
-
published: data.x_temporal_context,
|
|
51
|
-
relevance: 1.0,
|
|
52
|
-
accessed: now
|
|
53
|
-
}];
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
40
|
return matter.stringify(content ?? '', stripUndefined(data ?? {}));
|
|
57
41
|
}
|
|
58
42
|
|
|
@@ -63,13 +47,12 @@ export function walkMd(dir) {
|
|
|
63
47
|
let results = [];
|
|
64
48
|
if (!fs.existsSync(dir)) return results;
|
|
65
49
|
|
|
66
|
-
const list = fs.readdirSync(dir);
|
|
67
|
-
for (const
|
|
68
|
-
const fullPath = path.join(dir,
|
|
69
|
-
|
|
70
|
-
if (stat && stat.isDirectory()) {
|
|
50
|
+
const list = fs.readdirSync(dir, { withFileTypes: true });
|
|
51
|
+
for (const entry of list) {
|
|
52
|
+
const fullPath = path.join(dir, entry.name);
|
|
53
|
+
if (entry.isDirectory()) {
|
|
71
54
|
results = results.concat(walkMd(fullPath));
|
|
72
|
-
} else if (
|
|
55
|
+
} else if (entry.name.endsWith('.md')) {
|
|
73
56
|
results.push(fullPath);
|
|
74
57
|
}
|
|
75
58
|
}
|
|
@@ -178,13 +161,18 @@ export function writeNode(node, vaultDir) {
|
|
|
178
161
|
* @returns {boolean}
|
|
179
162
|
*/
|
|
180
163
|
export function deleteNode(slug, vaultDir) {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
164
|
+
if (!SAFE_NAME.test(slug)) return false;
|
|
165
|
+
if (!fs.existsSync(vaultDir)) return false;
|
|
166
|
+
|
|
167
|
+
// Scan category directories directly instead of loading entire vault
|
|
168
|
+
const entries = fs.readdirSync(vaultDir, { withFileTypes: true });
|
|
169
|
+
for (const entry of entries) {
|
|
170
|
+
if (!entry.isDirectory()) continue;
|
|
171
|
+
const filePath = path.join(vaultDir, entry.name, `${slug}.md`);
|
|
172
|
+
if (fs.existsSync(filePath)) {
|
|
173
|
+
fs.rmSync(filePath);
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
188
176
|
}
|
|
189
177
|
return false;
|
|
190
178
|
}
|