@portel/photon-core 2.22.0 → 2.23.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.
Files changed (45) hide show
  1. package/dist/base.d.ts +19 -0
  2. package/dist/base.d.ts.map +1 -1
  3. package/dist/base.js +29 -0
  4. package/dist/base.js.map +1 -1
  5. package/dist/bases-registry.d.ts +57 -0
  6. package/dist/bases-registry.d.ts.map +1 -0
  7. package/dist/bases-registry.js +127 -0
  8. package/dist/bases-registry.js.map +1 -0
  9. package/dist/data-paths.d.ts +31 -18
  10. package/dist/data-paths.d.ts.map +1 -1
  11. package/dist/data-paths.js +42 -43
  12. package/dist/data-paths.js.map +1 -1
  13. package/dist/index.d.ts +3 -2
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +6 -2
  16. package/dist/index.js.map +1 -1
  17. package/dist/memory.d.ts.map +1 -1
  18. package/dist/memory.js +81 -1
  19. package/dist/memory.js.map +1 -1
  20. package/dist/path-resolver.d.ts +13 -1
  21. package/dist/path-resolver.d.ts.map +1 -1
  22. package/dist/path-resolver.js +23 -1
  23. package/dist/path-resolver.js.map +1 -1
  24. package/dist/photon-loader-lite.d.ts +17 -2
  25. package/dist/photon-loader-lite.d.ts.map +1 -1
  26. package/dist/photon-loader-lite.js +162 -26
  27. package/dist/photon-loader-lite.js.map +1 -1
  28. package/dist/schema-extractor.d.ts +7 -2
  29. package/dist/schema-extractor.d.ts.map +1 -1
  30. package/dist/schema-extractor.js +21 -3
  31. package/dist/schema-extractor.js.map +1 -1
  32. package/dist/stateful.d.ts +3 -2
  33. package/dist/stateful.d.ts.map +1 -1
  34. package/dist/stateful.js +18 -6
  35. package/dist/stateful.js.map +1 -1
  36. package/package.json +1 -1
  37. package/src/base.ts +43 -0
  38. package/src/bases-registry.ts +141 -0
  39. package/src/data-paths.ts +43 -49
  40. package/src/index.ts +14 -1
  41. package/src/memory.ts +81 -0
  42. package/src/path-resolver.ts +26 -1
  43. package/src/photon-loader-lite.ts +176 -33
  44. package/src/schema-extractor.ts +24 -3
  45. package/src/stateful.ts +19 -6
@@ -18,7 +18,9 @@
18
18
  */
19
19
 
20
20
  import * as fs from 'fs/promises';
21
+ import * as fsSync from 'fs';
21
22
  import * as path from 'path';
23
+ import * as os from 'os';
22
24
  import { pathToFileURL } from 'url';
23
25
  import { compilePhotonTS } from './compiler.js';
24
26
  import { findPhotonClass } from './class-detection.js';
@@ -135,10 +137,79 @@ export async function photon<T = any>(
135
137
  }
136
138
 
137
139
  /**
138
- * Clear the photon instance cache. Useful for testing.
140
+ * Clear the photon instance cache. Fires onShutdown on each cached
141
+ * instance first so resources held across loads get a chance to drain.
139
142
  */
