@portel/photon 1.29.0 → 1.31.1

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 (139) hide show
  1. package/README.md +41 -11
  2. package/dist/asset-resolver.d.ts +44 -0
  3. package/dist/asset-resolver.d.ts.map +1 -0
  4. package/dist/asset-resolver.js +105 -0
  5. package/dist/asset-resolver.js.map +1 -0
  6. package/dist/auto-ui/beam/external-mcp-manager.d.ts +73 -0
  7. package/dist/auto-ui/beam/external-mcp-manager.d.ts.map +1 -0
  8. package/dist/auto-ui/beam/external-mcp-manager.js +65 -0
  9. package/dist/auto-ui/beam/external-mcp-manager.js.map +1 -0
  10. package/dist/auto-ui/beam/external-mcp.d.ts.map +1 -1
  11. package/dist/auto-ui/beam/external-mcp.js +25 -1
  12. package/dist/auto-ui/beam/external-mcp.js.map +1 -1
  13. package/dist/auto-ui/beam/photon-management.d.ts.map +1 -1
  14. package/dist/auto-ui/beam/photon-management.js +11 -8
  15. package/dist/auto-ui/beam/photon-management.js.map +1 -1
  16. package/dist/auto-ui/beam/routes/api-browse.d.ts.map +1 -1
  17. package/dist/auto-ui/beam/routes/api-browse.js +7 -4
  18. package/dist/auto-ui/beam/routes/api-browse.js.map +1 -1
  19. package/dist/auto-ui/beam/routes/api-config.d.ts.map +1 -1
  20. package/dist/auto-ui/beam/routes/api-config.js +3 -2
  21. package/dist/auto-ui/beam/routes/api-config.js.map +1 -1
  22. package/dist/auto-ui/beam/routes/api-marketplace.d.ts.map +1 -1
  23. package/dist/auto-ui/beam/routes/api-marketplace.js +6 -2
  24. package/dist/auto-ui/beam/routes/api-marketplace.js.map +1 -1
  25. package/dist/auto-ui/beam/startup.js.map +1 -1
  26. package/dist/auto-ui/beam/types.d.ts +5 -2
  27. package/dist/auto-ui/beam/types.d.ts.map +1 -1
  28. package/dist/auto-ui/beam.d.ts.map +1 -1
  29. package/dist/auto-ui/beam.js +162 -45
  30. package/dist/auto-ui/beam.js.map +1 -1
  31. package/dist/auto-ui/bridge/index.d.ts.map +1 -1
  32. package/dist/auto-ui/bridge/index.js +11 -0
  33. package/dist/auto-ui/bridge/index.js.map +1 -1
  34. package/dist/auto-ui/bridge/types.d.ts +2 -0
  35. package/dist/auto-ui/bridge/types.d.ts.map +1 -1
  36. package/dist/auto-ui/openapi-generator.js +1 -4
  37. package/dist/auto-ui/openapi-generator.js.map +1 -1
  38. package/dist/auto-ui/photon-bridge.d.ts +4 -0
  39. package/dist/auto-ui/photon-bridge.d.ts.map +1 -1
  40. package/dist/auto-ui/photon-bridge.js.map +1 -1
  41. package/dist/auto-ui/photon-host.js.map +1 -1
  42. package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
  43. package/dist/auto-ui/streamable-http-transport.js +24 -14
  44. package/dist/auto-ui/streamable-http-transport.js.map +1 -1
  45. package/dist/auto-ui/types.d.ts +15 -1
  46. package/dist/auto-ui/types.d.ts.map +1 -1
  47. package/dist/auto-ui/types.js.map +1 -1
  48. package/dist/beam.bundle.js +170 -22
  49. package/dist/beam.bundle.js.map +3 -3
  50. package/dist/capability-negotiator.d.ts +39 -1
  51. package/dist/capability-negotiator.d.ts.map +1 -1
  52. package/dist/capability-negotiator.js +5 -0
  53. package/dist/capability-negotiator.js.map +1 -1
  54. package/dist/cf-bindings-parser.d.ts +15 -0
  55. package/dist/cf-bindings-parser.d.ts.map +1 -0
  56. package/dist/cf-bindings-parser.js +98 -0
  57. package/dist/cf-bindings-parser.js.map +1 -0
  58. package/dist/cf-usage-scanner.d.ts +76 -0
  59. package/dist/cf-usage-scanner.d.ts.map +1 -0
  60. package/dist/cf-usage-scanner.js +179 -0
  61. package/dist/cf-usage-scanner.js.map +1 -0
  62. package/dist/cli/commands/build.js +1 -1
  63. package/dist/cli/commands/cf.d.ts +18 -0
  64. package/dist/cli/commands/cf.d.ts.map +1 -0
  65. package/dist/cli/commands/cf.js +207 -0
  66. package/dist/cli/commands/cf.js.map +1 -0
  67. package/dist/cli/commands/info.js +1 -1
  68. package/dist/cli/commands/info.js.map +1 -1
  69. package/dist/cli/commands/init.d.ts.map +1 -1
  70. package/dist/cli/commands/init.js +59 -46
  71. package/dist/cli/commands/init.js.map +1 -1
  72. package/dist/cli/commands/run.d.ts.map +1 -1
  73. package/dist/cli/commands/run.js +3 -0
  74. package/dist/cli/commands/run.js.map +1 -1
  75. package/dist/cli/index.d.ts.map +1 -1
  76. package/dist/cli/index.js +43 -6
  77. package/dist/cli/index.js.map +1 -1
  78. package/dist/daemon/client.d.ts.map +1 -1
  79. package/dist/daemon/client.js +40 -33
  80. package/dist/daemon/client.js.map +1 -1
  81. package/dist/daemon/manager.d.ts +6 -2
  82. package/dist/daemon/manager.d.ts.map +1 -1
  83. package/dist/daemon/manager.js +30 -9
  84. package/dist/daemon/manager.js.map +1 -1
  85. package/dist/daemon/server.js +34 -13
  86. package/dist/daemon/server.js.map +1 -1
  87. package/dist/daemon/worker-host.js.map +1 -1
  88. package/dist/deploy/cloudflare.d.ts +27 -0
  89. package/dist/deploy/cloudflare.d.ts.map +1 -1
  90. package/dist/deploy/cloudflare.js +129 -2
  91. package/dist/deploy/cloudflare.js.map +1 -1
  92. package/dist/embedded-runtime.js.map +1 -1
  93. package/dist/loader.d.ts +43 -66
  94. package/dist/loader.d.ts.map +1 -1
  95. package/dist/loader.js +185 -305
  96. package/dist/loader.js.map +1 -1
  97. package/dist/photon-cli-runner.d.ts.map +1 -1
  98. package/dist/photon-cli-runner.js +20 -11
  99. package/dist/photon-cli-runner.js.map +1 -1
  100. package/dist/resource-server.d.ts +3 -3
  101. package/dist/resource-server.d.ts.map +1 -1
  102. package/dist/resource-server.js.map +1 -1
  103. package/dist/runtime/cf-local.d.ts +157 -0
  104. package/dist/runtime/cf-local.d.ts.map +1 -0
  105. package/dist/runtime/cf-local.js +406 -0
  106. package/dist/runtime/cf-local.js.map +1 -0
  107. package/dist/server.d.ts +42 -2
  108. package/dist/server.d.ts.map +1 -1
  109. package/dist/server.js +166 -14
  110. package/dist/server.js.map +1 -1
  111. package/dist/settings-persistence.d.ts +50 -0
  112. package/dist/settings-persistence.d.ts.map +1 -0
  113. package/dist/settings-persistence.js +188 -0
  114. package/dist/settings-persistence.js.map +1 -0
  115. package/dist/shared/audit-sqlite.d.ts.map +1 -1
  116. package/dist/shared/audit-sqlite.js +0 -1
  117. package/dist/shared/audit-sqlite.js.map +1 -1
  118. package/dist/shared/error-handler.d.ts.map +1 -1
  119. package/dist/shared/error-handler.js +3 -1
  120. package/dist/shared/error-handler.js.map +1 -1
  121. package/dist/shared/io.d.ts.map +1 -1
  122. package/dist/shared/io.js +5 -2
  123. package/dist/shared/io.js.map +1 -1
  124. package/dist/shared/logger.js.map +1 -1
  125. package/dist/shared/sqlite-runtime.d.ts.map +1 -1
  126. package/dist/shared/sqlite-runtime.js +0 -1
  127. package/dist/shared/sqlite-runtime.js.map +1 -1
  128. package/dist/task-executor.js.map +1 -1
  129. package/dist/telemetry/sdk.d.ts.map +1 -1
  130. package/dist/telemetry/sdk.js +0 -1
  131. package/dist/telemetry/sdk.js.map +1 -1
  132. package/dist/test-runner.d.ts.map +1 -1
  133. package/dist/test-runner.js.map +1 -1
  134. package/dist/types/server-types.d.ts +16 -8
  135. package/dist/types/server-types.d.ts.map +1 -1
  136. package/package.json +11 -4
  137. package/templates/cloudflare/worker.ts.template +338 -11
  138. package/templates/cloudflare/wrangler.toml.template +1 -6
  139. package/templates/photon.template.ts +13 -0
