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.
- package/CHANGELOG.md +38 -0
- package/README.md +44 -11
- package/README.zh-TW.md +44 -9
- package/docs/migrating-0.8.md +132 -0
- package/docs/plugins.md +267 -23
- package/examples/discord-adapter.test.ts +42 -0
- package/examples/discord-adapter.ts +26 -0
- package/examples/echo-runtime.ts +1 -1
- package/examples/headless.test.ts +72 -0
- package/examples/headless.ts +31 -0
- package/examples/http.test.ts +31 -1
- package/examples/http.ts +39 -0
- package/package.json +3 -2
- package/src/cli/cli.ts +15 -4
- package/src/cli/doctor.ts +21 -5
- package/src/cli/init.ts +9 -1
- package/src/cli/project.ts +28 -2
- package/src/cli/templates.ts +46 -14
- package/src/core/agents/agent-team-fixture.ts +6 -0
- package/src/core/agents/team-turns.ts +6 -1
- package/src/core/builtin/agent-server.ts +30 -95
- package/src/core/builtin/modules.ts +133 -34
- package/src/core/config/adapters.ts +57 -0
- package/src/core/config/config.ts +174 -77
- package/src/core/contract/channels.ts +4 -0
- package/src/core/contract/providers.ts +3 -2
- package/src/core/contract/runtime.ts +8 -4
- package/src/core/contract/surface.ts +13 -0
- package/src/core/conversations/conversation-registry.ts +42 -0
- package/src/core/conversations/conversation-store.ts +113 -0
- package/src/core/conversations/conversations-plugin.ts +29 -0
- package/src/core/define-roundtable.ts +124 -54
- package/src/core/discord/discord-adapter.ts +9 -0
- package/src/core/discord/owner-cards.ts +19 -8
- package/src/core/domain/conversation.ts +5 -0
- package/src/core/domain/owner-prompts.ts +1 -1
- package/src/core/domain/ports.ts +6 -0
- package/src/core/domain/progress.ts +10 -0
- package/src/core/events.ts +1 -0
- package/src/core/host.ts +6 -3
- package/src/core/http/listeners.ts +174 -21
- package/src/core/http/websocket.ts +316 -0
- package/src/core/i18n/cards.ts +5 -7
- package/src/core/ops/error-reporter.ts +24 -12
- package/src/core/plugin.ts +14 -0
- package/src/core/registry/providers.ts +1 -1
- package/src/core/routing/channel-router.ts +8 -0
- package/src/core/routing/conversation-turns.ts +55 -1
- package/src/core/routing/surface-port.ts +3 -0
- package/src/core/runtime/extensions/confirmation-gate.ts +10 -2
- package/src/core/runtime/extensions/self-compact-guard.ts +11 -0
- package/src/core/runtime/pending-confirmation-store.ts +42 -4
- package/src/core/runtime/pi-agent-runtime.ts +13 -6
- package/src/core/runtime/prompt-slot.ts +2 -2
- package/src/core/runtime/runtime-plugin.ts +160 -0
- package/src/core/runtime/turn-progress.ts +116 -0
- package/src/core/services.ts +15 -1
- package/src/core/shared/unix-server.ts +35 -5
- package/src/core/testing/modules.ts +47 -23
- package/src/core/testing/surface-contract.ts +383 -0
- package/src/core/testing/test-host.ts +27 -9
- package/src/discord/index.ts +2 -0
- package/src/index.ts +18 -0
- package/src/testing.ts +41 -5
- package/templates/adapters/web/.env.example +24 -0
- package/templates/adapters/web/README.md +15 -0
- package/templates/adapters/web/package.json.tmpl +26 -0
- package/templates/adapters/web/persona/assistant.md +2 -0
- package/templates/adapters/web/roundtable.config.ts +55 -0
- package/templates/package.json.tmpl +1 -0
- package/templates/plugins/self-compact.ts +11 -0
- 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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
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
|
|
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>/<
|
|
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
|
-
/**
|
|
127
|
-
|
|
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:
|
|
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:
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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(), `${
|
|
349
|
-
|
|
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
|
-
...(
|
|
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
|
|
72
|
-
* Pi runtime itself when no plugin fills this slot
|
|
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
|
|
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
|
-
/**
|
|
135
|
-
|
|
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
|
|
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
|
+
}
|