pi-roundtable 0.7.19 → 0.8.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 (72) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +44 -11
  3. package/README.zh-TW.md +44 -9
  4. package/docs/migrating-0.8.md +132 -0
  5. package/docs/plugins.md +267 -23
  6. package/examples/discord-adapter.test.ts +42 -0
  7. package/examples/discord-adapter.ts +26 -0
  8. package/examples/echo-runtime.ts +1 -1
  9. package/examples/headless.test.ts +72 -0
  10. package/examples/headless.ts +31 -0
  11. package/examples/http.test.ts +31 -1
  12. package/examples/http.ts +39 -0
  13. package/package.json +3 -2
  14. package/src/cli/cli.ts +15 -4
  15. package/src/cli/doctor.ts +21 -5
  16. package/src/cli/init.ts +9 -1
  17. package/src/cli/project.ts +28 -2
  18. package/src/cli/templates.ts +46 -14
  19. package/src/core/agents/agent-team-fixture.ts +6 -0
  20. package/src/core/agents/team-turns.ts +6 -1
  21. package/src/core/builtin/agent-server.ts +30 -95
  22. package/src/core/builtin/modules.ts +133 -34
  23. package/src/core/config/adapters.ts +57 -0
  24. package/src/core/config/config.ts +174 -77
  25. package/src/core/contract/channels.ts +4 -0
  26. package/src/core/contract/providers.ts +3 -2
  27. package/src/core/contract/runtime.ts +8 -4
  28. package/src/core/contract/surface.ts +13 -0
  29. package/src/core/conversations/conversation-registry.ts +42 -0
  30. package/src/core/conversations/conversation-store.ts +113 -0
  31. package/src/core/conversations/conversations-plugin.ts +29 -0
  32. package/src/core/define-roundtable.ts +124 -54
  33. package/src/core/discord/discord-adapter.ts +9 -0
  34. package/src/core/discord/owner-cards.ts +19 -8
  35. package/src/core/domain/conversation.ts +5 -0
  36. package/src/core/domain/owner-prompts.ts +1 -1
  37. package/src/core/domain/ports.ts +6 -0
  38. package/src/core/domain/progress.ts +10 -0
  39. package/src/core/events.ts +1 -0
  40. package/src/core/host.ts +6 -3
  41. package/src/core/http/listeners.ts +174 -21
  42. package/src/core/http/websocket.ts +316 -0
  43. package/src/core/i18n/cards.ts +5 -7
  44. package/src/core/ops/error-reporter.ts +24 -12
  45. package/src/core/plugin.ts +14 -0
  46. package/src/core/registry/providers.ts +1 -1
  47. package/src/core/routing/channel-router.ts +8 -0
  48. package/src/core/routing/conversation-turns.ts +55 -1
  49. package/src/core/routing/surface-port.ts +3 -0
  50. package/src/core/runtime/extensions/confirmation-gate.ts +10 -2
  51. package/src/core/runtime/extensions/self-compact-guard.ts +11 -0
  52. package/src/core/runtime/pending-confirmation-store.ts +42 -4
  53. package/src/core/runtime/pi-agent-runtime.ts +13 -6
  54. package/src/core/runtime/prompt-slot.ts +2 -2
  55. package/src/core/runtime/runtime-plugin.ts +160 -0
  56. package/src/core/runtime/turn-progress.ts +116 -0
  57. package/src/core/services.ts +15 -1
  58. package/src/core/shared/unix-server.ts +35 -5
  59. package/src/core/testing/modules.ts +47 -23
  60. package/src/core/testing/surface-contract.ts +383 -0
  61. package/src/core/testing/test-host.ts +27 -9
  62. package/src/discord/index.ts +2 -0
  63. package/src/index.ts +18 -0
  64. package/src/testing.ts +41 -5
  65. package/templates/adapters/web/.env.example +24 -0
  66. package/templates/adapters/web/README.md +15 -0
  67. package/templates/adapters/web/package.json.tmpl +26 -0
  68. package/templates/adapters/web/persona/assistant.md +2 -0
  69. package/templates/adapters/web/roundtable.config.ts +55 -0
  70. package/templates/package.json.tmpl +1 -0
  71. package/templates/plugins/self-compact.ts +11 -0
  72. package/templates/roundtable.config.ts +2 -1
