@portel/photon-core 2.8.3 → 2.9.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 (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 +362 -10
  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 +381 -10
  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[];
@@ -182,6 +184,22 @@ export class SchemaExtractor {
182
184
  const scheduled = this.extractScheduled(jsdoc, methodName);
183
185
  const locked = this.extractLocked(jsdoc, methodName);
184
186
 
187
+ // Functional tags — individual fields kept for backward compat
188
+ const fallback = this.extractFallback(jsdoc);
189
+ const logged = this.extractLogged(jsdoc);
190
+ const circuitBreaker = this.extractCircuitBreaker(jsdoc);
191
+ const cached = this.extractCached(jsdoc);
192
+ const timeout = this.extractTimeout(jsdoc);
193
+ const retryable = this.extractRetryable(jsdoc);
194
+ const throttled = this.extractThrottled(jsdoc);
195
+ const debounced = this.extractDebounced(jsdoc);
196
+ const queued = this.extractQueued(jsdoc);
197
+ const validations = this.extractValidations(jsdoc);
198
+ const deprecated = this.extractDeprecated(jsdoc);
199
+
200
+ // Build unified middleware declarations (single source of truth for runtime)
201
+ const middleware = this.buildMiddlewareDeclarations(jsdoc);
202
+
185
203
  // Check for static keyword on the method
186
204
  const isStaticMethod = member.modifiers?.some(m => m.kind === ts.SyntaxKind.StaticKeyword) || false;
187
205
 
@@ -204,6 +222,20 @@ export class SchemaExtractor {
204
222
  ...(webhook !== undefined ? { webhook } : {}),
205
223
  ...(scheduled ? { scheduled } : {}),
206
224
  ...(locked !== undefined ? { locked } : {}),
225
+ // Functional tags (individual fields for backward compat)
226
+ ...(fallback ? { fallback } : {}),
227
+ ...(logged ? { logged } : {}),
228
+ ...(circuitBreaker ? { circuitBreaker } : {}),
229
+ ...(cached ? { cached } : {}),
230
+ ...(timeout ? { timeout } : {}),
231
+ ...(retryable ? { retryable } : {}),
232
+ ...(throttled ? { throttled } : {}),
233
+ ...(debounced ? { debounced } : {}),
234
+ ...(queued ? { queued } : {}),
235
+ ...(validations && validations.length > 0 ? { validations } : {}),
236
+ ...(deprecated !== undefined ? { deprecated } : {}),
237
+ // Unified middleware declarations (new — runtime uses this)
238
+ ...(middleware.length > 0 ? { middleware } : {}),
207
239
  });
208
240
  }
209
241
  };
@@ -712,28 +744,194 @@ export class SchemaExtractor {
712
744
  return initializer.getText(sourceFile);
713
745
  }
714
746
 
