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.
@@ -0,0 +1,77 @@
1
+ import type { CharacterSpec } from "../../characters/index.ts";
2
+ import type { Logger } from "../log.ts";
3
+ import type { Agent } from "./agent-store.ts";
4
+
5
+ /** What the refresh needs of the team: the agents, a render, and a way to set the picture. */
6
+ export interface AvatarRefreshDeps {
7
+ agents(): readonly Agent[];
8
+ /** Renders a look to its picture and returns the content hash; the same look gives the same hash. */
9
+ render(character: CharacterSpec): Promise<string>;
10
+ /**
11
+ * Sets the picture only while the agent is active and still wears `character`; false when it
12
+ * does not, which leaves the newer look's picture alone.
13
+ */
14
+ setAvatar(
15
+ name: string,
16
+ character: CharacterSpec,
17
+ avatarHash: string,
18
+ ): Promise<boolean>;
19
+ /** Set when the team stops: no further render, write or log happens. */
20
+ signal?: AbortSignal;
21
+ logger: Logger;
22
+ }
23
+
24
+ export interface AvatarRefreshResult {
25
+ rendered: number;
26
+ unchanged: number;
27
+ /** Archived, or given another look, while it rendered. */
28
+ skipped: number;
29
+ failed: number;
30
+ }
31
+
32
+ /**
33
+ * Renders every active agent that has a character again and sets the picture when its hash
34
+ * differs from the stored one, so a restart with nothing changed writes nothing. One agent's
35
+ * failure is counted and does not stop the others. An agent archived or given another look while
36
+ * it rendered is skipped, so the newer picture stays. Stops at once when the signal aborts, with
37
+ * no further write or log. Logs one summary line.
38
+ */
39
+ export async function refreshAvatars(
40
+ deps: AvatarRefreshDeps,
41
+ ): Promise<AvatarRefreshResult> {
42
+ const { agents, render, setAvatar, signal, logger } = deps;
43
+ const result: AvatarRefreshResult = {
44
+ rendered: 0,
45
+ unchanged: 0,
46
+ skipped: 0,
47
+ failed: 0,
48
+ };
49
+ const reasons: string[] = [];
50
+ for (const agent of agents()) {
51
+ if (signal?.aborted) return result;
52
+ if (agent.status !== "active" || !agent.character) continue;
53
+ try {
54
+ const avatarHash = await render(agent.character);
55
+ if (signal?.aborted) return result;
56
+ if (avatarHash === agent.avatarHash) {
57
+ result.unchanged++;
58
+ continue;
59
+ }
60
+ if (await setAvatar(agent.name, agent.character, avatarHash))
61
+ result.rendered++;
62
+ else result.skipped++;
63
+ } catch (error) {
64
+ if (signal?.aborted) return result;
65
+ result.failed++;
66
+ reasons.push(
67
+ `${agent.name}: ${error instanceof Error ? error.message : String(error)}`,
68
+ );
69
+ }
70
+ }
71
+ if (signal?.aborted) return result;
72
+ const summary = { ...result, ...(reasons.length > 0 ? { reasons } : {}) };
73
+ const line = `avatars refreshed: re-rendered ${result.rendered}, unchanged ${result.unchanged}, skipped ${result.skipped}, failed ${result.failed}`;
74
+ if (result.failed > 0) logger.warn(summary, line);
75
+ else logger.info(summary, line);
76
+ return result;
77
+ }
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
- import type { CharacterSpec } from "../../characters/index.ts";
4
+ import type { CharacterSpec, Hex } from "../../characters/index.ts";
5
5
  import { resolveCharacter } from "../../characters/index.ts";
6
6
  import { squarePng } from "../attachments/square-png.ts";
7
7
  import type { ImageDrawer } from "../contract/providers.ts";
