pi-roundtable 0.1.0 → 0.2.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 (238) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/README.md +6 -4
  3. package/docs/plugins.md +1448 -66
  4. package/examples/echo-runtime.test.ts +122 -0
  5. package/examples/echo-runtime.ts +107 -0
  6. package/examples/events.test.ts +1 -0
  7. package/examples/events.ts +2 -2
  8. package/examples/fake-surface.test.ts +180 -0
  9. package/examples/fake-surface.ts +109 -0
  10. package/examples/interactions.test.ts +12 -3
  11. package/examples/interactions.ts +19 -19
  12. package/examples/shared-services.test.ts +74 -0
  13. package/examples/shared-services.ts +60 -0
  14. package/examples/study-room.test.ts +69 -0
  15. package/examples/study-room.ts +52 -0
  16. package/examples/support-desk.test.ts +34 -0
  17. package/examples/support-desk.ts +40 -0
  18. package/package.json +9 -2
  19. package/src/cli/checks/database.ts +11 -30
  20. package/src/cli/checks/images.ts +19 -0
  21. package/src/cli/doctor.ts +6 -0
  22. package/src/cli/main.ts +2 -1
  23. package/src/core/agents/agent-claim.ts +42 -16
  24. package/src/core/agents/agent-messages.ts +4 -4
  25. package/src/core/agents/agent-ports.ts +8 -21
  26. package/src/core/agents/agent-prompt.ts +3 -1
  27. package/src/core/agents/agent-store.ts +6 -4
  28. package/src/core/agents/agent-team-fixture.ts +389 -0
  29. package/src/core/agents/agent-team.ts +26 -25
  30. package/src/core/agents/agent-tools.ts +34 -15
  31. package/src/core/agents/avatar-studio.ts +40 -6
  32. package/src/core/agents/fallback-avatar.ts +85 -0
  33. package/src/core/agents/team-editing.ts +49 -10
  34. package/src/core/agents/team-keys.ts +22 -9
  35. package/src/core/agents/team-layout.ts +4 -3
  36. package/src/core/agents/team-lifecycle.ts +28 -2
  37. package/src/core/agents/team-options.ts +9 -5
  38. package/src/core/agents/team-status.ts +4 -4
  39. package/src/core/agents/team-text.ts +8 -6
  40. package/src/core/agents/team-turn-types.ts +4 -4
  41. package/src/core/agents/team-turns.ts +6 -5
  42. package/src/core/builtin/agent-server.ts +187 -121
  43. package/src/core/builtin/discord-admin.ts +41 -0
  44. package/src/core/builtin/discord.ts +65 -40
  45. package/src/core/builtin/modules.ts +67 -58
  46. package/src/core/builtin/session-tool.ts +24 -0
  47. package/src/core/builtin/skills.ts +72 -0
  48. package/src/core/builtin/stores.ts +55 -31
  49. package/src/core/config/config.ts +45 -4
  50. package/src/core/config/schema.ts +6 -0
  51. package/src/core/contract/channels.ts +54 -14
  52. package/src/core/contract/providers.ts +17 -0
  53. package/src/core/contract/runtime.ts +138 -0
  54. package/src/core/contract/services.ts +45 -0
  55. package/src/core/contract/surface.ts +88 -0
  56. package/src/core/db/migrations.ts +118 -3
  57. package/src/core/define-roundtable.ts +70 -46
  58. package/src/core/define.ts +2 -1
  59. package/src/core/discord/agent-commands.ts +39 -14
  60. package/src/core/discord/agent-panel.ts +66 -0
  61. package/src/core/discord/channel-executor.ts +6 -3
  62. package/src/core/discord/channel-operations.ts +14 -11
  63. package/src/core/discord/command-collection.ts +46 -0
  64. package/src/core/{registry/interactions.ts → discord/compose-commands.ts} +5 -5
  65. package/src/core/discord/connection.ts +35 -0
  66. package/src/core/discord/discord-surface.ts +32 -94
  67. package/src/core/discord/dispatch-threads.ts +2 -2
  68. package/src/core/discord/inbound-message.ts +99 -0
  69. package/src/core/discord/interaction-module.ts +57 -1
  70. package/src/core/discord/owner-cards.ts +4 -4
  71. package/src/core/discord/owner-command.ts +44 -11
  72. package/src/core/discord/owner-discord.ts +2 -2
  73. package/src/core/discord/schedule-commands.ts +21 -18
  74. package/src/core/discord/stop-button.ts +44 -1
  75. package/src/core/domain/attachment.ts +12 -8
  76. package/src/core/domain/conversation.ts +3 -4
  77. package/src/core/domain/owner-prompts.ts +5 -5
  78. package/src/core/domain/ports.ts +9 -53
  79. package/src/core/freeze.ts +9 -0
  80. package/src/core/host.ts +255 -82
  81. package/src/core/http/listeners.ts +55 -20
  82. package/src/core/i18n/agent-panel.ts +4 -0
  83. package/src/core/i18n/index.ts +12 -2
  84. package/src/core/i18n/owner.ts +7 -1
  85. package/src/core/i18n/schedules.ts +12 -6
  86. package/src/core/identity.ts +1 -1
  87. package/src/core/judging/effort-judge.ts +25 -3
  88. package/src/core/log.ts +21 -3
  89. package/src/core/models.ts +2 -2
  90. package/src/core/modules/background/background-turns.ts +6 -4
  91. package/src/core/modules/delegation/delegate.ts +3 -2
  92. package/src/core/modules/delegation/delegator.ts +15 -13
  93. package/src/core/modules/delegation/{sol-worker.ts → web-research-worker.ts} +6 -6
  94. package/src/core/modules/discord-admin/discord-admin.ts +6 -6
  95. package/src/core/modules/host-shell/shell-policy.ts +8 -3
  96. package/src/core/modules/memory/owner-memory-store.ts +27 -25
  97. package/src/core/modules/memory/owner-memory.ts +9 -13
  98. package/src/core/modules/schedules/recurrence.ts +5 -3
  99. package/src/core/modules/schedules/schedule-store.ts +11 -11
  100. package/src/core/modules/schedules/schedule-tools.ts +22 -20
  101. package/src/core/modules/schedules/scheduler.ts +7 -2
  102. package/src/core/modules/schedules/schedules.ts +9 -3
  103. package/src/core/modules/skills/skill-registry.ts +3 -2
  104. package/src/core/modules/skills/skill-store.ts +2 -15
  105. package/src/core/modules/skills/skill-tools.ts +10 -4
  106. package/src/core/ops/error-reporter.ts +1 -1
  107. package/src/core/plugin.ts +232 -28
  108. package/src/core/registry/contributions.ts +173 -14
  109. package/src/core/registry/providers.ts +35 -5
  110. package/src/core/registry/services.ts +229 -0
  111. package/src/core/routing/channel-queue.ts +5 -0
  112. package/src/core/routing/channel-router.ts +21 -7
  113. package/src/core/routing/conversation-turns.ts +139 -0
  114. package/src/core/routing/message-text.ts +9 -2
  115. package/src/core/routing/surface-port.ts +39 -0
  116. package/src/core/runtime/conversation-sessions.ts +4 -3
  117. package/src/core/runtime/extensions/confirmation-fixture.ts +11 -0
  118. package/src/core/runtime/extensions/confirmation-gate.ts +11 -9
  119. package/src/core/runtime/mcp.ts +1 -1
  120. package/src/core/runtime/pending-confirmation-store.ts +10 -12
  121. package/src/core/runtime/pi-agent-runtime.ts +9 -6
  122. package/src/core/runtime/prompt-slot.ts +8 -3
  123. package/src/core/runtime/runtime-types.ts +9 -35
  124. package/src/core/runtime/session-factory.ts +21 -5
  125. package/src/core/runtime/text-tools.ts +1 -3
  126. package/src/core/services.ts +238 -90
  127. package/src/core/sessions.ts +3 -2
  128. package/src/core/shared/{profile-tools.ts → active-tools.ts} +2 -2
  129. package/src/core/shared/delegate-tool.ts +4 -3
  130. package/src/core/shared/schedule-tools.ts +22 -13
  131. package/src/core/shared/session-messages.ts +1 -1
  132. package/src/core/speakers.ts +6 -38
  133. package/src/core/testing/database.ts +1 -7
  134. package/src/core/testing/eager-catalog.ts +30 -0
  135. package/src/core/testing/locale.ts +23 -5
  136. package/src/core/testing/modules.ts +105 -41
  137. package/src/core/testing/test-host.ts +279 -0
  138. package/src/core/testing/tool-set.ts +64 -0
  139. package/src/core/time.ts +6 -1
  140. package/src/core/tool-set.snapshot.json +288 -0
  141. package/src/core/tool-tiers.ts +17 -57
  142. package/src/discord/index.ts +64 -0
  143. package/src/index.ts +181 -10
  144. package/src/kit/channels.ts +14 -0
  145. package/src/kit/domain.ts +18 -0
  146. package/src/kit/holds.ts +4 -0
  147. package/src/kit/index.ts +129 -0
  148. package/src/kit/judging.ts +10 -0
  149. package/src/kit/memory.ts +3 -0
  150. package/src/kit/mirror.ts +19 -0
  151. package/src/kit/presentation.ts +7 -0
  152. package/src/kit/shell.ts +7 -0
  153. package/src/kit/skills.ts +8 -0
  154. package/src/kit/support.ts +21 -0
  155. package/src/kit/threads.ts +7 -0
  156. package/src/kit/tools.ts +14 -0
  157. package/src/kit/worker.ts +16 -0
  158. package/src/testing.ts +394 -34
  159. package/examples/guide.test.ts +0 -52
  160. package/src/cli/add-plugin.test.ts +0 -107
  161. package/src/cli/checks/basic.test.ts +0 -276
  162. package/src/cli/checks/database.test.ts +0 -117
  163. package/src/cli/checks/discord.test.ts +0 -200
  164. package/src/cli/cli.test.ts +0 -122
  165. package/src/cli/config-edit.test.ts +0 -130
  166. package/src/cli/doctor.test.ts +0 -184
  167. package/src/cli/init.test.ts +0 -127
  168. package/src/cli/size.test.ts +0 -9
  169. package/src/cli/templates.test.ts +0 -129
  170. package/src/cli/testing/fixtures.ts +0 -106
  171. package/src/core/agents/agent-claim.test.ts +0 -142
  172. package/src/core/agents/agent-dashboard.test.ts +0 -120
  173. package/src/core/agents/agent-guild.test.ts +0 -124
  174. package/src/core/agents/agent-store.test.ts +0 -205
  175. package/src/core/agents/avatar-studio.test.ts +0 -88
  176. package/src/core/agents/group-round.test.ts +0 -143
  177. package/src/core/agents/owner-identity.test.ts +0 -172
  178. package/src/core/agents/team-turns.test.ts +0 -278
  179. package/src/core/attachments/attachments.test.ts +0 -85
  180. package/src/core/boundary.test.ts +0 -45
  181. package/src/core/builtin/modules.test.ts +0 -127
  182. package/src/core/config/config.test.ts +0 -135
  183. package/src/core/contract/discord.ts +0 -33
  184. package/src/core/db/migrations.test.ts +0 -237
  185. package/src/core/define-roundtable.test.ts +0 -134
  186. package/src/core/define.test.ts +0 -144
  187. package/src/core/discord/agent-commands.test.ts +0 -58
  188. package/src/core/discord/agent-discord.test.ts +0 -70
  189. package/src/core/discord/dispatch-thread-host.test.ts +0 -114
  190. package/src/core/discord/dispatch-threads.test.ts +0 -94
  191. package/src/core/discord/owner-cards.test.ts +0 -435
  192. package/src/core/discord/owner-discord.test.ts +0 -325
  193. package/src/core/domain/expression.ts +0 -21
  194. package/src/core/domain/profile.ts +0 -32
  195. package/src/core/drain.test.ts +0 -46
  196. package/src/core/events.test.ts +0 -81
  197. package/src/core/holds.test.ts +0 -54
  198. package/src/core/host.test.ts +0 -536
  199. package/src/core/http/listeners.test.ts +0 -164
  200. package/src/core/i18n/i18n.test.ts +0 -139
  201. package/src/core/identity.test.ts +0 -18
  202. package/src/core/judging/effort-judge.test.ts +0 -112
  203. package/src/core/judging/model-judge.test.ts +0 -126
  204. package/src/core/modules/delegation/delegator.test.ts +0 -170
  205. package/src/core/modules/memory/owner-memory-store.test.ts +0 -244
  206. package/src/core/modules/schedules/schedule.test.ts +0 -358
  207. package/src/core/modules/skills/skill-kind.test.ts +0 -55
  208. package/src/core/modules/skills/skill-registry.test.ts +0 -376
  209. package/src/core/ops/error-reporter.test.ts +0 -283
  210. package/src/core/presentation/card-cadence.ts +0 -41
  211. package/src/core/presentation/presentation.test.ts +0 -166
  212. package/src/core/public-entry.test.ts +0 -59
  213. package/src/core/registry/contributions.test.ts +0 -215
  214. package/src/core/registry/interactions.test.ts +0 -87
  215. package/src/core/registry/providers.test.ts +0 -103
  216. package/src/core/routing/channel-queue.test.ts +0 -31
  217. package/src/core/routing/channel-router.test.ts +0 -326
  218. package/src/core/routing/conversation-kind.ts +0 -14
  219. package/src/core/routing/settle-turn.test.ts +0 -21
  220. package/src/core/runtime/compaction-tiers.test.ts +0 -227
  221. package/src/core/runtime/extensions/ask-user.test.ts +0 -114
  222. package/src/core/runtime/extensions/self-compact-guard.test.ts +0 -50
  223. package/src/core/runtime/pending-confirmation-store.test.ts +0 -52
  224. package/src/core/runtime/session-archive.test.ts +0 -20
  225. package/src/core/runtime/steerable-run.test.ts +0 -264
  226. package/src/core/runtime/text-tools.test.ts +0 -110
  227. package/src/core/runtime/turn-answer.test.ts +0 -72
  228. package/src/core/runtime/worker-task.test.ts +0 -82
  229. package/src/core/services.test.ts +0 -28
  230. package/src/core/sessions.test.ts +0 -85
  231. package/src/core/shared/unix-server.ts +0 -18
  232. package/src/core/size.test.ts +0 -9
  233. package/src/core/speakers.test.ts +0 -78
  234. package/src/core/testing/file-size.ts +0 -34
  235. package/src/core/time.test.ts +0 -166
  236. package/src/core/tool-tiers.test.ts +0 -117
  237. package/src/entries.test.ts +0 -112
  238. package/src/testing.test.ts +0 -156
