@portel/photon-core 2.22.0 → 2.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/dist/base.d.ts +56 -0
  2. package/dist/base.d.ts.map +1 -1
  3. package/dist/base.js +100 -2
  4. package/dist/base.js.map +1 -1
  5. package/dist/bases-registry.d.ts +57 -0
  6. package/dist/bases-registry.d.ts.map +1 -0
  7. package/dist/bases-registry.js +127 -0
  8. package/dist/bases-registry.js.map +1 -0
  9. package/dist/data-paths.d.ts +31 -18
  10. package/dist/data-paths.d.ts.map +1 -1
  11. package/dist/data-paths.js +42 -43
  12. package/dist/data-paths.js.map +1 -1
  13. package/dist/description-sanitizer.d.ts +34 -0
  14. package/dist/description-sanitizer.d.ts.map +1 -0
  15. package/dist/description-sanitizer.js +80 -0
  16. package/dist/description-sanitizer.js.map +1 -0
  17. package/dist/index.d.ts +4 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +7 -2
  20. package/dist/index.js.map +1 -1
  21. package/dist/memory.d.ts.map +1 -1
  22. package/dist/memory.js +109 -1
  23. package/dist/memory.js.map +1 -1
  24. package/dist/middleware.d.ts.map +1 -1
  25. package/dist/middleware.js +96 -0
  26. package/dist/middleware.js.map +1 -1
  27. package/dist/mixins.d.ts.map +1 -1
  28. package/dist/mixins.js +9 -2
  29. package/dist/mixins.js.map +1 -1
  30. package/dist/path-resolver.d.ts +13 -1
  31. package/dist/path-resolver.d.ts.map +1 -1
  32. package/dist/path-resolver.js +23 -1
  33. package/dist/path-resolver.js.map +1 -1
  34. package/dist/photon-loader-lite.d.ts +17 -2
  35. package/dist/photon-loader-lite.d.ts.map +1 -1
  36. package/dist/photon-loader-lite.js +203 -26
  37. package/dist/photon-loader-lite.js.map +1 -1
  38. package/dist/schedule.d.ts +10 -1
  39. package/dist/schedule.d.ts.map +1 -1
  40. package/dist/schedule.js +20 -10
  41. package/dist/schedule.js.map +1 -1
  42. package/dist/schema-extractor.d.ts +9 -3
  43. package/dist/schema-extractor.d.ts.map +1 -1
  44. package/dist/schema-extractor.js +149 -17
  45. package/dist/schema-extractor.js.map +1 -1
  46. package/dist/stateful.d.ts +3 -2
  47. package/dist/stateful.d.ts.map +1 -1
  48. package/dist/stateful.js +18 -6
  49. package/dist/stateful.js.map +1 -1
  50. package/dist/types.d.ts +9 -1
  51. package/dist/types.d.ts.map +1 -1
  52. package/dist/types.js.map +1 -1
  53. package/package.json +2 -2
  54. package/src/base.ts +123 -2
  55. package/src/bases-registry.ts +141 -0
  56. package/src/data-paths.ts +43 -49
  57. package/src/description-sanitizer.ts +102 -0
  58. package/src/index.ts +20 -1
  59. package/src/memory.ts +109 -0
  60. package/src/middleware.ts +98 -0
  61. package/src/mixins.ts +14 -2
  62. package/src/path-resolver.ts +26 -1
  63. package/src/photon-loader-lite.ts +214 -33
  64. package/src/schedule.ts +26 -10
  65. package/src/schema-extractor.ts +164 -17
  66. package/src/stateful.ts +19 -6
  67. package/src/types.ts +9 -0
@@ -11,6 +11,21 @@ import * as fs from 'fs/promises';
11
11
  import * as ts from 'typescript';
12
12
  import { parseDuration, parseRate } from './utils/duration.js';
13
13
  import { builtinRegistry } from './middleware.js';
14
+ import { sanitizeDescription } from './description-sanitizer.js';
15
+ // Warn once per unique (method, rule) pair so poisoned photons don't spam.
16
+ const sanitizeWarnings = new Set();
17
+ // Track which `handle*` method names have already emitted a deprecation
18
+ // warning this process so large photons don't spam the console.
19
+ const handlePrefixWarned = new Set();
20
+ function warnHandlePrefixOnce(methodName) {
21
+ if (handlePrefixWarned.has(methodName))
22
+ return;
23
+ handlePrefixWarned.add(methodName);
24
+ // eslint-disable-next-line no-console
25
+ console.error(`[photon] deprecation: method "${methodName}" is being auto-registered as a ` +
26
+ `webhook via the legacy handle* prefix. Add an explicit "@webhook" JSDoc ` +
27
+ `tag; the prefix convention will be removed in the next minor release.`);
28
+ }
14
29
  /**
15
30
  * Extract schemas from a Photon MCP class file
16
31
  */
