pi-roundtable 0.23.2 → 0.24.1

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.
@@ -1,54 +1,36 @@
1
1
  import { tmpdir } from "node:os";
2
2
  import { join } from "node:path";
3
+ import { CATALOG, type Hex } from "../../characters/index.ts";
3
4
  import type { AgentSeed } from "../agents/agent-rules.ts";
4
5
  import { ConfigError } from "../domain/errors.ts";
5
6
  import type { InterimTextMode } from "../domain/interim.ts";
6
7
  import { DRAIN_LIMIT_MS } from "../drain.ts";
7
- import { isLocale, type Locale } from "../i18n/index.ts";
8
+ import type { Locale } from "../i18n/index.ts";
8
9
  import type { AccessRules } from "../identity/access-policy.ts";
9
10
  import type { OwnerIdentity } from "../identity.ts";
10
11
  import {
11
12
  type EffortPolicy,
12
13
  type ModelRef,
13
14
  parseModelRef,
14
- THINKING_LEVELS,
15
15
  type ThinkingLevel,
16
16
  } from "../models.ts";
17
17
  import type { PerPrincipalLimits } from "../modules/background/personal-target.ts";
18
18
  import type { RoundtablePlugin } from "../plugin.ts";
19
19
  import { PRIMARY_CHARS } from "../runtime/interim-text.ts";
20
20
  import type { Tier, TierMembers } from "../speakers.ts";
21
- import { type AccessConfig, accessOf, accessShape } from "./access.ts";
21
+ import { type AccessConfig, accessOf } from "./access.ts";
22
22
  import {
23
23
  type DiscordConfig,
24
- discordShape,
25
24
  liftAdapters,
26
25
  type ResolvedDiscord,
27
26
  resolvedDiscord,
28
27
  } from "./adapters.ts";
29
- import { checkAgentTeamKeys } from "./agent-team.ts";
30
- import { type AttachmentsConfig, attachmentsShape } from "./attachments.ts";
31
- import {
32
- type LimitsConfig,
33
- limitsOf,
34
- limitsShape,
35
- type ResolvedLimits,
36
- } from "./limits.ts";
28
+ import { checkAgentServerOff, checkAgentTeamKeys } from "./agent-team.ts";
29
+ import type { AttachmentsConfig } from "./attachments.ts";
30
+ import { configSchema } from "./config-schema.ts";
31
+ import { type LimitsConfig, limitsOf, type ResolvedLimits } from "./limits.ts";
37
32
  import { opsOf, type ResolvedOps } from "./ops.ts";
38
- import {
39
- bool,
40
- characterInput,
41
- guarded,
42
- integer,
43
- list,
44
- number,
45
- oneOf,
46
- optional,
47
- record,
48
- shape,
49
- text,
50
- } from "./schema.ts";
51
- import { type SkillsConfig, skillsField } from "./skills-config.ts";
33
+ import type { SkillsConfig } from "./skills-config.ts";
52
34
 
53
35
  export type { DiscordConfig };
54
36
 
