nebula-notebook-mcp 0.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.
Files changed (72) hide show
  1. package/README.md +314 -0
  2. package/bin/nebula-mcp.js +10 -0
  3. package/dist/circuit-breaker.d.ts +157 -0
  4. package/dist/circuit-breaker.d.ts.map +1 -0
  5. package/dist/circuit-breaker.js +237 -0
  6. package/dist/circuit-breaker.js.map +1 -0
  7. package/dist/errors.d.ts +72 -0
  8. package/dist/errors.d.ts.map +1 -0
  9. package/dist/errors.js +314 -0
  10. package/dist/errors.js.map +1 -0
  11. package/dist/index.d.ts +13 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +41 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/mcp/index.d.ts +8 -0
  16. package/dist/mcp/index.d.ts.map +1 -0
  17. package/dist/mcp/index.js +13 -0
  18. package/dist/mcp/index.js.map +1 -0
  19. package/dist/mcp/server.d.ts +31 -0
  20. package/dist/mcp/server.d.ts.map +1 -0
  21. package/dist/mcp/server.js +237 -0
  22. package/dist/mcp/server.js.map +1 -0
  23. package/dist/notebook/client.d.ts +643 -0
  24. package/dist/notebook/client.d.ts.map +1 -0
  25. package/dist/notebook/client.js +1720 -0
  26. package/dist/notebook/client.js.map +1 -0
  27. package/dist/notebook/index.d.ts +6 -0
  28. package/dist/notebook/index.d.ts.map +1 -0
  29. package/dist/notebook/index.js +6 -0
  30. package/dist/notebook/index.js.map +1 -0
  31. package/dist/notebook/tools.d.ts +244 -0
  32. package/dist/notebook/tools.d.ts.map +1 -0
  33. package/dist/notebook/tools.js +279 -0
  34. package/dist/notebook/tools.js.map +1 -0
  35. package/dist/tools/execution.d.ts +38 -0
  36. package/dist/tools/execution.d.ts.map +1 -0
  37. package/dist/tools/execution.js +116 -0
  38. package/dist/tools/execution.js.map +1 -0
  39. package/dist/tools/files.d.ts +70 -0
  40. package/dist/tools/files.d.ts.map +1 -0
  41. package/dist/tools/files.js +286 -0
  42. package/dist/tools/files.js.map +1 -0
  43. package/dist/tools/index.d.ts +74 -0
  44. package/dist/tools/index.d.ts.map +1 -0
  45. package/dist/tools/index.js +217 -0
  46. package/dist/tools/index.js.map +1 -0
  47. package/dist/tools/kernel.d.ts +36 -0
  48. package/dist/tools/kernel.d.ts.map +1 -0
  49. package/dist/tools/kernel.js +182 -0
  50. package/dist/tools/kernel.js.map +1 -0
  51. package/dist/tools/notebook.d.ts +252 -0
  52. package/dist/tools/notebook.d.ts.map +1 -0
  53. package/dist/tools/notebook.js +1089 -0
  54. package/dist/tools/notebook.js.map +1 -0
  55. package/dist/tools/types.d.ts +78 -0
  56. package/dist/tools/types.d.ts.map +1 -0
  57. package/dist/tools/types.js +8 -0
  58. package/dist/tools/types.js.map +1 -0
  59. package/dist/types.d.ts +473 -0
  60. package/dist/types.d.ts.map +1 -0
  61. package/dist/types.js +5 -0
  62. package/dist/types.js.map +1 -0
  63. package/dist/utils/imageResize.d.ts +24 -0
  64. package/dist/utils/imageResize.d.ts.map +1 -0
  65. package/dist/utils/imageResize.js +67 -0
  66. package/dist/utils/imageResize.js.map +1 -0
  67. package/dist/utils/polling.d.ts +40 -0
  68. package/dist/utils/polling.d.ts.map +1 -0
  69. package/dist/utils/polling.js +49 -0
  70. package/dist/utils/polling.js.map +1 -0
  71. package/package.json +61 -0
  72. package/setup-mcp.js +468 -0
