pi-roundtable 0.8.0 → 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 (150) hide show
  1. package/CHANGELOG.md +102 -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 +606 -78
  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 +140 -85
  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 +78 -41
  44. package/src/core/contract/channel-context.ts +53 -0
  45. package/src/core/contract/channels.ts +77 -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 +41 -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/host.ts +27 -8
  69. package/src/core/i18n/cards.ts +8 -4
  70. package/src/core/identity/access-policy.ts +227 -0
  71. package/src/core/identity/actor-facts.ts +65 -0
  72. package/src/core/identity/caching-principal-store.ts +111 -0
  73. package/src/core/identity/config-owner.ts +145 -0
  74. package/src/core/identity/contact.ts +27 -0
  75. package/src/core/identity/identity-plugin.ts +56 -0
  76. package/src/core/identity/identity-schema.ts +282 -0
  77. package/src/core/identity/identity-service.ts +489 -0
  78. package/src/core/identity/identity-view.ts +77 -0
  79. package/src/core/identity/plugin-identities.ts +109 -0
  80. package/src/core/identity/principal-reads.ts +38 -0
  81. package/src/core/identity/principal-store.ts +478 -0
  82. package/src/core/identity/principal-tables.ts +53 -0
  83. package/src/core/identity/seen-throttle.ts +57 -0
  84. package/src/core/identity/ulid.ts +36 -0
  85. package/src/core/identity.ts +34 -5
  86. package/src/core/interactions/prompts.ts +127 -0
  87. package/src/core/modules/background/background-turns.ts +56 -17
  88. package/src/core/modules/background/personal-target.ts +50 -0
  89. package/src/core/modules/delegation/delegate.ts +28 -14
  90. package/src/core/modules/delegation/delegator.ts +23 -7
  91. package/src/core/modules/memory/owner-memory.ts +123 -50
  92. package/src/core/modules/notify/notify.ts +53 -13
  93. package/src/core/modules/notify/session-notify.ts +110 -0
  94. package/src/core/modules/schedules/prechecks.ts +8 -2
  95. package/src/core/modules/schedules/schedule-prechecks.ts +4 -1
  96. package/src/core/modules/schedules/schedule-store.ts +51 -20
  97. package/src/core/modules/schedules/schedule-tools.ts +122 -12
  98. package/src/core/modules/schedules/scheduler.ts +43 -2
  99. package/src/core/modules/schedules/schedules.ts +43 -14
  100. package/src/core/plugin.ts +48 -0
  101. package/src/core/presence/direct-channels.ts +104 -0
  102. package/src/core/registry/contributions.ts +63 -3
  103. package/src/core/routing/channel-router.ts +218 -17
  104. package/src/core/routing/conversation-turns.ts +38 -8
  105. package/src/core/routing/surface-port.ts +32 -2
  106. package/src/core/routing/system-turns.ts +29 -0
  107. package/src/core/runtime/bridge-guard.ts +225 -0
  108. package/src/core/runtime/compaction-tiers.ts +5 -1
  109. package/src/core/runtime/conversation-sessions.ts +124 -12
  110. package/src/core/runtime/extensions/ask-user.ts +1 -1
  111. package/src/core/runtime/extensions/confirmation-gate.ts +28 -14
  112. package/src/core/runtime/extensions/private-memory.ts +447 -0
  113. package/src/core/runtime/pending-confirmation-store.ts +30 -5
  114. package/src/core/runtime/pi-agent-runtime.ts +135 -134
  115. package/src/core/runtime/preflight-tools.ts +47 -0
  116. package/src/core/runtime/prompt-slot.ts +4 -4
  117. package/src/core/runtime/reader-history.ts +129 -0
  118. package/src/core/runtime/runtime-plugin.ts +50 -5
  119. package/src/core/runtime/runtime-types.ts +40 -12
  120. package/src/core/runtime/session-conversation.ts +143 -0
  121. package/src/core/runtime/session-factory.ts +115 -15
  122. package/src/core/runtime/session-scope.ts +78 -0
  123. package/src/core/runtime/steerable-run.ts +9 -0
  124. package/src/core/runtime/tool-call-scope.ts +43 -0
  125. package/src/core/runtime/turn-answer.ts +30 -1
  126. package/src/core/services.ts +27 -1
  127. package/src/core/sessions.ts +30 -0
  128. package/src/core/speakers.ts +21 -19
  129. package/src/core/testing/background.ts +31 -0
  130. package/src/core/testing/fixture-database.ts +55 -0
  131. package/src/core/testing/map-identity.ts +76 -0
  132. package/src/core/testing/module-sessions.ts +134 -0
  133. package/src/core/testing/modules.ts +108 -21
  134. package/src/core/testing/owner.ts +17 -0
  135. package/src/core/testing/surface-contract.ts +118 -41
  136. package/src/core/testing/test-host.ts +79 -16
  137. package/src/core/testing/tool-set.ts +32 -0
  138. package/src/core/tool-set.snapshot.json +4 -4
  139. package/src/core/tool-tiers.ts +57 -3
  140. package/src/discord/index.ts +8 -0
  141. package/src/index.ts +47 -8
  142. package/src/kit/compaction.ts +13 -0
  143. package/src/kit/index.ts +10 -0
  144. package/src/kit/support.ts +1 -1
  145. package/src/testing.ts +55 -16
  146. package/templates/.env.example +1 -1
  147. package/templates/adapters/web/README.md +7 -0
  148. package/templates/adapters/web/roundtable.config.ts +14 -10
  149. package/templates/roundtable.config.ts +12 -1
  150. package/src/core/domain/owner-prompts.ts +0 -45
