pi-roundtable 0.1.0 → 0.2.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 (239) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/README.md +10 -6
  3. package/README.zh-TW.md +144 -0
  4. package/docs/plugins.md +1448 -66
  5. package/examples/echo-runtime.test.ts +122 -0
  6. package/examples/echo-runtime.ts +107 -0
  7. package/examples/events.test.ts +1 -0
  8. package/examples/events.ts +2 -2
  9. package/examples/fake-surface.test.ts +180 -0
  10. package/examples/fake-surface.ts +109 -0
  11. package/examples/interactions.test.ts +12 -3
  12. package/examples/interactions.ts +19 -19
  13. package/examples/shared-services.test.ts +74 -0
  14. package/examples/shared-services.ts +60 -0
  15. package/examples/study-room.test.ts +69 -0
  16. package/examples/study-room.ts +52 -0
  17. package/examples/support-desk.test.ts +34 -0
  18. package/examples/support-desk.ts +40 -0
  19. package/package.json +11 -3
  20. package/src/cli/checks/database.ts +11 -30
  21. package/src/cli/checks/images.ts +19 -0
  22. package/src/cli/doctor.ts +6 -0
  23. package/src/cli/main.ts +2 -1
  24. package/src/core/agents/agent-claim.ts +42 -16
  25. package/src/core/agents/agent-messages.ts +4 -4
  26. package/src/core/agents/agent-ports.ts +8 -21
  27. package/src/core/agents/agent-prompt.ts +3 -1
  28. package/src/core/agents/agent-store.ts +6 -4
  29. package/src/core/agents/agent-team-fixture.ts +389 -0
  30. package/src/core/agents/agent-team.ts +26 -25
  31. package/src/core/agents/agent-tools.ts +34 -15
  32. package/src/core/agents/avatar-studio.ts +40 -6
  33. package/src/core/agents/fallback-avatar.ts +85 -0
  34. package/src/core/agents/team-editing.ts +49 -10
  35. package/src/core/agents/team-keys.ts +22 -9
  36. package/src/core/agents/team-layout.ts +4 -3
  37. package/src/core/agents/team-lifecycle.ts +28 -2
  38. package/src/core/agents/team-options.ts +9 -5
  39. package/src/core/agents/team-status.ts +4 -4
  40. package/src/core/agents/team-text.ts +8 -6
  41. package/src/core/agents/team-turn-types.ts +4 -4
  42. package/src/core/agents/team-turns.ts +6 -5
  43. package/src/core/builtin/agent-server.ts +187 -121
  44. package/src/core/builtin/discord-admin.ts +41 -0
  45. package/src/core/builtin/discord.ts +65 -40
  46. package/src/core/builtin/modules.ts +67 -58
  47. package/src/core/builtin/session-tool.ts +24 -0
  48. package/src/core/builtin/skills.ts +72 -0
  49. package/src/core/builtin/stores.ts +55 -31
  50. package/src/core/config/config.ts +45 -4
  51. package/src/core/config/schema.ts +6 -0
  52. package/src/core/contract/channels.ts +54 -14
  53. package/src/core/contract/providers.ts +17 -0
  54. package/src/core/contract/runtime.ts +138 -0
  55. package/src/core/contract/services.ts +45 -0
  56. package/src/core/contract/surface.ts +88 -0
  57. package/src/core/db/migrations.ts +118 -3
  58. package/src/core/define-roundtable.ts +70 -46
  59. package/src/core/define.ts +2 -1
  60. package/src/core/discord/agent-commands.ts +39 -14
  61. package/src/core/discord/agent-panel.ts +66 -0
  62. package/src/core/discord/channel-executor.ts +6 -3
  63. package/src/core/discord/channel-operations.ts +14 -11
  64. package/src/core/discord/command-collection.ts +46 -0
  65. package/src/core/{registry/interactions.ts → discord/compose-commands.ts} +5 -5
  66. package/src/core/discord/connection.ts +35 -0
  67. package/src/core/discord/discord-surface.ts +32 -94
  68. package/src/core/discord/dispatch-threads.ts +2 -2
  69. package/src/core/discord/inbound-message.ts +99 -0
  70. package/src/core/discord/interaction-module.ts +57 -1
  71. package/src/core/discord/owner-cards.ts +4 -4
  72. package/src/core/discord/owner-command.ts +44 -11
  73. package/src/core/discord/owner-discord.ts +2 -2
  74. package/src/core/discord/schedule-commands.ts +21 -18
  75. package/src/core/discord/stop-button.ts +44 -1
  76. package/src/core/domain/attachment.ts +12 -8
  77. package/src/core/domain/conversation.ts +3 -4
  78. package/src/core/domain/owner-prompts.ts +5 -5
  79. package/src/core/domain/ports.ts +9 -53
  80. package/src/core/freeze.ts +9 -0
  81. package/src/core/host.ts +255 -82
  82. package/src/core/http/listeners.ts +55 -20
  83. package/src/core/i18n/agent-panel.ts +4 -0
  84. package/src/core/i18n/index.ts +12 -2
  85. package/src/core/i18n/owner.ts +7 -1
  86. package/src/core/i18n/schedules.ts +12 -6
  87. package/src/core/identity.ts +1 -1
  88. package/src/core/judging/effort-judge.ts +25 -3
  89. package/src/core/log.ts +21 -3
  90. package/src/core/models.ts +2 -2
  91. package/src/core/modules/background/background-turns.ts +6 -4
  92. package/src/core/modules/delegation/delegate.ts +3 -2
  93. package/src/core/modules/delegation/delegator.ts +15 -13
  94. package/src/core/modules/delegation/{sol-worker.ts → web-research-worker.ts} +6 -6
  95. package/src/core/modules/discord-admin/discord-admin.ts +6 -6
  96. package/src/core/modules/host-shell/shell-policy.ts +8 -3
  97. package/src/core/modules/memory/owner-memory-store.ts +27 -25
  98. package/src/core/modules/memory/owner-memory.ts +9 -13
  99. package/src/core/modules/schedules/recurrence.ts +5 -3
  100. package/src/core/modules/schedules/schedule-store.ts +11 -11
  101. package/src/core/modules/schedules/schedule-tools.ts +22 -20
  102. package/src/core/modules/schedules/scheduler.ts +7 -2
  103. package/src/core/modules/schedules/schedules.ts +9 -3
  104. package/src/core/modules/skills/skill-registry.ts +3 -2
  105. package/src/core/modules/skills/skill-store.ts +2 -15
  106. package/src/core/modules/skills/skill-tools.ts +10 -4
  107. package/src/core/ops/error-reporter.ts +1 -1
  108. package/src/core/plugin.ts +232 -28
  109. package/src/core/registry/contributions.ts +173 -14
  110. package/src/core/registry/providers.ts +35 -5
  111. package/src/core/registry/services.ts +229 -0
  112. package/src/core/routing/channel-queue.ts +5 -0
  113. package/src/core/routing/channel-router.ts +21 -7
  114. package/src/core/routing/conversation-turns.ts +139 -0
  115. package/src/core/routing/message-text.ts +9 -2
  116. package/src/core/routing/surface-port.ts +39 -0
  117. package/src/core/runtime/conversation-sessions.ts +4 -3
  118. package/src/core/runtime/extensions/confirmation-fixture.ts +11 -0
  119. package/src/core/runtime/extensions/confirmation-gate.ts +11 -9
  120. package/src/core/runtime/mcp.ts +1 -1
  121. package/src/core/runtime/pending-confirmation-store.ts +10 -12
  122. package/src/core/runtime/pi-agent-runtime.ts +9 -6
  123. package/src/core/runtime/prompt-slot.ts +8 -3
  124. package/src/core/runtime/runtime-types.ts +9 -35
  125. package/src/core/runtime/session-factory.ts +21 -5
  126. package/src/core/runtime/text-tools.ts +1 -3
  127. package/src/core/services.ts +238 -90
  128. package/src/core/sessions.ts +3 -2
  129. package/src/core/shared/{profile-tools.ts → active-tools.ts} +2 -2
  130. package/src/core/shared/delegate-tool.ts +4 -3
  131. package/src/core/shared/schedule-tools.ts +22 -13
  132. package/src/core/shared/session-messages.ts +1 -1
  133. package/src/core/speakers.ts +6 -38
  134. package/src/core/testing/database.ts +1 -7
  135. package/src/core/testing/eager-catalog.ts +30 -0
  136. package/src/core/testing/locale.ts +23 -5
  137. package/src/core/testing/modules.ts +105 -41
  138. package/src/core/testing/test-host.ts +279 -0
  139. package/src/core/testing/tool-set.ts +64 -0
  140. package/src/core/time.ts +6 -1
  141. package/src/core/tool-set.snapshot.json +288 -0
  142. package/src/core/tool-tiers.ts +17 -57
  143. package/src/discord/index.ts +64 -0
  144. package/src/index.ts +181 -10
  145. package/src/kit/channels.ts +14 -0
  146. package/src/kit/domain.ts +18 -0
  147. package/src/kit/holds.ts +4 -0
  148. package/src/kit/index.ts +129 -0
  149. package/src/kit/judging.ts +10 -0
  150. package/src/kit/memory.ts +3 -0
  151. package/src/kit/mirror.ts +19 -0
  152. package/src/kit/presentation.ts +7 -0
  153. package/src/kit/shell.ts +7 -0
  154. package/src/kit/skills.ts +8 -0
  155. package/src/kit/support.ts +21 -0
  156. package/src/kit/threads.ts +7 -0
  157. package/src/kit/tools.ts +14 -0
  158. package/src/kit/worker.ts +16 -0
  159. package/src/testing.ts +394 -34
  160. package/examples/guide.test.ts +0 -52
  161. package/src/cli/add-plugin.test.ts +0 -107
  162. package/src/cli/checks/basic.test.ts +0 -276
  163. package/src/cli/checks/database.test.ts +0 -117
  164. package/src/cli/checks/discord.test.ts +0 -200
  165. package/src/cli/cli.test.ts +0 -122
  166. package/src/cli/config-edit.test.ts +0 -130
  167. package/src/cli/doctor.test.ts +0 -184
  168. package/src/cli/init.test.ts +0 -127
  169. package/src/cli/size.test.ts +0 -9
  170. package/src/cli/templates.test.ts +0 -129
  171. package/src/cli/testing/fixtures.ts +0 -106
  172. package/src/core/agents/agent-claim.test.ts +0 -142
  173. package/src/core/agents/agent-dashboard.test.ts +0 -120
  174. package/src/core/agents/agent-guild.test.ts +0 -124
  175. package/src/core/agents/agent-store.test.ts +0 -205
  176. package/src/core/agents/avatar-studio.test.ts +0 -88
  177. package/src/core/agents/group-round.test.ts +0 -143
  178. package/src/core/agents/owner-identity.test.ts +0 -172
  179. package/src/core/agents/team-turns.test.ts +0 -278
  180. package/src/core/attachments/attachments.test.ts +0 -85
  181. package/src/core/boundary.test.ts +0 -45
  182. package/src/core/builtin/modules.test.ts +0 -127
  183. package/src/core/config/config.test.ts +0 -135
  184. package/src/core/contract/discord.ts +0 -33
  185. package/src/core/db/migrations.test.ts +0 -237
  186. package/src/core/define-roundtable.test.ts +0 -134
  187. package/src/core/define.test.ts +0 -144
  188. package/src/core/discord/agent-commands.test.ts +0 -58
  189. package/src/core/discord/agent-discord.test.ts +0 -70
  190. package/src/core/discord/dispatch-thread-host.test.ts +0 -114
  191. package/src/core/discord/dispatch-threads.test.ts +0 -94
  192. package/src/core/discord/owner-cards.test.ts +0 -435
  193. package/src/core/discord/owner-discord.test.ts +0 -325
  194. package/src/core/domain/expression.ts +0 -21
  195. package/src/core/domain/profile.ts +0 -32
  196. package/src/core/drain.test.ts +0 -46
  197. package/src/core/events.test.ts +0 -81
  198. package/src/core/holds.test.ts +0 -54
  199. package/src/core/host.test.ts +0 -536
  200. package/src/core/http/listeners.test.ts +0 -164
  201. package/src/core/i18n/i18n.test.ts +0 -139
  202. package/src/core/identity.test.ts +0 -18
  203. package/src/core/judging/effort-judge.test.ts +0 -112
  204. package/src/core/judging/model-judge.test.ts +0 -126
  205. package/src/core/modules/delegation/delegator.test.ts +0 -170
  206. package/src/core/modules/memory/owner-memory-store.test.ts +0 -244
  207. package/src/core/modules/schedules/schedule.test.ts +0 -358
  208. package/src/core/modules/skills/skill-kind.test.ts +0 -55
  209. package/src/core/modules/skills/skill-registry.test.ts +0 -376
  210. package/src/core/ops/error-reporter.test.ts +0 -283
  211. package/src/core/presentation/card-cadence.ts +0 -41
  212. package/src/core/presentation/presentation.test.ts +0 -166
  213. package/src/core/public-entry.test.ts +0 -59
  214. package/src/core/registry/contributions.test.ts +0 -215
  215. package/src/core/registry/interactions.test.ts +0 -87
  216. package/src/core/registry/providers.test.ts +0 -103
  217. package/src/core/routing/channel-queue.test.ts +0 -31
  218. package/src/core/routing/channel-router.test.ts +0 -326
  219. package/src/core/routing/conversation-kind.ts +0 -14
  220. package/src/core/routing/settle-turn.test.ts +0 -21
  221. package/src/core/runtime/compaction-tiers.test.ts +0 -227
  222. package/src/core/runtime/extensions/ask-user.test.ts +0 -114
  223. package/src/core/runtime/extensions/self-compact-guard.test.ts +0 -50
  224. package/src/core/runtime/pending-confirmation-store.test.ts +0 -52
  225. package/src/core/runtime/session-archive.test.ts +0 -20
  226. package/src/core/runtime/steerable-run.test.ts +0 -264
  227. package/src/core/runtime/text-tools.test.ts +0 -110
  228. package/src/core/runtime/turn-answer.test.ts +0 -72
  229. package/src/core/runtime/worker-task.test.ts +0 -82
  230. package/src/core/services.test.ts +0 -28
  231. package/src/core/sessions.test.ts +0 -85
  232. package/src/core/shared/unix-server.ts +0 -18
  233. package/src/core/size.test.ts +0 -9
  234. package/src/core/speakers.test.ts +0 -78
  235. package/src/core/testing/file-size.ts +0 -34
  236. package/src/core/time.test.ts +0 -166
  237. package/src/core/tool-tiers.test.ts +0 -117
  238. package/src/entries.test.ts +0 -112
  239. package/src/testing.test.ts +0 -156
