total-recall-brain 3.4.0 → 3.6.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.
@@ -944,12 +944,19 @@ router.get('/api/extension/status', requireAuth, requireScope('config:read'), as
944
944
  try {
945
945
  const extDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../extension');
946
946
  const available = fs.existsSync(path.join(extDir, 'manifest.json'));
947
+ let version = '0.0.0';
948
+ if (available) {
949
+ try {
950
+ const manifest = JSON.parse(fs.readFileSync(path.join(extDir, 'manifest.json'), 'utf8'));
951
+ version = manifest.version || '0.0.0';
952
+ } catch {}
953
+ }
947
954
 
948
955
  // Check if extension has ever connected by looking for the marker file
949
956
  const markerPath = path.join(BRAIN_DIR, 'config', '.extension-connected');
950
957
  const connected = fs.existsSync(markerPath);
951
958
 
952
- res.json({ available, connected });
959
+ res.json({ available, connected, version });
953
960
  } catch (err) { serverError(res, err); }
954
961
  });
955
962
 
@@ -1371,10 +1378,12 @@ router.get('/api/config-json', requireAuth, requireScope('config:read'), (req, r
1371
1378
  const securityPath = path.join(CONFIG_DIR, 'security.yml');
1372
1379
  const budgetPath = path.join(CONFIG_DIR, 'budget.yml');
1373
1380
  const brainPath = path.join(CONFIG_DIR, 'brain.json');
1381
+ const secretsPath = path.join(AGENT_DIR, 'secrets.enc');
1374
1382
 
1375
1383
  let security = {};
1376
1384
  let budget = {};
1377
1385
  let brain = {};
1386
+ let secrets = {};
1378
1387
 
1379
1388
  if (fs.existsSync(securityPath)) {
1380
1389
  try {
@@ -1391,6 +1400,11 @@ router.get('/api/config-json', requireAuth, requireScope('config:read'), (req, r
1391
1400
  brain = JSON.parse(fs.readFileSync(brainPath, 'utf8')) || {};
1392
1401
  } catch {}
1393
1402
  }
1403
+ if (fs.existsSync(secretsPath)) {
1404
+ try {
1405
+ secrets = JSON.parse(fs.readFileSync(secretsPath, 'utf8')) || {};
1406
+ } catch {}
1407
+ }
1394
1408
 
1395
1409
  const safeBrain = { ...brain };
1396
1410
  if (safeBrain.token) {
@@ -1398,16 +1412,25 @@ router.get('/api/config-json', requireAuth, requireScope('config:read'), (req, r
1398
1412
  delete safeBrain.token;
1399
1413
  }
1400
1414
 
1401
- res.json({ security, budget, brain: safeBrain });
1415
+ const allowedKeys = ['google_api_key', 'anthropic_api_key', 'openai_api_key', 'tavily_api_key', 'brave_api_key', 'exa_api_key', 'serper_api_key', 'github_token'];
1416
+ const safeSecrets = {};
1417
+ for (const key of allowedKeys) {
1418
+ if (secrets[key] !== undefined) {
1419
+ safeSecrets[key] = secrets[key];
1420
+ }
1421
+ }
1422
+
1423
+ res.json({ security, budget, brain: safeBrain, secrets: safeSecrets });
1402
1424
  } catch (err) { serverError(res, err); }
1403
1425
  });
1404
1426
 
1405
1427
  router.post('/api/config-json', requireAuth, requireScope('config:write'), (req, res) => {
1406
1428
  try {
1407
- const { security, budget, brain } = req.body;
1429
+ const { security, budget, brain, secrets } = req.body;
1408
1430
  const securityPath = path.join(CONFIG_DIR, 'security.yml');
1409
1431
  const budgetPath = path.join(CONFIG_DIR, 'budget.yml');
1410
1432
  const brainPath = path.join(CONFIG_DIR, 'brain.json');
1433
+ const secretsPath = path.join(AGENT_DIR, 'secrets.enc');
1411
1434
 
1412
1435
  if (!fs.existsSync(CONFIG_DIR)) {
1413
1436
  fs.mkdirSync(CONFIG_DIR, { recursive: true });
@@ -1433,6 +1456,25 @@ router.post('/api/config-json', requireAuth, requireScope('config:write'), (req,
1433
1456
  delete nextBrain.has_token;
1434
1457
  fs.writeFileSync(brainPath, JSON.stringify(nextBrain, null, 2), { encoding: 'utf8', mode: 0o600 });
1435
1458
  }
1459
+ if (secrets) {
1460
+ let existingSecrets = {};
1461
+ if (fs.existsSync(secretsPath)) {
1462
+ try {
1463
+ existingSecrets = JSON.parse(fs.readFileSync(secretsPath, 'utf8')) || {};
1464
+ } catch {}
1465
+ }
1466
+ const allowedKeys = ['google_api_key', 'anthropic_api_key', 'openai_api_key', 'tavily_api_key', 'brave_api_key', 'exa_api_key', 'serper_api_key', 'github_token'];
1467
+ for (const key of allowedKeys) {
1468
+ if (secrets[key] !== undefined) {
1469
+ if (secrets[key] === '') {
1470
+ delete existingSecrets[key];
1471
+ } else {
1472
+ existingSecrets[key] = secrets[key];
1473
+ }
1474
+ }
1475
+ }
1476
+ fs.writeFileSync(secretsPath, JSON.stringify(existingSecrets, null, 2), { encoding: 'utf8', mode: 0o600 });
1477
+ }
1436
1478
 
1437
1479
  res.json({ success: true });
1438
1480
  } catch (err) { serverError(res, err); }
@@ -1960,4 +2002,117 @@ function getLastModified(filePath) {
1960
2002
  return null;
1961
2003
  }
1962
2004
 
2005
+ /**
2006
+ * GET /api/update/check
2007
+ */
2008
+ router.get('/api/update/check', requireAuth, async (req, res) => {
2009
+ try {
2010
+ const localPkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
2011
+ const currentVersion = localPkg.version || '0.0.0';
2012
+
2013
+ const registryRes = await fetch('https://registry.npmjs.org/total-recall-brain/latest');
2014
+ if (!registryRes.ok) {
2015
+ throw new Error(`Failed to fetch latest version from npm: ${registryRes.status}`);
2016
+ }
2017
+ const registryData = await registryRes.json();
2018
+ const latestVersion = registryData.version || '0.0.0';
2019
+
2020
+ const updateAvailable = latestVersion !== currentVersion;
2021
+
2022
+ res.json({
2023
+ currentVersion,
2024
+ latestVersion,
2025
+ updateAvailable
2026
+ });
2027
+ } catch (err) {
2028
+ serverError(res, err);
2029
+ }
2030
+ });
2031
+
2032
+ /**
2033
+ * POST /api/update/run
2034
+ */
2035
+ router.post('/api/update/run', requireAuth, async (req, res) => {
2036
+ try {
2037
+ res.json({ success: true, message: 'Update started. Server is updating and will restart shortly.' });
2038
+
2039
+ setImmediate(async () => {
2040
+ logger.info('update', 'Starting auto-update process...');
2041
+ try {
2042
+ const { exec } = await import('node:child_process');
2043
+
2044
+ const runCmd = (cmd, cwd) => new Promise((resolve, reject) => {
2045
+ logger.info('update', `Running: ${cmd} in ${cwd}`);
2046
+ exec(cmd, { cwd }, (err, stdout, stderr) => {
2047
+ if (err) {
2048
+ logger.error('update', `Command failed: ${cmd}. Error: ${err.message}`);
2049
+ return reject(err);
2050
+ }
2051
+ resolve({ stdout, stderr });
2052
+ });
2053
+ });
2054
+
2055
+ // 1. git pull
2056
+ await runCmd('git pull', ROOT);
2057
+ // 2. npm install
2058
+ await runCmd('npm install', ROOT);
2059
+ // 3. build/install frontend if package.json exists
2060
+ const frontendPath = path.join(ROOT, 'frontend');
2061
+ if (fs.existsSync(path.join(frontendPath, 'package.json'))) {
2062
+ await runCmd('npm install', frontendPath);
2063
+ await runCmd('npm run build', frontendPath);
2064
+ }
2065
+
2066
+ logger.info('update', 'Update successfully applied. Restarting server...');
2067
+ process.exit(0);
2068
+ } catch (err) {
2069
+ logger.error('update', `Auto-update failed: ${err.message}`);
2070
+ }
2071
+ });
2072
+ } catch (err) {
2073
+ serverError(res, err);
2074
+ }
2075
+ });
2076
+
2077
+ router.get('/api/help', requireAuth, (req, res) => {
2078
+ const { topic } = req.query;
2079
+ const docsDir = path.join(ROOT, 'docs');
2080
+ const referenceDir = path.join(docsDir, 'reference');
2081
+
2082
+ if (!topic) {
2083
+ return res.json({
2084
+ topics: [
2085
+ { id: 'cli-reference', title: 'CLI Reference Guide', description: 'npx total-recall command catalog and flags' },
2086
+ { id: 'ssss', title: 'SSSS Specifications', description: 'Structured Semantic Syntax System guide' },
2087
+ { id: 'architecture', title: 'System Architecture', description: 'System topology and VFS structures' },
2088
+ { id: 'collab', title: 'Collaboration Guide', description: 'Collaborative workspaces and team annotations' }
2089
+ ]
2090
+ });
2091
+ }
2092
+
2093
+ try {
2094
+ let filePath = '';
2095
+ if (topic === 'cli-reference') {
2096
+ filePath = path.join(referenceDir, 'cli-reference.md');
2097
+ } else if (topic === 'ssss') {
2098
+ filePath = path.join(docsDir, 'SSSS.md');
2099
+ } else if (topic === 'architecture') {
2100
+ filePath = path.join(docsDir, 'ARCHITECTURE.md');
2101
+ } else if (topic === 'collab') {
2102
+ filePath = path.join(referenceDir, 'collab.md');
2103
+ } else {
2104
+ return res.status(404).json({ error: 'Help topic not found' });
2105
+ }
2106
+
2107
+ if (!fs.existsSync(filePath)) {
2108
+ return res.status(404).json({ error: 'Help topic document not found on server' });
2109
+ }
2110
+
2111
+ const content = fs.readFileSync(filePath, 'utf8');
2112
+ res.json({ topic, content });
2113
+ } catch (err) {
2114
+ serverError(res, err);
2115
+ }
2116
+ });
2117
+
1963
2118
  export { router as restRouter };
@@ -103,6 +103,10 @@
103
103
  "method": "GET",
104
104
  "path": "/api/graph"
105
105
  },
106
+ {
107
+ "method": "GET",
108
+ "path": "/api/help"
109
+ },
106
110
  {
107
111
  "method": "GET",
108
112
  "path": "/api/import/rules"
@@ -295,6 +299,14 @@
295
299
  "method": "GET",
296
300
  "path": "/api/tts/status"
297
301
  },
302
+ {
303
+ "method": "GET",
304
+ "path": "/api/update/check"
305
+ },
306
+ {
307
+ "method": "POST",
308
+ "path": "/api/update/run"
309
+ },
298
310
  {
299
311
  "method": "GET",
300
312
  "path": "/api/usage"
@@ -0,0 +1,286 @@
1
+ import express from 'express';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+ import bcrypt from 'bcrypt';
6
+ import jwt from 'jsonwebtoken';
7
+ import { WebSocket } from 'ws';
8
+ import { brainDir as configBrainDir } from '../../core/config.mjs';
9
+
10
+ const JWT_SECRET = process.env.JWT_SECRET || 'total-recall-collab-secret-key-1234';
11
+
12
+ // Setup database paths in sovereign brainDir
13
+ const DATA_DIR = path.join(configBrainDir, 'collab');
14
+ if (!fs.existsSync(DATA_DIR)) {
15
+ fs.mkdirSync(DATA_DIR, { recursive: true });
16
+ }
17
+
18
+ const USERS_FILE = path.join(DATA_DIR, 'users.json');
19
+ const GROUPS_FILE = path.join(DATA_DIR, 'groups.json');
20
+ const ANNOTATIONS_FILE = path.join(DATA_DIR, 'annotations.json');
21
+
22
+ function readJsonFile(filePath, defaultData = []) {
23
+ if (!fs.existsSync(filePath)) return defaultData;
24
+ try {
25
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
26
+ } catch {
27
+ return defaultData;
28
+ }
29
+ }
30
+
31
+ function writeJsonFile(filePath, data) {
32
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
33
+ }
34
+
35
+ // ─── Database Helpers ────────────────────────────────────────────────────────
36
+ function loadUsers() { return readJsonFile(USERS_FILE, []); }
37
+ function saveUsers(users) { writeJsonFile(USERS_FILE, users); }
38
+ function getUser(username) {
39
+ return loadUsers().find(u => u.username.toLowerCase() === username.toLowerCase());
40
+ }
41
+ function createUser(username, passwordHash) {
42
+ const users = loadUsers();
43
+ if (getUser(username)) throw new Error('User already exists');
44
+ const user = { username, passwordHash, created_at: new Date().toISOString() };
45
+ users.push(user);
46
+ saveUsers(users);
47
+ return user;
48
+ }
49
+
50
+ function loadGroups() { return readJsonFile(GROUPS_FILE, []); }
51
+ function saveGroups(groups) { writeJsonFile(GROUPS_FILE, groups); }
52
+ function createGroup(name, owner) {
53
+ const groups = loadGroups();
54
+ const code = crypto.randomBytes(4).toString('hex');
55
+ const newGroup = {
56
+ id: crypto.randomUUID(),
57
+ name,
58
+ code,
59
+ owner,
60
+ members: [owner],
61
+ created_at: new Date().toISOString()
62
+ };
63
+ groups.push(newGroup);
64
+ saveGroups(groups);
65
+ return newGroup;
66
+ }
67
+ function joinGroup(code, username) {
68
+ const groups = loadGroups();
69
+ const group = groups.find(g => g.code.toLowerCase() === code.toLowerCase());
70
+ if (!group) throw new Error('Group not found');
71
+ if (group.members.includes(username)) return group;
72
+ group.members.push(username);
73
+ saveGroups(groups);
74
+ return group;
75
+ }
76
+ function getUserGroups(username) {
77
+ return loadGroups().filter(g => g.members.includes(username));
78
+ }
79
+
80
+ function loadAnnotations() { return readJsonFile(ANNOTATIONS_FILE, []); }
81
+ function saveAnnotations(annotations) { writeJsonFile(ANNOTATIONS_FILE, annotations); }
82
+ function addAnnotation(url, groupCode, author, text, excerpt = '') {
83
+ const annotations = loadAnnotations();
84
+ const cleanUrl = url.split('#')[0];
85
+ const newAnnotation = {
86
+ id: crypto.randomUUID(),
87
+ url: cleanUrl,
88
+ groupCode,
89
+ author,
90
+ text,
91
+ excerpt,
92
+ created_at: new Date().toISOString()
93
+ };
94
+ annotations.push(newAnnotation);
95
+ saveAnnotations(annotations);
96
+ return newAnnotation;
97
+ }
98
+ function getAnnotations(url, userGroups = []) {
99
+ const cleanUrl = url.split('#')[0];
100
+ const groupCodes = userGroups.map(g => g.code);
101
+ return loadAnnotations().filter(a => a.url === cleanUrl && groupCodes.includes(a.groupCode));
102
+ }
103
+
104
+ // ─── Middleware ──────────────────────────────────────────────────────────────
105
+ function requireCollabAuth(req, res, next) {
106
+ const authHeader = req.headers.authorization;
107
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
108
+ return res.status(401).json({ error: 'Authorization token required' });
109
+ }
110
+ const token = authHeader.split(' ')[1];
111
+ try {
112
+ const payload = jwt.verify(token, JWT_SECRET);
113
+ req.user = payload;
114
+ next();
115
+ } catch (err) {
116
+ return res.status(401).json({ error: 'Invalid or expired token' });
117
+ }
118
+ }
119
+
120
+ // ─── Router ──────────────────────────────────────────────────────────────────
121
+ export const collabRouter = express.Router();
122
+
123
+ collabRouter.post('/api/collab/auth/register', (req, res) => {
124
+ const { username, password } = req.body;
125
+ if (!username || !password) {
126
+ return res.status(400).json({ error: 'Username and password required' });
127
+ }
128
+ try {
129
+ const passwordHash = bcrypt.hashSync(password, 10);
130
+ const user = createUser(username, passwordHash);
131
+ const group = createGroup(`${username}'s Workspace`, username);
132
+ const token = jwt.sign({ username: user.username }, JWT_SECRET, { expiresIn: '7d' });
133
+ res.json({ token, username: user.username, defaultGroupCode: group.code });
134
+ } catch (err) {
135
+ res.status(400).json({ error: err.message });
136
+ }
137
+ });
138
+
139
+ collabRouter.post('/api/collab/auth/login', (req, res) => {
140
+ const { username, password } = req.body;
141
+ if (!username || !password) {
142
+ return res.status(400).json({ error: 'Username and password required' });
143
+ }
144
+ const user = getUser(username);
145
+ if (!user || !bcrypt.compareSync(password, user.passwordHash)) {
146
+ return res.status(401).json({ error: 'Invalid credentials' });
147
+ }
148
+ const token = jwt.sign({ username: user.username }, JWT_SECRET, { expiresIn: '7d' });
149
+ res.json({ token, username: user.username });
150
+ });
151
+
152
+ collabRouter.get('/api/collab/groups', requireCollabAuth, (req, res) => {
153
+ try {
154
+ const groups = getUserGroups(req.user.username);
155
+ res.json(groups);
156
+ } catch (err) {
157
+ res.status(500).json({ error: err.message });
158
+ }
159
+ });
160
+
161
+ collabRouter.post('/api/collab/groups', requireCollabAuth, (req, res) => {
162
+ const { name } = req.body;
163
+ if (!name) return res.status(400).json({ error: 'Group name required' });
164
+ try {
165
+ const group = createGroup(name, req.user.username);
166
+ res.json(group);
167
+ } catch (err) {
168
+ res.status(500).json({ error: err.message });
169
+ }
170
+ });
171
+
172
+ collabRouter.post('/api/collab/groups/join', requireCollabAuth, (req, res) => {
173
+ const { code } = req.body;
174
+ if (!code) return res.status(400).json({ error: 'Invite code required' });
175
+ try {
176
+ const group = joinGroup(code, req.user.username);
177
+ res.json({ success: true, group });
178
+ } catch (err) {
179
+ res.status(400).json({ error: err.message });
180
+ }
181
+ });
182
+
183
+ collabRouter.get('/api/collab/annotations', requireCollabAuth, (req, res) => {
184
+ const { url } = req.query;
185
+ if (!url) return res.status(400).json({ error: 'URL query parameter required' });
186
+ try {
187
+ const userGroups = getUserGroups(req.user.username);
188
+ const list = getAnnotations(url, userGroups);
189
+ res.json(list);
190
+ } catch (err) {
191
+ res.status(500).json({ error: err.message });
192
+ }
193
+ });
194
+
195
+ collabRouter.post('/api/collab/annotations', requireCollabAuth, (req, res) => {
196
+ const { url, groupCode, text, excerpt } = req.body;
197
+ if (!url || !groupCode || !text) {
198
+ return res.status(400).json({ error: 'URL, groupCode, and text required' });
199
+ }
200
+ try {
201
+ const userGroups = getUserGroups(req.user.username);
202
+ if (!userGroups.some(g => g.code === groupCode)) {
203
+ return res.status(403).json({ error: 'You are not a member of this group' });
204
+ }
205
+ const note = addAnnotation(url, groupCode, req.user.username, text, excerpt);
206
+ broadcastToUrl(url, { type: 'ANNOTATION_ADDED', annotation: note });
207
+ res.json(note);
208
+ } catch (err) {
209
+ res.status(500).json({ error: err.message });
210
+ }
211
+ });
212
+
213
+ // ─── Live WS Presence / Chat Sync Registry ────────────────────────────────────
214
+ const clients = new Map();
215
+
216
+ export function handleCollabUpgrade(request, socket, head, wss) {
217
+ const url = new URL(request.url, `http://${request.headers.host}`);
218
+ const token = url.searchParams.get('token');
219
+
220
+ if (!token) {
221
+ socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
222
+ socket.destroy();
223
+ return;
224
+ }
225
+
226
+ try {
227
+ const payload = jwt.verify(token, JWT_SECRET);
228
+ wss.handleUpgrade(request, socket, head, (ws) => {
229
+ clients.set(ws, { username: payload.username, currentUrl: null });
230
+
231
+ ws.on('message', (message) => {
232
+ try {
233
+ const data = JSON.parse(message);
234
+ const clientInfo = clients.get(ws);
235
+
236
+ if (data.type === 'SUBSCRIBE') {
237
+ const cleanUrl = data.url.split('#')[0];
238
+ clientInfo.currentUrl = cleanUrl;
239
+ broadcastToUrl(cleanUrl, {
240
+ type: 'USER_JOINED',
241
+ username: clientInfo.username,
242
+ url: cleanUrl
243
+ }, ws);
244
+ }
245
+
246
+ if (data.type === 'CHAT_MESSAGE') {
247
+ if (!clientInfo.currentUrl) return;
248
+ broadcastToUrl(clientInfo.currentUrl, {
249
+ type: 'CHAT_MESSAGE',
250
+ username: clientInfo.username,
251
+ text: data.text,
252
+ created_at: new Date().toISOString()
253
+ });
254
+ }
255
+ } catch (err) {
256
+ ws.send(JSON.stringify({ type: 'ERROR', error: 'Invalid frame' }));
257
+ }
258
+ });
259
+
260
+ ws.on('close', () => {
261
+ const clientInfo = clients.get(ws);
262
+ if (clientInfo && clientInfo.currentUrl) {
263
+ broadcastToUrl(clientInfo.currentUrl, {
264
+ type: 'USER_LEFT',
265
+ username: clientInfo.username
266
+ });
267
+ }
268
+ clients.delete(ws);
269
+ });
270
+ });
271
+ } catch (err) {
272
+ socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
273
+ socket.destroy();
274
+ }
275
+ }
276
+
277
+ function broadcastToUrl(url, messageObj, excludeWs = null) {
278
+ const cleanUrl = url.split('#')[0];
279
+ const payload = JSON.stringify(messageObj);
280
+
281
+ for (const [ws, info] of clients.entries()) {
282
+ if (ws !== excludeWs && info.currentUrl === cleanUrl && ws.readyState === WebSocket.OPEN) {
283
+ ws.send(payload);
284
+ }
285
+ }
286
+ }