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
@@ -50,10 +50,13 @@ export interface AgentTeamOptions
50
50
  shellUser: string;
51
51
  /** The scratch dir the agents' shell writes temporary files to. */
52
52
  scratchDir?: string;
53
- /** The prompt every persona shares, the assistant's included; each agent's starts with it. */
54
- sharedPrompt: string;
53
+ /**
54
+ * The prompt every persona shares, the assistant's included; each agent's starts with it. A
55
+ * function is read before each turn, so an edit of the text applies from the next one.
56
+ */
57
+ sharedPrompt: string | (() => string);
55
58
  /** The shared prompt for speakers other than the owner, when `sharedPrompt` speaks to the owner. */
56
- guestPrompt?: string;
59
+ guestPrompt?: string | (() => string);
57
60
  /** The skill registry: what each agent carries. Absent when the skills addon is off. */
58
61
  skills?: SkillRegistry;
59
62
  }
@@ -0,0 +1,28 @@
1
+ import type { AgentTurnScope } from "../domain/ports.ts";
2
+ import { SHELL_TOOLS } from "../modules/host-shell/shell-policy.ts";
3
+ import type { TurnSelection } from "../sessions.ts";
4
+ import { AGENT_TOOLS, MESSAGE_AGENT_TOOL } from "./agent-tools.ts";
5
+ import type { AgentTeamOptions } from "./team-options.ts";
6
+
7
+ /** The tools every agent turn runs with: the plugins', plus the shell and agent tools. */
8
+ export function teamSelection(
9
+ options: Pick<AgentTeamOptions, "pluginSelection" | "pluginTools">,
10
+ scope?: AgentTurnScope,
11
+ ): TurnSelection {
12
+ const plugged = options.pluginSelection();
13
+ return {
14
+ id: "agent",
15
+ tools: [
16
+ ...new Set([
17
+ ...plugged.tools,
18
+ ...SHELL_TOOLS,
19
+ // Group members hand off by mentioning each other, so a group seat has no message_agent.
20
+ ...AGENT_TOOLS.filter(
21
+ (tool) => !(scope?.group && tool === MESSAGE_AGENT_TOOL),
22
+ ),
23
+ ...(options.pluginTools?.() ?? []),
24
+ ]),
25
+ ],
26
+ groups: plugged.groups,
27
+ };
28
+ }
@@ -16,6 +16,7 @@ import type { ChannelKey } from "../domain/conversation.ts";
16
16
  import { ConfigError } from "../domain/errors.ts";
17
17
  import type { OwnerIdentity } from "../identity.ts";
18
18
  import { ConfirmationJudge } from "../judging/confirmation-judge.ts";
19
+ import type { Logger } from "../log.ts";
19
20
  import { AUTO_THINKING, formatModelRef, type ModelRef } from "../models.ts";
20
21
  import type { ErrorReporter } from "../ops/error-reporter.ts";
21
22
  import type { PluginContext, RoundtablePlugin } from "../plugin.ts";
@@ -25,6 +26,7 @@ import type { AgentSessionsSlot } from "../runtime/runtime-plugin.ts";
25
26
  import {
26
27
  AGENTS,
27
28
  BACKGROUND_TURNS,
29
+ PROMPTS,
28
30
  RUNTIME,
29
31
  SCHEDULES,
30
32
  SKILLS,
@@ -126,11 +128,12 @@ export const AGENT_TEAM_SERVICE = "team";
126
128
  /** The agent tools and the agent's own prompt, which only agent sessions load. */
127
129
  export function agentSessionTools(
128
130
  team: Pick<DiscordAgentTeam, "extension" | "systemPrompt">,
131
+ logger: Logger,
129
132
  ): SessionTool[] {
130
133
  return [
131
134
  agentOnly("agent-tools", (scope) => team.extension(scope)),
132
135
  agentOnly("agent-prompt", (scope) =>
133
- agentPromptExtension(() => team.systemPrompt(scope)),
136
+ agentPromptExtension(() => team.systemPrompt(scope), logger, scope.name),
134
137
  ),
135
138
  ];
136
139
  }
@@ -235,6 +238,27 @@ export function agentServerPlugin(
235
238
  assistant: options.assistant,
236
239
  logger,
237
240
  });
