pi-roundtable 0.24.2 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/docs/plugins.md +174 -7
  3. package/package.json +1 -1
  4. package/src/cli/checks/agent-tools.ts +23 -0
  5. package/src/cli/doctor.ts +6 -0
  6. package/src/cli/principal.ts +9 -2
  7. package/src/core/agents/agent-book.ts +155 -0
  8. package/src/core/agents/agent-ports.ts +5 -1
  9. package/src/core/agents/agent-prompt.ts +6 -1
  10. package/src/core/agents/agent-schema.ts +3 -0
  11. package/src/core/agents/agent-statements.ts +179 -0
  12. package/src/core/agents/agent-store.ts +184 -191
  13. package/src/core/agents/agent-team-fixture.ts +21 -2
  14. package/src/core/agents/agent-team.ts +44 -36
  15. package/src/core/agents/agent-tools.ts +12 -6
  16. package/src/core/agents/team-editing.ts +154 -32
  17. package/src/core/agents/team-keys.ts +17 -0
  18. package/src/core/agents/team-lifecycle.ts +96 -23
  19. package/src/core/agents/team-options.ts +9 -4
  20. package/src/core/agents/team-selection.ts +32 -0
  21. package/src/core/builtin/agent-server.ts +30 -5
  22. package/src/core/builtin/channel-context.ts +21 -0
  23. package/src/core/builtin/discord.ts +4 -1
  24. package/src/core/builtin/prompts.ts +40 -0
  25. package/src/core/builtin/skills.ts +9 -5
  26. package/src/core/config/config-schema.ts +22 -6
  27. package/src/core/config/config.ts +30 -2
  28. package/src/core/config/conversations.ts +21 -0
  29. package/src/core/config/profiles.ts +53 -0
  30. package/src/core/contract/channel-context.ts +106 -0
  31. package/src/core/contract/surface.ts +16 -0
  32. package/src/core/conversations/conversation-registry.ts +6 -0
  33. package/src/core/conversations/conversation-retention.ts +107 -0
  34. package/src/core/conversations/conversation-store.ts +6 -0
  35. package/src/core/conversations/conversations-plugin.ts +19 -3
  36. package/src/core/define-roundtable.ts +12 -2
  37. package/src/core/discord/channel-context.ts +47 -78
  38. package/src/core/discord/discord-surface.ts +16 -0
  39. package/src/core/events.ts +1 -0
  40. package/src/core/host-registration.ts +4 -0
  41. package/src/core/host.ts +3 -1
  42. package/src/core/i18n/agent-panel.ts +4 -0
  43. package/src/core/i18n/cards.ts +1 -1
  44. package/src/core/identity/actor-facts.ts +7 -0
  45. package/src/core/identity/identity-plugin.ts +27 -5
  46. package/src/core/identity/identity-port.ts +62 -0
  47. package/src/core/identity/identity-schema.ts +2 -0
  48. package/src/core/identity/identity-service.ts +45 -52
  49. package/src/core/identity/identity-view.ts +1 -0
  50. package/src/core/identity/person-profiles.ts +275 -0
  51. package/src/core/identity/principal-tables.ts +10 -0
  52. package/src/core/identity/profile-choices.ts +45 -0
  53. package/src/core/identity/profile-memory.ts +65 -0
  54. package/src/core/identity/profile-picture.ts +117 -0
  55. package/src/core/identity/profiles-view.ts +20 -0
  56. package/src/core/identity/profiles.ts +82 -0
  57. package/src/core/modules/host-shell/shell-policy.ts +18 -3
  58. package/src/core/modules/prompts/prompt-documents.ts +425 -0
  59. package/src/core/modules/prompts/prompt-history.ts +103 -0
  60. package/src/core/modules/skills/skill-contract.ts +52 -0
  61. package/src/core/modules/skills/skill-document-files.ts +66 -0
  62. package/src/core/modules/skills/skill-registry.ts +136 -8
  63. package/src/core/modules/skills/skill-rules.ts +94 -0
  64. package/src/core/modules/skills/skill-store.ts +88 -17
  65. package/src/core/modules/skills/skill-tools.ts +18 -10
  66. package/src/core/plugin.ts +40 -0
  67. package/src/core/registry/contributions.ts +8 -1
  68. package/src/core/runtime/chat-only.ts +32 -0
  69. package/src/core/runtime/conversation-sessions.ts +2 -1
  70. package/src/core/runtime/extensions/agent-prompt.ts +20 -5
  71. package/src/core/runtime/extensions/persona-prompt.ts +39 -0
  72. package/src/core/runtime/extensions/prompt-read.ts +22 -0
  73. package/src/core/runtime/runtime-plugin.ts +1 -0
  74. package/src/core/runtime/runtime-types.ts +3 -1
  75. package/src/core/runtime/session-factory.ts +70 -65
  76. package/src/core/runtime/session-helpers.ts +27 -0
  77. package/src/core/services.ts +97 -33
  78. package/src/index.ts +31 -0
  79. package/src/kit/index.ts +1 -0
  80. package/src/kit/oidc.ts +16 -0
  81. package/src/testing.ts +1 -0
