pi-roundtable 0.16.0 → 0.17.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 (37) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +5 -0
  3. package/docs/plugins.md +68 -0
  4. package/package.json +1 -1
  5. package/src/cli/checks/configuration.ts +21 -1
  6. package/src/cli/project.ts +59 -8
  7. package/src/cli/runtime.ts +3 -0
  8. package/src/core/agents/agent-store.ts +4 -1
  9. package/src/core/builtin/discord.ts +5 -0
  10. package/src/core/config/adapters.ts +12 -0
  11. package/src/core/contract/runtime.ts +7 -0
  12. package/src/core/contract/surface.ts +7 -0
  13. package/src/core/db/check-database.ts +39 -0
  14. package/src/core/define-roundtable.ts +3 -0
  15. package/src/core/discord/discord-surface.ts +5 -0
  16. package/src/core/discord/formatting.ts +20 -0
  17. package/src/core/host-registration.ts +292 -0
  18. package/src/core/host.ts +45 -227
  19. package/src/core/identity/config-owner.ts +34 -0
  20. package/src/core/identity/identity-plugin.ts +7 -2
  21. package/src/core/identity/identity-service.ts +8 -1
  22. package/src/core/identity/plugin-identities.ts +19 -4
  23. package/src/core/modules/skills/skill-store.ts +3 -1
  24. package/src/core/runtime/extensions/surface-formatting.ts +22 -0
  25. package/src/core/runtime/pi-agent-runtime.ts +6 -8
  26. package/src/core/runtime/running-turn.ts +19 -0
  27. package/src/core/runtime/runtime-plugin.ts +4 -0
  28. package/src/core/runtime/runtime-types.ts +12 -1
  29. package/src/core/runtime/session-factory.ts +7 -0
  30. package/src/core/testing/database.ts +20 -2
  31. package/src/core/testing/postgres.ts +160 -0
  32. package/src/core/testing/run-tool.ts +110 -0
  33. package/src/core/testing/test-host.ts +78 -30
  34. package/src/index.ts +1 -0
  35. package/src/testing.ts +25 -56
  36. package/templates/official/codex-images/plugin.test.ts.tmpl +29 -1
  37. package/templates/official/codex-images/plugin.ts +19 -2
package/CHANGELOG.md CHANGED
@@ -5,6 +5,24 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.17.0] - 2026-10-16
9
+
10
+ ### Added
11
+
12
+ - `Roundtable.check()` and `RegistrationReport` (`pi-roundtable`): sets up every plugin as `run()` would and refuses what `run()` refuses (a plugin that adds nothing, a clash, a missing service, a removed field, a bad route), with the same messages, without migrating the database, starting a service, opening a listener or reaching Discord. It resolves the plugins that run, the services they add in start order and their routes. A plugin whose `setup` queries the database fails the check with `plugin <name>: setup failed: the database was queried during setup…`, which says to query from a service's `start`; the built-in identity sync and the skills and agent stores skip their load under it. It runs no preflight and does not count as a running host. It runs each plugin's `setup`, so check a definition you do not run: the built-in agent server binds its sessions once, which makes the plugin objects of a checked definition impossible to run or check again, and `roundtable doctor` and `start` therefore check a definition assembled again, with the configuration file evaluated again (Bun runs it again under a new query), so the plugins it makes with a factory are new objects. The file's top-level code therefore runs twice under `doctor` and `start`; keep side effects out of it. When the file was not evaluated again, because the runtime did not run it or because its default export is built in a module it imports, the check fails and says so; build the definition in `roundtable.config.ts` itself. A plugin object that a module the file imports exports is shared, and so is state kept at module level, so a plugin's `setup` must keep nothing one-shot outside its call. As `run()` does, it sets the process's locale, time zone and, when `environment.agentDir` is set, `PI_CODING_AGENT_DIR`. The identity sync settles under it only what the configuration and the declared identities settle without a stored principal. The registration moved out of `src/core/host.ts` into one shared module, and `run()` behaves as before.
13
+ - `requireDatabase`, `usePostgresForTests` and `UsePostgresOptions` (`pi-roundtable/testing`). `requireDatabase()` gives the test database's URL and throws, naming how to provide one, when the run has none. `usePostgresForTests({ binDirs?, require? })` is for a test preload: unless `ROUNDTABLE_TEST_DATABASE_URL` is set, it starts a throwaway PostgreSQL on `127.0.0.1` (trust authentication, `fsync` off, a temporary data directory), sets the variable and `testDatabaseUrl`, and stops and removes it after the run; with `require: true` it throws when `initdb` or `postgres` is not found.
14
+ - `TestHost.runTool(name, args, options?)` and `TestHost.hold(name, args, options?)` (`pi-roundtable/testing`): run a contributed tool in the owner's session as a speaker on a channel and read the text the model would read (a refusal comes back as its text, as with `testPlugin`'s `runTool`; any other error throws), and ask the tool's hold what it would show for the call. `testPlugin`'s `runTool` shares the code that runs the tool.
15
+ - The `codex-images` template takes `onMissingLogin: "throw" | "warn"` in `createCodexImages`. The default `"throw"` keeps today's behavior, where a host without an `openai-codex` login refuses to start; `"warn"` logs one warning at setup and lets the host start, and a drawing then fails with the same `PluginError`.
16
+ - Per-surface formatting guidance. A chat surface tells the model how its replies render, and the runtime appends that text to the system prompt of every turn that replies on the surface, after the persona and everything appended before it; an agent answered on Discord with Markdown tables, which Discord shows as raw `|` pipes. The new optional `ChatSurface.formatting` is a short English text, and the surface is found from the channel the turn replies in, so one persona can answer on several surfaces with each one's rules. A turn whose channel has no surface, and a surface without `formatting`, add nothing. The built-in values: Discord says Markdown renders except tables (use bullet lists or `key: value` lines), and that a reply over 2,000 characters is split; `pi-roundtable-line` says LINE shows plain text only; `pi-roundtable-webchat` says full Markdown renders, tables included. A host overrides or removes a value through that surface's options: `discord.formatting` in the configuration, `formatting` in `line({ ... })` and `webChat({ ... })`; a text replaces it and `false` tells the model nothing, and an empty text is refused. `RuntimeDeps.formatting(channel)` gives a custom runtime provider the same lookup, and the runtime of `pi-roundtable/testing` is handed it too. `pi-roundtable-web` has no reply surface and is unchanged.
17
+
18
+ ### Changed
19
+
20
+ - `testPlugin` runs the plugin's `preflight` after it is set up and before its services start, as the host does, and a `preflight` that throws fails `testPlugin` with that error. A plugin that does its start-up reading there (such as `pi-roundtable-mcp`'s) works under `testPlugin` as it does on a host.
21
+ - **Breaking for plugins that read `CONNECTORS` while they set up (`pi-roundtable-mcp`):** `mcpConnectors` and `remoteMcp` set up without querying the database or reaching the gateway, so `Roundtable.check()` passes with them; they do that work in their `preflight`, before any service starts. `CONNECTORS.list()`, `servers()` and `profileSources()` are empty during `setup`, and `version` changes once they are loaded; read them from a service's `start` or later (see the package changelog).
22
+ - `roundtable doctor` and `roundtable start` run `Roundtable.check()`, on a definition assembled again for the purpose, in the offline `plugins` check, after the duplicate-name check, and print the message `run()` would fail with. A configuration with a plugin that adds nothing, or any other registration clash, now fails before `start` migrates the database or contacts Discord.
23
+ - `testDatabaseUrl` and `describeDb` (`pi-roundtable/testing`) are live exports (`declare let`): `usePostgresForTests()` updates them after the module has loaded. Reading them is unchanged.
24
+ - Every package's core peer range moves to `>=0.17.0 <0.18.0`.
25
+
8
26
  ## [0.16.0] - 2026-10-16
