@portel/photon-core 2.22.0 → 2.24.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 (67) hide show
  1. package/dist/base.d.ts +56 -0
  2. package/dist/base.d.ts.map +1 -1
  3. package/dist/base.js +100 -2
  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/description-sanitizer.d.ts +34 -0
  14. package/dist/description-sanitizer.d.ts.map +1 -0
  15. package/dist/description-sanitizer.js +80 -0
  16. package/dist/description-sanitizer.js.map +1 -0
  17. package/dist/index.d.ts +4 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +7 -2
  20. package/dist/index.js.map +1 -1
  21. package/dist/memory.d.ts.map +1 -1
  22. package/dist/memory.js +109 -1
  23. package/dist/memory.js.map +1 -1
  24. package/dist/middleware.d.ts.map +1 -1
  25. package/dist/middleware.js +96 -0
  26. package/dist/middleware.js.map +1 -1
  27. package/dist/mixins.d.ts.map +1 -1
  28. package/dist/mixins.js +9 -2
  29. package/dist/mixins.js.map +1 -1
  30. package/dist/path-resolver.d.ts +13 -1
  31. package/dist/path-resolver.d.ts.map +1 -1
  32. package/dist/path-resolver.js +23 -1
  33. package/dist/path-resolver.js.map +1 -1
  34. package/dist/photon-loader-lite.d.ts +17 -2
  35. package/dist/photon-loader-lite.d.ts.map +1 -1
  36. package/dist/photon-loader-lite.js +203 -26
  37. package/dist/photon-loader-lite.js.map +1 -1
  38. package/dist/schedule.d.ts +10 -1
  39. package/dist/schedule.d.ts.map +1 -1
  40. package/dist/schedule.js +20 -10
  41. package/dist/schedule.js.map +1 -1
  42. package/dist/schema-extractor.d.ts +9 -3
  43. package/dist/schema-extractor.d.ts.map +1 -1
  44. package/dist/schema-extractor.js +149 -17
  45. package/dist/schema-extractor.js.map +1 -1
  46. package/dist/stateful.d.ts +3 -2
  47. package/dist/stateful.d.ts.map +1 -1
  48. package/dist/stateful.js +18 -6
  49. package/dist/stateful.js.map +1 -1
  50. package/dist/types.d.ts +9 -1
  51. package/dist/types.d.ts.map +1 -1
  52. package/dist/types.js.map +1 -1
  53. package/package.json +2 -2
  54. package/src/base.ts +123 -2
  55. package/src/bases-registry.ts +141 -0
  56. package/src/data-paths.ts +43 -49
  57. package/src/description-sanitizer.ts +102 -0
  58. package/src/index.ts +20 -1
  59. package/src/memory.ts +109 -0
  60. package/src/middleware.ts +98 -0
  61. package/src/mixins.ts +14 -2
  62. package/src/path-resolver.ts +26 -1
  63. package/src/photon-loader-lite.ts +214 -33
  64. package/src/schedule.ts +26 -10
  65. package/src/schema-extractor.ts +164 -17
  66. package/src/stateful.ts +19 -6
  67. package/src/types.ts +9 -0
@@ -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,50 @@ 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);
272
+ instance._baseDir = options.baseDir;
273
+ instance._photonFilePath = absolutePath;
274
+ // Stat-gate baseline. When executeTool() sees the source file has
275
+ // changed, it fires _photonReloader (registered just below) to swap
276
+ // in the fresh compile before dispatching.
277
+ try {
278
+ const s = fsSync.statSync(absolutePath);
279
+ instance._photonSourceStat = { mtimeMs: s.mtimeMs, size: s.size, ino: s.ino };
280
+ } catch {
281
+ // No stat — skip baselining so the gate is a no-op.
282
+ }
283
+ instance._photonReloader = async () => {
284
+ // Invalidate the cache entry, then re-run the whole load pipeline
285
+ // and copy public surface from the fresh instance onto the existing
286
+ // one. Callers already holding a reference to the proxy see new
287
+ // method behavior on the very next dispatch.
288
+ instanceCache.delete(cacheKey);
289
+ const fresh = (await photon(absolutePath, options)) as Record<string, any>;
290
+ // Refresh stat baseline on success so we don't re-trigger.
291
+ try {
292
+ const s = fsSync.statSync(absolutePath);
293
+ instance._photonSourceStat = { mtimeMs: s.mtimeMs, size: s.size, ino: s.ino };
294
+ } catch {
295
+ // ignore — next call will re-evaluate
296
+ }
297
+ // Rewire every own property from the fresh instance onto the
298
+ // live one. Prototype-level methods are re-looked-up through the
299
+ // instance's class on each dispatch via the proxy's method lookup,
300
+ // so they pick up the new code automatically. Own-property state
301
+ // (collections, explicit fields) gets refreshed here.
302
+ for (const key of Object.keys(fresh)) {
303
+ try {
304
+ (instance as Record<string, any>)[key] = fresh[key];
305
+ } catch {
306
+ // Read-only own property — skip; the proxy path will handle it.
307
+ }
308
+ }
309
+ };
198
310
  if (options.instanceName) {
199
311
  instance.instanceName = options.instanceName;
200
312
  }
