@portel/photon-core 1.1.0 → 1.2.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/src/generator.ts CHANGED
@@ -1,192 +1,651 @@
1
1
  /**
2
- * Generator-based Tool Execution
2
+ * Generator-based Tool Execution with Ask/Emit Pattern
3
3
  *
4
- * Enables photon tools to use generator functions with `yield` for:
5
- * - User prompts (text, password, confirm, select)
6
- * - Progress updates
7
- * - Streaming responses
8
- * - Multi-step wizards
4
+ * Enables photon tools to use async generator functions with `yield` for:
5
+ * - Interactive user input (ask) - blocks until user responds
6
+ * - Real-time output (emit) - fire and forget, no response needed
9
7
  *
10
- * The runtime handles yields appropriately based on the protocol:
11
- * - REST: Extract yields as optional parameters
12
- * - WebSocket/MCP: Interactive prompts
13
- * - CLI: readline prompts
14
- * - Fallback: Native OS dialogs
8
+ * ══════════════════════════════════════════════════════════════════════════════
9
+ * DESIGN PHILOSOPHY
10
+ * ══════════════════════════════════════════════════════════════════════════════
11
+ *
12
+ * The `ask` vs `emit` pattern provides instant clarity:
13
+ * - `ask` = "I need something FROM the user" (blocks, returns value)
14
+ * - `emit` = "I'm sending something TO the user" (non-blocking, void)
15
+ *
16
+ * This maps naturally to all runtime contexts:
17
+ *
18
+ * | Runtime | ask (input) | emit (output) |
19
+ * |------------|--------------------------|----------------------------|
20
+ * | REST API | Returns 202 + continue | Included in response or SSE|
21
+ * | WebSocket | Server request → client | Server push to client |
22
+ * | CLI | Readline prompt | Console output |
23
+ * | MCP | Elicitation dialog | Notification/logging |
24
+ * | Chatbot | Bot question → user reply| Status message, typing... |
25
+ *
26
+ * ══════════════════════════════════════════════════════════════════════════════
27
+ * REST API CONTINUATION PATTERN
28
+ * ══════════════════════════════════════════════════════════════════════════════
29
+ *
30
+ * When a generator yields `ask`, REST APIs can implement a continuation flow:
31
+ *
32
+ * ```
33
+ * POST /api/google-tv/connect
34
+ * Body: { ip: "192.168.1.100" }
35
+ *
36
+ * Response (202 Accepted):
37
+ * {
38
+ * "status": "awaiting_input",
39
+ * "continuation_id": "ctx_abc123",
40
+ * "ask": { "type": "text", "id": "pairing_code", "message": "Enter code:" },
41
+ * "continue": "/api/google-tv/connect/ctx_abc123"
42
+ * }
43
+ *
44
+ * POST /api/google-tv/connect/ctx_abc123
45
+ * Body: { "pairing_code": "123456" }
46
+ *
47
+ * Response (200 OK):
48
+ * { "status": "complete", "result": { "success": true } }
49
+ * ```
50
+ *
51
+ * ══════════════════════════════════════════════════════════════════════════════
52
+ * USAGE EXAMPLE
53
+ * ══════════════════════════════════════════════════════════════════════════════
15
54
  *
16
- * @example
17
55
  * ```typescript
18
56
  * async *connect(params: { ip: string }) {
19
- * await this.startConnection(params.ip);
57
+ * yield { emit: 'status', message: 'Connecting to TV...' };
58
+ *
59
+ * await this.startPairing(params.ip);
20
60
  *
61
+ * yield { emit: 'progress', value: 0.3, message: 'Waiting for code...' };
62
+ *
63
+ * // Blocks until user provides input
21
64
  * const code: string = yield {
22
- * prompt: 'Enter the 6-digit code:',
23
- * type: 'text'
65
+ * ask: 'text',
66
+ * id: 'pairing_code',
67
+ * message: 'Enter the 6-digit code shown on TV:',
68
+ * pattern: '^[0-9]{6}$',
69
+ * required: true
24
70
  * };
25
71
  *
72
+ * yield { emit: 'status', message: 'Verifying code...' };
73
+ *
26
74
  * await this.sendCode(code);
27
- * return { success: true };
75
+ *
76
+ * yield { emit: 'toast', message: 'Connected!', type: 'success' };
77
+ *
78
+ * return { success: true, paired: true };
28
79
  * }
29
80
  * ```
81
+ *
82
+ * @module generator
30
83
  */
31
84
 
32
- // ============================================================================
33
- // Yield Types - What can be yielded from generator tools
34
- // ============================================================================
85
+ // ══════════════════════════════════════════════════════════════════════════════
86
+ // ASK YIELDS - Input from user (blocks until response)
87
+ // ══════════════════════════════════════════════════════════════════════════════
35
88
 
36
89
  /**
37
- * Text input prompt
90
+ * Base properties shared by all ask yields
38
91
  */