9
27
 
10
28
  ### Changed
package/README.md CHANGED
@@ -196,6 +196,11 @@ When a conversation is started over in a server channel or thread, the assistant
196
196
  | text | `─── new conversation ───` | The divider's text |
197
197
  | `false` | | Post no divider |
198
198
 
199
+ | `discord.formatting` | Default | What it is |
200
+ | --- | --- | --- |
201
+ | text | Markdown renders except tables | What the model is told, in English, about how a reply renders in Discord; appended to the system prompt of every turn that replies there |
202
+ | `false` | | Tell the model nothing |
203
+
199
204
  An approval card or an `ask_user` question holds its turn for `discord.cardGraceSeconds` (default `120`).
200
205
  The files of a message in an agent's channel download within `discord.attachmentDownload.deadlineMs` (default 60,000) for the message and the one it replies to together, and a file that sends no byte for `discord.attachmentDownload.idleMs` (default 10,000) is given up; the turn goes on without a file it could not get.
201
206
  The copy button of an approval card keeps its text in `card-copies.json` under the data directory, the newest 100 for 30 days; `discord.cardCopies` (`{ keepDays?, keep? }`) changes that, and `false` keeps them in memory only.
package/docs/plugins.md CHANGED
@@ -2348,6 +2348,10 @@ It is not posted in a direct message or on another surface, and a failed post is
2348
2348
  The text is `discord.freshMarker` (default `─── new conversation ───`); `false` posts none.
2349
2349
  A surface a plugin contributes may implement the optional `ChatSurface.markFresh(channel)` for the same job.
2350
2350
 
2351
+ Discord does not render Markdown tables, so the Discord surface tells the model about it: every turn that replies in a Discord channel gets a short guidance text appended to its system prompt, after the persona.
2352
+ The default says that Markdown renders except tables, that bullet lists or `key: value` lines replace them, that headings, bold, lists, quotes, inline code, fenced code blocks and `-#` small text work, and that a reply over 2,000 characters is split.
2353
+ `discord.formatting` replaces the text; `false` tells the model nothing.
2354
+
2351
2355
  The block gives each author as a name, their Discord id and a role (`owner`, `bot` or `member`) in separate attributes, and says the messages were not addressed to the assistant. A name loses the characters `"`, `'`, `=`, `<`, `>`, `(`, `)` and `,`, so it cannot imitate another author, an attribute or a role, and every `<` (also the full-width `<`) in a message's text is replaced by `‹`, so a message can neither close the block nor open a message of its own.
2352
2356
  `ChannelContext` is `{ messages, text, images? }`: `messages` are `ChannelContextMessage`s (`id`, `authorId`, `authorName`, `bot`, `owner`, `text`, `at`, `attachments`), oldest first, `text` is the block, and `images` the fetched images (see above).
2353
2357
  `ChannelContextOptions` holds the options above, and `CHANNEL_CONTEXT_DEFAULTS` their defaults.
@@ -2549,6 +2553,7 @@ The agent server claims only `discord:` keys, so claims on your surface's channe
2549
2553
  | `start(deliver)` | Connects, and calls `deliver(message)` for every incoming message; the host passes its conversation router | (required) |
2550
2554
  | `sendReply(channel, reply)` | Posts an `OutboundReply`: a thinking line, a card, text chunks, then files | (required) |
2551
2555
  | `stop()` | Disconnects when the host stops | nothing to stop |
2556
+ | `formatting` | A short English text on how a reply renders here, such as "no Markdown tables"; the runtime appends it to the system prompt of every turn that replies on this surface, after the persona | nothing is appended |
2552
2557
  | `startTyping(channel)` | Shows a typing indicator until the returned function is called | none is shown |
2553
2558
  | `showStop(channel)` | Shows the owner a stop control until the returned function is called; using it calls `conversations.stop(channel)` | none is shown |
2554
2559
  | `react`, `unreact` | Adds or removes the bot's reaction on a message | no marks on queued or steered messages |