package/dist/loader.js CHANGED
@@ -6,6 +6,9 @@
6
6
  import * as fs from 'fs/promises';
7
7
  import { realpathSync, existsSync, mkdirSync, symlinkSync, readFileSync, statSync, } from 'fs';
8
8
  import { readText, readJSON, writeText, writeJSON } from './shared/io.js';
9
+ import { parseCfBindings } from './cf-bindings-parser.js';
10
+ import { CFLocalRuntime, mergeBindings } from './runtime/cf-local.js';
11
+ import { scanCfUsage } from './cf-usage-scanner.js';
9
12
  import { extractHttpRoutesFromSource } from './shared/http-route-extractor.js';
10
13
  import { extractExposesFromSource } from './shared/expose-route-extractor.js';
11
14
  import { createRequire } from 'module';
@@ -28,7 +31,7 @@ ProgressRenderer,
28
31
  // CLI formatting
29
32
  formatOutput as cliFormatOutput, createMCPProxy, MCPConfigurationError, SDKMCPClientFactory, resolveMCPSource,
30
33
  // Photon runtime configuration
31
- loadPhotonMCPConfig, resolveEnvVars, isClass as sharedIsClass, findPhotonClass as sharedFindPhotonClass, parseEnvValue as sharedParseEnvValue, compilePhotonTS, parseRuntimeRequirement, checkRuntimeCompatibility, discoverAssets as sharedDiscoverAssets,
34
+ loadPhotonMCPConfig, resolveEnvVars, isClass as sharedIsClass, findPhotonClass as sharedFindPhotonClass, parseEnvValue as sharedParseEnvValue, compilePhotonTS, parseRuntimeRequirement, checkRuntimeCompatibility,
32
35
  // Execution audit trail
