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
@@ -13,17 +13,34 @@ import {
13
13
  precheckPlugin,
14
14
  scheduleStorePlugin,
15
15
  } from "./builtin/stores.ts";
16
- import { type RoundtableConfig, resolveConfig } from "./config/config.ts";
16
+ import { warnDeprecations } from "./config/access.ts";
17
+ import {
18
+ type ResolvedConfig,
19
+ type RoundtableConfig,
20
+ resolveConfig,
21
+ } from "./config/config.ts";
22
+ import { conversationsPlugin } from "./conversations/conversations-plugin.ts";
17
23
  import { ConfigError } from "./domain/errors.ts";
18
24
  import { JudgeError } from "./errors.ts";
19
25
  import type { RoundtableOptions } from "./host.ts";
20
26
  import type { ListenerConfig } from "./http/listeners.ts";
27
+ import { identityPlugin } from "./identity/identity-plugin.ts";
21
28
  import { type JudgeModel, piJudgeModel } from "./judging/model-judge.ts";
22
29
  import { createLogger, type LogEntry, type Logger } from "./log.ts";
23
30
  import { formatModelRef, type ModelRef } from "./models.ts";
24
31
  import { ErrorReporter } from "./ops/error-reporter.ts";
25
32
  import type { RoundtablePlugin } from "./plugin.ts";
26
- import { speakerPolicy } from "./speakers.ts";
33
+ import {
34
+ BRIDGE_MEMORY_FIX,
35
+ bridgeMemoryProblem,
36
+ configuredCrowd,
37
+ onClaudeBridge,
38
+ } from "./runtime/bridge-guard.ts";
39
+ import {
40
+ type AgentSessionsSlot,
41
+ agentSessionsSlot,
42
+ runtimePlugin,
43
+ } from "./runtime/runtime-plugin.ts";
27
44
  import { toolTiers } from "./tool-tiers.ts";
28
45
 
29
46
  /** What `defineRoundtable` returns: give both to `new Roundtable(options, plugins)`. */
