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
@@ -52,10 +52,11 @@ import crypto from 'node:crypto';
52
52
  import { spawn, spawnSync } from 'node:child_process';
53
53
  import matter from 'gray-matter';
54
54
  import { fileURLToPath } from 'node:url';
55
+ import yaml from 'yaml';
55
56
  import { loadRuntimeConfig } from '../core/runtime.mjs';
56
57
 
57
58
 
58
- import { loadNodes, writeNode, deleteNode, walkMd, safeStringify } from '../core/vault.mjs';
59
+ import { writeNode, deleteNode, safeStringify } from '../core/vault.mjs';
59
60
  import { getNodes, invalidate } from '../core/vault-cache.mjs';
60
61
  import { compileSurface } from '../core/surface.mjs';
61
62
  import { runInSandbox } from '../core/sandbox.mjs';
@@ -231,6 +232,161 @@ router.post('/api/vault/compile', requireAuth, requireScope('memory:recompile'),
231
232
  }
232
233
  });
233
234
 
235
+ /**
236
+ * POST /api/vault/compact
237
+ * Compact all active append-only log files (dedup, remove tombstones).
238
+ */
239
+ router.post('/api/vault/compact', requireAuth, requireScope('memory:write'), async (req, res) => {
240
+ try {
241
+ const { compactAppendLogs } = await import('../core/append-log.mjs');
242
+ const result = compactAppendLogs();
243
+ res.json({ compacted: true, ...result });
244
+ } catch (err) {
245
+ serverError(res, err);
246
+ }
247
+ });
248
+
249
+ /**
250
+ * POST /api/context
251
+ * Dynamic streaming context compilation.
252
+ * Assembles a query-aware, temporally-scored, budget-constrained context
253
+ * document from the vault in real-time. Every token earns its place.
254
+ *
255
+ * Body: { query: string, budget?: object, momentum_slugs?: string[] }
256
+ * Returns: { context: string, stats: object }
257
+ */
258
+ router.post('/api/context', requireAuth, requireScope('memory:read'), async (req, res) => {
259
+ try {
260
+ const { compileContext } = await import('../core/context-compiler.mjs');
261
+ const { query, budget, momentum_slugs } = req.body || {};
262
+ const result = await compileContext({
263
+ query: query || '',
264
+ vaultDir: VAULT_DIR,
265
+ derivedDir: DERIVED_DIR,
266
+ budget: budget || {},
267
+ consumer: 'api',
268
+ momentumSlugs: momentum_slugs || [],
269
+ });
270
+ res.json(result);
271
+ } catch (err) {
272
+ serverError(res, err);
273
+ }
274
+ });
275
+
276
+ /**
277
+ * GET /api/context/preview
278
+ * Lightweight context preview — returns temporally-scored candidates
279
+ * and budget allocation without computing embeddings.
280
+ */
281
+ router.get('/api/context/preview', requireAuth, requireScope('memory:read'), async (req, res) => {
282
+ try {
283
+ const { previewContext } = await import('../core/context-compiler.mjs');
284
+ const result = previewContext({ vaultDir: VAULT_DIR });
285
+ res.json(result);
286
+ } catch (err) {
287
+ serverError(res, err);
288
+ }
289
+ });
290
+
291
+ /**
292
+ * POST /api/context/stream
293
+ * Massively parallel context streaming via Flash fan-out.
294
+ * Chunks the entire vault, dispatches parallel Flash calls to score
295
+ * every node against the query simultaneously, merges into budget.
296
+ *
297
+ * Body: { query: string, budget_tokens?: number, batch_size?: number,
298
+ * concurrency?: number, min_score?: number }
299
+ * Returns: { context: string, stats: object, scored: array }
300
+ */
301
+ router.post('/api/context/stream', requireAuth, requireScope('memory:read'), async (req, res) => {
302
+ try {
303
+ const { streamParallelContext } = await import('../core/parallel-context.mjs');
304
+ const { query, budget_tokens, batch_size, concurrency, min_score } = req.body || {};
305
+ if (!query) return badRequest(res, 'query is required');
306
+ const result = await streamParallelContext({
307
+ query,
308
+ vaultDir: VAULT_DIR,
309
+ budgetTokens: budget_tokens,
310
+ batchSize: batch_size,
311
+ concurrency,
312
+ minScore: min_score,
313
+ });
314
+ res.json(result);
315
+ } catch (err) {
316
+ serverError(res, err);
317
+ }
318
+ });
319
+
320
+ /**
321
+ * GET /api/context/flash/health
322
+ * Check Flash API connectivity for parallel context streaming.
323
+ */
324
+ router.get('/api/context/flash/health', requireAuth, requireScope('memory:read'), async (req, res) => {
325
+ try {
326
+ const { checkFlashHealth } = await import('../core/parallel-context.mjs');
327
+ const result = await checkFlashHealth();
328
+ res.json(result);
329
+ } catch (err) {
330
+ serverError(res, err);
331
+ }
332
+ });
333
+
334
+ // ─── Vector Field ─────────────────────────────────────────────────────────────
335
+
336
+ /**
337
+ * POST /api/field/compile
338
+ * Compile the full vector field: embed all nodes, compute N×N covariance
339
+ * matrix, track velocities, persist to disk.
340
+ */
341
+ router.post('/api/field/compile', requireAuth, requireScope('memory:recompile'), async (req, res) => {
342
+ try {
343
+ const { compileField } = await import('../core/vector-field.mjs');
344
+ const result = await compileField({ vaultDir: VAULT_DIR, derivedDir: DERIVED_DIR });
345
+ res.json({ compiled: true, ...result.meta });
346
+ } catch (err) {
347
+ serverError(res, err);
348
+ }
349
+ });
350
+
351
+ /**
352
+ * POST /api/field/sample
353
+ * Sample the vector field at a query point.
354
+ * Returns ranked nodes with direct similarity, entanglement boost, and velocity.
355
+ *
356
+ * Body: { query: string, top_k?: number, entanglement_boost?: number, velocity_weight?: number }
357
+ */
358
+ router.post('/api/field/sample', requireAuth, requireScope('memory:read'), async (req, res) => {
359
+ try {
360
+ const { sampleField } = await import('../core/vector-field.mjs');
361
+ const { query, top_k, entanglement_boost, velocity_weight } = req.body || {};
362
+ if (!query) return badRequest(res, 'query is required');
363
+ const result = await sampleField({
364
+ query,
365
+ topK: top_k,
366
+ entanglementBoost: entanglement_boost,
367
+ velocityWeight: velocity_weight,
368
+ derivedDir: DERIVED_DIR,
369
+ });
370
+ res.json(result);
371
+ } catch (err) {
372
+ serverError(res, err);
373
+ }
374
+ });
375
+
376
+ /**
377
+ * GET /api/field/stats
378
+ * Vector field statistics: point count, velocity distribution,
379
+ * strongest couplings, compilation metadata.
380
+ */
381
+ router.get('/api/field/stats', requireAuth, requireScope('memory:read'), async (req, res) => {
382
+ try {
383
+ const { fieldStats } = await import('../core/vector-field.mjs');
384
+ res.json(fieldStats(DERIVED_DIR));
385
+ } catch (err) {
386
+ serverError(res, err);
387
+ }
388
+ });
389
+
234
390
  /**
235
391
  * POST /api/dream
236
392
  */
