@portel/photon-core 2.10.1 → 2.12.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 +39 -0
- package/dist/base.d.ts.map +1 -1
- package/dist/base.js +49 -0
- package/dist/base.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/mixins.d.ts.map +1 -1
- package/dist/mixins.js +20 -0
- package/dist/mixins.js.map +1 -1
- package/dist/schedule.d.ts +157 -0
- package/dist/schedule.d.ts.map +1 -0
- package/dist/schedule.js +283 -0
- package/dist/schedule.js.map +1 -0
- package/dist/schema-extractor.d.ts +60 -0
- package/dist/schema-extractor.d.ts.map +1 -1
- package/dist/schema-extractor.js +201 -2
- package/dist/schema-extractor.js.map +1 -1
- package/dist/types.d.ts +67 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/src/base.ts +51 -0
- package/src/index.ts +10 -0
- package/src/mixins.ts +22 -0
- package/src/schedule.ts +365 -0
- package/src/schema-extractor.ts +219 -2
- package/src/types.ts +65 -0
package/src/schedule.ts
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime Scheduling System
|
|
3
|
+
*
|
|
4
|
+
* Programmatic scheduling for photons — create, pause, resume, and cancel
|
|
5
|
+
* scheduled tasks at runtime. Available as `this.schedule` on Photon.
|
|
6
|
+
*
|
|
7
|
+
* Complements static `@scheduled`/`@cron` JSDoc tags with dynamic scheduling.
|
|
8
|
+
* Schedules persist to disk; the daemon reads and executes them.
|
|
9
|
+
*
|
|
10
|
+
* Storage: ~/.photon/schedules/{photonId}/{taskId}.json
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```typescript
|
|
14
|
+
* export default class Cleanup extends Photon {
|
|
15
|
+
* async setup() {
|
|
16
|
+
* await this.schedule.create({
|
|
17
|
+
* name: 'nightly-cleanup',
|
|
18
|
+
* schedule: '0 0 * * *',
|
|
19
|
+
* method: 'purge',
|
|
20
|
+
* params: { olderThan: 30 },
|
|
21
|
+
* });
|
|
22
|
+
* }
|
|
23
|
+
*
|
|
24
|
+
* async purge({ olderThan }: { olderThan: number }) {
|
|
25
|
+
* // ... cleanup logic
|
|
26
|
+
* }
|
|
27
|
+
* }
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import * as fs from 'fs/promises';
|
|
32
|
+
import * as path from 'path';
|
|
33
|
+
import * as os from 'os';
|
|
34
|
+
import { randomUUID } from 'crypto';
|
|
35
|
+
|
|
36
|
+
// ── Types ──────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
export type ScheduleStatus = 'active' | 'paused' | 'completed' | 'error';
|
|
39
|
+
|
|
40
|
+
export interface ScheduledTask {
|
|
41
|
+
/** Unique task ID */
|
|
42
|
+
id: string;
|
|
43
|
+
/** Human-readable name */
|
|
44
|
+
name: string;
|
|
45
|
+
/** Optional description */
|
|
46
|
+
description?: string;
|
|
47
|
+
/** Cron expression (5-field: minute hour day month weekday) */
|
|
48
|
+
cron: string;
|
|
49
|
+
/** Method name on this photon to call */
|
|
50
|
+
method: string;
|
|
51
|
+
/** Parameters to pass to the method */
|
|
52
|
+
params: Record<string, any>;
|
|
53
|
+
/** Execute once then mark completed */
|
|
54
|
+
fireOnce: boolean;
|
|
55
|
+
/** Maximum number of executions (0 = unlimited) */
|
|
56
|
+
maxExecutions: number;
|
|
57
|
+
/** Current status */
|
|
58
|
+
status: ScheduleStatus;
|
|
59
|
+
/** ISO timestamp of creation */
|
|
60
|
+
createdAt: string;
|
|
61
|
+
/** ISO timestamp of last execution */
|
|
62
|
+
lastExecutionAt?: string;
|
|
63
|
+
/** Total execution count */
|
|
64
|
+
executionCount: number;
|
|
65
|
+
/** Error message if status is 'error' */
|
|
66
|
+
errorMessage?: string;
|
|
67
|
+
/** Photon that owns this task */
|
|
68
|
+
photonId: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface CreateScheduleOptions {
|
|
72
|
+
/** Human-readable name (must be unique per photon) */
|
|
73
|
+
name: string;
|
|
74
|
+
/** Cron expression (5-field) or shorthand: '@hourly', '@daily', '@weekly', '@monthly' */
|
|
75
|
+
schedule: string;
|
|
76
|
+
/** Method name on this photon to invoke */
|
|
77
|
+
method: string;
|
|
78
|
+
/** Parameters to pass to the method */
|
|
79
|
+
params?: Record<string, any>;
|
|
80
|
+
/** Optional description */
|
|
81
|
+
description?: string;
|
|
82
|
+
/** Execute once then auto-complete (default: false) */
|
|
83
|
+
fireOnce?: boolean;
|
|
84
|
+
/** Maximum executions before auto-complete (0 = unlimited, default: 0) */
|
|
85
|
+
maxExecutions?: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface UpdateScheduleOptions {
|
|
89
|
+
/** New cron schedule */
|
|
90
|
+
schedule?: string;
|
|
91
|
+
/** New method name */
|
|
92
|
+
method?: string;
|
|
93
|
+
/** New parameters */
|
|
94
|
+
params?: Record<string, any>;
|
|
95
|
+
/** New description */
|
|
96
|
+
description?: string;
|
|
97
|
+
/** Update fire-once flag */
|
|
98
|
+
fireOnce?: boolean;
|
|
99
|
+
/** Update max executions */
|
|
100
|
+
maxExecutions?: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Cron Shorthands ────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
const CRON_SHORTHANDS: Record<string, string> = {
|
|
106
|
+
'@yearly': '0 0 1 1 *',
|
|
107
|
+
'@annually': '0 0 1 1 *',
|
|
108
|
+
'@monthly': '0 0 1 * *',
|
|
109
|
+
'@weekly': '0 0 * * 0',
|
|
110
|
+
'@daily': '0 0 * * *',
|
|
111
|
+
'@midnight': '0 0 * * *',
|
|
112
|
+
'@hourly': '0 * * * *',
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Resolve cron shorthands and validate basic format.
|
|
117
|
+
* Returns the resolved 5-field cron expression.
|
|
118
|
+
*/
|
|
119
|
+
function resolveCron(schedule: string): string {
|
|
120
|
+
const trimmed = schedule.trim();
|
|
121
|
+
|
|
122
|
+
// Check shorthands
|
|
123
|
+
const shorthand = CRON_SHORTHANDS[trimmed.toLowerCase()];
|
|
124
|
+
if (shorthand) return shorthand;
|
|
125
|
+
|
|
126
|
+
// Validate 5-field cron format
|
|
127
|
+
const fields = trimmed.split(/\s+/);
|
|
128
|
+
if (fields.length !== 5) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`Invalid cron expression: '${schedule}'. Expected 5 fields (minute hour day month weekday) or a shorthand (@hourly, @daily, @weekly, @monthly, @yearly).`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return trimmed;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── Storage Helpers ────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
function getSchedulesDir(): string {
|
|
140
|
+
return process.env.PHOTON_SCHEDULES_DIR || path.join(os.homedir(), '.photon', 'schedules');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function photonScheduleDir(photonId: string): string {
|
|
144
|
+
const safeName = photonId.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
145
|
+
return path.join(getSchedulesDir(), safeName);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function taskPath(photonId: string, taskId: string): string {
|
|
149
|
+
return path.join(photonScheduleDir(photonId), `${taskId}.json`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function ensureDir(dir: string): Promise<void> {
|
|
153
|
+
try {
|
|
154
|
+
await fs.mkdir(dir, { recursive: true });
|
|
155
|
+
} catch (err: any) {
|
|
156
|
+
if (err.code !== 'EEXIST') throw err;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── Schedule Provider ──────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Runtime Schedule Provider
|
|
164
|
+
*
|
|
165
|
+
* Provides CRUD operations for scheduled tasks.
|
|
166
|
+
* Tasks are persisted as JSON files that the daemon watches and executes.
|
|
167
|
+
*/
|
|
168
|
+
export class ScheduleProvider {
|
|
169
|
+
private _photonId: string;
|
|
170
|
+
|
|
171
|
+
constructor(photonId: string) {
|
|
172
|
+
this._photonId = photonId;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Create a new scheduled task
|
|
177
|
+
*
|
|
178
|
+
* @example
|
|
179
|
+
* ```typescript
|
|
180
|
+
* await this.schedule.create({
|
|
181
|
+
* name: 'daily-report',
|
|
182
|
+
* schedule: '0 9 * * *',
|
|
183
|
+
* method: 'generate',
|
|
184
|
+
* params: { format: 'pdf' },
|
|
185
|
+
* });
|
|
186
|
+
* ```
|
|
187
|
+
*/
|
|
188
|
+
async create(options: CreateScheduleOptions): Promise<ScheduledTask> {
|
|
189
|
+
const cron = resolveCron(options.schedule);
|
|
190
|
+
|
|
191
|
+
// Check for duplicate name
|
|
192
|
+
const existing = await this.getByName(options.name);
|
|
193
|
+
if (existing) {
|
|
194
|
+
throw new Error(`Schedule '${options.name}' already exists (id: ${existing.id}). Use update() to modify it.`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const task: ScheduledTask = {
|
|
198
|
+
id: randomUUID(),
|
|
199
|
+
name: options.name,
|
|
200
|
+
description: options.description,
|
|
201
|
+
cron,
|
|
202
|
+
method: options.method,
|
|
203
|
+
params: options.params || {},
|
|
204
|
+
fireOnce: options.fireOnce ?? false,
|
|
205
|
+
maxExecutions: options.maxExecutions ?? 0,
|
|
206
|
+
status: 'active',
|
|
207
|
+
createdAt: new Date().toISOString(),
|
|
208
|
+
executionCount: 0,
|
|
209
|
+
photonId: this._photonId,
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
await this._save(task);
|
|
213
|
+
return task;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Get a scheduled task by ID
|
|
218
|
+
*/
|
|
219
|
+
async get(taskId: string): Promise<ScheduledTask | null> {
|
|
220
|
+
try {
|
|
221
|
+
const content = await fs.readFile(taskPath(this._photonId, taskId), 'utf-8');
|
|
222
|
+
return JSON.parse(content) as ScheduledTask;
|
|
223
|
+
} catch (err: any) {
|
|
224
|
+
if (err.code === 'ENOENT') return null;
|
|
225
|
+
throw err;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Get a scheduled task by name
|
|
231
|
+
*/
|
|
232
|
+
async getByName(name: string): Promise<ScheduledTask | null> {
|
|
233
|
+
const tasks = await this.list();
|
|
234
|
+
return tasks.find(t => t.name === name) || null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* List all scheduled tasks, optionally filtered by status
|
|
239
|
+
*/
|
|
240
|
+
async list(status?: ScheduleStatus): Promise<ScheduledTask[]> {
|
|
241
|
+
const dir = photonScheduleDir(this._photonId);
|
|
242
|
+
let files: string[];
|
|
243
|
+
try {
|
|
244
|
+
files = await fs.readdir(dir);
|
|
245
|
+
} catch (err: any) {
|
|
246
|
+
if (err.code === 'ENOENT') return [];
|
|
247
|
+
throw err;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const tasks: ScheduledTask[] = [];
|
|
251
|
+
for (const file of files) {
|
|
252
|
+
if (!file.endsWith('.json')) continue;
|
|
253
|
+
try {
|
|
254
|
+
const content = await fs.readFile(path.join(dir, file), 'utf-8');
|
|
255
|
+
const task = JSON.parse(content) as ScheduledTask;
|
|
256
|
+
if (!status || task.status === status) {
|
|
257
|
+
tasks.push(task);
|
|
258
|
+
}
|
|
259
|
+
} catch {
|
|
260
|
+
// Skip corrupt files
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return tasks.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Update an existing scheduled task
|
|
269
|
+
*/
|
|
270
|
+
async update(taskId: string, updates: UpdateScheduleOptions): Promise<ScheduledTask> {
|
|
271
|
+
const task = await this.get(taskId);
|
|
272
|
+
if (!task) {
|
|
273
|
+
throw new Error(`Schedule not found: ${taskId}`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (updates.schedule !== undefined) {
|
|
277
|
+
task.cron = resolveCron(updates.schedule);
|
|
278
|
+
}
|
|
279
|
+
if (updates.method !== undefined) task.method = updates.method;
|
|
280
|
+
if (updates.params !== undefined) task.params = updates.params;
|
|
281
|
+
if (updates.description !== undefined) task.description = updates.description;
|
|
282
|
+
if (updates.fireOnce !== undefined) task.fireOnce = updates.fireOnce;
|
|
283
|
+
if (updates.maxExecutions !== undefined) task.maxExecutions = updates.maxExecutions;
|
|
284
|
+
|
|
285
|
+
await this._save(task);
|
|
286
|
+
return task;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Pause a scheduled task (stops execution until resumed)
|
|
291
|
+
*/
|
|
292
|
+
async pause(taskId: string): Promise<ScheduledTask> {
|
|
293
|
+
const task = await this.get(taskId);
|
|
294
|
+
if (!task) throw new Error(`Schedule not found: ${taskId}`);
|
|
295
|
+
if (task.status !== 'active') {
|
|
296
|
+
throw new Error(`Cannot pause task with status '${task.status}'. Only active tasks can be paused.`);
|
|
297
|
+
}
|
|
298
|
+
task.status = 'paused';
|
|
299
|
+
await this._save(task);
|
|
300
|
+
return task;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Resume a paused scheduled task
|
|
305
|
+
*/
|
|
306
|
+
async resume(taskId: string): Promise<ScheduledTask> {
|
|
307
|
+
const task = await this.get(taskId);
|
|
308
|
+
if (!task) throw new Error(`Schedule not found: ${taskId}`);
|
|
309
|
+
if (task.status !== 'paused') {
|
|
310
|
+
throw new Error(`Cannot resume task with status '${task.status}'. Only paused tasks can be resumed.`);
|
|
311
|
+
}
|
|
312
|
+
task.status = 'active';
|
|
313
|
+
await this._save(task);
|
|
314
|
+
return task;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Cancel (delete) a scheduled task
|
|
319
|
+
*/
|
|
320
|
+
async cancel(taskId: string): Promise<boolean> {
|
|
321
|
+
try {
|
|
322
|
+
await fs.unlink(taskPath(this._photonId, taskId));
|
|
323
|
+
return true;
|
|
324
|
+
} catch (err: any) {
|
|
325
|
+
if (err.code === 'ENOENT') return false;
|
|
326
|
+
throw err;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Cancel a scheduled task by name
|
|
332
|
+
*/
|
|
333
|
+
async cancelByName(name: string): Promise<boolean> {
|
|
334
|
+
const task = await this.getByName(name);
|
|
335
|
+
if (!task) return false;
|
|
336
|
+
return this.cancel(task.id);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Check if a schedule with the given name exists
|
|
341
|
+
*/
|
|
342
|
+
async has(name: string): Promise<boolean> {
|
|
343
|
+
const task = await this.getByName(name);
|
|
344
|
+
return task !== null;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Cancel all scheduled tasks for this photon
|
|
349
|
+
*/
|
|
350
|
+
async cancelAll(): Promise<number> {
|
|
351
|
+
const tasks = await this.list();
|
|
352
|
+
let count = 0;
|
|
353
|
+
for (const task of tasks) {
|
|
354
|
+
if (await this.cancel(task.id)) count++;
|
|
355
|
+
}
|
|
356
|
+
return count;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** @internal */
|
|
360
|
+
private async _save(task: ScheduledTask): Promise<void> {
|
|
361
|
+
const dir = photonScheduleDir(this._photonId);
|
|
362
|
+
await ensureDir(dir);
|
|
363
|
+
await fs.writeFile(taskPath(this._photonId, task.id), JSON.stringify(task, null, 2));
|
|
364
|
+
}
|
|
365
|
+
}
|
package/src/schema-extractor.ts
CHANGED
|
@@ -243,11 +243,22 @@ export class SchemaExtractor {
|
|
|
243
243
|
const layoutHints = this.extractLayoutHints(jsdoc);
|
|
244
244
|
const buttonLabel = this.extractButtonLabel(jsdoc);
|
|
245
245
|
const icon = this.extractIcon(jsdoc);
|
|
246
|
+
const iconImages = this.extractIconImages(jsdoc);
|
|
246
247
|
const yields = isGenerator ? this.extractYieldsFromJSDoc(jsdoc) : undefined;
|
|
247
248
|
const isStateful = this.hasStatefulTag(jsdoc);
|
|
248
249
|
const autorun = this.hasAutorunTag(jsdoc);
|
|
249
250
|
const isAsync = this.hasAsyncTag(jsdoc);
|
|
250
251
|
|
|
252
|
+
// MCP standard annotations
|
|
253
|
+
const title = this.extractTitle(jsdoc);
|
|
254
|
+
const readOnlyHint = this.hasReadOnlyHint(jsdoc);
|
|
255
|
+
const destructiveHint = this.hasDestructiveHint(jsdoc);
|
|
256
|
+
const idempotentHint = this.hasIdempotentHint(jsdoc);
|
|
257
|
+
const openWorldHint = this.extractOpenWorldHint(jsdoc);
|
|
258
|
+
const audience = this.extractAudience(jsdoc);
|
|
259
|
+
const contentPriority = this.extractContentPriority(jsdoc);
|
|
260
|
+
const outputSchema = this.inferOutputSchemaFromReturnType(member, sourceFile);
|
|
261
|
+
|
|
251
262
|
// Daemon features
|
|
252
263
|
const webhook = this.extractWebhook(jsdoc, methodName);
|
|
253
264
|
const scheduled = this.extractScheduled(jsdoc, methodName);
|
|
@@ -294,6 +305,7 @@ export class SchemaExtractor {
|
|
|
294
305
|
...(layoutHints ? { layoutHints } : {}),
|
|
295
306
|
...(buttonLabel ? { buttonLabel } : {}),
|
|
296
307
|
...(icon ? { icon } : {}),
|
|
308
|
+
...(iconImages ? { iconImages } : {}),
|
|
297
309
|
...(isGenerator ? { isGenerator: true } : {}),
|
|
298
310
|
...(yields && yields.length > 0 ? { yields } : {}),
|
|
299
311
|
...(isStateful ? { isStateful: true } : {}),
|
|
@@ -319,6 +331,15 @@ export class SchemaExtractor {
|
|
|
319
331
|
...(deprecated !== undefined ? { deprecated } : {}),
|
|
320
332
|
// Unified middleware declarations (new — runtime uses this)
|
|
321
333
|
...(middleware.length > 0 ? { middleware } : {}),
|
|
334
|
+
// MCP standard annotations
|
|
335
|
+
...(title ? { title } : {}),
|
|
336
|
+
...(readOnlyHint ? { readOnlyHint: true } : {}),
|
|
337
|
+
...(destructiveHint ? { destructiveHint: true } : {}),
|
|
338
|
+
...(idempotentHint ? { idempotentHint: true } : {}),
|
|
339
|
+
...(openWorldHint !== undefined ? { openWorldHint } : {}),
|
|
340
|
+
...(audience ? { audience } : {}),
|
|
341
|
+
...(contentPriority !== undefined ? { contentPriority } : {}),
|
|
342
|
+
...(outputSchema ? { outputSchema } : {}),
|
|
322
343
|
// Event emission (for @stateful classes)
|
|
323
344
|
...emitsEventData,
|
|
324
345
|
});
|
|
@@ -632,6 +653,12 @@ export class SchemaExtractor {
|
|
|
632
653
|
properties[propName] = { type: 'object' };
|
|
633
654
|
}
|
|
634
655
|
|
|
656
|
+
// Extract JSDoc description from property (e.g., /** Task ID */ id: string)
|
|
657
|
+
const jsDocComment = this.getPropertyJsDoc(member, sourceFile);
|
|
658
|
+
if (jsDocComment) {
|
|
659
|
+
properties[propName].description = jsDocComment;
|
|
660
|
+
}
|
|
661
|
+
|
|
635
662
|
// Add readonly from TypeScript (JSDoc can override)
|
|
636
663
|
if (isReadonly) {
|
|
637
664
|
properties[propName]._tsReadOnly = true;
|
|
@@ -643,6 +670,32 @@ export class SchemaExtractor {
|
|
|
643
670
|
return { properties, required };
|
|
644
671
|
}
|
|
645
672
|
|
|
673
|
+
/**
|
|
674
|
+
* Extract JSDoc description from a property signature.
|
|
675
|
+
* Handles both /** comment * / style and // comment style.
|
|
676
|
+
*/
|
|
677
|
+
private getPropertyJsDoc(member: ts.PropertySignature, sourceFile: ts.SourceFile): string | undefined {
|
|
678
|
+
// Check for JSDoc comments attached to the node
|
|
679
|
+
const jsDocNodes = (member as any).jsDoc;
|
|
680
|
+
if (jsDocNodes && jsDocNodes.length > 0) {
|
|
681
|
+
const comment = jsDocNodes[0].comment;
|
|
682
|
+
if (typeof comment === 'string') return comment.trim();
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Fallback: look for leading comment in source text
|
|
686
|
+
const fullText = sourceFile.getFullText();
|
|
687
|
+
const start = member.getFullStart();
|
|
688
|
+
const leading = fullText.substring(start, member.getStart(sourceFile)).trim();
|
|
689
|
+
|
|
690
|
+
// Match /** ... */ or /* ... */
|
|
691
|
+
const blockMatch = leading.match(/\/\*\*?\s*([\s\S]*?)\s*\*\//);
|
|
692
|
+
if (blockMatch) {
|
|
693
|
+
return blockMatch[1].replace(/\s*\*\s*/g, ' ').trim();
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
return undefined;
|
|
697
|
+
}
|
|
698
|
+
|
|
646
699
|
/**
|
|
647
700
|
* Convert TypeScript type node to JSON schema
|
|
648
701
|
*/
|
|
@@ -772,6 +825,15 @@ export class SchemaExtractor {
|
|
|
772
825
|
return;
|
|
773
826
|
}
|
|
774
827
|
|
|
828
|
+
// Also resolve interface declarations → synthesize a TypeLiteral
|
|
829
|
+
if (ts.isInterfaceDeclaration(node) && node.name.text === typeName) {
|
|
830
|
+
// Create a synthetic TypeLiteralNode from interface members
|
|
831
|
+
resolved = ts.factory.createTypeLiteralNode(
|
|
832
|
+
node.members.filter(ts.isPropertySignature) as ts.PropertySignature[]
|
|
833
|
+
);
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
|
|
775
837
|
ts.forEachChild(node, visit);
|
|
776
838
|
};
|
|
777
839
|
|
|
@@ -1756,6 +1818,122 @@ export class SchemaExtractor {
|
|
|
1756
1818
|
return /@async\b/i.test(jsdocContent);
|
|
1757
1819
|
}
|
|
1758
1820
|
|
|
1821
|
+
/**
|
|
1822
|
+
* Extract @title tag value (human-readable display name)
|
|
1823
|
+
* Example: @title Create New Task
|
|
1824
|
+
*/
|
|
1825
|
+
private extractTitle(jsdocContent: string): string | undefined {
|
|
1826
|
+
const match = jsdocContent.match(/@title\s+(.+?)(?:\n|\s*\*\/|\s*\*\s*@)/s);
|
|
1827
|
+
if (match) {
|
|
1828
|
+
return match[1].replace(/\s*\*\s*/g, ' ').trim();
|
|
1829
|
+
}
|
|
1830
|
+
return undefined;
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
/**
|
|
1834
|
+
* Check if JSDoc contains method-level @readOnly tag (NOT param-level {@readOnly})
|
|
1835
|
+
* Method-level: indicates tool has no side effects (MCP readOnlyHint)
|
|
1836
|
+
* Param-level: {@readOnly} inside @param — different regex, no conflict
|
|
1837
|
+
*/
|
|
1838
|
+
private hasReadOnlyHint(jsdocContent: string): boolean {
|
|
1839
|
+
// Match @readOnly that is NOT inside curly braces (param-level uses {@readOnly})
|
|
1840
|
+
// Look for @readOnly at start of line (after * ) or start of JSDoc
|
|
1841
|
+
return /(?:^|\n)\s*\*?\s*@readOnly\b/m.test(jsdocContent);
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
/**
|
|
1845
|
+
* Check if JSDoc contains @destructive tag
|
|
1846
|
+
* Indicates tool performs destructive operations requiring confirmation
|
|
1847
|
+
*/
|
|
1848
|
+
private hasDestructiveHint(jsdocContent: string): boolean {
|
|
1849
|
+
return /@destructive\b/i.test(jsdocContent);
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
/**
|
|
1853
|
+
* Check if JSDoc contains @idempotent tag
|
|
1854
|
+
* Indicates tool is safe to retry — multiple calls produce same effect
|
|
1855
|
+
*/
|
|
1856
|
+
private hasIdempotentHint(jsdocContent: string): boolean {
|
|
1857
|
+
return /@idempotent\b/i.test(jsdocContent);
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
/**
|
|
1861
|
+
* Extract open/closed world hint from @openWorld or @closedWorld tags
|
|
1862
|
+
* @openWorld → true (tool interacts with external systems)
|
|
1863
|
+
* @closedWorld → false (tool operates only on local data)
|
|
1864
|
+
* Returns undefined if neither tag present
|
|
1865
|
+
*/
|
|
1866
|
+
private extractOpenWorldHint(jsdocContent: string): boolean | undefined {
|
|
1867
|
+
if (/@openWorld\b/i.test(jsdocContent)) return true;
|
|
1868
|
+
if (/@closedWorld\b/i.test(jsdocContent)) return false;
|
|
1869
|
+
return undefined;
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
/**
|
|
1873
|
+
* Extract @audience tag value
|
|
1874
|
+
* @audience user → ['user']
|
|
1875
|
+
* @audience assistant → ['assistant']
|
|
1876
|
+
* @audience user assistant → ['user', 'assistant']
|
|
1877
|
+
*/
|
|
1878
|
+
private extractAudience(jsdocContent: string): ('user' | 'assistant')[] | undefined {
|
|
1879
|
+
const match = jsdocContent.match(/@audience\s+([\w\s]+?)(?:\n|\s*\*\/|\s*\*\s*@)/);
|
|
1880
|
+
if (match) {
|
|
1881
|
+
const values = match[1].trim().split(/\s+/) as ('user' | 'assistant')[];
|
|
1882
|
+
const valid = values.filter(v => v === 'user' || v === 'assistant');
|
|
1883
|
+
return valid.length > 0 ? valid : undefined;
|
|
1884
|
+
}
|
|
1885
|
+
return undefined;
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
/**
|
|
1889
|
+
* Extract @priority tag value (content importance 0.0-1.0)
|
|
1890
|
+
*/
|
|
1891
|
+
private extractContentPriority(jsdocContent: string): number | undefined {
|
|
1892
|
+
const match = jsdocContent.match(/@priority\s+([\d.]+)/);
|
|
1893
|
+
if (match) {
|
|
1894
|
+
const value = parseFloat(match[1]);
|
|
1895
|
+
if (!isNaN(value) && value >= 0 && value <= 1) {
|
|
1896
|
+
return value;
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
return undefined;
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
/**
|
|
1903
|
+
* Infer output schema from TypeScript return type annotation.
|
|
1904
|
+
* Unwraps Promise<T> and converts T to JSON Schema.
|
|
1905
|
+
* Property descriptions come from JSDoc on the type/interface properties.
|
|
1906
|
+
* Only produces a schema for object return types (not primitives/arrays).
|
|
1907
|
+
*/
|
|
1908
|
+
private inferOutputSchemaFromReturnType(method: ts.MethodDeclaration, sourceFile: ts.SourceFile): { type: 'object'; properties: Record<string, any>; required?: string[] } | undefined {
|
|
1909
|
+
const returnType = method.type;
|
|
1910
|
+
if (!returnType) return undefined;
|
|
1911
|
+
|
|
1912
|
+
// Unwrap Promise<T> → T
|
|
1913
|
+
let innerType: ts.TypeNode = returnType;
|
|
1914
|
+
if (ts.isTypeReferenceNode(returnType)) {
|
|
1915
|
+
const typeName = returnType.typeName.getText(sourceFile);
|
|
1916
|
+
if (typeName === 'Promise' && returnType.typeArguments?.length) {
|
|
1917
|
+
innerType = returnType.typeArguments[0];
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
// Convert to JSON Schema
|
|
1922
|
+
const schema = this.typeNodeToSchema(innerType, sourceFile);
|
|
1923
|
+
|
|
1924
|
+
// Only produce outputSchema for object types with properties
|
|
1925
|
+
if (schema.type === 'object' && schema.properties && Object.keys(schema.properties).length > 0) {
|
|
1926
|
+
const result: { type: 'object'; properties: Record<string, any>; required?: string[] } = {
|
|
1927
|
+
type: 'object',
|
|
1928
|
+
properties: schema.properties,
|
|
1929
|
+
};
|
|
1930
|
+
if (schema.required?.length) result.required = schema.required;
|
|
1931
|
+
return result;
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
return undefined;
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1759
1937
|
/**
|
|
1760
1938
|
* Extract notification subscriptions from @notify-on tag
|
|
1761
1939
|
* Specifies which event types this photon is interested in
|
|
@@ -2111,7 +2289,7 @@ export class SchemaExtractor {
|
|
|
2111
2289
|
}
|
|
2112
2290
|
|
|
2113
2291
|
// Match visualization formats
|
|
2114
|
-
if (['metric', 'gauge', 'timeline', 'dashboard', 'cart'].includes(format)) {
|
|
2292
|
+
if (['metric', 'gauge', 'timeline', 'dashboard', 'cart', 'qr'].includes(format)) {
|
|
2115
2293
|
return format as OutputFormat;
|
|
2116
2294
|
}
|
|
2117
2295
|
|
|
@@ -2177,11 +2355,50 @@ export class SchemaExtractor {
|
|
|
2177
2355
|
const withoutLayoutHints = jsdocContent.replace(/\{[^}]+\}/g, '');
|
|
2178
2356
|
const iconMatch = withoutLayoutHints.match(/@icon\s+([^\s@*,]+)/i);
|
|
2179
2357
|
if (iconMatch) {
|
|
2180
|
-
|
|
2358
|
+
const value = iconMatch[1].trim();
|
|
2359
|
+
// If it looks like a file path, don't return as emoji icon
|
|
2360
|
+
if (value.startsWith('./') || value.startsWith('../') || /\.(png|jpg|jpeg|gif|svg|webp|ico)$/i.test(value)) {
|
|
2361
|
+
return undefined;
|
|
2362
|
+
}
|
|
2363
|
+
return value;
|
|
2181
2364
|
}
|
|
2182
2365
|
return undefined;
|
|
2183
2366
|
}
|
|
2184
2367
|
|
|
2368
|
+
/**
|
|
2369
|
+
* Extract icon image entries from @icon (file path) and @icons tags
|
|
2370
|
+
* @icon ./icons/calc.png → [{ path: './icons/calc.png' }]
|
|
2371
|
+
* @icon ./icons/calc.svg → [{ path: './icons/calc.svg' }]
|
|
2372
|
+
* @icons ./icons/calc-48.png 48x48 → [{ path: '...', sizes: '48x48' }]
|
|
2373
|
+
* @icons ./icons/calc-dark.svg dark → [{ path: '...', theme: 'dark' }]
|
|
2374
|
+
* @icons ./icons/calc-96.png 96x96 dark → [{ path: '...', sizes: '96x96', theme: 'dark' }]
|
|
2375
|
+
*/
|
|
2376
|
+
private extractIconImages(jsdocContent: string): Array<{ path: string; sizes?: string; theme?: string }> | undefined {
|
|
2377
|
+
const images: Array<{ path: string; sizes?: string; theme?: string }> = [];
|
|
2378
|
+
|
|
2379
|
+
// Check if @icon value is a file path
|
|
2380
|
+
const withoutLayoutHints = jsdocContent.replace(/\{[^}]+\}/g, '');
|
|
2381
|
+
const iconMatch = withoutLayoutHints.match(/@icon\s+([^\s@*,]+)/i);
|
|
2382
|
+
if (iconMatch) {
|
|
2383
|
+
const value = iconMatch[1].trim();
|
|
2384
|
+
if (value.startsWith('./') || value.startsWith('../') || /\.(png|jpg|jpeg|gif|svg|webp|ico)$/i.test(value)) {
|
|
2385
|
+
images.push({ path: value });
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
// Extract @icons entries (can have multiple)
|
|
2390
|
+
const iconsRegex = /@icons\s+([^\s@*,]+)(?:\s+(\d+x\d+))?(?:\s+(light|dark))?/gi;
|
|
2391
|
+
let match: RegExpExecArray | null;
|
|
2392
|
+
while ((match = iconsRegex.exec(jsdocContent)) !== null) {
|
|
2393
|
+
const entry: { path: string; sizes?: string; theme?: string } = { path: match[1].trim() };
|
|
2394
|
+
if (match[2]) entry.sizes = match[2];
|
|
2395
|
+
if (match[3]) entry.theme = match[3] as 'light' | 'dark';
|
|
2396
|
+
images.push(entry);
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
return images.length > 0 ? images : undefined;
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2185
2402
|
/**
|
|
2186
2403
|
* Extract MIME type from @mimeType tag
|
|
2187
2404
|
* Example: @mimeType text/markdown
|