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,79 @@
1
+ import type { TurnAttachments } from "../domain/attachment.ts";
2
+ import type { InboundMessage } from "../domain/conversation.ts";
3
+ import type { Logger } from "../log.ts";
4
+ import { fetchAttachments } from "./attachment-fetcher.ts";
5
+ import {
6
+ isModelImage,
7
+ MAX_IMAGES_PER_TURN,
8
+ prepareImage,
9
+ } from "./image-prep.ts";
10
+
11
+ /** Saves the message's and its reference's files, and turns up to four images into model images. */
12
+ export async function collectAttachments(
13
+ message: InboundMessage,
14
+ dir: string,
15
+ options: { logger: Logger; fetchImpl?: typeof fetch },
16
+ ): Promise<TurnAttachments> {
17
+ const { fetchImpl, logger } = options;
18
+ const own = await fetchAttachments(
19
+ {
20
+ refs: message.attachments,
21
+ dir,
22
+ prefix: message.messageId,
23
+ fromReference: false,
24
+ },
25
+ fetchImpl,
26
+ );
27
+ const referenced = await fetchAttachments(
28
+ {
29
+ refs: message.reference?.attachments ?? [],
30
+ dir,
31
+ prefix: `${message.messageId}-ref`,
32
+ fromReference: true,
33
+ },
34
+ fetchImpl,
35
+ );
36
+ const files = [...own.files, ...referenced.files];
37
+ const failures = [...own.failures, ...referenced.failures];
38
+ const images = [];
39
+ for (const file of files.filter(isModelImage).slice(0, MAX_IMAGES_PER_TURN)) {
40
+ try {
41
+ images.push(await prepareImage(file));
42
+ } catch (error) {
43
+ logger.warn(
44
+ { file: file.file, err: error },
45
+ "image could not be prepared",
46
+ );
47
+ failures.push({
48
+ name: file.name,
49
+ reason: "the image could not be decoded",
50
+ fromReference: file.fromReference,
51
+ });
52
+ }
53
+ }
54
+ if (files.length > 0 || failures.length > 0) {
55
+ logger.info(
56
+ {
57
+ channel: message.channel,
58
+ files: files.length,
59
+ images: images.length,
60
+ failures: failures.length,
61
+ },
62
+ "attachments received",
63
+ );
64
+ }
65
+ return { files, images, failures };
66
+ }
67
+
68
+ /** Collects a message's attachments into `dir`, reporting the files that failed. */
69
+ export function attachmentsOf(
70
+ message: InboundMessage,
71
+ dir: string,
72
+ logger: Logger,
73
+ fetchImpl?: typeof fetch,
74
+ ): Promise<TurnAttachments> {
75
+ return collectAttachments(message, dir, {
76
+ logger,
77
+ ...(fetchImpl ? { fetchImpl } : {}),
78
+ });
79
+ }
@@ -0,0 +1,45 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { readdirSync, readFileSync } from "node:fs";
3
+ import { dirname, join, relative, resolve } from "node:path";
4
+
5
+ const ROOT = resolve(import.meta.dir, "../..");
6
+ const CORE = join(ROOT, "src/core");
7
+
8
+ function coreFiles(dir: string): string[] {
9
+ return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
10
+ const path = join(dir, entry.name);
11
+ if (entry.isDirectory()) return coreFiles(path);
12
+ return entry.name.endsWith(".ts") ? [path] : [];
13
+ });
14
+ }
15
+
16
+ /** Relative imports of a file, as repository paths. */
17
+ function localImports(file: string): string[] {
18
+ const source = readFileSync(file, "utf8");
19
+ return [...source.matchAll(/from\s+"(\.{1,2}\/[^"]+)"/g)].map((match) =>
20
+ relative(ROOT, resolve(dirname(file), match[1] ?? "")),
21
+ );
22
+ }
23
+
24
+ describe("core boundary", () => {
25
+ test("the core imports only itself", () => {
26
+ const outside = coreFiles(CORE).flatMap((file) =>
27
+ localImports(file)
28
+ .filter((path) => !path.startsWith("src/core/"))
29
+ .map((path) => `${relative(ROOT, file)} -> ${path}`),
30
+ );
31
+ expect(outside).toEqual([]);
32
+ });
33
+
34
+ test("the core never imports a consumer's plugins", () => {
35
+ const consumers = readdirSync(join(ROOT, "src"), { withFileTypes: true })
36
+ .filter((entry) => entry.isDirectory() && entry.name !== "core")
37
+ .map((entry) => `src/${entry.name}/`);
38
+ const found = coreFiles(CORE).flatMap((file) =>
39
+ localImports(file).filter((path) =>
40
+ consumers.some((prefix) => path.startsWith(prefix)),
41
+ ),
42
+ );
43
+ expect(found).toEqual([]);
44
+ });
45
+ });
@@ -0,0 +1,275 @@
1
+ import type {
2
+ ExtensionFactory,
3
+ ModelRuntime,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import { agentClaim } from "../agents/agent-claim.ts";
6
+ import { AgentDashboard } from "../agents/agent-dashboard.ts";
7
+ import { AgentTeam } from "../agents/agent-team.ts";
8
+ import { RelevanceScorer } from "../agents/group-round.ts";
9
+ import { channelKey } from "../agents/team-keys.ts";
10
+ import { ownerAttachmentDir } from "../attachments/attachment-dir.ts";
11
+ import type { ChannelKey } from "../domain/conversation.ts";
12
+ import type { OwnerIdentity } from "../identity.ts";
13
+ import { ConfirmationJudge } from "../judging/confirmation-judge.ts";
14
+ import { AGENT_BRIEF, EffortJudge } from "../judging/effort-judge.ts";
15
+ import {
16
+ AUTO_THINKING,
17
+ formatModelRef,
18
+ type ModelRef,
19
+ type ThinkingLevel,
20
+ } from "../models.ts";
21
+ import { SkillRegistry } from "../modules/skills/skill-registry.ts";
22
+ import type { ErrorReporter } from "../ops/error-reporter.ts";
23
+ import type { RoundtablePlugin } from "../plugin.ts";
24
+ import { splitReply } from "../presentation/reply-splitter.ts";
25
+ import { agentPromptExtension } from "../runtime/extensions/agent-prompt.ts";
26
+ import { PiAgentRuntime } from "../runtime/pi-agent-runtime.ts";
27
+ import type { AgentTurnScope, SessionTool } from "../sessions.ts";
28
+ import type { SpeakerPolicy } from "../speakers.ts";
29
+
30
+ export interface AgentServerOptions {
31
+ guildId: string;
32
+ /** The channel the coordinator lives in. */
33
+ entryChannelId: string;
34
+ owner: OwnerIdentity & { id: string };
35
+ /** The assistant's display name, as the confirmation judge names it. */
36
+ assistant: string;
37
+ /** Who may talk to the agents, and at which tier. */
38
+ speakers: SpeakerPolicy;
39
+ /** Shared with the rest of the host, so logins refresh in one place. */
40
+ modelRuntime: ModelRuntime;
41
+ agentDir: string;
42
+ dataDir: string;
43
+ /** The model of agents and of the assistant, and the thinking level a judge falls back to. */
44
+ model: ModelRef;
45
+ thinking: ThinkingLevel;
46
+ /** How sure the judges must be before their answer is used. */
47
+ judgeThreshold: number;
48
+ /** The shell's shared working directory. */
49
+ workDir: string;
50
+ /** The host account the agents' shell and file tools run as. */
51
+ shellUser: string;
52
+ /** The prompt every agent starts with, and the one for speakers other than the owner. */
53
+ prompts: { shared: string; guest?: string };
54
+ /** Where the skill registry reads and writes skills. */
55
+ skills: { reposDir: string; writtenDir: string; builtinDir: string };
56
+ /** The listener the avatar pictures are served on. */
57
+ avatarListener: string;
58
+ /** Reports the process's own errors to an agent; without one nothing is reported. */
59
+ errorReporter?: ErrorReporter;
60
+ /**
61
+ * Conversations outside the agent server, such as the owner's own: their system prompt, and the
62
+ * tools startup refuses to run without.
63
+ */
64
+ ownerSessions?: { persona: string; requiredTools: readonly string[] };
65
+ }
66
+
67
+ /** A session tool whose extension never changes, for agent sessions only. */
68
+ function agentOnly(
69
+ name: string,
70
+ factory: (scope: AgentTurnScope) => ExtensionFactory,
71
+ ): SessionTool {
72
+ return {
73
+ name,
74
+ phase: "tools",
75
+ snapshot: () => ({
76
+ revision: 0,
77
+ factory: (session) => (session.agent ? factory(session.agent) : null),
78
+ }),
79
+ };
80
+ }
81
+
82
+ /** The agent tools and the agent's own prompt, which only agent sessions load. */
83
+ export function agentSessionTools(
84
+ team: Pick<AgentTeam, "extension" | "systemPrompt">,
85
+ ): SessionTool[] {
86
+ return [
87
+ agentOnly("agent-tools", (scope) => team.extension(scope)),
88
+ agentOnly("agent-prompt", (scope) =>
89
+ agentPromptExtension(() => team.systemPrompt(scope)),
90
+ ),
91
+ ];
92
+ }
93
+
94
+ /**
95
+ * The agent server: the skill registry, the team with its channels and dashboard, and the
96
+ * runtime that runs every agent turn. It claims the agent channels, starts the team in the
97
+ * background, and hands what it built to the plugins after it.
98
+ */
99
+ export function agentServerPlugin(
100
+ options: AgentServerOptions,
101
+ ): RoundtablePlugin {
102
+ let runtime: PiAgentRuntime | undefined;
103
+ let team: AgentTeam | undefined;
104
+ let skills: SkillRegistry | undefined;
105
+ return {
106
+ name: "agent-server",
107
+ // The skills are read once every plugin is set up, so a plugin may still place some in setup.
108
+ preflight: async () => {
109
+ skills?.init();
110
+ await runtime?.preflight();
111
+ },
112
+ stopTurn: (channel) => runtime?.stop(channel) ?? false,
113
+ agentServer: () => team?.start() ?? Promise.resolve(),
114
+ setup: (context) => {
115
+ const { stores, discord } = context.core;
116
+ const { surface } = discord;
117
+ const { logger, providers } = context;
118
+ const judge = providers.judge;
119
+ const confirmations = new ConfirmationJudge({
120
+ judge,
121
+ threshold: options.judgeThreshold,
122
+ assistant: options.assistant,
123
+ logger,
124
+ });
125
+ const registry = new SkillRegistry({
126
+ store: stores.skills,
127
+ reposDir: options.skills.reposDir,
128
+ writtenDir: options.skills.writtenDir,
129
+ builtinDir: options.skills.builtinDir,
130
+ logger,
131
+ });
132
+ skills = registry;
133
+ const built: AgentTeam = new AgentTeam({
134
+ guildId: options.guildId,
135
+ entryChannelId: options.entryChannelId,
136
+ seeds: () => context.sessions().seeds,
137
+ promptSections: () => context.sessions().prompt,
138
+ pluginTools: () => context.sessions().agentTools,
139
+ pluginSelection: () => context.sessions().agentSelection(),
140
+ events: context.events,
141
+ owner: options.owner,
142
+ toolTiers: context.toolTiers,
143
+ shellUser: options.shellUser,
144
+ store: stores.agents,
145
+ channels: surface.agentChannels(options.guildId),
146
+ studio: discord.studio,
147
+ // The runtime is built next, with this team's tools.
148
+ runtime: () => {
149
+ if (!runtime) throw new Error("the runtime is built with the team");
150
+ return runtime;
151
+ },
152
+ confirmations,
153
+ scorer: new RelevanceScorer(judge, logger, options.owner),
154
+ models: {
155
+ defaults: {
156
+ model: formatModelRef(options.model),
157
+ thinking: AUTO_THINKING,
158
+ },
159
+ // The snapshot is filled in the background at startup; a form must open within 3 s.
160
+ usable: async () => {
161
+ const snapshot = options.modelRuntime.getAvailableSnapshot();
162
+ const models =
163
+ snapshot.length > 0
164
+ ? snapshot
165
+ : await options.modelRuntime.getAvailable();
166
+ return models.map((model) => `${model.provider}/${model.id}`);
167
+ },
168
+ },
169
+ schedules: stores.schedules,
170
+ queue: context.queue,
171
+ startTyping: (channel) => surface.startTyping(channel),
172
+ showStop: (channel) => surface.showStop(channel),
173
+ workDir: options.workDir,
174
+ sharedPrompt: options.prompts.shared,
175
+ ...(options.prompts.guest
176
+ ? { guestPrompt: options.prompts.guest }
177
+ : {}),
178
+ skills: registry,
179
+ threads: discord.threads,
180
+ logger,
181
+ });
182
+ team = built;
183
+ surface.onChannelDeleted((channelId) => {
184
+ if (!built.owns(channelKey(channelId))) return;
185
+ void context.queue
186
+ .run(channelKey(channelId), () => built.channelDeleted(channelId))
187
+ // pi-lens-ignore: no-unknown-parameters
188
+ .catch((error: unknown) =>
189
+ logger.error({ channelId, err: error }, "archive failed"),
190
+ );
191
+ });
192
+ const running = new PiAgentRuntime({
193
+ owner: options.owner,
194
+ sessions: context.sessions,
195
+ agentDir: options.agentDir,
196
+ modelRuntime: options.modelRuntime,
197
+ dataDir: options.dataDir,
198
+ model: options.model,
199
+ thinking: options.thinking,
200
+ effort: new EffortJudge({
201
+ judge,
202
+ brief: AGENT_BRIEF,
203
+ fallback: options.thinking,
204
+ threshold: options.judgeThreshold,
205
+ logger,
206
+ }),
207
+ persona: options.ownerSessions?.persona ?? "",
208
+ confirmations: stores.confirmations,
209
+ requiredTools: options.ownerSessions?.requiredTools ?? [],
210
+ toolTiers: context.toolTiers,
211
+ agents: {
212
+ workDir: options.workDir,
213
+ modelOf: (name) => built.modelOf(name),
214
+ skills: (name) => built.skillsOf(name),
215
+ turnChannel: (scope) => built.turnChannel(scope),
216
+ },
217
+ prompts: (channel, speaker) => discord.cards.prompts(channel, speaker),
218
+ logger,
219
+ });
220
+ runtime = running;
221
+ context.core.provide("agents", {
222
+ team: built,
223
+ runtime: running,
224
+ skills: registry,
225
+ confirmations,
226
+ });
227
+ const attachmentDir = (channel: ChannelKey) =>
228
+ ownerAttachmentDir(options.dataDir, channel);
229
+ const dashboard = new AgentDashboard({
230
+ status: () => built.status(),
231
+ board: surface.agentDashboard(options.guildId),
232
+ lines: () => context.dashboard(),
233
+ logger,
234
+ });
235
+ built.onChange(() => dashboard.changed());
236
+ return {
237
+ services: [
238
+ { name: "runtime", stop: () => running.dispose() },
239
+ { name: "dashboard", stop: () => dashboard.stop() },
240
+ ],
241
+ channels: [
242
+ agentClaim({
243
+ owner: options.owner,
244
+ speakers: options.speakers,
245
+ team: built,
246
+ runtime: running,
247
+ surface,
248
+ attachmentDir,
249
+ logger,
250
+ }),
251
+ ],
252
+ http: [discord.studio.route(options.avatarListener)],
253
+ sessionTools: agentSessionTools(built),
254
+ events: {
255
+ agentServer: (outcome) => {
256
+ options.errorReporter?.connect({
257
+ channelOf: (name) => {
258
+ const agent = stores.agents.agent(name);
259
+ return agent?.status === "active" && agent.channelId
260
+ ? channelKey(agent.channelId)
261
+ : undefined;
262
+ },
263
+ post: (channel, text) =>
264
+ surface.sendReply(channel, { chunks: splitReply(text) }),
265
+ turn: (channel, text) =>
266
+ context.core.background.runErrorReport(channel, text),
267
+ logger,
268
+ });
269
+ if (outcome === "ready") dashboard.start();
270
+ },
271
+ },
272
+ };
273
+ },
274
+ };
275
+ }
@@ -0,0 +1,95 @@
1
+ import { join } from "node:path";
2
+ import { AvatarStudio } from "../agents/avatar-studio.ts";
3
+ import { DiscordSurface } from "../discord/discord-surface.ts";
4
+ import { DispatchThreads } from "../discord/dispatch-threads.ts";
5
+ import { OwnerCards } from "../discord/owner-cards.ts";
6
+ import { OwnerGuard } from "../discord/owner-command.ts";
7
+ import type { RoundtablePlugin } from "../plugin.ts";
8
+ import type { SpeakerPolicy } from "../speakers.ts";
9
+
10
+ export interface DiscordOptions {
11
+ token: string;
12
+ ownerId: string;
13
+ /** How the owner is named in audit-log reasons and refusals. */
14
+ ownerName: string;
15
+ /** Who may answer a card besides the owner. */
16
+ speakers: SpeakerPolicy;
17
+ dataDir: string;
18
+ /** The origin the agents' pictures are served from. */
19
+ avatarUrl: string;
20
+ /** The name of the root slash command, without the slash. */
21
+ rootCommand: string;
22
+ /** The assistant's neutral avatar: the style reference and the picture of an agent without one. */
23
+ avatarReference: string;
24
+ }
25
+
26
+ /**
27
+ * The Discord connection and what stands on it: the avatar studio, the owner's cards, the owner
28
+ * guard, and the threads that carry background reports. It starts the surface, and the channel
29
+ * queue joins the shutdown drain.
30
+ */
31
+ export function discordPlugin(options: DiscordOptions): RoundtablePlugin {
32
+ let commands: DiscordSurface | undefined;
33
+ return {
34
+ name: "discord",
35
+ // Discord registers the commands as it connects, so they go to the surface before it starts.
36
+ useCommands: (composed) => commands?.useInteractions(composed),
37
+ setup: async ({ queue, conversations, providers, core, logger }) => {
38
+ const studio = new AvatarStudio({
39
+ dir: join(options.dataDir, "avatars"),
40
+ publicUrl: options.avatarUrl,
41
+ referencePath: options.avatarReference,
42
+ draw: providers.images,
43
+ });
44
+ await studio.init();
45
+ // The surface exists by the time a turn posts a card.
46
+ const cards: OwnerCards = new OwnerCards({
47
+ ownerId: options.ownerId,
48
+ speakers: options.speakers,
49
+ channel: (channelId) => surface.cardChannel(channelId),
50
+ logger,
51
+ });
52
+ const surface = new DiscordSurface({
53
+ token: options.token,
54
+ ownerId: options.ownerId,
55
+ ownerName: options.ownerName,
56
+ logger,
57
+ });
58
+ const guard = new OwnerGuard(
59
+ options.ownerId,
60
+ logger,
61
+ options.rootCommand,
62
+ );
63
+ // A channel whose owner keeps reports in place, such as a party channel, opens no thread.
64
+ const threads = new DispatchThreads({
65
+ host: {
66
+ open: (parentId, name, line) =>
67
+ surface.threadHost().open(parentId, name, line),
68
+ post: (threadId, text) => surface.threadHost().post(threadId, text),
69
+ close: (threadId) => surface.threadHost().close(threadId),
70
+ },
71
+ ledgerPath: join(options.dataDir, "dispatch-threads.json"),
72
+ excluded: (channel) => conversations.postsInPlace(channel),
73
+ logger,
74
+ });
75
+ commands = surface;
76
+ core.provide("discord", { surface, cards, guard, studio, threads });
77
+ return {
78
+ services: [
79
+ {
80
+ name: "surface",
81
+ start: async () => {
82
+ await surface.start(
83
+ (message) => void conversations.handle(message),
84
+ );
85
+ void threads.sweep();
86
+ },
87
+ stop: () => surface.stop(),
88
+ },
89
+ { name: "channel-queue", busy: () => queue.busy() },
90
+ ],
91
+ interactions: [{ module: cards }],
92
+ };
93
+ },
94
+ };
95
+ }
@@ -0,0 +1,127 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type {
3
+ ExtensionAPI,
4
+ ExtensionFactory,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import {
7
+ type AgentTurnScope,
8
+ type ChannelKey,
9
+ compileSessionPlan,
10
+ type SessionContext,
11
+ } from "../sessions.ts";
12
+ import { OWNER_CHANNEL, setUpModules } from "../testing/modules.ts";
13
+
14
+ const HOME: ChannelKey = "discord:scout";
15
+ const GROUP: ChannelKey = "discord:war-room";
16
+ const OUTSIDE: ChannelKey = "party:table-1";
17
+
18
+ const scout: AgentTurnScope = { name: "scout", session: HOME, home: HOME };
19
+ const seat: AgentTurnScope = {
20
+ name: "scout",
21
+ session: GROUP,
22
+ home: HOME,
23
+ group: "war-room",
24
+ };
25
+
26
+ function context(agent?: AgentTurnScope, home?: ChannelKey): SessionContext {
27
+ const session: SessionContext = {
28
+ kind: agent ? "agent" : "owner",
29
+ homeChannel: home ?? agent?.home ?? OWNER_CHANNEL,
30
+ turnChannel: agent?.session ?? home ?? OWNER_CHANNEL,
31
+ compaction: { wrap: (compactor) => compactor },
32
+ speaker: () => undefined,
33
+ runTask: async () => "report",
34
+ };
35
+ if (agent) session.agent = agent;
36
+ return session;
37
+ }
38
+
39
+ interface Registered {
40
+ name: string;
41
+ parameters: { properties: Record<string, unknown> };
42
+ execute(
43
+ id: string,
44
+ params: unknown,
45
+ ): Promise<{ content: { text: string }[]; isError?: boolean }>;
46
+ }
47
+
48
+ /** The tools one of the modules' extensions registers in a session. */
49
+ async function registered(
50
+ setup: Awaited<ReturnType<typeof setUpModules>>,
51
+ session: SessionContext,
52
+ extension: string,
53
+ ): Promise<Registered[]> {
54
+ const tool = setup.contribution.sessionTools?.find(
55
+ (candidate) => candidate.name === extension,
56
+ );
57
+ const factory: ExtensionFactory | null | undefined = tool
58
+ ?.snapshot()
59
+ .factory(session);
60
+ if (!factory) throw new Error(`no ${extension} extension`);
61
+ const tools: Registered[] = [];
62
+ await factory({
63
+ registerTool: (definition: Registered) => tools.push(definition),
64
+ on: () => undefined,
65
+ } as unknown as ExtensionAPI);
66
+ return tools;
67
+ }
68
+
69
+ describe("modulesPlugin", () => {
70
+ test("adds the owner's tools in a fixed order, and the delegator to the shutdown drain", async () => {
71
+ const setup = await setUpModules();
72
+ const { sessionTools, services } = setup.contribution;
73
+ expect(sessionTools?.map((tool) => tool.name)).toEqual([
74
+ "owner-memory",
75
+ "notify",
76
+ "schedules",
77
+ "delegate",
78
+ "discord-admin",
79
+ ]);
80
+ expect(sessionTools?.every((tool) => tool.phase === "tools")).toBe(true);
81
+ expect(services?.map((service) => service.name)).toEqual(["delegator"]);
82
+ expect(compileSessionPlan(sessionTools ?? []).tools).toHaveLength(5);
83
+ });
84
+
85
+ test("provides the background turns and the delegator to the plugins after it", async () => {
86
+ const { core } = await setUpModules();
87
+ expect(core.background).toBeDefined();
88
+ expect(core.delegator.runningChannels()).toEqual([]);
89
+ });
90
+
91
+ test("delegate_task reports home and opens its thread in the group", async () => {
92
+ const setup = await setUpModules();
93
+ const [delegate] = await registered(setup, context(seat), "delegate");
94
+ await delegate?.execute("1", { title: "t", task: "look it up" });
95
+ await setup.core.delegator.idle();
96
+ expect(setup.record.reportChannels).toEqual([HOME]);
97
+ expect(setup.record.threadOrigins).toEqual([GROUP]);
98
+ expect(setup.record.ownerChannelAsked).toBe(0);
99
+ });
100
+
101
+ test("a conversation without a chat channel reports in the owner's messages", async () => {
102
+ const setup = await setUpModules();
103
+ const [delegate] = await registered(
104
+ setup,
105
+ context(undefined, OUTSIDE),
106
+ "delegate",
107
+ );
108
+ await delegate?.execute("1", { title: "t", task: "look it up" });
109
+ await setup.core.delegator.idle();
110
+ expect(setup.record.reportChannels).toEqual([OWNER_CHANNEL]);
111
+ expect(setup.record.ownerChannelAsked).toBe(1);
112
+ });
113
+
114
+ test("only agent sessions may read another agent's schedules", async () => {
115
+ const setup = await setUpModules({ agentChannelOf: () => HOME });
116
+ const list = async (session: SessionContext) =>
117
+ (await registered(setup, session, "schedules")).find(
118
+ (tool) => tool.name === "schedule_list",
119
+ );
120
+ expect(
121
+ Object.keys((await list(context(scout)))?.parameters.properties ?? {}),
122
+ ).toContain("agent");
123
+ expect(
124
+ Object.keys((await list(context()))?.parameters.properties ?? {}),
125
+ ).not.toContain("agent");
126
+ });
127
+ });