@@ -2556,6 +2561,13 @@ The agent server claims only `discord:` keys, so claims on your surface's channe
2556
2561
  | `interim(channel)` | Where a running turn posts the text it writes before its final answer, as `InterimPosts`: `post(text)` sends one message of at most 2000 characters and resolves to an `InterimMessage` whose `edit(text)` changes it in place | only the final reply is posted |
2557
2562
  | `progress(channel, event)` | Shows a turn run through `context.turns` as it goes, such as a live preview in a web chat. `event` is a `TurnProgress`: `{ type: "text", delta }` (the reply's text, joined over 250 ms and always sent before a tool event, never the thinking), `{ type: "tool_start", id, tool, preview? }` (a one-line preview of the arguments, at most 80 characters, never their full text), or `{ type: "tool_end", id, tool, ok, refused? }` (`refused`, only on a failed end that a hold refused, says how: `declined` on its approval card, `expired` when a card was shown and went unanswered, `pending` while the card stays open, or `held` when no card could be shown (no one may approve it, or it could not be posted); it is absent for a success and for a real failure). The final reply still comes through `sendReply`; a rejection is logged and the turn goes on | only the final reply is shown |
2558
2563
 
2564
+ A surface tells the model how its replies render with `formatting`: a short English text, kept under about 400 characters, which the runtime appends to the system prompt of every turn that replies on the surface, after the persona and everything appended before it, for the channel the turn replies in.
2565
+ One persona can answer on several surfaces, so the guidance comes from the surface and never from the persona.
2566
+ A turn whose channel has no surface, and a surface without `formatting`, add nothing.
2567
+ The built-in values: Discord says that Markdown renders except tables, which show as raw pipes, so the model writes bullet lists or `key: value` lines instead; the `line` surface says it shows plain text only; the web chat says full Markdown renders, tables included.
2568
+ A host overrides or removes a built-in value through that surface's options (`discord.formatting`, `line({ formatting })`, `webChat({ formatting })`): a text replaces it, and `false` tells the model nothing.
2569
+ A custom runtime provider reads the same lookup as `RuntimeDeps.formatting(channel)`.
2570
+
2559
2571
  Each message a surface delivers says who wrote it in `actor`, the `ActorFacts` the identity service resolves: `provider` and `subject`, such as `discord` and the user id, `name`, `roles` written `<surface>:role:<name>` (left out where the surface does not know them, such as a Discord DM, which is not the same as none), and `legacyId`, the speaker id 0.8 gave the person, so someone carried over keeps their principal.
2560
2572
  A surface that leaves `actor` out has it read from `authorId`, `authorName`, and `authorRoleIds` with its prefix as the provider, and the host warns once that this goes away in 1.0.
2561
2573
 
@@ -3233,6 +3245,18 @@ Run every test with `bun test`, and the types with `bun run typecheck`.
3233
3245
  Importing `pi-roundtable/testing` works with `CI=true` and no database URL.
3234
3246
  `describeDb` is Bun's `describe` when `ROUNDTABLE_TEST_DATABASE_URL` is set, and `describe.skip` otherwise; use it to gate a database suite.
3235
3247
  `testDatabaseUrl` is that URL or an empty string, and `TEST_GUILD` is a synthetic guild identifier.
3248
+ Both are live bindings: `describeDb` and `testDatabaseUrl` read after `usePostgresForTests()` has run show the database it started.
3249
+ `requireDatabase()` gives the URL, and throws an error that says how to provide one when the run has none, for a test file that must not be skipped.
3250
+ `usePostgresForTests({ binDirs?, require? })` is for a test preload: unless `ROUNDTABLE_TEST_DATABASE_URL` is already set, it starts a throwaway PostgreSQL on `127.0.0.1` (trust authentication, `fsync` off, a temporary data directory), sets the variable and `testDatabaseUrl`, and stops and removes it after the run.
3251
+ It looks for `initdb` and `postgres` in `binDirs` (default `PG_BIN` and the usual Homebrew and Debian directories) and then on `PATH`; with `require: true` it throws when it finds neither, and without it the tests that need a database skip or call `requireDatabase()`.
3252
+ Three lines are the whole setup: put `[test]` `preload = ["./test-support/postgres.ts"]` in `bunfig.toml`, and in that file:
3253
+
3254
+ ```ts
3255
+ import { usePostgresForTests } from "pi-roundtable/testing";
3256
+
3257
+ await usePostgresForTests({ require: true });
3258
+ ```
3259
+
3236
3260
  `openTestStore(Store, ...args)` runs the store's `migrations(...args)` or its single `migration`, calls `Store.attach`, and returns a `TestStore<T>` whose `close()` closes its own SQL pool.
3237
3261
  Use it with your own store class in a gated database test, and close the store when the test ends.
3238
3262
  It doesn't attach another instance of the host's stores.
@@ -3270,6 +3294,8 @@ It returns:
3270
3294
  | `commands` | `added`, every slash-command contribution the plugins handed Discord in order, and `composed()`, the tree Discord would register |
3271
3295
  | `sessionTools(scope?)` | The tools each extension of a session registers, in plan order (an extension that registers commands, flags, or event handlers besides tools is accepted, and only its tools are listed); the owner's session unless an `AgentTurnScope` is given |
3272
3296
  | `sessionContext(scope?)` | A `SessionContext` as the runtime builds it, with the real compaction wrapper |
3297
+ | `runTool(name, args, { speaker?, channel?, origin? })` | Runs a contributed tool in the owner's session as the speaker on the channel (the owner's channel by default), as `testPlugin`'s `runTool` does, and gives the text the model would read: a tool that throws throws, a refusal (`ToolRefusal`) comes back as its text, and a tool no plugin added throws a `PluginError` |
3298
+ | `hold(name, args, { speaker?, channel? })` | What the tool's hold shows for the call, asked as a turn of that speaker on that channel asks it, or `undefined` when the call runs without an approval |
3273
3299
  | `stop()` | Stops the host; call it when the test ends |
