pi-roundtable 0.7.19 → 0.9.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 (177) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/README.md +72 -15
  3. package/README.zh-TW.md +65 -13
  4. package/docs/migrating-0.8.md +132 -0
  5. package/docs/migration-0.9.md +247 -0
  6. package/docs/plugins.md +853 -81
  7. package/examples/channel-context.test.ts +88 -0
  8. package/examples/channel-context.ts +62 -0
  9. package/examples/discord-adapter.test.ts +42 -0
  10. package/examples/discord-adapter.ts +26 -0
  11. package/examples/echo-runtime.test.ts +1 -0
  12. package/examples/echo-runtime.ts +1 -1
  13. package/examples/fake-surface.test.ts +69 -6
  14. package/examples/fake-surface.ts +73 -7
  15. package/examples/headless.test.ts +72 -0
  16. package/examples/headless.ts +35 -0
  17. package/examples/http.test.ts +31 -1
  18. package/examples/http.ts +39 -0
  19. package/examples/prechecks.test.ts +1 -0
  20. package/examples/principals.test.ts +60 -0
  21. package/examples/principals.ts +48 -0
  22. package/examples/prompt.test.ts +1 -1
  23. package/examples/study-room.test.ts +29 -1
  24. package/examples/study-room.ts +16 -19
  25. package/examples/support-desk.test.ts +3 -1
  26. package/examples/tools.test.ts +6 -1
  27. package/package.json +7 -3
  28. package/src/cli/checks/database.ts +11 -0
  29. package/src/cli/checks/identity.ts +225 -0
  30. package/src/cli/checks/model.ts +21 -3
  31. package/src/cli/cli.ts +130 -5
  32. package/src/cli/config-edit.ts +83 -13
  33. package/src/cli/doctor.ts +28 -5
  34. package/src/cli/init.ts +9 -1
  35. package/src/cli/line-diff.ts +72 -0
  36. package/src/cli/principal.ts +412 -0
  37. package/src/cli/project.ts +33 -2
  38. package/src/cli/report.ts +12 -3
  39. package/src/cli/templates.ts +46 -14
  40. package/src/cli/upgrade-rewrite.ts +102 -0
  41. package/src/cli/upgrade-source.ts +430 -0
  42. package/src/cli/upgrade.ts +176 -0
  43. package/src/core/agents/agent-claim.ts +56 -34
  44. package/src/core/agents/agent-team-fixture.ts +6 -0
  45. package/src/core/agents/team-turns.ts +10 -1
  46. package/src/core/builtin/agent-server.ts +38 -101
  47. package/src/core/builtin/discord-admin.ts +4 -1
  48. package/src/core/builtin/discord.ts +71 -13
  49. package/src/core/builtin/modules.ts +197 -43
  50. package/src/core/builtin/stores.ts +40 -21
  51. package/src/core/config/access.ts +229 -0
  52. package/src/core/config/adapters.ts +120 -0
  53. package/src/core/config/config.ts +228 -94
  54. package/src/core/contract/channel-context.ts +53 -0
  55. package/src/core/contract/channels.ts +81 -9
  56. package/src/core/contract/providers.ts +3 -2
  57. package/src/core/contract/runtime.ts +31 -11
  58. package/src/core/contract/surface.ts +35 -6
  59. package/src/core/conversations/conversation-registry.ts +56 -0
  60. package/src/core/conversations/conversation-store.ts +126 -0
  61. package/src/core/conversations/conversations-plugin.ts +29 -0
  62. package/src/core/define-roundtable.ts +157 -57
  63. package/src/core/discord/agent-panel.ts +2 -2
  64. package/src/core/discord/card-audience.ts +148 -0
  65. package/src/core/discord/channel-context.ts +348 -0
  66. package/src/core/discord/connection.ts +15 -3
  67. package/src/core/discord/direct-channel.ts +54 -0
  68. package/src/core/discord/discord-adapter.ts +9 -0
  69. package/src/core/discord/discord-owners.ts +133 -0
  70. package/src/core/discord/discord-surface.ts +69 -14
  71. package/src/core/discord/fresh-marker.ts +24 -0
  72. package/src/core/discord/inbound-message.ts +21 -7
  73. package/src/core/discord/interaction-module.ts +11 -1
  74. package/src/core/discord/owner-cards-fixture.ts +80 -0
  75. package/src/core/discord/owner-cards.ts +58 -86
  76. package/src/core/discord/owner-command.ts +27 -13
  77. package/src/core/discord/stop-button.ts +4 -4
  78. package/src/core/domain/conversation.ts +10 -0
  79. package/src/core/domain/errors.ts +5 -0
  80. package/src/core/domain/ports.ts +33 -2
  81. package/src/core/domain/progress.ts +10 -0
  82. package/src/core/events.ts +1 -0
  83. package/src/core/host.ts +32 -10
  84. package/src/core/http/listeners.ts +174 -21
  85. package/src/core/http/websocket.ts +316 -0
  86. package/src/core/i18n/cards.ts +11 -9
  87. package/src/core/identity/access-policy.ts +227 -0
  88. package/src/core/identity/actor-facts.ts +65 -0
  89. package/src/core/identity/caching-principal-store.ts +111 -0
  90. package/src/core/identity/config-owner.ts +145 -0
  91. package/src/core/identity/contact.ts +27 -0
  92. package/src/core/identity/identity-plugin.ts +56 -0
  93. package/src/core/identity/identity-schema.ts +282 -0
  94. package/src/core/identity/identity-service.ts +489 -0
  95. package/src/core/identity/identity-view.ts +77 -0
  96. package/src/core/identity/plugin-identities.ts +109 -0
  97. package/src/core/identity/principal-reads.ts +38 -0
  98. package/src/core/identity/principal-store.ts +478 -0
  99. package/src/core/identity/principal-tables.ts +53 -0
  100. package/src/core/identity/seen-throttle.ts +57 -0
  101. package/src/core/identity/ulid.ts +36 -0
  102. package/src/core/identity.ts +34 -5
  103. package/src/core/interactions/prompts.ts +127 -0
  104. package/src/core/modules/background/background-turns.ts +56 -17
  105. package/src/core/modules/background/personal-target.ts +50 -0
  106. package/src/core/modules/delegation/delegate.ts +28 -14
  107. package/src/core/modules/delegation/delegator.ts +23 -7
  108. package/src/core/modules/memory/owner-memory.ts +123 -50
  109. package/src/core/modules/notify/notify.ts +53 -13
  110. package/src/core/modules/notify/session-notify.ts +110 -0
  111. package/src/core/modules/schedules/prechecks.ts +8 -2
  112. package/src/core/modules/schedules/schedule-prechecks.ts +4 -1
  113. package/src/core/modules/schedules/schedule-store.ts +51 -20
  114. package/src/core/modules/schedules/schedule-tools.ts +122 -12
  115. package/src/core/modules/schedules/scheduler.ts +43 -2
  116. package/src/core/modules/schedules/schedules.ts +43 -14
  117. package/src/core/ops/error-reporter.ts +24 -12
  118. package/src/core/plugin.ts +62 -0
  119. package/src/core/presence/direct-channels.ts +104 -0
  120. package/src/core/registry/contributions.ts +63 -3
  121. package/src/core/registry/providers.ts +1 -1
  122. package/src/core/routing/channel-router.ts +226 -17
  123. package/src/core/routing/conversation-turns.ts +85 -1
  124. package/src/core/routing/surface-port.ts +35 -2
  125. package/src/core/routing/system-turns.ts +29 -0
  126. package/src/core/runtime/bridge-guard.ts +225 -0
  127. package/src/core/runtime/compaction-tiers.ts +5 -1
  128. package/src/core/runtime/conversation-sessions.ts +124 -12
  129. package/src/core/runtime/extensions/ask-user.ts +1 -1
  130. package/src/core/runtime/extensions/confirmation-gate.ts +32 -10
  131. package/src/core/runtime/extensions/private-memory.ts +447 -0
  132. package/src/core/runtime/extensions/self-compact-guard.ts +11 -0
  133. package/src/core/runtime/pending-confirmation-store.ts +67 -4
  134. package/src/core/runtime/pi-agent-runtime.ts +143 -135
  135. package/src/core/runtime/preflight-tools.ts +47 -0
  136. package/src/core/runtime/prompt-slot.ts +6 -6
  137. package/src/core/runtime/reader-history.ts +129 -0
  138. package/src/core/runtime/runtime-plugin.ts +205 -0
  139. package/src/core/runtime/runtime-types.ts +40 -12
  140. package/src/core/runtime/session-conversation.ts +143 -0
  141. package/src/core/runtime/session-factory.ts +115 -15
  142. package/src/core/runtime/session-scope.ts +78 -0
  143. package/src/core/runtime/steerable-run.ts +9 -0
  144. package/src/core/runtime/tool-call-scope.ts +43 -0
  145. package/src/core/runtime/turn-answer.ts +30 -1
  146. package/src/core/runtime/turn-progress.ts +116 -0
  147. package/src/core/services.ts +42 -2
  148. package/src/core/sessions.ts +30 -0
  149. package/src/core/shared/unix-server.ts +35 -5
  150. package/src/core/speakers.ts +21 -19
  151. package/src/core/testing/background.ts +31 -0
  152. package/src/core/testing/fixture-database.ts +55 -0
  153. package/src/core/testing/map-identity.ts +76 -0
  154. package/src/core/testing/module-sessions.ts +134 -0
  155. package/src/core/testing/modules.ts +149 -38
  156. package/src/core/testing/owner.ts +17 -0
  157. package/src/core/testing/surface-contract.ts +460 -0
  158. package/src/core/testing/test-host.ts +100 -19
  159. package/src/core/testing/tool-set.ts +32 -0
  160. package/src/core/tool-set.snapshot.json +4 -4
  161. package/src/core/tool-tiers.ts +57 -3
  162. package/src/discord/index.ts +10 -0
  163. package/src/index.ts +65 -8
  164. package/src/kit/compaction.ts +13 -0
  165. package/src/kit/index.ts +10 -0
  166. package/src/kit/support.ts +1 -1
  167. package/src/testing.ts +96 -21
  168. package/templates/.env.example +1 -1
  169. package/templates/adapters/web/.env.example +24 -0
  170. package/templates/adapters/web/README.md +22 -0
  171. package/templates/adapters/web/package.json.tmpl +26 -0
  172. package/templates/adapters/web/persona/assistant.md +2 -0
  173. package/templates/adapters/web/roundtable.config.ts +59 -0
  174. package/templates/package.json.tmpl +1 -0
  175. package/templates/plugins/self-compact.ts +11 -0
  176. package/templates/roundtable.config.ts +14 -2
  177. package/src/core/domain/owner-prompts.ts +0 -45
package/docs/plugins.md CHANGED
@@ -67,7 +67,7 @@ Use the kit's building blocks for a plugin that runs Pi itself, such as a coding
67
67
  - Tools: `textToolsExtension`, `requiredString`, `stringList` (with `toolText` and `toolError`) for tools that return text.
68
68
  - 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`.
69
69
  The specs take the locale and time zone for their descriptions, so the worker needs no process-wide setting.
70
- - Compaction: `CompactionTiers` gives a session the core's compaction tiers (`settings()` for Pi's `SettingsManager`, `wrapCompactor(factory, onBypass)` to hold a compaction extension back past the ceiling, `latest()`), with `SOFT_COMPACT_TOKENS` (300,000), `HARD_COMPACT_TOKENS` (500,000), `COMPACT_HEADROOM_TOKENS` (50,000), `compactionEngine(details, engine)` and the types `CompactionEngine`, `CompactionHistory` and `LatestCompaction`.
70
+ - Compaction: `CompactionTiers` gives a session the core's compaction tiers (`settings()` for Pi's `SettingsManager`, `wrapCompactor(factory, onBypass, prepare?)` to hold a compaction extension back past the ceiling and change what it is given first, `latest()`), with `SOFT_COMPACT_TOKENS` (300,000), `HARD_COMPACT_TOKENS` (500,000), `COMPACT_HEADROOM_TOKENS` (50,000), `compactionEngine(details, engine)` and the types `CompactionEngine`, `CompactionHistory` and `LatestCompaction`. `summaryProjection(messages, history?)` is what a shared conversation's compaction may summarize, without anyone's private memory, prompt states, or reasoning, and `privateCompaction(preparation, additionalMessages?)` sets a `session_before_compact` preparation to it. Optional surrounding history pairs calls/results split across summary, prefix and kept context. Independent shared sessions use `memoryProjection(messages, view)` on requests and `hidesPrivateExchange(messages, view)` for raw replay guards (`MemoryView`: `shared` and `reader`; legacy attribution is internal to the bridge guard, not a projection option). `recordMemoryTurn(manager, reader, privateMemory)` appends `MEMORY_TURN_ENTRY` (`roundtable-memory-turn`) before prompting and returns a marker for memory first used later in that turn; `carriesMemory(message)` recognizes built-in memory calls/results and custom `privateTo` results, including immediate failures and unanswered calls. `bridgeHistoryHidesMemory(rawBranch, reader)` checks those records and raw exchanges across compaction; SYSTEM is a reader of its own there, and the core's SYSTEM-as-primary-owner attribution and its pre-scope legacy attribution stay inside the core.
71
71
  - Jev compaction: `jevCompact(input, options)` compacts through Jev (pi-jev-compaction 1.0.0) and returns `{ compaction }` or `{ skipped, detail? }` (a `JevSkipReason`: pi-jev-compaction's fallbacks `no_key`, `aborted`, `nothing_to_compact`, `no_candidates`, `cannot_fit`, `jev_error`, `reduction_too_small`, or `previous_summary_too_large`), for Pi's own summary to run instead.
72
72
  Its summary carries the previous summary once, as the transcript's `[previous compaction]` message, with `estimatedTokensAfter` measured on the final text; a previous summary over `previousSummaryLimitTokens` (default `JEV_PREVIOUS_SUMMARY_LIMIT_TOKENS`, 60,000) skips without calling Jev, so Pi's summary condenses the chain.
73
73
  Jev judges with `goal` (default `JEV_GOAL`: keep the tool results that set rules still in force, drop stale lookups, listings and finished edits), and the summary ends with a `## Rules loaded before this compaction` section naming each rule load among the summarized messages (tool name and JSON arguments cut at 200 characters) for the agent to load again; `ruleLoad(tool, args)` picks them (default `isRuleLoad`: tools ending in `invoke-skill` or `get-system-prompt`, and tools ending in `read` whose `path` is a `SKILL.md`, an `AGENTS.md` or under `.agents/skills/`).
