@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.
- package/dist/base.d.ts +114 -0
- package/dist/base.d.ts.map +1 -1
- package/dist/base.js +195 -2
- package/dist/base.js.map +1 -1
- package/dist/description-sanitizer.d.ts +34 -0
- package/dist/description-sanitizer.d.ts.map +1 -0
- package/dist/description-sanitizer.js +80 -0
- package/dist/description-sanitizer.js.map +1 -0
- package/dist/generator.d.ts +102 -0
- package/dist/generator.d.ts.map +1 -1
- package/dist/generator.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/memory.d.ts.map +1 -1
- package/dist/memory.js +28 -0
- package/dist/memory.js.map +1 -1
- package/dist/middleware.d.ts.map +1 -1
- package/dist/middleware.js +96 -0
- package/dist/middleware.js.map +1 -1
- package/dist/mixins.d.ts.map +1 -1
- package/dist/mixins.js +9 -2
- package/dist/mixins.js.map +1 -1
- package/dist/photon-loader-lite.js +41 -0
- package/dist/photon-loader-lite.js.map +1 -1
- package/dist/schedule.d.ts +41 -2
- package/dist/schedule.d.ts.map +1 -1
- package/dist/schedule.js +72 -16
- package/dist/schedule.js.map +1 -1
- package/dist/schema-extractor.d.ts +2 -1
- package/dist/schema-extractor.d.ts.map +1 -1
- package/dist/schema-extractor.js +135 -14
- package/dist/schema-extractor.js.map +1 -1
- package/dist/types.d.ts +9 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +2 -2
- package/src/base.ts +224 -2
- package/src/description-sanitizer.ts +102 -0
- package/src/generator.ts +93 -0
- package/src/index.ts +12 -0
- package/src/memory.ts +28 -0
- package/src/middleware.ts +98 -0
- package/src/mixins.ts +14 -2
- package/src/photon-loader-lite.ts +38 -0
- package/src/schedule.ts +98 -14
- package/src/schema-extractor.ts +147 -14
- 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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
375
|
+
await fs.unlink(taskPath(this._photonId, taskId, this._baseDir));
|
|
376
|
+
removed = true;
|
|
328
377
|
} catch (err: any) {
|
|
329
|
-
if (err.code
|
|
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(
|
|
448
|
+
await fs.writeFile(
|
|
449
|
+
taskPath(this._photonId, task.id, this._baseDir),
|
|
450
|
+
JSON.stringify(task, null, 2)
|
|
451
|
+
);
|
|
368
452
|
}
|
|
369
453
|
}
|
package/src/schema-extractor.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
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 (
|
|
3040
|
-
if (
|
|
3041
|
-
if (
|
|
3042
|
-
if (
|
|
3043
|
-
if (
|
|
3044
|
-
if (
|
|
3045
|
-
if (
|
|
3046
|
-
if (
|
|
3047
|
-
if (
|
|
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
|
/**
|