3274
3300
 
3275
3301
  `useEagerCatalog()` sets a catalog that marks every text and uses a foreign time zone.
@@ -3278,6 +3304,43 @@ It returns:
3278
3304
 
3279
3305
  `holdChain(rules)` is in `pi-roundtable/kit`: the chain the host links from every plugin's hold rules, to test a rule set without a harness.
3280
3306
 
3307
+ ### `Roundtable.check()`: prove the registration without a database
3308
+
3309
+ `new Roundtable(options, plugins).check()` sets up every plugin as `run()` would and refuses what `run()` refuses: a plugin that adds nothing, two services of one name, a service no plugin provides, a field the host removed, a route that clashes or names a listener that is not configured.
3310
+ It migrates nothing, starts no service, opens no listener, reaches no Discord and runs no preflight, so it needs neither PostgreSQL nor the network.
3311
+ It runs each plugin's `setup`, so a plugin keeps whatever its setup keeps: the built-in agent server binds its sessions once, so the plugin objects of a definition that was checked cannot be run, or checked again.
3312
+ Check a definition you do not run: call `defineRoundtable` again for the check, as `roundtable doctor` and `roundtable start` do (they also evaluate `roundtable.config.ts` again, so the plugins it makes with a factory are new objects), and run the one you did not check.
3313
+ The configuration file's top-level code therefore runs twice under `doctor` and `start`: keep side effects out of it (opening a port, writing a file, calling a helper that refuses a second call). If the file was not evaluated again, because the runtime did not run it or because its default export is built in a module it imports, the check fails and says so; build the definition in `roundtable.config.ts` itself.
3314
+ What stays shared between the two definitions is a plugin object that a module the configuration imports exports, and state kept at module level: a plugin's `setup` must keep nothing one-shot outside its call, such as a value it binds once and then refuses to bind again, because `doctor` and `start` set the same plugin up twice.
3315
+ As `run()` does, it sets three things of the process: the locale and the time zone from the host's `environment`, and `PI_CODING_AGENT_DIR` when `environment.agentDir` is set.
3316
+ A check of another host while one runs therefore changes them for the running host, and in one `bun test` process they carry into later test files; give the hosts you check the same `environment`.
3317
+ It is no running host: a check never makes `run()` refuse a second host.
3318
+ It resolves a `RegistrationReport`: `plugins` (those that run, in set-up order), `services` (those they contribute, in start order) and `routes` (as `<listener> <path>`).
3319
+
3320
+ A plugin's `setup` sees a database that refuses every query, so setup may take `database()` and hand it to a store but may not query it: query from a service's `start` or from a handler.
3321
+ A plugin that queries during setup fails the check with `plugin <name>: setup failed: the database was queried during setup…`.
3322
+ Work that must be done before any service starts, such as reading the stored rows a command or a turn needs at once, belongs in the plugin's `preflight`, which the host runs after every plugin is set up and `check()` never runs.
3323
+ The built-in plugins that read or write rows when they attach (the identity sync, the skills and the agent stores) skip that load here and start empty.
3324
+ The identity sync still settles what the configuration and the declared identities settle without a stored principal: a plugin identity that names no principal when `access.owners` lists no one to bind it to, and an owner who names their `principal` and lists an identity a plugin binds to another principal.
3325
+ What needs a stored principal or link, such as a plugin bound to a principal that does not exist, is left to the start.
3326
+ Keep resources out of `setup`; open them from a service's `start`, which `check()` never runs.
3327
+
3328
+ Test the production composition with it, since the other tests build their settings by hand:
3329
+
3330
+ ```ts
3331
+ import { expect, test } from "bun:test";
3332
+ import { defineRoundtable, Roundtable } from "pi-roundtable";
3333
+ import { silentLogger } from "pi-roundtable/testing";
3334
+
3335
+ test("the production configuration registers every plugin", async () => {
3336
+ const defined = await defineRoundtable(productionConfig(), { logger: silentLogger() });
3337
+ const report = await new Roundtable(defined.options, defined.plugins).check();
3338
+ expect(report.plugins).toContain("my-plugin");
3339
+ });
3340
+ ```
3341
+
3342
+ `roundtable doctor` and `roundtable start` run it in the offline `plugins` check, on a definition assembled again for the purpose (the configuration file is evaluated again too), so a configuration the host would refuse fails before `start` migrates or contacts Discord.
3343
+
3281
3344
  ### `describeSurfaceContract`: what every chat surface keeps
3282
3345
 
3283
3346
  A plugin that contributes a `ChatSurface` can test it against the contract every surface keeps.
@@ -3384,6 +3447,7 @@ These are the messages as the code writes them, with `<...>` where your names go
3384
3447
  | `no persona is registered for the conversation kind "<kind>". A plugin adds one with personas: [...], or its claim must start conversations of a kind that has one.` (a failed turn) | Contribute a persona of that kind, or run the turn with a kind that has one |
3385
3448
  | `no runtime provider is configured: the runtime plugin builds the Pi runtime when no plugin fills the runtime slot; read the running one from services.get(RUNTIME)` | Only a plugin that calls `providers.runtime` without `providers.filled.has("runtime")` sees it; read `services.get(RUNTIME)` instead |
3386
3449
  | `session tool <name> takes a core extension name. Rename it.` | Pick a name other than the core's |
3450
+ | `plugin <name>: setup failed: the database was queried during setup…` (from `Roundtable.check()`, `roundtable doctor` or `start`) | A plugin's `setup` ran a query. Take `database()` in setup and query from a service's `start` or from a handler |
3387
3451
  | `two plugins are named <name>.` (from `roundtable doctor`) | Rename yours; the built-in plugins are `memory`, `schedule-store`, `prechecks`, `discord`, `modules`, `discord-admin`, `skills`, `conversations`, `runtime`, `agent-server`, `seeds`, and `schedules` |
