@portel/photon-core 2.8.4 → 2.9.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 (57) hide show
  1. package/dist/base.d.ts +7 -7
  2. package/dist/base.d.ts.map +1 -1
  3. package/dist/base.js +8 -8
  4. package/dist/base.js.map +1 -1
  5. package/dist/collections/Collection.d.ts +2 -2
  6. package/dist/collections/Collection.js +2 -2
  7. package/dist/compiler.js +7 -7
  8. package/dist/compiler.js.map +1 -1
  9. package/dist/config.d.ts +1 -1
  10. package/dist/config.js +1 -1
  11. package/dist/index.d.ts +7 -3
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +16 -4
  14. package/dist/index.js.map +1 -1
  15. package/dist/instance-store.d.ts +64 -0
  16. package/dist/instance-store.d.ts.map +1 -0
  17. package/dist/instance-store.js +144 -0
  18. package/dist/instance-store.js.map +1 -0
  19. package/dist/memory.d.ts +2 -2
  20. package/dist/memory.js +2 -2
  21. package/dist/middleware.d.ts +69 -0
  22. package/dist/middleware.d.ts.map +1 -0
  23. package/dist/middleware.js +570 -0
  24. package/dist/middleware.js.map +1 -0
  25. package/dist/schema-extractor.d.ts +111 -1
  26. package/dist/schema-extractor.d.ts.map +1 -1
  27. package/dist/schema-extractor.js +337 -2
  28. package/dist/schema-extractor.js.map +1 -1
  29. package/dist/stateful.d.ts +2 -0
  30. package/dist/stateful.d.ts.map +1 -1
  31. package/dist/stateful.js +2 -0
  32. package/dist/stateful.js.map +1 -1
  33. package/dist/types.d.ts +111 -5
  34. package/dist/types.d.ts.map +1 -1
  35. package/dist/types.js.map +1 -1
  36. package/dist/utils/duration.d.ts +24 -0
  37. package/dist/utils/duration.d.ts.map +1 -0
  38. package/dist/utils/duration.js +64 -0
  39. package/dist/utils/duration.js.map +1 -0
  40. package/dist/watcher.d.ts +62 -0
  41. package/dist/watcher.d.ts.map +1 -0
  42. package/dist/watcher.js +270 -0
  43. package/dist/watcher.js.map +1 -0
  44. package/package.json +2 -2
  45. package/src/base.ts +8 -8
  46. package/src/collections/Collection.ts +2 -2
  47. package/src/compiler.ts +7 -7
  48. package/src/config.ts +1 -1
  49. package/src/index.ts +34 -4
  50. package/src/instance-store.ts +155 -0
  51. package/src/memory.ts +2 -2
  52. package/src/middleware.ts +714 -0
  53. package/src/schema-extractor.ts +358 -2
  54. package/src/stateful.ts +4 -0
  55. package/src/types.ts +106 -5
  56. package/src/utils/duration.ts +67 -0
  57. package/src/watcher.ts +317 -0
@@ -11,6 +11,8 @@
11
11
  import * as fs from 'fs/promises';
12
12
  import * as ts from 'typescript';
13
13
  import { ExtractedSchema, ConstructorParam, TemplateInfo, StaticInfo, OutputFormat, YieldInfo, MCPDependency, PhotonDependency, CLIDependency, ResolvedInjection, PhotonAssets, UIAsset, PromptAsset, ResourceAsset, ConfigSchema, ConfigParam } from './types.js';
14
+ import { parseDuration, parseRate } from './utils/duration.js';
15
+ import { builtinRegistry, type MiddlewareDeclaration } from './middleware.js';
14
16
 