@@ -1,13 +1,16 @@
1
- import type {
2
- ExtensionFactory,
3
- ModelRuntime,
4
- } from "@earendil-works/pi-coding-agent";
5
- import { agentClaim } from "../agents/agent-claim.ts";
1
+ import { join } from "node:path";
2
+ import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
3
+ import type { SQL } from "bun";
4
+ import { agentClaim, OWNER_TARGET } from "../agents/agent-claim.ts";
6
5
  import { AgentDashboard } from "../agents/agent-dashboard.ts";
7
- import { AgentTeam } from "../agents/agent-team.ts";
6
+ import { PgAgentStore } from "../agents/agent-store.ts";
7
+ import { DiscordAgentTeam } from "../agents/agent-team.ts";
8
+ import { AGENT_TOOLS } from "../agents/agent-tools.ts";
9
+ import { FileAvatarStudio } from "../agents/avatar-studio.ts";
8
10
  import { RelevanceScorer } from "../agents/group-round.ts";
9
- import { channelKey } from "../agents/team-keys.ts";
11
+ import { discordKey } from "../agents/team-keys.ts";
10
12
  import { ownerAttachmentDir } from "../attachments/attachment-dir.ts";
13
+ import type { AgentRuntime, AgentSessions } from "../contract/runtime.ts";
11
14
  import type { ChannelKey } from "../domain/conversation.ts";
