total-recall-brain 3.3.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +1 -1
  2. package/extension/background.js +13 -1
  3. package/extension/content-script.js +18 -1
  4. package/extension/lib/brain-client.js +18 -5
  5. package/extension/options/options.js +9 -4
  6. package/frontend/index.html +8 -0
  7. package/frontend/src/api.ts +1 -2
  8. package/frontend/src/pages/ChatPage.tsx +9 -6
  9. package/frontend/src/pages/IntegrationsPage.tsx +4 -2
  10. package/package.json +2 -2
  11. package/scaffold/.agent/skills/total-recall/SKILL.md +55 -6
  12. package/scaffold/.agent/skills/total-recall/memory-vault/invariants/operating-instructions.md +2 -2
  13. package/scaffold/.agent/skills/total-recall/skills/cli-agents/agents.yml +1 -6
  14. package/scaffold/.agent/skills/total-recall/skills/ssss/SKILL.md +19 -0
  15. package/scaffold/.agent/skills/total-recall/skills/ssss/references/ssss-spec.md +33 -5
  16. package/src/cli/forget.mjs +18 -12
  17. package/src/cli/ingest/index.mjs +3 -2
  18. package/src/cli/map.mjs +2 -2
  19. package/src/cli/rebuild.mjs +1 -1
  20. package/src/cli/remember.mjs +21 -11
  21. package/src/cli/share.mjs +7 -2
  22. package/src/core/append-log.mjs +131 -0
  23. package/src/core/clarity-rewriter.mjs +10 -6
  24. package/src/core/conclusion-writer.mjs +3 -2
  25. package/src/core/config.mjs +6 -0
  26. package/src/core/conflict-detector.mjs +4 -2
  27. package/src/core/context-compiler.mjs +412 -0
  28. package/src/core/daemon-loop.mjs +15 -0
  29. package/src/core/dream.mjs +4 -4
  30. package/src/core/drift-detector.mjs +3 -3
  31. package/src/core/embeddings.mjs +60 -10
  32. package/src/core/fact-seeker.mjs +70 -44
  33. package/src/core/import-rules.mjs +3 -2
  34. package/src/core/inference-engine.mjs +5 -4
  35. package/src/core/migrate.mjs +4 -3
  36. package/src/core/optimizer.mjs +6 -5
  37. package/src/core/parallel-context.mjs +288 -0
  38. package/src/core/post-mortem.mjs +4 -3
  39. package/src/core/research-queue.mjs +4 -3
  40. package/src/core/runtime.mjs +2 -2
  41. package/src/core/sandbox.mjs +56 -26
  42. package/src/core/scheduler.mjs +6 -5
  43. package/src/core/schema.mjs +2 -2
  44. package/src/core/search.mjs +167 -101
  45. package/src/core/surface.mjs +203 -53
  46. package/src/core/validated-write.mjs +100 -0
  47. package/src/core/vault-cache.mjs +46 -18
  48. package/src/core/vault.mjs +17 -29
  49. package/src/core/vector-field.mjs +659 -0
  50. package/src/core/vector-store.mjs +85 -0
  51. package/src/server/api.mjs +4 -3
  52. package/src/server/index.mjs +13 -61
  53. package/src/server/rest.mjs +233 -62
  54. package/src/server/route-manifest.json +344 -77
  55. package/src/server/routes/memory.mjs +85 -39
  56. package/src/server/routes/sessions.mjs +6 -2
  57. package/src/server/routes/share.mjs +9 -2
