pi-roundtable 0.22.5 → 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 (129) hide show
  1. package/CHANGELOG.md +55 -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 +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 +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,12 +1,20 @@
1
+ import {
2
+ CharacterError,
3
+ type CharacterInput,
4
+ type CharacterSpec,
5
+ resolveCharacter,
6
+ suggestCharacter,
7
+ } from "../../characters/index.ts";
1
8
  import { AgentError } from "../domain/errors.ts";
2
9
  import type { AgentTurnScope } from "../domain/ports.ts";
3
- import { assistantName } from "../i18n/index.ts";
10
+ import type { AgentChange } from "../services.ts";
4
11
  import { addressee } from "../speakers.ts";
5
12
  import { openingTaskText } from "./agent-prompt.ts";
13
+ import { characterOf, overBase } from "./agent-rules.ts";
6
14
  import { modelSetting, thinkingSetting } from "./agent-settings.ts";
7
15
  import type { Agent } from "./agent-store.ts";
8
16
  import type { AvatarMode } from "./agent-tools.ts";
9
- import { discordKey } from "./team-keys.ts";
17
+ import { discordKey, keysOf } from "./team-keys.ts";
10
18
  import { groupTopic, teamCategory } from "./team-layout.ts";
11
19
  import type { TeamContext } from "./team-options.ts";
12
20
 
