pi-roundtable 0.7.19 → 0.8.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 (72) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +44 -11
  3. package/README.zh-TW.md +44 -9
  4. package/docs/migrating-0.8.md +132 -0
  5. package/docs/plugins.md +267 -23
  6. package/examples/discord-adapter.test.ts +42 -0
  7. package/examples/discord-adapter.ts +26 -0
  8. package/examples/echo-runtime.ts +1 -1
  9. package/examples/headless.test.ts +72 -0
  10. package/examples/headless.ts +31 -0
  11. package/examples/http.test.ts +31 -1
  12. package/examples/http.ts +39 -0
  13. package/package.json +3 -2
  14. package/src/cli/cli.ts +15 -4
  15. package/src/cli/doctor.ts +21 -5
  16. package/src/cli/init.ts +9 -1
  17. package/src/cli/project.ts +28 -2
  18. package/src/cli/templates.ts +46 -14
  19. package/src/core/agents/agent-team-fixture.ts +6 -0
  20. package/src/core/agents/team-turns.ts +6 -1
  21. package/src/core/builtin/agent-server.ts +30 -95
  22. package/src/core/builtin/modules.ts +133 -34
  23. package/src/core/config/adapters.ts +57 -0
  24. package/src/core/config/config.ts +174 -77
  25. package/src/core/contract/channels.ts +4 -0
  26. package/src/core/contract/providers.ts +3 -2
  27. package/src/core/contract/runtime.ts +8 -4
  28. package/src/core/contract/surface.ts +13 -0
  29. package/src/core/conversations/conversation-registry.ts +42 -0
  30. package/src/core/conversations/conversation-store.ts +113 -0
  31. package/src/core/conversations/conversations-plugin.ts +29 -0
  32. package/src/core/define-roundtable.ts +124 -54
  33. package/src/core/discord/discord-adapter.ts +9 -0
  34. package/src/core/discord/owner-cards.ts +19 -8
  35. package/src/core/domain/conversation.ts +5 -0
  36. package/src/core/domain/owner-prompts.ts +1 -1
  37. package/src/core/domain/ports.ts +6 -0
  38. package/src/core/domain/progress.ts +10 -0
  39. package/src/core/events.ts +1 -0
  40. package/src/core/host.ts +6 -3
  41. package/src/core/http/listeners.ts +174 -21
  42. package/src/core/http/websocket.ts +316 -0
  43. package/src/core/i18n/cards.ts +5 -7
  44. package/src/core/ops/error-reporter.ts +24 -12
  45. package/src/core/plugin.ts +14 -0
  46. package/src/core/registry/providers.ts +1 -1
  47. package/src/core/routing/channel-router.ts +8 -0
  48. package/src/core/routing/conversation-turns.ts +55 -1
  49. package/src/core/routing/surface-port.ts +3 -0
  50. package/src/core/runtime/extensions/confirmation-gate.ts +10 -2
  51. package/src/core/runtime/extensions/self-compact-guard.ts +11 -0
  52. package/src/core/runtime/pending-confirmation-store.ts +42 -4
  53. package/src/core/runtime/pi-agent-runtime.ts +13 -6
  54. package/src/core/runtime/prompt-slot.ts +2 -2
  55. package/src/core/runtime/runtime-plugin.ts +160 -0
  56. package/src/core/runtime/turn-progress.ts +116 -0
  57. package/src/core/services.ts +15 -1
  58. package/src/core/shared/unix-server.ts +35 -5
  59. package/src/core/testing/modules.ts +47 -23
  60. package/src/core/testing/surface-contract.ts +383 -0
  61. package/src/core/testing/test-host.ts +27 -9
  62. package/src/discord/index.ts +2 -0
  63. package/src/index.ts +18 -0
  64. package/src/testing.ts +41 -5
  65. package/templates/adapters/web/.env.example +24 -0
  66. package/templates/adapters/web/README.md +15 -0
  67. package/templates/adapters/web/package.json.tmpl +26 -0
  68. package/templates/adapters/web/persona/assistant.md +2 -0
  69. package/templates/adapters/web/roundtable.config.ts +55 -0
  70. package/templates/package.json.tmpl +1 -0
  71. package/templates/plugins/self-compact.ts +11 -0
  72. package/templates/roundtable.config.ts +2 -1
