@paperpod/cli 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.
@@ -0,0 +1,684 @@
1
+ /**
2
+ * WebSocket Transport
3
+ *
4
+ * Handles WebSocket connection to PaperPod server.
5
+ * Provides a simple request/response interface over WebSocket.
6
+ *
7
+ * Note: The server derives sandbox ID from the authenticated user's
8
+ * identity (email). No client-side session tracking needed.
9
+ */
10
+ import WebSocket from "ws";
11
+ import { getToken, getApiUrl, DEFAULT_TIMEOUT } from "./config.js";
12
+ // ============================================================================
13
+ // Transport Class
14
+ // ============================================================================
15
+ export class PaperpodTransport {
16
+ ws = null;
17
+ sandboxId = null;
18
+ pendingRequests = new Map();
19
+ globalSuggestionHandler = null;
20
+ messageIdCounter = 0;
21
+ /**
22
+ * Connect to PaperPod WebSocket server
23
+ *
24
+ * The server derives the sandbox ID from your authenticated identity.
25
+ * Same token → same user → same sandbox.
26
+ */
27
+ async connect(options = {}) {
28
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
29
+ return this.sandboxId;
30
+ }
31
+ const token = getToken();
32
+ if (!token) {
33
+ throw new Error("No token found. Set PAPERPOD_TOKEN env var or run: ppod login");
34
+ }
35
+ const url = getApiUrl();
36
+ return new Promise((resolve, reject) => {
37
+ const timeout = setTimeout(() => {
38
+ reject(new Error("Connection timeout"));
39
+ this.ws?.close();
40
+ }, options.timeout ?? DEFAULT_TIMEOUT);
41
+ this.ws = new WebSocket(url, {
42
+ headers: {
43
+ Authorization: `Bearer ${token}`,
44
+ },
45
+ });
46
+ this.ws.on("open", () => {
47
+ // Wait for connected message
48
+ });
49
+ this.ws.on("message", (data) => {
50
+ try {
51
+ const message = JSON.parse(data.toString());
52
+ // Handle connection acknowledgment
53
+ if (message.type === "connected") {
54
+ clearTimeout(timeout);
55
+ this.sandboxId = message.data?.sessionId || null;
56
+ resolve(this.sandboxId);
57
+ return;
58
+ }
59
+ // Handle streaming output
60
+ if (message.type === "stdout" && message.id) {
61
+ const pending = this.pendingRequests.get(message.id);
62
+ if (pending?.onStdout) {
63
+ pending.onStdout(message.data?.text ?? "");
64
+ }
65
+ return;
66
+ }
67
+ if (message.type === "stderr" && message.id) {
68
+ const pending = this.pendingRequests.get(message.id);
69
+ if (pending?.onStderr) {
70
+ pending.onStderr(message.data?.text ?? "");
71
+ }
72
+ return;
73
+ }
74
+ // Handle suggestion messages (port detection, etc.)
75
+ if (message.type === "suggestion") {
76
+ const suggestionData = message.data;
77
+ if (suggestionData) {
78
+ // Try request-specific handler first, then global
79
+ if (message.id) {
80
+ const pending = this.pendingRequests.get(message.id);
81
+ if (pending?.onSuggestion) {
82
+ pending.onSuggestion(suggestionData);
83
+ return;
84
+ }
85
+ }
86
+ // Fall back to global handler
87
+ if (this.globalSuggestionHandler) {
88
+ this.globalSuggestionHandler(suggestionData);
89
+ }
90
+ }
91
+ return;
92
+ }
93
+ // Handle result/exit/error responses
94
+ if (message.id && (message.type === "result" || message.type === "exit" || message.type === "error")) {
95
+ const pending = this.pendingRequests.get(message.id);
96
+ if (pending) {
97
+ this.pendingRequests.delete(message.id);
98
+ pending.resolve(message);
99
+ }
100
+ }
101
+ }
102
+ catch {
103
+ // Ignore parse errors
104
+ }
105
+ });
106
+ this.ws.on("error", (error) => {
107
+ clearTimeout(timeout);
108
+ reject(error);
109
+ });
110
+ this.ws.on("close", (code, reason) => {
111
+ clearTimeout(timeout);
112
+ // Reject all pending requests
113
+ for (const [id, pending] of this.pendingRequests) {
114
+ pending.reject(new Error(`Connection closed: ${code} ${reason}`));
115
+ this.pendingRequests.delete(id);
116
+ }
117
+ this.ws = null;
118
+ this.sandboxId = null;
119
+ });
120
+ });
121
+ }
122
+ /**
123
+ * Disconnect from server
124
+ */
125
+ disconnect() {
126
+ if (this.ws) {
127
+ this.ws.close();
128
+ this.ws = null;
129
+ this.sandboxId = null;
130
+ }
131
+ this.globalSuggestionHandler = null;
132
+ }
133
+ /**
134
+ * Set a global suggestion handler for background suggestions (like port detection)
135
+ */
136
+ setSuggestionHandler(handler) {
137
+ this.globalSuggestionHandler = handler;
138
+ }
139
+ /**
140
+ * Send a message and wait for response
141
+ */
142
+ async send(message, options = {}) {
143
+ await this.connect(options);
144
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
145
+ throw new Error("Not connected");
146
+ }
147
+ const id = `msg_${++this.messageIdCounter}_${Date.now()}`;
148
+ const messageWithId = { ...message, id };
149
+ return new Promise((resolve, reject) => {
150
+ const timeout = setTimeout(() => {
151
+ this.pendingRequests.delete(id);
152
+ reject(new Error("Request timeout"));
153
+ }, options.timeout ?? DEFAULT_TIMEOUT);
154
+ this.pendingRequests.set(id, {
155
+ resolve: (response) => {
156
+ clearTimeout(timeout);
157
+ resolve(response);
158
+ },
159
+ reject: (error) => {
160
+ clearTimeout(timeout);
161
+ reject(error);
162
+ },
163
+ onStdout: options.onStdout,
164
+ onStderr: options.onStderr,
165
+ onSuggestion: options.onSuggestion,
166
+ });
167
+ this.ws.send(JSON.stringify(messageWithId));
168
+ });
169
+ }
170
+ /**
171
+ * Execute a command and return result
172
+ */
173
+ async exec(command, options = {}) {
174
+ const response = await this.send({
175
+ type: "exec",
176
+ command,
177
+ stream: !!(options.onStdout || options.onStderr),
178
+ }, options);
179
+ // Handle exit message (streaming) or result message (non-streaming)
180
+ const data = response.data || {};
181
+ return {
182
+ exitCode: data.exitCode ?? 1,
183
+ stdout: data.stdout ?? "",
184
+ stderr: data.stderr ?? "",
185
+ };
186
+ }
187
+ /**
188
+ * Write a file to the sandbox
189
+ */
190
+ async writeFile(path, content, options = {}) {
191
+ const response = await this.send({
192
+ type: "write",
193
+ path,
194
+ content,
195
+ }, options);
196
+ if (response.data?.success === false) {
197
+ throw new Error(response.data.error || "Write failed");
198
+ }
199
+ }
200
+ /**
201
+ * Read a file from the sandbox
202
+ */
203
+ async readFile(path, options = {}) {
204
+ const response = await this.send({
205
+ type: "read",
206
+ path,
207
+ }, options);
208
+ if (response.data?.success === false) {
209
+ throw new Error(response.data.error || "Read failed");
210
+ }
211
+ return response.data?.content ?? "";
212
+ }
213
+ /**
214
+ * List directory contents
215
+ */
216
+ async listDir(path, options = {}) {
217
+ const response = await this.send({
218
+ type: "list",
219
+ path,
220
+ }, options);
221
+ if (response.data?.success === false) {
222
+ throw new Error(response.data.error || "List failed");
223
+ }
224
+ return response.data?.files ?? [];
225
+ }
226
+ /**
227
+ * Write multiple files at once
228
+ */
229
+ async writeMany(files, options = {}) {
230
+ const response = await this.send({
231
+ type: "writeMany",
232
+ files,
233
+ }, options);
234
+ if (response.data?.success === false) {
235
+ throw new Error(response.data.error || "Write many failed");
236
+ }
237
+ return {
238
+ written: response.data?.written ?? files.length,
239
+ failed: response.data?.failed ?? [],
240
+ };
241
+ }
242
+ /**
243
+ * Send stdin input to a running process
244
+ */
245
+ async stdin(processId, input, options = {}) {
246
+ const response = await this.send({
247
+ type: "stdin",
248
+ processId,
249
+ input,
250
+ }, options);
251
+ if (response.data?.success === false) {
252
+ throw new Error(response.data.error || "Stdin failed");
253
+ }
254
+ }
255
+ /**
256
+ * Run code with rich output interpreter (charts, images)
257
+ */
258
+ async interpret(code, options = {}) {
259
+ const response = await this.send({
260
+ type: "interpret",
261
+ code,
262
+ language: options.language ?? "python",
263
+ }, options);
264
+ if (response.data?.success === false) {
265
+ throw new Error(response.data.error || "Interpret failed");
266
+ }
267
+ return {
268
+ results: response.data?.results ?? [],
269
+ };
270
+ }
271
+ /**
272
+ * Get sandbox ID (assigned by server based on your identity)
273
+ */
274
+ getSandboxId() {
275
+ return this.sandboxId;
276
+ }
277
+ /**
278
+ * Check if connected
279
+ */
280
+ isConnected() {
281
+ return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
282
+ }
283
+ // ==========================================================================
284
+ // Port Exposure
285
+ // ==========================================================================
286
+ /**
287
+ * Expose a port and get a public preview URL
288
+ */
289
+ async expose(port, options = {}) {
290
+ const response = await this.send({ type: "expose", port }, options);
291
+ if (response.data?.success === false) {
292
+ throw new Error(response.data.error || "Expose failed");
293
+ }
294
+ return {
295
+ url: response.data?.url ?? "",
296
+ wsUrl: response.data?.wsUrl,
297
+ exposedAt: response.data?.exposedAt ?? new Date().toISOString(),
298
+ };
299
+ }
300
+ // ==========================================================================
301
+ // Browser Operations
302
+ // ==========================================================================
303
+ /**
304
+ * Take a screenshot of a URL
305
+ */
306
+ async browserScreenshot(url, options = {}) {
307
+ const response = await this.send({
308
+ type: "browser_screenshot",
309
+ url,
310
+ fullPage: options.fullPage,
311
+ width: options.width,
312
+ height: options.height,
313
+ format: options.format,
314
+ }, options);
315
+ if (response.data?.success === false) {
316
+ throw new Error(response.data.error || "Screenshot failed");
317
+ }
318
+ return {
319
+ image: response.data?.image ?? "",
320
+ format: response.data?.format ?? "png",
321
+ width: response.data?.width ?? 0,
322
+ height: response.data?.height ?? 0,
323
+ };
324
+ }
325
+ /**
326
+ * Generate PDF of a URL
327
+ */
328
+ async browserPdf(url, options = {}) {
329
+ const response = await this.send({
330
+ type: "browser_pdf",
331
+ url,
332
+ format: options.format,
333
+ landscape: options.landscape,
334
+ }, options);
335
+ if (response.data?.success === false) {
336
+ throw new Error(response.data.error || "PDF generation failed");
337
+ }
338
+ return {
339
+ pdf: response.data?.pdf ?? "",
340
+ pages: response.data?.pages ?? 1,
341
+ };
342
+ }
343
+ /**
344
+ * Extract markdown from a URL
345
+ */
346
+ async browserMarkdown(url, options = {}) {
347
+ const response = await this.send({ type: "browser_markdown", url }, options);
348
+ if (response.data?.success === false) {
349
+ throw new Error(response.data.error || "Markdown extraction failed");
350
+ }
351
+ return {
352
+ markdown: response.data?.markdown ?? "",
353
+ title: response.data?.title,
354
+ };
355
+ }
356
+ /**
357
+ * Scrape elements from a URL
358
+ */
359
+ async browserScrape(url, selector, options = {}) {
360
+ const response = await this.send({
361
+ type: "browser_scrape",
362
+ url,
363
+ selector,
364
+ attributes: options.attributes,
365
+ limit: options.limit,
366
+ }, options);
367
+ if (response.data?.success === false) {
368
+ throw new Error(response.data.error || "Scrape failed");
369
+ }
370
+ return {
371
+ elements: response.data?.elements ?? [],
372
+ };
373
+ }
374
+ /**
375
+ * Get rendered HTML content of a URL
376
+ */
377
+ async browserContent(url, options = {}) {
378
+ const response = await this.send({ type: "browser_content", url }, options);
379
+ if (response.data?.success === false) {
380
+ throw new Error(response.data.error || "Content extraction failed");
381
+ }
382
+ return {
383
+ html: response.data?.html ?? "",
384
+ title: response.data?.title,
385
+ };
386
+ }
387
+ /**
388
+ * Start/stop browser tracing for performance recording
389
+ */
390
+ async browserTrace(action, options = {}) {
391
+ const response = await this.send({
392
+ type: "browser_trace",
393
+ action,
394
+ sessionId: options.sessionId,
395
+ }, options);
396
+ if (response.data?.success === false) {
397
+ throw new Error(response.data.error || "Trace operation failed");
398
+ }
399
+ return {
400
+ traceData: response.data?.traceData,
401
+ sessionId: response.data?.sessionId,
402
+ };
403
+ }
404
+ /**
405
+ * Run Playwright assertions on a page
406
+ */
407
+ async browserTest(url, assertions, options = {}) {
408
+ const response = await this.send({
409
+ type: "browser_test",
410
+ url,
411
+ assertions,
412
+ }, options);
413
+ if (response.data?.success === false) {
414
+ throw new Error(response.data.error || "Test execution failed");
415
+ }
416
+ return {
417
+ passed: response.data?.passed ?? false,
418
+ results: response.data?.results ?? [],
419
+ };
420
+ }
421
+ /**
422
+ * List active browser sessions
423
+ */
424
+ async browserSessions(options = {}) {
425
+ const response = await this.send({ type: "browser_sessions" }, options);
426
+ if (response.data?.success === false) {
427
+ throw new Error(response.data.error || "Failed to list sessions");
428
+ }
429
+ return response.data?.sessions ?? [];
430
+ }
431
+ /**
432
+ * Get browser usage limits
433
+ */
434
+ async browserLimits(options = {}) {
435
+ const response = await this.send({ type: "browser_limits" }, options);
436
+ if (response.data?.success === false) {
437
+ throw new Error(response.data.error || "Failed to get limits");
438
+ }
439
+ return {
440
+ maxConcurrent: response.data?.maxConcurrent ?? 2,
441
+ currentActive: response.data?.currentActive ?? 0,
442
+ maxSessionDuration: response.data?.maxSessionDuration ?? 300000,
443
+ };
444
+ }
445
+ // ==========================================================================
446
+ // AI Operations
447
+ // ==========================================================================
448
+ /**
449
+ * Generate text with LLM
450
+ */
451
+ async aiGenerate(prompt, options = {}) {
452
+ const response = await this.send({
453
+ type: "ai_generate",
454
+ prompt,
455
+ model: options.model,
456
+ systemPrompt: options.systemPrompt,
457
+ maxTokens: options.maxTokens,
458
+ temperature: options.temperature,
459
+ }, options);
460
+ if (response.data?.success === false) {
461
+ throw new Error(response.data.error || "AI generation failed");
462
+ }
463
+ return {
464
+ // Server sends 'response', not 'text'
465
+ text: response.data?.response ?? response.data?.text ?? "",
466
+ model: response.data?.model ?? "unknown",
467
+ tokens: response.data?.neuronsUsed,
468
+ };
469
+ }
470
+ /**
471
+ * Generate embeddings
472
+ */
473
+ async aiEmbed(text, options = {}) {
474
+ const response = await this.send({
475
+ type: "ai_embed",
476
+ text,
477
+ model: options.model,
478
+ }, options);
479
+ if (response.data?.success === false) {
480
+ throw new Error(response.data.error || "Embedding failed");
481
+ }
482
+ return {
483
+ embeddings: response.data?.embeddings ?? [],
484
+ model: response.data?.model ?? "unknown",
485
+ };
486
+ }
487
+ /**
488
+ * Generate images from text prompts
489
+ */
490
+ async aiImage(prompt, options = {}) {
491
+ const response = await this.send({
492
+ type: "ai_image",
493
+ prompt,
494
+ model: options.model,
495
+ width: options.width,
496
+ height: options.height,
497
+ num_steps: options.numSteps,
498
+ }, options);
499
+ if (response.data?.success === false) {
500
+ throw new Error(response.data.error || "Image generation failed");
501
+ }
502
+ return {
503
+ image: response.data?.image ?? "",
504
+ model: response.data?.model ?? "unknown",
505
+ };
506
+ }
507
+ /**
508
+ * Transcribe audio to text
509
+ */
510
+ async aiTranscribe(audio, options = {}) {
511
+ const response = await this.send({
512
+ type: "ai_transcribe",
513
+ audio,
514
+ model: options.model,
515
+ }, options);
516
+ if (response.data?.success === false) {
517
+ throw new Error(response.data.error || "Transcription failed");
518
+ }
519
+ return {
520
+ text: response.data?.text ?? "",
521
+ model: response.data?.model ?? "unknown",
522
+ };
523
+ }
524
+ // ==========================================================================
525
+ // Agent Memory (Persistent Storage)
526
+ // ==========================================================================
527
+ /**
528
+ * Write to persistent memory
529
+ */
530
+ async memoryWrite(path, content, options = {}) {
531
+ const response = await this.send({
532
+ type: "memory_write",
533
+ path,
534
+ content,
535
+ }, options);
536
+ if (response.data?.success === false) {
537
+ throw new Error(response.data.error || "Memory write failed");
538
+ }
539
+ }
540
+ /**
541
+ * Read from persistent memory
542
+ */
543
+ async memoryRead(path, options = {}) {
544
+ const response = await this.send({ type: "memory_read", path }, options);
545
+ if (response.data?.success === false) {
546
+ throw new Error(response.data.error || "Memory read failed");
547
+ }
548
+ return response.data?.content ?? "";
549
+ }
550
+ /**
551
+ * List persistent memory files
552
+ */
553
+ async memoryList(prefix, options = {}) {
554
+ const response = await this.send({ type: "memory_list", prefix }, options);
555
+ if (response.data?.success === false) {
556
+ throw new Error(response.data.error || "Memory list failed");
557
+ }
558
+ return response.data?.files ?? [];
559
+ }
560
+ /**
561
+ * Delete from persistent memory
562
+ */
563
+ async memoryDelete(path, options = {}) {
564
+ const response = await this.send({ type: "memory_delete", path }, options);
565
+ if (response.data?.success === false) {
566
+ throw new Error(response.data.error || "Memory delete failed");
567
+ }
568
+ }
569
+ /**
570
+ * Get memory usage
571
+ */
572
+ async memoryUsage(options = {}) {
573
+ const response = await this.send({ type: "memory_usage" }, options);
574
+ if (response.data?.success === false) {
575
+ throw new Error(response.data.error || "Memory usage failed");
576
+ }
577
+ return {
578
+ usedBytes: response.data?.usedBytes ?? 0,
579
+ quotaBytes: response.data?.quotaBytes ?? 10 * 1024 * 1024,
580
+ fileCount: response.data?.fileCount ?? 0,
581
+ };
582
+ }
583
+ // ==========================================================================
584
+ // Process Management
585
+ // ==========================================================================
586
+ /**
587
+ * Start a background process
588
+ *
589
+ * @param command - Shell command to run
590
+ * @param options.waitForSuggestions - Wait N ms for port detection suggestions (default: 0)
591
+ * @returns Process info and any detected suggestions
592
+ */
593
+ async processStart(command, options = {}) {
594
+ const suggestions = [];
595
+ // Collect suggestions during the request
596
+ const response = await this.send({
597
+ type: "process",
598
+ action: "start",
599
+ command,
600
+ processId: options.processId,
601
+ env: options.env,
602
+ }, {
603
+ ...options,
604
+ onSuggestion: (s) => suggestions.push(s),
605
+ });
606
+ if (response.data?.success === false) {
607
+ throw new Error(response.data.error || "Process start failed");
608
+ }
609
+ const result = {
610
+ processId: response.data?.processId ?? "",
611
+ pid: response.data?.pid,
612
+ suggestions,
613
+ };
614
+ // Wait for background suggestions (port detection happens ~1.5s after start)
615
+ if (options.waitForSuggestions && options.waitForSuggestions > 0) {
616
+ await new Promise((resolve) => {
617
+ const timeout = setTimeout(resolve, options.waitForSuggestions);
618
+ // Set up temporary global handler to catch late suggestions
619
+ const originalHandler = this.globalSuggestionHandler;
620
+ this.globalSuggestionHandler = (s) => {
621
+ suggestions.push(s);
622
+ // Also call original handler if exists
623
+ originalHandler?.(s);
624
+ };
625
+ // Clean up after timeout
626
+ setTimeout(() => {
627
+ this.globalSuggestionHandler = originalHandler;
628
+ }, options.waitForSuggestions + 100);
629
+ });
630
+ }
631
+ return result;
632
+ }
633
+ /**
634
+ * List running processes
635
+ */
636
+ async processList(options = {}) {
637
+ const response = await this.send({ type: "process", action: "list" }, options);
638
+ if (response.data?.success === false) {
639
+ throw new Error(response.data.error || "Process list failed");
640
+ }
641
+ return response.data?.processes ?? [];
642
+ }
643
+ /**
644
+ * Kill a process
645
+ */
646
+ async processKill(processId, options = {}) {
647
+ const response = await this.send({
648
+ type: "process",
649
+ action: "kill",
650
+ processId,
651
+ }, options);
652
+ if (response.data?.success === false) {
653
+ throw new Error(response.data.error || "Process kill failed");
654
+ }
655
+ }
656
+ /**
657
+ * Get balance
658
+ */
659
+ async balance(options = {}) {
660
+ const response = await this.send({ type: "balance" }, options);
661
+ if (response.data?.success === false) {
662
+ throw new Error(response.data.error || "Balance check failed");
663
+ }
664
+ return {
665
+ balance: response.data?.balance ?? 0,
666
+ freeCredits: response.data?.freeCredits ?? 0,
667
+ paidCredits: response.data?.paidCredits ?? 0,
668
+ };
669
+ }
670
+ }
671
+ // ============================================================================
672
+ // Singleton Instance
673
+ // ============================================================================
674
+ let instance = null;
675
+ /**
676
+ * Get shared transport instance
677
+ */
678
+ export function getTransport() {
679
+ if (!instance) {
680
+ instance = new PaperpodTransport();
681
+ }
682
+ return instance;
683
+ }
684
+ //# sourceMappingURL=transport.js.map