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,180 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ renameSync,
6
+ writeFileSync,
7
+ } from "node:fs";
8
+ import { dirname } from "node:path";
9
+ import type { ChannelKey } from "../domain/conversation.ts";
10
+ import { messages } from "../i18n/index.ts";
11
+ import type { Logger } from "../log.ts";
12
+
13
+ const PREFIX = "discord:";
14
+ /** Discord's limit on a thread name. */
15
+ export const THREAD_NAME_LIMIT = 100;
16
+
17
+ /** The chat side of dispatch threads; the Discord surface implements it. */
18
+ export interface ThreadHost {
19
+ /**
20
+ * Posts the start line in the parent and opens a public thread from it; `line` gets the
21
+ * thread's mention once it exists. Undefined when the parent cannot host threads, such as a
22
+ * DM or a thread; throws when Discord refuses.
23
+ */
24
+ open(
25
+ parentId: string,
26
+ name: string,
27
+ line: (thread?: string) => string,
28
+ ): Promise<string | undefined>;
29
+ post(threadId: string, text: string): Promise<void>;
30
+ /** Archives the thread, and locks it when allowed. */
31
+ close(threadId: string): Promise<void>;
32
+ }
33
+
34
+ /** One background dispatch's thread: its progress while it runs, then its report. */
35
+ export interface DispatchThread {
36
+ readonly id: string;
37
+ /** The thread as a conversation channel, where its owner cards are posted. */
38
+ readonly channel: ChannelKey;
39
+ /** `<#id>`, for the report turn and the start line. */
40
+ readonly mention: string;
41
+ /** Posts in the thread; a failure is logged. */
42
+ post(text: string): Promise<void>;
43
+ /** Posts the final report, if any, then archives the thread. Runs once; never rejects. */
44
+ close(report?: string): Promise<void>;
45
+ }
46
+
47
+ export interface DispatchThreadsOptions {
48
+ host: ThreadHost;
49
+ /** Threads still open, so a start after a crash can archive them. */
50
+ ledgerPath: string;
51
+ /** Channels that keep posting in place, such as party channels. */
52
+ excluded?: (channel: ChannelKey) => boolean;
53
+ logger: Logger;
54
+ }
55
+
56
+ interface LedgerEntry {
57
+ title: string;
58
+ parent: ChannelKey;
59
+ startedAt: string;
60
+ }
61
+
62
+ /** The thread's name: the title on one line, within Discord's limit. */
63
+ export function threadName(title: string): string {
64
+ const line = title.replace(/\s+/g, " ").trim() || "dispatch";
65
+ return line.length > THREAD_NAME_LIMIT
66
+ ? `${line.slice(0, THREAD_NAME_LIMIT - 1)}…`
67
+ : line;
68
+ }
69
+
70
+ /**
71
+ * Background dispatches (coding tasks, delegated tasks, messages between agents) each get a
72
+ * thread in the channel that started them, leaving one start line there. A channel that cannot
73
+ * host one, or a failed creation, gets no thread, and the dispatch posts as before.
74
+ */
75
+ export class DispatchThreads {
76
+ readonly #options: DispatchThreadsOptions;
77
+ readonly #open = new Map<string, LedgerEntry>();
78
+
79
+ constructor(options: DispatchThreadsOptions) {
80
+ this.#options = options;
81
+ const { ledgerPath } = options;
82
+ if (!existsSync(ledgerPath)) return;
83
+ let parsed: unknown;
84
+ try {
85
+ parsed = JSON.parse(readFileSync(ledgerPath, "utf8"));
86
+ } catch (error) {
87
+ throw new Error(`${ledgerPath} is not valid JSON`, { cause: error });
88
+ }
89
+ if (typeof parsed !== "object" || parsed === null)
90
+ throw new Error(`${ledgerPath} must hold a JSON object`);
91
+ for (const [id, entry] of Object.entries(parsed))
92
+ this.#open.set(id, entry as LedgerEntry);
93
+ }
94
+
95
+ /** Opens the dispatch's thread under `origin`, posting `intro` first; never rejects. */
96
+ async open(
97
+ origin: ChannelKey | undefined,
98
+ title: string,
99
+ intro?: string,
100
+ ): Promise<DispatchThread | undefined> {
101
+ const { host, excluded, logger } = this.#options;
102
+ if (!origin?.startsWith(PREFIX) || excluded?.(origin)) return undefined;
103
+ const name = threadName(title);
104
+ let id: string | undefined;
105
+ try {
106
+ id = await host.open(origin.slice(PREFIX.length), name, (thread) =>
107
+ messages().threadStarted(name, thread),
108
+ );
109
+ } catch (error) {
110
+ logger.warn({ origin, err: error }, "dispatch thread not opened");
111
+ return undefined;
112
+ }
113
+ if (!id) return undefined;
114
+ this.#open.set(id, {
115
+ title: name,
116
+ parent: origin,
117
+ startedAt: new Date().toISOString(),
118
+ });
119
+ this.#write();
120
+ logger.info({ origin, thread: id }, "dispatch thread opened");
121
+ const thread = this.#thread(id);
122
+ if (intro) await thread.post(intro);
123
+ return thread;
124
+ }
125
+
126
+ /** Archives the threads a restart left open; their dispatches never report. */
127
+ async sweep(): Promise<void> {
128
+ for (const id of [...this.#open.keys()])
129
+ await this.#thread(id).close(messages().restartNotice);
130
+ }
131
+
132
+ #thread(id: string): DispatchThread {
133
+ const { host, logger } = this.#options;
134
+ let closed = false;
135
+ const post = async (text: string) => {
136
+ try {
137
+ await host.post(id, text);
138
+ } catch (error) {
139
+ logger.warn({ thread: id, err: error }, "dispatch thread post failed");
140
+ }
141
+ };
142
+ return {
143
+ id,
144
+ channel: `${PREFIX}${id}`,
145
+ mention: `<#${id}>`,
146
+ post,
147
+ close: async (report) => {
148
+ if (closed) return;
149
+ closed = true;
150
+ if (report) await post(report);
151
+ try {
152
+ await host.close(id);
153
+ } catch (error) {
154
+ logger.warn(
155
+ { thread: id, err: error },
156
+ "dispatch thread not archived",
157
+ );
158
+ }
159
+ this.#open.delete(id);
160
+ this.#write();
161
+ },
162
+ };
163
+ }
164
+
165
+ #write(): void {
166
+ const path = this.#options.ledgerPath;
167
+ try {
168
+ mkdirSync(dirname(path), { recursive: true });
169
+ const temporary = `${path}.tmp`;
170
+ writeFileSync(
171
+ temporary,
172
+ `${JSON.stringify(Object.fromEntries(this.#open), null, 2)}\n`,
173
+ { mode: 0o600 },
174
+ );
175
+ renameSync(temporary, path);
176
+ } catch (error) {
177
+ this.#options.logger.warn({ err: error }, "dispatch threads not saved");
178
+ }
179
+ }
180
+ }
@@ -0,0 +1 @@
1
+ export type { InteractionModule } from "../contract/discord.ts";
@@ -0,0 +1,435 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { Interaction } from "discord.js";
3
+ import type { OwnerQuestion } from "../domain/owner-prompts.ts";
4
+ import { messages } from "../i18n/index.ts";
5
+ import { silentLogger } from "../log.ts";
6
+ import { speakerPolicy } from "../speakers.ts";
7
+ import {
8
+ CARD_PREFIX,
9
+ type CardPayload,
10
+ OwnerCards,
11
+ type OwnerCardsOptions,
12
+ } from "./owner-cards.ts";
13
+
14
+ const OWNER = "100000000000000001";
15
+
16
+ /** The card's JSON, where its text, custom ids, and disabled flags can be read. */
17
+ const json = (payload: CardPayload | undefined) =>
18
+ JSON.stringify(payload?.components.map((c) => c.toJSON()));
19
+
20
+ /** A channel, or a thread, that records the cards sent to it and every edit of them. */
21
+ function fakeChannel(thread = false) {
22
+ const sent: CardPayload[] = [];
23
+ const edits: CardPayload[] = [];
24
+ return {
25
+ sent,
26
+ edits,
27
+ /** The id of the latest card sent. */
28
+ cardId: () =>
29
+ json(sent.at(-1)).match(new RegExp(`${CARD_PREFIX}([0-9a-f-]+):`))?.[1] ??
30
+ "",
31
+ channel: async () => ({
32
+ thread,
33
+ send: async (payload: CardPayload) => {
34
+ sent.push(payload);
35
+ return {
36
+ edit: async (change: CardPayload) => {
37
+ edits.push(change);
38
+ },
39
+ };
40
+ },
41
+ }),
42
+ };
43
+ }
44
+
45
+ type Kind = "button" | "select" | "modal";
46
+
47
+ /** A press of a card's control, recording how it was answered. */
48
+ function press(
49
+ kind: Kind,
50
+ customId: string,
51
+ options: {
52
+ user?: string;
53
+ roles?: string[];
54
+ values?: string[];
55
+ text?: string;
56
+ } = {},
57
+ ) {
58
+ const replies: string[] = [];
59
+ const updates: CardPayload[] = [];
60
+ const modals: unknown[] = [];
61
+ const interaction = {
62
+ isButton: () => kind === "button",
63
+ isStringSelectMenu: () => kind === "select",
64
+ isModalSubmit: () => kind === "modal",
65
+ isFromMessage: () => true,
66
+ customId,
67
+ user: { id: options.user ?? OWNER },
68
+ member: {
69
+ roles: { cache: new Map((options.roles ?? []).map((r) => [r, r])) },
70
+ },
71
+ values: options.values ?? [],
72
+ fields: { getTextInputValue: () => options.text ?? "" },
73
+ reply: async (answer: { content: string }) => {
74
+ replies.push(answer.content);
75
+ },
76
+ update: async (payload: CardPayload) => {
77
+ updates.push(payload);
78
+ },
79
+ showModal: async (modal: unknown) => {
80
+ modals.push(modal);
81
+ },
82
+ deferUpdate: async () => undefined,
83
+ } as unknown as Interaction;
84
+ return { interaction, replies, updates, modals };
85
+ }
86
+
87
+ function setup(options: Partial<OwnerCardsOptions> = {}) {
88
+ const fake = fakeChannel();
89
+ const cards = new OwnerCards({
90
+ ownerId: OWNER,
91
+ channel: fake.channel,
92
+ logger: silentLogger(),
93
+ ...options,
94
+ });
95
+ const prompts = cards.prompts("discord:555");
96
+ if (!prompts) throw new Error("no prompts for a Discord channel");
97
+ return { fake, cards, prompts };
98
+ }
99
+
100
+ const tick = () => Bun.sleep(1);
101
+
102
+ describe("OwnerCards", () => {
103
+ test("a conversation outside Discord has no cards", () => {
104
+ const { cards } = setup();
105
+ expect(cards.prompts("mcp:agent")).toBeUndefined();
106
+ });
107
+
108
+ test("a dispatch thread's cards are posted in the thread", async () => {
109
+ const asked: string[] = [];
110
+ const fake = fakeChannel();
111
+ const cards = new OwnerCards({
112
+ ownerId: OWNER,
113
+ channel: async (channelId) => {
114
+ asked.push(channelId);
115
+ return fake.channel();
116
+ },
117
+ logger: silentLogger(),
118
+ timeoutMs: 5,
119
+ });
120
+ await cards.prompts("discord:900")?.confirm("t", "m");
121
+ expect(asked).toEqual(["900"]);
122
+ expect(fake.sent).toHaveLength(1);
123
+ });
124
+
125
+ test("the owner approves; the card then shows the outcome with its buttons disabled", async () => {
126
+ const { fake, cards, prompts } = setup();
127
+ const answer = prompts.confirm(
128
+ "The assistant wants to run this action",
129
+ "**send mail**",
130
+ );
131
+ await tick();
132
+ expect(json(fake.sent[0])).toContain("send mail");
133
+ expect(json(fake.sent[0])).toContain(messages().cardRunLabel);
134
+ const yes = press("button", `${CARD_PREFIX}${fake.cardId()}:yes`);
135
+ expect(await cards.handle(yes.interaction)).toBe(true);
136
+ expect(await answer).toBe("approved");
137
+ expect(json(yes.updates[0])).toContain(messages().cardApproved);
138
+ expect(json(yes.updates[0])).toContain('"disabled":true');
139
+ });
140
+
141
+ test("a card in a channel mentions nobody", async () => {
142
+ const { fake, prompts } = setup({ timeoutMs: 5 });
143
+ await prompts.confirm(
144
+ "The assistant wants to run this action",
145
+ "**send mail**",
146
+ );
147
+ expect(fake.sent[0]?.allowedMentions).toEqual({ parse: [] });
148
+ expect(json(fake.sent[0])).not.toContain(`<@${OWNER}>`);
149
+ });
150
+
151
+ test("a card in a thread mentions the owner alone, above the unchanged card", async () => {
152
+ const inChannel = fakeChannel();
153
+ const inThread = fakeChannel(true);
154
+ const cards = (fake: ReturnType<typeof fakeChannel>) =>
155
+ new OwnerCards({
156
+ ownerId: OWNER,
157
+ channel: fake.channel,
158
+ logger: silentLogger(),
159
+ });
160
+ const threadCards = cards(inThread);
161
+ void cards(inChannel).prompts("discord:555")?.confirm("t", "**m**");
162
+ const answer = threadCards.prompts("discord:900")?.confirm("t", "**m**");
163
+ await tick();
164
+ const card = inThread.sent[0];
165
+ expect(card?.allowedMentions).toEqual({ users: [OWNER] });
166
+ const [mention, ...panel] = card?.components ?? [];
167
+ expect(mention?.toJSON()).toMatchObject({ content: `<@${OWNER}>` });
168
+ // Past the mention the card is the one a channel gets.
169
+ expect(JSON.stringify(panel.map((c) => c.toJSON()))).toBe(
170
+ json(inChannel.sent[0]).replaceAll(inChannel.cardId(), inThread.cardId()),
171
+ );
172
+ const yes = press("button", `${CARD_PREFIX}${inThread.cardId()}:yes`);
173
+ await threadCards.handle(yes.interaction);
174
+ expect(await answer).toBe("approved");
175
+ // The closed card keeps its mention line.
176
+ expect(json(yes.updates[0])).toContain(`<@${OWNER}>`);
177
+ });
178
+
179
+ test("only the owner may answer; anyone else is refused and the card stays open", async () => {
180
+ const { fake, cards, prompts } = setup();
181
+ const answer = prompts.confirm("t", "m");
182
+ await tick();
183
+ const id = fake.cardId();
184
+ const stranger = press("button", `${CARD_PREFIX}${id}:yes`, { user: "1" });
185
+ await cards.handle(stranger.interaction);
186
+ expect(stranger.replies).toEqual([messages().cardOwnerOnly]);
187
+ expect(stranger.updates).toHaveLength(0);
188
+ const no = press("button", `${CARD_PREFIX}${id}:no`);
189
+ await cards.handle(no.interaction);
190
+ expect(await answer).toBe("declined");
191
+ expect(json(no.updates[0])).toContain(messages().cardDeclined);
192
+ });
193
+
194
+ test("an unanswered card expires and is edited to say so", async () => {
195
+ const { fake, cards, prompts } = setup({ timeoutMs: 5 });
196
+ expect(await prompts.confirm("t", "m")).toBe("expired");
197
+ await tick();
198
+ expect(json(fake.edits[0])).toContain(messages().cardExpired);
199
+ expect(json(fake.edits[0])).toContain('"disabled":true');
200
+ // A late press finds the card gone.
201
+ const late = press("button", `${CARD_PREFIX}${fake.cardId()}:yes`);
202
+ await cards.handle(late.interaction);
203
+ expect(late.replies[0]).toContain(messages().cardInactive);
204
+ });
205
+
206
+ test("a stopped turn cancels its card", async () => {
207
+ const { fake, prompts } = setup();
208
+ const stop = new AbortController();
209
+ const answer = prompts.confirm("t", "m", stop.signal);
210
+ await tick();
211
+ stop.abort();
212
+ expect(await answer).toBe("cancelled");
213
+ await tick();
214
+ expect(json(fake.edits[0])).toContain(messages().cardStopped);
215
+ });
216
+
217
+ test("a card that cannot be posted counts as expired", async () => {
218
+ const { prompts } = setup({
219
+ channel: async () => {
220
+ throw new Error("missing access");
221
+ },
222
+ });
223
+ expect(await prompts.confirm("t", "m")).toBe("expired");
224
+ });
225
+
226
+ test("a press of a card from before a restart says it no longer works", async () => {
227
+ const { cards } = setup();
228
+ const old = press("button", `${CARD_PREFIX}gone:yes`);
229
+ expect(await cards.handle(old.interaction)).toBe(true);
230
+ expect(old.replies[0]).toContain(messages().cardInactive);
231
+ });
232
+
233
+ test("other components are left to other modules", async () => {
234
+ const { cards } = setup();
235
+ expect(
236
+ await cards.handle(press("button", "roundtable:stop").interaction),
237
+ ).toBe(false);
238
+ });
239
+
240
+ describe("questions", () => {
241
+ const question = (q: Partial<OwnerQuestion>): OwnerQuestion => ({
242
+ question: "Which day?",
243
+ options: [
244
+ { label: "Saturday", description: "morning" },
245
+ { label: "Sunday" },
246
+ { label: "Monday" },
247
+ ],
248
+ multi: false,
249
+ allowOther: false,
250
+ ...q,
251
+ });
252
+
253
+ test("a single choice", async () => {
254
+ const { fake, cards, prompts } = setup();
255
+ const answer = prompts.ask(
256
+ "The assistant has a question for you",
257
+ question({}),
258
+ );
259
+ await tick();
260
+ const card = json(fake.sent[0]);
261
+ expect(card).toContain("Which day?");
262
+ expect(card).toContain('"max_values":1');
263
+ expect(card).not.toContain(messages().cardOtherLabel);
264
+ const pick = press("select", `${CARD_PREFIX}${fake.cardId()}:pick`, {
265
+ values: ["1"],
266
+ });
267
+ await cards.handle(pick.interaction);
268
+ expect(await answer).toEqual({ choices: ["Sunday"] });
269
+ expect(json(pick.updates[0])).toContain(
270
+ messages().cardAnswered(["Sunday"]),
271
+ );
272
+ });
273
+
274
+ test("several choices, in the order they were offered", async () => {
275
+ const { fake, cards, prompts } = setup();
276
+ const answer = prompts.ask("t", question({ multi: true }));
277
+ await tick();
278
+ expect(json(fake.sent[0])).toContain('"max_values":3');
279
+ await cards.handle(
280
+ press("select", `${CARD_PREFIX}${fake.cardId()}:pick`, {
281
+ values: ["2", "0"],
282
+ }).interaction,
283
+ );
284
+ expect(await answer).toEqual({ choices: ["Saturday", "Monday"] });
285
+ });
286
+
287
+ test("the other option opens a form, and its text comes back with the other choices", async () => {
288
+ const { fake, cards, prompts } = setup();
289
+ const answer = prompts.ask(
290
+ "t",
291
+ question({ multi: true, allowOther: true }),
292
+ );
293
+ await tick();
294
+ const id = fake.cardId();
295
+ expect(json(fake.sent[0])).toContain(messages().cardOtherLabel);
296
+ const pick = press("select", `${CARD_PREFIX}${id}:pick`, {
297
+ values: ["0", "other"],
298
+ });
299
+ await cards.handle(pick.interaction);
300
+ expect(pick.modals).toHaveLength(1);
301
+ expect(pick.updates).toHaveLength(0);
302
+ const submit = press("modal", `${CARD_PREFIX}${id}:text`, {
303
+ text: "Wednesday evening",
304
+ });
305
+ await cards.handle(submit.interaction);
306
+ expect(await answer).toEqual({
307
+ choices: ["Saturday"],
308
+ text: "Wednesday evening",
309
+ });
310
+ expect(json(submit.updates[0])).toContain("Wednesday evening");
311
+ });
312
+
313
+ test("without options, a button opens the answer form", async () => {
314
+ const { fake, cards, prompts } = setup();
315
+ const answer = prompts.ask("t", question({ options: [] }));
316
+ await tick();
317
+ const id = fake.cardId();
318
+ const write = press("button", `${CARD_PREFIX}${id}:write`);
319
+ await cards.handle(write.interaction);
320
+ expect(write.modals).toHaveLength(1);
321
+ await cards.handle(
322
+ press("modal", `${CARD_PREFIX}${id}:text`, { text: "anything" })
323
+ .interaction,
324
+ );
325
+ expect(await answer).toEqual({ choices: [], text: "anything" });
326
+ });
327
+
328
+ test("an unanswered question has no answer", async () => {
329
+ const { prompts } = setup({ timeoutMs: 5 });
330
+ expect(await prompts.ask("t", question({}))).toBeUndefined();
331
+ });
332
+ });
333
+ });
334
+
335
+ describe("cards for lower tiers", () => {
336
+ const ADMIN = "100000000000000002";
337
+ const MEMBER = "100000000000000003";
338
+ const ROLE = "100000000000000004";
339
+ const speakers = speakerPolicy({
340
+ owners: [OWNER],
341
+ admins: { users: [ADMIN] },
342
+ members: { roles: [ROLE] },
343
+ });
344
+ const askAdminApproval = () => {
345
+ const fake = fakeChannel();
346
+ const cards = new OwnerCards({
347
+ ownerId: OWNER,
348
+ speakers,
349
+ channel: fake.channel,
350
+ logger: silentLogger(),
351
+ });
352
+ const speaker = { id: ADMIN, name: "Ada", tier: "admin" } as const;
353
+ const answer = cards
354
+ .prompts("discord:555", speaker)
355
+ ?.confirm("t", "**create an agent**", undefined, "admin");
356
+ return { fake, cards, answer };
357
+ };
358
+
359
+ test("an approval for admin tools shows who may approve and takes an admin's press", async () => {
360
+ const { fake, cards, answer } = askAdminApproval();
361
+ await tick();
362
+ expect(json(fake.sent[0])).toContain(messages().cardApproversNote("admin"));
363
+ const admin = press("button", `${CARD_PREFIX}${fake.cardId()}:yes`, {
364
+ user: ADMIN,
365
+ });
366
+ await cards.handle(admin.interaction);
367
+ expect(await answer).toBe("approved");
368
+ expect(admin.replies).toEqual([]);
369
+ });
370
+
371
+ test("the owner may approve it too, but a member may not", async () => {
372
+ const { fake, cards, answer } = askAdminApproval();
373
+ await tick();
374
+ const member = press("button", `${CARD_PREFIX}${fake.cardId()}:yes`, {
375
+ user: MEMBER,
376
+ roles: [ROLE],
377
+ });
378
+ await cards.handle(member.interaction);
379
+ expect(member.replies).toEqual([messages().cardApproversRefusal("admin")]);
380
+ const owner = press("button", `${CARD_PREFIX}${fake.cardId()}:no`, {
381
+ user: OWNER,
382
+ });
383
+ await cards.handle(owner.interaction);
384
+ expect(await answer).toBe("declined");
385
+ });
386
+
387
+ test("a shell approval stays the owner's even when the speaker is an admin", async () => {
388
+ const fake = fakeChannel();
389
+ const cards = new OwnerCards({
390
+ ownerId: OWNER,
391
+ speakers,
392
+ channel: fake.channel,
393
+ logger: silentLogger(),
394
+ });
395
+ const speaker = { id: ADMIN, name: "Ada", tier: "admin" } as const;
396
+ void cards.prompts("discord:555", speaker)?.confirm("t", "**rm**");
397
+ await tick();
398
+ const admin = press("button", `${CARD_PREFIX}${fake.cardId()}:yes`, {
399
+ user: ADMIN,
400
+ });
401
+ await cards.handle(admin.interaction);
402
+ expect(admin.replies).toEqual([messages().cardOwnerOnly]);
403
+ });
404
+
405
+ test("a question is for the speaker who is asked, and the owner", async () => {
406
+ const fake = fakeChannel();
407
+ const cards = new OwnerCards({
408
+ ownerId: OWNER,
409
+ speakers,
410
+ channel: fake.channel,
411
+ logger: silentLogger(),
412
+ });
413
+ const speaker = { id: ADMIN, name: "Ada", tier: "admin" } as const;
414
+ const question = {
415
+ question: "Which one?",
416
+ options: [{ label: "First" }, { label: "Second" }],
417
+ multi: false,
418
+ allowOther: false,
419
+ };
420
+ const answer = cards.prompts("discord:555", speaker)?.ask("t", question);
421
+ await tick();
422
+ const stranger = press("select", `${CARD_PREFIX}${fake.cardId()}:pick`, {
423
+ user: MEMBER,
424
+ values: ["0"],
425
+ });
426
+ await cards.handle(stranger.interaction);
427
+ expect(stranger.replies).toEqual([messages().cardAskerRefusal]);
428
+ const asked = press("select", `${CARD_PREFIX}${fake.cardId()}:pick`, {
429
+ user: ADMIN,
430
+ values: ["1"],
431
+ });
432
+ await cards.handle(asked.interaction);
433
+ expect(await answer).toEqual({ choices: ["Second"] });
434
+ });
435
+ });