@ixo/oracle-runtime-workers 0.41.0 → 0.41.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ixo/oracle-runtime-workers",
3
- "version": "0.41.0",
3
+ "version": "0.41.2",
4
4
  "description": "QiForge oracle runtime for Cloudflare Workers — Durable Object per user (SQLite in WASM over DO storage), Matrix E2EE gateway Durable Object, Hono HTTP/SSE shell.",
5
5
  "private": false,
6
6
  "publishConfig": {
@@ -67,8 +67,8 @@
67
67
  "protobufjs": "^7.6.5",
68
68
  "@ixo/topic-protocol": "1.0.0-rc.8",
69
69
  "survey-core": "2.3.11",
70
- "@ixo/common": "1.8.0",
71
70
  "@ixo/ixo-transaction": "0.2.0",
71
+ "@ixo/common": "1.8.0",
72
72
  "@ixo/ucan": "2.3.0",
73
73
  "@ixo/action-interactions": "0.1.1"
74
74
  },
@@ -2,14 +2,14 @@
2
2
  * The asks skills, read at runtime from the released ixo-plugins repo so a
3
3
  * newly published skill needs no oracle release: mainnet reads
4
4
  * `plugins/asks/skills/<name>/SKILL.md`, testnet and devnet
5
- * `plugins/asks-testnet/...` (practice money). The names come from the
6
- * folder listing.
5
+ * `plugins/asks-testnet/...` (practice money). The names come from
6
+ * `plugins/<folder>/skills/index.json`.
7
7
  *
8
8
  * Never on a turn's path: `skills()` answers from memory, then the Workers
9
9
  * cache (across isolate restarts), then the bundled copy, and refreshes in
10
10
  * the background once the copy is an hour old, with `If-None-Match` so an
11
- * unchanged file costs a 304 (which GitHub does not count against its rate
12
- * limit). Any failure keeps what is there.
11
+ * unchanged file costs a 304. Everything is read from
12
+ * raw.githubusercontent.com. Any failure keeps what is there.
13
13
  *
14
14
  * The bundled copy (`skills.bundled.json`) was taken from ixo-plugins `main`;
15
15
  * refresh it by re-reading the same two folders when the skills change a lot.
@@ -158,25 +158,18 @@ export class AsksSkills {
158
158
  private async pull(): Promise<void> {
159
159
  const { folder } = this.deps;
160
160
  const before = this.snapshot ?? bundledSnapshot(folder);
161
+ // Not GitHub's API: unauthenticated, it answers 403 on Cloudflare's shared IPs.
161
162
  const list = await this.deps.fetch(
162
- `https://api.github.com/repos/${REPO}/contents/plugins/${folder}/skills?ref=${BRANCH}`,
163
- {
164
- headers: {
165
- accept: 'application/vnd.github+json',
166
- 'user-agent': 'ixo-oracle',
167
- ...(before.listEtag ? { 'if-none-match': before.listEtag } : {}),
168
- },
169
- },
163
+ `https://raw.githubusercontent.com/${REPO}/${BRANCH}/plugins/${folder}/skills/index.json`,
164
+ { headers: before.listEtag ? { 'if-none-match': before.listEtag } : {} },
170
165
  );
171
166
  let names: string[];
172
167
  if (list.status === 304) names = Object.keys(before.files);
173
168
  else if (list.ok) {
174
- const entries =
175
- await list.json<Array<{ name?: unknown; type?: unknown }>>();
176
- names = entries.flatMap((one) =>
177
- one.type === 'dir' && typeof one.name === 'string' ? [one.name] : [],
178
- );
179
- } else throw new Error(`listing answered ${list.status}`);
169
+ const index = await list.json<unknown>();
170
+ if (!Array.isArray(index)) throw new Error('index.json is not a list');
171
+ names = index.filter((name): name is string => typeof name === 'string');
172
+ } else throw new Error(`index.json answered ${list.status}`);
180
173
  names = names.filter((name) => SKILL_NAME.test(name)).slice(0, MAX_SKILLS);
181
174
 
182
175
  const files: SkillSnapshot['files'] = {};
@@ -141,7 +141,6 @@ export function fakeContext(
141
141
  error: () => undefined,
142
142
  debug: () => undefined,
143
143
  },
144
- background: (work: Promise<unknown>) => void work,
145
144
  ...extra,
146
145
  } as unknown as RuntimeContext;
147
146
  }
@@ -470,8 +470,13 @@ function connectorTool(
470
470
  };
471
471
  }
472
472
 
