pi-roundtable 0.1.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 (315) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +140 -0
  4. package/docs/plugins.md +924 -0
  5. package/examples/channels.test.ts +13 -0
  6. package/examples/channels.ts +27 -0
  7. package/examples/dashboard.test.ts +11 -0
  8. package/examples/dashboard.ts +9 -0
  9. package/examples/events.test.ts +26 -0
  10. package/examples/events.ts +25 -0
  11. package/examples/guide.test.ts +52 -0
  12. package/examples/holds.test.ts +16 -0
  13. package/examples/holds.ts +30 -0
  14. package/examples/http.test.ts +12 -0
  15. package/examples/http.ts +17 -0
  16. package/examples/interactions.test.ts +25 -0
  17. package/examples/interactions.ts +28 -0
  18. package/examples/migrations.test.ts +31 -0
  19. package/examples/migrations.ts +42 -0
  20. package/examples/packages.test.ts +9 -0
  21. package/examples/packages.ts +9 -0
  22. package/examples/preflight.test.ts +11 -0
  23. package/examples/preflight.ts +19 -0
  24. package/examples/prompt.test.ts +25 -0
  25. package/examples/prompt.ts +20 -0
  26. package/examples/providers.test.ts +10 -0
  27. package/examples/providers.ts +17 -0
  28. package/examples/seeds.test.ts +12 -0
  29. package/examples/seeds.ts +18 -0
  30. package/examples/selection.test.ts +13 -0
  31. package/examples/selection.ts +14 -0
  32. package/examples/services.test.ts +20 -0
  33. package/examples/services.ts +30 -0
  34. package/examples/session-tools.test.ts +24 -0
  35. package/examples/session-tools.ts +34 -0
  36. package/examples/tools.test.ts +18 -0
  37. package/examples/tools.ts +28 -0
  38. package/package.json +55 -0
  39. package/src/cli/add-plugin.test.ts +107 -0
  40. package/src/cli/add-plugin.ts +75 -0
  41. package/src/cli/checks/basic.test.ts +276 -0
  42. package/src/cli/checks/bun.ts +26 -0
  43. package/src/cli/checks/configuration.ts +28 -0
  44. package/src/cli/checks/database.test.ts +117 -0
  45. package/src/cli/checks/database.ts +100 -0
  46. package/src/cli/checks/discord.test.ts +200 -0
  47. package/src/cli/checks/discord.ts +218 -0
  48. package/src/cli/checks/environment.ts +27 -0
  49. package/src/cli/checks/model.ts +28 -0
  50. package/src/cli/checks/public-url.ts +40 -0
  51. package/src/cli/cli.test.ts +122 -0
  52. package/src/cli/cli.ts +166 -0
  53. package/src/cli/config-edit.test.ts +130 -0
  54. package/src/cli/config-edit.ts +199 -0
  55. package/src/cli/discord-api.ts +96 -0
  56. package/src/cli/doctor.test.ts +184 -0
  57. package/src/cli/doctor.ts +92 -0
  58. package/src/cli/http.ts +23 -0
  59. package/src/cli/init.test.ts +127 -0
  60. package/src/cli/init.ts +82 -0
  61. package/src/cli/main.ts +13 -0
  62. package/src/cli/project.ts +123 -0
  63. package/src/cli/report.ts +68 -0
  64. package/src/cli/roundtable.mjs +22 -0
  65. package/src/cli/runtime.ts +53 -0
  66. package/src/cli/size.test.ts +9 -0
  67. package/src/cli/start.ts +33 -0
  68. package/src/cli/templates.test.ts +129 -0
  69. package/src/cli/templates.ts +119 -0
  70. package/src/cli/testing/fixtures.ts +106 -0
  71. package/src/core/agents/agent-claim.test.ts +142 -0
  72. package/src/core/agents/agent-claim.ts +170 -0
  73. package/src/core/agents/agent-dashboard.test.ts +120 -0
  74. package/src/core/agents/agent-dashboard.ts +185 -0
  75. package/src/core/agents/agent-guild.test.ts +124 -0
  76. package/src/core/agents/agent-messages.ts +189 -0
  77. package/src/core/agents/agent-ports.ts +103 -0
  78. package/src/core/agents/agent-prompt.ts +163 -0
  79. package/src/core/agents/agent-rules.ts +106 -0
  80. package/src/core/agents/agent-schema.ts +115 -0
  81. package/src/core/agents/agent-settings.ts +49 -0
  82. package/src/core/agents/agent-store.test.ts +205 -0
  83. package/src/core/agents/agent-store.ts +384 -0
  84. package/src/core/agents/agent-team.ts +445 -0
  85. package/src/core/agents/agent-tools.ts +386 -0
  86. package/src/core/agents/avatar-studio.test.ts +88 -0
  87. package/src/core/agents/avatar-studio.ts +147 -0
  88. package/src/core/agents/group-messages.ts +99 -0
  89. package/src/core/agents/group-round.test.ts +143 -0
  90. package/src/core/agents/group-round.ts +117 -0
  91. package/src/core/agents/group-turns.ts +132 -0
  92. package/src/core/agents/owner-identity.test.ts +172 -0
  93. package/src/core/agents/team-editing.ts +205 -0
  94. package/src/core/agents/team-keys.ts +40 -0
  95. package/src/core/agents/team-layout.ts +135 -0
  96. package/src/core/agents/team-lifecycle.ts +188 -0
  97. package/src/core/agents/team-options.ts +52 -0
  98. package/src/core/agents/team-status.ts +80 -0
  99. package/src/core/agents/team-text.ts +60 -0
  100. package/src/core/agents/team-turn-types.ts +107 -0
  101. package/src/core/agents/team-turns.test.ts +278 -0
  102. package/src/core/agents/team-turns.ts +323 -0
  103. package/src/core/assets/neutral.png +0 -0
  104. package/src/core/assets/prompts/shared-guest.md +11 -0
  105. package/src/core/assets/prompts/shared.md +11 -0
  106. package/src/core/assets/skills/writing-skills/SKILL.md +21 -0
  107. package/src/core/attachments/attachment-dir.ts +15 -0
  108. package/src/core/attachments/attachment-fetcher.ts +83 -0
  109. package/src/core/attachments/attachments.test.ts +85 -0
  110. package/src/core/attachments/image-prep.ts +50 -0
  111. package/src/core/attachments/prompt-block.ts +37 -0
  112. package/src/core/attachments/turn-attachments.ts +79 -0
  113. package/src/core/boundary.test.ts +45 -0
  114. package/src/core/builtin/agent-server.ts +275 -0
  115. package/src/core/builtin/discord.ts +95 -0
  116. package/src/core/builtin/modules.test.ts +127 -0
  117. package/src/core/builtin/modules.ts +176 -0
  118. package/src/core/builtin/seeds.ts +10 -0
  119. package/src/core/builtin/stores.ts +42 -0
  120. package/src/core/config/config.test.ts +135 -0
  121. package/src/core/config/config.ts +285 -0
  122. package/src/core/config/schema.ts +181 -0
  123. package/src/core/contract/channels.ts +152 -0
  124. package/src/core/contract/discord.ts +33 -0
  125. package/src/core/contract/providers.ts +67 -0
  126. package/src/core/db/guild-scope.ts +53 -0
  127. package/src/core/db/migrations.test.ts +237 -0
  128. package/src/core/db/migrations.ts +37 -0
  129. package/src/core/define-roundtable.test.ts +134 -0
  130. package/src/core/define-roundtable.ts +195 -0
  131. package/src/core/define.test.ts +144 -0
  132. package/src/core/define.ts +154 -0
  133. package/src/core/discord/agent-commands.test.ts +58 -0
  134. package/src/core/discord/agent-commands.ts +385 -0
  135. package/src/core/discord/agent-discord.test.ts +70 -0
  136. package/src/core/discord/agent-discord.ts +367 -0
  137. package/src/core/discord/channel-executor.ts +404 -0
  138. package/src/core/discord/channel-operations.ts +329 -0
  139. package/src/core/discord/discord-surface.ts +431 -0
  140. package/src/core/discord/dispatch-thread-host.test.ts +114 -0
  141. package/src/core/discord/dispatch-thread-host.ts +72 -0
  142. package/src/core/discord/dispatch-threads.test.ts +94 -0
  143. package/src/core/discord/dispatch-threads.ts +180 -0
  144. package/src/core/discord/interaction-module.ts +1 -0
  145. package/src/core/discord/owner-cards.test.ts +435 -0
  146. package/src/core/discord/owner-cards.ts +463 -0
  147. package/src/core/discord/owner-command.ts +126 -0
  148. package/src/core/discord/owner-discord-access.ts +183 -0
  149. package/src/core/discord/owner-discord-threads.ts +160 -0
  150. package/src/core/discord/owner-discord.test.ts +325 -0
  151. package/src/core/discord/owner-discord.ts +282 -0
  152. package/src/core/discord/owner-panel.ts +103 -0
  153. package/src/core/discord/schedule-commands.ts +149 -0
  154. package/src/core/discord/stop-button.ts +20 -0
  155. package/src/core/domain/attachment.ts +39 -0
  156. package/src/core/domain/conversation.ts +42 -0
  157. package/src/core/domain/errors.ts +31 -0
  158. package/src/core/domain/expression.ts +21 -0
  159. package/src/core/domain/owner-prompts.ts +45 -0
  160. package/src/core/domain/ports.ts +91 -0
  161. package/src/core/domain/profile.ts +32 -0
  162. package/src/core/drain.test.ts +46 -0
  163. package/src/core/drain.ts +33 -0
  164. package/src/core/errors.ts +29 -0
  165. package/src/core/events.test.ts +81 -0
  166. package/src/core/events.ts +55 -0
  167. package/src/core/holds.test.ts +54 -0
  168. package/src/core/holds.ts +46 -0
  169. package/src/core/host.test.ts +536 -0
  170. package/src/core/host.ts +297 -0
  171. package/src/core/http/listeners.test.ts +164 -0
  172. package/src/core/http/listeners.ts +151 -0
  173. package/src/core/i18n/agent-panel.ts +96 -0
  174. package/src/core/i18n/cards.ts +64 -0
  175. package/src/core/i18n/channels.ts +28 -0
  176. package/src/core/i18n/conversation.ts +49 -0
  177. package/src/core/i18n/dashboard.ts +72 -0
  178. package/src/core/i18n/discord.ts +23 -0
  179. package/src/core/i18n/en.ts +27 -0
  180. package/src/core/i18n/i18n.test.ts +139 -0
  181. package/src/core/i18n/index.ts +58 -0
  182. package/src/core/i18n/owner.ts +35 -0
  183. package/src/core/i18n/schedules.ts +92 -0
  184. package/src/core/i18n/time-zones.ts +27 -0
  185. package/src/core/i18n/types.ts +7 -0
  186. package/src/core/i18n/zh-tw.ts +26 -0
  187. package/src/core/identity.test.ts +18 -0
  188. package/src/core/identity.ts +31 -0
  189. package/src/core/judging/confirmation-judge.ts +68 -0
  190. package/src/core/judging/effort-judge.test.ts +112 -0
  191. package/src/core/judging/effort-judge.ts +158 -0
  192. package/src/core/judging/model-judge.test.ts +126 -0
  193. package/src/core/judging/model-judge.ts +193 -0
  194. package/src/core/log.ts +45 -0
  195. package/src/core/models.ts +52 -0
  196. package/src/core/modules/background/background-turns.ts +78 -0
  197. package/src/core/modules/delegation/delegate.ts +54 -0
  198. package/src/core/modules/delegation/delegator.test.ts +170 -0
  199. package/src/core/modules/delegation/delegator.ts +191 -0
  200. package/src/core/modules/delegation/sol-worker.ts +78 -0
  201. package/src/core/modules/discord-admin/discord-admin.ts +288 -0
  202. package/src/core/modules/host-shell/shell-policy.ts +386 -0
  203. package/src/core/modules/memory/owner-memory-store.test.ts +244 -0
  204. package/src/core/modules/memory/owner-memory-store.ts +228 -0
  205. package/src/core/modules/memory/owner-memory.ts +153 -0
  206. package/src/core/modules/notify/notify.ts +26 -0
  207. package/src/core/modules/schedules/recurrence.ts +206 -0
  208. package/src/core/modules/schedules/schedule-store.ts +213 -0
  209. package/src/core/modules/schedules/schedule-tools.ts +196 -0
  210. package/src/core/modules/schedules/schedule.test.ts +358 -0
  211. package/src/core/modules/schedules/scheduler.ts +121 -0
  212. package/src/core/modules/schedules/schedules.ts +87 -0
  213. package/src/core/modules/skills/repo-name.ts +11 -0
  214. package/src/core/modules/skills/skill-kind.test.ts +55 -0
  215. package/src/core/modules/skills/skill-link.ts +69 -0
  216. package/src/core/modules/skills/skill-listing.ts +128 -0
  217. package/src/core/modules/skills/skill-registry.test.ts +376 -0
  218. package/src/core/modules/skills/skill-registry.ts +323 -0
  219. package/src/core/modules/skills/skill-rules.ts +120 -0
  220. package/src/core/modules/skills/skill-store.ts +285 -0
  221. package/src/core/modules/skills/skill-tools.ts +207 -0
  222. package/src/core/ops/error-reporter.test.ts +283 -0
  223. package/src/core/ops/error-reporter.ts +271 -0
  224. package/src/core/plugin.ts +200 -0
  225. package/src/core/presentation/card-cadence.ts +41 -0
  226. package/src/core/presentation/headline.ts +34 -0
  227. package/src/core/presentation/presentation.test.ts +166 -0
  228. package/src/core/presentation/quiet-links.ts +36 -0
  229. package/src/core/presentation/reply-splitter.ts +110 -0
  230. package/src/core/presentation/thinking-line.ts +24 -0
  231. package/src/core/public-entry.test.ts +59 -0
  232. package/src/core/registry/contributions.test.ts +215 -0
  233. package/src/core/registry/contributions.ts +249 -0
  234. package/src/core/registry/interactions.test.ts +87 -0
  235. package/src/core/registry/interactions.ts +55 -0
  236. package/src/core/registry/providers.test.ts +103 -0
  237. package/src/core/registry/providers.ts +55 -0
  238. package/src/core/routing/channel-queue.test.ts +31 -0
  239. package/src/core/routing/channel-queue.ts +44 -0
  240. package/src/core/routing/channel-router.test.ts +326 -0
  241. package/src/core/routing/channel-router.ts +157 -0
  242. package/src/core/routing/conversation-kind.ts +14 -0
  243. package/src/core/routing/forward-join.ts +65 -0
  244. package/src/core/routing/message-text.ts +16 -0
  245. package/src/core/routing/settle-turn.test.ts +21 -0
  246. package/src/core/routing/settle-turn.ts +26 -0
  247. package/src/core/runtime/compaction-tiers.test.ts +227 -0
  248. package/src/core/runtime/compaction-tiers.ts +203 -0
  249. package/src/core/runtime/conversation-sessions.ts +162 -0
  250. package/src/core/runtime/extensions/agent-prompt.ts +16 -0
  251. package/src/core/runtime/extensions/ask-user.test.ts +114 -0
  252. package/src/core/runtime/extensions/ask-user.ts +98 -0
  253. package/src/core/runtime/extensions/confirmation-gate.ts +247 -0
  254. package/src/core/runtime/extensions/self-compact-guard.test.ts +50 -0
  255. package/src/core/runtime/extensions/self-compact-guard.ts +23 -0
  256. package/src/core/runtime/mcp.ts +23 -0
  257. package/src/core/runtime/pending-confirmation-store.test.ts +52 -0
  258. package/src/core/runtime/pending-confirmation-store.ts +75 -0
  259. package/src/core/runtime/pi-agent-runtime.ts +477 -0
  260. package/src/core/runtime/prompt-slot.ts +81 -0
  261. package/src/core/runtime/runtime-types.ts +150 -0
  262. package/src/core/runtime/session-archive.test.ts +20 -0
  263. package/src/core/runtime/session-archive.ts +17 -0
  264. package/src/core/runtime/session-factory.ts +291 -0
  265. package/src/core/runtime/steerable-run.test.ts +264 -0
  266. package/src/core/runtime/steerable-run.ts +109 -0
  267. package/src/core/runtime/text-tools.test.ts +110 -0
  268. package/src/core/runtime/text-tools.ts +66 -0
  269. package/src/core/runtime/turn-answer.test.ts +72 -0
  270. package/src/core/runtime/turn-answer.ts +79 -0
  271. package/src/core/runtime/worker-task.test.ts +82 -0
  272. package/src/core/runtime/worker-task.ts +65 -0
  273. package/src/core/services.test.ts +28 -0
  274. package/src/core/services.ts +107 -0
  275. package/src/core/sessions.test.ts +85 -0
  276. package/src/core/sessions.ts +149 -0
  277. package/src/core/shared/attachment-reader.ts +91 -0
  278. package/src/core/shared/delegate-tool.ts +15 -0
  279. package/src/core/shared/mcp-adapter.ts +58 -0
  280. package/src/core/shared/package-dir.ts +9 -0
  281. package/src/core/shared/profile-tools.ts +16 -0
  282. package/src/core/shared/read-attachment-tool.ts +43 -0
  283. package/src/core/shared/schedule-tools.ts +118 -0
  284. package/src/core/shared/session-messages.ts +50 -0
  285. package/src/core/shared/tool-result.ts +9 -0
  286. package/src/core/shared/unix-server.ts +18 -0
  287. package/src/core/size.test.ts +9 -0
  288. package/src/core/speakers.test.ts +78 -0
  289. package/src/core/speakers.ts +143 -0
  290. package/src/core/testing/database.ts +52 -0
  291. package/src/core/testing/file-size.ts +34 -0
  292. package/src/core/testing/locale.ts +8 -0
  293. package/src/core/testing/modules.ts +97 -0
  294. package/src/core/testing/owner.ts +15 -0
  295. package/src/core/testing/thread-host.ts +60 -0
  296. package/src/core/time.test.ts +166 -0
  297. package/src/core/time.ts +67 -0
  298. package/src/core/tool-tiers.test.ts +117 -0
  299. package/src/core/tool-tiers.ts +127 -0
  300. package/src/entries.test.ts +112 -0
  301. package/src/index.ts +28 -0
  302. package/src/testing.test.ts +156 -0
  303. package/src/testing.ts +182 -0
  304. package/templates/.env.example +22 -0
  305. package/templates/README.md +11 -0
  306. package/templates/_gitignore +3 -0
  307. package/templates/agents.ts +12 -0
  308. package/templates/biome.json.tmpl +7 -0
  309. package/templates/docker-compose.yml +16 -0
  310. package/templates/package.json.tmpl +24 -0
  311. package/templates/persona/shared.md +2 -0
  312. package/templates/plugin/plugin.test.ts.tmpl +13 -0
  313. package/templates/plugin/plugin.ts +18 -0
  314. package/templates/roundtable.config.ts +24 -0
  315. package/templates/tsconfig.json.tmpl +16 -0
