pi-roundtable 0.24.2 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/docs/plugins.md +174 -7
  3. package/package.json +1 -1
  4. package/src/cli/checks/agent-tools.ts +23 -0
  5. package/src/cli/doctor.ts +6 -0
  6. package/src/cli/principal.ts +9 -2
  7. package/src/core/agents/agent-book.ts +155 -0
  8. package/src/core/agents/agent-ports.ts +5 -1
  9. package/src/core/agents/agent-prompt.ts +6 -1
  10. package/src/core/agents/agent-schema.ts +3 -0
  11. package/src/core/agents/agent-statements.ts +179 -0
  12. package/src/core/agents/agent-store.ts +184 -191
  13. package/src/core/agents/agent-team-fixture.ts +21 -2
  14. package/src/core/agents/agent-team.ts +44 -36
  15. package/src/core/agents/agent-tools.ts +12 -6
  16. package/src/core/agents/team-editing.ts +154 -32
  17. package/src/core/agents/team-keys.ts +17 -0
  18. package/src/core/agents/team-lifecycle.ts +96 -23
  19. package/src/core/agents/team-options.ts +9 -4
  20. package/src/core/agents/team-selection.ts +32 -0
  21. package/src/core/builtin/agent-server.ts +30 -5
  22. package/src/core/builtin/channel-context.ts +21 -0
  23. package/src/core/builtin/discord.ts +4 -1
  24. package/src/core/builtin/prompts.ts +40 -0
  25. package/src/core/builtin/skills.ts +9 -5
  26. package/src/core/config/config-schema.ts +22 -6
  27. package/src/core/config/config.ts +30 -2
  28. package/src/core/config/conversations.ts +21 -0
  29. package/src/core/config/profiles.ts +53 -0
  30. package/src/core/contract/channel-context.ts +106 -0
  31. package/src/core/contract/surface.ts +16 -0
  32. package/src/core/conversations/conversation-registry.ts +6 -0
  33. package/src/core/conversations/conversation-retention.ts +107 -0
  34. package/src/core/conversations/conversation-store.ts +6 -0
  35. package/src/core/conversations/conversations-plugin.ts +19 -3
  36. package/src/core/define-roundtable.ts +12 -2
  37. package/src/core/discord/channel-context.ts +47 -78
  38. package/src/core/discord/discord-surface.ts +16 -0
  39. package/src/core/events.ts +1 -0
  40. package/src/core/host-registration.ts +4 -0
  41. package/src/core/host.ts +3 -1
  42. package/src/core/i18n/agent-panel.ts +4 -0
  43. package/src/core/i18n/cards.ts +1 -1
  44. package/src/core/identity/actor-facts.ts +7 -0
  45. package/src/core/identity/identity-plugin.ts +27 -5
  46. package/src/core/identity/identity-port.ts +62 -0
  47. package/src/core/identity/identity-schema.ts +2 -0
  48. package/src/core/identity/identity-service.ts +45 -52
  49. package/src/core/identity/identity-view.ts +1 -0
  50. package/src/core/identity/person-profiles.ts +275 -0
  51. package/src/core/identity/principal-tables.ts +10 -0
  52. package/src/core/identity/profile-choices.ts +45 -0
  53. package/src/core/identity/profile-memory.ts +65 -0
  54. package/src/core/identity/profile-picture.ts +117 -0
  55. package/src/core/identity/profiles-view.ts +20 -0
  56. package/src/core/identity/profiles.ts +82 -0
  57. package/src/core/modules/host-shell/shell-policy.ts +18 -3
  58. package/src/core/modules/prompts/prompt-documents.ts +425 -0
  59. package/src/core/modules/prompts/prompt-history.ts +103 -0
  60. package/src/core/modules/skills/skill-contract.ts +52 -0
  61. package/src/core/modules/skills/skill-document-files.ts +66 -0
  62. package/src/core/modules/skills/skill-registry.ts +136 -8
  63. package/src/core/modules/skills/skill-rules.ts +94 -0
  64. package/src/core/modules/skills/skill-store.ts +88 -17
  65. package/src/core/modules/skills/skill-tools.ts +18 -10
  66. package/src/core/plugin.ts +40 -0
  67. package/src/core/registry/contributions.ts +8 -1
  68. package/src/core/runtime/chat-only.ts +32 -0
  69. package/src/core/runtime/conversation-sessions.ts +2 -1
  70. package/src/core/runtime/extensions/agent-prompt.ts +20 -5
  71. package/src/core/runtime/extensions/persona-prompt.ts +39 -0
  72. package/src/core/runtime/extensions/prompt-read.ts +22 -0
  73. package/src/core/runtime/runtime-plugin.ts +1 -0
  74. package/src/core/runtime/runtime-types.ts +3 -1
  75. package/src/core/runtime/session-factory.ts +70 -65
  76. package/src/core/runtime/session-helpers.ts +27 -0
  77. package/src/core/services.ts +97 -33
  78. package/src/index.ts +31 -0
  79. package/src/kit/index.ts +1 -0
  80. package/src/kit/oidc.ts +16 -0
  81. package/src/testing.ts +1 -0
