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
package/docs/plugins.md CHANGED
@@ -10,7 +10,8 @@ Each example has a test next to it that runs it without Discord or PostgreSQL, e
10
10
 
11
11
  A plugin is an object with a name and a `setup` function.
12
12
  `setup` returns the parts the plugin adds to the bot: tools agents can call, text added to their prompt, agents to create, handlers for events, long-lived services, slash commands, HTTP routes, and so on.
13
- The bot itself is assembled from plugins too: the core ships built-in plugins for its stores, Discord connection, agent server, skills, memory, notifications, delegation, and schedules, and yours are added after them.
13
+ The bot itself is assembled from plugins too: the core ships built-in plugins for its memory and schedule stores, Discord connection, agent server, notifications, delegation, and schedules, and three [addons](#addons-memory-skills-and-discord-administration) you can switch off (memory, skills, and Discord administration), and yours are added after them.
14
+ The built-ins share what they build through [keyed services](#services-what-plugins-provide-to-each-other), and a plugin of yours can read them or replace them.
14
15
 
15
16
  You write plugins in TypeScript, list them in `roundtable.config.ts`, and Bun loads them directly.
16
17
  There is no build step and no plugin registry: importing a plugin is how you install it.
@@ -26,12 +27,38 @@ export default {
26
27
  } satisfies RoundtableConfig;
27
28
  ```
28
29
 
29
- Everything a plugin author needs comes from two entries, and nothing else can be imported from the package:
30
+ Everything a plugin author needs comes from four entries, and nothing else can be imported from the package:
30
31
 
31
32
  | Entry | What it exports |
32
33
  |---|---|
33
34
  | `pi-roundtable` | `definePlugin`, `defineTool`, `defineRoundtable`, `ToolRefusal`, `PluginError`, `NotLinkedError`, `Roundtable`, and the types (`Tier`, `Speaker`, `Contribution`, `PluginContext`, and so on) |
34
- | `pi-roundtable/testing` | `testPlugin`, the harness that runs a plugin against a fake context |
35
+ | `pi-roundtable/testing` | Fixtures for testing plugins, without Discord or a database unless the test explicitly opens one; `testHost` names a few discord.js types (`ComposedCommands`, `CommandGuard`, `InteractionModule`, `RootOption`) so a test can drive the composed slash commands |
36
+ | `pi-roundtable/kit` | Unstable helpers for channel claims, tools, presentation, and naming existing core parts |
37
+ | `pi-roundtable/discord` | Unstable, and the entry built on discord.js types: the `DISCORD` service (slash commands, the owner guard), owner-command and panel helpers, the agent panel, and the channel-operation tables |
38
+
39
+ ### Advanced building blocks
40
+
41
+ Start with the main entry and the context's built-in services.
42
+ The kit is unstable before 1.0 and is not covered by semver.
43
+ Use `pi-roundtable/kit` for channel claims, tool and presentation helpers, and the types of the core's existing parts.
44
+ Use `pi-roundtable/discord` for everything that touches Discord: slash commands, owner-command modules and panels, the agent panel, and the channel-operation tables.
45
+ The main and kit entries name no discord.js type (a test checks their declarations), so a plugin that does not talk to Discord never depends on it.
46
+ Neither exports a class of a built-in service: read the service through its key, or provide your own that keeps to the port.
47
+ Their names are grouped by area in the source, but there is one entry each; directory paths and files under `src/core` are internal.
48
+ The [changelog](../CHANGELOG.md) lists every exported name, including type-only contracts.
49
+ Fixtures and fake threads belong to `pi-roundtable/testing`, not production imports.
50
+
51
+ #### Helpers for a Pi session of your own
52
+
53
+ A plugin that runs Pi itself, a coding worker or a container with no network, takes its building blocks from the kit instead of rebuilding them:
54
+
55
+ - MCP: `mcpExtension` and `VirtualServer` expose MCP servers to a session, and `mcpAdapterExtension` and `readAttachmentExtension` do the same inside an out-of-process worker.
56
+ - Work: `promptSlot` (how a run asks the owner while it works), `workTimeout` (a time limit that does not count the time spent waiting on the owner), `runWorkerTask`, `archiveSessions`, and `approvalCard` and `canonicalJson` for the cards of held actions.
57
+ - Shell: `SHELL_TOOLS` and `shellHoldRule`, the hold rule that keeps risky host-shell commands behind the owner's approval.
58
+ - Tools: `textToolsExtension`, `requiredString`, `stringList` (with `toolText` and `toolError`) for tools that return text.
59
+ - Mirroring a built-in tool in a worker that cannot reach the host: `SCHEDULE_TOOLS`, `scheduleToolSpecs({ locale, timeZone })`, `isScheduleTool`, `callScheduleTool`, `DELEGATE_TOOL` and `DELEGATE_TOOL_SPEC`. The specs take the locale and time zone their descriptions are written in, so the worker needs no process-wide setting.
60
+ - Effort: `effortJudge` picks a turn's thinking level from a message with your own brief (`EffortBrief`, `JUDGE_WORK`).
61
+ - Presentation and small helpers: `thinkingLine`, `zonedStamp(date, timeZone)`, `channelQueue()` (a queue of your own, so work does not wait behind a running turn), `checkRepoName` and `SKILL_LIST_TOOL` with `skillListExtension` for repositories and skills, and `searchTerms` for memory search.
35
62
 
36
63
  `roundtable add plugin <name>` creates `plugins/<name>.ts` and its test from a small template and lists it in `roundtable.config.ts`.
37
64
  The name is lowercase words joined by dashes, such as `my-notes`.
@@ -43,6 +70,8 @@ definePlugin({
43
70
  name: "my-notes", // lowercase words joined by dashes; unique across all plugins
44
71
  migrations: [], // optional: tables the plugin needs
45
72
  providers: {}, // optional: replaces a part the core runs on
73
+ provides: [], // optional: keys of the services setup provides to other plugins
74
+ replaces: [], // optional: keys of built-in services this plugin takes over
46
75
  preflight() {}, // optional: a check that runs before anything starts
47
76
  setup(context) { // required: returns the parts the plugin adds
48
77
  return { /* parts */ };
@@ -61,16 +90,180 @@ A plugin whose `setup` returns `{}` and that has no migrations, providers, or ho
61
90
 
62
91
  | Field | What it is |
63
92
  |---|---|
64
- | `logger` | A pino logger; each line is JSON on stdout |
93
+ | `logger` | The host's logger with `plugin: <your name>` on every line; each line is JSON on stdout. Its type is `Logger` (`debug`, `info`, `warn`, `error`, `fatal`, `child(fields)`), which a pino logger satisfies |
94
+ | `env` | The host's own environment for the run: `locale`, `timeZone` (an IANA zone), and `now()`; read the zone from here rather than from the process |
65
95
  | `database()` | The host's one PostgreSQL connection (a Bun `SQL`), already migrated |
66
96
  | `toolTiers` | What each tool needs; ask it when a tool is used, not during setup |
67
97
  | `events` | Where the core reports turns and team changes to every plugin's handlers |
68
- | `providers` | Each provider slot, from the plugin that fills it or the core's default |
98
+ | `providers` | Each provider slot, from the plugin that fills it or the core's default; `providers.filled` is the set of slots a plugin fills |
69
99
  | `queue` | The one channel queue that every conversation and channel operation shares |
70
- | `core` | What the built-in plugins built (stores, the Discord surface, the team, the runtime), for advanced plugins |
71
- | `sessions()`, `conversations`, `dashboard()` | Linked once every plugin has been set up; calling them during `setup` throws `NotLinkedError` |
100
+ | `services` | The services plugins provide to each other, read by key: `services.get(SCHEDULES)`; see [services](#services-what-plugins-provide-to-each-other) |
101
+ | `surfaces` | Every contributed [chat surface](#surfaces-a-chat-network-of-your-own), chosen by the prefix of a channel key: `of`, `sendReply`, `startTyping`, `showStop`, `react`, `unreact`, `prompts` |
102
+ | `turns` | Runs one turn of a conversation your claim owns, over the runtime and the surfaces: [`turns.run`](#personas-and-contextturns-conversations-of-a-kind-of-your-own) |
103
+ | `sessions()`, `conversations`, `surfaces`, `turns`, `dashboard()` | Linked once every plugin has been set up; calling them during `setup` throws `NotLinkedError` |
72
104
 
73
- Use `sessions()`, `conversations`, and `dashboard()` from a service's `start` or from an event handler, not from `setup`.
105
+ Use `sessions()`, `conversations`, `surfaces`, `turns`, and `dashboard()` from a service's `start`, from an event handler, or from a claim's turn, not from `setup`.
106
+
107
+ Use main's `QueuePort` for `context.queue`; the kit's `ChannelQueue` is also type-only.
108
+
109
+ `context.core` of 0.1.0 is gone: reading it throws a `PluginError` that names `context.services`.
110
+
111
+ ### Logging and error reports
112
+
113
+ Every plugin's `context.logger` is a child of the host's logger that adds `plugin: <your plugin's name>` to each line it writes, so a line in the journal says which plugin wrote it.
114
+ `Logger` is a small interface of the five levels and `child(fields)`; a pino logger satisfies it, so a process that already has one passes it as `DefineOverrides.logger`.
115
+
116
+ With `defineRoundtable`, the logger the host builds hands every `error` and `fatal` line to the ops agent named by `config.ops.agent`, which reports it in its channel, and then to `DefineOverrides.errorSink(entry)` when you give one (a plain function that must not throw).
117
+ A logger you pass yourself is yours: it does not reach the ops agent or `errorSink` unless it forwards its error lines there.
118
+ The report names the plugin next to `app` and `module`; the same error is still reported at most once an hour, whichever plugin wrote it.
119
+
120
+ ### Services: what plugins provide to each other
121
+
122
+ A service is something one plugin builds and others read, such as the schedule store or the agent team.
123
+ Each has a key made with `serviceKey<T>(id)`, and `T` is an interface, a port: an object with the same methods is a service, so you need no built-in class to provide one or to fake one in a test.
124
+ A plugin lists the keys it provides in `provides` and provides each from `setup` with `services.provide(KEY, value)`; the host reads the list before any setup, so it knows who declares what.
125
+
126
+ | Method | What it does |
127
+ |---|---|
128
+ | `services.get(KEY)` | The service, or a `PluginError` that names the key and the plugin to register first when it is not provided yet |
129
+ | `services.find(KEY)` | The service, or `undefined` when no registered plugin declares it, such as an addon that is off; it throws like `get` when a plugin declares it but has not set up yet, because that is order, not absence |
130
+ | `services.provide(KEY, value)` | Only from setup, only for a key the plugin declares in `provides`, once per key |
131
+
132
+ A plugin that declares a key and does not provide it is refused when its `setup` returns, and two plugins that declare one key are refused before any setup.
133
+
134
+ The built-in plugins provide these, from the main entry:
135
+
136
+ | Key | Port | Provided by | What it is |
137
+ |---|---|---|---|
138
+ | `AGENTS` | `AgentServer` | `agent-server` | The `team` (`AgentTeam`), the read-only `directory` (`AgentDirectory`), the `runtime` every agent turn runs on, `approvals` (whether the owner's reply approves held actions), and `avatars` (`AvatarStudio`) |
139
+ | `SKILLS` | `SkillRegistry` | `skills` (an addon) | What agents carry: `carried`, `carriedNames`, `describeCarried`, `catalog`, `list`, `linkedFrom`, `checkRegistered`, `link`, `attach` |
140
+ | `SCHEDULES` | `ScheduleStore` | `schedule-store` | The stored schedules: `create`, `get`, `forChannel`, `all`, `update`, `remove`, `due`, `claim`, `recordStatus` |
141
+ | `MEMORY` | `MemoryStore` | `memory` (an addon) | `forSpeaker(id)` gives that speaker's `SpeakerMemory`: `list`, `forPrompt`, `add`, `search`, `update`, `removeById`, `remove`; `MEMORY_KINDS` is `core`, `note`, `event` |
142
+ | `BACKGROUND_TURNS` | `BackgroundTurns` | `modules` | Turns nobody wrote: `runScheduled`, `runDelegated`, `runErrorReport` |
143
+ | `DELEGATION` | `Delegator` | `modules` | `start(request)` a background task, `runningChannels()`, `idle()` |
144
+
145
+ The data types the ports use (`Schedule`, `NewSchedule`, `Agent`, `AgentGroup`, `TeamStatus`, `Memory`, `SkillSet`, `DelegationJob`, and so on) are in the main entry too.
146
+ Your plugins run after the built-ins, so they can read every key above.
147
+ The Discord connection's key, `DISCORD`, is in the Discord entry, because its port names discord.js types: `DiscordServices` has `connection`, `commands`, `guard`, and `threads` (see [`commands.add`](#slash-commands-commandsadd)).
148
+
149
+ A plugin of yours shares a service the same way: the key is a constant you export, the port is an interface you export, and plugins registered after yours read it.
150
+ A plugin registered before yours cannot, and says so: `service <id> is not provided yet; plugin <yours> provides it. Register plugin <yours> before plugin <reader>.`
151
+
152
+ <!-- example: examples/shared-services.ts -->
153
+ ```ts
154
+ import { definePlugin, serviceKey } from "pi-roundtable";
155
+
156
+ /** What the notes plugin offers other plugins: a port, so any object with these methods will do. */
157
+ export interface NoteIndex {
158
+ add(text: string): void;
159
+ all(): readonly string[];
160
+ }
161
+
162
+ /** The key is the service's name. Give its id a prefix of your own; two keys with one id are one service. */
163
+ export const NOTE_INDEX = serviceKey<NoteIndex>("my-notes.index");
164
+
165
+ /** A plugin lists the services it provides, then provides each from `setup`. */
166
+ export function notes() {
167
+ const stored: string[] = [];
168
+ return definePlugin({
169
+ name: "my-notes",
170
+ provides: [NOTE_INDEX],
171
+ setup: ({ services }) => {
172
+ services.provide(NOTE_INDEX, {
173
+ add: (text) => void stored.push(text),
174
+ all: () => stored,
175
+ });
176
+ return { services: [{ name: "notes-ready" }] };
177
+ },
178
+ });
179
+ }
180
+
181
+ /** A plugin registered after it reads the service; `find` is undefined when nobody provides it. */
182
+ export function noteReader(
183
+ onNotes: (notes: readonly string[] | undefined) => void,
184
+ ) {
185
+ return definePlugin({
186
+ name: "note-reader",
187
+ setup: ({ services }) => ({
188
+ services: [
189
+ {
190
+ name: "note-reader",
191
+ start: () => onNotes(services.find(NOTE_INDEX)?.all()),
192
+ },
193
+ ],
194
+ }),
195
+ });
196
+ }
197
+
198
+ /** A plugin that provides the same key and lists it in `replaces` takes the place of the one before it. */
199
+ export function shoutingNotes() {
200
+ const stored: string[] = [];
201
+ return definePlugin({
202
+ name: "shouting-notes",
203
+ provides: [NOTE_INDEX],
204
+ replaces: [NOTE_INDEX],
205
+ setup: ({ services }) => {
206
+ services.provide(NOTE_INDEX, {
207
+ add: (text) => void stored.push(text.toUpperCase()),
208
+ all: () => stored,
209
+ });
210
+ return { services: [{ name: "shouting-notes-ready" }] };
211
+ },
212
+ });
213
+ }
214
+ ```
215
+ <!-- /example -->
216
+
217
+ #### Replacing a built-in service
218
+
219
+ A plugin that lists a key in both `provides` and `replaces` takes over that service: the host drops the plugin that provides the key and sets the replacement up where it stood, so the replacement may read what the plugins before that place provide and nothing after.
220
+ The dropped plugin's migrations and setup do not run.
221
+ To replace the schedule store, implement `ScheduleStore`, provide it under `SCHEDULES`, and replace it:
222
+
223
+ ```ts
224
+ definePlugin({
225
+ name: "my-schedules",
226
+ provides: [SCHEDULES],
227
+ replaces: [SCHEDULES],
228
+ migrations: [/* your own tables */],
229
+ setup: ({ services }) => {
230
+ services.provide(SCHEDULES, myStore);
231
+ return {};
232
+ },
233
+ });
234
+ ```
235
+
236
+ The host refuses a replacement that cannot be made whole:
237
+
238
+ | Message | Fix |
239
+ |---|---|
240
+ | `plugin <name>: replaces service <id>, which no other registered plugin provides.` | Register the plugin that provides it, or drop the key from `replaces` |
241
+ | `plugin <name>: service <id> is also replaced by plugin <other>.` | Keep one replacement per service |
242
+ | `plugin <name>: replaces service <id> but does not list it in provides.` | Provide what you replace |
243
+ | `plugin <name>: replacing plugin <built-in> would drop <id> too, which it also provides.` | Replace every service of that plugin, or none; `BACKGROUND_TURNS` and `DELEGATION` come from the one `modules` plugin, so they are replaced together |
244
+
245
+ #### Addons: memory, skills, and Discord administration
246
+
247
+ Three features are built-in plugins of their own that the host adds unless the configuration switches them off.
248
+ They are on by default, so a bot that says nothing about them has all three.
249
+
250
+ | Addon | Plugin | Switch in `roundtable.config.ts` | What it adds | When it is off |
251
+ |---|---|---|---|---|
252
+ | Memory | `memory` (provides `MEMORY`) | `memory: false` | The memory table, the `memory_add`, `memory_search` and `memory_remove` tools, and the memory block of every system prompt | No memory tools and no block; the table is left as it is |
253
+ | Skills | `skills` (provides `SKILLS`) | `skills: false` | The skill tables, the skill tools of agent sessions (`skill_link`, `skill_create`, `agent_skills`, and the rest), and the skills every agent carries, `writing-skills` included | No skill tools and no skills in any session; `agent_get` has no skills line; `agent_create` leaves out its `skills` parameter and refuses a call that passes some with `Skills are off on this host`; the tables are left as they are |
254
+ | Discord administration | `discord-admin` | `discord: { admin: false }` | The `discord_*` tools that read and manage the server, for the owner | No `discord_*` tools; the channel executor that remote MCP uses is the connection's, so it stays |
255
+
256
+ A switched-off addon's tables and rows are never touched: turning it on again finds them as they were.
257
+ `skills` also takes the two directories (`skills: { builtinDir, reposDir }`) when it is on.
258
+
259
+ A plugin of yours that can work without an addon reads its service with `find`, which is `undefined` while the addon is off; one that cannot uses `get`, which fails with a message naming the switch:
260
+
261
+ ```text
262
+ service roundtable.memory is not provided. The memory addon is switched off (config memory: false). Switch it on, or provide the service from a plugin of your own.
263
+ ```
264
+
265
+ `serviceKey(id, { absent })` lets your own keys say the same.
266
+ Switching an addon off and adding a plugin of yours that provides the same key is equivalent to `replaces`.
74
267
 
75
268
  ## Tiers: who may use what
76
269
 
@@ -81,6 +274,7 @@ An operator who opens the bot to others lists them under `speakers` in `roundtab
81
274
  A tool must say which tier may call it.
82
275
  The tool is offered only in turns whose speaker is at that tier or above, and the operator's `toolTiers` setting can override what the plugin chose.
83
276
  A tool nobody named needs the owner.
277
+ A plugin that adds raw session tools names their tiers with `toolTiers`; the core's own table names only `ask_user`, `compact_session` and `read_attachment`.
84
278
 
85
279
  ## The parts
86
280
 
@@ -265,9 +459,9 @@ export const library = definePlugin({
265
459
 
266
460
  | Handler | Runs when |
267
461
  |---|---|
268
- | `agentServer(outcome)` | The agent server has started (`"ready"`) or failed to (`"failed"`); the rest of the process runs either way |
269
- | `turnStarted(turn)` | An agent's turn began |
270
- | `turnEnded(turn)` | An agent's turn ended; `turn.result` is `"ok"`, `"failed"`, or `"stopped"` |
462
+ | `serviceStarted(event)` | A service's `startInBackground` ended: `event` is `{ plugin, service, outcome }`, and `outcome` is `"ready"` or `"failed"`; the rest of the process runs either way |
463
+ | `turnStarted(turn)` | An agent's turn began, or a turn run through `context.turns` |
464
+ | `turnEnded(turn)` | The turn ended; `turn.result` is `"ok"`, `"failed"`, or `"stopped"` |
271
465
  | `changed()` | The team changed: an agent or group was created, edited, arranged, archived, or started over |
272
466
  | `shutdown(left)` | The shutdown drain ended, before any service stops; `left` lists the work it gave up on |
273
467
 
@@ -284,10 +478,10 @@ export function turnLog(lines: string[]) {
284
478
  setup: () => ({
285
479
  events: {
286
480
  turnStarted: (turn) => {
287
- lines.push(`${turn.agent} started`);
481
+ lines.push(`${turn.agent ?? turn.kind} started`);
288
482
  },
289
483
  turnEnded: (turn) => {
290
- lines.push(`${turn.agent} ${turn.result}`);
484
+ lines.push(`${turn.agent ?? turn.kind} ${turn.result}`);
291
485
  },
292
486
  changed: () => {
293
487
  lines.push("team changed");
@@ -303,7 +497,12 @@ export function turnLog(lines: string[]) {
303
497
  ```
304
498
  <!-- /example -->
305
499
 
306
- A test calls a handler itself with the payload the core sends: a turn is `{ agent, channel, speaker }` (plus `group` for a member's turn in a group), `turnEnded` adds `result`, `changed` takes nothing, and `shutdown` takes the list of unfinished work, which `harness.stop()` delivers as an empty one.
500
+ `serviceStarted` names the plugin and the service, so a plugin hears the agent server's start as `AGENT_SERVER_PLUGIN` and `AGENT_TEAM_SERVICE` (`"agent-server"` and `"team"`) with `outcome === "ready"`: by then the agents' channels and the dashboard are up.
501
+ A plugin that has to act after the team is running, such as posting a notice, waits for that event instead of posting while it starts.
502
+
503
+ A turn event has a `kind`: `"agent"` for an agent's turn, else the kind the turn ran as (`"study"` in the example below).
504
+ `agent` is the agent's name and is absent for a turn of another kind.
505
+ A test calls a handler itself with the payload the core sends: a turn is `{ agent, kind, channel, speaker }` (plus `group` for a member's turn in a group), `turnEnded` adds `result`, `changed` takes nothing, and `shutdown` takes the list of unfinished work, which `harness.stop()` delivers as an empty one.
307
506
 
308
507
  <!-- example: examples/events.test.ts -->
309
508
  ```ts
@@ -318,6 +517,7 @@ test("the handlers record a turn, a team change, and the shutdown", async () =>
318
517
  // The harness does not deliver the core's events: call the handlers with the payload the core sends.
319
518
  const turn = {
320
519
  agent: "guide",
520
+ kind: "agent",
321
521
  channel: "discord:1",
322
522
  speaker: undefined,
323
523
  } as const;
@@ -341,6 +541,24 @@ test("the handlers record a turn, a team change, and the shutdown", async () =>
341
541
  A service has a name and optional `start`, `stop`, and `busy`.
342
542
  Services start once everything is set up and stop in reverse order.
343
543
  `busy` lists the work still running, one entry each; a shutdown waits until every service's list is empty, so a deploy never cuts work short.
544
+ The host adds its own channel queue to that wait, so a surface that queues its turns through `context.queue` needs no `busy` for them.
545
+
546
+ A service may also have `startInBackground`.
547
+ The host runs every service's background start once all the `start`s are done and the HTTP listeners are open, and does not wait for it: the boot has already finished.
548
+ A background start that throws is logged and never fatal, and the other services' background starts still run.
549
+ Either way every plugin hears `serviceStarted` with `ready` or `failed`, after the start has done everything it does, so a service that brings something up in the background can be relied on once its event says `ready`.
550
+
551
+ ```ts
552
+ services: [
553
+ {
554
+ name: "warm-index",
555
+ // Runs after the boot, so a slow start never holds up the listeners.
556
+ startInBackground: async () => {
557
+ await buildIndex();
558
+ },
559
+ },
560
+ ],
561
+ ```
344
562
 
345
563
  Use a service for anything with a lifetime: a timer, a queue, a connection.
346
564
  There is no separate part for schedules: agents create schedules with the built-in `schedule_create` tool, the built-in scheduler fires them, and a scheduled turn is an ordinary agent turn that can call your tools.
@@ -385,8 +603,25 @@ export function heartbeat(everyMs: number, beat: () => Promise<void> | void) {
385
603
 
386
604
  `migrations` sits on the plugin, not in what `setup` returns.
387
605
  Every start runs every plugin's migrations, in plugin order, before any `setup`, so a table is there when `setup` asks for the database.
388
- A migration is `{ name, up(sql) }`, and `up` must be idempotent (`CREATE TABLE IF NOT EXISTS`) because it runs again over the existing schema on every start.
389
- Migration names and table names are shared with the core and with every other plugin, so prefix them with your plugin's name.
606
+ A migration is `{ name, runs?, up(sql) }`.
607
+ Table names are shared with the core and with every other plugin, so prefix them with your plugin's name; a migration's own name only has to be unique inside its plugin.
608
+
609
+ The host keeps a ledger, the table `roundtable_migrations`, which it creates itself.
610
+ A migration is recorded there under the id `<plugin>/<name>`, for example `stores/visit-counter`, and the ids are the ledger's keys: renaming a plugin or a migration makes its migrations run again, so keep both names once a database holds them.
611
+
612
+ - `runs: "once"` (the default) runs the migration one time over a database and records it.
613
+ It runs in its own transaction under an advisory lock, and the ledger row is written in that same transaction, so a migration that fails leaves nothing behind, and two hosts starting at once apply it once between them.
614
+ A `db.begin(...)` inside `up` becomes a savepoint of that transaction.
615
+ Write DDL that PostgreSQL can run in a transaction (no `CREATE INDEX CONCURRENTLY`).
616
+ - `runs: "every-boot"` runs at every start and is never recorded, so `up` must be idempotent (`CREATE TABLE IF NOT EXISTS`, `UPDATE ... WHERE` a condition that stops matching).
617
+ Use it for a migration that converges data another build may write since, such as a move from a table an older version still writes.
618
+
619
+ A start logs one `migrations` line with the ids applied now, the number skipped, and the number that ran every boot.
620
+ Adding a ledger to a database that already ran the migrations is safe: no id is recorded yet, so each `once` migration runs one more time, as every start did before, and is recorded.
621
+ `roundtable doctor` runs the same runner inside a transaction it rolls back, so it checks exactly what a start would run.
622
+
623
+ `migrateDatabase(url, plugins)` (main entry) runs the plugins' migrations over a database with the same ledger and lock, then closes its connection, and returns a `MigrationReport` (`applied`, `skipped`, `everyBoot`, each a list of ids).
624
+ Use it in a test or a script that needs the tables before the host runs, and pass the same plugins, with the same names, the host runs.
390
625
 
391
626
  `testPlugin` gives your plugin the database you pass it but does not run its migrations; run them yourself first, as this example's test does.
392
627
  This is the one example whose test needs PostgreSQL: it is skipped unless `ROUNDTABLE_TEST_DATABASE_URL` is set.
@@ -485,12 +720,17 @@ test.skipIf(!url)(
485
720
  ### `providers`: replace a part the core runs on
486
721
 
487
722
  `providers` also sits on the plugin.
488
- There are two slots, and one plugin may fill each.
723
+ There are three slots, and one plugin may fill each; a slot that is not one of these, such as a misspelled name, stops the start with an error that names the valid slots.
489
724
 
490
725
  | Slot | The core's default | Your replacement |
491
726
  |---|---|---|
492
727
  | `judge` | Asks the configured model small questions: does this reply approve the held actions, how hard is this turn, which agents does this group message concern | An object with `askYesNo`, `askChoice`, and `askScore` |
493
- | `images` | No drawing; agents keep the neutral avatar | `async (prompt, references) => bytes` returning PNG bytes |
728
+ | `images` | No drawing; each agent gets an avatar generated from its display name | `async (prompt, references) => bytes` returning PNG bytes |
729
+ | `runtime` | None: the agent server builds the Pi runtime itself | `(deps) => runtime`, an [`AgentRuntime`](#the-runtime-slot-replace-pi) that runs every conversation |
730
+
731
+ Without an `images` provider, agents are not offered drawing: `agent_create` takes no `avatar_prompt`, there is no `agent_avatar` tool, and the owner's profile panel says no image provider is configured instead of offering to redraw.
732
+ Each agent still gets a picture, generated from its display name and the assistant's icon, so agents stay easy to tell apart.
733
+ `bunx roundtable doctor` reports whether the slot is filled.
494
734
 
495
735
  <!-- example: examples/providers.ts -->
496
736
  ```ts
@@ -514,6 +754,273 @@ export const pixelAvatars = definePlugin({
514
754
  ```
515
755
  <!-- /example -->
516
756
 
757
+ ### The `runtime` slot: replace Pi
758
+
759
+ The runtime runs the conversations of the agent server, and of every claim that runs turns through `context.turns`: one persistent conversation per channel key, its turns, its held actions, and its history.
760
+ By default the agent server builds the Pi runtime.
761
+ A plugin that fills the `runtime` slot replaces the whole of it: agent turns, the owner's conversations, steering, held actions, and transcripts then run on the plugin's runtime, and Pi is never built.
762
+ The default of the slot is a factory that refuses, so a plugin that calls `providers.runtime` checks `providers.filled.has("runtime")` first; the agent server does this for you.
763
+
764
+ The slot is a `RuntimeFactory`: `(deps: RuntimeDeps) => AgentRuntime`, called once when the agent server sets up.
765
+ `deps` has what a runtime needs from the host: `logger`, `env`, the `owner`, `sessions()` (the linked hold rules, packages, session tools and personas, available from the preflight on, so call it in a turn, not in the factory), `toolTiers`, `prompts(conversation, speaker)` (the owner's approval and question cards on the conversation's surface, or `undefined`), `agents` (the agent server's per-agent settings: `workDir`, `skills(name)`, `modelOf(name)`, `turnChannel(scope)`), `confirmations` (where held actions persist across a restart), and the host's `judge`.
766
+
767
+ An `AgentRuntime` has these methods:
768
+
769
+ | Method | What it does |
770
+ |---|---|
771
+ | `runTurn(request)` | Runs one turn and returns `{ ok: true, text }` or `{ ok: false, error }`; `error` is an `Error`, and `AgentRunError` (exported) is the core's own |
772
+ | `steer(conversation, text, attachments, speakerId?)` | Adds a message to the conversation's running steerable turn; `false` when the message must wait for its own turn |
773
+ | `stop(conversation)` | Aborts the conversation's running turn; `false` when none runs |
774
+ | `startFresh(conversation)`, `deleteConversation(conversation)` | Archive the conversation, or remove it for good; called between turns |
775
+ | `pendingConfirmation(conversation)`, `heldActions(conversation)` | The held actions known in memory, and those restored from the store after a restart: the agent server reads `heldActions` to show an approval card again after a restart. A `PendingConfirmation` carries the `selectionId` of the `TurnSelection` whose turn held the calls, an opaque string that the caller resolves again when the owner confirms; the core stores it and never reads it |
776
+ | `recentTranscript(conversation, limit)` | The latest messages, for the owner's and the dashboard's views of a conversation |
777
+ | `contextUsage?(conversation)` | How full the conversation's context is, `{ tokens, contextWindow }`; leave it out and the team status shows no context bar |
778
+ | `preflight?()` | Runs in the host's preflight, before anything starts; a throw stops the boot |
779
+ | `dispose?()` | Runs when the host stops the agent server's runtime service |
780
+
781
+ A request has the turn's `channel`, `selection` (its tools), `text`, `attachments`, `speaker`, and flags (`steerable`, `interactive`, `confirmed`); an agent's turn also has `agent`, the agent's scope, whose `session` is the conversation's key; any other turn has a `kind` (`"owner"` when absent) that names the conversation's persona.
782
+
783
+ <!-- example: examples/echo-runtime.ts -->
784
+ ```ts
785
+ import {
786
+ AgentRunError,
787
+ type AgentRuntime,
788
+ type ChannelKey,
789
+ definePlugin,
790
+ type PendingConfirmation,
791
+ type RuntimeDeps,
792
+ type RuntimeFactory,
793
+ type TranscriptEntry,
794
+ type TurnRequest,
795
+ type TurnResult,
796
+ } from "pi-roundtable";
797
+
798
+ /**
799
+ * A runtime runs the conversations of the agent server and of every claim that calls
800
+ * `context.turns.run`: one conversation per channel key, its history, and its held actions.
801
+ * This one answers with the text it was given, and keeps each conversation's transcript in memory.
802
+ */
803
+ export class EchoRuntime implements AgentRuntime {
804
+ readonly #deps: RuntimeDeps;
805
+ readonly #transcripts = new Map<ChannelKey, TranscriptEntry[]>();
806
+
807
+ constructor(deps: RuntimeDeps) {
808
+ this.#deps = deps;
809
+ }
810
+
811
+ async runTurn(request: TurnRequest): Promise<TurnResult> {
812
+ // An agent's turn carries the agent's scope, and its conversation is the scope's session.
813
+ const conversation = request.agent?.session ?? request.channel;
814
+ const prompt = this.#promptOf(request);
815
+ if (prompt === undefined)
816
+ return {
817
+ ok: false,
818
+ error: new AgentRunError(
819
+ `no persona for the conversation kind "${request.kind}": a plugin adds one with \`personas\``,
820
+ ),
821
+ };
822
+ const text = `[${prompt}] ${request.text}`;
823
+ const transcript = this.#transcripts.get(conversation) ?? [];
824
+ transcript.push(
825
+ { role: "user", text: request.text },
826
+ { role: "assistant", text },
827
+ );
828
+ this.#transcripts.set(conversation, transcript);
829
+ return { ok: true, text };
830
+ }
831
+
832
+ /** What a model would get as its system prompt: the agent's name, or the persona of the kind. */
833
+ #promptOf(request: TurnRequest): string | undefined {
834
+ if (request.agent) return `agent ${request.agent.name}`;
835
+ // Only the turn's kind picks the persona; "owner" is the kind of a turn that names none.
836
+ const kind = request.kind ?? "owner";
837
+ return kind === "owner"
838
+ ? (this.#deps.sessions().persona("owner") ?? "owner")
839
+ : this.#deps.sessions().persona(kind);
840
+ }
841
+
842
+ /** Nothing runs long enough to take a steering message, so each waits for its own turn. */
843
+ async steer(): Promise<boolean> {
844
+ return false;
845
+ }
846
+
847
+ /** No turn outlives its `runTurn`, so there is never one to stop. */
848
+ stop(): boolean {
849
+ return false;
850
+ }
851
+
852
+ async startFresh(conversation: ChannelKey): Promise<void> {
853
+ this.#transcripts.delete(conversation);
854
+ }
855
+
856
+ async deleteConversation(conversation: ChannelKey): Promise<void> {
857
+ this.#transcripts.delete(conversation);
858
+ }
859
+
860
+ /** This runtime holds no actions for approval; a real one keeps them in `deps.confirmations`. */
861
+ pendingConfirmation(): PendingConfirmation | undefined {
862
+ return undefined;
863
+ }
864
+
865
+ async heldActions(): Promise<PendingConfirmation | undefined> {
866
+ return undefined;
867
+ }
868
+
869
+ async recentTranscript(
870
+ conversation: ChannelKey,
871
+ limit: number,
872
+ ): Promise<TranscriptEntry[]> {
873
+ return (this.#transcripts.get(conversation) ?? []).slice(-limit);
874
+ }
875
+ }
876
+
877
+ /** The factory the agent server calls once, with what a runtime needs from the host. */
878
+ export const createEchoRuntime: RuntimeFactory = (deps) => {
879
+ deps.logger.info("the echo runtime replaces Pi");
880
+ return new EchoRuntime(deps);
881
+ };
882
+
883
+ /**
884
+ * A plugin fills the `runtime` slot to replace the whole conversation runtime; without one the
885
+ * agent server builds the Pi runtime. One plugin may fill it.
886
+ */
887
+ export const echoRuntime = definePlugin({
888
+ name: "echo-runtime",
889
+ providers: { runtime: createEchoRuntime },
890
+ setup: () => ({}),
891
+ });
892
+ ```
893
+ <!-- /example -->
894
+
895
+ A test sets the plugin up with `testPlugin`, which builds the runtime from the slot the way the agent server does (`harness.runtime`) and runs `context.turns` over it, with the fake surface and no Discord or Pi:
896
+
897
+ <!-- example: examples/echo-runtime.test.ts -->
898
+ ```ts
899
+ import { afterEach, expect, test } from "bun:test";
900
+ import {
901
+ definePlugin,
902
+ PluginError,
903
+ Roundtable,
904
+ type RoundtablePlugin,
905
+ } from "pi-roundtable";
906
+ import { OWNER_SPEAKER, silentLogger, testPlugin } from "pi-roundtable/testing";
907
+ import { createEchoRuntime, EchoRuntime, echoRuntime } from "./echo-runtime.ts";
908
+ import { FakeSurface } from "./fake-surface.ts";
909
+
910
+ /** One host runs per process, so each test stops its own. */
911
+ const hosts: Roundtable[] = [];
912
+ afterEach(async () => {
913
+ for (const host of hosts.splice(0)) await host.shutdown("test");
914
+ });
915
+
916
+ function host(plugins: RoundtablePlugin[]): Roundtable {
917
+ const roundtable = new Roundtable({ logger: silentLogger() }, plugins);
918
+ hosts.push(roundtable);
919
+ return roundtable;
920
+ }
921
+
922
+ test("the harness builds the runtime from the slot, the way the agent server does", async () => {
923
+ const harness = await testPlugin(echoRuntime);
924
+ expect(harness.runtime).toBeInstanceOf(EchoRuntime);
925
+ await harness.stop();
926
+ });
927
+
928
+ test("a turn run through context.turns goes through the plugin's runtime, and the answer goes out through the surface", async () => {
929
+ const surface = new FakeSurface();
930
+ const harness = await testPlugin(echoRuntime, { surfaces: [surface] });
931
+ const result = await harness.turns.run({
932
+ channel: "fake:room",
933
+ kind: "owner",
934
+ text: "hello",
935
+ speaker: OWNER_SPEAKER,
936
+ });
937
+ expect(result).toEqual({ ok: true, text: "[owner] hello" });
938
+ expect(surface.replies).toEqual([
939
+ { channel: "fake:room", reply: { chunks: ["[owner] hello"] } },
940
+ ]);
941
+ // The surface showed typing and the stop control while the turn ran.
942
+ expect(surface.typing).toEqual(["start fake:room", "stop fake:room"]);
943
+ expect(surface.stops).toEqual(["show fake:room", "hide fake:room"]);
944
+ expect(harness.events.map(({ name }) => name)).toEqual([
945
+ "turnStarted",
946
+ "turnEnded",
947
+ ]);
948
+ await harness.stop();
949
+ });
950
+
951
+ test("an agent's turn carries its scope, and its conversation is the scope's session", async () => {
952
+ const harness = await testPlugin(echoRuntime);
953
+ const { runtime } = harness;
954
+ if (!runtime) throw new Error("the plugin fills the runtime slot");
955
+ const result = await runtime.runTurn({
956
+ channel: "fake:agent-room",
957
+ selection: { id: "agent", tools: [], groups: [] },
958
+ text: "status?",
959
+ agent: {
960
+ name: "infra",
961
+ session: "fake:agent-room",
962
+ home: "fake:agent-room",
963
+ },
964
+ });
965
+ expect(result).toEqual({ ok: true, text: "[agent infra] status?" });
966
+ expect(await runtime.recentTranscript("fake:agent-room", 10)).toEqual([
967
+ { role: "user", text: "status?" },
968
+ { role: "assistant", text: "[agent infra] status?" },
969
+ ]);
970
+ await runtime.startFresh("fake:agent-room");
971
+ expect(await runtime.recentTranscript("fake:agent-room", 10)).toEqual([]);
972
+ await harness.stop();
973
+ });
974
+
975
+ test("a turn of a kind nobody wrote a persona for fails with the fix, and the surface says so", async () => {
976
+ const surface = new FakeSurface();
977
+ const harness = await testPlugin(echoRuntime, { surfaces: [surface] });
978
+ const result = await harness.turns.run({
979
+ channel: "fake:room",
980
+ kind: "quiz",
981
+ text: "hello",
982
+ speaker: OWNER_SPEAKER,
983
+ });
984
+ expect(result.ok).toBe(false);
985
+ expect(!result.ok && result.error.message).toContain("`personas`");
986
+ expect(surface.replies).toHaveLength(1);
987
+ await harness.stop();
988
+ });
989
+
990
+ test("the host resolves the slot to the plugin's factory", async () => {
991
+ let filled = false;
992
+ const probe = definePlugin({
993
+ name: "probe",
994
+ setup: ({ providers }) => {
995
+ filled = providers.filled.has("runtime");
996
+ expect(providers.runtime).toBe(createEchoRuntime);
997
+ return { services: [{ name: "probe" }] };
998
+ },
999
+ });
1000
+ await host([echoRuntime, probe]).run();
1001
+ expect(filled).toBe(true);
1002
+ });
1003
+
1004
+ test("only one plugin may fill the runtime slot", async () => {
1005
+ const another = definePlugin({
1006
+ name: "another-runtime",
1007
+ providers: { runtime: createEchoRuntime },
1008
+ setup: () => ({}),
1009
+ });
1010
+ const error = await host([echoRuntime, another])
1011
+ .run()
1012
+ .then(
1013
+ () => undefined,
1014
+ (failure: unknown) => failure,
1015
+ );
1016
+ expect(error).toBeInstanceOf(PluginError);
1017
+ expect(String(error)).toContain(
1018
+ "provider slot runtime is already filled by plugin echo-runtime",
1019
+ );
1020
+ });
1021
+ ```
1022
+ <!-- /example -->
1023
+
517
1024
  ### `preflight`: refuse to start with a bad setting
518
1025
 
519
1026
  `preflight` also sits on the plugin.
@@ -544,50 +1051,68 @@ export function needsKey(
544
1051
  ```
545
1052
  <!-- /example -->
546
1053
 
547
- ### `interactions`: slash commands
1054
+ ### Slash commands: `commands.add`
1055
+
1056
+ Slash commands belong to the Discord plugin, which composes the commands of every plugin under one root command, `/roundtable` unless `discord.rootCommand` says otherwise, and registers them with Discord as it connects.
1057
+ A plugin adds its own from `setup` with `context.services.get(DISCORD).commands.add(...)`, with `DISCORD` from `pi-roundtable/discord`.
1058
+ A subcommand goes under the root command; the `module` answers the interactions Discord sends and returns `true` for the ones it handled; `commands()` returns top-level commands of its own, and never the root.
1059
+ A plugin that only adds commands returns `{}`: reading a service in `setup` is enough for the host not to treat it as empty.
548
1060
 
549
- A subcommand goes under the one root command, `/roundtable` unless `discord.rootCommand` says otherwise.
550
- The `module` answers the interactions Discord sends and returns `true` for the ones it handled; `commands()` returns top-level commands of its own, and never the root.
1061
+ The Discord plugin composes the commands in its `preflight`, which runs before any service starts, so a duplicate command name, a module that registers the root itself, or a subcommand added twice stops the start with a `PluginError` before anything reaches Discord.
1062
+ `commands.add` after that preflight throws too (`commands can be added only while plugins set up`), so call it from `setup`, never from a service's `start` or a handler.
1063
+ Use `services.find(DISCORD)` when the plugin also works on a host without the Discord plugin; without it there are no commands, and the rest of the plugin still runs.
1064
+
1065
+ `pi-roundtable/discord` also has the pieces a command module is built from: `ownerCommandModule(guard, handlers)` for a feature's part of the root command (owner-only, deferred, with a failure panel), `groupOption` for a subcommand group, the panel helpers (`ownerPanel`, `ownerPanels`, `ephemeralPanel`, `replyWithPanels`, `plain`, `OwnerFacingError`), and `agentPanel({ guard, agents })` for the profile panel of an agent.
1066
+ `DISCORD.guard` is the `CommandGuard`: `isOwner(actor)` takes an interaction or anything with `user.id`, so a test needs no cast, and `DiscordOptions.refusalHint` (`discord.refusalHint` in the configuration) is text appended as it is to the refusal a non-owner gets.
551
1067
 
552
1068
  <!-- example: examples/interactions.ts -->
553
1069
  ```ts
554
1070
  import { definePlugin } from "pi-roundtable";
1071
+ import { DISCORD } from "pi-roundtable/discord";
555
1072
 
556
1073
  /**
557
- * Interactions add slash commands. A subcommand goes under the one root command (`/roundtable`
558
- * by default); the module answers the interactions Discord sends and returns true for the ones it handled.
1074
+ * Slash commands belong to the Discord plugin: a plugin adds its own from setup with
1075
+ * `commands.add`. A subcommand goes under the one root command (`/roundtable` by default); the
1076
+ * module answers the interactions Discord sends and returns true for the ones it handled.
559
1077
  */
560
1078
  export const ping = definePlugin({
561
1079
  name: "ping",
562
- setup: () => ({
563
- interactions: [
564
- {
565
- rootOptions: [
566
- { type: 1, name: "ping", description: "Check that the bot answers" },
567
- ],
568
- module: {
569
- commands: () => [],
570
- handle: async (interaction) => {
571
- if (!interaction.isChatInputCommand()) return false;
572
- if (interaction.options.getSubcommand(false) !== "ping")
573
- return false;
574
- await interaction.reply("pong");
575
- return true;
576
- },
1080
+ setup: ({ services }) => {
1081
+ services.get(DISCORD).commands.add({
1082
+ rootOptions: [
1083
+ { type: 1, name: "ping", description: "Check that the bot answers" },
1084
+ ],
1085
+ module: {
1086
+ commands: () => [],
1087
+ handle: async (interaction) => {
1088
+ if (!interaction.isChatInputCommand()) return false;
1089
+ if (interaction.options.getSubcommand(false) !== "ping") return false;
1090
+ await interaction.reply("pong");
1091
+ return true;
577
1092
  },
578
1093
  },
579
- ],
580
- }),
1094
+ });
1095
+ return {};
1096
+ },
581
1097
  });
582
1098
  ```
583
1099
  <!-- /example -->
584
1100
 
1101
+ A test gives the plugin `fakeDiscord()` from `pi-roundtable/testing`: `testPlugin(ping, { services: [discord.service] })` records what the plugin added (`discord.added()`), and `discord.compose()` returns the tree Discord would get, composed the way the Discord plugin composes it.
1102
+
585
1103
  ### `http`: routes on the bot's listener
586
1104
 
587
1105
  The configuration's `http` block opens one listener, named `public`, and `http.publicUrl` is the address that reaches it from the internet; the agents' avatars are served from it.
588
1106
  A route names the listener, a path (`{ exact }` or `{ prefix }`), optionally the methods, and a handler that gets a `Request` and returns a `Response`.
589
1107
  Two routes that could take the same request are refused, so a route cannot shadow the avatars.
590
1108
  Anything on this listener is reachable from the internet: check a secret in the handler before doing anything.
1109
+ A handler that throws, or returns a rejected promise, answers `500 Internal Server Error` with that fixed body, and the listener keeps serving.
1110
+ The host logs one error line with the route's `name` and its `listener`; it never logs the request URL, since a path may hold a secret.
1111
+
1112
+ A listener serves on a TCP port or, when `http.socketPath` is set, a unix socket that a tunnel or proxy reaches.
1113
+ The socket file's permission bits are `http.socketMode`, `0o660` by default: only its owner and group can connect.
1114
+ Widen it (`0o666`) only when the proxy runs as a user outside that group.
1115
+ More listeners than `public` come from `defineRoundtable`'s `listeners` override, each `{ id, socketPath, mode? }` or `{ id, port, hostname? }`, and a route attaches to one by its `id`.
591
1116
 
592
1117
  <!-- example: examples/http.ts -->
593
1118
  ```ts
@@ -673,11 +1198,19 @@ export const webSearch = definePlugin({
673
1198
  ```
674
1199
  <!-- /example -->
675
1200
 
1201
+ ### `toolTiers`: who may use your raw session tools
1202
+
1203
+ `toolTiers` maps the name of each tool a `sessionTools` extension registers to the lowest tier that may use it: `{ toolTiers: { notes_search: "member", notes_edit: "admin" } }`.
1204
+ A tool built with `defineTool` already carries its tier; this is the same for the raw form.
1205
+ The operator's `toolTiers` setting still wins, a tool nobody names needs the owner, and two plugins naming one tool is a `PluginError` that names both.
1206
+ The built-in addons use it: each declares the tiers of its own tools.
1207
+
676
1208
  ### `sessionTools`: the raw form of `tools`
677
1209
 
678
1210
  A session tool is a Pi extension placed in every conversation session by its phase: `tools`, `compaction`, or `mcp`.
679
1211
  Reach for it only when `defineTool` cannot express what you need, such as a tool whose set changes while the process runs (bump `revision`) or a tool that depends on the session.
680
- The extension's name must be unique, and a plugin may not take the name of a core extension (`read-attachment`, `confirmation-gate`, `ask-user`, `self-compact-guard`, `profile-tools`).
1212
+ The extension's name must be unique, and a plugin may not take the name of a core extension (`read-attachment`, `confirmation-gate`, `ask-user`, `self-compact-guard`, `active-tools`).
1213
+ A runtime of your own pins the active tools the way the core does: `activeToolsExtension(() => tools)` from `pi-roundtable/kit` is the extension the core places last, so its handler runs after every other extension's.
681
1214
  At most one plugin may add a `compaction` extension, and it must name the `engine` its compactions record.
682
1215
 
683
1216
  <!-- example: examples/session-tools.ts -->
@@ -725,6 +1258,22 @@ A claim makes the plugin the owner of the conversations in some channels.
725
1258
  The router asks claims by descending `priority`, then plugin order; the first that owns a channel decides everything there, and a message its `admit` returns nothing for is dropped.
726
1259
  Most plugins never need one: the built-in agent server already owns the agents' channels.
727
1260
 
1261
+ A channel key is `<surface>:<id>`: the surface names the chat network (`discord`), and the id is that network's own, which may contain colons.
1262
+ `parseChannelKey(key)` splits a key at its first colon into `{ surface, id }` and throws for a key without a surface; `channelKey(surface, id)` builds one.
1263
+ A claim's `owns(channel, space)` receives, besides the channel, the `space` of the message being routed: the server or workspace the message was posted in, in its surface's own ids, or nothing when the router asks about a channel alone or the message is a direct one.
1264
+ The agent server uses it to own every channel of its Discord server.
1265
+ Read a key with `parseChannelKey`, never by slicing a prefix off it, and make `owns` check the surface: a claim that owns `mcp:` keys says `parseChannelKey(channel).surface === "mcp"`, and one that answers on Discord checks `"discord"`.
1266
+
1267
+ The built-in agent server claims with `AGENT_SERVER_PRIORITY` (100), the highest of any claim, and only on `discord:` keys: the agents' own channels, and, so that nothing else answers there, every other channel of its Discord guild (which it leaves silent; those channels are the owner's notes).
1268
+ A claim of yours on Discord channels therefore never hears a message in the agents' channels or the rest of the agent guild, whatever its priority below 100: a claim written with `priority: 10` for `discord:` keys is beaten there.
1269
+ Keys of another surface are never the agent server's, so a claim of yours on them, such as the `echo:` keys of the example, needs no priority against it.
1270
+
1271
+ A claim may have `stop(channel)`, which stops the channel's running turn and returns whether one was running; the Stop button, `conversations.stop`, and every other stop go through it.
1272
+ The router asks only the claim that owns the channel: a claim without `stop` means `false`, and a claim below it is not asked in its place.
1273
+ Give `stop` to a claim whose conversations run turns that can be interrupted.
1274
+
1275
+ A claim that runs conversations of its own returns the conversation's kind from `startFresh`, and runs its turns with [`context.turns`](#personas-and-contextturns-conversations-of-a-kind-of-your-own) instead of writing the pipeline of typing, stop control, events, and reply.
1276
+
728
1277
  <!-- example: examples/channels.ts -->
729
1278
  ```ts
730
1279
  import { definePlugin } from "pi-roundtable";
@@ -757,26 +1306,425 @@ export const echo = definePlugin({
757
1306
  ```
758
1307
  <!-- /example -->
759
1308
 
1309
+ ### `personas` and `context.turns`: conversations of a kind of your own
1310
+
1311
+ A conversation has a kind: the string its claim returns from `startFresh`, such as `"owner"` or `"study"`.
1312
+ The host reads none of them, so a plugin names the kinds of its own claims.
1313
+ A `Persona` is the system prompt of every non-agent conversation of one kind: `{ kind, prompt() }`, where `prompt()` is read when the conversation's session is made, so text from the message catalog is in the host's language.
1314
+
1315
+ - A plugin contributes `personas` with the kinds it owns; `sessions().persona(kind)` is the linked lookup a runtime uses.
1316
+ - Two personas of one kind are refused, naming both plugins, and so is the kind `"agent"`, which the agent server keeps for its agents.
1317
+ - The kind `"owner"` is the default kind of a conversation whose claim names none: the plugin that owns the owner's conversations contributes its persona. With none, those conversations start with an empty system prompt.
1318
+ - A plugin that needs tools to exist contributes `requiredTools` (a list of tool names): startup builds a session and refuses to run when one is not registered. The lists of every plugin are merged, each name once.
1319
+ - A turn of a kind that has no persona is refused, naming `personas`, instead of running with the owner's prompt.
1320
+ The Pi runtime refuses when it makes the session; a runtime of your own does the same, as the example's does.
1321
+
1322
+ `context.turns.run(input)` is how a claim runs a turn in a conversation it owns, inside its own queue task, without writing the turn pipeline.
1323
+ It shows typing and the stop control on the channel's surface, runs the turn on the runtime, emits `turnStarted` and `turnEnded` (with the turn's `kind`), settles a runtime that throws into a failed result, and posts the answer, or a failure or stopped notice, through the surface, unless you give `reply(result)`.
1324
+ `input` has `channel`, `kind`, `text`, `speaker`, and optionally `attachments`, `selection` (by default the plugins' `agentSelection`), `steerable`, `interactive`, `confirmed`, and `reply`.
1325
+ It rejects with `NotLinkedError` during `setup`, and with a `PluginError` on a host whose agent server has not provided a runtime.
1326
+
1327
+ <!-- example: examples/study-room.ts -->
1328
+ ```ts
1329
+ import { definePlugin, parseChannelKey } from "pi-roundtable";
1330
+
1331
+ /** The kind of a study room's conversations: the string `startFresh` returns, and the persona's kind. */
1332
+ const STUDY = "study";
1333
+
1334
+ /**
1335
+ * A conversation kind of its own. The persona is the system prompt of every `study` conversation,
1336
+ * the claim owns the channels of the study rooms, and `context.turns` runs each message as a turn
1337
+ * of that kind on whatever runtime the host has, and posts the answer through the channel's
1338
+ * surface.
1339
+ */
1340
+ export const studyRoom = definePlugin({
1341
+ name: "study-room",
1342
+ setup: ({ turns }) => ({
1343
+ personas: [
1344
+ {
1345
+ kind: STUDY,
1346
+ prompt: () =>
1347
+ "You are a patient tutor. Ask one question back before you give the answer.",
1348
+ },
1349
+ ],
1350
+ channels: [
1351
+ {
1352
+ name: "study-rooms",
1353
+ priority: 10,
1354
+ // The id of a room starts with `study-`, on whichever surface carries it.
1355
+ owns: (channel) => parseChannelKey(channel).id.startsWith("study-"),
1356
+ admit: (message) =>
1357
+ message.authorIsBot
1358
+ ? undefined
1359
+ : {
1360
+ kind: "turn",
1361
+ run: async () => {
1362
+ await turns.run({
1363
+ channel: message.channel,
1364
+ kind: STUDY,
1365
+ text: message.text,
1366
+ speaker: {
1367
+ id: message.authorId,
1368
+ name: message.authorName,
1369
+ tier: "member",
1370
+ },
1371
+ });
1372
+ },
1373
+ failure: "a study turn failed",
1374
+ },
1375
+ // What the conversation was, so a host picks the right persona when it starts over.
1376
+ startFresh: async () => STUDY,
1377
+ },
1378
+ ],
1379
+ }),
1380
+ });
1381
+ ```
1382
+ <!-- /example -->
1383
+
1384
+ The test runs the plugin over the fake surface and the echo runtime of [the runtime slot](#the-runtime-slot-replace-pi), with nothing else:
1385
+
1386
+ <!-- example: examples/study-room.test.ts -->
1387
+ ```ts
1388
+ import { expect, test } from "bun:test";
1389
+ import { testPlugin } from "pi-roundtable/testing";
1390
+ import { createEchoRuntime } from "./echo-runtime.ts";
1391
+ import { FakeSurface } from "./fake-surface.ts";
1392
+ import { studyRoom } from "./study-room.ts";
1393
+
1394
+ /** The plugin over the fake surface and the echo runtime, with nothing else: no Discord, no Pi, no database. */
1395
+ async function studying() {
1396
+ const surface = new FakeSurface();
1397
+ const harness = await testPlugin(studyRoom, {
1398
+ surfaces: [surface],
1399
+ providers: { runtime: createEchoRuntime },
1400
+ });
1401
+ return { surface, harness };
1402
+ }
1403
+
1404
+ async function until(done: () => boolean): Promise<void> {
1405
+ for (let waited = 0; !done() && waited < 1000; waited += 5)
1406
+ await Bun.sleep(5);
1407
+ expect(done()).toBe(true);
1408
+ }
1409
+
1410
+ test("a message in a study room runs as a turn of the study kind, with the tutor's persona", async () => {
1411
+ const { surface, harness } = await studying();
1412
+ surface.say("fake:study-algebra", "What is a group?");
1413
+ await until(() => surface.replies.length > 0);
1414
+ expect(surface.replies).toEqual([
1415
+ {
1416
+ channel: "fake:study-algebra",
1417
+ reply: {
1418
+ chunks: [
1419
+ "[You are a patient tutor. Ask one question back before you give the answer.] What is a group?",
1420
+ ],
1421
+ },
1422
+ },
1423
+ ]);
1424
+ // The plugins' turn events carry the kind and no agent.
1425
+ const started = harness.events.find(({ name }) => name === "turnStarted");
1426
+ expect(started?.turn).toMatchObject({
1427
+ kind: "study",
1428
+ channel: "fake:study-algebra",
1429
+ });
1430
+ expect(started?.turn?.agent).toBeUndefined();
1431
+ await harness.stop();
1432
+ });
1433
+
1434
+ test("a channel that is not a study room is not the claim's", async () => {
1435
+ const { surface, harness } = await studying();
1436
+ surface.say("fake:lounge", "hello");
1437
+ await Bun.sleep(30);
1438
+ expect(surface.replies).toEqual([]);
1439
+ await harness.stop();
1440
+ });
1441
+
1442
+ test("starting a room over says it was a study conversation", async () => {
1443
+ const { harness } = await studying();
1444
+ expect(await harness.conversations.startFresh("fake:study-algebra")).toBe(
1445
+ "study",
1446
+ );
1447
+ await harness.stop();
1448
+ });
1449
+
1450
+ test("the persona is the plugin's and belongs to the study kind only", async () => {
1451
+ const { harness } = await studying();
1452
+ const [persona] = harness.contribution.personas ?? [];
1453
+ expect(persona?.kind).toBe("study");
1454
+ expect(persona?.prompt()).toContain("patient tutor");
1455
+ await harness.stop();
1456
+ });
1457
+ ```
1458
+ <!-- /example -->
1459
+
1460
+ ### `backgroundTargets`: whose turn a schedule or delegated task is
1461
+
1462
+ A schedule, and a task an agent delegates, comes back later as a turn nobody wrote in the channel.
1463
+ A `BackgroundTarget` says whose turn that is: a `name` that the schedule or job stores, a `label(locale)` that lists such as `/<root> schedule` show, and the limits that apply to it.
1464
+ `schedules` (`perChannel`, `promptChars`, `aheadDays`) bounds what `schedule_create` accepts, and `delegation` (`maxRunning`) bounds how many delegated tasks may run in one channel; a target without one of them may not schedule or delegate at all.
1465
+ The claim that answers the target serves it in its `background(turn)`, where `turn.target` is the name, and skips every target it does not serve.
1466
+ That is what keeps the owner's tools out of a channel open to many people: a channel's claim runs a turn only for its own target.
1467
+
1468
+ - A plugin contributes `backgroundTargets` next to the claim that serves them, and `context.conversations.target(name)` reads one when it is used, so call it from a service or a handler, not during setup.
1469
+ - Two targets of one name are refused, naming both plugins, the way two personas of one kind are.
1470
+ - The agent server contributes `OWNER_TARGET` (name `"owner"`, exported from the main entry) for the owner's and the agents' conversations, and its claim answers only that target: a turn for any other target is skipped, never run with the owner's tools.
1471
+ - A turn for a target no plugin contributes is skipped with the reason `no plugin contributes the background target "<name>"`. It never falls back to the owner's, and a recurring schedule of that target is kept, with the reason as its last status, so it runs again once a plugin contributes the target (a one-time schedule is spent when it fires, as any is).
1472
+ - A schedule keeps its target's name in its row, so renaming a target orphans the schedules made for it.
1473
+
1474
+ <!-- example: examples/support-desk.ts -->
1475
+ ```ts
1476
+ import { type BackgroundTarget, definePlugin } from "pi-roundtable";
1477
+
1478
+ /**
1479
+ * Whose turn a schedule or a delegated task asks for. The target sets what may be scheduled or
1480
+ * delegated for it, so a desk open to many people is held tighter than the owner's own agent.
1481
+ */
1482
+ const SUPPORT: BackgroundTarget = {
1483
+ name: "support",
1484
+ label: () => "Support desk",
1485
+ schedules: { perChannel: 3, promptChars: 500, aheadDays: 30 },
1486
+ delegation: { maxRunning: 1 },
1487
+ };
1488
+
1489
+ /**
1490
+ * A plugin that answers the turns nobody wrote in its channels. The router skips a turn whose
1491
+ * target no plugin contributes, and this claim skips the targets it does not serve, so another
1492
+ * target's schedule never runs here.
1493
+ */
1494
+ export const supportDesk = definePlugin({
1495
+ name: "support-desk",
1496
+ setup: () => ({
1497
+ backgroundTargets: [SUPPORT],
1498
+ channels: [
1499
+ {
1500
+ name: "support-desks",
1501
+ priority: 10,
1502
+ owns: (channel) => channel.startsWith("support:"),
1503
+ admit: () => undefined,
1504
+ background: async (turn) =>
1505
+ turn.target === SUPPORT.name
1506
+ ? { status: "ran" }
1507
+ : {
1508
+ status: "skipped",
1509
+ reason: `the support desk does not serve "${turn.target}"`,
1510
+ },
1511
+ startFresh: async () => "support",
1512
+ },
1513
+ ],
1514
+ }),
1515
+ });
1516
+ ```
1517
+ <!-- /example -->
1518
+
1519
+ ### `surfaces`: a chat network of your own
1520
+
1521
+ A chat surface connects the host to one chat network: it reports what people write, and it posts what the conversations answer.
1522
+ The host ships Discord's; a plugin contributes another with `surfaces`, and its claims answer on it through `context.surfaces`.
1523
+ A host with a second surface still has one conversation router: a surface only delivers messages, and the claim that owns the channel decides what happens to them.
1524
+
1525
+ A surface serves the channels whose key starts with its `surface` prefix: `surface = "fake"` serves `fake:<id>` keys, and only those reach its methods.
1526
+ The prefix is one non-empty word without a colon or a space, unique per host: two surfaces with one prefix stop the start, naming both plugins.
1527
+ A surface's channels are never the agent server's, which claims `discord:` keys only, so a claim of yours on them needs no priority against it.
1528
+
1529
+ | Method | What the surface does | Without it |
1530
+ |---|---|---|
1531
+ | `surface` | The key prefix | (required) |
1532
+ | `start(deliver)` | Connects, and calls `deliver(message)` for every incoming message; the host passes its conversation router | (required) |
1533
+ | `sendReply(channel, reply)` | Posts an `OutboundReply`: a thinking line, a card, text chunks, then files | (required) |
1534
+ | `stop()` | Disconnects when the host stops | nothing to stop |
1535
+ | `startTyping(channel)` | Shows a typing indicator until the returned function is called | none is shown |
1536
+ | `showStop(channel)` | Shows the owner a stop control until the returned function is called; using it calls `conversations.stop(channel)` | none is shown |
1537
+ | `react`, `unreact` | Adds or removes the bot's reaction on a message | no marks on queued or steered messages |
1538
+ | `prompts(channel, speaker?)` | The owner's way to approve a held action or answer `ask_user` inside a running turn, as `OwnerPrompts`: `confirm` and `ask` | the action is held until the owner's next message |
1539
+
1540
+ The host starts each surface as a service named `surface:<prefix>`, before the contributing plugin's own services and in that plugin's place in the order, and stops it in reverse like any service.
1541
+ The messages a surface delivers must have its prefix; one with another prefix is logged and dropped, because no claim can tell whose it is.
1542
+ `context.surfaces` picks the surface by the key's prefix.
1543
+ Its `sendReply` rejects with a `PluginError` naming the prefix when no surface serves it.
1544
+ `startTyping`, `showStop`, `react`, and `unreact` do nothing for a channel whose surface does not show them or that has no surface, and `prompts` returns `undefined` there, which is what a held action needs to wait for the owner's next message.
1545
+ `of(channel)` returns the surface, or `undefined`.
1546
+ The agent server asks the owner for approvals through `context.surfaces.prompts`, so a surface that gives `prompts` gets them in its own channels.
1547
+
1548
+ Slash commands are Discord's: the host composes none and hands none to a surface.
1549
+ The Discord plugin collects them (see [slash commands](#slash-commands-commandsadd)), and a surface of another network has no commands.
1550
+
1551
+ Here is a surface for an in-memory chat that records everything the host asks of it, and the plugin that contributes it with a claim that answers through `context.surfaces`:
1552
+
1553
+ <!-- example: examples/fake-surface.ts -->
1554
+ ```ts
1555
+ import {
1556
+ type ChannelKey,
1557
+ type ChatSurface,
1558
+ definePlugin,
1559
+ type InboundMessage,
1560
+ type OutboundReply,
1561
+ type OwnerPrompts,
1562
+ parseChannelKey,
1563
+ } from "pi-roundtable";
1564
+
1565
+ /**
1566
+ * A chat surface connects the host to one chat network. This one is an in-memory chat: its
1567
+ * channels are the keys that start with `fake:`, and it records what the host asks of it. A real
1568
+ * surface talks to its network in `start` and `sendReply`, and skips the optional methods it
1569
+ * cannot do.
1570
+ */
1571
+ export class FakeSurface implements ChatSurface {
1572
+ readonly surface = "fake";
1573
+ readonly replies: { channel: ChannelKey; reply: OutboundReply }[] = [];
1574
+ readonly typing: string[] = [];
1575
+ readonly stops: string[] = [];
1576
+ readonly asked: string[] = [];
1577
+ #deliver: ((message: InboundMessage) => void) | undefined;
1578
+
1579
+ /** The host hands over its router; every message the network reports goes to it. */
1580
+ async start(deliver: (message: InboundMessage) => void): Promise<void> {
1581
+ this.#deliver = deliver;
1582
+ }
1583
+
1584
+ async stop(): Promise<void> {
1585
+ this.#deliver = undefined;
1586
+ }
1587
+
1588
+ /** Someone writes in a channel. */
1589
+ say(channel: ChannelKey, text: string): void {
1590
+ this.#deliver?.({
1591
+ channel,
1592
+ messageId: `m${this.replies.length}`,
1593
+ authorId: "1",
1594
+ authorName: "Ada",
1595
+ authorIsBot: false,
1596
+ isDirect: true,
1597
+ mentionsBot: false,
1598
+ repliesToBot: false,
1599
+ text,
1600
+ attachments: [],
1601
+ });
1602
+ }
1603
+
1604
+ async sendReply(channel: ChannelKey, reply: OutboundReply): Promise<void> {
1605
+ this.replies.push({ channel, reply });
1606
+ }
1607
+
1608
+ startTyping(channel: ChannelKey): () => void {
1609
+ this.typing.push(`start ${channel}`);
1610
+ return () => void this.typing.push(`stop ${channel}`);
1611
+ }
1612
+
1613
+ showStop(channel: ChannelKey): () => void {
1614
+ this.stops.push(`show ${channel}`);
1615
+ return () => void this.stops.push(`hide ${channel}`);
1616
+ }
1617
+
1618
+ /** How the owner would approve a held action or answer a question in a turn. */
1619
+ prompts(channel: ChannelKey): OwnerPrompts {
1620
+ return {
1621
+ confirm: async (title) => {
1622
+ this.asked.push(`${channel}: ${title}`);
1623
+ return "approved";
1624
+ },
1625
+ ask: async () => undefined,
1626
+ };
1627
+ }
1628
+ }
1629
+
1630
+ /**
1631
+ * The plugin contributes the surface, and a claim that owns the channels of its prefix and
1632
+ * answers through `context.surfaces`, which picks the surface by the prefix of the channel's key.
1633
+ */
1634
+ export function fakeChat(surface: FakeSurface) {
1635
+ return definePlugin({
1636
+ name: "fake-chat",
1637
+ setup: ({ surfaces }) => ({
1638
+ surfaces: [surface],
1639
+ channels: [
1640
+ {
1641
+ name: "fake-channels",
1642
+ priority: 10,
1643
+ owns: (channel) => parseChannelKey(channel).surface === "fake",
1644
+ admit: (message) => ({
1645
+ kind: "turn",
1646
+ run: async () => {
1647
+ const stopTyping = surfaces.startTyping(message.channel);
1648
+ try {
1649
+ await surfaces.sendReply(message.channel, {
1650
+ chunks: [`echo: ${message.text}`],
1651
+ });
1652
+ } finally {
1653
+ stopTyping();
1654
+ }
1655
+ },
1656
+ failure: "a fake chat turn failed",
1657
+ }),
1658
+ startFresh: async () => "The fake chat has nothing to start over.",
1659
+ },
1660
+ ],
1661
+ }),
1662
+ });
1663
+ }
1664
+ ```
1665
+ <!-- /example -->
1666
+
1667
+ A test boots a host with the plugin, writes through the surface, and reads what came out (`examples/fake-surface.test.ts` does this with `Roundtable` and `silentLogger`, and also covers the refusals above).
1668
+
760
1669
  ### What plugins do not extend
761
1670
 
762
- `useCommands`, `agentServer`, and `stopTurn` are hooks on the plugin that the built-in plugins use to receive the composed commands, start the agent server, and stop a running turn.
763
- Only one plugin may start the agent server, and the built-in `agent-server` already does; a plugin that tries is refused.
1671
+ A plugin adds slash commands through the Discord plugin's registrar and never receives the composed commands (see [slash commands](#slash-commands-commandsadd)).
1672
+
1673
+ Some fields and parts of 0.1.0 are gone, because only the built-in plugins used them.
1674
+ A plugin that still has one is refused where it is written (`definePlugin`) or when the host starts, with an error that names the replacement:
1675
+
1676
+ | Removed | Use instead |
1677
+ |---|---|
1678
+ | `RoundtablePlugin.useCommands(composed)` | `context.services.get(DISCORD).commands.add(...)` from `setup` |
1679
+ | The `interactions` part of a contribution | The same: `commands.add({ module, rootOptions })` |
1680
+ | `ChatSurface.useCommands(composed)` | Nothing calls it any more; a surface that has it is refused |
1681
+ | The host option `commands` (`RoundtableOptions.commands`) | `discord.rootCommand` in the configuration; the Discord plugin composes under it |
1682
+ | `RoundtablePlugin.agentServer()` and the `agentServer(outcome)` event | A service with `startInBackground`, and the `serviceStarted` event handler |
1683
+ | `RoundtablePlugin.stopTurn(channel)` | `stop(channel)` on the `ChannelClaim` that owns the channel |
764
1684
 
765
1685
  ## Testing a plugin
766
1686
 
767
- `testPlugin(plugin, options?)` sets one plugin up against a fake context and starts its services, with no Discord and no PostgreSQL unless you pass `{ database }`.
1687
+ Two harnesses, by what the test needs: `testPlugin(plugin, options?)` sets one plugin up alone, against a fake context, and is the default; [`testHost`](#testhost-the-built-in-plugins-and-yours-over-postgresql) boots the built-in plugins and yours together over PostgreSQL, for a test that depends on them or on the order the host sets things up in.
1688
+
1689
+ `testPlugin` sets one plugin up against a fake context and starts its services, with no Discord and no PostgreSQL unless you pass `{ database }`.
768
1690
  It returns:
769
1691
 
770
1692
  | Field | What it is |
771
1693
  |---|---|
772
- | `contribution` | What the plugin added, as the host would collect it: `tools`, `prompt`, `seeds`, `events`, `services`, `interactions`, `http`, and the rest |
1694
+ | `contribution` | What the plugin added, as the host would collect it: `tools`, `prompt`, `seeds`, `events`, `services`, `http`, and the rest |
773
1695
  | `tools`, `tiers` | The tool names, and the table that says what tier each needs |
774
- | `runTool(name, args, { speaker }?)` | Runs a tool the way an agent's turn would, and returns the text the model reads |
775
- | `events` | The events the plugin itself reported through `context.events` |
776
- | `stop()` | Delivers `shutdown` and stops the services in reverse |
1696
+ | `holds` | The plugin's `holdRules` chained as the host links them (`holdChain`): `holds(tool, input, { workspace? })` returns the description of a call that must be approved first, or `undefined` |
1697
+ | `runTool(name, args, { speaker, channel }?)` | Runs a tool the way an agent's turn would, in the channel (default `test:1`) for the speaker, and returns the text the model reads |
1698
+ | `events` | The events the plugin itself reported through `context.events`, and those of `context.turns` |
1699
+ | `conversations`, `turns`, `surfaces` | What the plugin sees as `context.conversations`, `context.turns`, and `context.surfaces`, for a test to drive its claims |
1700
+ | `runtime` | The runtime the plugin's `runtime` provider built, given stand-in dependencies; `undefined` when it fills no such slot |
1701
+ | `stop()` | Delivers `shutdown`, stops the injected surfaces, and stops the services in reverse |
777
1702
 
778
1703
  The harness applies the same checks as the host (a plugin that adds nothing, an unknown part, a clash of names, a tool with no tier), so a mistake fails your test with the message the start would print.
779
- It does not run migrations (see [`migrations`](#migrations-and-contextdatabase-tables-of-your-own) for a test that does), it does not deliver the core's events (call the handlers yourself, as [`events`](#events-hear-what-the-core-does) shows), and it does not build a prompt (call `contribution.prompt`'s `build` yourself, as [`prompt`](#prompt-text-added-to-every-agent-turn) shows), and `sessions()` and `conversations` throw `NotLinkedError` in it, as they do in `setup`.
1704
+ It does not run migrations (see [`migrations`](#migrations-and-contextdatabase-tables-of-your-own) for a test that does), it does not deliver the core's events (call the handlers yourself, as [`events`](#events-hear-what-the-core-does) shows), and it does not build a prompt (call `contribution.prompt`'s `build` yourself, as [`prompt`](#prompt-text-added-to-every-agent-turn) shows).
1705
+ `sessions()`, `conversations`, `surfaces`, `turns`, and `dashboard()` throw `NotLinkedError` during `setup`, as they do in the host, and work once the plugin is set up: `conversations` routes to the claims of the plugin under test, `surfaces` has the plugin's surfaces, which start with its services, and `turns` runs over the runtime and the surfaces.
1706
+
1707
+ #### Options
1708
+
1709
+ | Option | What it gives the plugin |
1710
+ |---|---|
1711
+ | `env` | A partial `HostEnv` for `context.env`; `en` and `UTC` by default |
1712
+ | `owner` | The owner the runtime's dependencies and the agent server's claim know: `{ id, name, pronouns? }`; `owner` named Owner, addressed as they, by default |
1713
+ | `database` | A Bun `SQL` for `context.database()`; without one it throws `no database is configured` |
1714
+ | `providers` | Provider slots filled as if another plugin filled them |
1715
+ | `surfaces` | Chat surfaces besides the plugin's own, such as the fake surface of `examples/fake-surface.ts`: they are in `context.surfaces`, start after the plugin's services with the harness routing their messages to `conversations`, and stop with `stop()` |
1716
+ | `services` | What the plugin reads from `context.services`: one `servicePair(KEY, { ... })` for each service, with the members you give it. `servicePair(AGENTS, { runtime })` is the runtime `context.turns` runs on. Reading a member you did not give throws a `PluginError` that names the option to add; a service you did not give reads as absent to `find`, and `get` says to give it, except for the ones below |
1717
+ | `conversations` | Methods that replace the router's, such as `stop`, for a plugin that calls them |
1718
+ | `turns` | A `ConversationTurns` that replaces the default one |
1719
+ | `forwardJoinMs` | How long the router holds a bare forward for the message that follows it (the host option `conversations.forwardJoinMs`) |
1720
+
1721
+ The harness supplies what the host would, so a claim or a background turn behaves as it does there:
1722
+
1723
+ - `BACKGROUND_TURNS` is the real background turns over the harness's router, so a schedule's or a delegated task's turn reaches the plugin's claims. Give your own with `servicePair(BACKGROUND_TURNS, { ... })`.
1724
+ - Once `AGENTS` is given, its `approvals` is the real confirmation judge over `providers.judge` when you pass a judge, so a held action is approved or declined as the agent server decides.
1725
+ - Giving `AGENTS` a `team` puts the agent server's own claim in the router, for the `owner`, so the plugin's claims are tested against the agent channels as on a host; its `owner` background target comes with it.
1726
+
1727
+ A plugin that fills the `runtime` slot needs none of these for its own runtime: the harness builds it from the slot (with a silent logger, a one-owner identity, in-memory held actions, and one stand-in agent setting) and puts it under `AGENTS`'s `runtime`.
780
1728
 
781
1729
  A test for the tools example:
782
1730
 
@@ -805,32 +1753,81 @@ test("note_add saves a note for the speaker and refuses an empty one", async ()
805
1753
 
806
1754
  Run every test with `bun test`, and the types with `bun run typecheck`.
807
1755
 
1756
+ ### Optional database and locale fixtures
1757
+
1758
+ Importing `pi-roundtable/testing` works with `CI=true` and no database URL.
1759
+ `describeDb` is Bun's `describe` when `ROUNDTABLE_TEST_DATABASE_URL` is set, and `describe.skip` otherwise; use it to gate a database suite.
1760
+ `testDatabaseUrl` is that URL or an empty string, and `TEST_GUILD` is a synthetic guild identifier.
1761
+ `openTestStore(Store, ...args)` runs the store's `migrations(...args)` or its single `migration`, calls `Store.attach`, and returns a `TestStore<T>` whose `close()` closes its own SQL pool.
1762
+ Use it only with your own store class in a gated database test and always close the store; it does not attach another instance of the host's stores.
1763
+ `useTestLocale()` resets the process-wide locale to English and time zone to UTC; call it after a test that changes either, not from a running plugin.
1764
+ `testPlugin`'s options take `env` (a partial `HostEnv`) for the `context.env` the plugin sees; it defaults to `en` and `UTC`.
1765
+ `OWNER_SPEAKER`, `fakeThreads`, and `silentLogger` supply neutral stand-ins for owner turns, dispatch threads, and logging.
1766
+ `fakeDiscord({ ownerId?, rootCommand? })` is the `DISCORD` service for a plugin that adds slash commands: give it as `services: [discord.service]`, read what the plugin added with `discord.added()`, and compose the tree Discord would get with `discord.compose()`.
1767
+ Only `commands` and `guard` are given; a plugin that reads another member of `DISCORD` in a test gives its own with `servicePair(DISCORD, { ... })`.
1768
+
1769
+ ### `testHost`: the built-in plugins and yours, over PostgreSQL
1770
+
1771
+ `testPlugin` has no built-in plugins. When a test needs the agent server, the modules, the stores, or the order the host sets the session tools up in, `testHost(options?)` boots `defineRoundtable` over the test database (`ROUNDTABLE_TEST_DATABASE_URL`, so gate the suite with `describeDb`) with Discord and the runtime standing in:
1772
+
1773
+ | Option | What it gives the host |
1774
+ |---|---|
1775
+ | `config` | Over a test configuration (an owner, a guild, a temporary `dataDir`, the test database, one agent): any of the `RoundtableConfig` keys, such as `skills: false` |
1776
+ | `plugins` | Your plugins, placed after the built-in ones as `defineRoundtable` places them |
1777
+ | `runtime` | The runtime every turn runs on; by default one that answers `""` and builds no Pi session |
1778
+ | `discord` | What the stand-in Discord hands out: `agentChannels(guildId)` and `ownerChannel()` |
1779
+
1780
+ It returns:
1781
+
1782
+ | Field | What it is |
1783
+ |---|---|
1784
+ | `context` | The `PluginContext` of a probe plugin set up after every other: read `services`, `sessions()`, or `toolTiers` from it |
1785
+ | `conversations` | The host's conversations, as a surface would drive them |
1786
+ | `commands` | `added`, every slash-command contribution the plugins handed Discord in order, and `composed()`, the tree Discord would register |
1787
+ | `sessionTools(scope?)` | The tools each extension of a session registers, in plan order (an extension that registers commands, flags, or event handlers besides tools is accepted, and only its tools are listed); the owner's session unless an `AgentTurnScope` is given |
1788
+ | `sessionContext(scope?)` | A `SessionContext` as the runtime builds it, with the real compaction wrapper |
1789
+ | `stop()` | Stops the host; call it when the test ends |
1790
+
1791
+ `useEagerCatalog()` puts the process under a catalog whose every text carries a mark and a foreign time zone, and `eagerText(value)` lists the marked strings under a value: a test that builds its composition under the catalog proves that nothing was written from it before the host applied its own environment. `useTestLocale()` gives the neutral defaults back.
1792
+
1793
+ `holdChain(rules)` is in `pi-roundtable/kit`: the chain the host links from every plugin's hold rules, to test a rule set without a harness.
1794
+
808
1795
  ## What happens when the bot starts and stops
809
1796
 
810
1797
  `roundtable start` first runs the checks that need no network (Bun, `.env`, the configuration, the plugins, the model login, the public URL), and stops with the message `roundtable doctor` prints for a failed one.
811
1798
  Then the host runs `run()`:
812
1799
 
813
- 1. Providers are resolved: each slot from the plugin that fills it, or the core's default.
1800
+ 1. Each plugin's `replaces` is applied: the plugin that provided a replaced service is dropped, and the replacement stands where it stood.
1801
+ Providers are then resolved: each slot from the plugin that fills it, or the core's default.
1802
+ The agent server builds its runtime later, in its own setup: from the `runtime` slot when a plugin fills it, else the Pi runtime.
814
1803
  2. The database is opened and every plugin's migrations run, in plugin order.
815
1804
  3. Every plugin's `setup` runs, in plugin order.
816
- The order is the built-ins (`stores`, `discord`, `modules`, `agent-server`, `seeds`), then yours in the order of `plugins` in `roundtable.config.ts`, then the built-in `schedules`, so a due schedule fires only once everything it can reach is running.
1805
+ The order is the built-ins (`memory`, `schedule-store`, `discord`, `modules`, `discord-admin`, `skills`, `agent-server`, `seeds`; an addon that is switched off is not there), then yours in the order of `plugins` in `roundtable.config.ts`, then the built-in `schedules`, so a due schedule fires only once everything it can reach is running.
817
1806
  4. The contributions are linked: tool tiers, hold rules, the session plan, the channel router, and the events.
818
- From here `sessions()`, `conversations`, and `dashboard()` work.
1807
+ From here `sessions()`, `conversations`, `surfaces`, `turns`, and `dashboard()` work.
819
1808
  5. Every plugin's `preflight` runs, in plugin order.
820
- 6. The composed slash commands are handed to the plugins that take them.
821
- 7. Every service starts: the plugins' in plugin order, and each plugin's own in the order it listed them.
822
- 8. The HTTP listener opens.
823
- 9. The agent server starts in the background, and then every plugin hears `agentServer("ready")` or `agentServer("failed")`.
1809
+ The Discord plugin's preflight is where it composes the slash commands every plugin added in `setup`; a clash stops the start here.
1810
+ 6. Every service starts: the plugins' in plugin order, and each plugin's own in the order it listed them, after the plugin's chat surfaces (the `surface:<prefix>` services).
1811
+ 7. The HTTP listeners open.
1812
+ 8. Every service's `startInBackground` runs, at once and without holding up the boot, and every plugin hears `serviceStarted` as each ends; the agent server's `team` service starts the agents' channels and the dashboard there.
824
1813
 
825
1814
  Nothing reaches Discord or the listener unless steps 1 to 5 succeeded.
826
1815
 
1816
+ `run()` first applies the host's environment to the process: the locale and time zone, the assistant's name, and `PI_CODING_AGENT_DIR` from `options.environment`; `defineRoundtable` only puts them in the options.
1817
+ Text from the message catalog is therefore read after that: a plugin builds its command and tool descriptions in `setup` or later, never at import or in its factory; the Discord plugin builds the root command's description in its preflight, once the environment is in effect.
1818
+ One host runs per process, because the message catalog and the time zone are process-wide: a second `run()` while one host is running is refused, so stop the first host or run the second in a separate process.
1819
+ If any step fails, the host stops what it had started, listeners first and then services in reverse, closes the pool, and rethrows; the process then exits non-zero.
1820
+ The same host may call `run()` again after that.
1821
+
827
1822
  On `SIGTERM` or `SIGINT` the bot stops serving new work last:
828
1823
 
829
- 1. It keeps serving until no service reports `busy()` work, for at most an hour; whatever is left is logged and given up on.
1824
+ 1. It keeps serving until the channel queue is empty and no service reports `busy()` work, for at most an hour; whatever is left is logged and given up on.
830
1825
  2. Every plugin hears `shutdown(left)`, while every service is still running.
831
1826
  3. The HTTP listener closes, so no request reaches a service that has stopped.
832
1827
  4. Services stop in the reverse of the order they started.
833
- 5. The database pool closes, and the process exits.
1828
+ 5. The database pool closes.
1829
+
1830
+ `shutdown()` returns the exit code, `0` or `1` when a listener, a service, or the pool failed to stop, and every call shares the one shutdown; only `listen()`, which the command line and Merlin call, exits the process with it.
834
1831
 
835
1832
  ## Errors and their fixes
836
1833
 
@@ -846,7 +1843,7 @@ These are the messages as the code writes them, with `<...>` where your names go
846
1843
  | `plugin <name>: setup is missing. Give the function that returns what the plugin adds.` | Add `setup` |
847
1844
  | `plugin <name> adds nothing. Give it a part (tools, services, channels, and so on), a migration, or a provider, or remove it.` | Return a part from `setup`, or remove the plugin from `roundtable.config.ts` |
848
1845
  | `plugin <name>: setup must return an object of the parts it adds; return {} to add none.` | Return an object, not `undefined` |
849
- | `plugin <name>: setup returned an unknown part "<key>". Did you mean "<closest>"? The parts are services, events, interactions, http, holdRules, piPackages, sessionTools, channels, dashboard, tools, seeds, prompt, agentSelection.` | Fix the key; `migrations`, `providers`, and `preflight` belong on the plugin, not in what `setup` returns |
1846
+ | `plugin <name>: setup returned an unknown part "<key>". Did you mean "<closest>"? The parts are services, events, http, holdRules, piPackages, sessionTools, channels, surfaces, personas, backgroundTargets, dashboard, tools, seeds, prompt, agentSelection, requiredTools.` | Fix the key; `migrations`, `providers`, and `preflight` belong on the plugin, not in what `setup` returns |
850
1847
 
851
1848
  ### Tools
852
1849
 
@@ -866,27 +1863,43 @@ These are the messages as the code writes them, with `<...>` where your names go
866
1863
  | `plugin <b>: service <name> is already registered by plugin <a>. Rename one of the two.` | Rename one service |
867
1864
  | `plugin <b>: hold rule <name> is already registered by plugin <a>. Rename one of the two.` | Rename one rule |
868
1865
  | `plugin <b>: provider slot <slot> is already filled by plugin <a>. Keep one plugin that fills it.` | Fill each slot from one plugin only |
869
- | `plugins <a> and <b> both start the agent server. Keep one.` | Do not define `agentServer` on your plugin; the built-in one starts it |
1866
+ | `plugin <name>: "<field>" was removed in 0.2.0; <replacement>.` (`useCommands`, `agentServer`, `stopTurn`) | Use what the message names: `commands.add`, a service with startInBackground and a serviceStarted handler, or stop on the channel claim |
1867
+ | `plugin <name>: the "interactions" part was removed in 0.2.0; …` | Call `context.services.get(DISCORD).commands.add({ module, rootOptions })` from `setup`, with `DISCORD` from `pi-roundtable/discord` |
1868
+ | `plugin <name>: surface <prefix> has useCommands, which was removed in 0.2.0; …` | Drop `useCommands` from the surface; nothing calls it |
1869
+ | `RoundtableOptions.commands was removed in 0.2.0; …` | Drop the option; the root command is `discord.rootCommand` in the configuration |
1870
+ | `plugin <name>: the "agentServer" event was removed in 0.2.0; hear serviceStarted instead, which names the plugin and service whose background start ended.` | Handle `serviceStarted`, and check `event.plugin` and `event.service` |
1871
+ | `plugin <b>: surface <prefix> is already registered by plugin <a>. Rename one of the two.` | Serve one prefix from one plugin only |
1872
+ | `plugin <name>: a surface is named by the prefix of its channel keys, a non-empty word without a colon or space, such as discord; got "<value>".` | Give the surface a prefix like `fake` |
1873
+ | `plugin <name>: unknown provider slot "<slot>". Did you mean "<closest>"? The slots are judge, images, runtime.` | Fill one of the listed slots |
1874
+ | `plugin <b>: persona kind "<kind>" is already registered by plugin <a>. Keep one persona per kind.` | Give each conversation kind one persona |
1875
+ | `plugin <name>: the persona kind "agent" is reserved for the agent server's agents. Give the persona the kind of your own conversations.` | Name your own kind |
1876
+ | `plugin <b>: background target "<name>" is already registered by plugin <a>. Keep one target per name.` | Give each background target one plugin, or rename one of them |
1877
+ | `no plugin contributes the background target "<name>"` (a schedule's last status, or a skipped turn) | Contribute the target from the plugin whose claim serves it, or cancel the schedule |
1878
+ | `no persona is registered for the conversation kind "<kind>". A plugin adds one with personas: [...], or its claim must start conversations of a kind that has one.` (a failed turn) | Contribute a persona of that kind, or run the turn with a kind that has one |
1879
+ | `no runtime provider is configured: the agent server builds the Pi runtime when no plugin fills the runtime slot` | Only a plugin that calls `providers.runtime` without `providers.filled.has("runtime")` sees it; check `filled` first |
870
1880
  | `session tool <name> takes a core extension name. Rename it.` | Pick a name other than the core's |
871
1881
  | `two plugins are named <name>.` (from `roundtable doctor`) | Rename yours; the built-in plugins are `stores`, `discord`, `modules`, `agent-server`, `seeds`, and `schedules` |
872
- | `migration <name> is declared twice` | Migration names are shared by every plugin: prefix each with its plugin's name |
1882
+ | `migration <plugin>/<name> is declared twice` | A plugin declares two migrations with one name: rename one (the same name in two plugins is fine) |
873
1883
  | `/<root> <name> is added twice`, `/<name> is registered twice` | Give each slash command and subcommand its own name |
874
1884
  | `route <name> is registered twice`, `routes <a> and <b> overlap on listener <id>` | Give each route its own name and a path no other route can take |
875
1885
 
876
1886
  ### Things used before they are ready
877
1887
 
878
- `sessions()`, `conversations`, and `dashboard()` are linked after every plugin is set up.
1888
+ `sessions()`, `conversations`, `surfaces`, `turns`, and `dashboard()` are linked after every plugin is set up.
879
1889
  Calling one from `setup` fails, and the message says when it becomes ready:
880
1890
 
881
1891
  ```text
882
1892
  plugin <name>: setup failed: session parts are linked once every plugin is set up. Call sessions() from a service's start or from a handler, not during setup. Fix the error, or remove the plugin.
883
1893
  ```
884
1894
 
885
- The same message exists for `conversations` (`Use them from a service's start or from a handler, not during setup.`) and for `dashboard()`.
1895
+ The same message exists for `conversations` (`Use them from a service's start or from a handler, not during setup.`), for `surfaces` (`chat surfaces are linked once every plugin is set up.`), for `turns` (`conversation turns are linked once every plugin is set up.`), and for `dashboard()`.
886
1896
  The fix is the one it says: move the call into a service's `start` or into an event handler.
887
1897
 
888
- `context.core.<service>` before the built-in plugin that provides it has run throws `core service <name> is not provided yet. Register the built-in plugin that provides it before the plugin that reads it.`
889
- Your plugins always run after the built-ins, so this shows only in `testPlugin`, which has none: pass what you need or test that part elsewhere.
1898
+ `context.services.get(KEY)` before the plugin that provides it has run throws `service <id> is not provided yet; plugin <name> provides it. Register plugin <name> before plugin <yours>.`
1899
+ When no registered plugin declares the key it says `service <id> is not provided. Register a plugin that provides it, before the plugin that reads it.`
1900
+ Your plugins always run after the built-ins, so the built-in keys show this only in `testPlugin`, which has none and says so: `service <id> is not provided. testPlugin has no built-in plugins: give it in the services option, ...`.
1901
+ Pass what you need in the harness's `services` option, or test that part elsewhere.
1902
+ `find(KEY)` is `undefined` for a service nobody declares, in the host and in the harness.
890
1903
 
891
1904
  ### A setup or a migration that throws
892
1905
 
@@ -912,7 +1925,8 @@ Migrations are idempotent, so start again once the cause is fixed.
912
1925
 
913
1926
  | Message | Fix |
914
1927
  |---|---|
915
- | `interactions need a configured root command` | Only reachable if you build the host yourself; `defineRoundtable` always sets it |
1928
+ | `commands can be added only while plugins set up: the Discord plugin composed them in its preflight, …` | Call `commands.add` from `setup`, not from a service or a handler |
1929
+ | `commands.add takes { module, rootOptions? }, …` | Give it a module with `commands()` and `handle(interaction)` |
916
1930
  | `migrations need a configured database` | Only reachable if you build the host yourself; `defineRoundtable` always sets it |
917
1931
  | `no database is configured` | In `testPlugin`, pass `{ database }` to a plugin that calls `context.database()` |
918
1932
  | `route <name> needs listener <id>, which is not configured` | Use the listener `public` |
@@ -922,3 +1936,371 @@ Migrations are idempotent, so start again once the cause is fixed.
922
1936
  The text the bot shows in Discord comes from a message catalog chosen by `locale` in `roundtable.config.ts`: `en` (the default) or `zh-TW`.
923
1937
  Both catalogs have the same keys.
924
1938
  Your own plugins' text is yours to write in any language.
1939
+
1940
+ ## Consumer TypeScript configuration
1941
+
1942
+ The package ships `.ts`, so your compiler checks its source with your project's options; `skipLibCheck` skips dependency declarations, not package source.
1943
+ The following configuration was tested against an installed tarball with TypeScript 5.9.3, and the package itself is checked with TypeScript 7.0.2:
1944
+
1945
+ ```json
1946
+ {
1947
+ "compilerOptions": {
1948
+ "target": "ESNext",
1949
+ "module": "Preserve",
1950
+ "moduleResolution": "bundler",
1951
+ "lib": ["ESNext"],
1952
+ "types": ["bun"],
1953
+ "strict": true,
1954
+ "noUncheckedIndexedAccess": true,
1955
+ "noImplicitOverride": true,
1956
+ "verbatimModuleSyntax": true,
1957
+ "allowImportingTsExtensions": true,
1958
+ "skipLibCheck": true,
1959
+ "noEmit": true,
1960
+ "exactOptionalPropertyTypes": false,
1961
+ "noPropertyAccessFromIndexSignature": false
1962
+ }
1963
+ }
1964
+ ```
1965
+
1966
+ Install TypeScript and `@types/bun` as development dependencies, as the generated project does.
1967
+ Keep `exactOptionalPropertyTypes` and `noPropertyAccessFromIndexSignature` disabled: a main-only consumer produces 3 and 132 package-source errors respectively when either is enabled, even with `skipLibCheck: true`.
1968
+ These are existing compatibility limits, not flags the package overrides in your project.
1969
+ `noUnusedLocals`, `noUnusedParameters`, `noImplicitReturns`, and `noUncheckedSideEffectImports` were also tested enabled and pass.
1970
+ With `skipLibCheck: false`, the example consumer instead reports 97 dependency-declaration errors, so keep it enabled for this configuration.
1971
+ Discord-facing signatures, which are in `pi-roundtable/discord` and, for the composed slash commands, in `pi-roundtable/testing`, use the package's pinned `discord.js` types; use those compatible types for panel rows and interaction handlers. `discord.js` is a regular dependency of the package, so a project that imports either entry installs it with `pi-roundtable`.
1972
+
1973
+ ## Name-to-entry index
1974
+
1975
+ Type-only exports require `import type` when `verbatimModuleSyntax` is enabled.
1976
+ Names occur in exactly one entry; a wrong-path import is a TypeScript and runtime error.
1977
+ The source area files are not package subpaths.
1978
+
1979
+ | Name | Entry | Kind |
1980
+ |---|---|---|
1981
+ | `AGENTS` | `pi-roundtable` | value |
1982
+ | `AGENT_SERVER_PLUGIN` | `pi-roundtable` | value |
1983
+ | `AGENT_SERVER_PRIORITY` | `pi-roundtable` | value |
1984
+ | `AGENT_TEAM_SERVICE` | `pi-roundtable` | value |
1985
+ | `Admission` | `pi-roundtable` | type |
1986
+ | `Agent` | `pi-roundtable` | type |
1987
+ | `AgentChange` | `pi-roundtable` | type |
1988
+ | `AgentDirectory` | `pi-roundtable` | type |
1989
+ | `AgentGroup` | `pi-roundtable` | type |
1990
+ | `AgentRunError` | `pi-roundtable` | value |
1991
+ | `AgentRuntime` | `pi-roundtable` | type |
1992
+ | `AgentSeed` | `pi-roundtable` | type |
1993
+ | `AgentServer` | `pi-roundtable` | type |
1994
+ | `AgentSessions` | `pi-roundtable` | type |
1995
+ | `AgentStatus` | `pi-roundtable` | type |
1996
+ | `AgentTeam` | `pi-roundtable` | type |
1997
+ | `AgentTurnScope` | `pi-roundtable` | type |
1998
+ | `Approval` | `pi-roundtable` | type |
1999
+ | `AskOption` | `pi-roundtable` | type |
2000
+ | `AttachmentFailure` | `pi-roundtable` | type |
2001
+ | `AttachmentRef` | `pi-roundtable` | type |
2002
+ | `AvatarMode` | `pi-roundtable` | type |
2003
+ | `AvatarStudio` | `pi-roundtable` | type |
2004
+ | `BACKGROUND_TURNS` | `pi-roundtable` | value |
2005
+ | `BackgroundTarget` | `pi-roundtable` | type |
2006
+ | `BackgroundTurn` | `pi-roundtable` | type |
2007
+ | `BackgroundTurns` | `pi-roundtable` | type |
2008
+ | `ChannelClaim` | `pi-roundtable` | type |
2009
+ | `ChannelKey` | `pi-roundtable` | type |
2010
+ | `ChatSurface` | `pi-roundtable` | type |
2011
+ | `ConfigError` | `pi-roundtable` | value |
2012
+ | `ContextUse` | `pi-roundtable` | type |
2013
+ | `Contribution` | `pi-roundtable` | type |
2014
+ | `ConversationKind` | `pi-roundtable` | type |
2015
+ | `ConversationPort` | `pi-roundtable` | type |
2016
+ | `ConversationTurnInput` | `pi-roundtable` | type |
2017
+ | `ConversationTurns` | `pi-roundtable` | type |
2018
+ | `DELEGATION` | `pi-roundtable` | value |
2019
+ | `DefineOverrides` | `pi-roundtable` | type |
2020
+ | `DefinedRoundtable` | `pi-roundtable` | type |
2021
+ | `DelegationError` | `pi-roundtable` | value |
2022
+ | `DelegationJob` | `pi-roundtable` | type |
2023
+ | `DelegationOutcome` | `pi-roundtable` | type |
2024
+ | `DelegationRequest` | `pi-roundtable` | type |
2025
+ | `Delegator` | `pi-roundtable` | type |
2026
+ | `DrainOptions` | `pi-roundtable` | type |
2027
+ | `EventHandlers` | `pi-roundtable` | type |
2028
+ | `EventSink` | `pi-roundtable` | type |
2029
+ | `GroupStatus` | `pi-roundtable` | type |
2030
+ | `HeldActionStore` | `pi-roundtable` | type |
2031
+ | `HeldCall` | `pi-roundtable` | type |
2032
+ | `HoldCheck` | `pi-roundtable` | type |
2033
+ | `HoldContext` | `pi-roundtable` | type |
2034
+ | `HoldRule` | `pi-roundtable` | type |
2035
+ | `HostEnv` | `pi-roundtable` | type |
2036
+ | `HostEnvironment` | `pi-roundtable` | type |
2037
+ | `HttpRoute` | `pi-roundtable` | type |
2038
+ | `ImageDrawer` | `pi-roundtable` | type |
2039
+ | `InboundMessage` | `pi-roundtable` | type |
2040
+ | `Judge` | `pi-roundtable` | type |
2041
+ | `JudgeError` | `pi-roundtable` | value |
2042
+ | `JudgeModel` | `pi-roundtable` | type |
2043
+ | `LinkedSessions` | `pi-roundtable` | type |
2044
+ | `ListenerAddress` | `pi-roundtable` | type |
2045
+ | `ListenerConfig` | `pi-roundtable` | type |
2046
+ | `LoadedSkill` | `pi-roundtable` | type |
2047
+ | `Locale` | `pi-roundtable` | type |
2048
+ | `LogEntry` | `pi-roundtable` | type |
2049
+ | `LogFn` | `pi-roundtable` | type |
2050
+ | `Logger` | `pi-roundtable` | type |
2051
+ | `MEMORY` | `pi-roundtable` | value |
2052
+ | `MEMORY_KINDS` | `pi-roundtable` | value |
2053
+ | `Memory` | `pi-roundtable` | type |
2054
+ | `MemoryError` | `pi-roundtable` | value |
2055
+ | `MemoryKind` | `pi-roundtable` | type |
2056
+ | `MemoryStore` | `pi-roundtable` | type |
2057
+ | `Migration` | `pi-roundtable` | type |
2058
+ | `MigrationError` | `pi-roundtable` | value |
2059
+ | `MigrationReport` | `pi-roundtable` | type |
2060
+ | `ModelImage` | `pi-roundtable` | type |
2061
+ | `NO_ATTACHMENTS` | `pi-roundtable` | value |
2062
+ | `NewSchedule` | `pi-roundtable` | type |
2063
+ | `NotLinkedError` | `pi-roundtable` | value |
2064
+ | `OWNER_TARGET` | `pi-roundtable` | value |
2065
+ | `OutboundReply` | `pi-roundtable` | type |
2066
+ | `OwnerAnswer` | `pi-roundtable` | type |
2067
+ | `OwnerPrompts` | `pi-roundtable` | type |
2068
+ | `OwnerQuestion` | `pi-roundtable` | type |
2069
+ | `PendingConfirmation` | `pi-roundtable` | type |
2070
+ | `Persona` | `pi-roundtable` | type |
2071
+ | `PluginContext` | `pi-roundtable` | type |
2072
+ | `PluginError` | `pi-roundtable` | value |
2073
+ | `PromptMemory` | `pi-roundtable` | type |
2074
+ | `PromptSection` | `pi-roundtable` | type |
2075
+ | `PromptTurn` | `pi-roundtable` | type |
2076
+ | `Pronouns` | `pi-roundtable` | type |
2077
+ | `ProviderError` | `pi-roundtable` | value |
2078
+ | `Providers` | `pi-roundtable` | type |
2079
+ | `QueuePort` | `pi-roundtable` | type |
2080
+ | `Recurrence` | `pi-roundtable` | type |
2081
+ | `ReferenceImage` | `pi-roundtable` | type |
2082
+ | `ResolvedProviders` | `pi-roundtable` | type |
2083
+ | `ResolvedSkill` | `pi-roundtable` | type |
2084
+ | `Roundtable` | `pi-roundtable` | value |
2085
+ | `RoundtableConfig` | `pi-roundtable` | type |
2086
+ | `RoundtableOptions` | `pi-roundtable` | type |
2087
+ | `RoundtablePlugin` | `pi-roundtable` | type |
2088
+ | `RuntimeDeps` | `pi-roundtable` | type |
2089
+ | `RuntimeFactory` | `pi-roundtable` | type |
2090
+ | `SCHEDULES` | `pi-roundtable` | value |
2091
+ | `SKILLS` | `pi-roundtable` | value |
2092
+ | `Schedule` | `pi-roundtable` | type |
2093
+ | `ScheduleChange` | `pi-roundtable` | type |
2094
+ | `ScheduleError` | `pi-roundtable` | value |
2095
+ | `ScheduleStore` | `pi-roundtable` | type |
2096
+ | `ScheduledOutcome` | `pi-roundtable` | type |
2097
+ | `Service` | `pi-roundtable` | type |
2098
+ | `ServiceKey` | `pi-roundtable` | type |
2099
+ | `ServiceStartOutcome` | `pi-roundtable` | type |
2100
+ | `ServiceStartedEvent` | `pi-roundtable` | type |
2101
+ | `Services` | `pi-roundtable` | type |
2102
+ | `SessionContext` | `pi-roundtable` | type |
2103
+ | `SessionPlan` | `pi-roundtable` | type |
2104
+ | `SessionTool` | `pi-roundtable` | type |
2105
+ | `SessionToolSnapshot` | `pi-roundtable` | type |
2106
+ | `SkillCatalogEntry` | `pi-roundtable` | type |
2107
+ | `SkillRegistry` | `pi-roundtable` | type |
2108
+ | `SkillSet` | `pi-roundtable` | type |
2109
+ | `SkillSource` | `pi-roundtable` | type |
2110
+ | `Speaker` | `pi-roundtable` | type |
2111
+ | `SpeakerMemory` | `pi-roundtable` | type |
2112
+ | `StoredAttachment` | `pi-roundtable` | type |
2113
+ | `SurfacePort` | `pi-roundtable` | type |
2114
+ | `THE_SPEAKER` | `pi-roundtable` | value |
2115
+ | `TIERS` | `pi-roundtable` | value |
2116
+ | `TeamAgentStatus` | `pi-roundtable` | type |
2117
+ | `TeamStatus` | `pi-roundtable` | type |
2118
+ | `ThinkingLevel` | `pi-roundtable` | type |
2119
+ | `ThinkingSetting` | `pi-roundtable` | type |
2120
+ | `Tier` | `pi-roundtable` | type |
2121
+ | `TierConfig` | `pi-roundtable` | type |
2122
+ | `ToolContribution` | `pi-roundtable` | type |
2123
+ | `ToolRefusal` | `pi-roundtable` | value |
2124
+ | `ToolSelection` | `pi-roundtable` | type |
2125
+ | `ToolSpec` | `pi-roundtable` | type |
2126
+ | `ToolTierTable` | `pi-roundtable` | type |
2127
+ | `ToolTiers` | `pi-roundtable` | type |
2128
+ | `ToolTurn` | `pi-roundtable` | type |
2129
+ | `TranscriptEntry` | `pi-roundtable` | type |
2130
+ | `TransientTask` | `pi-roundtable` | type |
2131
+ | `TurnAttachments` | `pi-roundtable` | type |
2132
+ | `TurnEndEvent` | `pi-roundtable` | type |
2133
+ | `TurnEvent` | `pi-roundtable` | type |
2134
+ | `TurnRequest` | `pi-roundtable` | type |
2135
+ | `TurnResult` | `pi-roundtable` | type |
2136
+ | `TurnSelection` | `pi-roundtable` | type |
2137
+ | `Weekday` | `pi-roundtable` | type |
2138
+ | `channelKey` | `pi-roundtable` | value |
2139
+ | `definePlugin` | `pi-roundtable` | value |
2140
+ | `defineRoundtable` | `pi-roundtable` | value |
2141
+ | `defineTool` | `pi-roundtable` | value |
2142
+ | `migrateDatabase` | `pi-roundtable` | value |
2143
+ | `parseChannelKey` | `pi-roundtable` | value |
2144
+ | `serviceKey` | `pi-roundtable` | value |
2145
+ | `FakeDiscord` | `pi-roundtable/testing` | type |
2146
+ | `FakeThreadHost` | `pi-roundtable/testing` | type |
2147
+ | `OWNER_SPEAKER` | `pi-roundtable/testing` | value |
2148
+ | `RecordedEvent` | `pi-roundtable/testing` | type |
2149
+ | `ServicePair` | `pi-roundtable/testing` | type |
2150
+ | `TEST_GUILD` | `pi-roundtable/testing` | value |
2151
+ | `TestHost` | `pi-roundtable/testing` | type |
2152
+ | `TestHostOptions` | `pi-roundtable/testing` | type |
2153
+ | `TestLocale` | `pi-roundtable/testing` | type |
2154
+ | `TestPluginOptions` | `pi-roundtable/testing` | type |
2155
+ | `TestPluginResult` | `pi-roundtable/testing` | type |
2156
+ | `TestStore` | `pi-roundtable/testing` | type |
2157
+ | `describeDb` | `pi-roundtable/testing` | value |
2158
+ | `eagerText` | `pi-roundtable/testing` | value |
2159
+ | `fakeDiscord` | `pi-roundtable/testing` | value |
2160
+ | `fakeThreads` | `pi-roundtable/testing` | value |
2161
+ | `openTestStore` | `pi-roundtable/testing` | value |
2162
+ | `servicePair` | `pi-roundtable/testing` | value |
2163
+ | `silentLogger` | `pi-roundtable/testing` | value |
2164
+ | `testDatabaseUrl` | `pi-roundtable/testing` | value |
2165
+ | `testHost` | `pi-roundtable/testing` | value |
2166
+ | `testPlugin` | `pi-roundtable/testing` | value |
2167
+ | `useEagerCatalog` | `pi-roundtable/testing` | value |
2168
+ | `useTestLocale` | `pi-roundtable/testing` | value |
2169
+ | `AUTO_THINKING` | `pi-roundtable/kit` | value |
2170
+ | `AgentCategory` | `pi-roundtable/kit` | type |
2171
+ | `AgentChannelLookup` | `pi-roundtable/kit` | type |
2172
+ | `AgentChannels` | `pi-roundtable/kit` | type |
2173
+ | `AgentError` | `pi-roundtable/kit` | value |
2174
+ | `AgentModels` | `pi-roundtable/kit` | type |
2175
+ | `AgentOps` | `pi-roundtable/kit` | type |
2176
+ | `AgentPost` | `pi-roundtable/kit` | type |
2177
+ | `AgentTurnRunner` | `pi-roundtable/kit` | type |
2178
+ | `AssistantLike` | `pi-roundtable/kit` | type |
2179
+ | `Backlog` | `pi-roundtable/kit` | type |
2180
+ | `CategoryLayout` | `pi-roundtable/kit` | type |
2181
+ | `ChannelMessage` | `pi-roundtable/kit` | type |
2182
+ | `ChannelQueue` | `pi-roundtable/kit` | type |
2183
+ | `ChoiceAnswer` | `pi-roundtable/kit` | type |
2184
+ | `ChoiceQuestion` | `pi-roundtable/kit` | type |
2185
+ | `DELEGATE_TOOL` | `pi-roundtable/kit` | value |
2186
+ | `DELEGATE_TOOL_SPEC` | `pi-roundtable/kit` | value |
2187
+ | `DashboardBoard` | `pi-roundtable/kit` | type |
2188
+ | `DelegationWorker` | `pi-roundtable/kit` | type |
2189
+ | `DispatchThread` | `pi-roundtable/kit` | type |
2190
+ | `DispatchThreads` | `pi-roundtable/kit` | type |
2191
+ | `DispatchThreadsOptions` | `pi-roundtable/kit` | type |
2192
+ | `EffortBrief` | `pi-roundtable/kit` | type |
2193
+ | `EffortJudgeOptions` | `pi-roundtable/kit` | type |
2194
+ | `EffortLevel` | `pi-roundtable/kit` | type |
2195
+ | `EffortPicker` | `pi-roundtable/kit` | type |
2196
+ | `GroupMessage` | `pi-roundtable/kit` | type |
2197
+ | `JUDGE_WORK` | `pi-roundtable/kit` | value |
2198
+ | `McpEndpoint` | `pi-roundtable/kit` | type |
2199
+ | `ModelRef` | `pi-roundtable/kit` | type |
2200
+ | `OwnerIdentity` | `pi-roundtable/kit` | type |
2201
+ | `OwnerNotifier` | `pi-roundtable/kit` | type |
2202
+ | `PreviousTurn` | `pi-roundtable/kit` | type |
2203
+ | `PromptSlot` | `pi-roundtable/kit` | type |
2204
+ | `SCHEDULE_TOOLS` | `pi-roundtable/kit` | value |
2205
+ | `SHELL_TOOLS` | `pi-roundtable/kit` | value |
2206
+ | `SKILL_LIST_TOOL` | `pi-roundtable/kit` | value |
2207
+ | `ScheduleToolContext` | `pi-roundtable/kit` | type |
2208
+ | `ScheduleToolName` | `pi-roundtable/kit` | type |
2209
+ | `ScheduleToolSpec` | `pi-roundtable/kit` | type |
2210
+ | `ScheduleToolWording` | `pi-roundtable/kit` | type |
2211
+ | `ScoreQuestion` | `pi-roundtable/kit` | type |
2212
+ | `SpeakerFacts` | `pi-roundtable/kit` | type |
2213
+ | `SpeakerPolicy` | `pi-roundtable/kit` | type |
2214
+ | `THINKING_LEVELS` | `pi-roundtable/kit` | value |
2215
+ | `TextToolDef` | `pi-roundtable/kit` | type |
2216
+ | `ThinkingPicker` | `pi-roundtable/kit` | type |
2217
+ | `ThreadHost` | `pi-roundtable/kit` | type |
2218
+ | `ToolInput` | `pi-roundtable/kit` | type |
2219
+ | `VirtualServer` | `pi-roundtable/kit` | type |
2220
+ | `YesNoQuestion` | `pi-roundtable/kit` | type |
2221
+ | `activeToolsExtension` | `pi-roundtable/kit` | value |
2222
+ | `approvalCard` | `pi-roundtable/kit` | value |
2223
+ | `archiveSessions` | `pi-roundtable/kit` | value |
2224
+ | `attachmentsOf` | `pi-roundtable/kit` | value |
2225
+ | `callScheduleTool` | `pi-roundtable/kit` | value |
2226
+ | `canonicalJson` | `pi-roundtable/kit` | value |
2227
+ | `channelQueue` | `pi-roundtable/kit` | value |
2228
+ | `channelSegment` | `pi-roundtable/kit` | value |
2229
+ | `checkRepoName` | `pi-roundtable/kit` | value |
2230
+ | `discordKey` | `pi-roundtable/kit` | value |
2231
+ | `effortJudge` | `pi-roundtable/kit` | value |
2232
+ | `formatModelRef` | `pi-roundtable/kit` | value |
2233
+ | `headline` | `pi-roundtable/kit` | value |
2234
+ | `holdChain` | `pi-roundtable/kit` | value |
2235
+ | `isScheduleTool` | `pi-roundtable/kit` | value |
2236
+ | `lastAssistant` | `pi-roundtable/kit` | value |
2237
+ | `mcpAdapterExtension` | `pi-roundtable/kit` | value |
2238
+ | `mcpExtension` | `pi-roundtable/kit` | value |
2239
+ | `outcome` | `pi-roundtable/kit` | value |
2240
+ | `ownerAttachmentDir` | `pi-roundtable/kit` | value |
2241
+ | `parseModelRef` | `pi-roundtable/kit` | value |
2242
+ | `promptSlot` | `pi-roundtable/kit` | value |
2243
+ | `quietLinks` | `pi-roundtable/kit` | value |
2244
+ | `readAttachmentExtension` | `pi-roundtable/kit` | value |
2245
+ | `requiredString` | `pi-roundtable/kit` | value |
2246
+ | `runWorkerTask` | `pi-roundtable/kit` | value |
2247
+ | `scheduleToolSpecs` | `pi-roundtable/kit` | value |
2248
+ | `searchTerms` | `pi-roundtable/kit` | value |
2249
+ | `settleTurn` | `pi-roundtable/kit` | value |
2250
+ | `shellHoldRule` | `pi-roundtable/kit` | value |
2251
+ | `skillListExtension` | `pi-roundtable/kit` | value |
2252
+ | `splitReply` | `pi-roundtable/kit` | value |
2253
+ | `stringList` | `pi-roundtable/kit` | value |
2254
+ | `textOf` | `pi-roundtable/kit` | value |
2255
+ | `textToolsExtension` | `pi-roundtable/kit` | value |
2256
+ | `thinkingLabel` | `pi-roundtable/kit` | value |
2257
+ | `thinkingLine` | `pi-roundtable/kit` | value |
2258
+ | `toolError` | `pi-roundtable/kit` | value |
2259
+ | `toolText` | `pi-roundtable/kit` | value |
2260
+ | `withAttachmentsBlock` | `pi-roundtable/kit` | value |
2261
+ | `withReference` | `pi-roundtable/kit` | value |
2262
+ | `workTimeout` | `pi-roundtable/kit` | value |
2263
+ | `zonedStamp` | `pi-roundtable/kit` | value |
2264
+ | `AgentPanel` | `pi-roundtable/discord` | type |
2265
+ | `AgentPanelMessage` | `pi-roundtable/discord` | type |
2266
+ | `AgentPanelOptions` | `pi-roundtable/discord` | type |
2267
+ | `CHANNEL_OPERATIONS` | `pi-roundtable/discord` | value |
2268
+ | `CHANNEL_TOOLS` | `pi-roundtable/discord` | value |
2269
+ | `ChannelExecutor` | `pi-roundtable/discord` | type |
2270
+ | `ChannelInfo` | `pi-roundtable/discord` | type |
2271
+ | `ChannelOperation` | `pi-roundtable/discord` | type |
2272
+ | `ChannelTool` | `pi-roundtable/discord` | type |
2273
+ | `ChannelToolError` | `pi-roundtable/discord` | value |
2274
+ | `CommandGuard` | `pi-roundtable/discord` | type |
2275
+ | `CommandGuardOptions` | `pi-roundtable/discord` | type |
2276
+ | `CommandRegistrar` | `pi-roundtable/discord` | type |
2277
+ | `CommandRoot` | `pi-roundtable/discord` | type |
2278
+ | `ComposedCommands` | `pi-roundtable/discord` | type |
2279
+ | `DISCORD` | `pi-roundtable/discord` | value |
2280
+ | `DISCORD_ADMIN_TOOLS` | `pi-roundtable/discord` | value |
2281
+ | `DiscordConnection` | `pi-roundtable/discord` | type |
2282
+ | `DiscordServices` | `pi-roundtable/discord` | type |
2283
+ | `InteractionContribution` | `pi-roundtable/discord` | type |
2284
+ | `InteractionModule` | `pi-roundtable/discord` | type |
2285
+ | `ManagedChannel` | `pi-roundtable/discord` | type |
2286
+ | `OPERATION_PERMISSIONS` | `pi-roundtable/discord` | value |
2287
+ | `OwnerCommandHandlers` | `pi-roundtable/discord` | type |
2288
+ | `OwnerFacingError` | `pi-roundtable/discord` | value |
2289
+ | `OwnerOperations` | `pi-roundtable/discord` | type |
2290
+ | `PanelContent` | `pi-roundtable/discord` | type |
2291
+ | `RootOption` | `pi-roundtable/discord` | type |
2292
+ | `agentPanel` | `pi-roundtable/discord` | value |
2293
+ | `commandGuard` | `pi-roundtable/discord` | value |
2294
+ | `composeCommands` | `pi-roundtable/discord` | value |
2295
+ | `ephemeralPanel` | `pi-roundtable/discord` | value |
2296
+ | `fetchManagedChannel` | `pi-roundtable/discord` | value |
2297
+ | `groupOption` | `pi-roundtable/discord` | value |
2298
+ | `isChannelOperation` | `pi-roundtable/discord` | value |
2299
+ | `operationLabel` | `pi-roundtable/discord` | value |
2300
+ | `ownerCommandModule` | `pi-roundtable/discord` | value |
2301
+ | `ownerPanel` | `pi-roundtable/discord` | value |
2302
+ | `ownerPanels` | `pi-roundtable/discord` | value |
2303
+ | `ownerRootCommand` | `pi-roundtable/discord` | value |
2304
+ | `parseChannelTool` | `pi-roundtable/discord` | value |
2305
+ | `plain` | `pi-roundtable/discord` | value |
2306
+ | `replyWithPanels` | `pi-roundtable/discord` | value |