@@ -35,6 +35,8 @@ export interface AvatarStudioOptions {
35
35
  neutralPath?: string;
36
36
  /** Told the error code of a reference that was replaced by `neutralPath`. */
37
37
  onReferenceFailed?: (code: string) => void;
38
+ /** The skin tones the pictures are drawn with; default the package's own. */
39
+ skins?: readonly Hex[];
38
40
  /** Rasterizes a character to a PNG; canvas with librsvg by default. A seam for tests. */
39
41
  rasterize?: typeof characterPng;
40
42
  /** Draws the pictures. Without one, the studio only makes each agent a picture from its name. */
@@ -59,6 +61,12 @@ export class FileAvatarStudio implements AvatarStudio {
59
61
  mkdirSync(options.dir, { recursive: true });
60
62
  }
61
63
 
64
+ /** The host's skin tones as a compose option; nothing when it has none, which draws the package's own. */
65
+ #skins(): { skins?: readonly Hex[] } {
66
+ const { skins } = this.#options;
67
+ return skins ? { skins } : {};
68
+ }
69
+
62
70
  /**
63
71
  * Stores the assistant's neutral avatar, the picture of an agent that has none yet. First
64
72
  * rasterizes a probe character: the agents' pictures need canvas with SVG support (librsvg),
@@ -68,6 +76,7 @@ export class FileAvatarStudio implements AvatarStudio {
68
76
  try {
69
77
  await this.#rasterize(resolveCharacter({ class: "wizard" }), {
70
78
  size: 32,
79
+ ...this.#skins(),
71
80
  });
72
81
  } catch (error) {
73
82
  throw new ConfigError(
@@ -111,7 +120,9 @@ export class FileAvatarStudio implements AvatarStudio {
111
120
 
112
121
  /** Renders a character as the agent's picture and stores it by content hash; the same spec gives the same hash. */
113
122
  async render(spec: CharacterSpec): Promise<string> {
114
- return this.#keep(await this.#rasterize(spec, { size: AVATAR_SIDE }));
123
+ return this.#keep(
124
+ await this.#rasterize(spec, { size: AVATAR_SIDE, ...this.#skins() }),
125
+ );
115
126
  }
116
127
 
117
128
  /**
@@ -9,6 +9,7 @@ import type { AgentTurnScope } from "../domain/ports.ts";
9
9
  import type { AgentChannels } from "./agent-ports.ts";
10
10
  import { overBase } from "./agent-rules.ts";
11
11
  import type { Agent, AgentGroup } from "./agent-store.ts";
12
+ import { refreshAvatars } from "./avatar-refresh.ts";
12
13
  import {
13
14
  agentOfChannel,
14
15
  coordinatorOf,
@@ -27,17 +28,24 @@ import type { TeamContext } from "./team-options.ts";
27
28
  /** The team's startup, fresh starts, and archiving of agents and groups. */