3388
3452
  | `migration <plugin>/<name> is declared twice` | A plugin declares two migrations with one name: rename one (the same name in two plugins is fine) |
3389
3453
  | `/<root> <name> is added twice`, `/<name> is registered twice` | Give each slash command and subcommand its own name |
@@ -3683,6 +3747,7 @@ Import from the entries listed below; source area files are internal.
3683
3747
  | `REPLY_FILE_LIMITS` | `pi-roundtable` | value |
3684
3748
  | `RoundtableConfig` | `pi-roundtable` | type |
3685
3749
  | `RoundtableOptions` | `pi-roundtable` | type |
3750
+ | `RegistrationReport` | `pi-roundtable` | type |
3686
3751
  | `RoundtablePlugin` | `pi-roundtable` | type |
3687
3752
  | `RouteSocket` | `pi-roundtable` | type |
3688
3753
  | `RuntimeDeps` | `pi-roundtable` | type |
@@ -3791,6 +3856,7 @@ Import from the entries listed below; source area files are internal.
3791
3856
  | `TestPluginOptions` | `pi-roundtable/testing` | type |
3792
3857
  | `TestPluginResult` | `pi-roundtable/testing` | type |
3793
3858
  | `TestStore` | `pi-roundtable/testing` | type |
3859
+ | `UsePostgresOptions` | `pi-roundtable/testing` | type |
3794
3860
  | `describeDb` | `pi-roundtable/testing` | value |
3795
3861
  | `describeSurfaceContract` | `pi-roundtable/testing` | value |
3796
3862
  | `checkSurfaceContract` | `pi-roundtable/testing` | value |
@@ -3810,6 +3876,7 @@ Import from the entries listed below; source area files are internal.
3810
3876
  | `openTestStore` | `pi-roundtable/testing` | value |
3811
3877
  | `servicePair` | `pi-roundtable/testing` | value |
3812
3878
  | `partial` | `pi-roundtable/testing` | value |
3879
+ | `requireDatabase` | `pi-roundtable/testing` | value |
3813
3880
  | `recordingLogger` | `pi-roundtable/testing` | value |
3814
3881
  | `RecordingLogger` | `pi-roundtable/testing` | type |
3815
3882
  | `RecordedLog` | `pi-roundtable/testing` | type |
@@ -3818,6 +3885,7 @@ Import from the entries listed below; source area files are internal.
3818
3885
  | `testHost` | `pi-roundtable/testing` | value |
3819
3886
  | `testPlugin` | `pi-roundtable/testing` | value |
3820
3887
  | `useEagerCatalog` | `pi-roundtable/testing` | value |
3888
+ | `usePostgresForTests` | `pi-roundtable/testing` | value |
3821
3889
  | `useTestLocale` | `pi-roundtable/testing` | value |
3822
3890
  | `AUTO_THINKING` | `pi-roundtable/kit` | value |
3823
3891
  | `AgentCategory` | `pi-roundtable/kit` | type |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "A plugin-driven Pi agent server for one owner, reached through Discord or the web",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,3 +1,5 @@
1
+ import { Roundtable } from "../../core/host.ts";
2
+ import { silentLogger } from "../../core/log.ts";
1
3
  import { CONFIG_FILE, type Project } from "../project.ts";
2
4
  import { fail, ok, type Result } from "../report.ts";
3
5
 
@@ -7,7 +9,11 @@ export async function checkConfiguration(project: Project): Promise<Result> {
7
9
  return assembled.ok ? ok(`${CONFIG_FILE} is valid`) : assembled.failure;
8
10
  }
9
11
 
10
- /** Every plugin has its own name, the operator's and the built-ins' together. */
12
+ /**
13
+ * Every plugin has its own name, the operator's and the built-ins' together, and the plugins
14
+ * register as `start` would register them: each set up, each adding something, none clashing,
15
+ * without a database, a listener or Discord.
16
+ */
11
17
  export async function checkPlugins(project: Project): Promise<Result> {
12
18
  const assembled = await project.assembled();
13
19
  if (!assembled.ok)
@@ -21,6 +27,20 @@ export async function checkPlugins(project: Project): Promise<Result> {
21
27
  );
22
28
  seen.add(name);
23
29
  }
30
+ try {
31
+ // A definition of its own: the one `start` runs must not have had its plugins set up.
32
+ const defined = await project.reassemble();
33
+ // The plugins' own log lines are the boot's to print, not the check's.
34
+ await new Roundtable(
35
+ { ...defined.options, logger: silentLogger() },
36
+ defined.plugins,
37
+ ).check();
38
+ } catch (error) {
39
+ return fail(
40
+ error instanceof Error ? error.message : String(error),
41
+ `Fix the plugin the message names in ${CONFIG_FILE}, or remove it: \`roundtable start\` would refuse it the same way.`,
42
+ );
43
+ }
24
44
  const own = assembled.value.config.plugins.map((plugin) => plugin.name);
