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
@@ -18,20 +18,24 @@ import type {
18
18
  OutboundReply,
19
19
  } from "../domain/conversation.ts";
20
20
  import type { InterimPosts } from "../domain/interim.ts";
21
- import type { OwnerPrompts } from "../domain/owner-prompts.ts";
22
21
  import type { OwnerNotifier } from "../domain/ports.ts";
23
22
  import { messages } from "../i18n/index.ts";
23
+ import type { PromptScope, Prompts } from "../interactions/prompts.ts";
24
24
  import type { Logger } from "../log.ts";
25
25
  import type { OwnerOperations } from "../modules/discord-admin/discord-admin.ts";
26
26
  import { splitReply } from "../presentation/reply-splitter.ts";
27
- import type { Speaker } from "../speakers.ts";
28
27
  import { DiscordAgentChannels, DiscordDashboard } from "./agent-discord.ts";
28
+ import {
29
+ type ContextSourceMessage,
30
+ contextSourceOf,
31
+ } from "./channel-context.ts";
29
32
  import { discordChannelExecutor } from "./channel-executor.ts";
30
33
  import type { ChannelExecutor } from "./channel-operations.ts";
31
34
  import type { ComposedCommands } from "./compose-commands.ts";
32
35
  import type { ChannelInfo, DiscordConnection } from "./connection.ts";
33
36
  import { DiscordThreadHost } from "./dispatch-thread-host.ts";
34
37
  import type { ThreadHost } from "./dispatch-threads.ts";
38
+ import { DEFAULT_FRESH_MARKER, postFreshMarker } from "./fresh-marker.ts";
35
39
  import { toInbound } from "./inbound-message.ts";
36
40
  import type { CardChannel } from "./owner-cards.ts";
37
41
  import { DiscordOwnerOps } from "./owner-discord.ts";
@@ -47,12 +51,15 @@ const STOP_PANEL_DELAY_MS = 5_000;
47
51
 
48
52
  export interface DiscordSurfaceOptions {
49
53
  token: string;
54
+ /** The primary owner's Discord user id, whom the admin tools act as and the deprecated owner channel reaches. */
50
55
  ownerId: string;
51
- /** How the owner is named in audit-log reasons and refusals. */
56
+ /** How the primary owner is named in audit-log reasons and refusals. */
52
57
  ownerName: string;
53
- /** The owner's cards in a Discord channel; the surface hands them out as its `prompts`. */
54
- prompts: (channel: ChannelKey, speaker?: Speaker) => OwnerPrompts | undefined;
58
+ /** The cards in a Discord channel; the surface hands them out as its `prompts`. */
59
+ prompts: (channel: ChannelKey, scope?: PromptScope) => Prompts | undefined;
55
60
  logger: Logger;
61
+ /** The divider posted in a server channel when its conversation starts over; `false` posts none. Default: `DEFAULT_FRESH_MARKER`. */
62
+ freshMarker?: string | false;
56
63
  }
57
64
 
58
65
  /**
@@ -96,7 +103,7 @@ export class DiscordSurface
96
103
  : undefined;
97
104
  }
98
105
 
99
- /** Discord reading and management for the owner's agent; calls fail until the connection is ready. */
106
+ /** Discord reading and management for the owner's agent, as the primary owner may; calls fail until the connection is ready. */
100
107
  ownerOperations(): OwnerOperations {
101
108
  return new DiscordOwnerOps(
102
109
  this.#client,
@@ -150,14 +157,37 @@ export class DiscordSurface
150
157
  };
151
158
  }
152
159
 
