tuna-agent 0.1.0 → 0.1.2

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 (68) hide show
  1. package/dist/agents/claude-code-adapter.d.ts +3 -1
  2. package/dist/agents/claude-code-adapter.js +28 -4
  3. package/dist/agents/factory.d.ts +2 -1
  4. package/dist/agents/factory.js +2 -2
  5. package/dist/browser/actions/download.d.ts +16 -0
  6. package/dist/browser/actions/download.js +39 -0
  7. package/dist/browser/actions/emulation.d.ts +53 -0
  8. package/dist/browser/actions/emulation.js +103 -0
  9. package/dist/browser/actions/evaluate.d.ts +29 -0
  10. package/dist/browser/actions/evaluate.js +92 -0
  11. package/dist/browser/actions/interaction.d.ts +79 -0
  12. package/dist/browser/actions/interaction.js +210 -0
  13. package/dist/browser/actions/keyboard.d.ts +6 -0
  14. package/dist/browser/actions/keyboard.js +9 -0
  15. package/dist/browser/actions/navigation.d.ts +40 -0
  16. package/dist/browser/actions/navigation.js +92 -0
  17. package/dist/browser/actions/wait.d.ts +12 -0
  18. package/dist/browser/actions/wait.js +33 -0
  19. package/dist/browser/browser.d.ts +722 -0
  20. package/dist/browser/browser.js +1066 -0
  21. package/dist/browser/capture/activity.d.ts +22 -0
  22. package/dist/browser/capture/activity.js +39 -0
  23. package/dist/browser/capture/pdf.d.ts +6 -0
  24. package/dist/browser/capture/pdf.js +6 -0
  25. package/dist/browser/capture/response.d.ts +8 -0
  26. package/dist/browser/capture/response.js +28 -0
  27. package/dist/browser/capture/screenshot.d.ts +30 -0
  28. package/dist/browser/capture/screenshot.js +72 -0
  29. package/dist/browser/capture/trace.d.ts +13 -0
  30. package/dist/browser/capture/trace.js +19 -0
  31. package/dist/browser/chrome-launcher.d.ts +8 -0
  32. package/dist/browser/chrome-launcher.js +543 -0
  33. package/dist/browser/connection.d.ts +42 -0
  34. package/dist/browser/connection.js +359 -0
  35. package/dist/browser/index.d.ts +6 -0
  36. package/dist/browser/index.js +3 -0
  37. package/dist/browser/security.d.ts +51 -0
  38. package/dist/browser/security.js +357 -0
  39. package/dist/browser/snapshot/ai-snapshot.d.ts +12 -0
  40. package/dist/browser/snapshot/ai-snapshot.js +47 -0
  41. package/dist/browser/snapshot/aria-snapshot.d.ts +26 -0
  42. package/dist/browser/snapshot/aria-snapshot.js +121 -0
  43. package/dist/browser/snapshot/ref-map.d.ts +31 -0
  44. package/dist/browser/snapshot/ref-map.js +250 -0
  45. package/dist/browser/storage/index.d.ts +36 -0
  46. package/dist/browser/storage/index.js +65 -0
  47. package/dist/browser/types.d.ts +429 -0
  48. package/dist/browser/types.js +2 -0
  49. package/dist/cli/commands/extension.d.ts +10 -0
  50. package/dist/cli/commands/extension.js +86 -0
  51. package/dist/cli/index.js +12 -0
  52. package/dist/daemon/extension-handlers.d.ts +63 -0
  53. package/dist/daemon/extension-handlers.js +630 -0
  54. package/dist/daemon/index.js +173 -44
  55. package/dist/daemon/ws-client.d.ts +28 -8
  56. package/dist/daemon/ws-client.js +68 -62
  57. package/dist/mcp/browser-server.d.ts +11 -0
  58. package/dist/mcp/browser-server.js +467 -0
  59. package/dist/mcp/knowledge-server.d.ts +11 -0
  60. package/dist/mcp/knowledge-server.js +263 -0
  61. package/dist/mcp/setup.d.ts +20 -0
  62. package/dist/mcp/setup.js +94 -0
  63. package/dist/types/index.d.ts +2 -0
  64. package/dist/utils/claude-cli.d.ts +2 -0
  65. package/dist/utils/claude-cli.js +29 -9
  66. package/dist/utils/message-schemas.d.ts +4 -1
  67. package/dist/utils/message-schemas.js +6 -1
  68. package/package.json +2 -1
