@getmegabrain/cli 0.1.6 → 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.
@@ -0,0 +1,99 @@
1
+ function codeOf(input) {
2
+ return typeof input.code === 'string' ? input.code : '';
3
+ }
4
+ export function pythonTool(bridge) {
5
+ return {
6
+ definition: {
7
+ name: 'run_python',
8
+ description: 'Execute Python in the shared local kernel on the user’s machine. State (variables, imports) persists across calls. Returns stdout and stderr. Prefer small, verifiable steps.',
9
+ input_schema: {
10
+ type: 'object',
11
+ properties: { code: { type: 'string', description: 'Python source to run' } },
12
+ required: ['code'],
13
+ },
14
+ },
15
+ describe: input => codeOf(input),
16
+ run: input => bridge.runCell(codeOf(input)),
17
+ };
18
+ }
19
+ const SYSTEM_PROMPT = `You are the MegaBrain Science research agent. You work in a live
20
+ notebook, sharing one Python kernel with the user — variables and imports persist across
21
+ cells and everything runs locally on the user's machine. Use run_python to write and run
22
+ code and iterate on its output. Prefer small, verifiable steps. When you have the answer,
23
+ stop calling tools and give a concise final summary of what you did and found.`;
24
+ const MAX_TURNS = 16;
25
+ export class AgentSession {
26
+ model;
27
+ emit;
28
+ agentContext;
29
+ cellCounter = 0;
30
+ tools;
31
+ constructor(model, tools, emit, agentContext = '') {
32
+ this.model = model;
33
+ this.emit = emit;
34
+ this.agentContext = agentContext;
35
+ this.tools = new Map(tools.map(t => [t.definition.name, t]));
36
+ }
37
+ async run(prompt) {
38
+ const system = this.agentContext
39
+ ? `${SYSTEM_PROMPT}\n\nProject context: ${this.agentContext}`
40
+ : SYSTEM_PROMPT;
41
+ const messages = [{ role: 'user', content: [{ type: 'text', text: prompt }] }];
42
+ const toolDefs = [...this.tools.values()].map(t => t.definition);
43
+ try {
44
+ for (let turn = 0; turn < MAX_TURNS; turn += 1) {
45
+ const response = await this.model.complete({ system, messages, tools: toolDefs });
46
+ messages.push({ role: 'assistant', content: response.content });
47
+ for (const block of response.content) {
48
+ if (block.type === 'text' && block.text.trim()) {
49
+ this.emit({ type: 'agent-text', text: block.text });
50
+ }
51
+ }
52
+ const toolUses = response.content.filter((b) => b.type === 'tool_use');
53
+ if (toolUses.length === 0) {
54
+ this.emit({ type: 'agent-done', text: collectText(response.content) });
55
+ return;
56
+ }
57
+ const results = [];
58
+ for (const toolUse of toolUses) {
59
+ this.cellCounter += 1;
60
+ const cellId = `agent-${this.cellCounter}`;
61
+ const tool = this.tools.get(toolUse.name);
62
+ if (!tool) {
63
+ results.push({
64
+ type: 'tool_result',
65
+ tool_use_id: toolUse.id,
66
+ content: `Unknown tool: ${toolUse.name}`,
67
+ });
68
+ continue;
69
+ }
70
+ this.emit({
71
+ type: 'tool-call',
72
+ cellId,
73
+ tool: toolUse.name,
74
+ code: tool.describe(toolUse.input),
75
+ });
76
+ const { stdout, stderr } = await tool.run(toolUse.input);
77
+ this.emit({ type: 'tool-result', cellId, stdout, stderr });
78
+ const content = [stdout, stderr && `[stderr]\n${stderr}`].filter(Boolean).join('\n') || '(no output)';
79
+ results.push({ type: 'tool_result', tool_use_id: toolUse.id, content });
80
+ }
81
+ messages.push({ role: 'user', content: results });
82
+ }
83
+ this.emit({ type: 'agent-error', error: `Agent stopped after ${MAX_TURNS} turns.` });
84
+ }
85
+ catch (error) {
86
+ this.emit({
87
+ type: 'agent-error',
88
+ error: error instanceof Error ? error.message : String(error),
89
+ });
90
+ }
91
+ }
92
+ }
93
+ function collectText(content) {
94
+ return content
95
+ .filter((b) => b.type === 'text')
96
+ .map(b => b.text)
97
+ .join('')
98
+ .trim();
99
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Minimal model-client contract (Anthropic Messages / tool-use shape) + the MegaBrain
3
+ * Gateway client. Ported from the retired Electron app. The agent loop is written against
4
+ * `ModelClient` so the transport is swappable; in production it's the Gateway
5
+ * (`/api/gateway/v1/messages`) authenticated with the signed-in account token.
6
+ */
7
+ export class GatewayModel {
8
+ url;
9
+ apiKey;
10
+ model;
11
+ maxTokens;
12
+ constructor(options) {
13
+ this.url =
14
+ options.url ??
15
+ process.env.MB_GATEWAY_URL ??
16
+ `${(process.env.MEGABRAIN_APP_URL ?? 'https://getmegabrain.com').replace(/\/+$/, '')}/api/gateway/v1/messages`;
17
+ this.apiKey = options.apiKey;
18
+ this.model = options.model ?? process.env.MB_SCIENCE_MODEL ?? 'claude-opus-4-8';
19
+ this.maxTokens = options.maxTokens ?? 4096;
20
+ }
21
+ async complete(request) {
22
+ if (!this.apiKey)
23
+ throw new Error('Sign in to MegaBrain to use the agent.');
24
+ const response = await fetch(this.url, {
25
+ method: 'POST',
26
+ headers: {
27
+ 'content-type': 'application/json',
28
+ authorization: `Bearer ${this.apiKey}`,
29
+ 'anthropic-version': '2023-06-01',
30
+ },
31
+ body: JSON.stringify({
32
+ model: this.model,
33
+ max_tokens: this.maxTokens,
34
+ system: request.system,
35
+ messages: request.messages,
36
+ tools: request.tools,
37
+ }),
38
+ });
39
+ if (!response.ok) {
40
+ const detail = await response.text();
41
+ throw new Error(`Gateway request failed (${response.status}): ${detail.slice(0, 500)}`);
42
+ }
43
+ const data = (await response.json());
44
+ return { content: data.content ?? [], stopReason: data.stop_reason ?? 'end_turn' };
45
+ }
46
+ }
@@ -63,6 +63,48 @@ export class KernelBridge {
63
63
  this.start();
64
64
  this.child?.stdin.write(`${JSON.stringify(request)}\n`);
65
65
  }
66
+ /**
67
+ * Execute a cell and resolve with its collected output once the kernel returns to idle.
68
+ * Used by the agent's run_python tool (it needs the result to feed back to the model),
69
+ * while the same frames also stream live to the transcript.
70
+ */
71
+ runCell(code, timeoutMs = 120_000) {
72
+ if (!this.child)
73
+ this.start();
74
+ this.cellCounter += 1;
75
+ const cellId = `cell-${this.cellCounter}`;
76
+ return new Promise(resolve => {
77
+ let stdout = '';
78
+ let stderr = '';
79
+ let done = false;
80
+ const finish = (extraErr = '') => {
81
+ if (done)
82
+ return;
83
+ done = true;
84
+ clearTimeout(timer);
85
+ off();
86
+ resolve({ stdout, stderr: stderr + extraErr });
87
+ };
88
+ const off = this.onFrame(f => {
89
+ if (f.type === 'stream' && f.cellId === cellId) {
90
+ if (f.kind === 'stderr')
91
+ stderr += f.data;
92
+ else
93
+ stdout += f.data;
94
+ }
95
+ else if (f.type === 'status' &&
96
+ f.cellId === cellId &&
97
+ (f.state === 'idle' || f.state === 'error')) {
98
+ finish(f.state === 'error' && f.error ? f.error : '');
99
+ }
100
+ else if (f.type === 'status' && f.state === 'error' && !f.cellId) {
101
+ finish(f.error ? `\n${f.error}` : '\nkernel error');
102
+ }
103
+ });
104
+ const timer = setTimeout(() => finish('\n(timed out)'), timeoutMs);
105
+ this.send({ type: 'execute', cellId, code });
106
+ });
107
+ }
66
108
  restart() {
67
109
  this.stop();
68
110
  this.buffer = '';
@@ -253,43 +253,94 @@ export function startWizardPage(d) {
253
253
  </script>`);
254
254
  }
255
255
  /**
256
- * A working session with a live local-kernel REPL (#274/#277). The transcript + composer
257
- * (reused cloud-agent-next) replaces this markup later; the kernel SSE + execute contract it
258
- * exercises is the durable part compute runs locally, streamed over Server-Sent Events.
256
+ * The working session a full-window Claude-Science-like workspace (#271/#276/#277): a left
257
+ * sidebar of sessions, a chat transcript of the agent's steps, and an "Ask anything" composer
258
+ * that drives the agent (Gateway local kernel). Full-page layout (not the centered card),
259
+ * with the client logic served as /session-client.js.
259
260
  */
260
261
  export function sessionPage(projectId, sessionId) {
261
- const sid = JSON.stringify(sessionId);
262
- return shell('Session — MegaBrain Science', `<div style="text-align:left">
263
- <h1 id="title" style="font-size:18px">Session</h1>
264
- <div class="status" id="kstate">kernel: starting…</div>
265
- <div id="out" style="font:12px/1.5 ui-monospace,Menlo,monospace;white-space:pre-wrap;background:rgba(128,128,128,.08);border-radius:10px;padding:12px;min-height:80px;max-height:40vh;overflow:auto"></div>
266
- <textarea id="code" rows="4" spellcheck="false" style="width:100%;margin-top:10px;font:12px/1.5 ui-monospace,Menlo,monospace" placeholder="print(6 * 7)"></textarea>
267
- <div style="display:flex;gap:8px;margin-top:8px">
268
- <button class="btn btn-primary" style="width:auto;padding:8px 16px;margin:0" id="run">Run ⌘⏎</button>
269
- <button class="btn btn-ghost" style="width:auto;padding:8px 16px;margin:0" id="restart">Restart kernel</button>
270
- <a class="btn btn-ghost" style="width:auto;padding:8px 16px;margin:0" href="/projects/${esc(projectId)}">← Project</a>
271
- </div>
272
- </div>
273
- <script>
274
- const sid = ${sid};
275
- const out = document.getElementById('out');
276
- fetch('/api/sessions/' + sid).then(r => r.json()).then(s => {
277
- if (s && s.title) document.getElementById('title').textContent = s.title;
278
- });
279
- const es = new EventSource('/api/sessions/' + sid + '/kernel/stream');
280
- es.onmessage = e => {
281
- const f = JSON.parse(e.data);
282
- if (f.type === 'status') document.getElementById('kstate').textContent = 'kernel: ' + f.state + (f.error ? ' — ' + f.error : '');
283
- else if (f.type === 'stream') { out.textContent += f.data; out.scrollTop = out.scrollHeight; }
284
- };
285
- async function run() {
286
- const code = document.getElementById('code').value;
287
- if (!code.trim()) return;
288
- out.textContent += '>>> ' + code + '\\n';
289
- await fetch('/api/sessions/' + sid + '/kernel/execute', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ code }) });
290
- }
291
- document.getElementById('run').addEventListener('click', run);
292
- document.getElementById('code').addEventListener('keydown', e => { if ((e.metaKey||e.ctrlKey) && e.key==='Enter') { e.preventDefault(); run(); } });
293
- document.getElementById('restart').addEventListener('click', () => fetch('/api/sessions/' + sid + '/kernel/restart', { method:'POST' }));
294
- </script>`);
262
+ const boot = JSON.stringify({ sid: sessionId, pid: projectId });
263
+ return `<!doctype html>
264
+ <html lang="en">
265
+ <head>
266
+ <meta charset="utf-8" />
267
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
268
+ <title>Session — MegaBrain Science</title>
269
+ <style>
270
+ :root { color-scheme: light dark; --bg:#fff; --panel:#faf9f8; --border:#eceae7; --fg:#1a1a1a; --muted:#8a8a8a; --accent:#c8492a; }
271
+ @media (prefers-color-scheme: dark) { :root { --bg:#111; --panel:#161513; --border:#262626; --fg:#ededed; --muted:#8a8a8a; --accent:#e8623a; } }
272
+ * { box-sizing: border-box; }
273
+ html, body { margin:0; height:100%; }
274
+ body { display:flex; height:100vh; overflow:hidden; background:var(--bg); color:var(--fg);
275
+ font:15px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif; }
276
+ /* Sidebar */
277
+ .sidebar { width:264px; flex-shrink:0; border-right:1px solid var(--border); background:var(--panel);
278
+ display:flex; flex-direction:column; padding:14px; gap:10px; }
279
+ .brand { display:flex; align-items:center; gap:8px; font-weight:600; font-size:14px; }
280
+ .mark { width:26px; height:26px; border-radius:7px; background:linear-gradient(135deg,#e8623a,#c8492a);
281
+ color:#fff; display:grid; place-items:center; font-size:12px; font-weight:700; }
282
+ .newbtn { border:1px solid var(--border); background:var(--bg); color:inherit; border-radius:10px;
283
+ padding:9px 12px; font:inherit; font-weight:600; cursor:pointer; text-align:left; }
284
+ .newbtn:hover { border-color:var(--accent); }
285
+ .proj { font-size:11px; text-transform:uppercase; letter-spacing:.06em; color:var(--muted); margin-top:6px; }
286
+ .sessions { display:flex; flex-direction:column; gap:2px; overflow:auto; flex:1; margin:0 -6px; }
287
+ .session { display:block; padding:8px 10px; border-radius:8px; color:inherit; text-decoration:none;
288
+ font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
289
+ .session:hover { background:var(--bg); }
290
+ .session.active { background:var(--bg); font-weight:600; }
291
+ .side-foot { display:flex; gap:12px; font-size:13px; }
292
+ .side-foot a { color:var(--muted); text-decoration:none; } .side-foot a:hover { color:var(--fg); }
293
+ /* Main */
294
+ .main { flex:1; display:flex; flex-direction:column; min-width:0; }
295
+ .topbar { height:52px; flex-shrink:0; border-bottom:1px solid var(--border); display:flex; align-items:center;
296
+ gap:10px; padding:0 20px; font-weight:600; }
297
+ .kstate { font-size:12px; color:var(--muted); font-weight:400; margin-left:auto; }
298
+ .transcript { flex:1; overflow:auto; padding:24px; display:flex; flex-direction:column; gap:16px; }
299
+ .wrap { width:100%; max-width:760px; margin:0 auto; display:flex; flex-direction:column; gap:16px; }
300
+ .msg.user { display:flex; justify-content:flex-end; }
301
+ .msg.user .bubble { background:var(--accent); color:#fff; border-radius:14px 14px 4px 14px; padding:9px 14px; max-width:80%; white-space:pre-wrap; }
302
+ .msg.agent .prose { white-space:pre-wrap; }
303
+ .step { border:1px solid var(--border); border-radius:12px; overflow:hidden; background:var(--panel); }
304
+ .step-head { display:flex; align-items:center; gap:8px; padding:9px 12px; font-size:13px; font-weight:600; }
305
+ .step-dot { width:7px; height:7px; border-radius:50%; background:var(--accent); }
306
+ .step .code, .step .out { margin:0; padding:10px 12px; font:12px/1.5 ui-monospace,Menlo,monospace;
307
+ white-space:pre-wrap; overflow-x:auto; border-top:1px solid var(--border); }
308
+ .step .out { color:var(--muted); background:var(--bg); }
309
+ .step .out.err { color:#c0392b; }
310
+ /* Composer */
311
+ .composer { flex-shrink:0; border-top:1px solid var(--border); padding:14px 20px; }
312
+ .composer-inner { max-width:760px; margin:0 auto; display:flex; gap:10px; align-items:flex-end;
313
+ border:1px solid var(--border); border-radius:16px; padding:10px 12px; background:var(--panel); }
314
+ .composer-inner:focus-within { border-color:var(--accent); }
315
+ textarea#composer { flex:1; border:0; background:transparent; resize:none; color:inherit; font:inherit;
316
+ outline:none; max-height:160px; }
317
+ .model { font-size:12px; color:var(--muted); align-self:center; white-space:nowrap; }
318
+ #send { border:0; border-radius:10px; background:var(--accent); color:#fff; font:inherit; font-weight:600;
319
+ padding:8px 16px; cursor:pointer; }
320
+ #send:disabled { opacity:.5; cursor:default; }
321
+ </style>
322
+ </head>
323
+ <body>
324
+ <aside class="sidebar">
325
+ <div class="brand"><span class="mark">MB</span> MegaBrain Science</div>
326
+ <button class="newbtn" id="new-session">+ New session</button>
327
+ <div class="proj" id="project-name">Project</div>
328
+ <nav class="sessions" id="session-list"></nav>
329
+ <div class="side-foot"><a href="/">Home</a><a href="/projects/${esc(projectId)}">Project</a></div>
330
+ </aside>
331
+ <main class="main">
332
+ <div class="topbar"><span id="session-title">Session</span><span class="kstate" id="kstate">kernel: idle</span></div>
333
+ <div class="transcript"><div class="wrap" id="transcript"></div></div>
334
+ <div class="composer">
335
+ <div class="composer-inner">
336
+ <textarea id="composer" rows="1" placeholder="Ask anything — the agent runs code locally…"></textarea>
337
+ <span class="model">Opus 4.8</span>
338
+ <button id="send">Send</button>
339
+ </div>
340
+ </div>
341
+ </main>
342
+ <script>window.__MB__ = ${boot};</script>
343
+ <script src="/session-client.js"></script>
344
+ </body>
345
+ </html>`;
295
346
  }
@@ -1,11 +1,15 @@
1
1
  import { randomBytes } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
2
3
  import { createServer } from 'node:http';
4
+ import { fileURLToPath } from 'node:url';
3
5
  import { readCredentials, writeCredentials, clearCredentials } from '../credentials.js';
4
6
  import { startDeviceAuth } from '../device-auth.js';
5
7
  import { NonceStore } from './nonce.js';
6
8
  import { homePage, loginPage, nonceInvalidPage, nonceLandingPage, projectPage, sessionPage, startWizardPage, } from './pages.js';
7
9
  import { FEATURED_CONNECTORS, NETWORK_CATEGORIES, WorkspaceStore } from './store.js';
8
10
  import { KernelManager } from './kernel.js';
11
+ import { AgentSession, pythonTool } from './agent/agent.js';
12
+ import { GatewayModel } from './agent/model.js';
9
13
  /**
10
14
  * The `megabrain science` local daemon's HTTP server. Serves the sign-in hand-off on
11
15
  * localhost (docs/megabrain-science/reference/claude-science-flow.md §1.1) and, once signed
@@ -15,12 +19,25 @@ import { KernelManager } from './kernel.js';
15
19
  */
16
20
  const COOKIE = 'mb_sci';
17
21
  const PREFERRED_PORT = 8765;
22
+ const SESSION_CLIENT = fileURLToPath(new URL('./session-client.js', import.meta.url));
18
23
  export async function startScienceServer(host = '127.0.0.1') {
19
24
  const nonces = new NonceStore();
20
25
  const trusted = new Set();
21
26
  const store = new WorkspaceStore();
22
27
  const kernels = new KernelManager();
23
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
+ };
24
41
  const isSignedIn = () => readCredentials() !== null;
25
42
  const readBody = (req) => new Promise(resolve => {
26
43
  let raw = '';
@@ -70,6 +87,15 @@ export async function startScienceServer(host = '127.0.0.1') {
70
87
  const path = url.pathname;
71
88
  const method = req.method ?? 'GET';
72
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
+ }
73
99
  // ── Sign-in hand-off ───────────────────────────────────────────────
74
100
  if (path === '/' && url.searchParams.has('nonce')) {
75
101
  const result = nonces.claim(url.searchParams.get('nonce') ?? '');
@@ -191,33 +217,54 @@ export async function startScienceServer(host = '127.0.0.1') {
191
217
  });
192
218
  return json(res, { projectId: project.id, sessionId: session?.id ?? null }, 201);
193
219
  }
194
- // ── Local kernel (#274). Per-session Python process; SSE for live frames. ──
195
- if (seg[0] === 'api' && seg[1] === 'sessions' && seg[2] && seg[3] === 'kernel') {
196
- if (!isSignedIn())
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)
197
230
  return json(res, { error: 'signed-out' }, 401);
198
231
  const sid = seg[2];
199
- if (seg[4] === 'stream' && method === 'GET') {
232
+ if (seg[3] === 'events' && method === 'GET') {
200
233
  res.writeHead(200, {
201
234
  'content-type': 'text/event-stream',
202
235
  'cache-control': 'no-cache',
203
236
  connection: 'keep-alive',
204
237
  });
205
- const off = kernels
206
- .get(sid)
207
- .onFrame(frame => res.write(`data: ${JSON.stringify(frame)}\n\n`));
208
- req.on('close', off);
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));
209
249
  return;
210
250
  }
211
- if (seg[4] === 'execute' && method === 'POST') {
212
- const b = await readBody(req);
213
- const cellId = kernels.get(sid).execute(str(b.code) ?? '');
214
- store.touchSession(sid);
215
- return json(res, { cellId });
216
- }
217
- if (seg[4] === 'restart' && method === 'POST') {
251
+ if (seg[3] === 'kernel' && seg[4] === 'restart' && method === 'POST') {
218
252
  kernels.restart(sid);
219
253
  return json(res, { ok: true });
220
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
+ }
221
268
  }
222
269
  // ── Workspace API (projects/sessions, #270/#271). Signed-in only. ────
223
270
  if (path.startsWith('/api/projects') || path.startsWith('/api/sessions')) {
@@ -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
+ })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getmegabrain/cli",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "MegaBrain CLI — sign in once, then code from your terminal via OpenCode wired to the MegaBrain Gateway, or run `megabrain science` for the local research workbench.",
5
5
  "license": "MIT",
6
6
  "type": "module",