pi-roundtable 0.22.5 → 0.23.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.
Files changed (129) hide show
  1. package/CHANGELOG.md +61 -1
  2. package/README.md +35 -9
  3. package/README.zh-TW.md +51 -13
  4. package/docs/migrating-0.8.md +2 -2
  5. package/docs/plugins.md +351 -28
  6. package/examples/discord-adapter.ts +9 -1
  7. package/examples/seeds.test.ts +2 -2
  8. package/examples/seeds.ts +1 -2
  9. package/package.json +6 -2
  10. package/src/characters/catalog.ts +1055 -0
  11. package/src/characters/characters.css +68 -0
  12. package/src/characters/colors.ts +108 -0
  13. package/src/characters/compose.ts +281 -0
  14. package/src/characters/css.ts +63 -0
  15. package/src/characters/index.ts +33 -0
  16. package/src/characters/parse.ts +196 -0
  17. package/src/characters/parts-face.ts +354 -0
  18. package/src/characters/parts-hair.ts +56 -0
  19. package/src/characters/parts-hats.ts +57 -0
  20. package/src/characters/parts-outfits.ts +81 -0
  21. package/src/characters/parts-props.ts +137 -0
  22. package/src/characters/spec.ts +224 -0
  23. package/src/characters/suggest.ts +101 -0
  24. package/src/cli/checks/characters.ts +23 -0
  25. package/src/cli/checks/images.ts +5 -3
  26. package/src/cli/doctor.ts +2 -0
  27. package/src/core/agents/agent-claim.ts +30 -11
  28. package/src/core/agents/agent-dashboard.ts +1 -1
  29. package/src/core/agents/agent-messages.ts +43 -15
  30. package/src/core/agents/agent-prompt.ts +2 -4
  31. package/src/core/agents/agent-rules.ts +42 -3
  32. package/src/core/agents/agent-schema.ts +37 -15
  33. package/src/core/agents/agent-store.ts +57 -13
  34. package/src/core/agents/agent-team-fixture.ts +90 -14
  35. package/src/core/agents/agent-team.ts +83 -50
  36. package/src/core/agents/agent-tools.ts +94 -41
  37. package/src/core/agents/avatar-studio.ts +40 -4
  38. package/src/core/agents/character-png.ts +40 -0
  39. package/src/core/agents/group-messages.ts +7 -4
  40. package/src/core/agents/group-turns.ts +27 -14
  41. package/src/core/agents/team-editing.ts +99 -58
  42. package/src/core/agents/team-keys.ts +177 -11
  43. package/src/core/agents/team-lifecycle.ts +151 -84
  44. package/src/core/agents/team-options.ts +13 -6
  45. package/src/core/agents/team-status.ts +31 -13
  46. package/src/core/agents/team-text.ts +13 -3
  47. package/src/core/agents/team-turn-types.ts +25 -7
  48. package/src/core/agents/team-turns.ts +143 -28
  49. package/src/core/attachments/attachment-fetcher.ts +162 -64
  50. package/src/core/attachments/prompt-block.ts +7 -1
  51. package/src/core/attachments/tool-attachment.ts +20 -1
  52. package/src/core/attachments/turn-attachments.ts +18 -2
  53. package/src/core/builtin/agent-server.ts +103 -40
  54. package/src/core/builtin/discord.ts +4 -17
  55. package/src/core/builtin/modules.ts +6 -5
  56. package/src/core/builtin/threads.ts +37 -0
  57. package/src/core/config/agent-team.ts +43 -0
  58. package/src/core/config/config.ts +50 -59
  59. package/src/core/config/limits.ts +93 -0
  60. package/src/core/config/ops.ts +34 -0
  61. package/src/core/config/schema.ts +26 -0
  62. package/src/core/contract/channels.ts +20 -0
  63. package/src/core/contract/surface.ts +77 -7
  64. package/src/core/define-roundtable.ts +70 -42
  65. package/src/core/discord/agent-commands.ts +108 -86
  66. package/src/core/discord/agent-panel.ts +11 -2
  67. package/src/core/discord/connection.ts +1 -1
  68. package/src/core/discord/discord-surface.ts +10 -1
  69. package/src/core/discord/dispatch-thread-host.ts +1 -1
  70. package/src/core/domain/conversation.ts +24 -0
  71. package/src/core/holds.ts +221 -15
  72. package/src/core/host-registration.ts +2 -1
  73. package/src/core/http/listeners.ts +63 -3
  74. package/src/core/i18n/agent-panel.ts +14 -21
  75. package/src/core/interactions/prompts.ts +12 -0
  76. package/src/core/modes/autonomy.ts +23 -0
  77. package/src/core/modes/conversation-modes.ts +213 -0
  78. package/src/core/modes/modes-plugin.ts +24 -0
  79. package/src/core/modules/delegation/delegator.ts +4 -4
  80. package/src/core/modules/host-shell/shell-arithmetic.ts +81 -0
  81. package/src/core/modules/host-shell/shell-lexer-types.ts +53 -0
  82. package/src/core/modules/host-shell/shell-lexer.ts +493 -0
  83. package/src/core/modules/host-shell/shell-paths.ts +85 -4
  84. package/src/core/modules/host-shell/shell-policy.ts +341 -332
  85. package/src/core/modules/host-shell/shell-programs.ts +298 -0
  86. package/src/core/modules/host-shell/shell-quoting.ts +237 -0
  87. package/src/core/modules/host-shell/shell-scripts.ts +114 -0
  88. package/src/core/modules/host-shell/shell-wrappers.ts +104 -0
  89. package/src/core/modules/schedules/precheck-tools.ts +17 -0
  90. package/src/core/modules/schedules/schedules.ts +9 -3
  91. package/src/core/plugin.ts +8 -1
  92. package/src/core/presence/direct-channels.ts +30 -0
  93. package/src/core/presentation/reply-body.ts +25 -0
  94. package/src/core/registry/contributions.ts +31 -7
  95. package/src/core/registry/providers.ts +1 -1
  96. package/src/core/routing/conversation-turns.ts +2 -15
  97. package/src/core/routing/surface-port.ts +5 -1
  98. package/src/core/runtime/extensions/approval-card.ts +1 -5
  99. package/src/core/runtime/extensions/approval-fields.ts +3 -7
  100. package/src/core/runtime/extensions/confirmation-gate-extension.ts +13 -2
  101. package/src/core/runtime/extensions/confirmation-gate.ts +41 -40
  102. package/src/core/runtime/extensions/gate-refusals.ts +23 -0
  103. package/src/core/runtime/extensions/held-calls.ts +16 -1
  104. package/src/core/runtime/extensions/hold-describer.ts +58 -8
  105. package/src/core/runtime/extensions/hold-lookups.ts +19 -19
  106. package/src/core/runtime/extensions/yolo.ts +105 -0
  107. package/src/core/runtime/pi-agent-runtime.ts +3 -5
  108. package/src/core/runtime/runtime-plugin.ts +42 -1
  109. package/src/core/runtime/runtime-types.ts +11 -0
  110. package/src/core/runtime/turn-budget.ts +32 -5
  111. package/src/core/runtime/turn-limits.ts +14 -3
  112. package/src/core/services.ts +61 -9
  113. package/src/core/shared/attachment-reader.ts +6 -0
  114. package/src/core/shared/unix-server.ts +10 -1
  115. package/src/core/testing/modules.ts +12 -8
  116. package/src/core/testing/surface-threads.ts +58 -0
  117. package/src/core/testing/thread-host.ts +17 -5
  118. package/src/core/threads/dispatch-threads.ts +299 -0
  119. package/src/core/threads/host-threads.ts +19 -0
  120. package/src/core/tool-set.snapshot.json +5 -0
  121. package/src/index.ts +27 -1
  122. package/src/kit/characters.ts +5 -0
  123. package/src/kit/index.ts +2 -0
  124. package/src/kit/shell.ts +1 -0
  125. package/src/kit/support.ts +1 -1
  126. package/src/kit/threads.ts +1 -1
  127. package/src/testing.ts +4 -0
  128. package/templates/agents.ts +1 -1
  129. package/src/core/discord/dispatch-threads.ts +0 -194
