pi-roundtable 0.1.0 → 0.2.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 (238) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/README.md +6 -4
  3. package/docs/plugins.md +1448 -66
  4. package/examples/echo-runtime.test.ts +122 -0
  5. package/examples/echo-runtime.ts +107 -0
  6. package/examples/events.test.ts +1 -0
  7. package/examples/events.ts +2 -2
  8. package/examples/fake-surface.test.ts +180 -0
  9. package/examples/fake-surface.ts +109 -0
  10. package/examples/interactions.test.ts +12 -3
  11. package/examples/interactions.ts +19 -19
  12. package/examples/shared-services.test.ts +74 -0
  13. package/examples/shared-services.ts +60 -0
  14. package/examples/study-room.test.ts +69 -0
  15. package/examples/study-room.ts +52 -0
  16. package/examples/support-desk.test.ts +34 -0
  17. package/examples/support-desk.ts +40 -0
  18. package/package.json +9 -2
  19. package/src/cli/checks/database.ts +11 -30
  20. package/src/cli/checks/images.ts +19 -0
  21. package/src/cli/doctor.ts +6 -0
  22. package/src/cli/main.ts +2 -1
  23. package/src/core/agents/agent-claim.ts +42 -16
  24. package/src/core/agents/agent-messages.ts +4 -4
  25. package/src/core/agents/agent-ports.ts +8 -21
  26. package/src/core/agents/agent-prompt.ts +3 -1
  27. package/src/core/agents/agent-store.ts +6 -4
  28. package/src/core/agents/agent-team-fixture.ts +389 -0
  29. package/src/core/agents/agent-team.ts +26 -25
  30. package/src/core/agents/agent-tools.ts +34 -15
  31. package/src/core/agents/avatar-studio.ts +40 -6
  32. package/src/core/agents/fallback-avatar.ts +85 -0
  33. package/src/core/agents/team-editing.ts +49 -10
  34. package/src/core/agents/team-keys.ts +22 -9
  35. package/src/core/agents/team-layout.ts +4 -3
  36. package/src/core/agents/team-lifecycle.ts +28 -2
  37. package/src/core/agents/team-options.ts +9 -5
  38. package/src/core/agents/team-status.ts +4 -4
  39. package/src/core/agents/team-text.ts +8 -6
  40. package/src/core/agents/team-turn-types.ts +4 -4
  41. package/src/core/agents/team-turns.ts +6 -5
  42. package/src/core/builtin/agent-server.ts +187 -121
  43. package/src/core/builtin/discord-admin.ts +41 -0
  44. package/src/core/builtin/discord.ts +65 -40
  45. package/src/core/builtin/modules.ts +67 -58
  46. package/src/core/builtin/session-tool.ts +24 -0
  47. package/src/core/builtin/skills.ts +72 -0
  48. package/src/core/builtin/stores.ts +55 -31
  49. package/src/core/config/config.ts +45 -4
  50. package/src/core/config/schema.ts +6 -0
  51. package/src/core/contract/channels.ts +54 -14
  52. package/src/core/contract/providers.ts +17 -0
  53. package/src/core/contract/runtime.ts +138 -0
  54. package/src/core/contract/services.ts +45 -0
  55. package/src/core/contract/surface.ts +88 -0
  56. package/src/core/db/migrations.ts +118 -3
  57. package/src/core/define-roundtable.ts +70 -46
  58. package/src/core/define.ts +2 -1
  59. package/src/core/discord/agent-commands.ts +39 -14
  60. package/src/core/discord/agent-panel.ts +66 -0
  61. package/src/core/discord/channel-executor.ts +6 -3
  62. package/src/core/discord/channel-operations.ts +14 -11
  63. package/src/core/discord/command-collection.ts +46 -0
  64. package/src/core/{registry/interactions.ts → discord/compose-commands.ts} +5 -5
  65. package/src/core/discord/connection.ts +35 -0
  66. package/src/core/discord/discord-surface.ts +32 -94
  67. package/src/core/discord/dispatch-threads.ts +2 -2
  68. package/src/core/discord/inbound-message.ts +99 -0
  69. package/src/core/discord/interaction-module.ts +57 -1
  70. package/src/core/discord/owner-cards.ts +4 -4
  71. package/src/core/discord/owner-command.ts +44 -11
  72. package/src/core/discord/owner-discord.ts +2 -2
  73. package/src/core/discord/schedule-commands.ts +21 -18
  74. package/src/core/discord/stop-button.ts +44 -1
  75. package/src/core/domain/attachment.ts +12 -8
  76. package/src/core/domain/conversation.ts +3 -4
  77. package/src/core/domain/owner-prompts.ts +5 -5
  78. package/src/core/domain/ports.ts +9 -53
  79. package/src/core/freeze.ts +9 -0
  80. package/src/core/host.ts +255 -82
  81. package/src/core/http/listeners.ts +55 -20
  82. package/src/core/i18n/agent-panel.ts +4 -0
  83. package/src/core/i18n/index.ts +12 -2
  84. package/src/core/i18n/owner.ts +7 -1
  85. package/src/core/i18n/schedules.ts +12 -6
  86. package/src/core/identity.ts +1 -1
  87. package/src/core/judging/effort-judge.ts +25 -3
  88. package/src/core/log.ts +21 -3
  89. package/src/core/models.ts +2 -2
  90. package/src/core/modules/background/background-turns.ts +6 -4
  91. package/src/core/modules/delegation/delegate.ts +3 -2
  92. package/src/core/modules/delegation/delegator.ts +15 -13
  93. package/src/core/modules/delegation/{sol-worker.ts → web-research-worker.ts} +6 -6
  94. package/src/core/modules/discord-admin/discord-admin.ts +6 -6
  95. package/src/core/modules/host-shell/shell-policy.ts +8 -3
  96. package/src/core/modules/memory/owner-memory-store.ts +27 -25
  97. package/src/core/modules/memory/owner-memory.ts +9 -13
  98. package/src/core/modules/schedules/recurrence.ts +5 -3
  99. package/src/core/modules/schedules/schedule-store.ts +11 -11
  100. package/src/core/modules/schedules/schedule-tools.ts +22 -20
  101. package/src/core/modules/schedules/scheduler.ts +7 -2
  102. package/src/core/modules/schedules/schedules.ts +9 -3
  103. package/src/core/modules/skills/skill-registry.ts +3 -2
  104. package/src/core/modules/skills/skill-store.ts +2 -15
  105. package/src/core/modules/skills/skill-tools.ts +10 -4
  106. package/src/core/ops/error-reporter.ts +1 -1
  107. package/src/core/plugin.ts +232 -28
  108. package/src/core/registry/contributions.ts +173 -14
  109. package/src/core/registry/providers.ts +35 -5
  110. package/src/core/registry/services.ts +229 -0
  111. package/src/core/routing/channel-queue.ts +5 -0
  112. package/src/core/routing/channel-router.ts +21 -7
  113. package/src/core/routing/conversation-turns.ts +139 -0
  114. package/src/core/routing/message-text.ts +9 -2
  115. package/src/core/routing/surface-port.ts +39 -0
  116. package/src/core/runtime/conversation-sessions.ts +4 -3
  117. package/src/core/runtime/extensions/confirmation-fixture.ts +11 -0
  118. package/src/core/runtime/extensions/confirmation-gate.ts +11 -9
  119. package/src/core/runtime/mcp.ts +1 -1
  120. package/src/core/runtime/pending-confirmation-store.ts +10 -12
  121. package/src/core/runtime/pi-agent-runtime.ts +9 -6
  122. package/src/core/runtime/prompt-slot.ts +8 -3
  123. package/src/core/runtime/runtime-types.ts +9 -35
  124. package/src/core/runtime/session-factory.ts +21 -5
  125. package/src/core/runtime/text-tools.ts +1 -3
  126. package/src/core/services.ts +238 -90
  127. package/src/core/sessions.ts +3 -2
  128. package/src/core/shared/{profile-tools.ts → active-tools.ts} +2 -2
  129. package/src/core/shared/delegate-tool.ts +4 -3
  130. package/src/core/shared/schedule-tools.ts +22 -13
  131. package/src/core/shared/session-messages.ts +1 -1
  132. package/src/core/speakers.ts +6 -38
  133. package/src/core/testing/database.ts +1 -7
  134. package/src/core/testing/eager-catalog.ts +30 -0
  135. package/src/core/testing/locale.ts +23 -5
  136. package/src/core/testing/modules.ts +105 -41
  137. package/src/core/testing/test-host.ts +279 -0
  138. package/src/core/testing/tool-set.ts +64 -0
  139. package/src/core/time.ts +6 -1
  140. package/src/core/tool-set.snapshot.json +288 -0
  141. package/src/core/tool-tiers.ts +17 -57
  142. package/src/discord/index.ts +64 -0
  143. package/src/index.ts +181 -10
  144. package/src/kit/channels.ts +14 -0
  145. package/src/kit/domain.ts +18 -0
  146. package/src/kit/holds.ts +4 -0
  147. package/src/kit/index.ts +129 -0
  148. package/src/kit/judging.ts +10 -0
  149. package/src/kit/memory.ts +3 -0
  150. package/src/kit/mirror.ts +19 -0
  151. package/src/kit/presentation.ts +7 -0
  152. package/src/kit/shell.ts +7 -0
  153. package/src/kit/skills.ts +8 -0
  154. package/src/kit/support.ts +21 -0
  155. package/src/kit/threads.ts +7 -0
  156. package/src/kit/tools.ts +14 -0
  157. package/src/kit/worker.ts +16 -0
  158. package/src/testing.ts +394 -34
  159. package/examples/guide.test.ts +0 -52
  160. package/src/cli/add-plugin.test.ts +0 -107
  161. package/src/cli/checks/basic.test.ts +0 -276
  162. package/src/cli/checks/database.test.ts +0 -117
  163. package/src/cli/checks/discord.test.ts +0 -200
  164. package/src/cli/cli.test.ts +0 -122
  165. package/src/cli/config-edit.test.ts +0 -130
  166. package/src/cli/doctor.test.ts +0 -184
  167. package/src/cli/init.test.ts +0 -127
  168. package/src/cli/size.test.ts +0 -9
  169. package/src/cli/templates.test.ts +0 -129
  170. package/src/cli/testing/fixtures.ts +0 -106
  171. package/src/core/agents/agent-claim.test.ts +0 -142
  172. package/src/core/agents/agent-dashboard.test.ts +0 -120
  173. package/src/core/agents/agent-guild.test.ts +0 -124
  174. package/src/core/agents/agent-store.test.ts +0 -205
  175. package/src/core/agents/avatar-studio.test.ts +0 -88
  176. package/src/core/agents/group-round.test.ts +0 -143
  177. package/src/core/agents/owner-identity.test.ts +0 -172
  178. package/src/core/agents/team-turns.test.ts +0 -278
  179. package/src/core/attachments/attachments.test.ts +0 -85
  180. package/src/core/boundary.test.ts +0 -45
  181. package/src/core/builtin/modules.test.ts +0 -127
  182. package/src/core/config/config.test.ts +0 -135
  183. package/src/core/contract/discord.ts +0 -33
  184. package/src/core/db/migrations.test.ts +0 -237
  185. package/src/core/define-roundtable.test.ts +0 -134
  186. package/src/core/define.test.ts +0 -144
  187. package/src/core/discord/agent-commands.test.ts +0 -58
  188. package/src/core/discord/agent-discord.test.ts +0 -70
  189. package/src/core/discord/dispatch-thread-host.test.ts +0 -114
  190. package/src/core/discord/dispatch-threads.test.ts +0 -94
  191. package/src/core/discord/owner-cards.test.ts +0 -435
  192. package/src/core/discord/owner-discord.test.ts +0 -325
  193. package/src/core/domain/expression.ts +0 -21
  194. package/src/core/domain/profile.ts +0 -32
  195. package/src/core/drain.test.ts +0 -46
  196. package/src/core/events.test.ts +0 -81
  197. package/src/core/holds.test.ts +0 -54
  198. package/src/core/host.test.ts +0 -536
  199. package/src/core/http/listeners.test.ts +0 -164
  200. package/src/core/i18n/i18n.test.ts +0 -139
  201. package/src/core/identity.test.ts +0 -18
  202. package/src/core/judging/effort-judge.test.ts +0 -112
  203. package/src/core/judging/model-judge.test.ts +0 -126
  204. package/src/core/modules/delegation/delegator.test.ts +0 -170
  205. package/src/core/modules/memory/owner-memory-store.test.ts +0 -244
  206. package/src/core/modules/schedules/schedule.test.ts +0 -358
  207. package/src/core/modules/skills/skill-kind.test.ts +0 -55
  208. package/src/core/modules/skills/skill-registry.test.ts +0 -376
  209. package/src/core/ops/error-reporter.test.ts +0 -283
  210. package/src/core/presentation/card-cadence.ts +0 -41
  211. package/src/core/presentation/presentation.test.ts +0 -166
  212. package/src/core/public-entry.test.ts +0 -59
  213. package/src/core/registry/contributions.test.ts +0 -215
  214. package/src/core/registry/interactions.test.ts +0 -87
  215. package/src/core/registry/providers.test.ts +0 -103
  216. package/src/core/routing/channel-queue.test.ts +0 -31
  217. package/src/core/routing/channel-router.test.ts +0 -326
  218. package/src/core/routing/conversation-kind.ts +0 -14
  219. package/src/core/routing/settle-turn.test.ts +0 -21
  220. package/src/core/runtime/compaction-tiers.test.ts +0 -227
  221. package/src/core/runtime/extensions/ask-user.test.ts +0 -114
  222. package/src/core/runtime/extensions/self-compact-guard.test.ts +0 -50
  223. package/src/core/runtime/pending-confirmation-store.test.ts +0 -52
  224. package/src/core/runtime/session-archive.test.ts +0 -20
  225. package/src/core/runtime/steerable-run.test.ts +0 -264
  226. package/src/core/runtime/text-tools.test.ts +0 -110
  227. package/src/core/runtime/turn-answer.test.ts +0 -72
  228. package/src/core/runtime/worker-task.test.ts +0 -82
  229. package/src/core/services.test.ts +0 -28
  230. package/src/core/sessions.test.ts +0 -85
  231. package/src/core/shared/unix-server.ts +0 -18
  232. package/src/core/size.test.ts +0 -9
  233. package/src/core/speakers.test.ts +0 -78
  234. package/src/core/testing/file-size.ts +0 -34
  235. package/src/core/time.test.ts +0 -166
  236. package/src/core/tool-tiers.test.ts +0 -117
  237. package/src/entries.test.ts +0 -112
  238. package/src/testing.test.ts +0 -156
