@portel/photon 1.23.1 → 1.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +66 -0
  2. package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
  3. package/dist/auto-ui/streamable-http-transport.js +262 -18
  4. package/dist/auto-ui/streamable-http-transport.js.map +1 -1
  5. package/dist/beam.bundle.js +58287 -56177
  6. package/dist/beam.bundle.js.map +4 -4
  7. package/dist/capability-negotiator.d.ts +9 -0
  8. package/dist/capability-negotiator.d.ts.map +1 -1
  9. package/dist/capability-negotiator.js +14 -0
  10. package/dist/capability-negotiator.js.map +1 -1
  11. package/dist/cli/commands/claim.d.ts +17 -0
  12. package/dist/cli/commands/claim.d.ts.map +1 -0
  13. package/dist/cli/commands/claim.js +124 -0
  14. package/dist/cli/commands/claim.js.map +1 -0
  15. package/dist/cli/commands/run.d.ts.map +1 -1
  16. package/dist/cli/commands/run.js +2 -0
  17. package/dist/cli/commands/run.js.map +1 -1
  18. package/dist/cli/index.d.ts.map +1 -1
  19. package/dist/cli/index.js +2 -0
  20. package/dist/cli/index.js.map +1 -1
  21. package/dist/daemon/claims.d.ts +108 -0
  22. package/dist/daemon/claims.d.ts.map +1 -0
  23. package/dist/daemon/claims.js +245 -0
  24. package/dist/daemon/claims.js.map +1 -0
  25. package/dist/daemon/client.d.ts.map +1 -1
  26. package/dist/daemon/client.js +15 -29
  27. package/dist/daemon/client.js.map +1 -1
  28. package/dist/daemon/cron.d.ts +36 -0
  29. package/dist/daemon/cron.d.ts.map +1 -0
  30. package/dist/daemon/cron.js +216 -0
  31. package/dist/daemon/cron.js.map +1 -0
  32. package/dist/daemon/schedule-loader.d.ts +76 -0
  33. package/dist/daemon/schedule-loader.d.ts.map +1 -0
  34. package/dist/daemon/schedule-loader.js +124 -0
  35. package/dist/daemon/schedule-loader.js.map +1 -0
  36. package/dist/daemon/server.js +76 -226
  37. package/dist/daemon/server.js.map +1 -1
  38. package/dist/deploy/cloudflare.d.ts.map +1 -1
  39. package/dist/deploy/cloudflare.js +154 -4
  40. package/dist/deploy/cloudflare.js.map +1 -1
  41. package/dist/loader.d.ts +22 -1
  42. package/dist/loader.d.ts.map +1 -1
  43. package/dist/loader.js +246 -30
  44. package/dist/loader.js.map +1 -1
  45. package/dist/photon-cli-runner.d.ts.map +1 -1
  46. package/dist/photon-cli-runner.js +32 -2
  47. package/dist/photon-cli-runner.js.map +1 -1
  48. package/dist/server.d.ts +10 -0
  49. package/dist/server.d.ts.map +1 -1
  50. package/dist/server.js +50 -1
  51. package/dist/server.js.map +1 -1
  52. package/dist/shared/memory-sqlite.d.ts +37 -0
  53. package/dist/shared/memory-sqlite.d.ts.map +1 -0
  54. package/dist/shared/memory-sqlite.js +143 -0
  55. package/dist/shared/memory-sqlite.js.map +1 -0
  56. package/dist/telemetry/context.d.ts +9 -0
  57. package/dist/telemetry/context.d.ts.map +1 -1
  58. package/dist/telemetry/context.js.map +1 -1
  59. package/package.json +4 -4
  60. package/templates/cloudflare/worker.ts.template +53 -74
  61. package/templates/cloudflare/wrangler.toml.template +6 -1
package/dist/loader.js CHANGED
@@ -12,7 +12,7 @@ import { fileURLToPath, pathToFileURL } from 'url';
12
12
  import * as crypto from 'crypto';
13
13
  import { startToolSpan } from './telemetry/otel.js';
14
14
  import { recordToolCall, recordCircuitStateChange, recordRateLimitRejection, recordBulkheadRejection, } from './telemetry/metrics.js';