@@ -1,5 +1,11 @@
1
1
  import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
+ import {
4
+ CATALOG,
5
+ type CharacterInput,
6
+ CLASS_IDS,
7
+ type PaletteName,
8
+ } from "../../characters/index.ts";
3
9
  import { AgentError } from "../domain/errors.ts";
4
10
  import type { AgentTurnScope } from "../domain/ports.ts";
5
11
  import { assistantName, messages } from "../i18n/index.ts";
@@ -14,16 +20,17 @@ import {
14
20
  } from "../runtime/text-tools.ts";
15
21
  import { timeZone } from "../time.ts";
16
22
  import {
17
- MAX_AVATAR_PROMPT_CHARS,
18
23
  MAX_GROUP_MEMBERS,
19
24
  MAX_PROMPT_CHARS,
20
25
  MIN_GROUP_MEMBERS,
21
26
  } from "./agent-store.ts";
22
27
 
28
+ const PALETTE_NAMES = Object.keys(CATALOG.palettes) as PaletteName[];
29
+
23
30
  /** The tool that hands a request to another agent; a group seat has none. */
24
31
  export const MESSAGE_AGENT_TOOL = "message_agent";
25
32
 
26
- /** The tool that redraws an avatar; a host without an image provider does not offer it. */
33
+ /** The tool that changes an agent's look; every host offers it. */
27
34
  export const AVATAR_TOOL = "agent_avatar";