28
29
  export class TeamLifecycle {
29
30
  readonly #ctx: TeamContext;
31
+ readonly #stopped = new AbortController();
30
32
 
31
33
  constructor(ctx: TeamContext) {
32
34
  this.#ctx = ctx;
33
35
  }
34
36
 
37
+ /** Ends the background avatar refresh: nothing is rendered, written or logged after it. */
38
+ stop(): void {
39
+ this.#stopped.abort();
40
+ }
41
+
35
42
  // ── Startup ────────────────────────────────────────────────────────────
36
43
 
37
44
  /**
38
45
  * Inserts the seed agents, gives every active agent a channel, archives agents and groups
39
46
  * whose channels were deleted while the assistant was down, and dresses agents without a
40
- * character in the background. Without Discord channels the agents live in rooms, which need no setup, so
47
+ * character in the background, then, with `refreshAvatars`, renders every agent's picture
48
+ * again. Without Discord channels the agents live in rooms, which need no setup, so
41
49
  * none is made or checked.
42
50
  */
43
51
  async start(): Promise<void> {
@@ -46,8 +54,25 @@ export class TeamLifecycle {
46
54
  if (added.length > 0) logger.info({ added }, "seed agents added");
47
55
  if (channels) await this.#startChannels(channels);
48
56
  this.#ctx.changed();
49
- void this.#dressMissing().then((dressed) => {
57
+ void this.#dressMissing().then(async (dressed) => {
58
+ if (this.#stopped.signal.aborted) return;
50
59
  if (dressed) this.#ctx.changed();
60
+ if (!this.#ctx.options.refreshAvatars) return;
61
+ // Never throws: a failure is counted per agent, and the start has already finished.
62
+ const { store, studio, logger } = this.#ctx.options;
63
+ const result = await refreshAvatars({
64
+ agents: () => store.agents(),
65
+ render: (character) => studio.render(character),
66
+ setAvatar: (name, character, avatarHash) =>
67
+ store.setAvatarIfLook(name, character, avatarHash),
68
+ signal: this.#stopped.signal,
69
+ logger,
70
+ }).catch((error: unknown) => {
71
+ logger.warn({ err: error }, "avatars not refreshed");
72
+ return undefined;
73
+ });
74
+ if (result && !this.#stopped.signal.aborted && result.rendered > 0)
75
+ this.#ctx.changed();
51
76
  });
52
77
  }
53
78
 
@@ -31,6 +31,12 @@ export interface AgentTeamOptions
31
31
  */
32
32
  studio: Pick<AvatarStudio, "url" | "render" | "draw" | "edit"> &
33
33
  Partial<Pick<AvatarStudio, "canDraw">>;
34
+ /**
35
+ * Render every active agent that has a character again when the team starts, and set the
36
+ * picture of those whose hash changed; default false. Runs in the background after the agents
37
+ * are dressed.
38
+ */
39
+ refreshAvatars?: boolean;
34
40
  /** The tools and groups the plugins give every agent, read before each agent turn. */
35
41
  pluginSelection: () => ToolSelection;
36
42
  models: AgentModels;
@@ -101,6 +101,11 @@ export interface AgentServerOptions {
101
101
  * `/avatars/<hash>.png`, which the surface that serves the pictures resolves against its origin.
102
102
  */
103
103
  avatarUrl?: string;
104
+ /**
105
+ * Render every agent's picture again at start (`characters.refreshAvatars`) and set it where
106
+ * its hash differs from the stored one; default false.
107
+ */
108
+ refreshAvatars?: boolean;
104
109
  /** The assistant's neutral avatar: the style reference and the picture of an agent without one. */
105
110
  avatarReference: string;
106
111
  /** The picture used instead when `avatarReference` cannot be read as one; the failure is logged. */
@@ -202,6 +207,7 @@ export function agentServerPlugin(
202
207
  if (!connection) hostsRooms = () => surfaces.roomSurfaces().length > 0;
203
208
  const studio = new FileAvatarStudio({
204
209
  dir: join(options.dataDir, "avatars"),
210
+ skins: context.env.characterSkins,
205
211
  ...(options.avatarUrl ? { publicUrl: options.avatarUrl } : {}),
206
212
  referencePath: options.avatarReference,
207
213
  ...(options.avatarNeutral
@@ -255,6 +261,7 @@ export function agentServerPlugin(
255
261
  ? { channels: connection.agentChannels(options.guildId) }
256
262
  : {}),
257
263
  studio,
264
+ ...(options.refreshAvatars ? { refreshAvatars: true } : {}),
258
265
  runtime: () => running,
259
266
  confirmations,
260
267
  scorer: new RelevanceScorer(judge, logger, options.owner),
@@ -313,6 +320,12 @@ export function agentServerPlugin(
313
320
  runtime: running,
314
321
  approvals: confirmations,
315
322
  avatars: studio,
323
+ workspace: {
324
+ workDir: options.workDir,
325
+ ...(agentSessions.scratchDir
326
+ ? { scratchDir: agentSessions.scratchDir }
327
+ : {}),
328
+ },
316
329
  });
317
330
  const attachmentDir = (channel: ChannelKey) =>
318
331
  ownerAttachmentDir(options.dataDir, channel);
@@ -348,6 +361,7 @@ export function agentServerPlugin(
348
361
  : []),
349
362
  {
350
363
  name: AGENT_TEAM_SERVICE,
364
+ stop: () => built.stop(),
351
365
  // The dashboard shows the team, so it starts once the team has; the error
352
366
  // reporter connects whether or not the team did, since it reports the failure.
353
367
  startInBackground: async () => {
@@ -18,8 +18,8 @@ export interface DiscordConfig {
18
18
  token: string;
19
19
  /** The agent server's guild id. */
20
20
  guild: string;
21
- /** The channel the coordinator lives in. */
22
- entryChannel: string;
21
+ /** The channel the coordinator lives in. Required unless the configuration says `agentServer: false`, which has no coordinator. */
22
+ entryChannel?: string;
23
23
  /** The root slash command, without the slash; default the lowercase assistant name. */
24
24
  rootCommand?: string;
25
25
  /**
@@ -92,7 +92,8 @@ export interface DiscordConfig {
92
92
  export interface ResolvedDiscord {
93
93
  token: string;
94
94
  guild: string;
95
- entryChannel: string;
95
+ /** Undefined only with `agentServer: false`. */
96
+ entryChannel?: string;
96
97
  rootCommand: string;
97
98
  admin: boolean;
98
99
  refusalHint?: string;
@@ -117,7 +118,9 @@ export function resolvedDiscord(
117
118
  return {
118
119
  token: discord.token,
119
120
  guild: discord.guild,
120
- entryChannel: discord.entryChannel,
121
+ ...(discord.entryChannel === undefined
122
+ ? {}
123
+ : { entryChannel: discord.entryChannel }),
121
124
  rootCommand,
122
125
  admin: discord.admin ?? true,
123
126
  ...(discord.refusalHint === undefined
@@ -170,7 +173,7 @@ const channelContextShape = shape({
170
173
  export const discordShape = shape({
171
174
  token: text,
172
175
  guild: text,
173
- entryChannel: text,
176
+ entryChannel: optional(text),
174
177
  rootCommand: optional(text),
175
178
  admin: optional(bool),
176
179
  refusalHint: optional(text),
@@ -41,3 +41,39 @@ export function checkAgentTeamKeys(
41
41
  "config skills: the skills are the agents', and this host has none: without discord they need agents. Configure agents or discord, or remove skills.",
42
42
  );
43
43
  }
44
+
45
+ /** The keys of the configuration, as written, that only the agent server uses. */
46
+ interface AgentOnlyKeys {
47
+ agents?: readonly unknown[];
48
+ skills?: unknown;
49
+ ops?: { agent?: string; conversation?: string };
50
+ discord?: {
51
+ agentMemory?: unknown;
52
+ agentSpeakers?: unknown;
53
+ attachmentDownload?: unknown;
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Refuses, for `agentServer: false`, what only the agent server uses, as the configuration
59
+ * writes it (not as defaults fill it): the seeds need the server to create them. An empty `agents`
60
+ * list and `skills: false` say nothing, so they pass.
61
+ */
62
+ export function checkAgentServerOff(config: AgentOnlyKeys): void {
63
+ const used: [string, boolean][] = [
64
+ ["agents", (config.agents?.length ?? 0) > 0],
65
+ ["skills", config.skills !== undefined && config.skills !== false],
66
+ ["ops.agent", config.ops?.agent !== undefined],
67
+ ["discord.agentMemory", config.discord?.agentMemory !== undefined],
68
+ ["discord.agentSpeakers", config.discord?.agentSpeakers !== undefined],
69
+ [
70
+ "discord.attachmentDownload",
71
+ config.discord?.attachmentDownload !== undefined,
72
+ ],
73
+ ];
74
+ const found = used.find(([, set]) => set);
75
+ if (found)
76
+ throw new ConfigError(
77
+ `config ${found[0]}: only the agent server uses it, and agentServer is false. Remove ${found[0]}, or remove agentServer to keep the agent server.`,
78
+ );
79
+ }
@@ -0,0 +1,138 @@
1
+ import type { InterimTextMode } from "../domain/interim.ts";
2
+ import { isLocale, type Locale } from "../i18n/index.ts";
3
+ import { type EffortPolicy, THINKING_LEVELS } from "../models.ts";
4
+ import type { RoundtablePlugin } from "../plugin.ts";
5
+ import type { Tier } from "../speakers.ts";
6
+ import { accessShape } from "./access.ts";
7
+ import { discordShape } from "./adapters.ts";
8
+ import { attachmentsShape } from "./attachments.ts";
9
+ import type { Pronouns } from "./config.ts";
10
+ import { limitsShape } from "./limits.ts";
11
+ import {
12
+ bool,
13
+ characterInput,
14
+ guarded,
15
+ integer,
16
+ list,
17
+ number,
18
+ oneOf,
19
+ optional,
20
+ record,
21
+ shape,
22
+ skinTones,
23
+ text,
24
+ } from "./schema.ts";
25
+ import { skillsField } from "./skills-config.ts";
26
+
27
+ /** The shape `roundtable.config.ts` is checked against: every key, its type and its bounds. */
28
+ const tierMembers = shape({
29
+ users: optional(list(text)),
30
+ roles: optional(list(text)),
31
+ everyone: optional(bool),
32
+ });
33
+
34
+ const isPolicy = (value: unknown): value is EffortPolicy =>
35
+ typeof value === "function";
36
+ const isPlugin = (value: unknown): value is RoundtablePlugin =>
37
+ typeof value === "object" &&
38
+ value !== null &&
39
+ typeof (value as { name?: unknown }).name === "string" &&
40
+ typeof (value as { setup?: unknown }).setup === "function";
41
+
42
+ export const configSchema = shape({
43
+ name: optional(text),
44
+ owner: optional(
45
+ shape({
46
+ id: text,
47
+ name: text,
48
+ pronouns: optional(oneOf<Pronouns>("he", "she", "they")),
49
+ }),
50
+ ),
51
+ discord: optional(discordShape),
52
+ agentServer: optional(
53
+ guarded(
54
+ "false, to leave the agent server out (leave the key out to keep it)",
55
+ (value): value is false => value === false,
56
+ ),
57
+ ),
58
+ database: shape({ url: text }),
59
+ dataDir: text,
60
+ attachments: attachmentsShape,
61
+ agentDir: optional(text),
62
+ model: text,
63
+ thinking: optional(oneOf(...THINKING_LEVELS)),
64
+ judge: optional(
65
+ shape({ model: optional(text), threshold: optional(number(0, 1)) }),
66
+ ),
67
+ effortPolicy: optional(
68
+ guarded("a function from a turn to its effort rule", isPolicy),
69
+ ),
70
+ delegation: optional(
71
+ shape({
72
+ model: optional(text),
73
+ thinking: optional(oneOf(...THINKING_LEVELS)),
74
+ }),
75
+ ),
76
+ locale: optional(
77
+ guarded("a locale, en or zh-TW", (value): value is Locale =>
78
+ isLocale(value),
79
+ ),
80
+ ),
81
+ timeZone: optional(text),
82
+ speakers: optional(
83
+ shape({ admins: optional(tierMembers), members: optional(tierMembers) }),
84
+ ),
85
+ access: optional(accessShape),
86
+ background: optional(
87
+ shape({
88
+ perPrincipal: optional(
89
+ shape({
90
+ schedules: optional(integer(1, 10_000)),
91
+ delegations: optional(integer(1, 1_000)),
92
+ }),
93
+ ),
94
+ }),
95
+ ),
96
+ toolTiers: optional(record(oneOf<Tier>("owner", "admin", "member"))),
97
+ prompts: optional(shape({ shared: text, guest: optional(text) })),
98
+ agents: optional(
99
+ list(
100
+ shape({
101
+ name: text,
102
+ displayName: text,
103
+ prompt: text,
104
+ character: optional(characterInput),
105
+ avatarPrompt: optional(text),
106
+ channelId: optional(text),
107
+ }),
108
+ ),
109
+ ),
110
+ coordinator: optional(text),
111
+ characters: optional(
112
+ shape({ skins: optional(skinTones), refreshAvatars: optional(bool) }),
113
+ ),
114
+ http: optional(
115
+ shape({
116
+ publicUrl: optional(text),
117
+ port: optional(integer(1, 65535)),
118
+ hostname: optional(text),
119
+ socketPath: optional(text),
120
+ socketMode: optional(integer(0, 0o777)),
121
+ }),
122
+ ),
123
+ avatar: optional(text),
124
+ workDir: optional(text),
125
+ scratchDir: optional(text),
126
+ skills: optional(skillsField),
127
+ memory: optional(bool),
128
+ ops: optional(shape({ agent: optional(text), conversation: optional(text) })),
129
+ drainSeconds: optional(integer(1, 3_600)),
130
+ ...limitsShape,
131
+ interimText: optional(oneOf<InterimTextMode>("on", "off")),
132
+ interimPrimaryChars: optional(integer(1, 100_000)),
133
+ plugins: optional(
134
+ list(
135
+ guarded("a plugin, an object with a name and a setup function", isPlugin),
136
+ ),
137
+ ),
138
+ });