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,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
|
+
|