Add the harness, envs, per-bot models and chat attachments
- Harness: providers, model catalog synced hourly, automatic free picks, cooldowns, tool loop with context trimming, MCP computer tools - Bots queue runs replies and tasks through the harness; jobs wait (attempt refunded) while every model is resting - Envs: Settings > Environment, scoped general/workspace/computer, written to computers and redacted from tool output - Models: Settings > Models, per-harness provider select defaulting to OpenRouter, and a models modal on each bot's chat (the coordinator's edits the defaults) - Chat: thinking row while a reply runs, file attachments (upload, paste, drop) that replies read as images, audio and document text - Fix media uploads failing after a user's first file, and private files being served from the public media path Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -75,7 +75,7 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - a read-onl
|
||||
|
||||
### Cron
|
||||
|
||||
`src/cron/index.ts` is the single registration point called from `server.ts`. Tasks live in one folder each (`src/cron/<name>/index.ts`) as default-exported async functions, scheduled with in-process `Bun.cron` via `registerCronJob({ title, schedule, task, run_on_start })` (`src/cron/register-cron-job.ts`), which logs before each run and catches/logs errors. Schedules are cron expressions or nicknames (`@hourly`) interpreted in UTC; runs never overlap. Gated in production by `MAIN_SERVER`. The bundled `heartbeat` task doubles as a DB-liveness probe.
|
||||
`src/cron/index.ts` is the single registration point called from `server.ts`. Tasks live in one folder each (`src/cron/<name>/index.ts`) as default-exported async functions, scheduled with in-process `Bun.cron` via `registerCronJob({ title, schedule, task, run_on_start })` (`src/cron/register-cron-job.ts`), which logs before each run and catches/logs errors. Schedules are cron expressions or nicknames (`@hourly`) interpreted in UTC; runs never overlap. Gated in production by `MAIN_SERVER`. The bundled `heartbeat` task doubles as a DB-liveness probe; `sync-harness-models` refreshes the model catalog hourly.
|
||||
|
||||
### Admin CRUD API
|
||||
|
||||
@@ -85,17 +85,41 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - a read-onl
|
||||
|
||||
- **Tables:** `bots` (per user; `kind` worker|coordinator, `hue`, `role`, `face`, `status_line`, `system_prompt` (the bot's core instructions, markdown), `unread`), `bot_messages`, `bot_tasks`, `bot_task_steps`. Enums in `src/dict/bot-dict.ts`.
|
||||
- **Coordinator:** every user has exactly one `kind: coordinator` bot that coordinates the crew and delegates to the workers. It is created with the account (signup, `bun run seed`) and, as a fallback, by `/admin/bots`, all via `ensureCoordinatorBot` (`src/functions/backend/db/bots/ensure-coordinator-bot.ts`, idempotent). Name in `CoordinatorBotName` (`src/dict/bot-dict.ts`). Its default prompt is `CoordinatorSystemPrompt` (`src/data/coordinator-system-prompt.ts`): attached at creation, and backfilled onto a coordinator whose prompt is empty, never over an edited one.
|
||||
- **Routes:** `/admin/bots` ensures the coordinator and redirects to its chat (`/admin/bots/1` on a fresh install); `/admin/bots/[bot_id]` renders conversation + working-on panel. Both render in the full-bleed `src/layouts/bots/` layout (rail of conversations + user popover), which `__root.tsx` uses for every `/admin/*` page.
|
||||
- **Server:** `src/functions/backend/db/bots/` (`grab-bots`, `grab-bot-messages`, `grab-active-bot-task`, `grab-user-bot`, `mark-bot-read`, `insert-user-message`). `api/admin/bots/send-message.ts` posts a user message to a bot owned by the session user (zod: `src/schemas/bot-schema.ts`).
|
||||
- **Routes:** `/admin/bots` ensures the coordinator and redirects to its chat (`/admin/bots/1` on a fresh install); `/admin/bots/[bot_id]` renders conversation + working-on panel; `/admin/bots/[bot_id]/models` is a worker bot's own model settings (the coordinator's redirects to its chat). Both render in the full-bleed `src/layouts/bots/` layout (rail of conversations + user popover), which `__root.tsx` uses for every `/admin/*` page.
|
||||
- **Server:** `src/functions/backend/db/bots/` (`grab-bots`, `grab-bot-messages`, `grab-active-bot-task`, `grab-user-bot`, `mark-bot-read`, `insert-user-message`). `api/admin/bots/send-message.ts` posts a user message to a bot owned by the session user (zod: `src/schemas/bot-schema.ts`), queues the bot's reply (`enqueueBotReply`) and wakes the queue (`src/bots-queue/wake-bots-queue.ts`; never import `src/bots-queue/index.ts` from a page or API route, it pulls the bunext runtime into the page bundles). `upload-attachment.ts` stores one file for the next message (private `media` row, paradigm `bot-attachment`, type from `attachmentFileKind`, documents get `text_content`, limits in `AppData.BotAttachment*`) and `send-message` takes their `media_ids` (checked to be the user's own by `grabAttachableMedia`), linked in `bot_message_media`; messages carry `attachments` (`BotMessageWithAttachments`). `mark-read.ts` clears unread for a message that arrives in the open conversation; `save-harness-settings.ts` saves a worker bot's models.
|
||||
- **Attachments and thinking:** the composer uploads files as they're picked, pasted or dropped (`use-composer-attachments`), shows them as chips, and sends once none is still uploading; the thread shows images as thumbnails and other files as chips. Replies read the files of the last `BotReplyAttachmentMessages` messages with any (`load-attachment-parts.ts`: images and audio as parts, documents as their text up to `BotAttachmentMaxTextChars`, videos as a note); older ones are only named. Tasks don't get the files yet. While a reply runs (face `working`, status `BotReplyJobTitle`) or waits for a model (`BotWaitingStatusLine`), `BotThinkingRow` sits under the last message. Live notices come from the process that ran the job, so two dev servers on one database split them.
|
||||
- **Avatars:** `BotStone` (`src/components/general/bot-stone.tsx`) renders the stone inline from `src/data/stone-geometry.ts`, hue as a fill token, face (`idle|working|done|asleep|needs-you`) drawn as eyes. The coordinator is hue-less (ink body, paper eyes; inverts in dark). Never put a stone in a circle/badge or add outline/shadow. Wordmark is vector (`Wordmark`, `src/data/wordmark-paths.ts`), never live text.
|
||||
- **Copy helpers:** `src/utils/crew-status.ts` (`3 working · 1 needs you`, "Three bots are working. One needs you."), `src/utils/relative-time.ts` (relative timestamps only).
|
||||
- **Prompt and memory (self-learning):** a bot has `system_prompt` (core instructions) and a separate `memory` (markdown, what it must always remember). The bot rewrites both as it adapts to the user (`update-bot-system-prompt`, `update-bot-memory` in `src/functions/backend/db/bots/`; whole-text replace, capped at `AppData.PromptMaxLength`). `grab-bot-prompt` builds the full prompt via `composeBotPrompt` (`src/utils/compose-bot-prompt.ts`): core instructions, then each non-archived workspace's `system_prompt` under `# Workspace: <name>`, then `# Memory`.
|
||||
- **Queue:** long-running work (typically on a computer) goes through `bots_queue`, a bun-sqlite `isQueue` table worked with the native `BunSQLite.queue` API (`status` pending|processing|completed|failed, `attempts`, `available_at`, `locked_at`/`locked_by`, `last_error`; options in `AppData.BotQueue*`). Not every message needs a job: the bot replies right away that it's on it and `enqueueBotJob` queues the brief (`instructions`, `params`, optional `message_id`/`computer_id`, the computer checked against the bot's access; a `bot_tasks` row is created for the working-on panel unless `task_id` is given). DB helpers in `src/functions/backend/db/bots-queue/`: `claimBotJobs`, `updateBotJob` (renews the lock, saves `progress`/`checkpoint`; false once lost or cancelled), `completeBotJob` (result message, task done, face done/needs-you), `failBotJob` (backoff retry, or `fatal`; out of attempts posts a needs-you message via `notifyBotJobFailed`), `cancelBotJob` (failed with `BotJobCancelledError`, `src/dict/bot-queue-dict.ts`), `releaseWorkerBotJobs`.
|
||||
- **Queue handler:** `src/bots-queue/index.ts`, started from `server.ts` after cron and gated the same way. It releases its own cut-off jobs on startup (stable worker `AppData.BotQueueWorker`), then polls every `BotQueuePollMs`: reports jobs bun-sqlite failed on a lock timeout (`report-lost-bot-jobs`), claims up to `BotQueueConcurrency`, and runs each with `processBotJob` (heartbeat at a quarter of the lock timeout, abort on cancel/loss, one job per computer via `createKeyLock`, face `working` once it starts, outcome pushed live). The actual work is `run-bot-job.ts` (`BotJobRunner`, context in `types.ts`): the model layer plugs in there; until then it throws `BotJobFatalError`. Throw `BotJobFatalError` for failures retrying can't fix. The `prune-bots-queue` cron deletes finished jobs after `BotQueueRetentionDays`.
|
||||
- **Live updates:** server push only. `sendToUser` (`src/websocket/(functions)/send-to-user.ts`) sends to one user's sockets; the queue pushes `server:bot-message` and `server:bot-updated`. The client doesn't listen yet.
|
||||
- **Queue:** every bot job goes through `bots_queue`, with `kind` (`BotJobKinds`): `reply` (answers the user in chat, no task row) or `task` (long-running work, typically on a computer). Long-running work goes through `bots_queue`, a bun-sqlite `isQueue` table worked with the native `BunSQLite.queue` API (`status` pending|processing|completed|failed, `attempts`, `available_at`, `locked_at`/`locked_by`, `last_error`; options in `AppData.BotQueue*`). Not every message needs a job: the bot replies right away that it's on it and `enqueueBotJob` queues the brief (`instructions`, `params`, optional `message_id`/`computer_id`, the computer checked against the bot's access; a `bot_tasks` row is created for the working-on panel unless `task_id` is given). DB helpers in `src/functions/backend/db/bots-queue/`: `claimBotJobs`, `updateBotJob` (renews the lock, saves `progress`/`checkpoint`; false once lost or cancelled), `completeBotJob` (result message, task done, face done/needs-you), `failBotJob` (backoff retry, or `fatal`; out of attempts posts a needs-you message via `notifyBotJobFailed`), `cancelBotJob` (failed with `BotJobCancelledError`, `src/dict/bot-queue-dict.ts`), `releaseWorkerBotJobs`.
|
||||
- **Queue handler:** `src/bots-queue/index.ts`, started from `server.ts` after cron and gated the same way. It releases its own cut-off jobs on startup (stable worker `AppData.BotQueueWorker`), then polls every `BotQueuePollMs`: reports jobs bun-sqlite failed on a lock timeout (`report-lost-bot-jobs`), claims per kind (`claimBotJobs` mirrors bun-sqlite's atomic claim plus `kind`, since `queue.claim` has no filter) up to `BotQueueReplyConcurrency` replies and `BotQueueConcurrency` tasks on separate slots, and runs each with `processBotJob` (heartbeat at a quarter of the lock timeout, abort on cancel/loss, one task per computer and one reply per bot via `createKeyLock`, face `working` once it starts, outcome pushed live). The actual work is `run-bot-job.ts` (`BotJobRunner`, context in `types.ts`), which dispatches to `run-bot-reply.ts` and `run-bot-task.ts`. Throw `BotJobFatalError` for failures retrying can't fix. When every model is resting (`HarnessError.resting`, nothing was tried) the job is put back with `deferBotJob` until the first comes back, its attempt refunded and the bot's face `asleep`; other retryable harness errors retry no sooner than their `retry_at`. The `prune-bots-queue` cron deletes finished jobs after `BotQueueRetentionDays`.
|
||||
- **Live updates:** server push only. `sendToUser` (`src/websocket/(functions)/send-to-user.ts`) sends to one user's sockets; `pushBotNotice` sends `server:bot-message` (the message row, user messages too, for other tabs) and `server:bot-updated` (`BotUpdatedData`: face, status line, active task). The bots layout opens one socket (twui `useWebSocket`); components listen with `useServerEvent` (`src/hooks/`), and `useLiveBots` keeps the rail and header current. The conversation dedupes messages by id; the working-on panel takes the pushed task.
|
||||
- **Workspaces:** `workspaces` (per user, `name`, `description`, `system_prompt` shared by every bot in it, `archived`), with bots assigned through `workspace_bots` and computers through `workspace_computers`. Helpers in `src/functions/backend/db/workspaces/` (`create-workspace`, `update-workspace`, `add/remove-bot-*`, `add/remove-computer-*`), all ownership-checked against the session user.
|
||||
- **Computers:** a computer is an AIO sandbox in its own docker container on the `AppData.DockerNetworkName` network. `containers` records every docker container the app knows about except the main app container (`name` doubles as the hostname, plus `docker_id`, `image`, `network`, `port`, `status`, `metadata`). `computers` (per user, `kind` main|sandbox, `container_id`) and `bot_computers` (direct access). Each user has one main computer, recorded with the account (signup, `bun run seed`) and as a fallback by `/admin/bots` via `ensureMainComputer` (`src/functions/backend/db/computers/ensure-main-computer.ts`, idempotent), which also records its container (`AppData.MainComputerContainerName`, created by the installer) and grants the coordinator access. That one grant can't be removed (`isLockedComputerAccess`, enforced in `remove-bot-from-computer` and the `bot_computers` CRUD handler); every other grant can. A bot's effective computers (`grab-bot-computers`) are its direct grants plus every computer assigned to a non-archived workspace it's in. Enums in `src/dict/computer-dict.ts`.
|
||||
- **Not built yet:** add-bot flow, attachments, routines, help, client handling of live WebSocket updates, the model layer (plugs into `src/bots-queue/run-bot-job.ts`).
|
||||
- **Not built yet:** add-bot flow, routines, help, task steps (the working-on panel shows the task title; nothing writes `bot_task_steps` yet), telling the coordinator when a delegated task finishes.
|
||||
|
||||
### Envs
|
||||
|
||||
Envs are set in Settings, under Environment (super admin only), not in `.env`. Table `envs` (private): `key`, encrypted `value`, `description`, `paradigm` (`EnvParadigms`: general, workspace, computer) with `workspace_id`/`computer_id`, `secret`, `expose_to_computers`. One per key and scope, enforced in `saveEnv` (`src/functions/backend/db/envs/`), since SQLite unique constraints treat NULL ids as distinct. Decrypted rows are cached per process (`grabAllEnvs`; `saveEnv`/`deleteEnv` clear it).
|
||||
|
||||
- **App settings** (`EnvDict` in `src/dict/env-dict.ts`: `MAIN_COMPUTER_URL` and every provider preset's `key_env`) are what the app reads, through `grabEnv({ key })`: the general value, else `process.env`, so an existing `.env` still works. General only, never on computers. `ProcessOnlyEnvKeys` (`ENCRYPTION_KEY`/`SALT`, `PORT`, `DATA_DIR`, `MAIN_SERVER`) can't be saved: they encrypt the table or are read before the database opens.
|
||||
- **Bot and computer envs** are any other key. `grabComputerEnvs` resolves one run's set (`resolveScopedEnvs`): computer beats workspace beats general; workspaces are the non-archived ones the computer shares with the run's bot, oldest winning a tie. `openComputerTools` writes them to `~/.cobble/env` and `env.json` on the computer (owner only, rewritten every run) over the sandbox's REST API (`writeComputerEnv`); the sandbox's shells read no profile, so `withComputerEnv` makes `sandbox_execute_bash` source the file and `sandbox_execute_code` (Python, JavaScript) load the JSON, invisibly to the model. Bots get keys and descriptions in the task prompt, never values, and every computer tool result is passed through `redactSecrets` (each secret value shown as `[KEY]`).
|
||||
- **API:** `api/admin/envs/` (`save-env`, `delete-env`), super admin only, logged by key, each returning `grabEnvsOverview` (secret values left out; a secret is only replaced, never read back).
|
||||
|
||||
### Harness (the model layer)
|
||||
|
||||
`src/harness/` is the brain: every model call goes through it. Install-wide, managed by super admins at `/admin/settings/models`.
|
||||
|
||||
- **Harnesses** (`Harnesses` in `src/dict/harness-dict.ts`): `text` (chats, plans, calls tools, drives computers; always on), `vision` ("Media reading": images/scans/screenshots for a text model that can't see), `transcription` (speech to text), `speech` (text to speech), `image` (image generation). Each has one `harness_settings` row (no row = automatic, on, with fallback): a `provider_id` (a `harness_providers` row the harness is limited to, for its pick and its automatic picks; null for any provider; with no row, the default provider, OpenRouter, `grabDefaultHarnessProviderId`), a pinned `model_id` (a `harness_models` row from that provider, not a foreign key) or null for automatic, `fallback` (also try the automatic picks), `enabled`, `options` (`voice`).
|
||||
- **Providers** (`HarnessProviderKinds` in `src/dict/harness-provider-dict.ts`): OpenRouter (default, created by `ensureDefaultHarnessProvider`), OpenAI, Anthropic, Gemini, DeepSeek, Groq, Mistral, xAI, Ollama (local, its models count as free), any OpenAI-compatible server. Rows in `harness_providers` (a private table: the key is encrypted with `src/utils/crypt.ts`, never sent to the client; falls back to the preset's env, e.g. `OPENROUTER_API_KEY`, via `grabEnv`). One adapter today, `src/harness/providers/openai-compatible/` (`chat`, `transcribe`, `speak`, `generateImage`, `listModels`), chosen in `grab-harness-adapter.ts`; a provider needing a native API gets its own adapter there (`HarnessAdapter` in `src/harness/types.ts`).
|
||||
- **Catalog:** `harness_models` per provider (modalities, tools support, voices, Artificial Analysis scores, `free`, `available`). The `sync-harness-models` cron (hourly, on start) refreshes every enabled provider (`syncAllHarnessModels`); unlisted models are kept but marked unavailable. OpenRouter lists every modality with capabilities; other providers list ids, so capabilities are guessed (`guess-model-modalities`). Free on OpenRouter means `:free` or `openrouter/free`, never zero token prices.
|
||||
- **Automatic picks** (`rank-auto-models`): free models that can do the job (on the setting's provider when it has one; a provider without free models needs a picked model) (`model-serves-harness`: text needs tools), by vendor `AutoModelVendorPreference` (DeepSeek, Qwen, NVIDIA, Liquid), then other vendors that look competent (`AppData.HarnessAutoMin*`), then routers; within a vendor by intelligence index, then full-size before small, newest first. Transcription puts real speech-to-text models first.
|
||||
- **Calling:** `resolveHarnessCandidates` (pinned, then automatic) -> `withCandidates` tries them in order with process-wide cooldowns (`resolve/harness-cooldowns.ts`): rate limits and outages rest the model (until `Retry-After` when given), 401/402 rest the whole provider, 403/404 rest the model for an hour. Errors are `HarnessError` (`code` provider|unavailable|aborted, `retryable`), classified in `to-harness-error.ts`. Entry points: `text/generate-text.ts` (one turn), `text/run-text-loop.ts` (tool loop, sticks to one model, wraps up after `HarnessMaxToolSteps`, trims the oldest tool output before each turn to fit the model's context, `fit-transcript-to-context.ts`), `media/read-image.ts`, `media/read-media.ts` (documents locally via `fileToText`, images and audio through their harnesses), `audio/transcribe-audio.ts`, `audio/speak-text.ts`, `image/generate-image.ts`. A text model that can't take an image or audio part gets it read by the vision/transcription harness first (`prepare-messages-for-model.ts`).
|
||||
- **Per-bot models:** 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 model button (the text model it runs on) 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 button is read only 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: ...`. 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).
|
||||
- **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` (container `metadata.mcp_url`, else 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`), leaves out `ComputerMcpExcludedTools`, and sets the computer's envs (see Envs).
|
||||
- **Replies and tasks:** `run-bot-reply.ts` answers from the last `BotReplyHistoryMessages` messages (`botMessagesToHarness`) with `ChatReplyNote` and the chat tools, at most `BotReplyMaxToolSteps` steps, and finishes silently if the bot already had the last word (`enqueueBotReply` reuses a pending reply, so a burst of messages gets one). `run-bot-task.ts` runs the brief (`compose-job-brief.ts`) with `TaskRunNote`, the bot tools and the job's computer tools, closing the MCP connection after. Retryable harness errors go back to the queue's backoff, the rest are `BotJobFatalError`.
|
||||
- **API:** `api/admin/harness/` (`save-provider` also syncs, `sync-provider`, `delete-provider`, `save-settings`), super admin only, each returning the fresh `grabHarnessOverview()` page data.
|
||||
|
||||
## Admin Route Hierarchy
|
||||
|
||||
@@ -105,7 +129,7 @@ Live routes under `/admin` (see `AdminSections`): `/admin/bots` and `/admin/sett
|
||||
- `/admin/bots` (live) - the Cobble chat shell, plus `[bot_id]/`
|
||||
- `/admin/logs` - audit log (super admin; the "Activity" item in the bots popover is disabled while this is closed)
|
||||
- `/admin/backups` - super admin only: create/list/download database snapshots in `db/.backups`, plus `restore/` (restore a listed backup, or upload a SQLite database file which is saved to `.backups` then restored). Shells out to the `bun-sqlite` CLI (`backup`, and `import <file>` since `restore` is interactive) via `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), `profile/` (current user profile) and `change-password/` (set/change own password), 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. 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), `profile/` (current user profile), `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. 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 shows the crew (`(partials)/rail-crew-list.tsx`, needs `bots` in page props), 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`).
|
||||
|
||||
@@ -128,7 +152,7 @@ Server functions use `index.server.ts` alongside `index.tsx` (e.g. `single-docum
|
||||
|
||||
## Shared Code Organization
|
||||
|
||||
- **`src/hooks/`** - Shared React hooks: `use-admin-crud-get`, `use-fetch-get`, `use-form-init`, `use-local-user`, `use-users-get`
|
||||
- **`src/hooks/`** - Shared React hooks: `use-admin-crud-get`, `use-fetch-get`, `use-form-init`, `use-local-user`, `use-users-get`, `use-server-event`, `use-live-bots`
|
||||
- **`src/functions/`** - Shared functions split by target:
|
||||
- `src/functions/backend/` - Server-only: auth (`backend/auth/`), DB queries (`backend/db/` incl. `bots/`), email client + templates (`email/`), media upload, `logger.ts` (audit logging)
|
||||
- `src/functions/frontend/` - Client-safe: CRUD handlers, fetch handlers, media upload/delete handlers
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"": {
|
||||
"name": "horandez-and-detroit",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.32.1",
|
||||
"@moduletrace/bun-sqlite": "^1.3.0",
|
||||
"@moduletrace/bunext": "^1.3.3",
|
||||
"@moduletrace/twui": "^0.1.1",
|
||||
@@ -13,7 +14,7 @@
|
||||
"gray-matter": "^4.0.3",
|
||||
"html-to-react": "^1.7.0",
|
||||
"lodash": "^4.18.1",
|
||||
"lucide-react": "^1.31.0",
|
||||
"lucide-react": "^1.59.0",
|
||||
"mammoth": "^1.12.1",
|
||||
"mdx": "^0.3.1",
|
||||
"next-mdx-remote": "^6.0.0",
|
||||
@@ -123,6 +124,8 @@
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="],
|
||||
|
||||
"@hono/node-server": ["@hono/[email protected]", "", { "peerDependencies": { "hono": "^4 || ^5.0.0-0" } }, "sha512-MgP/I06GdvsilDspMk/qmRzpk8LOUStP6jFAbSRi40W09DhoyUn+Mj7eyKICPqeYKybOjVFLVIAQzvu1bfIa7Q=="],
|
||||
|
||||
"@img/colour": ["@img/[email protected]", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
|
||||
|
||||
"@img/sharp-darwin-arm64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.2" }, "os": "darwin", "cpu": "arm64" }, "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg=="],
|
||||
@@ -225,6 +228,8 @@
|
||||
|
||||
"@mixmark-io/domino": ["@mixmark-io/[email protected]", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
|
||||
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/[email protected]", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw=="],
|
||||
|
||||
"@moduletrace/bun-sqlite": ["@moduletrace/[email protected]", "https://git.tben.me/api/packages/Moduletrace/npm/%40moduletrace%2Fbun-sqlite/-/1.3.0/bun-sqlite-1.3.0.tgz", { "dependencies": { "@inquirer/prompts": "^8.3.0", "chalk": "^5.6.2", "commander": "^14.0.3", "inquirer": "^13.3.2", "lodash": "^4.17.23", "sqlite-vec": "^0.1.7-alpha.2" }, "peerDependencies": { "typescript": "^5" }, "bin": { "bun-sqlite": "dist/commands/index.js" } }, "sha512-5xZg36vvT7uIpJSzeXW+debQ19Q7m99aUt2OvTEpWZvlnp0ePL9J6/1ibq2IPBmLpRZdif5Tq76ipiwwSN63jg=="],
|
||||
|
||||
"@moduletrace/bunext": ["@moduletrace/[email protected]", "https://git.tben.me/api/packages/Moduletrace/npm/%40moduletrace%2Fbunext/-/1.3.3/bunext-1.3.3.tgz", { "dependencies": { "@tailwindcss/postcss": "^4.3.3", "@types/bun": "1.3.14", "@types/node": "^24.10.0", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "bun-plugin-tailwind": "^0.1.2", "chalk": "^5.6.2", "chokidar": "^5.0.0", "commander": "^14.0.2", "esbuild": "^0.28.2", "lightningcss-wasm": "^1.32.0", "lodash": "^4.18.1", "ora": "^9.0.0", "postcss": "^8.5.30", "react": "^19.3.0", "react-dom": "^19.3.0", "tailwindcss": "^4.3.3", "typescript": "^5.0.0", "uuid": "^14.0.2" }, "bin": { "bunext": "dist/commands/index.js" } }, "sha512-bbUtQaigsDPUQf6vtDOSCo8GYR7Gnw2ny1Fi3JtUnKsYsYQEcEadzmqntAgf7rLcKc12dadDUgFLxru+jiA92g=="],
|
||||
@@ -379,6 +384,8 @@
|
||||
|
||||
"@xmldom/xmldom": ["@xmldom/[email protected]", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="],
|
||||
|
||||
"accepts": ["[email protected]", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||
|
||||
"ace-builds": ["[email protected]", "", {}, "sha512-PFNMSYqFdEUkul2Ntud0HvA09AgY+F1ag0UYdpMH60wNI/qOA8cB8tlTgoALMEwIdUPJK2CjrIQ7OnbiSS/ugQ=="],
|
||||
|
||||
"acorn": ["[email protected]", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
|
||||
@@ -387,6 +394,10 @@
|
||||
|
||||
"adler-32": ["[email protected]", "", {}, "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A=="],
|
||||
|
||||
"ajv": ["[email protected]", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
|
||||
|
||||
"ajv-formats": ["[email protected]", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
|
||||
|
||||
"ansi-regex": ["[email protected]", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||
|
||||
"argparse": ["[email protected]", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
||||
@@ -401,6 +412,8 @@
|
||||
|
||||
"bluebird": ["[email protected]", "", {}, "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA=="],
|
||||
|
||||
"body-parser": ["[email protected]", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="],
|
||||
|
||||
"body-scroll-lock": ["[email protected]", "", {}, "sha512-Yi1Xaml0EvNA0OYWxXiYNqY24AfWkbA6w5vxE7GWxtKfzIbZM+Qw+aSmkgsbWzbHiy/RCSkUZBplVxTA+E4jJg=="],
|
||||
|
||||
"bun": ["[email protected]", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.14", "@oven/bun-darwin-x64": "1.3.14", "@oven/bun-darwin-x64-baseline": "1.3.14", "@oven/bun-freebsd-aarch64": "1.3.14", "@oven/bun-freebsd-x64": "1.3.14", "@oven/bun-linux-aarch64": "1.3.14", "@oven/bun-linux-aarch64-android": "1.3.14", "@oven/bun-linux-aarch64-musl": "1.3.14", "@oven/bun-linux-x64": "1.3.14", "@oven/bun-linux-x64-android": "1.3.14", "@oven/bun-linux-x64-baseline": "1.3.14", "@oven/bun-linux-x64-musl": "1.3.14", "@oven/bun-linux-x64-musl-baseline": "1.3.14", "@oven/bun-windows-aarch64": "1.3.14", "@oven/bun-windows-x64": "1.3.14", "@oven/bun-windows-x64-baseline": "1.3.14" }, "os": [ "!aix", "!sunos", "!openbsd", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-aB6GVd42x1Y5ie1K16SF+oLGtgSkwX9hgoDdIW88pjvfTccU8F1vfpoOt34QLv0dZ1v3XimtaxPlZUG81Gx9Zg=="],
|
||||
@@ -409,6 +422,12 @@
|
||||
|
||||
"bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||
|
||||
"bytes": ["[email protected]", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||
|
||||
"call-bind-apply-helpers": ["[email protected]", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"call-bound": ["[email protected]", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||
|
||||
"camelcase": ["[email protected]", "", {}, "sha512-DLIsRzJVBQu72meAKPkWQOLcujdXT32hwdfnkI1frSiSRMK1MofjKHf+MEx0SB6fjEFXL8fBDv1dKymBlOp4Qw=="],
|
||||
|
||||
"camelcase-keys": ["[email protected]", "", { "dependencies": { "camelcase": "^2.0.0", "map-obj": "^1.0.0" } }, "sha512-bA/Z/DERHKqoEOrp+qeGKw1QlvEQkGZSc0XaY6VnTxZr+Kv1G5zFwttpjv8qxZ/sBPT4nthwZaAcsAZTJlSKXQ=="],
|
||||
@@ -449,10 +468,22 @@
|
||||
|
||||
"commander": ["[email protected]", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||
|
||||
"content-disposition": ["[email protected]", "", {}, "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog=="],
|
||||
|
||||
"content-type": ["[email protected]", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
||||
|
||||
"cookie": ["[email protected]", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"cookie-signature": ["[email protected]", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||
|
||||
"core-util-is": ["[email protected]", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
|
||||
|
||||
"cors": ["[email protected]", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
|
||||
|
||||
"crc-32": ["[email protected]", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="],
|
||||
|
||||
"cross-spawn": ["[email protected]", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"css-color-keywords": ["[email protected]", "", {}, "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg=="],
|
||||
|
||||
"css-to-react-native": ["[email protected]", "", { "dependencies": { "camelize": "^1.0.0", "css-color-keywords": "^1.0.0", "postcss-value-parser": "^4.0.2" } }, "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ=="],
|
||||
@@ -467,6 +498,8 @@
|
||||
|
||||
"decode-named-character-reference": ["[email protected]", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
|
||||
|
||||
"depd": ["[email protected]", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
||||
|
||||
"dequal": ["[email protected]", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||
|
||||
"detect-libc": ["[email protected]", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
@@ -485,20 +518,32 @@
|
||||
|
||||
"duck": ["[email protected]", "", { "dependencies": { "underscore": "^1.13.1" } }, "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg=="],
|
||||
|
||||
"dunder-proto": ["[email protected]", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"ee-first": ["[email protected]", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"encodeurl": ["[email protected]", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
||||
|
||||
"enhanced-resolve": ["[email protected]", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="],
|
||||
|
||||
"entities": ["[email protected]", "", {}, "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA=="],
|
||||
|
||||
"error-ex": ["[email protected]", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="],
|
||||
|
||||
"es-define-property": ["[email protected]", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["[email protected]", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-object-atoms": ["[email protected]", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="],
|
||||
|
||||
"esast-util-from-estree": ["[email protected]", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "unist-util-position-from-estree": "^2.0.0" } }, "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ=="],
|
||||
|
||||
"esast-util-from-js": ["[email protected]", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "acorn": "^8.0.0", "esast-util-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw=="],
|
||||
|
||||
"esbuild": ["[email protected]", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="],
|
||||
|
||||
"escape-html": ["[email protected]", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||
|
||||
"escape-string-regexp": ["[email protected]", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
|
||||
"esprima": ["[email protected]", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
|
||||
@@ -517,34 +562,62 @@
|
||||
|
||||
"estree-walker": ["[email protected]", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||
|
||||
"etag": ["[email protected]", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
||||
|
||||
"eventsource": ["[email protected]", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
|
||||
|
||||
"eventsource-parser": ["[email protected]", "", {}, "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ=="],
|
||||
|
||||
"express": ["[email protected]", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.3.0", "content-disposition": "^2.0.1", "content-type": "^2.0.0", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.8", "qs": "^6.16.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.1.0", "vary": "^1.1.2" } }, "sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g=="],
|
||||
|
||||
"express-rate-limit": ["[email protected]", "", { "dependencies": { "debug": "^4.4.3", "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA=="],
|
||||
|
||||
"extend": ["[email protected]", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||
|
||||
"extend-shallow": ["[email protected]", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="],
|
||||
|
||||
"fast-deep-equal": ["[email protected]", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-string-truncated-width": ["[email protected]", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="],
|
||||
|
||||
"fast-string-width": ["[email protected]", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="],
|
||||
|
||||
"fast-uri": ["[email protected]", "", {}, "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg=="],
|
||||
|
||||
"fast-wrap-ansi": ["[email protected]", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="],
|
||||
|
||||
"fault": ["[email protected]", "", { "dependencies": { "format": "^0.2.0" } }, "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA=="],
|
||||
|
||||
"finalhandler": ["[email protected]", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
|
||||
|
||||
"find-up": ["[email protected]", "", { "dependencies": { "path-exists": "^2.0.0", "pinkie-promise": "^2.0.0" } }, "sha512-jvElSjyuo4EMQGoTwo1uJU5pQMwTW5lS1x05zzfJuTIyLR3zwO27LYrxNg+dlvKpGOuGy/MzBdXh80g0ve5+HA=="],
|
||||
|
||||
"format": ["[email protected]", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="],
|
||||
|
||||
"forwarded": ["[email protected]", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
||||
|
||||
"frac": ["[email protected]", "", {}, "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA=="],
|
||||
|
||||
"fresh": ["[email protected]", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
||||
|
||||
"function-bind": ["[email protected]", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"get-east-asian-width": ["[email protected]", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
|
||||
|
||||
"get-intrinsic": ["[email protected]", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-proto": ["[email protected]", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"get-stdin": ["[email protected]", "", {}, "sha512-F5aQMywwJ2n85s4hJPTT9RPxGmubonuB10MNYo17/xph174n2MIR33HRguhzVag10O/npM7SPk73LMZNP+FaWw=="],
|
||||
|
||||
"gopd": ["[email protected]", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"graceful-fs": ["[email protected]", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"gray-matter": ["[email protected]", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="],
|
||||
|
||||
"has-symbols": ["[email protected]", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"hasown": ["[email protected]", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
|
||||
|
||||
"hast-util-from-html": ["[email protected]", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="],
|
||||
@@ -567,6 +640,8 @@
|
||||
|
||||
"highlightjs-vue": ["[email protected]", "", {}, "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA=="],
|
||||
|
||||
"hono": ["[email protected]", "", {}, "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA=="],
|
||||
|
||||
"hosted-git-info": ["[email protected]", "", {}, "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw=="],
|
||||
|
||||
"html-to-react": ["[email protected]", "", { "dependencies": { "domhandler": "^5.0", "htmlparser2": "^9.0", "lodash.camelcase": "^4.3.0" }, "peerDependencies": { "react": "^0.13.0 || ^0.14.0 || >=15" } }, "sha512-b5HTNaTGyOj5GGIMiWVr1k57egAZ/vGy0GGefnCQ1VW5hu9+eku8AXHtf2/DeD95cj/FKBKYa1J7SWBOX41yUQ=="],
|
||||
@@ -575,6 +650,8 @@
|
||||
|
||||
"htmlparser2": ["[email protected]", "", { "dependencies": { "domelementtype": "^3.0.0", "domhandler": "^6.0.0", "domutils": "^4.0.2", "entities": "^8.0.0" } }, "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw=="],
|
||||
|
||||
"http-errors": ["[email protected]", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
||||
|
||||
"iconv-lite": ["[email protected]", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="],
|
||||
|
||||
"immediate": ["[email protected]", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
|
||||
@@ -587,6 +664,10 @@
|
||||
|
||||
"inquirer": ["[email protected]", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/core": "^11.1.10", "@inquirer/prompts": "^8.4.3", "@inquirer/type": "^4.0.5", "mute-stream": "^3.0.0", "run-async": "^4.0.6", "rxjs": "^7.8.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-EPd3IqieHSavSOXh+LZhrIkdQcOELWeRblLT6kslQr+cF9XTh/HxZdSt1YkHH1iq4dvqBnV42uwg2YlorgOy6g=="],
|
||||
|
||||
"ip-address": ["[email protected]", "", {}, "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg=="],
|
||||
|
||||
"ipaddr.js": ["[email protected]", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||
|
||||
"is-alphabetical": ["[email protected]", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
|
||||
|
||||
"is-alphanumerical": ["[email protected]", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
|
||||
@@ -609,18 +690,28 @@
|
||||
|
||||
"is-plain-object": ["[email protected]", "", {}, "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw=="],
|
||||
|
||||
"is-promise": ["[email protected]", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
|
||||
|
||||
"is-unicode-supported": ["[email protected]", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
|
||||
|
||||
"is-utf8": ["[email protected]", "", {}, "sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q=="],
|
||||
|
||||
"isarray": ["[email protected]", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
|
||||
|
||||
"isexe": ["[email protected]", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"jiti": ["[email protected]", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
|
||||
|
||||
"jose": ["[email protected]", "", {}, "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw=="],
|
||||
|
||||
"js-tokens": ["[email protected]", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"js-yaml": ["[email protected]", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag=="],
|
||||
|
||||
"json-schema-traverse": ["[email protected]", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"json-schema-typed": ["[email protected]", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
|
||||
|
||||
"jszip": ["[email protected]", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="],
|
||||
|
||||
"kind-of": ["[email protected]", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="],
|
||||
@@ -671,7 +762,7 @@
|
||||
|
||||
"lowlight": ["[email protected]", "", { "dependencies": { "fault": "^1.0.0", "highlight.js": "~10.7.0" } }, "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw=="],
|
||||
|
||||
"lucide-react": ["lucide-react@1.31.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg=="],
|
||||
"lucide-react": ["lucide-react@1.59.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-5qgMyyCPU6OvnKpLS2t693+ZtiqA8oaSweWoUows0BLFKFf6V75m4+CptpmohZHpD5IuOVIBXHBBiv2vpLJmtw=="],
|
||||
|
||||
"magic-string": ["[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
@@ -683,6 +774,8 @@
|
||||
|
||||
"markdown-table": ["[email protected]", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
|
||||
|
||||
"math-intrinsics": ["[email protected]", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"mdast-util-find-and-replace": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="],
|
||||
|
||||
"mdast-util-from-markdown": ["[email protected]", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="],
|
||||
@@ -717,8 +810,12 @@
|
||||
|
||||
"mdx": ["[email protected]", "", { "dependencies": { "meow": "3.6.0", "mustache": "2.2.1", "object-assign": "4.0.1", "read-input": "0.3.1" }, "bin": { "mdx": "./bin/mdx" } }, "sha512-i+oUkB4ntcYVYnjiuktpYP77m/ISDg7z1B2pL+alDNFPgRkXlkYaW6zY03103/88A06E3Pn6x/DL9qB8pT9xWA=="],
|
||||
|
||||
"media-typer": ["[email protected]", "", {}, "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ=="],
|
||||
|
||||
"meow": ["[email protected]", "", { "dependencies": { "camelcase-keys": "^2.0.0", "loud-rejection": "^1.0.0", "minimist": "^1.1.3", "normalize-package-data": "^2.3.4", "object-assign": "^4.0.1", "read-pkg-up": "^1.0.1", "redent": "^1.0.0", "trim-newlines": "^1.0.0" } }, "sha512-1zRGO8C/2QD8uBxZbwwKbIQHrHKANzVnlK/3Gj7xro+ks4HLmayvETy+BnCV+wm68PE6dYcfgyTDMVG2mjlQwg=="],
|
||||
|
||||
"merge-descriptors": ["[email protected]", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
|
||||
|
||||
"micromark": ["[email protected]", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
|
||||
|
||||
"micromark-core-commonmark": ["[email protected]", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
|
||||
@@ -789,6 +886,10 @@
|
||||
|
||||
"micromark-util-types": ["[email protected]", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
|
||||
|
||||
"mime-db": ["[email protected]", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
|
||||
"mime-types": ["[email protected]", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
||||
|
||||
"mimic-function": ["[email protected]", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
|
||||
|
||||
"minimist": ["[email protected]", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
@@ -801,6 +902,8 @@
|
||||
|
||||
"nanoid": ["[email protected]", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="],
|
||||
|
||||
"negotiator": ["[email protected]", "", { "dependencies": { "content-type": "^2.1.0" } }, "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg=="],
|
||||
|
||||
"next-mdx-remote": ["[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.23.5", "@mdx-js/mdx": "^3.0.1", "@mdx-js/react": "^3.0.1", "unist-util-remove": "^4.0.0", "unist-util-visit": "^5.1.0", "vfile": "^6.0.1", "vfile-matter": "^5.0.0" }, "peerDependencies": { "react": ">=16" } }, "sha512-cJEpEZlgD6xGjB4jL8BnI8FaYdN9BzZM4NwadPe1YQr7pqoWjg9EBCMv3nXBkuHqMRfv2y33SzUsuyNh9LFAQQ=="],
|
||||
|
||||
"nodemailer": ["[email protected]", "", {}, "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ=="],
|
||||
@@ -809,6 +912,12 @@
|
||||
|
||||
"object-assign": ["[email protected]", "", {}, "sha512-c6legOHWepAbWnp3j5SRUMpxCXBKI4rD7A5Osn9IzZ8w4O/KccXdW0lqdkQKbpk0eHGjNgKihgzY6WuEq99Tfw=="],
|
||||
|
||||
"object-inspect": ["[email protected]", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"on-finished": ["[email protected]", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||
|
||||
"once": ["[email protected]", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"onetime": ["[email protected]", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
|
||||
|
||||
"openai": ["[email protected]", "", { "peerDependencies": { "@aws-sdk/credential-provider-node": ">=3.972.0 <4", "@smithy/hash-node": ">=4.3.0 <5", "@smithy/signature-v4": ">=5.4.0 <6", "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["@aws-sdk/credential-provider-node", "@smithy/hash-node", "@smithy/signature-v4", "ws", "zod"] }, "sha512-+C9Muit5x8j9R8ej8ZzVgKcrVDtqFqTy9gxFdov0EItLgU68zrJtF9ZeT0cyqJQW9S3PCJkdFgADtRGquRBtew=="],
|
||||
@@ -829,12 +938,18 @@
|
||||
|
||||
"parse5": ["[email protected]", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||
|
||||
"parseurl": ["[email protected]", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
||||
|
||||
"path-exists": ["[email protected]", "", { "dependencies": { "pinkie-promise": "^2.0.0" } }, "sha512-yTltuKuhtNeFJKa1PiRzfLAU5182q1y4Eb4XCJ3PBqyzEDkAZRzBrKKBct682ls9reBVHf9udYLN5Nd+K1B9BQ=="],
|
||||
|
||||
"path-is-absolute": ["[email protected]", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
|
||||
|
||||
"path-key": ["[email protected]", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-parse": ["[email protected]", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||
|
||||
"path-to-regexp": ["[email protected]", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
|
||||
|
||||
"path-type": ["[email protected]", "", { "dependencies": { "graceful-fs": "^4.1.2", "pify": "^2.0.0", "pinkie-promise": "^2.0.0" } }, "sha512-S4eENJz1pkiQn9Znv33Q+deTOKmbl+jj1Fl+qiP/vYezj+S8x+J3Uo0ISrx/QoEvIlOaDWJhPaRd1flJ9HXZqg=="],
|
||||
|
||||
"pdf-lib": ["[email protected]", "", { "dependencies": { "@pdf-lib/standard-fonts": "^1.0.0", "@pdf-lib/upng": "^1.0.1", "pako": "^1.0.11", "tslib": "^1.11.1" } }, "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw=="],
|
||||
@@ -847,6 +962,8 @@
|
||||
|
||||
"pinkie-promise": ["[email protected]", "", { "dependencies": { "pinkie": "^2.0.0" } }, "sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw=="],
|
||||
|
||||
"pkce-challenge": ["[email protected]", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
|
||||
|
||||
"postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA=="],
|
||||
|
||||
"postcss-value-parser": ["[email protected]", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
|
||||
@@ -857,6 +974,14 @@
|
||||
|
||||
"property-information": ["[email protected]", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="],
|
||||
|
||||
"proxy-addr": ["[email protected]", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ=="],
|
||||
|
||||
"qs": ["[email protected]", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg=="],
|
||||
|
||||
"range-parser": ["[email protected]", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="],
|
||||
|
||||
"raw-body": ["[email protected]", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||
|
||||
"react": ["[email protected]", "", {}, "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw=="],
|
||||
|
||||
"react-code-blocks": ["[email protected]", "", { "dependencies": { "@babel/runtime": "^7.10.4", "react-syntax-highlighter": "^15.5.0", "styled-components": "^6.1.0", "tslib": "^2.6.0" }, "peerDependencies": { "react": ">=16" } }, "sha512-ENNuxG07yO+OuX1ChRje3ieefPRz6yrIpHmebQlaFQgzcAHbUfVeTINpOpoI9bSRSObeYo/OdHsporeToZ7fcg=="],
|
||||
@@ -909,10 +1034,14 @@
|
||||
|
||||
"repeating": ["[email protected]", "", { "dependencies": { "is-finite": "^1.0.0" } }, "sha512-ZqtSMuVybkISo2OWvqvm7iHSWngvdaW3IpsT9/uP8v4gMi591LY6h35wdOfvQdWCKFWZWm2Y1Opp4kV7vQKT6A=="],
|
||||
|
||||
"require-from-string": ["[email protected]", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"resolve": ["[email protected]", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
|
||||
|
||||
"restore-cursor": ["[email protected]", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
|
||||
|
||||
"router": ["[email protected]", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
|
||||
|
||||
"run-async": ["[email protected]", "", {}, "sha512-IoDlSLTs3Yq593mb3ZoKWKXMNu3UpObxhgA/Xuid5p4bbfi2jdY1Hj0m1K+0/tEuQTxIGMhQDqGjKb7RuxGpAQ=="],
|
||||
|
||||
"rxjs": ["[email protected]", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
|
||||
@@ -929,10 +1058,28 @@
|
||||
|
||||
"semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||
|
||||
"send": ["[email protected]", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
|
||||
|
||||
"serve-static": ["[email protected]", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
|
||||
|
||||
"setimmediate": ["[email protected]", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="],
|
||||
|
||||
"setprototypeof": ["[email protected]", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||
|
||||
"sharp": ["[email protected]", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" } }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="],
|
||||
|
||||
"shebang-command": ["[email protected]", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["[email protected]", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"side-channel": ["[email protected]", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="],
|
||||
|
||||
"side-channel-list": ["[email protected]", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
|
||||
|
||||
"side-channel-map": ["[email protected]", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
|
||||
|
||||
"side-channel-weakmap": ["[email protected]", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||
|
||||
"signal-exit": ["[email protected]", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
|
||||
"source-map": ["[email protected]", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
||||
@@ -965,6 +1112,8 @@
|
||||
|
||||
"ssf": ["[email protected]", "", { "dependencies": { "frac": "~1.1.2" } }, "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g=="],
|
||||
|
||||
"statuses": ["[email protected]", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||
|
||||
"stdin-discarder": ["[email protected]", "", {}, "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A=="],
|
||||
|
||||
"string-width": ["[email protected]", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="],
|
||||
@@ -997,6 +1146,8 @@
|
||||
|
||||
"tapable": ["[email protected]", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
||||
|
||||
"toidentifier": ["[email protected]", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||
|
||||
"trim-lines": ["[email protected]", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
|
||||
|
||||
"trim-newlines": ["[email protected]", "", {}, "sha512-Nm4cF79FhSTzrLKGDMi3I4utBtFv8qKy4sq1enftf2gMdpqI8oVQTAfySkTz5r49giVzDj88SVZXP4CeYQwjaw=="],
|
||||
@@ -1007,6 +1158,8 @@
|
||||
|
||||
"turndown": ["[email protected]", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ=="],
|
||||
|
||||
"type-is": ["[email protected]", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
|
||||
|
||||
"typescript": ["[email protected]", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="],
|
||||
|
||||
"underscore": ["[email protected]", "", {}, "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ=="],
|
||||
@@ -1033,12 +1186,16 @@
|
||||
|
||||
"unpdf": ["[email protected]", "", { "peerDependencies": { "@napi-rs/canvas": "^0.1.69 || ^1.0.0" }, "optionalPeers": ["@napi-rs/canvas"] }, "sha512-jQlkckbe5nKxRHQdbvo9IKHlU6Cjq00UlxxB8lrCIxvS2o5IbAL1wM+4a1Yc3+BIohg9p5AsoaftwO+G4Aq/qQ=="],
|
||||
|
||||
"unpipe": ["[email protected]", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
||||
|
||||
"util-deprecate": ["[email protected]", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"uuid": ["[email protected]", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Sc5u1VVln93y1Xnz4R+CunOZc7R9LUcTmhj7Rc7Eg3HVcf7rVn2FOoa812fe0W8p3jD7xeoGSYbfqCGGH2B6xw=="],
|
||||
|
||||
"validate-npm-package-license": ["[email protected]", "", { "dependencies": { "spdx-correct": "^3.0.0", "spdx-expression-parse": "^3.0.0" } }, "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew=="],
|
||||
|
||||
"vary": ["[email protected]", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
|
||||
"vfile": ["[email protected]", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
|
||||
|
||||
"vfile-location": ["[email protected]", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="],
|
||||
@@ -1049,10 +1206,14 @@
|
||||
|
||||
"web-namespaces": ["[email protected]", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
|
||||
|
||||
"which": ["[email protected]", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"wmf": ["[email protected]", "", {}, "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw=="],
|
||||
|
||||
"word": ["[email protected]", "", {}, "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA=="],
|
||||
|
||||
"wrappy": ["[email protected]", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
"xlsx": ["[email protected]", "", { "dependencies": { "adler-32": "~1.3.0", "cfb": "~1.2.1", "codepage": "~1.15.0", "crc-32": "~1.2.1", "ssf": "~0.11.2", "wmf": "~1.0.1", "word": "~0.3.0" }, "bin": { "xlsx": "bin/xlsx.njs" } }, "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ=="],
|
||||
|
||||
"xmlbuilder": ["[email protected]", "", {}, "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg=="],
|
||||
@@ -1065,6 +1226,8 @@
|
||||
|
||||
"zod": ["[email protected]", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="],
|
||||
|
||||
"zod-to-json-schema": ["[email protected]", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
|
||||
|
||||
"zwitch": ["[email protected]", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
|
||||
|
||||
"@emnapi/runtime/tslib": ["[email protected]", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
@@ -1095,6 +1258,8 @@
|
||||
|
||||
"@tailwindcss/postcss/postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="],
|
||||
|
||||
"body-parser/content-type": ["[email protected]", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
|
||||
|
||||
"decode-named-character-reference/character-entities": ["[email protected]", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
|
||||
|
||||
"dom-serializer/domhandler": ["[email protected]", "", { "dependencies": { "domelementtype": "^3.0.0" } }, "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg=="],
|
||||
@@ -1103,6 +1268,8 @@
|
||||
|
||||
"domutils/domhandler": ["[email protected]", "", { "dependencies": { "domelementtype": "^3.0.0" } }, "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg=="],
|
||||
|
||||
"express/content-type": ["[email protected]", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
|
||||
|
||||
"html-to-react/htmlparser2": ["[email protected]", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "entities": "^4.5.0" } }, "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ=="],
|
||||
|
||||
"htmlparser2/domhandler": ["[email protected]", "", { "dependencies": { "domelementtype": "^3.0.0" } }, "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg=="],
|
||||
@@ -1113,6 +1280,8 @@
|
||||
|
||||
"mdast-util-find-and-replace/escape-string-regexp": ["[email protected]", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
||||
|
||||
"negotiator/content-type": ["[email protected]", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
|
||||
|
||||
"normalize-package-data/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="],
|
||||
|
||||
"parse-entities/@types/unist": ["@types/[email protected]", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
@@ -1125,6 +1294,8 @@
|
||||
|
||||
"rxjs/tslib": ["[email protected]", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"type-is/content-type": ["[email protected]", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
|
||||
|
||||
"@moduletrace/bunext/@types/node/undici-types": ["[email protected]", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
"@tailwindcss/postcss/postcss/nanoid": ["[email protected]", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
|
||||
|
||||
+380
@@ -13,8 +13,12 @@ import {
|
||||
ContainerPurposes,
|
||||
ContainerStatuses,
|
||||
} from "@/src/dict/computer-dict";
|
||||
import { Harnesses } from "@/src/dict/harness-dict";
|
||||
import { HarnessProviderKinds } from "@/src/dict/harness-provider-dict";
|
||||
import { BotJobKinds } from "@/src/dict/bot-queue-dict";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import type { BUN_SQLITE_DatabaseSchemaType } from "@moduletrace/bun-sqlite/types";
|
||||
import { EnvParadigms } from "@/src/dict/env-dict";
|
||||
|
||||
const schema: BUN_SQLITE_DatabaseSchemaType = {
|
||||
tables: [
|
||||
@@ -396,6 +400,13 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
|
||||
backoff: AppData.BotQueueBackoffMs,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
fieldName: "kind",
|
||||
fieldDescription: `task (long-running work, shown in the working-on panel) or reply (answers the user in chat)`,
|
||||
dataType: "TEXT",
|
||||
options: BotJobKinds.map((k) => k.value),
|
||||
defaultValue: "task",
|
||||
},
|
||||
{
|
||||
fieldName: "bot_id",
|
||||
fieldDescription: `The bot doing the work`,
|
||||
@@ -572,6 +583,50 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
tableName: "bot_message_media",
|
||||
tableDescription: `Files attached to a chat message (\`media\` rows, private to the message's owner)`,
|
||||
fields: [
|
||||
{
|
||||
fieldName: "message_id",
|
||||
dataType: "INTEGER",
|
||||
foreignKey: {
|
||||
cascadeDelete: true,
|
||||
destinationTableName: "bot_messages",
|
||||
destinationTableColumnName: "id",
|
||||
destinationTableColumnType: "INTEGER",
|
||||
foreignKeyName: "bot_message_media_message_fk",
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: "media_id",
|
||||
dataType: "INTEGER",
|
||||
foreignKey: {
|
||||
cascadeDelete: true,
|
||||
destinationTableName: "media",
|
||||
destinationTableColumnName: "id",
|
||||
destinationTableColumnType: "INTEGER",
|
||||
foreignKeyName: "bot_message_media_media_fk",
|
||||
},
|
||||
},
|
||||
],
|
||||
uniqueConstraints: [
|
||||
{
|
||||
alias: "one_link_per_message_media",
|
||||
constraintName: "A file is attached to a message once",
|
||||
constraintTableFields: [
|
||||
{ value: "message_id" },
|
||||
{ value: "media_id" },
|
||||
],
|
||||
},
|
||||
],
|
||||
indexes: [
|
||||
{
|
||||
indexName: "idx_bot_message_media_message_id",
|
||||
indexTableFields: ["message_id"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
tableName: "containers",
|
||||
fields: [
|
||||
@@ -773,6 +828,260 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
tableName: "harness_providers",
|
||||
fields: [
|
||||
{
|
||||
fieldName: "kind",
|
||||
fieldDescription: `Which provider preset this is`,
|
||||
dataType: "TEXT",
|
||||
options: HarnessProviderKinds.map((hpk) => hpk.value),
|
||||
},
|
||||
{
|
||||
fieldName: "name",
|
||||
dataType: "TEXT",
|
||||
},
|
||||
{
|
||||
fieldName: "base_url",
|
||||
fieldDescription: `API base URL, e.g. https://openrouter.ai/api/v1`,
|
||||
dataType: "TEXT",
|
||||
},
|
||||
{
|
||||
fieldName: "api_key",
|
||||
fieldDescription: `API key, encrypted with ENCRYPTION_KEY. Never sent to the client`,
|
||||
dataType: "TEXT",
|
||||
},
|
||||
{
|
||||
fieldName: "enabled",
|
||||
dataType: "INTEGER",
|
||||
options: [0, 1],
|
||||
defaultValue: 1,
|
||||
},
|
||||
{
|
||||
fieldName: "last_synced_at",
|
||||
fieldDescription: `Unix timestamp of the last model list sync`,
|
||||
dataType: "INTEGER",
|
||||
},
|
||||
{
|
||||
fieldName: "sync_error",
|
||||
fieldDescription: `Why the last model list sync failed, if it did`,
|
||||
dataType: "TEXT",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
tableName: "harness_models",
|
||||
fields: [
|
||||
{
|
||||
fieldName: "provider_id",
|
||||
dataType: "INTEGER",
|
||||
foreignKey: {
|
||||
cascadeDelete: true,
|
||||
destinationTableName: "harness_providers",
|
||||
destinationTableColumnName: "id",
|
||||
destinationTableColumnType: "INTEGER",
|
||||
foreignKeyName: "harness_model_provider_fk",
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: "slug",
|
||||
fieldDescription: `The provider's model id, e.g. deepseek/deepseek-v4.1-flash:free`,
|
||||
dataType: "TEXT",
|
||||
},
|
||||
{
|
||||
fieldName: "name",
|
||||
dataType: "TEXT",
|
||||
},
|
||||
{
|
||||
fieldName: "free",
|
||||
dataType: "INTEGER",
|
||||
options: [0, 1],
|
||||
defaultValue: 0,
|
||||
},
|
||||
{
|
||||
fieldName: "available",
|
||||
fieldDescription: `0 once the provider stops listing it`,
|
||||
dataType: "INTEGER",
|
||||
options: [0, 1],
|
||||
defaultValue: 1,
|
||||
},
|
||||
{
|
||||
fieldName: "input_modalities",
|
||||
fieldDescription: `JSON array, e.g. ["text","image"]`,
|
||||
dataType: "TEXT",
|
||||
json: true,
|
||||
},
|
||||
{
|
||||
fieldName: "output_modalities",
|
||||
fieldDescription: `JSON array, e.g. ["text"], ["speech"], ["image"]`,
|
||||
dataType: "TEXT",
|
||||
json: true,
|
||||
},
|
||||
{
|
||||
fieldName: "supports_tools",
|
||||
dataType: "INTEGER",
|
||||
options: [0, 1],
|
||||
defaultValue: 0,
|
||||
},
|
||||
{
|
||||
fieldName: "context_length",
|
||||
dataType: "INTEGER",
|
||||
},
|
||||
{
|
||||
fieldName: "max_output_tokens",
|
||||
dataType: "INTEGER",
|
||||
},
|
||||
{
|
||||
fieldName: "voices",
|
||||
fieldDescription: `JSON array of voice names, for speech models`,
|
||||
dataType: "TEXT",
|
||||
json: true,
|
||||
},
|
||||
{
|
||||
fieldName: "intelligence_score",
|
||||
fieldDescription: `Artificial Analysis intelligence index`,
|
||||
dataType: "REAL",
|
||||
},
|
||||
{
|
||||
fieldName: "agentic_score",
|
||||
fieldDescription: `Artificial Analysis agentic index`,
|
||||
dataType: "REAL",
|
||||
},
|
||||
{
|
||||
fieldName: "released_at",
|
||||
dataType: "INTEGER",
|
||||
},
|
||||
{
|
||||
fieldName: "expires_at",
|
||||
fieldDescription: `When the provider retires the model`,
|
||||
dataType: "INTEGER",
|
||||
},
|
||||
{
|
||||
fieldName: "last_seen_at",
|
||||
fieldDescription: `Unix timestamp of the last sync that listed it`,
|
||||
dataType: "INTEGER",
|
||||
},
|
||||
],
|
||||
uniqueConstraints: [
|
||||
{
|
||||
alias: "one_model_slug_per_provider",
|
||||
constraintName: "A provider lists a model once",
|
||||
constraintTableFields: [
|
||||
{ value: "provider_id" },
|
||||
{ value: "slug" },
|
||||
],
|
||||
},
|
||||
],
|
||||
indexes: [
|
||||
{
|
||||
indexName: "idx_harness_models_free_available",
|
||||
indexTableFields: ["free", "available"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
tableName: "harness_settings",
|
||||
fields: [
|
||||
{
|
||||
fieldName: "harness",
|
||||
dataType: "TEXT",
|
||||
options: Harnesses.map((h) => h.value),
|
||||
unique: true,
|
||||
},
|
||||
{
|
||||
fieldName: "model_id",
|
||||
fieldDescription: `harness_models row picked by a super admin. Null picks automatically. Not a foreign key, so removing a provider leaves the setting`,
|
||||
dataType: "INTEGER",
|
||||
},
|
||||
{
|
||||
fieldName: "provider_id",
|
||||
fieldDescription: `harness_providers row this harness is limited to (its pick and automatic picks). Null: any provider. Not a foreign key; removing the provider clears it`,
|
||||
dataType: "INTEGER",
|
||||
},
|
||||
{
|
||||
fieldName: "fallback",
|
||||
fieldDescription: `When the picked model fails, try the automatic picks`,
|
||||
dataType: "INTEGER",
|
||||
options: [0, 1],
|
||||
defaultValue: 1,
|
||||
},
|
||||
{
|
||||
fieldName: "enabled",
|
||||
fieldDescription: `0 turns the harness off. Text is always on`,
|
||||
dataType: "INTEGER",
|
||||
options: [0, 1],
|
||||
defaultValue: 1,
|
||||
},
|
||||
{
|
||||
fieldName: "options",
|
||||
fieldDescription: `Harness options, e.g. {"voice":"george"} for speech`,
|
||||
dataType: "TEXT",
|
||||
json: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
tableName: "bot_harness_settings",
|
||||
fields: [
|
||||
{
|
||||
fieldName: "bot_id",
|
||||
fieldDescription: `The worker bot that overrides the default. The coordinator always uses the defaults`,
|
||||
dataType: "INTEGER",
|
||||
foreignKey: {
|
||||
cascadeDelete: true,
|
||||
destinationTableName: "bots",
|
||||
destinationTableColumnName: "id",
|
||||
destinationTableColumnType: "INTEGER",
|
||||
foreignKeyName: "bot_harness_settings_bot_fk",
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: "harness",
|
||||
dataType: "TEXT",
|
||||
options: Harnesses.map((h) => h.value),
|
||||
},
|
||||
{
|
||||
fieldName: "model_id",
|
||||
fieldDescription: `harness_models row picked for this bot. Null picks automatically. Not a foreign key, like harness_settings`,
|
||||
dataType: "INTEGER",
|
||||
},
|
||||
{
|
||||
fieldName: "provider_id",
|
||||
fieldDescription: `harness_providers row this bot's harness is limited to. Null: any provider. Not a foreign key, like harness_settings`,
|
||||
dataType: "INTEGER",
|
||||
},
|
||||
{
|
||||
fieldName: "fallback",
|
||||
fieldDescription: `When the picked model fails, try the automatic picks`,
|
||||
dataType: "INTEGER",
|
||||
options: [0, 1],
|
||||
defaultValue: 1,
|
||||
},
|
||||
{
|
||||
fieldName: "enabled",
|
||||
fieldDescription: `0 turns the harness off for this bot. Text is always on`,
|
||||
dataType: "INTEGER",
|
||||
options: [0, 1],
|
||||
defaultValue: 1,
|
||||
},
|
||||
{
|
||||
fieldName: "options",
|
||||
fieldDescription: `Harness options, e.g. {"voice":"george"} for speech`,
|
||||
dataType: "TEXT",
|
||||
json: true,
|
||||
},
|
||||
],
|
||||
uniqueConstraints: [
|
||||
{
|
||||
alias: "one_setting_per_bot_harness",
|
||||
constraintName: "A bot overrides a harness once",
|
||||
constraintTableFields: [
|
||||
{ value: "bot_id" },
|
||||
{ value: "harness" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
tableName: "logs",
|
||||
fields: [
|
||||
@@ -836,6 +1145,77 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
tableName: "envs",
|
||||
fields: [
|
||||
{
|
||||
fieldName: "key",
|
||||
fieldDescription: `Variable name. One per key and scope (enforced in saveEnv: SQLite unique constraints treat NULL ids as distinct)`,
|
||||
dataType: "TEXT",
|
||||
},
|
||||
{
|
||||
fieldName: "value",
|
||||
fieldDescription: `Encrypted env value.`,
|
||||
dataType: "TEXT",
|
||||
},
|
||||
{
|
||||
fieldName: "description",
|
||||
dataType: "TEXT",
|
||||
fieldDescription: `Env description. Can be searched. Bots see it beside the key, never the value`,
|
||||
},
|
||||
{
|
||||
fieldName: "paradigm",
|
||||
fieldDescription: `Where it applies: general, a workspace, or a computer. Computer beats workspace beats general`,
|
||||
dataType: "TEXT",
|
||||
options: EnvParadigms.map((p) => p.value),
|
||||
defaultValue: "general",
|
||||
},
|
||||
{
|
||||
fieldName: "workspace_id",
|
||||
fieldDescription: `The workspace, for paradigm workspace`,
|
||||
dataType: "INTEGER",
|
||||
foreignKey: {
|
||||
cascadeDelete: true,
|
||||
destinationTableName: "workspaces",
|
||||
destinationTableColumnName: "id",
|
||||
destinationTableColumnType: "INTEGER",
|
||||
foreignKeyName: "envs_workspace_fk",
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: "computer_id",
|
||||
fieldDescription: `The computer, for paradigm computer`,
|
||||
dataType: "INTEGER",
|
||||
foreignKey: {
|
||||
cascadeDelete: true,
|
||||
destinationTableName: "computers",
|
||||
destinationTableColumnName: "id",
|
||||
destinationTableColumnType: "INTEGER",
|
||||
foreignKeyName: "envs_computer_fk",
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: "secret",
|
||||
fieldDescription: `1 hides the value once saved (only replaced, never shown) and redacts it from everything bots read`,
|
||||
dataType: "INTEGER",
|
||||
options: [0, 1],
|
||||
defaultValue: 1,
|
||||
},
|
||||
{
|
||||
fieldName: "expose_to_computers",
|
||||
fieldDescription: `1 puts it in the shell of every computer it applies to. App settings (EnvDict) never are`,
|
||||
dataType: "INTEGER",
|
||||
options: [0, 1],
|
||||
defaultValue: 1,
|
||||
},
|
||||
],
|
||||
indexes: [
|
||||
{
|
||||
indexName: "idx_envs_paradigm_key",
|
||||
indexTableFields: ["paradigm", "key"],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
+248
-3
@@ -12,11 +12,17 @@ export const BunSQLiteTables = [
|
||||
"bots_queue",
|
||||
"workspaces",
|
||||
"workspace_bots",
|
||||
"bot_message_media",
|
||||
"containers",
|
||||
"computers",
|
||||
"bot_computers",
|
||||
"workspace_computers",
|
||||
"harness_providers",
|
||||
"harness_models",
|
||||
"harness_settings",
|
||||
"bot_harness_settings",
|
||||
"logs",
|
||||
"envs",
|
||||
] as const
|
||||
|
||||
export type BUN_SQLITE_COBBLE_USERS = {
|
||||
@@ -108,7 +114,7 @@ export type BUN_SQLITE_COBBLE_MEDIA = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
summary?: string;
|
||||
media_type?: "image" | "video" | "document" | "";
|
||||
media_type?: "image" | "video" | "document" | "audio" | "";
|
||||
media_write_path?: string;
|
||||
media_thumbnail_write_path?: string;
|
||||
mime_type?: string;
|
||||
@@ -133,7 +139,7 @@ export type BUN_SQLITE_COBBLE_MEDIA_PARADIGMS = {
|
||||
*/
|
||||
updated_at?: number | "";
|
||||
media_id?: number | "";
|
||||
media_paradigm?: "user-profile-image" | "generic" | "event" | "";
|
||||
media_paradigm?: "user-profile-image" | "generic" | "event" | "bot-attachment" | "";
|
||||
}
|
||||
|
||||
export type BUN_SQLITE_COBBLE_BOTS = {
|
||||
@@ -265,6 +271,10 @@ export type BUN_SQLITE_COBBLE_BOTS_QUEUE = {
|
||||
* The time when the record was updated. (Unix Timestamp)
|
||||
*/
|
||||
updated_at?: number | "";
|
||||
/**
|
||||
* task (long-running work, shown in the working-on panel) or reply (answers the user in chat)
|
||||
*/
|
||||
kind?: "task" | "reply" | "";
|
||||
/**
|
||||
* The bot doing the work
|
||||
*/
|
||||
@@ -382,6 +392,23 @@ export type BUN_SQLITE_COBBLE_WORKSPACE_BOTS = {
|
||||
bot_id?: number | "";
|
||||
}
|
||||
|
||||
export type BUN_SQLITE_COBBLE_BOT_MESSAGE_MEDIA = {
|
||||
/**
|
||||
* 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 | "";
|
||||
message_id?: number | "";
|
||||
media_id?: number | "";
|
||||
}
|
||||
|
||||
export type BUN_SQLITE_COBBLE_CONTAINERS = {
|
||||
/**
|
||||
* The unique identifier of the record.
|
||||
@@ -500,6 +527,177 @@ export type BUN_SQLITE_COBBLE_WORKSPACE_COMPUTERS = {
|
||||
computer_id?: number | "";
|
||||
}
|
||||
|
||||
export type BUN_SQLITE_COBBLE_HARNESS_PROVIDERS = {
|
||||
/**
|
||||
* 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 | "";
|
||||
/**
|
||||
* Which provider preset this is
|
||||
*/
|
||||
kind?: "openrouter" | "openai" | "anthropic" | "google" | "deepseek" | "groq" | "mistral" | "xai" | "ollama" | "custom" | "";
|
||||
name?: string;
|
||||
/**
|
||||
* API base URL, e.g. https://openrouter.ai/api/v1
|
||||
*/
|
||||
base_url?: string;
|
||||
/**
|
||||
* API key, encrypted with ENCRYPTION_KEY. Never sent to the client
|
||||
*/
|
||||
api_key?: string;
|
||||
enabled?: 0 | 1 | "";
|
||||
/**
|
||||
* Unix timestamp of the last model list sync
|
||||
*/
|
||||
last_synced_at?: number | "";
|
||||
/**
|
||||
* Why the last model list sync failed, if it did
|
||||
*/
|
||||
sync_error?: string;
|
||||
}
|
||||
|
||||
export type BUN_SQLITE_COBBLE_HARNESS_MODELS = {
|
||||
/**
|
||||
* 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 | "";
|
||||
provider_id?: number | "";
|
||||
/**
|
||||
* The provider's model id, e.g. deepseek/deepseek-v4.1-flash:free
|
||||
*/
|
||||
slug?: string;
|
||||
name?: string;
|
||||
free?: 0 | 1 | "";
|
||||
/**
|
||||
* 0 once the provider stops listing it
|
||||
*/
|
||||
available?: 0 | 1 | "";
|
||||
/**
|
||||
* JSON array, e.g. ["text","image"]
|
||||
*/
|
||||
input_modalities?: string;
|
||||
/**
|
||||
* JSON array, e.g. ["text"], ["speech"], ["image"]
|
||||
*/
|
||||
output_modalities?: string;
|
||||
supports_tools?: 0 | 1 | "";
|
||||
context_length?: number | "";
|
||||
max_output_tokens?: number | "";
|
||||
/**
|
||||
* JSON array of voice names, for speech models
|
||||
*/
|
||||
voices?: string;
|
||||
/**
|
||||
* Artificial Analysis intelligence index
|
||||
*/
|
||||
intelligence_score?: number | "";
|
||||
/**
|
||||
* Artificial Analysis agentic index
|
||||
*/
|
||||
agentic_score?: number | "";
|
||||
released_at?: number | "";
|
||||
/**
|
||||
* When the provider retires the model
|
||||
*/
|
||||
expires_at?: number | "";
|
||||
/**
|
||||
* Unix timestamp of the last sync that listed it
|
||||
*/
|
||||
last_seen_at?: number | "";
|
||||
}
|
||||
|
||||
export type BUN_SQLITE_COBBLE_HARNESS_SETTINGS = {
|
||||
/**
|
||||
* 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 | "";
|
||||
harness?: "text" | "vision" | "transcription" | "speech" | "image" | "";
|
||||
/**
|
||||
* harness_models row picked by a super admin. Null picks automatically. Not a foreign key, so removing a provider leaves the setting
|
||||
*/
|
||||
model_id?: number | "";
|
||||
/**
|
||||
* harness_providers row this harness is limited to (its pick and automatic picks). Null: any provider. Not a foreign key; removing the provider clears it
|
||||
*/
|
||||
provider_id?: number | "";
|
||||
/**
|
||||
* When the picked model fails, try the automatic picks
|
||||
*/
|
||||
fallback?: 0 | 1 | "";
|
||||
/**
|
||||
* 0 turns the harness off. Text is always on
|
||||
*/
|
||||
enabled?: 0 | 1 | "";
|
||||
/**
|
||||
* Harness options, e.g. {"voice":"george"} for speech
|
||||
*/
|
||||
options?: string;
|
||||
}
|
||||
|
||||
export type BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS = {
|
||||
/**
|
||||
* 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 | "";
|
||||
/**
|
||||
* The worker bot that overrides the default. The coordinator always uses the defaults
|
||||
*/
|
||||
bot_id?: number | "";
|
||||
harness?: "text" | "vision" | "transcription" | "speech" | "image" | "";
|
||||
/**
|
||||
* harness_models row picked for this bot. Null picks automatically. Not a foreign key, like harness_settings
|
||||
*/
|
||||
model_id?: number | "";
|
||||
/**
|
||||
* harness_providers row this bot's harness is limited to. Null: any provider. Not a foreign key, like harness_settings
|
||||
*/
|
||||
provider_id?: number | "";
|
||||
/**
|
||||
* When the picked model fails, try the automatic picks
|
||||
*/
|
||||
fallback?: 0 | 1 | "";
|
||||
/**
|
||||
* 0 turns the harness off for this bot. Text is always on
|
||||
*/
|
||||
enabled?: 0 | 1 | "";
|
||||
/**
|
||||
* Harness options, e.g. {"voice":"george"} for speech
|
||||
*/
|
||||
options?: string;
|
||||
}
|
||||
|
||||
export type BUN_SQLITE_COBBLE_LOGS = {
|
||||
/**
|
||||
* The unique identifier of the record.
|
||||
@@ -547,4 +745,51 @@ export type BUN_SQLITE_COBBLE_LOGS = {
|
||||
ip_address?: 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_MESSAGES & BUN_SQLITE_COBBLE_BOT_TASKS & BUN_SQLITE_COBBLE_BOT_TASK_STEPS & BUN_SQLITE_COBBLE_BOTS_QUEUE & BUN_SQLITE_COBBLE_WORKSPACES & BUN_SQLITE_COBBLE_WORKSPACE_BOTS & BUN_SQLITE_COBBLE_CONTAINERS & BUN_SQLITE_COBBLE_COMPUTERS & BUN_SQLITE_COBBLE_BOT_COMPUTERS & BUN_SQLITE_COBBLE_WORKSPACE_COMPUTERS & BUN_SQLITE_COBBLE_LOGS
|
||||
export type BUN_SQLITE_COBBLE_ENVS = {
|
||||
/**
|
||||
* 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 | "";
|
||||
/**
|
||||
* Variable name. One per key and scope (enforced in saveEnv: SQLite unique constraints treat NULL ids as distinct)
|
||||
*/
|
||||
key?: string;
|
||||
/**
|
||||
* Encrypted env value.
|
||||
*/
|
||||
value?: string;
|
||||
/**
|
||||
* Env description. Can be searched. Bots see it beside the key, never the value
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* Where it applies: general, a workspace, or a computer. Computer beats workspace beats general
|
||||
*/
|
||||
paradigm?: "general" | "workspace" | "computer" | "";
|
||||
/**
|
||||
* The workspace, for paradigm workspace
|
||||
*/
|
||||
workspace_id?: number | "";
|
||||
/**
|
||||
* The computer, for paradigm computer
|
||||
*/
|
||||
computer_id?: number | "";
|
||||
/**
|
||||
* 1 hides the value once saved (only replaced, never shown) and redacts it from everything bots read
|
||||
*/
|
||||
secret?: 0 | 1 | "";
|
||||
/**
|
||||
* 1 puts it in the shell of every computer it applies to. App settings (EnvDict) never are
|
||||
*/
|
||||
expose_to_computers?: 0 | 1 | "";
|
||||
}
|
||||
|
||||
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_MESSAGES & BUN_SQLITE_COBBLE_BOT_TASKS & BUN_SQLITE_COBBLE_BOT_TASK_STEPS & BUN_SQLITE_COBBLE_BOTS_QUEUE & BUN_SQLITE_COBBLE_WORKSPACES & BUN_SQLITE_COBBLE_WORKSPACE_BOTS & 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_LOGS & BUN_SQLITE_COBBLE_ENVS
|
||||
+2
-1
@@ -35,6 +35,7 @@
|
||||
"typescript": "^7.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.32.1",
|
||||
"@moduletrace/bun-sqlite": "^1.3.0",
|
||||
"@moduletrace/bunext": "^1.3.3",
|
||||
"@moduletrace/twui": "^0.1.1",
|
||||
@@ -43,7 +44,7 @@
|
||||
"gray-matter": "^4.0.3",
|
||||
"html-to-react": "^1.7.0",
|
||||
"lodash": "^4.18.1",
|
||||
"lucide-react": "^1.31.0",
|
||||
"lucide-react": "^1.59.0",
|
||||
"mammoth": "^1.12.1",
|
||||
"mdx": "^0.3.1",
|
||||
"next-mdx-remote": "^6.0.0",
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import botMessagesToHarness from "./bot-messages-to-harness";
|
||||
|
||||
const message = (author: "user" | "bot", text: string) => ({ author, text });
|
||||
|
||||
describe("botMessagesToHarness", () => {
|
||||
test("maps authors to roles", () => {
|
||||
expect(
|
||||
botMessagesToHarness({
|
||||
messages: [message("user", "hi"), message("bot", "hello")],
|
||||
}),
|
||||
).toEqual([
|
||||
{ role: "user", content: "hi" },
|
||||
{ role: "assistant", content: "hello" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("joins back-to-back messages from one side", () => {
|
||||
expect(
|
||||
botMessagesToHarness({
|
||||
messages: [message("user", "one"), message("user", "two")],
|
||||
}),
|
||||
).toEqual([{ role: "user", content: "one\n\ntwo" }]);
|
||||
});
|
||||
|
||||
test("starts with the user and skips empty messages", () => {
|
||||
expect(
|
||||
botMessagesToHarness({
|
||||
messages: [
|
||||
message("bot", "welcome"),
|
||||
message("user", " "),
|
||||
message("user", "hi"),
|
||||
],
|
||||
}),
|
||||
).toEqual([{ role: "user", content: "hi" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("botMessagesToHarness with files", () => {
|
||||
const attachments = [{ id: 9, name: "shot.png", media_type: "image" as const, url: "/media/private/9" }];
|
||||
|
||||
test("puts a message's loaded files after its text", () => {
|
||||
const parts = new Map([[1, [{ type: "image" as const, url: "data:image/jpeg;base64,AA", name: "shot.png" }]]]);
|
||||
|
||||
expect(
|
||||
botMessagesToHarness({
|
||||
messages: [{ id: 1, author: "user", text: "What's this?", attachments }],
|
||||
parts,
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "What's this?" },
|
||||
{ type: "image", url: "data:image/jpeg;base64,AA", name: "shot.png" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("names files it didn't load, and keeps a file-only message", () => {
|
||||
expect(
|
||||
botMessagesToHarness({
|
||||
messages: [{ id: 2, author: "user", text: "", attachments }],
|
||||
}),
|
||||
).toEqual([{ role: "user", content: `[Attached earlier: "shot.png"]` }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { HarnessContentPart, HarnessMessage } from "@/src/harness/types";
|
||||
import type { BotMessageWithAttachments } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
messages: Pick<BotMessageWithAttachments, "id" | "author" | "text" | "attachments">[];
|
||||
/**
|
||||
* Loaded files by message id (`loadAttachmentParts`). Files of other
|
||||
* messages are only named.
|
||||
*/
|
||||
parts?: Map<number, HarnessContentPart[]>;
|
||||
};
|
||||
|
||||
type Turn = { role: "user" | "assistant"; parts: HarnessContentPart[] };
|
||||
|
||||
/**
|
||||
* A bot's conversation (oldest first) as model turns: the user's messages
|
||||
* (with their files) as user turns, the bot's as its own. Back-to-back
|
||||
* messages from the same side are joined, since some providers reject
|
||||
* repeated roles. Leading bot messages are dropped so the conversation
|
||||
* starts with the user. A turn that is only text stays a string.
|
||||
*/
|
||||
export default function botMessagesToHarness({ messages, parts }: Params): HarnessMessage[] {
|
||||
const turns: Turn[] = [];
|
||||
|
||||
for (const message of messages) {
|
||||
const role = message.author == "user" ? "user" : "assistant";
|
||||
const content = messageParts({ message, role, parts });
|
||||
|
||||
if (!content.length) continue;
|
||||
if (!turns.length && role == "assistant") continue;
|
||||
|
||||
const last = turns[turns.length - 1];
|
||||
|
||||
if (last?.role == role) {
|
||||
last.parts.push(...content);
|
||||
} else {
|
||||
turns.push({ role, parts: content });
|
||||
}
|
||||
}
|
||||
|
||||
return turns.map(({ role, parts }) => {
|
||||
const merged = mergeText({ parts });
|
||||
|
||||
if (role == "assistant" || merged.every((part) => part.type == "text")) {
|
||||
return {
|
||||
role,
|
||||
content: merged.map((part) => (part.type == "text" ? part.text : "")).join("\n\n"),
|
||||
};
|
||||
}
|
||||
|
||||
return { role, content: merged };
|
||||
});
|
||||
}
|
||||
|
||||
function messageParts({
|
||||
message,
|
||||
role,
|
||||
parts,
|
||||
}: {
|
||||
message: Params["messages"][number];
|
||||
role: Turn["role"];
|
||||
parts?: Params["parts"];
|
||||
}): HarnessContentPart[] {
|
||||
const text = message.text?.trim();
|
||||
const content: HarnessContentPart[] = text ? [{ type: "text", text }] : [];
|
||||
|
||||
if (role == "assistant" || !message.attachments?.length) return content;
|
||||
|
||||
const loaded = parts?.get(Number(message.id));
|
||||
|
||||
if (loaded?.length) return [...content, ...loaded];
|
||||
|
||||
return [
|
||||
...content,
|
||||
{
|
||||
type: "text",
|
||||
text: `[Attached earlier: ${message.attachments.map((file) => `"${file.name}"`).join(", ")}]`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Joins neighbouring text parts with a blank line.
|
||||
*/
|
||||
function mergeText({ parts }: { parts: HarnessContentPart[] }) {
|
||||
const merged: HarnessContentPart[] = [];
|
||||
|
||||
for (const part of parts) {
|
||||
const last = merged[merged.length - 1];
|
||||
|
||||
if (part.type == "text" && last?.type == "text") {
|
||||
merged[merged.length - 1] = { type: "text", text: `${last.text}\n\n${part.text}` };
|
||||
} else {
|
||||
merged.push(part);
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import composeJobBrief from "./compose-job-brief";
|
||||
|
||||
describe("composeJobBrief", () => {
|
||||
test("puts the title, instructions and parameters in order", () => {
|
||||
expect(
|
||||
composeJobBrief({
|
||||
title: "Tidy invoices",
|
||||
instructions: " Rename every PDF by date. ",
|
||||
params: { folder: "~/invoices" },
|
||||
}),
|
||||
).toBe(
|
||||
[
|
||||
"# Job: Tidy invoices",
|
||||
"Rename every PDF by date.",
|
||||
'## Parameters\n\n```json\n{\n "folder": "~/invoices"\n}\n```',
|
||||
"Work on this until it's done. Then reply to the user with the result: what you did, and anything they need to know or do.",
|
||||
].join("\n\n"),
|
||||
);
|
||||
});
|
||||
|
||||
test("leaves out empty parts", () => {
|
||||
expect(composeJobBrief({ params: {} })).toStartWith(
|
||||
"# Job: Untitled\n\nWork on this",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
type Params = {
|
||||
title?: string | null;
|
||||
instructions?: string | null;
|
||||
params?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The message a bot gets to start a queued job (markdown): the brief it
|
||||
* wrote for itself, its parameters, and how to finish.
|
||||
*/
|
||||
export default function composeJobBrief({ title, instructions, params }: Params) {
|
||||
const parts = [`# Job: ${title?.trim() || "Untitled"}`];
|
||||
|
||||
if (instructions?.trim()) {
|
||||
parts.push(instructions.trim());
|
||||
}
|
||||
|
||||
if (params && Object.keys(params).length) {
|
||||
parts.push(
|
||||
`## Parameters\n\n\`\`\`json\n${JSON.stringify(params, null, 2)}\n\`\`\``,
|
||||
);
|
||||
}
|
||||
|
||||
parts.push(
|
||||
`Work on this until it's done. Then reply to the user with the result: what you did, and anything they need to know or do.`,
|
||||
);
|
||||
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
+35
-12
@@ -1,18 +1,23 @@
|
||||
import bunext from "@moduletrace/bunext";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import type { BotJobKind } from "@/src/dict/bot-queue-dict";
|
||||
import claimBotJobs from "@/src/functions/backend/db/bots-queue/claim-bot-jobs";
|
||||
import releaseWorkerBotJobs from "@/src/functions/backend/db/bots-queue/release-worker-bot-jobs";
|
||||
import { logError } from "@/src/functions/backend/logger";
|
||||
import processBotJob from "./process-bot-job";
|
||||
import reportLostBotJobs from "./report-lost-bot-jobs";
|
||||
import { setBotsQueueWaker } from "./wake-bots-queue";
|
||||
|
||||
const worker = AppData.BotQueueWorker;
|
||||
let running = 0;
|
||||
const running = { task: 0, reply: 0 };
|
||||
let polling = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
/**
|
||||
* Starts the `bots_queue` handler: releases jobs a restart cut off, then
|
||||
* polls every `AppData.BotQueuePollMs`, reporting lost jobs and claiming
|
||||
* up to `AppData.BotQueueConcurrency` at once. Gated in production to the
|
||||
* up to `AppData.BotQueueConcurrency` tasks and, on their own slots,
|
||||
* `AppData.BotQueueReplyConcurrency` chat replies. Gated in production to the
|
||||
* `MAIN_SERVER` container, like cron.
|
||||
*/
|
||||
export default async function botsQueue() {
|
||||
@@ -28,20 +33,27 @@ export default async function botsQueue() {
|
||||
bunext.bunextLog.info(`Bots queue: released ${released} cut-off job(s)`);
|
||||
}
|
||||
|
||||
setBotsQueueWaker({ waker: wake });
|
||||
poll();
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
await reportLostBotJobs();
|
||||
/**
|
||||
* Polls now, unless a poll is already running (`wakeBotsQueue`).
|
||||
*/
|
||||
function wake() {
|
||||
if (polling) return;
|
||||
|
||||
const free = AppData.BotQueueConcurrency - running;
|
||||
clearTimeout(timer);
|
||||
poll();
|
||||
}
|
||||
|
||||
if (free > 0) {
|
||||
const jobs = await claimBotJobs({ worker, limit: free });
|
||||
async function claimAndRun({ kind, slots }: { kind: BotJobKind; slots: number }) {
|
||||
const free = slots - running[kind];
|
||||
|
||||
for (const job of jobs) {
|
||||
running++;
|
||||
if (free < 1) return;
|
||||
|
||||
for (const job of await claimBotJobs({ worker, limit: free, kind })) {
|
||||
running[kind]++;
|
||||
|
||||
processBotJob({ job, worker })
|
||||
.catch((error) =>
|
||||
@@ -51,15 +63,26 @@ async function poll() {
|
||||
}).catch(() => {}),
|
||||
)
|
||||
.finally(() => {
|
||||
running--;
|
||||
running[kind]--;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
polling = true;
|
||||
|
||||
try {
|
||||
await reportLostBotJobs();
|
||||
|
||||
await claimAndRun({ kind: "reply", slots: AppData.BotQueueReplyConcurrency });
|
||||
await claimAndRun({ kind: "task", slots: AppData.BotQueueConcurrency });
|
||||
} catch (error) {
|
||||
await logError({ title: "Bots queue poll failed", error }).catch(
|
||||
() => {},
|
||||
);
|
||||
} finally {
|
||||
setTimeout(poll, AppData.BotQueuePollMs);
|
||||
polling = false;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(poll, AppData.BotQueuePollMs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import path from "node:path";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import grabMessagesMedia, {
|
||||
type MessageMediaRow,
|
||||
} from "@/src/functions/backend/db/bots/grab-messages-media";
|
||||
import type { HarnessContentPart } from "@/src/harness/types";
|
||||
import type { BotMessageWithAttachments } from "@/src/types";
|
||||
import grabDirNames from "@/src/utils/grab-dir-names";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* The conversation a reply reads, oldest first.
|
||||
*/
|
||||
messages: BotMessageWithAttachments[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The files of the latest few messages that have any
|
||||
* (`AppData.BotReplyAttachmentMessages`), as model input by message id:
|
||||
* images and audio as themselves (a model that can't take them gets them
|
||||
* read first), documents as their text, videos as a note. Older files are
|
||||
* only named (`botMessagesToHarness`).
|
||||
*/
|
||||
export default async function loadAttachmentParts({ messages }: Params) {
|
||||
const recent = messages
|
||||
.filter((message) => message.attachments?.length)
|
||||
.slice(-AppData.BotReplyAttachmentMessages)
|
||||
.map((message) => Number(message.id));
|
||||
|
||||
const media = await grabMessagesMedia({ message_ids: recent });
|
||||
const parts = new Map<number, HarnessContentPart[]>();
|
||||
|
||||
for (const [message_id, files] of media) {
|
||||
parts.set(
|
||||
message_id,
|
||||
(await Promise.all(files.map((file) => fileToParts({ file })))).flat(),
|
||||
);
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
async function fileToParts({ file }: { file: MessageMediaRow }): Promise<HarnessContentPart[]> {
|
||||
const name = file.title || `file ${file.id}`;
|
||||
|
||||
if (file.media_type == "document") {
|
||||
const text = file.text_content?.trim();
|
||||
|
||||
if (!text) {
|
||||
return [{ type: "text", text: `[Attached file "${name}": its text couldn't be read.]` }];
|
||||
}
|
||||
|
||||
const cut = text.length > AppData.BotAttachmentMaxTextChars;
|
||||
|
||||
return [
|
||||
{
|
||||
type: "text",
|
||||
text: `Attached file "${name}":\n\n${text.slice(0, AppData.BotAttachmentMaxTextChars)}${cut ? `\n\n[The rest of "${name}" was cut: it's ${text.length} characters long.]` : ""}`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (file.media_type == "video") {
|
||||
return [{ type: "text", text: `[Attached video "${name}": videos can't be read yet.]` }];
|
||||
}
|
||||
|
||||
const disk = Bun.file(path.join(grabDirNames().DATA_DIR, file.media_write_path || ""));
|
||||
|
||||
if (!file.media_write_path || !(await disk.exists())) {
|
||||
return [{ type: "text", text: `[Attached file "${name}" is missing on the server.]` }];
|
||||
}
|
||||
|
||||
const base64 = Buffer.from(await disk.arrayBuffer()).toString("base64");
|
||||
|
||||
if (file.media_type == "image") {
|
||||
return [
|
||||
{ type: "text", text: `[Attached image "${name}"]` },
|
||||
{ type: "image", url: `data:image/${file.mime_type || "jpeg"};base64,${base64}`, name },
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{ type: "text", text: `[Attached audio "${name}"]` },
|
||||
{ type: "audio", data: base64, format: file.mime_type || "mp3", name },
|
||||
];
|
||||
}
|
||||
@@ -6,6 +6,9 @@ import failBotJob from "@/src/functions/backend/db/bots-queue/fail-bot-job";
|
||||
import updateBotJob from "@/src/functions/backend/db/bots-queue/update-bot-job";
|
||||
import { logError } from "@/src/functions/backend/logger";
|
||||
import createKeyLock from "@/src/utils/create-key-lock";
|
||||
import HarnessError from "@/src/harness/harness-error";
|
||||
import deferBotJob from "@/src/functions/backend/db/bots-queue/defer-bot-job";
|
||||
import { BotWaitingStatusLine } from "@/src/dict/bot-queue-dict";
|
||||
import BotJobFatalError from "./bot-job-fatal-error";
|
||||
import pushBotNotice from "./push-bot-notice";
|
||||
import runBotJob from "./run-bot-job";
|
||||
@@ -17,6 +20,11 @@ import type { BotJobContext, BotJobRunner } from "./types";
|
||||
*/
|
||||
const computer_lock = createKeyLock();
|
||||
|
||||
/**
|
||||
* One reply per bot at a time, so replies never cross in the chat.
|
||||
*/
|
||||
const reply_lock = createKeyLock();
|
||||
|
||||
/**
|
||||
* Renew the lock well inside the timeout, so one slow write can't lose it.
|
||||
*/
|
||||
@@ -86,7 +94,10 @@ export default async function processBotJob({
|
||||
});
|
||||
};
|
||||
|
||||
const outcome = job.computer_id
|
||||
const outcome =
|
||||
job.kind == "reply"
|
||||
? await reply_lock.run({ key: bot_id, task: run })
|
||||
: job.computer_id
|
||||
? await computer_lock.run({ key: job.computer_id, task: run })
|
||||
: await run();
|
||||
|
||||
@@ -99,11 +110,35 @@ export default async function processBotJob({
|
||||
worker,
|
||||
result: outcome.result,
|
||||
needs_you: outcome.needs_you,
|
||||
silent: outcome.silent,
|
||||
})),
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
/**
|
||||
* Every model is cooling down (rate limits): nothing was tried, so
|
||||
* wait for the first to come back without using an attempt.
|
||||
*/
|
||||
if (error instanceof HarnessError && error.resting) {
|
||||
await deferBotJob({
|
||||
job_id,
|
||||
worker,
|
||||
until: error.retry_at || Date.now() + AppData.HarnessCooldownMs,
|
||||
refund: true,
|
||||
});
|
||||
|
||||
await pushBotNotice({
|
||||
bot_id,
|
||||
bot: await setBotFace({
|
||||
bot_id,
|
||||
face: "asleep",
|
||||
status_line: BotWaitingStatusLine,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const failed = await failBotJob({
|
||||
job_id,
|
||||
worker,
|
||||
@@ -120,6 +155,18 @@ export default async function processBotJob({
|
||||
if (failed?.notice) {
|
||||
await pushBotNotice({ bot_id, ...failed.notice });
|
||||
}
|
||||
|
||||
/**
|
||||
* A retry no sooner than the provider said to come back.
|
||||
*/
|
||||
if (
|
||||
failed?.status == "pending" &&
|
||||
error instanceof HarnessError &&
|
||||
error.retryable &&
|
||||
error.retry_at
|
||||
) {
|
||||
await deferBotJob({ job_id, worker, until: error.retry_at });
|
||||
}
|
||||
} finally {
|
||||
clearInterval(heartbeat);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
BUN_SQLITE_COBBLE_BOT_MESSAGES,
|
||||
BUN_SQLITE_COBBLE_BOTS,
|
||||
} from "@/db/types/db";
|
||||
import grabActiveBotTask from "@/src/functions/backend/db/bots/grab-active-bot-task";
|
||||
import pushToBotOwner from "./push-to-bot-owner";
|
||||
|
||||
type Params = {
|
||||
@@ -12,6 +13,7 @@ type Params = {
|
||||
|
||||
/**
|
||||
* Pushes a new bot message and/or face change to the owner's open tabs.
|
||||
* A face change carries the bot's active task, for the working-on panel.
|
||||
*/
|
||||
export default async function pushBotNotice({ bot_id, message, bot }: Params) {
|
||||
if (message) {
|
||||
@@ -24,7 +26,10 @@ export default async function pushBotNotice({ bot_id, message, bot }: Params) {
|
||||
if (bot) {
|
||||
await pushToBotOwner({
|
||||
bot_id,
|
||||
data: { event: "server:bot-updated", data: bot },
|
||||
data: {
|
||||
event: "server:bot-updated",
|
||||
data: { ...bot, task: await grabActiveBotTask({ bot_id }) },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,42 @@
|
||||
import type { BotJobRunner } from "./types";
|
||||
import grabBotById from "@/src/functions/backend/db/bots/grab-bot-by-id";
|
||||
import HarnessError from "@/src/harness/harness-error";
|
||||
import BotJobFatalError from "./bot-job-fatal-error";
|
||||
import runBotReply from "./run-bot-reply";
|
||||
import runBotTask from "./run-bot-task";
|
||||
import type { BotJobRunner } from "./types";
|
||||
|
||||
/**
|
||||
* Does a job's actual work: the bot follows `job.instructions` with its
|
||||
* full prompt (`grabBotPrompt`), on `job.computer_id` if set, and returns
|
||||
* the message for the user. This is where the model layer plugs in; until
|
||||
* it exists every job fails at once and the user is told.
|
||||
* Does a job's actual work on the harness: a `reply` answers the user in
|
||||
* chat (`runBotReply`), a `task` works through its brief (`runBotTask`).
|
||||
*
|
||||
* Harness failures that waiting can fix (rate limits, outages) are thrown
|
||||
* as they are, so the queue retries with backoff; the rest are fatal.
|
||||
*/
|
||||
const runBotJob: BotJobRunner = async () => {
|
||||
throw new BotJobFatalError(`Bots can't run queued work yet.`);
|
||||
const runBotJob: BotJobRunner = async (context) => {
|
||||
const { job } = context;
|
||||
const bot = job.bot_id ? await grabBotById({ bot_id: job.bot_id }) : null;
|
||||
|
||||
if (!bot?.id || !bot.user_id) {
|
||||
throw new BotJobFatalError(`The bot for this job no longer exists.`);
|
||||
}
|
||||
|
||||
const owned = { ...bot, id: bot.id, user_id: bot.user_id };
|
||||
|
||||
try {
|
||||
return job.kind == "reply"
|
||||
? await runBotReply({ ...context, bot: owned })
|
||||
: await runBotTask({ ...context, bot: owned });
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
error instanceof HarnessError &&
|
||||
error.code != "aborted" &&
|
||||
!error.retryable
|
||||
) {
|
||||
throw new BotJobFatalError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export default runBotJob;
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { ChatReplyNote, CoordinatorReplyNote } from "@/src/data/harness-prompts";
|
||||
import grabBotMessages from "@/src/functions/backend/db/bots/grab-bot-messages";
|
||||
import grabBotPrompt from "@/src/functions/backend/db/bots/grab-bot-prompt";
|
||||
import runTextLoop from "@/src/harness/text/run-text-loop";
|
||||
import grabBotChatTools from "@/src/harness/tools/bot/grab-bot-chat-tools";
|
||||
import botMessagesToHarness from "./bot-messages-to-harness";
|
||||
import loadAttachmentParts from "./load-attachment-parts";
|
||||
import type { BotJobContext, BotJobOutcome } from "./types";
|
||||
|
||||
type Params = BotJobContext & {
|
||||
bot: BUN_SQLITE_COBBLE_BOTS & { id: number; user_id: number };
|
||||
};
|
||||
|
||||
/**
|
||||
* Answers the user in chat: the bot reads the recent conversation (and
|
||||
* the files of the latest messages, `loadAttachmentParts`) and
|
||||
* replies, with its chat tools (memory, instructions, start_task, and for
|
||||
* the coordinator delegate_task). Finishes silently when the last message
|
||||
* is already the bot's, i.e. an earlier reply answered everything.
|
||||
*/
|
||||
export default async function runBotReply({
|
||||
bot,
|
||||
job,
|
||||
signal,
|
||||
}: Params): Promise<BotJobOutcome> {
|
||||
const history = await grabBotMessages({
|
||||
bot_id: bot.id,
|
||||
limit: AppData.BotReplyHistoryMessages,
|
||||
});
|
||||
|
||||
if (history[history.length - 1]?.author != "user") {
|
||||
return { result: "", silent: true };
|
||||
}
|
||||
|
||||
const prompt = await grabBotPrompt({ bot_id: bot.id, user_id: bot.user_id });
|
||||
|
||||
const result = await runTextLoop({
|
||||
system: [
|
||||
prompt,
|
||||
ChatReplyNote,
|
||||
bot.kind == "coordinator" ? CoordinatorReplyNote : "",
|
||||
`Now: ${new Date().toUTCString()}.`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n"),
|
||||
messages: botMessagesToHarness({
|
||||
messages: history,
|
||||
parts: await loadAttachmentParts({ messages: history }),
|
||||
}),
|
||||
tools: await grabBotChatTools({
|
||||
bot,
|
||||
user_id: bot.user_id,
|
||||
message_id: job.message_id || undefined,
|
||||
}),
|
||||
max_steps: AppData.BotReplyMaxToolSteps,
|
||||
bot_id: bot.id,
|
||||
signal,
|
||||
});
|
||||
|
||||
return { result: result.text };
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { TaskRunNote } from "@/src/data/harness-prompts";
|
||||
import grabBotPrompt from "@/src/functions/backend/db/bots/grab-bot-prompt";
|
||||
import runTextLoop from "@/src/harness/text/run-text-loop";
|
||||
import openComputerTools from "@/src/harness/tools/computer/open-computer-tools";
|
||||
import grabBotTools from "@/src/harness/tools/bot/grab-bot-tools";
|
||||
import composeJobBrief from "./compose-job-brief";
|
||||
import type { BotJobContext, BotJobOutcome } from "./types";
|
||||
|
||||
type Params = BotJobContext & {
|
||||
bot: { id: number; user_id: number };
|
||||
};
|
||||
|
||||
/**
|
||||
* Works through a task: the bot follows its brief with its full prompt,
|
||||
* its own tools and, when the job has a computer, that computer's tools
|
||||
* (over MCP), until it answers. That answer is the report for the user.
|
||||
* Progress is saved after every model turn.
|
||||
*/
|
||||
export default async function runBotTask({
|
||||
bot,
|
||||
job,
|
||||
params,
|
||||
signal,
|
||||
update,
|
||||
}: Params): Promise<BotJobOutcome> {
|
||||
const computer = job.computer_id
|
||||
? await openComputerTools({ computer_id: job.computer_id, bot_id: bot.id, signal })
|
||||
: null;
|
||||
|
||||
try {
|
||||
const prompt = await grabBotPrompt({ bot_id: bot.id, user_id: bot.user_id });
|
||||
|
||||
const result = await runTextLoop({
|
||||
system: [
|
||||
prompt,
|
||||
TaskRunNote,
|
||||
computer
|
||||
? `You're working on the computer "${computer.name}" (a Linux sandbox with a shell, files, code runners and a browser). Use its tools; don't ask the user to run anything.`
|
||||
: `You have no computer for this task.`,
|
||||
computer?.envs.length
|
||||
? `Environment variables set on this computer (use them by name, e.g. $KEY; never print their values):\n${computer.envs
|
||||
.map((env) => `- ${env.key}${env.description ? `: ${env.description}` : ""}`)
|
||||
.join("\n")}`
|
||||
: "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n"),
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: composeJobBrief({
|
||||
title: job.title,
|
||||
instructions: job.instructions,
|
||||
params,
|
||||
}),
|
||||
},
|
||||
],
|
||||
tools: [
|
||||
...grabBotTools({ user_id: bot.user_id, bot_id: bot.id }),
|
||||
...(computer?.tools || []),
|
||||
],
|
||||
bot_id: bot.id,
|
||||
signal,
|
||||
onStep: async ({ text, tool_calls }) => {
|
||||
await update({
|
||||
progress: tool_calls.length
|
||||
? `Using ${tool_calls.map((call) => call.name).join(", ")}`
|
||||
: text?.slice(0, 500) || undefined,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return { result: computer ? computer.redact(result.text) : result.text };
|
||||
} finally {
|
||||
await computer?.close();
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,10 @@ export type BotJobOutcome = {
|
||||
*/
|
||||
result: string;
|
||||
needs_you?: boolean;
|
||||
/**
|
||||
* Finish without posting `result` (a reply with nothing left to answer).
|
||||
*/
|
||||
silent?: boolean;
|
||||
};
|
||||
|
||||
export type BotJobRunner = (context: BotJobContext) => Promise<BotJobOutcome>;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
let wake: (() => void) | undefined;
|
||||
|
||||
/**
|
||||
* Lets the running queue handler register how to poll at once. Kept apart
|
||||
* from the handler so API routes can wake it without bundling it.
|
||||
*/
|
||||
export function setBotsQueueWaker({ waker }: { waker: () => void }) {
|
||||
wake = waker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls now instead of at the next tick, so a chat reply starts at once.
|
||||
* Does nothing where the queue doesn't run (the next poll on the main
|
||||
* server picks the job up).
|
||||
*/
|
||||
export default function wakeBotsQueue() {
|
||||
wake?.();
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import bunext from "@moduletrace/bunext";
|
||||
import cronHeartbeat from "./heartbeat";
|
||||
import pruneBotsQueue from "./prune-bots-queue";
|
||||
import cronSyncHarnessModels from "./sync-harness-models";
|
||||
import registerCronJob from "./register-cron-job";
|
||||
|
||||
export default function cron() {
|
||||
@@ -22,4 +23,11 @@ export default function cron() {
|
||||
schedule: "@daily",
|
||||
task: pruneBotsQueue,
|
||||
});
|
||||
|
||||
registerCronJob({
|
||||
title: "sync-harness-models",
|
||||
schedule: "@hourly",
|
||||
task: cronSyncHarnessModels,
|
||||
run_on_start: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import bunext from "@moduletrace/bunext";
|
||||
import syncAllHarnessModels from "@/src/functions/backend/db/harness/sync-all-harness-models";
|
||||
|
||||
/**
|
||||
* Refreshes every enabled provider's model list. Free models on OpenRouter
|
||||
* come and go, and automatic picks follow them from this list.
|
||||
*/
|
||||
export default async function cronSyncHarnessModels() {
|
||||
const results = await syncAllHarnessModels();
|
||||
|
||||
for (const result of results) {
|
||||
if (result.error) {
|
||||
bunext.bunextLog.error(
|
||||
`Cron: model sync for provider #${result.provider_id} failed: ${result.error}`,
|
||||
);
|
||||
} else {
|
||||
bunext.bunextLog.info(
|
||||
`Cron: provider #${result.provider_id} lists ${result.count} model(s)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
+66
-1
@@ -56,11 +56,76 @@ export const AppData = {
|
||||
BotQueueWorker: "main",
|
||||
BotQueuePollMs: 3000,
|
||||
/**
|
||||
* Jobs run at once. Jobs on the same computer always run one at a time.
|
||||
* Tasks run at once. Tasks on the same computer always run one at a time.
|
||||
*/
|
||||
BotQueueConcurrency: 3,
|
||||
/**
|
||||
* Chat replies run at once, on their own slots so they never wait
|
||||
* behind long tasks. One per bot at a time.
|
||||
*/
|
||||
BotQueueReplyConcurrency: 3,
|
||||
/**
|
||||
* Finished jobs are deleted after this many days.
|
||||
*/
|
||||
BotQueueRetentionDays: 30,
|
||||
/**
|
||||
* Automatic model picks (`rankAutoModels`). A free model from outside
|
||||
* `AutoModelVendorPreference` must have at least this much context, and
|
||||
* this intelligence index or, unscored, this many billion parameters.
|
||||
*/
|
||||
HarnessAutoMinContext: 32000,
|
||||
HarnessAutoMinScore: 12,
|
||||
HarnessAutoMinSizeB: 20,
|
||||
/**
|
||||
* Models tried per harness call before giving up (pinned plus
|
||||
* automatic fallbacks).
|
||||
*/
|
||||
HarnessMaxCandidates: 5,
|
||||
HarnessRequestTimeoutMs: 3 * 60 * 1000,
|
||||
/**
|
||||
* A model that rate limits or fails is skipped for this long, unless
|
||||
* the provider says when to come back (capped at the max).
|
||||
*/
|
||||
HarnessCooldownMs: 60 * 1000,
|
||||
HarnessMaxCooldownMs: 24 * 60 * 60 * 1000,
|
||||
/**
|
||||
* Model turns in one text run before it must stop and report.
|
||||
*/
|
||||
HarnessMaxToolSteps: 30,
|
||||
/**
|
||||
* Longest tool result handed back to the model.
|
||||
*/
|
||||
HarnessToolResultMaxChars: 20000,
|
||||
/**
|
||||
* Upload cap for audio sent to transcription.
|
||||
*/
|
||||
HarnessMaxAudioBytes: 25 * 1024 * 1024,
|
||||
/**
|
||||
* Connecting to an MCP server (a computer) gives up after this long.
|
||||
*/
|
||||
McpConnectTimeoutMs: 15 * 1000,
|
||||
/**
|
||||
* One MCP tool call gives up after this long without progress.
|
||||
*/
|
||||
McpToolTimeoutMs: 5 * 60 * 1000,
|
||||
/**
|
||||
* How much of the conversation a bot sees when it replies.
|
||||
*/
|
||||
BotReplyHistoryMessages: 40,
|
||||
/**
|
||||
* Tool turns a chat reply may take. Long work goes to a task instead.
|
||||
*/
|
||||
BotReplyMaxToolSteps: 8,
|
||||
/**
|
||||
* Chat attachments: per file, per message, and how much of a
|
||||
* document's text a reply reads.
|
||||
*/
|
||||
BotAttachmentMaxBytes: 20 * 1024 * 1024,
|
||||
BotAttachmentMaxFiles: 6,
|
||||
BotAttachmentMaxTextChars: 20000,
|
||||
/**
|
||||
* Only this many of the latest messages with files hand the files
|
||||
* themselves to a reply; older ones are named.
|
||||
*/
|
||||
BotReplyAttachmentMessages: 4,
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Prompts the helper harnesses send. The text model gets their output in
|
||||
* place of the media, so they must leave nothing out that it may need.
|
||||
*/
|
||||
export const ReadImagePrompt = `Describe this image for someone who can't see it and has to act on it.
|
||||
- Copy all visible text exactly, keeping its layout where it matters (tables, forms, code, menus).
|
||||
- Describe what is shown, its structure, and anything notable or unusual.
|
||||
- For a screenshot, name the app or page, its state, and every control a user could click.
|
||||
- Don't guess at what you can't see. Plain text only, no preamble.`;
|
||||
|
||||
export const TranscribeAudioPrompt = `Transcribe this audio exactly as spoken, in the language spoken. Output only the transcript: no preamble, no notes, no timestamps.`;
|
||||
|
||||
/**
|
||||
* Added to the text model's prompt when a message part was read by another
|
||||
* harness, so it knows it is working from a description.
|
||||
*/
|
||||
export const MediaReadNote = `Some attachments in this conversation were read for you by another model; you see its reading in [brackets] instead of the file.`;
|
||||
|
||||
/**
|
||||
* Added to a bot's prompt when it answers the user in chat.
|
||||
*/
|
||||
export const ChatReplyNote = `# Replying in chat
|
||||
|
||||
You are chatting with the user. Answer their latest messages directly and briefly, in markdown.
|
||||
|
||||
- Quick questions and small changes: answer now.
|
||||
- Work that takes more than a moment, or anything on a computer (running commands, editing files, browsing): call start_task with a clear brief, then tell the user in a sentence that you're on it. The result is posted here when it's done.
|
||||
- When a message mixes both, answer the quick parts in this reply and start a task for the rest.
|
||||
- When the user tells you something worth keeping, save it with update_memory. When they correct how you work, update your instructions with update_instructions.
|
||||
- Never claim you did something you didn't do.`;
|
||||
|
||||
/**
|
||||
* Added to the coordinator's chat prompt: it hands work to the crew.
|
||||
*/
|
||||
export const CoordinatorReplyNote = `As the coordinator, hand work that fits a crew member's role to them with delegate_task. They post the result in their own conversation, so tell the user who is on it.`;
|
||||
|
||||
/**
|
||||
* Added to a bot's prompt when it works through a queued task.
|
||||
*/
|
||||
export const TaskRunNote = `# Working on a task
|
||||
|
||||
You are working through a task on your own; the user isn't watching. Use your tools until the task is done, then answer with the report for the user: what you did, what you found, and anything left for them. Keep the report short and in markdown. If you're blocked on something only the user can give you, stop and ask for it in the report.`;
|
||||
@@ -9,3 +9,25 @@ export const BotJobCancelledError = "Cancelled";
|
||||
* silent past the lock timeout on its last attempt.
|
||||
*/
|
||||
export const BotJobLockTimedOutError = "Lock timed out";
|
||||
|
||||
/**
|
||||
* What a queued job is. A `task` is long-running work with a row in the
|
||||
* working-on panel; a `reply` answers the user's latest messages in chat.
|
||||
*/
|
||||
export const BotJobKinds = [
|
||||
{ title: "Task", value: "task" },
|
||||
{ title: "Reply", value: "reply" },
|
||||
] as const;
|
||||
|
||||
export type BotJobKind = (typeof BotJobKinds)[number]["value"];
|
||||
|
||||
/**
|
||||
* A reply job's title, and the bot's status line while it runs. The chat
|
||||
* shows a thinking row while a bot has it.
|
||||
*/
|
||||
export const BotReplyJobTitle = "Thinking";
|
||||
|
||||
/**
|
||||
* The bot's status line while its job waits for a rate-limited model.
|
||||
*/
|
||||
export const BotWaitingStatusLine = "Waiting for a free model";
|
||||
|
||||
@@ -70,3 +70,20 @@ export const ContainerPurposes = [
|
||||
] as const;
|
||||
|
||||
export type ContainerPurpose = (typeof ContainerPurposes)[number]["value"];
|
||||
|
||||
/**
|
||||
* Path of the AIO sandbox's MCP endpoint, on the computer's address.
|
||||
*/
|
||||
export const ComputerMcpPath = "/mcp";
|
||||
|
||||
/**
|
||||
* Sandbox MCP tools bots don't get: noise (cdp urls), skills they have no
|
||||
* use for, and raw display control that needs pixel coordinates (the page
|
||||
* tools cover the same ground by element).
|
||||
*/
|
||||
export const ComputerMcpExcludedTools = [
|
||||
"browser_get_info",
|
||||
"sandbox_load_skill",
|
||||
"browser_gui_screenshot",
|
||||
"browser_gui_execute_action",
|
||||
] as const;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { HarnessProviderKinds } from "./harness-provider-dict";
|
||||
|
||||
/**
|
||||
* Where an env applies. A computer's env beats a workspace's, which beats
|
||||
* a general one.
|
||||
*/
|
||||
export const EnvParadigms = [
|
||||
{
|
||||
title: "General",
|
||||
value: "general",
|
||||
},
|
||||
{
|
||||
title: "Workspace",
|
||||
value: "workspace",
|
||||
},
|
||||
{
|
||||
title: "Computer",
|
||||
value: "computer",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type EnvParadigm = (typeof EnvParadigms)[number]["value"];
|
||||
|
||||
export type KnownEnv = {
|
||||
key: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
/**
|
||||
* Hidden once saved; only replaced, never shown.
|
||||
*/
|
||||
secret: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Envs the app itself reads (`grabEnv`), set in Settings, under
|
||||
* Environment. They are general only and never go to computers. Users add
|
||||
* their own keys beside these for bots and computers to use.
|
||||
*
|
||||
* Not here, and never in the `envs` table: `ENCRYPTION_KEY` and
|
||||
* `ENCRYPTION_SALT` (they encrypt the table), and `PORT`, `DATA_DIR` and
|
||||
* `MAIN_SERVER` (read before the database opens). Those stay in the
|
||||
* process environment.
|
||||
*/
|
||||
export const EnvDict: readonly KnownEnv[] = [
|
||||
{
|
||||
key: "MAIN_COMPUTER_URL",
|
||||
description: `The main computer's address, for a server running outside docker (e.g. http://localhost:8170). Empty uses the docker network.`,
|
||||
required: false,
|
||||
secret: false,
|
||||
},
|
||||
...HarnessProviderKinds.flatMap((provider) =>
|
||||
provider.key_env
|
||||
? [
|
||||
{
|
||||
key: provider.key_env,
|
||||
description: `${provider.title} key, used when the provider has no key of its own.`,
|
||||
required: false,
|
||||
secret: true,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
];
|
||||
|
||||
/**
|
||||
* Keys that must stay in the process environment.
|
||||
*/
|
||||
export const ProcessOnlyEnvKeys = [
|
||||
"ENCRYPTION_KEY",
|
||||
"ENCRYPTION_SALT",
|
||||
"PORT",
|
||||
"DATA_DIR",
|
||||
"MAIN_SERVER",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* What an env key may look like: a shell variable name.
|
||||
*/
|
||||
export const EnvKeyRegex = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* The harnesses: what a model is used for. Each harness has one model
|
||||
* setting (`harness_settings`), automatic by default. Text is the brain and
|
||||
* can't be turned off; the others are helpers it calls on.
|
||||
*/
|
||||
export const Harnesses = [
|
||||
{
|
||||
title: "Text",
|
||||
value: "text",
|
||||
description: `Chats, plans and works on computers. Every bot thinks with it.`,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
title: "Media reading",
|
||||
value: "vision",
|
||||
description: `Reads images, scans and screenshots for the text model when it can't see them itself.`,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
title: "Transcription",
|
||||
value: "transcription",
|
||||
description: `Turns voice messages and recordings into text.`,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
title: "Speech",
|
||||
value: "speech",
|
||||
description: `Reads bot replies aloud.`,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
title: "Image generation",
|
||||
value: "image",
|
||||
description: `Makes images when a bot is asked for one.`,
|
||||
required: false,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type HarnessKind = (typeof Harnesses)[number]["value"];
|
||||
|
||||
/**
|
||||
* Vendors preferred for automatic picks, best first. Free models from any
|
||||
* other vendor come after these, and only if they look competent.
|
||||
*/
|
||||
export const AutoModelVendorPreference = [
|
||||
"deepseek",
|
||||
"qwen",
|
||||
"nvidia",
|
||||
"liquid",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Routers that pick a model themselves (e.g. `openrouter/free`). Used as
|
||||
* the last automatic pick, after every named model.
|
||||
*/
|
||||
export const AutoModelRouterSlugs = ["openrouter/free"];
|
||||
|
||||
/**
|
||||
* Models with one narrow job (moderation, embeddings, ranking). Never
|
||||
* picked automatically.
|
||||
*/
|
||||
export const SpecialistModelPattern =
|
||||
/guard|safety|moderation|embed|rerank|classif/i;
|
||||
|
||||
/**
|
||||
* Names of small variants, ranked after their bigger siblings.
|
||||
*/
|
||||
export const SmallModelPattern =
|
||||
/(?:^|[-_.:/])(nano|mini|small|lite|lightning|tiny|micro|xs)(?=$|[-_.:])/i;
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Model providers a super admin can plug in. Every one is called through
|
||||
* its OpenAI-compatible API for now (`src/harness/providers/`); a provider
|
||||
* that needs its own API gets its own adapter there.
|
||||
*
|
||||
* - `key_env`: environment variable used when no key is saved
|
||||
* - `catalog`: how its models are listed. `openrouter` gives modalities,
|
||||
* tools support and prices; `openai-compatible` gives only ids, so
|
||||
* capabilities are guessed from the names
|
||||
* - `local`: runs on the user's own hardware, so its models count as free
|
||||
* - `image_path`: image generation endpoint
|
||||
*/
|
||||
export const HarnessProviderKinds = [
|
||||
{
|
||||
title: "OpenRouter",
|
||||
value: "openrouter",
|
||||
base_url: "https://openrouter.ai/api/v1",
|
||||
needs_key: true,
|
||||
key_env: "OPENROUTER_API_KEY",
|
||||
catalog: "openrouter",
|
||||
local: false,
|
||||
image_path: "/images",
|
||||
},
|
||||
{
|
||||
title: "OpenAI",
|
||||
value: "openai",
|
||||
base_url: "https://api.openai.com/v1",
|
||||
needs_key: true,
|
||||
key_env: "OPENAI_API_KEY",
|
||||
catalog: "openai-compatible",
|
||||
local: false,
|
||||
image_path: "/images/generations",
|
||||
},
|
||||
{
|
||||
title: "Anthropic",
|
||||
value: "anthropic",
|
||||
base_url: "https://api.anthropic.com/v1",
|
||||
needs_key: true,
|
||||
key_env: "ANTHROPIC_API_KEY",
|
||||
catalog: "openai-compatible",
|
||||
local: false,
|
||||
image_path: "/images/generations",
|
||||
},
|
||||
{
|
||||
title: "Google Gemini",
|
||||
value: "google",
|
||||
base_url: "https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
needs_key: true,
|
||||
key_env: "GEMINI_API_KEY",
|
||||
catalog: "openai-compatible",
|
||||
local: false,
|
||||
image_path: "/images/generations",
|
||||
},
|
||||
{
|
||||
title: "DeepSeek",
|
||||
value: "deepseek",
|
||||
base_url: "https://api.deepseek.com",
|
||||
needs_key: true,
|
||||
key_env: "DEEPSEEK_API_KEY",
|
||||
catalog: "openai-compatible",
|
||||
local: false,
|
||||
image_path: "/images/generations",
|
||||
},
|
||||
{
|
||||
title: "Groq",
|
||||
value: "groq",
|
||||
base_url: "https://api.groq.com/openai/v1",
|
||||
needs_key: true,
|
||||
key_env: "GROQ_API_KEY",
|
||||
catalog: "openai-compatible",
|
||||
local: false,
|
||||
image_path: "/images/generations",
|
||||
},
|
||||
{
|
||||
title: "Mistral",
|
||||
value: "mistral",
|
||||
base_url: "https://api.mistral.ai/v1",
|
||||
needs_key: true,
|
||||
key_env: "MISTRAL_API_KEY",
|
||||
catalog: "openai-compatible",
|
||||
local: false,
|
||||
image_path: "/images/generations",
|
||||
},
|
||||
{
|
||||
title: "xAI",
|
||||
value: "xai",
|
||||
base_url: "https://api.x.ai/v1",
|
||||
needs_key: true,
|
||||
key_env: "XAI_API_KEY",
|
||||
catalog: "openai-compatible",
|
||||
local: false,
|
||||
image_path: "/images/generations",
|
||||
},
|
||||
{
|
||||
title: "Ollama",
|
||||
value: "ollama",
|
||||
base_url: "http://localhost:11434/v1",
|
||||
needs_key: false,
|
||||
key_env: "",
|
||||
catalog: "openai-compatible",
|
||||
local: true,
|
||||
image_path: "/images/generations",
|
||||
},
|
||||
{
|
||||
title: "Other (OpenAI-compatible)",
|
||||
value: "custom",
|
||||
base_url: "",
|
||||
needs_key: false,
|
||||
key_env: "",
|
||||
catalog: "openai-compatible",
|
||||
local: false,
|
||||
image_path: "/images/generations",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type HarnessProviderKind = (typeof HarnessProviderKinds)[number]["value"];
|
||||
|
||||
export type HarnessProviderPreset = (typeof HarnessProviderKinds)[number];
|
||||
|
||||
/**
|
||||
* Created on first run so the automatic free picks work out of the box.
|
||||
*/
|
||||
export const DefaultHarnessProviderKind: HarnessProviderKind = "openrouter";
|
||||
@@ -11,6 +11,10 @@ export const MediaTypes = [
|
||||
title: "Document",
|
||||
value: "document",
|
||||
},
|
||||
{
|
||||
title: "Audio",
|
||||
value: "audio",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const MediaParadigms = [
|
||||
@@ -26,4 +30,8 @@ export const MediaParadigms = [
|
||||
title: "Event",
|
||||
value: "event",
|
||||
},
|
||||
{
|
||||
title: "Bot Attachment",
|
||||
value: "bot-attachment",
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import type { TableType } from "@/src/types";
|
||||
|
||||
/**
|
||||
* Tables holding secrets (password hashes, pending login codes). They are
|
||||
* never reachable through the admin CRUD API, not even by super admins.
|
||||
* Tables holding secrets (password hashes, pending login codes, model
|
||||
* provider keys, envs). They are never reachable through the admin CRUD API, not
|
||||
* even by super admins.
|
||||
*/
|
||||
export const PrivateTables: TableType[] = ["user_passwords", "sso_login_codes"];
|
||||
export const PrivateTables: TableType[] = [
|
||||
"user_passwords",
|
||||
"sso_login_codes",
|
||||
"harness_providers",
|
||||
"envs",
|
||||
];
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import {
|
||||
CpuAi,
|
||||
KeyRound,
|
||||
SlidersHorizontal,
|
||||
UserRound,
|
||||
Variable,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -9,6 +11,10 @@ export type SettingsPage = {
|
||||
title: string;
|
||||
url: string;
|
||||
Icon: LucideIcon;
|
||||
/**
|
||||
* Shown and served to super admins only.
|
||||
*/
|
||||
super_admin_only?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -27,6 +33,18 @@ export const SettingsPages: SettingsPage[] = [
|
||||
url: "/admin/settings/change-password",
|
||||
Icon: KeyRound,
|
||||
},
|
||||
{
|
||||
title: "Models",
|
||||
url: "/admin/settings/models",
|
||||
Icon: CpuAi,
|
||||
super_admin_only: true,
|
||||
},
|
||||
{
|
||||
title: "Environment",
|
||||
url: "/admin/settings/environment",
|
||||
Icon: Variable,
|
||||
super_admin_only: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const SettingsDefaultPath = "/admin/settings/general";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BotJobKind } from "@/src/dict/bot-queue-dict";
|
||||
import type {
|
||||
BUN_SQLITE_COBBLE_BOTS_QUEUE,
|
||||
BunSQLiteTables,
|
||||
@@ -11,21 +12,43 @@ type Params = {
|
||||
*/
|
||||
worker: string;
|
||||
limit?: number;
|
||||
/**
|
||||
* Only jobs of this kind, so replies and tasks get their own slots.
|
||||
*/
|
||||
kind: BotJobKind;
|
||||
};
|
||||
|
||||
/**
|
||||
* Claims the next pending jobs for a queue handler. Jobs whose handler
|
||||
* stopped sending updates are released back to the queue first, so a lost
|
||||
* job is picked up again (resume from its `checkpoint`).
|
||||
* Claims the next pending jobs of one kind for a queue handler. Jobs
|
||||
* whose handler stopped sending updates are released back to the queue
|
||||
* first, so a lost job is picked up again (resume from its `checkpoint`).
|
||||
*
|
||||
* bun-sqlite's `queue.claim` takes no filter, so the claim is its same
|
||||
* single statement (atomic: two workers never claim one job) plus `kind`.
|
||||
*/
|
||||
export default async function claimBotJobs({ worker, limit = 1 }: Params) {
|
||||
const res = await BunSQLite.queue.claim<
|
||||
export default async function claimBotJobs({ worker, limit = 1, kind }: Params) {
|
||||
const released = await BunSQLite.queue.release_stale<
|
||||
BUN_SQLITE_COBBLE_BOTS_QUEUE,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "bots_queue",
|
||||
worker,
|
||||
limit,
|
||||
>({ table: "bots_queue" });
|
||||
|
||||
if (!released.success) {
|
||||
throw new Error(`Couldn't release stale jobs: ${released.msg || released.error}`);
|
||||
}
|
||||
|
||||
if (limit < 1) return [];
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS_QUEUE>({
|
||||
sql: `UPDATE bots_queue SET status = 'processing', attempts = attempts + 1,
|
||||
locked_at = ?, locked_by = ?, updated_at = ?
|
||||
WHERE id IN (
|
||||
SELECT id FROM bots_queue
|
||||
WHERE status = 'pending' AND available_at <= ? AND kind = ?
|
||||
ORDER BY id LIMIT ?
|
||||
) RETURNING *`,
|
||||
values: [now, worker, now, now, kind, Math.floor(limit)],
|
||||
});
|
||||
|
||||
if (!res.success) {
|
||||
|
||||
@@ -17,12 +17,17 @@ type Params = {
|
||||
* Flag the message as needing the user.
|
||||
*/
|
||||
needs_you?: boolean;
|
||||
/**
|
||||
* Finish without posting a message.
|
||||
*/
|
||||
silent?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Marks a job done, closes its task and sends the completion message back
|
||||
* to the user in the bot's conversation. The bot shows done (or needs-you)
|
||||
* unless it has other jobs open. Throws if this handler no longer holds
|
||||
* to the user in the bot's conversation. Unless it has other jobs open,
|
||||
* the bot shows needs-you if asked, else done after a task and idle after
|
||||
* a reply. Throws if this handler no longer holds
|
||||
* the job.
|
||||
*/
|
||||
export default async function completeBotJob({
|
||||
@@ -30,6 +35,7 @@ export default async function completeBotJob({
|
||||
worker,
|
||||
result,
|
||||
needs_you,
|
||||
silent,
|
||||
}: Params) {
|
||||
const job = await grabLockedBotJob({ job_id, worker });
|
||||
|
||||
@@ -60,7 +66,9 @@ export default async function completeBotJob({
|
||||
});
|
||||
}
|
||||
|
||||
const message = await insertBotMessage({
|
||||
const message = silent
|
||||
? null
|
||||
: await insertBotMessage({
|
||||
bot_id: job.bot_id,
|
||||
text: result,
|
||||
needs_you,
|
||||
@@ -74,7 +82,10 @@ export default async function completeBotJob({
|
||||
})
|
||||
: (await countOpenBotJobs({ bot_id: job.bot_id }))
|
||||
? null
|
||||
: await setBotFace({ bot_id: job.bot_id, face: "done" });
|
||||
: await setBotFace({
|
||||
bot_id: job.bot_id,
|
||||
face: job.kind == "reply" ? "idle" : "done",
|
||||
});
|
||||
|
||||
return { message, bot };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
|
||||
type Params = {
|
||||
job_id: number | string;
|
||||
worker: string;
|
||||
/**
|
||||
* Unix ms before which the job can't be claimed again.
|
||||
*/
|
||||
until: number;
|
||||
/**
|
||||
* Give back the attempt this claim used: nothing was tried.
|
||||
*/
|
||||
refund?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Holds a job back without failing it: a pending job whose `available_at`
|
||||
* is pushed to `until` (capped at `AppData.HarnessMaxCooldownMs` from
|
||||
* now), or, for a job still held by `worker`, released back to pending
|
||||
* that way. False when the job is neither.
|
||||
*/
|
||||
export default async function deferBotJob({ job_id, worker, until, refund }: Params) {
|
||||
const now = Date.now();
|
||||
const at = Math.min(Math.max(until, now), now + AppData.HarnessMaxCooldownMs);
|
||||
|
||||
const res = await BunSQLite.sql({
|
||||
sql: `UPDATE bots_queue SET status = 'pending', locked_at = NULL, locked_by = NULL,
|
||||
attempts = CASE WHEN ? AND status = 'processing' THEN MAX(attempts - 1, 0) ELSE attempts END,
|
||||
available_at = MAX(COALESCE(available_at, 0), ?), updated_at = ?
|
||||
WHERE id = ? AND (status = 'pending' OR (status = 'processing' AND locked_by = ?))
|
||||
RETURNING id`,
|
||||
values: [refund ? 1 : 0, at, now, job_id, worker],
|
||||
});
|
||||
|
||||
return Boolean(res.payload?.length);
|
||||
}
|
||||
@@ -4,11 +4,17 @@ import type {
|
||||
BUN_SQLITE_COBBLE_BOTS_QUEUE,
|
||||
BunSQLiteTables,
|
||||
} from "@/db/types/db";
|
||||
import type { BotJobKind } from "@/src/dict/bot-queue-dict";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import grabUserBot from "../bots/grab-user-bot";
|
||||
import grabBotComputers from "../computers/grab-bot-computers";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* `task` (the default) shows in the working-on panel; a `reply` answers
|
||||
* the user in chat and has no task.
|
||||
*/
|
||||
kind?: BotJobKind;
|
||||
user_id: number | string;
|
||||
bot_id: number | string;
|
||||
title: string;
|
||||
@@ -24,7 +30,7 @@ type Params = {
|
||||
message_id?: number | string;
|
||||
/**
|
||||
* The task for the working-on panel. One is created from `title` when
|
||||
* not given.
|
||||
* not given (replies have none).
|
||||
*/
|
||||
task_id?: number | string;
|
||||
/**
|
||||
@@ -44,6 +50,7 @@ type Params = {
|
||||
* every message needs a job; quick answers are replied to directly.
|
||||
*/
|
||||
export default async function enqueueBotJob({
|
||||
kind = "task",
|
||||
user_id,
|
||||
bot_id,
|
||||
title,
|
||||
@@ -76,7 +83,10 @@ export default async function enqueueBotJob({
|
||||
}
|
||||
}
|
||||
|
||||
const final_task_id = task_id || (await createJobTask({ bot_id: bot.id, title }));
|
||||
const final_task_id =
|
||||
kind == "reply"
|
||||
? undefined
|
||||
: task_id || (await createJobTask({ bot_id: bot.id, title }));
|
||||
|
||||
const res = await BunSQLite.queue.add<
|
||||
BUN_SQLITE_COBBLE_BOTS_QUEUE,
|
||||
@@ -85,12 +95,13 @@ export default async function enqueueBotJob({
|
||||
table: "bots_queue",
|
||||
data: [
|
||||
{
|
||||
kind,
|
||||
bot_id: bot.id,
|
||||
title,
|
||||
instructions,
|
||||
params: params ? JSON.stringify(params) : undefined,
|
||||
message_id: message_id ? Number(message_id) : undefined,
|
||||
task_id: Number(final_task_id),
|
||||
task_id: final_task_id ? Number(final_task_id) : undefined,
|
||||
computer_id: computer_id ? Number(computer_id) : undefined,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_BOTS_QUEUE } from "@/db/types/db";
|
||||
import enqueueBotJob from "./enqueue-bot-job";
|
||||
import { BotReplyJobTitle } from "@/src/dict/bot-queue-dict";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
bot_id: number | string;
|
||||
/**
|
||||
* The user message to answer.
|
||||
*/
|
||||
message_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Queues the bot's chat reply to the user's latest message(s). A reply
|
||||
* reads the whole recent conversation when it runs, so a burst of
|
||||
* messages shares one reply: while one is still pending, it is reused.
|
||||
*/
|
||||
export default async function enqueueBotReply({
|
||||
user_id,
|
||||
bot_id,
|
||||
message_id,
|
||||
}: Params) {
|
||||
const pending = (
|
||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS_QUEUE>({
|
||||
sql: `SELECT id FROM bots_queue
|
||||
WHERE bot_id = ? AND kind = 'reply' AND status = 'pending'
|
||||
ORDER BY id DESC LIMIT 1`,
|
||||
values: [bot_id],
|
||||
})
|
||||
).payload?.[0];
|
||||
|
||||
if (pending?.id) {
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE bots_queue SET message_id = ? WHERE id = ?`,
|
||||
values: [message_id, pending.id],
|
||||
});
|
||||
|
||||
return pending.id;
|
||||
}
|
||||
|
||||
return await enqueueBotJob({
|
||||
kind: "reply",
|
||||
user_id,
|
||||
bot_id,
|
||||
title: BotReplyJobTitle,
|
||||
instructions: "Reply to the user's latest messages.",
|
||||
message_id,
|
||||
});
|
||||
}
|
||||
@@ -30,7 +30,12 @@ export default async function notifyBotJobFailed({
|
||||
bot_id: job.bot_id,
|
||||
text:
|
||||
failure_message ||
|
||||
[`I couldn't finish "${job.title || "the job"}".`, job.last_error]
|
||||
[
|
||||
job.kind == "reply"
|
||||
? `I couldn't reply.`
|
||||
: `I couldn't finish "${job.title || "the job"}".`,
|
||||
job.last_error,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
needs_you: true,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_MEDIA } from "@/db/types/db";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
|
||||
type Params = {
|
||||
user_id: number;
|
||||
media_ids: number[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The session user's own uploaded files, in the given order. Throws when
|
||||
* any isn't theirs (or no longer exists), so a message can't carry
|
||||
* someone else's file.
|
||||
*/
|
||||
export default async function grabAttachableMedia({ user_id, media_ids }: Params) {
|
||||
const ids = [...new Set(media_ids)];
|
||||
|
||||
if (!ids.length) return [];
|
||||
|
||||
const rows =
|
||||
(
|
||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_MEDIA>({
|
||||
sql: `SELECT * FROM media WHERE user_id = ? AND id IN (${ids.map(() => "?").join(", ")})`,
|
||||
values: [user_id, ...ids],
|
||||
})
|
||||
).payload || [];
|
||||
|
||||
if (rows.length != ids.length) {
|
||||
throw new AppError(`One of the files is gone. Attach it again.`);
|
||||
}
|
||||
|
||||
return ids.map((id) => rows.find((row) => row.id == id)!);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
|
||||
type Params = {
|
||||
bot_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A bot by id, whoever owns it. Server-side work only (the queue); request
|
||||
* handlers use `grabUserBot` to check ownership.
|
||||
*/
|
||||
export default async function grabBotById({
|
||||
bot_id,
|
||||
}: Params): Promise<BUN_SQLITE_COBBLE_BOTS | null> {
|
||||
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
|
||||
sql: `SELECT * FROM bots WHERE id = ? LIMIT 1`,
|
||||
values: [bot_id],
|
||||
});
|
||||
|
||||
return res.payload?.[0] || null;
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
|
||||
import type { BotMessageWithAttachments } from "@/src/types";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import grabMessagesMedia from "./grab-messages-media";
|
||||
import mediaToAttachment from "./media-to-attachment";
|
||||
|
||||
type Params = {
|
||||
bot_id: number | string;
|
||||
@@ -7,12 +10,13 @@ type Params = {
|
||||
};
|
||||
|
||||
/**
|
||||
* The latest messages of a bot's conversation, oldest first.
|
||||
* The latest messages of a bot's conversation, oldest first, with their
|
||||
* attachments.
|
||||
*/
|
||||
export default async function grabBotMessages({
|
||||
bot_id,
|
||||
limit = 100,
|
||||
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_MESSAGES[]> {
|
||||
}: Params): Promise<BotMessageWithAttachments[]> {
|
||||
let sql = ``;
|
||||
|
||||
sql += `SELECT * FROM (\n`;
|
||||
@@ -27,5 +31,16 @@ export default async function grabBotMessages({
|
||||
values: [bot_id, limit],
|
||||
});
|
||||
|
||||
return res.payload || [];
|
||||
const messages = res.payload || [];
|
||||
const media = await grabMessagesMedia({
|
||||
message_ids: messages.map((message) => Number(message.id)),
|
||||
});
|
||||
|
||||
return messages.map((message) => {
|
||||
const files = media.get(Number(message.id));
|
||||
|
||||
return files
|
||||
? { ...message, attachments: files.map((file) => mediaToAttachment({ media: file })) }
|
||||
: message;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_MEDIA } from "@/db/types/db";
|
||||
|
||||
type Params = {
|
||||
message_ids: number[];
|
||||
};
|
||||
|
||||
export type MessageMediaRow = BUN_SQLITE_COBBLE_MEDIA & { message_id: number };
|
||||
|
||||
/**
|
||||
* The files attached to these messages, by message id, in the order they
|
||||
* were attached.
|
||||
*/
|
||||
export default async function grabMessagesMedia({ message_ids }: Params) {
|
||||
const by_message = new Map<number, MessageMediaRow[]>();
|
||||
const ids = [...new Set(message_ids.filter(Boolean))];
|
||||
|
||||
if (!ids.length) return by_message;
|
||||
|
||||
const rows =
|
||||
(
|
||||
await BunSQLite.sql<MessageMediaRow>({
|
||||
sql: `SELECT m.*, l.message_id FROM bot_message_media l
|
||||
JOIN media m ON m.id = l.media_id
|
||||
WHERE l.message_id IN (${ids.map(() => "?").join(", ")})
|
||||
ORDER BY l.id ASC`,
|
||||
values: ids,
|
||||
})
|
||||
).payload || [];
|
||||
|
||||
for (const row of rows) {
|
||||
const list = by_message.get(row.message_id) || [];
|
||||
list.push(row);
|
||||
by_message.set(row.message_id, list);
|
||||
}
|
||||
|
||||
return by_message;
|
||||
}
|
||||
@@ -1,22 +1,32 @@
|
||||
import type {
|
||||
BUN_SQLITE_COBBLE_BOT_MESSAGES,
|
||||
BUN_SQLITE_COBBLE_MEDIA,
|
||||
BunSQLiteTables,
|
||||
} from "@/db/types/db";
|
||||
import type { BotMessageWithAttachments } from "@/src/types";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import mediaToAttachment from "./media-to-attachment";
|
||||
|
||||
type Params = {
|
||||
bot_id: number;
|
||||
text: string;
|
||||
/**
|
||||
* Files to attach, already checked to be the user's own
|
||||
* (`grabAttachableMedia`).
|
||||
*/
|
||||
media?: BUN_SQLITE_COBBLE_MEDIA[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Appends a user message to a bot's conversation and bumps its activity.
|
||||
* Callers must check the bot belongs to the session user first.
|
||||
* Appends a user message (and its files) to a bot's conversation and
|
||||
* bumps its activity. Callers must check the bot belongs to the session
|
||||
* user first.
|
||||
*/
|
||||
export default async function insertUserMessage({
|
||||
bot_id,
|
||||
text,
|
||||
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_MESSAGES> {
|
||||
media = [],
|
||||
}: Params): Promise<BotMessageWithAttachments> {
|
||||
const now = Date.now();
|
||||
|
||||
const res = await BunSQLite.insert<
|
||||
@@ -33,6 +43,13 @@ export default async function insertUserMessage({
|
||||
throw new Error(`Failed to save message: ${res.msg}`);
|
||||
}
|
||||
|
||||
for (const file of media) {
|
||||
await BunSQLite.sql({
|
||||
sql: `INSERT INTO bot_message_media (created_at, updated_at, message_id, media_id) VALUES (?, ?, ?, ?)`,
|
||||
values: [now, now, id, file.id!],
|
||||
});
|
||||
}
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE bots SET last_activity_at = ? WHERE id = ?`,
|
||||
values: [now, bot_id],
|
||||
@@ -46,5 +63,6 @@ export default async function insertUserMessage({
|
||||
needs_you: 0,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
attachments: media.map((file) => mediaToAttachment({ media: file })),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { BUN_SQLITE_COBBLE_MEDIA } from "@/db/types/db";
|
||||
import grabMediaURL from "@/src/functions/backend/media/grab-media-url";
|
||||
import type { BotMessageAttachment, MediaType } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
media: BUN_SQLITE_COBBLE_MEDIA;
|
||||
};
|
||||
|
||||
/**
|
||||
* A `media` row as a chat attachment: its original name (`title`), type
|
||||
* and the private URLs it's served at.
|
||||
*/
|
||||
export default function mediaToAttachment({ media }: Params): BotMessageAttachment {
|
||||
const { media_url, media_thumbnail_url } = grabMediaURL({ media: { ...media } });
|
||||
|
||||
return {
|
||||
id: Number(media.id),
|
||||
name: media.title || `File ${media.id}`,
|
||||
media_type: (media.media_type || "document") as MediaType,
|
||||
url: media_url || "",
|
||||
thumbnail_url: media_thumbnail_url,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type {
|
||||
BUN_SQLITE_COBBLE_COMPUTERS,
|
||||
BUN_SQLITE_COBBLE_CONTAINERS,
|
||||
} from "@/db/types/db";
|
||||
import parseJobJson from "@/src/bots-queue/parse-job-json";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { ComputerMcpPath } from "@/src/dict/computer-dict";
|
||||
import grabEnv from "../envs/grab-env";
|
||||
|
||||
type Params = {
|
||||
computer_id: number | string;
|
||||
};
|
||||
|
||||
type ComputerRow = Pick<BUN_SQLITE_COBBLE_COMPUTERS, "id" | "name" | "kind"> &
|
||||
Pick<BUN_SQLITE_COBBLE_CONTAINERS, "hostname" | "port" | "metadata">;
|
||||
|
||||
/**
|
||||
* Where a computer's MCP server answers. In order: the container's
|
||||
* `metadata.mcp_url`, `MAIN_COMPUTER_URL` for the main computer (an app
|
||||
* setting, `grabEnv`: for a server outside docker, e.g.
|
||||
* `http://localhost:8170`), else its docker hostname and port. `base_url`
|
||||
* is the same address without `/mcp`, for the sandbox's REST API. Null
|
||||
* when the computer has no container.
|
||||
*/
|
||||
export default async function grabComputerMcpUrl({ computer_id }: Params) {
|
||||
const row = (
|
||||
await BunSQLite.sql<ComputerRow>({
|
||||
sql: `SELECT co.id, co.name, co.kind, c.hostname, c.port, c.metadata
|
||||
FROM computers co JOIN containers c ON c.id = co.container_id
|
||||
WHERE co.id = ? LIMIT 1`,
|
||||
values: [computer_id],
|
||||
})
|
||||
).payload?.[0];
|
||||
|
||||
if (!row?.hostname) return null;
|
||||
|
||||
const metadata = parseJobJson({ text: row.metadata }) || {};
|
||||
|
||||
if (typeof metadata.mcp_url == "string" && metadata.mcp_url) {
|
||||
return {
|
||||
url: metadata.mcp_url,
|
||||
base_url: metadata.mcp_url.replace(/\/mcp\/?$/, ""),
|
||||
name: row.name || "",
|
||||
};
|
||||
}
|
||||
|
||||
const main_override = row.kind == "main"
|
||||
? (await grabEnv({ key: "MAIN_COMPUTER_URL" }))?.trim()
|
||||
: undefined;
|
||||
|
||||
const base = main_override
|
||||
? main_override.replace(/\/+$/, "")
|
||||
: `http://${row.hostname}:${row.port || AppData.ComputerPort}`;
|
||||
|
||||
return { url: `${base}${ComputerMcpPath}`, base_url: base, name: row.name || "" };
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import { clearEnvCache } from "./grab-all-envs";
|
||||
|
||||
type Params = {
|
||||
env_id: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes an env. Computers drop it on their next run, when their env file
|
||||
* is rewritten.
|
||||
*/
|
||||
export default async function deleteEnv({ env_id }: Params) {
|
||||
const res = await BunSQLite.sql({
|
||||
sql: `DELETE FROM envs WHERE id = ?`,
|
||||
values: [env_id],
|
||||
});
|
||||
|
||||
if (!res.success) throw new Error(`Couldn't remove the env: ${res.error}`);
|
||||
|
||||
clearEnvCache();
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_ENVS } from "@/db/types/db";
|
||||
import type { EnvParadigm } from "@/src/dict/env-dict";
|
||||
import { decrypt } from "@/src/utils/crypt";
|
||||
|
||||
export type DecryptedEnv = {
|
||||
id: number;
|
||||
key: string;
|
||||
value: string;
|
||||
description: string;
|
||||
paradigm: EnvParadigm;
|
||||
workspace_id: number | null;
|
||||
computer_id: number | null;
|
||||
secret: boolean;
|
||||
expose_to_computers: boolean;
|
||||
};
|
||||
|
||||
let cache: Promise<DecryptedEnv[]> | undefined;
|
||||
|
||||
/**
|
||||
* Every env, decrypted, cached in this process until `clearEnvCache` (every
|
||||
* save and delete calls it). A value that can't be decrypted (the
|
||||
* encryption key changed) reads as empty.
|
||||
*/
|
||||
export default function grabAllEnvs(): Promise<DecryptedEnv[]> {
|
||||
cache ||= load().catch((error) => {
|
||||
cache = undefined;
|
||||
throw error;
|
||||
});
|
||||
|
||||
return cache;
|
||||
}
|
||||
|
||||
export function clearEnvCache() {
|
||||
cache = undefined;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const rows =
|
||||
(
|
||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_ENVS>({
|
||||
sql: `SELECT * FROM envs ORDER BY id ASC`,
|
||||
})
|
||||
).payload || [];
|
||||
|
||||
return await Promise.all(
|
||||
rows.map(async (row): Promise<DecryptedEnv> => ({
|
||||
id: Number(row.id),
|
||||
key: row.key || "",
|
||||
value: row.value ? await decrypt(row.value).catch(() => "") : "",
|
||||
description: row.description || "",
|
||||
paradigm: (row.paradigm || "general") as EnvParadigm,
|
||||
workspace_id: typeof row.workspace_id == "number" ? row.workspace_id : null,
|
||||
computer_id: typeof row.computer_id == "number" ? row.computer_id : null,
|
||||
secret: row.secret != 0,
|
||||
expose_to_computers: row.expose_to_computers != 0,
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import grabAllEnvs from "./grab-all-envs";
|
||||
import resolveScopedEnvs from "./resolve-scoped-envs";
|
||||
|
||||
type Params = {
|
||||
computer_id: number | string;
|
||||
/**
|
||||
* The bot using the computer. Only workspaces it shares with the
|
||||
* computer apply; without a bot, every workspace the computer is in.
|
||||
*/
|
||||
bot_id?: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The envs a run on this computer gets (`resolveScopedEnvs`). Workspaces
|
||||
* are non-archived ones, oldest first, so the oldest wins a tie.
|
||||
*/
|
||||
export default async function grabComputerEnvs({ computer_id, bot_id }: Params) {
|
||||
const workspaces =
|
||||
(
|
||||
await BunSQLite.sql<{ id: number }>({
|
||||
sql: `SELECT w.id FROM workspaces w
|
||||
JOIN workspace_computers wc ON wc.workspace_id = w.id AND wc.computer_id = ?
|
||||
WHERE w.archived = 0
|
||||
${bot_id ? `AND w.id IN (SELECT workspace_id FROM workspace_bots WHERE bot_id = ?)` : ""}
|
||||
ORDER BY w.id ASC`,
|
||||
values: bot_id ? [computer_id, bot_id] : [computer_id],
|
||||
})
|
||||
).payload || [];
|
||||
|
||||
return resolveScopedEnvs({
|
||||
envs: await grabAllEnvs(),
|
||||
computer_id: Number(computer_id),
|
||||
workspace_ids: workspaces.map((workspace) => Number(workspace.id)),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import grabAllEnvs from "./grab-all-envs";
|
||||
|
||||
type Params = {
|
||||
key: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* An app setting (an `EnvDict` key): the general value set in Settings,
|
||||
* else the process environment, else undefined.
|
||||
*/
|
||||
export default async function grabEnv({ key }: Params) {
|
||||
const saved = (await grabAllEnvs()).find(
|
||||
(env) => env.paradigm == "general" && env.key == key,
|
||||
);
|
||||
|
||||
return saved?.value || process.env[key] || undefined;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type {
|
||||
BUN_SQLITE_COBBLE_COMPUTERS,
|
||||
BUN_SQLITE_COBBLE_WORKSPACES,
|
||||
} from "@/db/types/db";
|
||||
import { EnvDict } from "@/src/dict/env-dict";
|
||||
import type { EnvsOverviewPayload } from "@/src/types";
|
||||
import grabAllEnvs from "./grab-all-envs";
|
||||
|
||||
type Params = {
|
||||
user_id: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* The Environment settings page: where envs can be set (general, the
|
||||
* user's workspaces and computers), every env (secret values left out),
|
||||
* and the app settings with where each comes from.
|
||||
*/
|
||||
export default async function grabEnvsOverview({
|
||||
user_id,
|
||||
}: Params): Promise<EnvsOverviewPayload> {
|
||||
const [workspaces, computers, envs] = await Promise.all([
|
||||
BunSQLite.sql<BUN_SQLITE_COBBLE_WORKSPACES>({
|
||||
sql: `SELECT id, name, archived FROM workspaces WHERE user_id = ? ORDER BY archived ASC, id ASC`,
|
||||
values: [user_id],
|
||||
}),
|
||||
BunSQLite.sql<BUN_SQLITE_COBBLE_COMPUTERS>({
|
||||
sql: `SELECT id, name, kind FROM computers WHERE user_id = ? ORDER BY kind = 'main' DESC, id ASC`,
|
||||
values: [user_id],
|
||||
}),
|
||||
grabAllEnvs(),
|
||||
]);
|
||||
|
||||
const workspace_ids = new Set((workspaces.payload || []).map((w) => Number(w.id)));
|
||||
const computer_ids = new Set((computers.payload || []).map((c) => Number(c.id)));
|
||||
const known_keys = new Set(EnvDict.map((env) => env.key));
|
||||
|
||||
const visible = envs.filter(
|
||||
(env) =>
|
||||
env.paradigm == "general" ||
|
||||
(env.workspace_id !== null && workspace_ids.has(env.workspace_id)) ||
|
||||
(env.computer_id !== null && computer_ids.has(env.computer_id)),
|
||||
);
|
||||
|
||||
return {
|
||||
scopes: [
|
||||
{ paradigm: "general", id: null, title: "General" },
|
||||
...(workspaces.payload || []).map((workspace) => ({
|
||||
paradigm: "workspace" as const,
|
||||
id: Number(workspace.id),
|
||||
title: `Workspace: ${workspace.name}${workspace.archived ? " (archived)" : ""}`,
|
||||
})),
|
||||
...(computers.payload || []).map((computer) => ({
|
||||
paradigm: "computer" as const,
|
||||
id: Number(computer.id),
|
||||
title: `Computer: ${computer.name || computer.kind}`,
|
||||
})),
|
||||
],
|
||||
envs: visible.map((env) => ({
|
||||
id: env.id,
|
||||
key: env.key,
|
||||
description: env.description,
|
||||
paradigm: env.paradigm,
|
||||
workspace_id: env.workspace_id,
|
||||
computer_id: env.computer_id,
|
||||
secret: env.secret,
|
||||
expose_to_computers: env.expose_to_computers,
|
||||
value: env.secret ? undefined : env.value,
|
||||
known: known_keys.has(env.key),
|
||||
})),
|
||||
known: EnvDict.map((env) => ({
|
||||
...env,
|
||||
saved: envs.some((saved) => saved.paradigm == "general" && saved.key == env.key),
|
||||
from_process: Boolean(process.env[env.key]),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { DecryptedEnv } from "./grab-all-envs";
|
||||
import resolveScopedEnvs from "./resolve-scoped-envs";
|
||||
|
||||
let next_id = 1;
|
||||
|
||||
const env = (fields: Partial<DecryptedEnv> & { key: string; value: string }): DecryptedEnv => ({
|
||||
id: next_id++,
|
||||
description: "",
|
||||
paradigm: "general",
|
||||
workspace_id: null,
|
||||
computer_id: null,
|
||||
secret: true,
|
||||
expose_to_computers: true,
|
||||
...fields,
|
||||
});
|
||||
|
||||
const values = (envs: DecryptedEnv[]) =>
|
||||
Object.fromEntries(envs.map((e) => [e.key, e.value]));
|
||||
|
||||
describe("resolveScopedEnvs", () => {
|
||||
test("computer beats workspace beats general", () => {
|
||||
const envs = [
|
||||
env({ key: "TOKEN", value: "general" }),
|
||||
env({ key: "TOKEN", value: "workspace", paradigm: "workspace", workspace_id: 2 }),
|
||||
env({ key: "TOKEN", value: "computer", paradigm: "computer", computer_id: 7 }),
|
||||
env({ key: "REGION", value: "eu", paradigm: "workspace", workspace_id: 2 }),
|
||||
env({ key: "ONLY_GENERAL", value: "yes" }),
|
||||
];
|
||||
|
||||
expect(values(resolveScopedEnvs({ envs, computer_id: 7, workspace_ids: [2] }))).toEqual({
|
||||
TOKEN: "computer",
|
||||
REGION: "eu",
|
||||
ONLY_GENERAL: "yes",
|
||||
});
|
||||
});
|
||||
|
||||
test("ignores other computers and workspaces", () => {
|
||||
const envs = [
|
||||
env({ key: "TOKEN", value: "general" }),
|
||||
env({ key: "TOKEN", value: "other", paradigm: "computer", computer_id: 8 }),
|
||||
env({ key: "TOKEN", value: "elsewhere", paradigm: "workspace", workspace_id: 3 }),
|
||||
];
|
||||
|
||||
expect(values(resolveScopedEnvs({ envs, computer_id: 7, workspace_ids: [2] }))).toEqual({
|
||||
TOKEN: "general",
|
||||
});
|
||||
});
|
||||
|
||||
test("the first workspace listed wins a tie", () => {
|
||||
const envs = [
|
||||
env({ key: "TOKEN", value: "first", paradigm: "workspace", workspace_id: 2 }),
|
||||
env({ key: "TOKEN", value: "second", paradigm: "workspace", workspace_id: 5 }),
|
||||
];
|
||||
|
||||
expect(values(resolveScopedEnvs({ envs, computer_id: 7, workspace_ids: [2, 5] }))).toEqual({
|
||||
TOKEN: "first",
|
||||
});
|
||||
});
|
||||
|
||||
test("leaves out unexposed envs and app settings", () => {
|
||||
const envs = [
|
||||
env({ key: "HIDDEN", value: "x", expose_to_computers: false }),
|
||||
env({ key: "OPENROUTER_API_KEY", value: "sk-or" }),
|
||||
];
|
||||
|
||||
expect(resolveScopedEnvs({ envs, computer_id: 7, workspace_ids: [] })).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { EnvDict } from "@/src/dict/env-dict";
|
||||
import type { DecryptedEnv } from "./grab-all-envs";
|
||||
|
||||
type Params = {
|
||||
envs: DecryptedEnv[];
|
||||
computer_id: number;
|
||||
/**
|
||||
* The workspaces whose envs apply, in order of precedence (first wins).
|
||||
*/
|
||||
workspace_ids: number[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The envs that reach one computer, one per key: its own beat its
|
||||
* workspaces' (the first listed workspace wins a tie), which beat general
|
||||
* ones. Only envs exposed to computers; app settings never are.
|
||||
*/
|
||||
export default function resolveScopedEnvs({
|
||||
envs,
|
||||
computer_id,
|
||||
workspace_ids,
|
||||
}: Params): DecryptedEnv[] {
|
||||
const app_keys = new Set(EnvDict.map((env) => env.key));
|
||||
const exposed = envs.filter(
|
||||
(env) => env.expose_to_computers && !app_keys.has(env.key),
|
||||
);
|
||||
const resolved = new Map<string, DecryptedEnv>();
|
||||
|
||||
const layers = [
|
||||
exposed.filter((env) => env.paradigm == "general"),
|
||||
...[...workspace_ids].reverse().map((id) =>
|
||||
exposed.filter((env) => env.paradigm == "workspace" && env.workspace_id == id),
|
||||
),
|
||||
exposed.filter((env) => env.paradigm == "computer" && env.computer_id == computer_id),
|
||||
];
|
||||
|
||||
for (const layer of layers) {
|
||||
for (const env of layer) resolved.set(env.key, env);
|
||||
}
|
||||
|
||||
return [...resolved.values()].sort((a, b) => a.key.localeCompare(b.key));
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_ENVS } from "@/db/types/db";
|
||||
import {
|
||||
EnvDict,
|
||||
EnvKeyRegex,
|
||||
ProcessOnlyEnvKeys,
|
||||
type EnvParadigm,
|
||||
} from "@/src/dict/env-dict";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import { encrypt } from "@/src/utils/crypt";
|
||||
import { clearEnvCache } from "./grab-all-envs";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* Owner of the workspace or computer the env is scoped to.
|
||||
*/
|
||||
user_id: number;
|
||||
/**
|
||||
* Edits this env; adds one when not given.
|
||||
*/
|
||||
env_id?: number;
|
||||
key: string;
|
||||
/**
|
||||
* Required when adding. Left out when editing, the saved value stays.
|
||||
*/
|
||||
value?: string;
|
||||
description?: string;
|
||||
paradigm: EnvParadigm;
|
||||
workspace_id?: number | null;
|
||||
computer_id?: number | null;
|
||||
secret: boolean;
|
||||
expose_to_computers: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds or edits an env. One per key and scope: SQLite unique constraints
|
||||
* treat NULL ids as distinct, so the check is here. App settings
|
||||
* (`EnvDict`) are general only, never go to computers, and keep their
|
||||
* dict's secret flag.
|
||||
*/
|
||||
export default async function saveEnv({
|
||||
user_id,
|
||||
env_id,
|
||||
key,
|
||||
value,
|
||||
description,
|
||||
paradigm,
|
||||
workspace_id,
|
||||
computer_id,
|
||||
secret,
|
||||
expose_to_computers,
|
||||
}: Params) {
|
||||
const final_key = key.trim();
|
||||
|
||||
if (!EnvKeyRegex.test(final_key)) {
|
||||
throw new AppError(`Use letters, digits and underscores for the key, starting with a letter or underscore.`);
|
||||
}
|
||||
|
||||
if ((ProcessOnlyEnvKeys as readonly string[]).includes(final_key)) {
|
||||
throw new AppError(`${final_key} must stay in the server's environment.`);
|
||||
}
|
||||
|
||||
const known = EnvDict.find((env) => env.key == final_key);
|
||||
|
||||
if (known && paradigm != "general") {
|
||||
throw new AppError(`${final_key} is an app setting, so it can only be set in General.`);
|
||||
}
|
||||
|
||||
const scope = await resolveScope({ user_id, paradigm, workspace_id, computer_id });
|
||||
|
||||
const duplicate = (
|
||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_ENVS>({
|
||||
sql: `SELECT id FROM envs WHERE key = ? AND paradigm = ?
|
||||
AND COALESCE(workspace_id, 0) = ? AND COALESCE(computer_id, 0) = ?
|
||||
AND id != ? LIMIT 1`,
|
||||
values: [final_key, paradigm, scope.workspace_id ?? 0, scope.computer_id ?? 0, env_id ?? 0],
|
||||
})
|
||||
).payload?.[0];
|
||||
|
||||
if (duplicate) {
|
||||
throw new AppError(`${final_key} is already set here. Edit that one instead.`);
|
||||
}
|
||||
|
||||
const fields = {
|
||||
key: final_key,
|
||||
description: description?.trim() || known?.description || "",
|
||||
paradigm,
|
||||
workspace_id: scope.workspace_id,
|
||||
computer_id: scope.computer_id,
|
||||
secret: (known ? known.secret : secret) ? 1 : 0,
|
||||
expose_to_computers: !known && expose_to_computers ? 1 : 0,
|
||||
};
|
||||
|
||||
if (env_id) {
|
||||
const existing = (
|
||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_ENVS>({
|
||||
sql: `SELECT id FROM envs WHERE id = ? LIMIT 1`,
|
||||
values: [env_id],
|
||||
})
|
||||
).payload?.[0];
|
||||
|
||||
if (!existing) throw new AppError(`That env no longer exists.`);
|
||||
|
||||
const sets = Object.keys(fields).map((field) => `${field} = ?`);
|
||||
const values: unknown[] = Object.values(fields);
|
||||
|
||||
if (value !== undefined) {
|
||||
sets.push(`value = ?`);
|
||||
values.push(await encrypt(value));
|
||||
}
|
||||
|
||||
const res = await BunSQLite.sql({
|
||||
sql: `UPDATE envs SET ${sets.join(", ")}, updated_at = ? WHERE id = ?`,
|
||||
values: [...values, Date.now(), env_id] as any[],
|
||||
});
|
||||
|
||||
if (!res.success) throw new Error(`Couldn't save the env: ${res.error}`);
|
||||
} else {
|
||||
if (value === undefined) {
|
||||
throw new AppError(`Enter a value.`);
|
||||
}
|
||||
|
||||
const row = { ...fields, value: await encrypt(value) };
|
||||
const now = Date.now();
|
||||
|
||||
const res = await BunSQLite.sql({
|
||||
sql: `INSERT INTO envs (created_at, updated_at, ${Object.keys(row).join(", ")})
|
||||
VALUES (?, ?, ${Object.keys(row).map(() => "?").join(", ")})`,
|
||||
values: [now, now, ...Object.values(row)] as any[],
|
||||
});
|
||||
|
||||
if (!res.success) throw new Error(`Couldn't save the env: ${res.error}`);
|
||||
}
|
||||
|
||||
clearEnvCache();
|
||||
}
|
||||
|
||||
async function resolveScope({
|
||||
user_id,
|
||||
paradigm,
|
||||
workspace_id,
|
||||
computer_id,
|
||||
}: {
|
||||
user_id: number;
|
||||
paradigm: EnvParadigm;
|
||||
workspace_id?: number | null;
|
||||
computer_id?: number | null;
|
||||
}) {
|
||||
if (paradigm == "general") {
|
||||
return { workspace_id: null, computer_id: null };
|
||||
}
|
||||
|
||||
const table = paradigm == "workspace" ? "workspaces" : "computers";
|
||||
const id = paradigm == "workspace" ? workspace_id : computer_id;
|
||||
|
||||
const owned = id
|
||||
? (
|
||||
await BunSQLite.sql({
|
||||
sql: `SELECT id FROM ${table} WHERE id = ? AND user_id = ? LIMIT 1`,
|
||||
values: [id, user_id],
|
||||
})
|
||||
).payload?.[0]
|
||||
: undefined;
|
||||
|
||||
if (!owned) {
|
||||
throw new AppError(`Pick one of your ${table}.`);
|
||||
}
|
||||
|
||||
return paradigm == "workspace"
|
||||
? { workspace_id: Number(id), computer_id: null }
|
||||
: { workspace_id: null, computer_id: Number(id) };
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_HARNESS_MODELS } from "@/db/types/db";
|
||||
import type { HarnessKind } from "@/src/dict/harness-dict";
|
||||
import modelServesHarness from "@/src/harness/catalog/model-serves-harness";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import harnessModelRowToCatalog from "./harness-model-row-to-catalog";
|
||||
|
||||
type Params = {
|
||||
harness: HarnessKind;
|
||||
model_id: number;
|
||||
/**
|
||||
* The provider the setting is limited to, if any.
|
||||
*/
|
||||
provider_id?: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Throws unless the model is available, can do the harness's job and,
|
||||
* when the setting is limited to a provider, comes from it.
|
||||
*/
|
||||
export default async function assertHarnessModel({ harness, model_id, provider_id }: Params) {
|
||||
const row = (
|
||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_HARNESS_MODELS>({
|
||||
sql: `SELECT * FROM harness_models WHERE id = ? AND available = 1 LIMIT 1`,
|
||||
values: [model_id],
|
||||
})
|
||||
).payload?.[0];
|
||||
|
||||
if (!row) {
|
||||
throw new AppError(`That model isn't available anymore.`);
|
||||
}
|
||||
|
||||
if (provider_id && row.provider_id != provider_id) {
|
||||
throw new AppError(`That model isn't from the picked provider.`);
|
||||
}
|
||||
|
||||
if (!modelServesHarness({ model: harnessModelRowToCatalog({ row }), harness })) {
|
||||
throw new AppError(`That model can't do this job.`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import grabHarnessProviders from "./grab-harness-providers";
|
||||
|
||||
type Params = {
|
||||
provider_id: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Throws unless the provider exists and is turned on.
|
||||
*/
|
||||
export default async function assertHarnessProvider({ provider_id }: Params) {
|
||||
const provider = (await grabHarnessProviders({ provider_id }))[0];
|
||||
|
||||
if (!provider?.id || !provider.enabled) {
|
||||
throw new AppError(`That provider isn't available. Turn it on in Settings, under Models.`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import grabHarnessProviders from "./grab-harness-providers";
|
||||
|
||||
type Params = {
|
||||
provider_id: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes a provider and its models. Harnesses (default and per bot) that
|
||||
* picked one of its models, or were limited to it, go back to automatic
|
||||
* on any provider.
|
||||
*/
|
||||
export default async function deleteHarnessProvider({ provider_id }: Params) {
|
||||
const provider = (await grabHarnessProviders({ provider_id }))[0];
|
||||
|
||||
if (!provider?.id) {
|
||||
throw new AppError(`Provider not found.`);
|
||||
}
|
||||
|
||||
for (const table of ["harness_settings", "bot_harness_settings"]) {
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE ${table} SET model_id = NULL, provider_id = NULL, updated_at = ?
|
||||
WHERE provider_id = ? OR model_id IN (SELECT id FROM harness_models WHERE provider_id = ?)`,
|
||||
values: [Date.now(), provider.id, provider.id],
|
||||
});
|
||||
}
|
||||
|
||||
const res = await BunSQLite.sql({
|
||||
sql: `DELETE FROM harness_providers WHERE id = ?`,
|
||||
values: [provider.id],
|
||||
});
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(`Couldn't remove the provider: ${res.error}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type {
|
||||
BUN_SQLITE_COBBLE_HARNESS_PROVIDERS,
|
||||
BunSQLiteTables,
|
||||
} from "@/db/types/db";
|
||||
import { DefaultHarnessProviderKind } from "@/src/dict/harness-provider-dict";
|
||||
import grabProviderPreset from "@/src/harness/providers/grab-provider-preset";
|
||||
|
||||
/**
|
||||
* Adds the default provider (OpenRouter) when no provider exists, so the
|
||||
* free models work as soon as a key is set. Idempotent; a super admin who
|
||||
* removed it and added others doesn't get it back.
|
||||
*/
|
||||
export default async function ensureDefaultHarnessProvider() {
|
||||
const existing = await BunSQLite.sql<BUN_SQLITE_COBBLE_HARNESS_PROVIDERS>({
|
||||
sql: `SELECT id FROM harness_providers LIMIT 1`,
|
||||
});
|
||||
|
||||
if (existing.payload?.[0]) return;
|
||||
|
||||
const preset = grabProviderPreset({ kind: DefaultHarnessProviderKind });
|
||||
|
||||
const res = await BunSQLite.insert<
|
||||
BUN_SQLITE_COBBLE_HARNESS_PROVIDERS,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "harness_providers",
|
||||
data: [
|
||||
{
|
||||
kind: preset.value,
|
||||
name: preset.title,
|
||||
base_url: preset.base_url,
|
||||
enabled: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(`Couldn't add the default model provider: ${res.msg}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Harnesses } from "@/src/dict/harness-dict";
|
||||
import type { BotHarnessOverviewPayload } from "@/src/types";
|
||||
import grabHarnessModelEntries from "./grab-harness-model-entries";
|
||||
import grabHarnessOverviewItem from "./grab-harness-overview-item";
|
||||
import grabHarnessSetting from "./grab-harness-setting";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* A worker bot the caller has checked belongs to the session user.
|
||||
*/
|
||||
bot_id: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* A worker bot's model settings page: per harness, the bot's own setting
|
||||
* (or the default it inherits), the models it can pick, what it runs on
|
||||
* now, and what the default runs on.
|
||||
*/
|
||||
export default async function grabBotHarnessOverview({
|
||||
bot_id,
|
||||
}: Params): Promise<BotHarnessOverviewPayload> {
|
||||
const [own, defaults] = await Promise.all([
|
||||
Promise.all(
|
||||
Harnesses.map((harness) => grabHarnessSetting({ harness: harness.value, bot_id })),
|
||||
),
|
||||
Promise.all(
|
||||
Harnesses.map((harness) => grabHarnessSetting({ harness: harness.value })),
|
||||
),
|
||||
]);
|
||||
|
||||
const entries = await grabHarnessModelEntries({
|
||||
pinned_ids: [...own, ...defaults].flatMap((setting) =>
|
||||
setting.model_id ? [setting.model_id] : [],
|
||||
),
|
||||
});
|
||||
|
||||
return {
|
||||
harnesses: await Promise.all(
|
||||
own.map(async (setting, index) => {
|
||||
const [item, default_item] = await Promise.all([
|
||||
grabHarnessOverviewItem({ harness: setting.harness, setting, entries, bot_id }),
|
||||
grabHarnessOverviewItem({
|
||||
harness: setting.harness,
|
||||
setting: defaults[index]!,
|
||||
entries,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
...item,
|
||||
inherit: setting.source == "default",
|
||||
default: {
|
||||
enabled: default_item.enabled,
|
||||
current: default_item.current,
|
||||
candidate_count: default_item.candidate_count,
|
||||
},
|
||||
};
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_HARNESS_PROVIDERS } from "@/db/types/db";
|
||||
import { DefaultHarnessProviderKind } from "@/src/dict/harness-provider-dict";
|
||||
|
||||
/**
|
||||
* The default provider (`DefaultHarnessProviderKind`, OpenRouter): the
|
||||
* oldest enabled one of that kind. Undefined once it's removed or turned
|
||||
* off, which leaves harnesses on any provider.
|
||||
*/
|
||||
export default async function grabDefaultHarnessProviderId() {
|
||||
const row = (
|
||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_HARNESS_PROVIDERS>({
|
||||
sql: `SELECT id FROM harness_providers WHERE kind = ? AND enabled = 1 ORDER BY id ASC LIMIT 1`,
|
||||
values: [DefaultHarnessProviderKind],
|
||||
})
|
||||
).payload?.[0];
|
||||
|
||||
return row?.id || undefined;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_HARNESS_MODELS } from "@/db/types/db";
|
||||
import type { HarnessCatalogModel } from "@/src/harness/types";
|
||||
import type { HarnessModelOption } from "@/src/types";
|
||||
import harnessModelRowToCatalog from "./harness-model-row-to-catalog";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* Picked models, listed even when no longer available.
|
||||
*/
|
||||
pinned_ids?: number[];
|
||||
};
|
||||
|
||||
type ModelRow = BUN_SQLITE_COBBLE_HARNESS_MODELS & { provider_name?: string };
|
||||
|
||||
export type HarnessModelEntry = {
|
||||
row: ModelRow;
|
||||
model: HarnessCatalogModel;
|
||||
option: HarnessModelOption;
|
||||
};
|
||||
|
||||
/**
|
||||
* Every model a setting can pick (available, on an enabled provider),
|
||||
* free first, plus the given picked ones, each with its picker option.
|
||||
*/
|
||||
export default async function grabHarnessModelEntries({
|
||||
pinned_ids = [],
|
||||
}: Params = {}): Promise<HarnessModelEntry[]> {
|
||||
const ids = [...new Set(pinned_ids)];
|
||||
|
||||
const rows =
|
||||
(
|
||||
await BunSQLite.sql<ModelRow>({
|
||||
sql: `SELECT m.*, p.name AS provider_name FROM harness_models m
|
||||
JOIN harness_providers p ON p.id = m.provider_id
|
||||
WHERE (m.available = 1 AND p.enabled = 1)
|
||||
${ids.length ? `OR m.id IN (${ids.map(() => "?").join(", ")})` : ""}
|
||||
ORDER BY m.free DESC, p.id ASC, m.name ASC`,
|
||||
values: ids,
|
||||
})
|
||||
).payload || [];
|
||||
|
||||
return rows.map((row) => {
|
||||
const model = harnessModelRowToCatalog({ row });
|
||||
|
||||
return {
|
||||
row,
|
||||
model,
|
||||
option: {
|
||||
id: row.id || 0,
|
||||
provider_id: row.provider_id || 0,
|
||||
provider_name: row.provider_name || "",
|
||||
slug: model.slug,
|
||||
name: `${model.name || model.slug}${row.available == 0 ? " (no longer listed)" : ""}`,
|
||||
free: model.free,
|
||||
voices: model.voices,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { HarnessKind } from "@/src/dict/harness-dict";
|
||||
import { Harnesses } from "@/src/dict/harness-dict";
|
||||
import modelServesHarness from "@/src/harness/catalog/model-serves-harness";
|
||||
import resolveHarnessCandidates from "@/src/harness/resolve/resolve-harness-candidates";
|
||||
import type { HarnessOverview } from "@/src/types";
|
||||
import type { HarnessModelEntry } from "./grab-harness-model-entries";
|
||||
import type { HarnessSetting } from "./grab-harness-setting";
|
||||
|
||||
type Params = {
|
||||
harness: HarnessKind;
|
||||
setting: HarnessSetting;
|
||||
entries: HarnessModelEntry[];
|
||||
/**
|
||||
* Resolve what runs now for this bot instead of the default.
|
||||
*/
|
||||
bot_id?: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* One harness as the model settings pages show it: its setting, the
|
||||
* models it can be set to, and the model it would use right now.
|
||||
*/
|
||||
export default async function grabHarnessOverviewItem({
|
||||
harness,
|
||||
setting,
|
||||
entries,
|
||||
bot_id,
|
||||
}: Params): Promise<HarnessOverview> {
|
||||
const meta = Harnesses.find((h) => h.value == harness)!;
|
||||
const candidates = setting.enabled
|
||||
? await resolveHarnessCandidates({ harness, bot_id }).catch(() => [])
|
||||
: [];
|
||||
const first = candidates[0];
|
||||
const serving = entries.filter(({ model }) => modelServesHarness({ model, harness }));
|
||||
const providers = [
|
||||
...new Map(
|
||||
serving.map(({ option }) => [
|
||||
option.provider_id,
|
||||
{ id: option.provider_id, name: option.provider_name },
|
||||
]),
|
||||
).values(),
|
||||
];
|
||||
|
||||
if (setting.provider_id && !providers.some((p) => p.id == setting.provider_id)) {
|
||||
providers.push({ id: setting.provider_id, name: "Unavailable provider" });
|
||||
}
|
||||
const current = first
|
||||
? entries.find(({ row, model }) => `${row.provider_id}:${model.slug}` == first.key)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
harness,
|
||||
title: meta.title,
|
||||
description: meta.description,
|
||||
required: meta.required,
|
||||
model_id: setting.model_id,
|
||||
provider_id: setting.provider_id,
|
||||
providers,
|
||||
fallback: setting.fallback,
|
||||
enabled: setting.enabled,
|
||||
options: setting.options,
|
||||
models: serving.map(({ option }) => option),
|
||||
current: current && first ? { ...current.option, source: first.source } : undefined,
|
||||
candidate_count: candidates.length,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Harnesses } from "@/src/dict/harness-dict";
|
||||
import type { HarnessOverviewPayload } from "@/src/types";
|
||||
import grabHarnessModelEntries from "./grab-harness-model-entries";
|
||||
import grabHarnessOverviewItem from "./grab-harness-overview-item";
|
||||
import grabHarnessProviders from "./grab-harness-providers";
|
||||
import grabHarnessSetting from "./grab-harness-setting";
|
||||
import toPublicHarnessProvider from "./to-public-harness-provider";
|
||||
|
||||
/**
|
||||
* Everything the Models settings page shows: the providers (without keys)
|
||||
* and, per harness, its default setting, the models it can be set to
|
||||
* (free first) and what it runs on right now.
|
||||
*/
|
||||
export default async function grabHarnessOverview(): Promise<HarnessOverviewPayload> {
|
||||
const providers = await grabHarnessProviders();
|
||||
const settings = await Promise.all(
|
||||
Harnesses.map((harness) => grabHarnessSetting({ harness: harness.value })),
|
||||
);
|
||||
const entries = await grabHarnessModelEntries({
|
||||
pinned_ids: settings.flatMap((setting) =>
|
||||
setting.model_id ? [setting.model_id] : [],
|
||||
),
|
||||
});
|
||||
|
||||
return {
|
||||
providers: providers.map((provider) => toPublicHarnessProvider({ provider })),
|
||||
harnesses: await Promise.all(
|
||||
settings.map((setting) =>
|
||||
grabHarnessOverviewItem({ harness: setting.harness, setting, entries }),
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_HARNESS_PROVIDERS } from "@/db/types/db";
|
||||
|
||||
type Params = {
|
||||
provider_id?: number | string;
|
||||
enabled_only?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Provider rows, oldest first. They hold the encrypted key: server only,
|
||||
* never hand them to the client as they are (`toPublicHarnessProvider`).
|
||||
*/
|
||||
export default async function grabHarnessProviders({
|
||||
provider_id,
|
||||
enabled_only,
|
||||
}: Params = {}) {
|
||||
const where: string[] = [];
|
||||
const values: (string | number)[] = [];
|
||||
|
||||
if (provider_id) {
|
||||
where.push(`id = ?`);
|
||||
values.push(provider_id);
|
||||
}
|
||||
|
||||
if (enabled_only) {
|
||||
where.push(`enabled = 1`);
|
||||
}
|
||||
|
||||
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_HARNESS_PROVIDERS>({
|
||||
sql: `SELECT * FROM harness_providers ${where.length ? `WHERE ${where.join(" AND ")}` : ""} ORDER BY id ASC`,
|
||||
values,
|
||||
});
|
||||
|
||||
return res.payload || [];
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type {
|
||||
BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS,
|
||||
BUN_SQLITE_COBBLE_HARNESS_SETTINGS,
|
||||
} from "@/db/types/db";
|
||||
import type { HarnessKind } from "@/src/dict/harness-dict";
|
||||
import parseJobJson from "@/src/bots-queue/parse-job-json";
|
||||
import grabDefaultHarnessProviderId from "./grab-default-harness-provider-id";
|
||||
|
||||
export type HarnessSetting = {
|
||||
harness: HarnessKind;
|
||||
/**
|
||||
* The picked `harness_models` row; undefined picks automatically.
|
||||
*/
|
||||
model_id?: number;
|
||||
/**
|
||||
* The `harness_providers` row the harness is limited to (its pick
|
||||
* and its automatic picks); undefined: any provider.
|
||||
*/
|
||||
provider_id?: number;
|
||||
fallback: boolean;
|
||||
enabled: boolean;
|
||||
options: Record<string, unknown>;
|
||||
/**
|
||||
* `bot` when a worker bot overrides the default for this harness.
|
||||
*/
|
||||
source: "default" | "bot";
|
||||
};
|
||||
|
||||
type Params = {
|
||||
harness: HarnessKind;
|
||||
/**
|
||||
* The bot the call is for. A worker bot's own setting wins; the
|
||||
* coordinator always uses the default.
|
||||
*/
|
||||
bot_id?: number | string;
|
||||
};
|
||||
|
||||
type SettingRow = Pick<
|
||||
BUN_SQLITE_COBBLE_HARNESS_SETTINGS,
|
||||
"model_id" | "provider_id" | "fallback" | "enabled" | "options"
|
||||
>;
|
||||
|
||||
/**
|
||||
* A harness's setting for a call. With no row it is automatic on the
|
||||
* default provider (OpenRouter), on, with fallback. Text is always on.
|
||||
*/
|
||||
export default async function grabHarnessSetting({
|
||||
harness,
|
||||
bot_id,
|
||||
}: Params): Promise<HarnessSetting> {
|
||||
const bot_row = bot_id
|
||||
? await grabBotOverride({ harness, bot_id })
|
||||
: undefined;
|
||||
|
||||
if (bot_row) {
|
||||
return toSetting({ harness, row: bot_row, source: "bot" });
|
||||
}
|
||||
|
||||
const row = (
|
||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_HARNESS_SETTINGS>({
|
||||
sql: `SELECT * FROM harness_settings WHERE harness = ? LIMIT 1`,
|
||||
values: [harness],
|
||||
})
|
||||
).payload?.[0];
|
||||
|
||||
if (!row) {
|
||||
return {
|
||||
...toSetting({ harness, source: "default" }),
|
||||
provider_id: await grabDefaultHarnessProviderId(),
|
||||
};
|
||||
}
|
||||
|
||||
return toSetting({ harness, row, source: "default" });
|
||||
}
|
||||
|
||||
async function grabBotOverride({
|
||||
harness,
|
||||
bot_id,
|
||||
}: {
|
||||
harness: HarnessKind;
|
||||
bot_id: number | string;
|
||||
}) {
|
||||
return (
|
||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS>({
|
||||
sql: `SELECT s.* FROM bot_harness_settings s
|
||||
JOIN bots b ON b.id = s.bot_id
|
||||
WHERE s.bot_id = ? AND s.harness = ? AND b.kind != 'coordinator'
|
||||
LIMIT 1`,
|
||||
values: [bot_id, harness],
|
||||
})
|
||||
).payload?.[0];
|
||||
}
|
||||
|
||||
function toSetting({
|
||||
harness,
|
||||
row,
|
||||
source,
|
||||
}: {
|
||||
harness: HarnessKind;
|
||||
row?: SettingRow;
|
||||
source: HarnessSetting["source"];
|
||||
}): HarnessSetting {
|
||||
return {
|
||||
harness,
|
||||
model_id: typeof row?.model_id == "number" ? row.model_id : undefined,
|
||||
provider_id: typeof row?.provider_id == "number" ? row.provider_id : undefined,
|
||||
fallback: row ? row.fallback != 0 : true,
|
||||
enabled: harness == "text" || !row || row.enabled != 0,
|
||||
options: parseJobJson({ text: row?.options }) || {},
|
||||
source,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { BUN_SQLITE_COBBLE_HARNESS_MODELS } from "@/db/types/db";
|
||||
import type { HarnessCatalogModel } from "@/src/harness/types";
|
||||
|
||||
type Params = {
|
||||
row: BUN_SQLITE_COBBLE_HARNESS_MODELS;
|
||||
};
|
||||
|
||||
/**
|
||||
* A `harness_models` row as a catalog entry.
|
||||
*/
|
||||
export default function harnessModelRowToCatalog({
|
||||
row,
|
||||
}: Params): HarnessCatalogModel {
|
||||
return {
|
||||
slug: row.slug || "",
|
||||
name: row.name || row.slug || "",
|
||||
free: row.free == 1,
|
||||
input_modalities: parseList({ text: row.input_modalities }) || ["text"],
|
||||
output_modalities: parseList({ text: row.output_modalities }) || [
|
||||
"text",
|
||||
],
|
||||
supports_tools: row.supports_tools == 1,
|
||||
context_length: numberOrUndefined(row.context_length),
|
||||
max_output_tokens: numberOrUndefined(row.max_output_tokens),
|
||||
voices: parseList({ text: row.voices }),
|
||||
intelligence_score: numberOrUndefined(row.intelligence_score),
|
||||
agentic_score: numberOrUndefined(row.agentic_score),
|
||||
released_at: numberOrUndefined(row.released_at),
|
||||
expires_at: numberOrUndefined(row.expires_at),
|
||||
};
|
||||
}
|
||||
|
||||
function parseList({ text }: { text?: string | null }) {
|
||||
if (!text) return undefined;
|
||||
|
||||
try {
|
||||
const value = JSON.parse(text);
|
||||
return Array.isArray(value) ? value.map(String) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function numberOrUndefined(value: unknown) {
|
||||
return typeof value == "number" ? value : undefined;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { BUN_SQLITE_COBBLE_HARNESS_PROVIDERS } from "@/db/types/db";
|
||||
import type { HarnessProviderKind } from "@/src/dict/harness-provider-dict";
|
||||
import grabProviderPreset from "@/src/harness/providers/grab-provider-preset";
|
||||
import type { HarnessProviderConfig } from "@/src/harness/types";
|
||||
import { decrypt } from "@/src/utils/crypt";
|
||||
import grabEnv from "../envs/grab-env";
|
||||
|
||||
type Params = {
|
||||
provider: BUN_SQLITE_COBBLE_HARNESS_PROVIDERS;
|
||||
};
|
||||
|
||||
/**
|
||||
* A provider row as the adapters need it, with its key decrypted (or, if
|
||||
* none is saved, the preset's env: set in Settings, under Environment, or
|
||||
* the process environment, `grabEnv`). `usable` is
|
||||
* false when the provider needs a key and has none.
|
||||
*/
|
||||
export default async function resolveProviderConfig({
|
||||
provider,
|
||||
}: Params): Promise<{ config: HarnessProviderConfig; usable: boolean }> {
|
||||
const preset = grabProviderPreset({ kind: provider.kind });
|
||||
let api_key: string | undefined;
|
||||
|
||||
if (provider.api_key) {
|
||||
api_key = await decrypt(provider.api_key).catch(() => undefined);
|
||||
}
|
||||
|
||||
if (!api_key && preset.key_env) {
|
||||
api_key = await grabEnv({ key: preset.key_env });
|
||||
}
|
||||
|
||||
const config: HarnessProviderConfig = {
|
||||
id: provider.id || undefined,
|
||||
kind: preset.value as HarnessProviderKind,
|
||||
name: provider.name || preset.title,
|
||||
base_url: provider.base_url || preset.base_url,
|
||||
api_key,
|
||||
};
|
||||
|
||||
return {
|
||||
config,
|
||||
usable: Boolean(config.base_url) && (!preset.needs_key || !!api_key),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { HarnessKind } from "@/src/dict/harness-dict";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import assertHarnessModel from "./assert-harness-model";
|
||||
import assertHarnessProvider from "./assert-harness-provider";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* A worker bot the caller has checked belongs to the session user.
|
||||
*/
|
||||
bot_id: number;
|
||||
harness: HarnessKind;
|
||||
/**
|
||||
* Drop the bot's own setting and use the default again.
|
||||
*/
|
||||
inherit: boolean;
|
||||
model_id: number | null;
|
||||
/**
|
||||
* Limits the harness to one provider; null: any.
|
||||
*/
|
||||
provider_id?: number | null;
|
||||
fallback: boolean;
|
||||
enabled: boolean;
|
||||
options?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Saves (or, with `inherit`, removes) a worker bot's own setting for one
|
||||
* harness. Same rules as the default: the model must be available and fit
|
||||
* the job, and text can't be turned off.
|
||||
*/
|
||||
export default async function saveBotHarnessSetting({
|
||||
bot_id,
|
||||
harness,
|
||||
inherit,
|
||||
model_id,
|
||||
provider_id = null,
|
||||
fallback,
|
||||
enabled,
|
||||
options,
|
||||
}: Params) {
|
||||
if (inherit) {
|
||||
await BunSQLite.sql({
|
||||
sql: `DELETE FROM bot_harness_settings WHERE bot_id = ? AND harness = ?`,
|
||||
values: [bot_id, harness],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (harness == "text" && !enabled) {
|
||||
throw new AppError(`The text model can't be turned off.`);
|
||||
}
|
||||
|
||||
if (provider_id) {
|
||||
await assertHarnessProvider({ provider_id });
|
||||
}
|
||||
|
||||
if (model_id) {
|
||||
await assertHarnessModel({ harness, model_id, provider_id });
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
const res = await BunSQLite.sql({
|
||||
sql: `INSERT INTO bot_harness_settings (created_at, updated_at, bot_id, harness, model_id, provider_id, fallback, enabled, options)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(bot_id, harness) DO UPDATE SET updated_at = excluded.updated_at,
|
||||
model_id = excluded.model_id, provider_id = excluded.provider_id, fallback = excluded.fallback,
|
||||
enabled = excluded.enabled, options = excluded.options`,
|
||||
values: [
|
||||
now,
|
||||
now,
|
||||
bot_id,
|
||||
harness,
|
||||
model_id,
|
||||
provider_id,
|
||||
fallback ? 1 : 0,
|
||||
enabled ? 1 : 0,
|
||||
JSON.stringify(options || {}),
|
||||
],
|
||||
});
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(`Couldn't save the bot's model setting: ${res.error}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type {
|
||||
BUN_SQLITE_COBBLE_HARNESS_PROVIDERS,
|
||||
BunSQLiteTables,
|
||||
} from "@/db/types/db";
|
||||
import type { HarnessProviderKind } from "@/src/dict/harness-provider-dict";
|
||||
import grabProviderPreset from "@/src/harness/providers/grab-provider-preset";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import { encrypt } from "@/src/utils/crypt";
|
||||
import grabHarnessProviders from "./grab-harness-providers";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* Updates this provider; adds one when not given.
|
||||
*/
|
||||
provider_id?: number;
|
||||
kind: HarnessProviderKind;
|
||||
name?: string;
|
||||
base_url?: string;
|
||||
/**
|
||||
* A new key. Left out keeps the saved one.
|
||||
*/
|
||||
api_key?: string;
|
||||
clear_key?: boolean;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds or updates a model provider. The key is encrypted before it's
|
||||
* stored and never read back to the client. Returns the provider row.
|
||||
*/
|
||||
export default async function saveHarnessProvider({
|
||||
provider_id,
|
||||
kind,
|
||||
name,
|
||||
base_url,
|
||||
api_key,
|
||||
clear_key,
|
||||
enabled,
|
||||
}: Params) {
|
||||
const preset = grabProviderPreset({ kind });
|
||||
const final_base_url = (base_url || preset.base_url).trim().replace(/\/+$/, "");
|
||||
|
||||
if (!/^https?:\/\/[^\s/]+/i.test(final_base_url)) {
|
||||
throw new AppError(`Enter the provider's API address, starting with http:// or https://.`);
|
||||
}
|
||||
|
||||
const existing = provider_id
|
||||
? (await grabHarnessProviders({ provider_id }))[0]
|
||||
: undefined;
|
||||
|
||||
if (provider_id && !existing) {
|
||||
throw new AppError(`Provider not found.`);
|
||||
}
|
||||
|
||||
const key_update = api_key?.trim()
|
||||
? { api_key: await encrypt(api_key.trim()) }
|
||||
: clear_key
|
||||
? { api_key: "" }
|
||||
: {};
|
||||
|
||||
const data: BUN_SQLITE_COBBLE_HARNESS_PROVIDERS = {
|
||||
kind: preset.value,
|
||||
name: name?.trim() || preset.title,
|
||||
base_url: final_base_url,
|
||||
enabled: enabled ? 1 : 0,
|
||||
...key_update,
|
||||
};
|
||||
|
||||
if (existing?.id) {
|
||||
const res = await BunSQLite.update<
|
||||
BUN_SQLITE_COBBLE_HARNESS_PROVIDERS,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "harness_providers",
|
||||
data,
|
||||
targetId: existing.id,
|
||||
});
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(`Couldn't save the provider: ${res.msg}`);
|
||||
}
|
||||
|
||||
return (await grabHarnessProviders({ provider_id: existing.id }))[0]!;
|
||||
}
|
||||
|
||||
const res = await BunSQLite.insert<
|
||||
BUN_SQLITE_COBBLE_HARNESS_PROVIDERS,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "harness_providers",
|
||||
data: [data],
|
||||
});
|
||||
|
||||
const new_id = res.singleRes?.id || res.postInsertReturn?.insertId;
|
||||
|
||||
if (!res.success || !new_id) {
|
||||
throw new Error(`Couldn't add the provider: ${res.msg}`);
|
||||
}
|
||||
|
||||
return (await grabHarnessProviders({ provider_id: new_id }))[0]!;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { HarnessKind } from "@/src/dict/harness-dict";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import assertHarnessModel from "./assert-harness-model";
|
||||
import assertHarnessProvider from "./assert-harness-provider";
|
||||
|
||||
type Params = {
|
||||
harness: HarnessKind;
|
||||
/**
|
||||
* A `harness_models` row, or null to pick automatically.
|
||||
*/
|
||||
model_id: number | null;
|
||||
/**
|
||||
* Limits the harness to one provider; null: any.
|
||||
*/
|
||||
provider_id?: number | null;
|
||||
fallback: boolean;
|
||||
enabled: boolean;
|
||||
options?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Saves a harness's model setting. The picked model must be available and
|
||||
* able to do the harness's job. Text can't be turned off.
|
||||
*/
|
||||
export default async function saveHarnessSetting({
|
||||
harness,
|
||||
model_id,
|
||||
provider_id = null,
|
||||
fallback,
|
||||
enabled,
|
||||
options,
|
||||
}: Params) {
|
||||
if (harness == "text" && !enabled) {
|
||||
throw new AppError(`The text model can't be turned off.`);
|
||||
}
|
||||
|
||||
if (provider_id) {
|
||||
await assertHarnessProvider({ provider_id });
|
||||
}
|
||||
|
||||
if (model_id) {
|
||||
await assertHarnessModel({ harness, model_id, provider_id });
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
const res = await BunSQLite.sql({
|
||||
sql: `INSERT INTO harness_settings (created_at, updated_at, harness, model_id, provider_id, fallback, enabled, options)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(harness) DO UPDATE SET updated_at = excluded.updated_at,
|
||||
model_id = excluded.model_id, provider_id = excluded.provider_id, fallback = excluded.fallback,
|
||||
enabled = excluded.enabled, options = excluded.options`,
|
||||
values: [
|
||||
now,
|
||||
now,
|
||||
harness,
|
||||
model_id,
|
||||
provider_id,
|
||||
fallback ? 1 : 0,
|
||||
enabled ? 1 : 0,
|
||||
JSON.stringify(options || {}),
|
||||
],
|
||||
});
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(`Couldn't save the model setting: ${res.error}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import ensureDefaultHarnessProvider from "./ensure-default-harness-provider";
|
||||
import grabHarnessProviders from "./grab-harness-providers";
|
||||
import syncHarnessProviderModels from "./sync-harness-provider-models";
|
||||
|
||||
/**
|
||||
* Refreshes every enabled provider's model list, so automatic picks follow
|
||||
* free models as they come and go. One provider failing doesn't stop the
|
||||
* rest; its error is saved on its row.
|
||||
*/
|
||||
export default async function syncAllHarnessModels() {
|
||||
await ensureDefaultHarnessProvider();
|
||||
|
||||
const providers = await grabHarnessProviders({ enabled_only: true });
|
||||
const results: { provider_id?: number; count?: number; error?: string }[] =
|
||||
[];
|
||||
|
||||
for (const provider of providers) {
|
||||
try {
|
||||
const count = await syncHarnessProviderModels({ provider });
|
||||
results.push({ provider_id: provider.id || undefined, count });
|
||||
} catch (error: unknown) {
|
||||
results.push({
|
||||
provider_id: provider.id || undefined,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_HARNESS_PROVIDERS } from "@/db/types/db";
|
||||
import grabHarnessAdapter from "@/src/harness/providers/grab-harness-adapter";
|
||||
import resolveProviderConfig from "./resolve-provider-config";
|
||||
import upsertHarnessModels from "./upsert-harness-models";
|
||||
|
||||
type Params = {
|
||||
provider: BUN_SQLITE_COBBLE_HARNESS_PROVIDERS;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches one provider's model list into `harness_models`, and records
|
||||
* when it ran and why it failed, if it did. Returns how many models the
|
||||
* provider listed. Throws when the list couldn't be fetched.
|
||||
*/
|
||||
export default async function syncHarnessProviderModels({ provider }: Params) {
|
||||
if (!provider.id) {
|
||||
throw new Error(`Provider has no id`);
|
||||
}
|
||||
|
||||
const { config } = await resolveProviderConfig({ provider });
|
||||
|
||||
try {
|
||||
const models = await grabHarnessAdapter({
|
||||
kind: config.kind,
|
||||
}).listModels({ provider: config });
|
||||
|
||||
const count = await upsertHarnessModels({
|
||||
provider_id: provider.id,
|
||||
models,
|
||||
});
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE harness_providers SET last_synced_at = ?, sync_error = NULL, updated_at = ? WHERE id = ?`,
|
||||
values: [Date.now(), Date.now(), provider.id],
|
||||
});
|
||||
|
||||
return count;
|
||||
} catch (error: unknown) {
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE harness_providers SET sync_error = ?, updated_at = ? WHERE id = ?`,
|
||||
values: [
|
||||
(error instanceof Error ? error.message : String(error)).slice(
|
||||
0,
|
||||
500,
|
||||
),
|
||||
Date.now(),
|
||||
provider.id,
|
||||
],
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { BUN_SQLITE_COBBLE_HARNESS_PROVIDERS } from "@/db/types/db";
|
||||
import grabProviderPreset from "@/src/harness/providers/grab-provider-preset";
|
||||
import type { PublicHarnessProvider } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
provider: BUN_SQLITE_COBBLE_HARNESS_PROVIDERS;
|
||||
};
|
||||
|
||||
/**
|
||||
* A provider row safe for the client: the key is dropped, only whether
|
||||
* one is saved (or set in the environment) is kept.
|
||||
*/
|
||||
export default function toPublicHarnessProvider({
|
||||
provider,
|
||||
}: Params): PublicHarnessProvider {
|
||||
const preset = grabProviderPreset({ kind: provider.kind });
|
||||
|
||||
return {
|
||||
id: provider.id || 0,
|
||||
kind: preset.value,
|
||||
name: provider.name || preset.title,
|
||||
base_url: provider.base_url || preset.base_url,
|
||||
enabled: provider.enabled != 0,
|
||||
has_key: Boolean(provider.api_key),
|
||||
has_env_key: Boolean(preset.key_env && process.env[preset.key_env]),
|
||||
needs_key: preset.needs_key,
|
||||
key_env: preset.key_env || undefined,
|
||||
last_synced_at: provider.last_synced_at || undefined,
|
||||
sync_error: provider.sync_error || undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { HarnessCatalogModel } from "@/src/harness/types";
|
||||
|
||||
type Params = {
|
||||
provider_id: number;
|
||||
models: HarnessCatalogModel[];
|
||||
now?: number;
|
||||
};
|
||||
|
||||
const Columns = [
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"provider_id",
|
||||
"slug",
|
||||
"name",
|
||||
"free",
|
||||
"available",
|
||||
"input_modalities",
|
||||
"output_modalities",
|
||||
"supports_tools",
|
||||
"context_length",
|
||||
"max_output_tokens",
|
||||
"voices",
|
||||
"intelligence_score",
|
||||
"agentic_score",
|
||||
"released_at",
|
||||
"expires_at",
|
||||
"last_seen_at",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Rows per statement, well under SQLite's bound-variable limit.
|
||||
*/
|
||||
const ChunkSize = 50;
|
||||
|
||||
/**
|
||||
* Writes a provider's model list: new models are added, listed ones are
|
||||
* refreshed and marked available, and models it no longer lists are kept
|
||||
* but marked unavailable (a super admin's pick may point at them).
|
||||
*/
|
||||
export default async function upsertHarnessModels({
|
||||
provider_id,
|
||||
models,
|
||||
now = Date.now(),
|
||||
}: Params) {
|
||||
const unique = [...new Map(models.map((m) => [m.slug, m])).values()];
|
||||
|
||||
for (let i = 0; i < unique.length; i += ChunkSize) {
|
||||
const chunk = unique.slice(i, i + ChunkSize);
|
||||
|
||||
const res = await BunSQLite.sql({
|
||||
sql: `INSERT INTO harness_models (${Columns.join(", ")})
|
||||
VALUES ${chunk.map(() => `(${Columns.map(() => "?").join(", ")})`).join(", ")}
|
||||
ON CONFLICT(provider_id, slug) DO UPDATE SET
|
||||
${Columns.filter((c) => c != "created_at" && c != "provider_id" && c != "slug")
|
||||
.map((c) => `${c} = excluded.${c}`)
|
||||
.join(", ")}`,
|
||||
values: chunk.flatMap((model) => [
|
||||
now,
|
||||
now,
|
||||
provider_id,
|
||||
model.slug,
|
||||
model.name || model.slug,
|
||||
model.free ? 1 : 0,
|
||||
1,
|
||||
JSON.stringify(model.input_modalities),
|
||||
JSON.stringify(model.output_modalities),
|
||||
model.supports_tools ? 1 : 0,
|
||||
model.context_length ?? null,
|
||||
model.max_output_tokens ?? null,
|
||||
model.voices ? JSON.stringify(model.voices) : null,
|
||||
model.intelligence_score ?? null,
|
||||
model.agentic_score ?? null,
|
||||
model.released_at ?? null,
|
||||
model.expires_at ?? null,
|
||||
now,
|
||||
]),
|
||||
});
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(`Couldn't save the model list: ${res.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE harness_models SET available = 0, updated_at = ?
|
||||
WHERE provider_id = ? AND available = 1 AND (last_seen_at IS NULL OR last_seen_at < ?)`,
|
||||
values: [now, provider_id, now],
|
||||
});
|
||||
|
||||
return unique.length;
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export default async function MediaDataToArrayBuffer(
|
||||
if (body instanceof ReadableStream) {
|
||||
return Bun.readableStreamToArrayBuffer(body);
|
||||
}
|
||||
// Blob, BunFile, Request, Response — wrap in Response and use Bun's reader
|
||||
// Blob, BunFile, Request, Response: wrap in Response and use Bun's reader
|
||||
return new Response(body as BodyInit)
|
||||
.bytes()
|
||||
.then((b) => b.buffer as ArrayBuffer);
|
||||
|
||||
@@ -230,7 +230,13 @@ export default async function uploadAndRecordMedia({
|
||||
const media_data: BUN_SQLITE_COBBLE_MEDIA = {
|
||||
...media,
|
||||
user_id: Number(user.id),
|
||||
is_primary: is_primary ? 1 : 0,
|
||||
/**
|
||||
* NULL, not 0, when not primary: the (user_id, is_primary) unique
|
||||
* constraint only means one primary image per user, and SQLite
|
||||
* lets NULLs repeat.
|
||||
*/
|
||||
is_primary: is_primary ? 1 : (null as any),
|
||||
is_private: is_private ? 1 : 0,
|
||||
media_write_path: media_write_path,
|
||||
media_thumbnail_write_path:
|
||||
media_type === "image" ? media_thumbnail_write_path : undefined,
|
||||
|
||||
@@ -59,6 +59,14 @@ export default async function handleMediaServer({
|
||||
});
|
||||
|
||||
media = media_res.singleRes || undefined;
|
||||
|
||||
/**
|
||||
* A private file is only served through `/media/private`, to its
|
||||
* owner.
|
||||
*/
|
||||
if (media?.is_private) {
|
||||
media = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (!media?.id) {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import looksLikeMissingAudio from "./looks-like-missing-audio";
|
||||
|
||||
describe("looksLikeMissingAudio", () => {
|
||||
test("spots a model asking for the audio it never got", () => {
|
||||
for (const text of [
|
||||
"Please provide the audio file you’d like transcribed.",
|
||||
"I’m sorry, but I can’t transcribe without the audio.",
|
||||
"I cannot hear any audio in your message.",
|
||||
"There is no audio attached.",
|
||||
"I didn't receive any audio.",
|
||||
]) {
|
||||
expect(looksLikeMissingAudio({ text })).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("leaves real transcripts alone", () => {
|
||||
expect(looksLikeMissingAudio({ text: "Hello from cobble." })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
looksLikeMissingAudio({
|
||||
text: `Can you send the audio report to finance? ${"And then more. ".repeat(30)}`,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
type Params = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
const MissingAudio =
|
||||
/(provide|upload|share|attach|send)\b.{0,40}\baudio|can(?:'|’)?t (?:transcribe|hear|access|listen)|cannot (?:transcribe|hear|access|listen)|no audio|without (?:the |an |any )?audio|didn(?:'|’)?t (?:receive|get) (?:the |an |any )?audio/i;
|
||||
|
||||
/**
|
||||
* Whether a chat model's "transcript" is really it saying it got no audio.
|
||||
* Some providers list audio input for a model but drop the audio.
|
||||
*/
|
||||
export default function looksLikeMissingAudio({ text }: Params) {
|
||||
return text.length < 300 && MissingAudio.test(text);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import grabHarnessSetting from "@/src/functions/backend/db/harness/grab-harness-setting";
|
||||
import grabHarnessAdapter from "../providers/grab-harness-adapter";
|
||||
import resolveHarnessCandidates from "../resolve/resolve-harness-candidates";
|
||||
import withCandidates from "../resolve/with-candidates";
|
||||
import type { HarnessCandidate } from "../types";
|
||||
|
||||
type Params = {
|
||||
text: string;
|
||||
/**
|
||||
* `mp3` unless set.
|
||||
*/
|
||||
format?: string;
|
||||
signal?: AbortSignal;
|
||||
/**
|
||||
* Uses this bot's speech model and voice when it has its own.
|
||||
*/
|
||||
bot_id?: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads text aloud with the speech harness. Uses the voice picked in the
|
||||
* speech setting when the model has it, else the model's first voice.
|
||||
*/
|
||||
export default async function speakText({
|
||||
text,
|
||||
format,
|
||||
signal,
|
||||
bot_id,
|
||||
}: Params) {
|
||||
const [candidates, setting] = await Promise.all([
|
||||
resolveHarnessCandidates({ harness: "speech", bot_id }),
|
||||
grabHarnessSetting({ harness: "speech", bot_id }),
|
||||
]);
|
||||
|
||||
const { result } = await withCandidates({
|
||||
harness: "speech",
|
||||
candidates,
|
||||
run: async (candidate) =>
|
||||
await grabHarnessAdapter({ kind: candidate.provider.kind }).speak({
|
||||
candidate,
|
||||
text,
|
||||
voice: pickVoice({
|
||||
candidate,
|
||||
voice:
|
||||
typeof setting.options.voice == "string"
|
||||
? setting.options.voice
|
||||
: undefined,
|
||||
}),
|
||||
format,
|
||||
signal: signal || new AbortController().signal,
|
||||
}),
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function pickVoice({
|
||||
candidate,
|
||||
voice,
|
||||
}: {
|
||||
candidate: HarnessCandidate;
|
||||
voice?: string;
|
||||
}) {
|
||||
const voices = candidate.model.voices;
|
||||
|
||||
if (voice && (candidate.source == "pinned" || voices?.includes(voice))) {
|
||||
return voice;
|
||||
}
|
||||
|
||||
if (voices?.[0]) return voices[0];
|
||||
|
||||
return candidate.provider.kind == "openai" ? "alloy" : undefined;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { TranscribeAudioPrompt } from "@/src/data/harness-prompts";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import HarnessError from "../harness-error";
|
||||
import grabHarnessAdapter from "../providers/grab-harness-adapter";
|
||||
import resolveHarnessCandidates from "../resolve/resolve-harness-candidates";
|
||||
import withCandidates from "../resolve/with-candidates";
|
||||
import type { HarnessAudioInput } from "../types";
|
||||
import looksLikeMissingAudio from "./looks-like-missing-audio";
|
||||
|
||||
/**
|
||||
* A chat model that drops audio is skipped this long.
|
||||
*/
|
||||
const MissingAudioCooldownMs = 60 * 60 * 1000;
|
||||
|
||||
type Params = {
|
||||
audio: HarnessAudioInput;
|
||||
/**
|
||||
* ISO 639-1 code, when known. Helps speech-to-text models.
|
||||
*/
|
||||
language?: string;
|
||||
signal?: AbortSignal;
|
||||
/**
|
||||
* Uses this bot's transcription model when it has its own.
|
||||
*/
|
||||
bot_id?: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Turns speech into text with the transcription harness. Speech-to-text
|
||||
* models get the file at `/audio/transcriptions`; chat models that hear
|
||||
* audio are asked for a verbatim transcript.
|
||||
*/
|
||||
export default async function transcribeAudio({
|
||||
audio,
|
||||
language,
|
||||
signal,
|
||||
bot_id,
|
||||
}: Params) {
|
||||
if (audio.data.byteLength > AppData.HarnessMaxAudioBytes) {
|
||||
throw new AppError(`That recording is too long to transcribe.`);
|
||||
}
|
||||
|
||||
const candidates = await resolveHarnessCandidates({
|
||||
harness: "transcription",
|
||||
bot_id,
|
||||
});
|
||||
|
||||
const final_signal = signal || new AbortController().signal;
|
||||
|
||||
const { result } = await withCandidates({
|
||||
harness: "transcription",
|
||||
candidates,
|
||||
run: async (candidate) => {
|
||||
const adapter = grabHarnessAdapter({
|
||||
kind: candidate.provider.kind,
|
||||
});
|
||||
|
||||
const is_speech_to_text =
|
||||
candidate.model.output_modalities.includes("transcription");
|
||||
|
||||
const text = is_speech_to_text
|
||||
? (
|
||||
await adapter.transcribe({
|
||||
candidate,
|
||||
audio,
|
||||
language,
|
||||
signal: final_signal,
|
||||
})
|
||||
).text
|
||||
: (
|
||||
await adapter.chat({
|
||||
candidate,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: TranscribeAudioPrompt },
|
||||
{
|
||||
type: "audio",
|
||||
data: Buffer.from(audio.data).toString(
|
||||
"base64",
|
||||
),
|
||||
format: audio.format,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
temperature: 0,
|
||||
signal: final_signal,
|
||||
})
|
||||
).text;
|
||||
|
||||
if (text === null || text === undefined) {
|
||||
throw new HarnessError({
|
||||
message: `The model returned no transcript.`,
|
||||
retryable: true,
|
||||
candidate_key: candidate.key,
|
||||
});
|
||||
}
|
||||
|
||||
if (!is_speech_to_text && looksLikeMissingAudio({ text })) {
|
||||
throw new HarnessError({
|
||||
message: `${candidate.model.name || candidate.model.slug} didn't get the audio.`,
|
||||
retryable: true,
|
||||
retry_at: Date.now() + MissingAudioCooldownMs,
|
||||
candidate_key: candidate.key,
|
||||
});
|
||||
}
|
||||
|
||||
return text.trim();
|
||||
},
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import guessModelModalities from "./guess-model-modalities";
|
||||
|
||||
const outputs = (slug: string) =>
|
||||
guessModelModalities({ slug }).output_modalities;
|
||||
|
||||
describe("guessModelModalities", () => {
|
||||
test("spots speech, transcription, image and embedding models", () => {
|
||||
expect(outputs("whisper-large-v3")).toEqual(["transcription"]);
|
||||
expect(outputs("gpt-4o-mini-transcribe")).toEqual(["transcription"]);
|
||||
expect(outputs("tts-1-hd")).toEqual(["speech"]);
|
||||
expect(outputs("gpt-image-1")).toEqual(["image"]);
|
||||
expect(outputs("dall-e-3")).toEqual(["image"]);
|
||||
expect(outputs("text-embedding-3-small")).toEqual(["embeddings"]);
|
||||
});
|
||||
|
||||
test("takes everything else for a tool-calling chat model", () => {
|
||||
expect(guessModelModalities({ slug: "deepseek-chat" })).toEqual({
|
||||
input_modalities: ["text"],
|
||||
output_modalities: ["text"],
|
||||
supports_tools: true,
|
||||
});
|
||||
expect(outputs("mistral-large-latest")).toEqual(["text"]);
|
||||
});
|
||||
|
||||
test("knows which chat families see images", () => {
|
||||
expect(
|
||||
guessModelModalities({ slug: "claude-sonnet-5-5" }).input_modalities,
|
||||
).toEqual(["text", "image"]);
|
||||
expect(
|
||||
guessModelModalities({ slug: "models/gemini-3.5-flash" })
|
||||
.input_modalities,
|
||||
).toEqual(["text", "image"]);
|
||||
expect(
|
||||
guessModelModalities({ slug: "qwen2.5-vl:7b" }).input_modalities,
|
||||
).toEqual(["text", "image"]);
|
||||
expect(
|
||||
guessModelModalities({ slug: "deepseek-r1:14b" }).input_modalities,
|
||||
).toEqual(["text"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { HarnessModelInfo } from "../types";
|
||||
|
||||
type Params = {
|
||||
slug: string;
|
||||
};
|
||||
|
||||
type Guess = Pick<
|
||||
HarnessModelInfo,
|
||||
"input_modalities" | "output_modalities" | "supports_tools"
|
||||
>;
|
||||
|
||||
const Transcription = /whisper|transcri|(?:^|[-_/])(stt|asr)(?=$|[-_:.])|scribe/i;
|
||||
const Speech = /(?:^|[-_/])tts(?=$|[-_:.])|speech|voice/i;
|
||||
const ImageOut = /dall-e|gpt-image|imagen|flux|stable-diffusion|sdxl|image-gen/i;
|
||||
const Embedding = /embed/i;
|
||||
/**
|
||||
* Chat families that read images.
|
||||
*/
|
||||
const ImageIn =
|
||||
/vision|(?:^|[-_/])vl(?=$|[-_:.])|gpt-4o|gpt-4\.1|gpt-[5-9]|claude|gemini|llava|pixtral|gemma-?[34]|grok-[2-9]|llama-?4|mistral-(small|medium)/i;
|
||||
|
||||
/**
|
||||
* What a model takes and makes, guessed from its id, for providers whose
|
||||
* model list only has ids. Anything that isn't plainly a speech, image or
|
||||
* embedding model is taken for a chat model that can call tools.
|
||||
*/
|
||||
export default function guessModelModalities({ slug }: Params): Guess {
|
||||
if (Transcription.test(slug)) {
|
||||
return {
|
||||
input_modalities: ["audio"],
|
||||
output_modalities: ["transcription"],
|
||||
supports_tools: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (Speech.test(slug)) {
|
||||
return {
|
||||
input_modalities: ["text"],
|
||||
output_modalities: ["speech"],
|
||||
supports_tools: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (ImageOut.test(slug)) {
|
||||
return {
|
||||
input_modalities: ["text", "image"],
|
||||
output_modalities: ["image"],
|
||||
supports_tools: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (Embedding.test(slug)) {
|
||||
return {
|
||||
input_modalities: ["text"],
|
||||
output_modalities: ["embeddings"],
|
||||
supports_tools: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
input_modalities: ImageIn.test(slug) ? ["text", "image"] : ["text"],
|
||||
output_modalities: ["text"],
|
||||
supports_tools: true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import modelServesHarness from "./model-serves-harness";
|
||||
|
||||
const chat = {
|
||||
input_modalities: ["text"],
|
||||
output_modalities: ["text"],
|
||||
supports_tools: true,
|
||||
};
|
||||
|
||||
describe("modelServesHarness", () => {
|
||||
test("text needs tools", () => {
|
||||
expect(modelServesHarness({ model: chat, harness: "text" })).toBe(true);
|
||||
expect(
|
||||
modelServesHarness({
|
||||
model: { ...chat, supports_tools: false },
|
||||
harness: "text",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("vision needs image input", () => {
|
||||
expect(modelServesHarness({ model: chat, harness: "vision" })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
modelServesHarness({
|
||||
model: { ...chat, input_modalities: ["text", "image"] },
|
||||
harness: "vision",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("transcription takes speech-to-text or audio-hearing chat models", () => {
|
||||
expect(
|
||||
modelServesHarness({
|
||||
model: {
|
||||
input_modalities: ["audio"],
|
||||
output_modalities: ["transcription"],
|
||||
supports_tools: false,
|
||||
},
|
||||
harness: "transcription",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
modelServesHarness({
|
||||
model: { ...chat, input_modalities: ["text", "audio"] },
|
||||
harness: "transcription",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
modelServesHarness({ model: chat, harness: "transcription" }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("speech and image need that output", () => {
|
||||
expect(
|
||||
modelServesHarness({
|
||||
model: { ...chat, output_modalities: ["speech"] },
|
||||
harness: "speech",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
modelServesHarness({
|
||||
model: { ...chat, output_modalities: ["text", "audio"] },
|
||||
harness: "speech",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
modelServesHarness({
|
||||
model: { ...chat, output_modalities: ["image", "text"] },
|
||||
harness: "image",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { HarnessKind } from "@/src/dict/harness-dict";
|
||||
import type { HarnessModelInfo } from "../types";
|
||||
|
||||
type Params = {
|
||||
model: Pick<
|
||||
HarnessModelInfo,
|
||||
"input_modalities" | "output_modalities" | "supports_tools"
|
||||
>;
|
||||
harness: HarnessKind;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a model can do a harness's job:
|
||||
* - text: reads and writes text, and calls tools (bots work through tools)
|
||||
* - vision: reads images, writes text
|
||||
* - transcription: a speech-to-text model, or a chat model that hears audio
|
||||
* - speech: a text-to-speech model
|
||||
* - image: makes images
|
||||
*/
|
||||
export default function modelServesHarness({ model, harness }: Params) {
|
||||
const input = new Set(model.input_modalities);
|
||||
const output = new Set(model.output_modalities);
|
||||
|
||||
switch (harness) {
|
||||
case "text":
|
||||
return (
|
||||
input.has("text") && output.has("text") && model.supports_tools
|
||||
);
|
||||
|
||||
case "vision":
|
||||
return input.has("image") && output.has("text");
|
||||
|
||||
case "transcription":
|
||||
return (
|
||||
output.has("transcription") ||
|
||||
(input.has("audio") && output.has("text"))
|
||||
);
|
||||
|
||||
case "speech":
|
||||
return output.has("speech");
|
||||
|
||||
case "image":
|
||||
return output.has("image");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import parseModelSize from "./parse-model-size";
|
||||
|
||||
describe("parseModelSize", () => {
|
||||
test("reads total parameters, skipping active ones", () => {
|
||||
expect(parseModelSize({ slug: "qwen/qwen3-30b-a3b" })).toBe(30);
|
||||
expect(
|
||||
parseModelSize({ slug: "nvidia/nemotron-3-ultra-550b-a55b:free" }),
|
||||
).toBe(550);
|
||||
expect(parseModelSize({ slug: "liquid/lfm-2.5-2.6b:free" })).toBe(2.6);
|
||||
expect(parseModelSize({ slug: "deepseek-r1:14b" })).toBe(14);
|
||||
});
|
||||
|
||||
test("reads trillions", () => {
|
||||
expect(parseModelSize({ slug: "qwen/qwen3.8-2.4t-a95b" })).toBe(2400);
|
||||
});
|
||||
|
||||
test("is undefined when the slug has no size", () => {
|
||||
expect(parseModelSize({ slug: "deepseek/deepseek-v4.1-flash" })).toBe(
|
||||
undefined,
|
||||
);
|
||||
expect(parseModelSize({ slug: "google/gemma-3n-e4b-it" })).toBe(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
type Params = {
|
||||
slug: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Total parameters in billions, read from a model slug
|
||||
* (`qwen3-30b-a3b` is 30, `qwen3.8-2.4t-a95b` is 2400). Active-parameter
|
||||
* counts (`a3b`) are skipped. Undefined when the slug doesn't say.
|
||||
*/
|
||||
export default function parseModelSize({ slug }: Params): number | undefined {
|
||||
const name = slug.split("/").pop() || slug;
|
||||
const match = name.match(/(?<![a-z0-9.])(\d+(?:\.\d+)?)([bt])(?![a-z])/i);
|
||||
|
||||
if (!match?.[1] || !match[2]) return undefined;
|
||||
|
||||
const size = Number(match[1]);
|
||||
|
||||
return match[2].toLowerCase() == "t" ? size * 1000 : size;
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { HarnessCatalogModel } from "../types";
|
||||
import rankAutoModels from "./rank-auto-models";
|
||||
|
||||
const day = 24 * 60 * 60 * 1000;
|
||||
|
||||
function model(
|
||||
slug: string,
|
||||
extra: Partial<HarnessCatalogModel> = {},
|
||||
): HarnessCatalogModel {
|
||||
return {
|
||||
slug,
|
||||
free: true,
|
||||
input_modalities: ["text"],
|
||||
output_modalities: ["text"],
|
||||
supports_tools: true,
|
||||
context_length: 262144,
|
||||
released_at: 0,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenRouter's free list on 2026-10-18 (no DeepSeek or Qwen that day).
|
||||
*/
|
||||
const live = [
|
||||
model("xingchen-agi/xing4.0-29b-a4b:free", { released_at: 20 * day }),
|
||||
model("nvidia/nemotron-3.5-super-vl:free", {
|
||||
input_modalities: ["text", "image", "video"],
|
||||
intelligence_score: 26.5,
|
||||
}),
|
||||
model("liquid/lfm-2.5-2.6b:free", {
|
||||
context_length: 65536,
|
||||
intelligence_score: 8.4,
|
||||
}),
|
||||
model("nvidia/nemotron-3.5-lightning:free", { intelligence_score: 12.9 }),
|
||||
model("thinkingmachines/inkling-small:free", {
|
||||
input_modalities: ["text", "image", "audio"],
|
||||
intelligence_score: 25.7,
|
||||
agentic_score: 23.5,
|
||||
}),
|
||||
model("cohere/north-mini-code:free", { intelligence_score: 9.9 }),
|
||||
model("nvidia/nemotron-3.5-content-safety:free", {
|
||||
supports_tools: false,
|
||||
}),
|
||||
model("nvidia/nemotron-3-ultra-550b-a55b:free", {
|
||||
intelligence_score: 22.9,
|
||||
}),
|
||||
model("google/gemma-4-31b-it:free", {
|
||||
input_modalities: ["image", "text", "video"],
|
||||
intelligence_score: 14.7,
|
||||
}),
|
||||
model("openrouter/free", { input_modalities: ["text", "image"] }),
|
||||
model("anthropic/claude-paid", { free: false }),
|
||||
];
|
||||
|
||||
const slugs = (models: HarnessCatalogModel[]) => models.map((m) => m.slug);
|
||||
|
||||
describe("rankAutoModels", () => {
|
||||
test("follows the vendor preference, then competent others, then routers", () => {
|
||||
expect(slugs(rankAutoModels({ models: live, harness: "text" }))).toEqual(
|
||||
[
|
||||
"nvidia/nemotron-3.5-super-vl:free",
|
||||
"nvidia/nemotron-3-ultra-550b-a55b:free",
|
||||
"nvidia/nemotron-3.5-lightning:free",
|
||||
"liquid/lfm-2.5-2.6b:free",
|
||||
"thinkingmachines/inkling-small:free",
|
||||
"google/gemma-4-31b-it:free",
|
||||
"xingchen-agi/xing4.0-29b-a4b:free",
|
||||
"openrouter/free",
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("puts DeepSeek, then Qwen, ahead of everything once they're free", () => {
|
||||
const ranked = rankAutoModels({
|
||||
models: [
|
||||
...live,
|
||||
model("qwen/qwen3.8-flash:free"),
|
||||
model("deepseek/deepseek-v4.1-flash:free"),
|
||||
],
|
||||
harness: "text",
|
||||
});
|
||||
|
||||
expect(slugs(ranked).slice(0, 2)).toEqual([
|
||||
"deepseek/deepseek-v4.1-flash:free",
|
||||
"qwen/qwen3.8-flash:free",
|
||||
]);
|
||||
});
|
||||
|
||||
test("recognises vendors in local model names", () => {
|
||||
const ranked = rankAutoModels({
|
||||
models: [model("llama3.3:70b"), model("qwen3:32b")],
|
||||
harness: "text",
|
||||
});
|
||||
|
||||
expect(slugs(ranked)).toEqual(["qwen3:32b", "llama3.3:70b"]);
|
||||
});
|
||||
|
||||
test("filters by what the harness needs", () => {
|
||||
expect(
|
||||
slugs(rankAutoModels({ models: live, harness: "vision" })),
|
||||
).toEqual([
|
||||
"nvidia/nemotron-3.5-super-vl:free",
|
||||
"thinkingmachines/inkling-small:free",
|
||||
"google/gemma-4-31b-it:free",
|
||||
"openrouter/free",
|
||||
]);
|
||||
expect(
|
||||
slugs(rankAutoModels({ models: live, harness: "transcription" })),
|
||||
).toEqual(["thinkingmachines/inkling-small:free"]);
|
||||
expect(rankAutoModels({ models: live, harness: "speech" })).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("puts speech-to-text models ahead of chat models that hear audio", () => {
|
||||
const ranked = rankAutoModels({
|
||||
models: [
|
||||
model("deepseek/omni", { input_modalities: ["text", "audio"] }),
|
||||
model("acme/scribe", {
|
||||
input_modalities: ["audio"],
|
||||
output_modalities: ["transcription"],
|
||||
supports_tools: false,
|
||||
}),
|
||||
],
|
||||
harness: "transcription",
|
||||
});
|
||||
|
||||
expect(slugs(ranked)).toEqual(["acme/scribe", "deepseek/omni"]);
|
||||
});
|
||||
|
||||
test("drops retired and unscored small models from other vendors", () => {
|
||||
const ranked = rankAutoModels({
|
||||
models: [
|
||||
model("acme/tiny-7b"),
|
||||
model("acme/big-mini"),
|
||||
model("acme/gone", { expires_at: 10 }),
|
||||
model("acme/short", { context_length: 8000 }),
|
||||
model("acme/solid-70b"),
|
||||
],
|
||||
harness: "text",
|
||||
now: 100,
|
||||
});
|
||||
|
||||
expect(slugs(ranked)).toEqual(["acme/solid-70b"]);
|
||||
});
|
||||
|
||||
test("within a vendor, unscored full-size models beat small ones, newest first", () => {
|
||||
const ranked = rankAutoModels({
|
||||
models: [
|
||||
model("deepseek/deepseek-lite", { released_at: 3 }),
|
||||
model("deepseek/deepseek-old", { released_at: 1 }),
|
||||
model("deepseek/deepseek-new", { released_at: 2 }),
|
||||
],
|
||||
harness: "text",
|
||||
});
|
||||
|
||||
expect(slugs(ranked)).toEqual([
|
||||
"deepseek/deepseek-new",
|
||||
"deepseek/deepseek-old",
|
||||
"deepseek/deepseek-lite",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import {
|
||||
AutoModelRouterSlugs,
|
||||
AutoModelVendorPreference,
|
||||
SmallModelPattern,
|
||||
SpecialistModelPattern,
|
||||
type HarnessKind,
|
||||
} from "@/src/dict/harness-dict";
|
||||
import type { HarnessCatalogModel } from "../types";
|
||||
import modelServesHarness from "./model-serves-harness";
|
||||
import parseModelSize from "./parse-model-size";
|
||||
|
||||
type Params<T extends HarnessCatalogModel> = {
|
||||
models: T[];
|
||||
harness: HarnessKind;
|
||||
/**
|
||||
* Unix ms, for retired models. Defaults to now.
|
||||
*/
|
||||
now?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* The free models a harness picks automatically, best first:
|
||||
* 1. `AutoModelVendorPreference` in order (DeepSeek, Qwen, NVIDIA, Liquid)
|
||||
* 2. any other vendor's model that looks competent (text and vision only)
|
||||
* 3. routers such as `openrouter/free`, last
|
||||
*
|
||||
* Within a group, benchmarked models come first by intelligence index,
|
||||
* then the rest: full-size before small variants, newest first.
|
||||
*/
|
||||
export default function rankAutoModels<T extends HarnessCatalogModel>({
|
||||
models,
|
||||
harness,
|
||||
now = Date.now(),
|
||||
}: Params<T>): T[] {
|
||||
const ranked = models
|
||||
.filter(
|
||||
(model) =>
|
||||
model.free &&
|
||||
!(model.expires_at && model.expires_at <= now) &&
|
||||
!SpecialistModelPattern.test(model.slug) &&
|
||||
modelServesHarness({ model, harness }),
|
||||
)
|
||||
.map((model) => ({ model, tier: grabTier({ model, harness }) }))
|
||||
.filter(
|
||||
(entry): entry is { model: T; tier: number } =>
|
||||
entry.tier !== undefined,
|
||||
);
|
||||
|
||||
return ranked
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(harness == "transcription"
|
||||
? Number(!isSpeechToText(a.model)) -
|
||||
Number(!isSpeechToText(b.model))
|
||||
: 0) ||
|
||||
a.tier - b.tier ||
|
||||
compareInTier(a.model, b.model),
|
||||
)
|
||||
.map((entry) => entry.model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedicated speech-to-text models transcribe before chat models that hear
|
||||
* audio: some free chat endpoints list audio input but drop it.
|
||||
*/
|
||||
function isSpeechToText(model: HarnessCatalogModel) {
|
||||
return model.output_modalities.includes("transcription");
|
||||
}
|
||||
|
||||
const OtherTier = AutoModelVendorPreference.length;
|
||||
const RouterTier = OtherTier + 1;
|
||||
|
||||
/**
|
||||
* Undefined drops the model.
|
||||
*/
|
||||
function grabTier({
|
||||
model,
|
||||
harness,
|
||||
}: {
|
||||
model: HarnessCatalogModel;
|
||||
harness: HarnessKind;
|
||||
}) {
|
||||
const slug = model.slug.toLowerCase();
|
||||
|
||||
if (AutoModelRouterSlugs.includes(slug)) {
|
||||
return RouterTier;
|
||||
}
|
||||
|
||||
const name = slug.split("/").pop() || slug;
|
||||
const vendor_tier = AutoModelVendorPreference.findIndex(
|
||||
(vendor) => slug.startsWith(`${vendor}/`) || name.startsWith(vendor),
|
||||
);
|
||||
|
||||
if (vendor_tier >= 0) {
|
||||
return vendor_tier;
|
||||
}
|
||||
|
||||
if (harness != "text" && harness != "vision") {
|
||||
return OtherTier;
|
||||
}
|
||||
|
||||
return isCompetent({ model }) ? OtherTier : undefined;
|
||||
}
|
||||
|
||||
function isCompetent({ model }: { model: HarnessCatalogModel }) {
|
||||
if (
|
||||
model.context_length &&
|
||||
model.context_length < AppData.HarnessAutoMinContext
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (model.intelligence_score !== undefined) {
|
||||
return model.intelligence_score >= AppData.HarnessAutoMinScore;
|
||||
}
|
||||
|
||||
const size = parseModelSize({ slug: model.slug });
|
||||
|
||||
return (
|
||||
!SmallModelPattern.test(model.slug) &&
|
||||
(size === undefined || size >= AppData.HarnessAutoMinSizeB)
|
||||
);
|
||||
}
|
||||
|
||||
function compareInTier(a: HarnessCatalogModel, b: HarnessCatalogModel) {
|
||||
const a_scored = a.intelligence_score !== undefined;
|
||||
const b_scored = b.intelligence_score !== undefined;
|
||||
|
||||
if (a_scored != b_scored) {
|
||||
return a_scored ? -1 : 1;
|
||||
}
|
||||
|
||||
if (a_scored && b_scored) {
|
||||
const by_score =
|
||||
(b.intelligence_score || 0) - (a.intelligence_score || 0) ||
|
||||
(b.agentic_score || 0) - (a.agentic_score || 0);
|
||||
|
||||
if (by_score) return by_score;
|
||||
}
|
||||
|
||||
const a_small = isSmall(a);
|
||||
const b_small = isSmall(b);
|
||||
|
||||
if (a_small != b_small) {
|
||||
return a_small ? 1 : -1;
|
||||
}
|
||||
|
||||
return (
|
||||
(b.released_at || 0) - (a.released_at || 0) ||
|
||||
(b.context_length || 0) - (a.context_length || 0) ||
|
||||
a.slug.localeCompare(b.slug)
|
||||
);
|
||||
}
|
||||
|
||||
function isSmall(model: HarnessCatalogModel) {
|
||||
const size = parseModelSize({ slug: model.slug });
|
||||
|
||||
return (
|
||||
SmallModelPattern.test(model.slug) ||
|
||||
(size !== undefined && size < AppData.HarnessAutoMinSizeB)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* - `provider`: the provider refused or failed the call
|
||||
* - `unavailable`: no model is set up (or left) for the harness
|
||||
* - `aborted`: the caller cancelled
|
||||
*/
|
||||
export type HarnessErrorCode = "provider" | "unavailable" | "aborted";
|
||||
|
||||
type Params = {
|
||||
message: string;
|
||||
code?: HarnessErrorCode;
|
||||
status?: number;
|
||||
retryable?: boolean;
|
||||
retry_at?: number;
|
||||
provider_wide?: boolean;
|
||||
candidate_key?: string;
|
||||
resting?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A failed harness call. Its message is safe to show the user.
|
||||
*/
|
||||
export default class HarnessError extends Error {
|
||||
code: HarnessErrorCode;
|
||||
status?: number;
|
||||
/**
|
||||
* Worth trying again later (rate limit, outage, timeout). False when
|
||||
* only a different model or setting can help.
|
||||
*/
|
||||
retryable: boolean;
|
||||
/**
|
||||
* Unix ms before which the failed candidate shouldn't be tried again.
|
||||
*/
|
||||
retry_at?: number;
|
||||
/**
|
||||
* The provider's key or account is the problem, so every model on it
|
||||
* fails the same way.
|
||||
*/
|
||||
provider_wide: boolean;
|
||||
candidate_key?: string;
|
||||
/**
|
||||
* No model was tried: every one is cooling down until `retry_at`. A
|
||||
* queued job waits instead of using up an attempt.
|
||||
*/
|
||||
resting: boolean;
|
||||
|
||||
constructor({
|
||||
message,
|
||||
code = "provider",
|
||||
status,
|
||||
retryable = false,
|
||||
retry_at,
|
||||
provider_wide = false,
|
||||
candidate_key,
|
||||
resting = false,
|
||||
}: Params) {
|
||||
super(message);
|
||||
this.name = "HarnessError";
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
this.retryable = retryable;
|
||||
this.retry_at = retry_at;
|
||||
this.provider_wide = provider_wide;
|
||||
this.candidate_key = candidate_key;
|
||||
this.resting = resting;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import grabHarnessAdapter from "../providers/grab-harness-adapter";
|
||||
import resolveHarnessCandidates from "../resolve/resolve-harness-candidates";
|
||||
import withCandidates from "../resolve/with-candidates";
|
||||
|
||||
type Params = {
|
||||
prompt: string;
|
||||
n?: number;
|
||||
size?: string;
|
||||
/**
|
||||
* Reference images: https or `data:` URLs.
|
||||
*/
|
||||
references?: string[];
|
||||
signal?: AbortSignal;
|
||||
/**
|
||||
* Uses this bot's image model when it has its own.
|
||||
*/
|
||||
bot_id?: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Makes images with the image generation harness. Returns their bytes.
|
||||
*/
|
||||
export default async function generateImage({
|
||||
prompt,
|
||||
n,
|
||||
size,
|
||||
references,
|
||||
signal,
|
||||
bot_id,
|
||||
}: Params) {
|
||||
const candidates = await resolveHarnessCandidates({ harness: "image", bot_id });
|
||||
|
||||
const { result } = await withCandidates({
|
||||
harness: "image",
|
||||
candidates,
|
||||
run: async (candidate) =>
|
||||
await grabHarnessAdapter({
|
||||
kind: candidate.provider.kind,
|
||||
}).generateImage({
|
||||
candidate,
|
||||
prompt,
|
||||
n,
|
||||
size,
|
||||
references,
|
||||
signal: signal || new AbortController().signal,
|
||||
}),
|
||||
});
|
||||
|
||||
return result.images;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* The server's MCP endpoint, e.g. `http://cobble-computer:8080/mcp`.
|
||||
*/
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
/**
|
||||
* Connects to an MCP server over Streamable HTTP, falling back to the
|
||||
* older SSE transport. Close the client when the run is done.
|
||||
*/
|
||||
export default async function connectMcpServer({
|
||||
url,
|
||||
headers,
|
||||
signal,
|
||||
}: Params): Promise<Client> {
|
||||
const endpoint = new URL(url);
|
||||
const request_init = headers ? { headers } : undefined;
|
||||
const options = { signal, timeout: AppData.McpConnectTimeoutMs };
|
||||
|
||||
try {
|
||||
const client = newClient();
|
||||
await client.connect(
|
||||
new StreamableHTTPClientTransport(endpoint, { requestInit: request_init }),
|
||||
options,
|
||||
);
|
||||
return client;
|
||||
} catch (error: unknown) {
|
||||
if (signal?.aborted) throw error;
|
||||
|
||||
try {
|
||||
const client = newClient();
|
||||
await client.connect(
|
||||
new SSEClientTransport(endpoint, { requestInit: request_init }),
|
||||
options,
|
||||
);
|
||||
return client;
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Couldn't connect to the MCP server at ${endpoint.origin}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function newClient() {
|
||||
return new Client({ name: SiteData["SiteSlug"], version: "1.0.0" });
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import type { HarnessTool } from "../types";
|
||||
import mcpResultToText, { type McpImage } from "./mcp-result-to-text";
|
||||
|
||||
type Params = {
|
||||
client: Client;
|
||||
/**
|
||||
* Tool names to leave out.
|
||||
*/
|
||||
exclude?: readonly string[];
|
||||
describeImage?: (image: McpImage) => Promise<string>;
|
||||
/**
|
||||
* Applied to every result (and error) before the model sees it, e.g.
|
||||
* to hide secret values.
|
||||
*/
|
||||
redact?: (text: string) => string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Every tool an MCP server offers, as harness tools. Arguments go to the
|
||||
* server as the model wrote them (the server checks them against its own
|
||||
* schema); results come back as text via `mcpResultToText`.
|
||||
*/
|
||||
export default async function grabMcpTools({
|
||||
client,
|
||||
exclude = [],
|
||||
describeImage,
|
||||
redact = (text) => text,
|
||||
}: Params): Promise<HarnessTool[]> {
|
||||
const tools: HarnessTool[] = [];
|
||||
let cursor: string | undefined;
|
||||
|
||||
do {
|
||||
const page = await client.listTools(cursor ? { cursor } : undefined, {
|
||||
timeout: AppData.McpConnectTimeoutMs,
|
||||
});
|
||||
|
||||
for (const tool of page.tools) {
|
||||
if (exclude.includes(tool.name)) continue;
|
||||
|
||||
tools.push({
|
||||
name: tool.name,
|
||||
description: tool.description || tool.title || tool.name,
|
||||
parameters: tool.inputSchema as Record<string, unknown>,
|
||||
run: async ({ args, signal }) => {
|
||||
try {
|
||||
return redact(
|
||||
await mcpResultToText({
|
||||
result: await client.callTool(
|
||||
{ name: tool.name, arguments: args },
|
||||
undefined,
|
||||
{
|
||||
signal,
|
||||
timeout: AppData.McpToolTimeoutMs,
|
||||
resetTimeoutOnProgress: true,
|
||||
},
|
||||
),
|
||||
describeImage,
|
||||
}),
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) error.message = redact(error.message);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
cursor = page.nextCursor;
|
||||
} while (cursor);
|
||||
|
||||
return tools;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import mcpResultToText from "./mcp-result-to-text";
|
||||
|
||||
describe("mcpResultToText", () => {
|
||||
test("joins text parts", async () => {
|
||||
expect(
|
||||
await mcpResultToText({
|
||||
result: {
|
||||
content: [
|
||||
{ type: "text", text: "one" },
|
||||
{ type: "text", text: "two" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
).toBe("one\n\ntwo");
|
||||
});
|
||||
|
||||
test("describes images with the given reader", async () => {
|
||||
expect(
|
||||
await mcpResultToText({
|
||||
result: {
|
||||
content: [{ type: "image", data: "aGk=", mimeType: "image/png" }],
|
||||
},
|
||||
describeImage: async ({ data, mime_type }) =>
|
||||
`${mime_type} ${data}`,
|
||||
}),
|
||||
).toBe("[Image: image/png aGk=]");
|
||||
});
|
||||
|
||||
test("notes images when nothing can read them", async () => {
|
||||
expect(
|
||||
await mcpResultToText({
|
||||
result: {
|
||||
content: [{ type: "image", data: "aGk=", mimeType: "image/png" }],
|
||||
},
|
||||
}),
|
||||
).toBe("[Image (image/png)]");
|
||||
});
|
||||
|
||||
test("keeps going when an image can't be read", async () => {
|
||||
expect(
|
||||
await mcpResultToText({
|
||||
result: { content: [{ type: "image", data: "aGk=" }] },
|
||||
describeImage: async () => {
|
||||
throw new Error("No vision model.");
|
||||
},
|
||||
}),
|
||||
).toBe("[Image: couldn't be read. No vision model.]");
|
||||
});
|
||||
|
||||
test("falls back to structured content", async () => {
|
||||
expect(
|
||||
await mcpResultToText({
|
||||
result: { content: [], structuredContent: { ok: true } },
|
||||
}),
|
||||
).toBe(`{"ok":true}`);
|
||||
});
|
||||
|
||||
test("throws error results", async () => {
|
||||
expect(
|
||||
mcpResultToText({
|
||||
result: {
|
||||
isError: true,
|
||||
content: [{ type: "text", text: "No such file" }],
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("No such file");
|
||||
});
|
||||
|
||||
test("says when the tool returned nothing", async () => {
|
||||
expect(await mcpResultToText({ result: {} })).toBe(
|
||||
"Done. The tool returned nothing.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
export type McpImage = {
|
||||
/**
|
||||
* Base64.
|
||||
*/
|
||||
data: string;
|
||||
mime_type: string;
|
||||
};
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* A `tools/call` result.
|
||||
*/
|
||||
result: {
|
||||
content?: unknown;
|
||||
structuredContent?: unknown;
|
||||
isError?: unknown;
|
||||
/**
|
||||
* Old servers (protocol 2024-10-07) answer with this instead.
|
||||
*/
|
||||
toolResult?: unknown;
|
||||
};
|
||||
/**
|
||||
* Turns an image (a screenshot) into text for the model. Without it,
|
||||
* images are noted but not described.
|
||||
*/
|
||||
describeImage?: (image: McpImage) => Promise<string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* An MCP tool result as text for the model: text as is, images described,
|
||||
* resources by their text or link. Tool messages carry text only, so an
|
||||
* image reaches the model as its reading. A result flagged as an error is
|
||||
* thrown, so the model gets it as `Error: ...`.
|
||||
*/
|
||||
export default async function mcpResultToText({
|
||||
result,
|
||||
describeImage,
|
||||
}: Params): Promise<string> {
|
||||
const parts = Array.isArray(result.content) ? result.content : [];
|
||||
const texts: string[] = [];
|
||||
|
||||
for (const part of parts) {
|
||||
texts.push(await partToText({ part, describeImage }));
|
||||
}
|
||||
|
||||
const structured = result.structuredContent ?? result.toolResult;
|
||||
|
||||
if (!texts.length && structured !== undefined) {
|
||||
texts.push(typeof structured == "string" ? structured : JSON.stringify(structured));
|
||||
}
|
||||
|
||||
const text = texts.filter(Boolean).join("\n\n") || "Done. The tool returned nothing.";
|
||||
|
||||
if (result.isError) {
|
||||
throw new Error(text);
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
async function partToText({
|
||||
part,
|
||||
describeImage,
|
||||
}: {
|
||||
part: any;
|
||||
describeImage?: Params["describeImage"];
|
||||
}): Promise<string> {
|
||||
switch (part?.type) {
|
||||
case "text":
|
||||
return String(part.text ?? "");
|
||||
|
||||
case "image": {
|
||||
if (!describeImage || typeof part.data != "string") {
|
||||
return `[Image (${part.mimeType || "unknown type"})]`;
|
||||
}
|
||||
|
||||
try {
|
||||
return `[Image: ${await describeImage({
|
||||
data: part.data,
|
||||
mime_type: part.mimeType || "image/png",
|
||||
})}]`;
|
||||
} catch (error: unknown) {
|
||||
return `[Image: couldn't be read. ${error instanceof Error ? error.message : String(error)}]`;
|
||||
}
|
||||
}
|
||||
|
||||
case "audio":
|
||||
return `[Audio (${part.mimeType || "unknown type"})]`;
|
||||
|
||||
case "resource":
|
||||
return typeof part.resource?.text == "string"
|
||||
? part.resource.text
|
||||
: `[File: ${part.resource?.uri || "unnamed"}]`;
|
||||
|
||||
case "resource_link":
|
||||
return `[Link: ${part.name || part.uri} ${part.uri || ""}]`.trim();
|
||||
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { ReadImagePrompt } from "@/src/data/harness-prompts";
|
||||
import HarnessError from "../harness-error";
|
||||
import grabHarnessAdapter from "../providers/grab-harness-adapter";
|
||||
import resolveHarnessCandidates from "../resolve/resolve-harness-candidates";
|
||||
import withCandidates from "../resolve/with-candidates";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* An https or `data:` URL.
|
||||
*/
|
||||
url: string;
|
||||
/**
|
||||
* What to look for, added to the standard reading prompt.
|
||||
*/
|
||||
focus?: string;
|
||||
signal?: AbortSignal;
|
||||
/**
|
||||
* Uses this bot's media reading model when it has its own.
|
||||
*/
|
||||
bot_id?: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads an image with the media reading (vision) harness and returns what
|
||||
* it sees, as text for a model that can't see.
|
||||
*/
|
||||
export default async function readImage({ url, focus, signal, bot_id }: Params) {
|
||||
const candidates = await resolveHarnessCandidates({
|
||||
harness: "vision",
|
||||
bot_id,
|
||||
});
|
||||
|
||||
const { result } = await withCandidates({
|
||||
harness: "vision",
|
||||
candidates,
|
||||
run: async (candidate) => {
|
||||
const answer = await grabHarnessAdapter({
|
||||
kind: candidate.provider.kind,
|
||||
}).chat({
|
||||
candidate,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: focus
|
||||
? `${ReadImagePrompt}\n\nPay special attention to: ${focus}`
|
||||
: ReadImagePrompt,
|
||||
},
|
||||
{ type: "image", url },
|
||||
],
|
||||
},
|
||||
],
|
||||
temperature: 0,
|
||||
signal: signal || new AbortController().signal,
|
||||
});
|
||||
|
||||
if (!answer.text?.trim()) {
|
||||
throw new HarnessError({
|
||||
message: `The model couldn't read the image.`,
|
||||
retryable: true,
|
||||
candidate_key: candidate.key,
|
||||
});
|
||||
}
|
||||
|
||||
return answer.text.trim();
|
||||
},
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import fileToText from "@/src/functions/backend/db/users/media/file-to-text";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import transcribeAudio from "../audio/transcribe-audio";
|
||||
import readImage from "./read-image";
|
||||
|
||||
type Params = {
|
||||
data: Uint8Array;
|
||||
/**
|
||||
* e.g. `image/png`, `audio/webm`, `application/pdf`.
|
||||
*/
|
||||
media_type: string;
|
||||
/**
|
||||
* File name, used for its extension when the type is vague.
|
||||
*/
|
||||
name?: string;
|
||||
signal?: AbortSignal;
|
||||
/**
|
||||
* Reads with this bot's models when it has its own.
|
||||
*/
|
||||
bot_id?: number | string;
|
||||
};
|
||||
|
||||
const DocumentExtensions = ["pdf", "docx", "txt", "md", "csv", "json", "html", "xml", "yaml", "yml"];
|
||||
|
||||
const TypeExtensions: Record<string, string> = {
|
||||
"application/pdf": "pdf",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document":
|
||||
"docx",
|
||||
"text/plain": "txt",
|
||||
"text/markdown": "md",
|
||||
"text/csv": "csv",
|
||||
"application/json": "json",
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads any attachment into text for the text harness: documents are
|
||||
* extracted on this machine, images go to the media reading harness and
|
||||
* audio to the transcription harness.
|
||||
*/
|
||||
export default async function readMedia({
|
||||
data,
|
||||
media_type,
|
||||
name,
|
||||
signal,
|
||||
bot_id,
|
||||
}: Params): Promise<string> {
|
||||
const type = media_type.toLowerCase().split(";")[0]!.trim();
|
||||
const ext = (
|
||||
name?.includes(".")
|
||||
? name.split(".").pop()
|
||||
: TypeExtensions[type] || type.split("/").pop()
|
||||
)?.toLowerCase() || "";
|
||||
|
||||
if (type.startsWith("image/")) {
|
||||
return await readImage({
|
||||
url: `data:${type};base64,${Buffer.from(data).toString("base64")}`,
|
||||
signal,
|
||||
bot_id,
|
||||
});
|
||||
}
|
||||
|
||||
if (type.startsWith("audio/")) {
|
||||
return await transcribeAudio({
|
||||
audio: { data, format: audioFormat({ type, ext }) },
|
||||
signal,
|
||||
bot_id,
|
||||
});
|
||||
}
|
||||
|
||||
if (type.startsWith("text/") || DocumentExtensions.includes(ext)) {
|
||||
const text = await fileToText({
|
||||
file_stream: Buffer.from(data),
|
||||
file_type: ext,
|
||||
});
|
||||
|
||||
if (!text?.trim()) {
|
||||
throw new AppError(
|
||||
ext == "pdf"
|
||||
? `This PDF has no text in it (it may be a scan). Send its pages as images instead.`
|
||||
: `This file has no text in it.`,
|
||||
);
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
throw new AppError(`Bots can't read ${ext ? `.${ext}` : "this kind of"} files yet.`);
|
||||
}
|
||||
|
||||
function audioFormat({ type, ext }: { type: string; ext: string }) {
|
||||
if (type.includes("mpeg") || ext == "mp3") return "mp3";
|
||||
if (type.includes("mp4") || ext == "m4a") return "m4a";
|
||||
if (type.includes("wav")) return "wav";
|
||||
if (type.includes("ogg")) return "ogg";
|
||||
if (type.includes("flac")) return "flac";
|
||||
return ext || "webm";
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { HarnessProviderKind } from "@/src/dict/harness-provider-dict";
|
||||
import type { HarnessAdapter } from "../types";
|
||||
import OpenAICompatibleAdapter from "./openai-compatible";
|
||||
|
||||
type Params = {
|
||||
kind: HarnessProviderKind;
|
||||
};
|
||||
|
||||
/**
|
||||
* The adapter for a provider kind. Every kind speaks the OpenAI-compatible
|
||||
* API today; a provider that gets a native adapter returns it here.
|
||||
*/
|
||||
export default function grabHarnessAdapter({ kind }: Params): HarnessAdapter {
|
||||
switch (kind) {
|
||||
default:
|
||||
return OpenAICompatibleAdapter;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
import type { HarnessProviderConfig } from "../types";
|
||||
|
||||
type Params = {
|
||||
provider: HarnessProviderConfig;
|
||||
};
|
||||
|
||||
/**
|
||||
* Auth and attribution headers for a provider call.
|
||||
*/
|
||||
export default function grabProviderHeaders({
|
||||
provider,
|
||||
}: Params): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
if (provider.api_key) {
|
||||
headers["Authorization"] = `Bearer ${provider.api_key}`;
|
||||
}
|
||||
|
||||
switch (provider.kind) {
|
||||
case "openrouter":
|
||||
headers["HTTP-Referer"] = SiteData["SiteURL"];
|
||||
headers["X-Title"] = SiteData["SiteName"];
|
||||
break;
|
||||
|
||||
case "anthropic":
|
||||
if (provider.api_key) {
|
||||
headers["x-api-key"] = provider.api_key;
|
||||
}
|
||||
headers["anthropic-version"] = "2023-06-01";
|
||||
break;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
HarnessProviderKinds,
|
||||
type HarnessProviderKind,
|
||||
type HarnessProviderPreset,
|
||||
} from "@/src/dict/harness-provider-dict";
|
||||
|
||||
type Params = {
|
||||
kind: HarnessProviderKind | string | null | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* The preset for a provider kind. Unknown kinds are treated as a generic
|
||||
* OpenAI-compatible server.
|
||||
*/
|
||||
export default function grabProviderPreset({
|
||||
kind,
|
||||
}: Params): HarnessProviderPreset {
|
||||
return (
|
||||
HarnessProviderKinds.find((preset) => preset.value == kind) ||
|
||||
HarnessProviderKinds.find((preset) => preset.value == "custom")!
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { HarnessAdapter } from "../../types";
|
||||
import grabProviderPreset from "../grab-provider-preset";
|
||||
import providerRequest from "./provider-request";
|
||||
import readChatResponse from "./read-chat-response";
|
||||
import toWireMessages from "./to-wire-messages";
|
||||
|
||||
/**
|
||||
* `POST /chat/completions`, with tools when given.
|
||||
*/
|
||||
const chat: HarnessAdapter["chat"] = async ({
|
||||
candidate,
|
||||
messages,
|
||||
tools,
|
||||
max_tokens,
|
||||
temperature,
|
||||
signal,
|
||||
}) => {
|
||||
const { provider, model } = candidate;
|
||||
|
||||
const res = await providerRequest({
|
||||
provider,
|
||||
path: "/chat/completions",
|
||||
json: {
|
||||
model: model.slug,
|
||||
messages: toWireMessages({ messages }),
|
||||
...(tools?.length
|
||||
? {
|
||||
tools: tools.map((tool) => ({
|
||||
type: "function",
|
||||
function: tool,
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
...(max_tokens ? { max_tokens } : {}),
|
||||
...(temperature !== undefined ? { temperature } : {}),
|
||||
},
|
||||
signal,
|
||||
candidate_key: candidate.key,
|
||||
});
|
||||
|
||||
return readChatResponse({
|
||||
json: await res.json().catch(() => null),
|
||||
provider_name:
|
||||
provider.name || grabProviderPreset({ kind: provider.kind }).title,
|
||||
candidate_key: candidate.key,
|
||||
});
|
||||
};
|
||||
|
||||
export default chat;
|
||||
@@ -0,0 +1,81 @@
|
||||
import HarnessError from "../../harness-error";
|
||||
import type { HarnessAdapter, HarnessImageOutput } from "../../types";
|
||||
import grabProviderPreset from "../grab-provider-preset";
|
||||
import providerRequest from "./provider-request";
|
||||
|
||||
/**
|
||||
* Image generation at the provider's `image_path` (`/images` on
|
||||
* OpenRouter, `/images/generations` elsewhere). Images come back as
|
||||
* base64, or as URLs that are downloaded here.
|
||||
*/
|
||||
const generateImage: HarnessAdapter["generateImage"] = async ({
|
||||
candidate,
|
||||
prompt,
|
||||
n,
|
||||
size,
|
||||
references,
|
||||
signal,
|
||||
}) => {
|
||||
const { provider, model } = candidate;
|
||||
const preset = grabProviderPreset({ kind: provider.kind });
|
||||
const is_openrouter = provider.kind == "openrouter";
|
||||
|
||||
const res = await providerRequest({
|
||||
provider,
|
||||
path: preset.image_path,
|
||||
json: {
|
||||
model: model.slug,
|
||||
prompt,
|
||||
...(n ? { n } : {}),
|
||||
...(size ? { size } : {}),
|
||||
...(is_openrouter
|
||||
? references?.length
|
||||
? {
|
||||
input_references: references.map((url) => ({
|
||||
type: "image_url",
|
||||
image_url: { url },
|
||||
})),
|
||||
}
|
||||
: {}
|
||||
: { response_format: "b64_json" }),
|
||||
},
|
||||
signal,
|
||||
candidate_key: candidate.key,
|
||||
});
|
||||
|
||||
const json = await res.json().catch(() => null);
|
||||
const items: any[] = Array.isArray(json?.data) ? json.data : [];
|
||||
const images: HarnessImageOutput[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (typeof item?.b64_json == "string") {
|
||||
images.push({
|
||||
data: Uint8Array.from(Buffer.from(item.b64_json, "base64")),
|
||||
media_type: item.media_type || "image/png",
|
||||
});
|
||||
} else if (typeof item?.url == "string") {
|
||||
const download = await fetch(item.url, { signal });
|
||||
|
||||
if (download.ok) {
|
||||
images.push({
|
||||
data: new Uint8Array(await download.arrayBuffer()),
|
||||
media_type:
|
||||
download.headers.get("content-type")?.split(";")[0] ||
|
||||
"image/png",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!images.length) {
|
||||
throw new HarnessError({
|
||||
message: `${provider.name || preset.title}: No image came back.`,
|
||||
retryable: true,
|
||||
candidate_key: candidate.key,
|
||||
});
|
||||
}
|
||||
|
||||
return { images };
|
||||
};
|
||||
|
||||
export default generateImage;
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { HarnessAdapter } from "../../types";
|
||||
import chat from "./chat";
|
||||
import generateImage from "./generate-image";
|
||||
import listModels from "./list-models";
|
||||
import speak from "./speak";
|
||||
import transcribe from "./transcribe";
|
||||
|
||||
/**
|
||||
* The OpenAI-compatible API: OpenRouter, OpenAI, Groq, Mistral, xAI,
|
||||
* DeepSeek, Gemini and Anthropic (through their compatibility layers),
|
||||
* Ollama, vLLM, LM Studio and llama.cpp.
|
||||
*/
|
||||
const OpenAICompatibleAdapter: HarnessAdapter = {
|
||||
chat,
|
||||
transcribe,
|
||||
speak,
|
||||
generateImage,
|
||||
listModels,
|
||||
};
|
||||
|
||||
export default OpenAICompatibleAdapter;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user