@@ -0,0 +1,630 @@
1
+ /**
2
+ * Extension Task Handlers
3
+ * Handles specialized tasks from the Chrome extension that don't need Claude:
4
+ * - get_history — return local video history
5
+ * - retry_video — re-queue a video for rendering
6
+ * - generate_scene(s) — call Veo3 to create clips
7
+ * - render_video — compose final video via Remotion
8
+ * - list_characters — return character profiles with thumbnails
9
+ * - create_character — create new character profile directory
10
+ */
11
+ import fs from 'fs';
12
+ import path from 'path';
13
+ import os from 'os';
14
+ import { runClaude } from '../utils/claude-cli.js';
15
+ // ─── Storage ──────────────────────────────────────────────────────────────────
16
+ const VIDEOS_DIR = path.join(os.homedir(), '.tuna-content', 'videos');
17
+ const VIDEOS_DB = path.join(VIDEOS_DIR, 'history.json');
18
+ function ensureDir() {
19
+ fs.mkdirSync(VIDEOS_DIR, { recursive: true });
20
+ }
21
+ function loadHistory() {
22
+ try {
23
+ ensureDir();
24
+ if (!fs.existsSync(VIDEOS_DB))
25
+ return [];
26
+ return JSON.parse(fs.readFileSync(VIDEOS_DB, 'utf-8'));
27
+ }
28
+ catch {
29
+ return [];
30
+ }
31
+ }
32
+ function saveHistory(videos) {
33
+ ensureDir();
34
+ fs.writeFileSync(VIDEOS_DB, JSON.stringify(videos, null, 2));
35
+ }
36
+ function upsertVideo(record) {
37
+ const all = loadHistory();
38
+ const idx = all.findIndex(v => v.id === record.id);
39
+ if (idx >= 0)
40
+ all[idx] = record;
41
+ else
42
+ all.unshift(record);
43
+ saveHistory(all);
44
+ }
45
+ // ─── Handler: get_history ─────────────────────────────────────────────────────
46
+ export function handleGetHistory(ws, code, taskId) {
47
+ const videos = loadHistory();
48
+ ws.sendExtensionEvent(code, { type: 'history_result', videos });
49
+ ws.sendExtensionDone(code, taskId, { ok: true });
50
+ }
51
+ // Fixed path to content-creator agent installed on this machine
52
+ const CONTENT_CREATOR_DIR = path.join(os.homedir(), 'agents/content-creator');
53
+ /** Returns true only when the content-creator agent is installed on this machine */
54
+ function hasContentCreator() {
55
+ try {
56
+ return fs.existsSync(path.join(CONTENT_CREATOR_DIR, '.claude', 'commands', 'script.md'));
57
+ }
58
+ catch {
59
+ return false;
60
+ }
61
+ }
62
+ // ─── Handler: generate_ideas ──────────────────────────────────────────────────
63
+ export async function handleGenerateIdeas(ws, code, taskId, topic) {
64
+ if (!hasContentCreator()) {
65
+ const error = 'content-creator agent not found on this machine';
66
+ console.error(`[generate_ideas] ${error}`);
67
+ ws.sendExtensionEvent(code, { type: 'ideas_result', ideas: [], error });
68
+ ws.sendExtensionDone(code, taskId, { error });
69
+ return;
70
+ }
71
+ const systemPrompt = [
72
+ `You are a top-tier content strategist and viral video expert specializing in YouTube Shorts and TikTok.`,
73
+ `You deeply understand viral hooks, storytelling patterns, and audience psychology.`,
74
+ `You know what makes people stop scrolling: curiosity gaps, emotional triggers, POV format, plot twists, listicles, and controversial takes.`,
75
+ `You always write titles in Vietnamese that feel native, not translated.`,
76
+ ].join(' ');
77
+ const prompt = [
78
+ `Generate exactly 5 viral YouTube Shorts video ideas for the topic: "${topic}".`,
79
+ ``,
80
+ `Requirements:`,
81
+ `- Each idea is a catchy, scroll-stopping video title`,
82
+ `- Use proven viral patterns: POV, "X điều...", plot twist, emotional hook, controversial take`,
83
+ `- Titles must be in Vietnamese, natural and engaging`,
84
+ `- Mix different angles/formats for variety`,
85
+ ``,
86
+ `Respond with ONLY a JSON array of 5 strings. No explanation, no markdown, no wrapping.`,
87
+ `Example format: ["title 1","title 2","title 3","title 4","title 5"]`,
88
+ ].join('\n');
89
+ try {
90
+ const result = await runClaude({
91
+ prompt,
92
+ systemPrompt,
93
+ cwd: CONTENT_CREATOR_DIR,
94
+ maxTurns: 1,
95
+ outputFormat: 'json',
96
+ timeoutMs: 60000,
97
+ });
98
+ let ideas = [];
99
+ try {
100
+ // Try all JSON arrays in the response and pick the one with 5 items
101
+ const arrays = [];
102
+ const re = /\[(?:[^\[\]]*(?:"(?:[^"\\]|\\.)*"[^\[\]]*)*)\]/g;
103
+ let m;
104
+ while ((m = re.exec(result.result)) !== null) {
105
+ try {
106
+ const parsed = JSON.parse(m[0]);
107
+ if (Array.isArray(parsed) && parsed.every(i => typeof i === 'string')) {
108
+ arrays.push(parsed);
109
+ }
110
+ }
111
+ catch { /* skip non-JSON matches */ }
112
+ }
113
+ // Prefer the array closest to 5 items
114
+ if (arrays.length > 0) {
115
+ ideas = arrays.reduce((best, cur) => Math.abs(cur.length - 5) < Math.abs(best.length - 5) ? cur : best);
116
+ }
117
+ }
118
+ catch { /* fall through to fallback */ }
119
+ // Fallback: split numbered/bulleted lines
120
+ if (ideas.length === 0) {
121
+ ideas = result.result
122
+ .split('\n')
123
+ .map(l => l.replace(/^[\d\-\*\.\)\s]+/, '').replace(/^[""]|[""]$/g, '').trim())
124
+ .filter(l => l.length > 10 && !l.startsWith('[') && !l.toLowerCase().includes('json'))
125
+ .slice(0, 5);
126
+ }
127
+ // Clean up ideas: strip U+FFFD replacement chars and stray emoji that may not render
128
+ ideas = ideas.map(idea => idea.replace(/\uFFFD/g, '').trim());
129
+ ws.sendExtensionEvent(code, { type: 'ideas_result', ideas });
130
+ ws.sendExtensionDone(code, taskId, { ok: true });
131
+ }
132
+ catch (err) {
133
+ const error = err instanceof Error ? err.message : String(err);
134
+ ws.sendExtensionEvent(code, { type: 'ideas_result', ideas: [], error });
135
+ ws.sendExtensionDone(code, taskId, { error });
136
+ }
137
+ }
138
+ // ─── Handler: generate_script ─────────────────────────────────────────────────
139
+ //
140
+ // Reads the /script command template, expands $ARGUMENTS, and runs Claude in
141
+ // lightweight mode (no MCP servers, no tool loading). This avoids loading
142
+ // Pencil MCP and other IDE-specific servers that hang in headless mode.
143
+ //
144
+ // Flow:
145
+ // 1. Read .claude/commands/script.md, replace $ARGUMENTS with idea + style
146
+ // 2. Run `claude -p <prompt> --append-system-prompt <template>` in lightweight mode
147
+ // 3. Stream text deltas back to extension
148
+ // 4. Return full script on completion
149
+ export async function handleGenerateScript(ws, code, taskId, idea, topic, style) {
150
+ if (!hasContentCreator()) {
151
+ const error = 'content-creator agent not found on this machine';
152
+ console.error(`[generate_script] ${error}`);
153
+ ws.sendExtensionDone(code, taskId, { error });
154
+ return;
155
+ }
156
+ // Read and expand the /script command template
157
+ const templatePath = path.join(CONTENT_CREATOR_DIR, '.claude', 'commands', 'script.md');
158
+ let template;
159
+ try {
160
+ template = fs.readFileSync(templatePath, 'utf-8');
161
+ }
162
+ catch (err) {
163
+ const error = `Cannot read script template: ${err instanceof Error ? err.message : err}`;
164
+ console.error(`[generate_script] ${error}`);
165
+ ws.sendExtensionDone(code, taskId, { error });
166
+ return;
167
+ }
168
+ const styleFlag = style ? `--style ${style} ` : '';
169
+ const args = `${styleFlag}${idea}`.trim();
170
+ const expandedTemplate = template.replace(/\$ARGUMENTS/g, args);
171
+ // Also read character profiles so Claude can match characters
172
+ let characterContext = '';
173
+ try {
174
+ if (fs.existsSync(CHARACTERS_DIR)) {
175
+ const chars = fs.readdirSync(CHARACTERS_DIR).filter(d => fs.statSync(path.join(CHARACTERS_DIR, d)).isDirectory());
176
+ for (const charName of chars) {
177
+ const profilePath = path.join(CHARACTERS_DIR, charName, 'profile.md');
178
+ if (fs.existsSync(profilePath)) {
179
+ const profile = fs.readFileSync(profilePath, 'utf-8');
180
+ characterContext += `\n\n--- Character: ${charName} ---\n${profile}`;
181
+ }
182
+ }
183
+ }
184
+ }
185
+ catch {
186
+ // Non-fatal — Claude will use N/A for characters
187
+ }
188
+ const systemPrompt = expandedTemplate + (characterContext
189
+ ? `\n\n## AVAILABLE CHARACTERS\n${characterContext}`
190
+ : '');
191
+ try {
192
+ let streamChunks = 0;
193
+ let sentTextLen = 0; // track how much text we've already streamed
194
+ const result = await runClaude({
195
+ prompt: `Viết script video theo hướng dẫn trên. Arguments: ${args}`,
196
+ systemPrompt,
197
+ cwd: CONTENT_CREATOR_DIR,
198
+ maxTurns: 4,
199
+ outputFormat: 'stream-json',
200
+ includePartialMessages: true,
201
+ timeoutMs: 180000,
202
+ onStreamLine: (data) => {
203
+ if (streamChunks < 5) {
204
+ const sub = data.subtype || '';
205
+ console.log(`[generate_script] stream #${streamChunks}: type=${data.type} sub=${sub}`);
206
+ }
207
+ streamChunks++;
208
+ // stream_event: raw API events (token-level deltas) — fast, smooth streaming
209
+ if (data.type === 'stream_event') {
210
+ const evt = data.event;
211
+ if (evt?.type === 'content_block_delta') {
212
+ const delta = evt.delta;
213
+ if (delta?.type === 'text_delta' && typeof delta.text === 'string') {
214
+ sentTextLen += delta.text.length;
215
+ ws.sendExtensionStream(code, taskId, delta.text);
216
+ }
217
+ }
218
+ return;
219
+ }
220
+ // assistant: partial message snapshots (fallback for coarser updates)
221
+ if (data.type === 'assistant') {
222
+ const msg = data.message;
223
+ const content = (msg?.content ?? data.content);
224
+ if (Array.isArray(content)) {
225
+ let fullText = '';
226
+ for (const block of content) {
227
+ if (block.type === 'text' && typeof block.text === 'string') {
228
+ fullText += block.text;
229
+ }
230
+ }
231
+ if (fullText.length > sentTextLen) {
232
+ const delta = fullText.slice(sentTextLen);
233
+ sentTextLen = fullText.length;
234
+ ws.sendExtensionStream(code, taskId, delta);
235
+ }
236
+ }
237
+ }
238
+ },
239
+ });
240
+ console.log(`[generate_script] Done: ${streamChunks} chunks, streamed=${sentTextLen} chars, result=${result.result?.length || 0} chars`);
241
+ const scriptText = result.result;
242
+ ws.sendExtensionDone(code, taskId, { script: scriptText, text: scriptText });
243
+ }
244
+ catch (err) {
245
+ const error = err instanceof Error ? err.message : String(err);
246
+ console.error(`[generate_script] Error: ${error}`);
247
+ ws.sendExtensionDone(code, taskId, { error });
248
+ }
249
+ }
250
+ // ─── Handler: retry_video ─────────────────────────────────────────────────────
251
+ export function handleRetryVideo(ws, code, taskId, videoId) {
252
+ const videos = loadHistory();
253
+ const v = videos.find(x => x.id === videoId);
254
+ if (!v) {
255
+ ws.sendExtensionDone(code, taskId, { error: 'Video not found' });
256
+ return;
257
+ }
258
+ // Reset status and re-queue
259
+ v.status = 'processing';
260
+ v.progress = 0;
261
+ v.error = undefined;
262
+ saveHistory(videos);
263
+ ws.sendExtensionEvent(code, { type: 'history_result', videos: loadHistory() });
264
+ ws.sendExtensionDone(code, taskId, { ok: true });
265
+ // Re-run the render in background
266
+ _doRender(ws, code, 'retry-' + taskId, v);
267
+ }
268
+ // ─── Handler: generate_scene ─────────────────────────────────────────────────
269
+ export async function handleGenerateScene(ws, code, taskId, sceneIdx, prompt, aspectRatio) {
270
+ ws.sendExtensionEvent(code, { type: 'scene_progress', idx: sceneIdx, progress: 0 });
271
+ try {
272
+ const videoUrl = await _callVeo3(prompt, aspectRatio, (p) => {
273
+ ws.sendExtensionEvent(code, { type: 'scene_progress', idx: sceneIdx, progress: p });
274
+ });
275
+ ws.sendExtensionEvent(code, { type: 'scene_done', idx: sceneIdx, videoUrl });
276
+ ws.sendExtensionDone(code, taskId, { ok: true, idx: sceneIdx, videoUrl });
277
+ }
278
+ catch (err) {
279
+ const error = err instanceof Error ? err.message : String(err);
280
+ ws.sendExtensionEvent(code, { type: 'scene_failed', idx: sceneIdx, error });
281
+ ws.sendExtensionDone(code, taskId, { error });
282
+ }
283
+ }
284
+ // ─── Handler: generate_scenes (batch) ────────────────────────────────────────
285
+ export async function handleGenerateScenes(ws, code, taskId, scenes, aspectRatio) {
286
+ for (const scene of scenes) {
287
+ await handleGenerateScene(ws, code, taskId + '-' + scene.idx, scene.idx, scene.prompt, aspectRatio);
288
+ }
289
+ ws.sendExtensionDone(code, taskId, { ok: true });
290
+ }
291
+ // ─── Handler: render_video ────────────────────────────────────────────────────
292
+ export async function handleRenderVideo(ws, code, taskId, payload) {
293
+ const videoId = 'v-' + Date.now();
294
+ const record = {
295
+ id: videoId,
296
+ title: payload.title || 'Untitled',
297
+ niche: '',
298
+ status: 'processing',
299
+ progress: 0,
300
+ createdAt: new Date().toISOString(),
301
+ scenes: payload.scenes,
302
+ aspectRatio: payload.aspectRatio,
303
+ };
304
+ upsertVideo(record);
305
+ ws.sendExtensionEvent(code, { type: 'render_progress', progress: 0 });
306
+ _doRender(ws, code, taskId, record, payload);
307
+ }
308
+ // ─── Internal: Veo3 call ──────────────────────────────────────────────────────
309
+ async function _callVeo3(prompt, aspectRatio, onProgress) {
310
+ // TODO: integrate real Veo3 API
311
+ // Simulate progress for now
312
+ for (let p = 10; p <= 90; p += 20) {
313
+ await _sleep(800);
314
+ onProgress(p);
315
+ }
316
+ await _sleep(800);
317
+ onProgress(100);
318
+ // Placeholder: return a dummy local file path
319
+ // Real impl: call Google Veo3 API, download clip to VIDEOS_DIR, return file:// path
320
+ const outFile = path.join(VIDEOS_DIR, `scene-${Date.now()}.mp4`);
321
+ return 'file://' + outFile;
322
+ }
323
+ // ─── Internal: Render ─────────────────────────────────────────────────────────
324
+ async function _doRender(ws, code, taskId, record, payload) {
325
+ try {
326
+ // Progress: 0 → 100
327
+ for (let p = 10; p <= 90; p += 10) {
328
+ await _sleep(600);
329
+ record.progress = p;
330
+ upsertVideo(record);
331
+ ws.sendExtensionEvent(code, { type: 'render_progress', progress: p });
332
+ }
333
+ // TODO: integrate real Remotion render
334
+ // Real impl: call /render skill or Remotion CLI with scene clips + voiceover
335
+ const outFile = path.join(VIDEOS_DIR, `${record.id}.mp4`);
336
+ record.status = 'rendered';
337
+ record.progress = 100;
338
+ record.outputPath = outFile;
339
+ upsertVideo(record);
340
+ ws.sendExtensionEvent(code, {
341
+ type: 'render_done',
342
+ outputPath: outFile,
343
+ title: record.title,
344
+ video: record,
345
+ });
346
+ ws.sendExtensionDone(code, taskId, { ok: true, videoId: record.id });
347
+ }
348
+ catch (err) {
349
+ const error = err instanceof Error ? err.message : String(err);
350
+ record.status = 'failed';
351
+ record.error = error;
352
+ upsertVideo(record);
353
+ ws.sendExtensionEvent(code, { type: 'render_failed', error });
354
+ ws.sendExtensionDone(code, taskId, { error });
355
+ }
356
+ }
357
+ // ─── Character profile interface ─────────────────────────────────────────────
358
+ const CHARACTERS_DIR = path.join(os.homedir(), '.tuna', 'characters');
359
+ /** Parse profile.md key-value lines like `- **Key:** Value` */
360
+ function _parseProfileMd(text) {
361
+ const result = {};
362
+ // Extract title (# Name)
363
+ const titleMatch = text.match(/^#\s+(.+)/m);
364
+ if (titleMatch)
365
+ result.name = titleMatch[1].trim();
366
+ // Extract key-value pairs
367
+ const kvRe = /- \*\*(.+?):\*\*\s*(.+)/g;
368
+ let m;
369
+ while ((m = kvRe.exec(text)) !== null) {
370
+ result[m[1].toLowerCase().replace(/\s+/g, '_')] = m[2].trim();
371
+ }
372
+ return result;
373
+ }
374
+ // ─── Handler: list_characters ────────────────────────────────────────────────
375
+ export function handleListCharacters(ws, code, taskId) {
376
+ const charDir = CHARACTERS_DIR;
377
+ const characters = [];
378
+ try {
379
+ if (fs.existsSync(charDir)) {
380
+ const dirs = fs.readdirSync(charDir).filter(d => {
381
+ try {
382
+ return fs.statSync(path.join(charDir, d)).isDirectory();
383
+ }
384
+ catch {
385
+ return false;
386
+ }
387
+ });
388
+ for (const dirName of dirs) {
389
+ const profilePath = path.join(charDir, dirName, 'profile.md');
390
+ const profileText = fs.existsSync(profilePath)
391
+ ? fs.readFileSync(profilePath, 'utf-8') : '';
392
+ const parsed = _parseProfileMd(profileText);
393
+ // Read main image as base64
394
+ let thumbnail = '';
395
+ const imgCandidates = ['main.jpg', 'main.jpeg', 'main.png'];
396
+ for (const fname of imgCandidates) {
397
+ const imgPath = path.join(charDir, dirName, fname);
398
+ if (fs.existsSync(imgPath)) {
399
+ const imgBuf = fs.readFileSync(imgPath);
400
+ const ext = path.extname(imgPath).slice(1);
401
+ const mime = ext === 'png' ? 'image/png' : 'image/jpeg';
402
+ thumbnail = `data:${mime};base64,${imgBuf.toString('base64')}`;
403
+ break;
404
+ }
405
+ }
406
+ // Count selected poses
407
+ const selectedDir = path.join(charDir, dirName, 'selected');
408
+ let poseCount = 0;
409
+ if (fs.existsSync(selectedDir)) {
410
+ poseCount = fs.readdirSync(selectedDir).filter(f => /\.(jpe?g|png)$/i.test(f)).length;
411
+ }
412
+ characters.push({
413
+ id: dirName,
414
+ name: parsed.name || dirName,
415
+ thumbnail,
416
+ gender: parsed.gender || '',
417
+ age: parsed.age || '',
418
+ ethnicity: parsed.ethnicity || '',
419
+ vibe: parsed.vibe || '',
420
+ suitableFor: parsed.suitable_for || '',
421
+ poseCount,
422
+ });
423
+ }
424
+ }
425
+ }
426
+ catch (err) {
427
+ console.error(`[list_characters] Error reading characters:`, err);
428
+ }
429
+ ws.sendExtensionEvent(code, { type: 'characters_result', characters });
430
+ ws.sendExtensionDone(code, taskId, { ok: true });
431
+ }
432
+ // ─── Helper: send a flow command and unwrap result ──────────────────────────
433
+ async function flowCmd(ws, code, command, timeout = 60000) {
434
+ const result = await ws.sendFlowCommand(code, command, timeout);
435
+ if (result && !result.success) {
436
+ throw new Error(`Flow command failed: ${result.error || JSON.stringify(result)}`);
437
+ }
438
+ return result;
439
+ }
440
+ // ─── Handler: create_character ───────────────────────────────────────────────
441
+ export async function handleCreateCharacter(ws, code, taskId, name, displayName, prompt, outputCount = 2, orientation = 'portrait') {
442
+ const charDir = path.join(CHARACTERS_DIR, name);
443
+ // Validate
444
+ if (!name || !/^[a-z0-9_]+$/.test(name)) {
445
+ ws.sendExtensionEvent(code, { type: 'char_create_failed', error: 'Invalid name — use lowercase letters, numbers, underscores only' });
446
+ ws.sendExtensionDone(code, taskId, { error: 'invalid name' });
447
+ return;
448
+ }
449
+ try {
450
+ // Create directory structure (allow re-creation for regeneration)
451
+ fs.mkdirSync(path.join(charDir, 'generated'), { recursive: true });
452
+ fs.mkdirSync(path.join(charDir, 'selected'), { recursive: true });
453
+ // Write profile.md
454
+ fs.writeFileSync(path.join(charDir, 'profile.md'), `# ${displayName}\n\n- **Orientation:** ${orientation}\n- **Output Count:** ${outputCount}\n\n${prompt}\n`);
455
+ // ── Step 1: Open Google Flow tab ──────────────────────────────────────
456
+ ws.sendExtensionEvent(code, {
457
+ type: 'char_create_progress',
458
+ phase: 'setup',
459
+ progress: 5,
460
+ current: 'Opening Google Flow...',
461
+ });
462
+ await flowCmd(ws, code, { type: 'openFlowTab' }, 15000);
463
+ await _sleep(2000); // Extra time for page to fully initialize
464
+ // ── Step 2: Ping content script to verify it's ready ──────────────────
465
+ ws.sendExtensionEvent(code, {
466
+ type: 'char_create_progress',
467
+ phase: 'setup',
468
+ progress: 10,
469
+ current: 'Waiting for Google Flow to load...',
470
+ });
471
+ // Poll until content script is responsive
472
+ let pingOk = false;
473
+ for (let attempt = 0; attempt < 10; attempt++) {
474
+ try {
475
+ await flowCmd(ws, code, { type: 'ping' }, 5000);
476
+ pingOk = true;
477
+ break;
478
+ }
479
+ catch {
480
+ await _sleep(2000);
481
+ }
482
+ }
483
+ if (!pingOk) {
484
+ throw new Error('Google Flow tab not responding — make sure Flow is open at labs.google/fx/tools/flow');
485
+ }
486
+ // ── Step 3: Configure settings (mode, orientation, count) ─────────────
487
+ ws.sendExtensionEvent(code, {
488
+ type: 'char_create_progress',
489
+ phase: 'configuring',
490
+ progress: 15,
491
+ current: 'Configuring image settings...',
492
+ });
493
+ // Map orientation to Flow aspect ratio names
494
+ const aspectRatio = orientation === 'landscape' ? 'landscape' : 'portrait';
495
+ await flowCmd(ws, code, {
496
+ type: 'configureSettings',
497
+ settings: {
498
+ mode: 'create image',
499
+ aspectRatio,
500
+ outputs: String(outputCount),
501
+ },
502
+ }, 15000);
503
+ await _sleep(1000);
504
+ // ── Step 4: Fill prompt and submit ────────────────────────────────────
505
+ ws.sendExtensionEvent(code, {
506
+ type: 'char_create_progress',
507
+ phase: 'submitting',
508
+ progress: 25,
509
+ current: 'Entering prompt...',
510
+ });
511
+ await flowCmd(ws, code, { type: 'fillTextbox', text: prompt }, 10000);
512
+ await _sleep(500);
513
+ await flowCmd(ws, code, { type: 'submitPrompt' }, 10000);
514
+ // ── Step 5: Wait for images to appear ────────────────────────────────
515
+ ws.sendExtensionEvent(code, {
516
+ type: 'char_create_progress',
517
+ phase: 'generating',
518
+ progress: 30,
519
+ current: 'Generating images (this may take 30-60s)...',
520
+ });
521
+ // Poll countGoogleImages until we have enough images
522
+ const maxWait = 120000; // 2 minutes max
523
+ const pollInterval = 3000;
524
+ const startTime = Date.now();
525
+ let imageCount = 0;
526
+ while (Date.now() - startTime < maxWait) {
527
+ await _sleep(pollInterval);
528
+ const countResult = await flowCmd(ws, code, { type: 'countImages' }, 10000);
529
+ imageCount = countResult.visible || 0;
530
+ const elapsed = Date.now() - startTime;
531
+ const progressPct = Math.min(30 + Math.round((elapsed / maxWait) * 60), 90);
532
+ ws.sendExtensionEvent(code, {
533
+ type: 'char_create_progress',
534
+ phase: 'generating',
535
+ progress: progressPct,
536
+ current: `Waiting for images... (${imageCount} found)`,
537
+ });
538
+ if (imageCount >= outputCount)
539
+ break;
540
+ }
541
+ if (imageCount === 0) {
542
+ throw new Error('No images generated after waiting 2 minutes. Please check Google Flow.');
543
+ }
544
+ // ── Step 6: Extract images as base64 ──────────────────────────────────
545
+ ws.sendExtensionEvent(code, {
546
+ type: 'char_create_progress',
547
+ phase: 'downloading',
548
+ progress: 92,
549
+ current: 'Downloading generated images...',
550
+ });
551
+ const imgResult = await flowCmd(ws, code, { type: 'getImageBase64', minSize: 100 }, 30000);
552
+ const base64Images = (imgResult.images || []);
553
+ // Save generated images to disk
554
+ const genDir = path.join(charDir, 'generated');
555
+ const images = [];
556
+ for (let i = 0; i < base64Images.length; i++) {
557
+ const dataUrl = base64Images[i];
558
+ images.push(dataUrl);
559
+ // Also save to file
560
+ const match = dataUrl.match(/^data:image\/(\w+);base64,(.+)$/);
561
+ if (match) {
562
+ const ext = match[1] === 'png' ? 'png' : 'jpg';
563
+ const fname = `gen_${String(i + 1).padStart(2, '0')}.${ext}`;
564
+ fs.writeFileSync(path.join(genDir, fname), Buffer.from(match[2], 'base64'));
565
+ }
566
+ }
567
+ // ── Step 7: Done ─────────────────────────────────────────────────────
568
+ ws.sendExtensionEvent(code, {
569
+ type: 'char_create_progress',
570
+ phase: 'done',
571
+ progress: 100,
572
+ current: 'Generation complete',
573
+ });
574
+ // Return the new character profile with images
575
+ const character = {
576
+ id: name,
577
+ name: displayName,
578
+ thumbnail: images.length > 0 ? images[0] : '',
579
+ gender: '',
580
+ age: '',
581
+ ethnicity: '',
582
+ vibe: '',
583
+ suitableFor: '',
584
+ poseCount: 0,
585
+ images,
586
+ };
587
+ ws.sendExtensionEvent(code, { type: 'char_created', character });
588
+ ws.sendExtensionDone(code, taskId, { ok: true });
589
+ }
590
+ catch (err) {
591
+ const error = err instanceof Error ? err.message : String(err);
592
+ console.error(`[create_character] Error:`, error);
593
+ ws.sendExtensionEvent(code, { type: 'char_create_failed', error });
594
+ ws.sendExtensionDone(code, taskId, { error });
595
+ }
596
+ }
597
+ // ─── Handler: save_character_selection ────────────────────────────────────────
598
+ export function handleSaveCharacterSelection(ws, code, taskId, characterId, selectedImages) {
599
+ const charDir = path.join(CHARACTERS_DIR, characterId);
600
+ try {
601
+ if (!fs.existsSync(charDir)) {
602
+ ws.sendExtensionDone(code, taskId, { error: 'Character not found' });
603
+ return;
604
+ }
605
+ const selectedDir = path.join(charDir, 'selected');
606
+ fs.mkdirSync(selectedDir, { recursive: true });
607
+ // Save first selected image as main.jpg if no main exists
608
+ if (selectedImages.length > 0) {
609
+ const mainPath = path.join(charDir, 'main.jpg');
610
+ if (!fs.existsSync(mainPath)) {
611
+ const first = selectedImages[0];
612
+ const match = first.match(/^data:image\/\w+;base64,(.+)$/);
613
+ if (match) {
614
+ fs.writeFileSync(mainPath, Buffer.from(match[1], 'base64'));
615
+ }
616
+ }
617
+ }
618
+ // TODO: Copy selected generated images to selected/ directory
619
+ // For now, just acknowledge the selection
620
+ ws.sendExtensionDone(code, taskId, { ok: true, count: selectedImages.length });
621
+ }
622
+ catch (err) {
623
+ const error = err instanceof Error ? err.message : String(err);
624
+ console.error(`[save_character_selection] Error:`, error);
625
+ ws.sendExtensionDone(code, taskId, { error });
626
+ }
627
+ }
628
+ function _sleep(ms) {
629
+ return new Promise(resolve => setTimeout(resolve, ms));
630
+ }