pi-roundtable 0.22.4 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/CHANGELOG.md +61 -1
  2. package/README.md +36 -10
  3. package/README.zh-TW.md +51 -13
  4. package/docs/migrating-0.8.md +2 -2
  5. package/docs/plugins.md +350 -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 +7 -3
  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/http/websocket.ts +2 -1
  75. package/src/core/i18n/agent-panel.ts +14 -21
  76. package/src/core/interactions/prompts.ts +12 -0
  77. package/src/core/modes/autonomy.ts +23 -0
  78. package/src/core/modes/conversation-modes.ts +213 -0
  79. package/src/core/modes/modes-plugin.ts +24 -0
  80. package/src/core/modules/delegation/delegator.ts +4 -4
  81. package/src/core/modules/host-shell/shell-arithmetic.ts +81 -0
  82. package/src/core/modules/host-shell/shell-lexer-types.ts +53 -0
  83. package/src/core/modules/host-shell/shell-lexer.ts +493 -0
  84. package/src/core/modules/host-shell/shell-paths.ts +85 -4
  85. package/src/core/modules/host-shell/shell-policy.ts +341 -332
  86. package/src/core/modules/host-shell/shell-programs.ts +298 -0
  87. package/src/core/modules/host-shell/shell-quoting.ts +237 -0
  88. package/src/core/modules/host-shell/shell-scripts.ts +114 -0
  89. package/src/core/modules/host-shell/shell-wrappers.ts +104 -0
  90. package/src/core/modules/schedules/precheck-tools.ts +17 -0
  91. package/src/core/modules/schedules/schedules.ts +9 -3
  92. package/src/core/plugin.ts +8 -1
  93. package/src/core/presence/direct-channels.ts +30 -0
  94. package/src/core/presentation/reply-body.ts +25 -0
  95. package/src/core/registry/contributions.ts +31 -7
  96. package/src/core/registry/providers.ts +1 -1
  97. package/src/core/routing/conversation-turns.ts +2 -15
  98. package/src/core/routing/surface-port.ts +5 -1
  99. package/src/core/runtime/extensions/approval-card.ts +1 -5
  100. package/src/core/runtime/extensions/approval-fields.ts +3 -7
  101. package/src/core/runtime/extensions/confirmation-gate-extension.ts +13 -2
  102. package/src/core/runtime/extensions/confirmation-gate.ts +41 -40
  103. package/src/core/runtime/extensions/gate-refusals.ts +23 -0
  104. package/src/core/runtime/extensions/held-calls.ts +16 -1
  105. package/src/core/runtime/extensions/hold-describer.ts +58 -8
  106. package/src/core/runtime/extensions/hold-lookups.ts +19 -19
  107. package/src/core/runtime/extensions/yolo.ts +105 -0
  108. package/src/core/runtime/pi-agent-runtime.ts +3 -5
  109. package/src/core/runtime/runtime-plugin.ts +42 -1
  110. package/src/core/runtime/runtime-types.ts +11 -0
  111. package/src/core/runtime/turn-budget.ts +32 -5
  112. package/src/core/runtime/turn-limits.ts +14 -3
  113. package/src/core/services.ts +61 -9
  114. package/src/core/shared/attachment-reader.ts +6 -0
  115. package/src/core/shared/unix-server.ts +10 -1
  116. package/src/core/testing/modules.ts +12 -8
  117. package/src/core/testing/surface-threads.ts +58 -0
  118. package/src/core/testing/thread-host.ts +17 -5
  119. package/src/core/threads/dispatch-threads.ts +299 -0
  120. package/src/core/threads/host-threads.ts +19 -0
  121. package/src/core/tool-set.snapshot.json +5 -0
  122. package/src/index.ts +27 -1
  123. package/src/kit/characters.ts +5 -0
  124. package/src/kit/index.ts +2 -0
  125. package/src/kit/shell.ts +1 -0
  126. package/src/kit/support.ts +1 -1
  127. package/src/kit/threads.ts +1 -1
  128. package/src/testing.ts +4 -0
  129. package/templates/adapters/web/package.json.tmpl +1 -1
  130. package/templates/agents.ts +1 -1
  131. package/templates/package.json.tmpl +1 -1
  132. package/src/core/discord/dispatch-threads.ts +0 -194
@@ -22,13 +22,14 @@ import { outcome } from "../routing/settle-turn.ts";
22
22
  import { systemTurn } from "../routing/system-turns.ts";
