total-recall-brain 3.3.0 → 3.5.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 (62) hide show
  1. package/README.md +1 -1
  2. package/bin/antigravity.mjs +19 -8
  3. package/extension/background.js +13 -1
  4. package/extension/content-script.js +18 -1
  5. package/extension/lib/brain-client.js +18 -5
  6. package/extension/options/options.js +9 -4
  7. package/frontend/index.html +8 -0
  8. package/frontend/src/api.ts +20 -1
  9. package/frontend/src/pages/ChatPage.tsx +9 -6
  10. package/frontend/src/pages/HealthPage.tsx +179 -7
  11. package/frontend/src/pages/IntegrationsPage.tsx +4 -2
  12. package/frontend/src/pages/SettingsPage.tsx +197 -2
  13. package/frontend/src/types.ts +16 -0
  14. package/package.json +2 -2
  15. package/scaffold/.agent/skills/total-recall/SKILL.md +55 -6
  16. package/scaffold/.agent/skills/total-recall/memory-vault/invariants/operating-instructions.md +2 -2
  17. package/scaffold/.agent/skills/total-recall/research-queue.jsonl +1 -0
  18. package/scaffold/.agent/skills/total-recall/skills/cli-agents/agents.yml +1 -6
  19. package/scaffold/.agent/skills/total-recall/skills/ssss/SKILL.md +19 -0
  20. package/scaffold/.agent/skills/total-recall/skills/ssss/references/ssss-spec.md +33 -5
  21. package/src/cli/forget.mjs +18 -12
  22. package/src/cli/ingest/index.mjs +3 -2
  23. package/src/cli/map.mjs +2 -2
  24. package/src/cli/rebuild.mjs +1 -1
  25. package/src/cli/remember.mjs +21 -11
  26. package/src/cli/share.mjs +7 -2
  27. package/src/core/append-log.mjs +131 -0
  28. package/src/core/clarity-rewriter.mjs +10 -6
  29. package/src/core/conclusion-writer.mjs +3 -2
  30. package/src/core/config.mjs +6 -0
  31. package/src/core/conflict-detector.mjs +4 -2
  32. package/src/core/context-compiler.mjs +412 -0
  33. package/src/core/daemon-loop.mjs +15 -0
  34. package/src/core/dream.mjs +4 -4
  35. package/src/core/drift-detector.mjs +3 -3
  36. package/src/core/embeddings.mjs +60 -10
  37. package/src/core/fact-seeker.mjs +70 -44
  38. package/src/core/import-rules.mjs +3 -2
  39. package/src/core/inference-engine.mjs +5 -4
  40. package/src/core/migrate.mjs +4 -3
  41. package/src/core/optimizer.mjs +6 -5
  42. package/src/core/parallel-context.mjs +288 -0
  43. package/src/core/post-mortem.mjs +4 -3
  44. package/src/core/research-queue.mjs +4 -3
  45. package/src/core/runtime.mjs +30 -7
  46. package/src/core/sandbox.mjs +56 -26
  47. package/src/core/scheduler.mjs +6 -5
  48. package/src/core/schema.mjs +483 -2
  49. package/src/core/search.mjs +167 -101
  50. package/src/core/surface.mjs +203 -53
  51. package/src/core/validated-write.mjs +100 -0
  52. package/src/core/vault-cache.mjs +46 -18
  53. package/src/core/vault.mjs +17 -29
  54. package/src/core/vector-field.mjs +659 -0
  55. package/src/core/vector-store.mjs +85 -0
  56. package/src/server/api.mjs +4 -3
  57. package/src/server/index.mjs +13 -61
  58. package/src/server/rest.mjs +341 -63
  59. package/src/server/route-manifest.json +352 -77
  60. package/src/server/routes/memory.mjs +85 -39
  61. package/src/server/routes/sessions.mjs +6 -2
  62. package/src/server/routes/share.mjs +9 -2