@@ -13,7 +13,12 @@ import {
13
13
  precheckPlugin,
14
14
  scheduleStorePlugin,
15
15
  } from "./builtin/stores.ts";
16
- import { type RoundtableConfig, resolveConfig } from "./config/config.ts";
16
+ import {
17
+ type ResolvedConfig,
18
+ type RoundtableConfig,
19
+ resolveConfig,
20
+ } from "./config/config.ts";
21
+ import { conversationsPlugin } from "./conversations/conversations-plugin.ts";
17
22
  import { ConfigError } from "./domain/errors.ts";
18
23
  import { JudgeError } from "./errors.ts";
19
24
  import type { RoundtableOptions } from "./host.ts";
@@ -23,6 +28,11 @@ import { createLogger, type LogEntry, type Logger } from "./log.ts";
23
28
  import { formatModelRef, type ModelRef } from "./models.ts";
24
29
  import { ErrorReporter } from "./ops/error-reporter.ts";
25
30
  import type { RoundtablePlugin } from "./plugin.ts";
31
+ import {
32
+ type AgentSessionsSlot,
33
+ agentSessionsSlot,
34
+ runtimePlugin,
35
+ } from "./runtime/runtime-plugin.ts";
26
36
  import { speakerPolicy } from "./speakers.ts";
27
37
  import { toolTiers } from "./tool-tiers.ts";
28
38
 