28
35
 
29
36
  export const AGENT_TOOLS = [
@@ -40,6 +47,10 @@ export const AGENT_TOOLS = [
40
47
  "channel_read",
41
48
  ] as const;
42
49
 
50
+ /**
51
+ * @deprecated Agents are composed characters and no longer drawn; use `AgentTeam.dress`.
52
+ * Removed in 1.0.
53
+ */
43
54
  export type AvatarMode = "redraw" | "new_prompt" | "edit";
44
55
 
45
56
  /** What the agent tools do; the agent team implements it. Each returns text for the model. */
@@ -61,14 +72,15 @@ export interface AgentOps {
61
72
  name: string;
62
73
  displayName: string;
63
74
  prompt: string;
64
- /** Absent on a host without an image provider. */
65
- avatarPrompt?: string;
75
+ /** Fields of its look; any left out come from the look its role suggests. */
76
+ character?: CharacterInput;
66
77
  task: string;
67
78
  category?: string;
68
79
  skills?: string[];
69
80
  },
70
81
  ): Promise<string>;
71
- avatar(name: string, mode: AvatarMode, text?: string): Promise<string>;
82
+ /** Changes an agent's look; a field left out stays as it is. */
83
+ avatar(name: string, look: CharacterInput): Promise<string>;
72
84
  /** Queues the message and returns at once. */
73
85
  message(caller: AgentTurnScope, to: string, text: string): string;
