pi-roundtable 0.23.1 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/docs/plugins.md +39 -6
- package/package.json +1 -1
- package/src/characters/compose.ts +18 -3
- package/src/characters/index.ts +1 -0
- package/src/characters/parts-hats.ts +12 -12
- package/src/characters/parts-outfits.ts +12 -12
- package/src/characters/parts-props.ts +3 -3
- package/src/characters/skins.ts +36 -0
- package/src/cli/checks/discord.ts +4 -1
- package/src/core/agents/agent-store.ts +22 -0
- package/src/core/agents/agent-team.ts +5 -0
- package/src/core/agents/avatar-refresh.ts +77 -0
- package/src/core/agents/avatar-studio.ts +13 -2
- package/src/core/agents/team-lifecycle.ts +27 -2
- package/src/core/agents/team-options.ts +6 -0
- package/src/core/builtin/agent-server.ts +14 -0
- package/src/core/config/adapters.ts +8 -5
- package/src/core/config/agent-team.ts +36 -0
- package/src/core/config/config-schema.ts +138 -0
- package/src/core/config/config.ts +64 -133
- package/src/core/config/schema.ts +18 -0
- package/src/core/define-roundtable.ts +15 -7
- package/src/core/host-registration.ts +25 -0
- package/src/core/host.ts +3 -0
- package/src/core/plugin-removed.ts +85 -0
- package/src/core/plugin.ts +14 -77
- package/src/core/services.ts +21 -2
- package/src/core/shared/workspace-files.ts +7 -4
- package/src/index.ts +1 -0
- package/src/kit/index.ts +1 -0
- package/src/kit/workspace.ts +3 -0
- package/src/testing.ts +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,36 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
5
5
|
|
|
6
|
+
## [Unreleased]
|
|
7
|
+
|
|
8
|
+
## [0.24.0] - 2026-10-29
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- A Discord host without the agent server. `agentServer: false` (top level; the key is optional, there is no `true`, and a later release can accept an object under the same name) leaves the agent server out of a host that has `discord`: no agent team, no `#dashboard` channel or pinned message, no agent channels or routes, no coordinator, and no skills addon. Discord, access, memory, commands and the plugins' claims work as before, so a plugin such as the sandbox can answer in the guild alone. With it, `discord.entryChannel` is optional (type `DiscordConfig.entryChannel?: string`, and `ResolvedDiscord.entryChannel` may be undefined) and `http.publicUrl` is no longer required with `discord`, since only the agents' avatars use it; `discord.guild` stays required. Setting it together with anything only agents use is a `ConfigError` naming both keys, judged on the configuration as written: a non-empty `agents`, `skills` (other than `false`), `ops.agent`, `discord.agentMemory`, `discord.agentSpeakers` and `discord.attachmentDownload`. Plugins that need the agent server fail fast at startup, chosen over degrading silently: `pi-roundtable-web` (its overview and rooms panes require `AGENTS`) and `pi-roundtable-mcp`'s remote-mcp default conversation (it reads `AGENTS` once the plugins are set up) stop the start with the message that the host has no agent server and that `agentServer: false` is set. `roundtable doctor` skips the Discord channel check with "agent server: off (config agentServer: false)". `ResolvedConfig.agentServer` says whether the agent server runs.
|
|
13
|
+
- pi-roundtable-sandbox: one sealed bot for a whole Discord server. `guilds` sandboxes every channel and thread of the listed guilds, `workspace: "server"` shares one workspace per guild while each channel keeps its own history, `files` and `skills` add workspace file tools and a skill index with `host_read_skill`, `budgets` sets the prompt, tool-result and request sizes, and `modelUrl` may be cleartext on loopback. Rebuild the worker image to use them; see its changelog.
|
|
14
|
+
- `pi-roundtable-web`: the files pane. The owner browses the agents' workspace on a phone or a desktop, reads files with CodeMirror syntax highlighting, and edits, creates, renames and deletes them (to a restorable trash). Every save is checked against the hash the file was read with, so a partner's change is never overwritten without a choice; paths, symlinks, special files, `.git` and read-only places are refused as the README lists. New `workspace` pane (in the default list when there is a place to show) and `workspace.roots` / `workspace.agents` / `workspace.scratch` options; the editor and its languages load only when a file opens. The package's changelog has the detail.
|
|
15
|
+
- `AGENTS.workspace` (`AgentWorkspace`, `pi-roundtable`): the directories the agents work in, `{ workDir, scratchDir? }`, so a plugin that shows or serves their files no longer needs the host to hand it the paths. Read-only data from the options the agent server already received: no hold, session or shell behavior changes. `AgentServer` gains the required `workspace` field, so code that builds an `AgentServer` by hand (a test stand-in) must set it. `within` (`pi-roundtable/kit`, `within(parent, child)`) is the core's lexical "is this path inside that directory" check, exported so a plugin that serves files applies the same rule; resolve symlinks with `realpath` first. See [the agent server's services](docs/plugins.md#services-what-plugins-provide-to-each-other).
|
|
16
|
+
- A host option for the agents' skin tones. `characters: { skins }` in the configuration is exactly six `#rrggbb` colors that a look's `skin` index (0 to 5) picks from; it defaults to the six the package ships, so a host that says nothing sees no change. It reaches every picture of an agent: the web console's characters, its creator's skin swatches and its round table (the page receives them as `characterSkins` in `GET api/config`), and the agent server's PNGs for Discord. A list of another length or a color that is not `#rrggbb` is a `ConfigError` that names `config characters.skins` and stops the start. Stored looks keep their index, so existing agents take the new tones with no migration; a Discord picture is a PNG stored by content hash and renders again with the host's skins when an agent's look is saved. `EAST_ASIAN_SKINS` (`pi-roundtable/characters`) is a ready set of six light East Asian tones, from very fair porcelain to light beige, warm pink to peach and never green or yellow, with the face lines reading at 5:1 or better on each. For plugins and tests: `HostEnv.characterSkins` (`context.env.characterSkins`, always six colors), `HostEnvironment.characterSkins` for a host that builds `RoundtableOptions` itself, `ComposeOptions.skins` for `composeSvg`, and `checkSkins` (`value`, optional `field`) for the check they share. `HostEnv.characterSkins` is a required field, so code that builds a `HostEnv` by hand must now set it (`testPlugin` fills it). See [host skins](docs/plugins.md#host-skins-the-charactersskins-option).
|
|
17
|
+
- `pi-roundtable-web`: a room switcher for phones. A swipe right anywhere over the messages, or a tap on the header picture, opens a drawer of rooms (those with a card waiting first, then by latest activity, with a search field); a tap on a row opens that room in place, and each room keeps its unsent text. A swipe that starts within 32 px of either screen edge is left to the system's Back gesture. The package's changelog has the detail.
|
|
18
|
+
- `pi-roundtable-web`: one action menu on a long press, a right click and the Menu key. It is a bottom sheet on a phone and a popover at the pointer elsewhere, and opens on an agent or group row (in the list and the drawer: edit, look, yolo, long-running, start over), a message (copy, quote, select, share), a message's picture, a card and a file. The package's changelog has the detail.
|
|
19
|
+
- `pi-roundtable-web`: an agent sheet for editing an agent where the person already is. Tapping the room header's name (or Edit agent in a menu, or an agent's picture in a message) opens a wide sheet, a bottom sheet on a phone, with the name, a prompt editor with a full-screen mode and a diff to read before saving, model and thinking as chips, the character creator, and a Notifications switch. The switch mutes one room's push for every owner and device: the room's posts, and those of its threads, are not pushed, while a waiting card and a notice still are. A bell-slash marks a muted room in the list, the drawer and the header. A new additive table, `web_push_muted_rooms`, and `PUT rooms/<key>/mute` carry it. The package's changelog has the detail.
|
|
20
|
+
|
|
21
|
+
- A host option to bring every agent's Discord picture up to date at start. `characters: { refreshAvatars: true }` renders each active agent that has a character again when the agent server starts, with the host's skins, and sets the picture where its content hash differs from the stored one; agents whose picture is unchanged and agents without a character are skipped. It defaults to `false`, so a host that says nothing sees no change. It runs in the background after the agents are dressed, so it does not hold the start back, makes no Discord request of its own, logs one summary line (`avatars refreshed: re-rendered n, unchanged m, failed k`, with the reasons at warning level), and one agent's failure stops neither the others nor the start. It overwrites an agent that still wears an old hand-drawn (deprecated) picture. A non-boolean value is a `ConfigError` naming `config characters.refreshAvatars`. See [refreshing the pictures](docs/plugins.md#refreshing-the-pictures-the-charactersrefreshavatars-option).
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- The characters' parts are medieval, with every part id kept so stored looks and the 24 class presets work as before. Hats: `mortar` is a biretta (was a mortarboard), `goggcap` a leather cap with ear flaps (was a goggle cap), `goggles` a pointed cap (was goggles), `beret` a chaperon with a hanging tail (was a beret), `tallcap` a tall hat with a trimmed band and brooch, `tricorn` a pilgrim hat with a scallop badge (was a tricorn), `softcap` a soft cap with a stitched band, `hardhat` an iron skullcap with a brass rim (was a hard hat), `toque` a cook's linen cap, `cavalier` a feathered hat with a rolled brim, `fcap` a hunter's cap with a feather (was a flat cap) and `sailor` a straw hat (was a sailor cap). Outfits: `armor` is a mail hauberk under a surcoat, belt and hem trim, `chef` a cook's linen tunic with a rope belt, `coat` a belted doublet (was a lapelled coat), `overalls` a laced work jerkin, `vest` a pourpoint with a chain of office (was a buttoned vest), `sailor` a mariner's tunic with a rope belt (was a striped shirt), and `robe` gains a rope belt and tassel. Props: `telescope` is an astrolabe, `wrench` a pair of dividers, `map` a chart whose mark is a compass star (was an X); `flask` is the alchemist's phial, drawn as before. The hair, beards, spectacles, shields, bows, lutes and the other hats, outfits and props were already medieval and are unchanged; plate harness is kept. The English labels (`characterLabel`) and the built-in zh-TW labels change with the drawings, and no class preset changed. The palette system, the chibi proportions and the line weights are as before. Fifteen classes draw differently (wizard, knight, scholar, smith, bard, alchemist, steward, navigator, seer, scribe, engineer, innkeeper, envoy, falconer and shipwright), so the pictures a host stored for Discord stay as they were until an agent's look is saved again. A test's lab fixtures of those 15 classes, the wizard's 13 expressions and its shield are the composer's pictures of the redraw.
|
|
26
|
+
- `pi-roundtable-web`: the agent's own page, `#/agent/<name>`, is gone; the address still works and opens the agent's room with the sheet on top.
|
|
27
|
+
- `pi-roundtable-web`: the dialogs of the console (the SKILL.md view, the note editor, delete and start-over confirmations) now share the sheet's look: a padded header with the title and an icon close button, a divider, and a body that scrolls while the header stays put. The skill document opens in a wider panel, lists its frontmatter as keys and values beside each other, and reads at a comfortable line length with code blocks that scroll inside themselves.
|
|
28
|
+
- `pi-roundtable-web`: the round table is rebuilt as a council hall drawn in one flat, outlined style with the characters: a ring of podiums with gold nameplates (which can letter Chinese names), the agents as waist-up standees, an overview camera and a speaker close-up with speed lines. The package's changelog has the detail.
|
|
29
|
+
|
|
30
|
+
## [0.23.2] - 2026-10-29
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
- `pi-roundtable-web`: quiet hours for Web Push. From 23:00 to 08:00 on the console's time zone, an agent's post and a notice are not pushed, while a card (waiting, or the long-running cap) still is. `push.quietHours` sets the window as `{ start, end }` in `HH:MM` (an `end` before the `start` crosses midnight) or `false` to push at any hour; a bad value stops the start. Settings shows the window read-only. The package's changelog has the detail.
|
|
35
|
+
|
|
6
36
|
## [0.23.1] - 2026-10-29
|
|
7
37
|
|
|
8
38
|
### Added
|
package/docs/plugins.md
CHANGED
|
@@ -115,7 +115,7 @@ Use the kit's building blocks for a plugin that runs Pi itself, such as a coding
|
|
|
115
115
|
Every failure throws a `JudgeError`: a failed request, an answer that is missing or malformed, a choice that is not an option, and a question that outlasts `timeoutMs` (default 20,000, a limit of its own for each question that also covers reading the answer), as `Jev did not answer within <n> ms`.
|
|
116
116
|
The `apiKey` is required, and an empty or blank one throws a `JudgeError` when `jevJudge` is called, because pi-jev-compaction's client would otherwise fall back to the key in the environment. `baseUrl` and `model` default to pi-jev-compaction's; `fetch` replaces the request function, for tests. The option type is `JevJudgeOptions`.
|
|
117
117
|
- Effort: `effortJudge` picks a turn's thinking level from a message with your own brief (`EffortBrief`, `JUDGE_WORK`). A host that wants other turns decided differently sets `effortPolicy` (an `EffortPolicy`) in its configuration: it gets the turn's `channel`, `kind`, `agent` and `origin` and answers `{ fixed: level }` (no judge call), `{ requiredMass: [0, medium, high, xhigh] }` (the highest level whose share of the judge's answer at or above it meets its entry; the previous level does not carry over and a failed judge gives low), or `undefined` for the default. The `kind` of an agent-server agent's turn is `"agent"`; a policy that throws, or a `requiredMass` that is not four finite numbers in [0, 1], fails the turn.
|
|
118
|
-
- Presentation and small helpers: `thinkingLine`, `zonedStamp(date, timeZone)`, `channelQueue()` (a queue of your own, so work does not wait behind a running turn), `checkRepoName` and `SKILL_LIST_TOOL` with `skillListExtension` for repositories and skills,
|
|
118
|
+
- Presentation and small helpers: `thinkingLine`, `zonedStamp(date, timeZone)`, `channelQueue()` (a queue of your own, so work does not wait behind a running turn), `checkRepoName` and `SKILL_LIST_TOOL` with `skillListExtension` for repositories and skills, `searchTerms` for memory search, and `within` for a path-inside-a-directory test.
|
|
119
119
|
|
|
120
120
|
The [coding package source][coding-source] shows these helpers in an out-of-process Pi worker.
|
|
121
121
|
The [MCP package source][mcp-source] is a full example of connectors and remote MCP endpoints.
|
|
@@ -165,7 +165,7 @@ A plugin that replaces `IDENTITY` replaces this too: the declarations are the bu
|
|
|
165
165
|
| Field | What it is |
|
|
166
166
|
|---|---|
|
|
167
167
|
| `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()`,
|
|
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 |
|
|
169
169
|
| `database()` | The host's one PostgreSQL connection (a Bun `SQL`), already migrated |
|
|
170
170
|
| `toolTiers` | What each tool needs; ask it when a tool is used, not during setup |
|
|
171
171
|
| `events` | Where the core reports turns and team changes to every plugin's handlers |
|
|
@@ -238,7 +238,7 @@ The built-in plugins provide these, from the main entry:
|
|
|
238
238
|
| `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
239
|
| `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
240
|
| `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
|
-
| `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),
|
|
241
|
+
| `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
242
|
| `SKILLS` | `SkillRegistry` | `skills` (an addon) | What agents carry: `carried`, `carriedNames`, `describeCarried`, `catalog`, `list`, `linkedFrom`, `checkRegistered`, `link`, `attach` |
|
|
243
243
|
| `SCHEDULES` | `ScheduleStore` | `schedule-store` | The stored schedules: `create`, `createWithin`, `get`, `forChannel`, `all`, `update`, `remove`, `due`, `claim`, `recordStatus` |
|
|
244
244
|
| `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` |
|
|
@@ -691,6 +691,7 @@ The host logs counts only (`files`, `bytes`, `unattributedBytes`), never a file
|
|
|
691
691
|
`defineTool` takes a name (lowercase words joined by underscores), a description the model reads to decide when to call it, a Typebox parameter schema, the lowest tier that may call it, and the function.
|
|
692
692
|
`run` receives the arguments, already typed, and the turn: the speaker, the channel, the agent, an abort signal, `workspace`, `attachFile`, `attachment`, `attachments`, `origin`, and `client`.
|
|
693
693
|
`turn.workspace` is `{ workspace, scratchDir? }` in a session with a shared workspace (an agent's), the same roots as `SessionContext.workspace`, and undefined elsewhere: a tool that makes a file can save it there, under the scratch dir when there is one, and name its path, so the model sends it on with `attach_file` or a `discord_send_message` file `path` instead of carrying its bytes.
|
|
694
|
+
A plugin that shows or serves the agents' files, outside a turn, reads the same two directories from `AGENTS.workspace` (`{ workDir, scratchDir? }`, read-only data from the agent server's options; the directories may not exist yet), and checks a path against a directory it serves with `within(parent, child)` from `pi-roundtable/kit` (a lexical test of two absolute paths; resolve symlinks with `realpath` first, as `attach_file` does).
|
|
694
695
|
It returns the text the model reads, or `{ text, images? }` (`ToolOutput`) to show the model images beside it: each image is `{ mimeType, data: Uint8Array }` and reaches the model as an image part of the tool result.
|
|
695
696
|
`turn.origin` is where the turn's request came from, as the transport that carried it reported it, and undefined when the channel gives none: the web chat sets it to the browser origin its connection opened with (`https://chat.example.com`, `chrome-extension://<id>`), a Discord message, a schedule or an agent's turn leave it undefined.
|
|
696
697
|
Only a channel's transport sets it, never the message text or the model, so a tool may write it on a record it creates, such as an issue's source line; a tool that needs it fails closed when it is undefined.
|
|
@@ -3364,7 +3365,7 @@ The app has three ways to know who is asking, chosen with `auth`: `{ mode: "veri
|
|
|
3364
3365
|
The agents' shell runs as the service user and inherits its environment, so with `none` an agent can call the app's socket as the owner, approve its own held actions and turn yolo on, and with `password` it can read the password and the session key.
|
|
3365
3366
|
Choose `none` or `password` for a host whose agents have no shell or run as another user, and keep a signing proxy for one whose agents run commands; the README's threat model has the details.
|
|
3366
3367
|
|
|
3367
|
-
The app requires `AGENTS` for the panes that show the team, `MODES` for Rooms and `SCHEDULES` for Schedules, and the `memory` addon for Notes.
|
|
3368
|
+
The app requires `AGENTS` for the panes that show the team (and for the Files pane's default places, `AGENTS.workspace`), `MODES` for Rooms and `SCHEDULES` for Schedules, and the `memory` addon for Notes.
|
|
3368
3369
|
A host that lists a pane the services cannot back does not start, and says which service is missing.
|
|
3369
3370
|
|
|
3370
3371
|
### What plugins do not extend
|
|
@@ -3399,7 +3400,7 @@ const svg = composeSvg(look, { look: "camelot-dark", frame: "shield", size: 256
|
|
|
3399
3400
|
|
|
3400
3401
|
A `CharacterSpec` is a complete look: `v: 1`, a `class`, and the parts `outfit`, `hat`, `prop`, `hair`, `beard` (or `"none"`), `accessory` (or `"none"`), the `build` (`slim`, `regular` or `stout`), a `skin` tone `0`–`5`, a `hairColor`, and `colors` (`primary`, the agent's accent; `secondary`; `trim`), each a lowercase `#rrggbb`.
|
|
3401
3402
|
`palette` names the preset the colors equal, and is absent when they are custom.
|
|
3402
|
-
A class is a preset of parts, not a body, so a new class is an outfit and gear: the 24 are `wizard`, `knight`, `scholar`, `smith`, `ranger`, `bard`, `alchemist`, `healer`, `steward`, `navigator`, `seer`, `scribe`, `engineer`, `druid`, `innkeeper`, `sentinel`, `envoy`, `rogue`, `monk`, `falconer`, `shipwright`, `jester`, `paladin` and `archivist`.
|
|
3403
|
+
The parts are medieval: the clothes, hats and gear of an agent are what a late-medieval town or court would know, so a `goggles` hat is a pointed cap, `wrench` a pair of dividers and `telescope` an astrolabe, and the ids stay the same. A class is a preset of parts, not a body, so a new class is an outfit and gear: the 24 are `wizard`, `knight`, `scholar`, `smith`, `ranger`, `bard`, `alchemist`, `healer`, `steward`, `navigator`, `seer`, `scribe`, `engineer`, `druid`, `innkeeper`, `sentinel`, `envoy`, `rogue`, `monk`, `falconer`, `shipwright`, `jester`, `paladin` and `archivist`.
|
|
3403
3404
|
`CATALOG` lists them with their `label`, the `jobs` they suit and the `keywords` that suggest them (English and zh-TW), and lists every outfit, hat, prop, hair, beard, accessory, the eight palettes (`azure`, `gules`, `vert`, `purpure`, `amber`, `sable`, `teal`, `rose`), the six skin tones and the hair swatches. The catalog is frozen.
|
|
3404
3405
|
|
|
3405
3406
|
| Function | What it does |
|
|
@@ -3411,17 +3412,45 @@ A class is a preset of parts, not a body, so a new class is an outfit and gear:
|
|
|
3411
3412
|
| `composeSvg(spec, options?)` | The picture as an SVG string, see below |
|
|
3412
3413
|
| `accentOf(spec, on)` | `colors.primary` as a UI accent: shaded until it reads at 3:1 on the page. `on` is a scheme (`light`: white, `dark`: `#0d1018`) or a look (`modern-light`, `modern-dark` as the schemes; `camelot-light`: the darkest parchment `#e3d2a9`, `camelot-dark`: the lightest night blue `#243557`) |
|
|
3413
3414
|
| `characterCss()` | The text of `characters.css` |
|
|
3415
|
+
| `checkSkins(value, field?)` | A host's skin tones checked: exactly six `#rrggbb` colors, returned lower-cased and frozen; otherwise a `CharacterError` whose `field` is `field` (default `skins`) or the place in the list. The host's configuration and `composeSvg` call it |
|
|
3416
|
+
| `EAST_ASIAN_SKINS` | Six light East Asian skin tones, from very fair porcelain to light beige (`#fbebe0`, `#f8e1d2`, `#f5d8c5`, `#f1cfb8`, `#edc6ac`, `#e9bf9f`), warm pink to peach and never green or yellow, with the face lines reading on each at 5:1 or better. A frozen constant for a host's `characters.skins` |
|
|
3414
3417
|
|
|
3415
3418
|
`CharacterError.field` is the path of the offending field (`hat`, `colors.primary`).
|
|
3416
3419
|
|
|
3417
3420
|
### The picture: `composeSvg`
|
|
3418
3421
|
|
|
3419
|
-
`ComposeOptions` are all optional: `expr` (one of the 13 `EXPRESSIONS`, by default the one `STATE_EXPRESSION` gives the state), `state` (one of the seven `AVATAR_STATES`: `idle`, `thinking`, `working`, `waiting`, `done`, `error`, `sleepy`), `look` (`modern-light` by default, `modern-dark`, `camelot-light` or `camelot-dark`, which change only the background and ring), `frame` (`disc` or `shield`), `bare` (no frame or background), `still`, `uid`, `size` and `
|
|
3422
|
+
`ComposeOptions` are all optional: `expr` (one of the 13 `EXPRESSIONS`, by default the one `STATE_EXPRESSION` gives the state), `state` (one of the seven `AVATAR_STATES`: `idle`, `thinking`, `working`, `waiting`, `done`, `error`, `sleepy`), `look` (`modern-light` by default, `modern-dark`, `camelot-light` or `camelot-dark`, which change only the background and ring), `frame` (`disc` or `shield`), `bare` (no frame or background), `still`, `uid`, `size`, `label` and `skins` (six `#rrggbb` skin tones that `CharacterSpec.skin` indexes, in place of `CATALOG.skins`; anything but six valid colors throws a `CharacterError` whose `field` is `skins` or `skins[3]`).
|
|
3420
3423
|
|
|
3421
3424
|
- The output has literal colors only. It has no `style` attribute, variable, `<style>`, script, text, image, link, event handler or `<foreignObject>`, so a page whose content security policy forbids inline styles can inject it, and `canvas` rasterizes it as it is. It is at most 8 KB and the same for the same input. The root is `<svg class="av" data-expr>` and the layers are `<g class="NAME" data-part="NAME">` in a fixed order (`frame`, `ch` holding `back`, `body`, `head`, `hat`, `eyes-brows`, `mouth`, `fx`, `acc`, `prop`, then `state`); the prop group carries `data-prop`.
|
|
3422
3425
|
- `composeSvg` checks the spec again with `parseCharacter`, so a spec cast from untrusted data throws instead of reaching the markup. `uid` (`/^[a-z0-9]{1,16}$/`, default `a`) ends every clip path id, so each avatar on a page needs its own `uid`: two with the same one share clip path ids and one clips the other. The server-side renders pass an explicit one. `size` (an integer up to 4096) adds `width` and `height`, which `canvas` needs. `label` (at most 200 characters) becomes `aria-label` with `<>"'=()&` written as entities; without it the figure is `aria-hidden`.
|
|
3423
3426
|
- A live figure carries every state overlay and leaves the motion to the stylesheet. With `still: true` only the `state`'s overlay is drawn and the figure has class `av static`, for files, PNGs and 3D textures; an `error` state turns the ring red in both.
|
|
3424
3427
|
|
|
3428
|
+
### Host skins: the `characters.skins` option
|
|
3429
|
+
|
|
3430
|
+
A host picks the six skin tones its agents are drawn with. The catalog's own (`CATALOG.skins`, light to dark) stay the default, so a host that says nothing sees no change.
|
|
3431
|
+
|
|
3432
|
+
```ts
|
|
3433
|
+
import { EAST_ASIAN_SKINS } from "pi-roundtable/characters";
|
|
3434
|
+
|
|
3435
|
+
export default defineRoundtable({
|
|
3436
|
+
// ...
|
|
3437
|
+
characters: { skins: EAST_ASIAN_SKINS },
|
|
3438
|
+
});
|
|
3439
|
+
```
|
|
3440
|
+
|
|
3441
|
+
- `characters.skins` is exactly six `#rrggbb` colors, in the order a look's `skin` index (`0` to `5`) picks them. A list of another length or a color that is not `#rrggbb` is a `ConfigError` that names `config characters.skins` (or the place in the list), and the host does not start. A host that builds `RoundtableOptions` itself gives the same list as `environment.characterSkins`, which is checked at start the same way.
|
|
3442
|
+
- A stored look keeps its index, so existing agents wear the new tones with no migration. The Discord pictures are PNGs stored by content hash, and render again with the host's skins whenever an agent's look is saved; to bring every agent up to date at once, turn on [`characters.refreshAvatars`](#refreshing-the-pictures-the-charactersrefreshavatars-option).
|
|
3443
|
+
- Every render path follows it: plugins read `context.env.characterSkins` (always six colors, the catalog's when the host chose none) and pass it as `composeSvg`'s `skins`; the agent server's studio (`AvatarStudio.render`, so the Discord pictures) and `pi-roundtable-web`'s page (the characters, the creator's skin swatches and the round table) already do. The web console receives them as `characterSkins` in `GET api/config`.
|
|
3444
|
+
|
|
3445
|
+
### Refreshing the pictures: the `characters.refreshAvatars` option
|
|
3446
|
+
|
|
3447
|
+
An agent's Discord picture is rendered when its look is saved, so an agent nobody saved since a redraw of the parts or a change of `characters.skins` keeps its old picture on Discord while the web console shows the new one. `characters: { refreshAvatars: true }` renders them all again when the agent server starts. It is `false` by default, and a host that says nothing sees no change.
|
|
3448
|
+
|
|
3449
|
+
- After the team has started and its agents without a look are dressed, the agent server renders every active agent that has a character with the host's skins and stores the PNG by content hash. When the hash differs from the one stored for the agent, the agent's picture is set to it, and Discord shows the new one on the agent's next post; an agent whose hash is the same is skipped, so a restart with nothing changed sets nothing. Agents without a character are left alone.
|
|
3450
|
+
- It runs in the background, one agent at a time, and does not hold the start back or the team's readiness. It makes no Discord request of its own: the picture is the avatar URL of the agent's webhook posts, which go through the poster's usual rate limits.
|
|
3451
|
+
- One summary line is logged: `avatars refreshed: re-rendered n, unchanged m, skipped s, failed k`, at warning level with the agents and reasons that failed when `k` is not 0. A failed agent keeps its picture and the others still render; a failure never stops the start. An agent archived or given another look while it rendered is counted as skipped and keeps the newer picture: the write checks the look inside the store's write queue. Stopping the agent server ends the refresh, with no further render, write or log.
|
|
3452
|
+
- It overwrites the picture of an agent that still wears an old hand-drawn one (deprecated since 0.23), because the look decides the picture. A non-boolean value is a `ConfigError` that names `config characters.refreshAvatars`.
|
|
3453
|
+
|
|
3425
3454
|
### Motion: `characters.css`
|
|
3426
3455
|
|
|
3427
3456
|
Import `pi-roundtable/characters.css` once. It breathes, tilts, bobs, leans, pops and shakes the figure by `data-state`, which you set on the `<svg>` or, so the markup does not change with the state, on a wrapper with class `av-host`. It moves only `transform` and `opacity`, keeps the pivots in selectors (`.ch`, `.eyes`, the badges and one rule per `[data-prop=<id>]`) since the markup carries no inline style, stops for `prefers-reduced-motion`, and stops on `.av.static`. The file is generated from `characterCss()` by `bun scripts/build-character-css.ts`, and a test fails when they differ.
|
|
@@ -4030,6 +4059,7 @@ Import from the entries listed below; source area files are internal.
|
|
|
4030
4059
|
| `AttachmentsConfig` | `pi-roundtable` | type |
|
|
4031
4060
|
| `AutoApproval` | `pi-roundtable` | type |
|
|
4032
4061
|
| `AvatarMode` | `pi-roundtable` | type |
|
|
4062
|
+
| `AgentWorkspace` | `pi-roundtable` | type |
|
|
4033
4063
|
| `AvatarStudio` | `pi-roundtable` | type |
|
|
4034
4064
|
| `characterOf` | `pi-roundtable` | value |
|
|
4035
4065
|
| `BACKGROUND_TURNS` | `pi-roundtable` | value |
|
|
@@ -4491,6 +4521,7 @@ Import from the entries listed below; source area files are internal.
|
|
|
4491
4521
|
| `toolText` | `pi-roundtable/kit` | value |
|
|
4492
4522
|
| `withAttachmentsBlock` | `pi-roundtable/kit` | value |
|
|
4493
4523
|
| `withReference` | `pi-roundtable/kit` | value |
|
|
4524
|
+
| `within` | `pi-roundtable/kit` | value |
|
|
4494
4525
|
| `workTimeout` | `pi-roundtable/kit` | value |
|
|
4495
4526
|
| `zonedStamp` | `pi-roundtable/kit` | value |
|
|
4496
4527
|
| `AgentPanel` | `pi-roundtable/discord` | type |
|
|
@@ -4569,10 +4600,12 @@ Import from the entries listed below; source area files are internal.
|
|
|
4569
4600
|
| `CATALOG` | `pi-roundtable/characters` | value |
|
|
4570
4601
|
| `CLASS_IDS` | `pi-roundtable/characters` | value |
|
|
4571
4602
|
| `CharacterError` | `pi-roundtable/characters` | value |
|
|
4603
|
+
| `EAST_ASIAN_SKINS` | `pi-roundtable/characters` | value |
|
|
4572
4604
|
| `EXPRESSIONS` | `pi-roundtable/characters` | value |
|
|
4573
4605
|
| `STATE_EXPRESSION` | `pi-roundtable/characters` | value |
|
|
4574
4606
|
| `accentOf` | `pi-roundtable/characters` | value |
|
|
4575
4607
|
| `characterCss` | `pi-roundtable/characters` | value |
|
|
4608
|
+
| `checkSkins` | `pi-roundtable/characters` | value |
|
|
4576
4609
|
| `composeSvg` | `pi-roundtable/characters` | value |
|
|
4577
4610
|
| `parseCharacter` | `pi-roundtable/characters` | value |
|
|
4578
4611
|
| `readCharacter` | `pi-roundtable/characters` | value |
|
package/package.json
CHANGED
|
@@ -13,6 +13,7 @@ import { ACCESSORIES, BEARDS, HAIR } from "./parts-hair.ts";
|
|
|
13
13
|
import { HATS } from "./parts-hats.ts";
|
|
14
14
|
import { OUTFITS } from "./parts-outfits.ts";
|
|
15
15
|
import { PROPS } from "./parts-props.ts";
|
|
16
|
+
import { checkSkins } from "./skins.ts";
|
|
16
17
|
import {
|
|
17
18
|
CharacterError,
|
|
18
19
|
type CharacterSpec,
|
|
@@ -45,6 +46,11 @@ export interface ComposeOptions {
|
|
|
45
46
|
size?: number;
|
|
46
47
|
/** The accessible name, escaped into `aria-label`; without it the figure is `aria-hidden`. */
|
|
47
48
|
label?: string;
|
|
49
|
+
/**
|
|
50
|
+
* Six `#rrggbb` skin tones that `CharacterSpec.skin` indexes, in place of `CATALOG.skins`; a
|
|
51
|
+
* host's own set, such as `EAST_ASIAN_SKINS`. Anything but six valid colors throws.
|
|
52
|
+
*/
|
|
53
|
+
skins?: readonly Hex[] | undefined;
|
|
48
54
|
}
|
|
49
55
|
|
|
50
56
|
const LOOKS = ["modern-light", "modern-dark", "camelot-light", "camelot-dark"];
|
|
@@ -105,9 +111,14 @@ function escapeAttribute(text: string): string {
|
|
|
105
111
|
);
|
|
106
112
|
}
|
|
107
113
|
|
|
108
|
-
function tonesOf(
|
|
114
|
+
function tonesOf(
|
|
115
|
+
spec: CharacterSpec,
|
|
116
|
+
look: LookStyle,
|
|
117
|
+
error: boolean,
|
|
118
|
+
skins: readonly Hex[],
|
|
119
|
+
) {
|
|
109
120
|
const { primary, secondary, trim } = spec.colors;
|
|
110
|
-
const skin =
|
|
121
|
+
const skin = skins[spec.skin] as Hex;
|
|
111
122
|
const tones: Tones = {
|
|
112
123
|
p: primary,
|
|
113
124
|
pd: darken(primary, 0.3),
|
|
@@ -180,11 +191,15 @@ export function composeSvg(
|
|
|
180
191
|
"label",
|
|
181
192
|
`must be text of at most ${MAX_LABEL} characters`,
|
|
182
193
|
);
|
|
194
|
+
const skins =
|
|
195
|
+
options.skins === undefined
|
|
196
|
+
? CATALOG.skins
|
|
197
|
+
: checkSkins(options.skins, "skins");
|
|
183
198
|
const bare = options.bare === true;
|
|
184
199
|
const still = options.still === true;
|
|
185
200
|
|
|
186
201
|
const style = STYLES[lookName];
|
|
187
|
-
const { tones, bg, ring } = tonesOf(s, style, state === "error");
|
|
202
|
+
const { tones, bg, ring } = tonesOf(s, style, state === "error", skins);
|
|
188
203
|
const face = faceParts(
|
|
189
204
|
{ ink: INK, brow: mix(s.hairColor, INK, 0.5), dot: mix(tones.p, INK, 0.1) },
|
|
190
205
|
expr,
|
package/src/characters/index.ts
CHANGED
|
@@ -22,6 +22,7 @@ export type { CharacterLook, ComposeOptions } from "./compose.ts";
|
|
|
22
22
|
export { accentOf, composeSvg } from "./compose.ts";
|
|
23
23
|
export { characterCss } from "./css.ts";
|
|
24
24
|
export { parseCharacter, readCharacter } from "./parse.ts";
|
|
25
|
+
export { checkSkins, EAST_ASIAN_SKINS } from "./skins.ts";
|
|
25
26
|
export type {
|
|
26
27
|
CharacterColors,
|
|
27
28
|
CharacterInput,
|
|
@@ -13,43 +13,43 @@ export const HATS: Record<HatId, Part> = {
|
|
|
13
13
|
helm: (v) =>
|
|
14
14
|
`<path d="M50 24C42 6 66 -1 80 12C68 9 60 15 57 25Z" fill="${v.p}"/><path d="M23 47C21 23 79 23 77 47L72 39Q50 31 28 39Z" fill="${STEEL}"/><path d="M26 36Q50 24 74 36" fill="none" stroke="#fff" stroke-width="2" opacity=".55"/><path d="M28 39Q50 31 72 39" fill="none" stroke="${v.t}" stroke-width="3"/><circle cx="50" cy="26.5" r="3.4" fill="${v.t}"/>`,
|
|
15
15
|
mortar: (v) =>
|
|
16
|
-
`<path d="M30
|
|
16
|
+
`<path d="M30 29L33 13Q50 8 67 13L70 29Q50 34 30 29Z" fill="${v.p}"/><path d="M41 10.6L39 30.4M50 9.2V32M59 10.6L61 30.4" fill="none" stroke="${v.pd}" stroke-width="2" stroke-linecap="round"/><path d="M27.6 29.4Q50 36 72.4 29.4V36.6Q50 43.4 27.6 36.6Z" fill="${v.pd}"/><path d="M28 33Q50 39.6 72 33" fill="none" stroke="${v.t}" stroke-width="1.6" opacity=".9"/><circle cx="50" cy="8.2" r="3.2" fill="${v.t}" stroke="${v.td}" stroke-width="1"/>`,
|
|
17
17
|
goggcap: (v) =>
|
|
18
|
-
`<path d="
|
|
18
|
+
`<path d="M22.4 42Q20.4 53 24.4 61Q29 59.6 30 52L30.4 42Z" fill="${v.pd}"/><path d="M77.6 42Q79.6 53 75.6 61Q71 59.6 70 52L69.6 42Z" fill="${v.pd}"/><path d="M24.4 61l-1 4.4M75.6 61l1 4.4" stroke="${v.t}" stroke-width="1.4" stroke-linecap="round"/><path d="M23 42C22 20 78 20 77 42Q50 33 23 42Z" fill="${v.p}"/><path d="M50 25V34.6" fill="none" stroke="${v.pd}" stroke-width="1.6" opacity=".6"/><path d="M22.4 38.5Q50 29.5 77.6 38.5L77 43.5Q50 34.5 23 43.5Z" fill="${v.pd}"/><circle cx="36.5" cy="37.8" r="1.4" fill="${v.t}"/><circle cx="50" cy="36.4" r="1.4" fill="${v.t}"/><circle cx="63.5" cy="37.8" r="1.4" fill="${v.t}"/>`,
|
|
19
19
|
hood: (v) =>
|
|
20
20
|
`<path d="M21 53C20 26 80 26 79 53C76 40 66 33.5 50 33.5C34 33.5 24 40 21 53Z" fill="${v.p}"/><path d="M21 53C20 26 80 26 79 53" fill="none" stroke="${v.pd}" stroke-width="2"/><path d="M70 33Q90 24 93 8Q76 12 66 30Z" fill="${v.t}"/><path d="M72 29Q84 20 91 10" fill="none" stroke="${v.td}" stroke-width="1.2"/>`,
|
|
21
21
|
beret: (v) =>
|
|
22
|
-
`<path d="
|
|
22
|
+
`<path d="M27 36C20 14 56 6 70 16C84 26 80 36 74 40Q50 33 27 36Z" fill="${v.p}"/><path d="M60 12C70 10 76 14 74 18" fill="none" stroke="${v.pd}" stroke-width="1.6" stroke-linecap="round"/><path d="M22 38C22 30 78 30 78 38C78 47 66 43 50 43C34 43 22 47 22 38Z" fill="${v.pd}"/><path d="M26 38.4Q50 33 74 38.4" fill="none" stroke="${v.t}" stroke-width="1.6" stroke-dasharray="2.6 2.2" stroke-linecap="round"/><path d="M71 17C88 22 93 42 88 66L83 64C86 46 80 34 70 26Z" fill="${v.p}" stroke="${v.pd}" stroke-width="1"/><circle cx="85.4" cy="67" r="2.6" fill="${v.t}"/>`,
|
|
23
23
|
goggles: (v) =>
|
|
24
|
-
`<path d="
|
|
24
|
+
`<path d="M23 42C22 26 40 15 55 12C62 9 66 13 61 17C73 23 78 32 77 42Q50 33 23 42Z" fill="${v.p}"/><path d="M36 30Q50 21 62 24" fill="none" stroke="${v.pd}" stroke-width="1.6" stroke-linecap="round" opacity=".6"/><path d="M22.4 38.5Q50 29.5 77.6 38.5L77 43.5Q50 34.5 23 43.5Z" fill="${v.pl}"/><circle cx="50" cy="36.6" r="1.8" fill="${v.t}"/>`,
|
|
25
25
|
coif: (v) =>
|
|
26
26
|
`<path d="M19 55C17 22 83 22 81 55C77 38 65 32 50 32C35 32 23 38 19 55Z" fill="#fff" stroke="#d9deeb" stroke-width="1.2"/><path d="M24 44Q50 30 76 44" fill="none" stroke="${v.t}" stroke-width="2.4"/><path d="M48.4 18H51.6V23H56.4V26H51.6V32H48.4V26H43.6V23H48.4Z" fill="${v.p}" transform="translate(0 4)"/>`,
|
|
27
27
|
tallcap: (v) =>
|
|
28
|
-
`<path d="M31
|
|
28
|
+
`<path d="M31 38L36 12Q50 4 64 12L69 38Z" fill="${v.pd}"/><path d="M40 12Q50 8 60 12" fill="none" stroke="#fff" stroke-width="1.6" stroke-linecap="round" opacity=".3"/><path d="M28 39C28 32 72 32 72 39C72 45 28 45 28 39Z" fill="${v.pl}" stroke="${v.p}" stroke-width="1.2"/><path d="M33 38l2.4 2.2 2.6-2.2 2.6 2.2 2.6-2.2 2.6 2.2 2.6-2.2 2.6 2.2 2.6-2.2 2.6 2.2 2.6-2.2 2.6 2.2" fill="none" stroke="${v.p}" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" opacity=".7"/><circle cx="62" cy="35" r="2.8" fill="${v.t}" stroke="${v.td}" stroke-width="1"/><path d="M62 33Q74 20 80 8Q77 24 65 36Z" fill="${v.s}" stroke="${v.sd}" stroke-width="1"/>`,
|
|
29
29
|
tricorn: (v) =>
|
|
30
|
-
`<path d="
|
|
30
|
+
`<path d="M7 38Q50 24 93 38Q50 49 7 38Z" fill="${v.pd}"/><path d="M28 37C26 14 74 14 72 37Q50 31 28 37Z" fill="${v.p}"/><path d="M27.4 34.6Q50 41 72.6 34.6" fill="none" stroke="${v.t}" stroke-width="2.4"/><path d="M50 36C41 36 40 26 42 23.6Q46 21 50 20.4Q54 21 58 23.6C60 26 59 36 50 36Z" fill="${v.t}" stroke="${v.td}" stroke-width="1"/><path d="M50 35V21M45.6 34L43.6 25M54.4 34L56.4 25" fill="none" stroke="${v.td}" stroke-width="1"/>`,
|
|
31
31
|
turban: (v) =>
|
|
32
32
|
`<path d="M21 46C19 18 81 18 79 46C70 34 30 34 21 46Z" fill="${v.pl}"/><path d="M26 38Q50 22 74 38M23 44Q50 28 77 44" fill="none" stroke="${v.p}" stroke-width="2.4"/><path d="M50 22l2.6 4 4.6.6-3.4 3.2.9 4.6-4.7-2.4-4.7 2.4.9-4.6-3.4-3.2 4.6-.6z" fill="${v.t}" stroke="${v.td}" stroke-width="1"/>`,
|
|
33
33
|
softcap: (v) =>
|
|
34
|
-
`<path d="
|
|
34
|
+
`<path d="M25 38C14 24 28 12 50 12C72 12 86 24 75 38Z" fill="${v.pl}"/><path d="M33 22Q50 14 67 22" fill="none" stroke="${v.p}" stroke-width="1.6" stroke-linecap="round" opacity=".55"/><path d="M22 37Q50 27 78 37V44Q50 34 22 44Z" fill="${v.p}"/><path d="M25 38.6Q50 29.6 75 38.6" fill="none" stroke="${v.t}" stroke-width="1.4" stroke-dasharray="2.4 2" stroke-linecap="round"/><circle cx="50" cy="12.4" r="2.4" fill="${v.p}"/>`,
|
|
35
35
|
hardhat: (v) =>
|
|
36
|
-
`<path d="M25
|
|
36
|
+
`<path d="M25 40C23 10 77 10 75 40Z" fill="${STEEL}" stroke="${STEELD}" stroke-width="1.4"/><path d="M50 12V37" fill="none" stroke="${STEELD}" stroke-width="2.4"/><path d="M30 25Q36 17 44 15" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" opacity=".7"/><rect x="21.6" y="34.4" width="56.8" height="6" rx="3" fill="${v.t}" stroke="${v.td}" stroke-width="1"/><circle cx="34" cy="37.4" r="1.3" fill="${STEELD}"/><circle cx="50" cy="37.4" r="1.3" fill="${STEELD}"/><circle cx="66" cy="37.4" r="1.3" fill="${STEELD}"/>`,
|
|
37
37
|
wreath: (v) =>
|
|
38
38
|
`<g fill="#4fae6c"><ellipse cx="28" cy="40" rx="6" ry="3.2" transform="rotate(60 28 40)"/><ellipse cx="31" cy="31" rx="6" ry="3.2" transform="rotate(30 31 31)"/><ellipse cx="40" cy="26" rx="6" ry="3.2" transform="rotate(8 40 26)"/><ellipse cx="50" cy="24" rx="6" ry="3.2"/><ellipse cx="60" cy="26" rx="6" ry="3.2" transform="rotate(-8 60 26)"/><ellipse cx="69" cy="31" rx="6" ry="3.2" transform="rotate(-30 69 31)"/><ellipse cx="72" cy="40" rx="6" ry="3.2" transform="rotate(-60 72 40)"/></g><g fill="#2f8a50"><ellipse cx="36" cy="28" rx="5" ry="2.6" transform="rotate(-20 36 28)"/><ellipse cx="64" cy="28" rx="5" ry="2.6" transform="rotate(20 64 28)"/></g><circle cx="50" cy="24" r="2.6" fill="${v.p}"/><circle cx="31" cy="32" r="2.2" fill="${v.t}"/><circle cx="69" cy="32" r="2.2" fill="${v.t}"/>`,
|
|
39
39
|
toque: (v) =>
|
|
40
|
-
`<path d="
|
|
40
|
+
`<path d="M26 40C22 26 34 17 50 17C66 17 78 26 74 40Z" fill="#fff" stroke="#d9deeb" stroke-width="1.2"/><path d="M36 22Q50 16 64 22M31 31Q50 24 69 31" fill="none" stroke="#e1e5f0" stroke-width="1.4" stroke-linecap="round"/><path d="M23 38Q50 29 77 38Q79 44 73 44Q50 36 27 44Q21 44 23 38Z" fill="#f3f5fb" stroke="#d9deeb" stroke-width="1.2"/><path d="M74 38Q86 38 88 48M75 41Q82 50 79 58" fill="none" stroke="#d9deeb" stroke-width="2.2" stroke-linecap="round"/><circle cx="50" cy="17.4" r="1.8" fill="${v.p}"/>`,
|
|
41
41
|
kettle: (v) =>
|
|
42
42
|
`<path d="M28 33C28 8 72 8 72 33Z" fill="${STEEL}"/><ellipse cx="50" cy="33" rx="38" ry="6.4" fill="${STEELD}"/><path d="M34 22Q50 12 66 22" fill="none" stroke="#fff" stroke-width="2" opacity=".5"/><circle cx="50" cy="17" r="2.4" fill="${v.t}"/><path d="M28 27H72" stroke="${v.p}" stroke-width="3"/>`,
|
|
43
43
|
cavalier: (v) =>
|
|
44
|
-
`<path d="
|
|
44
|
+
`<path d="M63 18Q82 2 94 6Q91 26 72 30Z" fill="${v.s}" stroke="${v.t}" stroke-width="1.4"/><path d="M66 21Q80 10 92 7" fill="none" stroke="${v.t}" stroke-width="1"/><path d="M31 36C28 12 72 12 69 36Z" fill="${v.p}"/><path d="M21 38Q23 29 31 33Q50 40 69 33Q77 29 79 38Q50 47 21 38Z" fill="${v.pd}"/><path d="M30.4 32.4Q50 38.4 69.6 32.4" fill="none" stroke="${v.t}" stroke-width="2.2"/><circle cx="62" cy="33" r="2.6" fill="${v.t}" stroke="${v.td}" stroke-width="1"/>`,
|
|
45
45
|
cowl: (v) =>
|
|
46
46
|
`<path d="M19 54C16 30 38 14 58 3C58 18 82 32 81 54C75 41 63 34 50 34C37 34 25 41 19 54Z" fill="${v.pd}"/><path d="M26 44Q50 30 74 44" fill="none" stroke="${v.ink}" stroke-width="2" opacity=".3"/>`,
|
|
47
47
|
headband: (v) =>
|
|
48
48
|
`<path d="M22 39Q50 29 78 39V44.5Q50 34.5 22 44.5Z" fill="${v.p}"/><path d="M76 40Q86 38 90 46M76 42Q88 46 88 56" fill="none" stroke="${v.p}" stroke-width="3.2" stroke-linecap="round"/><circle cx="50" cy="31.5" r="2.4" fill="${v.t}"/>`,
|
|
49
49
|
fcap: (v) =>
|
|
50
|
-
`<path d="
|
|
50
|
+
`<path d="M28 37C27 15 71 11 73 36Z" fill="${v.pd}"/><path d="M60 24Q78 22 94 8Q87 29 70 35Z" fill="${v.s}" stroke="${v.td}" stroke-width="1.2"/><path d="M62 27Q79 22 92 10" fill="none" stroke="${v.td}" stroke-width="1"/><path d="M14 33Q50 26 86 33Q76 42 50 41Q24 42 14 33Z" fill="${v.p}"/><path d="M28 33Q50 38 72 33" fill="none" stroke="${v.t}" stroke-width="1.8"/>`,
|
|
51
51
|
sailor: (v) =>
|
|
52
|
-
`<path d="
|
|
52
|
+
`<path d="M9 37Q50 26 91 37Q50 49 9 37Z" fill="#e8d194" stroke="#c9a867" stroke-width="1.2"/><path d="M29 36C27 15 73 15 71 36Q50 31 29 36Z" fill="#f2e0a8"/><path d="M29.4 33.2Q50 38.4 70.6 33.2" fill="none" stroke="${v.p}" stroke-width="3.4"/><path d="M36 22Q50 17 64 22M33 28Q50 22 67 28" fill="none" stroke="#d9c07a" stroke-width="1.2" stroke-linecap="round"/><path d="M14 37.6Q50 44 86 37.6" fill="none" stroke="#c9a867" stroke-width="1" opacity=".7"/>`,
|
|
53
53
|
jester: (v) =>
|
|
54
54
|
`<path d="M26 40C14 30 12 14 18 6C28 16 32 26 42 33Z" fill="${v.p}"/><path d="M74 40C86 30 88 14 82 6C72 16 68 26 58 33Z" fill="${v.s}" stroke="${v.sd}" stroke-width="1"/><path d="M26 40C26 28 74 28 74 40Q50 34 26 40Z" fill="${v.pd}"/><circle cx="18" cy="6" r="4" fill="${v.t}" stroke="${v.td}" stroke-width="1"/><circle cx="82" cy="6" r="4" fill="${v.t}" stroke="${v.td}" stroke-width="1"/><circle cx="50" cy="31" r="3.4" fill="${v.t}"/>`,
|
|
55
55
|
winghelm: (v) =>
|
|
@@ -19,7 +19,7 @@ export const OUTFITS: Record<OutfitId, Part> = {
|
|
|
19
19
|
robe: (v) =>
|
|
20
20
|
arms(v.p) +
|
|
21
21
|
torso(v.p) +
|
|
22
|
-
`<path d="M30 89H70L69.6 94H30.4Z" fill="${v.t}"/>` +
|
|
22
|
+
`<path d="M30 89H70L69.6 94H30.4Z" fill="${v.t}"/><path d="M31 82.6Q50 87 69 82.6" fill="none" stroke="${WOOD}" stroke-width="2.6"/><path d="M50 83.6V92" stroke="${WOOD}" stroke-width="1.8" stroke-linecap="round"/><circle cx="50" cy="93" r="1.8" fill="${v.t}"/>` +
|
|
23
23
|
star5(50, 77, v.t) +
|
|
24
24
|
hand(v, 27, 88, 4.4),
|
|
25
25
|
scholar: (v) =>
|
|
@@ -29,7 +29,7 @@ export const OUTFITS: Record<OutfitId, Part> = {
|
|
|
29
29
|
armor: (v) =>
|
|
30
30
|
arms(STEEL) +
|
|
31
31
|
torso(STEEL) +
|
|
32
|
-
`<path d="
|
|
32
|
+
`<g fill="none" stroke="${STEELD}" stroke-width=".9" stroke-linecap="round" opacity=".8"><path d="M24 79q2 1.6 4 0t4 0M23.4 84q2 1.6 4 0t4 0M68 79q2 1.6 4 0t4 0M68.6 84q2 1.6 4 0t4 0"/></g><ellipse cx="50" cy="72.6" rx="12.6" ry="3.6" fill="${STEELD}"/><path d="M37 73L63 73L66.4 94H33.6Z" fill="${v.p}"/><path d="M36 73.4L38.6 73.4L37 76ZM64 73.4L61.4 73.4L63 76Z" fill="${STEELD}"/><path d="M33.8 89H66.2L66.4 94H33.6Z" fill="${v.t}"/><path d="M34.6 83.6H65.4" stroke="${WOOD}" stroke-width="2.4"/><rect x="47" y="81.8" width="6" height="3.6" rx="1" fill="${v.t}"/><path d="M50 74.6V80M47.4 77H52.6" stroke="${v.t}" stroke-width="2.2" stroke-linecap="round"/><circle cx="31" cy="76" r="5.6" fill="${STEELD}"/><circle cx="29.6" cy="74.2" r="1.7" fill="#fff" opacity=".6"/><circle cx="69" cy="76" r="5.6" fill="${STEELD}"/><circle cx="67.6" cy="74.2" r="1.7" fill="#fff" opacity=".6"/>`,
|
|
33
33
|
plate: (v) =>
|
|
34
34
|
arms("#e6eaf3") +
|
|
35
35
|
torso("#e6eaf3") +
|
|
@@ -39,9 +39,9 @@ export const OUTFITS: Record<OutfitId, Part> = {
|
|
|
39
39
|
torso(v.s) +
|
|
40
40
|
`<path d="M38 71H62L66 94H34Z" fill="${LEATHER}"/><path d="M38 71L42 75M62 71L58 75" stroke="#7a4f27" stroke-width="2"/><rect x="43.5" y="82" width="13" height="8" rx="2" fill="#8d5c30"/><rect x="24" y="85" width="9" height="3" rx="1.5" fill="#8d5c30"/>`,
|
|
41
41
|
chef: (v) =>
|
|
42
|
-
arms("#
|
|
43
|
-
torso("#
|
|
44
|
-
`<path d="
|
|
42
|
+
arms("#f6f1e4") +
|
|
43
|
+
torso("#f6f1e4") +
|
|
44
|
+
`<path d="M33 89H67L68.2 94H31.8Z" fill="${v.s}"/><path d="M42 71.6L50 80L58 71.6" fill="none" stroke="#d6cdb4" stroke-width="1.6"/><path d="M46 74.6L50 78.4L54 74.6" fill="none" stroke="${v.p}" stroke-width="1.4"/><path d="M33 83.4Q50 88 67 83.4" fill="none" stroke="${LEATHER}" stroke-width="2.6"/><path d="M50 84.4V91.6" stroke="${LEATHER}" stroke-width="1.8" stroke-linecap="round"/><path d="M24.5 86.4h7M68.5 86.4h7" stroke="#d6cdb4" stroke-width="1.4"/>`,
|
|
45
45
|
cloak: (v) =>
|
|
46
46
|
`<path d="M26 79Q50 66 74 79L72 94H28Z" fill="${v.pd}"/>` +
|
|
47
47
|
arms(v.p) +
|
|
@@ -54,15 +54,15 @@ export const OUTFITS: Record<OutfitId, Part> = {
|
|
|
54
54
|
coat: (v) =>
|
|
55
55
|
arms(v.pd) +
|
|
56
56
|
torso(v.pd) +
|
|
57
|
-
`<path d="M50 72V94" stroke="${v.ink}" stroke-width="1" opacity=".35"/><path d="
|
|
57
|
+
`<path d="M50 72V94" stroke="${v.ink}" stroke-width="1" opacity=".35"/><path d="M37 71.6Q50 78 63 71.6L64 74.6Q50 81 36 74.6Z" fill="${v.s}"/><circle cx="50" cy="80.4" r="1.7" fill="${v.t}"/><circle cx="50" cy="85.8" r="1.7" fill="${v.t}"/><path d="M32 89.6Q50 93.6 68 89.6" fill="none" stroke="${v.t}" stroke-width="2"/><path d="M24.5 87.4h7M68.5 87.4h7" stroke="${v.t}" stroke-width="1.8"/>`,
|
|
58
58
|
overalls: (v) =>
|
|
59
|
-
arms(
|
|
60
|
-
torso(v.
|
|
61
|
-
`<path d="
|
|
59
|
+
arms("#efe6cf") +
|
|
60
|
+
torso(v.p) +
|
|
61
|
+
`<path d="M40 71.6L50 76L60 71.6V94H40Z" fill="#efe6cf"/><path d="M50 76V94" stroke="${v.pd}" stroke-width="1" opacity=".6"/><path d="M46.4 78.4h7.2M46.4 83h7.2M46.4 87.6h7.2" stroke="${v.pd}" stroke-width="1.2" stroke-linecap="round"/><path d="M31.4 89Q50 93 68.6 89V94H31.4Z" fill="${v.pd}"/><path d="M33 83.4Q50 87.6 67 83.4" fill="none" stroke="${LEATHER}" stroke-width="2.4"/><rect x="46.6" y="81.4" width="6.8" height="4.2" rx="1" fill="${v.t}"/>`,
|
|
62
62
|
vest: (v) =>
|
|
63
63
|
arms(v.s) +
|
|
64
|
-
torso(v.
|
|
65
|
-
`<path d="
|
|
64
|
+
torso(v.p) +
|
|
65
|
+
`<path d="M38 71.5L50 94H36ZM62 71.5L50 94H64Z" fill="${v.s}" opacity="0"/><path d="M50 72V94" stroke="${v.ink}" stroke-width="1" opacity=".35"/><path d="M36.6 73.6Q50 80 63.4 73.6" fill="none" stroke="${v.t}" stroke-width="1.4"/><circle cx="50" cy="80.6" r="1.6" fill="${v.t}"/><circle cx="50" cy="86.4" r="1.6" fill="${v.t}"/><path d="M38 72Q35 84 50 90Q65 84 62 72" fill="none" stroke="${v.t}" stroke-width="1.8" stroke-linecap="round"/><circle cx="50" cy="90.4" r="2.4" fill="${v.t}" stroke="${v.td}" stroke-width="1"/>`,
|
|
66
66
|
habit: (v) =>
|
|
67
67
|
arms(v.p) +
|
|
68
68
|
`<path d="M29 94C27 78 35 71 50 71C65 71 73 78 71 94Z" fill="${v.p}"/><path d="M40 71.5L50 82L60 71.5" fill="none" stroke="${v.s}" stroke-width="3.2" stroke-linejoin="round"/><path d="M29 87H71" stroke="${v.s}" stroke-width="3"/><circle cx="50" cy="87" r="2" fill="${v.t}"/>`,
|
|
@@ -77,5 +77,5 @@ export const OUTFITS: Record<OutfitId, Part> = {
|
|
|
77
77
|
sailor: (v) =>
|
|
78
78
|
arms(v.s) +
|
|
79
79
|
torso(v.s) +
|
|
80
|
-
`<path d="
|
|
80
|
+
`<path d="M40 71.6L50 79L60 71.6" fill="none" stroke="${v.pd}" stroke-width="2.2" stroke-linejoin="round"/><path d="M45 73.6L55 73.6M46.4 76.4L53.6 76.4" stroke="${v.pd}" stroke-width="1.1" stroke-linecap="round"/><path d="M31 83.4Q50 88 69 83.4" fill="none" stroke="#d8c38c" stroke-width="3" stroke-linecap="round" stroke-dasharray="3.4 1.6"/><path d="M50 85V92" stroke="#d8c38c" stroke-width="2.2" stroke-linecap="round"/><path d="M31 89.6Q50 93.6 69 89.6V94H31Z" fill="${v.p}"/><rect x="55.6" y="75.6" width="6" height="5.4" rx="1" fill="${v.p}" opacity=".85"/>`,
|
|
81
81
|
};
|
|
@@ -84,15 +84,15 @@ export const PROPS: Record<PropId, Part> = {
|
|
|
84
84
|
),
|
|
85
85
|
map: (v) =>
|
|
86
86
|
held(
|
|
87
|
-
`<circle class="glow" cx="81" cy="76" r="8" fill="${v.p}"/><rect x="66" y="58" width="24" height="29" rx="2" fill="#f3e2b5" stroke="#c9a867" stroke-width="1.3"/><path d="M70 66Q78 62 82 70T86 80M70 82L74 76" fill="none" stroke="#8ec7d8" stroke-width="2" stroke-linecap="round" stroke-dasharray="3 2.4"/><path d="
|
|
87
|
+
`<circle class="glow" cx="81" cy="76" r="8" fill="${v.p}"/><rect x="66" y="58" width="24" height="29" rx="2" fill="#f3e2b5" stroke="#c9a867" stroke-width="1.3"/><path d="M70 66Q78 62 82 70T86 80M70 82L74 76" fill="none" stroke="#8ec7d8" stroke-width="2" stroke-linecap="round" stroke-dasharray="3 2.4"/><path d="M81 71.4l1.4 3.6 3.6 1.4-3.6 1.4-1.4 3.6-1.4-3.6-3.6-1.4 3.6-1.4z" fill="${v.p}"/>${hand(v, 78, 58, 4.4)}`,
|
|
88
88
|
),
|
|
89
89
|
telescope: (v) =>
|
|
90
90
|
held(
|
|
91
|
-
`<circle class="glow" cx="
|
|
91
|
+
`<circle class="glow" cx="76" cy="72" r="17" fill="${v.gl}"/><circle cx="76" cy="54.6" r="3.6" fill="none" stroke="${v.td}" stroke-width="2"/><path d="M73 57.4Q76 60 79 57.4" fill="none" stroke="${v.t}" stroke-width="2.4"/><circle cx="76" cy="72" r="13.4" fill="${v.t}" stroke="${v.td}" stroke-width="1.6"/><circle cx="76" cy="72" r="10" fill="#fbefd0" stroke="${v.td}" stroke-width="1"/><path d="M66 72H86M76 62V82" stroke="${v.td}" stroke-width="1.2"/><circle cx="76" cy="72" r="5.6" fill="none" stroke="${v.td}" stroke-width="1"/><path d="M70 66.6L82 77.4" stroke="${v.p}" stroke-width="1.6" stroke-linecap="round"/><circle cx="82" cy="65.6" r="1.8" fill="${v.p}"/>${hand(v, 76, 57, 4.4)}`,
|
|
92
92
|
),
|
|
93
93
|
wrench: (v) =>
|
|
94
94
|
held(
|
|
95
|
-
`<path class="glow" d="M90 52l1.6 3.6 3.8.4-2.8 2.6.8 3.8-3.4-2-3.4 2 .8-3.8-2.8-2.6 3.8-.4z" fill="${v.t}"/><path d="
|
|
95
|
+
`<path class="glow" d="M90 52l1.6 3.6 3.8.4-2.8 2.6.8 3.8-3.4-2-3.4 2 .8-3.8-2.8-2.6 3.8-.4z" fill="${v.t}"/><path d="M79 60L71 93M79 60L87 91" stroke="#aab3c5" stroke-width="3.4" stroke-linecap="round"/><path d="M79 60L71 93M79 60L87 91" stroke="#fff" stroke-width="1" stroke-linecap="round" opacity=".5"/><path d="M71 93l-.8 2.2M87 91l.8 2.2" stroke="#7d879b" stroke-width="1.6" stroke-linecap="round"/><path d="M73.4 81Q79 85 84.6 81" fill="none" stroke="#7d879b" stroke-width="1.6" stroke-linecap="round"/><circle cx="79" cy="60" r="3.4" fill="${v.t}" stroke="${v.td}" stroke-width="1.2"/>${hand(v, 79, 63.4, 4.4)}`,
|
|
96
96
|
),
|
|
97
97
|
spade: (v) =>
|
|
98
98
|
held(
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { CharacterError, checkHex, type Hex } from "./spec.ts";
|
|
2
|
+
|
|
3
|
+
/** How many skin tones a set holds: `CharacterSpec.skin` is an index from 0 to 5. */
|
|
4
|
+
const SKIN_COUNT = 6;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Six light East Asian skin tones, from very fair porcelain to light beige, for a host whose
|
|
8
|
+
* agents should read that way: `characters: { skins: EAST_ASIAN_SKINS }` in the configuration.
|
|
9
|
+
* Every tone leans pink to peach, never green or yellow, and the face lines read on each at 5:1
|
|
10
|
+
* or more. Stored looks keep their skin index, so they take the new tones as they are.
|
|
11
|
+
*/
|
|
12
|
+
export const EAST_ASIAN_SKINS: readonly Hex[] = Object.freeze([
|
|
13
|
+
"#fbebe0",
|
|
14
|
+
"#f8e1d2",
|
|
15
|
+
"#f5d8c5",
|
|
16
|
+
"#f1cfb8",
|
|
17
|
+
"#edc6ac",
|
|
18
|
+
"#e9bf9f",
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A host's skin tones checked: exactly six `#rrggbb` colors, light to dark by convention, in the
|
|
23
|
+
* order `CharacterSpec.skin` indexes them. Returns them lower-cased and frozen; `field` names the
|
|
24
|
+
* setting in the error.
|
|
25
|
+
*/
|
|
26
|
+
export function checkSkins(value: unknown, field = "skins"): readonly Hex[] {
|
|
27
|
+
if (!Array.isArray(value) || value.length !== SKIN_COUNT)
|
|
28
|
+
throw new CharacterError(
|
|
29
|
+
field,
|
|
30
|
+
`must be a list of exactly ${SKIN_COUNT} #rrggbb colors`,
|
|
31
|
+
);
|
|
32
|
+
return Object.freeze(
|
|
33
|
+
// Array.from, not map: map skips the holes of a sparse list, which would pass unchecked.
|
|
34
|
+
Array.from(value, (hex, index) => checkHex(hex, `${field}[${index}]`)),
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -151,11 +151,14 @@ export async function checkIntents(discord: Discord): Promise<Result> {
|
|
|
151
151
|
return ok("the Message Content intent is on");
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
-
/** The entry channel exists in the guild and the bot may do in it what the agents do. */
|
|
154
|
+
/** The entry channel exists in the guild and the bot may do in it what the agents do; skipped when the agent server is off, which has no entry channel. */
|
|
155
155
|
export async function checkChannel(
|
|
156
156
|
project: Project,
|
|
157
157
|
discord: Discord,
|
|
158
158
|
): Promise<Result> {
|
|
159
|
+
const raw = await project.raw();
|
|
160
|
+
if (raw.ok && object(raw.value).agentServer === false)
|
|
161
|
+
return skipped("agent server: off (config agentServer: false)");
|
|
159
162
|
const channel = await project.text("discord", "entryChannel");
|
|
160
163
|
const guild = await project.text("discord", "guild");
|
|
161
164
|
if (!channel || !guild)
|
|
@@ -330,6 +330,28 @@ export class PgAgentStore implements AgentDirectory {
|
|
|
330
330
|
});
|
|
331
331
|
}
|
|
332
332
|
|
|
333
|
+
/**
|
|
334
|
+
* Sets the picture only while the agent is active and still wears `character`, inside the
|
|
335
|
+
* store's write queue, so a look saved meanwhile keeps its own picture. Returns whether it was
|
|
336
|
+
* written.
|
|
337
|
+
*/
|
|
338
|
+
async setAvatarIfLook(
|
|
339
|
+
name: string,
|
|
340
|
+
character: CharacterSpec,
|
|
341
|
+
avatarHash: string,
|
|
342
|
+
): Promise<boolean> {
|
|
343
|
+
return this.#serial(async () => {
|
|
344
|
+
const rows: AgentRow[] = await this.#sql`
|
|
345
|
+
UPDATE agents SET avatar_hash = ${avatarHash}, updated_at = now()
|
|
346
|
+
WHERE guild_id = ${this.#guild} AND name = ${name} AND status = 'active'
|
|
347
|
+
AND character = ${character}::jsonb
|
|
348
|
+
RETURNING *`;
|
|
349
|
+
if (!rows[0]) return false;
|
|
350
|
+
this.#keepAgentRow(rows[0]);
|
|
351
|
+
return true;
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
333
355
|
#checkMembers(members: readonly string[], host: string): void {
|
|
334
356
|
if (
|
|
335
357
|
new Set(members).size !== members.length ||
|
|
@@ -277,6 +277,11 @@ export class DiscordAgentTeam implements AgentOps, AgentTeam {
|
|
|
277
277
|
return this.#lifecycle.start();
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
+
/** Ends the background work the team started. */
|
|
281
|
+
stop(): void {
|
|
282
|
+
this.#lifecycle.stop();
|
|
283
|
+
}
|
|
284
|
+
|
|
280
285
|
/** Starts an agent's or every group member's conversation over; call inside the channel's queue. */
|
|
281
286
|
startFresh(channel: ChannelKey): Promise<void> {
|
|
282
287
|
return this.#lifecycle.startFresh(channel);
|