@ixo/oracle-runtime-workers 0.41.0 → 0.41.1

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.1",
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,9 +67,9 @@
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/ucan": "2.3.0",
70
71
  "@ixo/common": "1.8.0",
71
72
  "@ixo/ixo-transaction": "0.2.0",
72
- "@ixo/ucan": "2.3.0",
73
73
  "@ixo/action-interactions": "0.1.1"
74
74
  },
75
75
  "devDependencies": {
@@ -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
  }