total-recall-brain 3.1.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 (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +173 -0
  3. package/bin/antigravity.mjs +212 -0
  4. package/bin/init.mjs +36 -0
  5. package/bin/oci-sniper.sh +69 -0
  6. package/bin/total-recall.mjs +229 -0
  7. package/docs/ARCHITECTURE.md +251 -0
  8. package/docs/IDE_INTEGRATION.md +112 -0
  9. package/package.json +71 -0
  10. package/scaffold/.agent/skills/total-recall/SKILL.md +195 -0
  11. package/scaffold/.agent/skills/total-recall/evals/evals.json +20 -0
  12. package/scaffold/.agent/skills/total-recall/memory-vault/concepts/cli-help-reference.md +145 -0
  13. package/scaffold/.agent/skills/total-recall/memory-vault/invariants/operating-instructions.md +75 -0
  14. package/scaffold/.agent/skills/total-recall/memory-vault/preferences/always-websearch-gap.md +16 -0
  15. package/scaffold/.agent/skills/total-recall/memory-vault/preferences/topic-research-sop.md +17 -0
  16. package/scaffold/.agent/skills/total-recall/references/api-reference.md +187 -0
  17. package/scaffold/.agent/skills/total-recall/references/architecture-reference.md +81 -0
  18. package/scaffold/.agent/skills/total-recall/references/cli-reference.md +103 -0
  19. package/scaffold/.agent/skills/total-recall/references/ssss-reference.md +88 -0
  20. package/scaffold/.agent/skills/total-recall/references/troubleshooting.md +68 -0
  21. package/scaffold/.agent/skills/total-recall/scripts/sync-repo.mjs +83 -0
  22. package/scaffold/.agent/skills/total-recall/skills/cli-agents/SKILL.md +122 -0
  23. package/scaffold/.agent/skills/total-recall/skills/cli-agents/agents.yml +27 -0
  24. package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/antigravity.txt +20 -0
  25. package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/claude.txt +58 -0
  26. package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/codex.txt +81 -0
  27. package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/gemini.txt +44 -0
  28. package/scaffold/.agent/skills/total-recall/skills/research/SKILL.md +337 -0
  29. package/scaffold/.agent/skills/total-recall/skills/skill/SKILL.md +281 -0
  30. package/scaffold/.agent/skills/total-recall/skills/skill/assets/skill-template/SKILL.md +20 -0
  31. package/scaffold/.agent/skills/total-recall/skills/skill/evals/evals.json +20 -0
  32. package/scaffold/.agent/skills/total-recall/skills/skill/hooks/on-skill-add.sh +14 -0
  33. package/scaffold/.agent/skills/total-recall/skills/skill/hooks/on-skill-delete.sh +14 -0
  34. package/scaffold/.agent/skills/total-recall/skills/skill/learnings/.gitkeep +0 -0
  35. package/scaffold/.agent/skills/total-recall/skills/skill/references/.gitkeep +0 -0
  36. package/scaffold/.agent/skills/total-recall/skills/skill/scripts/.gitkeep +0 -0
  37. package/scaffold/.agent/skills/total-recall/skills/skill/scripts/create-skill.sh +65 -0
  38. package/scaffold/.agent/skills/total-recall/skills/skill/scripts/enforce-skill-optimization.mjs +135 -0
  39. package/scaffold/.agent/skills/total-recall/skills/skill/scripts/find-skills.mjs +124 -0
  40. package/scaffold/.agent/skills/total-recall/skills/skill/scripts/scan-skill.mjs +207 -0
  41. package/scaffold/.agent/skills/total-recall/skills/skill/scripts/skill-watcher.mjs +64 -0
  42. package/scaffold/.agent/skills/total-recall/skills/skill/subagents/audit-all-skills.md +62 -0
  43. package/scaffold/.agent/skills/total-recall/skills/ssss/SKILL.md +27 -0
  44. package/scaffold/.agent/skills/total-recall/skills/ssss/evals/evals.json +17 -0
  45. package/scaffold/.agent/skills/total-recall/skills/ssss/references/ssss-spec.md +117 -0
  46. package/scaffold/.agent/skills/total-recall/skills/ssss/scripts/validate-schema.mjs +54 -0
  47. package/scaffold/.agent/skills/total-recall/skills/ssss/subagents/audit-ssss-node.md +9 -0
  48. package/scaffold/.agent/skills/total-recall/subagents/total-recall-diagnostician.md +13 -0
  49. package/src/cli/agent-dir.mjs +105 -0
  50. package/src/cli/backup.mjs +313 -0
  51. package/src/cli/brain.mjs +224 -0
  52. package/src/cli/chat.mjs +74 -0
  53. package/src/cli/config.mjs +247 -0
  54. package/src/cli/connect.mjs +872 -0
  55. package/src/cli/daemon.mjs +228 -0
  56. package/src/cli/deploy-ui.mjs +1793 -0
  57. package/src/cli/deploy.mjs +1726 -0
  58. package/src/cli/doctor.mjs +254 -0
  59. package/src/cli/dream.mjs +80 -0
  60. package/src/cli/forget.mjs +92 -0
  61. package/src/cli/friction.mjs +21 -0
  62. package/src/cli/generate-pat.mjs +65 -0
  63. package/src/cli/hash-password.mjs +22 -0
  64. package/src/cli/help.mjs +153 -0
  65. package/src/cli/import-rules.mjs +102 -0
  66. package/src/cli/ingest/index.mjs +318 -0
  67. package/src/cli/ingest/parsers/chrome-bookmarks.mjs +238 -0
  68. package/src/cli/ingest/parsers/google-keep.mjs +147 -0
  69. package/src/cli/ingest/parsers/search-history.mjs +142 -0
  70. package/src/cli/ingest/parsers/youtube-history.mjs +181 -0
  71. package/src/cli/ingest/utils/dedup.mjs +69 -0
  72. package/src/cli/ingest/utils/takeout-walker.mjs +101 -0
  73. package/src/cli/ingest.mjs +102 -0
  74. package/src/cli/init.mjs +736 -0
  75. package/src/cli/integration-dispatcher.mjs +128 -0
  76. package/src/cli/lint.mjs +166 -0
  77. package/src/cli/map.mjs +194 -0
  78. package/src/cli/migrate.mjs +87 -0
  79. package/src/cli/rebuild.mjs +88 -0
  80. package/src/cli/recall.mjs +219 -0
  81. package/src/cli/relay.mjs +518 -0
  82. package/src/cli/remember.mjs +306 -0
  83. package/src/cli/research.mjs +381 -0
  84. package/src/cli/reset-password.mjs +75 -0
  85. package/src/cli/restore.mjs +135 -0
  86. package/src/cli/setup.mjs +616 -0
  87. package/src/cli/share.mjs +180 -0
  88. package/src/cli/skill.mjs +313 -0
  89. package/src/cli/snapshot.mjs +108 -0
  90. package/src/cli/start.mjs +63 -0
  91. package/src/cli/status.mjs +296 -0
  92. package/src/cli/sync.mjs +383 -0
  93. package/src/cli/uninstall.mjs +613 -0
  94. package/src/cli/upgrade.mjs +141 -0
  95. package/src/cli/wizard.html +3682 -0
  96. package/src/core/blackboard.mjs +54 -0
  97. package/src/core/clarity-rewriter.mjs +770 -0
  98. package/src/core/conclusion-writer.mjs +259 -0
  99. package/src/core/config.mjs +277 -0
  100. package/src/core/conflict-detector.mjs +536 -0
  101. package/src/core/crypto.mjs +107 -0
  102. package/src/core/daemon-control.mjs +158 -0
  103. package/src/core/daemon-loop.mjs +364 -0
  104. package/src/core/dream.mjs +347 -0
  105. package/src/core/drift-detector.mjs +143 -0
  106. package/src/core/embeddings.mjs +632 -0
  107. package/src/core/emergency-alerts.mjs +137 -0
  108. package/src/core/evolution.mjs +107 -0
  109. package/src/core/fact-seeker.mjs +1602 -0
  110. package/src/core/friction.mjs +95 -0
  111. package/src/core/import-rules.mjs +308 -0
  112. package/src/core/inference-engine.mjs +367 -0
  113. package/src/core/logger.mjs +75 -0
  114. package/src/core/memory-layers.mjs +96 -0
  115. package/src/core/migrate.mjs +116 -0
  116. package/src/core/notifications.mjs +77 -0
  117. package/src/core/operation-validator.mjs +272 -0
  118. package/src/core/optimizer.mjs +330 -0
  119. package/src/core/pattern_detector.mjs +113 -0
  120. package/src/core/post-mortem.mjs +413 -0
  121. package/src/core/protect-instructions.mjs +148 -0
  122. package/src/core/quick-capture.mjs +145 -0
  123. package/src/core/research-queue.mjs +431 -0
  124. package/src/core/research.mjs +461 -0
  125. package/src/core/runtime.mjs +515 -0
  126. package/src/core/sandbox.mjs +163 -0
  127. package/src/core/scheduler.mjs +569 -0
  128. package/src/core/schema.mjs +333 -0
  129. package/src/core/search.mjs +219 -0
  130. package/src/core/semantic-index.mjs +276 -0
  131. package/src/core/session-watcher.mjs +850 -0
  132. package/src/core/snapshot.mjs +133 -0
  133. package/src/core/source-adapters.mjs +1038 -0
  134. package/src/core/steering.mjs +137 -0
  135. package/src/core/surface.mjs +444 -0
  136. package/src/core/task_runner.mjs +125 -0
  137. package/src/core/tts.mjs +142 -0
  138. package/src/core/usage-tracker.mjs +349 -0
  139. package/src/core/vault-cache.mjs +76 -0
  140. package/src/core/vault.mjs +273 -0
  141. package/src/core/watchdog.mjs +236 -0
  142. package/src/server/api.mjs +944 -0
  143. package/src/server/auth.mjs +410 -0
  144. package/src/server/index.mjs +868 -0
  145. package/src/server/keys.mjs +181 -0
  146. package/src/server/rest.mjs +1652 -0
  147. package/src/server/route-manifest.json +77 -0
  148. package/src/server/routes/_shared.mjs +93 -0
  149. package/src/server/routes/keys.mjs +55 -0
  150. package/src/server/routes/memory.mjs +249 -0
  151. package/src/server/routes/sessions.mjs +336 -0
  152. package/src/server/routes/share.mjs +138 -0
  153. package/src/server/tools.mjs +618 -0
  154. package/templates/Caddyfile +37 -0
  155. package/templates/com.totalrecall.backup.plist +42 -0
  156. package/templates/com.totalrecall.daemon.plist +40 -0
  157. package/templates/com.totalrecall.duckdns.plist +23 -0
  158. package/templates/com.totalrecall.server.plist +46 -0
  159. package/templates/default-config/budget.yml +6 -0
  160. package/templates/default-config/runtime.yml +11 -0
  161. package/templates/default-config/security.yml +53 -0
  162. package/templates/default-config/voice.yml +34 -0
  163. package/templates/obsidian-queries/conflicts.md +26 -0
  164. package/templates/obsidian-queries/daily.md +17 -0
  165. package/templates/obsidian-queries/index.md +38 -0
  166. package/templates/obsidian-queries/skills.md +14 -0
  167. package/templates/onboarding-interview.md +72 -0
  168. package/templates/skill_engineering.md +40 -0
  169. package/templates/total-recall-daemon.service +21 -0
  170. package/templates/total-recall-server.service +20 -0
@@ -0,0 +1,632 @@
1
+ /**
2
+ * src/core/embeddings.mjs
3
+ *
4
+ * Google AI embedding generation + cosine similarity for semantic vault search.
5
+ *
6
+ * Index file: .agent/memory-derived/embeddings.json
7
+ * { [slug]: { embedding: number[], model: string, generated_at: string } }
8
+ *
9
+ * Embedding model: gemini-embedding-2 (Google, 768 dims, free tier)
10
+ * Auth: GOOGLE_API_KEY env var
11
+ *
12
+ * Fallback: If GOOGLE_API_KEY is not set, tries OpenAI's text-embedding-3-small
13
+ * via OPENAI_API_KEY env var.
14
+ */
15
+
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import crypto from 'node:crypto';
19
+
20
+ import { googleApiKey, embedModel, brainDir } from './config.mjs';
21
+ import { logger } from './logger.mjs';
22
+
23
+ const DEFAULT_EMBED_MODEL = embedModel;
24
+ const GOOGLE_API_KEY = googleApiKey || process.env.GOOGLE_API_KEY;
25
+ const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
26
+
27
+ const BRAIN_DIR = brainDir;
28
+ const DERIVED_DIR = path.join(BRAIN_DIR, 'memory-derived');
29
+ const CACHE_DIR = path.join(DERIVED_DIR, 'embeddings-cache');
30
+
31
+ // ─── Hot process-level in-memory cache Map (max size 500) ────────────────────
32
+ const IN_MEMORY_CACHE = new Map();
33
+ const MAX_IN_MEMORY_SIZE = 500;
34
+
35
+ function getInMemory(key) {
36
+ if (!IN_MEMORY_CACHE.has(key)) return null;
37
+ const value = IN_MEMORY_CACHE.get(key);
38
+ // Refresh position for LRU
39
+ IN_MEMORY_CACHE.delete(key);
40
+ IN_MEMORY_CACHE.set(key, value);
41
+ return value;
42
+ }
43
+
44
+ function setInMemory(key, embedding) {
45
+ if (IN_MEMORY_CACHE.has(key)) {
46
+ IN_MEMORY_CACHE.delete(key);
47
+ } else if (IN_MEMORY_CACHE.size >= MAX_IN_MEMORY_SIZE) {
48
+ const oldestKey = IN_MEMORY_CACHE.keys().next().value;
49
+ IN_MEMORY_CACHE.delete(oldestKey);
50
+ }
51
+ IN_MEMORY_CACHE.set(key, embedding);
52
+ }
53
+
54
+ // ─── Query Embedding Cache Helpers (Partitioned 256-Dir Layout) ──────────────
55
+
56
+ function sha256(text) {
57
+ return crypto.createHash('sha256').update(text).digest('hex');
58
+ }
59
+
60
+ function getCachePartitionPath(key) {
61
+ const hash = sha256(key);
62
+ const prefix = hash.slice(0, 2);
63
+ const suffix = hash.slice(2, 4);
64
+ return {
65
+ dir: path.join(CACHE_DIR, prefix),
66
+ file: path.join(CACHE_DIR, prefix, `${suffix}.json`),
67
+ hash
68
+ };
69
+ }
70
+
71
+ export function getCachedEmbedding(key) {
72
+ if (process.env.NODE_ENV === 'test' && !global.__TEST_CACHE_OVERRIDE__) return null;
73
+ // Tier 1: Hot In-Memory LRU Cache
74
+ const hit = getInMemory(key);
75
+ if (hit) return hit;
76
+
77
+ // Tier 2: Partitioned Disk Cache
78
+ try {
79
+ const { file, hash } = getCachePartitionPath(key);
80
+ if (fs.existsSync(file)) {
81
+ const partition = JSON.parse(fs.readFileSync(file, 'utf8') || '{}');
82
+ const entry = partition[hash];
83
+ if (!entry) return null;
84
+
85
+ let embedding;
86
+ if (Array.isArray(entry)) {
87
+ embedding = entry;
88
+ } else if (entry && typeof entry === 'object' && Array.isArray(entry.embedding)) {
89
+ embedding = entry.embedding;
90
+ // Non-destructively update lastUsed time
91
+ entry.lastUsed = Date.now();
92
+ fs.writeFileSync(file, JSON.stringify(partition), 'utf8');
93
+ }
94
+
95
+ if (embedding) {
96
+ setInMemory(key, embedding);
97
+ return embedding;
98
+ }
99
+ }
100
+ } catch {}
101
+ return null;
102
+ }
103
+
104
+ export function saveCachedEmbedding(key, embedding) {
105
+ if (process.env.NODE_ENV === 'test' && !global.__TEST_CACHE_OVERRIDE__) return;
106
+ // Tier 1 sync
107
+ setInMemory(key, embedding);
108
+
109
+ // Tier 2 sync
110
+ try {
111
+ const { dir, file, hash } = getCachePartitionPath(key);
112
+ fs.mkdirSync(dir, { recursive: true });
113
+ const partition = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8') || '{}') : {};
114
+
115
+ // Save in upgraded object format non-destructively
116
+ partition[hash] = {
117
+ embedding,
118
+ lastUsed: Date.now()
119
+ };
120
+
121
+ // Partition Cache Auto-Pruning
122
+ const keys = Object.keys(partition);
123
+ if (keys.length > 500) {
124
+ const entries = keys.map(k => {
125
+ const entry = partition[k];
126
+ let lastUsed = 0;
127
+ if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
128
+ lastUsed = entry.lastUsed || 0;
129
+ }
130
+ return { k, lastUsed };
131
+ });
132
+ // Sort oldest first
133
+ entries.sort((a, b) => a.lastUsed - b.lastUsed);
134
+ // Evict oldest, keeping 400 keys
135
+ const toPrune = entries.slice(0, keys.length - 400);
136
+ for (const p of toPrune) {
137
+ delete partition[p.k];
138
+ }
139
+ }
140
+
141
+ fs.writeFileSync(file, JSON.stringify(partition), 'utf8');
142
+ } catch {}
143
+ }
144
+
145
+ // ─── Embedding generation ────────────────────────────────────────────────────
146
+
147
+ /**
148
+ * Resolve the embedding model dynamically from the active API registry list.
149
+ */
150
+ async function resolveEmbeddingModel(selectedModel = embedModel) {
151
+ if (selectedModel !== 'gemini-embedding-2' && selectedModel !== 'default') {
152
+ return selectedModel;
153
+ }
154
+
155
+ if (!GOOGLE_API_KEY) {
156
+ return 'gemini-embedding-2';
157
+ }
158
+
159
+ try {
160
+ const res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models?key=${GOOGLE_API_KEY}`, {
161
+ signal: AbortSignal.timeout(5000)
162
+ });
163
+ if (res.ok) {
164
+ const data = await res.json();
165
+ const models = data.models || [];
166
+ const preferences = [
167
+ 'gemini-embedding-2',
168
+ 'gemini-embedding-2-preview',
169
+ 'gemini-embedding-001',
170
+ 'text-embedding-004'
171
+ ];
172
+ for (const p of preferences) {
173
+ if (models.some(m => m.name === `models/${p}`)) {
174
+ return p;
175
+ }
176
+ }
177
+ }
178
+ } catch {
179
+ // Silent failover
180
+ }
181
+ return 'gemini-embedding-2';
182
+ }
183
+
184
+ /**
185
+ * Get an embedding from Google's embedding API.
186
+ */
187
+ async function getGoogleEmbedding(text, model) {
188
+ if (!GOOGLE_API_KEY) {
189
+ throw new Error('GOOGLE_API_KEY not set. Cannot generate embeddings.');
190
+ }
191
+
192
+ const resolved = await resolveEmbeddingModel(model);
193
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/${resolved}:embedContent?key=${GOOGLE_API_KEY}`;
194
+
195
+ const res = await fetch(url, {
196
+ method: 'POST',
197
+ headers: { 'Content-Type': 'application/json' },
198
+ body: JSON.stringify({
199
+ model: `models/${resolved}`,
200
+ content: { parts: [{ text }] },
201
+ }),
202
+ signal: AbortSignal.timeout(30000),
203
+ });
204
+
205
+ if (!res.ok) {
206
+ const body = await res.text().catch(() => '');
207
+ throw new Error(`Google embedding API error ${res.status}: ${body || 'no response body'}`);
208
+ }
209
+
210
+ const data = await res.json();
211
+ if (!data.embedding?.values || !Array.isArray(data.embedding.values)) {
212
+ throw new Error('Google returned no embedding vector');
213
+ }
214
+
215
+ return data.embedding.values;
216
+ }
217
+
218
+ /**
219
+ * Fallback: Get an embedding from OpenAI's text-embedding-3-small API.
220
+ */
221
+ async function getOpenAIEmbedding(text, model = 'text-embedding-3-small') {
222
+ if (!OPENAI_API_KEY) {
223
+ throw new Error('Neither GOOGLE_API_KEY nor OPENAI_API_KEY is set. Cannot generate embeddings.');
224
+ }
225
+
226
+ const res = await fetch('https://api.openai.com/v1/embeddings', {
227
+ method: 'POST',
228
+ headers: {
229
+ 'Content-Type': 'application/json',
230
+ 'Authorization': `Bearer ${OPENAI_API_KEY}`,
231
+ },
232
+ body: JSON.stringify({ model, input: text }),
233
+ signal: AbortSignal.timeout(30000),
234
+ });
235
+
236
+ if (!res.ok) {
237
+ const body = await res.text().catch(() => '');
238
+ throw new Error(`OpenAI embedding API error ${res.status}: ${body || 'no response body'}`);
239
+ }
240
+
241
+ const data = await res.json();
242
+ if (!data.data?.[0]?.embedding) {
243
+ throw new Error('OpenAI returned no embedding vector');
244
+ }
245
+
246
+ return data.data[0].embedding;
247
+ }
248
+
249
+ /**
250
+ * Get an embedding vector for the given text.
251
+ * Tries Google first, falls back to OpenAI.
252
+ *
253
+ * @param {string} text
254
+ * @param {string} [_unused] — kept for backward compatibility (was ollamaUrl)
255
+ * @param {string} [model]
256
+ * @returns {Promise<number[]>}
257
+ */
258
+ export async function getEmbedding(text, _unused, model = DEFAULT_EMBED_MODEL) {
259
+ const input = String(text).slice(0, 8000); // cap at 8k chars
260
+
261
+ const cacheKey = `${model}:${sha256(input)}`;
262
+ const cached = getCachedEmbedding(cacheKey);
263
+ if (cached) {
264
+ return cached;
265
+ }
266
+
267
+ let embedding;
268
+
269
+ // Try Google first
270
+ if (GOOGLE_API_KEY) {
271
+ try {
272
+ embedding = await getGoogleEmbedding(input, model);
273
+ } catch (err) {
274
+ logger.debug('embeddings: Google API failed, trying OpenAI fallback', { err: err.message });
275
+ }
276
+ }
277
+
278
+ // Fallback to OpenAI
279
+ if (!embedding && OPENAI_API_KEY) {
280
+ try {
281
+ embedding = await getOpenAIEmbedding(input);
282
+ } catch (err) {
283
+ logger.debug('embeddings: OpenAI fallback also failed', { err: err.message });
284
+ throw err;
285
+ }
286
+ }
287
+
288
+ if (!embedding) {
289
+ throw new Error('No embedding provider available. Set GOOGLE_API_KEY or OPENAI_API_KEY.');
290
+ }
291
+
292
+ // Save to cache
293
+ saveCachedEmbedding(cacheKey, embedding);
294
+
295
+ return embedding;
296
+ }
297
+
298
+ // ─── Cosine similarity ───────────────────────────────────────────────────────
299
+
300
+ /**
301
+ * Compute cosine similarity between two equal-length vectors.
302
+ * Returns a value in [-1, 1] where 1 = identical direction.
303
+ *
304
+ * @param {number[]} a
305
+ * @param {number[]} b
306
+ * @returns {number}
307
+ */
308
+ export function cosineSimilarity(a, b) {
309
+ if (!a || !b || a.length !== b.length) return 0;
310
+ let dot = 0, magA = 0, magB = 0;
311
+ for (let i = 0; i < a.length; i++) {
312
+ dot += a[i] * b[i];
313
+ magA += a[i] * a[i];
314
+ magB += b[i] * b[i];
315
+ }
316
+ const denom = Math.sqrt(magA) * Math.sqrt(magB);
317
+ return denom === 0 ? 0 : dot / denom;
318
+ }
319
+
320
+ // ─── Index file helpers ──────────────────────────────────────────────────────
321
+
322
+ function indexPath(derivedDir) {
323
+ return path.join(derivedDir, 'embeddings.json');
324
+ }
325
+
326
+ let cachedIndex = null;
327
+ let cachedIndexPath = null;
328
+ let cachedIndexMtime = 0;
329
+
330
+ /**
331
+ * Load the full embeddings index from disk.
332
+ * Returns {} if the file doesn't exist or is corrupt.
333
+ *
334
+ * @param {string} derivedDir
335
+ * @returns {Record<string, { embedding: number[], model: string, generated_at: string }>}
336
+ */
337
+ export function loadEmbeddingsIndex(derivedDir) {
338
+ const p = indexPath(derivedDir);
339
+ if (!fs.existsSync(p)) return {};
340
+ try {
341
+ const stat = fs.statSync(p);
342
+ if (cachedIndex && cachedIndexPath === p && cachedIndexMtime === stat.mtimeMs) {
343
+ return cachedIndex;
344
+ }
345
+ const data = JSON.parse(fs.readFileSync(p, 'utf8'));
346
+ cachedIndex = data;
347
+ cachedIndexPath = p;
348
+ cachedIndexMtime = stat.mtimeMs;
349
+ return data;
350
+ }
351
+ catch { return {}; }
352
+ }
353
+
354
+ /**
355
+ * Write one slug's embedding into the index (merges, does not replace whole file).
356
+ *
357
+ * @param {string} derivedDir
358
+ * @param {string} slug
359
+ * @param {number[]} embedding
360
+ * @param {string} [model]
361
+ */
362
+ export function saveEmbeddingToIndex(derivedDir, slug, embedding, model = DEFAULT_EMBED_MODEL) {
363
+ fs.mkdirSync(derivedDir, { recursive: true });
364
+ const index = loadEmbeddingsIndex(derivedDir);
365
+ index[slug] = { embedding, model, generated_at: new Date().toISOString() };
366
+ fs.writeFileSync(indexPath(derivedDir), JSON.stringify(index), 'utf8');
367
+ }
368
+
369
+ /**
370
+ * Remove a slug from the embeddings index (called when a node is deleted).
371
+ *
372
+ * @param {string} derivedDir
373
+ * @param {string} slug
374
+ */
375
+ export function removeEmbeddingFromIndex(derivedDir, slug) {
376
+ const p = indexPath(derivedDir);
377
+ if (!fs.existsSync(p)) return;
378
+ const index = loadEmbeddingsIndex(derivedDir);
379
+ delete index[slug];
380
+ fs.writeFileSync(p, JSON.stringify(index), 'utf8');
381
+ }
382
+
383
+ // ─── Text representation ─────────────────────────────────────────────────────
384
+
385
+ /**
386
+ * Build the rich text string used for embedding a vault node.
387
+ * Combines title, category, tags, and body for best semantic representation.
388
+ *
389
+ * @param {{ slug: string, title?: string, category?: string, tags?: string[], body?: string }} node
390
+ * @returns {string}
391
+ */
392
+ export function nodeToEmbedText(node) {
393
+ const parts = [node.title || node.slug];
394
+ if (node.category) parts.push(`Category: ${node.category}`);
395
+ 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
397
+ return parts.join('\n\n');
398
+ }
399
+
400
+ // ─── Bulk index builder ──────────────────────────────────────────────────────
401
+
402
+ /**
403
+ * Build or refresh the embeddings index for a set of vault nodes.
404
+ * Skips nodes that already have an entry in the index (incremental by default).
405
+ * Pass force=true to re-embed all nodes.
406
+ *
407
+ * @param {object[]} nodes
408
+ * @param {string} derivedDir
409
+ * @param {{ model?: string, force?: boolean, onProgress?: Function }} [opts]
410
+ * @returns {Promise<{ built: number, skipped: number, failed: number }>}
411
+ */
412
+ export async function buildEmbeddingsIndex(nodes, derivedDir, opts = {}) {
413
+ const {
414
+ model = DEFAULT_EMBED_MODEL,
415
+ force = false,
416
+ onProgress,
417
+ } = opts;
418
+
419
+ const existing = force ? {} : loadEmbeddingsIndex(derivedDir);
420
+ const index = { ...existing };
421
+
422
+ // Remove any stale keys in the index that are not present in nodes (stale clean-up)
423
+ const activeSlugs = new Set(nodes.map(n => n.slug));
424
+ for (const slug of Object.keys(index)) {
425
+ if (!activeSlugs.has(slug)) {
426
+ delete index[slug];
427
+ }
428
+ }
429
+
430
+ let built = 0, skipped = 0, failed = 0;
431
+
432
+ for (const node of nodes) {
433
+ if (!force && existing[node.slug]) {
434
+ skipped++;
435
+ continue;
436
+ }
437
+ 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() };
441
+ built++;
442
+ onProgress?.({ slug: node.slug, built, skipped, failed });
443
+ } catch (err) {
444
+ failed++;
445
+ onProgress?.({ slug: node.slug, error: err.message, built, skipped, failed });
446
+ }
447
+ }
448
+
449
+ fs.mkdirSync(derivedDir, { recursive: true });
450
+ fs.writeFileSync(indexPath(derivedDir), JSON.stringify(index), 'utf8');
451
+ return { built, skipped, failed };
452
+ }
453
+
454
+ // ─── Session index ───────────────────────────────────────────────────────────
455
+
456
+ const SESSION_INDEX_FILE = 'session-embeddings.json';
457
+
458
+ function sessionIndexPath(derivedDir) {
459
+ return path.join(derivedDir, SESSION_INDEX_FILE);
460
+ }
461
+
462
+ let cachedSessionIndex = null;
463
+ let cachedSessionIndexPath = null;
464
+ let cachedSessionIndexMtime = 0;
465
+
466
+ export function loadSessionEmbeddingsIndex(derivedDir) {
467
+ const p = sessionIndexPath(derivedDir);
468
+ if (!fs.existsSync(p)) return {};
469
+ try {
470
+ const stat = fs.statSync(p);
471
+ if (cachedSessionIndex && cachedSessionIndexPath === p && cachedSessionIndexMtime === stat.mtimeMs) {
472
+ return cachedSessionIndex;
473
+ }
474
+ const data = JSON.parse(fs.readFileSync(p, 'utf8'));
475
+ cachedSessionIndex = data;
476
+ cachedSessionIndexPath = p;
477
+ cachedSessionIndexMtime = stat.mtimeMs;
478
+ return data;
479
+ }
480
+ catch { return {}; }
481
+ }
482
+
483
+ function saveSessionIndex(derivedDir, index) {
484
+ fs.mkdirSync(derivedDir, { recursive: true });
485
+ fs.writeFileSync(sessionIndexPath(derivedDir), JSON.stringify(index), 'utf8');
486
+ }
487
+
488
+ export function saveSessionEmbeddingToIndex(derivedDir, key, snippet, embedding, model = DEFAULT_EMBED_MODEL) {
489
+ const index = loadSessionEmbeddingsIndex(derivedDir);
490
+ index[key] = { embedding, snippet: snippet.slice(0, 300), model, generated_at: new Date().toISOString() };
491
+ saveSessionIndex(derivedDir, index);
492
+ }
493
+
494
+ export function removeSessionEmbeddingFromIndex(derivedDir, sessionId) {
495
+ const index = loadSessionEmbeddingsIndex(derivedDir);
496
+ for (const key of Object.keys(index)) {
497
+ if (key === sessionId || key.startsWith(`${sessionId}:chunk-`)) {
498
+ delete index[key];
499
+ }
500
+ }
501
+ saveSessionIndex(derivedDir, index);
502
+ }
503
+
504
+ /**
505
+ * Build a readable text representation of a session for embedding.
506
+ * Concatenates role:content pairs. Returns array of chunks (each ≤ 6000 chars).
507
+ */
508
+ export function sessionToEmbedChunks(messages) {
509
+ const CHUNK_SIZE = 6000;
510
+ const full = messages
511
+ .filter(m => m && m.content)
512
+ .map(m => `${(m.role || 'unknown').toUpperCase()}: ${String(m.content).slice(0, 2000)}`)
513
+ .join('\n');
514
+
515
+ if (full.length === 0) return [];
516
+
517
+ const chunks = [];
518
+ for (let i = 0; i < full.length; i += CHUNK_SIZE) {
519
+ chunks.push(full.slice(i, i + CHUNK_SIZE));
520
+ }
521
+ return chunks;
522
+ }
523
+
524
+ /**
525
+ * Parse a session JSONL file into an array of message objects.
526
+ */
527
+ export function parseSessionFile(filePath) {
528
+ try {
529
+ const raw = fs.readFileSync(filePath, 'utf8').trim();
530
+ const lines = raw.split('\n').filter(Boolean)
531
+ .map(l => { try { return JSON.parse(l); } catch { return null; } })
532
+ .filter(Boolean);
533
+ if (lines.length === 1 && lines[0] && Array.isArray(lines[0].messages)) {
534
+ return lines[0].messages;
535
+ }
536
+ return lines;
537
+ } catch { return []; }
538
+ }
539
+
540
+ /**
541
+ * Embed all sessions in sessionsDir that are not yet in the index.
542
+ *
543
+ * @param {string} sessionsDir
544
+ * @param {string} derivedDir
545
+ * @param {{ force?: boolean, model?: string, onProgress?: Function }} [opts]
546
+ * @returns {Promise<{ indexed: number, skipped: number, failed: number, chunks: number }>}
547
+ */
548
+ export async function buildSessionEmbeddingsIndex(sessionsDir, derivedDir, opts = {}) {
549
+ const {
550
+ force = false,
551
+ model = DEFAULT_EMBED_MODEL,
552
+ onProgress,
553
+ } = opts;
554
+
555
+ if (!fs.existsSync(sessionsDir)) return { indexed: 0, skipped: 0, failed: 0, chunks: 0 };
556
+
557
+ const files = fs.readdirSync(sessionsDir)
558
+ .filter(f => f.endsWith('.jsonl') || f.endsWith('.json'));
559
+
560
+ if (files.length === 0) return { indexed: 0, skipped: 0, failed: 0, chunks: 0 };
561
+
562
+ let existing = force ? {} : loadSessionEmbeddingsIndex(derivedDir);
563
+
564
+ // Probe resolved model embedding dimension
565
+ let newDim = 0;
566
+ try {
567
+ const probe = await getEmbedding("test probe", undefined, model);
568
+ newDim = probe.length;
569
+ } catch (err) {
570
+ // If the probe fails, we cannot resolve/access the API, so skip gracefully
571
+ return { indexed: 0, skipped: files.length, failed: files.length, chunks: 0 };
572
+ }
573
+
574
+ // Check for dimension mismatch against existing cached session embeddings
575
+ if (Object.keys(existing).length > 0) {
576
+ const firstKey = Object.keys(existing)[0];
577
+ const firstVal = existing[firstKey];
578
+ const existingDim = firstVal && Array.isArray(firstVal.embedding) ? firstVal.embedding.length : 0;
579
+
580
+ if (existingDim > 0 && existingDim !== newDim) {
581
+ // Dimension mismatch detected! Wipe the old index to re-embed everything
582
+ existing = {};
583
+ try {
584
+ const file = sessionIndexPath(derivedDir);
585
+ if (fs.existsSync(file)) {
586
+ fs.unlinkSync(file);
587
+ }
588
+ } catch {}
589
+ }
590
+ }
591
+
592
+ const index = { ...existing };
593
+ let indexed = 0, skipped = 0, failed = 0, totalChunks = 0;
594
+
595
+ for (const file of files) {
596
+ const sessionId = file.replace(/\.(jsonl|json)$/, '');
597
+ if (!force && (existing[sessionId] || existing[`${sessionId}:chunk-0`])) {
598
+ skipped++;
599
+ continue;
600
+ }
601
+
602
+ try {
603
+ const messages = parseSessionFile(path.join(sessionsDir, file));
604
+ const chunks = sessionToEmbedChunks(messages);
605
+ if (chunks.length === 0) { skipped++; continue; }
606
+
607
+ for (let i = 0; i < chunks.length; i++) {
608
+ const key = chunks.length === 1 ? sessionId : `${sessionId}:chunk-${i}`;
609
+ const embedding = await getEmbedding(chunks[i], undefined, model);
610
+ index[key] = {
611
+ embedding,
612
+ snippet: chunks[i].slice(0, 300),
613
+ model,
614
+ session_id: sessionId,
615
+ chunk: i,
616
+ total_chunks: chunks.length,
617
+ generated_at: new Date().toISOString(),
618
+ };
619
+ totalChunks++;
620
+ }
621
+
622
+ indexed++;
623
+ onProgress?.({ sessionId, indexed, skipped, failed, chunks: totalChunks });
624
+ } catch (err) {
625
+ failed++;
626
+ onProgress?.({ sessionId, error: err.message, indexed, skipped, failed });
627
+ }
628
+ }
629
+
630
+ saveSessionIndex(derivedDir, index);
631
+ return { indexed, skipped, failed, chunks: totalChunks };
632
+ }