12
15
  import type { OwnerIdentity } from "../identity.ts";
13
16
  import { ConfirmationJudge } from "../judging/confirmation-judge.ts";
@@ -18,14 +21,17 @@ import {
18
21
  type ModelRef,
19
22
  type ThinkingLevel,
20
23
  } from "../models.ts";
21
- import { SkillRegistry } from "../modules/skills/skill-registry.ts";
22
24
  import type { ErrorReporter } from "../ops/error-reporter.ts";
23
- import type { RoundtablePlugin } from "../plugin.ts";
25
+ import type { PluginContext, RoundtablePlugin } from "../plugin.ts";
24
26
  import { splitReply } from "../presentation/reply-splitter.ts";
25
27
  import { agentPromptExtension } from "../runtime/extensions/agent-prompt.ts";
28
+ import { PendingConfirmationStore } from "../runtime/pending-confirmation-store.ts";
26
29
  import { PiAgentRuntime } from "../runtime/pi-agent-runtime.ts";
27
- import type { AgentTurnScope, SessionTool } from "../sessions.ts";
28
- import type { SpeakerPolicy } from "../speakers.ts";
30
+ import { AGENTS, BACKGROUND_TURNS, SCHEDULES, SKILLS } from "../services.ts";
31
+ import type { SessionTool } from "../sessions.ts";
32
+ import type { Speaker, SpeakerPolicy, Tier } from "../speakers.ts";
33
+ import { DISCORD } from "./discord.ts";
34
+ import { agentOnly } from "./session-tool.ts";
29
35
 
