pi-roundtable 0.24.2 → 0.25.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 (36) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/docs/plugins.md +45 -1
  3. package/package.json +1 -1
  4. package/src/core/agents/agent-book.ts +155 -0
  5. package/src/core/agents/agent-ports.ts +5 -1
  6. package/src/core/agents/agent-schema.ts +3 -0
  7. package/src/core/agents/agent-statements.ts +179 -0
  8. package/src/core/agents/agent-store.ts +184 -191
  9. package/src/core/agents/agent-team-fixture.ts +21 -2
  10. package/src/core/agents/agent-team.ts +42 -36
  11. package/src/core/agents/agent-tools.ts +12 -6
  12. package/src/core/agents/team-editing.ts +150 -32
  13. package/src/core/agents/team-keys.ts +17 -0
  14. package/src/core/agents/team-lifecycle.ts +96 -23
  15. package/src/core/agents/team-options.ts +6 -3
  16. package/src/core/agents/team-selection.ts +28 -0
  17. package/src/core/builtin/agent-server.ts +29 -5
  18. package/src/core/builtin/prompts.ts +40 -0
  19. package/src/core/builtin/skills.ts +9 -5
  20. package/src/core/define-roundtable.ts +3 -0
  21. package/src/core/i18n/agent-panel.ts +4 -0
  22. package/src/core/i18n/cards.ts +1 -1
  23. package/src/core/modules/prompts/prompt-documents.ts +425 -0
  24. package/src/core/modules/prompts/prompt-history.ts +103 -0
  25. package/src/core/modules/skills/skill-contract.ts +52 -0
  26. package/src/core/modules/skills/skill-document-files.ts +66 -0
  27. package/src/core/modules/skills/skill-registry.ts +136 -8
  28. package/src/core/modules/skills/skill-rules.ts +94 -0
  29. package/src/core/modules/skills/skill-store.ts +88 -17
  30. package/src/core/modules/skills/skill-tools.ts +18 -10
  31. package/src/core/runtime/extensions/agent-prompt.ts +20 -5
  32. package/src/core/runtime/extensions/persona-prompt.ts +39 -0
  33. package/src/core/runtime/extensions/prompt-read.ts +22 -0
  34. package/src/core/runtime/session-factory.ts +51 -35
  35. package/src/core/services.ts +88 -33
  36. package/src/index.ts +13 -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. */
@@ -168,6 +152,10 @@ export class DiscordAgentTeam implements AgentOps, AgentTeam {
168
152
  } = this.#options;
169
153
  const agent = store.agent(scope.name);
170
154
  if (!agent) throw new AgentError(`no agent ${scope.name}`);
155
+ const shared =
156
+ typeof sharedPrompt === "function" ? sharedPrompt() : sharedPrompt;
157
+ const guest =
158
+ typeof guestPrompt === "function" ? guestPrompt() : guestPrompt;
171
159
  const group = scope.group ? store.group(scope.group) : undefined;