@@ -11,28 +11,26 @@ import { FileAvatarStudio } from "../agents/avatar-studio.ts";
11
11
  import { RelevanceScorer } from "../agents/group-round.ts";
12
12
  import { discordKey } from "../agents/team-keys.ts";
13
13
  import { ownerAttachmentDir } from "../attachments/attachment-dir.ts";
14
- import type { AgentRuntime, AgentSessions } from "../contract/runtime.ts";
14
+ import type { AgentSessions } from "../contract/runtime.ts";
15
15
  import type { ChannelKey } from "../domain/conversation.ts";
16
16
  import { ConfigError } from "../domain/errors.ts";
17
- import type { InterimTextMode } from "../domain/interim.ts";
18
17
  import type { OwnerIdentity } from "../identity.ts";
19
18
  import { ConfirmationJudge } from "../judging/confirmation-judge.ts";
20
- import { AGENT_BRIEF, EffortJudge } from "../judging/effort-judge.ts";
21
- import {
22
- AUTO_THINKING,
23
- formatModelRef,
24
- type ModelRef,
25
- type ThinkingLevel,
26
- } from "../models.ts";
19
+ import { AUTO_THINKING, formatModelRef, type ModelRef } from "../models.ts";
27
20
  import type { ErrorReporter } from "../ops/error-reporter.ts";
28
21
  import type { PluginContext, RoundtablePlugin } from "../plugin.ts";
29
22
  import { splitReply } from "../presentation/reply-splitter.ts";
30
23
  import { agentPromptExtension } from "../runtime/extensions/agent-prompt.ts";
31
- import { PendingConfirmationStore } from "../runtime/pending-confirmation-store.ts";
32
- import { PiAgentRuntime } from "../runtime/pi-agent-runtime.ts";
33
- import { AGENTS, BACKGROUND_TURNS, SCHEDULES, SKILLS } from "../services.ts";
24
+ import type { AgentSessionsSlot } from "../runtime/runtime-plugin.ts";
25
+ import {
26
+ AGENTS,
27
+ BACKGROUND_TURNS,
28
+ RUNTIME,
29
+ SCHEDULES,
30
+ SKILLS,
31
+ } from "../services.ts";
34
32
  import type { SessionTool } from "../sessions.ts";
35
- import type { Speaker, SpeakerPolicy, Tier } from "../speakers.ts";
33
+ import type { SpeakerPolicy, Tier } from "../speakers.ts";
36
34
  import { DISCORD } from "./discord.ts";
37
35
  import { agentOnly } from "./session-tool.ts";
38
36
 
@@ -45,15 +43,15 @@ export interface AgentServerOptions {
45
43
  assistant: string;
46
44
  /** Who may talk to the agents, and at which tier. */
47
45
  speakers: SpeakerPolicy;
48
- /** Shared with the rest of the host, so logins refresh in one place. */
46
+ /** Shared with the rest of the host, so logins refresh in one place; lists the models an agent may run. */
49
47
  modelRuntime: ModelRuntime;
50
- agentDir: string;
51
48
  dataDir: string;
52
- /** The model of agents and of the assistant, and the thinking level a judge falls back to. */
49
+ /** The model of agents and of the assistant. */
53
50
  model: ModelRef;
54
- thinking: ThinkingLevel;
55
- /** How sure the judges must be before their answer is used. */
51
+ /** How sure the confirmation judge must be before its answer is used. */
56
52
  judgeThreshold: number;
53
+ /** Where the agent server hands the runtime plugin its per-agent settings. */
54
+ agents?: AgentSessionsSlot;
57
55
  /** The shell's shared working directory. */
58
56
  workDir: string;
59
57
  /**
@@ -73,10 +71,6 @@ export interface AgentServerOptions {
73
71
  avatarReference: string;
74
72
  /** Reports the process's own errors to an agent; without one nothing is reported. */
75
73
  errorReporter?: ErrorReporter;
76
- /** Whether turns post the text they write before their final answer as they go; default "on". */
77
- interimText?: InterimTextMode;
78
- /** An intermediate text this long or longer is posted as an ordinary message; default 400. */
79
- interimPrimaryChars?: number;
80
74
  }