23
23
  import { addressee, attributed, type Speaker } from "../speakers.ts";
24
24
  import type { DiscordAgentTeam } from "./agent-team.ts";
25
- import { discordIdOf } from "./team-keys.ts";
25
+ import { discordIdOf, roomOf } from "./team-keys.ts";
26
26
 
27
27
  /**
28
28
  * The agent server outranks every other claim on the Discord channels it owns, and its guild's
29
29
  * other channels stay silent even when another plugin's mode or a mention would answer there; a claim of a
30
- * plugin must use a higher priority to answer in that guild. It owns no channel of another
31
- * surface. It becomes the core's reserved branch once the team moves into the core
30
+ * plugin must use a higher priority to answer in that guild. Of another surface it owns only
31
+ * the rooms of an agent or group of the team on a surface that declares `agentRooms`, never
32
+ * another of its keys. It becomes the core's reserved branch once the team moves into the core
32
33
  * (pi-roundtable batch 7).
33
34
  */
34
35
  export const AGENT_SERVER_PRIORITY = 100;
@@ -58,7 +59,12 @@ export interface AgentClaimOptions {
58
59
  | "startFresh"
59
60
  >;
60
61
  runtime: Pick<AgentRuntime, "steer" | "stop">;
61
- surface: Pick<SurfacePort, "react" | "unreact">;
62
+ /**
63
+ * `roomSurfaces` names the surfaces whose agent and group rooms the claim may own, and `of`
64
+ * finds the surface that reads a message's attachments; without them the claim is Discord's alone.
65
+ */
66
+ surface: Pick<SurfacePort, "react" | "unreact"> &
67
+ Partial<Pick<SurfacePort, "roomSurfaces" | "of">>;
62
68
  /** Where a channel's attachments are saved. */
63
69
  attachmentDir: (channel: ChannelKey) => string;
64
70
  /** The downloads' limits: all of a message's files together (default 60 s), and one file without a byte (default 10 s). */
@@ -82,14 +88,24 @@ export interface AgentClaimOptions {
82
88
  */
83
89
  export function agentClaim(options: AgentClaimOptions): ChannelClaim {
84
90
  const { team, runtime, surface, attachmentDir, logger, fetchImpl } = options;
85
- const attachments = (message: InboundMessage) =>
86
- attachmentsOf(
91
+ const attachments = (message: InboundMessage) => {
92
+ // A surface that made a message's attachments reads them itself; they are never fetched.
93
+ const surfaceOf = surface.of?.(message.channel);
94
+ const reader = surfaceOf?.readAttachment?.bind(surfaceOf);
95
+ return attachmentsOf(
87
96
  message,
88
97
  attachmentDir(message.channel),
89
98
  logger,
90
99
  fetchImpl,
91
- options.attachmentDownload,
100
+ {
101
+ ...options.attachmentDownload,
102
+ ...(surfaceOf?.maxAttachmentBytes === undefined
103
+ ? {}
104
+ : { maxBytes: surfaceOf.maxAttachmentBytes }),
105
+ ...(reader ? { read: (ref) => reader(message.channel, ref) } : {}),
106
+ },
92
107
  );
108
+ };
93
109
  /** The channel context of a message in an agent's channel; none, logged, when it cannot be read. */
94
110
  const around = async (
95
111
  message: InboundMessage,
@@ -148,11 +164,14 @@ export function agentClaim(options: AgentClaimOptions): ChannelClaim {
148
164
  return {
149
165
  name: "agent-server",
150
166
  priority: AGENT_SERVER_PRIORITY,
151
- // Only Discord keys: another surface's channel is never the agent server's, whatever its id.
167
+ // Discord's channels, or the room of an agent or group on a surface that hosts rooms: another
168
+ // surface's key is never the agent server's, whatever its id, and a room needs a known name.
152
169
  owns: (channel, space) =>
153
- discordIdOf(channel) !== undefined &&
154
- (team.owns(channel) !== undefined ||
155
- (space !== undefined && space === team.guildId)),
170
+ discordIdOf(channel) !== undefined
171
+ ? team.owns(channel) !== undefined ||
172
+ (space !== undefined && space === team.guildId)
173
+ : roomOf(channel, surface.roomSurfaces?.() ?? []) !== undefined &&
174
+ team.owns(channel) !== undefined,
156
175
  admit: (message) => {
157
176
  const owned = team.owns(message.channel);
158
177
  // The agent server's other channels are the owner's notes: nothing there is answered.
@@ -61,7 +61,7 @@ function groupBlock(group: GroupStatus, now: Date): string {
61
61
  ? text.dashLastActive(when(group.lastActive, now))
62
62
  : text.dashNotActive;
63
63
  return [
64
- `**${group.displayName}** <#${group.channelId}>`,
64
+ `**${group.displayName}**${group.channelId ? ` <#${group.channelId}>` : ""}`,
65
65
  text.dashGroupMembers(group.members, group.host),
66
66
  state,
67
67
  `-# ${last}`,
@@ -1,3 +1,4 @@
1
+ import type { ChannelKey } from "../domain/conversation.ts";
1
2
  import { AgentError } from "../domain/errors.ts";
2
3
  import type { AgentTurnScope } from "../domain/ports.ts";
3
4
  import { messages } from "../i18n/index.ts";
@@ -12,7 +13,13 @@ import {
12
13
  returnedAnswerPost,
13
14
  } from "./agent-prompt.ts";
14
15
  import type { Agent } from "./agent-store.ts";
15
- import { discordKey, homeScope } from "./team-keys.ts";
16
+ import {
17
+ agentRoomKey,
18
+ discordKey,
19
+ homeScope,
20
+ roomScope,
21
+ roomSurfaceOf,
22
+ } from "./team-keys.ts";
16
23
  import {
17
24
  type Chain,
18
25
  type Exchange,
@@ -34,7 +41,11 @@ export class AgentMessages {
34
41
  throw new AgentError("message_agent is for other agents, not yourself.");
35
42
  const target = store.activeAgent(to);
36
43
  const sender = store.activeAgent(caller.name);
37
- if (!target.channelId) throw new AgentError(`${to} has no channel yet.`);
44
+ // A turn in a room of another surface talks to the other agents' rooms there, never in Discord.
45
+ const parent = this.#host.turnChannel(caller);
46
+ const via = roomSurfaceOf(parent);
47
+ if (via === undefined && !target.channelId)
48
+ throw new AgentError(`${to} has no channel yet.`);
38
49
  // Every agent turn runs through #turn, which registers its chain; a message from anywhere
39
50
  // else would start an uncounted chain, so it is refused.
40
51
  const chain = this.#host.chains.get(caller.session);
@@ -47,11 +58,13 @@ export class AgentMessages {
47
58
  chainLimitReason(addressee(chain.speaker, this.#host.options.owner)),
48
59
  );
49
60
  chain.messages += 1;
50
- const targetChannel = discordKey(target.channelId);
51
- const exchange = this.#openExchange(caller, sender, target, text);
61
+ const targetChannel = this.#home(target, via);
62
+ // The thread opens in the sender's turn channel, on its surface when that hosts threads; a
63
+ // surface that hosts none leaves the answer returning to the sender's room.
64
+ const exchange = this.#openExchange(parent, sender, target, text);
52
65
  void queue
53
66
  .run(targetChannel, async () =>
54
- this.#deliver(sender, target, text, chain, await exchange),
67
+ this.#deliver(sender, target, text, chain, await exchange, via),
55
68
  )
56
69
  .catch((error: unknown) =>
57
70
  logger.error(
@@ -66,17 +79,24 @@ export class AgentMessages {
66
79
  return `Sent to "${target.displayName}". It works in its own channel; its answer comes back to you as a new turn.`;
67
80
  }
68
81
 
82
+ /** The agent's own channel: its Discord channel, or its room on the surface of a room turn. */
83
+ #home(agent: Agent, via: string | undefined): ChannelKey {
84
+ return via === undefined
85
+ ? discordKey(agent.channelId ?? "")
86
+ : agentRoomKey(via, agent.name);
87
+ }
88
+
69
89
  /**
70
- * Opens the message's thread in the sender's turn channel and posts the request there under
71
- * the sender's name; undefined where no thread opens. Never rejects.
90
+ * Opens the message's thread in the sender's turn channel, a Discord channel or a room, and
91
+ * posts the request there under the sender's name; undefined where no thread opens. Never
92
+ * rejects.
72
93
  */
73
94
  async #openExchange(
74
- caller: AgentTurnScope,
95
+ parent: ChannelKey,
75
96
  sender: Agent,
76
97
  target: Agent,
77
98
  text: string,
78
99
  ): Promise<Exchange | undefined> {
79
- const parent = this.#host.turnChannel(caller);
80
100
  const thread = await this.#host.options.threads?.open(
81
101
  parent,
82
102
  `→ ${target.displayName}`,
@@ -105,9 +125,10 @@ export class AgentMessages {
105
125
  text: string,
106
126
  chain: Chain,
107
127
  exchange: Exchange | undefined,
128
+ via: string | undefined,
108
129
  ): Promise<void> {
109
130
  try {
110
- await this.#exchange(sender, target, text, chain, exchange);
131
+ await this.#exchange(sender, target, text, chain, exchange, via);
111
132
  } finally {
112
133
  await exchange?.thread.close();
113
134
  }
@@ -119,9 +140,10 @@ export class AgentMessages {
119
140
  text: string,
120
141
  chain: Chain,
121
142
  exchange: Exchange | undefined,
143
+ via: string | undefined,
122
144
  ): Promise<void> {
123
145
  const { queue, logger } = this.#host.options;
124
- const targetChannel = discordKey(target.channelId ?? "");
146
+ const targetChannel = this.#home(target, via);
125
147
  await this.#host.post(targetChannel, this.#host.current(sender), {
126
148
  chunks: splitReply(
127
149
  deliveredMessagePost(this.#host.current(target), text),
@@ -129,7 +151,7 @@ export class AgentMessages {
129
151
  });
130
152
  const result = await this.#host.turn(
131
153
  target,
132
- homeScope(target),
154
+ via === undefined ? homeScope(target) : roomScope(target, targetChannel),
133
155
  targetChannel,
134
156
  agentMessageText(
135
157
  this.#host.current(sender),
@@ -142,8 +164,12 @@ export class AgentMessages {
142
164
  if (thread && !result.ok)
143
165
  await thread.close(messages().agentNoReply(result.error.message));
144
166
  const current = this.#host.options.store.agent(sender.name);
145
- if (current?.status !== "active" || !current.channelId) return;
146
- const senderChannel = discordKey(current.channelId);
167
+ if (
168
+ current?.status !== "active" ||
169
+ (via === undefined && !current.channelId)
170
+ )
171
+ return;
172
+ const senderChannel = this.#home(current, via);
147
173
  // With a thread the answer goes there, leaving the sender's channel its own reply.
148
174
  if (result.ok)
149
175
  await this.#host
@@ -173,7 +199,9 @@ export class AgentMessages {
173
199
  .run(senderChannel, () =>
174
200
  this.#host.turn(
175
201
  current,
176
- homeScope(current),
202
+ via === undefined
203
+ ? homeScope(current)
204
+ : roomScope(current, senderChannel),
177
205
  senderChannel,
178
206
  followUp,
179
207
  chain,
@@ -23,8 +23,6 @@ export function agentSystemPrompt(input: {
23
23
  group?: { group: AgentGroup; members: readonly Agent[] };
24
24
  /** Who the turn is for; without one, or the owner, the prompt speaks to the owner. */
25
25
  speaker?: Speaker;
26
- /** Whether agents can redraw avatars; false without an image provider. Default true. */
27
- avatars?: boolean;
28
26
  /** Whether the session offers generate_image; true only with an image provider. Default false. */
29
27
  images?: boolean;
30
28
  }): string {
@@ -38,7 +36,7 @@ export function agentSystemPrompt(input: {
38
36
  (guest && input.guestShared) || shared,
39
37
  `You are "${agent.displayName}" (agent name \`${agent.name}\`), one of ${o.name}'s agents in ${o.his} Discord agent server. Each agent owns one channel and one conversation; you all share ${o.his} tools and ${o.his} memory.`,
40
38
  roleText(agent, coordinator, who),
41
- `The team: agent_list shows every agent and group; agent_get and agent_update read and improve any agent's prompt, display name, model, and thinking level, yours included; agent_create adds an agent with its own channel; ${input.avatars === false ? "" : "agent_avatar redraws an avatar; "}schedule_list with agent reads another agent's schedules. A message marked as coming from another agent is that agent speaking, not ${w.name}; only ${w.name} approves held actions.`,
39
+ `The team: agent_list shows every agent and group; agent_get and agent_update read and improve any agent's prompt, display name, model, and thinking level, yours included; agent_create adds an agent with its own channel; agent_avatar changes an agent's look (class, colors, parts); schedule_list with agent reads another agent's schedules. A message marked as coming from another agent is that agent speaking, not ${w.name}; only ${w.name} approves held actions.`,
42
40
  `Your shell and file tools run on ${o.his} VPS as the user ${shellUser}, in the shared workspace ${workDir}.${input.scratchDir ? ` Write temporary files under the scratch dir ${input.scratchDir} ($TMPDIR), not elsewhere in /tmp; make them with \`mktemp -p "$TMPDIR"\`, since a bare \`mktemp\` ignores $TMPDIR on some systems.` : ""} Commands that are destructive or change the system, and writes outside the workspace${input.scratchDir ? " and the scratch dir" : ""}, are held for ${w.his} confirmation: tell ${w.him} exactly what will run and ask ${w.him} to confirm. To show a file you have, attach it to your reply with attach_file or give its path to discord_send_message; never read an image or base64 a file to send it.${input.images ? " To make a new picture, describe it to generate_image, which saves it and attaches it to your reply." : ""}`,
43
41
  ];
44
42
  if (group) {
@@ -79,7 +77,7 @@ function roleText(
79
77
  if (coordinator?.name === agent.name)
80
78
  return `You are the team's coordinator: ${o.name}'s general requests come to you, and you hand specialist work to the agent that owns it.`;
81
79
  const who = coordinator
82
- ? `The team's coordinator is "${coordinator.displayName}" (\`${coordinator.name}\`) in <#${coordinator.channelId}>.`
80
+ ? `The team's coordinator is "${coordinator.displayName}" (\`${coordinator.name}\`)${coordinator.channelId ? ` in <#${coordinator.channelId}>` : ""}.`
83
81
  : "The team's coordinator is the agent in the server's entry channel.";
84
82
  return `${who} You are a specialist: speak of yourself only as "${agent.displayName}", never as the coordinator. Team work ${o.name} asks of you directly, such as creating agents or groups, archiving, or arranging channels, you do in your own name. When your role below is empty or only a placeholder, ask ${o.him} what your job is before taking on other work.`;
85
83
  }
@@ -1,3 +1,8 @@
1
+ import {
2
+ type CharacterInput,
3
+ type CharacterSpec,
4
+ suggestCharacter,
5
+ } from "../../characters/index.ts";
1
6
  import { AgentError } from "../domain/errors.ts";
2
7
  import { THINKING_LEVELS, type ThinkingLevel } from "../models.ts";
3
8
 
@@ -14,7 +19,9 @@ export interface Agent {
14
19
  displayName: string;
15
20
  prompt: string;
16
21
  avatarPrompt: string;
17
- /** The drawn picture's content hash, when one exists. */
22
+ /** What the agent looks like; absent only on a row read before the team assigned one. */
23
+ character?: CharacterSpec;
24
+ /** The rendered picture's content hash, when one exists. */
18
25
  avatarHash?: string;
19
26
  /** Its channel in the agent server; a seed agent has none until one is created. */
20
27
  channelId?: string;
@@ -28,7 +35,8 @@ export interface Agent {
28
35
  export interface AgentGroup {
29
36
  name: string;
30
37
  displayName: string;
31
- channelId: string;
38
+ /** Its Discord channel; absent in a team without Discord, whose groups live in rooms only. */
39
+ channelId?: string;
32
40
  /** Agent names in the group's order. */
33
41
  members: string[];
34
42
  host: string;
@@ -39,7 +47,10 @@ export interface AgentSeed {
39
47
  name: string;
40
48
  displayName: string;
41
49
  prompt: string;
42
- avatarPrompt: string;
50
+ /** The agent's look, resolved over the class preset; without one the team suggests it from the role. */
51
+ character?: CharacterInput;
52
+ /** @deprecated Agents are composed characters; this is ignored and removed in 1.0. */
53
+ avatarPrompt?: string;
43
54
  channelId?: string;
44
55
  }
45
56
 
@@ -104,3 +115,31 @@ export function checkAvatarPrompt(prompt: string): void {
104
115
  `An avatar prompt must have 1 to ${MAX_AVATAR_PROMPT_CHARS} characters; this one has ${prompt.length}.`,
105
116
  );
106
117
  }
118
+
119
+ /** An agent's look: the one it was dressed in, otherwise the one its role suggests. */
120
+ export function characterOf(
121
+ agent: Pick<Agent, "name" | "displayName" | "prompt" | "character">,
122
+ ): CharacterSpec {
123
+ return agent.character ?? suggestCharacter(agent);
124
+ }
125
+
126
+ /**
127
+ * An input read over a base look. A new class alone keeps the base's colors, since the agent's
128
+ * colors are its own, and a class keeps the base's skin and build unless the input names them, so
129
+ * two agents of one class still differ.
130
+ */
131
+ export function overBase(
132
+ input: CharacterInput,
133
+ base: CharacterSpec,
134
+ ): CharacterInput {
135
+ if (input === null || typeof input !== "object" || input.class === undefined)
136
+ return input;
137
+ return {
138
+ skin: base.skin,
139
+ build: base.build,
140
+ ...(input.palette === undefined && input.colors === undefined
141
+ ? { colors: base.colors }
142
+ : {}),
143
+ ...input,
144
+ };
145
+ }
@@ -1,3 +1,4 @@
1
+ import { readCharacter } from "../../characters/index.ts";
1
2
  import { scopeToGuild } from "../db/guild-scope.ts";
2
3
  import type { Migration } from "../db/migrations.ts";
3
4
  import type { ThinkingLevel } from "../models.ts";
@@ -9,6 +10,8 @@ export interface AgentRow {
9
10
  prompt: string;
10
11
  avatar_prompt: string;
11
12
  avatar_hash: string | null;
13
+ /** jsonb: the stored spec, an object, or null before the team dressed the agent. */
14
+ character: unknown | null;
12
15
  channel_id: string | null;
13
16
  model: string | null;
14
17
  thinking: ThinkingLevel | null;
@@ -18,28 +21,32 @@ export interface AgentRow {
18
21
  export interface GroupRow {
19
22
  name: string;
20
23
  display_name: string;
21
- channel_id: string;
24
+ channel_id: string | null;
22
25
  members: string;
23
26
  host: string;
24
27
  status: AgentStatus;
25
28
  }
26
29
 
27
- export const toAgent = (row: AgentRow): Agent => ({
28
- name: row.name,
29
- displayName: row.display_name,
30
- prompt: row.prompt,
31
- avatarPrompt: row.avatar_prompt,
32
- ...(row.avatar_hash ? { avatarHash: row.avatar_hash } : {}),
33
- ...(row.channel_id ? { channelId: row.channel_id } : {}),
34
- ...(row.model ? { model: row.model } : {}),
35
- ...(row.thinking ? { thinking: row.thinking } : {}),
36
- status: row.status,
37
- });
30
+ export const toAgent = (row: AgentRow): Agent => {
31
+ const character = readCharacter(row.character);
32
+ return {
33
+ name: row.name,
34
+ displayName: row.display_name,
35
+ prompt: row.prompt,
36
+ avatarPrompt: row.avatar_prompt,
37
+ ...(character ? { character } : {}),
38
+ ...(row.avatar_hash ? { avatarHash: row.avatar_hash } : {}),
39
+ ...(row.channel_id ? { channelId: row.channel_id } : {}),
40
+ ...(row.model ? { model: row.model } : {}),
41
+ ...(row.thinking ? { thinking: row.thinking } : {}),
42
+ status: row.status,
43
+ };
44
+ };
38
45
 
39
46
  export const toGroup = (row: GroupRow): AgentGroup => ({
40
47
  name: row.name,
41
48
  displayName: row.display_name,
42
- channelId: row.channel_id,
49
+ ...(row.channel_id ? { channelId: row.channel_id } : {}),
43
50
  members: row.members.split(",").filter(Boolean),
44
51
  host: row.host,
45
52
  status: row.status,
@@ -95,8 +102,9 @@ export const agentsMigration: Migration = {
95
102
  };
96
103
 
97
104
  /**
98
- * The migrations of the store's tables for the guild the process serves: the tables, then
99
- * each row's guild, with existing rows given `guildId`.
105
+ * The migrations of the store's tables for the guild the process serves, or the local team
106
+ * without Discord: the tables, then each row's guild, with existing rows given `guildId`, then
107
+ * the group channel made optional.
100
108
  */
101
109
  export function agentMigrations(guildId: string): Migration[] {
102
110
  return [
@@ -111,5 +119,19 @@ export function agentMigrations(guildId: string): Migration[] {
111
119
  { table: "agent_group_cursors", key: ["group_name", "agent_name"] },
112
120
  ]),
113
121
  },
122
+ // A team without Discord has groups that live in rooms only, with no channel.
123
+ {
124
+ name: "agent-groups-channel-optional",
125
+ up: async (sql) => {
126
+ await sql`ALTER TABLE agent_groups ALTER COLUMN channel_id DROP NOT NULL`;
127
+ },
128
+ },
129
+ // The composed look, dressed in code at the team's start; rows keep their drawn picture until then.
130
+ {
131
+ name: "agents-character",
132
+ up: async (sql) => {
133
+ await sql`ALTER TABLE agents ADD COLUMN IF NOT EXISTS character jsonb`;
134
+ },
135
+ },
114
136
  ];
115
137
  }
@@ -1,4 +1,5 @@
1
1
  import type { SQL } from "bun";
2
+ import type { CharacterSpec } from "../../characters/index.ts";
2
3
  import type { Migration } from "../db/migrations.ts";
3
4
  import { AgentError } from "../domain/errors.ts";
4
5
  import type { ThinkingLevel } from "../models.ts";
@@ -57,6 +58,8 @@ import { isCheckDatabase } from "../db/check-database.ts";
57
58
  export class PgAgentStore implements AgentDirectory {
58
59
  readonly #sql: SQL;
59
60
  readonly #agents = new Map<string, Agent>();
61
+ /** Agents whose character column holds a value, readable or not. */
62
+ readonly #dressed = new Set<string>();
60
63
  readonly #groups = new Map<string, AgentGroup>();
61
64
  /** The tail of the agent and group writes, which run one at a time so none reads a map another is about to change. */
62
65
  #queue: Promise<unknown> = Promise.resolve();
@@ -93,7 +96,8 @@ export class PgAgentStore implements AgentDirectory {
93
96
  .#sql`SELECT * FROM agent_groups WHERE guild_id = ${this.#guild}`;
94
97
  this.#agents.clear();
95
98
  this.#groups.clear();
96
- for (const row of agents) this.#agents.set(row.name, toAgent(row));
99
+ this.#dressed.clear();
100
+ for (const row of agents) this.#keepAgentRow(row);
97
101
  for (const row of groups) this.#groups.set(row.name, toGroup(row));
98
102
  }
99
103
 
@@ -104,7 +108,7 @@ export class PgAgentStore implements AgentDirectory {
104
108
  for (const seed of seeds) {
105
109
  const rows: AgentRow[] = await this.#sql`
106
110
  INSERT INTO agents (guild_id, name, display_name, prompt, avatar_prompt, channel_id)
107
- VALUES (${this.#guild}, ${seed.name}, ${seed.displayName}, ${seed.prompt}, ${seed.avatarPrompt},
111
+ VALUES (${this.#guild}, ${seed.name}, ${seed.displayName}, ${seed.prompt}, ${seed.avatarPrompt ?? ""},
108
112
  ${seed.channelId ?? null})
109
113
  ON CONFLICT DO NOTHING
110
114
  RETURNING *`;
@@ -186,20 +190,25 @@ export class PgAgentStore implements AgentDirectory {
186
190
  name: string;
187
191
  displayName: string;
188
192
  prompt: string;
189
- avatarPrompt: string;
190
- channelId: string;
193
+ /** @deprecated Ignored by the team; kept for a store written by an older host. */
194
+ avatarPrompt?: string;
195
+ character?: CharacterSpec;
196
+ avatarHash?: string;
197
+ /** Absent in a team without Discord, whose agents live in rooms. */
198
+ channelId?: string;
191
199
  }): Promise<Agent> {
192
200
  return this.#serial(async () => {
193
201
  checkAgentName(agent.name);
194
202
  checkDisplayName(agent.displayName);
195
203
  checkPrompt(agent.prompt);
196
- // An agent of a host without an image provider has no avatar prompt.
197
- if (agent.avatarPrompt !== "") checkAvatarPrompt(agent.avatarPrompt);
204
+ const avatarPrompt = agent.avatarPrompt ?? "";
205
+ if (avatarPrompt !== "") checkAvatarPrompt(avatarPrompt);
198
206
  this.#checkFree(agent.name);
199
207
  const rows: AgentRow[] = await this.#sql`
200
- INSERT INTO agents (guild_id, name, display_name, prompt, avatar_prompt, channel_id)
201
- VALUES (${this.#guild}, ${agent.name}, ${agent.displayName.trim()}, ${agent.prompt}, ${agent.avatarPrompt},
202
- ${agent.channelId})
208
+ INSERT INTO agents (guild_id, name, display_name, prompt, avatar_prompt, channel_id, avatar_hash, character)
209
+ VALUES (${this.#guild}, ${agent.name}, ${agent.displayName.trim()}, ${agent.prompt}, ${avatarPrompt},
210
+ ${agent.channelId ?? null}, ${agent.avatarHash ?? null},
211
+ ${agent.character ?? null}::jsonb)
203
212
  RETURNING *`;
204
213
  return this.#keepAgent(rows[0]);
205
214
  });
@@ -218,6 +227,7 @@ export class PgAgentStore implements AgentDirectory {
218
227
  prompt?: string;
219
228
  avatarPrompt?: string;
220
229
  avatarHash?: string;
230
+ character?: CharacterSpec;
221
231
  channelId?: string;
222
232
  /** `null` returns it to the assistant's. */
223
233
  model?: string | null;
@@ -245,6 +255,7 @@ export class PgAgentStore implements AgentDirectory {
245
255
  prompt = ${change.prompt ?? current.prompt},
246
256
  avatar_prompt = ${change.avatarPrompt ?? current.avatarPrompt},
247
257
  avatar_hash = ${change.avatarHash ?? current.avatarHash ?? null},
258
+ character = CASE WHEN ${change.character !== undefined} THEN ${change.character ?? null}::jsonb ELSE character END,
248
259
  channel_id = ${change.channelId ?? current.channelId ?? null},
249
260
  model = ${model},
250
261
  thinking = ${thinking},
@@ -282,11 +293,43 @@ export class PgAgentStore implements AgentDirectory {
282
293
 
283
294
  #keepAgent(row: AgentRow | undefined): Agent {
284
295
  if (!row) throw new AgentError("the agent was not found");
296
+ return this.#keepAgentRow(row);
297
+ }
298
+
299
+ #keepAgentRow(row: AgentRow): Agent {
285
300
  const agent = toAgent(row);
286
301
  this.#agents.set(agent.name, agent);
302
+ if (row.character !== null && row.character !== undefined)
303
+ this.#dressed.add(agent.name);
304
+ else this.#dressed.delete(agent.name);
287
305
  return agent;
288
306
  }
289
307
 
308
+ /** Whether the stored column holds a character, even one this version cannot read. */
309
+ hasStoredCharacter(name: string): boolean {
310
+ return this.#dressed.has(name);
311
+ }
312
+
313
+ /**
314
+ * Stores a character and picture only while the agent still has none, so a look chosen in the
315
+ * meantime wins. Returns whether it was written.
316
+ */
317
+ async dressIfUndressed(
318
+ name: string,
319
+ character: CharacterSpec,
320
+ avatarHash: string,
321
+ ): Promise<boolean> {
322
+ return this.#serial(async () => {
323
+ const rows: AgentRow[] = await this.#sql`
324
+ UPDATE agents SET character = ${character}::jsonb, avatar_hash = ${avatarHash}, updated_at = now()
325
+ WHERE guild_id = ${this.#guild} AND name = ${name} AND character IS NULL
326
+ RETURNING *`;
327
+ if (!rows[0]) return false;
328
+ this.#keepAgentRow(rows[0]);
329
+ return true;
330
+ });
331
+ }
332
+
290
333
  #checkMembers(members: readonly string[], host: string): void {
291
334
  if (
292
335
  new Set(members).size !== members.length ||
@@ -311,7 +354,8 @@ export class PgAgentStore implements AgentDirectory {
311
354
  async createGroup(group: {
312
355
  name: string;
313
356
  displayName: string;
314
- channelId: string;
357
+ /** Absent in a team without Discord, whose groups live in rooms. */
358
+ channelId?: string;
315
359
  members: string[];
316
360
  host: string;
317
361
  }): Promise<AgentGroup> {
@@ -320,7 +364,7 @@ export class PgAgentStore implements AgentDirectory {
320
364
  checkDisplayName(group.displayName);
321
365
  const rows: GroupRow[] = await this.#sql`
322
366
  INSERT INTO agent_groups (guild_id, name, display_name, channel_id, members, host)
323
- VALUES (${this.#guild}, ${group.name}, ${group.displayName.trim()}, ${group.channelId},
367
+ VALUES (${this.#guild}, ${group.name}, ${group.displayName.trim()}, ${group.channelId ?? null},
324
368
  ${group.members.join(",")}, ${group.host})
325
369
  RETURNING *`;
326
370
  return this.#keepGroup(rows[0]);
@@ -406,7 +450,7 @@ export class PgAgentStore implements AgentDirectory {
406
450
  }
407
451
 
408
452
  /** Marks every message so far as received by every member, for a group started over. */
409
- catchUp(group: AgentGroup): Promise<void> {
410
- return this.#messages.catchUp(group);
453
+ catchUp(group: AgentGroup, log?: string): Promise<void> {
454
+ return this.#messages.catchUp(group, log);
411
455
  }
412
456
  }