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,385 @@
1
+ import {
2
+ ActionRowBuilder,
3
+ AttachmentBuilder,
4
+ ButtonBuilder,
5
+ type ButtonInteraction,
6
+ ButtonStyle,
7
+ ContainerBuilder,
8
+ FileBuilder,
9
+ LabelBuilder,
10
+ MediaGalleryBuilder,
11
+ MediaGalleryItemBuilder,
12
+ MessageFlags,
13
+ ModalBuilder,
14
+ type ModalSubmitInteraction,
15
+ SeparatorBuilder,
16
+ SeparatorSpacingSize,
17
+ StringSelectMenuBuilder,
18
+ StringSelectMenuOptionBuilder,
19
+ TextDisplayBuilder,
20
+ TextInputBuilder,
21
+ TextInputStyle,
22
+ } from "discord.js";
23
+ import {
24
+ type Agent,
25
+ type AgentStore,
26
+ MAX_AVATAR_PROMPT_CHARS,
27
+ MAX_PROMPT_CHARS,
28
+ } from "../agents/agent-store.ts";
29
+ import type { AgentTeam } from "../agents/agent-team.ts";
30
+ import type { AvatarMode } from "../agents/agent-tools.ts";
31
+ import type { AvatarStudio } from "../agents/avatar-studio.ts";
32
+ import { AgentError } from "../domain/errors.ts";
33
+ import { messages } from "../i18n/index.ts";
34
+ import { THINKING_LEVELS, thinkingLabel } from "../models.ts";
35
+ import {
36
+ BUILTIN_SKILLS,
37
+ type SkillRegistry,
38
+ type SkillSet,
39
+ } from "../modules/skills/skill-registry.ts";
40
+ import { OwnerFacingError, ownerPanel } from "./owner-panel.ts";
41
+
42
+ export const AGENT_BUTTON_PREFIX = "roundtable:agent:";
43
+ export const AGENT_MODAL_PREFIX = "roundtable:agent-modal:";
44
+ /** A prompt this long goes out as a file, so the panel stays inside Discord's text limit. */
45
+ const INLINE_PROMPT_CHARS = 2_500;
46
+
47
+ /** The models the model form offers, in this order; agent_update still sets any usable model. */
48
+ const FORM_MODELS = [
49
+ "openai-codex/gpt-6-astra",
50
+ "openai-codex/gpt-6-sol",
51
+ "openai-codex/gpt-6-luna",
52
+ "claude-bridge/claude-sonnet-5-5",
53
+ "claude-bridge/claude-opus-5-5",
54
+ ];
55
+ const DEFAULT_SETTING = "default";
56
+
57
+ /** The form's models the host can run, after the agent's current one when it is not among them. */
58
+ export function formModels(
59
+ usable: readonly string[],
60
+ current?: string,
61
+ ): string[] {
62
+ const offered = FORM_MODELS.filter((m) => usable.includes(m));
63
+ return current && !offered.includes(current)
64
+ ? [current, ...offered]
65
+ : offered;
66
+ }
67
+
68
+ type Action = "edit" | "redraw" | "newprompt" | "editavatar" | "model";
69
+ const ACTIONS: readonly Action[] = [
70
+ "edit",
71
+ "redraw",
72
+ "newprompt",
73
+ "editavatar",
74
+ "model",
75
+ ];
76
+
77
+ export interface AgentCommandsOptions {
78
+ store: Pick<AgentStore, "agentByChannel" | "groupByChannel" | "agent">;
79
+ team: Pick<
80
+ AgentTeam,
81
+ "redrawAvatar" | "update" | "modelOf" | "usableModels" | "defaultModel"
82
+ >;
83
+ studio: Pick<AvatarStudio, "url">;
84
+ skills: Pick<SkillRegistry, "carried">;
85
+ }
86
+
87
+ /** The skills line of the panel: built-in ones marked, missing ones with their reason. */
88
+ export function skillsText({ skills, missing }: SkillSet): string {
89
+ const text = messages();
90
+ const names = skills.map((skill) =>
91
+ (BUILTIN_SKILLS as readonly string[]).includes(skill.name)
92
+ ? text.agentSkillBuiltin(skill.name)
93
+ : `\`${skill.name}\``,
94
+ );
95
+ return [
96
+ text.agentSkills(names),
97
+ ...missing.map(({ name, reason }) => text.agentSkillMissing(name, reason)),
98
+ ].join("\n");
99
+ }
100
+
101
+ /** `/<root> profile`: an agent's prompt, avatar, model, and skills, with buttons and forms to change them. */
102
+ export class AgentCommands {
103
+ readonly #options: AgentCommandsOptions;
104
+
105
+ constructor(options: AgentCommandsOptions) {
106
+ this.#options = options;
107
+ }
108
+
109
+ /** The panel for the agent of a channel; an error when the channel has none. */
110
+ panel(channelId: string, note?: string) {
111
+ const { store } = this.#options;
112
+ const agent = store.agentByChannel(channelId);
113
+ if (!agent)
114
+ throw new OwnerFacingError(
115
+ store.groupByChannel(channelId)
116
+ ? messages().agentGroupChannel
117
+ : messages().agentNoAgent,
118
+ );
119
+ return this.#panel(agent, note);
120
+ }
121
+
122
+ #panel(agent: Agent, note?: string) {
123
+ const text = messages();
124
+ const inline = agent.prompt.length <= INLINE_PROMPT_CHARS;
125
+ const { model, thinking } = this.#options.team.modelOf(agent.name);
126
+ const follows =
127
+ agent.model === undefined && agent.thinking === undefined
128
+ ? text.agentFollowsAssistant
129
+ : "";
130
+ const container = new ContainerBuilder()
131
+ .addTextDisplayComponents(
132
+ new TextDisplayBuilder().setContent(
133
+ text.agentHeader({
134
+ displayName: agent.displayName,
135
+ name: agent.name,
136
+ model,
137
+ thinking: thinkingLabel(thinking),
138
+ follows,
139
+ skills: skillsText(this.#options.skills.carried(agent.name)),
140
+ }),
141
+ ),
142
+ )
143
+ .addMediaGalleryComponents(
144
+ new MediaGalleryBuilder().addItems(
145
+ new MediaGalleryItemBuilder().setURL(
146
+ this.#options.studio.url(agent.avatarHash),
147
+ ),
148
+ ),
149
+ )
150
+ .addTextDisplayComponents(
151
+ new TextDisplayBuilder().setContent(
152
+ text.agentAvatarPromptText(agent.avatarPrompt),
153
+ ),
154
+ new TextDisplayBuilder().setContent(
155
+ inline
156
+ ? text.agentPromptInline(agent.prompt.replaceAll("```", "'''"))
157
+ : text.agentPromptFile(agent.prompt.length),
158
+ ),
159
+ );
160
+ if (!inline)
161
+ container.addFileComponents(
162
+ new FileBuilder().setURL("attachment://prompt.md"),
163
+ );
164
+ const button = (
165
+ action: Action,
166
+ label: string,
167
+ style = ButtonStyle.Secondary,
168
+ ) =>
169
+ new ButtonBuilder()
170
+ .setCustomId(`${AGENT_BUTTON_PREFIX}${action}:${agent.name}`)
171
+ .setLabel(label)
172
+ .setStyle(style);
173
+ container
174
+ .addSeparatorComponents(
175
+ new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small),
176
+ )
177
+ .addActionRowComponents(
178
+ new ActionRowBuilder<ButtonBuilder>().addComponents(
179
+ button("edit", text.agentEditPromptLabel, ButtonStyle.Primary),
180
+ button("redraw", text.agentRedrawLabel),
181
+ button("newprompt", text.agentNewAvatarPromptLabel),
182
+ button("editavatar", text.agentEditAvatarLabel),
183
+ button("model", text.agentModelLabel),
184
+ ),
185
+ )
186
+ .addTextDisplayComponents(
187
+ new TextDisplayBuilder().setContent(
188
+ `-# ${note ?? text.agentDefaultNote}`,
189
+ ),
190
+ );
191
+ return {
192
+ components: [container],
193
+ flags: MessageFlags.IsComponentsV2 as const,
194
+ allowedMentions: { parse: [] },
195
+ files: inline
196
+ ? []
197
+ : [
198
+ new AttachmentBuilder(Buffer.from(agent.prompt), {
199
+ name: "prompt.md",
200
+ }),
201
+ ],
202
+ };
203
+ }
204
+
205
+ /** A panel button: a form opens, or a redraw starts. */
206
+ async button(interaction: ButtonInteraction): Promise<void> {
207
+ const [action, name] = interaction.customId
208
+ .slice(AGENT_BUTTON_PREFIX.length)
209
+ .split(":");
210
+ const agent = name ? this.#options.store.agent(name) : undefined;
211
+ if (!agent || !ACTIONS.includes(action as Action))
212
+ throw new OwnerFacingError(messages().agentNotFound);
213
+ if (action === "redraw") {
214
+ await interaction.deferReply({ flags: MessageFlags.Ephemeral });
215
+ await interaction.editReply(await this.#avatar(agent.name, "redraw"));
216
+ return;
217
+ }
218
+ await interaction.showModal(
219
+ action === "model"
220
+ ? await this.#modelModal(agent)
221
+ : this.#modal(action as Action, agent),
222
+ );
223
+ }
224
+
225
+ /** Two dropdowns: the model and the thinking level, each with "follow the assistant" first. */
226
+ async #modelModal(agent: Agent): Promise<ModalBuilder> {
227
+ const { team } = this.#options;
228
+ const fallback = team.defaultModel();
229
+ const models = formModels(await team.usableModels(), agent.model);
230
+ const option = (label: string, value: string, chosen: boolean) =>
231
+ new StringSelectMenuOptionBuilder()
232
+ .setLabel(label.slice(0, 100))
233
+ .setValue(value)
234
+ .setDefault(chosen);
235
+ const modelMenu = new StringSelectMenuBuilder()
236
+ .setCustomId("model")
237
+ .addOptions(
238
+ option(
239
+ messages().agentFollowAssistant(fallback.model),
240
+ DEFAULT_SETTING,
241
+ agent.model === undefined,
242
+ ),
243
+ ...models.map((m) => option(m, m, m === agent.model)),
244
+ );
245
+ const thinkingMenu = new StringSelectMenuBuilder()
246
+ .setCustomId("thinking")
247
+ .addOptions(
248
+ option(
249
+ messages().agentFollowAssistant(thinkingLabel(fallback.thinking)),
250
+ DEFAULT_SETTING,
251
+ agent.thinking === undefined,
252
+ ),
253
+ ...THINKING_LEVELS.map((level) =>
254
+ option(level, level, level === agent.thinking),
255
+ ),
256
+ );
257
+ return new ModalBuilder()
258
+ .setCustomId(`${AGENT_MODAL_PREFIX}model:${agent.name}`)
259
+ .setTitle(messages().agentModelModalTitle(agent.displayName).slice(0, 45))
260
+ .addLabelComponents(
261
+ new LabelBuilder()
262
+ .setLabel(messages().agentModelLabel)
263
+ .setStringSelectMenuComponent(modelMenu),
264
+ new LabelBuilder()
265
+ .setLabel("Thinking")
266
+ .setStringSelectMenuComponent(thinkingMenu),
267
+ );
268
+ }
269
+
270
+ #modal(action: Action, agent: Agent): ModalBuilder {
271
+ const field = (
272
+ id: string,
273
+ label: string,
274
+ style: TextInputStyle,
275
+ max: number,
276
+ value?: string,
277
+ ) => {
278
+ const input = new TextInputBuilder()
279
+ .setCustomId(id)
280
+ .setStyle(style)
281
+ .setRequired(true)
282
+ .setMaxLength(max);
283
+ if (value) input.setValue(value);
284
+ return new LabelBuilder().setLabel(label).setTextInputComponent(input);
285
+ };
286
+ const modal = new ModalBuilder().setCustomId(
287
+ `${AGENT_MODAL_PREFIX}${action}:${agent.name}`,
288
+ );
289
+ if (action === "edit")
290
+ return modal
291
+ .setTitle(
292
+ messages().agentEditModalTitle(agent.displayName).slice(0, 45),
293
+ )
294
+ .addLabelComponents(
295
+ field(
296
+ "display",
297
+ messages().agentDisplayNameLabel,
298
+ TextInputStyle.Short,
299
+ 32,
300
+ agent.displayName,
301
+ ),
302
+ field(
303
+ "prompt",
304
+ messages().agentPromptLabel,
305
+ TextInputStyle.Paragraph,
306
+ MAX_PROMPT_CHARS,
307
+ agent.prompt,
308
+ ),
309
+ );
310
+ if (action === "newprompt")
311
+ return modal
312
+ .setTitle(messages().agentNewAvatarPromptLabel)
313
+ .addLabelComponents(
314
+ field(
315
+ "text",
316
+ messages().agentAvatarPromptLabel,
317
+ TextInputStyle.Paragraph,
318
+ MAX_AVATAR_PROMPT_CHARS,
319
+ agent.avatarPrompt,
320
+ ),
321
+ );
322
+ return modal
323
+ .setTitle(messages().agentEditAvatarLabel)
324
+ .addLabelComponents(
325
+ field(
326
+ "text",
327
+ messages().agentEditAvatarField,
328
+ TextInputStyle.Paragraph,
329
+ MAX_AVATAR_PROMPT_CHARS,
330
+ ),
331
+ );
332
+ }
333
+
334
+ /** A submitted form; the reply is deferred by the caller. */
335
+ async submit(interaction: ModalSubmitInteraction): Promise<void> {
336
+ const [action, name] = interaction.customId
337
+ .slice(AGENT_MODAL_PREFIX.length)
338
+ .split(":");
339
+ const agent = name ? this.#options.store.agent(name) : undefined;
340
+ if (!agent) throw new OwnerFacingError(messages().agentNotFound);
341
+ const value = (id: string) => interaction.fields.getTextInputValue(id);
342
+ const chosen = (id: string) =>
343
+ interaction.fields.getStringSelectValues(id)[0] ?? DEFAULT_SETTING;
344
+ if (action === "edit" || action === "model") {
345
+ try {
346
+ await this.#options.team.update(
347
+ agent.name,
348
+ action === "edit"
349
+ ? { displayName: value("display"), prompt: value("prompt") }
350
+ : { model: chosen("model"), thinking: chosen("thinking") },
351
+ );
352
+ } catch (error) {
353
+ if (error instanceof AgentError)
354
+ throw new OwnerFacingError(error.message);
355
+ throw error;
356
+ }
357
+ const updated = this.#options.store.agent(agent.name) ?? agent;
358
+ await interaction.editReply(this.#panel(updated, messages().agentSaved));
359
+ return;
360
+ }
361
+ const mode: AvatarMode = action === "newprompt" ? "new_prompt" : "edit";
362
+ await interaction.editReply(
363
+ await this.#avatar(agent.name, mode, value("text")),
364
+ );
365
+ }
366
+
367
+ async #avatar(name: string, mode: AvatarMode, text?: string) {
368
+ try {
369
+ const agent = await this.#options.team.redrawAvatar(name, mode, text);
370
+ return this.#panel(agent, messages().agentAvatarRedrawn);
371
+ } catch (error) {
372
+ if (!(error instanceof AgentError)) throw error;
373
+ const agent = this.#options.store.agent(name);
374
+ return agent
375
+ ? this.#panel(
376
+ agent,
377
+ messages().agentAvatarFailed(error.message).slice(0, 300),
378
+ )
379
+ : ownerPanel({
380
+ title: messages().ownerFailedTitle,
381
+ sections: [error.message],
382
+ });
383
+ }
384
+ }
385
+ }
@@ -0,0 +1,70 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { ChannelType, type Client } from "discord.js";
3
+ import { silentLogger } from "../log.ts";
4
+ import { DiscordDashboard } from "./agent-discord.ts";
5
+
6
+ const BOT = "900";
7
+
8
+ /** A dashboard channel in a fake server whose one bot message may already be pinned. */
9
+ function fixture(options: { pinned: boolean; pinFails?: boolean }) {
10
+ const pins: string[] = [];
11
+ const message = {
12
+ id: "m1",
13
+ pinned: options.pinned,
14
+ author: { id: BOT },
15
+ channel: { messages: { fetch: async () => message } },
16
+ edit: async () => message,
17
+ pin: async () => {
18
+ if (options.pinFails) throw new Error("Missing Permissions");
19
+ pins.push("m1");
20
+ },
21
+ };
22
+ const channel = {
23
+ type: ChannelType.GuildText,
24
+ name: "dashboard",
25
+ parentId: null,
26
+ topic: null,
27
+ setTopic: async () => undefined,
28
+ messages: {
29
+ fetch: async () => ({
30
+ find: (match: (m: typeof message) => boolean) => [message].find(match),
31
+ }),
32
+ },
33
+ };
34
+ channel.topic = null;
35
+ const client = {
36
+ user: { id: BOT },
37
+ guilds: {
38
+ fetch: async () => ({
39
+ channels: {
40
+ fetch: async () => ({
41
+ find: (match: (c: typeof channel) => boolean) =>
42
+ [channel].find(match),
43
+ }),
44
+ },
45
+ }),
46
+ },
47
+ } as unknown as Client;
48
+ return { pins, dashboard: new DiscordDashboard(client, "1", silentLogger()) };
49
+ }
50
+
51
+ describe("DiscordDashboard pinning", () => {
52
+ test("an existing unpinned message is pinned once, however often it is updated", async () => {
53
+ const { dashboard, pins } = fixture({ pinned: false });
54
+ await dashboard.show(["a"]);
55
+ await dashboard.show(["b"]);
56
+ expect(pins).toEqual(["m1"]);
57
+ });
58
+
59
+ test("an already pinned message is left alone", async () => {
60
+ const { dashboard, pins } = fixture({ pinned: true });
61
+ await dashboard.show(["a"]);
62
+ expect(pins).toEqual([]);
63
+ });
64
+
65
+ test("a refused pin does not stop the update", async () => {
66
+ const { dashboard } = fixture({ pinned: false, pinFails: true });
67
+ await dashboard.show(["a"]);
68
+ await dashboard.show(["b"]);
69
+ });
70
+ });