33
36
  getAuditTrail,
34
37
  // Capability detection for auto-injection
@@ -49,6 +52,8 @@ import { PHOTON_VERSION, getResolvedPhotonCoreVersion } from './version.js';
49
52
  // Timeout for external fetch requests (marketplace, GitHub)
50
53
  const FETCH_TIMEOUT_MS = 30 * 1000;
51
54
  import { generateConfigErrorMessage, summarizeConstructorParams } from './shared/config-docs.js';
55
+ import { SettingsPersistence } from './settings-persistence.js';
56
+ import { AssetResolver } from './asset-resolver.js';
52
57
  import { createLogger } from './shared/logger.js';
53
58
  import { getErrorMessage } from './shared/error-handler.js';
54
59
  import { validateOrThrow, assertString, notEmpty, hasExtension } from './shared/validation.js';
@@ -183,6 +188,31 @@ function injectEmitHelpers(instance) {
183
188
  instance.thinking = (active = true) => emit({ emit: 'thinking', active });
184
189
  }
185
190
  }
191
+ /** True when the scanner detected any CF usage that warrants attaching a runtime. */
192
+ function cfUsageNonEmpty(usage) {
193
+ for (const cat of ['kv', 'r2', 'd1', 'queue', 'vectorize']) {
194
+ if (usage.qualifiers[cat].size > 0)
195
+ return true;
196
+ }
197
+ return usage.shared.ai || usage.shared.images || usage.shared.browser;
198
+ }
199
+ /** Short, log-friendly summary of which CF surfaces a photon reaches for. */
200
+ function describeUsage(usage, declared) {
201
+ const parts = [];
202
+ for (const cat of ['kv', 'r2', 'd1', 'queue', 'vectorize']) {
203
+ if (usage.qualifiers[cat].size > 0)
204
+ parts.push(`${cat}×${usage.qualifiers[cat].size}`);
205
+ }
206
+ if (usage.shared.ai)
207
+ parts.push('ai');
208
+ if (usage.shared.images)
209
+ parts.push('images');
210
+ if (usage.shared.browser)
211
+ parts.push('browser');
212
+ if (parts.length === 0 && declared)
213
+ parts.push('overrides-only');
214
+ return parts.join(', ') || 'cf';
215
+ }
186
216
  /** Extra regex checks that force 'emit' capability when helper methods are used. */
