talkiebot 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.
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "talkiebot",
3
+ "version": "0.1.0",
4
+ "description": "A voice-first, cassette tape-themed interface for Claude Code with conversation management and MCP integration",
5
+ "type": "module",
6
+ "bin": {
7
+ "talkie": "bin/talkie.js",
8
+ "talkie-server": "bin/talkie-server.js",
9
+ "talkie-mcp": "mcp-server/index.js"
10
+ },
11
+ "files": [
12
+ "bin/talkie.js",
13
+ "bin/talkie-server.js",
14
+ "mcp-server/index.js",
15
+ "server/**/*.js",
16
+ "dist/"
17
+ ],
18
+ "scripts": {
19
+ "dev": "vite",
20
+ "build": "tsc && vite build && npm run build:server",
21
+ "build:server": "node scripts/build-server.js",
22
+ "preview": "vite preview",
23
+ "lint": "eslint . --ext ts,tsx",
24
+ "test": "vitest run",
25
+ "test:watch": "vitest",
26
+ "prepublishOnly": "npm run build"
27
+ },
28
+ "keywords": [
29
+ "claude",
30
+ "voice",
31
+ "ai",
32
+ "assistant",
33
+ "mcp",
34
+ "claude-code",
35
+ "speech-recognition",
36
+ "text-to-speech",
37
+ "cassette",
38
+ "retro"
39
+ ],
40
+ "author": "",
41
+ "license": "MIT",
42
+ "dependencies": {
43
+ "@hono/node-server": "^1.12.0",
44
+ "@modelcontextprotocol/sdk": "^1.0.0",
45
+ "better-sqlite3": "^11.0.0",
46
+ "grammy": "^1.21.0",
47
+ "hono": "^4.4.0",
48
+ "open": "^10.1.0",
49
+ "react": "^18.2.0",
50
+ "react-dom": "^18.2.0",
51
+ "selfsigned": "^2.4.1",
52
+ "undici": "^7.19.2",
53
+ "zustand": "^4.5.0"
54
+ },
55
+ "devDependencies": {
56
+ "@testing-library/jest-dom": "^6.9.1",
57
+ "@testing-library/react": "^16.3.2",
58
+ "@types/better-sqlite3": "^7.6.0",
59
+ "@types/react": "^18.2.0",
60
+ "@types/react-dom": "^18.2.0",
61
+ "@vitejs/plugin-basic-ssl": "^1.1.0",
62
+ "@vitejs/plugin-react": "^4.2.0",
63
+ "esbuild": "^0.21.0",
64
+ "eslint": "^8.55.0",
65
+ "jsdom": "^27.0.1",
66
+ "typescript": "^5.3.0",
67
+ "vite": "^5.0.0",
68
+ "vitest": "^2.1.9"
69
+ }
70
+ }
package/server/api.js ADDED
@@ -0,0 +1,614 @@
1
+ import { Hono } from "hono";
2
+ import { cors } from "hono/cors";
3
+ import { streamSSE } from "hono/streaming";
4
+ import { spawn } from "child_process";
5
+ import { state, updateState } from "./state.js";
6
+ import { isDbConnected } from "./db/index.js";
7
+ import * as conversations from "./db/repositories/conversations.js";
8
+ import * as messages from "./db/repositories/messages.js";
9
+ import * as activities from "./db/repositories/activities.js";
10
+ import * as search from "./db/repositories/search.js";
11
+ import * as plans from "./db/repositories/plans.js";
12
+ import { spawnClaude } from "./jobs/runner.js";
13
+ import { jobRoutes } from "./jobs/api.js";
14
+ const api = new Hono();
15
+ api.use("*", cors());
16
+ api.route("/jobs", jobRoutes);
17
+ api.get("/status", (c) => {
18
+ return c.json({
19
+ running: true,
20
+ avatarState: state.avatarState,
21
+ dbStatus: isDbConnected() ? "connected" : "unavailable"
22
+ });
23
+ });
24
+ api.get("/conversations", (c) => {
25
+ const limit = parseInt(c.req.query("limit") || "50", 10);
26
+ const offset = parseInt(c.req.query("offset") || "0", 10);
27
+ const convos = conversations.listConversations(limit, offset);
28
+ const total = conversations.countConversations();
29
+ return c.json({
30
+ conversations: convos.map((conv) => ({
31
+ id: conv.id,
32
+ title: conv.title,
33
+ createdAt: conv.created_at,
34
+ updatedAt: conv.updated_at,
35
+ projectId: conv.project_id,
36
+ parentId: conv.parent_id
37
+ })),
38
+ total,
39
+ limit,
40
+ offset
41
+ });
42
+ });
43
+ api.get("/conversations/:id", (c) => {
44
+ const id = c.req.param("id");
45
+ const conv = conversations.getConversation(id);
46
+ if (!conv) {
47
+ return c.json({ error: "Conversation not found" }, 404);
48
+ }
49
+ const msgs = messages.getMessagesForConversation(id);
50
+ const messageIds = msgs.map((m) => m.id);
51
+ const imageMap = messages.getImagesForMessages(messageIds);
52
+ const acts = activities.getActivitiesForConversation(id);
53
+ return c.json({
54
+ id: conv.id,
55
+ title: conv.title,
56
+ createdAt: conv.created_at,
57
+ updatedAt: conv.updated_at,
58
+ projectId: conv.project_id,
59
+ parentId: conv.parent_id,
60
+ messages: msgs.map((m) => ({
61
+ id: m.id,
62
+ role: m.role,
63
+ content: m.content,
64
+ timestamp: m.timestamp,
65
+ source: m.source,
66
+ images: (imageMap.get(m.id) || []).map((img) => ({
67
+ id: img.id,
68
+ dataUrl: img.data_url,
69
+ fileName: img.file_name,
70
+ description: img.description
71
+ }))
72
+ })),
73
+ activities: acts.map((a) => ({
74
+ id: a.id,
75
+ tool: a.tool,
76
+ input: a.input,
77
+ status: a.status,
78
+ timestamp: a.timestamp,
79
+ duration: a.duration,
80
+ error: a.error
81
+ }))
82
+ });
83
+ });
84
+ api.post("/conversations", async (c) => {
85
+ const body = await c.req.json().catch(() => ({}));
86
+ const id = body.id || crypto.randomUUID();
87
+ const title = body.title || "New conversation";
88
+ const conv = conversations.createConversation({ id, title });
89
+ return c.json({
90
+ id: conv.id,
91
+ title: conv.title,
92
+ createdAt: conv.created_at,
93
+ updatedAt: conv.updated_at
94
+ }, 201);
95
+ });
96
+ api.patch("/conversations/:id", async (c) => {
97
+ const id = c.req.param("id");
98
+ const body = await c.req.json();
99
+ const conv = conversations.updateConversation(id, {
100
+ title: body.title,
101
+ projectId: body.projectId,
102
+ parentId: body.parentId
103
+ });
104
+ if (!conv) {
105
+ return c.json({ error: "Conversation not found" }, 404);
106
+ }
107
+ return c.json({
108
+ id: conv.id,
109
+ title: conv.title,
110
+ createdAt: conv.created_at,
111
+ updatedAt: conv.updated_at
112
+ });
113
+ });
114
+ api.delete("/conversations/:id", (c) => {
115
+ const id = c.req.param("id");
116
+ const deleted = conversations.deleteConversation(id);
117
+ if (!deleted) {
118
+ return c.json({ error: "Conversation not found" }, 404);
119
+ }
120
+ return c.json({ success: true });
121
+ });
122
+ api.get("/conversations/:id/liner-notes", (c) => {
123
+ const id = c.req.param("id");
124
+ const conv = conversations.getConversation(id);
125
+ if (!conv) {
126
+ return c.json({ error: "Conversation not found" }, 404);
127
+ }
128
+ return c.json({
129
+ linerNotes: conv.liner_notes || null
130
+ });
131
+ });
132
+ api.put("/conversations/:id/liner-notes", async (c) => {
133
+ const id = c.req.param("id");
134
+ const body = await c.req.json();
135
+ const conv = conversations.getConversation(id);
136
+ if (!conv) {
137
+ return c.json({ error: "Conversation not found" }, 404);
138
+ }
139
+ conversations.updateLinerNotes(id, body.linerNotes || null);
140
+ return c.json({ success: true });
141
+ });
142
+ api.post("/conversations/:id/messages", async (c) => {
143
+ const conversationId = c.req.param("id");
144
+ const body = await c.req.json();
145
+ const conv = conversations.getConversation(conversationId);
146
+ if (!conv) {
147
+ return c.json({ error: "Conversation not found" }, 404);
148
+ }
149
+ const msg = messages.createMessage({
150
+ id: body.id || crypto.randomUUID(),
151
+ conversationId,
152
+ role: body.role,
153
+ content: body.content,
154
+ timestamp: body.timestamp,
155
+ source: body.source || "web",
156
+ images: body.images
157
+ });
158
+ if (msg.role === "user" && msg.position === 0) {
159
+ const title = msg.content.length > 40 ? msg.content.slice(0, 40) + "..." : msg.content;
160
+ conversations.updateConversation(conversationId, { title });
161
+ }
162
+ if (body.activities && Array.isArray(body.activities)) {
163
+ activities.createActivitiesBatch(
164
+ body.activities.map((a) => ({
165
+ id: a.id || crypto.randomUUID(),
166
+ conversationId,
167
+ messageId: msg.id,
168
+ tool: a.tool,
169
+ input: a.input,
170
+ status: a.status,
171
+ timestamp: a.timestamp,
172
+ duration: a.duration,
173
+ error: a.error
174
+ }))
175
+ );
176
+ }
177
+ return c.json({
178
+ id: msg.id,
179
+ role: msg.role,
180
+ content: msg.content,
181
+ timestamp: msg.timestamp,
182
+ source: msg.source
183
+ }, 201);
184
+ });
185
+ api.patch("/images/:id", async (c) => {
186
+ const imageId = c.req.param("id");
187
+ const { description } = await c.req.json();
188
+ if (typeof description !== "string") {
189
+ return c.json({ error: "description required" }, 400);
190
+ }
191
+ const updated = messages.updateImageDescription(imageId, description);
192
+ if (!updated) {
193
+ return c.json({ error: "Image not found" }, 404);
194
+ }
195
+ return c.json({ success: true });
196
+ });
197
+ api.get("/plans", (c) => {
198
+ const limit = parseInt(c.req.query("limit") || "50", 10);
199
+ const offset = parseInt(c.req.query("offset") || "0", 10);
200
+ const planList = plans.listPlans(limit, offset);
201
+ return c.json({
202
+ plans: planList.map((p) => ({
203
+ id: p.id,
204
+ title: p.title,
205
+ content: p.content,
206
+ status: p.status,
207
+ conversationId: p.conversation_id,
208
+ createdAt: p.created_at,
209
+ updatedAt: p.updated_at
210
+ }))
211
+ });
212
+ });
213
+ api.get("/plans/:id", (c) => {
214
+ const id = c.req.param("id");
215
+ const plan = plans.getPlan(id);
216
+ if (!plan) {
217
+ return c.json({ error: "Plan not found" }, 404);
218
+ }
219
+ return c.json({
220
+ id: plan.id,
221
+ title: plan.title,
222
+ content: plan.content,
223
+ status: plan.status,
224
+ conversationId: plan.conversation_id,
225
+ createdAt: plan.created_at,
226
+ updatedAt: plan.updated_at
227
+ });
228
+ });
229
+ api.post("/plans", async (c) => {
230
+ const body = await c.req.json();
231
+ const plan = plans.createPlan({
232
+ id: body.id || crypto.randomUUID(),
233
+ title: body.title || "Untitled Plan",
234
+ content: body.content || "",
235
+ status: body.status,
236
+ conversationId: body.conversationId
237
+ });
238
+ return c.json({
239
+ id: plan.id,
240
+ title: plan.title,
241
+ content: plan.content,
242
+ status: plan.status,
243
+ conversationId: plan.conversation_id,
244
+ createdAt: plan.created_at,
245
+ updatedAt: plan.updated_at
246
+ }, 201);
247
+ });
248
+ api.put("/plans/:id", async (c) => {
249
+ const id = c.req.param("id");
250
+ const body = await c.req.json();
251
+ const existing = plans.getPlan(id);
252
+ if (!existing) {
253
+ return c.json({ error: "Plan not found" }, 404);
254
+ }
255
+ plans.updatePlan(id, {
256
+ title: body.title,
257
+ content: body.content,
258
+ status: body.status
259
+ });
260
+ return c.json({ success: true });
261
+ });
262
+ api.delete("/plans/:id", (c) => {
263
+ const id = c.req.param("id");
264
+ plans.deletePlan(id);
265
+ return c.json({ success: true });
266
+ });
267
+ api.get("/search", (c) => {
268
+ const query = c.req.query("q") || "";
269
+ const limit = parseInt(c.req.query("limit") || "50", 10);
270
+ if (!query.trim()) {
271
+ return c.json({ results: [] });
272
+ }
273
+ const results = search.searchMessages(query, limit);
274
+ return c.json({
275
+ query,
276
+ results: results.map((r) => ({
277
+ messageId: r.message_id,
278
+ conversationId: r.conversation_id,
279
+ conversationTitle: r.conversation_title,
280
+ role: r.role,
281
+ content: r.content,
282
+ timestamp: r.timestamp,
283
+ snippet: r.snippet
284
+ }))
285
+ });
286
+ });
287
+ api.post("/migrate", async (c) => {
288
+ const body = await c.req.json();
289
+ const localConversations = body.conversations;
290
+ if (!localConversations || !Array.isArray(localConversations)) {
291
+ return c.json({ error: "Invalid conversations data" }, 400);
292
+ }
293
+ let imported = 0;
294
+ let skipped = 0;
295
+ for (const conv of localConversations) {
296
+ if (conversations.getConversation(conv.id)) {
297
+ skipped++;
298
+ continue;
299
+ }
300
+ conversations.createConversation({
301
+ id: conv.id,
302
+ title: conv.title
303
+ });
304
+ for (const msg of conv.messages || []) {
305
+ messages.createMessage({
306
+ id: msg.id,
307
+ conversationId: conv.id,
308
+ role: msg.role,
309
+ content: msg.content,
310
+ timestamp: msg.timestamp,
311
+ source: "web",
312
+ images: msg.images
313
+ });
314
+ }
315
+ if (conv.activities && conv.activities.length > 0) {
316
+ activities.createActivitiesBatch(
317
+ conv.activities.map((a) => ({
318
+ id: a.id,
319
+ conversationId: conv.id,
320
+ tool: a.tool,
321
+ input: a.input,
322
+ status: a.status,
323
+ timestamp: a.timestamp,
324
+ duration: a.duration,
325
+ error: a.error
326
+ }))
327
+ );
328
+ }
329
+ imported++;
330
+ }
331
+ return c.json({
332
+ success: true,
333
+ imported,
334
+ skipped,
335
+ total: localConversations.length
336
+ });
337
+ });
338
+ api.get("/integrations", (c) => {
339
+ let telegramConfigured = !!process.env.TELEGRAM_BOT_TOKEN;
340
+ if (!telegramConfigured) {
341
+ try {
342
+ const { existsSync } = require("fs");
343
+ const { join } = require("path");
344
+ const { homedir } = require("os");
345
+ const tokenPath = join(homedir(), ".talkie", "telegram.token");
346
+ telegramConfigured = existsSync(tokenPath);
347
+ } catch {
348
+ }
349
+ }
350
+ return c.json({
351
+ mcp: {
352
+ configured: true,
353
+ toolCount: 15,
354
+ tools: [
355
+ "launch_talkie",
356
+ "get_talkie_status",
357
+ "get_transcript",
358
+ "get_conversation_history",
359
+ "get_claude_session",
360
+ "set_claude_session",
361
+ "disconnect_claude_session",
362
+ "get_pending_message",
363
+ "respond_to_talkie",
364
+ "update_talkie_state",
365
+ "analyze_image",
366
+ "open_url",
367
+ "create_talkie_job",
368
+ "get_talkie_job",
369
+ "list_talkie_jobs"
370
+ ],
371
+ transport: "stdio"
372
+ },
373
+ telegram: {
374
+ configured: telegramConfigured
375
+ }
376
+ });
377
+ });
378
+ api.get("/transcript", (c) => {
379
+ return c.json({
380
+ transcript: state.transcript,
381
+ lastUserMessage: state.lastUserMessage,
382
+ lastAssistantMessage: state.lastAssistantMessage
383
+ });
384
+ });
385
+ api.get("/history", (c) => {
386
+ return c.json({
387
+ messages: state.messages
388
+ });
389
+ });
390
+ api.post("/state", async (c) => {
391
+ const update = await c.req.json();
392
+ updateState(update);
393
+ return c.json({ success: true });
394
+ });
395
+ api.get("/session", (c) => {
396
+ return c.json({ sessionId: state.claudeSessionId });
397
+ });
398
+ api.post("/session", async (c) => {
399
+ const { sessionId } = await c.req.json();
400
+ updateState({ claudeSessionId: sessionId || null });
401
+ console.log("Claude session ID set:", state.claudeSessionId);
402
+ return c.json({ success: true, sessionId: state.claudeSessionId });
403
+ });
404
+ api.delete("/session", (c) => {
405
+ updateState({ claudeSessionId: null });
406
+ console.log("Claude session ID cleared");
407
+ return c.json({ success: true });
408
+ });
409
+ api.get("/pending", (c) => {
410
+ return c.json({
411
+ pending: state.pendingMessage,
412
+ sessionConnected: !!state.claudeSessionId
413
+ });
414
+ });
415
+ api.post("/respond", async (c) => {
416
+ const { content } = await c.req.json();
417
+ if (!content) {
418
+ return c.json({ error: "Content required" }, 400);
419
+ }
420
+ console.log("IPC response received:", content.slice(0, 100) + "...");
421
+ updateState({ pendingMessage: null });
422
+ for (const callback of state.responseCallbacks) {
423
+ callback(content);
424
+ }
425
+ updateState({ responseCallbacks: [] });
426
+ return c.json({ success: true });
427
+ });
428
+ api.post("/send", async (c) => {
429
+ const { message } = await c.req.json();
430
+ if (!message) {
431
+ return c.json({ error: "Message required" }, 400);
432
+ }
433
+ console.log("IPC message received from frontend:", message.slice(0, 100));
434
+ updateState({
435
+ pendingMessage: {
436
+ content: message,
437
+ timestamp: Date.now()
438
+ }
439
+ });
440
+ return streamSSE(c, async (stream) => {
441
+ const timeout = setTimeout(() => {
442
+ const callbacks = state.responseCallbacks.filter((cb) => cb !== callback);
443
+ updateState({ responseCallbacks: callbacks });
444
+ stream.writeSSE({ data: JSON.stringify({ error: "Timeout waiting for response" }) });
445
+ stream.close();
446
+ }, 12e4);
447
+ const callback = (response) => {
448
+ clearTimeout(timeout);
449
+ stream.writeSSE({ data: JSON.stringify({ text: response }) });
450
+ stream.writeSSE({ data: JSON.stringify({ done: true }) });
451
+ stream.close();
452
+ };
453
+ state.responseCallbacks.push(callback);
454
+ await new Promise((resolve) => {
455
+ const checkClosed = setInterval(() => {
456
+ if (!state.responseCallbacks.includes(callback)) {
457
+ clearInterval(checkClosed);
458
+ resolve();
459
+ }
460
+ }, 100);
461
+ });
462
+ });
463
+ });
464
+ api.post("/analyze-image", async (c) => {
465
+ const { dataUrl, fileName, type, apiKey: clientApiKey } = await c.req.json();
466
+ if (!dataUrl) {
467
+ return c.json({ error: "Image data required" }, 400);
468
+ }
469
+ const apiKey = clientApiKey || process.env.ANTHROPIC_API_KEY;
470
+ if (!apiKey) {
471
+ return c.json({ error: "API key required for image analysis - please add one in Settings even when using Claude Code mode" }, 400);
472
+ }
473
+ const base64Data = dataUrl.split(",")[1];
474
+ const mediaType = type || "image/png";
475
+ const response = await fetch("https://api.anthropic.com/v1/messages", {
476
+ method: "POST",
477
+ headers: {
478
+ "Content-Type": "application/json",
479
+ "x-api-key": apiKey,
480
+ "anthropic-version": "2023-06-01"
481
+ },
482
+ body: JSON.stringify({
483
+ model: "claude-sonnet-4-20250514",
484
+ max_tokens: 1024,
485
+ system: `You are analyzing images for a voice assistant app. Describe the image in detail, focusing on:
486
+ - If it's a UI mockup/wireframe: describe the layout, components, navigation, and user flow
487
+ - If it's a screenshot: describe what app/website it is, the state shown, and key elements
488
+ - If it's a hand-drawn sketch: interpret the drawing and describe what it represents
489
+ - For any image: note colors, text visible, key visual elements
490
+
491
+ Be thorough but concise. This description will be used as context for building or discussing the content.`,
492
+ messages: [
493
+ {
494
+ role: "user",
495
+ content: [
496
+ {
497
+ type: "image",
498
+ source: {
499
+ type: "base64",
500
+ media_type: mediaType,
501
+ data: base64Data
502
+ }
503
+ },
504
+ {
505
+ type: "text",
506
+ text: "Describe this image in detail. If it appears to be a UI design, wireframe, or sketch, focus on the structure and components."
507
+ }
508
+ ]
509
+ }
510
+ ]
511
+ })
512
+ });
513
+ if (!response.ok) {
514
+ const error = await response.text();
515
+ return c.json({ error: `API error: ${error}` }, response.status);
516
+ }
517
+ const data = await response.json();
518
+ const description = data.content[0]?.text || "Unable to analyze image.";
519
+ return c.json({ description, fileName });
520
+ });
521
+ api.post("/analyze-image-cc", async (c) => {
522
+ const { dataUrl, fileName } = await c.req.json();
523
+ if (!dataUrl) {
524
+ return c.json({ error: "Image data required" }, 400);
525
+ }
526
+ return new Promise((resolve) => {
527
+ let description = "";
528
+ const handle = spawnClaude({
529
+ prompt: "Describe this image in detail. If it appears to be a UI design, wireframe, or sketch, focus on the structure and components. Be thorough but concise. Output ONLY the description, no preamble.",
530
+ images: [{ dataUrl, fileName: fileName || "image.png" }],
531
+ rawMode: true,
532
+ callbacks: {
533
+ onText: (text) => {
534
+ description += text;
535
+ },
536
+ onActivity: () => {
537
+ },
538
+ onError: (error) => {
539
+ console.error("Image analysis via Claude Code failed:", error);
540
+ },
541
+ onComplete: () => {
542
+ resolve(c.json({
543
+ description: description.trim() || "Unable to analyze image.",
544
+ fileName
545
+ }));
546
+ }
547
+ }
548
+ });
549
+ setTimeout(() => {
550
+ handle.kill();
551
+ resolve(c.json({
552
+ description: description.trim() || "Analysis timed out.",
553
+ fileName
554
+ }));
555
+ }, 6e4);
556
+ });
557
+ });
558
+ api.post("/open-url", async (c) => {
559
+ const { url } = await c.req.json();
560
+ if (!url || typeof url !== "string") {
561
+ return c.json({ error: "URL required" }, 400);
562
+ }
563
+ if (!url.startsWith("http://") && !url.startsWith("https://")) {
564
+ return c.json({ error: "Only http/https URLs allowed" }, 400);
565
+ }
566
+ console.log("Opening URL in browser:", url);
567
+ return new Promise((resolve) => {
568
+ const open = spawn("open", [url]);
569
+ open.on("error", (err) => {
570
+ resolve(c.json({ error: err.message }, 500));
571
+ });
572
+ open.on("close", (code) => {
573
+ if (code === 0) {
574
+ resolve(c.json({ success: true }));
575
+ } else {
576
+ resolve(c.json({ error: `Failed to open URL (code ${code})` }, 500));
577
+ }
578
+ });
579
+ });
580
+ });
581
+ api.post("/claude-code", async (c) => {
582
+ const { message, history, images } = await c.req.json();
583
+ if (!message) {
584
+ return c.json({ error: "Message required" }, 400);
585
+ }
586
+ return streamSSE(c, async (stream) => {
587
+ const handle = spawnClaude({
588
+ prompt: message,
589
+ history: history || state.messages || [],
590
+ images,
591
+ callbacks: {
592
+ onText: (text) => {
593
+ stream.writeSSE({ data: JSON.stringify({ text }) });
594
+ },
595
+ onActivity: (event) => {
596
+ stream.writeSSE({ data: JSON.stringify({ activity: event }) });
597
+ },
598
+ onPlan: (plan) => {
599
+ stream.writeSSE({ data: JSON.stringify({ plan }) });
600
+ },
601
+ onError: (error) => {
602
+ stream.writeSSE({ data: JSON.stringify({ error }) });
603
+ },
604
+ onComplete: (code) => {
605
+ stream.writeSSE({ data: JSON.stringify({ done: true, code }) });
606
+ }
607
+ }
608
+ });
609
+ await handle.promise;
610
+ });
611
+ });
612
+ export {
613
+ api
614
+ };