pi-roundtable 0.26.3 → 0.27.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.
Files changed (84) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +23 -0
  3. package/README.zh-TW.md +31 -8
  4. package/docs/plugins.md +176 -7
  5. package/package.json +1 -1
  6. package/src/core/agents/agent-book.ts +34 -1
  7. package/src/core/agents/agent-claim.ts +25 -5
  8. package/src/core/agents/agent-dashboard.ts +23 -2
  9. package/src/core/agents/agent-messages.ts +28 -10
  10. package/src/core/agents/agent-ports.ts +10 -0
  11. package/src/core/agents/agent-prompt.ts +17 -1
  12. package/src/core/agents/agent-rules.ts +11 -1
  13. package/src/core/agents/agent-schema.ts +22 -0
  14. package/src/core/agents/agent-statements.ts +16 -2
  15. package/src/core/agents/agent-store.ts +30 -20
  16. package/src/core/agents/agent-team-fake-runtime.ts +48 -0
  17. package/src/core/agents/agent-team-fixture.ts +32 -44
  18. package/src/core/agents/agent-team.ts +69 -74
  19. package/src/core/agents/agent-tool-schema.ts +84 -0
  20. package/src/core/agents/agent-tools.ts +50 -85
  21. package/src/core/agents/group-turns.ts +132 -41
  22. package/src/core/agents/helper-create.ts +180 -0
  23. package/src/core/agents/helper-ops.ts +33 -0
  24. package/src/core/agents/helper-test-kit.ts +72 -0
  25. package/src/core/agents/helper-tools.ts +104 -0
  26. package/src/core/agents/helper-work.ts +101 -0
  27. package/src/core/agents/message-target.ts +15 -23
  28. package/src/core/agents/routes.ts +347 -0
  29. package/src/core/agents/team-approval.ts +32 -0
  30. package/src/core/agents/team-editing.ts +97 -32
  31. package/src/core/agents/team-helpers.ts +216 -0
  32. package/src/core/agents/team-lifecycle.ts +26 -1
  33. package/src/core/agents/team-options.ts +8 -0
  34. package/src/core/agents/team-prompt.ts +75 -0
  35. package/src/core/agents/team-selection.ts +31 -13
  36. package/src/core/agents/team-status.ts +6 -0
  37. package/src/core/agents/team-text.ts +20 -4
  38. package/src/core/agents/team-turn-types.ts +12 -1
  39. package/src/core/agents/team-turns.ts +25 -31
  40. package/src/core/builtin/agent-server.ts +37 -2
  41. package/src/core/builtin/modules.ts +4 -4
  42. package/src/core/config/adapters.ts +12 -0
  43. package/src/core/config/agent-team.ts +2 -0
  44. package/src/core/config/config-schema.ts +4 -0
  45. package/src/core/config/config.ts +48 -2
  46. package/src/core/config/helpers.ts +60 -0
  47. package/src/core/config/ops.ts +8 -0
  48. package/src/core/config/routing.ts +296 -0
  49. package/src/core/contract/channels.ts +73 -2
  50. package/src/core/contract/surface.ts +31 -1
  51. package/src/core/define-roundtable.ts +8 -1
  52. package/src/core/discord/agent-discord.ts +67 -6
  53. package/src/core/discord/discord-surface.ts +14 -16
  54. package/src/core/discord/message-changes.ts +66 -0
  55. package/src/core/discord/poster/poster-checks.ts +62 -8
  56. package/src/core/discord/poster/poster.ts +41 -0
  57. package/src/core/discord/poster/rest-poster-read.ts +130 -0
  58. package/src/core/discord/poster/rest-poster.ts +38 -120
  59. package/src/core/domain/round.ts +31 -0
  60. package/src/core/host-registration.ts +15 -0
  61. package/src/core/host.ts +6 -0
  62. package/src/core/i18n/conversation.ts +3 -0
  63. package/src/core/i18n/dashboard.ts +4 -0
  64. package/src/core/ops/error-reporter.ts +4 -0
  65. package/src/core/ops/ops-alerts.ts +102 -6
  66. package/src/core/plugin.ts +17 -0
  67. package/src/core/registry/contributions.ts +1 -26
  68. package/src/core/registry/surface-service.ts +41 -0
  69. package/src/core/routing/channel-router.ts +54 -3
  70. package/src/core/routing/claim-events.ts +42 -0
  71. package/src/core/routing/message-observers.ts +86 -0
  72. package/src/core/routing/peer-bots.ts +149 -0
  73. package/src/core/routing/surface-port.ts +4 -0
  74. package/src/core/services.ts +7 -6
  75. package/src/core/testing/fake-poster-guild.ts +1 -1
  76. package/src/core/testing/fake-poster-reads.ts +96 -0
  77. package/src/core/testing/fake-poster.ts +62 -94
  78. package/src/core/tool-set.snapshot.json +8 -0
  79. package/src/discord/index.ts +2 -0
  80. package/src/index.ts +18 -1
  81. package/src/kit/channels.ts +1 -0
  82. package/src/kit/index.ts +5 -0
  83. package/src/kit/ops-alerts.ts +4 -0
  84. package/src/testing.ts +18 -1
