@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/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(name, this._baseDir);
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
- 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
  /**
@@ -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 contains @Template tag
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 contains @Static tag
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 @Static tag
2452
- * Example: @Static github://repos/{owner}/{repo}/readme
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}`);