30
36
  export interface AgentServerOptions {
31
37
  guildId: string;
@@ -51,37 +57,28 @@ export interface AgentServerOptions {
51
57
  shellUser: string;
52
58
  /** The prompt every agent starts with, and the one for speakers other than the owner. */
53
59
  prompts: { shared: string; guest?: string };
54
- /** Where the skill registry reads and writes skills. */
55
- skills: { reposDir: string; writtenDir: string; builtinDir: string };
56
60
  /** The listener the avatar pictures are served on. */
57
61
  avatarListener: string;
62
+ /** The origin the agents' pictures are served from. */
63
+ avatarUrl: string;
64
+ /** The assistant's neutral avatar: the style reference and the picture of an agent without one. */
65
+ avatarReference: string;
58
66
  /** Reports the process's own errors to an agent; without one nothing is reported. */
59
67
  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
68
  }
66
69
 
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
- }
70
+ /** The name of the agent server's plugin, as `serviceStarted` events name it. */
71
+ export const AGENT_SERVER_PLUGIN = "agent-server";
72
+
73
+ /**
74
+ * The agent server's service that starts the team and the dashboard in the background: once its
75
+ * `serviceStarted` event says `ready`, the agents' channels and the dashboard are up.
76
+ */
77
+ export const AGENT_TEAM_SERVICE = "team";
81
78
 