81
75
 
82
76
  /** The name of the agent server's plugin, as `serviceStarted` events name it. */
@@ -109,11 +103,9 @@ const AGENT_TIERS: Readonly<Record<string, Tier>> = {
109
103
  channel_read: "member",
110
104
  };
111
105
 
112
- /** The tables the agent server keeps: its agents and groups, and the held actions. */
106
+ /** The tables the agent server keeps: its agents and groups. */
113
107
  export interface AgentServerStores {
114
108
  agents: PgAgentStore;
115
- /** Held actions, kept across a restart. */
116
- confirmations: PendingConfirmationStore;
117
109
  }
118
110
 
119
111
  /** Attaches the agent server's stores over the host's migrated pool. */
@@ -121,16 +113,14 @@ async function attachStores(
121
113
  sql: SQL,
122
114
  guildId: string,
123
115
  ): Promise<AgentServerStores> {
124
- return {
125
- agents: await PgAgentStore.attach(sql, guildId),
126
- confirmations: await PendingConfirmationStore.attach(sql),
127
- };
116
+ return { agents: await PgAgentStore.attach(sql, guildId) };
128
117
  }
129
118
 
130
119
  /**
131
- * The agent server: the team with its channels and dashboard, and the runtime that runs every
132
- * agent turn. The skills the agents carry come from the skills addon when it is on. It claims the agent channels, starts the team in the
133
- * background, and hands what it built to the plugins after it.
120
+ * The agent server: the team with its channels and dashboard, run on the runtime plugin's runtime,
121
+ * which it hands its per-agent settings. The skills the agents carry come from the skills addon
122
+ * when it is on. It claims the agent channels, starts the team in the background, and hands what
123
+ * it built to the plugins after it.
134
124
  */