@@ -219,7 +331,7 @@ async function loadPhotonInternal(
219
331
  method: string,
220
332
  params: Record<string, any>,
221
333
  ) => {
222
- const targetPath = resolvePhotonPath(targetPhotonName, absolutePath);
334
+ const targetPath = resolvePhotonPath(targetPhotonName, absolutePath, options.baseDir);
223
335
  const target = await photon(targetPath, {
224
336
  baseDir: options.baseDir,
225
337
  mcpFactory: options.mcpFactory,
@@ -233,15 +345,35 @@ async function loadPhotonInternal(
233
345
  await instance.onInitialize();
234
346
  }
235
347
 
236
- // 16. Build middleware proxy
348
+ // 16. Build middleware proxy (wraps dispatch with onError observability)
237
349
  const proxy = buildMiddlewareProxy(instance, photonName, toolSchemas, options);
238
350
 
351
+ // 17. Register for shutdown on process exit so onShutdown fires for
352
+ // lite-loaded photons the caller never explicitly disposed.
353
+ registerExitHook();
354
+
239
355
  return proxy;
240
356
  } finally {
241
357
  loadingPaths.delete(absolutePath);
242
358
  }
243
359
  }
244
360
 
361
+ // ═══════════════════════════════════════════════════════════════════
362
+ // Namespace derivation (mirrors classic loader's resolveNamespace)
363
+ // ═══════════════════════════════════════════════════════════════════
364
+
365
+ function deriveNamespace(absolutePath: string, baseDir?: string): string {
366
+ if (!baseDir) return 'local';
367
+ const resolvedBase = path.resolve(baseDir);
368
+ const rel = path.relative(resolvedBase, absolutePath);
369
+ // File outside baseDir — fall back to 'local'.
370
+ if (rel.startsWith('..')) return 'local';
371
+ const parts = rel.split(path.sep);
372
+ // Flat file at baseDir root — use 'local' (equivalent to '' in getPhotonDataDir).
373
+ if (parts.length < 2) return 'local';
374
+ return parts.slice(0, -1).join(path.sep);
375
+ }
376
+
245
377
  // ═══════════════════════════════════════════════════════════════════
246
378
  // Constructor injection
247
379
  // ═══════════════════════════════════════════════════════════════════
@@ -268,7 +400,12 @@ async function resolveConstructorArgs(
268
400
  case 'photon': {
269
401
  // Recursive photon loading
270
402
  const dep = injection.photonDependency!;
271
- const depPath = resolvePhotonDepPath(dep.source, dep.sourceType, currentPath);
403
+ const depPath = resolvePhotonDepPath(
404
+ dep.source,
405
+ dep.sourceType,
406
+ currentPath,
407
+ options.baseDir,
408
+ );
272
409
  const depInstance = await photon(depPath, {
273
410
  baseDir: options.baseDir,
274
411
  mcpFactory: options.mcpFactory,
@@ -507,14 +644,18 @@ function buildMiddlewareProxy(
507
644
 
508
645
  const schema = toolMap.get(prop);
509
646
  const declarations: MiddlewareDeclaration[] = schema?.middleware || [];
647
+ const hasErrorHook = typeof instance.onError === 'function';
510
648
 
511
- // No middleware — return bound method directly
512
- if (declarations.length === 0) {
649
+ // No middleware AND no onError preserve the bound-method fast path
650
+ // (keeps sync methods sync for callers that don't need the hook).
651
+ if (declarations.length === 0 && !hasErrorHook) {
513
652
  return value.bind(target);
514
653
  }
515
654
 
516
- // Return a function that applies middleware on each call
517
- return (...args: any[]) => {
655
+ // Return a function that runs through the middleware chain (if any)
656
+ // and routes any thrown error through the onError observability hook
657
+ // before re-throwing. Hook cannot suppress or transform the error.
658
+ return async (...args: any[]) => {
518
659
  const ctx: MiddlewareContext = {
519
660
  photon: photonName,
520
661
  tool: prop,
@@ -523,20 +664,52 @@ function buildMiddlewareProxy(
523
664
  };
524
665
 
525
666
  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();
667
+ const dispatch =
668
+ declarations.length === 0
669
+ ? execute
670
+ : buildMiddlewareChain(execute, declarations, registry, stateStores, ctx);
671
+
672
+ try {
673
+ return await dispatch();
674
+ } catch (err) {
675
+ await invokeErrorHookLite(instance, err, { tool: prop, params: args[0] ?? {} });
676
+ throw err;
677
+ }
535
678
  };
536
679
  },
537
680
  });
538
681
  }
539
682
 
683
+ /** Fire onError with a bounded 5s timeout. Never throws, never suppresses. */
684
+ async function invokeErrorHookLite(
685
+ instance: Record<string, any>,
686
+ error: unknown,
687
+ ctx: { tool: string; params: any },
688
+ ): Promise<void> {
689
+ const hook = instance.onError;
690
+ if (typeof hook !== 'function') return;
691
+ const TIMEOUT_MS = 5_000;
692
+ let timer: ReturnType<typeof setTimeout> | undefined;
693
+ try {
694
+ await Promise.race([
695
+ Promise.resolve(hook.call(instance, error, ctx)),
696
+ new Promise<never>((_, reject) => {
697
+ timer = setTimeout(
698
+ () => reject(new Error(`onError hook exceeded ${TIMEOUT_MS}ms`)),
699
+ TIMEOUT_MS,
700
+ );
701
+ }),
702
+ ]);
703
+ } catch (hookErr: any) {
704
+ // eslint-disable-next-line no-console
705
+ console.error(
706
+ `onError hook failed for ${ctx.tool}: ${hookErr?.message ?? String(hookErr)}`,
707
+ );
708
+ } finally {
709
+ if (timer) clearTimeout(timer);
710
+ }
711
+ }
712
+
540
713
  // ═══════════════════════════════════════════════════════════════════
541
714
  // Path utilities
542
715
  // ═══════════════════════════════════════════════════════════════════
@@ -553,12 +726,15 @@ function derivePhotonName(filePath: string): string {
553
726
  }
554
727
 
555
728
  /**
556
- * Resolve a photon dependency source to an absolute path.
729
+ * Resolve a photon dependency source to an absolute path. Marketplace
730
+ * lookups respect the resolved PHOTON_DIR so lite-loaded photons find
731
+ * their dependencies under the same base the caller configured.
557
732
  */
558
733
  function resolvePhotonDepPath(
559
734
  source: string,
560
735
  sourceType: string,
561
736
  currentPhotonPath: string,
737
+ baseDir?: string,
562
738
  ): string {
563
739
  if (sourceType === 'local') {
564
740
  if (source.startsWith('./') || source.startsWith('../')) {
@@ -567,10 +743,14 @@ function resolvePhotonDepPath(
567
743
  return source;
568
744
  }
569
745
 
570
- // For marketplace photons, look in ~/.photon/photons/<name>/
746
+ // Marketplace photons live under the resolved PHOTON_DIR (not hardcoded
747
+ // to ~/.photon as before). Canonical layout is `{base}/{source}.photon.ts`
748
+ // for flat installs, `{base}/<ns>/{source}.photon.ts` for namespaced ones.
749
+ // Here we return the flat path; callers that need namespaced resolution
750
+ // should use the classic loader.
571
751
  if (sourceType === 'marketplace') {
572
- const homeDir = process.env.HOME || process.env.USERPROFILE || '';
573
- return path.join(homeDir, '.photon', 'photons', source, `${source}.photon.ts`);
752
+ const base = baseDir || process.env.PHOTON_DIR || path.join(os.homedir(), '.photon');
753
+ return path.join(base, `${source}.photon.ts`);
574
754
  }
575
755
 
576
756
  // npm and github sources — for now, throw a helpful error
@@ -583,14 +763,15 @@ function resolvePhotonDepPath(
583
763
 
584
764
  /**
585
765
  * Resolve a photon name to a path for cross-photon calls.
586
- * Searches: sibling files, then ~/.photon/photons/
766
+ * Prefers a sibling file next to the caller; falls back to the resolved
767
+ * PHOTON_DIR. Returns the most likely path; the caller reports an
768
+ * actionable error if it doesn't exist.
587
769
  */
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;
770
+ function resolvePhotonPath(photonName: string, callerPath: string, baseDir?: string): string {
771
+ const siblingPath = path.join(path.dirname(callerPath), `${photonName}.photon.ts`);
772
+ if (fsSync.existsSync(siblingPath)) return siblingPath;
773
+ const base = baseDir || process.env.PHOTON_DIR || path.join(os.homedir(), '.photon');
774
+ const baseFlat = path.join(base, `${photonName}.photon.ts`);
775
+ if (fsSync.existsSync(baseFlat)) return baseFlat;
776
+ return siblingPath; // load will fail with a clear error if missing
596
777
  }
package/src/schedule.ts CHANGED
@@ -139,9 +139,9 @@ function resolveCron(schedule: string): string {
139
139
 
140
140
  // ── Storage Helpers ────────────────────────────────────────────────────
141
141
 
142
- function photonScheduleDir(photonId: string, namespace?: string): string {
142
+ function photonScheduleDir(photonId: string, namespace?: string, baseDir?: string): string {
143
143
  const ns = namespace || 'local';
144
- const newDir = getPhotonSchedulesDir(ns, photonId);
144
+ const newDir = getPhotonSchedulesDir(ns, photonId, baseDir);
145
145
  if (!fsSync.existsSync(newDir)) {
146
146
  const legacyDir = getLegacySchedulesDir(photonId);
147
147
  if (fsSync.existsSync(legacyDir)) return legacyDir;
@@ -149,8 +149,8 @@ function photonScheduleDir(photonId: string, namespace?: string): string {
149
149
  return newDir;
150
150
  }
151
151
 
152
- function taskPath(photonId: string, taskId: string): string {
153
- return path.join(photonScheduleDir(photonId), `${taskId}.json`);
152
+ function taskPath(photonId: string, taskId: string, baseDir?: string): string {
153
+ return path.join(photonScheduleDir(photonId, undefined, baseDir), `${taskId}.json`);
154
154
  }
155
155
 
156
156
  async function ensureDir(dir: string): Promise<void> {
@@ -171,9 +171,19 @@ async function ensureDir(dir: string): Promise<void> {
171
171
  */
172
172
  export class ScheduleProvider {
173
173
  private _photonId: string;
174
+ private _baseDir?: string;
174
175
 
175
- constructor(photonId: string) {
176
+ /**
177
+ * @param photonId Photon identifier used as the bucket under .data/
178
+ * @param baseDir PHOTON_DIR the photon was loaded from. Pinned so
179
+ * schedule files stay under this base regardless of which process
180
+ * reads back later — mirrors the fix applied to MemoryProvider.
181
+ * Without it, photonScheduleDir falls through to PHOTON_DIR env or
182
+ * ~/.photon and schedule files drift across daemon restarts.
183
+ */
184
+ constructor(photonId: string, baseDir?: string) {
176
185
  this._photonId = photonId;
186
+ this._baseDir = baseDir;
177
187
  }
178
188
 
179
189
  /**
@@ -222,7 +232,10 @@ export class ScheduleProvider {
222
232
  */
223
233
  async get(taskId: string): Promise<ScheduledTask | null> {
224
234
  try {
225
- const content = await fs.readFile(taskPath(this._photonId, taskId), 'utf-8');
235
+ const content = await fs.readFile(
236
+ taskPath(this._photonId, taskId, this._baseDir),
237
+ 'utf-8'
238
+ );
226
239
  return JSON.parse(content) as ScheduledTask;
227
240
  } catch (err: any) {
228
241
  if (err.code === 'ENOENT') return null;
@@ -242,7 +255,7 @@ export class ScheduleProvider {
242
255
  * List all scheduled tasks, optionally filtered by status
243
256
  */
244
257
  async list(status?: ScheduleStatus): Promise<ScheduledTask[]> {
245
- const dir = photonScheduleDir(this._photonId);
258
+ const dir = photonScheduleDir(this._photonId, undefined, this._baseDir);
246
259
  let files: string[];
247
260
  try {
248
261
  files = await fs.readdir(dir);
@@ -323,7 +336,7 @@ export class ScheduleProvider {
323
336
  */
324
337
  async cancel(taskId: string): Promise<boolean> {
325
338
  try {
326
- await fs.unlink(taskPath(this._photonId, taskId));
339
+ await fs.unlink(taskPath(this._photonId, taskId, this._baseDir));
327
340
  return true;
328
341
  } catch (err: any) {
329
342
  if (err.code === 'ENOENT') return false;
@@ -362,8 +375,11 @@ export class ScheduleProvider {
362
375
 
363
376
  /** @internal */
364
377
  private async _save(task: ScheduledTask): Promise<void> {
365
- const dir = photonScheduleDir(this._photonId);
378
+ const dir = photonScheduleDir(this._photonId, undefined, this._baseDir);
366
379
  await ensureDir(dir);
367
- await fs.writeFile(taskPath(this._photonId, task.id), JSON.stringify(task, null, 2));
380
+ await fs.writeFile(
381
+ taskPath(this._photonId, task.id, this._baseDir),
382
+ JSON.stringify(task, null, 2)
383
+ );
368
384
  }
369
385
  }