@@ -26,15 +34,7 @@ export class TeamEditing {
26
34
  * Changes an agent's display name, prompt, model, or thinking level; `default` returns the
27
35
  * model or thinking level to the assistant's. A model must be one the host runs.
28
36
  */
29
- async update(
30
- name: string,
31
- change: {
32
- displayName?: string;
33
- prompt?: string;
34
- model?: string;
35
- thinking?: string;
36
- },
37
- ): Promise<string> {
37
+ async update(name: string, change: AgentChange): Promise<string> {
38
38
  const { store, logger } = this.#ctx.options;
39
39
  if (Object.values(change).every((value) => value === undefined))
40
40
  throw new AgentError(
@@ -78,14 +78,16 @@ export class TeamEditing {
78
78
  name: string;
79
79
  displayName: string;
80
80
  prompt: string;
81
- /** Required when an image provider is configured, ignored otherwise. */
81
+ /** @deprecated Ignored: agents are composed characters. */
82
82
  avatarPrompt?: string;
83
+ /** Fields of its look; any left out come from the look its role suggests. */
84
+ character?: CharacterInput;
83
85
  task: string;
84
86
  category?: string;
85
87
  skills?: string[];
86
88
  },
87
89
  ): Promise<string> {
88
- const { store, studio, channels, queue, skills, logger } =
90
+ const { store, studio, channels, queue, skills, surfaces, logger } =
89
91
  this.#ctx.options;
90
92
  if (input.skills && input.skills.length > 0 && !skills)
91
93
  throw new AgentError(
@@ -96,45 +98,58 @@ export class TeamEditing {
96
98
  throw new AgentError(
97
99
  "agent_create can only be used during an agent turn.",
98
100
  );
99
- const drawing = studio.canDraw !== false;
100
101
  const {
101
102
  category,
102
103
  skills: carried = [],
103
- avatarPrompt = "",
104
+ character: wanted,
105
+ avatarPrompt: _ignored,
104
106
  ...fields
105
107
  } = input;
106
- if (drawing && avatarPrompt.trim() === "")
107
- throw new AgentError("Give avatar_prompt: the avatar is drawn from it.");
108
108
  const parent = teamCategory(category, "Agents");
109
109
  store.checkNewName(input.name);
110
110
  skills?.checkRegistered(carried);
111
- const channelId = await channels.createChannel(
111
+ // The picture comes before the row and the channel: a render failure fails the create.
112
+ const taken = store
113
+ .agents()
114
+ .flatMap((other) =>
115
+ other.status === "active" && other.character ? [other.character] : [],
116
+ );
117
+ const suggested = suggestCharacter(input, taken);
118
+ const character = this.#resolve(wanted ?? {}, suggested);
119
+ const avatarHash = await studio.render(character);
120
+ // Without Discord channels the agent lives in its rooms, which exist as soon as it does.
121
+ const channelId = await channels?.createChannel(
112
122
  input.name,
113
123
  input.displayName,
114
124
  parent,
115
125
  );
116
126
  const agent = await store.createAgent({
117
127
  ...fields,
118
- avatarPrompt: drawing ? avatarPrompt : "",
119
- channelId,
128
+ character,
129
+ avatarHash,
130
+ ...(channelId ? { channelId } : {}),
120
131
  });
121
132
  if (skills && carried.length > 0)
122
133
  await skills.attach(agent.name, carried, []);
123
- logger.info({ agent: agent.name, by: caller.name }, "agent created");
124
- this.#ctx.changed();
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",
133
- (error: unknown) =>
134
- `its avatar could not be drawn (${error instanceof Error ? error.message : String(error)}), so it uses ${assistantName()}'s for now`,
134
+ logger.info(
135
+ { agent: agent.name, by: caller.name, class: character.class },
136
+ "agent created",
135
137
  );
136
- const home = discordKey(channelId);
138
+ this.#ctx.changed();
139
+ const avatar = `its picture is the ${character.class} look`;
140
+ // The opening task runs where the agent is first reached: its channel, else its first room.
141
+ const home = channelId
142
+ ? discordKey(channelId)
143
+ : keysOf(store, agent.name, surfaces?.roomSurfaces() ?? [])[0];
137
144
  const creator = store.agent(caller.name);
145
+ const where = channelId ? ` in <#${channelId}>` : "";
146
+ if (!home) {
147
+ logger.warn(
148
+ { agent: agent.name },
149
+ "opening turn skipped: the agent has no channel or room",
150
+ );
151
+ return `Created "${agent.displayName}" (${agent.name})${where}; ${avatar}. It has no room to start its opening task in yet.`;
152
+ }
138
153
  void queue
139
154
  .run(home, () =>
140
155
  this.#ctx.turns.answerBackground(
@@ -150,33 +165,45 @@ export class TeamEditing {
150
165
  .catch((error: unknown) =>
151
166
  logger.error({ agent: agent.name, err: error }, "opening turn failed"),
152
167
  );
153
- return `Created "${agent.displayName}" (${agent.name}) in <#${channelId}>; ${avatar}. It is starting on its opening task now.`;
168
+ return `Created "${agent.displayName}" (${agent.name})${where}; ${avatar}. It is starting on its opening task now.`;
154
169
  }
155
170
 
156
- async avatar(name: string, mode: AvatarMode, text?: string): Promise<string> {
157
- await this.#redraw(name, mode, text);
158
- return `The new avatar of "${this.#ctx.options.store.agent(name)?.displayName ?? name}" shows from its next message.`;
171
+ /** The agent tool: changes a look, then says what the owner sees. */
172
+ async avatar(name: string, look: CharacterInput): Promise<string> {
173
+ const agent = await this.dress(name, look);
174
+ return `"${agent.displayName}" now looks like a ${characterOf(agent).class}; the new picture shows from its next message.`;
159
175
  }
160
176
 
161
- /** Draws and stores a new picture; the owner's panel and the agent tool both use it. */
177
+ /**
178
+ * Changes an agent's look. The input is resolved over the agent's current character; a new
179
+ * class keeps the agent's colors unless the input names a palette or colors. The picture is
180
+ * rendered before anything is stored, and the character and picture are stored together.
181
+ */
182
+ async dress(name: string, input: CharacterInput): Promise<Agent> {
183
+ const { store, studio, logger } = this.#ctx.options;
184
+ const agent = store.activeAgent(name);
185
+ const base = characterOf(agent);
186
+ const character = this.#resolve(input, base);
187
+ let avatarHash: string;
188
+ try {
189
+ avatarHash = await studio.render(character);
190
+ } catch (error) {
191
+ throw new AgentError(
192
+ `The picture could not be rendered: ${error instanceof Error ? error.message : String(error)}. The previous look stays.`,
193
+ );
194
+ }
195
+ const dressed = await store.updateAgent(name, { character, avatarHash });
196
+ logger.info({ agent: name, class: character.class }, "agent dressed");
197
+ this.#ctx.changed();
198
+ return dressed;
199
+ }
200
+
201
+ /** @deprecated Use `dress`; removed in 1.0. Draws and stores a new picture. */
162
202
  async redrawAvatar(
163
203
  name: string,
164
204
  mode: AvatarMode,
165
205
  text?: string,
166
206
  ): Promise<Agent> {
167
- return this.#redraw(name, mode, text);
168
- }
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
-
179
- async #redraw(name: string, mode: AvatarMode, text?: string): Promise<Agent> {
180
207
  const { store, studio, logger } = this.#ctx.options;
181
208
  const agent = store.activeAgent(name);
182
209
  if (studio.canDraw === false) throw new AgentError(NO_IMAGE_PROVIDER);
@@ -205,6 +232,19 @@ export class TeamEditing {
205
232
  return store.updateAgent(name, { avatarHash: hash });
206
233
  }
207
234
 
235
+ /**
236
+ * An input over a base look (see overBase); a bad field is an AgentError that names it.
237
+ */
238
+ #resolve(input: CharacterInput, base: CharacterSpec): CharacterSpec {
239
+ try {
240
+ return resolveCharacter(overBase(input, base), base);
241
+ } catch (error) {
242
+ if (error instanceof CharacterError)
243
+ throw new AgentError(`The look is not usable: ${error.message}.`);
244
+ throw error;
245
+ }
246
+ }
247
+
208
248
  async createGroup(input: {
209
249
  name: string;
210
250
  displayName: string;
@@ -221,15 +261,16 @@ export class TeamEditing {
221
261
  ? "coordinator"
222
262
  : (input.members[0] ?? ""));
223
263
  store.checkNewGroup(input.name, input.members, host);
224
- const topic = groupTopic(
225
- { ...fields, host, channelId: "", status: "active" },
226
- store,
227
- );
228
- const channelId = await channels.createChannel(input.name, topic, parent);
229
- const group = await store.createGroup({ ...fields, host, channelId });
264
+ const topic = groupTopic({ ...fields, host, status: "active" }, store);
265
+ const channelId = await channels?.createChannel(input.name, topic, parent);
266
+ const group = await store.createGroup({
267
+ ...fields,
268
+ host,
269
+ ...(channelId ? { channelId } : {}),
270
+ });
230
271
  logger.info({ group: group.name, members: group.members }, "group created");
231
272
  this.#ctx.changed();
232
- return `Created the group "${group.displayName}" in <#${channelId}> with ${group.members.join(", ")}; host ${group.host}.`;
273
+ return `Created the group "${group.displayName}"${channelId ? ` in <#${channelId}>` : ""} with ${group.members.join(", ")}; host ${group.host}.`;
233
274
  }
234
275
 
235
276
  async updateGroup(
@@ -1,5 +1,6 @@
1
1
  import { channelKey, parseChannelKey } from "../contract/surface.ts";
2
2
  import type { ChannelKey } from "../domain/conversation.ts";
3
+ import { AgentError } from "../domain/errors.ts";
3
4
  import type { AgentTurnScope } from "../domain/ports.ts";
4
5
  import type { AgentDirectory } from "../services.ts";
5
6
  import type { Agent, AgentGroup } from "./agent-store.ts";
@@ -20,34 +21,199 @@ export function discordIdOf(channel: ChannelKey): string | undefined {
20
21
  /** The id part of a key the caller already knows is a Discord one, because the agent server owns it. */
21
22
  export const channelIdOf = (channel: ChannelKey) => parseChannelKey(channel).id;
22
23
 
23
- /** An agent's conversation inside a group, apart from its own. */
24
- export const groupSessionKey = (group: AgentGroup, agent: string): ChannelKey =>
25
- `agentgroup:${group.channelId}.${agent}`;
24
+ /** The key of an agent's room on a surface that serves agent rooms. */
25
+ export const agentRoomKey = (surface: string, agent: string): ChannelKey =>
26
+ channelKey(surface, `agent.${agent}`);
26
27
 
27
- /** An agent's own conversation. */
28
+ /** The key of a group's room on a surface that serves agent rooms. */
29
+ export const groupRoomKey = (surface: string, group: string): ChannelKey =>
30
+ channelKey(surface, `group.${group}`);
31
+
32
+ const ROOM_ID = /^(agent|group)\.([a-z0-9-]{1,32})$/;
33
+
34
+ /**
35
+ * Which agent's or group's room a key is, when its surface is one of `roomSurfaces` and its id has
36
+ * the exact shape of a room; undefined for anything else, so no other key is ever taken for one.
37
+ * Discord never hosts rooms.
38
+ */
39
+ export function roomOf(
40
+ key: ChannelKey,
41
+ roomSurfaces: readonly string[],
42
+ ): { kind: "agent" | "group"; name: string } | undefined {
43
+ const at = typeof key === "string" ? key.indexOf(":") : -1;
44
+ if (at <= 0) return undefined;
45
+ const surface = key.slice(0, at);
46
+ if (surface === DISCORD_SURFACE || !roomSurfaces.includes(surface))
47
+ return undefined;
48
+ const match = ROOM_ID.exec(key.slice(at + 1));
49
+ if (!match) return undefined;
50
+ return { kind: match[1] as "agent" | "group", name: match[2] as string };
51
+ }
52
+
53
+ /** What the surfaces say of rooms: which of them host agent rooms, asked only for a key that is not a Discord one. */
54
+ interface RoomHosts {
55
+ roomSurfaces(): readonly string[];
56
+ }
57
+
58
+ /** The agent whose Discord channel or room the key is; an archived one is returned too. */
59
+ export function agentOfChannel(
60
+ store: Pick<AgentDirectory, "agent" | "agentByChannel">,
61
+ hosts: RoomHosts | undefined,
62
+ channel: ChannelKey,
63
+ ): Agent | undefined {
64
+ const id = discordIdOf(channel);
65
+ if (id !== undefined) return store.agentByChannel(id);
66
+ const room = roomOf(channel, hosts?.roomSurfaces() ?? []);
67
+ return room?.kind === "agent" ? store.agent(room.name) : undefined;
68
+ }
69
+
70
+ /** The group whose Discord channel or room the key is; an archived one is returned too. */
71
+ export function groupOfChannel(
72
+ store: Pick<AgentDirectory, "group" | "groupByChannel">,
73
+ hosts: RoomHosts | undefined,
74
+ channel: ChannelKey,
75
+ ): AgentGroup | undefined {
76
+ const id = discordIdOf(channel);
77
+ if (id !== undefined) return store.groupByChannel(id);
78
+ const room = roomOf(channel, hosts?.roomSurfaces() ?? []);
79
+ return room?.kind === "group" ? store.group(room.name) : undefined;
80
+ }
81
+
82
+ /** The surface of a key that is not a Discord channel's; undefined for a Discord channel. */
83
+ export const roomSurfaceOf = (room: ChannelKey): string | undefined => {
84
+ const at = typeof room === "string" ? room.indexOf(":") : -1;
85
+ if (at <= 0) return undefined;
86
+ const surface = room.slice(0, at);
87
+ return surface === DISCORD_SURFACE ? undefined : surface;
88
+ };
89
+
90
+ /**
91
+ * An agent's conversation inside a group, apart from its own. In a Discord group channel, or
92
+ * without a room, it is keyed by the channel; in a room of another surface, by the surface and the
93
+ * group's name, so the room's seats are conversations of their own.
94
+ */
95
+ export function groupSessionKey(
96
+ group: AgentGroup,
97
+ agent: string,
98
+ room?: ChannelKey,
99
+ ): ChannelKey {
100
+ const surface = room === undefined ? undefined : roomSurfaceOf(room);
101
+ if (surface !== undefined)
102
+ return `agentgroup:${surface}.group.${group.name}.${agent}`;
103
+ // A group of a team without Discord has rooms only, so it never has a seat of its own to key.
104
+ if (!group.channelId)
105
+ throw new AgentError(`the group ${group.name} has no Discord channel`);
106
+ return `agentgroup:${group.channelId}.${agent}`;
107
+ }
108
+
109
+ /**
110
+ * The name a group's messages are logged under: the group's own in its Discord channel, and
111
+ * `<group>@<surface>` in a room, so each conversation keeps its own history and cursors.
112
+ */
113
+ export function groupLogName(group: AgentGroup, room: ChannelKey): string {
114
+ const surface = roomSurfaceOf(room);
115
+ return surface === undefined ? group.name : `${group.name}@${surface}`;
116
+ }
117
+
118
+ /**
119
+ * The coordinator: with Discord, the agent in the entry channel; without it, the agent the
120
+ * configuration names. Undefined while none is active.
121
+ */
122
+ export function coordinatorOf(
123
+ store: Pick<AgentDirectory, "agent" | "agentByChannel">,
124
+ team: {
125
+ entryChannelId?: string;
126
+ coordinator?: string | (() => string | undefined);
127
+ },
128
+ ): Agent | undefined {
129
+ if (team.entryChannelId !== undefined)
130
+ return store.agentByChannel(team.entryChannelId);
131
+ const name =
132
+ typeof team.coordinator === "function"
133
+ ? team.coordinator()
134
+ : team.coordinator;
135
+ const named = name ? store.agent(name) : undefined;
136
+ return named?.status === "active" ? named : undefined;
137
+ }
138
+
139
+ /** An agent's own channel conversation. */
28
140
  export function homeScope(agent: Agent): AgentTurnScope {
29
141
  const home = discordKey(agent.channelId ?? "");
30
142
  return { name: agent.name, session: home, home };
31
143
  }
32
144
 
33
- /** An agent's conversation inside a group. */
34
- export function groupScope(agent: Agent, group: AgentGroup): AgentTurnScope {
145
+ /** An agent's conversation in its room: the room is its session and its home. */
146
+ export function roomScope(agent: Agent, room: ChannelKey): AgentTurnScope {
147
+ return { name: agent.name, session: room, home: room };
148
+ }
149
+
150
+ /**
151
+ * An agent's conversation inside a group: in a Discord group channel, or without a room, as
152
+ * before; in a room, the room's seat, whose home is the agent's own room on that surface.
153
+ */
154
+ export function groupScope(
155
+ agent: Agent,
156
+ group: AgentGroup,
157
+ room?: ChannelKey,
158
+ ): AgentTurnScope {
159
+ const surface = room === undefined ? undefined : roomSurfaceOf(room);
35
160
  return {
36
161
  name: agent.name,
37
- session: groupSessionKey(group, agent.name),
38
- home: discordKey(agent.channelId ?? ""),
162
+ session: groupSessionKey(group, agent.name, room),
163
+ home:
164
+ surface === undefined
165
+ ? discordKey(agent.channelId ?? "")
166
+ : agentRoomKey(surface, agent.name),
39
167
  group: group.name,
40
168
  };
41
169
  }
42
170
 
43
- /** The agent or group that owns a channel of the agent server. */
171
+ /**
172
+ * The agent or group that owns a channel of the agent server: by Discord channel, or by room on
173
+ * one of `roomSurfaces`. An archived agent or group owns nothing.
174
+ */
44
175
  export function channelOwner(
45
- store: Pick<AgentDirectory, "agentByChannel" | "groupByChannel">,
176
+ store: Pick<AgentDirectory, "agentByChannel" | "groupByChannel"> &
177
+ Partial<Pick<AgentDirectory, "agent" | "group">>,
46
178
  channel: ChannelKey,
179
+ roomSurfaces: readonly string[] = [],
47
180
  ): "agent" | "group" | undefined {
48
181
  const id = discordIdOf(channel);
49
- if (id === undefined) return undefined;
182
+ if (id === undefined) {
183
+ const room = roomOf(channel, roomSurfaces);
184
+ if (!room) return undefined;
185
+ if (room.kind === "agent")
186
+ return store.agent?.(room.name)?.status === "active"
187
+ ? "agent"
188
+ : undefined;
189
+ return store.group?.(room.name)?.status === "active" ? "group" : undefined;
190
+ }
50
191
  if (store.agentByChannel(id)) return "agent";
51
192
  if (store.groupByChannel(id)) return "group";
52
193
  return undefined;
53
194
  }
195
+
196
+ /**
197
+ * Every key an agent or group is addressed by: its Discord channel, when it has one, then its
198
+ * room on each of `roomSurfaces`; none for a name the team does not know. Archived ones count,
199
+ * so what they left behind can be cleared.
200
+ */
201
+ export function keysOf(
202
+ store: Pick<AgentDirectory, "agent" | "group">,
203
+ name: string,
204
+ roomSurfaces: readonly string[],
205
+ ): ChannelKey[] {
206
+ const agent = store.agent(name);
207
+ if (agent)
208
+ return [
209
+ ...(agent.channelId ? [discordKey(agent.channelId)] : []),
210
+ ...roomSurfaces.map((surface) => agentRoomKey(surface, name)),
211
+ ];
212
+ const group = store.group(name);
213
+ if (group)
214
+ return [
215
+ ...(group.channelId ? [discordKey(group.channelId)] : []),
216
+ ...roomSurfaces.map((surface) => groupRoomKey(surface, name)),
217
+ ];
218
+ return [];
219
+ }