pi-roundtable 0.8.0 → 0.9.1

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 (158) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/README.md +35 -11
  3. package/README.zh-TW.md +27 -10
  4. package/docs/migration-0.9.md +247 -0
  5. package/docs/plugins.md +618 -86
  6. package/examples/channel-context.test.ts +88 -0
  7. package/examples/channel-context.ts +62 -0
  8. package/examples/echo-runtime.test.ts +1 -0
  9. package/examples/fake-surface.test.ts +69 -6
  10. package/examples/fake-surface.ts +73 -7
  11. package/examples/headless.ts +5 -1
  12. package/examples/prechecks.test.ts +1 -0
  13. package/examples/principals.test.ts +60 -0
  14. package/examples/principals.ts +48 -0
  15. package/examples/prompt.test.ts +1 -1
  16. package/examples/study-room.test.ts +29 -1
  17. package/examples/study-room.ts +16 -19
  18. package/examples/support-desk.test.ts +3 -1
  19. package/examples/tools.test.ts +6 -1
  20. package/package.json +5 -2
  21. package/src/cli/checks/database.ts +11 -0
  22. package/src/cli/checks/identity.ts +225 -0
  23. package/src/cli/checks/model.ts +21 -3
  24. package/src/cli/cli.ts +115 -1
  25. package/src/cli/config-edit.ts +83 -13
  26. package/src/cli/doctor.ts +7 -0
  27. package/src/cli/line-diff.ts +72 -0
  28. package/src/cli/principal.ts +412 -0
  29. package/src/cli/project.ts +5 -0
  30. package/src/cli/report.ts +12 -3
  31. package/src/cli/upgrade-rewrite.ts +102 -0
  32. package/src/cli/upgrade-source.ts +430 -0
  33. package/src/cli/upgrade.ts +176 -0
  34. package/src/core/agents/agent-claim.ts +56 -34
  35. package/src/core/agents/team-turns.ts +8 -4
  36. package/src/core/builtin/agent-server.ts +9 -7
  37. package/src/core/builtin/discord-admin.ts +4 -1
  38. package/src/core/builtin/discord.ts +71 -13
  39. package/src/core/builtin/modules.ts +150 -86
  40. package/src/core/builtin/stores.ts +40 -21
  41. package/src/core/config/access.ts +229 -0
  42. package/src/core/config/adapters.ts +64 -1
  43. package/src/core/config/config.ts +87 -41
  44. package/src/core/contract/channel-context.ts +53 -0
  45. package/src/core/contract/channels.ts +82 -9
  46. package/src/core/contract/runtime.ts +23 -7
  47. package/src/core/contract/surface.ts +22 -6
  48. package/src/core/conversations/conversation-registry.ts +14 -0
  49. package/src/core/conversations/conversation-store.ts +13 -0
  50. package/src/core/define-roundtable.ts +42 -11
  51. package/src/core/discord/agent-panel.ts +2 -2
  52. package/src/core/discord/card-audience.ts +148 -0
  53. package/src/core/discord/channel-context.ts +348 -0
  54. package/src/core/discord/connection.ts +15 -3
  55. package/src/core/discord/direct-channel.ts +54 -0
  56. package/src/core/discord/discord-owners.ts +133 -0
  57. package/src/core/discord/discord-surface.ts +69 -14
  58. package/src/core/discord/fresh-marker.ts +24 -0
  59. package/src/core/discord/inbound-message.ts +21 -7
  60. package/src/core/discord/interaction-module.ts +11 -1
  61. package/src/core/discord/owner-cards-fixture.ts +80 -0
  62. package/src/core/discord/owner-cards.ts +58 -97
  63. package/src/core/discord/owner-command.ts +27 -13
  64. package/src/core/discord/stop-button.ts +4 -4
  65. package/src/core/domain/conversation.ts +7 -2
  66. package/src/core/domain/errors.ts +5 -0
  67. package/src/core/domain/ports.ts +27 -2
  68. package/src/core/drain.ts +56 -5
  69. package/src/core/errors.ts +13 -0
  70. package/src/core/host-background.ts +56 -0
  71. package/src/core/host.ts +52 -68
  72. package/src/core/i18n/cards.ts +8 -4
  73. package/src/core/i18n/conversation.ts +2 -0
  74. package/src/core/identity/access-policy.ts +227 -0
  75. package/src/core/identity/actor-facts.ts +65 -0
  76. package/src/core/identity/caching-principal-store.ts +111 -0
  77. package/src/core/identity/config-owner.ts +145 -0
  78. package/src/core/identity/contact.ts +27 -0
  79. package/src/core/identity/identity-plugin.ts +56 -0
  80. package/src/core/identity/identity-schema.ts +282 -0
  81. package/src/core/identity/identity-service.ts +489 -0
  82. package/src/core/identity/identity-view.ts +77 -0
  83. package/src/core/identity/plugin-identities.ts +109 -0
  84. package/src/core/identity/principal-reads.ts +38 -0
  85. package/src/core/identity/principal-store.ts +478 -0
  86. package/src/core/identity/principal-tables.ts +53 -0
  87. package/src/core/identity/seen-throttle.ts +57 -0
  88. package/src/core/identity/ulid.ts +36 -0
  89. package/src/core/identity.ts +34 -5
  90. package/src/core/interactions/prompts.ts +127 -0
  91. package/src/core/modules/background/background-turns.ts +56 -17
  92. package/src/core/modules/background/personal-target.ts +50 -0
  93. package/src/core/modules/delegation/delegate.ts +28 -14
  94. package/src/core/modules/delegation/delegator.ts +23 -7
  95. package/src/core/modules/memory/owner-memory.ts +123 -50
  96. package/src/core/modules/notify/notify.ts +53 -13
  97. package/src/core/modules/notify/session-notify.ts +110 -0
  98. package/src/core/modules/schedules/prechecks.ts +8 -2
  99. package/src/core/modules/schedules/schedule-prechecks.ts +4 -1
  100. package/src/core/modules/schedules/schedule-store.ts +51 -20
  101. package/src/core/modules/schedules/schedule-tools.ts +122 -12
  102. package/src/core/modules/schedules/scheduler.ts +56 -4
  103. package/src/core/modules/schedules/schedules.ts +43 -14
  104. package/src/core/plugin.ts +48 -0
  105. package/src/core/presence/direct-channels.ts +104 -0
  106. package/src/core/registry/contributions.ts +63 -3
  107. package/src/core/routing/channel-queue.ts +18 -1
  108. package/src/core/routing/channel-router.ts +274 -21
  109. package/src/core/routing/conversation-turns.ts +45 -10
  110. package/src/core/routing/surface-port.ts +32 -2
  111. package/src/core/routing/system-turns.ts +29 -0
  112. package/src/core/runtime/abort-guard.ts +58 -0
  113. package/src/core/runtime/bridge-guard.ts +225 -0
  114. package/src/core/runtime/compaction-tiers.ts +5 -1
  115. package/src/core/runtime/conversation-sessions.ts +139 -12
  116. package/src/core/runtime/extensions/ask-user.ts +1 -1
  117. package/src/core/runtime/extensions/confirmation-gate.ts +28 -14
  118. package/src/core/runtime/extensions/private-memory.ts +447 -0
  119. package/src/core/runtime/pending-confirmation-store.ts +30 -5
  120. package/src/core/runtime/pi-agent-runtime.ts +174 -190
  121. package/src/core/runtime/preflight-tools.ts +47 -0
  122. package/src/core/runtime/prompt-slot.ts +4 -4
  123. package/src/core/runtime/reader-history.ts +129 -0
  124. package/src/core/runtime/runtime-plugin.ts +51 -5
  125. package/src/core/runtime/runtime-types.ts +48 -12
  126. package/src/core/runtime/session-conversation.ts +143 -0
  127. package/src/core/runtime/session-factory.ts +115 -15
  128. package/src/core/runtime/session-scope.ts +78 -0
  129. package/src/core/runtime/steerable-run.ts +21 -3
  130. package/src/core/runtime/thinking-level.ts +47 -0
  131. package/src/core/runtime/tool-call-scope.ts +43 -0
  132. package/src/core/runtime/turn-answer.ts +30 -1
  133. package/src/core/runtime/turn-wrapup.ts +63 -0
  134. package/src/core/services.ts +27 -1
  135. package/src/core/sessions.ts +30 -0
  136. package/src/core/speakers.ts +21 -19
  137. package/src/core/testing/background.ts +31 -0
  138. package/src/core/testing/fixture-database.ts +55 -0
  139. package/src/core/testing/map-identity.ts +76 -0
  140. package/src/core/testing/module-sessions.ts +134 -0
  141. package/src/core/testing/modules.ts +108 -21
  142. package/src/core/testing/owner.ts +17 -0
  143. package/src/core/testing/surface-contract.ts +118 -41
  144. package/src/core/testing/test-host.ts +79 -16
  145. package/src/core/testing/tool-set.ts +32 -0
  146. package/src/core/tool-set.snapshot.json +4 -4
  147. package/src/core/tool-tiers.ts +57 -3
  148. package/src/discord/index.ts +8 -0
  149. package/src/index.ts +48 -8
  150. package/src/kit/compaction.ts +13 -0
  151. package/src/kit/index.ts +10 -0
  152. package/src/kit/support.ts +1 -1
  153. package/src/testing.ts +55 -16
  154. package/templates/.env.example +1 -1
  155. package/templates/adapters/web/README.md +7 -0
  156. package/templates/adapters/web/roundtable.config.ts +14 -10
  157. package/templates/roundtable.config.ts +12 -1
  158. package/src/core/domain/owner-prompts.ts +0 -45
