@portel/photon-core 2.12.0 → 2.14.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/index.ts CHANGED
@@ -37,7 +37,6 @@
37
37
  // CLI formatting, progress, text utils, fuzzy matching, logging
38
38
  export {
39
39
  // Types
40
- type OutputFormat,
41
40
  type TextWrapOptions,
42
41
  type FuzzyMatch,
43
42
  type ExecutionContext,
@@ -239,6 +238,7 @@ export {
239
238
  type EmitThinking,
240
239
  type EmitArtifact,
241
240
  type EmitUI,
241
+ type EmitQR,
242
242
 
243
243
  // Checkpoint yield type
244
244
  type CheckpointYield,
@@ -481,6 +481,10 @@ export {
481
481
  type NextFn,
482
482
  } from './middleware.js';
483
483
 
484
+ // ===== PHOTON LOADER LITE =====
485
+ // Direct TypeScript API for loading .photon.ts files with full enhancements
486
+ export { photon, clearPhotonCache, type PhotonOptions, type PhotonEvent } from './photon-loader-lite.js';
487
+
484
488
  // ===== FILE WATCHING =====
485
489
  // Reusable photon file watcher with symlink resolution, debouncing, rename handling
486
490
  export { PhotonWatcher, type PhotonWatcherOptions } from './watcher.js';
@@ -0,0 +1,594 @@
1
+ /**
2
+ * Photon Loader Lite — Direct TypeScript API
3
+ *
4
+ * Load a .photon.ts file and get a fully-enhanced instance with all
5
+ * runtime features: middleware, memory, scheduling, events, __meta.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * import { photon } from '@portel/photon-core';
10
+ *
11
+ * const todo = await photon('./todo.photon.ts');
12
+ * await todo.add({ title: 'Buy milk' });
13
+ * // ✅ @cached, @throttled, @retry all work
14
+ * // ✅ this.memory, this.schedule work
15
+ * // ✅ @stateful events emitted, __meta attached
16
+ * // ✅ @photon dependencies recursively loaded
17
+ * ```
18
+ */
19
+
20
+ import * as fs from 'fs/promises';
21
+ import * as path from 'path';
22
+ import { pathToFileURL } from 'url';
23
+ import { compilePhotonTS } from './compiler.js';
24
+ import { findPhotonClass } from './class-detection.js';
25
+ import { SchemaExtractor, detectCapabilities } from './schema-extractor.js';
26
+ import {
27
+ buildMiddlewareChain,
28
+ builtinRegistry,
29
+ MiddlewareRegistry,
30
+ createStateStore,
31
+ type MiddlewareContext,
32
+ type MiddlewareState,
33
+ type MiddlewareDeclaration,
34
+ } from './middleware.js';
35
+ import { withPhotonCapabilities } from './mixins.js';
36
+ import { MemoryProvider } from './memory.js';
37
+ import { ScheduleProvider } from './schedule.js';
38
+ import { toEnvVarName, parseEnvValue, type MissingParamInfo } from './env-utils.js';
39
+ import type { ExtractedSchema } from './types.js';
40
+ import type { MCPClientFactory } from '@portel/mcp';
41
+
42
+ // ═══════════════════════════════════════════════════════════════════
43
+ // Types
44
+ // ═══════════════════════════════════════════════════════════════════
45
+
46
+ export interface PhotonOptions {
47
+ /** Override the base directory for memory/schedule storage (default: ~/.photon) */
48
+ baseDir?: string;
49
+ /** MCP client factory for this.mcp() support */
50
+ mcpFactory?: MCPClientFactory;
51
+ /** Named instance identifier */
52
+ instanceName?: string;
53
+ /** Receive emitted events from @stateful methods */
54
+ onEvent?: (event: PhotonEvent) => void;
55
+ /** Session ID for session-scoped memory */
56
+ sessionId?: string;
57
+ }
58
+
59
+ export interface PhotonEvent {
60
+ method: string;
61
+ params: Record<string, any>;
62
+ result: any;
63
+ timestamp: string;
64
+ instance?: string;
65
+ }
66
+
67
+ // ═══════════════════════════════════════════════════════════════════
68
+ // Loading state (cycle detection, caching)
69
+ // ═══════════════════════════════════════════════════════════════════
70
+
71
+ /** Currently-loading photon paths for cycle detection */
72
+ const loadingPaths = new Set<string>();
73
+
74
+ /** Cache of loaded photon instances (keyed by absolutePath::instanceName) */
75
+ const instanceCache = new Map<string, any>();
76
+
77
+ /** Dedup concurrent loads */
78
+ const loadPromises = new Map<string, Promise<any>>();
79
+
80
+ // ═══════════════════════════════════════════════════════════════════
81
+ // Main API
82
+ // ═══════════════════════════════════════════════════════════════════
83
+
84
+ /**
85
+ * Load a .photon.ts file and return a fully-enhanced instance.
86
+ *
87
+ * The returned object has all methods working with middleware (@cached, @retry, etc.),
88
+ * memory, scheduling, @stateful event emission, and cross-photon calls.
89
+ *
90
+ * @param filePath Path to the .photon.ts file (absolute or relative to cwd)
91
+ * @param options Optional configuration
92
+ * @returns Enhanced photon instance with all runtime features
93
+ */
94
+ export async function photon<T = any>(
95
+ filePath: string,
96
+ options: PhotonOptions = {},
97
+ ): Promise<T> {
98
+ // Resolve to absolute path
99
+ const absolutePath = path.isAbsolute(filePath)
100
+ ? filePath
101
+ : path.resolve(process.cwd(), filePath);
102
+
103
+ const instanceName = options.instanceName || '';
104
+ const cacheKey = instanceName ? `${absolutePath}::${instanceName}` : absolutePath;
105
+
106
+ // Cycle detection (must come before dedup check to avoid deadlock)
107
+ if (loadingPaths.has(absolutePath)) {
108
+ const chain = Array.from(loadingPaths).concat(absolutePath).join(' → ');
109
+ throw new Error(`Circular @photon dependency: ${chain}`);
110
+ }
111
+
112
+ // Return cached instance
113
+ if (instanceCache.has(cacheKey)) {
114
+ return instanceCache.get(cacheKey) as T;
115
+ }
116
+
117
+ // Dedup concurrent loads
118
+ if (loadPromises.has(cacheKey)) {
119
+ return loadPromises.get(cacheKey) as Promise<T>;
120
+ }
121
+
122
+ const promise = loadPhotonInternal(absolutePath, cacheKey, options);
123
+ loadPromises.set(cacheKey, promise);
124
+
125
+ try {
126
+ const result = await promise;
127
+ instanceCache.set(cacheKey, result);
128
+ return result as T;
129
+ } finally {
130
+ loadPromises.delete(cacheKey);
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Clear the photon instance cache. Useful for testing.
136
+ */
137
+ export function clearPhotonCache(): void {
138
+ instanceCache.clear();
139
+ }
140
+
141
+ // ═══════════════════════════════════════════════════════════════════
142
+ // Internal pipeline
143
+ // ═══════════════════════════════════════════════════════════════════
144
+
145
+ async function loadPhotonInternal(
146
+ absolutePath: string,
147
+ cacheKey: string,
148
+ options: PhotonOptions,
149
+ ): Promise<any> {
150
+ loadingPaths.add(absolutePath);
151
+
152
+ try {
153
+ // 1. Read source
154
+ const source = await fs.readFile(absolutePath, 'utf-8');
155
+
156
+ // 2. Compile TypeScript → JavaScript
157
+ const homeDir = process.env.HOME || process.env.USERPROFILE || '';
158
+ const cacheDir = path.join(homeDir, '.photon', 'cache');
159
+ const compiledPath = await compilePhotonTS(absolutePath, { cacheDir });
160
+
161
+ // 3. Import compiled module
162
+ const moduleUrl = pathToFileURL(compiledPath).href;
163
+ const module = await import(moduleUrl);
164
+
165
+ // 4. Find the Photon class
166
+ const PhotonClass = findPhotonClass(module as Record<string, unknown>);
167
+ if (!PhotonClass) {
168
+ throw new Error(`No Photon class found in ${absolutePath}`);
169
+ }
170
+
171
+ // 5. Derive photon name from file path
172
+ const photonName = derivePhotonName(absolutePath);
173
+
174
+ // 6. Extract schema for middleware and metadata
175
+ const extractor = new SchemaExtractor();
176
+ const metadata = extractor.extractAllFromSource(source);
177
+ const toolSchemas = metadata.tools;
178
+
179
+ // 7. Resolve constructor injections
180
+ const injections = extractor.resolveInjections(source, photonName);
181
+ const constructorArgs = await resolveConstructorArgs(
182
+ injections,
183
+ photonName,
184
+ absolutePath,
185
+ options,
186
+ );
187
+
188
+ // 8. Enhance class with capabilities (for plain classes)
189
+ const EnhancedClass = withPhotonCapabilities(PhotonClass);
190
+
191
+ // 9. Instantiate
192
+ const instance = new EnhancedClass(...constructorArgs) as Record<string, any>;
193
+
194
+ // 10. Set photon identity
195
+ instance._photonName = photonName;
196
+ if (options.instanceName) {
197
+ instance.instanceName = options.instanceName;
198
+ }
199
+ if (options.sessionId) {
200
+ instance._sessionId = options.sessionId;
201
+ }
202
+
203
+ // 11. Wire reactive collections
204
+ wireReactiveCollections(instance);
205
+
206
+ // 12. Wrap @stateful methods (event emission + __meta)
207
+ wrapStatefulMethods(instance, source, options.onEvent);
208
+
209
+ // 13. Inject MCP factory if provided
210
+ if (options.mcpFactory && typeof instance.setMCPFactory === 'function') {
211
+ instance.setMCPFactory(options.mcpFactory);
212
+ }
213
+
214
+ // 14. Inject cross-photon call handler (in-process resolution)
215
+ instance._callHandler = async (
216
+ targetPhotonName: string,
217
+ method: string,
218
+ params: Record<string, any>,
219
+ ) => {
220
+ const targetPath = resolvePhotonPath(targetPhotonName, absolutePath);
221
+ const target = await photon(targetPath, {
222
+ baseDir: options.baseDir,
223
+ mcpFactory: options.mcpFactory,
224
+ sessionId: options.sessionId,
225
+ });
226
+ return (target as any)[method](params);
227
+ };
228
+
229
+ // 15. Call onInitialize lifecycle hook
230
+ if (typeof instance.onInitialize === 'function') {
231
+ await instance.onInitialize();
232
+ }
233
+
234
+ // 16. Build middleware proxy
235
+ const proxy = buildMiddlewareProxy(instance, photonName, toolSchemas, options);
236
+
237
+ return proxy;
238
+ } finally {
239
+ loadingPaths.delete(absolutePath);
240
+ }
241
+ }
242
+
243
+ // ═══════════════════════════════════════════════════════════════════
244
+ // Constructor injection
245
+ // ═══════════════════════════════════════════════════════════════════
246
+
247
+ async function resolveConstructorArgs(
248
+ injections: Array<{
249
+ param: { name: string; type: string; isOptional: boolean; hasDefault: boolean; defaultValue?: any };
250
+ injectionType: string;
251
+ envVarName?: string;
252
+ photonDependency?: { name: string; source: string; sourceType: string; instanceName?: string };
253
+ mcpDependency?: { name: string; source: string; sourceType: string };
254
+ }>,
255
+ photonName: string,
256
+ currentPath: string,
257
+ options: PhotonOptions,
258
+ ): Promise<any[]> {
259
+ const values: any[] = [];
260
+ const missing: MissingParamInfo[] = [];
261
+
262
+ for (const injection of injections) {
263
+ const { param, injectionType } = injection;
264
+
265
+ switch (injectionType) {
266
+ case 'photon': {
267
+ // Recursive photon loading
268
+ const dep = injection.photonDependency!;
269
+ const depPath = resolvePhotonDepPath(dep.source, dep.sourceType, currentPath);
270
+ const depInstance = await photon(depPath, {
271
+ baseDir: options.baseDir,
272
+ mcpFactory: options.mcpFactory,
273
+ instanceName: dep.instanceName,
274
+ sessionId: options.sessionId,
275
+ });
276
+ values.push(depInstance);
277
+ break;
278
+ }
279
+
280
+ case 'mcp': {
281
+ // MCP dependencies require a factory
282
+ if (!options.mcpFactory) {
283
+ throw new Error(
284
+ `Photon "${photonName}" requires MCP dependency "${param.name}" but no mcpFactory was provided. ` +
285
+ `Pass { mcpFactory } in the options to photon().`,
286
+ );
287
+ }
288
+ // Will be resolved by the instance's mcp() method at call time
289
+ values.push(undefined);
290
+ break;
291
+ }
292
+
293
+ case 'env': {
294
+ const envVarName = injection.envVarName || toEnvVarName(photonName, param.name);
295
+ const envValue = process.env[envVarName];
296
+
297
+ if (envValue !== undefined) {
298
+ values.push(parseEnvValue(envValue, param.type));
299
+ } else if (param.hasDefault || param.isOptional) {
300
+ values.push(undefined);
301
+ } else {
302
+ missing.push({ paramName: param.name, envVarName, type: param.type });
303
+ values.push(undefined);
304
+ }
305
+ break;
306
+ }
307
+
308
+ case 'state': {
309
+ // State injection — use default value (state is loaded by the class itself)
310
+ values.push(undefined);
311
+ break;
312
+ }
313
+
314
+ default:
315
+ values.push(undefined);
316
+ }
317
+ }
318
+
319
+ if (missing.length > 0) {
320
+ const envList = missing.map(m => ` ${m.envVarName} (${m.paramName}: ${m.type})`).join('\n');
321
+ console.warn(
322
+ `⚠️ ${photonName}: Missing environment variables:\n${envList}\n` +
323
+ `Some methods may fail until these are set.`,
324
+ );
325
+ }
326
+
327
+ return values;
328
+ }
329
+
330
+ // ═══════════════════════════════════════════════════════════════════
331
+ // Reactive collection wiring
332
+ // ═══════════════════════════════════════════════════════════════════
333
+
334
+ function wireReactiveCollections(instance: Record<string, any>): void {
335
+ const emit = typeof instance.emit === 'function'
336
+ ? instance.emit.bind(instance)
337
+ : null;
338
+
339
+ if (!emit) return;
340
+
341
+ for (const key of Object.keys(instance)) {
342
+ const value = instance[key];
343
+ if (!value || typeof value !== 'object') continue;
344
+
345
+ const ctorName = value.constructor?.name;
346
+ if (
347
+ ctorName === 'ReactiveArray' ||
348
+ ctorName === 'ReactiveMap' ||
349
+ ctorName === 'ReactiveSet' ||
350
+ ctorName === 'Collection'
351
+ ) {
352
+ value._propertyName = key;
353
+ value._emitter = emit;
354
+ }
355
+ }
356
+ }
357
+
358
+ // ═══════════════════════════════════════════════════════════════════
359
+ // @stateful method wrapping
360
+ // ═══════════════════════════════════════════════════════════════════
361
+
362
+ function wrapStatefulMethods(
363
+ instance: Record<string, any>,
364
+ source: string,
365
+ onEvent?: (event: PhotonEvent) => void,
366
+ ): void {
367
+ if (!/@stateful\b/i.test(source)) return;
368
+
369
+ // Skip framework-injected methods from withPhotonCapabilities
370
+ const frameworkMethods = new Set([
371
+ 'emit', 'call', 'mcp', 'setMCPFactory', 'onInitialize', 'onShutdown',
372
+ ]);
373
+
374
+ // Walk the prototype chain to find all public methods
375
+ // (withPhotonCapabilities creates a subclass, so methods may be on grandparent prototype)
376
+ const methodNames: string[] = [];
377
+ const seen = new Set<string>();
378
+ let proto = Object.getPrototypeOf(instance);
379
+ while (proto && proto !== Object.prototype) {
380
+ for (const name of Object.getOwnPropertyNames(proto)) {
381
+ if (seen.has(name) || name === 'constructor' || name.startsWith('_')) continue;
382
+ if (frameworkMethods.has(name)) continue;
383
+ seen.add(name);
384
+ const descriptor = Object.getOwnPropertyDescriptor(proto, name);
385
+ if (descriptor && typeof descriptor.value === 'function') {
386
+ methodNames.push(name);
387
+ }
388
+ }
389
+ proto = Object.getPrototypeOf(proto);
390
+ }
391
+
392
+ if (methodNames.length === 0) return;
393
+
394
+ for (const methodName of methodNames) {
395
+ const original = instance[methodName];
396
+ if (typeof original !== 'function') continue;
397
+
398
+ instance[methodName] = function (this: any, ...args: any[]) {
399
+ const paramNames = extractParamNames(original);
400
+ const params = Object.fromEntries(paramNames.map((name, i) => [name, args[i]]));
401
+
402
+ const result = original.apply(this, args);
403
+
404
+ // Handle both sync and async results
405
+ const attachMeta = (res: any) => {
406
+ if (res && typeof res === 'object' && !Array.isArray(res) && !res.__meta) {
407
+ const timestamp = new Date().toISOString();
408
+ Object.defineProperty(res, '__meta', {
409
+ value: {
410
+ createdAt: timestamp,
411
+ createdBy: methodName,
412
+ modifiedAt: null,
413
+ modifiedBy: null,
414
+ modifications: [],
415
+ },
416
+ enumerable: false,
417
+ writable: true,
418
+ configurable: true,
419
+ });
420
+ }
421
+
422
+ // Emit event
423
+ if (onEvent) {
424
+ const event: PhotonEvent = {
425
+ method: methodName,
426
+ params,
427
+ result: res,
428
+ timestamp: new Date().toISOString(),
429
+ };
430
+ if (this.instanceName) {
431
+ event.instance = this.instanceName;
432
+ }
433
+ onEvent(event);
434
+ }
435
+
436
+ return res;
437
+ };
438
+
439
+ // Support async methods (most common case)
440
+ if (result && typeof result.then === 'function') {
441
+ return result.then(attachMeta);
442
+ }
443
+
444
+ return attachMeta(result);
445
+ };
446
+ }
447
+ }
448
+
449
+ /**
450
+ * Extract parameter names from a function signature string
451
+ */
452
+ function extractParamNames(fn: (...args: any[]) => any): string[] {
453
+ const fnStr = fn.toString();
454
+ const match = fnStr.match(/\(([^)]*)\)/);
455
+ if (!match?.[1]) return [];
456
+
457
+ return match[1]
458
+ .split(',')
459
+ .map(param => {
460
+ const cleaned = param
461
+ .trim()
462
+ .split('=')[0] // Remove default value
463
+ .split(':')[0] // Remove type annotations
464
+ .trim();
465
+ return cleaned;
466
+ })
467
+ .filter(name => name && name !== 'this');
468
+ }
469
+
470
+ // ═══════════════════════════════════════════════════════════════════
471
+ // Middleware proxy
472
+ // ═══════════════════════════════════════════════════════════════════
473
+
474
+ function buildMiddlewareProxy(
475
+ instance: Record<string, any>,
476
+ photonName: string,
477
+ toolSchemas: ExtractedSchema[],
478
+ options: PhotonOptions,
479
+ ): any {
480
+ // Build tool lookup: method name → schema
481
+ const toolMap = new Map<string, ExtractedSchema>();
482
+ for (const schema of toolSchemas) {
483
+ toolMap.set(schema.name, schema);
484
+ }
485
+
486
+ // Middleware state stores (shared across calls)
487
+ const stateStores = new Map<string, MiddlewareState>();
488
+
489
+ // Build combined registry (builtins only for now; custom middleware can be added later)
490
+ const registry = new MiddlewareRegistry();
491
+ for (const name of builtinRegistry.names()) {
492
+ registry.register(builtinRegistry.get(name)!);
493
+ }
494
+
495
+ return new Proxy(instance, {
496
+ get(target, prop, receiver) {
497
+ const value = Reflect.get(target, prop, receiver);
498
+ if (typeof value !== 'function') return value;
499
+ if (typeof prop !== 'string') return value;
500
+
501
+ // Skip internal/private methods
502
+ if (prop.startsWith('_') || prop === 'constructor') {
503
+ return value.bind(target);
504
+ }
505
+
506
+ const schema = toolMap.get(prop);
507
+ const declarations: MiddlewareDeclaration[] = schema?.middleware || [];
508
+
509
+ // No middleware — return bound method directly
510
+ if (declarations.length === 0) {
511
+ return value.bind(target);
512
+ }
513
+
514
+ // Return a function that applies middleware on each call
515
+ return (...args: any[]) => {
516
+ const ctx: MiddlewareContext = {
517
+ photon: photonName,
518
+ tool: prop,
519
+ instance: options.instanceName || 'default',
520
+ params: args[0] ?? {},
521
+ };
522
+
523
+ const execute = () => value.apply(target, args);
524
+ const chain = buildMiddlewareChain(
525
+ execute,
526
+ declarations,
527
+ registry,
528
+ stateStores,
529
+ ctx,
530
+ );
531
+
532
+ return chain();
533
+ };
534
+ },
535
+ });
536
+ }
537
+
538
+ // ═══════════════════════════════════════════════════════════════════
539
+ // Path utilities
540
+ // ═══════════════════════════════════════════════════════════════════
541
+
542
+ /**
543
+ * Derive a photon name from a file path.
544
+ * e.g., '/path/to/todo.photon.ts' → 'todo'
545
+ */
546
+ function derivePhotonName(filePath: string): string {
547
+ const basename = path.basename(filePath);
548
+ return basename
549
+ .replace(/\.photon\.(ts|js|mjs)$/, '')
550
+ .replace(/\.(ts|js|mjs)$/, '');
551
+ }
552
+
553
+ /**
554
+ * Resolve a photon dependency source to an absolute path.
555
+ */
556
+ function resolvePhotonDepPath(
557
+ source: string,
558
+ sourceType: string,
559
+ currentPhotonPath: string,
560
+ ): string {
561
+ if (sourceType === 'local') {
562
+ if (source.startsWith('./') || source.startsWith('../')) {
563
+ return path.resolve(path.dirname(currentPhotonPath), source);
564
+ }
565
+ return source;
566
+ }
567
+
568
+ // For marketplace photons, look in ~/.photon/photons/<name>/
569
+ if (sourceType === 'marketplace') {
570
+ const homeDir = process.env.HOME || process.env.USERPROFILE || '';
571
+ return path.join(homeDir, '.photon', 'photons', source, `${source}.photon.ts`);
572
+ }
573
+
574
+ // npm and github sources — for now, throw a helpful error
575
+ throw new Error(
576
+ `Cannot resolve ${sourceType} photon dependency "${source}" in lite loader. ` +
577
+ `Only local paths and marketplace photons are supported. ` +
578
+ `Use the full runtime for npm/github dependencies.`,
579
+ );
580
+ }
581
+
582
+ /**
583
+ * Resolve a photon name to a path for cross-photon calls.
584
+ * Searches: sibling files, then ~/.photon/photons/
585
+ */
586
+ function resolvePhotonPath(photonName: string, callerPath: string): string {
587
+ // Try sibling file first
588
+ const dir = path.dirname(callerPath);
589
+ const siblingPath = path.join(dir, `${photonName}.photon.ts`);
590
+
591
+ // We can't do sync fs.existsSync in an async context cleanly,
592
+ // so just return the sibling path — the load will fail with a clear error if not found
593
+ return siblingPath;
594
+ }
package/src/types.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  export type OutputFormat =
13
13
  | 'primitive' | 'table' | 'tree' | 'list' | 'none'
14
14
  | 'json' | 'markdown' | 'yaml' | 'xml' | 'html' | 'mermaid'
15
- | 'card' | 'grid' | 'chips' | 'kv'
15
+ | 'card' | 'grid' | 'chips' | 'kv' | 'qr'
16
16
  | 'chart' | `chart:${string}` | 'metric' | 'gauge' | 'timeline' | 'dashboard' | 'cart'
17
17
  | 'panels' | 'tabs' | 'accordion' | 'stack' | 'columns'
18
18
  | `code` | `code:${string}`;