total-recall-brain 3.18.1 → 3.18.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "total-recall-brain",
3
- "version": "3.18.1",
3
+ "version": "3.18.2",
4
4
  "description": "Personal AI system — database-free, SSSS-powered intelligence engine deployable to any POSIX host.",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -15,20 +15,56 @@ import path from 'node:path';
15
15
  import crypto from 'node:crypto';
16
16
  import { throttledFetch } from './throttled-fetch.mjs';
17
17
  import Database from 'better-sqlite3';
18
- import * as sqliteVss from 'sqlite-vss';
18
+ import {
19
+ getVectorLoadablePath,
20
+ getVssLoadablePath,
21
+ } from 'sqlite-vss';
19
22
 
20
23
  import { googleApiKey, embedModel, brainDir as defaultBrainDir } from './config.mjs';
21
24
  import { logger } from './logger.mjs';
22
25
 
23
26
  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;
27
+
28
+ /** Resolve API keys at call time so tests can set env after import. */
29
+ function resolveGoogleApiKey() {
30
+ return googleApiKey || process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY || null;
31
+ }
32
+
33
+ function resolveOpenAiApiKey() {
34
+ return process.env.OPENAI_API_KEY || null;
35
+ }
26
36
 
27
37
  let dbInstance = null;
28
38
  let currentDbPath = null;
39
+ /** @type {WeakMap<object, boolean>} */
40
+ const dbVssEnabled = new WeakMap();
41
+
42
+ /**
43
+ * better-sqlite3 on Linux appends ".so" when the path already ends in ".so",
44
+ * producing "vss0.so.so". Strip known extensions before loadExtension.
45
+ */
46
+ function loadExtensionBare(db, absolutePath) {
47
+ const bare = String(absolutePath).replace(/\.(so|dylib|dll)$/i, '');
48
+ try {
49
+ db.loadExtension(bare);
50
+ } catch {
51
+ db.loadExtension(absolutePath);
52
+ }
53
+ }
54
+
55
+ function tryLoadSqliteVss(db) {
56
+ try {
57
+ loadExtensionBare(db, getVectorLoadablePath());
58
+ loadExtensionBare(db, getVssLoadablePath());
59
+ return true;
60
+ } catch (err) {
61
+ logger.warn('embeddings', `sqlite-vss unavailable; using JSON-only store: ${err.message}`);
62
+ return false;
63
+ }
64
+ }
29
65
 
30
66
  /**
31
- * Get or initialize the SQLite database connection with sqlite-vss loaded.
67
+ * Get or initialize the SQLite database connection with sqlite-vss loaded when available.
32
68
  */