@@ -86,7 +103,7 @@ function judgeThrough(modelRuntime: ModelRuntime, ref: ModelRef): JudgeModel {
86
103
 
87
104
  /** The listener `config.http` names: its unix socket when it has one, else its TCP port. */
88
105
  function publicListener(
89
- http: ReturnType<typeof resolveConfig>["http"],
106
+ http: NonNullable<ResolvedConfig["http"]>,
90
107
  ): ListenerConfig {
91
108
  if (http.socketPath)
92
109
  return {
@@ -101,11 +118,107 @@ function publicListener(
101
118
  };
102
119
  }
103
120
 
121
+ /**
122
+ * The owner the Discord parts act for: the primary owner, by the Discord user id among their
123
+ * identities, until those parts follow each turn's principal.
124
+ */
125
+ export function discordOwnerOf(
126
+ config: ResolvedConfig,
127
+ ): ResolvedConfig["owner"] {
128
+ return {
129
+ ...config.owner,
130
+ id: config.primaryOwner.discordId ?? config.owner.id,
131
+ };
132
+ }
133
+
134
+ /** The Discord bot and the agent server that lives there, each in its place in the plugin list. */
135
+ interface DiscordAssembly {
136
+ discord: RoundtablePlugin;
137
+ admin: RoundtablePlugin[];
138
+ skills: RoundtablePlugin[];
139
+ agentServer: RoundtablePlugin;
140
+ seeds: RoundtablePlugin;
141
+ }
142
+
143
+ function discordAssembly(
144
+ config: ResolvedConfig,
145
+ discord: NonNullable<ResolvedConfig["discord"]>,
146
+ shared: {
147
+ modelRuntime: ModelRuntime;
148
+ agentSessions: AgentSessionsSlot;
149
+ errorReporter: ErrorReporter | undefined;
150
+ },
151
+ ): DiscordAssembly {
152
+ const { name } = config;
153
+ const owner = discordOwnerOf(config);
154
+ const sharedPrompt = config.prompts.shared
155
+ ? readPrompt(config.prompts.shared, "prompts.shared")
156
+ : readPrompt(join(ASSETS, "prompts", "shared.md"), "prompts.shared");
157
+ const guest = config.prompts.guest
158
+ ? readPrompt(config.prompts.guest, "prompts.guest")
159
+ : readPrompt(join(ASSETS, "prompts", "shared-guest.md"), "prompts.guest");
160
+ const { errorReporter } = shared;
161
+ return {
162
+ discord: discordPlugin({
163
+ token: discord.token,
164
+ ownerId: owner.id,
165
+ ownerName: owner.name,
166
+ rootCommand: discord.rootCommand,
167
+ dataDir: config.dataDir,
168
+ ...(discord.refusalHint === undefined
169
+ ? {}
170
+ : { refusalHint: discord.refusalHint }),
171
+ channelContext: discord.channelContext,
172
+ ...(discord.freshMarker === undefined
173
+ ? {}
174
+ : { freshMarker: discord.freshMarker }),
175
+ }),
176
+ admin: discord.admin ? [discordAdminPlugin({ owner })] : [],
177
+ skills: config.skills
178
+ ? [
179
+ skillsPlugin({
180
+ guildId: discord.guild,
181
+ reposDir: config.skills.reposDir ?? join(config.dataDir, "repos"),
182
+ writtenDir: join(config.dataDir, "skills"),
183
+ builtinDir: config.skills.builtinDir ?? join(ASSETS, "skills"),
184
+ }),
185
+ ]
186
+ : [],
187
+ agentServer: agentServerPlugin({
188
+ guildId: discord.guild,
189
+ entryChannelId: discord.entryChannel,
190
+ owner,
191
+ assistant: name,
192
+ modelRuntime: shared.modelRuntime,
193
+ dataDir: config.dataDir,
194
+ model: config.model,
195
+ judgeThreshold: config.judge.threshold,
196
+ agents: shared.agentSessions,
197
+ workDir: config.workDir,
198
+ scratchDir: config.scratchDir,
199
+ shellUser: userInfo().username,
200
+ prompts: { shared: sharedPrompt, guest },
201
+ ...(discord.agentMemory ? { memory: discord.agentMemory } : {}),
202
+ avatarListener: "public",
203
+ // resolveConfig refuses Discord without a public address.
204
+ avatarUrl: config.http?.publicUrl ?? "",
205
+ avatarReference: config.avatar ?? join(ASSETS, "neutral.png"),
206
+ // An ops agent's reports are the agent server's to deliver.
207
+ ...(errorReporter && "agent" in errorReporter.destination
208
+ ? { errorReporter }
209
+ : {}),
210
+ }),
211
+ seeds: seedsPlugin(config.agents),
212
+ };
213
+ }
214
+
104
215
  /**
105
216
  * The host's options and the plugin list of a configured bot: the built-in plugins in their
106
217
  * fixed order (each service one provides is read by the ones after it), then the operator's, then the scheduler, so a due schedule fires only once
107
218
  * everything it reaches runs. The memory, Discord administration, and skills addons are left out
108
- * when the configuration switches them off. Configuration mistakes stop here, naming the key and the fix.
219
+ * when the configuration switches them off. Without `discord` the Discord plugins, the skills,
220
+ * the agent server and its seeds are left out, and the runtime still runs every turn of
221
+ * `context.turns`. Configuration mistakes stop here, naming the key and the fix.
109
222
  */
110
223
  export async function defineRoundtable(
111
224
  input: RoundtableConfig,
@@ -113,6 +226,7 @@ export async function defineRoundtable(
113
226
  ): Promise<DefinedRoundtable> {
114
227
  const config = resolveConfig(input);
115
228
  const { name, owner, discord } = config;
229
+ const crowd = configuredCrowd(config.access, config.plugins);
116
230
  // Nothing here touches the process: the host applies the environment when it runs.
117
231
  const modelRuntime =
118
232
  overrides.modelRuntime ??
@@ -122,13 +236,13 @@ export async function defineRoundtable(
122
236
  }));
123
237
  const registry = new ModelRegistry(modelRuntime);
124
238
  const errorReporter = config.ops
125
- ? new ErrorReporter({ opsAgent: config.ops.agent, app: name })
239
+ ? new ErrorReporter({ destination: config.ops, app: name })
126
240
  : undefined;
127
241
  const { errorSink } = overrides;
128
- const logger =
242
+ const logger: Logger =
129
243
  overrides.logger ??
130
244
  createLogger(
131
- discord.rootCommand,
245
+ config.slug,
132
246
  errorReporter || errorSink
133
247
  ? (entry) => {
134
248
  errorReporter?.record(entry);
@@ -136,17 +250,21 @@ export async function defineRoundtable(
136
250
  }
137
251
  : undefined,
138
252
  );
139
- const speakers = speakerPolicy({
140
- owners: [owner.id],
141
- ...(config.speakers.admins ? { admins: config.speakers.admins } : {}),
142
- ...(config.speakers.members ? { members: config.speakers.members } : {}),
143
- });
144
- const shared = config.prompts.shared
145
- ? readPrompt(config.prompts.shared, "prompts.shared")
146
- : readPrompt(join(ASSETS, "prompts", "shared.md"), "prompts.shared");
147
- const guest = config.prompts.guest
148
- ? readPrompt(config.prompts.guest, "prompts.guest")
149
- : readPrompt(join(ASSETS, "prompts", "shared-guest.md"), "prompts.guest");
253
+ if (config.memory && crowd && onClaudeBridge(config.model))
254
+ logger.warn(
255
+ {},
256
+ `${bridgeMemoryProblem(formatModelRef(config.model), crowd)} ${BRIDGE_MEMORY_FIX}`,
257
+ );
258
+ warnDeprecations(logger, config.deprecations);
259
+ // The agent server hands the runtime its per-agent settings once it sets up.
260
+ const agentSessions = agentSessionsSlot();
261
+ const assembly = discord
262
+ ? discordAssembly(config, discord, {
263
+ modelRuntime,
264
+ agentSessions,
265
+ errorReporter,
266
+ })
267
+ : undefined;
150
268
  return {
151
269
  options: {
152
270
  logger,
@@ -154,31 +272,25 @@ export async function defineRoundtable(
154
272
  locale: config.locale,
155
273
  timeZone: config.timeZone,
156
274
  assistant: name,
157
- rootCommand: discord.rootCommand,
275
+ rootCommand: config.slug,
158
276
  agentDir: config.agentDir,
159
277
  },
160
278
  database: { url: config.databaseUrl },
161
- toolTiers: toolTiers(config.toolTiers),
162
- listeners: [publicListener(config.http), ...(overrides.listeners ?? [])],
279
+ toolTiers: toolTiers(config.toolTiers, logger),
280
+ listeners: [
281
+ ...(config.http ? [publicListener(config.http)] : []),
282
+ ...(overrides.listeners ?? []),
283
+ ],
163
284
  judgeModel: judgeThrough(modelRuntime, config.judge.model),
164
285
  apiKey: (provider) => registry.getApiKeyForProvider(provider),
165
286
  ...(overrides.aborted ? { aborted: overrides.aborted } : {}),
166
287
  },
167
288
  plugins: [
289
+ identityPlugin({ rules: config.access, plugins: config.plugins }),
168
290
  ...(config.memory ? [memoryPlugin({ owner })] : []),
169
291
  scheduleStorePlugin(),
170
292
  precheckPlugin(),
171
- discordPlugin({
172
- token: discord.token,
173
- ownerId: owner.id,
174
- ownerName: owner.name,
175
- speakers,
176
- rootCommand: discord.rootCommand,
177
- dataDir: config.dataDir,
178
- ...(discord.refusalHint === undefined
179
- ? {}
180
- : { refusalHint: discord.refusalHint }),
181
- }),
293
+ ...(assembly ? [assembly.discord] : []),
182
294
  modulesPlugin({
183
295
  owner,
184
296
  assistant: name,
@@ -186,42 +298,30 @@ export async function defineRoundtable(
186
298
  agentDir: config.agentDir,
187
299
  dataDir: config.dataDir,
188
300
  delegation: config.delegation,
301
+ perPrincipal: config.background.perPrincipal,
302
+ // A conversation's reports are the modules' to deliver.
303
+ ...(errorReporter && "conversation" in errorReporter.destination
304
+ ? { errorReporter }
305
+ : {}),
189
306
  }),
190
- ...(discord.admin ? [discordAdminPlugin({ owner })] : []),
191
- ...(config.skills
192
- ? [
193
- skillsPlugin({
194
- guildId: discord.guild,
195
- reposDir: config.skills.reposDir ?? join(config.dataDir, "repos"),
196
- writtenDir: join(config.dataDir, "skills"),
197
- builtinDir: config.skills.builtinDir ?? join(ASSETS, "skills"),
198
- }),
199
- ]
200
- : []),
201
- agentServerPlugin({
202
- guildId: discord.guild,
203
- entryChannelId: discord.entryChannel,
307
+ ...(assembly?.admin ?? []),
308
+ ...(assembly?.skills ?? []),
309
+ conversationsPlugin(),
310
+ runtimePlugin({
204
311
  owner,
205
- assistant: name,
206
- speakers,
207
312
  modelRuntime,
208
313
  agentDir: config.agentDir,
209
314
  dataDir: config.dataDir,
210
315
  model: config.model,
211
316
  thinking: config.thinking,
212
317
  judgeThreshold: config.judge.threshold,
213
- workDir: config.workDir,
214
- scratchDir: config.scratchDir,
215
- shellUser: userInfo().username,
216
- prompts: { shared, guest },
217
- avatarListener: "public",
218
- avatarUrl: config.http.publicUrl,
219
- avatarReference: config.avatar ?? join(ASSETS, "neutral.png"),
318
+ agents: agentSessions,
220
319
  interimText: config.interimText,
221
320
  interimPrimaryChars: config.interimPrimaryChars,
222
- ...(errorReporter ? { errorReporter } : {}),
321
+ memory: config.memory,
322
+ ...(crowd ? { crowd } : {}),
223
323
  }),
224
- seedsPlugin(config.agents),
324
+ ...(assembly ? [assembly.agentServer, assembly.seeds] : []),
225
325
  ...config.plugins,
226
326
  schedulerPlugin(),
227
327
  ],
@@ -47,7 +47,7 @@ export function agentPanel(options: AgentPanelOptions): AgentPanel {
47
47
  interaction.isButton() &&
48
48
  interaction.customId.startsWith(AGENT_BUTTON_PREFIX)
49
49
  ) {
50
- if (!guard.isOwner(interaction)) return true;
50
+ if (!(await guard.allows(interaction))) return true;
51
51
  await guard.run(interaction, () => commands.button(interaction));
52
52
  return true;
53
53
  }
@@ -55,7 +55,7 @@ export function agentPanel(options: AgentPanelOptions): AgentPanel {
55
55
  interaction.isModalSubmit() &&
56
56
  interaction.customId.startsWith(AGENT_MODAL_PREFIX)
57
57
  ) {
58
- if (!guard.isOwner(interaction)) return true;
58
+ if (!(await guard.allows(interaction))) return true;
59
59
  await interaction.deferReply({ flags: MessageFlags.Ephemeral });
60
60
  await guard.run(interaction, () => commands.submit(interaction));
61
61
  return true;
@@ -0,0 +1,148 @@
1
+ import { messages } from "../i18n/index.ts";
2
+ import type { PromptScope } from "../interactions/prompts.ts";
3
+ import { type Tier, tierAtLeast } from "../speakers.ts";
4
+ import type {
5
+ DiscordOwners,
6
+ DiscordOwnersOptions,
7
+ DiscordUser,
8
+ } from "./discord-owners.ts";
9
+
10
+ /** The people a card is for. */
11
+ export interface Audience {
12
+ /** Whether this user may answer, as they are when they press. */
13
+ allows(user: DiscordUser): Promise<boolean>;
14
+ /** The users the card mentions in a thread, in order. */
15
+ mentions: readonly string[];
16
+ /** Shown on the card; empty for none. */
17
+ note: string;
18
+ /** What someone else who presses it is told. */
19
+ refusal: string;
20
+ }
21
+
22
+ /** The primary owner, who answers every card the owners may, and who else holds a tier. */
23
+ export type CardAudienceOptions = DiscordOwnersOptions;
24
+
25
+ /**
26
+ * Whom a card is for, by the prompt scope of the turn that asks. The speaker's principal answers
27
+ * their own card on any of their Discord identities that is still theirs when they press when,
28
+ * for an approval, they hold its tier, checked again then, and, for an owner-tier one, they are
29
+ * an owner; the card mentions the identity that spoke where it is one of those, or else all of
30
+ * them. Where the scope
31
+ * escalates to the owners, every owner with a Discord identity answers it too, and a card the
32
+ * speaker cannot answer, or whose principal has no Discord identity, is theirs alone; where it
33
+ * escalates to no one, that card goes to no one. Without a scope, a card is the owners'.
34
+ */
35
+ export class CardAudiences {
36
+ readonly #options: CardAudienceOptions;
37
+ readonly #owners: DiscordOwners;
38
+
39
+ constructor(options: CardAudienceOptions, owners: DiscordOwners) {
40
+ this.#options = options;
41
+ this.#owners = owners;
42
+ }
43
+
44
+ /** Who may approve a call held at `minTier`; undefined when no one may. */
45
+ approval(
46
+ scope: PromptScope | undefined,
47
+ minTier: Tier,
48
+ ): Promise<Audience | undefined> {
49
+ return this.#audience(scope, minTier, {
50
+ note: messages().cardApproversNote,
51
+ refusal: messages().cardApproversRefusal,
52
+ });
53
+ }
54
+
55
+ /** Who may answer a question; undefined when no one may. */
56
+ question(scope: PromptScope | undefined): Promise<Audience | undefined> {
57
+ return this.#audience(scope, undefined, {
58
+ note: messages().cardAskerNote,
59
+ refusal: messages().cardAskerRefusal,
60
+ });
61
+ }
62
+
63
+ async #audience(
64
+ scope: PromptScope | undefined,
65
+ minTier: Tier | undefined,
66
+ words: { note(...userIds: string[]): string; refusal: string },
67
+ ): Promise<Audience | undefined> {
68
+ const { ownerId } = this.#options;
69
+ const escalates = scope?.escalate !== "none";
70
+ const own = scope && (await this.#ownIdentities(scope, minTier));
71
+ if (scope && own) {
72
+ const primary = own.includes(ownerId);
73
+ const mentions = own.includes(scope.speakerId) ? [scope.speakerId] : own;
74
+ return {
75
+ allows: async (user) => {
76
+ // Read the links again: one moved or unlinked since the card was posted is no longer theirs.
77
+ if (
78
+ own.includes(user.id) &&
79
+ (await this.#stillOwn(scope.principalId, user.id))
80
+ )
81
+ return (
82
+ (primary && user.id === ownerId) ||
83
+ this.#owners.holds(user, minTier, scope.principalId)
84
+ );
85
+ return escalates && this.#owners.isOwner(user);
86
+ },
87
+ mentions,
88
+ note: primary ? "" : words.note(...mentions),
89
+ refusal: primary ? messages().cardOwnerOnly : words.refusal,
90
+ };
91
+ }
92
+ if (!escalates) return undefined;
93
+ return {
94
+ allows: (user) => this.#owners.isOwner(user),
95
+ mentions: await this.#owners.ids(),
96
+ note: "",
97
+ refusal: messages().cardOwnerOnly,
98
+ };
99
+ }
100
+
101
+ /** Whether the Discord user is still one of the principal's identities; false when that cannot be read. */
102
+ async #stillOwn(principalId: string, userId: string): Promise<boolean> {
103
+ return (
104
+ (await this.#discordIdentities(principalId))?.includes(userId) ?? false
105
+ );
106
+ }
107
+
108
+ /** The principal's Discord user ids; undefined, and logged, when they cannot be read. */
109
+ async #discordIdentities(principalId: string): Promise<string[] | undefined> {
110
+ const { identity, logger } = this.#options;
111
+ if (!identity) return undefined;
112
+ try {
113
+ return (await identity.identities(principalId))
114
+ .filter((link) => link.provider === "discord")
115
+ .map((link) => link.subject);
116
+ } catch (error) {
117
+ logger.warn(
118
+ { principal: principalId, err: error },
119
+ "could not read the speaker's identities; none counts as theirs, so their card is the owners'",
120
+ );
121
+ return undefined;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * The Discord identities of the scope's principal when they may answer their own card: at the
127
+ * tier it needs and, for an owner-tier call, an owner; undefined when they may not, or have
128
+ * none.
129
+ */
130
+ async #ownIdentities(
131
+ scope: PromptScope,
132
+ minTier: Tier | undefined,
133
+ ): Promise<string[] | undefined> {
134
+ if (
135
+ !this.#options.identity ||
136
+ (minTier && !tierAtLeast(scope.tier, minTier))
137
+ )
138
+ return undefined;
139
+ // A turn whose tier defaulted to owner is not an owner's: its owner-tier call is the owners'.
140
+ if (
141
+ minTier === "owner" &&
142
+ !(await this.#owners.isOwnerPrincipal(scope.principalId))
143
+ )
144
+ return undefined;
145
+ const own = await this.#discordIdentities(scope.principalId);
146
+ return own && own.length > 0 ? own : undefined;
147
+ }
148
+ }