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.
- package/LICENSE +21 -0
- package/README.md +173 -0
- package/bin/antigravity.mjs +212 -0
- package/bin/init.mjs +36 -0
- package/bin/oci-sniper.sh +69 -0
- package/bin/total-recall.mjs +229 -0
- package/docs/ARCHITECTURE.md +251 -0
- package/docs/IDE_INTEGRATION.md +112 -0
- package/package.json +71 -0
- package/scaffold/.agent/skills/total-recall/SKILL.md +195 -0
- package/scaffold/.agent/skills/total-recall/evals/evals.json +20 -0
- package/scaffold/.agent/skills/total-recall/memory-vault/concepts/cli-help-reference.md +145 -0
- package/scaffold/.agent/skills/total-recall/memory-vault/invariants/operating-instructions.md +75 -0
- package/scaffold/.agent/skills/total-recall/memory-vault/preferences/always-websearch-gap.md +16 -0
- package/scaffold/.agent/skills/total-recall/memory-vault/preferences/topic-research-sop.md +17 -0
- package/scaffold/.agent/skills/total-recall/references/api-reference.md +187 -0
- package/scaffold/.agent/skills/total-recall/references/architecture-reference.md +81 -0
- package/scaffold/.agent/skills/total-recall/references/cli-reference.md +103 -0
- package/scaffold/.agent/skills/total-recall/references/ssss-reference.md +88 -0
- package/scaffold/.agent/skills/total-recall/references/troubleshooting.md +68 -0
- package/scaffold/.agent/skills/total-recall/scripts/sync-repo.mjs +83 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/SKILL.md +122 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/agents.yml +27 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/antigravity.txt +20 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/claude.txt +58 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/codex.txt +81 -0
- package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/gemini.txt +44 -0
- package/scaffold/.agent/skills/total-recall/skills/research/SKILL.md +337 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/SKILL.md +281 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/assets/skill-template/SKILL.md +20 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/evals/evals.json +20 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/hooks/on-skill-add.sh +14 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/hooks/on-skill-delete.sh +14 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/learnings/.gitkeep +0 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/references/.gitkeep +0 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/.gitkeep +0 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/create-skill.sh +65 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/enforce-skill-optimization.mjs +135 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/find-skills.mjs +124 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/scan-skill.mjs +207 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/scripts/skill-watcher.mjs +64 -0
- package/scaffold/.agent/skills/total-recall/skills/skill/subagents/audit-all-skills.md +62 -0
- package/scaffold/.agent/skills/total-recall/skills/ssss/SKILL.md +27 -0
- package/scaffold/.agent/skills/total-recall/skills/ssss/evals/evals.json +17 -0
- package/scaffold/.agent/skills/total-recall/skills/ssss/references/ssss-spec.md +117 -0
- package/scaffold/.agent/skills/total-recall/skills/ssss/scripts/validate-schema.mjs +54 -0
- package/scaffold/.agent/skills/total-recall/skills/ssss/subagents/audit-ssss-node.md +9 -0
- package/scaffold/.agent/skills/total-recall/subagents/total-recall-diagnostician.md +13 -0
- package/src/cli/agent-dir.mjs +105 -0
- package/src/cli/backup.mjs +313 -0
- package/src/cli/brain.mjs +224 -0
- package/src/cli/chat.mjs +74 -0
- package/src/cli/config.mjs +247 -0
- package/src/cli/connect.mjs +872 -0
- package/src/cli/daemon.mjs +228 -0
- package/src/cli/deploy-ui.mjs +1793 -0
- package/src/cli/deploy.mjs +1726 -0
- package/src/cli/doctor.mjs +254 -0
- package/src/cli/dream.mjs +80 -0
- package/src/cli/forget.mjs +92 -0
- package/src/cli/friction.mjs +21 -0
- package/src/cli/generate-pat.mjs +65 -0
- package/src/cli/hash-password.mjs +22 -0
- package/src/cli/help.mjs +153 -0
- package/src/cli/import-rules.mjs +102 -0
- package/src/cli/ingest/index.mjs +318 -0
- package/src/cli/ingest/parsers/chrome-bookmarks.mjs +238 -0
- package/src/cli/ingest/parsers/google-keep.mjs +147 -0
- package/src/cli/ingest/parsers/search-history.mjs +142 -0
- package/src/cli/ingest/parsers/youtube-history.mjs +181 -0
- package/src/cli/ingest/utils/dedup.mjs +69 -0
- package/src/cli/ingest/utils/takeout-walker.mjs +101 -0
- package/src/cli/ingest.mjs +102 -0
- package/src/cli/init.mjs +736 -0
- package/src/cli/integration-dispatcher.mjs +128 -0
- package/src/cli/lint.mjs +166 -0
- package/src/cli/map.mjs +194 -0
- package/src/cli/migrate.mjs +87 -0
- package/src/cli/rebuild.mjs +88 -0
- package/src/cli/recall.mjs +219 -0
- package/src/cli/relay.mjs +518 -0
- package/src/cli/remember.mjs +306 -0
- package/src/cli/research.mjs +381 -0
- package/src/cli/reset-password.mjs +75 -0
- package/src/cli/restore.mjs +135 -0
- package/src/cli/setup.mjs +616 -0
- package/src/cli/share.mjs +180 -0
- package/src/cli/skill.mjs +313 -0
- package/src/cli/snapshot.mjs +108 -0
- package/src/cli/start.mjs +63 -0
- package/src/cli/status.mjs +296 -0
- package/src/cli/sync.mjs +383 -0
- package/src/cli/uninstall.mjs +613 -0
- package/src/cli/upgrade.mjs +141 -0
- package/src/cli/wizard.html +3682 -0
- package/src/core/blackboard.mjs +54 -0
- package/src/core/clarity-rewriter.mjs +770 -0
- package/src/core/conclusion-writer.mjs +259 -0
- package/src/core/config.mjs +277 -0
- package/src/core/conflict-detector.mjs +536 -0
- package/src/core/crypto.mjs +107 -0
- package/src/core/daemon-control.mjs +158 -0
- package/src/core/daemon-loop.mjs +364 -0
- package/src/core/dream.mjs +347 -0
- package/src/core/drift-detector.mjs +143 -0
- package/src/core/embeddings.mjs +632 -0
- package/src/core/emergency-alerts.mjs +137 -0
- package/src/core/evolution.mjs +107 -0
- package/src/core/fact-seeker.mjs +1602 -0
- package/src/core/friction.mjs +95 -0
- package/src/core/import-rules.mjs +308 -0
- package/src/core/inference-engine.mjs +367 -0
- package/src/core/logger.mjs +75 -0
- package/src/core/memory-layers.mjs +96 -0
- package/src/core/migrate.mjs +116 -0
- package/src/core/notifications.mjs +77 -0
- package/src/core/operation-validator.mjs +272 -0
- package/src/core/optimizer.mjs +330 -0
- package/src/core/pattern_detector.mjs +113 -0
- package/src/core/post-mortem.mjs +413 -0
- package/src/core/protect-instructions.mjs +148 -0
- package/src/core/quick-capture.mjs +145 -0
- package/src/core/research-queue.mjs +431 -0
- package/src/core/research.mjs +461 -0
- package/src/core/runtime.mjs +515 -0
- package/src/core/sandbox.mjs +163 -0
- package/src/core/scheduler.mjs +569 -0
- package/src/core/schema.mjs +333 -0
- package/src/core/search.mjs +219 -0
- package/src/core/semantic-index.mjs +276 -0
- package/src/core/session-watcher.mjs +850 -0
- package/src/core/snapshot.mjs +133 -0
- package/src/core/source-adapters.mjs +1038 -0
- package/src/core/steering.mjs +137 -0
- package/src/core/surface.mjs +444 -0
- package/src/core/task_runner.mjs +125 -0
- package/src/core/tts.mjs +142 -0
- package/src/core/usage-tracker.mjs +349 -0
- package/src/core/vault-cache.mjs +76 -0
- package/src/core/vault.mjs +273 -0
- package/src/core/watchdog.mjs +236 -0
- package/src/server/api.mjs +944 -0
- package/src/server/auth.mjs +410 -0
- package/src/server/index.mjs +868 -0
- package/src/server/keys.mjs +181 -0
- package/src/server/rest.mjs +1652 -0
- package/src/server/route-manifest.json +77 -0
- package/src/server/routes/_shared.mjs +93 -0
- package/src/server/routes/keys.mjs +55 -0
- package/src/server/routes/memory.mjs +249 -0
- package/src/server/routes/sessions.mjs +336 -0
- package/src/server/routes/share.mjs +138 -0
- package/src/server/tools.mjs +618 -0
- package/templates/Caddyfile +37 -0
- package/templates/com.totalrecall.backup.plist +42 -0
- package/templates/com.totalrecall.daemon.plist +40 -0
- package/templates/com.totalrecall.duckdns.plist +23 -0
- package/templates/com.totalrecall.server.plist +46 -0
- package/templates/default-config/budget.yml +6 -0
- package/templates/default-config/runtime.yml +11 -0
- package/templates/default-config/security.yml +53 -0
- package/templates/default-config/voice.yml +34 -0
- package/templates/obsidian-queries/conflicts.md +26 -0
- package/templates/obsidian-queries/daily.md +17 -0
- package/templates/obsidian-queries/index.md +38 -0
- package/templates/obsidian-queries/skills.md +14 -0
- package/templates/onboarding-interview.md +72 -0
- package/templates/skill_engineering.md +40 -0
- package/templates/total-recall-daemon.service +21 -0
- 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> — 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> — Ask anything. I\\'ll answer from your memory first, then reason from what I know.',
|
|
424
|
+
journal: '<strong>Journal mode</strong> — Private space. These entries stay here and won\\'t be surfaced to your IDE tools.',
|
|
425
|
+
reflect: '<strong>Reflect mode</strong> — 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,'&').replace(/</g,'<').replace(/>/g,'>')
|
|
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
|
+
|