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
@@ -28,16 +28,33 @@ import {
28
28
  RUNTIME,
29
29
  SCHEDULES,
30
30
  SKILLS,
31
+ THREADS,
31
32
  } from "../services.ts";
32
33
  import type { SessionTool } from "../sessions.ts";
33
34
  import type { Tier } from "../speakers.ts";
34
35
  import { DISCORD } from "./discord.ts";
35
36
  import { agentOnly } from "./session-tool.ts";
36
37
 
38
+ /**
39
+ * The id of the team on a host without Discord, in place of the Discord guild: the rows the team
40
+ * keeps are stored under it, as under the guild's id on a host with Discord.
41
+ */
42
+ export const LOCAL_TEAM_ID = "local";
43
+
37
44
  export interface AgentServerOptions {
38
- guildId: string;
39
- /** The channel the coordinator lives in. */
40
- entryChannelId: string;
45
+ /**
46
+ * The Discord guild the team lives in. Absent on a host without Discord: the team is then
47
+ * `LOCAL_TEAM_ID`, it has no channels, and its agents live in the rooms of the surfaces that
48
+ * host them.
49
+ */
50
+ guildId?: string;
51
+ /** The channel the coordinator lives in; set with `guildId`, never with `coordinator`. */
52
+ entryChannelId?: string;
53
+ /**
54
+ * The coordinator's name on a host without Discord, where no entry channel names it; the first
55
+ * seed when absent. Refused with `entryChannelId`.
56
+ */
57
+ coordinator?: string;
41
58
  owner: OwnerIdentity & { id: string };
42
59
  /** The assistant's display name, as the confirmation judge names it. */
43
60
  assistant: string;
@@ -77,10 +94,13 @@ export interface AgentServerOptions {
77
94
  attachmentDownload?: { deadlineMs?: number; idleMs?: number };
78
95
  /** The prompt every agent starts with, and the one for speakers other than the owner. */
79
96
  prompts: { shared: string; guest?: string };
80
- /** The listener the avatar pictures are served on. */
81
- avatarListener: string;
82
- /** The origin the agents' pictures are served from. */
83
- avatarUrl: string;
97
+ /** The listener the avatar pictures are served on; none mounts no route, and a surface serves them through `AGENTS.avatars.serve`. */
98
+ avatarListener?: string;
99
+ /**
100
+ * The origin the agents' pictures are served from. Without one a picture's URL is
101
+ * `/avatars/<hash>.png`, which the surface that serves the pictures resolves against its origin.
102
+ */
103
+ avatarUrl?: string;
84
104
  /** The assistant's neutral avatar: the style reference and the picture of an agent without one. */
85
105
  avatarReference: string;
86
106
  /** The picture used instead when `avatarReference` cannot be read as one; the failure is logged. */
@@ -127,41 +147,62 @@ export interface AgentServerStores {
127
147
  /** Attaches the agent server's stores over the host's migrated pool. */
128
148
  async function attachStores(
129
149
  sql: SQL,
130
- guildId: string,
150
+ teamId: string,
131
151
  ): Promise<AgentServerStores> {
132
- return { agents: await PgAgentStore.attach(sql, guildId) };
152
+ return { agents: await PgAgentStore.attach(sql, teamId) };
133
153
  }
134
154
 
135
155
  /**
136
156
  * The agent server: the team with its channels and dashboard, run on the runtime plugin's runtime,
137
157
  * which it hands its per-agent settings. The skills the agents carry come from the skills addon
138
158
  * when it is on. It claims the agent channels, starts the team in the background, and hands what
139
- * it built to the plugins after it.
159
+ * it built to the plugins after it. On a host without Discord the team has no channels or
160
+ * dashboard: its agents live in the rooms of the surfaces that host them, and the host refuses to
161
+ * start when no surface does.
140
162
  */
141
163
  export function agentServerPlugin(
142
164
  options: AgentServerOptions,
143
165
  /** Opens the stores; a test hands stand-ins, so the plugin can be set up without a database. */
144
166
  openStores: (
145
167
  context: Pick<PluginContext, "database">,
146
- guildId: string,
147
- ) => Promise<AgentServerStores> = (context, guildId) =>
148
- attachStores(context.database(), guildId),
168
+ teamId: string,
169
+ ) => Promise<AgentServerStores> = (context, teamId) =>
170
+ attachStores(context.database(), teamId),
149
171
  ): RoundtablePlugin {
172
+ if (options.entryChannelId !== undefined && options.coordinator !== undefined)
173
+ throw new ConfigError(
174
+ "the agent server: entryChannelId and coordinator both name the coordinator. Set the entry channel with Discord, the coordinator without it.",
175
+ );
176
+ const teamId = options.guildId ?? LOCAL_TEAM_ID;
177
+ /** Set once the plugin has set up; the preflight reads it after every plugin linked. */
178
+ let hostsRooms: (() => boolean) | undefined;
150
179
  return {
151
180
  name: AGENT_SERVER_PLUGIN,
152
181
  // The agent server's own tables; the host runs them before any setup, in this order. The
153
182
  // held actions' table is the runtime plugin's.
154
- migrations: PgAgentStore.migrations(options.guildId),
183
+ migrations: PgAgentStore.migrations(teamId),
155
184
  provides: [AGENTS],
156
185
  requires: [RUNTIME],
186
+ // A team with no Discord channels is reached through rooms only; without a surface that
187
+ // hosts them its agents could never be spoken to.
188
+ preflight: () => {
189
+ if (hostsRooms && !hostsRooms())
190
+ throw new ConfigError(
191
+ "the agents need a surface that hosts their rooms, such as pi-roundtable-web, and none is installed. Without Discord channels there is nowhere to talk to them; add a plugin whose chat surface declares agentRooms: true, or configure discord.",
192
+ );
193
+ },
157
194
  setup: async (context) => {
158
- const discord = context.services.get(DISCORD);
195
+ // Absent on a host without Discord: then the team has no channels or dashboard.
196
+ const discord = context.services.find(DISCORD);
197
+ // Dispatch threads on any surface that hosts them, Discord's or a room surface's.
198
+ const threads = context.services.find(THREADS);
159
199
  const schedules = context.services.get(SCHEDULES);
160
- const { connection } = discord;
200
+ const connection = discord?.connection;
161
201
  const { logger, providers, surfaces } = context;
202
+ if (!connection) hostsRooms = () => surfaces.roomSurfaces().length > 0;
162
203
  const studio = new FileAvatarStudio({
163
204
  dir: join(options.dataDir, "avatars"),
164
- publicUrl: options.avatarUrl,
205
+ ...(options.avatarUrl ? { publicUrl: options.avatarUrl } : {}),
165
206
  referencePath: options.avatarReference,
166
207
  ...(options.avatarNeutral
167
208
  ? {
@@ -179,7 +220,7 @@ export function agentServerPlugin(
179
220
  await studio.init();
180
221
  // Absent when the skills addon is off: the agents then carry none.
181
222
  const skills = context.services.find(SKILLS);
182
- const { agents: store } = await openStores(context, options.guildId);
223
+ const { agents: store } = await openStores(context, teamId);
183
224
  const running = context.services.get(RUNTIME);
184
225
  const judge = providers.judge;
185
226
  const confirmations = new ConfirmationJudge({
@@ -189,8 +230,17 @@ export function agentServerPlugin(
189
230
  logger,
190
231
  });
191
232
  const built: DiscordAgentTeam = new DiscordAgentTeam({
192
- guildId: options.guildId,
193
- entryChannelId: options.entryChannelId,
233
+ guildId: teamId,
234
+ ...(options.entryChannelId !== undefined
235
+ ? { entryChannelId: options.entryChannelId }
236
+ : {}),
237
+ // Without an entry channel the coordinator is the named agent, else the first seed.
238
+ ...(options.entryChannelId === undefined
239
+ ? {
240
+ coordinator: () =>
241
+ options.coordinator ?? context.sessions().seeds[0]?.name,
242
+ }
243
+ : {}),
194
244
  seeds: () => context.sessions().seeds,
195
245
  promptSections: () => context.sessions().prompt,
196
246
  pluginTools: () => context.sessions().agentTools,
@@ -201,7 +251,9 @@ export function agentServerPlugin(
201
251
  shellUser: options.shellUser,
202
252
  ...(options.scratchDir ? { scratchDir: options.scratchDir } : {}),
203
253
  store,
204
- channels: connection.agentChannels(options.guildId),
254
+ ...(connection && options.guildId
255
+ ? { channels: connection.agentChannels(options.guildId) }
256
+ : {}),
205
257
  studio,
206
258
  runtime: () => running,
207
259
  confirmations,
@@ -223,6 +275,7 @@ export function agentServerPlugin(
223
275
  },
224
276
  schedules,
225
277
  queue: context.queue,
278
+ surfaces,
226
279
  startTyping: (channel) => surfaces.startTyping(channel),
227
280
  showStop: (channel) => surfaces.showStop(channel),
228
281
  workDir: options.workDir,
@@ -231,10 +284,10 @@ export function agentServerPlugin(
231
284
  ? { guestPrompt: options.prompts.guest }
232
285
  : {}),
233
286
  ...(skills ? { skills } : {}),
234
- threads: discord.threads,
287
+ ...(threads ? { threads } : {}),
235
288
  logger,
236
289
  });
237
- connection.onChannelDeleted((channelId) => {
290
+ connection?.onChannelDeleted((channelId) => {
238
291
  if (!built.owns(discordKey(channelId))) return;
239
292
  void context.queue
240
293
  .run(discordKey(channelId), () => built.channelDeleted(channelId))
@@ -263,21 +316,23 @@ export function agentServerPlugin(
263
316
  });
264
317
  const attachmentDir = (channel: ChannelKey) =>
265
318
  ownerAttachmentDir(options.dataDir, channel);
266
- const dashboard = new AgentDashboard({
267
- status: () => built.status(),
268
- board: connection.agentDashboard(options.guildId),
269
- lines: () => context.dashboard(),
270
- logger,
271
- });
272
- built.onChange(() => dashboard.changed());
319
+ // The dashboard is a Discord message; a team without Discord has none.
320
+ const dashboard =
321
+ connection && options.guildId
322
+ ? new AgentDashboard({
323
+ status: () => built.status(),
324
+ board: connection.agentDashboard(options.guildId),
325
+ lines: () => context.dashboard(),
326
+ logger,
327
+ })
328
+ : undefined;
329
+ if (dashboard) built.onChange(() => dashboard.changed());
273
330
  const connectErrorReporter = () =>
274
331
  options.errorReporter?.connect({
275
- channelOf: (name) => {
276
- const agent = store.agent(name);
277
- return agent?.status === "active" && agent.channelId
278
- ? discordKey(agent.channelId)
279
- : undefined;
280
- },
332
+ channelOf: (name) =>
333
+ store.agent(name)?.status === "active"
334
+ ? built.roomsOf(name)[0]
335
+ : undefined,
281
336
  post: (channel, text) =>
282
337
  surfaces.sendReply(channel, { chunks: splitReply(text) }),
283
338
  turn: (channel, text) =>
@@ -288,7 +343,9 @@ export function agentServerPlugin(
288
343
  });
289
344
  return {
290
345
  services: [
291
- { name: "dashboard", stop: () => dashboard.stop() },
346
+ ...(dashboard
347
+ ? [{ name: "dashboard", stop: () => dashboard.stop() }]
348
+ : []),
292
349
  {
293
350
  name: AGENT_TEAM_SERVICE,
294
351
  // The dashboard shows the team, so it starts once the team has; the error
@@ -296,7 +353,7 @@ export function agentServerPlugin(
296
353
  startInBackground: async () => {
297
354
  try {
298
355
  await built.start();
299
- dashboard.start();
356
+ dashboard?.start();
300
357
  } finally {
301
358
  connectErrorReporter();
302
359
  }
@@ -315,10 +372,16 @@ export function agentServerPlugin(
315
372
  ? { attachmentDownload: options.attachmentDownload }
316
373
  : {}),
317
374
  logger,
318
- channelContext: (message) => discord.channelContext(message),
375
+ ...(discord
376
+ ? { channelContext: (message) => discord.channelContext(message) }
377
+ : {}),
319
378
  }),
320
379
  ],
321
- http: [studio.route(options.avatarListener)],
380
+ // The pictures are public on a listener only where a public address reaches it.
381
+ http:
382
+ options.avatarListener && options.avatarUrl
383
+ ? [studio.route(options.avatarListener)]
384
+ : [],
322
385
  sessionTools: agentSessionTools(built),
323
386
  toolTiers: AGENT_TIERS,
324
387
  };
@@ -11,7 +11,6 @@ import type { DiscordConnection } from "../discord/connection.ts";
11
11
  import { discordDirectChannel } from "../discord/direct-channel.ts";
12
12
  import { DiscordOwners } from "../discord/discord-owners.ts";
13
13
  import { DiscordSurface } from "../discord/discord-surface.ts";
14
- import { DispatchThreads } from "../discord/dispatch-threads.ts";
15
14
  import type {
16
15
  CommandGuard,
17
16
  CommandRegistrar,
@@ -23,7 +22,8 @@ import { restOf, restPoster } from "../discord/poster/rest-poster.ts";
23
22
  import { stopButtonModule } from "../discord/stop-button.ts";
24
23
  import type { InboundMessage } from "../domain/conversation.ts";
25
24
  import type { RoundtablePlugin } from "../plugin.ts";
26
- import { IDENTITY } from "../services.ts";
25
+ import { IDENTITY, THREADS } from "../services.ts";
26
+ import type { DispatchThreads } from "../threads/dispatch-threads.ts";
27
27
 
28
28
  /** The Discord connection and what stands on it, as plugins use it. Provided by the Discord plugin. */
29
29
  export interface DiscordServices {
@@ -161,19 +161,8 @@ export function discordPlugin(options: DiscordOptions): RoundtablePlugin {
161
161
  ? {}
162
162
  : { refusalHint: options.refusalHint }),
163
163
  });
164
- // A channel whose claim keeps reports in place, such as an open channel, opens no thread.
165
- const threads = new DispatchThreads({
166
- host: {
167
- open: (parentId, name, line) =>
168
- connected.threadHost().open(parentId, name, line),
169
- post: (threadId, text) => connected.threadHost().post(threadId, text),
170
- send: (threadId, text) => connected.threadHost().send(threadId, text),
171
- close: (threadId) => connected.threadHost().close(threadId),
172
- },
173
- ledgerPath: join(options.dataDir, "dispatch-threads.json"),
174
- excluded: (channel) => conversations.postsInPlace(channel),
175
- logger,
176
- });
164
+ // The host's dispatch threads, which reach Discord's through its surface.
165
+ const threads = services.get(THREADS);
177
166
  // The cards answer the interactions of held actions, and core posts the stop button, so
178
167
  // core answers both; they come first, as they always have.
179
168
  collection.registrar.add({ module: cards });
@@ -215,8 +204,6 @@ export function discordPlugin(options: DiscordOptions): RoundtablePlugin {
215
204
  }),
216
205
  ],
217
206
  services: [
218
- // The sweep reads threads through the connection, so it follows the surface's start.
219
- { name: "threads", start: () => void threads.sweep() },
220
207
  // The copy texts of the last cards are on disk before the host exits.
221
208
  {
222
209
  name: "card-copies",
@@ -39,6 +39,7 @@ import {
39
39
  IDENTITY,
40
40
  PRECHECKS,
41
41
  SCHEDULES,
42
+ THREADS,
42
43
  } from "../services.ts";
43
44
  import type { SessionContext } from "../sessions.ts";
44
45
  import { DELEGATE_TOOL } from "../shared/delegate-tool.ts";
@@ -119,11 +120,11 @@ export function modulesPlugin(options: ModulesOptions): RoundtablePlugin {
119
120
  const schedules = services.get(SCHEDULES);
120
121
  // Absent when a plugin list leaves the prechecks plugin out: then none can be attached.
121
122
  const prechecks = services.find(PRECHECKS);
122
- // Absent on a host without Discord: then there are no threads.
123
- const discord = services.find(DISCORD);
123
+ // Absent when a plugin list leaves the threads plugin out: then no dispatch opens a thread.
124
+ const threads = services.find(THREADS);
124
125
  // Another agent's channel, for schedule_list; asked when a tool runs, after the agent server set up.
125
- const agentChannelOf = (agent: string) =>
126
- services.get(AGENTS).team.channelOf(agent);
126
+ const agentChannelOf = (agent: string, near: ChannelKey) =>
127
+ services.get(AGENTS).team.channelOf(agent, near);
127
128
  // A conversation no chat surface carries keeps its runs in the creator's direct messages, where
128
129
  // a claim must take them; anyone's elsewhere would run in a conversation that is not theirs.
129
130
  const channelFor = async (
@@ -166,7 +167,7 @@ export function modulesPlugin(options: ModulesOptions): RoundtablePlugin {
166
167
  }),
167
168
  targets: (name) => conversations.target(name),
168
169
  deliver: (job, outcome) => background.runDelegated(job, outcome),
169
- ...(discord ? { threads: discord.threads } : {}),
170
+ ...(threads ? { threads } : {}),
170
171
  logger,
171
172
  });
172
173
  services.provide(BACKGROUND_TURNS, background);
@@ -0,0 +1,37 @@
1
+ import { join } from "node:path";
2
+ import type { RoundtablePlugin } from "../plugin.ts";
3
+ import { THREADS } from "../services.ts";
4
+ import { DispatchThreads } from "../threads/dispatch-threads.ts";
5
+
6
+ export interface ThreadsOptions {
7
+ /** Where the ledger of the threads still open is kept, as `dispatch-threads.json`. */
8
+ dataDir: string;
9
+ }
10
+
11
+ /**
12
+ * The dispatch threads of every surface that hosts them. The plugin provides one instance, over
13
+ * the host's surfaces; the Discord plugin hands the same one out as `DISCORD.threads`. After a
14
+ * restart it closes the threads the last run left open, once every service has started, so each
15
+ * surface is up to close its own.
16
+ */
17
+ export function threadsPlugin(options: ThreadsOptions): RoundtablePlugin {
18
+ return {
19
+ name: "threads",
20
+ provides: [THREADS],
21
+ setup: ({ conversations, services, surfaces, logger }) => {
22
+ const threads = new DispatchThreads({
23
+ surfaces,
24
+ ledgerPath: join(options.dataDir, "dispatch-threads.json"),
25
+ // A channel whose claim keeps reports in place, such as an open channel, opens no thread.
26
+ excluded: (channel) => conversations.postsInPlace(channel),
27
+ logger,
28
+ });
29
+ services.provide(THREADS, threads);
30
+ return {
31
+ services: [
32
+ { name: "threads", startInBackground: () => threads.sweep() },
33
+ ],
34
+ };
35
+ },
36
+ };
37
+ }
@@ -0,0 +1,43 @@
1
+ import { ConfigError } from "../domain/errors.ts";
2
+
3
+ /** The keys of the configuration that shape the agent team. */
4
+ interface AgentTeamKeys {
5
+ agents?: readonly { name: string; channelId?: string }[];
6
+ coordinator?: string;
7
+ skills?: unknown;
8
+ }
9
+
10
+ /**
11
+ * Refuses what the agent team needs and a host without Discord lacks, naming the key; and the
12
+ * coordinator, which only a host without Discord names: with Discord it is the entry channel's agent.
13
+ */
14
+ export function checkAgentTeamKeys(
15
+ config: AgentTeamKeys,
16
+ withDiscord: boolean,
17
+ ): void {
18
+ const seeds = config.agents ?? [];
19
+ if (config.coordinator !== undefined) {
20
+ if (withDiscord)
21
+ throw new ConfigError(
22
+ "config coordinator: the entry channel's agent is the coordinator with discord. Remove coordinator, or move the agent to discord.entryChannel.",
23
+ );
24
+ if (seeds.length === 0)
25
+ throw new ConfigError(
26
+ "config coordinator: needs agents, and names one of them. Add the agent to agents, or remove coordinator.",
27
+ );
28
+ if (!seeds.some((seed) => seed.name === config.coordinator))
29
+ throw new ConfigError(
30
+ `config coordinator: ${JSON.stringify(config.coordinator)} is not one of agents. Name one of ${seeds.map((seed) => seed.name).join(", ")}.`,
31
+ );
32
+ }
33
+ if (withDiscord) return;
34
+ const placed = seeds.findIndex((seed) => seed.channelId !== undefined);
35
+ if (placed >= 0)
36
+ throw new ConfigError(
37
+ `config agents[${placed}].channelId: a Discord channel, and Discord is not configured. Remove channelId, or configure discord.`,
38
+ );
39
+ if (config.skills && seeds.length === 0)
40
+ throw new ConfigError(
41
+ "config skills: the skills are the agents', and this host has none: without discord they need agents. Configure agents or discord, or remove skills.",
42
+ );
43
+ }
@@ -17,7 +17,6 @@ import {
17
17
  import type { PerPrincipalLimits } from "../modules/background/personal-target.ts";
18
18
  import type { RoundtablePlugin } from "../plugin.ts";
19
19
  import { PRIMARY_CHARS } from "../runtime/interim-text.ts";
20
- import type { ChannelKey } from "../sessions.ts";
21
20
  import type { Tier, TierMembers } from "../speakers.ts";
22
21
  import { type AccessConfig, accessOf, accessShape } from "./access.ts";
23
22
  import {
@@ -27,9 +26,18 @@ import {
27
26
  type ResolvedDiscord,
28
27
  resolvedDiscord,
29
28
  } from "./adapters.ts";
29
+ import { checkAgentTeamKeys } from "./agent-team.ts";
30
30
  import { type AttachmentsConfig, attachmentsShape } from "./attachments.ts";
31
+ import {
32
+ type LimitsConfig,
33
+ limitsOf,
34
+ limitsShape,
35
+ type ResolvedLimits,
36
+ } from "./limits.ts";
37
+ import { opsOf, type ResolvedOps } from "./ops.ts";
31
38
  import {
32
39
  bool,
40
+ characterInput,
33
41
  guarded,
34
42
  integer,
35
43
  list,
@@ -74,7 +82,7 @@ export interface BackgroundConfig {
74
82
  }
75
83
 
76
84
  /** What `roundtable.config.ts` gives `defineRoundtable`. */
77
- export interface RoundtableConfig {
85
+ export interface RoundtableConfig extends LimitsConfig {
78
86
  /** The assistant's display name; default "Roundtable". */
79
87
  name?: string;
80
88
  /**
@@ -85,9 +93,11 @@ export interface RoundtableConfig {
85
93
  owner?: { id: string; name: string; pronouns?: Pronouns };
86
94
  /**
87
95
  * The Discord bot, its guild, and the agent server that lives there. Leave it out for a host
88
- * without Discord: no Discord plugins, no agent server or agents, no skills, and no tool that
89
- * messages the owner on Discord; conversations come through the plugins' own surfaces. The same
90
- * settings may come as `adapters: [discord({...})]` instead, never both.
96
+ * without Discord: no Discord plugins and no tool that messages the owner on Discord;
97
+ * conversations come through the plugins' own surfaces. The agent team (`agents`, `skills`) then
98
+ * lives in the rooms of a surface that hosts them, such as pi-roundtable-web, and the host
99
+ * refuses to start without one. The same settings may come as `adapters: [discord({...})]`
100
+ * instead, never both.
91
101
  */
92
102
  discord?: DiscordConfig;
93
103
  /**
@@ -133,8 +143,17 @@ export interface RoundtableConfig {
133
143
  toolTiers?: Record<string, Tier>;
134
144
  /** Prompt files, read once at start; `shared` starts every agent's prompt. */
135
145
  prompts?: { shared: string; guest?: string };
136
- /** The first team, created once; an agent already stored is never overwritten. Needs `discord`. */
146
+ /**
147
+ * The first team, created once; an agent already stored is never overwritten. Without `discord`
148
+ * the team lives in the rooms of a surface that hosts them, so a seed has no `channelId`.
149
+ */
137
150
  agents?: AgentSeed[];
151
+ /**
152
+ * Without `discord`, the name of the agent that coordinates the team: the one the owner's
153
+ * general requests come to, which cannot be archived. It must be one of `agents`; default the
154
+ * first. With `discord` the agent in `discord.entryChannel` is the coordinator, and this key is refused.
155
+ */
156
+ coordinator?: string;
138
157
  /**
139
158
  * The `public` listener: the public address that reaches it, and the TCP port (default 3000) or
140
159
  * unix socket the process listens on. `socketMode` is the socket file's permission bits
@@ -149,11 +168,7 @@ export interface RoundtableConfig {
149
168
  socketPath?: string;
150
169
  socketMode?: number;
151
170
  };
152
- /**
153
- * A picture of your own for agents without one, and the style reference for drawing them; default
154
- * a plain bot. A PNG, JPEG or GIF of any side within the 64 MP decoded-pixel budget (an ordinary
155
- * camera photo); one that cannot be read as a picture is logged and the plain bot is used.
156
- */
171
+ /** @deprecated Agents are composed characters since 0.23, so this picture is ignored and a warning is logged at start; removed in 1.0. */
157
172
  avatar?: string;
158
173
  /** The agents' shared working directory; default `<dataDir>/work`. */
159
174
  workDir?: string;
@@ -166,7 +181,7 @@ export interface RoundtableConfig {
166
181
  /**
167
182
  * Where the skill registry reads built-in skills and keeps linked repositories. `false` leaves
168
183
  * the `skills` addon out: agents carry no skills, and no skill tools exist. Stored skills stay
169
- * in their tables. Without `discord` there are no agents, so the addon is off.
184
+ * in their tables. Without `discord` and `agents` there is no one to carry skills, so the addon is off.
170
185
  */
171
186
  skills?: false | SkillsConfig;
172
187
  /**
@@ -267,11 +282,13 @@ const schema = shape({
267
282
  name: text,
268
283
  displayName: text,
269
284
  prompt: text,
270
- avatarPrompt: text,
285
+ character: optional(characterInput),
286
+ avatarPrompt: optional(text),
271
287
  channelId: optional(text),
272
288
  }),
273
289
  ),
274
290
  ),
291
+ coordinator: optional(text),
275
292
  http: optional(
276
293
  shape({
277
294
  publicUrl: optional(text),
@@ -288,6 +305,7 @@ const schema = shape({
288
305
  memory: optional(bool),
289
306
  ops: optional(shape({ agent: optional(text), conversation: optional(text) })),
290
307
  drainSeconds: optional(integer(1, 3_600)),
308
+ ...limitsShape,
291
309
  interimText: optional(oneOf<InterimTextMode>("on", "off")),
292
310
  interimPrimaryChars: optional(integer(1, 100_000)),
293
311
  plugins: optional(
@@ -297,8 +315,12 @@ const schema = shape({
297
315
  ),
298
316
  });
299
317
 
318
+ /** Logged once when `avatar` is set: agents are composed characters and no picture is drawn from it. */
319
+ export const AVATAR_DEPRECATION =
320
+ "agents are composed characters since 0.23; `avatar` is ignored";
321
+
300
322
  /** The configuration with every default filled in. */
301
- export interface ResolvedConfig {
323
+ export interface ResolvedConfig extends ResolvedLimits {
302
324
  name: string;
303
325
  /**
304
326
  * The process's short name: the logger's and the scratch dir's, and the root slash command's
@@ -337,6 +359,8 @@ export interface ResolvedConfig {
337
359
  toolTiers: Record<string, Tier>;
338
360
  prompts: { shared?: string; guest?: string };
339
361
  agents: AgentSeed[];
362
+ /** The coordinator's name; set only without `discord` and with `agents`: the configured one, else the first seed. */
363
+ coordinator?: string;
340
364
  /** Undefined when no `http` is configured, and the host opens no `public` listener. */
341
365
  http?: {
342
366
  /** Always set with Discord. */
@@ -352,7 +376,7 @@ export interface ResolvedConfig {
352
376
  /** `false` when the skills addon is off. */
353
377
  skills: false | SkillsConfig;
354
378
  memory: boolean;
355
- ops?: { agent: string } | { conversation: ChannelKey };
379
+ ops?: ResolvedOps;
356
380
  drainMs: number;
357
381
  interimText: InterimTextMode;
358
382
  interimPrimaryChars: number;
@@ -368,47 +392,6 @@ function modelOf(value: string, path: string): ModelRef {
368
392
  return parsed;
369
393
  }
370
394
 
371
- /** Where the errors go: one of an agent or a conversation; an agent only where the agents live. */
372
- function opsOf(
373
- ops: { agent?: string; conversation?: string } | undefined,
374
- withDiscord: boolean,
375
- ): ResolvedConfig["ops"] {
376
- if (!ops) return undefined;
377
- const { agent, conversation } = ops;
378
- if (agent !== undefined && conversation !== undefined)
379
- throw new ConfigError(
380
- "config ops: name an agent or a conversation, not both. Keep the one the reports go to.",
381
- );
382
- if (agent !== undefined) {
383
- if (!withDiscord)
384
- throw new ConfigError(
385
- '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.',
386
- );
387
- return { agent };
388
- }
389
- if (conversation === undefined)
390
- throw new ConfigError(
391
- 'config ops: name an agent or a conversation, such as ops: { agent: "infra" } or ops: { conversation: "<surface>:<id>" }.',
392
- );
393
- if (!/^[^:]+:.+$/.test(conversation))
394
- throw new ConfigError(
395
- `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.`,
396
- );
397
- return { conversation: conversation as ChannelKey };
398
- }
399
-
400
- /** Refuses what only the agent server serves on a host without Discord, naming the key. */
401
- function refuseAgentServerKeys(config: RoundtableConfig): void {
402
- if (config.agents && config.agents.length > 0)
403
- throw new ConfigError(
404
- "config agents: the agents live in Discord, which is not configured. Configure discord, or remove agents.",
405
- );
406
- if (config.skills)
407
- throw new ConfigError(
408
- "config skills: the skills are the agents', and the agents live in Discord, which is not configured. Configure discord, or remove skills.",
409
- );
410
- }
411
-
412
395
  /** The configuration checked against its schema, defaults filled; throws a ConfigError naming the key and the fix. */
413
396
  export function resolveConfig(input: unknown): ResolvedConfig {
414
397
  const config = schema.check(liftAdapters(input), "") as RoundtableConfig;
@@ -420,7 +403,7 @@ export function resolveConfig(input: unknown): ResolvedConfig {
420
403
  throw new ConfigError(
421
404
  "config http.publicUrl: required with discord, expected the public address that reaches the bot; Discord fetches the agents' avatars from it. Add it to roundtable.config.ts.",
422
405
  );
423
- if (!discord) refuseAgentServerKeys(config);
406
+ checkAgentTeamKeys(config, discord !== undefined);
424
407
  const slug =
425
408
  discord?.rootCommand ?? name.toLowerCase().replace(/[^a-z0-9-]+/g, "-");
426
409
  const ops = opsOf(config.ops, discord !== undefined);
@@ -430,6 +413,10 @@ export function resolveConfig(input: unknown): ResolvedConfig {
430
413
  name,
431
414
  slug,
432
415
  ...access,
416
+ deprecations: [
417
+ ...access.deprecations,
418
+ ...(config.avatar ? [AVATAR_DEPRECATION] : []),
419
+ ],
433
420
  owner: {
434
421
  id: primaryOwner.id,
435
422
  name: primaryOwner.name,
@@ -467,6 +454,9 @@ export function resolveConfig(input: unknown): ResolvedConfig {
467
454
  toolTiers: config.toolTiers ?? {},
468
455
  prompts: config.prompts ?? {},
469
456
  agents: config.agents ?? [],
457
+ ...(!discord && config.agents?.[0]
458
+ ? { coordinator: config.coordinator ?? config.agents[0].name }
459
+ : {}),
470
460
  ...(http
471
461
  ? {
472
462
  http: {
@@ -483,11 +473,12 @@ export function resolveConfig(input: unknown): ResolvedConfig {
483
473
  ...(config.avatar ? { avatar: config.avatar } : {}),
484
474
  workDir: config.workDir ?? `${config.dataDir}/work`,
485
475
  scratchDir: config.scratchDir ?? join(tmpdir(), `${slug}-scratch`),
486
- // Without Discord there are no agents to carry skills, so the addon is off.
487
- skills: config.skills ?? (discord ? {} : false),
476
+ // Without Discord and without agents there is no one to carry skills, so the addon is off.
477
+ skills: config.skills ?? (discord || config.agents?.length ? {} : false),
488
478
  memory: config.memory ?? true,
489
479
  ...(ops ? { ops } : {}),
490
480
  drainMs: (config.drainSeconds ?? DRAIN_LIMIT_MS / 1_000) * 1_000,
481
+ ...limitsOf(config),
491
482
  interimText: config.interimText ?? "on",
492
483
  interimPrimaryChars: config.interimPrimaryChars ?? PRIMARY_CHARS,
493
484
  plugins: config.plugins ?? [],