15
17
  export interface ExtractedMetadata {
16
18
  tools: ExtractedSchema[];
@@ -106,6 +108,11 @@ export class SchemaExtractor {
106
108
 
107
109
  const jsdoc = this.getJSDocComment(member, sourceFile);
108
110
 
111
+ // Skip @internal methods — hidden from LLM and sidebar
112
+ if (/@internal\b/.test(jsdoc)) {
113
+ return;
114
+ }
115
+
109
116
  // Check if this is an async generator method (has asterisk token)
110
117
  const isGenerator = member.asteriskToken !== undefined;
111
118
 
@@ -182,6 +189,22 @@ export class SchemaExtractor {
182
189
  const scheduled = this.extractScheduled(jsdoc, methodName);
183
190
  const locked = this.extractLocked(jsdoc, methodName);
184
191
 
192
+ // Functional tags — individual fields kept for backward compat
193
+ const fallback = this.extractFallback(jsdoc);
194
+ const logged = this.extractLogged(jsdoc);
195
+ const circuitBreaker = this.extractCircuitBreaker(jsdoc);
196
+ const cached = this.extractCached(jsdoc);
197
+ const timeout = this.extractTimeout(jsdoc);
198
+ const retryable = this.extractRetryable(jsdoc);
199
+ const throttled = this.extractThrottled(jsdoc);
200
+ const debounced = this.extractDebounced(jsdoc);
201
+ const queued = this.extractQueued(jsdoc);
202
+ const validations = this.extractValidations(jsdoc);
203
+ const deprecated = this.extractDeprecated(jsdoc);
204
+
205
+ // Build unified middleware declarations (single source of truth for runtime)
206
+ const middleware = this.buildMiddlewareDeclarations(jsdoc);
207
+
185
208
  // Check for static keyword on the method
186
209
  const isStaticMethod = member.modifiers?.some(m => m.kind === ts.SyntaxKind.StaticKeyword) || false;
187
210
 
@@ -204,6 +227,20 @@ export class SchemaExtractor {
204
227
  ...(webhook !== undefined ? { webhook } : {}),
205
228
  ...(scheduled ? { scheduled } : {}),
206
229
  ...(locked !== undefined ? { locked } : {}),
230
+ // Functional tags (individual fields for backward compat)
231
+ ...(fallback ? { fallback } : {}),
232
+ ...(logged ? { logged } : {}),
233
+ ...(circuitBreaker ? { circuitBreaker } : {}),
234
+ ...(cached ? { cached } : {}),
235
+ ...(timeout ? { timeout } : {}),
236
+ ...(retryable ? { retryable } : {}),
237
+ ...(throttled ? { throttled } : {}),
238
+ ...(debounced ? { debounced } : {}),
239
+ ...(queued ? { queued } : {}),
240
+ ...(validations && validations.length > 0 ? { validations } : {}),
241
+ ...(deprecated !== undefined ? { deprecated } : {}),
242
+ // Unified middleware declarations (new — runtime uses this)
243
+ ...(middleware.length > 0 ? { middleware } : {}),
207
244
  });
208
245
  }
209
246
  };
@@ -712,13 +749,159 @@ export class SchemaExtractor {
712
749
  return initializer.getText(sourceFile);
713
750
  }
714
751
 