160
+ /**
161
+ * A server text channel's messages before one, as channel context reads them; undefined for a
162
+ * direct message or a channel that holds no messages. Throws while the connection is not ready
163
+ * or when Discord refuses, such as for a channel the bot cannot read.
164
+ */
165
+ async messagesBefore(
166
+ channelId: string,
167
+ before: string,
168
+ limit: number,
169
+ ): Promise<ContextSourceMessage[] | undefined> {
170
+ if (!this.#client.isReady()) throw new Error("Discord is not connected");
171
+ const channel = await this.#client.channels.fetch(channelId);
172
+ if (!channel?.isTextBased() || channel.isDMBased()) return undefined;
173
+ const fetched = await channel.messages.fetch({ before, limit });
174
+ const self = {
175
+ botId: this.#client.user.id,
176
+ applicationId: this.#client.application.id,
177
+ };
178
+ return [...fetched.values()].map((message) =>
179
+ contextSourceOf(message, self),
180
+ );
181
+ }
182
+
153
183
  /** Reports deleted server channels by id; register before start. */
154
184
  onChannelDeleted(handler: (channelId: string) => void): void {
155
185
  this.#client.on(Events.ChannelDelete, (channel) => handler(channel.id));
156
186
  }
157
187
 
158
- /** The owner's approval and question cards in a Discord channel. */
159
- prompts(channel: ChannelKey, speaker?: Speaker): OwnerPrompts | undefined {
160
- return this.#options.prompts(channel, speaker);
188
+ /** The approval and question cards in a Discord channel, for those the scope names. */
189
+ prompts(channel: ChannelKey, scope?: PromptScope): Prompts | undefined {
190
+ return this.#options.prompts(channel, scope);
161
191
  }
162
192
 
163
193
  /** The channel's interim posts: ordinary messages, edited in place for the progress line. */
@@ -244,6 +274,20 @@ export class DiscordSurface
244
274
  }
245
275
  }
246
276
 
277
+ /** The divider that ends channel context's window; see `postFreshMarker`. */
278
+ async markFresh(channel: ChannelKey): Promise<void> {
279
+ await postFreshMarker(
280
+ this.#options.freshMarker ?? DEFAULT_FRESH_MARKER,
281
+ async () => {
282
+ const target = await this.#sendable(channel);
283
+ return {
284
+ dm: target.isDMBased(),
285
+ send: (text) => target.send({ content: text }),
286
+ };
287
+ },
288
+ );
289
+ }
290
+
247
291
  startTyping(channel: ChannelKey): () => void {
248
292
  let stopped = false;
249
293
  const tick = () => {
@@ -339,15 +383,26 @@ export class DiscordSurface
339
383
  };
340
384
  }
341
385
 
