@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
@@ -13,6 +13,25 @@ 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>();
20
+
21
+ // Track which `handle*` method names have already emitted a deprecation
22
+ // warning this process so large photons don't spam the console.
23
+ const handlePrefixWarned = new Set<string>();
24
+
25
+ function warnHandlePrefixOnce(methodName: string): void {
26
+ if (handlePrefixWarned.has(methodName)) return;
27
+ handlePrefixWarned.add(methodName);
28
+ // eslint-disable-next-line no-console
29
+ console.error(
30
+ `[photon] deprecation: method "${methodName}" is being auto-registered as a ` +
31
+ `webhook via the legacy handle* prefix. Add an explicit "@webhook" JSDoc ` +
32
+ `tag; the prefix convention will be removed in the next minor release.`,
33
+ );
34
+ }
16
35
 
17
36
  export interface ExtractedMetadata {
18
37
  tools: ExtractedSchema[];
@@ -381,6 +400,33 @@ export class SchemaExtractor {
381
400
 
382
401
  const properties: SettingsProperty[] = [];
383
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
+
384
430
  for (const prop of member.initializer.properties) {
385
431
  if (!ts.isPropertyAssignment(prop)) continue;
386
432
  const propName = prop.name.getText(sourceFile);
@@ -437,7 +483,7 @@ export class SchemaExtractor {
437
483
  properties.push({
438
484
  name: propName,
439
485
  type,
440
- description: propertyDocs.get(propName),
486
+ description: propertyDocs.get(propName) ?? inlineDescriptionFor(prop),
441
487
  default: defaultValue,
442
488
  required,
443
489
  });
@@ -981,10 +1027,36 @@ export class SchemaExtractor {
981
1027
  true
982
1028
  );
983
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
+
984
1054
  const visit = (node: ts.Node) => {
985
1055
  if (ts.isClassDeclaration(node)) {
986
1056
  node.members.forEach((member) => {
987
1057
  if (ts.isConstructorDeclaration(member)) {
1058
+ const ctorJsdoc = this.getJSDocComment(member as any, sourceFile);
1059
+ const ctorParamDocs = this.extractParamDocs(ctorJsdoc);
988
1060
  member.parameters.forEach((param) => {
989
1061
  if (param.name && ts.isIdentifier(param.name)) {
990
1062
  const name = param.name.getText(sourceFile);
@@ -1004,6 +1076,7 @@ export class SchemaExtractor {
1004
1076
  hasDefault,
1005
1077
  defaultValue,
1006
1078
  isPrimitive: this.isPrimitiveType(type),
1079
+ description: inlineDescriptionFor(param) ?? ctorParamDocs.get(name),
1007
1080
  });
1008
1081
  }
1009
1082
  });
@@ -1249,6 +1322,29 @@ export class SchemaExtractor {
1249
1322
  declarations.push({ name: 'locked', config: { name: lockName }, phase: def?.phase ?? 60 });
1250
1323
  }
1251
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
+
1252
1348
  // 2. Extract @use declarations
1253
1349
  const useDecls = this.extractUseDeclarations(jsdocContent);
1254
1350
  for (const { name, rawConfig } of useDecls) {
@@ -1276,7 +1372,7 @@ export class SchemaExtractor {
1276
1372
  private extractDescription(jsdocContent: string): string {
1277
1373
  // Split by @tags that appear at start of a JSDoc line (after optional * prefix)
1278
1374
  // This avoids matching @tag references inline in description text
1279
- 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];
1280
1376
 
1281
1377
  // Remove leading * from each line and trim
1282
1378
  const lines = beforeTags
@@ -1313,8 +1409,26 @@ export class SchemaExtractor {
1313
1409
 
1314
1410
  const description = parts.join('');
1315
1411
 
1316
- // Clean up multiple spaces
1317
- 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;
1318
1432
  }
1319
1433
 
1320
1434
  /**
@@ -2048,10 +2162,15 @@ export class SchemaExtractor {
2048
2162
  // ═══════════════════════════════════════════════════════════════════════════════
2049
2163
 
2050
2164
  /**
2051
- * Extract webhook configuration from @webhook tag or handle* prefix
2165
+ * Extract webhook configuration from @webhook tag or handle* prefix.
2052
2166
  * - @webhook → use method name as path
2053
2167
  * - @webhook stripe → custom path "stripe"
2054
- * - handle* prefix → auto-detected as webhook
2168
+ * - handle* prefix → auto-detected as webhook (DEPRECATED)
2169
+ *
2170
+ * The handle* prefix is a legacy convention being removed. It still
2171
+ * works for one release but emits a one-time stderr warning per
2172
+ * method so users can migrate to the explicit @webhook tag before
2173
+ * the convention is dropped.
2055
2174
  */
2056
2175
  private extractWebhook(jsdocContent: string, methodName: string): boolean | string | undefined {
2057
2176
  // Check for @webhook tag with optional path
@@ -2063,8 +2182,9 @@ export class SchemaExtractor {
2063
2182
  return path || true;
2064
2183
  }
2065
2184
 
2066
- // Check for handle* prefix (convention)
2185
+ // Check for handle* prefix (legacy convention — deprecated).
2067
2186
  if (methodName.startsWith('handle')) {
2187
+ warnHandlePrefixOnce(methodName);
2068
2188
  return true;
2069
2189
  }
2070
2190
 
@@ -2391,6 +2511,11 @@ export class SchemaExtractor {
2391
2511
  return format as OutputFormat;
2392
2512
  }
2393
2513
 
2514
+ // Match declarative UI formats (A2UI v0.9 rides on AG-UI)
2515
+ if (format === 'a2ui') {
2516
+ return 'a2ui' as OutputFormat;
2517
+ }
2518
+
2394
2519
  return undefined;
2395
2520
  }
2396
2521
 
@@ -3004,25 +3129,47 @@ export class SchemaExtractor {
3004
3129
  */
3005
3130
  export type PhotonCapability = 'emit' | 'memory' | 'call' | 'mcp' | 'lock' | 'instanceMeta' | 'allInstances' | 'caller';
3006
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
+
3007
3153
  /**
3008
3154
  * Detect capabilities used by a Photon from its source code.
3009
3155
  *
3010
3156
  * Scans for `this.emit(`, `this.memory`, `this.call(`, etc. patterns
3011
- * 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.
3012
3159
  *
3013
3160
  * This enables plain classes (no extends Photon) to use all framework
3014
3161
  * features — the loader detects usage and injects automatically.
3015
3162
  */
3016
3163
  export function detectCapabilities(source: string): Set<PhotonCapability> {
3017
3164
  const caps = new Set<PhotonCapability>();
3018
- if (/this\.emit\s*\(/.test(source)) caps.add('emit');
3019
- if (/this\.render\s*\(/.test(source)) caps.add('emit'); // render() needs emit injection
3020
- if (/this\.memory\b/.test(source)) caps.add('memory');
3021
- if (/this\.call\s*\(/.test(source)) caps.add('call');
3022
- if (/this\.mcp\s*\(/.test(source)) caps.add('mcp');
3023
- if (/this\.withLock\s*\(/.test(source)) caps.add('lock');
3024
- if (/this\.instanceMeta\b/.test(source)) caps.add('instanceMeta');
3025
- if (/this\.allInstances\s*\(/.test(source)) caps.add('allInstances');
3026
- 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');
3027
3174
  return caps;
3028
3175
  }
package/src/stateful.ts CHANGED
@@ -87,10 +87,21 @@ import {
87
87
  // ══════════════════════════════════════════════════════════════════════════════
88
88
 
89
89
  /**
90
- * Default runs directory (legacy: ~/.photon/runs)
91
- * @deprecated Use getPhotonRunsDir(namespace, photonName) from data-paths.ts
90
+ * Resolve the default runs directory at call time so a long-lived daemon
91
+ * that serves multiple PHOTON_DIRs picks up each base's runs. Previously
92
+ * a module-level const frozen at import time.
93
+ * @deprecated Use getPhotonRunsDir(namespace, photonName, baseDir) for per-photon runs.
92
94
  */
93
- export const RUNS_DIR = getLegacyRunsDir();
95
+ function defaultRunsDir(): string {
96
+ return getLegacyRunsDir();
97
+ }
98
+
99
+ /**
100
+ * Back-compat export. Resolved at import time — kept for consumers that
101
+ * read `RUNS_DIR` as a constant. New code should call getPhotonRunsDir().
102
+ * @deprecated Use getPhotonRunsDir(namespace, photonName, baseDir).
103
+ */
104
+ export const RUNS_DIR = defaultRunsDir();
94
105
 
95
106
  // ══════════════════════════════════════════════════════════════════════════════
96
107
  // CHECKPOINT YIELD TYPE
@@ -136,7 +147,9 @@ export class StateLog {
136
147
  private logPath: string;
137
148
 
138
149
  constructor(runId: string, runsDir?: string) {
139
- this.logPath = path.join(runsDir || RUNS_DIR, `${runId}.jsonl`);
150
+ // Resolve runs dir at call time so the current PHOTON_DIR is honored
151
+ // even when a long-lived process has served earlier bases.
152
+ this.logPath = path.join(runsDir || defaultRunsDir(), `${runId}.jsonl`);
140
153
  }
141
154
 
142
155
  /**
@@ -573,7 +586,7 @@ export async function executeStatefulGenerator<T>(
573
586
  * List all workflow runs
574
587
  */
575
588
  export async function listRuns(runsDir?: string): Promise<WorkflowRun[]> {
576
- const dir = runsDir || RUNS_DIR;
589
+ const dir = runsDir || defaultRunsDir();
577
590
  const runs: WorkflowRun[] = [];
578
591
 
579
592
  try {
@@ -639,7 +652,7 @@ export async function getRunInfo(runId: string, runsDir?: string): Promise<Workf
639
652
  * Delete a workflow run
640
653
  */
641
654
  export async function deleteRun(runId: string, runsDir?: string): Promise<void> {
642
- const logPath = path.join(runsDir || RUNS_DIR, `${runId}.jsonl`);
655
+ const logPath = path.join(runsDir || defaultRunsDir(), `${runId}.jsonl`);
643
656
  await fs.unlink(logPath);
644
657
  }
645
658
 
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
  /**