@@ -100,6 +82,17 @@ export interface RoundtableConfig extends LimitsConfig {
100
82
  * instead, never both.
101
83
  */
102
84
  discord?: DiscordConfig;
85
+ /**
86
+ * `false` leaves the agent server out of a host that has `discord`: no agent team, no
87
+ * `#dashboard` channel or pinned message, no agent channels or routes, no coordinator, and no
88
+ * skills addon. Discord, access, memory, commands and the plugins' claims work as before;
89
+ * `discord.entryChannel` is optional and `http.publicUrl` is no longer required (it serves only
90
+ * the agents' avatars). Refused together with anything only agents use: `agents`, `skills`,
91
+ * `ops.agent`, `discord.agentMemory`, `discord.agentSpeakers`, `discord.attachmentDownload`.
92
+ * Plugins that need the agent server (pi-roundtable-web, remote-mcp's default conversation)
93
+ * stop startup. Left out, the agent server runs; there is no `true`.
94
+ */
95
+ agentServer?: false;
103
96
  /**
104
97
  * The chat networks the host talks through, each made by its adapter's factory, such as
105
98
  * `discord()` from `pi-roundtable/discord`. A Discord adapter is the same as the top-level
@@ -154,6 +147,20 @@ export interface RoundtableConfig extends LimitsConfig {
154
147
  * first. With `discord` the agent in `discord.entryChannel` is the coordinator, and this key is refused.
155
148
  */
156
149
  coordinator?: string;
150
+ /**
151
+ * How the agents' characters are drawn, everywhere the host draws them: the web console, its
152
+ * round table and the agents' pictures on Discord. `skins` is exactly six `#rrggbb` skin tones
153
+ * that a look's `skin` index (0 to 5) picks from, such as `EAST_ASIAN_SKINS` from
154
+ * `pi-roundtable/characters`; default the six the package ships. Stored looks keep their index,
155
+ * so they take the new tones with no migration. A bad list stops the start.
156
+ *
157
+ * `refreshAvatars` (default false) renders every agent's Discord picture again each time the
158
+ * agent server starts, with the host's skins, and sets it where it differs from the stored one,
159
+ * so an agent that was not saved since a redraw or a `skins` change catches up. It overwrites
160
+ * an agent that still wears an old hand-drawn (deprecated) picture. It runs after the start
161
+ * without holding it back; a restart with nothing changed sets nothing.
162
+ */
163
+ characters?: { skins?: readonly string[]; refreshAvatars?: boolean };
157
164
  /**
158
165
  * The `public` listener: the public address that reaches it, and the TCP port (default 3000) or
159
166
  * unix socket the process listens on. `socketMode` is the socket file's permission bits
@@ -212,109 +219,6 @@ export interface RoundtableConfig extends LimitsConfig {
212
219
  plugins?: RoundtablePlugin[];
213
220
  }
214
221
 
215
- const tierMembers = shape({
216
- users: optional(list(text)),
217
- roles: optional(list(text)),
218
- everyone: optional(bool),
219
- });
220
-
221
- const isPolicy = (value: unknown): value is EffortPolicy =>
222
- typeof value === "function";
223
- const isPlugin = (value: unknown): value is RoundtablePlugin =>
224
- typeof value === "object" &&
225
- value !== null &&
226
- typeof (value as { name?: unknown }).name === "string" &&
227
- typeof (value as { setup?: unknown }).setup === "function";
228
-
229
- const schema = shape({
230
- name: optional(text),
231
- owner: optional(
232
- shape({
233
- id: text,
234
- name: text,
235
- pronouns: optional(oneOf<Pronouns>("he", "she", "they")),
236
- }),
237
- ),
238
- discord: optional(discordShape),
239
- database: shape({ url: text }),
240
- dataDir: text,
241
- attachments: attachmentsShape,
242
- agentDir: optional(text),
243
- model: text,
244
- thinking: optional(oneOf(...THINKING_LEVELS)),
245
- judge: optional(
246
- shape({ model: optional(text), threshold: optional(number(0, 1)) }),
247
- ),
248
- effortPolicy: optional(
249
- guarded("a function from a turn to its effort rule", isPolicy),
250
- ),
251
- delegation: optional(
252
- shape({
253
- model: optional(text),
254
- thinking: optional(oneOf(...THINKING_LEVELS)),
255
- }),
256
- ),
257
- locale: optional(
258
- guarded("a locale, en or zh-TW", (value): value is Locale =>
259
- isLocale(value),
260
- ),
261
- ),
262
- timeZone: optional(text),
263
- speakers: optional(
264
- shape({ admins: optional(tierMembers), members: optional(tierMembers) }),
265
- ),
266
- access: optional(accessShape),
267
- background: optional(
268
- shape({
269
- perPrincipal: optional(
270
- shape({
271
- schedules: optional(integer(1, 10_000)),
272
- delegations: optional(integer(1, 1_000)),
273
- }),
274
- ),
275
- }),
276
- ),
277
- toolTiers: optional(record(oneOf<Tier>("owner", "admin", "member"))),
278
- prompts: optional(shape({ shared: text, guest: optional(text) })),
279
- agents: optional(
280
- list(
281
- shape({
282
- name: text,
283
- displayName: text,
284
- prompt: text,
285
- character: optional(characterInput),
286
- avatarPrompt: optional(text),
287
- channelId: optional(text),
288
- }),
289
- ),
290
- ),
291
- coordinator: optional(text),
292
- http: optional(
293
- shape({
294
- publicUrl: optional(text),
295
- port: optional(integer(1, 65535)),
296
- hostname: optional(text),
297
- socketPath: optional(text),
298
- socketMode: optional(integer(0, 0o777)),
299
- }),
300
- ),
301
- avatar: optional(text),
302
- workDir: optional(text),
303
- scratchDir: optional(text),
304
- skills: optional(skillsField),
305
- memory: optional(bool),
306
- ops: optional(shape({ agent: optional(text), conversation: optional(text) })),
307
- drainSeconds: optional(integer(1, 3_600)),
308
- ...limitsShape,
309
- interimText: optional(oneOf<InterimTextMode>("on", "off")),
310
- interimPrimaryChars: optional(integer(1, 100_000)),
311
- plugins: optional(
312
- list(
313
- guarded("a plugin, an object with a name and a setup function", isPlugin),
314
- ),
315
- ),
316
- });
317
-
318
222
  /** Logged once when `avatar` is set: agents are composed characters and no picture is drawn from it. */
319
223
  export const AVATAR_DEPRECATION =
320
224
  "agents are composed characters since 0.23; `avatar` is ignored";
@@ -340,6 +244,8 @@ export interface ResolvedConfig extends ResolvedLimits {
340
244
  deprecations: string[];
341
245
  /** Undefined on a host without Discord. */
342
246
  discord?: ResolvedDiscord;
247
+ /** Whether the agent server runs: false when `agentServer: false`, and otherwise on a host with `discord` or `agents`. */
248
+ agentServer: boolean;
343
249
  databaseUrl: string;
344
250
  dataDir: string;
345
251
  /** Absent `retention`: used files live as long as their conversation. */
@@ -361,9 +267,11 @@ export interface ResolvedConfig extends ResolvedLimits {
361
267
  agents: AgentSeed[];
362
268
  /** The coordinator's name; set only without `discord` and with `agents`: the configured one, else the first seed. */
363
269
  coordinator?: string;
270
+ /** How the agents' characters are drawn: the six skin tones, the package's own unless `characters.skins` is set. */
271
+ characters: { skins: readonly Hex[]; refreshAvatars: boolean };
364
272
  /** Undefined when no `http` is configured, and the host opens no `public` listener. */
365
273
  http?: {
366
- /** Always set with Discord. */
274
+ /** Always set with Discord, unless `agentServer` is false. */
367
275
  publicUrl?: string;
368
276
  port: number;
369
277
  hostname?: string;
@@ -392,14 +300,28 @@ function modelOf(value: string, path: string): ModelRef {
392
300
  return parsed;
393
301
  }
394
302
 
303
+ /** The skin tones the configuration chose, or the package's own. */
304
+ function skinsOf(skins: readonly string[] | undefined): readonly Hex[] {
305
+ return (skins as readonly Hex[] | undefined) ?? CATALOG.skins;
306
+ }
307
+
395
308
  /** The configuration checked against its schema, defaults filled; throws a ConfigError naming the key and the fix. */
396
309
  export function resolveConfig(input: unknown): ResolvedConfig {
397
- const config = schema.check(liftAdapters(input), "") as RoundtableConfig;
310
+ const config = configSchema.check(
311
+ liftAdapters(input),
312
+ "",
313
+ ) as RoundtableConfig;
398
314
  const name = config.name ?? "Roundtable";
399
315
  const model = modelOf(config.model, "model");
400
316
  const thinking = config.thinking ?? "medium";
401
317
  const { discord, http } = config;
402
- if (discord && !http?.publicUrl)
318
+ const agentServer = config.agentServer !== false;
319
+ if (!agentServer) checkAgentServerOff(config);
320
+ if (discord && agentServer && !discord.entryChannel)
321
+ throw new ConfigError(
322
+ "config discord.entryChannel: required, expected the id of the channel the coordinator lives in. Add it to roundtable.config.ts, or set agentServer: false for a host without agents.",
323
+ );
324
+ if (discord && agentServer && !http?.publicUrl)
403
325
  throw new ConfigError(
404
326
  "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.",
405
327
  );
@@ -454,9 +376,16 @@ export function resolveConfig(input: unknown): ResolvedConfig {
454
376
  toolTiers: config.toolTiers ?? {},
455
377
  prompts: config.prompts ?? {},
456
378
  agents: config.agents ?? [],
379
+ agentServer:
380
+ agentServer &&
381
+ (discord !== undefined || (config.agents?.length ?? 0) > 0),
457
382
  ...(!discord && config.agents?.[0]
458
383
  ? { coordinator: config.coordinator ?? config.agents[0].name }
459
384
  : {}),
385
+ characters: {
386
+ skins: skinsOf(config.characters?.skins),
387
+ refreshAvatars: config.characters?.refreshAvatars ?? false,
388
+ },
460
389
  ...(http
461
390
  ? {
462
391
  http: {
@@ -474,7 +403,9 @@ export function resolveConfig(input: unknown): ResolvedConfig {
474
403
  workDir: config.workDir ?? `${config.dataDir}/work`,
475
404
  scratchDir: config.scratchDir ?? join(tmpdir(), `${slug}-scratch`),
476
405
  // Without Discord and without agents there is no one to carry skills, so the addon is off.
477
- skills: config.skills ?? (discord || config.agents?.length ? {} : false),
406
+ skills: agentServer
407
+ ? (config.skills ?? (discord || config.agents?.length ? {} : false))
408
+ : false,
478
409
  memory: config.memory ?? true,
479
410
  ...(ops ? { ops } : {}),
480
411
  drainMs: (config.drainSeconds ?? DRAIN_LIMIT_MS / 1_000) * 1_000,
@@ -1,6 +1,8 @@
1
1
  import {
2
2
  CharacterError,
3
3
  type CharacterInput,
4
+ checkSkins,
5
+ type Hex,
4
6
  resolveCharacter,
5
7
  } from "../../characters/index.ts";
6
8
  import { ConfigError } from "../domain/errors.ts";
@@ -155,6 +157,22 @@ export const characterInput: Field<CharacterInput> = {
155
157
  },
156
158
  };
157
159
 
160
+ /** The six skin tones of a host's agents, exactly six `#rrggbb` colors; a bad one is named by its place in the list. */
161
+ export const skinTones: Field<readonly Hex[]> = {
162
+ describe: "a list of exactly 6 #rrggbb colors, such as EAST_ASIAN_SKINS",
163
+ check: (value, path) => {
164
+ try {
165
+ return checkSkins(value, path);
166
+ } catch (error) {
167
+ if (error instanceof CharacterError)
168
+ throw new ConfigError(
169
+ `config ${error.message}. Fix the skins in roundtable.config.ts.`,
170
+ );
171
+ throw error;
172
+ }
173
+ },
174
+ };
175
+
158
176
  /** `false`, or whatever `item` accepts: a feature that is switched off, or configured. */
159
177
  export const orOff = <T>(item: Field<T>): Field<T | false> => ({
160
178
  describe: `false, or ${item.describe}`,
@@ -212,7 +212,12 @@ function agentAssembly(
212
212
  : [],
213
213
  agentServer: agentServerPlugin({
214
214
  ...(discord
215
- ? { guildId: discord.guild, entryChannelId: discord.entryChannel }
215
+ ? {
216
+ guildId: discord.guild,
217
+ ...(discord.entryChannel
218
+ ? { entryChannelId: discord.entryChannel }
219
+ : {}),
220
+ }
216
221
  : config.coordinator
217
222
  ? { coordinator: config.coordinator }
218
223
  : {}),
@@ -235,6 +240,7 @@ function agentAssembly(
235
240
  // The pictures are public on the listener only where a public address reaches it.
236
241
  ...(config.http ? { avatarListener: "public" } : {}),
237
242
  ...(config.http?.publicUrl ? { avatarUrl: config.http.publicUrl } : {}),
243
+ ...(config.characters.refreshAvatars ? { refreshAvatars: true } : {}),
238
244
  avatarReference: join(ASSETS, "neutral.png"),
239
245
  avatarNeutral: join(ASSETS, "neutral.png"),
240
246
  // An ops agent's reports are the agent server's to deliver.
@@ -250,7 +256,8 @@ function agentAssembly(
250
256
  * The host's options and the plugin list of a configured bot: the built-in plugins in their
251
257
  * fixed order (each service one provides is read by the ones after it), then the operator's, then the scheduler, so a due schedule fires only once
252
258
  * everything it reaches runs. The memory, Discord administration, and skills addons are left out
253
- * when the configuration switches them off. Without `discord` the Discord plugins are left out,
259
+ * when the configuration switches them off. With `agentServer: false` the agent server, its
260
+ * seeds and the skills addon are left out of a Discord host. Without `discord` the Discord plugins are left out,
254
261
  * and the skills, the agent server and its seeds stay only when `agents` are configured: their
255
262
  * team then lives in the rooms of the surfaces that host them, and the runtime still runs every
256
263
  * turn of `context.turns`. Configuration mistakes stop here, naming the key and the fix.
@@ -294,11 +301,11 @@ export async function defineRoundtable(
294
301
  // The agent server hands the runtime its per-agent settings once it sets up.
295
302
  const agentSessions = agentSessionsSlot();
296
303
  const assembly = discord ? discordAssembly(config, discord) : undefined;
297
- // The agent server runs with Discord, and without it whenever agents are configured.
298
- const agentTeam =
299
- discord || config.agents.length > 0
300
- ? agentAssembly(config, { modelRuntime, agentSessions, errorReporter })
301
- : undefined;
304
+ // The agent server runs with Discord, and without it whenever agents are configured;
305
+ // `agentServer: false` leaves it out, with the skills addon and the seeds.
306
+ const agentTeam = config.agentServer
307
+ ? agentAssembly(config, { modelRuntime, agentSessions, errorReporter })
308
+ : undefined;
302
309
  return {
303
310
  options: {
304
311
  logger,
@@ -309,6 +316,7 @@ export async function defineRoundtable(
309
316
  rootCommand: config.slug,
310
317
  agentDir: config.agentDir,
311
318
  scratchDir: config.scratchDir,
319
+ characterSkins: config.characters.skins,
312
320
  },
313
321
  database: { url: config.databaseUrl },
314
322
  dataDir: config.dataDir,
@@ -1,10 +1,17 @@
1
1
  import type { SQL } from "bun";
2
+ import {
3
+ CATALOG,
4
+ CharacterError,
5
+ checkSkins,
6
+ type Hex,
7
+ } from "../characters/index.ts";
2
8
  import { attachmentPort } from "./attachments/attachment-port.ts";
3
9
  import { retentionServices } from "./attachments/attachment-retention.ts";
4
10
  import type { AttachmentPort } from "./contract/attachments.ts";
5
11
  import type { ConversationPort } from "./contract/channels.ts";
6
12
  import type { ResolvedProviders } from "./contract/providers.ts";
7
13
  import type { SurfacePort } from "./contract/surface.ts";
14
+ import { ConfigError } from "./domain/errors.ts";
8
15
  import { NotLinkedError } from "./errors.ts";
9
16
  import { EventBus } from "./events.ts";
10
17
  import type { RoundtableOptions } from "./host.ts";
@@ -37,6 +44,20 @@ import { CONVERSATIONS, IDENTITY, RUNTIME } from "./services.ts";
37
44
  import { setTimeZone } from "./time.ts";
38
45
  import { type ToolTierTable, toolTiers } from "./tool-tiers.ts";
39
46
 
47
+ /** The skin tones the host's environment names, checked, or the package's own; a bad set refuses the start. */
48
+ function characterSkinsOf(skins: readonly Hex[] | undefined): readonly Hex[] {
49
+ if (skins === undefined) return CATALOG.skins;
50
+ try {
51
+ return checkSkins(skins, "environment.characterSkins");
52
+ } catch (error) {
53
+ if (error instanceof CharacterError)
54
+ throw new ConfigError(
55
+ `${error.message}. Pass exactly six #rrggbb colors, such as EAST_ASIAN_SKINS from pi-roundtable/characters.`,
56
+ );
57
+ throw error;
58
+ }
59
+ }
60
+
40
61
  /**
41
62
  * What `Roundtable.check()` found: the plugins that would run, the services and HTTP routes they
42
63
  * add, as the host would start them.
@@ -102,7 +123,10 @@ export class Linkage {
102
123
  rootCommand = "roundtable",
103
124
  agentDir,
104
125
  scratchDir,
126
+ characterSkins,
105
127
  } = this.#options.environment ?? {};
128
+ // Checked before anything is applied, so a bad set stops the start with the process untouched.
129
+ const skins = characterSkinsOf(characterSkins);
106
130
  setLocale(locale, { assistant, root: rootCommand });
107
131
  setTimeZone(timeZone);
108
132
  // Pi packages such as pi-web-access read their config from the Pi agent directory.
@@ -112,6 +136,7 @@ export class Linkage {
112
136
  timeZone,
113
137
  now: () => new Date(),
114
138
  ...(scratchDir !== undefined ? { scratchDir } : {}),
139
+ characterSkins: skins,
115
140
  };
116
141
  }
117
142
 
package/src/core/host.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { SQL } from "bun";
2
+ import type { Hex } from "../characters/index.ts";
2
3
  import type { AttachmentRetention } from "./attachments/attachment-retention.ts";
3
4
  import { checkDatabase } from "./db/check-database.ts";
4
5
  import { openPool, runMigrations } from "./db/migrations.ts";
@@ -33,6 +34,8 @@ export interface HostEnvironment {
33
34
  agentDir?: string;
34
35
  /** The host's scratch dir, which plugins read as `env.scratchDir`; unset gives them none. */
35
36
  scratchDir?: string;
37
+ /** Six `#rrggbb` skin tones for the agents' characters, which plugins read as `env.characterSkins`; default the package's own. Anything but six valid colors stops the start. */
38
+ characterSkins?: readonly Hex[];
36
39
  }
37
40
 
38
41
  export interface RoundtableOptions {
@@ -0,0 +1,85 @@
1
+ import type { ChatSurface } from "./contract/surface.ts";
2
+ import { PluginError } from "./errors.ts";
3
+ import type {
4
+ Contribution,
5
+ EventHandlers,
6
+ RoundtablePlugin,
7
+ } from "./plugin.ts";
8
+
9
+ /**
10
+ * The plugin fields of 0.1.0 that are gone, each with what replaces it. The host refuses a
11
+ * plugin that still has one, so a plugin written for 0.1.0 fails at once instead of being ignored.
12
+ */
13
+ const REMOVED_PLUGIN_FIELDS = {
14
+ useCommands:
15
+ "the composed slash commands go to the Discord plugin's surface, not to a plugin; add the commands from setup with context.services.get(DISCORD).commands.add(...), with DISCORD from pi-roundtable/discord",
16
+ agentServer:
17
+ "give the plugin a service with startInBackground, and hear how it ended in a serviceStarted event handler",
18
+ stopTurn:
19
+ "put stop(channel) on the channel claim that owns the channel; the router asks only the owning claim",
20
+ } as const;
21
+
22
+ /** The contribution parts of 0.1.0 that are gone, each with what replaces it. */
23
+ const REMOVED_PARTS = {
24
+ interactions:
25
+ "slash commands belong to the Discord plugin now; add them from setup with context.services.get(DISCORD).commands.add({ module, rootOptions }), with DISCORD from pi-roundtable/discord",
26
+ } as const;
27
+
28
+ /** The chat surface methods of 0.1.0 that are gone, each with what replaces it. */
29
+ const REMOVED_SURFACE_METHODS = {
30
+ useCommands:
31
+ "the host no longer composes slash commands or hands them to a surface; the Discord plugin composes them, and a plugin adds its own with context.services.get(DISCORD).commands.add(...) from pi-roundtable/discord",
32
+ } as const;
33
+
34
+ const REMOVED_EVENT = {
35
+ agentServer:
36
+ "hear serviceStarted instead, which names the plugin and service whose background start ended",
37
+ } as const;
38
+
39
+ /**
40
+ * Refuses a plugin that still has a field of 0.1.0 that is gone, naming what replaces it, so it
41
+ * fails where it is written or when the host links it instead of being ignored.
42
+ */
43
+ export function refuseRemovedFields(plugin: RoundtablePlugin): void {
44
+ for (const [field, replacement] of Object.entries(REMOVED_PLUGIN_FIELDS))
45
+ if (field in plugin)
46
+ throw new PluginError(
47
+ `plugin ${plugin.name}: "${field}" was removed in 0.2.0; ${replacement}.`,
48
+ );
49
+ }
50
+
51
+ /** Refuses a contribution part of 0.1.0 that is gone, naming what replaces it. */
52
+ export function refuseRemovedParts(
53
+ plugin: string,
54
+ contribution: Contribution,
55
+ ): void {
56
+ for (const [part, replacement] of Object.entries(REMOVED_PARTS))
57
+ if (part in contribution)
58
+ throw new PluginError(
59
+ `plugin ${plugin}: the "${part}" part was removed in 0.2.0; ${replacement}.`,
60
+ );
61
+ }
62
+
63
+ /** Refuses a chat surface that still has a method of 0.1.0 that is gone, naming what replaces it. */
64
+ export function refuseRemovedSurfaceMethods(
65
+ plugin: string,
66
+ surface: ChatSurface,
67
+ ): void {
68
+ for (const [method, replacement] of Object.entries(REMOVED_SURFACE_METHODS))
69
+ if (method in surface)
70
+ throw new PluginError(
71
+ `plugin ${plugin}: surface ${surface.surface} has ${method}, which was removed in 0.2.0; ${replacement}.`,
72
+ );
73
+ }
74
+
75
+ /** Refuses an event handler of 0.1.0 that is gone, naming what replaces it. */
76
+ export function refuseRemovedEvents(
77
+ plugin: string,
78
+ events: EventHandlers,
79
+ ): void {
80
+ for (const [event, replacement] of Object.entries(REMOVED_EVENT))
81
+ if (event in events)
82
+ throw new PluginError(
83
+ `plugin ${plugin}: the "${event}" event was removed in 0.2.0; ${replacement}.`,
84
+ );
85
+ }
@@ -1,4 +1,5 @@
1
1
  import type { SQL } from "bun";
2
+ import type { Hex } from "../characters/index.ts";
2
3
  import type { AgentSeed } from "./agents/agent-store.ts";
3
4
  import type { AttachmentPort } from "./contract/attachments.ts";
4
5
  import type {
@@ -276,6 +277,13 @@ export interface HostEnv {
276
277
  * without a hold. Plugins that run shells of their own (coding workers) give it to them too.
277
278
  */
278
279
  readonly scratchDir?: string;
280
+ /**
281
+ * The six skin tones the host draws its agents' characters with, which `CharacterSpec.skin`
282
+ * indexes: the package's own unless the host configured `characters.skins`. A plugin that
283
+ * draws a character passes them as `composeSvg`'s `skins` option, so every picture of an
284
+ * agent wears the same ones.
285
+ */
286
+ readonly characterSkins: readonly Hex[];
279
287
  }
280
288
 
281
289
  /** What the host gives every plugin. */
@@ -392,36 +400,6 @@ export interface RoundtablePlugin {
392
400
  preflight?(): Promise<void> | void;
393
401
  }
394
402
 
395
- /**
396
- * The plugin fields of 0.1.0 that are gone, each with what replaces it. The host refuses a
397
- * plugin that still has one, so a plugin written for 0.1.0 fails at once instead of being ignored.
398
- */
399
- const REMOVED_PLUGIN_FIELDS = {
400
- useCommands:
401
- "the composed slash commands go to the Discord plugin's surface, not to a plugin; add the commands from setup with context.services.get(DISCORD).commands.add(...), with DISCORD from pi-roundtable/discord",
402
- agentServer:
403
- "give the plugin a service with startInBackground, and hear how it ended in a serviceStarted event handler",
404
- stopTurn:
405
- "put stop(channel) on the channel claim that owns the channel; the router asks only the owning claim",
406
- } as const;
407
-
408
- /** The contribution parts of 0.1.0 that are gone, each with what replaces it. */
409
- const REMOVED_PARTS = {
410
- interactions:
411
- "slash commands belong to the Discord plugin now; add them from setup with context.services.get(DISCORD).commands.add({ module, rootOptions }), with DISCORD from pi-roundtable/discord",
412
- } as const;
413
-
414
- /** The chat surface methods of 0.1.0 that are gone, each with what replaces it. */
415
- const REMOVED_SURFACE_METHODS = {
416
- useCommands:
417
- "the host no longer composes slash commands or hands them to a surface; the Discord plugin composes them, and a plugin adds its own with context.services.get(DISCORD).commands.add(...) from pi-roundtable/discord",
418
- } as const;
419
-
420
- const REMOVED_EVENT = {
421
- agentServer:
422
- "hear serviceStarted instead, which names the plugin and service whose background start ended",
423
- } as const;
424
-
425
403
  /**
426
404
  * The context one plugin's setup gets: the host's parts and that plugin's view of the services.
427
405
  * `context.core` of 0.1.0 is gone; reading it throws a PluginError naming `context.services`, where
@@ -449,50 +427,9 @@ export function pluginContext(
449
427
  return context;
450
428
  }
451
429
 
452
- /**
453
- * Refuses a plugin that still has a field of 0.1.0 that is gone, naming what replaces it, so it
454
- * fails where it is written or when the host links it instead of being ignored.
455
- */
456
- export function refuseRemovedFields(plugin: RoundtablePlugin): void {
457
- for (const [field, replacement] of Object.entries(REMOVED_PLUGIN_FIELDS))
458
- if (field in plugin)
459
- throw new PluginError(
460
- `plugin ${plugin.name}: "${field}" was removed in 0.2.0; ${replacement}.`,
461
- );
462
- }
463
-
464
- /** Refuses a contribution part of 0.1.0 that is gone, naming what replaces it. */
465
- export function refuseRemovedParts(
466
- plugin: string,
467
- contribution: Contribution,
468
- ): void {
469
- for (const [part, replacement] of Object.entries(REMOVED_PARTS))
470
- if (part in contribution)
471
- throw new PluginError(
472
- `plugin ${plugin}: the "${part}" part was removed in 0.2.0; ${replacement}.`,
473
- );
474
- }
475
-
476
- /** Refuses a chat surface that still has a method of 0.1.0 that is gone, naming what replaces it. */
477
- export function refuseRemovedSurfaceMethods(
478
- plugin: string,
479
- surface: ChatSurface,
480
- ): void {
481
- for (const [method, replacement] of Object.entries(REMOVED_SURFACE_METHODS))
482
- if (method in surface)
483
- throw new PluginError(
484
- `plugin ${plugin}: surface ${surface.surface} has ${method}, which was removed in 0.2.0; ${replacement}.`,
485
- );
486
- }
487
-
488
- /** Refuses an event handler of 0.1.0 that is gone, naming what replaces it. */
489
- export function refuseRemovedEvents(
490
- plugin: string,
491
- events: EventHandlers,
492
- ): void {
493
- for (const [event, replacement] of Object.entries(REMOVED_EVENT))
494
- if (event in events)
495
- throw new PluginError(
496
- `plugin ${plugin}: the "${event}" event was removed in 0.2.0; ${replacement}.`,
497
- );
498
- }
430
+ export {
431
+ refuseRemovedEvents,
432
+ refuseRemovedFields,
433
+ refuseRemovedParts,
434
+ refuseRemovedSurfaceMethods,
435
+ } from "./plugin-removed.ts";