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.
Files changed (132) hide show
  1. package/CHANGELOG.md +61 -1
  2. package/README.md +36 -10
  3. package/README.zh-TW.md +51 -13
  4. package/docs/migrating-0.8.md +2 -2
  5. package/docs/plugins.md +350 -28
  6. package/examples/discord-adapter.ts +9 -1
  7. package/examples/seeds.test.ts +2 -2
  8. package/examples/seeds.ts +1 -2
  9. package/package.json +7 -3
  10. package/src/characters/catalog.ts +1055 -0
  11. package/src/characters/characters.css +68 -0
  12. package/src/characters/colors.ts +108 -0
  13. package/src/characters/compose.ts +281 -0
  14. package/src/characters/css.ts +63 -0
  15. package/src/characters/index.ts +33 -0
  16. package/src/characters/parse.ts +196 -0
  17. package/src/characters/parts-face.ts +354 -0
  18. package/src/characters/parts-hair.ts +56 -0
  19. package/src/characters/parts-hats.ts +57 -0
  20. package/src/characters/parts-outfits.ts +81 -0
  21. package/src/characters/parts-props.ts +137 -0
  22. package/src/characters/spec.ts +224 -0
  23. package/src/characters/suggest.ts +101 -0
  24. package/src/cli/checks/characters.ts +23 -0
  25. package/src/cli/checks/images.ts +5 -3
  26. package/src/cli/doctor.ts +2 -0
  27. package/src/core/agents/agent-claim.ts +30 -11
  28. package/src/core/agents/agent-dashboard.ts +1 -1
  29. package/src/core/agents/agent-messages.ts +43 -15
  30. package/src/core/agents/agent-prompt.ts +2 -4
  31. package/src/core/agents/agent-rules.ts +42 -3
  32. package/src/core/agents/agent-schema.ts +37 -15
  33. package/src/core/agents/agent-store.ts +57 -13
  34. package/src/core/agents/agent-team-fixture.ts +90 -14
  35. package/src/core/agents/agent-team.ts +83 -50
  36. package/src/core/agents/agent-tools.ts +94 -41
  37. package/src/core/agents/avatar-studio.ts +40 -4
  38. package/src/core/agents/character-png.ts +40 -0
  39. package/src/core/agents/group-messages.ts +7 -4
  40. package/src/core/agents/group-turns.ts +27 -14
  41. package/src/core/agents/team-editing.ts +99 -58
  42. package/src/core/agents/team-keys.ts +177 -11
  43. package/src/core/agents/team-lifecycle.ts +151 -84
  44. package/src/core/agents/team-options.ts +13 -6
  45. package/src/core/agents/team-status.ts +31 -13
  46. package/src/core/agents/team-text.ts +13 -3
  47. package/src/core/agents/team-turn-types.ts +25 -7
  48. package/src/core/agents/team-turns.ts +143 -28
  49. package/src/core/attachments/attachment-fetcher.ts +162 -64
  50. package/src/core/attachments/prompt-block.ts +7 -1
  51. package/src/core/attachments/tool-attachment.ts +20 -1
  52. package/src/core/attachments/turn-attachments.ts +18 -2
  53. package/src/core/builtin/agent-server.ts +103 -40
  54. package/src/core/builtin/discord.ts +4 -17
  55. package/src/core/builtin/modules.ts +6 -5
  56. package/src/core/builtin/threads.ts +37 -0
  57. package/src/core/config/agent-team.ts +43 -0
  58. package/src/core/config/config.ts +50 -59
  59. package/src/core/config/limits.ts +93 -0
  60. package/src/core/config/ops.ts +34 -0
  61. package/src/core/config/schema.ts +26 -0
  62. package/src/core/contract/channels.ts +20 -0
  63. package/src/core/contract/surface.ts +77 -7
  64. package/src/core/define-roundtable.ts +70 -42
  65. package/src/core/discord/agent-commands.ts +108 -86
  66. package/src/core/discord/agent-panel.ts +11 -2
  67. package/src/core/discord/connection.ts +1 -1
  68. package/src/core/discord/discord-surface.ts +10 -1
  69. package/src/core/discord/dispatch-thread-host.ts +1 -1
  70. package/src/core/domain/conversation.ts +24 -0
  71. package/src/core/holds.ts +221 -15
  72. package/src/core/host-registration.ts +2 -1
  73. package/src/core/http/listeners.ts +63 -3
  74. package/src/core/http/websocket.ts +2 -1
  75. package/src/core/i18n/agent-panel.ts +14 -21
  76. package/src/core/interactions/prompts.ts +12 -0
  77. package/src/core/modes/autonomy.ts +23 -0
  78. package/src/core/modes/conversation-modes.ts +213 -0
  79. package/src/core/modes/modes-plugin.ts +24 -0
  80. package/src/core/modules/delegation/delegator.ts +4 -4
  81. package/src/core/modules/host-shell/shell-arithmetic.ts +81 -0
  82. package/src/core/modules/host-shell/shell-lexer-types.ts +53 -0
  83. package/src/core/modules/host-shell/shell-lexer.ts +493 -0
  84. package/src/core/modules/host-shell/shell-paths.ts +85 -4
  85. package/src/core/modules/host-shell/shell-policy.ts +341 -332
  86. package/src/core/modules/host-shell/shell-programs.ts +298 -0
  87. package/src/core/modules/host-shell/shell-quoting.ts +237 -0
  88. package/src/core/modules/host-shell/shell-scripts.ts +114 -0
  89. package/src/core/modules/host-shell/shell-wrappers.ts +104 -0
  90. package/src/core/modules/schedules/precheck-tools.ts +17 -0
  91. package/src/core/modules/schedules/schedules.ts +9 -3
  92. package/src/core/plugin.ts +8 -1
  93. package/src/core/presence/direct-channels.ts +30 -0
  94. package/src/core/presentation/reply-body.ts +25 -0
  95. package/src/core/registry/contributions.ts +31 -7
  96. package/src/core/registry/providers.ts +1 -1
  97. package/src/core/routing/conversation-turns.ts +2 -15
  98. package/src/core/routing/surface-port.ts +5 -1
  99. package/src/core/runtime/extensions/approval-card.ts +1 -5
  100. package/src/core/runtime/extensions/approval-fields.ts +3 -7
  101. package/src/core/runtime/extensions/confirmation-gate-extension.ts +13 -2
  102. package/src/core/runtime/extensions/confirmation-gate.ts +41 -40
  103. package/src/core/runtime/extensions/gate-refusals.ts +23 -0
  104. package/src/core/runtime/extensions/held-calls.ts +16 -1
  105. package/src/core/runtime/extensions/hold-describer.ts +58 -8
  106. package/src/core/runtime/extensions/hold-lookups.ts +19 -19
  107. package/src/core/runtime/extensions/yolo.ts +105 -0
  108. package/src/core/runtime/pi-agent-runtime.ts +3 -5
  109. package/src/core/runtime/runtime-plugin.ts +42 -1
  110. package/src/core/runtime/runtime-types.ts +11 -0
  111. package/src/core/runtime/turn-budget.ts +32 -5
  112. package/src/core/runtime/turn-limits.ts +14 -3
  113. package/src/core/services.ts +61 -9
  114. package/src/core/shared/attachment-reader.ts +6 -0
  115. package/src/core/shared/unix-server.ts +10 -1
  116. package/src/core/testing/modules.ts +12 -8
  117. package/src/core/testing/surface-threads.ts +58 -0
  118. package/src/core/testing/thread-host.ts +17 -5
  119. package/src/core/threads/dispatch-threads.ts +299 -0
  120. package/src/core/threads/host-threads.ts +19 -0
  121. package/src/core/tool-set.snapshot.json +5 -0
  122. package/src/index.ts +27 -1
  123. package/src/kit/characters.ts +5 -0
  124. package/src/kit/index.ts +2 -0
  125. package/src/kit/shell.ts +1 -0
  126. package/src/kit/support.ts +1 -1
  127. package/src/kit/threads.ts +1 -1
  128. package/src/testing.ts +4 -0
  129. package/templates/adapters/web/package.json.tmpl +1 -1
  130. package/templates/agents.ts +1 -1
  131. package/templates/package.json.tmpl +1 -1
  132. package/src/core/discord/dispatch-threads.ts +0 -194
