pi-roundtable 0.16.0 → 0.17.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 +18 -0
- package/README.md +5 -0
- package/docs/plugins.md +68 -0
- package/package.json +1 -1
- package/src/cli/checks/configuration.ts +21 -1
- package/src/cli/project.ts +59 -8
- package/src/cli/runtime.ts +3 -0
- package/src/core/agents/agent-store.ts +4 -1
- package/src/core/builtin/discord.ts +5 -0
- package/src/core/config/adapters.ts +12 -0
- package/src/core/contract/runtime.ts +7 -0
- package/src/core/contract/surface.ts +7 -0
- package/src/core/db/check-database.ts +39 -0
- package/src/core/define-roundtable.ts +3 -0
- package/src/core/discord/discord-surface.ts +5 -0
- package/src/core/discord/formatting.ts +20 -0
- package/src/core/host-registration.ts +292 -0
- package/src/core/host.ts +45 -227
- package/src/core/identity/config-owner.ts +34 -0
- package/src/core/identity/identity-plugin.ts +7 -2
- package/src/core/identity/identity-service.ts +8 -1
- package/src/core/identity/plugin-identities.ts +19 -4
- package/src/core/modules/skills/skill-store.ts +3 -1
- package/src/core/runtime/extensions/surface-formatting.ts +22 -0
- package/src/core/runtime/pi-agent-runtime.ts +6 -8
- package/src/core/runtime/running-turn.ts +19 -0
- package/src/core/runtime/runtime-plugin.ts +4 -0
- package/src/core/runtime/runtime-types.ts +12 -1
- package/src/core/runtime/session-factory.ts +7 -0
- package/src/core/testing/database.ts +20 -2
- package/src/core/testing/postgres.ts +160 -0
- package/src/core/testing/run-tool.ts +110 -0
- package/src/core/testing/test-host.ts +78 -30
- package/src/index.ts +1 -0
- package/src/testing.ts +25 -56
- package/templates/official/codex-images/plugin.test.ts.tmpl +29 -1
- package/templates/official/codex-images/plugin.ts +19 -2
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
|
+
import { attachmentPort } from "./attachments/attachment-port.ts";
|
|
3
|
+
import { retentionServices } from "./attachments/attachment-retention.ts";
|
|
4
|
+
import type { AttachmentPort } from "./contract/attachments.ts";
|
|
5
|
+
import type { ConversationPort } from "./contract/channels.ts";
|
|
6
|
+
import type { ResolvedProviders } from "./contract/providers.ts";
|
|
7
|
+
import type { SurfacePort } from "./contract/surface.ts";
|
|
8
|
+
import { NotLinkedError } from "./errors.ts";
|
|
9
|
+
import { EventBus } from "./events.ts";
|
|
10
|
+
import type { RoundtableOptions } from "./host.ts";
|
|
11
|
+
import { HttpListeners } from "./http/listeners.ts";
|
|
12
|
+
import { setLocale } from "./i18n/index.ts";
|
|
13
|
+
import { contactsOf } from "./identity/identity-view.ts";
|
|
14
|
+
import type {
|
|
15
|
+
HostEnv,
|
|
16
|
+
LinkedSessions,
|
|
17
|
+
RoundtablePlugin,
|
|
18
|
+
Service,
|
|
19
|
+
} from "./plugin.ts";
|
|
20
|
+
import { refuseRemovedFields } from "./plugin.ts";
|
|
21
|
+
import {
|
|
22
|
+
collectContributions,
|
|
23
|
+
emptyRegistry,
|
|
24
|
+
linkSessions,
|
|
25
|
+
type Registry,
|
|
26
|
+
} from "./registry/contributions.ts";
|
|
27
|
+
import { resolveProviders } from "./registry/providers.ts";
|
|
28
|
+
import { replaceServices, ServiceRegistry } from "./registry/services.ts";
|
|
29
|
+
import { ChannelQueue } from "./routing/channel-queue.ts";
|
|
30
|
+
import { ChannelRouter } from "./routing/channel-router.ts";
|
|
31
|
+
import {
|
|
32
|
+
type ConversationTurns,
|
|
33
|
+
conversationTurns,
|
|
34
|
+
} from "./routing/conversation-turns.ts";
|
|
35
|
+
import { surfacePort } from "./routing/surface-port.ts";
|
|
36
|
+
import { CONVERSATIONS, IDENTITY, RUNTIME } from "./services.ts";
|
|
37
|
+
import { setTimeZone } from "./time.ts";
|
|
38
|
+
import { type ToolTierTable, toolTiers } from "./tool-tiers.ts";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* What `Roundtable.check()` found: the plugins that would run, the services and HTTP routes they
|
|
42
|
+
* add, as the host would start them.
|
|
43
|
+
*/
|
|
44
|
+
export interface RegistrationReport {
|
|
45
|
+
/** The plugins that would run, in set-up order (after replacements). */
|
|
46
|
+
plugins: readonly string[];
|
|
47
|
+
/** The services they contribute, in start order. */
|
|
48
|
+
services: readonly string[];
|
|
49
|
+
/** Their HTTP routes, as `<listener> <path>`. */
|
|
50
|
+
routes: readonly string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** What the plugins' registration leaves for the host to start: the listeners it validated and the retention services. */
|
|
54
|
+
export interface Linked {
|
|
55
|
+
http: HttpListeners;
|
|
56
|
+
retention: Service[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The host's registration of its plugins: set up every plugin, link what they add, and refuse any
|
|
61
|
+
* clash, before a service starts or a listener opens. `run()` and `check()` share it; they differ
|
|
62
|
+
* only in the database the plugins' setup sees.
|
|
63
|
+
*/
|
|
64
|
+
export class Linkage {
|
|
65
|
+
readonly queue = new ChannelQueue();
|
|
66
|
+
readonly events: EventBus;
|
|
67
|
+
readonly tiers: ToolTierTable;
|
|
68
|
+
registry: Registry = emptyRegistry();
|
|
69
|
+
sessions: LinkedSessions | undefined;
|
|
70
|
+
router: ChannelRouter | undefined;
|
|
71
|
+
services: ServiceRegistry | undefined;
|
|
72
|
+
/** The plugins that run: the registered ones, less those a replacement dropped, in set-up order. */
|
|
73
|
+
active: readonly RoundtablePlugin[] = [];
|
|
74
|
+
readonly #options: RoundtableOptions;
|
|
75
|
+
readonly #plugins: readonly RoundtablePlugin[];
|
|
76
|
+
readonly #warned = new Set<string>();
|
|
77
|
+
#prepared:
|
|
78
|
+
| {
|
|
79
|
+
env: HostEnv;
|
|
80
|
+
providers: ResolvedProviders;
|
|
81
|
+
attachments: AttachmentPort;
|
|
82
|
+
retention: Service[];
|
|
83
|
+
}
|
|
84
|
+
| undefined;
|
|
85
|
+
|
|
86
|
+
constructor(
|
|
87
|
+
options: RoundtableOptions,
|
|
88
|
+
plugins: readonly RoundtablePlugin[],
|
|
89
|
+
) {
|
|
90
|
+
this.#options = options;
|
|
91
|
+
this.#plugins = plugins;
|
|
92
|
+
this.tiers = options.toolTiers ?? toolTiers();
|
|
93
|
+
this.events = new EventBus(options.logger);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Applies the host's environment to the process and returns what plugins read of it. */
|
|
97
|
+
#applyEnvironment(): HostEnv {
|
|
98
|
+
const {
|
|
99
|
+
locale = "en",
|
|
100
|
+
timeZone = "UTC",
|
|
101
|
+
assistant = "Roundtable",
|
|
102
|
+
rootCommand = "roundtable",
|
|
103
|
+
agentDir,
|
|
104
|
+
scratchDir,
|
|
105
|
+
} = this.#options.environment ?? {};
|
|
106
|
+
setLocale(locale, { assistant, root: rootCommand });
|
|
107
|
+
setTimeZone(timeZone);
|
|
108
|
+
// Pi packages such as pi-web-access read their config from the Pi agent directory.
|
|
109
|
+
if (agentDir !== undefined) process.env.PI_CODING_AGENT_DIR = agentDir;
|
|
110
|
+
return {
|
|
111
|
+
locale,
|
|
112
|
+
timeZone,
|
|
113
|
+
now: () => new Date(),
|
|
114
|
+
...(scratchDir !== undefined ? { scratchDir } : {}),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Applies the environment and settles which plugins run and what they require, before any migration or setup. */
|
|
119
|
+
prepare(): void {
|
|
120
|
+
const { logger } = this.#options;
|
|
121
|
+
for (const plugin of this.#plugins) refuseRemovedFields(plugin);
|
|
122
|
+
const env = this.#applyEnvironment();
|
|
123
|
+
// A plugin that replaces a service takes the place of the one that provided it.
|
|
124
|
+
this.active = replaceServices(this.#plugins);
|
|
125
|
+
this.services = new ServiceRegistry(this.active);
|
|
126
|
+
this.services.checkRequires();
|
|
127
|
+
const providers = resolveProviders(this.active, this.#options.judgeModel);
|
|
128
|
+
const attachments = attachmentPort({
|
|
129
|
+
dataDir: this.#options.dataDir,
|
|
130
|
+
registry: () => this.services?.find(CONVERSATIONS),
|
|
131
|
+
logger,
|
|
132
|
+
});
|
|
133
|
+
const retention = retentionServices(
|
|
134
|
+
this.#options.attachments?.retention,
|
|
135
|
+
this.#options.dataDir,
|
|
136
|
+
attachments,
|
|
137
|
+
logger,
|
|
138
|
+
);
|
|
139
|
+
this.#prepared = { env, providers, attachments, retention };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Sets up every plugin over `database`, links the session parts, builds the router and validates
|
|
144
|
+
* the routes against the listeners, without opening one.
|
|
145
|
+
*/
|
|
146
|
+
async link(database: () => SQL): Promise<Linked> {
|
|
147
|
+
const { logger, listeners = [] } = this.#options;
|
|
148
|
+
const prepared = this.#prepared;
|
|
149
|
+
const { services } = this;
|
|
150
|
+
if (!prepared || !services)
|
|
151
|
+
throw new NotLinkedError("the plugins are prepared before they link.");
|
|
152
|
+
this.registry = await collectContributions(
|
|
153
|
+
this.active,
|
|
154
|
+
{
|
|
155
|
+
logger,
|
|
156
|
+
env: prepared.env,
|
|
157
|
+
sessions: () => {
|
|
158
|
+
if (!this.sessions)
|
|
159
|
+
throw new NotLinkedError(
|
|
160
|
+
"session parts are linked once every plugin is set up. Call sessions() from a service's start or from a handler, not during setup.",
|
|
161
|
+
);
|
|
162
|
+
return this.sessions;
|
|
163
|
+
},
|
|
164
|
+
queue: this.queue,
|
|
165
|
+
toolTiers: this.tiers,
|
|
166
|
+
events: this.events.sink,
|
|
167
|
+
conversations: this.#conversations(),
|
|
168
|
+
attachments: prepared.attachments,
|
|
169
|
+
surfaces: this.#surfaces(),
|
|
170
|
+
turns: this.#turns(),
|
|
171
|
+
database,
|
|
172
|
+
providers: prepared.providers,
|
|
173
|
+
dashboard: () => {
|
|
174
|
+
if (!this.sessions)
|
|
175
|
+
throw new NotLinkedError(
|
|
176
|
+
"dashboard lines are linked once every plugin is set up. Call dashboard() from a service's start or from a handler, not during setup.",
|
|
177
|
+
);
|
|
178
|
+
return this.registry.dashboard;
|
|
179
|
+
},
|
|
180
|
+
apiKey: async (provider) => this.#options.apiKey?.(provider),
|
|
181
|
+
},
|
|
182
|
+
this.tiers,
|
|
183
|
+
services,
|
|
184
|
+
);
|
|
185
|
+
const { routes, channels } = this.registry;
|
|
186
|
+
this.sessions = linkSessions(this.registry);
|
|
187
|
+
this.events.link(this.registry.handlers);
|
|
188
|
+
const identity = services.find(IDENTITY);
|
|
189
|
+
this.router = new ChannelRouter({
|
|
190
|
+
claims: channels,
|
|
191
|
+
...(identity
|
|
192
|
+
? { contacts: contactsOf(identity), principals: identity }
|
|
193
|
+
: {}),
|
|
194
|
+
targets: (name) =>
|
|
195
|
+
this.registry.backgroundTargets.find((t) => t.name === name),
|
|
196
|
+
queue: this.queue,
|
|
197
|
+
surfaces: this.#surfaces(),
|
|
198
|
+
logger,
|
|
199
|
+
...(this.#options.conversations?.forwardJoinMs === undefined
|
|
200
|
+
? {}
|
|
201
|
+
: { forwardJoinMs: this.#options.conversations.forwardJoinMs }),
|
|
202
|
+
});
|
|
203
|
+
return {
|
|
204
|
+
http: new HttpListeners(listeners, routes, logger),
|
|
205
|
+
retention: prepared.retention,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** What the plugins added, as `check()` reports it. */
|
|
210
|
+
report(): RegistrationReport {
|
|
211
|
+
return {
|
|
212
|
+
plugins: this.active.map((plugin) => plugin.name),
|
|
213
|
+
services: this.registry.services.map((service) => service.name),
|
|
214
|
+
routes: this.registry.routes.map(
|
|
215
|
+
(route) =>
|
|
216
|
+
`${route.listener} ${"exact" in route.path ? route.path.exact : route.path.prefix}`,
|
|
217
|
+
),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The claimed channels' conversations; every call before linking throws NotLinkedError. */
|
|
222
|
+
#conversations(): ConversationPort {
|
|
223
|
+
const router = () => {
|
|
224
|
+
if (!this.router)
|
|
225
|
+
throw new NotLinkedError(
|
|
226
|
+
"conversations are linked once every plugin is set up. Use them from a service's start or from a handler, not during setup.",
|
|
227
|
+
);
|
|
228
|
+
return this.router;
|
|
229
|
+
};
|
|
230
|
+
return {
|
|
231
|
+
handle: (message) => router().handle(message),
|
|
232
|
+
background: (turn) => router().background(turn),
|
|
233
|
+
runsAs: (turn) => router().runsAs(turn),
|
|
234
|
+
target: (name) => router().target(name),
|
|
235
|
+
startFresh: (channel) => router().startFresh(channel),
|
|
236
|
+
deleteConversation: (channel) => router().deleteConversation(channel),
|
|
237
|
+
stop: (channel) => router().stop(channel),
|
|
238
|
+
postsInPlace: (channel) => router().postsInPlace(channel),
|
|
239
|
+
owns: (channel) => router().owns(channel),
|
|
240
|
+
takesBackground: (channel) => router().takesBackground(channel),
|
|
241
|
+
takesSystemReports: (channel) => router().takesSystemReports(channel),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The contributed surfaces by channel prefix; every call before linking throws NotLinkedError. */
|
|
246
|
+
#surfaces(): SurfacePort {
|
|
247
|
+
return surfacePort(
|
|
248
|
+
() => {
|
|
249
|
+
if (!this.sessions)
|
|
250
|
+
throw new NotLinkedError(
|
|
251
|
+
"chat surfaces are linked once every plugin is set up. Use surfaces from a service's start or from a handler, not during setup.",
|
|
252
|
+
);
|
|
253
|
+
return this.registry.surfaces;
|
|
254
|
+
},
|
|
255
|
+
{ deprecated: (message) => this.#deprecated(message) },
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Logs a deprecation once per host. */
|
|
260
|
+
#deprecated(message: string): void {
|
|
261
|
+
if (this.#warned.has(message)) return;
|
|
262
|
+
this.#warned.add(message);
|
|
263
|
+
this.#options.logger.warn(message);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Turns over the runtime plugin's runtime and the surfaces; every call before linking is refused with NotLinkedError. */
|
|
267
|
+
#turns(): ConversationTurns {
|
|
268
|
+
return conversationTurns({
|
|
269
|
+
linked: () => {
|
|
270
|
+
if (!this.sessions)
|
|
271
|
+
throw new NotLinkedError(
|
|
272
|
+
"conversation turns are linked once every plugin is set up. Use turns from a service's start or from a handler, not during setup.",
|
|
273
|
+
);
|
|
274
|
+
},
|
|
275
|
+
runtime: () => {
|
|
276
|
+
if (!this.services)
|
|
277
|
+
throw new NotLinkedError("the host has not started yet.");
|
|
278
|
+
return this.services.get(RUNTIME);
|
|
279
|
+
},
|
|
280
|
+
registry: () => this.services?.find(CONVERSATIONS),
|
|
281
|
+
surfaces: this.#surfaces(),
|
|
282
|
+
events: this.events.sink,
|
|
283
|
+
stopping: () => this.queue.closed,
|
|
284
|
+
selection: () => {
|
|
285
|
+
if (!this.sessions)
|
|
286
|
+
throw new NotLinkedError("session parts are not linked yet.");
|
|
287
|
+
return this.sessions.agentSelection();
|
|
288
|
+
},
|
|
289
|
+
logger: this.#options.logger,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
}
|
package/src/core/host.ts
CHANGED
|
@@ -1,47 +1,18 @@
|
|
|
1
1
|
import type { SQL } from "bun";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
type AttachmentRetention,
|
|
5
|
-
retentionServices,
|
|
6
|
-
} from "./attachments/attachment-retention.ts";
|
|
7
|
-
import type { ConversationPort } from "./contract/channels.ts";
|
|
8
|
-
import type { SurfacePort } from "./contract/surface.ts";
|
|
2
|
+
import type { AttachmentRetention } from "./attachments/attachment-retention.ts";
|
|
3
|
+
import { checkDatabase } from "./db/check-database.ts";
|
|
9
4
|
import { openPool, runMigrations } from "./db/migrations.ts";
|
|
10
5
|
import { type DrainOptions, drainWork, stopTurns } from "./drain.ts";
|
|
11
|
-
import { MigrationError,
|
|
12
|
-
import { EventBus } from "./events.ts";
|
|
6
|
+
import { MigrationError, PluginError } from "./errors.ts";
|
|
13
7
|
import { attempt, startInBackground } from "./host-background.ts";
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
8
|
+
import { Linkage, type RegistrationReport } from "./host-registration.ts";
|
|
9
|
+
import type { HttpListeners, ListenerConfig } from "./http/listeners.ts";
|
|
10
|
+
import type { Locale } from "./i18n/index.ts";
|
|
17
11
|
import type { JudgeModel } from "./judging/model-judge.ts";
|
|
18
12
|
import type { Logger } from "./log.ts";
|
|
19
|
-
import type {
|
|
20
|
-
HostEnv,
|
|
21
|
-
LinkedSessions,
|
|
22
|
-
RoundtablePlugin,
|
|
23
|
-
Service,
|
|
24
|
-
} from "./plugin.ts";
|
|
25
|
-
import { refuseRemovedFields } from "./plugin.ts";
|
|
26
|
-
import {
|
|
27
|
-
collectContributions,
|
|
28
|
-
emptyRegistry,
|
|
29
|
-
linkSessions,
|
|
30
|
-
type Registry,
|
|
31
|
-
} from "./registry/contributions.ts";
|
|
32
|
-
import { resolveProviders } from "./registry/providers.ts";
|
|
33
|
-
import { replaceServices, ServiceRegistry } from "./registry/services.ts";
|
|
34
|
-
import { ChannelQueue } from "./routing/channel-queue.ts";
|
|
35
|
-
import { ChannelRouter } from "./routing/channel-router.ts";
|
|
36
|
-
import {
|
|
37
|
-
type ConversationTurns,
|
|
38
|
-
conversationTurns,
|
|
39
|
-
} from "./routing/conversation-turns.ts";
|
|
40
|
-
import { surfacePort } from "./routing/surface-port.ts";
|
|
41
|
-
import { CONVERSATIONS, IDENTITY, RUNTIME } from "./services.ts";
|
|
13
|
+
import type { RoundtablePlugin, Service } from "./plugin.ts";
|
|
42
14
|
import type { ChannelKey } from "./sessions.ts";
|
|
43
|
-
import {
|
|
44
|
-
import { type ToolTierTable, toolTiers } from "./tool-tiers.ts";
|
|
15
|
+
import type { ToolTierTable } from "./tool-tiers.ts";
|
|
45
16
|
|
|
46
17
|
/**
|
|
47
18
|
* What differs between hosts: the words and time the process speaks in, and the Pi agent
|
|
@@ -120,18 +91,10 @@ let running: Roundtable | undefined;
|
|
|
120
91
|
export class Roundtable {
|
|
121
92
|
readonly #options: RoundtableOptions;
|
|
122
93
|
readonly #plugins: readonly RoundtablePlugin[];
|
|
123
|
-
|
|
124
|
-
#
|
|
125
|
-
#router: ChannelRouter | undefined;
|
|
94
|
+
/** What the plugins' registration built: the registry, router, queue and events the host runs on. */
|
|
95
|
+
readonly #link: Linkage;
|
|
126
96
|
#listeners: HttpListeners | undefined;
|
|
127
97
|
#pool: SQL | undefined;
|
|
128
|
-
readonly #queue = new ChannelQueue();
|
|
129
|
-
readonly #warned = new Set<string>();
|
|
130
|
-
readonly #tiers: ToolTierTable;
|
|
131
|
-
readonly #events: EventBus;
|
|
132
|
-
#services: ServiceRegistry | undefined;
|
|
133
|
-
/** The plugins that run: the registered ones, less those a replacement dropped, in set-up order. */
|
|
134
|
-
#active: readonly RoundtablePlugin[] = [];
|
|
135
98
|
/** The services whose start finished, in start order. */
|
|
136
99
|
#started: Service[] = [];
|
|
137
100
|
#booting: Promise<void> | undefined;
|
|
@@ -148,80 +111,7 @@ export class Roundtable {
|
|
|
148
111
|
);
|
|
149
112
|
this.#options = options;
|
|
150
113
|
this.#plugins = plugins;
|
|
151
|
-
this.#
|
|
152
|
-
this.#events = new EventBus(options.logger);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/** The claimed channels' conversations; every call before linking throws NotLinkedError. */
|
|
156
|
-
#conversations(): ConversationPort {
|
|
157
|
-
const router = () => {
|
|
158
|
-
if (!this.#router)
|
|
159
|
-
throw new NotLinkedError(
|
|
160
|
-
"conversations are linked once every plugin is set up. Use them from a service's start or from a handler, not during setup.",
|
|
161
|
-
);
|
|
162
|
-
return this.#router;
|
|
163
|
-
};
|
|
164
|
-
return {
|
|
165
|
-
handle: (message) => router().handle(message),
|
|
166
|
-
background: (turn) => router().background(turn),
|
|
167
|
-
runsAs: (turn) => router().runsAs(turn),
|
|
168
|
-
target: (name) => router().target(name),
|
|
169
|
-
startFresh: (channel) => router().startFresh(channel),
|
|
170
|
-
deleteConversation: (channel) => router().deleteConversation(channel),
|
|
171
|
-
stop: (channel) => router().stop(channel),
|
|
172
|
-
postsInPlace: (channel) => router().postsInPlace(channel),
|
|
173
|
-
owns: (channel) => router().owns(channel),
|
|
174
|
-
takesBackground: (channel) => router().takesBackground(channel),
|
|
175
|
-
takesSystemReports: (channel) => router().takesSystemReports(channel),
|
|
176
|
-
};
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/** The contributed surfaces by channel prefix; every call before linking throws NotLinkedError. */
|
|
180
|
-
#surfaces(): SurfacePort {
|
|
181
|
-
return surfacePort(
|
|
182
|
-
() => {
|
|
183
|
-
if (!this.#sessions)
|
|
184
|
-
throw new NotLinkedError(
|
|
185
|
-
"chat surfaces are linked once every plugin is set up. Use surfaces from a service's start or from a handler, not during setup.",
|
|
186
|
-
);
|
|
187
|
-
return this.#registry.surfaces;
|
|
188
|
-
},
|
|
189
|
-
{ deprecated: (message) => this.#deprecated(message) },
|
|
190
|
-
);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/** Logs a deprecation once per host. */
|
|
194
|
-
#deprecated(message: string): void {
|
|
195
|
-
if (this.#warned.has(message)) return;
|
|
196
|
-
this.#warned.add(message);
|
|
197
|
-
this.#options.logger.warn(message);
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/** Turns over the runtime plugin's runtime and the surfaces; every call before linking is refused with NotLinkedError. */
|
|
201
|
-
#turns(): ConversationTurns {
|
|
202
|
-
return conversationTurns({
|
|
203
|
-
linked: () => {
|
|
204
|
-
if (!this.#sessions)
|
|
205
|
-
throw new NotLinkedError(
|
|
206
|
-
"conversation turns are linked once every plugin is set up. Use turns from a service's start or from a handler, not during setup.",
|
|
207
|
-
);
|
|
208
|
-
},
|
|
209
|
-
runtime: () => {
|
|
210
|
-
if (!this.#services)
|
|
211
|
-
throw new NotLinkedError("the host has not started yet.");
|
|
212
|
-
return this.#services.get(RUNTIME);
|
|
213
|
-
},
|
|
214
|
-
registry: () => this.#services?.find(CONVERSATIONS),
|
|
215
|
-
surfaces: this.#surfaces(),
|
|
216
|
-
events: this.#events.sink,
|
|
217
|
-
stopping: () => this.#queue.closed,
|
|
218
|
-
selection: () => {
|
|
219
|
-
if (!this.#sessions)
|
|
220
|
-
throw new NotLinkedError("session parts are not linked yet.");
|
|
221
|
-
return this.#sessions.agentSelection();
|
|
222
|
-
},
|
|
223
|
-
logger: this.#options.logger,
|
|
224
|
-
});
|
|
114
|
+
this.#link = new Linkage(options, plugins);
|
|
225
115
|
}
|
|
226
116
|
|
|
227
117
|
/**
|
|
@@ -250,106 +140,16 @@ export class Roundtable {
|
|
|
250
140
|
}
|
|
251
141
|
}
|
|
252
142
|
|
|
253
|
-
/** Applies the host's environment to the process and returns what plugins read of it. */
|
|
254
|
-
#applyEnvironment(): HostEnv {
|
|
255
|
-
const {
|
|
256
|
-
locale = "en",
|
|
257
|
-
timeZone = "UTC",
|
|
258
|
-
assistant = "Roundtable",
|
|
259
|
-
rootCommand = "roundtable",
|
|
260
|
-
agentDir,
|
|
261
|
-
scratchDir,
|
|
262
|
-
} = this.#options.environment ?? {};
|
|
263
|
-
setLocale(locale, { assistant, root: rootCommand });
|
|
264
|
-
setTimeZone(timeZone);
|
|
265
|
-
// Pi packages such as pi-web-access read their config from the Pi agent directory.
|
|
266
|
-
if (agentDir !== undefined) process.env.PI_CODING_AGENT_DIR = agentDir;
|
|
267
|
-
return {
|
|
268
|
-
locale,
|
|
269
|
-
timeZone,
|
|
270
|
-
now: () => new Date(),
|
|
271
|
-
...(scratchDir !== undefined ? { scratchDir } : {}),
|
|
272
|
-
};
|
|
273
|
-
}
|
|
274
|
-
|
|
275
143
|
async #boot(): Promise<void> {
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
const env = this.#applyEnvironment();
|
|
279
|
-
// A plugin that replaces a service takes the place of the one that provided it.
|
|
280
|
-
this.#active = replaceServices(this.#plugins);
|
|
281
|
-
this.#services = new ServiceRegistry(this.#active);
|
|
282
|
-
this.#services.checkRequires();
|
|
283
|
-
const providers = resolveProviders(this.#active, this.#options.judgeModel);
|
|
284
|
-
const attachments = attachmentPort({
|
|
285
|
-
dataDir: this.#options.dataDir,
|
|
286
|
-
registry: () => this.#services?.find(CONVERSATIONS),
|
|
287
|
-
logger,
|
|
288
|
-
});
|
|
289
|
-
const retention = retentionServices(
|
|
290
|
-
this.#options.attachments?.retention,
|
|
291
|
-
this.#options.dataDir,
|
|
292
|
-
attachments,
|
|
293
|
-
logger,
|
|
294
|
-
);
|
|
144
|
+
const link = this.#link;
|
|
145
|
+
link.prepare();
|
|
295
146
|
await this.#migrate();
|
|
296
|
-
|
|
297
|
-
this.#
|
|
298
|
-
|
|
299
|
-
logger,
|
|
300
|
-
env,
|
|
301
|
-
sessions: () => {
|
|
302
|
-
if (!this.#sessions)
|
|
303
|
-
throw new NotLinkedError(
|
|
304
|
-
"session parts are linked once every plugin is set up. Call sessions() from a service's start or from a handler, not during setup.",
|
|
305
|
-
);
|
|
306
|
-
return this.#sessions;
|
|
307
|
-
},
|
|
308
|
-
queue: this.#queue,
|
|
309
|
-
toolTiers: this.#tiers,
|
|
310
|
-
events: this.#events.sink,
|
|
311
|
-
conversations: this.#conversations(),
|
|
312
|
-
attachments,
|
|
313
|
-
surfaces: this.#surfaces(),
|
|
314
|
-
turns: this.#turns(),
|
|
315
|
-
database: () => {
|
|
316
|
-
if (!this.#pool) throw new PluginError("no database is configured");
|
|
317
|
-
return this.#pool;
|
|
318
|
-
},
|
|
319
|
-
providers,
|
|
320
|
-
dashboard: () => {
|
|
321
|
-
if (!this.#sessions)
|
|
322
|
-
throw new NotLinkedError(
|
|
323
|
-
"dashboard lines are linked once every plugin is set up. Call dashboard() from a service's start or from a handler, not during setup.",
|
|
324
|
-
);
|
|
325
|
-
return this.#registry.dashboard;
|
|
326
|
-
},
|
|
327
|
-
apiKey: async (provider) => this.#options.apiKey?.(provider),
|
|
328
|
-
},
|
|
329
|
-
this.#tiers,
|
|
330
|
-
this.#services,
|
|
331
|
-
);
|
|
332
|
-
const { routes, channels } = this.#registry;
|
|
333
|
-
this.#sessions = linkSessions(this.#registry);
|
|
334
|
-
this.#events.link(this.#registry.handlers);
|
|
335
|
-
const identity = this.#services.find(IDENTITY);
|
|
336
|
-
this.#router = new ChannelRouter({
|
|
337
|
-
claims: channels,
|
|
338
|
-
...(identity
|
|
339
|
-
? { contacts: contactsOf(identity), principals: identity }
|
|
340
|
-
: {}),
|
|
341
|
-
targets: (name) =>
|
|
342
|
-
this.#registry.backgroundTargets.find((t) => t.name === name),
|
|
343
|
-
queue: this.#queue,
|
|
344
|
-
surfaces: this.#surfaces(),
|
|
345
|
-
logger,
|
|
346
|
-
...(this.#options.conversations?.forwardJoinMs === undefined
|
|
347
|
-
? {}
|
|
348
|
-
: { forwardJoinMs: this.#options.conversations.forwardJoinMs }),
|
|
147
|
+
const { http, retention } = await link.link(() => {
|
|
148
|
+
if (!this.#pool) throw new PluginError("no database is configured");
|
|
149
|
+
return this.#pool;
|
|
349
150
|
});
|
|
350
|
-
const
|
|
351
|
-
for (const
|
|
352
|
-
for (const service of [...this.#registry.services, ...retention]) {
|
|
151
|
+
for (const plugin of link.active) await plugin.preflight?.();
|
|
152
|
+
for (const service of [...link.registry.services, ...retention]) {
|
|
353
153
|
await service.start?.();
|
|
354
154
|
this.#started.push(service);
|
|
355
155
|
}
|
|
@@ -357,12 +157,30 @@ export class Roundtable {
|
|
|
357
157
|
this.#listeners = http;
|
|
358
158
|
http.start();
|
|
359
159
|
// After the listeners, so a background start may rely on everything else running.
|
|
360
|
-
void startInBackground(
|
|
160
|
+
void startInBackground(link.registry, this.#options.logger);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Sets up every plugin as `run()` would and refuses what `run()` refuses (a plugin that adds
|
|
165
|
+
* nothing, a clash, a missing service, a removed field, a bad route), without migrating the
|
|
166
|
+
* database, starting a service, opening a listener or reaching Discord. A plugin that queries the
|
|
167
|
+
* database during setup is refused with a message that says to query from a service's start.
|
|
168
|
+
* Preflights are not run. It runs each plugin's `setup`, so check a definition you do not run,
|
|
169
|
+
* such as `defineRoundtable` called again: the built-in agent server binds its sessions once, and
|
|
170
|
+
* a plugin keeps whatever its setup keeps. As `run()` does, it sets the process's locale and time
|
|
171
|
+
* zone, and `PI_CODING_AGENT_DIR` when `environment.agentDir` is set. It does not count as a
|
|
172
|
+
* running host.
|
|
173
|
+
*/
|
|
174
|
+
async check(): Promise<RegistrationReport> {
|
|
175
|
+
const link = new Linkage(this.#options, this.#plugins);
|
|
176
|
+
link.prepare();
|
|
177
|
+
await link.link(checkDatabase);
|
|
178
|
+
return link.report();
|
|
361
179
|
}
|
|
362
180
|
|
|
363
181
|
/** Opens the pool and runs every plugin's migrations; a failure closes it again and stops the boot. */
|
|
364
182
|
async #migrate(): Promise<void> {
|
|
365
|
-
const migrations = this.#active.flatMap(
|
|
183
|
+
const migrations = this.#link.active.flatMap(
|
|
366
184
|
(plugin) => plugin.migrations ?? [],
|
|
367
185
|
);
|
|
368
186
|
const { database } = this.#options;
|
|
@@ -373,7 +191,7 @@ export class Roundtable {
|
|
|
373
191
|
}
|
|
374
192
|
const pool = openPool(database.url);
|
|
375
193
|
try {
|
|
376
|
-
const report = await runMigrations(pool, this.#active);
|
|
194
|
+
const report = await runMigrations(pool, this.#link.active);
|
|
377
195
|
this.#options.logger.info(
|
|
378
196
|
{
|
|
379
197
|
applied: report.applied,
|
|
@@ -385,7 +203,7 @@ export class Roundtable {
|
|
|
385
203
|
} catch (error) {
|
|
386
204
|
await pool.close();
|
|
387
205
|
if (error instanceof MigrationError) {
|
|
388
|
-
const owner = this.#active.find((plugin) =>
|
|
206
|
+
const owner = this.#link.active.find((plugin) =>
|
|
389
207
|
plugin.migrations?.some(
|
|
390
208
|
({ name }) => `${plugin.name}/${name}` === error.migration,
|
|
391
209
|
),
|
|
@@ -430,18 +248,18 @@ export class Roundtable {
|
|
|
430
248
|
if (running !== this) return this.#bootFailed ? 1 : 0;
|
|
431
249
|
// Turns already running finish, but nothing new starts from here on, whoever asks.
|
|
432
250
|
logger.info({ signal }, "shutting down once idle");
|
|
433
|
-
this.#queue.close();
|
|
251
|
+
this.#link.queue.close();
|
|
434
252
|
const left = await drainWork({
|
|
435
253
|
...drain,
|
|
436
254
|
busy: () => [
|
|
437
|
-
...this.#queue.busy(),
|
|
255
|
+
...this.#link.queue.busy(),
|
|
438
256
|
...this.#started.flatMap((service) => service.busy?.() ?? []),
|
|
439
257
|
],
|
|
440
258
|
// A turn the limit finds still running is stopped, as the owner's stop would.
|
|
441
259
|
abort: () =>
|
|
442
260
|
stopTurns(
|
|
443
|
-
this.#queue.busy(),
|
|
444
|
-
(channel) => this.#router?.stop(channel as ChannelKey) ?? false,
|
|
261
|
+
this.#link.queue.busy(),
|
|
262
|
+
(channel) => this.#link.router?.stop(channel as ChannelKey) ?? false,
|
|
445
263
|
logger,
|
|
446
264
|
),
|
|
447
265
|
});
|
|
@@ -454,7 +272,7 @@ export class Roundtable {
|
|
|
454
272
|
if (!recorded.ok)
|
|
455
273
|
logger.error({ err: recorded.error }, "aborted work not recorded");
|
|
456
274
|
}
|
|
457
|
-
await this.#events.deliver("shutdown", left);
|
|
275
|
+
await this.#link.events.deliver("shutdown", left);
|
|
458
276
|
logger.info({ signal }, "shutting down");
|
|
459
277
|
const clean = await this.#teardown();
|
|
460
278
|
running = undefined;
|
|
@@ -143,3 +143,37 @@ export async function configuredOwnerId(
|
|
|
143
143
|
}
|
|
144
144
|
return id;
|
|
145
145
|
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The refusals `configuredOwnerId` makes about an identity a plugin declares that the configuration
|
|
149
|
+
* settles alone: an owner who names their `principal`, and so is the same principal on every boot,
|
|
150
|
+
* against the principal the plugin binds the identity to. An owner the boot would tell by a stored
|
|
151
|
+
* link, or who follows an owner so told, is left to the start.
|
|
152
|
+
*/
|
|
153
|
+
export function refuseOwnerDeclarations(
|
|
154
|
+
owners: readonly AccessOwner[],
|
|
155
|
+
declared: readonly DeclaredIdentity[],
|
|
156
|
+
): void {
|
|
157
|
+
const declaredBy = new Map(declared.map((each) => [each.identity, each]));
|
|
158
|
+
const primary = owners[0]?.principal;
|
|
159
|
+
for (const [n, owner] of owners.entries()) {
|
|
160
|
+
if (owner.principal === undefined || (n > 0 && primary === undefined))
|
|
161
|
+
continue;
|
|
162
|
+
for (const [i, identity] of owner.identities.entries()) {
|
|
163
|
+
const plugin = declaredBy.get(identity);
|
|
164
|
+
if (!plugin) continue;
|
|
165
|
+
const conflict = declaredConflict({
|
|
166
|
+
index: n,
|
|
167
|
+
i,
|
|
168
|
+
identity,
|
|
169
|
+
id: owner.principal,
|
|
170
|
+
link: undefined,
|
|
171
|
+
declared: plugin,
|
|
172
|
+
primary: n === 0 ? undefined : primary,
|
|
173
|
+
first: n === 0,
|
|
174
|
+
});
|
|
175
|
+
if (conflict)
|
|
176
|
+
throw new ConfigError(`config ${conflict.problem}. ${conflict.fix}.`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|