39
- export interface PromptYield {
40
- prompt: string;
41
- type?: 'text' | 'password';
42
- default?: string;
43
- /** Unique identifier for this prompt (auto-generated if not provided) */
92
+ interface AskBase {
93
+ /**
94
+ * Unique identifier for this input.
95
+ * Used for:
96
+ * - REST API parameter mapping (pre-provided inputs)
97
+ * - Continuation token correlation
98
+ * - Form field identification
99
+ *
100
+ * Auto-generated if not provided (ask_0, ask_1, etc.)
101
+ */
44
102
  id?: string;
45
- /** Validation pattern */
46
- pattern?: string;
47
- /** Whether this prompt is required */
103
+
104
+ /**
105
+ * The prompt message shown to the user.
106
+ * Should be clear and actionable.
107
+ */
108
+ message: string;
109
+
110
+ /**
111
+ * Whether this input is required.
112
+ * If false, user can skip/cancel.
113
+ * @default true
114
+ */
48
115
  required?: boolean;
49
116
  }
50
117
 
51
118
  /**
52
- * Confirmation dialog
119
+ * Text input - single line string
120
+ *
121
+ * @example
122
+ * const name: string = yield {
123
+ * ask: 'text',
124
+ * message: 'Enter your name:',
125
+ * default: 'Guest',
126
+ * placeholder: 'John Doe'
127
+ * };
128
+ */
129
+ export interface AskText extends AskBase {
130
+ ask: 'text';
131
+ /** Default value if user submits empty */
132
+ default?: string;
133
+ /** Placeholder hint shown in input field */
134
+ placeholder?: string;
135
+ /** Regex pattern for validation */
136
+ pattern?: string;
137
+ /** Minimum length */
138
+ minLength?: number;
139
+ /** Maximum length */
140
+ maxLength?: number;
141
+ }
142
+
143
+ /**
144
+ * Password input - hidden/masked string
145
+ *
146
+ * @example
147
+ * const apiKey: string = yield {
148
+ * ask: 'password',
149
+ * message: 'Enter your API key:'
150
+ * };
151
+ */
152
+ export interface AskPassword extends AskBase {
153
+ ask: 'password';
154
+ }
155
+
156
+ /**
157
+ * Confirmation - yes/no boolean
158
+ *
159
+ * @example
160
+ * const confirmed: boolean = yield {
161
+ * ask: 'confirm',
162
+ * message: 'Delete this file permanently?',
163
+ * dangerous: true
164
+ * };
53
165
  */
54
- export interface ConfirmYield {
55
- confirm: string;
56
- /** Mark as dangerous action (UI can show warning styling) */
166
+ export interface AskConfirm extends AskBase {
167
+ ask: 'confirm';
168
+ /**
169
+ * Mark as dangerous/destructive action.
170
+ * UI may show warning styling (red button, confirmation dialog).
171
+ */
57
172
  dangerous?: boolean;
58
- id?: string;
173
+ /** Default value if user just presses enter */
174
+ default?: boolean;
59
175
  }
60
176
 
61
177
  /**
62
- * Selection from options
178
+ * Selection from predefined options
179
+ *
180
+ * @example
181
+ * // Simple string options
182
+ * const env: string = yield {
183
+ * ask: 'select',
184
+ * message: 'Choose environment:',
185
+ * options: ['development', 'staging', 'production']
186
+ * };
187
+ *
188
+ * // Rich options with labels
189
+ * const region: string = yield {
190
+ * ask: 'select',
191
+ * message: 'Select region:',
192
+ * options: [
193
+ * { value: 'us-east-1', label: 'US East (N. Virginia)' },
194
+ * { value: 'eu-west-1', label: 'EU West (Ireland)' }
195
+ * ]
196
+ * };
197
+ *
198
+ * // Multi-select
199
+ * const features: string[] = yield {
200
+ * ask: 'select',
201
+ * message: 'Enable features:',
202
+ * options: ['auth', 'logging', 'metrics'],
203
+ * multi: true
204
+ * };
63
205
  */
64
- export interface SelectYield {
65
- select: string;
66
- options: Array<string | { value: string; label: string }>;
67
- /** Allow multiple selections */
206
+ export interface AskSelect extends AskBase {
207
+ ask: 'select';
208
+ /** Available options */
209
+ options: Array<string | { value: string; label: string; description?: string }>;
210
+ /** Allow selecting multiple options */
68
211
  multi?: boolean;
69
- id?: string;
212
+ /** Default selected value(s) */
213
+ default?: string | string[];
214
+ }
215
+
216
+ /**
217
+ * Number input with optional constraints
218
+ *
219
+ * @example
220
+ * const quantity: number = yield {
221
+ * ask: 'number',
222
+ * message: 'Enter quantity:',
223
+ * min: 1,
224
+ * max: 100,
225
+ * step: 1
226
+ * };
227
+ */
228
+ export interface AskNumber extends AskBase {
229
+ ask: 'number';
230
+ /** Minimum value */
231
+ min?: number;
232
+ /** Maximum value */
233
+ max?: number;
234
+ /** Step increment */
235
+ step?: number;
236
+ /** Default value */
237
+ default?: number;
238
+ }
239
+
240
+ /**
241
+ * File selection (for supported runtimes)
242
+ *
243
+ * @example
244
+ * const file: FileInfo = yield {
245
+ * ask: 'file',
246
+ * message: 'Select a document:',
247
+ * accept: '.pdf,.doc,.docx',
248
+ * multiple: false
249
+ * };
250
+ */
251
+ export interface AskFile extends AskBase {
252
+ ask: 'file';
253
+ /** Accepted file types (MIME types or extensions) */
254
+ accept?: string;
255
+ /** Allow multiple file selection */
256
+ multiple?: boolean;
257
+ }
258
+
259
+ /**
260
+ * Date/time selection
261
+ *
262
+ * @example
263
+ * const date: string = yield {
264
+ * ask: 'date',
265
+ * message: 'Select delivery date:',
266
+ * min: '2024-01-01',
267
+ * max: '2024-12-31'
268
+ * };
269
+ */
270
+ export interface AskDate extends AskBase {
271
+ ask: 'date';
272
+ /** Include time selection */
273
+ includeTime?: boolean;
274
+ /** Minimum date (ISO string) */
275
+ min?: string;
276
+ /** Maximum date (ISO string) */
277
+ max?: string;
278
+ /** Default value (ISO string) */
279
+ default?: string;
280
+ }
281
+
282
+ /**
283
+ * Union of all ask (input) yield types
284
+ */
285
+ export type AskYield =
286
+ | AskText
287
+ | AskPassword
288
+ | AskConfirm
289
+ | AskSelect
290
+ | AskNumber
291
+ | AskFile
292
+ | AskDate;
293
+
294
+ // ══════════════════════════════════════════════════════════════════════════════
295
+ // EMIT YIELDS - Output to user (fire and forget)
296
+ // ══════════════════════════════════════════════════════════════════════════════
297
+
298
+ /**
299
+ * Status message - general purpose user notification
300
+ *
301
+ * Use for: progress updates, step completions, informational messages
302
+ *
303
+ * @example
304
+ * yield { emit: 'status', message: 'Connecting to server...' };
305
+ * yield { emit: 'status', message: 'Upload complete!', type: 'success' };
306
+ */
307
+ export interface EmitStatus {
308
+ emit: 'status';
309
+ /** Message to display */
310
+ message: string;
311
+ /** Message type for styling */
312
+ type?: 'info' | 'success' | 'warning' | 'error';
70
313
  }
71
314
 
72
315
  /**
73
- * Progress update (for long-running operations)
316
+ * Progress update - for long-running operations
317
+ *
318
+ * Runtimes may display as: progress bar, percentage, spinner
319
+ *
320
+ * @example
321
+ * yield { emit: 'progress', value: 0.0, message: 'Starting...' };
322
+ * yield { emit: 'progress', value: 0.5, message: 'Halfway there...' };
323
+ * yield { emit: 'progress', value: 1.0, message: 'Complete!' };
74
324
  */
75
- export interface ProgressYield {
76
- progress: number; // 0-100
77
- status?: string;
78
- /** Additional data to stream to client */
79
- data?: any;
325
+ export interface EmitProgress {
326
+ emit: 'progress';
327
+ /** Progress value from 0 to 1 (0% to 100%) */
328
+ value: number;
329
+ /** Optional status message */
330
+ message?: string;
331
+ /** Additional metadata */
332
+ meta?: Record<string, any>;
80
333
  }
81
334
 
82
335
  /**
83
- * Stream data to client
336
+ * Streaming data - for chunked responses
337
+ *
338
+ * Use for: streaming text, large file transfers, real-time data
339
+ *
340
+ * @example
341
+ * for await (const chunk of aiStream) {
342
+ * yield { emit: 'stream', data: chunk.text };
343
+ * }
344
+ * yield { emit: 'stream', data: '', final: true };
84
345
  */
85
- export interface StreamYield {
86
- stream: any;
346
+ export interface EmitStream {
347
+ emit: 'stream';
348
+ /** Data chunk to send */
349
+ data: any;
87
350
  /** Whether this is the final chunk */
88
351
  final?: boolean;
352
+ /** Content type hint */
353
+ contentType?: string;
89
354
  }
90
355
 
91
356
  /**
92
- * Log/debug message
357
+ * Log message - for debugging/development
358
+ *
359
+ * May be hidden in production or routed to logging system
360
+ *
361
+ * @example
362
+ * yield { emit: 'log', message: 'Processing item', level: 'debug', data: { id: 123 } };
93
363
  */
94
- export interface LogYield {
95
- log: string;
364
+ export interface EmitLog {
365
+ emit: 'log';
366
+ /** Log message */
367
+ message: string;
368
+ /** Log level */
96
369
  level?: 'debug' | 'info' | 'warn' | 'error';
370
+ /** Additional structured data */
371
+ data?: Record<string, any>;
372
+ }
373
+
374
+ /**
375
+ * Toast notification - ephemeral popup message
376
+ *
377
+ * Use for: success confirmations, quick alerts, non-blocking notices
378
+ *
379
+ * @example
380
+ * yield { emit: 'toast', message: 'Settings saved!', type: 'success' };
381
+ * yield { emit: 'toast', message: 'Connection lost', type: 'error', duration: 5000 };
382
+ */
383
+ export interface EmitToast {
384
+ emit: 'toast';
385
+ /** Toast message */
386
+ message: string;
387
+ /** Toast type for styling */
388
+ type?: 'info' | 'success' | 'warning' | 'error';
389
+ /** Display duration in ms (0 = sticky) */
390
+ duration?: number;
391
+ }
392
+
393
+ /**
394
+ * Thinking indicator - for chatbot/AI contexts
395
+ *
396
+ * Shows user that processing is happening (typing dots, spinner)
397
+ *
398
+ * @example
399
+ * yield { emit: 'thinking', active: true };
400
+ * const result = await this.heavyComputation();
401
+ * yield { emit: 'thinking', active: false };
402
+ */
403
+ export interface EmitThinking {
404
+ emit: 'thinking';
405
+ /** Whether thinking indicator should be shown */
406
+ active: boolean;
407
+ }
408
+
409
+ /**
410
+ * Rich artifact - embedded content preview
411
+ *
412
+ * Use for: images, code blocks, documents, embeds
413
+ *
414
+ * @example
415
+ * yield {
416
+ * emit: 'artifact',
417
+ * type: 'image',
418
+ * url: 'https://example.com/chart.png',
419
+ * title: 'Sales Chart Q4'
420
+ * };
421
+ *
422
+ * yield {
423
+ * emit: 'artifact',
424
+ * type: 'code',
425
+ * language: 'typescript',
426
+ * content: 'const x = 1;',
427
+ * title: 'Example'
428
+ * };
429
+ */
430
+ export interface EmitArtifact {
431
+ emit: 'artifact';
432
+ /** Artifact type */
433
+ type: 'image' | 'code' | 'document' | 'embed' | 'json';
434
+ /** Title/label */
435
+ title?: string;
436
+ /** URL for external content */
437
+ url?: string;
438
+ /** Inline content */
439
+ content?: string;
440
+ /** Language hint for code */
441
+ language?: string;
442
+ /** MIME type hint */
443
+ mimeType?: string;
444
+ }
445
+
446
+ /**
447
+ * Union of all emit (output) yield types
448
+ */
449
+ export type EmitYield =
450
+ | EmitStatus
451
+ | EmitProgress
452
+ | EmitStream
453
+ | EmitLog
454
+ | EmitToast
455
+ | EmitThinking
456
+ | EmitArtifact;
457
+
458
+ // ══════════════════════════════════════════════════════════════════════════════
459
+ // COMBINED TYPES
460
+ // ══════════════════════════════════════════════════════════════════════════════
461
+
462
+ /**
463
+ * All possible yield types from a photon generator
464
+ */
465
+ export type PhotonYield = AskYield | EmitYield;
466
+
467
+ // ══════════════════════════════════════════════════════════════════════════════
468
+ // TYPE GUARDS - Check what kind of yield we have
469
+ // ══════════════════════════════════════════════════════════════════════════════
470
+
471
+ /**
472
+ * Check if yield is an ask (requires user input)
473
+ *
474
+ * @example
475
+ * if (isAskYield(yielded)) {
476
+ * const userInput = await promptUser(yielded);
477
+ * generator.next(userInput);
478
+ * }
479
+ */
480
+ export function isAskYield(y: PhotonYield): y is AskYield {
481
+ return 'ask' in y;
97
482
  }
98
483
 
99
484
  /**
100
- * All possible yield types
485
+ * Check if yield is an emit (output only, no response needed)
486
+ *
487
+ * @example
488
+ * if (isEmitYield(yielded)) {
489
+ * handleOutput(yielded);
490
+ * generator.next(); // Continue without value
491
+ * }
101
492
  */
102
- export type PhotonYield =
103
- | PromptYield
104
- | ConfirmYield
105
- | SelectYield
106
- | ProgressYield
107
- | StreamYield
108
- | LogYield;
493
+ export function isEmitYield(y: PhotonYield): y is EmitYield {
494
+ return 'emit' in y;
495
+ }
109
496
 
110
497
  /**
111
- * Check if a yield requires user input
498
+ * Get the type of an ask yield
112
499
  */
113
- export function isInputYield(y: PhotonYield): y is PromptYield | ConfirmYield | SelectYield {
114
- return 'prompt' in y || 'confirm' in y || 'select' in y;
500
+ export function getAskType(y: AskYield): AskYield['ask'] {
501
+ return y.ask;
115
502
  }
116
503
 
117
504
  /**
118
- * Check if a yield is a progress update
505
+ * Get the type of an emit yield
119
506
  */
120
- export function isProgressYield(y: PhotonYield): y is ProgressYield {
121
- return 'progress' in y;
507
+ export function getEmitType(y: EmitYield): EmitYield['emit'] {
508
+ return y.emit;
122
509
  }
123
510
 
511
+ // ══════════════════════════════════════════════════════════════════════════════
512
+ // GENERATOR DETECTION
513
+ // ══════════════════════════════════════════════════════════════════════════════
514
+
124
515
  /**
125
- * Check if a yield is streaming data
516
+ * Check if a function is an async generator function
517
+ *
518
+ * @example
519
+ * if (isAsyncGeneratorFunction(method)) {
520
+ * const gen = method.call(instance, params);
521
+ * await executeGenerator(gen, config);
522
+ * }
126
523
  */
127
- export function isStreamYield(y: PhotonYield): y is StreamYield {
128
- return 'stream' in y;
524
+ export function isAsyncGeneratorFunction(fn: any): fn is (...args: any[]) => AsyncGenerator {
525
+ if (!fn) return false;
526
+ const constructor = fn.constructor;
527
+ if (!constructor) return false;
528
+ if (constructor.name === 'AsyncGeneratorFunction') return true;
529
+ const prototype = Object.getPrototypeOf(fn);
530
+ return prototype?.constructor?.name === 'AsyncGeneratorFunction';
129
531
  }
130
532
 
131
533
  /**
132
- * Check if a yield is a log message
534
+ * Check if a value is an async generator instance (already invoked)
535
+ *
536
+ * @example
537
+ * const result = method.call(instance, params);
538
+ * if (isAsyncGenerator(result)) {
539
+ * await executeGenerator(result, config);
540
+ * }
133
541
  */
134
- export function isLogYield(y: PhotonYield): y is LogYield {
135
- return 'log' in y;
542
+ export function isAsyncGenerator(obj: any): obj is AsyncGenerator {
543
+ return obj &&
544
+ typeof obj.next === 'function' &&
545
+ typeof obj.return === 'function' &&
546
+ typeof obj.throw === 'function' &&
547
+ typeof obj[Symbol.asyncIterator] === 'function';
136
548
  }
137
549
 
138
- // ============================================================================
139
- // Input Provider - How runtimes provide values for yields
140
- // ============================================================================
550
+ // ══════════════════════════════════════════════════════════════════════════════
551
+ // INPUT PROVIDER - How runtimes supply values for ask yields
552
+ // ══════════════════════════════════════════════════════════════════════════════
141
553
 
142
554
  /**
143
- * Function that provides input for a yield
144
- * Runtimes implement this based on their protocol
555
+ * Function that provides input for an ask yield.
556
+ *
557
+ * Runtimes implement this based on their capabilities:
558
+ * - CLI: readline prompts
559
+ * - MCP: elicitation dialogs
560
+ * - WebSocket: request/response messages
561
+ * - REST: throw NeedsInputError for continuation flow
562
+ *
563
+ * @example
564
+ * const cliInputProvider: InputProvider = async (ask) => {
565
+ * if (ask.ask === 'text') return await readline(ask.message);
566
+ * if (ask.ask === 'confirm') return await confirm(ask.message);
567
+ * // ...
568
+ * };
145
569
  */
146
- export type InputProvider = (yielded: PhotonYield) => Promise<any>;
570
+ export type InputProvider = (ask: AskYield) => Promise<any>;
147
571
 
148
572
  /**
149
- * Handler for non-input yields (progress, stream, log)
573
+ * Handler for emit yields (output).
574
+ *
575
+ * Runtimes implement this to handle output:
576
+ * - CLI: console.log, progress bar
577
+ * - WebSocket: push message to client
578
+ * - REST: collect for response or send via SSE
579
+ *
580
+ * @example
581
+ * const cliOutputHandler: OutputHandler = (emit) => {
582
+ * if (emit.emit === 'status') console.log(emit.message);
583
+ * if (emit.emit === 'progress') updateProgressBar(emit.value);
584
+ * };
150
585
  */
151
- export type OutputHandler = (yielded: PhotonYield) => void | Promise<void>;
586
+ export type OutputHandler = (emit: EmitYield) => void | Promise<void>;
152
587
 
153
588
  /**
154
589
  * Configuration for generator execution
155
590
  */
156
591
  export interface GeneratorExecutorConfig {
157
- /** Provides input for prompt/confirm/select yields */
592
+ /**
593
+ * Provides input values for ask yields.
594
+ * Required unless all asks are pre-provided.
595
+ */
158
596
  inputProvider: InputProvider;
159
- /** Handles progress/stream/log yields */
597
+
598
+ /**
599
+ * Handles emit yields (optional).
600
+ * If not provided, emits are silently ignored.
601
+ */
160
602
  outputHandler?: OutputHandler;
161
- /** Pre-provided inputs (for REST APIs) */
603
+
604
+ /**
605
+ * Pre-provided inputs keyed by ask id.
606
+ * Used by REST APIs to pass all inputs upfront.
607
+ *
608
+ * @example
609
+ * // If photon yields { ask: 'text', id: 'name', message: '...' }
610
+ * // and preProvidedInputs = { name: 'John' }
611
+ * // The generator receives 'John' without calling inputProvider
612
+ */
162
613
  preProvidedInputs?: Record<string, any>;
163
- /** Timeout for waiting for input (ms) */
614
+
615
+ /**
616
+ * Timeout for waiting on input (ms).
617
+ * @default 300000 (5 minutes)
618
+ */
164
619
  inputTimeout?: number;
165
620
  }
166
621
 
167
- // ============================================================================
168
- // Generator Executor - Runs generator tools
169
- // ============================================================================
622
+ // ══════════════════════════════════════════════════════════════════════════════
623
+ // GENERATOR EXECUTOR - Runs generator tools to completion
624
+ // ══════════════════════════════════════════════════════════════════════════════
170
625
 
171
626
  /**
172
- * Execute a generator-based tool
627
+ * Execute a generator-based photon tool to completion.
628
+ *
629
+ * Handles the yield/resume loop:
630
+ * 1. Run generator until it yields
631
+ * 2. If ask yield: get input from provider (or pre-provided), resume with value
632
+ * 3. If emit yield: call output handler, resume without value
633
+ * 4. Repeat until generator returns
173
634
  *
174
635
  * @param generator - The async generator to execute
175
636
  * @param config - Configuration for handling yields
176
637
  * @returns The final return value of the generator
177
638
  *
178
639
  * @example
179
- * ```typescript
180
- * const result = await executeGenerator(tool.connect({ ip: '192.168.1.1' }), {
181
- * inputProvider: async (y) => {
182
- * if ('prompt' in y) return await readline(y.prompt);
183
- * if ('confirm' in y) return await confirm(y.confirm);
640
+ * const result = await executeGenerator(photon.connect({ ip: '192.168.1.1' }), {
641
+ * inputProvider: async (ask) => {
642
+ * if (ask.ask === 'text') return await readline(ask.message);
643
+ * if (ask.ask === 'confirm') return await confirm(ask.message);
184
644
  * },
185
- * outputHandler: (y) => {
186
- * if ('progress' in y) console.log(`Progress: ${y.progress}%`);
645
+ * outputHandler: (emit) => {
646
+ * if (emit.emit === 'progress') console.log(`${emit.value * 100}%`);
187
647
  * }
188
648
  * });
189
- * ```
190
649
  */
191
650
  export async function executeGenerator<T>(
192
651
  generator: AsyncGenerator<PhotonYield, T, any>,
@@ -194,20 +653,20 @@ export async function executeGenerator<T>(
194
653
  ): Promise<T> {
195
654
  const { inputProvider, outputHandler, preProvidedInputs } = config;
196
655
 
197
- let promptIndex = 0;
656
+ let askIndex = 0;
198
657
  let result = await generator.next();
199
658
 
200
659
  while (!result.done) {
201
660
  const yielded = result.value;
202
661
 
203
- // Handle input yields (prompt, confirm, select)
204
- if (isInputYield(yielded)) {
205
- // Generate ID if not provided
206
- const yieldId = yielded.id || `prompt_${promptIndex++}`;
662
+ // Handle ask yields (need input)
663
+ if (isAskYield(yielded)) {
664
+ // Generate id if not provided
665
+ const askId = yielded.id || `ask_${askIndex++}`;
207
666
 
208
667
  // Check for pre-provided input (REST API style)
209
- if (preProvidedInputs && yieldId in preProvidedInputs) {
210
- result = await generator.next(preProvidedInputs[yieldId]);
668
+ if (preProvidedInputs && askId in preProvidedInputs) {
669
+ result = await generator.next(preProvidedInputs[askId]);
211
670
  continue;
212
671
  }
213
672
 
@@ -215,80 +674,64 @@ export async function executeGenerator<T>(
215
674
  const input = await inputProvider(yielded);
216
675
  result = await generator.next(input);
217
676
  }
218
- // Handle output yields (progress, stream, log)
219
- else {
677
+ // Handle emit yields (output only)
678
+ else if (isEmitYield(yielded)) {
220
679
  if (outputHandler) {
221
680
  await outputHandler(yielded);
222
681
  }
223
682
  // Continue without providing a value
224
683
  result = await generator.next();
225
684
  }
685
+ // Unknown yield type - skip
686
+ else {
687
+ console.warn('[generator] Unknown yield type:', yielded);
688
+ result = await generator.next();
689
+ }
226
690
  }
227
691
 
228
692
  return result.value;
229
693
  }
230
694
 
231
- // ============================================================================
232
- // Generator Detection - Check if a function is a generator
233
- // ============================================================================
234
-
235
- /**
236
- * Check if a function is an async generator function
237
- */
238
- export function isAsyncGeneratorFunction(fn: any): fn is (...args: any[]) => AsyncGenerator {
239
- if (!fn) return false;
240
- const constructor = fn.constructor;
241
- if (!constructor) return false;
242
- if (constructor.name === 'AsyncGeneratorFunction') return true;
243
- // Check prototype chain
244
- const prototype = Object.getPrototypeOf(fn);
245
- return prototype && prototype.constructor &&
246
- prototype.constructor.name === 'AsyncGeneratorFunction';
247
- }
248
-
249
- /**
250
- * Check if a value is an async generator (already invoked)
251
- */
252
- export function isAsyncGenerator(obj: any): obj is AsyncGenerator {
253
- return obj &&
254
- typeof obj.next === 'function' &&
255
- typeof obj.return === 'function' &&
256
- typeof obj.throw === 'function' &&
257
- typeof obj[Symbol.asyncIterator] === 'function';
258
- }
259
-
260
- // ============================================================================
261
- // Yield Extraction - Extract yields from generator for schema generation
262
- // ============================================================================
695
+ // ══════════════════════════════════════════════════════════════════════════════
696
+ // YIELD EXTRACTION - For REST API schema generation
697
+ // ══════════════════════════════════════════════════════════════════════════════
263
698
 
264
699
  /**
265
- * Information about a yield point extracted from a generator
700
+ * Information about an ask yield extracted from a generator.
701
+ * Used to generate REST API schemas (optional parameters).
266
702
  */
267
- export interface ExtractedYield {
703
+ export interface ExtractedAsk {
268
704
  id: string;
269
- type: 'prompt' | 'confirm' | 'select';
270
- prompt?: string;
271
- options?: Array<string | { value: string; label: string }>;
272
- default?: string;
705
+ type: AskYield['ask'];
706
+ message: string;
273
707
  required?: boolean;
708
+ default?: any;
709
+ options?: Array<string | { value: string; label: string }>;
274
710
  pattern?: string;
275
- dangerous?: boolean;
276
- multi?: boolean;
711
+ min?: number;
712
+ max?: number;
277
713
  }
278
714
 
279
715
  /**
280
- * Extract yield information by running generator with mock provider
281
- * This is used for REST API schema generation
716
+ * Extract ask yield information by running generator with mock provider.
717
+ *
718
+ * This is used for REST API schema generation - each ask becomes
719
+ * an optional request parameter.
282
720
  *
283
- * Note: This only extracts yields that are reachable with default/empty inputs
284
- * Complex conditional yields may not be extracted
721
+ * Note: Only extracts asks reachable with default/empty inputs.
722
+ * Conditional asks may not be discovered.
723
+ *
724
+ * @example
725
+ * const asks = await extractAsks(Photon.prototype.connect, { ip: '' });
726
+ * // Returns: [{ id: 'pairing_code', type: 'text', message: '...' }]
727
+ * // These become optional query/body params in REST API
285
728
  */
286
- export async function extractYields(
729
+ export async function extractAsks(
287
730
  generatorFn: (...args: any[]) => AsyncGenerator<PhotonYield, any, any>,
288
731
  mockParams: any = {}
289
- ): Promise<ExtractedYield[]> {
290
- const yields: ExtractedYield[] = [];
291
- let promptIndex = 0;
732
+ ): Promise<ExtractedAsk[]> {
733
+ const asks: ExtractedAsk[] = [];
734
+ let askIndex = 0;
292
735
 
293
736
  try {
294
737
  const generator = generatorFn(mockParams);
@@ -297,108 +740,224 @@ export async function extractYields(
297
740
  while (!result.done) {
298
741
  const yielded = result.value;
299
742
 
300
- if (isInputYield(yielded)) {
301
- const id = yielded.id || `prompt_${promptIndex++}`;
302
-
303
- if ('prompt' in yielded) {
304
- yields.push({
305
- id,
306
- type: yielded.type === 'password' ? 'prompt' : 'prompt',
307
- prompt: yielded.prompt,
308
- default: yielded.default,
309
- required: yielded.required,
310
- pattern: yielded.pattern,
311
- });
312
- // Provide mock value to continue
313
- result = await generator.next(yielded.default || '');
314
- } else if ('confirm' in yielded) {
315
- yields.push({
316
- id,
317
- type: 'confirm',
318
- prompt: yielded.confirm,
319
- dangerous: yielded.dangerous,
320
- });
321
- result = await generator.next(true);
322
- } else if ('select' in yielded) {
323
- yields.push({
324
- id,
325
- type: 'select',
326
- prompt: yielded.select,
327
- options: yielded.options,
328
- multi: yielded.multi,
329
- });
330
- const firstOption = yielded.options[0];
331
- const mockValue = typeof firstOption === 'string' ? firstOption : firstOption.value;
332
- result = await generator.next(yielded.multi ? [mockValue] : mockValue);
743
+ if (isAskYield(yielded)) {
744
+ const id = yielded.id || `ask_${askIndex++}`;
745
+
746
+ const extracted: ExtractedAsk = {
747
+ id,
748
+ type: yielded.ask,
749
+ message: yielded.message,
750
+ required: yielded.required,
751
+ };
752
+
753
+ // Extract type-specific properties
754
+ if (yielded.ask === 'text') {
755
+ extracted.default = yielded.default;
756
+ extracted.pattern = yielded.pattern;
757
+ } else if (yielded.ask === 'confirm') {
758
+ extracted.default = yielded.default;
759
+ } else if (yielded.ask === 'select') {
760
+ extracted.options = yielded.options;
761
+ extracted.default = yielded.default;
762
+ } else if (yielded.ask === 'number') {
763
+ extracted.default = yielded.default;
764
+ extracted.min = yielded.min;
765
+ extracted.max = yielded.max;
333
766
  }
767
+
768
+ asks.push(extracted);
769
+
770
+ // Provide mock value to continue
771
+ const mockValue = getMockValue(yielded);
772
+ result = await generator.next(mockValue);
334
773
  } else {
335
- // Skip non-input yields
774
+ // Skip emit yields
336
775
  result = await generator.next();
337
776
  }
338
777
  }
339
778
  } catch (error) {
340
779
  // Generator may throw if it needs real resources
341
780
  // Return what we've extracted so far
342
- console.warn('[generator] Yield extraction incomplete:', error);
781
+ console.warn('[generator] Ask extraction incomplete:', error);
343
782
  }
344
783
 
345
- return yields;
784
+ return asks;
785
+ }
786
+
787
+ /**
788
+ * Get a mock value for an ask yield (for extraction purposes)
789
+ */
790
+ function getMockValue(ask: AskYield): any {
791
+ switch (ask.ask) {
792
+ case 'text':
793
+ case 'password':
794
+ return (ask as AskText).default || '';
795
+ case 'confirm':
796
+ return (ask as AskConfirm).default ?? true;
797
+ case 'select':
798
+ const select = ask as AskSelect;
799
+ const firstOpt = select.options[0];
800
+ const firstVal = typeof firstOpt === 'string' ? firstOpt : firstOpt.value;
801
+ return select.multi ? [firstVal] : firstVal;
802
+ case 'number':
803
+ return (ask as AskNumber).default ?? 0;
804
+ case 'file':
805
+ return null;
806
+ case 'date':
807
+ return (ask as AskDate).default || new Date().toISOString();
808
+ default:
809
+ return null;
810
+ }
346
811
  }
347
812
 
348
- // ============================================================================
349
- // Default Input Providers - Built-in implementations for common scenarios
350
- // ============================================================================
813
+ // ══════════════════════════════════════════════════════════════════════════════
814
+ // BUILT-IN INPUT PROVIDERS
815
+ // ══════════════════════════════════════════════════════════════════════════════
816
+
817
+ /**
818
+ * Error thrown when input is required but not available.
819
+ *
820
+ * REST APIs can catch this to return a continuation response.
821
+ *
822
+ * @example
823
+ * try {
824
+ * await executeGenerator(gen, { inputProvider: createPrefilledProvider({}) });
825
+ * } catch (e) {
826
+ * if (e instanceof NeedsInputError) {
827
+ * return {
828
+ * status: 'awaiting_input',
829
+ * ask: e.ask,
830
+ * continuation_id: saveContinuation(gen)
831
+ * };
832
+ * }
833
+ * }
834
+ */
835
+ export class NeedsInputError extends Error {
836
+ public readonly ask: AskYield;
837
+
838
+ constructor(ask: AskYield) {
839
+ super(`Input required: ${ask.message}`);
840
+ this.name = 'NeedsInputError';
841
+ this.ask = ask;
842
+ }
843
+ }
351
844
 
352
845
  /**
353
- * Create an input provider from pre-provided values
354
- * Throws if a required value is missing
846
+ * Create an input provider from pre-provided values.
847
+ * Throws NeedsInputError if a required value is missing.
848
+ *
849
+ * Use for REST APIs where all inputs are provided upfront.
850
+ *
851
+ * @example
852
+ * const provider = createPrefilledProvider({
853
+ * name: 'John',
854
+ * confirmed: true
855
+ * });
355
856
  */
356
857
  export function createPrefilledProvider(inputs: Record<string, any>): InputProvider {
357
- return async (yielded: PhotonYield) => {
358
- if (!isInputYield(yielded)) return undefined;
858
+ let askIndex = 0;
359
859
 
360
- const id = yielded.id || 'default';
860
+ return async (ask: AskYield) => {
861
+ const id = ask.id || `ask_${askIndex++}`;
361
862
 
362
863
  if (id in inputs) {
363
864
  return inputs[id];
364
865
  }
365
866
 
366
867
  // Check for default value
367
- if ('prompt' in yielded && yielded.default !== undefined) {
368
- return yielded.default;
868
+ if ('default' in ask && ask.default !== undefined) {
869
+ return ask.default;
369
870
  }
370
871
 
371
- throw new NeedsInputError(yielded);
872
+ // No input available
873
+ throw new NeedsInputError(ask);
372
874
  };
373
875
  }
374
876
 
877
+ // ══════════════════════════════════════════════════════════════════════════════
878
+ // UTILITY: Wrap regular function as generator
879
+ // ══════════════════════════════════════════════════════════════════════════════
880
+
375
881
  /**
376
- * Error thrown when input is needed but not available
377
- * Runtimes can catch this to return appropriate responses
882
+ * Wrap a regular async function to behave like a generator.
883
+ * Useful for uniform handling in runtimes.
884
+ *
885
+ * @example
886
+ * const gen = wrapAsGenerator(() => photon.simpleMethod(params));
887
+ * const result = await executeGenerator(gen, config);
378
888
  */
379
- export class NeedsInputError extends Error {
380
- public readonly yielded: PhotonYield;
889
+ export async function* wrapAsGenerator<T>(
890
+ asyncFn: () => Promise<T>
891
+ ): AsyncGenerator<never, T, unknown> {
892
+ return await asyncFn();
893
+ }
381
894
 
382
- constructor(yielded: PhotonYield) {
383
- const message = 'prompt' in yielded ? yielded.prompt :
384
- 'confirm' in yielded ? yielded.confirm :
385
- 'select' in yielded ? yielded.select : 'Input required';
386
- super(`Input required: ${message}`);
387
- this.name = 'NeedsInputError';
388
- this.yielded = yielded;
389
- }
895
+ // ══════════════════════════════════════════════════════════════════════════════
896
+ // LEGACY COMPATIBILITY - Map old format to new
897
+ // ══════════════════════════════════════════════════════════════════════════════
898
+
899
+ /**
900
+ * @deprecated Use AskYield instead
901
+ */
902
+ export type PromptYield = AskText | AskPassword;
903
+
904
+ /**
905
+ * @deprecated Use AskConfirm instead
906
+ */
907
+ export type ConfirmYield = AskConfirm;
908
+
909
+ /**
910
+ * @deprecated Use AskSelect instead
911
+ */
912
+ export type SelectYield = AskSelect;
913
+
914
+ /**
915
+ * @deprecated Use EmitProgress instead
916
+ */
917
+ export type ProgressYield = EmitProgress;
918
+
919
+ /**
920
+ * @deprecated Use EmitStream instead
921
+ */
922
+ export type StreamYield = EmitStream;
923
+
924
+ /**
925
+ * @deprecated Use EmitLog instead
926
+ */
927
+ export type LogYield = EmitLog;
928
+
929
+ /**
930
+ * @deprecated Use isAskYield instead
931
+ */
932
+ export const isInputYield = isAskYield;
933
+
934
+ /**
935
+ * @deprecated Use isEmitYield instead
936
+ */
937
+ export function isProgressYield(y: PhotonYield): y is EmitProgress {
938
+ return isEmitYield(y) && y.emit === 'progress';
390
939
  }
391
940
 
392
- // ============================================================================
393
- // Utility: Wrap regular async function to match generator interface
394
- // ============================================================================
941
+ /**
942
+ * @deprecated Use isEmitYield instead
943
+ */
944
+ export function isStreamYield(y: PhotonYield): y is EmitStream {
945
+ return isEmitYield(y) && y.emit === 'stream';
946
+ }
395
947
 
396
948
  /**
397
- * Wrap a regular async function to behave like a generator
398
- * Useful for uniform handling in runtimes
949
+ * @deprecated Use isEmitYield instead
399
950
  */
400
- export async function* wrapAsGenerator<T>(
401
- asyncFn: () => Promise<T>
402
- ): AsyncGenerator<never, T, unknown> {
403
- return await asyncFn();
951
+ export function isLogYield(y: PhotonYield): y is EmitLog {
952
+ return isEmitYield(y) && y.emit === 'log';
404
953
  }
954
+
955
+ /**
956
+ * @deprecated Use extractAsks instead
957
+ */
958
+ export const extractYields = extractAsks;
959
+
960
+ /**
961
+ * @deprecated Use ExtractedAsk instead
962
+ */
963
+ export type ExtractedYield = ExtractedAsk;