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,200 @@
1
+ import type { SQL } from "bun";
2
+ import type { AgentSeed } from "./agents/agent-store.ts";
3
+ import type {
4
+ ChannelClaim,
5
+ ConversationPort,
6
+ QueuePort,
7
+ } from "./contract/channels.ts";
8
+ import type { InteractionContribution } from "./contract/discord.ts";
9
+ import type { Providers } from "./contract/providers.ts";
10
+ import type { Migration } from "./db/migrations.ts";
11
+ import type { ToolContribution } from "./define.ts";
12
+ import type { HoldCheck, HoldRule } from "./holds.ts";
13
+ import type { HttpRoute } from "./http/listeners.ts";
14
+ import type { Logger } from "./log.ts";
15
+ import type { ComposedInteractions } from "./registry/interactions.ts";
16
+ import type { CoreAccess, CoreRegistry } from "./services.ts";
17
+ import type {
18
+ AgentTurnScope,
19
+ ChannelKey,
20
+ SessionPlan,
21
+ SessionTool,
22
+ ToolSelection,
23
+ } from "./sessions.ts";
24
+ import type { Speaker } from "./speakers.ts";
25
+ import type { ToolTiers } from "./tool-tiers.ts";
26
+
27
+ /**
28
+ * A long-lived part of a plugin. Services start in registration order and stop in reverse,
29
+ * so one registered earlier outlives those registered after it.
30
+ */
31
+ export interface Service {
32
+ name: string;
33
+ start?(): Promise<void> | void;
34
+ stop?(): Promise<void> | void;
35
+ /** Work still running or waiting, one entry each; the shutdown drain waits until every list is empty. */
36
+ busy?(): string[];
37
+ }
38
+
39
+ /** How the agent server's startup ended. */
40
+ export type AgentServerOutcome = "ready" | "failed";
41
+
42
+ /** One agent turn, as a handler hears of it. */
43
+ export interface TurnEvent {
44
+ agent: string;
45
+ /** The channel the turn runs in: the agent's own, or a group's. */
46
+ channel: ChannelKey;
47
+ /** Who the turn is for. */
48
+ speaker: Speaker | undefined;
49
+ /** The group, for a member's turn in one. */
50
+ group?: string;
51
+ }
52
+
53
+ /** How a turn ended. */
54
+ export interface TurnEndEvent extends TurnEvent {
55
+ result: "ok" | "failed" | "stopped";
56
+ }
57
+
58
+ /** What a plugin may react to; a handler that throws is logged and never stops the others. */
59
+ export interface EventHandlers {
60
+ /** Once the agent server has started, or failed to; the rest of the process runs either way. */
61
+ agentServer?(outcome: AgentServerOutcome): Promise<void> | void;
62
+ /** An agent's turn began. */
63
+ turnStarted?(turn: TurnEvent): Promise<void> | void;
64
+ /** An agent's turn ended, however it ended. */
65
+ turnEnded?(turn: TurnEndEvent): Promise<void> | void;
66
+ /** The team changed: an agent or group was created, edited, arranged, archived, or started over. */
67
+ changed?(): Promise<void> | void;
68
+ /** The shutdown drain ended, before any service stops; `left` is the work it gave up on. */
69
+ shutdown?(left: readonly string[]): Promise<void> | void;
70
+ }
71
+
72
+ /** What the core reports to the plugins' handlers. */
73
+ export interface EventSink {
74
+ turnStarted(turn: TurnEvent): void;
75
+ turnEnded(turn: TurnEndEvent): void;
76
+ changed(): void;
77
+ }
78
+
79
+ /** What a prompt section is built for: one agent's turn. */
80
+ export interface PromptTurn {
81
+ agent: { name: string; displayName: string };
82
+ /** Who the turn is for; undefined between turns. */
83
+ speaker: Speaker | undefined;
84
+ scope: AgentTurnScope;
85
+ }
86
+
87
+ /** A section a plugin adds to every agent turn's system prompt. */
88
+ export interface PromptSection {
89
+ name: string;
90
+ /** The section's text for the turn, or undefined to add nothing. */
91
+ build(turn: PromptTurn): string | undefined;
92
+ }
93
+
94
+ /** What a plugin adds to the process; a plugin that adds nothing is refused. */
95
+ export interface Contribution {
96
+ services?: Service[];
97
+ events?: EventHandlers;
98
+ /** Modules that answer Discord interactions, with their subcommands under the root command. */
99
+ interactions?: InteractionContribution[];
100
+ /** Handlers on the host's HTTP listeners. */
101
+ http?: HttpRoute[];
102
+ /** Rules deciding which tool calls wait for the owner's approval, asked in contribution order. */
103
+ holdRules?: readonly HoldRule[];
104
+ /** Pi packages every conversation session loads, in contribution order. */
105
+ piPackages?: readonly string[];
106
+ /** Extensions of every conversation session, placed by phase around the core's own. */
107
+ sessionTools?: readonly SessionTool[];
108
+ /** The channels whose conversations the plugin owns. */
109
+ channels?: readonly ChannelClaim[];
110
+ /** Lines the agent server's dashboard shows under its title, such as links, in contribution order. */
111
+ dashboard?: readonly string[];
112
+ /** Tools the plugin adds, each with the lowest tier that may use it; built with `defineTool`. */
113
+ tools?: readonly ToolContribution[];
114
+ /** Agents created on the first start; one already stored is never overwritten. */
115
+ seeds?: readonly AgentSeed[];
116
+ /** Sections added to every agent turn's system prompt, in contribution order. */
117
+ prompt?: readonly PromptSection[];
118
+ /**
119
+ * Tools and tool groups every agent turn carries besides its own, read before each turn so a
120
+ * set that changes while the process runs, such as connected servers, stays current.
121
+ */
122
+ agentSelection?: () => ToolSelection;
123
+ }
124
+
125
+ /** The parts a contribution may have; any other key is a mistake the host names. */
126
+ export const CONTRIBUTION_KEYS = [
127
+ "services",
128
+ "events",
129
+ "interactions",
130
+ "http",
131
+ "holdRules",
132
+ "piPackages",
133
+ "sessionTools",
134
+ "channels",
135
+ "dashboard",
136
+ "tools",
137
+ "seeds",
138
+ "prompt",
139
+ "agentSelection",
140
+ ] as const satisfies readonly (keyof Contribution)[];
141
+
142
+ /** The session parts the host links from every plugin's contributions once all are set up. */
143
+ export interface LinkedSessions {
144
+ /** Every contributed hold rule, asked in contribution order until one describes the call. */
145
+ holds: HoldCheck;
146
+ /** Every contributed Pi package in contribution order, each once. */
147
+ piPackages: readonly string[];
148
+ plan: SessionPlan;
149
+ /** The agents every plugin seeds, in contribution order. */
150
+ seeds: readonly AgentSeed[];
151
+ /** Every plugin's prompt sections, in contribution order. */
152
+ prompt: readonly PromptSection[];
153
+ /** The tools plugins defined for agents, by name. */
154
+ agentTools: readonly string[];
155
+ /** Every plugin's agent selection merged, read before each agent turn. */
156
+ agentSelection(): ToolSelection;
157
+ }
158
+
159
+ /** What the host gives every plugin. */
160
+ export interface PluginContext {
161
+ logger: Logger;
162
+ /** The linked session parts; throws NotLinkedError when called during setup. */
163
+ sessions(): LinkedSessions;
164
+ /** The one channel queue every conversation and channel operation shares. */
165
+ queue: QueuePort;
166
+ /** What each tool needs; plugin tools are added when the host links, so ask at use time. */
167
+ toolTiers: ToolTiers;
168
+ /** Where the core reports turns and team changes, for every plugin's handlers. */
169
+ events: EventSink;
170
+ /** The claimed channels' conversations; calls during setup throw NotLinkedError. */
171
+ conversations: ConversationPort;
172
+ /** The host's one connection pool, migrated before any setup; throws PluginError without a database. */
173
+ database(): SQL;
174
+ /** What the built-in plugins provide, for the plugins registered after them; throws PluginError until provided. */
175
+ core: CoreAccess & { provide: CoreRegistry["provide"] };
176
+ /** Each provider slot, from the plugin that fills it or the core's default. */
177
+ providers: Providers;
178
+ /** Every plugin's dashboard lines, in contribution order; throws NotLinkedError during setup. */
179
+ dashboard(): readonly string[];
180
+ }
181
+
182
+ export interface RoundtablePlugin {
183
+ name: string;
184
+ /** The plugin's tables; the host runs every plugin's, in registration order, before any setup. */
185
+ migrations?: readonly Migration[];
186
+ /** The provider slots the plugin fills, resolved before any setup; two plugins cannot fill one slot. */
187
+ providers?: Partial<Providers>;
188
+ setup(context: PluginContext): Promise<Contribution> | Contribution;
189
+ /**
190
+ * Runs once every plugin is set up and linked, before the commands are handed over or any
191
+ * service starts; a failure stops the boot, so a broken setup never reaches Discord.
192
+ */
193
+ preflight?(): Promise<void> | void;
194
+ /** Receives the composed slash commands before any service starts, since Discord registers them as it connects. */
195
+ useCommands?(composed: ComposedInteractions): void;
196
+ /** Starts the agent server, in the background once every service has started; only one plugin has it. */
197
+ agentServer?(): Promise<void>;
198
+ /** Stops a running turn of a channel the plugin's conversations serve; true when one was running. */
199
+ stopTurn?(channel: ChannelKey): boolean;
200
+ }
@@ -0,0 +1,41 @@
1
+ import type { ChannelKey } from "../domain/conversation.ts";
2
+ import type { Expression } from "../domain/expression.ts";
3
+
4
+ /** A channel quiet this long starts a new conversation, whose first reply shows a card. */
5
+ export const CONVERSATION_GAP_MS = 30 * 60_000;
6
+
7
+ /**
8
+ * Decides which replies carry a card: one that opens a conversation or changes its tone.
9
+ * Kept in memory; after a restart every channel's next reply opens a conversation.
10
+ */
11
+ export class CardCadence {
12
+ readonly #channels = new Map<
13
+ ChannelKey,
14
+ { lastReplyAt: number; lastCard: Expression | undefined }
15
+ >();
16
+
17
+ /** Records a reply about to be posted and says whether it shows a card. */
18
+ shows(
19
+ channel: ChannelKey,
20
+ expression: Expression,
21
+ failed: boolean,
22
+ now = Date.now(),
23
+ ): boolean {
24
+ const state = this.#channels.get(channel);
25
+ const card =
26
+ failed ||
27
+ !state ||
28
+ now - state.lastReplyAt >= CONVERSATION_GAP_MS ||
29
+ (expression !== "neutral" && expression !== state.lastCard);
30
+ this.#channels.set(channel, {
31
+ lastReplyAt: now,
32
+ lastCard: card ? expression : state?.lastCard,
33
+ });
34
+ return card;
35
+ }
36
+
37
+ /** The channel's next reply opens a conversation again. */
38
+ forget(channel: ChannelKey): void {
39
+ this.#channels.delete(channel);
40
+ }
41
+ }
@@ -0,0 +1,34 @@
1
+ const MAX_LENGTH = 80;
2
+ // The card font has no emoji glyphs, so the card would draw each as a code-point box.
3
+ const EMOJI =
4
+ /\p{Extended_Pictographic}|[\u{1F1E6}-\u{1F1FF}\u{1F3FB}-\u{1F3FF}]|\u{FE0F}|\u{200D}|\u{20E3}/gu;
5
+
6
+ function stripMarkdown(line: string): string {
7
+ return line
8
+ .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
9
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
10
+ .replace(/<(https?:\/\/[^>]+)>/g, "$1")
11
+ .replace(/^\s{0,3}(#{1,6}\s+|>\s?|[-*+]\s+|\d+[.)]\s+)/, "")
12
+ .replace(/(\*\*|__|~~|`)/g, "")
13
+ .replace(/(^|\s)[*_](\S[^*_]*)[*_](?=\s|$|[,。!?,.!?])/g, "$1$2")
14
+ .replace(EMOJI, "")
15
+ .replace(/\s{2,}/g, " ")
16
+ .trim();
17
+ }
18
+
19
+ /** The reply's first sentence as plain text without emoji, at most 80 characters. */
20
+ export function headline(text: string): string {
21
+ const withoutCode = text.replace(/```[\s\S]*?(```|$)/g, "\n");
22
+ const firstLine = withoutCode
23
+ .split("\n")
24
+ .map(stripMarkdown)
25
+ .find((line) => line.length > 0);
26
+ if (!firstLine) return "";
27
+
28
+ const sentence =
29
+ firstLine.match(/^.*?(?:[。!?!?]|\.(?=\s|$))/)?.[0] ?? firstLine;
30
+ const characters = Array.from(sentence.trim());
31
+ return characters.length <= MAX_LENGTH
32
+ ? characters.join("")
33
+ : `${characters.slice(0, MAX_LENGTH - 1).join("")}…`;
34
+ }
@@ -0,0 +1,166 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { CardCadence, CONVERSATION_GAP_MS } from "./card-cadence.ts";
3
+ import { headline } from "./headline.ts";
4
+ import { quietLinks } from "./quiet-links.ts";
5
+ import { DISCORD_MESSAGE_LIMIT, splitReply } from "./reply-splitter.ts";
6
+ import { THINKING_LIMIT, thinkingLine } from "./thinking-line.ts";
7
+
8
+ describe("headline", () => {
9
+ test("takes the first sentence", () => {
10
+ expect(headline("Today is a nice day。Let us go for a walk!")).toBe(
11
+ "Today is a nice day。",
12
+ );
13
+ expect(headline("Bun 1.4.2 is out. It fixes a lot.")).toBe(
14
+ "Bun 1.4.2 is out.",
15
+ );
16
+ });
17
+
18
+ test("does not stop at a decimal point", () => {
19
+ expect(headline("Version 1.4.2 ships today")).toBe(
20
+ "Version 1.4.2 ships today",
21
+ );
22
+ });
23
+
24
+ test("strips markdown and skips code", () => {
25
+ expect(
26
+ headline(
27
+ "```ts\nconst x = 1;\n```\n## **Key point**:see [this post](https://x.y)",
28
+ ),
29
+ ).toBe("Key point:see this post");
30
+ expect(headline("- `bun` is fast")).toBe("bun is fast");
31
+ });
32
+
33
+ test("truncates to 80 characters", () => {
34
+ const result = headline("é".repeat(120));
35
+ expect(Array.from(result)).toHaveLength(80);
36
+ expect(result.endsWith("…")).toBe(true);
37
+ });
38
+
39
+ test("emoji are left out, since the card font cannot draw them", () => {
40
+ expect(headline("Schedule test OK,get some water 💧")).toBe(
41
+ "Schedule test OK,get some water",
42
+ );
43
+ expect(headline("🎉 Congrats 👍🏽 done 🇹🇼!")).toBe("Congrats done !");
44
+ expect(headline("👨‍👩‍👧 family❤️ all good")).toBe("family all good");
45
+ expect(headline("💧")).toBe("");
46
+ });
47
+
48
+ test("empty text gives an empty headline", () => {
49
+ expect(headline("\n\n")).toBe("");
50
+ });
51
+ });
52
+
53
+ describe("splitReply", () => {
54
+ test("short text stays one message", () => {
55
+ expect(splitReply("hello\n\nworld")).toEqual(["hello\n\nworld"]);
56
+ });
57
+
58
+ test("splits on paragraph boundaries within the limit", () => {
59
+ const [a, b, c] = ["a".repeat(900), "b".repeat(900), "c".repeat(900)];
60
+ expect(splitReply(`${a}\n\n${b}\n\n${c}`)).toEqual([`${a}\n\n${b}`, c]);
61
+ });
62
+
63
+ test("keeps a code block with blank lines intact", () => {
64
+ const code = "```ts\nconst a = 1;\n\nconst b = 2;\n```";
65
+ const text = `${"x".repeat(1980)}\n\n${code}`;
66
+ expect(splitReply(text)).toEqual(["x".repeat(1980), code]);
67
+ });
68
+
69
+ test("a long code block becomes several complete code blocks", () => {
70
+ const body = Array.from(
71
+ { length: 200 },
72
+ (_, i) => `line ${i} ${"-".repeat(20)}`,
73
+ ).join("\n");
74
+ const chunks = splitReply(`\`\`\`ts\n${body}\n\`\`\``);
75
+ expect(chunks.length).toBeGreaterThan(1);
76
+ for (const chunk of chunks) {
77
+ expect(chunk.length).toBeLessThanOrEqual(2000);
78
+ expect(chunk.startsWith("```ts\n")).toBe(true);
79
+ expect(chunk.endsWith("\n```")).toBe(true);
80
+ }
81
+ const rejoined = chunks.map((c) => c.slice(6, -4)).join("\n");
82
+ expect(rejoined).toBe(body);
83
+ });
84
+
85
+ test("one overlong line is split by characters", () => {
86
+ const chunks = splitReply("é".repeat(4500));
87
+ expect(chunks.map((c) => c.length)).toEqual([2000, 2000, 500]);
88
+ });
89
+
90
+ test("every chunk respects the limit", () => {
91
+ const text = Array.from(
92
+ { length: 50 },
93
+ (_, i) => `Paragraph ${i} ${"é".repeat(i * 10)}`,
94
+ ).join("\n\n");
95
+ const chunks = splitReply(text);
96
+ expect(chunks.every((c) => c.length <= 2000)).toBe(true);
97
+ expect(chunks.join("\n\n")).toBe(text);
98
+ });
99
+ });
100
+
101
+ describe("quietLinks", () => {
102
+ test("wraps cited links and keeps sentence punctuation outside", () => {
103
+ expect(
104
+ quietLinks("Source:https://example.com/a?b=1。Also https://x.org/p."),
105
+ ).toBe("Source:<https://example.com/a?b=1>。Also <https://x.org/p>.");
106
+ });
107
+
108
+ test("GIF and video links keep their preview", () => {
109
+ const text =
110
+ "https://giphy.com/gifs/clap\nhttps://www.youtube.com/watch?v=abc";
111
+ expect(quietLinks(text)).toBe(text);
112
+ });
113
+
114
+ test("markdown links and already wrapped links", () => {
115
+ expect(
116
+ quietLinks("[review](https://example.com/r) and <https://a.io>"),
117
+ ).toBe("[review](<https://example.com/r>) and <https://a.io>");
118
+ });
119
+ });
120
+
121
+ describe("thinkingLine", () => {
122
+ test("each line becomes small grey text and blank lines are dropped", () => {
123
+ expect(thinkingLine(" check the date\n\nthen answer ")).toBe(
124
+ "-# check the date\n-# then answer",
125
+ );
126
+ expect(thinkingLine(" ")).toBeUndefined();
127
+ });
128
+
129
+ test("long thinking is cut and stays within one message", () => {
130
+ const line = thinkingLine("é".repeat(THINKING_LIMIT + 50));
131
+ expect(line?.endsWith("…")).toBe(true);
132
+ expect(line?.length).toBeLessThanOrEqual(DISCORD_MESSAGE_LIMIT);
133
+ });
134
+ });
135
+
136
+ describe("card cadence", () => {
137
+ const at = (minutes: number) => minutes * 60_000;
138
+
139
+ test("a conversation's first reply shows a card; the same tone does not repeat it", () => {
140
+ const cadence = new CardCadence();
141
+ expect(cadence.shows("discord:1", "neutral", false, at(0))).toBe(true);
142
+ expect(cadence.shows("discord:1", "neutral", false, at(1))).toBe(false);
143
+ expect(cadence.shows("discord:1", "happy", false, at(2))).toBe(true);
144
+ expect(cadence.shows("discord:1", "happy", false, at(3))).toBe(false);
145
+ expect(cadence.shows("discord:1", "neutral", false, at(4))).toBe(false);
146
+ expect(cadence.shows("discord:1", "thinking", false, at(5))).toBe(true);
147
+ });
148
+
149
+ test("a quiet channel opens a new conversation", () => {
150
+ const cadence = new CardCadence();
151
+ cadence.shows("discord:1", "neutral", false, 0);
152
+ expect(
153
+ cadence.shows("discord:1", "neutral", false, CONVERSATION_GAP_MS - 1),
154
+ ).toBe(false);
155
+ expect(
156
+ cadence.shows("discord:1", "neutral", false, 2 * CONVERSATION_GAP_MS - 1),
157
+ ).toBe(true);
158
+ });
159
+
160
+ test("a failure always shows its card; channels are independent", () => {
161
+ const cadence = new CardCadence();
162
+ cadence.shows("discord:1", "apologetic", true, at(0));
163
+ expect(cadence.shows("discord:1", "apologetic", true, at(1))).toBe(true);
164
+ expect(cadence.shows("discord:2", "neutral", false, at(1))).toBe(true);
165
+ });
166
+ });
@@ -0,0 +1,36 @@
1
+ /** Links whose preview is the point: a GIF or a video shows inline. */
2
+ const PREVIEWED_HOSTS = [
3
+ "giphy.com",
4
+ "media.giphy.com",
5
+ "tenor.com",
6
+ "youtube.com",
7
+ "www.youtube.com",
8
+ "youtu.be",
9
+ ];
10
+
11
+ const URL_CHARS = "[A-Za-z0-9\\-._~:/?#\\[\\]@!$&'*+,;=%]+";
12
+ const BARE_URL = new RegExp(`(^|[^<(])(https?://${URL_CHARS})`, "g");
13
+ const MARKDOWN_URL = new RegExp(`\\]\\((https?://${URL_CHARS})\\)`, "g");
14
+
15
+ function keepsPreview(url: string): boolean {
16
+ try {
17
+ return PREVIEWED_HOSTS.includes(new URL(url).hostname);
18
+ } catch {
19
+ return true;
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Wraps links in `<…>` so Discord does not unfold a preview card for each cited source.
25
+ * GIF and video links keep their preview. Trailing sentence punctuation stays outside.
26
+ */
27
+ export function quietLinks(text: string): string {
28
+ const bare = text.replace(BARE_URL, (match, before: string, raw: string) => {
29
+ const url = raw.replace(/[.,;:!?'\]]+$/, "");
30
+ if (keepsPreview(url)) return match;
31
+ return `${before}<${url}>${raw.slice(url.length)}`;
32
+ });
33
+ return bare.replace(MARKDOWN_URL, (match, url: string) =>
34
+ keepsPreview(url) ? match : `](<${url}>)`,
35
+ );
36
+ }
@@ -0,0 +1,110 @@
1
+ export const DISCORD_MESSAGE_LIMIT = 2000;
2
+
3
+ interface Block {
4
+ text: string;
5
+ /** The opening fence line when the block is a fenced code block. */
6
+ fence?: string;
7
+ }
8
+
9
+ function fenceMarker(openingLine: string): string {
10
+ return openingLine.trim().slice(0, 3);
11
+ }
12
+
13
+ /** Paragraphs separated by blank lines; a fenced code block is one block even with blank lines. */
14
+ function parseBlocks(text: string): Block[] {
15
+ const blocks: Block[] = [];
16
+ let lines: string[] = [];
17
+ let fence: string | undefined;
18
+ const flush = () => {
19
+ if (lines.some((line) => line.trim())) {
20
+ blocks.push({ text: lines.join("\n"), fence });
21
+ }
22
+ lines = [];
23
+ fence = undefined;
24
+ };
25
+
26
+ for (const line of text.replace(/\r\n/g, "\n").split("\n")) {
27
+ if (fence) {
28
+ lines.push(line);
29
+ if (line.trim() === fenceMarker(fence)) flush();
30
+ } else if (/^\s*(```|~~~)/.test(line)) {
31
+ flush();
32
+ fence = line;
33
+ lines.push(line);
34
+ } else if (!line.trim()) {
35
+ flush();
36
+ } else {
37
+ lines.push(line);
38
+ }
39
+ }
40
+ flush();
41
+ return blocks;
42
+ }
43
+
44
+ function hardSplit(text: string, limit: number): string[] {
45
+ const pieces: string[] = [];
46
+ let current = "";
47
+ for (const character of text) {
48
+ if (current.length + character.length > limit) {
49
+ pieces.push(current);
50
+ current = "";
51
+ }
52
+ current += character;
53
+ }
54
+ if (current) pieces.push(current);
55
+ return pieces;
56
+ }
57
+
58
+ /** Packs lines into pieces of at most `limit`, splitting a single overlong line by characters. */
59
+ function packLines(lines: string[], limit: number): string[] {
60
+ const pieces: string[] = [];
61
+ let current: string | undefined;
62
+ for (const line of lines.flatMap((l) =>
63
+ l.length > limit ? hardSplit(l, limit) : [l],
64
+ )) {
65
+ if (current !== undefined && current.length + 1 + line.length <= limit) {
66
+ current += `\n${line}`;
67
+ } else {
68
+ if (current !== undefined) pieces.push(current);
69
+ current = line;
70
+ }
71
+ }
72
+ if (current !== undefined) pieces.push(current);
73
+ return pieces;
74
+ }
75
+
76
+ function splitBlock(block: Block, limit: number): string[] {
77
+ if (block.text.length <= limit) return [block.text];
78
+ if (!block.fence) return packLines(block.text.split("\n"), limit);
79
+
80
+ // A long code block becomes several complete code blocks, each reopened with the same fence.
81
+ const marker = fenceMarker(block.fence);
82
+ const lines = block.text.split("\n");
83
+ const closed = lines.length > 1 && lines.at(-1)?.trim() === marker;
84
+ const body = lines.slice(1, closed ? -1 : undefined);
85
+ const overhead = block.fence.length + 1 + 1 + marker.length;
86
+ return packLines(body, limit - overhead).map(
87
+ (piece) => `${block.fence}\n${piece}\n${marker}`,
88
+ );
89
+ }
90
+
91
+ /** Splits a reply into Discord messages on paragraph and code-block boundaries. */
92
+ export function splitReply(
93
+ text: string,
94
+ limit = DISCORD_MESSAGE_LIMIT,
95
+ ): string[] {
96
+ const chunks: string[] = [];
97
+ let current: string | undefined;
98
+ for (const piece of parseBlocks(text).flatMap((block) =>
99
+ splitBlock(block, limit),
100
+ )) {
101
+ if (current !== undefined && current.length + 2 + piece.length <= limit) {
102
+ current += `\n\n${piece}`;
103
+ } else {
104
+ if (current !== undefined) chunks.push(current);
105
+ current = piece;
106
+ }
107
+ }
108
+ if (current !== undefined) chunks.push(current);
109
+ return chunks;
110
+ }
@@ -0,0 +1,24 @@
1
+ import { DISCORD_MESSAGE_LIMIT } from "./reply-splitter.ts";
2
+
3
+ /** Kept well inside the message limit, since every line also carries the `-#` prefix. */
4
+ export const THINKING_LIMIT = 1_200;
5
+
6
+ /**
7
+ * The assistant's thinking as one quiet Discord message: small grey text, blank lines dropped so the
8
+ * subheading style holds, and cut with an ellipsis when it runs long.
9
+ */
10
+ export function thinkingLine(thinking: string): string | undefined {
11
+ const text = thinking.trim();
12
+ if (!text) return undefined;
13
+ const cut =
14
+ text.length > THINKING_LIMIT ? `${text.slice(0, THINKING_LIMIT)}…` : text;
15
+ const message = cut
16
+ .split("\n")
17
+ .map((line) => line.trim())
18
+ .filter(Boolean)
19
+ .map((line) => `-# ${line}`)
20
+ .join("\n");
21
+ return message.length > DISCORD_MESSAGE_LIMIT
22
+ ? message.slice(0, DISCORD_MESSAGE_LIMIT - 1)
23
+ : message;
24
+ }
@@ -0,0 +1,59 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { readdirSync, readFileSync } from "node:fs";
3
+ import { join, relative, resolve } from "node:path";
4
+
5
+ const ROOT = resolve(import.meta.dir, "../..");
6
+ const CORE = join(ROOT, "src/core");
7
+
8
+ /** The core's modules, tests and test helpers aside. */
9
+ function modules(dir: string): string[] {
10
+ return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
11
+ const path = join(dir, entry.name);
12
+ if (entry.isDirectory())
13
+ return entry.name === "testing" ? [] : modules(path);
14
+ return entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts")
15
+ ? [path]
16
+ : [];
17
+ });
18
+ }
19
+
20
+ /** The packages a module imports, by their bare names. */
21
+ function packages(file: string): string[] {
22
+ const source = readFileSync(file, "utf8");
23
+ return [
24
+ ...source.matchAll(/(?:from|import)\s*\(?\s*"([^."][^"]*)"/g),
25
+ ].flatMap((match) => (match[1] ? [match[1]] : []));
26
+ }
27
+
28
+ /** Services only the first consumer's deployment runs; the core must work without them. */
29
+ const PRIVATE = new RegExp(
30
+ // Spelled in pieces so this test does not itself name the private services.
31
+ ["j" + "ev", "phoe" + "nix", "context" + "forge", "type" + "safe"].join("|"),
32
+ "i",
33
+ );
34
+
35
+ describe("the public core", () => {
36
+ test("imports no package of a private service", () => {
37
+ const found = modules(CORE).flatMap((file) =>
38
+ packages(file)
39
+ .filter((name) => PRIVATE.test(name))
40
+ .map((name) => `${relative(ROOT, file)} -> ${name}`),
41
+ );
42
+ expect(found).toEqual([]);
43
+ });
44
+
45
+ test("every module loads with no consumer configuration", () => {
46
+ const imports = modules(CORE)
47
+ .map((file) => `await import(${JSON.stringify(file)});`)
48
+ .join("\n");
49
+ const run = Bun.spawnSync({
50
+ cmd: [process.execPath, "-e", imports],
51
+ cwd: ROOT,
52
+ // Only what a process needs to start; none of a consumer's variables.
53
+ env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "" },
54
+ stderr: "pipe",
55
+ });
56
+ expect(run.stderr.toString()).toBe("");
57
+ expect(run.exitCode).toBe(0);
58
+ });
59
+ });