pi-roundtable 0.24.2 → 0.26.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 (81) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/docs/plugins.md +174 -7
  3. package/package.json +1 -1
  4. package/src/cli/checks/agent-tools.ts +23 -0
  5. package/src/cli/doctor.ts +6 -0
  6. package/src/cli/principal.ts +9 -2
  7. package/src/core/agents/agent-book.ts +155 -0
  8. package/src/core/agents/agent-ports.ts +5 -1
  9. package/src/core/agents/agent-prompt.ts +6 -1
  10. package/src/core/agents/agent-schema.ts +3 -0
  11. package/src/core/agents/agent-statements.ts +179 -0
  12. package/src/core/agents/agent-store.ts +184 -191
  13. package/src/core/agents/agent-team-fixture.ts +21 -2
  14. package/src/core/agents/agent-team.ts +44 -36
  15. package/src/core/agents/agent-tools.ts +12 -6
  16. package/src/core/agents/team-editing.ts +154 -32
  17. package/src/core/agents/team-keys.ts +17 -0
  18. package/src/core/agents/team-lifecycle.ts +96 -23
  19. package/src/core/agents/team-options.ts +9 -4
  20. package/src/core/agents/team-selection.ts +32 -0
  21. package/src/core/builtin/agent-server.ts +30 -5
  22. package/src/core/builtin/channel-context.ts +21 -0
  23. package/src/core/builtin/discord.ts +4 -1
  24. package/src/core/builtin/prompts.ts +40 -0
  25. package/src/core/builtin/skills.ts +9 -5
  26. package/src/core/config/config-schema.ts +22 -6
  27. package/src/core/config/config.ts +30 -2
  28. package/src/core/config/conversations.ts +21 -0
  29. package/src/core/config/profiles.ts +53 -0
  30. package/src/core/contract/channel-context.ts +106 -0
  31. package/src/core/contract/surface.ts +16 -0
  32. package/src/core/conversations/conversation-registry.ts +6 -0
  33. package/src/core/conversations/conversation-retention.ts +107 -0
  34. package/src/core/conversations/conversation-store.ts +6 -0
  35. package/src/core/conversations/conversations-plugin.ts +19 -3
  36. package/src/core/define-roundtable.ts +12 -2
  37. package/src/core/discord/channel-context.ts +47 -78
  38. package/src/core/discord/discord-surface.ts +16 -0
  39. package/src/core/events.ts +1 -0
  40. package/src/core/host-registration.ts +4 -0
  41. package/src/core/host.ts +3 -1
  42. package/src/core/i18n/agent-panel.ts +4 -0
  43. package/src/core/i18n/cards.ts +1 -1
  44. package/src/core/identity/actor-facts.ts +7 -0
  45. package/src/core/identity/identity-plugin.ts +27 -5
  46. package/src/core/identity/identity-port.ts +62 -0
  47. package/src/core/identity/identity-schema.ts +2 -0
  48. package/src/core/identity/identity-service.ts +45 -52
  49. package/src/core/identity/identity-view.ts +1 -0
  50. package/src/core/identity/person-profiles.ts +275 -0
  51. package/src/core/identity/principal-tables.ts +10 -0
  52. package/src/core/identity/profile-choices.ts +45 -0
  53. package/src/core/identity/profile-memory.ts +65 -0
  54. package/src/core/identity/profile-picture.ts +117 -0
  55. package/src/core/identity/profiles-view.ts +20 -0
  56. package/src/core/identity/profiles.ts +82 -0
  57. package/src/core/modules/host-shell/shell-policy.ts +18 -3
  58. package/src/core/modules/prompts/prompt-documents.ts +425 -0
  59. package/src/core/modules/prompts/prompt-history.ts +103 -0
  60. package/src/core/modules/skills/skill-contract.ts +52 -0
  61. package/src/core/modules/skills/skill-document-files.ts +66 -0
  62. package/src/core/modules/skills/skill-registry.ts +136 -8
  63. package/src/core/modules/skills/skill-rules.ts +94 -0
  64. package/src/core/modules/skills/skill-store.ts +88 -17
  65. package/src/core/modules/skills/skill-tools.ts +18 -10
  66. package/src/core/plugin.ts +40 -0
  67. package/src/core/registry/contributions.ts +8 -1
  68. package/src/core/runtime/chat-only.ts +32 -0
  69. package/src/core/runtime/conversation-sessions.ts +2 -1
  70. package/src/core/runtime/extensions/agent-prompt.ts +20 -5
  71. package/src/core/runtime/extensions/persona-prompt.ts +39 -0
  72. package/src/core/runtime/extensions/prompt-read.ts +22 -0
  73. package/src/core/runtime/runtime-plugin.ts +1 -0
  74. package/src/core/runtime/runtime-types.ts +3 -1
  75. package/src/core/runtime/session-factory.ts +70 -65
  76. package/src/core/runtime/session-helpers.ts +27 -0
  77. package/src/core/services.ts +97 -33
  78. package/src/index.ts +31 -0
  79. package/src/kit/index.ts +1 -0
  80. package/src/kit/oidc.ts +16 -0
  81. package/src/testing.ts +1 -0