@@ -253,24 +409,39 @@ router.post('/api/dream', requireAuth, requireScope('memory:recompile'), async (
253
409
  }
254
410
  });
255
411
 
412
+ /**
413
+ * GET /api/vault/hash
414
+ * Lightweight endpoint for PWA cache invalidation.
415
+ * Returns only the vault content hash — no expensive I/O.
416
+ */
417
+ router.get('/api/vault/hash', requireAuth, requireScope('memory:read'), (req, res) => {
418
+ try {
419
+ const hashFile = path.join(DERIVED_DIR, 'vault-hash.txt');
420
+ const hash = fs.existsSync(hashFile) ? fs.readFileSync(hashFile, 'utf8').trim() : null;
421
+ res.json({ vault_hash: hash });
422
+ } catch (err) {
423
+ serverError(res, err);
424
+ }
425
+ });
426
+
256
427
  /**
257
428
  * GET /api/vault/status
258
429
  */
259
430
  router.get('/api/vault/status', requireAuth, requireScope('memory:read'), async (req, res) => {
260
431
  try {
261
- const nodeCount = fs.existsSync(VAULT_DIR) ? walkMd(VAULT_DIR).length : 0;
262
- const skillCount = fs.existsSync(SKILLS_DIR) ? fs.readdirSync(SKILLS_DIR).filter(d =>
263
- fs.existsSync(path.join(SKILLS_DIR, d, 'SKILL.md'))).length : 0;
432
+ // Use vault-cache instead of scanning every .md file from disk
433
+ const nodeCount = getNodes(VAULT_DIR).length;
434
+ const skillCount = fs.existsSync(SKILLS_DIR) ? fs.readdirSync(SKILLS_DIR, { withFileTypes: true }).filter(d =>
435
+ d.isDirectory() && fs.existsSync(path.join(SKILLS_DIR, d.name, 'SKILL.md'))).length : 0;
264
436
  const lastCompile = fs.existsSync(INSTRUCTIONS)
265
437
  ? fs.statSync(INSTRUCTIONS).mtime.toISOString()
266
438
  : null;
267
439
  const derivedFiles = fs.existsSync(DERIVED_DIR)
268
440
  ? fs.readdirSync(DERIVED_DIR).length : 0;
269
441
 
270
- const vaultEmbedCount = fs.existsSync(path.join(DERIVED_DIR, 'embeddings.json'))
271
- ? Object.keys(JSON.parse(fs.readFileSync(path.join(DERIVED_DIR, 'embeddings.json'), 'utf8') || '{}')).length : 0;
272
- const sessionEmbedCount = fs.existsSync(path.join(DERIVED_DIR, 'session-embeddings.json'))
273
- ? Object.keys(JSON.parse(fs.readFileSync(path.join(DERIVED_DIR, 'session-embeddings.json'), 'utf8') || '{}')).length : 0;
442
+ // Use mtime-cached loaders instead of raw readFile + JSON.parse
443
+ const vaultEmbedCount = Object.keys(loadEmbeddingsIndex(DERIVED_DIR)).length;
444
+ const sessionEmbedCount = Object.keys(loadSessionEmbeddingsIndex(DERIVED_DIR)).length;
274
445
 
275
446
  // CLI agent availability (best-effort)
276
447
  const { findBinaryInPath } = await import('../core/runtime.mjs');
@@ -471,6 +642,7 @@ router.get('/api', (req, res) => {
471
642
  },
472
643
  vault: {
473
644
  'POST /api/vault/compile': 'Recompile SSSS surface (INSTRUCTIONS.md)',
645
+ 'POST /api/vault/compact': 'Compact all active append-only log files',
474
646
  'GET /api/vault/status': 'Node count, skill count, last compile time',
475
647
  },
476
648
  keys: {
@@ -728,17 +900,7 @@ router.get('/api/brain/export', requireAuth, requireScope('brain:export'), async
728
900
 
729
901
  // ─── Chrome Extension Download ───────────────────────────────────────────────
730
902
 
731
- router.get('/api/extension/download', async (_req, res) => {
732
- let preconfiguredPath = '';
733
- let originalContent = '';
734
- const cleanup = () => {
735
- if (preconfiguredPath && originalContent) {
736
- try {
737
- fs.writeFileSync(preconfiguredPath, originalContent, 'utf8');
738
- } catch {}
739
- }
740
- };
741
-
903
+ router.get('/api/extension/download', requireAuth, requireScope('config:read'), async (_req, res) => {
742
904
  try {
743
905
  // Extension lives at <package-root>/extension/
744
906
  const extDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../extension');
@@ -746,30 +908,8 @@ router.get('/api/extension/download', async (_req, res) => {
746
908
  return res.status(404).json({ error: 'Chrome extension not found in this installation.' });
747
909
  }
748
910
 
749
- // Read local brain configuration to get active URL and token
750
- const brainPath = path.join(CONFIG_DIR, 'brain.json');
751
- let brainUrl = '';
752
- let patToken = '';
753
- if (fs.existsSync(brainPath)) {
754
- try {
755
- const brain = JSON.parse(fs.readFileSync(brainPath, 'utf8'));
756
- brainUrl = brain.url || '';
757
- patToken = brain.token || '';
758
- } catch {}
759
- }
760
-
761
- // Overwrite preconfigured.js temporarily with injected values
762
- preconfiguredPath = path.join(extDir, 'lib', 'preconfigured.js');
763
- if (fs.existsSync(preconfiguredPath)) {
764
- originalContent = fs.readFileSync(preconfiguredPath, 'utf8');
765
- const injectedContent = `// Exposes default configuration dynamically injected by the server
766
- self.PreConfigured = {
767
- brainUrl: "${brainUrl}",
768
- pat: "${patToken}"
769
- };
770
- `;
771
- fs.writeFileSync(preconfiguredPath, injectedContent, 'utf8');
772
- }
911
+ // Never inject PATs into packaged extension source. Pair from the extension
912
+ // options page so secrets stay in extension-local storage.
773
913
 
774
914
  res.setHeader('Content-Type', 'application/zip');
775
915
  res.setHeader('Content-Disposition', 'attachment; filename="total-recall-extension.zip"');
@@ -784,18 +924,14 @@ self.PreConfigured = {
784
924
  res.setHeader('Content-Disposition', 'attachment; filename="total-recall-extension.tar.gz"');
785
925
  const tar = spawn('tar', ['czf', '-', '-C', path.dirname(extDir), 'extension'], { stdio: ['ignore', 'pipe', 'ignore'] });
786
926
  tar.stdout.pipe(res);
787
- tar.on('error', err => { cleanup(); if (!res.headersSent) serverError(res, err); });
788
- tar.on('close', () => { cleanup(); if (!res.writableEnded) res.end(); });
789
- } else {
790
- cleanup();
927
+ tar.on('error', err => { if (!res.headersSent) serverError(res, err); });
928
+ tar.on('close', () => { if (!res.writableEnded) res.end(); });
791
929
  }
792
930
  });
793
931
  zip.on('close', code => {
794
- cleanup();
795
932
  if (code !== 0 && !res.writableEnded) res.end();
796
933
  });
797
934
  } catch (err) {
798
- cleanup();
799
935
  serverError(res, err);
800
936
  }
801
937
  });
@@ -804,7 +940,7 @@ self.PreConfigured = {
804
940
  * GET /api/extension/status
805
941
  * Returns whether the extension is available (packaged) and connected (has sent captures).
806
942
  */
807
- router.get('/api/extension/status', async (_req, res) => {
943
+ router.get('/api/extension/status', requireAuth, requireScope('config:read'), async (_req, res) => {
808
944
  try {
809
945
  const extDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../extension');
810
946
  const available = fs.existsSync(path.join(extDir, 'manifest.json'));
@@ -1104,7 +1240,7 @@ router.put("/api/scripts/:name", requireAuth, requireScope("files:write"), (req,
1104
1240
  }
1105
1241
  });
1106
1242
 
1107
- router.post("/api/scripts/:name/run", requireAuth, requireScope("sandbox:run"), (req, res) => {
1243
+ router.post("/api/scripts/:name/run", sandboxRateLimiter(), requireAuth, requireScope("sandbox:run"), requireSandboxEnabled, async (req, res) => {
1108
1244
  try {
1109
1245
  const { name } = req.params;
1110
1246
  if (name.includes("..") || name.includes("/") || name.includes("\\")) {
@@ -1115,15 +1251,21 @@ router.post("/api/scripts/:name/run", requireAuth, requireScope("sandbox:run"),
1115
1251
  return res.status(404).json({ error: `Script "${name}" not found` });
1116
1252
  }
1117
1253
 
1118
- const isPython = name.endsWith(".py");
1119
- const isShell = name.endsWith(".sh");
1120
- const runner = isPython ? "python3" : isShell ? "bash" : "node";
1254
+ const isNodeScript = name.endsWith(".js") || name.endsWith(".mjs") || name.endsWith(".cjs");
1255
+ if (!isNodeScript) {
1256
+ return res.status(400).json({
1257
+ error: "Only Node.js scripts can be run through the sandbox endpoint."
1258
+ });
1259
+ }
1121
1260
 
1122
- const result = spawnSync(runner, [scriptPath], { encoding: "utf8", timeout: 10000 });
1261
+ const result = await runInSandbox(scriptPath, 10000, {
1262
+ allowNetwork: req.body?.allowNetwork === true
1263
+ });
1123
1264
  res.json({
1124
- success: result.status === 0,
1125
- output: result.stdout || result.stderr || "(no output)",
1126
- exitCode: result.status
1265
+ success: result.success,
1266
+ output: result.output || "(no output)",
1267
+ exitCode: result.code ?? null,
1268
+ signal: result.signal ?? null
1127
1269
  });
1128
1270
  } catch (err) {
1129
1271
  serverError(res, err);
@@ -1229,10 +1371,12 @@ router.get('/api/config-json', requireAuth, requireScope('config:read'), (req, r
1229
1371
  const securityPath = path.join(CONFIG_DIR, 'security.yml');
1230
1372
  const budgetPath = path.join(CONFIG_DIR, 'budget.yml');
1231
1373
  const brainPath = path.join(CONFIG_DIR, 'brain.json');
1374
+ const secretsPath = path.join(AGENT_DIR, 'secrets.enc');
1232
1375
 
1233
1376
  let security = {};
1234
1377
  let budget = {};
1235
1378
  let brain = {};
1379
+ let secrets = {};
1236
1380
 
1237
1381
  if (fs.existsSync(securityPath)) {
1238
1382
  try {
@@ -1249,17 +1393,37 @@ router.get('/api/config-json', requireAuth, requireScope('config:read'), (req, r
1249
1393
  brain = JSON.parse(fs.readFileSync(brainPath, 'utf8')) || {};
1250
1394
  } catch {}
1251
1395
  }
1396
+ if (fs.existsSync(secretsPath)) {
1397
+ try {
1398
+ secrets = JSON.parse(fs.readFileSync(secretsPath, 'utf8')) || {};
1399
+ } catch {}
1400
+ }
1252
1401
 
1253
- res.json({ security, budget, brain });
1402
+ const safeBrain = { ...brain };
1403
+ if (safeBrain.token) {
1404
+ safeBrain.has_token = true;
1405
+ delete safeBrain.token;
1406
+ }
1407
+
1408
+ const allowedKeys = ['google_api_key', 'anthropic_api_key', 'openai_api_key', 'tavily_api_key', 'brave_api_key', 'exa_api_key', 'serper_api_key', 'github_token'];
1409
+ const safeSecrets = {};
1410
+ for (const key of allowedKeys) {
1411
+ if (secrets[key] !== undefined) {
1412
+ safeSecrets[key] = secrets[key];
1413
+ }
1414
+ }
1415
+
1416
+ res.json({ security, budget, brain: safeBrain, secrets: safeSecrets });
1254
1417
  } catch (err) { serverError(res, err); }
1255
1418
  });
1256
1419
 
1257
1420
  router.post('/api/config-json', requireAuth, requireScope('config:write'), (req, res) => {
1258
1421
  try {
1259
- const { security, budget, brain } = req.body;
1422
+ const { security, budget, brain, secrets } = req.body;
1260
1423
  const securityPath = path.join(CONFIG_DIR, 'security.yml');
1261
1424
  const budgetPath = path.join(CONFIG_DIR, 'budget.yml');
1262
1425
  const brainPath = path.join(CONFIG_DIR, 'brain.json');
1426
+ const secretsPath = path.join(AGENT_DIR, 'secrets.enc');
1263
1427
 
1264
1428
  if (!fs.existsSync(CONFIG_DIR)) {
1265
1429
  fs.mkdirSync(CONFIG_DIR, { recursive: true });
@@ -1272,7 +1436,37 @@ router.post('/api/config-json', requireAuth, requireScope('config:write'), (req,
1272
1436
  fs.writeFileSync(budgetPath, yaml.stringify(budget), { encoding: 'utf8', mode: 0o600 });
1273
1437
  }
1274
1438
  if (brain) {
1275
- fs.writeFileSync(brainPath, JSON.stringify(brain, null, 2), { encoding: 'utf8', mode: 0o600 });
1439
+ let existingBrain = {};
1440
+ if (fs.existsSync(brainPath)) {
1441
+ try {
1442
+ existingBrain = JSON.parse(fs.readFileSync(brainPath, 'utf8')) || {};
1443
+ } catch {}
1444
+ }
1445
+ const nextBrain = { ...existingBrain, ...brain };
1446
+ if ((brain.token === undefined || brain.token === '') && existingBrain.token) {
1447
+ nextBrain.token = existingBrain.token;
1448
+ }
1449
+ delete nextBrain.has_token;
1450
+ fs.writeFileSync(brainPath, JSON.stringify(nextBrain, null, 2), { encoding: 'utf8', mode: 0o600 });
1451
+ }
1452
+ if (secrets) {
1453
+ let existingSecrets = {};
1454
+ if (fs.existsSync(secretsPath)) {
1455
+ try {
1456
+ existingSecrets = JSON.parse(fs.readFileSync(secretsPath, 'utf8')) || {};
1457
+ } catch {}
1458
+ }
1459
+ const allowedKeys = ['google_api_key', 'anthropic_api_key', 'openai_api_key', 'tavily_api_key', 'brave_api_key', 'exa_api_key', 'serper_api_key', 'github_token'];
1460
+ for (const key of allowedKeys) {
1461
+ if (secrets[key] !== undefined) {
1462
+ if (secrets[key] === '') {
1463
+ delete existingSecrets[key];
1464
+ } else {
1465
+ existingSecrets[key] = secrets[key];
1466
+ }
1467
+ }
1468
+ }
1469
+ fs.writeFileSync(secretsPath, JSON.stringify(existingSecrets, null, 2), { encoding: 'utf8', mode: 0o600 });
1276
1470
  }
1277
1471
 
1278
1472
  res.json({ success: true });
@@ -1548,7 +1742,7 @@ router.get('/api/brains', requireAuth, requireScope('ssss:read'), (req, res) =>
1548
1742
  let globalNodeCount = 0;
1549
1743
  if (fs.existsSync(globalVaultDir)) {
1550
1744
  try {
1551
- globalNodeCount = loadNodes(globalVaultDir).length;
1745
+ globalNodeCount = getNodes(globalVaultDir).length;
1552
1746
  } catch {
1553
1747
  // Count .md files manually
1554
1748
  const countMd = (dir) => {
@@ -1649,7 +1843,19 @@ router.get('/api/brains/:id/nodes', requireAuth, requireScope('ssss:read', 'memo
1649
1843
  return res.json({ nodes: [], brain_id: brainId });
1650
1844
  }
1651
1845
 
1652
- const nodes = loadNodes(vaultDir);
1846
+ let nodes = getNodes(vaultDir);
1847
+ const { q, category, status, tag } = req.query;
1848
+ if (q) {
1849
+ const query = String(q).toLowerCase();
1850
+ nodes = nodes.filter(n =>
1851
+ [n.slug, n.title, n.category, (n.tags || []).join(' '), n.body]
1852
+ .join(' ').toLowerCase().includes(query)
1853
+ );
1854
+ }
1855
+ if (category) nodes = nodes.filter(n => n.category === category);
1856
+ if (status) nodes = nodes.filter(n => n.status === status);
1857
+ if (tag) nodes = nodes.filter(n => (n.tags || []).includes(tag));
1858
+
1653
1859
  res.json({ nodes, brain_id: brainId, count: nodes.length });
1654
1860
  } catch (err) { serverError(res, err); }
1655
1861
  });
@@ -1789,4 +1995,76 @@ function getLastModified(filePath) {
1789
1995
  return null;
1790
1996
  }
1791
1997
 
1998
+ /**
1999
+ * GET /api/update/check
2000
+ */
2001
+ router.get('/api/update/check', requireAuth, async (req, res) => {
2002
+ try {
2003
+ const localPkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
2004
+ const currentVersion = localPkg.version || '0.0.0';
2005
+
2006
+ const registryRes = await fetch('https://registry.npmjs.org/total-recall-brain/latest');
2007
+ if (!registryRes.ok) {
2008
+ throw new Error(`Failed to fetch latest version from npm: ${registryRes.status}`);
2009
+ }
2010
+ const registryData = await registryRes.json();
2011
+ const latestVersion = registryData.version || '0.0.0';
2012
+
2013
+ const updateAvailable = latestVersion !== currentVersion;
2014
+
2015
+ res.json({
2016
+ currentVersion,
2017
+ latestVersion,
2018
+ updateAvailable
2019
+ });
2020
+ } catch (err) {
2021
+ serverError(res, err);
2022
+ }
2023
+ });
2024
+
2025
+ /**
2026
+ * POST /api/update/run
2027
+ */
2028
+ router.post('/api/update/run', requireAuth, async (req, res) => {
2029
+ try {
2030
+ res.json({ success: true, message: 'Update started. Server is updating and will restart shortly.' });
2031
+
2032
+ setImmediate(async () => {
2033
+ logger.info('update', 'Starting auto-update process...');
2034
+ try {
2035
+ const { exec } = await import('node:child_process');
2036
+
2037
+ const runCmd = (cmd, cwd) => new Promise((resolve, reject) => {
2038
+ logger.info('update', `Running: ${cmd} in ${cwd}`);
2039
+ exec(cmd, { cwd }, (err, stdout, stderr) => {
2040
+ if (err) {
2041
+ logger.error('update', `Command failed: ${cmd}. Error: ${err.message}`);
2042
+ return reject(err);
2043
+ }
2044
+ resolve({ stdout, stderr });
2045
+ });
2046
+ });
2047
+
2048
+ // 1. git pull
2049
+ await runCmd('git pull', ROOT);
2050
+ // 2. npm install
2051
+ await runCmd('npm install', ROOT);
2052
+ // 3. build/install frontend if package.json exists
2053
+ const frontendPath = path.join(ROOT, 'frontend');
2054
+ if (fs.existsSync(path.join(frontendPath, 'package.json'))) {
2055
+ await runCmd('npm install', frontendPath);
2056
+ await runCmd('npm run build', frontendPath);
2057
+ }
2058
+
2059
+ logger.info('update', 'Update successfully applied. Restarting server...');
2060
+ process.exit(0);
2061
+ } catch (err) {
2062
+ logger.error('update', `Auto-update failed: ${err.message}`);
2063
+ }
2064
+ });
2065
+ } catch (err) {
2066
+ serverError(res, err);
2067
+ }
2068
+ });
2069
+
1792
2070
  export { router as restRouter };