82
79
  /** The agent tools and the agent's own prompt, which only agent sessions load. */
83
80
  export function agentSessionTools(
84
- team: Pick<AgentTeam, "extension" | "systemPrompt">,
81
+ team: Pick<DiscordAgentTeam, "extension" | "systemPrompt">,
85
82
  ): SessionTool[] {
86
83
  return [
87
84
  agentOnly("agent-tools", (scope) => team.extension(scope)),
@@ -91,30 +88,78 @@ export function agentSessionTools(
91
88
  ];
92
89
  }
93
90
 
91
+ /** What every speaker may do with agents; admins create and edit them. */
92
+ const AGENT_TIERS: Readonly<Record<string, Tier>> = {
93
+ ...Object.fromEntries(AGENT_TOOLS.map((tool) => [tool, "admin" as const])),
94
+ agent_list: "member",
95
+ agent_get: "member",
96
+ message_agent: "member",
97
+ channel_read: "member",
98
+ };
99
+
100
+ /** The tables the agent server keeps: its agents and groups, and the held actions. */
101
+ export interface AgentServerStores {
102
+ agents: PgAgentStore;
103
+ /** Held actions, kept across a restart. */
104
+ confirmations: PendingConfirmationStore;
105
+ }
106
+
107
+ /** Attaches the agent server's stores over the host's migrated pool. */
108
+ async function attachStores(
109
+ sql: SQL,
110
+ guildId: string,
111
+ ): Promise<AgentServerStores> {
112
+ return {
113
+ agents: await PgAgentStore.attach(sql, guildId),
114
+ confirmations: await PendingConfirmationStore.attach(sql),
115
+ };
116
+ }
117
+
94
118
  /**
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
119
+ * The agent server: the team with its channels and dashboard, and the runtime that runs every
120
+ * agent turn. The skills the agents carry come from the skills addon when it is on. It claims the agent channels, starts the team in the
97
121
  * background, and hands what it built to the plugins after it.
98
122
  */
99
123
  export function agentServerPlugin(
100
124
  options: AgentServerOptions,
125
+ /** Opens the stores; a test hands stand-ins, so the plugin can be set up without a database. */
126
+ openStores: (
127
+ context: Pick<PluginContext, "database">,
128
+ guildId: string,
129
+ ) => Promise<AgentServerStores> = (context, guildId) =>
130
+ attachStores(context.database(), guildId),
101
131
  ): RoundtablePlugin {
102
- let runtime: PiAgentRuntime | undefined;
103
- let team: AgentTeam | undefined;
104
- let skills: SkillRegistry | undefined;
132
+ let runtime: AgentRuntime | undefined;
105
133
  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.
134
+ name: AGENT_SERVER_PLUGIN,
135
+ // The agent server's own tables; the host runs them before any setup, in this order.
136
+ migrations: [
137
+ PendingConfirmationStore.migration,
138
+ ...PgAgentStore.migrations(options.guildId),
139
+ ],
140
+ provides: [AGENTS],
108
141
  preflight: async () => {
109
- skills?.init();
110
- await runtime?.preflight();
142
+ await runtime?.preflight?.();
111
143
  },
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;
144
+ setup: async (context) => {
145
+ const discord = context.services.get(DISCORD);
146
+ const schedules = context.services.get(SCHEDULES);
147
+ const { connection } = discord;
148
+ const { logger, providers, surfaces } = context;
149
+ const studio = new FileAvatarStudio({
150
+ dir: join(options.dataDir, "avatars"),
151
+ publicUrl: options.avatarUrl,
152
+ referencePath: options.avatarReference,
153
+ // Without an image provider the studio makes each agent a picture from its name.
154
+ ...(providers.filled.has("images") ? { draw: providers.images } : {}),
155
+ });
156
+ await studio.init();
157
+ // Absent when the skills addon is off: the agents then carry none.
158
+ const skills = context.services.find(SKILLS);
159
+ const { agents: store, confirmations: heldActions } = await openStores(
160
+ context,
161
+ options.guildId,
162
+ );
118
163
  const judge = providers.judge;
119
164
  const confirmations = new ConfirmationJudge({
120
165
  judge,
@@ -122,15 +167,7 @@ export function agentServerPlugin(
122
167
  assistant: options.assistant,
123
168
  logger,
124
169
  });
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({
170
+ const built: DiscordAgentTeam = new DiscordAgentTeam({
134
171
  guildId: options.guildId,
135
172
  entryChannelId: options.entryChannelId,
136
173
  seeds: () => context.sessions().seeds,
@@ -141,9 +178,9 @@ export function agentServerPlugin(
141
178
  owner: options.owner,
142
179
  toolTiers: context.toolTiers,
143
180
  shellUser: options.shellUser,
144
- store: stores.agents,
145
- channels: surface.agentChannels(options.guildId),
146
- studio: discord.studio,
181
+ store,
182
+ channels: connection.agentChannels(options.guildId),
183
+ studio,
147
184
  // The runtime is built next, with this team's tools.
148
185
  runtime: () => {
149
186
  if (!runtime) throw new Error("the runtime is built with the team");
@@ -166,109 +203,138 @@ export function agentServerPlugin(
166
203
  return models.map((model) => `${model.provider}/${model.id}`);
167
204
  },
168
205
  },
169
- schedules: stores.schedules,
206
+ schedules,
170
207
  queue: context.queue,
171
- startTyping: (channel) => surface.startTyping(channel),
172
- showStop: (channel) => surface.showStop(channel),
208
+ startTyping: (channel) => surfaces.startTyping(channel),
209
+ showStop: (channel) => surfaces.showStop(channel),
173
210
  workDir: options.workDir,
174
211
  sharedPrompt: options.prompts.shared,
175
212
  ...(options.prompts.guest
176
213
  ? { guestPrompt: options.prompts.guest }
177
214
  : {}),
178
- skills: registry,
215
+ ...(skills ? { skills } : {}),
179
216
  threads: discord.threads,
180
217
  logger,
181
218
  });
182
- team = built;
183
- surface.onChannelDeleted((channelId) => {
184
- if (!built.owns(channelKey(channelId))) return;
219
+ connection.onChannelDeleted((channelId) => {
220
+ if (!built.owns(discordKey(channelId))) return;
185
221
  void context.queue
186
- .run(channelKey(channelId), () => built.channelDeleted(channelId))
222
+ .run(discordKey(channelId), () => built.channelDeleted(channelId))
187
223
  // pi-lens-ignore: no-unknown-parameters
188
224
  .catch((error: unknown) =>
189
225
  logger.error({ channelId, err: error }, "archive failed"),
190
226
  );
191
227
  });
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
- });
228
+ const agentSessions: AgentSessions = {
229
+ workDir: options.workDir,
230
+ modelOf: (name) => built.modelOf(name),
231
+ skills: (name) => built.skillsOf(name),
232
+ turnChannel: (scope) => built.turnChannel(scope),
233
+ };
234
+ const prompts = (channel: ChannelKey, speaker?: Speaker) =>
235
+ context.surfaces.prompts(channel, speaker);
236
+ // A plugin that fills the runtime slot replaces the whole conversation runtime; without
237
+ // one the agent server runs Pi, built from the options below.
238
+ const running: AgentRuntime = providers.filled.has("runtime")
239
+ ? providers.runtime({
240
+ logger,
241
+ env: context.env,
242
+ owner: { id: options.owner.id, name: options.owner.name },
243
+ sessions: context.sessions,
244
+ toolTiers: context.toolTiers,
245
+ prompts,
246
+ agents: agentSessions,
247
+ confirmations: heldActions,
248
+ judge,
249
+ })
250
+ : new PiAgentRuntime({
251
+ owner: options.owner,
252
+ sessions: context.sessions,
253
+ agentDir: options.agentDir,
254
+ modelRuntime: options.modelRuntime,
255
+ dataDir: options.dataDir,
256
+ model: options.model,
257
+ thinking: options.thinking,
258
+ effort: new EffortJudge({
259
+ judge,
260
+ brief: AGENT_BRIEF,
261
+ fallback: options.thinking,
262
+ threshold: options.judgeThreshold,
263
+ logger,
264
+ }),
265
+ confirmations: heldActions,
266
+ toolTiers: context.toolTiers,
267
+ agents: agentSessions,
268
+ prompts,
269
+ logger,
270
+ });
220
271
  runtime = running;
221
- context.core.provide("agents", {
272
+ context.services.provide(AGENTS, {
222
273
  team: built,
274
+ directory: store,
223
275
  runtime: running,
224
- skills: registry,
225
- confirmations,
276
+ approvals: confirmations,
277
+ avatars: studio,
226
278
  });
227
279
  const attachmentDir = (channel: ChannelKey) =>
228
280
  ownerAttachmentDir(options.dataDir, channel);
229
281
  const dashboard = new AgentDashboard({
230
282
  status: () => built.status(),
231
- board: surface.agentDashboard(options.guildId),
283
+ board: connection.agentDashboard(options.guildId),
232
284
  lines: () => context.dashboard(),
233
285
  logger,
234
286
  });
235
287
  built.onChange(() => dashboard.changed());
288
+ const connectErrorReporter = () =>
289
+ options.errorReporter?.connect({
290
+ channelOf: (name) => {
291
+ const agent = store.agent(name);
292
+ return agent?.status === "active" && agent.channelId
293
+ ? discordKey(agent.channelId)
294
+ : undefined;
295
+ },
296
+ post: (channel, text) =>
297
+ surfaces.sendReply(channel, { chunks: splitReply(text) }),
298
+ turn: (channel, text) =>
299
+ context.services
300
+ .get(BACKGROUND_TURNS)
301
+ .runErrorReport(channel, text),
302
+ logger,
303
+ });
236
304
  return {
237
305
  services: [
238
- { name: "runtime", stop: () => running.dispose() },
306
+ { name: "runtime", stop: () => running.dispose?.() },
239
307
  { name: "dashboard", stop: () => dashboard.stop() },
308
+ {
309
+ name: AGENT_TEAM_SERVICE,
310
+ // The dashboard shows the team, so it starts once the team has; the error
311
+ // reporter connects whether or not the team did, since it reports the failure.
312
+ startInBackground: async () => {
313
+ try {
314
+ await built.start();
315
+ dashboard.start();
316
+ } finally {
317
+ connectErrorReporter();
318
+ }
319
+ },
320
+ },
240
321
  ],
322
+ // The schedules and delegated tasks of the owner's and the agents' conversations are its own.
323
+ backgroundTargets: [OWNER_TARGET],
241
324
  channels: [
242
325
  agentClaim({
243
326
  owner: options.owner,
244
327
  speakers: options.speakers,
245
328
  team: built,
246
329
  runtime: running,
247
- surface,
330
+ surface: context.surfaces,
248
331
  attachmentDir,
249
332
  logger,
250
333
  }),
251
334
  ],
252
- http: [discord.studio.route(options.avatarListener)],
335
+ http: [studio.route(options.avatarListener)],
253
336
  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
- },
337
+ toolTiers: AGENT_TIERS,
272
338
  };
273
339
  },
274
340
  };
@@ -0,0 +1,41 @@
1
+ import type { OwnerIdentity } from "../identity.ts";
2
+ import {
3
+ DISCORD_ADMIN_TOOLS,
4
+ discordAdminExtension,
5
+ } from "../modules/discord-admin/discord-admin.ts";
6
+ import type { RoundtablePlugin } from "../plugin.ts";
7
+ import { DISCORD } from "./discord.ts";
8
+ import { fixed } from "./session-tool.ts";
9
+
10
+ export interface DiscordAdminOptions {
11
+ /** Whose Discord permissions the tools check, and who they serve. */
12
+ owner: OwnerIdentity & { id: string };
13
+ }
14
+
15
+ /**
16
+ * Discord administration: the owner's tools to read and manage the server, each a session tool
17
+ * of every conversation, built on the Discord connection. An addon; `discord.admin: false` leaves
18
+ * it out, and no conversation has a Discord tool.
19
+ */
20
+ export function discordAdminPlugin(
21
+ options: DiscordAdminOptions,
22
+ ): RoundtablePlugin {
23
+ const { owner } = options;
24
+ return {
25
+ name: "discord-admin",
26
+ setup: ({ services }) => {
27
+ const { connection } = services.get(DISCORD);
28
+ return {
29
+ sessionTools: [
30
+ fixed("discord-admin", () =>
31
+ discordAdminExtension(connection.ownerOperations(), owner),
32
+ ),
33
+ ],
34
+ // Administering the server stays with the owner until an operator lowers a tool.
35
+ toolTiers: Object.fromEntries(
36
+ DISCORD_ADMIN_TOOLS.map((tool) => [tool, "owner" as const]),
37
+ ),
38
+ };
39
+ },
40
+ };
41
+ }
@@ -1,12 +1,34 @@
1
1
  import { join } from "node:path";
2
- import { AvatarStudio } from "../agents/avatar-studio.ts";
2
+ import { type ServiceKey, serviceKey } from "../contract/services.ts";
3
+ import { CommandCollection } from "../discord/command-collection.ts";
4
+ import type { DiscordConnection } from "../discord/connection.ts";
3
5
  import { DiscordSurface } from "../discord/discord-surface.ts";
4
6
  import { DispatchThreads } from "../discord/dispatch-threads.ts";
7
+ import type {
8
+ CommandGuard,
9
+ CommandRegistrar,
10
+ } from "../discord/interaction-module.ts";
5
11
  import { OwnerCards } from "../discord/owner-cards.ts";
6
- import { OwnerGuard } from "../discord/owner-command.ts";
12
+ import { OwnerGuard, ownerRootCommand } from "../discord/owner-command.ts";
13
+ import { stopButtonModule } from "../discord/stop-button.ts";
7
14
  import type { RoundtablePlugin } from "../plugin.ts";
8
15
  import type { SpeakerPolicy } from "../speakers.ts";
9
16
 
17
+ /** The Discord connection and what stands on it, as plugins use it. Provided by the Discord plugin. */
18
+ export interface DiscordServices {
19
+ connection: DiscordConnection;
20
+ /** Adds a plugin's slash commands and components; during setup only. */
21
+ commands: CommandRegistrar;
22
+ /** Who may use the owner's commands. */
23
+ guard: CommandGuard;
24
+ /** The threads that carry background reports. */
25
+ threads: DispatchThreads;
26
+ }
27
+
28
+ /** The Discord connection, provided by the Discord plugin. */
29
+ export const DISCORD: ServiceKey<DiscordServices> =
30
+ serviceKey<DiscordServices>("roundtable.discord");
31
+
10
32
  export interface DiscordOptions {
11
33
  token: string;
12
34
  ownerId: string;
@@ -15,80 +37,83 @@ export interface DiscordOptions {
15
37
  /** Who may answer a card besides the owner. */
16
38
  speakers: SpeakerPolicy;
17
39
  dataDir: string;
18
- /** The origin the agents' pictures are served from. */
19
- avatarUrl: string;
20
40
  /** The name of the root slash command, without the slash. */
21
41
  rootCommand: string;
22
- /** The assistant's neutral avatar: the style reference and the picture of an agent without one. */
23
- avatarReference: string;
42
+ /** Text appended as it is to the refusal a non-owner gets, such as a pointer to the commands anyone may use. */
43
+ refusalHint?: string;
24
44
  }
25
45
 
26
46
  /**
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.
47
+ * The Discord connection and what stands on it: the owner's cards, the command guard, and the
48
+ * threads that carry background reports. It contributes the Discord surface, collects the
49
+ * plugins' slash commands while they set up, and composes them under the root command in its
50
+ * preflight, so a clash fails before any service starts.
30
51
  */
31
52
  export function discordPlugin(options: DiscordOptions): RoundtablePlugin {
32
- let commands: DiscordSurface | undefined;
53
+ const collection = new CommandCollection();
54
+ let surface: DiscordSurface | undefined;
33
55
  return {
34
56
  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();
57
+ provides: [DISCORD],
58
+ preflight: () => {
59
+ // The host has applied its environment by now, so the description is in its language.
60
+ surface?.setCommands(
61
+ collection.compose(ownerRootCommand(options.rootCommand)),
62
+ );
63
+ },
64
+ setup: async ({ conversations, services, logger }) => {
45
65
  // The surface exists by the time a turn posts a card.
46
66
  const cards: OwnerCards = new OwnerCards({
47
67
  ownerId: options.ownerId,
48
68
  speakers: options.speakers,
49
- channel: (channelId) => surface.cardChannel(channelId),
69
+ channel: (channelId) => connected.cardChannel(channelId),
50
70
  logger,
51
71
  });
52
- const surface = new DiscordSurface({
72
+ const connected = new DiscordSurface({
53
73
  token: options.token,
54
74
  ownerId: options.ownerId,
55
75
  ownerName: options.ownerName,
76
+ prompts: (channel, speaker) => cards.prompts(channel, speaker),
56
77
  logger,
57
78
  });
79
+ surface = connected;
58
80
  const guard = new OwnerGuard(
59
81
  options.ownerId,
60
82
  logger,
61
83
  options.rootCommand,
84
+ options.refusalHint,
62
85
  );
63
- // A channel whose owner keeps reports in place, such as a party channel, opens no thread.
86
+ // A channel whose claim keeps reports in place, such as an open channel, opens no thread.
64
87
  const threads = new DispatchThreads({
65
88
  host: {
66
89
  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),
90
+ connected.threadHost().open(parentId, name, line),
91
+ post: (threadId, text) => connected.threadHost().post(threadId, text),
92
+ close: (threadId) => connected.threadHost().close(threadId),
70
93
  },
71
94
  ledgerPath: join(options.dataDir, "dispatch-threads.json"),
72
95
  excluded: (channel) => conversations.postsInPlace(channel),
73
96
  logger,
74
97
  });
75
- commands = surface;
76
- core.provide("discord", { surface, cards, guard, studio, threads });
98
+ // The cards answer the interactions of held actions, and core posts the stop button, so
99
+ // core answers both; they come first, as they always have.
100
+ collection.registrar.add({ module: cards });
101
+ collection.registrar.add({
102
+ module: stopButtonModule({ guard, conversations }),
103
+ });
104
+ services.provide(DISCORD, {
105
+ connection: connected,
106
+ commands: collection.registrar,
107
+ guard,
108
+ threads,
109
+ });
77
110
  return {
111
+ // The host starts the surface first; it registers the composed commands as it connects.
112
+ surfaces: [connected],
78
113
  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() },
114
+ // The sweep reads threads through the connection, so it follows the surface's start.
115
+ { name: "threads", start: () => void threads.sweep() },
90
116
  ],
91
- interactions: [{ module: cards }],
92
117
  };
93
118
  },
94
119
  };