From 08d02e60338766840efffa6a8d5ecb1c6c618f60 Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Thu, 22 Oct 2026 12:31:42 +0100 Subject: [PATCH] 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. --- .gitignore | 1 + AGENTS.md | 5 +- README.md | 1 + db/schema.ts | 127 ++++++++++++++++++ db/types/db.ts | 79 ++++++++++- src/bots-queue/run-bot-reply.ts | 12 +- src/bots-queue/run-bot-task.ts | 10 +- .../general/extension-add-select.tsx | 9 +- src/components/general/extension-icon.tsx | 9 +- src/data/app-data.ts | 8 ++ src/data/harness-prompts.ts | 12 ++ src/dict/bot-queue-dict.ts | 1 + src/dict/custom-tool-dict.ts | 1 + src/dict/settings-pages-dict.ts | 2 + src/dict/skill-dict.ts | 16 +++ .../assert-bot-extension-manager.ts | 2 +- .../extensions/attach-default-extensions.ts | 10 +- .../backend/db/extensions/delete-skill.ts | 25 ++++ .../backend/db/extensions/extension-tables.ts | 7 + .../grab-bot-extensions-overview.ts | 4 +- .../db/extensions/grab-bot-extensions.ts | 13 +- .../db/extensions/grab-extensions-overview.ts | 40 +++++- .../backend/db/extensions/grab-skill.ts | 24 ++++ .../db/extensions/grab-skills-overview.ts | 34 +++++ .../grab-workspace-extensions-overview.ts | 2 +- .../backend/db/extensions/parse-skill-row.ts | 21 +++ .../backend/db/extensions/save-skill.ts | 66 +++++++++ .../tools/extensions/open-bot-extensions.ts | 13 +- .../tools/skills/open-bot-skills.test.ts | 78 +++++++++++ src/harness/tools/skills/open-bot-skills.ts | 67 +++++++++ src/hooks/use-extensions.ts | 2 +- .../[bot_id]/(partials)/composer-tool-row.tsx | 17 ++- .../(partials)/composer-tools-modal.tsx | 16 +-- .../[bot_id]/(partials)/composer-tools.tsx | 2 +- .../bots/[bot_id]/(partials)/composer.tsx | 2 +- .../(partials)/extension-access-modal.tsx | 4 +- .../settings/(partials)/extension-row.tsx | 27 +++- .../settings/(partials)/extensions-group.tsx | 22 ++- .../(sections)/bot-settings-section.tsx | 2 +- .../skills/(partials)/skill-modal.tsx | 120 +++++++++++++++++ .../settings/skills/(partials)/skill-row.tsx | 91 +++++++++++++ .../(sections)/skills-settings-section.tsx | 79 +++++++++++ .../admin/settings/skills/index.server.ts | 20 +++ src/pages/admin/settings/skills/index.tsx | 13 ++ .../(sections)/workspace-overview.tsx | 2 +- src/pages/api/admin/bots/add-extension.ts | 5 +- src/pages/api/admin/bots/remove-extension.ts | 5 +- src/pages/api/admin/skills/delete-skill.ts | 52 +++++++ src/pages/api/admin/skills/save-skill.ts | 52 +++++++ .../api/admin/skills/set-skill-access.ts | 59 ++++++++ .../api/admin/workspaces/add-extension.ts | 5 +- .../api/admin/workspaces/remove-extension.ts | 5 +- src/schemas/extension-schema.ts | 37 ++++- src/types/index.ts | 32 ++++- src/utils/bot-extensions-sql.test.ts | 16 +++ src/utils/bot-extensions-sql.ts | 12 +- src/utils/check-skill-slug.test.ts | 29 ++++ src/utils/check-skill-slug.ts | 22 +++ src/utils/extension-noun.test.ts | 19 +++ src/utils/extension-noun.ts | 36 +++++ src/utils/extension-option-value.test.ts | 9 ++ src/utils/extension-option-value.ts | 4 +- 62 files changed, 1438 insertions(+), 79 deletions(-) create mode 100644 src/dict/skill-dict.ts create mode 100644 src/functions/backend/db/extensions/delete-skill.ts create mode 100644 src/functions/backend/db/extensions/grab-skill.ts create mode 100644 src/functions/backend/db/extensions/grab-skills-overview.ts create mode 100644 src/functions/backend/db/extensions/parse-skill-row.ts create mode 100644 src/functions/backend/db/extensions/save-skill.ts create mode 100644 src/harness/tools/skills/open-bot-skills.test.ts create mode 100644 src/harness/tools/skills/open-bot-skills.ts create mode 100644 src/pages/admin/settings/skills/(partials)/skill-modal.tsx create mode 100644 src/pages/admin/settings/skills/(partials)/skill-row.tsx create mode 100644 src/pages/admin/settings/skills/(sections)/skills-settings-section.tsx create mode 100644 src/pages/admin/settings/skills/index.server.ts create mode 100644 src/pages/admin/settings/skills/index.tsx create mode 100644 src/pages/api/admin/skills/delete-skill.ts create mode 100644 src/pages/api/admin/skills/save-skill.ts create mode 100644 src/pages/api/admin/skills/set-skill-access.ts create mode 100644 src/utils/check-skill-slug.test.ts create mode 100644 src/utils/check-skill-slug.ts create mode 100644 src/utils/extension-noun.test.ts create mode 100644 src/utils/extension-noun.ts diff --git a/.gitignore b/.gitignore index 860307a..8d090fb 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json /public/sitemap.xml /public/robots.txt /public/sitemap-chunks/ +/.tmp \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 73af1a8..462fdb4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`). - **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://:/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`. - **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/` (`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 `__` (`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. @@ -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) - 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 `, 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-.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`). diff --git a/README.md b/README.md index 3307673..82d528e 100644 --- a/README.md +++ b/README.md @@ -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 | | 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 | +| 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 | ## Screenshots diff --git a/db/schema.ts b/db/schema.ts index 38aa26b..de66e8f 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -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", 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", fields: [ diff --git a/db/types/db.ts b/db/types/db.ts index 5311d6e..711d6a9 100644 --- a/db/types/db.ts +++ b/db/types/db.ts @@ -35,10 +35,13 @@ export const BunSQLiteTables = [ "workspace_harness_settings", "mcp_servers", "custom_tools", + "skills", "bot_mcp_servers", "workspace_mcp_servers", "bot_custom_tools", "workspace_custom_tools", + "bot_skills", + "workspace_skills", "logs", "user_presence", "push_subscriptions", @@ -1312,6 +1315,46 @@ export type BUN_SQLITE_COBBLE_CUSTOM_TOOLS = { 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 = { /** * The unique identifier of the record. @@ -1380,6 +1423,40 @@ export type BUN_SQLITE_COBBLE_WORKSPACE_CUSTOM_TOOLS = { 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 = { /** * The unique identifier of the record. @@ -1720,4 +1797,4 @@ export type BUN_SQLITE_COBBLE_APP_SETTINGS = { 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 \ No newline at end of file +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 \ No newline at end of file diff --git a/src/bots-queue/run-bot-reply.ts b/src/bots-queue/run-bot-reply.ts index 2f0c65e..ee97259 100644 --- a/src/bots-queue/run-bot-reply.ts +++ b/src/bots-queue/run-bot-reply.ts @@ -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 grabBotFileTools from "@/src/harness/tools/storage/grab-bot-file-tools"; 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 composeComputersNote from "./compose-computers-note"; import grabSessionCarryOver from "./grab-session-carry-over"; @@ -112,6 +113,14 @@ export default async function runBotReply({ 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. */ @@ -140,6 +149,7 @@ export default async function runBotReply({ in_workspace: Boolean(workspace_id), }), crew_note, + skills.note, extensions.note, `Now: ${new Date().toUTCString()}.`, ] @@ -153,7 +163,7 @@ export default async function runBotReply({ user_id, }), }), - tools: [...tools, ...file_tools, ...extensions.tools], + tools: [...tools, ...file_tools, ...extensions.tools, ...skills.tools], max_steps: AppData.BotReplyMaxToolSteps, bot_id: bot.id, workspace_id, diff --git a/src/bots-queue/run-bot-task.ts b/src/bots-queue/run-bot-task.ts index 3730b22..3d5c8a4 100644 --- a/src/bots-queue/run-bot-task.ts +++ b/src/bots-queue/run-bot-task.ts @@ -14,6 +14,7 @@ import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user- import runTextLoop from "@/src/harness/text/run-text-loop"; import openComputerTools from "@/src/harness/tools/computer/open-computer-tools"; 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 createWorkspaceTool from "@/src/harness/tools/bot/create-workspace-tool"; import updateWorkspaceTool from "@/src/harness/tools/bot/update-workspace-tool"; @@ -71,6 +72,7 @@ export default async function runBotTask({ : null; let extensions: OpenBotExtensions | undefined; + let skills: OpenBotSkills | undefined; try { const session_id = await jobSessionId({ job }); @@ -173,6 +175,11 @@ export default async function runBotTask({ signal, }); + skills = openBotSkills({ + skills: extensions.skills, + taken: [...tools, ...extensions.tools].map((tool) => tool.name), + }); + const result = await runTextLoop({ system: [ prompt, @@ -200,6 +207,7 @@ export default async function runBotTask({ .map((env) => `- ${env.key}${env.description ? `: ${env.description}` : ""}`) .join("\n")}` : "", + skills.note, extensions.note, ] .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, workspace_id, signal, diff --git a/src/components/general/extension-add-select.tsx b/src/components/general/extension-add-select.tsx index da7b9c9..7813724 100644 --- a/src/components/general/extension-add-select.tsx +++ b/src/components/general/extension-add-select.tsx @@ -1,25 +1,26 @@ import type { BotExtensionsPayload } from "@/src/types"; import { extensionOptionValue, parseExtensionOptionValue } from "@/src/utils/extension-option-value"; +import extensionNoun from "@/src/utils/extension-noun"; import InlineAddSelect from "./inline-add-select"; type Props = { id: string; addable: BotExtensionsPayload["addable"]; 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) { return ( ({ value: extensionOptionValue(item), - title: `${item.kind == "mcp" ? "MCP" : "Tool"} · ${item.name}`, + title: `${extensionNoun({ kind: item.kind })}: ${item.name}`, }))} loading={loading} onAdd={({ value }) => { diff --git a/src/components/general/extension-icon.tsx b/src/components/general/extension-icon.tsx index 40aebd6..dfd934d 100644 --- a/src/components/general/extension-icon.tsx +++ b/src/components/general/extension-icon.tsx @@ -1,9 +1,12 @@ -import { Plug, Wrench } from "lucide-react"; +import { BookOpen, Plug, Wrench } from "lucide-react"; 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"] }) { - return kind == "mcp" ? Plug : Wrench; + if (kind == "mcp") return Plug; + if (kind == "skill") return BookOpen; + + return Wrench; } diff --git a/src/data/app-data.ts b/src/data/app-data.ts index 59aa8c7..aca3e13 100644 --- a/src/data/app-data.ts +++ b/src/data/app-data.ts @@ -283,6 +283,14 @@ export const AppData = { */ CustomToolDefaultTimeoutMs: 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, * and how much of each computer's output the bot reads. diff --git a/src/data/harness-prompts.ts b/src/data/harness-prompts.ts index 2a7b143..2f7dab9 100644 --- a/src/data/harness-prompts.ts +++ b/src/data/harness-prompts.ts @@ -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` }- 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 * create, and check after you act. diff --git a/src/dict/bot-queue-dict.ts b/src/dict/bot-queue-dict.ts index ba5f193..1e98eef 100644 --- a/src/dict/bot-queue-dict.ts +++ b/src/dict/bot-queue-dict.ts @@ -86,6 +86,7 @@ export const JobStepToolLabels: Record = { assign_computer: "Changing computer access", update_memory: "Updating memory", update_instructions: "Updating instructions", + load_skill: "Reading a skill", }; /** diff --git a/src/dict/custom-tool-dict.ts b/src/dict/custom-tool-dict.ts index 0db8228..cc96548 100644 --- a/src/dict/custom-tool-dict.ts +++ b/src/dict/custom-tool-dict.ts @@ -66,6 +66,7 @@ export const ReservedToolNames = [ "delegate_task", "delete_schedule", "list_schedules", + "load_skill", "report_run_failed", "search_computers", "set_bot_workspace_instructions", diff --git a/src/dict/settings-pages-dict.ts b/src/dict/settings-pages-dict.ts index bc18b37..0d1f8a2 100644 --- a/src/dict/settings-pages-dict.ts +++ b/src/dict/settings-pages-dict.ts @@ -1,5 +1,6 @@ import { Bell, + BookOpen, Bot, CpuAi, DatabaseBackup, @@ -61,6 +62,7 @@ export const SettingsPages: SettingsPage[] = [ { title: "Computers", url: SettingsComputersPath, Icon: Monitor, 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: "Skills", url: "/admin/settings/skills", Icon: BookOpen, can_view: canManageCrew }, { title: "Users", url: "/admin/settings/users", diff --git a/src/dict/skill-dict.ts b/src/dict/skill-dict.ts new file mode 100644 index 0000000..5aec266 --- /dev/null +++ b/src/dict/skill-dict.ts @@ -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]+)*$/; diff --git a/src/functions/backend/db/extensions/assert-bot-extension-manager.ts b/src/functions/backend/db/extensions/assert-bot-extension-manager.ts index 4fb24ee..96cc69d 100644 --- a/src/functions/backend/db/extensions/assert-bot-extension-manager.ts +++ b/src/functions/backend/db/extensions/assert-bot-extension-manager.ts @@ -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 * the bot. */ diff --git a/src/functions/backend/db/extensions/attach-default-extensions.ts b/src/functions/backend/db/extensions/attach-default-extensions.ts index c7b4d83..be74990 100644 --- a/src/functions/backend/db/extensions/attach-default-extensions.ts +++ b/src/functions/backend/db/extensions/attach-default-extensions.ts @@ -5,8 +5,8 @@ type Params = { }; /** - * Gives a new bot every MCP server and tool set to "every bot". Called - * when a bot is created; it can drop any of them afterwards. + * Gives a new bot every MCP server, tool and skill set to "every bot". + * Called when a bot is created; it can drop any of them afterwards. */ export default async function attachDefaultExtensions({ bot_id }: Params) { 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`, 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)], + }); } diff --git a/src/functions/backend/db/extensions/delete-skill.ts b/src/functions/backend/db/extensions/delete-skill.ts new file mode 100644 index 0000000..93c78fc --- /dev/null +++ b/src/functions/backend/db/extensions/delete-skill.ts @@ -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; +} diff --git a/src/functions/backend/db/extensions/extension-tables.ts b/src/functions/backend/db/extensions/extension-tables.ts index c5da60b..cf631c1 100644 --- a/src/functions/backend/db/extensions/extension-tables.ts +++ b/src/functions/backend/db/extensions/extension-tables.ts @@ -17,6 +17,13 @@ export const ExtensionTables = { column: "custom_tool_id", label: "tool", }, + skill: { + table: "skills", + bot_link: "bot_skills", + workspace_link: "workspace_skills", + column: "skill_id", + label: "skill", + }, } as const; export type ExtensionKind = keyof typeof ExtensionTables; diff --git a/src/functions/backend/db/extensions/grab-bot-extensions-overview.ts b/src/functions/backend/db/extensions/grab-bot-extensions-overview.ts index 260d856..74aae39 100644 --- a/src/functions/backend/db/extensions/grab-bot-extensions-overview.ts +++ b/src/functions/backend/db/extensions/grab-bot-extensions-overview.ts @@ -11,14 +11,14 @@ type Params = { user_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. */ 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. */ export default async function grabBotExtensionsOverview({ diff --git a/src/functions/backend/db/extensions/grab-bot-extensions.ts b/src/functions/backend/db/extensions/grab-bot-extensions.ts index f7c9596..5211919 100644 --- a/src/functions/backend/db/extensions/grab-bot-extensions.ts +++ b/src/functions/backend/db/extensions/grab-bot-extensions.ts @@ -1,8 +1,13 @@ 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 parseCustomToolRow from "./parse-custom-tool-row"; import parseMcpServerRow from "./parse-mcp-server-row"; +import parseSkillRow from "./parse-skill-row"; type Params = { 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. */ 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(botExtensionsSql({ kind: "mcp", bot_id, workspace_id })), BunSQLite.sql(botExtensionsSql({ kind: "tool", bot_id, workspace_id })), + BunSQLite.sql(botExtensionsSql({ kind: "skill", bot_id, workspace_id })), ]); return { servers: (servers.payload || []).map((row) => parseMcpServerRow({ row })), tools: (tools.payload || []).map((row) => parseCustomToolRow({ row })), + skills: (skills.payload || []).map((row) => parseSkillRow({ row })), }; } diff --git a/src/functions/backend/db/extensions/grab-extensions-overview.ts b/src/functions/backend/db/extensions/grab-extensions-overview.ts index acd6863..9d843dc 100644 --- a/src/functions/backend/db/extensions/grab-extensions-overview.ts +++ b/src/functions/backend/db/extensions/grab-extensions-overview.ts @@ -1,10 +1,15 @@ 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 { McpTransports } from "@/src/dict/mcp-dict"; import type { BotExtension, BotExtensionsPayload } from "@/src/types"; import parseCustomToolRow from "./parse-custom-tool-row"; import parseMcpServerRow from "./parse-mcp-server-row"; +import parseSkillRow from "./parse-skill-row"; type Params = { /** @@ -23,7 +28,7 @@ type Params = { type Linked = 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`, * `grabWorkspaceExtensionsOverview`). */ @@ -45,8 +50,9 @@ export default async function grabExtensionsOverview({ 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 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>({ sql: mcp_query.sql.replace("%TABLE%", "mcp_servers"), values: mcp_query.values, @@ -55,12 +61,21 @@ export default async function grabExtensionsOverview({ sql: tool_query.sql.replace("%TABLE%", "custom_tools"), values: tool_query.values, }), + BunSQLite.sql>({ + sql: skill_query.sql.replace("%TABLE%", "skills"), + values: skill_query.values, + }), can_manage ? BunSQLite.sql<{ id: number; name: string }>({ sql: `SELECT id, name FROM mcp_servers ORDER BY name COLLATE NOCASE` }) : null, can_manage ? BunSQLite.sql<{ id: number; name: string }>({ sql: `SELECT id, name FROM custom_tools ORDER BY name` }) : null, + can_manage + ? BunSQLite.sql<{ id: number; name: string }>({ + sql: `SELECT id, name FROM skills ORDER BY name COLLATE NOCASE`, + }) + : null, ]); const attached: BotExtension[] = [ @@ -94,13 +109,27 @@ export default async function grabExtensionsOverview({ 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 * 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")); return { @@ -112,6 +141,9 @@ export default async function grabExtensionsOverview({ ...(all_tools?.payload || []) .filter((row) => !has("tool", Number(row.id))) .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, }; diff --git a/src/functions/backend/db/extensions/grab-skill.ts b/src/functions/backend/db/extensions/grab-skill.ts new file mode 100644 index 0000000..7806640 --- /dev/null +++ b/src/functions/backend/db/extensions/grab-skill.ts @@ -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({ + sql: `SELECT * FROM skills WHERE id = ?`, + values: [skill_id], + }) + ).payload?.[0]; + + if (!row) throw new AppError(`That skill doesn't exist.`); + + return parseSkillRow({ row }); +} diff --git a/src/functions/backend/db/extensions/grab-skills-overview.ts b/src/functions/backend/db/extensions/grab-skills-overview.ts new file mode 100644 index 0000000..dc56c97 --- /dev/null +++ b/src/functions/backend/db/extensions/grab-skills-overview.ts @@ -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 { + const role = await grabCrewRole({ user_id }); + const [rows, access, targets] = await Promise.all([ + BunSQLite.sql({ 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) } }; + }), + }; +} diff --git a/src/functions/backend/db/extensions/grab-workspace-extensions-overview.ts b/src/functions/backend/db/extensions/grab-workspace-extensions-overview.ts index 61aefdd..be19126 100644 --- a/src/functions/backend/db/extensions/grab-workspace-extensions-overview.ts +++ b/src/functions/backend/db/extensions/grab-workspace-extensions-overview.ts @@ -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. */ export default async function grabWorkspaceExtensionsOverview({ diff --git a/src/functions/backend/db/extensions/parse-skill-row.ts b/src/functions/backend/db/extensions/parse-skill-row.ts new file mode 100644 index 0000000..d0a4bd4 --- /dev/null +++ b/src/functions/backend/db/extensions/parse-skill-row.ts @@ -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, + }; +} diff --git a/src/functions/backend/db/extensions/save-skill.ts b/src/functions/backend/db/extensions/save-skill.ts new file mode 100644 index 0000000..e4a3341 --- /dev/null +++ b/src/functions/backend/db/extensions/save-skill.ts @@ -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({ + 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({ + 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); +} diff --git a/src/harness/tools/extensions/open-bot-extensions.ts b/src/harness/tools/extensions/open-bot-extensions.ts index d7b3e35..ced230c 100644 --- a/src/harness/tools/extensions/open-bot-extensions.ts +++ b/src/harness/tools/extensions/open-bot-extensions.ts @@ -1,5 +1,6 @@ import grabBotExtensions from "@/src/functions/backend/db/extensions/grab-bot-extensions"; 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 redactSecrets from "@/src/utils/redact-secrets"; 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. */ note: string; + /** + * The skills the run has, for `openBotSkills`: read from the same query + * as the MCP servers and tools. + */ + skills: SkillRecord[]; close: () => Promise; }; @@ -47,9 +53,11 @@ export default async function openBotExtensions({ taken = [], signal, }: Params): Promise { - 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 secrets = envs.filter((env) => env.secret); @@ -98,6 +106,7 @@ export default async function openBotExtensions({ return { tools: result, + skills, 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")}` : "", diff --git a/src/harness/tools/skills/open-bot-skills.test.ts b/src/harness/tools/skills/open-bot-skills.test.ts new file mode 100644 index 0000000..8027c73 --- /dev/null +++ b/src/harness/tools/skills/open-bot-skills.test.ts @@ -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 & { 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"); + }); +}); diff --git a/src/harness/tools/skills/open-bot-skills.ts b/src/harness/tools/skills/open-bot-skills.ts new file mode 100644 index 0000000..3558dfb --- /dev/null +++ b/src/harness/tools/skills/open-bot-skills.ts @@ -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 }; +} diff --git a/src/hooks/use-extensions.ts b/src/hooks/use-extensions.ts index 228688f..0404dcb 100644 --- a/src/hooks/use-extensions.ts +++ b/src/hooks/use-extensions.ts @@ -18,7 +18,7 @@ type Params = { 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 * and the chat's composer. */ diff --git a/src/pages/admin/bots/[bot_id]/(partials)/composer-tool-row.tsx b/src/pages/admin/bots/[bot_id]/(partials)/composer-tool-row.tsx index 7b05caa..f9d46a0 100644 --- a/src/pages/admin/bots/[bot_id]/(partials)/composer-tool-row.tsx +++ b/src/pages/admin/bots/[bot_id]/(partials)/composer-tool-row.tsx @@ -17,8 +17,9 @@ type Props = { }; /** - * One MCP server or tool the bot can use here, in the composer's tools - * modal. One that isn't working says so, one that's off is struck out. + * One MCP server, tool or skill the bot can use here, in the composer's + * 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) { 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.enabled ? "Off for every bot" - : item.state == "error" - ? "Not working" - : item.state == "untested" - ? "Not checked yet" - : "", + : item.kind == "skill" + ? "" + : item.state == "error" + ? "Not working" + : item.state == "untested" + ? "Not checked yet" + : "", ].filter(Boolean); return ( diff --git a/src/pages/admin/bots/[bot_id]/(partials)/composer-tools-modal.tsx b/src/pages/admin/bots/[bot_id]/(partials)/composer-tools-modal.tsx index 5d0fc46..d2950c9 100644 --- a/src/pages/admin/bots/[bot_id]/(partials)/composer-tools-modal.tsx +++ b/src/pages/admin/bots/[bot_id]/(partials)/composer-tools-modal.tsx @@ -18,19 +18,19 @@ type Props = { * The extension being changed, as `kind:id`. */ busy?: string; - onAdd: (params: { kind: "mcp" | "tool"; id: number }) => Promise; + onAdd: (params: { kind: "mcp" | "tool" | "skill"; id: number }) => Promise; onRemove: (params: { - kind: "mcp" | "tool"; + kind: "mcp" | "tool" | "skill"; id: number; name: string; }) => Promise; }; /** - * The composer's tools button: how many MCP servers and tools the bot can - * use here, opening them in a modal rather than filling the composer with - * chips. Super admins and admins remove any it was given (even one every - * bot started with) and add more; a workspace's own stay. + * The composer's tools button: how many MCP servers, tools and skills the + * bot can use here, opening them in a modal rather than filling the + * composer with chips. Super admins and admins remove any it was given + * (even one every bot started with) and add more; a workspace's own stay. */ export default function ComposerToolsModal({ bot_name, @@ -75,8 +75,8 @@ export default function ComposerToolsModal({ {count - ? "MCP servers and tools 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."} + ? "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, tool or skill below, or make one under Settings."} diff --git a/src/pages/admin/bots/[bot_id]/(partials)/composer-tools.tsx b/src/pages/admin/bots/[bot_id]/(partials)/composer-tools.tsx index 26cbe2c..bc03d52 100644 --- a/src/pages/admin/bots/[bot_id]/(partials)/composer-tools.tsx +++ b/src/pages/admin/bots/[bot_id]/(partials)/composer-tools.tsx @@ -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 * composer), then the computer it's on, opening it in the viewer. */ diff --git a/src/pages/admin/bots/[bot_id]/(partials)/composer.tsx b/src/pages/admin/bots/[bot_id]/(partials)/composer.tsx index 5980eaa..f6ae48e 100644 --- a/src/pages/admin/bots/[bot_id]/(partials)/composer.tsx +++ b/src/pages/admin/bots/[bot_id]/(partials)/composer.tsx @@ -51,7 +51,7 @@ type Props = { */ 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; /** diff --git a/src/pages/admin/settings/(partials)/extension-access-modal.tsx b/src/pages/admin/settings/(partials)/extension-access-modal.tsx index 5ef690d..b1db139 100644 --- a/src/pages/admin/settings/(partials)/extension-access-modal.tsx +++ b/src/pages/admin/settings/(partials)/extension-access-modal.tsx @@ -18,13 +18,13 @@ type Props = { /** * 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; 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 * one; each can still drop it. A workspace gives it to every bot in * it, there. diff --git a/src/pages/admin/settings/(partials)/extension-row.tsx b/src/pages/admin/settings/(partials)/extension-row.tsx index 3dc22a2..0d59e26 100644 --- a/src/pages/admin/settings/(partials)/extension-row.tsx +++ b/src/pages/admin/settings/(partials)/extension-row.tsx @@ -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) { const state = !item.enabled ? "Off for every bot" - : item.state == "error" - ? "Not working" - : item.state == "untested" - ? "Not checked yet" - : ""; + : item.kind == "skill" + ? "" + : item.state == "error" + ? "Not working" + : item.state == "untested" + ? "Not checked yet" + : ""; return ( } title={item.name} - href={item.kind == "mcp" ? "/admin/settings/mcps" : "/admin/settings/tools"} + href={ExtensionHrefs[item.kind]} subtitle={[item.detail, state].filter(Boolean).join(" · ")} > {onRemove ? ( diff --git a/src/pages/admin/settings/(partials)/extensions-group.tsx b/src/pages/admin/settings/(partials)/extensions-group.tsx index fa70d1c..4b90465 100644 --- a/src/pages/admin/settings/(partials)/extensions-group.tsx +++ b/src/pages/admin/settings/(partials)/extensions-group.tsx @@ -1,6 +1,6 @@ import Button from "@moduletrace/twui/layout/Button"; 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 useExtensions from "@/src/hooks/use-extensions"; 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 - * picker to add more. On the bot and workspace settings pages. + * The MCP servers, tools and skills a bot or a workspace has, removable, + * and a picker to add more. On the bot and workspace settings pages. */ export default function ExtensionsGroup({ owner, owner_name, description, initial }: Props) { const extensions = useExtensions({ owner, owner_name, initial }); const id = "bot_id" in owner ? `bot-${owner.bot_id}` : `workspace-${owner.workspace_id}`; return ( - - + + {extensions.attached.map((item) => ( Tools + diff --git a/src/pages/admin/settings/bots/[bot_id]/(sections)/bot-settings-section.tsx b/src/pages/admin/settings/bots/[bot_id]/(sections)/bot-settings-section.tsx index b5681a7..31cd9bc 100644 --- a/src/pages/admin/settings/bots/[bot_id]/(sections)/bot-settings-section.tsx +++ b/src/pages/admin/settings/bots/[bot_id]/(sections)/bot-settings-section.tsx @@ -75,7 +75,7 @@ export default function BotSettingsSection() { diff --git a/src/pages/admin/settings/skills/(partials)/skill-modal.tsx b/src/pages/admin/settings/skills/(partials)/skill-modal.tsx new file mode 100644 index 0000000..5c61994 --- /dev/null +++ b/src/pages/admin/settings/skills/(partials)/skill-modal.tsx @@ -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(); + + 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({ + 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 ( + !next && onCancel()} + title={skill ? `Edit ${skill.name}` : "Add a skill"} + description={"Bots get it once it's attached to them."} + > +
+ setName(e.target.value)} + /> + setSlug(e.target.value)} + /> +