total-recall-brain 3.5.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.
- package/bin/total-recall.mjs +3 -0
- package/extension/content-script.js +354 -11
- package/extension/popup/popup.html +5 -0
- package/extension/popup/popup.js +25 -0
- package/frontend/src/App.tsx +29 -0
- package/frontend/src/api.ts +20 -0
- package/frontend/src/pages/ChatPage.tsx +101 -8
- package/frontend/src/pages/CollabPage.tsx +470 -0
- package/frontend/src/pages/GraphPage.tsx +62 -0
- package/frontend/src/pages/HealthPage.tsx +6 -1
- package/frontend/src/pages/HelpPage.tsx +141 -0
- package/package.json +3 -2
- package/src/cli/collab.mjs +49 -0
- package/src/cli/help.mjs +1 -1
- package/src/core/dream.mjs +41 -0
- package/src/core/source-adapters.mjs +14 -6
- package/src/server/index.mjs +21 -1
- package/src/server/rest.mjs +49 -1
- package/src/server/route-manifest.json +4 -0
- package/src/server/routes/collab.mjs +286 -0
|
@@ -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
|
+
}
|