@@ -0,0 +1,431 @@
1
+ import {
2
+ AttachmentBuilder,
3
+ ChannelType,
4
+ Client,
5
+ DiscordAPIError,
6
+ Events,
7
+ GatewayIntentBits,
8
+ type Interaction,
9
+ type Message,
10
+ MessageFlags,
11
+ MessageReferenceType,
12
+ Partials,
13
+ } from "discord.js";
14
+ import type { AttachmentRef } from "../domain/attachment.ts";
15
+ import type {
16
+ ChannelKey,
17
+ InboundMessage,
18
+ OutboundReply,
19
+ } from "../domain/conversation.ts";
20
+ import type { ChatSurface, OwnerNotifier } from "../domain/ports.ts";
21
+ import { messages } from "../i18n/index.ts";
22
+ import type { Logger } from "../log.ts";
23
+ import { splitReply } from "../presentation/reply-splitter.ts";
24
+ import type { ComposedInteractions } from "../registry/interactions.ts";
25
+ import { DiscordAgentChannels, DiscordDashboard } from "./agent-discord.ts";
26
+ import { discordChannelExecutor } from "./channel-executor.ts";
27
+ import type { ChannelExecutor } from "./channel-operations.ts";
28
+ import { DiscordThreadHost } from "./dispatch-thread-host.ts";
29
+ import type { CardChannel } from "./owner-cards.ts";
30
+ import { DiscordOwnerOps } from "./owner-discord.ts";
31
+ import { stopPanel } from "./stop-button.ts";
32
+
33
+ const PREFIX = "discord:";
34
+ /** Discord's error code for a channel that does not exist or cannot be seen. */
35
+ const UNKNOWN_CHANNEL = 10003;
36
+
37
+ export type ChannelInfo =
38
+ | { kind: "dm"; name: string }
39
+ | { kind: "guild"; name: string; guild: string; guildId: string };
40
+ const TYPING_REFRESH_MS = 8_000;
41
+ /** A turn shorter than this shows no stop button, so quick answers do not flicker one. */
42
+ const STOP_PANEL_DELAY_MS = 5_000;
43
+
44
+ export interface DiscordSurfaceOptions {
45
+ token: string;
46
+ ownerId: string;
47
+ /** How the owner is named in audit-log reasons and refusals. */
48
+ ownerName: string;
49
+ logger: Logger;
50
+ }
51
+
52
+ /**
53
+ * ChatSurface and OwnerNotifier over discord.js. It reports message facts only; the
54
+ * conversation service decides who is answered. The Message Content intent lets the agent
55
+ * server's channels answer the owner without a mention; elsewhere the assistant still answers only
56
+ * DMs and messages that mention it.
57
+ */
58
+ export class DiscordSurface implements ChatSurface, OwnerNotifier {
59
+ readonly #options: DiscordSurfaceOptions;
60
+ readonly #client = new Client({
61
+ intents: [
62
+ GatewayIntentBits.Guilds,
63
+ GatewayIntentBits.GuildMessages,
64
+ GatewayIntentBits.DirectMessages,
65
+ GatewayIntentBits.MessageContent,
66
+ ],
67
+ partials: [Partials.Channel],
68
+ allowedMentions: { parse: [] },
69
+ });
70
+
71
+ #interactions: ComposedInteractions = { commands: [], modules: [] };
72
+
73
+ constructor(options: DiscordSurfaceOptions) {
74
+ this.#options = options;
75
+ }
76
+
77
+ /** Slash commands and the modules that answer them; the commands are registered globally at startup. */
78
+ useInteractions(interactions: ComposedInteractions): void {
79
+ this.#interactions = interactions;
80
+ }
81
+
82
+ /** Channel operations for outside agents; undefined until the connection is ready. */
83
+ channelExecutor(): ChannelExecutor | undefined {
84
+ return this.#client.isReady()
85
+ ? discordChannelExecutor(this.#client)
86
+ : undefined;
87
+ }
88
+
89
+ /** Discord reading and management for the owner's agent; calls fail until the connection is ready. */
90
+ ownerDiscord(): DiscordOwnerOps {
91
+ return new DiscordOwnerOps(
92
+ this.#client,
93
+ this.#options.ownerId,
94
+ this.#options.ownerName,
95
+ );
96
+ }
97
+
98
+ /** The agent server's channels and webhooks. */
99
+ agentChannels(guildId: string): DiscordAgentChannels {
100
+ return new DiscordAgentChannels(this.#client, guildId);
101
+ }
102
+
103
+ /** Threads for background dispatches, in the channel that started each. */
104
+ threadHost(): DiscordThreadHost {
105
+ return new DiscordThreadHost(this.#client);
106
+ }
107
+
108
+ /** The agent server's `#dashboard` and its pinned status message. */
109
+ agentDashboard(guildId: string): DiscordDashboard {
110
+ return new DiscordDashboard(this.#client, guildId, this.#options.logger);
111
+ }
112
+
113
+ /**
114
+ * What a channel is called, for the web app's lists: undefined when Discord no longer knows
115
+ * it; throws while the connection is not ready or Discord cannot be reached.
116
+ */
117
+ async channelInfo(channelId: string): Promise<ChannelInfo | undefined> {
118
+ if (!this.#client.isReady()) throw new Error("Discord is not connected");
119
+ const channel = await this.#client.channels
120
+ .fetch(channelId)
121
+ .catch((error: unknown) => {
122
+ if (error instanceof DiscordAPIError && error.code === UNKNOWN_CHANNEL)
123
+ return null;
124
+ throw error;
125
+ });
126
+ if (!channel) return undefined;
127
+ if (channel.isDMBased())
128
+ return {
129
+ kind: "dm",
130
+ name:
131
+ channel.type === ChannelType.DM
132
+ ? (channel.recipient?.username ?? "")
133
+ : (channel.name ?? ""),
134
+ };
135
+ return {
136
+ kind: "guild",
137
+ name: channel.name,
138
+ guild: channel.guild.name,
139
+ guildId: channel.guild.id,
140
+ };
141
+ }
142
+
143
+ /** Reports deleted server channels by id; register before start. */
144
+ onChannelDeleted(handler: (channelId: string) => void): void {
145
+ this.#client.on(Events.ChannelDelete, (channel) => handler(channel.id));
146
+ }
147
+
148
+ async start(onMessage: (message: InboundMessage) => void): Promise<void> {
149
+ const { logger, token } = this.#options;
150
+ this.#client.on(Events.MessageCreate, (message) => {
151
+ this.#toInbound(message)
152
+ .then((inbound) => {
153
+ if (inbound) onMessage(inbound);
154
+ })
155
+ .catch((error: unknown) =>
156
+ logger.error({ err: error }, "inbound message could not be read"),
157
+ );
158
+ });
159
+ this.#client.on(Events.InteractionCreate, (interaction) => {
160
+ void this.#dispatch(interaction);
161
+ });
162
+ this.#client.on(Events.Error, (error) =>
163
+ logger.error({ err: error }, "discord client error"),
164
+ );
165
+ const ready = new Promise<void>((resolve) =>
166
+ this.#client.once(Events.ClientReady, () => resolve()),
167
+ );
168
+ await this.#client.login(token);
169
+ await ready;
170
+ const { commands } = this.#interactions;
171
+ await this.#client.application?.commands.set(commands);
172
+ logger.info(
173
+ { user: this.#client.user?.tag, commands: commands.map((c) => c.name) },
174
+ "discord ready",
175
+ );
176
+ }
177
+
178
+ /**
179
+ * Discord draws attachments below a message's text, so the card goes out as its own message
180
+ * first and produced files last, each on its own. Replies may mention people but never everyone or a role.
181
+ */
182
+ async sendReply(channel: ChannelKey, reply: OutboundReply): Promise<void> {
183
+ const target = await this.#sendable(channel);
184
+ const allowedMentions = { parse: ["users" as const] };
185
+ if (reply.thinking) {
186
+ await target.send({ content: reply.thinking, allowedMentions });
187
+ }
188
+ if (reply.card) {
189
+ await target.send({
190
+ files: [
191
+ new AttachmentBuilder(Buffer.from(reply.card), {
192
+ name: "card.png",
193
+ }),
194
+ ],
195
+ });
196
+ }
197
+ for (const chunk of reply.chunks) {
198
+ await target.send({ content: chunk, allowedMentions });
199
+ }
200
+ // One file per message: Discord crops several images in one message into a grid.
201
+ for (const file of reply.files ?? []) {
202
+ await target.send({
203
+ files: [
204
+ new AttachmentBuilder(Buffer.from(file.data), { name: file.name }),
205
+ ],
206
+ });
207
+ }
208
+ }
209
+
210
+ startTyping(channel: ChannelKey): () => void {
211
+ let stopped = false;
212
+ const tick = () => {
213
+ if (stopped) return;
214
+ this.#sendable(channel)
215
+ .then((target) => target.sendTyping())
216
+ .catch((error: unknown) =>
217
+ this.#options.logger.warn(
218
+ { channel, err: error },
219
+ "typing indicator failed",
220
+ ),
221
+ );
222
+ };
223
+ tick();
224
+ const timer = setInterval(tick, TYPING_REFRESH_MS);
225
+ return () => {
226
+ stopped = true;
227
+ clearInterval(timer);
228
+ };
229
+ }
230
+
231
+ showStop(channel: ChannelKey): () => void {
232
+ const { logger } = this.#options;
233
+ // An outside agent's conversation has no Discord channel to show a button in.
234
+ if (!channel.startsWith(PREFIX)) return () => undefined;
235
+ let hidden = false;
236
+ let panel: Message | undefined;
237
+ const remove = (message: Message) =>
238
+ message
239
+ .delete()
240
+ .catch((error: unknown) =>
241
+ logger.warn({ channel, err: error }, "stop panel not removed"),
242
+ );
243
+ const timer = setTimeout(() => {
244
+ this.#sendable(channel)
245
+ .then((target): Promise<Message> => target.send(stopPanel()))
246
+ .then((message) => {
247
+ if (hidden) void remove(message);
248
+ else panel = message;
249
+ })
250
+ .catch((error: unknown) =>
251
+ logger.warn({ channel, err: error }, "stop panel not shown"),
252
+ );
253
+ }, STOP_PANEL_DELAY_MS);
254
+ return () => {
255
+ hidden = true;
256
+ clearTimeout(timer);
257
+ if (panel) void remove(panel);
258
+ };
259
+ }
260
+
261
+ async react(
262
+ channel: ChannelKey,
263
+ messageId: string,
264
+ emoji: string,
265
+ ): Promise<void> {
266
+ try {
267
+ const target = await this.#sendable(channel);
268
+ await (await target.messages.fetch(messageId)).react(emoji);
269
+ } catch (error) {
270
+ this.#options.logger.warn({ channel, err: error }, "reaction not added");
271
+ }
272
+ }
273
+
274
+ async unreact(
275
+ channel: ChannelKey,
276
+ messageId: string,
277
+ emoji: string,
278
+ ): Promise<void> {
279
+ try {
280
+ const target = await this.#sendable(channel);
281
+ const message = await target.messages.fetch(messageId);
282
+ await message.reactions.cache
283
+ .find((reaction) => reaction.emoji.name === emoji)
284
+ ?.users.remove();
285
+ } catch (error) {
286
+ this.#options.logger.warn(
287
+ { channel, err: error },
288
+ "reaction not removed",
289
+ );
290
+ }
291
+ }
292
+
293
+ /** Where the owner's cards in a channel are posted. */
294
+ async cardChannel(channelId: string): Promise<CardChannel> {
295
+ const target = await this.#sendable(`${PREFIX}${channelId}`);
296
+ return {
297
+ thread: target.isThread(),
298
+ send: async (payload) => {
299
+ const message = await target.send(payload);
300
+ return { edit: (change) => message.edit(change) };
301
+ },
302
+ };
303
+ }
304
+
305
+ /** The owner's direct-message channel. */
306
+ async ownerChannel(): Promise<ChannelKey> {
307
+ const owner = await this.#client.users.fetch(this.#options.ownerId);
308
+ return `${PREFIX}${(await owner.createDM()).id}`;
309
+ }
310
+
311
+ async notifyOwner(text: string): Promise<void> {
312
+ const owner = await this.#client.users.fetch(this.#options.ownerId);
313
+ for (const chunk of splitReply(text)) await owner.send({ content: chunk });
314
+ }
315
+
316
+ async stop(): Promise<void> {
317
+ await this.#client.destroy();
318
+ }
319
+
320
+ async #toInbound(message: Message): Promise<InboundMessage | undefined> {
321
+ const botId = this.#client.user?.id;
322
+ if (!botId || message.author.id === botId) return undefined;
323
+ const mention = new RegExp(`<@!?${botId}>`, "g");
324
+ const reference = message.reference;
325
+ // A forward also has a reference, to a message elsewhere; its copy is the snapshot.
326
+ const forward =
327
+ reference?.type === MessageReferenceType.Forward
328
+ ? message.messageSnapshots.first()
329
+ : undefined;
330
+ const referenced =
331
+ reference?.messageId && !forward
332
+ ? await message.fetchReference().catch(() => undefined)
333
+ : undefined;
334
+ return {
335
+ channel: `${PREFIX}${message.channelId}`,
336
+ messageId: message.id,
337
+ authorId: message.author.id,
338
+ authorName:
339
+ message.member?.displayName ??
340
+ message.author.globalName ??
341
+ message.author.username,
342
+ authorIsBot: message.author.bot,
343
+ ...(message.member
344
+ ? { authorRoleIds: [...message.member.roles.cache.keys()] }
345
+ : {}),
346
+ ...(message.webhookId
347
+ ? {
348
+ webhookId: message.webhookId,
349
+ // Webhooks the assistant created carry its application on every message they post.
350
+ ownWebhook:
351
+ message.applicationId !== null &&
352
+ message.applicationId === (this.#client.application?.id ?? botId),
353
+ }
354
+ : {}),
355
+ isDirect: message.channel.isDMBased(),
356
+ ...(message.guildId ? { guildId: message.guildId } : {}),
357
+ mentionsBot: message.mentions.users.has(botId),
358
+ repliesToBot: referenced?.author.id === botId,
359
+ text: message.content.replace(mention, "").trim(),
360
+ attachments: [
361
+ ...attachmentRefs(message),
362
+ ...(forward ? attachmentRefs(forward) : []),
363
+ ],
364
+ ...(forward && reference?.channelId
365
+ ? {
366
+ forwarded: {
367
+ text: forward.content ?? "",
368
+ channelMention: `<#${reference.channelId}>`,
369
+ url: `https://discord.com/channels/${reference.guildId ?? "@me"}/${reference.channelId}/${reference.messageId ?? ""}`,
370
+ },
371
+ }
372
+ : {}),
373
+ ...(referenced
374
+ ? {
375
+ reference: {
376
+ text: referenced.content.replace(mention, "").trim(),
377
+ attachments: attachmentRefs(referenced),
378
+ ...(referenced.webhookId
379
+ ? { webhookName: referenced.author.username }
380
+ : {}),
381
+ },
382
+ }
383
+ : {}),
384
+ };
385
+ }
386
+
387
+ async #dispatch(interaction: Interaction): Promise<void> {
388
+ const { logger } = this.#options;
389
+ try {
390
+ for (const module of this.#interactions.modules) {
391
+ if (await module.handle(interaction)) return;
392
+ }
393
+ } catch (error) {
394
+ logger.error({ err: error }, "interaction failed");
395
+ if (
396
+ interaction.isRepliable() &&
397
+ !interaction.replied &&
398
+ !interaction.deferred
399
+ ) {
400
+ await interaction
401
+ .reply({
402
+ content: messages().surfaceActionFailed,
403
+ flags: MessageFlags.Ephemeral,
404
+ })
405
+ .catch(() => undefined);
406
+ }
407
+ }
408
+ }
409
+
410
+ async #sendable(channel: ChannelKey) {
411
+ if (!channel.startsWith(PREFIX))
412
+ throw new Error(`not a Discord channel: ${channel}`);
413
+ const target = await this.#client.channels.fetch(
414
+ channel.slice(PREFIX.length),
415
+ );
416
+ if (!target?.isSendable())
417
+ throw new Error(`channel ${channel} cannot receive messages`);
418
+ return target;
419
+ }
420
+ }
421
+
422
+ function attachmentRefs(
423
+ message: Pick<Message, "attachments">,
424
+ ): AttachmentRef[] {
425
+ return [...message.attachments.values()].map((attachment) => ({
426
+ url: attachment.url,
427
+ name: attachment.name,
428
+ ...(attachment.contentType ? { contentType: attachment.contentType } : {}),
429
+ size: attachment.size,
430
+ }));
431
+ }
@@ -0,0 +1,114 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { ChannelType, type Client } from "discord.js";
3
+ import { messages } from "../i18n/index.ts";
4
+ import { DiscordThreadHost } from "./dispatch-thread-host.ts";
5
+
6
+ class FakeThread {
7
+ readonly sent: string[] = [];
8
+ readonly edits: unknown[] = [];
9
+ archived = false;
10
+ failEdit = false;
11
+ constructor(readonly id: string) {}
12
+ isThread() {
13
+ return true;
14
+ }
15
+ async send({ content }: { content: string }) {
16
+ this.sent.push(content);
17
+ }
18
+ async edit(change: unknown) {
19
+ if (this.failEdit) throw new Error("Missing Permissions");
20
+ this.edits.push(change);
21
+ }
22
+ async setArchived(archived: boolean) {
23
+ this.archived = archived;
24
+ }
25
+ }
26
+
27
+ class FakeStartMessage {
28
+ deleted = false;
29
+ thread: { name: string; autoArchiveDuration: number } | undefined;
30
+ constructor(
31
+ public content: string,
32
+ readonly failThread: boolean,
33
+ ) {}
34
+ async startThread(options: { name: string; autoArchiveDuration: number }) {
35
+ if (this.failThread) throw new Error("Missing Permissions");
36
+ this.thread = options;
37
+ return { id: "700" };
38
+ }
39
+ async edit({ content }: { content: string }) {
40
+ this.content = content;
41
+ }
42
+ async delete() {
43
+ this.deleted = true;
44
+ }
45
+ }
46
+
47
+ class FakeParent {
48
+ readonly messages: FakeStartMessage[] = [];
49
+ failThread = false;
50
+ constructor(readonly type: ChannelType) {}
51
+ isThread() {
52
+ return false;
53
+ }
54
+ isSendable() {
55
+ return true;
56
+ }
57
+ async send({ content }: { content: string }) {
58
+ const message = new FakeStartMessage(content, this.failThread);
59
+ this.messages.push(message);
60
+ return message;
61
+ }
62
+ }
63
+
64
+ function setup() {
65
+ const channels = new Map<string, unknown>([
66
+ ["10", new FakeParent(ChannelType.GuildText)],
67
+ ["11", new FakeParent(ChannelType.DM)],
68
+ ["12", new FakeParent(ChannelType.PublicThread)],
69
+ ["700", new FakeThread("700")],
70
+ ]);
71
+ const client = {
72
+ channels: { fetch: async (id: string) => channels.get(id) ?? null },
73
+ } as unknown as Client;
74
+ return {
75
+ host: new DiscordThreadHost(client),
76
+ parent: (id: string) => channels.get(id) as FakeParent,
77
+ thread: channels.get("700") as FakeThread,
78
+ };
79
+ }
80
+
81
+ const line = (thread?: string) => messages().threadStarted("t", thread);
82
+
83
+ describe("DiscordThreadHost", () => {
84
+ test("starts the thread from the start line, then links it there", async () => {
85
+ const { host, parent } = setup();
86
+ expect(await host.open("10", "repo #1", line)).toBe("700");
87
+ const [start] = parent("10").messages;
88
+ expect(start?.thread?.name).toBe("repo #1");
89
+ expect(start?.content).toBe(messages().threadStarted("t", "<#700>"));
90
+ });
91
+
92
+ test("DMs and threads cannot host one; a refused thread removes its start line", async () => {
93
+ const { host, parent } = setup();
94
+ expect(await host.open("11", "t", line)).toBeUndefined();
95
+ expect(await host.open("12", "t", line)).toBeUndefined();
96
+ expect(await host.open("99", "t", line).catch(() => "threw")).toBe(
97
+ undefined,
98
+ );
99
+ parent("10").failThread = true;
100
+ expect(await host.open("10", "t", line).catch(() => "threw")).toBe("threw");
101
+ expect(parent("10").messages[0]?.deleted).toBe(true);
102
+ });
103
+
104
+ test("posts in the thread, then archives and locks it, or only archives without the permission", async () => {
105
+ const { host, thread } = setup();
106
+ await host.post("700", "report");
107
+ expect(thread.sent).toEqual(["report"]);
108
+ await host.close("700");
109
+ expect(thread.edits).toEqual([{ archived: true, locked: true }]);
110
+ thread.failEdit = true;
111
+ await host.close("700");
112
+ expect(thread.archived).toBe(true);
113
+ });
114
+ });
@@ -0,0 +1,72 @@
1
+ import {
2
+ ChannelType,
3
+ type Client,
4
+ type Message,
5
+ ThreadAutoArchiveDuration,
6
+ } from "discord.js";
7
+ import { splitReply } from "../presentation/reply-splitter.ts";
8
+ import type { ThreadHost } from "./dispatch-threads.ts";
9
+
10
+ /** Parents a dispatch thread may start in; DMs, threads, forums, and voice chats keep posting in place. */
11
+ const HOSTS: readonly ChannelType[] = [
12
+ ChannelType.GuildText,
13
+ ChannelType.GuildAnnouncement,
14
+ ];
15
+
16
+ /** Dispatch threads over the bot's own connection; the bot posts everything in them. */
17
+ export class DiscordThreadHost implements ThreadHost {
18
+ readonly #client: Pick<Client, "channels">;
19
+
20
+ constructor(client: Pick<Client, "channels">) {
21
+ this.#client = client;
22
+ }
23
+
24
+ async open(
25
+ parentId: string,
26
+ name: string,
27
+ line: (thread?: string) => string,
28
+ ): Promise<string | undefined> {
29
+ const parent = await this.#client.channels.fetch(parentId);
30
+ if (!parent || !HOSTS.includes(parent.type) || !parent.isSendable())
31
+ return undefined;
32
+ const start: Message = await parent.send({ content: line() });
33
+ let threadId: string;
34
+ try {
35
+ const thread = await start.startThread({
36
+ name,
37
+ // Outlasts the longest run, so a quiet worker's thread stays open.
38
+ autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
39
+ });
40
+ threadId = thread.id;
41
+ } catch (error) {
42
+ await start.delete().catch(() => undefined);
43
+ throw error;
44
+ }
45
+ await start
46
+ .edit({ content: line(`<#${threadId}>`) })
47
+ .catch(() => undefined);
48
+ return threadId;
49
+ }
50
+
51
+ async post(threadId: string, text: string): Promise<void> {
52
+ const thread = await this.#thread(threadId);
53
+ for (const chunk of splitReply(text)) await thread.send({ content: chunk });
54
+ }
55
+
56
+ async close(threadId: string): Promise<void> {
57
+ const thread = await this.#thread(threadId);
58
+ // Locking needs Manage Threads; without it the thread is only archived.
59
+ try {
60
+ await thread.edit({ archived: true, locked: true });
61
+ } catch {
62
+ await thread.setArchived(true);
63
+ }
64
+ }
65
+
66
+ async #thread(threadId: string) {
67
+ const channel = await this.#client.channels.fetch(threadId);
68
+ if (!channel?.isThread())
69
+ throw new Error(`channel ${threadId} is not a thread`);
70
+ return channel;
71
+ }
72
+ }
@@ -0,0 +1,94 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+ import { messages } from "../i18n/index.ts";
4
+ import { silentLogger } from "../log.ts";
5
+ import { FakeThreadHost, fakeThreads } from "../testing/thread-host.ts";
6
+ import { DispatchThreads, threadName } from "./dispatch-threads.ts";
7
+
8
+ describe("DispatchThreads", () => {
9
+ test("opens a thread under the origin with a start line there and the intro inside", async () => {
10
+ const { host, threads } = fakeThreads();
11
+ const thread = await threads.open(
12
+ "discord:10",
13
+ "research",
14
+ "📋 Task: find it",
15
+ );
16
+ expect(host.opened).toEqual([
17
+ { parentId: "10", name: "research", id: "900" },
18
+ ]);
19
+ expect(host.lines.get("10")).toBe(
20
+ messages().threadStarted("research", "<#900>"),
21
+ );
22
+ expect(thread?.mention).toBe("<#900>");
23
+ expect(host.textsIn("900")).toEqual(["📋 Task: find it"]);
24
+ });
25
+
26
+ test("close posts the report, archives once, and forgets the thread", async () => {
27
+ const { host, threads, ledgerPath } = fakeThreads();
28
+ const thread = await threads.open("discord:10", "t");
29
+ expect(Object.keys(JSON.parse(readFileSync(ledgerPath, "utf8")))).toEqual([
30
+ "900",
31
+ ]);
32
+ await thread?.close("✅ Done");
33
+ await thread?.close("again");
34
+ expect(host.textsIn("900")).toEqual(["✅ Done"]);
35
+ expect(host.closed).toEqual(["900"]);
36
+ expect(JSON.parse(readFileSync(ledgerPath, "utf8"))).toEqual({});
37
+ });
38
+
39
+ test("no thread outside Discord, in excluded channels, where the host cannot, or when creation fails", async () => {
40
+ const host = new FakeThreadHost();
41
+ host.hostless.add("20");
42
+ const { threads, ledgerPath } = fakeThreads(host);
43
+ const party = new DispatchThreads({
44
+ host,
45
+ ledgerPath,
46
+ excluded: (channel) => channel === "discord:30",
47
+ logger: silentLogger(),
48
+ });
49
+ expect(await threads.open(undefined, "t")).toBeUndefined();
50
+ expect(await threads.open("mcp:abc", "t")).toBeUndefined();
51
+ expect(await threads.open("discord:20", "t")).toBeUndefined();
52
+ expect(await party.open("discord:30", "t")).toBeUndefined();
53
+ host.failOpen = true;
54
+ expect(await threads.open("discord:40", "t")).toBeUndefined();
55
+ expect(host.opened).toEqual([]);
56
+ });
57
+
58
+ test("a failed post or archive is logged, not thrown", async () => {
59
+ const host = new FakeThreadHost();
60
+ host.post = async () => {
61
+ throw new Error("gone");
62
+ };
63
+ host.close = async () => {
64
+ throw new Error("gone");
65
+ };
66
+ const { threads } = fakeThreads(host);
67
+ const thread = await threads.open("discord:10", "t", "intro");
68
+ await thread?.post("x");
69
+ await thread?.close("report");
70
+ });
71
+
72
+ test("a start after a restart archives the threads left open, with a notice", async () => {
73
+ const { host, threads, ledgerPath } = fakeThreads();
74
+ await threads.open("discord:10", "a");
75
+ const done = await threads.open("discord:11", "b");
76
+ await done?.close();
77
+ const restarted = new DispatchThreads({
78
+ host,
79
+ ledgerPath,
80
+ logger: silentLogger(),
81
+ });
82
+ await restarted.sweep();
83
+ expect(host.closed).toEqual(["901", "900"]);
84
+ expect(host.textsIn("900")).toEqual([messages().restartNotice]);
85
+ expect(JSON.parse(readFileSync(ledgerPath, "utf8"))).toEqual({});
86
+ });
87
+
88
+ test("thread names are one line within Discord's 100 characters", () => {
89
+ expect(threadName(" a\nb ")).toBe("a b");
90
+ const long = threadName("x".repeat(150));
91
+ expect(long.length).toBe(100);
92
+ expect(long.endsWith("…")).toBe(true);
93
+ });
94
+ });