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,9 @@ import {
13
13
  } from "../models.ts";
14
14
  import type { RoundtablePlugin } from "../plugin.ts";
15
15
  import { PRIMARY_CHARS } from "../runtime/interim-text.ts";
16
+ import type { ChannelKey } from "../sessions.ts";
16
17
  import type { Tier, TierMembers } from "../speakers.ts";
18
+ import { discordShape, liftAdapters } from "./adapters.ts";
17
19
  import {
18
20
  bool,
19
21
  guarded,
@@ -41,33 +43,60 @@ export interface TierConfig {
41
43
  everyone?: boolean;
42
44
  }
43
45
 
46
+ /** The Discord bot and its guild, as the top-level `discord` or `discord()` from `pi-roundtable/discord` gives them. */
47
+ export interface DiscordConfig {
48
+ /** The bot token; keep it in `.env`, not in this file. */
49
+ token: string;
50
+ /** The agent server's guild id. */
51
+ guild: string;
52
+ /** The channel the coordinator lives in. */
53
+ entryChannel: string;
54
+ /** The root slash command, without the slash; default the lowercase assistant name. */
55
+ rootCommand?: string;
56
+ /**
57
+ * Whether the owner's conversations have Discord administration tools; default true. `false`
58
+ * leaves the `discord-admin` addon out.
59
+ */
60
+ admin?: boolean;
61
+ /**
62
+ * Text appended as it is to the refusal a non-owner gets from the root command, such as a
63
+ * pointer to the commands anyone may use; include the space or punctuation your language
64
+ * needs before it. Default none.
65
+ */
66
+ refusalHint?: string;
67
+ }
68
+
69
+ /** A chat network the host talks through, as its adapter's factory makes it; `adapter` names the network. */
70
+ export interface AdapterConfig {
71
+ readonly adapter: string;
72
+ }
73
+
74
+ /** What `discord()` from `pi-roundtable/discord` makes: the Discord settings under the adapter's name. */
75
+ export interface DiscordAdapterConfig extends AdapterConfig {
76
+ readonly adapter: "discord";
77
+ readonly discord: DiscordConfig;
78
+ }
79
+
44
80
  /** What `roundtable.config.ts` gives `defineRoundtable`. */
45
81
  export interface RoundtableConfig {
46
82
  /** The assistant's display name; default "Roundtable". */
47
83
  name?: string;
48
84
  /** The one owner: the only person who can change everything. */
49
85
  owner: { id: string; name: string; pronouns?: Pronouns };
50
- discord: {
51
- /** The bot token; keep it in `.env`, not in this file. */
52
- token: string;
53
- /** The agent server's guild id. */
54
- guild: string;
55
- /** The channel the coordinator lives in. */
56
- entryChannel: string;
57
- /** The root slash command, without the slash; default the lowercase assistant name. */
58
- rootCommand?: string;
59
- /**
60
- * Whether the owner's conversations have Discord administration tools; default true. `false`
61
- * leaves the `discord-admin` addon out.
62
- */
63
- admin?: boolean;
64
- /**
65
- * Text appended as it is to the refusal a non-owner gets from the root command, such as a
66
- * pointer to the commands anyone may use; include the space or punctuation your language
67
- * needs before it. Default none.
68
- */
69
- refusalHint?: string;
70
- };
86
+ /**
87
+ * The Discord bot, its guild, and the agent server that lives there. Leave it out for a host
88
+ * without Discord: no Discord plugins, no agent server or agents, no skills, and no tool that
89
+ * messages the owner on Discord; conversations come through the plugins' own surfaces. The same
90
+ * settings may come as `adapters: [discord({...})]` instead, never both.
91
+ */
92
+ discord?: DiscordConfig;
93
+ /**
94
+ * The chat networks the host talks through, each made by its adapter's factory, such as
95
+ * `discord()` from `pi-roundtable/discord`. A Discord adapter is the same as the top-level
96
+ * `discord`; a chat network that comes as a plugin, such as pi-roundtable-webchat, stays in
97
+ * `plugins`.
98
+ */
99
+ adapters?: readonly AdapterConfig[];
71
100
  database: { url: string };
72
101
  /** Where the process keeps its files, such as pictures, attachments, and skills. */
73
102
  dataDir: string;
@@ -89,15 +118,17 @@ export interface RoundtableConfig {
89
118
  toolTiers?: Record<string, Tier>;
90
119
  /** Prompt files, read once at start; `shared` starts every agent's prompt. */
91
120
  prompts?: { shared: string; guest?: string };
92
- /** The first team, created once; an agent already stored is never overwritten. */
121
+ /** The first team, created once; an agent already stored is never overwritten. Needs `discord`. */
93
122
  agents?: AgentSeed[];
94
123
  /**
95
- * Where the agents' avatars are served: the public address that reaches it, and the TCP port
96
- * (default 3000) or unix socket the process listens on. `socketMode` is the socket file's
97
- * permission bits (default `0o660`); widen it only for a proxy that runs as another user.
124
+ * The `public` listener: the public address that reaches it, and the TCP port (default 3000) or
125
+ * unix socket the process listens on. `socketMode` is the socket file's permission bits
126
+ * (default `0o660`); widen it only for a proxy that runs as another user. With `discord` it is
127
+ * required, with `publicUrl`, the address the agents' avatars are served from; without
128
+ * `discord` leave it out when no plugin serves HTTP, and the host opens no listener.
98
129
  */
99
- http: {
100
- publicUrl: string;
130
+ http?: {
131
+ publicUrl?: string;
101
132
  port?: number;
102
133
  hostname?: string;
103
134
  socketPath?: string;
@@ -109,13 +140,14 @@ export interface RoundtableConfig {
109
140
  workDir?: string;
110
141
  /**
111
142
  * The agents' scratch dir: their shell runs with TMPDIR pointing to it, and writes and removals
112
- * inside it run without a hold. Default `<os temp dir>/<discord.rootCommand>-scratch`.
143
+ * inside it run without a hold. Default `<os temp dir>/<slug>-scratch`, where the slug is
144
+ * `discord.rootCommand`, or the lowercase assistant name without Discord.
113
145
  */
114
146
  scratchDir?: string;
115
147
  /**
116
148
  * Where the skill registry reads built-in skills and keeps linked repositories. `false` leaves
117
149
  * the `skills` addon out: agents carry no skills, and no skill tools exist. Stored skills stay
118
- * in their tables.
150
+ * in their tables. Without `discord` there are no agents, so the addon is off.
119
151
  */
120
152
  skills?: false | { builtinDir?: string; reposDir?: string };
121
153
  /**
@@ -123,8 +155,13 @@ export interface RoundtableConfig {
123
155
  * `memory` addon out: no memory tools, no memory prompt block, and the table stays as it is.
124
156
  */
125
157
  memory?: boolean;
126
- /** The agent that investigates the process's own errors, by name. */
127
- ops?: { agent: string };
158
+ /**
159
+ * Where the process's own errors are reported: to an agent, by name, in its channel (needs
160
+ * `discord`), or to a conversation, by its key `<surface>:<id>`, as a visible message and a
161
+ * report turn its claim answers. The host does not start when no chat surface serves the
162
+ * conversation or no plugin's claim owns it; the web chat takes no error reports.
163
+ */
164
+ ops?: { agent: string } | { conversation: string };
128
165
  /**
129
166
  * Whether a turn posts the text it writes before its final answer as it goes: long or
130
167
  * structured text as ordinary messages, short narration and the tools called in one small
@@ -155,14 +192,7 @@ const schema = shape({
155
192
  name: text,
156
193
  pronouns: optional(oneOf<Pronouns>("he", "she", "they")),
157
194
  }),
158
- discord: shape({
159
- token: text,
160
- guild: text,
161
- entryChannel: text,
162
- rootCommand: optional(text),
163
- admin: optional(bool),
164
- refusalHint: optional(text),
165
- }),
195
+ discord: optional(discordShape),
166
196
  database: shape({ url: text }),
167
197
  dataDir: text,
168
198
  agentDir: optional(text),
@@ -199,13 +229,15 @@ const schema = shape({
199
229
  }),
200
230
  ),
201
231
  ),
202
- http: shape({
203
- publicUrl: text,
204
- port: optional(integer(1, 65535)),
205
- hostname: optional(text),
206
- socketPath: optional(text),
207
- socketMode: optional(integer(0, 0o777)),
208
- }),
232
+ http: optional(
233
+ shape({
234
+ publicUrl: optional(text),
235
+ port: optional(integer(1, 65535)),
236
+ hostname: optional(text),
237
+ socketPath: optional(text),
238
+ socketMode: optional(integer(0, 0o777)),
239
+ }),
240
+ ),
209
241
  avatar: optional(text),
210
242
  workDir: optional(text),
211
243
  scratchDir: optional(text),
@@ -213,7 +245,7 @@ const schema = shape({
213
245
  orOff(shape({ builtinDir: optional(text), reposDir: optional(text) })),
214
246
  ),
215
247
  memory: optional(bool),
216
- ops: optional(shape({ agent: text })),
248
+ ops: optional(shape({ agent: optional(text), conversation: optional(text) })),
217
249
  interimText: optional(oneOf<InterimTextMode>("on", "off")),
218
250
  interimPrimaryChars: optional(integer(1, 100_000)),
219
251
  plugins: optional(
@@ -232,8 +264,14 @@ const PRONOUNS: Record<Pronouns, OwnerIdentity["pronouns"]> = {
232
264
  /** The configuration with every default filled in. */
233
265
  export interface ResolvedConfig {
234
266
  name: string;
267
+ /**
268
+ * The process's short name: the logger's and the scratch dir's, and the root slash command's
269
+ * with Discord. `discord.rootCommand` when Discord is on, else the lowercase assistant name.
270
+ */
271
+ slug: string;
235
272
  owner: OwnerIdentity & { id: string };
236
- discord: {
273
+ /** Undefined on a host without Discord. */
274
+ discord?: {
237
275
  token: string;
238
276
  guild: string;
239
277
  entryChannel: string;
@@ -254,8 +292,10 @@ export interface ResolvedConfig {
254
292
  toolTiers: Record<string, Tier>;
255
293
  prompts: { shared?: string; guest?: string };
256
294
  agents: AgentSeed[];
257
- http: {
258
- publicUrl: string;
295
+ /** Undefined when no `http` is configured, and the host opens no `public` listener. */
296
+ http?: {
297
+ /** Always set with Discord. */
298
+ publicUrl?: string;
259
299
  port: number;
260
300
  hostname?: string;
261
301
  socketPath?: string;
@@ -267,7 +307,7 @@ export interface ResolvedConfig {
267
307
  /** `false` when the skills addon is off. */
268
308
  skills: false | { builtinDir?: string; reposDir?: string };
269
309
  memory: boolean;
270
- ops?: { agent: string };
310
+ ops?: { agent: string } | { conversation: ChannelKey };
271
311
  interimText: InterimTextMode;
272
312
  interimPrimaryChars: number;
273
313
  plugins: RoundtablePlugin[];
@@ -282,32 +322,84 @@ function modelOf(value: string, path: string): ModelRef {
282
322
  return parsed;
283
323
  }
284
324
 
325
+ /** Where the errors go: one of an agent or a conversation; an agent only where the agents live. */
326
+ function opsOf(
327
+ ops: { agent?: string; conversation?: string } | undefined,
328
+ withDiscord: boolean,
329
+ ): ResolvedConfig["ops"] {
330
+ if (!ops) return undefined;
331
+ const { agent, conversation } = ops;
332
+ if (agent !== undefined && conversation !== undefined)
333
+ throw new ConfigError(
334
+ "config ops: name an agent or a conversation, not both. Keep the one the reports go to.",
335
+ );
336
+ if (agent !== undefined) {
337
+ if (!withDiscord)
338
+ throw new ConfigError(
339
+ 'config ops.agent: the agents live in Discord, which is not configured. Report to a conversation a plugin\'s chat surface serves instead, with ops: { conversation: "<surface>:<id>" }; the web chat takes no error reports.',
340
+ );
341
+ return { agent };
342
+ }
343
+ if (conversation === undefined)
344
+ throw new ConfigError(
345
+ 'config ops: name an agent or a conversation, such as ops: { agent: "infra" } or ops: { conversation: "<surface>:<id>" }.',
346
+ );
347
+ if (!/^[^:]+:.+$/.test(conversation))
348
+ throw new ConfigError(
349
+ `config ops.conversation: expected a conversation key <surface>:<id>, got ${JSON.stringify(conversation)}. Write the surface's prefix, a colon, and the conversation's id.`,
350
+ );
351
+ return { conversation: conversation as ChannelKey };
352
+ }
353
+
354
+ /** Refuses what only the agent server serves on a host without Discord, naming the key. */
355
+ function refuseAgentServerKeys(config: RoundtableConfig): void {
356
+ if (config.agents && config.agents.length > 0)
357
+ throw new ConfigError(
358
+ "config agents: the agents live in Discord, which is not configured. Configure discord, or remove agents.",
359
+ );
360
+ if (config.skills)
361
+ throw new ConfigError(
362
+ "config skills: the skills are the agents', and the agents live in Discord, which is not configured. Configure discord, or remove skills.",
363
+ );
364
+ }
365
+
285
366
  /** The configuration checked against its schema, defaults filled; throws a ConfigError naming the key and the fix. */
286
367
  export function resolveConfig(input: unknown): ResolvedConfig {
287
- const config = schema.check(input, "") as RoundtableConfig;
368
+ const config = schema.check(liftAdapters(input), "") as RoundtableConfig;
288
369
  const name = config.name ?? "Roundtable";
289
370
  const model = modelOf(config.model, "model");
290
371
  const thinking = config.thinking ?? "medium";
291
- const rootCommand =
292
- config.discord.rootCommand ??
293
- name.toLowerCase().replace(/[^a-z0-9-]+/g, "-");
372
+ const { discord, http } = config;
373
+ if (discord && !http?.publicUrl)
374
+ throw new ConfigError(
375
+ "config http.publicUrl: required with discord, expected the public address that reaches the bot; Discord fetches the agents' avatars from it. Add it to roundtable.config.ts.",
376
+ );
377
+ if (!discord) refuseAgentServerKeys(config);
378
+ const slug =
379
+ discord?.rootCommand ?? name.toLowerCase().replace(/[^a-z0-9-]+/g, "-");
380
+ const ops = opsOf(config.ops, discord !== undefined);
294
381
  return {
295
382
  name,
383
+ slug,
296
384
  owner: {
297
385
  id: config.owner.id,
298
386
  name: config.owner.name,
299
387
  pronouns: PRONOUNS[config.owner.pronouns ?? "they"],
300
388
  },
301
- discord: {
302
- token: config.discord.token,
303
- guild: config.discord.guild,
304
- entryChannel: config.discord.entryChannel,
305
- rootCommand,
306
- admin: config.discord.admin ?? true,
307
- ...(config.discord.refusalHint === undefined
308
- ? {}
309
- : { refusalHint: config.discord.refusalHint }),
310
- },
389
+ ...(discord
390
+ ? {
391
+ discord: {
392
+ token: discord.token,
393
+ guild: discord.guild,
394
+ entryChannel: discord.entryChannel,
395
+ rootCommand: slug,
396
+ admin: discord.admin ?? true,
397
+ ...(discord.refusalHint === undefined
398
+ ? {}
399
+ : { refusalHint: discord.refusalHint }),
400
+ },
401
+ }
402
+ : {}),
311
403
  databaseUrl: config.database.url,
312
404
  dataDir: config.dataDir,
313
405
  agentDir: config.agentDir ?? `${config.dataDir}/pi`,
@@ -334,21 +426,26 @@ export function resolveConfig(input: unknown): ResolvedConfig {
334
426
  toolTiers: config.toolTiers ?? {},
335
427
  prompts: config.prompts ?? {},
336
428
  agents: config.agents ?? [],
337
- http: {
338
- publicUrl: config.http.publicUrl,
339
- port: config.http.port ?? 3000,
340
- ...(config.http.hostname ? { hostname: config.http.hostname } : {}),
341
- ...(config.http.socketPath ? { socketPath: config.http.socketPath } : {}),
342
- ...(config.http.socketMode === undefined
343
- ? {}
344
- : { socketMode: config.http.socketMode }),
345
- },
429
+ ...(http
430
+ ? {
431
+ http: {
432
+ ...(http.publicUrl ? { publicUrl: http.publicUrl } : {}),
433
+ port: http.port ?? 3000,
434
+ ...(http.hostname ? { hostname: http.hostname } : {}),
435
+ ...(http.socketPath ? { socketPath: http.socketPath } : {}),
436
+ ...(http.socketMode === undefined
437
+ ? {}
438
+ : { socketMode: http.socketMode }),
439
+ },
440
+ }
441
+ : {}),
346
442
  ...(config.avatar ? { avatar: config.avatar } : {}),
347
443
  workDir: config.workDir ?? `${config.dataDir}/work`,
348
- scratchDir: config.scratchDir ?? join(tmpdir(), `${rootCommand}-scratch`),
349
- skills: config.skills ?? {},
444
+ scratchDir: config.scratchDir ?? join(tmpdir(), `${slug}-scratch`),
445
+ // Without Discord there are no agents to carry skills, so the addon is off.
446
+ skills: config.skills ?? (discord ? {} : false),
350
447
  memory: config.memory ?? true,
351
- ...(config.ops ? { ops: config.ops } : {}),
448
+ ...(ops ? { ops } : {}),
352
449
  interimText: config.interimText ?? "on",
353
450
  interimPrimaryChars: config.interimPrimaryChars ?? PRIMARY_CHARS,
354
451
  plugins: config.plugins ?? [],
@@ -178,6 +178,10 @@ export interface ConversationPort {
178
178
  stop(channel: ChannelKey): boolean;
179
179
  /** Whether reports of work started in the channel stay in it, by its owner's policy. */
180
180
  postsInPlace(channel: ChannelKey): boolean;
181
+ /** Whether a claim owns the channel, so its messages and background turns reach a conversation. */
182
+ owns(channel: ChannelKey): boolean;
183
+ /** Whether the claim that owns the channel runs background turns there; false when none owns it. */
184
+ takesBackground(channel: ChannelKey): boolean;
181
185
  }
182
186
 
183
187
  /** The host's channel queue, as plugins use it. */
@@ -68,8 +68,9 @@ export interface Providers {
68
68
  images: ImageDrawer;
69
69
  /**
70
70
  * Builds the runtime that runs the agent server's conversations and every turn run through
71
- * `context.turns`, in place of the Pi runtime. The default refuses: the agent server builds the
72
- * Pi runtime itself when no plugin fills this slot, so check `filled` before calling it.
71
+ * `context.turns`, in place of the Pi runtime. The default refuses: the host's runtime plugin
72
+ * builds the Pi runtime itself when no plugin fills this slot. Read the running runtime from
73
+ * `services.get(RUNTIME)` rather than calling this.
73
74
  */
74
75
  runtime: RuntimeFactory;
75
76
  }
@@ -109,7 +109,7 @@ export interface HeldActionStore {
109
109
  ): Promise<void>;
110
110
  }
111
111
 
112
- /** What the agent server hands a runtime provider when it builds the runtime. */
112
+ /** What the host's runtime plugin hands a runtime provider when it builds the runtime. */
113
113
  export interface RuntimeDeps {
114
114
  logger: Logger;
115
115
  /** The host's locale and time zone. */
@@ -131,13 +131,17 @@ export interface RuntimeDeps {
131
131
  conversation: ChannelKey,
132
132
  speaker?: Speaker,
133
133
  ): OwnerPrompts | undefined;
134
- /** The agent server's per-agent settings, for agent turns. */
135
- agents: AgentSessions;
134
+ /**
135
+ * The agent server's per-agent settings, for agent turns. The runtime is built before the agent
136
+ * server, so read this when a turn runs, not in the factory; it stays undefined on a host without
137
+ * the agent server, where no agent turn runs.
138
+ */
139
+ readonly agents?: AgentSessions;
136
140
  /** Where held actions persist across restarts. */
137
141
  confirmations: HeldActionStore;
138
142
  /** The host's judge, resolved from the `judge` slot. */
139
143
  judge: Judge;
140
144
  }
141
145
 
142
- /** Builds the runtime, once, when the agent server sets up. */
146
+ /** Builds the runtime, once, when the host's runtime plugin sets up. */
143
147
  export type RuntimeFactory = (deps: RuntimeDeps) => AgentRuntime;
@@ -1,6 +1,7 @@
1
1
  import type { OutboundReply } from "../domain/conversation.ts";
2
2
  import type { InterimPosts } from "../domain/interim.ts";
3
3
  import type { OwnerPrompts } from "../domain/owner-prompts.ts";
4
+ import type { TurnProgress } from "../domain/progress.ts";
4
5
  import { PluginError } from "../errors.ts";
5
6
  import type { ChannelKey } from "../sessions.ts";
6
7
  import type { Speaker } from "../speakers.ts";
@@ -74,6 +75,12 @@ export interface ChatSurface {
74
75
  * may edit in place. Absent, or undefined, = only the final reply is posted.
75
76
  */
76
77
  interim?(channel: ChannelKey): InterimPosts | undefined;
78
+ /**
79
+ * Shows what a running turn writes and which tools it runs, as it goes, such as a live preview
80
+ * in a web chat. Called between the turn's start and its reply; a rejection is logged and the
81
+ * turn goes on. Absent = the surface shows only the final reply.
82
+ */
83
+ progress?(channel: ChannelKey, event: TurnProgress): Promise<void> | void;
77
84
  }
78
85
 
79
86
  /**
@@ -95,4 +102,10 @@ export interface SurfacePort {
95
102
  prompts(channel: ChannelKey, speaker?: Speaker): OwnerPrompts | undefined;
96
103
  /** The channel's interim posts; undefined when its surface has none. */
97
104
  interim(channel: ChannelKey): InterimPosts | undefined;
105
+ /**
106
+ * Shows a running turn's progress; a no-op when no surface serves the channel or its surface
107
+ * shows none. The host's port always has it; it is optional so a port written before 0.8 still
108
+ * type-checks, and a turn run over one without it shows no progress.
109
+ */
110
+ progress?(channel: ChannelKey, event: TurnProgress): Promise<void>;
98
111
  }
@@ -0,0 +1,42 @@
1
+ import type { ChannelKey } from "../sessions.ts";
2
+
3
+ /** Who may take part: `private` belongs to one principal, `shared` to whoever its claim admits. */
4
+ export type ConversationVisibility = "private" | "shared";
5
+
6
+ /** What the first turn of a conversation records about it. */
7
+ export interface ConversationRegistration {
8
+ key: ChannelKey;
9
+ /** The conversation's kind, such as "study"; the persona its session runs. */
10
+ kind: string;
11
+ visibility: ConversationVisibility;
12
+ /** Whose conversation it is: the speaker's id, for a private one. */
13
+ principalId?: string;
14
+ /** A name to list it by. */
15
+ title?: string;
16
+ }
17
+
18
+ /** A conversation the host knows: who it belongs to and when it was last active. */
19
+ export interface ConversationRecord extends ConversationRegistration {
20
+ /** The prefix of its key, such as "web". */
21
+ surface: string;
22
+ createdAt: Date;
23
+ lastActiveAt: Date;
24
+ }
25
+
26
+ /**
27
+ * The conversations run through `context.turns`, recorded at their first turn. Its kind,
28
+ * visibility, and principal are fixed then; each later turn only marks it active. Who may speak
29
+ * in it stays the claim's decision: the registry records, it does not refuse.
30
+ */
31
+ export interface ConversationRegistry {
32
+ /** Records the conversation at its first turn, or marks a known one active; returns what is stored. */
33
+ register(entry: ConversationRegistration): Promise<ConversationRecord>;
34
+ get(key: ChannelKey): Promise<ConversationRecord | undefined>;
35
+ /** One principal's conversations, or every one; the most recently active first. */
36
+ list(filter?: { principal?: string }): Promise<ConversationRecord[]>;
37
+ /** Names a conversation, or clears its name; undefined when the key is unknown. */
38
+ setTitle(
39
+ key: ChannelKey,
40
+ title: string | undefined,
41
+ ): Promise<ConversationRecord | undefined>;
42
+ }
@@ -0,0 +1,113 @@
1
+ import type { SQL } from "bun";
2
+ import { parseChannelKey } from "../contract/surface.ts";
3
+ import type { Migration } from "../db/migrations.ts";
4
+ import type { ChannelKey } from "../sessions.ts";
5
+ import type {
6
+ ConversationRecord,
7
+ ConversationRegistration,
8
+ ConversationRegistry,
9
+ ConversationVisibility,
10
+ } from "./conversation-registry.ts";
11
+
12
+ interface Row {
13
+ key: string;
14
+ surface: string;
15
+ kind: string;
16
+ principal_id: string | null;
17
+ visibility: ConversationVisibility;
18
+ title: string | null;
19
+ created_at: Date;
20
+ last_active_at: Date;
21
+ }
22
+
23
+ function recordOf(row: Row): ConversationRecord {
24
+ return {
25
+ key: row.key as ChannelKey,
26
+ surface: row.surface,
27
+ kind: row.kind,
28
+ visibility: row.visibility,
29
+ ...(row.principal_id === null ? {} : { principalId: row.principal_id }),
30
+ ...(row.title === null ? {} : { title: row.title }),
31
+ createdAt: row.created_at,
32
+ lastActiveAt: row.last_active_at,
33
+ };
34
+ }
35
+
36
+ /** The conversation registry in the `conversations` table. */
37
+ export class PgConversationRegistry implements ConversationRegistry {
38
+ readonly #sql: SQL;
39
+
40
+ private constructor(sql: SQL) {
41
+ this.#sql = sql;
42
+ }
43
+
44
+ /** The registry's table; the host runs this before the registry attaches. */
45
+ static readonly migration: Migration = {
46
+ name: "conversations",
47
+ up: async (sql) => {
48
+ await sql`
49
+ CREATE TABLE IF NOT EXISTS conversations (
50
+ key text PRIMARY KEY,
51
+ surface text NOT NULL,
52
+ kind text NOT NULL,
53
+ principal_id text,
54
+ visibility text NOT NULL CHECK (visibility IN ('private', 'shared')),
55
+ title text,
56
+ created_at timestamptz NOT NULL DEFAULT now(),
57
+ last_active_at timestamptz NOT NULL DEFAULT now()
58
+ )`;
59
+ await sql`
60
+ CREATE INDEX IF NOT EXISTS conversations_principal
61
+ ON conversations (principal_id, last_active_at DESC)`;
62
+ },
63
+ };
64
+
65
+ /** The registry over the host's migrated pool. */
66
+ static async attach(sql: SQL): Promise<PgConversationRegistry> {
67
+ return new PgConversationRegistry(sql);
68
+ }
69
+
70
+ async register(entry: ConversationRegistration): Promise<ConversationRecord> {
71
+ const { surface } = parseChannelKey(entry.key);
72
+ const rows: Row[] = await this.#sql`
73
+ INSERT INTO conversations (key, surface, kind, principal_id, visibility, title)
74
+ VALUES (${entry.key}, ${surface}, ${entry.kind}, ${entry.principalId ?? null},
75
+ ${entry.visibility}, ${entry.title ?? null})
76
+ ON CONFLICT (key) DO UPDATE SET last_active_at = now()
77
+ RETURNING *`;
78
+ const [row] = rows;
79
+ if (!row) throw new Error(`conversation ${entry.key} was not recorded`);
80
+ return recordOf(row);
81
+ }
82
+
83
+ async get(key: ChannelKey): Promise<ConversationRecord | undefined> {
84
+ const rows: Row[] = await this.#sql`
85
+ SELECT * FROM conversations WHERE key = ${key}`;
86
+ const [row] = rows;
87
+ return row ? recordOf(row) : undefined;
88
+ }
89
+
90
+ async list(
91
+ filter: { principal?: string } = {},
92
+ ): Promise<ConversationRecord[]> {
93
+ const rows: Row[] =
94
+ filter.principal === undefined
95
+ ? await this.#sql`
96
+ SELECT * FROM conversations ORDER BY last_active_at DESC, key`
97
+ : await this.#sql`
98
+ SELECT * FROM conversations WHERE principal_id = ${filter.principal}
99
+ ORDER BY last_active_at DESC, key`;
100
+ return rows.map(recordOf);
101
+ }
102
+
103
+ async setTitle(
104
+ key: ChannelKey,
105
+ title: string | undefined,
106
+ ): Promise<ConversationRecord | undefined> {
107
+ const rows: Row[] = await this.#sql`
108
+ UPDATE conversations SET title = ${title ?? null} WHERE key = ${key}
109
+ RETURNING *`;
110
+ const [row] = rows;
111
+ return row ? recordOf(row) : undefined;
112
+ }
113
+ }
@@ -0,0 +1,29 @@
1
+ import type { SQL } from "bun";
2
+ import type { RoundtablePlugin } from "../plugin.ts";
3
+ import { CONVERSATIONS } from "../services.ts";
4
+ import type { ConversationRegistry } from "./conversation-registry.ts";
5
+ import { PgConversationRegistry } from "./conversation-store.ts";
6
+
7
+ /** The name of the conversation registry's plugin, as the migration ledger names it. */
8
+ export const CONVERSATIONS_PLUGIN = "conversations";
9
+
10
+ /**
11
+ * The conversation registry, provided as `CONVERSATIONS`: every conversation run through
12
+ * `context.turns`, recorded at its first turn. No session file moves; a conversation from before
13
+ * the registry is recorded at its next turn.
14
+ */
15
+ export function conversationsPlugin(
16
+ /** Opens the registry; a test hands a stand-in, so the plugin sets up without a database. */
17
+ open: (sql: SQL) => Promise<ConversationRegistry> = (sql) =>
18
+ PgConversationRegistry.attach(sql),
19
+ ): RoundtablePlugin {
20
+ return {
21
+ name: CONVERSATIONS_PLUGIN,
22
+ migrations: [PgConversationRegistry.migration],
23
+ provides: [CONVERSATIONS],
24
+ setup: async ({ database, services }) => {
25
+ services.provide(CONVERSATIONS, await open(database()));
26
+ return {};
27
+ },
28
+ };
29
+ }