@portel/photon-core 1.0.2 → 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/dist/elicit.d.ts +93 -0
- package/dist/elicit.d.ts.map +1 -0
- package/dist/elicit.js +373 -0
- package/dist/elicit.js.map +1 -0
- package/dist/generator.d.ts +685 -0
- package/dist/generator.d.ts.map +1 -0
- package/dist/generator.js +410 -0
- package/dist/generator.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -1
- package/dist/schema-extractor.d.ts +6 -0
- package/dist/schema-extractor.d.ts.map +1 -1
- package/dist/schema-extractor.js +26 -1
- package/dist/schema-extractor.js.map +1 -1
- package/dist/types.d.ts +22 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/src/elicit.ts +438 -0
- package/src/generator.ts +963 -0
- package/src/index.ts +91 -0
- package/src/schema-extractor.ts +32 -2
- package/src/types.ts +20 -0
package/src/generator.ts
ADDED
|
@@ -0,0 +1,963 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generator-based Tool Execution with Ask/Emit Pattern
|
|
3
|
+
*
|
|
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
|
|
7
|
+
*
|
|
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
|
+
* ══════════════════════════════════════════════════════════════════════════════
|
|
54
|
+
*
|
|
55
|
+
* ```typescript
|
|
56
|
+
* async *connect(params: { ip: string }) {
|
|
57
|
+
* yield { emit: 'status', message: 'Connecting to TV...' };
|
|
58
|
+
*
|
|
59
|
+
* await this.startPairing(params.ip);
|
|
60
|
+
*
|
|
61
|
+
* yield { emit: 'progress', value: 0.3, message: 'Waiting for code...' };
|
|
62
|
+
*
|
|
63
|
+
* // Blocks until user provides input
|
|
64
|
+
* const code: string = yield {
|
|
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
|
|
70
|
+
* };
|
|
71
|
+
*
|
|
72
|
+
* yield { emit: 'status', message: 'Verifying code...' };
|
|
73
|
+
*
|
|
74
|
+
* await this.sendCode(code);
|
|
75
|
+
*
|
|
76
|
+
* yield { emit: 'toast', message: 'Connected!', type: 'success' };
|
|
77
|
+
*
|
|
78
|
+
* return { success: true, paired: true };
|
|
79
|
+
* }
|
|
80
|
+
* ```
|
|
81
|
+
*
|
|
82
|
+
* @module generator
|
|
83
|
+
*/
|
|
84
|
+
|
|
85
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
86
|
+
// ASK YIELDS - Input from user (blocks until response)
|
|
87
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Base properties shared by all ask yields
|
|
91
|
+
*/
|
|
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
|
+
*/
|
|
102
|
+
id?: string;
|
|
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
|
+
*/
|
|
115
|
+
required?: boolean;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
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
|
+
* };
|
|
165
|
+
*/
|
|
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
|
+
*/
|
|
172
|
+
dangerous?: boolean;
|
|
173
|
+
/** Default value if user just presses enter */
|
|
174
|
+
default?: boolean;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
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
|
+
* };
|
|
205
|
+
*/
|
|
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 */
|
|
211
|
+
multi?: boolean;
|
|
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';
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
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!' };
|
|
324
|
+
*/
|
|
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>;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
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 };
|
|
345
|
+
*/
|
|
346
|
+
export interface EmitStream {
|
|
347
|
+
emit: 'stream';
|
|
348
|
+
/** Data chunk to send */
|
|
349
|
+
data: any;
|
|
350
|
+
/** Whether this is the final chunk */
|
|
351
|
+
final?: boolean;
|
|
352
|
+
/** Content type hint */
|
|
353
|
+
contentType?: string;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
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 } };
|
|
363
|
+
*/
|
|
364
|
+
export interface EmitLog {
|
|
365
|
+
emit: 'log';
|
|
366
|
+
/** Log message */
|
|
367
|
+
message: string;
|
|
368
|
+
/** Log level */
|
|
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;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
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
|
+
* }
|
|
492
|
+
*/
|
|
493
|
+
export function isEmitYield(y: PhotonYield): y is EmitYield {
|
|
494
|
+
return 'emit' in y;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Get the type of an ask yield
|
|
499
|
+
*/
|
|
500
|
+
export function getAskType(y: AskYield): AskYield['ask'] {
|
|
501
|
+
return y.ask;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Get the type of an emit yield
|
|
506
|
+
*/
|
|
507
|
+
export function getEmitType(y: EmitYield): EmitYield['emit'] {
|
|
508
|
+
return y.emit;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
512
|
+
// GENERATOR DETECTION
|
|
513
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
514
|
+
|
|
515
|
+
/**
|
|
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
|
+
* }
|
|
523
|
+
*/
|
|
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';
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
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
|
+
* }
|
|
541
|
+
*/
|
|
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';
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
551
|
+
// INPUT PROVIDER - How runtimes supply values for ask yields
|
|
552
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
553
|
+
|
|
554
|
+
/**
|
|
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
|
+
* };
|
|
569
|
+
*/
|
|
570
|
+
export type InputProvider = (ask: AskYield) => Promise<any>;
|
|
571
|
+
|
|
572
|
+
/**
|
|
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
|
+
* };
|
|
585
|
+
*/
|
|
586
|
+
export type OutputHandler = (emit: EmitYield) => void | Promise<void>;
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Configuration for generator execution
|
|
590
|
+
*/
|
|
591
|
+
export interface GeneratorExecutorConfig {
|
|
592
|
+
/**
|
|
593
|
+
* Provides input values for ask yields.
|
|
594
|
+
* Required unless all asks are pre-provided.
|
|
595
|
+
*/
|
|
596
|
+
inputProvider: InputProvider;
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Handles emit yields (optional).
|
|
600
|
+
* If not provided, emits are silently ignored.
|
|
601
|
+
*/
|
|
602
|
+
outputHandler?: OutputHandler;
|
|
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
|
+
*/
|
|
613
|
+
preProvidedInputs?: Record<string, any>;
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Timeout for waiting on input (ms).
|
|
617
|
+
* @default 300000 (5 minutes)
|
|
618
|
+
*/
|
|
619
|
+
inputTimeout?: number;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
623
|
+
// GENERATOR EXECUTOR - Runs generator tools to completion
|
|
624
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
625
|
+
|
|
626
|
+
/**
|
|
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
|
|
634
|
+
*
|
|
635
|
+
* @param generator - The async generator to execute
|
|
636
|
+
* @param config - Configuration for handling yields
|
|
637
|
+
* @returns The final return value of the generator
|
|
638
|
+
*
|
|
639
|
+
* @example
|
|
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);
|
|
644
|
+
* },
|
|
645
|
+
* outputHandler: (emit) => {
|
|
646
|
+
* if (emit.emit === 'progress') console.log(`${emit.value * 100}%`);
|
|
647
|
+
* }
|
|
648
|
+
* });
|
|
649
|
+
*/
|
|
650
|
+
export async function executeGenerator<T>(
|
|
651
|
+
generator: AsyncGenerator<PhotonYield, T, any>,
|
|
652
|
+
config: GeneratorExecutorConfig
|
|
653
|
+
): Promise<T> {
|
|
654
|
+
const { inputProvider, outputHandler, preProvidedInputs } = config;
|
|
655
|
+
|
|
656
|
+
let askIndex = 0;
|
|
657
|
+
let result = await generator.next();
|
|
658
|
+
|
|
659
|
+
while (!result.done) {
|
|
660
|
+
const yielded = result.value;
|
|
661
|
+
|
|
662
|
+
// Handle ask yields (need input)
|
|
663
|
+
if (isAskYield(yielded)) {
|
|
664
|
+
// Generate id if not provided
|
|
665
|
+
const askId = yielded.id || `ask_${askIndex++}`;
|
|
666
|
+
|
|
667
|
+
// Check for pre-provided input (REST API style)
|
|
668
|
+
if (preProvidedInputs && askId in preProvidedInputs) {
|
|
669
|
+
result = await generator.next(preProvidedInputs[askId]);
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Get input from provider
|
|
674
|
+
const input = await inputProvider(yielded);
|
|
675
|
+
result = await generator.next(input);
|
|
676
|
+
}
|
|
677
|
+
// Handle emit yields (output only)
|
|
678
|
+
else if (isEmitYield(yielded)) {
|
|
679
|
+
if (outputHandler) {
|
|
680
|
+
await outputHandler(yielded);
|
|
681
|
+
}
|
|
682
|
+
// Continue without providing a value
|
|
683
|
+
result = await generator.next();
|
|
684
|
+
}
|
|
685
|
+
// Unknown yield type - skip
|
|
686
|
+
else {
|
|
687
|
+
console.warn('[generator] Unknown yield type:', yielded);
|
|
688
|
+
result = await generator.next();
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
return result.value;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
696
|
+
// YIELD EXTRACTION - For REST API schema generation
|
|
697
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Information about an ask yield extracted from a generator.
|
|
701
|
+
* Used to generate REST API schemas (optional parameters).
|
|
702
|
+
*/
|
|
703
|
+
export interface ExtractedAsk {
|
|
704
|
+
id: string;
|
|
705
|
+
type: AskYield['ask'];
|
|
706
|
+
message: string;
|
|
707
|
+
required?: boolean;
|
|
708
|
+
default?: any;
|
|
709
|
+
options?: Array<string | { value: string; label: string }>;
|
|
710
|
+
pattern?: string;
|
|
711
|
+
min?: number;
|
|
712
|
+
max?: number;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
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.
|
|
720
|
+
*
|
|
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
|
|
728
|
+
*/
|
|
729
|
+
export async function extractAsks(
|
|
730
|
+
generatorFn: (...args: any[]) => AsyncGenerator<PhotonYield, any, any>,
|
|
731
|
+
mockParams: any = {}
|
|
732
|
+
): Promise<ExtractedAsk[]> {
|
|
733
|
+
const asks: ExtractedAsk[] = [];
|
|
734
|
+
let askIndex = 0;
|
|
735
|
+
|
|
736
|
+
try {
|
|
737
|
+
const generator = generatorFn(mockParams);
|
|
738
|
+
let result = await generator.next();
|
|
739
|
+
|
|
740
|
+
while (!result.done) {
|
|
741
|
+
const yielded = result.value;
|
|
742
|
+
|
|
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;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
asks.push(extracted);
|
|
769
|
+
|
|
770
|
+
// Provide mock value to continue
|
|
771
|
+
const mockValue = getMockValue(yielded);
|
|
772
|
+
result = await generator.next(mockValue);
|
|
773
|
+
} else {
|
|
774
|
+
// Skip emit yields
|
|
775
|
+
result = await generator.next();
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
} catch (error) {
|
|
779
|
+
// Generator may throw if it needs real resources
|
|
780
|
+
// Return what we've extracted so far
|
|
781
|
+
console.warn('[generator] Ask extraction incomplete:', error);
|
|
782
|
+
}
|
|
783
|
+
|
|
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
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
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
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
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
|
+
* });
|
|
856
|
+
*/
|
|
857
|
+
export function createPrefilledProvider(inputs: Record<string, any>): InputProvider {
|
|
858
|
+
let askIndex = 0;
|
|
859
|
+
|
|
860
|
+
return async (ask: AskYield) => {
|
|
861
|
+
const id = ask.id || `ask_${askIndex++}`;
|
|
862
|
+
|
|
863
|
+
if (id in inputs) {
|
|
864
|
+
return inputs[id];
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// Check for default value
|
|
868
|
+
if ('default' in ask && ask.default !== undefined) {
|
|
869
|
+
return ask.default;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// No input available
|
|
873
|
+
throw new NeedsInputError(ask);
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
878
|
+
// UTILITY: Wrap regular function as generator
|
|
879
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
880
|
+
|
|
881
|
+
/**
|
|
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);
|
|
888
|
+
*/
|
|
889
|
+
export async function* wrapAsGenerator<T>(
|
|
890
|
+
asyncFn: () => Promise<T>
|
|
891
|
+
): AsyncGenerator<never, T, unknown> {
|
|
892
|
+
return await asyncFn();
|
|
893
|
+
}
|
|
894
|
+
|
|
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';
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* @deprecated Use isEmitYield instead
|
|
943
|
+
*/
|
|
944
|
+
export function isStreamYield(y: PhotonYield): y is EmitStream {
|
|
945
|
+
return isEmitYield(y) && y.emit === 'stream';
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* @deprecated Use isEmitYield instead
|
|
950
|
+
*/
|
|
951
|
+
export function isLogYield(y: PhotonYield): y is EmitLog {
|
|
952
|
+
return isEmitYield(y) && y.emit === 'log';
|
|
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;
|