@@ -97,6 +97,7 @@ definePlugin({
97
97
  providers: {}, // optional: replaces a part the core runs on
98
98
  provides: [], // optional: keys of the services setup provides to other plugins
99
99
  replaces: [], // optional: keys of built-in services this plugin takes over
100
+ identities: [], // optional: identities the plugin's own credentials stand for
100
101
  preflight() {}, // optional: a check that runs before anything starts
101
102
  setup(context) { // required: returns the parts the plugin adds
102
103
  return { /* parts */ };
@@ -109,6 +110,16 @@ Errors point to the plugin definition.
109
110
 
110
111
  A plugin whose `setup` returns `{}` and has no migrations, providers, or hooks stops startup (see [Errors](#errors-and-their-fixes)).
111
112
 
113
+ `identities` is for a plugin that serves a credential of its own, such as the bearer token of an endpoint, and runs turns for whoever holds it.
114
+ Each entry is a `PluginIdentity`, `{ identity, principal? }`: the identity written `token:<subject>`, such as `token:remote-mcp`, and the id of the principal it stands for, by default the primary owner (the first of `access.owners`).
115
+ Only the `token` provider is accepted. A surface's identity, such as `discord:<user id>` or `oidc:…`, is a person's own account: declaring it would make whoever holds it the principal the plugin binds it to, owner included, and unlink it when the plugin stops declaring it. Link those in `access.owners` or with `roundtable principal link`.
116
+ At every start the `identity` plugin links each one to its principal as the plugin's (`roundtable principal list` shows `plugin <name>`), moves one whose principal changed since the last start, and unlinks one no plugin declares any more.
117
+ The start stops with a `ConfigError` for an identity that does not parse or is not a `token:` identity, one two plugins declare, the system principal, a principal that does not exist, and an identity linked to someone else by the configuration, the CLI, or a first contact; the message names what to change.
118
+ A declared identity is never admitted or claimed as someone new, and linking it leaves the principal's 0.8 claim alone: a plugin's credential is not the person's own identity.
119
+ Nor does its link tell who a configured owner is: an owner in `access.owners` that also lists a declared identity is known by its `principal` or its other identities, and the start stops when that is not the principal the plugin binds the identity to on that start (its `principal`, or else the primary owner), or when nothing else tells who the owner is. Whom the identity was bound to at an earlier start does not matter: once the plugin is bound to that owner in its options, the next start moves the identity to them.
120
+ Read whom it stands for with `IDENTITY.principalOf(identity)`, and start the turn with `IDENTITY.speakerFor(principalId)`.
121
+ A plugin that replaces `IDENTITY` replaces this too: the declarations are the built-in `identity` plugin's to link.
122
+
112
123
  ### The context
113
124
 
114
125
  `setup` receives a `PluginContext`:
@@ -126,9 +137,10 @@ A plugin whose `setup` returns `{}` and has no migrations, providers, or hooks s
126
137
  | `services` | The services plugins provide to each other, read by key: `services.get(SCHEDULES)`; see [services](#services-what-plugins-provide-to-each-other) |
127
138
  | `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` |
128
139
  | `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) |
129
- | `sessions()`, `conversations`, `surfaces`, `turns`, `dashboard()` | Linked once every plugin has been set up; calling them during `setup` throws `NotLinkedError` |
140
+ | `directChannels` | Every plugin's [direct channels](#directchannels-reaching-a-person-on-their-own): `providers()`, `reach(principalId)`, `known(principalId)`, and `notify(principalId, text)` |
141
+ | `sessions()`, `conversations`, `surfaces`, `directChannels`, `turns`, `dashboard()` | Linked once every plugin has been set up; calling them during `setup` throws `NotLinkedError` |
130
142
 
131
- `sessions()`, `conversations`, `surfaces`, `turns`, and `dashboard()` are available from a service's `start`, an event handler, or a claim's turn.
143
+ `sessions()`, `conversations`, `surfaces`, `directChannels`, `turns`, and `dashboard()` are available from a service's `start`, an event handler, a session tool, or a claim's turn.
132
144
 
133
145
  Use `QueuePort` from the main entry for `context.queue`; the kit's `ChannelQueue` is also type-only.
134
146
 
@@ -139,7 +151,10 @@ Use `QueuePort` from the main entry for `context.queue`; the kit's `ChannelQueue
139
151
  `context.logger` is a child of the host's logger and adds `plugin: <your plugin's name>` to each line in the journal.
140
152
  `Logger` has five levels and `child(fields)`; you can pass an existing pino logger as `DefineOverrides.logger`.
141
153
 
142
- With `defineRoundtable`, the host's logger sends every `error` and `fatal` line to the ops agent named by `config.ops.agent`, which reports it in its channel.
154
+ With `defineRoundtable`, the host's logger sends every `error` and `fatal` line to the ops agent named by `config.ops.agent`, which reports it in its channel, or, with `config.ops.conversation`, to that conversation as a visible message and a report turn its claim answers.
155
+ The report turn is the host's own background turn of the `owner` target: it runs as the system principal (`SYSTEM_PRINCIPAL`) at the owner tier, as in 0.8, and so does the report turn of an outside Discord webhook's post in an agent's channel, whose text is untrusted input; neither can set up a schedule or delegate a task. The host does not start, with a `ConfigError` naming `config ops.conversation` and the reason, when no chat surface serves the key, no plugin's claim owns it, no plugin contributes the `owner` background target, the claim that owns it takes no background turns (has no `background`), or that claim says `takesSystemReports: false`, since its conversations are each private to their person and refuse the system principal's turns; a report is then never only logged. A claim like that is answered at startup, not by a warning at each report.
156
+ The core contributes `PERSONAL_TARGET` (`"owner"`) with or without Discord; `ops.conversation` still needs a surface and a claim that accepts system report turns.
157
+ Webchat accepts personal background turns but declares `takesSystemReports: false`, so `web:<id>` fails at startup; name a shared conversation of another surface, or an agent with Discord.
143
158
  It then calls `DefineOverrides.errorSink(entry)` if you supply one; this function must not throw.
144
159
  A logger you supply reaches the ops agent and `errorSink` only if it forwards its error lines there.
145
160
  The report names the plugin next to `app` and `module`; the same error is reported at most once an hour, regardless of which plugin wrote it.
@@ -172,11 +187,14 @@ The built-in plugins provide these, from the main entry:
172
187
 
173
188
  | Key | Port | Provided by | What it is |
174
189
  |---|---|---|---|
175
- | `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`) |
190
+ | `IDENTITY` | `IdentityService` | `identity` | Who the host serves, read-only: `resolve(facts, { conversation }?)` gives the `Speaker` with its `principalId` behind a surface's `ActorFacts`, or undefined; `principal(id)`, `list()`, `identities(principalId)`, `roles(principalId)`, `tierOf(principalId)` (the lasting roles' tier), `speakerFor(principalId, tier?)` for a turn on someone's behalf, `owners()`, and `principalOf(identity)`, the principal an identity written `<provider>:<subject>` is linked to, such as one a plugin declares in `identities`. Principals are written only by the configuration and `roundtable principal`. `SYSTEM_PRINCIPAL` is the host's own, which no plugin speaks as |
191
+ | `CONVERSATIONS` | `ConversationRegistry` | `conversations` | The conversations run through `context.turns`: `register`, `get(key)`, `list({ principal }?)`, `setTitle(key, title)`, and `adopt(key, principalId)` only for migrating a conversation recorded before 0.9; each a `ConversationRecord` of `key`, `surface`, `kind`, `visibility` (`"private"` or `"shared"`), `principalId?`, `title?`, `createdAt`, `lastActiveAt` |
192
+ | `RUNTIME` | `AgentRuntime` | `runtime` | The runtime every conversation turn runs on, the agent server's and `context.turns`': the `runtime` slot's when a plugin fills it, Pi's otherwise |
193
+ | `AGENTS` | `AgentServer` | `agent-server` | The `team` (`AgentTeam`), the read-only `directory` (`AgentDirectory`), the `runtime` every agent turn runs on (the same one `RUNTIME` provides), `approvals` (whether the owner's reply approves held actions), and `avatars` (`AvatarStudio`) |
176
194
  | `SKILLS` | `SkillRegistry` | `skills` (an addon) | What agents carry: `carried`, `carriedNames`, `describeCarried`, `catalog`, `list`, `linkedFrom`, `checkRegistered`, `link`, `attach` |
177
- | `SCHEDULES` | `ScheduleStore` | `schedule-store` | The stored schedules: `create`, `get`, `forChannel`, `all`, `update`, `remove`, `due`, `claim`, `recordStatus` |
195
+ | `SCHEDULES` | `ScheduleStore` | `schedule-store` | The stored schedules: `create`, `createWithin`, `get`, `forChannel`, `all`, `update`, `remove`, `due`, `claim`, `recordStatus` |
178
196
  | `PRECHECKS` | `PrecheckRegistry` | `prechecks` | The host's named [prechecks](#prechecks-wake-a-schedule-only-when-it-has-work): `register`, `get`, `list`; and the runner of agents' precheck scripts: `useScriptRunner`, `scriptRunner` |
179
- | `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` |
197
+ | `MEMORY` | `MemoryStore` | `memory` (an addon) | `forSpeaker(principalId)` gives that principal's `SpeakerMemory`: `list`, `forPrompt`, `add`, `search`, `update`, `removeById`, `remove`; `MEMORY_KINDS` is `core`, `note`, `event` |
180
198
  | `BACKGROUND_TURNS` | `BackgroundTurns` | `modules` | Turns nobody wrote: `runScheduled`, `runDelegated`, `runErrorReport` |
181
199
  | `DELEGATION` | `Delegator` | `modules` | `start(request)` a background task, `runningChannels()`, `idle()` |
182
200
 
@@ -322,9 +340,9 @@ You can switch them off in the configuration.
322
340
 
323
341
  | Addon | Plugin | Switch in `roundtable.config.ts` | What it adds | When it is off |
324
342
  |---|---|---|---|---|
325
- | 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 |
343
+ | Memory | `memory` (provides `MEMORY`) | `memory: false` | The memory table, keyed by principal, the `memory_add`, `memory_search` and `memory_remove` tools, and the memory block of every system prompt but a `memory: "none"` persona's | No memory tools and no block; the table is left as it is |
326
344
  | Skills | `skills` (provides `SKILLS`) | `skills: false` | The skill tables, the skill tools of agent sessions (`skill_list`, `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 |
327
- | 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 |
345
+ | Discord administration | `discord-admin` | `discord: { admin: false }` | The `discord_*` tools that read and manage the server, for the owner; they check what the primary owner may do on the server, whichever owner's turn calls them | No `discord_*` tools; the channel executor that remote MCP uses is the connection's, so it stays |
328
346
 
329
347
  Switching an addon off leaves its tables and rows unchanged, ready for when you turn it on again.
330
348
  `skills` also takes the two directories (`skills: { builtinDir, reposDir }`) when it is on.
@@ -339,11 +357,166 @@ service roundtable.memory is not provided. The memory addon is switched off (con
339
357
  `serviceKey(id, { absent })` lets your own keys say the same.
340
358
  Switching an addon off and adding a plugin of yours that provides the same key is equivalent to `replaces`.
341
359
 
360
+ ##### Memory in the agents' channels
361
+
362
+ By default every speaker an agent's channel admits reads their own memory there, as in any shared conversation.
363
+ `discord: { agentMemory: "owners" }` (or the same key in `discord({ ... })`; `AgentServerOptions.memory`, which the agent server hands the runtime as `AgentSessions.memory`) limits that to the owners: a turn of a speaker below the owner tier has no memory block in its prompt and no memory tools, and its worker tasks load none either, while an owner's turns in the same channel are unchanged.
364
+ A guest's turn then records no private memory, so it never makes a `claude-bridge` model refuse the owner afterwards (see the [bridge guard](migration-0.9.md#claude-bridge-guard)).
365
+ It does not make the guest's turn safe to run after the owner's: once the owner's turn has loaded memory, the guest's bridge turns are refused, as they are without the option, until the conversation starts fresh.
366
+ It applies to the agents' channels and groups only; other conversations keep deciding by their persona.
367
+ The default `"everyone"` is the 0.8 and 0.9 behavior.
368
+
369
+ ## Principals and access
370
+
371
+ A principal is the person the host serves; an identity is an account or credential linked to that person.
372
+ `Speaker.id` identifies the external actor, while required `Speaker.principalId` keys memory, private conversations, schedule authors, notifications, and per-person limits.
373
+ Link Discord and web accounts with `roundtable principal link <principal> <identity>` to make them one person rather than two memories.
374
+ A 0.8 person's principal retains their old speaker id; new admitted people get opaque `p_<ulid>` ids.
375
+
376
+ Configure top-level `access.owners`, `admins`, and `members`.
377
+ Identities are `<provider>:<subject>` (OIDC uses `oidc:<base64url issuer>:<subject>`), and roles are `<surface>:role:<name>`.
378
+ `everyone: ["web"]` admits that surface only; `everyone: true` admits all surfaces and deserves particular care on a host with several adapters.
379
+ The primary owner is the first owner, with an explicit `principal` and, on Discord, a Discord identity; Discord administration still acts with that person's permissions.
380
+ Owner is granted only through configuration or the principal CLI, never an IdP role.
381
+ `provisioning: "admitted"` is the default; `"linked"` admits only already linked identities.
382
+ A disabled principal is refused, and CLI changes reach a running host within the identity cache's 30 seconds.
383
+
384
+ Surfaces report verified `ActorFacts`, not authority.
385
+ The router resolves them through `IDENTITY` before admission and overwrites a surface's own `message.speaker`.
386
+ A claim must ignore a message without a resolved speaker and run an admitted message as that speaker, rather than reconstructing one at an assumed tier.
387
+ Old author fields are a warned fallback until 1.0.
388
+ A plugin initiating a turn on someone's behalf uses `IDENTITY.speakerFor(principalId)` and passes the speaker explicitly; `runTurn` without one is refused.
389
+ A plugin's own credential bindings accept only `token:` (see [the plugin object](#the-plugin-object)), never a surface's account.
390
+
391
+ The example below reads only the current principal, so two linked actors ask about the same person and owner tier alone cannot invent a missing principal.
392
+ Put its `access` in the host configuration and its plugin in `plugins`.
393
+
394
+ <!-- example: examples/principals.ts -->
395
+ ```ts
396
+ import {
397
+ type AccessConfig,
398
+ definePlugin,
399
+ defineTool,
400
+ IDENTITY,
401
+ ToolRefusal,
402
+ } from "pi-roundtable";
403
+ import { Type } from "typebox";
404
+
405
+ /** One person may have several identities; surface roles never make an owner. */
406
+ export const access = {
407
+ owners: [
408
+ {
409
+ principal: "operator",
410
+ name: "Ada",
411
+ identities: ["discord:966666600000000001", "token:remote-mcp"],
412
+ },
413
+ ],
414
+ members: { roles: ["web:role:App.User"] },
415
+ provisioning: "admitted",
416
+ } satisfies AccessConfig;
417
+
418
+ /** Ask about the current principal, not the external actor or the primary owner. */
419
+ export const principals = definePlugin({
420
+ name: "principals",
421
+ requires: [IDENTITY],
422
+ setup: ({ services }) => {
423
+ const identity = services.get(IDENTITY);
424
+ return {
425
+ tools: [
426
+ defineTool({
427
+ name: "principal_who",
428
+ description: "Show who this turn is for.",
429
+ parameters: Type.Object({}),
430
+ minTier: "member",
431
+ run: async (_args, turn) => {
432
+ if (!turn.speaker)
433
+ throw new ToolRefusal("No speaker for this turn.");
434
+ const person = await identity.principal(turn.speaker.principalId);
435
+ if (!person || person.disabled)
436
+ throw new ToolRefusal("This principal is not available.");
437
+ return `${person.displayName} (${person.id})`;
438
+ },
439
+ }),
440
+ ],
441
+ };
442
+ },
443
+ });
444
+ ```
445
+ <!-- /example -->
446
+
447
+ <!-- example: examples/principals.test.ts -->
448
+ ```ts
449
+ import { expect, test } from "bun:test";
450
+ import { IDENTITY, type Speaker } from "pi-roundtable";
451
+ import { servicePair, testPlugin } from "pi-roundtable/testing";
452
+ import { access, principals } from "./principals.ts";
453
+
454
+ test("two actor identities of one principal ask about the same person", async () => {
455
+ const lookedUp: string[] = [];
456
+ const harness = await testPlugin(principals, {
457
+ services: [
458
+ servicePair(IDENTITY, {
459
+ principal: async (id) => {
460
+ lookedUp.push(id);
461
+ return { id, displayName: "Mo", disabled: false };
462
+ },
463
+ }),
464
+ ],
465
+ });
466
+ try {
467
+ for (const id of ["discord-actor", "web-actor"]) {
468
+ const speaker: Speaker = {
469
+ id,
470
+ name: "Mo",
471
+ principalId: "p_mo",
472
+ tier: "member",
473
+ };
474
+ expect(await harness.runTool("principal_who", {}, { speaker })).toBe(
475
+ "Mo (p_mo)",
476
+ );
477
+ }
478
+ expect(lookedUp).toEqual(["p_mo", "p_mo"]);
479
+ } finally {
480
+ await harness.stop();
481
+ }
482
+ });
483
+
484
+ test("an unavailable principal is refused even at owner tier", async () => {
485
+ const harness = await testPlugin(principals, {
486
+ services: [servicePair(IDENTITY, { principal: async () => undefined })],
487
+ });
488
+ try {
489
+ expect(
490
+ await harness.runTool(
491
+ "principal_who",
492
+ {},
493
+ {
494
+ speaker: {
495
+ id: "actor",
496
+ name: "Ada",
497
+ principalId: "operator",
498
+ tier: "owner",
499
+ },
500
+ },
501
+ ),
502
+ ).toContain("This principal is not available.");
503
+ expect(access.owners[0]?.principal).toBe("operator");
504
+ expect(access.members.roles).toEqual(["web:role:App.User"]);
505
+ } finally {
506
+ await harness.stop();
507
+ }
508
+ });
509
+ ```
510
+ <!-- /example -->
511
+
512
+ For existing hosts and compatibility aliases, read [Migrating to 0.9](migration-0.9.md).
513
+
342
514
  ## Tiers: who may use what
343
515
 
344
516
  Every turn has a speaker with a tier: `owner`, `admin`, or `member`, from most to least trusted.
345
- By default, only the owner speaks.
346
- The operator can open the bot to others by listing them under `speakers` in `roundtable.config.ts` and is responsible for that setup.
517
+ By default, only the configured owner speaks.
518
+ The operator can open admission through `access` and is responsible for that setup.
519
+ A turn's `kind` chooses a persona, never a tier.
347
520
 
348
521
  Each tool names the lowest tier that may call it.
349
522
  The bot offers the tool in turns whose speaker is at that tier or above; the operator's `toolTiers` setting can override the plugin's choice.
@@ -511,7 +684,7 @@ export const cleanup = definePlugin({
511
684
 
512
685
  A rule whose verdict depends on the input, such as one that holds only a `delete` action, can also answer `mayHold(tool)`: whether it may hold some call of that tool.
513
686
  It is asked when the input is not known yet, as for a [precheck script](#precheck-scripts-prechecks-the-agent-writes)'s call whose arguments are computed when it runs; a rule without it is judged by `describe` with an empty input.
514
- A rule whose held call stands for others can answer `approvalTier(tool, input, context)`: the lowest tier that may approve it when higher than the tool's own. The held call keeps it as `minTier`, and both its card and a confirming message require it.
687
+ A rule whose held call stands for others can answer `approvalTier(tool, input, context)`: the lowest tier that may approve it when higher than the tool's own. The held call keeps it as `minTier`, and both its card and a confirming message require it. A card is answered, and a confirming message accepted, from the speaker whose turn held the call, when their tier is at least `minTier`, and from the owners where the conversation is shared ([who answers a prompt](#who-answers-a-prompt-promptscope)); nobody else in the channel may approve it. The held call records that speaker as `PendingConfirmation.speakerId` and their principal as `principalId`, so a confirming message from another identity of theirs counts after a restart too; a call held without them is the owners' to approve, and one held before 0.9, without a principal, is matched by `speakerId`.
515
688
 
516
689
  #### The shell rule
517
690
 
@@ -596,7 +769,7 @@ test("the section names the agent, and the speaker when there is one", async ()
596
769
  expect(
597
770
  section?.build({
598
771
  agent,
599
- speaker: { id: "1", name: "Ada", tier: "member" },
772
+ speaker: { id: "1", name: "Ada", tier: "member", principalId: "1" },
600
773
  scope,
601
774
  }),
602
775
  ).toContain("You are talking with Ada.");
@@ -642,6 +815,7 @@ export const library = definePlugin({
642
815
  | `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 |
643
816
  | `turnStarted(turn)` | An agent's turn began, or a turn run through `context.turns` |
644
817
  | `turnEnded(turn)` | The turn ended; `turn.result` is `"ok"`, `"failed"`, or `"stopped"` |
818
+ | `turnProgress(event)` | A turn run through `context.turns` wrote text or ran a tool, between its start and its end: `event` is the turn's fields and `progress`, a `TurnProgress` (see the surface's `progress` below); a runtime without live progress reports none |
645
819
  | `changed()` | The team changed: an agent or group was created, edited, arranged, archived, or started over |
646
820
  | `shutdown(left)` | The shutdown drain ended, before any service stops; `left` lists the work it gave up on |
647
821
 
@@ -849,8 +1023,9 @@ The core stores the script with the schedule and decides with it exactly as with
849
1023
  pi-roundtable-sandbox's `precheckScriptRunner` runs each script in a sealed container whose only way out is the MCP tools the host grants for that schedule; see its README.
850
1024
 
851
1025
  - `schedule_create` and `schedule_update` take `precheck_script`, a JavaScript module of at most `PRECHECK_SCRIPT_CHARS` (8,000) characters with a default export; it is parsed, never run, when it is set. A schedule has a `precheck` or a `precheck_script`; setting one removes the other, and `null` removes either.
852
- - The runner's `run(script, context)` gets the `PrecheckScriptContext`: the schedule, `firedAt`, `signal`, the host's `timeZone`, and `today`, the date there. Its answer is checked like a named precheck's, and its finding is named `script`.
853
- - `describe({ channel, target, tier })` (a `PrecheckScope`) tells the model how to write one and what it may call there; `schedule_list` shows it, waiting at most 10 seconds. `tier` is the asker's there and the script's creator's when it runs, so a runner may grant lower tiers less.
1026
+ - The runner's `run(script, context)` gets the `PrecheckScriptContext`: the schedule, `firedAt`, `tier`, `signal`, the host's `timeZone`, and `today`, the date there. `tier` is the tier the run is for: the schedule's, capped at what its creator holds now, as its turn would run. Its answer is checked like a named precheck's, and its finding is named `script`.
1027
+ - `describe({ channel, target, tier })` (a `PrecheckScope`) tells the model how to write one and what it may call there; `schedule_list` shows it, waiting at most 10 seconds. `tier` is the asker's there and the run's `tier` when it runs, so a runner may grant lower tiers less.
1028
+ - Before a schedule's precheck, named or a script, the scheduler checks who its turn would run as, as `ConversationPort.runsAs` does: a creator who may not run it now, such as one disabled, runs no precheck, and the run is recorded `skipped:` with the reason.
854
1029
  - When the host stops, the scheduler aborts running scripts and waits up to 15 seconds for the runner to clean up; a precheck that ends then starts no turn.
855
1030
  - Without a runner, the tools neither take nor mention `precheck_script`, a script is refused with the registered names, and a schedule that already has one wakes with `### Precheck failed: script`, never a silent skip.
856
1031
 
@@ -996,7 +1171,7 @@ An unknown or misspelled slot stops startup with an error listing the valid name
996
1171
  |---|---|---|
997
1172
  | `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` |
998
1173
  | `images` | No drawing; each agent gets an avatar generated from its display name | `async (prompt, references) => bytes` returning PNG bytes |
999
- | `runtime` | None: the agent server builds the Pi runtime itself | `(deps) => runtime`, an [`AgentRuntime`](#the-runtime-slot-replace-pi) that runs every conversation |
1174
+ | `runtime` | None: the runtime plugin builds the Pi runtime itself | `(deps) => runtime`, an [`AgentRuntime`](#the-runtime-slot-replace-pi) that runs every conversation |
1000
1175
 
1001
1176
  When no `images` provider is configured, `agent_create` has no `avatar_prompt` parameter and `agent_avatar` is unavailable.
1002
1177
  The owner's profile panel reports the missing provider and offers no redraw option.
@@ -1030,16 +1205,17 @@ export const pixelAvatars = definePlugin({
1030
1205
 
1031
1206
  The runtime handles the agent server's conversations and those of every claim that runs turns through `context.turns`.
1032
1207
  It keeps one persistent conversation per channel key, with its turns, held actions, and history.
1033
- By default, the agent server builds the Pi runtime.
1208
+ By default, the host's `runtime` plugin builds the Pi runtime and provides it as `RUNTIME`; the agent server and `context.turns` both run on it, and a host without the agent server has it too.
1034
1209
  Filling the `runtime` slot replaces it for agent turns, the owner's conversations, steering, held actions, and transcripts; the host skips building Pi.
1035
- The slot's default factory refuses calls, so check `providers.filled.has("runtime")` before calling `providers.runtime`.
1036
- The agent server does this for you.
1210
+ The slot's default factory refuses calls: read the running runtime with `services.get(RUNTIME)` instead of calling `providers.runtime`.
1037
1211
 
1038
- The slot is a `RuntimeFactory`: `(deps: RuntimeDeps) => AgentRuntime`, called once when the agent server sets up.
1212
+ The slot is a `RuntimeFactory`: `(deps: RuntimeDeps) => AgentRuntime`, called once when the runtime plugin sets up, before the agent server.
1039
1213
  `deps` provides `logger`, `env`, `owner`, `toolTiers`, and the host's `judge`.
1040
1214
  Its `sessions()` gives you linked hold rules, packages, session tools, and personas from preflight onwards; call it in a turn, when those parts are available.
1041
- `prompts(conversation, speaker)` gives you the owner's approval and question cards on the conversation's surface, or `undefined`.
1042
- `agents` holds the agent server's per-agent settings: `workDir`, `scratchDir` (the agents' shell's `TMPDIR`), `skills(name)`, `modelOf(name)`, and `turnChannel(scope)`.
1215
+ `prompts(conversation, scope)` gives you the approval and question prompts on the conversation's surface for a turn's `PromptScope`, made with `promptScope(speaker, visibility)` from the turn's speaker and `TurnRequest.conversation`, or `undefined`; see [who answers a prompt](#who-answers-a-prompt-promptscope).
1216
+ Given a `Speaker`, the 0.8 form, it reads them as the speaker of a shared conversation and the host warns once that the form goes away in 1.0.
1217
+ `agents` holds the agent server's per-agent settings: `workDir`, `scratchDir` (the agents' shell's `TMPDIR`), `skills(name)`, `modelOf(name)`, `turnChannel(scope)`, and `memory` (`"everyone"`, the default, or `"owners"`; see [memory in the agents' channels](#memory-in-the-agents-channels)).
1218
+ The agent server sets up after the runtime is built, so read `agents` when a turn runs; it is `undefined` on a host without the agent server, where no agent turn runs.
1043
1219
  `confirmations` stores held actions across restarts.
1044
1220
 
1045
1221
  An `AgentRuntime` has these methods:
@@ -1050,13 +1226,13 @@ An `AgentRuntime` has these methods:
1050
1226
  | `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 |
1051
1227
  | `stop(conversation)` | Aborts the conversation's running turn; `false` when none runs |
1052
1228
  | `startFresh(conversation)`, `deleteConversation(conversation)` | Archive the conversation, or remove it for good; called between turns |
1053
- | `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 |
1054
- | `recentTranscript(conversation, limit)` | The latest messages, for the owner's and the dashboard's views of a conversation |
1229
+ | `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. After a restart `pendingConfirmation` knows a conversation's held actions only once `heldActions` or `recentTranscript` has restored them, so a host that decides an approval asks one of them first. 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 |
1230
+ | `recentTranscript(conversation, limit)` | The latest messages, for the owner's and the dashboard's views of a conversation; the built-in runtime gives none of a history recorded, or a session opened, for someone else than the conversation serves now |
1055
1231
  | `contextUsage?(conversation)` | How full the conversation's context is, `{ tokens, contextWindow }`; leave it out and the team status shows no context bar |
1056
1232
  | `preflight?()` | Runs in the host's preflight, before anything starts; a throw stops the boot |
1057
- | `dispose?()` | Runs when the host stops the agent server's runtime service |
1233
+ | `dispose?()` | Runs when the host stops the runtime plugin's `runtime` service |
1058
1234
 
1059
- A request has the turn's `channel`, `selection` (its tools), `text`, `attachments`, `speaker`, flags (`steerable`, `interactive`, `confirmed`), and `interim`, where the turn may post the text it writes before its final answer ([interim text](#interim-text-what-a-turn-writes-before-its-final-answer)).
1235
+ A request has the turn's `channel`, `selection` (its tools), `text`, `attachments`, `speaker` (required: the person the turn is for, at whose tier it runs; the built-in runtime refuses a request without one with an `AgentRunError` before asking the model, and a plugin that starts a turn on someone's behalf gets theirs from `IDENTITY.speakerFor`), `conversation` (`TurnConversation`: `private` to a `principalId`, or `shared`, as `context.turns` records it; shared when absent), flags (`steerable`, `interactive`, `confirmed`), and `interim`, where the turn may post the text it writes before its final answer ([interim text](#interim-text-what-a-turn-writes-before-its-final-answer)).
1060
1236
  An agent's turn also has `agent`, the agent's scope, whose `session` is the conversation's key.
1061
1237
  Other turns use `kind` to name the conversation's persona, defaulting to `"owner"` when absent.
1062
1238
 
@@ -1162,7 +1338,7 @@ export const createEchoRuntime: RuntimeFactory = (deps) => {
1162
1338
 
1163
1339
  /**
1164
1340
  * A plugin fills the `runtime` slot to replace the whole conversation runtime; without one the
1165
- * agent server builds the Pi runtime. One plugin may fill it.
1341
+ * runtime plugin builds the Pi runtime. One plugin may fill it.
1166
1342
  */
1167
1343
  export const echoRuntime = definePlugin({
1168
1344
  name: "echo-runtime",
@@ -1237,6 +1413,7 @@ test("an agent's turn carries its scope, and its conversation is the scope's ses
1237
1413
  channel: "fake:agent-room",
1238
1414
  selection: { id: "agent", tools: [], groups: [] },
1239
1415
  text: "status?",
1416
+ speaker: OWNER_SPEAKER,
1240
1417
  agent: {
1241
1418
  name: "infra",
1242
1419
  session: "fake:agent-room",
@@ -1348,9 +1525,12 @@ Call `commands.add` from `setup`; after preflight it throws `commands can be add
1348
1525
  Use `services.find(DISCORD)` if your plugin can work without Discord.
1349
1526
  When the service is absent, the rest of your plugin can run without commands.
1350
1527
 
1351
- Build a feature's part of the root command with `ownerCommandModule(guard, handlers)` from `pi-roundtable/discord`; it is owner-only, defers interactions, and supplies a failure panel.
1528
+ Build a feature's part of the root command with `ownerCommandModule(guard, handlers)` from `pi-roundtable/discord`; only the owners may use it, it defers interactions, and supplies a failure panel.
1352
1529
  That entry also exports `groupOption` for subcommand groups, the panel helpers (`ownerPanel`, `ownerPanels`, `ephemeralPanel`, `replyWithPanels`, `plain`, `OwnerFacingError`), and `agentPanel({ guard, agents })` for an agent's profile panel.
1353
- `DISCORD.guard` is the `CommandGuard`; its `isOwner(actor)` accepts an interaction or anything with `user.id`, so a test needs no cast.
1530
+ `DISCORD.guard` is the `CommandGuard`; `await guard.allows(actor)` is whether the actor is an owner now: the primary owner by user id, and every other owner principal by their Discord identity, checked through `IDENTITY` each time, so an owner granted or revoked with the CLI is seen at once and a stranger is never resolved. It accepts an interaction or anything with `user.id` (a `DiscordActor`), so a test needs no cast.
1531
+ `guard.isOwner(actor)` is deprecated until 1.0: it is the primary owner only, by user id, so a component handler that still asks it refuses the other owners.
1532
+ `commandGuard({ ownerId, identity?, root, logger, refusalHint? })` makes one; without `identity` the primary owner is the only owner.
1533
+ The connection's `ownerChannel()` and `notifyOwner(text)` are deprecated until 1.0: they reach the primary owner alone. Reach a person through `context.directChannels` instead, where the Discord plugin's provider finds their direct messages by a Discord identity of theirs.
1354
1534
  `DiscordOptions.refusalHint` (`discord.refusalHint` in the configuration) is appended unchanged to the refusal a non-owner gets.
1355
1535
 
1356
1536
  <!-- example: examples/interactions.ts -->
@@ -1391,7 +1571,7 @@ A test gives the plugin `fakeDiscord()` from `pi-roundtable/testing`: `testPlugi
1391
1571
  ### `http`: routes on the bot's listener
1392
1572
 
1393
1573
  The configuration's `http` block opens a listener named `public`, which serves the agents' avatars.
1394
- `http.publicUrl` is its internet address.
1574
+ `http.publicUrl` is its internet address, required with Discord; a host without Discord may leave `http` out and opens no listener.
1395
1575
  A route names the listener, a path (`{ exact }` or `{ prefix }`), optionally the methods, and a handler that gets a `Request` and returns a `Response`.
1396
1576
  Two routes that could take the same request are refused, so a route cannot shadow the avatars.
1397
1577
  This listener is reachable from the internet, so check a secret in the handler before taking action.
@@ -1404,6 +1584,20 @@ The socket file's permission bits are `http.socketMode`, `0o660` by default: onl
1404
1584
  Widen it (`0o666`) only when the proxy runs as a user outside that group.
1405
1585
  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`.
1406
1586
 
1587
+ A route that also declares `websocket` takes WebSocket upgrades on its path; every other request still reaches `handle`, and its `methods`, when listed, must include `GET`.
1588
+ An upgrade is a `GET` with `Upgrade: websocket`; one without `Sec-WebSocket-Key` or with a `Sec-WebSocket-Version` other than `13` gets `400`, and any other method goes to `handle` as usual.
1589
+ An upgrade request first passes the Origin check: `origins` lists the `scheme://host[:port]` values a browser may connect from, exactly as a browser sends them, such as `https://chat.example.com` or `chrome-extension://<id>` (a lowercase scheme and no path, trailing slash, query, fragment, or user; another form, and `"null"`, is refused at startup), and a request with another Origin, or none, gets `403` without reaching the route. `"any"` skips the check, for clients that are not browsers.
1590
+ A route holds at most `maxConnections` sockets at once (256 by default), counting upgrades still in `accept`; one more gets `503` before `accept` runs.
1591
+ Then `accept` authenticates and authorizes the request before any socket opens: it returns `{ data }`, which every handler reads as `socket.data`, or a `Response` such as `401` or `403` that refuses the upgrade. An `accept` that throws answers `500` like a failing `handle`.
1592
+ Browsers cannot set headers on a WebSocket, so a browser client proves itself with a ticket: one-time and short-lived in the query, so a ticket that leaks from a log or the history opens nothing, or carried in `Sec-WebSocket-Protocol`, which `accept` answers by returning the chosen protocol in `headers`.
1593
+ Never combine `origins: "any"` with cookie authentication: the browser sends the cookie from any site, so any page its user opens could take over their socket (cross-site WebSocket hijacking).
1594
+ Each message reaches `message` as it arrives. A message over `maxMessageBytes` (64 KiB by default) closes the socket with `1009`, and more than `rate.messages` in `rate.perMs` (120 a minute by default) closes it with `1008`.
1595
+ `socket.send` answers `"sent"`, `"queued"` when the client reads slower than the route sends, or `"dropped"`. `maxBufferedBytes` (1 MiB by default) is checked before each `send`: once that many bytes wait for a client that stopped reading, the next `send` answers `"dropped"` and the host cuts the socket without a close frame, so the client and the route's `close` both see `1006`. A `send` that finds fewer waiting goes out whole, so a single message larger than the limit still reaches a client that reads, and the bytes waiting can reach the limit plus one message. A `send` on a closed socket answers `"dropped"` too, and an empty message is `"sent"`.
1596
+ Every limit (`maxMessageBytes`, `maxBufferedBytes`, `maxConnections`, `rate.messages`, `rate.perMs`) must be a positive integer; another value is refused at startup with an error naming the route and field.
1597
+ The handlers' return values are ignored and a returned promise is awaited, so `message: (socket, message) => socket.send(message)` is a complete echo.
1598
+ A handler that throws or rejects closes only its own socket, with `1011`, and logs the route and listener like a failing `handle`.
1599
+ When the host shuts down, every open socket is closed with `1001`, and the shutdown waits up to five seconds for the routes' `close` handlers before it stops the services.
1600
+
1407
1601
  <!-- example: examples/http.ts -->
1408
1602
  ```ts
1409
1603
  import { definePlugin } from "pi-roundtable";
@@ -1423,6 +1617,45 @@ export const health = definePlugin({
1423
1617
  ],
1424
1618
  }),
1425
1619
  });
1620
+
1621
+ /**
1622
+ * A route with `websocket` also takes upgrades. The Origin check and `accept` run before any
1623
+ * socket opens, so a browser on another site, or a client without a ticket, never connects.
1624
+ * A ticket opens one socket: `accept` spends it, so one that leaks from a log or the browser's
1625
+ * history opens nothing. Whoever issues the tickets should also let them expire within seconds.
1626
+ */
1627
+ export const echo = (tickets: Set<string>) =>
1628
+ definePlugin({
1629
+ name: "echo",
1630
+ setup: () => ({
1631
+ http: [
1632
+ {
1633
+ name: "echo",
1634
+ listener: "public",
1635
+ path: { exact: "/echo" },
1636
+ methods: ["GET"],
1637
+ handle: () => new Response("Upgrade Required", { status: 426 }),
1638
+ websocket: {
1639
+ origins: ["https://chat.example.com"],
1640
+ accept: (request) => {
1641
+ const ticket = URL.parse(request.url)?.searchParams.get("ticket");
1642
+ return ticket && tickets.delete(ticket)
1643
+ ? { data: { since: Date.now() } }
1644
+ : new Response("Unauthorized", { status: 401 });
1645
+ },
1646
+ maxMessageBytes: 4096,
1647
+ rate: { messages: 20, perMs: 10_000 },
1648
+ maxBufferedBytes: 64 * 1024,
1649
+ maxConnections: 50,
1650
+ message: (socket, message) => {
1651
+ // "dropped" means the client stopped reading and the host cut it; an echo has nothing to resend.
1652
+ socket.send(message);
1653
+ },
1654
+ },
1655
+ },
1656
+ ],
1657
+ }),
1658
+ });
1426
1659
  ```
1427
1660
  <!-- /example -->
1428
1661
 
@@ -1524,9 +1757,18 @@ The built-in addons use it: each declares the tiers of its own tools.
1524
1757
 
1525
1758
  A session tool is a Pi extension placed in every conversation session by its phase: `tools`, `compaction`, or `mcp`.
1526
1759
  Use it for tools that `defineTool` cannot express, such as a set that changes while the process runs (bump `revision`) or a tool that depends on the session.
1527
- Each extension needs a unique name; the core reserves `read-attachment`, `confirmation-gate`, `ask-user`, `self-compact-guard`, and `active-tools`.
1760
+ Each extension needs a unique name; the core reserves `read-attachment`, `confirmation-gate`, `ask-user`, `self-compact-guard`, `private-memory`, and `active-tools`.
1528
1761
  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.
1529
1762
  At most one plugin may add a `compaction` extension, and it must name the `engine` its compactions record.
1763
+ In a shared conversation the core's wrapper hands it the history to summarize as `summaryProjection` gives it, without anyone's private memory, the prompt states, or the turns' reasoning, and Pi's own summary reads the same, since a summary outlives the turns it covers and every later speaker reads it. A compactor that reads the history otherwise, outside `session.compaction.wrap` or in a runtime of your own, must summarize a shared conversation's history the same way: call `privateCompaction(event.preparation, branchMessages)` from `pi-roundtable/kit` before reading it, passing the message entries of `event.branchEntries` so exchanges split across the kept boundary are paired too.
1764
+
1765
+ The factory gets the session's `SessionContext`, fixed when the session is made:
1766
+ - `kind`, `homeChannel`, `turnChannel`, and `agent` say what the session is for and where its turns run.
1767
+ - `conversation` (`SessionConversation`) says whom it serves: `{ visibility: "private", principalId, principal? }` for one person's conversation, with their name and pronouns when the host has a record of them, or `{ visibility: "shared" }`. An agent's session is always shared. Otherwise it is as the turn's `TurnRequest.conversation` names it, else as the conversation registry recorded it, else shared; it is never the owner's by default. The runtime reads this again at every turn, the registry included, falling back to whom the open session or its recorded history served when nothing names the conversation, and refuses a turn of someone else in a conversation private to its person before the session is touched. A session's history records whom it served: when the conversation serves someone else, another principal or the other visibility, the session is made anew with nothing held for it, and its earlier history is archived rather than replayed, after a restart too; a history from before 0.9, which records no one, carries on. Changed session tools or skills rebuild a session with its history.
1768
+ - `addressee` is whom tool descriptions should name: the primary owner exactly as configured in their own private conversation, another person by their name and pronouns (their name alone when they gave none), and `THE_SPEAKER` in a shared conversation, or a private one of someone the host knows by no name yet.
1769
+ - `memory` is `"none"` for a persona that declares it, and `"speaker"` otherwise; a memory tool of your own loads nothing for `"none"`. A task the session runs with `runTask` keeps its conversation's `memory`.
1770
+ - `speaker()` is the running turn's speaker, undefined between turns; a tool reads it when it runs, never when the session is made, since a session outlives the turn it is built in.
1771
+
1530
1772
  The core's Jev compactor is one such extension:
1531
1773
 
1532
1774
  ```ts
@@ -1602,6 +1844,13 @@ It leaves those other channels silent for the owner's notes, preventing other cl
1602
1844
  Your Discord claim won't receive messages from that guild at any priority below 100, including `priority: 10`.
1603
1845
  Claims on other surfaces, such as the example's `echo:` keys, don't compete with the agent server.
1604
1846
 
1847
+ Before a claim's `admit` sees a message, the router resolves its author through `IDENTITY` and sets `message.speaker`: their `id` on the surface, `name`, `tier`, and `principalId`, the principal whose memory, schedules, and conversations are theirs.
1848
+ It is undefined when the access rules serve no one by them, and for bots and integrations, which are not resolved; a `speaker` the surface set is dropped.
1849
+ A claim that serves only the people the host serves answers only when `speaker` is set.
1850
+ The router records the author, such as linking them at their first contact, only once a claim admits the message, so messages no claim takes link or make no one.
1851
+ One exception: an author already linked whom the access rules now refuse is recorded as seen at no tier when the message is resolved, whether or not a claim takes it, at most once every few minutes, so their background turns stop with them.
1852
+ When that record finds the author someone else than the claim admitted, such as linked to another principal meanwhile or refused now, or cannot be made, the router drops the message: a turn admission's optional `dropped()` is called instead of `run`, so the claim frees what it holds for it, and a background admission's `unanswered` hears it skipped.
1853
+
1605
1854
  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.
1606
1855
  The router calls only the owning claim's `stop`, returning `false` when that method is absent.
1607
1856
  Give `stop` to a claim whose conversations run turns that can be interrupted.
@@ -1647,7 +1896,8 @@ export const echo = definePlugin({
1647
1896
 
1648
1897
  A conversation has a kind: the string its claim returns from `startFresh`, such as `"owner"` or `"study"`.
1649
1898
  Your plugin names its claims' kinds; the host treats them as opaque strings.
1650
- A `Persona` supplies the system prompt for every non-agent conversation of one kind: `{ kind, prompt() }`.
1899
+ A `Persona` supplies the system prompt for every non-agent conversation of one kind: `{ kind, prompt(), memory? }`.
1900
+ `memory: "none"` leaves memory out of its conversations: no memory tools and no memory in the prompt; `"speaker"`, the default, reads as the next list says.
1651
1901
  The runtime reads `prompt()` when it creates the conversation's session, so message catalog text uses the host's language.
1652
1902
 
1653
1903
  - A plugin contributes `personas` with the kinds it owns; `sessions().persona(kind)` is the linked lookup a runtime uses.
@@ -1658,14 +1908,34 @@ The runtime reads `prompt()` when it creates the conversation's session, so mess
1658
1908
  The host merges every plugin's list and keeps each name once.
1659
1909
  - A turn whose kind has no persona is refused with an error naming `personas`.
1660
1910
  The Pi runtime refuses when it makes the session; a runtime of your own does the same, as the example's does.
1661
- - With the memory addon on, a turn's `speaker` decides whose memory the system prompt carries and the memory tools change: a member's or an admin's own, and the owner's for a speaker of the owner tier.
1911
+ - With the memory addon on, the conversation decides whose memory the system prompt carries and the memory tools change, by principal and never by tier: a private conversation's person's, whoever speaks in it; in a shared conversation, each turn's speaker's (`speaker.principalId`), so a second owner reads their own and not the primary owner's; and no one's in the host's own turns (`SYSTEM_PRINCIPAL`) in a shared conversation.
1912
+ The primary owner's memory keeps its 0.8 heading and rows: their principal is 0.8's owner id.
1913
+ - A shared conversation's history never hands one person's memory to another. Each memory tool's result records whose memory it holds (`details: { privateTo: <principalId> }`), and before every model request the Pi runtime shows a result of someone other than the turn's reader, the host's own turns included, as `(another person's private memory, hidden)`, and a memory result that does not say whose as a placeholder too, one recorded before results said whose or a failed call's, such as one whose arguments were refused (a memory tool whose store fails still records whose memory the call was for, so its person reads their own failed exchange); the call that result answers, matched by its id, keeps its id and name but reads its arguments as `{ hidden: <the same placeholder> }`, so what the model copied from that memory into the call is not sent either; the prompt states Pi recorded in the history collapse into the current prompt alone, so an earlier speaker's memory section is not sent either, and the answers of earlier turns go without their reasoning (thinking, encrypted reasoning, tool calls' thought signatures, and the thought signature Gemini puts on an answer's text; OpenAI's signature of an answer's message, which names its id and phase, is no reasoning, but the message and call item ids OpenAI's Responses paired with the removed reasoning go with it, while each call keeps the id its result answers and each message its phase), which may restate the memory their turn read; the running turn keeps its own. What people wrote, and the assistant's replies, stay shared. A tool of your own whose result holds someone's private data returns the same `details.privateTo` principal id, including an error that repeats private input: both its arguments and result are hidden, regardless of the tool name.
1914
+ A hidden result also drops private payload in its `details` and nested-call records, retaining only its ownership marker.
1915
+ A host building an independent shared Pi session applies `memoryProjection` on `context_with_system` and `privateCompaction` before summarizing; the optional surrounding-history arguments of `summaryProjection` and `privateCompaction` preserve redaction across split exchanges. In the opt-in sandbox Pi protocol, return `PiToolResponse.privateTo` and pass the admitted `author.principalId`; the worker records `details.privateTo` and applies those projections itself. The runtime does it for you when the tool's `session.runTask` worker loaded the reader's memory: that call's result, and those of the calls it runs within (the outer call of a nested call, the call whose worker's task started it), record the reader as whose it is, while a call merely running beside it keeps its result public. The runtime knows which call started a task from the call its session tools' `execute` runs within; a task started from outside every tool call, such as an event handler's, cannot say, so every call running then records the reader. The guarantee, then, covers memory exchanges: a memory tool's call and result, and a call whose worker loaded the reader's memory and its result, each pair hidden from every other speaker and from compaction summaries, and the reasoning of earlier turns. Anything else the model writes, into another tool's arguments or into its reply, is public, and whatever it repeats there of someone's memory reaches everyone in the conversation. This holds for the requests the core sends; a provider that replays history of its own bypasses it. Each turn records its actual reader and whether its request carried private prompt memory, a memory exchange, or a memory-loaded worker. Before any provider call and on agent model switches too, claude-bridge checks the raw branch, including compacted reader records, and refuses a turn whose history carried another reader's private memory or an inaccessible explicit exchange. This refuses reasoning replay across readers even without memory tool calls. A recorded turn without private memory never blocks another reader merely for speaking; malformed, truncated and unanswered memory calls belong to their recorded turn reader, so its own reader can continue. For this core check only SYSTEM counts as primary owner, including modern exchanges/reasoning; SYSTEM can read that raw owner memory and repeat it publicly, despite per-request projection loading no personal memory for SYSTEM. Pre-0.9 untagged memory results before the first scope record retain owner attribution; later unowned exchanges without reader records fail closed. Disabling future memory does not bypass recorded private history. In Pi sandbox mode, party-wide prompt blocks must explicitly set `memory.visibility: "shared"` (default private), and the worker capability handshake fails private turns closed on an old image. Admission of other principals warns at startup and in doctor rather than failing boot. Public replies, untagged custom tools and old summaries remain possible disclosure paths: see the [bridge guard](migration-0.9.md#claude-bridge-guard).
1916
+ - A conversation 0.8 recorded through `context.turns` without `conversation` stays shared: whether such a room was one person's cannot be told, and adopting it for someone would refuse everyone else's turns. Its memory is each speaker's, as before, and its tools name the speaker. A plugin whose 0.8 conversations were one person's adopts them with `CONVERSATIONS.adopt`, as remote-mcp does.
1662
1917
 
1663
1918
  Call `context.turns.run(input)` inside your claim's queue task to run a turn in a conversation it owns.
1664
1919
  It shows typing and the stop control on the channel's surface, runs the turn, and emits `turnStarted` and `turnEnded` with the turn's `kind`.
1665
1920
  It catches runtime errors as failed results and posts the answer, failure notice, or stopped notice through the surface.
1666
1921
  Supply `reply(result)` to handle the reply yourself.
1667
- `input` has `channel`, `kind`, `text`, `speaker`, and optionally `attachments`, `selection` (by default the plugins' `agentSelection`), `steerable`, `interactive`, `confirmed`, and `reply`.
1668
- It rejects with `NotLinkedError` during `setup`, and with a `PluginError` on a host whose agent server has not provided a runtime.
1922
+ `input` has `channel`, `kind`, `text`, `speaker`, and optionally `attachments`, `selection` (by default the plugins' `agentSelection`), `steerable`, `interactive`, `confirmed`, `reply`, and `conversation`.
1923
+ It rejects with `NotLinkedError` during `setup`, and with a `PluginError` on a host whose runtime plugin has not provided a runtime.
1924
+
1925
+ Before each turn runs, `context.turns.run` records its conversation in the host's registry, `CONVERSATIONS`: at the first turn its key, surface, kind, visibility, owner, and title, and at every later turn only that it was active.
1926
+ `conversation: { visibility: "private" }` records it as the speaker's own (its `principalId` is `speaker.principalId`); without it the conversation is `shared`.
1927
+ Only its person speaks in a private conversation: a turn whose speaker is another principal is refused before it starts, except the host's own turns (`SYSTEM_PRINCIPAL`), such as a report's.
1928
+ Neither a later turn nor another plugin changes what the first turn recorded, with one exception for data from before 0.9: `adopt(key, principalId)` makes a conversation recorded `shared` with no principal, as 0.8 recorded every turn run without `conversation`, private to that principal.
1929
+ It is for a plugin migrating what its own 0.8 version left, such as remote-mcp giving each session 0.8 opened to the primary owner; it never changes a private conversation or one with a principal, and repeating or racing it is safe.
1930
+ Adopting a conversation that 0.9 or later recorded is outside its contract: a shared conversation recorded since then is shared on purpose, and a later version may refuse to adopt it.
1931
+ `conversation.title` names it at its first turn; `setTitle(key, title)` renames it later.
1932
+ The runtime is told who the conversation belongs to as the registry keeps it, in `TurnRequest.conversation`, so a private conversation's prompts stay its person's at every later turn; a host without the registry passes the turn's own `conversation`, if it gives one.
1933
+ A turn whose conversation cannot be recorded does not run, and the call rejects.
1934
+ The registry records and never refuses: who may speak in a conversation stays your claim's decision, which may read `get(key)` to check the owner.
1935
+ `list({ principal })` gives one principal's conversations and `list()` every one, the most recently active first; the web console lists them too.
1936
+ A conversation from before the registry is recorded at its next turn.
1937
+ Register a private conversation before the first direct runtime call, transcript reads included, rather than relying on a current speaker to define the session.
1938
+ Never change visibility to reuse history: when the effective private principal or visibility changes, the runtime archives history, drops held actions, and starts isolated, after a restart too.
1669
1939
 
1670
1940
  <!-- example: examples/study-room.ts -->
1671
1941
  ```ts
@@ -1696,25 +1966,22 @@ export const studyRoom = definePlugin({
1696
1966
  priority: 10,
1697
1967
  // The id of a room starts with `study-`, on whichever surface carries it.
1698
1968
  owns: (channel) => parseChannelKey(channel).id.startsWith("study-"),
1699
- admit: (message) =>
1700
- message.authorIsBot
1701
- ? undefined
1702
- : {
1703
- kind: "turn",
1704
- run: async () => {
1705
- await turns.run({
1706
- channel: message.channel,
1707
- kind: STUDY,
1708
- text: message.text,
1709
- speaker: {
1710
- id: message.authorId,
1711
- name: message.authorName,
1712
- tier: "member",
1713
- },
1714
- });
1715
- },
1716
- failure: "a study turn failed",
1717
- },
1969
+ admit: (message) => {
1970
+ const speaker = message.speaker;
1971
+ if (message.authorIsBot || !speaker) return undefined;
1972
+ return {
1973
+ kind: "turn",
1974
+ run: async () => {
1975
+ await turns.run({
1976
+ channel: message.channel,
1977
+ kind: STUDY,
1978
+ text: message.text,
1979
+ speaker,
1980
+ });
1981
+ },
1982
+ failure: "a study turn failed",
1983
+ };
1984
+ },
1718
1985
  // What the conversation was, so a host picks the right persona when it starts over.
1719
1986
  startFresh: async () => STUDY,
1720
1987
  },
@@ -1729,7 +1996,8 @@ The test uses the fake surface and echo runtime from [the runtime slot](#the-run
1729
1996
  <!-- example: examples/study-room.test.ts -->
1730
1997
  ```ts
1731
1998
  import { expect, test } from "bun:test";
1732
- import { testPlugin } from "pi-roundtable/testing";
1999
+ import { IDENTITY } from "pi-roundtable";
2000
+ import { servicePair, testPlugin } from "pi-roundtable/testing";
1733
2001
  import { createEchoRuntime } from "./echo-runtime.ts";
1734
2002
  import { FakeSurface } from "./fake-surface.ts";
1735
2003
  import { studyRoom } from "./study-room.ts";
@@ -1740,6 +2008,16 @@ async function studying() {
1740
2008
  const harness = await testPlugin(studyRoom, {
1741
2009
  surfaces: [surface],
1742
2010
  providers: { runtime: createEchoRuntime },
2011
+ services: [
2012
+ servicePair(IDENTITY, {
2013
+ resolve: async (facts) => ({
2014
+ id: facts.subject,
2015
+ name: facts.name,
2016
+ principalId: "student",
2017
+ tier: "member",
2018
+ }),
2019
+ }),
2020
+ ],
1743
2021
  });
1744
2022
  return { surface, harness };
1745
2023
  }
@@ -1769,6 +2047,7 @@ test("a message in a study room runs as a turn of the study kind, with the tutor
1769
2047
  expect(started?.turn).toMatchObject({
1770
2048
  kind: "study",
1771
2049
  channel: "fake:study-algebra",
2050
+ speaker: { principalId: "student", tier: "member" },
1772
2051
  });
1773
2052
  expect(started?.turn?.agent).toBeUndefined();
1774
2053
  await harness.stop();
@@ -1790,6 +2069,22 @@ test("starting a room over says it was a study conversation", async () => {
1790
2069
  await harness.stop();
1791
2070
  });
1792
2071
 
2072
+ test("an author with no resolved tier never starts a study turn", async () => {
2073
+ const surface = new FakeSurface();
2074
+ const harness = await testPlugin(studyRoom, {
2075
+ surfaces: [surface],
2076
+ providers: { runtime: createEchoRuntime },
2077
+ });
2078
+ try {
2079
+ surface.say("fake:study-algebra", "hello");
2080
+ await Bun.sleep(30);
2081
+ expect(surface.replies).toEqual([]);
2082
+ expect(harness.events).toEqual([]);
2083
+ } finally {
2084
+ await harness.stop();
2085
+ }
2086
+ });
2087
+
1793
2088
  test("the persona is the plugin's and belongs to the study kind only", async () => {
1794
2089
  const { harness } = await studying();
1795
2090
  const [persona] = harness.contribution.personas ?? [];
@@ -1800,18 +2095,136 @@ test("the persona is the plugin's and belongs to the study kind only", async ()
1800
2095
  ```
1801
2096
  <!-- /example -->
1802
2097
 
2098
+ ### Channel context: what was said around an addressed message
2099
+
2100
+ In a Discord server channel, people talk among themselves and address the assistant now and then.
2101
+ Channel context lets the turn for an addressed message also read what was posted there since the assistant last posted, mentions or not.
2102
+ The agent server reads it for every turn in an agent's channel; a group's round does not, since it already carries what was said since the group's last turn.
2103
+ A plugin's claim opts in by calling `DISCORD.channelContext(message, options?)` from `pi-roundtable/discord` inside its turn's `run`, and appending the result with `withChannelContext(text, context)`, as `withReference` adds the replied-to message.
2104
+
2105
+ It reads Discord, not the host's history, so it survives restarts:
2106
+
2107
+ - It fetches the `fetch` messages before the addressed one (default 50, at most 100) and keeps those after the assistant's latest post there: its bot user's, or one of its application's webhooks, such as an agent's voice. Any post of the assistant ends the window, including a notice that a conversation started over; the core posts no marker of its own, so a fresh-start reply must be a public post, not an ephemeral one, to cut the window.
2108
+ - It leaves out the assistant's own posts, the addressed message, empty messages, and every message it handed over before or that addressed it before in that channel, remembered in memory per channel, so a restart can repeat at most what came since the assistant's last post.
2109
+ - Other bots and integrations are kept and marked as bots; the host's owners (the primary owner and every other owner's Discord identities) are marked as owners.
2110
+ - Consecutive messages of one author at least `similarity` alike (default 0.8, by their longest common run of text) count as one, the later kept.
2111
+ - It keeps the newest `keep` (default 15), oldest first. Mentions read as names, and stickers and files as text with their names and links; nothing is downloaded or shown to the model as an image. A message is cut at `messageChars` (default 500), another bot's at `botMessageChars` (default 80).
2112
+ - It never fails the turn over Discord: a Discord error, such as a channel the bot cannot read, is logged and the turn runs without context.
2113
+
2114
+ `DISCORD.channelContext` resolves undefined in a direct message, on another surface, when nothing new was said, and when the host's `discord.channelContext` is `false`, which turns it off for the agent server and every claim.
2115
+ The configuration's values are the host's defaults, and a call's `options` override them for that call.
2116
+ Call it once per answered message: what it returns is remembered as handed over.
2117
+ A call's `options` are validated like the configuration (`fetch`, `keep`, `messageChars` and `botMessageChars` positive integers, `similarity` from 0 to 1); an invalid one makes the call reject, a programming error, so `keep: 0` is not "none" (skip the call for that).
2118
+ It remembers what it handed over and what addressed the assistant, not every message that ran a turn: a post a claim answered some other way, such as a webhook report turn or a steered message, can also come in the context of a later turn if the assistant has not posted since.
2119
+
2120
+ What it returns is public channel text, visible to everyone in the channel; it is not private memory, carries no `privateTo`, and goes into the turn's text, so it lives in the conversation's history, not in the system prompt.
2121
+ The turn's reader stays the person who addressed the assistant.
2122
+ People the access rules give no tier are included as channel text, as everyone there sees them, but they do not become speakers: they are not resolved, linked, or recorded, and the turn does not run as them.
2123
+ Only server channels are read, never direct messages, and only messages the bot can read.
2124
+
2125
+ The window ends at the assistant's last public post, so a conversation started over must end it too, and the owner's reply to the command is ephemeral.
2126
+ When any claim's conversation is started over in a server channel or thread, through `conversations.startFresh` (the agent server's channels, a plugin's claim such as a party, an owner channel), the core posts one short divider there as the bot, after the claim's `startFresh` succeeded, and the next turn reads nothing from before it.
2127
+ It is not posted in a direct message or on another surface, and a failed post is logged as a warning: the restart still counts and the ephemeral reply still goes out.
2128
+ The text is `discord.freshMarker` (default `─── new conversation ───`); `false` posts none.
2129
+ A surface a plugin contributes may implement the optional `ChatSurface.markFresh(channel)` for the same job.
2130
+
2131
+ The block gives each author as a name, their Discord id and a role (`owner`, `bot` or `member`) in separate attributes, and says the messages were not addressed to the assistant. A name loses the characters `"`, `'`, `=`, `<`, `>`, `(`, `)` and `,`, so it cannot imitate another author, an attribute or a role, and every `<` (also the full-width `<`) in a message's text is replaced by `‹`, so a message can neither close the block nor open a message of its own.
2132
+ `ChannelContext` is `{ messages, text }`: `messages` are `ChannelContextMessage`s (`id`, `authorId`, `authorName`, `bot`, `owner`, `text`, `at`), oldest first, and `text` is the block.
2133
+ `ChannelContextOptions` holds the options above, and `CHANNEL_CONTEXT_DEFAULTS` their defaults.
2134
+ In a test, `fakeDiscord({ channelContext })` answers `DISCORD.channelContext` as the test says; by default with no context.
2135
+
2136
+ <!-- example: examples/channel-context.ts -->
2137
+ ```ts
2138
+ import { definePlugin, parseChannelKey } from "pi-roundtable";
2139
+ import { DISCORD, withChannelContext } from "pi-roundtable/discord";
2140
+
2141
+ /** The kind of the tavern's conversations. */
2142
+ const TAVERN = "tavern";
2143
+
2144
+ /**
2145
+ * A claim that opts in to channel context: in the Discord channels it owns it answers only the
2146
+ * messages that mention the assistant or reply to it, and each turn also reads what the others
2147
+ * said there since the assistant last posted. The context is public channel text appended to the
2148
+ * turn's text; the people who wrote it do not become speakers.
2149
+ */
2150
+ export function tavern(channelIds: readonly string[]) {
2151
+ return definePlugin({
2152
+ name: "tavern",
2153
+ setup: ({ turns, services }) => {
2154
+ const discord = services.get(DISCORD);
2155
+ return {
2156
+ personas: [
2157
+ {
2158
+ kind: TAVERN,
2159
+ prompt: () =>
2160
+ "You keep the tavern's table. Answer the person who addressed you.",
2161
+ },
2162
+ ],
2163
+ channels: [
2164
+ {
2165
+ name: "tavern",
2166
+ priority: 10,
2167
+ owns: (channel) => {
2168
+ const { surface, id } = parseChannelKey(channel);
2169
+ return surface === "discord" && channelIds.includes(id);
2170
+ },
2171
+ admit: (message) => {
2172
+ const { speaker } = message;
2173
+ if (message.authorIsBot || !speaker) return undefined;
2174
+ if (!message.mentionsBot && !message.repliesToBot)
2175
+ return undefined;
2176
+ return {
2177
+ kind: "turn",
2178
+ run: async () => {
2179
+ // Once per answered message, inside `run`: what it returns is not returned again.
2180
+ const around = await discord.channelContext(message, {
2181
+ keep: 20,
2182
+ });
2183
+ await turns.run({
2184
+ channel: message.channel,
2185
+ kind: TAVERN,
2186
+ text: withChannelContext(message.text, around),
2187
+ speaker,
2188
+ });
2189
+ },
2190
+ failure: "a tavern turn failed",
2191
+ };
2192
+ },
2193
+ startFresh: async () => TAVERN,
2194
+ },
2195
+ ],
2196
+ };
2197
+ },
2198
+ });
2199
+ }
2200
+ ```
2201
+ <!-- /example -->
2202
+
1803
2203
  ### `backgroundTargets`: whose turn a schedule or delegated task is
1804
2204
 
1805
2205
  Schedules and delegated tasks return later as turns without a new message in the channel.
1806
2206
  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.
1807
- `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.
2207
+ `schedules` (`perChannel`, `promptChars`, `aheadDays`, and optionally `perPrincipal`) bounds what `schedule_create` accepts, and `delegation` (`maxRunning`, and optionally `maxRunningPerPrincipal`) bounds how many delegated tasks may run in one channel; a target without one of them may not schedule or delegate at all. `perPrincipal` and `maxRunningPerPrincipal` hold each person to that many of the target's schedules, or running tasks, across all their conversations. A schedule store counts a person's schedules for `perPrincipal` in `createWithin`, which must count and store as one step for that person, so creates at once in several conversations stay within the limit; the built-in store does it in one transaction under a lock on the creator.
1808
2208
  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.
1809
2209
  A channel's claim runs turns only for its own target, keeping the owner's tools out of channels open to many people.
1810
2210
 
2211
+ A turn is someone's: `turn.author.principalId` is the principal who set up the work, `turn.author.id` and `name` how they are shown, and `turn.tier` the tier it asks for, all required.
2212
+ The router checks the author as the turn starts, in its place in the channel's queue, and hands the claim `turn.speaker`, the speaker to run it as: the principal at the lower of `turn.tier` and their tier now.
2213
+ It skips the turn, with the reason, when the principal does not exist, is disabled, holds no tier, or holds one only from what a surface reports and was last seen more than `access.backgroundStaleDays` ago, and when the turn names no principal or tier; it never runs one as the owner's by default.
2214
+ A turn naming the system principal (`SYSTEM_PRINCIPAL`) runs only when the host itself started it.
2215
+ A schedule stores its creator's principal as `createdById` and runs as them; a delegated task's report runs as the person whose turn delegated it.
2216
+ In a turn nobody is named for, the schedule and delegation tools refuse.
2217
+
1811
2218
  - Contribute `backgroundTargets` alongside the claim that serves them; read one with `context.conversations.target(name)` from a service or handler.
1812
2219
  - Two targets with the same name are refused, naming both plugins.
1813
- - The agent server contributes `OWNER_TARGET` (name `"owner"`, exported from the main entry) for the owner's and agents' conversations.
1814
- Its claim answers that target and skips all others, so a turn for another target never runs with the owner's tools.
2220
+ - The core's `modules` plugin contributes `PERSONAL_TARGET` (name `"owner"`, exported from the main entry; `OWNER_TARGET` is a deprecated alias) on every host, for every person's conversations whose claim answers background turns, such as the agents' and an app's owner conversation.
2221
+ The agent server's claim answers that target and skips all others, so a turn for another target never runs with the owner's tools.
2222
+ Its limits are 0.8's, and it holds no one to a number of schedules or running tasks across their conversations unless the host's configuration says so: `background: { perPrincipal: { schedules: 20, delegations: 2 } }` sets its `perPrincipal` and `maxRunningPerPrincipal`. A host that serves many people sets both.
2223
+ - The built-in schedule and delegation tools make `PERSONAL_TARGET` work, and a session has them only where it can run: when the claim that owns its conversation has `background`, or, for a conversation no chat surface carries, when its creator has a direct channel, as a provider knows without the network.
2224
+ Such a conversation has the tools only when the registry records it private: its person is whom the work runs for. One recorded shared, or not recorded, has neither tool, whoever speaks in it, because a session outlives the turn it is built in.
2225
+ A plugin that calls the runtime directly records its no-surface conversation as private before the session is built.
2226
+ Creation reaches the speaker's own direct channel and checks that its claim takes background turns, refusing when either is absent; it never falls back to the primary owner's messages.
2227
+ In a private conversation, as the host's conversation registry records it, `schedule_list`, `schedule_update`, and `schedule_cancel` see only the speaker's own schedules.
1815
2228
  - A turn for a target no plugin contributes is skipped with the reason `no plugin contributes the background target "<name>"`.
1816
2229
  It never falls back to the owner's target; a recurring schedule keeps the reason as its last status and runs again once a plugin contributes the target.
1817
2230
  A one-time schedule is spent when it fires.
@@ -1862,6 +2275,42 @@ export const supportDesk = definePlugin({
1862
2275
  ```
1863
2276
  <!-- /example -->
1864
2277
 
2278
+ ### `directChannels`: reaching a person on their own
2279
+
2280
+ A plugin whose chat network gives each person a conversation of their own, such as direct messages or a web inbox, contributes a `DirectChannelProvider` for it:
2281
+
2282
+ ```ts
2283
+ setup: ({ services }) => ({
2284
+ directChannels: [
2285
+ {
2286
+ name: "inbox",
2287
+ label: "a notice in the web chat's inbox",
2288
+ reaches: async (principalId) => inboxOf(principalId),
2289
+ deliver: async (principalId, text) => postNotice(principalId, text),
2290
+ },
2291
+ ],
2292
+ }),
2293
+ ```
2294
+
2295
+ | Field | What it is |
2296
+ |---|---|
2297
+ | `name` | Unique across every plugin; a second provider of one name is a `PluginError` naming both plugins |
2298
+ | `label` | How the `notify` tool names a notice sent through it, completing "Send Ada …", such as `a direct message on Discord` |
2299
+ | `reaches(principalId)` | The person's own conversation here, as a channel key, or `undefined` when they have none here. It may reach the network and throw |
2300
+ | `knows(principalId)` | Optional: whether the person has a conversation here, from the host's own records such as their linked identities, without the network. A session asks it when it decides whether to offer `notify` or the schedule and delegation tools, so an outage then does not take them away; without it the host asks `reaches` |
2301
+ | `deliver(principalId, text)` | Optional: sends the notice itself. Without it the host posts the text in the channel `reaches` names, through the surface that serves it |
2302
+
2303
+ The host reaches a person through the first provider, in contribution order, that reaches them.
2304
+ `context.directChannels.reach(principalId)` resolves that provider and channel, or `undefined`; `known(principalId)` resolves the first provider that knows the person without the network; `notify(principalId, text)` sends a notice there and resolves `false` when no provider reaches the person.
2305
+ The Discord plugin contributes one named `discord`: it finds a principal's Discord identities through `IDENTITY` and reaches the first one's direct messages, the primary owner's configured identity first, so a single owner is reached in the same direct messages as in 0.8.
2306
+
2307
+ The `notify` tool, 0.8's `notify_owner`, sends such a notice. A session has it only when a provider is contributed, and a private conversation only when a provider knows its person; the channel itself is reached when a notice is sent.
2308
+ It notifies the conversation's person in a private conversation and the turn's speaker in a shared one, and refuses when no provider reaches them or when the speaker is not the private conversation's person; the host's own turns, such as a report's, notify the conversation's person in a private conversation and the primary owner elsewhere, as `notify_owner` did.
2309
+ Its description names the channels that can reach the person, so a host with only Discord reads `Send Ada a direct message on Discord. …` as 0.8 did.
2310
+ In a shared conversation it names the primary owner while only they can receive a notice (the host has no other owner, and only owners may use it), and "the speaker" otherwise; this is checked again before each turn, so an open session follows an owner granted or revoked meanwhile.
2311
+ Until 1.0 the name `notify_owner` still selects it in a selection or a profile and names it in a task's `exclude` and a plugin's `requiredTools`, and an operator's or a plugin's `toolTiers` entry for `notify_owner` applies to `notify` unless `notify` has its own.
2312
+ Using the old name logs a deprecation warning once per logger, whether in a selection or in `toolTiers`.
2313
+
1865
2314
  ### `surfaces`: a chat network of your own
1866
2315
 
1867
2316
  A chat surface connects the host to one chat network: it reports what people write, and it posts what the conversations answer.
@@ -1882,8 +2331,12 @@ The agent server claims only `discord:` keys, so claims on your surface's channe
1882
2331
  | `startTyping(channel)` | Shows a typing indicator until the returned function is called | none is shown |
1883
2332
  | `showStop(channel)` | Shows the owner a stop control until the returned function is called; using it calls `conversations.stop(channel)` | none is shown |
1884
2333
  | `react`, `unreact` | Adds or removes the bot's reaction on a message | no marks on queued or steered messages |
1885
- | `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 |
2334
+ | `prompts(channel, scope?)` | How those the turn's `PromptScope` names approve a held action or answer `ask_user` inside a running turn, as `Prompts`: `confirm` and `ask`; without a scope, the owners | the action is held until a message approves it |
1886
2335
  | `interim(channel)` | Where a running turn posts the text it writes before its final answer, as `InterimPosts`: `post(text)` sends one message of at most 2000 characters and resolves to an `InterimMessage` whose `edit(text)` changes it in place | only the final reply is posted |
2336
+ | `progress(channel, event)` | Shows a turn run through `context.turns` as it goes, such as a live preview in a web chat. `event` is a `TurnProgress`: `{ type: "text", delta }` (the reply's text, joined over 250 ms and always sent before a tool event, never the thinking), `{ type: "tool_start", id, tool, preview? }` (a one-line preview of the arguments, at most 80 characters, never their full text), or `{ type: "tool_end", id, tool, ok }`. The final reply still comes through `sendReply`; a rejection is logged and the turn goes on | only the final reply is shown |
2337
+
2338
+ Each message a surface delivers says who wrote it in `actor`, the `ActorFacts` the identity service resolves: `provider` and `subject`, such as `discord` and the user id, `name`, `roles` written `<surface>:role:<name>` (left out where the surface does not know them, such as a Discord DM, which is not the same as none), and `legacyId`, the speaker id 0.8 gave the person, so someone carried over keeps their principal.
2339
+ A surface that leaves `actor` out has it read from `authorId`, `authorName`, and `authorRoleIds` with its prefix as the provider, and the host warns once that this goes away in 1.0.
1887
2340
 
1888
2341
  The host starts each surface as `surface:<prefix>` at the contributing plugin's place in the order, before that plugin's own services.
1889
2342
  It stops surfaces in reverse order, like other services.
@@ -1891,9 +2344,25 @@ The host logs and drops messages whose prefix differs from the delivering surfac
1891
2344
  `context.surfaces` picks the surface by the key's prefix.
1892
2345
  Its `sendReply` rejects with a `PluginError` naming the prefix when no surface serves it.
1893
2346
  `startTyping`, `showStop`, `react`, and `unreact` do nothing when a channel has no surface or its surface omits those methods.
1894
- If `prompts` is unavailable, it returns `undefined` and held actions wait for the owner's next message.
2347
+ If `prompts` is unavailable, it returns `undefined` and held actions wait for a message that approves them.
1895
2348
  `of(channel)` returns the surface, or `undefined`.
1896
- The agent server asks the owner for approvals through `context.surfaces.prompts`, so a surface that gives `prompts` gets them in its own channels.
2349
+ The runtime asks for approvals through `context.surfaces.prompts`, so a surface that gives `prompts` gets them in its own channels.
2350
+
2351
+ #### Who answers a prompt: `PromptScope`
2352
+
2353
+ The runtime makes each interactive turn's `PromptScope` from its speaker and its conversation: `principalId` and `speakerId` (the speaker's principal and the identity that spoke), `tier`, and `escalate`.
2354
+ A conversation `context.turns` records as `private` gives `escalate: "none"`; a shared one, and a turn that names no conversation, `"owners"`.
2355
+ A surface's prompts keep these rules:
2356
+
2357
+ - The speaker's principal answers their own question on any of their identities the surface knows, and approves a call when their tier is at least its `minTier` (the owner tier when absent) and, for an owner-tier call, the principal is an owner; a surface that can check their tier again when they answer does.
2358
+ - Where the scope escalates to the owners, every owner may answer too, and an approval above the speaker's tier is the owners' alone.
2359
+ - Where it escalates to no one, nobody else answers, and an approval above the speaker's tier resolves `expired` at once, shown to no one: the call stays held.
2360
+ - Anyone else is refused, even at the same tier as the speaker.
2361
+ - Without a scope, as for a turn with no speaker, the prompts are the owners'.
2362
+
2363
+ On Discord the owners are every owner with a Discord identity, and the primary owner always; a card in a thread mentions the identity that spoke when it is theirs and one of the principal's Discord identities, every Discord identity of the principal when the speaker is none of them, and the owners when it goes to them, so a single owner's cards read as they did in 0.8.
2364
+ A principal with no Discord identity, such as the reporter of a background report, answers nothing there, so their card goes to the owners; so does an owner-tier call of a turn whose tier defaulted to owner when its principal is not an owner.
2365
+ The web chat shows prompts to the conversation's person only: its owners are not on it, so an approval above that person's tier expires at once whatever the scope says.
1897
2366
 
1898
2367
  #### Interim text: what a turn writes before its final answer
1899
2368
 
@@ -1919,15 +2388,26 @@ Its plugin adds a claim that answers through `context.surfaces`:
1919
2388
  <!-- example: examples/fake-surface.ts -->
1920
2389
  ```ts
1921
2390
  import {
2391
+ type Approval,
1922
2392
  type ChannelKey,
1923
2393
  type ChatSurface,
1924
2394
  definePlugin,
1925
2395
  type InboundMessage,
1926
2396
  type OutboundReply,
1927
- type OwnerPrompts,
2397
+ type PromptScope,
2398
+ type Prompts,
1928
2399
  parseChannelKey,
2400
+ TIERS,
1929
2401
  } from "pi-roundtable";
1930
2402
 
2403
+ /** A prompt the surface showed, open until someone it is for answers it. */
2404
+ interface Asked {
2405
+ id: string;
2406
+ channel: ChannelKey;
2407
+ title: string;
2408
+ open: boolean;
2409
+ }
2410
+
1931
2411
  /**
1932
2412
  * A chat surface connects the host to one chat network. This one is an in-memory chat: its
1933
2413
  * channels are the keys that start with `fake:`, and it records what the host asks of it. A real
@@ -1939,7 +2419,12 @@ export class FakeSurface implements ChatSurface {
1939
2419
  readonly replies: { channel: ChannelKey; reply: OutboundReply }[] = [];
1940
2420
  readonly typing: string[] = [];
1941
2421
  readonly stops: string[] = [];
1942
- readonly asked: string[] = [];
2422
+ readonly asked: Asked[] = [];
2423
+ /** Who may answer each open prompt, and how it settles. */
2424
+ readonly #answering = new Map<
2425
+ string,
2426
+ { principalId?: string; owners: boolean; settle(answer: Approval): void }
2427
+ >();
1943
2428
  #deliver: ((message: InboundMessage) => void) | undefined;
1944
2429
 
1945
2430
  /** The host hands over its router; every message the network reports goes to it. */
@@ -1981,16 +2466,66 @@ export class FakeSurface implements ChatSurface {
1981
2466
  return () => void this.stops.push(`hide ${channel}`);
1982
2467
  }
1983
2468
 
1984
- /** How the owner would approve a held action or answer a question in a turn. */
1985
- prompts(channel: ChannelKey): OwnerPrompts {
2469
+ /**
2470
+ * How a turn asks to approve a held action or answers a question. The scope names who may
2471
+ * answer: its speaker when their tier holds the call, and the owners unless the conversation
2472
+ * is private, where a call above the speaker's tier goes to no one and expires at once.
2473
+ */
2474
+ prompts(channel: ChannelKey, scope?: PromptScope): Prompts {
1986
2475
  return {
1987
- confirm: async (title) => {
1988
- this.asked.push(`${channel}: ${title}`);
1989
- return "approved";
2476
+ confirm: async (title, _message, signal, minTier = "owner") => {
2477
+ const theirs =
2478
+ scope !== undefined &&
2479
+ TIERS.indexOf(scope.tier) >= TIERS.indexOf(minTier);
2480
+ const owners = scope?.escalate !== "none";
2481
+ if (!theirs && !owners) return "expired";
2482
+ return this.#ask(channel, title, signal, {
2483
+ ...(theirs ? { principalId: scope.principalId } : {}),
2484
+ owners,
2485
+ });
1990
2486
  },
1991
2487
  ask: async () => undefined,
1992
2488
  };
1993
2489
  }
2490
+
2491
+ /** Someone answers an open prompt; it settles only when it is theirs to answer. */
2492
+ answer(
2493
+ id: string,
2494
+ who: { principalId: string; owner?: boolean },
2495
+ approved: boolean,
2496
+ ): void {
2497
+ const open = this.#answering.get(id);
2498
+ if (!open) return;
2499
+ const theirs = open.principalId === who.principalId;
2500
+ if (theirs || (who.owner && open.owners))
2501
+ open.settle(approved ? "approved" : "declined");
2502
+ }
2503
+
2504
+ #ask(
2505
+ channel: ChannelKey,
2506
+ title: string,
2507
+ signal: AbortSignal | undefined,
2508
+ audience: { principalId?: string; owners: boolean },
2509
+ ): Promise<Approval> {
2510
+ const asked = {
2511
+ id: `q${this.asked.length + 1}`,
2512
+ channel,
2513
+ title,
2514
+ open: true,
2515
+ };
2516
+ this.asked.push(asked);
2517
+ return new Promise((resolve) => {
2518
+ const settle = (answer: Approval) => {
2519
+ this.#answering.delete(asked.id);
2520
+ asked.open = false;
2521
+ resolve(answer);
2522
+ };
2523
+ this.#answering.set(asked.id, { ...audience, settle });
2524
+ signal?.addEventListener("abort", () => settle("cancelled"), {
2525
+ once: true,
2526
+ });
2527
+ });
2528
+ }
1994
2529
  }
1995
2530
 
1996
2531
  /**
@@ -2032,6 +2567,155 @@ export function fakeChat(surface: FakeSurface) {
2032
2567
 
2033
2568
  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).
2034
2569
 
2570
+ ### `adapters`: the chat networks the host talks through
2571
+
2572
+ `adapters` lists the chat networks the host talks through, each made by its adapter's factory.
2573
+ In 0.8 the one adapter is Discord: `discord()` from `pi-roundtable/discord` takes the same settings as the top-level `discord`, and a host configured either way assembles the same plugins in the same order.
2574
+ The top-level `discord` stays the form `roundtable init` writes; configure Discord once, in one place or the other.
2575
+ A chat network that comes as a plugin, such as pi-roundtable-webchat's `webChat()`, stays in `plugins`.
2576
+
2577
+ <!-- example: examples/discord-adapter.ts -->
2578
+ ```ts
2579
+ import type { RoundtableConfig } from "pi-roundtable";
2580
+ import { discord } from "pi-roundtable/discord";
2581
+
2582
+ /**
2583
+ * Discord as an adapter: `discord()` takes the same settings as the top-level `discord`, and the
2584
+ * host assembles the same plugins in the same order. Configure Discord in one place, not both.
2585
+ */
2586
+ export function withDiscordAdapter(
2587
+ env: (name: string) => string,
2588
+ dataDir: string,
2589
+ ): RoundtableConfig {
2590
+ return {
2591
+ owner: { id: env("OWNER_ID"), name: env("OWNER_NAME") },
2592
+ adapters: [
2593
+ discord({
2594
+ token: env("DISCORD_TOKEN"),
2595
+ guild: env("DISCORD_GUILD_ID"),
2596
+ entryChannel: env("DISCORD_ENTRY_CHANNEL_ID"),
2597
+ }),
2598
+ ],
2599
+ database: { url: env("DATABASE_URL") },
2600
+ dataDir,
2601
+ model: env("MODEL"),
2602
+ http: { publicUrl: env("PUBLIC_URL") },
2603
+ };
2604
+ }
2605
+ ```
2606
+ <!-- /example -->
2607
+
2608
+ `examples/discord-adapter.test.ts` checks that both forms assemble the same plugins.
2609
+ `roundtable doctor` reads a Discord adapter's settings as it reads the top-level `discord`.
2610
+ The types are `AdapterConfig` and `DiscordConfig` from `pi-roundtable`, and `DiscordAdapterConfig` from `pi-roundtable/discord`.
2611
+
2612
+ ### A host without Discord
2613
+
2614
+ Leave `discord` out of `roundtable.config.ts` and the host runs without it: no Discord plugin, no agent server, no agents, and no skills.
2615
+ The runtime plugin still builds the runtime, so every claim that runs turns through `context.turns` works, over the surfaces your plugins bring.
2616
+ `http` is optional too; without it the host opens no listener, and a route then names a listener that is not configured.
2617
+
2618
+ What needs Discord is refused or left out rather than failing later:
2619
+
2620
+ - `agents`, `skills` (anything but `false`), and `ops.agent` are configuration errors. `ops: { conversation: "<surface>:<id>" }` needs a chat surface that serves it and a claim that owns it and takes background turns, or the host does not start. Webchat declares `takesSystemReports: false` and takes no system error reports.
2621
+ - `notify` is not registered unless a plugin contributes a [direct channel](#directchannels-reaching-a-person-on-their-own), since there is nowhere to send a notice.
2622
+ - `schedule_*` and `delegate_task` are registered only in a conversation whose claim takes background turns: their runs are turns of the [background target](#backgroundtargets-whose-turn-a-schedule-or-delegated-task-is) `PERSONAL_TARGET`, named `owner`, which the core contributes on every host. A conversation of a claim without `background` has neither; a no-surface conversation needs a registered private principal known to a direct channel, as [background targets](#backgroundtargets-whose-turn-a-schedule-or-delegated-task-is) describes.
2623
+ - `roundtable doctor` skips the Discord checks.
2624
+
2625
+ Pi's runtime still needs the `compact_session` tool in every session, from the Pi package pi-self-compact: load it from a plugin with `piPackages: ["pi-self-compact"]`, as the `plugins/self-compact.ts` of a project `roundtable init` creates does, or the preflight stops the start and says so.
2626
+ `roundtable init --adapter web` creates a host without Discord around the [web chat](#web-chat-pi-roundtable-webchat).
2627
+ A host that keeps Discord may write it as an adapter instead, as [`adapters`](#adapters-the-chat-networks-the-host-talks-through) describes.
2628
+
2629
+ <!-- example: examples/headless.ts -->
2630
+ ```ts
2631
+ import { definePlugin, type RoundtableConfig } from "pi-roundtable";
2632
+ import type { FakeSurface } from "./fake-surface.ts";
2633
+ import { studyRoom } from "./study-room.ts";
2634
+
2635
+ /** A plugin that brings a chat network and nothing else; the study room's claim answers its rooms. */
2636
+ export function chatNetwork(surface: FakeSurface) {
2637
+ return definePlugin({
2638
+ name: "chat-network",
2639
+ setup: () => ({ surfaces: [surface] }),
2640
+ });
2641
+ }
2642
+
2643
+ /**
2644
+ * A host without Discord: no `discord` key, so no Discord plugin, no agent server, no agents and
2645
+ * no skills; and no `http`, so no listener opens until a plugin needs one. Conversations come
2646
+ * through the plugins' own chat surfaces, and `context.turns` runs them on the host's runtime,
2647
+ * Pi's unless a plugin fills the `runtime` slot.
2648
+ */
2649
+ export function studyHall(
2650
+ surface: FakeSurface,
2651
+ where: { databaseUrl: string; dataDir: string },
2652
+ ): RoundtableConfig {
2653
+ return {
2654
+ name: "Study Hall",
2655
+ access: {
2656
+ owners: [{ principal: "owner", name: "Ada" }],
2657
+ // Admit the fake surface's authors explicitly, not in the claim.
2658
+ members: { everyone: ["fake"] },
2659
+ },
2660
+ database: { url: where.databaseUrl },
2661
+ dataDir: where.dataDir,
2662
+ model: "anthropic/claude-sonnet-5-5",
2663
+ plugins: [chatNetwork(surface), studyRoom],
2664
+ };
2665
+ }
2666
+ ```
2667
+ <!-- /example -->
2668
+
2669
+ `examples/headless.test.ts` boots this configuration over PostgreSQL with the echo runtime in place of Pi, and a study room answers through the fake surface.
2670
+ `testHost({ discord: false })` boots the same kind of host for a plugin's tests.
2671
+
2672
+ ### Web chat: pi-roundtable-webchat
2673
+
2674
+ [pi-roundtable-webchat][webchat-package] is a chat network that comes as a plugin.
2675
+ `webChat({ verifier, personas, origins })` adds a chat surface whose conversations have keys `web:<conversation>`, the claim that runs each message as a turn of its conversation's persona through `context.turns`, and a REST API and a WebSocket under `/chat` on the host's `public` listener.
2676
+ People an OpenID Connect provider signs in open private conversations, see each turn's text and tools as it runs, and answer its approval cards.
2677
+ It needs no Discord: a host whose configuration has no `discord` and lists `webChat(...)` in `plugins` is a web-only assistant.
2678
+
2679
+ #### `roundtable init --adapter web`
2680
+
2681
+ `roundtable init --adapter web` writes a project without Discord around it: no `agents.ts` and no shared persona, a `roundtable.config.ts` that lists `webChat(...)` with an `oidcJwtVerifier`, an access map whose members and admins come from the token's roles, in top-level `access`, and one persona, `assistant`, whose prompt is `persona/assistant.md`.
2682
+ The persona's `selection` names its tools (the hello plugin's), so tools a later plugin adds, such as pi-web-access's, stay out until you add them by name.
2683
+ `package.json` pins pi-roundtable-webchat at the core's version and pi-self-compact, and `.env.example` asks for `OWNER_NAME`, `DATABASE_URL`, `MODEL`, `OIDC_ISSUER`, `OIDC_AUDIENCE`, `OIDC_JWKS_URL`, `CHAT_MEMBER_ROLES`, and `CHAT_ORIGINS`, with no Discord variable.
2684
+ The host listens on `127.0.0.1:3000`; serve it over HTTPS through a reverse proxy.
2685
+ `--adapter discord` is the default and writes the Discord project.
2686
+
2687
+ #### Protocol
2688
+
2689
+ A browser asks for a one-time ticket with `POST /chat/tickets` and `Authorization: Bearer <token>`, then opens `/chat/socket` offering the subprotocols `roundtable.webchat.v1` and `ticket.<ticket>`; another client sends the bearer token on the upgrade.
2690
+ The token never travels in a URL.
2691
+ Each WebSocket message is one JSON object with a `type`: the client sends `send`, `stop`, `approval`, `answer`, and `auth`; the server answers with `ready`, `accepted`, `typing`, `stoppable`, `progress`, `reply`, `failed`, `prompt`, `prompt_closed`, `notice`, `reauth`, and `error`.
2692
+ `progress` carries the turn's [`TurnProgress`](#surfaces-a-chat-network-of-your-own) events, never the thinking and never a tool's full arguments; `reply` carries the answer in full markdown.
2693
+ The REST API lists a person's conversations, opens one, reads its messages, and lists and acknowledges private inbox notices from `notify`.
2694
+ Tickets, quotas, connections, and approvals follow `principalId`; refreshing a token to another principal closes the socket with 4403.
2695
+ The package README documents every frame, error code, close code, and limit.
2696
+
2697
+ #### Security model
2698
+
2699
+ - A conversation belongs to the person who opened it: the claim checks the [conversation registry](#services-what-plugins-provide-to-each-other) inside the conversation's queue before every turn, and only that person may list, read, write in, stop, or answer it.
2700
+ - Tokens are checked on every REST call, every upgrade, and every `auth` frame. By default only a person's access token passes: a token without a scope or app roles, or an app-only token, is refused. A socket closes when its token expires.
2701
+ - `origins` is required, and checked on every upgrade and every browser request.
2702
+ - Each person has bounded sockets, tickets, conversations opened per hour, and turns running or queued at once; frames are bounded in size and rate.
2703
+ - An approval card goes to the conversation's person only, at the tier the held call needs.
2704
+ - No token claim makes anyone the owner; list their linked identity in top-level `access.owners` or grant a lasting role through the CLI.
2705
+ - The old webchat `access` option is removed; prefix roles as `<surface>:role:<name>` and scope old `everyone: true` to `everyone: ["<surface>"]`.
2706
+
2707
+ #### Provider settings
2708
+
2709
+ The verifier is generic; what makes it safe is how it is pointed at your provider.
2710
+ The README's [provider settings][webchat-provider-settings] explain, with Microsoft Entra ID as the example, why to name people by a claim that stays the same across app registrations (`subjectClaim`), pin the tenant in `check`, accept access tokens only, refuse app-only tokens, merge issuers of one tenant only (`speakerIssuer`), and mind the guests `everyone` admits.
2711
+
2712
+ The web chat takes no attachments or system error reports (`takesSystemReports: false`), so `ops.conversation` cannot name it.
2713
+ It accepts `PERSONAL_TARGET` background turns only for the private conversation's principal; include `schedule_*`, `delegate_task`, and `notify` in a persona's selection only deliberately, adjusting their tiers if needed.
2714
+ Leave out `web_search` and `fetch_content` unless people may make the host fetch any address, internal ones included.
2715
+
2716
+ [webchat-package]: https://github.com/wayne930242/pi-roundtable/blob/master/packages/webchat/README.md
2717
+ [webchat-provider-settings]: https://github.com/wayne930242/pi-roundtable/blob/master/packages/webchat/README.md#provider-settings
2718
+
2035
2719
  ### What plugins do not extend
2036
2720
 
2037
2721
  A plugin adds slash commands through the Discord plugin's registrar and never receives the composed commands (see [slash commands](#slash-commands-commandsadd)).
@@ -2058,10 +2742,9 @@ Inspect the ready-made implementations in the [codex-images][codex-images-templa
2058
2742
 
2059
2743
  The [pi-roundtable-mcp][mcp-package] workspace, published separately on npm, adds two more plugins, `mcpConnectors` and `remoteMcp`.
2060
2744
  The first lets the owner add MCP servers such as Notion or a calendar from Discord and gives your code the list; the second lets an agent outside Discord reach your agent.
2061
- Install it with `bun add pi-roundtable-mcp` after the next lockstep release publishes the migrated package.
2062
- Until then, npm's MCP 0.4.1 requires core below 0.6.0 and is not compatible with core 0.7.x.
2745
+ Install it with `bun add pi-roundtable-mcp`.
2063
2746
 
2064
- Five official packages live in this repository as Bun workspaces and publish as separate npm packages, versioned in lockstep with the core.
2747
+ Six official packages live in this repository as Bun workspaces and publish as separate npm packages, versioned in lockstep with the core.
2065
2748
  The [site guide source files][package-guides] describe installation, requirements, configuration, and security considerations:
2066
2749
 
2067
2750
  - [pi-roundtable-drawing][drawing-package]: [site guide source][drawing-guide] · local relationship maps, magic circles, sigils, sacred geometry, and card spreads.
@@ -2069,6 +2752,7 @@ The [site guide source files][package-guides] describe installation, requirement
2069
2752
  - [pi-roundtable-web][web-package]: [site guide source][web-guide] · an owner-only console for conversations, transcripts, and memory notes with live updates.
2070
2753
  - [pi-roundtable-sandbox][sandbox-package]: [site guide source][sandbox-guide] · sealed guest channels on native Linux Docker with a host-only credential broker and allow-listed tools.
2071
2754
  - [pi-roundtable-mcp][mcp-package]: [site guide source][mcp-guide] · MCP connectors through a gateway and remote MCP endpoints for agent turns and owner-granted Discord channel tools.
2755
+ - [pi-roundtable-webchat][webchat-package]: [site guide source][webchat-guide] · a WebSocket chat for people an OpenID Connect provider signs in, with private conversations, live progress, and approval cards; see [web chat](#web-chat-pi-roundtable-webchat).
2072
2756
 
2073
2757
  [drawing-package]: https://github.com/wayne930242/pi-roundtable/blob/master/packages/drawing/README.md
2074
2758
  [coding-package]: https://github.com/wayne930242/pi-roundtable/blob/master/packages/coding/README.md
@@ -2081,6 +2765,7 @@ The [site guide source files][package-guides] describe installation, requirement
2081
2765
  [web-guide]: https://github.com/wayne930242/pi-roundtable/blob/master/site/src/content/docs/plugins/web-package.mdx
2082
2766
  [sandbox-guide]: https://github.com/wayne930242/pi-roundtable/blob/master/site/src/content/docs/plugins/sandbox-package.mdx
2083
2767
  [mcp-guide]: https://github.com/wayne930242/pi-roundtable/blob/master/site/src/content/docs/plugins/mcp-package.mdx
2768
+ [webchat-guide]: https://github.com/wayne930242/pi-roundtable/blob/master/site/src/content/docs/plugins/webchat-package.mdx
2084
2769
  [coding-source]: https://github.com/wayne930242/pi-roundtable/tree/master/packages/coding
2085
2770
  [sandbox-source]: https://github.com/wayne930242/pi-roundtable/tree/master/packages/sandbox
2086
2771
  [web-source]: https://github.com/wayne930242/pi-roundtable/tree/master/packages/web
@@ -2141,6 +2826,8 @@ Use `testPlugin(plugin, options?)` for most tests; it sets up one plugin against
2141
2826
  Use [`testHost`](#testhost-the-built-in-plugins-and-yours-over-postgresql) for tests that depend on built-in plugins or the host's setup order; it boots them alongside yours over PostgreSQL.
2142
2827
 
2143
2828
  `testPlugin` sets one plugin up against a fake context and starts its services, with no Discord and no PostgreSQL unless you pass `{ database }`.
2829
+ It resolves only its default owner unless you inject `servicePair(IDENTITY, { resolve })` for your test's actors; do not invent a speaker in a claim to bypass admission.
2830
+ The [principals example](#principals-and-access) injects only the read method it exercises, and the study-room example tests router-resolved attribution and refusal.
2144
2831
  It returns:
2145
2832
 
2146
2833
  | Field | What it is |
@@ -2170,7 +2857,7 @@ After setup, `conversations` routes to the plugin's claims, `surfaces` contains
2170
2857
  | `database` | A Bun `SQL` for `context.database()`; without one it throws `no database is configured` |
2171
2858
  | `providers` | Provider slots filled as if another plugin filled them |
2172
2859
  | `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()` |
2173
- | `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 |
2860
+ | `services` | What the plugin reads from `context.services`: one `servicePair(KEY, { ... })` for each service, with the members you give it. `servicePair(RUNTIME, runtime)` is the runtime `context.turns` runs on, given whole; `servicePair(AGENTS, { runtime })` also works when no `RUNTIME` is given. 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 |
2174
2861
  | `conversations` | Methods that replace the router's, such as `stop`, for a plugin that calls them |
2175
2862
  | `turns` | A `ConversationTurns` that replaces the default one |
2176
2863
  | `apiKeys` | The credentials `context.apiKey(provider)` returns, by provider name: `{ "openai-codex": "key" }`. A provider not listed reads as having none, as on a host that is not logged in |
@@ -2200,7 +2887,12 @@ import { notes } from "./tools.ts";
2200
2887
 
2201
2888
  test("note_add saves a note for the speaker and refuses an empty one", async () => {
2202
2889
  const harness = await testPlugin(notes);
2203
- const ada: Speaker = { id: "1", name: "Ada", tier: "member" };
2890
+ const ada: Speaker = {
2891
+ id: "1",
2892
+ name: "Ada",
2893
+ tier: "member",
2894
+ principalId: "1",
2895
+ };
2204
2896
  expect(harness.tools).toEqual(["note_add"]);
2205
2897
  expect(harness.tiers.minTier("note_add")).toBe("member");
2206
2898
  expect(
@@ -2232,14 +2924,14 @@ Use `partial<Port>({ ... })` to stand in for a port your code takes as an argume
2232
2924
  It provides the members you give it and throws an error naming any missing member you read, so the test needs no `as unknown as Port` cast.
2233
2925
  `fakePrecheck(name, answer, { description?, timeoutMs? })` is a precheck that answers `answer` (a `PrecheckResult`, an `Error` it throws, or a function of its context) and records each context in `calls`; `fakePrechecks(...prechecks)` is a real in-memory `PrecheckRegistry` with them registered, which a test gives a plugin as `servicePair(PRECHECKS, registry)` and reads back with `registry.get(name)`.
2234
2926
  `fakeScriptRunner(answer, { describe?, timeoutMs? })` is a `PrecheckScriptRunner` that runs nothing and answers `answer` (a result, an `Error` it throws, or a function of the script and its context), recording each call in `calls`; give it to `registry.useScriptRunner`.
2235
- `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()`.
2927
+ `fakeDiscord({ ownerId?, rootCommand?, channelContext? })` is the `DISCORD` service for a plugin that adds slash commands or reads channel context: give it as `services: [discord.service]`, read what the plugin added with `discord.added()`, and compose the tree Discord would get with `discord.compose()`; `channelContext` answers `DISCORD.channelContext`, by default with no context.
2236
2928
  Only `commands` and `guard` are given; a plugin that reads another member of `DISCORD` in a test gives its own with `servicePair(DISCORD, { ... })`.
2237
2929
 
2238
2930
  ### `testHost`: the built-in plugins and yours, over PostgreSQL
2239
2931
 
2240
2932
  `testPlugin` sets up your plugin alone.
2241
2933
  For tests that need the agent server, modules, stores, or the host's session-tool setup order, use `testHost(options?)`.
2242
- It boots `defineRoundtable` over `ROUNDTABLE_TEST_DATABASE_URL`, with stand-ins for Discord and the runtime; gate the suite with `describeDb`:
2934
+ It boots `defineRoundtable` over `ROUNDTABLE_TEST_DATABASE_URL`, with stand-ins for Discord and the runtime, or without Discord; gate the suite with `describeDb`:
2243
2935
 
2244
2936
  | Option | What it gives the host |
2245
2937
  |---|---|
@@ -2247,7 +2939,7 @@ It boots `defineRoundtable` over `ROUNDTABLE_TEST_DATABASE_URL`, with stand-ins
2247
2939
  | `plugins` | Your plugins, placed after the built-in ones as `defineRoundtable` places them |
2248
2940
  | `runtime` | The runtime every turn runs on; by default one that answers `""` and builds no Pi session |
2249
2941
  | `apiKeys` | The credentials `context.apiKey(provider)` returns, by provider name; a provider not listed reads as having none, whatever login the machine holds |
2250
- | `discord` | What the stand-in Discord hands out: `agentChannels(guildId)` and `ownerChannel()` |
2942
+ | `discord` | What the stand-in Discord hands out: `agentChannels(guildId)` and `ownerChannel()`; `false` boots a [host without Discord](#a-host-without-discord), with no `discord`, `http`, or `agents` in the test configuration |
2251
2943
 
2252
2944
  It returns:
2253
2945
 
@@ -2266,6 +2958,15 @@ It returns:
2266
2958
 
2267
2959
  `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.
2268
2960
 
2961
+ ### `describeSurfaceContract`: what every chat surface keeps
2962
+
2963
+ A plugin that contributes a `ChatSurface` can test it against the contract every surface keeps.
2964
+ `describeSurfaceContract(name, make)` registers one test per check, each on a fresh `SurfaceContractSubject` from `make`: the `surface`, a `channel` of it, and one person on its network, who can `write(text)`, whose `observations()` list what they have seen (`SurfaceObservation`: a reply's text and file names, typing and stop controls going on and off, progress, and prompts opening and closing), and who may `answer(prompt, approved)` an approval; `speaker` is who they are to `surface.prompts`, given as `promptScope(speaker, visibility)`, an owner by default, and every approval the contract asks for needs exactly their tier. `stranger`, when given, is someone else on the network, another principal and not an owner, who knows the id of the person's open approval and may `join()` and `answer(prompt, approved)` it; `owner`, when given, is an owner on the network, another principal than the person, who does the same in the person's private conversation. Both are `SurfaceContractAnswerer`s.
2965
+ The contract starts the surface, calls the subject's `join()`, and stops the surface after the subject's `close()`.
2966
+ It checks that the surface names a prefix and owns its channel, delivers what the person writes, and shows every chunk of a reply; and, for the parts the surface offers, that it delivers files when `supportsFiles` is set, ends typing and stop controls idempotently, shows progress, and resolves an approval as the person approves or declines it, or `cancelled` when the turn stops, with a `stranger`, that the stranger's answer leaves the person's approval open, and with an `owner`, that an owner's answer leaves it open in a private conversation; and, for a speaker below the owner tier, that a private conversation's approval above their tier resolves `expired` at once and is not shown.
2967
+ A check for a part the surface does not offer passes.
2968
+ `checkSurfaceContract(make)` runs the same checks and returns the ones that failed, as `SurfaceContractFailure`s.
2969
+
2269
2970
  ## What happens when the bot starts and stops
2270
2971
 
2271
2972
  `roundtable start` checks Bun, `.env`, the configuration, the plugins, the model login, and the public URL without using the network.
@@ -2274,10 +2975,10 @@ The host's `run()` handles startup:
2274
2975
 
2275
2976
  1. Each plugin's `replaces` is applied: the plugin that provided a replaced service is dropped, and the replacement stands where it stood.
2276
2977
  Providers are then resolved: each slot from the plugin that fills it, or the core's default.
2277
- The agent server builds its runtime later, in its own setup: from the `runtime` slot when a plugin fills it, else the Pi runtime.
2978
+ The runtime plugin builds the runtime later, in its own setup: from the `runtime` slot when a plugin fills it, else the Pi runtime.
2278
2979
  2. The database is opened and every plugin's migrations run, in plugin order.
2279
2980
  3. Every plugin's `setup` runs, in plugin order.
2280
- 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.
2981
+ The order is the built-ins (`memory`, `schedule-store`, `prechecks`, `discord`, `modules`, `discord-admin`, `skills`, `conversations`, `runtime`, `agent-server`, `seeds`; an addon that is switched off is not there, and without Discord neither are `discord`, `discord-admin`, `skills`, `agent-server`, or `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.
2281
2982
  4. The contributions are linked: tool tiers, hold rules, the session plan, the channel router, and the events.
2282
2983
  From here `sessions()`, `conversations`, `surfaces`, `turns`, and `dashboard()` work.
2283
2984
  5. Every plugin's `preflight` runs, in plugin order.
@@ -2324,7 +3025,7 @@ These are the messages as the code writes them, with `<...>` where your names go
2324
3025
  | `plugin <name>: setup is missing. Give the function that returns what the plugin adds.` | Add `setup` |
2325
3026
  | `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` |
2326
3027
  | `plugin <name>: setup must return an object of the parts it adds; return {} to add none.` | Return an object, not `undefined` |
2327
- | `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 |
3028
+ | `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, directChannels, dashboard, tools, seeds, prompt, agentSelection, requiredTools.` | Fix the key; `migrations`, `providers`, and `preflight` belong on the plugin, not in what `setup` returns |
2328
3029
 
2329
3030
  ### Tools
2330
3031
 
@@ -2357,9 +3058,9 @@ These are the messages as the code writes them, with `<...>` where your names go
2357
3058
  | `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 |
2358
3059
  | `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 |
2359
3060
  | `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 |
2360
- | `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 |
3061
+ | `no runtime provider is configured: the runtime plugin builds the Pi runtime when no plugin fills the runtime slot; read the running one from services.get(RUNTIME)` | Only a plugin that calls `providers.runtime` without `providers.filled.has("runtime")` sees it; read `services.get(RUNTIME)` instead |
2361
3062
  | `session tool <name> takes a core extension name. Rename it.` | Pick a name other than the core's |
2362
- | `two plugins are named <name>.` (from `roundtable doctor`) | Rename yours; the built-in plugins are `memory`, `schedule-store`, `discord`, `modules`, `discord-admin`, `skills`, `agent-server`, `seeds`, and `schedules` |
3063
+ | `two plugins are named <name>.` (from `roundtable doctor`) | Rename yours; the built-in plugins are `memory`, `schedule-store`, `prechecks`, `discord`, `modules`, `discord-admin`, `skills`, `conversations`, `runtime`, `agent-server`, `seeds`, and `schedules` |
2363
3064
  | `migration <plugin>/<name> is declared twice` | A plugin declares two migrations with one name: rename one (the same name in two plugins is fine) |
2364
3065
  | `/<root> <name> is added twice`, `/<name> is registered twice` | Give each slash command and subcommand its own name |
2365
3066
  | `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 |
@@ -2405,6 +3106,9 @@ Migrations are idempotent, so start again once the cause is fixed.
2405
3106
  | `config model: expected <provider>/<id>, got "<value>". Write it like anthropic/claude-sonnet-5-5.` | Write the model as `<provider>/<id>` |
2406
3107
  | `config locale: expected a locale, en or zh-TW, got "<value>". Fix the value in roundtable.config.ts.` | Use `en` or `zh-TW` |
2407
3108
  | `config <key>: cannot read <path>: <reason>. Create the file or fix the path.` | Create the prompt file, or fix the path in `prompts` |
3109
+ | `config adapters[<n>]: unknown adapter "<name>". The adapters are discord; …` | Use `discord()` from `pi-roundtable/discord`; a chat network that comes as a plugin, such as `webChat()`, belongs in `plugins` |
3110
+ | `config adapters[<n>]: Discord is configured twice, …` | Keep the top-level `discord` or the Discord adapter, not both |
3111
+ | `required tools are not registered: compact_session. compact_session comes from the Pi package pi-self-compact: …` | `bun add pi-self-compact`, and load it from a plugin with `piPackages: ["pi-self-compact"]`, as a project `roundtable init` creates does in `plugins/self-compact.ts` |
2408
3112
 
2409
3113
  ### Missing settings
2410
3114
 
@@ -2484,10 +3188,18 @@ Import from the entries listed below; source area files are internal.
2484
3188
 
2485
3189
  | Name | Entry | Kind |
2486
3190
  |---|---|---|
3191
+ | `AccessConfig` | `pi-roundtable` | type |
3192
+ | `AccessOwner` | `pi-roundtable` | type |
3193
+ | `AccessOwnerConfig` | `pi-roundtable` | type |
3194
+ | `AccessRules` | `pi-roundtable` | type |
3195
+ | `AccessTier` | `pi-roundtable` | type |
3196
+ | `AccessTierConfig` | `pi-roundtable` | type |
3197
+ | `ActorFacts` | `pi-roundtable` | type |
2487
3198
  | `AGENTS` | `pi-roundtable` | value |
2488
3199
  | `AGENT_SERVER_PLUGIN` | `pi-roundtable` | value |
2489
3200
  | `AGENT_SERVER_PRIORITY` | `pi-roundtable` | value |
2490
3201
  | `AGENT_TEAM_SERVICE` | `pi-roundtable` | value |
3202
+ | `AdapterConfig` | `pi-roundtable` | type |
2491
3203
  | `Admission` | `pi-roundtable` | type |
2492
3204
  | `Agent` | `pi-roundtable` | type |
2493
3205
  | `AgentChange` | `pi-roundtable` | type |
@@ -2508,6 +3220,9 @@ Import from the entries listed below; source area files are internal.
2508
3220
  | `AvatarMode` | `pi-roundtable` | type |
2509
3221
  | `AvatarStudio` | `pi-roundtable` | type |
2510
3222
  | `BACKGROUND_TURNS` | `pi-roundtable` | value |
3223
+ | `CONVERSATIONS` | `pi-roundtable` | value |
3224
+ | `BackgroundConfig` | `pi-roundtable` | type |
3225
+ | `BackgroundRunsAs` | `pi-roundtable` | type |
2511
3226
  | `BackgroundTarget` | `pi-roundtable` | type |
2512
3227
  | `BackgroundTurn` | `pi-roundtable` | type |
2513
3228
  | `BackgroundTurns` | `pi-roundtable` | type |
@@ -2519,16 +3234,24 @@ Import from the entries listed below; source area files are internal.
2519
3234
  | `Contribution` | `pi-roundtable` | type |
2520
3235
  | `ConversationKind` | `pi-roundtable` | type |
2521
3236
  | `ConversationPort` | `pi-roundtable` | type |
3237
+ | `ConversationRecord` | `pi-roundtable` | type |
3238
+ | `ConversationRegistration` | `pi-roundtable` | type |
3239
+ | `ConversationRegistry` | `pi-roundtable` | type |
3240
+ | `ConversationVisibility` | `pi-roundtable` | type |
2522
3241
  | `ConversationTurnInput` | `pi-roundtable` | type |
2523
3242
  | `ConversationTurns` | `pi-roundtable` | type |
2524
3243
  | `DELEGATION` | `pi-roundtable` | value |
2525
3244
  | `DefineOverrides` | `pi-roundtable` | type |
3245
+ | `DirectChannelProvider` | `pi-roundtable` | type |
3246
+ | `DirectChannels` | `pi-roundtable` | type |
3247
+ | `DirectReach` | `pi-roundtable` | type |
2526
3248
  | `DefinedRoundtable` | `pi-roundtable` | type |
2527
3249
  | `DelegationError` | `pi-roundtable` | value |
2528
3250
  | `DelegationJob` | `pi-roundtable` | type |
2529
3251
  | `DelegationOutcome` | `pi-roundtable` | type |
2530
3252
  | `DelegationRequest` | `pi-roundtable` | type |
2531
3253
  | `Delegator` | `pi-roundtable` | type |
3254
+ | `DiscordConfig` | `pi-roundtable` | type |
2532
3255
  | `DrainOptions` | `pi-roundtable` | type |
2533
3256
  | `EventHandlers` | `pi-roundtable` | type |
2534
3257
  | `EventSink` | `pi-roundtable` | type |
@@ -2541,6 +3264,11 @@ Import from the entries listed below; source area files are internal.
2541
3264
  | `HostEnv` | `pi-roundtable` | type |
2542
3265
  | `HostEnvironment` | `pi-roundtable` | type |
2543
3266
  | `HttpRoute` | `pi-roundtable` | type |
3267
+ | `IDENTITY` | `pi-roundtable` | value |
3268
+ | `IdentityError` | `pi-roundtable` | value |
3269
+ | `IdentityLink` | `pi-roundtable` | type |
3270
+ | `IdentityRef` | `pi-roundtable` | type |
3271
+ | `IdentityService` | `pi-roundtable` | type |
2544
3272
  | `ImageDrawer` | `pi-roundtable` | type |
2545
3273
  | `InboundMessage` | `pi-roundtable` | type |
2546
3274
  | `InterimMessage` | `pi-roundtable` | type |
@@ -2550,6 +3278,7 @@ Import from the entries listed below; source area files are internal.
2550
3278
  | `JudgeError` | `pi-roundtable` | value |
2551
3279
  | `JudgeModel` | `pi-roundtable` | type |
2552
3280
  | `LinkedSessions` | `pi-roundtable` | type |
3281
+ | `LinkSource` | `pi-roundtable` | type |
2553
3282
  | `ListenerAddress` | `pi-roundtable` | type |
2554
3283
  | `ListenerConfig` | `pi-roundtable` | type |
2555
3284
  | `LoadedSkill` | `pi-roundtable` | type |
@@ -2571,17 +3300,23 @@ Import from the entries listed below; source area files are internal.
2571
3300
  | `NewSchedule` | `pi-roundtable` | type |
2572
3301
  | `NotLinkedError` | `pi-roundtable` | value |
2573
3302
  | `OWNER_TARGET` | `pi-roundtable` | value |
3303
+ | `PERSONAL_TARGET` | `pi-roundtable` | value |
2574
3304
  | `OutboundReply` | `pi-roundtable` | type |
2575
3305
  | `OwnerAnswer` | `pi-roundtable` | type |
2576
3306
  | `OwnerPrompts` | `pi-roundtable` | type |
2577
3307
  | `OwnerQuestion` | `pi-roundtable` | type |
2578
3308
  | `PendingConfirmation` | `pi-roundtable` | type |
3309
+ | `PerPrincipalLimits` | `pi-roundtable` | type |
2579
3310
  | `Persona` | `pi-roundtable` | type |
2580
3311
  | `PluginContext` | `pi-roundtable` | type |
3312
+ | `PluginIdentity` | `pi-roundtable` | type |
2581
3313
  | `PluginError` | `pi-roundtable` | value |
3314
+ | `Principal` | `pi-roundtable` | type |
2582
3315
  | `PromptMemory` | `pi-roundtable` | type |
3316
+ | `PromptScope` | `pi-roundtable` | type |
2583
3317
  | `PromptSection` | `pi-roundtable` | type |
2584
3318
  | `PromptTurn` | `pi-roundtable` | type |
3319
+ | `Prompts` | `pi-roundtable` | type |
2585
3320
  | `Pronouns` | `pi-roundtable` | type |
2586
3321
  | `ProviderError` | `pi-roundtable` | value |
2587
3322
  | `Providers` | `pi-roundtable` | type |
@@ -2590,6 +3325,8 @@ Import from the entries listed below; source area files are internal.
2590
3325
  | `ReferenceImage` | `pi-roundtable` | type |
2591
3326
  | `ResolvedProviders` | `pi-roundtable` | type |
2592
3327
  | `ResolvedSkill` | `pi-roundtable` | type |
3328
+ | `RoleGrant` | `pi-roundtable` | type |
3329
+ | `RoleSource` | `pi-roundtable` | type |
2593
3330
  | `Roundtable` | `pi-roundtable` | value |
2594
3331
  | `ReplyFile` | `pi-roundtable` | type |
2595
3332
  | `ReplyFileError` | `pi-roundtable` | value |
@@ -2597,10 +3334,12 @@ Import from the entries listed below; source area files are internal.
2597
3334
  | `RoundtableConfig` | `pi-roundtable` | type |
2598
3335
  | `RoundtableOptions` | `pi-roundtable` | type |
2599
3336
  | `RoundtablePlugin` | `pi-roundtable` | type |
3337
+ | `RouteSocket` | `pi-roundtable` | type |
2600
3338
  | `RuntimeDeps` | `pi-roundtable` | type |
2601
3339
  | `RuntimeFactory` | `pi-roundtable` | type |
2602
3340
  | `SCHEDULES` | `pi-roundtable` | value |
2603
3341
  | `PRECHECKS` | `pi-roundtable` | value |
3342
+ | `RUNTIME` | `pi-roundtable` | value |
2604
3343
  | `PRECHECK_TIMEOUT_MS` | `pi-roundtable` | value |
2605
3344
  | `Precheck` | `pi-roundtable` | type |
2606
3345
  | `PrecheckContext` | `pi-roundtable` | type |
@@ -2624,6 +3363,7 @@ Import from the entries listed below; source area files are internal.
2624
3363
  | `ServiceStartedEvent` | `pi-roundtable` | type |
2625
3364
  | `Services` | `pi-roundtable` | type |
2626
3365
  | `SessionContext` | `pi-roundtable` | type |
3366
+ | `SessionConversation` | `pi-roundtable` | type |
2627
3367
  | `SessionPlan` | `pi-roundtable` | type |
2628
3368
  | `SessionTool` | `pi-roundtable` | type |
2629
3369
  | `SessionToolSnapshot` | `pi-roundtable` | type |
@@ -2635,6 +3375,7 @@ Import from the entries listed below; source area files are internal.
2635
3375
  | `SpeakerMemory` | `pi-roundtable` | type |
2636
3376
  | `StoredAttachment` | `pi-roundtable` | type |
2637
3377
  | `SurfacePort` | `pi-roundtable` | type |
3378
+ | `SYSTEM_PRINCIPAL` | `pi-roundtable` | value |
2638
3379
  | `THE_SPEAKER` | `pi-roundtable` | value |
2639
3380
  | `TIERS` | `pi-roundtable` | value |
2640
3381
  | `TeamAgentStatus` | `pi-roundtable` | type |
@@ -2653,13 +3394,20 @@ Import from the entries listed below; source area files are internal.
2653
3394
  | `TranscriptEntry` | `pi-roundtable` | type |
2654
3395
  | `TransientTask` | `pi-roundtable` | type |
2655
3396
  | `TurnAttachments` | `pi-roundtable` | type |
3397
+ | `TurnConversation` | `pi-roundtable` | type |
2656
3398
  | `TurnEndEvent` | `pi-roundtable` | type |
3399
+ | `TurnProgress` | `pi-roundtable` | type |
3400
+ | `TurnProgressEvent` | `pi-roundtable` | type |
2657
3401
  | `TurnEvent` | `pi-roundtable` | type |
2658
3402
  | `TurnRequest` | `pi-roundtable` | type |
2659
3403
  | `TurnResult` | `pi-roundtable` | type |
2660
3404
  | `TurnSelection` | `pi-roundtable` | type |
3405
+ | `WebSocketAccept` | `pi-roundtable` | type |
3406
+ | `WebSocketRoute` | `pi-roundtable` | type |
3407
+ | `WebSocketSendResult` | `pi-roundtable` | type |
2661
3408
  | `Weekday` | `pi-roundtable` | type |
2662
3409
  | `attachReplyFile` | `pi-roundtable` | value |
3410
+ | `promptScope` | `pi-roundtable` | value |
2663
3411
  | `withReplyFiles` | `pi-roundtable` | value |
2664
3412
  | `prepareImageBytes` | `pi-roundtable/kit` | value |
2665
3413
  | `ImagePreparationError` | `pi-roundtable/kit` | value |
@@ -2676,6 +3424,7 @@ Import from the entries listed below; source area files are internal.
2676
3424
  | `OWNER_SPEAKER` | `pi-roundtable/testing` | value |
2677
3425
  | `RecordedEvent` | `pi-roundtable/testing` | type |
2678
3426
  | `ServicePair` | `pi-roundtable/testing` | type |
3427
+ | `SurfaceContractAnswerer` | `pi-roundtable/testing` | type |
2679
3428
  | `TEST_GUILD` | `pi-roundtable/testing` | value |
2680
3429
  | `TestHost` | `pi-roundtable/testing` | type |
2681
3430
  | `TestHostOptions` | `pi-roundtable/testing` | type |
@@ -2684,6 +3433,11 @@ Import from the entries listed below; source area files are internal.
2684
3433
  | `TestPluginResult` | `pi-roundtable/testing` | type |
2685
3434
  | `TestStore` | `pi-roundtable/testing` | type |
2686
3435
  | `describeDb` | `pi-roundtable/testing` | value |
3436
+ | `describeSurfaceContract` | `pi-roundtable/testing` | value |
3437
+ | `checkSurfaceContract` | `pi-roundtable/testing` | value |
3438
+ | `SurfaceContractFailure` | `pi-roundtable/testing` | type |
3439
+ | `SurfaceContractSubject` | `pi-roundtable/testing` | type |
3440
+ | `SurfaceObservation` | `pi-roundtable/testing` | type |
2687
3441
  | `eagerText` | `pi-roundtable/testing` | value |
2688
3442
  | `fakeDiscord` | `pi-roundtable/testing` | value |
2689
3443
  | `fakePrecheck` | `pi-roundtable/testing` | value |
@@ -2738,6 +3492,7 @@ Import from the entries listed below; source area files are internal.
2738
3492
  | `McpEndpoint` | `pi-roundtable/kit` | type |
2739
3493
  | `ModelRef` | `pi-roundtable/kit` | type |
2740
3494
  | `OwnerIdentity` | `pi-roundtable/kit` | type |
3495
+ | `Notifier` | `pi-roundtable/kit` | type |
2741
3496
  | `OwnerNotifier` | `pi-roundtable/kit` | type |
2742
3497
  | `PreviousTurn` | `pi-roundtable/kit` | type |
2743
3498
  | `PromptSlot` | `pi-roundtable/kit` | type |
@@ -2780,6 +3535,15 @@ Import from the entries listed below; source area files are internal.
2780
3535
  | `compactionEngine` | `pi-roundtable/kit` | value |
2781
3536
  | `HARD_COMPACT_TOKENS` | `pi-roundtable/kit` | value |
2782
3537
  | `SOFT_COMPACT_TOKENS` | `pi-roundtable/kit` | value |
3538
+ | `summaryProjection` | `pi-roundtable/kit` | value |
3539
+ | `privateCompaction` | `pi-roundtable/kit` | value |
3540
+ | `memoryProjection` | `pi-roundtable/kit` | value |
3541
+ | `hidesPrivateExchange` | `pi-roundtable/kit` | value |
3542
+ | `MemoryView` | `pi-roundtable/kit` | type |
3543
+ | `MEMORY_TURN_ENTRY` | `pi-roundtable/kit` | value |
3544
+ | `recordMemoryTurn` | `pi-roundtable/kit` | value |
3545
+ | `carriesMemory` | `pi-roundtable/kit` | value |
3546
+ | `bridgeHistoryHidesMemory` | `pi-roundtable/kit` | value |
2783
3547
  | `CompactionEngine` | `pi-roundtable/kit` | type |
2784
3548
  | `CompactionHistory` | `pi-roundtable/kit` | type |
2785
3549
  | `LatestCompaction` | `pi-roundtable/kit` | type |
@@ -2830,8 +3594,12 @@ Import from the entries listed below; source area files are internal.
2830
3594
  | `AgentPanel` | `pi-roundtable/discord` | type |
2831
3595
  | `AgentPanelMessage` | `pi-roundtable/discord` | type |
2832
3596
  | `AgentPanelOptions` | `pi-roundtable/discord` | type |
3597
+ | `CHANNEL_CONTEXT_DEFAULTS` | `pi-roundtable/discord` | value |
2833
3598
  | `CHANNEL_OPERATIONS` | `pi-roundtable/discord` | value |
2834
3599
  | `CHANNEL_TOOLS` | `pi-roundtable/discord` | value |
3600
+ | `ChannelContext` | `pi-roundtable/discord` | type |
3601
+ | `ChannelContextMessage` | `pi-roundtable/discord` | type |
3602
+ | `ChannelContextOptions` | `pi-roundtable/discord` | type |
2835
3603
  | `ChannelExecutor` | `pi-roundtable/discord` | type |
2836
3604
  | `ChannelInfo` | `pi-roundtable/discord` | type |
2837
3605
  | `ChannelOperation` | `pi-roundtable/discord` | type |
@@ -2844,6 +3612,8 @@ Import from the entries listed below; source area files are internal.
2844
3612
  | `ComposedCommands` | `pi-roundtable/discord` | type |
2845
3613
  | `DISCORD` | `pi-roundtable/discord` | value |
2846
3614
  | `DISCORD_ADMIN_TOOLS` | `pi-roundtable/discord` | value |
3615
+ | `DiscordActor` | `pi-roundtable/discord` | type |
3616
+ | `DiscordAdapterConfig` | `pi-roundtable/discord` | type |
2847
3617
  | `DiscordConnection` | `pi-roundtable/discord` | type |
2848
3618
  | `DiscordServices` | `pi-roundtable/discord` | type |
2849
3619
  | `InteractionContribution` | `pi-roundtable/discord` | type |
@@ -2858,6 +3628,7 @@ Import from the entries listed below; source area files are internal.
2858
3628
  | `agentPanel` | `pi-roundtable/discord` | value |
2859
3629
  | `commandGuard` | `pi-roundtable/discord` | value |
2860
3630
  | `composeCommands` | `pi-roundtable/discord` | value |
3631
+ | `discord` | `pi-roundtable/discord` | value |
2861
3632
  | `ephemeralPanel` | `pi-roundtable/discord` | value |
2862
3633
  | `fetchManagedChannel` | `pi-roundtable/discord` | value |
2863
3634
  | `groupOption` | `pi-roundtable/discord` | value |
@@ -2870,3 +3641,4 @@ Import from the entries listed below; source area files are internal.
2870
3641
  | `parseChannelTool` | `pi-roundtable/discord` | value |
2871
3642
  | `plain` | `pi-roundtable/discord` | value |
2872
3643
  | `replyWithPanels` | `pi-roundtable/discord` | value |
3644
+ | `withChannelContext` | `pi-roundtable/discord` | value |