@portel/photon-core 2.24.0 → 2.26.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/asset-discovery.d.ts +8 -2
- package/dist/asset-discovery.d.ts.map +1 -1
- package/dist/asset-discovery.js +46 -9
- package/dist/asset-discovery.js.map +1 -1
- package/dist/base.d.ts +126 -0
- package/dist/base.d.ts.map +1 -1
- package/dist/base.js +178 -1
- package/dist/base.js.map +1 -1
- 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 +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/schedule.d.ts +32 -2
- package/dist/schedule.d.ts.map +1 -1
- package/dist/schedule.js +53 -7
- package/dist/schedule.js.map +1 -1
- package/dist/schema-extractor.d.ts +21 -5
- package/dist/schema-extractor.d.ts.map +1 -1
- package/dist/schema-extractor.js +40 -8
- package/dist/schema-extractor.js.map +1 -1
- package/package.json +1 -1
- package/src/asset-discovery.ts +44 -9
- package/src/base.ts +202 -1
- package/src/generator.ts +93 -0
- package/src/index.ts +6 -0
- package/src/schedule.ts +73 -5
- package/src/schema-extractor.ts +53 -9
package/src/base.ts
CHANGED
|
@@ -48,6 +48,13 @@ import { ScheduleProvider } from './schedule.js';
|
|
|
48
48
|
import * as path from 'path';
|
|
49
49
|
import * as fs from 'fs';
|
|
50
50
|
import { getPhotonDataDir } from './data-paths.js';
|
|
51
|
+
import type {
|
|
52
|
+
AskYield,
|
|
53
|
+
InputProvider,
|
|
54
|
+
SampleParams,
|
|
55
|
+
SamplingMessage,
|
|
56
|
+
SamplingProvider,
|
|
57
|
+
} from './generator.js';
|
|
51
58
|
|
|
52
59
|
/**
|
|
53
60
|
* Simple base class for creating Photons
|
|
@@ -154,6 +161,184 @@ export class Photon {
|
|
|
154
161
|
return store?.caller ?? { id: 'anonymous', anonymous: true };
|
|
155
162
|
}
|
|
156
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Ask the caller a yes/no question and await the answer.
|
|
166
|
+
*
|
|
167
|
+
* Imperative sugar over the existing `{ ask: 'confirm' }` yield.
|
|
168
|
+
* Works in any method (generator or plain async), routed through the
|
|
169
|
+
* runtime's elicitation pipeline — returns `true`/`false` based on the
|
|
170
|
+
* user's response in whichever surface the client offers (Beam
|
|
171
|
+
* dialog, Claude confirm prompt, CLI readline, etc.).
|
|
172
|
+
*
|
|
173
|
+
* @throws If no client with elicitation capability is connected.
|
|
174
|
+
*
|
|
175
|
+
* @example
|
|
176
|
+
* ```typescript
|
|
177
|
+
* if (await this.confirm('Delete all records?')) {
|
|
178
|
+
* await purge();
|
|
179
|
+
* }
|
|
180
|
+
* ```
|
|
181
|
+
*/
|
|
182
|
+
async confirm(question: string): Promise<boolean> {
|
|
183
|
+
const provider = this._resolveInputProvider('this.confirm()');
|
|
184
|
+
const result = await provider({ ask: 'confirm', question, message: question } as AskYield);
|
|
185
|
+
return Boolean(result);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Pose an arbitrary elicitation request and await the answer.
|
|
190
|
+
*
|
|
191
|
+
* Accepts any `AskYield` (text, password, select, number, form, etc.)
|
|
192
|
+
* and returns the client's response. Prefer `this.confirm()` for
|
|
193
|
+
* yes/no; use `yield { ask: ... }` inside async-generator workflows.
|
|
194
|
+
* `this.elicit()` is the imperative form for plain async methods
|
|
195
|
+
* that need a single input without the generator plumbing.
|
|
196
|
+
*
|
|
197
|
+
* The name matches MCP spec terminology (`elicitation/create`) to
|
|
198
|
+
* avoid ambiguity with the legacy `this.ask(type, message, opts)`
|
|
199
|
+
* factory, which just constructs an `AskYield` object for use with
|
|
200
|
+
* `yield`.
|
|
201
|
+
*
|
|
202
|
+
* @throws If no client with elicitation capability is connected.
|
|
203
|
+
*/
|
|
204
|
+
async elicit<T = unknown>(params: AskYield): Promise<T> {
|
|
205
|
+
const provider = this._resolveInputProvider('this.elicit()');
|
|
206
|
+
return (await provider(params)) as T;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Ask the client's LLM to generate text (MCP `sampling/createMessage`).
|
|
211
|
+
*
|
|
212
|
+
* The caller's agent model generates the completion — no API key
|
|
213
|
+
* needed in the photon, and inference cost is borne by whoever is
|
|
214
|
+
* driving the tool. The client must declare the `sampling`
|
|
215
|
+
* capability during initialize; otherwise this throws.
|
|
216
|
+
*
|
|
217
|
+
* Returns just the generated text for the common case. For
|
|
218
|
+
* multi-block / image / structured responses, access the underlying
|
|
219
|
+
* provider via the runtime.
|
|
220
|
+
*
|
|
221
|
+
* @example
|
|
222
|
+
* ```typescript
|
|
223
|
+
* async summarize(params: { text: string }) {
|
|
224
|
+
* return await this.sample({
|
|
225
|
+
* prompt: `Summarize this in one sentence:\n\n${params.text}`,
|
|
226
|
+
* maxTokens: 128,
|
|
227
|
+
* });
|
|
228
|
+
* }
|
|
229
|
+
* ```
|
|
230
|
+
*/
|
|
231
|
+
async sample(params: SampleParams): Promise<string> {
|
|
232
|
+
const store = executionContext.getStore() as { samplingProvider?: SamplingProvider } | undefined;
|
|
233
|
+
const provider = store?.samplingProvider;
|
|
234
|
+
if (!provider) {
|
|
235
|
+
throw new Error(
|
|
236
|
+
'this.sample() requires the connected MCP client to declare the ' +
|
|
237
|
+
'`sampling` capability. None is available in this invocation — ' +
|
|
238
|
+
"either the client didn't declare sampling during initialize, or " +
|
|
239
|
+
'this method is being called outside an MCP request context (e.g. ' +
|
|
240
|
+
'from a scheduled task without a live session).'
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
if (!params.prompt && !params.messages?.length) {
|
|
244
|
+
throw new Error('this.sample() requires either `prompt` or `messages`.');
|
|
245
|
+
}
|
|
246
|
+
const messages: SamplingMessage[] =
|
|
247
|
+
params.messages ??
|
|
248
|
+
[{ role: 'user', content: { type: 'text', text: params.prompt! } }];
|
|
249
|
+
const result = await provider({
|
|
250
|
+
messages,
|
|
251
|
+
systemPrompt: params.systemPrompt,
|
|
252
|
+
maxTokens: params.maxTokens ?? 1024,
|
|
253
|
+
temperature: params.temperature,
|
|
254
|
+
modelPreferences: params.modelPreferences,
|
|
255
|
+
stopSequences: params.stopSequences,
|
|
256
|
+
includeContext: params.includeContext,
|
|
257
|
+
});
|
|
258
|
+
const first = Array.isArray(result.content) ? result.content[0] : result.content;
|
|
259
|
+
if (first && first.type === 'text') return first.text;
|
|
260
|
+
// Non-text responses (image-only) — return empty string rather than
|
|
261
|
+
// a JSON-stringified blob so callers can reliably concatenate the
|
|
262
|
+
// result. Users needing image output should hit the provider directly.
|
|
263
|
+
return '';
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Workspace roots declared by the connected MCP client (per spec
|
|
268
|
+
* `roots/list`). Each entry is a `{ uri, name? }` pair; URIs always start
|
|
269
|
+
* with `file://` per the current spec.
|
|
270
|
+
*
|
|
271
|
+
* Empty when:
|
|
272
|
+
* - the client did not declare the `roots` capability,
|
|
273
|
+
* - the photon is running outside a live MCP session (CLI / unit test),
|
|
274
|
+
* - the client returned an empty list.
|
|
275
|
+
*
|
|
276
|
+
* The runtime fetches `roots/list` once per session and refreshes on
|
|
277
|
+
* `notifications/roots/list_changed`, so reads inside a tool call are
|
|
278
|
+
* synchronous and consistent.
|
|
279
|
+
*
|
|
280
|
+
* @example
|
|
281
|
+
* ```typescript
|
|
282
|
+
* async listProjectFiles() {
|
|
283
|
+
* const roots = this.roots;
|
|
284
|
+
* if (roots.length === 0) return [];
|
|
285
|
+
* // ...resolve files under each root.uri...
|
|
286
|
+
* }
|
|
287
|
+
* ```
|
|
288
|
+
*/
|
|
289
|
+
get roots(): Array<{ uri: string; name?: string }> {
|
|
290
|
+
const store = executionContext.getStore() as
|
|
291
|
+
| { roots?: Array<{ uri: string; name?: string }> }
|
|
292
|
+
| undefined;
|
|
293
|
+
return store?.roots ?? [];
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Notify subscribed MCP clients that the resource at `uri` has changed.
|
|
298
|
+
* Triggers `notifications/resources/updated` for every client that has
|
|
299
|
+
* issued `resources/subscribe` against this exact URI.
|
|
300
|
+
*
|
|
301
|
+
* Default no-op so calls from CLI execution (no live MCP server) do not
|
|
302
|
+
* throw. The runtime overrides this with a wired version on every
|
|
303
|
+
* instance it constructs; that wired version dispatches into the
|
|
304
|
+
* server's subscription registry.
|
|
305
|
+
*
|
|
306
|
+
* Use it from `@stateful` methods or any code path that mutates the
|
|
307
|
+
* data behind a `@resource <uri-template>` resolver:
|
|
308
|
+
*
|
|
309
|
+
* @example
|
|
310
|
+
* ```typescript
|
|
311
|
+
* async upsertPerson(params: { slug: string; ... }) {
|
|
312
|
+
* this.people[params.slug] = ...;
|
|
313
|
+
* this.notifyResourceUpdated(`person://${params.slug}`);
|
|
314
|
+
* }
|
|
315
|
+
* ```
|
|
316
|
+
*/
|
|
317
|
+
notifyResourceUpdated(_uri: string): void {
|
|
318
|
+
// Runtime injects a wired version on every instance — see
|
|
319
|
+
// photon/src/loader.ts injectResourceNotifier. This default is for
|
|
320
|
+
// standalone use (CLI, unit tests) where no MCP server is attached.
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Internal: resolve the runtime-supplied input provider, throwing a
|
|
325
|
+
* clear error if none is attached to the current execution context.
|
|
326
|
+
* @internal
|
|
327
|
+
*/
|
|
328
|
+
private _resolveInputProvider(forMethod: string): InputProvider {
|
|
329
|
+
const store = executionContext.getStore() as { inputProvider?: InputProvider } | undefined;
|
|
330
|
+
const provider = store?.inputProvider;
|
|
331
|
+
if (!provider) {
|
|
332
|
+
throw new Error(
|
|
333
|
+
`${forMethod} requires a connected MCP client that supports elicitation. ` +
|
|
334
|
+
'The runtime attaches an input provider for every tool invocation; ' +
|
|
335
|
+
"this call is running outside that context (e.g. a background task " +
|
|
336
|
+
'without a session, or a client that declined elicitation).'
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
return provider;
|
|
340
|
+
}
|
|
341
|
+
|
|
157
342
|
/**
|
|
158
343
|
* Scoped key-value storage for photon data
|
|
159
344
|
*
|
|
@@ -231,11 +416,27 @@ export class Photon {
|
|
|
231
416
|
.replace(/([A-Z])/g, '-$1')
|
|
232
417
|
.toLowerCase()
|
|
233
418
|
.replace(/^-/, '');
|
|
234
|
-
this._schedule = new ScheduleProvider(
|
|
419
|
+
this._schedule = new ScheduleProvider(
|
|
420
|
+
name,
|
|
421
|
+
this._baseDir,
|
|
422
|
+
this._scheduleUnscheduleHook
|
|
423
|
+
);
|
|
235
424
|
}
|
|
236
425
|
return this._schedule;
|
|
237
426
|
}
|
|
238
427
|
|
|
428
|
+
/**
|
|
429
|
+
* Runtime-injected hook that evicts an in-memory cron registration.
|
|
430
|
+
* The photon runtime (photon-repo loader) attaches a callback that
|
|
431
|
+
* IPCs the daemon's `unschedule` request; photon-core doesn't know
|
|
432
|
+
* about the daemon, so the hook is passed through to ScheduleProvider
|
|
433
|
+
* which calls it after unlinking the disk file. Without the hook,
|
|
434
|
+
* `this.schedule.cancel()` leaves a ghost registration that keeps
|
|
435
|
+
* firing until the next daemon restart.
|
|
436
|
+
* @internal
|
|
437
|
+
*/
|
|
438
|
+
_scheduleUnscheduleHook?: import('./schedule.js').UnscheduleHook;
|
|
439
|
+
|
|
239
440
|
/**
|
|
240
441
|
* Get an absolute path to a storage directory for this photon's data.
|
|
241
442
|
*
|
package/src/generator.ts
CHANGED
|
@@ -921,6 +921,99 @@ export type InputProvider = (ask: AskYield) => Promise<any>;
|
|
|
921
921
|
*/
|
|
922
922
|
export type OutputHandler = (emit: EmitYield) => void | Promise<void>;
|
|
923
923
|
|
|
924
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
925
|
+
// SAMPLING - Photon-to-client LLM requests (MCP sampling/createMessage)
|
|
926
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* A message in an MCP sampling conversation.
|
|
930
|
+
*
|
|
931
|
+
* Mirrors `SamplingMessage` in the MCP spec: `role` plus either text or
|
|
932
|
+
* image content. Most photon callers only need `{ role: 'user', content:
|
|
933
|
+
* { type: 'text', text: '...' } }` — use the `prompt` shortcut on
|
|
934
|
+
* `SampleParams` to build that automatically.
|
|
935
|
+
*/
|
|
936
|
+
export interface SamplingMessage {
|
|
937
|
+
role: 'user' | 'assistant';
|
|
938
|
+
content:
|
|
939
|
+
| { type: 'text'; text: string }
|
|
940
|
+
| { type: 'image'; data: string; mimeType: string };
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* Preferences passed to the client's sampling LLM (MCP modelPreferences).
|
|
945
|
+
* All fields are advisory — the client picks the final model.
|
|
946
|
+
*/
|
|
947
|
+
export interface ModelPreferences {
|
|
948
|
+
/** Hints at desired models, in priority order (e.g. `[{name:'claude-3-5-sonnet'}]`) */
|
|
949
|
+
hints?: Array<{ name: string }>;
|
|
950
|
+
/** 0-1 weight favoring cheaper models */
|
|
951
|
+
costPriority?: number;
|
|
952
|
+
/** 0-1 weight favoring faster responses */
|
|
953
|
+
speedPriority?: number;
|
|
954
|
+
/** 0-1 weight favoring stronger models */
|
|
955
|
+
intelligencePriority?: number;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* Parameters for `this.sample()`.
|
|
960
|
+
*
|
|
961
|
+
* Pick ONE of `prompt` or `messages`. `prompt` is the ergonomic path:
|
|
962
|
+
* it becomes a single user-role text message. `messages` gives full
|
|
963
|
+
* control over multi-turn conversations or image content.
|
|
964
|
+
*/
|
|
965
|
+
export interface SampleParams {
|
|
966
|
+
/** Convenience: wraps the string as a single user message */
|
|
967
|
+
prompt?: string;
|
|
968
|
+
/** Full message array (alternative to `prompt`) */
|
|
969
|
+
messages?: SamplingMessage[];
|
|
970
|
+
/** Optional system prompt */
|
|
971
|
+
systemPrompt?: string;
|
|
972
|
+
/**
|
|
973
|
+
* Max tokens the client should generate. MCP spec requires this field;
|
|
974
|
+
* defaults to 1024 when omitted. Keep modest — sampling is not free.
|
|
975
|
+
*/
|
|
976
|
+
maxTokens?: number;
|
|
977
|
+
temperature?: number;
|
|
978
|
+
modelPreferences?: ModelPreferences;
|
|
979
|
+
stopSequences?: string[];
|
|
980
|
+
/** Whether the client should include this or other servers' context */
|
|
981
|
+
includeContext?: 'none' | 'thisServer' | 'allServers';
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* Result of a sampling request (a `CreateMessageResult` from MCP).
|
|
986
|
+
* The `content` union accommodates both single-block responses (the
|
|
987
|
+
* common case) and multi-block responses from tool-capable sampling.
|
|
988
|
+
*/
|
|
989
|
+
export interface SamplingResult {
|
|
990
|
+
role: 'assistant';
|
|
991
|
+
content:
|
|
992
|
+
| { type: 'text'; text: string }
|
|
993
|
+
| { type: 'image'; data: string; mimeType: string }
|
|
994
|
+
| Array<{ type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }>;
|
|
995
|
+
model: string;
|
|
996
|
+
stopReason?: string;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* Runtime hook that forwards a sampling request to the MCP client.
|
|
1001
|
+
*
|
|
1002
|
+
* Runtimes implement this by calling the client's
|
|
1003
|
+
* `sampling/createMessage`. When the client doesn't declare the
|
|
1004
|
+
* `sampling` capability, the runtime should throw or omit the provider
|
|
1005
|
+
* entirely — `this.sample()` surfaces a clear error in that case.
|
|
1006
|
+
*/
|
|
1007
|
+
export type SamplingProvider = (params: {
|
|
1008
|
+
messages: SamplingMessage[];
|
|
1009
|
+
systemPrompt?: string;
|
|
1010
|
+
maxTokens: number;
|
|
1011
|
+
temperature?: number;
|
|
1012
|
+
modelPreferences?: ModelPreferences;
|
|
1013
|
+
stopSequences?: string[];
|
|
1014
|
+
includeContext?: 'none' | 'thisServer' | 'allServers';
|
|
1015
|
+
}) => Promise<SamplingResult>;
|
|
1016
|
+
|
|
924
1017
|
/**
|
|
925
1018
|
* Configuration for generator execution
|
|
926
1019
|
*/
|
package/src/index.ts
CHANGED
|
@@ -265,6 +265,12 @@ export {
|
|
|
265
265
|
type OutputHandler,
|
|
266
266
|
type GeneratorExecutorConfig,
|
|
267
267
|
type ExtractedAsk,
|
|
268
|
+
// Sampling (MCP createMessage) — powers this.sample()
|
|
269
|
+
type SampleParams,
|
|
270
|
+
type SamplingMessage,
|
|
271
|
+
type SamplingResult,
|
|
272
|
+
type SamplingProvider,
|
|
273
|
+
type ModelPreferences,
|
|
268
274
|
} from './generator.js';
|
|
269
275
|
|
|
270
276
|
// Stateful Workflow Execution
|
package/src/schedule.ts
CHANGED
|
@@ -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
|
*
|
|
@@ -172,6 +186,7 @@ async function ensureDir(dir: string): Promise<void> {
|
|
|
172
186
|
export class ScheduleProvider {
|
|
173
187
|
private _photonId: string;
|
|
174
188
|
private _baseDir?: string;
|
|
189
|
+
private _unscheduleHook?: UnscheduleHook;
|
|
175
190
|
|
|
176
191
|
/**
|
|
177
192
|
* @param photonId Photon identifier used as the bucket under .data/
|
|
@@ -180,10 +195,20 @@ export class ScheduleProvider {
|
|
|
180
195
|
* reads back later — mirrors the fix applied to MemoryProvider.
|
|
181
196
|
* Without it, photonScheduleDir falls through to PHOTON_DIR env or
|
|
182
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.
|
|
183
202
|
*/
|
|
184
|
-
constructor(photonId: string, baseDir?: string) {
|
|
203
|
+
constructor(photonId: string, baseDir?: string, unscheduleHook?: UnscheduleHook) {
|
|
185
204
|
this._photonId = photonId;
|
|
186
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}`;
|
|
187
212
|
}
|
|
188
213
|
|
|
189
214
|
/**
|
|
@@ -332,16 +357,59 @@ export class ScheduleProvider {
|
|
|
332
357
|
}
|
|
333
358
|
|
|
334
359
|
/**
|
|
335
|
-
* 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.
|
|
336
371
|
*/
|
|
337
372
|
async cancel(taskId: string): Promise<boolean> {
|
|
373
|
+
let removed = false;
|
|
338
374
|
try {
|
|
339
375
|
await fs.unlink(taskPath(this._photonId, taskId, this._baseDir));
|
|
340
|
-
|
|
376
|
+
removed = true;
|
|
341
377
|
} catch (err: any) {
|
|
342
|
-
if (err.code
|
|
343
|
-
throw err;
|
|
378
|
+
if (err.code !== 'ENOENT') throw err;
|
|
344
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;
|
|
345
413
|
}
|
|
346
414
|
|
|
347
415
|
/**
|
package/src/schema-extractor.ts
CHANGED
|
@@ -33,6 +33,12 @@ function warnHandlePrefixOnce(methodName: string): void {
|
|
|
33
33
|
);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
export interface HttpRoute {
|
|
37
|
+
method: 'GET' | 'POST';
|
|
38
|
+
path: string;
|
|
39
|
+
handler: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
36
42
|
export interface ExtractedMetadata {
|
|
37
43
|
tools: ExtractedSchema[];
|
|
38
44
|
templates: TemplateInfo[];
|
|
@@ -48,8 +54,11 @@ export interface ExtractedMetadata {
|
|
|
48
54
|
* - 'required': all methods require authenticated caller
|
|
49
55
|
* - 'optional': caller populated if token present, anonymous allowed
|
|
50
56
|
* - string URL: OIDC provider URL (implies required)
|
|
57
|
+
* - 'cf-access': use Cloudflare Access JWT to identify caller
|
|
51
58
|
*/
|
|
52
|
-
auth?: 'required' | 'optional' | string;
|
|
59
|
+
auth?: 'required' | 'optional' | 'cf-access' | string;
|
|
60
|
+
/** HTTP routes declared with @get or @post on individual methods */
|
|
61
|
+
httpRoutes?: HttpRoute[];
|
|
53
62
|
}
|
|
54
63
|
|
|
55
64
|
/**
|
|
@@ -93,6 +102,9 @@ export class SchemaExtractor {
|
|
|
93
102
|
// MCP OAuth auth requirement (from @auth tag)
|
|
94
103
|
let auth: 'required' | 'optional' | string | undefined;
|
|
95
104
|
|
|
105
|
+
// HTTP routes from @get / @post method-level tags
|
|
106
|
+
const httpRoutes: HttpRoute[] = [];
|
|
107
|
+
|
|
96
108
|
try {
|
|
97
109
|
// If source doesn't contain a class declaration, wrap it in one
|
|
98
110
|
let sourceToParse = source;
|
|
@@ -156,6 +168,18 @@ export class SchemaExtractor {
|
|
|
156
168
|
return;
|
|
157
169
|
}
|
|
158
170
|
|
|
171
|
+
// @get /path or @post /path — HTTP-only route, not an MCP tool
|
|
172
|
+
const getMatch = jsdoc.match(/@get\s+(\/\S*)/i);
|
|
173
|
+
const postMatch = jsdoc.match(/@post\s+(\/\S*)/i);
|
|
174
|
+
if (getMatch || postMatch) {
|
|
175
|
+
httpRoutes.push({
|
|
176
|
+
method: getMatch ? 'GET' : 'POST',
|
|
177
|
+
path: (getMatch ?? postMatch)![1],
|
|
178
|
+
handler: methodName,
|
|
179
|
+
});
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
159
183
|
// Check if this is an async generator method (has asterisk token)
|
|
160
184
|
const isGenerator = member.asteriskToken !== undefined;
|
|
161
185
|
|
|
@@ -562,6 +586,11 @@ export class SchemaExtractor {
|
|
|
562
586
|
result.auth = auth;
|
|
563
587
|
}
|
|
564
588
|
|
|
589
|
+
// Include HTTP routes if any
|
|
590
|
+
if (httpRoutes.length > 0) {
|
|
591
|
+
result.httpRoutes = httpRoutes;
|
|
592
|
+
}
|
|
593
|
+
|
|
565
594
|
return result;
|
|
566
595
|
}
|
|
567
596
|
|
|
@@ -1980,17 +2009,24 @@ export class SchemaExtractor {
|
|
|
1980
2009
|
}
|
|
1981
2010
|
|
|
1982
2011
|
/**
|
|
1983
|
-
* Check if JSDoc
|
|
2012
|
+
* Check if a method's JSDoc marks it as an MCP prompt template.
|
|
2013
|
+
* Canonical form: `@prompt`. Legacy form `@Template` still accepted
|
|
2014
|
+
* for backward compatibility with photons authored before the rename.
|
|
1984
2015
|
*/
|
|
1985
2016
|
private hasTemplateTag(jsdocContent: string): boolean {
|
|
1986
|
-
return /@Template/i.test(jsdocContent);
|
|
2017
|
+
return /@(?:Template|prompt)\b/i.test(jsdocContent);
|
|
1987
2018
|
}
|
|
1988
2019
|
|
|
1989
2020
|
/**
|
|
1990
|
-
* Check if JSDoc
|
|
2021
|
+
* Check if a method's JSDoc marks it as a dynamic MCP resource resolver.
|
|
2022
|
+
* Canonical form: `@resource <uri-template>`. Legacy form `@Static`
|
|
2023
|
+
* still accepted. Disambiguation from the class-level static-file form
|
|
2024
|
+
* (`@resource <id> <path>`) is by argument shape: the class-level form
|
|
2025
|
+
* requires `<id>` followed by a path starting with `./` or `/`, which
|
|
2026
|
+
* the URI-template form never matches.
|
|
1991
2027
|
*/
|
|
1992
2028
|
private hasStaticTag(jsdocContent: string): boolean {
|
|
1993
|
-
return /@Static/i.test(jsdocContent);
|
|
2029
|
+
return /@(?:Static|resource)\b/i.test(jsdocContent);
|
|
1994
2030
|
}
|
|
1995
2031
|
|
|
1996
2032
|
/**
|
|
@@ -2448,11 +2484,12 @@ export class SchemaExtractor {
|
|
|
2448
2484
|
}
|
|
2449
2485
|
|
|
2450
2486
|
/**
|
|
2451
|
-
* Extract URI pattern from
|
|
2452
|
-
*
|
|
2487
|
+
* Extract URI pattern from a method's resource annotation.
|
|
2488
|
+
* Canonical: `@resource github://repos/{owner}/{repo}/readme`
|
|
2489
|
+
* Legacy: `@Static github://repos/{owner}/{repo}/readme`
|
|
2453
2490
|
*/
|
|
2454
2491
|
private extractStaticURI(jsdocContent: string): string | null {
|
|
2455
|
-
const match = jsdocContent.match(/@Static\s+([\w:\/\{\}\-_.]+)/i);
|
|
2492
|
+
const match = jsdocContent.match(/@(?:Static|resource)\s+([\w:\/\{\}\-_.]+)/i);
|
|
2456
2493
|
return match ? match[1].trim() : null;
|
|
2457
2494
|
}
|
|
2458
2495
|
|
|
@@ -3143,8 +3180,15 @@ export type PhotonCapability = 'emit' | 'memory' | 'call' | 'mcp' | 'lock' | 'in
|
|
|
3143
3180
|
* compensates by always-injecting the cheap convenience methods whose
|
|
3144
3181
|
* gating would otherwise silently fail for those patterns.
|
|
3145
3182
|
*/
|
|
3183
|
+
// The `(this as <T>)` alternative allows one level of nested parens inside
|
|
3184
|
+
// the type annotation so function-type syntax like `(k: string) => void`
|
|
3185
|
+
// doesn't truncate the match. Without the `(?:[^()]|\([^()]*\))+` fallback,
|
|
3186
|
+
// `(this as unknown as { memory: { set: (k: string) => Promise<void> } })`
|
|
3187
|
+
// would terminate at the first inner `)` and the trailing `.memory` access
|
|
3188
|
+
// would never be seen — silently disabling this.memory injection for any
|
|
3189
|
+
// plain class that uses a complex TS type cast to reach memory.
|
|
3146
3190
|
const THIS_BASE =
|
|
3147
|
-
String.raw`(?:\bthis\b|\(\s*<[^>]+>\s*this\s*\)|\(\s*this\s+as\s+[^)]+\))`;
|
|
3191
|
+
String.raw`(?:\bthis\b|\(\s*<[^>]+>\s*this\s*\)|\(\s*this\s+as\s+(?:[^()]|\([^()]*\))+\))`;
|
|
3148
3192
|
|
|
3149
3193
|
function memberAccess(name: string, trailing: '\\(' | '\\b'): RegExp {
|
|
3150
3194
|
return new RegExp(`${THIS_BASE}\\s*\\.\\s*${name}\\s*${trailing}`);
|