@@ -1,6 +1,6 @@
1
1
  import type { AgentSeed } from "../agents/agent-store.ts";
2
- import type { ChannelClaim } from "../contract/channels.ts";
3
- import type { InteractionContribution } from "../contract/discord.ts";
2
+ import type { BackgroundTarget, ChannelClaim } from "../contract/channels.ts";
3
+ import type { ChatSurface } from "../contract/surface.ts";
4
4
  import type { ToolContribution } from "../define.ts";
5
5
  import { PluginError } from "../errors.ts";
6
6
  import { type HoldRule, holdChain } from "../holds.ts";
@@ -10,9 +10,14 @@ import {
10
10
  type Contribution,
11
11
  type EventHandlers,
12
12
  type LinkedSessions,
13
+ type Persona,
13
14
  type PluginContext,
14
15
  type PromptSection,
16
+ pluginContext,
15
17
  type RoundtablePlugin,
18
+ refuseRemovedEvents,
19
+ refuseRemovedParts,
20
+ refuseRemovedSurfaceMethods,
16
21
  type Service,
17
22
  } from "../plugin.ts";
18
23
  import {
@@ -20,39 +25,51 @@ import {
20
25
  type SessionTool,
21
26
  type ToolSelection,
22
27
  } from "../sessions.ts";
28
+ import { TIERS } from "../speakers.ts";
23
29
  import type { ToolTierTable } from "../tool-tiers.ts";
30
+ import { ServiceRegistry } from "./services.ts";
24
31
 
25
32
  /** Everything the plugins contributed, in contribution order. */
26
33
  export interface Registry {
27
34
  services: Service[];
35
+ /** The plugin that contributed each service. */
36
+ servicePlugins: Map<Service, string>;
28
37
  handlers: { plugin: string; events: EventHandlers }[];
29
- interactions: InteractionContribution[];
30
38
  routes: HttpRoute[];
31
39
  holdRules: HoldRule[];
32
40
  piPackages: string[];
33
41
  sessionTools: SessionTool[];
34
42
  channels: ChannelClaim[];
43
+ /** The contributed chat surfaces, each also in `services` as `surface:<prefix>`. */
44
+ surfaces: ChatSurface[];
45
+ personas: Persona[];
46
+ backgroundTargets: BackgroundTarget[];
35
47
  dashboard: string[];
36
48
  tools: ToolContribution[];
37
49
  seeds: AgentSeed[];
38
50
  prompt: PromptSection[];
39
51
  agentSelections: (() => ToolSelection)[];
52
+ requiredTools: string[];
40
53
  }
41
54
 
42
55
  export const emptyRegistry = (): Registry => ({
43
56
  services: [],
57
+ servicePlugins: new Map(),
44
58
  handlers: [],
45
- interactions: [],
46
59
  routes: [],
47
60
  holdRules: [],
48
61
  piPackages: [],
49
62
  sessionTools: [],
50
63
  channels: [],
64
+ surfaces: [],
65
+ personas: [],
66
+ backgroundTargets: [],
51
67
  dashboard: [],
52
68
  tools: [],
53
69
  seeds: [],
54
70
  prompt: [],
55
71
  agentSelections: [],
72
+ requiredTools: [],
56
73
  });
57
74
 
58
75
  /** The edit distance between two words, for naming the key a typo meant. */
@@ -129,6 +146,99 @@ class Names {
129
146
  }
130
147
  }