@@ -0,0 +1,85 @@
1
+ /**
2
+ * src/core/vector-store.mjs
3
+ *
4
+ * Pluggable vector index interface. Defaults to in-memory cosine similarity scanning,
5
+ * but structured to allow drop-in SQLite-VSS or HNSWLib index engines.
6
+ */
7
+
8
+ export class VectorStore {
9
+ constructor() {
10
+ this.vectors = new Map(); // slug -> { embedding: number[], metadata?: any }
11
+ }
12
+
13
+ /**
14
+ * Load vector index from standard index payload.
15
+ * @param {Record<string, { embedding: number[], chunks?: Array<{ embedding: number[] }> }>} indexData
16
+ */
17
+ load(indexData) {
18
+ this.vectors.clear();
19
+ if (!indexData) return;
20
+ for (const [slug, entry] of Object.entries(indexData)) {
21
+ if (entry && Array.isArray(entry.embedding)) {
22
+ this.vectors.set(slug, {
23
+ embedding: entry.embedding,
24
+ chunks: entry.chunks || []
25
+ });
26
+ }
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Add or update a single vector.
32
+ */
33
+ add(slug, embedding, chunks = []) {
34
+ if (Array.isArray(embedding)) {
35
+ this.vectors.set(slug, { embedding, chunks });
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Delete a single vector.
41
+ */
42
+ delete(slug) {
43
+ this.vectors.delete(slug);
44
+ }
45
+
46
+ /**
47
+ * Perform cosine-similarity based k-nearest neighbors (k-NN) search.
48
+ * Compares query against parent embedding and all child chunk embeddings,
49
+ * matching on the maximum similarity.
50
+ *
51
+ * @param {number[]} queryEmbedding - The query vector
52
+ * @param {number} topK - Maximum results
53
+ * @param {Set<string>|null} filterSet - Optional allow-list of slugs
54
+ * @param {Function} cosineSimilarityFn - Cosine similarity function
55
+ * @returns {Array<{ slug: string, similarity: number }>} Sorted matches
56
+ */
57
+ search(queryEmbedding, topK = 5, filterSet = null, cosineSimilarityFn) {
58
+ if (!queryEmbedding || this.vectors.size === 0) return [];
59
+
60
+ const results = [];
61
+ for (const [slug, data] of this.vectors.entries()) {
62
+ if (filterSet && !filterSet.has(slug)) continue;
63
+
64
+ let maxSimilarity = 0;
65
+ if (Array.isArray(data.embedding)) {
66
+ maxSimilarity = cosineSimilarityFn(queryEmbedding, data.embedding);
67
+ }
68
+
69
+ for (const chunk of data.chunks || []) {
70
+ if (Array.isArray(chunk.embedding)) {
71
+ const sim = cosineSimilarityFn(queryEmbedding, chunk.embedding);
72
+ if (sim > maxSimilarity) {
73
+ maxSimilarity = sim;
74
+ }
75
+ }
76
+ }
77
+
78
+ results.push({ slug, similarity: maxSimilarity });
79
+ }
80
+
81
+ return results
82
+ .sort((a, b) => b.similarity - a.similarity)
83
+ .slice(0, topK);
84
+ }
85
+ }
@@ -8,7 +8,8 @@ import { requireAuth, requireScope, loginHandler, logoutHandler, changePasswordH
8
8
  import { logger } from '../core/logger.mjs';
9
9
  import { synthesize as synthesizeTts, isTtsEnabled, TtsNotConfiguredError } from '../core/tts.mjs';
10
10
  import { KNOWN_SCOPES, loadKeys, issueKey, revokeKey } from './keys.mjs';
11
- import { loadNodes, writeNode } from '../core/vault.mjs';
11
+ import { writeNode } from '../core/vault.mjs';
12
+ import { getNodes } from '../core/vault-cache.mjs';
12
13
  import { compileSurface } from '../core/surface.mjs';
13
14
  import { runInSandbox } from '../core/sandbox.mjs';
14
15
  import { resolveAgentDir } from '../cli/agent-dir.mjs';
@@ -599,7 +600,7 @@ ${interviewTask}`;
599
600
  if (Array.isArray(groundingNodes) && groundingNodes.length > 0) {
600
601
  try {
601
602
  const groundingVaultDir = resolveVaultDir(brainId);
602
- const allNodes = loadNodes(groundingVaultDir);
603
+ const allNodes = getNodes(groundingVaultDir);
603
604
  let groundingPrompt = '\n\n=== ACTIVE GROUNDING BRAIN NODES ===\nThe user has explicitly selected the following brain memory nodes as context for this conversation. Integrate their contents into your knowledge base and refer to them to inform your answers:';
604
605
  let groundedAny = false;
605
606
  for (const slug of groundingNodes) {
@@ -864,7 +865,7 @@ apiRouter.delete('/v1/chat/threads/:id', requireAuth, requireScope('chat:write')
864
865
  apiRouter.get('/v1/chat/suggestions', requireAuth, requireScope('chat:read'), (req, res) => {
865
866
  try {
866
867
  const suggestionsVaultDir = resolveVaultDir(req.query?.brain);
867
- const allNodes = loadNodes(suggestionsVaultDir);
868
+ const allNodes = getNodes(suggestionsVaultDir);
868
869
  // Find active or draft memory nodes that represent facts, concepts, or research
869
870
  const candidates = allNodes.filter(n => n.status !== 'archived' && n.category !== 'preferences');
870
871
 
@@ -17,6 +17,7 @@ import cookieParser from 'cookie-parser';
17
17
  import path from 'node:path';
18
18
  import { fileURLToPath } from 'node:url';
19
19
  import zlib from 'node:zlib';
20
+ import fs from 'node:fs';
20
21
  import {
21
22
  apiRateLimiter,
22
23
  corsOptions,
@@ -34,6 +35,13 @@ import { getDaemonStatus, ensureDaemonRunning } from '../core/daemon-control.mjs
34
35
 
35
36
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
36
37
  const ROOT = path.join(__dirname, '..', '..');
38
+ const PACKAGE_VERSION = (() => {
39
+ try {
40
+ return JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version || 'unknown';
41
+ } catch {
42
+ return 'unknown';
43
+ }
44
+ })();
37
45
 
38
46
  // ─── Watchdog ───────────────────────────────────────────────────────────────────
39
47
  // Attach circuit-breaker log monitor before any subsystem can emit events.
@@ -91,8 +99,6 @@ app.use(express.json({ limit: '50mb' }));
91
99
  app.use(express.urlencoded({ limit: '50mb', extended: true }));
92
100
  app.use(cookieParser());
93
101
 
94
- import fs from 'node:fs';
95
-
96
102
  // ─── Health Check ───────────────────────────────────────────────────────────────
97
103
 
98
104
  app.get('/health', requireAuthOrLocal, async (req, res) => {
@@ -134,7 +140,7 @@ app.get('/health', requireAuthOrLocal, async (req, res) => {
134
140
 
135
141
  res.json({
136
142
  status: hasCriticalIssue ? 'degraded' : 'healthy',
137
- version: '3.0.0',
143
+ version: PACKAGE_VERSION,
138
144
  uptime_seconds: Math.floor(process.uptime()),
139
145
  timestamp: new Date().toISOString(),
140
146
  disk,
@@ -205,60 +211,7 @@ try {
205
211
  logger.info('server', 'API routes mounted at /v1/chat/completions');
206
212
  }
207
213
  } catch (err) {
208
- // api.mjs may still be in standalone mode — mount it directly
209
- logger.warn('server', 'API router not exported as middleware, loading standalone routes...');
210
- const { callLocalRuntimeRaw, loadRuntimeConfig } = await import('../core/runtime.mjs');
211
- const os = await import('node:os');
212
-
213
- app.post('/v1/chat/completions', async (req, res) => {
214
- try {
215
- const authHeader = req.headers.authorization;
216
- if (!authHeader || !authHeader.startsWith('Bearer ')) {
217
- return res.status(401).json({ error: 'Missing or invalid Authorization header' });
218
- }
219
-
220
- const configPath = path.join(configBrainDir, 'config', 'runtime.yml');
221
- const config = loadRuntimeConfig(configPath);
222
- const { messages, model, temperature } = req.body;
223
-
224
- if (!messages || messages.length === 0) {
225
- return res.status(400).json({ error: 'Messages array is required' });
226
- }
227
-
228
- const activeConfig = {
229
- ...config,
230
- model: model || config.model,
231
- temperature: temperature || config.temperature
232
- };
233
-
234
- if (model && activeConfig.agents) {
235
- const targetAgentName = model.toLowerCase();
236
- const idx = activeConfig.agents.findIndex(a => a.name === targetAgentName);
237
- if (idx >= 0) {
238
- activeConfig.agents = activeConfig.agents.map(a => ({ ...a }));
239
- activeConfig.agents[idx].priority = 0;
240
- activeConfig.agents.sort((a, b) => a.priority - b.priority);
241
- }
242
- }
243
-
244
- const response = await callLocalRuntimeRaw(messages, activeConfig);
245
-
246
- res.json({
247
- id: `chatcmpl-${Date.now()}`,
248
- object: 'chat.completion',
249
- created: Math.floor(Date.now() / 1000),
250
- model: model || config.model,
251
- choices: [{
252
- index: 0,
253
- message: response,
254
- finish_reason: 'stop'
255
- }]
256
- });
257
- } catch (error) {
258
- logger.error('api', 'API Error', { error: error.message });
259
- res.status(500).json({ error: error.message });
260
- }
261
- });
214
+ logger.error('server', `API router failed to load; /v1/chat/completions left unmounted: ${err.message}`);
262
215
  }
263
216
 
264
217
 
@@ -271,7 +224,7 @@ const frontendDist = path.join(ROOT, 'frontend', 'dist');
271
224
  if (!fs.existsSync(path.join(frontendDist, 'index.html'))) {
272
225
  const frontendDir = path.join(ROOT, 'frontend');
273
226
  if (fs.existsSync(path.join(frontendDir, 'package.json'))) {
274
- log.info('Frontend not built. Building automatically...');
227
+ logger.info('server', 'Frontend not built. Building automatically...');
275
228
  try {
276
229
  const { execSync } = await import('node:child_process');
277
230
  execSync('npm install --no-audit --no-fund 2>/dev/null && npm run build', {
@@ -279,9 +232,9 @@ if (!fs.existsSync(path.join(frontendDist, 'index.html'))) {
279
232
  stdio: 'pipe',
280
233
  timeout: 120_000,
281
234
  });
282
- log.info('Frontend build complete.');
235
+ logger.info('server', 'Frontend build complete.');
283
236
  } catch (err) {
284
- log.warn(`Frontend auto-build failed: ${err.message?.split('\\n')[0] || 'unknown error'}. Dashboard will show API info instead.`);
237
+ logger.warn('server', `Frontend auto-build failed: ${err.message?.split('\\n')[0] || 'unknown error'}. Dashboard will show API info instead.`);
285
238
  }
286
239
  }
287
240
  }
@@ -865,4 +818,3 @@ async function handleShutdown(signal) {
865
818
 
866
819
  process.on('SIGTERM', () => handleShutdown('SIGTERM'));
867
820
  process.on('SIGINT', () => handleShutdown('SIGINT'));
868
-
@@ -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);
@@ -1250,7 +1392,13 @@ router.get('/api/config-json', requireAuth, requireScope('config:read'), (req, r
1250
1392
  } catch {}
1251
1393
  }
1252
1394
 
1253
- res.json({ security, budget, brain });
1395
+ const safeBrain = { ...brain };
1396
+ if (safeBrain.token) {
1397
+ safeBrain.has_token = true;
1398
+ delete safeBrain.token;
1399
+ }
1400
+
1401
+ res.json({ security, budget, brain: safeBrain });
1254
1402
  } catch (err) { serverError(res, err); }
1255
1403
  });
1256
1404
 
@@ -1272,7 +1420,18 @@ router.post('/api/config-json', requireAuth, requireScope('config:write'), (req,
1272
1420
  fs.writeFileSync(budgetPath, yaml.stringify(budget), { encoding: 'utf8', mode: 0o600 });
1273
1421
  }
1274
1422
  if (brain) {
1275
- fs.writeFileSync(brainPath, JSON.stringify(brain, null, 2), { encoding: 'utf8', mode: 0o600 });
1423
+ let existingBrain = {};
1424
+ if (fs.existsSync(brainPath)) {
1425
+ try {
1426
+ existingBrain = JSON.parse(fs.readFileSync(brainPath, 'utf8')) || {};
1427
+ } catch {}
1428
+ }
1429
+ const nextBrain = { ...existingBrain, ...brain };
1430
+ if ((brain.token === undefined || brain.token === '') && existingBrain.token) {
1431
+ nextBrain.token = existingBrain.token;
1432
+ }
1433
+ delete nextBrain.has_token;
1434
+ fs.writeFileSync(brainPath, JSON.stringify(nextBrain, null, 2), { encoding: 'utf8', mode: 0o600 });
1276
1435
  }
1277
1436
 
1278
1437
  res.json({ success: true });
@@ -1548,7 +1707,7 @@ router.get('/api/brains', requireAuth, requireScope('ssss:read'), (req, res) =>
1548
1707
  let globalNodeCount = 0;
1549
1708
  if (fs.existsSync(globalVaultDir)) {
1550
1709
  try {
1551
- globalNodeCount = loadNodes(globalVaultDir).length;
1710
+ globalNodeCount = getNodes(globalVaultDir).length;
1552
1711
  } catch {
1553
1712
  // Count .md files manually
1554
1713
  const countMd = (dir) => {
@@ -1649,7 +1808,19 @@ router.get('/api/brains/:id/nodes', requireAuth, requireScope('ssss:read', 'memo
1649
1808
  return res.json({ nodes: [], brain_id: brainId });
1650
1809
  }
1651
1810
 
1652
- const nodes = loadNodes(vaultDir);
1811
+ let nodes = getNodes(vaultDir);
1812
+ const { q, category, status, tag } = req.query;
1813
+ if (q) {
1814
+ const query = String(q).toLowerCase();
1815
+ nodes = nodes.filter(n =>
1816
+ [n.slug, n.title, n.category, (n.tags || []).join(' '), n.body]
1817
+ .join(' ').toLowerCase().includes(query)
1818
+ );
1819
+ }
1820
+ if (category) nodes = nodes.filter(n => n.category === category);
1821
+ if (status) nodes = nodes.filter(n => n.status === status);
1822
+ if (tag) nodes = nodes.filter(n => (n.tags || []).includes(tag));
1823
+
1653
1824
  res.json({ nodes, brain_id: brainId, count: nodes.length });
1654
1825
  } catch (err) { serverError(res, err); }
1655
1826
  });