@portel/photon-core 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,404 @@
1
+ /**
2
+ * Generator-based Tool Execution
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
9
+ *
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
15
+ *
16
+ * @example
17
+ * ```typescript
18
+ * async *connect(params: { ip: string }) {
19
+ * await this.startConnection(params.ip);
20
+ *
21
+ * const code: string = yield {
22
+ * prompt: 'Enter the 6-digit code:',
23
+ * type: 'text'
24
+ * };
25
+ *
26
+ * await this.sendCode(code);
27
+ * return { success: true };
28
+ * }
29
+ * ```
30
+ */
31
+
32
+ // ============================================================================
33
+ // Yield Types - What can be yielded from generator tools
34
+ // ============================================================================
35
+
36
+ /**
37
+ * Text input prompt
38
+ */
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) */
44
+ id?: string;
45
+ /** Validation pattern */
46
+ pattern?: string;
47
+ /** Whether this prompt is required */
48
+ required?: boolean;
49
+ }
50
+
51
+ /**
52
+ * Confirmation dialog
53
+ */
54
+ export interface ConfirmYield {
55
+ confirm: string;
56
+ /** Mark as dangerous action (UI can show warning styling) */
57
+ dangerous?: boolean;
58
+ id?: string;
59
+ }
60
+
61
+ /**
62
+ * Selection from options
63
+ */
64
+ export interface SelectYield {
65
+ select: string;
66
+ options: Array<string | { value: string; label: string }>;
67
+ /** Allow multiple selections */
68
+ multi?: boolean;
69
+ id?: string;
70
+ }
71
+
72
+ /**
73
+ * Progress update (for long-running operations)
74
+ */
75
+ export interface ProgressYield {
76
+ progress: number; // 0-100
77
+ status?: string;
78
+ /** Additional data to stream to client */
79
+ data?: any;
80
+ }
81
+
82
+ /**
83
+ * Stream data to client
84
+ */
85
+ export interface StreamYield {
86
+ stream: any;
87
+ /** Whether this is the final chunk */
88
+ final?: boolean;
89
+ }
90
+
91
+ /**
92
+ * Log/debug message
93
+ */
94
+ export interface LogYield {
95
+ log: string;
96
+ level?: 'debug' | 'info' | 'warn' | 'error';
97
+ }
98
+
99
+ /**
100
+ * All possible yield types
101
+ */
102
+ export type PhotonYield =
103
+ | PromptYield
104
+ | ConfirmYield
105
+ | SelectYield
106
+ | ProgressYield
107
+ | StreamYield
108
+ | LogYield;
109
+
110
+ /**
111
+ * Check if a yield requires user input
112
+ */
113
+ export function isInputYield(y: PhotonYield): y is PromptYield | ConfirmYield | SelectYield {
114
+ return 'prompt' in y || 'confirm' in y || 'select' in y;
115
+ }
116
+
117
+ /**
118
+ * Check if a yield is a progress update
119
+ */
120
+ export function isProgressYield(y: PhotonYield): y is ProgressYield {
121
+ return 'progress' in y;
122
+ }
123
+
124
+ /**
125
+ * Check if a yield is streaming data
126
+ */
127
+ export function isStreamYield(y: PhotonYield): y is StreamYield {
128
+ return 'stream' in y;
129
+ }
130
+
131
+ /**
132
+ * Check if a yield is a log message
133
+ */
134
+ export function isLogYield(y: PhotonYield): y is LogYield {
135
+ return 'log' in y;
136
+ }
137
+
138
+ // ============================================================================
139
+ // Input Provider - How runtimes provide values for yields
140
+ // ============================================================================
141
+
142
+ /**
143
+ * Function that provides input for a yield
144
+ * Runtimes implement this based on their protocol
145
+ */
146
+ export type InputProvider = (yielded: PhotonYield) => Promise<any>;
147
+
148
+ /**
149
+ * Handler for non-input yields (progress, stream, log)
150
+ */
151
+ export type OutputHandler = (yielded: PhotonYield) => void | Promise<void>;
152
+
153
+ /**
154
+ * Configuration for generator execution
155
+ */
156
+ export interface GeneratorExecutorConfig {
157
+ /** Provides input for prompt/confirm/select yields */
158
+ inputProvider: InputProvider;
159
+ /** Handles progress/stream/log yields */
160
+ outputHandler?: OutputHandler;
161
+ /** Pre-provided inputs (for REST APIs) */
162
+ preProvidedInputs?: Record<string, any>;
163
+ /** Timeout for waiting for input (ms) */
164
+ inputTimeout?: number;
165
+ }
166
+
167
+ // ============================================================================
168
+ // Generator Executor - Runs generator tools
169
+ // ============================================================================
170
+
171
+ /**
172
+ * Execute a generator-based tool
173
+ *
174
+ * @param generator - The async generator to execute
175
+ * @param config - Configuration for handling yields
176
+ * @returns The final return value of the generator
177
+ *
178
+ * @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);
184
+ * },
185
+ * outputHandler: (y) => {
186
+ * if ('progress' in y) console.log(`Progress: ${y.progress}%`);
187
+ * }
188
+ * });
189
+ * ```
190
+ */
191
+ export async function executeGenerator<T>(
192
+ generator: AsyncGenerator<PhotonYield, T, any>,
193
+ config: GeneratorExecutorConfig
194
+ ): Promise<T> {
195
+ const { inputProvider, outputHandler, preProvidedInputs } = config;
196
+
197
+ let promptIndex = 0;
198
+ let result = await generator.next();
199
+
200
+ while (!result.done) {
201
+ const yielded = result.value;
202
+
203
+ // Handle input yields (prompt, confirm, select)
204
+ if (isInputYield(yielded)) {
205
+ // Generate ID if not provided
206
+ const yieldId = yielded.id || `prompt_${promptIndex++}`;
207
+
208
+ // Check for pre-provided input (REST API style)
209
+ if (preProvidedInputs && yieldId in preProvidedInputs) {
210
+ result = await generator.next(preProvidedInputs[yieldId]);
211
+ continue;
212
+ }
213
+
214
+ // Get input from provider
215
+ const input = await inputProvider(yielded);
216
+ result = await generator.next(input);
217
+ }
218
+ // Handle output yields (progress, stream, log)
219
+ else {
220
+ if (outputHandler) {
221
+ await outputHandler(yielded);
222
+ }
223
+ // Continue without providing a value
224
+ result = await generator.next();
225
+ }
226
+ }
227
+
228
+ return result.value;
229
+ }
230
+
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
+ // ============================================================================
263
+
264
+ /**
265
+ * Information about a yield point extracted from a generator
266
+ */
267
+ export interface ExtractedYield {
268
+ id: string;
269
+ type: 'prompt' | 'confirm' | 'select';
270
+ prompt?: string;
271
+ options?: Array<string | { value: string; label: string }>;
272
+ default?: string;
273
+ required?: boolean;
274
+ pattern?: string;
275
+ dangerous?: boolean;
276
+ multi?: boolean;
277
+ }
278
+
279
+ /**
280
+ * Extract yield information by running generator with mock provider
281
+ * This is used for REST API schema generation
282
+ *
283
+ * Note: This only extracts yields that are reachable with default/empty inputs
284
+ * Complex conditional yields may not be extracted
285
+ */
286
+ export async function extractYields(
287
+ generatorFn: (...args: any[]) => AsyncGenerator<PhotonYield, any, any>,
288
+ mockParams: any = {}
289
+ ): Promise<ExtractedYield[]> {
290
+ const yields: ExtractedYield[] = [];
291
+ let promptIndex = 0;
292
+
293
+ try {
294
+ const generator = generatorFn(mockParams);
295
+ let result = await generator.next();
296
+
297
+ while (!result.done) {
298
+ const yielded = result.value;
299
+
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);
333
+ }
334
+ } else {
335
+ // Skip non-input yields
336
+ result = await generator.next();
337
+ }
338
+ }
339
+ } catch (error) {
340
+ // Generator may throw if it needs real resources
341
+ // Return what we've extracted so far
342
+ console.warn('[generator] Yield extraction incomplete:', error);
343
+ }
344
+
345
+ return yields;
346
+ }
347
+
348
+ // ============================================================================
349
+ // Default Input Providers - Built-in implementations for common scenarios
350
+ // ============================================================================
351
+
352
+ /**
353
+ * Create an input provider from pre-provided values
354
+ * Throws if a required value is missing
355
+ */
356
+ export function createPrefilledProvider(inputs: Record<string, any>): InputProvider {
357
+ return async (yielded: PhotonYield) => {
358
+ if (!isInputYield(yielded)) return undefined;
359
+
360
+ const id = yielded.id || 'default';
361
+
362
+ if (id in inputs) {
363
+ return inputs[id];
364
+ }
365
+
366
+ // Check for default value
367
+ if ('prompt' in yielded && yielded.default !== undefined) {
368
+ return yielded.default;
369
+ }
370
+
371
+ throw new NeedsInputError(yielded);
372
+ };
373
+ }
374
+
375
+ /**
376
+ * Error thrown when input is needed but not available
377
+ * Runtimes can catch this to return appropriate responses
378
+ */
379
+ export class NeedsInputError extends Error {
380
+ public readonly yielded: PhotonYield;
381
+
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
+ }
390
+ }
391
+
392
+ // ============================================================================
393
+ // Utility: Wrap regular async function to match generator interface
394
+ // ============================================================================
395
+
396
+ /**
397
+ * Wrap a regular async function to behave like a generator
398
+ * Useful for uniform handling in runtimes
399
+ */
400
+ export async function* wrapAsGenerator<T>(
401
+ asyncFn: () => Promise<T>
402
+ ): AsyncGenerator<never, T, unknown> {
403
+ return await asyncFn();
404
+ }
package/src/index.ts CHANGED
@@ -76,3 +76,57 @@ export {
76
76
 
77
77
  // Types
78
78
  export * from './types.js';
79
+
80
+ // Generator-based tools - yield for prompts, progress, streaming
81
+ export {
82
+ // Yield type checking
83
+ isInputYield,
84
+ isProgressYield,
85
+ isStreamYield,
86
+ isLogYield,
87
+ // Generator detection
88
+ isAsyncGeneratorFunction,
89
+ isAsyncGenerator,
90
+ // Executor
91
+ executeGenerator,
92
+ // Yield extraction (for REST schema generation)
93
+ extractYields,
94
+ // Built-in providers
95
+ createPrefilledProvider,
96
+ NeedsInputError,
97
+ // Utility
98
+ wrapAsGenerator,
99
+ // Types
100
+ type PhotonYield,
101
+ type PromptYield,
102
+ type ConfirmYield,
103
+ type SelectYield,
104
+ type ProgressYield,
105
+ type StreamYield,
106
+ type LogYield,
107
+ type InputProvider,
108
+ type OutputHandler,
109
+ type GeneratorExecutorConfig,
110
+ type ExtractedYield,
111
+ } from './generator.js';
112
+
113
+ // Elicit - Cross-platform user input (legacy, prefer generators)
114
+ export {
115
+ // Simple functions (no imports needed in photon files)
116
+ prompt,
117
+ confirm,
118
+ // Full elicit with options
119
+ elicit,
120
+ elicitReadline,
121
+ elicitNativeDialog,
122
+ // Handler management (for runtimes)
123
+ setPromptHandler,
124
+ getPromptHandler,
125
+ setElicitHandler,
126
+ getElicitHandler,
127
+ // Types
128
+ type ElicitOptions,
129
+ type ElicitResult,
130
+ type ElicitHandler,
131
+ type PromptHandler,
132
+ } from './elicit.js';
@@ -10,7 +10,7 @@
10
10
 
11
11
  import * as fs from 'fs/promises';
12
12
  import * as ts from 'typescript';
13
- import { ExtractedSchema, ConstructorParam, TemplateInfo, StaticInfo, OutputFormat } from './types.js';
13
+ import { ExtractedSchema, ConstructorParam, TemplateInfo, StaticInfo, OutputFormat, YieldInfo } from './types.js';
14
14
 
15
15
  export interface ExtractedMetadata {
16
16
  tools: ExtractedSchema[];
@@ -63,6 +63,9 @@ export class SchemaExtractor {
63
63
  const methodName = member.name.getText(sourceFile);
64
64
  const jsdoc = this.getJSDocComment(member, sourceFile);
65
65
 
66
+ // Check if this is an async generator method (has asterisk token)
67
+ const isGenerator = member.asteriskToken !== undefined;
68
+
66
69
  // Extract parameter type information
67
70
  const paramsType = this.getFirstParameterType(member, sourceFile);
68
71
  if (!paramsType) {
@@ -126,11 +129,15 @@ export class SchemaExtractor {
126
129
  // Otherwise, it's a regular tool
127
130
  else {
128
131
  const outputFormat = this.extractFormat(jsdoc);
132
+ const yields = isGenerator ? this.extractYieldsFromJSDoc(jsdoc) : undefined;
133
+
129
134
  tools.push({
130
135
  name: methodName,
131
136
  description,
132
137
  inputSchema,
133
138
  ...(outputFormat ? { outputFormat } : {}),
139
+ ...(isGenerator ? { isGenerator: true } : {}),
140
+ ...(yields && yields.length > 0 ? { yields } : {}),
134
141
  });
135
142
  }
136
143
  };
@@ -140,7 +147,7 @@ export class SchemaExtractor {
140
147
  // Look for class declarations
141
148
  if (ts.isClassDeclaration(node)) {
142
149
  node.members.forEach((member) => {
143
- // Look for async methods
150
+ // Look for async methods (including async generators with *)
144
151
  if (ts.isMethodDeclaration(member) &&
145
152
  member.modifiers?.some(m => m.kind === ts.SyntaxKind.AsyncKeyword)) {
146
153
  processMethod(member);
@@ -846,4 +853,27 @@ export class SchemaExtractor {
846
853
  const match = jsdocContent.match(/@mimeType\s+([\w\/\-+.]+)/i);
847
854
  return match ? match[1].trim() : undefined;
848
855
  }
856
+
857
+ /**
858
+ * Extract yield information from JSDoc for generator methods
859
+ * Supports @yields tags with id, type, and description
860
+ * Example: @yields {pairing_code} text Enter the 6-digit code shown on TV
861
+ */
862
+ private extractYieldsFromJSDoc(jsdocContent: string): YieldInfo[] {
863
+ const yields: YieldInfo[] = [];
864
+ // Match @yields {id} type description
865
+ const yieldRegex = /@yields?\s+\{(\w+)\}\s+(prompt|confirm|select)\s+(.+)/gi;
866
+
867
+ let match;
868
+ while ((match = yieldRegex.exec(jsdocContent)) !== null) {
869
+ const [, id, type, description] = match;
870
+ yields.push({
871
+ id,
872
+ type: type.toLowerCase() as 'prompt' | 'confirm' | 'select',
873
+ prompt: description.trim(),
874
+ });
875
+ }
876
+
877
+ return yields;
878
+ }
849
879
  }
package/src/types.ts CHANGED
@@ -23,6 +23,22 @@ export interface PhotonTool {
23
23
  outputFormat?: OutputFormat;
24
24
  }
25
25
 
26
+ /**
27
+ * Yield information extracted from generator methods
28
+ * Used for REST API schema generation (yields become optional parameters)
29
+ */
30
+ export interface YieldInfo {
31
+ id: string;
32
+ type: 'prompt' | 'confirm' | 'select';
33
+ prompt?: string;
34
+ options?: Array<string | { value: string; label: string }>;
35
+ default?: string;
36
+ required?: boolean;
37
+ pattern?: string;
38
+ dangerous?: boolean;
39
+ multi?: boolean;
40
+ }
41
+
26
42
  export interface ExtractedSchema {
27
43
  name: string;
28
44
  description: string;
@@ -32,6 +48,10 @@ export interface ExtractedSchema {
32
48
  required?: string[];
33
49
  };
34
50
  outputFormat?: OutputFormat;
51
+ /** True if this method is an async generator (uses yield for prompts) */
52
+ isGenerator?: boolean;
53
+ /** Yield information for generator methods (used by REST APIs) */
54
+ yields?: YieldInfo[];
35
55
  }
36
56
 
37
57
  export interface PhotonMCPClass {