342
- /** The owner's direct-message channel. */
386
+ /** A Discord user's direct-message channel. */
387
+ async directChannel(userId: string): Promise<ChannelKey> {
388
+ const user = await this.#client.users.fetch(userId);
389
+ return `${PREFIX}${(await user.createDM()).id}`;
390
+ }
391
+
392
+ /** Sends a Discord user a direct message, split as a reply is. */
393
+ async sendDirect(userId: string, text: string): Promise<void> {
394
+ const user = await this.#client.users.fetch(userId);
395
+ for (const chunk of splitReply(text)) await user.send({ content: chunk });
396
+ }
397
+
398
+ /** @deprecated The primary owner's direct-message channel; see `DiscordConnection.ownerChannel`. */
343
399
  async ownerChannel(): Promise<ChannelKey> {
344
- const owner = await this.#client.users.fetch(this.#options.ownerId);
345
- return `${PREFIX}${(await owner.createDM()).id}`;
400
+ return this.directChannel(this.#options.ownerId);
346
401
  }
347
402
 
403
+ /** @deprecated Sends the primary owner a direct message; see `DiscordConnection.notifyOwner`. */
348
404
  async notifyOwner(text: string): Promise<void> {
349
- const owner = await this.#client.users.fetch(this.#options.ownerId);
350
- for (const chunk of splitReply(text)) await owner.send({ content: chunk });
405
+ await this.sendDirect(this.#options.ownerId, text);
351
406
  }
352
407
 
353
408
  async stop(): Promise<void> {
@@ -0,0 +1,24 @@
1
+ /** The divider a guild channel gets when its conversation starts over. */
2
+ export const DEFAULT_FRESH_MARKER = "─── new conversation ───";
3
+
4
+ /** A channel as the divider needs it: whether it is a direct message, and a way to post. */
5
+ export interface MarkerTarget {
6
+ dm: boolean;
7
+ send(text: string): Promise<unknown>;
8
+ }
9
+
10
+ /**
11
+ * Posts the divider that ends channel context's window after a conversation started over: a
12
+ * public post of the assistant in a server channel or thread, never in a direct message, and
13
+ * nothing for `false`. A channel that cannot be found or posted to is left as it is; a Discord
14
+ * error rejects, for the caller to log.
15
+ */
16
+ export async function postFreshMarker(
17
+ marker: string | false,
18
+ find: () => Promise<MarkerTarget | undefined>,
19
+ ): Promise<void> {
20
+ if (marker === false) return;
21
+ const target = await find();
22
+ if (!target || target.dm) return;
23
+ await target.send(marker);
24
+ }
@@ -55,18 +55,32 @@ export async function toInbound(
55
55
  reference?.messageId && !forward
56
56
  ? await message.fetchReference().catch(() => undefined)
57
57
  : undefined;
58
+ const authorName =
59
+ message.member?.displayName ??
60
+ message.author.globalName ??
61
+ message.author.username;
62
+ const roleIds = message.member
63
+ ? [...message.member.roles.cache.keys()]
64
+ : undefined;
58
65
  return {
59
66
  channel: channelKey("discord", message.channelId),
60
67
  messageId: message.id,
68
+ // A DM has no member, so its author's roles are unknown, not none.
69
+ actor: {
70
+ provider: "discord",
71
+ subject: message.author.id,
72
+ name: authorName,
73
+ surface: "discord",
74
+ ...(roleIds
75
+ ? { roles: roleIds.map((role) => `discord:role:${role}`) }
76
+ : {}),
77
+ ...(message.guildId ? { space: message.guildId } : {}),
78
+ legacyId: message.author.id,
79
+ },
61
80
  authorId: message.author.id,
62
- authorName:
63
- message.member?.displayName ??
64
- message.author.globalName ??
65
- message.author.username,
81
+ authorName,
66
82
  authorIsBot: message.author.bot,
67
- ...(message.member
68
- ? { authorRoleIds: [...message.member.roles.cache.keys()] }
69
- : {}),
83
+ ...(roleIds ? { authorRoleIds: roleIds } : {}),
70
84
  ...(message.webhookId
71
85
  ? {
72
86
  integration: {
@@ -5,6 +5,7 @@ import type {
5
5
  InteractionContextType,
6
6
  RESTPostAPIChatInputApplicationCommandsJSONBody,
7
7
  } from "discord.js";
8
+ import type { DiscordActor } from "./discord-owners.ts";
8
9
 
9
10
  /** A group of slash commands and the components they post, handled together. */
10
11
  export interface InteractionModule {
@@ -50,7 +51,16 @@ export interface CommandGuard {
50
51
  * anyone may use; include the space or punctuation your language needs before it.
51
52
  */
52
53
  readonly refusalHint?: string;
53
- /** Whether the actor is the owner: an interaction, or anything with `user.id`, such as a test's. */
54
+ /**
55
+ * Whether the actor may use the owner's commands now: an owner, the primary owner by their user
56
+ * id and any other by their Discord identity, checked again each time. The actor is an
57
+ * interaction, or anything with `user.id`, such as a test's.
58
+ */
59
+ allows(actor: DiscordActor): Promise<boolean>;
60
+ /**
61
+ * Whether the actor is the primary owner, by user id.
62
+ * @deprecated Since 0.9 every owner uses the owner's commands; ask `allows`. Goes away in 1.0.
63
+ */
54
64
  isOwner(actor: { user: { id: string } }): boolean;
55
65
  /** Runs a handler; a failure becomes a panel instead of a hanging interaction. */
56
66
  run(interaction: Interaction, run: () => Promise<void>): Promise<void>;
@@ -0,0 +1,80 @@
1
+ import type { Interaction } from "discord.js";
2
+ import { CARD_PREFIX, type CardPayload } from "./owner-cards.ts";
3
+
4
+ /** The primary owner of the cards' tests. */
5
+ export const OWNER = "100000000000000001";
6
+
7
+ /** The card's JSON, where its text, custom ids, and disabled flags can be read. */
8
+ export const json = (payload: CardPayload | undefined) =>
9
+ JSON.stringify(payload?.components.map((c) => c.toJSON()));
10
+
11
+ /** A channel, or a thread, that records the cards sent to it and every edit of them. */
12
+ export function fakeChannel(thread = false) {
13
+ const sent: CardPayload[] = [];
14
+ const edits: CardPayload[] = [];
15
+ return {
16
+ sent,
17
+ edits,
18
+ /** The id of the latest card sent. */
19
+ cardId: () =>
20
+ json(sent.at(-1)).match(new RegExp(`${CARD_PREFIX}([0-9a-f-]+):`))?.[1] ??
21
+ "",
22
+ channel: async () => ({
23
+ thread,
24
+ send: async (payload: CardPayload) => {
25
+ sent.push(payload);
26
+ return {
27
+ edit: async (change: CardPayload) => {
28
+ edits.push(change);
29
+ },
30
+ };
31
+ },
32
+ }),
33
+ };
34
+ }
35
+
36
+ export type Kind = "button" | "select" | "modal";
37
+
38
+ /** A press of a card's control, recording how it was answered. */
39
+ export function press(
40
+ kind: Kind,
41
+ customId: string,
42
+ options: {
43
+ user?: string;
44
+ roles?: string[];
45
+ values?: string[];
46
+ text?: string;
47
+ } = {},
48
+ ) {
49
+ const replies: string[] = [];
50
+ const updates: CardPayload[] = [];
51
+ const modals: unknown[] = [];
52
+ // SAFETY: the cards read only these members of an interaction.
53
+ const interaction = {
54
+ isButton: () => kind === "button",
55
+ isStringSelectMenu: () => kind === "select",
56
+ isModalSubmit: () => kind === "modal",
57
+ isFromMessage: () => true,
58
+ customId,
59
+ user: { id: options.user ?? OWNER },
60
+ member: {
61
+ roles: { cache: new Map((options.roles ?? []).map((r) => [r, r])) },
62
+ },
63
+ values: options.values ?? [],
64
+ fields: { getTextInputValue: () => options.text ?? "" },
65
+ reply: async (answer: { content: string }) => {
66
+ replies.push(answer.content);
67
+ },
68
+ update: async (payload: CardPayload) => {
69
+ updates.push(payload);
70
+ },
71
+ showModal: async (modal: unknown) => {
72
+ modals.push(modal);
73
+ },
74
+ deferUpdate: async () => undefined,
75
+ } as unknown as Interaction;
76
+ return { interaction, replies, updates, modals };
77
+ }
78
+
79
+ /** Lets a posted card settle. */
80
+ export const tick = () => Bun.sleep(1);
@@ -14,20 +14,21 @@ import {
14
14
  } from "discord.js";
15
15
  import { parseChannelKey } from "../contract/surface.ts";
16
16
  import type { ChannelKey } from "../domain/conversation.ts";
17
+ import { messages } from "../i18n/index.ts";
17
18
  import type {
18
19
  Approval,
19
20
  OwnerAnswer,
20
- OwnerPrompts,
21
21
  OwnerQuestion,
22
- } from "../domain/owner-prompts.ts";
23
- import { messages } from "../i18n/index.ts";
22
+ PromptScope,
23
+ Prompts,
24
+ } from "../interactions/prompts.ts";
24
25
  import type { Logger } from "../log.ts";
25
26
  import {
26
- type Speaker,
27
- type SpeakerPolicy,
28
- type Tier,
29
- tierAtLeast,
30
- } from "../speakers.ts";
27
+ type Audience,
28
+ type CardAudienceOptions,
29
+ CardAudiences,
30
+ } from "./card-audience.ts";
31
+ import { DiscordOwners, discordUser } from "./discord-owners.ts";
31
32
  import type { InteractionModule } from "./interaction-module.ts";
32
33
  import { ownerPanel, type PanelContent, plain } from "./owner-panel.ts";
33
34
 
@@ -37,7 +38,7 @@ export const CARD_TIMEOUT_MS = 30 * 60_000;
37
38
  const OTHER = "other";
38
39
  const TEXT_FIELD = "text";
39
40
 
40
- /** A card's message: the panel, led by a mention of the owner in a thread. */
41
+ /** A card's message: the panel, led in a thread by a mention of those it is for. */
41
42
  export type CardPayload = Omit<
42
43
  ReturnType<typeof ownerPanel>,
43
44
  "components" | "allowedMentions"
@@ -57,14 +58,18 @@ export interface CardMessage {
57
58
  /** Where a channel's cards are posted. */
58
59
  export interface CardChannel {
59
60
  send(payload: CardPayload): Promise<CardMessage>;
60
- /** A thread, where the owner is mentioned so they hear of the card; a channel never mentions them. */
61
+ /** A thread, where those a card is for are mentioned so they hear of it; a channel mentions no one. */
61
62
  thread?: boolean;
62
63
  }
63
64
 
64
65
  export interface OwnerCardsOptions {
66
+ /** The primary owner's Discord user id. */
65
67
  ownerId: string;
66
- /** Decides who holds a tier, for cards that lower tiers may answer; without it only the owner answers. */
67
- speakers?: SpeakerPolicy;
68
+ /**
69
+ * Who holds a tier now, and which owners have a Discord identity, for cards that others than
70
+ * the primary owner may answer; without it only the primary owner answers.
71
+ */
72
+ identity?: CardAudienceOptions["identity"];
68
73
  /** The Discord channel by id; throws when it cannot take messages. */
69
74
  channel(channelId: string): Promise<CardChannel>;
70
75
  logger: Logger;
@@ -91,41 +96,41 @@ interface OpenCard {
91
96
  audience: Audience;
92
97
  }
93
98
 
94
- /** The people a card is for. */
95
- interface Audience {
96
- allows(user: { id: string; roleIds: readonly string[] }): boolean;
97
- /** The user the card mentions in a thread. */
98
- mentionId: string;
99
- /** Shown on the card. */
100
- note: string;
101
- refusal: string;
102
- }
103
-
104
99
  /**
105
- * The owner's cards in the assistant's channels: approvals of held actions and ask_user questions,
106
- * answered with buttons, a menu, or a form. Only the owner may answer; an unanswered card
107
- * expires after 30 minutes and a stopped turn cancels it. Open cards live in memory, so a
108
- * restart abandons them; pressing one then says it no longer works.
100
+ * The cards in the assistant's channels: approvals of held actions and ask_user questions,
101
+ * answered with buttons, a menu, or a form, by those the turn's prompt scope names (see
102
+ * `CardAudiences`): the speaker whose turn asks, when the card allows (a question always; an
103
+ * approval at its tier), and, in a shared conversation, the owners. An unanswered card expires
104
+ * after 30 minutes and a stopped turn cancels it. Open cards live in memory, so a restart
105
+ * abandons them; pressing one then says it no longer works.
109
106
  */
110
107
  export class OwnerCards implements InteractionModule {
111
108
  readonly #options: OwnerCardsOptions;
109
+ readonly #audiences: CardAudiences;
112
110
  readonly #open = new Map<string, OpenCard>();
113
111
 
114
112
  constructor(options: OwnerCardsOptions) {
115
113
  this.#options = options;
114
+ this.#audiences = new CardAudiences(options, new DiscordOwners(options));
116
115
  }
117
116
 
118
117
  commands() {
119
118
  return [];
120
119
  }
121
120
 
122
- /** The channel's cards; the chat surface port sends only Discord keys here. */
123
- prompts(channel: ChannelKey, speaker?: Speaker): OwnerPrompts | undefined {
121
+ /**
122
+ * The channel's cards for the scope's turn; without a scope they are the owners'. The chat
123
+ * surface port sends only Discord keys here.
124
+ */
125
+ prompts(channel: ChannelKey, scope?: PromptScope): Prompts | undefined {
124
126
  const channelId = parseChannelKey(channel).id;
125
127
  return {
126
- confirm: (title, message, signal, minTier = "owner") =>
127
- this.#post<Approval>(channelId, signal, "expired", "cancelled", {
128
- audience: this.#approvers(minTier, speaker),
128
+ confirm: async (title, message, signal, minTier = "owner") => {
129
+ const audience = await this.#audiences.approval(scope, minTier);
130
+ // A private conversation's call above its person's tier is no one's to approve.
131
+ if (!audience) return "expired";
132
+ return this.#post<Approval>(channelId, signal, "expired", "cancelled", {
133
+ audience,
129
134
  title,
130
135
  sections: [message],
131
136
  rows: (id, disabled) => [
@@ -145,22 +150,26 @@ export class OwnerCards implements InteractionModule {
145
150
  ),
146
151
  ],
147
152
  footer: messages().cardApprovalFooter,
148
- }),
149
- ask: (title, question, signal) =>
150
- this.#post<OwnerAnswer | undefined>(
153
+ });
154
+ },
155
+ ask: async (title, question, signal) => {
156
+ const audience = await this.#audiences.question(scope);
157
+ if (!audience) return undefined;
158
+ return this.#post<OwnerAnswer | undefined>(
151
159
  channelId,
152
160
  signal,
153
161
  undefined,
154
162
  undefined,
155
163
  {
156
- audience: this.#asker(speaker),
164
+ audience,
157
165
  title,
158
166
  sections: [question.question],
159
167
  question,
160
168
  rows: (id, disabled) => askRows(id, question, disabled),
161
169
  footer: messages().cardQuestionFooter,
162
170
  },
163
- ),
171
+ );
172
+ },
164
173
  };
165
174
  }
166
175
 
@@ -213,7 +222,7 @@ export class OwnerCards implements InteractionModule {
213
222
  footer,
214
223
  },
215
224
  open.mention === true,
216
- card.audience.mentionId,
225
+ card.audience.mentions,
217
226
  );
218
227
  const abort = () => settle(cancelled, messages().cardStopped);
219
228
  const timer = setTimeout(
@@ -249,57 +258,22 @@ export class OwnerCards implements InteractionModule {
249
258
  }
250
259
 
251
260
  /** The card's panel; in a thread it leads with the mention of who may answer. */
252
- #panel(content: PanelContent, mention: boolean, userId: string): CardPayload {
261
+ #panel(
262
+ content: PanelContent,
263
+ mention: boolean,
264
+ userIds: readonly string[],
265
+ ): CardPayload {
253
266
  const panel = ownerPanel(content);
254
267
  if (!mention) return panel;
255
268
  return {
256
269
  ...panel,
257
270
  components: [
258
- new TextDisplayBuilder().setContent(`<@${userId}>`),
271
+ new TextDisplayBuilder().setContent(
272
+ userIds.map((id) => `<@${id}>`).join(" "),
273
+ ),
259
274
  ...panel.components,
260
275
  ],
261
- allowedMentions: { users: [userId] },
262
- };
263
- }
264
-
265
- /** An approval is for the speakers whose tier holds the tool, the owner alone by default. */
266
- #approvers(minTier: Tier, speaker: Speaker | undefined): Audience {
267
- const { ownerId, speakers } = this.#options;
268
- const mentionId =
269
- speaker && tierAtLeast(speaker.tier, minTier) ? speaker.id : ownerId;
270
- if (minTier === "owner" || !speakers)
271
- return {
272
- allows: (user) => user.id === ownerId,
273
- mentionId: ownerId,
274
- note: "",
275
- refusal: messages().cardOwnerOnly,
276
- };
277
- return {
278
- allows: (user) => {
279
- const who = speakers.resolve({ ...user, name: "" });
280
- return who !== undefined && tierAtLeast(who.tier, minTier);
281
- },
282
- mentionId,
283
- note: messages().cardApproversNote(minTier),
284
- refusal: messages().cardApproversRefusal(minTier),
285
- };
286
- }
287
-
288
- /** A question is for the speaker whose turn asks it, and for the owner. */
289
- #asker(speaker: Speaker | undefined): Audience {
290
- const { ownerId } = this.#options;
291
- if (!speaker || speaker.id === ownerId)
292
- return {
293
- allows: (user) => user.id === ownerId,
294
- mentionId: ownerId,
295
- note: "",
296
- refusal: messages().cardOwnerOnly,
297
- };
298
- return {
299
- allows: (user) => user.id === speaker.id || user.id === ownerId,
300
- mentionId: speaker.id,
301
- note: messages().cardAskerNote(speaker.id),
302
- refusal: messages().cardAskerRefusal,
276
+ allowedMentions: { users: [...userIds] },
303
277
  };
304
278
  }
305
279
 
@@ -324,10 +298,8 @@ export class OwnerCards implements InteractionModule {
324
298
  });
325
299
  return true;
326
300
  }
327
- const { member } = interaction;
328
- const roleIds =
329
- member && "cache" in member.roles ? [...member.roles.cache.keys()] : [];
330
- if (!card.audience.allows({ id: interaction.user.id, roleIds })) {
301
+ const user = discordUser(interaction);
302
+ if (!(await card.audience.allows(user))) {
331
303
  await interaction.reply({
332
304
  content: card.audience.refusal,
333
305
  flags: MessageFlags.Ephemeral,
@@ -343,7 +315,7 @@ export class OwnerCards implements InteractionModule {
343
315
  footer: outcome,
344
316
  },
345
317
  card.mention === true,
346
- card.audience.mentionId,
318
+ card.audience.mentions,
347
319
  );
348
320
  if (interaction.isButton() && (action === "yes" || action === "no")) {
349
321
  const approved = action === "yes";
@@ -7,7 +7,13 @@ import {
7
7
  SlashCommandSubcommandGroupBuilder,
8
8
  } from "discord.js";
9
9
  import { messages } from "../i18n/index.ts";
10
+ import type { IdentityService } from "../identity/identity-service.ts";
10
11
  import type { Logger } from "../log.ts";
12
+ import {
13
+ type DiscordActor,
14
+ DiscordOwners,
15
+ discordUser,
16
+ } from "./discord-owners.ts";
11
17
  import type {
12
18
  CommandGuard,
13
19
  CommandRoot,
@@ -33,31 +39,36 @@ export const groupOption = (
33
39
  ): RootOption => build(new SlashCommandSubcommandGroupBuilder()).toJSON();
34
40
 
35
41
  /**
36
- * Only the owner may use the root command, checked by user ID; every answer is visible only to them,
37
- * and a failure becomes a panel instead of a hanging interaction.
42
+ * Only an owner may use the root command: the primary owner by user ID, and, with the identity
43
+ * service, every other owner principal's Discord identity, checked again each time. Every answer
44
+ * is visible only to them, and a failure becomes a panel instead of a hanging interaction.
38
45
  */
39
- // pi-lens-ignore: large-class — two methods: the owner check and the failure panel
46
+ // pi-lens-ignore: large-class — three methods: the owner checks and the failure panel
40
47
  export class OwnerGuard implements CommandGuard {
41
- readonly #ownerId: string;
48
+ readonly #owners: DiscordOwners;
42
49
  readonly #logger: Logger;
43
50
  /** The name of the root command the guard serves, without the slash. */
44
51
  readonly root: string;
45
52
  readonly refusalHint: string | undefined;
46
53
 
47
54
  constructor(
48
- ownerId: string,
55
+ owners: DiscordOwners,
49
56
  logger: Logger,
50
57
  root: string,
51
58
  refusalHint?: string,
52
59
  ) {
53
- this.#ownerId = ownerId;
60
+ this.#owners = owners;
54
61
  this.#logger = logger;
55
62
  this.root = root;
56
63
  this.refusalHint = refusalHint;
57
64
  }
58
65
 
66
+ allows(actor: DiscordActor): Promise<boolean> {
67
+ return this.#owners.isOwner(discordUser(actor));
68
+ }
69
+
59
70
  isOwner(actor: { user: { id: string } }): boolean {
60
- return actor.user.id === this.#ownerId;
71
+ return actor.user.id === this.#owners.primary;
61
72
  }
62
73
 
63
74
  /** Runs a handler; failures become a panel instead of a hanging interaction. */
@@ -82,8 +93,10 @@ export class OwnerGuard implements CommandGuard {
82
93
 
83
94
  /** What `commandGuard` needs. */
84
95
  export interface CommandGuardOptions {
85
- /** The one user who may use the owner's commands. */
96
+ /** The primary owner's Discord user id, who may always use the owner's commands. */
86
97
  ownerId: string;
98
+ /** Which other owners there are and whether they still are; without it only `ownerId` may. */
99
+ identity?: Pick<IdentityService, "resolve" | "owners" | "identities">;
87
100
  /** The name of the root command the guard serves, without the slash. */
88
101
  root: string;
89
102
  /** Where a failing command is logged. */
@@ -94,9 +107,10 @@ export interface CommandGuardOptions {
94
107
 
95
108
  /** A guard for the owner's commands, such as the one a test hands a module; the Discord plugin makes its own. */
96
109
  export function commandGuard(options: CommandGuardOptions): CommandGuard {
110
+ const { ownerId, identity, logger } = options;
97
111
  return new OwnerGuard(
98
- options.ownerId,
99
- options.logger,
112
+ new DiscordOwners({ ownerId, ...(identity ? { identity } : {}), logger }),
113
+ logger,
100
114
  options.root,
101
115
  options.refusalHint,
102
116
  );
@@ -121,7 +135,7 @@ const refusal = (hint: string | undefined) =>
121
135
  sections: [`${messages().ownerRefusalBody}${hint ?? ""}`],
122
136
  });
123
137
 
124
- /** Answers a feature's root subcommands and components, for the owner only. */
138
+ /** Answers a feature's root subcommands and components, for the owners only. */
125
139
  export function ownerCommandModule(
126
140
  guard: CommandGuard,
127
141
  handlers: OwnerCommandHandlers,
@@ -139,14 +153,14 @@ export function ownerCommandModule(
139
153
  async handle(interaction) {
140
154
  if (interaction.isAutocomplete()) {
141
155
  if (!owned(interaction)) return false;
142
- if (!guard.isOwner(interaction)) await interaction.respond([]);
156
+ if (!(await guard.allows(interaction))) await interaction.respond([]);
143
157
  else await handlers.autocomplete?.(interaction);
144
158
  return true;
145
159
  }
146
160
  if (await handlers.component?.(interaction)) return true;
147
161
  if (!interaction.isChatInputCommand() || !owned(interaction))
148
162
  return false;
149
- if (!guard.isOwner(interaction)) {
163
+ if (!(await guard.allows(interaction))) {
150
164
  await interaction.reply(refusal(guard.refusalHint));
151
165
  return true;
152
166
  }