752
+ // ═══════════════════════════════════════════════════════════════════════════════
753
+ // INLINE CONFIG + @use PARSING
754
+ // ═══════════════════════════════════════════════════════════════════════════════
755
+
756
+ /**
757
+ * Parse inline {@prop value} config from a string
758
+ * @example parseInlineConfig('{@ttl 5m} {@key params.userId}')
759
+ * → { ttl: '5m', key: 'params.userId' }
760
+ */
761
+ parseInlineConfig(text: string): Record<string, string> {
762
+ const config: Record<string, string> = {};
763
+ const regex = /\{@(\w+)\s+([^}]+)\}/g;
764
+ let match;
765
+ while ((match = regex.exec(text)) !== null) {
766
+ config[match[1]] = match[2].trim();
767
+ }
768
+ return config;
769
+ }
770
+
771
+ /**
772
+ * Extract @use declarations from JSDoc
773
+ * @example extractUseDeclarations('* @use audit {@level info} {@tags api}')
774
+ * → [{ name: 'audit', rawConfig: { level: 'info', tags: 'api' } }]
775
+ */
776
+ extractUseDeclarations(jsdocContent: string): Array<{ name: string; rawConfig: Record<string, string> }> {
777
+ const declarations: Array<{ name: string; rawConfig: Record<string, string> }> = [];
778
+ const regex = /@use\s+([\w][\w-]*)((?:\s+\{@\w+\s+[^}]+\})*)/g;
779
+ let match;
780
+ while ((match = regex.exec(jsdocContent)) !== null) {
781
+ const name = match[1];
782
+ const configStr = match[2] || '';
783
+ const rawConfig = this.parseInlineConfig(configStr);
784
+ declarations.push({ name, rawConfig });
785
+ }
786
+ return declarations;
787
+ }
788
+
789
+ /**
790
+ * Build middleware declarations from all tags on a method's JSDoc.
791
+ * Converts both sugar tags (@cached 5m) and @use tags into a unified
792
+ * MiddlewareDeclaration[] array.
793
+ */
794
+ buildMiddlewareDeclarations(jsdocContent: string): MiddlewareDeclaration[] {
795
+ const declarations: MiddlewareDeclaration[] = [];
796
+
797
+ // 1. Extract sugar tags → convert to declarations
798
+
799
+ // @fallback
800
+ const fallback = this.extractFallback(jsdocContent);
801
+ if (fallback) {
802
+ const def = builtinRegistry.get('fallback');
803
+ declarations.push({ name: 'fallback', config: fallback, phase: def?.phase ?? 3 });
804
+ }
805
+
806
+ // @logged
807
+ const logged = this.extractLogged(jsdocContent);
808
+ if (logged) {
809
+ const def = builtinRegistry.get('logged');
810
+ declarations.push({ name: 'logged', config: logged, phase: def?.phase ?? 5 });
811
+ }
812
+
813
+ // @circuitBreaker
814
+ const circuitBreaker = this.extractCircuitBreaker(jsdocContent);
815
+ if (circuitBreaker) {
816
+ const def = builtinRegistry.get('circuitBreaker');
817
+ declarations.push({ name: 'circuitBreaker', config: circuitBreaker, phase: def?.phase ?? 8 });
818
+ }
819
+
820
+ // @cached
821
+ const cached = this.extractCached(jsdocContent);
822
+ if (cached) {
823
+ const def = builtinRegistry.get('cached');
824
+ declarations.push({ name: 'cached', config: cached, phase: def?.phase ?? 30 });
825
+ }
826
+
827
+ // @timeout
828
+ const timeout = this.extractTimeout(jsdocContent);
829
+ if (timeout) {
830
+ const def = builtinRegistry.get('timeout');
831
+ declarations.push({ name: 'timeout', config: timeout, phase: def?.phase ?? 70 });
832
+ }
833
+
834
+ // @retryable
835
+ const retryable = this.extractRetryable(jsdocContent);
836
+ if (retryable) {
837
+ const def = builtinRegistry.get('retryable');
838
+ declarations.push({ name: 'retryable', config: retryable, phase: def?.phase ?? 80 });
839
+ }
840
+
841
+ // @throttled
842
+ const throttled = this.extractThrottled(jsdocContent);
843
+ if (throttled) {
844
+ const def = builtinRegistry.get('throttled');
845
+ declarations.push({ name: 'throttled', config: throttled, phase: def?.phase ?? 10 });
846
+ }
847
+
848
+ // @debounced
849
+ const debounced = this.extractDebounced(jsdocContent);
850
+ if (debounced) {
851
+ const def = builtinRegistry.get('debounced');
852
+ declarations.push({ name: 'debounced', config: debounced, phase: def?.phase ?? 20 });
853
+ }
854
+
855
+ // @queued
856
+ const queued = this.extractQueued(jsdocContent);
857
+ if (queued) {
858
+ const def = builtinRegistry.get('queued');
859
+ declarations.push({ name: 'queued', config: queued, phase: def?.phase ?? 50 });
860
+ }
861
+
862
+ // @validate
863
+ const validations = this.extractValidations(jsdocContent);
864
+ if (validations && validations.length > 0) {
865
+ const def = builtinRegistry.get('validate');
866
+ declarations.push({ name: 'validate', config: { validations }, phase: def?.phase ?? 40 });
867
+ }
868
+
869
+ // @locked (handled as middleware when it appears as a functional tag)
870
+ const lockedMatch = jsdocContent.match(/@locked(?:\s+(\w[\w\-:]*))?/i);
871
+ if (lockedMatch) {
872
+ const lockName = lockedMatch[1]?.trim() || '';
873
+ const def = builtinRegistry.get('locked');
874
+ declarations.push({ name: 'locked', config: { name: lockName }, phase: def?.phase ?? 60 });
875
+ }
876
+
877
+ // 2. Extract @use declarations
878
+ const useDecls = this.extractUseDeclarations(jsdocContent);
879
+ for (const { name, rawConfig } of useDecls) {
880
+ // Check if this is a built-in (allow @use cached {@ttl 5m} syntax)
881
+ const def = builtinRegistry.get(name);
882
+ if (def) {
883
+ // Use parseConfig if available, otherwise pass raw
884
+ const config = def.parseConfig ? def.parseConfig(rawConfig) : rawConfig;
885
+ // Don't duplicate if sugar tag already added this middleware
886
+ if (!declarations.some(d => d.name === name)) {
887
+ declarations.push({ name, config, phase: def.phase ?? 45 });
888
+ }
889
+ } else {
890
+ // Custom middleware — phase defaults to 45
891
+ declarations.push({ name, config: rawConfig, phase: 45 });
892
+ }
893
+ }
894
+
895
+ return declarations;
896
+ }
897
+
715
898
  /**
716
899
  * Extract main description from JSDoc comment
717
900
  */