@@ -0,0 +1,93 @@
1
+ import { ConfigError } from "../domain/errors.ts";
2
+ import { type Autonomy, DEFAULT_LONG_RUNNING_MS } from "../modes/autonomy.ts";
3
+ import { integer, list, optional, shape, text } from "./schema.ts";
4
+
5
+ /** The configuration's `turnLimits` and `autonomy`: how long a turn works, and what runs without a person. */
6
+ export interface LimitsConfig {
7
+ /**
8
+ * How long one turn may work before it asks the owner on a card whether to go on, or is
9
+ * stopped when it cannot show a card. Time spent waiting on the owner's cards does not count.
10
+ */
11
+ turnLimits?: {
12
+ /** The budget of a turn, renewed by each message steered into it; 60 to 86400, default 600 (10 min). */
13
+ timeoutSeconds?: number;
14
+ /** The most one turn works in all, however often it is steered; at least `timeoutSeconds`, default three times it. */
15
+ maxSeconds?: number;
16
+ /** How long the card that asks whether to go on waits for an answer, in wall time; 30 to 3600, default 300. */
17
+ cardSeconds?: number;
18
+ };
19
+ /** What a conversation may do without a person, once its owner switches the mode on. */
20
+ autonomy?: {
21
+ /** Yolo mode: the server approves held calls instead of asking, within these limits. */
22
+ yolo?: {
23
+ /**
24
+ * Hold classes yolo approves although it cards them by default, such as `shell.rm`, and the
25
+ * names of rules that report no classes, such as `tool:repo_push`, whose calls it then approves.
26
+ */
27
+ allow?: readonly string[];
28
+ /** Hold classes or rule names yolo never approves, such as `shell.push` or `tool:repo_push`. */
29
+ card?: readonly string[];
30
+ };
31
+ /** Long-running mode: a turn that reaches its time limit goes on by itself, up to this. */
32
+ longRunning?: {
33
+ /** The most one turn works in all before it asks or stops; 600 to 604800, default 14400 (4 h). */
34
+ maxSeconds?: number;
35
+ };
36
+ };
37
+ }
38
+
39
+ /** The two keys' shapes, spread into the configuration's. */
40
+ export const limitsShape = {
41
+ turnLimits: optional(
42
+ shape({
43
+ timeoutSeconds: optional(integer(60, 86_400)),
44
+ maxSeconds: optional(integer(60, 604_800)),
45
+ cardSeconds: optional(integer(30, 3_600)),
46
+ }),
47
+ ),
48
+ autonomy: optional(
49
+ shape({
50
+ yolo: optional(
51
+ shape({ allow: optional(list(text)), card: optional(list(text)) }),
52
+ ),
53
+ longRunning: optional(
54
+ shape({ maxSeconds: optional(integer(600, 604_800)) }),
55
+ ),
56
+ }),
57
+ ),
58
+ };
59
+
60
+ /** The limits with their defaults, in ms; `maxMs` is never below `timeoutMs`. */
61
+ export interface ResolvedLimits {
62
+ turnLimits: { timeoutMs: number; maxMs: number; cardMs: number };
63
+ autonomy: Autonomy;
64
+ }
65
+
66
+ /** The configuration's limits with every default filled, refusing a cap below the timeout. */
67
+ export function limitsOf(config: LimitsConfig): ResolvedLimits {
68
+ const { turnLimits, autonomy } = config;
69
+ const timeoutSeconds = turnLimits?.timeoutSeconds ?? 600;
70
+ const maxSeconds = turnLimits?.maxSeconds ?? 3 * timeoutSeconds;
71
+ if (maxSeconds < timeoutSeconds)
72
+ throw new ConfigError(
73
+ `config turnLimits.maxSeconds: expected at least turnLimits.timeoutSeconds (${timeoutSeconds}), got ${maxSeconds}. Raise maxSeconds, or lower timeoutSeconds.`,
74
+ );
75
+ const longRunning = autonomy?.longRunning?.maxSeconds;
76
+ return {
77
+ turnLimits: {
78
+ timeoutMs: timeoutSeconds * 1_000,
79
+ maxMs: maxSeconds * 1_000,
80
+ cardMs: (turnLimits?.cardSeconds ?? 300) * 1_000,
81
+ },
82
+ autonomy: {
83
+ yolo: {
84
+ allow: autonomy?.yolo?.allow ?? [],
85
+ card: autonomy?.yolo?.card ?? [],
86
+ },
87
+ longRunningMaxMs:
88
+ longRunning === undefined
89
+ ? DEFAULT_LONG_RUNNING_MS
90
+ : longRunning * 1_000,
91
+ },
92
+ };
93
+ }
@@ -0,0 +1,34 @@
1
+ import { ConfigError } from "../domain/errors.ts";
2
+ import type { ChannelKey } from "../sessions.ts";
3
+
4
+ /** Where the errors go: one agent, or one conversation. */
5
+ export type ResolvedOps = { agent: string } | { conversation: ChannelKey };
6
+
7
+ /** Where the errors go: one of an agent or a conversation; an agent only where the agents live. */
8
+ export function opsOf(
9
+ ops: { agent?: string; conversation?: string } | undefined,
10
+ withDiscord: boolean,
11
+ ): ResolvedOps | undefined {
12
+ if (!ops) return undefined;
13
+ const { agent, conversation } = ops;
14
+ if (agent !== undefined && conversation !== undefined)
15
+ throw new ConfigError(
16
+ "config ops: name an agent or a conversation, not both. Keep the one the reports go to.",
17
+ );
18
+ if (agent !== undefined) {
19
+ if (!withDiscord)
20
+ throw new ConfigError(
21
+ 'config ops.agent: the agents live in Discord, which is not configured. Report to a conversation a plugin\'s chat surface serves instead, with ops: { conversation: "<surface>:<id>" }; the web chat takes no error reports.',
22
+ );
23
+ return { agent };
24
+ }
25
+ if (conversation === undefined)
26
+ throw new ConfigError(
27
+ 'config ops: name an agent or a conversation, such as ops: { agent: "infra" } or ops: { conversation: "<surface>:<id>" }.',
28
+ );
29
+ if (!/^[^:]+:.+$/.test(conversation))
30
+ throw new ConfigError(
31
+ `config ops.conversation: expected a conversation key <surface>:<id>, got ${JSON.stringify(conversation)}. Write the surface's prefix, a colon, and the conversation's id.`,
32
+ );
33
+ return { conversation: conversation as ChannelKey };
34
+ }
@@ -1,3 +1,8 @@
1
+ import {
2
+ CharacterError,
3
+ type CharacterInput,
4
+ resolveCharacter,
5
+ } from "../../characters/index.ts";
1
6
  import { ConfigError } from "../domain/errors.ts";