747
+ // ═══════════════════════════════════════════════════════════════════════════════
748
+ // INLINE CONFIG + @use PARSING
749
+ // ═══════════════════════════════════════════════════════════════════════════════
750
+
751
+ /**
752
+ * Parse inline {@prop value} config from a string
753
+ * @example parseInlineConfig('{@ttl 5m} {@key params.userId}')
754
+ * → { ttl: '5m', key: 'params.userId' }
755
+ */
756
+ parseInlineConfig(text: string): Record<string, string> {
757
+ const config: Record<string, string> = {};
758
+ const regex = /\{@(\w+)\s+([^}]+)\}/g;
759
+ let match;
760
+ while ((match = regex.exec(text)) !== null) {
761
+ config[match[1]] = match[2].trim();
762
+ }
763
+ return config;
764
+ }
765
+
766
+ /**
767
+ * Extract @use declarations from JSDoc
768
+ * @example extractUseDeclarations('* @use audit {@level info} {@tags api}')
769
+ * → [{ name: 'audit', rawConfig: { level: 'info', tags: 'api' } }]
770
+ */
771
+ extractUseDeclarations(jsdocContent: string): Array<{ name: string; rawConfig: Record<string, string> }> {
772
+ const declarations: Array<{ name: string; rawConfig: Record<string, string> }> = [];
773
+ const regex = /@use\s+([\w][\w-]*)((?:\s+\{@\w+\s+[^}]+\})*)/g;
774
+ let match;
775
+ while ((match = regex.exec(jsdocContent)) !== null) {
776
+ const name = match[1];
777
+ const configStr = match[2] || '';
778
+ const rawConfig = this.parseInlineConfig(configStr);
779
+ declarations.push({ name, rawConfig });
780
+ }
781
+ return declarations;
782
+ }
783
+
784
+ /**
785
+ * Build middleware declarations from all tags on a method's JSDoc.
786
+ * Converts both sugar tags (@cached 5m) and @use tags into a unified
787
+ * MiddlewareDeclaration[] array.
788
+ */
789
+ buildMiddlewareDeclarations(jsdocContent: string): MiddlewareDeclaration[] {
790
+ const declarations: MiddlewareDeclaration[] = [];
791
+
792
+ // 1. Extract sugar tags → convert to declarations
793
+
794
+ // @fallback
795
+ const fallback = this.extractFallback(jsdocContent);
796
+ if (fallback) {
797
+ const def = builtinRegistry.get('fallback');
798
+ declarations.push({ name: 'fallback', config: fallback, phase: def?.phase ?? 3 });
799
+ }
800
+
801
+ // @logged
802
+ const logged = this.extractLogged(jsdocContent);
803
+ if (logged) {
804
+ const def = builtinRegistry.get('logged');
805
+ declarations.push({ name: 'logged', config: logged, phase: def?.phase ?? 5 });
806
+ }
807
+
808
+ // @circuitBreaker
809
+ const circuitBreaker = this.extractCircuitBreaker(jsdocContent);
810
+ if (circuitBreaker) {
811
+ const def = builtinRegistry.get('circuitBreaker');
812
+ declarations.push({ name: 'circuitBreaker', config: circuitBreaker, phase: def?.phase ?? 8 });
813
+ }
814
+
815
+ // @cached
816
+ const cached = this.extractCached(jsdocContent);
817
+ if (cached) {
818
+ const def = builtinRegistry.get('cached');
819
+ declarations.push({ name: 'cached', config: cached, phase: def?.phase ?? 30 });
820
+ }
821
+
822
+ // @timeout
823
+ const timeout = this.extractTimeout(jsdocContent);
824
+ if (timeout) {
825
+ const def = builtinRegistry.get('timeout');
826
+ declarations.push({ name: 'timeout', config: timeout, phase: def?.phase ?? 70 });
827
+ }
828
+
829
+ // @retryable
830
+ const retryable = this.extractRetryable(jsdocContent);
831
+ if (retryable) {
832
+ const def = builtinRegistry.get('retryable');
833
+ declarations.push({ name: 'retryable', config: retryable, phase: def?.phase ?? 80 });
834
+ }
835
+
836
+ // @throttled
837
+ const throttled = this.extractThrottled(jsdocContent);
838
+ if (throttled) {
839
+ const def = builtinRegistry.get('throttled');
840
+ declarations.push({ name: 'throttled', config: throttled, phase: def?.phase ?? 10 });
841
+ }
842
+
843
+ // @debounced
844
+ const debounced = this.extractDebounced(jsdocContent);
845
+ if (debounced) {
846
+ const def = builtinRegistry.get('debounced');
847
+ declarations.push({ name: 'debounced', config: debounced, phase: def?.phase ?? 20 });
848
+ }
849
+
850
+ // @queued
851
+ const queued = this.extractQueued(jsdocContent);
852
+ if (queued) {
853
+ const def = builtinRegistry.get('queued');
854
+ declarations.push({ name: 'queued', config: queued, phase: def?.phase ?? 50 });
855
+ }
856
+
857
+ // @validate
858
+ const validations = this.extractValidations(jsdocContent);
859
+ if (validations && validations.length > 0) {
860
+ const def = builtinRegistry.get('validate');
861
+ declarations.push({ name: 'validate', config: { validations }, phase: def?.phase ?? 40 });
862
+ }
863
+
864
+ // @locked (handled as middleware when it appears as a functional tag)
865
+ const lockedMatch = jsdocContent.match(/@locked(?:\s+(\w[\w\-:]*))?/i);
866
+ if (lockedMatch) {
867
+ const lockName = lockedMatch[1]?.trim() || '';
868
+ const def = builtinRegistry.get('locked');
869
+ declarations.push({ name: 'locked', config: { name: lockName }, phase: def?.phase ?? 60 });
870
+ }
871
+
872
+ // 2. Extract @use declarations
873
+ const useDecls = this.extractUseDeclarations(jsdocContent);
874
+ for (const { name, rawConfig } of useDecls) {
875
+ // Check if this is a built-in (allow @use cached {@ttl 5m} syntax)
876
+ const def = builtinRegistry.get(name);
877
+ if (def) {
878
+ // Use parseConfig if available, otherwise pass raw
879
+ const config = def.parseConfig ? def.parseConfig(rawConfig) : rawConfig;
880
+ // Don't duplicate if sugar tag already added this middleware
881
+ if (!declarations.some(d => d.name === name)) {
882
+ declarations.push({ name, config, phase: def.phase ?? 45 });
883
+ }
884
+ } else {
885
+ // Custom middleware — phase defaults to 45
886
+ declarations.push({ name, config: rawConfig, phase: 45 });
887
+ }
888
+ }
889
+
890
+ return declarations;
891
+ }
892
+
715
893
  /**
716
894
  * Extract main description from JSDoc comment
717
895
  */
