Give bots skills: runbooks they load when a job matches

A skill is a runbook: a name, a unique slug, a one-line description of when
it applies, and the markdown body. A run only ever sees the slug and
description (one load_skill tool carries them), and reads the whole body
only when it calls the tool, so a long runbook costs nothing until needed.

Skills are the third extension kind beside MCP servers and custom tools, on
the same botExtensionsSql path, so per-bot, per-workspace and all-bots
grants work the same way, and attachDefaultExtensions hands an every-bot
skill to new bots. load_skill is a reserved tool name, so a custom tool
cannot shadow it, and a built-in wins a clash.

Managed on Settings > Skills; ExtensionRow links each kind to its own page
and a skill never reports a failure, since nothing connects or runs.
This commit is contained in:
2026-10-22 12:31:42 +01:00
parent 379ef35e08
commit 08d02e6033
62 changed files with 1438 additions and 79 deletions
+1
View File
@@ -53,3 +53,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
/public/sitemap.xml /public/sitemap.xml
/public/robots.txt /public/robots.txt
/public/sitemap-chunks/ /public/sitemap-chunks/
/.tmp
+3 -2
View File
@@ -194,7 +194,8 @@ Every folder on the data volume, browsable read-only: a bot's own, a workspace's
- **Per-bot models:** in a workspace, its `workspace_harness_settings` row wins (`source: "workspace"`). Otherwise a worker bot inherits the defaults per harness unless it has a `bot_harness_settings` row (same fields as `harness_settings`, unique per bot and harness). `grabHarnessSetting({ harness, bot_id })` returns the bot's row (`source: "bot"`) or the default; the coordinator always gets the default. Every entry point takes `bot_id` and passes it on (text loop -> media reading/transcription), so a bot's run uses its own models throughout. Saved by `saveBotHarnessSetting` (`inherit` deletes the row); the page data is `grabBotHarnessOverview`. A bot's chat header has a ghost model icon (`(partials)/header-icon-button.tsx`; the text model and provider show under the bot's name) that opens a modal with every harness (`(partials)/bot-models-modal.tsx`); the modal and `/admin/bots/[bot_id]/models` both use `(partials)/bot-harness-form.tsx`. On the coordinator the modal edits the defaults themselves (`api/admin/harness/save-settings`, the same settings as Settings, under Models, without the providers): super admins only, the icon is disabled for everyone else. Every one of these wraps `settings/models/(partials)/harness-settings-form.tsx`: per harness a provider select first (`harness-provider-select.tsx`: any provider, one provider, or on a bot "Same as the default"), then the model select narrowed to it. Removing a provider (`deleteHarnessProvider`) puts default and bot settings that picked it or its models back on any provider (`save` prop; "Same as the default" shows when the overview item has `default`). - **Per-bot models:** in a workspace, its `workspace_harness_settings` row wins (`source: "workspace"`). Otherwise a worker bot inherits the defaults per harness unless it has a `bot_harness_settings` row (same fields as `harness_settings`, unique per bot and harness). `grabHarnessSetting({ harness, bot_id })` returns the bot's row (`source: "bot"`) or the default; the coordinator always gets the default. Every entry point takes `bot_id` and passes it on (text loop -> media reading/transcription), so a bot's run uses its own models throughout. Saved by `saveBotHarnessSetting` (`inherit` deletes the row); the page data is `grabBotHarnessOverview`. A bot's chat header has a ghost model icon (`(partials)/header-icon-button.tsx`; the text model and provider show under the bot's name) that opens a modal with every harness (`(partials)/bot-models-modal.tsx`); the modal and `/admin/bots/[bot_id]/models` both use `(partials)/bot-harness-form.tsx`. On the coordinator the modal edits the defaults themselves (`api/admin/harness/save-settings`, the same settings as Settings, under Models, without the providers): super admins only, the icon is disabled for everyone else. Every one of these wraps `settings/models/(partials)/harness-settings-form.tsx`: per harness a provider select first (`harness-provider-select.tsx`: any provider, one provider, or on a bot "Same as the default"), then the model select narrowed to it. Removing a provider (`deleteHarnessProvider`) puts default and bot settings that picked it or its models back on any provider (`save` prop; "Same as the default" shows when the overview item has `default`).
- **Tools:** `HarnessTool` (built per run with its bot bound): own tools validate with a zod `schema`; tools described elsewhere pass a JSON schema as `parameters` and validate themselves. `run-tool-call.ts` reports bad calls and failures back to the model as `Error: ...`, and caps every tool call at `AppData.HarnessToolTimeoutMs` (aborted, its result handed back as an error the model can work around). Bot tools in `src/harness/tools/storage/`: `list_files`, `read_file`, `write_file`, `search_files`, `move_file`, `delete_file`, `ingest_file`, `copy_file`, `generate_image`, `make_pdf` (`grabBotFileTools`, every run, see Bot files and folders). Bot tools in `src/harness/tools/bot/`: `update_memory`, `update_instructions` (`grabBotTools`, every run), `start_task` and, for the coordinator with workers, `delegate_task` (`grabBotChatTools`, chat replies only; the job's `requested_by_bot_id` is the coordinator, and `relayJobToRequester` posts the worker's result or failure in its conversation, pushed as `relay` by `pushBotNotice`), `ask_user_to_take_over` (see Take-over), plus for the coordinator `create_workspace` and `update_workspace` (a new workspace with its shared context, or changing one; both also in its tasks, so a task that gathers material can write it into the workspace), `create_bot` (add a worker with its name, role and instructions, and join it to a workspace straight away) and, in its own chat, `add_bot_to_workspace` (put an existing worker in one of the user's workspaces) and `remove_bot_from_workspace` (take it out, keeping its conversation there), `assign_computer` (grant or revoke a worker's direct access, `add/remove-bot-to/from-computer`; access through a workspace stays and the result says so), `update_bot` (change a worker's name, role or core instructions, `src/functions/backend/db/bots/update-bot.ts`), `find_bot` and `find_workspace` (look the crew, or the live workspaces, up by id, name, role/description or instructions/context), `delete_bot` (a worker with its conversations, tasks, schedules and workspace places; the coordinator can't go) and `delete_workspace` (its conversations, tasks, schedules and links; its bots and computers stay). `search_computers` (`src/harness/tools/computer/search-computers-tool.ts`, chat replies and tasks) runs one read-only command on all of a bot's computers at once over the sandbox REST API (`run-computer-command.ts`), each output under its `[computer:ID]`, secrets redacted, capped by `AppData.ComputerSearch*`. - **Tools:** `HarnessTool` (built per run with its bot bound): own tools validate with a zod `schema`; tools described elsewhere pass a JSON schema as `parameters` and validate themselves. `run-tool-call.ts` reports bad calls and failures back to the model as `Error: ...`, and caps every tool call at `AppData.HarnessToolTimeoutMs` (aborted, its result handed back as an error the model can work around). Bot tools in `src/harness/tools/storage/`: `list_files`, `read_file`, `write_file`, `search_files`, `move_file`, `delete_file`, `ingest_file`, `copy_file`, `generate_image`, `make_pdf` (`grabBotFileTools`, every run, see Bot files and folders). Bot tools in `src/harness/tools/bot/`: `update_memory`, `update_instructions` (`grabBotTools`, every run), `start_task` and, for the coordinator with workers, `delegate_task` (`grabBotChatTools`, chat replies only; the job's `requested_by_bot_id` is the coordinator, and `relayJobToRequester` posts the worker's result or failure in its conversation, pushed as `relay` by `pushBotNotice`), `ask_user_to_take_over` (see Take-over), plus for the coordinator `create_workspace` and `update_workspace` (a new workspace with its shared context, or changing one; both also in its tasks, so a task that gathers material can write it into the workspace), `create_bot` (add a worker with its name, role and instructions, and join it to a workspace straight away) and, in its own chat, `add_bot_to_workspace` (put an existing worker in one of the user's workspaces) and `remove_bot_from_workspace` (take it out, keeping its conversation there), `assign_computer` (grant or revoke a worker's direct access, `add/remove-bot-to/from-computer`; access through a workspace stays and the result says so), `update_bot` (change a worker's name, role or core instructions, `src/functions/backend/db/bots/update-bot.ts`), `find_bot` and `find_workspace` (look the crew, or the live workspaces, up by id, name, role/description or instructions/context), `delete_bot` (a worker with its conversations, tasks, schedules and workspace places; the coordinator can't go) and `delete_workspace` (its conversations, tasks, schedules and links; its bots and computers stay). `search_computers` (`src/harness/tools/computer/search-computers-tool.ts`, chat replies and tasks) runs one read-only command on all of a bot's computers at once over the sandbox REST API (`run-computer-command.ts`), each output under its `[computer:ID]`, secrets redacted, capped by `AppData.ComputerSearch*`.
- **MCP:** `src/harness/mcp/`: `connectMcpServer` (Streamable HTTP, SSE fallback), `grabMcpTools` (each server tool becomes a `HarnessTool` with its JSON schema), `mcpResultToText` (text as is, images read by the vision harness, `isError` thrown). Computers are reached this way: `openComputerTools` (`src/harness/tools/computer/`) connects to the computer's AIO sandbox at `grabComputerMcpUrl` (the `MAIN_COMPUTER_URL` app setting for the main computer, for a dev server outside docker, e.g. `http://localhost:8170`, else `http://<hostname>:<port>/mcp`, or its IP address when the hostname can't be reached), leaves out `ComputerMcpExcludedTools`, and sets the computer's envs (see Envs). - **MCP:** `src/harness/mcp/`: `connectMcpServer` (Streamable HTTP, SSE fallback), `grabMcpTools` (each server tool becomes a `HarnessTool` with its JSON schema), `mcpResultToText` (text as is, images read by the vision harness, `isError` thrown). Computers are reached this way: `openComputerTools` (`src/harness/tools/computer/`) connects to the computer's AIO sandbox at `grabComputerMcpUrl` (the `MAIN_COMPUTER_URL` app setting for the main computer, for a dev server outside docker, e.g. `http://localhost:8170`, else `http://<hostname>:<port>/mcp`, or its IP address when the hostname can't be reached), leaves out `ComputerMcpExcludedTools`, and sets the computer's envs (see Envs).
- **MCPs and custom tools (extensions):** installs add their own MCP servers (`mcp_servers`, Settings, under MCPs) and tools (`custom_tools`, Settings, under Tools). A bot gets the ones given to it (`bot_mcp_servers`, `bot_custom_tools`) and, in a workspace, that workspace's (`workspace_mcp_servers`, `workspace_custom_tools`); `botExtensionsSql` (`src/utils/bot-extensions-sql.ts`, tested) resolves a run's, `grabBotExtensions` reads them. `all_bots` ("every bot by default") only decides what bots are given: turning it on gives it to every bot (`attachToAllBots`, the coordinator included) and every new bot gets it (`attachDefaultExtensions`, from `createBot` and `ensureCoordinatorBot`); turning it off takes it from no one, and a bot that drops one keeps it dropped (`src/scripts/attach-every-bot-extensions.ts`, safe to rerun, moved installs from the old "reaches every bot" meaning). Super admins define them. Super admins and admins (the super admin alone for the coordinator, `assertBotExtensionManager`) give and take them per bot on the bot's settings page and under its chat's composer (`composer-tools.tsx`: one "N tools" button (`composer-tools-modal.tsx`) counting everything it can use there, which opens a modal listing them (a workspace's not removable) with the add picker, so ten of them don't fill the composer; beside it `composer-computer-button.tsx` opens the bot's current computer in the viewer), and per workspace on the workspace's settings page; the settings pages share `settings/(partials)/extensions-group.tsx`, all through `useExtensions` (`src/hooks/`), `grabBotExtensionsOverview` / `grabWorkspaceExtensionsOverview` (`bot_extensions` / `workspace_extensions` page props, both on `grabExtensionsOverview`) and `api/admin/bots/` and `api/admin/workspaces/` `add-extension`, `remove-extension` (`setBotExtension`, `setWorkspaceExtension`); the MCPs and Tools pages' Access modal sets bots and workspaces in bulk (`setExtensionAccess`, an admin never touches the coordinator's or `all_bots`). Helpers in `src/functions/backend/db/extensions/`, API in `api/admin/mcps/` and `api/admin/tools/`, zod in `src/schemas/extension-schema.ts`. `openBotExtensions` (`src/harness/tools/extensions/`) turns them into harness tools for chat replies and tasks (built-ins win a name clash; `close()` in the runners' `finally`; `note` tells the bot about a server it can't use). Templates (`fillTemplate`, `src/utils/fill-template.ts`): `{{args.x}}` and `{{env.KEY}}`, envs from `grabRunEnvs` (the bot's own beat the workspace's, which beat general; app settings never, no need to be set on computers); secret values are redacted from every result. - **MCPs and custom tools (extensions):** installs add their own MCP servers (`mcp_servers`, Settings, under MCPs) and tools (`custom_tools`, Settings, under Tools). A bot gets the ones given to it (`bot_mcp_servers`, `bot_custom_tools`) and, in a workspace, that workspace's (`workspace_mcp_servers`, `workspace_custom_tools`); `botExtensionsSql` (`src/utils/bot-extensions-sql.ts`, tested) resolves a run's, `grabBotExtensions` reads them. `all_bots` ("every bot by default") only decides what bots are given: turning it on gives it to every bot (`attachToAllBots`, the coordinator included) and every new bot gets it (`attachDefaultExtensions`, from `createBot` and `ensureCoordinatorBot`); turning it off takes it from no one, and a bot that drops one keeps it dropped (`src/scripts/attach-every-bot-extensions.ts`, safe to rerun, moved installs from the old "reaches every bot" meaning). Super admins define them. Super admins and admins (the super admin alone for the coordinator, `assertBotExtensionManager`) give and take them per bot on the bot's settings page and under its chat's composer (`composer-tools.tsx`: one "N tools" button (`composer-tools-modal.tsx`) counting everything it can use there, which opens a modal listing them (a workspace's not removable) with the add picker, so ten of them don't fill the composer; beside it `composer-computer-button.tsx` opens the bot's current computer in the viewer), and per workspace on the workspace's settings page; the settings pages share `settings/(partials)/extensions-group.tsx`, all through `useExtensions` (`src/hooks/`), `grabBotExtensionsOverview` / `grabWorkspaceExtensionsOverview` (`bot_extensions` / `workspace_extensions` page props, both on `grabExtensionsOverview`) and `api/admin/bots/` and `api/admin/workspaces/` `add-extension`, `remove-extension` (`setBotExtension`, `setWorkspaceExtension`); the MCPs and Tools pages' Access modal sets bots and workspaces in bulk (`setExtensionAccess`, an admin never touches the coordinator's or `all_bots`). Helpers in `src/functions/backend/db/extensions/`, API in `api/admin/mcps/` and `api/admin/tools/`, zod in `src/schemas/extension-schema.ts`.
- **Skills** are the third extension kind (`skills`, Settings, under Skills; `bot_skills` / `workspace_skills`, same `all_bots` rules, `ExtensionTables.kind = "skill"`). A skill is a runbook: `name`, a unique `slug` a bot loads it by, a one-line `description` (when it applies) and the markdown `body`, capped by `AppData.SkillBodyMaxChars`. Unlike an MCP server or a tool it has no `status`/`last_error`: nothing connects or runs, and it never reports a failure (`ExtensionRow` and `ComposerToolRow` key the state line off `kind == "skill"`). A run sees only the slug and description; `openBotSkills` (`src/harness/tools/skills/`, fed by the `skills` that `grabBotExtensions` already reads) adds one `load_skill` tool that returns the body, cut at `AppData.SkillBodyShownChars` with a line saying so, and throws the valid slugs for an unknown one. Slugs are checked by `checkSkillSlug` (`src/utils/check-skill-slug.ts`, tested); `load_skill` is in `ReservedToolNames` so a custom tool can't shadow it. The settings page is `api/admin/skills/` (`save-skill`, `delete-skill` super admin only, `set-skill-access`), `grabSkillsOverview`; `deleteSkill` deletes the two link tables itself, since foreign keys are off outside the server process. `openBotExtensions` (`src/harness/tools/extensions/`) turns them into harness tools for chat replies and tasks (built-ins win a name clash; `close()` in the runners' `finally`; `note` tells the bot about a server it can't use). Templates (`fillTemplate`, `src/utils/fill-template.ts`): `{{args.x}}` and `{{env.KEY}}`, envs from `grabRunEnvs` (the bot's own beat the workspace's, which beat general; app settings never, no need to be set on computers); secret values are redacted from every result.
- **Custom tools** (`CustomToolKinds` in `src/dict/custom-tool-dict.ts`): a name (`checkCustomToolName`: snake_case, never a built-in's, `ReservedToolNames`), a description, a JSON-schema `parameters` and a `config` per kind, built by `buildCustomTool` (`src/harness/tools/custom/`): `http` (`runHttpTool`: the app fetches, arguments URL-encoded in the URL, no body template sends the arguments as JSON), `script` (`runScriptTool`: bash/python/javascript on the run's computer, the task's or in chat the bot's current one, over the sandbox REST API in a subshell, computer envs set, arguments as JSON in `COBBLE_ARGS` and on stdin; left out without a computer), `prompt` (`runPromptTool`: one `generateText` turn with the bot's models). Super admins try one from the page (`testCustomTool`, the main computer for scripts): a saved one from its row (Try), or the form as it is, saved or not (`draft`, nothing recorded), from the Test panel in its modal (`custom-tool-test-panel.tsx`). Every run (a bot's call or a try; not bad arguments or a cancel) is saved as the tool's `status`, `last_error` and `last_checked_at` (`recordCustomToolRun`); editing resets it to `untested`. - **Custom tools** (`CustomToolKinds` in `src/dict/custom-tool-dict.ts`): a name (`checkCustomToolName`: snake_case, never a built-in's, `ReservedToolNames`), a description, a JSON-schema `parameters` and a `config` per kind, built by `buildCustomTool` (`src/harness/tools/custom/`): `http` (`runHttpTool`: the app fetches, arguments URL-encoded in the URL, no body template sends the arguments as JSON), `script` (`runScriptTool`: bash/python/javascript on the run's computer, the task's or in chat the bot's current one, over the sandbox REST API in a subshell, computer envs set, arguments as JSON in `COBBLE_ARGS` and on stdin; left out without a computer), `prompt` (`runPromptTool`: one `generateText` turn with the bot's models). Super admins try one from the page (`testCustomTool`, the main computer for scripts): a saved one from its row (Try), or the form as it is, saved or not (`draft`, nothing recorded), from the Test panel in its modal (`custom-tool-test-panel.tsx`). Every run (a bot's call or a try; not bad arguments or a cancel) is saved as the tool's `status`, `last_error` and `last_checked_at` (`recordCustomToolRun`); editing resets it to `untested`.
- **MCP servers** (`McpTransports` in `src/dict/mcp-dict.ts`): `remote` (URL plus templated headers, `connectMcpServer`) or `stdio` (a command run with the host's shell, `hostShell`, in `DATA_DIR/mcp/<id>` (`grabMcpDir`), after its `setup_command`, which `runMcpSetup` reruns whenever it changes (marker file) or on Reinstall; its env is only its own `env` map plus the shell basics, `grabMcpStdioEnv`, never the app's). Their tools are listed into `tools_cache` (`syncMcpServer`: on save, Refresh, and hourly for remote ones by the `sync-mcp-servers` cron) and named `<slug>__<tool>` (`mcpToolName`); the modal's Test connection checks the form as it is, saved or not, outside the pool (`testMcpDraft`, `testMcpServer`, `api/admin/mcps/test-mcp`, a new stdio server's setup runs in `mcp/0`); a run that can't connect, or connects again after failing, updates its `status` too (`recordMcpServerConnection`); runs build tools from the cache (`mcpHarnessTool`) and connect on the first call (`openMcpServer`). stdio servers live in a per-process pool (`src/harness/mcp/mcp-stdio-pool.ts`), stopped after `AppData.McpStdioIdleMs` idle, on save or delete. - **MCP servers** (`McpTransports` in `src/dict/mcp-dict.ts`): `remote` (URL plus templated headers, `connectMcpServer`) or `stdio` (a command run with the host's shell, `hostShell`, in `DATA_DIR/mcp/<id>` (`grabMcpDir`), after its `setup_command`, which `runMcpSetup` reruns whenever it changes (marker file) or on Reinstall; its env is only its own `env` map plus the shell basics, `grabMcpStdioEnv`, never the app's). Their tools are listed into `tools_cache` (`syncMcpServer`: on save, Refresh, and hourly for remote ones by the `sync-mcp-servers` cron) and named `<slug>__<tool>` (`mcpToolName`); the modal's Test connection checks the form as it is, saved or not, outside the pool (`testMcpDraft`, `testMcpServer`, `api/admin/mcps/test-mcp`, a new stdio server's setup runs in `mcp/0`); a run that can't connect, or connects again after failing, updates its `status` too (`recordMcpServerConnection`); runs build tools from the cache (`mcpHarnessTool`) and connect on the first call (`openMcpServer`). stdio servers live in a per-process pool (`src/harness/mcp/mcp-stdio-pool.ts`), stopped after `AppData.McpStdioIdleMs` idle, on save or delete.
- **Check before and after acting:** every reply and task gets `ActionCheckNote` (`src/data/harness-prompts.ts`): look for an existing record before creating one (update it instead), and verify every action took effect before saying it's done. The tools back it: `update_memory` and `update_instructions` read the save back (`checkSavedText`, `grabBotSavedText`) and fail if it didn't take; `create_bot` refuses a name already on the crew, `create_schedule` a twin of one of the bot's schedules (`findDuplicateCronJob`: same place and same title, or same timing and brief), and `start_task`/`delegate_task` a task with the same title still open in that session (`grabOpenBotTask`); `create_workspace` already refuses a name in use. - **Check before and after acting:** every reply and task gets `ActionCheckNote` (`src/data/harness-prompts.ts`): look for an existing record before creating one (update it instead), and verify every action took effect before saying it's done. The tools back it: `update_memory` and `update_instructions` read the save back (`checkSavedText`, `grabBotSavedText`) and fail if it didn't take; `create_bot` refuses a name already on the crew, `create_schedule` a twin of one of the bot's schedules (`findDuplicateCronJob`: same place and same title, or same timing and brief), and `start_task`/`delegate_task` a task with the same title still open in that session (`grabOpenBotTask`); `create_workspace` already refuses a name in use.
@@ -211,7 +212,7 @@ Live routes under `/admin` (see `AdminSections`): `/admin/bots`, `/admin/workspa
- `/admin/help` (live) - familiar problems and their fixes, one `SettingsGroup` per topic (`(data)/help-topics.ts`, markdown rendered with the chat's `MessageMarkdownComponents`, the topic id as the group's anchor). First topic: signing in to Google and other strict sites with Cookie-Editor over the Desktop view (`HelpSignInTopicId` in `src/dict/help-dict.ts`; `ask_user_to_take_over` points bots to it) - `/admin/help` (live) - familiar problems and their fixes, one `SettingsGroup` per topic (`(data)/help-topics.ts`, markdown rendered with the chat's `MessageMarkdownComponents`, the topic id as the group's anchor). First topic: signing in to Google and other strict sites with Cookie-Editor over the Desktop view (`HelpSignInTopicId` in `src/dict/help-dict.ts`; `ask_user_to_take_over` points bots to it)
- Routines, Files, Activity and Help are the `RailPages` (`src/dict/rail-pages-dict.ts`), opened from the rail's user popover (which lists them, then Settings and Sign out). On them the rail shows its own aside (`(partials)/rail-pages-list.tsx`): a back link to the bots, then those pages, like settings - Routines, Files, Activity and Help are the `RailPages` (`src/dict/rail-pages-dict.ts`), opened from the rail's user popover (which lists them, then Settings and Sign out). On them the rail shows its own aside (`(partials)/rail-pages-list.tsx`): a back link to the bots, then those pages, like settings
- `/admin/settings/backups` - super admin only (listed in `SettingsPages`), two groups. Database snapshots: create, list, restore, download and delete in `db/.backups` (`bun-sqlite backup`); "Restore from file" beside "Create backup" opens `upload-restore-modal.tsx` (upload a SQLite database file, which is saved to `.backups` then restored with `bun-sqlite import <file>`, since `restore` is interactive). Whole-data archives: `write-data-backup-archive.ts` tars all of `DATA_DIR` (database, media, computer homes, bot and workspace files, MCP installs) into `BACKUP_ARCHIVE_DIR` as `cobble-data-<epoch-ms>.tar.gz` (via `tar -czf` into a `.part` name, renamed on success so a cut-off run is never listed, with a `--transform` putting every member under one root folder named after the archive), keeping the newest `AppData.DataBackupsKept` and never touching `db/.backups`; its own "Restore from file" uploads a tar.gz (`upload-restore-data.ts`, gzip header checked first). `restore-data-backup.ts` extracts an archive to a temp dir, requires exactly one root folder holding a `db` folder (`findArchiveRoot`, since an upload is saved under a fresh timestamp and its inner name no longer matches the file name), takes a safety archive before touching anything (`write-data-backup-archive.ts`, not the locked `create-data-backup.ts`), empties `DATA_DIR` (the snapshots included) and copies the archive's in, then `restart-app.ts` restarts the process through the docker socket after the response lands (`process.exit`, the container's restart policy brings it back), because the open SQLite handle points at the replaced file. Row actions are icon-only (`RowIconButton`, `src/components/general/row-icon-button.tsx`): restore, download, delete. `BACKUP_ARCHIVE_DIR` is a `ProcessOnlyEnvKeys` start-up path (the installer mounts `$COBBLE_HOME/backups-archives` there; outside docker it falls back to `~/.cobble/backups`). Every backup and restore, of either kind, runs one at a time via `with-backup-run-lock.ts`. Helpers in `src/functions/backend/backups/`, API routes in `api/admin/backups/` - `/admin/settings/backups` - super admin only (listed in `SettingsPages`), two groups. Database snapshots: create, list, restore, download and delete in `db/.backups` (`bun-sqlite backup`); "Restore from file" beside "Create backup" opens `upload-restore-modal.tsx` (upload a SQLite database file, which is saved to `.backups` then restored with `bun-sqlite import <file>`, since `restore` is interactive). Whole-data archives: `write-data-backup-archive.ts` tars all of `DATA_DIR` (database, media, computer homes, bot and workspace files, MCP installs) into `BACKUP_ARCHIVE_DIR` as `cobble-data-<epoch-ms>.tar.gz` (via `tar -czf` into a `.part` name, renamed on success so a cut-off run is never listed, with a `--transform` putting every member under one root folder named after the archive), keeping the newest `AppData.DataBackupsKept` and never touching `db/.backups`; its own "Restore from file" uploads a tar.gz (`upload-restore-data.ts`, gzip header checked first). `restore-data-backup.ts` extracts an archive to a temp dir, requires exactly one root folder holding a `db` folder (`findArchiveRoot`, since an upload is saved under a fresh timestamp and its inner name no longer matches the file name), takes a safety archive before touching anything (`write-data-backup-archive.ts`, not the locked `create-data-backup.ts`), empties `DATA_DIR` (the snapshots included) and copies the archive's in, then `restart-app.ts` restarts the process through the docker socket after the response lands (`process.exit`, the container's restart policy brings it back), because the open SQLite handle points at the replaced file. Row actions are icon-only (`RowIconButton`, `src/components/general/row-icon-button.tsx`): restore, download, delete. `BACKUP_ARCHIVE_DIR` is a `ProcessOnlyEnvKeys` start-up path (the installer mounts `$COBBLE_HOME/backups-archives` there; outside docker it falls back to `~/.cobble/backups`). Every backup and restore, of either kind, runs one at a time via `with-backup-run-lock.ts`. Helpers in `src/functions/backend/backups/`, API routes in `api/admin/backups/`
- `/admin/settings` (live) - redirects to `SettingsDefaultPath` (`/admin/settings/general`). Sub pages: `general/` (theme: System/Light/Dark per device via `useThemePreference`, stored in `localStorage[AppData.ThemeStorageKey]`, the same key `ThemeInitScript` and twui's `ColorSchemeSelector` read; applies instantly, no save bar; for super admins the prompt appendix (`prompt-section.tsx`, `system-prompt-append-form.tsx`) saving `api/admin/settings/system-prompt-append` into the `bot_system_prompt_append` app setting; and, for `canManageCrew`, the bot file quota: `storage-section.tsx`, `storage-quota-form.tsx` saving `api/admin/settings/storage-quota` into `app_settings`), `profile/` (current user profile), `notifications/` (this device's Web Push switch, a test send, and every device the user subscribed), `change-password/` (set/change own password) `models/` (super admin only, `super_admin_only` in the dict hides it from the rail: model providers and which model each harness uses) and `environment/` (super admin only: app settings and the envs bots and computers use, per scope), each wrapped in `SettingsShell` (`(partials)/settings-shell.tsx`: 64px pane header like the conversation header, then one centered column). Content is built from `SettingsGroup` blocks (label + description left, controls right, hairline-separated, no cards) and ends with the sticky `SettingsSaveBar` (Save enabled only when dirty, Discard, one-line status). Saves don't reload the page. Keep save notices in component state: `useFormInit` clears `status` whenever the form changes. `bots/` (the crew: chat, edit with `BotForm`, models, delete a worker via `api/admin/bots/delete-bot`), `workspaces/` (open, create through the Add flow, delete), `computers/` (state and resources, live; super admins create, edit with `AddComputerForm`, retry, delete), `mcps/` and `tools/` (see MCPs and custom tools: super admins add, edit, refresh or try, delete; admins only set Access, `extension-access-modal.tsx`; add, edit, access and try open in modals (`AddModalShell`)), each row with a status line under it (`extension-status-line.tsx`: connected or not, last run worked or failed, when, and why), those five for `canManageCrew` only (`grab-crew-settings-server-props.ts`, `can_view` in the dict) and `users/` (for `canListUsers`: super admins and admins; the dict's `can_view` gates it in the rail; add with a password (`canCreateUser`), edit (`canUpdateUser`), both with the one `UserForm` (`admin/settings/users/(partials)/user-form/`, `useFormInit`, photo included, saved in one request), revoke (adds `revoked`, `canRevokeUser`), delete (`canRevokeUser`'s rules, never yourself), pick their user types from `UserTypes` (`user-types-select.tsx`: only the ones `filterAssignableUserTypes` allows, never `UneditableUserTypes`; at least one, since login needs a type; `saveUser` checks each grant and removal with `userTypeAssignment`; the super admin's types are never shown or changed there, and they can't be revoked; the password is entered twice, `SaveUserRequestSchema` checks they match), delete with everything theirs; `api/admin/users/save-user`, `delete-user`). The lists share `SettingsList`, `SettingsListRow`, `SettingsRowAction` and `use-settings-list`. Sub pages are declared once in `src/dict/settings-pages-dict.ts`; on every `/admin/settings/*` page the rail swaps the crew for a back link plus that list. Add a sub page by adding its folder and a dict entry - `/admin/settings` (live) - redirects to `SettingsDefaultPath` (`/admin/settings/general`). Sub pages: `general/` (theme: System/Light/Dark per device via `useThemePreference`, stored in `localStorage[AppData.ThemeStorageKey]`, the same key `ThemeInitScript` and twui's `ColorSchemeSelector` read; applies instantly, no save bar; for super admins the prompt appendix (`prompt-section.tsx`, `system-prompt-append-form.tsx`) saving `api/admin/settings/system-prompt-append` into the `bot_system_prompt_append` app setting; and, for `canManageCrew`, the bot file quota: `storage-section.tsx`, `storage-quota-form.tsx` saving `api/admin/settings/storage-quota` into `app_settings`), `profile/` (current user profile), `notifications/` (this device's Web Push switch, a test send, and every device the user subscribed), `change-password/` (set/change own password) `models/` (super admin only, `super_admin_only` in the dict hides it from the rail: model providers and which model each harness uses) and `environment/` (super admin only: app settings and the envs bots and computers use, per scope), each wrapped in `SettingsShell` (`(partials)/settings-shell.tsx`: 64px pane header like the conversation header, then one centered column). Content is built from `SettingsGroup` blocks (label + description left, controls right, hairline-separated, no cards) and ends with the sticky `SettingsSaveBar` (Save enabled only when dirty, Discard, one-line status). Saves don't reload the page. Keep save notices in component state: `useFormInit` clears `status` whenever the form changes. `bots/` (the crew: chat, edit with `BotForm`, models, delete a worker via `api/admin/bots/delete-bot`), `workspaces/` (open, create through the Add flow, delete), `computers/` (state and resources, live; super admins create, edit with `AddComputerForm`, retry, delete), `mcps/`, `tools/` and `skills/` (see MCPs, custom tools and skills: super admins add, edit, refresh or try, delete; admins only set Access, `extension-access-modal.tsx`; add, edit, access and try open in modals (`AddModalShell`). A skill has no status: nothing connects or runs, so `skill-row.tsx` puts its slug, who gets it (`extension-access-label.ts`) and its description where a server's or tool's status line goes), each MCP server or tool row with a status line under it (`extension-status-line.tsx`: connected or not, last run worked or failed, when, and why), those five for `canManageCrew` only (`grab-crew-settings-server-props.ts`, `can_view` in the dict) and `users/` (for `canListUsers`: super admins and admins; the dict's `can_view` gates it in the rail; add with a password (`canCreateUser`), edit (`canUpdateUser`), both with the one `UserForm` (`admin/settings/users/(partials)/user-form/`, `useFormInit`, photo included, saved in one request), revoke (adds `revoked`, `canRevokeUser`), delete (`canRevokeUser`'s rules, never yourself), pick their user types from `UserTypes` (`user-types-select.tsx`: only the ones `filterAssignableUserTypes` allows, never `UneditableUserTypes`; at least one, since login needs a type; `saveUser` checks each grant and removal with `userTypeAssignment`; the super admin's types are never shown or changed there, and they can't be revoked; the password is entered twice, `SaveUserRequestSchema` checks they match), delete with everything theirs; `api/admin/users/save-user`, `delete-user`). The lists share `SettingsList`, `SettingsListRow`, `SettingsRowAction` and `use-settings-list`. Sub pages are declared once in `src/dict/settings-pages-dict.ts`; on every `/admin/settings/*` page the rail swaps the crew for a back link plus that list. Add a sub page by adding its folder and a dict entry
Every `/admin/*` page renders in the bots layout (`src/layouts/bots/`); there is no separate admin layout or aside. The rail header's Add icon (super admins and admins only, like the rail's "+" buttons) opens the Add flow (`(partials)/add-flow.tsx`, `AddFlowContext`): a bot, a workspace or (super admins) a computer, each form in a modal stacked over the choice, so closing it goes back there (the Bots and Workspaces labels have a "+" that opens the form directly; in a workspace's rail, "Workspace bots" has a "+" that opens `link-bots-modal.tsx` instead: the crew's workers not in it yet (`api/admin/workspaces/linkable-bots`), each added with `add-bot`, which pushes `server:bot-created`, and "New bot" stacking the bot form over it); on a workspace's pages new bots and computers join it. The rail shows the crew (`(partials)/rail-crew-list.tsx`, needs `bots` and `workspaces` in page props): the coordinator, the latest `AppData.RailRecentBots` workers (plus any open, unread or needing you, `pick-rail-bots`), "See all bots" with a few stones opening every bot in a modal, then Workspaces with a "+". On `/admin/workspaces/*` it shows the workspace's crew instead (`(partials)/rail-workspace-list.tsx`: back link, the workspace, then only the bots attached to it, "Workspace bots"; the coordinator stays in the main crew rail, and on a workspace bot's page the back link returns to the workspace page), except on `/admin/settings/*` where it shows "Back to bots" plus the settings sub pages (`(partials)/rail-settings-list.tsx`). Page heroes use the shared `AdminHero` component (`src/components/general/admin-hero.tsx`). Every `/admin/*` page renders in the bots layout (`src/layouts/bots/`); there is no separate admin layout or aside. The rail header's Add icon (super admins and admins only, like the rail's "+" buttons) opens the Add flow (`(partials)/add-flow.tsx`, `AddFlowContext`): a bot, a workspace or (super admins) a computer, each form in a modal stacked over the choice, so closing it goes back there (the Bots and Workspaces labels have a "+" that opens the form directly; in a workspace's rail, "Workspace bots" has a "+" that opens `link-bots-modal.tsx` instead: the crew's workers not in it yet (`api/admin/workspaces/linkable-bots`), each added with `add-bot`, which pushes `server:bot-created`, and "New bot" stacking the bot form over it); on a workspace's pages new bots and computers join it. The rail shows the crew (`(partials)/rail-crew-list.tsx`, needs `bots` and `workspaces` in page props): the coordinator, the latest `AppData.RailRecentBots` workers (plus any open, unread or needing you, `pick-rail-bots`), "See all bots" with a few stones opening every bot in a modal, then Workspaces with a "+". On `/admin/workspaces/*` it shows the workspace's crew instead (`(partials)/rail-workspace-list.tsx`: back link, the workspace, then only the bots attached to it, "Workspace bots"; the coordinator stays in the main crew rail, and on a workspace bot's page the back link returns to the workspace page), except on `/admin/settings/*` where it shows "Back to bots" plus the settings sub pages (`(partials)/rail-settings-list.tsx`). Page heroes use the shared `AdminHero` component (`src/components/general/admin-hero.tsx`).
+1
View File
@@ -58,6 +58,7 @@ your data on your own machine.
| Models | OpenRouter, OpenAI, Anthropic, Gemini, DeepSeek, Groq, Mistral, xAI, Ollama or any OpenAI-compatible server, per bot and per harness | | Models | OpenRouter, OpenAI, Anthropic, Gemini, DeepSeek, Groq, Mistral, xAI, Ollama or any OpenAI-compatible server, per bot and per harness |
| Voice | Dictate into the composer, play any bot reply, with speech-to-text and text-to-speech models of your choice | | Voice | Dictate into the composer, play any bot reply, with speech-to-text and text-to-speech models of your choice |
| Tools | Install your own MCP servers and custom HTTP, script or prompt tools, and give them to bots or workspaces | | Tools | Install your own MCP servers and custom HTTP, script or prompt tools, and give them to bots or workspaces |
| Skills | Write runbooks your bots follow. Each bot sees what a skill is for and reads the whole thing only when it needs it |
| App | Installable PWA with Web Push, dark mode, and a rail built for a phone | | App | Installable PWA with Web Push, dark mode, and a rail built for a phone |
## Screenshots ## Screenshots
+127
View File
@@ -2140,6 +2140,51 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
}, },
], ],
}, },
{
tableName: "skills",
tableDescription: `Runbooks bots follow (Settings, under Skills). A bot sees each one's name and description and reads the whole body only when it loads it with load_skill. Per bot (bot_skills), per workspace (workspace_skills) or every bot (all_bots)`,
fields: [
{
fieldName: "name",
fieldDescription: `What the settings page calls it, e.g. "Weekly SEO report"`,
dataType: "TEXT",
},
{
fieldName: "slug",
fieldDescription: `What a bot passes to load_skill: lowercase letters, numbers and hyphens. Unique`,
dataType: "TEXT",
unique: true,
},
{
fieldName: "description",
fieldDescription: `The one line a bot reads to decide whether the skill applies`,
dataType: "TEXT",
},
{
fieldName: "body",
fieldDescription: `The runbook itself, markdown. Read only when a bot loads the skill`,
dataType: "TEXT",
},
{
fieldName: "all_bots",
fieldDescription: `1 gives it to every bot, the coordinator included`,
dataType: "INTEGER",
options: [0, 1],
defaultValue: 0,
},
{
fieldName: "enabled",
dataType: "INTEGER",
options: [0, 1],
defaultValue: 1,
},
{
fieldName: "user_id",
fieldDescription: `Who created it. Not a foreign key: it belongs to the install`,
dataType: "INTEGER",
},
],
},
{ {
tableName: "bot_mcp_servers", tableName: "bot_mcp_servers",
tableDescription: `A bot that gets an MCP server's tools everywhere it works`, tableDescription: `A bot that gets an MCP server's tools everywhere it works`,
@@ -2304,6 +2349,88 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
}, },
], ],
}, },
{
tableName: "bot_skills",
tableDescription: `A bot that gets a skill everywhere it works`,
fields: [
{
fieldName: "bot_id",
dataType: "INTEGER",
foreignKey: {
cascadeDelete: true,
destinationTableName: "bots",
destinationTableColumnName: "id",
destinationTableColumnType: "INTEGER",
foreignKeyName: "bot_skills_bots_fk",
},
},
{
fieldName: "skill_id",
dataType: "INTEGER",
foreignKey: {
cascadeDelete: true,
destinationTableName: "skills",
destinationTableColumnName: "id",
destinationTableColumnType: "INTEGER",
foreignKeyName: "bot_skills_skills_fk",
},
},
],
uniqueConstraints: [
{
alias: "one_skill_per_bot",
constraintName: "A bot gets a skill once",
constraintTableFields: [{ value: "bot_id" }, { value: "skill_id" }],
},
],
indexes: [
{
indexName: "idx_bot_skills_skill_id",
indexTableFields: ["skill_id"],
},
],
},
{
tableName: "workspace_skills",
tableDescription: `Every bot in the workspace gets the skill there`,
fields: [
{
fieldName: "workspace_id",
dataType: "INTEGER",
foreignKey: {
cascadeDelete: true,
destinationTableName: "workspaces",
destinationTableColumnName: "id",
destinationTableColumnType: "INTEGER",
foreignKeyName: "workspace_skills_workspaces_fk",
},
},
{
fieldName: "skill_id",
dataType: "INTEGER",
foreignKey: {
cascadeDelete: true,
destinationTableName: "skills",
destinationTableColumnName: "id",
destinationTableColumnType: "INTEGER",
foreignKeyName: "workspace_skills_skills_fk",
},
},
],
uniqueConstraints: [
{
alias: "one_skill_per_workspace",
constraintName: "A workspace gets a skill once",
constraintTableFields: [{ value: "workspace_id" }, { value: "skill_id" }],
},
],
indexes: [
{
indexName: "idx_workspace_skills_skill_id",
indexTableFields: ["skill_id"],
},
],
},
{ {
tableName: "logs", tableName: "logs",
fields: [ fields: [
+78 -1
View File
@@ -35,10 +35,13 @@ export const BunSQLiteTables = [
"workspace_harness_settings", "workspace_harness_settings",
"mcp_servers", "mcp_servers",
"custom_tools", "custom_tools",
"skills",
"bot_mcp_servers", "bot_mcp_servers",
"workspace_mcp_servers", "workspace_mcp_servers",
"bot_custom_tools", "bot_custom_tools",
"workspace_custom_tools", "workspace_custom_tools",
"bot_skills",
"workspace_skills",
"logs", "logs",
"user_presence", "user_presence",
"push_subscriptions", "push_subscriptions",
@@ -1312,6 +1315,46 @@ export type BUN_SQLITE_COBBLE_CUSTOM_TOOLS = {
user_id?: number | ""; user_id?: number | "";
} }
export type BUN_SQLITE_COBBLE_SKILLS = {
/**
* The unique identifier of the record.
*/
id?: number | "";
/**
* The time when the record was created. (Unix Timestamp)
*/
created_at?: number | "";
/**
* The time when the record was updated. (Unix Timestamp)
*/
updated_at?: number | "";
/**
* What the settings page calls it, e.g. "Weekly SEO report"
*/
name?: string;
/**
* What a bot passes to load_skill: lowercase letters, numbers and hyphens. Unique
*/
slug?: string;
/**
* The one line a bot reads to decide whether the skill applies
*/
description?: string;
/**
* The runbook itself, markdown. Read only when a bot loads the skill
*/
body?: string;
/**
* 1 gives it to every bot, the coordinator included
*/
all_bots?: 0 | 1 | "";
enabled?: 0 | 1 | "";
/**
* Who created it. Not a foreign key: it belongs to the install
*/
user_id?: number | "";
}
export type BUN_SQLITE_COBBLE_BOT_MCP_SERVERS = { export type BUN_SQLITE_COBBLE_BOT_MCP_SERVERS = {
/** /**
* The unique identifier of the record. * The unique identifier of the record.
@@ -1380,6 +1423,40 @@ export type BUN_SQLITE_COBBLE_WORKSPACE_CUSTOM_TOOLS = {
custom_tool_id?: number | ""; custom_tool_id?: number | "";
} }
export type BUN_SQLITE_COBBLE_BOT_SKILLS = {
/**
* The unique identifier of the record.
*/
id?: number | "";
/**
* The time when the record was created. (Unix Timestamp)
*/
created_at?: number | "";
/**
* The time when the record was updated. (Unix Timestamp)
*/
updated_at?: number | "";
bot_id?: number | "";
skill_id?: number | "";
}
export type BUN_SQLITE_COBBLE_WORKSPACE_SKILLS = {
/**
* The unique identifier of the record.
*/
id?: number | "";
/**
* The time when the record was created. (Unix Timestamp)
*/
created_at?: number | "";
/**
* The time when the record was updated. (Unix Timestamp)
*/
updated_at?: number | "";
workspace_id?: number | "";
skill_id?: number | "";
}
export type BUN_SQLITE_COBBLE_LOGS = { export type BUN_SQLITE_COBBLE_LOGS = {
/** /**
* The unique identifier of the record. * The unique identifier of the record.
@@ -1720,4 +1797,4 @@ export type BUN_SQLITE_COBBLE_APP_SETTINGS = {
value?: string; value?: string;
} }
export type BUN_SQLITE_COBBLE_ALL_TYPEDEFS = BUN_SQLITE_COBBLE_USERS & BUN_SQLITE_COBBLE_USER_TYPES & BUN_SQLITE_COBBLE_SSO_LOGIN_CODES & BUN_SQLITE_COBBLE_USER_PASSWORDS & BUN_SQLITE_COBBLE_MEDIA & BUN_SQLITE_COBBLE_MEDIA_PARADIGMS & BUN_SQLITE_COBBLE_BOTS & BUN_SQLITE_COBBLE_BOT_SESSIONS & BUN_SQLITE_COBBLE_BOT_MESSAGES & BUN_SQLITE_COBBLE_BOT_MESSAGE_REACTIONS & BUN_SQLITE_COBBLE_BOT_TASKS & BUN_SQLITE_COBBLE_BOT_TASK_STEPS & BUN_SQLITE_COBBLE_TASKS & BUN_SQLITE_COBBLE_TASK_MEDIA & BUN_SQLITE_COBBLE_TASK_NOTES & BUN_SQLITE_COBBLE_BOTS_QUEUE & BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES & BUN_SQLITE_COBBLE_CRON_JOBS & BUN_SQLITE_COBBLE_WORKSPACES & BUN_SQLITE_COBBLE_WORKSPACE_BOTS & BUN_SQLITE_COBBLE_WORKSPACE_USERS & BUN_SQLITE_COBBLE_BOT_USERS & BUN_SQLITE_COBBLE_BOT_USER_MEMORIES & BUN_SQLITE_COBBLE_BOT_USER_FACES & BUN_SQLITE_COBBLE_BOT_MESSAGE_MEDIA & BUN_SQLITE_COBBLE_CONTAINERS & BUN_SQLITE_COBBLE_COMPUTERS & BUN_SQLITE_COBBLE_BOT_COMPUTERS & BUN_SQLITE_COBBLE_WORKSPACE_COMPUTERS & BUN_SQLITE_COBBLE_HARNESS_PROVIDERS & BUN_SQLITE_COBBLE_HARNESS_MODELS & BUN_SQLITE_COBBLE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_WORKSPACE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_MCP_SERVERS & BUN_SQLITE_COBBLE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_BOT_MCP_SERVERS & BUN_SQLITE_COBBLE_WORKSPACE_MCP_SERVERS & BUN_SQLITE_COBBLE_BOT_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_WORKSPACE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_LOGS & BUN_SQLITE_COBBLE_USER_PRESENCE & BUN_SQLITE_COBBLE_PUSH_SUBSCRIPTIONS & BUN_SQLITE_COBBLE_ENVS & BUN_SQLITE_COBBLE_BOT_FILES & BUN_SQLITE_COBBLE_BOT_FILE_CHUNKS & BUN_SQLITE_COBBLE_BOT_FILE_VECTORS & BUN_SQLITE_COBBLE_FILE_INGEST_QUEUE & BUN_SQLITE_COBBLE_APP_SETTINGS export type BUN_SQLITE_COBBLE_ALL_TYPEDEFS = BUN_SQLITE_COBBLE_USERS & BUN_SQLITE_COBBLE_USER_TYPES & BUN_SQLITE_COBBLE_SSO_LOGIN_CODES & BUN_SQLITE_COBBLE_USER_PASSWORDS & BUN_SQLITE_COBBLE_MEDIA & BUN_SQLITE_COBBLE_MEDIA_PARADIGMS & BUN_SQLITE_COBBLE_BOTS & BUN_SQLITE_COBBLE_BOT_SESSIONS & BUN_SQLITE_COBBLE_BOT_MESSAGES & BUN_SQLITE_COBBLE_BOT_MESSAGE_REACTIONS & BUN_SQLITE_COBBLE_BOT_TASKS & BUN_SQLITE_COBBLE_BOT_TASK_STEPS & BUN_SQLITE_COBBLE_TASKS & BUN_SQLITE_COBBLE_TASK_MEDIA & BUN_SQLITE_COBBLE_TASK_NOTES & BUN_SQLITE_COBBLE_BOTS_QUEUE & BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES & BUN_SQLITE_COBBLE_CRON_JOBS & BUN_SQLITE_COBBLE_WORKSPACES & BUN_SQLITE_COBBLE_WORKSPACE_BOTS & BUN_SQLITE_COBBLE_WORKSPACE_USERS & BUN_SQLITE_COBBLE_BOT_USERS & BUN_SQLITE_COBBLE_BOT_USER_MEMORIES & BUN_SQLITE_COBBLE_BOT_USER_FACES & BUN_SQLITE_COBBLE_BOT_MESSAGE_MEDIA & BUN_SQLITE_COBBLE_CONTAINERS & BUN_SQLITE_COBBLE_COMPUTERS & BUN_SQLITE_COBBLE_BOT_COMPUTERS & BUN_SQLITE_COBBLE_WORKSPACE_COMPUTERS & BUN_SQLITE_COBBLE_HARNESS_PROVIDERS & BUN_SQLITE_COBBLE_HARNESS_MODELS & BUN_SQLITE_COBBLE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_WORKSPACE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_MCP_SERVERS & BUN_SQLITE_COBBLE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_SKILLS & BUN_SQLITE_COBBLE_BOT_MCP_SERVERS & BUN_SQLITE_COBBLE_WORKSPACE_MCP_SERVERS & BUN_SQLITE_COBBLE_BOT_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_WORKSPACE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_BOT_SKILLS & BUN_SQLITE_COBBLE_WORKSPACE_SKILLS & BUN_SQLITE_COBBLE_LOGS & BUN_SQLITE_COBBLE_USER_PRESENCE & BUN_SQLITE_COBBLE_PUSH_SUBSCRIPTIONS & BUN_SQLITE_COBBLE_ENVS & BUN_SQLITE_COBBLE_BOT_FILES & BUN_SQLITE_COBBLE_BOT_FILE_CHUNKS & BUN_SQLITE_COBBLE_BOT_FILE_VECTORS & BUN_SQLITE_COBBLE_FILE_INGEST_QUEUE & BUN_SQLITE_COBBLE_APP_SETTINGS
+11 -1
View File
@@ -18,6 +18,7 @@ import runTextLoop from "@/src/harness/text/run-text-loop";
import grabBotChatTools from "@/src/harness/tools/bot/grab-bot-chat-tools"; import grabBotChatTools from "@/src/harness/tools/bot/grab-bot-chat-tools";
import grabBotFileTools from "@/src/harness/tools/storage/grab-bot-file-tools"; import grabBotFileTools from "@/src/harness/tools/storage/grab-bot-file-tools";
import openBotExtensions from "@/src/harness/tools/extensions/open-bot-extensions"; import openBotExtensions from "@/src/harness/tools/extensions/open-bot-extensions";
import openBotSkills from "@/src/harness/tools/skills/open-bot-skills";
import botMessagesToHarness from "./bot-messages-to-harness"; import botMessagesToHarness from "./bot-messages-to-harness";
import composeComputersNote from "./compose-computers-note"; import composeComputersNote from "./compose-computers-note";
import grabSessionCarryOver from "./grab-session-carry-over"; import grabSessionCarryOver from "./grab-session-carry-over";
@@ -112,6 +113,14 @@ export default async function runBotReply({
signal, signal,
}); });
/**
* The run's skills: one `load_skill` tool and the lines that name them.
*/
const skills = openBotSkills({
skills: extensions.skills,
taken: [...tools, ...extensions.tools].map((tool) => tool.name),
});
/** /**
* delegate_task is only there while the coordinator has workers. * delegate_task is only there while the coordinator has workers.
*/ */
@@ -140,6 +149,7 @@ export default async function runBotReply({
in_workspace: Boolean(workspace_id), in_workspace: Boolean(workspace_id),
}), }),
crew_note, crew_note,
skills.note,
extensions.note, extensions.note,
`Now: ${new Date().toUTCString()}.`, `Now: ${new Date().toUTCString()}.`,
] ]
@@ -153,7 +163,7 @@ export default async function runBotReply({
user_id, user_id,
}), }),
}), }),
tools: [...tools, ...file_tools, ...extensions.tools], tools: [...tools, ...file_tools, ...extensions.tools, ...skills.tools],
max_steps: AppData.BotReplyMaxToolSteps, max_steps: AppData.BotReplyMaxToolSteps,
bot_id: bot.id, bot_id: bot.id,
workspace_id, workspace_id,
+9 -1
View File
@@ -14,6 +14,7 @@ import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-
import runTextLoop from "@/src/harness/text/run-text-loop"; import runTextLoop from "@/src/harness/text/run-text-loop";
import openComputerTools from "@/src/harness/tools/computer/open-computer-tools"; import openComputerTools from "@/src/harness/tools/computer/open-computer-tools";
import openBotExtensions, { type OpenBotExtensions } from "@/src/harness/tools/extensions/open-bot-extensions"; import openBotExtensions, { type OpenBotExtensions } from "@/src/harness/tools/extensions/open-bot-extensions";
import openBotSkills, { type OpenBotSkills } from "@/src/harness/tools/skills/open-bot-skills";
import grabBotTools from "@/src/harness/tools/bot/grab-bot-tools"; import grabBotTools from "@/src/harness/tools/bot/grab-bot-tools";
import createWorkspaceTool from "@/src/harness/tools/bot/create-workspace-tool"; import createWorkspaceTool from "@/src/harness/tools/bot/create-workspace-tool";
import updateWorkspaceTool from "@/src/harness/tools/bot/update-workspace-tool"; import updateWorkspaceTool from "@/src/harness/tools/bot/update-workspace-tool";
@@ -71,6 +72,7 @@ export default async function runBotTask({
: null; : null;
let extensions: OpenBotExtensions | undefined; let extensions: OpenBotExtensions | undefined;
let skills: OpenBotSkills | undefined;
try { try {
const session_id = await jobSessionId({ job }); const session_id = await jobSessionId({ job });
@@ -173,6 +175,11 @@ export default async function runBotTask({
signal, signal,
}); });
skills = openBotSkills({
skills: extensions.skills,
taken: [...tools, ...extensions.tools].map((tool) => tool.name),
});
const result = await runTextLoop({ const result = await runTextLoop({
system: [ system: [
prompt, prompt,
@@ -200,6 +207,7 @@ export default async function runBotTask({
.map((env) => `- ${env.key}${env.description ? `: ${env.description}` : ""}`) .map((env) => `- ${env.key}${env.description ? `: ${env.description}` : ""}`)
.join("\n")}` .join("\n")}`
: "", : "",
skills.note,
extensions.note, extensions.note,
] ]
.filter(Boolean) .filter(Boolean)
@@ -214,7 +222,7 @@ export default async function runBotTask({
}), }),
}, },
], ],
tools: [...tools, ...extensions.tools], tools: [...tools, ...extensions.tools, ...skills.tools],
bot_id: bot.id, bot_id: bot.id,
workspace_id, workspace_id,
signal, signal,
@@ -1,25 +1,26 @@
import type { BotExtensionsPayload } from "@/src/types"; import type { BotExtensionsPayload } from "@/src/types";
import { extensionOptionValue, parseExtensionOptionValue } from "@/src/utils/extension-option-value"; import { extensionOptionValue, parseExtensionOptionValue } from "@/src/utils/extension-option-value";
import extensionNoun from "@/src/utils/extension-noun";
import InlineAddSelect from "./inline-add-select"; import InlineAddSelect from "./inline-add-select";
type Props = { type Props = {
id: string; id: string;
addable: BotExtensionsPayload["addable"]; addable: BotExtensionsPayload["addable"];
loading?: boolean; loading?: boolean;
onAdd: (params: { kind: "mcp" | "tool"; id: number }) => void; onAdd: (params: { kind: "mcp" | "tool" | "skill"; id: number }) => void;
}; };
/** /**
* Picks an MCP server or tool to give a bot. * Picks an MCP server, tool or skill to give a bot.
*/ */
export default function ExtensionAddSelect({ id, addable, loading, onAdd }: Props) { export default function ExtensionAddSelect({ id, addable, loading, onAdd }: Props) {
return ( return (
<InlineAddSelect <InlineAddSelect
id={id} id={id}
noun="tool or MCP server" noun="tool, MCP server or skill"
options={addable.map((item) => ({ options={addable.map((item) => ({
value: extensionOptionValue(item), value: extensionOptionValue(item),
title: `${item.kind == "mcp" ? "MCP" : "Tool"} · ${item.name}`, title: `${extensionNoun({ kind: item.kind })}: ${item.name}`,
}))} }))}
loading={loading} loading={loading}
onAdd={({ value }) => { onAdd={({ value }) => {
+6 -3
View File
@@ -1,9 +1,12 @@
import { Plug, Wrench } from "lucide-react"; import { BookOpen, Plug, Wrench } from "lucide-react";
import type { BotExtension } from "@/src/types"; import type { BotExtension } from "@/src/types";
/** /**
* The icon of an MCP server (a plug) or a tool (a wrench). * The icon of an MCP server (a plug), a tool (a wrench) or a skill (a book).
*/ */
export default function extensionIcon({ kind }: { kind: BotExtension["kind"] }) { export default function extensionIcon({ kind }: { kind: BotExtension["kind"] }) {
return kind == "mcp" ? Plug : Wrench; if (kind == "mcp") return Plug;
if (kind == "skill") return BookOpen;
return Wrench;
} }
+8
View File
@@ -283,6 +283,14 @@ export const AppData = {
*/ */
CustomToolDefaultTimeoutMs: 60 * 1000, CustomToolDefaultTimeoutMs: 60 * 1000,
CustomToolMaxTimeoutMs: 10 * 60 * 1000, CustomToolMaxTimeoutMs: 10 * 60 * 1000,
/**
* Skills (Settings, under Skills): how long one's body may be, and how
* much of it `load_skill` hands back in one call. A runbook past that
* is worth splitting into two skills.
*/
SkillBodyMaxChars: 60000,
SkillBodyShownChars: 30000,
SkillDescriptionMaxChars: 300,
/** /**
* `search_computers`: how long the command may run on each computer, * `search_computers`: how long the command may run on each computer,
* and how much of each computer's output the bot reads. * and how much of each computer's output the bot reads.
+12
View File
@@ -85,6 +85,18 @@ ${
: `- Work that ends in a file on the computer is a task: start one, and it saves what it made into your files with import_file, so it is kept and searchable.\n` : `- Work that ends in a file on the computer is a task: start one, and it saves what it made into your files with import_file, so it is kept and searchable.\n`
}- Never say a file is saved if it only exists on the computer. Say where it is, and that it can be imported.`; }- Never say a file is saved if it only exists on the computer. Say where it is, and that it can be imported.`;
/**
* Added to a run that has skills: what they are, and that the body is read
* only with load_skill.
*/
export const SkillsNote = `# Skills
You have runbooks (skills): load_skill lists them, with the one line each says about when it applies.
- A skill's steps are the way that job is done here. Before you start work one covers, call load_skill with its slug and follow it: don't work from memory or guess what it says.
- Load one only when it applies to what you're doing; loading everything fills your context with instructions you don't need.
- If a skill turns out to be wrong or out of date for the job, follow it as written and tell the user in your reply, so they can fix it in Settings, under Skills.`;
/** /**
* Added to every run, chat replies and tasks alike: check before you * Added to every run, chat replies and tasks alike: check before you
* create, and check after you act. * create, and check after you act.
+1
View File
@@ -86,6 +86,7 @@ export const JobStepToolLabels: Record<string, string> = {
assign_computer: "Changing computer access", assign_computer: "Changing computer access",
update_memory: "Updating memory", update_memory: "Updating memory",
update_instructions: "Updating instructions", update_instructions: "Updating instructions",
load_skill: "Reading a skill",
}; };
/** /**
+1
View File
@@ -66,6 +66,7 @@ export const ReservedToolNames = [
"delegate_task", "delegate_task",
"delete_schedule", "delete_schedule",
"list_schedules", "list_schedules",
"load_skill",
"report_run_failed", "report_run_failed",
"search_computers", "search_computers",
"set_bot_workspace_instructions", "set_bot_workspace_instructions",
+2
View File
@@ -1,5 +1,6 @@
import { import {
Bell, Bell,
BookOpen,
Bot, Bot,
CpuAi, CpuAi,
DatabaseBackup, DatabaseBackup,
@@ -61,6 +62,7 @@ export const SettingsPages: SettingsPage[] = [
{ title: "Computers", url: SettingsComputersPath, Icon: Monitor, can_view: canManageCrew }, { title: "Computers", url: SettingsComputersPath, Icon: Monitor, can_view: canManageCrew },
{ title: "MCPs", url: "/admin/settings/mcps", Icon: Plug, can_view: canManageCrew }, { title: "MCPs", url: "/admin/settings/mcps", Icon: Plug, can_view: canManageCrew },
{ title: "Tools", url: "/admin/settings/tools", Icon: Wrench, can_view: canManageCrew }, { title: "Tools", url: "/admin/settings/tools", Icon: Wrench, can_view: canManageCrew },
{ title: "Skills", url: "/admin/settings/skills", Icon: BookOpen, can_view: canManageCrew },
{ {
title: "Users", title: "Users",
url: "/admin/settings/users", url: "/admin/settings/users",
+16
View File
@@ -0,0 +1,16 @@
/**
* What a skill is: a runbook a bot follows. It reads the name and
* description in its prompt and pulls the body with `load_skill` only when
* the skill applies, so a long runbook costs nothing until it's needed.
*/
export const SkillNoun = "skill";
/**
* The tool a bot calls to read a skill's body.
*/
export const LoadSkillToolName = "load_skill";
/**
* The slug format, for the settings form and its hint.
*/
export const SkillSlugPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/;
@@ -8,7 +8,7 @@ type Params = {
}; };
/** /**
* Throws unless the user manages this bot's MCP servers and tools: super * Throws unless the user manages this bot's MCP servers, tools and skills: super
* admins and admins, the super admin alone for the coordinator. Returns * admins and admins, the super admin alone for the coordinator. Returns
* the bot. * the bot.
*/ */
@@ -5,8 +5,8 @@ type Params = {
}; };
/** /**
* Gives a new bot every MCP server and tool set to "every bot". Called * Gives a new bot every MCP server, tool and skill set to "every bot".
* when a bot is created; it can drop any of them afterwards. * Called when a bot is created; it can drop any of them afterwards.
*/ */
export default async function attachDefaultExtensions({ bot_id }: Params) { export default async function attachDefaultExtensions({ bot_id }: Params) {
const now = Date.now(); const now = Date.now();
@@ -22,4 +22,10 @@ export default async function attachDefaultExtensions({ bot_id }: Params) {
SELECT ?, ?, ?, id FROM custom_tools WHERE all_bots = 1`, SELECT ?, ?, ?, id FROM custom_tools WHERE all_bots = 1`,
values: [now, now, Number(bot_id)], values: [now, now, Number(bot_id)],
}); });
await BunSQLite.sql({
sql: `INSERT OR IGNORE INTO bot_skills (created_at, updated_at, bot_id, skill_id)
SELECT ?, ?, ?, id FROM skills WHERE all_bots = 1`,
values: [now, now, Number(bot_id)],
});
} }
@@ -0,0 +1,25 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import grabSkill from "./grab-skill";
type Params = {
skill_id: number | string;
};
/**
* Deletes a skill, and its bot and workspace links with it. The links are
* deleted here rather than left to the schema's cascade: foreign keys are
* off outside the server process (`enableForeignKeys`), so a delete from a
* script would otherwise strand them.
*/
export default async function deleteSkill({ skill_id }: Params) {
const skill = await grabSkill({ skill_id });
await BunSQLite.sql({ sql: `DELETE FROM bot_skills WHERE skill_id = ?`, values: [skill.id] });
await BunSQLite.sql({ sql: `DELETE FROM workspace_skills WHERE skill_id = ?`, values: [skill.id] });
const res = await BunSQLite.sql({ sql: `DELETE FROM skills WHERE id = ?`, values: [skill.id] });
if (!res.success) throw new Error(`Couldn't delete the skill: ${res.error}`);
return skill;
}
@@ -17,6 +17,13 @@ export const ExtensionTables = {
column: "custom_tool_id", column: "custom_tool_id",
label: "tool", label: "tool",
}, },
skill: {
table: "skills",
bot_link: "bot_skills",
workspace_link: "workspace_skills",
column: "skill_id",
label: "skill",
},
} as const; } as const;
export type ExtensionKind = keyof typeof ExtensionTables; export type ExtensionKind = keyof typeof ExtensionTables;
@@ -11,14 +11,14 @@ type Params = {
user_id: number | string; user_id: number | string;
bot_id: number | string; bot_id: number | string;
/** /**
* The workspace the bot is seen in: its MCP servers and tools show * The workspace the bot is seen in: its MCP servers, tools and skills show
* too, not removable from the bot. * too, not removable from the bot.
*/ */
workspace_id?: number | string | null; workspace_id?: number | string | null;
}; };
/** /**
* A bot's MCP servers and tools (given to it, and its workspace's there), * A bot's MCP servers, tools and skills (given to it, and its workspace's there),
* and the rest of them, which can be added. * and the rest of them, which can be added.
*/ */
export default async function grabBotExtensionsOverview({ export default async function grabBotExtensionsOverview({
@@ -1,8 +1,13 @@
import BunSQLite from "@moduletrace/bun-sqlite"; import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_CUSTOM_TOOLS, BUN_SQLITE_COBBLE_MCP_SERVERS } from "@/db/types/db"; import type {
BUN_SQLITE_COBBLE_CUSTOM_TOOLS,
BUN_SQLITE_COBBLE_MCP_SERVERS,
BUN_SQLITE_COBBLE_SKILLS,
} from "@/db/types/db";
import botExtensionsSql from "@/src/utils/bot-extensions-sql"; import botExtensionsSql from "@/src/utils/bot-extensions-sql";
import parseCustomToolRow from "./parse-custom-tool-row"; import parseCustomToolRow from "./parse-custom-tool-row";
import parseMcpServerRow from "./parse-mcp-server-row"; import parseMcpServerRow from "./parse-mcp-server-row";
import parseSkillRow from "./parse-skill-row";
type Params = { type Params = {
bot_id: number | string; bot_id: number | string;
@@ -10,17 +15,19 @@ type Params = {
}; };
/** /**
* The MCP servers and custom tools a bot gets on one run * The MCP servers, custom tools and skills a bot gets on one run
* (`botExtensionsSql`): every bot's, its own, and its workspace's there. * (`botExtensionsSql`): every bot's, its own, and its workspace's there.
*/ */
export default async function grabBotExtensions({ bot_id, workspace_id }: Params) { export default async function grabBotExtensions({ bot_id, workspace_id }: Params) {
const [servers, tools] = await Promise.all([ const [servers, tools, skills] = await Promise.all([
BunSQLite.sql<BUN_SQLITE_COBBLE_MCP_SERVERS>(botExtensionsSql({ kind: "mcp", bot_id, workspace_id })), BunSQLite.sql<BUN_SQLITE_COBBLE_MCP_SERVERS>(botExtensionsSql({ kind: "mcp", bot_id, workspace_id })),
BunSQLite.sql<BUN_SQLITE_COBBLE_CUSTOM_TOOLS>(botExtensionsSql({ kind: "tool", bot_id, workspace_id })), BunSQLite.sql<BUN_SQLITE_COBBLE_CUSTOM_TOOLS>(botExtensionsSql({ kind: "tool", bot_id, workspace_id })),
BunSQLite.sql<BUN_SQLITE_COBBLE_SKILLS>(botExtensionsSql({ kind: "skill", bot_id, workspace_id })),
]); ]);
return { return {
servers: (servers.payload || []).map((row) => parseMcpServerRow({ row })), servers: (servers.payload || []).map((row) => parseMcpServerRow({ row })),
tools: (tools.payload || []).map((row) => parseCustomToolRow({ row })), tools: (tools.payload || []).map((row) => parseCustomToolRow({ row })),
skills: (skills.payload || []).map((row) => parseSkillRow({ row })),
}; };
} }
@@ -1,10 +1,15 @@
import BunSQLite from "@moduletrace/bun-sqlite"; import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_CUSTOM_TOOLS, BUN_SQLITE_COBBLE_MCP_SERVERS } from "@/db/types/db"; import type {
BUN_SQLITE_COBBLE_CUSTOM_TOOLS,
BUN_SQLITE_COBBLE_MCP_SERVERS,
BUN_SQLITE_COBBLE_SKILLS,
} from "@/db/types/db";
import { CustomToolKinds } from "@/src/dict/custom-tool-dict"; import { CustomToolKinds } from "@/src/dict/custom-tool-dict";
import { McpTransports } from "@/src/dict/mcp-dict"; import { McpTransports } from "@/src/dict/mcp-dict";
import type { BotExtension, BotExtensionsPayload } from "@/src/types"; import type { BotExtension, BotExtensionsPayload } from "@/src/types";
import parseCustomToolRow from "./parse-custom-tool-row"; import parseCustomToolRow from "./parse-custom-tool-row";
import parseMcpServerRow from "./parse-mcp-server-row"; import parseMcpServerRow from "./parse-mcp-server-row";
import parseSkillRow from "./parse-skill-row";
type Params = { type Params = {
/** /**
@@ -23,7 +28,7 @@ type Params = {
type Linked<T> = T & { source: "bot" | "workspace" }; type Linked<T> = T & { source: "bot" | "workspace" };
/** /**
* The MCP servers and tools a bot or a workspace has, and the rest of * The MCP servers, tools and skills a bot or a workspace has, and the rest of
* them, which can be added (`grabBotExtensionsOverview`, * them, which can be added (`grabBotExtensionsOverview`,
* `grabWorkspaceExtensionsOverview`). * `grabWorkspaceExtensionsOverview`).
*/ */
@@ -45,8 +50,9 @@ export default async function grabExtensionsOverview({
const mcp_query = linked("mcp_server_id", "bot_mcp_servers", "workspace_mcp_servers"); const mcp_query = linked("mcp_server_id", "bot_mcp_servers", "workspace_mcp_servers");
const tool_query = linked("custom_tool_id", "bot_custom_tools", "workspace_custom_tools"); const tool_query = linked("custom_tool_id", "bot_custom_tools", "workspace_custom_tools");
const skill_query = linked("skill_id", "bot_skills", "workspace_skills");
const [servers, tools, all_servers, all_tools] = await Promise.all([ const [servers, tools, skills, all_servers, all_tools, all_skills] = await Promise.all([
BunSQLite.sql<Linked<BUN_SQLITE_COBBLE_MCP_SERVERS>>({ BunSQLite.sql<Linked<BUN_SQLITE_COBBLE_MCP_SERVERS>>({
sql: mcp_query.sql.replace("%TABLE%", "mcp_servers"), sql: mcp_query.sql.replace("%TABLE%", "mcp_servers"),
values: mcp_query.values, values: mcp_query.values,
@@ -55,12 +61,21 @@ export default async function grabExtensionsOverview({
sql: tool_query.sql.replace("%TABLE%", "custom_tools"), sql: tool_query.sql.replace("%TABLE%", "custom_tools"),
values: tool_query.values, values: tool_query.values,
}), }),
BunSQLite.sql<Linked<BUN_SQLITE_COBBLE_SKILLS>>({
sql: skill_query.sql.replace("%TABLE%", "skills"),
values: skill_query.values,
}),
can_manage can_manage
? BunSQLite.sql<{ id: number; name: string }>({ sql: `SELECT id, name FROM mcp_servers ORDER BY name COLLATE NOCASE` }) ? BunSQLite.sql<{ id: number; name: string }>({ sql: `SELECT id, name FROM mcp_servers ORDER BY name COLLATE NOCASE` })
: null, : null,
can_manage can_manage
? BunSQLite.sql<{ id: number; name: string }>({ sql: `SELECT id, name FROM custom_tools ORDER BY name` }) ? BunSQLite.sql<{ id: number; name: string }>({ sql: `SELECT id, name FROM custom_tools ORDER BY name` })
: null, : null,
can_manage
? BunSQLite.sql<{ id: number; name: string }>({
sql: `SELECT id, name FROM skills ORDER BY name COLLATE NOCASE`,
})
: null,
]); ]);
const attached: BotExtension[] = [ const attached: BotExtension[] = [
@@ -94,13 +109,27 @@ export default async function grabExtensionsOverview({
source: row.source, source: row.source,
}; };
}), }),
...(skills.payload || []).map((row) => {
const skill = parseSkillRow({ row });
return {
kind: "skill" as const,
id: skill.id,
name: skill.name,
description: skill.description,
detail: `Skill · ${skill.slug}`,
state: "ok" as const,
enabled: skill.enabled,
source: row.source,
};
}),
]; ];
/** /**
* A bot can be given one its workspace has; a workspace only what it * A bot can be given one its workspace has; a workspace only what it
* hasn't. * hasn't.
*/ */
const has = (kind: "mcp" | "tool", id: number) => const has = (kind: BotExtension["kind"], id: number) =>
attached.some((item) => item.kind == kind && item.id == id && (!bot || item.source == "bot")); attached.some((item) => item.kind == kind && item.id == id && (!bot || item.source == "bot"));
return { return {
@@ -112,6 +141,9 @@ export default async function grabExtensionsOverview({
...(all_tools?.payload || []) ...(all_tools?.payload || [])
.filter((row) => !has("tool", Number(row.id))) .filter((row) => !has("tool", Number(row.id)))
.map((row) => ({ kind: "tool" as const, id: Number(row.id), name: row.name })), .map((row) => ({ kind: "tool" as const, id: Number(row.id), name: row.name })),
...(all_skills?.payload || [])
.filter((row) => !has("skill", Number(row.id)))
.map((row) => ({ kind: "skill" as const, id: Number(row.id), name: row.name })),
], ],
can_manage, can_manage,
}; };
@@ -0,0 +1,24 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_SKILLS } from "@/db/types/db";
import AppError from "@/src/utils/app-error";
import parseSkillRow from "./parse-skill-row";
type Params = {
skill_id: number | string;
};
/**
* One skill, or an `AppError` when there's none.
*/
export default async function grabSkill({ skill_id }: Params) {
const row = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_SKILLS>({
sql: `SELECT * FROM skills WHERE id = ?`,
values: [skill_id],
})
).payload?.[0];
if (!row) throw new AppError(`That skill doesn't exist.`);
return parseSkillRow({ row });
}
@@ -0,0 +1,34 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_SKILLS } from "@/db/types/db";
import type { SkillsOverviewPayload } from "@/src/types";
import grabCrewRole from "../access/grab-crew-role";
import grabExtensionAccess from "./grab-extension-access";
import grabExtensionTargets from "./grab-extension-targets";
import parseSkillRow from "./parse-skill-row";
type Params = {
user_id: number | string;
};
/**
* The Skills settings page: every skill with who gets it, and what it can
* be attached to. A skill holds nothing secret, so admins read it too;
* only a super admin adds, edits or deletes.
*/
export default async function grabSkillsOverview({ user_id }: Params): Promise<SkillsOverviewPayload> {
const role = await grabCrewRole({ user_id });
const [rows, access, targets] = await Promise.all([
BunSQLite.sql<BUN_SQLITE_COBBLE_SKILLS>({ sql: `SELECT * FROM skills ORDER BY name COLLATE NOCASE ASC, id ASC` }),
grabExtensionAccess({ kind: "skill" }),
grabExtensionTargets({ role }),
]);
return {
...targets,
skills: (rows.payload || []).map((row) => {
const { all_bots, ...skill } = parseSkillRow({ row });
return { ...skill, access: { all_bots, ...access(skill.id) } };
}),
};
}
@@ -11,7 +11,7 @@ type Params = {
}; };
/** /**
* The MCP servers and tools every bot working in a workspace gets there, * The MCP servers, tools and skills every bot working in a workspace gets there,
* and the rest of them, which can be added. * and the rest of them, which can be added.
*/ */
export default async function grabWorkspaceExtensionsOverview({ export default async function grabWorkspaceExtensionsOverview({
@@ -0,0 +1,21 @@
import type { BUN_SQLITE_COBBLE_SKILLS } from "@/db/types/db";
import type { SkillRecord } from "@/src/types";
type Params = {
row: BUN_SQLITE_COBBLE_SKILLS;
};
/**
* A `skills` row, with its flags read as booleans.
*/
export default function parseSkillRow({ row }: Params): SkillRecord {
return {
id: Number(row.id),
name: row.name || "",
slug: row.slug || "",
description: row.description || "",
body: row.body || "",
all_bots: row.all_bots == 1,
enabled: row.enabled != 0,
};
}
@@ -0,0 +1,66 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import attachToAllBots from "./attach-to-all-bots";
import type { BUN_SQLITE_COBBLE_SKILLS, BunSQLiteTables } from "@/db/types/db";
import type { SaveSkillRequest } from "@/src/schemas/extension-schema";
import AppError from "@/src/utils/app-error";
import grabSkill from "./grab-skill";
type Params = {
/**
* The super admin saving it (checked by the caller).
*/
user_id: number | string;
data: SaveSkillRequest;
};
/**
* Adds or edits a skill and returns its id. Slugs are unique: a bot loads a
* skill by its slug, so two skills can't answer to the same one.
*/
export default async function saveSkill({ user_id, data }: Params) {
const clash = (
await BunSQLite.sql<{ id: number }>({
sql: `SELECT id FROM skills WHERE slug = ? AND id != ?`,
values: [data.slug, data.skill_id || 0],
})
).payload?.[0];
if (clash) throw new AppError(`Another skill already uses the slug ${data.slug}.`);
const fields = {
name: data.name,
slug: data.slug,
description: data.description,
body: data.body,
enabled: data.enabled ? 1 : 0,
all_bots: data.all_bots ? 1 : 0,
} as const;
if (data.skill_id) {
const skill = await grabSkill({ skill_id: data.skill_id });
const res = await BunSQLite.update<BUN_SQLITE_COBBLE_SKILLS, (typeof BunSQLiteTables)[number]>({
table: "skills",
targetId: skill.id,
data: fields,
});
if (!res.success) throw new Error(`Couldn't save the skill: ${res.msg}`);
if (data.all_bots && !skill.all_bots) await attachToAllBots({ kind: "skill", id: skill.id });
return skill.id;
}
const res = await BunSQLite.insert<BUN_SQLITE_COBBLE_SKILLS, (typeof BunSQLiteTables)[number]>({
table: "skills",
data: [{ ...fields, user_id: Number(user_id) }],
});
const id = res.singleRes?.id || res.postInsertReturn?.insertId;
if (!res.success || !id) throw new Error(`Couldn't add the skill: ${res.msg}`);
if (data.all_bots) await attachToAllBots({ kind: "skill", id: Number(id) });
return Number(id);
}
@@ -1,5 +1,6 @@
import grabBotExtensions from "@/src/functions/backend/db/extensions/grab-bot-extensions"; import grabBotExtensions from "@/src/functions/backend/db/extensions/grab-bot-extensions";
import grabRunEnvs from "@/src/functions/backend/db/extensions/grab-run-envs"; import grabRunEnvs from "@/src/functions/backend/db/extensions/grab-run-envs";
import type { SkillRecord } from "@/src/types";
import mcpToolName from "@/src/utils/mcp-tool-name"; import mcpToolName from "@/src/utils/mcp-tool-name";
import redactSecrets from "@/src/utils/redact-secrets"; import redactSecrets from "@/src/utils/redact-secrets";
import mcpHarnessTool from "../../mcp/mcp-harness-tool"; import mcpHarnessTool from "../../mcp/mcp-harness-tool";
@@ -30,6 +31,11 @@ export type OpenBotExtensions = {
* with no tools listed), so the bot can say so. Empty otherwise. * with no tools listed), so the bot can say so. Empty otherwise.
*/ */
note: string; note: string;
/**
* The skills the run has, for `openBotSkills`: read from the same query
* as the MCP servers and tools.
*/
skills: SkillRecord[];
close: () => Promise<void>; close: () => Promise<void>;
}; };
@@ -47,9 +53,11 @@ export default async function openBotExtensions({
taken = [], taken = [],
signal, signal,
}: Params): Promise<OpenBotExtensions> { }: Params): Promise<OpenBotExtensions> {
const { servers, tools } = await grabBotExtensions({ bot_id, workspace_id }); const { servers, tools, skills } = await grabBotExtensions({ bot_id, workspace_id });
if (!servers.length && !tools.length) return { tools: [], note: "", close: async () => {} }; if (!servers.length && !tools.length) {
return { tools: [], note: "", skills, close: async () => {} };
}
const envs = await grabRunEnvs({ bot_id, workspace_id }); const envs = await grabRunEnvs({ bot_id, workspace_id });
const secrets = envs.filter((env) => env.secret); const secrets = envs.filter((env) => env.secret);
@@ -98,6 +106,7 @@ export default async function openBotExtensions({
return { return {
tools: result, tools: result,
skills,
note: notes.length note: notes.length
? `These MCP servers were set up for you but you can't use them right now; if the user needs them, say so (an admin fixes them in Settings, under MCPs):\n${notes.join("\n")}` ? `These MCP servers were set up for you but you can't use them right now; if the user needs them, say so (an admin fixes them in Settings, under MCPs):\n${notes.join("\n")}`
: "", : "",
@@ -0,0 +1,78 @@
import { describe, expect, test } from "bun:test";
import { AppData } from "@/src/data/app-data";
import type { SkillRecord } from "@/src/types";
import openBotSkills from "./open-bot-skills";
function skill(overrides: Partial<SkillRecord> & { slug: string }): SkillRecord {
return {
id: 1,
name: overrides.slug,
description: `When ${overrides.slug} applies.`,
body: `${overrides.slug} body`,
all_bots: false,
enabled: true,
...overrides,
};
}
describe("openBotSkills", () => {
test("no skills means no tool and no note", () => {
expect(openBotSkills({ skills: [] })).toEqual({ tools: [], note: "" });
});
test("a disabled skill is left out entirely", () => {
const opened = openBotSkills({ skills: [skill({ slug: "seo", enabled: false })] });
expect(opened.tools).toHaveLength(0);
expect(opened.note).toBe("");
});
test("one tool, named load_skill, whose description lists the slugs", () => {
const opened = openBotSkills({ skills: [skill({ slug: "seo" }), skill({ slug: "inbox", id: 2 })] });
expect(opened.tools).toHaveLength(1);
expect(opened.tools[0]!.name).toBe("load_skill");
expect(opened.tools[0]!.description).toContain("- seo:");
expect(opened.tools[0]!.description).toContain("- inbox:");
expect(opened.note).toContain("# Skills");
});
test("a built-in that took the name wins", () => {
expect(openBotSkills({ skills: [skill({ slug: "seo" })], taken: ["load_skill"] })).toEqual({
tools: [],
note: "",
});
});
test("the tool returns the body of the slug it's given", async () => {
const opened = openBotSkills({ skills: [skill({ slug: "seo", body: "1. Pull rankings" })] });
const output = await opened.tools[0]!.run!({ args: { slug: "seo" }, signal: AbortSignal.timeout(1000) });
expect(output).toBe("1. Pull rankings");
});
test("an unknown slug fails with the slugs it does have", async () => {
const opened = openBotSkills({ skills: [skill({ slug: "seo" })] });
await expect(opened.tools[0]!.run!({ args: { slug: "nope" }, signal: AbortSignal.timeout(1000) })).rejects.toThrow(
/no skill called "nope".*seo/,
);
});
test("a long body is cut at the limit and says so", async () => {
const opened = openBotSkills({
skills: [skill({ slug: "seo", body: "x".repeat(AppData.SkillBodyShownChars + 500) })],
});
const output = await opened.tools[0]!.run!({ args: { slug: "seo" }, signal: AbortSignal.timeout(1000) });
expect(output).toContain("Cut at");
expect(output.length).toBeLessThan(AppData.SkillBodyShownChars + 200);
});
test("an empty body says it is empty rather than returning nothing", async () => {
const opened = openBotSkills({ skills: [skill({ slug: "seo", body: "" })] });
const output = await opened.tools[0]!.run!({ args: { slug: "seo" }, signal: AbortSignal.timeout(1000) });
expect(output).toContain("is empty");
});
});
@@ -0,0 +1,67 @@
import { z } from "zod";
import { AppData } from "@/src/data/app-data";
import { SkillsNote } from "@/src/data/harness-prompts";
import { LoadSkillToolName } from "@/src/dict/skill-dict";
import type { SkillRecord } from "@/src/types";
import type { HarnessTool } from "../../types";
type Params = {
/**
* The skills the run reaches (`grabBotExtensions`).
*/
skills: SkillRecord[];
/**
* Names the run's built-in tools already use: they win.
*/
taken?: string[];
};
export type OpenBotSkills = {
tools: HarnessTool[];
/**
* For the system prompt: one line per skill, and that the body is read
* with `load_skill`. Empty when the run has none.
*/
note: string;
};
/**
* The skills a run has, as one `load_skill` tool plus the lines that say
* which ones exist. A skill's body costs nothing until the bot asks for it,
* so a run that never needs a long runbook never pays for it.
*/
export default function openBotSkills({ skills, taken = [] }: Params): OpenBotSkills {
const enabled = skills.filter((skill) => skill.enabled);
if (!enabled.length || taken.includes(LoadSkillToolName)) return { tools: [], note: "" };
const by_slug = new Map(enabled.map((skill) => [skill.slug, skill]));
const slugs = enabled.map((skill) => skill.slug);
const list = enabled.map((skill) => `- ${skill.slug}: ${skill.description}`).join("\n");
const tool: HarnessTool<{ slug: string }> = {
name: LoadSkillToolName,
description: `Reads one of your skills in full. Pass its slug. The skills you have, and when each applies:\n${list}`,
schema: z.object({ slug: z.string().trim().min(1, "Pass the skill's slug") }),
run: async ({ args }) => {
const skill = by_slug.get(args.slug);
if (!skill) {
throw new Error(
`You have no skill called "${args.slug}". Load one of: ${slugs.join(", ")}.`,
);
}
const body = skill.body || "";
if (!body) return `The skill ${skill.name} (${skill.slug}) is empty.`;
if (body.length <= AppData.SkillBodyShownChars) return body;
return `${body.slice(0, AppData.SkillBodyShownChars)}\n\n[Cut at ${AppData.SkillBodyShownChars} characters. The skill continues: ask the user to split it if you need the rest.]`;
},
};
return { tools: [tool], note: SkillsNote };
}
+1 -1
View File
@@ -18,7 +18,7 @@ type Params = {
type Status = { error?: boolean; success?: boolean; msg?: string }; type Status = { error?: boolean; success?: boolean; msg?: string };
/** /**
* A bot's or workspace's MCP servers and tools, with adding and removing * A bot's or workspace's MCP servers, tools and skills, with adding and removing
* them (each asks first). Shared by the bot and workspace settings pages * them (each asks first). Shared by the bot and workspace settings pages
* and the chat's composer. * and the chat's composer.
*/ */
@@ -17,8 +17,9 @@ type Props = {
}; };
/** /**
* One MCP server or tool the bot can use here, in the composer's tools * One MCP server, tool or skill the bot can use here, in the composer's
* modal. One that isn't working says so, one that's off is struck out. * tools modal. One that isn't working says so, one that's off is struck
* out. A skill has nothing to connect or run, so it never reports one.
*/ */
export default function ComposerToolRow({ item, busy, onRemove }: Props) { export default function ComposerToolRow({ item, busy, onRemove }: Props) {
const Icon = extensionIcon({ kind: item.kind }); const Icon = extensionIcon({ kind: item.kind });
@@ -27,11 +28,13 @@ export default function ComposerToolRow({ item, busy, onRemove }: Props) {
item.source == "workspace" ? "From this workspace" : "", item.source == "workspace" ? "From this workspace" : "",
!item.enabled !item.enabled
? "Off for every bot" ? "Off for every bot"
: item.state == "error" : item.kind == "skill"
? "Not working" ? ""
: item.state == "untested" : item.state == "error"
? "Not checked yet" ? "Not working"
: "", : item.state == "untested"
? "Not checked yet"
: "",
].filter(Boolean); ].filter(Boolean);
return ( return (
@@ -18,19 +18,19 @@ type Props = {
* The extension being changed, as `kind:id`. * The extension being changed, as `kind:id`.
*/ */
busy?: string; busy?: string;
onAdd: (params: { kind: "mcp" | "tool"; id: number }) => Promise<boolean>; onAdd: (params: { kind: "mcp" | "tool" | "skill"; id: number }) => Promise<boolean>;
onRemove: (params: { onRemove: (params: {
kind: "mcp" | "tool"; kind: "mcp" | "tool" | "skill";
id: number; id: number;
name: string; name: string;
}) => Promise<boolean>; }) => Promise<boolean>;
}; };
/** /**
* The composer's tools button: how many MCP servers and tools the bot can * The composer's tools button: how many MCP servers, tools and skills the
* use here, opening them in a modal rather than filling the composer with * bot can use here, opening them in a modal rather than filling the
* chips. Super admins and admins remove any it was given (even one every * composer with chips. Super admins and admins remove any it was given
* bot started with) and add more; a workspace's own stay. * (even one every bot started with) and add more; a workspace's own stay.
*/ */
export default function ComposerToolsModal({ export default function ComposerToolsModal({
bot_name, bot_name,
@@ -75,8 +75,8 @@ export default function ComposerToolsModal({
</H2> </H2>
<Span className="text-[13px] leading-[18px] text-ink-secondary"> <Span className="text-[13px] leading-[18px] text-ink-secondary">
{count {count
? "MCP servers and tools it was given, plus this workspace's. Remove one to take it away, or add another below." ? "MCP servers, tools and skills it was given, plus this workspace's. Remove one to take it away, or add another below."
: "It has none yet. Give it an MCP server or tool below, or make one under Settings."} : "It has none yet. Give it an MCP server, tool or skill below, or make one under Settings."}
</Span> </Span>
</Stack> </Stack>
@@ -15,7 +15,7 @@ type Props = {
}; };
/** /**
* Under the composer: one button counting the MCP servers and tools the * Under the composer: one button counting the MCP servers, tools and skills the
* bot can use here (they open in a modal, so ten of them don't fill the * bot can use here (they open in a modal, so ten of them don't fill the
* composer), then the computer it's on, opening it in the viewer. * composer), then the computer it's on, opening it in the viewer.
*/ */
@@ -51,7 +51,7 @@ type Props = {
*/ */
workspace_id?: number | null; workspace_id?: number | null;
/** /**
* Its MCP servers and tools, listed under the input. * Its MCP servers, tools and skills, listed under the input.
*/ */
extensions?: BotExtensionsPayload | null; extensions?: BotExtensionsPayload | null;
/** /**
@@ -18,13 +18,13 @@ type Props<T> = {
/** /**
* Under `/api/admin/`. * Under `/api/admin/`.
*/ */
path: "mcps/set-mcp-access" | "tools/set-tool-access"; path: "mcps/set-mcp-access" | "tools/set-tool-access" | "skills/set-skill-access";
onSaved: (overview: T, msg: string) => void; onSaved: (overview: T, msg: string) => void;
onCancel: () => void; onCancel: () => void;
}; };
/** /**
* Who gets an MCP server or tool: the bots and workspaces picked here. * Who gets an MCP server, tool or skill: the bots and workspaces picked here.
* "Every bot" (super admins only) gives it to every bot now and every new * "Every bot" (super admins only) gives it to every bot now and every new
* one; each can still drop it. A workspace gives it to every bot in * one; each can still drop it. A workspace gives it to every bot in
* it, there. * it, there.
@@ -11,22 +11,35 @@ type Props = {
}; };
/** /**
* One MCP server or tool a bot or workspace has, and whether it's working. * The settings page each kind is managed on.
*/
const ExtensionHrefs = {
mcp: "/admin/settings/mcps",
tool: "/admin/settings/tools",
skill: "/admin/settings/skills",
} as const;
/**
* One MCP server, tool or skill a bot or workspace has, and whether it's
* working. A skill has nothing to connect or run, so it never reports a
* failure.
*/ */
export default function ExtensionRow({ item, busy, onRemove }: Props) { export default function ExtensionRow({ item, busy, onRemove }: Props) {
const state = !item.enabled const state = !item.enabled
? "Off for every bot" ? "Off for every bot"
: item.state == "error" : item.kind == "skill"
? "Not working" ? ""
: item.state == "untested" : item.state == "error"
? "Not checked yet" ? "Not working"
: ""; : item.state == "untested"
? "Not checked yet"
: "";
return ( return (
<SettingsListRow <SettingsListRow
avatar={<IconTile Icon={extensionIcon({ kind: item.kind })} />} avatar={<IconTile Icon={extensionIcon({ kind: item.kind })} />}
title={item.name} title={item.name}
href={item.kind == "mcp" ? "/admin/settings/mcps" : "/admin/settings/tools"} href={ExtensionHrefs[item.kind]}
subtitle={[item.detail, state].filter(Boolean).join(" · ")} subtitle={[item.detail, state].filter(Boolean).join(" · ")}
> >
{onRemove ? ( {onRemove ? (
@@ -1,6 +1,6 @@
import Button from "@moduletrace/twui/layout/Button"; import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row"; import Row from "@moduletrace/twui/layout/Row";
import { Plug, Wrench } from "lucide-react"; import { BookOpen, Plug, Wrench } from "lucide-react";
import ExtensionAddSelect from "@/src/components/general/extension-add-select"; import ExtensionAddSelect from "@/src/components/general/extension-add-select";
import useExtensions from "@/src/hooks/use-extensions"; import useExtensions from "@/src/hooks/use-extensions";
import type { BotExtensionsPayload } from "@/src/types"; import type { BotExtensionsPayload } from "@/src/types";
@@ -17,16 +17,16 @@ type Props = {
}; };
/** /**
* The MCP servers and tools a bot or a workspace has, removable, and a * The MCP servers, tools and skills a bot or a workspace has, removable,
* picker to add more. On the bot and workspace settings pages. * and a picker to add more. On the bot and workspace settings pages.
*/ */
export default function ExtensionsGroup({ owner, owner_name, description, initial }: Props) { export default function ExtensionsGroup({ owner, owner_name, description, initial }: Props) {
const extensions = useExtensions({ owner, owner_name, initial }); const extensions = useExtensions({ owner, owner_name, initial });
const id = "bot_id" in owner ? `bot-${owner.bot_id}` : `workspace-${owner.workspace_id}`; const id = "bot_id" in owner ? `bot-${owner.bot_id}` : `workspace-${owner.workspace_id}`;
return ( return (
<SettingsGroup id="tools" title="Tools and MCP servers" description={description}> <SettingsGroup id="tools" title="Tools, MCP servers and skills" description={description}>
<SettingsList is_empty={!extensions.attached.length} empty="No tools or MCP servers yet."> <SettingsList is_empty={!extensions.attached.length} empty="No tools, MCP servers or skills yet.">
{extensions.attached.map((item) => ( {extensions.attached.map((item) => (
<ExtensionRow <ExtensionRow
key={`${item.kind}:${item.id}`} key={`${item.kind}:${item.id}`}
@@ -73,6 +73,18 @@ export default function ExtensionsGroup({ owner, owner_name, description, initia
> >
Tools Tools
</Button> </Button>
<Button
title="Manage skills"
type="button"
variant="ghost"
color="gray"
size="small"
href="/admin/settings/skills"
beforeIcon={<BookOpen size={14} aria-hidden />}
className="gap-1!"
>
Skills
</Button>
</Row> </Row>
<SettingsStatus status={extensions.status} /> <SettingsStatus status={extensions.status} />
</SettingsGroup> </SettingsGroup>
@@ -75,7 +75,7 @@ export default function BotSettingsSection() {
<ExtensionsGroup <ExtensionsGroup
owner={{ bot_id: bot.id }} owner={{ bot_id: bot.id }}
owner_name={name} owner_name={name}
description="What it can use besides its own tools, everywhere it works. A workspace's own come on top while it works there." description="What it can use besides its own tools, everywhere it works: MCP servers, tools and skills. A workspace's own come on top while it works there."
initial={pageProps?.bot_extensions} initial={pageProps?.bot_extensions}
/> />
<ScopedEnvsGroup initial={pageProps?.bot_envs} /> <ScopedEnvsGroup initial={pageProps?.bot_envs} />
@@ -0,0 +1,120 @@
import { useState } from "react";
import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
import Checkbox from "@moduletrace/twui/form/Checkbox";
import Form from "@moduletrace/twui/form/Form";
import Input from "@moduletrace/twui/form/Input";
import Textarea from "@moduletrace/twui/form/Textarea";
import CodeEditor from "@/src/components/general/code-editor";
import { AppData } from "@/src/data/app-data";
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
import type { PublicSkill, SkillsOverviewPayload } from "@/src/types";
type Props = {
/**
* Edits this skill; adds one when not given.
*/
skill?: PublicSkill;
onSaved: (overview: SkillsOverviewPayload, msg: string) => void;
onCancel: () => void;
};
/**
* Adds or edits a skill: the name the settings page shows, the slug a bot
* loads it by, the one line that tells the bot when it applies, and the
* runbook itself in markdown.
*/
export default function SkillModal({ skill, onSaved, onCancel }: Props) {
const [name, setName] = useState(skill?.name || "");
const [slug, setSlug] = useState(skill?.slug || "");
const [description, setDescription] = useState(skill?.description || "");
const [body, setBody] = useState(skill?.body || "");
const [enabled, setEnabled] = useState(skill?.enabled ?? true);
const [all_bots, setAllBots] = useState(skill?.access.all_bots ?? false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
const id = `skill-${skill?.id || "new"}`;
const submit = async () => {
if (loading || !window.confirm(skill ? `Save ${skill.name}?` : `Add ${name || "this skill"}?`)) return;
setLoading(true);
setError(undefined);
const res = await postAdminApi<SkillsOverviewPayload>({
path: "skills/save-skill",
body: { skill_id: skill?.id, name, slug, description, body, enabled, all_bots },
});
setLoading(false);
if (!res.success || !res.singleRes) {
setError(res.msg || "Couldn't save the skill.");
return;
}
onSaved(res.singleRes, res.msg || "Saved.");
};
return (
<AddModalShell
open
setOpen={(next) => !next && onCancel()}
title={skill ? `Edit ${skill.name}` : "Add a skill"}
description={"Bots get it once it's attached to them."}
>
<Form className="w-full gap-4! items-stretch pt-4" onSubmit={submit}>
<Input
id={`${id}-name`}
title="Name"
label="Name"
showLabel
placeholder="Weekly SEO report"
defaultValue={name}
autoComplete="off"
info="What the settings page calls it."
onChange={(e) => setName(e.target.value)}
/>
<Input
id={`${id}-slug`}
title="Slug"
label="Slug"
showLabel
placeholder="weekly-seo-report"
defaultValue={slug}
autoComplete="off"
info="What a bot passes to load_skill: lowercase letters, numbers and hyphens. Unique."
onChange={(e) => setSlug(e.target.value)}
/>
<Textarea
id={`${id}-description`}
title="When to use it"
label="When to use it"
showLabel
rows={2}
placeholder="Use for the Monday search report: rankings, traffic and what changed."
defaultValue={description}
info="The one line a bot reads to decide whether this skill applies. Say what job it covers."
onChange={(e) => setDescription(e.target.value)}
/>
<CodeEditor
mode="markdown"
className="h-80"
title="Runbook"
content={body}
info={`Markdown. The steps, the checks, and what good looks like. A bot reads it only when it loads the skill, up to ${AppData.SkillBodyShownChars} characters of it at a time.`}
onChange={(value) => setBody(value)}
/>
<Checkbox title="On" label="On: bots get it" checked={enabled} changeHandler={setEnabled} />
<Checkbox
title="Every bot"
label="Every bot by default: every bot now and every new one, the coordinator included. Each can still drop it."
checked={all_bots}
changeHandler={setAllBots}
/>
<AddFormActions submit_label={skill ? "Save" : "Add skill"} loading={loading} error={error} onCancel={onCancel} />
</Form>
</AddModalShell>
);
}
@@ -0,0 +1,91 @@
import { useState } from "react";
import Stack from "@moduletrace/twui/layout/Stack";
import { BookOpen } from "lucide-react";
import IconTile from "@/src/components/general/icon-tile";
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
import type { ExtensionTargets, PublicSkill, SkillsOverviewPayload } from "@/src/types";
import ExtensionAccessModal from "../../(partials)/extension-access-modal";
import SettingsListRow from "../../(partials)/settings-list-row";
import SettingsRowAction from "../../(partials)/settings-row-action";
import extensionAccessLabel from "../../(utils)/extension-access-label";
import SkillModal from "./skill-modal";
type Props = {
skill: PublicSkill;
targets: ExtensionTargets;
onChanged: (overview: SkillsOverviewPayload, msg: string) => void;
onError: (msg: string) => void;
};
/**
* One skill: its slug and when it applies, and who gets it. Edit and Access
* open in a modal.
*/
export default function SkillRow({ skill, targets, onChanged, onError }: Props) {
const [open, setOpen] = useState<"edit" | "access">();
const [busy, setBusy] = useState(false);
const saved = (overview: SkillsOverviewPayload, msg: string) => {
setOpen(undefined);
onChanged(overview, msg);
};
const remove = async () => {
if (busy || !window.confirm(`Delete ${skill.name}? Bots lose it at once.`)) return;
setBusy(true);
const res = await postAdminApi<SkillsOverviewPayload>({
path: "skills/delete-skill",
body: { skill_id: skill.id },
});
setBusy(false);
if (res.success && res.singleRes) onChanged(res.singleRes, res.msg || "Deleted.");
else onError(res.msg || "Couldn't delete the skill.");
};
return (
<Stack className="w-full gap-0! items-stretch border-0 border-b border-solid border-hairline last:border-b-0">
<SettingsListRow
avatar={<IconTile Icon={BookOpen} />}
title={skill.name}
badge={skill.enabled ? undefined : "Off"}
subtitle={[skill.slug, extensionAccessLabel({ access: skill.access }), skill.description]
.filter(Boolean)
.join(" · ")}
>
{targets.can_edit ? (
<SettingsRowAction
title={`Edit ${skill.name}`}
label="Edit"
disabled={busy}
onClick={() => setOpen(open == "edit" ? undefined : "edit")}
/>
) : null}
<SettingsRowAction
title={`Choose who gets ${skill.name}`}
label="Access"
disabled={busy}
onClick={() => setOpen(open == "access" ? undefined : "access")}
/>
{targets.can_edit ? (
<SettingsRowAction title={`Delete ${skill.name}`} label="Delete" disabled={busy} onClick={remove} />
) : null}
</SettingsListRow>
{open == "edit" ? (
<SkillModal skill={skill} onSaved={saved} onCancel={() => setOpen(undefined)} />
) : null}
{open == "access" ? (
<ExtensionAccessModal<SkillsOverviewPayload>
id={skill.id}
name={skill.name}
access={skill.access}
targets={targets}
path="skills/set-skill-access"
onSaved={saved}
onCancel={() => setOpen(undefined)}
/>
) : null}
</Stack>
);
}
@@ -0,0 +1,79 @@
import { useContext, useState } from "react";
import Button from "@moduletrace/twui/layout/Button";
import { Plus } from "lucide-react";
import { AppContext } from "@/src/pages/__root";
import type { SkillsOverviewPayload } from "@/src/types";
import SettingsGroup from "../../(partials)/settings-group";
import SettingsList from "../../(partials)/settings-list";
import SettingsStatus from "../../(partials)/settings-status";
import SkillModal from "../(partials)/skill-modal";
import SkillRow from "../(partials)/skill-row";
type Status = { error?: boolean; success?: boolean; msg?: string };
/**
* Every skill with who gets it. Super admins add, edit and delete them;
* admins choose which bots and workspaces get each one.
*/
export default function SkillsSettingsSection() {
const { pageProps } = useContext(AppContext);
const [overview, setOverview] = useState(pageProps?.skills_overview);
const [adding, setAdding] = useState(false);
const [status, setStatus] = useState<Status>();
/**
* Remounts the rows when the data changes under them.
*/
const [version, setVersion] = useState(0);
if (!overview) return null;
const changed = (next: SkillsOverviewPayload, msg: string) => {
setOverview(next);
setStatus({ success: true, msg });
setVersion((prev) => prev + 1);
};
const failed = (msg: string) => setStatus({ error: true, msg });
return (
<SettingsGroup
title="Skills"
description="A runbook for a job a bot does: the steps, the checks and what good looks like. A bot sees each skill's name and when it applies; it reads the whole thing only when it loads it, so a long one costs nothing until it's needed. Give one to a bot on its settings page or in its chat, or to a workspace to reach every bot there."
>
{adding ? (
<SkillModal
onSaved={(next, msg) => {
setAdding(false);
changed(next, msg);
}}
onCancel={() => setAdding(false)}
/>
) : null}
<SettingsList is_empty={!overview.skills.length} empty="No skills yet.">
{overview.skills.map((skill) => (
<SkillRow
key={`${skill.id}-${version}`}
skill={skill}
targets={overview}
onChanged={changed}
onError={failed}
/>
))}
</SettingsList>
{overview.can_edit && !adding ? (
<Button
title="Add a skill"
type="button"
variant="ghost"
color="gray"
size="small"
onClick={() => setAdding(true)}
className="self-start gap-1!"
>
<Plus size={14} aria-hidden />
New skill
</Button>
) : null}
<SettingsStatus status={status} />
</SettingsGroup>
);
}
@@ -0,0 +1,20 @@
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import grabSkillsOverview from "@/src/functions/backend/db/extensions/grab-skills-overview";
import grabCrewSettingsServerProps from "../(functions)/grab-crew-settings-server-props";
/**
* Super admins and admins: admins only choose who gets each skill.
*/
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const settings = await grabCrewSettingsServerProps(params);
if (!settings.props?.person?.id) return settings;
return {
props: {
...settings.props,
skills_overview: await grabSkillsOverview({ user_id: settings.props.person.id }),
},
};
};
+13
View File
@@ -0,0 +1,13 @@
import SkillsSettingsSection from "./(sections)/skills-settings-section";
import SettingsShell from "../(partials)/settings-shell";
import settingsPageMeta from "../(functions)/settings-page-meta";
export default function AdminSettingsSkillsPage() {
return (
<SettingsShell title="Skills" description="Runbooks your bots follow when a job matches one.">
<SkillsSettingsSection />
</SettingsShell>
);
}
export const meta = settingsPageMeta({ label: "Skills" });
@@ -68,7 +68,7 @@ export default function WorkspaceOverview() {
<ExtensionsGroup <ExtensionsGroup
owner={{ workspace_id }} owner={{ workspace_id }}
owner_name={name} owner_name={name}
description="Every bot working here gets these, the coordinator too, on top of its own, only while it works here." description="Every bot working here gets these, the coordinator too, on top of its own, only while it works here: MCP servers, tools and skills."
initial={pageProps?.workspace_extensions} initial={pageProps?.workspace_extensions}
/> />
<ScopedEnvsGroup initial={pageProps?.workspace_envs} /> <ScopedEnvsGroup initial={pageProps?.workspace_envs} />
+3 -2
View File
@@ -7,6 +7,7 @@ import type { BotExtensionsPayload } from "@/src/types";
import AppError from "@/src/utils/app-error"; import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema"; import parseWithSchema from "@/src/utils/parse-with-schema";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user"; import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import extensionNoun, { extensionLogTitle } from "@/src/utils/extension-noun";
import safeErrorMessage from "@/src/utils/safe-error-message"; import safeErrorMessage from "@/src/utils/safe-error-message";
import type { import type {
APIResponseObject, APIResponseObject,
@@ -37,10 +38,10 @@ export const handler: BunextAPIRouteHandler<APIResponseObject<BotExtensionsPaylo
const { bot, name } = await setBotExtension({ user_id: user.id, bot_id, kind, id, attached: true }); const { bot, name } = await setBotExtension({ user_id: user.id, bot_id, kind, id, attached: true });
await logEvent({ await logEvent({
title: "Bot tool added", title: extensionLogTitle({ kind, verb: "added" }),
category: "system", category: "system",
user_id: user.id, user_id: user.id,
plain_text: `User #${user.id} gave ${kind == "mcp" ? "the MCP server" : "the tool"} ${name} to ${bot.name} (#${bot.id}).`, plain_text: `User #${user.id} gave ${extensionNoun({ kind, article: true })} ${name} to ${bot.name} (#${bot.id}).`,
metadata: { bot_id, kind, id }, metadata: { bot_id, kind, id },
ip_address: grabRequestIP(req), ip_address: grabRequestIP(req),
}); });
+3 -2
View File
@@ -7,6 +7,7 @@ import type { BotExtensionsPayload } from "@/src/types";
import AppError from "@/src/utils/app-error"; import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema"; import parseWithSchema from "@/src/utils/parse-with-schema";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user"; import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import extensionNoun, { extensionLogTitle } from "@/src/utils/extension-noun";
import safeErrorMessage from "@/src/utils/safe-error-message"; import safeErrorMessage from "@/src/utils/safe-error-message";
import type { import type {
APIResponseObject, APIResponseObject,
@@ -38,10 +39,10 @@ export const handler: BunextAPIRouteHandler<APIResponseObject<BotExtensionsPaylo
const { bot, name } = await setBotExtension({ user_id: user.id, bot_id, kind, id, attached: false }); const { bot, name } = await setBotExtension({ user_id: user.id, bot_id, kind, id, attached: false });
await logEvent({ await logEvent({
title: "Bot tool removed", title: extensionLogTitle({ kind, verb: "removed" }),
category: "system", category: "system",
user_id: user.id, user_id: user.id,
plain_text: `User #${user.id} took ${kind == "mcp" ? "the MCP server" : "the tool"} ${name} from ${bot.name} (#${bot.id}).`, plain_text: `User #${user.id} took ${extensionNoun({ kind, article: true })} ${name} from ${bot.name} (#${bot.id}).`,
metadata: { bot_id, kind, id }, metadata: { bot_id, kind, id },
ip_address: grabRequestIP(req), ip_address: grabRequestIP(req),
}); });
@@ -0,0 +1,52 @@
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
import deleteSkill from "@/src/functions/backend/db/extensions/delete-skill";
import grabSkillsOverview from "@/src/functions/backend/db/extensions/grab-skills-overview";
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
import { SkillIdRequestSchema } from "@/src/schemas/extension-schema";
import type { SkillsOverviewPayload } from "@/src/types";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Super admin only: deletes a skill. Bots lose it at once.
*/
export const handler: BunextAPIRouteHandler<
APIResponseObject<SkillsOverviewPayload>
> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const user = await grabSuperAdminUser({ req });
if (!user?.id) {
throw new AppError(`Only a super admin can do that.`);
}
const { skill_id } = parseWithSchema({ schema: SkillIdRequestSchema, data: body });
const skill = await deleteSkill({ skill_id });
await logEvent({
title: "Skill deleted",
category: "system",
user_id: user.id,
plain_text: `User #${user.id} deleted the skill ${skill.name} (#${skill.id}, ${skill.slug}).`,
metadata: { skill_id },
ip_address: grabRequestIP(req),
});
return {
success: true,
msg: `Deleted ${skill.name}.`,
singleRes: await grabSkillsOverview({ user_id: user.id }),
};
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+52
View File
@@ -0,0 +1,52 @@
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
import grabSkillsOverview from "@/src/functions/backend/db/extensions/grab-skills-overview";
import saveSkill from "@/src/functions/backend/db/extensions/save-skill";
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
import { SaveSkillRequestSchema } from "@/src/schemas/extension-schema";
import type { SkillsOverviewPayload } from "@/src/types";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Super admin only: adds or edits a skill.
*/
export const handler: BunextAPIRouteHandler<
APIResponseObject<SkillsOverviewPayload>
> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const user = await grabSuperAdminUser({ req });
if (!user?.id) {
throw new AppError(`Only a super admin can do that.`);
}
const data = parseWithSchema({ schema: SaveSkillRequestSchema, data: body });
const skill_id = await saveSkill({ user_id: user.id, data });
await logEvent({
title: data.skill_id ? "Skill changed" : "Skill added",
category: "system",
user_id: user.id,
plain_text: `User #${user.id} ${data.skill_id ? "changed" : "added"} the skill ${data.name} (#${skill_id}, ${data.slug}).`,
metadata: { skill_id, slug: data.slug },
ip_address: grabRequestIP(req),
});
return {
success: true,
msg: `Saved ${data.name}.`,
singleRes: await grabSkillsOverview({ user_id: user.id }),
};
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
@@ -0,0 +1,59 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabSkillsOverview from "@/src/functions/backend/db/extensions/grab-skills-overview";
import setExtensionAccess from "@/src/functions/backend/db/extensions/set-extension-access";
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
import { SetExtensionAccessRequestSchema } from "@/src/schemas/extension-schema";
import type { SkillsOverviewPayload } from "@/src/types";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Super admins and admins: attaches a skill to exactly these bots and
* workspaces. Only a super admin sets "all bots" (`setExtensionAccess`).
*/
export const handler: BunextAPIRouteHandler<
APIResponseObject<SkillsOverviewPayload>
> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
if (isReadOnlyUser({ user_types: user_types || [] })) {
throw new AppError(`This user can only read records.`);
}
const access = parseWithSchema({ schema: SetExtensionAccessRequestSchema, data: body });
await setExtensionAccess({ user_id: user.id, kind: "skill", ...access });
await logEvent({
title: "Skill access changed",
category: "system",
user_id: user.id,
plain_text: `User #${user.id} changed who gets the skill #${access.id}.`,
metadata: access,
ip_address: grabRequestIP(req),
});
return {
success: true,
msg: `Saved who gets it.`,
singleRes: await grabSkillsOverview({ user_id: user.id }),
};
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
@@ -7,6 +7,7 @@ import type { BotExtensionsPayload } from "@/src/types";
import AppError from "@/src/utils/app-error"; import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema"; import parseWithSchema from "@/src/utils/parse-with-schema";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user"; import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import extensionNoun, { extensionLogTitle } from "@/src/utils/extension-noun";
import safeErrorMessage from "@/src/utils/safe-error-message"; import safeErrorMessage from "@/src/utils/safe-error-message";
import type { import type {
APIResponseObject, APIResponseObject,
@@ -37,10 +38,10 @@ export const handler: BunextAPIRouteHandler<APIResponseObject<BotExtensionsPaylo
const { workspace, name } = await setWorkspaceExtension({ user_id: user.id, workspace_id, kind, id, attached: true }); const { workspace, name } = await setWorkspaceExtension({ user_id: user.id, workspace_id, kind, id, attached: true });
await logEvent({ await logEvent({
title: "Workspace tool added", title: extensionLogTitle({ kind, verb: "added" }),
category: "system", category: "system",
user_id: user.id, user_id: user.id,
plain_text: `User #${user.id} gave ${kind == "mcp" ? "the MCP server" : "the tool"} ${name} to the workspace ${workspace.name} (#${workspace.id}).`, plain_text: `User #${user.id} gave ${extensionNoun({ kind, article: true })} ${name} to the workspace ${workspace.name} (#${workspace.id}).`,
metadata: { workspace_id, kind, id }, metadata: { workspace_id, kind, id },
ip_address: grabRequestIP(req), ip_address: grabRequestIP(req),
}); });
@@ -7,6 +7,7 @@ import type { BotExtensionsPayload } from "@/src/types";
import AppError from "@/src/utils/app-error"; import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema"; import parseWithSchema from "@/src/utils/parse-with-schema";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user"; import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import extensionNoun, { extensionLogTitle } from "@/src/utils/extension-noun";
import safeErrorMessage from "@/src/utils/safe-error-message"; import safeErrorMessage from "@/src/utils/safe-error-message";
import type { import type {
APIResponseObject, APIResponseObject,
@@ -36,10 +37,10 @@ export const handler: BunextAPIRouteHandler<APIResponseObject<BotExtensionsPaylo
const { workspace, name } = await setWorkspaceExtension({ user_id: user.id, workspace_id, kind, id, attached: false }); const { workspace, name } = await setWorkspaceExtension({ user_id: user.id, workspace_id, kind, id, attached: false });
await logEvent({ await logEvent({
title: "Workspace tool removed", title: extensionLogTitle({ kind, verb: "removed" }),
category: "system", category: "system",
user_id: user.id, user_id: user.id,
plain_text: `User #${user.id} took ${kind == "mcp" ? "the MCP server" : "the tool"} ${name} from the workspace ${workspace.name} (#${workspace.id}).`, plain_text: `User #${user.id} took ${extensionNoun({ kind, article: true })} ${name} from the workspace ${workspace.name} (#${workspace.id}).`,
metadata: { workspace_id, kind, id }, metadata: { workspace_id, kind, id },
ip_address: grabRequestIP(req), ip_address: grabRequestIP(req),
}); });
+35 -2
View File
@@ -7,6 +7,7 @@ import {
} from "@/src/dict/custom-tool-dict"; } from "@/src/dict/custom-tool-dict";
import { McpTransports, type McpTransport } from "@/src/dict/mcp-dict"; import { McpTransports, type McpTransport } from "@/src/dict/mcp-dict";
import checkCustomToolName from "@/src/utils/check-custom-tool-name"; import checkCustomToolName from "@/src/utils/check-custom-tool-name";
import checkSkillSlug from "@/src/utils/check-skill-slug";
const Id = z.coerce.number().int().positive(); const Id = z.coerce.number().int().positive();
const Ids = z.array(Id).max(1000).default([]); const Ids = z.array(Id).max(1000).default([]);
@@ -77,7 +78,7 @@ export const SetExtensionAccessRequestSchema = z.object({
export const SetBotExtensionRequestSchema = z.object({ export const SetBotExtensionRequestSchema = z.object({
bot_id: Id, bot_id: Id,
kind: z.enum(["mcp", "tool"]), kind: z.enum(["mcp", "tool", "skill"]),
id: Id, id: Id,
/** /**
* The workspace the bot is seen in, for the list that comes back. * The workspace the bot is seen in, for the list that comes back.
@@ -87,7 +88,7 @@ export const SetBotExtensionRequestSchema = z.object({
export const SetWorkspaceExtensionRequestSchema = z.object({ export const SetWorkspaceExtensionRequestSchema = z.object({
workspace_id: Id, workspace_id: Id,
kind: z.enum(["mcp", "tool"]), kind: z.enum(["mcp", "tool", "skill"]),
id: Id, id: Id,
}); });
@@ -170,7 +171,39 @@ export const TestCustomToolRequestSchema = z
}) })
.refine((value) => value.custom_tool_id || value.draft, { message: "Pick a tool to try." }); .refine((value) => value.custom_tool_id || value.draft, { message: "Pick a tool to try." });
/**
* A skill: the runbook, and the line that tells a bot when to load it.
*/
export const SaveSkillRequestSchema = z.object({
skill_id: Id.optional(),
name: z.string().trim().min(1, "Enter a name").max(80),
slug: z
.string()
.trim()
.superRefine((slug, ctx) => {
const problem = checkSkillSlug({ slug });
if (problem) ctx.addIssue({ code: "custom", message: problem });
}),
description: z
.string()
.trim()
.min(1, "Say in one line when a bot should use it")
.max(AppData.SkillDescriptionMaxChars),
body: z
.string()
.trim()
.min(1, "Write the runbook")
.max(AppData.SkillBodyMaxChars, `Keep the runbook under ${AppData.SkillBodyMaxChars} characters`),
enabled: z.boolean().default(true),
all_bots: z.boolean().default(false),
});
export const SkillIdRequestSchema = z.object({
skill_id: Id,
});
export type SaveMcpServerRequest = z.infer<typeof SaveMcpServerRequestSchema>; export type SaveMcpServerRequest = z.infer<typeof SaveMcpServerRequestSchema>;
export type SaveSkillRequest = z.infer<typeof SaveSkillRequestSchema>;
export type SaveCustomToolRequest = z.infer<typeof SaveCustomToolRequestSchema>; export type SaveCustomToolRequest = z.infer<typeof SaveCustomToolRequestSchema>;
export type TestMcpServerRequest = z.infer<typeof TestMcpServerRequestSchema>; export type TestMcpServerRequest = z.infer<typeof TestMcpServerRequestSchema>;
export type DraftCustomTool = z.infer<typeof DraftCustomTool>; export type DraftCustomTool = z.infer<typeof DraftCustomTool>;
+29 -3
View File
@@ -598,6 +598,27 @@ export type CustomToolRecord = {
| { kind: "prompt"; config: CustomToolPromptConfig } | { kind: "prompt"; config: CustomToolPromptConfig }
); );
/**
* A `skills` row: a runbook a bot loads when it applies.
*/
export type SkillRecord = {
id: number;
name: string;
slug: string;
description: string;
body: string;
all_bots: boolean;
enabled: boolean;
};
/**
* A skill as the Skills settings page shows it: nothing secret in one, so
* it's the row with who gets it instead of `all_bots`.
*/
export type PublicSkill = Omit<SkillRecord, "all_bots"> & {
access: ExtensionAccess;
};
/** /**
* An MCP server as the MCPs settings page shows it. Only super admins get * An MCP server as the MCPs settings page shows it. Only super admins get
* how it's reached (`url`, `headers`, `command`, `setup_command`, `env`). * how it's reached (`url`, `headers`, `command`, `setup_command`, `env`).
@@ -650,7 +671,7 @@ export type ExtensionTargets = {
* composer show it. * composer show it.
*/ */
export type BotExtension = { export type BotExtension = {
kind: "mcp" | "tool"; kind: "mcp" | "tool" | "skill";
id: number; id: number;
name: string; name: string;
description: string; description: string;
@@ -668,12 +689,12 @@ export type BotExtension = {
}; };
/** /**
* A bot's MCP servers and tools, and what can be added. Only super admins * A bot's MCP servers, tools and skills, and what can be added. Only super admins
* and admins (the super admin alone for the coordinator) manage them. * and admins (the super admin alone for the coordinator) manage them.
*/ */
export type BotExtensionsPayload = { export type BotExtensionsPayload = {
attached: BotExtension[]; attached: BotExtension[];
addable: { kind: "mcp" | "tool"; id: number; name: string }[]; addable: { kind: "mcp" | "tool" | "skill"; id: number; name: string }[];
can_manage: boolean; can_manage: boolean;
}; };
@@ -681,6 +702,10 @@ export type McpServersOverviewPayload = ExtensionTargets & {
servers: PublicMcpServer[]; servers: PublicMcpServer[];
}; };
export type SkillsOverviewPayload = ExtensionTargets & {
skills: PublicSkill[];
};
export type CustomToolsOverviewPayload = ExtensionTargets & { export type CustomToolsOverviewPayload = ExtensionTargets & {
tools: PublicCustomTool[]; tools: PublicCustomTool[];
}; };
@@ -1016,6 +1041,7 @@ export type PagePropsType = {
push_overview?: PushOverview | null; push_overview?: PushOverview | null;
mcps_overview?: McpServersOverviewPayload | null; mcps_overview?: McpServersOverviewPayload | null;
tools_overview?: CustomToolsOverviewPayload | null; tools_overview?: CustomToolsOverviewPayload | null;
skills_overview?: SkillsOverviewPayload | null;
bot_extensions?: BotExtensionsPayload | null; bot_extensions?: BotExtensionsPayload | null;
/** /**
* The files browser's folders (a user's own crew and media, and what a * The files browser's folders (a user's own crew and media, and what a
+16
View File
@@ -8,6 +8,9 @@ beforeAll(() => {
db.run(`CREATE TABLE custom_tools (id INTEGER PRIMARY KEY, all_bots INTEGER, enabled INTEGER)`); db.run(`CREATE TABLE custom_tools (id INTEGER PRIMARY KEY, all_bots INTEGER, enabled INTEGER)`);
db.run(`CREATE TABLE bot_custom_tools (bot_id INTEGER, custom_tool_id INTEGER)`); db.run(`CREATE TABLE bot_custom_tools (bot_id INTEGER, custom_tool_id INTEGER)`);
db.run(`CREATE TABLE workspace_custom_tools (workspace_id INTEGER, custom_tool_id INTEGER)`); db.run(`CREATE TABLE workspace_custom_tools (workspace_id INTEGER, custom_tool_id INTEGER)`);
db.run(`CREATE TABLE skills (id INTEGER PRIMARY KEY, all_bots INTEGER, enabled INTEGER)`);
db.run(`CREATE TABLE bot_skills (bot_id INTEGER, skill_id INTEGER)`);
db.run(`CREATE TABLE workspace_skills (workspace_id INTEGER, skill_id INTEGER)`);
/** /**
* 1: every bot (given to 5 and 6). 2: bot 5. 3: workspace 9. 4: off, * 1: every bot (given to 5 and 6). 2: bot 5. 3: workspace 9. 4: off,
* given to 5. 5: nobody. 6: every bot, but 6 dropped it. * given to 5. 5: nobody. 6: every bot, but 6 dropped it.
@@ -15,6 +18,12 @@ beforeAll(() => {
db.run(`INSERT INTO custom_tools VALUES (1, 1, 1), (2, 0, 1), (3, 0, 1), (4, 1, 0), (5, 0, 1), (6, 1, 1)`); db.run(`INSERT INTO custom_tools VALUES (1, 1, 1), (2, 0, 1), (3, 0, 1), (4, 1, 0), (5, 0, 1), (6, 1, 1)`);
db.run(`INSERT INTO bot_custom_tools VALUES (5, 1), (6, 1), (5, 2), (5, 4), (5, 6)`); db.run(`INSERT INTO bot_custom_tools VALUES (5, 1), (6, 1), (5, 2), (5, 4), (5, 6)`);
db.run(`INSERT INTO workspace_custom_tools VALUES (9, 3)`); db.run(`INSERT INTO workspace_custom_tools VALUES (9, 3)`);
/**
* The same shape, so skills reach a bot the same way.
*/
db.run(`INSERT INTO skills VALUES (1, 1, 1), (2, 0, 1), (3, 0, 1), (4, 1, 0), (5, 0, 1), (6, 1, 1)`);
db.run(`INSERT INTO bot_skills VALUES (5, 1), (6, 1), (5, 2), (5, 4), (5, 6)`);
db.run(`INSERT INTO workspace_skills VALUES (9, 3)`);
}); });
function ids(params: Parameters<typeof botExtensionsSql>[0]) { function ids(params: Parameters<typeof botExtensionsSql>[0]) {
@@ -37,4 +46,11 @@ describe("botExtensionsSql", () => {
expect(ids({ kind: "tool", bot_id: 5, workspace_id: 9 })).toEqual([1, 2, 3, 6]); expect(ids({ kind: "tool", bot_id: 5, workspace_id: 9 })).toEqual([1, 2, 3, 6]);
expect(ids({ kind: "tool", bot_id: 5, workspace_id: 8 })).toEqual([1, 2, 6]); expect(ids({ kind: "tool", bot_id: 5, workspace_id: 8 })).toEqual([1, 2, 6]);
}); });
test("skills reach a bot the same way", () => {
expect(ids({ kind: "skill", bot_id: 5 })).toEqual([1, 2, 6]);
expect(ids({ kind: "skill", bot_id: 6 })).toEqual([1]);
expect(ids({ kind: "skill", bot_id: 7 })).toEqual([]);
expect(ids({ kind: "skill", bot_id: 6, workspace_id: 9 })).toEqual([1, 3]);
});
}); });
+9 -3
View File
@@ -1,8 +1,8 @@
type Params = { type Params = {
/** /**
* Which kind: MCP servers or custom tools. * Which kind: MCP servers, custom tools or skills.
*/ */
kind: "mcp" | "tool"; kind: "mcp" | "tool" | "skill";
bot_id: number | string; bot_id: number | string;
/** /**
* The workspace the run is in: what's attached to it counts too. * The workspace the run is in: what's attached to it counts too.
@@ -23,10 +23,16 @@ const Tables = {
workspace_link: "workspace_custom_tools", workspace_link: "workspace_custom_tools",
column: "custom_tool_id", column: "custom_tool_id",
}, },
skill: {
table: "skills",
bot_link: "bot_skills",
workspace_link: "workspace_skills",
column: "skill_id",
},
} as const; } as const;
/** /**
* The enabled MCP servers or custom tools a bot gets on one run: those * The enabled MCP servers, custom tools or skills a bot gets on one run: those
* attached to the bot, and in a workspace those attached to the * attached to the bot, and in a workspace those attached to the
* workspace. "Every bot" only decides what a bot is given (now and when * workspace. "Every bot" only decides what a bot is given (now and when
* it's created, `attachToAllBots`, `attachDefaultExtensions`); one it * it's created, `attachToAllBots`, `attachDefaultExtensions`); one it
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, test } from "bun:test";
import checkSkillSlug from "./check-skill-slug";
describe("checkSkillSlug", () => {
test("accepts lowercase words joined by single hyphens", () => {
expect(checkSkillSlug({ slug: "seo" })).toBeNull();
expect(checkSkillSlug({ slug: "weekly-seo-report" })).toBeNull();
expect(checkSkillSlug({ slug: "report2024" })).toBeNull();
});
test("refuses capitals, spaces, underscores and dots", () => {
expect(checkSkillSlug({ slug: "Weekly" })).not.toBeNull();
expect(checkSkillSlug({ slug: "weekly seo" })).not.toBeNull();
expect(checkSkillSlug({ slug: "weekly_seo" })).not.toBeNull();
expect(checkSkillSlug({ slug: "weekly.seo" })).not.toBeNull();
});
test("refuses a hyphen at either end and doubled hyphens", () => {
expect(checkSkillSlug({ slug: "-weekly" })).not.toBeNull();
expect(checkSkillSlug({ slug: "weekly-" })).not.toBeNull();
expect(checkSkillSlug({ slug: "weekly--seo" })).not.toBeNull();
});
test("refuses slugs shorter than 2 or longer than 64 characters", () => {
expect(checkSkillSlug({ slug: "a" })).not.toBeNull();
expect(checkSkillSlug({ slug: "a".repeat(65) })).not.toBeNull();
expect(checkSkillSlug({ slug: "a".repeat(64) })).toBeNull();
});
});
+22
View File
@@ -0,0 +1,22 @@
import { SkillSlugPattern } from "@/src/dict/skill-dict";
type Params = {
slug: string;
};
/**
* Why a skill can't take this slug, or null when it can: lowercase letters,
* numbers and single hyphens, 2 to 64 characters, never starting or ending
* with one. It's what a bot writes in `load_skill`.
*/
export default function checkSkillSlug({ slug }: Params): string | null {
if (slug.length < 2 || slug.length > 64) {
return `Use 2 to 64 characters.`;
}
if (!SkillSlugPattern.test(slug)) {
return `Use lowercase letters, numbers and hyphens, e.g. weekly-seo-report.`;
}
return null;
}
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, test } from "bun:test";
import extensionNoun, { extensionLogTitle } from "./extension-noun";
describe("extensionNoun", () => {
test("names each kind, with or without its article", () => {
expect(extensionNoun({ kind: "mcp" })).toBe("MCP server");
expect(extensionNoun({ kind: "tool" })).toBe("tool");
expect(extensionNoun({ kind: "skill" })).toBe("skill");
expect(extensionNoun({ kind: "skill", article: true })).toBe("the skill");
});
});
describe("extensionLogTitle", () => {
test("capitalizes the noun and adds what happened", () => {
expect(extensionLogTitle({ kind: "mcp", verb: "added" })).toBe("MCP server added");
expect(extensionLogTitle({ kind: "tool", verb: "removed" })).toBe("Tool removed");
expect(extensionLogTitle({ kind: "skill", verb: "added" })).toBe("Skill added");
});
});
+36
View File
@@ -0,0 +1,36 @@
import type { BotExtension } from "@/src/types";
type Params = {
kind: BotExtension["kind"];
/**
* With the article, for a sentence: "the tool", "the MCP server".
*/
article?: boolean;
};
/**
* What an extension kind is called in copy: a tool, an MCP server or a
* skill.
*/
export default function extensionNoun({ kind, article }: Params) {
const noun = kind == "mcp" ? "MCP server" : kind == "skill" ? "skill" : "tool";
return article ? `the ${noun}` : noun;
}
type TitleParams = {
kind: BotExtension["kind"];
/**
* What happened, in one word: "added", "removed".
*/
verb: string;
};
/**
* A log entry's title: "MCP server added", "Skill removed".
*/
export function extensionLogTitle({ kind, verb }: TitleParams) {
const noun = extensionNoun({ kind });
return `${noun.charAt(0).toUpperCase()}${noun.slice(1)} ${verb}`;
}
+9
View File
@@ -7,11 +7,20 @@ describe("extensionOptionValue", () => {
kind: "mcp", kind: "mcp",
id: 3, id: 3,
}); });
expect(parseExtensionOptionValue({ value: extensionOptionValue({ kind: "tool", id: 4 }) })).toEqual({
kind: "tool",
id: 4,
});
expect(parseExtensionOptionValue({ value: extensionOptionValue({ kind: "skill", id: 5 }) })).toEqual({
kind: "skill",
id: 5,
});
}); });
test("refuses anything else", () => { test("refuses anything else", () => {
expect(parseExtensionOptionValue({ value: "" })).toBeNull(); expect(parseExtensionOptionValue({ value: "" })).toBeNull();
expect(parseExtensionOptionValue({ value: "bot:2" })).toBeNull(); expect(parseExtensionOptionValue({ value: "bot:2" })).toBeNull();
expect(parseExtensionOptionValue({ value: "tool:x" })).toBeNull(); expect(parseExtensionOptionValue({ value: "tool:x" })).toBeNull();
expect(parseExtensionOptionValue({ value: "skill:0" })).toBeNull();
}); });
}); });
+2 -2
View File
@@ -1,7 +1,7 @@
import type { BotExtension } from "@/src/types"; import type { BotExtension } from "@/src/types";
/** /**
* An MCP server or tool as one select value (`mcp:3`), and back. * An MCP server, tool or skill as one select value (`mcp:3`), and back.
*/ */
export function extensionOptionValue({ kind, id }: { kind: BotExtension["kind"]; id: number }) { export function extensionOptionValue({ kind, id }: { kind: BotExtension["kind"]; id: number }) {
return `${kind}:${id}`; return `${kind}:${id}`;
@@ -10,7 +10,7 @@ export function extensionOptionValue({ kind, id }: { kind: BotExtension["kind"];
export function parseExtensionOptionValue({ value }: { value: string }) { export function parseExtensionOptionValue({ value }: { value: string }) {
const [kind, id] = value.split(":"); const [kind, id] = value.split(":");
if ((kind != "mcp" && kind != "tool") || !Number(id)) return null; if ((kind != "mcp" && kind != "tool" && kind != "skill") || !Number(id)) return null;
return { kind: kind as BotExtension["kind"], id: Number(id) }; return { kind: kind as BotExtension["kind"], id: Number(id) };
} }