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,944 @@
1
+ import express from 'express';
2
+ import cors from 'cors';
3
+ import crypto from 'node:crypto';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { callLocalRuntimeRaw, loadRuntimeConfig, checkRuntimeHealth, cleanAndParseJSON } from '../core/runtime.mjs';
6
+ import { AVAILABLE_TOOLS, handleToolCall } from './tools.mjs';
7
+ import { requireAuth, requireScope, loginHandler, logoutHandler, changePasswordHandler, apiRateLimiter } from './auth.mjs';
8
+ import { logger } from '../core/logger.mjs';
9
+ import { synthesize as synthesizeTts, isTtsEnabled, TtsNotConfiguredError } from '../core/tts.mjs';
10
+ import { KNOWN_SCOPES, loadKeys, issueKey, revokeKey } from './keys.mjs';
11
+ import { loadNodes, writeNode } from '../core/vault.mjs';
12
+ import { compileSurface } from '../core/surface.mjs';
13
+ import { runInSandbox } from '../core/sandbox.mjs';
14
+ import { resolveAgentDir } from '../cli/agent-dir.mjs';
15
+ import { removeSessionEmbeddingFromIndex } from '../core/embeddings.mjs';
16
+ import path from 'path';
17
+ import os from 'os';
18
+ import fs from 'fs';
19
+ import matter from 'gray-matter';
20
+ import { agentDir, brainDir } from '../core/config.mjs';
21
+
22
+ const AGENT_DIR = process.env.AGENT_DIR || agentDir;
23
+ const BRAIN_DIR = process.env.TR_BRAIN || path.join(AGENT_DIR, 'skills', 'total-recall');
24
+ const VAULT_DIR = path.join(BRAIN_DIR, 'memory-vault');
25
+ const SKILLS_DIR = path.join(AGENT_DIR, 'skills');
26
+ const DERIVED_DIR = path.join(BRAIN_DIR, 'memory-derived');
27
+ const INSTRUCTIONS_FILE = path.join(AGENT_DIR, 'INSTRUCTIONS.md');
28
+ const SESSIONS_DIR = path.join(BRAIN_DIR, 'sessions');
29
+ const FILES_DIR = path.join(BRAIN_DIR, 'files');
30
+ const TASKS_DIR = path.join(BRAIN_DIR, 'scheduler', 'queue');
31
+ const CONFIG_DIR = path.join(BRAIN_DIR, 'config');
32
+ const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
33
+ const MODEL_CATALOG_DIR = path.join(ROOT, 'models', 'catalog', 'total-recall');
34
+
35
+ /**
36
+ * Resolve a brain-specific vault directory from a brain ID string.
37
+ *
38
+ * @param {string|undefined} brainId - 'global', 'project:<name>', or undefined
39
+ * @returns {string} Absolute path to the memory-vault directory
40
+ */
41
+ function resolveVaultDir(brainId) {
42
+ if (!brainId || brainId === 'global') {
43
+ return VAULT_DIR;
44
+ }
45
+
46
+ if (brainId.startsWith('project:')) {
47
+ const projectName = brainId.slice('project:'.length);
48
+ const globalBrainDir = path.join(os.homedir(), '.agent', 'skills', 'total-recall');
49
+ const registryPath = path.join(globalBrainDir, 'config', 'project-registry.json');
50
+
51
+ if (fs.existsSync(registryPath)) {
52
+ try {
53
+ const registry = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
54
+ const project = registry.find(p => p.name === projectName);
55
+ if (project) {
56
+ const projectVaultDir = path.join(project.brainDir, 'memory-vault');
57
+ if (fs.existsSync(projectVaultDir)) {
58
+ return projectVaultDir;
59
+ }
60
+ }
61
+ } catch {
62
+ // Registry parse error — fall through to default
63
+ }
64
+ }
65
+ }
66
+
67
+ return VAULT_DIR;
68
+ }
69
+
70
+ function sha256(content) {
71
+ return crypto.createHash('sha256').update(content).digest('hex');
72
+ }
73
+
74
+ function readTextResource(filePath, name) {
75
+ if (!fs.existsSync(filePath)) return null;
76
+ const content = fs.readFileSync(filePath, 'utf8');
77
+ const stat = fs.statSync(filePath);
78
+ return {
79
+ name,
80
+ content,
81
+ sha256: sha256(content),
82
+ bytes: stat.size,
83
+ modified: stat.mtime.toISOString()
84
+ };
85
+ }
86
+
87
+ function sendTextResource(res, filePath, name) {
88
+ const resource = readTextResource(filePath, name);
89
+ if (!resource) {
90
+ return res.status(404).json({ error: `${name} is not available` });
91
+ }
92
+ return res.json(resource);
93
+ }
94
+
95
+ function baseUrl(req) {
96
+ return `${req.protocol}://${req.get('host')}`;
97
+ }
98
+
99
+ function absoluteUrl(req, routePath) {
100
+ return new URL(routePath, baseUrl(req)).toString();
101
+ }
102
+
103
+ function endpointManifest(req) {
104
+ return {
105
+ discovery: absoluteUrl(req, '/.well-known/total-recall.json'),
106
+ chat_completions: absoluteUrl(req, '/v1/chat/completions'),
107
+ models: absoluteUrl(req, '/v1/models'),
108
+ health: absoluteUrl(req, '/health'),
109
+ api_health: absoluteUrl(req, '/api/health'),
110
+ instructions: absoluteUrl(req, '/api/instructions'),
111
+ ssss: absoluteUrl(req, '/api/ssss'),
112
+ memory: absoluteUrl(req, '/api/memory'),
113
+ api_keys_dashboard: absoluteUrl(req, '/keys')
114
+ };
115
+ }
116
+
117
+ function listFilesRecursive(root, predicate) {
118
+ const out = [];
119
+ if (!fs.existsSync(root)) return out;
120
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
121
+ const fullPath = path.join(root, entry.name);
122
+ if (entry.isDirectory()) out.push(...listFilesRecursive(fullPath, predicate));
123
+ else if (entry.isFile() && predicate(fullPath)) out.push(fullPath);
124
+ }
125
+ return out;
126
+ }
127
+
128
+ function loadCatalogModels(runtimeConfig = {}) {
129
+ const modelFiles = listFilesRecursive(MODEL_CATALOG_DIR, file => path.basename(file) === 'MODEL.md');
130
+ return modelFiles.map((filePath) => {
131
+ const raw = fs.readFileSync(filePath, 'utf8');
132
+ const parsed = matter(raw);
133
+ const data = parsed.data || {};
134
+ const folderId = path.basename(path.dirname(filePath));
135
+ const id = data.name || `total-recall/${folderId}`;
136
+ const aliases = [...new Set([
137
+ id,
138
+ data.model_id,
139
+ data.name,
140
+ `total-recall/${folderId}`,
141
+ folderId
142
+ ].filter(Boolean))];
143
+
144
+ return {
145
+ id,
146
+ object: 'model',
147
+ created: 0,
148
+ owned_by: data.provider || 'total-recall',
149
+ root: runtimeConfig.model || data.model_id || id,
150
+ parent: null,
151
+ aliases,
152
+ metadata: {
153
+ provider: data.provider || 'total-recall',
154
+ provider_type: data.provider_type || 'local-runtime',
155
+ display_name: data.display_name || data.name || id,
156
+ model_id: data.model_id || id,
157
+ runtime_model: runtimeConfig.model || null,
158
+ pricing_prompt: data.pricing_prompt ?? 0,
159
+ pricing_completion: data.pricing_completion ?? 0,
160
+ supports_tools: data.supports_tools ?? true,
161
+ supports_vision: data.supports_vision ?? false,
162
+ supports_code: data.supports_code ?? true
163
+ }
164
+ };
165
+ });
166
+ }
167
+
168
+ function resolveRequestedModel(requestedModel, runtimeConfig) {
169
+ if (!requestedModel) return runtimeConfig.model;
170
+ if (requestedModel === runtimeConfig.model) return requestedModel;
171
+
172
+ const catalogModels = loadCatalogModels(runtimeConfig);
173
+ const knownTotalRecallAlias = catalogModels.some(model => model.aliases.includes(requestedModel));
174
+ return knownTotalRecallAlias ? runtimeConfig.model : requestedModel;
175
+ }
176
+
177
+ function ssssReferenceDir() {
178
+ return path.join(SKILLS_DIR, 'total-recall', 'skills', 'ssss', 'references');
179
+ }
180
+
181
+ function listSsssReferences(req) {
182
+ const refsDir = ssssReferenceDir();
183
+ if (!fs.existsSync(refsDir)) return [];
184
+ return fs.readdirSync(refsDir)
185
+ .filter(file => file.endsWith('.md'))
186
+ .sort()
187
+ .map((file) => {
188
+ const name = file.replace(/\.md$/, '');
189
+ const resource = readTextResource(path.join(refsDir, file), name);
190
+ return {
191
+ name,
192
+ url: absoluteUrl(req, `/api/ssss/references/${name}`),
193
+ sha256: resource?.sha256 || null,
194
+ bytes: resource?.bytes || 0,
195
+ modified: resource?.modified || null
196
+ };
197
+ });
198
+ }
199
+
200
+ function safeReferencePath(name) {
201
+ if (!/^[a-zA-Z0-9_-]+$/.test(String(name || ''))) return null;
202
+ return path.join(ssssReferenceDir(), `${name}.md`);
203
+ }
204
+
205
+ function getSessionId(req) {
206
+ const fromHeader = req.headers['x-session-id'];
207
+ if (typeof fromHeader === 'string' && /^[a-zA-Z0-9_-]{4,64}$/.test(fromHeader)) {
208
+ return fromHeader;
209
+ }
210
+ const fromQuery = req.query?.sessionId;
211
+ if (typeof fromQuery === 'string' && /^[a-zA-Z0-9_-]{4,64}$/.test(fromQuery)) {
212
+ return fromQuery;
213
+ }
214
+ const cookieSession = req.cookies?.session_id;
215
+ if (typeof cookieSession === 'string' && /^[a-zA-Z0-9_-]{4,64}$/.test(cookieSession)) {
216
+ return cookieSession;
217
+ }
218
+ // Fall back to a date-based id so concurrent unauthenticated requests share a file.
219
+ return `daily-${new Date().toISOString().split('T')[0]}`;
220
+ }
221
+
222
+ function writeSessionRecord(sessionId, record) {
223
+ try {
224
+ if (!fs.existsSync(SESSIONS_DIR)) {
225
+ fs.mkdirSync(SESSIONS_DIR, { recursive: true });
226
+ }
227
+ const file = path.join(SESSIONS_DIR, `${sessionId}.jsonl`);
228
+ fs.appendFileSync(file, JSON.stringify(record) + '\n');
229
+ } catch (err) {
230
+ logger.error('api', `Failed to write session record: ${err.message}`);
231
+ }
232
+ }
233
+
234
+ export const apiRouter = express.Router();
235
+
236
+ apiRouter.use('/v1', apiRateLimiter(), requireAuth);
237
+
238
+ function extractToolCallsFromContent(content) {
239
+ if (!content || typeof content !== 'string') return [];
240
+ const toolCalls = [];
241
+
242
+ // 1. Look for <tool_call>...</tool_call> tags
243
+ const matches = [...content.matchAll(/<tool_call>([\s\S]*?)<\/tool_call>/g)];
244
+ for (const match of matches) {
245
+ const raw = match[1].trim();
246
+ try {
247
+ const parsed = JSON.parse(raw);
248
+ if (parsed.name) {
249
+ toolCalls.push({
250
+ id: `call_${crypto.randomUUID()}`,
251
+ type: 'function',
252
+ function: {
253
+ name: parsed.name,
254
+ arguments: typeof parsed.arguments === 'string' ? parsed.arguments : JSON.stringify(parsed.arguments || {})
255
+ }
256
+ });
257
+ }
258
+ } catch {
259
+ try {
260
+ const parsed = cleanAndParseJSON(raw);
261
+ if (parsed && parsed.name) {
262
+ toolCalls.push({
263
+ id: `call_${crypto.randomUUID()}`,
264
+ type: 'function',
265
+ function: {
266
+ name: parsed.name,
267
+ arguments: typeof parsed.arguments === 'string' ? parsed.arguments : JSON.stringify(parsed.arguments || {})
268
+ }
269
+ });
270
+ }
271
+ } catch {}
272
+ }
273
+ }
274
+
275
+ // 2. If no tag-based tool calls found, check for json markdown blocks
276
+ if (toolCalls.length === 0) {
277
+ const jsonBlockMatch = content.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/);
278
+ if (jsonBlockMatch) {
279
+ const raw = jsonBlockMatch[1].trim();
280
+ try {
281
+ const parsed = JSON.parse(raw);
282
+ if (parsed.name && parsed.arguments) {
283
+ toolCalls.push({
284
+ id: `call_${crypto.randomUUID()}`,
285
+ type: 'function',
286
+ function: {
287
+ name: parsed.name,
288
+ arguments: typeof parsed.arguments === 'string' ? parsed.arguments : JSON.stringify(parsed.arguments || {})
289
+ }
290
+ });
291
+ }
292
+ } catch {
293
+ try {
294
+ const parsed = cleanAndParseJSON(raw);
295
+ if (parsed && parsed.name && parsed.arguments) {
296
+ toolCalls.push({
297
+ id: `call_${crypto.randomUUID()}`,
298
+ type: 'function',
299
+ function: {
300
+ name: parsed.name,
301
+ arguments: typeof parsed.arguments === 'string' ? parsed.arguments : JSON.stringify(parsed.arguments || {})
302
+ }
303
+ });
304
+ }
305
+ } catch {}
306
+ }
307
+ }
308
+ }
309
+
310
+ // 3. If still none, check if the entire content is a raw JSON block representing a tool call
311
+ if (toolCalls.length === 0) {
312
+ const trimmed = content.trim();
313
+ if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
314
+ try {
315
+ const parsed = JSON.parse(trimmed);
316
+ if (parsed.name && parsed.arguments) {
317
+ toolCalls.push({
318
+ id: `call_${crypto.randomUUID()}`,
319
+ type: 'function',
320
+ function: {
321
+ name: parsed.name,
322
+ arguments: typeof parsed.arguments === 'string' ? parsed.arguments : JSON.stringify(parsed.arguments || {})
323
+ }
324
+ });
325
+ }
326
+ } catch {
327
+ try {
328
+ const parsed = cleanAndParseJSON(trimmed);
329
+ if (parsed && parsed.name && parsed.arguments) {
330
+ toolCalls.push({
331
+ id: `call_${crypto.randomUUID()}`,
332
+ type: 'function',
333
+ function: {
334
+ name: parsed.name,
335
+ arguments: typeof parsed.arguments === 'string' ? parsed.arguments : JSON.stringify(parsed.arguments || {})
336
+ }
337
+ });
338
+ }
339
+ } catch {}
340
+ }
341
+ }
342
+ }
343
+
344
+ return toolCalls;
345
+ }
346
+
347
+ // ─── Chat Completions ──────────────────────────────────────────────────────────
348
+
349
+ apiRouter.post('/v1/chat/completions', requireScope('chat:write'), async (req, res) => {
350
+ try {
351
+ const runtimeConfigPath = path.join(CONFIG_DIR, 'runtime.yml');
352
+
353
+ const { messages, model, temperature, groundingNodes, brainId } = req.body;
354
+
355
+ logger.info('api', '=== NEW CHAT REQUEST ===');
356
+ logger.info('api', `Messages count: ${messages?.length || 0}`);
357
+ if (messages && messages.length > 0) {
358
+ const lastUserMsg = messages.filter(m => m.role === 'user').pop();
359
+ if (lastUserMsg) logger.info('api', `User Prompt: "${lastUserMsg.content.slice(0, 150)}${lastUserMsg.content.length > 150 ? '...' : ''}"`);
360
+ }
361
+
362
+ if (!messages || messages.length === 0) {
363
+ return res.status(400).json({ error: 'Messages array is required' });
364
+ }
365
+
366
+ const rtConfig = loadRuntimeConfig();
367
+ const activeConfig = {
368
+ ...rtConfig,
369
+ model: resolveRequestedModel(model, rtConfig),
370
+ response_model: model || rtConfig.model,
371
+ temperature: temperature || rtConfig.temperature
372
+ };
373
+
374
+ // If the requested model matches one of our CLI agents, elevate that agent to highest priority
375
+ if (model && activeConfig.agents) {
376
+ let targetAgentName = model.toLowerCase();
377
+ let subModel = null;
378
+ if (targetAgentName.includes(':')) {
379
+ const parts = targetAgentName.split(':');
380
+ targetAgentName = parts[0];
381
+ subModel = parts[1];
382
+ } else if (targetAgentName.includes('/')) {
383
+ const parts = targetAgentName.split('/');
384
+ targetAgentName = parts[0];
385
+ subModel = parts[1];
386
+ }
387
+
388
+ const idx = activeConfig.agents.findIndex(a => a.name === targetAgentName);
389
+ if (idx >= 0) {
390
+ // Deep copy the agents list so we don't mutate the cached config
391
+ activeConfig.agents = activeConfig.agents.map(a => ({ ...a }));
392
+ activeConfig.agents[idx].priority = 0;
393
+ if (subModel) {
394
+ activeConfig.agents[idx].model = subModel;
395
+ }
396
+ activeConfig.agents.sort((a, b) => a.priority - b.priority);
397
+ logger.info('api', `Elevated priority of agent "${targetAgentName}" to 0 based on model request.${subModel ? ` Submodel set to "${subModel}"` : ''}`);
398
+ }
399
+ }
400
+
401
+
402
+ const dateStr = new Date().toLocaleString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', timeZoneName: 'short' });
403
+ let baseSystemPrompt = `You are Total Recall, a Sovereign AI OS running a database-free, Markdown-first Structured Semantic Syntax System (SSSS) architecture. The current date and time is ${dateStr}.
404
+ CRITICAL RULES — you have these tools and MUST use them:
405
+
406
+ BROWSER TOOLS (headless Chromium — use for web tasks):
407
+ - 'search_web': ALWAYS use for current events, news, prices, facts outside training data. Never guess.
408
+ - 'browser_navigate': Open any URL and read its full content as markdown.
409
+ - 'browser_click': Click buttons, links, or elements on the current page by CSS selector or text.
410
+ - 'browser_type': Fill in forms or search boxes on the current page.
411
+ - 'browser_get_content': Get the full text of whatever page is currently open.
412
+ - 'browser_screenshot': Take a screenshot of the current browser page.
413
+ - 'browser_eval': Run JavaScript in the current browser page to extract data or interact with APIs.
414
+
415
+ COMPUTER USE TOOLS (desktop/X11 — use to control apps or the full desktop):
416
+ - 'computer_screenshot': Take a screenshot of the entire desktop. Do this FIRST to see what's on screen.
417
+ - 'computer_left_click': Click at absolute screen coordinates (x, y).
418
+ - 'computer_double_click': Double-click at screen coordinates.
419
+ - 'computer_right_click': Right-click at screen coordinates (opens context menus).
420
+ - 'computer_mouse_move': Move the mouse without clicking.
421
+ - 'computer_type': Type text at the current cursor position.
422
+ - 'computer_key': Press a key combo (e.g. "Return", "ctrl+c", "alt+Tab").
423
+ - 'computer_scroll': Scroll up or down at screen coordinates.
424
+
425
+ CODE / MEMORY:
426
+ - 'execute_code': Run Node.js to call APIs, process data, or perform calculations.
427
+ - 'update_design': Write markdown to DESIGN.md when asked to create a UI or document.
428
+
429
+ MANDATORY TOOL USE AND CITATION RULES:
430
+ 1. FORCE SEARCH: If the user asks you to "search", "lookup", "find", or asks about current/recent events or anything you do not explicitly have in your memory vault, you MUST output a tool call to 'search_web' immediately. Do not write a conversational introduction first.
431
+ 2. CITATION MANDATE: When answering using results from 'search_web' or 'browser_navigate', you MUST explicitly cite your sources by embedding clickable markdown links (e.g., [Source Title](URL)) directly in your response. Never summarize web facts without providing their corresponding URLs.
432
+ 3. BE DETERMINISTIC: Do not explain what you would do or write placeholders. Always execute the actual tool call.
433
+ 4. FALLBACK FORMAT: If your runtime does not support native OpenAI-compatible tool calls, or if your tool calls fail to trigger, you MUST write the tool call in your text response wrapped in <tool_call> tags.
434
+ Example:
435
+ <tool_call>
436
+ {
437
+ "name": "search_web",
438
+ "arguments": {
439
+ "query": "precise search keywords here"
440
+ }
441
+ }
442
+ </tool_call>
443
+ Do not add any other conversational text when outputting the fallback tool call. Output it immediately as your entire response.
444
+
445
+ You have a REAL browser AND full desktop control. Use them. Navigate, click, type, scrape — do not just describe what you would do. For web tasks prefer browser tools. For native apps or desktop workflows use computer_screenshot first to orient yourself.`;
446
+
447
+ const compressInstructions = (text) => {
448
+ if (!text) return '';
449
+ if (text.length < 1000) return text;
450
+ const sections = text.split(/\r?\n(?=#+ )/);
451
+ const result = [];
452
+ for (const sec of sections) {
453
+ const trimmed = sec.trim();
454
+ if (!trimmed) continue;
455
+ const lines = trimmed.split('\n');
456
+ const header = lines[0];
457
+ if (header.includes('Before You Respond') || header.includes('Topic → Skill Routing')) {
458
+ result.push(trimmed);
459
+ continue;
460
+ }
461
+ if (header.includes('Total Recall System')) {
462
+ let compressed = trimmed;
463
+ compressed = compressed.replace(/```bash\s*curl[\s\S]*?```/g, '(endpoint usage curl example omitted for brevity)');
464
+ result.push(compressed);
465
+ continue;
466
+ }
467
+ if (header.includes('Always reply directly') || header.includes('Do not mention') || header.includes('Operating Protocol') || header.includes('CHECK INTERRUPTS')) {
468
+ const bodyLines = lines.slice(1).filter(l => l.trim()).slice(0, 5);
469
+ result.push(`${header}\n${bodyLines.join('\n')}`);
470
+ continue;
471
+ }
472
+ }
473
+ return result.join('\n\n');
474
+ };
475
+
476
+ const compressSsssSkill = (text) => {
477
+ if (!text) return '';
478
+ if (text.length < 1000) return text;
479
+ const sections = text.split(/\r?\n(?=#+ )/);
480
+ const result = [];
481
+ for (const sec of sections) {
482
+ const trimmed = sec.trim();
483
+ if (!trimmed) continue;
484
+ const lines = trimmed.split('\n');
485
+ const header = lines[0];
486
+ if (!header.startsWith('#')) {
487
+ result.push(trimmed);
488
+ continue;
489
+ }
490
+ if (header.includes('Core Mandate') || header.includes('Three-Tier Memory Hierarchy') || header.includes('Cognitive Memory Layers') || header.includes('Vault Directory Layout')) {
491
+ result.push(trimmed);
492
+ continue;
493
+ }
494
+ if (header.includes('File Types') || header.includes('Derived Artifacts') || header.includes('Staging Area') || header.includes('Interoperability') || header.includes('Naming Conventions')) {
495
+ result.push(`${header}\n\n[SSSS architectural specifications compressed. Refer to ssss/SKILL.md or references/ssss-spec.md for schema details]`);
496
+ continue;
497
+ }
498
+ if (trimmed.length < 800) {
499
+ result.push(trimmed);
500
+ }
501
+ }
502
+ return result.join('\n\n');
503
+ };
504
+
505
+ try {
506
+ const instructionsPath = path.join(AGENT_DIR, 'INSTRUCTIONS.md');
507
+ if (fs.existsSync(instructionsPath)) {
508
+ const instructions = fs.readFileSync(instructionsPath, 'utf8');
509
+ baseSystemPrompt += `\n\n=== TIER 1 HOT MEMORY INSTRUCTIONS ===\n${compressInstructions(instructions)}`;
510
+ }
511
+
512
+ const ssssPath = path.join(AGENT_DIR, 'skills', 'total-recall', 'skills', 'ssss', 'SKILL.md');
513
+ if (fs.existsSync(ssssPath)) {
514
+ const ssssContent = fs.readFileSync(ssssPath, 'utf8');
515
+ baseSystemPrompt += `\n\n=== STRUCTURED SEMANTIC SYNTAX SYSTEM (SSSS) DOCUMENTATION ===\nYou are the orchestrator of this system. Here is the architectural specification:\n${compressSsssSkill(ssssContent)}`;
516
+ }
517
+
518
+ // Load user profile — drives all personalisation and idle work
519
+ const profilePath = path.join(BRAIN_DIR, 'memory-vault', 'preferences', 'user-profile.md');
520
+ const prioritiesPath = path.join(BRAIN_DIR, 'memory-vault', 'preferences', 'user-priorities.md');
521
+ const interviewTaskPath = path.join(BRAIN_DIR, 'scheduler', 'queue', 'onboarding-interview.md');
522
+
523
+ // Self-aware API reference — inject brain URL so the AI can call its own endpoints
524
+ const selfBase = baseUrl(req);
525
+ baseSystemPrompt += `\n\n=== YOUR OWN REST API (call these directly via execute_code or HTTP tools) ===
526
+ Base URL: ${selfBase}
527
+
528
+ MEMORY:
529
+ GET ${selfBase}/api/memory — list all nodes (optional ?type=&tag=)
530
+ GET ${selfBase}/api/memory/:id — get one node
531
+ POST ${selfBase}/api/memory — create node body: {type,title,body,tags,metadata}
532
+ PUT ${selfBase}/api/memory/:id — update node
533
+ DELETE ${selfBase}/api/memory/:id — delete node
534
+ POST ${selfBase}/api/memory/search — keyword search body: {query,type,limit}
535
+ POST ${selfBase}/api/memory/search/semantic — semantic (meaning-based) search body: {query,top_k,include_sessions}
536
+ Returns vault nodes AND session chunks ranked by similarity
537
+
538
+ RESEARCH QUEUE:
539
+ GET ${selfBase}/api/research — list all research projects (past + pending) ?status=pending|done|...
540
+ POST ${selfBase}/api/research — queue a new research topic body: {topic,priority,notes}
541
+ PATCH ${selfBase}/api/research/:id — update status/notes/node_slug
542
+ DELETE ${selfBase}/api/research/:id — remove from queue
543
+
544
+ VAULT:
545
+ POST ${selfBase}/api/vault/compile — recompile INSTRUCTIONS.md + rebuild all embeddings
546
+ GET ${selfBase}/api/vault/nodes — list all SSSS nodes with frontmatter
547
+ GET ${selfBase}/api/vault/surface — get compiled surface text
548
+ GET ${selfBase}/api/vault/status — node count, last compile time
549
+
550
+ SESSIONS:
551
+ GET ${selfBase}/api/sessions — list ingested sessions
552
+ GET ${selfBase}/api/sessions/:id — get session messages
553
+ POST ${selfBase}/api/sessions/ingest — ingest a session body: {source,messages:[{role,content}]}
554
+ DELETE ${selfBase}/api/sessions/:id — delete session
555
+
556
+ BRAIN:
557
+ GET ${selfBase}/api/brain/export — download full brain as .tar.gz ?include=vault,derived,sessions,config,skills
558
+
559
+ CHAT (OpenAI-compatible):
560
+ POST ${selfBase}/v1/chat/completions — send a chat message to yourself
561
+ GET ${selfBase}/v1/models — list available models
562
+
563
+ OTHER:
564
+ GET ${selfBase}/health — health check (vault stats, embedding index sizes, ollama status)
565
+ GET ${selfBase}/.well-known/total-recall.json — discovery manifest
566
+ GET ${selfBase}/api/keys — list API keys (admin)
567
+
568
+ Authentication: include Authorization: Bearer <token> on all requests (already set for your current session).
569
+ Use execute_code to call these endpoints with node's built-in fetch() — you are your own memory system.`;
570
+
571
+ if (fs.existsSync(profilePath)) {
572
+ const profile = fs.readFileSync(profilePath, 'utf8');
573
+ baseSystemPrompt += `\n\n=== USER PROFILE ===\n${profile}`;
574
+ if (fs.existsSync(prioritiesPath)) {
575
+ const priorities = fs.readFileSync(prioritiesPath, 'utf8');
576
+ baseSystemPrompt += `\n\n=== USER PRIORITIES & GOALS ===\n${priorities}`;
577
+ }
578
+ } else if (fs.existsSync(interviewTaskPath)) {
579
+ // No profile yet — enter interview mode
580
+ const interviewTask = fs.readFileSync(interviewTaskPath, 'utf8');
581
+ baseSystemPrompt += `\n\n=== ONBOARDING INTERVIEW MODE ===
582
+ This user has not been onboarded yet. You MUST conduct the onboarding interview before doing anything else.
583
+ DO NOT answer any other questions until the interview is complete.
584
+ Ask questions one at a time, warmly and conversationally. Listen carefully. Reflect back what you hear.
585
+ After the interview, write the user's answers to:
586
+ - ${BRAIN_DIR}/memory-vault/preferences/user-profile.md
587
+ - ${BRAIN_DIR}/memory-vault/preferences/user-priorities.md
588
+ - ${BRAIN_DIR}/memory-vault/preferences/user-interests.md
589
+ Then mark ${interviewTaskPath} as status: done.
590
+
591
+ INTERVIEW TASK:
592
+ ${interviewTask}`;
593
+ }
594
+ } catch (e) {
595
+ logger.error('api', `Failed to load system documentation: ${e.message}`);
596
+ }
597
+
598
+ // Support topical grounding context
599
+ if (Array.isArray(groundingNodes) && groundingNodes.length > 0) {
600
+ try {
601
+ const groundingVaultDir = resolveVaultDir(brainId);
602
+ const allNodes = loadNodes(groundingVaultDir);
603
+ let groundingPrompt = '\n\n=== ACTIVE GROUNDING BRAIN NODES ===\nThe user has explicitly selected the following brain memory nodes as context for this conversation. Integrate their contents into your knowledge base and refer to them to inform your answers:';
604
+ let groundedAny = false;
605
+ for (const slug of groundingNodes) {
606
+ const node = allNodes.find(n => n.slug === slug);
607
+ if (node) {
608
+ const bodyTruncated = node.body ? (node.body.slice(0, 5000) + (node.body.length > 5000 ? '\n[Truncated for context size]' : '')) : '';
609
+ groundingPrompt += `\n\nNode Slug: ${node.slug}\nTitle: "${node.title || 'Untitled'}"\nCategory: ${node.category || 'unknown'}\n---\n${bodyTruncated}\n---`;
610
+ groundedAny = true;
611
+ }
612
+ }
613
+ if (groundedAny) {
614
+ baseSystemPrompt += groundingPrompt;
615
+ }
616
+ } catch (err) {
617
+ logger.error('api', `Failed to load grounding nodes: ${err.message}`);
618
+ }
619
+ }
620
+
621
+ let currentMessages = [...messages];
622
+ if (currentMessages.length > 0 && currentMessages[0].role === 'system') {
623
+ currentMessages[0].content = `${baseSystemPrompt}\n\n${currentMessages[0].content}`;
624
+ } else {
625
+ currentMessages.unshift({ role: 'system', content: baseSystemPrompt });
626
+ }
627
+ let finalMessage = null;
628
+ const startTime = Date.now();
629
+
630
+ let useTools = AVAILABLE_TOOLS;
631
+ // Tool loop (up to 5 iterations to prevent infinite loops)
632
+ for (let i = 0; i < 5; i++) {
633
+ logger.info('api', `Invoking local model (Iteration ${i + 1})...`);
634
+ let message;
635
+ try {
636
+ message = await callLocalRuntimeRaw(currentMessages, activeConfig, useTools);
637
+ } catch (err) {
638
+ if (useTools && (err.message.includes('does not support tools') || err.message.includes('invalid_request_error') || err.message.includes('400'))) {
639
+ logger.warn('api', `Local model ${activeConfig.model} does not support native tools. Retrying without tools and with simplified system prompt.`);
640
+ useTools = undefined;
641
+
642
+ // Overwrite system prompt to protect the CPU/memory footprint of the fallback model
643
+ const systemMsgIdx = currentMessages.findIndex(m => m.role === 'system');
644
+ if (systemMsgIdx !== -1) {
645
+ currentMessages[systemMsgIdx].content = `You are Total Recall, a Sovereign AI OS running a database-free, Markdown-first Structured Semantic Syntax System (SSSS) architecture. The current date and time is ${dateStr}.
646
+ You are operating in a lightweight text-only mode. Answer the user's questions directly, concisely, and helpfully without using tools. Keep your responses short and complete.`;
647
+ }
648
+
649
+ message = await callLocalRuntimeRaw(currentMessages, activeConfig, undefined);
650
+ } else {
651
+ throw err;
652
+ }
653
+ }
654
+ currentMessages.push(message);
655
+
656
+ if (!message.tool_calls || message.tool_calls.length === 0) {
657
+ const parsedCalls = extractToolCallsFromContent(message.content);
658
+ if (parsedCalls && parsedCalls.length > 0) {
659
+ logger.info('api', `Extracted ${parsedCalls.length} fallback tool calls from text content`);
660
+ message.tool_calls = parsedCalls;
661
+ // Optionally clean content of XML tags to keep user-facing text clean
662
+ message.content = message.content.replace(/<tool_call>[\s\S]*?<\/tool_call>/g, '').trim();
663
+
664
+ // Also handle ```json blocks if they were parsed
665
+ const jsonBlockRegex = /```(?:json)?\s*(\{[\s\S]*?\})\s*```/;
666
+ if (jsonBlockRegex.test(message.content)) {
667
+ if (message.content.replace(jsonBlockRegex, '').trim() === '') {
668
+ message.content = '';
669
+ }
670
+ }
671
+ }
672
+ }
673
+
674
+ if (message.tool_calls && message.tool_calls.length > 0) {
675
+ // Execute tools
676
+ for (const toolCall of message.tool_calls) {
677
+ logger.info('api', `Executing tool: ${toolCall.function.name}`);
678
+ const toolResult = await handleToolCall(toolCall);
679
+ currentMessages.push({
680
+ role: 'tool',
681
+ content: toolResult,
682
+ tool_call_id: toolCall.id
683
+ });
684
+ }
685
+ } else {
686
+ finalMessage = message;
687
+ break;
688
+ }
689
+ }
690
+
691
+ if (!finalMessage) {
692
+ finalMessage = currentMessages[currentMessages.length - 1];
693
+ }
694
+
695
+ const elapsedMs = Date.now() - startTime;
696
+ const elapsed = (elapsedMs / 1000).toFixed(2);
697
+ logger.info('api', `=== CHAT RESPONSE GENERATED (${elapsed}s) ===`);
698
+ if (finalMessage.content) {
699
+ logger.info('api', `Response: "${finalMessage.content.slice(0, 150)}${finalMessage.content.length > 150 ? '...' : ''}"`);
700
+ } else if (finalMessage.tool_calls) {
701
+ logger.info('api', `Response: [Triggered ${finalMessage.tool_calls.length} tool calls]`);
702
+ }
703
+
704
+ // Persist this exchange so dream-cycle Light Sleep can scan it for candidates.
705
+ const sessionId = getSessionId(req);
706
+ const promptText = messages.filter(m => m.role !== 'system').map(m => m.content).join('\n');
707
+ const promptTokens = Math.ceil((promptText.length || 0) / 4);
708
+ const completionTokens = Math.ceil(((finalMessage.content || '').length || 0) / 4);
709
+ writeSessionRecord(sessionId, {
710
+ id: `exchange-${crypto.randomUUID()}`,
711
+ session_id: sessionId,
712
+ timestamp: new Date().toISOString(),
713
+ model: activeConfig.model,
714
+ latency_ms: elapsedMs,
715
+ messages: currentMessages,
716
+ response: finalMessage,
717
+ tokens: promptTokens + completionTokens,
718
+ brain_id: brainId || 'global'
719
+ });
720
+ // Emit latency + tokens so the watchdog log monitor can react to anomalies.
721
+ logger.info('api', 'chat exchange completed', {
722
+ session_id: sessionId,
723
+ latency_ms: elapsedMs,
724
+ tokens: promptTokens + completionTokens
725
+ });
726
+
727
+ res.json({
728
+ id: `chatcmpl-${Date.now()}`,
729
+ object: 'chat.completion',
730
+ created: Math.floor(Date.now() / 1000),
731
+ model: activeConfig.response_model || activeConfig.model,
732
+ choices: [
733
+ {
734
+ index: 0,
735
+ message: finalMessage,
736
+ finish_reason: finalMessage.tool_calls ? 'tool_calls' : 'stop'
737
+ }
738
+ ]
739
+ });
740
+ } catch (error) {
741
+ logger.error('api', 'API Error', { error: error.message });
742
+ res.status(500).json({ error: error.message });
743
+ }
744
+ });
745
+
746
+ apiRouter.get('/v1/chat/history', requireAuth, requireScope('chat:read'), (req, res) => {
747
+ try {
748
+ const sessionId = getSessionId(req);
749
+ const file = path.join(SESSIONS_DIR, `${sessionId}.jsonl`);
750
+ if (!fs.existsSync(file)) {
751
+ return res.json({ messages: [] });
752
+ }
753
+
754
+ // Read the file and parse the last valid JSON line
755
+ const content = fs.readFileSync(file, 'utf8').trim().split('\n');
756
+ if (content.length === 0 || !content[0]) {
757
+ return res.json({ messages: [] });
758
+ }
759
+
760
+ // The last exchange contains the cumulative messages
761
+ const lastLine = content[content.length - 1];
762
+ const record = JSON.parse(lastLine);
763
+
764
+ // Filter out system messages so they don't clutter the UI
765
+ const chatHistory = (record.messages || [])
766
+ .filter(m => m.role !== 'system')
767
+ .map((m, idx) => ({
768
+ id: `hist-${idx}`,
769
+ role: m.role,
770
+ content: m.content,
771
+ timestamp: new Date(record.timestamp).getTime(),
772
+ versions: [m.content],
773
+ currentVersionIndex: 0
774
+ }));
775
+
776
+ res.json({ messages: chatHistory });
777
+ } catch (err) {
778
+ logger.error('api', 'Error loading chat history', { error: err.message });
779
+ res.status(500).json({ error: err.message });
780
+ }
781
+ });
782
+
783
+ apiRouter.get('/v1/chat/threads', requireAuth, requireScope('chat:read'), (req, res) => {
784
+ try {
785
+ if (!fs.existsSync(SESSIONS_DIR)) {
786
+ return res.json([]);
787
+ }
788
+ const files = fs.readdirSync(SESSIONS_DIR);
789
+ const threads = [];
790
+ for (const file of files) {
791
+ if (!file.endsWith('.jsonl')) continue;
792
+ if (!file.startsWith('thread-')) continue;
793
+
794
+ const filePath = path.join(SESSIONS_DIR, file);
795
+ const stat = fs.statSync(filePath);
796
+ if (stat.size === 0) continue;
797
+
798
+ try {
799
+ const content = fs.readFileSync(filePath, 'utf8').trim().split('\n').filter(Boolean);
800
+ if (content.length === 0) continue;
801
+
802
+ const firstLine = content[0];
803
+ const firstRecord = JSON.parse(firstLine);
804
+ const messages = firstRecord.messages || [];
805
+ const userMsg = messages.find(m => m.role === 'user');
806
+
807
+ let title = file.replace(/\.jsonl$/, '');
808
+ if (userMsg && typeof userMsg.content === 'string' && userMsg.content.trim()) {
809
+ const contentStr = userMsg.content.trim();
810
+ title = contentStr.slice(0, 45) + (contentStr.length > 45 ? '...' : '');
811
+ }
812
+
813
+ const lastLine = content[content.length - 1];
814
+ const lastRecord = JSON.parse(lastLine);
815
+ const lastUpdated = lastRecord.timestamp ? new Date(lastRecord.timestamp).getTime() : stat.mtimeMs;
816
+ const turns = content.length;
817
+
818
+ threads.push({
819
+ id: file.replace(/\.jsonl$/, ''),
820
+ title,
821
+ turns,
822
+ lastUpdated,
823
+ brainId: firstRecord.brain_id || 'global'
824
+ });
825
+ } catch (err) {
826
+ logger.error('api', `Error reading session file ${file}`, { error: err.message });
827
+ }
828
+ }
829
+
830
+ threads.sort((a, b) => b.lastUpdated - a.lastUpdated);
831
+ res.json(threads);
832
+ } catch (err) {
833
+ logger.error('api', 'Error listing threads', { error: err.message });
834
+ res.status(500).json({ error: err.message });
835
+ }
836
+ });
837
+
838
+ apiRouter.delete('/v1/chat/threads/:id', requireAuth, requireScope('chat:write'), (req, res) => {
839
+ try {
840
+ const threadId = req.params.id;
841
+ if (!/^[a-zA-Z0-9_-]{4,64}$/.test(threadId)) {
842
+ return res.status(400).json({ error: 'Invalid thread ID format' });
843
+ }
844
+ const file = path.join(SESSIONS_DIR, `${threadId}.jsonl`);
845
+ if (!fs.existsSync(file)) {
846
+ return res.status(404).json({ error: 'Thread not found' });
847
+ }
848
+
849
+ fs.unlinkSync(file);
850
+
851
+ try {
852
+ removeSessionEmbeddingFromIndex(DERIVED_DIR, threadId);
853
+ } catch (err) {
854
+ logger.error('api', `Failed to remove session embeddings for ${threadId}`, { error: err.message });
855
+ }
856
+
857
+ res.json({ deleted: true, id: threadId });
858
+ } catch (err) {
859
+ logger.error('api', 'Error deleting thread', { error: err.message });
860
+ res.status(500).json({ error: err.message });
861
+ }
862
+ });
863
+
864
+ apiRouter.get('/v1/chat/suggestions', requireAuth, requireScope('chat:read'), (req, res) => {
865
+ try {
866
+ const suggestionsVaultDir = resolveVaultDir(req.query?.brain);
867
+ const allNodes = loadNodes(suggestionsVaultDir);
868
+ // Find active or draft memory nodes that represent facts, concepts, or research
869
+ const candidates = allNodes.filter(n => n.status !== 'archived' && n.category !== 'preferences');
870
+
871
+ const suggestions = [];
872
+
873
+ // Fact Suggestion
874
+ const factNode = candidates.find(n => n.category === 'facts') || candidates[0];
875
+ if (factNode) {
876
+ suggestions.push({
877
+ type: 'fact',
878
+ title: `🧠 Brain Fact: ${factNode.title}`,
879
+ text: `Let's discuss the facts and findings documented in '**${factNode.title}**'. What else should we analyze?`,
880
+ nodes: [factNode.slug]
881
+ });
882
+ }
883
+
884
+ // Concept Suggestion
885
+ const conceptNode = candidates.find(n => n.category === 'concepts' && n.slug !== (factNode?.slug)) || candidates.find(n => n.slug !== (factNode?.slug));
886
+ if (conceptNode) {
887
+ suggestions.push({
888
+ type: 'concept',
889
+ title: `💡 Concept Idea: ${conceptNode.title}`,
890
+ text: `Regarding the concept '**${conceptNode.title}**', would you like to brainstorm how we can expand this into a production-ready system?`,
891
+ nodes: [conceptNode.slug]
892
+ });
893
+ }
894
+
895
+ // Question Suggestion
896
+ const draftNode = candidates.find(n => n.status === 'draft' && n.slug !== (factNode?.slug) && n.slug !== (conceptNode?.slug))
897
+ || candidates.find(n => n.category === 'patterns' && n.slug !== (factNode?.slug) && n.slug !== (conceptNode?.slug))
898
+ || candidates.find(n => n.slug !== (factNode?.slug) && n.slug !== (conceptNode?.slug));
899
+ if (draftNode) {
900
+ suggestions.push({
901
+ type: 'question',
902
+ title: `❓ Topic Question: ${draftNode.title}`,
903
+ text: `I noticed the '**${draftNode.title}**' node in your vault. Moving forward, how should we prioritize its development or resolve any open design details?`,
904
+ nodes: [draftNode.slug]
905
+ });
906
+ }
907
+
908
+ // Default suggestions fallback if vault is mostly empty
909
+ if (suggestions.length < 3) {
910
+ const standardSuggestions = [
911
+ {
912
+ type: 'fact',
913
+ title: '🧠 Sovereign OS Operations',
914
+ text: 'Let\'s review the core architecture of your Sovereign OS and see how we can make memory vaults even more powerful.',
915
+ nodes: []
916
+ },
917
+ {
918
+ type: 'concept',
919
+ title: '💡 Custom Agent Skills',
920
+ text: 'What kind of specialized automation skills should we design next for developer productivity?',
921
+ nodes: []
922
+ },
923
+ {
924
+ type: 'question',
925
+ title: '❓ Brain Health & Scaling',
926
+ text: 'Would you like to analyze system health indexes and run a memory compilation to ensure all search indexes are fully synchronized?',
927
+ nodes: []
928
+ }
929
+ ];
930
+ while (suggestions.length < 3 && standardSuggestions.length > 0) {
931
+ const standard = standardSuggestions.shift();
932
+ if (!suggestions.some(s => s.title === standard.title)) {
933
+ suggestions.push(standard);
934
+ }
935
+ }
936
+ }
937
+
938
+ res.json(suggestions.slice(0, 3));
939
+ } catch (err) {
940
+ logger.error('api', 'Error loading chat suggestions', { error: err.message });
941
+ res.status(500).json({ error: err.message });
942
+ }
943
+ });
944
+