@@ -309,6 +324,31 @@ export class SchemaExtractor {
309
324
  propertyDocs.set(propMatch[1], propMatch[2].trim());
310
325
  }
311
326
  const properties = [];
327
+ // Inline-JSDoc helper: pull the description from a /** ... */ comment
328
+ // that immediately precedes a property assignment inside the object
329
+ // literal. Class-level @property tags still win when both are
330
+ // present (so existing photons aren't disrupted).
331
+ const sourceText = sourceFile.text;
332
+ const inlineDescriptionFor = (prop) => {
333
+ const ranges = ts.getLeadingCommentRanges(sourceText, prop.pos);
334
+ if (!ranges || ranges.length === 0)
335
+ return undefined;
336
+ // Use the last comment block before the property (closest to it).
337
+ const block = ranges[ranges.length - 1];
338
+ if (sourceText[block.pos] !== '/' ||
339
+ sourceText[block.pos + 1] !== '*' ||
340
+ sourceText[block.pos + 2] !== '*') {
341
+ return undefined;
342
+ }
343
+ const raw = sourceText.slice(block.pos + 3, block.end - 2);
344
+ // Strip leading "*" markers, trim, collapse whitespace.
345
+ return raw
346
+ .split('\n')
347
+ .map((line) => line.replace(/^\s*\*\s?/, '').trim())
348
+ .filter(Boolean)
349
+ .join(' ')
350
+ .trim();
351
+ };
312
352
  for (const prop of member.initializer.properties) {
313
353
  if (!ts.isPropertyAssignment(prop))
314
354
  continue;
@@ -374,7 +414,7 @@ export class SchemaExtractor {
374
414
  properties.push({
375
415
  name: propName,
376
416
  type,
377
- description: propertyDocs.get(propName),
417
+ description: propertyDocs.get(propName) ?? inlineDescriptionFor(prop),
378
418
  default: defaultValue,
379
419
  required,
380
420
  });
@@ -837,10 +877,34 @@ export class SchemaExtractor {
837
877
  const params = [];
838
878
  try {
839
879
  const sourceFile = ts.createSourceFile('temp.ts', source, ts.ScriptTarget.Latest, true);
880
+ // Per-parameter JSDoc: prefer an inline /** ... */ block immediately
881
+ // before the parameter (the natural way authors write it), fall back
882
+ // to a constructor-level @param tag.
883
+ const sourceText = sourceFile.text;
884
+ const inlineDescriptionFor = (param) => {
885
+ const ranges = ts.getLeadingCommentRanges(sourceText, param.pos);
886
+ if (!ranges || ranges.length === 0)
887
+ return undefined;
888
+ const block = ranges[ranges.length - 1];
889
+ if (sourceText[block.pos] !== '/' ||
890
+ sourceText[block.pos + 1] !== '*' ||
891
+ sourceText[block.pos + 2] !== '*') {
892
+ return undefined;
893
+ }
894
+ const raw = sourceText.slice(block.pos + 3, block.end - 2);
895
+ return raw
896
+ .split('\n')
897
+ .map((line) => line.replace(/^\s*\*\s?/, '').trim())
898
+ .filter(Boolean)
899
+ .join(' ')
900
+ .trim();
901
+ };
840
902
  const visit = (node) => {
841
903
  if (ts.isClassDeclaration(node)) {
842
904
  node.members.forEach((member) => {
843
905
  if (ts.isConstructorDeclaration(member)) {
906
+ const ctorJsdoc = this.getJSDocComment(member, sourceFile);
907
+ const ctorParamDocs = this.extractParamDocs(ctorJsdoc);
844
908
  member.parameters.forEach((param) => {
845
909
  if (param.name && ts.isIdentifier(param.name)) {
846
910
  const name = param.name.getText(sourceFile);
@@ -858,6 +922,7 @@ export class SchemaExtractor {
858
922
  hasDefault,
859
923
  defaultValue,
860
924
  isPrimitive: this.isPrimitiveType(type),
925
+ description: inlineDescriptionFor(param) ?? ctorParamDocs.get(name),
861
926
  });
862
927
  }
863
928
  });
@@ -1074,6 +1139,27 @@ export class SchemaExtractor {
1074
1139
  const def = builtinRegistry.get('locked');
1075
1140
  declarations.push({ name: 'locked', config: { name: lockName }, phase: def?.phase ?? 60 });
1076
1141
  }
1142
+ // @mask <field1,field2,...> — redact named fields from the response.
1143
+ // Accepts comma- or whitespace-separated field names.
1144
+ const maskMatch = jsdocContent.match(/@mask\s+([^\n@]+)/i);
1145
+ if (maskMatch) {
1146
+ const def = builtinRegistry.get('mask');
1147
+ const rawValue = maskMatch[1].trim();
1148
+ const config = def?.parseShorthand
1149
+ ? def.parseShorthand(rawValue)
1150
+ : { fields: rawValue.split(/[,\s]+/).filter(Boolean), placeholder: '[REDACTED]' };
1151
+ declarations.push({ name: 'mask', config, phase: def?.phase ?? 85 });
1152
+ }
1153
+ // @maxResponseBytes <N> — cap serialized response size.
1154
+ const maxBytesMatch = jsdocContent.match(/@maxResponseBytes\s+(\d+)/i);
1155
+ if (maxBytesMatch) {
1156
+ const def = builtinRegistry.get('maxResponseBytes');
1157
+ const rawValue = maxBytesMatch[1];
1158
+ const config = def?.parseShorthand
1159
+ ? def.parseShorthand(rawValue)
1160
+ : { limit: parseInt(rawValue, 10) || 0 };
1161
+ declarations.push({ name: 'maxResponseBytes', config, phase: def?.phase ?? 88 });
1162
+ }
1077
1163
  // 2. Extract @use declarations
1078
1164
  const useDecls = this.extractUseDeclarations(jsdocContent);
1079
1165
  for (const { name, rawConfig } of useDecls) {
@@ -1100,7 +1186,7 @@ export class SchemaExtractor {
1100
1186
  extractDescription(jsdocContent) {
1101
1187
  // Split by @tags that appear at start of a JSDoc line (after optional * prefix)
1102
1188
  // This avoids matching @tag references inline in description text
1103
- 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|auth)\b/)[0];
1189
+ 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|auth|mask|maxResponseBytes)\b/)[0];
1104
1190
  // Remove leading * from each line and trim
1105
1191
  const lines = beforeTags
1106
1192
  .split('\n')
@@ -1135,8 +1221,25 @@ export class SchemaExtractor {
1135
1221
  prevWasBlank = false;
1136
1222
  }
1137
1223
  const description = parts.join('');
1138
- // Clean up multiple spaces
1139
- return description.replace(/\s+/g, ' ').trim() || 'No description';
1224
+ // Clean up multiple spaces, then defend against tool-description poisoning.
1225
+ const collapsed = description.replace(/\s+/g, ' ').trim() || 'No description';
1226
+ const { cleaned, warnings, truncated } = sanitizeDescription(collapsed);
1227
+ if (warnings.length > 0 || truncated) {
1228
+ for (const w of warnings) {
1229
+ const key = `${w.rule}|${w.sample}`;
1230
+ if (sanitizeWarnings.has(key))
1231
+ continue;
1232
+ sanitizeWarnings.add(key);
1233
+ // eslint-disable-next-line no-console
1234
+ console.warn(`[photon] description sanitizer: redacted ${w.rule} (sample: "${w.sample}")`);
1235
+ }
1236
+ if (truncated && !sanitizeWarnings.has('truncated')) {
1237
+ sanitizeWarnings.add('truncated');
1238
+ // eslint-disable-next-line no-console
1239
+ console.warn('[photon] description sanitizer: truncated oversized description');
1240
+ }
1241
+ }
1242
+ return cleaned;
1140
1243
  }
1141
1244
  /**
1142
1245
  * Extract parameter descriptions from JSDoc @param tags
@@ -1790,10 +1893,15 @@ export class SchemaExtractor {
1790
1893
  // DAEMON FEATURE EXTRACTION
1791
1894
  // ═══════════════════════════════════════════════════════════════════════════════
1792
1895
  /**
1793
- * Extract webhook configuration from @webhook tag or handle* prefix
1896
+ * Extract webhook configuration from @webhook tag or handle* prefix.
1794
1897
  * - @webhook → use method name as path
1795
1898
  * - @webhook stripe → custom path "stripe"
1796
- * - handle* prefix → auto-detected as webhook
1899
+ * - handle* prefix → auto-detected as webhook (DEPRECATED)
1900
+ *
1901
+ * The handle* prefix is a legacy convention being removed. It still
1902
+ * works for one release but emits a one-time stderr warning per
1903
+ * method so users can migrate to the explicit @webhook tag before
1904
+ * the convention is dropped.
1797
1905
  */
1798
1906
  extractWebhook(jsdocContent, methodName) {
1799
1907
  // Check for @webhook tag with optional path
@@ -1804,8 +1912,9 @@ export class SchemaExtractor {
1804
1912
  // Return custom path if specified, otherwise true for bare @webhook
1805
1913
  return path || true;
1806
1914
  }
1807
- // Check for handle* prefix (convention)
1915
+ // Check for handle* prefix (legacy convention — deprecated).
1808
1916
  if (methodName.startsWith('handle')) {
1917
+ warnHandlePrefixOnce(methodName);
1809
1918
  return true;
1810
1919
  }
1811
1920
  return undefined;
@@ -2091,6 +2200,10 @@ export class SchemaExtractor {
2091
2200
  if (['panels', 'tabs', 'accordion', 'stack', 'columns'].includes(format)) {
2092
2201
  return format;
2093
2202
  }
2203
+ // Match declarative UI formats (A2UI v0.9 rides on AG-UI)
2204
+ if (format === 'a2ui') {
2205
+ return 'a2ui';
2206
+ }
2094
2207
  return undefined;
2095
2208
  }
2096
2209
  /**
@@ -2630,34 +2743,53 @@ export class SchemaExtractor {
2630
2743
  return mimeTypes[ext] || 'application/octet-stream';
2631
2744
  }
2632
2745
  }
2746
+ /**
2747
+ * Match a `this`-like base in source code. Covers:
2748
+ * this — literal
2749
+ * (this as any) — TS `as` cast (the most common workaround when
2750
+ * TypeScript can't see a runtime-injected method)
2751
+ * (this as SomeClass), (this as unknown as T)
2752
+ * (<any>this), (<T>this) — older angle-bracket cast syntax
2753
+ *
2754
+ * Not covered: aliasing (`const self = this`), destructuring
2755
+ * (`const { call } = this`), or bracket access (`this['call']`).
2756
+ * Those require dataflow analysis which a regex can't do; the loader
2757
+ * compensates by always-injecting the cheap convenience methods whose
2758
+ * gating would otherwise silently fail for those patterns.
2759
+ */
2760
+ const THIS_BASE = String.raw `(?:\bthis\b|\(\s*<[^>]+>\s*this\s*\)|\(\s*this\s+as\s+[^)]+\))`;
2761
+ function memberAccess(name, trailing) {
2762
+ return new RegExp(`${THIS_BASE}\\s*\\.\\s*${name}\\s*${trailing}`);
2763
+ }
2633
2764
  /**
2634
2765
  * Detect capabilities used by a Photon from its source code.
2635
2766
  *
2636
2767
  * Scans for `this.emit(`, `this.memory`, `this.call(`, etc. patterns
2637
- * and returns the set of capabilities that the runtime should inject.
2768
+ * (including typed-access workarounds like `(this as any).call(`) and
2769
+ * returns the set of capabilities that the runtime should inject.
2638
2770
  *
2639
2771
  * This enables plain classes (no extends Photon) to use all framework
2640
2772
  * features — the loader detects usage and injects automatically.
2641
2773
  */
2642
2774
  export function detectCapabilities(source) {
2643
2775
  const caps = new Set();
2644
- if (/this\.emit\s*\(/.test(source))
2776
+ if (memberAccess('emit', '\\(').test(source))
2645
2777
  caps.add('emit');
2646
- if (/this\.render\s*\(/.test(source))
2778
+ if (memberAccess('render', '\\(').test(source))
2647
2779
  caps.add('emit'); // render() needs emit injection
2648
- if (/this\.memory\b/.test(source))
2780
+ if (memberAccess('memory', '\\b').test(source))
2649
2781
  caps.add('memory');
2650
- if (/this\.call\s*\(/.test(source))
2782
+ if (memberAccess('call', '\\(').test(source))
2651
2783
  caps.add('call');
2652
- if (/this\.mcp\s*\(/.test(source))
2784
+ if (memberAccess('mcp', '\\(').test(source))
2653
2785
  caps.add('mcp');
2654
- if (/this\.withLock\s*\(/.test(source))
2786
+ if (memberAccess('withLock', '\\(').test(source))
2655
2787
  caps.add('lock');
2656
- if (/this\.instanceMeta\b/.test(source))
2788
+ if (memberAccess('instanceMeta', '\\b').test(source))
2657
2789
  caps.add('instanceMeta');
2658
- if (/this\.allInstances\s*\(/.test(source))
2790
+ if (memberAccess('allInstances', '\\(').test(source))
2659
2791
  caps.add('allInstances');
2660
- if (/this\.caller\b/.test(source))
2792
+ if (memberAccess('caller', '\\b').test(source))
2661
2793
  caps.add('caller');
2662
2794
  return caps;
2663
2795
  }