718
901
  private extractDescription(jsdocContent: string): string {
719
902
  // Split by @tags that appear at start of a JSDoc line (after optional * prefix)
720
903
  // This avoids matching @tag references inline in description text
721
- const beforeTags = jsdocContent.split(/(?:^|\n)\s*\*?\s*@(?:param|example|returns?|throws?|see|since|deprecated|version|author|license|ui|icon|format|stateful|autorun|async|webhook|cron|scheduled|locked|Template|Static|mcp|photon|cli|tags|dependencies|csp|visibility)\b/)[0];
904
+ const beforeTags = jsdocContent.split(/(?:^|\n)\s*\*?\s*@(?:param|example|returns?|throws?|see|since|deprecated|version|author|license|ui|icon|format|stateful|autorun|async|webhook|cron|scheduled|locked|fallback|logged|circuitBreaker|cached|timeout|retryable|throttled|debounced|queued|validate|use|Template|Static|mcp|photon|cli|tags|dependencies|csp|visibility)\b/)[0];
722
905
 
723
906
  // Remove leading * from each line and trim
724
907
  const lines = beforeTags
@@ -1237,6 +1420,153 @@ export class SchemaExtractor {
1237
1420
  return undefined;
1238
1421
  }
1239
1422
 
1423
+ // ═══════════════════════════════════════════════════════════════════════════════
1424
+ // FUNCTIONAL TAG EXTRACTION
1425
+ // ═══════════════════════════════════════════════════════════════════════════════
1426
+
1427
+ /**
1428
+ * Extract logging config from @logged tag
1429
+ * - @logged → info level
1430
+ * - @logged debug → debug level
1431
+ */
1432
+ private extractLogged(jsdocContent: string): { level: string } | undefined {
1433
+ const match = jsdocContent.match(/@logged(?:\s+(\w+))?/i);
1434
+ if (!match) return undefined;
1435
+ return { level: match[1]?.trim() || 'info' };
1436
+ }
1437
+
1438
+ /**
1439
+ * Extract fallback value from @fallback tag
1440
+ * - @fallback [] → empty array on error
1441
+ * - @fallback {} → empty object on error
1442
+ * - @fallback null → null on error
1443
+ * - @fallback 0 → zero on error
1444
+ */
1445
+ private extractFallback(jsdocContent: string): { value: string } | undefined {
1446
+ const match = jsdocContent.match(/@fallback\s+(.+?)(?:\s*$|\s*\n|\s*\*)/m);
1447
+ if (!match) return undefined;
1448
+ return { value: match[1].trim() };
1449
+ }
1450
+
1451
+ /**
1452
+ * Extract circuit breaker configuration from @circuitBreaker tag
1453
+ * - @circuitBreaker 5 30s → 5 failures, 30s reset
1454
+ * - @circuitBreaker 3 1m → 3 failures, 1 minute reset
1455
+ */
1456
+ private extractCircuitBreaker(jsdocContent: string): { threshold: number; resetAfter: string } | undefined {
1457
+ const match = jsdocContent.match(/@circuitBreaker\s+(\d+)\s+(\d+(?:ms|s|sec|m|min|h|hr|d|day))/i);
1458
+ if (!match) return undefined;
1459
+ return { threshold: parseInt(match[1], 10), resetAfter: match[2] };
1460
+ }
1461
+
1462
+ /**
1463
+ * Extract cache configuration from @cached tag
1464
+ * - @cached → default 5m TTL
1465
+ * - @cached 30s → 30 second TTL
1466
+ * - @cached 1h → 1 hour TTL
1467
+ */
1468
+ private extractCached(jsdocContent: string): { ttl: number } | undefined {
1469
+ const match = jsdocContent.match(/@cached(?:\s+(\d+(?:ms|s|sec|m|min|h|hr|d|day)))?/i);
1470
+ if (!match) return undefined;
1471
+ const ttl = match[1] ? parseDuration(match[1]) : 300_000; // default 5m
1472
+ return { ttl };
1473
+ }
1474
+
1475
+ /**
1476
+ * Extract timeout configuration from @timeout tag
1477
+ * - @timeout 30s → 30 second timeout
1478
+ * - @timeout 5m → 5 minute timeout
1479
+ */
1480
+ private extractTimeout(jsdocContent: string): { ms: number } | undefined {
1481
+ const match = jsdocContent.match(/@timeout\s+(\d+(?:ms|s|sec|m|min|h|hr|d|day))/i);
1482
+ if (!match) return undefined;
1483
+ return { ms: parseDuration(match[1]) };
1484
+ }
1485
+
1486
+ /**
1487
+ * Extract retry configuration from @retryable tag
1488
+ * - @retryable → default 3 retries, 1s delay
1489
+ * - @retryable 5 → 5 retries, 1s delay
1490
+ * - @retryable 3 2s → 3 retries, 2s delay
1491
+ */
1492
+ private extractRetryable(jsdocContent: string): { count: number; delay: number } | undefined {
1493
+ const match = jsdocContent.match(/@retryable(?:\s+(\d+))?(?:\s+(\d+(?:ms|s|sec|m|min|h|hr|d|day)))?/i);
1494
+ if (!match) return undefined;
1495
+ const count = match[1] ? parseInt(match[1], 10) : 3;
1496
+ const delay = match[2] ? parseDuration(match[2]) : 1_000;
1497
+ return { count, delay };
1498
+ }
1499
+
1500
+ /**
1501
+ * Extract throttle configuration from @throttled tag
1502
+ * - @throttled 10/min → 10 calls per minute
1503
+ * - @throttled 100/h → 100 calls per hour
1504
+ */
1505
+ private extractThrottled(jsdocContent: string): { count: number; windowMs: number } | undefined {
1506
+ const match = jsdocContent.match(/@throttled\s+(\d+\/(?:s|sec|m|min|h|hr|d|day))/i);
1507
+ if (!match) return undefined;
1508
+ return parseRate(match[1]);
1509
+ }
1510
+
1511
+ /**
1512
+ * Extract debounce configuration from @debounced tag
1513
+ * - @debounced → default 500ms
1514
+ * - @debounced 200ms → 200ms delay
1515
+ * - @debounced 1s → 1 second delay
1516
+ */
1517
+ private extractDebounced(jsdocContent: string): { delay: number } | undefined {
1518
+ const match = jsdocContent.match(/@debounced(?:\s+(\d+(?:ms|s|sec|m|min|h|hr|d|day)))?/i);
1519
+ if (!match) return undefined;
1520
+ const delay = match[1] ? parseDuration(match[1]) : 500;
1521
+ return { delay };
1522
+ }
1523
+
1524
+ /**
1525
+ * Extract queue configuration from @queued tag
1526
+ * - @queued → default concurrency 1
1527
+ * - @queued 3 → concurrency 3
1528
+ */
1529
+ private extractQueued(jsdocContent: string): { concurrency: number } | undefined {
1530
+ const match = jsdocContent.match(/@queued(?:\s+(\d+))?/i);
1531
+ if (!match) return undefined;
1532
+ const concurrency = match[1] ? parseInt(match[1], 10) : 1;
1533
+ return { concurrency };
1534
+ }
1535
+
1536
+ /**
1537
+ * Extract validation rules from @validate tags
1538
+ * - @validate params.email must be a valid email
1539
+ * - @validate params.amount must be positive
1540
+ */
1541
+ private extractValidations(jsdocContent: string): Array<{ field: string; rule: string }> | undefined {
1542
+ const validations: Array<{ field: string; rule: string }> = [];
1543
+ const regex = /@validate\s+([\w.]+)\s+(.+)/g;
1544
+ let match;
1545
+ while ((match = regex.exec(jsdocContent)) !== null) {
1546
+ validations.push({
1547
+ field: match[1].replace(/^params\./, ''), // strip params. prefix
1548
+ rule: match[2].trim().replace(/\*\/$/, '').trim(), // strip JSDoc closing
1549
+ });
1550
+ }
1551
+ return validations.length > 0 ? validations : undefined;
1552
+ }
1553
+
1554
+ /**
1555
+ * Extract deprecation notice from @deprecated tag (class-level, not param-level)
1556
+ * - @deprecated → true
1557
+ * - @deprecated Use addV2 instead → "Use addV2 instead"
1558
+ *
1559
+ * Note: Only matches @deprecated at the start of a JSDoc line (after * prefix),
1560
+ * NOT inside {@deprecated} inline tags (those are param-level).
1561
+ */
1562
+ private extractDeprecated(jsdocContent: string): string | true | undefined {
1563
+ // Match @deprecated at start of line (with optional * prefix), not inside {}
1564
+ const match = jsdocContent.match(/(?:^|\n)\s*\*?\s*@deprecated(?:\s+([^\n*]+))?/i);
1565
+ if (!match) return undefined;
1566
+ const message = match[1]?.trim();
1567
+ return message || true;
1568
+ }
1569
+
1240
1570
  /**
1241
1571
  * Extract URI pattern from @Static tag
1242
1572
  * Example: @Static github://repos/{owner}/{repo}/readme
@@ -1663,7 +1993,7 @@ export class SchemaExtractor {
1663
1993
  * * @prompt system ./prompts/system.md
1664
1994
  * * @resource config ./resources/config.json
1665
1995
  * *\/
1666
- * export default class MyPhoton extends PhotonMCP { ... }
1996
+ * export default class MyPhoton extends Photon { ... }
1667
1997
  * ```
1668
1998
  */
1669
1999
  extractAssets(source: string, assetFolder?: string): PhotonAssets {
@@ -1828,3 +2158,29 @@ export class SchemaExtractor {
1828
2158
  return mimeTypes[ext] || 'application/octet-stream';
1829
2159
  }
1830
2160
  }
2161
+
2162
+ /**
2163
+ * Capability types that can be auto-detected from source code
2164
+ */
2165
+ export type PhotonCapability = 'emit' | 'memory' | 'call' | 'mcp' | 'lock' | 'instanceMeta' | 'allInstances';
2166
+
2167
+ /**
2168
+ * Detect capabilities used by a Photon from its source code.
2169
+ *
2170
+ * Scans for `this.emit(`, `this.memory`, `this.call(`, etc. patterns
2171
+ * and returns the set of capabilities that the runtime should inject.
2172
+ *
2173
+ * This enables plain classes (no extends Photon) to use all framework
2174
+ * features — the loader detects usage and injects automatically.
2175
+ */
2176
+ export function detectCapabilities(source: string): Set<PhotonCapability> {
2177
+ const caps = new Set<PhotonCapability>();
2178
+ if (/this\.emit\s*\(/.test(source)) caps.add('emit');
2179
+ if (/this\.memory\b/.test(source)) caps.add('memory');
2180
+ if (/this\.call\s*\(/.test(source)) caps.add('call');
2181
+ if (/this\.mcp\s*\(/.test(source)) caps.add('mcp');
2182
+ if (/this\.withLock\s*\(/.test(source)) caps.add('lock');
2183
+ if (/this\.instanceMeta\b/.test(source)) caps.add('instanceMeta');
2184
+ if (/this\.allInstances\s*\(/.test(source)) caps.add('allInstances');
2185
+ return caps;
2186
+ }
package/src/stateful.ts CHANGED
@@ -690,6 +690,8 @@ export interface MaybeStatefulResult<T> {
690
690
  result?: T;
691
691
  /** Error message (if failed) */
692
692
  error?: string;
693
+ /** Original error object (preserves .name for typed errors like PhotonTimeoutError) */
694
+ originalError?: Error;
693
695
  /** Run ID (only if stateful - checkpoint was yielded) */
694
696
  runId?: string;
695
697
  /** Was this a stateful workflow? */
@@ -821,6 +823,7 @@ export async function maybeStatefulExecute<T>(
821
823
  } catch (error: any) {
822
824
  return {
823
825
  error: error.message,
826
+ originalError: error instanceof Error ? error : undefined,
824
827
  isStateful: false,
825
828
  resumed: false,
826
829
  checkpointsCompleted: 0,
@@ -947,6 +950,7 @@ export async function maybeStatefulExecute<T>(
947
950
 
948
951
  return {
949
952
  error: error.message,
953
+ originalError: error instanceof Error ? error : undefined,
950
954
  runId,
951
955
  isStateful,
952
956
  resumed: false,
package/src/types.ts CHANGED
@@ -96,9 +96,104 @@ export interface ExtractedSchema {
96
96
  * - string: custom lock name (e.g., @locked board:write)
97
97
  */
98
98
  locked?: boolean | string;
99
+
100
+ // ═══ FUNCTIONAL TAGS ═══
101
+
102
+ /**
103
+ * Logging configuration (from @logged tag)
104
+ * Auto-logs method execution with timing and error details
105
+ * @example @logged or @logged debug
106
+ */
107
+ logged?: { level: string };
108
+
109
+ /**
110
+ * Fallback value on error (from @fallback tag)
111
+ * Returns this value instead of throwing when the method fails
112
+ * @example @fallback []
113
+ */
114
+ fallback?: { value: string };
115
+
116
+ /**
117
+ * Circuit breaker configuration (from @circuitBreaker tag)
118
+ * Fast-rejects after N consecutive failures, probes after reset period
119
+ * @example @circuitBreaker 5 30s
120
+ */
121
+ circuitBreaker?: { threshold: number; resetAfter: string };
122
+
123
+ /**
124
+ * Cache configuration (from @cached tag)
125
+ * Memoize return value for the specified TTL
126
+ * @example @cached 5m
127
+ */
128
+ cached?: { ttl: number };
129
+
130
+ /**
131
+ * Execution timeout in ms (from @timeout tag)
132
+ * Rejects with TimeoutError if method doesn't resolve in time
133
+ * @example @timeout 30s
134
+ */
135
+ timeout?: { ms: number };
136
+
137
+ /**
138
+ * Auto-retry configuration (from @retryable tag)
139
+ * Retries on failure with delay between attempts
140
+ * @example @retryable 3 1s
141
+ */
142
+ retryable?: { count: number; delay: number };
143
+
144
+ /**
145
+ * Rate limiting configuration (from @throttled tag)
146
+ * At most N calls per time window
147
+ * @example @throttled 10/min
148
+ */
149
+ throttled?: { count: number; windowMs: number };
150
+
151
+ /**
152
+ * Debounce configuration (from @debounced tag)
153
+ * Collapses rapid repeated calls — only the last one executes
154
+ * @example @debounced 500ms
155
+ */
156
+ debounced?: { delay: number };
157
+
158
+ /**
159
+ * Queue configuration (from @queued tag)
160
+ * At most N concurrent executions, excess calls are queued
161
+ * @example @queued 1
162
+ */
163
+ queued?: { concurrency: number };
164
+
165
+ /**
166
+ * Custom validation rules (from @validate tag)
167
+ * Runtime validation beyond JSON Schema
168
+ * @example @validate params.email must be a valid email
169
+ */
170
+ validations?: Array<{ field: string; rule: string }>;
171
+
172
+ /**
173
+ * Deprecation notice (from @deprecated tag)
174
+ * Tool still works but shows warnings everywhere
175
+ * @example @deprecated Use addV2 instead
176
+ */
177
+ deprecated?: string | true;
178
+
179
+ // ═══ MIDDLEWARE PIPELINE ═══
180
+
181
+ /**
182
+ * Unified middleware declarations (from @use tags and sugar tags)
183
+ * Single source of truth for the runtime middleware pipeline.
184
+ * Replaces individual functional tag fields for execution purposes.
185
+ */
186
+ middleware?: Array<{
187
+ name: string;
188
+ config: Record<string, any>;
189
+ phase: number;
190
+ }>;
191
+
192
+ /** When true, method uses individual params instead of a single params object */
193
+ simpleParams?: boolean;
99
194
  }
100
195
 
101
- export interface PhotonMCPClass {
196
+ export interface PhotonClass {
102
197
  name: string;
103
198
  description?: string;
104
199
  tools: PhotonTool[];
@@ -107,6 +202,9 @@ export interface PhotonMCPClass {
107
202
  classConstructor?: Record<string, Function>;
108
203
  }
109
204
 
205
+ /** @deprecated Use PhotonClass instead */
206
+ export type PhotonMCPClass = PhotonClass;
207
+
110
208
  export interface ConstructorParam {
111
209
  name: string;
112
210
  type: string;
@@ -154,7 +252,7 @@ export interface ResolvedInjection {
154
252
  * * @mcp github anthropics/mcp-server-github
155
253
  * * @mcp fs npm:@modelcontextprotocol/server-filesystem
156
254
  * *\/
157
- * export default class MyPhoton extends PhotonMCP {
255
+ * export default class MyPhoton extends Photon {
158
256
  * async doSomething() {
159
257
  * const issues = await this.github.list_issues({ repo: 'owner/repo' });
160
258
  * }
@@ -380,9 +478,9 @@ export interface StaticInfo {
380
478
  }
381
479
 
382
480
  /**
383
- * Extended PhotonMCPClass with templates and statics
481
+ * Extended PhotonClass with templates and statics
384
482
  */
385
- export interface PhotonMCPClassExtended extends PhotonMCPClass {
483
+ export interface PhotonClassExtended extends PhotonClass {
386
484
  templates: TemplateInfo[];
387
485
  statics: StaticInfo[];
388
486
  /** Assets from the Photon's asset folder (UI, prompts, resources) */
@@ -391,6 +489,9 @@ export interface PhotonMCPClassExtended extends PhotonMCPClass {
391
489
  injectedPhotons?: string[];
392
490
  }
393
491
 
492
+ /** @deprecated Use PhotonClassExtended instead */
493
+ export type PhotonMCPClassExtended = PhotonClassExtended;
494
+
394
495
  // ══════════════════════════════════════════════════════════════════════════════
395
496
  // STATEFUL WORKFLOW TYPES
396
497
  // ══════════════════════════════════════════════════════════════════════════════
@@ -575,7 +676,7 @@ export interface ConfigParam {
575
676
  *
576
677
  * Example:
577
678
  * ```typescript
578
- * export default class MyPhoton extends PhotonMCP {
679
+ * export default class MyPhoton extends Photon {
579
680
  * async configure(params: {
580
681
  * apiEndpoint: string;
581
682
  * maxRetries?: number;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Duration and rate parsing utilities for functional tags
3
+ *
4
+ * Supports duration strings: 30s, 5m, 1h, 1d, 500ms
5
+ * Supports rate expressions: 10/min, 100/h, 5/s
6
+ */
7
+
8
+ const DURATION_MULTIPLIERS: Record<string, number> = {
9
+ ms: 1,
10
+ s: 1_000,
11
+ sec: 1_000,
12
+ m: 60_000,
13
+ min: 60_000,
14
+ h: 3_600_000,
15
+ hr: 3_600_000,
16
+ d: 86_400_000,
17
+ day: 86_400_000,
18
+ };
19
+
20
+ /**
21
+ * Parse a duration string into milliseconds
22
+ * @example parseDuration('30s') → 30000
23
+ * @example parseDuration('5m') → 300000
24
+ * @example parseDuration('500ms') → 500
25
+ * @example parseDuration('1234') → 1234 (raw ms fallback)
26
+ */
27
+ export function parseDuration(input: string): number {
28
+ const trimmed = input.trim();
29
+ const match = trimmed.match(/^(\d+(?:\.\d+)?)(ms|s|sec|m|min|h|hr|d|day)$/i);
30
+ if (match) {
31
+ const value = parseFloat(match[1]);
32
+ const unit = match[2].toLowerCase();
33
+ return Math.round(value * DURATION_MULTIPLIERS[unit]);
34
+ }
35
+ // Fallback: raw milliseconds
36
+ const raw = parseInt(trimmed, 10);
37
+ return isNaN(raw) ? 0 : raw;
38
+ }
39
+
40
+ const RATE_WINDOW_MULTIPLIERS: Record<string, number> = {
41
+ s: 1_000,
42
+ sec: 1_000,
43
+ m: 60_000,
44
+ min: 60_000,
45
+ h: 3_600_000,
46
+ hr: 3_600_000,
47
+ d: 86_400_000,
48
+ day: 86_400_000,
49
+ };
50
+
51
+ /**
52
+ * Parse a rate expression into count and window
53
+ * @example parseRate('10/min') → { count: 10, windowMs: 60000 }
54
+ * @example parseRate('100/h') → { count: 100, windowMs: 3600000 }
55
+ */
56
+ export function parseRate(input: string): { count: number; windowMs: number } {
57
+ const trimmed = input.trim();
58
+ const match = trimmed.match(/^(\d+)\/(s|sec|m|min|h|hr|d|day)$/i);
59
+ if (match) {
60
+ const count = parseInt(match[1], 10);
61
+ const unit = match[2].toLowerCase();
62
+ return { count, windowMs: RATE_WINDOW_MULTIPLIERS[unit] };
63
+ }
64
+ // Fallback: treat as count per minute
65
+ const count = parseInt(trimmed, 10);
66
+ return { count: isNaN(count) ? 10 : count, windowMs: 60_000 };
67
+ }