131
148
 
149
+ /** The kind of a conversation the agent server runs itself, which no plugin's persona may take. */
150
+ const RESERVED_KIND = "agent";
151
+
152
+ /**
153
+ * Refuses a persona without a kind, for the reserved agent kind, or for a kind another plugin's
154
+ * persona already has, so a conversation's prompt is never a matter of plugin order.
155
+ */
156
+ function claimPersona(
157
+ kinds: Map<string, string>,
158
+ plugin: string,
159
+ persona: Persona,
160
+ ): void {
161
+ const { kind } = persona;
162
+ if (typeof kind !== "string" || kind === "")
163
+ throw new PluginError(
164
+ `plugin ${plugin}: a persona needs a kind, a non-empty string such as "study", and got ${JSON.stringify(kind)}.`,
165
+ );
166
+ if (kind === RESERVED_KIND)
167
+ throw new PluginError(
168
+ `plugin ${plugin}: the persona kind "${RESERVED_KIND}" is reserved for the agent server's agents. Give the persona the kind of your own conversations.`,
169
+ );
170
+ if (typeof persona.prompt !== "function")
171
+ throw new PluginError(
172
+ `plugin ${plugin}: the persona of kind "${kind}" needs a prompt() that returns its text.`,
173
+ );
174
+ const other = kinds.get(kind);
175
+ if (other !== undefined)
176
+ throw new PluginError(
177
+ `plugin ${plugin}: persona kind "${kind}" is already registered by plugin ${other}. Keep one persona per kind.`,
178
+ );
179
+ kinds.set(kind, plugin);
180
+ }
181
+
182
+ /**
183
+ * Refuses a background target without a name or a label, or whose name another plugin's target
184
+ * already has, so a stored schedule's target never depends on plugin order.
185
+ */
186
+ function claimTarget(
187
+ names: Map<string, string>,
188
+ plugin: string,
189
+ target: BackgroundTarget,
190
+ ): void {
191
+ const { name } = target;
192
+ if (typeof name !== "string" || name === "")
193
+ throw new PluginError(
194
+ `plugin ${plugin}: a background target needs a name, a non-empty string such as "support", and got ${JSON.stringify(name)}.`,
195
+ );
196
+ if (typeof target.label !== "function")
197
+ throw new PluginError(
198
+ `plugin ${plugin}: the background target "${name}" needs a label(locale) that returns its name for lists.`,
199
+ );
200
+ const other = names.get(name);
201
+ if (other !== undefined)
202
+ throw new PluginError(
203
+ `plugin ${plugin}: background target "${name}" is already registered by plugin ${other}. Keep one target per name.`,
204
+ );
205
+ names.set(name, plugin);
206
+ }
207
+
208
+ /** The surface's own words for what a prefix must be, checked before its service is named. */
209
+ function checkSurfacePrefix(plugin: string, prefix: unknown): void {
210
+ if (typeof prefix !== "string" || prefix === "" || /[:\s]/.test(prefix))
211
+ throw new PluginError(
212
+ `plugin ${plugin}: a surface is named by the prefix of its channel keys, a non-empty word without a colon or space, such as discord; got ${JSON.stringify(prefix)}.`,
213
+ );
214
+ }
215
+
216
+ /**
217
+ * The surface as a service: it starts with the router's `handle` as its delivery, which drops
218
+ * and logs a message that belongs to another prefix, and stops with the surface.
219
+ */
220
+ function surfaceService(
221
+ surface: ChatSurface,
222
+ context: Pick<PluginContext, "logger" | "conversations">,
223
+ ): Service {
224
+ const { logger, conversations } = context;
225
+ return {
226
+ name: `surface:${surface.surface}`,
227
+ start: () =>
228
+ surface.start((message) => {
229
+ if (!message.channel.startsWith(`${surface.surface}:`)) {
230
+ logger.error(
231
+ { surface: surface.surface, channel: message.channel },
232
+ "a surface delivered a message of another prefix; dropped",
233
+ );
234
+ return;
235
+ }
236
+ void conversations.handle(message);
237
+ }),
238
+ ...(surface.stop ? { stop: () => surface.stop?.() } : {}),
239
+ };
240
+ }
241
+
132
242
  /**
133
243
  * Sets up every plugin in order and collects what each adds. A plugin that adds nothing, that
134
244
  * reuses a name, or whose setup throws or returns a part the contract does not have is refused
@@ -136,17 +246,25 @@ class Names {
136
246
  */