241
+ // The owner edits the two prompts in the app when the prompts plugin is there; the text from the files is their default.
242
+ const documents = context.services.find(PROMPTS);
243
+ const shared = documents?.register({
244
+ id: "shared",
245
+ label: "Shared prompt",
246
+ description:
247
+ "The base every agent's prompt starts with, the main assistant's included.",
248
+ default: options.prompts.shared,
249
+ });
250
+ const guest =
251
+ documents && options.prompts.guest
252
+ ? documents.register({
253
+ id: "guest",
254
+ label: "Guest prompt",
255
+ description:
256
+ "Used instead of the shared prompt when the person speaking is not the owner.",
257
+ default: options.prompts.guest,
258
+ })
259
+ : undefined;
260
+ const sharedPrompt = shared ? () => shared.text() : undefined;
261
+ const guestPrompt = guest ? () => guest.text() : undefined;
238
262
  const built: DiscordAgentTeam = new DiscordAgentTeam({
239
263
  guildId: teamId,
240
264
  ...(options.entryChannelId !== undefined
@@ -286,9 +310,9 @@ export function agentServerPlugin(
286
310
  startTyping: (channel) => surfaces.startTyping(channel),
287
311
  showStop: (channel) => surfaces.showStop(channel),
288
312
  workDir: options.workDir,
289
- sharedPrompt: options.prompts.shared,
290
- ...(options.prompts.guest
291
- ? { guestPrompt: options.prompts.guest }
313
+ sharedPrompt: sharedPrompt ?? options.prompts.shared,
314
+ ...((guestPrompt ?? options.prompts.guest)
315
+ ? { guestPrompt: guestPrompt ?? options.prompts.guest }
292
316
  : {}),
293
317
  ...(skills ? { skills } : {}),
294
318
  ...(threads ? { threads } : {}),
@@ -396,7 +420,7 @@ export function agentServerPlugin(
396
420
  options.avatarListener && options.avatarUrl
397
421
  ? [studio.route(options.avatarListener)]
398
422
  : [],
399
- sessionTools: agentSessionTools(built),
423
+ sessionTools: agentSessionTools(built, logger),
400
424
  toolTiers: AGENT_TIERS,
401
425
  };
402
426
  },
@@ -0,0 +1,40 @@
1
+ import type { SQL } from "bun";
2
+ import type { Logger } from "../log.ts";
3
+ import { PgPromptDocuments } from "../modules/prompts/prompt-documents.ts";
4
+ import type { RoundtablePlugin } from "../plugin.ts";
5
+ import { PROMPTS } from "../services.ts";
6
+
7
+ export interface PromptsOptions {
8
+ /** The guild whose overrides the documents keep, or the local team's id. */
9
+ guildId: string;
10
+ }
11
+
12
+ /**
13
+ * The prompt texts the owner edits in the app: the tables, the documents provided as `PROMPTS`
14
+ * (the agent server's and the plugins' registered during their setup), and one warning per
15
+ * document that differs from its file version, said once every plugin has registered.
16
+ */
17
+ export function promptsPlugin(
18
+ options: PromptsOptions,
19
+ /** Opens the documents; a test hands stand-ins, so the plugin can be set up without a database. */
20
+ open: (
21
+ sql: SQL,
22
+ guildId: string,
23
+ logger: Logger,
24
+ ) => Promise<PgPromptDocuments> = (sql, guildId, logger) =>
25
+ PgPromptDocuments.attach(sql, guildId, logger),
26
+ ): RoundtablePlugin {
27
+ let opened: { documents: PgPromptDocuments; logger: Logger } | undefined;
28
+ return {
29
+ name: "prompts",
30
+ migrations: PgPromptDocuments.migrations(),
31
+ provides: [PROMPTS],
32
+ preflight: () => opened?.documents.logOverrides(opened.logger),
33
+ setup: async ({ database, services, logger }) => {
34
+ const documents = await open(database(), options.guildId, logger);
35
+ opened = { documents, logger };
36
+ services.provide(PROMPTS, documents);
37
+ return {};
38
+ },
39
+ };
40
+ }
@@ -70,11 +70,15 @@ export function skillsPlugin(
70
70
  return {
71
71
  sessionTools: [
72
72
  agentOnly("skill-list", () => skillListExtension(built)),
73
- agentOnly("skill-tools", () =>
74
- skillToolsExtension(built, (name) => {
75
- // The agent server is set up after this plugin and read when a session is built.
76
- services.get(AGENTS).directory.activeAgent(name);
77
- }),
73
+ agentOnly("skill-tools", (scope) =>
74
+ skillToolsExtension(
75
+ built,
76
+ (name) => {
77
+ // The agent server is set up after this plugin and read when a session is built.
78
+ services.get(AGENTS).directory.activeAgent(name);
79
+ },
80
+ scope.name,
81
+ ),
78
82
  ),
79
83
  ],
80
84
  agentSelection: () => ({
@@ -6,6 +6,7 @@ import { agentServerPlugin, LOCAL_TEAM_ID } from "./builtin/agent-server.ts";
6
6
  import { discordPlugin } from "./builtin/discord.ts";
7
7
  import { discordAdminPlugin } from "./builtin/discord-admin.ts";
8
8
  import { modulesPlugin, schedulerPlugin } from "./builtin/modules.ts";
9
+ import { promptsPlugin } from "./builtin/prompts.ts";
9
10
  import { seedsPlugin } from "./builtin/seeds.ts";
10
11
  import { skillsPlugin } from "./builtin/skills.ts";
11
12
  import {
@@ -338,6 +339,8 @@ export async function defineRoundtable(
338
339
  ...(config.memory ? [memoryPlugin({ owner })] : []),
339
340
  scheduleStorePlugin(),
340
341
  precheckPlugin(),
342
+ // Before the agent server and the operator's plugins, which register the texts the owner edits.
343
+ promptsPlugin({ guildId: discord?.guild ?? LOCAL_TEAM_ID }),
341
344
  // Before Discord, whose services hand out its instance, and the modules and agent server that use it.
342
345
  threadsPlugin({ dataDir: config.dataDir }),
343
346
  ...(assembly ? [assembly.discord] : []),
@@ -29,6 +29,8 @@ export function agentPanelEn(ctx: CatalogContext) {
29
29
  agentDefaultNote:
30
30
  "A changed prompt takes effect from this agent's next turn; the conversation history is kept.",
31
31
  agentNotFound: "That agent was not found.",
32
+ agentSeededNotDeleted:
33
+ "This agent comes from the host's configuration, so it would return at the next start. Remove it from the configuration first.",
32
34
  agentFollowAssistant: (value: string) =>
33
35
  `Follow ${ctx.assistant} (${value})`,
34
36
  agentModelModalTitle: (displayName: string) => `${displayName}'s model`,
@@ -76,6 +78,8 @@ export function agentPanelZhTW(
76
78
  agentDefaultNote:
77
79
  "提示詞改完,從這個 agent 的下一回合開始生效;對話紀錄保留。",
78
80
  agentNotFound: "找不到這個 agent。",
81
+ agentSeededNotDeleted:
82
+ "這個 agent 來自主機的設定,刪除後下次啟動又會出現。請先從設定中移除它。",
79
83
  agentFollowAssistant: (value: string) =>
80
84
  `跟隨 ${ctx.assistant}(${value})`,
81
85
  agentModelModalTitle: (displayName: string) => `${displayName} 的模型`,
@@ -65,7 +65,7 @@ export function cardsZhTW(ctx: CatalogContext): ReturnType<typeof cardsEn> {
65
65
  cardRunLabel: "執行",
66
66
  cardCancelLabel: "取消",
67
67
  cardCopyLabel: "複製",
68
- cardCopyGone: "這張卡片的文字已經不在了;請再請助理顯示一次。",
68
+ cardCopyGone: "這張卡片的文字已經不在了;請再請夥伴顯示一次。",
69
69
  cardCopyFile: "文字比較長,所以用檔案附上。",
70
70
  cardApprovalFooter: "你核准之前不會執行;準備好再回覆就好。",
71
71
  cardQuestionFooter: "準備好再回答就好。",
@@ -0,0 +1,425 @@
1
+ import type { SQL } from "bun";
2
+ import { isCheckDatabase } from "../../db/check-database.ts";
3
+ import type { Migration } from "../../db/migrations.ts";
4
+ import { AgentError } from "../../domain/errors.ts";
5
+ import { type Logger, silentLogger } from "../../log.ts";
6
+ import {
7
+ PromptHistory,
8
+ type PromptVersion,
9
+ promptHistoryMigration,
10
+ } from "./prompt-history.ts";
11
+
12
+ export type { PromptAction, PromptVersion } from "./prompt-history.ts";
13
+
14
+ /** The most characters a document may have unless its spec says otherwise. */
15
+ export const DEFAULT_DOCUMENT_CHARS = 20_000;
16
+
17
+ const DOCUMENT_ID = /^[a-z0-9-]{1,32}$/;
18
+
19
+ /** A prompt text the owner may edit, with the text from a file as its default. */
20
+ export interface PromptDocumentSpec {
21
+ /** Lowercase letters, digits and hyphens; names the document for good. */
22
+ id: string;
23
+ /** What the owner sees it called. */
24
+ label: string;
25
+ /** One line on where the text is used. */
26
+ description?: string;
27
+ /**
28
+ * The file version: the text read from a file or the configuration. A function is called each
29
+ * time the document is read, so a default that follows a file is current.
30
+ */
31
+ default: string | (() => string);
32
+ /** Default 20,000. */
33
+ maxChars?: number;
34
+ }
35
+
36
+ /** A registered document, read by whoever uses its text. */
37
+ export interface PromptHandle {
38
+ readonly id: string;
39
+ /** The text in force now: the owner's override while one is set, else the file version. */
40
+ text(): string;
41
+ }
42
+
43
+ /** A document as the owner sees it. */
44
+ export interface PromptDocumentView {
45
+ id: string;
46
+ label: string;
47
+ description?: string;
48
+ /** The text in force. */
49
+ text: string;
50
+ /** The file version. */
51
+ fileText: string;
52
+ /** An owner's override is set. */
53
+ overridden: boolean;
54
+ /** The override is set and its text is not the file version. */
55
+ differs: boolean;
56
+ override?: { by: string; at: Date };
57
+ maxChars: number;
58
+ /**
59
+ * Why the file version cannot be read, when its `default` throws. The document is then listed
60
+ * with an empty `fileText`, `differs` false and the override as `text` (empty with none), and
61
+ * `set` and `revert` refuse it until the file reads again. One broken document never blanks
62
+ * the others.
63
+ */
64
+ broken?: string;
65
+ }
66
+
67
+ /**
68
+ * A change made from a text that is no longer the one in force: someone else changed the document
69
+ * meanwhile. Nothing was written; `current` is what is in force now.
70
+ */
71
+ export class PromptConflictError extends Error {
72
+ override name = "PromptConflictError";
73
+ constructor(readonly current: PromptDocumentView) {
74
+ super(
75
+ `The prompt document "${current.id}" changed while it was being edited.`,
76
+ );
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Prompt texts the owner edits outside the files: each has a file version, an optional override
82
+ * stored in the database, and a history. The override wins while it is set and is read at every
83
+ * turn, so a change applies from the next turn without a restart.
84
+ */
85
+ export interface PromptDocuments {
86
+ /**
87
+ * Registers a document during a plugin's setup. Throws AgentError for an unusable or repeated
88
+ * id, and for a `default` that cannot be read now: the host does not start with it.
89
+ */
90
+ register(spec: PromptDocumentSpec): PromptHandle;
91
+ /**
92
+ * The text in force; throws AgentError for an unknown id, and for a document with no override
93
+ * whose file version cannot be read (logged as an error naming the document).
94
+ */
95
+ text(id: string): string;
96
+ /** Every document, in the order they were registered. */
97
+ list(): PromptDocumentView[];
98
+ /** One document, or undefined for an unknown id. */
99
+ get(id: string): PromptDocumentView | undefined;
100
+ /**
101
+ * Sets the override. The text must have 1 to `maxChars` characters and differ from the text in
102
+ * force; text equal to the file version reverts instead. Throws AgentError otherwise.
103
+ *
104
+ * `base` is the text the change was edited from. When it is given and is not the text in force
105
+ * when the write comes up in the queue, nothing is written and PromptConflictError carries the
106
+ * document as it is now.
107
+ */
108
+ set(
109
+ id: string,
110
+ text: string,
111
+ by: string,
112
+ base?: string,
113
+ ): Promise<PromptDocumentView>;
114
+ /**
115
+ * Returns to the file version, dropping the override. Throws AgentError when none is set, and
116
+ * PromptConflictError as `set` does when `base` is not the text in force.
117
+ */
118
+ revert(id: string, by: string, base?: string): Promise<PromptDocumentView>;
119
+ /** The changes of one document, newest first. */
120
+ history(id: string, limit?: number): Promise<PromptVersion[]>;
121
+ }
122
+
123
+ interface OverrideRow {
124
+ name: string;
125
+ text: string;
126
+ by_name: string;
127
+ at: Date;
128
+ }
129
+
130
+ interface Override {
131
+ text: string;
132
+ by: string;
133
+ at: Date;
134
+ }
135
+
136
+ interface Registered {
137
+ spec: PromptDocumentSpec;
138
+ }
139
+
140
+ const fileText = (spec: PromptDocumentSpec): string =>
141
+ typeof spec.default === "function" ? spec.default() : spec.default;
142
+
143
+ /** What reading a document's file version gave: its text, or why it could not be read. */
144
+ type FileRead = { text: string } | { broken: string; error: unknown };
145
+
146
+ const readFile = (spec: PromptDocumentSpec): FileRead => {
147
+ try {
148
+ return { text: fileText(spec) };
149
+ } catch (error) {
150
+ return {
151
+ broken: error instanceof Error ? error.message : String(error),
152
+ error,
153
+ };
154
+ }
155
+ };
156
+
157
+ /** The documents of one guild (or the local team), with their overrides in PostgreSQL. */
158
+ export class PgPromptDocuments implements PromptDocuments {
159
+ readonly #sql: SQL;
160
+ readonly #guild: string;
161
+ readonly #history: PromptHistory;
162
+ readonly #logger: Logger;
163
+ readonly #documents = new Map<string, Registered>();
164
+ readonly #overrides = new Map<string, Override>();
165
+ /** The tail of the writes, which run one at a time so none reads an override another is about to change. */
166
+ #queue: Promise<unknown> = Promise.resolve();
167
+
168
+ private constructor(sql: SQL, guildId: string, logger: Logger) {
169
+ this.#sql = sql;
170
+ this.#guild = guildId;
171
+ this.#logger = logger;
172
+ this.#history = new PromptHistory(sql, guildId);
173
+ }
174
+
175
+ /** The overrides and the history, created beside the agent server's own tables. */
176
+ static migrations(): Migration[] {
177
+ return [
178
+ {
179
+ name: "prompt-overrides",
180
+ up: async (sql) => {
181
+ await sql`
182
+ CREATE TABLE IF NOT EXISTS prompt_overrides (
183
+ guild_id text NOT NULL,
184
+ name text NOT NULL,
185
+ text text NOT NULL,
186
+ by_name text NOT NULL,
187
+ at timestamptz NOT NULL DEFAULT now(),
188
+ PRIMARY KEY (guild_id, name)
189
+ )`;
190
+ },
191
+ },
192
+ promptHistoryMigration("prompt-history"),
193
+ ];
194
+ }
195
+
196
+ /**
197
+ * The documents over the host's migrated pool, holding the overrides of one guild. A file
198
+ * version that cannot be read is logged to `logger` as an error naming the document.
199
+ */
200
+ static async attach(
201
+ sql: SQL,
202
+ guildId: string,
203
+ logger: Logger = silentLogger(),
204
+ ): Promise<PgPromptDocuments> {
205
+ const documents = new PgPromptDocuments(sql, guildId, logger);
206
+ // `Roundtable.check()` has no database to read the rows from: nothing is overridden.
207
+ if (!isCheckDatabase(sql)) await documents.#reload();
208
+ return documents;
209
+ }
210
+
211
+ async #reload(): Promise<void> {
212
+ const rows: OverrideRow[] = await this
213
+ .#sql`SELECT name, text, by_name, at FROM prompt_overrides WHERE guild_id = ${this.#guild}`;
214
+ this.#overrides.clear();
215
+ for (const row of rows)
216
+ this.#overrides.set(row.name, {
217
+ text: row.text,
218
+ by: row.by_name,
219
+ at: new Date(row.at),
220
+ });
221
+ }
222
+
223
+ #serial<T>(write: () => Promise<T>): Promise<T> {
224
+ const run = this.#queue.then(write);
225
+ this.#queue = run.catch(() => undefined);
226
+ return run;
227
+ }
228
+
229
+ register(spec: PromptDocumentSpec): PromptHandle {
230
+ if (!DOCUMENT_ID.test(spec.id))
231
+ throw new AgentError(
232
+ `"${spec.id}" is not a usable prompt document id: use 1 to 32 lowercase letters, digits and hyphens.`,
233
+ );
234
+ if (this.#documents.has(spec.id))
235
+ throw new AgentError(`A prompt document "${spec.id}" already exists.`);
236
+ if (!spec.label.trim())
237
+ throw new AgentError(`The prompt document "${spec.id}" needs a label.`);
238
+ // A default that cannot be read now stops the host here, not at the first turn that needs it.
239
+ const read = readFile(spec);
240
+ if ("broken" in read)
241
+ throw new AgentError(
242
+ `The prompt document "${spec.id}" has no readable file version: ${read.broken}`,
243
+ );
244
+ this.#documents.set(spec.id, { spec });
245
+ return { id: spec.id, text: () => this.text(spec.id) };
246
+ }
247
+
248
+ #registered(id: string): PromptDocumentSpec {
249
+ const found = this.#documents.get(id);
250
+ if (!found) throw new AgentError(`There is no prompt document "${id}".`);
251
+ return found.spec;
252
+ }
253
+
254
+ /** The file version, or an AgentError naming the document when it cannot be read; logged either way. */
255
+ #file(spec: PromptDocumentSpec): string {
256
+ const read = readFile(spec);
257
+ if (!("broken" in read)) return read.text;
258
+ this.#logUnreadable(spec, read.error);
259
+ throw new AgentError(
260
+ `The file version of the prompt document "${spec.id}" cannot be read: ${read.broken}`,
261
+ );
262
+ }
263
+
264
+ #logUnreadable(spec: PromptDocumentSpec, error: unknown): void {
265
+ this.#logger.error(
266
+ { document: spec.id, err: error },
267
+ "the file version of a prompt document cannot be read",
268
+ );
269
+ }
270
+
271
+ text(id: string): string {
272
+ const spec = this.#registered(id);
273
+ return this.#overrides.get(id)?.text ?? this.#file(spec);
274
+ }
275
+
276
+ #view(spec: PromptDocumentSpec): PromptDocumentView {
277
+ const read = readFile(spec);
278
+ const override = this.#overrides.get(spec.id);
279
+ const common = {
280
+ id: spec.id,
281
+ label: spec.label,
282
+ ...(spec.description ? { description: spec.description } : {}),
283
+ overridden: override !== undefined,
284
+ ...(override ? { override: { by: override.by, at: override.at } } : {}),
285
+ maxChars: spec.maxChars ?? DEFAULT_DOCUMENT_CHARS,
286
+ };
287
+ if ("broken" in read) {
288
+ this.#logUnreadable(spec, read.error);
289
+ return {
290
+ ...common,
291
+ text: override?.text ?? "",
292
+ fileText: "",
293
+ differs: false,
294
+ broken: read.broken,
295
+ };
296
+ }
297
+ return {
298
+ ...common,
299
+ text: override?.text ?? read.text,
300
+ fileText: read.text,
301
+ differs: override !== undefined && override.text !== read.text,
302
+ };
303
+ }
304
+
305
+ list(): PromptDocumentView[] {
306
+ return [...this.#documents.values()].map(({ spec }) => this.#view(spec));
307
+ }
308
+
309
+ get(id: string): PromptDocumentView | undefined {
310
+ const found = this.#documents.get(id);
311
+ return found ? this.#view(found.spec) : undefined;
312
+ }
313
+
314
+ set(
315
+ id: string,
316
+ text: string,
317
+ by: string,
318
+ base?: string,
319
+ ): Promise<PromptDocumentView> {
320
+ return this.#serial(async () => {
321
+ const spec = this.#registered(id);
322
+ const file = this.#file(spec);
323
+ const now = this.#overrides.get(id)?.text ?? file;
324
+ if (base !== undefined && base !== now)
325
+ throw new PromptConflictError(this.#view(spec));
326
+ const max = spec.maxChars ?? DEFAULT_DOCUMENT_CHARS;
327
+ if (!text.trim() || text.length > max)
328
+ throw new AgentError(
329
+ `A prompt document must have 1 to ${max} characters; this one has ${text.length}.`,
330
+ );
331
+ if (text === now)
332
+ throw new AgentError("The text is the one already in force.");
333
+ if (text === file) return this.#revert(id, spec, file, by);
334
+ const at = new Date();
335
+ await this.#sql.begin(async (tx) => {
336
+ await tx`
337
+ INSERT INTO prompt_overrides (guild_id, name, text, by_name, at)
338
+ VALUES (${this.#guild}, ${id}, ${text}, ${by}, ${at})
339
+ ON CONFLICT (guild_id, name) DO UPDATE
340
+ SET text = EXCLUDED.text, by_name = EXCLUDED.by_name, at = EXCLUDED.at`;
341
+ await this.#history.record(
342
+ "document",
343
+ id,
344
+ { action: "set", text, previous: now, by },
345
+ tx,
346
+ );
347
+ });
348
+ // The cache follows the committed write.
349
+ this.#overrides.set(id, { text, by, at });
350
+ return this.#view(spec);
351
+ });
352
+ }
353
+
354
+ revert(id: string, by: string, base?: string): Promise<PromptDocumentView> {
355
+ return this.#serial(async () => {
356
+ const spec = this.#registered(id);
357
+ const file = this.#file(spec);
358
+ if (
359
+ base !== undefined &&
360
+ base !== (this.#overrides.get(id)?.text ?? file)
361
+ )
362
+ throw new PromptConflictError(this.#view(spec));
363
+ if (!this.#overrides.has(id))
364
+ throw new AgentError(
365
+ `"${id}" has no override; the file version is in force.`,
366
+ );
367
+ return this.#revert(id, spec, file, by);
368
+ });
369
+ }
370
+
371
+ async #revert(
372
+ id: string,
373
+ spec: PromptDocumentSpec,
374
+ file: string,
375
+ by: string,
376
+ ): Promise<PromptDocumentView> {
377
+ const previous = this.#overrides.get(id)?.text ?? file;
378
+ await this.#sql.begin(async (tx) => {
379
+ await tx`DELETE FROM prompt_overrides WHERE guild_id = ${this.#guild} AND name = ${id}`;
380
+ await this.#history.record(
381
+ "document",
382
+ id,
383
+ { action: "revert", text: file, previous, by },
384
+ tx,
385
+ );
386
+ });
387
+ this.#overrides.delete(id);
388
+ return this.#view(spec);
389
+ }
390
+
391
+ history(id: string, limit?: number): Promise<PromptVersion[]> {
392
+ this.#registered(id);
393
+ return this.#history.list("document", id, limit);
394
+ }
395
+
396
+ /**
397
+ * Says which documents differ from their file versions, and which overrides name a document
398
+ * nobody registers; the host calls it once every plugin has registered its own.
399
+ */
400
+ logOverrides(logger: Logger): void {
401
+ for (const [id, override] of this.#overrides) {
402
+ const found = this.#documents.get(id);
403
+ if (!found) {
404
+ logger.warn(
405
+ { document: id, by: override.by },
406
+ "a stored prompt override names a document no plugin registers; it is ignored",
407
+ );
408
+ continue;
409
+ }
410
+ const read = readFile(found.spec);
411
+ if ("broken" in read) {
412
+ logger.error(
413
+ { document: id, err: read.error },
414
+ "the file version of a prompt document cannot be read",
415
+ );
416
+ continue;
417
+ }
418
+ if (override.text === read.text) continue;
419
+ logger.warn(
420
+ { document: id, by: override.by, at: override.at.toISOString() },
421
+ "a prompt document differs from its file version: the owner's override is in force until it is reverted in the app",
422
+ );
423
+ }
424
+ }
425
+ }