2
7
  import { nearest } from "../registry/contributions.ts";
3
8
 
@@ -129,6 +134,27 @@ export const guarded = <T>(
129
134
  },
130
135
  });
131
136
 
137
+ /** The fields of an agent's look, checked as `resolveCharacter` does; a bad field is named in the error. */
138
+ export const characterInput: Field<CharacterInput> = {
139
+ describe: "an object of character fields, such as { class: 'knight' }",
140
+ check: (value, path) => {
141
+ try {
142
+ // Over a base so that a look without `class` is checked too.
143
+ resolveCharacter(
144
+ value as CharacterInput,
145
+ resolveCharacter({ class: "wizard" }),
146
+ );
147
+ } catch (error) {
148
+ if (error instanceof CharacterError)
149
+ throw new ConfigError(
150
+ `config ${path}.${error.field}: ${error.message}. Fix the character in roundtable.config.ts.`,
151
+ );
152
+ throw error;
153
+ }
154
+ return value as CharacterInput;
155
+ },
156
+ };
157
+
132
158
  /** `false`, or whatever `item` accepts: a feature that is switched off, or configured. */
133
159
  export const orOff = <T>(item: Field<T>): Field<T | false> => ({
134
160
  describe: `false, or ${item.describe}`,
@@ -12,6 +12,26 @@ export interface AttachmentRef {
12
12
  size: number;
13
13
  }
14
14
 
15
+ /**
16
+ * A file on the host's disk that a surface hands the core in place of its bytes, such as an upload
17
+ * the surface stored. The core copies it into the conversation's attachment store as a stream and
18
+ * leaves the original where it is.
19
+ */
20
+ export interface AttachmentFile {
21
+ /** Absolute path of a regular file the surface owns; a relative path, a symlink or another kind of file is refused with `EINVAL`. */
22
+ path: string;
23
+ }
24
+
25
+ /**
26
+ * What a surface hands the core for an attachment it made: the bytes, or, for a file too large to
27
+ * hold in memory, a file on disk or a stream the core stores as it comes. A file or stream is
28
+ * held to the surface's `maxAttachmentBytes`, and never buffered whole.
29
+ */
30
+ export type AttachmentContent =
31
+ | Uint8Array
32
+ | AttachmentFile
33
+ | ReadableStream<Uint8Array>;
34
+
15
35
  /** The facts a surface reports about one incoming message; it never decides who may talk. */
16
36
  export interface InboundMessage {
17
37
  channel: ChannelKey;
@@ -1,11 +1,15 @@
1
- import type { OutboundReply } from "../domain/conversation.ts";
2
- import type { InterimPosts } from "../domain/interim.ts";
1
+ import type { OutboundReply, PostAuthor } from "../domain/conversation.ts";
2
+ import type { InterimMessage, InterimPosts } from "../domain/interim.ts";
3
3
  import type { TurnProgress } from "../domain/progress.ts";
4
4
  import { PluginError } from "../errors.ts";
5
5
  import type { PromptScope, Prompts } from "../interactions/prompts.ts";
6
6
  import type { ChannelKey } from "../sessions.ts";
7
7
  import type { Speaker } from "../speakers.ts";
8
- import type { InboundMessage } from "./channels.ts";
8
+ import type {
9
+ AttachmentContent,
10
+ AttachmentRef,
11
+ InboundMessage,
12
+ } from "./channels.ts";
9
13
 
10
14
  /**
11
15
  * Splits a channel key, `<surface>:<id>`, at its first colon: the surface names the chat network
@@ -33,6 +37,36 @@ export function channelKey(surface: string, id: string): ChannelKey {
33
37
  return `${surface}:${id}`;
34
38
  }
35
39
 
40
+ /**
41
+ * The threads a surface hosts for background dispatches (coding tasks, delegated tasks, messages
42
+ * between agents): a place beside a channel where one dispatch posts its progress and report. A
43
+ * thread's id is the surface's own and unique among its threads; the thread as a conversation
44
+ * channel is `channelKey(surface, id)`.
45
+ */
46
+ export interface SurfaceThreads {
47
+ /**
48
+ * Posts the start line in `parent` and opens a thread from it. `line` gives the start line's
49
+ * text: called with no argument for the first post, and with the thread's `mention` once the
50
+ * thread exists, so a surface that can edit its post shows the link. Resolves to undefined when
51
+ * `parent` cannot host a thread, such as a direct message or a thread; rejects when the surface
52
+ * refuses. `mention` is how the thread is named in text, such as `<#id>` on Discord.
53
+ */
54
+ open(
55
+ parent: ChannelKey,
56
+ name: string,
57
+ line: (mention?: string) => string,
58
+ ): Promise<{ id: string; mention: string } | undefined>;
59
+ /** Posts text in the thread. */
60
+ post(thread: string, text: string): Promise<void>;
61
+ /**
62
+ * Posts one message of at most 2000 characters and resolves to it, so it can be edited in
63
+ * place; without it a thread has no `interim` posts.
64
+ */
65
+ send?(thread: string, text: string): Promise<InterimMessage>;
66
+ /** Archives the thread, and locks it when the surface allows. */
67
+ close(thread: string): Promise<void>;
68
+ }
69
+
36
70
  /**
37
71
  * One chat network the host talks through. The host ships Discord's; a plugin may contribute
38
72
  * another with `surfaces`. A surface serves the channels whose key starts with its `surface`
@@ -43,6 +77,19 @@ export interface ChatSurface {
43
77
  readonly surface: string;
44
78
  /** Explicit opt-in to delivering OutboundReply.files; absent or false refuses turn attachments. */
45
79
  readonly supportsFiles?: boolean;
80
+ /**
81
+ * Serves a room for each agent and group of the agent team, `<surface>:agent.<name>` and
82
+ * `<surface>:group.<name>`: separate conversations from the agents' Discord channels, with
83
+ * their own session, held actions and group log, run by the same team. The team replies in a
84
+ * room through `sendReply` with `OutboundReply.author`. Absent or false = no rooms.
85
+ */
86
+ readonly agentRooms?: boolean;
87
+ /**
88
+ * Hosts dispatch threads: a background dispatch started in one of this surface's channels
89
+ * opens its thread here, and the thread is the conversation `<surface>:<id>`. Absent = the
90
+ * dispatch posts in the channel as before.
91
+ */
92
+ readonly threads?: SurfaceThreads;
46
93
  /**
47
94
  * Short English guidance on how a reply renders here, such as "no Markdown tables", which the
48
95
  * runtime appends to the system prompt of every turn that replies on this surface, after the
@@ -88,9 +135,30 @@ export interface ChatSurface {
88
135
  prompts?(channel: ChannelKey, scope?: PromptScope): Prompts | undefined;
89
136
  /**
90
137
  * Where a running turn posts the text it writes before its final answer: ordinary messages it
91
- * may edit in place. Absent, or undefined, = only the final reply is posted.
138
+ * may edit in place. `as` names who speaks when an agent of the team does, as `author` does in
139
+ * `sendReply`. Absent, or undefined, = only the final reply is posted.
140
+ */
141
+ interim?(channel: ChannelKey, as?: PostAuthor): InterimPosts | undefined;
142
+ /**
143
+ * The content of an attachment this surface made, such as a file the owner uploaded to a room:
144
+ * its bytes, or, for a file too large to hold in memory, `{ path }` of a file on the host or a
145
+ * stream, which the core stores without buffering the whole. A ref of a message on this
146
+ * surface is read here and never fetched over the network; a rejection fails that file. Absent
147
+ * = the refs of its messages are downloaded from their URLs.
148
+ */
149
+ readAttachment?(
150
+ channel: ChannelKey,
151
+ ref: AttachmentRef,
152
+ ): Promise<AttachmentContent>;
153
+ /**
154
+ * The most bytes of one attachment a turn takes from this surface, for a ref, a file, a stream
155
+ * or a download alike; default 25 MiB (`MAX_ATTACHMENT_BYTES`), which Discord keeps. A larger
156
+ * file is refused as `larger than <n> MB`; a smaller value than the default is allowed.
157
+ * An image over 25 MiB is still not shown to the model, and a text or PDF file over 25 MiB is
158
+ * not read by `read_attachment`: both stay in the conversation's store, named in the turn's
159
+ * `## Attachments` block, for tools that take files.
92
160
  */
93
- interim?(channel: ChannelKey): InterimPosts | undefined;
161
+ readonly maxAttachmentBytes?: number;
94
162
  /**
95
163
  * Shows what a running turn writes and which tools it runs, as it goes, such as a live preview
96
164
  * in a web chat. Called between the turn's start and its reply; a rejection is logged and the
@@ -123,8 +191,10 @@ export interface SurfacePort {
123
191
  channel: ChannelKey,
124
192
  scope?: PromptScope | Speaker,
125
193
  ): Prompts | undefined;
126
- /** The channel's interim posts; undefined when its surface has none. */
127
- interim(channel: ChannelKey): InterimPosts | undefined;
194
+ /** The surfaces that serve agent rooms (`agentRooms: true`); read after link. */
195
+ roomSurfaces(): readonly string[];
196
+ /** The channel's interim posts, under `as` when given; undefined when its surface has none. */
197
+ interim(channel: ChannelKey, as?: PostAuthor): InterimPosts | undefined;
128
198
  /**
129
199
  * Shows a running turn's progress; a no-op when no surface serves the channel or its surface
130
200
  * shows none. The host's port always has it; it is optional so a port written before 0.8 still
@@ -2,7 +2,7 @@ import { readFileSync } from "node:fs";
2
2
  import { userInfo } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { ModelRegistry, ModelRuntime } from "@earendil-works/pi-coding-agent";
5
- import { agentServerPlugin } from "./builtin/agent-server.ts";
5
+ import { agentServerPlugin, LOCAL_TEAM_ID } from "./builtin/agent-server.ts";
6
6
  import { discordPlugin } from "./builtin/discord.ts";
7
7
  import { discordAdminPlugin } from "./builtin/discord-admin.ts";
8
8
  import { modulesPlugin, schedulerPlugin } from "./builtin/modules.ts";
@@ -13,6 +13,7 @@ import {
13
13
  precheckPlugin,
14
14
  scheduleStorePlugin,
15
15
  } from "./builtin/stores.ts";
16
+ import { threadsPlugin } from "./builtin/threads.ts";
16
17
  import { workerSessionsPlugin } from "./builtin/worker-sessions.ts";
17
18
  import { warnDeprecations } from "./config/access.ts";
18
19
  import {
@@ -29,6 +30,7 @@ import { identityPlugin } from "./identity/identity-plugin.ts";
29
30
  import { type JudgeModel, piJudgeModel } from "./judging/model-judge.ts";
30
31
  import { createLogger, type LogEntry, type Logger } from "./log.ts";
31
32
  import { formatModelRef, type ModelRef } from "./models.ts";
33
+ import { modesPlugin } from "./modes/modes-plugin.ts";
32
34
  import { ErrorReporter } from "./ops/error-reporter.ts";
33
35
  import type { RoundtablePlugin } from "./plugin.ts";
34
36
  import {
@@ -132,34 +134,17 @@ export function discordOwnerOf(
132
134
  };
133
135
  }
134
136
 
135
- /** The Discord bot and the agent server that lives there, each in its place in the plugin list. */
137
+ /** The Discord bot and its administration, each in its place in the plugin list. */
136
138
  interface DiscordAssembly {
137
139
  discord: RoundtablePlugin;
138
140
  admin: RoundtablePlugin[];
139
- skills: RoundtablePlugin[];
140
- agentServer: RoundtablePlugin;
141
- /** Absent when no agent is configured: a plugin that adds nothing is refused by the registry. */
142
- seeds?: RoundtablePlugin;
143
141
  }
144
142
 
145
143
  function discordAssembly(
146
144
  config: ResolvedConfig,
147
145
  discord: NonNullable<ResolvedConfig["discord"]>,
148
- shared: {
149
- modelRuntime: ModelRuntime;
150
- agentSessions: AgentSessionsSlot;
151
- errorReporter: ErrorReporter | undefined;
152
- },
153
146
  ): DiscordAssembly {
154
- const { name } = config;
155
147
  const owner = discordOwnerOf(config);
156
- const sharedPrompt = config.prompts.shared
157
- ? readPrompt(config.prompts.shared, "prompts.shared")
158
- : readPrompt(join(ASSETS, "prompts", "shared.md"), "prompts.shared");
159
- const guest = config.prompts.guest
160
- ? readPrompt(config.prompts.guest, "prompts.guest")
161
- : readPrompt(join(ASSETS, "prompts", "shared-guest.md"), "prompts.guest");
162
- const { errorReporter } = shared;
163
148
  return {
164
149
  discord: discordPlugin({
165
150
  token: discord.token,
@@ -181,10 +166,43 @@ function discordAssembly(
181
166
  cardCopies: discord.cardCopies,
182
167
  }),
183
168
  admin: discord.admin ? [discordAdminPlugin({ owner })] : [],
169
+ };
170
+ }
171
+
172
+ /**
173
+ * The agent server and what only it uses, each in its place in the plugin list: the skills addon
174
+ * and the seed agents. Its team lives in the Discord guild when there is one, and otherwise in
175
+ * the rooms of the surfaces that host them.
176
+ */
177
+ interface AgentAssembly {
178
+ skills: RoundtablePlugin[];
179
+ agentServer: RoundtablePlugin;
180
+ /** Absent when no agent is configured: a plugin that adds nothing is refused by the registry. */
181
+ seeds?: RoundtablePlugin;
182
+ }
183
+
184
+ function agentAssembly(
185
+ config: ResolvedConfig,
186
+ shared: {
187
+ modelRuntime: ModelRuntime;
188
+ agentSessions: AgentSessionsSlot;
189
+ errorReporter: ErrorReporter | undefined;
190
+ },
191
+ ): AgentAssembly {
192
+ const { name, discord } = config;
193
+ const owner = discordOwnerOf(config);
194
+ const sharedPrompt = config.prompts.shared
195
+ ? readPrompt(config.prompts.shared, "prompts.shared")
196
+ : readPrompt(join(ASSETS, "prompts", "shared.md"), "prompts.shared");
197
+ const guest = config.prompts.guest
198
+ ? readPrompt(config.prompts.guest, "prompts.guest")
199
+ : readPrompt(join(ASSETS, "prompts", "shared-guest.md"), "prompts.guest");
200
+ const { errorReporter } = shared;
201
+ return {
184
202
  skills: config.skills
185
203
  ? [
186
204
  skillsPlugin({
187
- guildId: discord.guild,
205
+ guildId: discord?.guild ?? LOCAL_TEAM_ID,
188
206
  reposDir: config.skills.reposDir ?? join(config.dataDir, "repos"),
189
207
  writtenDir: join(config.dataDir, "skills"),
190
208
  builtinDir: config.skills.builtinDir ?? join(ASSETS, "skills"),
@@ -193,8 +211,11 @@ function discordAssembly(
193
211
  ]
194
212
  : [],
195
213
  agentServer: agentServerPlugin({
196
- guildId: discord.guild,
197
- entryChannelId: discord.entryChannel,
214
+ ...(discord
215
+ ? { guildId: discord.guild, entryChannelId: discord.entryChannel }
216
+ : config.coordinator
217
+ ? { coordinator: config.coordinator }
218
+ : {}),
198
219
  owner,
199
220
  assistant: name,
200
221
  modelRuntime: shared.modelRuntime,
@@ -206,15 +227,15 @@ function discordAssembly(
206
227
  scratchDir: config.scratchDir,
207
228
  shellUser: userInfo().username,
208
229
  prompts: { shared: sharedPrompt, guest },
209
- ...(discord.agentMemory ? { memory: discord.agentMemory } : {}),
210
- ...(discord.agentSpeakers ? { speakers: discord.agentSpeakers } : {}),
211
- ...(discord.attachmentDownload
230
+ ...(discord?.agentMemory ? { memory: discord.agentMemory } : {}),
231
+ ...(discord?.agentSpeakers ? { speakers: discord.agentSpeakers } : {}),
232
+ ...(discord?.attachmentDownload
212
233
  ? { attachmentDownload: discord.attachmentDownload }
213
234
  : {}),
214
- avatarListener: "public",
215
- // resolveConfig refuses Discord without a public address.
216
- avatarUrl: config.http?.publicUrl ?? "",
217
- avatarReference: config.avatar ?? join(ASSETS, "neutral.png"),
235
+ // The pictures are public on the listener only where a public address reaches it.
236
+ ...(config.http ? { avatarListener: "public" } : {}),
237
+ ...(config.http?.publicUrl ? { avatarUrl: config.http.publicUrl } : {}),
238
+ avatarReference: join(ASSETS, "neutral.png"),
218
239
  avatarNeutral: join(ASSETS, "neutral.png"),
219
240
  // An ops agent's reports are the agent server's to deliver.
220
241
  ...(errorReporter && "agent" in errorReporter.destination
@@ -229,9 +250,10 @@ function discordAssembly(
229
250
  * The host's options and the plugin list of a configured bot: the built-in plugins in their
230
251
  * fixed order (each service one provides is read by the ones after it), then the operator's, then the scheduler, so a due schedule fires only once
231
252
  * everything it reaches runs. The memory, Discord administration, and skills addons are left out
232
- * when the configuration switches them off. Without `discord` the Discord plugins, the skills,
233
- * the agent server and its seeds are left out, and the runtime still runs every turn of
234
- * `context.turns`. Configuration mistakes stop here, naming the key and the fix.
253
+ * when the configuration switches them off. Without `discord` the Discord plugins are left out,
254
+ * and the skills, the agent server and its seeds stay only when `agents` are configured: their
255
+ * team then lives in the rooms of the surfaces that host them, and the runtime still runs every
256
+ * turn of `context.turns`. Configuration mistakes stop here, naming the key and the fix.
235
257
  */
236
258
  export async function defineRoundtable(
237
259
  input: RoundtableConfig,
@@ -271,13 +293,12 @@ export async function defineRoundtable(
271
293
  warnDeprecations(logger, config.deprecations);
272
294
  // The agent server hands the runtime its per-agent settings once it sets up.
273
295
  const agentSessions = agentSessionsSlot();
274
- const assembly = discord
275
- ? discordAssembly(config, discord, {
276
- modelRuntime,
277
- agentSessions,
278
- errorReporter,
279
- })
280
- : undefined;
296
+ const assembly = discord ? discordAssembly(config, discord) : undefined;
297
+ // The agent server runs with Discord, and without it whenever agents are configured.
298
+ const agentTeam =
299
+ discord || config.agents.length > 0
300
+ ? agentAssembly(config, { modelRuntime, agentSessions, errorReporter })
301
+ : undefined;
281
302
  return {
282
303
  options: {
283
304
  logger,
@@ -309,6 +330,8 @@ export async function defineRoundtable(
309
330
  ...(config.memory ? [memoryPlugin({ owner })] : []),
310
331
  scheduleStorePlugin(),
311
332
  precheckPlugin(),
333
+ // Before Discord, whose services hand out its instance, and the modules and agent server that use it.
334
+ threadsPlugin({ dataDir: config.dataDir }),
312
335
  ...(assembly ? [assembly.discord] : []),
313
336
  modulesPlugin({
314
337
  owner,
@@ -330,8 +353,9 @@ export async function defineRoundtable(
330
353
  delegation: config.delegation,
331
354
  }),
332
355
  ...(assembly?.admin ?? []),
333
- ...(assembly?.skills ?? []),
356
+ ...(agentTeam?.skills ?? []),
334
357
  conversationsPlugin(),
358
+ modesPlugin(),
335
359
  runtimePlugin({
336
360
  owner,
337
361
  modelRuntime,
@@ -344,11 +368,15 @@ export async function defineRoundtable(
344
368
  agents: agentSessions,
345
369
  interimText: config.interimText,
346
370
  interimPrimaryChars: config.interimPrimaryChars,
371
+ turnTimeoutMs: config.turnLimits.timeoutMs,
372
+ turnMaxMs: config.turnLimits.maxMs,
373
+ turnTimeoutCardMs: config.turnLimits.cardMs,
374
+ autonomy: config.autonomy,
347
375
  memory: config.memory,
348
376
  ...(crowd ? { crowd } : {}),
349
377
  }),
350
- ...(assembly
351
- ? [assembly.agentServer, ...(assembly.seeds ? [assembly.seeds] : [])]
378
+ ...(agentTeam
379
+ ? [agentTeam.agentServer, ...(agentTeam.seeds ? [agentTeam.seeds] : [])]
352
380
  : []),
353
381
  ...config.plugins,
354
382
  schedulerPlugin(),