140
- export function clearPhotonCache(): void {
143
+ export async function clearPhotonCache(): Promise<void> {
144
+ await disposeAllPhotons('clear-cache');
145
+ }
146
+
147
+ /**
148
+ * Dispose one cached photon instance (by absolute path + optional instance
149
+ * name). Invokes onShutdown with the given reason before evicting the
150
+ * cache entry. Errors from onShutdown are logged and swallowed.
151
+ */
152
+ export async function disposePhoton(
153
+ filePath: string,
154
+ opts: { instanceName?: string; reason?: string } = {},
155
+ ): Promise<boolean> {
156
+ const absolutePath = path.isAbsolute(filePath)
157
+ ? filePath
158
+ : path.resolve(process.cwd(), filePath);
159
+ const cacheKey = opts.instanceName ? `${absolutePath}::${opts.instanceName}` : absolutePath;
160
+ const proxy = instanceCache.get(cacheKey);
161
+ if (!proxy) return false;
162
+ await invokeShutdownQuietly(proxy, opts.reason || 'dispose');
163
+ instanceCache.delete(cacheKey);
164
+ return true;
165
+ }
166
+
167
+ /**
168
+ * Dispose every cached photon instance and clear the cache. Fires
169
+ * onShutdown on each with the given reason.
170
+ */
171
+ export async function disposeAllPhotons(reason = 'dispose'): Promise<void> {
172
+ const proxies = Array.from(instanceCache.values());
141
173
  instanceCache.clear();
174
+ await Promise.all(proxies.map((p) => invokeShutdownQuietly(p, reason)));
175
+ }
176
+
177
+ /** Fire onShutdown with a bounded timeout; never propagates errors. */
178
+ async function invokeShutdownQuietly(proxy: any, reason: string): Promise<void> {
179
+ try {
180
+ const hook = proxy?.onShutdown;
181
+ if (typeof hook !== 'function') return;
182
+ const TIMEOUT_MS = 10_000;
183
+ let timer: ReturnType<typeof setTimeout> | undefined;
184
+ try {
185
+ await Promise.race([
186
+ Promise.resolve(hook.call(proxy, { reason })),
187
+ new Promise<never>((_, reject) => {
188
+ timer = setTimeout(
189
+ () => reject(new Error(`onShutdown hook exceeded ${TIMEOUT_MS}ms`)),
190
+ TIMEOUT_MS,
191
+ );
192
+ }),
193
+ ]);
194
+ } finally {
195
+ if (timer) clearTimeout(timer);
196
+ }
197
+ } catch (err: any) {
198
+ // eslint-disable-next-line no-console
199
+ console.error(`[photon] onShutdown failed during ${reason}: ${err?.message ?? err}`);
200
+ }
201
+ }
202
+
203
+ /** Register a single beforeExit handler that drains cached instances. */
204
+ let exitHookRegistered = false;
205
+ function registerExitHook(): void {
206
+ if (exitHookRegistered) return;
207
+ exitHookRegistered = true;
208
+ process.on('beforeExit', () => {
209
+ // Fire and forget — beforeExit allows async work, but we don't
210
+ // block process exit if a photon's onShutdown misbehaves.
211
+ void disposeAllPhotons('process-exit');
212
+ });
142
213
  }
143
214
 
144
215
  // ═══════════════════════════════════════════════════════════════════
@@ -192,9 +263,12 @@ async function loadPhotonInternal(
192
263
  // 9. Instantiate
193
264
  const instance = new EnhancedClass(...constructorArgs) as Record<string, any>;
194
265
 
195
- // 10. Set photon identity
266
+ // 10. Set photon identity. Namespace mirrors the file's position under
267
+ // baseDir (same rule the classic loader applies). Falls back to 'local'
268
+ // when no baseDir context is available. See
269
+ // docs/internals/PHOTON-DIR-AND-NAMESPACE.md §3.
196
270
  instance._photonName = photonName;
197
- instance._photonNamespace = options.namespace || 'local';
271
+ instance._photonNamespace = options.namespace ?? deriveNamespace(absolutePath, options.baseDir);
198
272
  if (options.instanceName) {
199
273
  instance.instanceName = options.instanceName;
200
274
  }
@@ -219,7 +293,7 @@ async function loadPhotonInternal(
219
293
  method: string,
220
294
  params: Record<string, any>,
221
295
  ) => {
222
- const targetPath = resolvePhotonPath(targetPhotonName, absolutePath);
296
+ const targetPath = resolvePhotonPath(targetPhotonName, absolutePath, options.baseDir);
223
297
  const target = await photon(targetPath, {
224
298
  baseDir: options.baseDir,
225
299
  mcpFactory: options.mcpFactory,
@@ -233,15 +307,35 @@ async function loadPhotonInternal(
233
307
  await instance.onInitialize();
234
308
  }
235
309
 
236
- // 16. Build middleware proxy
310
+ // 16. Build middleware proxy (wraps dispatch with onError observability)
237
311
  const proxy = buildMiddlewareProxy(instance, photonName, toolSchemas, options);
238
312
 
313
+ // 17. Register for shutdown on process exit so onShutdown fires for
314
+ // lite-loaded photons the caller never explicitly disposed.
315
+ registerExitHook();
316
+
239
317
  return proxy;
240
318
  } finally {
241
319
  loadingPaths.delete(absolutePath);
242
320
  }
243
321
  }
244
322
 
323
+ // ═══════════════════════════════════════════════════════════════════
324
+ // Namespace derivation (mirrors classic loader's resolveNamespace)
325
+ // ═══════════════════════════════════════════════════════════════════
326
+
327
+ function deriveNamespace(absolutePath: string, baseDir?: string): string {
328
+ if (!baseDir) return 'local';
329
+ const resolvedBase = path.resolve(baseDir);
330
+ const rel = path.relative(resolvedBase, absolutePath);
331
+ // File outside baseDir — fall back to 'local'.
332
+ if (rel.startsWith('..')) return 'local';
333
+ const parts = rel.split(path.sep);
334
+ // Flat file at baseDir root — use 'local' (equivalent to '' in getPhotonDataDir).
335
+ if (parts.length < 2) return 'local';
336
+ return parts.slice(0, -1).join(path.sep);
337
+ }
338
+
245
339
  // ═══════════════════════════════════════════════════════════════════
246
340
  // Constructor injection
247
341
  // ═══════════════════════════════════════════════════════════════════
@@ -268,7 +362,12 @@ async function resolveConstructorArgs(
268
362
  case 'photon': {
269
363
  // Recursive photon loading
270
364
  const dep = injection.photonDependency!;
271
- const depPath = resolvePhotonDepPath(dep.source, dep.sourceType, currentPath);
365
+ const depPath = resolvePhotonDepPath(
366
+ dep.source,
367
+ dep.sourceType,
368
+ currentPath,
369
+ options.baseDir,
370
+ );
272
371
  const depInstance = await photon(depPath, {
273
372
  baseDir: options.baseDir,
274
373
  mcpFactory: options.mcpFactory,
@@ -507,14 +606,18 @@ function buildMiddlewareProxy(
507
606
 
508
607
  const schema = toolMap.get(prop);
509
608
  const declarations: MiddlewareDeclaration[] = schema?.middleware || [];
609
+ const hasErrorHook = typeof instance.onError === 'function';
510
610
 
511
- // No middleware — return bound method directly
512
- if (declarations.length === 0) {
611
+ // No middleware AND no onError preserve the bound-method fast path
612
+ // (keeps sync methods sync for callers that don't need the hook).
613
+ if (declarations.length === 0 && !hasErrorHook) {
513
614
  return value.bind(target);
514
615
  }
515
616
 
516
- // Return a function that applies middleware on each call
517
- return (...args: any[]) => {
617
+ // Return a function that runs through the middleware chain (if any)
618
+ // and routes any thrown error through the onError observability hook
619
+ // before re-throwing. Hook cannot suppress or transform the error.
620
+ return async (...args: any[]) => {
518
621
  const ctx: MiddlewareContext = {
519
622
  photon: photonName,
520
623
  tool: prop,
@@ -523,20 +626,52 @@ function buildMiddlewareProxy(
523
626
  };
524
627
 
525
628
  const execute = () => value.apply(target, args);
526
- const chain = buildMiddlewareChain(
527
- execute,
528
- declarations,
529
- registry,
530
- stateStores,
531
- ctx,
532
- );
533
-
534
- return chain();
629
+ const dispatch =
630
+ declarations.length === 0
631
+ ? execute
632
+ : buildMiddlewareChain(execute, declarations, registry, stateStores, ctx);
633
+
634
+ try {
635
+ return await dispatch();
636
+ } catch (err) {
637
+ await invokeErrorHookLite(instance, err, { tool: prop, params: args[0] ?? {} });
638
+ throw err;
639
+ }
535
640
  };
536
641
  },
537
642
  });
538
643
  }
539
644
 
645
+ /** Fire onError with a bounded 5s timeout. Never throws, never suppresses. */
646
+ async function invokeErrorHookLite(
647
+ instance: Record<string, any>,
648
+ error: unknown,
649
+ ctx: { tool: string; params: any },
650
+ ): Promise<void> {
651
+ const hook = instance.onError;
652
+ if (typeof hook !== 'function') return;
653
+ const TIMEOUT_MS = 5_000;
654
+ let timer: ReturnType<typeof setTimeout> | undefined;
655
+ try {
656
+ await Promise.race([
657
+ Promise.resolve(hook.call(instance, error, ctx)),
658
+ new Promise<never>((_, reject) => {
659
+ timer = setTimeout(
660
+ () => reject(new Error(`onError hook exceeded ${TIMEOUT_MS}ms`)),
661
+ TIMEOUT_MS,
662
+ );
663
+ }),
664
+ ]);
665
+ } catch (hookErr: any) {
666
+ // eslint-disable-next-line no-console
667
+ console.error(
668
+ `onError hook failed for ${ctx.tool}: ${hookErr?.message ?? String(hookErr)}`,
669
+ );
670
+ } finally {
671
+ if (timer) clearTimeout(timer);
672
+ }
673
+ }
674
+
540
675
  // ═══════════════════════════════════════════════════════════════════
541
676
  // Path utilities
542
677
  // ═══════════════════════════════════════════════════════════════════
@@ -553,12 +688,15 @@ function derivePhotonName(filePath: string): string {
553
688
  }
554
689
 
555
690
  /**
556
- * Resolve a photon dependency source to an absolute path.
691
+ * Resolve a photon dependency source to an absolute path. Marketplace
692
+ * lookups respect the resolved PHOTON_DIR so lite-loaded photons find
693
+ * their dependencies under the same base the caller configured.
557
694
  */
558
695
  function resolvePhotonDepPath(
559
696
  source: string,
560
697
  sourceType: string,
561
698
  currentPhotonPath: string,
699
+ baseDir?: string,
562
700
  ): string {
563
701
  if (sourceType === 'local') {
564
702
  if (source.startsWith('./') || source.startsWith('../')) {
@@ -567,10 +705,14 @@ function resolvePhotonDepPath(
567
705
  return source;
568
706
  }
569
707
 
570
- // For marketplace photons, look in ~/.photon/photons/<name>/
708
+ // Marketplace photons live under the resolved PHOTON_DIR (not hardcoded
709
+ // to ~/.photon as before). Canonical layout is `{base}/{source}.photon.ts`
710
+ // for flat installs, `{base}/<ns>/{source}.photon.ts` for namespaced ones.
711
+ // Here we return the flat path; callers that need namespaced resolution
712
+ // should use the classic loader.
571
713
  if (sourceType === 'marketplace') {
572
- const homeDir = process.env.HOME || process.env.USERPROFILE || '';
573
- return path.join(homeDir, '.photon', 'photons', source, `${source}.photon.ts`);
714
+ const base = baseDir || process.env.PHOTON_DIR || path.join(os.homedir(), '.photon');
715
+ return path.join(base, `${source}.photon.ts`);
574
716
  }
575
717
 
576
718
  // npm and github sources — for now, throw a helpful error
@@ -583,14 +725,15 @@ function resolvePhotonDepPath(
583
725
 
584
726
  /**
585
727
  * Resolve a photon name to a path for cross-photon calls.
586
- * Searches: sibling files, then ~/.photon/photons/
728
+ * Prefers a sibling file next to the caller; falls back to the resolved
729
+ * PHOTON_DIR. Returns the most likely path; the caller reports an
730
+ * actionable error if it doesn't exist.
587
731
  */
588
- function resolvePhotonPath(photonName: string, callerPath: string): string {
589
- // Try sibling file first
590
- const dir = path.dirname(callerPath);
591
- const siblingPath = path.join(dir, `${photonName}.photon.ts`);
592
-
593
- // We can't do sync fs.existsSync in an async context cleanly,
594
- // so just return the sibling path — the load will fail with a clear error if not found
595
- return siblingPath;
732
+ function resolvePhotonPath(photonName: string, callerPath: string, baseDir?: string): string {
733
+ const siblingPath = path.join(path.dirname(callerPath), `${photonName}.photon.ts`);
734
+ if (fsSync.existsSync(siblingPath)) return siblingPath;
735
+ const base = baseDir || process.env.PHOTON_DIR || path.join(os.homedir(), '.photon');
736
+ const baseFlat = path.join(base, `${photonName}.photon.ts`);
737
+ if (fsSync.existsSync(baseFlat)) return baseFlat;
738
+ return siblingPath; // load will fail with a clear error if missing
596
739
  }
@@ -14,6 +14,21 @@ import { ExtractedSchema, ConstructorParam, TemplateInfo, StaticInfo, OutputForm
14
14
  import { parseDuration, parseRate } from './utils/duration.js';
15
15
  import { builtinRegistry, type MiddlewareDeclaration } from './middleware.js';
16
16
 
17
+ // Track which `handle*` method names have already emitted a deprecation
18
+ // warning this process so large photons don't spam the console.
19
+ const handlePrefixWarned = new Set<string>();
20
+
21
+ function warnHandlePrefixOnce(methodName: string): void {
22
+ if (handlePrefixWarned.has(methodName)) return;
23
+ handlePrefixWarned.add(methodName);
24
+ // eslint-disable-next-line no-console
25
+ console.error(
26
+ `[photon] deprecation: method "${methodName}" is being auto-registered as a ` +
27
+ `webhook via the legacy handle* prefix. Add an explicit "@webhook" JSDoc ` +
28
+ `tag; the prefix convention will be removed in the next minor release.`,
29
+ );
30
+ }
31
+
17
32
  export interface ExtractedMetadata {
18
33
  tools: ExtractedSchema[];
19
34
  templates: TemplateInfo[];
@@ -2048,10 +2063,15 @@ export class SchemaExtractor {
2048
2063
  // ═══════════════════════════════════════════════════════════════════════════════
2049
2064
 
2050
2065
  /**
2051
- * Extract webhook configuration from @webhook tag or handle* prefix
2066
+ * Extract webhook configuration from @webhook tag or handle* prefix.
2052
2067
  * - @webhook → use method name as path
2053
2068
  * - @webhook stripe → custom path "stripe"
2054
- * - handle* prefix → auto-detected as webhook
2069
+ * - handle* prefix → auto-detected as webhook (DEPRECATED)
2070
+ *
2071
+ * The handle* prefix is a legacy convention being removed. It still
2072
+ * works for one release but emits a one-time stderr warning per
2073
+ * method so users can migrate to the explicit @webhook tag before
2074
+ * the convention is dropped.
2055
2075
  */
2056
2076
  private extractWebhook(jsdocContent: string, methodName: string): boolean | string | undefined {
2057
2077
  // Check for @webhook tag with optional path
@@ -2063,8 +2083,9 @@ export class SchemaExtractor {
2063
2083
  return path || true;
2064
2084
  }
2065
2085
 
2066
- // Check for handle* prefix (convention)
2086
+ // Check for handle* prefix (legacy convention — deprecated).
2067
2087
  if (methodName.startsWith('handle')) {
2088
+ warnHandlePrefixOnce(methodName);
2068
2089
  return true;
2069
2090
  }
2070
2091
 
package/src/stateful.ts CHANGED
@@ -87,10 +87,21 @@ import {
87
87
  // ══════════════════════════════════════════════════════════════════════════════
88
88
 
89
89
  /**
90
- * Default runs directory (legacy: ~/.photon/runs)
91
- * @deprecated Use getPhotonRunsDir(namespace, photonName) from data-paths.ts
90
+ * Resolve the default runs directory at call time so a long-lived daemon
91
+ * that serves multiple PHOTON_DIRs picks up each base's runs. Previously
92
+ * a module-level const frozen at import time.
93
+ * @deprecated Use getPhotonRunsDir(namespace, photonName, baseDir) for per-photon runs.
92
94
  */
93
- export const RUNS_DIR = getLegacyRunsDir();
95
+ function defaultRunsDir(): string {
96
+ return getLegacyRunsDir();
97
+ }
98
+
99
+ /**
100
+ * Back-compat export. Resolved at import time — kept for consumers that
101
+ * read `RUNS_DIR` as a constant. New code should call getPhotonRunsDir().
102
+ * @deprecated Use getPhotonRunsDir(namespace, photonName, baseDir).
103
+ */
104
+ export const RUNS_DIR = defaultRunsDir();
94
105
 
95
106
  // ══════════════════════════════════════════════════════════════════════════════
96
107
  // CHECKPOINT YIELD TYPE
@@ -136,7 +147,9 @@ export class StateLog {
136
147
  private logPath: string;
137
148
 
138
149
  constructor(runId: string, runsDir?: string) {
139
- this.logPath = path.join(runsDir || RUNS_DIR, `${runId}.jsonl`);
150
+ // Resolve runs dir at call time so the current PHOTON_DIR is honored
151
+ // even when a long-lived process has served earlier bases.
152
+ this.logPath = path.join(runsDir || defaultRunsDir(), `${runId}.jsonl`);
140
153
  }
141
154
 
142
155
  /**
@@ -573,7 +586,7 @@ export async function executeStatefulGenerator<T>(
573
586
  * List all workflow runs
574
587
  */
575
588
  export async function listRuns(runsDir?: string): Promise<WorkflowRun[]> {
576
- const dir = runsDir || RUNS_DIR;
589
+ const dir = runsDir || defaultRunsDir();
577
590
  const runs: WorkflowRun[] = [];
578
591
 
579
592
  try {
@@ -639,7 +652,7 @@ export async function getRunInfo(runId: string, runsDir?: string): Promise<Workf
639
652
  * Delete a workflow run
640
653
  */
641
654
  export async function deleteRun(runId: string, runsDir?: string): Promise<void> {
642
- const logPath = path.join(runsDir || RUNS_DIR, `${runId}.jsonl`);
655
+ const logPath = path.join(runsDir || defaultRunsDir(), `${runId}.jsonl`);
643
656
  await fs.unlink(logPath);
644
657
  }
645
658