187
217
  function detectEmitHelperUsage(source) {
188
218
  return /this\.(toast|log|status|progress|thinking)\s*\(/.test(source);
@@ -369,6 +399,14 @@ export class PhotonLoader {
369
399
  marketplaceManager;
370
400
  marketplaceManagerPromise;
371
401
  logger;
402
+ settingsPersistence;
403
+ assetResolver;
404
+ /**
405
+ * One CFLocalRuntime per photon name. Boot is lazy inside the runtime
406
+ * itself; this cache just ensures every instance of a given photon
407
+ * shares a single miniflare sandbox.
408
+ */
409
+ cfRuntimes = new Map();
372
410
  /**
373
411
  * Per-instance call queue. Two tool invocations targeting the same photon
374
412
  * instance are serialized: the second starts only after the first returns.
@@ -430,6 +468,8 @@ export class PhotonLoader {
430
468
  this.verbose = verbose;
431
469
  this.logger = logger ?? createLogger({ component: 'photon-loader', minimal: true });
432
470
  this.baseDir = baseDir || getDefaultContext().baseDir;
471
+ this.settingsPersistence = new SettingsPersistence(this.baseDir, (msg, meta) => this.log(msg, meta));
472
+ this.assetResolver = new AssetResolver((msg, meta) => this.log(msg, meta));
433
473
  }
434
474
  /**
435
475
  * Load MCP configuration from ~/.photon/config.json
@@ -451,6 +491,63 @@ export class PhotonLoader {
451
491
  });
452
492
  return this.mcpConfigPromise;
453
493
  }
494
+ /**
495
+ * Resolve the path where a photon's CF binding override JSON lives.
496
+ * The override layers on top of `protected cfBindings` so users can
497
+ * repoint a binding (e.g., point `kv: cache` at a different namespace)
498
+ * without editing source.
499
+ */
500
+ getCfOverridePath(photonName) {
501
+ return path.join(this.baseDir, '.data', 'cf-overrides', `${photonName}.json`);
502
+ }
503
+ /** Load the per-photon CF override JSON. Returns null if missing. */
504
+ async loadCfOverride(photonName) {
505
+ try {
506
+ return await readJSON(this.getCfOverridePath(photonName));
507
+ }
508
+ catch {
509
+ return null;
510
+ }
511
+ }
512
+ /**
513
+ * Persist the CF override for a photon. Creates parent directories as
514
+ * needed; called by the `photon cf set` CLI command.
515
+ */
516
+ async saveCfOverride(photonName, override) {
517
+ const overridePath = this.getCfOverridePath(photonName);
518
+ await fs.mkdir(path.dirname(overridePath), { recursive: true });
519
+ await writeJSON(overridePath, override);
520
+ return overridePath;
521
+ }
522
+ /**
523
+ * Build (or fetch the cached) CFLocalRuntime for `photonName`. Uses
524
+ * the new options-form constructor so auto-naming applies and
525
+ * miniflare seeds every literal qualifier the scanner found, plus
526
+ * any override-declared bindings.
527
+ */
528
+ async attachCfRuntime(photonName, usage, declared) {
529
+ let runtime = this.cfRuntimes.get(photonName);
530
+ if (runtime)
531
+ return runtime;
532
+ const overrideJson = await this.loadCfOverride(photonName);
533
+ const overrides = mergeBindings(declared, overrideJson);
534
+ runtime = new CFLocalRuntime({
535
+ photonName,
536
+ baseDir: this.baseDir,
537
+ usage,
538
+ overrides,
539
+ });
540
+ this.cfRuntimes.set(photonName, runtime);
541
+ return runtime;
542
+ }
543
+ /** Read the effective bindings for a photon: declared (from source) plus override. */
544
+ async getEffectiveCfBindings(photonName, tsContent) {
545
+ const declared = parseCfBindings(tsContent);
546
+ if (!declared)
547
+ return { declared: null, override: null, effective: null };
548
+ const override = await this.loadCfOverride(photonName);
549
+ return { declared, override, effective: mergeBindings(declared, override) };
550
+ }
454
551
  async getMarketplaceManager() {
455
552
  if (this.marketplaceManager) {
456
553
  return this.marketplaceManager;
@@ -558,6 +655,18 @@ export class PhotonLoader {
558
655
  await this.clearDependencyCache(cacheKey);
559
656
  await this.clearBuildCache(cacheKey);
560
657
  }
658
+ /**
659
+ * Clear all caches for a photon identified by file path.
660
+ * Called by Beam when a photon fails to load, so the next reload
661
+ * recompiles from source rather than using stale cached artifacts.
662
+ */
663
+ async clearCacheForFile(filePath) {
664
+ const absolutePath = path.resolve(filePath);
665
+ const mcpName = path.basename(absolutePath, '.ts').replace('.photon', '');
666
+ const cacheKey = this.getCacheKey(mcpName, absolutePath);
667
+ await this.clearAllCaches(cacheKey);
668
+ this.log(`🗑️ Cache cleared for ${mcpName} (auto-retry after load failure)`);
669
+ }
561
670
  /**
562
671
  * Path to metadata file describing installed dependencies
563
672
  */
@@ -675,6 +784,11 @@ export class PhotonLoader {
675
784
  message.includes('Cannot find module') ||
676
785
  message.includes('require is not defined'));
677
786
  }
787
+ isCompilationServiceError(error) {
788
+ const message = error instanceof Error ? error.message : String(error);
789
+ return (message.includes('The service was stopped') ||
790
+ message.includes('The service is no longer running'));
791
+ }
678
792
  static parseDependenciesFromSource(source) {
679
793
  const deps = [];
680
794
  // Only match @dependencies inside JSDoc blocks (/** ... */)
@@ -802,6 +916,12 @@ export class PhotonLoader {
802
916
  module = await importModule();
803
917
  }
804
918
  else {
919
+ if (this.isCompilationServiceError(error) && cacheKey) {
920
+ // Compiler process crashed — clear the build cache so the next startup
921
+ // recompiles from source instead of finding a stale or missing artifact.
922
+ await this.clearBuildCache(cacheKey);
923
+ this.log(`⚠️ Compiler service crashed for ${mcpName || 'unknown'}, build cache cleared. Restart to recover.`);
924
+ }
805
925
  throw error;
806
926
  }
807
927
  }
@@ -1088,6 +1208,12 @@ export class PhotonLoader {
1088
1208
  configurable: true,
1089
1209
  });
1090
1210
  }
1211
+ // `this.cf` is no longer always-injected. The CF runtime
1212
+ // injection block above sets `instance.cf` directly when the
1213
+ // photon either references the surface (scanner detection) or
1214
+ // declares `protected cfBindings`. Plain photons that never
1215
+ // touch CF leave `this.cf` undefined — a plain runtime error
1216
+ // beats a throwing-Proxy stub for the diagnostic.
1091
1217
  // Always-inject `this.callerCwd`. Reads the originating CLI cwd from
1092
1218
  // the request context, falling back to `process.cwd()` when no context
1093
1219
  // is active (direct in-process load with no caller info). Inside a
@@ -1376,15 +1502,33 @@ export class PhotonLoader {
1376
1502
  }
1377
1503
  // Extract tools, templates, and statics (with schema override support)
1378
1504
  const { tools, templates, statics, settingsSchema, auth: extractedAuth, httpRoutes: extractedHttpRoutes, exposes: extractedExposes, } = await this.extractTools(MCPClass, absolutePath);
1505
+ // ═══ CF RUNTIME INJECTION ═══
1506
+ // The photon's CF surface comes from the new `Cloudflare` injection
1507
+ // (constructor param typed `Cloudflare`, or the forgiving `this.cf.*`
1508
+ // path on plain classes). The scanner discovers literal qualifiers
1509
+ // so miniflare seeds them up front; `protected cfBindings` is now
1510
+ // an optional override layer for repointing bindings at pre-existing
1511
+ // CF resources.
1512
+ if (tsContent) {
1513
+ const usage = scanCfUsage(tsContent);
1514
+ const overrides = parseCfBindings(tsContent);
1515
+ const photonUsesCf = cfUsageNonEmpty(usage) || overrides !== null;
1516
+ if (photonUsesCf) {
1517
+ const runtime = await this.attachCfRuntime(name, usage, overrides);
1518
+ instance.cf = runtime;
1519
+ const what = describeUsage(usage, overrides);
1520
+ this.log(`☁️ CF runtime attached to ${name} (${what})`);
1521
+ }
1522
+ }
1379
1523
  // ═══ SETTINGS INJECTION ═══
1380
1524
  // If the photon declared `protected settings = { ... }`, inject persistence + proxy
1381
1525
  if (settingsSchema?.hasSettings &&
1382
1526
  instance.settings &&
1383
1527
  typeof instance.settings === 'object') {
1384
1528
  const instanceName = options?.instanceName || 'default';
1385
- await this.injectSettings(instance, name, instanceName, settingsSchema);
1529
+ await this.settingsPersistence.inject(instance, name, instanceName, settingsSchema);
1386
1530
  // Auto-generate the `settings` MCP tool from the schema
1387
- const settingsTool = this.generateSettingsTool(settingsSchema);
1531
+ const settingsTool = this.settingsPersistence.generateTool(settingsSchema);
1388
1532
  tools.push(settingsTool);
1389
1533
  this.log(`⚙️ Settings tool auto-generated for ${name} (${settingsSchema.properties.length} props)`);
1390
1534
  }
@@ -1393,8 +1537,8 @@ export class PhotonLoader {
1393
1537
  this.logger.warn(`⚠️ ${name}: configure() method is deprecated. Use 'protected settings = { ... }' property instead.`);
1394
1538
  }
1395
1539
  // Extract assets from source and discover asset folder
1396
- const assets = await this.discoverAssets(absolutePath, tsContent || '');
1397
- this.attachAssetsToInstance(instance, assets);
1540
+ const assets = await this.assetResolver.discover(absolutePath, tsContent || '');
1541
+ this.assetResolver.attachToInstance(instance, assets);
1398
1542
  const counts = [
1399
1543
  tools.length > 0 ? `${tools.length} tools` : null,
1400
1544
  templates.length > 0 ? `${templates.length} templates` : null,
@@ -1736,15 +1880,25 @@ export class PhotonLoader {
1736
1880
  await this.invokeInitialize(instance, name, options);
1737
1881
  // Extract tools and metadata from embedded source (no disk I/O)
1738
1882
  const { tools, templates, statics, settingsSchema, auth: extractedAuth, httpRoutes: extractedHttpRoutes, exposes: extractedExposes, } = await this.extractTools(MCPClass, absolutePath, tsContent);
1883
+ // CF runtime injection (mirrors the loadFile path above)
1884
+ if (tsContent) {
1885
+ const usage = scanCfUsage(tsContent);
1886
+ const overrides = parseCfBindings(tsContent);
1887
+ const photonUsesCf = cfUsageNonEmpty(usage) || overrides !== null;
1888
+ if (photonUsesCf) {
1889
+ const runtime = await this.attachCfRuntime(name, usage, overrides);
1890
+ instance.cf = runtime;
1891
+ }
1892
+ }
1739
1893
  // Settings injection
1740
1894
  if (settingsSchema?.hasSettings && instance.settings && typeof instance.settings === 'object') {
1741
1895
  const instanceName = options?.instanceName || 'default';
1742
- await this.injectSettings(instance, name, instanceName, settingsSchema);
1743
- tools.push(this.generateSettingsTool(settingsSchema));
1896
+ await this.settingsPersistence.inject(instance, name, instanceName, settingsSchema);
1897
+ tools.push(this.settingsPersistence.generateTool(settingsSchema));
1744
1898
  }
1745
1899
  // Discover assets (for @ui)
1746
- const assets = await this.discoverAssets(absolutePath, tsContent);
1747
- this.attachAssetsToInstance(instance, assets);
1900
+ const assets = await this.assetResolver.discover(absolutePath, tsContent);
1901
+ this.assetResolver.attachToInstance(instance, assets);
1748
1902
  this.log(`✅ Loaded (preloaded): ${name} (${tools.length} tools)`);
1749
1903
  // Extract class-level metadata from docblock (icon, description, stateful)
1750
1904
  const classDocblock = this.extractClassDocblock(tsContent);
@@ -1819,7 +1973,10 @@ export class PhotonLoader {
1819
1973
  const files = await fs.readdir(buildDir).catch(() => []);
1820
1974
  for (const f of files) {
1821
1975
  if (f.startsWith(fileName) && f.endsWith('.mjs')) {
1822
- await fs.unlink(path.join(buildDir, f)).catch(() => { });
1976
+ await fs.unlink(path.join(buildDir, f)).catch((err) => this.logger.debug('Failed to remove stale cache file', {
1977
+ file: f,
1978
+ error: String(err),
1979
+ }));
1823
1980
  }
1824
1981
  }
1825
1982
  }
@@ -2073,199 +2230,9 @@ export class PhotonLoader {
2073
2230
  statics = statics.map((s) => ({ ...s, description: this.stripJSDocTags(s.description) }));
2074
2231
  return { tools, templates, statics };
2075
2232
  }
2076
- // ════════════════════════════════════════════════════════════════════════════
2077
- // SETTINGS property-driven configuration with auto-persistence
2078
- // ════════════════════════════════════════════════════════════════════════════
2079
- /**
2080
- * Get the settings persistence path for a photon instance.
2081
- * Co-located with state under Option B: the settings JSON lives next to
2082
- * state.json inside the per-instance directory.
2083
- */
2084
- getSettingsPath(photonName, instanceName) {
2085
- // getInstanceStatePath returns `.../state/{instance}/state.json`; swap
2086
- // the filename so settings land at `.../state/{instance}/settings.json`.
2087
- // Keeps the source of truth for the layout in context-store.
2088
- const statePath = getInstanceStatePath(photonName, instanceName, this.baseDir);
2089
- return path.join(path.dirname(statePath), 'settings.json');
2090
- }
2091
- /**
2092
- * Load persisted settings from disk
2093
- */
2094
- async loadSettings(photonName, instanceName) {
2095
- const settingsPath = this.getSettingsPath(photonName, instanceName);
2096
- try {
2097
- return await readJSON(settingsPath);
2098
- }
2099
- catch {
2100
- return {};
2101
- }
2102
- }
2103
- /**
2104
- * Persist settings to disk
2105
- */
2106
- async persistSettings(photonName, instanceName, values) {
2107
- const settingsPath = this.getSettingsPath(photonName, instanceName);
2108
- const dir = path.dirname(settingsPath);
2109
- await fs.mkdir(dir, { recursive: true });
2110
- await writeJSON(settingsPath, values);
2111
- }
2112
- /**
2113
- * Inject settings into a photon instance:
2114
- * - Load persisted values (persisted wins over defaults)
2115
- * - Replace instance.settings with a read-only Proxy
2116
- * - Store writable backing object for the settings tool
2117
- */
2118
- async injectSettings(instance, photonName, instanceName, schema) {
2119
- const defaults = instance.settings;
2120
- const persisted = await this.loadSettings(photonName, instanceName);
2121
- // Merge: persisted values over defaults
2122
- const backing = { ...defaults };
2123
- for (const key of Object.keys(persisted)) {
2124
- if (persisted[key] !== undefined) {
2125
- backing[key] = persisted[key];
2126
- }
2127
- }
2128
- // Store the writable backing object for the settings tool to update
2129
- instance._settingsBacking = backing;
2130
- instance._settingsPhotonName = photonName;
2131
- instance._settingsInstanceName = instanceName;
2132
- instance._settingsSchema = schema;
2133
- // Replace with read-only Proxy
2134
- instance.settings = new Proxy(backing, {
2135
- get(target, prop) {
2136
- if (typeof prop === 'string') {
2137
- return target[prop];
2138
- }
2139
- return undefined;
2140
- },
2141
- set(_target, prop, _value) {
2142
- throw new Error(`Cannot directly set settings.${String(prop)}. ` +
2143
- `Use the 'settings' tool to change settings (e.g., settings({ ${String(prop)}: newValue })).`);
2144
- },
2145
- deleteProperty(_target, prop) {
2146
- throw new Error(`Cannot delete settings.${String(prop)}. Use the 'settings' tool instead.`);
2147
- },
2148
- });
2149
- }
2150
- /**
2151
- * Generate an MCP tool definition from a SettingsSchema
2152
- */
2153
- generateSettingsTool(schema) {
2154
- const properties = {};
2155
- for (const prop of schema.properties) {
2156
- const propSchema = { type: prop.type };
2157
- if (prop.description) {
2158
- propSchema.description = prop.description;
2159
- }
2160
- if (prop.default !== undefined) {
2161
- propSchema.default = prop.default;
2162
- }
2163
- properties[prop.name] = propSchema;
2164
- }
2165
- return {
2166
- name: 'settings',
2167
- description: 'View or update photon settings. Call with no arguments to view current settings. Pass parameters to update specific settings.',
2168
- inputSchema: {
2169
- type: 'object',
2170
- properties,
2171
- // No required params — all settings are optional when calling the tool
2172
- },
2173
- };
2174
- }
2175
- /**
2176
- * Execute the auto-generated settings tool:
2177
- * - No params → return current settings
2178
- * - Params with values → update those settings, persist, emit change
2179
- * - Params with undefined + no default → trigger elicitation
2180
- */
2181
- async executeSettingsTool(instance, parameters, options) {
2182
- const backing = instance._settingsBacking;
2183
- const photonName = instance._settingsPhotonName;
2184
- const instanceName = instance._settingsInstanceName;
2185
- const schema = instance._settingsSchema;
2186
- if (!backing || !photonName || !schema) {
2187
- throw new Error('Settings not initialized for this photon');
2188
- }
2189
- // No params or empty params → return current settings
2190
- if (!parameters || Object.keys(parameters).length === 0) {
2191
- // Check if any required settings (undefined defaults) need elicitation
2192
- const needsElicitation = schema.properties.filter((p) => p.required && backing[p.name] === undefined);
2193
- if (needsElicitation.length > 0 && options?.inputProvider) {
2194
- for (const prop of needsElicitation) {
2195
- const result = await options.inputProvider({
2196
- ask: prop.type === 'number' ? 'number' : 'text',
2197
- message: prop.description || `Enter value for ${prop.name}:`,
2198
- });
2199
- if (result !== undefined && result !== null) {
2200
- const oldValue = backing[prop.name];
2201
- backing[prop.name] = result;
2202
- this.log(`⚙️ Settings: ${prop.name} = ${JSON.stringify(result)} (elicited)`);
2203
- this.emitSettingsChange(instance, prop.name, oldValue, result);
2204
- }
2205
- }
2206
- await this.persistSettings(photonName, instanceName, backing);
2207
- }
2208
- return { ...backing };
2209
- }
2210
- // Update specified settings
2211
- const changes = [];
2212
- for (const [key, value] of Object.entries(parameters)) {
2213
- // Verify this is a valid setting
2214
- const prop = schema.properties.find((p) => p.name === key);
2215
- if (!prop)
2216
- continue;
2217
- if (value === undefined && prop.required) {
2218
- // Elicit value from user
2219
- if (options?.inputProvider) {
2220
- const result = await options.inputProvider({
2221
- ask: prop.type === 'number' ? 'number' : 'text',
2222
- message: prop.description || `Enter value for ${key}:`,
2223
- });
2224
- if (result !== undefined && result !== null) {
2225
- const oldValue = backing[key];
2226
- backing[key] = result;
2227
- changes.push({ property: key, oldValue, newValue: result });
2228
- }
2229
- }
2230
- }
2231
- else {
2232
- const oldValue = backing[key];
2233
- backing[key] = value;
2234
- changes.push({ property: key, oldValue, newValue: value });
2235
- }
2236
- }
2237
- // Persist if anything changed
2238
- if (changes.length > 0) {
2239
- await this.persistSettings(photonName, instanceName, backing);
2240
- for (const change of changes) {
2241
- this.log(`⚙️ Settings: ${change.property}: ${JSON.stringify(change.oldValue)} → ${JSON.stringify(change.newValue)}`);
2242
- this.emitSettingsChange(instance, change.property, change.oldValue, change.newValue);
2243
- }
2244
- }
2245
- // Re-apply proxy (backing object is already updated in place, proxy still references it)
2246
- return { ...backing };
2247
- }
2248
- /**
2249
- * Emit a settings:changed event through the photon's emit system
2250
- */
2251
- emitSettingsChange(instance, property, oldValue, newValue) {
2252
- if (typeof instance.emit === 'function') {
2253
- try {
2254
- instance.emit({
2255
- event: 'settings:changed',
2256
- data: {
2257
- property,
2258
- oldValue,
2259
- newValue,
2260
- timestamp: Date.now(),
2261
- },
2262
- });
2263
- }
2264
- catch {
2265
- // Best-effort emit
2266
- }
2267
- }
2268
- }
2233
+ // Settings persistence (load, persist, inject Proxy, generate tool, execute
2234
+ // updates) lives in `./settings-persistence.ts` and is reachable via
2235
+ // `this.settingsPersistence`.
2269
2236
  /**
2270
2237
  * Extract constructor parameters from source file
2271
2238
  */
@@ -3037,7 +3004,6 @@ Run: photon mcp ${mcpName} --config
3037
3004
  * All middleware (built-in and custom) follows the same code path — no if-chain.
3038
3005
  */
3039
3006
  applyMiddleware(execute, toolMeta, photonName, toolName, parameters, instanceName, outputHandler) {
3040
- // Build middleware context
3041
3007
  const store = executionContext.getStore();
3042
3008
  const ctx = {
3043
3009
  photon: photonName,
@@ -3046,7 +3012,7 @@ Run: photon mcp ${mcpName} --config
3046
3012
  params: parameters,
3047
3013
  caller: store?.caller,
3048
3014
  outputHandler,
3049
- }; // MiddlewareContext type is in photon-core (read-only); caller/outputHandler added via runtime extension
3015
+ };
3050
3016
  // Get declarations from the new middleware[] field
3051
3017
  const declarations = toolMeta.middleware || [];
3052
3018
  if (declarations.length === 0) {
@@ -3280,9 +3246,13 @@ Run: photon mcp ${mcpName} --config
3280
3246
  parentTraceparent = metaPeek.traceparent;
3281
3247
  }
3282
3248
  }
3283
- // Start OTel span for tool execution (no-op if SDK not installed)
3284
- const toolMetaForSpan = mcp?.meta?.tools?.[toolName];
3285
- const isStateful = Boolean(toolMetaForSpan?.stateful ?? mcp?.meta?.stateful);
3249
+ // Start OTel span for tool execution (no-op if SDK not installed).
3250
+ // The `mcp.meta` shape is an internal extension some callers stamp on the
3251
+ // loaded photon it isn't on PhotonClass so we type the lookup as a
3252
+ // narrow structural shape rather than reach for `as any`.
3253
+ const mcpMeta = mcp.meta;
3254
+ const toolMetaForSpan = mcpMeta?.tools?.[toolName];
3255
+ const isStateful = Boolean(toolMetaForSpan?.stateful ?? mcpMeta?.stateful);
3286
3256
  const span = startToolSpan(mcp.name, toolName, parameters, options?.traceId, isStateful, parentTraceparent);
3287
3257
  if (mcp.instance?.instanceName) {
3288
3258
  span.setAttribute('photon.instance', mcp.instance.instanceName);
@@ -3316,7 +3286,9 @@ Run: photon mcp ${mcpName} --config
3316
3286
  if (mcp.instance._photonConfigError) {
3317
3287
  throw new Error(mcp.instance._photonConfigError);
3318
3288
  }
3319
- // Enforce @auth at method level — works across ALL transports (CLI, STDIO, HTTP, daemon)
3289
+ // Enforce @auth at method level — works across ALL transports (CLI, STDIO, HTTP, daemon).
3290
+ // `auth` is a runtime extension stamped by the loader on top of the published
3291
+ // PhotonClass type; reach via the narrower PhotonClassWithMeta interface.
3320
3292
  const photonAuth = mcp.auth;
3321
3293
  if (photonAuth === 'required') {
3322
3294
  const caller = options?.caller;
@@ -3371,7 +3343,7 @@ Run: photon mcp ${mcpName} --config
3371
3343
  }
3372
3344
  // Intercept auto-generated settings tool
3373
3345
  if (toolName === 'settings' && mcp.instance._settingsBacking) {
3374
- const result = await this.executeSettingsTool(mcp.instance, parameters, {
3346
+ const result = await this.settingsPersistence.execute(mcp.instance, parameters, {
3375
3347
  outputHandler: options?.outputHandler,
3376
3348
  inputProvider: options?.inputProvider,
3377
3349
  });
@@ -3673,7 +3645,7 @@ Run: photon mcp ${mcpName} --config
3673
3645
  instance: mcp.instance?.instanceName,
3674
3646
  });
3675
3647
  }
3676
- this.logger.error(`Tool execution failed: ${toolName} - ${getErrorMessage(error)}`);
3648
+ this.logger.debug(`Tool execution failed: ${toolName} - ${getErrorMessage(error)}`);
3677
3649
  throw error;
3678
3650
  }
3679
3651
  finally {
@@ -4183,57 +4155,8 @@ Run: photon mcp ${mcpName} --config
4183
4155
  throw initError;
4184
4156
  }
4185
4157
  }
4186
- /**
4187
- * Discover and extract assets from a Photon file
4188
- * Uses shared discoverAssets from photon-core for core logic,
4189
- * then applies photon-specific extensions (method UI links, URI generation).
4190
- */
4191
- async discoverAssets(photonPath, source) {
4192
- const basename = path.basename(photonPath, '.photon.ts');
4193
- // Use shared discovery from photon-core
4194
- const assets = await sharedDiscoverAssets(photonPath, source);
4195
- if (!assets) {
4196
- return undefined;
4197
- }
4198
- // Apply method-level @ui links AFTER auto-discovery
4199
- this.applyMethodUILinks(source, assets);
4200
- // Generate ui:// URIs for MCP Apps Extension support (SEP-1865)
4201
- this.generateAssetURIs(basename, assets);
4202
- return assets;
4203
- }
4204
- /**
4205
- * Expose discovered asset metadata on the instance without breaking Photon.assets().
4206
- *
4207
- * Photon subclasses inherit an `assets(subpath)` method from photon-core. We bind that
4208
- * method and decorate the function object with discovered metadata so both of these work:
4209
- * - `this.assets('templates')`
4210
- * - `this.assets.ui`
4211
- *
4212
- * Plain classes don't have the inherited method, so they receive the metadata object directly.
4213
- */
4214
- attachAssetsToInstance(instance, assets) {
4215
- if (!assets) {
4216
- return;
4217
- }
4218
- const existingAssets = instance.assets;
4219
- if (typeof existingAssets === 'function') {
4220
- const boundAssets = existingAssets.bind(instance);
4221
- Object.assign(boundAssets, assets);
4222
- Object.defineProperty(instance, 'assets', {
4223
- value: boundAssets,
4224
- configurable: true,
4225
- enumerable: false,
4226
- writable: false,
4227
- });
4228
- return;
4229
- }
4230
- Object.defineProperty(instance, 'assets', {
4231
- value: assets,
4232
- configurable: true,
4233
- enumerable: false,
4234
- writable: false,
4235
- });
4236
- }
4158
+ // Asset discovery + binding lives in `./asset-resolver.ts` and is reachable
4159
+ // via `this.assetResolver`.
4237
4160
  /**
4238
4161
  * Inject Photon path helpers for plain classes that use them without extending Photon.
4239
4162
  */
@@ -4290,48 +4213,5 @@ Run: photon mcp ${mcpName} --config
4290
4213
  };
4291
4214
  }
