pi-roundtable 0.22.4 → 0.23.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/CHANGELOG.md +61 -1
- package/README.md +36 -10
- package/README.zh-TW.md +51 -13
- package/docs/migrating-0.8.md +2 -2
- package/docs/plugins.md +350 -28
- package/examples/discord-adapter.ts +9 -1
- package/examples/seeds.test.ts +2 -2
- package/examples/seeds.ts +1 -2
- package/package.json +7 -3
- package/src/characters/catalog.ts +1055 -0
- package/src/characters/characters.css +68 -0
- package/src/characters/colors.ts +108 -0
- package/src/characters/compose.ts +281 -0
- package/src/characters/css.ts +63 -0
- package/src/characters/index.ts +33 -0
- package/src/characters/parse.ts +196 -0
- package/src/characters/parts-face.ts +354 -0
- package/src/characters/parts-hair.ts +56 -0
- package/src/characters/parts-hats.ts +57 -0
- package/src/characters/parts-outfits.ts +81 -0
- package/src/characters/parts-props.ts +137 -0
- package/src/characters/spec.ts +224 -0
- package/src/characters/suggest.ts +101 -0
- package/src/cli/checks/characters.ts +23 -0
- package/src/cli/checks/images.ts +5 -3
- package/src/cli/doctor.ts +2 -0
- package/src/core/agents/agent-claim.ts +30 -11
- package/src/core/agents/agent-dashboard.ts +1 -1
- package/src/core/agents/agent-messages.ts +43 -15
- package/src/core/agents/agent-prompt.ts +2 -4
- package/src/core/agents/agent-rules.ts +42 -3
- package/src/core/agents/agent-schema.ts +37 -15
- package/src/core/agents/agent-store.ts +57 -13
- package/src/core/agents/agent-team-fixture.ts +90 -14
- package/src/core/agents/agent-team.ts +83 -50
- package/src/core/agents/agent-tools.ts +94 -41
- package/src/core/agents/avatar-studio.ts +40 -4
- package/src/core/agents/character-png.ts +40 -0
- package/src/core/agents/group-messages.ts +7 -4
- package/src/core/agents/group-turns.ts +27 -14
- package/src/core/agents/team-editing.ts +99 -58
- package/src/core/agents/team-keys.ts +177 -11
- package/src/core/agents/team-lifecycle.ts +151 -84
- package/src/core/agents/team-options.ts +13 -6
- package/src/core/agents/team-status.ts +31 -13
- package/src/core/agents/team-text.ts +13 -3
- package/src/core/agents/team-turn-types.ts +25 -7
- package/src/core/agents/team-turns.ts +143 -28
- package/src/core/attachments/attachment-fetcher.ts +162 -64
- package/src/core/attachments/prompt-block.ts +7 -1
- package/src/core/attachments/tool-attachment.ts +20 -1
- package/src/core/attachments/turn-attachments.ts +18 -2
- package/src/core/builtin/agent-server.ts +103 -40
- package/src/core/builtin/discord.ts +4 -17
- package/src/core/builtin/modules.ts +6 -5
- package/src/core/builtin/threads.ts +37 -0
- package/src/core/config/agent-team.ts +43 -0
- package/src/core/config/config.ts +50 -59
- package/src/core/config/limits.ts +93 -0
- package/src/core/config/ops.ts +34 -0
- package/src/core/config/schema.ts +26 -0
- package/src/core/contract/channels.ts +20 -0
- package/src/core/contract/surface.ts +77 -7
- package/src/core/define-roundtable.ts +70 -42
- package/src/core/discord/agent-commands.ts +108 -86
- package/src/core/discord/agent-panel.ts +11 -2
- package/src/core/discord/connection.ts +1 -1
- package/src/core/discord/discord-surface.ts +10 -1
- package/src/core/discord/dispatch-thread-host.ts +1 -1
- package/src/core/domain/conversation.ts +24 -0
- package/src/core/holds.ts +221 -15
- package/src/core/host-registration.ts +2 -1
- package/src/core/http/listeners.ts +63 -3
- package/src/core/http/websocket.ts +2 -1
- package/src/core/i18n/agent-panel.ts +14 -21
- package/src/core/interactions/prompts.ts +12 -0
- package/src/core/modes/autonomy.ts +23 -0
- package/src/core/modes/conversation-modes.ts +213 -0
- package/src/core/modes/modes-plugin.ts +24 -0
- package/src/core/modules/delegation/delegator.ts +4 -4
- package/src/core/modules/host-shell/shell-arithmetic.ts +81 -0
- package/src/core/modules/host-shell/shell-lexer-types.ts +53 -0
- package/src/core/modules/host-shell/shell-lexer.ts +493 -0
- package/src/core/modules/host-shell/shell-paths.ts +85 -4
- package/src/core/modules/host-shell/shell-policy.ts +341 -332
- package/src/core/modules/host-shell/shell-programs.ts +298 -0
- package/src/core/modules/host-shell/shell-quoting.ts +237 -0
- package/src/core/modules/host-shell/shell-scripts.ts +114 -0
- package/src/core/modules/host-shell/shell-wrappers.ts +104 -0
- package/src/core/modules/schedules/precheck-tools.ts +17 -0
- package/src/core/modules/schedules/schedules.ts +9 -3
- package/src/core/plugin.ts +8 -1
- package/src/core/presence/direct-channels.ts +30 -0
- package/src/core/presentation/reply-body.ts +25 -0
- package/src/core/registry/contributions.ts +31 -7
- package/src/core/registry/providers.ts +1 -1
- package/src/core/routing/conversation-turns.ts +2 -15
- package/src/core/routing/surface-port.ts +5 -1
- package/src/core/runtime/extensions/approval-card.ts +1 -5
- package/src/core/runtime/extensions/approval-fields.ts +3 -7
- package/src/core/runtime/extensions/confirmation-gate-extension.ts +13 -2
- package/src/core/runtime/extensions/confirmation-gate.ts +41 -40
- package/src/core/runtime/extensions/gate-refusals.ts +23 -0
- package/src/core/runtime/extensions/held-calls.ts +16 -1
- package/src/core/runtime/extensions/hold-describer.ts +58 -8
- package/src/core/runtime/extensions/hold-lookups.ts +19 -19
- package/src/core/runtime/extensions/yolo.ts +105 -0
- package/src/core/runtime/pi-agent-runtime.ts +3 -5
- package/src/core/runtime/runtime-plugin.ts +42 -1
- package/src/core/runtime/runtime-types.ts +11 -0
- package/src/core/runtime/turn-budget.ts +32 -5
- package/src/core/runtime/turn-limits.ts +14 -3
- package/src/core/services.ts +61 -9
- package/src/core/shared/attachment-reader.ts +6 -0
- package/src/core/shared/unix-server.ts +10 -1
- package/src/core/testing/modules.ts +12 -8
- package/src/core/testing/surface-threads.ts +58 -0
- package/src/core/testing/thread-host.ts +17 -5
- package/src/core/threads/dispatch-threads.ts +299 -0
- package/src/core/threads/host-threads.ts +19 -0
- package/src/core/tool-set.snapshot.json +5 -0
- package/src/index.ts +27 -1
- package/src/kit/characters.ts +5 -0
- package/src/kit/index.ts +2 -0
- package/src/kit/shell.ts +1 -0
- package/src/kit/support.ts +1 -1
- package/src/kit/threads.ts +1 -1
- package/src/testing.ts +4 -0
- package/templates/adapters/web/package.json.tmpl +1 -1
- package/templates/agents.ts +1 -1
- package/templates/package.json.tmpl +1 -1
- package/src/core/discord/dispatch-threads.ts +0 -194
|
@@ -1,16 +1,18 @@
|
|
|
1
|
+
import { channelKey, parseChannelKey } from "../contract/surface.ts";
|
|
1
2
|
import type { TurnAttachments } from "../domain/attachment.ts";
|
|
2
3
|
import type {
|
|
3
4
|
ChannelKey,
|
|
4
5
|
PendingConfirmation,
|
|
6
|
+
PostAuthor,
|
|
5
7
|
ReplyFile,
|
|
6
8
|
TurnResult,
|
|
7
9
|
} from "../domain/conversation.ts";
|
|
8
10
|
import { AgentError } from "../domain/errors.ts";
|
|
9
11
|
import type { InterimPosts } from "../domain/interim.ts";
|
|
10
12
|
import type { AgentTurnScope } from "../domain/ports.ts";
|
|
11
|
-
import {
|
|
13
|
+
import type { TurnProgress } from "../domain/progress.ts";
|
|
14
|
+
import { replyBody } from "../presentation/reply-body.ts";
|
|
12
15
|
import { splitReply } from "../presentation/reply-splitter.ts";
|
|
13
|
-
import { thinkingLine } from "../presentation/thinking-line.ts";
|
|
14
16
|
import { withReplyFiles } from "../reply-files.ts";
|
|
15
17
|
import { endOf, settleTurn } from "../routing/settle-turn.ts";
|
|
16
18
|
import { mayApproveNamed } from "../runtime/extensions/held-calls.ts";
|
|
@@ -20,10 +22,15 @@ import { AgentMessages } from "./agent-messages.ts";
|
|
|
20
22
|
import type { Agent } from "./agent-store.ts";
|
|
21
23
|
import { GroupTurns } from "./group-turns.ts";
|
|
22
24
|
import {
|
|
25
|
+
agentOfChannel,
|
|
23
26
|
channelIdOf,
|
|
24
27
|
channelOwner,
|
|
28
|
+
coordinatorOf,
|
|
29
|
+
discordIdOf,
|
|
25
30
|
discordKey,
|
|
26
31
|
homeScope,
|
|
32
|
+
keysOf,
|
|
33
|
+
roomScope,
|
|
27
34
|
} from "./team-keys.ts";
|
|
28
35
|
import {
|
|
29
36
|
type Chain,
|
|
@@ -45,6 +52,8 @@ export class TeamTurns {
|
|
|
45
52
|
readonly #chains = new Map<ChannelKey, Chain>();
|
|
46
53
|
/** The channel each working agent's turn posts in. */
|
|
47
54
|
readonly #working = new Map<string, ChannelKey>();
|
|
55
|
+
/** The channel each running turn posts in, by session key; a seat's turns run in its group's room. */
|
|
56
|
+
readonly #posting = new Map<ChannelKey, ChannelKey>();
|
|
48
57
|
/** When each agent or group (by `group:<name>`) last started or ended a turn. */
|
|
49
58
|
readonly #lastActive = new Map<string, Date>();
|
|
50
59
|
|
|
@@ -97,7 +106,7 @@ export class TeamTurns {
|
|
|
97
106
|
attachments: TurnAttachments,
|
|
98
107
|
): Promise<TurnResult> {
|
|
99
108
|
const agent = this.#agentOf(channel);
|
|
100
|
-
const scope =
|
|
109
|
+
const scope = this.#scopeOf(agent, channel);
|
|
101
110
|
const pending = await this.#options.runtime().heldActions(scope.session);
|
|
102
111
|
const confirmed =
|
|
103
112
|
pending !== undefined &&
|
|
@@ -131,7 +140,7 @@ export class TeamTurns {
|
|
|
131
140
|
const agent = this.#agentOf(channel);
|
|
132
141
|
return this.#turn(
|
|
133
142
|
agent,
|
|
134
|
-
|
|
143
|
+
this.#scopeOf(agent, channel),
|
|
135
144
|
channel,
|
|
136
145
|
text,
|
|
137
146
|
{ messages: 0, speaker },
|
|
@@ -164,12 +173,30 @@ export class TeamTurns {
|
|
|
164
173
|
);
|
|
165
174
|
}
|
|
166
175
|
|
|
176
|
+
/** The surfaces that host rooms; none for a team without a surface port. */
|
|
177
|
+
#rooms(channel: ChannelKey): readonly string[] {
|
|
178
|
+
// Only a room key needs the list, which a surface port gives once the hosts are linked.
|
|
179
|
+
return discordIdOf(channel) === undefined
|
|
180
|
+
? (this.#options.surfaces?.roomSurfaces() ?? [])
|
|
181
|
+
: [];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The agent whose channel or room it is. */
|
|
167
185
|
#agentOf(channel: ChannelKey): Agent {
|
|
168
|
-
const
|
|
169
|
-
|
|
186
|
+
const { store, surfaces } = this.#options;
|
|
187
|
+
const agent = agentOfChannel(store, surfaces, channel);
|
|
188
|
+
if (agent?.status !== "active")
|
|
189
|
+
throw new AgentError(`no agent owns ${channel}`);
|
|
170
190
|
return agent;
|
|
171
191
|
}
|
|
172
192
|
|
|
193
|
+
/** The agent's own conversation in the channel: its Discord channel's, or its room's. */
|
|
194
|
+
#scopeOf(agent: Agent, channel: ChannelKey): AgentTurnScope {
|
|
195
|
+
return discordIdOf(channel) === undefined
|
|
196
|
+
? roomScope(agent, channel)
|
|
197
|
+
: homeScope(agent);
|
|
198
|
+
}
|
|
199
|
+
|
|
173
200
|
/**
|
|
174
201
|
* Runs one agent turn and posts its reply, or a failure notice, in `postTo` under the
|
|
175
202
|
* agent's name. Never rejects.
|
|
@@ -187,6 +214,7 @@ export class TeamTurns {
|
|
|
187
214
|
// A group round has several sessions; only an agent's own channel maps to one.
|
|
188
215
|
const hideStop = scope.group ? undefined : showStop(postTo);
|
|
189
216
|
this.#chains.set(scope.session, chain);
|
|
217
|
+
this.#posting.set(scope.session, postTo);
|
|
190
218
|
this.#working.set(agent.name, postTo);
|
|
191
219
|
this.#active(agent.name, scope.group);
|
|
192
220
|
const turn = {
|
|
@@ -198,11 +226,12 @@ export class TeamTurns {
|
|
|
198
226
|
};
|
|
199
227
|
this.#options.events?.turnStarted(turn);
|
|
200
228
|
const interim = this.#interim(postTo, agent);
|
|
229
|
+
const progress = this.#progress(postTo);
|
|
201
230
|
let result: TurnResult;
|
|
202
231
|
try {
|
|
203
232
|
result = await settleTurn(
|
|
204
233
|
() =>
|
|
205
|
-
withReplyFiles(
|
|
234
|
+
withReplyFiles(this.#carriesFiles(postTo), () =>
|
|
206
235
|
this.#options.runtime().runTurn({
|
|
207
236
|
channel: postTo,
|
|
208
237
|
selection: this.#options.selection(scope),
|
|
@@ -214,6 +243,7 @@ export class TeamTurns {
|
|
|
214
243
|
agent: scope,
|
|
215
244
|
speaker: chain.speaker,
|
|
216
245
|
...(interim ? { interim } : {}),
|
|
246
|
+
...(progress ? { progress } : {}),
|
|
217
247
|
}),
|
|
218
248
|
),
|
|
219
249
|
"agent turn",
|
|
@@ -221,6 +251,7 @@ export class TeamTurns {
|
|
|
221
251
|
} finally {
|
|
222
252
|
hideStop?.();
|
|
223
253
|
this.#chains.delete(scope.session);
|
|
254
|
+
this.#posting.delete(scope.session);
|
|
224
255
|
this.#working.delete(agent.name);
|
|
225
256
|
this.#active(agent.name, scope.group);
|
|
226
257
|
}
|
|
@@ -235,15 +266,7 @@ export class TeamTurns {
|
|
|
235
266
|
"agent turn failed",
|
|
236
267
|
);
|
|
237
268
|
}
|
|
238
|
-
|
|
239
|
-
result.ok && result.thinking
|
|
240
|
-
? thinkingLine(result.thinking)
|
|
241
|
-
: undefined;
|
|
242
|
-
await this.#post(postTo, this.#current(agent), {
|
|
243
|
-
...(thinking ? { thinking } : {}),
|
|
244
|
-
chunks: result.ok ? splitReply(result.text) : [failureNotice(result)],
|
|
245
|
-
...(result.ok && result.files?.length ? { files: result.files } : {}),
|
|
246
|
-
});
|
|
269
|
+
await this.#post(postTo, this.#current(agent), replyBody(result));
|
|
247
270
|
} catch (error) {
|
|
248
271
|
logger.error({ agent: agent.name, err: error }, "agent reply not posted");
|
|
249
272
|
} finally {
|
|
@@ -252,6 +275,17 @@ export class TeamTurns {
|
|
|
252
275
|
return result;
|
|
253
276
|
}
|
|
254
277
|
|
|
278
|
+
/**
|
|
279
|
+
* Whether a reply in the channel may carry files: Discord's webhook posts do; a room only when
|
|
280
|
+
* its surface declares `supportsFiles`, since a reply with files that surface refuses is lost whole.
|
|
281
|
+
*/
|
|
282
|
+
#carriesFiles(channel: ChannelKey): boolean {
|
|
283
|
+
return (
|
|
284
|
+
discordIdOf(channel) !== undefined ||
|
|
285
|
+
this.#options.surfaces?.of(channel)?.supportsFiles === true
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
255
289
|
#active(agent: string, group: string | undefined): void {
|
|
256
290
|
const now = new Date();
|
|
257
291
|
this.#lastActive.set(agent, now);
|
|
@@ -264,13 +298,47 @@ export class TeamTurns {
|
|
|
264
298
|
return this.#options.store.agent(agent.name) ?? agent;
|
|
265
299
|
}
|
|
266
300
|
|
|
301
|
+
/**
|
|
302
|
+
* What a turn in a room shows as it goes, through the room's surface; none in a Discord
|
|
303
|
+
* channel, which shows only the final reply, or on a surface that shows no progress.
|
|
304
|
+
*/
|
|
305
|
+
#progress(channel: ChannelKey): ((event: TurnProgress) => void) | undefined {
|
|
306
|
+
const { surfaces, logger } = this.#options;
|
|
307
|
+
if (discordIdOf(channel) !== undefined || !surfaces?.progress)
|
|
308
|
+
return undefined;
|
|
309
|
+
return (event) => {
|
|
310
|
+
// pi-lens-ignore: no-unknown-parameters — a rejection reason is unknown; it only reaches the logger
|
|
311
|
+
surfaces
|
|
312
|
+
.progress?.(channel, event)
|
|
313
|
+
.catch((error: unknown) =>
|
|
314
|
+
logger.warn({ channel, err: error }, "progress not shown"),
|
|
315
|
+
);
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
267
319
|
/**
|
|
268
320
|
* The turn's interim posts under the agent's name, read as each goes out so a name changed
|
|
269
321
|
* during the turn shows; none in a channel no agent or group owns any more.
|
|
270
322
|
*/
|
|
271
323
|
#interim(channel: ChannelKey, agent: Agent): InterimPosts | undefined {
|
|
272
|
-
const { store, channels, studio } = this.#options;
|
|
273
|
-
if (
|
|
324
|
+
const { store, channels, studio, surfaces } = this.#options;
|
|
325
|
+
if (discordIdOf(channel) === undefined) {
|
|
326
|
+
// A room's interim posts are its surface's, under the agent as author.
|
|
327
|
+
if (!surfaces?.interim(channel, this.#author(agent))) return undefined;
|
|
328
|
+
return {
|
|
329
|
+
post: async (text) => {
|
|
330
|
+
if (!channelOwner(store, channel, this.#rooms(channel)))
|
|
331
|
+
throw new AgentError(`${channel} is archived`);
|
|
332
|
+
const posts = surfaces.interim(
|
|
333
|
+
channel,
|
|
334
|
+
this.#author(this.#current(agent)),
|
|
335
|
+
);
|
|
336
|
+
if (!posts) throw new AgentError(`${channel} shows no interim posts`);
|
|
337
|
+
return posts.post(text);
|
|
338
|
+
},
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
if (!channels?.interim) return undefined;
|
|
274
342
|
const interim = channels.interim.bind(channels);
|
|
275
343
|
return {
|
|
276
344
|
post: async (text) => {
|
|
@@ -297,23 +365,50 @@ export class TeamTurns {
|
|
|
297
365
|
chunks: string[];
|
|
298
366
|
files?: ReplyFile[];
|
|
299
367
|
threadId?: string;
|
|
368
|
+
failed?: true;
|
|
300
369
|
},
|
|
301
370
|
): Promise<void> {
|
|
302
|
-
const { store, channels, studio, logger } = this.#options;
|
|
303
|
-
if (!channelOwner(store, channel)) {
|
|
371
|
+
const { store, channels, studio, surfaces, logger } = this.#options;
|
|
372
|
+
if (!channelOwner(store, channel, this.#rooms(channel))) {
|
|
304
373
|
logger.info(
|
|
305
374
|
{ channel, agent: as.name },
|
|
306
375
|
"post to an archived channel dropped",
|
|
307
376
|
);
|
|
308
377
|
return;
|
|
309
378
|
}
|
|
379
|
+
// Discord ignores `failed`.
|
|
380
|
+
const { failed, ...rest } = body;
|
|
381
|
+
if (discordIdOf(channel) === undefined) {
|
|
382
|
+
if (!surfaces)
|
|
383
|
+
throw new AgentError(`no surface serves the room ${channel}`);
|
|
384
|
+
const { thinking, chunks, files, threadId } = rest;
|
|
385
|
+
// A thread of the room's surface is a channel of that surface, and speaks there.
|
|
386
|
+
return surfaces.sendReply(
|
|
387
|
+
threadId === undefined
|
|
388
|
+
? channel
|
|
389
|
+
: channelKey(parseChannelKey(channel).surface, threadId),
|
|
390
|
+
{
|
|
391
|
+
...(thinking ? { thinking } : {}),
|
|
392
|
+
chunks,
|
|
393
|
+
...(files?.length ? { files } : {}),
|
|
394
|
+
author: this.#author(as),
|
|
395
|
+
...(failed ? { failed } : {}),
|
|
396
|
+
},
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
if (!channels) throw new AgentError(`no Discord channels serve ${channel}`);
|
|
310
400
|
return channels.post(channelIdOf(channel), {
|
|
311
401
|
name: as.displayName,
|
|
312
402
|
avatarUrl: studio.url(as.avatarHash),
|
|
313
|
-
...
|
|
403
|
+
...rest,
|
|
314
404
|
});
|
|
315
405
|
}
|
|
316
406
|
|
|
407
|
+
/** Who speaks for the agent on a surface that shows authors. */
|
|
408
|
+
#author(agent: Agent): PostAuthor {
|
|
409
|
+
return { name: agent.displayName, agent: agent.name };
|
|
410
|
+
}
|
|
411
|
+
|
|
317
412
|
/**
|
|
318
413
|
* Posts text under the caller's name in the channel of its turn: the group's in a group
|
|
319
414
|
* round, otherwise its own, as a change report is.
|
|
@@ -330,18 +425,38 @@ export class TeamTurns {
|
|
|
330
425
|
const group = scope.group
|
|
331
426
|
? this.#options.store.group(scope.group)
|
|
332
427
|
: undefined;
|
|
333
|
-
|
|
428
|
+
if (!group) return scope.home;
|
|
429
|
+
// A seat of a room's group runs in that room; its running turn knows which.
|
|
430
|
+
return (
|
|
431
|
+
this.#posting.get(scope.session) ??
|
|
432
|
+
(group.channelId ? discordKey(group.channelId) : scope.home)
|
|
433
|
+
);
|
|
334
434
|
}
|
|
335
435
|
|
|
336
|
-
/**
|
|
436
|
+
/**
|
|
437
|
+
* The assistant's own notice, such as a new release, posted in the coordinator's channel under
|
|
438
|
+
* its name: the entry channel, or, without Discord, its room on the first surface that hosts rooms.
|
|
439
|
+
*/
|
|
337
440
|
async announce(text: string): Promise<void> {
|
|
338
441
|
const { store, entryChannelId } = this.#options;
|
|
339
|
-
const coordinator = store
|
|
442
|
+
const coordinator = coordinatorOf(store, this.#options);
|
|
340
443
|
if (!coordinator)
|
|
341
|
-
throw new AgentError(
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
444
|
+
throw new AgentError(
|
|
445
|
+
entryChannelId === undefined
|
|
446
|
+
? "no active coordinator"
|
|
447
|
+
: "no active agent in the entry channel",
|
|
448
|
+
);
|
|
449
|
+
const home =
|
|
450
|
+
entryChannelId !== undefined
|
|
451
|
+
? discordKey(entryChannelId)
|
|
452
|
+
: keysOf(
|
|
453
|
+
store,
|
|
454
|
+
coordinator.name,
|
|
455
|
+
this.#options.surfaces?.roomSurfaces() ?? [],
|
|
456
|
+
)[0];
|
|
457
|
+
if (!home)
|
|
458
|
+
throw new AgentError(`${coordinator.name} has no room to post in`);
|
|
459
|
+
await this.#post(home, coordinator, { chunks: splitReply(text) });
|
|
345
460
|
}
|
|
346
461
|
|
|
347
462
|
message(caller: AgentTurnScope, to: string, text: string): string {
|
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import { mkdirSync } from "node:fs";
|
|
2
|
-
import {
|
|
1
|
+
import { constants, mkdirSync } from "node:fs";
|
|
2
|
+
import { lstat, open, rm } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute, join } from "node:path";
|
|
4
|
+
import type { AttachmentContent } from "../contract/channels.ts";
|
|
3
5
|
import type {
|
|
4
6
|
AttachmentFailure,
|
|
5
7
|
AttachmentRef,
|
|
6
8
|
StoredAttachment,
|
|
7
9
|
} from "../domain/attachment.ts";
|
|
10
|
+
import { PluginError } from "../errors.ts";
|
|
8
11
|
import { plainText } from "./attachment-name.ts";
|
|
9
12
|
|
|
10
13
|
export const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
|
|
@@ -23,6 +26,16 @@ export interface FetchRequest {
|
|
|
23
26
|
signal?: AbortSignal;
|
|
24
27
|
/** Gives up on a file that sends no byte for this long, and goes on to the next; default 10 s. */
|
|
25
28
|
idleMs?: number;
|
|
29
|
+
/** The most bytes of one file; default `MAX_ATTACHMENT_BYTES`. Holds a ref, a file, a stream and a download alike. */
|
|
30
|
+
maxBytes?: number;
|
|
31
|
+
/**
|
|
32
|
+
* Reads a ref through the surface that made it. A function that answers a promise is the ref's
|
|
33
|
+
* only source: the ref is never fetched over the network, and a rejection fails that file.
|
|
34
|
+
* The answer is the bytes, the path of a file to copy or a stream to store; the last two are
|
|
35
|
+
* never held whole in memory. `undefined` = the surface has no reader, and the ref's URL is
|
|
36
|
+
* downloaded.
|
|
37
|
+
*/
|
|
38
|
+
read?: (ref: AttachmentRef) => Promise<AttachmentContent> | undefined;
|
|
26
39
|
}
|
|
27
40
|
|
|
28
41
|
export interface FetchResult {
|
|
@@ -127,6 +140,113 @@ function untilAborted<T>(work: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
|
127
140
|
});
|
|
128
141
|
}
|
|
129
142
|
|
|
143
|
+
/** The refusal for a file over the limit, such as `larger than 25 MB (26214401 bytes)`. */
|
|
144
|
+
function tooLarge(limit: number, size: number): string {
|
|
145
|
+
return `larger than ${Number((limit / 1024 / 1024).toFixed(1))} MB (${size} bytes)`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Writes a stream to `path` as it comes, never holding more than a chunk. Returns the bytes
|
|
150
|
+
* written, or the observed size once the stream passes `limit`, in which case nothing is left
|
|
151
|
+
* behind; a failure or an abort removes the partial file too.
|
|
152
|
+
*/
|
|
153
|
+
async function storeStream(
|
|
154
|
+
stream: ReadableStream<Uint8Array>,
|
|
155
|
+
path: string,
|
|
156
|
+
limit: number,
|
|
157
|
+
watch: IdleWatch,
|
|
158
|
+
): Promise<number | { tooLarge: number }> {
|
|
159
|
+
const { signal } = watch;
|
|
160
|
+
const reader = stream.getReader();
|
|
161
|
+
// A body that stalls ends with the signal, also when the fetch itself ignores it.
|
|
162
|
+
const stop = () => void reader.cancel(signal.reason).catch(() => {});
|
|
163
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
164
|
+
let size = 0;
|
|
165
|
+
let handle: Awaited<ReturnType<typeof open>> | undefined;
|
|
166
|
+
let kept = false;
|
|
167
|
+
try {
|
|
168
|
+
if (signal.aborted) throw signal.reason ?? new Error("aborted");
|
|
169
|
+
handle = await open(path, "w");
|
|
170
|
+
for (;;) {
|
|
171
|
+
const { done, value } = await reader.read();
|
|
172
|
+
if (done) break;
|
|
173
|
+
watch.touch();
|
|
174
|
+
size += value.byteLength;
|
|
175
|
+
if (size > limit) {
|
|
176
|
+
await reader.cancel();
|
|
177
|
+
return { tooLarge: size };
|
|
178
|
+
}
|
|
179
|
+
await handle.write(value);
|
|
180
|
+
}
|
|
181
|
+
// A cancelled reader reads as the end of the body, so a cut-short body is not a whole file.
|
|
182
|
+
if (signal.aborted) throw signal.reason ?? new Error("aborted");
|
|
183
|
+
await handle.close();
|
|
184
|
+
handle = undefined;
|
|
185
|
+
kept = true;
|
|
186
|
+
return size;
|
|
187
|
+
} finally {
|
|
188
|
+
signal.removeEventListener("abort", stop);
|
|
189
|
+
reader.releaseLock();
|
|
190
|
+
await handle?.close().catch(() => {});
|
|
191
|
+
if (!kept) await rm(path, { force: true });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Stores what a surface handed over at `path`; the size, or the observed size once it is over `limit`. */
|
|
196
|
+
async function storeContent(
|
|
197
|
+
content: AttachmentContent,
|
|
198
|
+
path: string,
|
|
199
|
+
limit: number,
|
|
200
|
+
watch: IdleWatch,
|
|
201
|
+
): Promise<number | { tooLarge: number }> {
|
|
202
|
+
if (content instanceof Uint8Array) {
|
|
203
|
+
if (content.byteLength > limit) return { tooLarge: content.byteLength };
|
|
204
|
+
await Bun.write(path, content);
|
|
205
|
+
return content.byteLength;
|
|
206
|
+
}
|
|
207
|
+
if (content instanceof ReadableStream)
|
|
208
|
+
return storeStream(content, path, limit, watch);
|
|
209
|
+
return storeFile(content.path, path, limit, watch);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** An error that carries a code, so the failure reports the code and never a message that may quote a path. */
|
|
213
|
+
function codedError(code: string, message: string): Error {
|
|
214
|
+
return Object.assign(new Error(message), { code });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Copies a regular file the surface owns: absolute, not a symlink, opened once and read from that handle. */
|
|
218
|
+
async function storeFile(
|
|
219
|
+
source: string,
|
|
220
|
+
path: string,
|
|
221
|
+
limit: number,
|
|
222
|
+
watch: IdleWatch,
|
|
223
|
+
): Promise<number | { tooLarge: number }> {
|
|
224
|
+
if (!isAbsolute(source))
|
|
225
|
+
throw codedError("EINVAL", "attachment path is not absolute");
|
|
226
|
+
if ((await lstat(source)).isSymbolicLink())
|
|
227
|
+
throw codedError("EINVAL", "attachment path is a symlink");
|
|
228
|
+
// O_NOFOLLOW closes the gap between the check above and the open.
|
|
229
|
+
const handle = await open(source, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
230
|
+
try {
|
|
231
|
+
const info = await handle.stat();
|
|
232
|
+
if (!info.isFile())
|
|
233
|
+
throw codedError("EINVAL", "attachment path is not a regular file");
|
|
234
|
+
if (info.size > limit) return { tooLarge: info.size };
|
|
235
|
+
// The count in `storeStream` also holds a file that grows while it is copied.
|
|
236
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
237
|
+
async pull(controller) {
|
|
238
|
+
const chunk = new Uint8Array(64 * 1024);
|
|
239
|
+
const { bytesRead } = await handle.read(chunk, 0, chunk.length, null);
|
|
240
|
+
if (bytesRead === 0) controller.close();
|
|
241
|
+
else controller.enqueue(chunk.subarray(0, bytesRead));
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
return await storeStream(stream, path, limit, watch);
|
|
245
|
+
} finally {
|
|
246
|
+
await handle.close().catch(() => {});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
130
250
|
/** Downloads attachments one by one; a failed file is reported without stopping the others. */
|
|
131
251
|
export async function fetchAttachments(
|
|
132
252
|
request: FetchRequest,
|
|
@@ -136,6 +256,11 @@ export async function fetchAttachments(
|
|
|
136
256
|
const failures: AttachmentFailure[] = [];
|
|
137
257
|
const { refs, dir, prefix, fromReference, signal } = request;
|
|
138
258
|
const idleMs = request.idleMs ?? DEFAULT_IDLE_MS;
|
|
259
|
+
const limit = request.maxBytes ?? MAX_ATTACHMENT_BYTES;
|
|
260
|
+
if (!Number.isSafeInteger(limit) || limit <= 0)
|
|
261
|
+
throw new PluginError(
|
|
262
|
+
`the attachment size limit (maxAttachmentBytes) must be a positive integer of bytes, not ${limit}`,
|
|
263
|
+
);
|
|
139
264
|
if (refs.length === 0) return { files, failures };
|
|
140
265
|
|
|
141
266
|
for (const [index, ref] of refs.entries()) {
|
|
@@ -145,40 +270,54 @@ export async function fetchAttachments(
|
|
|
145
270
|
fail("download failed: deadline passed or download aborted");
|
|
146
271
|
continue;
|
|
147
272
|
}
|
|
148
|
-
if (ref.size >
|
|
149
|
-
fail(
|
|
273
|
+
if (ref.size > limit) {
|
|
274
|
+
fail(tooLarge(limit, ref.size));
|
|
150
275
|
continue;
|
|
151
276
|
}
|
|
152
277
|
const watch = new IdleWatch(idleMs, signal);
|
|
153
278
|
try {
|
|
154
279
|
// A directory that cannot be made fails the file, not the turn.
|
|
155
280
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
281
|
+
const file = `${prefix}-${index}-${safeFileName(ref.name)}`;
|
|
282
|
+
const path = join(dir, file);
|
|
283
|
+
let contentType = ref.contentType || "application/octet-stream";
|
|
284
|
+
let stored: number | { tooLarge: number };
|
|
285
|
+
const read = request.read?.(ref);
|
|
286
|
+
if (read) {
|
|
287
|
+
stored = await storeContent(
|
|
288
|
+
await untilAborted(read, watch.signal),
|
|
289
|
+
path,
|
|
290
|
+
limit,
|
|
291
|
+
watch,
|
|
292
|
+
);
|
|
293
|
+
} else {
|
|
294
|
+
const response = await untilAborted(
|
|
295
|
+
fetchImpl(ref.url, { signal: watch.signal }),
|
|
296
|
+
watch.signal,
|
|
297
|
+
);
|
|
298
|
+
if (!response.ok) {
|
|
299
|
+
await response.body?.cancel();
|
|
300
|
+
fail(`download failed with HTTP ${response.status}`);
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
contentType =
|
|
304
|
+
ref.contentType ||
|
|
305
|
+
response.headers.get("content-type") ||
|
|
306
|
+
"application/octet-stream";
|
|
307
|
+
stored = response.body
|
|
308
|
+
? await storeStream(response.body, path, limit, watch)
|
|
309
|
+
: await Bun.write(path, new Uint8Array());
|
|
164
310
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
fail(`larger than 25 MB (${data} bytes)`);
|
|
311
|
+
if (typeof stored === "object") {
|
|
312
|
+
fail(tooLarge(limit, stored.tooLarge));
|
|
168
313
|
continue;
|
|
169
314
|
}
|
|
170
|
-
const file = `${prefix}-${index}-${safeFileName(ref.name)}`;
|
|
171
|
-
const path = join(dir, file);
|
|
172
|
-
await Bun.write(path, data);
|
|
173
315
|
files.push({
|
|
174
316
|
name: ref.name,
|
|
175
317
|
file,
|
|
176
318
|
path,
|
|
177
|
-
contentType
|
|
178
|
-
|
|
179
|
-
response.headers.get("content-type") ||
|
|
180
|
-
"application/octet-stream",
|
|
181
|
-
size: data.byteLength,
|
|
319
|
+
contentType,
|
|
320
|
+
size: stored,
|
|
182
321
|
fromReference,
|
|
183
322
|
});
|
|
184
323
|
} catch (error) {
|
|
@@ -191,44 +330,3 @@ export async function fetchAttachments(
|
|
|
191
330
|
}
|
|
192
331
|
return { files, failures };
|
|
193
332
|
}
|
|
194
|
-
|
|
195
|
-
/** Returns the bytes, or the observed size once the stream exceeds the limit. */
|
|
196
|
-
async function readAttachmentBody(
|
|
197
|
-
response: Response,
|
|
198
|
-
watch: IdleWatch,
|
|
199
|
-
): Promise<Uint8Array | number> {
|
|
200
|
-
if (!response.body) return new Uint8Array();
|
|
201
|
-
const { signal } = watch;
|
|
202
|
-
const reader = response.body.getReader();
|
|
203
|
-
const chunks: Uint8Array[] = [];
|
|
204
|
-
let size = 0;
|
|
205
|
-
// A body that stalls ends with the signal, also when the fetch itself ignores it.
|
|
206
|
-
const stop = () => void reader.cancel(signal.reason).catch(() => {});
|
|
207
|
-
signal.addEventListener("abort", stop, { once: true });
|
|
208
|
-
try {
|
|
209
|
-
if (signal.aborted) throw signal.reason ?? new Error("aborted");
|
|
210
|
-
for (;;) {
|
|
211
|
-
const { done, value } = await reader.read();
|
|
212
|
-
if (done) break;
|
|
213
|
-
watch.touch();
|
|
214
|
-
size += value.byteLength;
|
|
215
|
-
if (size > MAX_ATTACHMENT_BYTES) {
|
|
216
|
-
await reader.cancel();
|
|
217
|
-
return size;
|
|
218
|
-
}
|
|
219
|
-
chunks.push(value);
|
|
220
|
-
}
|
|
221
|
-
// A cancelled reader reads as the end of the body, so a cut-short body is not a whole file.
|
|
222
|
-
if (signal.aborted) throw signal.reason ?? new Error("aborted");
|
|
223
|
-
} finally {
|
|
224
|
-
signal.removeEventListener("abort", stop);
|
|
225
|
-
reader.releaseLock();
|
|
226
|
-
}
|
|
227
|
-
const data = new Uint8Array(size);
|
|
228
|
-
let offset = 0;
|
|
229
|
-
for (const chunk of chunks) {
|
|
230
|
-
data.set(chunk, offset);
|
|
231
|
-
offset += chunk.byteLength;
|
|
232
|
-
}
|
|
233
|
-
return data;
|
|
234
|
-
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { TurnAttachments } from "../domain/attachment.ts";
|
|
2
|
+
import { MAX_ATTACHMENT_BYTES } from "./attachment-fetcher.ts";
|
|
2
3
|
import { plainContentType, plainText } from "./attachment-name.ts";
|
|
3
4
|
|
|
4
5
|
/** A name a prompt can quote: no control or bidi characters, and no quote to close it early. */
|
|
@@ -22,8 +23,13 @@ export function withAttachmentsBlock(
|
|
|
22
23
|
const lines: string[] = [];
|
|
23
24
|
for (const file of attachments.files) {
|
|
24
25
|
const origin = file.fromReference ? ", from the replied-to message" : "";
|
|
26
|
+
// read_attachment reads no file over the size every surface takes; tools that take files can.
|
|
27
|
+
const large =
|
|
28
|
+
file.size > MAX_ATTACHMENT_BYTES
|
|
29
|
+
? ", too large for read_attachment; kept as a file for tools"
|
|
30
|
+
: "";
|
|
25
31
|
lines.push(
|
|
26
|
-
`- ${plainText(file.file)} (original name "${quotable(file.name)}", ${plainContentType(file.contentType)}, ${formatSize(file.size)}${origin})`,
|
|
32
|
+
`- ${plainText(file.file)} (original name "${quotable(file.name)}", ${plainContentType(file.contentType)}, ${formatSize(file.size)}${origin}${large})`,
|
|
27
33
|
);
|
|
28
34
|
}
|
|
29
35
|
for (const failure of attachments.failures) {
|
|
@@ -12,8 +12,10 @@ export interface ToolAttachment {
|
|
|
12
12
|
name: string;
|
|
13
13
|
contentType: string;
|
|
14
14
|
size: number;
|
|
15
|
-
/** The file's whole content
|
|
15
|
+
/** The file's whole content, in memory; for a large file prefer `stream()`. */
|
|
16
16
|
bytes(): Promise<Uint8Array>;
|
|
17
|
+
/** The file's content as a stream, for a file too large to hold in memory, such as a video. */
|
|
18
|
+
stream(): ReadableStream<Uint8Array>;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
/** A file someone attached to the conversation, as a tool lists it. */
|
|
@@ -83,6 +85,23 @@ export async function openAttachment(
|
|
|
83
85
|
throw new AttachmentLookupError(`"${shown}" could not be read`);
|
|
84
86
|
}
|
|
85
87
|
},
|
|
88
|
+
stream: () => {
|
|
89
|
+
const source = handle.stream().getReader();
|
|
90
|
+
return new ReadableStream<Uint8Array>({
|
|
91
|
+
async pull(controller) {
|
|
92
|
+
try {
|
|
93
|
+
const next = await source.read();
|
|
94
|
+
if (next.done) controller.close();
|
|
95
|
+
else controller.enqueue(next.value);
|
|
96
|
+
} catch {
|
|
97
|
+
controller.error(
|
|
98
|
+
new AttachmentLookupError(`"${shown}" could not be read`),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
cancel: (reason) => source.cancel(reason),
|
|
103
|
+
});
|
|
104
|
+
},
|
|
86
105
|
};
|
|
87
106
|
}
|
|
88
107
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { AttachmentContent } from "../contract/channels.ts";
|
|
2
|
+
import type { AttachmentRef, TurnAttachments } from "../domain/attachment.ts";
|
|
2
3
|
import type { InboundMessage } from "../domain/conversation.ts";
|
|
3
4
|
import type { Logger } from "../log.ts";
|
|
4
5
|
import { fetchAttachments } from "./attachment-fetcher.ts";
|
|
@@ -29,6 +30,15 @@ export async function collectAttachments(
|
|
|
29
30
|
* the files on to something that prepares them itself.
|
|
30
31
|
*/
|
|
31
32
|
prepareImages?: boolean;
|
|
33
|
+
/** The most bytes of one file; default 25 MiB (`MAX_ATTACHMENT_BYTES`). */
|
|
34
|
+
maxBytes?: number;
|
|
35
|
+
/**
|
|
36
|
+
* Reads a ref through the surface that made it. A function that answers a promise is the
|
|
37
|
+
* ref's only source: it is never fetched, and a rejection fails that file. The answer is the
|
|
38
|
+
* bytes, `{ path }` of a file or a stream, the last two stored without holding them whole.
|
|
39
|
+
* `undefined` = the surface has no reader, and the ref's URL is downloaded.
|
|
40
|
+
*/
|
|
41
|
+
read?: (ref: AttachmentRef) => Promise<AttachmentContent> | undefined;
|
|
32
42
|
},
|
|
33
43
|
): Promise<TurnAttachments> {
|
|
34
44
|
const { fetchImpl, logger } = options;
|
|
@@ -38,7 +48,11 @@ export async function collectAttachments(
|
|
|
38
48
|
const signal = options.signal
|
|
39
49
|
? AbortSignal.any([deadline, options.signal])
|
|
40
50
|
: deadline;
|
|
41
|
-
const idle =
|
|
51
|
+
const idle = {
|
|
52
|
+
...(options.idleMs === undefined ? {} : { idleMs: options.idleMs }),
|
|
53
|
+
...(options.maxBytes === undefined ? {} : { maxBytes: options.maxBytes }),
|
|
54
|
+
...(options.read ? { read: options.read } : {}),
|
|
55
|
+
};
|
|
42
56
|
const own = await fetchAttachments(
|
|
43
57
|
{
|
|
44
58
|
refs: message.attachments,
|
|
@@ -97,6 +111,8 @@ export function attachmentsOf(
|
|
|
97
111
|
idleMs?: number;
|
|
98
112
|
signal?: AbortSignal;
|
|
99
113
|
prepareImages?: boolean;
|
|
114
|
+
maxBytes?: number;
|
|
115
|
+
read?: (ref: AttachmentRef) => Promise<AttachmentContent> | undefined;
|
|
100
116
|
} = {},
|
|
101
117
|
): Promise<TurnAttachments> {
|
|
102
118
|
return collectAttachments(message, dir, {
|