@portel/photon-core 2.24.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/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
- return true;
376
+ removed = true;
341
377
  } catch (err: any) {
342
- if (err.code === 'ENOENT') return false;
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
  /**
@@ -3143,8 +3143,15 @@ export type PhotonCapability = 'emit' | 'memory' | 'call' | 'mcp' | 'lock' | 'in
3143
3143
  * compensates by always-injecting the cheap convenience methods whose
3144
3144
  * gating would otherwise silently fail for those patterns.
3145
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.
3146
3153
  const THIS_BASE =
3147
- String.raw`(?:\bthis\b|\(\s*<[^>]+>\s*this\s*\)|\(\s*this\s+as\s+[^)]+\))`;
3154
+ String.raw`(?:\bthis\b|\(\s*<[^>]+>\s*this\s*\)|\(\s*this\s+as\s+(?:[^()]|\([^()]*\))+\))`;
3148
3155
 
3149
3156
  function memberAccess(name: string, trailing: '\\(' | '\\b'): RegExp {
3150
3157
  return new RegExp(`${THIS_BASE}\\s*\\.\\s*${name}\\s*${trailing}`);