@portel/photon-core 2.23.0 → 2.25.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 (49) hide show
  1. package/dist/base.d.ts +114 -0
  2. package/dist/base.d.ts.map +1 -1
  3. package/dist/base.js +195 -2
  4. package/dist/base.js.map +1 -1
  5. package/dist/description-sanitizer.d.ts +34 -0
  6. package/dist/description-sanitizer.d.ts.map +1 -0
  7. package/dist/description-sanitizer.js +80 -0
  8. package/dist/description-sanitizer.js.map +1 -0
  9. package/dist/generator.d.ts +102 -0
  10. package/dist/generator.d.ts.map +1 -1
  11. package/dist/generator.js.map +1 -1
  12. package/dist/index.d.ts +2 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +1 -0
  15. package/dist/index.js.map +1 -1
  16. package/dist/memory.d.ts.map +1 -1
  17. package/dist/memory.js +28 -0
  18. package/dist/memory.js.map +1 -1
  19. package/dist/middleware.d.ts.map +1 -1
  20. package/dist/middleware.js +96 -0
  21. package/dist/middleware.js.map +1 -1
  22. package/dist/mixins.d.ts.map +1 -1
  23. package/dist/mixins.js +9 -2
  24. package/dist/mixins.js.map +1 -1
  25. package/dist/photon-loader-lite.js +41 -0
  26. package/dist/photon-loader-lite.js.map +1 -1
  27. package/dist/schedule.d.ts +41 -2
  28. package/dist/schedule.d.ts.map +1 -1
  29. package/dist/schedule.js +72 -16
  30. package/dist/schedule.js.map +1 -1
  31. package/dist/schema-extractor.d.ts +2 -1
  32. package/dist/schema-extractor.d.ts.map +1 -1
  33. package/dist/schema-extractor.js +135 -14
  34. package/dist/schema-extractor.js.map +1 -1
  35. package/dist/types.d.ts +9 -1
  36. package/dist/types.d.ts.map +1 -1
  37. package/dist/types.js.map +1 -1
  38. package/package.json +2 -2
  39. package/src/base.ts +224 -2
  40. package/src/description-sanitizer.ts +102 -0
  41. package/src/generator.ts +93 -0
  42. package/src/index.ts +12 -0
  43. package/src/memory.ts +28 -0
  44. package/src/middleware.ts +98 -0
  45. package/src/mixins.ts +14 -2
  46. package/src/photon-loader-lite.ts +38 -0
  47. package/src/schedule.ts +98 -14
  48. package/src/schema-extractor.ts +147 -14
  49. package/src/types.ts +9 -0
@@ -11,6 +11,9 @@ 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();
14
17
  // Track which `handle*` method names have already emitted a deprecation
15
18
  // warning this process so large photons don't spam the console.
16
19
  const handlePrefixWarned = new Set();
@@ -321,6 +324,31 @@ export class SchemaExtractor {
321
324
  propertyDocs.set(propMatch[1], propMatch[2].trim());
322
325
  }
