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,142 @@
1
+ import { expect, test } from "bun:test";
2
+ import type { InboundMessage } from "../contract/channels.ts";
3
+ import { silentLogger } from "../log.ts";
4
+ import { type Speaker, speakerPolicy } from "../speakers.ts";
5
+ import { TEST_OWNER } from "../testing/owner.ts";
6
+ import { agentClaim } from "./agent-claim.ts";
7
+
8
+ const OWNER = "100000000000000001";
9
+ const ADMIN = "100000000000000002";
10
+ const STRANGER = "100000000000000009";
11
+ const ROLE = "200000000000000001";
12
+
13
+ function setup() {
14
+ const answered: { kind: string; speaker: Speaker; text: string }[] = [];
15
+ const team = {
16
+ guildId: "1",
17
+ owns: (channel: string) =>
18
+ channel === "discord:10"
19
+ ? ("agent" as const)
20
+ : channel === "discord:20"
21
+ ? ("group" as const)
22
+ : undefined,
23
+ answerOwner: async (_c: string, speaker: Speaker, text: string) => {
24
+ answered.push({ kind: "agent", speaker, text });
25
+ return { ok: true as const, text: "" };
26
+ },
27
+ answerGroup: async (_c: string, speaker: Speaker, text: string) => {
28
+ answered.push({ kind: "group", speaker, text });
29
+ },
30
+ answerBackground: async (_c: string, speaker: Speaker, text: string) => {
31
+ answered.push({ kind: "background", speaker, text });
32
+ return { ok: true as const, text: "" };
33
+ },
34
+ startFresh: async () => {},
35
+ };
36
+ const claim = agentClaim({
37
+ owner: TEST_OWNER,
38
+ speakers: speakerPolicy({
39
+ owners: [OWNER],
40
+ admins: { users: [ADMIN] },
41
+ members: { roles: [ROLE] },
42
+ }),
43
+ // biome-ignore lint/suspicious/noExplicitAny: a recording stand-in for the team
44
+ team: team as any,
45
+ runtime: { steer: async () => false },
46
+ surface: { react: async () => {}, unreact: async () => {} },
47
+ attachmentDir: () => "/tmp",
48
+ logger: silentLogger(),
49
+ });
50
+ return { claim, answered };
51
+ }
52
+
53
+ const message = (
54
+ authorId: string,
55
+ channel: `${string}:${string}`,
56
+ extra: Partial<InboundMessage> = {},
57
+ ): InboundMessage => ({
58
+ channel,
59
+ messageId: "m1",
60
+ authorId,
61
+ authorName: `name-${authorId.slice(-1)}`,
62
+ authorIsBot: false,
63
+ isDirect: false,
64
+ guildId: "1",
65
+ mentionsBot: false,
66
+ repliesToBot: false,
67
+ text: "hello",
68
+ attachments: [],
69
+ ...extra,
70
+ });
71
+
72
+ test("the owner, an admin, and a member by role are each answered as themselves", async () => {
73
+ const { claim, answered } = setup();
74
+ for (const [id, roles] of [
75
+ [OWNER, []],
76
+ [ADMIN, []],
77
+ [STRANGER, [ROLE]],
78
+ ] as const) {
79
+ const admission = claim.admit(
80
+ message(id, "discord:10", { authorRoleIds: roles }),
81
+ );
82
+ if (admission?.kind !== "turn") throw new Error("expected a turn");
83
+ await admission.run();
84
+ }
85
+ expect(answered.map((a) => a.speaker.tier)).toEqual([
86
+ "owner",
87
+ "admin",
88
+ "member",
89
+ ]);
90
+ expect(answered[1]?.speaker).toEqual({
91
+ id: ADMIN,
92
+ name: "name-2",
93
+ tier: "admin",
94
+ });
95
+ });
96
+
97
+ test("a turn in an agent's channel names a speaker who is not the owner, and the owner's stays plain", async () => {
98
+ const { claim, answered } = setup();
99
+ for (const id of [OWNER, ADMIN]) {
100
+ const admission = claim.admit(message(id, "discord:10"));
101
+ if (admission?.kind !== "turn") throw new Error("expected a turn");
102
+ await admission.run();
103
+ }
104
+ expect(answered[0]?.text).toBe("hello");
105
+ expect(answered[1]?.text).toBe(
106
+ "(Message from name-2, at the admin tier.)\n\nhello",
107
+ );
108
+ });
109
+
110
+ test("an author the policy does not name gets no turn, in an agent or a group channel", () => {
111
+ const { claim } = setup();
112
+ expect(claim.admit(message(STRANGER, "discord:10"))).toBeUndefined();
113
+ expect(claim.admit(message(STRANGER, "discord:20"))).toBeUndefined();
114
+ expect(
115
+ claim.admit(message(OWNER, "discord:10", { authorIsBot: true })),
116
+ ).toBeUndefined();
117
+ });
118
+
119
+ test("a group message carries its speaker", async () => {
120
+ const { claim, answered } = setup();
121
+ const admission = claim.admit(message(ADMIN, "discord:20"));
122
+ if (admission?.kind !== "turn") throw new Error("expected a turn");
123
+ await admission.run();
124
+ expect(answered[0]).toMatchObject({
125
+ kind: "group",
126
+ speaker: { tier: "admin" },
127
+ });
128
+ });
129
+
130
+ test("a background turn runs at its creator's tier, the owner's when a person set none", async () => {
131
+ const { claim, answered } = setup();
132
+ const turn = {
133
+ channel: "discord:10" as const,
134
+ mode: "owner" as const,
135
+ author: { id: ADMIN, name: "n" },
136
+ turnId: "t",
137
+ text: "go",
138
+ };
139
+ await claim.background?.({ ...turn, tier: "admin" });
140
+ await claim.background?.(turn);
141
+ expect(answered.map((a) => a.speaker.tier)).toEqual(["admin", "owner"]);
142
+ });
@@ -0,0 +1,170 @@
1
+ import { attachmentsOf } from "../attachments/turn-attachments.ts";
2
+ import type {
3
+ Admission,
4
+ ChannelClaim,
5
+ InboundMessage,
6
+ } from "../contract/channels.ts";
7
+ import type { ChannelKey } from "../domain/conversation.ts";
8
+ import type { AgentRuntime, ChatSurface } from "../domain/ports.ts";
9
+ import type { OwnerIdentity } from "../identity.ts";
10
+ import type { Logger } from "../log.ts";
11
+ import { withReference } from "../routing/message-text.ts";
12
+ import { outcome } from "../routing/settle-turn.ts";
13
+ import { addressee, attributed, type SpeakerPolicy } from "../speakers.ts";
14
+ import type { AgentTeam } from "./agent-team.ts";
15
+
16
+ /**
17
+ * The agent server outranks every other claim, and its guild's other channels stay silent even
18
+ * when party mode or a mention would answer there. It becomes the core's reserved branch once
19
+ * the team moves into the core (pi-roundtable batch 7).
20
+ */
21
+ export const AGENT_SERVER_PRIORITY = 100;
22
+
23
+ export interface AgentClaimOptions {
24
+ /** The owner, as forwarded messages name them. */
25
+ owner: OwnerIdentity;
26
+ /** Who may talk to the agents, and at which tier. */
27
+ speakers: SpeakerPolicy;
28
+ team: Pick<
29
+ AgentTeam,
30
+ | "guildId"
31
+ | "owns"
32
+ | "answerOwner"
33
+ | "answerGroup"
34
+ | "answerBackground"
35
+ | "startFresh"
36
+ >;
37
+ runtime: Pick<AgentRuntime, "steer">;
38
+ surface: Pick<ChatSurface, "react" | "unreact">;
39
+ /** Where a channel's attachments are saved. */
40
+ attachmentDir: (channel: ChannelKey) => string;
41
+ logger: Logger;
42
+ /** Replaceable in tests. */
43
+ fetchImpl?: typeof fetch;
44
+ }
45
+
46
+ /**
47
+ * The agent server's channels: every owner message in an agent's or group's channel is answered
48
+ * there, mention or not, and an outside webhook's post in an agent's channel, such as a CI
49
+ * failure notice, is a labelled report turn for the agent.
50
+ */
51
+ export function agentClaim(options: AgentClaimOptions): ChannelClaim {
52
+ const { speakers, team, runtime, surface, attachmentDir, logger, fetchImpl } =
53
+ options;
54
+ const attachments = (message: InboundMessage) =>
55
+ attachmentsOf(message, attachmentDir(message.channel), logger, fetchImpl);
56
+
57
+ const webhookReport = (message: InboundMessage): Admission | undefined => {
58
+ const text = message.text.trim();
59
+ if (!text) return undefined;
60
+ return {
61
+ kind: "background",
62
+ turn: {
63
+ channel: message.channel,
64
+ mode: "owner",
65
+ author: { id: message.authorId, name: message.authorName },
66
+ turnId: `webhook-${message.messageId}`,
67
+ text: `Webhook "${message.authorName}" posted in your channel:\n${text}`,
68
+ report: true,
69
+ },
70
+ unanswered: (result) =>
71
+ logger.warn(
72
+ {
73
+ channel: message.channel,
74
+ webhook: message.webhookId,
75
+ outcome: result,
76
+ },
77
+ "webhook post not answered",
78
+ ),
79
+ };
80
+ };
81
+
82
+ return {
83
+ name: "agent-server",
84
+ priority: AGENT_SERVER_PRIORITY,
85
+ owns: (channel, guildId) =>
86
+ team.owns(channel) !== undefined ||
87
+ (guildId !== undefined && guildId === team.guildId),
88
+ admit: (message) => {
89
+ const owned = team.owns(message.channel);
90
+ // The agent server's other channels are the owner's notes: nothing there is answered.
91
+ if (!owned) return undefined;
92
+ if (owned === "agent" && message.webhookId && !message.ownWebhook)
93
+ return webhookReport(message);
94
+ if (message.authorIsBot) return undefined;
95
+ const speaker = speakers.resolve({
96
+ id: message.authorId,
97
+ name: message.authorName,
98
+ ...(message.authorRoleIds ? { roleIds: message.authorRoleIds } : {}),
99
+ });
100
+ if (!speaker) return undefined;
101
+ const { channel, messageId } = message;
102
+ const forwarder = addressee(speaker, options.owner);
103
+ return {
104
+ kind: "turn",
105
+ busy: {
106
+ // A group's round is not steered; its messages wait their turn.
107
+ ...(owned === "agent"
108
+ ? {
109
+ steer: async () =>
110
+ runtime.steer(
111
+ channel,
112
+ attributed(speaker, withReference(message, forwarder)),
113
+ await attachments(message),
114
+ speaker.id,
115
+ ),
116
+ }
117
+ : {}),
118
+ react: (emoji) => surface.react(channel, messageId, emoji),
119
+ unreact: (emoji) => surface.unreact(channel, messageId, emoji),
120
+ },
121
+ run: async () => {
122
+ const saved = await attachments(message);
123
+ const text = withReference(message, forwarder);
124
+ // A group's history already names each author.
125
+ if (owned === "agent")
126
+ await team.answerOwner(
127
+ channel,
128
+ speaker,
129
+ attributed(speaker, text),
130
+ message.text,
131
+ saved,
132
+ );
133
+ else
134
+ await team.answerGroup(
135
+ channel,
136
+ speaker,
137
+ text,
138
+ message.text,
139
+ saved,
140
+ message.reference?.webhookName,
141
+ );
142
+ },
143
+ failure: "agent message handling failed",
144
+ };
145
+ },
146
+ background: async (turn) => {
147
+ if (turn.mode === "party")
148
+ return { status: "skipped", reason: "party mode is off" };
149
+ const owned = team.owns(turn.channel);
150
+ if (owned === "group")
151
+ return { status: "skipped", reason: "groups have no schedules" };
152
+ return outcome(
153
+ await team.answerBackground(
154
+ turn.channel,
155
+ {
156
+ id: turn.author.id,
157
+ name: turn.author.name,
158
+ tier: turn.tier ?? "owner",
159
+ },
160
+ turn.text,
161
+ turn.report === true,
162
+ ),
163
+ );
164
+ },
165
+ startFresh: async (channel) => {
166
+ await team.startFresh(channel);
167
+ return "agent";
168
+ },
169
+ };
170
+ }
@@ -0,0 +1,120 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { silentLogger } from "../log.ts";
3
+ import { AgentDashboard, dashboardSections } from "./agent-dashboard.ts";
4
+ import type { TeamStatus } from "./agent-team.ts";
5
+
6
+ const NOW = new Date("2026-09-27T06:30:00Z");
7
+ const TRACES = "https://example.com/traces/";
8
+ const APP = "https://example.com/app/";
9
+ /** The plugins' lines: the web app's, then the trace viewer's. */
10
+ const LINES = [
11
+ `🧭 [Web app](${APP}) full dashboard, notes, and cleanup`,
12
+ `🔭 [Traces](${TRACES}) each message's model, tools, and tokens`,
13
+ ];
14
+
15
+ const status: TeamStatus = {
16
+ agents: [
17
+ {
18
+ name: "infra",
19
+ displayName: "Infra",
20
+ channelId: "11",
21
+ model: "openai-codex/gpt-6-sol",
22
+ thinking: "high",
23
+ workingIn: "12",
24
+ waiting: 2,
25
+ context: { tokens: 42_000, contextWindow: 200_000 },
26
+ lastActive: new Date("2026-09-27T06:03:00Z"),
27
+ schedules: 1,
28
+ },
29
+ {
30
+ name: "doctor",
31
+ displayName: "Bot Doctor",
32
+ channelId: "13",
33
+ model: "claude-bridge/claude-opus-5-5",
34
+ thinking: "medium",
35
+ waiting: 0,
36
+ schedules: 0,
37
+ },
38
+ ],
39
+ groups: [
40
+ {
41
+ name: "ops",
42
+ displayName: "Ops group",
43
+ channelId: "12",
44
+ members: ["Coordinator", "Infra"],
45
+ host: "Coordinator",
46
+ busy: 1,
47
+ lastActive: new Date("2026-09-26T06:03:00Z"),
48
+ },
49
+ ],
50
+ };
51
+
52
+ describe("dashboardSections", () => {
53
+ test("shows the plugins' links, then each agent's and group's state", () => {
54
+ const [header, agents, groups] = dashboardSections(status, LINES, NOW);
55
+ // The header the links were written into before plugins contributed them, byte for byte.
56
+ expect(header).toBe(
57
+ "## Agent server\n🧭 [Web app](https://example.com/app/) full dashboard, notes, and cleanup\n🔭 [Traces](https://example.com/traces/) each message's model, tools, and tokens\n-# Updated 06:30; refreshed when a turn starts or ends, at most every 10 seconds",
58
+ );
59
+ expect(agents).toContain("**Infra** <#11>");
60
+ expect(agents).toContain("`openai-codex/gpt-6-sol` · thinking `high`");
61
+ expect(agents).toContain(
62
+ "🟠 Working (<#12>), 2 queued · context 42k / 200k (21%)",
63
+ );
64
+ expect(agents).toContain("-# Schedules 1 · Last active 06:03");
65
+ expect(agents).toContain("🟢 Idle · context —");
66
+ expect(agents).toContain("No activity since startup");
67
+ expect(groups).toContain("**Ops group** <#12>");
68
+ expect(groups).toContain("Members: Coordinator, Infra | Host: Coordinator");
69
+ expect(groups).toContain("🟠 1 in progress");
70
+ expect(groups).toContain("-# Last active 09-26 06:03");
71
+ });
72
+
73
+ test("stays within Discord's text limit, saying how many were left out", () => {
74
+ const many: TeamStatus = {
75
+ agents: Array.from({ length: 60 }, (_, i) => ({
76
+ ...status.agents[1],
77
+ name: `a${i}`,
78
+ displayName: `Agent ${i}`,
79
+ })) as TeamStatus["agents"],
80
+ groups: [],
81
+ };
82
+ const sections = dashboardSections(many, LINES, NOW);
83
+ expect(sections.join("").length).toBeLessThan(4000);
84
+ expect(sections[1]).toMatch(/\d+ more not listed/);
85
+ expect(sections[2]).toContain("None.");
86
+ });
87
+ });
88
+
89
+ describe("AgentDashboard", () => {
90
+ test("updates at once, then at most once per interval, and keeps going after a failure", async () => {
91
+ const shown: string[][] = [];
92
+ let fail = true;
93
+ const dashboard = new AgentDashboard({
94
+ status: async () => status,
95
+ board: {
96
+ show: async (sections) => {
97
+ if (fail) {
98
+ fail = false;
99
+ throw new Error("discord down");
100
+ }
101
+ shown.push(sections);
102
+ },
103
+ },
104
+ lines: () => LINES,
105
+ logger: silentLogger(),
106
+ minIntervalMs: 40,
107
+ refreshMs: 60_000,
108
+ });
109
+ dashboard.start();
110
+ await Bun.sleep(10);
111
+ expect(shown).toHaveLength(0);
112
+ dashboard.changed();
113
+ dashboard.changed();
114
+ await Bun.sleep(15);
115
+ expect(shown).toHaveLength(0);
116
+ await Bun.sleep(50);
117
+ expect(shown).toHaveLength(1);
118
+ dashboard.stop();
119
+ });
120
+ });
@@ -0,0 +1,185 @@
1
+ import { messages } from "../i18n/index.ts";
2
+ import type { Logger } from "../log.ts";
3
+ import { thinkingLabel } from "../models.ts";
4
+ import { zonedDate, zonedStamp } from "../time.ts";
5
+ import type { ContextUse, DashboardBoard } from "./agent-ports.ts";
6
+ import type { AgentStatus, GroupStatus, TeamStatus } from "./agent-team.ts";
7
+
8
+ /** At most one edit this often (spec behavior 50); Discord limits message edits per channel. */
9
+ const DASHBOARD_MIN_INTERVAL_MS = 10_000;
10
+ /** Refreshed this often regardless, so last-activity times and context use stay current. */
11
+ const DASHBOARD_REFRESH_MS = 5 * 60_000;
12
+ /** Components V2 allows 4000 characters of text per message. */
13
+ const TEXT_BUDGET = 3_800;
14
+
15
+ /** Configured local time, with the month and day when it is not today's. */
16
+ function when(at: Date, now: Date): string {
17
+ const date = zonedDate(at);
18
+ const clock = zonedStamp(at).slice(11);
19
+ return date === zonedDate(now) ? clock : `${date.slice(5)} ${clock}`;
20
+ }
21
+
22
+ const kilo = (tokens: number) => `${Math.round(tokens / 1000)}k`;
23
+
24
+ function contextText(context: ContextUse | undefined): string {
25
+ const text = messages();
26
+ if (!context) return text.dashContextNone;
27
+ if (context.tokens === null)
28
+ return text.dashContextCompacted(kilo(context.contextWindow));
29
+ const percent = Math.round((context.tokens / context.contextWindow) * 100);
30
+ return text.dashContext(
31
+ kilo(context.tokens),
32
+ kilo(context.contextWindow),
33
+ percent,
34
+ );
35
+ }
36
+
37
+ function agentState(agent: AgentStatus): string {
38
+ const text = messages();
39
+ if (agent.workingIn) return text.dashWorking(agent.workingIn, agent.waiting);
40
+ return agent.waiting > 0 ? text.dashQueued(agent.waiting) : text.dashIdle;
41
+ }
42
+
43
+ function agentBlock(agent: AgentStatus, now: Date): string {
44
+ const channel = agent.channelId ? ` <#${agent.channelId}>` : "";
45
+ const text = messages();
46
+ const last = agent.lastActive
47
+ ? text.dashLastActive(when(agent.lastActive, now))
48
+ : text.dashNotActive;
49
+ return [
50
+ `**${agent.displayName}**${channel}`,
51
+ `\`${agent.model}\` · thinking \`${thinkingLabel(agent.thinking)}\``,
52
+ `${agentState(agent)} · ${contextText(agent.context)}`,
53
+ text.dashAgentFooter(agent.schedules, last),
54
+ ].join("\n");
55
+ }
56
+
57
+ function groupBlock(group: GroupStatus, now: Date): string {
58
+ const text = messages();
59
+ const state = group.busy > 0 ? text.dashGroupBusy(group.busy) : text.dashIdle;
60
+ const last = group.lastActive
61
+ ? text.dashLastActive(when(group.lastActive, now))
62
+ : text.dashNotActive;
63
+ return [
64
+ `**${group.displayName}** <#${group.channelId}>`,
65
+ text.dashGroupMembers(group.members, group.host),
66
+ state,
67
+ `-# ${last}`,
68
+ ].join("\n");
69
+ }
70
+
71
+ /** Joins blocks under a heading until the text budget runs out, then says how many were left. */
72
+ function listed(heading: string, blocks: string[], budget: number): string {
73
+ let text = heading;
74
+ for (const [index, block] of blocks.entries()) {
75
+ if (text.length + block.length + 2 > budget) {
76
+ return `${text}\n\n${messages().dashMore(blocks.length - index)}`;
77
+ }
78
+ text += `\n\n${block}`;
79
+ }
80
+ return blocks.length > 0 ? text : `${text}\n${messages().dashNone}`;
81
+ }
82
+
83
+ /**
84
+ * The dashboard message: the plugins' lines, such as links, then every active agent and group
85
+ * (spec behavior 49; web app spec behavior 6).
86
+ */
87
+ export function dashboardSections(
88
+ status: TeamStatus,
89
+ lines: readonly string[],
90
+ now: Date,
91
+ ): string[] {
92
+ const text = messages();
93
+ const header = [
94
+ text.dashTitle,
95
+ ...lines,
96
+ text.dashUpdated(when(now, now)),
97
+ ].join("\n");
98
+ const budget = (TEXT_BUDGET - header.length) / 2;
99
+ return [
100
+ header,
101
+ listed(
102
+ "### Agents",
103
+ status.agents.map((agent) => agentBlock(agent, now)),
104
+ budget,
105
+ ),
106
+ listed(
107
+ text.dashGroupsHeading,
108
+ status.groups.map((group) => groupBlock(group, now)),
109
+ budget,
110
+ ),
111
+ ];
112
+ }
113
+
114
+ export interface AgentDashboardOptions {
115
+ status: () => Promise<TeamStatus>;
116
+ board: DashboardBoard;
117
+ /** The plugins' lines under the title, read at each update. */
118
+ lines: () => readonly string[];
119
+ logger: Logger;
120
+ minIntervalMs?: number;
121
+ refreshMs?: number;
122
+ }
123
+
124
+ /**
125
+ * Keeps the dashboard message current: an update right after `changed()` unless one ran in the
126
+ * last interval, in which case one runs when the interval ends; plus a periodic refresh.
127
+ */
128
+ export class AgentDashboard {
129
+ readonly #options: AgentDashboardOptions;
130
+ #lastRun = 0;
131
+ #pending: ReturnType<typeof setTimeout> | undefined;
132
+ #refresh: ReturnType<typeof setInterval> | undefined;
133
+ #running: Promise<void> | undefined;
134
+
135
+ constructor(options: AgentDashboardOptions) {
136
+ this.#options = options;
137
+ }
138
+
139
+ start(): void {
140
+ this.changed();
141
+ this.#refresh = setInterval(
142
+ () => this.changed(),
143
+ this.#options.refreshMs ?? DASHBOARD_REFRESH_MS,
144
+ );
145
+ }
146
+
147
+ stop(): void {
148
+ clearTimeout(this.#pending);
149
+ clearInterval(this.#refresh);
150
+ this.#pending = undefined;
151
+ }
152
+
153
+ changed(): void {
154
+ if (this.#pending) return;
155
+ const interval = this.#options.minIntervalMs ?? DASHBOARD_MIN_INTERVAL_MS;
156
+ const wait = Math.max(0, this.#lastRun + interval - Date.now());
157
+ this.#pending = setTimeout(() => {
158
+ this.#pending = undefined;
159
+ void this.#update();
160
+ }, wait);
161
+ }
162
+
163
+ /** One update at a time; a change during an update schedules the next one. */
164
+ async #update(): Promise<void> {
165
+ if (this.#running) {
166
+ await this.#running;
167
+ this.changed();
168
+ return;
169
+ }
170
+ this.#lastRun = Date.now();
171
+ this.#running = this.#show().finally(() => {
172
+ this.#running = undefined;
173
+ });
174
+ await this.#running;
175
+ }
176
+
177
+ async #show(): Promise<void> {
178
+ const { status, board, lines, logger } = this.#options;
179
+ try {
180
+ await board.show(dashboardSections(await status(), lines(), new Date()));
181
+ } catch (error) {
182
+ logger.warn({ err: error }, "dashboard not updated");
183
+ }
184
+ }
185
+ }