@portel/photon-core 2.23.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 (45) hide show
  1. package/dist/base.d.ts +37 -0
  2. package/dist/base.d.ts.map +1 -1
  3. package/dist/base.js +71 -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/index.d.ts +1 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +1 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/memory.d.ts.map +1 -1
  14. package/dist/memory.js +28 -0
  15. package/dist/memory.js.map +1 -1
  16. package/dist/middleware.d.ts.map +1 -1
  17. package/dist/middleware.js +96 -0
  18. package/dist/middleware.js.map +1 -1
  19. package/dist/mixins.d.ts.map +1 -1
  20. package/dist/mixins.js +9 -2
  21. package/dist/mixins.js.map +1 -1
  22. package/dist/photon-loader-lite.js +41 -0
  23. package/dist/photon-loader-lite.js.map +1 -1
  24. package/dist/schedule.d.ts +10 -1
  25. package/dist/schedule.d.ts.map +1 -1
  26. package/dist/schedule.js +20 -10
  27. package/dist/schedule.js.map +1 -1
  28. package/dist/schema-extractor.d.ts +2 -1
  29. package/dist/schema-extractor.d.ts.map +1 -1
  30. package/dist/schema-extractor.js +128 -14
  31. package/dist/schema-extractor.js.map +1 -1
  32. package/dist/types.d.ts +9 -1
  33. package/dist/types.d.ts.map +1 -1
  34. package/dist/types.js.map +1 -1
  35. package/package.json +2 -2
  36. package/src/base.ts +80 -2
  37. package/src/description-sanitizer.ts +102 -0
  38. package/src/index.ts +6 -0
  39. package/src/memory.ts +28 -0
  40. package/src/middleware.ts +98 -0
  41. package/src/mixins.ts +14 -2
  42. package/src/photon-loader-lite.ts +38 -0
  43. package/src/schedule.ts +26 -10
  44. package/src/schema-extractor.ts +140 -14
  45. package/src/types.ts +9 -0
@@ -13,6 +13,10 @@ import * as ts from 'typescript';
13
13
  import { ExtractedSchema, ConstructorParam, TemplateInfo, StaticInfo, OutputFormat, YieldInfo, MCPDependency, PhotonDependency, CLIDependency, ResolvedInjection, PhotonAssets, UIAsset, PromptAsset, ResourceAsset, ConfigSchema, ConfigParam, SettingsSchema, SettingsProperty, NotificationSubscription } from './types.js';
14
14
  import { parseDuration, parseRate } from './utils/duration.js';
15
15
  import { builtinRegistry, type MiddlewareDeclaration } from './middleware.js';
16
+ import { sanitizeDescription } from './description-sanitizer.js';
17
+
18
+ // Warn once per unique (method, rule) pair so poisoned photons don't spam.
19
+ const sanitizeWarnings = new Set<string>();
16
20
 
17
21
  // Track which `handle*` method names have already emitted a deprecation
18
22
  // warning this process so large photons don't spam the console.