74
86
  createGroup(input: {
@@ -106,12 +118,35 @@ const PROMPT = Type.String({
106
118
  description:
107
119
  "The agent's role prompt, in English: its job, sources, boundaries, and how it reports. Short and stated positively.",
108
120
  });
109
- const avatarPrompt = () =>
110
- Type.String({
111
- minLength: 1,
112
- maxLength: MAX_AVATAR_PROMPT_CHARS,
113
- description: `The avatar is ${assistantName()}'s face drawn as an anime character; describe the role's exaggerated pose and expression, one telling prop, the outfit colours, and the background colour. Pick outfit and background colours unlike the other agents' (their avatar prompts are in agent_get).`,
114
- });
121
+ const enumOf = (ids: readonly string[], description?: string) =>
122
+ Type.String({ enum: [...ids], ...(description ? { description } : {}) });
123
+ const CLASS = enumOf(
124
+ CLASS_IDS,
125
+ `The agent's character class, which sets its outfit, hat, and prop. ${CLASS_IDS.map((id) => `${id}: ${CATALOG.classes[id].jobs}`).join("; ")}.`,
126
+ );
127
+ const COLOR = enumOf(
128
+ PALETTE_NAMES,
129
+ "The agent's color palette. Pick one unlike the other agents' (agent_get shows each look).",
130
+ );
131
+ const PARTS = Type.Object(
132
+ {
133
+ outfit: Type.Optional(enumOf(CATALOG.outfits)),
134
+ hat: Type.Optional(enumOf(CATALOG.hats)),
135
+ prop: Type.Optional(enumOf(CATALOG.props)),
136
+ hair: Type.Optional(enumOf(CATALOG.hair)),
137
+ beard: Type.Optional(enumOf([...CATALOG.beards, "none"])),
138
+ accessory: Type.Optional(enumOf([...CATALOG.accessories, "none"])),
139
+ build: Type.Optional(enumOf(["slim", "regular", "stout"])),
140
+ skin: Type.Optional(Type.Integer({ minimum: 0, maximum: 5 })),
141
+ hair_color: Type.Optional(
142
+ Type.String({
143
+ pattern: "^#[0-9a-fA-F]{6}$",
144
+ description: "A hex color such as #3b2a1a.",
145
+ }),
146
+ ),
147
+ },
148
+ { description: "Single parts to change; any left out stay as they are." },
149
+ );
115
150
  const category = (kind: "Agents" | "Groups") =>
116
151
  Type.String({
117
152
  description: `The category its channel opens under: ${kind} (the default) or ${kind}-<suffix>, such as ${kind}-work; made when missing. Keep to the categories agent_list shows unless a new one fits better.`,
@@ -122,12 +157,38 @@ const MEMBERS = Type.Array(Type.String(), {
122
157
  description: "Agent names in speaking-tie order.",
123
158
  });
124
159
 
160
+ const PART_FIELDS = {
161
+ outfit: "outfit",
162
+ hat: "hat",
163
+ prop: "prop",
164
+ hair: "hair",
165
+ beard: "beard",
166
+ accessory: "accessory",
167
+ build: "build",
168
+ skin: "skin",
169
+ hair_color: "hairColor",
170
+ } as const;
171
+
172
+ /** The look a tool call names: class, color as palette, and parts; nothing it leaves out. */
173
+ function lookOf(input: Record<string, unknown>): CharacterInput {
174
+ const look: Record<string, unknown> = {};
175
+ const klass = optionalString(input, "class");
176
+ const color = optionalString(input, "color");
177
+ if (klass) look.class = klass;
178
+ if (color) look.palette = color;
179
+ const parts = input.parts;
180
+ if (parts !== null && typeof parts === "object")
181
+ for (const [key, field] of Object.entries(PART_FIELDS)) {
182
+ const value = (parts as Record<string, unknown>)[key];
183
+ if (value !== undefined) look[field] = value;
184
+ }
185
+ return look as CharacterInput;
186
+ }
187
+
125
188
  type AgentToolDef = TextToolDef<(typeof AGENT_TOOLS)[number]>;
126
189
 
127
190
  /** What the agent tools offer, by what the host can do. */
128
191
  export interface AgentToolOptions {
129
- /** Whether an image provider is configured; without one there is no avatar prompt and no agent_avatar. */
130
- avatars: boolean;
131
192
  /** Whether the skills addon is on; without it agent_create takes no skills. */
132
193
  skills: boolean;
133
194
  }
@@ -136,7 +197,7 @@ function toolDefs(
136
197
  ops: AgentOps,
137
198
  scope: AgentTurnScope,
138
199
  owner: OwnerIdentity,
139
- { avatars, skills: skillsOn }: AgentToolOptions,
200
+ { skills: skillsOn }: AgentToolOptions,
140
201
  ): AgentToolDef[] {
141
202
  const o = ownerWords(owner);
142
203
  const defs: AgentToolDef[] = [
@@ -152,7 +213,7 @@ function toolDefs(
152
213
  name: "agent_get",
153
214
  label: "Read agent",
154
215
  description:
155
- "Read an agent's display name, status, channel, model, thinking level, skills, prompt, and avatar prompt.",
216
+ "Read an agent's display name, status, channel, model, thinking level, skills, look, and prompt.",
156
217
  parameters: Type.Object({ name: NAME }),
157
218
  run: (i) => ops.get(requiredString(i, "name")),
158
219
  },
@@ -195,7 +256,8 @@ function toolDefs(
195
256
  {
196
257
  name: "agent_create",
197
258
  label: "Create agent",
198
- description: `Create a new agent with its own channel and ${avatars ? "a drawn avatar" : "an avatar generated from its display name"}; it starts at once on the opening task in its channel. Its name is permanent.`,
259
+ description:
260
+ "Create a new agent with its own channel and a character picture; its look follows its role unless class and color are given. It starts at once on the opening task in its channel. Its name is permanent.",
199
261
  parameters: Type.Object({
200
262
  name: Type.String({
201
263
  pattern: "^[a-z0-9-]{1,32}$",
@@ -204,7 +266,8 @@ function toolDefs(
204
266
  }),
205
267
  display_name: DISPLAY,
206
268
  prompt: PROMPT,
207
- ...(avatars ? { avatar_prompt: avatarPrompt() } : {}),
269
+ class: Type.Optional(CLASS),
270
+ color: Type.Optional(COLOR),
208
271
  task: Type.String({
209
272
  minLength: 1,
210
273
  maxLength: 4000,
@@ -225,13 +288,12 @@ function toolDefs(
225
288
  run: (i) => {
226
289
  const category = optionalString(i, "category");
227
290
  const skills = stringList(i, "skills");
291
+ const look = lookOf(i);
228
292
  return ops.create(scope, {
229
293
  name: requiredString(i, "name"),
230
294
  displayName: requiredString(i, "display_name"),
231
295
  prompt: requiredString(i, "prompt"),
232
- ...(avatars
233
- ? { avatarPrompt: requiredString(i, "avatar_prompt") }
234
- : {}),
296
+ ...(Object.keys(look).length > 0 ? { character: look } : {}),
235
297
  task: requiredString(i, "task"),
236
298
  ...(category ? { category } : {}),
237
299
  ...(skills && skills.length > 0 ? { skills } : {}),
@@ -240,30 +302,21 @@ function toolDefs(
240
302
  },
241
303
  {
242
304
  name: AVATAR_TOOL,
243
- label: "Redraw avatar",
305
+ label: "Change look",
244
306
  description:
245
- "Redraw an agent's avatar: redraw from its stored avatar prompt; new_prompt with a new avatar prompt, which replaces the stored one; or edit the current picture by an instruction.",
307
+ "Change an agent's look: its class, color, or single parts. What you leave out stays as it is; a new class keeps the agent's colors unless you give a color. The new picture shows from its next message.",
246
308
  parameters: Type.Object({
247
309
  name: NAME,
248
- mode: Type.Union([
249
- Type.Literal("redraw"),
250
- Type.Literal("new_prompt"),
251
- Type.Literal("edit"),
252
- ]),
253
- text: Type.Optional(
254
- Type.String({
255
- maxLength: MAX_AVATAR_PROMPT_CHARS,
256
- description:
257
- "The new avatar prompt for new_prompt, or the instruction for edit.",
258
- }),
259
- ),
310
+ class: Type.Optional(CLASS),
311
+ color: Type.Optional(COLOR),
312
+ parts: Type.Optional(PARTS),
260
313
  }),
261
- run: (i) =>
262
- ops.avatar(
263
- requiredString(i, "name"),
264
- requiredString(i, "mode") as AvatarMode,
265
- optionalString(i, "text"),
266
- ),
314
+ run: (i) => {
315
+ const look = lookOf(i);
316
+ if (Object.keys(look).length === 0)
317
+ throw new AgentError("Give a class, a color, or parts to change.");
318
+ return ops.avatar(requiredString(i, "name"), look);
319
+ },
267
320
  },
268
321
  {
269
322
  name: "group_create",
@@ -391,7 +444,7 @@ function toolDefs(
391
444
  run: (i) =>
392
445
  ops.message(scope, requiredString(i, "to"), requiredString(i, "text")),
393
446
  });
394
- return avatars ? defs : defs.filter((def) => def.name !== AVATAR_TOOL);
447
+ return defs;
395
448
  }
396
449
 
397
450
  /** Registers the agent tools for one agent session. */
@@ -1,11 +1,15 @@
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";
5
+ import { resolveCharacter } from "../../characters/index.ts";
4
6
  import { squarePng } from "../attachments/square-png.ts";
5
7
  import type { ImageDrawer } from "../contract/providers.ts";
8
+ import { ConfigError } from "../domain/errors.ts";
6
9
  import { ProviderError } from "../errors.ts";
7
10
  import type { HttpRoute } from "../http/listeners.ts";
8
11
  import type { AvatarStudio } from "../services.ts";
12
+ import { characterPng } from "./character-png.ts";
9
13
  import { fallbackAvatar } from "./fallback-avatar.ts";
10
14
 
11
15
  const AVATAR_SIDE = 512;
@@ -19,14 +23,20 @@ export type { ImageDrawer };
19
23
  export interface AvatarStudioOptions {
20
24
  /** Where pictures are kept, one `<sha256>.png` each. */
21
25
  dir: string;
22
- /** Origin the pictures are served from, such as https://assistant.example.com. */
23
- publicUrl: string;
26
+ /**
27
+ * Origin the pictures are served from, such as https://assistant.example.com. Without one a
28
+ * picture's URL is the path `/avatars/<hash>.png`, which a surface that serves the pictures
29
+ * itself, through `serve`, resolves against its own origin.
30
+ */
31
+ publicUrl?: string;
24
32
  /** The assistant's neutral avatar: the style reference and the picture of an agent without one. */
25
33
  referencePath: string;
26
34
  /** The neutral avatar shipped with the package; the reference is replaced by it when the reference cannot be read as a picture. */
27
35
  neutralPath?: string;
28
36
  /** Told the error code of a reference that was replaced by `neutralPath`. */
29
37
  onReferenceFailed?: (code: string) => void;
38
+ /** Rasterizes a character to a PNG; canvas with librsvg by default. A seam for tests. */
39
+ rasterize?: typeof characterPng;
30
40
  /** Draws the pictures. Without one, the studio only makes each agent a picture from its name. */
31
41
  draw?: ImageDrawer;
32
42
  }
@@ -40,15 +50,30 @@ export class FileAvatarStudio implements AvatarStudio {
40
50
  readonly #options: AvatarStudioOptions;
41
51
  #defaultHash: string | undefined;
42
52
  #referencePath: string;
53
+ readonly #rasterize: typeof characterPng;
43
54
 
44
55
  constructor(options: AvatarStudioOptions) {
45
56
  this.#options = options;
57
+ this.#rasterize = options.rasterize ?? characterPng;
46
58
  this.#referencePath = options.referencePath;
47
59
  mkdirSync(options.dir, { recursive: true });
48
60
  }
49
61
 
50
- /** Stores the assistant's neutral avatar, the picture of an agent that has none yet. */
62
+ /**
63
+ * Stores the assistant's neutral avatar, the picture of an agent that has none yet. First
64
+ * rasterizes a probe character: the agents' pictures need canvas with SVG support (librsvg),
65
+ * and a host without it refuses to start instead of failing on the first agent.
66
+ */
51
67
  async init(): Promise<void> {
68
+ try {
69
+ await this.#rasterize(resolveCharacter({ class: "wizard" }), {
70
+ size: 32,
71
+ });
72
+ } catch (error) {
73
+ throw new ConfigError(
74
+ `the agent server needs canvas with SVG support (librsvg) to draw the agents' pictures: ${error instanceof Error ? error.message : String(error)}`,
75
+ );
76
+ }
52
77
  try {
53
78
  this.#defaultHash = await this.#referenceStore();
54
79
  } catch (error) {
@@ -81,10 +106,17 @@ export class FileAvatarStudio implements AvatarStudio {
81
106
  url(hash: string | undefined): string {
82
107
  const chosen = hash ?? this.#defaultHash;
83
108
  if (!chosen) throw new Error("the avatar studio was not initialized");
84
- return `${this.#options.publicUrl}/avatars/${chosen}.png`;
109
+ return `${this.#options.publicUrl ?? ""}/avatars/${chosen}.png`;
110
+ }
111
+
112
+ /** Renders a character as the agent's picture and stores it by content hash; the same spec gives the same hash. */
113
+ async render(spec: CharacterSpec): Promise<string> {
114
+ return this.#keep(await this.#rasterize(spec, { size: AVATAR_SIDE }));
85
115
  }
86
116
 
87
117
  /**
118
+ * @deprecated Agents are composed characters and no longer drawn; removed in 1.0.
119
+ *
88
120
  * Draws a new picture from an avatar prompt: the assistant's face as an anime character in the
89
121
  * role's pose; returns its hash. Outfit and background colours come from the avatar prompt,
90
122
  * so agents stay easy to tell apart.
@@ -180,6 +212,10 @@ export class FileAvatarStudio implements AvatarStudio {
180
212
  size: AVATAR_SIDE,
181
213
  ...(maxSide === undefined ? {} : { maxSide }),
182
214
  });
215
+ return this.#keep(png);
216
+ }
217
+
218
+ #keep(png: Uint8Array): string {
183
219
  const hash = createHash("sha256").update(png).digest("hex");
184
220
  const path = this.#path(hash);
185
221
  if (!existsSync(path)) writeFileSync(path, png);
@@ -0,0 +1,40 @@
1
+ import { createCanvas, loadImage } from "canvas";
2
+ import {
3
+ type CharacterSpec,
4
+ type ComposeOptions,
5
+ composeSvg,
6
+ STATE_EXPRESSION,
7
+ } from "../../characters/index.ts";
8
+
9
+ const PNG_SIDE = 512;
10
+
11
+ /** What a Discord avatar shows: the idle face on the modern light disc. */
12
+ const AVATAR_OPTIONS: ComposeOptions = {
13
+ look: "modern-light",
14
+ frame: "disc",
15
+ expr: STATE_EXPRESSION.idle,
16
+ state: "idle",
17
+ still: true,
18
+ };
19
+
20
+ /**
21
+ * A character as a PNG: the composer's SVG, rasterized by canvas (librsvg). The SVG is composer
22
+ * output only, never text from a user or an agent. Each picture on a page needs its own `uid`,
23
+ * so this passes one explicitly.
24
+ */
25
+ export async function characterPng(
26
+ spec: CharacterSpec,
27
+ options: ComposeOptions = {},
28
+ ): Promise<Uint8Array> {
29
+ const size = options.size ?? PNG_SIDE;
30
+ const svg = composeSvg(spec, {
31
+ ...AVATAR_OPTIONS,
32
+ uid: "png",
33
+ ...options,
34
+ size,
35
+ });
36
+ const image = await loadImage(Buffer.from(svg));
37
+ const canvas = createCanvas(size, size);
38
+ canvas.getContext("2d").drawImage(image, 0, 0, size, size);
39
+ return canvas.toBuffer("image/png");
40
+ }
@@ -75,15 +75,18 @@ export class GroupMessages {
75
75
  DO UPDATE SET last_id = GREATEST(agent_group_cursors.last_id, EXCLUDED.last_id)`;
76
76
  }
77
77
 
78
- /** Marks every message so far as received by every member, for a group started over. */
79
- async catchUp(group: AgentGroup): Promise<void> {
78
+ /**
79
+ * Marks every message so far as received by every member, for a group started over; `log` is
80
+ * the conversation's log, the group's own name unless it is a room's.
81
+ */
82
+ async catchUp(group: AgentGroup, log: string = group.name): Promise<void> {
80
83
  const rows: { id: string | number | null }[] = await this.#sql`
81
84
  SELECT max(id) AS id FROM agent_group_messages
82
- WHERE guild_id = ${this.#guild} AND group_name = ${group.name}`;
85
+ WHERE guild_id = ${this.#guild} AND group_name = ${log}`;
83
86
  const last = rows[0]?.id;
84
87
  if (last === null || last === undefined) return;
85
88
  for (const member of group.members)
86
- await this.advanceCursor(group.name, member, Number(last));
89
+ await this.advanceCursor(log, member, Number(last));
87
90
  }
88
91
 
89
92
  /**
@@ -4,14 +4,19 @@ import { AgentError } from "../domain/errors.ts";
4
4
  import { messages } from "../i18n/index.ts";
5
5
  import type { Speaker } from "../speakers.ts";
6
6
  import { groupTurnText } from "./agent-prompt.ts";
7
- import type { Agent } from "./agent-store.ts";
7
+ import type { Agent, AgentGroup } from "./agent-store.ts";
8
8
  import {
9
9
  MAX_ROUND_REPLIES,
10
10
  mentionedMembers,
11
11
  planRound,
12
12
  RECENT_FOR_SCORING,
13
13
  } from "./group-round.ts";
14
- import { channelIdOf, groupScope, groupSessionKey } from "./team-keys.ts";
14
+ import {
15
+ groupLogName,
16
+ groupOfChannel,
17
+ groupScope,
18
+ groupSessionKey,
19
+ } from "./team-keys.ts";
15
20
  import {
16
21
  BACKLOG_LIMIT,
17
22
  type Chain,
@@ -27,6 +32,15 @@ export class GroupTurns {
27
32
  this.#host = host;
28
33
  }
29
34
 
35
+ /** The active group whose Discord channel or room the key is. */
36
+ #groupOf(channel: ChannelKey): AgentGroup {
37
+ const { store, surfaces } = this.#host.options;
38
+ const group = groupOfChannel(store, surfaces, channel);
39
+ if (group?.status !== "active")
40
+ throw new AgentError(`no group owns ${channel}`);
41
+ return group;
42
+ }
43
+
30
44
  /**
31
45
  * One owner message in a group channel: scored, then answered by the chosen members one at
32
46
  * a time, each seeing every group message since its last turn. Call inside the queue.
@@ -41,16 +55,15 @@ export class GroupTurns {
41
55
  ): Promise<void> {
42
56
  const { store, scorer, confirmations, logger } = this.#host.options;
43
57
  const runtime = this.#host.options.runtime();
44
- const group = store.groupByChannel(channelIdOf(channel));
45
- if (!group) throw new AgentError(`no group owns ${channel}`);
58
+ const group = this.#groupOf(channel);
59
+ // Each conversation of a group keeps its own log: the Discord channel's under the group's
60
+ // name, a room's under `<group>@<surface>`.
61
+ const log = groupLogName(group, channel);
46
62
  const members = group.members
47
63
  .map((name) => store.agent(name))
48
64
  .filter((m): m is Agent => m?.status === "active");
49
- const recent = await store.recentGroupMessages(
50
- group.name,
51
- RECENT_FOR_SCORING,
52
- );
53
- await store.appendGroupMessage(group.name, {
65
+ const recent = await store.recentGroupMessages(log, RECENT_FOR_SCORING);
66
+ await store.appendGroupMessage(log, {
54
67
  author: speaker.tier === "owner" ? "owner" : `human:${speaker.id}`,
55
68
  authorName:
56
69
  speaker.tier === "owner" ? this.#host.options.owner.name : speaker.name,
@@ -63,7 +76,7 @@ export class GroupTurns {
63
76
  const approvals = new Map<string, boolean>();
64
77
  for (const member of members) {
65
78
  const pending = await runtime.heldActions(
66
- groupSessionKey(group, member.name),
79
+ groupSessionKey(group, member.name, channel),
67
80
  );
68
81
  // Only a speaker whose tier holds the held tools may approve or wake a seat for them.
69
82
  if (!pending || !this.#host.mayApprove(speaker, pending)) continue;
@@ -100,10 +113,10 @@ export class GroupTurns {
100
113
  if (member?.status !== "active") continue;
101
114
  const first = !spoken.has(name);
102
115
  spoken.add(name);
103
- const backlog = await store.backlog(group.name, name, BACKLOG_LIMIT);
116
+ const backlog = await store.backlog(log, name, BACKLOG_LIMIT);
104
117
  // Delivered once the session has the text, so a message never comes twice.
105
118
  if (backlog.lastId !== undefined)
106
- await store.advanceCursor(group.name, name, backlog.lastId);
119
+ await store.advanceCursor(log, name, backlog.lastId);
107
120
  // Attachments and a confirmation belong to a member's first turn of the round only.
108
121
  const extra: TurnExtra = { interactive: true };
109
122
  if (first) {
@@ -112,7 +125,7 @@ export class GroupTurns {
112
125
  }
113
126
  const result = await this.#host.turn(
114
127
  member,
115
- groupScope(member, group),
128
+ groupScope(member, group, channel),
116
129
  channel,
117
130
  groupTurnText(group, member, backlog),
118
131
  chain,
@@ -121,7 +134,7 @@ export class GroupTurns {
121
134
  replies += 1;
122
135
  last = member;
123
136
  if (!result.ok) continue;
124
- await store.appendGroupMessage(group.name, {
137
+ await store.appendGroupMessage(log, {
125
138
  author: name,
126
139
  authorName: this.#host.current(member).displayName,
127
140
  text: result.text,