@@ -5,7 +5,7 @@ import type { ChannelKey, TurnResult } from "../domain/conversation.ts";
5
5
  import { AgentError, ScheduleError } from "../domain/errors.ts";
6
6
  import type { AgentTurnScope } from "../domain/ports.ts";
7
7
  import type { ThinkingSetting } from "../models.ts";
8
- import { SHELL_TOOLS } from "../modules/host-shell/shell-policy.ts";
8
+ import type { PromptVersion } from "../modules/prompts/prompt-history.ts";
9
9
  import type { ResolvedSkill } from "../modules/skills/skill-rules.ts";
10
10
  import type { AgentChange, AgentTeam } from "../services.ts";
11
11
  import type { TurnSelection } from "../sessions.ts";
@@ -13,11 +13,9 @@ import { type Speaker, THE_SPEAKER } from "../speakers.ts";
13
13
  import { agentSystemPrompt } from "./agent-prompt.ts";
14
14
  import type { Agent } from "./agent-store.ts";
15
15
  import {
16
- AGENT_TOOLS,
17
16
  type AgentOps,
18
17
  type AvatarMode,
19
18
  agentToolsExtension,
20
- MESSAGE_AGENT_TOOL,
21
19
  } from "./agent-tools.ts";
22
20
  import { TeamEditing } from "./team-editing.ts";