@@ -86,7 +96,7 @@ function judgeThrough(modelRuntime: ModelRuntime, ref: ModelRef): JudgeModel {
86
96
 
87
97
  /** The listener `config.http` names: its unix socket when it has one, else its TCP port. */
88
98
  function publicListener(
89
- http: ReturnType<typeof resolveConfig>["http"],
99
+ http: NonNullable<ResolvedConfig["http"]>,
90
100
  ): ListenerConfig {
91
101
  if (http.socketPath)
92
102
  return {
@@ -101,11 +111,95 @@ function publicListener(
101
111
  };
102
112
  }
103
113
 
114
+ /** The Discord bot and the agent server that lives there, each in its place in the plugin list. */
115
+ interface DiscordAssembly {
116
+ discord: RoundtablePlugin;
117
+ admin: RoundtablePlugin[];
118
+ skills: RoundtablePlugin[];
119
+ agentServer: RoundtablePlugin;
120
+ seeds: RoundtablePlugin;
121
+ }
122
+
123
+ function discordAssembly(
124
+ config: ResolvedConfig,
125
+ discord: NonNullable<ResolvedConfig["discord"]>,
126
+ shared: {
127
+ modelRuntime: ModelRuntime;
128
+ agentSessions: AgentSessionsSlot;
129
+ errorReporter: ErrorReporter | undefined;
130
+ },
131
+ ): DiscordAssembly {
132
+ const { name, owner } = config;
133
+ const speakers = speakerPolicy({
134
+ owners: [owner.id],
135
+ ...(config.speakers.admins ? { admins: config.speakers.admins } : {}),
136
+ ...(config.speakers.members ? { members: config.speakers.members } : {}),
137
+ });
138
+ const sharedPrompt = config.prompts.shared
139
+ ? readPrompt(config.prompts.shared, "prompts.shared")
140
+ : readPrompt(join(ASSETS, "prompts", "shared.md"), "prompts.shared");
141
+ const guest = config.prompts.guest
142
+ ? readPrompt(config.prompts.guest, "prompts.guest")
143
+ : readPrompt(join(ASSETS, "prompts", "shared-guest.md"), "prompts.guest");
144
+ const { errorReporter } = shared;
145
+ return {
146
+ discord: discordPlugin({
147
+ token: discord.token,
148
+ ownerId: owner.id,
149
+ ownerName: owner.name,
150
+ speakers,
151
+ rootCommand: discord.rootCommand,
152
+ dataDir: config.dataDir,
153
+ ...(discord.refusalHint === undefined
154
+ ? {}
155
+ : { refusalHint: discord.refusalHint }),
156
+ }),
157
+ admin: discord.admin ? [discordAdminPlugin({ owner })] : [],
158
+ skills: config.skills
159
+ ? [
160
+ skillsPlugin({
161
+ guildId: discord.guild,
162
+ reposDir: config.skills.reposDir ?? join(config.dataDir, "repos"),
163
+ writtenDir: join(config.dataDir, "skills"),
164
+ builtinDir: config.skills.builtinDir ?? join(ASSETS, "skills"),
165
+ }),
166
+ ]
167
+ : [],
168
+ agentServer: agentServerPlugin({
169
+ guildId: discord.guild,
170
+ entryChannelId: discord.entryChannel,
171
+ owner,
172
+ assistant: name,
173
+ speakers,
174
+ modelRuntime: shared.modelRuntime,
175
+ dataDir: config.dataDir,
176
+ model: config.model,
177
+ judgeThreshold: config.judge.threshold,
178
+ agents: shared.agentSessions,
179
+ workDir: config.workDir,
180
+ scratchDir: config.scratchDir,
181
+ shellUser: userInfo().username,
182
+ prompts: { shared: sharedPrompt, guest },
183
+ avatarListener: "public",
184
+ // resolveConfig refuses Discord without a public address.
185
+ avatarUrl: config.http?.publicUrl ?? "",
186
+ avatarReference: config.avatar ?? join(ASSETS, "neutral.png"),
187
+ // An ops agent's reports are the agent server's to deliver.
188
+ ...(errorReporter && "agent" in errorReporter.destination
189
+ ? { errorReporter }
190
+ : {}),
191
+ }),
192
+ seeds: seedsPlugin(config.agents),
193
+ };
194
+ }
195
+
104
196
  /**
105
197
  * The host's options and the plugin list of a configured bot: the built-in plugins in their
106
198
  * fixed order (each service one provides is read by the ones after it), then the operator's, then the scheduler, so a due schedule fires only once
107
199
  * everything it reaches runs. The memory, Discord administration, and skills addons are left out
108
- * when the configuration switches them off. Configuration mistakes stop here, naming the key and the fix.
200
+ * when the configuration switches them off. Without `discord` the Discord plugins, the skills,
201
+ * the agent server and its seeds are left out, and the runtime still runs every turn of
202
+ * `context.turns`. Configuration mistakes stop here, naming the key and the fix.
109
203
  */
110
204
  export async function defineRoundtable(
111
205
  input: RoundtableConfig,
@@ -122,13 +216,13 @@ export async function defineRoundtable(
122
216
  }));
123
217
  const registry = new ModelRegistry(modelRuntime);
124
218
  const errorReporter = config.ops
125
- ? new ErrorReporter({ opsAgent: config.ops.agent, app: name })
219
+ ? new ErrorReporter({ destination: config.ops, app: name })
126
220
  : undefined;
127
221
  const { errorSink } = overrides;
128
222
  const logger =
129
223
  overrides.logger ??
130
224
  createLogger(
131
- discord.rootCommand,
225
+ config.slug,
132
226
  errorReporter || errorSink
133
227
  ? (entry) => {
134
228
  errorReporter?.record(entry);
@@ -136,17 +230,15 @@ export async function defineRoundtable(
136
230
  }
137
231
  : undefined,
138
232
  );
139
- const speakers = speakerPolicy({
140
- owners: [owner.id],
141
- ...(config.speakers.admins ? { admins: config.speakers.admins } : {}),
142
- ...(config.speakers.members ? { members: config.speakers.members } : {}),
143
- });
144
- const shared = config.prompts.shared
145
- ? readPrompt(config.prompts.shared, "prompts.shared")
146
- : readPrompt(join(ASSETS, "prompts", "shared.md"), "prompts.shared");
147
- const guest = config.prompts.guest
148
- ? readPrompt(config.prompts.guest, "prompts.guest")
149
- : readPrompt(join(ASSETS, "prompts", "shared-guest.md"), "prompts.guest");
233
+ // The agent server hands the runtime its per-agent settings once it sets up.
234
+ const agentSessions = agentSessionsSlot();
235
+ const assembly = discord
236
+ ? discordAssembly(config, discord, {
237
+ modelRuntime,
238
+ agentSessions,
239
+ errorReporter,
240
+ })
241
+ : undefined;
150
242
  return {
151
243
  options: {
152
244
  logger,
@@ -154,12 +246,15 @@ export async function defineRoundtable(
154
246
  locale: config.locale,
155
247
  timeZone: config.timeZone,
156
248
  assistant: name,
157
- rootCommand: discord.rootCommand,
249
+ rootCommand: config.slug,
158
250
  agentDir: config.agentDir,
159
251
  },
160
252
  database: { url: config.databaseUrl },
161
253
  toolTiers: toolTiers(config.toolTiers),
162
- listeners: [publicListener(config.http), ...(overrides.listeners ?? [])],
254
+ listeners: [
255
+ ...(config.http ? [publicListener(config.http)] : []),
256
+ ...(overrides.listeners ?? []),
257
+ ],
163
258
  judgeModel: judgeThrough(modelRuntime, config.judge.model),
164
259
  apiKey: (provider) => registry.getApiKeyForProvider(provider),
165
260
  ...(overrides.aborted ? { aborted: overrides.aborted } : {}),
@@ -168,17 +263,7 @@ export async function defineRoundtable(
168
263
  ...(config.memory ? [memoryPlugin({ owner })] : []),
169
264
  scheduleStorePlugin(),
170
265
  precheckPlugin(),
171
- discordPlugin({
172
- token: discord.token,
173
- ownerId: owner.id,
174
- ownerName: owner.name,
175
- speakers,
176
- rootCommand: discord.rootCommand,
177
- dataDir: config.dataDir,
178
- ...(discord.refusalHint === undefined
179
- ? {}
180
- : { refusalHint: discord.refusalHint }),
181
- }),
266
+ ...(assembly ? [assembly.discord] : []),
182
267
  modulesPlugin({
183
268
  owner,
184
269
  assistant: name,
@@ -186,42 +271,27 @@ export async function defineRoundtable(
186
271
  agentDir: config.agentDir,
187
272
  dataDir: config.dataDir,
188
273
  delegation: config.delegation,
274
+ // A conversation's reports are the modules' to deliver.
275
+ ...(errorReporter && "conversation" in errorReporter.destination
276
+ ? { errorReporter }
277
+ : {}),
189
278
  }),
190
- ...(discord.admin ? [discordAdminPlugin({ owner })] : []),
191
- ...(config.skills
192
- ? [
193
- skillsPlugin({
194
- guildId: discord.guild,
195
- reposDir: config.skills.reposDir ?? join(config.dataDir, "repos"),
196
- writtenDir: join(config.dataDir, "skills"),
197
- builtinDir: config.skills.builtinDir ?? join(ASSETS, "skills"),
198
- }),
199
- ]
200
- : []),
201
- agentServerPlugin({
202
- guildId: discord.guild,
203
- entryChannelId: discord.entryChannel,
279
+ ...(assembly?.admin ?? []),
280
+ ...(assembly?.skills ?? []),
281
+ conversationsPlugin(),
282
+ runtimePlugin({
204
283
  owner,
205
- assistant: name,
206
- speakers,
207
284
  modelRuntime,
208
285
  agentDir: config.agentDir,
209
286
  dataDir: config.dataDir,
210
287
  model: config.model,
211
288
  thinking: config.thinking,
212
289
  judgeThreshold: config.judge.threshold,
213
- workDir: config.workDir,
214
- scratchDir: config.scratchDir,
215
- shellUser: userInfo().username,
216
- prompts: { shared, guest },
217
- avatarListener: "public",
218
- avatarUrl: config.http.publicUrl,
219
- avatarReference: config.avatar ?? join(ASSETS, "neutral.png"),
290
+ agents: agentSessions,
220
291
  interimText: config.interimText,
221
292
  interimPrimaryChars: config.interimPrimaryChars,
222
- ...(errorReporter ? { errorReporter } : {}),
223
293
  }),
224
- seedsPlugin(config.agents),
294
+ ...(assembly ? [assembly.agentServer, assembly.seeds] : []),
225
295
  ...config.plugins,
226
296
  schedulerPlugin(),
227
297
  ],
@@ -0,0 +1,9 @@
1
+ import type { DiscordAdapterConfig, DiscordConfig } from "../config/config.ts";
2
+
3
+ /**
4
+ * The Discord adapter, for `adapters: [discord({ ... })]` in roundtable.config.ts: the same
5
+ * settings, and the same host, as the top-level `discord`.
6
+ */
7
+ export function discord(options: DiscordConfig): DiscordAdapterConfig {
8
+ return { adapter: "discord", discord: options };
9
+ }
@@ -103,7 +103,8 @@ interface Audience {
103
103
 
104
104
  /**
105
105
  * The owner's cards in the assistant's channels: approvals of held actions and ask_user questions,
106
- * answered with buttons, a menu, or a form. Only the owner may answer; an unanswered card
106
+ * answered with buttons, a menu, or a form. The owner may answer, and so may the speaker whose
107
+ * turn asks, when the card allows (a question always; an approval at its tier); an unanswered card
107
108
  * expires after 30 minutes and a stopped turn cancels it. Open cards live in memory, so a
108
109
  * restart abandons them; pressing one then says it no longer works.
109
110
  */
@@ -262,12 +263,19 @@ export class OwnerCards implements InteractionModule {
262
263
  };
263
264
  }
264
265
 
265
- /** An approval is for the speakers whose tier holds the tool, the owner alone by default. */
266
+ /**
267
+ * An approval is for the speaker whose turn held the call, when their tier holds it, and for
268
+ * the owner; the owner's alone by default. Nobody else in the channel may approve it.
269
+ */
266
270
  #approvers(minTier: Tier, speaker: Speaker | undefined): Audience {
267
271
  const { ownerId, speakers } = this.#options;
268
- const mentionId =
269
- speaker && tierAtLeast(speaker.tier, minTier) ? speaker.id : ownerId;
270
- if (minTier === "owner" || !speakers)
272
+ if (
273
+ minTier === "owner" ||
274
+ !speakers ||
275
+ !speaker ||
276
+ speaker.id === ownerId ||
277
+ !tierAtLeast(speaker.tier, minTier)
278
+ )
271
279
  return {
272
280
  allows: (user) => user.id === ownerId,
273
281
  mentionId: ownerId,
@@ -276,12 +284,15 @@ export class OwnerCards implements InteractionModule {
276
284
  };
277
285
  return {
278
286
  allows: (user) => {
287
+ if (user.id === ownerId) return true;
288
+ if (user.id !== speaker.id) return false;
289
+ // Their tier when they press, in case it was lowered during the turn.
279
290
  const who = speakers.resolve({ ...user, name: "" });
280
291
  return who !== undefined && tierAtLeast(who.tier, minTier);
281
292
  },
282
- mentionId,
283
- note: messages().cardApproversNote(minTier),
284
- refusal: messages().cardApproversRefusal(minTier),
293
+ mentionId: speaker.id,
294
+ note: messages().cardApproversNote(speaker.id),
295
+ refusal: messages().cardApproversRefusal,
285
296
  };
286
297
  }
287
298
 
@@ -26,6 +26,11 @@ export interface PendingConfirmation {
26
26
  selectionId: string;
27
27
  heldAt: Date;
28
28
  calls: HeldCall[];
29
+ /**
30
+ * The speaker whose turn held the calls. Besides the owner, only they may approve them, at a
31
+ * tier that holds them; without one, the owner alone may.
32
+ */
33
+ speakerId?: string;
29
34
  }
30
35
 
31
36
  /** A file produced for a reply; data is raw bytes, not a path or base64. */
@@ -33,7 +33,7 @@ export interface OwnerPrompts {
33
33
  title: string,
34
34
  message: string,
35
35
  signal?: AbortSignal,
36
- /** The lowest tier that may approve; the owner only when absent. */
36
+ /** The tier the turn's speaker needs to approve it themselves; the owner may always approve, and only the owner when absent. */
37
37
  minTier?: Tier,
38
38
  ): Promise<Approval>;
39
39
  /** Asks them a question; undefined when it expired or was cancelled. */
@@ -3,6 +3,7 @@ import type { Speaker } from "../speakers.ts";
3
3
  import type { TurnAttachments } from "./attachment.ts";
4
4
  import type { ChannelKey } from "./conversation.ts";
5
5
  import type { InterimPosts } from "./interim.ts";
6
+ import type { TurnProgress } from "./progress.ts";
6
7
 
7
8
  export type { AgentTurnScope };
8
9
 
@@ -38,6 +39,11 @@ export interface TurnRequest {
38
39
  * hands the channel its reply goes to. Absent = only the final reply is posted.
39
40
  */
40
41
  interim?: InterimPosts;
42
+ /**
43
+ * Hears what the turn writes and which tools it runs, as it goes, until the turn ends. A runtime
44
+ * without live progress never calls it.
45
+ */
46
+ progress?: (event: TurnProgress) => void;
41
47
  }
42
48
 
43
49
  /** The judge's question and answer shapes. */
@@ -0,0 +1,10 @@
1
+ /**
2
+ * What a running turn reports as it goes, for a surface that shows it live. Text arrives in
3
+ * chunks joined over a short interval; a tool is named with a short one-line preview of its
4
+ * arguments, never their full text, and ends with whether it succeeded. The final reply still
5
+ * arrives through `sendReply`.
6
+ */
7
+ export type TurnProgress =
8
+ | { type: "text"; delta: string }
9
+ | { type: "tool_start"; id: string; tool: string; preview?: string }
10
+ | { type: "tool_end"; id: string; tool: string; ok: boolean };
@@ -50,6 +50,7 @@ export class EventBus {
50
50
  readonly sink: EventSink = {
51
51
  turnStarted: (turn) => void this.deliver("turnStarted", turn),
52
52
  turnEnded: (turn) => void this.deliver("turnEnded", turn),
53
+ turnProgress: (event) => void this.deliver("turnProgress", event),
53
54
  changed: () => void this.deliver("changed"),
54
55
  };
55
56
  }
package/src/core/host.ts CHANGED
@@ -32,7 +32,7 @@ import {
32
32
  conversationTurns,
33
33
  } from "./routing/conversation-turns.ts";
34
34
  import { surfacePort } from "./routing/surface-port.ts";
35
- import { AGENTS } from "./services.ts";
35
+ import { CONVERSATIONS, RUNTIME } from "./services.ts";
36
36
  import { setTimeZone } from "./time.ts";
37
37
  import { type ToolTierTable, toolTiers } from "./tool-tiers.ts";
38
38
 
@@ -196,6 +196,8 @@ export class Roundtable {
196
196
  deleteConversation: (channel) => router().deleteConversation(channel),
197
197
  stop: (channel) => router().stop(channel),
198
198
  postsInPlace: (channel) => router().postsInPlace(channel),
199
+ owns: (channel) => router().owns(channel),
200
+ takesBackground: (channel) => router().takesBackground(channel),
199
201
  };
200
202
  }
201
203
 
@@ -210,7 +212,7 @@ export class Roundtable {
210
212
  });
211
213
  }
212
214
 
213
- /** Turns over the agent server's runtime and the surfaces; every call before linking is refused with NotLinkedError. */
215
+ /** Turns over the runtime plugin's runtime and the surfaces; every call before linking is refused with NotLinkedError. */
214
216
  #turns(): ConversationTurns {
215
217
  return conversationTurns({
216
218
  linked: () => {
@@ -222,8 +224,9 @@ export class Roundtable {
222
224
  runtime: () => {
223
225
  if (!this.#services)
224
226
  throw new NotLinkedError("the host has not started yet.");
225
- return this.#services.get(AGENTS).runtime;
227
+ return this.#services.get(RUNTIME);
226
228
  },
229
+ registry: () => this.#services?.find(CONVERSATIONS),
227
230
  surfaces: this.#surfaces(),
228
231
  events: this.#events.sink,
229
232
  selection: () => {