@@ -0,0 +1,117 @@
1
+ /** The most a picture may weigh. */
2
+ export const PICTURE_MAX_BYTES = 2 * 1024 * 1024;
3
+ const PICTURE_BUDGET_MS = 5_000;
4
+
5
+ /** A picture the proxy accepted: its bytes and the type they really are. */
6
+ export interface Picture {
7
+ bytes: Uint8Array<ArrayBuffer>;
8
+ type: string;
9
+ }
10
+
11
+ type Fetch = (input: string, init?: RequestInit) => Promise<Response>;
12
+
13
+ /** The type a body's first bytes show, for the four accepted formats. */
14
+ function sniff(bytes: Uint8Array): string | undefined {
15
+ const starts = (...values: number[]) =>
16
+ values.every((value, i) => bytes[i] === value);
17
+ if (starts(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a))
18
+ return "image/png";
19
+ if (starts(0xff, 0xd8, 0xff)) return "image/jpeg";
20
+ if (starts(0x47, 0x49, 0x46, 0x38)) return "image/gif";
21
+ if (
22
+ starts(0x52, 0x49, 0x46, 0x46) &&
23
+ bytes[8] === 0x57 &&
24
+ bytes[9] === 0x45 &&
25
+ bytes[10] === 0x42 &&
26
+ bytes[11] === 0x50
27
+ )
28
+ return "image/webp";
29
+ return undefined;
30
+ }
31
+
32
+ /** Reads a body up to the cap; undefined when it is longer. */
33
+ async function readCapped(
34
+ response: Response,
35
+ ): Promise<Uint8Array<ArrayBuffer> | undefined> {
36
+ const length = Number(response.headers.get("content-length"));
37
+ if (Number.isFinite(length) && length > PICTURE_MAX_BYTES) {
38
+ await response.body?.cancel().catch(() => undefined);
39
+ return undefined;
40
+ }
41
+ const reader = response.body?.getReader();
42
+ if (!reader) return undefined;
43
+ const chunks: Uint8Array[] = [];
44
+ let total = 0;
45
+ for (;;) {
46
+ const { done, value } = await reader.read();
47
+ if (done) break;
48
+ total += value.byteLength;
49
+ if (total > PICTURE_MAX_BYTES) {
50
+ await reader.cancel().catch(() => undefined);
51
+ return undefined;
52
+ }
53
+ chunks.push(value);
54
+ }
55
+ const bytes = new Uint8Array(total);
56
+ let at = 0;
57
+ for (const chunk of chunks) {
58
+ bytes.set(chunk, at);
59
+ at += chunk.byteLength;
60
+ }
61
+ return bytes;
62
+ }
63
+
64
+ /**
65
+ * Fetches a picture for the proxy: only an `https` address on one of the allowed hosts, no
66
+ * redirect followed, only PNG, JPEG, GIF or WebP whose header and first bytes agree (so an SVG is
67
+ * refused), at most 2 MiB. Undefined for anything else, a failure included.
68
+ */
69
+ export async function fetchPicture(
70
+ address: string,
71
+ hosts: readonly string[],
72
+ fetchImpl: Fetch = fetch,
73
+ ): Promise<Picture | undefined> {
74
+ const url = URL.parse(address);
75
+ if (!url) return undefined;
76
+ if (
77
+ url.protocol !== "https:" ||
78
+ url.username !== "" ||
79
+ url.password !== "" ||
80
+ url.port !== "" ||
81
+ !hosts.some((host) => host.toLowerCase() === url.hostname)
82
+ )
83
+ return undefined;
84
+ try {
85
+ const response = await fetchImpl(url.href, {
86
+ redirect: "manual",
87
+ signal: AbortSignal.timeout(PICTURE_BUDGET_MS),
88
+ headers: { accept: "image/png,image/jpeg,image/gif,image/webp" },
89
+ });
90
+ if (response.status !== 200) {
91
+ await response.body?.cancel().catch(() => undefined);
92
+ return undefined;
93
+ }
94
+ const declared = (response.headers.get("content-type") ?? "")
95
+ .split(";")[0]
96
+ ?.trim()
97
+ .toLowerCase();
98
+ const bytes = await readCapped(response);
99
+ if (!bytes) return undefined;
100
+ const type = sniff(bytes);
101
+ return type && type === declared ? { bytes, type } : undefined;
102
+ } catch {
103
+ return undefined;
104
+ }
105
+ }
106
+
107
+ /** The proxy's answer: `nosniff`, kept by the one browser for an hour. */
108
+ export function pictureResponse(picture: Picture): Response {
109
+ return new Response(picture.bytes, {
110
+ headers: {
111
+ "content-type": picture.type,
112
+ "content-length": String(picture.bytes.byteLength),
113
+ "x-content-type-options": "nosniff",
114
+ "cache-control": "private, max-age=3600",
115
+ },
116
+ });
117
+ }
@@ -0,0 +1,20 @@
1
+ import type { PersonProfiles } from "./profiles.ts";
2
+
3
+ /**
4
+ * The directory as plugins get it: the `PersonProfiles` methods and nothing else, so no plugin
5
+ * reaches the name the core gives agents. Each answer is a frozen copy.
6
+ */
7
+ export function profilesView(directory: PersonProfiles): PersonProfiles {
8
+ return Object.freeze({
9
+ register: (source) => directory.register(source),
10
+ sources: () =>
11
+ Object.freeze(directory.sources().map((s) => Object.freeze({ ...s }))),
12
+ observed: (principalId, provider, name) =>
13
+ directory.observed(principalId, provider, name),
14
+ sourceOf: (principalId) => directory.sourceOf(principalId),
15
+ choose: (principalId, provider) => directory.choose(principalId, provider),
16
+ of: async (principalId) =>
17
+ Object.freeze({ ...(await directory.of(principalId)) }),
18
+ picture: (principalId) => directory.picture(principalId),
19
+ } satisfies PersonProfiles);
20
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * How the core names a person. A person's accounts each carry a name and maybe a picture; the
3
+ * person picks which account represents them (or the host's `profiles.prefer` order does), and
4
+ * the core shows that name where it names the person: the web app, transcripts, and, when the
5
+ * host lists the provider in `profiles.modelVisible`, the `Speaker.name` the agents read.
6
+ */
7
+
8
+ /**
9
+ * Where a provider's names and pictures come from. Example: a support inbox registers a
10
+ * `github` source that reads a user's public profile.
11
+ */
12
+ export interface ProfileSource {
13
+ /** The identity provider the source reads, an open string such as `line`, `discord` or `github`. */
14
+ provider: string;
15
+ /** What the person sees when choosing, such as "LINE". */
16
+ label: string;
17
+ /**
18
+ * The source's own default. It never widens anything on its own: the names reach
19
+ * `Speaker.name` only for a provider the host lists in `profiles.modelVisible`.
20
+ */
21
+ modelVisible: boolean;
22
+ /** The https hosts its pictures come from; the picture proxy fetches only these. */
23
+ pictureHosts: readonly string[];
24
+ /**
25
+ * The name and picture address of one account, or undefined when the provider knows none. It
26
+ * has two seconds; a miss, a failure or a timeout is remembered for ten minutes.
27
+ */
28
+ fetch(
29
+ subject: string,
30
+ signal: AbortSignal,
31
+ ): Promise<{ name?: string; pictureUrl?: string } | undefined>;
32
+ }
33
+
34
+ /** How one person is shown. */
35
+ export interface PersonDisplay {
36
+ name: string;
37
+ /** The provider the name comes from; absent when it is the principal's own display name. */
38
+ source?: string;
39
+ /** Whether `picture` has one to give. */
40
+ picture: boolean;
41
+ }
42
+
43
+ /**
44
+ * The names and pictures of the people the host serves, provided as `PROFILES` by the `identity`
45
+ * plugin. Fetched and observed names and picture addresses live in memory for at most an hour and
46
+ * are never stored; only a person's choice of source is.
47
+ */
48
+ export interface PersonProfiles {
49
+ /** Adds a source; call it from a plugin's `setup`. A second source for one provider throws. */
50
+ register(source: ProfileSource): void;
51
+ /** The registered sources. */
52
+ sources(): readonly { provider: string; label: string }[];
53
+ /**
54
+ * A name a signed-in session reported for one of the person's linked identities, such as an
55
+ * OIDC `name` claim. It is held in memory for an hour, renewed by each sign-in, and never
56
+ * stored, so an identity provider becomes a source without a fetch.
57
+ */
58
+ observed(principalId: string, provider: string, name: string): void;
59
+ /**
60
+ * The provider that names the person: their stored choice while it stays linked, else the
61
+ * first `profiles.prefer` provider they have linked, else undefined.
62
+ */
63
+ sourceOf(principalId: string): Promise<string | undefined>;
64
+ /** Stores the person's choice; the provider must be linked to them. `null` returns to the default. */
65
+ choose(principalId: string, provider: string | null): Promise<void>;
66
+ /** How the person is shown; their display name when no source names them. */
67
+ of(principalId: string): Promise<PersonDisplay>;
68
+ /**
69
+ * The person's picture, fetched through the source's allowed hosts, or undefined: an address
70
+ * on another host, a redirect, anything but PNG, JPEG, GIF or WebP, and a body over 2 MiB are
71
+ * all refused. The answer is `nosniff` and cacheable for an hour by the one browser.
72
+ */
73
+ picture(principalId: string): Promise<Response | undefined>;
74
+ }
75
+
76
+ /** The `profiles` key of the host's configuration, filled. */
77
+ export interface ProfileRules {
78
+ /** Providers in the order that names a person who has not chosen, such as `["line", "oidc:<base64url issuer>"]`. */
79
+ prefer: readonly string[];
80
+ /** Providers whose names may become `Speaker.name`, which agents read. */
81
+ modelVisible: readonly string[];
82
+ }
@@ -33,7 +33,22 @@ export type { PushPolicy } from "./git-push.ts";
33
33
  export { simpleCommands } from "./shell-lexer.ts";
34
34
  export { YOLO_CARDED_SHELL_CLASSES } from "./shell-programs.ts";
35
35
 
36
- const MAX_SHOWN_COMMAND = 600;
36
+ const MAX_SHOWN_COMMAND = 120;
37
+
38
+ /**
39
+ * The command as a card's sentence shows it: its first line, cut to a length, in one code span.
40
+ * The card shows the whole command beside it; a second line or a backtick here would break the
41
+ * sentence's Markdown (a `# comment` line reads as a heading).
42
+ */
43
+ function shownCommand(command: string): string {
44
+ const [line = ""] = command.trim().split("\n", 1);
45
+ const cut = line.length > MAX_SHOWN_COMMAND;
46
+ const more = cut || line.length < command.trim().length;
47
+ const shown = (cut ? line.slice(0, MAX_SHOWN_COMMAND) : line)
48
+ .replaceAll("`", "'")
49
+ .trimEnd();
50
+ return more ? `${shown} …` : shown;
51
+ }
37
52
 
38
53
  /** Pi's built-in shell and file tools, which agent sessions activate. */
39
54
  export const SHELL_TOOLS = Object.freeze([
@@ -382,7 +397,7 @@ function shellHold(
382
397
  let detail = `the ${toolName} call`;
383
398
  try {
384
399
  if (toolName === "bash" && typeof input.command === "string")
385
- detail = `the shell command \`${input.command.slice(0, MAX_SHOWN_COMMAND)}\``;
400
+ detail = `the shell command \`${shownCommand(input.command)}\``;
386
401
  } catch {
387
402
  // The input itself cannot be read; the call is held without showing it.
388
403
  }
@@ -410,7 +425,7 @@ function judge(
410
425
  const [first] = found;
411
426
  return first
412
427
  ? {
413
- action: `run the shell command \`${command.slice(0, MAX_SHOWN_COMMAND)}\` on the host (${first.reason})`,
428
+ action: `run the shell command \`${shownCommand(command)}\` on the host (${first.reason})`,
414
429
  classes: [...new Set(found.map((held) => held.class))],
415
430
  }
416
431
  : undefined;
@@ -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
+ }