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,868 @@
1
+ /**
2
+ * Total Recall — Unified Server Entry
3
+ *
4
+ * Mounts all HTTP routes on a single Express app:
5
+ * - /v1/chat/completions → API proxy (api.mjs)
6
+ * - /health → System diagnostics
7
+ * - /* → React SPA (frontend/dist/)
8
+ *
9
+ * Usage:
10
+ * node src/server/index.mjs
11
+ * PORT=3000 node src/server/index.mjs
12
+ */
13
+
14
+ import express from 'express';
15
+ import cors from 'cors';
16
+ import cookieParser from 'cookie-parser';
17
+ import path from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import zlib from 'node:zlib';
20
+ import {
21
+ apiRateLimiter,
22
+ corsOptions,
23
+ loadSecurityConfig,
24
+ requireAuth,
25
+ requireAuthOrLocal,
26
+ requireHttps,
27
+ requireScope
28
+ } from './auth.mjs';
29
+ import { logger, logEvents } from "../core/logger.mjs";
30
+ import { drainActiveEmbeddings } from './routes/sessions.mjs';
31
+ import { agentDir as configAgentDir, brainDir as configBrainDir, port as configPort, host as configHost, nodeEnv } from '../core/config.mjs';
32
+ import { getDaemonStatus, ensureDaemonRunning } from '../core/daemon-control.mjs';
33
+
34
+
35
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
36
+ const ROOT = path.join(__dirname, '..', '..');
37
+
38
+ // ─── Watchdog ───────────────────────────────────────────────────────────────────
39
+ // Attach circuit-breaker log monitor before any subsystem can emit events.
40
+ import { attachLogMonitor } from '../core/watchdog.mjs';
41
+ attachLogMonitor();
42
+
43
+ let tunnelProcess = null;
44
+
45
+ // ─── App ────────────────────────────────────────────────────────────────────────
46
+
47
+ const app = express();
48
+ app.disable('x-powered-by');
49
+ app.set('trust proxy', 'loopback');
50
+ app.use(requireHttps);
51
+
52
+ // Zero-dependency dynamic Gzip compression middleware for large API payloads
53
+ app.use((req, res, next) => {
54
+ const acceptEncoding = req.headers['accept-encoding'] || '';
55
+ if (!acceptEncoding.includes('gzip')) {
56
+ return next();
57
+ }
58
+
59
+ const oldSend = res.send;
60
+ res.send = function (body) {
61
+ if (!body || res.getHeader('Content-Encoding')) {
62
+ return oldSend.call(this, body);
63
+ }
64
+
65
+ let chunk = body;
66
+ if (typeof chunk === 'object' && !(chunk instanceof Buffer)) {
67
+ chunk = JSON.stringify(chunk);
68
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
69
+ }
70
+
71
+ const buffer = typeof chunk === 'string' ? Buffer.from(chunk) : chunk;
72
+ if (buffer.length < 1024) {
73
+ return oldSend.call(this, buffer);
74
+ }
75
+
76
+ zlib.gzip(buffer, (err, compressed) => {
77
+ if (err) {
78
+ return oldSend.call(this, buffer);
79
+ }
80
+ res.setHeader('Content-Encoding', 'gzip');
81
+ res.setHeader('Content-Length', compressed.length);
82
+ oldSend.call(this, compressed);
83
+ });
84
+ };
85
+
86
+ next();
87
+ });
88
+
89
+ app.use(cors(corsOptions()));
90
+ app.use(express.json({ limit: '50mb' }));
91
+ app.use(express.urlencoded({ limit: '50mb', extended: true }));
92
+ app.use(cookieParser());
93
+
94
+ import fs from 'node:fs';
95
+
96
+ // ─── Health Check ───────────────────────────────────────────────────────────────
97
+
98
+ app.get('/health', requireAuthOrLocal, async (req, res) => {
99
+ let disk = { free: 0, total: 0 };
100
+ try {
101
+ const stat = fs.statfsSync('/');
102
+ disk.free = stat.bavail * stat.bsize;
103
+ disk.total = stat.blocks * stat.bsize;
104
+ } catch (e) {
105
+ // ignore
106
+ }
107
+
108
+ // CLI agent availability
109
+ const { findBinaryInPath } = await import('../core/runtime.mjs');
110
+ const cliAgents = [];
111
+ for (const bin of ['antigravity', 'gemini', 'claude', 'codex']) {
112
+ if (findBinaryInPath(bin)) cliAgents.push(bin);
113
+ }
114
+
115
+ const agentDir = configAgentDir;
116
+ const brainDir = configBrainDir;
117
+ const vaultExists = fs.existsSync(path.join(brainDir, 'memory-vault'));
118
+ const skillExists = fs.existsSync(path.join(agentDir, 'skills', 'total-recall', 'SKILL.md'));
119
+
120
+ // Check emergency alerts
121
+ let emergencyAlerts = '';
122
+ try {
123
+ const alertsPath = path.join(brainDir, 'alerts', 'emergency.md');
124
+ if (fs.existsSync(alertsPath)) {
125
+ emergencyAlerts = fs.readFileSync(alertsPath, 'utf8');
126
+ }
127
+ } catch { /* non-fatal */ }
128
+
129
+ // Check daemon status
130
+ const daemonStatus = getDaemonStatus();
131
+
132
+ // Determine overall status
133
+ const hasCriticalIssue = emergencyAlerts.length > 0 || daemonStatus === 'dead' || cliAgents.length === 0;
134
+
135
+ res.json({
136
+ status: hasCriticalIssue ? 'degraded' : 'healthy',
137
+ version: '3.0.0',
138
+ uptime_seconds: Math.floor(process.uptime()),
139
+ timestamp: new Date().toISOString(),
140
+ disk,
141
+ cli_agents: cliAgents,
142
+ daemon: daemonStatus,
143
+ emergency_alerts: emergencyAlerts || null,
144
+ vfs: {
145
+ exists: vaultExists,
146
+ skill_exists: skillExists,
147
+ path: agentDir,
148
+ },
149
+ });
150
+ });
151
+
152
+ // ─── Brain Health Check (MODEL.md contract) ─────────────────────────────────────
153
+
154
+ app.get('/api/health', requireAuth, requireScope('health:read'), async (req, res) => {
155
+ const { findBinaryInPath } = await import('../core/runtime.mjs');
156
+ const agents = [];
157
+ for (const bin of ['antigravity', 'gemini', 'claude', 'codex']) {
158
+ if (findBinaryInPath(bin)) agents.push(bin);
159
+ }
160
+
161
+ if (agents.length === 0) {
162
+ return res.status(503).json({
163
+ status: 'degraded',
164
+ runtime: 'cli-agents',
165
+ uptime_seconds: Math.floor(process.uptime()),
166
+ capabilities: [],
167
+ reason: 'No CLI agents found. Install antigravity, claude, or codex.',
168
+ });
169
+ }
170
+
171
+ res.json({
172
+ status: 'ok',
173
+ runtime: 'cli-agents',
174
+ agents,
175
+ uptime_seconds: Math.floor(process.uptime()),
176
+ capabilities: [
177
+ 'text-generation',
178
+ 'code-generation',
179
+ 'research-synthesis',
180
+ 'structured-output',
181
+ ],
182
+ });
183
+ });
184
+
185
+ // ─── REST API (/api/*, /v1/models, /.well-known/*) ───────────────────────────
186
+
187
+ try {
188
+ const { restRouter } = await import('./rest.mjs');
189
+ // Throttle every authenticated /api/* route with the same limiter that
190
+ // already guards /v1/*. Resource-specific limiters (sandbox, ingest) sit
191
+ // inline on those handlers and stack on top of this one.
192
+ app.use('/api', apiRateLimiter());
193
+ app.use(restRouter);
194
+ logger.info('server', 'REST API mounted (/api/*, /v1/models, /.well-known/total-recall.json)');
195
+ } catch (err) {
196
+ logger.error('server', `REST API failed to load: ${err.message}`);
197
+ }
198
+
199
+ // ─── API Routes (/v1/chat/completions) ──────────────────────────────────────────
200
+
201
+ try {
202
+ const { apiRouter } = await import('./api.mjs');
203
+ if (apiRouter) {
204
+ app.use(apiRouter);
205
+ logger.info('server', 'API routes mounted at /v1/chat/completions');
206
+ }
207
+ } 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
+ });
262
+ }
263
+
264
+
265
+
266
+ // ─── Static Frontend (SPA catch-all) ────────────────────────────────────────────
267
+
268
+ const frontendDist = path.join(ROOT, 'frontend', 'dist');
269
+
270
+ // Auto-build frontend if dist/ doesn't exist
271
+ if (!fs.existsSync(path.join(frontendDist, 'index.html'))) {
272
+ const frontendDir = path.join(ROOT, 'frontend');
273
+ if (fs.existsSync(path.join(frontendDir, 'package.json'))) {
274
+ log.info('Frontend not built. Building automatically...');
275
+ try {
276
+ const { execSync } = await import('node:child_process');
277
+ execSync('npm install --no-audit --no-fund 2>/dev/null && npm run build', {
278
+ cwd: frontendDir,
279
+ stdio: 'pipe',
280
+ timeout: 120_000,
281
+ });
282
+ log.info('Frontend build complete.');
283
+ } catch (err) {
284
+ log.warn(`Frontend auto-build failed: ${err.message?.split('\\n')[0] || 'unknown error'}. Dashboard will show API info instead.`);
285
+ }
286
+ }
287
+ }
288
+
289
+ app.use(express.static(frontendDist));
290
+
291
+ // ─── Built-in Chat UI (/chat) ────────────────────────────────────────────────────
292
+ // Self-contained, no build step required. Works immediately after `npx total-recall start`.
293
+
294
+ import { createRequire } from 'node:module';
295
+ import os from 'node:os';
296
+
297
+ app.get('/chat', (req, res) => {
298
+ const agentDir = configAgentDir;
299
+ const instructionsPath = path.join(configBrainDir, 'INSTRUCTIONS.md');
300
+ const hasInstructions = fs.existsSync(instructionsPath);
301
+ const nodeCount = (() => {
302
+ try {
303
+ const vaultDir = path.join(configBrainDir, 'memory-vault', 'active');
304
+ if (!fs.existsSync(vaultDir)) return 0;
305
+ return fs.readdirSync(vaultDir).filter(f => f.endsWith('.md')).length;
306
+ } catch { return 0; }
307
+ })();
308
+
309
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
310
+ res.send(`<!DOCTYPE html>
311
+ <html lang="en">
312
+ <head>
313
+ <meta charset="UTF-8">
314
+ <meta name="viewport" content="width=device-width,initial-scale=1">
315
+ <title>Total Recall — Chat</title>
316
+ <style>
317
+ :root{--bg:#0d1117;--surface:#161b22;--border:#30363d;--text:#e6edf3;--muted:#8b949e;--blue:#58a6ff;--green:#3fb950;--purple:#bc8cff;--orange:#f0883e;--red:#f85149;}
318
+ *{box-sizing:border-box;margin:0;padding:0;}
319
+ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:var(--bg);color:var(--text);height:100vh;display:flex;flex-direction:column;}
320
+ header{background:var(--surface);border-bottom:1px solid var(--border);padding:12px 20px;display:flex;align-items:center;gap:12px;flex-shrink:0;}
321
+ header h1{font-size:16px;font-weight:700;letter-spacing:-0.3px;}
322
+ .badge{font-size:11px;padding:2px 8px;border-radius:10px;background:rgba(88,166,255,0.15);color:var(--blue);border:1px solid rgba(88,166,255,0.3);}
323
+ .mode-bar{display:flex;gap:4px;margin-left:auto;align-items:center;}
324
+ .mode-btn{background:none;border:1px solid var(--border);color:var(--muted);padding:4px 12px;border-radius:6px;font-size:12px;cursor:pointer;transition:all 0.15s;}
325
+ .mode-btn.active{background:var(--blue);border-color:var(--blue);color:#0d1117;font-weight:600;}
326
+ .search-toggle{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted);cursor:pointer;margin-left:12px;border-left:1px solid var(--border);padding-left:12px;}
327
+ .search-toggle input{width:14px;height:14px;cursor:pointer;accent-color:var(--blue);}
328
+ .search-toggle.on{color:var(--green);}
329
+ .messages{flex:1;overflow-y:auto;padding:20px;display:flex;flex-direction:column;gap:16px;}
330
+ .msg{display:flex;gap:12px;max-width:780px;width:100%;margin:0 auto;}
331
+ .msg.user{flex-direction:row-reverse;}
332
+ .avatar{width:32px;height:32px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:14px;flex-shrink:0;}
333
+ .msg.user .avatar{background:var(--blue);color:#0d1117;}
334
+ .msg.assistant .avatar{background:var(--purple);color:#0d1117;}
335
+ .bubble{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:12px 16px;font-size:14px;line-height:1.6;max-width:calc(100% - 44px);}
336
+ .msg.user .bubble{background:rgba(88,166,255,0.1);border-color:rgba(88,166,255,0.2);}
337
+ .bubble pre{background:rgba(0,0,0,0.3);border:1px solid var(--border);border-radius:6px;padding:10px;overflow-x:auto;margin:8px 0;}
338
+ .bubble code{font-family:'SF Mono',Menlo,monospace;font-size:12px;}
339
+ .bubble p{margin-bottom:8px;}
340
+ .bubble p:last-child{margin-bottom:0;}
341
+ .typing{display:flex;gap:4px;align-items:center;padding:4px 0;}
342
+ .typing span{width:6px;height:6px;border-radius:50%;background:var(--muted);animation:bounce 1.2s infinite;}
343
+ .typing span:nth-child(2){animation-delay:0.2s;}
344
+ .typing span:nth-child(3){animation-delay:0.4s;}
345
+ @keyframes bounce{0%,60%,100%{transform:translateY(0);}30%{transform:translateY(-6px);}}
346
+ .input-area{border-top:1px solid var(--border);padding:16px 20px;background:var(--surface);flex-shrink:0;}
347
+ .input-row{max-width:780px;margin:0 auto;display:flex;gap:10px;align-items:flex-end;}
348
+ textarea{flex:1;background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:10px 14px;color:var(--text);font-size:14px;resize:none;min-height:44px;max-height:180px;line-height:1.5;font-family:inherit;outline:none;transition:border-color 0.15s;}
349
+ textarea:focus{border-color:var(--blue);}
350
+ .send-btn{background:var(--blue);color:#0d1117;border:none;border-radius:10px;width:44px;height:44px;display:flex;align-items:center;justify-content:center;cursor:pointer;font-size:18px;flex-shrink:0;transition:opacity 0.15s;}
351
+ .send-btn:disabled{opacity:0.4;cursor:not-allowed;}
352
+ .status-bar{text-align:center;font-size:11px;color:var(--muted);margin-top:8px;}
353
+ .token-prompt{position:fixed;inset:0;background:rgba(0,0,0,0.8);display:flex;align-items:center;justify-content:center;z-index:100;}
354
+ .token-card{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:28px;max-width:400px;width:90%;}
355
+ .token-card h2{margin-bottom:8px;}
356
+ .token-card p{color:var(--muted);font-size:13px;margin-bottom:16px;}
357
+ .token-card input{width:100%;background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:10px;color:var(--text);font-family:monospace;font-size:13px;margin-bottom:12px;outline:none;}
358
+ .token-card input:focus{border-color:var(--blue);}
359
+ .token-card button{background:var(--blue);color:#0d1117;border:none;border-radius:8px;padding:10px 20px;font-weight:600;cursor:pointer;width:100%;}
360
+ .system-notice{background:rgba(188,140,255,0.08);border:1px solid rgba(188,140,255,0.2);border-radius:8px;padding:10px 14px;font-size:12px;color:var(--muted);max-width:780px;margin:0 auto 4px;display:flex;gap:8px;align-items:flex-start;}
361
+ .system-notice strong{color:var(--purple);}
362
+ #no-token{display:${hasInstructions ? 'none' : 'none'};} /* always hidden initially */
363
+ </style>
364
+ </head>
365
+ <body>
366
+
367
+ <header>
368
+ <span>⚡</span>
369
+ <h1>Total Recall</h1>
370
+ <span class="badge">${nodeCount} memories</span>
371
+ <div class="mode-bar">
372
+ <button class="mode-btn active" id="mode-knowledge" onclick="setMode('knowledge')">🧠 Knowledge</button>
373
+ <button class="mode-btn" id="mode-journal" onclick="setMode('journal')">📓 Journal</button>
374
+ <button class="mode-btn" id="mode-reflect" onclick="setMode('reflect')">🔮 Reflect</button>
375
+ <label class="search-toggle" id="search-toggle-label" title="Let the AI search the web during this conversation">
376
+ <input type="checkbox" id="search-toggle" onchange="updateSearchToggle()">
377
+ 🌐 Web search
378
+ </label>
379
+ </div>
380
+ </header>
381
+
382
+ <div class="messages" id="messages">
383
+ <div class="system-notice" id="mode-notice">
384
+ <strong>Knowledge mode</strong>&nbsp;— Ask anything. I'll answer from your memory first, then reason from what I know.
385
+ </div>
386
+ </div>
387
+
388
+ <div class="input-area">
389
+ <div class="input-row">
390
+ <textarea id="input" placeholder="Ask anything…" rows="1" onkeydown="handleKey(event)" oninput="autoResize(this)"></textarea>
391
+ <button class="send-btn" id="send-btn" onclick="sendMessage()" title="Send (Enter)">↑</button>
392
+ </div>
393
+ <div class="status-bar" id="status-bar">Connected to brain at ${req.headers.host || 'localhost:3000'}</div>
394
+ </div>
395
+
396
+ <div class="token-prompt" id="token-prompt" style="display:none">
397
+ <div class="token-card">
398
+ <h2>🔑 Access Token</h2>
399
+ <p>Enter your Personal Access Token to start chatting.</p>
400
+ <p style="font-size:12px;color:#8b949e;margin-top:6px">
401
+ Don't have one? Run this in your terminal:<br>
402
+ <code style="display:block;margin-top:6px;padding:6px 10px;background:#010409;border-radius:4px;font-size:11px;color:#58a6ff">npx total-recall generate-pat</code>
403
+ Then paste the token starting with <code>tr-</code> below.
404
+ </p>
405
+ <input type="password" id="token-input" placeholder="tr-…" autocomplete="off">
406
+ <button onclick="saveToken()">Connect →</button>
407
+ </div>
408
+ </div>
409
+
410
+ <script>
411
+ (function() {
412
+ var MODE = 'knowledge';
413
+ var history = [];
414
+ var streaming = false;
415
+
416
+ var MODE_PROMPTS = {
417
+ knowledge: 'You are the user\\'s personal knowledge assistant. You have access to everything they have chosen to remember. Answer questions by drawing on their memory vault first. Be specific, cite what you know, and clearly distinguish between things in their memory versus your general knowledge. Today is ' + new Date().toLocaleDateString('en-US', {weekday:'long',year:'numeric',month:'long',day:'numeric'}) + '.',
418
+ journal: 'You are a private, non-judgmental journal companion. The user is writing privately — these entries are personal and will not be shared with other AI tools or surfaces. Be warm, reflective, and supportive. Ask thoughtful follow-up questions. Today is ' + new Date().toLocaleDateString('en-US', {weekday:'long',year:'numeric',month:'long',day:'numeric'}) + '.',
419
+ reflect: 'You are a reflective intelligence reviewing what the user\\'s memory system has accumulated over time. Surface patterns, connections, and insights across their stored knowledge. Identify gaps, contradictions, and opportunities to build richer understanding. Today is ' + new Date().toLocaleDateString('en-US', {weekday:'long',year:'numeric',month:'long',day:'numeric'}) + '.',
420
+ };
421
+
422
+ var MODE_NOTICES = {
423
+ knowledge: '<strong>Knowledge mode</strong>&nbsp;— Ask anything. I\\'ll answer from your memory first, then reason from what I know.',
424
+ journal: '<strong>Journal mode</strong>&nbsp;— Private space. These entries stay here and won\\'t be surfaced to your IDE tools.',
425
+ reflect: '<strong>Reflect mode</strong>&nbsp;— I\\'ll surface patterns and insights across everything you\\'ve remembered.',
426
+ };
427
+
428
+ window.setMode = function(m) {
429
+ MODE = m;
430
+ document.querySelectorAll('.mode-btn').forEach(function(b) { b.classList.remove('active'); });
431
+ document.getElementById('mode-' + m).classList.add('active');
432
+ document.getElementById('mode-notice').innerHTML = MODE_NOTICES[m];
433
+ history = []; // fresh context per mode
434
+ document.getElementById('messages').querySelectorAll('.msg').forEach(function(el) { el.remove(); });
435
+ };
436
+
437
+ window.updateSearchToggle = function() {
438
+ var on = document.getElementById('search-toggle').checked;
439
+ document.getElementById('search-toggle-label').className = 'search-toggle' + (on ? ' on' : '');
440
+ };
441
+
442
+ function getToken() {
443
+ return localStorage.getItem('tr_pat') || '';
444
+ }
445
+
446
+ window.saveToken = function() {
447
+ var t = document.getElementById('token-input').value.trim();
448
+ if (!t) return;
449
+ localStorage.setItem('tr_pat', t);
450
+ document.getElementById('token-prompt').style.display = 'none';
451
+ appendSystemMsg('Connected. Ready to chat.');
452
+ };
453
+
454
+ function checkToken() {
455
+ if (!getToken()) {
456
+ document.getElementById('token-prompt').style.display = '';
457
+ }
458
+ }
459
+
460
+ function appendMsg(role, content) {
461
+ var msgs = document.getElementById('messages');
462
+ var div = document.createElement('div');
463
+ div.className = 'msg ' + role;
464
+ div.innerHTML = '<div class="avatar">' + (role === 'user' ? '👤' : '⚡') + '</div>' +
465
+ '<div class="bubble" id="msg-' + Date.now() + '">' + renderMarkdown(content) + '</div>';
466
+ msgs.appendChild(div);
467
+ msgs.scrollTop = msgs.scrollHeight;
468
+ return div.querySelector('.bubble');
469
+ }
470
+
471
+ function appendSystemMsg(text) {
472
+ var msgs = document.getElementById('messages');
473
+ var div = document.createElement('div');
474
+ div.style.cssText = 'text-align:center;font-size:11px;color:var(--muted);padding:4px;';
475
+ div.textContent = text;
476
+ msgs.appendChild(div);
477
+ msgs.scrollTop = msgs.scrollHeight;
478
+ }
479
+
480
+ function appendTyping() {
481
+ var msgs = document.getElementById('messages');
482
+ var div = document.createElement('div');
483
+ div.className = 'msg assistant';
484
+ div.id = 'typing-indicator';
485
+ div.innerHTML = '<div class="avatar">⚡</div><div class="bubble"><div class="typing"><span></span><span></span><span></span></div></div>';
486
+ msgs.appendChild(div);
487
+ msgs.scrollTop = msgs.scrollHeight;
488
+ return div;
489
+ }
490
+
491
+ function renderMarkdown(text) {
492
+ return text
493
+ .replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
494
+ .replace(/\`\`\`([\\s\\S]*?)\`\`\`/g, '<pre><code>$1</code></pre>')
495
+ .replace(/\`([^\`]+)\`/g, '<code>$1</code>')
496
+ .replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>')
497
+ .replace(/\\*([^*]+)\\*/g, '<em>$1</em>')
498
+ .replace(/^### (.+)$/gm, '<h3>$1</h3>')
499
+ .replace(/^## (.+)$/gm, '<h2>$1</h2>')
500
+ .replace(/^# (.+)$/gm, '<h1>$1</h1>')
501
+ .replace(/\\n/g, '<br>');
502
+ }
503
+
504
+ window.handleKey = function(e) {
505
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
506
+ };
507
+
508
+ window.autoResize = function(el) {
509
+ el.style.height = 'auto';
510
+ el.style.height = Math.min(el.scrollHeight, 180) + 'px';
511
+ };
512
+
513
+ window.sendMessage = async function() {
514
+ if (streaming) return;
515
+ var input = document.getElementById('input');
516
+ var text = input.value.trim();
517
+ if (!text) return;
518
+
519
+ var token = getToken();
520
+ if (!token) { document.getElementById('token-prompt').style.display = ''; return; }
521
+
522
+ input.value = '';
523
+ input.style.height = 'auto';
524
+ appendMsg('user', text);
525
+
526
+ history.push({ role: 'user', content: text });
527
+
528
+ var useSearch = document.getElementById('search-toggle').checked;
529
+ var systemPrompt = MODE_PROMPTS[MODE];
530
+ if (useSearch) {
531
+ systemPrompt += '\\n\\nYou have access to web search in this conversation. When the user asks about current events, recent information, or anything you\\'re uncertain about, search the web before answering. Use the search_web tool.';
532
+ }
533
+
534
+ var typing = appendTyping();
535
+ streaming = true;
536
+ document.getElementById('send-btn').disabled = true;
537
+ document.getElementById('status-bar').textContent = 'Thinking…';
538
+
539
+ try {
540
+ var resp = await fetch('/v1/chat/completions', {
541
+ method: 'POST',
542
+ headers: {
543
+ 'Content-Type': 'application/json',
544
+ 'Authorization': 'Bearer ' + token,
545
+ },
546
+ body: JSON.stringify({
547
+ model: 'default',
548
+ messages: [{ role: 'system', content: systemPrompt }].concat(history),
549
+ stream: false,
550
+ }),
551
+ });
552
+
553
+ if (resp.status === 401) {
554
+ localStorage.removeItem('tr_pat');
555
+ document.getElementById('token-prompt').style.display = '';
556
+ typing.remove();
557
+ return;
558
+ }
559
+
560
+ if (!resp.ok) {
561
+ var errBody = await resp.text();
562
+ typing.remove();
563
+ appendSystemMsg('Error ' + resp.status + ': ' + errBody.slice(0,200));
564
+ return;
565
+ }
566
+
567
+ var data = await resp.json();
568
+ var content = data.choices?.[0]?.message?.content || '(no response)';
569
+ history.push({ role: 'assistant', content: content });
570
+
571
+ typing.remove();
572
+ appendMsg('assistant', content);
573
+ document.getElementById('status-bar').textContent = 'Ready — ' + history.length / 2 + ' exchange' + (history.length > 2 ? 's' : '') + ' this session';
574
+ } catch(e) {
575
+ typing.remove();
576
+ appendSystemMsg('Failed to reach brain: ' + e.message);
577
+ document.getElementById('status-bar').textContent = 'Error — check brain is running';
578
+ } finally {
579
+ streaming = false;
580
+ document.getElementById('send-btn').disabled = false;
581
+ document.getElementById('input').focus();
582
+ }
583
+ };
584
+
585
+ checkToken();
586
+ document.getElementById('input').focus();
587
+ })();
588
+ </script>
589
+ </body>
590
+ </html>`);
591
+ });
592
+
593
+ // SPA fallback — serve index.html for all unmatched routes
594
+ app.get(/^(.*)$/, (req, res) => {
595
+ const indexPath = path.join(frontendDist, 'index.html');
596
+ res.sendFile(indexPath, (err) => {
597
+ if (err) {
598
+ // Frontend not built yet — return a helpful message
599
+ res.status(200).json({
600
+ message: 'Total Recall Brain is running.',
601
+ endpoints: {
602
+ api: 'POST /v1/chat/completions',
603
+ memory: 'GET /api/memory',
604
+ sandbox: 'POST /api/sandbox',
605
+ health: 'GET /health',
606
+ dashboard: 'Build frontend first: cd frontend && npm run build'
607
+ }
608
+ });
609
+ }
610
+ });
611
+ });
612
+
613
+ // ─── Start ──────────────────────────────────────────────────────────────────────
614
+
615
+ const serverSecurityConfig = loadSecurityConfig();
616
+ const PORT = configPort || serverSecurityConfig.bind?.port || 3000;
617
+ const configuredHost = configHost || serverSecurityConfig.bind?.host || '127.0.0.1';
618
+ const publicBindRequested = configuredHost === '0.0.0.0' || configuredHost === '::';
619
+ const HOST = nodeEnv === 'production' && publicBindRequested && serverSecurityConfig.bind?.allow_public_bind !== true
620
+ ? '127.0.0.1'
621
+ : configuredHost;
622
+
623
+ const server = app.listen(PORT, HOST, () => {
624
+ if (HOST !== configuredHost) {
625
+ logger.error("server", `Refusing public bind '${configuredHost}' in production. Bound to ${HOST}.`);
626
+ }
627
+ logger.info("server", `Total Recall Brain v3.0.0 is listening on http://${HOST}:${PORT}`);
628
+
629
+ logger.info("server", "┌─────────────────────────────────────────────┐");
630
+ logger.info("server", "│ Total Recall Brain v3.0.0 │");
631
+ logger.info("server", "│ │");
632
+ logger.info("server", `│ API: http://${HOST}:${PORT}/v1/chat/completions │`);
633
+ logger.info("server", `│ Memory: http://${HOST}:${PORT}/api/memory │`);
634
+ logger.info("server", `│ Health: http://${HOST}:${PORT}/health │`);
635
+ logger.info("server", `│ Dashboard: http://${HOST}:${PORT}/ │`);
636
+ logger.info("server", "└─────────────────────────────────────────────┘");
637
+
638
+ // ─── Live Agent Monitor Server (Port 9111) ───
639
+ const monitorApp = express();
640
+ monitorApp.use(cors(corsOptions()));
641
+
642
+ let sseClients = [];
643
+
644
+ logEvents.on("log", (entry) => {
645
+ const sseMessage = `data: ${JSON.stringify(entry)}\n\n`;
646
+ sseClients.forEach(res => {
647
+ try {
648
+ res.write(sseMessage);
649
+ } catch (err) {
650
+ // failed write
651
+ }
652
+ });
653
+ });
654
+
655
+ monitorApp.get("/stream", (req, res) => {
656
+ res.writeHead(200, {
657
+ "Content-Type": "text/event-stream",
658
+ "Cache-Control": "no-cache",
659
+ "Connection": "keep-alive"
660
+ });
661
+ res.write("\n"); // keep-alive initial chunk
662
+
663
+ sseClients.push(res);
664
+
665
+ req.on("close", () => {
666
+ sseClients = sseClients.filter(c => c !== res);
667
+ });
668
+ });
669
+
670
+ const monitorServer = monitorApp.listen(9111, HOST, () => {
671
+ logger.info("server", `Live Agent Monitor listening on http://${HOST}:9111/stream`);
672
+ });
673
+
674
+ // Track monitorServer for graceful shutdown
675
+ server.on("close", () => {
676
+ monitorServer.close();
677
+ });
678
+
679
+ // Unified Background Daemon Auto-Start & Watchdog
680
+ if (process.env.DISABLE_DAEMON !== 'true' && nodeEnv !== 'test') {
681
+ // 1. Auto-start on boot
682
+ ensureDaemonRunning()
683
+ .then((pid) => {
684
+ logger.info('server', `Daemon auto-started successfully on PID ${pid}.`);
685
+ })
686
+ .catch((err) => {
687
+ logger.error('server', `Failed to auto-start daemon on boot: ${err.message}`);
688
+ });
689
+
690
+ // 2. Periodic self-healing watchdog every 60 seconds
691
+ const daemonWatchdogInterval = setInterval(async () => {
692
+ try {
693
+ const status = getDaemonStatus();
694
+ if (status === 'dead' || status === 'not_started') {
695
+ logger.warn('server', `Daemon status is '${status}'. Watchdog initiating self-healing auto-restart...`);
696
+ await ensureDaemonRunning();
697
+ }
698
+ } catch (err) {
699
+ logger.error('server', `Daemon self-healing watchdog failed: ${err.message}`);
700
+ }
701
+ }, 60000);
702
+
703
+ // Unref the interval so it doesn't block process shutdown
704
+ daemonWatchdogInterval.unref();
705
+ }
706
+
707
+ // ─── Cloudflare Tunnel Auto-Start ───
708
+ (async () => {
709
+ try {
710
+ const configDir = path.join(configBrainDir, 'config');
711
+ const configFile = path.join(configDir, 'wizard-config.json');
712
+ if (!fs.existsSync(configFile)) return;
713
+
714
+ const wizardCfg = JSON.parse(fs.readFileSync(configFile, 'utf8') || '{}');
715
+ const deployMode = wizardCfg['deploy-mode'];
716
+ const autoStart = wizardCfg['tunnel-auto-start'];
717
+
718
+ if (!['quick-tunnel', 'named-tunnel'].includes(deployMode) || autoStart === false) {
719
+ return;
720
+ }
721
+
722
+ const logsDir = path.join(configBrainDir, 'logs');
723
+ fs.mkdirSync(logsDir, { recursive: true });
724
+ const cfLogPath = path.join(logsDir, 'cloudflared.log');
725
+ const cfPidPath = path.join(logsDir, 'cloudflared.pid');
726
+
727
+ // Check if another process is already alive on that PID
728
+ if (fs.existsSync(cfPidPath)) {
729
+ try {
730
+ const oldPid = parseInt(fs.readFileSync(cfPidPath, 'utf8').trim(), 10);
731
+ if (oldPid) {
732
+ process.kill(oldPid, 0);
733
+ logger.info('server', `Tunnel process is already running on PID ${oldPid}. Skipping spawn.`);
734
+ return;
735
+ }
736
+ } catch {
737
+ // PID is stale, proceed with spawn
738
+ }
739
+ }
740
+
741
+ try {
742
+ if (fs.existsSync(cfLogPath)) {
743
+ fs.unlinkSync(cfLogPath);
744
+ }
745
+ } catch {}
746
+
747
+ const logStream = fs.openSync(cfLogPath, 'w');
748
+ let args = [];
749
+ if (deployMode === 'quick-tunnel') {
750
+ args = ['tunnel', '--url', `http://localhost:${PORT}`];
751
+ } else {
752
+ const tunnelName = wizardCfg['cfg-cloudflare-tunnel-name'];
753
+ const credPath = wizardCfg['cfg-cloudflare-tunnel-credentials'];
754
+ if (!tunnelName || !credPath) {
755
+ logger.warn('server', 'Named tunnel config is incomplete. Missing name or credentials.');
756
+ return;
757
+ }
758
+ args = ['tunnel', '--credentials-file', credPath, 'run', tunnelName];
759
+ }
760
+
761
+ logger.info('server', `Spawning Cloudflare Tunnel (${deployMode}) with arguments: ${args.join(' ')}`);
762
+
763
+ const { spawn } = await import('node:child_process');
764
+ tunnelProcess = spawn('cloudflared', args, {
765
+ detached: true,
766
+ stdio: ['ignore', logStream, logStream]
767
+ });
768
+
769
+ fs.writeFileSync(cfPidPath, String(tunnelProcess.pid), 'utf8');
770
+ tunnelProcess.unref();
771
+
772
+ tunnelProcess.on('exit', (code) => {
773
+ logger.warn('server', `Cloudflare Tunnel process (PID ${tunnelProcess?.pid}) exited with code ${code}`);
774
+ tunnelProcess = null;
775
+ try {
776
+ fs.unlinkSync(cfPidPath);
777
+ } catch {}
778
+ });
779
+
780
+ if (deployMode === 'quick-tunnel') {
781
+ logger.info('server', 'Waiting for Cloudflare Quick Tunnel URL allocation...');
782
+ let tunnelUrl = '';
783
+ for (let attempt = 0; attempt < 20; attempt++) {
784
+ await new Promise(resolve => setTimeout(resolve, 500));
785
+ if (fs.existsSync(cfLogPath)) {
786
+ const logs = fs.readFileSync(cfLogPath, 'utf8');
787
+ const match = logs.match(/https:\/\/[a-zA-Z0-9-]+\.trycloudflare\.com/);
788
+ if (match) {
789
+ tunnelUrl = match[0];
790
+ break;
791
+ }
792
+ }
793
+ }
794
+
795
+ if (tunnelUrl) {
796
+ logger.info('server', `Cloudflare Quick Tunnel allocated successfully: ${tunnelUrl}`);
797
+
798
+ // Update wizard-config.json
799
+ wizardCfg['cfg-domain'] = tunnelUrl.replace('https://', '');
800
+ wizardCfg['cfg-api-url'] = tunnelUrl;
801
+ wizardCfg['cfg-dash-url'] = `${tunnelUrl}/dashboard`;
802
+ wizardCfg['cfg-health-url'] = `${tunnelUrl}/health`;
803
+
804
+ fs.writeFileSync(configFile, JSON.stringify(wizardCfg, null, 2), { encoding: 'utf8', mode: 0o600 });
805
+ logger.info('server', `Updated wizard-config.json with active Quick Tunnel URL: ${tunnelUrl}`);
806
+ } else {
807
+ logger.warn('server', 'Could not allocate Quick Tunnel URL. Please check logs in brainDir/logs/cloudflared.log');
808
+ }
809
+ } else {
810
+ const hostname = wizardCfg['cfg-domain'] || 'configured domain';
811
+ logger.info('server', `Cloudflare Named Tunnel is active. Dashboard is accessible via: https://${hostname}`);
812
+ }
813
+ } catch (err) {
814
+ logger.error('server', `Failed to auto-start Cloudflare Tunnel: ${err.message}`);
815
+ }
816
+ })();
817
+ });
818
+
819
+ let shutdownInProgress = false;
820
+
821
+ async function handleShutdown(signal) {
822
+ if (shutdownInProgress) return;
823
+ shutdownInProgress = true;
824
+ process.isShuttingDown = true;
825
+
826
+ logger.info('server', `Received ${signal}. Starting graceful shutdown...`);
827
+
828
+ if (tunnelProcess) {
829
+ logger.info('server', `Terminating active Cloudflare Tunnel process (PID ${tunnelProcess.pid})...`);
830
+ try {
831
+ tunnelProcess.kill('SIGTERM');
832
+ } catch (e) {
833
+ logger.error('server', `Error terminating Cloudflare Tunnel: ${e.message}`);
834
+ }
835
+ }
836
+
837
+ // 10s fallback hard-exit
838
+ const forceExitTimeout = setTimeout(() => {
839
+ logger.error('server', 'Graceful shutdown timed out. Forcefully exiting.');
840
+ process.exit(1);
841
+ }, 10000);
842
+ forceExitTimeout.unref();
843
+
844
+ // Close HTTP server to stop accepting new connections
845
+ server.close(async (err) => {
846
+ if (err) {
847
+ logger.error('server', 'Error closing HTTP server', { error: err.message });
848
+ } else {
849
+ logger.info('server', 'HTTP server closed.');
850
+ }
851
+
852
+ try {
853
+ logger.info('server', 'Draining active background operations...');
854
+ await drainActiveEmbeddings();
855
+ logger.info('server', 'All active background operations drained.');
856
+ } catch (e) {
857
+ logger.error('server', 'Error draining active background operations', { error: e.message });
858
+ }
859
+
860
+ clearTimeout(forceExitTimeout);
861
+ logger.info('server', 'Graceful shutdown complete. Exiting.');
862
+ process.exit(0);
863
+ });
864
+ }
865
+
866
+ process.on('SIGTERM', () => handleShutdown('SIGTERM'));
867
+ process.on('SIGINT', () => handleShutdown('SIGINT'));
868
+