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,1652 @@
1
+ /**
2
+ * Total Recall — Full REST API Router
3
+ *
4
+ * Mounted at /api/* and /v1/* (OpenAI-compat extensions).
5
+ * Delegates to resource sub-routers in src/server/routes/*.mjs.
6
+ *
7
+ * Route Inventory:
8
+ *
9
+ * Memory (routes/memory.mjs)
10
+ * GET /api/memory list nodes (supports ?q= search, ?category=, ?tag=)
11
+ * POST /api/memory create node
12
+ * GET /api/memory/:slug get node by slug
13
+ * PUT /api/memory/:slug update node (full replace)
14
+ * PATCH /api/memory/:slug partial update (body or tags)
15
+ * DELETE /api/memory/:slug delete node
16
+ * GET /api/memory/stats counts by category
17
+ *
18
+ * Keys (routes/keys.mjs)
19
+ * GET /api/keys list personal access tokens (no raw tokens)
20
+ * POST /api/keys issue a new personal access token (returns raw token once)
21
+ * DELETE /api/keys/:id revoke key
22
+ *
23
+ * Sessions (routes/sessions.mjs)
24
+ * GET /api/sessions list ingested session logs
25
+ * GET /api/sessions/:id get session details by id
26
+ * POST /api/sessions/ingest ingest a session log (Claude Code, Cursor, Cursor CLI, raw)
27
+ * DELETE /api/sessions/:id delete session log
28
+ *
29
+ * Sandbox
30
+ * POST /api/sandbox execute Node.js code securely in sandbox, returns stdout/stderr
31
+ *
32
+ * Config
33
+ * GET /api/config get sanitized runtime + security config
34
+ *
35
+ * Models (OpenAI-compatible extension)
36
+ * GET /v1/models list available Ollama models
37
+ *
38
+ * Vault (admin operations)
39
+ * POST /api/vault/compile trigger full surface compile (re-generates compile.json)
40
+ * GET /api/vault/status vault file counts + last compile time
41
+ *
42
+ * Discovery
43
+ * GET /.well-known/total-recall.json client auto-config manifest
44
+ */
45
+
46
+
47
+ import express from 'express';
48
+ import fs from 'node:fs';
49
+ import path from 'node:path';
50
+ import os from 'node:os';
51
+ import crypto from 'node:crypto';
52
+ import { spawn, spawnSync } from 'node:child_process';
53
+ import matter from 'gray-matter';
54
+ import { fileURLToPath } from 'node:url';
55
+ import { loadRuntimeConfig } from '../core/runtime.mjs';
56
+
57
+
58
+ import { loadNodes, writeNode, deleteNode, walkMd, safeStringify } from '../core/vault.mjs';
59
+ import { getNodes, invalidate } from '../core/vault-cache.mjs';
60
+ import { compileSurface } from '../core/surface.mjs';
61
+ import { runInSandbox } from '../core/sandbox.mjs';
62
+ import { issueKey } from './keys.mjs';
63
+ import connect from '../cli/connect.mjs';
64
+ import {
65
+ requireAuth,
66
+ requireScope,
67
+ requireAuthOrLocal,
68
+ loadSecurityConfig,
69
+ loginHandler,
70
+ logoutHandler,
71
+ changePasswordHandler,
72
+ sandboxRateLimiter,
73
+ requireSandboxEnabled,
74
+ } from './auth.mjs';
75
+ import { getEmbedding, cosineSimilarity, loadEmbeddingsIndex, loadSessionEmbeddingsIndex } from '../core/embeddings.mjs';
76
+ import { semanticSearch } from '../core/search.mjs';
77
+ import { listQueue, addToQueue, updateQueueItem, removeFromQueue } from '../core/research-queue.mjs';
78
+ import { detectRuleFiles, importRuleFiles } from '../core/import-rules.mjs';
79
+ import { synthesize as synthesizeTts, isTtsEnabled, TtsNotConfiguredError } from '../core/tts.mjs';
80
+ import { logger } from '../core/logger.mjs';
81
+ import { memoryRouter } from './routes/memory.mjs';
82
+ import { keysRouter } from './routes/keys.mjs';
83
+ import { sessionsRouter } from './routes/sessions.mjs';
84
+ import { shareRouter } from './routes/share.mjs';
85
+ // ollamaUrl removed — CLI agents replace Ollama
86
+ import {
87
+ AGENT_DIR,
88
+ BRAIN_DIR,
89
+ VAULT_DIR,
90
+ SKILLS_DIR,
91
+ DERIVED_DIR,
92
+ SESSIONS_DIR,
93
+ INSTRUCTIONS,
94
+ FILES_DIR,
95
+ TASKS_DIR,
96
+ CONFIG_DIR
97
+ } from './routes/_shared.mjs';
98
+
99
+ const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
100
+ const MODEL_CATALOG_DIR = path.join(ROOT, 'models', 'catalog', 'total-recall');
101
+
102
+ function listFilesRecursive(root, predicate) {
103
+ const out = [];
104
+ if (!fs.existsSync(root)) return out;
105
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
106
+ const fullPath = path.join(root, entry.name);
107
+ if (entry.isDirectory()) out.push(...listFilesRecursive(fullPath, predicate));
108
+ else if (entry.isFile() && predicate(fullPath)) out.push(fullPath);
109
+ }
110
+ return out;
111
+ }
112
+
113
+ function loadCatalogModels(runtimeConfig = {}) {
114
+ const modelFiles = listFilesRecursive(MODEL_CATALOG_DIR, file => path.basename(file) === 'MODEL.md');
115
+ return modelFiles.map((filePath) => {
116
+ const raw = fs.readFileSync(filePath, 'utf8');
117
+ const parsed = matter(raw);
118
+ const data = parsed.data || {};
119
+ const folderId = path.basename(path.dirname(filePath));
120
+ const id = data.name || `total-recall/${folderId}`;
121
+ const aliases = [...new Set([
122
+ id,
123
+ data.model_id,
124
+ data.name,
125
+ `total-recall/${folderId}`,
126
+ folderId
127
+ ].filter(Boolean))];
128
+
129
+ return {
130
+ id,
131
+ object: 'model',
132
+ created: 0,
133
+ owned_by: data.provider || 'total-recall',
134
+ root: runtimeConfig.model || data.model_id || id,
135
+ parent: null,
136
+ aliases,
137
+ metadata: {
138
+ provider: data.provider || 'total-recall',
139
+ provider_type: data.provider_type || 'local-runtime',
140
+ display_name: data.display_name || data.name || id,
141
+ model_id: data.model_id || id,
142
+ runtime_model: runtimeConfig.model || null,
143
+ pricing_prompt: data.pricing_prompt ?? 0,
144
+ pricing_completion: data.pricing_completion ?? 0,
145
+ supports_tools: data.supports_tools ?? true,
146
+ supports_vision: data.supports_vision ?? false,
147
+ supports_code: data.supports_code ?? true
148
+ }
149
+ };
150
+ });
151
+ }
152
+
153
+ const router = express.Router();
154
+
155
+ // Per-resource sub-routers (see ./routes/*.mjs). Mounted before the inline
156
+ // handlers below so URL precedence stays identical to the pre-refactor file.
157
+ router.use(memoryRouter);
158
+ router.use(keysRouter);
159
+ router.use(sessionsRouter);
160
+ router.use(shareRouter);
161
+
162
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
163
+
164
+ function notFound(res, msg) {
165
+ return res.status(404).json({ error: msg || 'Not found' });
166
+ }
167
+
168
+ function badRequest(res, msg) {
169
+ return res.status(400).json({ error: msg });
170
+ }
171
+
172
+ function serverError(res, err) {
173
+ logger.error('rest', 'Internal server error', { error: err.message, stack: err.stack });
174
+ return res.status(500).json({ error: 'Internal server error' });
175
+ }
176
+
177
+ function nodes() {
178
+ return getNodes(VAULT_DIR);
179
+ }
180
+
181
+ function sanitizeNode({ body, ...rest }) {
182
+ return { ...rest, content: body };
183
+ }
184
+
185
+ // ─── Memory CRUD ─── (moved to ./routes/memory.mjs)
186
+ // ─── Vault ────────────────────────────────────────────────────────────────────
187
+
188
+ /**
189
+ * POST /api/memory/search/semantic
190
+ * Body: { query: string, top_k?: number }
191
+ * Returns top-k vault nodes ranked by vector similarity to the query.
192
+ * Requires Ollama with nomic-embed-text running.
193
+ */
194
+ router.post('/api/memory/search/semantic', requireAuth, requireScope('memory:read'), async (req, res) => {
195
+ try {
196
+ const { query, top_k, include_sessions = true } = req.body || {};
197
+ if (!query) return badRequest(res, 'query is required');
198
+ const results = await semanticSearch(query, { vaultDir: VAULT_DIR, derivedDir: DERIVED_DIR, top_k, includeSessions: include_sessions });
199
+ if (results.length === 0) return res.status(503).json({ error: 'Embeddings index is empty. Run POST /api/vault/compile to build it.' });
200
+ res.json({ query, top_k: Math.min(Number(top_k) || 5, 20), results });
201
+ } catch (err) {
202
+ if (err.message?.includes('Ollama')) return res.status(503).json({ error: err.message });
203
+ serverError(res, err);
204
+ }
205
+ });
206
+
207
+ /**
208
+ * POST /api/vault/compile
209
+ */
210
+ router.post('/api/vault/compile', requireAuth, requireScope('memory:recompile'), async (req, res) => {
211
+ try {
212
+ const start = Date.now();
213
+ await compileSurface({
214
+ vaultDir: VAULT_DIR,
215
+ skillsDir: SKILLS_DIR,
216
+ derivedDir: DERIVED_DIR,
217
+ instructionsFile: INSTRUCTIONS,
218
+ });
219
+ // Incrementally embed any new vault nodes and sessions
220
+ let vaultEmbed = null, sessionEmbed = null;
221
+ try {
222
+ const { buildEmbeddingsIndex, buildSessionEmbeddingsIndex } = await import('../core/embeddings.mjs');
223
+ invalidate();
224
+ const vaultNodes = getNodes(VAULT_DIR);
225
+ vaultEmbed = await buildEmbeddingsIndex(vaultNodes, DERIVED_DIR);
226
+ sessionEmbed = await buildSessionEmbeddingsIndex(SESSIONS_DIR, DERIVED_DIR);
227
+ } catch { /* Ollama may not be running — non-fatal */ }
228
+ res.json({ compiled: true, elapsed_ms: Date.now() - start, vault_embeddings: vaultEmbed, session_embeddings: sessionEmbed });
229
+ } catch (err) {
230
+ serverError(res, err);
231
+ }
232
+ });
233
+
234
+ /**
235
+ * POST /api/dream
236
+ */
237
+ router.post('/api/dream', requireAuth, requireScope('memory:recompile'), async (req, res) => {
238
+ try {
239
+ const { runDreamCycle } = await import('../core/dream.mjs');
240
+ const conflictsDir = path.join(BRAIN_DIR, 'memory-inbox', 'conflicts');
241
+
242
+ const result = await runDreamCycle({
243
+ vaultDir: VAULT_DIR,
244
+ skillsDir: SKILLS_DIR,
245
+ derivedDir: DERIVED_DIR,
246
+ conflictsDir,
247
+ instructionsFile: INSTRUCTIONS,
248
+ });
249
+
250
+ res.json({ success: true, status: result.status });
251
+ } catch (err) {
252
+ serverError(res, err);
253
+ }
254
+ });
255
+
256
+ /**
257
+ * GET /api/vault/status
258
+ */
259
+ router.get('/api/vault/status', requireAuth, requireScope('memory:read'), async (req, res) => {
260
+ 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;
264
+ const lastCompile = fs.existsSync(INSTRUCTIONS)
265
+ ? fs.statSync(INSTRUCTIONS).mtime.toISOString()
266
+ : null;
267
+ const derivedFiles = fs.existsSync(DERIVED_DIR)
268
+ ? fs.readdirSync(DERIVED_DIR).length : 0;
269
+
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;
274
+
275
+ // CLI agent availability (best-effort)
276
+ const { findBinaryInPath } = await import('../core/runtime.mjs');
277
+ let cliAgents = [];
278
+ for (const bin of ['antigravity', 'gemini', 'claude', 'codex']) {
279
+ if (findBinaryInPath(bin)) cliAgents.push(bin);
280
+ }
281
+
282
+ res.json({
283
+ vault_dir: VAULT_DIR,
284
+ node_count: nodeCount,
285
+ skill_count: skillCount,
286
+ derived_files: derivedFiles,
287
+ last_compile: lastCompile,
288
+ instructions_exists: fs.existsSync(INSTRUCTIONS),
289
+ embeddings: {
290
+ vault_nodes: vaultEmbedCount,
291
+ session_chunks: sessionEmbedCount,
292
+ },
293
+ cli_agents: { available: cliAgents },
294
+ });
295
+ } catch (err) {
296
+ serverError(res, err);
297
+ }
298
+ });
299
+
300
+ // ─── Keys ─────────── (moved to ./routes/keys.mjs)
301
+ // ─── Sessions ─────── (moved to ./routes/sessions.mjs)
302
+ // ─── Sandbox ──────────────────────────────────────────────────────────────────
303
+
304
+ /**
305
+ * POST /api/sandbox
306
+ * Body: { code }
307
+ */
308
+ // Sandbox is rate-limited *before* requireAuth on purpose — limiter keys on
309
+ // the authenticated principal when present, so it's IP-bucketed for
310
+ // pre-auth misuse and key-bucketed once a PAT is attached.
311
+ router.post('/api/sandbox', sandboxRateLimiter(), requireAuth, requireScope('sandbox:run'), requireSandboxEnabled, async (req, res) => {
312
+ try {
313
+ const { code } = req.body || {};
314
+ if (!code) return badRequest(res, 'code is required');
315
+
316
+ const tmpDir = path.join(os.tmpdir(), 'total-recall-sandbox');
317
+ fs.mkdirSync(tmpDir, { recursive: true });
318
+ const script = path.join(tmpDir, `rest-${Date.now()}.mjs`);
319
+ fs.writeFileSync(script, code);
320
+
321
+ const result = await runInSandbox(script, 15000);
322
+ try { fs.unlinkSync(script); } catch {}
323
+
324
+ res.json({
325
+ success: result.success,
326
+ exit_code: result.code,
327
+ output: result.output,
328
+ });
329
+ } catch (err) {
330
+ serverError(res, err);
331
+ }
332
+ });
333
+
334
+ // ─── Config ───────────────────────────────────────────────────────────────────
335
+
336
+ /**
337
+ * GET /api/config
338
+ * Returns sanitized config (no secrets, no tokens)
339
+ */
340
+ router.get('/api/config', requireAuth, requireScope('config:read'), async (req, res) => {
341
+ try {
342
+ const sec = loadSecurityConfig();
343
+ // Scrub anything that looks like a secret
344
+ const safe = JSON.parse(JSON.stringify(sec));
345
+ if (safe.api) { safe.api.pats = '[redacted]'; }
346
+
347
+ // Runtime config (sanitized)
348
+ const runtimePath = path.join(BRAIN_DIR, 'config', 'runtime.yml');
349
+ let runtime = null;
350
+ if (fs.existsSync(runtimePath)) {
351
+ try {
352
+ const { default: yaml } = await import('yaml');
353
+ runtime = yaml.parse(fs.readFileSync(runtimePath, 'utf8'));
354
+ // Remove any api_key fields
355
+ for (const key of ['api_key', 'apiKey', 'secret', 'token']) {
356
+ if (runtime[key]) runtime[key] = '[redacted]';
357
+ }
358
+ } catch {}
359
+ }
360
+
361
+ res.json({ security: safe, runtime });
362
+ } catch (err) {
363
+ serverError(res, err);
364
+ }
365
+ });
366
+
367
+ // ─── Models (OpenAI-compatible) ───────────────────────────────────────────────
368
+
369
+ /**
370
+ * GET /v1/models
371
+ */
372
+ router.get('/v1/models', requireAuthOrLocal, async (req, res) => {
373
+ try {
374
+ const runtimeConfig = loadRuntimeConfig(path.join(CONFIG_DIR, 'runtime.yml'));
375
+ const catalogModels = loadCatalogModels(runtimeConfig);
376
+ const data = catalogModels.length > 0
377
+ ? catalogModels
378
+ : [{
379
+ id: runtimeConfig.model,
380
+ object: 'model',
381
+ created: 0,
382
+ owned_by: 'total-recall',
383
+ root: runtimeConfig.model,
384
+ parent: null,
385
+ aliases: [runtimeConfig.model],
386
+ metadata: {
387
+ provider: 'total-recall',
388
+ provider_type: runtimeConfig.runtime || 'local-runtime',
389
+ display_name: runtimeConfig.model,
390
+ model_id: runtimeConfig.model,
391
+ runtime_model: runtimeConfig.model,
392
+ pricing_prompt: 0,
393
+ pricing_completion: 0,
394
+ supports_tools: true,
395
+ supports_vision: false,
396
+ supports_code: true
397
+ }
398
+ }];
399
+
400
+ res.json({ object: 'list', data });
401
+ } catch (err) {
402
+ serverError(res, err);
403
+ }
404
+ });
405
+
406
+ // ─── Discovery manifest ────────────────────────────────────────────────────────
407
+
408
+ /**
409
+ * GET /.well-known/total-recall.json
410
+ * Used by UltraChat and other clients for auto-configuration.
411
+ */
412
+ router.get('/.well-known/total-recall.json', (req, res) => {
413
+ try {
414
+ const proto = req.secure ? 'https' : 'http';
415
+ const host = req.headers.host || 'localhost:3000';
416
+ const base = `${proto}://${host}`;
417
+ const sec = loadSecurityConfig();
418
+
419
+ res.json({
420
+ name: 'Total Recall',
421
+ version: '3.0.0',
422
+ base_url: base,
423
+ api: `${base}/v1`,
424
+ health: `${base}/health`,
425
+ models: `${base}/v1/models`,
426
+ auth: {
427
+ type: 'bearer',
428
+ token_prefix: 'tr_',
429
+ scopes: ['chat:read', 'chat:write', 'memory:read', 'memory:write'],
430
+ },
431
+ capabilities: ['chat', 'memory', 'sandbox', 'sessions'],
432
+ rate_limits: {
433
+ api: sec.rate_limits?.api_requests_per_minute || 60,
434
+ },
435
+ });
436
+ } catch (err) {
437
+ serverError(res, err);
438
+ }
439
+ });
440
+
441
+ // ─── API reference (human-readable) ───────────────────────────────────────────
442
+
443
+ /**
444
+ * GET /api
445
+ * Returns the full API reference as JSON (machine + human readable).
446
+ */
447
+ router.get('/api', (req, res) => {
448
+ const proto = req.secure ? 'https' : 'http';
449
+ const base = `${proto}://${req.headers.host || 'localhost:3000'}`;
450
+
451
+ res.json({
452
+ name: 'Total Recall REST API',
453
+ version: '3.0.0',
454
+ base_url: base,
455
+ auth: {
456
+ description: 'All endpoints require a Bearer PAT (Personal Access Token).',
457
+ header: 'Authorization: Bearer tr_<token>',
458
+ issue: 'npx total-recall generate-pat --scopes "*" --label myapp',
459
+ endpoint: 'POST /api/keys',
460
+ },
461
+ endpoints: {
462
+ memory: {
463
+ 'GET /api/memory': 'List nodes (q, category, tag, limit, offset)',
464
+ 'GET /api/memory/stats': 'Node counts by category',
465
+ 'GET /api/memory/:slug': 'Get node by slug',
466
+ 'POST /api/memory': 'Create node (slug, title, category, content)',
467
+ 'POST /api/memory/search/semantic': 'Semantic search by meaning (query, top_k) — requires Ollama',
468
+ 'PUT /api/memory/:slug': 'Replace node',
469
+ 'PATCH /api/memory/:slug': 'Partial update',
470
+ 'DELETE /api/memory/:slug': 'Delete node',
471
+ },
472
+ vault: {
473
+ 'POST /api/vault/compile': 'Recompile SSSS surface (INSTRUCTIONS.md)',
474
+ 'GET /api/vault/status': 'Node count, skill count, last compile time',
475
+ },
476
+ keys: {
477
+ 'GET /api/keys': 'List PATs (no raw tokens)',
478
+ 'POST /api/keys': 'Issue new PAT (name, scopes[], expires_at)',
479
+ 'DELETE /api/keys/:id': 'Revoke PAT',
480
+ },
481
+ sessions: {
482
+ 'GET /api/sessions': 'List ingested sessions',
483
+ 'GET /api/sessions/:id': 'Get session entries',
484
+ 'POST /api/sessions/ingest': 'Ingest session {id, source, messages[]}',
485
+ 'DELETE /api/sessions/:id': 'Delete session',
486
+ },
487
+ sandbox: {
488
+ 'POST /api/sandbox': 'Execute Node.js code {code}',
489
+ },
490
+ config: {
491
+ 'GET /api/config': 'Sanitized runtime + security config',
492
+ },
493
+ models: {
494
+ 'GET /v1/models': 'OpenAI-compatible model list',
495
+ 'POST /v1/chat/completions': 'OpenAI-compatible chat (streaming supported)',
496
+ },
497
+ discovery: {
498
+ 'GET /.well-known/total-recall.json': 'Client auto-config manifest',
499
+ 'GET /health': 'System health (disk, ollama, vault)',
500
+ },
501
+ },
502
+ scopes: {
503
+ '*': 'All permissions',
504
+ 'chat:read': 'Read chat completions',
505
+ 'chat:write': 'Create chat completions',
506
+ 'memory:read': 'Read memory nodes and sessions',
507
+ 'memory:write': 'Write/delete memory nodes and sessions',
508
+ 'memory:recompile': 'Trigger vault recompile',
509
+ 'keys:read': 'List API keys',
510
+ 'keys:write': 'Issue/revoke API keys',
511
+ 'sandbox:run': 'Execute code in sandbox',
512
+ 'config:read': 'Read sanitized config',
513
+ 'health:read': 'Read health endpoints',
514
+ },
515
+ });
516
+ });
517
+
518
+ // ─── Research Queue ───────────────────────────────────────────────────────────
519
+ // Thin REST wrappers over src/core/research-queue.mjs
520
+
521
+ router.get('/api/research', requireAuth, requireScope('memory:read'), (req, res) => {
522
+ try {
523
+ const { status, query, limit, offset } = req.query;
524
+ res.json(listQueue({ status, query, limit, offset }));
525
+ } catch (err) { serverError(res, err); }
526
+ });
527
+
528
+ router.post('/api/research', requireAuth, requireScope('memory:write'), (req, res) => {
529
+ try {
530
+ const { topic, priority, notes } = req.body || {};
531
+ if (!topic) return badRequest(res, 'topic is required');
532
+ res.status(201).json(addToQueue({ topic, priority, notes }));
533
+ } catch (err) { serverError(res, err); }
534
+ });
535
+
536
+ router.patch('/api/research/:id', requireAuth, requireScope('memory:write'), (req, res) => {
537
+ try {
538
+ res.json(updateQueueItem(req.params.id, req.body || {}));
539
+ } catch (err) {
540
+ if (err.status === 404) return notFound(res, err.message);
541
+ serverError(res, err);
542
+ }
543
+ });
544
+
545
+ router.delete('/api/research/:id', requireAuth, requireScope('memory:write'), (req, res) => {
546
+ try {
547
+ res.json(removeFromQueue(req.params.id));
548
+ } catch (err) {
549
+ if (err.status === 404) return notFound(res, err.message);
550
+ serverError(res, err);
551
+ }
552
+ });
553
+
554
+ // ─── Active Integrations ──────────────────────────────────────────────────────
555
+
556
+ /**
557
+ * GET /api/integrations/active
558
+ */
559
+ router.get('/api/integrations/active', requireAuth, (req, res) => {
560
+ try {
561
+ const HOME = os.homedir();
562
+ const configDir = path.join(BRAIN_DIR, 'config');
563
+ const configFile = path.join(configDir, 'wizard-config.json');
564
+
565
+ let configuredIdes = [];
566
+ if (fs.existsSync(configFile)) {
567
+ try {
568
+ const parsed = JSON.parse(fs.readFileSync(configFile, 'utf8'));
569
+ if (Array.isArray(parsed.configuredIdes)) {
570
+ configuredIdes = parsed.configuredIdes;
571
+ }
572
+ } catch (e) {
573
+ // ignore
574
+ }
575
+ }
576
+
577
+ // Fallback detection (filesystem probing) if configuredIdes is empty
578
+ if (configuredIdes.length === 0) {
579
+ const checks = {
580
+ 'claude-code': [path.join(HOME, '.claude', 'projects'), path.join(HOME, '.claude', 'CLAUDE.md')],
581
+ 'codex': [path.join(HOME, '.codex', 'sessions'), path.join(HOME, '.codex', 'AGENTS.md')],
582
+ 'cursor': [path.join(HOME, '.cursor', 'projects'), path.join(HOME, '.cursor')],
583
+ 'antigravity': [path.join(HOME, '.gemini', 'antigravity')],
584
+ 'vscode': [path.join(HOME, 'Library', 'Application Support', 'Code'), path.join(HOME, '.vscode')],
585
+ 'gemini': [path.join(HOME, '.gemini')],
586
+ 'pi': [path.join(HOME, '.pi', 'agent')],
587
+ 'hermes': [path.join(HOME, '.hermes')],
588
+ 'openclaw': [path.join(HOME, '.openclaw')],
589
+ };
590
+
591
+ for (const [ide, paths] of Object.entries(checks)) {
592
+ if (paths.some(p => { try { return fs.existsSync(p); } catch { return false; } })) {
593
+ configuredIdes.push(ide);
594
+ }
595
+ }
596
+ }
597
+
598
+ res.json({ success: true, active: configuredIdes });
599
+ } catch (err) {
600
+ res.status(500).json({ success: false, message: err.message });
601
+ }
602
+ });
603
+
604
+ // ─── Integrations Connection ──────────────────────────────────────────────────
605
+
606
+
607
+ /**
608
+ * POST /api/integrations/connect
609
+ * Body: { client: string, baseUrl?: string }
610
+ */
611
+ router.post('/api/integrations/connect', requireAuth, (req, res) => {
612
+ try {
613
+ const { client, baseUrl } = req.body || {};
614
+ if (!client) return badRequest(res, 'client is required');
615
+
616
+ const validClients = [
617
+ 'vscode', 'pi', 'hermes', 'openclaw', 'cursor', 'claude-code',
618
+ 'codex', 'gemini', 'aider', 'ultrachat', 'obsidian',
619
+ 'generic', 'antigravity'
620
+ ];
621
+
622
+ if (!validClients.includes(client)) {
623
+ return badRequest(res, `Unknown client: ${client}`);
624
+ }
625
+
626
+ // Generate a fresh key for this client
627
+ const scopes = ['ssss:read', 'memory:read'];
628
+ const keyName = `${client.charAt(0).toUpperCase() + client.slice(1)} Link`;
629
+ const newKey = issueKey(keyName, { scopes });
630
+ const token = newKey.token;
631
+
632
+ // Call the connect function from cli/connect.mjs
633
+ const args = [client, '--token', token];
634
+ if (baseUrl) {
635
+ args.push('--brain', baseUrl);
636
+ }
637
+
638
+ // Run the connection logic asynchronously in the background so it doesn't block,
639
+ // or run it synchronously (it is extremely fast as it just writes a few local files).
640
+ connect(args)
641
+ .then(() => {
642
+ res.json({
643
+ success: true,
644
+ message: `Successfully connected ${client}`,
645
+ token_preview: newKey.token_prefix,
646
+ key_id: newKey.id
647
+ });
648
+ })
649
+ .catch(err => {
650
+ serverError(res, err);
651
+ });
652
+ } catch (err) {
653
+ serverError(res, err);
654
+ }
655
+ });
656
+
657
+ // ─── Rule File Import ─────────────────────────────────────────────────────────
658
+ // Thin wrappers over src/core/import-rules.mjs
659
+
660
+ /**
661
+ * GET /api/import/rules
662
+ * Detect existing rule files in given dirs.
663
+ * Query: ?dir=/path (repeatable, default: process.cwd())
664
+ */
665
+ router.get('/api/import/rules', requireAuth, requireScope('memory:read'), (req, res) => {
666
+ try {
667
+ const allowedRoots = [process.cwd(), os.homedir()];
668
+ const rawDirs = req.query.dir ? (Array.isArray(req.query.dir) ? req.query.dir : [req.query.dir]) : allowedRoots;
669
+ const dirs = rawDirs.filter(d => allowedRoots.some(root => path.resolve(d).startsWith(path.resolve(root))));
670
+ if (dirs.length === 0) return badRequest(res, 'No permitted directories specified');
671
+ const detected = detectRuleFiles(dirs);
672
+ res.json({ dirs, detected });
673
+ } catch (err) { serverError(res, err); }
674
+ });
675
+
676
+ /**
677
+ * POST /api/import/rules
678
+ * Import rule files into the vault.
679
+ * Body: { dirs?: string[], files?: string[], force?: boolean, dryRun?: boolean }
680
+ */
681
+ router.post('/api/import/rules', requireAuth, requireScope('memory:write'), (req, res) => {
682
+ try {
683
+ const { dirs, force = false, dryRun = false } = req.body || {};
684
+ const searchDirs = dirs?.length ? dirs : [process.cwd(), os.homedir()];
685
+ const detected = detectRuleFiles(searchDirs);
686
+ const toImport = req.body?.files?.length
687
+ ? detected.filter(f => req.body.files.includes(f.absolutePath))
688
+ : detected.filter(f => !f.alreadyImported || force);
689
+ if (dryRun) return res.json({ dryRun: true, detected, toImport, imported: [], skipped: [], failed: [] });
690
+ const result = importRuleFiles(toImport, { force, vaultDir: VAULT_DIR });
691
+ res.json({ detected, ...result });
692
+ } catch (err) { serverError(res, err); }
693
+ });
694
+
695
+ // ─── Brain Export ─────────────────────────────────────────────────────────────
696
+
697
+ /**
698
+ * GET /api/brain/export
699
+ * Streams entire brain as .tar.gz: vault, derived, sessions, config, skills.
700
+ * Query: ?include=vault,derived,sessions,config,skills (default: all)
701
+ */
702
+ router.get('/api/brain/export', requireAuth, requireScope('brain:export'), async (req, res) => {
703
+ try {
704
+ const ALL_PARTS = {
705
+ vault: VAULT_DIR,
706
+ derived: DERIVED_DIR,
707
+ sessions: SESSIONS_DIR,
708
+ config: path.join(BRAIN_DIR, 'config'),
709
+ skills: SKILLS_DIR,
710
+ };
711
+ const requested = req.query.include
712
+ ? String(req.query.include).split(',').map(s => s.trim())
713
+ : Object.keys(ALL_PARTS);
714
+ const dirs = requested.filter(k => ALL_PARTS[k] && fs.existsSync(ALL_PARTS[k]));
715
+ if (dirs.length === 0) return res.status(404).json({ error: 'No brain data found to export.' });
716
+
717
+ const date = new Date().toISOString().slice(0, 10);
718
+ res.setHeader('Content-Type', 'application/gzip');
719
+ res.setHeader('Content-Disposition', `attachment; filename="total-recall-brain-${date}.tar.gz"`);
720
+
721
+ const relativeDirs = dirs.map(k => path.relative(BRAIN_DIR, ALL_PARTS[k]));
722
+ const tar = spawn('tar', ['czf', '-', '-C', BRAIN_DIR, '--exclude=security.yml', '--exclude=keys.jsonl', '--exclude=session-secret', ...relativeDirs], { stdio: ['ignore', 'pipe', 'ignore'] });
723
+ tar.stdout.pipe(res);
724
+ tar.on('error', err => { if (!res.headersSent) serverError(res, err); });
725
+ tar.on('close', code => { if (code !== 0 && !res.writableEnded) res.end(); });
726
+ } catch (err) { serverError(res, err); }
727
+ });
728
+
729
+ // ─── Dashboard Intelligence Endpoints (feature-flagged) ──────────────────────────
730
+ // Feature flag: presence of ~/.agent/memory-vault/preferences/dashboard-enhanced.md
731
+
732
+ function isDashboardEnhanced() {
733
+ return fs.existsSync(path.join(VAULT_DIR, '..', 'preferences', 'dashboard-enhanced.md'));
734
+ }
735
+
736
+ router.get('/api/graph', requireAuth, requireScope('ssss:read'), (req, res) => {
737
+ if (!isDashboardEnhanced()) {
738
+ return res.status(404).json({ error: 'dashboard-enhanced feature flag not enabled' });
739
+ }
740
+ try {
741
+ const graphFile = path.join(DERIVED_DIR, 'graph-index.jsonl');
742
+ const routesFile = path.join(DERIVED_DIR, 'skill-routes.jsonl');
743
+ const nodes = fs.existsSync(graphFile)
744
+ ? fs.readFileSync(graphFile, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l))
745
+ : [];
746
+ const routes = fs.existsSync(routesFile)
747
+ ? fs.readFileSync(routesFile, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l))
748
+ : [];
749
+ res.json({ nodes, routes });
750
+ } catch (err) { serverError(res, err); }
751
+ });
752
+
753
+ router.get("/api/conflicts", requireAuth, requireScope("ssss:read"), async (req, res) => {
754
+ if (!isDashboardEnhanced()) {
755
+ return res.status(404).json({ error: "dashboard-enhanced feature flag not enabled" });
756
+ }
757
+ try {
758
+ const conflictsDir = path.join(BRAIN_DIR, "memory-inbox", "conflicts");
759
+ const conflicts = [];
760
+
761
+ if (fs.existsSync(conflictsDir)) {
762
+ const files = fs.readdirSync(conflictsDir).filter(f => f.endsWith(".md"));
763
+ for (const file of files) {
764
+ try {
765
+ const raw = fs.readFileSync(path.join(conflictsDir, file), "utf8");
766
+ const parsed = matter(raw);
767
+ conflicts.push({
768
+ ...parsed.data,
769
+ body: parsed.content
770
+ });
771
+ } catch (e) {
772
+ // ignore malformed
773
+ }
774
+ }
775
+ }
776
+
777
+ // Also run a dynamic scan just in case
778
+ const { detectSemanticConflicts } = await import("../core/conflict-detector.mjs");
779
+ const list = nodes();
780
+ const dynamicConflicts = [];
781
+ for (let i = 0; i < list.length; i++) {
782
+ const found = detectSemanticConflicts(list[i], list.slice(0, i));
783
+ dynamicConflicts.push(...found);
784
+ }
785
+
786
+ // Merge them: if a conflict is already on disk, don't duplicate it.
787
+ const merged = [...conflicts];
788
+ for (const dc of dynamicConflicts) {
789
+ const exists = merged.some(c =>
790
+ (c.new_slug === dc.new_slug && c.existing_slug === dc.existing_slug) ||
791
+ (c.new_slug === dc.existing_slug && c.existing_slug === dc.new_slug)
792
+ );
793
+ if (!exists) {
794
+ merged.push(dc);
795
+ }
796
+ }
797
+
798
+ res.json({ conflicts: merged });
799
+ } catch (err) {
800
+ serverError(res, err);
801
+ }
802
+ });
803
+
804
+ router.post("/api/conflicts/resolve", requireAuth, requireScope("memory:write"), async (req, res) => {
805
+ try {
806
+ const { conflict_id, action, winner_slug } = req.body || {};
807
+ if (!conflict_id || !action || !winner_slug) {
808
+ return badRequest(res, "Required fields: conflict_id, action, winner_slug");
809
+ }
810
+ if (action !== "keep" && action !== "supersede") {
811
+ return badRequest(res, "action must be either 'keep' or 'supersede'");
812
+ }
813
+
814
+ const inboxDir = path.join(BRAIN_DIR, "memory-inbox");
815
+ const { resolveConflict } = await import("../core/conflict-detector.mjs");
816
+ const result = resolveConflict(conflict_id, inboxDir, action, winner_slug);
817
+ if (!result.resolved) {
818
+ return badRequest(res, result.error || "Failed to resolve conflict");
819
+ }
820
+
821
+ invalidate(); // clear cache
822
+ res.json({ success: true, conflict_id });
823
+ } catch (err) {
824
+ serverError(res, err);
825
+ }
826
+ });
827
+
828
+ // ─── Files, Skills & Tasks ───────────────────────────────────────────────────
829
+
830
+ router.get('/api/files', requireAuth, requireScope('files:read'), (req, res) => {
831
+ try {
832
+ if (!fs.existsSync(FILES_DIR)) {
833
+ fs.mkdirSync(FILES_DIR, { recursive: true });
834
+ }
835
+ const files = fs.readdirSync(FILES_DIR).map(file => {
836
+ const stats = fs.statSync(path.join(FILES_DIR, file));
837
+ return {
838
+ name: file,
839
+ size: stats.size,
840
+ modified: stats.mtime,
841
+ isDirectory: stats.isDirectory()
842
+ };
843
+ });
844
+ res.json(files);
845
+ } catch (err) { serverError(res, err); }
846
+ });
847
+
848
+ router.get('/api/skills', requireAuth, requireScope('files:read', 'ssss:read'), (req, res) => {
849
+ try {
850
+ if (!fs.existsSync(SKILLS_DIR)) {
851
+ fs.mkdirSync(SKILLS_DIR, { recursive: true });
852
+ }
853
+ const skills = fs.readdirSync(SKILLS_DIR).map(dir => {
854
+ const dirPath = path.join(SKILLS_DIR, dir);
855
+ const stats = fs.statSync(dirPath);
856
+
857
+ let subSkills = [];
858
+ const subSkillsPath = path.join(dirPath, 'skills');
859
+ if (fs.existsSync(subSkillsPath) && fs.statSync(subSkillsPath).isDirectory()) {
860
+ try {
861
+ subSkills = fs.readdirSync(subSkillsPath).filter(sd =>
862
+ fs.statSync(path.join(subSkillsPath, sd)).isDirectory()
863
+ );
864
+ } catch (e) {}
865
+ }
866
+
867
+ return {
868
+ name: dir,
869
+ size: stats.size,
870
+ modified: stats.mtime,
871
+ isDirectory: stats.isDirectory(),
872
+ subSkills
873
+ };
874
+ });
875
+ res.json(skills);
876
+ } catch (err) { serverError(res, err); }
877
+ });
878
+
879
+ router.get('/api/skills/search', requireAuth, requireScope('files:read', 'ssss:read'), async (req, res) => {
880
+ try {
881
+ const q = req.query.q || '';
882
+ if (!q) {
883
+ return res.status(400).json({ error: 'Missing search query parameter `q`.' });
884
+ }
885
+ const { searchAndSort } = await import('../../.agent/skills/total-recall/skills/skill/scripts/find-skills.mjs');
886
+ const results = searchAndSort(q);
887
+ res.json(results);
888
+ } catch (err) { serverError(res, err); }
889
+ });
890
+
891
+ router.post('/api/skills/install', requireAuth, requireScope('files:write', 'ssss:write'), async (req, res) => {
892
+ try {
893
+ const { pkg } = req.body;
894
+ if (!pkg) {
895
+ return res.status(400).json({ error: 'Missing required `pkg` body parameter.' });
896
+ }
897
+ const { installSkill } = await import('../../.agent/skills/total-recall/skills/skill/scripts/install-skill.mjs');
898
+ const result = installSkill(pkg);
899
+ res.json(result);
900
+ } catch (err) { serverError(res, err); }
901
+ });
902
+
903
+ router.get('/api/skills/:name', requireAuth, requireScope('files:read', 'ssss:read'), (req, res) => {
904
+ try {
905
+ const { name } = req.params;
906
+ if (name.includes('..') || name.includes('/') || name.includes('\\')) {
907
+ return res.status(400).json({ error: 'Invalid skill name' });
908
+ }
909
+ const skillPath = path.join(SKILLS_DIR, name, 'SKILL.md');
910
+ if (!fs.existsSync(skillPath)) {
911
+ return res.status(404).json({ error: `Skill "${name}" not found` });
912
+ }
913
+ const content = fs.readFileSync(skillPath, 'utf8');
914
+ res.json({ name, content });
915
+ } catch (err) { serverError(res, err); }
916
+ });
917
+
918
+ router.put('/api/skills/:name', requireAuth, requireScope('files:write', 'ssss:write'), (req, res) => {
919
+ try {
920
+ const { name } = req.params;
921
+ const { content } = req.body;
922
+ if (name.includes('..') || name.includes('/') || name.includes('\\')) {
923
+ return res.status(400).json({ error: 'Invalid skill name' });
924
+ }
925
+ if (typeof content !== 'string') {
926
+ return res.status(400).json({ error: 'Missing or invalid `content` field.' });
927
+ }
928
+ const skillDir = path.join(SKILLS_DIR, name);
929
+ if (!fs.existsSync(skillDir)) {
930
+ fs.mkdirSync(skillDir, { recursive: true });
931
+ }
932
+ const skillPath = path.join(skillDir, 'SKILL.md');
933
+ fs.writeFileSync(skillPath, content, 'utf8');
934
+ res.json({ success: true, message: `Skill "${name}" updated successfully` });
935
+ } catch (err) { serverError(res, err); }
936
+ });
937
+
938
+ router.delete('/api/skills/:name', requireAuth, requireScope('files:write', 'ssss:write'), (req, res) => {
939
+ try {
940
+ const { name } = req.params;
941
+ if (name.includes('..') || name.includes('/') || name.includes('\\')) {
942
+ return res.status(400).json({ error: 'Invalid skill name' });
943
+ }
944
+ const skillDir = path.join(SKILLS_DIR, name);
945
+ if (!fs.existsSync(skillDir)) {
946
+ return res.status(404).json({ error: `Skill "${name}" not found` });
947
+ }
948
+ fs.rmSync(skillDir, { recursive: true, force: true });
949
+ res.json({ success: true, message: `Skill "${name}" deleted successfully` });
950
+ } catch (err) { serverError(res, err); }
951
+ });
952
+
953
+ // ─── Scripts Editor & Execution ───────────────────────────────────────────────
954
+
955
+ const SCRIPTS_DIR = path.join(SKILLS_DIR, "total-recall", "scripts");
956
+
957
+ router.get("/api/scripts", requireAuth, requireScope("files:read"), (req, res) => {
958
+ try {
959
+ if (!fs.existsSync(SCRIPTS_DIR)) {
960
+ fs.mkdirSync(SCRIPTS_DIR, { recursive: true });
961
+ }
962
+ const files = fs.readdirSync(SCRIPTS_DIR).filter(file =>
963
+ file.endsWith(".mjs") || file.endsWith(".js") || file.endsWith(".py") || file.endsWith(".sh")
964
+ ).map(file => {
965
+ const stats = fs.statSync(path.join(SCRIPTS_DIR, file));
966
+ return {
967
+ name: file,
968
+ size: stats.size,
969
+ modified: stats.mtime
970
+ };
971
+ });
972
+ res.json(files);
973
+ } catch (err) {
974
+ serverError(res, err);
975
+ }
976
+ });
977
+
978
+ router.get("/api/scripts/:name", requireAuth, requireScope("files:read"), (req, res) => {
979
+ try {
980
+ const { name } = req.params;
981
+ if (name.includes("..") || name.includes("/") || name.includes("\\")) {
982
+ return res.status(400).json({ error: "Invalid script name" });
983
+ }
984
+ const scriptPath = path.join(SCRIPTS_DIR, name);
985
+ if (!fs.existsSync(scriptPath)) {
986
+ return res.status(404).json({ error: `Script "${name}" not found` });
987
+ }
988
+ const content = fs.readFileSync(scriptPath, "utf8");
989
+ res.json({ name, content });
990
+ } catch (err) {
991
+ serverError(res, err);
992
+ }
993
+ });
994
+
995
+ router.put("/api/scripts/:name", requireAuth, requireScope("files:write"), (req, res) => {
996
+ try {
997
+ const { name } = req.params;
998
+ const { content } = req.body;
999
+ if (name.includes("..") || name.includes("/") || name.includes("\\")) {
1000
+ return res.status(400).json({ error: "Invalid script name" });
1001
+ }
1002
+ if (typeof content !== "string") {
1003
+ return res.status(400).json({ error: "Missing or invalid `content` field." });
1004
+ }
1005
+ if (!fs.existsSync(SCRIPTS_DIR)) {
1006
+ fs.mkdirSync(SCRIPTS_DIR, { recursive: true });
1007
+ }
1008
+ const scriptPath = path.join(SCRIPTS_DIR, name);
1009
+ fs.writeFileSync(scriptPath, content, "utf8");
1010
+ res.json({ success: true, message: `Script "${name}" saved successfully` });
1011
+ } catch (err) {
1012
+ serverError(res, err);
1013
+ }
1014
+ });
1015
+
1016
+ router.post("/api/scripts/:name/run", requireAuth, requireScope("sandbox:run"), (req, res) => {
1017
+ try {
1018
+ const { name } = req.params;
1019
+ if (name.includes("..") || name.includes("/") || name.includes("\\")) {
1020
+ return res.status(400).json({ error: "Invalid script name" });
1021
+ }
1022
+ const scriptPath = path.join(SCRIPTS_DIR, name);
1023
+ if (!fs.existsSync(scriptPath)) {
1024
+ return res.status(404).json({ error: `Script "${name}" not found` });
1025
+ }
1026
+
1027
+ const isPython = name.endsWith(".py");
1028
+ const isShell = name.endsWith(".sh");
1029
+ const runner = isPython ? "python3" : isShell ? "bash" : "node";
1030
+
1031
+ const result = spawnSync(runner, [scriptPath], { encoding: "utf8", timeout: 10000 });
1032
+ res.json({
1033
+ success: result.status === 0,
1034
+ output: result.stdout || result.stderr || "(no output)",
1035
+ exitCode: result.status
1036
+ });
1037
+ } catch (err) {
1038
+ serverError(res, err);
1039
+ }
1040
+ });
1041
+
1042
+
1043
+
1044
+ router.get('/api/logs/:type', requireAuth, requireScope('health:read'), (req, res) => {
1045
+ try {
1046
+ const { type } = req.params;
1047
+ if (type !== 'server' && type !== 'daemon') {
1048
+ return res.status(400).json({ error: 'Invalid log type. Must be "server" or "daemon"' });
1049
+ }
1050
+ const logPath = path.join(BRAIN_DIR, 'logs', `${type}.log`);
1051
+ if (!fs.existsSync(logPath)) {
1052
+ return res.json({ content: '(no logs yet)' });
1053
+ }
1054
+
1055
+ const stat = fs.statSync(logPath);
1056
+ const maxReadBytes = 50000;
1057
+ let content = '';
1058
+
1059
+ if (stat.size > maxReadBytes) {
1060
+ const fd = fs.openSync(logPath, 'r');
1061
+ const buffer = Buffer.alloc(maxReadBytes);
1062
+ fs.readSync(fd, buffer, 0, maxReadBytes, stat.size - maxReadBytes);
1063
+ fs.closeSync(fd);
1064
+ content = buffer.toString('utf8');
1065
+ } else {
1066
+ content = fs.readFileSync(logPath, 'utf8');
1067
+ }
1068
+
1069
+ const lines = content.split('\n');
1070
+ const lastLines = lines.slice(-200).join('\n');
1071
+ res.json({ content: lastLines });
1072
+ } catch (err) { serverError(res, err); }
1073
+ });
1074
+
1075
+
1076
+
1077
+ router.get('/api/tasks', requireAuth, requireScope('tasks:read'), (req, res) => {
1078
+ try {
1079
+ if (!fs.existsSync(TASKS_DIR)) {
1080
+ return res.json([]);
1081
+ }
1082
+ const tasks = [];
1083
+ const files = fs.readdirSync(TASKS_DIR);
1084
+ for (const file of files) {
1085
+ if (!file.endsWith('.md')) continue;
1086
+ try {
1087
+ const raw = fs.readFileSync(path.join(TASKS_DIR, file), 'utf8');
1088
+ const { data, content } = matter(raw);
1089
+ tasks.push({ ...data, body: content.trim(), slug: file.replace('.md', '') });
1090
+ } catch (e) {
1091
+ // skip
1092
+ }
1093
+ }
1094
+ res.json(tasks.sort((a, b) => (a.priority || 5) - (b.priority || 5)));
1095
+ } catch (err) { serverError(res, err); }
1096
+ });
1097
+
1098
+ router.post('/api/tasks', requireAuth, requireScope('tasks:write'), (req, res) => {
1099
+ try {
1100
+ const { category, target, body, priority = 5 } = req.body || {};
1101
+ if (!category || !target) {
1102
+ return badRequest(res, 'Missing category or target');
1103
+ }
1104
+ if (!fs.existsSync(TASKS_DIR)) {
1105
+ fs.mkdirSync(TASKS_DIR, { recursive: true });
1106
+ }
1107
+ const slug = `task-${Date.now()}`;
1108
+ const frontmatter = {
1109
+ type: 'task',
1110
+ priority,
1111
+ category,
1112
+ target,
1113
+ estimated_calls: 5,
1114
+ deadline: new Date(Date.now() + 86400000).toISOString().split('T')[0],
1115
+ created_by: 'api',
1116
+ reason: 'User requested deep research via Chat UI',
1117
+ status: 'pending',
1118
+ progress: 0
1119
+ };
1120
+ const raw = safeStringify(body || '', frontmatter);
1121
+ fs.writeFileSync(path.join(TASKS_DIR, `${slug}.md`), raw, 'utf8');
1122
+ res.json({ slug, ...frontmatter });
1123
+ } catch (err) { serverError(res, err); }
1124
+ });
1125
+
1126
+ // ─── Config & Sandbox ─────────────────────────────────────────────────────────
1127
+
1128
+ function safeConfigName(name) {
1129
+ // Allow alphanumeric, hyphens, underscores, and dots (for file extensions like .yml)
1130
+ if (!/^[a-zA-Z0-9_.-]+$/.test(name) || name.includes('..')) {
1131
+ return null;
1132
+ }
1133
+ return path.join(CONFIG_DIR, name);
1134
+ }
1135
+
1136
+ router.get('/api/config-json', requireAuth, requireScope('config:read'), (req, res) => {
1137
+ try {
1138
+ const securityPath = path.join(CONFIG_DIR, 'security.yml');
1139
+ const budgetPath = path.join(CONFIG_DIR, 'budget.yml');
1140
+ const brainPath = path.join(CONFIG_DIR, 'brain.json');
1141
+
1142
+ let security = {};
1143
+ let budget = {};
1144
+ let brain = {};
1145
+
1146
+ if (fs.existsSync(securityPath)) {
1147
+ try {
1148
+ security = yaml.parse(fs.readFileSync(securityPath, 'utf8')) || {};
1149
+ } catch {}
1150
+ }
1151
+ if (fs.existsSync(budgetPath)) {
1152
+ try {
1153
+ budget = yaml.parse(fs.readFileSync(budgetPath, 'utf8')) || {};
1154
+ } catch {}
1155
+ }
1156
+ if (fs.existsSync(brainPath)) {
1157
+ try {
1158
+ brain = JSON.parse(fs.readFileSync(brainPath, 'utf8')) || {};
1159
+ } catch {}
1160
+ }
1161
+
1162
+ res.json({ security, budget, brain });
1163
+ } catch (err) { serverError(res, err); }
1164
+ });
1165
+
1166
+ router.post('/api/config-json', requireAuth, requireScope('config:write'), (req, res) => {
1167
+ try {
1168
+ const { security, budget, brain } = req.body;
1169
+ const securityPath = path.join(CONFIG_DIR, 'security.yml');
1170
+ const budgetPath = path.join(CONFIG_DIR, 'budget.yml');
1171
+ const brainPath = path.join(CONFIG_DIR, 'brain.json');
1172
+
1173
+ if (!fs.existsSync(CONFIG_DIR)) {
1174
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
1175
+ }
1176
+
1177
+ if (security) {
1178
+ fs.writeFileSync(securityPath, yaml.stringify(security), { encoding: 'utf8', mode: 0o600 });
1179
+ }
1180
+ if (budget) {
1181
+ fs.writeFileSync(budgetPath, yaml.stringify(budget), { encoding: 'utf8', mode: 0o600 });
1182
+ }
1183
+ if (brain) {
1184
+ fs.writeFileSync(brainPath, JSON.stringify(brain, null, 2), { encoding: 'utf8', mode: 0o600 });
1185
+ }
1186
+
1187
+ res.json({ success: true });
1188
+ } catch (err) { serverError(res, err); }
1189
+ });
1190
+
1191
+ router.get('/api/usage', requireAuth, async (req, res) => {
1192
+ try {
1193
+ const { calculateCurrentCost } = await import('../core/usage-tracker.mjs');
1194
+ res.json(calculateCurrentCost());
1195
+ } catch (err) { serverError(res, err); }
1196
+ });
1197
+
1198
+ router.get('/api/config/:name', requireAuth, requireScope('config:read'), (req, res) => {
1199
+ try {
1200
+ const filePath = safeConfigName(req.params.name);
1201
+ if (!filePath) return badRequest(res, 'Invalid config name');
1202
+ if (!fs.existsSync(filePath)) {
1203
+ if (req.params.name === 'DESIGN.md') {
1204
+ return res.json({ content: '# Design System\n\nPreview your markdown here.' });
1205
+ }
1206
+ return res.json({ content: '' });
1207
+ }
1208
+ const content = fs.readFileSync(filePath, 'utf8');
1209
+ res.json({ content });
1210
+ } catch (err) { serverError(res, err); }
1211
+ });
1212
+
1213
+ router.put('/api/config/:name', requireAuth, requireScope('config:write'), (req, res) => {
1214
+ try {
1215
+ const filePath = safeConfigName(req.params.name);
1216
+ if (!filePath) return badRequest(res, 'Invalid config name');
1217
+ if (!fs.existsSync(CONFIG_DIR)) {
1218
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
1219
+ }
1220
+ fs.writeFileSync(filePath, req.body.content, 'utf8');
1221
+ res.json({ success: true });
1222
+ } catch (err) { serverError(res, err); }
1223
+ });
1224
+
1225
+
1226
+
1227
+ // ─── Quick Capture — Slack / Discord inbound webhooks ────────────────────────────
1228
+ // Feature: Phase 8 quick-capture (parallel to future Telegram path).
1229
+ // Writes inbound messages as draft SSSS inbox nodes for Dream Cycle synthesis.
1230
+ router.post('/api/capture/:source', requireAuth, requireScope('memory:write'), async (req, res) => {
1231
+ const { source } = req.params;
1232
+ if (!['slack', 'discord'].includes(source)) {
1233
+ return res.status(400).json({ error: 'source must be "slack" or "discord"' });
1234
+ }
1235
+ try {
1236
+ const { captureMessage } = await import('../core/quick-capture.mjs');
1237
+ const body = req.body || {};
1238
+ // Normalise Slack and Discord payload shapes
1239
+ const text = body.text || body.content || body.message || '';
1240
+ const author = body.user?.name || body.user_name || body.author?.username || body.username || null;
1241
+ const channel = body.channel?.name || body.channel_name || body.channel_id || null;
1242
+ if (!text.trim()) return res.status(400).json({ error: 'No message text found in payload' });
1243
+ const result = captureMessage({ text, author, channel, source });
1244
+ res.json({ ok: true, slug: result.slug });
1245
+ } catch (err) {
1246
+ res.status(500).json({ error: err.message });
1247
+ }
1248
+ });
1249
+
1250
+ // ─── Consolidated Auth Routes ──────────────────────────────────────────────────
1251
+
1252
+ router.post('/auth/login', loginHandler);
1253
+ router.post('/auth/logout', logoutHandler);
1254
+ router.post('/auth/change-password', requireAuth, changePasswordHandler);
1255
+ router.get('/auth/me', requireAuth, (req, res) => res.json({ authenticated: true }));
1256
+
1257
+ router.get("/auth/status", (req, res) => {
1258
+ const config = loadSecurityConfig();
1259
+ const configured = !!config.dashboard?.password_hash;
1260
+ res.json({ configured });
1261
+ });
1262
+
1263
+ router.post("/auth/setup", async (req, res) => {
1264
+ const config = loadSecurityConfig();
1265
+ const alreadyConfigured = !!config.dashboard?.password_hash;
1266
+ if (alreadyConfigured) {
1267
+ return res.status(400).json({ error: "Dashboard password is already configured" });
1268
+ }
1269
+ return changePasswordHandler(req, res);
1270
+ });
1271
+
1272
+ // ─── Voice / TTS (Kokoro / System) ───────────────────────────────────────────────
1273
+
1274
+ router.get('/api/tts/status', requireAuth, requireScope('tts:use'), (_req, res) => {
1275
+ res.json({ enabled: isTtsEnabled() });
1276
+ });
1277
+
1278
+ router.post('/api/tts', requireAuth, requireScope('tts:use'), async (req, res) => {
1279
+ try {
1280
+ const { text, voice, format, speed } = req.body || {};
1281
+ if (typeof text !== 'string' || !text.trim()) {
1282
+ return res.status(400).json({ error: 'Missing or empty `text` field.' });
1283
+ }
1284
+ if (text.length > 5000) {
1285
+ return res.status(413).json({ error: 'Text exceeds 5000-character limit.' });
1286
+ }
1287
+
1288
+ const { buffer, mimeType } = await synthesizeTts(text, { voice, format, speed });
1289
+ res.setHeader('Content-Type', mimeType);
1290
+ res.setHeader('Cache-Control', 'no-store');
1291
+ res.send(buffer);
1292
+ } catch (err) {
1293
+ if (err instanceof TtsNotConfiguredError) {
1294
+ return res.status(503).json({ error: err.message, code: err.code });
1295
+ }
1296
+ logger.error('api', `TTS error: ${err.message}`);
1297
+ res.status(500).json({ error: err.message });
1298
+ }
1299
+ });
1300
+
1301
+ // ─── Instructions (sync consumers) ─────────────────────────────────────────────
1302
+
1303
+ router.get('/api/instructions', requireAuth, requireScope('instructions:read'), (req, res) => {
1304
+ return sendTextResource(res, INSTRUCTIONS, 'instructions');
1305
+ });
1306
+
1307
+ router.put('/api/instructions', requireAuth, requireScope('instructions:write'), (req, res) => {
1308
+ const { content } = req.body;
1309
+ if (typeof content !== 'string') {
1310
+ return res.status(400).json({ error: 'Missing or invalid `content` field.' });
1311
+ }
1312
+ try {
1313
+ fs.writeFileSync(INSTRUCTIONS, content, 'utf8');
1314
+ return res.json({ success: true, message: 'Instructions updated successfully' });
1315
+ } catch (err) {
1316
+ logger.error('api', `Failed to write instructions: ${err.message}`);
1317
+ return res.status(500).json({ error: err.message });
1318
+ }
1319
+ });
1320
+
1321
+
1322
+ // ─── SSSS Resources (sync and integration consumers) ─────────────────────────
1323
+
1324
+ function sha256(content) {
1325
+ return crypto.createHash('sha256').update(content).digest('hex');
1326
+ }
1327
+
1328
+ function readTextResource(filePath, name) {
1329
+ if (!fs.existsSync(filePath)) return null;
1330
+ const content = fs.readFileSync(filePath, 'utf8');
1331
+ const stat = fs.statSync(filePath);
1332
+ return {
1333
+ name,
1334
+ content,
1335
+ sha256: sha256(content),
1336
+ bytes: stat.size,
1337
+ modified: stat.mtime.toISOString()
1338
+ };
1339
+ }
1340
+
1341
+ function sendTextResource(res, filePath, name) {
1342
+ const resource = readTextResource(filePath, name);
1343
+ if (!resource) {
1344
+ return res.status(404).json({ error: `${name} is not available` });
1345
+ }
1346
+ return res.json(resource);
1347
+ }
1348
+
1349
+ function baseUrl(req) {
1350
+ return `${req.protocol}://${req.get('host')}`;
1351
+ }
1352
+
1353
+ function absoluteUrl(req, routePath) {
1354
+ return new URL(routePath, baseUrl(req)).toString();
1355
+ }
1356
+
1357
+ function ssssReferenceDir() {
1358
+ return path.join(SKILLS_DIR, 'total-recall', 'skills', 'ssss', 'references');
1359
+ }
1360
+
1361
+ function listSsssReferences(req) {
1362
+ const refsDir = ssssReferenceDir();
1363
+ if (!fs.existsSync(refsDir)) return [];
1364
+ return fs.readdirSync(refsDir)
1365
+ .filter(file => file.endsWith('.md'))
1366
+ .sort()
1367
+ .map((file) => {
1368
+ const name = file.replace(/\.md$/, '');
1369
+ const resource = readTextResource(path.join(refsDir, file), name);
1370
+ return {
1371
+ name,
1372
+ url: absoluteUrl(req, `/api/ssss/references/${name}`),
1373
+ sha256: resource?.sha256 || null,
1374
+ bytes: resource?.bytes || 0,
1375
+ modified: resource?.modified || null
1376
+ };
1377
+ });
1378
+ }
1379
+
1380
+ function safeReferencePath(name) {
1381
+ if (!/^[a-zA-Z0-9_-]+$/.test(String(name || ''))) return null;
1382
+ return path.join(ssssReferenceDir(), `${name}.md`);
1383
+ }
1384
+
1385
+ router.get('/api/ssss', requireAuth, requireScope('ssss:read'), (req, res) => {
1386
+ const resources = {
1387
+ instructions: {
1388
+ name: 'instructions',
1389
+ url: absoluteUrl(req, '/api/ssss/instructions'),
1390
+ ...(() => {
1391
+ const r = readTextResource(INSTRUCTIONS, 'instructions');
1392
+ return r ? { sha256: r.sha256, bytes: r.bytes, modified: r.modified } : { sha256: null, bytes: 0, modified: null };
1393
+ })()
1394
+ },
1395
+ skill: {
1396
+ name: 'ssss-skill',
1397
+ url: absoluteUrl(req, '/api/ssss/skill/ssss'),
1398
+ ...(() => {
1399
+ const r = readTextResource(path.join(SKILLS_DIR, 'total-recall', 'skills', 'ssss', 'SKILL.md'), 'ssss-skill');
1400
+ return r ? { sha256: r.sha256, bytes: r.bytes, modified: r.modified } : { sha256: null, bytes: 0, modified: null };
1401
+ })()
1402
+ },
1403
+ spec: {
1404
+ name: 'ssss-spec',
1405
+ url: absoluteUrl(req, '/api/ssss/spec'),
1406
+ ...(() => {
1407
+ const r = readTextResource(path.join(ssssReferenceDir(), 'ssss-spec.md'), 'ssss-spec');
1408
+ return r ? { sha256: r.sha256, bytes: r.bytes, modified: r.modified } : { sha256: null, bytes: 0, modified: null };
1409
+ })()
1410
+ },
1411
+ references: listSsssReferences(req)
1412
+ };
1413
+
1414
+ res.json({
1415
+ name: 'ssss',
1416
+ schema_version: 2,
1417
+ resources
1418
+ });
1419
+ });
1420
+
1421
+ router.get('/api/ssss/instructions', requireAuth, requireScope('ssss:read', 'instructions:read'), (_req, res) => {
1422
+ return sendTextResource(res, INSTRUCTIONS, 'instructions');
1423
+ });
1424
+
1425
+ router.get('/api/ssss/skill/ssss', requireAuth, requireScope('ssss:read'), (_req, res) => {
1426
+ return sendTextResource(res, path.join(SKILLS_DIR, 'total-recall', 'skills', 'ssss', 'SKILL.md'), 'ssss-skill');
1427
+ });
1428
+
1429
+ router.get('/api/ssss/spec', requireAuth, requireScope('ssss:read'), (_req, res) => {
1430
+ return sendTextResource(res, path.join(ssssReferenceDir(), 'ssss-spec.md'), 'ssss-spec');
1431
+ });
1432
+
1433
+ router.get('/api/ssss/references', requireAuth, requireScope('ssss:read'), (req, res) => {
1434
+ res.json({ references: listSsssReferences(req) });
1435
+ });
1436
+
1437
+ router.get('/api/ssss/references/:name', requireAuth, requireScope('ssss:read'), (req, res) => {
1438
+ const filePath = safeReferencePath(req.params.name);
1439
+ if (!filePath) return res.status(400).json({ error: 'Invalid reference name' });
1440
+ return sendTextResource(res, filePath, req.params.name);
1441
+ });
1442
+
1443
+ // ─── Brain Layer Management ────────────────────────────────────────────────
1444
+
1445
+ /**
1446
+ * GET /api/brains
1447
+ * List all known brains (global + registered projects) with state metadata.
1448
+ * The global brain reads project brain frontmatter for state display.
1449
+ */
1450
+ router.get('/api/brains', requireAuth, requireScope('ssss:read'), (req, res) => {
1451
+ try {
1452
+ const globalBrainDir = path.join(os.homedir(), '.agent', 'skills', 'total-recall');
1453
+ const brains = [];
1454
+
1455
+ // Global brain
1456
+ const globalVaultDir = path.join(globalBrainDir, 'memory-vault');
1457
+ let globalNodeCount = 0;
1458
+ if (fs.existsSync(globalVaultDir)) {
1459
+ try {
1460
+ globalNodeCount = loadNodes(globalVaultDir).length;
1461
+ } catch {
1462
+ // Count .md files manually
1463
+ const countMd = (dir) => {
1464
+ if (!fs.existsSync(dir)) return 0;
1465
+ let count = 0;
1466
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
1467
+ if (entry.isDirectory()) count += countMd(path.join(dir, entry.name));
1468
+ else if (entry.name.endsWith('.md')) count++;
1469
+ }
1470
+ return count;
1471
+ };
1472
+ globalNodeCount = countMd(globalVaultDir);
1473
+ }
1474
+ }
1475
+
1476
+ brains.push({
1477
+ id: 'global',
1478
+ name: 'Global Brain',
1479
+ layer: 'global',
1480
+ path: globalBrainDir,
1481
+ exists: fs.existsSync(globalBrainDir),
1482
+ node_count: globalNodeCount,
1483
+ last_compiled: getLastModified(path.join(globalBrainDir, 'memory-derived', 'graph-index.jsonl')),
1484
+ });
1485
+
1486
+ // Project brains from registry
1487
+ const registryPath = path.join(globalBrainDir, 'config', 'project-registry.json');
1488
+ if (fs.existsSync(registryPath)) {
1489
+ try {
1490
+ const registry = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
1491
+ for (const project of registry) {
1492
+ const projectVaultDir = path.join(project.brainDir, 'memory-vault');
1493
+ let nodeCount = 0;
1494
+ const exists = fs.existsSync(project.brainDir);
1495
+
1496
+ if (exists && fs.existsSync(projectVaultDir)) {
1497
+ const countMd = (dir) => {
1498
+ if (!fs.existsSync(dir)) return 0;
1499
+ let count = 0;
1500
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
1501
+ if (entry.isDirectory()) count += countMd(path.join(dir, entry.name));
1502
+ else if (entry.name.endsWith('.md')) count++;
1503
+ }
1504
+ return count;
1505
+ };
1506
+ nodeCount = countMd(projectVaultDir);
1507
+ }
1508
+
1509
+ brains.push({
1510
+ id: `project:${project.name}`,
1511
+ name: project.name,
1512
+ layer: 'project',
1513
+ path: project.brainDir,
1514
+ project_root: project.path,
1515
+ exists,
1516
+ node_count: nodeCount,
1517
+ registered_at: project.registered_at,
1518
+ last_compiled: project.last_compiled || getLastModified(path.join(project.brainDir, 'memory-derived', 'graph-index.jsonl')),
1519
+ });
1520
+ }
1521
+ } catch { /* ignore registry parse errors */ }
1522
+ }
1523
+
1524
+ res.json({ brains });
1525
+ } catch (err) { serverError(res, err); }
1526
+ });
1527
+
1528
+ /**
1529
+ * GET /api/brains/:id/nodes
1530
+ * List all memory nodes for a specific brain.
1531
+ */
1532
+ router.get('/api/brains/:id/nodes', requireAuth, requireScope('ssss:read', 'memory:read'), (req, res) => {
1533
+ try {
1534
+ const brainId = req.params.id;
1535
+ let brainDir;
1536
+
1537
+ if (brainId === 'global') {
1538
+ brainDir = path.join(os.homedir(), '.agent', 'skills', 'total-recall');
1539
+ } else if (brainId.startsWith('project:')) {
1540
+ const projectName = brainId.slice('project:'.length);
1541
+ const globalBrainDir = path.join(os.homedir(), '.agent', 'skills', 'total-recall');
1542
+ const registryPath = path.join(globalBrainDir, 'config', 'project-registry.json');
1543
+ if (!fs.existsSync(registryPath)) {
1544
+ return res.status(404).json({ error: 'Project registry not found' });
1545
+ }
1546
+ const registry = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
1547
+ const project = registry.find(p => p.name === projectName);
1548
+ if (!project) {
1549
+ return res.status(404).json({ error: `Project \"${projectName}\" not found in registry` });
1550
+ }
1551
+ brainDir = project.brainDir;
1552
+ } else {
1553
+ return res.status(400).json({ error: 'Invalid brain ID. Use \"global\" or \"project:<name>\"' });
1554
+ }
1555
+
1556
+ const vaultDir = path.join(brainDir, 'memory-vault');
1557
+ if (!fs.existsSync(vaultDir)) {
1558
+ return res.json({ nodes: [], brain_id: brainId });
1559
+ }
1560
+
1561
+ const nodes = loadNodes(vaultDir);
1562
+ res.json({ nodes, brain_id: brainId, count: nodes.length });
1563
+ } catch (err) { serverError(res, err); }
1564
+ });
1565
+
1566
+ /**
1567
+ * GET /api/gemini-models
1568
+ * Dynamically fetches the list of available Gemini models using the user's GOOGLE_API_KEY.
1569
+ * Falls back to a modern list of Gemini models if GOOGLE_API_KEY is not set or the fetch fails.
1570
+ */
1571
+ router.get('/api/gemini-models', requireAuth, async (req, res) => {
1572
+ try {
1573
+ let apiKey = process.env.GOOGLE_API_KEY;
1574
+ if (!apiKey) {
1575
+ // Find GOOGLE_API_KEY in upwards .env files
1576
+ try {
1577
+ let dir = process.cwd();
1578
+ while (dir !== path.dirname(dir)) {
1579
+ const envPath = path.join(dir, '.env');
1580
+ if (fs.existsSync(envPath)) {
1581
+ const content = fs.readFileSync(envPath, 'utf8');
1582
+ for (const line of content.split('\n')) {
1583
+ const match = line.match(/^\s*GOOGLE_API_KEY\s*=\s*(["']?)(.*?)\1\s*$/);
1584
+ if (match) {
1585
+ apiKey = match[2];
1586
+ break;
1587
+ }
1588
+ }
1589
+ }
1590
+ if (apiKey) break;
1591
+ dir = path.dirname(dir);
1592
+ }
1593
+ } catch {}
1594
+ }
1595
+
1596
+ const fallbackModels = [
1597
+ { id: 'gemini-2.5-flash', displayName: 'Gemini 2.5 Flash' },
1598
+ { id: 'gemini-2.5-pro', displayName: 'Gemini 2.5 Pro' },
1599
+ { id: 'gemini-1.5-pro', displayName: 'Gemini 1.5 Pro' },
1600
+ { id: 'gemini-1.5-flash', displayName: 'Gemini 1.5 Flash' },
1601
+ { id: 'gemini-3.5-flash', displayName: 'Gemini 3.5 Flash' },
1602
+ { id: 'gemini-3.1-flash-lite', displayName: 'Gemini 3.1 Flash Lite' },
1603
+ { id: 'gemini-2.0-flash-lite', displayName: 'Gemini 2.0 Flash Lite' },
1604
+ { id: 'gemini-pro-latest', displayName: 'Gemini Pro Latest' }
1605
+ ];
1606
+
1607
+ if (!apiKey) {
1608
+ return res.json({ models: fallbackModels, source: 'fallback' });
1609
+ }
1610
+
1611
+ try {
1612
+ const controller = new AbortController();
1613
+ const timeoutId = setTimeout(() => controller.abort(), 5000);
1614
+ const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`, {
1615
+ signal: controller.signal
1616
+ });
1617
+ clearTimeout(timeoutId);
1618
+
1619
+ if (response.ok) {
1620
+ const data = await response.json();
1621
+ const models = (data.models || [])
1622
+ .filter(m => m.name.startsWith('models/gemini-') || m.name.startsWith('models/gemini'))
1623
+ .map(m => {
1624
+ const id = m.name.replace(/^models\//, '');
1625
+ // Create nice display name e.g., "gemini-3.5-flash" -> "Gemini 3.5 Flash"
1626
+ const parts = id.split('-');
1627
+ const displayName = parts.map(p => p.charAt(0).toUpperCase() + p.slice(1)).join(' ');
1628
+ return { id, displayName };
1629
+ });
1630
+ if (models.length > 0) {
1631
+ return res.json({ models, source: 'dynamic' });
1632
+ }
1633
+ }
1634
+ } catch {}
1635
+
1636
+ res.json({ models: fallbackModels, source: 'fallback_error' });
1637
+ } catch (err) {
1638
+ serverError(res, err);
1639
+ }
1640
+ });
1641
+
1642
+
1643
+ function getLastModified(filePath) {
1644
+ try {
1645
+ if (fs.existsSync(filePath)) {
1646
+ return fs.statSync(filePath).mtime.toISOString();
1647
+ }
1648
+ } catch {}
1649
+ return null;
1650
+ }
1651
+
1652
+ export { router as restRouter };