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.
Files changed (66) hide show
  1. package/README.md +1 -1
  2. package/extension/background.js +14 -2
  3. package/extension/content-script.js +18 -1
  4. package/extension/lib/brain-client.js +86 -52
  5. package/extension/lib/preconfigured.js +5 -0
  6. package/extension/manifest.json +1 -1
  7. package/extension/options/options.html +1 -0
  8. package/extension/options/options.js +12 -6
  9. package/extension/popup/popup.html +1 -0
  10. package/extension/popup/popup.js +29 -4
  11. package/extension/sidepanel/sidepanel.html +1 -0
  12. package/extension/sidepanel/sidepanel.js +24 -6
  13. package/frontend/index.html +8 -0
  14. package/frontend/src/api.ts +1 -2
  15. package/frontend/src/pages/ChatPage.tsx +9 -6
  16. package/frontend/src/pages/IntegrationsPage.tsx +144 -3
  17. package/package.json +2 -2
  18. package/scaffold/.agent/skills/total-recall/SKILL.md +55 -6
  19. package/scaffold/.agent/skills/total-recall/memory-vault/invariants/operating-instructions.md +2 -2
  20. package/scaffold/.agent/skills/total-recall/skills/cli-agents/agents.yml +1 -6
  21. package/scaffold/.agent/skills/total-recall/skills/ssss/SKILL.md +19 -0
  22. package/scaffold/.agent/skills/total-recall/skills/ssss/references/ssss-spec.md +33 -5
  23. package/src/cli/forget.mjs +18 -12
  24. package/src/cli/ingest/index.mjs +3 -2
  25. package/src/cli/init.mjs +30 -0
  26. package/src/cli/map.mjs +2 -2
  27. package/src/cli/rebuild.mjs +1 -1
  28. package/src/cli/remember.mjs +21 -11
  29. package/src/cli/setup.mjs +10 -1
  30. package/src/cli/share.mjs +7 -2
  31. package/src/core/append-log.mjs +131 -0
  32. package/src/core/clarity-rewriter.mjs +10 -6
  33. package/src/core/conclusion-writer.mjs +3 -2
  34. package/src/core/config.mjs +6 -0
  35. package/src/core/conflict-detector.mjs +4 -2
  36. package/src/core/context-compiler.mjs +412 -0
  37. package/src/core/daemon-loop.mjs +15 -0
  38. package/src/core/dream.mjs +4 -4
  39. package/src/core/drift-detector.mjs +3 -3
  40. package/src/core/embeddings.mjs +60 -10
  41. package/src/core/fact-seeker.mjs +70 -44
  42. package/src/core/import-rules.mjs +3 -2
  43. package/src/core/inference-engine.mjs +5 -4
  44. package/src/core/migrate.mjs +4 -3
  45. package/src/core/optimizer.mjs +6 -5
  46. package/src/core/parallel-context.mjs +288 -0
  47. package/src/core/post-mortem.mjs +4 -3
  48. package/src/core/research-queue.mjs +4 -3
  49. package/src/core/runtime.mjs +2 -2
  50. package/src/core/sandbox.mjs +56 -26
  51. package/src/core/scheduler.mjs +6 -5
  52. package/src/core/schema.mjs +2 -2
  53. package/src/core/search.mjs +167 -101
  54. package/src/core/surface.mjs +203 -53
  55. package/src/core/validated-write.mjs +100 -0
  56. package/src/core/vault-cache.mjs +46 -18
  57. package/src/core/vault.mjs +17 -29
  58. package/src/core/vector-field.mjs +659 -0
  59. package/src/core/vector-store.mjs +85 -0
  60. package/src/server/api.mjs +4 -3
  61. package/src/server/index.mjs +13 -61
  62. package/src/server/rest.mjs +238 -29
  63. package/src/server/route-manifest.json +344 -77
  64. package/src/server/routes/memory.mjs +85 -39
  65. package/src/server/routes/sessions.mjs +6 -2
  66. package/src/server/routes/share.mjs +9 -2
@@ -1,6 +1,8 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
- import { loadNodes, loadSkills, atomicWrite } from './vault.mjs';
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
- export function buildRulesBlock(skillsDir, nodes = [], { vaultDir, consumer = 'ide' } = {}) {
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 and auto-archive them
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
- expired.status = 'deprecated';
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
- return list.map(n => {
228
- const text = n.body || n.content || '';
229
- return text.startsWith('-') ? text : `- ${text}`;
230
- }).join('\n');
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 set of connected IDE clients from config/clients.json.
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 pointers with active rules directly into root instruction shims.
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 = loadNodes(vaultDir);
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
- path.join(derivedDir, 'memory-layers.jsonl'),
419
- buildMemoryLayerIndex(nodes).map(n => JSON.stringify(n)).join('\n')
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 { buildSemanticIndex } = await import('./semantic-index.mjs');
426
- semanticResult = await buildSemanticIndex(nodes, derivedDir);
427
- } catch { /* semantic index is optional — never block compile */ }
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 (fire-and-forget)
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, // v2: no more skill injection
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
+ }
@@ -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
- let cachedNodes = null;
6
- let watcher = null;
7
- let currentVaultDir = null;
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
- currentVaultDir = vaultDir;
18
- if (cachedNodes !== null) {
19
- logger.debug('Vault cache hit');
20
- return cachedNodes;
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
- cachedNodes = loadNodes(vaultDir);
24
- startWatcher(vaultDir);
25
- return cachedNodes;
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, forcing the next read to load from disk.
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
- logger.info('Vault cache invalidated');
33
- cachedNodes = null;
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
- if (watcher) return;
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
- currentVaultDir = vaultDir;
100
+ if (!cacheMap.has(vaultDir)) {
101
+ cacheMap.set(vaultDir, { nodes: null, watcher: null });
102
+ }
75
103
  startWatcher(vaultDir);
76
104
  }
@@ -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 file of list) {
68
- const fullPath = path.join(dir, file);
69
- const stat = fs.statSync(fullPath);
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 (fullPath.endsWith('.md')) {
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
- const nodes = loadNodes(vaultDir);
182
- const node = nodes.find(n => n.slug === slug);
183
- if (!node) return false;
184
- const filePath = path.join(vaultDir, node.category, `${node.slug}.md`);
185
- if (fs.existsSync(filePath)) {
186
- fs.rmSync(filePath);
187
- return true;
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
  }