23
21
  import {
@@ -36,6 +34,7 @@ import {
36
34
  } from "./team-layout.ts";
37
35
  import { TeamLifecycle } from "./team-lifecycle.ts";
38
36
  import type { AgentTeamOptions, TeamContext } from "./team-options.ts";
37
+ import { teamSelection } from "./team-selection.ts";
39
38
  import { type TeamStatus, teamStatus } from "./team-status.ts";
40
39
  import {
41
40
  agentDetails,
@@ -137,22 +136,7 @@ export class DiscordAgentTeam implements AgentOps, AgentTeam {
137
136
 
138
137
  /** The tools every agent turn runs with: the plugins', plus the shell and agent tools. */
139
138
  selection(scope?: AgentTurnScope): TurnSelection {
140
- const plugged = this.#options.pluginSelection();
141
- return {
142
- id: "agent",
143
- tools: [
144
- ...new Set([
145
- ...plugged.tools,
146
- ...SHELL_TOOLS,
147
- // Group members hand off by mentioning each other, so a group seat has no message_agent.
148
- ...AGENT_TOOLS.filter(
149
- (tool) => !(scope?.group && tool === MESSAGE_AGENT_TOOL),
150
- ),
151
- ...(this.#options.pluginTools?.() ?? []),
152
- ]),
153
- ],
154
- groups: plugged.groups,
155
- };
139
+ return teamSelection(this.#options, scope);
156
140
  }
157
141
 
158
142
  /** The system prompt addition of an agent session, read before each of its runs. */
@@ -165,9 +149,14 @@ export class DiscordAgentTeam implements AgentOps, AgentTeam {
165
149
  owner,
166
150
  shellUser,
167
151
  scratchDir,
152
+ agentTools,
168
153
  } = this.#options;
169
154
  const agent = store.agent(scope.name);
170
155
  if (!agent) throw new AgentError(`no agent ${scope.name}`);
156
+ const shared =
157
+ typeof sharedPrompt === "function" ? sharedPrompt() : sharedPrompt;
158
+ const guest =
159
+ typeof guestPrompt === "function" ? guestPrompt() : guestPrompt;
171
160
  const group = scope.group ? store.group(scope.group) : undefined;
172
161
  const coordinator = coordinatorOf(store, this.#options);
173
162
  const speaker = this.#turns.speakerOf(scope);
@@ -175,13 +164,14 @@ export class DiscordAgentTeam implements AgentOps, AgentTeam {
175
164
  agent,
176
165
  ...(speaker ? { speaker } : {}),
177
166
  ...(coordinator ? { coordinator } : {}),
178
- shared: sharedPrompt,
179
- ...(guestPrompt ? { guestShared: guestPrompt } : {}),
167
+ shared,
168
+ ...(guest ? { guestShared: guest } : {}),
180
169
  workDir,
181
170
  owner,
182
171
  shellUser,
183
172
  ...(scratchDir ? { scratchDir } : {}),
184
173
  images: this.#images(),
174
+ ...(agentTools ? { agentTools } : {}),
185
175
  ...(group
186
176
  ? {
187
177
  group: {
@@ -292,14 +282,21 @@ export class DiscordAgentTeam implements AgentOps, AgentTeam {
292
282
  return this.#lifecycle.channelDeleted(channelId);
293
283
  }
294
284
 
295
- /**
296
- * Archives an agent or group like a deleted channel, then keeps its channel under Archive,
297
- * where no one owns it and so nothing answers.
298
- */
299
- archive(caller: AgentTurnScope, name: string): Promise<string> {
285
+ /** Archives an agent or group like a deleted channel, keeping its channel under Archive. */
286
+ archive(caller: AgentTurnScope | undefined, name: string): Promise<string> {
300
287
  return this.#lifecycle.archive(caller, name);
301
288
  }
302
289
 
290
+ /** Returns an archived agent or group to the active ones; see `AgentTeam.restore`. */
291
+ restore(name: string): Promise<string> {
292
+ return this.#lifecycle.restore(name);
293
+ }
294
+
295
+ /** Deletes an archived agent or group for good; see `AgentTeam.remove`. */
296
+ remove(name: string, cleanup?: () => Promise<void>): Promise<string> {
297
+ return this.#lifecycle.remove(name, cleanup);
298
+ }
299
+
303
300
  // ── Turns, run by TeamTurns ────────────────────────────────────────────
304
301
 
305
302
  /**
@@ -442,8 +439,15 @@ export class DiscordAgentTeam implements AgentOps, AgentTeam {
442
439
  * Changes an agent's display name, prompt, model, or thinking level; `default` returns the
443
440
  * model or thinking level to the assistant's. A model must be one the host runs.
444
441
  */
445
- update(name: string, change: AgentChange): Promise<string> {
446
- return this.#editing.update(name, change);
442
+ update(name: string, change: AgentChange, by?: string): Promise<string> {
443
+ return this.#editing.update(name, change, by);
444
+ }
445
+
446
+ /** The changes of an agent's prompt, newest first. */
447
+ async promptHistory(name: string, limit?: number): Promise<PromptVersion[]> {
448
+ if (!this.#options.store.agent(name))
449
+ throw new AgentError(`There is no agent "${name}".`);
450
+ return this.#options.store.promptHistory(name, limit);
447
451
  }
448
452
 
449
453
  create(
@@ -453,6 +457,14 @@ export class DiscordAgentTeam implements AgentOps, AgentTeam {
453
457
  return this.#editing.create(caller, input);
454
458
  }
455
459
 
460
+ /** An owner creates an agent outside any agent turn; see `AgentTeam.createAgent`. */
461
+ createAgent(
462
+ by: Speaker,
463
+ input: Parameters<TeamEditing["createFor"]>[1],
464
+ ): Promise<Agent> {
465
+ return this.#editing.createFor(by, input);
466
+ }
467
+
456
468
  /** Changes an agent's look; see `AgentTeam.dress`. */
457
469
  dress(name: string, input: CharacterInput): Promise<Agent> {
458
470
  return this.#editing.dress(name, input);
@@ -468,19 +480,15 @@ export class DiscordAgentTeam implements AgentOps, AgentTeam {
468
480
  return this.#editing.redrawAvatar(name, mode, text);
469
481
  }
470
482
 
471
- createGroup(input: {
472
- name: string;
473
- displayName: string;
474
- members: string[];
475
- host?: string;
476
- category?: string;
477
- }): Promise<string> {
483
+ createGroup(
484
+ input: Parameters<TeamEditing["createGroup"]>[0],
485
+ ): Promise<string> {
478
486
  return this.#editing.createGroup(input);
479
487
  }
480
488
 
481
489
  updateGroup(
482
490
  name: string,
483
- change: { displayName?: string; members?: string[]; host?: string },
491
+ change: Parameters<TeamEditing["updateGroup"]>[1],
484
492
  ): Promise<string> {
485
493
  return this.#editing.updateGroup(name, change);
486
494
  }
@@ -65,6 +65,8 @@ export interface AgentOps {
65
65
  model?: string;
66
66
  thinking?: string;
67
67
  },
68
+ /** Who changes it: the agent whose turn asks, for the prompt history. */
69
+ by?: string,
68
70
  ): Promise<string>;
69
71
  create(
70
72
  caller: AgentTurnScope,
@@ -245,12 +247,16 @@ function toolDefs(
245
247
  const prompt = optionalString(i, "prompt");
246
248
  const model = optionalString(i, "model");
247
249
  const thinking = optionalString(i, "thinking");
248
- return ops.update(requiredString(i, "name"), {
249
- ...(displayName !== undefined ? { displayName } : {}),
250
- ...(prompt !== undefined ? { prompt } : {}),
251
- ...(model !== undefined ? { model } : {}),
252
- ...(thinking !== undefined ? { thinking } : {}),
253
- });
250
+ return ops.update(
251
+ requiredString(i, "name"),
252
+ {
253
+ ...(displayName !== undefined ? { displayName } : {}),
254
+ ...(prompt !== undefined ? { prompt } : {}),
255
+ ...(model !== undefined ? { model } : {}),
256
+ ...(thinking !== undefined ? { thinking } : {}),
257
+ },
258
+ scope.name,
259
+ );
254
260
  },
255
261
  },
256
262
  {
@@ -5,16 +5,17 @@ import {
5
5
  resolveCharacter,
6
6
  suggestCharacter,
7
7
  } from "../../characters/index.ts";
8
+ import { parseChannelKey } from "../contract/surface.ts";
8
9
  import { AgentError } from "../domain/errors.ts";
9
10
  import type { AgentTurnScope } from "../domain/ports.ts";
10
11
  import type { AgentChange } from "../services.ts";
11
- import { addressee } from "../speakers.ts";
12
+ import { addressee, type Speaker } from "../speakers.ts";
12
13
  import { openingTaskText } from "./agent-prompt.ts";
13
14
  import { characterOf, overBase } from "./agent-rules.ts";
14
15
  import { modelSetting, thinkingSetting } from "./agent-settings.ts";
15
16
  import type { Agent } from "./agent-store.ts";
16
17
  import type { AvatarMode } from "./agent-tools.ts";
17
- import { discordKey, keysOf } from "./team-keys.ts";
18
+ import { agentRoomKey, discordKey, keysOf, seatsOf } from "./team-keys.ts";
18
19
  import { groupTopic, teamCategory } from "./team-layout.ts";
19
20
  import type { TeamContext } from "./team-options.ts";
20
21
 
@@ -34,7 +35,11 @@ export class TeamEditing {
34
35
  * Changes an agent's display name, prompt, model, or thinking level; `default` returns the
35
36
  * model or thinking level to the assistant's. A model must be one the host runs.
36
37
  */
37
- async update(name: string, change: AgentChange): Promise<string> {
38
+ async update(
39
+ name: string,
40
+ change: AgentChange,
41
+ by?: string,
42
+ ): Promise<string> {
38
43
  const { store, logger } = this.#ctx.options;
39
44
  if (Object.values(change).every((value) => value === undefined))
40
45
  throw new AgentError(
@@ -44,14 +49,18 @@ export class TeamEditing {
44
49
  this.#ctx.options.models.usable(),
45
50
  );
46
51
  const thinking = thinkingSetting(change.thinking);
47
- const agent = await store.updateAgent(name, {
48
- ...(change.displayName !== undefined
49
- ? { displayName: change.displayName }
50
- : {}),
51
- ...(change.prompt !== undefined ? { prompt: change.prompt } : {}),
52
- ...(model !== undefined ? { model } : {}),
53
- ...(thinking !== undefined ? { thinking } : {}),
54
- });
52
+ const agent = await store.updateAgent(
53
+ name,
54
+ {
55
+ ...(change.displayName !== undefined
56
+ ? { displayName: change.displayName }
57
+ : {}),
58
+ ...(change.prompt !== undefined ? { prompt: change.prompt } : {}),
59
+ ...(model !== undefined ? { model } : {}),
60
+ ...(thinking !== undefined ? { thinking } : {}),
61
+ },
62
+ by,
63
+ );
55
64
  logger.info(
56
65
  {
57
66
  agent: name,
@@ -72,6 +81,7 @@ export class TeamEditing {
72
81
  return `Updated "${agent.displayName}" (${agent.name}); it applies from its next turn. It runs ${runs} with thinking ${level}.`;
73
82
  }
74
83
 
84
+ /** The agent tool: an agent creates another, which starts on the opening task at once. */
75
85
  async create(
76
86
  caller: AgentTurnScope,
77
87
  input: {
@@ -87,27 +97,109 @@ export class TeamEditing {
87
97
  skills?: string[];
88
98
  },
89
99
  ): Promise<string> {
90
- const { store, studio, channels, queue, skills, surfaces, logger } =
91
- this.#ctx.options;
92
- if (input.skills && input.skills.length > 0 && !skills)
93
- throw new AgentError(
94
- "Skills are off on this host, so an agent cannot carry any. Create it without skills.",
95
- );
100
+ this.#refuseSkillsOff(input.skills);
96
101
  const speaker = this.#ctx.turns.speakerOf(caller);
97
102
  if (!speaker)
98
103
  throw new AgentError(
99
104
  "agent_create can only be used during an agent turn.",
100
105
  );
106
+ // The opening task starts on the surface the caller's turn runs on, as the web form's does.
107
+ const working = this.#ctx.turns.workingIn(caller.name);
108
+ const made = await this.#create(
109
+ {
110
+ speaker,
111
+ by: caller.name,
112
+ creator: this.#ctx.options.store.agent(caller.name),
113
+ openIn: working ? parseChannelKey(working).surface : undefined,
114
+ },
115
+ input,
116
+ );
117
+ return made.text;
118
+ }
119
+
120
+ /**
121
+ * An owner creates an agent from outside any agent turn (the web app's form; any other tier is
122
+ * refused, since the opening task runs as `speaker`): the same checks,
123
+ * picture, channel and opening task as `create`, and the model and thinking level the owner
124
+ * picked. Without a task the agent only exists, waiting in its room. Returns the new agent.
125
+ */
126
+ async createFor(
127
+ speaker: Speaker,
128
+ input: {
129
+ name: string;
130
+ displayName: string;
131
+ prompt: string;
132
+ character?: CharacterInput;
133
+ task?: string;
134
+ openIn?: string;
135
+ category?: string;
136
+ skills?: string[];
137
+ model?: string;
138
+ thinking?: string;
139
+ },
140
+ ): Promise<Agent> {
141
+ if (speaker.tier !== "owner")
142
+ throw new AgentError("Only an owner may create an agent here.");
143
+ const { model, thinking, task, openIn, ...rest } = input;
144
+ const made = await this.#create(
145
+ { speaker, by: speaker.name, creator: undefined, openIn },
146
+ { ...rest, ...(task ? { task } : {}) },
147
+ { model, thinking },
148
+ );
149
+ return made.agent;
150
+ }
151
+
152
+ /** An agent cannot carry skills on a host with skills switched off. */
153
+ #refuseSkillsOff(carried: string[] | undefined): void {
154
+ if (carried && carried.length > 0 && !this.#ctx.options.skills)
155
+ throw new AgentError(
156
+ "Skills are off on this host, so an agent cannot carry any. Create it without skills.",
157
+ );
158
+ }
159
+
160
+ async #create(
161
+ who: {
162
+ speaker: Speaker;
163
+ by: string;
164
+ creator: Agent | undefined;
165
+ /** The surface whose room runs the opening task. */
166
+ openIn?: string | undefined;
167
+ },
168
+ input: {
169
+ name: string;
170
+ displayName: string;
171
+ prompt: string;
172
+ /** @deprecated Ignored: agents are composed characters. */
173
+ avatarPrompt?: string;
174
+ character?: CharacterInput;
175
+ task?: string;
176
+ category?: string;
177
+ skills?: string[];
178
+ },
179
+ settings: {
180
+ model?: string | undefined;
181
+ thinking?: string | undefined;
182
+ } = {},
183
+ ): Promise<{ agent: Agent; text: string }> {
184
+ const { store, studio, channels, queue, skills, surfaces, logger } =
185
+ this.#ctx.options;
186
+ this.#refuseSkillsOff(input.skills);
101
187
  const {
102
188
  category,
103
189
  skills: carried = [],
104
190
  character: wanted,
105
191
  avatarPrompt: _ignored,
192
+ task,
106
193
  ...fields
107
194
  } = input;
108
195
  const parent = teamCategory(category, "Agents");
109
196
  store.checkNewName(input.name);
110
197
  skills?.checkRegistered(carried);
198
+ // A model or thinking level the host cannot run fails the create, before anything is made.
199
+ const model = await modelSetting(settings.model, () =>
200
+ this.#ctx.options.models.usable(),
201
+ );
202
+ const thinking = thinkingSetting(settings.thinking);
111
203
  // The picture comes before the row and the channel: a render failure fails the create.
112
204
  const taken = store
113
205
  .agents()
@@ -123,49 +215,69 @@ export class TeamEditing {
123
215
  input.displayName,
124
216
  parent,
125
217
  );
126
- const agent = await store.createAgent({
218
+ let agent = await store.createAgent({
127
219
  ...fields,
128
220
  character,
129
221
  avatarHash,
130
222
  ...(channelId ? { channelId } : {}),
131
223
  });
224
+ if (model || thinking)
225
+ agent = await store.updateAgent(agent.name, {
226
+ ...(model ? { model } : {}),
227
+ ...(thinking ? { thinking } : {}),
228
+ });
132
229
  if (skills && carried.length > 0)
133
230
  await skills.attach(agent.name, carried, []);
134
231
  logger.info(
135
- { agent: agent.name, by: caller.name, class: character.class },
232
+ { agent: agent.name, by: who.by, class: character.class },
136
233
  "agent created",
137
234
  );
138
235
  this.#ctx.changed();
139
236
  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];
144
- const creator = store.agent(caller.name);
145
237
  const where = channelId ? ` in <#${channelId}>` : "";
238
+ if (!task)
239
+ return {
240
+ agent,
241
+ text: `Created "${agent.displayName}" (${agent.name})${where}; ${avatar}.`,
242
+ };
243
+ // The opening task runs in the room of the surface that asked, else where the agent is first
244
+ // reached: its channel, else its first room.
245
+ const rooms = surfaces?.roomSurfaces() ?? [];
246
+ const home =
247
+ who.openIn !== undefined && rooms.includes(who.openIn)
248
+ ? agentRoomKey(who.openIn, agent.name)
249
+ : channelId
250
+ ? discordKey(channelId)
251
+ : keysOf(store, agent.name, rooms)[0];
146
252
  if (!home) {
147
253
  logger.warn(
148
254
  { agent: agent.name },
149
255
  "opening turn skipped: the agent has no channel or room",
150
256
  );
151
- return `Created "${agent.displayName}" (${agent.name})${where}; ${avatar}. It has no room to start its opening task in yet.`;
257
+ return {
258
+ agent,
259
+ text: `Created "${agent.displayName}" (${agent.name})${where}; ${avatar}. It has no room to start its opening task in yet.`,
260
+ };
152
261
  }
153
262
  void queue
154
263
  .run(home, () =>
155
264
  this.#ctx.turns.answerBackground(
156
265
  home,
157
- speaker,
266
+ who.speaker,
158
267
  openingTaskText(
159
- creator,
160
- input.task,
161
- addressee(speaker, this.#ctx.options.owner),
268
+ who.creator,
269
+ task,
270
+ addressee(who.speaker, this.#ctx.options.owner),
162
271
  ),
163
272
  ),
164
273
  )
165
274
  .catch((error: unknown) =>
166
275
  logger.error({ agent: agent.name, err: error }, "opening turn failed"),
167
276
  );
168
- return `Created "${agent.displayName}" (${agent.name})${where}; ${avatar}. It is starting on its opening task now.`;
277
+ return {
278
+ agent,
279
+ text: `Created "${agent.displayName}" (${agent.name})${where}; ${avatar}. It is starting on its opening task now.`,
280
+ };
169
281
  }
170
282
 
171
283
  /** The agent tool: changes a look, then says what the owner sees. */
@@ -260,7 +372,7 @@ export class TeamEditing {
260
372
  (input.members.includes("coordinator")
261
373
  ? "coordinator"
262
374
  : (input.members[0] ?? ""));
263
- store.checkNewGroup(input.name, input.members, host);
375
+ store.checkNewGroup(input.name, input.members, host, input.displayName);
264
376
  const topic = groupTopic({ ...fields, host, status: "active" }, store);
265
377
  const channelId = await channels?.createChannel(input.name, topic, parent);
266
378
  const group = await store.createGroup({
@@ -277,7 +389,17 @@ export class TeamEditing {
277
389
  name: string,
278
390
  change: { displayName?: string; members?: string[]; host?: string },
279
391
  ): Promise<string> {
280
- const group = await this.#ctx.options.store.updateGroup(name, change);
392
+ const { store, surfaces } = this.#ctx.options;
393
+ const before = store.group(name);
394
+ const group = await store.updateGroup(name, change);
395
+ // A member taken out starts its seat over, so an agent of that name that joins later
396
+ // does not resume the old conversation.
397
+ const runtime = this.#ctx.options.runtime();
398
+ const rooms = surfaces?.roomSurfaces() ?? [];
399
+ for (const left of before?.members ?? [])
400
+ if (!group.members.includes(left))
401
+ for (const seat of seatsOf(group, left, rooms))
402
+ await runtime.startFresh(seat);
281
403
  this.#ctx.retitle(group);
282
404
  this.#ctx.changed();
283
405
  return `Updated the group "${group.displayName}": members ${group.members.join(", ")}; host ${group.host}.`;
@@ -217,3 +217,20 @@ export function keysOf(
217
217
  ];
218
218
  return [];
219
219
  }
220
+
221
+ /**
222
+ * Every conversation of an agent's seat in a group: the Discord channel's, when the group has
223
+ * one, then each room's on `roomSurfaces`.
224
+ */
225
+ export function seatsOf(
226
+ group: AgentGroup,
227
+ agent: string,
228
+ roomSurfaces: readonly string[],
229
+ ): ChannelKey[] {
230
+ return [
231
+ ...(group.channelId ? [groupSessionKey(group, agent)] : []),
232
+ ...roomSurfaces.map((surface) =>
233
+ groupSessionKey(group, agent, groupRoomKey(surface, group.name)),
234
+ ),
235
+ ];
236
+ }
@@ -6,6 +6,7 @@ import {
6
6
  import type { ChannelKey } from "../domain/conversation.ts";
7
7
  import { AgentError } from "../domain/errors.ts";
8
8
  import type { AgentTurnScope } from "../domain/ports.ts";
9
+ import { messages } from "../i18n/index.ts";
9
10
  import type { AgentChannels } from "./agent-ports.ts";
10
11
  import { overBase } from "./agent-rules.ts";
11
12
  import type { Agent, AgentGroup } from "./agent-store.ts";
@@ -16,11 +17,11 @@ import {
16
17
  discordIdOf,
17
18
  groupLogName,
18
19
  groupOfChannel,
19
- groupRoomKey,
20
20
  groupSessionKey,
21
21
  homeScope,
22
22
  keysOf,
23
23
  roomScope,
24
+ seatsOf,
24
25
  } from "./team-keys.ts";
25
26
  import { categoryKind, groupTopic } from "./team-layout.ts";
26
27
  import type { TeamContext } from "./team-options.ts";
@@ -193,9 +194,13 @@ export class TeamLifecycle {
193
194
  * Archives an agent or group like a deleted channel, then keeps its channel under Archive,
194
195
  * where no one owns it and so nothing answers; an agent or group without a Discord channel
195
196
  * only stops answering in its rooms. Runs at once, not in the target's queue: the caller may be
196
- * a member of the group it archives, and the round stops at its next turn.
197
+ * a member of the group it archives, and the round stops at its next turn. An owner's call from
198
+ * outside any turn has no `caller`, so no agent is protected from archiving itself.
197
199
  */
198
- async archive(caller: AgentTurnScope, name: string): Promise<string> {
200
+ async archive(
201
+ caller: AgentTurnScope | undefined,
202
+ name: string,
203
+ ): Promise<string> {
199
204
  const { store, channels, logger } = this.#ctx.options;
200
205
  const agent = store.agent(name);
201
206
  const group = agent ? undefined : store.group(name);
@@ -208,7 +213,7 @@ export class TeamLifecycle {
208
213
  throw new AgentError(`"${name}" is already archived.`);
209
214
  if (agent && coordinatorOf(store, this.#ctx.options)?.name === name)
210
215
  throw new AgentError("The coordinator cannot be archived.");
211
- if (agent && name === caller.name)
216
+ if (agent && name === caller?.name)
212
217
  throw new AgentError("You cannot archive yourself; ask another agent.");
213
218
  const { channelId } = target;
214
219
  if (agent) await this.#archiveAgent(agent);
@@ -232,6 +237,91 @@ export class TeamLifecycle {
232
237
  return `Archived "${target.displayName}"; its channel <#${channelId}> is kept under Archive.`;
233
238
  }
234
239
 
240
+ /**
241
+ * Returns an archived agent or group to the active ones: its channel leaves Archive for its
242
+ * category (a new channel replaces one deleted meanwhile), and it answers again from its next
243
+ * message. An agent does not rejoin the groups it left; a group needs all its members active.
244
+ */
245
+ async restore(name: string): Promise<string> {
246
+ const { store, channels, logger } = this.#ctx.options;
247
+ const agent = store.agent(name);
248
+ const group = agent ? undefined : store.group(name);
249
+ const target = agent ?? group;
250
+ if (!target)
251
+ throw new AgentError(
252
+ `There is no agent or group "${name}"; see agent_list.`,
253
+ );
254
+ if (target.status === "active")
255
+ throw new AgentError(`"${name}" is not archived.`);
256
+ if (group) store.checkRestorableGroup(name);
257
+ // The store checks again when it restores. A member archived, or a second restore of the
258
+ // same name, between the two checks leaves the channel moved or made with no owner; it
259
+ // then waits under its category until the next restore claims it or an owner deletes it.
260
+ let { channelId } = target;
261
+ if (channels) {
262
+ const category = agent ? "Agents" : "Groups";
263
+ if (channelId && (await channels.exists(channelId)))
264
+ await channels.placeIn(channelId, category);
265
+ else if (agent || channelId)
266
+ channelId = await channels.createChannel(
267
+ name,
268
+ agent ? agent.displayName : groupTopic(target as AgentGroup, store),
269
+ category,
270
+ );
271
+ }
272
+ if (agent) await store.restoreAgent(name, channelId);
273
+ else {
274
+ const restored = await store.restoreGroup(name, channelId);
275
+ this.retitle(restored);
276
+ }
277
+ logger.info({ name, kind: agent ? "agent" : "group" }, "restored");
278
+ this.#ctx.changed();
279
+ return `Restored "${target.displayName}"; it answers again.${agent ? " It is in no group until it is added to one." : ""}`;
280
+ }
281
+
282
+ /**
283
+ * Deletes an archived agent or group for good, its name free again. Its carried skills are
284
+ * detached, and the runtime forgets its conversations (an agent's own, its seats in every
285
+ * group, a group's members' seats), so nothing is left for a later agent or group of that name;
286
+ * a Discord channel it had stays under Archive with its history, since Discord is never asked
287
+ * to delete one. An active agent or group is refused: archive it first, and so is an
288
+ * agent the host's seeds name, since the next start would seed it again.
289
+ */
290
+ async remove(name: string, cleanup?: () => Promise<void>): Promise<string> {
291
+ const { store, skills, seeds, logger } = this.#ctx.options;
292
+ const agent = store.agent(name);
293
+ const group = agent ? undefined : store.group(name);
294
+ const target = agent ?? group;
295
+ if (!target)
296
+ throw new AgentError(
297
+ `There is no agent or group "${name}"; see agent_list.`,
298
+ );
299
+ // Every start seeds the configured agents that are missing, so a deleted one would return.
300
+ if (agent && seeds().some((seed) => seed.name === name))
301
+ throw new AgentError(messages().agentSeededNotDeleted);
302
+ if (target.status === "active")
303
+ throw new AgentError(
304
+ `"${name}" is active; archive it before deleting it.`,
305
+ );
306
+ await cleanup?.();
307
+ // What a name keeps in the runtime would be picked up by the next agent or group made under it.
308
+ const runtime = this.#ctx.options.runtime();
309
+ const rooms = this.#rooms();
310
+ const seats = group
311
+ ? group.members.flatMap((member) => seatsOf(group, member, rooms))
312
+ : store.groups().flatMap((other) => seatsOf(other, name, rooms));
313
+ const kept = [...keysOf(store, name, rooms), ...seats];
314
+ for (const key of kept) await runtime.deleteConversation(key);
315
+ if (agent) {
316
+ await store.deleteAgent(name);
317
+ const carried = skills?.carriedNames(name) ?? [];
318
+ if (skills && carried.length > 0) await skills.attach(name, [], carried);
319
+ } else await store.deleteGroup(name);
320
+ logger.info({ name, kind: agent ? "agent" : "group" }, "deleted");
321
+ this.#ctx.changed();
322
+ return `Deleted "${target.displayName}"; the name "${name}" is free again.`;
323
+ }
324
+
235
325
  async #archiveAgent(agent: Agent): Promise<void> {
236
326
  const { store, schedules, logger } = this.#ctx.options;
237
327
  const runtime = this.#ctx.options.runtime();
@@ -244,7 +334,7 @@ export class TeamLifecycle {
244
334
  await runtime.startFresh(key);
245
335
  }
246
336
  for (const group of left) {
247
- for (const seat of this.#seats(group, agent.name, rooms))
337
+ for (const seat of seatsOf(group, agent.name, rooms))
248
338
  await runtime.startFresh(seat);
249
339
  if (group.status === "active") this.retitle(group);
250
340
  }
@@ -261,7 +351,7 @@ export class TeamLifecycle {
261
351
  const rooms = this.#rooms();
262
352
  await store.archiveGroup(group.name);
263
353
  for (const member of group.members)
264
- for (const seat of this.#seats(group, member, rooms))
354
+ for (const seat of seatsOf(group, member, rooms))
265
355
  await runtime.startFresh(seat);
266
356
  logger.info({ group: group.name }, "group archived");
267
357
  this.#ctx.changed();
@@ -272,23 +362,6 @@ export class TeamLifecycle {
272
362
  return this.#ctx.options.surfaces?.roomSurfaces() ?? [];
273
363
  }
274
364
 
275
- /**
276
- * Every conversation of an agent's seat in a group: the Discord channel's, when the group has
277
- * one, then each room's.
278
- */
279
- #seats(
280
- group: AgentGroup,
281
- agent: string,
282
- rooms: readonly string[],
283
- ): ChannelKey[] {
284
- return [
285
- ...(group.channelId ? [groupSessionKey(group, agent)] : []),
286
- ...rooms.map((surface) =>
287
- groupSessionKey(group, agent, groupRoomKey(surface, group.name)),
288
- ),
289
- ];
290
- }
291
-
292
365
  /** Writes a group's members and host into its channel topic, without waiting on Discord; a group without a channel has no topic. */
293
366
  retitle(group: AgentGroup): void {
294
367
  const { channels, store, logger } = this.#ctx.options;