@@ -11,9 +11,17 @@ export type ChannelInfo =
11
11
 
12
12
  /** The Discord connection as plugins use it, apart from the chat surface the host drives. */
13
13
  export interface DiscordConnection {
14
- /** The owner's direct-message channel. */
14
+ /**
15
+ * The primary owner's direct-message channel.
16
+ * @deprecated Since 0.9 there may be more than one owner; this is the primary owner's, kept for
17
+ * plugins written for 0.8. Reach a principal through their own channel instead. Goes away in 1.0.
18
+ */
15
19
  ownerChannel(): Promise<ChannelKey>;
16
- /** Sends the owner a direct message. */
20
+ /**
21
+ * Sends the primary owner a direct message.
22
+ * @deprecated Since 0.9 there may be more than one owner; this is the primary owner's, kept for
23
+ * plugins written for 0.8. Goes away in 1.0.
24
+ */
17
25
  notifyOwner(text: string): Promise<void>;
18
26
  /**
19
27
  * What a channel is called: undefined when Discord no longer knows it; throws while the
@@ -30,6 +38,10 @@ export interface DiscordConnection {
30
38
  agentDashboard(guildId: string): DashboardBoard;
31
39
  /** Threads for background dispatches, in the channel that started each. */
32
40
  threadHost(): ThreadHost;
33
- /** Discord reading and management for the owner's agent; calls fail until the connection is ready. */
41
+ /**
42
+ * Discord reading and management for the owner's agent, checked against what the primary owner
43
+ * may do on the server, whichever owner's turn calls them; calls fail until the connection is
44
+ * ready.
45
+ */
34
46
  ownerOperations(): OwnerOperations;
35
47
  }
