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
package/CHANGELOG.md CHANGED
@@ -5,6 +5,17 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.25.0] - 2026-10-29
9
+
10
+ ### Added
11
+
12
+ - The agent team can be edited by an owner outside an agent turn, with the operations the agent tools use. `AgentTeam` gains `createAgent(speaker, input)` (the fields of `agent_create`, `NewAgent`, plus `model` and `thinking`, checked before a picture or channel is made; without a `task` the agent only exists, and `openIn` names the surface whose room runs the opening task), `archive(caller | undefined, name)` (an owner has no calling agent), `restore(name)` and `remove(name, cleanup?)` for an archived agent or group (`cleanup` is the caller's part of the delete: it runs once the checks have passed and before anything is deleted, and one that throws leaves the target in place to delete again), and `createGroup` and `updateGroup`. Restoring moves a Discord channel back out of Archive (a new channel replaces one deleted meanwhile); an agent does not rejoin the groups it left, and a group needs all its members active. A group that an agent's archive would leave with fewer than two members is archived whole, its members kept, and restores once they are all active again. Removing frees the name, detaches the agent's skills, clears its group cursors and a group's messages, makes the runtime forget the conversations the name kept (an agent's own, its seats in every group, a group's members' seats), and leaves a Discord channel under Archive; an agent the host's seeds name is refused, since the next start would seed it again. Editing a group starts the seats of the members it removes over. `AgentTurnRunner` (the part of the runtime the agent team drives) gains `deleteConversation`, so a hand-built runner for the team must set it. The store gains `restoreAgent`, `restoreGroup`, `checkRestorableGroup`, `deleteAgent` and `deleteGroup`. `AgentTeam` has new required methods, so code that builds an `AgentTeam` by hand (a test stand-in) must set them.
13
+ - `pi-roundtable-web`: make, archive and arrange agents from the app. The **+** button of the room list and the drawer opens New agent (name, permanent name, prompt, model, optional opening task and a look from the creator; the new agent's room opens), New group and Archived. The agent sheet gains a Skills section (a switch per skill of the host's catalog; built-in ones stay on) and Archive; a group has a sheet for its name, members and host. Archive asks first and names the agent or group, and Archived lists what was archived with Restore and Delete, which asks first too and frees the name. New owner-only routes carry it: `POST agents`, `POST agents/<name>/archive` and `restore`, `DELETE agents/<name>`, `GET`/`PUT agents/<name>/skills`, `POST groups`, `GET`/`PATCH`/`DELETE groups/<name>`, `POST groups/<name>/archive` and `restore`, and `GET archive`. The new sheets load on demand. The package's changelog has the detail.
14
+ - Prompt texts and skills the owner edits outside an agent turn. A new built-in `prompts` plugin provides `PROMPTS` (`PromptDocuments`): named prompt documents, each with a file version, an override stored in PostgreSQL (additive migrations `prompt-overrides` and `prompt-history`) and a history of who changed what and when. `register(spec)` (`PromptDocumentSpec`: `id`, `label`, `description?`, `default` as a string or a function read at each use, `maxChars?`) returns a `PromptHandle` whose `text()` a plugin reads when it builds a prompt; `set(id, text, by, base?)` stores the override, which wins while it is set and applies from the next turn without a restart; `revert(id, by, base?)` returns to the file version; a `base` that is not the text in force when the write takes its turn in the queue refuses it with `PromptConflictError` (carrying the current document), so two saves from one text give one write; a function `default` that throws is an `AgentError` when the document is registered, and later (the file was removed) makes `text(id)` throw naming the document and log an error, ends the run that read it (a persona or an agent's prompt) instead of leaving it with the old text, and marks the document `broken` in `list()` and `get()` (empty `fileText`, `set` and `revert` refused) without failing the others; `list`, `get` and `history` read it (`PromptDocumentView`, `PromptVersion`). The core registers `shared` (`prompts.shared`) and `guest` (`prompts.guest`); a plugin registers its own, such as a persona's prompt. It is on by default because nothing is overridden until the owner saves a text and the file stays the default; at start the host logs one warning per document that differs from its file version, naming who set it and when. A directory agent keeps its stored prompt as the source (seeds do not overwrite an existing row): `AgentTeam.update(name, change, by?)` now records who changed the prompt, and `AgentTeam.promptHistory(name, limit?)` lists the versions. `SkillRegistry` gains `document(name)`, `saveDocument(name, text, baseHash, by)`, `createDocument(text, by)` and `documentHistory(name)` (`SkillDocument`, `SkillVersion`): an agent-written skill's whole SKILL.md with its SHA-256 hash, saved against that hash with the skill tools' frontmatter rules, refused with `SkillConflictError` (carrying the current document) when it changed meanwhile, and kept as versions. `skill_create` and `skill_update` take their turn in the same queue as an owner's save, so neither overwrites the other unseen, and they record the agent's name as the author of a version. `AgentTeam` has the new required `promptHistory`, and `SkillRegistry` four new required methods, so a hand-built stand-in must set them. See [`PROMPTS`](docs/plugins.md#prompts-prompt-texts-the-owner-edits).
15
+ - `pi-roundtable-web`: edit the main prompts and skills from the app. **Main prompts** (linked from Settings) lists the prompt documents (`shared`, `guest` and a plugin's own) with a "differs from the file version" badge, and edits one in a full-screen sheet with a diff to read before saving, a history (read a version against the one before, take it into the editor) and **Revert to the file version**, which asks first. The agent sheet gains History for the agent's prompt. The Skills page edits an agent-written skill's whole SKILL.md and makes a new skill, with the same frontmatter rules as the skill tools, a diff to read, a history and a conflict check (`409`, the diff read again against what is there now); built-in, package and linked skills show a lock and where they come from. Deleting a skill is not offered here. Owner-only routes: `GET api/prompts`, `GET`/`PUT api/prompts/<id>`, `POST api/prompts/<id>/revert`, `GET api/prompts/<id>/history`, `GET api/agents/<name>/prompt-history`, `GET`/`PUT api/skill-docs/<name>`, `POST api/skill-docs`, `GET api/skill-docs/<name>/history`. The package's changelog has the detail.
16
+ - `pi-roundtable-web`: pins and folders for the room list, shared by every owner and device, with drag to reorder and move, a menu for each, and a mute for a whole folder. Three additive tables (`web_room_folders`, `web_room_folder_rooms`, `web_room_pins`) and owner-only routes carry it; the package's changelog has the detail.
17
+ - `pi-roundtable-web`: choose who sits at the round table. A button in the page's header lists every agent with a switch; an agent that leaves the table has its room's push muted, and a seat that leaves or joins plays a short show. The package's changelog has the detail.
18
+
8
19
  ## [0.24.2] - 2026-10-29
9
20
 
10
21
  ### Added
package/docs/plugins.md CHANGED
@@ -239,7 +239,8 @@ The built-in plugins provide these, from the main entry:
239
239
  | `MODES` | `ConversationModes` | `modes` | Each conversation's modes, stored per key and kept across restarts: `peek(channel)` gives its `ConversationMode` (`yolo` and `longRunning`, both off by default; `changedBy?`, `changedAt?`) from memory, `set(channel, { yolo?, longRunning? }, speaker)` changes them and throws `PluginError` unless the speaker is an owner other than `SYSTEM_PRINCIPAL`, `onChange(listener)` hears each change, and `autoApproved(entry)` with `onAutoApproved(listener)` report a held call that yolo mode approved. See [Conversation modes](#conversation-modes-modes) |
240
240
  | `RUNTIME` | `AgentRuntime` | `runtime` | The runtime every conversation turn runs on, the agent server's and `context.turns`': the `runtime` slot's when a plugin fills it, Pi's otherwise |
241
241
  | `AGENTS` | `AgentServer` | `agent-server` | The `team` (`AgentTeam`), the read-only `directory` (`AgentDirectory`), the `runtime` every agent turn runs on (the same one `RUNTIME` provides), `approvals` (whether the owner's reply approves held actions), `avatars` (`AvatarStudio`), and `workspace` (`AgentWorkspace`: the agents' `workDir` and, when set, `scratchDir`) |
242
- | `SKILLS` | `SkillRegistry` | `skills` (an addon) | What agents carry: `carried`, `carriedNames`, `describeCarried`, `catalog`, `list`, `linkedFrom`, `checkRegistered`, `link`, `attach` |
242
+ | `PROMPTS` | `PromptDocuments` | `prompts` | The prompt texts the owner edits in the app, each with a file version, an override and a history: `register`, `text`, `list`, `get`, `set`, `revert`, `history`. Always on; read it with `get`. See [`PROMPTS`: prompt texts the owner edits](#prompts-prompt-texts-the-owner-edits) |
243
+ | `SKILLS` | `SkillRegistry` | `skills` (an addon) | What agents carry: `carried`, `carriedNames`, `describeCarried`, `catalog`, `list`, `linkedFrom`, `checkRegistered`, `link`, `attach`, and the owner's whole-SKILL.md edits `document`, `saveDocument`, `createDocument`, `documentHistory` |
243
244
  | `SCHEDULES` | `ScheduleStore` | `schedule-store` | The stored schedules: `create`, `createWithin`, `get`, `forChannel`, `all`, `update`, `remove`, `due`, `claim`, `recordStatus` |
244
245
  | `PRECHECKS` | `PrecheckRegistry` | `prechecks` | The host's named [prechecks](#prechecks-wake-a-schedule-only-when-it-has-work): `register`, `get`, `list`; and the runner of agents' precheck scripts: `useScriptRunner`, `scriptRunner` |
245
246
  | `MEMORY` | `MemoryStore` | `memory` (an addon) | `forSpeaker(principalId)` gives that principal's `SpeakerMemory`: `list`, `forPrompt`, `add`, `search`, `update`, `removeById`, `remove`; `MEMORY_KINDS` is `core`, `note`, `event` |
@@ -1093,6 +1094,37 @@ const shell = shellHoldRuleFor({
1093
1094
  });
1094
1095
  ```
1095
1096
 
1097
+ ### `PROMPTS`: prompt texts the owner edits
1098
+
1099
+ A prompt that comes from a file or the configuration can be made editable by the owner without touching the file.
1100
+ Register a document during `setup`; read its text with `text()` whenever you build a prompt:
1101
+
1102
+ ```ts
1103
+ import { definePlugin, PROMPTS } from "pi-roundtable";
1104
+
1105
+ export const persona = definePlugin({
1106
+ name: "persona",
1107
+ setup: ({ services }) => {
1108
+ const merlin = services.get(PROMPTS).register({
1109
+ id: "merlin",
1110
+ label: "Merlin's prompt",
1111
+ description: "Who Merlin is in the owner's conversations.",
1112
+ default: () => readFileSync("persona/merlin.md", "utf8"),
1113
+ });
1114
+ return { personas: [{ kind: "owner", prompt: () => merlin.text() }] };
1115
+ },
1116
+ });
1117
+ ```
1118
+
1119
+ - `default` is the file version: a string, or a function called at each read so a default that follows a file is current. The id is 1 to 32 lowercase letters, digits and hyphens; a repeated or unusable id is an `AgentError`, and so is a function `default` that throws when the document is registered, so the host does not start with it. `maxChars` defaults to 20,000.
1120
+ - The core registers `shared` (`prompts.shared`, the base every agent's prompt starts with) and `guest` (`prompts.guest`, used when the speaker is not the owner). A plugin's own documents are registered by that plugin; the plugin that registers them is set up after `prompts`, so it can `get` the service.
1121
+ - **The override.** `set(id, text, by, base?)` stores the owner's text in PostgreSQL (`prompt_overrides`, with `prompt_history` beside it; both are additive migrations). While an override is set it wins over the file version, and it is read at every turn, so a change applies from the next turn without a restart. `revert(id, by, base?)` drops it and the file version is back. Text equal to the file version reverts instead of storing a copy. A document has `text`, `fileText`, `overridden`, `differs` (set and not equal to the file) and `override: { by, at }`.
1122
+ - **Edits from a stale view.** `base` is the text the change was edited from. When it is given, it is compared with the text in force inside the write queue, where the write takes its turn, so two saves from the same text give one write; the other is refused with `PromptConflictError`, whose `current` is the document as it is now. A call without `base` writes as before.
1123
+ - **A file that stops reading.** When a function `default` throws later (the file was removed), nothing quietly carries on with an old text: `text(id)` throws an `AgentError` naming the document unless an override is set, and logs an error with the document's id; the persona or agent prompt that asked stops the run it was read for (an error in the log, the turn gets no reply) instead of running with the text it was built with, or with none. `list()` and `get()` still answer for every document: the broken one has `broken` (the reason), an empty `fileText` and `differs: false`, and `set` and `revert` refuse it until the file reads again.
1124
+ - **History.** `history(id, limit?)` lists each change newest first: `action` (`set` or `revert`), `text`, the `previous` text, `by` and `at`. An agent's own prompt keeps the same kind of history through `AgentTeam.promptHistory(name)`; `AgentTeam.update(name, change, by?)` records `by`. A directory agent has no override layer: its stored row is the source, and a seed does not overwrite an existing row.
1125
+ - **On by default.** The override layer is always on, because nothing is overridden until the owner saves one and the file stays the default. The host logs one warning per document that differs from its file version when it starts, naming who set it and when, and the web app shows a "differs from the file version" badge. Deleting the rows (or using **Revert to the file version**) returns the host to its files.
1126
+ - `PromptDocuments.set` and `revert` do not check who calls: the owner-only check belongs to the caller, as the web app's routes do it.
1127
+
1096
1128
  ### `prompt`: text added to every agent turn
1097
1129
 
1098
1130
  Each section's `build` gets the agent, the speaker (undefined between turns), and the turn's scope.
@@ -3247,6 +3279,7 @@ A host that keeps Discord may write it as an adapter instead, as [`adapters`](#a
3247
3279
  - No channel is made, checked or arranged, and the dashboard (a Discord message) does not exist. `channel_arrange` and `channel_read` answer that the host has no Discord channels. `agent_create` and `group_create` make no channel, and a new agent starts its opening task in its room on the first surface that hosts rooms. Archiving an agent or group stops its rooms from answering.
3248
3280
  - An agent's pictures have no public address unless `http.publicUrl` is set: `AGENTS.avatars.url(hash)` is then the path `/avatars/<hash>.png`, which the surface serves itself through `AGENTS.avatars.serve(pathname)` under its own origin, as the web console does. With `http.publicUrl` they are served on the `public` listener as with Discord.
3249
3281
  - `AgentTeam.channelOf(name, near?)` answers with the agent's room on the surface of `near` (the asker's own conversation) when that surface hosts rooms, so `schedule_list` from a room reads the room's schedules; for an agent with no Discord channel it falls back to its room on the first surface that hosts rooms. The status the dashboard and the web read counts an agent's schedules, waiting turns and context use across its Discord channel and its rooms.
3282
+ - A surface that lets the owner edit the team uses the same operations the agent tools do, from outside any agent turn: `AgentTeam.createAgent(speaker, input)` (the fields of `agent_create`, plus `model` and `thinking`, which are checked before anything is made; without a `task` the agent only exists, and `openIn` names the surface whose room runs the opening task instead of the Discord channel), `archive(undefined, name)` (an owner has no calling agent, so only the coordinator is protected), `restore(name)`, `remove(name, cleanup?)` (an archived agent or group only, and never an agent the host's seeds name, which the next start would seed again; it runs `cleanup`, your surface's part of the delete, after its checks and before deleting anything, so a cleanup that throws leaves the target in place to try again; then it frees the name, detaches the agent's skills, clears its group cursors and makes the runtime forget the conversations the name kept, and leaves a Discord channel under Archive), and `createGroup` and `updateGroup`. All of them throw `AgentError` with the reason, and fire the team's change signal.
3250
3283
  - Breaking: `AgentGroup.channelId` is optional (`string | undefined`), and so is `GroupStatus.channelId`; a plugin that reads them handles a group without a Discord channel. The `agent_groups.channel_id` column drops its `NOT NULL` in a migration of the agent server (`agent-groups-channel-optional`), and `AgentServerOptions.guildId` and `entryChannelId` are optional, with `coordinator` added. `AvatarStudioOptions.publicUrl` is optional.
3251
3284
 
3252
3285
  <!-- example: examples/headless.ts -->
@@ -4061,6 +4094,7 @@ Import from the entries listed below; source area files are internal.
4061
4094
  | `AvatarMode` | `pi-roundtable` | type |
4062
4095
  | `AgentWorkspace` | `pi-roundtable` | type |
4063
4096
  | `AvatarStudio` | `pi-roundtable` | type |
4097
+ | `PROMPTS` | `pi-roundtable` | value |
4064
4098
  | `characterOf` | `pi-roundtable` | value |
4065
4099
  | `BACKGROUND_TURNS` | `pi-roundtable` | value |
4066
4100
  | `CONVERSATIONS` | `pi-roundtable` | value |
@@ -4162,6 +4196,7 @@ Import from the entries listed below; source area files are internal.
4162
4196
  | `ModeChange` | `pi-roundtable` | type |
4163
4197
  | `ModelImage` | `pi-roundtable` | type |
4164
4198
  | `NO_ATTACHMENTS` | `pi-roundtable` | value |
4199
+ | `NewAgent` | `pi-roundtable` | type |
4165
4200
  | `NewSchedule` | `pi-roundtable` | type |
4166
4201
  | `NotLinkedError` | `pi-roundtable` | value |
4167
4202
  | `OWNER_TARGET` | `pi-roundtable` | value |
@@ -4178,10 +4213,15 @@ Import from the entries listed below; source area files are internal.
4178
4213
  | `PluginIdentity` | `pi-roundtable` | type |
4179
4214
  | `PluginError` | `pi-roundtable` | value |
4180
4215
  | `Principal` | `pi-roundtable` | type |
4216
+ | `PromptDocumentSpec` | `pi-roundtable` | type |
4217
+ | `PromptDocuments` | `pi-roundtable` | type |
4218
+ | `PromptDocumentView` | `pi-roundtable` | type |
4219
+ | `PromptHandle` | `pi-roundtable` | type |
4181
4220
  | `PromptMemory` | `pi-roundtable` | type |
4182
4221
  | `PromptScope` | `pi-roundtable` | type |
4183
4222
  | `PromptSection` | `pi-roundtable` | type |
4184
4223
  | `PromptTurn` | `pi-roundtable` | type |
4224
+ | `PromptVersion` | `pi-roundtable` | type |
4185
4225
  | `PromptWait` | `pi-roundtable` | type |
4186
4226
  | `Prompts` | `pi-roundtable` | type |
4187
4227
  | `Pronouns` | `pi-roundtable` | type |
@@ -4224,6 +4264,9 @@ Import from the entries listed below; source area files are internal.
4224
4264
  | `PrecheckScriptRunner` | `pi-roundtable` | type |
4225
4265
  | `PrecheckTool` | `pi-roundtable` | type |
4226
4266
  | `PRECHECK_SCRIPT_CHARS` | `pi-roundtable` | value |
4267
+ | `PromptConflictError` | `pi-roundtable` | value |
4268
+ | `SkillConflictError` | `pi-roundtable` | value |
4269
+ | `SkillDocument` | `pi-roundtable` | type |
4227
4270
  | `SKILLS` | `pi-roundtable` | value |
4228
4271
  | `SINGLE_QUESTION` | `pi-roundtable` | value |
4229
4272
  | `Schedule` | `pi-roundtable` | type |
@@ -4244,6 +4287,7 @@ Import from the entries listed below; source area files are internal.
4244
4287
  | `SessionToolSnapshot` | `pi-roundtable` | type |
4245
4288
  | `SkillCatalogEntry` | `pi-roundtable` | type |
4246
4289
  | `SkillRegistry` | `pi-roundtable` | type |
4290
+ | `SkillVersion` | `pi-roundtable` | type |
4247
4291
  | `WorkerSessions` | `pi-roundtable` | type |
4248
4292
  | `WorkerSessionRequest` | `pi-roundtable` | type |
4249
4293
  | `WORKER_SESSIONS` | `pi-roundtable` | value |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.24.2",
3
+ "version": "0.25.0",
4
4
  "description": "A plugin-driven Pi agent server for one owner, reached through Discord or the web",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,155 @@
1
+ import { AgentError } from "../domain/errors.ts";
2
+ import type { Agent, AgentGroup } from "./agent-rules.ts";
3
+ import {
4
+ checkAgentName,
5
+ checkDisplayName,
6
+ MAX_GROUP_MEMBERS,
7
+ MIN_GROUP_MEMBERS,
8
+ } from "./agent-rules.ts";
9
+ import {
10
+ type AgentRow,
11
+ type GroupRow,
12
+ toAgent,
13
+ toGroup,
14
+ } from "./agent-schema.ts";
15
+
16
+ /**
17
+ * The agents and groups a guild holds in memory, and the checks that read them: every inbound
18
+ * message asks, and only this process changes them. The store writes the rows and keeps these
19
+ * maps in step.
20
+ */
21
+ export abstract class AgentBook {
22
+ protected readonly agentMap = new Map<string, Agent>();
23
+ /** Agents whose character column holds a value, readable or not. */
24
+ protected readonly dressed = new Set<string>();
25
+ protected readonly groupMap = new Map<string, AgentGroup>();
26
+
27
+ agents(): Agent[] {
28
+ return [...this.agentMap.values()];
29
+ }
30
+
31
+ agent(name: string): Agent | undefined {
32
+ return this.agentMap.get(name);
33
+ }
34
+
35
+ /** The active agent with that name, or an error the caller can show. */
36
+ activeAgent(name: string): Agent {
37
+ const agent = this.agentMap.get(name);
38
+ if (!agent)
39
+ throw new AgentError(
40
+ `There is no agent "${name}". Active agents: ${this.activeNames()}.`,
41
+ );
42
+ if (agent.status !== "active")
43
+ throw new AgentError(
44
+ `Agent "${name}" is archived. Active agents: ${this.activeNames()}.`,
45
+ );
46
+ return agent;
47
+ }
48
+
49
+ agentByChannel(channelId: string): Agent | undefined {
50
+ for (const agent of this.agentMap.values())
51
+ if (agent.channelId === channelId && agent.status === "active")
52
+ return agent;
53
+ return undefined;
54
+ }
55
+
56
+ groups(): AgentGroup[] {
57
+ return [...this.groupMap.values()];
58
+ }
59
+
60
+ group(name: string): AgentGroup | undefined {
61
+ return this.groupMap.get(name);
62
+ }
63
+
64
+ groupByChannel(channelId: string): AgentGroup | undefined {
65
+ for (const group of this.groupMap.values())
66
+ if (group.channelId === channelId && group.status === "active")
67
+ return group;
68
+ return undefined;
69
+ }
70
+
71
+ protected activeNames(): string {
72
+ return (
73
+ this.agents()
74
+ .flatMap((a) => (a.status === "active" ? [a.name] : []))
75
+ .join(", ") || "none"
76
+ );
77
+ }
78
+
79
+ /** Names are shared between agents and groups, archived ones included. */
80
+ protected checkFree(name: string): void {
81
+ if (this.agentMap.has(name) || this.groupMap.has(name))
82
+ throw new AgentError(
83
+ `The name "${name}" is taken, archived agents and groups included.`,
84
+ );
85
+ }
86
+
87
+ /** A name that createAgent would accept, checked before its channel is made. */
88
+ checkNewName(name: string): void {
89
+ checkAgentName(name);
90
+ this.checkFree(name);
91
+ }
92
+
93
+ protected keepAgent(row: AgentRow | undefined): Agent {
94
+ if (!row) throw new AgentError("the agent was not found");
95
+ return this.keepAgentRow(row);
96
+ }
97
+
98
+ protected keepAgentRow(row: AgentRow): Agent {
99
+ const agent = toAgent(row);
100
+ this.agentMap.set(agent.name, agent);
101
+ if (row.character !== null && row.character !== undefined)
102
+ this.dressed.add(agent.name);
103
+ else this.dressed.delete(agent.name);
104
+ return agent;
105
+ }
106
+
107
+ /** Whether the stored column holds a character, even one this version cannot read. */
108
+ hasStoredCharacter(name: string): boolean {
109
+ return this.dressed.has(name);
110
+ }
111
+
112
+ protected checkMembers(members: readonly string[], host: string): void {
113
+ if (
114
+ new Set(members).size !== members.length ||
115
+ members.length < MIN_GROUP_MEMBERS ||
116
+ members.length > MAX_GROUP_MEMBERS
117
+ )
118
+ throw new AgentError(
119
+ `A group has ${MIN_GROUP_MEMBERS} to ${MAX_GROUP_MEMBERS} different members.`,
120
+ );
121
+ for (const member of members) this.activeAgent(member);
122
+ if (!members.includes(host))
123
+ throw new AgentError(`The host "${host}" must be one of the members.`);
124
+ }
125
+
126
+ /** Checks a new group before its channel is made; a `displayName` is checked too. */
127
+ checkNewGroup(
128
+ name: string,
129
+ members: readonly string[],
130
+ host: string,
131
+ displayName?: string,
132
+ ): void {
133
+ checkAgentName(name);
134
+ if (displayName !== undefined) checkDisplayName(displayName);
135
+ this.checkFree(name);
136
+ this.checkMembers(members, host);
137
+ }
138
+
139
+ /** The archived group, when it could be restored now; otherwise the error that says why not. */
140
+ checkRestorableGroup(name: string): AgentGroup {
141
+ const current = this.groupMap.get(name);
142
+ if (!current) throw new AgentError(`There is no group "${name}".`);
143
+ if (current.status === "active")
144
+ throw new AgentError(`"${name}" is not archived.`);
145
+ this.checkMembers(current.members, current.host);
146
+ return current;
147
+ }
148
+
149
+ protected keepGroup(row: GroupRow | undefined): AgentGroup {
150
+ if (!row) throw new AgentError("the group was not found");
151
+ const group = toGroup(row);
152
+ this.groupMap.set(group.name, group);
153
+ return group;
154
+ }
155
+ }
@@ -7,7 +7,11 @@ export type { ContextUse };
7
7
  /** The part of the runtime the agent team drives. */
8
8
  export type AgentTurnRunner = Pick<
9
9
  AgentRuntime,
10
- "runTurn" | "heldActions" | "startFresh" | "contextUsage"
10
+ | "runTurn"
11
+ | "heldActions"
12
+ | "startFresh"
13
+ | "deleteConversation"
14
+ | "contextUsage"
11
15
  >;
12
16
 
13
17
  /** One agent's message in a channel, posted under its name and avatar. */
@@ -2,6 +2,7 @@ import { readCharacter } from "../../characters/index.ts";
2
2
  import { scopeToGuild } from "../db/guild-scope.ts";
3
3
  import type { Migration } from "../db/migrations.ts";
4
4
  import type { ThinkingLevel } from "../models.ts";
5
+ import { promptHistoryMigration } from "../modules/prompts/prompt-history.ts";
5
6
  import type { Agent, AgentGroup, AgentStatus } from "./agent-rules.ts";
6
7
 
7
8
  export interface AgentRow {
@@ -133,5 +134,7 @@ export function agentMigrations(guildId: string): Migration[] {
133
134
  await sql`ALTER TABLE agents ADD COLUMN IF NOT EXISTS character jsonb`;
134
135
  },
135
136
  },
137
+ // The change history of the agents' prompts; the prompts plugin creates the same table for its documents.
138
+ promptHistoryMigration("agent-prompt-history"),
136
139
  ];
137
140
  }
@@ -0,0 +1,179 @@
1
+ import type { SQL } from "bun";
2
+ import type { CharacterSpec } from "../../characters/index.ts";
3
+ import type { AgentStatus } from "./agent-rules.ts";
4
+ import type { AgentRow, GroupRow } from "./agent-schema.ts";
5
+
6
+ /**
7
+ * The SQL the agent store runs for one guild. The store holds the rows in memory and decides
8
+ * what may change; each method here only writes the statement and returns the rows it touched.
9
+ */
10
+ export class AgentStatements {
11
+ readonly #sql: SQL;
12
+ readonly #guild: string;
13
+
14
+ constructor(sql: SQL, guildId: string) {
15
+ this.#sql = sql;
16
+ this.#guild = guildId;
17
+ }
18
+
19
+ /** Inserts a seed agent unless one of that name exists; no row comes back when one does. */
20
+ insertSeed(seed: {
21
+ name: string;
22
+ displayName: string;
23
+ prompt: string;
24
+ avatarPrompt: string;
25
+ channelId: string | null;
26
+ }): Promise<AgentRow[]> {
27
+ return this.#sql`
28
+ INSERT INTO agents (guild_id, name, display_name, prompt, avatar_prompt, channel_id)
29
+ VALUES (${this.#guild}, ${seed.name}, ${seed.displayName}, ${seed.prompt}, ${seed.avatarPrompt},
30
+ ${seed.channelId})
31
+ ON CONFLICT DO NOTHING
32
+ RETURNING *`;
33
+ }
34
+
35
+ insertAgent(agent: {
36
+ name: string;
37
+ displayName: string;
38
+ prompt: string;
39
+ avatarPrompt: string;
40
+ channelId: string | null;
41
+ avatarHash: string | null;
42
+ character: CharacterSpec | null;
43
+ }): Promise<AgentRow[]> {
44
+ return this.#sql`
45
+ INSERT INTO agents (guild_id, name, display_name, prompt, avatar_prompt, channel_id, avatar_hash, character)
46
+ VALUES (${this.#guild}, ${agent.name}, ${agent.displayName}, ${agent.prompt}, ${agent.avatarPrompt},
47
+ ${agent.channelId}, ${agent.avatarHash},
48
+ ${agent.character}::jsonb)
49
+ RETURNING *`;
50
+ }
51
+
52
+ /** Writes every column of an agent; `characterSet` false leaves the stored character alone. */
53
+ updateAgent(
54
+ name: string,
55
+ row: {
56
+ displayName: string;
57
+ prompt: string;
58
+ avatarPrompt: string;
59
+ avatarHash: string | null;
60
+ characterSet: boolean;
61
+ character: CharacterSpec | null;
62
+ channelId: string | null;
63
+ model: string | null;
64
+ thinking: string | null;
65
+ },
66
+ ): Promise<AgentRow[]> {
67
+ return this.#sql`
68
+ UPDATE agents SET
69
+ display_name = ${row.displayName},
70
+ prompt = ${row.prompt},
71
+ avatar_prompt = ${row.avatarPrompt},
72
+ avatar_hash = ${row.avatarHash},
73
+ character = CASE WHEN ${row.characterSet} THEN ${row.character}::jsonb ELSE character END,
74
+ channel_id = ${row.channelId},
75
+ model = ${row.model},
76
+ thinking = ${row.thinking},
77
+ updated_at = now()
78
+ WHERE guild_id = ${this.#guild} AND name = ${name}
79
+ RETURNING *`;
80
+ }
81
+
82
+ archiveAgent(name: string): Promise<AgentRow[]> {
83
+ return this.#sql`
84
+ UPDATE agents SET status = 'archived', updated_at = now()
85
+ WHERE guild_id = ${this.#guild} AND name = ${name} RETURNING *`;
86
+ }
87
+
88
+ restoreAgent(name: string, channelId: string | null): Promise<AgentRow[]> {
89
+ return this.#sql`
90
+ UPDATE agents SET status = 'active', channel_id = ${channelId},
91
+ updated_at = now()
92
+ WHERE guild_id = ${this.#guild} AND name = ${name}
93
+ RETURNING *`;
94
+ }
95
+
96
+ /** Deletes the agent's row, its group cursors and its prompt history together. */
97
+ async deleteAgent(name: string): Promise<void> {
98
+ await this.#sql.begin(async (tx) => {
99
+ await tx`DELETE FROM agent_group_cursors WHERE guild_id = ${this.#guild} AND agent_name = ${name}`;
100
+ await tx`DELETE FROM prompt_history WHERE guild_id = ${this.#guild} AND kind = 'agent' AND name = ${name}`;
101
+ await tx`DELETE FROM agents WHERE guild_id = ${this.#guild} AND name = ${name}`;
102
+ });
103
+ }
104
+
105
+ /** Gives an agent a character and picture only while it has no character. */
106
+ dressIfUndressed(
107
+ name: string,
108
+ character: CharacterSpec,
109
+ avatarHash: string,
110
+ ): Promise<AgentRow[]> {
111
+ return this.#sql`
112
+ UPDATE agents SET character = ${character}::jsonb, avatar_hash = ${avatarHash}, updated_at = now()
113
+ WHERE guild_id = ${this.#guild} AND name = ${name} AND character IS NULL
114
+ RETURNING *`;
115
+ }
116
+
117
+ /** Sets the picture only while the agent is active and still wears `character`. */
118
+ setAvatarIfLook(
119
+ name: string,
120
+ character: CharacterSpec,
121
+ avatarHash: string,
122
+ ): Promise<AgentRow[]> {
123
+ return this.#sql`
124
+ UPDATE agents SET avatar_hash = ${avatarHash}, updated_at = now()
125
+ WHERE guild_id = ${this.#guild} AND name = ${name} AND status = 'active'
126
+ AND character = ${character}::jsonb
127
+ RETURNING *`;
128
+ }
129
+
130
+ insertGroup(group: {
131
+ name: string;
132
+ displayName: string;
133
+ channelId: string | null;
134
+ members: readonly string[];
135
+ host: string;
136
+ }): Promise<GroupRow[]> {
137
+ return this.#sql`
138
+ INSERT INTO agent_groups (guild_id, name, display_name, channel_id, members, host)
139
+ VALUES (${this.#guild}, ${group.name}, ${group.displayName}, ${group.channelId},
140
+ ${group.members.join(",")}, ${group.host})
141
+ RETURNING *`;
142
+ }
143
+
144
+ updateGroup(
145
+ name: string,
146
+ row: {
147
+ displayName: string;
148
+ members: readonly string[];
149
+ host: string;
150
+ status: AgentStatus;
151
+ },
152
+ ): Promise<GroupRow[]> {
153
+ return this.#sql`
154
+ UPDATE agent_groups SET
155
+ display_name = ${row.displayName},
156
+ members = ${row.members.join(",")},
157
+ host = ${row.host},
158
+ status = ${row.status}
159
+ WHERE guild_id = ${this.#guild} AND name = ${name}
160
+ RETURNING *`;
161
+ }
162
+
163
+ restoreGroup(name: string, channelId: string | null): Promise<GroupRow[]> {
164
+ return this.#sql`
165
+ UPDATE agent_groups SET status = 'active', channel_id = ${channelId}
166
+ WHERE guild_id = ${this.#guild} AND name = ${name}
167
+ RETURNING *`;
168
+ }
169
+
170
+ /** Deletes the group's row, messages and cursors, in the Discord channel's log and each room's. */
171
+ async deleteGroup(name: string): Promise<void> {
172
+ const rooms = `${name}@%`;
173
+ await this.#sql.begin(async (tx) => {
174
+ await tx`DELETE FROM agent_group_cursors WHERE guild_id = ${this.#guild} AND (group_name = ${name} OR group_name LIKE ${rooms})`;
175
+ await tx`DELETE FROM agent_group_messages WHERE guild_id = ${this.#guild} AND (group_name = ${name} OR group_name LIKE ${rooms})`;
176
+ await tx`DELETE FROM agent_groups WHERE guild_id = ${this.#guild} AND name = ${name}`;
177
+ });
178
+ }
179
+ }