25
45
  return ok(
26
46
  own.length === 0 ? "no plugins of your own" : `yours: ${own.join(", ")}`,
@@ -8,6 +8,7 @@ import {
8
8
  resolveConfig,
9
9
  } from "../core/config/config.ts";
10
10
  import type { DefinedRoundtable } from "../core/define-roundtable.ts";
11
+ import { type Logger, silentLogger } from "../core/log.ts";
11
12
  import { fail, type Result } from "./report.ts";
12
13
 
13
14
  export const CONFIG_FILE = "roundtable.config.ts";
@@ -15,29 +16,49 @@ export const CONFIG_FILE = "roundtable.config.ts";
15
16
  /** What the command line reaches outside itself, so each check can be run against a fake. */
16
17
  export interface Ports {
17
18
  /** The value `roundtable.config.ts` exports by default. */
18
- loadConfig(cwd: string): Promise<unknown>;
19
+ loadConfig(cwd: string, options?: { fresh?: boolean }): Promise<unknown>;
19
20
  /** The bot's options and plugin list, assembled from a valid configuration. */
20
21
  define(
21
22
  config: RoundtableConfig,
22
23
  agentDir: string,
24
+ overrides?: { logger?: Logger },
23
25
  ): Promise<DefinedRoundtable>;
24
26
  /** Where the login for `provider` in `agentDir` comes from, or undefined when there is none. */
25
27
  login(agentDir: string, provider: string): Promise<string | undefined>;
26
28
  }
27
29
 
28
- /** Loads the project's configuration file with Bun, which reads `.env` on its own. */
29
- export async function loadConfigFile(cwd: string): Promise<unknown> {
30
+ /**
31
+ * Loads the project's configuration file with Bun, which reads `.env` on its own. With `fresh`
32
+ * the file is evaluated again, so the plugins its default export makes are new objects.
33
+ */
34
+ export async function loadConfigFile(
35
+ cwd: string,
36
+ options: { fresh?: boolean } = {},
37
+ ): Promise<unknown> {
30
38
  const path = join(cwd, CONFIG_FILE);
31
39
  if (!existsSync(path))
32
40
  throw new Error(
33
41
  `${CONFIG_FILE} is not in ${cwd}. Run this in the project directory, or create a project with \`roundtable init\`.`,
34
42
  );
35
- return loadDefault(path);
43
+ return loadDefault(path, options);
36
44
  }
37
45
 
38
- /** The value a configuration file exports by default, imported with Bun. */
39
- export async function loadDefault(path: string): Promise<unknown> {
40
- const module = (await import(pathToFileURL(path).href)) as {
46
+ let evaluations = 0;
47
+
48
+ /**
49
+ * The value a configuration file exports by default, imported with Bun. With `fresh` the file is
50
+ * imported under a path of its own (Bun evaluates a plain path with a new query again, and not a
51
+ * `file:` URL with one), so what the file makes at its top level is made again; the modules it
52
+ * imports stay as they were, shared with the first evaluation.
53
+ */
54
+ export async function loadDefault(
55
+ path: string,
56
+ { fresh = false }: { fresh?: boolean } = {},
57
+ ): Promise<unknown> {
58
+ const specifier = fresh
59
+ ? `${path}?fresh=${++evaluations}`
60
+ : pathToFileURL(path).href;
61
+ const module = (await import(specifier)) as {
41
62
  default?: unknown;
42
63
  };
43
64
  if (module.default === undefined)
@@ -76,6 +97,8 @@ export class Project {
76
97
  readonly #cwd: string;
77
98
  readonly #ports: Ports;
78
99
  #raw: Promise<Loaded<unknown>> | undefined;
100
+ /** The configuration file's default export as it was loaded, before its adapters were lifted. */
101
+ #exported: unknown;
79
102
  #assembled: Promise<Loaded<Assembled>> | undefined;
80
103
 
81
104
  constructor(cwd: string, ports: Ports) {
@@ -90,7 +113,10 @@ export class Project {
90
113
  */
91
114
  raw(): Promise<Loaded<unknown>> {
92
115
  this.#raw ??= this.#ports.loadConfig(this.#cwd).then(
93
- (value): Loaded<unknown> => ({ ok: true, value: lifted(value) }),
116
+ (value): Loaded<unknown> => {
117
+ this.#exported = value;
118
+ return { ok: true, value: lifted(value) };
119
+ },
94
120
  (error: unknown): Loaded<unknown> => ({
95
121
  ok: false,
96
122
  failure: fail(
@@ -133,6 +159,31 @@ export class Project {
133
159
  return this.#assembled;
134
160
  }
135
161
 
162
+ /**
163
+ * The configuration assembled again, for a check that runs every plugin's setup: the
164
+ * configuration file is evaluated again, so the plugins its default export makes with a factory
165
+ * are new objects, and the built-in plugins are made again. A plugin object that a module the
166
+ * file imports exports is the same object, and so is state kept at module level, so a plugin's
167
+ * `setup` must keep nothing one-shot outside its call. The plugins' own log lines go nowhere:
168
+ * the definition `start` runs speaks for the host.
169
+ */
170
+ async reassemble(): Promise<DefinedRoundtable> {
171
+ const assembled = await this.assembled();
172
+ if (!assembled.ok) throw new Error("the configuration is not valid yet");
173
+ const fresh = await this.#ports.loadConfig(this.#cwd, { fresh: true });
174
+ // A runtime that did not evaluate the file again would share every plugin with the first definition.
175
+ if (fresh === this.#exported)
176
+ throw new Error(
177
+ `${CONFIG_FILE} was not evaluated again, so the check would set up the very plugins that start runs. Either this Bun did not run the file again when it was imported under a new query (1.4.2 does), or the default export is built in a module the file imports (\`import config from "./base.ts"; export default config;\`), which stays shared. Build the definition in ${CONFIG_FILE} itself: call defineRoundtable and the plugin factories there.`,
178
+ );
179
+ const value = lifted(fresh);
180
+ return this.#ports.define(
181
+ value as RoundtableConfig,
182
+ assembled.value.config.agentDir,
183
+ { logger: silentLogger() },
184
+ );
185
+ }
186
+
136
187
  async #assemble(): Promise<Loaded<Assembled>> {
137
188
  const raw = await this.raw();
138
189
  if (!raw.ok) return raw;
@@ -4,6 +4,7 @@ import type { Credential, CredentialStore } from "@earendil-works/pi-ai";
4
4
  import { ModelRuntime } from "@earendil-works/pi-coding-agent";
5
5
  import type { DefinedRoundtable } from "../core/define-roundtable.ts";
6
6
  import { defineRoundtable } from "../core/define-roundtable.ts";
7
+ import type { Logger } from "../core/log.ts";
7
8
 
8
9
  /** A credential store that holds nothing and refuses writes, so looking never creates `auth.json`. */
9
10
  const emptyCredentials: CredentialStore = {
@@ -46,8 +47,10 @@ export async function providerLogin(
46
47
  export async function assemble(
47
48
  config: Parameters<typeof defineRoundtable>[0],
48
49
  agentDir: string,
50
+ overrides: { logger?: Logger } = {},
49
51
  ): Promise<DefinedRoundtable> {
50
52
  return defineRoundtable(config, {
51
53
  modelRuntime: await openModelRuntime(agentDir),
54
+ ...(overrides.logger ? { logger: overrides.logger } : {}),
52
55
  });
53
56
  }
@@ -48,6 +48,8 @@ export {
48
48
  MIN_GROUP_MEMBERS,
49
49
  } from "./agent-rules.ts";
50
50
 
51
+ import { isCheckDatabase } from "../db/check-database.ts";
52
+
51
53
  /**
52
54
  * Agents, groups, and group messages, in the database. Agents and groups are also held
53
55
  * in memory, because every inbound message asks and only this process changes them.
@@ -77,7 +79,8 @@ export class PgAgentStore implements AgentDirectory {
77
79
  /** The store over the host's migrated pool, holding the rows of one guild. */
78
80
  static async attach(sql: SQL, guildId: string): Promise<PgAgentStore> {
79
81
  const store = new PgAgentStore(sql, guildId);
80
- await store.#reload();
82
+ // `Roundtable.check()` has no database to read the rows from: the store starts empty.
83
+ if (!isCheckDatabase(sql)) await store.#reload();
81
84
  return store;
82
85
  }
83
86
 
@@ -68,6 +68,8 @@ export interface DiscordOptions {
68
68
  channelContext?: ChannelContextOptions | false;
69
69
  /** The divider posted in a server channel when its conversation starts over; `false` posts none. Default: a short English divider. */
70
70
  freshMarker?: string | false;
71
+ /** What the model is told about how replies render in Discord; `false` tells it nothing. Default: Markdown renders, except tables. */
72
+ formatting?: string | false;
71
73
  /**
72
74
  * How long a turn waits on its owner card before it goes on without the answer, leaving the
73
75
  * card open; a later answer starts a new turn. Default `CARD_GRACE_MS`, two minutes.
@@ -135,6 +137,9 @@ export function discordPlugin(options: DiscordOptions): RoundtablePlugin {
135
137
  ...(options.freshMarker === undefined
136
138
  ? {}
137
139
  : { freshMarker: options.freshMarker }),
140
+ ...(options.formatting === undefined
141
+ ? {}
142
+ : { formatting: options.formatting }),
138
143
  });
139
144
  surface = connected;
140
145
  // Every owner may use the owner's commands, by the identity service; the primary owner always.
@@ -57,6 +57,13 @@ export interface DiscordConfig {
57
57
  * never in a direct message. Default `─── new conversation ───`; `false` posts none.
58
58
  */
59
59
  freshMarker?: string | false;
60
+ /**
61
+ * What the model is told, English, about how a reply renders in Discord, appended to the system
62
+ * prompt of every turn that replies there, after the persona. The default says Markdown
63
+ * renders except tables, so the model writes bullet lists or `key: value` lines instead. A text
64
+ * replaces it; `false` tells the model nothing.
65
+ */
66
+ formatting?: string | false;
60
67
  /**
61
68
  * Seconds a turn waits on an owner card (an approval or an `ask_user` question) before it goes
62
69
  * on without the answer; the card stays open, and answering it later starts a new turn with
@@ -94,6 +101,7 @@ export interface ResolvedDiscord {
94
101
  /** As configured: `{}` when left out, so the defaults apply, or `false`. */
95
102
  channelContext: NonNullable<DiscordConfig["channelContext"]>;
96
103
  freshMarker?: string | false;
104
+ formatting?: string | false;
97
105
  cardGraceSeconds: number;
98
106
  /** As configured: `{}` when left out, so the defaults apply, or `false`. */
99
107
  cardCopies: NonNullable<DiscordConfig["cardCopies"]> | false;
@@ -125,6 +133,9 @@ export function resolvedDiscord(
125
133
  ...(discord.freshMarker === undefined
126
134
  ? {}
127
135
  : { freshMarker: discord.freshMarker }),
136
+ ...(discord.formatting === undefined
137
+ ? {}
138
+ : { formatting: discord.formatting }),
128
139
  cardGraceSeconds: discord.cardGraceSeconds ?? 120,
129
140
  cardCopies: discord.cardCopies ?? {},
130
141
  ...(discord.attachmentDownload === undefined
@@ -167,6 +178,7 @@ export const discordShape = shape({
167
178
  agentSpeakers: optional(oneOf<"everyone" | "owners">("everyone", "owners")),
168
179
  channelContext: optional(orOff(channelContextShape)),
169
180
  freshMarker: optional(orOff(text)),
181
+ formatting: optional(orOff(text)),
170
182
  cardGraceSeconds: optional(integer(1, 86_400)),
171
183
  cardCopies: optional(
172
184
  orOff(
@@ -147,6 +147,13 @@ export interface RuntimeDeps {
147
147
  conversation: ChannelKey,
148
148
  scope?: PromptScope | Speaker,
149
149
  ): Prompts | undefined;
150
+ /**
151
+ * The formatting guidance of the chat surface that serves a channel, as `ChatSurface.formatting`
152
+ * says; undefined when none serves it or it gives none. A runtime that builds its own system
153
+ * prompt appends it after the persona, for the channel a turn replies in. Optional so a host
154
+ * that predates 0.17 still type-checks.
155
+ */
156
+ formatting?(channel: ChannelKey): string | undefined;
150
157
  /**
151
158
  * The agent server's per-agent settings, for agent turns. The runtime is built before the agent
152
159
  * server, so read this when a turn runs, not in the factory; it stays undefined on a host without
@@ -43,6 +43,13 @@ export interface ChatSurface {
43
43
  readonly surface: string;
44
44
  /** Explicit opt-in to delivering OutboundReply.files; absent or false refuses turn attachments. */
45
45
  readonly supportsFiles?: boolean;
46
+ /**
47
+ * Short English guidance on how a reply renders here, such as "no Markdown tables", which the
48
+ * runtime appends to the system prompt of every turn that replies on this surface, after the
49
+ * persona. It is model-facing text, so it is not a message-catalog string. Absent = nothing is
50
+ * appended; a turn whose channel has no surface gets none either.
51
+ */
52
+ readonly formatting?: string;
46
53
  /**
47
54
  * Connects and delivers every incoming message; the host passes its conversation router. A
48
55
  * message whose channel key has another prefix is logged and dropped.
@@ -0,0 +1,39 @@
1
+ import type { SQL } from "bun";
2
+
3
+ const REFUSAL =
4
+ "the database was queried during setup, which `Roundtable.check()` does not migrate or connect. Open stores lazily, and query from a service's start or from a handler";
5
+
6
+ function refuse(): never {
7
+ throw new Error(REFUSAL);
8
+ }
9
+
10
+ /**
11
+ * The database a plugin's setup sees under `check()`: a pool that exists, so a store can attach to
12
+ * it, but that refuses every query, transaction and connection. A refusal reaches the plugin as an
13
+ * ordinary setup error, which the host words with the plugin's name.
14
+ */
15
+ export function checkDatabase(): SQL {
16
+ // SAFETY: the stand-in answers the members of SQL a plugin could reach during setup, all by refusing.
17
+ const stand = new Proxy(refuse, {
18
+ apply: refuse,
19
+ // Not awaitable and no other symbol, so returning it from an async setup or logging it is harmless.
20
+ get: (_target, property) =>
21
+ property === STAND_IN
22
+ ? true
23
+ : typeof property === "symbol" || property === "then"
24
+ ? undefined
25
+ : refuse,
26
+ }) as unknown as SQL;
27
+ return stand;
28
+ }
29
+
30
+ const STAND_IN = Symbol("roundtable.checkDatabase");
31
+
32
+ /**
33
+ * Whether `sql` is the stand-in `check()` hands the plugins. The built-in plugins whose setup would
34
+ * otherwise write to the database, such as the identity plugin's configuration sync, skip that
35
+ * write under it: `check()` proves the registration, not the data.
36
+ */
37
+ export function isCheckDatabase(sql: SQL): boolean {
38
+ return (sql as unknown as Record<symbol, unknown>)[STAND_IN] === true;
39
+ }
@@ -173,6 +173,9 @@ function discordAssembly(
173
173
  ...(discord.freshMarker === undefined
174
174
  ? {}
175
175
  : { freshMarker: discord.freshMarker }),
176
+ ...(discord.formatting === undefined
177
+ ? {}
178
+ : { formatting: discord.formatting }),
176
179
  cardGraceMs: discord.cardGraceSeconds * 1_000,
177
180
  cardCopies: discord.cardCopies,
178
181
  }),
@@ -34,6 +34,7 @@ import type { ChannelExecutor } from "./channel-operations.ts";
34
34
  import type { ComposedCommands } from "./compose-commands.ts";
35
35
  import type { ChannelInfo, DiscordConnection } from "./connection.ts";
36
36
  import { DiscordThreadHost } from "./dispatch-thread-host.ts";
37
+ import { DISCORD_FORMATTING, pickFormatting } from "./formatting.ts";
37
38
  import { DEFAULT_FRESH_MARKER, postFreshMarker } from "./fresh-marker.ts";
38
39
  import { lateAnswerMessage, toInbound } from "./inbound-message.ts";
39
40
  import type { CardChannel, LateTurn } from "./owner-cards.ts";
@@ -59,6 +60,8 @@ export interface DiscordSurfaceOptions {
59
60
  logger: Logger;
60
61
  /** The divider posted in a server channel when its conversation starts over; `false` posts none. Default: `DEFAULT_FRESH_MARKER`. */
61
62
  freshMarker?: string | false;
63
+ /** What the model is told about how replies render here; `false` tells it nothing. Default: `DISCORD_FORMATTING`. */
64
+ formatting?: string | false;
62
65
  }
63
66
 
64
67
  /**
@@ -72,6 +75,7 @@ export class DiscordSurface
72
75
  {
73
76
  readonly surface = "discord";
74
77
  readonly supportsFiles = true;
78
+ readonly formatting: string | undefined;
75
79
  readonly #options: DiscordSurfaceOptions;
76
80
  readonly #client = new Client({
77
81
  intents: [
@@ -89,6 +93,7 @@ export class DiscordSurface
89
93
 
90
94
  constructor(options: DiscordSurfaceOptions) {
91
95
  this.#options = options;
96
+ this.formatting = pickFormatting(options.formatting, DISCORD_FORMATTING);
92
97
  }
93
98
 
94
99
  /** Slash commands and the modules that answer them; the commands are registered globally at startup. */
@@ -0,0 +1,20 @@
1
+ /**
2
+ * What the model is told about Discord's rendering, as the surface's `formatting`. English: it is
3
+ * model-facing text, not a message-catalog string.
4
+ */
5
+ export const DISCORD_FORMATTING =
6
+ "Reply formatting (Discord): Markdown renders, except tables, which show as raw | pipes, so use bullet lists or `key: value` lines instead of tables. Headings, bold, italics, lists, quotes, inline code, fenced code blocks and -# small text all work. A reply over 2,000 characters is split into several messages, so keep answers compact.";
7
+
8
+ /** The guidance a surface option picks: the built-in when absent, none for `false`, else the override. */
9
+ export function pickFormatting(
10
+ option: string | false | undefined,
11
+ builtin: string,
12
+ ): string | undefined {
13
+ if (option === false) return undefined;
14
+ if (option === undefined) return builtin;
15
+ if (option.trim() === "")
16
+ throw new Error(
17
+ "formatting is empty; write the guidance, or false to give the model none",
18
+ );
19
+ return option;
20
+ }