@@ -1,8 +1,8 @@
1
1
  import type { OutboundReply } from "../domain/conversation.ts";
2
2
  import type { InterimPosts } from "../domain/interim.ts";
3
- import type { OwnerPrompts } from "../domain/owner-prompts.ts";
4
3
  import type { TurnProgress } from "../domain/progress.ts";
5
4
  import { PluginError } from "../errors.ts";
5
+ import type { PromptScope, Prompts } from "../interactions/prompts.ts";
6
6
  import type { ChannelKey } from "../sessions.ts";
7
7
  import type { Speaker } from "../speakers.ts";
8
8
  import type { InboundMessage } from "./channels.ts";
@@ -58,6 +58,13 @@ export interface ChatSurface {
58
58
  * `conversations.stop(channel)` when used. Absent = none shown.
59
59
  */
60
60
  showStop?(channel: ChannelKey): () => void;
61
+ /**
62
+ * Marks in the channel, publicly, that its conversation just started over, so what a reader of
63
+ * the channel's history would take for the conversation's context ends there; called after the
64
+ * claim's `startFresh` succeeded. A rejection is logged and the restart still counts. Absent =
65
+ * nothing is posted.
66
+ */
67
+ markFresh?(channel: ChannelKey): Promise<void>;
61
68
  /** Adds or removes the bot's reaction on a message; failures are logged, never thrown. */
62
69
  react?(channel: ChannelKey, messageId: string, emoji: string): Promise<void>;
63
70
  unreact?(
@@ -66,10 +73,12 @@ export interface ChatSurface {
66
73
  emoji: string,
67
74
  ): Promise<void>;
68
75
  /**
69
- * How the owner approves held actions or answers ask_user in a running turn; undefined, or
70
- * absent, = the action is held until the owner's next message.
76
+ * How those the scope names approve held actions or answer ask_user in a running turn: the
77
+ * scope's speaker, and in a shared conversation the owners; see `Prompts` and `PromptScope`.
78
+ * Without a scope the prompts are the owners'. Undefined, or absent, = the action is held
79
+ * until a message approves it.
71
80
  */
72
- prompts?(channel: ChannelKey, speaker?: Speaker): OwnerPrompts | undefined;
81
+ prompts?(channel: ChannelKey, scope?: PromptScope): Prompts | undefined;
73
82
  /**
74
83
  * Where a running turn posts the text it writes before its final answer: ordinary messages it
75
84
  * may edit in place. Absent, or undefined, = only the final reply is posted.
@@ -98,8 +107,15 @@ export interface SurfacePort {
98
107
  showStop(channel: ChannelKey): () => void;
99
108
  react(channel: ChannelKey, messageId: string, emoji: string): Promise<void>;
100
109
  unreact(channel: ChannelKey, messageId: string, emoji: string): Promise<void>;
101
- /** The owner's prompts in the channel; undefined when its surface has none. */
102
- prompts(channel: ChannelKey, speaker?: Speaker): OwnerPrompts | undefined;
110
+ /**
111
+ * The prompts in the channel for the scope's turn; undefined when its surface has none. A
112
+ * `Speaker`, the 0.8 form, is read as theirs in a shared conversation, with a deprecation
113
+ * warning, until 1.0.
114
+ */
115
+ prompts(
116
+ channel: ChannelKey,
117
+ scope?: PromptScope | Speaker,
118
+ ): Prompts | undefined;
103
119
  /** The channel's interim posts; undefined when its surface has none. */
104
120
  interim(channel: ChannelKey): InterimPosts | undefined;
105
121
  /**
@@ -34,6 +34,20 @@ export interface ConversationRegistry {
34
34
  get(key: ChannelKey): Promise<ConversationRecord | undefined>;
35
35
  /** One principal's conversations, or every one; the most recently active first. */
36
36
  list(filter?: { principal?: string }): Promise<ConversationRecord[]>;
37
+ /**
38
+ * Only for migrating a conversation recorded before 0.9, when every turn run without a
39
+ * visibility was recorded `shared` with no principal: makes such a conversation private to
40
+ * `principalId`, as the plugin that hands over what 0.8 left behind decides, such as remote-mcp
41
+ * giving 0.8's sessions to the primary owner. A conversation that is private or names a
42
+ * principal never changes, so adopting is safe to repeat and to race: one principal wins.
43
+ * Returns what is stored, or undefined when the key is unknown. Adopting a conversation 0.9 or
44
+ * later recorded is outside this contract: today it changes one recorded shared like any other,
45
+ * and a later version may refuse it.
46
+ */
47
+ adopt(
48
+ key: ChannelKey,
49
+ principalId: string,
50
+ ): Promise<ConversationRecord | undefined>;
37
51
  /** Names a conversation, or clears its name; undefined when the key is unknown. */
38
52
  setTitle(
39
53
  key: ChannelKey,
@@ -100,6 +100,19 @@ export class PgConversationRegistry implements ConversationRegistry {
100
100
  return rows.map(recordOf);
101
101
  }
102
102
 
103
+ async adopt(
104
+ key: ChannelKey,
105
+ principalId: string,
106
+ ): Promise<ConversationRecord | undefined> {
107
+ // The row lock makes a racing adopt wait and then find the conversation no longer no one's.
108
+ const rows: Row[] = await this.#sql`
109
+ UPDATE conversations SET visibility = 'private', principal_id = ${principalId}
110
+ WHERE key = ${key} AND visibility = 'shared' AND principal_id IS NULL
111
+ RETURNING *`;
112
+ const [row] = rows;
113
+ return row ? recordOf(row) : this.get(key);
114
+ }
115
+
103
116
  async setTitle(
104
117
  key: ChannelKey,
105
118
  title: string | undefined,
@@ -13,6 +13,7 @@ import {
13
13
  precheckPlugin,
14
14
  scheduleStorePlugin,
15
15
  } from "./builtin/stores.ts";
16
+ import { warnDeprecations } from "./config/access.ts";
16
17
  import {
17
18
  type ResolvedConfig,
18
19
  type RoundtableConfig,
@@ -23,17 +24,23 @@ import { ConfigError } from "./domain/errors.ts";
23
24
  import { JudgeError } from "./errors.ts";
24
25
  import type { RoundtableOptions } from "./host.ts";
25
26
  import type { ListenerConfig } from "./http/listeners.ts";
27
+ import { identityPlugin } from "./identity/identity-plugin.ts";
26
28
  import { type JudgeModel, piJudgeModel } from "./judging/model-judge.ts";
27
29
  import { createLogger, type LogEntry, type Logger } from "./log.ts";
28
30
  import { formatModelRef, type ModelRef } from "./models.ts";
29
31
  import { ErrorReporter } from "./ops/error-reporter.ts";
30
32
  import type { RoundtablePlugin } from "./plugin.ts";
33
+ import {
34
+ BRIDGE_MEMORY_FIX,
35
+ bridgeMemoryProblem,
36
+ configuredCrowd,
37
+ onClaudeBridge,
38
+ } from "./runtime/bridge-guard.ts";
31
39
  import {
32
40
  type AgentSessionsSlot,
33
41
  agentSessionsSlot,
34
42
  runtimePlugin,
35
43
  } from "./runtime/runtime-plugin.ts";
36
- import { speakerPolicy } from "./speakers.ts";
37
44
  import { toolTiers } from "./tool-tiers.ts";
38
45
 
39
46
  /** What `defineRoundtable` returns: give both to `new Roundtable(options, plugins)`. */
@@ -111,6 +118,19 @@ function publicListener(
111
118
  };
112
119
  }
113
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
+
114
134
  /** The Discord bot and the agent server that lives there, each in its place in the plugin list. */
115
135
  interface DiscordAssembly {
116
136
  discord: RoundtablePlugin;
@@ -129,12 +149,8 @@ function discordAssembly(
129
149
  errorReporter: ErrorReporter | undefined;
130
150
  },
131
151
  ): DiscordAssembly {
132
- const { name, owner } = config;
133
- const speakers = speakerPolicy({
134
- owners: [owner.id],
135
- ...(config.speakers.admins ? { admins: config.speakers.admins } : {}),
136
- ...(config.speakers.members ? { members: config.speakers.members } : {}),
137
- });
152
+ const { name } = config;
153
+ const owner = discordOwnerOf(config);
138
154
  const sharedPrompt = config.prompts.shared
139
155
  ? readPrompt(config.prompts.shared, "prompts.shared")
140
156
  : readPrompt(join(ASSETS, "prompts", "shared.md"), "prompts.shared");
@@ -147,12 +163,15 @@ function discordAssembly(
147
163
  token: discord.token,
148
164
  ownerId: owner.id,
149
165
  ownerName: owner.name,
150
- speakers,
151
166
  rootCommand: discord.rootCommand,
152
167
  dataDir: config.dataDir,
153
168
  ...(discord.refusalHint === undefined
154
169
  ? {}
155
170
  : { refusalHint: discord.refusalHint }),
171
+ channelContext: discord.channelContext,
172
+ ...(discord.freshMarker === undefined
173
+ ? {}
174
+ : { freshMarker: discord.freshMarker }),
156
175
  }),
157
176
  admin: discord.admin ? [discordAdminPlugin({ owner })] : [],
158
177
  skills: config.skills
@@ -170,7 +189,6 @@ function discordAssembly(
170
189
  entryChannelId: discord.entryChannel,
171
190
  owner,
172
191
  assistant: name,
173
- speakers,
174
192
  modelRuntime: shared.modelRuntime,
175
193
  dataDir: config.dataDir,
176
194
  model: config.model,
@@ -180,6 +198,7 @@ function discordAssembly(
180
198
  scratchDir: config.scratchDir,
181
199
  shellUser: userInfo().username,
182
200
  prompts: { shared: sharedPrompt, guest },
201
+ ...(discord.agentMemory ? { memory: discord.agentMemory } : {}),
183
202
  avatarListener: "public",
184
203
  // resolveConfig refuses Discord without a public address.
185
204
  avatarUrl: config.http?.publicUrl ?? "",
@@ -207,6 +226,7 @@ export async function defineRoundtable(
207
226
  ): Promise<DefinedRoundtable> {
208
227
  const config = resolveConfig(input);
209
228
  const { name, owner, discord } = config;
229
+ const crowd = configuredCrowd(config.access, config.plugins);
210
230
  // Nothing here touches the process: the host applies the environment when it runs.
211
231
  const modelRuntime =
212
232
  overrides.modelRuntime ??
@@ -219,7 +239,7 @@ export async function defineRoundtable(
219
239
  ? new ErrorReporter({ destination: config.ops, app: name })
220
240
  : undefined;
221
241
  const { errorSink } = overrides;
222
- const logger =
242
+ const logger: Logger =
223
243
  overrides.logger ??
224
244
  createLogger(
225
245
  config.slug,
@@ -230,6 +250,12 @@ export async function defineRoundtable(
230
250
  }
231
251
  : undefined,
232
252
  );
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);
233
259
  // The agent server hands the runtime its per-agent settings once it sets up.
234
260
  const agentSessions = agentSessionsSlot();
235
261
  const assembly = discord
@@ -250,16 +276,18 @@ export async function defineRoundtable(
250
276
  agentDir: config.agentDir,
251
277
  },
252
278
  database: { url: config.databaseUrl },
253
- toolTiers: toolTiers(config.toolTiers),
279
+ toolTiers: toolTiers(config.toolTiers, logger),
254
280
  listeners: [
255
281
  ...(config.http ? [publicListener(config.http)] : []),
256
282
  ...(overrides.listeners ?? []),
257
283
  ],
258
284
  judgeModel: judgeThrough(modelRuntime, config.judge.model),
259
285
  apiKey: (provider) => registry.getApiKeyForProvider(provider),
286
+ drain: { limitMs: config.drainMs },
260
287
  ...(overrides.aborted ? { aborted: overrides.aborted } : {}),
261
288
  },
262
289
  plugins: [
290
+ identityPlugin({ rules: config.access, plugins: config.plugins }),
263
291
  ...(config.memory ? [memoryPlugin({ owner })] : []),
264
292
  scheduleStorePlugin(),
265
293
  precheckPlugin(),
@@ -271,6 +299,7 @@ export async function defineRoundtable(
271
299
  agentDir: config.agentDir,
272
300
  dataDir: config.dataDir,
273
301
  delegation: config.delegation,
302
+ perPrincipal: config.background.perPrincipal,
274
303
  // A conversation's reports are the modules' to deliver.
275
304
  ...(errorReporter && "conversation" in errorReporter.destination
276
305
  ? { errorReporter }
@@ -290,6 +319,8 @@ export async function defineRoundtable(
290
319
  agents: agentSessions,
291
320
  interimText: config.interimText,
292
321
  interimPrimaryChars: config.interimPrimaryChars,
322
+ memory: config.memory,
323
+ ...(crowd ? { crowd } : {}),
293
324
  }),
294
325
  ...(assembly ? [assembly.agentServer, assembly.seeds] : []),
295
326
  ...config.plugins,
@@ -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
+ }