@@ -0,0 +1,54 @@
1
+ import type { ChannelKey } from "../domain/conversation.ts";
2
+ import type { IdentityService } from "../identity/identity-service.ts";
3
+ import type { DirectChannelProvider } from "../presence/direct-channels.ts";
4
+
5
+ export interface DiscordDirectChannelOptions {
6
+ /** A Discord user's direct-message channel; throws when Discord cannot be reached. */
7
+ directChannel(userId: string): Promise<ChannelKey>;
8
+ /** Sends a Discord user a direct message. */
9
+ sendDirect(userId: string, text: string): Promise<void>;
10
+ /**
11
+ * Whom a principal is on Discord, by the Discord identities linked to them. Without it only the
12
+ * primary owner is reached, whose principal 0.8 knew by their Discord user id.
13
+ */
14
+ identity?: Pick<IdentityService, "identities">;
15
+ /**
16
+ * The primary owner's Discord user id: of a principal's Discord identities, the one reached
17
+ * first, so a single owner is reached in the direct messages 0.8 used.
18
+ */
19
+ ownerId: string;
20
+ }
21
+
22
+ /**
23
+ * Reaches a person in their Discord direct messages, through a Discord identity linked to their
24
+ * principal; a principal with none is not reached here.
25
+ */
26
+ export function discordDirectChannel(
27
+ options: DiscordDirectChannelOptions,
28
+ ): DirectChannelProvider {
29
+ const { identity, ownerId } = options;
30
+ const userOf = async (principalId: string): Promise<string | undefined> => {
31
+ if (!identity) return principalId === ownerId ? ownerId : undefined;
32
+ const users = (await identity.identities(principalId))
33
+ .filter((link) => link.provider === "discord")
34
+ .map((link) => link.subject);
35
+ return users.includes(ownerId) ? ownerId : users[0];
36
+ };
37
+ return {
38
+ name: "discord",
39
+ label: "a direct message on Discord",
40
+ knows: async (principalId) => (await userOf(principalId)) !== undefined,
41
+ reaches: async (principalId) => {
42
+ const user = await userOf(principalId);
43
+ return user === undefined ? undefined : options.directChannel(user);
44
+ },
45
+ deliver: async (principalId, text) => {
46
+ const user = await userOf(principalId);
47
+ if (user === undefined)
48
+ throw new Error(
49
+ `principal ${principalId} has no Discord identity to send a direct message to`,
50
+ );
51
+ await options.sendDirect(user, text);
52
+ },
53
+ };
54
+ }
@@ -0,0 +1,133 @@
1
+ import type { IdentityService } from "../identity/identity-service.ts";
2
+ import type { Logger } from "../log.ts";
3
+ import { type Tier, tierAtLeast } from "../speakers.ts";
4
+
5
+ /** A Discord user who pressed or ran something; their roles are unknown where the interaction carries no member. */
6
+ export interface DiscordUser {
7
+ id: string;
8
+ name: string;
9
+ roleIds?: readonly string[];
10
+ }
11
+
12
+ /** What a Discord interaction tells of who made it; a test may give only `user.id`. */
13
+ export interface DiscordActor {
14
+ user: { id: string; username?: string; globalName?: string | null };
15
+ member?: unknown;
16
+ }
17
+
18
+ /** The user of an interaction, with their roles where its member carries them. */
19
+ export function discordUser(actor: DiscordActor): DiscordUser {
20
+ const { user, member } = actor;
21
+ const roles =
22
+ typeof member === "object" && member !== null && "roles" in member
23
+ ? member.roles
24
+ : undefined;
25
+ const roleIds =
26
+ typeof roles === "object" &&
27
+ roles !== null &&
28
+ "cache" in roles &&
29
+ roles.cache instanceof Map
30
+ ? [...roles.cache.keys()].map(String)
31
+ : undefined;
32
+ return {
33
+ id: user.id,
34
+ name: user.globalName ?? user.username ?? user.id,
35
+ ...(roleIds ? { roleIds } : {}),
36
+ };
37
+ }
38
+
39
+ export interface DiscordOwnersOptions {
40
+ /** The primary owner's Discord user id, an owner whatever the identity service says. */
41
+ ownerId: string;
42
+ /** Who holds a tier and which owners have a Discord identity; without it the primary owner is the only owner. */
43
+ identity?: Pick<IdentityService, "resolve" | "owners" | "identities">;
44
+ logger: Logger;
45
+ }
46
+
47
+ /**
48
+ * The owners as Discord knows them: the primary owner by their user id, and every other owner
49
+ * principal's Discord identities, each checked again as they act. Only an owner's own identity is
50
+ * ever resolved, so someone else's press or command admits no one.
51
+ */
52
+ export class DiscordOwners {
53
+ readonly #options: DiscordOwnersOptions;
54
+
55
+ constructor(options: DiscordOwnersOptions) {
56
+ this.#options = options;
57
+ }
58
+
59
+ /** The primary owner's Discord user id. */
60
+ get primary(): string {
61
+ return this.#options.ownerId;
62
+ }
63
+
64
+ /** The primary owner, then every other owner's Discord user ids. */
65
+ async ids(): Promise<string[]> {
66
+ const { ownerId, identity, logger } = this.#options;
67
+ if (!identity) return [ownerId];
68
+ const ids = new Set([ownerId]);
69
+ try {
70
+ for (const owner of await identity.owners())
71
+ for (const link of await identity.identities(owner.id))
72
+ if (link.provider === "discord") ids.add(link.subject);
73
+ } catch (error) {
74
+ logger.warn(
75
+ { err: error },
76
+ "could not read the owners' identities; only the primary owner counts",
77
+ );
78
+ }
79
+ return [...ids];
80
+ }
81
+
82
+ /** Whether the user is an owner now: the primary owner, or another owner's Discord identity at the owner tier. */
83
+ async isOwner(user: DiscordUser): Promise<boolean> {
84
+ if (user.id === this.#options.ownerId) return true;
85
+ if (!(await this.ids()).includes(user.id)) return false;
86
+ return this.holds(user, "owner");
87
+ }
88
+
89
+ /** Whether the principal is an owner's; false when it cannot be read. */
90
+ async isOwnerPrincipal(principalId: string): Promise<boolean> {
91
+ const { identity, logger } = this.#options;
92
+ if (!identity) return false;
93
+ try {
94
+ const owners = await identity.owners();
95
+ return owners.some((owner) => owner.id === principalId);
96
+ } catch (error) {
97
+ logger.warn(
98
+ { principal: principalId, err: error },
99
+ "could not read the owners; the principal is not counted as one",
100
+ );
101
+ return false;
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Whether the user holds `minTier` as they act, as the principal named when there is one;
107
+ * without `minTier` anyone does. Call it only for someone already known, so it admits no one.
108
+ */
109
+ async holds(
110
+ user: DiscordUser,
111
+ minTier: Tier | undefined,
112
+ principalId?: string,
113
+ ): Promise<boolean> {
114
+ const { identity } = this.#options;
115
+ if (!minTier) return true;
116
+ if (!identity) return false;
117
+ const who = await identity.resolve({
118
+ provider: "discord",
119
+ subject: user.id,
120
+ name: user.name,
121
+ surface: "discord",
122
+ ...(user.roleIds
123
+ ? { roles: user.roleIds.map((role) => `discord:role:${role}`) }
124
+ : {}),
125
+ legacyId: user.id,
126
+ });
127
+ return (
128
+ who !== undefined &&
129
+ (principalId === undefined || who.principalId === principalId) &&
130
+ tierAtLeast(who.tier, minTier)
131
+ );
132
+ }
133
+ }
@@ -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);