@@ -3,7 +3,10 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { createCanvas, loadImage } from "canvas";
5
5
  import type { ImageDrawer } from "../contract/providers.ts";
6
+ import { ProviderError } from "../errors.ts";
6
7
  import type { HttpRoute } from "../http/listeners.ts";
8
+ import type { AvatarStudio } from "../services.ts";
9
+ import { fallbackAvatar } from "./fallback-avatar.ts";
7
10
 
8
11
  const AVATAR_SIDE = 512;
9
12
  const REFERENCE_SIDE = 512;
@@ -18,7 +21,8 @@ export interface AvatarStudioOptions {
18
21
  publicUrl: string;
19
22
  /** The assistant's neutral avatar: the style reference and the picture of an agent without one. */
20
23
  referencePath: string;
21
- draw: ImageDrawer;
24
+ /** Draws the pictures. Without one, the studio only makes each agent a picture from its name. */
25
+ draw?: ImageDrawer;
22
26
  }
23
27
 
24
28
  /** A centered square crop scaled to at most `side` pixels, as PNG. */
@@ -44,10 +48,11 @@ async function squarePng(bytes: Uint8Array, side: number): Promise<Buffer> {
44
48
  }
45
49
 
46
50
  /**
47
- * Agent avatars: Codex draws them in the assistant's style, and they are kept and served by content
48
- * hash, so a new picture has a new URL and Discord shows it at once.
51
+ * Agent avatars: an image provider draws them in the assistant's style, and without one each agent
52
+ * gets a picture generated from its display name. They are kept and served by content hash, so a
53
+ * new picture has a new URL and Discord shows it at once.
49
54
  */