15
- import { runWithRequestContext } from './telemetry/context.js';
15
+ import { runWithRequestContext, getRequestContext } from './telemetry/context.js';
16
16
  import { spawn, execSync } from 'child_process';
17
17
  import { SchemaExtractor, DependencyManager,
18
18
  // Generator utilities (ask/emit pattern from 1.2.0)
@@ -164,6 +164,68 @@ function injectEmitHelpers(instance) {
164
164
  function detectEmitHelperUsage(source) {
165
165
  return /this\.(toast|log|status|progress|thinking)\s*\(/.test(source);
166
166
  }
167
+ /**
168
+ * Always-inject `this.sample`, `this.confirm`, and `this.elicit` on plain
169
+ * classes. Each pulls its provider out of the ALS execution context
170
+ * (set by the loader when it invokes a tool). User-defined methods on
171
+ * the class win — these wrappers only fill in when absent.
172
+ *
173
+ * Mirrors the Photon base class implementations in photon-core/base.ts,
174
+ * so a plain class and a class that extends Photon behave identically.
175
+ */
176
+ function injectSamplingAndElicitation(instance) {
177
+ if (!instance.confirm) {
178
+ instance.confirm = async (question) => {
179
+ const store = executionContext.getStore();
180
+ if (!store?.inputProvider) {
181
+ throw new Error('this.confirm() requires a connected MCP client that supports ' +
182
+ 'elicitation. None is attached to the current invocation.');
183
+ }
184
+ const result = await store.inputProvider({
185
+ ask: 'confirm',
186
+ question,
187
+ message: question,
188
+ });
189
+ return Boolean(result);
190
+ };
191
+ }
192
+ if (!instance.elicit) {
193
+ instance.elicit = async (params) => {
194
+ const store = executionContext.getStore();
195
+ if (!store?.inputProvider) {
196
+ throw new Error('this.elicit() requires a connected MCP client that supports ' +
197
+ 'elicitation. None is attached to the current invocation.');
198
+ }
199
+ return await store.inputProvider(params);
200
+ };
201
+ }
202
+ if (!instance.sample) {
203
+ instance.sample = async (params) => {
204
+ const store = executionContext.getStore();
205
+ if (!store?.samplingProvider) {
206
+ throw new Error('this.sample() requires the connected MCP client to declare the ' +
207
+ '`sampling` capability. None is available in this invocation.');
208
+ }
209
+ if (!params.prompt && !params.messages?.length) {
210
+ throw new Error('this.sample() requires either `prompt` or `messages`.');
211
+ }
212
+ const messages = params.messages ?? [
213
+ { role: 'user', content: { type: 'text', text: params.prompt } },
214
+ ];
215
+ const result = await store.samplingProvider({
216
+ messages,
217
+ systemPrompt: params.systemPrompt,
218
+ maxTokens: params.maxTokens ?? 1024,
219
+ temperature: params.temperature,
220
+ modelPreferences: params.modelPreferences,
221
+ stopSequences: params.stopSequences,
222
+ includeContext: params.includeContext,
223
+ });
224
+ const first = Array.isArray(result.content) ? result.content[0] : result.content;
225
+ return first?.type === 'text' ? first.text : '';
226
+ };
227
+ }
228
+ }
167
229
  /**
168
230
  * Render a formatted value in the CLI using @portel/cli's formatOutput.
169
231
  * Uses clear-and-replace semantics — each call overwrites the previous render.
@@ -230,6 +292,14 @@ export class PhotonLoader {
230
292
  marketplaceManager;
231
293
  marketplaceManagerPromise;
232
294
  logger;
295
+ /**
296
+ * Per-instance call queue. Two tool invocations targeting the same photon
297
+ * instance are serialized: the second starts only after the first returns.
298
+ * Prevents lost-update and read-after-write races between methods that share
299
+ * state via `this.memory` or instance fields. Keyed on the instance object
300
+ * via WeakMap so hot-reload drops the queue automatically.
301
+ */
302
+ _instanceCallTails = new WeakMap();
233
303
  // ════════════════════════════════════════════════════════════════════════════
234
304
  // MIDDLEWARE STATE
235
305
  // ════════════════════════════════════════════════════════════════════════════
@@ -695,6 +765,31 @@ export class PhotonLoader {
695
765
  instance.instanceName = options?.instanceName ?? '';
696
766
  // Inject file path for storage()/assets() resolution
697
767
  instance._photonFilePath = absolutePath;
768
+ // Inject schedule-unschedule hook so `this.schedule.cancel()` evicts
769
+ // the in-memory cron registration at the same time as unlinking
770
+ // the file. Prevents ghost schedule registrations — the scenario
771
+ // where a cancel + re-enable under the same name leaves the old
772
+ // cron firing in parallel with the new one until the next daemon
773
+ // restart. The hook is best-effort: if the daemon is unreachable
774
+ // the fire-time phantom-prune (daemon checks sourceFile before
775
+ // running) catches the ghost on its next tick.
776
+ const photonNameForSchedule = name;
777
+ const scheduleLogger = this.logger;
778
+ instance._scheduleUnscheduleHook = async (jobId) => {
779
+ try {
780
+ const { unscheduleJob } = await import('./daemon/client.js');
781
+ return await unscheduleJob(photonNameForSchedule, jobId);
782
+ }
783
+ catch (err) {
784
+ // Daemon unreachable or IPC failure — log loudly so operators
785
+ // notice. Phantom-prune (daemon checks sourceFile before
786
+ // running) still evicts the ghost on its next fire, but for
787
+ // low-frequency schedules that may be hours away. Returning
788
+ // false lets `schedule.cancel` surface the outcome too.
789
+ scheduleLogger.warn(`[schedule] failed to evict in-memory cron registration for ${photonNameForSchedule}:${jobId}`, { error: err instanceof Error ? err.message : String(err) });
790
+ return false;
791
+ }
792
+ };
698
793
  // Inject this.shell — execSync wrapper with cwd defaulted to the
699
794
  // photon's own folder. Shelling out from a photon method should run
700
795
  // from where the photon lives, not the daemon's cwd, so that
@@ -778,27 +873,33 @@ export class PhotonLoader {
778
873
  if (typeof instance._callHandler === 'undefined' || instance._callHandler === undefined) {
779
874
  const callBaseDir = this.baseDir;
780
875
  instance._callHandler = async (photonName, method, params, targetInstance) => {
781
- // Propagate trace context to nested photon-to-photon calls so the
782
- // downstream span chains as a child of the current span. Uses
783
- // in-band `_meta.traceparent` which executeTool peeks at before
784
- // schema validation works through worker threads transparently.
876
+ // Propagate trace context and caller cwd to nested photon-to-photon
877
+ // calls so the downstream span chains as a child of the current span
878
+ // and the callee can resolve relative paths against the originating
879
+ // CLI directory. Uses in-band `_meta` which executeTool peeks at
880
+ // before schema validation: works through worker threads transparently.
785
881
  const ctxMod = await import('./telemetry/context.js');
786
882
  const ctx = ctxMod.getRequestContext();
787
883
  let forwardedParams = params;
884
+ const metaAdditions = {};
788
885
  if (ctx) {
789
886
  const traceparent = ctx.parentTraceparent ||
790
887
  (ctx.traceId
791
888
  ? `00-${ctx.traceId}-${crypto.randomBytes(8).toString('hex')}-01`
792
889
  : undefined);
793
- if (traceparent) {
794
- const existingMeta = params?._meta;
795
- forwardedParams = {
796
- ...(params || {}),
797
- _meta: existingMeta && typeof existingMeta === 'object'
798
- ? { traceparent, ...existingMeta }
799
- : { traceparent },
800
- };
801
- }
890
+ if (traceparent)
891
+ metaAdditions.traceparent = traceparent;
892
+ if (ctx.cwd)
893
+ metaAdditions.callerCwd = ctx.cwd;
894
+ }
895
+ if (Object.keys(metaAdditions).length > 0) {
896
+ const existingMeta = params?._meta;
897
+ forwardedParams = {
898
+ ...(params || {}),
899
+ _meta: existingMeta && typeof existingMeta === 'object'
900
+ ? { ...metaAdditions, ...existingMeta }
901
+ : metaAdditions,
902
+ };
802
903
  }
803
904
  // Dynamic import to avoid circular dependency
804
905
  const { sendCommand } = await import('./daemon/client.js');
@@ -866,8 +967,18 @@ export class PhotonLoader {
866
967
  // Inject convenience helpers (render, toast, log, status, progress, thinking)
867
968
  injectEmitHelpers(instance);
868
969
  }
869
- if (caps.has('memory')) {
870
- // Inject lazy memory provider capture baseDir from loader context
970
+ // Always-inject lazy memory provider. The getter is a pure closure
971
+ // that constructs MemoryProvider on first access zero cost until
972
+ // used. Gating this on a regex (`detectCapabilities`) missed TS
973
+ // cast shapes with function-type parens like
974
+ // `(this as unknown as { memory: { set: (k: string) => void } })` —
975
+ // the classic TypeScript workaround when memory isn't on the
976
+ // declared class — and silently left `this.memory` undefined. That
977
+ // turned every `.memory.set(...)` call into a data-loss bug that
978
+ // only surfaced on daemon cold start.
979
+ // User-defined memory wins; `in` walks the prototype chain
980
+ // so Photon-base getters aren't silently shadowed.
981
+ if (!('memory' in instance)) {
871
982
  const memoryBaseDir = this.baseDir;
872
983
  Object.defineProperty(instance, 'memory', {
873
984
  get() {
@@ -879,6 +990,20 @@ export class PhotonLoader {
879
990
  configurable: true,
880
991
  });
881
992
  }
993
+ // Always-inject `this.callerCwd`. Reads the originating CLI cwd from
994
+ // the request context, falling back to `process.cwd()` when no context
995
+ // is active (direct in-process load with no caller info). Inside a
996
+ // daemon worker `process.cwd()` is the daemon's cwd, so photons that
997
+ // resolve defaults relative to the user's invocation directory must
998
+ // prefer `this.callerCwd`.
999
+ if (!('callerCwd' in instance)) {
1000
+ Object.defineProperty(instance, 'callerCwd', {
1001
+ get() {
1002
+ return getRequestContext()?.cwd ?? process.cwd();
1003
+ },
1004
+ configurable: true,
1005
+ });
1006
+ }
882
1007
  // Inject call() for cross-photon communication. Always-inject (no
883
1008
  // capability-detection gate) because the underlying _callHandler
884
1009
  // is ALWAYS wired above. Gating on a regex that matches literal
@@ -915,6 +1040,13 @@ export class PhotonLoader {
915
1040
  return client;
916
1041
  };
917
1042
  }
1043
+ // Always-inject sample/confirm/elicit helpers. These read the
1044
+ // relevant provider from the ALS execution context the loader
1045
+ // populates when it invokes a tool. Injections are pure closures —
1046
+ // zero cost until the method uses them. Matches the always-inject
1047
+ // philosophy: detection-gate misses mean silent breakage, so don't
1048
+ // gate cheap capabilities. User-defined methods win.
1049
+ injectSamplingAndElicitation(instance);
918
1050
  // Always-inject caller getter. The prototype check preserves any
919
1051
  // user-defined getter on the class (e.g. Photon base class has its
920
1052
  // own) so we don't clobber.
@@ -955,8 +1087,10 @@ export class PhotonLoader {
955
1087
  },
956
1088
  };
957
1089
  }
958
- if (caps.has('instanceMeta')) {
959
- // Inject instance metadata (file-stat-based timestamps)
1090
+ // Always-inject instance metadata. One file-stat call per load is
1091
+ // cheap and avoids the same detection-gate trap as memory. User-
1092
+ // defined instanceMeta on the class wins.
1093
+ if (!instance.instanceMeta) {
960
1094
  const instanceName = options?.instanceName || 'default';
961
1095
  const stateFile = getInstanceStatePath(name, instanceName, this.baseDir);
962
1096
  try {
@@ -1309,8 +1443,34 @@ export class PhotonLoader {
1309
1443
  if (typeof instance._callHandler === 'undefined' || instance._callHandler === undefined) {
1310
1444
  const callBaseDir = this.baseDir;
1311
1445
  instance._callHandler = async (photonName, method, params, targetInstance) => {
1446
+ // Mirror the primary path: propagate trace context and caller cwd via
1447
+ // in-band `_meta` so the callee resolves relative paths against the
1448
+ // originating CLI directory and traces chain across worker boundaries.
1449
+ const ctxMod = await import('./telemetry/context.js');
1450
+ const ctx = ctxMod.getRequestContext();
1451
+ let forwardedParams = params;
1452
+ const metaAdditions = {};
1453
+ if (ctx) {
1454
+ const traceparent = ctx.parentTraceparent ||
1455
+ (ctx.traceId
1456
+ ? `00-${ctx.traceId}-${crypto.randomBytes(8).toString('hex')}-01`
1457
+ : undefined);
1458
+ if (traceparent)
1459
+ metaAdditions.traceparent = traceparent;
1460
+ if (ctx.cwd)
1461
+ metaAdditions.callerCwd = ctx.cwd;
1462
+ }
1463
+ if (Object.keys(metaAdditions).length > 0) {
1464
+ const existingMeta = params?._meta;
1465
+ forwardedParams = {
1466
+ ...(params || {}),
1467
+ _meta: existingMeta && typeof existingMeta === 'object'
1468
+ ? { ...metaAdditions, ...existingMeta }
1469
+ : metaAdditions,
1470
+ };
1471
+ }
1312
1472
  const { sendCommand } = await import('./daemon/client.js');
1313
- return sendCommand(photonName, method, params, {
1473
+ return sendCommand(photonName, method, forwardedParams, {
1314
1474
  workingDir: callBaseDir,
1315
1475
  targetInstance,
1316
1476
  });
@@ -1359,7 +1519,8 @@ export class PhotonLoader {
1359
1519
  // Inject convenience helpers (render, toast, log, status, progress, thinking)
1360
1520
  injectEmitHelpers(instance);
1361
1521
  }
1362
- if (caps.has('memory')) {
1522
+ // Always-inject memory (see primary load path for rationale).
1523
+ if (!('memory' in instance)) {
1363
1524
  const memoryBaseDir = this.baseDir;
1364
1525
  Object.defineProperty(instance, 'memory', {
1365
1526
  get() {
@@ -1371,6 +1532,15 @@ export class PhotonLoader {
1371
1532
  configurable: true,
1372
1533
  });
1373
1534
  }
1535
+ // Always-inject callerCwd (see primary load path for rationale).
1536
+ if (!('callerCwd' in instance)) {
1537
+ Object.defineProperty(instance, 'callerCwd', {
1538
+ get() {
1539
+ return getRequestContext()?.cwd ?? process.cwd();
1540
+ },
1541
+ configurable: true,
1542
+ });
1543
+ }
1374
1544
  // Always-inject call() (see the primary load path for rationale).
1375
1545
  if (!instance.call) {
1376
1546
  instance.call = async (target, params = {}, opts) => {
@@ -1384,6 +1554,8 @@ export class PhotonLoader {
1384
1554
  return instance._callHandler(target.slice(0, dotIndex), target.slice(dotIndex + 1), params, opts?.instance);
1385
1555
  };
1386
1556
  }
1557
+ // Always-inject sample/confirm/elicit (see primary load path).
1558
+ injectSamplingAndElicitation(instance);
1387
1559
  }
1388
1560
  // Channel event capability: inject on()/off()/_dispatch()/_matchesFilter()
1389
1561
  // when source uses this._dispatch( — the universal channel dispatch pattern.
@@ -2890,29 +3062,63 @@ Run: photon mcp ${mcpName} --config
2890
3062
  * @returns Tool result, or wrapped result with runId for stateful workflows
2891
3063
  */
2892
3064
  async executeTool(mcp, toolName, parameters, options) {
2893
- // Resolve parentTraceparent from options or in-band `_meta.traceparent` so
2894
- // the ALS context reflects the true parent for any nested `this.call()`.
3065
+ // Resolve parentTraceparent and callerCwd from options or in-band `_meta`
3066
+ // so the ALS context reflects the true parent for any nested `this.call()`
3067
+ // and the originating CLI invocation directory propagates through worker
3068
+ // boundaries.
2895
3069
  let resolvedParentTraceparent = options?.parentTraceparent;
2896
- if (!resolvedParentTraceparent &&
2897
- parameters &&
2898
- typeof parameters === 'object' &&
2899
- '_meta' in parameters) {
3070
+ let resolvedCallerCwd;
3071
+ if (parameters && typeof parameters === 'object' && '_meta' in parameters) {
2900
3072
  const metaPeek = parameters._meta;
2901
- if (metaPeek && typeof metaPeek.traceparent === 'string') {
2902
- resolvedParentTraceparent = metaPeek.traceparent;
3073
+ if (metaPeek) {
3074
+ if (!resolvedParentTraceparent && typeof metaPeek.traceparent === 'string') {
3075
+ resolvedParentTraceparent = metaPeek.traceparent;
3076
+ }
3077
+ if (typeof metaPeek.callerCwd === 'string') {
3078
+ resolvedCallerCwd = metaPeek.callerCwd;
3079
+ }
2903
3080
  }
2904
3081
  }
2905
- return runWithRequestContext({
3082
+ const run = () => runWithRequestContext({
2906
3083
  photon: mcp.name,
2907
3084
  tool: toolName,
2908
3085
  traceId: options?.traceId,
2909
3086
  parentTraceparent: resolvedParentTraceparent,
2910
3087
  caller: options?.caller,
3088
+ cwd: resolvedCallerCwd,
2911
3089
  startedAt: Date.now(),
2912
3090
  }, () => this._executeToolInner(mcp, toolName, parameters, {
2913
3091
  ...options,
2914
3092
  parentTraceparent: resolvedParentTraceparent,
2915
3093
  }));
3094
+ const gateKey = mcp.instance;
3095
+ if (!gateKey)
3096
+ return run();
3097
+ return this._withInstanceGate(gateKey, run);
3098
+ }
3099
+ /**
3100
+ * Serialize calls targeting the same photon instance. Concurrent callers
3101
+ * queue behind the current in-flight call; each invocation gets an exclusive
3102
+ * slot for the duration of its method body (including all awaits), so two
3103
+ * methods that touch shared state via `this.memory` cannot interleave.
3104
+ */
3105
+ async _withInstanceGate(instance, fn) {
3106
+ const prev = this._instanceCallTails.get(instance) ?? Promise.resolve();
3107
+ let release;
3108
+ const tail = new Promise((r) => {
3109
+ release = r;
3110
+ });
3111
+ this._instanceCallTails.set(instance, tail);
3112
+ try {
3113
+ await prev;
3114
+ return await fn();
3115
+ }
3116
+ finally {
3117
+ release();
3118
+ if (this._instanceCallTails.get(instance) === tail) {
3119
+ this._instanceCallTails.delete(instance);
3120
+ }
3121
+ }
2916
3122
  }
2917
3123
  async _executeToolInner(mcp, toolName, parameters, options) {
2918
3124
  // Start audit trail recording
@@ -3210,9 +3416,19 @@ Run: photon mcp ${mcpName} --config
3210
3416
  }
3211
3417
  let generatorFn = isStatic
3212
3418
  ? () => method.call(null, ...args)
3213
- : () => executionContext.run({
3419
+ : () => executionContext.run(
3420
+ // inputProvider and samplingProvider are attached to the ALS
3421
+ // store so photon-core's `this.confirm()`, `this.elicit()`,
3422
+ // and `this.sample()` can find them without every method
3423
+ // having to accept runtime plumbing as an argument. The
3424
+ // @portel/cli ExecutionContext type doesn't declare these
3425
+ // optional fields, so the object is cast through `unknown`;
3426
+ // photon-core reads them back with matching casts.
3427
+ {
3214
3428
  outputHandler: outputHandler,
3215
3429
  caller: options?.caller,
3430
+ inputProvider,
3431
+ samplingProvider: options?.samplingProvider,
3216
3432
  }, () => {
3217
3433
  return method.call(mcp.instance, ...args);
3218
3434
  });