@ixo/oracle-runtime-workers 0.40.1 → 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.
@@ -0,0 +1,739 @@
1
+ /**
2
+ * The tools the asks plugin gives a turn. Not connected: `asks_connect`,
3
+ * `asks_status` and `asks_skill`. Connected: those, `asks_disconnect`,
4
+ * `asks_attach_files` and the hosted connector's own tools (minus the ones
5
+ * that do not fit a phone), called with the person's Bearer token, which only
6
+ * their connection object refreshes.
7
+ */
8
+ import { toJsonSchema } from '@langchain/core/utils/json_schema';
9
+ import { MultiServerMCPClient } from '@langchain/mcp-adapters';
10
+ import type { PluginTool, RuntimeContext } from '../../plugin-api';
11
+ import { z } from 'zod';
12
+ import { checkDraftUploads, sendFile, type KeptFile } from './attach';
13
+ import type { ConnectionApi, Surface } from './connection';
14
+ import { shortAccount } from './oauth';
15
+ import { withCallTimeout } from '../mcp-call-timeout';
16
+ import { adaptMcpClientTools } from '../mcp-tool-adapter';
17
+ import type { Skill } from './skills';
18
+
19
+ export const SERVER = 'asks';
20
+ export const PREFIX = `${SERVER}__`;
21
+ /** asks' functions run up to 60 s; a little more here so their own answer wins. */
22
+ export const CALL_TIMEOUT_MS = 70_000;
23
+ /** Listing the connector's tools on a cold cache; a hung asks must not hold the turn. */
24
+ export const LIST_TIMEOUT_MS = 15_000;
25
+ const DEFS_TTL_MS = 5 * 60_000;
26
+ const STATE_NS = 'asks/connection';
27
+ const FILES_NS = 'asks/files';
28
+ const FILES_TTL_MS = 7 * 24 * 60 * 60_000;
29
+
30
+ /** Connector tools that do not fit a phone chat (plans, campaigns, payouts, billing). */
31
+ export const LEFT_OUT: ReadonlySet<string> = new Set([
32
+ 'create_campaign',
33
+ 'organise_campaign',
34
+ 'draft_plan',
35
+ 'fund_plan',
36
+ 'close_plan',
37
+ 'scope_market',
38
+ 'plan_status',
39
+ 'set_payout_settings',
40
+ 'review_billing_status',
41
+ ]);
42
+
43
+ export const SIGN_IN_AGAIN =
44
+ 'The asks.world sign-in has ended (or was disconnected). Call asks_connect and send the person the new link.';
45
+
46
+ /* ── the MCP side ──────────────────────────────────────────────────────── */
47
+
48
+ export interface McpTool {
49
+ name: string;
50
+ description: string;
51
+ schema: z.ZodType;
52
+ invoke(input: unknown): Promise<unknown>;
53
+ }
54
+
55
+ export interface McpConnection {
56
+ tools: McpTool[];
57
+ /** `readOnlyHint` by tool name. */
58
+ readOnly: ReadonlySet<string>;
59
+ close(): Promise<void>;
60
+ }
61
+
62
+ export type McpConnect = (url: string, token: string) => Promise<McpConnection>;
63
+
64
+ /**
65
+ * A JSON Schema without `propertyNames`. The connector's open objects (a record of
66
+ * string keys, e.g. submit_work's `report` and `fields`) carry
67
+ * `propertyNames: { type: "string" }`; `z.fromJSONSchema` turns that into a schema
68
+ * with a transform, which the model binding cannot turn back into JSON Schema, and
69
+ * the whole turn fails before the first model call. Keys are always strings in
70
+ * JSON, so dropping it loses nothing.
71
+ */
72
+ export function withoutPropertyNames(schema: unknown): unknown {
73
+ if (Array.isArray(schema)) return schema.map(withoutPropertyNames);
74
+ if (!schema || typeof schema !== 'object') return schema;
75
+ return Object.fromEntries(
76
+ Object.entries(schema as Record<string, unknown>)
77
+ .filter(([key]) => key !== 'propertyNames')
78
+ .map(([key, value]) => [key, withoutPropertyNames(value)]),
79
+ );
80
+ }
81
+
82
+ /** Whether the model binding can state this schema as JSON Schema (a transform cannot). */
83
+ export function bindable(schema: z.ZodType): boolean {
84
+ try {
85
+ // The same conversion the model binding runs (LangChain's), not plain zod's:
86
+ // zod alone states a propertyNames record fine, LangChain's strict pass does not.
87
+ toJsonSchema(schema as never);
88
+ return true;
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * The adapted tools the model can be given. One tool whose schema can't be bound
96
+ * would fail every turn of the chat, so it is left out and logged instead.
97
+ */
98
+ export function bindableTools<T extends { name: string; schema: z.ZodType }>(
99
+ tools: readonly T[],
100
+ log: Pick<Console, 'warn'> = console,
101
+ ): T[] {
102
+ return tools.filter((tool) => {
103
+ if (bindable(tool.schema)) return true;
104
+ log.warn(`[asks] left out ${tool.name}: its input schema can't be bound`);
105
+ return false;
106
+ });
107
+ }
108
+
109
+ export const connectAsksMcp: McpConnect = async (url, token) => {
110
+ const client = new MultiServerMCPClient({
111
+ useStandardContentBlocks: true,
112
+ prefixToolNameWithServerName: true,
113
+ mcpServers: {
114
+ [SERVER]: {
115
+ type: 'http',
116
+ transport: 'http',
117
+ url,
118
+ headers: { authorization: `Bearer ${token}` },
119
+ },
120
+ },
121
+ });
122
+ try {
123
+ const raw = await client.getTools();
124
+ const readOnly = new Set(
125
+ raw
126
+ .filter(
127
+ (tool) =>
128
+ (
129
+ tool.metadata as
130
+ | { annotations?: { readOnlyHint?: boolean } }
131
+ | undefined
132
+ )?.annotations?.readOnlyHint === true,
133
+ )
134
+ .map((tool) => tool.name),
135
+ );
136
+ const cleaned = raw.map((tool) => ({
137
+ name: tool.name,
138
+ description: tool.description,
139
+ schema: withoutPropertyNames(tool.schema),
140
+ invoke: (
141
+ input: unknown,
142
+ config?: { metadata?: Record<string, unknown> },
143
+ ) => tool.invoke(input as never, config as never),
144
+ }));
145
+ return {
146
+ tools: bindableTools(
147
+ adaptMcpClientTools(cleaned, console, {
148
+ requestTimeoutMs: CALL_TIMEOUT_MS,
149
+ }),
150
+ ),
151
+ readOnly,
152
+ close: () => client.close(),
153
+ };
154
+ } catch (error) {
155
+ await client.close().catch(() => undefined);
156
+ throw error;
157
+ }
158
+ };
159
+
160
+ interface ToolDef {
161
+ name: string;
162
+ description: string;
163
+ schema: z.ZodType;
164
+ readOnly: boolean;
165
+ }
166
+
167
+ // Keyed by connector URL only: the connector lists the same tools for every
168
+ // signed-in account today. Key it by account too if that ever changes.
169
+ const defsCache = new Map<string, { defs: ToolDef[]; expiresAt: number }>();
170
+ const defsRefreshing = new Set<string>();
171
+
172
+ /** Test hook. */
173
+ export function clearAsksToolDefs(): void {
174
+ defsCache.clear();
175
+ defsRefreshing.clear();
176
+ }
177
+
178
+ /** The connector's tools a phone chat keeps. */
179
+ export function keptDefs(connection: McpConnection): ToolDef[] {
180
+ return connection.tools
181
+ .filter(
182
+ (tool) =>
183
+ tool.name.startsWith(PREFIX) &&
184
+ !LEFT_OUT.has(tool.name.slice(PREFIX.length)),
185
+ )
186
+ .map(({ name, description, schema }) => ({
187
+ name,
188
+ description,
189
+ schema,
190
+ readOnly: connection.readOnly.has(name),
191
+ }));
192
+ }
193
+
194
+ async function listDefs(
195
+ url: string,
196
+ token: string,
197
+ connect: McpConnect,
198
+ ): Promise<ToolDef[]> {
199
+ const opening = connect(url, token);
200
+ const connection = await withCallTimeout(
201
+ () => opening,
202
+ LIST_TIMEOUT_MS,
203
+ 'asks tool list',
204
+ () => opening.then((one) => one.close()).catch(() => undefined),
205
+ );
206
+ try {
207
+ return keptDefs(connection);
208
+ } finally {
209
+ await connection.close().catch(() => undefined);
210
+ }
211
+ }
212
+
213
+ /* ── the upload address: kept, never shown ─────────────────────────────── */
214
+
215
+ const UPLOAD_ADDRESS =
216
+ /https?:\/\/[^\s"'<>()]+?\/draft-uploads\/du_[A-Za-z0-9_-]{43}\//;
217
+ const DRAFT_ID = /ad_[A-Za-z0-9_-]{22}/;
218
+
219
+ /**
220
+ * Whether an upload address is on asks' own hosts: the connector's host or
221
+ * another under its domain (`https://api.asks.world`, `test.api.asks.world`).
222
+ * The signed PUT it hands out goes to R2 only (`signDraftUpload`, attach.ts).
223
+ */
224
+ export function isAsksUploadAddress(address: string, mcpUrl: string): boolean {
225
+ try {
226
+ const url = new URL(address);
227
+ const asks = new URL(mcpUrl).hostname;
228
+ const domain = asks.split('.').slice(-2).join('.');
229
+ return (
230
+ url.protocol === 'https:' &&
231
+ !url.username &&
232
+ !url.port &&
233
+ (url.hostname === asks || url.hostname.endsWith(`.${domain}`))
234
+ );
235
+ } catch {
236
+ return false;
237
+ }
238
+ }
239
+
240
+ /**
241
+ * A tool's text with every upload address line taken out. The address is
242
+ * kept only when `trusted` (submit_work's or set_my_page's own answer) and on asks' hosts:
243
+ * text from the board can carry a look-alike line.
244
+ */
245
+ export function hideUploadAddress(
246
+ text: string,
247
+ trusted?: { mcpUrl: string },
248
+ ): {
249
+ text: string;
250
+ kept: { address: string; draft: string; minutes: number } | null;
251
+ } {
252
+ if (!text.includes('/draft-uploads/')) return { text, kept: null };
253
+ const found = UPLOAD_ADDRESS.exec(text)?.[0];
254
+ const address =
255
+ found && trusted && isAsksUploadAddress(found, trusted.mcpUrl)
256
+ ? found
257
+ : undefined;
258
+ const draft = DRAFT_ID.exec(text)?.[0] ?? null;
259
+ const minutes = Number(/works for (\d+) minutes?/.exec(text)?.[1] ?? 60);
260
+ const note =
261
+ draft && address
262
+ ? `Files the person sent in this chat (photos, PDFs) can go with this draft before they open the link: call asks_attach_files with draft "${draft}". Otherwise they add them on the page.`
263
+ : 'Files from this chat cannot go with this answer; the person adds them on the page.';
264
+ let noted = false;
265
+ const lines = text.split('\n').flatMap((line) => {
266
+ if (!line.includes('/draft-uploads/')) return [line];
267
+ if (noted || !trusted) return [];
268
+ noted = true;
269
+ return [note];
270
+ });
271
+ return {
272
+ text: lines.join('\n'),
273
+ kept: draft && address ? { address, draft, minutes } : null,
274
+ };
275
+ }
276
+
277
+ /** Apply `fn` to every text a tool result carries (a string, content blocks, or a block). */
278
+ export function mapText(value: unknown, fn: (text: string) => string): unknown {
279
+ if (typeof value === 'string') return fn(value);
280
+ if (Array.isArray(value)) return value.map((one) => mapText(one, fn));
281
+ if (
282
+ value &&
283
+ typeof value === 'object' &&
284
+ typeof (value as { text?: unknown }).text === 'string'
285
+ ) {
286
+ return { ...value, text: fn((value as { text: string }).text) };
287
+ }
288
+ return value;
289
+ }
290
+
291
+ /**
292
+ * Whether an error may be asks refusing the token (HTTP 401) rather than a
293
+ * tool's own refusal (`returned an error`). The MCP adapters flatten the
294
+ * status into text ("(HTTP 401)", "Authentication failed"), so this only
295
+ * decides whether to ask the connection object: it checks the token with
296
+ * asks before anything is forgotten (`ConnectionApi.refused`).
297
+ */
298
+ function mayBeUnauthorized(error: unknown): boolean {
299
+ if (error instanceof Error && error.name === 'UnauthorizedError') return true;
300
+ // The MCP SDK's StreamableHTTPError carries the HTTP status as `code`.
301
+ if ((error as { code?: unknown } | null)?.code === 401) return true;
302
+ const message = error instanceof Error ? error.message : String(error);
303
+ return (
304
+ !message.includes('returned an error') &&
305
+ /\(HTTP 401\)|Authentication failed for/.test(message)
306
+ );
307
+ }
308
+
309
+ /* ── the turn ──────────────────────────────────────────────────────────── */
310
+
311
+ export interface AsksTurn {
312
+ ctx: RuntimeContext;
313
+ conn: ConnectionApi;
314
+ mcpUrl: string;
315
+ channelOrigins: readonly string[];
316
+ skills: readonly Skill[];
317
+ connect: McpConnect;
318
+ fetch: typeof fetch;
319
+ now: () => number;
320
+ }
321
+
322
+ type Seen = { status: 'connected' } | { status: 'pending'; until: number };
323
+
324
+ async function markSeen(turn: AsksTurn, seen: Seen | null): Promise<void> {
325
+ const kv = turn.ctx.kv;
326
+ if (!kv) return;
327
+ await (
328
+ seen ? kv.set(STATE_NS, 'state', seen) : kv.delete(STATE_NS, 'state')
329
+ ).catch(() => undefined);
330
+ }
331
+
332
+ /**
333
+ * Whether the person is connected, without a call to their object on most
334
+ * turns: their own store remembers it, and the object is asked only while a
335
+ * sign-in is pending (or when there is no store).
336
+ */
337
+ export async function isConnected(turn: AsksTurn): Promise<boolean> {
338
+ const kv = turn.ctx.kv;
339
+ const seen = (await kv?.get(STATE_NS, 'state').catch(() => undefined)) as
340
+ | Seen
341
+ | undefined;
342
+ if (seen?.status === 'connected') return true;
343
+ const pending = seen?.status === 'pending' && seen.until > turn.now();
344
+ if (kv && !pending) return false;
345
+ const { connected } = await turn.conn.status();
346
+ if (connected) await markSeen(turn, { status: 'connected' });
347
+ return connected;
348
+ }
349
+
350
+ /** The files the person sent with this turn, kept a week so a later turn can attach them. */
351
+ export async function keepTurnFiles(ctx: RuntimeContext): Promise<void> {
352
+ const channel = ctx.session.channel;
353
+ if (!ctx.kv || !channel?.attachments?.length) return;
354
+ for (const file of channel.attachments.slice(0, 10)) {
355
+ if (!/^[0-9a-f]{64}$/.test(file.sha256)) continue;
356
+ const kept: KeptFile = {
357
+ url: file.url,
358
+ sha256: file.sha256,
359
+ mimetype: file.mimetype,
360
+ size: file.size,
361
+ filename: file.filename,
362
+ bindingId: channel.bindingId,
363
+ };
364
+ await ctx.kv.set(FILES_NS, `channel:${file.sha256.slice(0, 16)}`, kept, {
365
+ idleTtlMs: FILES_TTL_MS,
366
+ maxEntries: 200,
367
+ });
368
+ }
369
+ }
370
+
371
+ /** One MCP client per turn, opened on the first call and reopened when the token changes. */
372
+ class AsksSession {
373
+ private live: { token: string; connection: Promise<McpConnection> } | null =
374
+ null;
375
+
376
+ private closeRegistered = false;
377
+
378
+ constructor(private readonly turn: AsksTurn) {}
379
+
380
+ close(): void {
381
+ const live = this.live;
382
+ this.live = null;
383
+ void live?.connection.then((one) => one.close()).catch(() => undefined);
384
+ }
385
+
386
+ private open(token: string): Promise<McpConnection> {
387
+ if (this.live?.token === token) return this.live.connection;
388
+ this.close();
389
+ if (!this.closeRegistered && this.turn.ctx.onTurnEnd) {
390
+ this.closeRegistered = true;
391
+ this.turn.ctx.onTurnEnd(() => this.close());
392
+ }
393
+ const connection = this.turn.connect(this.turn.mcpUrl, token);
394
+ this.live = { token, connection };
395
+ connection.catch(() => {
396
+ if (this.live?.connection === connection) this.live = null;
397
+ });
398
+ return connection;
399
+ }
400
+
401
+ async call(name: string, input: unknown, again = false): Promise<unknown> {
402
+ const read = await this.turn.conn.token();
403
+ if (!read.ok) {
404
+ if (read.reason === 'unreachable')
405
+ throw new Error('asks.world did not answer. Try again in a minute.');
406
+ await markSeen(this.turn, null);
407
+ return SIGN_IN_AGAIN;
408
+ }
409
+ try {
410
+ return await withCallTimeout(
411
+ async () => {
412
+ const tool = (await this.open(read.access)).tools.find(
413
+ (one) => one.name === name,
414
+ );
415
+ if (!tool) throw new Error(`asks.world no longer offers ${name}.`);
416
+ return tool.invoke(input);
417
+ },
418
+ CALL_TIMEOUT_MS,
419
+ `asks tool ${name}`,
420
+ () => this.close(),
421
+ );
422
+ } catch (error) {
423
+ if (!mayBeUnauthorized(error)) throw error;
424
+ const verdict = await this.turn.conn.refused(read.access);
425
+ // Another turn refreshed meanwhile (asks ends the old access token on a
426
+ // refresh). asks checks the token before the call, so a retry is safe.
427
+ if (verdict === 'replaced' && !again) return this.call(name, input, true);
428
+ if (verdict !== 'ended') throw error;
429
+ // asks ended the connection (e.g. a disconnect on /me/agents).
430
+ await markSeen(this.turn, null);
431
+ return SIGN_IN_AGAIN;
432
+ }
433
+ }
434
+ }
435
+
436
+ function connectorTool(
437
+ turn: AsksTurn,
438
+ session: AsksSession,
439
+ def: ToolDef,
440
+ ): PluginTool {
441
+ return {
442
+ name: def.name,
443
+ description: def.description,
444
+ schema: def.schema,
445
+ effect: def.readOnly ? 'read' : 'write',
446
+ ...(def.name === `${PREFIX}link_status` ? { repeatable: true } : {}),
447
+ handler: async (args) => {
448
+ const result = await session.call(def.name, args);
449
+ let kept: ReturnType<typeof hideUploadAddress>['kept'] = null;
450
+ const trusted =
451
+ // set_my_page hands out the same upload address for a pro page photo.
452
+ def.name === `${PREFIX}submit_work` ||
453
+ def.name === `${PREFIX}set_my_page`
454
+ ? { mcpUrl: turn.mcpUrl }
455
+ : undefined;
456
+ const shown = mapText(result, (text) => {
457
+ const hidden = hideUploadAddress(text, trusted);
458
+ kept ??= hidden.kept;
459
+ return hidden.text;
460
+ });
461
+ const upload = kept as ReturnType<typeof hideUploadAddress>['kept'];
462
+ if (upload)
463
+ await turn.conn.keepUpload(
464
+ upload.draft,
465
+ upload.address,
466
+ turn.now() + upload.minutes * 60_000,
467
+ );
468
+ return shown;
469
+ },
470
+ };
471
+ }
472
+
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'> {
480
+ const { mcpUrl } = turn;
481
+ const cached = defsCache.get(mcpUrl);
482
+ const list = async (token: string) => {
483
+ const fresh = await listDefs(mcpUrl, token, turn.connect);
484
+ defsCache.set(mcpUrl, { defs: fresh, expiresAt: turn.now() + DEFS_TTL_MS });
485
+ return fresh;
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
+ };
504
+ let defs = cached?.defs;
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
+ }
511
+ const read = await turn.conn.token();
512
+ if (!read.ok) {
513
+ if (read.reason === 'unreachable')
514
+ throw new Error('asks.world did not answer');
515
+ await markSeen(turn, null);
516
+ return null;
517
+ }
518
+ defs = await list(read.access);
519
+ } else if (cached && cached.expiresAt <= turn.now() && turn.ctx.background) {
520
+ // Stale definitions keep serving while they refresh in the background.
521
+ refresh(turn.ctx.background);
522
+ }
523
+ const session = new AsksSession(turn);
524
+ return defs.map((def) => connectorTool(turn, session, def));
525
+ }
526
+
527
+ /* ── the plugin's own tools ────────────────────────────────────────────── */
528
+
529
+ const accountWords = (account?: string): string =>
530
+ account ? ` as ${shortAccount(account)}` : '';
531
+
532
+ const surfaceOf = (ctx: RuntimeContext): Surface =>
533
+ ctx.session.client === 'channel' &&
534
+ ctx.session.channel?.provider === 'whatsapp'
535
+ ? 'whatsapp'
536
+ : 'chat';
537
+
538
+ function connectTool(turn: AsksTurn): PluginTool {
539
+ return {
540
+ name: 'asks_connect',
541
+ description:
542
+ 'Start the one-time asks.world sign-in (Google, on asks.world). Gives a link to send the person; they sign in there and come back. Use when Asks is not connected or a tool says the sign-in ended.',
543
+ schema: z.object({}),
544
+ handler: async () => {
545
+ const now = await turn.conn.status();
546
+ if (now.connected) {
547
+ await markSeen(turn, { status: 'connected' });
548
+ return `asks.world is already connected for this person${accountWords(now.account)}.`;
549
+ }
550
+ const { link, expiresAt } = await turn.conn.begin(
551
+ turn.ctx.user.did,
552
+ surfaceOf(turn.ctx),
553
+ );
554
+ await markSeen(turn, { status: 'pending', until: expiresAt });
555
+ return [
556
+ 'Send the person this link in your final message, on its own line:',
557
+ link,
558
+ 'They sign in with Google on asks.world with the same account they use here; it is for them only, works once and lasts 15 minutes. A sign-in with another account is refused. Ask them to tell you when it says "You’re connected". Do not call other asks tools until then.',
559
+ ].join('\n');
560
+ },
561
+ };
562
+ }
563
+
564
+ function statusTool(turn: AsksTurn): PluginTool {
565
+ return {
566
+ name: 'asks_status',
567
+ description:
568
+ 'Whether asks.world is connected for this person, or a sign-in is waiting.',
569
+ schema: z.object({}),
570
+ effect: 'read',
571
+ handler: async () => {
572
+ const { connected, pending, account } = await turn.conn.status();
573
+ if (connected) {
574
+ await markSeen(turn, { status: 'connected' });
575
+ return `asks.world is connected${accountWords(account)}. Its tools work from the next message on.`;
576
+ }
577
+ if (!pending) await markSeen(turn, null);
578
+ return pending
579
+ ? 'Not connected yet: the sign-in link is still waiting to be used.'
580
+ : 'Not connected. Call asks_connect for a sign-in link.';
581
+ },
582
+ };
583
+ }
584
+
585
+ function disconnectTool(turn: AsksTurn): PluginTool {
586
+ return {
587
+ name: 'asks_disconnect',
588
+ description:
589
+ 'Disconnect asks.world for this person: the sign-in is revoked at asks.world and forgotten here. Only when they ask.',
590
+ schema: z.object({}),
591
+ handler: async () => {
592
+ await turn.conn.disconnect();
593
+ await markSeen(turn, null);
594
+ return 'asks.world is disconnected. Nothing was posted or spent.';
595
+ },
596
+ };
597
+ }
598
+
599
+ function attachTool(turn: AsksTurn): PluginTool {
600
+ return {
601
+ name: 'asks_attach_files',
602
+ description:
603
+ "Attach files the person sent in this chat (PNG, JPG, WebP or PDF, up to 5 MB each, 10 at most) to an answer drafted with submit_work, or the photo for their pro page from set_my_page, before they open its link. Pass the draft (ad_…) and the files' channel references (channel:…); for a report or a form, name each file with the file id the answer expects (e.g. a finding's evidence id). The person checks them on the page and sends from there.",
604
+ schema: z.object({
605
+ draft: z
606
+ .string()
607
+ .regex(/^ad_[A-Za-z0-9_-]{22}$/)
608
+ .describe('The draft submit_work or set_my_page gave (ad_…).'),
609
+ files: z
610
+ .array(
611
+ z.object({
612
+ file: z
613
+ .string()
614
+ .regex(/^channel:[0-9a-f]{16}$/)
615
+ .describe('A file the person sent: channel:<16 hex>.'),
616
+ name: z
617
+ .string()
618
+ .regex(/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/)
619
+ .optional()
620
+ .describe(
621
+ "The file id the answer names for it, without an extension. Leave out for a words answer's one file.",
622
+ ),
623
+ }),
624
+ )
625
+ .min(1)
626
+ .max(10),
627
+ }),
628
+ handler: async (args) => {
629
+ const { draft, files } = args as {
630
+ draft: string;
631
+ files: Array<{ file: string; name?: string }>;
632
+ };
633
+ const address = await turn.conn.uploadOf(draft);
634
+ if (!address) {
635
+ return 'No upload address is kept for that answer: it lasts an hour and closes once the person opens the page. The person adds the files on the answer page.';
636
+ }
637
+ const lines: string[] = [];
638
+ const sentAs: string[] = [];
639
+ for (const { file, name } of files) {
640
+ const kept = (await turn.ctx.kv
641
+ ?.get(FILES_NS, file)
642
+ .catch(() => undefined)) as KeptFile | undefined;
643
+ const sent = kept
644
+ ? await sendFile({
645
+ file: kept,
646
+ name,
647
+ address,
648
+ origins: turn.channelOrigins,
649
+ fetch: turn.fetch,
650
+ })
651
+ : {
652
+ ok: false as const,
653
+ why: 'Not a file the person sent in this chat in the last 7 days.',
654
+ };
655
+ if (sent.ok) sentAs.push(sent.as);
656
+ else lines.push(`- ${file}: ${sent.why}`);
657
+ }
658
+ // asks checks what landed (size, type, names): its verdict is the count.
659
+ const check =
660
+ sentAs.length > 0 ? await checkDraftUploads(turn.fetch, address) : null;
661
+ const refused =
662
+ check?.ok === true
663
+ ? sentAs.filter((one) => check.refused.includes(one))
664
+ : [];
665
+ const attached = sentAs.length - refused.length;
666
+ return [
667
+ `Attached ${attached} of ${files.length} file${files.length === 1 ? '' : 's'}.`,
668
+ ...lines,
669
+ refused.length > 0
670
+ ? `asks refused ${refused.length} after checking them: ${refused.join(', ')}. The person adds those on the page.`
671
+ : '',
672
+ check?.ok === true && check.missing.length > 0
673
+ ? `Still missing for this answer (${check.missing.length}): ${check.missing.join(', ')}.`
674
+ : '',
675
+ check?.ok === false
676
+ ? 'Asks could not check the files just now; the answer page shows what arrived.'
677
+ : '',
678
+ attached > 0
679
+ ? 'The person sees them on the answer page and sends from there.'
680
+ : '',
681
+ ]
682
+ .filter(Boolean)
683
+ .join('\n');
684
+ },
685
+ };
686
+ }
687
+
688
+ /** How the skills are written vs. how they work in this chat. */
689
+ const SKILL_NOTE =
690
+ 'This skill is written for chat assistants with the asks plugin. Here: signing in is asks_connect; files from this chat go with asks_attach_files; the asks tools are named asks__<tool>; there is no plugin to install, update or switch, and this chat uses one network only.';
691
+
692
+ export function skillTool(skills: readonly Skill[]): PluginTool {
693
+ return {
694
+ name: 'asks_skill',
695
+ description: [
696
+ 'Read one asks.world skill: the how-to for one kind of task. Read the one that fits before acting (unless this turn already has it). The skills:',
697
+ ...skills.map((one) => `- ${one.name}: ${one.description}`),
698
+ ].join('\n'),
699
+ schema: z.object({
700
+ name: z.string().describe('The skill name, from the list.'),
701
+ }),
702
+ effect: 'read',
703
+ handler: async (args) => {
704
+ const { name } = args as { name: string };
705
+ const skill = skills.find(
706
+ (one) => one.name === name.trim().toLowerCase(),
707
+ );
708
+ return skill
709
+ ? `${SKILL_NOTE}\n\n${skill.text}`
710
+ : `No asks skill is called "${name}". The skills: ${skills.map((one) => one.name).join(', ')}.`;
711
+ },
712
+ };
713
+ }
714
+
715
+ /** The text a preloaded skill adds to this turn's system prompt. */
716
+ export const preloadText = (skill: Skill): string =>
717
+ `\n\n### asks skill for this message: ${skill.name}\n${SKILL_NOTE}\n\n${skill.text}`;
718
+
719
+ /** Every asks tool for this turn. */
720
+ export async function asksTools(turn: AsksTurn): Promise<PluginTool[]> {
721
+ const base = [connectTool(turn), statusTool(turn), skillTool(turn.skills)];
722
+ if (!(await isConnected(turn))) return base;
723
+ const remote = await connectorTools(turn).catch((error: unknown) => {
724
+ turn.ctx.logger.warn(
725
+ `[asks] connector tools unavailable this turn: ${String(error)}`,
726
+ );
727
+ return [];
728
+ });
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
+ }
738
+ return [...base, disconnectTool(turn), attachTool(turn), ...remote];
739
+ }