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,55 @@
1
+ import type { Judge, Providers } from "../contract/providers.ts";
2
+ import { PluginError, ProviderError } from "../errors.ts";
3
+ import { type JudgeModel, ModelJudge } from "../judging/model-judge.ts";
4
+ import type { RoundtablePlugin } from "../plugin.ts";
5
+
6
+ const SLOTS = [
7
+ "judge",
8
+ "images",
9
+ ] as const satisfies readonly (keyof Providers)[];
10
+
11
+ /** A judge with no model: every question throws, so each caller keeps its own default. */
12
+ const unconfiguredJudge = (): Judge => {
13
+ const refuse = async (): Promise<never> => {
14
+ throw new ProviderError(
15
+ "no judge is configured: provide one or give the host a judge model",
16
+ );
17
+ };
18
+ return { askYesNo: refuse, askChoice: refuse, askScore: refuse };
19
+ };
20
+
21
+ /** The core's own providers, for the slots no plugin fills. */
22
+ function defaults(judgeModel: JudgeModel | undefined): Providers {
23
+ return {
24
+ judge: judgeModel ? new ModelJudge(judgeModel) : unconfiguredJudge(),
25
+ images: async () => {
26
+ throw new ProviderError(
27
+ "no image provider is configured; agents keep the neutral avatar",
28
+ );
29
+ },
30
+ };
31
+ }
32
+
33
+ /** Each slot from the one plugin that fills it, else the core's default; two plugins filling one slot clash. */
34
+ export function resolveProviders(
35
+ plugins: readonly RoundtablePlugin[],
36
+ judgeModel?: JudgeModel,
37
+ ): Providers {
38
+ const resolved = defaults(judgeModel);
39
+ const filledBy = new Map<keyof Providers, string>();
40
+ for (const plugin of plugins) {
41
+ for (const slot of SLOTS) {
42
+ const provided = plugin.providers?.[slot];
43
+ if (!provided) continue;
44
+ const other = filledBy.get(slot);
45
+ if (other)
46
+ throw new PluginError(
47
+ `plugin ${plugin.name}: provider slot ${slot} is already filled by plugin ${other}. Keep one plugin that fills it.`,
48
+ );
49
+ filledBy.set(slot, plugin.name);
50
+ // One key of a Providers, so the slot and its provider agree.
51
+ Object.assign(resolved, { [slot]: provided });
52
+ }
53
+ }
54
+ return resolved;
55
+ }
@@ -0,0 +1,31 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { ChannelQueue } from "./channel-queue.ts";
3
+
4
+ describe("ChannelQueue", () => {
5
+ test("reports every change of a channel's count", async () => {
6
+ const queue = new ChannelQueue();
7
+ const seen: [string, number][] = [];
8
+ queue.onChange((channel) => seen.push([channel, queue.size(channel)]));
9
+ let release = () => {};
10
+ const first = queue.run(
11
+ "discord:1",
12
+ () => new Promise<void>((resolve) => (release = resolve)),
13
+ );
14
+ const second = queue.run("discord:1", async () => {});
15
+ expect(seen).toEqual([
16
+ ["discord:1", 1],
17
+ ["discord:1", 2],
18
+ ]);
19
+ // The first task starts on a later tick.
20
+ await Bun.sleep(0);
21
+ release();
22
+ await Promise.all([first, second]);
23
+ await Bun.sleep(0);
24
+ expect(seen).toEqual([
25
+ ["discord:1", 1],
26
+ ["discord:1", 2],
27
+ ["discord:1", 1],
28
+ ["discord:1", 0],
29
+ ]);
30
+ });
31
+ });
@@ -0,0 +1,44 @@
1
+ import type { ChannelKey } from "../sessions.ts";
2
+
3
+ /** Runs tasks one at a time per channel, in arrival order; different channels run independently. */
4
+ export class ChannelQueue {
5
+ readonly #tails = new Map<ChannelKey, Promise<unknown>>();
6
+ readonly #sizes = new Map<ChannelKey, number>();
7
+ readonly #listeners: ((channel: ChannelKey) => void)[] = [];
8
+
9
+ /** Called whenever a channel's count of running and waiting tasks changes. */
10
+ onChange(listener: (channel: ChannelKey) => void): void {
11
+ this.#listeners.push(listener);
12
+ }
13
+
14
+ #changed(channel: ChannelKey): void {
15
+ for (const listener of this.#listeners) listener(channel);
16
+ }
17
+
18
+ run<T>(channel: ChannelKey, task: () => Promise<T>): Promise<T> {
19
+ this.#sizes.set(channel, this.size(channel) + 1);
20
+ this.#changed(channel);
21
+ const previous = this.#tails.get(channel) ?? Promise.resolve();
22
+ const result = previous.then(task, task);
23
+ const tail = result.catch(() => undefined);
24
+ this.#tails.set(channel, tail);
25
+ void tail.then(() => {
26
+ const left = this.size(channel) - 1;
27
+ if (left > 0) this.#sizes.set(channel, left);
28
+ else this.#sizes.delete(channel);
29
+ if (this.#tails.get(channel) === tail) this.#tails.delete(channel);
30
+ this.#changed(channel);
31
+ });
32
+ return result;
33
+ }
34
+
35
+ /** Tasks of the channel running or waiting. */
36
+ size(channel: ChannelKey): number {
37
+ return this.#sizes.get(channel) ?? 0;
38
+ }
39
+
40
+ /** Channels with a task running or waiting. */
41
+ busy(): ChannelKey[] {
42
+ return [...this.#sizes.keys()];
43
+ }
44
+ }
@@ -0,0 +1,326 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type {
3
+ Admission,
4
+ ChannelClaim,
5
+ InboundMessage,
6
+ } from "../contract/channels.ts";
7
+ import { PluginError } from "../errors.ts";
8
+ import { silentLogger } from "../log.ts";
9
+ import type { ChannelKey } from "../sessions.ts";
10
+ import { ChannelQueue } from "./channel-queue.ts";
11
+ import { ChannelRouter, orderClaims } from "./channel-router.ts";
12
+
13
+ function message(overrides: Partial<InboundMessage> = {}): InboundMessage {
14
+ return {
15
+ channel: "discord:1",
16
+ messageId: "m1",
17
+ authorId: "owner",
18
+ authorName: "Riley",
19
+ authorIsBot: false,
20
+ isDirect: true,
21
+ mentionsBot: false,
22
+ repliesToBot: false,
23
+ text: "hi",
24
+ attachments: [],
25
+ ...overrides,
26
+ };
27
+ }
28
+
29
+ /** A claim over `channels` (every channel when empty) that records what it does in `log`. */
30
+ function claim(
31
+ name: string,
32
+ priority: number,
33
+ log: string[],
34
+ options: {
35
+ channels?: string[];
36
+ guild?: string;
37
+ admit?: (message: InboundMessage) => Admission | undefined;
38
+ deletes?: boolean;
39
+ } = {},
40
+ ): ChannelClaim {
41
+ const { channels = [], guild } = options;
42
+ return {
43
+ name,
44
+ priority,
45
+ owns: (channel, guildId) =>
46
+ channels.length === 0 ||
47
+ channels.includes(channel) ||
48
+ (guild !== undefined && guildId === guild),
49
+ admit:
50
+ options.admit ??
51
+ ((m) => ({
52
+ kind: "turn",
53
+ run: async () => void log.push(`${name} ${m.text}`),
54
+ failure: `${name} failed`,
55
+ })),
56
+ background: async (turn) => {
57
+ log.push(`${name} background ${turn.text}`);
58
+ return { status: "ran" };
59
+ },
60
+ startFresh: async (channel) => {
61
+ log.push(`${name} fresh ${channel}`);
62
+ return name;
63
+ },
64
+ ...(options.deletes
65
+ ? {
66
+ deleteConversation: async (channel: ChannelKey) =>
67
+ void log.push(`${name} delete ${channel}`),
68
+ }
69
+ : {}),
70
+ };
71
+ }
72
+
73
+ function router(claims: ChannelClaim[], queue = new ChannelQueue()) {
74
+ return new ChannelRouter({
75
+ claims,
76
+ queue,
77
+ stop: () => false,
78
+ logger: silentLogger(),
79
+ forwardJoinMs: 10,
80
+ });
81
+ }
82
+
83
+ describe("orderClaims", () => {
84
+ test("orders by descending priority, keeping registration order among equals", () => {
85
+ const log: string[] = [];
86
+ const ordered = orderClaims([
87
+ claim("owner", 0, log),
88
+ claim("party", 20, log),
89
+ claim("first", 30, log),
90
+ claim("second", 30, log),
91
+ claim("agents", 100, log),
92
+ ]);
93
+ expect(ordered.map((c) => c.name)).toEqual([
94
+ "agents",
95
+ "first",
96
+ "second",
97
+ "party",
98
+ "owner",
99
+ ]);
100
+ });
101
+
102
+ test("refuses a claim name used twice", () => {
103
+ const log: string[] = [];
104
+ expect(() =>
105
+ orderClaims([claim("party", 20, log), claim("party", 0, log)]),
106
+ ).toThrow(PluginError);
107
+ });
108
+ });
109
+
110
+ describe("ChannelRouter", () => {
111
+ test("the highest claim owning a channel answers there; the owner takes the rest", async () => {
112
+ const log: string[] = [];
113
+ const routing = router([
114
+ claim("owner", 0, log),
115
+ claim("party", 20, log, { channels: ["discord:party"] }),
116
+ claim("agents", 100, log, { channels: ["discord:agent"] }),
117
+ ]);
118
+ await routing.handle(message({ channel: "discord:party", text: "a" }));
119
+ await routing.handle(message({ channel: "discord:agent", text: "b" }));
120
+ await routing.handle(message({ channel: "discord:dm", text: "c" }));
121
+ expect(log).toEqual(["party a", "agents b", "owner c"]);
122
+ });
123
+
124
+ test("a message the owning claim refuses is dropped, never passed to a lower claim", async () => {
125
+ const log: string[] = [];
126
+ const routing = router([
127
+ claim("owner", 0, log),
128
+ claim("agents", 100, log, {
129
+ channels: ["discord:agent"],
130
+ guild: "agent-guild",
131
+ admit: () => undefined,
132
+ }),
133
+ ]);
134
+ await routing.handle(message({ guildId: "agent-guild", text: "notes" }));
135
+ await routing.handle(message({ guildId: "other", text: "hello" }));
136
+ expect(log).toEqual(["owner hello"]);
137
+ });
138
+
139
+ test("an empty message reaches no claim", async () => {
140
+ const log: string[] = [];
141
+ await router([claim("owner", 0, log)]).handle(message({ text: " " }));
142
+ expect(log).toEqual([]);
143
+ });
144
+
145
+ test("a message during a turn is offered to it, else marked until its own turn starts", async () => {
146
+ const log: string[] = [];
147
+ const queue = new ChannelQueue();
148
+ let takes = true;
149
+ let release = () => {};
150
+ const running = new Promise<void>((resolve) => {
151
+ release = resolve;
152
+ });
153
+ const routing = router(
154
+ [
155
+ claim("owner", 0, log, {
156
+ admit: (m) => ({
157
+ kind: "turn",
158
+ busy: {
159
+ steer: async () => takes,
160
+ react: async (emoji) => void log.push(`+${emoji} ${m.text}`),
161
+ unreact: async (emoji) => void log.push(`-${emoji} ${m.text}`),
162
+ },
163
+ run: async () => {
164
+ log.push(`run ${m.text}`);
165
+ if (m.text === "first") await running;
166
+ },
167
+ failure: "failed",
168
+ }),
169
+ }),
170
+ ],
171
+ queue,
172
+ );
173
+ const first = routing.handle(message({ text: "first" }));
174
+ await Bun.sleep(1);
175
+ await routing.handle(message({ text: "steered" }));
176
+ takes = false;
177
+ const waiting = routing.handle(message({ text: "waiting" }));
178
+ await Bun.sleep(1);
179
+ release();
180
+ await Promise.all([first, waiting]);
181
+ expect(log).toEqual([
182
+ "run first",
183
+ "+↪️ steered",
184
+ "+⏳ waiting",
185
+ "-⏳ waiting",
186
+ "run waiting",
187
+ ]);
188
+ });
189
+
190
+ test("a claim without busy handling is never marked", async () => {
191
+ const log: string[] = [];
192
+ const queue = new ChannelQueue();
193
+ const routing = router([claim("party", 0, log)], queue);
194
+ const first = routing.handle(message({ text: "one" }));
195
+ await routing.handle(message({ text: "two" }));
196
+ await first;
197
+ expect(log).toEqual(["party one", "party two"]);
198
+ });
199
+
200
+ test("a background admission runs as a background turn and reports when it did not run", async () => {
201
+ const log: string[] = [];
202
+ const routing = router([
203
+ {
204
+ ...claim("agents", 0, log),
205
+ admit: (m) => ({
206
+ kind: "background",
207
+ turn: {
208
+ channel: m.channel,
209
+ mode: "owner",
210
+ author: { id: "ci", name: "CI" },
211
+ turnId: "webhook-1",
212
+ text: m.text,
213
+ report: true,
214
+ },
215
+ unanswered: (outcome) => log.push(`unanswered ${outcome.status}`),
216
+ }),
217
+ background: async () => ({ status: "skipped", reason: "busy" }),
218
+ },
219
+ ]);
220
+ await routing.handle(message({ text: "build failed" }));
221
+ expect(log).toEqual(["unanswered skipped"]);
222
+ });
223
+
224
+ test("background turns and starting over go to the channel's owner when their queued turn starts", async () => {
225
+ const log: string[] = [];
226
+ const queue = new ChannelQueue();
227
+ const party = new Set<string>();
228
+ const routing = router(
229
+ [
230
+ claim("owner", 0, log),
231
+ {
232
+ ...claim("party", 20, log),
233
+ owns: (channel) => party.has(channel),
234
+ },
235
+ ],
236
+ queue,
237
+ );
238
+ let release = () => {};
239
+ const blocker = queue.run(
240
+ "discord:1",
241
+ () =>
242
+ new Promise<void>((resolve) => {
243
+ release = resolve;
244
+ }),
245
+ );
246
+ await Bun.sleep(1);
247
+ const turn = routing.background({
248
+ channel: "discord:1",
249
+ mode: "party",
250
+ author: { id: "owner", name: "Riley" },
251
+ turnId: "schedule-1",
252
+ text: "reminder",
253
+ });
254
+ const fresh = routing.startFresh("discord:1");
255
+ // Party mode turns on while the turn waits in the queue.
256
+ party.add("discord:1");
257
+ release();
258
+ await blocker;
259
+ expect(await turn).toEqual({ status: "ran" });
260
+ expect(await fresh).toBe("party");
261
+ expect(log).toEqual(["party background reminder", "party fresh discord:1"]);
262
+ });
263
+
264
+ test("deletes only through a claim that deletes, and never while the channel is busy", async () => {
265
+ const log: string[] = [];
266
+ const queue = new ChannelQueue();
267
+ const routing = router(
268
+ [
269
+ claim("owner", 0, log, { deletes: true }),
270
+ claim("party", 20, log, { channels: ["discord:party"] }),
271
+ ],
272
+ queue,
273
+ );
274
+ await expect(routing.deleteConversation("discord:party")).rejects.toThrow(
275
+ "discord:party is not an owner conversation",
276
+ );
277
+ let release = () => {};
278
+ const blocker = queue.run(
279
+ "discord:1",
280
+ () =>
281
+ new Promise<void>((resolve) => {
282
+ release = resolve;
283
+ }),
284
+ );
285
+ await Bun.sleep(1);
286
+ expect(await routing.deleteConversation("discord:1")).toBe("busy");
287
+ release();
288
+ await blocker;
289
+ // The queue counts the finished task out once its tail settles.
290
+ await Bun.sleep(1);
291
+ expect(await routing.deleteConversation("discord:1")).toBe("deleted");
292
+ expect(log).toEqual(["owner delete discord:1"]);
293
+ });
294
+
295
+ test("reports stay in place only where the channel's owner says so", () => {
296
+ const log: string[] = [];
297
+ const routing = router([
298
+ claim("owner", 0, log),
299
+ {
300
+ ...claim("party", 20, log, { channels: ["discord:party"] }),
301
+ postsInPlace: true,
302
+ },
303
+ ]);
304
+ expect(routing.postsInPlace("discord:party")).toBe(true);
305
+ expect(routing.postsInPlace("discord:dm")).toBe(false);
306
+ });
307
+
308
+ test("a turn that throws is logged and the next still runs", async () => {
309
+ const log: string[] = [];
310
+ const routing = router([
311
+ claim("owner", 0, log, {
312
+ admit: (m) => ({
313
+ kind: "turn",
314
+ run: async () => {
315
+ if (m.text === "boom") throw new Error("boom");
316
+ log.push(`run ${m.text}`);
317
+ },
318
+ failure: "failed",
319
+ }),
320
+ }),
321
+ ]);
322
+ await routing.handle(message({ text: "boom" }));
323
+ await routing.handle(message({ text: "next" }));
324
+ expect(log).toEqual(["run next"]);
325
+ });
326
+ });
@@ -0,0 +1,157 @@
1
+ import {
2
+ type BackgroundTurn,
3
+ type ChannelClaim,
4
+ type ConversationPort,
5
+ type InboundMessage,
6
+ QUEUED_MARK,
7
+ type ScheduledOutcome,
8
+ STEERED_MARK,
9
+ } from "../contract/channels.ts";
10
+ import { PluginError } from "../errors.ts";
11
+ import type { Logger } from "../log.ts";
12
+ import type { ChannelKey } from "../sessions.ts";
13
+ import type { ChannelQueue } from "./channel-queue.ts";
14
+ import { ForwardJoin } from "./forward-join.ts";
15
+
16
+ export interface ChannelRouterOptions {
17
+ /** Claims in registration order; the router orders them by priority. */
18
+ claims: readonly ChannelClaim[];
19
+ queue: ChannelQueue;
20
+ /** Stops the channel's running turn; false when none runs. */
21
+ stop(channel: ChannelKey): boolean;
22
+ logger: Logger;
23
+ /** How long a bare forward waits for the text sent with it; FORWARD_JOIN_MS by default. */
24
+ forwardJoinMs?: number;
25
+ }
26
+
27
+ /** Claims by descending priority, keeping registration order among equals; refuses a repeated name. */
28
+ export function orderClaims(
29
+ claims: readonly ChannelClaim[],
30
+ ): readonly ChannelClaim[] {
31
+ const names = new Set<string>();
32
+ for (const { name } of claims) {
33
+ if (names.has(name))
34
+ throw new PluginError(`channel claim ${name} is registered twice`);
35
+ names.add(name);
36
+ }
37
+ return claims.toSorted((a, b) => b.priority - a.priority);
38
+ }
39
+
40
+ /**
41
+ * Routes every conversation operation to the claim that owns its channel, each inside the
42
+ * channel's queue: messages after joining a bare forward to its text, background turns,
43
+ * starting over, and deleting.
44
+ */
45
+ export class ChannelRouter implements ConversationPort {
46
+ readonly #options: ChannelRouterOptions;
47
+ readonly #claims: readonly ChannelClaim[];
48
+ readonly #forwards: ForwardJoin;
49
+
50
+ constructor(options: ChannelRouterOptions) {
51
+ this.#options = options;
52
+ this.#claims = orderClaims(options.claims);
53
+ this.#forwards = new ForwardJoin(options.forwardJoinMs);
54
+ }
55
+
56
+ #owner(channel: ChannelKey, guildId?: string): ChannelClaim | undefined {
57
+ return this.#claims.find((claim) => claim.owns(channel, guildId));
58
+ }
59
+
60
+ /**
61
+ * Never rejects; every failure is logged. Resolves when the reply is posted or dropped. A
62
+ * forward without text waits briefly for the text its author sent with it, and the two make
63
+ * one turn.
64
+ */
65
+ handle(message: InboundMessage): Promise<void> {
66
+ return this.#forwards.handle(message, (m) => this.#route(m));
67
+ }
68
+
69
+ async #route(message: InboundMessage): Promise<void> {
70
+ const { queue, logger } = this.#options;
71
+ if (
72
+ !message.text.trim() &&
73
+ message.attachments.length === 0 &&
74
+ !message.forwarded?.text.trim()
75
+ )
76
+ return;
77
+ const admission = this.#owner(message.channel, message.guildId)?.admit(
78
+ message,
79
+ );
80
+ if (!admission) return;
81
+ if (admission.kind === "background") {
82
+ const outcome = await this.background(admission.turn);
83
+ if (outcome.status !== "ran") admission.unanswered(outcome);
84
+ return;
85
+ }
86
+ const { busy } = admission;
87
+ let queued = false;
88
+ // A message during a turn is added to it when the turn takes it, otherwise marked as waiting.
89
+ if (busy && queue.size(message.channel) > 0) {
90
+ if (await busy.steer?.()) {
91
+ await busy.react(STEERED_MARK);
92
+ return;
93
+ }
94
+ await busy.react(QUEUED_MARK);
95
+ queued = true;
96
+ }
97
+ await queue
98
+ .run(message.channel, () => {
99
+ if (queued) void busy?.unreact(QUEUED_MARK);
100
+ return admission.run();
101
+ })
102
+ // pi-lens-ignore: no-unknown-parameters
103
+ .catch((error: unknown) =>
104
+ logger.error(
105
+ { channel: message.channel, err: error },
106
+ admission.failure,
107
+ ),
108
+ );
109
+ }
110
+
111
+ /** A turn nobody wrote, run by the channel's claim inside its queue; never rejects. */
112
+ async background(turn: BackgroundTurn): Promise<ScheduledOutcome> {
113
+ try {
114
+ return await this.#options.queue.run(turn.channel, async () => {
115
+ const claim = this.#owner(turn.channel);
116
+ if (!claim?.background)
117
+ return {
118
+ status: "skipped",
119
+ reason: "no conversation takes background turns here",
120
+ };
121
+ return claim.background(turn);
122
+ });
123
+ } catch (error) {
124
+ return { status: "failed", error: String(error) };
125
+ }
126
+ }
127
+
128
+ startFresh(channel: ChannelKey): Promise<string> {
129
+ return this.#options.queue.run(channel, () => {
130
+ const claim = this.#owner(channel);
131
+ if (!claim) throw new Error(`no conversation owns ${channel}`);
132
+ return claim.startFresh(channel);
133
+ });
134
+ }
135
+
136
+ async deleteConversation(channel: ChannelKey): Promise<"deleted" | "busy"> {
137
+ const { queue } = this.#options;
138
+ const claim = this.#owner(channel);
139
+ const remove = claim?.deleteConversation?.bind(claim);
140
+ if (!remove) throw new Error(`${channel} is not an owner conversation`);
141
+ if (queue.size(channel) > 0) return "busy";
142
+ return queue.run(channel, async () => {
143
+ // A message queued between the check and this task would still be waiting behind it.
144
+ if (queue.size(channel) > 1) return "busy";
145
+ await remove(channel);
146
+ return "deleted";
147
+ });
148
+ }
149
+
150
+ stop(channel: ChannelKey): boolean {
151
+ return this.#options.stop(channel);
152
+ }
153
+
154
+ postsInPlace(channel: ChannelKey): boolean {
155
+ return this.#owner(channel)?.postsInPlace === true;
156
+ }
157
+ }
@@ -0,0 +1,14 @@
1
+ /** Whose conversation a channel holds, as starting it over reports it. */
2
+ export type ConversationKind = "owner" | "party" | "agent";
3
+
4
+ const isKind = (kind: string): kind is ConversationKind =>
5
+ kind === "owner" || kind === "party" || kind === "agent";
6
+
7
+ /** Narrows what a claim's startFresh said; a claim the host does not know about is a bug. */
8
+ export async function conversationKind(
9
+ whose: Promise<string>,
10
+ ): Promise<ConversationKind> {
11
+ const kind = await whose;
12
+ if (!isKind(kind)) throw new Error(`unknown conversation kind "${kind}"`);
13
+ return kind;
14
+ }
@@ -0,0 +1,65 @@
1
+ import type { InboundMessage } from "../contract/channels.ts";
2
+ import type { ChannelKey } from "../sessions.ts";
3
+
4
+ /** Discord sends a forward's added text as a second message right after the forward. */
5
+ export const FORWARD_JOIN_MS = 2_000;
6
+
7
+ /** A forward without text, waiting for the message Discord sends with its added text. */
8
+ interface HeldForward {
9
+ message: InboundMessage;
10
+ timer: ReturnType<typeof setTimeout>;
11
+ release(run: Promise<void>): void;
12
+ }
13
+
14
+ /**
15
+ * Joins a forward without text to the text its author sends right after it, so the two make
16
+ * one turn; a forward nothing follows runs alone once the wait ends.
17
+ */
18
+ export class ForwardJoin {
19
+ readonly #waitMs: number;
20
+ readonly #held = new Map<ChannelKey, HeldForward>();
21
+
22
+ constructor(waitMs = FORWARD_JOIN_MS) {
23
+ this.#waitMs = waitMs;
24
+ }
25
+
26
+ /** Passes each message, or a joined one, to `dispatch`; resolves when its run does. */
27
+ handle(
28
+ message: InboundMessage,
29
+ dispatch: (message: InboundMessage) => Promise<void>,
30
+ ): Promise<void> {
31
+ const held = this.#held.get(message.channel);
32
+ if (held && held.message.authorId === message.authorId) {
33
+ this.#held.delete(message.channel);
34
+ clearTimeout(held.timer);
35
+ if (!message.forwarded) {
36
+ const run = dispatch(joinForward(held.message, message));
37
+ held.release(run);
38
+ return run;
39
+ }
40
+ held.release(dispatch(held.message));
41
+ }
42
+ if (!message.forwarded || message.text.trim()) return dispatch(message);
43
+ return new Promise((resolve) => {
44
+ const release = (run: Promise<void>) => resolve(run);
45
+ const timer = setTimeout(() => {
46
+ this.#held.delete(message.channel);
47
+ release(dispatch(message));
48
+ }, this.#waitMs);
49
+ this.#held.set(message.channel, { message, timer, release });
50
+ });
51
+ }
52
+ }
53
+
54
+ /** A forward and the text its author sent right after it, as one message. */
55
+ function joinForward(
56
+ forward: InboundMessage,
57
+ text: InboundMessage,
58
+ ): InboundMessage {
59
+ return {
60
+ ...text,
61
+ mentionsBot: forward.mentionsBot || text.mentionsBot,
62
+ attachments: [...forward.attachments, ...text.attachments],
63
+ ...(forward.forwarded ? { forwarded: forward.forwarded } : {}),
64
+ };
65
+ }