@ixo/oracle-runtime-workers 0.39.0 → 0.41.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.
@@ -20,6 +20,28 @@ export interface RepetitionCaps {
20
20
 
21
21
  export const DEFAULT_REPETITION_CAPS: RepetitionCaps = { reads: 5, writes: 1 };
22
22
 
23
+ /**
24
+ * Tools that only find other tools; a model alternating between them never trips the identical-call cap.
25
+ * `load_capability` is not one: it is how a gated tool becomes callable.
26
+ */
27
+ export const DISCOVERY_TOOL_NAMES: ReadonlySet<string> = new Set([
28
+ 'list_capabilities',
29
+ 'list_skills',
30
+ 'search_skills',
31
+ ]);
32
+
33
+ function discoveryCallsInTurn(messages: readonly BaseMessage[]): number {
34
+ let count = 0;
35
+ for (let i = turnStart(messages); i < messages.length; i++) {
36
+ const msg = messages[i];
37
+ if (msg instanceof ToolMessage && DISCOVERY_TOOL_NAMES.has(msg.name ?? ''))
38
+ count += 1;
39
+ }
40
+ return count;
41
+ }
42
+ /** Discovery calls a turn may make, whatever their arguments. */
43
+ export const MAX_DISCOVERY_CALLS = 6;
44
+
23
45
  /**
24
46
  * Parse `TURN_MAX_IDENTICAL_READS` / `TURN_MAX_IDENTICAL_WRITES`; anything
25
47
  * that is not a positive number keeps its default (as `turnLimitsFromEnv`).
@@ -99,6 +121,18 @@ export const createToolRepetitionGuardMiddleware = (
99
121
 
100
122
  return createMiddleware({
101
123
  name: 'ToolRepetitionGuardMiddleware',
124
+ // A model can keep calling a refused tool forever; once the turn spent its searches, it no longer sees them.
125
+ wrapModelCall: (request, handler) => {
126
+ if (
127
+ discoveryCallsInTurn(request.state.messages ?? []) < MAX_DISCOVERY_CALLS
128
+ )
129
+ return handler(request);
130
+ const tools = request.tools.filter(
131
+ (t) =>
132
+ !(typeof t.name === 'string' && DISCOVERY_TOOL_NAMES.has(t.name)),
133
+ );
134
+ return handler({ ...request, tools });
135
+ },
102
136
  wrapToolCall: async (toolCallRequest, handler) => {
103
137
  const { toolCall, state } = toolCallRequest;
104
138
  const toolName = toolCall.name ?? toolCallRequest.tool?.name;
@@ -109,6 +143,26 @@ export const createToolRepetitionGuardMiddleware = (
109
143
  const opening = turnStart(messages);
110
144
  const start = Math.max(opening, messages.length - lookback, 0);
111
145
  const argsById = toolCallArgsById(messages, start);
146
+
147
+ if (DISCOVERY_TOOL_NAMES.has(toolName)) {
148
+ const discovered = discoveryCallsInTurn(messages);
149
+ if (discovered >= MAX_DISCOVERY_CALLS) {
150
+ logger.warn(
151
+ `Repetition guard: ${toolName} refused after ${discovered} discovery calls this turn`,
152
+ );
153
+ return new ToolMessage({
154
+ content: [
155
+ `You have already searched capabilities and skills ${discovered} times in this turn, so \`${toolName}\` was NOT run.`,
156
+ '',
157
+ 'Stop searching. Act with the tools you already have: read their descriptions and call the one that fits the request,',
158
+ 'or tell the user what you could not find and ask them what they want.',
159
+ ].join('\n'),
160
+ tool_call_id: toolCall.id ?? '',
161
+ name: toolName,
162
+ status: 'error',
163
+ });
164
+ }
165
+ }
112
166
  // The turn's calls a mid-turn summary condensed away (oldest first):
113
167
  // only while the lookback still reaches the summary.
114
168
  const openingMessage = messages[opening];
@@ -121,7 +175,13 @@ export const createToolRepetitionGuardMiddleware = (
121
175
  call.name === toolName && canonicalArgsKey(call.args) === argsKey,
122
176
  );
123
177
 
124
- const failedInTurn = (priorError: string): ToolMessage => {
178
+ const failedInTurn = (failure: string): ToolMessage => {
179
+ // A refusal of this guard is not quoted inside the next one, or each repeat nests every earlier one.
180
+ const priorError = /was NOT (called|run) again|was NOT run\b/.test(
181
+ failure,
182
+ )
183
+ ? `This guard already refused it: \`${toolName}\` reached its limit in this turn.`
184
+ : failure;
125
185
  logger.warn(
126
186
  `Repetition guard: short-circuiting duplicate failed call to ${toolName}`,
127
187
  { toolName, priorError },
@@ -1,3 +1,11 @@
1
+ import type {
2
+ CheckClaimDispatch,
3
+ CheckDispatch,
4
+ } from '@ixo/action-interactions';
5
+ import type {
6
+ InteractionCommand,
7
+ InteractionOutcome,
8
+ } from '../action-interactions/store';
1
9
  import type {
2
10
  ChannelAttachment,
3
11
  ChannelControlInput,
@@ -63,6 +71,7 @@ import type { DomainContextProvenance } from '../core/domain-context';
63
71
 
64
72
  /** Bindings every oracle Worker must declare (see the example `wrangler.jsonc`). */
65
73
  export interface OracleWorkerEnv {
74
+ ACTION_INTERACTIONS_ENABLED?: string;
66
75
  TOPIC_DELIVERABLES_ENABLED?: string;
67
76
  ACTION_PROPOSALS_RPC_TESTNET?: string;
68
77
  ACTION_PROPOSALS_RPC_DEVNET?: string;
@@ -782,6 +791,29 @@ export interface StorageStatus {
782
791
  * piped straight to the client.
783
792
  */
784
793
  export interface UserOracleObject extends Rpc.DurableObjectBranded {
794
+ readActionInteractionTask(
795
+ identity: TurnIdentity,
796
+ sessionId: string,
797
+ taskId: string,
798
+ ): Promise<InteractionOutcome<string>>;
799
+ checkDispatch(
800
+ identity: TurnIdentity,
801
+ input: CheckDispatch,
802
+ ): Promise<InteractionOutcome<string>>;
803
+ checkClaimDispatch(
804
+ identity: TurnIdentity,
805
+ input: CheckClaimDispatch,
806
+ ): Promise<InteractionOutcome<string>>;
807
+ actionInteraction(
808
+ identity: TurnIdentity,
809
+ command: InteractionCommand,
810
+ ): Promise<InteractionOutcome<string>>;
811
+ readActionInteraction(
812
+ identity: TurnIdentity,
813
+ sessionId: string,
814
+ id?: string,
815
+ references?: boolean,
816
+ ): Promise<InteractionOutcome<string>>;
785
817
  recordHubActionOutcome(outcome: ActionOutcomeV1): Promise<void>;
786
818
  topicDeliverable(
787
819
  identity: TurnIdentity,
@@ -1,3 +1,14 @@
1
+ import { taskCardSummary } from '../plugins/tasks/tasks-tools';
2
+ import {
3
+ ActionInteractionError,
4
+ type CheckClaimDispatch,
5
+ type CheckDispatch,
6
+ } from '@ixo/action-interactions';
7
+ import {
8
+ ActionInteractionStore,
9
+ interactionOutcome,
10
+ type InteractionCommand,
11
+ } from '../action-interactions/store';
1
12
  import {
2
13
  CHANNEL_DELIVERY_NOTICE_KWARG,
3
14
  channelDeliveryNote,
@@ -735,6 +746,11 @@ const META_LEGACY_CLEARED = 'meta:legacyCleared';
735
746
  */
736
747
  const META_LEGACY_UNUSABLE = 'meta:legacyUnusable';
737
748
  const META_USER_DID = 'meta:userDid';
749
+ /**
750
+ * Set when an evicted object keeps an alarm only for action-card draft
751
+ * retention; a boot clears it. The alarm then prunes on the bare object.
752
+ */
753
+ const META_RETENTION_WAKE = 'meta:interactionRetentionWake';
738
754
 
739
755
  /** KV key of a session's context-guard counters (`GET /debug/context?session=`). */
740
756
  const contextStatsKey = (sessionId: string): string => `ctxstats:${sessionId}`;
@@ -1690,6 +1706,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1690
1706
  throw new Error(`Object bound to ${stored}, not ${userDid}`);
1691
1707
  if (!stored) await this.ctx.storage.put(META_USER_DID, userDid);
1692
1708
  this.userDid = userDid;
1709
+ await this.ctx.storage.delete(META_RETENTION_WAKE);
1693
1710
 
1694
1711
  const env = this.env;
1695
1712
  // The gateway lazily starts on first use; make sure it is up before the
@@ -2769,6 +2786,21 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2769
2786
  // exists once the object booted).
2770
2787
  const keepAliveAt = this.runs?.keepAliveDeadline(wakeAt) ?? null;
2771
2788
  const recoveryAt = this.runs?.nextRecoveryAt() ?? null;
2789
+ // An evicted object (no working copy, no housekeeping deadline) with
2790
+ // no socket is only woken for action-card draft retention: prune on
2791
+ // the bare object instead of importing the owner copy, and re-arm.
2792
+ if (
2793
+ !this.db &&
2794
+ nextPingAt === null &&
2795
+ keepAliveAt === null &&
2796
+ recoveryAt === null &&
2797
+ (await this.ctx.storage.get<boolean>(META_RETENTION_WAKE)) === true
2798
+ ) {
2799
+ const next = await this.bareInteractionRetention();
2800
+ if (next !== null) await this.requestAlarm(next);
2801
+ else await this.ctx.storage.delete(META_RETENTION_WAKE);
2802
+ return;
2803
+ }
2772
2804
  if (this.runs && recoveryAt !== null && recoveryAt <= wakeAt + 1000) {
2773
2805
  // A store error here must not reject the alarm: the tick below (or
2774
2806
  // the re-arm) still has to run.
@@ -2984,6 +3016,22 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2984
3016
  }
2985
3017
  }
2986
3018
 
3019
+ // Action-card drafts past retention: one bounded batch per tick, so a
3020
+ // user who stopped preparing cards still loses expired draft values.
3021
+ // Its follow-up is no reason to keep an idle copy either.
3022
+ if (this.userDid) {
3023
+ try {
3024
+ await new ActionInteractionStore(this.ctx.storage, {
3025
+ oracleDid: this.env.ORACLE_DID,
3026
+ userDid: this.userDid,
3027
+ }).prune();
3028
+ } catch (err) {
3029
+ console.warn(
3030
+ `[action-interactions] retention tick failed for ${this.userDid}: ${err instanceof Error ? err.message : String(err)}`,
3031
+ );
3032
+ }
3033
+ }
3034
+
2987
3035
  // Idle housekeeping: a user silent for five days loses the cached
2988
3036
  // pages — their file is safe upstream, and we stop paying to store a
2989
3037
  // copy. Never evict while tasks are scheduled: their runs ARE
@@ -3015,12 +3063,22 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3015
3063
  });
3016
3064
  if (outcome === 'evicted') {
3017
3065
  await this.ctx.storage.delete(META_HOUSEKEEPING_AT);
3066
+ // Action-card drafts outlive the working copy: their retention
3067
+ // still needs a wake (handled on the bare object, see alarm()).
3068
+ const retentionAt = await this.interactionRetentionAt();
3018
3069
  // An attached (idle) socket still needs its heartbeat rounds.
3019
- if (nextPingAt !== null) await this.requestAlarm(nextPingAt);
3070
+ if (nextPingAt !== null)
3071
+ await this.requestAlarm(
3072
+ Math.min(nextPingAt, retentionAt ?? Infinity),
3073
+ );
3020
3074
  // A request that waited on the wipe has booted the object afresh
3021
3075
  // and armed its own deadlines (a task Start's run): keep them.
3022
- else if (this.initPromise === null)
3023
- await this.ctx.storage.deleteAlarm();
3076
+ else if (this.initPromise === null) {
3077
+ if (retentionAt !== null) {
3078
+ await this.ctx.storage.put(META_RETENTION_WAKE, true);
3079
+ await this.requestAlarm(retentionAt);
3080
+ } else await this.ctx.storage.deleteAlarm();
3081
+ }
3024
3082
  return;
3025
3083
  }
3026
3084
  if (outcome === 'not-current') {
@@ -3196,6 +3254,162 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3196
3254
 
3197
3255
  // ── RPC surface (contracts.ts UserOracleObject) ───────────────────────
3198
3256
 
3257
+ private async interactionStore(
3258
+ identity: TurnIdentity,
3259
+ sessionId: string,
3260
+ options: { recordAccess?: boolean } = {},
3261
+ ) {
3262
+ await this.ready(identity, options);
3263
+ if (
3264
+ this.userDid !== identity.userDid ||
3265
+ !(await this.sessions?.getSession(sessionId))
3266
+ )
3267
+ throw new ActionInteractionError(404, 'not_found', 'Session not found');
3268
+ return new ActionInteractionStore(this.ctx.storage, {
3269
+ oracleDid: this.env.ORACLE_DID,
3270
+ userDid: identity.userDid,
3271
+ });
3272
+ }
3273
+
3274
+ async readActionInteractionTask(
3275
+ identity: TurnIdentity,
3276
+ sessionId: string,
3277
+ taskId: string,
3278
+ ) {
3279
+ return interactionOutcome(async () => {
3280
+ // The task scheduler needs a booted object, but an open task card polls every 15 s:
3281
+ // the poll is not activity, so it never keeps an idle object from being evicted.
3282
+ await this.interactionStore(identity, sessionId, {
3283
+ recordAccess: false,
3284
+ });
3285
+ const task = await this.taskScheduler?.surface.get(taskId);
3286
+ if (!task)
3287
+ throw new ActionInteractionError(404, 'not_found', 'Task not found');
3288
+ return JSON.stringify({ task: taskCardSummary(task) });
3289
+ });
3290
+ }
3291
+
3292
+ /**
3293
+ * The draft store without a boot: the object is addressed by the
3294
+ * caller's DID, and the stored binding is checked against it.
3295
+ */
3296
+ private async bareInteractionStore(identity: TurnIdentity) {
3297
+ const bound =
3298
+ this.userDid ?? (await this.ctx.storage.get<string>(META_USER_DID));
3299
+ if (bound && bound !== identity.userDid)
3300
+ throw new ActionInteractionError(404, 'not_found', 'Owner not found');
3301
+ return new ActionInteractionStore(this.ctx.storage, {
3302
+ oracleDid: this.env.ORACLE_DID,
3303
+ userDid: identity.userDid,
3304
+ });
3305
+ }
3306
+
3307
+ async checkDispatch(identity: TurnIdentity, input: CheckDispatch) {
3308
+ return interactionOutcome(async () => {
3309
+ // Runs before every bid, claim-block and approval effect: no boot, as in checkClaimDispatch.
3310
+ const store = await this.bareInteractionStore(identity);
3311
+ return JSON.stringify(await store.checkDispatch(input));
3312
+ });
3313
+ }
3314
+
3315
+ async checkClaimDispatch(
3316
+ identity: TurnIdentity,
3317
+ input: CheckClaimDispatch,
3318
+ ) {
3319
+ return interactionOutcome(async () => {
3320
+ // Every Portal claim calls this, Companion user or not, so it must
3321
+ // not boot the object (gateway, signer, SQLite, owner-copy import):
3322
+ // it only reads and writes a few keys. The object is addressed by
3323
+ // the caller's DID; the stored binding is checked without a boot.
3324
+ const store = await this.bareInteractionStore(identity);
3325
+ return JSON.stringify(await store.checkClaimDispatch(input));
3326
+ });
3327
+ }
3328
+
3329
+ /** When stored action-card drafts next need retention, if any are kept. */
3330
+ private async interactionRetentionAt(): Promise<number | null> {
3331
+ const userDid =
3332
+ this.userDid ?? (await this.ctx.storage.get<string>(META_USER_DID));
3333
+ if (!userDid) return null;
3334
+ return new ActionInteractionStore(this.ctx.storage, {
3335
+ oracleDid: this.env.ORACLE_DID,
3336
+ userDid,
3337
+ })
3338
+ .nextRetentionAt()
3339
+ .catch(() => null);
3340
+ }
3341
+
3342
+ /** One retention pass on an un-booted object; returns the next due time. */
3343
+ private async bareInteractionRetention(): Promise<number | null> {
3344
+ const userDid = await this.ctx.storage.get<string>(META_USER_DID);
3345
+ if (!userDid) return null;
3346
+ const store = new ActionInteractionStore(this.ctx.storage, {
3347
+ oracleDid: this.env.ORACLE_DID,
3348
+ userDid,
3349
+ });
3350
+ try {
3351
+ await store.prune();
3352
+ } catch (err) {
3353
+ console.warn(
3354
+ `[action-interactions] retention on an evicted object failed for ${userDid}: ${err instanceof Error ? err.message : String(err)}`,
3355
+ );
3356
+ return Date.now() + 60 * 60 * 1000;
3357
+ }
3358
+ const next = await store.nextRetentionAt().catch(() => null);
3359
+ // A backlog larger than one pass resumes a minute later.
3360
+ return next === null ? null : Math.max(next, Date.now() + 60_000);
3361
+ }
3362
+
3363
+ /** Drafts of a deleted chat, in bounded batches. */
3364
+ private async forgetInteractions(userDid: string, sessionId: string) {
3365
+ const store = new ActionInteractionStore(this.ctx.storage, {
3366
+ oracleDid: this.env.ORACLE_DID,
3367
+ userDid,
3368
+ });
3369
+ while ((await store.forgetSession(sessionId)) > 0);
3370
+ }
3371
+
3372
+ async actionInteraction(
3373
+ identity: TurnIdentity,
3374
+ command: InteractionCommand,
3375
+ ) {
3376
+ return interactionOutcome(async () => {
3377
+ const store = await this.interactionStore(
3378
+ identity,
3379
+ command.input.sessionId,
3380
+ );
3381
+ if (
3382
+ command.kind === 'prepare' &&
3383
+ command.input.owner.kind === 'task' &&
3384
+ !(await this.taskScheduler?.surface.get(command.input.owner.taskId))
3385
+ )
3386
+ throw new ActionInteractionError(404, 'not_found', 'Task not found');
3387
+ return JSON.stringify(await store.command(command));
3388
+ });
3389
+ }
3390
+
3391
+ async readActionInteraction(
3392
+ identity: TurnIdentity,
3393
+ sessionId: string,
3394
+ id?: string,
3395
+ references = false,
3396
+ ) {
3397
+ return interactionOutcome(async () => {
3398
+ // Card polls never boot the object or count as activity: they read a
3399
+ // few keys, so an open card neither imports an evicted owner copy nor
3400
+ // keeps an idle object alive. A deleted chat's drafts are deleted
3401
+ // with it, so no session lookup is needed to hide them.
3402
+ const store = await this.bareInteractionStore(identity);
3403
+ return JSON.stringify(
3404
+ await (id
3405
+ ? store.read(id, sessionId)
3406
+ : references
3407
+ ? store.listReferences(sessionId)
3408
+ : store.list(sessionId)),
3409
+ );
3410
+ });
3411
+ }
3412
+
3199
3413
  async createSession(
3200
3414
  identity: TurnIdentity,
3201
3415
  o: { roomId?: string; sessionId?: string; markerTxnId?: string } = {},
@@ -4112,6 +4326,15 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
4112
4326
  });
4113
4327
  await this.ctx.storage.delete(contextStatsKey(sessionId));
4114
4328
  await this.feedbackMarkers!.forgetSession(sessionId);
4329
+ // The chat's action-card drafts go with it; a dispatched claim keeps
4330
+ // only the tombstones that stop a duplicate.
4331
+ await this.forgetInteractions(identity.userDid, sessionId).catch(
4332
+ (err: unknown) => {
4333
+ console.warn(
4334
+ `[action-interactions] could not delete the drafts of ${sessionId}; retention removes them later: ${err instanceof Error ? err.message : String(err)}`,
4335
+ );
4336
+ },
4337
+ );
4115
4338
  this.markDirty();
4116
4339
  }
4117
4340
  // A channel binding that pointed at the session opens a new one on
package/src/index.ts CHANGED
@@ -54,6 +54,8 @@ export {
54
54
  } from './owner-store/ixo-vfs-store';
55
55
  export { MatrixMediaOwnerStore } from './owner-store/matrix-media-store';
56
56
  export { MatrixGatewayDO } from './matrix/gateway-do';
57
+ /** asks.world sign-ins, one per person; a host re-exports it under this name (`docs/asks.md`). */
58
+ export { AsksConnectionDO } from './plugins/asks/connection-do';
57
59
  export {
58
60
  createUserOracleDO,
59
61
  type OracleWorkerHooks,
@@ -0,0 +1,226 @@
1
+ import {
2
+ defineDecision,
3
+ OraclePlugin,
4
+ type AgentMiddleware,
5
+ type AuthExcludedRoute,
6
+ type MergedConfig,
7
+ type PluginEnv,
8
+ type PluginManifest,
9
+ type PluginRoute,
10
+ type PluginTool,
11
+ type RuntimeContext,
12
+ } from '../../plugin-api';
13
+ import { createMiddleware } from 'langchain';
14
+ import { z } from 'zod';
15
+ import { channelOriginsOf } from './attach';
16
+ import type { AsksWorkerEnv } from './connection';
17
+ import { ASKS_OPERATING_GUIDE } from './guide';
18
+ import { asksMcpUrlOf, deriveKeys, objectNameOf, type AsksKeys } from './oauth';
19
+ import { asksRoutes, connectionOf } from './routes';
20
+ import {
21
+ asksSkillDecisionOptions,
22
+ lastHumanText,
23
+ pickSkill,
24
+ } from './skill-pick';
25
+ import {
26
+ AsksSkills,
27
+ folderOf,
28
+ workersSkillCache,
29
+ type Skill,
30
+ type SkillFolder,
31
+ } from './skills';
32
+ import {
33
+ asksTools,
34
+ connectAsksMcp,
35
+ keepTurnFiles,
36
+ preloadText,
37
+ type McpConnect,
38
+ } from './tools';
39
+
40
+ const configSchema = z.object({
41
+ /** Seals the tokens and names each person's connection object (`openssl rand -base64 32`). */
42
+ ASKS_TOKEN_KEY: z
43
+ .string()
44
+ .min(32, 'ASKS_TOKEN_KEY must be at least 32 characters.'),
45
+ /** `https://wa.me/<number>?text=…`: the callback page's "Back to WhatsApp". */
46
+ ASKS_WHATSAPP_RETURN_URL: z.string().url().optional(),
47
+ /** Pilot: comma-separated user DIDs who get asks. Unset: everyone. */
48
+ ASKS_ALLOWED_DIDS: z.string().optional(),
49
+ });
50
+
51
+ const manifest: PluginManifest = {
52
+ title: 'asks.world',
53
+ summary:
54
+ 'asks.world: find paid work and get paid, post asks real people answer, get an app or agent reviewed by verified pros, and see or cash out asks money.',
55
+ // The on-demand catalogue's line (runtime 0.39+, at most 60 characters).
56
+ brief: 'paid work, asks people answer, pro reviews, asks money',
57
+ whenToUse: [
58
+ 'The person mentions asks.world or an ask, wants paid work or to apply to one, has an invitation to an ask, or wants to hand in an answer or a report.',
59
+ 'The person wants to hire someone, have something checked or found out somewhere they cannot see, or get an app or agent reviewed by pros.',
60
+ 'The person asks about their asks money, earnings or cash-out, their pro page, or whether anyone answered their ask.',
61
+ ],
62
+ whenNotToUse: [
63
+ 'IXO wallet balances, chain transactions or other on-chain actions (asks money is separate).',
64
+ 'General web research or tasks that need no person on asks.world.',
65
+ ],
66
+ tags: ['asks', 'work', 'jobs', 'payments', 'reviews'],
67
+ category: 'integration',
68
+ visibility: 'on-demand',
69
+ stability: 'beta',
70
+ };
71
+
72
+ export interface AsksPluginOptions {
73
+ /** The Worker env: the binding, vars and secret. Default: `env` from `cloudflare:workers`. */
74
+ env?: () => AsksWorkerEnv;
75
+ connect?: McpConnect;
76
+ fetch?: typeof fetch;
77
+ }
78
+
79
+ /** Loaded on first use, so the plain-Node test suites can import the plugin. */
80
+ const workerEnv = async (): Promise<AsksWorkerEnv> => {
81
+ // The host's Env type is its own; the plugin reads only its optional keys.
82
+ const env: object = (await import('cloudflare:workers')).env;
83
+ return env;
84
+ };
85
+
86
+ /**
87
+ * asks.world through its hosted connector (`/v1/mcp` on the asks host for
88
+ * `NETWORK`), signed in once per person with OAuth (asks' Google login).
89
+ * See `docs/asks.md` and `guide.ts` for how the model is told to use it.
90
+ */
91
+ export class AsksPlugin extends OraclePlugin {
92
+ static readonly NAME = 'asks';
93
+
94
+ readonly name = AsksPlugin.NAME;
95
+
96
+ readonly version = '1.0.0';
97
+
98
+ readonly manifest = manifest;
99
+
100
+ override readonly operatingGuide = ASKS_OPERATING_GUIDE;
101
+
102
+ override readonly configSchema = configSchema;
103
+
104
+ override readonly autoDetectHint =
105
+ 'ASKS_TOKEN_KEY, ORACLE_PUBLIC_URL and NETWORK (mainnet, testnet or devnet)';
106
+
107
+ private keys: Promise<AsksKeys> | null = null;
108
+
109
+ private readonly skillSets = new Map<SkillFolder, AsksSkills>();
110
+
111
+ private readonly decision = defineDecision(asksSkillDecisionOptions);
112
+
113
+ constructor(private readonly options: AsksPluginOptions = {}) {
114
+ super();
115
+ }
116
+
117
+ override autoDetect(env: PluginEnv): boolean {
118
+ // The secret too: a deploy before it is set leaves asks off instead of failing the boot.
119
+ const set = ['ASKS_TOKEN_KEY', 'ORACLE_PUBLIC_URL'].every(
120
+ (key) => typeof env[key] === 'string' && env[key].length > 0,
121
+ );
122
+ if (!set) return false;
123
+ if (asksMcpUrlOf(env['NETWORK'])) return true;
124
+ // Never guess a network: the wrong one is the wrong money. No logger here.
125
+ // eslint-disable-next-line no-console
126
+ console.warn(
127
+ `[asks] off: NETWORK is ${typeof env['NETWORK'] === 'string' ? `"${env['NETWORK']}"` : 'not set'}; asks needs mainnet, testnet or devnet`,
128
+ );
129
+ return false;
130
+ }
131
+
132
+ private allowed(config: MergedConfig, did: string): boolean {
133
+ const list = configSchema.parse(config).ASKS_ALLOWED_DIDS;
134
+ return !list?.trim() || list.split(/[\s,]+/).includes(did);
135
+ }
136
+
137
+ private skillsFor(rtCtx: RuntimeContext): Promise<Skill[]> {
138
+ const folder = folderOf(rtCtx.config.NETWORK);
139
+ let set = this.skillSets.get(folder);
140
+ if (!set) {
141
+ set = new AsksSkills({
142
+ folder,
143
+ fetch: (input, init) => (this.options.fetch ?? fetch)(input, init),
144
+ now: () => Date.now(),
145
+ cache: workersSkillCache(),
146
+ warn: (line) => rtCtx.logger.warn(line),
147
+ });
148
+ this.skillSets.set(folder, set);
149
+ }
150
+ return set.skills(rtCtx.background);
151
+ }
152
+
153
+ override async getRequestTools(rtCtx: RuntimeContext): Promise<PluginTool[]> {
154
+ if (!this.allowed(rtCtx.config, rtCtx.user.did)) return [];
155
+ const config = configSchema.parse(rtCtx.config);
156
+ const mcpUrl = asksMcpUrlOf(rtCtx.config.NETWORK);
157
+ if (!mcpUrl) return [];
158
+ const env = this.options.env ? this.options.env() : await workerEnv();
159
+ this.keys ??= deriveKeys(config.ASKS_TOKEN_KEY);
160
+ const objectName = await objectNameOf(await this.keys, rtCtx.user.did);
161
+ const conn = connectionOf(env, objectName);
162
+ if (!conn) {
163
+ rtCtx.logger.warn(
164
+ '[asks] the ASKS_CONNECTIONS binding is missing: no asks tools',
165
+ );
166
+ return [];
167
+ }
168
+ await keepTurnFiles(rtCtx).catch((error: unknown) =>
169
+ rtCtx.logger.warn(
170
+ `[asks] could not keep this turn's files: ${String(error)}`,
171
+ ),
172
+ );
173
+ return asksTools({
174
+ ctx: rtCtx,
175
+ conn,
176
+ mcpUrl,
177
+ channelOrigins: channelOriginsOf(env.CHANNEL_SERVICE_DID),
178
+ skills: await this.skillsFor(rtCtx),
179
+ connect: this.options.connect ?? connectAsksMcp,
180
+ fetch: (input, init) => (this.options.fetch ?? fetch)(input, init),
181
+ now: () => Date.now(),
182
+ });
183
+ }
184
+
185
+ /** With asks loaded for the turn: the skill the message needs, in the prompt before the first model call. */
186
+ override async getRequestMiddlewares(
187
+ rtCtx: RuntimeContext,
188
+ ): Promise<AgentMiddleware[]> {
189
+ if (
190
+ !rtCtx.loadedPlugins.has(this.name) ||
191
+ !this.allowed(rtCtx.config, rtCtx.user.did)
192
+ )
193
+ return [];
194
+ const skills = await this.skillsFor(rtCtx);
195
+ let picked: Promise<Skill | null> | null = null;
196
+ return [
197
+ createMiddleware({
198
+ name: 'AsksSkillPreload',
199
+ wrapModelCall: async (request, handler) => {
200
+ picked ??= pickSkill(
201
+ rtCtx.decisions,
202
+ this.decision,
203
+ lastHumanText(request.messages),
204
+ skills,
205
+ rtCtx.abortSignal,
206
+ );
207
+ const skill = await picked;
208
+ return skill
209
+ ? handler({
210
+ ...request,
211
+ systemMessage: request.systemMessage.concat(preloadText(skill)),
212
+ })
213
+ : handler(request);
214
+ },
215
+ }),
216
+ ];
217
+ }
218
+
219
+ override getRoutes(): PluginRoute[] {
220
+ return asksRoutes();
221
+ }
222
+
223
+ override getAuthExcludedRoutes(): AuthExcludedRoute[] {
224
+ return asksRoutes().map(({ path }) => ({ path, method: 'GET' as const }));
225
+ }
226
+ }