137
247
  export async function collectContributions(
138
248
  plugins: readonly RoundtablePlugin[],
139
- context: PluginContext,
249
+ context: Omit<PluginContext, "services">,
140
250
  tiers: ToolTierTable,
251
+ services: ServiceRegistry = new ServiceRegistry(plugins),
141
252
  ): Promise<Registry> {
142
253
  const registry = emptyRegistry();
143
- const services = new Names("service");
254
+ const serviceNames = new Names("service");
144
255
  const sessionTools = new Names("session tool");
145
256
  const holds = new Names("hold rule");
257
+ const surfaces = new Names("surface");
258
+ const personaKinds = new Map<string, string>();
259
+ const targetNames = new Map<string, string>();
146
260
  for (const plugin of plugins) {
147
261
  let contribution: Contribution;
148
262
  try {
149
- contribution = await plugin.setup(context);
263
+ contribution = await services.setUp(plugin, () =>
264
+ plugin.setup(
265
+ pluginContext(plugin, context, services.forPlugin(plugin)),
266
+ ),
267
+ );
150
268
  } catch (error) {
151
269
  if (error instanceof PluginError) throw error;
152
270
  // Some messages end in a period already, such as a NotLinkedError's.
@@ -162,53 +280,87 @@ export async function collectContributions(
162
280
  throw new PluginError(
163
281
  `plugin ${plugin.name}: setup must return an object of the parts it adds; return {} to add none.`,
164
282
  );
283
+ refuseRemovedParts(plugin.name, contribution);
165
284
  checkKeys(plugin.name, contribution);
285
+ if (contribution.events)
286
+ refuseRemovedEvents(plugin.name, contribution.events);
166
287
  const {
167
288
  events,
168
- interactions = [],
169
289
  http = [],
170
290
  holdRules = [],
171
291
  piPackages = [],
172
292
  channels = [],
293
+ surfaces: contributedSurfaces = [],
294
+ personas = [],
295
+ backgroundTargets = [],
173
296
  dashboard = [],
174
297
  tools = [],
175
298
  seeds = [],
176
299
  prompt = [],
300
+ requiredTools = [],
177
301
  } = contribution;
178
302
  const parts = [
179
303
  contribution.services ?? [],
180
- interactions,
181
304
  http,
182
305
  holdRules,
183
306
  piPackages,
184
307
  contribution.sessionTools ?? [],
185
308
  channels,
309
+ contributedSurfaces,
310
+ personas,
311
+ backgroundTargets,
186
312
  dashboard,
187
313
  tools,
188
314
  seeds,
189
315
  prompt,
316
+ requiredTools,
190
317
  contribution.agentSelection ? [contribution.agentSelection] : [],
318
+ Object.keys(contribution.toolTiers ?? {}),
191
319
  ];
192
320
  const declares =
193
321
  (plugin.migrations?.length ?? 0) > 0 ||
322
+ (plugin.provides?.length ?? 0) > 0 ||
194
323
  Object.keys(plugin.providers ?? {}).length > 0 ||
195
324
  plugin.preflight !== undefined ||
196
- plugin.useCommands !== undefined ||
197
- plugin.agentServer !== undefined ||
198
- plugin.stopTurn !== undefined;
325
+ // It works through a service it read, such as the Discord plugin's command registrar.
326
+ services.reads(plugin);
199
327
  if (!declares && !events && parts.every((part) => part.length === 0))
200
328
  throw new PluginError(
201
329
  `plugin ${plugin.name} adds nothing. Give it a part (tools, services, channels, and so on), a migration, or a provider, or remove it.`,
202
330
  );
331
+ // Its surfaces start before its own services, which may rely on the connection.
332
+ for (const surface of contributedSurfaces) {
333
+ refuseRemovedSurfaceMethods(plugin.name, surface);
334
+ checkSurfacePrefix(plugin.name, surface.surface);
335
+ surfaces.claim(plugin.name, surface.surface);
336
+ const service = surfaceService(surface, context);
337
+ serviceNames.claim(plugin.name, service.name);
338
+ registry.surfaces.push(surface);
339
+ registry.services.push(service);
340
+ registry.servicePlugins.set(service, plugin.name);
341
+ }
203
342
  for (const service of contribution.services ?? []) {
204
- services.claim(plugin.name, service.name);
343
+ serviceNames.claim(plugin.name, service.name);
205
344
  registry.services.push(service);
345
+ registry.servicePlugins.set(service, plugin.name);
206
346
  }
347
+ for (const persona of personas)
348
+ claimPersona(personaKinds, plugin.name, persona);
349
+ for (const target of backgroundTargets)
350
+ claimTarget(targetNames, plugin.name, target);
207
351
  for (const rule of holdRules) holds.claim(plugin.name, rule.name);
208
352
  for (const tool of contribution.sessionTools ?? []) {
209
353
  sessionTools.claim(plugin.name, tool.name);
210
354
  registry.sessionTools.push(tool);
211
355
  }
356
+ if (contribution.toolTiers) {
357
+ for (const [tool, tier] of Object.entries(contribution.toolTiers))
358
+ if (!(TIERS as readonly string[]).includes(tier))
359
+ throw new PluginError(
360
+ `plugin ${plugin.name}: toolTiers names tool ${tool} at tier ${JSON.stringify(tier)}; use one of ${TIERS.join(", ")}.`,
361
+ );
362
+ tiers.declare(plugin.name, contribution.toolTiers);
363
+ }
212
364
  for (const tool of tools) {
213
365
  tiers.declare(plugin.name, { [tool.name]: tool.minTier });
214
366
  sessionTools.claim(plugin.name, tool.session.name);
@@ -222,20 +374,25 @@ export async function collectContributions(
222
374
  if (contribution.agentSelection)
223
375
  registry.agentSelections.push(contribution.agentSelection);
224
376
  if (events) registry.handlers.push({ plugin: plugin.name, events });
225
- registry.interactions.push(...interactions);
226
377
  registry.routes.push(...http);
227
378
  registry.holdRules.push(...holdRules);
228
379
  registry.piPackages.push(...piPackages);
229
380
  registry.channels.push(...channels);
381
+ registry.personas.push(...personas);
382
+ registry.backgroundTargets.push(...backgroundTargets);
230
383
  registry.dashboard.push(...dashboard);
231
384
  registry.seeds.push(...seeds);
232
385
  registry.prompt.push(...prompt);
386
+ registry.requiredTools.push(...requiredTools);
233
387
  }
234
388
  return registry;
235
389
  }
236
390
 
237
391
  /** Links the collected session parts; throws PluginError on a clash. */
238
392
  export function linkSessions(registry: Registry): LinkedSessions {
393
+ const personas = new Map(
394
+ registry.personas.map((persona) => [persona.kind, persona] as const),
395
+ );
239
396
  return {
240
397
  holds: holdChain(registry.holdRules),
241
398
  // A package two plugins ask for loads once, where it was first asked for.
@@ -243,6 +400,8 @@ export function linkSessions(registry: Registry): LinkedSessions {
243
400
  plan: compileSessionPlan(registry.sessionTools),
244
401
  seeds: registry.seeds,
245
402
  prompt: registry.prompt,
403
+ persona: (kind) => personas.get(kind)?.prompt(),
404
+ requiredTools: [...new Set(registry.requiredTools)],
246
405
  agentTools: registry.tools.filter((t) => t.agent).map((t) => t.name),
247
406
  agentSelection: () => mergeSelections(registry.agentSelections),
248
407
  };
@@ -1,11 +1,17 @@
1
- import type { Judge, Providers } from "../contract/providers.ts";
1
+ import type {
2
+ Judge,
3
+ Providers,
4
+ ResolvedProviders,
5
+ } from "../contract/providers.ts";
2
6
  import { PluginError, ProviderError } from "../errors.ts";
3
7
  import { type JudgeModel, ModelJudge } from "../judging/model-judge.ts";
4
8
  import type { RoundtablePlugin } from "../plugin.ts";
9
+ import { nearest } from "./contributions.ts";
5
10
 
6
11
  const SLOTS = [
7
12
  "judge",
8
13
  "images",
14
+ "runtime",
9
15
  ] as const satisfies readonly (keyof Providers)[];
10
16
 
11
17
  /** A judge with no model: every question throws, so each caller keeps its own default. */
@@ -24,20 +30,39 @@ function defaults(judgeModel: JudgeModel | undefined): Providers {
24
30
  judge: judgeModel ? new ModelJudge(judgeModel) : unconfiguredJudge(),
25
31
  images: async () => {
26
32
  throw new ProviderError(
27
- "no image provider is configured; agents keep the neutral avatar",
33
+ "no image provider is configured; agents use generated avatars",
34
+ );
35
+ },
36
+ runtime: () => {
37
+ throw new ProviderError(
38
+ "no runtime provider is configured: the agent server builds the Pi runtime when no plugin fills the runtime slot",
28
39
  );
29
40
  },
30
41
  };
31
42
  }
32
43
 
44
+ /** Refuses a provider slot the contract does not have, naming the nearest one and the valid ones. */
45
+ function checkSlots(plugin: RoundtablePlugin): void {
46
+ for (const slot of Object.keys(plugin.providers ?? {})) {
47
+ if ((SLOTS as readonly string[]).includes(slot)) continue;
48
+ const meant = nearest(slot, SLOTS);
49
+ throw new PluginError(
50
+ `plugin ${plugin.name}: unknown provider slot "${slot}".${
51
+ meant ? ` Did you mean "${meant}"?` : ""
52
+ } The slots are ${SLOTS.join(", ")}.`,
53
+ );
54
+ }
55
+ }
56
+
33
57
  /** Each slot from the one plugin that fills it, else the core's default; two plugins filling one slot clash. */
34
58
  export function resolveProviders(
35
59
  plugins: readonly RoundtablePlugin[],
36
60
  judgeModel?: JudgeModel,
37
- ): Providers {
38
- const resolved = defaults(judgeModel);
61
+ ): ResolvedProviders {
62
+ const resolved: Providers = defaults(judgeModel);
39
63
  const filledBy = new Map<keyof Providers, string>();
40
64
  for (const plugin of plugins) {
65
+ checkSlots(plugin);
41
66
  for (const slot of SLOTS) {
42
67
  const provided = plugin.providers?.[slot];
43
68
  if (!provided) continue;
@@ -51,5 +76,10 @@ export function resolveProviders(
51
76
  Object.assign(resolved, { [slot]: provided });
52
77
  }
53
78
  }
54
- return resolved;
79
+ return { ...resolved, filled: new Set(filledBy.keys()) };
80
+ }
81
+
82
+ /** Whether a plugin fills the `images` slot, so agents can be offered drawing before any host runs. */
83
+ export function fillsImages(plugins: readonly RoundtablePlugin[]): boolean {
84
+ return plugins.some((plugin) => Boolean(plugin.providers?.images));
55
85
  }
@@ -0,0 +1,229 @@
1
+ import type { ServiceKey, Services } from "../contract/services.ts";
2
+ import { PluginError } from "../errors.ts";
3
+ import type { RoundtablePlugin } from "../plugin.ts";
4
+
5
+ /** The host's words for reading a service no plugin declares; `testPlugin` says how to give one instead. */
6
+ const HOST_ADVICE =
7
+ "Register a plugin that provides it, before the plugin that reads it.";
8
+
9
+ /** The services a plugin declares, checked before any setup: each a key with an id, once per plugin. */
10
+ function declaredBy(plugin: RoundtablePlugin): readonly ServiceKey<unknown>[] {
11
+ const { provides } = plugin;
12
+ if (provides === undefined) return [];
13
+ if (!Array.isArray(provides))
14
+ throw new PluginError(
15
+ `plugin ${plugin.name}: provides must be a list of service keys made with serviceKey().`,
16
+ );
17
+ const seen = new Set<string>();
18
+ for (const key of provides) {
19
+ if (typeof key?.id !== "string" || key.id === "")
20
+ throw new PluginError(
21
+ `plugin ${plugin.name}: provides has an entry that is not a service key; make each with serviceKey("<id>").`,
22
+ );
23
+ if (seen.has(key.id))
24
+ throw new PluginError(
25
+ `plugin ${plugin.name}: provides lists service ${key.id} twice.`,
26
+ );
27
+ seen.add(key.id);
28
+ }
29
+ return provides;
30
+ }
31
+
32
+ /** The services a plugin replaces, checked to be a list of keys; empty when it replaces none. */
33
+ function replacedBy(plugin: RoundtablePlugin): readonly ServiceKey<unknown>[] {
34
+ const { replaces } = plugin;
35
+ if (replaces === undefined) return [];
36
+ if (!Array.isArray(replaces))
37
+ throw new PluginError(
38
+ `plugin ${plugin.name}: replaces must be a list of service keys made with serviceKey().`,
39
+ );
40
+ for (const key of replaces)
41
+ if (typeof key?.id !== "string" || key.id === "")
42
+ throw new PluginError(
43
+ `plugin ${plugin.name}: replaces has an entry that is not a service key; make each with serviceKey("<id>").`,
44
+ );
45
+ return replaces;
46
+ }
47
+
48
+ /**
49
+ * Applies every plugin's `replaces`: the plugin that provides a replaced service is dropped and
50
+ * the replacement is set up where it stood. Refuses a key nobody provides, a key two plugins
51
+ * replace, a replacement that does not declare what it replaces, and a partial replacement, where
52
+ * the dropped plugin provides a service the replacement does not.
53
+ */
54
+ export function replaceServices(
55
+ plugins: readonly RoundtablePlugin[],
56
+ ): RoundtablePlugin[] {
57
+ const replacers = plugins.filter((plugin) => replacedBy(plugin).length > 0);
58
+ if (replacers.length === 0) return [...plugins];
59
+ // Only a plugin that does not replace can be the one replaced.
60
+ const providerOf = new Map<string, RoundtablePlugin>();
61
+ for (const plugin of plugins)
62
+ if (!replacers.includes(plugin))
63
+ for (const key of declaredBy(plugin))
64
+ if (!providerOf.has(key.id)) providerOf.set(key.id, plugin);
65
+ const claimed = new Map<string, RoundtablePlugin>();
66
+ const dropped = new Map<RoundtablePlugin, RoundtablePlugin>();
67
+ for (const plugin of replacers) {
68
+ const own = new Set(declaredBy(plugin).map((key) => key.id));
69
+ for (const key of replacedBy(plugin)) {
70
+ const other = claimed.get(key.id);
71
+ if (other)
72
+ throw new PluginError(
73
+ `plugin ${plugin.name}: service ${key.id} is also replaced by plugin ${other.name}. Keep one replacement per service.`,
74
+ );
75
+ claimed.set(key.id, plugin);
76
+ if (!own.has(key.id))
77
+ throw new PluginError(
78
+ `plugin ${plugin.name}: replaces service ${key.id} but does not list it in provides. Provide what you replace.`,
79
+ );
80
+ const provider = providerOf.get(key.id);
81
+ if (!provider)
82
+ throw new PluginError(
83
+ `plugin ${plugin.name}: replaces service ${key.id}, which no other registered plugin provides. Remove it from replaces, or register the plugin that provides it.`,
84
+ );
85
+ dropped.set(provider, plugin);
86
+ }
87
+ }
88
+ for (const [provider, replacement] of dropped) {
89
+ const replaced = new Set(replacedBy(replacement).map((key) => key.id));
90
+ const missing = declaredBy(provider).filter((key) => !replaced.has(key.id));
91
+ if (missing.length > 0)
92
+ throw new PluginError(
93
+ `plugin ${replacement.name}: replacing plugin ${provider.name} would drop ${missing.map((key) => key.id).join(", ")} too, which it also provides. Replace every service of ${provider.name}, or none.`,
94
+ );
95
+ }
96
+ // The replacement takes the place of the first plugin it drops, not the place it was listed in.
97
+ const result: RoundtablePlugin[] = [];
98
+ for (const plugin of plugins) {
99
+ if (replacers.includes(plugin)) continue;
100
+ const replacement = dropped.get(plugin);
101
+ if (!replacement) result.push(plugin);
102
+ else if (!result.includes(replacement)) result.push(replacement);
103
+ }
104
+ return result;
105
+ }
106
+
107
+ /** What to do about a service whose provider has not set up yet: the order the plugins are registered in. */
108
+ function orderHint(
109
+ provider: RoundtablePlugin,
110
+ reader: RoundtablePlugin | undefined,
111
+ ): string {
112
+ if (reader === undefined) return "";
113
+ if (reader === provider)
114
+ return ` Plugin ${provider.name} reads it before its own setup has provided it.`;
115
+ return ` Register plugin ${provider.name} before plugin ${reader.name}.`;
116
+ }
117
+
118
+ /**
119
+ * The services the plugins provide and read. It knows from `provides` which plugin declares each
120
+ * key before any setup, so a read can tell a service switched off from one that is not set up yet.
121
+ */
122
+ export class ServiceRegistry {
123
+ readonly #declaredBy = new Map<string, RoundtablePlugin>();
124
+ readonly #values = new Map<string, unknown>();
125
+ readonly #advice: string;
126
+ /** The plugin whose setup is running, the only one that may provide. */
127
+ #setting: RoundtablePlugin | undefined;
128
+ /** The plugins that have read a service, such as one that adds its commands through it. */
129
+ readonly #readers = new Set<RoundtablePlugin>();
130
+
131
+ /** `advice` says how to get a service no plugin declares; the host's is to register one that does. */
132
+ constructor(plugins: readonly RoundtablePlugin[], advice = HOST_ADVICE) {
133
+ this.#advice = advice;
134
+ for (const plugin of plugins)
135
+ for (const key of declaredBy(plugin)) {
136
+ const other = this.#declaredBy.get(key.id);
137
+ if (other)
138
+ throw new PluginError(
139
+ `plugin ${plugin.name}: service ${key.id} is also provided by plugin ${other.name}. Keep one provider, or have one plugin replace the other with replaces.`,
140
+ );
141
+ this.#declaredBy.set(key.id, plugin);
142
+ }
143
+ }
144
+
145
+ /** Provides a service the host or a test holds itself, without a plugin's setup. */
146
+ preset<T>(key: ServiceKey<T>, value: T): void {
147
+ this.#values.set(key.id, value);
148
+ }
149
+
150
+ /** The service, for the host's own reads; throws like a plugin's `get`. */
151
+ get<T>(key: ServiceKey<T>): T {
152
+ return this.#read(key, undefined, true) as T;
153
+ }
154
+
155
+ find<T>(key: ServiceKey<T>): T | undefined {
156
+ return this.#read(key, undefined, false);
157
+ }
158
+
159
+ #read<T>(
160
+ key: ServiceKey<T>,
161
+ reader: RoundtablePlugin | undefined,
162
+ required: boolean,
163
+ ): T | undefined {
164
+ if (this.#values.has(key.id)) return this.#values.get(key.id) as T;
165
+ const provider = this.#declaredBy.get(key.id);
166
+ if (provider) {
167
+ throw new PluginError(
168
+ `service ${key.id} is not provided yet; plugin ${provider.name} provides it.${orderHint(provider, reader)}`,
169
+ );
170
+ }
171
+ if (!required) return undefined;
172
+ throw new PluginError(
173
+ `service ${key.id} is not provided. ${key.absent ?? this.#advice}`,
174
+ );
175
+ }
176
+
177
+ /** The view one plugin's setup gets: its reads name it, and only it may provide what it declares. */
178
+ forPlugin(plugin: RoundtablePlugin): Services {
179
+ return {
180
+ get: <T>(key: ServiceKey<T>) => {
181
+ this.#readers.add(plugin);
182
+ return this.#read(key, plugin, true) as T;
183
+ },
184
+ find: <T>(key: ServiceKey<T>) => {
185
+ this.#readers.add(plugin);
186
+ return this.#read(key, plugin, false);
187
+ },
188
+ provide: (key, value) => {
189
+ if (this.#setting !== plugin)
190
+ throw new PluginError(
191
+ `plugin ${plugin.name}: services are provided from setup only; provide ${key.id} while setup runs.`,
192
+ );
193
+ if (this.#declaredBy.get(key.id) !== plugin)
194
+ throw new PluginError(
195
+ `plugin ${plugin.name}: cannot provide service ${key.id}, which it does not declare. Add it to the plugin's provides.`,
196
+ );
197
+ if (this.#values.has(key.id))
198
+ throw new PluginError(
199
+ `plugin ${plugin.name}: service ${key.id} is provided twice.`,
200
+ );
201
+ this.#values.set(key.id, value);
202
+ },
203
+ };
204
+ }
205
+
206
+ /** Whether the plugin read a service: it then works through that service, even when it returns no part. */
207
+ reads(plugin: RoundtablePlugin): boolean {
208
+ return this.#readers.has(plugin);
209
+ }
210
+
211
+ /** Runs a plugin's setup with `provide` open to it, and refuses it when it declared a service it did not provide. */
212
+ async setUp<T>(
213
+ plugin: RoundtablePlugin,
214
+ setup: () => Promise<T> | T,
215
+ ): Promise<T> {
216
+ this.#setting = plugin;
217
+ try {
218
+ const result = await setup();
219
+ for (const key of declaredBy(plugin))
220
+ if (!this.#values.has(key.id))
221
+ throw new PluginError(
222
+ `plugin ${plugin.name}: declares that it provides service ${key.id}, but setup did not provide it. Call services.provide(...) in setup, or remove it from provides.`,
223
+ );
224
+ return result;
225
+ } finally {
226
+ this.#setting = undefined;
227
+ }
228
+ }
229
+ }
@@ -42,3 +42,8 @@ export class ChannelQueue {
42
42
  return [...this.#sizes.keys()];
43
43
  }
44
44
  }
45
+
46
+ /** A queue of its own, apart from the host's; for work that must not wait behind a running turn. */
47
+ export function channelQueue(): ChannelQueue {
48
+ return new ChannelQueue();
49
+ }