@@ -0,0 +1,1720 @@
1
+ /**
2
+ * Nebula Notebook Client - Unified API for Notebook Operations
3
+ *
4
+ * This client provides programmatic access to Nebula Notebook, enabling AI agents
5
+ * and automation tools to manipulate notebooks through a consistent interface.
6
+ *
7
+ * ## Architecture
8
+ *
9
+ * The client routes operations through Nebula's Operation Router, which transparently
10
+ * handles both UI-connected and headless modes:
11
+ *
12
+ * ```
13
+ * NebulaClient → HTTP/WS → Operation Router → UI (WebSocket) or Headless (File)
14
+ * ```
15
+ *
16
+ * From the client's perspective, both modes are identical - the router handles
17
+ * the complexity of determining where to apply operations.
18
+ *
19
+ * ## Key Features
20
+ *
21
+ * - **Kernel Management**: Start, stop, restart, interrupt Jupyter kernels
22
+ * - **Cell Operations**: Insert, delete, update, move, duplicate cells
23
+ * - **Code Execution**: WebSocket streaming with real-time output capture
24
+ * - **Agent Sessions**: Lock/unlock notebooks to prevent concurrent access
25
+ * - **Dual Mode**: Works seamlessly with or without UI connected
26
+ *
27
+ * ## Usage
28
+ *
29
+ * ```typescript
30
+ * const client = new NebulaClient({ baseUrl: 'http://localhost:8000' });
31
+ *
32
+ * // Start agent session (shows indicator in UI)
33
+ * await client.startAgentSession(path, 'my-agent');
34
+ *
35
+ * // Insert a cell
36
+ * const result = await client.insertCellOp(path, 0, {
37
+ * id: 'imports',
38
+ * type: 'code',
39
+ * content: 'import numpy as np'
40
+ * });
41
+ *
42
+ * // Execute the cell
43
+ * const execResult = await client.executeCell(path, sessionId, { cellIndex: 0 });
44
+ *
45
+ * // End session
46
+ * await client.endAgentSession(path);
47
+ * ```
48
+ *
49
+ * ## Error Handling
50
+ *
51
+ * All methods return `ToolResult<T>` with `success` boolean and either `data` or `error`:
52
+ *
53
+ * ```typescript
54
+ * const result = await client.insertCellOp(path, 0, cell);
55
+ * if (!result.success) {
56
+ * console.error(result.error);
57
+ * return;
58
+ * }
59
+ * console.log('Inserted cell:', result.data.cellId);
60
+ * ```
61
+ *
62
+ * @see {@link https://github.com/jzthree/nebula-notebook/blob/main/docs/AGENTIC_ARCHITECTURE.md}
63
+ * @module NebulaClient
64
+ */
65
+ /**
66
+ * Client for Nebula Notebook headless API
67
+ */
68
+ export class NebulaClient {
69
+ baseUrl;
70
+ timeout;
71
+ retries;
72
+ agentId;
73
+ clientName;
74
+ clientVersion;
75
+ autoStartAgentSession;
76
+ activeAgentSessions = new Set();
77
+ agentSessionInFlight = new Map();
78
+ pinnedKernelSessions = new Map();
79
+ lastBackend;
80
+ lastAutoStartWarning;
81
+ autoStartWarnedPaths = new Set();
82
+ activeNotebookPath = null;
83
+ // Track last tool call timestamp per notebook path for user change detection
84
+ lastToolCallTimestamp = new Map();
85
+ constructor(config = {}) {
86
+ this.baseUrl = config.baseUrl || 'http://localhost:8000';
87
+ this.timeout = config.timeout || 30000;
88
+ this.retries = config.retries ?? 3;
89
+ this.agentId = config.agentId;
90
+ this.clientName = config.clientName;
91
+ this.clientVersion = config.clientVersion;
92
+ this.autoStartAgentSession = config.autoStartAgentSession ?? Boolean(config.agentId);
93
+ }
94
+ recordBackend(backend) {
95
+ if (backend) {
96
+ this.lastBackend = backend;
97
+ }
98
+ }
99
+ consumeLastBackend() {
100
+ const backend = this.lastBackend;
101
+ this.lastBackend = undefined;
102
+ return backend;
103
+ }
104
+ consumeAutoStartWarning() {
105
+ const warning = this.lastAutoStartWarning;
106
+ this.lastAutoStartWarning = undefined;
107
+ return warning;
108
+ }
109
+ hasActiveAgentSession(path) {
110
+ return this.activeAgentSessions.has(path);
111
+ }
112
+ /**
113
+ * Get the notebook path associated with the active agent session (if any).
114
+ */
115
+ getActiveNotebookPath() {
116
+ return this.activeNotebookPath;
117
+ }
118
+ getPinnedKernelSessionId(path) {
119
+ return this.pinnedKernelSessions.get(path) ?? null;
120
+ }
121
+ setActiveNotebookPath(path) {
122
+ this.activeNotebookPath = path;
123
+ }
124
+ clearActiveNotebookPath(path) {
125
+ if (this.activeNotebookPath === path) {
126
+ const remaining = Array.from(this.activeAgentSessions);
127
+ this.activeNotebookPath = remaining.length > 0 ? remaining[remaining.length - 1] : null;
128
+ }
129
+ }
130
+ pinKernelSession(path, sessionId) {
131
+ this.pinnedKernelSessions.set(path, sessionId);
132
+ }
133
+ clearPinnedKernelSession(path) {
134
+ this.pinnedKernelSessions.delete(path);
135
+ }
136
+ clearPinnedKernelSessionById(sessionId) {
137
+ for (const [path, pinnedSessionId] of this.pinnedKernelSessions.entries()) {
138
+ if (pinnedSessionId === sessionId) {
139
+ this.pinnedKernelSessions.delete(path);
140
+ }
141
+ }
142
+ }
143
+ selectBestSessionForFile(sessions, notebookPath) {
144
+ const matching = sessions.filter(s => s.file_path === notebookPath);
145
+ if (matching.length === 0) {
146
+ return null;
147
+ }
148
+ return matching.reduce((best, session) => {
149
+ const bestCreated = typeof best.created_at === 'number' ? best.created_at : -Infinity;
150
+ const sessionCreated = typeof session.created_at === 'number' ? session.created_at : -Infinity;
151
+ return sessionCreated > bestCreated ? session : best;
152
+ }, matching[0]);
153
+ }
154
+ async resolveKernelSessionIdForNotebook(path, options = {}) {
155
+ const pinnedSessionId = this.getPinnedKernelSessionId(path);
156
+ if (pinnedSessionId) {
157
+ return { success: true, data: { sessionId: pinnedSessionId } };
158
+ }
159
+ const sessions = await this.listSessions();
160
+ if (sessions.success) {
161
+ const existing = this.selectBestSessionForFile(sessions.data || [], path);
162
+ if (existing?.id) {
163
+ this.pinKernelSession(path, existing.id);
164
+ return { success: true, data: { sessionId: existing.id } };
165
+ }
166
+ }
167
+ else if (!options.createIfMissing) {
168
+ return { success: false, error: sessions.error };
169
+ }
170
+ if (!options.createIfMissing) {
171
+ return { success: false, error: 'Kernel session not found for notebook' };
172
+ }
173
+ const created = await this.getOrCreateKernelForFile(path, options.kernelName);
174
+ if (!created.success) {
175
+ return { success: false, error: created.error };
176
+ }
177
+ return { success: true, data: { sessionId: created.data.sessionId } };
178
+ }
179
+ /**
180
+ * Check if an error is retryable (network/connection errors)
181
+ */
182
+ isRetryable(error) {
183
+ if (error instanceof Error) {
184
+ const msg = error.message.toLowerCase();
185
+ return (msg.includes('network') ||
186
+ msg.includes('connection') ||
187
+ msg.includes('econnrefused') ||
188
+ msg.includes('econnreset') ||
189
+ msg.includes('timeout'));
190
+ }
191
+ return false;
192
+ }
193
+ /**
194
+ * Check if an error is a connection failure (server not reachable)
195
+ */
196
+ isConnectionError(error) {
197
+ if (error instanceof Error) {
198
+ const msg = error.message.toLowerCase();
199
+ return (msg.includes('econnrefused') ||
200
+ msg.includes('fetch failed') ||
201
+ msg.includes('network request failed') ||
202
+ msg.includes('failed to fetch') ||
203
+ msg.includes('unable to connect') ||
204
+ msg.includes('enotfound') ||
205
+ msg.includes('getaddrinfo'));
206
+ }
207
+ return false;
208
+ }
209
+ /**
210
+ * Delay for retry backoff
211
+ */
212
+ delay(ms) {
213
+ return new Promise((resolve) => setTimeout(resolve, ms));
214
+ }
215
+ /**
216
+ * Fetch with automatic retry for transient errors
217
+ */
218
+ async fetch(path, options = {}) {
219
+ let lastError = 'Unknown error';
220
+ for (let attempt = 0; attempt < this.retries; attempt++) {
221
+ const controller = new AbortController();
222
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
223
+ try {
224
+ const response = await fetch(`${this.baseUrl}${path}`, {
225
+ ...options,
226
+ signal: controller.signal,
227
+ headers: {
228
+ 'Content-Type': 'application/json',
229
+ ...options.headers,
230
+ },
231
+ });
232
+ clearTimeout(timeoutId);
233
+ if (!response.ok) {
234
+ const error = await response.text();
235
+ // Don't retry 4xx errors (client errors)
236
+ if (response.status >= 400 && response.status < 500) {
237
+ return { success: false, error: `API error ${response.status}: ${error}` };
238
+ }
239
+ lastError = `API error ${response.status}: ${error}`;
240
+ // Retry 5xx errors
241
+ if (attempt < this.retries - 1) {
242
+ await this.delay(1000 * (attempt + 1));
243
+ continue;
244
+ }
245
+ return { success: false, error: lastError };
246
+ }
247
+ const data = await response.json();
248
+ return { success: true, data: data };
249
+ }
250
+ catch (e) {
251
+ clearTimeout(timeoutId);
252
+ if (e instanceof Error && e.name === 'AbortError') {
253
+ lastError = 'Request timeout';
254
+ }
255
+ else if (this.isConnectionError(e)) {
256
+ // Provide helpful error for connection failures
257
+ lastError = `Cannot connect to Nebula server at ${this.baseUrl}. ` +
258
+ `Ensure the server is running or use connect_server to configure the correct URL.`;
259
+ }
260
+ else {
261
+ lastError = `Request failed: ${e instanceof Error ? e.message : String(e)}`;
262
+ }
263
+ // Retry if retryable and not last attempt
264
+ if (this.isRetryable(e) && attempt < this.retries - 1) {
265
+ await this.delay(1000 * (attempt + 1));
266
+ continue;
267
+ }
268
+ return { success: false, error: lastError };
269
+ }
270
+ }
271
+ return { success: false, error: lastError };
272
+ }
273
+ // ===========================================================================
274
+ // Kernel Operations
275
+ // ===========================================================================
276
+ /**
277
+ * List available kernel specs with display names and versions
278
+ */
279
+ async listKernels() {
280
+ const result = await this.fetch('/api/kernels');
281
+ if (!result.success)
282
+ return { success: false, error: result.error };
283
+ return {
284
+ success: true,
285
+ data: result.data?.kernels.map((k) => ({
286
+ name: k.name,
287
+ displayName: k.display_name,
288
+ language: k.language,
289
+ })) ?? [],
290
+ };
291
+ }
292
+ /**
293
+ * List active kernel sessions
294
+ */
295
+ async listSessions() {
296
+ const result = await this.fetch('/api/kernels/sessions');
297
+ if (!result.success)
298
+ return { success: false, error: result.error };
299
+ return { success: true, data: result.data?.sessions ?? [] };
300
+ }
301
+ /**
302
+ * Start a new kernel session
303
+ */
304
+ async startKernel(kernelName = 'python3', filePath) {
305
+ const result = await this.fetch('/api/kernels/start', {
306
+ method: 'POST',
307
+ body: JSON.stringify({ kernel_name: kernelName, file_path: filePath }),
308
+ });
309
+ if (!result.success)
310
+ return { success: false, error: result.error };
311
+ if (filePath) {
312
+ this.pinKernelSession(filePath, result.data.session_id);
313
+ }
314
+ return {
315
+ success: true,
316
+ data: {
317
+ sessionId: result.data.session_id,
318
+ kernelName: result.data.kernel_name,
319
+ status: 'idle',
320
+ filePath,
321
+ },
322
+ };
323
+ }
324
+ /**
325
+ * Get or create a kernel session for a notebook file
326
+ */
327
+ async getOrCreateKernelForFile(filePath, kernelName) {
328
+ const result = await this.fetch('/api/kernels/for-file', {
329
+ method: 'POST',
330
+ body: JSON.stringify({ file_path: filePath, kernel_name: kernelName }),
331
+ });
332
+ if (!result.success)
333
+ return { success: false, error: result.error };
334
+ this.pinKernelSession(filePath, result.data.session_id);
335
+ return {
336
+ success: true,
337
+ data: {
338
+ sessionId: result.data.session_id,
339
+ kernelName: result.data.kernel_name,
340
+ status: 'idle',
341
+ filePath: result.data.file_path,
342
+ },
343
+ };
344
+ }
345
+ /**
346
+ * Execute code in a kernel session (simple REST version)
347
+ */
348
+ async executeCode(sessionId, code) {
349
+ return this.fetch(`/api/kernels/${sessionId}/execute`, {
350
+ method: 'POST',
351
+ body: JSON.stringify({ code }),
352
+ });
353
+ }
354
+ /**
355
+ * Execute code with WebSocket streaming for real-time output
356
+ */
357
+ async executeCodeStreaming(sessionId, code, timeoutMs = 60000) {
358
+ return new Promise((resolve) => {
359
+ const wsUrl = this.baseUrl.replace(/^http/, 'ws') + `/api/kernels/${sessionId}/ws`;
360
+ const outputs = [];
361
+ let executionCount;
362
+ let hasError = false;
363
+ let errorMessage;
364
+ // Use dynamic import for ws in Node.js environment
365
+ const connectWs = async () => {
366
+ try {
367
+ // Try browser WebSocket first
368
+ const WebSocketImpl = typeof WebSocket !== 'undefined' ? WebSocket : (await import('ws')).default;
369
+ const ws = new WebSocketImpl(wsUrl);
370
+ const timeout = setTimeout(() => {
371
+ ws.close();
372
+ resolve({
373
+ success: false,
374
+ error: `Execution timeout after ${timeoutMs / 1000}s`,
375
+ data: { outputs, success: false, executionCount },
376
+ });
377
+ }, timeoutMs);
378
+ ws.onopen = () => {
379
+ ws.send(JSON.stringify({ type: 'execute', code }));
380
+ };
381
+ ws.onmessage = (event) => {
382
+ try {
383
+ const data = typeof event.data === 'string' ? event.data : event.data.toString();
384
+ const msg = JSON.parse(data);
385
+ if (msg.type === 'output') {
386
+ outputs.push(this.parseJupyterOutput(msg.output));
387
+ }
388
+ else if (msg.type === 'result') {
389
+ executionCount = msg.result?.execution_count;
390
+ if (msg.result?.outputs) {
391
+ outputs.push(...msg.result.outputs.map((o) => this.parseJupyterOutput(o)));
392
+ }
393
+ }
394
+ else if (msg.type === 'error') {
395
+ hasError = true;
396
+ errorMessage = msg.error;
397
+ outputs.push({ type: 'error', content: msg.error || 'Unknown error' });
398
+ }
399
+ else if (msg.type === 'status' && msg.status === 'idle') {
400
+ clearTimeout(timeout);
401
+ ws.close();
402
+ resolve({
403
+ success: !hasError,
404
+ data: { outputs, success: !hasError, executionCount, error: errorMessage },
405
+ });
406
+ }
407
+ }
408
+ catch (e) {
409
+ // Ignore parse errors for non-JSON messages
410
+ }
411
+ };
412
+ ws.onerror = (error) => {
413
+ clearTimeout(timeout);
414
+ ws.close();
415
+ resolve({
416
+ success: false,
417
+ error: `WebSocket error: ${error.message || 'Unknown error'}`,
418
+ });
419
+ };
420
+ ws.onclose = () => {
421
+ clearTimeout(timeout);
422
+ };
423
+ }
424
+ catch (e) {
425
+ // Fall back to REST API if WebSocket fails
426
+ const result = await this.executeCode(sessionId, code);
427
+ resolve(result);
428
+ }
429
+ };
430
+ connectWs();
431
+ });
432
+ }
433
+ /**
434
+ * Interrupt a running kernel
435
+ */
436
+ async interruptKernel(sessionId) {
437
+ return this.fetch(`/api/kernels/${sessionId}/interrupt`, {
438
+ method: 'POST',
439
+ });
440
+ }
441
+ /**
442
+ * Restart a kernel session
443
+ */
444
+ async restartKernel(sessionId) {
445
+ return this.fetch(`/api/kernels/${sessionId}/restart`, {
446
+ method: 'POST',
447
+ });
448
+ }
449
+ /**
450
+ * Shutdown a kernel session
451
+ */
452
+ async shutdownKernel(sessionId) {
453
+ const result = await this.fetch(`/api/kernels/${sessionId}`, {
454
+ method: 'DELETE',
455
+ });
456
+ if (result.success) {
457
+ this.clearPinnedKernelSessionById(sessionId);
458
+ }
459
+ return result;
460
+ }
461
+ /**
462
+ * Stop a kernel session (alias for shutdownKernel)
463
+ */
464
+ async stopKernel(sessionId) {
465
+ return this.shutdownKernel(sessionId);
466
+ }
467
+ // ===========================================================================
468
+ // File Operations (non-notebook specific)
469
+ // ===========================================================================
470
+ /**
471
+ * List files in a directory
472
+ */
473
+ async listFiles(path = '.') {
474
+ const result = await this.fetch(`/api/fs/list?path=${encodeURIComponent(path)}`);
475
+ if (!result.success)
476
+ return { success: false, error: result.error };
477
+ // Transform the response to match expected format
478
+ const items = result.data.items.map((item) => ({
479
+ name: item.name,
480
+ type: item.isDirectory ? 'directory' : item.fileType || 'file',
481
+ }));
482
+ return { success: true, data: items };
483
+ }
484
+ /**
485
+ * Read a file's content
486
+ */
487
+ async readFile(path) {
488
+ const result = await this.fetch(`/api/fs/read?path=${encodeURIComponent(path)}`);
489
+ if (!result.success)
490
+ return { success: false, error: result.error };
491
+ // Handle binary files
492
+ if (result.data.type === 'binary') {
493
+ return { success: false, error: result.data.message || 'Binary file cannot be read as text' };
494
+ }
495
+ // Handle notebook files (return as JSON string)
496
+ if (result.data.type === 'notebook') {
497
+ return { success: true, data: { content: JSON.stringify(result.data.content, null, 2) } };
498
+ }
499
+ // Handle text files
500
+ return { success: true, data: { content: result.data.content } };
501
+ }
502
+ /**
503
+ * Write content to a file
504
+ */
505
+ async writeFile(path, content) {
506
+ return this.fetch('/api/fs/write', {
507
+ method: 'POST',
508
+ body: JSON.stringify({ path, content, file_type: 'text' }),
509
+ });
510
+ }
511
+ /**
512
+ * Delete a file or directory
513
+ */
514
+ async deleteFile(path) {
515
+ return this.fetch(`/api/fs/delete?path=${encodeURIComponent(path)}`, {
516
+ method: 'DELETE',
517
+ });
518
+ }
519
+ /**
520
+ * Rename/move a file or directory
521
+ */
522
+ async renameFile(oldPath, newPath) {
523
+ return this.fetch('/api/fs/rename', {
524
+ method: 'POST',
525
+ body: JSON.stringify({ old_path: oldPath, new_path: newPath }),
526
+ });
527
+ }
528
+ /**
529
+ * Download a file as Buffer (supports both text and binary)
530
+ * Uses the /fs/download endpoint that streams raw file content
531
+ */
532
+ async downloadFile(serverPath) {
533
+ try {
534
+ const url = `${this.baseUrl}/api/fs/download?path=${encodeURIComponent(serverPath)}`;
535
+ const response = await fetch(url);
536
+ if (!response.ok) {
537
+ const errorText = await response.text();
538
+ let errorDetail = 'Download failed';
539
+ try {
540
+ const errorJson = JSON.parse(errorText);
541
+ errorDetail = errorJson.detail || errorDetail;
542
+ }
543
+ catch {
544
+ errorDetail = errorText || errorDetail;
545
+ }
546
+ return { success: false, error: errorDetail };
547
+ }
548
+ const arrayBuffer = await response.arrayBuffer();
549
+ const content = Buffer.from(arrayBuffer);
550
+ return { success: true, data: { content } };
551
+ }
552
+ catch (e) {
553
+ const error = e instanceof Error ? e.message : String(e);
554
+ return { success: false, error: `Download failed: ${error}` };
555
+ }
556
+ }
557
+ /**
558
+ * Upload a file using multipart form data (supports binary)
559
+ */
560
+ async uploadFile(destDir, content, filename) {
561
+ try {
562
+ const blob = new Blob([content]);
563
+ const formData = new FormData();
564
+ // Path must precede the file part. The Nebula backend uses
565
+ // @fastify/multipart request.file(), which may not expose fields that
566
+ // arrive after the file stream in the multipart payload.
567
+ formData.append('path', destDir);
568
+ formData.append('file', blob, filename);
569
+ const url = `${this.baseUrl}/api/fs/upload`;
570
+ const response = await fetch(url, {
571
+ method: 'POST',
572
+ body: formData,
573
+ });
574
+ if (!response.ok) {
575
+ const errorText = await response.text();
576
+ let errorDetail = 'Upload failed';
577
+ try {
578
+ const errorJson = JSON.parse(errorText);
579
+ errorDetail = errorJson.detail || errorDetail;
580
+ }
581
+ catch {
582
+ errorDetail = errorText || errorDetail;
583
+ }
584
+ return { success: false, error: errorDetail };
585
+ }
586
+ return { success: true };
587
+ }
588
+ catch (e) {
589
+ const error = e instanceof Error ? e.message : String(e);
590
+ return { success: false, error: `Upload failed: ${error}` };
591
+ }
592
+ }
593
+ /**
594
+ * Get the metadata schema from Nebula API
595
+ */
596
+ async getMetadataSchema() {
597
+ return this.fetch('/api/cell/metadata-schema');
598
+ }
599
+ // ===========================================================================
600
+ // Execution
601
+ // ===========================================================================
602
+ /**
603
+ * Execute a cell in a notebook and save the outputs back
604
+ *
605
+ * This combines read -> execute -> save into a single operation,
606
+ * eliminating round-trips for the common iterative workflow.
607
+ *
608
+ * Supports both cellIndex (positional) and cellId (stable).
609
+ * When save is true, outputs are saved incrementally during execution
610
+ * so the notebook file reflects progress in real-time.
611
+ */
612
+ async executeCell(path, sessionId, options = {}) {
613
+ const { cellIndex, cellId, timeout = 60000, save = true, saveIntervalMs = 2000 } = options;
614
+ if (cellIndex === undefined && cellId === undefined) {
615
+ return { success: false, error: 'Must provide either cellIndex or cellId' };
616
+ }
617
+ // Read the notebook via router (gets UI state if connected, else file)
618
+ const notebookResult = await this.readNotebookViaRouter(path);
619
+ if (!notebookResult.success) {
620
+ return { success: false, error: notebookResult.error };
621
+ }
622
+ const notebook = notebookResult.data;
623
+ let foundIndex = -1;
624
+ let cell;
625
+ if (cellId !== undefined) {
626
+ foundIndex = notebook.cells.findIndex(c => c.id === cellId);
627
+ if (foundIndex === -1) {
628
+ return { success: false, error: `Cell with id '${cellId}' not found` };
629
+ }
630
+ cell = notebook.cells[foundIndex];
631
+ }
632
+ else {
633
+ if (cellIndex < 0 || cellIndex >= notebook.cells.length) {
634
+ return { success: false, error: `Cell index ${cellIndex} out of range (0-${notebook.cells.length - 1})` };
635
+ }
636
+ foundIndex = cellIndex;
637
+ cell = notebook.cells[foundIndex];
638
+ }
639
+ if (cell.type !== 'code') {
640
+ return { success: false, error: `Cell ${foundIndex} is not a code cell (type: ${cell.type})` };
641
+ }
642
+ // Execute with incremental saves via operation router
643
+ const result = await this.executeWithIncrementalSave(sessionId, cell.content, path, cell.id, { timeout, save, saveIntervalMs });
644
+ return {
645
+ success: result.success,
646
+ data: result.data ? {
647
+ ...result.data,
648
+ cellIndex: foundIndex,
649
+ cellId: cell.id,
650
+ } : undefined,
651
+ error: result.error,
652
+ };
653
+ }
654
+ /**
655
+ * Execute code with incremental saves via operation router
656
+ * Outputs are sent to UI (if connected) or saved to file via updateOutputs operation
657
+ */
658
+ async executeWithIncrementalSave(sessionId, code, path, cellId, options) {
659
+ const { timeout, save, saveIntervalMs } = options;
660
+ return new Promise((resolve) => {
661
+ const wsUrl = this.baseUrl.replace(/^http/, 'ws') + `/api/kernels/${sessionId}/ws`;
662
+ const outputs = [];
663
+ let executionCount;
664
+ let hasError = false;
665
+ let errorMessage;
666
+ let lastSaveTime = Date.now();
667
+ let saveTimer;
668
+ const saveOutputs = async () => {
669
+ if (!save)
670
+ return;
671
+ // Use operation router to update outputs (goes to UI if connected)
672
+ await this.updateOutputsOp(path, cellId, [...outputs], executionCount);
673
+ lastSaveTime = Date.now();
674
+ };
675
+ const connectWs = async () => {
676
+ try {
677
+ const WebSocketImpl = typeof WebSocket !== 'undefined' ? WebSocket : (await import('ws')).default;
678
+ const ws = new WebSocketImpl(wsUrl);
679
+ const timeoutTimer = setTimeout(async () => {
680
+ if (saveTimer)
681
+ clearInterval(saveTimer);
682
+ await saveOutputs(); // Final save before closing
683
+ ws.close();
684
+ resolve({
685
+ success: true, // Timeout is not an error, just incomplete
686
+ data: { outputs, success: true, executionCount },
687
+ });
688
+ }, timeout);
689
+ // Set up periodic save interval
690
+ if (save) {
691
+ saveTimer = setInterval(async () => {
692
+ if (outputs.length > 0 && Date.now() - lastSaveTime >= saveIntervalMs) {
693
+ await saveOutputs();
694
+ }
695
+ }, saveIntervalMs);
696
+ }
697
+ ws.onopen = () => {
698
+ ws.send(JSON.stringify({ type: 'execute', code }));
699
+ };
700
+ ws.onmessage = (event) => {
701
+ try {
702
+ const data = typeof event.data === 'string' ? event.data : event.data.toString();
703
+ const msg = JSON.parse(data);
704
+ if (msg.type === 'output') {
705
+ outputs.push(this.parseJupyterOutput(msg.output));
706
+ }
707
+ else if (msg.type === 'result') {
708
+ executionCount = msg.result?.execution_count;
709
+ if (msg.result?.outputs) {
710
+ outputs.push(...msg.result.outputs.map((o) => this.parseJupyterOutput(o)));
711
+ }
712
+ }
713
+ else if (msg.type === 'error') {
714
+ hasError = true;
715
+ errorMessage = msg.error;
716
+ outputs.push({ type: 'error', content: msg.error || 'Unknown error' });
717
+ }
718
+ else if (msg.type === 'status' && msg.status === 'idle') {
719
+ clearTimeout(timeoutTimer);
720
+ if (saveTimer)
721
+ clearInterval(saveTimer);
722
+ // Final save
723
+ (async () => {
724
+ await saveOutputs();
725
+ ws.close();
726
+ resolve({
727
+ success: !hasError,
728
+ data: { outputs, success: !hasError, executionCount, error: errorMessage },
729
+ });
730
+ })();
731
+ }
732
+ }
733
+ catch (e) {
734
+ // Ignore parse errors for non-JSON messages
735
+ }
736
+ };
737
+ ws.onerror = async (error) => {
738
+ clearTimeout(timeoutTimer);
739
+ if (saveTimer)
740
+ clearInterval(saveTimer);
741
+ await saveOutputs(); // Save what we have
742
+ ws.close();
743
+ resolve({
744
+ success: false,
745
+ error: `WebSocket error: ${error.message || 'Unknown error'}`,
746
+ });
747
+ };
748
+ ws.onclose = () => {
749
+ clearTimeout(timeoutTimer);
750
+ if (saveTimer)
751
+ clearInterval(saveTimer);
752
+ };
753
+ }
754
+ catch (e) {
755
+ // Fall back to non-incremental execution
756
+ const result = await this.executeCodeStreaming(sessionId, code, timeout);
757
+ if (result.success && save) {
758
+ await this.updateOutputsOp(path, cellId, result.data.outputs, result.data.executionCount);
759
+ }
760
+ resolve(result);
761
+ }
762
+ };
763
+ connectWs();
764
+ });
765
+ }
766
+ /**
767
+ * Search cells in a notebook by keyword
768
+ */
769
+ async searchCells(path, query, limit = 5) {
770
+ const notebookResult = await this.readNotebookViaRouter(path);
771
+ if (!notebookResult.success) {
772
+ return { success: false, error: notebookResult.error };
773
+ }
774
+ const notebook = notebookResult.data;
775
+ const terms = query.toLowerCase().split(/\s+/).filter((t) => t.length >= 2);
776
+ if (terms.length === 0) {
777
+ return { success: true, data: { cells: [], totalCells: notebook.cells.length } };
778
+ }
779
+ // Score each cell
780
+ const scored = notebook.cells.map((cell, index) => {
781
+ const content = cell.content.toLowerCase();
782
+ let score = 0;
783
+ for (const term of terms) {
784
+ const matches = (content.match(new RegExp(term, 'g')) || []).length;
785
+ score += matches;
786
+ }
787
+ // Boost exact phrase matches
788
+ if (content.includes(query.toLowerCase())) {
789
+ score += 10;
790
+ }
791
+ return { ...cell, index, score };
792
+ });
793
+ // Filter and sort by score
794
+ const matches = scored
795
+ .filter((c) => c.score > 0)
796
+ .sort((a, b) => b.score - a.score)
797
+ .slice(0, limit);
798
+ return {
799
+ success: true,
800
+ data: {
801
+ cells: matches.map((m) => ({
802
+ index: m.index,
803
+ score: m.score,
804
+ type: m.type,
805
+ content: m.content,
806
+ id: m.id,
807
+ })),
808
+ totalCells: notebook.cells.length,
809
+ },
810
+ };
811
+ }
812
+ // ===========================================================================
813
+ // LLM Operations
814
+ // ===========================================================================
815
+ /**
816
+ * Generate code using LLM
817
+ */
818
+ async generateCode(prompt, context, provider, model) {
819
+ return this.fetch('/api/llm/generate', {
820
+ method: 'POST',
821
+ body: JSON.stringify({
822
+ prompt,
823
+ context,
824
+ provider,
825
+ model,
826
+ }),
827
+ });
828
+ }
829
+ /**
830
+ * Chat with notebook context
831
+ */
832
+ async chat(message, notebookContext, history, provider, model) {
833
+ return this.fetch('/api/llm/chat', {
834
+ method: 'POST',
835
+ body: JSON.stringify({
836
+ message,
837
+ context: notebookContext,
838
+ history,
839
+ provider,
840
+ model,
841
+ }),
842
+ });
843
+ }
844
+ // ===========================================================================
845
+ // Helpers
846
+ // ===========================================================================
847
+ parseJupyterOutput(output) {
848
+ // Handle outputs already in internal format (from WebSocket)
849
+ if (output.type && output.content !== undefined) {
850
+ return {
851
+ type: output.type,
852
+ content: output.content,
853
+ };
854
+ }
855
+ // Handle Jupyter notebook format
856
+ if (output.output_type === 'stream') {
857
+ return {
858
+ type: output.name === 'stderr' ? 'stderr' : 'stdout',
859
+ content: Array.isArray(output.text) ? output.text.join('') : output.text,
860
+ };
861
+ }
862
+ if (output.output_type === 'error') {
863
+ return {
864
+ type: 'error',
865
+ content: (output.traceback || []).join('\n'),
866
+ };
867
+ }
868
+ if (output.output_type === 'execute_result' || output.output_type === 'display_data') {
869
+ if (output.data?.['image/png']) {
870
+ return { type: 'image', content: output.data['image/png'] };
871
+ }
872
+ if (output.data?.['text/html']) {
873
+ return { type: 'html', content: output.data['text/html'] };
874
+ }
875
+ if (output.data?.['text/plain']) {
876
+ return { type: 'stdout', content: output.data['text/plain'] };
877
+ }
878
+ }
879
+ return { type: 'stdout', content: JSON.stringify(output) };
880
+ }
881
+ toJupyterOutput(output) {
882
+ switch (output.type) {
883
+ case 'stdout':
884
+ return { output_type: 'stream', name: 'stdout', text: output.content };
885
+ case 'stderr':
886
+ return { output_type: 'stream', name: 'stderr', text: output.content };
887
+ case 'error':
888
+ return { output_type: 'error', traceback: output.content.split('\n') };
889
+ case 'image':
890
+ return { output_type: 'display_data', data: { 'image/png': output.content } };
891
+ case 'html':
892
+ return { output_type: 'display_data', data: { 'text/html': output.content } };
893
+ default:
894
+ return { output_type: 'stream', name: 'stdout', text: output.content };
895
+ }
896
+ }
897
+ // ===========================================================================
898
+ // Operation-Based API (routes through backend to UI or headless)
899
+ // ===========================================================================
900
+ //
901
+ // These methods use the Operation Router pattern:
902
+ // 1. Client sends operation to POST /api/notebook/operation
903
+ // 2. Backend checks if UI is connected via WebSocket
904
+ // 3. If UI connected: forwards to UI, UI applies and returns result
905
+ // 4. If no UI: applies via HeadlessOperationHandler (file-based)
906
+ //
907
+ // From the agent's perspective, both paths are identical.
908
+ // ===========================================================================
909
+ /**
910
+ * Apply a notebook operation through the operation router.
911
+ *
912
+ * Operations are routed to:
913
+ * - Connected UI via WebSocket (if available) - UI applies and saves
914
+ * - Headless manager (file-based) otherwise
915
+ *
916
+ * From the agent's perspective, both modes behave identically.
917
+ */
918
+ async applyOperation(operation) {
919
+ if (this.agentId) {
920
+ operation.agentId = this.agentId;
921
+ if (this.clientName) {
922
+ operation.clientName = this.clientName;
923
+ }
924
+ if (this.clientVersion) {
925
+ operation.clientVersion = this.clientVersion;
926
+ }
927
+ if (this.autoStartAgentSession && this.isWriteOperation(operation.type)) {
928
+ const ensured = await this.ensureAgentSession(operation.notebookPath);
929
+ if (!ensured.success) {
930
+ return { success: false, error: ensured.error };
931
+ }
932
+ }
933
+ }
934
+ const result = await this.fetch('/api/notebook/operation', {
935
+ method: 'POST',
936
+ body: JSON.stringify({ operation }),
937
+ });
938
+ if (result.success) {
939
+ this.recordBackend(result.data?.backend);
940
+ }
941
+ return result;
942
+ }
943
+ isWriteOperation(opType) {
944
+ const readOnlyOps = new Set(['readCell', 'readCellOutput', 'searchCells', 'readNotebook']);
945
+ const sessionOps = new Set(['startAgentSession', 'endAgentSession']);
946
+ const creationOps = new Set(['createNotebook']); // File doesn't exist yet, no session needed
947
+ return !readOnlyOps.has(opType) && !sessionOps.has(opType) && !creationOps.has(opType);
948
+ }
949
+ async ensureAgentSession(path) {
950
+ if (!this.agentId) {
951
+ return { success: true, data: {} };
952
+ }
953
+ if (this.activeAgentSessions.has(path)) {
954
+ return { success: true, data: {} };
955
+ }
956
+ const inFlight = this.agentSessionInFlight.get(path);
957
+ if (inFlight) {
958
+ return inFlight;
959
+ }
960
+ const shouldWarn = !this.autoStartWarnedPaths.has(path);
961
+ const startPromise = this.startAgentSession(path, this.agentId);
962
+ this.agentSessionInFlight.set(path, startPromise);
963
+ try {
964
+ const result = await startPromise;
965
+ if (result.success && shouldWarn) {
966
+ this.lastAutoStartWarning = `Auto-started agent session for ${path}. Call start_agent_session/end_agent_session explicitly.`;
967
+ this.autoStartWarnedPaths.add(path);
968
+ }
969
+ return result;
970
+ }
971
+ finally {
972
+ this.agentSessionInFlight.delete(path);
973
+ }
974
+ }
975
+ /**
976
+ * Read notebook through the operation router.
977
+ *
978
+ * If UI is connected, requests current state from UI.
979
+ * Otherwise reads from file.
980
+ */
981
+ async readNotebookViaRouter(path, options = {}) {
982
+ // Build query string with options
983
+ const params = new URLSearchParams({ path });
984
+ if (options.includeOutputs !== undefined) {
985
+ params.set('include_outputs', String(options.includeOutputs));
986
+ }
987
+ if (options.maxLines !== undefined) {
988
+ params.set('max_lines', String(options.maxLines));
989
+ }
990
+ if (options.maxChars !== undefined) {
991
+ params.set('max_chars', String(options.maxChars));
992
+ }
993
+ if (options.maxLinesError !== undefined) {
994
+ params.set('max_lines_error', String(options.maxLinesError));
995
+ }
996
+ if (options.maxCharsError !== undefined) {
997
+ params.set('max_chars_error', String(options.maxCharsError));
998
+ }
999
+ const result = await this.fetch(`/api/notebook/read?${params.toString()}`);
1000
+ if (!result.success) {
1001
+ return { success: false, error: result.error };
1002
+ }
1003
+ // Router returns response with success/data/error structure
1004
+ const routerResult = result.data;
1005
+ this.recordBackend(routerResult.backend);
1006
+ if (!routerResult.success) {
1007
+ return { success: false, error: routerResult.error };
1008
+ }
1009
+ return {
1010
+ success: true,
1011
+ data: {
1012
+ path: routerResult.data.path,
1013
+ cells: routerResult.data.cells,
1014
+ metadata: routerResult.data.metadata,
1015
+ backend: routerResult.backend,
1016
+ },
1017
+ };
1018
+ }
1019
+ /**
1020
+ * Check if a UI is connected for a notebook path
1021
+ */
1022
+ async hasUI(path) {
1023
+ const result = await this.fetch(`/api/notebook/has-ui?path=${encodeURIComponent(path)}`);
1024
+ return result.success && result.data?.hasUI === true;
1025
+ }
1026
+ // ===========================================================================
1027
+ // Operation-Based Notebook Methods
1028
+ // ===========================================================================
1029
+ /**
1030
+ * Create a new notebook using the operation router.
1031
+ *
1032
+ * Routes to UI if connected (UI creates file and tracks mtime),
1033
+ * otherwise creates via headless manager.
1034
+ *
1035
+ * @param path - Path to create the notebook
1036
+ * @param options - Optional settings
1037
+ * @param options.overwrite - Allow overwriting existing file (default: false)
1038
+ * @param options.kernelName - Kernel name (default: 'python3')
1039
+ * @param options.kernelDisplayName - Display name for kernel
1040
+ * @returns Result with path and mtime on success
1041
+ */
1042
+ async createNotebookOp(path, options = {}) {
1043
+ const { overwrite = false, kernelName = 'python3', kernelDisplayName = 'Python 3' } = options;
1044
+ return this.applyOperation({
1045
+ type: 'createNotebook',
1046
+ notebookPath: path,
1047
+ overwrite,
1048
+ kernelName,
1049
+ kernelDisplayName,
1050
+ });
1051
+ }
1052
+ // ===========================================================================
1053
+ // Operation-Based Cell Methods
1054
+ // ===========================================================================
1055
+ /**
1056
+ * Insert a cell using the operation router
1057
+ */
1058
+ async insertCellOp(path, index, cell) {
1059
+ const operation = {
1060
+ type: 'insertCell',
1061
+ notebookPath: path,
1062
+ index,
1063
+ cell,
1064
+ };
1065
+ const result = await this.applyOperation(operation);
1066
+ if (!result.success) {
1067
+ return { success: false, error: result.error };
1068
+ }
1069
+ const opResult = result.data;
1070
+ if (!opResult.success) {
1071
+ return { success: false, error: opResult.error };
1072
+ }
1073
+ if (opResult.sessionId) {
1074
+ this.pinKernelSession(path, opResult.sessionId);
1075
+ }
1076
+ return {
1077
+ success: true,
1078
+ data: {
1079
+ cellIndex: opResult.cellIndex,
1080
+ cellId: opResult.cellId,
1081
+ totalCells: -1, // Not available from operation result
1082
+ idModified: opResult.idModified,
1083
+ requestedId: opResult.requestedId,
1084
+ },
1085
+ };
1086
+ }
1087
+ /**
1088
+ * Delete a cell using the operation router
1089
+ */
1090
+ async deleteCellOp(path, options) {
1091
+ const operation = {
1092
+ type: 'deleteCell',
1093
+ notebookPath: path,
1094
+ cellId: options.cellId,
1095
+ cellIndex: options.cellIndex,
1096
+ };
1097
+ const result = await this.applyOperation(operation);
1098
+ if (!result.success) {
1099
+ return { success: false, error: result.error };
1100
+ }
1101
+ const opResult = result.data;
1102
+ if (!opResult.success) {
1103
+ return { success: false, error: opResult.error };
1104
+ }
1105
+ return { success: true };
1106
+ }
1107
+ /**
1108
+ * Update cell content using the operation router
1109
+ */
1110
+ async updateContentOp(path, cellId, content) {
1111
+ const operation = {
1112
+ type: 'updateContent',
1113
+ notebookPath: path,
1114
+ cellId,
1115
+ content,
1116
+ };
1117
+ const result = await this.applyOperation(operation);
1118
+ if (!result.success) {
1119
+ return { success: false, error: result.error };
1120
+ }
1121
+ const opResult = result.data;
1122
+ if (!opResult.success) {
1123
+ return { success: false, error: opResult.error };
1124
+ }
1125
+ return { success: true };
1126
+ }
1127
+ /**
1128
+ * Update cell metadata using the operation router
1129
+ */
1130
+ async updateMetadataOp(path, cellId, changes) {
1131
+ const operation = {
1132
+ type: 'updateMetadata',
1133
+ notebookPath: path,
1134
+ cellId,
1135
+ changes,
1136
+ };
1137
+ const result = await this.applyOperation(operation);
1138
+ if (!result.success) {
1139
+ return { success: false, error: result.error };
1140
+ }
1141
+ const opResult = result.data;
1142
+ if (!opResult.success) {
1143
+ return { success: false, error: opResult.error };
1144
+ }
1145
+ return { success: true };
1146
+ }
1147
+ /**
1148
+ * Move a cell using the operation router
1149
+ *
1150
+ * Supports two modes:
1151
+ * 1. By index: moveCellOp(path, 0, 5) - move from index 0 to 5
1152
+ * 2. By ID: moveCellOp(path, 0, 0, { cellId: 'cell-1', afterCellId: 'cell-2' })
1153
+ */
1154
+ async moveCellOp(path, fromIndex, toIndex, options) {
1155
+ const operation = {
1156
+ type: 'moveCell',
1157
+ notebookPath: path,
1158
+ fromIndex,
1159
+ toIndex,
1160
+ ...(options?.cellId && { cellId: options.cellId }),
1161
+ ...(options?.afterCellId && { afterCellId: options.afterCellId }),
1162
+ };
1163
+ const result = await this.applyOperation(operation);
1164
+ if (!result.success) {
1165
+ return { success: false, error: result.error };
1166
+ }
1167
+ const opResult = result.data;
1168
+ if (!opResult.success) {
1169
+ return { success: false, error: opResult.error };
1170
+ }
1171
+ return {
1172
+ success: true,
1173
+ data: {
1174
+ cellId: opResult.cellId,
1175
+ fromIndex: opResult.fromIndex ?? fromIndex,
1176
+ toIndex: opResult.toIndex ?? toIndex,
1177
+ },
1178
+ };
1179
+ }
1180
+ /**
1181
+ * Duplicate a cell using the operation router
1182
+ */
1183
+ async duplicateCellOp(path, cellIndex, newCellId) {
1184
+ const operation = {
1185
+ type: 'duplicateCell',
1186
+ notebookPath: path,
1187
+ cellIndex,
1188
+ newCellId,
1189
+ };
1190
+ const result = await this.applyOperation(operation);
1191
+ if (!result.success) {
1192
+ return { success: false, error: result.error };
1193
+ }
1194
+ const opResult = result.data;
1195
+ if (!opResult.success) {
1196
+ return { success: false, error: opResult.error };
1197
+ }
1198
+ return {
1199
+ success: true,
1200
+ data: {
1201
+ cellIndex: opResult.cellIndex,
1202
+ cellId: opResult.cellId,
1203
+ metadata: opResult.metadata // Phase 2 ready: include metadata from backend
1204
+ },
1205
+ };
1206
+ }
1207
+ /**
1208
+ * Update cell outputs using the operation router
1209
+ */
1210
+ async updateOutputsOp(path, cellId, outputs, executionCount) {
1211
+ const operation = {
1212
+ type: 'updateOutputs',
1213
+ notebookPath: path,
1214
+ cellId,
1215
+ outputs,
1216
+ executionCount,
1217
+ };
1218
+ const result = await this.applyOperation(operation);
1219
+ if (!result.success) {
1220
+ return { success: false, error: result.error };
1221
+ }
1222
+ const opResult = result.data;
1223
+ if (!opResult.success) {
1224
+ return { success: false, error: opResult.error };
1225
+ }
1226
+ return { success: true };
1227
+ }
1228
+ // ===========================================================================
1229
+ // Read Operations (single cell, no full notebook read needed)
1230
+ // ===========================================================================
1231
+ /**
1232
+ * Read a single cell using the operation router.
1233
+ *
1234
+ * More efficient than readNotebook when you only need one cell.
1235
+ * Routes to UI if connected, otherwise reads from file.
1236
+ */
1237
+ async readCellOp(path, options) {
1238
+ const operation = {
1239
+ type: 'readCell',
1240
+ notebookPath: path,
1241
+ cellId: options.cellId,
1242
+ cellIndex: options.cellIndex,
1243
+ };
1244
+ const result = await this.applyOperation(operation);
1245
+ if (!result.success) {
1246
+ return { success: false, error: result.error };
1247
+ }
1248
+ const opResult = result.data;
1249
+ if (!opResult.success) {
1250
+ return { success: false, error: opResult.error };
1251
+ }
1252
+ return {
1253
+ success: true,
1254
+ data: {
1255
+ cell: opResult.cell,
1256
+ cellIndex: opResult.cellIndex,
1257
+ },
1258
+ };
1259
+ }
1260
+ /**
1261
+ * Read cell outputs using the operation router.
1262
+ *
1263
+ * More efficient than readNotebook when you only need outputs.
1264
+ * Routes to UI if connected, otherwise reads from file.
1265
+ *
1266
+ * Supports truncation options for large outputs:
1267
+ * - maxLines: Max lines for regular output (default: 100)
1268
+ * - maxChars: Max characters for regular output (default: 10000)
1269
+ * - maxLinesError: Max lines for error output (default: 200)
1270
+ * - maxCharsError: Max characters for error output (default: 20000)
1271
+ * - lineOffset: Skip first N lines for pagination
1272
+ * - saveToFile: Force save full output to temp file
1273
+ */
1274
+ async readCellOutputOp(path, options) {
1275
+ const operation = {
1276
+ type: 'readCellOutput',
1277
+ notebookPath: path,
1278
+ cellId: options.cellId,
1279
+ cellIndex: options.cellIndex,
1280
+ // Pass truncation options using snake_case for backend
1281
+ max_lines: options.maxLines,
1282
+ max_chars: options.maxChars,
1283
+ max_lines_error: options.maxLinesError,
1284
+ max_chars_error: options.maxCharsError,
1285
+ line_offset: options.lineOffset,
1286
+ save_to_file: options.saveToFile,
1287
+ };
1288
+ const result = await this.applyOperation(operation);
1289
+ if (!result.success) {
1290
+ return { success: false, error: result.error };
1291
+ }
1292
+ const opResult = result.data;
1293
+ if (!opResult.success) {
1294
+ return { success: false, error: opResult.error };
1295
+ }
1296
+ return {
1297
+ success: true,
1298
+ data: {
1299
+ outputs: opResult.outputs || [],
1300
+ executionCount: opResult.executionCount,
1301
+ executionStatus: opResult.executionStatus,
1302
+ cellId: opResult.cellId,
1303
+ cellIndex: opResult.cellIndex,
1304
+ temp_files: opResult.temp_files,
1305
+ },
1306
+ };
1307
+ }
1308
+ /**
1309
+ * Clear all cells from a notebook using the operation router
1310
+ *
1311
+ * This is the proper way to clear a notebook - routes to UI if connected
1312
+ * or falls back to headless mode. Returns the number of cells deleted.
1313
+ */
1314
+ async clearNotebookOp(path) {
1315
+ const operation = {
1316
+ type: 'clearNotebook',
1317
+ notebookPath: path,
1318
+ };
1319
+ const result = await this.applyOperation(operation);
1320
+ if (!result.success) {
1321
+ return { success: false, error: result.error };
1322
+ }
1323
+ const opResult = result.data;
1324
+ if (!opResult.success) {
1325
+ return { success: false, error: opResult.error };
1326
+ }
1327
+ return {
1328
+ success: true,
1329
+ data: {
1330
+ deletedCount: opResult.deletedCount ?? 0,
1331
+ metadata: opResult.metadata // Phase 2 ready: include metadata from backend
1332
+ },
1333
+ };
1334
+ }
1335
+ // ===========================================================================
1336
+ // Batch Operations (Phase 1 enhancements)
1337
+ // ===========================================================================
1338
+ /**
1339
+ * Delete multiple cells by ID in a single operation
1340
+ *
1341
+ * More efficient than multiple single deletes - handles index shifting automatically.
1342
+ */
1343
+ async deleteCellsOp(path, cellIds) {
1344
+ const operation = {
1345
+ type: 'deleteCells',
1346
+ notebookPath: path,
1347
+ cellIds,
1348
+ };
1349
+ const result = await this.applyOperation(operation);
1350
+ if (!result.success) {
1351
+ return { success: false, error: result.error };
1352
+ }
1353
+ const opResult = result.data;
1354
+ if (!opResult.success) {
1355
+ return { success: false, error: opResult.error };
1356
+ }
1357
+ return {
1358
+ success: true,
1359
+ data: {
1360
+ deletedCount: opResult.deletedCount ?? 0,
1361
+ deletedIds: opResult.deletedIds ?? [],
1362
+ notFound: opResult.notFound,
1363
+ totalCells: opResult.totalCells ?? 0,
1364
+ },
1365
+ };
1366
+ }
1367
+ /**
1368
+ * Insert multiple cells at a position in a single operation
1369
+ *
1370
+ * More efficient than multiple single inserts.
1371
+ */
1372
+ async insertCellsOp(path, cells, position = -1) {
1373
+ const operation = {
1374
+ type: 'insertCells',
1375
+ notebookPath: path,
1376
+ cells,
1377
+ position,
1378
+ };
1379
+ const result = await this.applyOperation(operation);
1380
+ if (!result.success) {
1381
+ return { success: false, error: result.error };
1382
+ }
1383
+ const opResult = result.data;
1384
+ if (!opResult.success) {
1385
+ return { success: false, error: opResult.error };
1386
+ }
1387
+ return {
1388
+ success: true,
1389
+ data: {
1390
+ insertedCount: opResult.insertedCount ?? 0,
1391
+ insertedIds: opResult.insertedIds ?? [],
1392
+ startIndex: opResult.startIndex ?? 0,
1393
+ totalCells: opResult.totalCells ?? 0,
1394
+ },
1395
+ };
1396
+ }
1397
+ /**
1398
+ * Search cells with optional output search
1399
+ *
1400
+ * Enhanced version that can search in cell outputs as well as source code.
1401
+ */
1402
+ async searchCellsOp(path, query, options = {}) {
1403
+ const operation = {
1404
+ type: 'searchCells',
1405
+ notebookPath: path,
1406
+ query,
1407
+ includeOutputs: options.includeOutputs ?? false,
1408
+ limit: options.limit ?? 10,
1409
+ };
1410
+ const result = await this.applyOperation(operation);
1411
+ if (!result.success) {
1412
+ return { success: false, error: result.error };
1413
+ }
1414
+ const opResult = result.data;
1415
+ if (!opResult.success) {
1416
+ return { success: false, error: opResult.error };
1417
+ }
1418
+ return {
1419
+ success: true,
1420
+ data: {
1421
+ query: opResult.query ?? query,
1422
+ matchCount: opResult.matchCount ?? 0,
1423
+ matches: opResult.matches ?? [],
1424
+ hasMore: opResult.hasMore ?? false,
1425
+ },
1426
+ };
1427
+ }
1428
+ /**
1429
+ * Clear outputs from one or more cells
1430
+ *
1431
+ * Useful for cleanup before sharing notebooks.
1432
+ */
1433
+ async clearOutputsOp(path, cellIds) {
1434
+ const operation = {
1435
+ type: 'clearOutputs',
1436
+ notebookPath: path,
1437
+ ...(cellIds && cellIds.length > 0 ? { cellIds } : {}),
1438
+ };
1439
+ const result = await this.applyOperation(operation);
1440
+ if (!result.success) {
1441
+ return { success: false, error: result.error };
1442
+ }
1443
+ const opResult = result.data;
1444
+ if (!opResult.success) {
1445
+ return { success: false, error: opResult.error };
1446
+ }
1447
+ return {
1448
+ success: true,
1449
+ data: {
1450
+ clearedCount: opResult.clearedCount ?? 0,
1451
+ clearedIds: opResult.clearedIds ?? [],
1452
+ notFound: opResult.notFound,
1453
+ },
1454
+ };
1455
+ }
1456
+ // ===========================================================================
1457
+ // Execution Operations
1458
+ // ===========================================================================
1459
+ /**
1460
+ * Execute a cell through the operation router
1461
+ *
1462
+ * Routes to UI if connected (shows running indicator) or executes in headless mode.
1463
+ * For long-running cells, returns with status="busy" after maxWait seconds.
1464
+ * Use readCellOutputOp with max_wait to poll for more output.
1465
+ */
1466
+ async executeCellOp(path, options) {
1467
+ const resolvedSession = options.sessionId
1468
+ ? { success: true, data: { sessionId: options.sessionId } }
1469
+ : await this.resolveKernelSessionIdForNotebook(path, {
1470
+ createIfMissing: true,
1471
+ });
1472
+ if (!resolvedSession.success) {
1473
+ return { success: false, error: resolvedSession.error };
1474
+ }
1475
+ const effectiveSessionId = resolvedSession.data.sessionId;
1476
+ this.pinKernelSession(path, effectiveSessionId);
1477
+ const operation = {
1478
+ type: 'executeCell',
1479
+ notebookPath: path,
1480
+ cellId: options.cellId,
1481
+ cellIndex: options.cellIndex,
1482
+ sessionId: effectiveSessionId,
1483
+ maxWait: options.maxWait,
1484
+ saveOutputs: options.saveOutputs,
1485
+ };
1486
+ const result = await this.applyOperation(operation);
1487
+ if (!result.success) {
1488
+ return { success: false, error: result.error };
1489
+ }
1490
+ const opResult = result.data;
1491
+ if (!opResult.success) {
1492
+ return { success: false, error: opResult.error };
1493
+ }
1494
+ return {
1495
+ success: true,
1496
+ data: {
1497
+ cellId: opResult.cellId,
1498
+ cellIndex: opResult.cellIndex,
1499
+ executionStatus: opResult.executionStatus ?? 'idle',
1500
+ executionCount: opResult.executionCount,
1501
+ outputs: opResult.outputs ?? [],
1502
+ executionTime: opResult.executionTime,
1503
+ sessionId: opResult.sessionId,
1504
+ error: opResult.error,
1505
+ },
1506
+ };
1507
+ }
1508
+ // ===========================================================================
1509
+ // Agent Session Management
1510
+ // ===========================================================================
1511
+ //
1512
+ // Agent sessions provide:
1513
+ // 1. UI feedback: Shows "Agent" badge with purple styling when session active
1514
+ // 2. Conflict prevention: Warns if multiple agents try to access same notebook
1515
+ // 3. Session tracking: Records start time and agent ID for diagnostics
1516
+ //
1517
+ // Best practice: Always wrap agent operations in try/finally:
1518
+ //
1519
+ // await client.startAgentSession(path, 'my-agent');
1520
+ // try {
1521
+ // // ... operations ...
1522
+ // } finally {
1523
+ // await client.endAgentSession(path);
1524
+ // }
1525
+ //
1526
+ // In headless mode (no UI), session operations are no-ops that always succeed.
1527
+ // ===========================================================================
1528
+ /**
1529
+ * Start an agent session (locks notebook for agent use).
1530
+ * If a session is already active, returns a warning but still starts a new session.
1531
+ *
1532
+ * @param path - Path to the notebook file
1533
+ * @param agentId - Optional identifier for this agent session
1534
+ * @param force - If true, forcibly end any existing session and steal the lock.
1535
+ * Use only with explicit user permission.
1536
+ * @param lastSessionTimestamp - Optional timestamp (ms) to fetch updates since last session.
1537
+ */
1538
+ async startAgentSession(path, agentId, force, lastSessionTimestamp) {
1539
+ const operation = {
1540
+ type: 'startAgentSession',
1541
+ notebookPath: path,
1542
+ agentId: agentId || this.agentId,
1543
+ clientName: this.clientName,
1544
+ clientVersion: this.clientVersion,
1545
+ force,
1546
+ lastSessionTimestamp,
1547
+ };
1548
+ const result = await this.applyOperation(operation);
1549
+ if (!result.success) {
1550
+ return { success: false, error: result.error };
1551
+ }
1552
+ const opResult = result.data;
1553
+ if (!opResult.success) {
1554
+ return { success: false, error: opResult.error };
1555
+ }
1556
+ this.activeAgentSessions.add(path);
1557
+ this.setActiveNotebookPath(path);
1558
+ await this.resolveKernelSessionIdForNotebook(path, { createIfMissing: false }).catch(() => undefined);
1559
+ return {
1560
+ success: true,
1561
+ data: {
1562
+ warning: opResult.warning,
1563
+ updatesSince: opResult.updatesSince,
1564
+ },
1565
+ };
1566
+ }
1567
+ /**
1568
+ * End an agent session (unlocks notebook).
1569
+ * Should be called when agent work is complete.
1570
+ */
1571
+ async endAgentSession(path) {
1572
+ const operation = {
1573
+ type: 'endAgentSession',
1574
+ notebookPath: path,
1575
+ };
1576
+ const result = await this.applyOperation(operation);
1577
+ if (!result.success) {
1578
+ return { success: false, error: result.error };
1579
+ }
1580
+ const opResult = result.data;
1581
+ if (!opResult.success) {
1582
+ return { success: false, error: opResult.error };
1583
+ }
1584
+ this.activeAgentSessions.delete(path);
1585
+ this.clearActiveNotebookPath(path);
1586
+ this.clearPinnedKernelSession(path);
1587
+ return {
1588
+ success: true,
1589
+ data: {
1590
+ sessionDuration: opResult.sessionDuration,
1591
+ warning: opResult.warning,
1592
+ },
1593
+ };
1594
+ }
1595
+ // ===========================================================================
1596
+ // Python Environment Discovery
1597
+ // ===========================================================================
1598
+ /**
1599
+ * List available Python environments on the system.
1600
+ *
1601
+ * Discovers Python installations from:
1602
+ * - System Python
1603
+ * - Conda environments
1604
+ * - pyenv versions
1605
+ * - Virtual environments
1606
+ * - Homebrew Python
1607
+ */
1608
+ async listPythonEnvironments(refresh = false) {
1609
+ const result = await this.fetch(`/api/python/environments?refresh=${refresh}`);
1610
+ if (!result.success) {
1611
+ return { success: false, error: result.error };
1612
+ }
1613
+ return {
1614
+ success: true,
1615
+ data: result.data.environments.map(env => ({
1616
+ name: env.name,
1617
+ path: env.path,
1618
+ version: env.version,
1619
+ source: env.source,
1620
+ isActive: env.is_active,
1621
+ })),
1622
+ };
1623
+ }
1624
+ /**
1625
+ * Get detailed information about a Python environment.
1626
+ *
1627
+ * @param pythonPath - Path to the Python executable (optional, uses system Python if not provided)
1628
+ */
1629
+ async getPythonInfo(pythonPath) {
1630
+ try {
1631
+ // If no path provided, get info about the first available Python
1632
+ if (!pythonPath) {
1633
+ const envResult = await this.listPythonEnvironments();
1634
+ if (!envResult.success || !envResult.data?.length) {
1635
+ return { success: false, error: 'No Python environments found' };
1636
+ }
1637
+ pythonPath = envResult.data[0].path;
1638
+ }
1639
+ // For now, return basic info from the environment list
1640
+ // A more detailed endpoint could be added to the backend
1641
+ const envResult = await this.listPythonEnvironments();
1642
+ if (!envResult.success) {
1643
+ return { success: false, error: envResult.error };
1644
+ }
1645
+ const env = envResult.data?.find(e => e.path === pythonPath);
1646
+ if (!env) {
1647
+ return { success: false, error: `Python not found: ${pythonPath}` };
1648
+ }
1649
+ return {
1650
+ success: true,
1651
+ data: {
1652
+ path: env.path,
1653
+ version: env.version || 'unknown',
1654
+ },
1655
+ };
1656
+ }
1657
+ catch (error) {
1658
+ const message = error instanceof Error ? error.message : String(error);
1659
+ return { success: false, error: message };
1660
+ }
1661
+ }
1662
+ // ===========================================================================
1663
+ // User Change Tracking
1664
+ // ===========================================================================
1665
+ /**
1666
+ * Get the last tool call timestamp for a notebook path.
1667
+ * Used internally to track user changes between tool calls.
1668
+ */
1669
+ getLastToolCallTimestamp(path) {
1670
+ return this.lastToolCallTimestamp.get(path) ?? 0;
1671
+ }
1672
+ /**
1673
+ * Record the current time as the last tool call for a notebook path.
1674
+ * Called automatically after each operation.
1675
+ */
1676
+ recordToolCallTimestamp(path, timestamp) {
1677
+ this.lastToolCallTimestamp.set(path, timestamp ?? Date.now());
1678
+ }
1679
+ /**
1680
+ * Get updates since the last tool call for a notebook.
1681
+ *
1682
+ * Returns summaries of edits and events since the last recorded
1683
+ * tool call timestamp (no source filtering).
1684
+ */
1685
+ async getUpdatesSinceOp(path) {
1686
+ const sinceTimestamp = this.getLastToolCallTimestamp(path);
1687
+ const operation = {
1688
+ type: 'getUpdatesSince',
1689
+ notebookPath: path,
1690
+ sinceTimestamp,
1691
+ };
1692
+ const result = await this.applyOperation(operation);
1693
+ if (!result.success) {
1694
+ return { success: false, error: result.error };
1695
+ }
1696
+ const opResult = result.data;
1697
+ if (!opResult.success) {
1698
+ return { success: false, error: opResult.error };
1699
+ }
1700
+ // Update the timestamp with server's timestamp if provided
1701
+ if (opResult.serverTimestamp) {
1702
+ this.recordToolCallTimestamp(path, opResult.serverTimestamp);
1703
+ }
1704
+ return {
1705
+ success: true,
1706
+ data: {
1707
+ updates: opResult.updatesSince ?? [],
1708
+ sinceTimestamp,
1709
+ serverTimestamp: opResult.serverTimestamp ?? Date.now(),
1710
+ },
1711
+ };
1712
+ }
1713
+ }
1714
+ /**
1715
+ * Create a new Nebula client
1716
+ */
1717
+ export function createNebulaClient(config) {
1718
+ return new NebulaClient(config);
1719
+ }
1720
+ //# sourceMappingURL=client.js.map