@@ -0,0 +1,412 @@
1
+ /**
2
+ * src/core/context-compiler.mjs
3
+ *
4
+ * Streaming Context Compiler — Dynamic per-request context assembly.
5
+ *
6
+ * The traditional surface compiler produces a STATIC INSTRUCTIONS.md
7
+ * that is injected wholesale into every agent turn. This module replaces
8
+ * that with a DYNAMIC context document assembled per-request from the
9
+ * vault, scored by temporal relevance, and packed within a token budget.
10
+ *
11
+ * Architecture:
12
+ * 1. CLASSIFY — route query to relevant domains
13
+ * 2. RETRIEVE — pull candidate nodes from vault
14
+ * 3. SCORE — rank by temporal_boost × semantic_similarity
15
+ * 4. BUDGET — pack into token budget via greedy knapsack
16
+ * 5. ASSEMBLE — build final context string from variable slots
17
+ *
18
+ * Variable Slots:
19
+ * ${identity} — agent identity (lore nodes, minimal)
20
+ * ${invariants} — absolute rules (always present, priority: absolute)
21
+ * ${corrections} — active corrections relevant to query domain
22
+ * ${preferences} — user preferences relevant to query domain
23
+ * ${facts} — domain facts retrieved by similarity
24
+ * ${decisions} — relevant design decisions
25
+ * ${patterns} — applicable patterns/anti-patterns
26
+ * ${skills} — routed SKILL.md content
27
+ * ${sessions} — relevant prior session context
28
+ *
29
+ * Every token earns its place through relevance. Nothing is injected by default.
30
+ */
31
+
32
+ import { getNodes } from './vault-cache.mjs';
33
+ import {
34
+ getEmbedding,
35
+ cosineSimilarity,
36
+ loadEmbeddingsIndex,
37
+ loadSessionEmbeddingsIndex,
38
+ } from './embeddings.mjs';
39
+ import { inferMemoryLayer, memoryLayerRoutingWeight } from './memory-layers.mjs';
40
+ import { logger } from './logger.mjs';
41
+
42
+ // ─── Token Budget Defaults ──────────────────────────────────────────────────
43
+
44
+ const DEFAULT_BUDGET = {
45
+ total: 12000, // max tokens for compiled context
46
+ identity: 200, // lore/identity ceiling
47
+ invariants: 2000, // absolute rules ceiling
48
+ corrections: 1000, // corrections ceiling
49
+ preferences: 500, // preferences ceiling
50
+ facts: 3000, // domain facts ceiling
51
+ decisions: 1500, // design decisions ceiling
52
+ patterns: 1500, // patterns + anti-patterns ceiling
53
+ skills: 3000, // skill instructions ceiling
54
+ sessions: 1500, // session continuity ceiling
55
+ };
56
+
57
+ // Rough token estimation: ~4 chars per token (conservative)
58
+ const CHARS_PER_TOKEN = 4;
59
+
60
+ function estimateTokens(text) {
61
+ if (!text) return 0;
62
+ return Math.ceil(text.length / CHARS_PER_TOKEN);
63
+ }
64
+
65
+ // ─── Temporal Scoring ───────────────────────────────────────────────────────
66
+
67
+ /**
68
+ * Compute temporal relevance boost for a vault node.
69
+ * Same formula as search.mjs but extracted for reuse.
70
+ */
71
+ function temporalScore(node) {
72
+ if (!node) return 1.0;
73
+ const now = Date.now();
74
+
75
+ // Recency: exponential decay
76
+ let recency = 1.0;
77
+ const lastAccessed = node.last_accessed || node.updated || node.created;
78
+ if (lastAccessed) {
79
+ const ageMs = now - new Date(lastAccessed).getTime();
80
+ const halfLifeDays = node.decay?.half_life_days || 7;
81
+ const halfLifeMs = halfLifeDays * 24 * 60 * 60 * 1000;
82
+ recency = Math.pow(0.5, ageMs / halfLifeMs);
83
+ recency = Math.max(recency, 0.05);
84
+ }
85
+
86
+ // Confidence
87
+ const confidence = typeof node.confidence === 'number' ? node.confidence : 1.0;
88
+ const confidenceWeight = 0.5 + (confidence * 0.5);
89
+
90
+ // Frequency
91
+ const accessCount = node.decay?.access_count ?? 0;
92
+ const frequencySignal = 1.0 + (Math.log10(accessCount + 1) * 0.2);
93
+
94
+ // Importance
95
+ const importance = typeof node.importance === 'number' ? node.importance : 3;
96
+ const importanceBoost = 0.8 + ((importance - 1) * 0.1);
97
+
98
+ // Priority
99
+ const priorityBoost = node.priority === 'absolute' ? 1.3 : 1.0;
100
+
101
+ // Layer
102
+ const layer = inferMemoryLayer(node);
103
+ const layerWeight = memoryLayerRoutingWeight(layer);
104
+
105
+ const momentumBoost = node._momentum ? 1.25 : 1.0;
106
+ return recency * confidenceWeight * frequencySignal * importanceBoost * priorityBoost * layerWeight * momentumBoost;
107
+ }
108
+
109
+ // ─── Node Formatting ────────────────────────────────────────────────────────
110
+
111
+ function formatNode(node) {
112
+ const body = (node.body || node.content || '').trim();
113
+ if (!body) return '';
114
+ return body.startsWith('-') ? body : `- ${body}`;
115
+ }
116
+
117
+ function formatNodeWithMeta(node) {
118
+ const body = (node.body || node.content || '').trim();
119
+ if (!body) return '';
120
+ const meta = [];
121
+ if (node.title) meta.push(node.title);
122
+ if (node.slug) meta.push(`[${node.slug}]`);
123
+ const prefix = meta.length > 0 ? `**${meta.join(' ')}**: ` : '';
124
+ return `${prefix}${body}`;
125
+ }
126
+
127
+ // ─── Context Slot Resolvers ─────────────────────────────────────────────────
128
+
129
+ /**
130
+ * Resolve the invariants slot. These are ALWAYS included (guaranteed budget).
131
+ * Only absolute-priority and active invariants.
132
+ */
133
+ function resolveInvariants(nodes, budget) {
134
+ const invariants = nodes
135
+ .filter(n => n.category === 'invariants' && n.status === 'active')
136
+ .sort((a, b) => {
137
+ // absolute priority first, then by importance desc
138
+ if (a.priority === 'absolute' && b.priority !== 'absolute') return -1;
139
+ if (b.priority === 'absolute' && a.priority !== 'absolute') return 1;
140
+ return (b.importance || 3) - (a.importance || 3);
141
+ });
142
+
143
+ let result = '';
144
+ let tokens = 0;
145
+ for (const node of invariants) {
146
+ const text = formatNode(node);
147
+ const t = estimateTokens(text);
148
+ if (tokens + t > budget) break;
149
+ result += text + '\n';
150
+ tokens += t;
151
+ }
152
+ return { text: result.trim(), tokens };
153
+ }
154
+
155
+ /**
156
+ * Resolve a category slot with temporal scoring.
157
+ * Returns top nodes by temporal score within budget.
158
+ */
159
+ function resolveCategory(nodes, category, budget, queryEmbedding, embeddingsIndex) {
160
+ const candidates = nodes.filter(n => n.category === category && n.status === 'active');
161
+ if (candidates.length === 0) return { text: '', tokens: 0 };
162
+
163
+ // Score each candidate
164
+ const scored = candidates.map(node => {
165
+ let score = temporalScore(node);
166
+
167
+ // Blend with semantic similarity if embedding available
168
+ if (queryEmbedding && embeddingsIndex[node.slug]) {
169
+ const sim = cosineSimilarity(queryEmbedding, embeddingsIndex[node.slug].embedding);
170
+ // Semantic similarity has strong weight for non-invariant categories
171
+ score *= (0.3 + sim * 0.7);
172
+ }
173
+
174
+ return { node, score };
175
+ });
176
+
177
+ // Sort by score descending
178
+ scored.sort((a, b) => b.score - a.score);
179
+
180
+ // Pack into budget
181
+ let result = '';
182
+ let tokens = 0;
183
+ for (const { node } of scored) {
184
+ const text = formatNode(node);
185
+ const t = estimateTokens(text);
186
+ if (tokens + t > budget) break;
187
+ result += text + '\n';
188
+ tokens += t;
189
+ }
190
+ return { text: result.trim(), tokens };
191
+ }
192
+
193
+ /**
194
+ * Resolve session context with temporal and semantic scoring.
195
+ */
196
+ function resolveSessions(derivedDir, budget, queryEmbedding) {
197
+ if (!queryEmbedding || !derivedDir) return { text: '', tokens: 0 };
198
+
199
+ const sessionIndex = loadSessionEmbeddingsIndex(derivedDir);
200
+ const entries = Object.entries(sessionIndex);
201
+ if (entries.length === 0) return { text: '', tokens: 0 };
202
+
203
+ const now = Date.now();
204
+ const scored = entries.map(([key, entry]) => {
205
+ const sim = cosineSimilarity(queryEmbedding, entry.embedding);
206
+ // Temporal boost for sessions: recent sessions score higher
207
+ const generatedAt = entry.generated_at ? new Date(entry.generated_at).getTime() : now;
208
+ const ageMs = now - generatedAt;
209
+ const halfLifeMs = 3 * 24 * 60 * 60 * 1000; // 3 day half-life for sessions
210
+ const recency = Math.max(Math.pow(0.5, ageMs / halfLifeMs), 0.05);
211
+ return { key, entry, score: sim * recency };
212
+ });
213
+
214
+ scored.sort((a, b) => b.score - a.score);
215
+
216
+ let result = '';
217
+ let tokens = 0;
218
+ for (const { entry } of scored) {
219
+ const snippet = entry.snippet || '';
220
+ if (!snippet) continue;
221
+ const text = `> ${snippet}`;
222
+ const t = estimateTokens(text);
223
+ if (tokens + t > budget) break;
224
+ result += text + '\n';
225
+ tokens += t;
226
+ }
227
+ return { text: result.trim(), tokens };
228
+ }
229
+
230
+ // ─── Main Compiler ──────────────────────────────────────────────────────────
231
+
232
+ /**
233
+ * Compile a dynamic context document for a specific query.
234
+ *
235
+ * @param {object} opts
236
+ * @param {string} opts.query - The user query/task description
237
+ * @param {string} opts.vaultDir - Path to the vault
238
+ * @param {string} opts.derivedDir - Path to derived indexes
239
+ * @param {object} [opts.budget] - Token budget overrides per slot
240
+ * @param {string} [opts.consumer='api'] - 'api' or 'ide'
241
+ * @param {string[]} [opts.momentumSlugs] - Recent node slugs for momentum boost
242
+ * @returns {Promise<{ context: string, stats: object }>}
243
+ */
244
+ export async function compileContext({
245
+ query,
246
+ vaultDir,
247
+ derivedDir,
248
+ budget = {},
249
+ consumer = 'api',
250
+ momentumSlugs = [],
251
+ } = {}) {
252
+ const startTime = Date.now();
253
+ const b = { ...DEFAULT_BUDGET, ...budget };
254
+ const allNodes = getNodes(vaultDir).map(n => ({ ...n }));
255
+ const stats = { total_nodes: allNodes.length, slots: {} };
256
+
257
+ // ── Generate query embedding for semantic scoring ──
258
+ let queryEmbedding = null;
259
+ try {
260
+ if (query) {
261
+ queryEmbedding = await getEmbedding(String(query));
262
+ }
263
+ } catch { /* graceful degradation */ }
264
+
265
+ // ── Load embeddings index for similarity scoring ──
266
+ let embeddingsIndex = {};
267
+ try {
268
+ embeddingsIndex = loadEmbeddingsIndex(derivedDir);
269
+ } catch { /* graceful degradation */ }
270
+
271
+ // ── Apply momentum boost to recently-touched nodes ──
272
+ if (momentumSlugs.length > 0) {
273
+ const momentumSet = new Set(momentumSlugs);
274
+ for (const node of allNodes) {
275
+ if (momentumSet.has(node.slug)) {
276
+ // Temporarily boost last_accessed to now for momentum nodes
277
+ node._momentum = true;
278
+ }
279
+ }
280
+ }
281
+
282
+ // ── Resolve each variable slot ──
283
+ const sections = [];
284
+ let totalTokens = 0;
285
+
286
+ // SLOT 1: Invariants (guaranteed — always present)
287
+ const invariants = resolveInvariants(allNodes, b.invariants);
288
+ if (invariants.text) {
289
+ sections.push({ label: 'Rules', content: invariants.text });
290
+ totalTokens += invariants.tokens;
291
+ stats.slots.invariants = { nodes: invariants.text.split('\n').length, tokens: invariants.tokens };
292
+ }
293
+
294
+ // SLOT 2: Corrections (query-relevant anti-patterns)
295
+ const remaining = b.total - totalTokens;
296
+ const corrections = resolveCategory(allNodes, 'anti-patterns', Math.min(b.corrections, remaining), queryEmbedding, embeddingsIndex);
297
+ if (corrections.text) {
298
+ sections.push({ label: 'Corrections', content: corrections.text });
299
+ totalTokens += corrections.tokens;
300
+ stats.slots.corrections = { tokens: corrections.tokens };
301
+ }
302
+
303
+ // SLOT 3: Preferences (query-relevant)
304
+ const prefBudget = Math.min(b.preferences, b.total - totalTokens);
305
+ const preferences = resolveCategory(allNodes, 'preferences', prefBudget, queryEmbedding, embeddingsIndex);
306
+ if (preferences.text) {
307
+ sections.push({ label: 'Preferences', content: preferences.text });
308
+ totalTokens += preferences.tokens;
309
+ stats.slots.preferences = { tokens: preferences.tokens };
310
+ }
311
+
312
+ // SLOT 4: Facts (semantically retrieved domain knowledge)
313
+ const factBudget = Math.min(b.facts, b.total - totalTokens);
314
+ const facts = resolveCategory(allNodes, 'facts', factBudget, queryEmbedding, embeddingsIndex);
315
+ if (facts.text) {
316
+ sections.push({ label: 'Context', content: facts.text });
317
+ totalTokens += facts.tokens;
318
+ stats.slots.facts = { tokens: facts.tokens };
319
+ }
320
+
321
+ // SLOT 5: Decisions (relevant design decisions)
322
+ const decBudget = Math.min(b.decisions, b.total - totalTokens);
323
+ const decisions = resolveCategory(allNodes, 'decisions', decBudget, queryEmbedding, embeddingsIndex);
324
+ if (decisions.text) {
325
+ sections.push({ label: 'Decisions', content: decisions.text });
326
+ totalTokens += decisions.tokens;
327
+ stats.slots.decisions = { tokens: decisions.tokens };
328
+ }
329
+
330
+ // SLOT 6: Patterns + Anti-patterns
331
+ const patBudget = Math.min(b.patterns, b.total - totalTokens);
332
+ const patterns = resolveCategory(allNodes, 'patterns', patBudget, queryEmbedding, embeddingsIndex);
333
+ if (patterns.text) {
334
+ sections.push({ label: 'Patterns', content: patterns.text });
335
+ totalTokens += patterns.tokens;
336
+ stats.slots.patterns = { tokens: patterns.tokens };
337
+ }
338
+
339
+ // SLOT 7: Concepts (deep understanding nodes)
340
+ const conBudget = Math.min(b.facts, b.total - totalTokens); // shares budget class with facts
341
+ const concepts = resolveCategory(allNodes, 'concepts', conBudget, queryEmbedding, embeddingsIndex);
342
+ if (concepts.text) {
343
+ sections.push({ label: 'Concepts', content: concepts.text });
344
+ totalTokens += concepts.tokens;
345
+ stats.slots.concepts = { tokens: concepts.tokens };
346
+ }
347
+
348
+ // SLOT 8: Lore (identity, only if relevant)
349
+ const loreBudget = Math.min(b.identity, b.total - totalTokens);
350
+ const lore = resolveCategory(allNodes, 'lore', loreBudget, queryEmbedding, embeddingsIndex);
351
+ if (lore.text) {
352
+ sections.push({ label: 'Identity', content: lore.text });
353
+ totalTokens += lore.tokens;
354
+ stats.slots.lore = { tokens: lore.tokens };
355
+ }
356
+
357
+ // SLOT 9: Sessions (prior conversation context)
358
+ const sessBudget = Math.min(b.sessions, b.total - totalTokens);
359
+ const sessions = resolveSessions(derivedDir, sessBudget, queryEmbedding);
360
+ if (sessions.text) {
361
+ sections.push({ label: 'Prior Context', content: sessions.text });
362
+ totalTokens += sessions.tokens;
363
+ stats.slots.sessions = { tokens: sessions.tokens };
364
+ }
365
+
366
+ // ── Assemble final context ──
367
+ const context = sections
368
+ .map(s => `## ${s.label}\n\n${s.content}`)
369
+ .join('\n\n---\n\n');
370
+
371
+ stats.total_tokens = totalTokens;
372
+ stats.budget_used = totalTokens;
373
+ stats.budget_remaining = b.total - totalTokens;
374
+ stats.compile_ms = Date.now() - startTime;
375
+ stats.slots_filled = sections.length;
376
+
377
+ logger.info('context-compiler', `Compiled ${sections.length} slots, ${totalTokens} tokens in ${stats.compile_ms}ms`);
378
+
379
+ return { context, stats };
380
+ }
381
+
382
+ /**
383
+ * Lightweight variant that returns pre-scored candidates without embedding lookup.
384
+ * Used for fast context previews and budget estimation.
385
+ */
386
+ export function previewContext({ vaultDir, budget = {} } = {}) {
387
+ const b = { ...DEFAULT_BUDGET, ...budget };
388
+ const allNodes = getNodes(vaultDir).map(n => ({ ...n }));
389
+ const active = allNodes.filter(n => n.status === 'active');
390
+
391
+ const candidates = active.map(node => ({
392
+ slug: node.slug,
393
+ category: node.category,
394
+ title: node.title,
395
+ temporal_score: temporalScore(node),
396
+ tokens: estimateTokens(node.body || node.content || ''),
397
+ layer: inferMemoryLayer(node),
398
+ priority: node.priority || 'normal',
399
+ importance: node.importance || 3,
400
+ confidence: node.confidence ?? 1.0,
401
+ last_accessed: node.last_accessed || null,
402
+ }));
403
+
404
+ candidates.sort((a, b) => b.temporal_score - a.temporal_score);
405
+
406
+ return {
407
+ candidates,
408
+ budget: b,
409
+ total_candidates: candidates.length,
410
+ total_tokens: candidates.reduce((sum, c) => sum + c.tokens, 0),
411
+ };
412
+ }
@@ -80,6 +80,10 @@ async function dispatchTask(task) {
80
80
  case 'memory-maintenance':
81
81
  return await runMaintenanceTask(task);
82
82
 
83
+ case 'proactive-research':
84
+ case 'research-acquisition':
85
+ return await runResearchTask(task);
86
+
83
87
  default:
84
88
  // All other categories (research, deliberation, inference, etc.)
85
89
  // require LLM and are handled by IDE agents, not the daemon.
@@ -250,6 +254,17 @@ async function main() {
250
254
  } catch (err) {
251
255
  logger.info({ subsystem: 'daemon-loop', message: `Surface recompile error: ${err.message}` });
252
256
  }
257
+
258
+ // Compact any append logs with accumulated dirty entries
259
+ try {
260
+ const { compactAppendLogs } = await import('./append-log.mjs');
261
+ const compactResult = compactAppendLogs();
262
+ if (compactResult.logs_compacted > 0) {
263
+ logger.info({ subsystem: 'daemon-loop', message: `Compacted ${compactResult.logs_compacted} append logs (${compactResult.total_entries} entries)` });
264
+ }
265
+ } catch (err) {
266
+ logger.info({ subsystem: 'daemon-loop', message: `Append log compaction error: ${err.message}` });
267
+ }
253
268
  }