4292
4215
  }
4293
- /**
4294
- * Generate ui:// URIs for all UI assets (MCP Apps Extension support)
4295
- * URI format: ui://<photon-name>/<asset-id>
4296
- */
4297
- generateAssetURIs(photonName, assets) {
4298
- for (const ui of assets.ui) {
4299
- // Add uri field for MCP Apps compatibility
4300
- ui.uri = `ui://${photonName}/${ui.id}`;
4301
- this.log(` 🔗 URI: ${ui.uri}`);
4302
- }
4303
- }
4304
- /**
4305
- * Apply method-level @ui annotations to link UI assets to tools
4306
- * Called after auto-discovery so all UI assets are available
4307
- */
4308
- applyMethodUILinks(source, assets) {
4309
- // Match method JSDoc with @ui annotation: /** ... @ui <id> ... */ async methodName
4310
- const methodUiRegex = /\/\*\*[\s\S]*?@ui\s+(\w[\w-]*)[\s\S]*?\*\/\s*(?:async\s+)?\*?\s*(\w+)/g;
4311
- let match;
4312
- while ((match = methodUiRegex.exec(source)) !== null) {
4313
- const [, uiId, methodName] = match;
4314
- const asset = assets.ui.find((u) => u.id === uiId);
4315
- if (asset) {
4316
- // First method wins as primary (used for app detection)
4317
- if (!asset.linkedTool) {
4318
- asset.linkedTool = methodName;
4319
- this.log(` 🔗 UI ${uiId} → ${methodName}`);
4320
- }
4321
- // Track all methods that reference this UI
4322
- if (!asset.linkedTools)
4323
- asset.linkedTools = [];
4324
- if (!asset.linkedTools.includes(methodName)) {
4325
- asset.linkedTools.push(methodName);
4326
- if (asset.linkedTools.length > 1) {
4327
- this.log(` 🔗 UI ${uiId} → ${methodName} (shared)`);
4328
- }
4329
- }
4330
- }
4331
- else {
4332
- this.log(` ⚠️ @ui ${uiId} on ${methodName}: asset not found (check file exists)`);
4333
- }
4334
- }
4335
- }
4336
4216
  }
4337
4217
  //# sourceMappingURL=loader.js.map