@@ -0,0 +1,296 @@
1
+ import { ConfigError } from "../domain/errors.ts";
2
+ import type { ChannelKey } from "../sessions.ts";
3
+ import {
4
+ either,
5
+ type Field,
6
+ list,
7
+ oneOf,
8
+ optional,
9
+ record,
10
+ shape,
11
+ text,
12
+ } from "./schema.ts";
13
+
14
+ /**
15
+ * Where an agent's unprompted work lands: how an event that no person is waiting on picks its
16
+ * conversation. Every key is optional, and a key left out keeps what the host did before `routing`
17
+ * existed. A surface is named by its key prefix, such as `discord` or `app`; `web` stands for the
18
+ * one surface that hosts rooms when exactly one does.
19
+ */
20
+ export interface AgentRoutes {
21
+ /**
22
+ * The agent's primary place, where its unprompted work lands. `"auto"` (default) is its
23
+ * Discord channel when it has one, else its room on the first surface that hosts rooms; a
24
+ * surface name picks the agent's place there.
25
+ */
26
+ primary?: "auto" | (string & {});
27
+ /**
28
+ * Where a foreign webhook's or integration's post in the agent's place runs its report turn.
29
+ * `"origin"` (default) is the conversation the post came in; `"primary"` the agent's primary
30
+ * place; a surface name the agent's place there. The raw post stays where it was posted.
31
+ */
32
+ integration?: "origin" | "primary" | (string & {});
33
+ /**
34
+ * The conversation a `message_agent` call reaches when it names none. `"latest"` (default) is
35
+ * the one the agent last ran a turn in, never carrying a message from a room out to Discord;
36
+ * `"primary"` the agent's primary place; a surface name the agent's place there.
37
+ */
38
+ agentMessage?: "latest" | "primary" | (string & {});
39
+ /**
40
+ * Where an agent's opening task (`agent_create task`) runs. `"origin-surface"` (default) is the
41
+ * agent's place on the surface the creation came from, else its primary place; `"primary"`
42
+ * always its primary place.
43
+ */
44
+ opening?: "origin-surface" | "primary";
45
+ /**
46
+ * Reserved: surfaces that get a read-only copy of what the agent posts in its primary place.
47
+ * Not built yet; a non-empty list stops the start.
48
+ */
49
+ mirror?: string[];
50
+ }
51
+
52
+ /** A named destination of the host: a conversation, or one agent's primary place. */
53
+ export type PlaceTarget = `${string}:${string}` | { agent: string };
54
+
55
+ /** What a surface offers agents; both keys are reserved and only the default is accepted today. */
56
+ export interface SurfaceRouting {
57
+ /** `"all"` (default): every agent has a place on the surface. `"none"` is reserved and stops the start. */
58
+ agentPlaces?: "all" | "none";
59
+ /** Reserved: which claim answers the surface's conversations. Not built yet; setting it stops the start. */
60
+ answeredBy?: string;
61
+ }
62
+
63
+ /**
64
+ * The optional `routing` block: one declared table for where events land. Leaving it out keeps
65
+ * every default as it was before the block existed.
66
+ */
67
+ export interface RoutingConfig {
68
+ /**
69
+ * Per-agent rules by agent name; `"*"` is the default of every agent. A name applies to the
70
+ * agent when it is created later too. A field the agent's entry leaves out falls to `"*"`.
71
+ */
72
+ agents?: { "*"?: AgentRoutes } & Record<string, AgentRoutes | undefined>;
73
+ /**
74
+ * Named destinations of the host. `ops` is the process's error reports: a conversation key
75
+ * `<surface>:<id>`, or `{ agent }` for that agent's primary place.
76
+ */
77
+ places?: Record<string, PlaceTarget>;
78
+ /** Per-surface settings, by surface name. */
79
+ surfaces?: Record<string, SurfaceRouting>;
80
+ }
81
+
82
+ /** The places the core declares; a name outside this list is a typo until a plugin declares it. */
83
+ const DECLARED_PLACES: readonly string[] = ["ops"];
84
+
85
+ /** The agent-rule keys that name a destination, and the words each accepts besides a surface name. */
86
+ export const ROUTE_WORDS = {
87
+ primary: ["auto"],
88
+ integration: ["origin", "primary"],
89
+ agentMessage: ["latest", "primary"],
90
+ opening: ["origin-surface", "primary"],
91
+ } as const;
92
+
93
+ type RouteKey = keyof typeof ROUTE_WORDS;
94
+
95
+ /** Every word with a meaning of its own; none of them may name a surface. */
96
+ const KEYWORDS: readonly string[] = [
97
+ ...new Set(Object.values(ROUTE_WORDS).flat()),
98
+ ];
99
+
100
+ const AGENT_NAME = /^[a-z0-9-]{1,32}$/;
101
+ const CONVERSATION = /^[^:]+:.+$/;
102
+
103
+ /** A destination of `key`: one of its words, or a surface name; another kind's word is refused. */
104
+ function destination(key: RouteKey): Field<string> {
105
+ const words = ROUTE_WORDS[key];
106
+ const describe =
107
+ key === "opening"
108
+ ? `one of ${words.join(", ")}`
109
+ : `${words.map((w) => `"${w}"`).join(", ")}, or a surface name`;
110
+ return {
111
+ describe,
112
+ check: (value, path) => {
113
+ const given = text.check(value, path);
114
+ const word = given.trim();
115
+ const own = (words as readonly string[]).includes(word);
116
+ if (!own && (KEYWORDS.includes(word) || key === "opening"))
117
+ throw new ConfigError(
118
+ `config ${path}: ${JSON.stringify(word)} is not a value of ${key}. Use ${describe}.`,
119
+ );
120
+ return word;
121
+ },
122
+ };
123
+ }
124
+
125
+ const agentRoutes = shape({
126
+ primary: optional(destination("primary")),
127
+ integration: optional(destination("integration")),
128
+ agentMessage: optional(destination("agentMessage")),
129
+ opening: optional(destination("opening")),
130
+ mirror: optional(list(text)),
131
+ });
132
+
133
+ const placeTarget: Field<PlaceTarget> = either(
134
+ text as Field<`${string}:${string}`>,
135
+ shape({ agent: text }),
136
+ );
137
+
138
+ const surfaceRouting = shape({
139
+ agentPlaces: optional(oneOf("all" as const, "none" as const)),
140
+ answeredBy: optional(text),
141
+ });
142
+
143
+ /** The `routing` key's shape. */
144
+ export const routingShape = optional(
145
+ shape({
146
+ agents: optional(record(agentRoutes)),
147
+ places: optional(record(placeTarget)),
148
+ surfaces: optional(record(surfaceRouting)),
149
+ }),
150
+ );
151
+
152
+ /** The rules of one agent after checking: the reserved keys are gone. */
153
+ export interface ResolvedAgentRoutes {
154
+ primary?: string;
155
+ integration?: string;
156
+ agentMessage?: string;
157
+ opening?: "origin-surface" | "primary";
158
+ }
159
+
160
+ /** The checked `routing` block the agent server reads; a host without the key has none. */
161
+ export interface ResolvedRouting {
162
+ agents: Readonly<Record<string, ResolvedAgentRoutes>>;
163
+ /** Named destinations: a conversation key, or an agent whose primary place it is. */
164
+ places: Readonly<Record<string, ChannelKey | { agent: string }>>;
165
+ }
166
+
167
+ /** What the rest of the configuration says that the block is checked against. */
168
+ export interface RoutingContext {
169
+ /** Whether Discord is configured, so agents have Discord channels. */
170
+ withDiscord: boolean;
171
+ /** Whether the agent server runs: only then do agents and their places exist. */
172
+ agentServer: boolean;
173
+ /** Whether the top-level `ops` is set, which the `ops` place replaces. */
174
+ topLevelOps: boolean;
175
+ }
176
+
177
+ const refuse = (path: string, problem: string, fix: string): ConfigError =>
178
+ new ConfigError(`config ${path}: ${problem}. ${fix}`);
179
+
180
+ function checkReserved(config: RoutingConfig): void {
181
+ for (const [name, agent] of Object.entries(config.agents ?? {}))
182
+ if ((agent?.mirror?.length ?? 0) > 0)
183
+ throw refuse(
184
+ `routing.agents.${name}.mirror`,
185
+ "mirroring a place is not built yet, so it would do nothing",
186
+ "Remove mirror; the term and the key are reserved until a host needs the copies",
187
+ );
188
+ for (const [surface, rules] of Object.entries(config.surfaces ?? {})) {
189
+ if (rules.agentPlaces === "none")
190
+ throw refuse(
191
+ `routing.surfaces.${surface}.agentPlaces`,
192
+ '"none" is not built yet: Discord makes a channel for every agent, and a surface that hosts rooms hosts one for every agent',
193
+ 'Remove agentPlaces or write "all", which is what every surface does',
194
+ );
195
+ if (rules.answeredBy !== undefined)
196
+ throw refuse(
197
+ `routing.surfaces.${surface}.answeredBy`,
198
+ "naming the claim that answers a surface is not built yet; claim priorities decide",
199
+ "Remove answeredBy; give your plugin's claim a priority above the agent server's to answer there",
200
+ );
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Checks the block against the rest of the configuration, naming the key and the fix. Surfaces
206
+ * are checked again once the plugins have registered theirs, when the agent server links.
207
+ */
208
+ export function routingOf(
209
+ config: RoutingConfig | undefined,
210
+ context: RoutingContext,
211
+ ): ResolvedRouting | undefined {
212
+ if (!config) return undefined;
213
+ const agents = config.agents ?? {};
214
+ const places = config.places ?? {};
215
+ const surfaces = config.surfaces ?? {};
216
+ checkReserved(config);
217
+ for (const name of Object.keys(agents))
218
+ if (name !== "*" && !AGENT_NAME.test(name))
219
+ throw refuse(
220
+ `routing.agents.${name}`,
221
+ 'not an agent name: lowercase letters, digits and dashes, 1 to 32, or "*" for every agent',
222
+ "Rename the key to the agent's name",
223
+ );
224
+ for (const name of Object.keys(places))
225
+ if (!DECLARED_PLACES.includes(name))
226
+ throw refuse(
227
+ `routing.places.${name}`,
228
+ `no place is called ${JSON.stringify(name)}`,
229
+ `The places are ${DECLARED_PLACES.join(", ")}`,
230
+ );
231
+ for (const [name, target] of Object.entries(places))
232
+ if (typeof target === "string" && !CONVERSATION.test(target))
233
+ throw refuse(
234
+ `routing.places.${name}`,
235
+ `expected a conversation key <surface>:<id> or { agent }, got ${JSON.stringify(target)}`,
236
+ "Write the surface's prefix, a colon, and the conversation's id, or name an agent",
237
+ );
238
+ if (places.ops !== undefined && context.topLevelOps)
239
+ throw refuse(
240
+ "routing.places.ops",
241
+ "the top-level ops is set as well, and both say where the error reports go",
242
+ "Keep one: move ops into routing.places.ops and remove the top-level ops",
243
+ );
244
+ const needsAgents =
245
+ Object.keys(agents).length > 0 ||
246
+ Object.keys(surfaces).length > 0 ||
247
+ (typeof places.ops === "object" && places.ops !== undefined);
248
+ if (needsAgents && !context.agentServer)
249
+ throw refuse(
250
+ "routing",
251
+ "agents, per-surface settings and an agent place need the agent server, and this host runs none (agentServer is false, or there is neither discord nor agents)",
252
+ "Remove those keys from routing, or remove agentServer: false and configure discord or agents",
253
+ );
254
+ if (!context.withDiscord) {
255
+ const named = Object.entries(agents).flatMap(([name, rules]) =>
256
+ (["primary", "integration", "agentMessage"] as const)
257
+ .filter((key) => rules?.[key] === "discord")
258
+ .map((key) => `routing.agents.${name}.${key}`),
259
+ );
260
+ if (named[0] || surfaces.discord)
261
+ throw refuse(
262
+ named[0] ?? "routing.surfaces.discord",
263
+ "names the discord surface, and Discord is not configured",
264
+ "Name a surface that hosts rooms instead, or configure discord",
265
+ );
266
+ }
267
+ return {
268
+ agents: Object.fromEntries(
269
+ Object.entries(agents).flatMap(([name, rules]) =>
270
+ rules === undefined
271
+ ? []
272
+ : [
273
+ [
274
+ name,
275
+ {
276
+ ...(rules.primary ? { primary: rules.primary } : {}),
277
+ ...(rules.integration
278
+ ? { integration: rules.integration }
279
+ : {}),
280
+ ...(rules.agentMessage
281
+ ? { agentMessage: rules.agentMessage }
282
+ : {}),
283
+ ...(rules.opening ? { opening: rules.opening } : {}),
284
+ },
285
+ ],
286
+ ],
287
+ ),
288
+ ),
289
+ places: Object.fromEntries(
290
+ Object.entries(places).map(([name, target]) => [
291
+ name,
292
+ typeof target === "string" ? (target as ChannelKey) : target,
293
+ ]),
294
+ ),
295
+ };
296
+ }
@@ -47,8 +47,12 @@ export interface InboundMessage {
47
47
  /**
48
48
  * Who the author is, with their principal and tier, as the router resolved them before any
49
49
  * claim admits the message: undefined when the access rules serve no one by them, and for a bot
50
- * or an integration, which are not resolved. Only the router sets it; a surface's own value is
51
- * dropped. A claim that serves only people the host serves answers only when it is set.
50
+ * the host does not list as a peer (`peerBot`) and for an integration, which are not resolved.
51
+ * A listed peer bot is resolved too, so a claim that checks only `speaker` answers it; use
52
+ * `answersAuthor` to tell a person from a peer. A peer is served by the access rules through its
53
+ * identity and `everyone`, never through its server roles. Only the router sets it; a surface's
54
+ * own value is dropped. A claim that serves only people the host serves answers only when it is
55
+ * set.
52
56
  */
53
57
  speaker?: Speaker;
54
58
  /** The id the surface knows the author by; who they are to the host is `speaker`. */
@@ -56,6 +60,14 @@ export interface InboundMessage {
56
60
  /** How the author appears in the channel, for example a server nickname. */
57
61
  authorName: string;
58
62
  authorIsBot: boolean;
63
+ /**
64
+ * Set by the router when a bot wrote the message and the host lists that bot as a peer
65
+ * (`discord.peerBots`) for this channel, with its rounds not used up. Such a message is
66
+ * resolved to a `speaker` like a person's, and a claim that answers people may answer it too;
67
+ * a claim that answers a peer should still ask for a mention or a reply to the bot. Only the
68
+ * router sets it; a surface's own value is dropped.
69
+ */
70
+ peerBot?: true;
59
71
  /** The roles the author holds in the message's space; absent in direct messages. */
60
72
  authorRoleIds?: readonly string[];
61
73
  /**
@@ -231,6 +243,25 @@ export interface ChannelClaim {
231
243
  backgroundRefusal?(
232
244
  turn: BackgroundTurn & { speaker: Speaker },
233
245
  ): string | undefined;
246
+ /**
247
+ * A message in an owned channel was deleted, as its surface reports it (Discord does; a
248
+ * surface that cannot tell never does). It carries only the id, and also arrives for the
249
+ * assistant's own messages, since the surface cannot tell whose an uncached message was: a
250
+ * claim ignores an id it does not know. Delivered as it comes, not inside the channel's queue,
251
+ * so it is not held up behind a running turn; a throw or rejection is logged and isolated.
252
+ */
253
+ onMessageDeleted?(
254
+ channel: ChannelKey,
255
+ messageId: string,
256
+ ): Promise<void> | void;
257
+ /**
258
+ * A message in an owned channel was edited, as it reads now. The assistant's own edits are
259
+ * never reported, as its own messages never reach `admit`; an edit that changes neither the
260
+ * text, the embeds nor the attachments, such as a pin, is not either. The message carries no
261
+ * `speaker` and no `peerBot`, since an edit is never admitted and resolves nobody, and the
262
+ * router applies no peer rule to it. Delivered like `onMessageDeleted`.
263
+ */
264
+ onMessageEdited?(message: InboundMessage): Promise<void> | void;
234
265
  /** Starts the channel's conversation over, saying whose it was. */
235
266
  startFresh(channel: ChannelKey): Promise<string>;
236
267
  /**
@@ -285,8 +316,48 @@ export interface ConversationPort {
285
316
  takesBackground(channel: ChannelKey): boolean;
286
317
  /** Whether the claim that owns the channel takes the host's own turns; false when none owns it. */
287
318
  takesSystemReports(channel: ChannelKey): boolean;
319
+ /**
320
+ * Tells the claim that owns the channel a message was deleted (`ChannelClaim.onMessageDeleted`);
321
+ * never rejects. Optional so a port written before 0.27 still type-checks; the host's has it.
322
+ */
323
+ messageDeleted?(
324
+ channel: ChannelKey,
325
+ messageId: string,
326
+ space?: string,
327
+ ): Promise<void>;
328
+ /** Tells the claim that owns the channel a message was edited (`ChannelClaim.onMessageEdited`); never rejects. */
329
+ messageEdited?(message: InboundMessage): Promise<void>;
330
+ }
331
+
332
+ /**
333
+ * Which inbound messages an observer hears. Every field narrows; an empty filter hears every
334
+ * message in a channel some claim owns, in a server or workspace, never in a direct conversation
335
+ * and never in the rooms of a surface that hosts agent rooms (`agentRooms`), which are one
336
+ * person's or one team's. A filter that names that surface in `surface` hears them.
337
+ */
338
+ export interface ObserveFilter {
339
+ /**
340
+ * Only this surface's channels, by its key prefix, such as "discord". Naming a surface that
341
+ * hosts agent rooms is how an observer opts in to hearing those rooms; without it they are
342
+ * left out.
343
+ */
344
+ surface?: string;
345
+ /** Only this server or workspace, in the surface's own id. */
346
+ space?: string;
347
+ /** Only these channels. */
348
+ channels?: readonly ChannelKey[];
349
+ /**
350
+ * Also hear direct conversations. Off by default: a direct message is private to its two
351
+ * sides, and an observer that sees it must be one the people there would expect.
352
+ */
353
+ includeDirect?: boolean;
288
354
  }
289
355
 
356
+ /** Hears a message; a return is not read, and a throw or rejection is logged and isolated. */
357
+ export type MessageObserver = (
358
+ message: Readonly<InboundMessage>,
359
+ ) => Promise<void> | void;
360
+
290
361
  /** The host's channel queue, as plugins use it. */
291
362
  export interface QueuePort {
292
363
  run<T>(channel: ChannelKey, task: () => Promise<T>): Promise<T>;
@@ -1,6 +1,7 @@
1
1
  import type { OutboundReply, PostAuthor } from "../domain/conversation.ts";
2
2
  import type { InterimMessage, InterimPosts } from "../domain/interim.ts";
3
3
  import type { TurnProgress } from "../domain/progress.ts";
4
+ import type { RoundProgress } from "../domain/round.ts";
4
5
  import { PluginError } from "../errors.ts";
5
6
  import type { PromptScope, Prompts } from "../interactions/prompts.ts";
6
7
  import type { ChannelKey } from "../sessions.ts";
@@ -71,6 +72,18 @@ export interface SurfaceThreads {
71
72
  close(thread: string): Promise<void>;
72
73
  }
73
74
 
75
+ /**
76
+ * What a surface tells the host about messages already delivered, so the claim that owns the
77
+ * channel hears of it (`ChannelClaim.onMessageDeleted`, `onMessageEdited`). A surface that cannot
78
+ * tell, such as a web chat that keeps every message, never calls them.
79
+ */
80
+ export interface SurfaceMessageEvents {
81
+ /** A message was deleted; `space` is its server or workspace, when the surface has one. */
82
+ deleted(channel: ChannelKey, messageId: string, space?: string): void;
83
+ /** A message's text or attachments changed; the message is as it reads now. */
84
+ edited(message: InboundMessage): void;
85
+ }
86
+
74
87
  /**
75
88
  * One chat network the host talks through. The host ships Discord's; a plugin may contribute
76
89
  * another with `surfaces`. A surface serves the channels whose key starts with its `surface`
@@ -122,7 +135,10 @@ export interface ChatSurface {
122
135
  * Connects and delivers every incoming message; the host passes its conversation router. A
123
136
  * message whose channel key has another prefix is logged and dropped.
124
137
  */
125
- start(deliver: (message: InboundMessage) => void): Promise<void>;
138
+ start(
139
+ deliver: (message: InboundMessage) => void,
140
+ events?: SurfaceMessageEvents,
141
+ ): Promise<void>;
126
142
  /** Runs when the host stops, after the services started later have stopped. */
127
143
  stop?(): Promise<void>;
128
144
  sendReply(channel: ChannelKey, reply: OutboundReply): Promise<void>;
@@ -186,6 +202,13 @@ export interface ChatSurface {
186
202
  * turn goes on. Absent = the surface shows only the final reply.
187
203
  */
188
204
  progress?(channel: ChannelKey, event: TurnProgress): Promise<void> | void;
205
+ /**
206
+ * Shows where a group's round stands: who speaks now, who is next, who has spoken. Called when
207
+ * the round is planned, before and after each member's turn, on a handoff, and when it ends;
208
+ * each call carries the whole state. A rejection is logged and the round goes on. Absent = the
209
+ * surface shows no rounds, as Discord does not.
210
+ */
211
+ groupRound?(channel: ChannelKey, round: RoundProgress): Promise<void> | void;
189
212
  }
190
213
 
191
214
  /**
@@ -214,6 +237,11 @@ export interface SurfacePort {
214
237
  ): Prompts | undefined;
215
238
  /** The surfaces that serve agent rooms (`agentRooms: true`); read after link. */
216
239
  roomSurfaces(): readonly string[];
240
+ /**
241
+ * Every registered surface, by name; read after link. The host's port always has it; optional
242
+ * so a port written before 0.27 still type-checks.
243
+ */
244
+ names?(): readonly string[];
217
245
  /** The channel's interim posts, under `as` when given; undefined when its surface has none. */
218
246
  interim(channel: ChannelKey, as?: PostAuthor): InterimPosts | undefined;
219
247
  /**
@@ -222,4 +250,6 @@ export interface SurfacePort {
222
250
  * type-checks, and a turn run over one without it shows no progress.
223
251
  */
224
252
  progress?(channel: ChannelKey, event: TurnProgress): Promise<void>;
253
+ /** Shows where a group's round stands; a no-op when no surface serves the channel or its surface shows none. */
254
+ groupRound?(channel: ChannelKey, round: RoundProgress): Promise<void>;
225
255
  }
@@ -245,6 +245,8 @@ function agentAssembly(
245
245
  ...(config.characters.refreshAvatars ? { refreshAvatars: true } : {}),
246
246
  avatarReference: join(ASSETS, "neutral.png"),
247
247
  avatarNeutral: join(ASSETS, "neutral.png"),
248
+ ...(config.routing ? { routing: config.routing } : {}),
249
+ helpers: config.helpers,
248
250
  // An ops agent's reports are the agent server's to deliver.
249
251
  ...(errorReporter && "agent" in errorReporter.destination
250
252
  ? { errorReporter }
@@ -280,7 +282,11 @@ export async function defineRoundtable(
280
282
  }));
281
283
  const registry = new ModelRegistry(modelRuntime);
282
284
  const errorReporter = config.ops
283
- ? new ErrorReporter({ destination: config.ops, app: name })
285
+ ? new ErrorReporter({
286
+ destination: config.ops,
287
+ app: name,
288
+ configKey: config.opsKey === "ops" ? "ops.conversation" : config.opsKey,
289
+ })
284
290
  : undefined;
285
291
  const { errorSink } = overrides;
286
292
  const logger: Logger =
@@ -334,6 +340,7 @@ export async function defineRoundtable(
334
340
  judgeModel: judgeThrough(modelRuntime, config.judge.model),
335
341
  apiKey: (provider) => registry.getApiKeyForProvider(provider),
336
342
  drain: { limitMs: config.drainMs },
343
+ ...(discord?.peerBots ? { peerBots: discord.peerBots } : {}),
337
344
  ...(overrides.aborted ? { aborted: overrides.aborted } : {}),
338
345
  },
339
346
  plugins: [
@@ -13,6 +13,7 @@ import {
13
13
  SeparatorSpacingSize,
14
14
  type TextChannel,
15
15
  TextDisplayBuilder,
16
+ ThreadAutoArchiveDuration,
16
17
  type Webhook,
17
18
  } from "discord.js";
18
19
  import type {
@@ -39,19 +40,41 @@ export class DiscordAgentChannels implements AgentChannels {
39
40
  readonly #client: Client;
40
41
  readonly #guildId: string;
41
42
  readonly #webhooks = new Map<string, Promise<Webhook>>();
43
+ /** The parent channel of each thread the agents live in, and null for a channel that is none. */
44
+ readonly #parents = new Map<string, string | null>();
42
45
 
43
46
  constructor(client: Client, guildId: string) {
44
47
  this.#client = client;
45
48
  this.#guildId = guildId;
46
49
  }
47
50
 
51
+ /**
52
+ * Where a post to `channelId` is sent: the channel whose webhook speaks, and the thread it
53
+ * speaks into. A temp knight's place is a thread, which has no webhook of its own.
54
+ */
55
+ async #route(
56
+ channelId: string,
57
+ threadId: string | undefined,
58
+ ): Promise<{ hook: string; threadId?: string }> {
59
+ let parent = this.#parents.get(channelId);
60
+ if (parent === undefined) {
61
+ const channel = await this.#client.channels.fetch(channelId);
62
+ parent = channel?.isThread() ? (channel.parentId ?? null) : null;
63
+ this.#parents.set(channelId, parent);
64
+ }
65
+ if (parent === null)
66
+ return { hook: channelId, ...(threadId ? { threadId } : {}) };
67
+ return { hook: parent, threadId: channelId };
68
+ }
69
+
48
70
  async post(channelId: string, post: AgentPost): Promise<void> {
49
- const webhook = await this.#webhook(channelId);
71
+ const route = await this.#route(channelId, post.threadId);
72
+ const webhook = await this.#webhook(route.hook);
50
73
  const as = {
51
74
  username: post.name,
52
75
  avatarURL: post.avatarUrl,
53
76
  allowedMentions: { parse: ["users" as const] },
54
- ...(post.threadId ? { threadId: post.threadId } : {}),
77
+ ...(route.threadId ? { threadId: route.threadId } : {}),
55
78
  };
56
79
  try {
57
80
  if (post.thinking) await webhook.send({ ...as, content: post.thinking });
@@ -66,7 +89,7 @@ export class DiscordAgentChannels implements AgentChannels {
66
89
  });
67
90
  } catch (error) {
68
91
  // A webhook deleted by hand is found or made again on the next post.
69
- this.#webhooks.delete(channelId);
92
+ this.#webhooks.delete(route.hook);
70
93
  throw error;
71
94
  }
72
95
  }
@@ -75,10 +98,11 @@ export class DiscordAgentChannels implements AgentChannels {
75
98
  channelId: string,
76
99
  as: Omit<AgentPost, "thinking" | "chunks" | "files">,
77
100
  ): InterimPosts {
78
- const threadId = as.threadId ? { threadId: as.threadId } : {};
79
101
  return {
80
102
  post: async (text) => {
81
- const webhook = await this.#webhook(channelId);
103
+ const route = await this.#route(channelId, as.threadId);
104
+ const threadId = route.threadId ? { threadId: route.threadId } : {};
105
+ const webhook = await this.#webhook(route.hook);
82
106
  try {
83
107
  const message = await webhook.send({
84
108
  username: as.name,
@@ -96,13 +120,43 @@ export class DiscordAgentChannels implements AgentChannels {
96
120
  },
97
121
  };
98
122
  } catch (error) {
99
- this.#webhooks.delete(channelId);
123
+ this.#webhooks.delete(route.hook);
100
124
  throw error;
101
125
  }
102
126
  },
103
127
  };
104
128
  }
105
129
 
130
+ async createThread(parentChannelId: string, name: string): Promise<string> {
131
+ const parent = await this.#text(parentChannelId);
132
+ const thread = await parent.threads.create({
133
+ name: name.slice(0, 100),
134
+ // The longest a thread stays open untouched, so a quiet helper's place is not archived under it.
135
+ autoArchiveDuration: ThreadAutoArchiveDuration.OneWeek,
136
+ });
137
+ this.#parents.set(thread.id, parentChannelId);
138
+ return thread.id;
139
+ }
140
+
141
+ async closeThread(threadId: string): Promise<void> {
142
+ const thread = await this.#thread(threadId);
143
+ // Locking needs Manage Threads; without it the thread is only archived.
144
+ try {
145
+ await thread.edit({ archived: true, locked: true });
146
+ } catch {
147
+ await thread.setArchived(true);
148
+ }
149
+ }
150
+
151
+ async openThread(threadId: string): Promise<void> {
152
+ const thread = await this.#thread(threadId);
153
+ try {
154
+ await thread.edit({ archived: false, locked: false });
155
+ } catch {
156
+ await thread.setArchived(false);
157
+ }
158
+ }
159
+
106
160
  async createChannel(
107
161
  name: string,
108
162
  topic: string,
@@ -239,6 +293,13 @@ export class DiscordAgentChannels implements AgentChannels {
239
293
  );
240
294
  }
241
295
 
296
+ async #thread(threadId: string) {
297
+ const channel = await this.#client.channels.fetch(threadId);
298
+ if (!channel?.isThread())
299
+ throw new AgentError(`channel ${threadId} is not a thread`);
300
+ return channel;
301
+ }
302
+
242
303
  async #text(channelId: string): Promise<TextChannel> {
243
304
  const channel = await this.#client.channels.fetch(channelId);
244
305
  if (channel?.type !== ChannelType.GuildText)