50
- export class AvatarStudio {
55
+ export class FileAvatarStudio implements AvatarStudio {
51
56
  readonly #options: AvatarStudioOptions;
52
57
  #defaultHash: string | undefined;
53
58
 
@@ -63,6 +68,11 @@ export class AvatarStudio {
63
68
  );
64
69
  }
65
70
 
71
+ /** Whether an image provider is configured; without one, only `fallback` makes pictures. */
72
+ get canDraw(): boolean {
73
+ return this.#options.draw !== undefined;
74
+ }
75
+
66
76
  /** The public URL of a picture, or of the default when there is none. */
67
77
  url(hash: string | undefined): string {
68
78
  const chosen = hash ?? this.#defaultHash;
@@ -76,11 +86,12 @@ export class AvatarStudio {
76
86
  * so agents stay easy to tell apart.
77
87
  */
78
88
  async draw(avatarPrompt: string): Promise<string> {
89
+ const draw = this.#drawer();
79
90
  const reference = await squarePng(
80
91
  readFileSync(this.#options.referencePath),
81
92
  REFERENCE_SIDE,
82
93
  );
83
- const bytes = await this.#options.draw(
94
+ const bytes = await draw(
84
95
  `Draw a square Discord avatar of the person in the reference image, redrawn as an anime character: keep their face, short dark hair, and features recognizable as the same person, but in vivid anime cel-shading with a big, exaggerated expression and a dramatic pose that shows the role. Dress them and paint the background exactly in the colours the description names; ignore the reference's green jacket and teal background. Head-and-shoulders framing that still reads at 40 px. No text, no border. The role: ${avatarPrompt}`,
85
96
  [{ data: reference.toString("base64"), mimeType: "image/png" }],
86
97
  );
@@ -89,16 +100,31 @@ export class AvatarStudio {
89
100
 
90
101
  /** Edits a stored picture by an instruction; returns the new picture's hash. */
91
102
  async edit(hash: string | undefined, instruction: string): Promise<string> {
103
+ const draw = this.#drawer();
92
104
  const current = readFileSync(
93
105
  this.#path(hash ?? this.#defaultHashOrThrow()),
94
106
  );
95
- const bytes = await this.#options.draw(
107
+ const bytes = await draw(
96
108
  `Edit this Discord avatar: ${instruction}. Keep everything else, the style, and the square head-and-shoulders framing the same. No text, no border.`,
97
109
  [{ data: current.toString("base64"), mimeType: "image/png" }],
98
110
  );
99
111
  return this.#store(bytes);
100
112
  }
101
113
 
114
+ /**
115
+ * A picture made from an agent's display name, its name, and the assistant's icon, for a host without an
116
+ * image provider; the same name always gives the same picture. Returns its hash.
117
+ */
118
+ async fallback(displayName: string, name: string): Promise<string> {
119
+ return this.#store(
120
+ await fallbackAvatar(
121
+ displayName,
122
+ readFileSync(this.#options.referencePath),
123
+ name,
124
+ ),
125
+ );
126
+ }
127
+
102
128
  /** Avatar pictures, public on the listener that cloudflared forwards the public hostname to. */
103
129
  route(listener: string): HttpRoute {
104
130
  return {
@@ -127,6 +153,14 @@ export class AvatarStudio {
127
153
  });
128
154
  }
129
155
 
156
+ #drawer(): ImageDrawer {
157
+ if (!this.#options.draw)
158
+ throw new ProviderError(
159
+ "no image provider is configured, so avatars cannot be drawn",
160
+ );
161
+ return this.#options.draw;
162
+ }
163
+
130
164
  #defaultHashOrThrow(): string {
131
165
  if (!this.#defaultHash)
132
166
  throw new Error("the avatar studio was not initialized");
@@ -0,0 +1,85 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createCanvas, loadImage } from "canvas";
3
+
4
+ const SIDE = 512;
5
+ /** The icon sits in a disc, with a badge overlapping its lower right. */
6
+ const ICON_RADIUS = 176;
7
+ const ICON_CENTER = { x: 256, y: 232 };
8
+ const BADGE = { x: 396, y: 396, radius: 64 };
9
+
10
+ const hsl = (h: number, s: number, l: number): string =>
11
+ `hsl(${Math.round(h) % 360},${s}%,${l}%)`;
12
+
13
+ const layer = (body: string): Buffer =>
14
+ Buffer.from(
15
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${SIDE}" height="${SIDE}" viewBox="0 0 ${SIDE} ${SIDE}">${body}</svg>`,
16
+ );
17
+
18
+ /**
19
+ * A stable picture for an agent nobody drew: the assistant's icon on a colour taken from the
20
+ * display name, with a badge holding the initial of the agent's name. The name is lowercase Latin
21
+ * letters, digits, and dashes, so the badge is always drawn from a font every server has, whatever
22
+ * script the display name is written in. The same display name, name, and icon always give the
23
+ * same bytes.
24
+ */
25
+ export async function fallbackAvatar(
26
+ displayName: string,
27
+ icon: Uint8Array,
28
+ name: string,
29
+ ): Promise<Buffer> {
30
+ const shown = displayName.normalize("NFC").trim();
31
+ const digest = createHash("sha256").update(shown).digest();
32
+ const byte = (i: number) => digest[i] ?? 0;
33
+ const hue = ((byte(0) << 8) | byte(1)) % 360;
34
+ const dark = hsl(hue, 58, 26);
35
+
36
+ const bubbles = [0, 1, 2]
37
+ .map((i) => {
38
+ const cx = (byte(2 + i * 3) / 255) * SIDE;
39
+ const cy = (byte(3 + i * 3) / 255) * SIDE;
40
+ const r = 90 + (byte(4 + i * 3) / 255) * 130;
41
+ return `<circle cx="${cx.toFixed(1)}" cy="${cy.toFixed(1)}" r="${r.toFixed(1)}" fill="#fff" fill-opacity="0.09"/>`;
42
+ })
43
+ .join("");
44
+ const back = layer(
45
+ `<defs><linearGradient id="g" x1="0" y1="0" x2="0.35" y2="1"><stop offset="0" stop-color="${hsl(hue, 62, 55)}"/><stop offset="1" stop-color="${hsl(hue + 32, 60, 33)}"/></linearGradient></defs>` +
46
+ `<rect width="${SIDE}" height="${SIDE}" fill="url(#g)"/>${bubbles}` +
47
+ `<circle cx="${ICON_CENTER.x}" cy="${ICON_CENTER.y}" r="${ICON_RADIUS + 8}" fill="#fff" fill-opacity="0.35"/>` +
48
+ `<circle cx="${ICON_CENTER.x}" cy="${ICON_CENTER.y}" r="${ICON_RADIUS}" fill="#fff" fill-opacity="0.55"/>`,
49
+ );
50
+
51
+ const initial = (name.match(/[a-z0-9]/)?.[0] ?? "?").toUpperCase();
52
+ const mark = `<text x="${BADGE.x}" y="${BADGE.y + 28}" font-family="DejaVu Sans, Helvetica, Arial, sans-serif" font-size="80" font-weight="bold" text-anchor="middle" fill="${dark}">${initial}</text>`;
53
+ const front = layer(
54
+ `<circle cx="${BADGE.x}" cy="${BADGE.y}" r="${BADGE.radius + 8}" fill="${hsl(hue + 32, 60, 33)}"/>` +
55
+ `<circle cx="${BADGE.x}" cy="${BADGE.y}" r="${BADGE.radius}" fill="#fff"/>${mark}`,
56
+ );
57
+
58
+ const canvas = createCanvas(SIDE, SIDE);
59
+ const ctx = canvas.getContext("2d");
60
+ ctx.drawImage(await loadImage(back), 0, 0);
61
+ const face = await loadImage(Buffer.from(icon));
62
+ const crop = Math.min(face.width, face.height);
63
+ ctx.save();
64
+ ctx.beginPath();
65
+ ctx.arc(ICON_CENTER.x, ICON_CENTER.y, ICON_RADIUS, 0, Math.PI * 2);
66
+ ctx.clip();
67
+ ctx.drawImage(
68
+ face,
69
+ (face.width - crop) / 2,
70
+ (face.height - crop) / 2,
71
+ crop,
72
+ crop,
73
+ ICON_CENTER.x - ICON_RADIUS,
74
+ ICON_CENTER.y - ICON_RADIUS,
75
+ ICON_RADIUS * 2,
76
+ ICON_RADIUS * 2,
77
+ );
78
+ // Tint the icon's own backdrop with the name's colour so the disc belongs to the picture.
79
+ ctx.globalCompositeOperation = "multiply";
80
+ ctx.fillStyle = hsl(hue, 45, 93);
81
+ ctx.fillRect(0, 0, SIDE, SIDE);
82
+ ctx.restore();
83
+ ctx.drawImage(await loadImage(front), 0, 0);
84
+ return canvas.toBuffer("image/png");
85
+ }
@@ -6,10 +6,14 @@ import { openingTaskText } from "./agent-prompt.ts";
6
6
  import { modelSetting, thinkingSetting } from "./agent-settings.ts";
7
7
  import type { Agent } from "./agent-store.ts";
8
8
  import type { AvatarMode } from "./agent-tools.ts";
9
- import { channelKey } from "./team-keys.ts";
9
+ import { discordKey } from "./team-keys.ts";
10
10
  import { groupTopic, teamCategory } from "./team-layout.ts";
11
11
  import type { TeamContext } from "./team-options.ts";
12
12
 
13
+ /** Said when an avatar is to be redrawn on a host without an image provider. */
14
+ const NO_IMAGE_PROVIDER =
15
+ "No image provider is configured, so avatars cannot be redrawn; the current picture stays. A plugin fills the images slot to draw them.";
16
+
13
17
  /** Creating and changing agents, their avatars, and groups. */
14
18
  export class TeamEditing {
15
19
  readonly #ctx: TeamContext;
@@ -74,37 +78,62 @@ export class TeamEditing {
74
78
  name: string;
75
79
  displayName: string;
76
80
  prompt: string;
77
- avatarPrompt: string;
81
+ /** Required when an image provider is configured, ignored otherwise. */
82
+ avatarPrompt?: string;
78
83
  task: string;
79
84
  category?: string;
80
85
  skills?: string[];
81
86
  },
82
87
  ): Promise<string> {
88
+ const { store, studio, channels, queue, skills, logger } =
89
+ this.#ctx.options;
90
+ if (input.skills && input.skills.length > 0 && !skills)
91
+ throw new AgentError(
92
+ "Skills are off on this host, so an agent cannot carry any. Create it without skills.",
93
+ );
83
94
  const speaker = this.#ctx.turns.speakerOf(caller);
84
95
  if (!speaker)
85
96
  throw new AgentError(
86
97
  "agent_create can only be used during an agent turn.",
87
98
  );
88
- const { store, channels, queue, skills, logger } = this.#ctx.options;
89
- const { category, skills: carried = [], ...fields } = input;
99
+ const drawing = studio.canDraw !== false;
100
+ const {
101
+ category,
102
+ skills: carried = [],
103
+ avatarPrompt = "",
104
+ ...fields
105
+ } = input;
106
+ if (drawing && avatarPrompt.trim() === "")
107
+ throw new AgentError("Give avatar_prompt: the avatar is drawn from it.");
90
108
  const parent = teamCategory(category, "Agents");
91
109
  store.checkNewName(input.name);
92
- skills.checkRegistered(carried);
110
+ skills?.checkRegistered(carried);
93
111
  const channelId = await channels.createChannel(
94
112
  input.name,
95
113
  input.displayName,
96
114
  parent,
97
115
  );
98
- const agent = await store.createAgent({ ...fields, channelId });
99
- if (carried.length > 0) await skills.attach(agent.name, carried, []);
116
+ const agent = await store.createAgent({
117
+ ...fields,
118
+ avatarPrompt: drawing ? avatarPrompt : "",
119
+ channelId,
120
+ });
121
+ if (skills && carried.length > 0)
122
+ await skills.attach(agent.name, carried, []);
100
123
  logger.info({ agent: agent.name, by: caller.name }, "agent created");
101
124
  this.#ctx.changed();
102
- const avatar = await this.#redraw(agent.name, "redraw").then(
103
- () => "its avatar is drawn",
125
+ const avatar = await (drawing
126
+ ? this.#redraw(agent.name, "redraw")
127
+ : this.#generate(agent)
128
+ ).then(
129
+ () =>
130
+ drawing
131
+ ? "its avatar is drawn"
132
+ : "its avatar is generated from its display name",
104
133
  (error: unknown) =>
105
134
  `its avatar could not be drawn (${error instanceof Error ? error.message : String(error)}), so it uses ${assistantName()}'s for now`,
106
135
  );
107
- const home = channelKey(channelId);
136
+ const home = discordKey(channelId);
108
137
  const creator = store.agent(caller.name);
109
138
  void queue
110
139
  .run(home, () =>
@@ -138,9 +167,19 @@ export class TeamEditing {
138
167
  return this.#redraw(name, mode, text);
139
168
  }
140
169
 
170
+ /** Gives an agent the picture made from its display name; a host without an image provider has no other. */
171
+ async #generate(agent: Agent): Promise<Agent> {
172
+ const { store, studio } = this.#ctx.options;
173
+ if (!studio.fallback)
174
+ throw new AgentError("The avatar studio has no generated avatars.");
175
+ const avatarHash = await studio.fallback(agent.displayName, agent.name);
176
+ return store.updateAgent(agent.name, { avatarHash });
177
+ }
178
+
141
179
  async #redraw(name: string, mode: AvatarMode, text?: string): Promise<Agent> {
142
180
  const { store, studio, logger } = this.#ctx.options;
143
181
  const agent = store.activeAgent(name);
182
+ if (studio.canDraw === false) throw new AgentError(NO_IMAGE_PROVIDER);
144
183
  if (mode !== "redraw" && !text?.trim())
145
184
  throw new AgentError(
146
185
  mode === "edit"
@@ -1,12 +1,24 @@
1
+ import { channelKey, parseChannelKey } from "../contract/surface.ts";
1
2
  import type { ChannelKey } from "../domain/conversation.ts";
2
3
  import type { AgentTurnScope } from "../domain/ports.ts";
3
- import type { Agent, AgentGroup, AgentStore } from "./agent-store.ts";
4
+ import type { AgentDirectory } from "../services.ts";
5
+ import type { Agent, AgentGroup } from "./agent-store.ts";
4
6
 
5
- export const channelKey = (channelId: string): ChannelKey =>
6
- `discord:${channelId}`;
7
+ /** The surface of the channels the agent server runs on. */
8
+ const DISCORD_SURFACE = "discord";
7
9
 
8
- export const channelIdOf = (channel: ChannelKey) =>
9
- channel.slice("discord:".length);
10
+ /** The key of a Discord channel. */
11
+ export const discordKey = (channelId: string): ChannelKey =>
12
+ channelKey(DISCORD_SURFACE, channelId);
13
+
14
+ /** The Discord channel id of a key, or undefined when the key belongs to another surface. */
15
+ export function discordIdOf(channel: ChannelKey): string | undefined {
16
+ const { surface, id } = parseChannelKey(channel);
17
+ return surface === DISCORD_SURFACE ? id : undefined;
18
+ }
19
+
20
+ /** The id part of a key the caller already knows is a Discord one, because the agent server owns it. */
21
+ export const channelIdOf = (channel: ChannelKey) => parseChannelKey(channel).id;
10
22
 
11
23
  /** An agent's conversation inside a group, apart from its own. */
12
24
  export const groupSessionKey = (group: AgentGroup, agent: string): ChannelKey =>
@@ -14,7 +26,7 @@ export const groupSessionKey = (group: AgentGroup, agent: string): ChannelKey =>
14
26
 
15
27
  /** An agent's own conversation. */
16
28
  export function homeScope(agent: Agent): AgentTurnScope {
17
- const home = channelKey(agent.channelId ?? "");
29
+ const home = discordKey(agent.channelId ?? "");
18
30
  return { name: agent.name, session: home, home };
19
31
  }
20
32
 
@@ -23,17 +35,18 @@ export function groupScope(agent: Agent, group: AgentGroup): AgentTurnScope {
23
35
  return {
24
36
  name: agent.name,
25
37
  session: groupSessionKey(group, agent.name),
26
- home: channelKey(agent.channelId ?? ""),
38
+ home: discordKey(agent.channelId ?? ""),
27
39
  group: group.name,
28
40
  };
29
41
  }
30
42
 
31
43
  /** The agent or group that owns a channel of the agent server. */
32
44
  export function channelOwner(
33
- store: Pick<AgentStore, "agentByChannel" | "groupByChannel">,
45
+ store: Pick<AgentDirectory, "agentByChannel" | "groupByChannel">,
34
46
  channel: ChannelKey,
35
47
  ): "agent" | "group" | undefined {
36
- const id = channelIdOf(channel);
48
+ const id = discordIdOf(channel);
49
+ if (id === undefined) return undefined;
37
50
  if (store.agentByChannel(id)) return "agent";
38
51
  if (store.groupByChannel(id)) return "group";
39
52
  return undefined;
@@ -1,10 +1,11 @@
1
1
  import { AgentError } from "../domain/errors.ts";
2
2
  import { messages } from "../i18n/index.ts";
3
+ import type { AgentDirectory } from "../services.ts";
3
4
  import type { CategoryLayout } from "./agent-ports.ts";
4
- import type { AgentGroup, AgentStore } from "./agent-store.ts";
5
+ import type { AgentGroup } from "./agent-store.ts";
5
6
 
6
7
  type TeamStore = Pick<
7
- AgentStore,
8
+ AgentDirectory,
8
9
  "agent" | "group" | "agentByChannel" | "groupByChannel"
9
10
  >;
10
11
 
@@ -38,7 +39,7 @@ export function teamCategory(
38
39
  /** A group channel's topic: who is in it and who hosts. */
39
40
  export function groupTopic(
40
41
  group: AgentGroup,
41
- store: Pick<AgentStore, "agent">,
42
+ store: Pick<AgentDirectory, "agent">,
42
43
  ): string {
43
44
  const shown = (name: string) => store.agent(name)?.displayName ?? name;
44
45
  return messages().groupTopic(
@@ -4,7 +4,7 @@ import type { AgentTurnScope } from "../domain/ports.ts";
4
4
  import type { AgentGroup } from "./agent-store.ts";
5
5
  import {
6
6
  channelIdOf,
7
- channelKey,
7
+ discordKey,
8
8
  groupSessionKey,
9
9
  homeScope,
10
10
  } from "./team-keys.ts";
@@ -66,6 +66,7 @@ export class TeamLifecycle {
66
66
 
67
67
  async #drawMissingAvatars(): Promise<void> {
68
68
  const { store, studio, logger } = this.#ctx.options;
69
+ if (studio.canDraw === false) return this.#generateMissingAvatars();
69
70
  for (const agent of store.agents()) {
70
71
  if (agent.status !== "active" || agent.avatarHash) continue;
71
72
  try {
@@ -78,6 +79,31 @@ export class TeamLifecycle {
78
79
  }
79
80
  }
80
81
 
82
+ /**
83
+ * Without an image provider, gives each active agent that has no picture the one made from
84
+ * its display name, and says so once instead of once per agent on every boot.
85
+ */
86
+ async #generateMissingAvatars(): Promise<void> {
87
+ const { store, studio, logger } = this.#ctx.options;
88
+ if (!studio.fallback) return;
89
+ let generated = 0;
90
+ for (const agent of store.agents()) {
91
+ if (agent.status !== "active" || agent.avatarHash) continue;
92
+ try {
93
+ const avatarHash = await studio.fallback(agent.displayName, agent.name);
94
+ await store.updateAgent(agent.name, { avatarHash });
95
+ generated++;
96
+ } catch (error) {
97
+ logger.warn({ agent: agent.name, err: error }, "avatar not generated");
98
+ }
99
+ }
100
+ if (generated > 0)
101
+ logger.info(
102
+ { generated },
103
+ "no image provider is configured; agents without a picture got one generated from their display name",
104
+ );
105
+ }
106
+
81
107
  /** Starts an agent's or every group member's conversation over; call inside the channel's queue. */
82
108
  async startFresh(channel: ChannelKey): Promise<void> {
83
109
  const { store } = this.#ctx.options;
@@ -149,7 +175,7 @@ export class TeamLifecycle {
149
175
  const agent = store.agentByChannel(channelId);
150
176
  if (agent) {
151
177
  const left = await store.archiveAgent(agent.name);
152
- const home = channelKey(channelId);
178
+ const home = discordKey(channelId);
153
179
  for (const schedule of await schedules.forChannel(home))
154
180
  await schedules.remove(schedule.id, home);
155
181
  await runtime.startFresh(home);
@@ -1,11 +1,10 @@
1
1
  import type { ChannelKey } from "../domain/conversation.ts";
2
2
  import type { ThinkingSetting } from "../models.ts";
3
- import type { SkillRegistry } from "../modules/skills/skill-registry.ts";
4
3
  import type { PromptSection } from "../plugin.ts";
4
+ import type { AvatarStudio, SkillRegistry } from "../services.ts";
5
5
  import type { ToolSelection } from "../sessions.ts";
6
6
  import type { AgentChannels, AgentModels } from "./agent-ports.ts";
7
7
  import type { AgentGroup, AgentSeed } from "./agent-store.ts";
8
- import type { AvatarStudio } from "./avatar-studio.ts";
9
8
  import type { TeamTurns, TeamTurnsOptions } from "./team-turns.ts";
10
9
 
11
10
  export interface AgentTeamOptions
@@ -19,7 +18,12 @@ export interface AgentTeamOptions
19
18
  /** The tools plugins defined for agents, read before each agent turn. */
20
19
  pluginTools?: () => readonly string[];
21
20
  channels: AgentChannels;
22
- studio: Pick<AvatarStudio, "url" | "draw" | "edit">;
21
+ /**
22
+ * `canDraw: false` marks a host without an image provider: agents are offered no drawing and
23
+ * get `fallback` pictures. A studio that leaves `canDraw` out draws.
24
+ */
25
+ studio: Pick<AvatarStudio, "url" | "draw" | "edit"> &
26
+ Partial<Pick<AvatarStudio, "canDraw" | "fallback">>;
23
27
  /** The tools and groups the plugins give every agent, read before each agent turn. */
24
28
  pluginSelection: () => ToolSelection;
25
29
  models: AgentModels;
@@ -35,8 +39,8 @@ export interface AgentTeamOptions
35
39
  sharedPrompt: string;
36
40
  /** The shared prompt for speakers other than the owner, when `sharedPrompt` speaks to the owner. */
37
41
  guestPrompt?: string;
38
- /** The skill registry: what each agent carries and the skill tools. */
39
- skills: SkillRegistry;
42
+ /** The skill registry: what each agent carries. Absent when the skills addon is off. */
43
+ skills?: SkillRegistry;
40
44
  }
41
45
 
42
46
  /** What the team's collaborators share: its options, its turns, and its change signal. */
@@ -1,6 +1,6 @@
1
1
  import type { ThinkingSetting } from "../models.ts";
2
2
  import type { ContextUse } from "./agent-ports.ts";
3
- import { channelIdOf, channelKey } from "./team-keys.ts";
3
+ import { channelIdOf, discordKey } from "./team-keys.ts";
4
4
  import type { TeamContext } from "./team-options.ts";
5
5
 
6
6
  /** An active agent as the dashboard shows it. */
@@ -44,10 +44,10 @@ export async function teamStatus(ctx: TeamContext): Promise<TeamStatus> {
44
44
  const agents: AgentStatus[] = [];
45
45
  for (const agent of store.agents()) {
46
46
  if (agent.status !== "active") continue;
47
- const home = agent.channelId ? channelKey(agent.channelId) : undefined;
47
+ const home = agent.channelId ? discordKey(agent.channelId) : undefined;
48
48
  const workingIn = turns.workingIn(agent.name);
49
49
  const queued = home ? queue.size(home) : 0;
50
- const context = home ? runtime.contextUsage(home) : undefined;
50
+ const context = home ? runtime.contextUsage?.(home) : undefined;
51
51
  const lastActive = turns.lastActive(agent.name);
52
52
  agents.push({
53
53
  name: agent.name,
@@ -72,7 +72,7 @@ export async function teamStatus(ctx: TeamContext): Promise<TeamStatus> {
72
72
  channelId: group.channelId,
73
73
  members: group.members.map((m) => store.agent(m)?.displayName ?? m),
74
74
  host: store.agent(group.host)?.displayName ?? group.host,
75
- busy: queue.size(channelKey(group.channelId)),
75
+ busy: queue.size(discordKey(group.channelId)),
76
76
  ...(lastActive ? { lastActive } : {}),
77
77
  };
78
78
  });
@@ -1,10 +1,11 @@
1
1
  import { AgentError } from "../domain/errors.ts";
2
2
  import { assistantName } from "../i18n/index.ts";
3
3
  import type { ThinkingSetting } from "../models.ts";
4
+ import type { AgentDirectory } from "../services.ts";
4
5
  import { zonedStamp } from "../time.ts";
5
6
  import type { ChannelMessage } from "./agent-ports.ts";
6
7
  import { describeThinking } from "./agent-settings.ts";
7
- import type { Agent, AgentStore } from "./agent-store.ts";
8
+ import type { Agent } from "./agent-store.ts";
8
9
 
9
10
  /** One message as channel_read shows it: id, local time, author, text, attachments. */
10
11
  export function messageLine(message: ChannelMessage): string {
@@ -26,7 +27,7 @@ export function channelIdFromRef(channel: string): string {
26
27
  }
27
28
 
28
29
  /** Every agent and group as agent_list shows them, before the category layout. */
29
- export function teamListText(store: Pick<AgentStore, "agents" | "groups">) {
30
+ export function teamListText(store: Pick<AgentDirectory, "agents" | "groups">) {
30
31
  const agents = store.agents().map((a) => {
31
32
  const channel = a.channelId ? `<#${a.channelId}>` : "no channel";
32
33
  return `- ${a.name} "${a.displayName}" — ${channel}, ${a.status}`;
@@ -40,11 +41,12 @@ export function teamListText(store: Pick<AgentStore, "agents" | "groups">) {
40
41
  return `Agents:\n${agents.join("\n") || "none"}\n\nGroups:\n${groups.join("\n") || "none"}`;
41
42
  }
42
43
 
43
- /** One agent as agent_get shows it; unset settings name the assistant's. */
44
+ /** One agent as agent_get shows it; unset settings name the assistant's, and no skills line while the skills addon is off. */
44
45
  export function agentDetails(
45
46
  agent: Agent,
46
47
  defaults: { model: string; thinking: ThinkingSetting },
47
- skills: string,
48
+ skills: string | undefined,
49
+ avatars = true,
48
50
  ): string {
49
51
  return [
50
52
  `Name: ${agent.name}`,
@@ -53,8 +55,8 @@ export function agentDetails(
53
55
  `Channel: ${agent.channelId ? `<#${agent.channelId}>` : "none"}`,
54
56
  `Model: ${agent.model ?? `${assistantName()}'s (${defaults.model})`}`,
55
57
  `Thinking: ${agent.thinking ?? `${assistantName()}'s (${describeThinking(defaults.thinking)})`}`,
56
- `Skills: ${skills}`,
57
- `Avatar prompt: ${agent.avatarPrompt}`,
58
+ ...(skills === undefined ? [] : [`Skills: ${skills}`]),
59
+ ...(avatars ? [`Avatar prompt: ${agent.avatarPrompt}`] : []),
58
60
  `Prompt:\n${agent.prompt}`,
59
61
  ].join("\n");
60
62
  }
@@ -13,12 +13,12 @@ import type { AgentTurnScope } from "../domain/ports.ts";
13
13
  import type { OwnerIdentity } from "../identity.ts";
14
14
  import type { Logger } from "../log.ts";
15
15
  import type { EventSink } from "../plugin.ts";
16
+ import type { AvatarStudio } from "../services.ts";
16
17
  import type { TurnSelection } from "../sessions.ts";
17
18
  import type { Speaker } from "../speakers.ts";
18
19
  import type { ToolTiers } from "../tool-tiers.ts";
19
20
  import type { AgentChannels, AgentTurnRunner } from "./agent-ports.ts";
20
- import type { Agent, AgentStore } from "./agent-store.ts";
21
- import type { AvatarStudio } from "./avatar-studio.ts";
21
+ import type { Agent, PgAgentStore } from "./agent-store.ts";
22
22
  import type { RelevanceScorer } from "./group-round.ts";
23
23
 
24
24
  /** Agent messages without an owner message in between (spec behavior 13). */
@@ -43,7 +43,7 @@ export interface TeamTurnsOptions {
43
43
  entryChannelId: string;
44
44
  /** Who the agents work for, as their prompts and group history name them. */
45
45
  owner: OwnerIdentity;
46
- store: AgentStore;
46
+ store: PgAgentStore;
47
47
  channels: Pick<AgentChannels, "post">;
48
48
  studio: Pick<AvatarStudio, "url">;
49
49
  /** Set once the runtime exists, which itself needs the team's tools. */
@@ -76,7 +76,7 @@ export interface TurnExtra {
76
76
  attachments?: TurnAttachments;
77
77
  confirmed?: boolean;
78
78
  steerable?: boolean;
79
- /** The owner started it by writing, or it delivers a report, so it may ask him on cards. */
79
+ /** The owner started it by writing, or it delivers a report, so it may ask them on cards. */
80
80
  interactive?: boolean;
81
81
  }
82
82
 
@@ -17,8 +17,8 @@ import type { Agent } from "./agent-store.ts";
17
17
  import { GroupTurns } from "./group-turns.ts";
18
18
  import {
19
19
  channelIdOf,
20
- channelKey,
21
20
  channelOwner,
21
+ discordKey,
22
22
  homeScope,
23
23
  } from "./team-keys.ts";
24
24
  import {
@@ -83,7 +83,7 @@ export class TeamTurns {
83
83
 
84
84
  /**
85
85
  * The owner's message in an agent's channel; call inside the channel's queue. `text`
86
- * carries the reference, `replyText` is what he typed.
86
+ * carries the reference, `replyText` is what they typed.
87
87
  */
88
88
  async answerOwner(
89
89
  channel: ChannelKey,
@@ -168,6 +168,7 @@ export class TeamTurns {
168
168
  this.#active(agent.name, scope.group);
169
169
  const turn = {
170
170
  agent: agent.name,
171
+ kind: "agent",
171
172
  channel: postTo,
172
173
  speaker: chain.speaker,
173
174
  ...(scope.group ? { group: scope.group } : {}),
@@ -267,7 +268,7 @@ export class TeamTurns {
267
268
 
268
269
  /**
269
270
  * Posts text under the caller's name in the channel of its turn: the group's in a group
270
- * round, otherwise its own, as a change report is (repos-and-skills spec behavior 10).
271
+ * round, otherwise its own, as a change report is.
271
272
  */
272
273
  async postAs(caller: AgentTurnScope, text: string): Promise<void> {
273
274
  const agent = this.#options.store.activeAgent(caller.name);
@@ -281,7 +282,7 @@ export class TeamTurns {
281
282
  const group = scope.group
282
283
  ? this.#options.store.group(scope.group)
283
284
  : undefined;
284
- return group ? channelKey(group.channelId) : scope.home;
285
+ return group ? discordKey(group.channelId) : scope.home;
285
286
  }
286
287
 
287
288
  /** The assistant's own notice, such as a new release, posted in the coordinator's channel under its name. */
@@ -290,7 +291,7 @@ export class TeamTurns {
290
291
  const coordinator = store.agentByChannel(entryChannelId);
291
292
  if (!coordinator)
292
293
  throw new AgentError("no active agent in the entry channel");
293
- await this.#post(channelKey(entryChannelId), coordinator, {
294
+ await this.#post(discordKey(entryChannelId), coordinator, {
294
295
  chunks: splitReply(text),
295
296
  });
296
297
  }