pi-roundtable 0.1.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.
- package/CHANGELOG.md +28 -0
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/docs/plugins.md +924 -0
- package/examples/channels.test.ts +13 -0
- package/examples/channels.ts +27 -0
- package/examples/dashboard.test.ts +11 -0
- package/examples/dashboard.ts +9 -0
- package/examples/events.test.ts +26 -0
- package/examples/events.ts +25 -0
- package/examples/guide.test.ts +52 -0
- package/examples/holds.test.ts +16 -0
- package/examples/holds.ts +30 -0
- package/examples/http.test.ts +12 -0
- package/examples/http.ts +17 -0
- package/examples/interactions.test.ts +25 -0
- package/examples/interactions.ts +28 -0
- package/examples/migrations.test.ts +31 -0
- package/examples/migrations.ts +42 -0
- package/examples/packages.test.ts +9 -0
- package/examples/packages.ts +9 -0
- package/examples/preflight.test.ts +11 -0
- package/examples/preflight.ts +19 -0
- package/examples/prompt.test.ts +25 -0
- package/examples/prompt.ts +20 -0
- package/examples/providers.test.ts +10 -0
- package/examples/providers.ts +17 -0
- package/examples/seeds.test.ts +12 -0
- package/examples/seeds.ts +18 -0
- package/examples/selection.test.ts +13 -0
- package/examples/selection.ts +14 -0
- package/examples/services.test.ts +20 -0
- package/examples/services.ts +30 -0
- package/examples/session-tools.test.ts +24 -0
- package/examples/session-tools.ts +34 -0
- package/examples/tools.test.ts +18 -0
- package/examples/tools.ts +28 -0
- package/package.json +55 -0
- package/src/cli/add-plugin.test.ts +107 -0
- package/src/cli/add-plugin.ts +75 -0
- package/src/cli/checks/basic.test.ts +276 -0
- package/src/cli/checks/bun.ts +26 -0
- package/src/cli/checks/configuration.ts +28 -0
- package/src/cli/checks/database.test.ts +117 -0
- package/src/cli/checks/database.ts +100 -0
- package/src/cli/checks/discord.test.ts +200 -0
- package/src/cli/checks/discord.ts +218 -0
- package/src/cli/checks/environment.ts +27 -0
- package/src/cli/checks/model.ts +28 -0
- package/src/cli/checks/public-url.ts +40 -0
- package/src/cli/cli.test.ts +122 -0
- package/src/cli/cli.ts +166 -0
- package/src/cli/config-edit.test.ts +130 -0
- package/src/cli/config-edit.ts +199 -0
- package/src/cli/discord-api.ts +96 -0
- package/src/cli/doctor.test.ts +184 -0
- package/src/cli/doctor.ts +92 -0
- package/src/cli/http.ts +23 -0
- package/src/cli/init.test.ts +127 -0
- package/src/cli/init.ts +82 -0
- package/src/cli/main.ts +13 -0
- package/src/cli/project.ts +123 -0
- package/src/cli/report.ts +68 -0
- package/src/cli/roundtable.mjs +22 -0
- package/src/cli/runtime.ts +53 -0
- package/src/cli/size.test.ts +9 -0
- package/src/cli/start.ts +33 -0
- package/src/cli/templates.test.ts +129 -0
- package/src/cli/templates.ts +119 -0
- package/src/cli/testing/fixtures.ts +106 -0
- package/src/core/agents/agent-claim.test.ts +142 -0
- package/src/core/agents/agent-claim.ts +170 -0
- package/src/core/agents/agent-dashboard.test.ts +120 -0
- package/src/core/agents/agent-dashboard.ts +185 -0
- package/src/core/agents/agent-guild.test.ts +124 -0
- package/src/core/agents/agent-messages.ts +189 -0
- package/src/core/agents/agent-ports.ts +103 -0
- package/src/core/agents/agent-prompt.ts +163 -0
- package/src/core/agents/agent-rules.ts +106 -0
- package/src/core/agents/agent-schema.ts +115 -0
- package/src/core/agents/agent-settings.ts +49 -0
- package/src/core/agents/agent-store.test.ts +205 -0
- package/src/core/agents/agent-store.ts +384 -0
- package/src/core/agents/agent-team.ts +445 -0
- package/src/core/agents/agent-tools.ts +386 -0
- package/src/core/agents/avatar-studio.test.ts +88 -0
- package/src/core/agents/avatar-studio.ts +147 -0
- package/src/core/agents/group-messages.ts +99 -0
- package/src/core/agents/group-round.test.ts +143 -0
- package/src/core/agents/group-round.ts +117 -0
- package/src/core/agents/group-turns.ts +132 -0
- package/src/core/agents/owner-identity.test.ts +172 -0
- package/src/core/agents/team-editing.ts +205 -0
- package/src/core/agents/team-keys.ts +40 -0
- package/src/core/agents/team-layout.ts +135 -0
- package/src/core/agents/team-lifecycle.ts +188 -0
- package/src/core/agents/team-options.ts +52 -0
- package/src/core/agents/team-status.ts +80 -0
- package/src/core/agents/team-text.ts +60 -0
- package/src/core/agents/team-turn-types.ts +107 -0
- package/src/core/agents/team-turns.test.ts +278 -0
- package/src/core/agents/team-turns.ts +323 -0
- package/src/core/assets/neutral.png +0 -0
- package/src/core/assets/prompts/shared-guest.md +11 -0
- package/src/core/assets/prompts/shared.md +11 -0
- package/src/core/assets/skills/writing-skills/SKILL.md +21 -0
- package/src/core/attachments/attachment-dir.ts +15 -0
- package/src/core/attachments/attachment-fetcher.ts +83 -0
- package/src/core/attachments/attachments.test.ts +85 -0
- package/src/core/attachments/image-prep.ts +50 -0
- package/src/core/attachments/prompt-block.ts +37 -0
- package/src/core/attachments/turn-attachments.ts +79 -0
- package/src/core/boundary.test.ts +45 -0
- package/src/core/builtin/agent-server.ts +275 -0
- package/src/core/builtin/discord.ts +95 -0
- package/src/core/builtin/modules.test.ts +127 -0
- package/src/core/builtin/modules.ts +176 -0
- package/src/core/builtin/seeds.ts +10 -0
- package/src/core/builtin/stores.ts +42 -0
- package/src/core/config/config.test.ts +135 -0
- package/src/core/config/config.ts +285 -0
- package/src/core/config/schema.ts +181 -0
- package/src/core/contract/channels.ts +152 -0
- package/src/core/contract/discord.ts +33 -0
- package/src/core/contract/providers.ts +67 -0
- package/src/core/db/guild-scope.ts +53 -0
- package/src/core/db/migrations.test.ts +237 -0
- package/src/core/db/migrations.ts +37 -0
- package/src/core/define-roundtable.test.ts +134 -0
- package/src/core/define-roundtable.ts +195 -0
- package/src/core/define.test.ts +144 -0
- package/src/core/define.ts +154 -0
- package/src/core/discord/agent-commands.test.ts +58 -0
- package/src/core/discord/agent-commands.ts +385 -0
- package/src/core/discord/agent-discord.test.ts +70 -0
- package/src/core/discord/agent-discord.ts +367 -0
- package/src/core/discord/channel-executor.ts +404 -0
- package/src/core/discord/channel-operations.ts +329 -0
- package/src/core/discord/discord-surface.ts +431 -0
- package/src/core/discord/dispatch-thread-host.test.ts +114 -0
- package/src/core/discord/dispatch-thread-host.ts +72 -0
- package/src/core/discord/dispatch-threads.test.ts +94 -0
- package/src/core/discord/dispatch-threads.ts +180 -0
- package/src/core/discord/interaction-module.ts +1 -0
- package/src/core/discord/owner-cards.test.ts +435 -0
- package/src/core/discord/owner-cards.ts +463 -0
- package/src/core/discord/owner-command.ts +126 -0
- package/src/core/discord/owner-discord-access.ts +183 -0
- package/src/core/discord/owner-discord-threads.ts +160 -0
- package/src/core/discord/owner-discord.test.ts +325 -0
- package/src/core/discord/owner-discord.ts +282 -0
- package/src/core/discord/owner-panel.ts +103 -0
- package/src/core/discord/schedule-commands.ts +149 -0
- package/src/core/discord/stop-button.ts +20 -0
- package/src/core/domain/attachment.ts +39 -0
- package/src/core/domain/conversation.ts +42 -0
- package/src/core/domain/errors.ts +31 -0
- package/src/core/domain/expression.ts +21 -0
- package/src/core/domain/owner-prompts.ts +45 -0
- package/src/core/domain/ports.ts +91 -0
- package/src/core/domain/profile.ts +32 -0
- package/src/core/drain.test.ts +46 -0
- package/src/core/drain.ts +33 -0
- package/src/core/errors.ts +29 -0
- package/src/core/events.test.ts +81 -0
- package/src/core/events.ts +55 -0
- package/src/core/holds.test.ts +54 -0
- package/src/core/holds.ts +46 -0
- package/src/core/host.test.ts +536 -0
- package/src/core/host.ts +297 -0
- package/src/core/http/listeners.test.ts +164 -0
- package/src/core/http/listeners.ts +151 -0
- package/src/core/i18n/agent-panel.ts +96 -0
- package/src/core/i18n/cards.ts +64 -0
- package/src/core/i18n/channels.ts +28 -0
- package/src/core/i18n/conversation.ts +49 -0
- package/src/core/i18n/dashboard.ts +72 -0
- package/src/core/i18n/discord.ts +23 -0
- package/src/core/i18n/en.ts +27 -0
- package/src/core/i18n/i18n.test.ts +139 -0
- package/src/core/i18n/index.ts +58 -0
- package/src/core/i18n/owner.ts +35 -0
- package/src/core/i18n/schedules.ts +92 -0
- package/src/core/i18n/time-zones.ts +27 -0
- package/src/core/i18n/types.ts +7 -0
- package/src/core/i18n/zh-tw.ts +26 -0
- package/src/core/identity.test.ts +18 -0
- package/src/core/identity.ts +31 -0
- package/src/core/judging/confirmation-judge.ts +68 -0
- package/src/core/judging/effort-judge.test.ts +112 -0
- package/src/core/judging/effort-judge.ts +158 -0
- package/src/core/judging/model-judge.test.ts +126 -0
- package/src/core/judging/model-judge.ts +193 -0
- package/src/core/log.ts +45 -0
- package/src/core/models.ts +52 -0
- package/src/core/modules/background/background-turns.ts +78 -0
- package/src/core/modules/delegation/delegate.ts +54 -0
- package/src/core/modules/delegation/delegator.test.ts +170 -0
- package/src/core/modules/delegation/delegator.ts +191 -0
- package/src/core/modules/delegation/sol-worker.ts +78 -0
- package/src/core/modules/discord-admin/discord-admin.ts +288 -0
- package/src/core/modules/host-shell/shell-policy.ts +386 -0
- package/src/core/modules/memory/owner-memory-store.test.ts +244 -0
- package/src/core/modules/memory/owner-memory-store.ts +228 -0
- package/src/core/modules/memory/owner-memory.ts +153 -0
- package/src/core/modules/notify/notify.ts +26 -0
- package/src/core/modules/schedules/recurrence.ts +206 -0
- package/src/core/modules/schedules/schedule-store.ts +213 -0
- package/src/core/modules/schedules/schedule-tools.ts +196 -0
- package/src/core/modules/schedules/schedule.test.ts +358 -0
- package/src/core/modules/schedules/scheduler.ts +121 -0
- package/src/core/modules/schedules/schedules.ts +87 -0
- package/src/core/modules/skills/repo-name.ts +11 -0
- package/src/core/modules/skills/skill-kind.test.ts +55 -0
- package/src/core/modules/skills/skill-link.ts +69 -0
- package/src/core/modules/skills/skill-listing.ts +128 -0
- package/src/core/modules/skills/skill-registry.test.ts +376 -0
- package/src/core/modules/skills/skill-registry.ts +323 -0
- package/src/core/modules/skills/skill-rules.ts +120 -0
- package/src/core/modules/skills/skill-store.ts +285 -0
- package/src/core/modules/skills/skill-tools.ts +207 -0
- package/src/core/ops/error-reporter.test.ts +283 -0
- package/src/core/ops/error-reporter.ts +271 -0
- package/src/core/plugin.ts +200 -0
- package/src/core/presentation/card-cadence.ts +41 -0
- package/src/core/presentation/headline.ts +34 -0
- package/src/core/presentation/presentation.test.ts +166 -0
- package/src/core/presentation/quiet-links.ts +36 -0
- package/src/core/presentation/reply-splitter.ts +110 -0
- package/src/core/presentation/thinking-line.ts +24 -0
- package/src/core/public-entry.test.ts +59 -0
- package/src/core/registry/contributions.test.ts +215 -0
- package/src/core/registry/contributions.ts +249 -0
- package/src/core/registry/interactions.test.ts +87 -0
- package/src/core/registry/interactions.ts +55 -0
- package/src/core/registry/providers.test.ts +103 -0
- package/src/core/registry/providers.ts +55 -0
- package/src/core/routing/channel-queue.test.ts +31 -0
- package/src/core/routing/channel-queue.ts +44 -0
- package/src/core/routing/channel-router.test.ts +326 -0
- package/src/core/routing/channel-router.ts +157 -0
- package/src/core/routing/conversation-kind.ts +14 -0
- package/src/core/routing/forward-join.ts +65 -0
- package/src/core/routing/message-text.ts +16 -0
- package/src/core/routing/settle-turn.test.ts +21 -0
- package/src/core/routing/settle-turn.ts +26 -0
- package/src/core/runtime/compaction-tiers.test.ts +227 -0
- package/src/core/runtime/compaction-tiers.ts +203 -0
- package/src/core/runtime/conversation-sessions.ts +162 -0
- package/src/core/runtime/extensions/agent-prompt.ts +16 -0
- package/src/core/runtime/extensions/ask-user.test.ts +114 -0
- package/src/core/runtime/extensions/ask-user.ts +98 -0
- package/src/core/runtime/extensions/confirmation-gate.ts +247 -0
- package/src/core/runtime/extensions/self-compact-guard.test.ts +50 -0
- package/src/core/runtime/extensions/self-compact-guard.ts +23 -0
- package/src/core/runtime/mcp.ts +23 -0
- package/src/core/runtime/pending-confirmation-store.test.ts +52 -0
- package/src/core/runtime/pending-confirmation-store.ts +75 -0
- package/src/core/runtime/pi-agent-runtime.ts +477 -0
- package/src/core/runtime/prompt-slot.ts +81 -0
- package/src/core/runtime/runtime-types.ts +150 -0
- package/src/core/runtime/session-archive.test.ts +20 -0
- package/src/core/runtime/session-archive.ts +17 -0
- package/src/core/runtime/session-factory.ts +291 -0
- package/src/core/runtime/steerable-run.test.ts +264 -0
- package/src/core/runtime/steerable-run.ts +109 -0
- package/src/core/runtime/text-tools.test.ts +110 -0
- package/src/core/runtime/text-tools.ts +66 -0
- package/src/core/runtime/turn-answer.test.ts +72 -0
- package/src/core/runtime/turn-answer.ts +79 -0
- package/src/core/runtime/worker-task.test.ts +82 -0
- package/src/core/runtime/worker-task.ts +65 -0
- package/src/core/services.test.ts +28 -0
- package/src/core/services.ts +107 -0
- package/src/core/sessions.test.ts +85 -0
- package/src/core/sessions.ts +149 -0
- package/src/core/shared/attachment-reader.ts +91 -0
- package/src/core/shared/delegate-tool.ts +15 -0
- package/src/core/shared/mcp-adapter.ts +58 -0
- package/src/core/shared/package-dir.ts +9 -0
- package/src/core/shared/profile-tools.ts +16 -0
- package/src/core/shared/read-attachment-tool.ts +43 -0
- package/src/core/shared/schedule-tools.ts +118 -0
- package/src/core/shared/session-messages.ts +50 -0
- package/src/core/shared/tool-result.ts +9 -0
- package/src/core/shared/unix-server.ts +18 -0
- package/src/core/size.test.ts +9 -0
- package/src/core/speakers.test.ts +78 -0
- package/src/core/speakers.ts +143 -0
- package/src/core/testing/database.ts +52 -0
- package/src/core/testing/file-size.ts +34 -0
- package/src/core/testing/locale.ts +8 -0
- package/src/core/testing/modules.ts +97 -0
- package/src/core/testing/owner.ts +15 -0
- package/src/core/testing/thread-host.ts +60 -0
- package/src/core/time.test.ts +166 -0
- package/src/core/time.ts +67 -0
- package/src/core/tool-tiers.test.ts +117 -0
- package/src/core/tool-tiers.ts +127 -0
- package/src/entries.test.ts +112 -0
- package/src/index.ts +28 -0
- package/src/testing.test.ts +156 -0
- package/src/testing.ts +182 -0
- package/templates/.env.example +22 -0
- package/templates/README.md +11 -0
- package/templates/_gitignore +3 -0
- package/templates/agents.ts +12 -0
- package/templates/biome.json.tmpl +7 -0
- package/templates/docker-compose.yml +16 -0
- package/templates/package.json.tmpl +24 -0
- package/templates/persona/shared.md +2 -0
- package/templates/plugin/plugin.test.ts.tmpl +13 -0
- package/templates/plugin/plugin.ts +18 -0
- package/templates/roundtable.config.ts +24 -0
- package/templates/tsconfig.json.tmpl +16 -0
package/docs/plugins.md
ADDED
|
@@ -0,0 +1,924 @@
|
|
|
1
|
+
# Writing plugins for pi-roundtable
|
|
2
|
+
|
|
3
|
+
This guide is for someone who has run `npx pi-roundtable init` and wants the bot to do something it does not do yet.
|
|
4
|
+
Read it once, top to bottom, and you can write, test, and run a plugin.
|
|
5
|
+
|
|
6
|
+
Every code block below marked with `example:` is a real file under [`examples/`](../examples), and the test suite fails when a block here differs from its file.
|
|
7
|
+
Each example has a test next to it that runs it without Discord or PostgreSQL, except the one that needs a database, which says so.
|
|
8
|
+
|
|
9
|
+
## What a plugin is
|
|
10
|
+
|
|
11
|
+
A plugin is an object with a name and a `setup` function.
|
|
12
|
+
`setup` returns the parts the plugin adds to the bot: tools agents can call, text added to their prompt, agents to create, handlers for events, long-lived services, slash commands, HTTP routes, and so on.
|
|
13
|
+
The bot itself is assembled from plugins too: the core ships built-in plugins for its stores, Discord connection, agent server, skills, memory, notifications, delegation, and schedules, and yours are added after them.
|
|
14
|
+
|
|
15
|
+
You write plugins in TypeScript, list them in `roundtable.config.ts`, and Bun loads them directly.
|
|
16
|
+
There is no build step and no plugin registry: importing a plugin is how you install it.
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
// roundtable.config.ts
|
|
20
|
+
import type { RoundtableConfig } from "pi-roundtable";
|
|
21
|
+
import { notes } from "./plugins/notes.ts";
|
|
22
|
+
|
|
23
|
+
export default {
|
|
24
|
+
// ...the settings `init` wrote...
|
|
25
|
+
plugins: [notes],
|
|
26
|
+
} satisfies RoundtableConfig;
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Everything a plugin author needs comes from two entries, and nothing else can be imported from the package:
|
|
30
|
+
|
|
31
|
+
| Entry | What it exports |
|
|
32
|
+
|---|---|
|
|
33
|
+
| `pi-roundtable` | `definePlugin`, `defineTool`, `defineRoundtable`, `ToolRefusal`, `PluginError`, `NotLinkedError`, `Roundtable`, and the types (`Tier`, `Speaker`, `Contribution`, `PluginContext`, and so on) |
|
|
34
|
+
| `pi-roundtable/testing` | `testPlugin`, the harness that runs a plugin against a fake context |
|
|
35
|
+
|
|
36
|
+
`roundtable add plugin <name>` creates `plugins/<name>.ts` and its test from a small template and lists it in `roundtable.config.ts`.
|
|
37
|
+
The name is lowercase words joined by dashes, such as `my-notes`.
|
|
38
|
+
|
|
39
|
+
## The plugin object
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
definePlugin({
|
|
43
|
+
name: "my-notes", // lowercase words joined by dashes; unique across all plugins
|
|
44
|
+
migrations: [], // optional: tables the plugin needs
|
|
45
|
+
providers: {}, // optional: replaces a part the core runs on
|
|
46
|
+
preflight() {}, // optional: a check that runs before anything starts
|
|
47
|
+
setup(context) { // required: returns the parts the plugin adds
|
|
48
|
+
return { /* parts */ };
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`definePlugin` returns the object it was given, typed, after checking the name and that `setup` is there, so a mistake shows where the plugin is written.
|
|
54
|
+
|
|
55
|
+
A plugin must add something.
|
|
56
|
+
A plugin whose `setup` returns `{}` and that has no migrations, providers, or hooks stops the start (see [Errors](#errors-and-their-fixes)).
|
|
57
|
+
|
|
58
|
+
### The context
|
|
59
|
+
|
|
60
|
+
`setup` receives a `PluginContext`:
|
|
61
|
+
|
|
62
|
+
| Field | What it is |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `logger` | A pino logger; each line is JSON on stdout |
|
|
65
|
+
| `database()` | The host's one PostgreSQL connection (a Bun `SQL`), already migrated |
|
|
66
|
+
| `toolTiers` | What each tool needs; ask it when a tool is used, not during setup |
|
|
67
|
+
| `events` | Where the core reports turns and team changes to every plugin's handlers |
|
|
68
|
+
| `providers` | Each provider slot, from the plugin that fills it or the core's default |
|
|
69
|
+
| `queue` | The one channel queue that every conversation and channel operation shares |
|
|
70
|
+
| `core` | What the built-in plugins built (stores, the Discord surface, the team, the runtime), for advanced plugins |
|
|
71
|
+
| `sessions()`, `conversations`, `dashboard()` | Linked once every plugin has been set up; calling them during `setup` throws `NotLinkedError` |
|
|
72
|
+
|
|
73
|
+
Use `sessions()`, `conversations`, and `dashboard()` from a service's `start` or from an event handler, not from `setup`.
|
|
74
|
+
|
|
75
|
+
## Tiers: who may use what
|
|
76
|
+
|
|
77
|
+
Every turn is for a speaker, and a speaker has a tier: `owner`, `admin`, or `member`, from most to least trusted.
|
|
78
|
+
With nothing configured only the owner speaks.
|
|
79
|
+
An operator who opens the bot to others lists them under `speakers` in `roundtable.config.ts` and owns that setup.
|
|
80
|
+
|
|
81
|
+
A tool must say which tier may call it.
|
|
82
|
+
The tool is offered only in turns whose speaker is at that tier or above, and the operator's `toolTiers` setting can override what the plugin chose.
|
|
83
|
+
A tool nobody named needs the owner.
|
|
84
|
+
|
|
85
|
+
## The parts
|
|
86
|
+
|
|
87
|
+
Each heading below is a key `setup` may return.
|
|
88
|
+
A key the contract does not have stops the start and names the closest one.
|
|
89
|
+
|
|
90
|
+
### `tools`: what agents can call
|
|
91
|
+
|
|
92
|
+
`defineTool` takes a name (lowercase words joined by underscores), a description the model reads to decide when to call it, a Typebox parameter schema, the lowest tier that may call it, and the function.
|
|
93
|
+
`run` receives the arguments, already typed, and the turn: the speaker, the channel, the agent, and an abort signal.
|
|
94
|
+
It returns the text the model reads.
|
|
95
|
+
Throw `ToolRefusal` for a call the model should correct; any other error fails the call.
|
|
96
|
+
|
|
97
|
+
<!-- example: examples/tools.ts -->
|
|
98
|
+
```ts
|
|
99
|
+
import { definePlugin, defineTool, ToolRefusal } from "pi-roundtable";
|
|
100
|
+
import { Type } from "typebox";
|
|
101
|
+
|
|
102
|
+
/** Tools are what agents can call; each names the lowest tier of speaker whose turns may call it. */
|
|
103
|
+
export const notes = definePlugin({
|
|
104
|
+
name: "notes",
|
|
105
|
+
setup: () => {
|
|
106
|
+
const saved: string[] = [];
|
|
107
|
+
return {
|
|
108
|
+
tools: [
|
|
109
|
+
defineTool({
|
|
110
|
+
name: "note_add",
|
|
111
|
+
description:
|
|
112
|
+
"Save a short note. Call it when asked to remember something.",
|
|
113
|
+
parameters: Type.Object({ text: Type.String() }),
|
|
114
|
+
minTier: "member",
|
|
115
|
+
run: ({ text }, turn) => {
|
|
116
|
+
// A refusal is read by the model, which can correct the call.
|
|
117
|
+
if (text.trim() === "")
|
|
118
|
+
throw new ToolRefusal("The note is empty. Ask what to save.");
|
|
119
|
+
saved.push(text);
|
|
120
|
+
return `Saved note ${saved.length} for ${turn.speaker?.name ?? "nobody"}.`;
|
|
121
|
+
},
|
|
122
|
+
}),
|
|
123
|
+
],
|
|
124
|
+
};
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
```
|
|
128
|
+
<!-- /example -->
|
|
129
|
+
|
|
130
|
+
The tool names `bash`, `read`, `edit`, and `write` belong to the agents already.
|
|
131
|
+
|
|
132
|
+
### `holdRules`, and a tool's `hold`: calls that wait for the owner
|
|
133
|
+
|
|
134
|
+
A held call is described to the owner, who approves or refuses it in Discord before the call runs.
|
|
135
|
+
A tool's `hold` returns the description for its own calls, and `holdRules` are rules over every tool call, asked in order until one describes the call.
|
|
136
|
+
Each rule needs a name, unique across plugins.
|
|
137
|
+
|
|
138
|
+
<!-- example: examples/holds.ts -->
|
|
139
|
+
```ts
|
|
140
|
+
import { definePlugin, defineTool } from "pi-roundtable";
|
|
141
|
+
import { Type } from "typebox";
|
|
142
|
+
|
|
143
|
+
/** Holds make a call wait for the owner's approval; the description is what the owner is asked to approve. */
|
|
144
|
+
export const cleanup = definePlugin({
|
|
145
|
+
name: "cleanup",
|
|
146
|
+
setup: () => ({
|
|
147
|
+
tools: [
|
|
148
|
+
defineTool({
|
|
149
|
+
name: "file_delete",
|
|
150
|
+
description: "Delete a file in the shared workspace.",
|
|
151
|
+
parameters: Type.Object({ path: Type.String() }),
|
|
152
|
+
minTier: "admin",
|
|
153
|
+
// Returning text holds this call; returning undefined lets it run.
|
|
154
|
+
hold: ({ path }) => `Delete ${path}`,
|
|
155
|
+
run: ({ path }) => `Deleted ${path}.`,
|
|
156
|
+
}),
|
|
157
|
+
],
|
|
158
|
+
// A rule sees every tool call, whoever defined the tool.
|
|
159
|
+
holdRules: [
|
|
160
|
+
{
|
|
161
|
+
name: "cleanup-production",
|
|
162
|
+
describe: (tool, input) =>
|
|
163
|
+
JSON.stringify(input).includes("production")
|
|
164
|
+
? `${tool} touches production`
|
|
165
|
+
: undefined,
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
}),
|
|
169
|
+
});
|
|
170
|
+
```
|
|
171
|
+
<!-- /example -->
|
|
172
|
+
|
|
173
|
+
### `prompt`: text added to every agent turn
|
|
174
|
+
|
|
175
|
+
Each section's `build` gets the agent, the speaker (undefined between turns), and the turn's scope.
|
|
176
|
+
What it returns is added after the core's prompt, separated by a blank line, in plugin order; returning `undefined` or empty text adds nothing.
|
|
177
|
+
|
|
178
|
+
<!-- example: examples/prompt.ts -->
|
|
179
|
+
```ts
|
|
180
|
+
import { definePlugin } from "pi-roundtable";
|
|
181
|
+
|
|
182
|
+
/** A prompt section is text added after the core's prompt in every agent turn; return undefined to add nothing. */
|
|
183
|
+
export const houseRules = definePlugin({
|
|
184
|
+
name: "house-rules",
|
|
185
|
+
setup: () => ({
|
|
186
|
+
prompt: [
|
|
187
|
+
{
|
|
188
|
+
name: "house-rules",
|
|
189
|
+
build: ({ agent, speaker }) =>
|
|
190
|
+
[
|
|
191
|
+
`House rules for ${agent.displayName}: answer in the language you were asked in.`,
|
|
192
|
+
speaker ? `You are talking with ${speaker.name}.` : undefined,
|
|
193
|
+
]
|
|
194
|
+
.filter((line) => line !== undefined)
|
|
195
|
+
.join(" "),
|
|
196
|
+
},
|
|
197
|
+
],
|
|
198
|
+
}),
|
|
199
|
+
});
|
|
200
|
+
```
|
|
201
|
+
<!-- /example -->
|
|
202
|
+
|
|
203
|
+
A test builds a section the way a turn does: `section.build({ agent, speaker, scope })` takes the agent's `name` and `displayName`, the speaker or `undefined`, and the scope (`name`, `session`, `home`), and returns the text that would be added.
|
|
204
|
+
|
|
205
|
+
<!-- example: examples/prompt.test.ts -->
|
|
206
|
+
```ts
|
|
207
|
+
import { expect, test } from "bun:test";
|
|
208
|
+
import { testPlugin } from "pi-roundtable/testing";
|
|
209
|
+
import { houseRules } from "./prompt.ts";
|
|
210
|
+
|
|
211
|
+
test("the section names the agent, and the speaker when there is one", async () => {
|
|
212
|
+
const harness = await testPlugin(houseRules);
|
|
213
|
+
const section = harness.contribution.prompt?.[0];
|
|
214
|
+
const scope = {
|
|
215
|
+
name: "guide",
|
|
216
|
+
session: "discord:1",
|
|
217
|
+
home: "discord:1",
|
|
218
|
+
} as const;
|
|
219
|
+
const agent = { name: "guide", displayName: "Guide" };
|
|
220
|
+
expect(section?.build({ agent, speaker: undefined, scope })).toBe(
|
|
221
|
+
"House rules for Guide: answer in the language you were asked in.",
|
|
222
|
+
);
|
|
223
|
+
expect(
|
|
224
|
+
section?.build({
|
|
225
|
+
agent,
|
|
226
|
+
speaker: { id: "1", name: "Ada", tier: "member" },
|
|
227
|
+
scope,
|
|
228
|
+
}),
|
|
229
|
+
).toContain("You are talking with Ada.");
|
|
230
|
+
await harness.stop();
|
|
231
|
+
});
|
|
232
|
+
```
|
|
233
|
+
<!-- /example -->
|
|
234
|
+
|
|
235
|
+
### `seeds`: agents created on the first start
|
|
236
|
+
|
|
237
|
+
A seed has a name, a display name, a prompt, and a prompt for drawing its avatar.
|
|
238
|
+
The first start creates the agents that are not stored yet and never overwrites one that is, so agents are edited in Discord afterwards.
|
|
239
|
+
`agents` in `roundtable.config.ts` is the same list, for your own team.
|
|
240
|
+
|
|
241
|
+
<!-- example: examples/seeds.ts -->
|
|
242
|
+
```ts
|
|
243
|
+
import { definePlugin } from "pi-roundtable";
|
|
244
|
+
|
|
245
|
+
/** Seeds are agents created on the first start; an agent already stored is never overwritten, so edit it in Discord afterwards. */
|
|
246
|
+
export const library = definePlugin({
|
|
247
|
+
name: "library",
|
|
248
|
+
setup: () => ({
|
|
249
|
+
seeds: [
|
|
250
|
+
{
|
|
251
|
+
name: "librarian",
|
|
252
|
+
displayName: "Librarian",
|
|
253
|
+
prompt:
|
|
254
|
+
"You keep the team's reading list. Answer briefly and cite what you were given.",
|
|
255
|
+
avatarPrompt:
|
|
256
|
+
"A calm librarian with round glasses and a stack of books",
|
|
257
|
+
},
|
|
258
|
+
],
|
|
259
|
+
}),
|
|
260
|
+
});
|
|
261
|
+
```
|
|
262
|
+
<!-- /example -->
|
|
263
|
+
|
|
264
|
+
### `events`: hear what the core does
|
|
265
|
+
|
|
266
|
+
| Handler | Runs when |
|
|
267
|
+
|---|---|
|
|
268
|
+
| `agentServer(outcome)` | The agent server has started (`"ready"`) or failed to (`"failed"`); the rest of the process runs either way |
|
|
269
|
+
| `turnStarted(turn)` | An agent's turn began |
|
|
270
|
+
| `turnEnded(turn)` | An agent's turn ended; `turn.result` is `"ok"`, `"failed"`, or `"stopped"` |
|
|
271
|
+
| `changed()` | The team changed: an agent or group was created, edited, arranged, archived, or started over |
|
|
272
|
+
| `shutdown(left)` | The shutdown drain ended, before any service stops; `left` lists the work it gave up on |
|
|
273
|
+
|
|
274
|
+
A handler that throws is logged and never stops the others.
|
|
275
|
+
|
|
276
|
+
<!-- example: examples/events.ts -->
|
|
277
|
+
```ts
|
|
278
|
+
import { definePlugin } from "pi-roundtable";
|
|
279
|
+
|
|
280
|
+
/** Handlers hear what the core does. One that throws is logged and never stops the others. */
|
|
281
|
+
export function turnLog(lines: string[]) {
|
|
282
|
+
return definePlugin({
|
|
283
|
+
name: "turn-log",
|
|
284
|
+
setup: () => ({
|
|
285
|
+
events: {
|
|
286
|
+
turnStarted: (turn) => {
|
|
287
|
+
lines.push(`${turn.agent} started`);
|
|
288
|
+
},
|
|
289
|
+
turnEnded: (turn) => {
|
|
290
|
+
lines.push(`${turn.agent} ${turn.result}`);
|
|
291
|
+
},
|
|
292
|
+
changed: () => {
|
|
293
|
+
lines.push("team changed");
|
|
294
|
+
},
|
|
295
|
+
// The drain is over, and no service has stopped yet.
|
|
296
|
+
shutdown: (left) => {
|
|
297
|
+
lines.push(`shutdown, ${left.length} unfinished`);
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
}),
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
```
|
|
304
|
+
<!-- /example -->
|
|
305
|
+
|
|
306
|
+
A test calls a handler itself with the payload the core sends: a turn is `{ agent, channel, speaker }` (plus `group` for a member's turn in a group), `turnEnded` adds `result`, `changed` takes nothing, and `shutdown` takes the list of unfinished work, which `harness.stop()` delivers as an empty one.
|
|
307
|
+
|
|
308
|
+
<!-- example: examples/events.test.ts -->
|
|
309
|
+
```ts
|
|
310
|
+
import { expect, test } from "bun:test";
|
|
311
|
+
import { testPlugin } from "pi-roundtable/testing";
|
|
312
|
+
import { turnLog } from "./events.ts";
|
|
313
|
+
|
|
314
|
+
test("the handlers record a turn, a team change, and the shutdown", async () => {
|
|
315
|
+
const lines: string[] = [];
|
|
316
|
+
const harness = await testPlugin(turnLog(lines));
|
|
317
|
+
const { events } = harness.contribution;
|
|
318
|
+
// The harness does not deliver the core's events: call the handlers with the payload the core sends.
|
|
319
|
+
const turn = {
|
|
320
|
+
agent: "guide",
|
|
321
|
+
channel: "discord:1",
|
|
322
|
+
speaker: undefined,
|
|
323
|
+
} as const;
|
|
324
|
+
await events?.turnStarted?.(turn);
|
|
325
|
+
await events?.turnEnded?.({ ...turn, result: "ok" });
|
|
326
|
+
await events?.changed?.();
|
|
327
|
+
// stop() delivers shutdown(left) with an empty list, then stops the services.
|
|
328
|
+
await harness.stop();
|
|
329
|
+
expect(lines).toEqual([
|
|
330
|
+
"guide started",
|
|
331
|
+
"guide ok",
|
|
332
|
+
"team changed",
|
|
333
|
+
"shutdown, 0 unfinished",
|
|
334
|
+
]);
|
|
335
|
+
});
|
|
336
|
+
```
|
|
337
|
+
<!-- /example -->
|
|
338
|
+
|
|
339
|
+
### `services`: long-lived parts
|
|
340
|
+
|
|
341
|
+
A service has a name and optional `start`, `stop`, and `busy`.
|
|
342
|
+
Services start once everything is set up and stop in reverse order.
|
|
343
|
+
`busy` lists the work still running, one entry each; a shutdown waits until every service's list is empty, so a deploy never cuts work short.
|
|
344
|
+
|
|
345
|
+
Use a service for anything with a lifetime: a timer, a queue, a connection.
|
|
346
|
+
There is no separate part for schedules: agents create schedules with the built-in `schedule_create` tool, the built-in scheduler fires them, and a scheduled turn is an ordinary agent turn that can call your tools.
|
|
347
|
+
A plugin that needs its own timer writes a service like this one.
|
|
348
|
+
|
|
349
|
+
<!-- example: examples/services.ts -->
|
|
350
|
+
```ts
|
|
351
|
+
import { definePlugin } from "pi-roundtable";
|
|
352
|
+
|
|
353
|
+
/** A service is a long-lived part: it starts once everything is set up and stops in reverse order at shutdown. */
|
|
354
|
+
export function heartbeat(everyMs: number, beat: () => Promise<void> | void) {
|
|
355
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
356
|
+
let running = 0;
|
|
357
|
+
return definePlugin({
|
|
358
|
+
name: "heartbeat",
|
|
359
|
+
setup: () => ({
|
|
360
|
+
services: [
|
|
361
|
+
{
|
|
362
|
+
name: "heartbeat-timer",
|
|
363
|
+
start: () => {
|
|
364
|
+
timer = setInterval(async () => {
|
|
365
|
+
running++;
|
|
366
|
+
try {
|
|
367
|
+
await beat();
|
|
368
|
+
} finally {
|
|
369
|
+
running--;
|
|
370
|
+
}
|
|
371
|
+
}, everyMs);
|
|
372
|
+
},
|
|
373
|
+
stop: () => clearInterval(timer),
|
|
374
|
+
// Shutdown waits until this list is empty, so a beat is never cut off.
|
|
375
|
+
busy: () => (running > 0 ? ["a heartbeat is running"] : []),
|
|
376
|
+
},
|
|
377
|
+
],
|
|
378
|
+
}),
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
```
|
|
382
|
+
<!-- /example -->
|
|
383
|
+
|
|
384
|
+
### `migrations` and `context.database()`: tables of your own
|
|
385
|
+
|
|
386
|
+
`migrations` sits on the plugin, not in what `setup` returns.
|
|
387
|
+
Every start runs every plugin's migrations, in plugin order, before any `setup`, so a table is there when `setup` asks for the database.
|
|
388
|
+
A migration is `{ name, up(sql) }`, and `up` must be idempotent (`CREATE TABLE IF NOT EXISTS`) because it runs again over the existing schema on every start.
|
|
389
|
+
Migration names and table names are shared with the core and with every other plugin, so prefix them with your plugin's name.
|
|
390
|
+
|
|
391
|
+
`testPlugin` gives your plugin the database you pass it but does not run its migrations; run them yourself first, as this example's test does.
|
|
392
|
+
This is the one example whose test needs PostgreSQL: it is skipped unless `ROUNDTABLE_TEST_DATABASE_URL` is set.
|
|
393
|
+
The package exports no client of its own for tests, so the test opens a Bun `SQL` on that URL, runs the migrations twice, passes the client to `testPlugin`, and drops its table in `finally`.
|
|
394
|
+
Point the variable at a database you can write to and lose:
|
|
395
|
+
|
|
396
|
+
```sh
|
|
397
|
+
ROUNDTABLE_TEST_DATABASE_URL=postgres://postgres:postgres@localhost:5432/plugin_test bun test
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
<!-- example: examples/migrations.ts -->
|
|
401
|
+
```ts
|
|
402
|
+
import { definePlugin, defineTool } from "pi-roundtable";
|
|
403
|
+
import { Type } from "typebox";
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Migrations create the plugin's tables before any setup runs, on every start, so they must be
|
|
407
|
+
* idempotent. Their names and the tables are shared with every other plugin: prefix them.
|
|
408
|
+
*/
|
|
409
|
+
export const visitCounter = definePlugin({
|
|
410
|
+
name: "visit-counter",
|
|
411
|
+
migrations: [
|
|
412
|
+
{
|
|
413
|
+
name: "visit-counter-1-create",
|
|
414
|
+
up: async (sql) => {
|
|
415
|
+
await sql`CREATE TABLE IF NOT EXISTS visit_counter (
|
|
416
|
+
channel text PRIMARY KEY,
|
|
417
|
+
visits integer NOT NULL DEFAULT 0
|
|
418
|
+
)`;
|
|
419
|
+
},
|
|
420
|
+
},
|
|
421
|
+
],
|
|
422
|
+
setup: (context) => {
|
|
423
|
+
// The host's one connection pool, already migrated.
|
|
424
|
+
const sql = context.database();
|
|
425
|
+
return {
|
|
426
|
+
tools: [
|
|
427
|
+
defineTool({
|
|
428
|
+
name: "visit_count",
|
|
429
|
+
description: "Count a visit to a place and say which visit it is.",
|
|
430
|
+
parameters: Type.Object({ place: Type.String() }),
|
|
431
|
+
minTier: "member",
|
|
432
|
+
run: async ({ place }) => {
|
|
433
|
+
const [row] = await sql`
|
|
434
|
+
INSERT INTO visit_counter (channel, visits) VALUES (${place}, 1)
|
|
435
|
+
ON CONFLICT (channel) DO UPDATE SET visits = visit_counter.visits + 1
|
|
436
|
+
RETURNING visits`;
|
|
437
|
+
return `Visit ${row?.visits} to ${place}.`;
|
|
438
|
+
},
|
|
439
|
+
}),
|
|
440
|
+
],
|
|
441
|
+
};
|
|
442
|
+
},
|
|
443
|
+
});
|
|
444
|
+
```
|
|
445
|
+
<!-- /example -->
|
|
446
|
+
|
|
447
|
+
The test:
|
|
448
|
+
|
|
449
|
+
<!-- example: examples/migrations.test.ts -->
|
|
450
|
+
```ts
|
|
451
|
+
import { expect, test } from "bun:test";
|
|
452
|
+
import { SQL } from "bun";
|
|
453
|
+
import { testPlugin } from "pi-roundtable/testing";
|
|
454
|
+
import { visitCounter } from "./migrations.ts";
|
|
455
|
+
|
|
456
|
+
const url = process.env.ROUNDTABLE_TEST_DATABASE_URL;
|
|
457
|
+
|
|
458
|
+
// The harness gives the plugin your database but does not migrate it: run the migrations first.
|
|
459
|
+
test.skipIf(!url)(
|
|
460
|
+
"the tool counts visits in the plugin's own table",
|
|
461
|
+
async () => {
|
|
462
|
+
const sql = new SQL(url as string);
|
|
463
|
+
try {
|
|
464
|
+
for (const migration of visitCounter.migrations ?? []) {
|
|
465
|
+
await migration.up(sql);
|
|
466
|
+
await migration.up(sql); // Idempotent: the host runs it on every start.
|
|
467
|
+
}
|
|
468
|
+
const harness = await testPlugin(visitCounter, { database: sql });
|
|
469
|
+
expect(await harness.runTool("visit_count", { place: "lab" })).toBe(
|
|
470
|
+
"Visit 1 to lab.",
|
|
471
|
+
);
|
|
472
|
+
expect(await harness.runTool("visit_count", { place: "lab" })).toBe(
|
|
473
|
+
"Visit 2 to lab.",
|
|
474
|
+
);
|
|
475
|
+
await harness.stop();
|
|
476
|
+
} finally {
|
|
477
|
+
await sql`DROP TABLE IF EXISTS visit_counter`;
|
|
478
|
+
await sql.close();
|
|
479
|
+
}
|
|
480
|
+
},
|
|
481
|
+
);
|
|
482
|
+
```
|
|
483
|
+
<!-- /example -->
|
|
484
|
+
|
|
485
|
+
### `providers`: replace a part the core runs on
|
|
486
|
+
|
|
487
|
+
`providers` also sits on the plugin.
|
|
488
|
+
There are two slots, and one plugin may fill each.
|
|
489
|
+
|
|
490
|
+
| Slot | The core's default | Your replacement |
|
|
491
|
+
|---|---|---|
|
|
492
|
+
| `judge` | Asks the configured model small questions: does this reply approve the held actions, how hard is this turn, which agents does this group message concern | An object with `askYesNo`, `askChoice`, and `askScore` |
|
|
493
|
+
| `images` | No drawing; agents keep the neutral avatar | `async (prompt, references) => bytes` returning PNG bytes |
|
|
494
|
+
|
|
495
|
+
<!-- example: examples/providers.ts -->
|
|
496
|
+
```ts
|
|
497
|
+
import { definePlugin } from "pi-roundtable";
|
|
498
|
+
|
|
499
|
+
// A one-pixel PNG standing in for a real image service.
|
|
500
|
+
const PIXEL = Buffer.from(
|
|
501
|
+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
|
|
502
|
+
"base64",
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
/** A provider fills a slot the core otherwise runs on its default; one plugin may fill each slot. */
|
|
506
|
+
export const pixelAvatars = definePlugin({
|
|
507
|
+
name: "pixel-avatars",
|
|
508
|
+
providers: {
|
|
509
|
+
// The images slot draws an agent's avatar from a prompt and reference pictures.
|
|
510
|
+
images: async (_prompt, _references) => new Uint8Array(PIXEL),
|
|
511
|
+
},
|
|
512
|
+
setup: () => ({}),
|
|
513
|
+
});
|
|
514
|
+
```
|
|
515
|
+
<!-- /example -->
|
|
516
|
+
|
|
517
|
+
### `preflight`: refuse to start with a bad setting
|
|
518
|
+
|
|
519
|
+
`preflight` also sits on the plugin.
|
|
520
|
+
It runs once every plugin is set up and linked, before any command is registered or any service starts.
|
|
521
|
+
A throw stops the boot, so a missing setting is caught before Discord connects.
|
|
522
|
+
|
|
523
|
+
<!-- example: examples/preflight.ts -->
|
|
524
|
+
```ts
|
|
525
|
+
import { definePlugin } from "pi-roundtable";
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* A preflight runs once every plugin is set up and linked, before any command is registered or
|
|
529
|
+
* service starts. A throw stops the boot, so a bad setting is caught before Discord connects.
|
|
530
|
+
*/
|
|
531
|
+
export function needsKey(
|
|
532
|
+
name: string,
|
|
533
|
+
env: Record<string, string | undefined>,
|
|
534
|
+
) {
|
|
535
|
+
return definePlugin({
|
|
536
|
+
name: "needs-key",
|
|
537
|
+
preflight: () => {
|
|
538
|
+
if (!env[name]?.trim())
|
|
539
|
+
throw new Error(`${name} is empty. Set it in .env.`);
|
|
540
|
+
},
|
|
541
|
+
setup: () => ({ dashboard: [`Uses ${name}`] }),
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
```
|
|
545
|
+
<!-- /example -->
|
|
546
|
+
|
|
547
|
+
### `interactions`: slash commands
|
|
548
|
+
|
|
549
|
+
A subcommand goes under the one root command, `/roundtable` unless `discord.rootCommand` says otherwise.
|
|
550
|
+
The `module` answers the interactions Discord sends and returns `true` for the ones it handled; `commands()` returns top-level commands of its own, and never the root.
|
|
551
|
+
|
|
552
|
+
<!-- example: examples/interactions.ts -->
|
|
553
|
+
```ts
|
|
554
|
+
import { definePlugin } from "pi-roundtable";
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Interactions add slash commands. A subcommand goes under the one root command (`/roundtable`
|
|
558
|
+
* by default); the module answers the interactions Discord sends and returns true for the ones it handled.
|
|
559
|
+
*/
|
|
560
|
+
export const ping = definePlugin({
|
|
561
|
+
name: "ping",
|
|
562
|
+
setup: () => ({
|
|
563
|
+
interactions: [
|
|
564
|
+
{
|
|
565
|
+
rootOptions: [
|
|
566
|
+
{ type: 1, name: "ping", description: "Check that the bot answers" },
|
|
567
|
+
],
|
|
568
|
+
module: {
|
|
569
|
+
commands: () => [],
|
|
570
|
+
handle: async (interaction) => {
|
|
571
|
+
if (!interaction.isChatInputCommand()) return false;
|
|
572
|
+
if (interaction.options.getSubcommand(false) !== "ping")
|
|
573
|
+
return false;
|
|
574
|
+
await interaction.reply("pong");
|
|
575
|
+
return true;
|
|
576
|
+
},
|
|
577
|
+
},
|
|
578
|
+
},
|
|
579
|
+
],
|
|
580
|
+
}),
|
|
581
|
+
});
|
|
582
|
+
```
|
|
583
|
+
<!-- /example -->
|
|
584
|
+
|
|
585
|
+
### `http`: routes on the bot's listener
|
|
586
|
+
|
|
587
|
+
The configuration's `http` block opens one listener, named `public`, and `http.publicUrl` is the address that reaches it from the internet; the agents' avatars are served from it.
|
|
588
|
+
A route names the listener, a path (`{ exact }` or `{ prefix }`), optionally the methods, and a handler that gets a `Request` and returns a `Response`.
|
|
589
|
+
Two routes that could take the same request are refused, so a route cannot shadow the avatars.
|
|
590
|
+
Anything on this listener is reachable from the internet: check a secret in the handler before doing anything.
|
|
591
|
+
|
|
592
|
+
<!-- example: examples/http.ts -->
|
|
593
|
+
```ts
|
|
594
|
+
import { definePlugin } from "pi-roundtable";
|
|
595
|
+
|
|
596
|
+
/** A route answers requests on a listener the host runs; "public" is the one the configuration's `http` block opens. */
|
|
597
|
+
export const health = definePlugin({
|
|
598
|
+
name: "health",
|
|
599
|
+
setup: () => ({
|
|
600
|
+
http: [
|
|
601
|
+
{
|
|
602
|
+
name: "health-check",
|
|
603
|
+
listener: "public",
|
|
604
|
+
path: { exact: "/healthz" },
|
|
605
|
+
methods: ["GET"],
|
|
606
|
+
handle: () => new Response("ok"),
|
|
607
|
+
},
|
|
608
|
+
],
|
|
609
|
+
}),
|
|
610
|
+
});
|
|
611
|
+
```
|
|
612
|
+
<!-- /example -->
|
|
613
|
+
|
|
614
|
+
### `dashboard`: lines on the dashboard message
|
|
615
|
+
|
|
616
|
+
The agent server keeps a dashboard message pinned in Discord that shows the team's state.
|
|
617
|
+
Each string here is added under its title, in plugin order.
|
|
618
|
+
|
|
619
|
+
<!-- example: examples/dashboard.ts -->
|
|
620
|
+
```ts
|
|
621
|
+
import { definePlugin } from "pi-roundtable";
|
|
622
|
+
|
|
623
|
+
/** Dashboard lines are shown under the title of the agent server's dashboard message, in plugin order. */
|
|
624
|
+
export const links = definePlugin({
|
|
625
|
+
name: "links",
|
|
626
|
+
setup: () => ({
|
|
627
|
+
dashboard: ["Docs: https://example.com/docs"],
|
|
628
|
+
}),
|
|
629
|
+
});
|
|
630
|
+
```
|
|
631
|
+
<!-- /example -->
|
|
632
|
+
|
|
633
|
+
### `agentSelection`: tools every agent carries
|
|
634
|
+
|
|
635
|
+
It is a function, read before each turn, so a set that changes while the process runs stays current.
|
|
636
|
+
It names tools and tool groups; a tool still has to pass the speaker's tier.
|
|
637
|
+
|
|
638
|
+
<!-- example: examples/selection.ts -->
|
|
639
|
+
```ts
|
|
640
|
+
import { definePlugin } from "pi-roundtable";
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* The selection names tools every agent turn carries besides its own, read before each turn so
|
|
644
|
+
* a set that changes while the process runs stays current.
|
|
645
|
+
*/
|
|
646
|
+
export function alwaysOn(tools: () => string[]) {
|
|
647
|
+
return definePlugin({
|
|
648
|
+
name: "always-on",
|
|
649
|
+
setup: () => ({
|
|
650
|
+
agentSelection: () => ({ tools: tools(), groups: [] }),
|
|
651
|
+
}),
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
```
|
|
655
|
+
<!-- /example -->
|
|
656
|
+
|
|
657
|
+
### `piPackages`: Pi extensions every session loads
|
|
658
|
+
|
|
659
|
+
Names of npm packages, installed in your project (`bun add pi-web-access`), whose Pi extensions every conversation session loads.
|
|
660
|
+
Two plugins that name the same package load it once.
|
|
661
|
+
|
|
662
|
+
<!-- example: examples/packages.ts -->
|
|
663
|
+
```ts
|
|
664
|
+
import { definePlugin } from "pi-roundtable";
|
|
665
|
+
|
|
666
|
+
/** Pi packages are npm packages whose Pi extensions every session loads; install each one in your project first. */
|
|
667
|
+
export const webSearch = definePlugin({
|
|
668
|
+
name: "web-search",
|
|
669
|
+
setup: () => ({
|
|
670
|
+
piPackages: ["pi-web-access"],
|
|
671
|
+
}),
|
|
672
|
+
});
|
|
673
|
+
```
|
|
674
|
+
<!-- /example -->
|
|
675
|
+
|
|
676
|
+
### `sessionTools`: the raw form of `tools`
|
|
677
|
+
|
|
678
|
+
A session tool is a Pi extension placed in every conversation session by its phase: `tools`, `compaction`, or `mcp`.
|
|
679
|
+
Reach for it only when `defineTool` cannot express what you need, such as a tool whose set changes while the process runs (bump `revision`) or a tool that depends on the session.
|
|
680
|
+
The extension's name must be unique, and a plugin may not take the name of a core extension (`read-attachment`, `confirmation-gate`, `ask-user`, `self-compact-guard`, `profile-tools`).
|
|
681
|
+
At most one plugin may add a `compaction` extension, and it must name the `engine` its compactions record.
|
|
682
|
+
|
|
683
|
+
<!-- example: examples/session-tools.ts -->
|
|
684
|
+
```ts
|
|
685
|
+
import { definePlugin } from "pi-roundtable";
|
|
686
|
+
import { Type } from "typebox";
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* A session tool is a Pi extension added to every conversation session: the raw form of `tools`,
|
|
690
|
+
* for what defineTool cannot express. The factory returns null for a session it does not apply
|
|
691
|
+
* to, and a new `revision` rebuilds open sessions on their next turn.
|
|
692
|
+
*/
|
|
693
|
+
export const clock = definePlugin({
|
|
694
|
+
name: "clock",
|
|
695
|
+
setup: () => ({
|
|
696
|
+
sessionTools: [
|
|
697
|
+
{
|
|
698
|
+
name: "clock",
|
|
699
|
+
phase: "tools",
|
|
700
|
+
snapshot: () => ({
|
|
701
|
+
revision: 0,
|
|
702
|
+
factory: () => (pi) => {
|
|
703
|
+
pi.registerTool({
|
|
704
|
+
name: "clock_now",
|
|
705
|
+
label: "clock_now",
|
|
706
|
+
description: "Tell the current UTC time.",
|
|
707
|
+
parameters: Type.Object({}),
|
|
708
|
+
execute: async () => ({
|
|
709
|
+
content: [{ type: "text", text: new Date().toISOString() }],
|
|
710
|
+
details: undefined,
|
|
711
|
+
}),
|
|
712
|
+
});
|
|
713
|
+
},
|
|
714
|
+
}),
|
|
715
|
+
},
|
|
716
|
+
],
|
|
717
|
+
}),
|
|
718
|
+
});
|
|
719
|
+
```
|
|
720
|
+
<!-- /example -->
|
|
721
|
+
|
|
722
|
+
### `channels`: conversations of your own
|
|
723
|
+
|
|
724
|
+
A claim makes the plugin the owner of the conversations in some channels.
|
|
725
|
+
The router asks claims by descending `priority`, then plugin order; the first that owns a channel decides everything there, and a message its `admit` returns nothing for is dropped.
|
|
726
|
+
Most plugins never need one: the built-in agent server already owns the agents' channels.
|
|
727
|
+
|
|
728
|
+
<!-- example: examples/channels.ts -->
|
|
729
|
+
```ts
|
|
730
|
+
import { definePlugin } from "pi-roundtable";
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* A channel claim makes a plugin the owner of the conversations in some channels. The router
|
|
734
|
+
* asks claims by descending priority; the first that owns a channel decides everything there,
|
|
735
|
+
* and a message its `admit` returns nothing for is dropped.
|
|
736
|
+
*/
|
|
737
|
+
export const echo = definePlugin({
|
|
738
|
+
name: "echo",
|
|
739
|
+
setup: () => ({
|
|
740
|
+
channels: [
|
|
741
|
+
{
|
|
742
|
+
name: "echo-channels",
|
|
743
|
+
priority: 10,
|
|
744
|
+
owns: (channel) => channel.startsWith("echo:"),
|
|
745
|
+
admit: (message) => ({
|
|
746
|
+
kind: "turn",
|
|
747
|
+
run: async () => {
|
|
748
|
+
console.log(`echo: ${message.text}`);
|
|
749
|
+
},
|
|
750
|
+
failure: "an echo turn failed",
|
|
751
|
+
}),
|
|
752
|
+
startFresh: async () => "The echo channel has nothing to start over.",
|
|
753
|
+
},
|
|
754
|
+
],
|
|
755
|
+
}),
|
|
756
|
+
});
|
|
757
|
+
```
|
|
758
|
+
<!-- /example -->
|
|
759
|
+
|
|
760
|
+
### What plugins do not extend
|
|
761
|
+
|
|
762
|
+
`useCommands`, `agentServer`, and `stopTurn` are hooks on the plugin that the built-in plugins use to receive the composed commands, start the agent server, and stop a running turn.
|
|
763
|
+
Only one plugin may start the agent server, and the built-in `agent-server` already does; a plugin that tries is refused.
|
|
764
|
+
|
|
765
|
+
## Testing a plugin
|
|
766
|
+
|
|
767
|
+
`testPlugin(plugin, options?)` sets one plugin up against a fake context and starts its services, with no Discord and no PostgreSQL unless you pass `{ database }`.
|
|
768
|
+
It returns:
|
|
769
|
+
|
|
770
|
+
| Field | What it is |
|
|
771
|
+
|---|---|
|
|
772
|
+
| `contribution` | What the plugin added, as the host would collect it: `tools`, `prompt`, `seeds`, `events`, `services`, `interactions`, `http`, and the rest |
|
|
773
|
+
| `tools`, `tiers` | The tool names, and the table that says what tier each needs |
|
|
774
|
+
| `runTool(name, args, { speaker }?)` | Runs a tool the way an agent's turn would, and returns the text the model reads |
|
|
775
|
+
| `events` | The events the plugin itself reported through `context.events` |
|
|
776
|
+
| `stop()` | Delivers `shutdown` and stops the services in reverse |
|
|
777
|
+
|
|
778
|
+
The harness applies the same checks as the host (a plugin that adds nothing, an unknown part, a clash of names, a tool with no tier), so a mistake fails your test with the message the start would print.
|
|
779
|
+
It does not run migrations (see [`migrations`](#migrations-and-contextdatabase-tables-of-your-own) for a test that does), it does not deliver the core's events (call the handlers yourself, as [`events`](#events-hear-what-the-core-does) shows), and it does not build a prompt (call `contribution.prompt`'s `build` yourself, as [`prompt`](#prompt-text-added-to-every-agent-turn) shows), and `sessions()` and `conversations` throw `NotLinkedError` in it, as they do in `setup`.
|
|
780
|
+
|
|
781
|
+
A test for the tools example:
|
|
782
|
+
|
|
783
|
+
<!-- example: examples/tools.test.ts -->
|
|
784
|
+
```ts
|
|
785
|
+
import { expect, test } from "bun:test";
|
|
786
|
+
import type { Speaker } from "pi-roundtable";
|
|
787
|
+
import { testPlugin } from "pi-roundtable/testing";
|
|
788
|
+
import { notes } from "./tools.ts";
|
|
789
|
+
|
|
790
|
+
test("note_add saves a note for the speaker and refuses an empty one", async () => {
|
|
791
|
+
const harness = await testPlugin(notes);
|
|
792
|
+
const ada: Speaker = { id: "1", name: "Ada", tier: "member" };
|
|
793
|
+
expect(harness.tools).toEqual(["note_add"]);
|
|
794
|
+
expect(harness.tiers.minTier("note_add")).toBe("member");
|
|
795
|
+
expect(
|
|
796
|
+
await harness.runTool("note_add", { text: "milk" }, { speaker: ada }),
|
|
797
|
+
).toBe("Saved note 1 for Ada.");
|
|
798
|
+
expect(await harness.runTool("note_add", { text: " " })).toBe(
|
|
799
|
+
"The note is empty. Ask what to save.",
|
|
800
|
+
);
|
|
801
|
+
await harness.stop();
|
|
802
|
+
});
|
|
803
|
+
```
|
|
804
|
+
<!-- /example -->
|
|
805
|
+
|
|
806
|
+
Run every test with `bun test`, and the types with `bun run typecheck`.
|
|
807
|
+
|
|
808
|
+
## What happens when the bot starts and stops
|
|
809
|
+
|
|
810
|
+
`roundtable start` first runs the checks that need no network (Bun, `.env`, the configuration, the plugins, the model login, the public URL), and stops with the message `roundtable doctor` prints for a failed one.
|
|
811
|
+
Then the host runs `run()`:
|
|
812
|
+
|
|
813
|
+
1. Providers are resolved: each slot from the plugin that fills it, or the core's default.
|
|
814
|
+
2. The database is opened and every plugin's migrations run, in plugin order.
|
|
815
|
+
3. Every plugin's `setup` runs, in plugin order.
|
|
816
|
+
The order is the built-ins (`stores`, `discord`, `modules`, `agent-server`, `seeds`), then yours in the order of `plugins` in `roundtable.config.ts`, then the built-in `schedules`, so a due schedule fires only once everything it can reach is running.
|
|
817
|
+
4. The contributions are linked: tool tiers, hold rules, the session plan, the channel router, and the events.
|
|
818
|
+
From here `sessions()`, `conversations`, and `dashboard()` work.
|
|
819
|
+
5. Every plugin's `preflight` runs, in plugin order.
|
|
820
|
+
6. The composed slash commands are handed to the plugins that take them.
|
|
821
|
+
7. Every service starts: the plugins' in plugin order, and each plugin's own in the order it listed them.
|
|
822
|
+
8. The HTTP listener opens.
|
|
823
|
+
9. The agent server starts in the background, and then every plugin hears `agentServer("ready")` or `agentServer("failed")`.
|
|
824
|
+
|
|
825
|
+
Nothing reaches Discord or the listener unless steps 1 to 5 succeeded.
|
|
826
|
+
|
|
827
|
+
On `SIGTERM` or `SIGINT` the bot stops serving new work last:
|
|
828
|
+
|
|
829
|
+
1. It keeps serving until no service reports `busy()` work, for at most an hour; whatever is left is logged and given up on.
|
|
830
|
+
2. Every plugin hears `shutdown(left)`, while every service is still running.
|
|
831
|
+
3. The HTTP listener closes, so no request reaches a service that has stopped.
|
|
832
|
+
4. Services stop in the reverse of the order they started.
|
|
833
|
+
5. The database pool closes, and the process exits.
|
|
834
|
+
|
|
835
|
+
## Errors and their fixes
|
|
836
|
+
|
|
837
|
+
A mistake in a plugin or in the configuration stops the start, before Discord connects, with a message of the form `plugin <name>: <what>. <fix>.`
|
|
838
|
+
`roundtable doctor` prints the same messages.
|
|
839
|
+
These are the messages as the code writes them, with `<...>` where your names go.
|
|
840
|
+
|
|
841
|
+
### A plugin's shape
|
|
842
|
+
|
|
843
|
+
| Message | Fix |
|
|
844
|
+
|---|---|
|
|
845
|
+
| `plugin "<name>": the name must be lowercase words joined by dashes, such as my-notes. Rename the plugin.` | Rename it in `definePlugin` |
|
|
846
|
+
| `plugin <name>: setup is missing. Give the function that returns what the plugin adds.` | Add `setup` |
|
|
847
|
+
| `plugin <name> adds nothing. Give it a part (tools, services, channels, and so on), a migration, or a provider, or remove it.` | Return a part from `setup`, or remove the plugin from `roundtable.config.ts` |
|
|
848
|
+
| `plugin <name>: setup must return an object of the parts it adds; return {} to add none.` | Return an object, not `undefined` |
|
|
849
|
+
| `plugin <name>: setup returned an unknown part "<key>". Did you mean "<closest>"? The parts are services, events, interactions, http, holdRules, piPackages, sessionTools, channels, dashboard, tools, seeds, prompt, agentSelection.` | Fix the key; `migrations`, `providers`, and `preflight` belong on the plugin, not in what `setup` returns |
|
|
850
|
+
|
|
851
|
+
### Tools
|
|
852
|
+
|
|
853
|
+
| Message | Fix |
|
|
854
|
+
|---|---|
|
|
855
|
+
| `tool "<name>": the name must be lowercase words joined by underscores, such as note_add. Rename the tool.` | Rename it |
|
|
856
|
+
| `tool <name>: the agents already have a tool of this name. Rename the tool.` | Do not use `bash`, `read`, `edit`, or `write` |
|
|
857
|
+
| `tool <name>: minTier must be one of member, admin, owner; got <value>. Set the lowest tier that may use it.` | Give `minTier`; leaving it out is also a type error in TypeScript |
|
|
858
|
+
| `tool <name>: the description is empty. Tell the model when to call the tool.` | Write the description; the model reads it to decide when to call the tool |
|
|
859
|
+
| `tool <name>: run is missing. Give the function the tool runs.` | Add `run` |
|
|
860
|
+
|
|
861
|
+
### Clashes
|
|
862
|
+
|
|
863
|
+
| Message | Fix |
|
|
864
|
+
|---|---|
|
|
865
|
+
| `plugin <b>: tool <name> is already defined by plugin <a>. Rename one of the two tools.` | Rename one tool |
|
|
866
|
+
| `plugin <b>: service <name> is already registered by plugin <a>. Rename one of the two.` | Rename one service |
|
|
867
|
+
| `plugin <b>: hold rule <name> is already registered by plugin <a>. Rename one of the two.` | Rename one rule |
|
|
868
|
+
| `plugin <b>: provider slot <slot> is already filled by plugin <a>. Keep one plugin that fills it.` | Fill each slot from one plugin only |
|
|
869
|
+
| `plugins <a> and <b> both start the agent server. Keep one.` | Do not define `agentServer` on your plugin; the built-in one starts it |
|
|
870
|
+
| `session tool <name> takes a core extension name. Rename it.` | Pick a name other than the core's |
|
|
871
|
+
| `two plugins are named <name>.` (from `roundtable doctor`) | Rename yours; the built-in plugins are `stores`, `discord`, `modules`, `agent-server`, `seeds`, and `schedules` |
|
|
872
|
+
| `migration <name> is declared twice` | Migration names are shared by every plugin: prefix each with its plugin's name |
|
|
873
|
+
| `/<root> <name> is added twice`, `/<name> is registered twice` | Give each slash command and subcommand its own name |
|
|
874
|
+
| `route <name> is registered twice`, `routes <a> and <b> overlap on listener <id>` | Give each route its own name and a path no other route can take |
|
|
875
|
+
|
|
876
|
+
### Things used before they are ready
|
|
877
|
+
|
|
878
|
+
`sessions()`, `conversations`, and `dashboard()` are linked after every plugin is set up.
|
|
879
|
+
Calling one from `setup` fails, and the message says when it becomes ready:
|
|
880
|
+
|
|
881
|
+
```text
|
|
882
|
+
plugin <name>: setup failed: session parts are linked once every plugin is set up. Call sessions() from a service's start or from a handler, not during setup. Fix the error, or remove the plugin.
|
|
883
|
+
```
|
|
884
|
+
|
|
885
|
+
The same message exists for `conversations` (`Use them from a service's start or from a handler, not during setup.`) and for `dashboard()`.
|
|
886
|
+
The fix is the one it says: move the call into a service's `start` or into an event handler.
|
|
887
|
+
|
|
888
|
+
`context.core.<service>` before the built-in plugin that provides it has run throws `core service <name> is not provided yet. Register the built-in plugin that provides it before the plugin that reads it.`
|
|
889
|
+
Your plugins always run after the built-ins, so this shows only in `testPlugin`, which has none: pass what you need or test that part elsewhere.
|
|
890
|
+
|
|
891
|
+
### A setup or a migration that throws
|
|
892
|
+
|
|
893
|
+
```text
|
|
894
|
+
plugin <name>: setup failed: <what the error said>. Fix the error, or remove the plugin.
|
|
895
|
+
plugin <name>: migration <migration> failed: <what the database said>. Fix the migration or restore the database, then start again.
|
|
896
|
+
```
|
|
897
|
+
|
|
898
|
+
A failed migration stops the start before any plugin is set up.
|
|
899
|
+
Migrations are idempotent, so start again once the cause is fixed.
|
|
900
|
+
|
|
901
|
+
### Configuration
|
|
902
|
+
|
|
903
|
+
| Message | Fix |
|
|
904
|
+
|---|---|
|
|
905
|
+
| `config <key>: unknown key. Did you mean "<closest>"? The keys here are ...` | Fix the spelling |
|
|
906
|
+
| `config <key>: required, expected <kind>. Add it to roundtable.config.ts.` | Add the setting, or fill in the variable in `.env` that it reads |
|
|
907
|
+
| `config model: expected <provider>/<id>, got "<value>". Write it like anthropic/claude-sonnet-5-5.` | Write the model as `<provider>/<id>` |
|
|
908
|
+
| `config locale: expected a locale, en or zh-TW, got "<value>". Fix the value in roundtable.config.ts.` | Use `en` or `zh-TW` |
|
|
909
|
+
| `config <key>: cannot read <path>: <reason>. Create the file or fix the path.` | Create the prompt file, or fix the path in `prompts` |
|
|
910
|
+
|
|
911
|
+
### Missing settings
|
|
912
|
+
|
|
913
|
+
| Message | Fix |
|
|
914
|
+
|---|---|
|
|
915
|
+
| `interactions need a configured root command` | Only reachable if you build the host yourself; `defineRoundtable` always sets it |
|
|
916
|
+
| `migrations need a configured database` | Only reachable if you build the host yourself; `defineRoundtable` always sets it |
|
|
917
|
+
| `no database is configured` | In `testPlugin`, pass `{ database }` to a plugin that calls `context.database()` |
|
|
918
|
+
| `route <name> needs listener <id>, which is not configured` | Use the listener `public` |
|
|
919
|
+
|
|
920
|
+
## Changing the bot's language
|
|
921
|
+
|
|
922
|
+
The text the bot shows in Discord comes from a message catalog chosen by `locale` in `roundtable.config.ts`: `en` (the default) or `zh-TW`.
|
|
923
|
+
Both catalogs have the same keys.
|
|
924
|
+
Your own plugins' text is yours to write in any language.
|