@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
package/src/mixins.ts CHANGED
@@ -58,6 +58,13 @@ export function withPhotonCapabilities<T extends Constructor>(Base: T): T {
58
58
  */
59
59
  _photonName?: string;
60
60
  _photonNamespace?: string;
61
+ /**
62
+ * PHOTON_DIR this instance was loaded from - set by runtime loader.
63
+ * Pinned so memory and other .data/-rooted state resolve to the same
64
+ * root regardless of which process reads back later.
65
+ * @internal
66
+ */
67
+ _baseDir?: string;
61
68
 
62
69
  /**
63
70
  * Session ID for session-scoped memory - set by runtime
@@ -113,7 +120,12 @@ export function withPhotonCapabilities<T extends Constructor>(Base: T): T {
113
120
  .replace(/([A-Z])/g, '-$1')
114
121
  .toLowerCase()
115
122
  .replace(/^-/, '');
116
- this._memory = new MemoryProvider(name, this._sessionId, this._photonNamespace);
123
+ this._memory = new MemoryProvider(
124
+ name,
125
+ this._sessionId,
126
+ this._photonNamespace,
127
+ this._baseDir
128
+ );
117
129
  }
118
130
  return this._memory;
119
131
  }
@@ -128,7 +140,7 @@ export function withPhotonCapabilities<T extends Constructor>(Base: T): T {
128
140
  .replace(/([A-Z])/g, '-$1')
129
141
  .toLowerCase()
130
142
  .replace(/^-/, '');
131
- this._schedule = new ScheduleProvider(name);
143
+ this._schedule = new ScheduleProvider(name, this._baseDir);
132
144
  }
133
145
  return this._schedule;
134
146
  }
@@ -269,6 +269,44 @@ async function loadPhotonInternal(
269
269
  // docs/internals/PHOTON-DIR-AND-NAMESPACE.md §3.
270
270
  instance._photonName = photonName;
271
271
  instance._photonNamespace = options.namespace ?? deriveNamespace(absolutePath, options.baseDir);
272
+ instance._baseDir = options.baseDir;
273
+ instance._photonFilePath = absolutePath;
274
+ // Stat-gate baseline. When executeTool() sees the source file has
275
+ // changed, it fires _photonReloader (registered just below) to swap
276
+ // in the fresh compile before dispatching.
277
+ try {
278
+ const s = fsSync.statSync(absolutePath);
279
+ instance._photonSourceStat = { mtimeMs: s.mtimeMs, size: s.size, ino: s.ino };
280
+ } catch {
281
+ // No stat — skip baselining so the gate is a no-op.
282
+ }
283
+ instance._photonReloader = async () => {
284
+ // Invalidate the cache entry, then re-run the whole load pipeline
285
+ // and copy public surface from the fresh instance onto the existing
286
+ // one. Callers already holding a reference to the proxy see new
287
+ // method behavior on the very next dispatch.
288
+ instanceCache.delete(cacheKey);
289
+ const fresh = (await photon(absolutePath, options)) as Record<string, any>;
290
+ // Refresh stat baseline on success so we don't re-trigger.
291
+ try {
292
+ const s = fsSync.statSync(absolutePath);
293
+ instance._photonSourceStat = { mtimeMs: s.mtimeMs, size: s.size, ino: s.ino };
294
+ } catch {
295
+ // ignore — next call will re-evaluate
296
+ }
297
+ // Rewire every own property from the fresh instance onto the
298
+ // live one. Prototype-level methods are re-looked-up through the
299
+ // instance's class on each dispatch via the proxy's method lookup,
300
+ // so they pick up the new code automatically. Own-property state
301
+ // (collections, explicit fields) gets refreshed here.
302
+ for (const key of Object.keys(fresh)) {
303
+ try {
304
+ (instance as Record<string, any>)[key] = fresh[key];
305
+ } catch {
306
+ // Read-only own property — skip; the proxy path will handle it.
307
+ }
308
+ }
309
+ };
272
310
  if (options.instanceName) {
273
311
  instance.instanceName = options.instanceName;
274
312
  }
package/src/schedule.ts CHANGED
@@ -139,9 +139,9 @@ function resolveCron(schedule: string): string {
139
139
 
140
140
  // ── Storage Helpers ────────────────────────────────────────────────────
141
141
 
142
- function photonScheduleDir(photonId: string, namespace?: string): string {
142
+ function photonScheduleDir(photonId: string, namespace?: string, baseDir?: string): string {
143
143
  const ns = namespace || 'local';
144
- const newDir = getPhotonSchedulesDir(ns, photonId);
144
+ const newDir = getPhotonSchedulesDir(ns, photonId, baseDir);
145
145
  if (!fsSync.existsSync(newDir)) {
146
146
  const legacyDir = getLegacySchedulesDir(photonId);
147
147
  if (fsSync.existsSync(legacyDir)) return legacyDir;
@@ -149,8 +149,8 @@ function photonScheduleDir(photonId: string, namespace?: string): string {
149
149
  return newDir;
150
150
  }
151
151
 
152
- function taskPath(photonId: string, taskId: string): string {
153
- return path.join(photonScheduleDir(photonId), `${taskId}.json`);
152
+ function taskPath(photonId: string, taskId: string, baseDir?: string): string {
153
+ return path.join(photonScheduleDir(photonId, undefined, baseDir), `${taskId}.json`);
154
154
  }
155
155
 
156
156
  async function ensureDir(dir: string): Promise<void> {
@@ -163,6 +163,20 @@ async function ensureDir(dir: string): Promise<void> {
163
163
 
164
164
  // ── Schedule Provider ──────────────────────────────────────────────────
165
165
 
166
+ /**
167
+ * Callback the runtime injects so `this.schedule.cancel()` (and
168
+ * transitively `cancelByName`, `cancelAll`) can evict the in-memory
169
+ * cron registration on top of unlinking the disk file. Without this,
170
+ * the daemon keeps firing the cancelled schedule until its next
171
+ * restart / first fire (where the phantom-prune check at fire time
172
+ * catches it as a backstop) — doubled executions in the window.
173
+ *
174
+ * The parameter is the namespaced job id the daemon uses:
175
+ * `${photonId}:sched:${taskId}`. Return value mirrors the daemon's
176
+ * `unscheduleJob` boolean (true if something was evicted).
177
+ */
178
+ export type UnscheduleHook = (namespacedJobId: string) => Promise<boolean> | boolean;
179
+
166
180
  /**
167
181
  * Runtime Schedule Provider
168
182
  *
@@ -171,9 +185,30 @@ async function ensureDir(dir: string): Promise<void> {
171
185
  */
172
186
  export class ScheduleProvider {
173
187
  private _photonId: string;
188
+ private _baseDir?: string;
189
+ private _unscheduleHook?: UnscheduleHook;
174
190
 
175
- constructor(photonId: string) {
191
+ /**
192
+ * @param photonId Photon identifier used as the bucket under .data/
193
+ * @param baseDir PHOTON_DIR the photon was loaded from. Pinned so
194
+ * schedule files stay under this base regardless of which process
195
+ * reads back later — mirrors the fix applied to MemoryProvider.
196
+ * Without it, photonScheduleDir falls through to PHOTON_DIR env or
197
+ * ~/.photon and schedule files drift across daemon restarts.
198
+ * @param unscheduleHook Runtime-injected callback that evicts the
199
+ * in-memory cron registration after a disk cancel. Optional — when
200
+ * absent, `cancel()` still unlinks the file and the daemon's
201
+ * fire-time phantom prune is the fallback.
202
+ */
203
+ constructor(photonId: string, baseDir?: string, unscheduleHook?: UnscheduleHook) {
176
204
  this._photonId = photonId;
205
+ this._baseDir = baseDir;
206
+ this._unscheduleHook = unscheduleHook;
207
+ }
208
+
209
+ /** Shape the daemon keys cron jobs under — see schedule-loader.ts. */
210
+ private _jobId(taskId: string): string {
211
+ return `${this._photonId}:sched:${taskId}`;
177
212
  }
178
213
 
179
214
  /**
@@ -222,7 +257,10 @@ export class ScheduleProvider {
222
257
  */
223
258
  async get(taskId: string): Promise<ScheduledTask | null> {
224
259
  try {
225
- const content = await fs.readFile(taskPath(this._photonId, taskId), 'utf-8');
260
+ const content = await fs.readFile(
261
+ taskPath(this._photonId, taskId, this._baseDir),
262
+ 'utf-8'
263
+ );
226
264
  return JSON.parse(content) as ScheduledTask;
227
265
  } catch (err: any) {
228
266
  if (err.code === 'ENOENT') return null;
@@ -242,7 +280,7 @@ export class ScheduleProvider {
242
280
  * List all scheduled tasks, optionally filtered by status
243
281
  */
244
282
  async list(status?: ScheduleStatus): Promise<ScheduledTask[]> {
245
- const dir = photonScheduleDir(this._photonId);
283
+ const dir = photonScheduleDir(this._photonId, undefined, this._baseDir);
246
284
  let files: string[];
247
285
  try {
248
286
  files = await fs.readdir(dir);
@@ -319,16 +357,59 @@ export class ScheduleProvider {
319
357
  }
320
358
 
321
359
  /**
322
- * Cancel (delete) a scheduled task
360
+ * Cancel (delete) a scheduled task.
361
+ *
362
+ * Two steps:
363
+ * 1. Unlink the disk file so daemon restarts don't re-register.
364
+ * 2. Notify the running daemon via `unscheduleHook` so the
365
+ * in-memory cron registration is evicted immediately.
366
+ *
367
+ * Without step 2, a cancel followed by a re-enable under the same
368
+ * name produced two in-memory registrations — the old one (never
369
+ * evicted) and the new one — both firing on schedule until the
370
+ * next daemon restart. The hook closes that window.
323
371
  */
324
372
  async cancel(taskId: string): Promise<boolean> {
373
+ let removed = false;
325
374
  try {
326
- await fs.unlink(taskPath(this._photonId, taskId));
327
- return true;
375
+ await fs.unlink(taskPath(this._photonId, taskId, this._baseDir));
376
+ removed = true;
328
377
  } catch (err: any) {
329
- if (err.code === 'ENOENT') return false;
330
- throw err;
378
+ if (err.code !== 'ENOENT') throw err;
331
379
  }
380
+
381
+ // Always call the hook — even when the file was already gone the
382
+ // in-memory registration might still be alive (ghost schedule from
383
+ // a prior session). The daemon's unschedule is idempotent, so an
384
+ // extra call when no registration exists is harmless.
385
+ //
386
+ // Eviction is best-effort. If the daemon is unreachable we log
387
+ // loudly and rely on the fire-time phantom-prune (daemon checks
388
+ // sourceFile before running) to catch the ghost on its next
389
+ // scheduled tick. For low-frequency schedules that tick may be
390
+ // hours away, so a silent swallow would let `cancel()` return
391
+ // truthy while duplicate runs continue; logging gives operators
392
+ // a chance to see and retry.
393
+ if (this._unscheduleHook) {
394
+ const jobId = this._jobId(taskId);
395
+ try {
396
+ const acknowledged = await this._unscheduleHook(jobId);
397
+ if (!acknowledged) {
398
+ console.warn(
399
+ `[schedule] cancel(${taskId}): daemon did not acknowledge unschedule for ${jobId}. ` +
400
+ `The disk file was removed; the ghost cron registration will be pruned at next fire.`
401
+ );
402
+ }
403
+ } catch (err) {
404
+ console.warn(
405
+ `[schedule] cancel(${taskId}): unschedule hook threw for ${jobId} — ` +
406
+ `relying on fire-time phantom-prune as fallback.`,
407
+ err instanceof Error ? err.message : err
408
+ );
409
+ }
410
+ }
411
+
412
+ return removed;
332
413
  }
333
414
 
334
415
  /**
@@ -362,8 +443,11 @@ export class ScheduleProvider {
362
443
 
363
444
  /** @internal */
364
445
  private async _save(task: ScheduledTask): Promise<void> {
365
- const dir = photonScheduleDir(this._photonId);
446
+ const dir = photonScheduleDir(this._photonId, undefined, this._baseDir);
366
447
  await ensureDir(dir);
367
- await fs.writeFile(taskPath(this._photonId, task.id), JSON.stringify(task, null, 2));
448
+ await fs.writeFile(
449
+ taskPath(this._photonId, task.id, this._baseDir),
450
+ JSON.stringify(task, null, 2)
451
+ );
368
452
  }
369
453
  }
@@ -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,54 @@ 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
+ // The `(this as <T>)` alternative allows one level of nested parens inside
3147
+ // the type annotation so function-type syntax like `(k: string) => void`
3148
+ // doesn't truncate the match. Without the `(?:[^()]|\([^()]*\))+` fallback,
3149
+ // `(this as unknown as { memory: { set: (k: string) => Promise<void> } })`
3150
+ // would terminate at the first inner `)` and the trailing `.memory` access
3151
+ // would never be seen — silently disabling this.memory injection for any
3152
+ // plain class that uses a complex TS type cast to reach memory.
3153
+ const THIS_BASE =
3154
+ String.raw`(?:\bthis\b|\(\s*<[^>]+>\s*this\s*\)|\(\s*this\s+as\s+(?:[^()]|\([^()]*\))+\))`;
3155
+
3156
+ function memberAccess(name: string, trailing: '\\(' | '\\b'): RegExp {
3157
+ return new RegExp(`${THIS_BASE}\\s*\\.\\s*${name}\\s*${trailing}`);
3158
+ }
3159
+
3028
3160
  /**
3029
3161
  * Detect capabilities used by a Photon from its source code.
3030
3162
  *
3031
3163
  * Scans for `this.emit(`, `this.memory`, `this.call(`, etc. patterns
3032
- * and returns the set of capabilities that the runtime should inject.
3164
+ * (including typed-access workarounds like `(this as any).call(`) and
3165
+ * returns the set of capabilities that the runtime should inject.
3033
3166
  *
3034
3167
  * This enables plain classes (no extends Photon) to use all framework
3035
3168
  * features — the loader detects usage and injects automatically.
3036
3169
  */
3037
3170
  export function detectCapabilities(source: string): Set<PhotonCapability> {
3038
3171
  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');
3172
+ if (memberAccess('emit', '\\(').test(source)) caps.add('emit');
3173
+ if (memberAccess('render', '\\(').test(source)) caps.add('emit'); // render() needs emit injection
3174
+ if (memberAccess('memory', '\\b').test(source)) caps.add('memory');
3175
+ if (memberAccess('call', '\\(').test(source)) caps.add('call');
3176
+ if (memberAccess('mcp', '\\(').test(source)) caps.add('mcp');
3177
+ if (memberAccess('withLock', '\\(').test(source)) caps.add('lock');
3178
+ if (memberAccess('instanceMeta', '\\b').test(source)) caps.add('instanceMeta');
3179
+ if (memberAccess('allInstances', '\\(').test(source)) caps.add('allInstances');
3180
+ if (memberAccess('caller', '\\b').test(source)) caps.add('caller');
3048
3181
  return caps;
3049
3182
  }
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
  /**