473
- /** The connector's tools for this turn: cached definitions, a lazy client. `null`: signed out. */
474
- async function connectorTools(turn: AsksTurn): Promise<PluginTool[] | null> {
473
+ /**
474
+ * The connector's tools for this turn: cached definitions, a lazy client.
475
+ * `null`: signed out. `'loading'`: none cached yet, being fetched for the next message.
476
+ */
477
+ async function connectorTools(
478
+ turn: AsksTurn,
479
+ ): Promise<PluginTool[] | null | 'loading'> {
475
480
  const { mcpUrl } = turn;
476
481
  const cached = defsCache.get(mcpUrl);
477
482
  const list = async (token: string) => {
@@ -479,8 +484,30 @@ async function connectorTools(turn: AsksTurn): Promise<PluginTool[] | null> {
479
484
  defsCache.set(mcpUrl, { defs: fresh, expiresAt: turn.now() + DEFS_TTL_MS });
480
485
  return fresh;
481
486
  };
487
+ const refresh = (background: (work: Promise<unknown>) => void) => {
488
+ if (defsRefreshing.has(mcpUrl)) return;
489
+ defsRefreshing.add(mcpUrl);
490
+ const work = turn.conn
491
+ .token()
492
+ .then(async (read) => {
493
+ if (read.ok) await list(read.access);
494
+ else if (read.reason !== 'unreachable') await markSeen(turn, null);
495
+ })
496
+ .catch((error: unknown) =>
497
+ turn.ctx.logger.warn(
498
+ `[asks] could not list asks tools: ${String(error)}`,
499
+ ),
500
+ )
501
+ .finally(() => defsRefreshing.delete(mcpUrl));
502
+ background(work);
503
+ };
482
504
  let defs = cached?.defs;
483
505
  if (!defs) {
506
+ // Never hold a turn on asks.world: the full list is there from the next message.
507
+ if (turn.ctx.background) {
508
+ refresh(turn.ctx.background);
509
+ return 'loading';
510
+ }
484
511
  const read = await turn.conn.token();
485
512
  if (!read.ok) {
486
513
  if (read.reason === 'unreachable')
@@ -489,19 +516,9 @@ async function connectorTools(turn: AsksTurn): Promise<PluginTool[] | null> {
489
516
  return null;
490
517
  }
491
518
  defs = await list(read.access);
492
- } else if (
493
- cached &&
494
- cached.expiresAt <= turn.now() &&
495
- !defsRefreshing.has(mcpUrl)
496
- ) {
519
+ } else if (cached && cached.expiresAt <= turn.now() && turn.ctx.background) {
497
520
  // Stale definitions keep serving while they refresh in the background.
498
- defsRefreshing.add(mcpUrl);
499
- const work = turn.conn
500
- .token()
501
- .then((read) => (read.ok ? list(read.access) : undefined))
502
- .catch(() => undefined)
503
- .finally(() => defsRefreshing.delete(mcpUrl));
504
- turn.ctx.background?.(work);
521
+ refresh(turn.ctx.background);
505
522
  }
506
523
  const session = new AsksSession(turn);
507
524
  return defs.map((def) => connectorTool(turn, session, def));
@@ -710,5 +727,13 @@ export async function asksTools(turn: AsksTurn): Promise<PluginTool[]> {
710
727
  return [];
711
728
  });
712
729
  if (remote === null) return base;
730
+ if (remote === 'loading') {
731
+ const status = base[1]!;
732
+ base[1] = {
733
+ ...status,
734
+ description: `${status.description} asks.world's other actions are still loading and are there from the next message: never say asks cannot do something.`,
735
+ };
736
+ return [...base, disconnectTool(turn), attachTool(turn)];
737
+ }
713
738
  return [...base, disconnectTool(turn), attachTool(turn), ...remote];
714
739
  }
@@ -903,6 +903,41 @@ function issuesOf(
903
903
  );
904
904
  }
905
905
 
906
+ /** Why a Hub action tool did nothing: Hub actions are off for this oracle. */
907
+ export const HUB_ACTIONS_OFF_REASON =
908
+ 'Hub actions are not enabled for this oracle.';
909
+
910
+ /**
911
+ * Why a Hub action tool did nothing on a Portal, Matrix or other non-channel
912
+ * turn. Auth Hub reviews are bound to a channel binding, so nothing can be
913
+ * prepared here; the reason says so in words the model can relay, so it
914
+ * never reads the refusal as an Auth Hub outage.
915
+ */
916
+ export const NOT_A_CHANNEL_CHAT_REASON =
917
+ 'Coin sends, chain transactions and other Auth Hub actions can be prepared only in a channel chat with this oracle (such as WhatsApp), not in this chat. Auth Hub was not contacted and nothing was prepared or sent. Tell the person to ask in their channel chat.';
918
+
919
+ /**
920
+ * The turn's channel binding and Hub client when Hub actions can be
921
+ * prepared on this turn, else the refusal that names why not.
922
+ */
923
+ function channelTurnOf(ctx: RuntimeContext):
924
+ | {
925
+ channel: NonNullable<RuntimeContext['session']['channel']>;
926
+ hubActions: NonNullable<RuntimeContext['hubActions']>;
927
+ }
928
+ | { refusal: { status: 'unavailable'; reason: string } } {
929
+ if (ctx.config.HUB_ACTIONS_ENABLED !== 'true' || !ctx.hubActions)
930
+ return {
931
+ refusal: { status: 'unavailable', reason: HUB_ACTIONS_OFF_REASON },
932
+ };
933
+ const channel = ctx.session.channel;
934
+ if (ctx.session.client !== 'channel' || !channel)
935
+ return {
936
+ refusal: { status: 'unavailable', reason: NOT_A_CHANNEL_CHAT_REASON },
937
+ };
938
+ return { channel, hubActions: ctx.hubActions };
939
+ }
940
+
906
941
  /**
907
942
  * The catalogue types this account may use: the Hub capabilities'
908
943
  * `chainTx.messageTypes`, which follow the protocol level the Hub speaks
@@ -916,16 +951,11 @@ function issuesOf(
916
951
  async function availableChainTxTypes(
917
952
  ctx: RuntimeContext,
918
953
  ): Promise<readonly ChainTxMessageType[]> {
919
- if (
920
- ctx.config.HUB_ACTIONS_ENABLED !== 'true' ||
921
- ctx.session.client !== 'channel' ||
922
- !ctx.session.channel ||
923
- !ctx.hubActions
924
- )
925
- return [];
954
+ const turn = channelTurnOf(ctx);
955
+ if ('refusal' in turn) return [];
926
956
  let chainTx;
927
957
  try {
928
- chainTx = (await ctx.hubActions.capabilities({ turn: true })).chainTx;
958
+ chainTx = (await turn.hubActions.capabilities({ turn: true })).chainTx;
929
959
  } catch (error) {
930
960
  ctx.logger.warn(
931
961
  `[hub-actions] capabilities unavailable, no chain transaction types this turn: ${error instanceof Error ? error.message : String(error)}`,
@@ -1101,12 +1131,16 @@ async function hubActionStatus(
1101
1131
  ctx: RuntimeContext,
1102
1132
  ): Promise<unknown> {
1103
1133
  const parsed = statusSchema.safeParse(input);
1104
- if (
1105
- !parsed.success ||
1106
- !ctx.hubActions ||
1107
- ctx.config.HUB_ACTIONS_ENABLED !== 'true'
1108
- )
1109
- return { status: 'unavailable' };
1134
+ if (!parsed.success)
1135
+ return {
1136
+ status: 'unavailable',
1137
+ reason:
1138
+ 'An actionId is required: the 32-character id of a Hub action prepared for this owner.',
1139
+ };
1140
+ // Not only a channel turn: an action prepared in a channel chat is read
1141
+ // from the Portal too, where the channel's conversation is mirrored.
1142
+ if (ctx.config.HUB_ACTIONS_ENABLED !== 'true' || !ctx.hubActions)
1143
+ return { status: 'unavailable', reason: HUB_ACTIONS_OFF_REASON };
1110
1144
  try {
1111
1145
  const read = await ctx.hubActions.status(
1112
1146
  ctx.user.did,
@@ -1138,15 +1172,10 @@ async function hubActionCapabilities(
1138
1172
  _input: unknown,
1139
1173
  ctx: RuntimeContext,
1140
1174
  ): Promise<unknown> {
1141
- if (
1142
- ctx.config.HUB_ACTIONS_ENABLED !== 'true' ||
1143
- ctx.session.client !== 'channel' ||
1144
- !ctx.session.channel ||
1145
- !ctx.hubActions
1146
- )
1147
- return { status: 'unavailable' };
1175
+ const turn = channelTurnOf(ctx);
1176
+ if ('refusal' in turn) return turn.refusal;
1148
1177
  try {
1149
- return await ctx.hubActions.capabilities();
1178
+ return await turn.hubActions.capabilities();
1150
1179
  } catch (error) {
1151
1180
  const refused = refusalOf(error);
1152
1181
  if (refused) return refused;
@@ -1158,17 +1187,12 @@ async function hubActionOptions(
1158
1187
  _input: unknown,
1159
1188
  ctx: RuntimeContext,
1160
1189
  ): Promise<unknown> {
1161
- const channel = ctx.session.channel;
1162
- if (
1163
- ctx.config.HUB_ACTIONS_ENABLED !== 'true' ||
1164
- ctx.session.client !== 'channel' ||
1165
- !channel ||
1166
- !ctx.hubActions
1167
- )
1168
- return { status: 'unavailable' };
1190
+ const turn = channelTurnOf(ctx);
1191
+ if ('refusal' in turn) return turn.refusal;
1192
+ const { channel } = turn;
1169
1193
  let options;
1170
1194
  try {
1171
- options = await ctx.hubActions.options({
1195
+ options = await turn.hubActions.options({
1172
1196
  ownerDid: ctx.user.did,
1173
1197
  provider: channel.provider,
1174
1198
  bindingId: channel.bindingId,
@@ -1184,6 +1208,60 @@ async function hubActionOptions(
1184
1208
  return { ...options, ownerAddress: ixoAddressOf(ctx.user.did) };
1185
1209
  }
1186
1210
 
1211
+ /**
1212
+ * The tools that prepare a Hub action, offered only on a channel turn
1213
+ * (`getRequestTools`): Auth Hub binds every review to the turn's channel
1214
+ * binding, so on a Portal, Matrix or other turn they could only refuse, and
1215
+ * a model offered them reads the refusal as an Auth Hub outage. Their
1216
+ * handlers still refuse off a channel turn, with a reason. Built once.
1217
+ */
1218
+ export const HUB_ACTION_CHANNEL_TOOLS: readonly PluginTool[] = [
1219
+ {
1220
+ name: 'hub_action_capabilities',
1221
+ description:
1222
+ "Read this authenticated oracle account's enabled operation kinds and exact registered form field schema before proposing a Hub action.",
1223
+ schema: discoverSchema,
1224
+ effect: 'read',
1225
+ handler: hubActionCapabilities,
1226
+ },
1227
+ {
1228
+ name: 'hub_action_options',
1229
+ description:
1230
+ "Read the owner’s confirmed Gmail and Calendar connections, requestable documents, action resources and bank tokens (`transfer.tokens`: denom, symbol, decimals, verified, balance in base units). Use each returned service, identifier and label to prepare a related Hub review. A Gmail profile connection does not authorize sending. For a token that is not verified, name its full denom and say it is unverified; its amounts are base units. ownerDid is the authenticated user's DID and ownerAddress their IXO account address.",
1231
+ schema: discoverSchema,
1232
+ effect: 'read',
1233
+ handler: hubActionOptions,
1234
+ },
1235
+ {
1236
+ name: 'request_hub_action',
1237
+ description:
1238
+ 'Prepare a supported operation for the authenticated channel owner to review in Auth Hub. Never approves, signs, or executes. For email, propose one plain-text message to one recipient using a confirmed Gmail send connection. Supply only the operation candidate; the runtime supplies identity and turn binding. Coin sends and other chain transactions have their own tool (request_chain_transaction). Never prepare a replacement email after an unknown send without the user checking Gmail’s Sent folder and asking again. Limits are checked on the call; `startsAt` and `endsAt` are ISO 8601 with an offset, a saved `filename` ends in .txt, a form field `id` is lower_snake_case.',
1239
+ schema: requestSchema,
1240
+ effect: 'write',
1241
+ handler: requestHubAction,
1242
+ },
1243
+ {
1244
+ name: 'describe_chain_messages',
1245
+ description: DESCRIBE_CHAIN_MESSAGES_TOOL_DESCRIPTION,
1246
+ schema: describeChainMessagesSchema,
1247
+ effect: 'read',
1248
+ handler: describeChainMessages,
1249
+ },
1250
+ ];
1251
+
1252
+ /**
1253
+ * hub_action_status, offered on every turn: an action prepared in a channel
1254
+ * chat is followed from the Portal too, where that conversation is mirrored.
1255
+ */
1256
+ export const HUB_ACTION_STATUS_TOOL: PluginTool = {
1257
+ name: 'hub_action_status',
1258
+ description:
1259
+ 'Read the verified status of a Hub action already prepared for this owner. Never executes. A `cancelled` outcome was withdrawn before anything was sent, by the person or by Auth Hub (`meaning` says so): never say the person cancelled it. When `meaning` is present, it says what the outcome means for the person (for example a carried-out group proposal whose actions failed): follow it. Coin sends, chain transactions and other Hub actions are prepared only in a channel chat (such as WhatsApp): when this chat has no tool for one, tell the person to ask there, and never call it an Auth Hub outage.',
1260
+ schema: statusSchema,
1261
+ effect: 'read',
1262
+ handler: hubActionStatus,
1263
+ };
1264
+
1187
1265
  const manifest: PluginManifest = {
1188
1266
  title: 'Auth Hub action review',
1189
1267
  summary:
@@ -1224,63 +1302,26 @@ export class HubActionsPlugin extends OraclePlugin {
1224
1302
  );
1225
1303
  }
1226
1304
  /**
1305
+ * The tools that prepare a Hub action, on a channel turn only (none on a
1306
+ * Portal, Matrix or other turn, where Auth Hub cannot bind a review).
1227
1307
  * request_chain_transaction is built per turn: its schema names only the
1228
1308
  * message types this account may use (Auth Hub capabilities, cached by
1229
1309
  * the owner object), so 37 types do not reach a model that may use 4.
1230
1310
  */
1231
1311
  override async getRequestTools(ctx: RuntimeContext): Promise<PluginTool[]> {
1312
+ if ('refusal' in channelTurnOf(ctx)) return [];
1232
1313
  const [available, described] = await Promise.all([
1233
1314
  chainTxTypesForTurn(ctx),
1234
1315
  describedChainTxTypes(ctx, ctx.history.messages),
1235
1316
  ]);
1236
1317
  const tool = chainTransactionTool(available, described);
1237
- return tool ? [tool] : [];
1318
+ return [...HUB_ACTION_CHANNEL_TOOLS, ...(tool ? [tool] : [])];
1238
1319
  }
1239
1320
  /** Gives each model call the request tool with the fields described so far in the turn. */
1240
1321
  override getRequestMiddlewares(ctx: RuntimeContext): AgentMiddleware[] {
1241
1322
  return [chainTxFieldsMiddleware(ctx)];
1242
1323
  }
1243
1324
  override getTools(): PluginTool[] {
1244
- return [
1245
- {
1246
- name: 'hub_action_capabilities',
1247
- description:
1248
- "Read this authenticated oracle account's enabled operation kinds and exact registered form field schema before proposing a Hub action.",
1249
- schema: discoverSchema,
1250
- effect: 'read',
1251
- handler: hubActionCapabilities,
1252
- },
1253
- {
1254
- name: 'hub_action_options',
1255
- description:
1256
- "Read the owner’s confirmed Gmail and Calendar connections, requestable documents, action resources and bank tokens (`transfer.tokens`: denom, symbol, decimals, verified, balance in base units). Use each returned service, identifier and label to prepare a related Hub review. A Gmail profile connection does not authorize sending. For a token that is not verified, name its full denom and say it is unverified; its amounts are base units. ownerDid is the authenticated user's DID and ownerAddress their IXO account address.",
1257
- schema: discoverSchema,
1258
- effect: 'read',
1259
- handler: hubActionOptions,
1260
- },
1261
- {
1262
- name: 'request_hub_action',
1263
- description:
1264
- 'Prepare a supported operation for the authenticated channel owner to review in Auth Hub. Never approves, signs, or executes. For email, propose one plain-text message to one recipient using a confirmed Gmail send connection. Supply only the operation candidate; the runtime supplies identity and turn binding. Coin sends and other chain transactions have their own tool (request_chain_transaction). Never prepare a replacement email after an unknown send without the user checking Gmail’s Sent folder and asking again. Limits are checked on the call; `startsAt` and `endsAt` are ISO 8601 with an offset, a saved `filename` ends in .txt, a form field `id` is lower_snake_case.',
1265
- schema: requestSchema,
1266
- effect: 'write',
1267
- handler: requestHubAction,
1268
- },
1269
- {
1270
- name: 'describe_chain_messages',
1271
- description: DESCRIBE_CHAIN_MESSAGES_TOOL_DESCRIPTION,
1272
- schema: describeChainMessagesSchema,
1273
- effect: 'read',
1274
- handler: describeChainMessages,
1275
- },
1276
- {
1277
- name: 'hub_action_status',
1278
- description:
1279
- 'Read the verified status of a Hub action already prepared for this owner. Never executes. A `cancelled` outcome was withdrawn before anything was sent, by the person or by Auth Hub (`meaning` says so): never say the person cancelled it. When `meaning` is present, it says what the outcome means for the person (for example a carried-out group proposal whose actions failed): follow it.',
1280
- schema: statusSchema,
1281
- effect: 'read',
1282
- handler: hubActionStatus,
1283
- },
1284
- ];
1325
+ return [HUB_ACTION_STATUS_TOOL];
1285
1326
  }
1286
1327
  }