33
69
  function getDb(derivedDir) {
34
70
  const dbPath = path.join(derivedDir, 'embeddings.db');
@@ -43,9 +79,8 @@ function getDb(derivedDir) {
43
79
  fs.mkdirSync(derivedDir, { recursive: true });
44
80
 
45
81
  const db = new Database(dbPath);
46
-
47
- // Load sqlite-vss extension
48
- sqliteVss.load(db);
82
+ const vssOk = tryLoadSqliteVss(db);
83
+ dbVssEnabled.set(db, vssOk);
49
84
 
50
85
  // Initialize schema
51
86
  db.exec(`
@@ -67,22 +102,58 @@ function getDb(derivedDir) {
67
102
  generated_at TEXT,
68
103
  embedding_json TEXT
69
104
  );
70
-
71
- -- Virtual tables for VSS
72
- CREATE VIRTUAL TABLE IF NOT EXISTS vss_vault_embeddings USING vss0(
73
- embedding(768)
74
- );
75
-
76
- CREATE VIRTUAL TABLE IF NOT EXISTS vss_session_embeddings USING vss0(
77
- embedding(768)
78
- );
79
105
  `);
80
106
 
107
+ if (vssOk) {
108
+ db.exec(`
109
+ CREATE VIRTUAL TABLE IF NOT EXISTS vss_vault_embeddings USING vss0(
110
+ embedding(768)
111
+ );
112
+ CREATE VIRTUAL TABLE IF NOT EXISTS vss_session_embeddings USING vss0(
113
+ embedding(768)
114
+ );
115
+ `);
116
+ }
117
+
81
118
  dbInstance = db;
82
119
  currentDbPath = dbPath;
83
120
  return db;
84
121
  }
85
122
 
123
+ function hasVss(db) {
124
+ return dbVssEnabled.get(db) === true;
125
+ }
126
+
127
+ function vssReplaceVault(db, rowId, embeddingJson) {
128
+ if (!hasVss(db)) return;
129
+ db.prepare('DELETE FROM vss_vault_embeddings WHERE rowid = ?').run(rowId);
130
+ db.prepare('INSERT INTO vss_vault_embeddings(rowid, embedding) VALUES (?, ?)').run(rowId, embeddingJson);
131
+ }
132
+
133
+ function vssDeleteVault(db, rowId) {
134
+ if (!hasVss(db)) return;
135
+ db.prepare('DELETE FROM vss_vault_embeddings WHERE rowid = ?').run(rowId);
136
+ }
137
+
138
+ function vssReplaceSession(db, rowId, embeddingJson) {
139
+ if (!hasVss(db)) return;
140
+ db.prepare('DELETE FROM vss_session_embeddings WHERE rowid = ?').run(rowId);
141
+ db.prepare('INSERT INTO vss_session_embeddings(rowid, embedding) VALUES (?, ?)').run(rowId, embeddingJson);
142
+ }
143
+
144
+ function vssDeleteSession(db, rowId) {
145
+ if (!hasVss(db)) return;
146
+ db.prepare('DELETE FROM vss_session_embeddings WHERE rowid = ?').run(rowId);
147
+ }
148
+
149
+ function vssClearAll(db) {
150
+ if (!hasVss(db)) {
151
+ db.exec('DELETE FROM vault_embeddings; DELETE FROM session_embeddings;');
152
+ return;
153
+ }
154
+ db.exec('DELETE FROM vss_vault_embeddings; DELETE FROM vault_embeddings; DELETE FROM vss_session_embeddings; DELETE FROM session_embeddings;');
155
+ }
156
+
86
157
  function sha256(text) {
87
158
  return crypto.createHash('sha256').update(text).digest('hex');
88
159
  }
@@ -94,12 +165,13 @@ async function resolveEmbeddingModel(selectedModel = embedModel) {
94
165
  return selectedModel;
95
166
  }
96
167
 
97
- if (!GOOGLE_API_KEY) {
168
+ const googleKey = resolveGoogleApiKey();
169
+ if (!googleKey) {
98
170
  return 'gemini-embedding-2';
99
171
  }
100
172
 
101
173
  try {
102
- const res = await throttledFetch(`https://generativelanguage.googleapis.com/v1beta/models?key=${GOOGLE_API_KEY}`, {
174
+ const res = await throttledFetch(`https://generativelanguage.googleapis.com/v1beta/models?key=${googleKey}`, {
103
175
  signal: AbortSignal.timeout(5000)
104
176
  });
105
177
  if (res.ok) {
@@ -124,12 +196,13 @@ async function resolveEmbeddingModel(selectedModel = embedModel) {
124
196
  }
125
197
 
126
198
  async function getGoogleEmbedding(text, model) {
127
- if (!GOOGLE_API_KEY) {
199
+ const googleKey = resolveGoogleApiKey();
200
+ if (!googleKey) {
128
201
  throw new Error('GOOGLE_API_KEY not set. Cannot generate embeddings.');
129
202
  }
130
203
 
131
204
  const resolved = await resolveEmbeddingModel(model);
132
- const url = `https://generativelanguage.googleapis.com/v1beta/models/${resolved}:embedContent?key=${GOOGLE_API_KEY}`;
205
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/${resolved}:embedContent?key=${googleKey}`;
133
206
 
134
207
  const res = await throttledFetch(url, {
135
208
  method: 'POST',
@@ -155,7 +228,8 @@ async function getGoogleEmbedding(text, model) {
155
228
  }
156
229
 
157
230
  async function getOpenAIEmbedding(text, model = 'text-embedding-3-small') {
158
- if (!OPENAI_API_KEY) {
231
+ const openaiKey = resolveOpenAiApiKey();
232
+ if (!openaiKey) {
159
233
  throw new Error('Neither GOOGLE_API_KEY nor OPENAI_API_KEY is set. Cannot generate embeddings.');
160
234
  }
161
235
 
@@ -163,7 +237,7 @@ async function getOpenAIEmbedding(text, model = 'text-embedding-3-small') {
163
237
  method: 'POST',
164
238
  headers: {
165
239
  'Content-Type': 'application/json',
166
- 'Authorization': `Bearer ${OPENAI_API_KEY}`,
240
+ 'Authorization': `Bearer ${openaiKey}`,
167
241
  },
168
242
  body: JSON.stringify({ model, input: text }),
169
243
  signal: AbortSignal.timeout(30000),
@@ -184,9 +258,11 @@ async function getOpenAIEmbedding(text, model = 'text-embedding-3-small') {
184
258
 
185
259
  export async function getEmbedding(text, _unused, model = DEFAULT_EMBED_MODEL) {
186
260
  const input = String(text).slice(0, 8000);
261
+ const googleKey = resolveGoogleApiKey();
262
+ const openaiKey = resolveOpenAiApiKey();
187
263
 
188
264
  let embedding;
189
- if (GOOGLE_API_KEY) {
265
+ if (googleKey) {
190
266
  try {
191
267
  embedding = await getGoogleEmbedding(input, model);
192
268
  } catch (err) {
@@ -194,7 +270,7 @@ export async function getEmbedding(text, _unused, model = DEFAULT_EMBED_MODEL) {
194
270
  }
195
271
  }
196
272
 
197
- if (!embedding && OPENAI_API_KEY) {
273
+ if (!embedding && openaiKey) {
198
274
  try {
199
275
  embedding = await getOpenAIEmbedding(input);
200
276
  } catch (err) {
@@ -254,10 +330,8 @@ export function saveEmbeddingToIndex(derivedDir, slug, embedding, model = DEFAUL
254
330
  embedding_json=excluded.embedding_json
255
331
  `).run(slug, model, now, embeddingJson);
256
332
 
257
- // Add to vss index
258
333
  const rowId = db.prepare('SELECT rowid FROM vault_embeddings WHERE slug = ?').get(slug).rowid;
259
- db.prepare('DELETE FROM vss_vault_embeddings WHERE rowid = ?').run(rowId);
260
- db.prepare('INSERT INTO vss_vault_embeddings(rowid, embedding) VALUES (?, ?)').run(rowId, embeddingJson);
334
+ vssReplaceVault(db, rowId, embeddingJson);
261
335
  })();
262
336
  }
263
337
 
@@ -266,7 +340,7 @@ export function removeEmbeddingFromIndex(derivedDir, slug) {
266
340
  db.transaction(() => {
267
341
  const row = db.prepare('SELECT rowid FROM vault_embeddings WHERE slug = ?').get(slug);
268
342
  if (row) {
269
- db.prepare('DELETE FROM vss_vault_embeddings WHERE rowid = ?').run(row.rowid);
343
+ vssDeleteVault(db, row.rowid);
270
344
  db.prepare('DELETE FROM vault_embeddings WHERE slug = ?').run(slug);
271
345
  }
272
346
  })();
@@ -295,7 +369,8 @@ export async function buildEmbeddingsIndex(nodes, derivedDir, opts = {}) {
295
369
  const db = getDb(derivedDir);
296
370
 
297
371
  if (force) {
298
- db.exec('DELETE FROM vss_vault_embeddings; DELETE FROM vault_embeddings;');
372
+ if (hasVss(db)) db.exec('DELETE FROM vss_vault_embeddings;');
373
+ db.exec('DELETE FROM vault_embeddings;');
299
374
  }
300
375
 
301
376
  const existing = force ? {} : loadEmbeddingsIndex(derivedDir);
@@ -312,7 +387,7 @@ export async function buildEmbeddingsIndex(nodes, derivedDir, opts = {}) {
312
387
  for (const slug of toDelete) {
313
388
  const row = db.prepare('SELECT rowid FROM vault_embeddings WHERE slug = ?').get(slug);
314
389
  if (row) {
315
- db.prepare('DELETE FROM vss_vault_embeddings WHERE rowid = ?').run(row.rowid);
390
+ vssDeleteVault(db, row.rowid);
316
391
  db.prepare('DELETE FROM vault_embeddings WHERE slug = ?').run(slug);
317
392
  }
318
393
  }
@@ -350,8 +425,7 @@ export async function buildEmbeddingsIndex(nodes, derivedDir, opts = {}) {
350
425
  `).run(node.slug, model, now, contentHash, embeddingJson);
351
426
 
352
427
  const rowId = db.prepare('SELECT rowid FROM vault_embeddings WHERE slug = ?').get(node.slug).rowid;
353
- db.prepare('DELETE FROM vss_vault_embeddings WHERE rowid = ?').run(rowId);
354
- db.prepare('INSERT INTO vss_vault_embeddings(rowid, embedding) VALUES (?, ?)').run(rowId, embeddingJson);
428
+ vssReplaceVault(db, rowId, embeddingJson);
355
429
  })();
356
430
 
357
431
  built++;
@@ -405,8 +479,7 @@ export function saveSessionEmbeddingToIndex(derivedDir, key, snippet, embedding,
405
479
  `).run(key, session_id, chunk, 1, snippet.slice(0, 300), model, now, embeddingJson);
406
480
 
407
481
  const rowId = db.prepare('SELECT rowid FROM session_embeddings WHERE key = ?').get(key).rowid;
408
- db.prepare('DELETE FROM vss_session_embeddings WHERE rowid = ?').run(rowId);
409
- db.prepare('INSERT INTO vss_session_embeddings(rowid, embedding) VALUES (?, ?)').run(rowId, embeddingJson);
482
+ vssReplaceSession(db, rowId, embeddingJson);
410
483
  })();
411
484
  }
412
485
 
@@ -415,7 +488,7 @@ export function removeSessionEmbeddingFromIndex(derivedDir, sessionId) {
415
488
  db.transaction(() => {
416
489
  const rows = db.prepare('SELECT rowid, key FROM session_embeddings WHERE session_id = ?').all(sessionId);
417
490
  for (const row of rows) {
418
- db.prepare('DELETE FROM vss_session_embeddings WHERE rowid = ?').run(row.rowid);
491
+ vssDeleteSession(db, row.rowid);
419
492
  db.prepare('DELETE FROM session_embeddings WHERE key = ?').run(row.key);
420
493
  }
421
494
  })();
@@ -462,7 +535,8 @@ export async function buildSessionEmbeddingsIndex(sessionsDir, derivedDir, opts
462
535
  if (files.length === 0) return { indexed: 0, skipped: 0, failed: 0, chunks: 0 };
463
536
 
464
537
  if (force) {
465
- db.exec('DELETE FROM vss_session_embeddings; DELETE FROM session_embeddings;');
538
+ if (hasVss(db)) db.exec('DELETE FROM vss_session_embeddings;');
539
+ db.exec('DELETE FROM session_embeddings;');
466
540
  }
467
541
 
468
542
  const existing = force ? {} : loadSessionEmbeddingsIndex(derivedDir);
@@ -497,8 +571,7 @@ export async function buildSessionEmbeddingsIndex(sessionsDir, derivedDir, opts
497
571
  `).run(key, sessionId, i, chunks.length, snippet, model, new Date().toISOString(), JSON.stringify(embedding));
498
572
 
499
573
  const rowId = db.prepare('SELECT rowid FROM session_embeddings WHERE key = ?').get(key).rowid;
500
- db.prepare('DELETE FROM vss_session_embeddings WHERE rowid = ?').run(rowId);
501
- db.prepare('INSERT INTO vss_session_embeddings(rowid, embedding) VALUES (?, ?)').run(rowId, JSON.stringify(embedding));
574
+ vssReplaceSession(db, rowId, JSON.stringify(embedding));
502
575
  })();
503
576
 
504
577
  totalChunks++;
@@ -90,7 +90,8 @@ export function validateCommand(commandLine) {
90
90
  * Executes untrusted agent code in a restricted container or OS-level sandbox.
91
91
  */
92
92
  export async function runInSandbox(scriptPath, timeoutMs = 5000, options = {}) {
93
- const cappedTimeout = Math.min(Math.max(timeoutMs, 1000), 30000);
93
+ // Allow short timeouts for tests; clamp upper bound for safety.
94
+ const cappedTimeout = Math.min(Math.max(Number(timeoutMs) || 5000, 200), 30000);
94
95
  const allowNetwork = options.allowNetwork === true;
95
96
 
96
97
  // Restrict environment variables
@@ -104,6 +105,8 @@ export async function runInSandbox(scriptPath, timeoutMs = 5000, options = {}) {
104
105
  safeEnv.NODE_OPTIONS = '--experimental-vm-modules';
105
106
 
106
107
  // OS-specific sandboxing wrappers
108
+ // Prefer plain node + hard kill timeout. unshare --pid --fork can orphan infinite
109
+ // loops so the parent wait never ends on Linux CI.
107
110
  let cmd = process.execPath;
108
111
  let args = ['--no-warnings', scriptPath];
109
112
  let tempSbProfilePath = null;
@@ -119,26 +122,25 @@ export async function runInSandbox(scriptPath, timeoutMs = 5000, options = {}) {
119
122
  cmd = 'sandbox-exec';
120
123
  args = ['-f', tempSbProfilePath, process.execPath, '--no-warnings', scriptPath];
121
124
  }
122
- } else if (process.platform === 'linux') {
123
- // Linux namespace isolation check via unshare
124
- const hasUnshare = fs.existsSync('/usr/bin/unshare') || fs.existsSync('/bin/unshare');
125
- if (hasUnshare) {
126
- cmd = 'unshare';
127
- const unshareArgs = ['--mount', '--ipc', '--pid', '--fork'];
128
- if (!allowNetwork) {
129
- unshareArgs.push('--net');
130
- }
131
- args = [...unshareArgs, process.execPath, '--no-warnings', scriptPath];
132
- }
133
125
  }
134
126
  } catch (err) {
135
127
  logger.warn('sandbox', `Failed to configure OS-specific isolation: ${err.message}. Using default environment limits.`);
136
128
  }
137
129
 
138
130
  return new Promise((resolve) => {
131
+ let settled = false;
132
+ const finish = (payload) => {
133
+ if (settled) return;
134
+ settled = true;
135
+ if (tempSbProfilePath && fs.existsSync(tempSbProfilePath)) {
136
+ try { fs.unlinkSync(tempSbProfilePath); } catch { /* ignore */ }
137
+ }
138
+ resolve(payload);
139
+ };
140
+
139
141
  const proc = spawn(cmd, args, {
140
142
  env: safeEnv,
141
- timeout: cappedTimeout
143
+ stdio: ['ignore', 'pipe', 'pipe'],
142
144
  });
143
145
 
144
146
  let stdout = '';
@@ -147,22 +149,35 @@ export async function runInSandbox(scriptPath, timeoutMs = 5000, options = {}) {
147
149
  proc.stdout.on('data', (data) => { stdout += data.toString(); });
148
150
  proc.stderr.on('data', (data) => { stderr += data.toString(); });
149
151
 
152
+ const timer = setTimeout(() => {
153
+ try {
154
+ proc.kill('SIGKILL');
155
+ } catch { /* ignore */ }
156
+ // Also try process group if detached later
157
+ try {
158
+ if (proc.pid) process.kill(proc.pid, 'SIGKILL');
159
+ } catch { /* ignore */ }
160
+ finish({
161
+ success: false,
162
+ output: stderr || stdout || `Sandbox timeout after ${cappedTimeout}ms`,
163
+ code: null,
164
+ signal: 'SIGKILL',
165
+ timedOut: true,
166
+ });
167
+ }, cappedTimeout);
168
+
150
169
  proc.on('close', (code, signal) => {
151
- if (tempSbProfilePath && fs.existsSync(tempSbProfilePath)) {
152
- try { fs.unlinkSync(tempSbProfilePath); } catch {}
153
- }
170
+ clearTimeout(timer);
154
171
  if (code === 0) {
155
- resolve({ success: true, output: stdout, code, signal });
172
+ finish({ success: true, output: stdout, code, signal });
156
173
  } else {
157
- resolve({ success: false, output: stderr || stdout, code, signal });
174
+ finish({ success: false, output: stderr || stdout, code, signal });
158
175
  }
159
176
  });
160
177
 
161
178
  proc.on('error', (err) => {
162
- if (tempSbProfilePath && fs.existsSync(tempSbProfilePath)) {
163
- try { fs.unlinkSync(tempSbProfilePath); } catch {}
164
- }
165
- resolve({ success: false, output: err.message, code: -1 });
179
+ clearTimeout(timer);
180
+ finish({ success: false, output: err.message, code: -1 });
166
181
  });
167
182
  });
168
183
  }
@@ -263,10 +263,38 @@
263
263
  "method": "GET",
264
264
  "path": "/api/memory/stats"
265
265
  },
266
+ {
267
+ "method": "GET",
268
+ "path": "/api/mesh/election/history"
269
+ },
270
+ {
271
+ "method": "POST",
272
+ "path": "/api/mesh/election/log"
273
+ },
266
274
  {
267
275
  "method": "POST",
268
276
  "path": "/api/mesh/election/refresh"
269
277
  },
278
+ {
279
+ "method": "GET",
280
+ "path": "/api/mesh/interfaces"
281
+ },
282
+ {
283
+ "method": "GET",
284
+ "path": "/api/mesh/io"
285
+ },
286
+ {
287
+ "method": "GET",
288
+ "path": "/api/mesh/lan"
289
+ },
290
+ {
291
+ "method": "POST",
292
+ "path": "/api/mesh/lan/register"
293
+ },
294
+ {
295
+ "method": "GET",
296
+ "path": "/api/mesh/latency"
297
+ },
270
298
  {
271
299
  "method": "GET",
272
300
  "path": "/api/mesh/leader"
@@ -683,6 +711,10 @@
683
711
  "method": "GET",
684
712
  "path": "/api/webhooks/events"
685
713
  },
714
+ {
715
+ "method": "POST",
716
+ "path": "/api/webhooks/events/:id/redeliver"
717
+ },
686
718
  {
687
719
  "method": "POST",
688
720
  "path": "/api/webhooks/test/:provider"