package/CHANGELOG.md CHANGED
@@ -5,6 +5,44 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.26.0] - 2026-10-30
9
+
10
+ ### Added
11
+
12
+ - `IdentityService.peek(facts, { conversation }?)`: who these facts resolve to now, read only. It never links, claims, admits, records the person as seen or touches a tier, also when it refuses, and its tier is exactly what `resolve` gives (the lasting roles together with what the access rules give the facts), never the tier last recorded. It answers undefined for an unlinked identity, a disabled principal, or no tier. **Breaking** for code: a hand-built `IdentityService` stand-in adds `peek` (`testHost` and `servicePair` already cover tests).
13
+ - `ActorFacts.partial`: a surface that is not the authority on a person's roles marks its facts `partial: true`, and `resolve` records no lower tier and no refusal from them. Optional, not breaking.
14
+ - `PROFILES` (`PersonProfiles`), provided by the `identity` plugin: how the core names a person (D1, D3). A plugin registers a `ProfileSource` per provider (`provider`, `label`, `modelVisible`, `pictureHosts`, `fetch(subject, signal)`); a signed-in session reports the name its identity provider gave with `observed(principalId, provider, name)`; the person chooses which linked account represents them with `choose` (refused for an unlinked provider; `null` returns to the default), else the first provider in the new `profiles.prefer` they have linked; `of(principalId)` gives the name, the source and whether a picture exists, and `picture(principalId)` the picture through the source's allowed hosts only (https, no redirect, PNG/JPEG/GIF/WebP, at most 2 MiB, `nosniff`). Fetched and observed names and picture addresses live in memory for an hour at most and are written to no table; a fetch has two seconds and a miss, failure or timeout is remembered for ten minutes. Only the choice is stored, in the new `principal_profile_choices` table (migration `principal-profile-choices`, additive). Types: `ProfileSource`, `PersonProfiles`, `PersonDisplay`, `ProfileRules`, `ProfilesConfig`.
15
+ - **Breaking** for a host that replaces the `identity` plugin: it now provides `PROFILES` too, so a replacement lists `PROFILES` in `provides` and `replaces`, as it already does for `IDENTITY_LINKS`.
16
+ - `profiles.modelVisible` and `profiles.prefer` in the host's configuration. `Speaker.name` from `IDENTITY.resolve`, `peek` and `speakerFor` becomes the profile name for a provider the host lists in `profiles.modelVisible`; a source's own `modelVisible` flag never widens it. With no source, nothing observed and an empty list, every name is as in 0.25. **Breaking** only for a host that lists a provider: its agents then read that name.
17
+ - `pi-roundtable-line`: `lineProfileSource(api)`, a `ProfileSource` for provider `line` over LINE's `/v2/bot/profile/{userId}`, with the picture hosts `profile.line-scdn.net` and `obs.line-scdn.net` and `modelVisible: false`.
18
+ - `roundtable principal show` prints the profile source a person chose.
19
+ - `pi-roundtable/kit`: `oidcProvider` (`oidcProvider(issuer)`) and `oidcIdentity` (`oidcIdentity(issuer, subject)`), the one writer of `oidc:<base64url issuer>[:<subject>]`. `oidcSpeakerId` of `pi-roundtable-webchat` gives the same strings through it.
20
+ - `agentServer: { tools: "chat-only" }` (config), `HostEnv.agentTools` and `ResolvedConfig.agentTools` (`"full"` by default, `AgentTools`): the configuration form and the value plugins read as `context.env.agentTools`. `RoundtableOptions.environment.agentTools` carries it for hosts that build their options by hand. With it no agent or persona turn is offered `bash`, `read`, `edit` or `write`: the agent team's selection leaves them out, the agent's prompt says it has no shell, file tools or workspace, its sessions get no workspace, and `SessionFactory` refuses any selection naming one of them (`toolsFor`, `taskTools`) with a `ConfigError`, so the turn fails closed and is logged, whatever built the selection. `RoundtablePlugin.hostAccess?: true` marks a plugin whose tools reach the host (`pi-roundtable-coding` declares it): a chat-only host does not start with one, nor with a plugin whose `agentSelection` names a shell tool, and `pi-roundtable-webchat` refuses a persona `selection` that does. `roundtable doctor` gains an `agent tools` check that prints the mode. A host without the key behaves as before. See [Chat-only agents](docs/plugins.md#chat-only-agents-no-shell-file-or-workspace-tools).
21
+ - `conversations: { keepDays: { <kind>: days } }` (config; `ResolvedConfig.conversations`): a daily sweep deletes the recorded conversations of a kind that were last active longer ago, through `ConversationPort.deleteConversation`, skips a busy one until the next sweep and forgets the record with the new optional `ConversationRegistry.remove(key)`. A kind not listed, and a host without the key, keeps its conversations. Type: `ConversationsConfig`. See [Conversation retention](docs/plugins.md#conversation-retention-conversationskeepdays).
22
+ - Events: `EventHandlers.action(event)` and the optional `EventSink.action(event)`: a plugin reports what a person did through its surface (`ActionEvent`: `kind`, `actor`, `principalId`, `channel?`, `ok`, `at`, never content) and every plugin's handler hears it; a handler that throws is logged and never stops the others. The core reports none itself.
23
+
24
+ - Channel context for any surface. `ChatSurface.channelContext(message, options?)` is the new optional method a surface implements; the new built-in `channel-context` plugin provides `CHANNEL_CONTEXT` (`ChannelContexts`: `read(message, options?)`), which picks the surface by the message's channel key as `THREADS` does and returns what it reads, `undefined` for a surface without the method. `DISCORD.channelContext` is unchanged for callers and is the Discord surface's own answer. `ChannelContextOptions.authors(message)` (`ChannelContextSource` in, `ChannelContextVerdict` `"keep" | "skip" | "end"` or `undefined` out) generalises `foreignWebhook` to any message and surface and wins where it answers; `ChannelContextMessage.external` (and `ChannelContextSource.external`) marks an author outside the people the host serves, never an owner, and the block labels it `role="external"`. `ChannelContextSourceAttachment` is the attachment shape of a fetched message. `formatChannelContext` is now defined with the contract types and re-exported by the Discord entry unchanged.
25
+
26
+ ### Breaking
27
+
28
+ - `HostEnv.agentTools` is required: code that builds a `HostEnv` by hand sets it (`testHost` and `testPlugin` fill `"full"`).
29
+
30
+ ### Fixed
31
+
32
+ - A held shell command's sentence on its approval card shows the command's first line, cut at 120 characters, instead of up to 600 characters of every line inside one code span: a multi-line script (a heredoc with `# comments`) broke the card's Markdown into headings. The card's code block still shows the whole command.
33
+ - `agent_create` from an agent's turn in a web room started the new agent's opening task in its Discord channel. It now starts on the surface the caller's turn runs on, as the web app's new-agent form already did; a turn on Discord, or on a surface that hosts no rooms, keeps the Discord channel.
34
+
35
+ ## [0.25.0] - 2026-10-29
36
+
37
+ ### Added
38
+
39
+ - The agent team can be edited by an owner outside an agent turn, with the operations the agent tools use. `AgentTeam` gains `createAgent(speaker, input)` (the fields of `agent_create`, `NewAgent`, plus `model` and `thinking`, checked before a picture or channel is made; without a `task` the agent only exists, and `openIn` names the surface whose room runs the opening task), `archive(caller | undefined, name)` (an owner has no calling agent), `restore(name)` and `remove(name, cleanup?)` for an archived agent or group (`cleanup` is the caller's part of the delete: it runs once the checks have passed and before anything is deleted, and one that throws leaves the target in place to delete again), and `createGroup` and `updateGroup`. Restoring moves a Discord channel back out of Archive (a new channel replaces one deleted meanwhile); an agent does not rejoin the groups it left, and a group needs all its members active. A group that an agent's archive would leave with fewer than two members is archived whole, its members kept, and restores once they are all active again. Removing frees the name, detaches the agent's skills, clears its group cursors and a group's messages, makes the runtime forget the conversations the name kept (an agent's own, its seats in every group, a group's members' seats), and leaves a Discord channel under Archive; an agent the host's seeds name is refused, since the next start would seed it again. Editing a group starts the seats of the members it removes over. `AgentTurnRunner` (the part of the runtime the agent team drives) gains `deleteConversation`, so a hand-built runner for the team must set it. The store gains `restoreAgent`, `restoreGroup`, `checkRestorableGroup`, `deleteAgent` and `deleteGroup`. `AgentTeam` has new required methods, so code that builds an `AgentTeam` by hand (a test stand-in) must set them.
40
+ - `pi-roundtable-web`: make, archive and arrange agents from the app. The **+** button of the room list and the drawer opens New agent (name, permanent name, prompt, model, optional opening task and a look from the creator; the new agent's room opens), New group and Archived. The agent sheet gains a Skills section (a switch per skill of the host's catalog; built-in ones stay on) and Archive; a group has a sheet for its name, members and host. Archive asks first and names the agent or group, and Archived lists what was archived with Restore and Delete, which asks first too and frees the name. New owner-only routes carry it: `POST agents`, `POST agents/<name>/archive` and `restore`, `DELETE agents/<name>`, `GET`/`PUT agents/<name>/skills`, `POST groups`, `GET`/`PATCH`/`DELETE groups/<name>`, `POST groups/<name>/archive` and `restore`, and `GET archive`. The new sheets load on demand. The package's changelog has the detail.
41
+ - Prompt texts and skills the owner edits outside an agent turn. A new built-in `prompts` plugin provides `PROMPTS` (`PromptDocuments`): named prompt documents, each with a file version, an override stored in PostgreSQL (additive migrations `prompt-overrides` and `prompt-history`) and a history of who changed what and when. `register(spec)` (`PromptDocumentSpec`: `id`, `label`, `description?`, `default` as a string or a function read at each use, `maxChars?`) returns a `PromptHandle` whose `text()` a plugin reads when it builds a prompt; `set(id, text, by, base?)` stores the override, which wins while it is set and applies from the next turn without a restart; `revert(id, by, base?)` returns to the file version; a `base` that is not the text in force when the write takes its turn in the queue refuses it with `PromptConflictError` (carrying the current document), so two saves from one text give one write; a function `default` that throws is an `AgentError` when the document is registered, and later (the file was removed) makes `text(id)` throw naming the document and log an error, ends the run that read it (a persona or an agent's prompt) instead of leaving it with the old text, and marks the document `broken` in `list()` and `get()` (empty `fileText`, `set` and `revert` refused) without failing the others; `list`, `get` and `history` read it (`PromptDocumentView`, `PromptVersion`). The core registers `shared` (`prompts.shared`) and `guest` (`prompts.guest`); a plugin registers its own, such as a persona's prompt. It is on by default because nothing is overridden until the owner saves a text and the file stays the default; at start the host logs one warning per document that differs from its file version, naming who set it and when. A directory agent keeps its stored prompt as the source (seeds do not overwrite an existing row): `AgentTeam.update(name, change, by?)` now records who changed the prompt, and `AgentTeam.promptHistory(name, limit?)` lists the versions. `SkillRegistry` gains `document(name)`, `saveDocument(name, text, baseHash, by)`, `createDocument(text, by)` and `documentHistory(name)` (`SkillDocument`, `SkillVersion`): an agent-written skill's whole SKILL.md with its SHA-256 hash, saved against that hash with the skill tools' frontmatter rules, refused with `SkillConflictError` (carrying the current document) when it changed meanwhile, and kept as versions. `skill_create` and `skill_update` take their turn in the same queue as an owner's save, so neither overwrites the other unseen, and they record the agent's name as the author of a version. `AgentTeam` has the new required `promptHistory`, and `SkillRegistry` four new required methods, so a hand-built stand-in must set them. See [`PROMPTS`](docs/plugins.md#prompts-prompt-texts-the-owner-edits).
42
+ - `pi-roundtable-web`: edit the main prompts and skills from the app. **Main prompts** (linked from Settings) lists the prompt documents (`shared`, `guest` and a plugin's own) with a "differs from the file version" badge, and edits one in a full-screen sheet with a diff to read before saving, a history (read a version against the one before, take it into the editor) and **Revert to the file version**, which asks first. The agent sheet gains History for the agent's prompt. The Skills page edits an agent-written skill's whole SKILL.md and makes a new skill, with the same frontmatter rules as the skill tools, a diff to read, a history and a conflict check (`409`, the diff read again against what is there now); built-in, package and linked skills show a lock and where they come from. Deleting a skill is not offered here. Owner-only routes: `GET api/prompts`, `GET`/`PUT api/prompts/<id>`, `POST api/prompts/<id>/revert`, `GET api/prompts/<id>/history`, `GET api/agents/<name>/prompt-history`, `GET`/`PUT api/skill-docs/<name>`, `POST api/skill-docs`, `GET api/skill-docs/<name>/history`. The package's changelog has the detail.
43
+ - `pi-roundtable-web`: pins and folders for the room list, shared by every owner and device, with drag to reorder and move, a menu for each, and a mute for a whole folder. Three additive tables (`web_room_folders`, `web_room_folder_rooms`, `web_room_pins`) and owner-only routes carry it; the package's changelog has the detail.
44
+ - `pi-roundtable-web`: choose who sits at the round table. A button in the page's header lists every agent with a switch; an agent that leaves the table has its room's push muted, and a seat that leaves or joins plays a short show. The package's changelog has the detail.
45
+
8
46
  ## [0.24.2] - 2026-10-29
9
47
 
10
48
  ### Added
package/docs/plugins.md CHANGED
@@ -136,6 +136,7 @@ definePlugin({
136
136
  provides: [], // optional: keys of the services setup provides to other plugins
137
137
  replaces: [], // optional: keys of built-in services this plugin takes over
138
138
  identities: [], // optional: identities the plugin's own credentials stand for
139
+ hostAccess: true, // optional: its tools reach the host's machine (shell, files)
139
140
  preflight() {}, // optional: a check that runs before anything starts
140
141
  setup(context) { // required: returns the parts the plugin adds
141
142
  return { /* parts */ };
@@ -146,6 +147,9 @@ definePlugin({
146
147
  `definePlugin` checks the name and the presence of `setup`, then returns the typed object you gave it.
147
148
  Errors point to the plugin definition.
148
149
 
150
+ `hostAccess: true` marks a plugin whose tools reach the host's machine, such as one whose workers run shells or edit files (`pi-roundtable-coding` declares it).
151
+ A host that gives its agents [no shell](#chat-only-agents-no-shell-file-or-workspace-tools) refuses to start with such a plugin registered.
152
+
149
153
  A plugin whose `setup` returns `{}` and has no migrations, providers, or hooks stops startup (see [Errors](#errors-and-their-fixes)).
150
154
 
151
155
  `identities` is for a plugin that serves a credential of its own, such as the bearer token of an endpoint, and runs turns for whoever holds it.
@@ -165,7 +169,7 @@ A plugin that replaces `IDENTITY` replaces this too: the declarations are the bu
165
169
  | Field | What it is |
166
170
  |---|---|
167
171
  | `logger` | The host's logger with `plugin: <your name>` on every line; each line is JSON on stdout. Its type is `Logger` (`debug`, `info`, `warn`, `error`, `fatal`, `child(fields)`), which a pino logger satisfies |
168
- | `env` | The host's own environment for the run: `locale`, `timeZone` (an IANA zone), `now()`, `scratchDir` when the host has one, and `characterSkins`, the six skin tones the host draws its agents with ([host skins](#host-skins-the-charactersskins-option)); read the zone from here rather than from the process, give `scratchDir` to a shell the plugin runs itself, and pass `characterSkins` to `composeSvg` when the plugin draws a character |
172
+ | `env` | The host's own environment for the run: `locale`, `timeZone` (an IANA zone), `now()`, `scratchDir` when the host has one, `characterSkins`, the six skin tones the host draws its agents with ([host skins](#host-skins-the-charactersskins-option)), and `agentTools`, `"full"` or `"chat-only"` ([chat-only agents](#chat-only-agents-no-shell-file-or-workspace-tools)); read the zone from here rather than from the process, give `scratchDir` to a shell the plugin runs itself, and pass `characterSkins` to `composeSvg` when the plugin draws a character |
169
173
  | `database()` | The host's one PostgreSQL connection (a Bun `SQL`), already migrated |
170
174
  | `toolTiers` | What each tool needs; ask it when a tool is used, not during setup |
171
175
  | `events` | Where the core reports turns and team changes to every plugin's handlers |
@@ -235,17 +239,25 @@ The built-in plugins provide these, from the main entry:
235
239
  |---|---|---|---|
236
240
  | `IDENTITY` | `IdentityService` | `identity` | Who the host serves, read-only: `resolve(facts, { conversation }?)` gives the `Speaker` with its `principalId` behind a surface's `ActorFacts`, or undefined; `principal(id)`, `list()`, `identities(principalId)`, `roles(principalId)`, `tierOf(principalId)` (the lasting roles' tier), `speakerFor(principalId, tier?)` for a turn on someone's behalf, `owners()`, and `principalOf(identity)`, the principal an identity written `<provider>:<subject>` is linked to, such as one a plugin declares in `identities`. Principals are written only by the configuration and `roundtable principal`. `SYSTEM_PRINCIPAL` is the host's own, which no plugin speaks as |
237
241
  | `IDENTITY_LINKS` | `IdentityLinks` | `identity`, only with `access.selfLink` | Lets a plugin link a person's own account to their principal, which `IDENTITY` cannot: `link(principalId, identity)` and `unlink(identity)`, identities written `<provider>:<subject>`. See [Self-linking](#self-linking). Absent unless the configuration lists the providers; read it with `find` |
242
+ | `PROFILES` | `PersonProfiles` | `identity` | How the core names a person: `register(source)` a `ProfileSource` for a provider, `observed(principalId, provider, name)` a name a signed-in session reported, `sourceOf`, `choose`, `of(principalId)` (name, source, whether a picture exists) and `picture(principalId)` (the picture through the source's allowed hosts). Always on. See [Naming people: `PROFILES`](#naming-people-profiles) |
238
243
  | `CONVERSATIONS` | `ConversationRegistry` | `conversations` | The conversations run through `context.turns`: `register`, `get(key)`, `list({ principal }?)`, `setTitle(key, title)`, and `adopt(key, principalId)` only for migrating a conversation recorded before 0.9; each a `ConversationRecord` of `key`, `surface`, `kind`, `visibility` (`"private"` or `"shared"`), `principalId?`, `title?`, `createdAt`, `lastActiveAt` |
239
244
  | `MODES` | `ConversationModes` | `modes` | Each conversation's modes, stored per key and kept across restarts: `peek(channel)` gives its `ConversationMode` (`yolo` and `longRunning`, both off by default; `changedBy?`, `changedAt?`) from memory, `set(channel, { yolo?, longRunning? }, speaker)` changes them and throws `PluginError` unless the speaker is an owner other than `SYSTEM_PRINCIPAL`, `onChange(listener)` hears each change, and `autoApproved(entry)` with `onAutoApproved(listener)` report a held call that yolo mode approved. See [Conversation modes](#conversation-modes-modes) |
240
245
  | `RUNTIME` | `AgentRuntime` | `runtime` | The runtime every conversation turn runs on, the agent server's and `context.turns`': the `runtime` slot's when a plugin fills it, Pi's otherwise |
241
246
  | `AGENTS` | `AgentServer` | `agent-server` | The `team` (`AgentTeam`), the read-only `directory` (`AgentDirectory`), the `runtime` every agent turn runs on (the same one `RUNTIME` provides), `approvals` (whether the owner's reply approves held actions), `avatars` (`AvatarStudio`), and `workspace` (`AgentWorkspace`: the agents' `workDir` and, when set, `scratchDir`) |
242
- | `SKILLS` | `SkillRegistry` | `skills` (an addon) | What agents carry: `carried`, `carriedNames`, `describeCarried`, `catalog`, `list`, `linkedFrom`, `checkRegistered`, `link`, `attach` |
247
+ | `PROMPTS` | `PromptDocuments` | `prompts` | The prompt texts the owner edits in the app, each with a file version, an override and a history: `register`, `text`, `list`, `get`, `set`, `revert`, `history`. Always on; read it with `get`. See [`PROMPTS`: prompt texts the owner edits](#prompts-prompt-texts-the-owner-edits) |
248
+ | `SKILLS` | `SkillRegistry` | `skills` (an addon) | What agents carry: `carried`, `carriedNames`, `describeCarried`, `catalog`, `list`, `linkedFrom`, `checkRegistered`, `link`, `attach`, and the owner's whole-SKILL.md edits `document`, `saveDocument`, `createDocument`, `documentHistory` |
243
249
  | `SCHEDULES` | `ScheduleStore` | `schedule-store` | The stored schedules: `create`, `createWithin`, `get`, `forChannel`, `all`, `update`, `remove`, `due`, `claim`, `recordStatus` |
244
250
  | `PRECHECKS` | `PrecheckRegistry` | `prechecks` | The host's named [prechecks](#prechecks-wake-a-schedule-only-when-it-has-work): `register`, `get`, `list`; and the runner of agents' precheck scripts: `useScriptRunner`, `scriptRunner` |
245
251
  | `MEMORY` | `MemoryStore` | `memory` (an addon) | `forSpeaker(principalId)` gives that principal's `SpeakerMemory`: `list`, `forPrompt`, `add`, `search`, `update`, `removeById`, `remove`; `MEMORY_KINDS` is `core`, `note`, `event` |
246
252
  | `BACKGROUND_TURNS` | `BackgroundTurns` | `modules` | Turns nobody wrote: `runScheduled`, `runDelegated`, `runErrorReport` |
253
+ | `CHANNEL_CONTEXT` | `pi-roundtable` | value |
254
+ | `ChannelContexts` | `pi-roundtable` | type |
255
+ | `ChannelContextSource` | `pi-roundtable` | type |
256
+ | `ChannelContextSourceAttachment` | `pi-roundtable` | type |
257
+ | `ChannelContextVerdict` | `pi-roundtable` | type |
247
258
  | `DELEGATION` | `Delegator` | `modules` | `start(request)` a background task, `runningChannels()`, `idle()` |
248
259
  | `THREADS` | `DispatchThreads` | `threads` | The threads of background dispatches (coding tasks, delegated tasks, messages between agents), on any surface that hosts them: `open(origin, title, intro?)` opens the thread under the channel `origin` through the `threads` of the surface that serves its prefix and resolves to a `DispatchThread`, or `undefined` when that surface hosts none or the channel cannot. See [Dispatch threads on any surface](#dispatch-threads-on-any-surface) |
260
+ | `CHANNEL_CONTEXT` | `ChannelContexts` | `channel-context` | `read(message, options?)` gives the context of one addressed message, from the surface whose key prefix its channel carries: the messages posted there since the assistant last posted, or `undefined` for a surface without `channelContext`, a channel that is not shared, and when nothing new was said. See [Channel context](#channel-context-what-was-said-around-an-addressed-message) |
249
261
  | `WORKER_SESSIONS` | `WorkerSessions` | `worker-sessions` | Fresh Pi sessions for a plugin's own workers, on the host's model login: `delegation` is the host's delegation `model` and `thinking`, and `run({ task, signal, prompt, extensions, toolNames, model?, thinking?, aborted? })` runs the task in a session that has only the tools `extensions` register and `toolNames` names (no built-in tool, skill, context file, memory or agent tool) and returns the worker's final answer; see [Workers of your own](#workers-of-your-own) |
250
262
 
251
263
  The data types the ports use (`Schedule`, `NewSchedule`, `Agent`, `AgentGroup`, `TeamStatus`, `Memory`, `SkillSet`, `DelegationJob`, and so on) are in the main entry too.
@@ -466,6 +478,21 @@ Owner is granted only through configuration or the principal CLI, never an IdP r
466
478
  A map gives the mode per surface, such as `provisioning: { web: "admitted", discord: "linked" }`: with `members.everyone: ["web", "discord"]`, a Discord id someone linked is served at member, an unlinked one is refused and not recorded, and web admits as before. A surface the map leaves out is `linked`, so a misspelled name cannot admit everyone; the one-word form and an omitted field stay `admitted`. `roundtable doctor` warns about a map key that is not a known surface and prints the effective mode.
467
479
  A disabled principal is refused, and CLI changes reach a running host within the identity cache's 30 seconds.
468
480
 
481
+ ### Reading who someone is without recording it: `peek` and `partial`
482
+
483
+ `IDENTITY.resolve(facts)` is the call for a contact that happens: it links a first contact, may admit a new principal, and records the person as seen at their tier.
484
+ `IDENTITY.peek(facts, { conversation }?)` only reads.
485
+ It never links, claims, admits or records, also when it refuses, and it gives the tier `resolve` would give now: the principal's lasting roles together with what the access rules give these facts, never the tier last recorded.
486
+ It answers undefined for an identity nothing is linked to, for a disabled principal, and when no tier results.
487
+ Use it where a request must be checked again and again, such as a web session, so the check leaves no trace; a first contact still goes through `resolve`.
488
+ A plugin that replaces `IDENTITY` adds `peek` to its service.
489
+
490
+ A surface that cannot see everything a person holds reports `partial: true` in its `ActorFacts`.
491
+ The facts then show less than the person may hold, so `resolve` never records a lower tier from them and never records them as a refusal, as it does for Discord facts that lack the roles the rules decide by.
492
+ The owner app sets it on every app sign-in, because an identity provider's roles for another application say nothing about this one.
493
+
494
+ `oidcProvider(issuer)` and `oidcIdentity(issuer, subject)` from `pi-roundtable/kit` write the provider `oidc:<base64url issuer>` and the identity `oidc:<base64url issuer>:<subject>` that `access.owners[].identities`, `selfLink.providers` and `ActorFacts.provider` take.
495
+
469
496
  ### Self-linking
470
497
 
471
498
  `access: { selfLink: { providers: ["discord"] } }` lets a plugin link the account a person controls to their principal.
@@ -612,6 +639,45 @@ test("an unavailable principal is refused even at owner tier", async () => {
612
639
 
613
640
  For existing hosts and compatibility aliases, read [Migrating to 0.9](migration-0.9.md).
614
641
 
642
+ ### Naming people: `PROFILES`
643
+
644
+ A person's linked accounts each carry a name and maybe a picture, and a host decides which one represents them.
645
+ `PROFILES` (provided by the `identity` plugin, always) holds the sources and the choices:
646
+
647
+ ```ts
648
+ setup: ({ services }) => {
649
+ services.get(PROFILES).register({
650
+ provider: "github",
651
+ label: "GitHub",
652
+ modelVisible: false,
653
+ pictureHosts: ["avatars.githubusercontent.com"],
654
+ fetch: async (login, signal) => {
655
+ const user = await (await fetch(`https://api.github.com/users/${login}`, { signal })).json();
656
+ return { name: user.name ?? login, pictureUrl: user.avatar_url };
657
+ },
658
+ });
659
+ }
660
+ ```
661
+
662
+ - **Who names a person.** Their stored choice (`choose(principalId, provider)`, which refuses a provider they have not linked; `null` returns to the default), else the first provider in `profiles.prefer` they have linked, else nobody, and `of` falls back to the principal's display name.
663
+ - **Observed names.** A surface that signs a person in reports the name its identity provider gave (`observed`), so an OIDC issuer names people with no fetch. It is held in memory for an hour and renewed by each sign-in.
664
+ - **A fetch** has two seconds. A miss, a failure or a timeout is remembered for ten minutes, so the name on the hot path costs the wait at most once per person per ten minutes. Fetched and observed names and picture addresses stay in memory for an hour at most and are written to no table; only the choice (`principal_profile_choices`) is stored.
665
+ - **What agents read.** `Speaker.name` from `IDENTITY.resolve`, `peek` and `speakerFor` is the profile name only for a provider the host lists in `profiles.modelVisible`; the source's own `modelVisible` flag never decides it. With no source, no observed name and an empty list nothing changes. Names are made safe first: control characters become spaces and at most 80 characters remain.
666
+ - **Pictures.** `picture(principalId)` fetches only an `https` address on the source's `pictureHosts`, follows no redirect, accepts PNG, JPEG, GIF and WebP only (an SVG is refused), at most 2 MiB, and answers `nosniff` and `private, max-age=3600`. The bytes stay in memory for an hour, and a failure is remembered for ten minutes.
667
+
668
+ ```ts
669
+ export default defineRoundtable({
670
+ // ...
671
+ profiles: {
672
+ prefer: ["line"], // LINE names whoever linked it and has not chosen
673
+ modelVisible: ["line"], // and agents read that name
674
+ },
675
+ });
676
+ ```
677
+
678
+ `pi-roundtable-line` ships `lineProfileSource(services.get(LINE).api)`, a source for provider `line` over LINE's `/v2/bot/profile/{userId}` (picture hosts `profile.line-scdn.net` and `obs.line-scdn.net`, `modelVisible: false`).
679
+ `roundtable principal show` prints the source a person chose.
680
+
615
681
  ## Tiers: who may use what
616
682
 
617
683
  Every turn has a speaker with a tier: `owner`, `admin`, or `member`, from most to least trusted.
@@ -1093,6 +1159,37 @@ const shell = shellHoldRuleFor({
1093
1159
  });
1094
1160
  ```
1095
1161
 
1162
+ ### `PROMPTS`: prompt texts the owner edits
1163
+
1164
+ A prompt that comes from a file or the configuration can be made editable by the owner without touching the file.
1165
+ Register a document during `setup`; read its text with `text()` whenever you build a prompt:
1166
+
1167
+ ```ts
1168
+ import { definePlugin, PROMPTS } from "pi-roundtable";
1169
+
1170
+ export const persona = definePlugin({
1171
+ name: "persona",
1172
+ setup: ({ services }) => {
1173
+ const merlin = services.get(PROMPTS).register({
1174
+ id: "merlin",
1175
+ label: "Merlin's prompt",
1176
+ description: "Who Merlin is in the owner's conversations.",
1177
+ default: () => readFileSync("persona/merlin.md", "utf8"),
1178
+ });
1179
+ return { personas: [{ kind: "owner", prompt: () => merlin.text() }] };
1180
+ },
1181
+ });
1182
+ ```
1183
+
1184
+ - `default` is the file version: a string, or a function called at each read so a default that follows a file is current. The id is 1 to 32 lowercase letters, digits and hyphens; a repeated or unusable id is an `AgentError`, and so is a function `default` that throws when the document is registered, so the host does not start with it. `maxChars` defaults to 20,000.
1185
+ - The core registers `shared` (`prompts.shared`, the base every agent's prompt starts with) and `guest` (`prompts.guest`, used when the speaker is not the owner). A plugin's own documents are registered by that plugin; the plugin that registers them is set up after `prompts`, so it can `get` the service.
1186
+ - **The override.** `set(id, text, by, base?)` stores the owner's text in PostgreSQL (`prompt_overrides`, with `prompt_history` beside it; both are additive migrations). While an override is set it wins over the file version, and it is read at every turn, so a change applies from the next turn without a restart. `revert(id, by, base?)` drops it and the file version is back. Text equal to the file version reverts instead of storing a copy. A document has `text`, `fileText`, `overridden`, `differs` (set and not equal to the file) and `override: { by, at }`.
1187
+ - **Edits from a stale view.** `base` is the text the change was edited from. When it is given, it is compared with the text in force inside the write queue, where the write takes its turn, so two saves from the same text give one write; the other is refused with `PromptConflictError`, whose `current` is the document as it is now. A call without `base` writes as before.
1188
+ - **A file that stops reading.** When a function `default` throws later (the file was removed), nothing quietly carries on with an old text: `text(id)` throws an `AgentError` naming the document unless an override is set, and logs an error with the document's id; the persona or agent prompt that asked stops the run it was read for (an error in the log, the turn gets no reply) instead of running with the text it was built with, or with none. `list()` and `get()` still answer for every document: the broken one has `broken` (the reason), an empty `fileText` and `differs: false`, and `set` and `revert` refuse it until the file reads again.
1189
+ - **History.** `history(id, limit?)` lists each change newest first: `action` (`set` or `revert`), `text`, the `previous` text, `by` and `at`. An agent's own prompt keeps the same kind of history through `AgentTeam.promptHistory(name)`; `AgentTeam.update(name, change, by?)` records `by`. A directory agent has no override layer: its stored row is the source, and a seed does not overwrite an existing row.
1190
+ - **On by default.** The override layer is always on, because nothing is overridden until the owner saves one and the file stays the default. The host logs one warning per document that differs from its file version when it starts, naming who set it and when, and the web app shows a "differs from the file version" badge. Deleting the rows (or using **Revert to the file version**) returns the host to its files.
1191
+ - `PromptDocuments.set` and `revert` do not check who calls: the owner-only check belongs to the caller, as the web app's routes do it.
1192
+
1096
1193
  ### `prompt`: text added to every agent turn
1097
1194
 
1098
1195
  Each section's `build` gets the agent, the speaker (undefined between turns), and the turn's scope.
@@ -1193,6 +1290,7 @@ export const library = definePlugin({
1193
1290
  | `turnEnded(turn)` | The turn ended; `turn.result` is `"ok"`, `"failed"`, or `"stopped"` |
1194
1291
  | `turnProgress(event)` | A turn run through `context.turns` wrote text or ran a tool, between its start and its end: `event` is the turn's fields and `progress`, a `TurnProgress` (see the surface's `progress` below); a runtime without live progress reports none |
1195
1292
  | `changed()` | The team changed: an agent or group was created, edited, arranged, archived, or started over |
1293
+ | `action(event)` | A plugin reported something a person did through its surface with `context.events.action?.(...)`: `event` is `{ kind, actor, principalId, channel?, ok, at }`, with `kind` in the reporting plugin's own words (a support inbox says `"assign"`, `"reply"` or `"close"`), `actor` the `Speaker.id`, and `channel` a `ChannelKey` or a `page:<name>`. It carries no content. The core reports none itself; a plugin hears the others' |
1196
1294
  | `shutdown(left)` | The shutdown drain ended, before any service stops; `left` lists the work it gave up on |
1197
1295
 
1198
1296
  The host logs errors thrown by a handler and continues running the others.
@@ -2085,6 +2183,48 @@ export function alwaysOn(tools: () => string[]) {
2085
2183
  ```
2086
2184
  <!-- /example -->
2087
2185
 
2186
+ ### Chat-only agents: no shell, file, or workspace tools
2187
+
2188
+ By default an agent's turn carries Pi's shell and file tools (`bash`, `read`, `edit`, `write`) and works in a shared workspace on the host.
2189
+ A host whose agents only talk, such as one that serves a team through a web app and has no use for a shell, switches them off:
2190
+
2191
+ ```ts
2192
+ export default defineRoundtable({
2193
+ // ...
2194
+ agentServer: { tools: "chat-only" },
2195
+ });
2196
+ ```
2197
+
2198
+ The agent server stays, with its team, rooms, memory, schedules and the tools plugins add; `agentServer: false` still leaves it out, and a key left out keeps the full tools.
2199
+ Plugins read the mode as `context.env.agentTools` (`"full"` or `"chat-only"`), and `ResolvedConfig.agentTools` and `RoundtableOptions.environment.agentTools` carry it for a host that builds its own options.
2200
+ With `"chat-only"`:
2201
+
2202
+ - No turn is offered `bash`, `read`, `edit` or `write`: the agent team's selection leaves them out, an agent's system prompt says it has no shell, file tools or workspace, its sessions get no workspace (no `attach_file`, no shell scratch dir) and its held-action gate judges none.
2203
+ - The host refuses to start with a plugin that declares `hostAccess: true` (`pi-roundtable-coding` does), with a `ConfigError` that names the plugin, and with a plugin whose `agentSelection` names one of the four tools. `roundtable doctor` prints `agent tools: chat-only (no shell, file or workspace tools)` and fails for a registered `hostAccess` plugin; with the full tools it prints `full`.
2204
+ - A selection that names one of the four tools anyway, whatever built it (a turn run through `context.turns` with its own `selection`, a plugin that grants tools at run time), is refused at the one place where `SessionFactory` turns a selection into active tools (`toolsFor` and `taskTools`): the turn fails closed with a `ConfigError`, which is logged as the turn's failure, and no session runs it.
2205
+ - `pi-roundtable-webchat` stops at start when a persona's own `selection` names one of the four tools.
2206
+
2207
+ The mode is the host's decision, not a permission: it does not change who may use which tool (`toolTiers`), and an MCP server or other plugin that reaches the machine in a way it does not declare is outside what it can see.
2208
+ A plugin whose tools reach the host declares `hostAccess`, so that the one flag decides.
2209
+
2210
+ ### Conversation retention: `conversations.keepDays`
2211
+
2212
+ The conversations run through `context.turns` are recorded (`CONVERSATIONS`).
2213
+ `conversations.keepDays` deletes the idle ones of a kind after a number of days, so a host that must not keep a kind of conversation for ever, such as support threads, does not:
2214
+
2215
+ ```ts
2216
+ export default defineRoundtable({
2217
+ // ...
2218
+ conversations: { keepDays: { support: 365, chat: 30 } },
2219
+ });
2220
+ ```
2221
+
2222
+ The key is a conversation's kind (`ConversationRecord.kind`) and the value a whole number of days from 1 up, counted from the conversation's last activity.
2223
+ A sweep runs when the host starts and once a day after: for each recorded conversation of a listed kind past its days, it calls `ConversationPort.deleteConversation`, which its claim carries out, and then forgets the record (`ConversationRegistry.remove`, optional on a hand-built registry).
2224
+ A conversation with a turn running or waiting (`"busy"`) is skipped and tried again at the next sweep; one whose claim cannot delete it is logged and kept.
2225
+ A kind not listed, and a host without the key, keeps its conversations as before.
2226
+ The agents' own channels are not recorded conversations and are not touched.
2227
+
2088
2228
  ### `piPackages`: Pi extensions every session loads
2089
2229
 
2090
2230
  List npm packages whose Pi extensions every conversation session should load, and install them in your project (`bun add pi-web-access`).
@@ -2488,6 +2628,8 @@ Channel context lets the turn for an addressed message also read what was posted
2488
2628
  The agent server reads it for every turn in an agent's channel; a group's round does not, since it already carries what was said since the group's last turn.
2489
2629
  A plugin's claim opts in by calling `DISCORD.channelContext(message, options?)` from `pi-roundtable/discord` inside its turn's `run`, and appending the result with `withChannelContext(text, context, imagesKept?)`, as `withReference` adds the replied-to message.
2490
2630
 
2631
+ The same call works on any surface. A chat surface a plugin contributes implements the optional `ChatSurface.channelContext(message, options?)`, and `CHANNEL_CONTEXT.read(message, options?)` (from the main entry) picks the surface by the prefix of the message's channel key, as `THREADS` does, and returns what that surface returns; a surface without the method, a channel no surface serves and a message with nothing new all give `undefined`. A support inbox whose rooms are email threads, say, answers with the customer's messages since the assistant last replied, each marked `external: true` (see below). `DISCORD.channelContext` is the Discord surface's own answer to the same call, so a claim that reads either sees the same context. `ChannelContext`, `ChannelContextOptions`, `ChannelContextMessage`, `withChannelContext`, `withChannelContextImages` and `formatChannelContext` stay in the `pi-roundtable/discord` entry, which a plugin for another surface imports from too; `CHANNEL_CONTEXT`, `ChannelContexts`, `ChannelContextSource`, `ChannelContextSourceAttachment` and `ChannelContextVerdict` are in the main entry.
2632
+
2491
2633
  It reads Discord, not the host's history, so it survives restarts:
2492
2634
 
2493
2635
  - It fetches the `fetch` messages before the addressed one (default 50, at most 100) and keeps those after the assistant's latest post there: its bot user's, or one of its application's webhooks, such as an agent's voice. Any post of the assistant ends the window, including the divider the core posts when a conversation starts over (see `discord.freshMarker`) and a notice that it did; a fresh-start reply must be a public post, not an ephemeral one, to cut the window.
@@ -2497,14 +2639,16 @@ It reads Discord, not the host's history, so it survives restarts:
2497
2639
  - It keeps the newest `keep` (default 15), oldest first. Mentions read as names, and stickers and files as text with their names and links; by default nothing is downloaded or shown to the model as an image (see `attachments` below). A message is cut at `messageChars` (default 500), another bot's at `botMessageChars` (default 80).
2498
2640
  - It never fails the turn over Discord: a Discord error, such as a channel the bot cannot read, is logged and the turn runs without context.
2499
2641
 
2500
- A plugin may mirror an outside chat into a channel through a webhook of its own, created by the bot, so that the application owns it; by default such a post counts as the assistant's own, which ends the window and is left out, and the people mirrored never reach a turn. `foreignWebhook(webhookId)` changes that for the webhooks it returns true for: a post through one is another author's, kept like another bot's (`bot: true`, `authorName` the post's username, `authorId` the webhook's id) and no longer ends the window, which then ends only at the assistant's own posts: its bot user's and every webhook `foreignWebhook` returns false for, such as an agent's voice. It may also return `"skip"` for a webhook that is neither, such as one the plugin posts its own status notices through: a post through it is dropped from the context and does not end the window, as if it were not in the channel, so notices between two mirrored posts neither appear as someone's words nor cut the window, and they are not counted among the messages delivered. Any answer other than `true` and `"skip"` reads as false. A skipped post is still one of the `fetch` messages read, so it uses the budget the window is chosen from. The host sets it as `discord.channelContext.foreignWebhook` and a call overrides it for that call; being a function, it can be set only in code. Posts through a webhook that `foreignWebhook` returns true for are merged as near repeats only when the poster's name is the same too, since one webhook carries many people; every other message merges by author alone, as before. An edited message reads as Discord now holds it.
2642
+ A plugin may mirror an outside chat into a channel through a webhook of its own, created by the bot, so that the application owns it; by default such a post counts as the assistant's own, which ends the window and is left out, and the people mirrored never reach a turn. `foreignWebhook(webhookId)` changes that for the webhooks it returns true for: a post through one is another author's, kept like another bot's (`bot: true`, `authorName` the post's username, `authorId` the webhook's id) and no longer ends the window, which then ends only at the assistant's own posts: its bot user's and every webhook `foreignWebhook` returns false for, such as an agent's voice. It may also return `"skip"` for a webhook that is neither, such as one the plugin posts its own status notices through: a post through it is dropped from the context and does not end the window, as if it were not in the channel, so notices between two mirrored posts neither appear as someone's words nor cut the window, and they are not counted among the messages delivered. Any answer other than `true` and `"skip"` reads as false. A skipped post is still one of the `fetch` messages read, so it uses the budget the window is chosen from. The host sets it as `discord.channelContext.foreignWebhook` and a call overrides it for that call; being a function, it can be set only in code. `authors(message)` is the same decision for any surface and any message, not only a webhook's: it receives the fetched message (`ChannelContextSource`: `id`, `authorId`, `authorName`, `bot`, `own`, `webhookId?`, `external?`, `attachments?`, `at`, `text`) and answers `"keep"` (another author's, which does not end the window; `foreignWebhook` returning `true`), `"skip"` (dropped as if it were not in the channel, without ending the window; `"skip"` of `foreignWebhook`), `"end"` (the assistant's own, which ends the window and is left out; `foreignWebhook` returning `false` for an application webhook) or `undefined`, which leaves the default: the assistant's own posts end the window and everything else is kept. Where it answers, it wins over `foreignWebhook`; it is a function, so it can be set only in code, as a host default (`discord.channelContext.authors`) or per call. Posts through a webhook that `foreignWebhook` returns true for are merged as near repeats only when the poster's name is the same too, since one webhook carries many people; every other message merges by author alone, as before. An edited message reads as Discord now holds it.
2643
+
2644
+ A message a surface knows to come from outside the people the host serves, such as a customer whose email a support inbox mirrors, is marked `external: true` (`ChannelContextMessage.external`, `ChannelContextSource.external`): it is never an owner, and the block labels it `role="external"`, the model's cue that its text is untrusted. Discord marks none.
2501
2645
 
2502
2646
  Every returned message has `attachments` (`ChannelContextAttachment`: `name`, `contentType`, `size`, `url`, `messageId`), whether or not anything was fetched. With `attachments: { images, maxBytes }` (`ChannelContextAttachmentOptions`; default none) a call also fetches up to `images` (at most 4, a turn's limit) PNG, JPEG, GIF or WebP images of the kept messages, the newest first, each at most `maxBytes` (default 10 MiB, at most 25 MiB), and returns them as `context.images`, prepared as a turn's own images are (large ones are scaled down) and kept in memory by this call and never logged; once a turn is given them they are in its session history like the turn's own images, and stay there. Pass them on with `withChannelContextImages(attachments, context)`, which returns `{ attachments, kept }`: the turn's attachments with the context's images after the turn's own, within the four a turn carries, and how many of them fitted (a turn that already carries four keeps none, and an image crowded out is not noted anywhere). Give `kept` to `withChannelContext(text, context, kept)`: above zero it adds a line that pictures are shown among the turn's images, and it adds none otherwise, so the block never claims images the turn does not have. The image is sent as the type its bytes show (PNG, JPEG, GIF or WebP), not as Discord's guess from the file name, and bytes of none of them are skipped as `unsupported type`. Up to two images are downloaded at once, within 20 seconds for the whole call; the images still to be fetched when it passes are skipped as `download failed`. An image that is not a type the model takes, is over `maxBytes`, cannot be downloaded or cannot be decoded is skipped, and its message's text says `[image not attached: <name>: <reason>]`, the reason being `unsupported type` (by Discord's type or by the bytes), `too large`, `download failed` or `could not be decoded`; skipped images do not count toward `images`, but at most twice that many are tried. An image of a message already handed over is not fetched again, since its message is not returned again.
2503
2647
 
2504
2648
  `DISCORD.channelContext` resolves undefined in a direct message, on another surface, when nothing new was said, and when the host's `discord.channelContext` is `false`, which turns it off for the agent server and every claim.
2505
2649
  The configuration's values are the host's defaults, and a call's `options` override them for that call.
2506
2650
  Call it once per answered message: what it returns is remembered as handed over.
2507
- A call's `options` are validated like the configuration (`fetch`, `keep`, `messageChars` and `botMessageChars` positive integers, `similarity` from 0 to 1, `foreignWebhook` a function of a webhook id returning `true`, `false` or `"skip"`, `attachments.images` an integer from 0 to 4, `attachments.maxBytes` an integer from 1 to 25 MiB); an invalid one makes the call reject, a programming error, so `keep: 0` is not "none" (skip the call for that).
2651
+ A call's `options` are validated like the configuration (`fetch`, `keep`, `messageChars` and `botMessageChars` positive integers, `similarity` from 0 to 1, `foreignWebhook` a function of a webhook id returning `true`, `false` or `"skip"`, `authors` a function of a message returning `"keep"`, `"skip"`, `"end"` or `undefined`, `attachments.images` an integer from 0 to 4, `attachments.maxBytes` an integer from 1 to 25 MiB); an invalid one makes the call reject, a programming error, so `keep: 0` is not "none" (skip the call for that).
2508
2652
  It remembers what it handed over and what addressed the assistant, not every message that ran a turn: a post a claim answered some other way, such as a webhook report turn or a steered message, can also come in the context of a later turn if the assistant has not posted since.
2509
2653
 
2510
2654
  What it returns is public channel text, visible to everyone in the channel; it is not private memory, carries no `privateTo`, and goes into the turn's text, so it lives in the conversation's history, not in the system prompt.
@@ -2522,10 +2666,10 @@ Discord does not render Markdown tables, so the Discord surface tells the model
2522
2666
  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.
2523
2667
  `discord.formatting` replaces the text; `false` tells the model nothing.
2524
2668
 
2525
- 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.
2526
- `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).
2669
+ The block gives each author as a name, their id and a role (`owner`, `bot`, `external` 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.
2670
+ `ChannelContext` is `{ messages, text, images? }`: `messages` are `ChannelContextMessage`s (`id`, `authorId`, `authorName`, `bot`, `owner`, `external?`, `text`, `at`, `attachments`), oldest first, `text` is the block, and `images` the fetched images (see above).
2527
2671
  `ChannelContextOptions` holds the options above, and `CHANNEL_CONTEXT_DEFAULTS` their defaults.
2528
- `formatChannelContext(messages)` is the function that writes `ChannelContext.text`, exported so a host can test what the model sees without reaching into the core: it takes `ChannelContextMessage`s (only `authorId`, `authorName`, `bot`, `owner` and `text` are read) and returns the block, headed and delimited by `<channel-context>`. Each message is a `<message from="…" id="…" role="owner|member|bot">` line whose author is in the attributes, never in the text, and every `<` of a name or text (plain or full-width) is replaced by `‹`, so no message can pass as markup. It does not fetch, cut or select anything, and it is the same function the reader calls, so a block it returns is the block a turn carries.
2672
+ `formatChannelContext(messages)` is the function that writes `ChannelContext.text`, exported so a host can test what the model sees without reaching into the core: it takes `ChannelContextMessage`s (only `authorId`, `authorName`, `bot`, `owner`, `external` and `text` are read) and returns the block, headed and delimited by `<channel-context>`. Each message is a `<message from="…" id="…" role="owner|member|bot|external">` line whose author is in the attributes, never in the text, and every `<` of a name or text (plain or full-width) is replaced by `‹`, so no message can pass as markup. It does not fetch, cut or select anything, and it is the same function the reader calls, so a block it returns is the block a turn carries.
2529
2673
  In a test, `fakeDiscord({ channelContext })` answers `DISCORD.channelContext` as the test says; by default with no context.
2530
2674
 
2531
2675
  <!-- example: examples/channel-context.ts -->
@@ -3247,6 +3391,7 @@ A host that keeps Discord may write it as an adapter instead, as [`adapters`](#a
3247
3391
  - No channel is made, checked or arranged, and the dashboard (a Discord message) does not exist. `channel_arrange` and `channel_read` answer that the host has no Discord channels. `agent_create` and `group_create` make no channel, and a new agent starts its opening task in its room on the first surface that hosts rooms. Archiving an agent or group stops its rooms from answering.
3248
3392
  - An agent's pictures have no public address unless `http.publicUrl` is set: `AGENTS.avatars.url(hash)` is then the path `/avatars/<hash>.png`, which the surface serves itself through `AGENTS.avatars.serve(pathname)` under its own origin, as the web console does. With `http.publicUrl` they are served on the `public` listener as with Discord.
3249
3393
  - `AgentTeam.channelOf(name, near?)` answers with the agent's room on the surface of `near` (the asker's own conversation) when that surface hosts rooms, so `schedule_list` from a room reads the room's schedules; for an agent with no Discord channel it falls back to its room on the first surface that hosts rooms. The status the dashboard and the web read counts an agent's schedules, waiting turns and context use across its Discord channel and its rooms.
3394
+ - A surface that lets the owner edit the team uses the same operations the agent tools do, from outside any agent turn: `AgentTeam.createAgent(speaker, input)` (the fields of `agent_create`, plus `model` and `thinking`, which are checked before anything is made; without a `task` the agent only exists, and `openIn` names the surface whose room runs the opening task instead of the Discord channel), `archive(undefined, name)` (an owner has no calling agent, so only the coordinator is protected), `restore(name)`, `remove(name, cleanup?)` (an archived agent or group only, and never an agent the host's seeds name, which the next start would seed again; it runs `cleanup`, your surface's part of the delete, after its checks and before deleting anything, so a cleanup that throws leaves the target in place to try again; then it frees the name, detaches the agent's skills, clears its group cursors and makes the runtime forget the conversations the name kept, and leaves a Discord channel under Archive), and `createGroup` and `updateGroup`. All of them throw `AgentError` with the reason, and fire the team's change signal.
3250
3395
  - Breaking: `AgentGroup.channelId` is optional (`string | undefined`), and so is `GroupStatus.channelId`; a plugin that reads them handles a group without a Discord channel. The `agent_groups.channel_id` column drops its `NOT NULL` in a migration of the agent server (`agent-groups-channel-optional`), and `AgentServerOptions.guildId` and `entryChannelId` are optional, with `coordinator` added. `AvatarStudioOptions.publicUrl` is optional.
3251
3396
 
3252
3397
  <!-- example: examples/headless.ts -->
@@ -4020,6 +4165,7 @@ Import from the entries listed below; source area files are internal.
4020
4165
  | `AccessRules` | `pi-roundtable` | type |
4021
4166
  | `AccessTier` | `pi-roundtable` | type |
4022
4167
  | `AccessTierConfig` | `pi-roundtable` | type |
4168
+ | `ActionEvent` | `pi-roundtable` | type |
4023
4169
  | `ActorFacts` | `pi-roundtable` | type |
4024
4170
  | `AGENTS` | `pi-roundtable` | value |
4025
4171
  | `AGENT_SERVER_PLUGIN` | `pi-roundtable` | value |
@@ -4038,6 +4184,7 @@ Import from the entries listed below; source area files are internal.
4038
4184
  | `AgentSessions` | `pi-roundtable` | type |
4039
4185
  | `AgentStatus` | `pi-roundtable` | type |
4040
4186
  | `AgentTeam` | `pi-roundtable` | type |
4187
+ | `AgentTools` | `pi-roundtable` | type |
4041
4188
  | `AgentTurnScope` | `pi-roundtable` | type |
4042
4189
  | `Approval` | `pi-roundtable` | type |
4043
4190
  | `ApprovalDetails` | `pi-roundtable` | type |
@@ -4061,6 +4208,7 @@ Import from the entries listed below; source area files are internal.
4061
4208
  | `AvatarMode` | `pi-roundtable` | type |
4062
4209
  | `AgentWorkspace` | `pi-roundtable` | type |
4063
4210
  | `AvatarStudio` | `pi-roundtable` | type |
4211
+ | `PROMPTS` | `pi-roundtable` | value |
4064
4212
  | `characterOf` | `pi-roundtable` | value |
4065
4213
  | `BACKGROUND_TURNS` | `pi-roundtable` | value |
4066
4214
  | `CONVERSATIONS` | `pi-roundtable` | value |
@@ -4089,6 +4237,7 @@ Import from the entries listed below; source area files are internal.
4089
4237
  | `ConversationRegistration` | `pi-roundtable` | type |
4090
4238
  | `ConversationRegistry` | `pi-roundtable` | type |
4091
4239
  | `ConversationVisibility` | `pi-roundtable` | type |
4240
+ | `ConversationsConfig` | `pi-roundtable` | type |
4092
4241
  | `ConversationTurnInput` | `pi-roundtable` | type |
4093
4242
  | `ConversationTurns` | `pi-roundtable` | type |
4094
4243
  | `DELEGATION` | `pi-roundtable` | value |
@@ -4162,6 +4311,7 @@ Import from the entries listed below; source area files are internal.
4162
4311
  | `ModeChange` | `pi-roundtable` | type |
4163
4312
  | `ModelImage` | `pi-roundtable` | type |
4164
4313
  | `NO_ATTACHMENTS` | `pi-roundtable` | value |
4314
+ | `NewAgent` | `pi-roundtable` | type |
4165
4315
  | `NewSchedule` | `pi-roundtable` | type |
4166
4316
  | `NotLinkedError` | `pi-roundtable` | value |
4167
4317
  | `OWNER_TARGET` | `pi-roundtable` | value |
@@ -4173,15 +4323,25 @@ Import from the entries listed below; source area files are internal.
4173
4323
  | `OwnerQuestion` | `pi-roundtable` | type |
4174
4324
  | `PendingConfirmation` | `pi-roundtable` | type |
4175
4325
  | `PerPrincipalLimits` | `pi-roundtable` | type |
4326
+ | `PersonDisplay` | `pi-roundtable` | type |
4327
+ | `PersonProfiles` | `pi-roundtable` | type |
4176
4328
  | `Persona` | `pi-roundtable` | type |
4177
4329
  | `PluginContext` | `pi-roundtable` | type |
4178
4330
  | `PluginIdentity` | `pi-roundtable` | type |
4179
4331
  | `PluginError` | `pi-roundtable` | value |
4180
4332
  | `Principal` | `pi-roundtable` | type |
4333
+ | `ProfileRules` | `pi-roundtable` | type |
4334
+ | `ProfileSource` | `pi-roundtable` | type |
4335
+ | `ProfilesConfig` | `pi-roundtable` | type |
4336
+ | `PromptDocumentSpec` | `pi-roundtable` | type |
4337
+ | `PromptDocuments` | `pi-roundtable` | type |
4338
+ | `PromptDocumentView` | `pi-roundtable` | type |
4339
+ | `PromptHandle` | `pi-roundtable` | type |
4181
4340
  | `PromptMemory` | `pi-roundtable` | type |
4182
4341
  | `PromptScope` | `pi-roundtable` | type |
4183
4342
  | `PromptSection` | `pi-roundtable` | type |
4184
4343
  | `PromptTurn` | `pi-roundtable` | type |
4344
+ | `PromptVersion` | `pi-roundtable` | type |
4185
4345
  | `PromptWait` | `pi-roundtable` | type |
4186
4346
  | `Prompts` | `pi-roundtable` | type |
4187
4347
  | `Pronouns` | `pi-roundtable` | type |
@@ -4212,6 +4372,7 @@ Import from the entries listed below; source area files are internal.
4212
4372
  | `RuntimeFactory` | `pi-roundtable` | type |
4213
4373
  | `SCHEDULES` | `pi-roundtable` | value |
4214
4374
  | `PRECHECKS` | `pi-roundtable` | value |
4375
+ | `PROFILES` | `pi-roundtable` | value |
4215
4376
  | `RUNTIME` | `pi-roundtable` | value |
4216
4377
  | `PRECHECK_TIMEOUT_MS` | `pi-roundtable` | value |
4217
4378
  | `Precheck` | `pi-roundtable` | type |
@@ -4224,6 +4385,9 @@ Import from the entries listed below; source area files are internal.
4224
4385
  | `PrecheckScriptRunner` | `pi-roundtable` | type |
4225
4386
  | `PrecheckTool` | `pi-roundtable` | type |
4226
4387
  | `PRECHECK_SCRIPT_CHARS` | `pi-roundtable` | value |
4388
+ | `PromptConflictError` | `pi-roundtable` | value |
4389
+ | `SkillConflictError` | `pi-roundtable` | value |
4390
+ | `SkillDocument` | `pi-roundtable` | type |
4227
4391
  | `SKILLS` | `pi-roundtable` | value |
4228
4392
  | `SINGLE_QUESTION` | `pi-roundtable` | value |
4229
4393
  | `Schedule` | `pi-roundtable` | type |
@@ -4244,6 +4408,7 @@ Import from the entries listed below; source area files are internal.
4244
4408
  | `SessionToolSnapshot` | `pi-roundtable` | type |
4245
4409
  | `SkillCatalogEntry` | `pi-roundtable` | type |
4246
4410
  | `SkillRegistry` | `pi-roundtable` | type |
4411
+ | `SkillVersion` | `pi-roundtable` | type |
4247
4412
  | `WorkerSessions` | `pi-roundtable` | type |
4248
4413
  | `WorkerSessionRequest` | `pi-roundtable` | type |
4249
4414
  | `WORKER_SESSIONS` | `pi-roundtable` | value |
@@ -4497,6 +4662,8 @@ Import from the entries listed below; source area files are internal.
4497
4662
  | `lastAssistant` | `pi-roundtable/kit` | value |
4498
4663
  | `mcpAdapterExtension` | `pi-roundtable/kit` | value |
4499
4664
  | `mcpExtension` | `pi-roundtable/kit` | value |
4665
+ | `oidcIdentity` | `pi-roundtable/kit` | value |
4666
+ | `oidcProvider` | `pi-roundtable/kit` | value |
4500
4667
  | `outcome` | `pi-roundtable/kit` | value |
4501
4668
  | `ownerAttachmentDir` | `pi-roundtable/kit` | value |
4502
4669
  | `parseModelRef` | `pi-roundtable/kit` | value |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.24.2",
3
+ "version": "0.26.0",
4
4
  "description": "A plugin-driven Pi agent server for one owner, reached through Discord or the web",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,23 @@
1
+ import type { Project } from "../project.ts";
2
+ import { fail, ok, type Result, skipped } from "../report.ts";
3
+
4
+ /**
5
+ * What the agents' turns may use of the machine: the shell and file tools ("full"), or none
6
+ * ("chat-only", `agentServer: { tools: "chat-only" }`). Fails for a chat-only host with a plugin
7
+ * whose tools reach the host, which the host would refuse to start with.
8
+ */
9
+ export async function checkAgentTools(project: Project): Promise<Result> {
10
+ const assembled = await project.assembled();
11
+ if (!assembled.ok) return skipped("the configuration is not valid yet");
12
+ const { config, defined } = assembled.value;
13
+ if (!config.agentServer) return skipped("the agent server is off");
14
+ if (config.agentTools === "full")
15
+ return ok("full (the agents carry the shell and file tools)");
16
+ const reaching = defined.plugins.find((plugin) => plugin.hostAccess === true);
17
+ if (reaching)
18
+ return fail(
19
+ `plugin ${reaching.name} declares hostAccess: its tools reach the host's machine, and the configuration says agentServer: { tools: "chat-only" }.`,
20
+ `Remove the plugin ${reaching.name}, or remove tools: "chat-only" from agentServer.`,
21
+ );
22
+ return ok("chat-only (no shell, file or workspace tools)");
23
+ }
package/src/cli/doctor.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { checkAgentTools } from "./checks/agent-tools.ts";
1
2
  import { type BunFacts, checkBun } from "./checks/bun.ts";
2
3
  import { checkCharacters } from "./checks/characters.ts";
3
4
  import { checkConfiguration, checkPlugins } from "./checks/configuration.ts";
@@ -67,6 +68,11 @@ export function buildChecks(
67
68
  },
68
69
  { name: "plugins", offline: true, run: () => checkPlugins(project) },
69
70
  { name: "access", offline: true, run: () => checkAccess(project) },
71
+ {
72
+ name: "agent tools",
73
+ offline: true,
74
+ run: () => checkAgentTools(project),
75
+ },
70
76
  {
71
77
  name: "image provider",
72
78
  offline: true,
@@ -11,6 +11,7 @@ import {
11
11
  type Pronouns,
12
12
  type RoleGrant,
13
13
  } from "../core/identity/principal-store.ts";
14
+ import { chosenProfileSource } from "../core/identity/profile-choices.ts";
14
15
  import type { Tier } from "../core/speakers.ts";
15
16
  import { CONFIG_FILE } from "./project.ts";
16
17
 
@@ -166,6 +167,7 @@ async function list(
166
167
 
167
168
  async function show(
168
169
  store: PgPrincipalStore,
170
+ sql: SQL,
169
171
  principal: PrincipalRecord,
170
172
  io: PrincipalIo,
171
173
  configured: Configured,
@@ -178,6 +180,10 @@ async function show(
178
180
  ` last seen: ${principal.lastSeenAt ? `${stamp(principal.lastSeenAt)} as ${principal.lastTier ?? "unknown"}` : "never"}`,
179
181
  );
180
182
  io.out(` roles: ${rolesText(await store.rolesOf(principal.id))}`);
183
+ const chosen = await chosenProfileSource(sql, principal.id);
184
+ io.out(
185
+ ` profile source: ${chosen ?? "none chosen (the profiles.prefer order, else the display name)"}`,
186
+ );
181
187
  const links = await store.identitiesOf(principal.id);
182
188
  io.out(` identities: ${links.length === 0 ? "none" : ""}`.trimEnd());
183
189
  for (const link of links)
@@ -236,6 +242,7 @@ function listedUnder(
236
242
 
237
243
  async function run(
238
244
  store: PgPrincipalStore,
245
+ sql: SQL,
239
246
  args: readonly string[],
240
247
  io: PrincipalIo,
241
248
  configured: Configured,
@@ -317,7 +324,7 @@ async function run(
317
324
  return;
318
325
  }
319
326
  const principal = await find(store, rest[0]);
320
- if (command === "show") return show(store, principal, io, configured);
327
+ if (command === "show") return show(store, sql, principal, io, configured);
321
328
  if (command === "link") {
322
329
  const identity = identityArgument(rest[1]);
323
330
  const spends = claimable(principal, await store.rolesOf(principal.id));
@@ -398,7 +405,7 @@ export async function principalCommand(
398
405
  );
399
406
  return 1;
400
407
  }
401
- await run(await PgPrincipalStore.attach(sql), args, io, configured);
408
+ await run(await PgPrincipalStore.attach(sql), sql, args, io, configured);
402
409
  return 0;
403
410
  } catch (error) {
404
411
  if (error instanceof Refusal || error instanceof IdentityError) {