@@ -396,6 +400,33 @@ export class SchemaExtractor {
396
400
 
397
401
  const properties: SettingsProperty[] = [];
398
402
 
403
+ // Inline-JSDoc helper: pull the description from a /** ... */ comment
404
+ // that immediately precedes a property assignment inside the object
405
+ // literal. Class-level @property tags still win when both are
406
+ // present (so existing photons aren't disrupted).
407
+ const sourceText = sourceFile.text;
408
+ const inlineDescriptionFor = (prop: ts.Node): string | undefined => {
409
+ const ranges = ts.getLeadingCommentRanges(sourceText, prop.pos);
410
+ if (!ranges || ranges.length === 0) return undefined;
411
+ // Use the last comment block before the property (closest to it).
412
+ const block = ranges[ranges.length - 1];
413
+ if (
414
+ sourceText[block.pos] !== '/' ||
415
+ sourceText[block.pos + 1] !== '*' ||
416
+ sourceText[block.pos + 2] !== '*'
417
+ ) {
418
+ return undefined;
419
+ }
420
+ const raw = sourceText.slice(block.pos + 3, block.end - 2);
421
+ // Strip leading "*" markers, trim, collapse whitespace.
422
+ return raw
423
+ .split('\n')
424
+ .map((line) => line.replace(/^\s*\*\s?/, '').trim())
425
+ .filter(Boolean)
426
+ .join(' ')
427
+ .trim();
428
+ };
429
+
399
430
  for (const prop of member.initializer.properties) {
400
431
  if (!ts.isPropertyAssignment(prop)) continue;
401
432
  const propName = prop.name.getText(sourceFile);
@@ -452,7 +483,7 @@ export class SchemaExtractor {
452
483
  properties.push({
453
484
  name: propName,
454
485
  type,
455
- description: propertyDocs.get(propName),
486
+ description: propertyDocs.get(propName) ?? inlineDescriptionFor(prop),
456
487
  default: defaultValue,
457
488
  required,
458
489
  });
@@ -996,10 +1027,36 @@ export class SchemaExtractor {
996
1027
  true
997
1028
  );
998
1029
 
1030
+ // Per-parameter JSDoc: prefer an inline /** ... */ block immediately
1031
+ // before the parameter (the natural way authors write it), fall back
1032
+ // to a constructor-level @param tag.
1033
+ const sourceText = sourceFile.text;
1034
+ const inlineDescriptionFor = (param: ts.Node): string | undefined => {
1035
+ const ranges = ts.getLeadingCommentRanges(sourceText, param.pos);
1036
+ if (!ranges || ranges.length === 0) return undefined;
1037
+ const block = ranges[ranges.length - 1];
1038
+ if (
1039
+ sourceText[block.pos] !== '/' ||
1040
+ sourceText[block.pos + 1] !== '*' ||
1041
+ sourceText[block.pos + 2] !== '*'
1042
+ ) {
1043
+ return undefined;
1044
+ }
1045
+ const raw = sourceText.slice(block.pos + 3, block.end - 2);
1046
+ return raw
1047
+ .split('\n')
1048
+ .map((line) => line.replace(/^\s*\*\s?/, '').trim())
1049
+ .filter(Boolean)
1050
+ .join(' ')
1051
+ .trim();
1052
+ };
1053
+
999
1054
  const visit = (node: ts.Node) => {
1000
1055
  if (ts.isClassDeclaration(node)) {
1001
1056
  node.members.forEach((member) => {
1002
1057
  if (ts.isConstructorDeclaration(member)) {
1058
+ const ctorJsdoc = this.getJSDocComment(member as any, sourceFile);
1059
+ const ctorParamDocs = this.extractParamDocs(ctorJsdoc);
1003
1060
  member.parameters.forEach((param) => {
1004
1061
  if (param.name && ts.isIdentifier(param.name)) {
1005
1062
  const name = param.name.getText(sourceFile);
@@ -1019,6 +1076,7 @@ export class SchemaExtractor {
1019
1076
  hasDefault,
1020
1077
  defaultValue,
1021
1078
  isPrimitive: this.isPrimitiveType(type),
1079
+ description: inlineDescriptionFor(param) ?? ctorParamDocs.get(name),
1022
1080
  });
1023
1081
  }
1024
1082
  });
@@ -1264,6 +1322,29 @@ export class SchemaExtractor {
1264
1322
  declarations.push({ name: 'locked', config: { name: lockName }, phase: def?.phase ?? 60 });
1265
1323
  }
1266
1324
 
1325
+ // @mask <field1,field2,...> — redact named fields from the response.
1326
+ // Accepts comma- or whitespace-separated field names.
1327
+ const maskMatch = jsdocContent.match(/@mask\s+([^\n@]+)/i);
1328
+ if (maskMatch) {
1329
+ const def = builtinRegistry.get('mask');
1330
+ const rawValue = maskMatch[1].trim();
1331
+ const config = def?.parseShorthand
1332
+ ? def.parseShorthand(rawValue)
1333
+ : { fields: rawValue.split(/[,\s]+/).filter(Boolean), placeholder: '[REDACTED]' };
1334
+ declarations.push({ name: 'mask', config, phase: def?.phase ?? 85 });
1335
+ }
1336
+
1337
+ // @maxResponseBytes <N> — cap serialized response size.
1338
+ const maxBytesMatch = jsdocContent.match(/@maxResponseBytes\s+(\d+)/i);
1339
+ if (maxBytesMatch) {
1340
+ const def = builtinRegistry.get('maxResponseBytes');
1341
+ const rawValue = maxBytesMatch[1];
1342
+ const config = def?.parseShorthand
1343
+ ? def.parseShorthand(rawValue)
1344
+ : { limit: parseInt(rawValue, 10) || 0 };
1345
+ declarations.push({ name: 'maxResponseBytes', config, phase: def?.phase ?? 88 });
1346
+ }
1347
+
1267
1348
  // 2. Extract @use declarations
1268
1349
  const useDecls = this.extractUseDeclarations(jsdocContent);
1269
1350
  for (const { name, rawConfig } of useDecls) {
@@ -1291,7 +1372,7 @@ export class SchemaExtractor {
1291
1372
  private extractDescription(jsdocContent: string): string {
1292
1373
  // Split by @tags that appear at start of a JSDoc line (after optional * prefix)
1293
1374
  // This avoids matching @tag references inline in description text
1294
- 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];
1375
+ 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];
1295
1376
 
1296
1377
  // Remove leading * from each line and trim
1297
1378
  const lines = beforeTags
@@ -1328,8 +1409,26 @@ export class SchemaExtractor {
1328
1409
 
1329
1410
  const description = parts.join('');
1330
1411
 
1331
- // Clean up multiple spaces
1332
- return description.replace(/\s+/g, ' ').trim() || 'No description';
1412
+ // Clean up multiple spaces, then defend against tool-description poisoning.
1413
+ const collapsed = description.replace(/\s+/g, ' ').trim() || 'No description';
1414
+ const { cleaned, warnings, truncated } = sanitizeDescription(collapsed);
1415
+ if (warnings.length > 0 || truncated) {
1416
+ for (const w of warnings) {
1417
+ const key = `${w.rule}|${w.sample}`;
1418
+ if (sanitizeWarnings.has(key)) continue;
1419
+ sanitizeWarnings.add(key);
1420
+ // eslint-disable-next-line no-console
1421
+ console.warn(
1422
+ `[photon] description sanitizer: redacted ${w.rule} (sample: "${w.sample}")`
1423
+ );
1424
+ }
1425
+ if (truncated && !sanitizeWarnings.has('truncated')) {
1426
+ sanitizeWarnings.add('truncated');
1427
+ // eslint-disable-next-line no-console
1428
+ console.warn('[photon] description sanitizer: truncated oversized description');
1429
+ }
1430
+ }
1431
+ return cleaned;
1333
1432
  }
1334
1433
 
1335
1434
  /**
@@ -2412,6 +2511,11 @@ export class SchemaExtractor {
2412
2511
  return format as OutputFormat;
2413
2512
  }
2414
2513
 
2514
+ // Match declarative UI formats (A2UI v0.9 rides on AG-UI)
2515
+ if (format === 'a2ui') {
2516
+ return 'a2ui' as OutputFormat;
2517
+ }
2518
+
2415
2519
  return undefined;
2416
2520
  }
2417
2521
 
@@ -3025,25 +3129,47 @@ export class SchemaExtractor {
3025
3129
  */
3026
3130
  export type PhotonCapability = 'emit' | 'memory' | 'call' | 'mcp' | 'lock' | 'instanceMeta' | 'allInstances' | 'caller';
3027
3131
 
3132
+ /**
3133
+ * Match a `this`-like base in source code. Covers:
3134
+ * this — literal
3135
+ * (this as any) — TS `as` cast (the most common workaround when
3136
+ * TypeScript can't see a runtime-injected method)
3137
+ * (this as SomeClass), (this as unknown as T)
3138
+ * (<any>this), (<T>this) — older angle-bracket cast syntax
3139
+ *
3140
+ * Not covered: aliasing (`const self = this`), destructuring
3141
+ * (`const { call } = this`), or bracket access (`this['call']`).
3142
+ * Those require dataflow analysis which a regex can't do; the loader
3143
+ * compensates by always-injecting the cheap convenience methods whose
3144
+ * gating would otherwise silently fail for those patterns.
3145
+ */
3146
+ const THIS_BASE =
3147
+ String.raw`(?:\bthis\b|\(\s*<[^>]+>\s*this\s*\)|\(\s*this\s+as\s+[^)]+\))`;
3148
+
3149
+ function memberAccess(name: string, trailing: '\\(' | '\\b'): RegExp {
3150
+ return new RegExp(`${THIS_BASE}\\s*\\.\\s*${name}\\s*${trailing}`);
3151
+ }
3152
+
3028
3153
  /**
3029
3154
  * Detect capabilities used by a Photon from its source code.
3030
3155
  *
3031
3156
  * Scans for `this.emit(`, `this.memory`, `this.call(`, etc. patterns
3032
- * and returns the set of capabilities that the runtime should inject.
3157
+ * (including typed-access workarounds like `(this as any).call(`) and
3158
+ * returns the set of capabilities that the runtime should inject.
3033
3159
  *
3034
3160
  * This enables plain classes (no extends Photon) to use all framework
3035
3161
  * features — the loader detects usage and injects automatically.
3036
3162
  */
3037
3163
  export function detectCapabilities(source: string): Set<PhotonCapability> {
3038
3164
  const caps = new Set<PhotonCapability>();
3039
- if (/this\.emit\s*\(/.test(source)) caps.add('emit');
3040
- if (/this\.render\s*\(/.test(source)) caps.add('emit'); // render() needs emit injection
3041
- if (/this\.memory\b/.test(source)) caps.add('memory');
3042
- if (/this\.call\s*\(/.test(source)) caps.add('call');
3043
- if (/this\.mcp\s*\(/.test(source)) caps.add('mcp');
3044
- if (/this\.withLock\s*\(/.test(source)) caps.add('lock');
3045
- if (/this\.instanceMeta\b/.test(source)) caps.add('instanceMeta');
3046
- if (/this\.allInstances\s*\(/.test(source)) caps.add('allInstances');
3047
- if (/this\.caller\b/.test(source)) caps.add('caller');
3165
+ if (memberAccess('emit', '\\(').test(source)) caps.add('emit');
3166
+ if (memberAccess('render', '\\(').test(source)) caps.add('emit'); // render() needs emit injection
3167
+ if (memberAccess('memory', '\\b').test(source)) caps.add('memory');
3168
+ if (memberAccess('call', '\\(').test(source)) caps.add('call');
3169
+ if (memberAccess('mcp', '\\(').test(source)) caps.add('mcp');
3170
+ if (memberAccess('withLock', '\\(').test(source)) caps.add('lock');
3171
+ if (memberAccess('instanceMeta', '\\b').test(source)) caps.add('instanceMeta');
3172
+ if (memberAccess('allInstances', '\\(').test(source)) caps.add('allInstances');
3173
+ if (memberAccess('caller', '\\b').test(source)) caps.add('caller');
3048
3174
  return caps;
3049
3175
  }
package/src/types.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  * - Visualization: chart, chart:<type>, metric, gauge, timeline, dashboard, cart
10
10
  * - Content: json, markdown, yaml, xml, html, mermaid, code, code:<lang>, slides
11
11
  * - Container: panels, tabs, accordion, stack, columns
12
+ * - Declarative: a2ui (A2UI v0.9 JSONL — emits createSurface/updateComponents/updateDataModel)
12
13
  */
13
14
  export type OutputFormat =
14
15
  | 'primitive' | 'table' | 'tree' | 'list' | 'none'
@@ -16,6 +17,7 @@ export type OutputFormat =
16
17
  | 'card' | 'grid' | 'chips' | 'kv' | 'qr'
17
18
  | 'chart' | `chart:${string}` | 'metric' | 'gauge' | 'timeline' | 'dashboard' | 'cart'
18
19
  | 'panels' | 'tabs' | 'accordion' | 'stack' | 'columns'
20
+ | 'a2ui'
19
21
  | `code` | `code:${string}`;
20
22
 
21
23
  export interface PhotonTool {
@@ -299,6 +301,13 @@ export interface ConstructorParam {
299
301
  defaultValue?: any;
300
302
  /** True if type is string, number, or boolean (inject from env var) */
301
303
  isPrimitive: boolean;
304
+ /**
305
+ * Per-parameter JSDoc description, taken from an inline `/** ... *\/`
306
+ * comment immediately before the parameter, or from a constructor-level
307
+ * `@param <name>` tag when the inline form is absent. Used by the Beam
308
+ * Setup form for field help text.
309
+ */
310
+ description?: string;
302
311
  }
303
312
 
304
313
  /**