323
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
+ };
324
352
  for (const prop of member.initializer.properties) {
325
353
  if (!ts.isPropertyAssignment(prop))
326
354
  continue;
@@ -386,7 +414,7 @@ export class SchemaExtractor {
386
414
  properties.push({
387
415
  name: propName,
388
416
  type,
389
- description: propertyDocs.get(propName),
417
+ description: propertyDocs.get(propName) ?? inlineDescriptionFor(prop),
390
418
  default: defaultValue,
391
419
  required,
392
420
  });
@@ -849,10 +877,34 @@ export class SchemaExtractor {
849
877
  const params = [];
850
878
  try {
851
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
+ };
852
902
  const visit = (node) => {
853
903
  if (ts.isClassDeclaration(node)) {
854
904
  node.members.forEach((member) => {
855
905
  if (ts.isConstructorDeclaration(member)) {
906
+ const ctorJsdoc = this.getJSDocComment(member, sourceFile);
907
+ const ctorParamDocs = this.extractParamDocs(ctorJsdoc);
856
908
  member.parameters.forEach((param) => {
857
909
  if (param.name && ts.isIdentifier(param.name)) {
858
910
  const name = param.name.getText(sourceFile);
@@ -870,6 +922,7 @@ export class SchemaExtractor {
870
922
  hasDefault,
871
923
  defaultValue,
872
924
  isPrimitive: this.isPrimitiveType(type),
925
+ description: inlineDescriptionFor(param) ?? ctorParamDocs.get(name),
873
926
  });
874
927
  }
875
928
  });
@@ -1086,6 +1139,27 @@ export class SchemaExtractor {
1086
1139
  const def = builtinRegistry.get('locked');
1087
1140
  declarations.push({ name: 'locked', config: { name: lockName }, phase: def?.phase ?? 60 });
1088
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
+ }
1089
1163
  // 2. Extract @use declarations
1090
1164
  const useDecls = this.extractUseDeclarations(jsdocContent);
1091
1165
  for (const { name, rawConfig } of useDecls) {
@@ -1112,7 +1186,7 @@ export class SchemaExtractor {
1112
1186
  extractDescription(jsdocContent) {
1113
1187
  // Split by @tags that appear at start of a JSDoc line (after optional * prefix)
1114
1188
  // This avoids matching @tag references inline in description text
1115
- 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];
1116
1190
  // Remove leading * from each line and trim
1117
1191
  const lines = beforeTags
1118
1192
  .split('\n')
@@ -1147,8 +1221,25 @@ export class SchemaExtractor {
1147
1221
  prevWasBlank = false;
1148
1222
  }
1149
1223
  const description = parts.join('');
1150
- // Clean up multiple spaces
1151
- 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;
1152
1243
  }
1153
1244
  /**
1154
1245
  * Extract parameter descriptions from JSDoc @param tags
@@ -2109,6 +2200,10 @@ export class SchemaExtractor {
2109
2200
  if (['panels', 'tabs', 'accordion', 'stack', 'columns'].includes(format)) {
2110
2201
  return format;
2111
2202
  }
2203
+ // Match declarative UI formats (A2UI v0.9 rides on AG-UI)
2204
+ if (format === 'a2ui') {
2205
+ return 'a2ui';
2206
+ }
2112
2207
  return undefined;
2113
2208
  }
2114
2209
  /**
@@ -2648,34 +2743,60 @@ export class SchemaExtractor {
2648
2743
  return mimeTypes[ext] || 'application/octet-stream';
2649
2744
  }
2650
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
+ // The `(this as <T>)` alternative allows one level of nested parens inside
2761
+ // the type annotation so function-type syntax like `(k: string) => void`
2762
+ // doesn't truncate the match. Without the `(?:[^()]|\([^()]*\))+` fallback,
2763
+ // `(this as unknown as { memory: { set: (k: string) => Promise<void> } })`
2764
+ // would terminate at the first inner `)` and the trailing `.memory` access
2765
+ // would never be seen — silently disabling this.memory injection for any
2766
+ // plain class that uses a complex TS type cast to reach memory.
2767
+ const THIS_BASE = String.raw `(?:\bthis\b|\(\s*<[^>]+>\s*this\s*\)|\(\s*this\s+as\s+(?:[^()]|\([^()]*\))+\))`;
2768
+ function memberAccess(name, trailing) {
2769
+ return new RegExp(`${THIS_BASE}\\s*\\.\\s*${name}\\s*${trailing}`);
2770
+ }
2651
2771
  /**
2652
2772
  * Detect capabilities used by a Photon from its source code.
2653
2773
  *
2654
2774
  * Scans for `this.emit(`, `this.memory`, `this.call(`, etc. patterns
2655
- * and returns the set of capabilities that the runtime should inject.
2775
+ * (including typed-access workarounds like `(this as any).call(`) and
2776
+ * returns the set of capabilities that the runtime should inject.
2656
2777
  *
2657
2778
  * This enables plain classes (no extends Photon) to use all framework
2658
2779
  * features — the loader detects usage and injects automatically.
2659
2780
  */
2660
2781
  export function detectCapabilities(source) {
2661
2782
  const caps = new Set();
2662
- if (/this\.emit\s*\(/.test(source))
2783
+ if (memberAccess('emit', '\\(').test(source))
2663
2784
  caps.add('emit');
2664
- if (/this\.render\s*\(/.test(source))
2785
+ if (memberAccess('render', '\\(').test(source))
2665
2786
  caps.add('emit'); // render() needs emit injection
2666
- if (/this\.memory\b/.test(source))
2787
+ if (memberAccess('memory', '\\b').test(source))
2667
2788
  caps.add('memory');
2668
- if (/this\.call\s*\(/.test(source))
2789
+ if (memberAccess('call', '\\(').test(source))
2669
2790
  caps.add('call');
2670
- if (/this\.mcp\s*\(/.test(source))
2791
+ if (memberAccess('mcp', '\\(').test(source))
2671
2792
  caps.add('mcp');
2672
- if (/this\.withLock\s*\(/.test(source))
2793
+ if (memberAccess('withLock', '\\(').test(source))
2673
2794
  caps.add('lock');
2674
- if (/this\.instanceMeta\b/.test(source))
2795
+ if (memberAccess('instanceMeta', '\\b').test(source))
2675
2796
  caps.add('instanceMeta');
2676
- if (/this\.allInstances\s*\(/.test(source))
2797
+ if (memberAccess('allInstances', '\\(').test(source))
2677
2798
  caps.add('allInstances');
2678
- if (/this\.caller\b/.test(source))
2799
+ if (memberAccess('caller', '\\b').test(source))
2679
2800
  caps.add('caller');
2680
2801
  return caps;
2681
2802
  }