718
896
  private extractDescription(jsdocContent: string): string {
719
- // Split by @param to get only the description part (also stop at other @tags)
720
- const beforeTags = jsdocContent.split(/@(?: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];
897
+ // Split by @tags that appear at start of a JSDoc line (after optional * prefix)
898
+ // This avoids matching @tag references inline in description text
899
+ 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];
721
900
 
722
901
  // Remove leading * from each line and trim
723
902
  const lines = beforeTags
724
903
  .split('\n')
725
- .map((line) => line.trim().replace(/^\*\s?/, ''))
726
- .filter((line) => line !== ''); // Keep non-empty lines
904
+ .map((line) => line.trim().replace(/^\*\s?/, ''));
727
905
 
728
- // Take lines up to the first markdown heading (## sections are extended docs)
729
- const descLines: string[] = [];
906
+ // Build description with paragraph-aware joining:
907
+ // - Blank lines = paragraph boundaries (insert period separator)
908
+ // - Non-blank consecutive lines = continuation (join with space)
909
+ let prevWasBlank = false;
910
+ const parts: string[] = [];
730
911
  for (const line of lines) {
912
+ if (line.length === 0) {
913
+ prevWasBlank = true;
914
+ continue;
915
+ }
916
+ // Stop at markdown headings (## sections are extended docs)
731
917
  if (line.startsWith('#')) break;
732
- descLines.push(line);
918
+
919
+ if (parts.length === 0) {
920
+ parts.push(line);
921
+ } else if (prevWasBlank) {
922
+ // Paragraph break — add period if previous part doesn't end with punctuation
923
+ const prev = parts[parts.length - 1];
924
+ const needsPeriod = !/[.!?:,;]$/.test(prev);
925
+ parts[parts.length - 1] = prev + (needsPeriod ? '. ' : ' ');
926
+ parts.push(line);
927
+ } else {
928
+ // Continuation line — join with space
929
+ parts[parts.length - 1] += ' ' + line;
930
+ }
931
+ prevWasBlank = false;
733
932
  }
734
933
 
735
- // Join all description lines into a single string
736
- const description = descLines.join(' ');
934
+ const description = parts.join('');
737
935
 
738
936
  // Clean up multiple spaces
739
937
  return description.replace(/\s+/g, ' ').trim() || 'No description';
@@ -1217,6 +1415,153 @@ export class SchemaExtractor {
1217
1415
  return undefined;
1218
1416
  }
1219
1417
 
1418
+ // ═══════════════════════════════════════════════════════════════════════════════
1419
+ // FUNCTIONAL TAG EXTRACTION
1420
+ // ═══════════════════════════════════════════════════════════════════════════════
1421
+
1422
+ /**
1423
+ * Extract logging config from @logged tag
1424
+ * - @logged → info level
1425
+ * - @logged debug → debug level
1426
+ */
1427
+ private extractLogged(jsdocContent: string): { level: string } | undefined {
1428
+ const match = jsdocContent.match(/@logged(?:\s+(\w+))?/i);
1429
+ if (!match) return undefined;
1430
+ return { level: match[1]?.trim() || 'info' };
1431
+ }
1432
+
1433
+ /**
1434
+ * Extract fallback value from @fallback tag
1435
+ * - @fallback [] → empty array on error
1436
+ * - @fallback {} → empty object on error
1437
+ * - @fallback null → null on error
1438
+ * - @fallback 0 → zero on error
1439
+ */
1440
+ private extractFallback(jsdocContent: string): { value: string } | undefined {
1441
+ const match = jsdocContent.match(/@fallback\s+(.+?)(?:\s*$|\s*\n|\s*\*)/m);
1442
+ if (!match) return undefined;
1443
+ return { value: match[1].trim() };
1444
+ }
1445
+
1446
+ /**
1447
+ * Extract circuit breaker configuration from @circuitBreaker tag
1448
+ * - @circuitBreaker 5 30s → 5 failures, 30s reset
1449
+ * - @circuitBreaker 3 1m → 3 failures, 1 minute reset
1450
+ */
1451
+ private extractCircuitBreaker(jsdocContent: string): { threshold: number; resetAfter: string } | undefined {
1452
+ const match = jsdocContent.match(/@circuitBreaker\s+(\d+)\s+(\d+(?:ms|s|sec|m|min|h|hr|d|day))/i);
1453
+ if (!match) return undefined;
1454
+ return { threshold: parseInt(match[1], 10), resetAfter: match[2] };
1455
+ }
1456
+
1457
+ /**
1458
+ * Extract cache configuration from @cached tag
1459
+ * - @cached → default 5m TTL
1460
+ * - @cached 30s → 30 second TTL
1461
+ * - @cached 1h → 1 hour TTL
1462
+ */
1463
+ private extractCached(jsdocContent: string): { ttl: number } | undefined {
1464
+ const match = jsdocContent.match(/@cached(?:\s+(\d+(?:ms|s|sec|m|min|h|hr|d|day)))?/i);
1465
+ if (!match) return undefined;
1466
+ const ttl = match[1] ? parseDuration(match[1]) : 300_000; // default 5m
1467
+ return { ttl };
1468
+ }
1469
+
1470
+ /**
1471
+ * Extract timeout configuration from @timeout tag
1472
+ * - @timeout 30s → 30 second timeout
1473
+ * - @timeout 5m → 5 minute timeout
1474
+ */
1475
+ private extractTimeout(jsdocContent: string): { ms: number } | undefined {
1476
+ const match = jsdocContent.match(/@timeout\s+(\d+(?:ms|s|sec|m|min|h|hr|d|day))/i);
1477
+ if (!match) return undefined;
1478
+ return { ms: parseDuration(match[1]) };
1479
+ }
1480
+
1481
+ /**
1482
+ * Extract retry configuration from @retryable tag
1483
+ * - @retryable → default 3 retries, 1s delay
1484
+ * - @retryable 5 → 5 retries, 1s delay
1485
+ * - @retryable 3 2s → 3 retries, 2s delay
1486
+ */
1487
+ private extractRetryable(jsdocContent: string): { count: number; delay: number } | undefined {
1488
+ const match = jsdocContent.match(/@retryable(?:\s+(\d+))?(?:\s+(\d+(?:ms|s|sec|m|min|h|hr|d|day)))?/i);
1489
+ if (!match) return undefined;
1490
+ const count = match[1] ? parseInt(match[1], 10) : 3;
1491
+ const delay = match[2] ? parseDuration(match[2]) : 1_000;
1492
+ return { count, delay };
1493
+ }
1494
+
1495
+ /**
1496
+ * Extract throttle configuration from @throttled tag
1497
+ * - @throttled 10/min → 10 calls per minute
1498
+ * - @throttled 100/h → 100 calls per hour
1499
+ */
1500
+ private extractThrottled(jsdocContent: string): { count: number; windowMs: number } | undefined {
1501
+ const match = jsdocContent.match(/@throttled\s+(\d+\/(?:s|sec|m|min|h|hr|d|day))/i);
1502
+ if (!match) return undefined;
1503
+ return parseRate(match[1]);
1504
+ }
1505
+
1506
+ /**
1507
+ * Extract debounce configuration from @debounced tag
1508
+ * - @debounced → default 500ms
1509
+ * - @debounced 200ms → 200ms delay
1510
+ * - @debounced 1s → 1 second delay
1511
+ */
1512
+ private extractDebounced(jsdocContent: string): { delay: number } | undefined {
1513
+ const match = jsdocContent.match(/@debounced(?:\s+(\d+(?:ms|s|sec|m|min|h|hr|d|day)))?/i);
1514
+ if (!match) return undefined;
1515
+ const delay = match[1] ? parseDuration(match[1]) : 500;
1516
+ return { delay };
1517
+ }
1518
+
1519
+ /**
1520
+ * Extract queue configuration from @queued tag
1521
+ * - @queued → default concurrency 1
1522
+ * - @queued 3 → concurrency 3
1523
+ */
1524
+ private extractQueued(jsdocContent: string): { concurrency: number } | undefined {
1525
+ const match = jsdocContent.match(/@queued(?:\s+(\d+))?/i);
1526
+ if (!match) return undefined;
1527
+ const concurrency = match[1] ? parseInt(match[1], 10) : 1;
1528
+ return { concurrency };
1529
+ }
1530
+
1531
+ /**
1532
+ * Extract validation rules from @validate tags
1533
+ * - @validate params.email must be a valid email
1534
+ * - @validate params.amount must be positive
1535
+ */
1536
+ private extractValidations(jsdocContent: string): Array<{ field: string; rule: string }> | undefined {
1537
+ const validations: Array<{ field: string; rule: string }> = [];
1538
+ const regex = /@validate\s+([\w.]+)\s+(.+)/g;
1539
+ let match;
1540
+ while ((match = regex.exec(jsdocContent)) !== null) {
1541
+ validations.push({
1542
+ field: match[1].replace(/^params\./, ''), // strip params. prefix
1543
+ rule: match[2].trim().replace(/\*\/$/, '').trim(), // strip JSDoc closing
1544
+ });
1545
+ }
1546
+ return validations.length > 0 ? validations : undefined;
1547
+ }
1548
+
1549
+ /**
1550
+ * Extract deprecation notice from @deprecated tag (class-level, not param-level)
1551
+ * - @deprecated → true
1552
+ * - @deprecated Use addV2 instead → "Use addV2 instead"
1553
+ *
1554
+ * Note: Only matches @deprecated at the start of a JSDoc line (after * prefix),
1555
+ * NOT inside {@deprecated} inline tags (those are param-level).
1556
+ */
1557
+ private extractDeprecated(jsdocContent: string): string | true | undefined {
1558
+ // Match @deprecated at start of line (with optional * prefix), not inside {}
1559
+ const match = jsdocContent.match(/(?:^|\n)\s*\*?\s*@deprecated(?:\s+([^\n*]+))?/i);
1560
+ if (!match) return undefined;
1561
+ const message = match[1]?.trim();
1562
+ return message || true;
1563
+ }
1564
+
1220
1565
  /**
1221
1566
  * Extract URI pattern from @Static tag
1222
1567
  * Example: @Static github://repos/{owner}/{repo}/readme
@@ -1643,7 +1988,7 @@ export class SchemaExtractor {
1643
1988
  * * @prompt system ./prompts/system.md
1644
1989
  * * @resource config ./resources/config.json
1645
1990
  * *\/
1646
- * export default class MyPhoton extends PhotonMCP { ... }
1991
+ * export default class MyPhoton extends Photon { ... }
1647
1992
  * ```
1648
1993
  */
1649
1994
  extractAssets(source: string, assetFolder?: string): PhotonAssets {
@@ -1808,3 +2153,29 @@ export class SchemaExtractor {
1808
2153
  return mimeTypes[ext] || 'application/octet-stream';
1809
2154
  }
1810
2155
  }
2156
+
2157
+ /**
2158
+ * Capability types that can be auto-detected from source code
2159
+ */
2160
+ export type PhotonCapability = 'emit' | 'memory' | 'call' | 'mcp' | 'lock' | 'instanceMeta' | 'allInstances';
2161
+
2162
+ /**
2163
+ * Detect capabilities used by a Photon from its source code.
2164
+ *
2165
+ * Scans for `this.emit(`, `this.memory`, `this.call(`, etc. patterns
2166
+ * and returns the set of capabilities that the runtime should inject.
2167
+ *
2168
+ * This enables plain classes (no extends Photon) to use all framework
2169
+ * features — the loader detects usage and injects automatically.
2170
+ */
2171
+ export function detectCapabilities(source: string): Set<PhotonCapability> {
2172
+ const caps = new Set<PhotonCapability>();
2173
+ if (/this\.emit\s*\(/.test(source)) caps.add('emit');
2174
+ if (/this\.memory\b/.test(source)) caps.add('memory');
2175
+ if (/this\.call\s*\(/.test(source)) caps.add('call');
2176
+ if (/this\.mcp\s*\(/.test(source)) caps.add('mcp');
2177
+ if (/this\.withLock\s*\(/.test(source)) caps.add('lock');
2178
+ if (/this\.instanceMeta\b/.test(source)) caps.add('instanceMeta');
2179
+ if (/this\.allInstances\s*\(/.test(source)) caps.add('allInstances');
2180
+ return caps;
2181
+ }
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
+ }