172
160
  const coordinator = coordinatorOf(store, this.#options);
173
161
  const speaker = this.#turns.speakerOf(scope);
@@ -175,8 +163,8 @@ export class DiscordAgentTeam implements AgentOps, AgentTeam {
175
163
  agent,
176
164
  ...(speaker ? { speaker } : {}),
177
165
  ...(coordinator ? { coordinator } : {}),
178
- shared: sharedPrompt,
179
- ...(guestPrompt ? { guestShared: guestPrompt } : {}),
166
+ shared,
167
+ ...(guest ? { guestShared: guest } : {}),
180
168
  workDir,
181
169
  owner,
182
170
  shellUser,
@@ -292,14 +280,21 @@ export class DiscordAgentTeam implements AgentOps, AgentTeam {
292
280
  return this.#lifecycle.channelDeleted(channelId);
293
281
  }
294
282
 
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> {
283
+ /** Archives an agent or group like a deleted channel, keeping its channel under Archive. */
284
+ archive(caller: AgentTurnScope | undefined, name: string): Promise<string> {
300
285
  return this.#lifecycle.archive(caller, name);
301
286
  }
302
287
 
288
+ /** Returns an archived agent or group to the active ones; see `AgentTeam.restore`. */
289
+ restore(name: string): Promise<string> {
290
+ return this.#lifecycle.restore(name);
291
+ }
292
+
293
+ /** Deletes an archived agent or group for good; see `AgentTeam.remove`. */
294
+ remove(name: string, cleanup?: () => Promise<void>): Promise<string> {
295
+ return this.#lifecycle.remove(name, cleanup);
296
+ }
297
+
303
298
  // ── Turns, run by TeamTurns ────────────────────────────────────────────
304
299
 
305
300
  /**
@@ -442,8 +437,15 @@ export class DiscordAgentTeam implements AgentOps, AgentTeam {
442
437
  * Changes an agent's display name, prompt, model, or thinking level; `default` returns the
443
438
  * model or thinking level to the assistant's. A model must be one the host runs.
444
439
  */
445
- update(name: string, change: AgentChange): Promise<string> {
446
- return this.#editing.update(name, change);
440
+ update(name: string, change: AgentChange, by?: string): Promise<string> {
441
+ return this.#editing.update(name, change, by);
442
+ }
443
+
444
+ /** The changes of an agent's prompt, newest first. */
445
+ async promptHistory(name: string, limit?: number): Promise<PromptVersion[]> {
446
+ if (!this.#options.store.agent(name))
447
+ throw new AgentError(`There is no agent "${name}".`);
448
+ return this.#options.store.promptHistory(name, limit);
447
449
  }
448
450
 
449
451
  create(
@@ -453,6 +455,14 @@ export class DiscordAgentTeam implements AgentOps, AgentTeam {
453
455
  return this.#editing.create(caller, input);
454
456
  }
455
457
 
458
+ /** An owner creates an agent outside any agent turn; see `AgentTeam.createAgent`. */
459
+ createAgent(
460
+ by: Speaker,
461
+ input: Parameters<TeamEditing["createFor"]>[1],
462
+ ): Promise<Agent> {
463
+ return this.#editing.createFor(by, input);
464
+ }
465
+
456
466
  /** Changes an agent's look; see `AgentTeam.dress`. */
457
467
  dress(name: string, input: CharacterInput): Promise<Agent> {
458
468
  return this.#editing.dress(name, input);
@@ -468,19 +478,15 @@ export class DiscordAgentTeam implements AgentOps, AgentTeam {
468
478
  return this.#editing.redrawAvatar(name, mode, text);
469
479
  }
470
480
 
471
- createGroup(input: {
472
- name: string;
473
- displayName: string;
474
- members: string[];
475
- host?: string;
476
- category?: string;
477
- }): Promise<string> {
481
+ createGroup(
482
+ input: Parameters<TeamEditing["createGroup"]>[0],
483
+ ): Promise<string> {
478
484
  return this.#editing.createGroup(input);
479
485
  }
480
486
 
481
487
  updateGroup(
482
488
  name: string,
483
- change: { displayName?: string; members?: string[]; host?: string },
489
+ change: Parameters<TeamEditing["updateGroup"]>[1],
484
490
  ): Promise<string> {
485
491
  return this.#editing.updateGroup(name, change);
486
492
  }
@@ -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
  {
@@ -8,13 +8,13 @@ import {
8
8
  import { AgentError } from "../domain/errors.ts";
9
9
  import type { AgentTurnScope } from "../domain/ports.ts";
10
10
  import type { AgentChange } from "../services.ts";
11
- import { addressee } from "../speakers.ts";
11
+ import { addressee, type Speaker } from "../speakers.ts";
12
12
  import { openingTaskText } from "./agent-prompt.ts";
13
13
  import { characterOf, overBase } from "./agent-rules.ts";
14
14
  import { modelSetting, thinkingSetting } from "./agent-settings.ts";
15
15
  import type { Agent } from "./agent-store.ts";
16
16
  import type { AvatarMode } from "./agent-tools.ts";
17
- import { discordKey, keysOf } from "./team-keys.ts";
17
+ import { agentRoomKey, discordKey, keysOf, seatsOf } from "./team-keys.ts";
18
18
  import { groupTopic, teamCategory } from "./team-layout.ts";
19
19
  import type { TeamContext } from "./team-options.ts";
20
20
 
@@ -34,7 +34,11 @@ export class TeamEditing {
34
34
  * Changes an agent's display name, prompt, model, or thinking level; `default` returns the
35
35
  * model or thinking level to the assistant's. A model must be one the host runs.
36
36
  */
37
- async update(name: string, change: AgentChange): Promise<string> {
37
+ async update(
38
+ name: string,
39
+ change: AgentChange,
40
+ by?: string,
41
+ ): Promise<string> {
38
42
  const { store, logger } = this.#ctx.options;
39
43
  if (Object.values(change).every((value) => value === undefined))
40
44
  throw new AgentError(
@@ -44,14 +48,18 @@ export class TeamEditing {
44
48
  this.#ctx.options.models.usable(),
45
49
  );
46
50
  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
- });
51
+ const agent = await store.updateAgent(
52
+ name,
53
+ {
54
+ ...(change.displayName !== undefined
55
+ ? { displayName: change.displayName }
56
+ : {}),
57
+ ...(change.prompt !== undefined ? { prompt: change.prompt } : {}),
58
+ ...(model !== undefined ? { model } : {}),
59
+ ...(thinking !== undefined ? { thinking } : {}),
60
+ },
61
+ by,
62
+ );
55
63
  logger.info(
56
64
  {
57
65
  agent: name,
@@ -72,6 +80,7 @@ export class TeamEditing {
72
80
  return `Updated "${agent.displayName}" (${agent.name}); it applies from its next turn. It runs ${runs} with thinking ${level}.`;
73
81
  }
74
82
 
83
+ /** The agent tool: an agent creates another, which starts on the opening task at once. */
75
84
  async create(
76
85
  caller: AgentTurnScope,
77
86
  input: {
@@ -87,27 +96,106 @@ export class TeamEditing {
87
96
  skills?: string[];
88
97
  },
89
98
  ): 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
- );
99
+ this.#refuseSkillsOff(input.skills);
96
100
  const speaker = this.#ctx.turns.speakerOf(caller);
97
101
  if (!speaker)
98
102
  throw new AgentError(
99
103
  "agent_create can only be used during an agent turn.",
100
104
  );
105
+ const made = await this.#create(
106
+ {
107
+ speaker,
108
+ by: caller.name,
109
+ creator: this.#ctx.options.store.agent(caller.name),
110
+ },
111
+ input,
112
+ );
113
+ return made.text;
114
+ }
115
+
116
+ /**
117
+ * An owner creates an agent from outside any agent turn (the web app's form; any other tier is
118
+ * refused, since the opening task runs as `speaker`): the same checks,
119
+ * picture, channel and opening task as `create`, and the model and thinking level the owner
120
+ * picked. Without a task the agent only exists, waiting in its room. Returns the new agent.
121
+ */
122
+ async createFor(
123
+ speaker: Speaker,
124
+ input: {
125
+ name: string;
126
+ displayName: string;
127
+ prompt: string;
128
+ character?: CharacterInput;
129
+ task?: string;
130
+ openIn?: string;
131
+ category?: string;
132
+ skills?: string[];
133
+ model?: string;
134
+ thinking?: string;
135
+ },
136
+ ): Promise<Agent> {
137
+ if (speaker.tier !== "owner")
138
+ throw new AgentError("Only an owner may create an agent here.");
139
+ const { model, thinking, task, openIn, ...rest } = input;
140
+ const made = await this.#create(
141
+ { speaker, by: speaker.name, creator: undefined, openIn },
142
+ { ...rest, ...(task ? { task } : {}) },
143
+ { model, thinking },
144
+ );
145
+ return made.agent;
146
+ }
147
+
148
+ /** An agent cannot carry skills on a host with skills switched off. */
149
+ #refuseSkillsOff(carried: string[] | undefined): void {
150
+ if (carried && carried.length > 0 && !this.#ctx.options.skills)
151
+ throw new AgentError(
152
+ "Skills are off on this host, so an agent cannot carry any. Create it without skills.",
153
+ );
154
+ }
155
+
156
+ async #create(
157
+ who: {
158
+ speaker: Speaker;
159
+ by: string;
160
+ creator: Agent | undefined;
161
+ /** The surface whose room runs the opening task. */
162
+ openIn?: string | undefined;
163
+ },
164
+ input: {
165
+ name: string;
166
+ displayName: string;
167
+ prompt: string;
168
+ /** @deprecated Ignored: agents are composed characters. */
169
+ avatarPrompt?: string;
170
+ character?: CharacterInput;
171
+ task?: string;
172
+ category?: string;
173
+ skills?: string[];
174
+ },
175
+ settings: {
176
+ model?: string | undefined;
177
+ thinking?: string | undefined;
178
+ } = {},
179
+ ): Promise<{ agent: Agent; text: string }> {
180
+ const { store, studio, channels, queue, skills, surfaces, logger } =
181
+ this.#ctx.options;
182
+ this.#refuseSkillsOff(input.skills);
101
183
  const {
102
184
  category,
103
185
  skills: carried = [],
104
186
  character: wanted,
105
187
  avatarPrompt: _ignored,
188
+ task,
106
189
  ...fields
107
190
  } = input;
108
191
  const parent = teamCategory(category, "Agents");
109
192
  store.checkNewName(input.name);
110
193
  skills?.checkRegistered(carried);
194
+ // A model or thinking level the host cannot run fails the create, before anything is made.
195
+ const model = await modelSetting(settings.model, () =>
196
+ this.#ctx.options.models.usable(),
197
+ );
198
+ const thinking = thinkingSetting(settings.thinking);
111
199
  // The picture comes before the row and the channel: a render failure fails the create.
112
200
  const taken = store
113
201
  .agents()
@@ -123,49 +211,69 @@ export class TeamEditing {
123
211
  input.displayName,
124
212
  parent,
125
213
  );
126
- const agent = await store.createAgent({
214
+ let agent = await store.createAgent({
127
215
  ...fields,
128
216
  character,
129
217
  avatarHash,
130
218
  ...(channelId ? { channelId } : {}),
131
219
  });
220
+ if (model || thinking)
221
+ agent = await store.updateAgent(agent.name, {
222
+ ...(model ? { model } : {}),
223
+ ...(thinking ? { thinking } : {}),
224
+ });
132
225
  if (skills && carried.length > 0)
133
226
  await skills.attach(agent.name, carried, []);
134
227
  logger.info(
135
- { agent: agent.name, by: caller.name, class: character.class },
228
+ { agent: agent.name, by: who.by, class: character.class },
136
229
  "agent created",
137
230
  );
138
231
  this.#ctx.changed();
139
232
  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
233
  const where = channelId ? ` in <#${channelId}>` : "";
234
+ if (!task)
235
+ return {
236
+ agent,
237
+ text: `Created "${agent.displayName}" (${agent.name})${where}; ${avatar}.`,
238
+ };
239
+ // The opening task runs in the room of the surface that asked, else where the agent is first
240
+ // reached: its channel, else its first room.
241
+ const rooms = surfaces?.roomSurfaces() ?? [];
242
+ const home =
243
+ who.openIn !== undefined && rooms.includes(who.openIn)
244
+ ? agentRoomKey(who.openIn, agent.name)
245
+ : channelId
246
+ ? discordKey(channelId)
247
+ : keysOf(store, agent.name, rooms)[0];
146
248
  if (!home) {
147
249
  logger.warn(
148
250
  { agent: agent.name },
149
251
  "opening turn skipped: the agent has no channel or room",
150
252
  );
151
- return `Created "${agent.displayName}" (${agent.name})${where}; ${avatar}. It has no room to start its opening task in yet.`;
253
+ return {
254
+ agent,
255
+ text: `Created "${agent.displayName}" (${agent.name})${where}; ${avatar}. It has no room to start its opening task in yet.`,
256
+ };
152
257
  }
153
258
  void queue
154
259
  .run(home, () =>
155
260
  this.#ctx.turns.answerBackground(
156
261
  home,
157
- speaker,
262
+ who.speaker,
158
263
  openingTaskText(
159
- creator,
160
- input.task,
161
- addressee(speaker, this.#ctx.options.owner),
264
+ who.creator,
265
+ task,
266
+ addressee(who.speaker, this.#ctx.options.owner),
162
267
  ),
163
268
  ),
164
269
  )
165
270
  .catch((error: unknown) =>
166
271
  logger.error({ agent: agent.name, err: error }, "opening turn failed"),
167
272
  );
168
- return `Created "${agent.displayName}" (${agent.name})${where}; ${avatar}. It is starting on its opening task now.`;
273
+ return {
274
+ agent,
275
+ text: `Created "${agent.displayName}" (${agent.name})${where}; ${avatar}. It is starting on its opening task now.`,
276
+ };
169
277
  }
170
278
 
171
279
  /** The agent tool: changes a look, then says what the owner sees. */
@@ -260,7 +368,7 @@ export class TeamEditing {
260
368
  (input.members.includes("coordinator")
261
369
  ? "coordinator"
262
370
  : (input.members[0] ?? ""));
263
- store.checkNewGroup(input.name, input.members, host);
371
+ store.checkNewGroup(input.name, input.members, host, input.displayName);
264
372
  const topic = groupTopic({ ...fields, host, status: "active" }, store);
265
373
  const channelId = await channels?.createChannel(input.name, topic, parent);
266
374
  const group = await store.createGroup({
@@ -277,7 +385,17 @@ export class TeamEditing {
277
385
  name: string,
278
386
  change: { displayName?: string; members?: string[]; host?: string },
279
387
  ): Promise<string> {
280
- const group = await this.#ctx.options.store.updateGroup(name, change);
388
+ const { store, surfaces } = this.#ctx.options;
389
+ const before = store.group(name);
390
+ const group = await store.updateGroup(name, change);
391
+ // A member taken out starts its seat over, so an agent of that name that joins later
392
+ // does not resume the old conversation.
393
+ const runtime = this.#ctx.options.runtime();
394
+ const rooms = surfaces?.roomSurfaces() ?? [];
395
+ for (const left of before?.members ?? [])
396
+ if (!group.members.includes(left))
397
+ for (const seat of seatsOf(group, left, rooms))
398
+ await runtime.startFresh(seat);
281
399
  this.#ctx.retitle(group);
282
400
  this.#ctx.changed();
283
401
  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;