@getmegabrain/cli 0.1.5 → 0.1.7
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/dist/device-auth.js +29 -17
- package/dist/index.js +9 -0
- package/dist/science/agent/agent.js +99 -0
- package/dist/science/agent/model.js +46 -0
- package/dist/science/index.js +77 -0
- package/dist/science/kernel-protocol.js +7 -0
- package/dist/science/kernel.js +159 -0
- package/dist/science/kernel_driver.py +93 -0
- package/dist/science/nonce.js +53 -0
- package/dist/science/pages.js +346 -0
- package/dist/science/server.js +353 -0
- package/dist/science/session-client.js +165 -0
- package/dist/science/store.js +160 -0
- package/package.json +3 -3
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { readCredentials, writeCredentials, clearCredentials } from '../credentials.js';
|
|
6
|
+
import { startDeviceAuth } from '../device-auth.js';
|
|
7
|
+
import { NonceStore } from './nonce.js';
|
|
8
|
+
import { homePage, loginPage, nonceInvalidPage, nonceLandingPage, projectPage, sessionPage, startWizardPage, } from './pages.js';
|
|
9
|
+
import { FEATURED_CONNECTORS, NETWORK_CATEGORIES, WorkspaceStore } from './store.js';
|
|
10
|
+
import { KernelManager } from './kernel.js';
|
|
11
|
+
import { AgentSession, pythonTool } from './agent/agent.js';
|
|
12
|
+
import { GatewayModel } from './agent/model.js';
|
|
13
|
+
/**
|
|
14
|
+
* The `megabrain science` local daemon's HTTP server. Serves the sign-in hand-off on
|
|
15
|
+
* localhost (docs/megabrain-science/reference/claude-science-flow.md §1.1) and, once signed
|
|
16
|
+
* in, a placeholder app shell where the reused session UI will mount. Two trust layers:
|
|
17
|
+
* 1. a single-use nonce authorizes a browser to drive this daemon (a printed localhost URL),
|
|
18
|
+
* 2. MegaBrain device-auth authenticates the account (reused from the CLI).
|
|
19
|
+
*/
|
|
20
|
+
const COOKIE = 'mb_sci';
|
|
21
|
+
const PREFERRED_PORT = 8765;
|
|
22
|
+
const SESSION_CLIENT = fileURLToPath(new URL('./session-client.js', import.meta.url));
|
|
23
|
+
export async function startScienceServer(host = '127.0.0.1') {
|
|
24
|
+
const nonces = new NonceStore();
|
|
25
|
+
const trusted = new Set();
|
|
26
|
+
const store = new WorkspaceStore();
|
|
27
|
+
const kernels = new KernelManager();
|
|
28
|
+
let pending = null;
|
|
29
|
+
// Per-session Server-Sent-Events fan-out. Kernel frames + agent transcript events both
|
|
30
|
+
// flow to every browser watching a session, tagged with a `channel`.
|
|
31
|
+
const sseClients = new Map();
|
|
32
|
+
const kernelHooked = new Set();
|
|
33
|
+
const fanout = (sid, channel, payload) => {
|
|
34
|
+
const set = sseClients.get(sid);
|
|
35
|
+
if (!set)
|
|
36
|
+
return;
|
|
37
|
+
const line = `data: ${JSON.stringify({ channel, payload })}\n\n`;
|
|
38
|
+
for (const r of set)
|
|
39
|
+
r.write(line);
|
|
40
|
+
};
|
|
41
|
+
const isSignedIn = () => readCredentials() !== null;
|
|
42
|
+
const readBody = (req) => new Promise(resolve => {
|
|
43
|
+
let raw = '';
|
|
44
|
+
req.on('data', chunk => {
|
|
45
|
+
raw += chunk;
|
|
46
|
+
if (raw.length > 1_000_000)
|
|
47
|
+
req.destroy(); // guard against oversized bodies
|
|
48
|
+
});
|
|
49
|
+
req.on('end', () => {
|
|
50
|
+
try {
|
|
51
|
+
resolve(raw ? JSON.parse(raw) : {});
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
resolve({});
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
req.on('error', () => resolve({}));
|
|
58
|
+
});
|
|
59
|
+
const str = (v) => (typeof v === 'string' ? v : undefined);
|
|
60
|
+
const cookieId = (req) => {
|
|
61
|
+
const raw = req.headers.cookie ?? '';
|
|
62
|
+
for (const part of raw.split(';')) {
|
|
63
|
+
const [k, v] = part.trim().split('=');
|
|
64
|
+
if (k === COOKIE && v)
|
|
65
|
+
return v;
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
};
|
|
69
|
+
const isTrusted = (req) => {
|
|
70
|
+
const id = cookieId(req);
|
|
71
|
+
return id !== null && trusted.has(id);
|
|
72
|
+
};
|
|
73
|
+
const html = (res, body, status = 200) => {
|
|
74
|
+
res.writeHead(status, { 'content-type': 'text/html; charset=utf-8' });
|
|
75
|
+
res.end(body);
|
|
76
|
+
};
|
|
77
|
+
const json = (res, body, status = 200) => {
|
|
78
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
79
|
+
res.end(JSON.stringify(body));
|
|
80
|
+
};
|
|
81
|
+
const redirect = (res, location) => {
|
|
82
|
+
res.writeHead(302, { location });
|
|
83
|
+
res.end();
|
|
84
|
+
};
|
|
85
|
+
const server = createServer(async (req, res) => {
|
|
86
|
+
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? host}`);
|
|
87
|
+
const path = url.pathname;
|
|
88
|
+
const method = req.method ?? 'GET';
|
|
89
|
+
const seg = path.split('/').filter(Boolean);
|
|
90
|
+
// Static workspace client (referenced by the session page).
|
|
91
|
+
if (path === '/session-client.js' && method === 'GET') {
|
|
92
|
+
res.writeHead(200, {
|
|
93
|
+
'content-type': 'text/javascript; charset=utf-8',
|
|
94
|
+
'cache-control': 'no-cache',
|
|
95
|
+
});
|
|
96
|
+
res.end(readFileSync(SESSION_CLIENT, 'utf8'));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
// ── Sign-in hand-off ───────────────────────────────────────────────
|
|
100
|
+
if (path === '/' && url.searchParams.has('nonce')) {
|
|
101
|
+
const result = nonces.claim(url.searchParams.get('nonce') ?? '');
|
|
102
|
+
if (result !== 'ok')
|
|
103
|
+
return html(res, nonceInvalidPage(), 410);
|
|
104
|
+
const id = randomBytes(18).toString('hex');
|
|
105
|
+
trusted.add(id);
|
|
106
|
+
res.setHeader('set-cookie', `${COOKIE}=${id}; HttpOnly; SameSite=Lax; Path=/`);
|
|
107
|
+
return html(res, nonceLandingPage(url.searchParams.get('nonce') ?? ''));
|
|
108
|
+
}
|
|
109
|
+
if (path === '/') {
|
|
110
|
+
if (!isTrusted(req))
|
|
111
|
+
return html(res, nonceInvalidPage(), 401);
|
|
112
|
+
const creds = readCredentials();
|
|
113
|
+
if (!creds)
|
|
114
|
+
return redirect(res, '/login');
|
|
115
|
+
// First run: send new accounts through the onboarding wizard before Home.
|
|
116
|
+
if (!store.getSettings().onboarded)
|
|
117
|
+
return redirect(res, '/start');
|
|
118
|
+
return html(res, homePage(creds.userEmail));
|
|
119
|
+
}
|
|
120
|
+
if (path === '/login' && method === 'GET') {
|
|
121
|
+
if (!isTrusted(req))
|
|
122
|
+
return html(res, nonceInvalidPage(), 401);
|
|
123
|
+
return isSignedIn() ? redirect(res, '/') : html(res, loginPage());
|
|
124
|
+
}
|
|
125
|
+
if (path === '/start' && method === 'GET') {
|
|
126
|
+
if (!isTrusted(req))
|
|
127
|
+
return html(res, nonceInvalidPage(), 401);
|
|
128
|
+
if (!isSignedIn())
|
|
129
|
+
return redirect(res, '/login');
|
|
130
|
+
const s = store.getSettings();
|
|
131
|
+
return html(res, startWizardPage({
|
|
132
|
+
networkCategories: [...NETWORK_CATEGORIES],
|
|
133
|
+
connectorCatalog: [...FEATURED_CONNECTORS],
|
|
134
|
+
network: s.network,
|
|
135
|
+
connectors: s.connectors,
|
|
136
|
+
profile: s.profile,
|
|
137
|
+
}));
|
|
138
|
+
}
|
|
139
|
+
// ── Workbench pages (trusted + signed in) ───────────────────────────
|
|
140
|
+
if (seg[0] === 'projects' && method === 'GET') {
|
|
141
|
+
if (!isTrusted(req))
|
|
142
|
+
return html(res, nonceInvalidPage(), 401);
|
|
143
|
+
if (!isSignedIn())
|
|
144
|
+
return redirect(res, '/login');
|
|
145
|
+
if (seg.length === 2)
|
|
146
|
+
return html(res, projectPage(seg[1] ?? ''));
|
|
147
|
+
if (seg.length === 4 && seg[2] === 'frames') {
|
|
148
|
+
return html(res, sessionPage(seg[1] ?? '', seg[3] ?? ''));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// ── Auth API (browser-driven, gated on the local-trust cookie) ──────
|
|
152
|
+
if (path === '/api/signin' && method === 'POST') {
|
|
153
|
+
if (!isTrusted(req))
|
|
154
|
+
return json(res, { error: 'untrusted' }, 401);
|
|
155
|
+
if (isSignedIn())
|
|
156
|
+
return json(res, { signedIn: true });
|
|
157
|
+
const respond = (p) => json(res, { verificationUrl: p.verificationUrl, code: p.code });
|
|
158
|
+
if (pending)
|
|
159
|
+
return respond(pending);
|
|
160
|
+
void startDeviceAuth()
|
|
161
|
+
.then(p => {
|
|
162
|
+
pending = p;
|
|
163
|
+
p.completed
|
|
164
|
+
.then(creds => writeCredentials(creds))
|
|
165
|
+
.catch(() => {
|
|
166
|
+
/* denied / expired — the browser can retry, which mints a new code */
|
|
167
|
+
})
|
|
168
|
+
.finally(() => {
|
|
169
|
+
pending = null;
|
|
170
|
+
});
|
|
171
|
+
respond(p);
|
|
172
|
+
})
|
|
173
|
+
.catch(() => json(res, { error: 'start-failed' }, 502));
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (path === '/api/auth/status' && method === 'GET') {
|
|
177
|
+
const creds = readCredentials();
|
|
178
|
+
return json(res, { signedIn: creds !== null, userEmail: creds?.userEmail ?? null });
|
|
179
|
+
}
|
|
180
|
+
if (path === '/api/signout' && method === 'POST') {
|
|
181
|
+
clearCredentials();
|
|
182
|
+
return json(res, { ok: true });
|
|
183
|
+
}
|
|
184
|
+
// ── Onboarding API (#286). Signed-in only. ──────────────────────────
|
|
185
|
+
if (path === '/api/onboarding') {
|
|
186
|
+
if (!isSignedIn())
|
|
187
|
+
return json(res, { error: 'signed-out' }, 401);
|
|
188
|
+
if (method === 'GET')
|
|
189
|
+
return json(res, store.getSettings());
|
|
190
|
+
if (method === 'PUT') {
|
|
191
|
+
const b = await readBody(req);
|
|
192
|
+
const arr = (v) => Array.isArray(v) ? v.filter((x) => typeof x === 'string') : undefined;
|
|
193
|
+
return json(res, store.updateSettings({
|
|
194
|
+
network: arr(b.network),
|
|
195
|
+
connectors: arr(b.connectors),
|
|
196
|
+
skills: arr(b.skills),
|
|
197
|
+
profile: str(b.profile),
|
|
198
|
+
onboarded: typeof b.onboarded === 'boolean' ? b.onboarded : undefined,
|
|
199
|
+
}));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (path === '/api/onboarding/start' && method === 'POST') {
|
|
203
|
+
if (!isSignedIn())
|
|
204
|
+
return json(res, { error: 'signed-out' }, 401);
|
|
205
|
+
const b = await readBody(req);
|
|
206
|
+
const task = str(b.task)?.trim() ?? '';
|
|
207
|
+
const profile = str(b.profile)?.trim() ?? '';
|
|
208
|
+
store.updateSettings({ onboarded: true, ...(profile ? { profile } : {}) });
|
|
209
|
+
const project = store.createProject({
|
|
210
|
+
name: profile ? profile.slice(0, 60) : 'My research',
|
|
211
|
+
agentContext: profile,
|
|
212
|
+
});
|
|
213
|
+
const session = store.createSession({
|
|
214
|
+
projectId: project.id,
|
|
215
|
+
title: task || 'New session',
|
|
216
|
+
summary: task,
|
|
217
|
+
});
|
|
218
|
+
return json(res, { projectId: project.id, sessionId: session?.id ?? null }, 201);
|
|
219
|
+
}
|
|
220
|
+
// ── Session event stream + agent (#274/#276/#277). Signed-in only. ──
|
|
221
|
+
// One SSE per session carries two channels: `kernel` (raw kernel status/output frames)
|
|
222
|
+
// and `agent` (the agent loop's transcript events). The agent's model runs in the cloud
|
|
223
|
+
// (Gateway) but drives the local kernel — compute stays on the machine.
|
|
224
|
+
if (seg[0] === 'api' &&
|
|
225
|
+
seg[1] === 'sessions' &&
|
|
226
|
+
seg[2] &&
|
|
227
|
+
(seg[3] === 'events' || seg[3] === 'agent' || seg[3] === 'kernel')) {
|
|
228
|
+
const creds = readCredentials();
|
|
229
|
+
if (!creds)
|
|
230
|
+
return json(res, { error: 'signed-out' }, 401);
|
|
231
|
+
const sid = seg[2];
|
|
232
|
+
if (seg[3] === 'events' && method === 'GET') {
|
|
233
|
+
res.writeHead(200, {
|
|
234
|
+
'content-type': 'text/event-stream',
|
|
235
|
+
'cache-control': 'no-cache',
|
|
236
|
+
connection: 'keep-alive',
|
|
237
|
+
});
|
|
238
|
+
let set = sseClients.get(sid);
|
|
239
|
+
if (!set) {
|
|
240
|
+
set = new Set();
|
|
241
|
+
sseClients.set(sid, set);
|
|
242
|
+
}
|
|
243
|
+
set.add(res);
|
|
244
|
+
if (!kernelHooked.has(sid)) {
|
|
245
|
+
kernelHooked.add(sid);
|
|
246
|
+
kernels.get(sid).onFrame(frame => fanout(sid, 'kernel', frame));
|
|
247
|
+
}
|
|
248
|
+
req.on('close', () => sseClients.get(sid)?.delete(res));
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (seg[3] === 'kernel' && seg[4] === 'restart' && method === 'POST') {
|
|
252
|
+
kernels.restart(sid);
|
|
253
|
+
return json(res, { ok: true });
|
|
254
|
+
}
|
|
255
|
+
if (seg[3] === 'agent' && method === 'POST') {
|
|
256
|
+
const b = await readBody(req);
|
|
257
|
+
const prompt = str(b.prompt)?.trim() ?? '';
|
|
258
|
+
if (!prompt)
|
|
259
|
+
return json(res, { error: 'prompt-required' }, 400);
|
|
260
|
+
const session = store.getSession(sid);
|
|
261
|
+
const project = session ? store.getProject(session.projectId) : null;
|
|
262
|
+
store.touchSession(sid, session && !session.summary ? { summary: prompt } : undefined);
|
|
263
|
+
fanout(sid, 'agent', { type: 'user', text: prompt });
|
|
264
|
+
const agent = new AgentSession(new GatewayModel({ apiKey: creds.token }), [pythonTool(kernels.get(sid))], evt => fanout(sid, 'agent', evt), project?.agentContext ?? '');
|
|
265
|
+
void agent.run(prompt);
|
|
266
|
+
return json(res, { ok: true });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
// ── Workspace API (projects/sessions, #270/#271). Signed-in only. ────
|
|
270
|
+
if (path.startsWith('/api/projects') || path.startsWith('/api/sessions')) {
|
|
271
|
+
if (!isSignedIn())
|
|
272
|
+
return json(res, { error: 'signed-out' }, 401);
|
|
273
|
+
if (path === '/api/projects' && method === 'GET')
|
|
274
|
+
return json(res, store.listProjects());
|
|
275
|
+
if (path === '/api/projects' && method === 'POST') {
|
|
276
|
+
const b = await readBody(req);
|
|
277
|
+
const name = str(b.name);
|
|
278
|
+
if (!name)
|
|
279
|
+
return json(res, { error: 'name-required' }, 400);
|
|
280
|
+
return json(res, store.createProject({
|
|
281
|
+
name,
|
|
282
|
+
description: str(b.description),
|
|
283
|
+
agentContext: str(b.agentContext),
|
|
284
|
+
}), 201);
|
|
285
|
+
}
|
|
286
|
+
if (seg[0] === 'api' && seg[1] === 'projects' && seg[2]) {
|
|
287
|
+
const pid = seg[2];
|
|
288
|
+
if (seg.length === 3 && method === 'GET') {
|
|
289
|
+
const project = store.getProject(pid);
|
|
290
|
+
return project
|
|
291
|
+
? json(res, { project, sessions: store.listSessions(pid) })
|
|
292
|
+
: json(res, { error: 'not-found' }, 404);
|
|
293
|
+
}
|
|
294
|
+
if (seg.length === 3 && method === 'PATCH') {
|
|
295
|
+
const b = await readBody(req);
|
|
296
|
+
const updated = store.updateProject(pid, {
|
|
297
|
+
name: str(b.name),
|
|
298
|
+
description: str(b.description),
|
|
299
|
+
agentContext: str(b.agentContext),
|
|
300
|
+
});
|
|
301
|
+
return updated ? json(res, updated) : json(res, { error: 'not-found' }, 404);
|
|
302
|
+
}
|
|
303
|
+
if (seg.length === 4 && seg[3] === 'sessions' && method === 'POST') {
|
|
304
|
+
const b = await readBody(req);
|
|
305
|
+
const session = store.createSession({
|
|
306
|
+
projectId: pid,
|
|
307
|
+
title: str(b.title),
|
|
308
|
+
summary: str(b.summary),
|
|
309
|
+
});
|
|
310
|
+
return session ? json(res, session, 201) : json(res, { error: 'not-found' }, 404);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (path === '/api/sessions/recent' && method === 'GET') {
|
|
314
|
+
return json(res, store.recentSessions());
|
|
315
|
+
}
|
|
316
|
+
if (seg[0] === 'api' && seg[1] === 'sessions' && seg[2] && method === 'GET') {
|
|
317
|
+
const session = store.getSession(seg[2]);
|
|
318
|
+
return session ? json(res, session) : json(res, { error: 'not-found' }, 404);
|
|
319
|
+
}
|
|
320
|
+
return json(res, { error: 'not-found' }, 404);
|
|
321
|
+
}
|
|
322
|
+
// Mint a fresh sign-in URL for `megabrain science url` (localhost-only, like the daemon
|
|
323
|
+
// itself — any local terminal can ask for a link; the account auth still gates access).
|
|
324
|
+
if (path === '/internal/mint' && method === 'POST') {
|
|
325
|
+
const origin = `http://${host}:${port}`;
|
|
326
|
+
return json(res, { url: `${origin}/?nonce=${nonces.create()}` });
|
|
327
|
+
}
|
|
328
|
+
res.writeHead(404, { 'content-type': 'text/plain' });
|
|
329
|
+
res.end('Not found');
|
|
330
|
+
});
|
|
331
|
+
const port = await listen(server, host, PREFERRED_PORT);
|
|
332
|
+
const origin = () => `http://${host}:${port}`;
|
|
333
|
+
return { server, port, nonces, kernels, origin };
|
|
334
|
+
}
|
|
335
|
+
/** Bind the preferred port, falling back to an OS-assigned one if it's taken. */
|
|
336
|
+
function listen(server, host, preferred) {
|
|
337
|
+
return new Promise((resolve, reject) => {
|
|
338
|
+
const onError = (err) => {
|
|
339
|
+
if (err.code === 'EADDRINUSE') {
|
|
340
|
+
server.listen(0, host);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
reject(err);
|
|
344
|
+
};
|
|
345
|
+
server.on('error', onError);
|
|
346
|
+
server.on('listening', () => {
|
|
347
|
+
server.off('error', onError);
|
|
348
|
+
const address = server.address();
|
|
349
|
+
resolve(typeof address === 'object' && address ? address.port : preferred);
|
|
350
|
+
});
|
|
351
|
+
server.listen(preferred, host);
|
|
352
|
+
});
|
|
353
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Workspace client for a MegaBrain Science session (#271/#276/#277). Renders the sidebar
|
|
2
|
+
// (sessions), the transcript (agent steps), and the composer; streams agent + kernel events
|
|
3
|
+
// over SSE. Shipped as a static asset (see copy-assets) so it needs no build-time escaping.
|
|
4
|
+
(function () {
|
|
5
|
+
var MB = window.__MB__ || {};
|
|
6
|
+
var sid = MB.sid,
|
|
7
|
+
pid = MB.pid;
|
|
8
|
+
|
|
9
|
+
var $ = function (id) {
|
|
10
|
+
return document.getElementById(id);
|
|
11
|
+
};
|
|
12
|
+
var transcript = $('transcript');
|
|
13
|
+
var scroller = document.querySelector('.transcript');
|
|
14
|
+
var composer = $('composer');
|
|
15
|
+
var sendBtn = $('send');
|
|
16
|
+
var kstate = $('kstate');
|
|
17
|
+
var lastCard = null;
|
|
18
|
+
|
|
19
|
+
function el(tag, cls, text) {
|
|
20
|
+
var e = document.createElement(tag);
|
|
21
|
+
if (cls) e.className = cls;
|
|
22
|
+
if (text != null) e.textContent = text;
|
|
23
|
+
return e;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function scroll() {
|
|
27
|
+
scroller.scrollTop = scroller.scrollHeight;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function addUser(text) {
|
|
31
|
+
var row = el('div', 'msg user');
|
|
32
|
+
row.appendChild(el('div', 'bubble', text));
|
|
33
|
+
transcript.appendChild(row);
|
|
34
|
+
scroll();
|
|
35
|
+
}
|
|
36
|
+
function addAgentText(text) {
|
|
37
|
+
var row = el('div', 'msg agent');
|
|
38
|
+
row.appendChild(el('div', 'prose', text));
|
|
39
|
+
transcript.appendChild(row);
|
|
40
|
+
scroll();
|
|
41
|
+
}
|
|
42
|
+
function addToolCall(tool, code) {
|
|
43
|
+
var card = el('div', 'step');
|
|
44
|
+
var head = el('div', 'step-head');
|
|
45
|
+
head.appendChild(el('span', 'step-dot'));
|
|
46
|
+
head.appendChild(el('span', 'step-title', tool === 'run_python' ? 'Ran Python' : tool));
|
|
47
|
+
card.appendChild(head);
|
|
48
|
+
var pre = el('pre', 'code');
|
|
49
|
+
pre.textContent = code;
|
|
50
|
+
card.appendChild(pre);
|
|
51
|
+
var out = el('pre', 'out');
|
|
52
|
+
out.hidden = true;
|
|
53
|
+
card.appendChild(out);
|
|
54
|
+
transcript.appendChild(card);
|
|
55
|
+
lastCard = card;
|
|
56
|
+
scroll();
|
|
57
|
+
}
|
|
58
|
+
function addToolResult(stdout, stderr) {
|
|
59
|
+
if (!lastCard) return;
|
|
60
|
+
var out = lastCard.querySelector('.out');
|
|
61
|
+
var text = (stdout || '') + (stderr ? (stdout ? '\n' : '') + stderr : '');
|
|
62
|
+
out.textContent = text || '(no output)';
|
|
63
|
+
out.hidden = false;
|
|
64
|
+
if (stderr) out.classList.add('err');
|
|
65
|
+
scroll();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function setRunning(on) {
|
|
69
|
+
sendBtn.disabled = on;
|
|
70
|
+
sendBtn.textContent = on ? 'Running…' : 'Send';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Sidebar: this project's sessions ────────────────────────────────
|
|
74
|
+
function loadSidebar() {
|
|
75
|
+
if (!pid) return;
|
|
76
|
+
fetch('/api/projects/' + pid)
|
|
77
|
+
.then(function (r) {
|
|
78
|
+
return r.json();
|
|
79
|
+
})
|
|
80
|
+
.then(function (p) {
|
|
81
|
+
if (p.project) $('project-name').textContent = p.project.name;
|
|
82
|
+
var list = $('session-list');
|
|
83
|
+
list.textContent = '';
|
|
84
|
+
(p.sessions || []).forEach(function (s) {
|
|
85
|
+
var a = el('a', 'session' + (s.id === sid ? ' active' : ''), s.title);
|
|
86
|
+
a.href = '/projects/' + pid + '/frames/' + s.id;
|
|
87
|
+
list.appendChild(a);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Live event stream ───────────────────────────────────────────────
|
|
93
|
+
function connect() {
|
|
94
|
+
var es = new EventSource('/api/sessions/' + sid + '/events');
|
|
95
|
+
es.onmessage = function (e) {
|
|
96
|
+
var m = JSON.parse(e.data);
|
|
97
|
+
if (m.channel === 'kernel') {
|
|
98
|
+
var f = m.payload;
|
|
99
|
+
if (f.type === 'status') kstate.textContent = f.state === 'busy' ? 'running' : f.state;
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
var p = m.payload;
|
|
103
|
+
if (p.type === 'user') addUser(p.text);
|
|
104
|
+
else if (p.type === 'agent-text') addAgentText(p.text);
|
|
105
|
+
else if (p.type === 'tool-call') addToolCall(p.tool, p.code);
|
|
106
|
+
else if (p.type === 'tool-result') addToolResult(p.stdout, p.stderr);
|
|
107
|
+
else if (p.type === 'agent-done') setRunning(false);
|
|
108
|
+
else if (p.type === 'agent-error') {
|
|
109
|
+
addAgentText('⚠ ' + p.error);
|
|
110
|
+
setRunning(false);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
es.onerror = function () {
|
|
114
|
+
kstate.textContent = 'reconnecting…';
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function send() {
|
|
119
|
+
var text = composer.value.trim();
|
|
120
|
+
if (!text || sendBtn.disabled) return;
|
|
121
|
+
composer.value = '';
|
|
122
|
+
setRunning(true);
|
|
123
|
+
fetch('/api/sessions/' + sid + '/agent', {
|
|
124
|
+
method: 'POST',
|
|
125
|
+
headers: { 'content-type': 'application/json' },
|
|
126
|
+
body: JSON.stringify({ prompt: text }),
|
|
127
|
+
}).catch(function () {
|
|
128
|
+
setRunning(false);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
sendBtn.addEventListener('click', send);
|
|
133
|
+
composer.addEventListener('keydown', function (e) {
|
|
134
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
135
|
+
e.preventDefault();
|
|
136
|
+
send();
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
$('new-session').addEventListener('click', function () {
|
|
140
|
+
fetch('/api/projects/' + pid + '/sessions', {
|
|
141
|
+
method: 'POST',
|
|
142
|
+
headers: { 'content-type': 'application/json' },
|
|
143
|
+
body: JSON.stringify({ title: 'New session' }),
|
|
144
|
+
})
|
|
145
|
+
.then(function (r) {
|
|
146
|
+
return r.json();
|
|
147
|
+
})
|
|
148
|
+
.then(function (s) {
|
|
149
|
+
location.href = '/projects/' + pid + '/frames/' + s.id;
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
fetch('/api/sessions/' + sid)
|
|
154
|
+
.then(function (r) {
|
|
155
|
+
return r.json();
|
|
156
|
+
})
|
|
157
|
+
.then(function (s) {
|
|
158
|
+
if (s && s.title) $('session-title').textContent = s.title;
|
|
159
|
+
// Prefill the composer with the seeded first task so the user can just hit Send.
|
|
160
|
+
if (s && (s.summary || s.title) && !composer.value) composer.value = s.summary || s.title;
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
loadSidebar();
|
|
164
|
+
connect();
|
|
165
|
+
})();
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
4
|
+
import { MEGABRAIN_CONFIG_DIR } from '../paths.js';
|
|
5
|
+
/** Scientific-web egress categories offered in onboarding step "Connect to the scientific web". */
|
|
6
|
+
export const NETWORK_CATEGORIES = [
|
|
7
|
+
'NCBI / NIH',
|
|
8
|
+
'Genomics & biology',
|
|
9
|
+
'Proteomics',
|
|
10
|
+
'Literature & citations',
|
|
11
|
+
'Clinical & pharma',
|
|
12
|
+
];
|
|
13
|
+
/** Featured research connectors offered in onboarding step "Connectors & skills". */
|
|
14
|
+
export const FEATURED_CONNECTORS = [
|
|
15
|
+
'BioMart',
|
|
16
|
+
'bioRxiv',
|
|
17
|
+
'Cancer Models',
|
|
18
|
+
'CellGuide',
|
|
19
|
+
'PubMed',
|
|
20
|
+
'OpenAlex',
|
|
21
|
+
];
|
|
22
|
+
function defaultSettings() {
|
|
23
|
+
return {
|
|
24
|
+
onboarded: false,
|
|
25
|
+
network: [...NETWORK_CATEGORIES],
|
|
26
|
+
connectors: [...FEATURED_CONNECTORS],
|
|
27
|
+
skills: [],
|
|
28
|
+
profile: '',
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const STORE_FILE = path.join(MEGABRAIN_CONFIG_DIR, 'science-workspace.json');
|
|
32
|
+
function id(prefix) {
|
|
33
|
+
return `${prefix}_${randomBytes(8).toString('hex')}`;
|
|
34
|
+
}
|
|
35
|
+
export class WorkspaceStore {
|
|
36
|
+
file;
|
|
37
|
+
now;
|
|
38
|
+
data;
|
|
39
|
+
constructor(file = STORE_FILE, now = () => new Date().toISOString()) {
|
|
40
|
+
this.file = file;
|
|
41
|
+
this.now = now;
|
|
42
|
+
this.data = this.load();
|
|
43
|
+
}
|
|
44
|
+
load() {
|
|
45
|
+
try {
|
|
46
|
+
const parsed = JSON.parse(fs.readFileSync(this.file, 'utf8'));
|
|
47
|
+
return {
|
|
48
|
+
projects: parsed.projects ?? [],
|
|
49
|
+
sessions: parsed.sessions ?? [],
|
|
50
|
+
settings: parsed.settings,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return { projects: [], sessions: [] };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
save() {
|
|
58
|
+
fs.mkdirSync(path.dirname(this.file), { recursive: true });
|
|
59
|
+
// Atomic write so a crash mid-save can't corrupt the workspace.
|
|
60
|
+
const tmp = `${this.file}.${randomBytes(4).toString('hex')}.tmp`;
|
|
61
|
+
fs.writeFileSync(tmp, JSON.stringify(this.data, null, 2), { mode: 0o600 });
|
|
62
|
+
fs.renameSync(tmp, this.file);
|
|
63
|
+
}
|
|
64
|
+
// ── Projects ─────────────────────────────────────────────────────────
|
|
65
|
+
listProjects() {
|
|
66
|
+
return this.data.projects
|
|
67
|
+
.map(p => {
|
|
68
|
+
const sessions = this.data.sessions.filter(s => s.projectId === p.id);
|
|
69
|
+
const lastActivity = sessions.reduce((max, s) => (s.updatedAt > max ? s.updatedAt : max), p.updatedAt);
|
|
70
|
+
return { ...p, sessionCount: sessions.length, lastActivity };
|
|
71
|
+
})
|
|
72
|
+
.sort((a, b) => b.lastActivity.localeCompare(a.lastActivity));
|
|
73
|
+
}
|
|
74
|
+
getProject(projectId) {
|
|
75
|
+
return this.data.projects.find(p => p.id === projectId) ?? null;
|
|
76
|
+
}
|
|
77
|
+
createProject(input) {
|
|
78
|
+
const ts = this.now();
|
|
79
|
+
const project = {
|
|
80
|
+
id: id('proj'),
|
|
81
|
+
name: input.name.trim() || 'Untitled project',
|
|
82
|
+
description: input.description?.trim() ?? '',
|
|
83
|
+
agentContext: input.agentContext?.trim() ?? '',
|
|
84
|
+
createdAt: ts,
|
|
85
|
+
updatedAt: ts,
|
|
86
|
+
};
|
|
87
|
+
this.data.projects.push(project);
|
|
88
|
+
this.save();
|
|
89
|
+
return project;
|
|
90
|
+
}
|
|
91
|
+
updateProject(projectId, patch) {
|
|
92
|
+
const project = this.data.projects.find(p => p.id === projectId);
|
|
93
|
+
if (!project)
|
|
94
|
+
return null;
|
|
95
|
+
if (patch.name !== undefined)
|
|
96
|
+
project.name = patch.name.trim() || project.name;
|
|
97
|
+
if (patch.description !== undefined)
|
|
98
|
+
project.description = patch.description.trim();
|
|
99
|
+
if (patch.agentContext !== undefined)
|
|
100
|
+
project.agentContext = patch.agentContext.trim();
|
|
101
|
+
project.updatedAt = this.now();
|
|
102
|
+
this.save();
|
|
103
|
+
return project;
|
|
104
|
+
}
|
|
105
|
+
// ── Sessions ─────────────────────────────────────────────────────────
|
|
106
|
+
listSessions(projectId) {
|
|
107
|
+
return this.data.sessions
|
|
108
|
+
.filter(s => s.projectId === projectId)
|
|
109
|
+
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
110
|
+
}
|
|
111
|
+
recentSessions(limit = 10) {
|
|
112
|
+
return [...this.data.sessions]
|
|
113
|
+
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
|
114
|
+
.slice(0, limit);
|
|
115
|
+
}
|
|
116
|
+
getSession(sessionId) {
|
|
117
|
+
return this.data.sessions.find(s => s.id === sessionId) ?? null;
|
|
118
|
+
}
|
|
119
|
+
createSession(input) {
|
|
120
|
+
if (!this.getProject(input.projectId))
|
|
121
|
+
return null;
|
|
122
|
+
const ts = this.now();
|
|
123
|
+
const session = {
|
|
124
|
+
id: id('sess'),
|
|
125
|
+
projectId: input.projectId,
|
|
126
|
+
title: input.title?.trim() || 'New session',
|
|
127
|
+
summary: input.summary?.trim() ?? '',
|
|
128
|
+
createdAt: ts,
|
|
129
|
+
updatedAt: ts,
|
|
130
|
+
};
|
|
131
|
+
this.data.sessions.push(session);
|
|
132
|
+
this.save();
|
|
133
|
+
return session;
|
|
134
|
+
}
|
|
135
|
+
/** Bump a session's activity timestamp (and optionally its summary/title). */
|
|
136
|
+
touchSession(sessionId, patch) {
|
|
137
|
+
const session = this.data.sessions.find(s => s.id === sessionId);
|
|
138
|
+
if (!session)
|
|
139
|
+
return null;
|
|
140
|
+
if (patch?.title !== undefined)
|
|
141
|
+
session.title = patch.title.trim() || session.title;
|
|
142
|
+
if (patch?.summary !== undefined)
|
|
143
|
+
session.summary = patch.summary.trim();
|
|
144
|
+
session.updatedAt = this.now();
|
|
145
|
+
this.save();
|
|
146
|
+
return session;
|
|
147
|
+
}
|
|
148
|
+
// ── Onboarding settings ──────────────────────────────────────────────
|
|
149
|
+
getSettings() {
|
|
150
|
+
return { ...defaultSettings(), ...this.data.settings };
|
|
151
|
+
}
|
|
152
|
+
updateSettings(patch) {
|
|
153
|
+
const next = { ...this.getSettings(), ...patch };
|
|
154
|
+
if (patch.profile !== undefined)
|
|
155
|
+
next.profile = patch.profile.trim();
|
|
156
|
+
this.data.settings = next;
|
|
157
|
+
this.save();
|
|
158
|
+
return next;
|
|
159
|
+
}
|
|
160
|
+
}
|