135
125
  export function agentServerPlugin(
136
126
  options: AgentServerOptions,
@@ -141,18 +131,13 @@ export function agentServerPlugin(
141
131
  ) => Promise<AgentServerStores> = (context, guildId) =>
142
132
  attachStores(context.database(), guildId),
143
133
  ): RoundtablePlugin {
144
- let runtime: AgentRuntime | undefined;
145
134
  return {
146
135
  name: AGENT_SERVER_PLUGIN,
147
- // The agent server's own tables; the host runs them before any setup, in this order.
148
- migrations: [
149
- PendingConfirmationStore.migration,
150
- ...PgAgentStore.migrations(options.guildId),
151
- ],
136
+ // The agent server's own tables; the host runs them before any setup, in this order. The
137
+ // held actions' table is the runtime plugin's.
138
+ migrations: PgAgentStore.migrations(options.guildId),
152
139
  provides: [AGENTS],
153
- preflight: async () => {
154
- await runtime?.preflight?.();
155
- },
140
+ requires: [RUNTIME],
156
141
  setup: async (context) => {
157
142
  const discord = context.services.get(DISCORD);
158
143
  const schedules = context.services.get(SCHEDULES);
@@ -168,10 +153,8 @@ export function agentServerPlugin(
168
153
  await studio.init();
169
154
  // Absent when the skills addon is off: the agents then carry none.
170
155
  const skills = context.services.find(SKILLS);
171
- const { agents: store, confirmations: heldActions } = await openStores(
172
- context,
173
- options.guildId,
174
- );
156
+ const { agents: store } = await openStores(context, options.guildId);
157
+ const running = context.services.get(RUNTIME);
175
158
  const judge = providers.judge;
176
159
  const confirmations = new ConfirmationJudge({
177
160
  judge,
@@ -194,11 +177,7 @@ export function agentServerPlugin(
194
177
  store,
195
178
  channels: connection.agentChannels(options.guildId),
196
179
  studio,
197
- // The runtime is built next, with this team's tools.
198
- runtime: () => {
199
- if (!runtime) throw new Error("the runtime is built with the team");
200
- return runtime;
201
- },
180
+ runtime: () => running,
202
181
  confirmations,
203
182
  scorer: new RelevanceScorer(judge, logger, options.owner),
204
183
  models: {
@@ -247,50 +226,7 @@ export function agentServerPlugin(
247
226
  skills: (name) => built.skillsOf(name),
248
227
  turnChannel: (scope) => built.turnChannel(scope),
249
228
  };
250
- const prompts = (channel: ChannelKey, speaker?: Speaker) =>
251
- context.surfaces.prompts(channel, speaker);
252
- // A plugin that fills the runtime slot replaces the whole conversation runtime; without
253
- // one the agent server runs Pi, built from the options below.
254
- const running: AgentRuntime = providers.filled.has("runtime")
255
- ? providers.runtime({
256
- logger,
257
- env: context.env,
258
- owner: { id: options.owner.id, name: options.owner.name },
259
- sessions: context.sessions,
260
- toolTiers: context.toolTiers,
261
- prompts,
262
- agents: agentSessions,
263
- confirmations: heldActions,
264
- judge,
265
- })
266
- : new PiAgentRuntime({
267
- owner: options.owner,
268
- sessions: context.sessions,
269
- agentDir: options.agentDir,
270
- modelRuntime: options.modelRuntime,
271
- dataDir: options.dataDir,
272
- model: options.model,
273
- thinking: options.thinking,
274
- effort: new EffortJudge({
275
- judge,
276
- brief: AGENT_BRIEF,
277
- fallback: options.thinking,
278
- threshold: options.judgeThreshold,
279
- logger,
280
- }),
281
- confirmations: heldActions,
282
- toolTiers: context.toolTiers,
283
- agents: agentSessions,
284
- prompts,
285
- logger,
286
- ...(options.interimText
287
- ? { interimText: options.interimText }
288
- : {}),
289
- ...(options.interimPrimaryChars
290
- ? { interimPrimaryChars: options.interimPrimaryChars }
291
- : {}),
292
- });
293
- runtime = running;
229
+ options.agents?.bind(agentSessions);
294
230
  context.services.provide(AGENTS, {
295
231
  team: built,
296
232
  directory: store,
@@ -325,7 +261,6 @@ export function agentServerPlugin(
325
261
  });
326
262
  return {
327
263
  services: [
328
- { name: "runtime", stop: () => running.dispose?.() },
329
264
  { name: "dashboard", stop: () => dashboard.stop() },
330
265
  {
331
266
  name: AGENT_TEAM_SERVICE,
@@ -1,8 +1,14 @@
1
1
  import { join } from "node:path";
2
2
  import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
3
+ import { OWNER_TARGET } from "../agents/agent-claim.ts";
3
4
  import type { SurfacePort } from "../contract/surface.ts";
4
5
  import { scheduleCommands } from "../discord/schedule-commands.ts";
5
6
  import type { ChannelKey } from "../domain/conversation.ts";
7
+ import {
8
+ ConfigError,
9
+ DelegationError,
10
+ ScheduleError,
11
+ } from "../domain/errors.ts";
6
12
  import { messages } from "../i18n/index.ts";
7
13
  import type { OwnerIdentity } from "../identity.ts";
8
14
  import type { ModelRef, ThinkingLevel } from "../models.ts";
@@ -17,7 +23,9 @@ import { notifyExtension } from "../modules/notify/notify.ts";
17
23
  import { precheckScriptHoldRule } from "../modules/schedules/precheck-tools.ts";
18
24
  import { Scheduler } from "../modules/schedules/scheduler.ts";
19
25
  import { schedulesExtension } from "../modules/schedules/schedules.ts";
26
+ import type { ErrorReporter } from "../ops/error-reporter.ts";
20
27
  import type { RoundtablePlugin } from "../plugin.ts";
28
+ import { splitReply } from "../presentation/reply-splitter.ts";
21
29
  import {
22
30
  AGENTS,
23
31
  BACKGROUND_TURNS,
@@ -33,6 +41,11 @@ import { DISCORD } from "./discord.ts";
33
41
  import { fixed } from "./session-tool.ts";
34
42
 
35
43
  export interface ModulesOptions {
44
+ /**
45
+ * Reports the process's own errors to a conversation, connected when the modules start; one
46
+ * that reports to an agent is the agent server's to connect.
47
+ */
48
+ errorReporter?: ErrorReporter;
36
49
  /** Who the tools serve in the owner's own sessions. */
37
50
  owner: OwnerIdentity & { id: string };
38
51
  /** The name a turn the process itself starts is written by. */
@@ -65,19 +78,32 @@ const MODULE_TIERS: Readonly<Record<string, Tier>> = {
65
78
 
66
79
  /**
67
80
  * The owner's modules: notifications, schedules, and delegated tasks, each a session tool, and
68
- * the turns nobody wrote. The delegator's running jobs join the shutdown drain.
81
+ * the turns nobody wrote. The delegator's running jobs join the shutdown drain. Without Discord
82
+ * there are no owner's messages: no `notify_owner`, and a conversation no chat surface carries
83
+ * can neither schedule nor delegate. Without the owner's background target no session gets the
84
+ * schedule or delegation tools.
69
85
  */
70
86
  export function modulesPlugin(options: ModulesOptions): RoundtablePlugin {
71
87
  const { owner } = options;
72
- // Outside the agent server a conversation has no channel of its own to post a run in.
88
+ // A conversation no chat surface carries posts its runs in the owner's messages, when there are any.
73
89
  const channelFor = async (
74
90
  channel: ChannelKey,
75
91
  surfaces: SurfacePort,
76
- ownerChannel: () => Promise<ChannelKey>,
77
- ) => (surfaces.of(channel) ? channel : ownerChannel());
92
+ ownerChannel: (() => Promise<ChannelKey>) | undefined,
93
+ refused: (message: string) => Error,
94
+ ) => {
95
+ if (surfaces.of(channel)) return channel;
96
+ if (ownerChannel) return ownerChannel();
97
+ throw refused(
98
+ "this conversation has no chat surface to post a run in, and the host has no owner's messages to post it in instead",
99
+ );
100
+ };
101
+ // Set up once the modules set up; the preflight reads it after every plugin linked.
102
+ let reportsReach: (() => void) | undefined;
78
103
  return {
79
104
  name: "modules",
80
105
  provides: [BACKGROUND_TURNS, DELEGATION],
106
+ preflight: () => reportsReach?.(),
81
107
  setup: ({
82
108
  conversations,
83
109
  services,
@@ -89,13 +115,29 @@ export function modulesPlugin(options: ModulesOptions): RoundtablePlugin {
89
115
  const schedules = services.get(SCHEDULES);
90
116
  // Absent when a plugin list leaves the prechecks plugin out: then none can be attached.
91
117
  const prechecks = services.find(PRECHECKS);
92
- const discord = services.get(DISCORD);
93
- const { connection } = discord;
118
+ // Absent on a host without Discord: then there are no owner's messages and no threads.
119
+ const discord = services.find(DISCORD);
120
+ const connection = discord?.connection;
121
+ const ownerChannel = connection
122
+ ? () => connection.ownerChannel()
123
+ : undefined;
94
124
  // Another agent's channel, for schedule_list; asked when a tool runs, after the agent server set up.
95
125
  const agentChannelOf = (agent: string) =>
96
126
  services.get(AGENTS).team.channelOf(agent);
97
- const ownerChannelFor = (channel: ChannelKey) =>
98
- channelFor(channel, surfaces, () => connection.ownerChannel());
127
+ const scheduleChannelFor = (channel: ChannelKey) =>
128
+ channelFor(
129
+ channel,
130
+ surfaces,
131
+ ownerChannel,
132
+ (message) => new ScheduleError(message),
133
+ );
134
+ const delegateChannelFor = (channel: ChannelKey) =>
135
+ channelFor(
136
+ channel,
137
+ surfaces,
138
+ ownerChannel,
139
+ (message) => new DelegationError(message),
140
+ );
99
141
  const background = new ConversationBackgroundTurns({
100
142
  conversations,
101
143
  system: { id: "assistant", name: options.assistant },
@@ -113,7 +155,7 @@ export function modulesPlugin(options: ModulesOptions): RoundtablePlugin {
113
155
  }),
114
156
  targets: (name) => conversations.target(name),
115
157
  deliver: (job, outcome) => background.runDelegated(job, outcome),
116
- threads: discord.threads,
158
+ ...(discord ? { threads: discord.threads } : {}),
117
159
  logger,
118
160
  });
119
161
  services.provide(BACKGROUND_TURNS, background);
@@ -121,9 +163,60 @@ export function modulesPlugin(options: ModulesOptions): RoundtablePlugin {
121
163
  // An agent session serves every speaker, so its tools name none.
122
164
  const served = (session: SessionContext) =>
123
165
  session.agent ? THE_SPEAKER : owner;
166
+ // Schedules and delegated reports run as the owner's background turns; without a plugin
167
+ // contributing that target, as on a host without the agent server, none could ever start,
168
+ // so no session gets the tools. Read when a session is made, after every plugin set up.
169
+ const ownerTurns = () =>
170
+ conversations.target(OWNER_TARGET.name) !== undefined;
171
+ // A reporter for an ops agent is the agent server's to connect.
172
+ const reporter =
173
+ options.errorReporter &&
174
+ "conversation" in options.errorReporter.destination
175
+ ? options.errorReporter
176
+ : undefined;
177
+ if (reporter && "conversation" in reporter.destination) {
178
+ const channel = reporter.destination.conversation;
179
+ // A report that no surface can post, or that no conversation owns, would only be logged;
180
+ // its report turn is the owner's background turn, which runs only where a claim takes one.
181
+ reportsReach = () => {
182
+ if (!surfaces.of(channel))
183
+ throw new ConfigError(
184
+ `config ops.conversation: no chat surface serves ${JSON.stringify(channel)}, so its error reports could never be posted. Name a conversation of a surface a plugin brings, or leave ops out.`,
185
+ );
186
+ if (!conversations.owns(channel))
187
+ throw new ConfigError(
188
+ `config ops.conversation: no plugin's conversations own ${JSON.stringify(channel)}, so nothing answers its error reports. Name a conversation a plugin's claim owns, or leave ops out.`,
189
+ );
190
+ if (!ownerTurns())
191
+ throw new ConfigError(
192
+ `config ops.conversation: no plugin contributes the "${OWNER_TARGET.name}" background target, so the report turns of ${JSON.stringify(channel)} could never run. Configure discord, whose agent server contributes it, add a plugin that contributes it, or leave ops out.`,
193
+ );
194
+ if (!conversations.takesBackground(channel))
195
+ throw new ConfigError(
196
+ `config ops.conversation: the claim that owns ${JSON.stringify(channel)} takes no background turns, so nothing answers its error reports. Name a conversation whose claim takes background turns, or leave ops out.`,
197
+ );
198
+ };
199
+ }
124
200
  return {
125
201
  services: [
126
202
  { name: "delegator", busy: () => delegator.runningChannels() },
203
+ ...(reporter
204
+ ? [
205
+ {
206
+ name: "error-reports",
207
+ start: () =>
208
+ reporter.connect({
209
+ post: (channel, text) =>
210
+ surfaces.sendReply(channel, {
211
+ chunks: splitReply(text),
212
+ }),
213
+ turn: (channel, text) =>
214
+ background.runErrorReport(channel, text),
215
+ logger,
216
+ }),
217
+ },
218
+ ]
219
+ : []),
127
220
  ],
128
221
  // Saving a precheck script that calls a held tool waits for the owner, as the call would.
129
222
  holdRules: [
@@ -134,34 +227,40 @@ export function modulesPlugin(options: ModulesOptions): RoundtablePlugin {
134
227
  }),
135
228
  ],
136
229
  sessionTools: [
137
- fixed("notify", () => notifyExtension(connection, owner)),
230
+ ...(connection
231
+ ? [fixed("notify", () => notifyExtension(connection, owner))]
232
+ : []),
138
233
  fixed("schedules", (session) =>
139
- schedulesExtension(
140
- {
141
- store: schedules,
142
- owner: { id: owner.id, name: owner.name },
143
- channelFor: ownerChannelFor,
144
- ...(prechecks ? { prechecks } : {}),
145
- holds: () => sessions().holds,
146
- },
147
- session.homeChannel,
148
- served(session),
149
- session.agent ? agentChannelOf : undefined,
150
- session.speaker,
151
- ),
234
+ ownerTurns()
235
+ ? schedulesExtension(
236
+ {
237
+ store: schedules,
238
+ owner: { id: owner.id, name: owner.name },
239
+ channelFor: scheduleChannelFor,
240
+ ...(prechecks ? { prechecks } : {}),
241
+ holds: () => sessions().holds,
242
+ },
243
+ session.homeChannel,
244
+ served(session),
245
+ session.agent ? agentChannelOf : undefined,
246
+ session.speaker,
247
+ )
248
+ : null,
152
249
  ),
153
250
  fixed("delegate", (session) =>
154
- delegateExtension(
155
- {
156
- delegator,
157
- owner: { id: owner.id, name: owner.name },
158
- channelFor: ownerChannelFor,
159
- },
160
- session.homeChannel,
161
- served(session),
162
- session.turnChannel,
163
- session.speaker,
164
- ),
251
+ ownerTurns()
252
+ ? delegateExtension(
253
+ {
254
+ delegator,
255
+ owner: { id: owner.id, name: owner.name },
256
+ channelFor: delegateChannelFor,
257
+ },
258
+ session.homeChannel,
259
+ served(session),
260
+ session.turnChannel,
261
+ session.speaker,
262
+ )
263
+ : null,
165
264
  ),
166
265
  ],
167
266
  toolTiers: MODULE_TIERS,
@@ -0,0 +1,57 @@
1
+ import { ConfigError } from "../domain/errors.ts";
2
+ import { bool, optional, shape, text } from "./schema.ts";
3
+
4
+ /** The Discord settings, at the top level or in a Discord adapter. */
5
+ export const discordShape = shape({
6
+ token: text,
7
+ guild: text,
8
+ entryChannel: text,
9
+ rootCommand: optional(text),
10
+ admin: optional(bool),
11
+ refusalHint: optional(text),
12
+ });
13
+
14
+ /** The adapters `adapters` takes, by the name their factory gives them. */
15
+ const ADAPTERS = ["discord"] as const;
16
+
17
+ /**
18
+ * The configuration with its adapters in the places the host reads them: a Discord adapter's
19
+ * settings as the top-level `discord`. Throws a ConfigError naming the adapter by its place.
20
+ */
21
+ // pi-lens-ignore: no-unknown-returns — the configuration as written is untyped until the schema checks it
22
+ export function liftAdapters(input: unknown): unknown {
23
+ if (typeof input !== "object" || input === null || !("adapters" in input))
24
+ return input;
25
+ const { adapters, ...rest } = input as Record<string, unknown>;
26
+ if (adapters === undefined) return rest;
27
+ if (!Array.isArray(adapters))
28
+ throw new ConfigError(
29
+ `config adapters: expected a list of adapters, such as [discord({ ... })] with discord from pi-roundtable/discord, got ${JSON.stringify(adapters)}. Fix the value in roundtable.config.ts.`,
30
+ );
31
+ const lifted: Record<string, unknown> = { ...rest };
32
+ adapters.forEach((entry: unknown, index) => {
33
+ const at = `adapters[${index}]`;
34
+ const name =
35
+ typeof entry === "object" && entry !== null
36
+ ? (entry as { adapter?: unknown }).adapter
37
+ : undefined;
38
+ if (typeof name !== "string")
39
+ throw new ConfigError(
40
+ `config ${at}: expected an adapter, an object its factory makes such as discord({ ... }) from pi-roundtable/discord, got ${JSON.stringify(entry)}. Fix the value in roundtable.config.ts.`,
41
+ );
42
+ if (!(ADAPTERS as readonly string[]).includes(name))
43
+ throw new ConfigError(
44
+ `config ${at}: unknown adapter ${JSON.stringify(name)}. The adapters are ${ADAPTERS.join(", ")}; a chat network that comes as a plugin, such as pi-roundtable-webchat's webChat(), belongs in plugins.`,
45
+ );
46
+ if (lifted.discord !== undefined)
47
+ throw new ConfigError(
48
+ `config ${at}: Discord is configured twice, at the top level or by another adapter. Keep one.`,
49
+ );
50
+ const { adapter: _adapter, ...settings } = entry as Record<string, unknown>;
51
+ lifted.discord = shape({ discord: discordShape }).check(
52
+ settings,
53
+ at,
54
+ ).discord;
55
+ });
56
+ return lifted;
57
+ }