254
269
 
255
270
  // Refresh scheduler from disk on each iteration
@@ -1,7 +1,8 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import os from 'os';
4
- import { loadNodes, writeNode, atomicWrite, walkMd } from './vault.mjs';
4
+ import { writeNode, atomicWrite, walkMd } from './vault.mjs';
5
+ import { getNodes } from './vault-cache.mjs';
5
6
  import { logger } from './logger.mjs';
6
7
  import { agentDir, brainDir } from './config.mjs';
7
8
  import { loadQueue } from './research-queue.mjs';
@@ -247,7 +248,7 @@ export async function runDreamCycle({
247
248
  const candidates = [];
248
249
 
249
250
  logger.info('dream', 'PHASE 2 — REM (Pattern Recognition)');
250
- const existingNodes = loadNodes(vaultDir);
251
+ const existingNodes = getNodes(vaultDir);
251
252
 
252
253
  if (candidates.length > 0) {
253
254
  const { promoted, conflicted } = evaluateCandidates(candidates, existingNodes, conflictsDir);
@@ -294,7 +295,7 @@ export async function runDreamCycle({
294
295
 
295
296
  // Write daily note summary (native SSSS node; Obsidian Daily Notes reads it directly)
296
297
  try {
297
- const existingNodes = loadNodes(vaultDir);
298
+ const existingNodes = getNodes(vaultDir);
298
299
  writeDailyNote(vaultDir, [
299
300
  `Modified vault files scanned: ${modified.length}`,
300
301
  `Active nodes: ${existingNodes.filter(n => n.status === 'active').length}`,
@@ -318,7 +319,6 @@ export async function runDreamCycle({
318
319
  // ─── Standalone Daemon Execution ────────────────────────────────────────────────
319
320
 
320
321
  import { fileURLToPath } from 'url';
321
- import os from 'os';
322
322
 
323
323
  if (process.argv[1] === fileURLToPath(import.meta.url)) {
324
324
  const vaultDir = path.join(brainDir, 'memory-vault');
@@ -1,7 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import crypto from 'node:crypto';
4
- import { loadNodes } from './vault.mjs';
4
+ import { getNodes } from './vault-cache.mjs';
5
5
 
6
6
  /**
7
7
  * SSSS Drift Detector
@@ -45,10 +45,10 @@ export function detectIndexDrift(vaultDir, derivedDir, globalVaultDir = undefine
45
45
  }
46
46
 
47
47
  // Load canonical nodes
48
- const nodes = loadNodes(vaultDir);
48
+ const nodes = getNodes(vaultDir);
49
49
  if (globalVaultDir && fs.existsSync(globalVaultDir)) {
50
50
  try {
51
- const globalNodes = loadNodes(globalVaultDir);
51
+ const globalNodes = getNodes(globalVaultDir);
52
52
  nodes.push(...globalNodes);
53
53
  } catch (err) {
54
54
  // Ignore or log global vault load failure
@@ -360,10 +360,14 @@ export function loadEmbeddingsIndex(derivedDir) {
360
360
  * @param {string} [model]
361
361
  */
362
362
  export function saveEmbeddingToIndex(derivedDir, slug, embedding, model = DEFAULT_EMBED_MODEL) {
363
- fs.mkdirSync(derivedDir, { recursive: true });
363
+ const p = indexPath(derivedDir);
364
364
  const index = loadEmbeddingsIndex(derivedDir);
365
365
  index[slug] = { embedding, model, generated_at: new Date().toISOString() };
366
- fs.writeFileSync(indexPath(derivedDir), JSON.stringify(index), 'utf8');
366
+ fs.mkdirSync(derivedDir, { recursive: true });
367
+ fs.writeFileSync(p, JSON.stringify(index), 'utf8');
368
+ cachedIndex = index;
369
+ cachedIndexPath = p;
370
+ cachedIndexMtime = fs.statSync(p).mtimeMs;
367
371
  }
368
372
 
369
373
  /**
@@ -374,10 +378,13 @@ export function saveEmbeddingToIndex(derivedDir, slug, embedding, model = DEFAUL
374
378
  */
375
379
  export function removeEmbeddingFromIndex(derivedDir, slug) {
376
380
  const p = indexPath(derivedDir);
377
- if (!fs.existsSync(p)) return;
378
381
  const index = loadEmbeddingsIndex(derivedDir);
379
382
  delete index[slug];
383
+ fs.mkdirSync(derivedDir, { recursive: true });
380
384
  fs.writeFileSync(p, JSON.stringify(index), 'utf8');
385
+ cachedIndex = index;
386
+ cachedIndexPath = p;
387
+ cachedIndexMtime = fs.statSync(p).mtimeMs;
381
388
  }
382
389
 
383
390
  // ─── Text representation ─────────────────────────────────────────────────────
@@ -390,13 +397,26 @@ export function removeEmbeddingFromIndex(derivedDir, slug) {
390
397
  * @returns {string}
391
398
  */
392
399
  export function nodeToEmbedText(node) {
393
- const parts = [node.title || node.slug];
400
+ const parts = [];
401
+ const title = node.title || node.slug;
402
+ parts.push(`Title: ${title}`);
394
403
  if (node.category) parts.push(`Category: ${node.category}`);
395
404
  if (node.tags?.length) parts.push(`Tags: ${node.tags.join(', ')}`);
396
- if (node.body) parts.push(node.body.slice(0, 2000)); // cap body at 2k chars
405
+ if (node.body) parts.push(`Body: ${node.body.slice(0, 2000)}`);
397
406
  return parts.join('\n\n');
398
407
  }
399
408
 
409
+ /**
410
+ * Split a node's body into paragraph chunks for hierarchical child matching.
411
+ */
412
+ export function chunkNodeBody(body) {
413
+ if (!body) return [];
414
+ return body
415
+ .split(/\n\n+/)
416
+ .map(p => p.trim())
417
+ .filter(p => p.length > 50); // only chunk substantial paragraphs
418
+ }
419
+
400
420
  // ─── Bulk index builder ──────────────────────────────────────────────────────
401
421
 
402
422
  /**
@@ -430,14 +450,44 @@ export async function buildEmbeddingsIndex(nodes, derivedDir, opts = {}) {
430
450
  let built = 0, skipped = 0, failed = 0;
431
451
 
432
452
  for (const node of nodes) {
453
+ const text = nodeToEmbedText(node);
454
+ const contentHash = crypto.createHash('sha256').update(text).digest('hex');
455
+
456
+ // Skip if existing entry has matching content hash (content unchanged)
433
457
  if (!force && existing[node.slug]) {
434
- skipped++;
435
- continue;
458
+ const cachedHash = existing[node.slug].content_sha256;
459
+ if (cachedHash && cachedHash === contentHash) {
460
+ skipped++;
461
+ continue;
462
+ }
436
463
  }
464
+
437
465
  try {
438
- const text = nodeToEmbedText(node);
439
- const embedding = await getEmbedding(text, undefined, model);
440
- index[node.slug] = { embedding, model, generated_at: new Date().toISOString() };
466
+ const parentEmbedding = await getEmbedding(text, undefined, model);
467
+
468
+ // Calculate embeddings for children chunks
469
+ const childChunks = chunkNodeBody(node.body);
470
+ const chunks = [];
471
+
472
+ for (const chunkText of childChunks) {
473
+ try {
474
+ const chunkEmb = await getEmbedding(`Parent: ${node.title}\nChunk: ${chunkText}`, undefined, model);
475
+ chunks.push({
476
+ text: chunkText,
477
+ embedding: chunkEmb
478
+ });
479
+ } catch {
480
+ // Ignore failures for individual chunks
481
+ }
482
+ }
483
+
484
+ index[node.slug] = {
485
+ embedding: parentEmbedding,
486
+ chunks,
487
+ model,
488
+ generated_at: new Date().toISOString(),
489
+ content_sha256: contentHash
490
+ };
441
491
  built++;
442
492
  onProgress?.({ slug: node.slug, built, skipped, failed });
443
493
  } catch (err) {