Add workspaces, conversation sessions, app-managed computers and pictures
- Rail: Add icon with stacked form modals (bot, workspace, computer), coordinator, recent bots with "See all bots", Workspaces section, and a workspace mode listing only its crew - Workspaces: page for context, bots (per-workspace instructions and memory), computers (default, link, unlink), models and delete; conversations scoped per bot per workspace - Sessions: conversations split into searchable, reopenable sessions with carry-over summaries; sessions and new-session header icons - Computers: created and provisioned over the docker socket, home dir kept at DATA_DIR/computers/<id> after delete; super admins only - Models: workspace settings win over a bot's own, then the defaults - Pictures for bots and workspaces; bots editable from the header - Coordinator tools: update_workspace, set_bot_workspace_instructions Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -53,7 +53,7 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - a read-onl
|
|||||||
### Auth
|
### Auth
|
||||||
|
|
||||||
- **Auth guard:** `src/pages/__root.server.ts` - server function that runs on every request.
|
- **Auth guard:** `src/pages/__root.server.ts` - server function that runs on every request.
|
||||||
- No public pages: anything outside the live admin sections (`AdminSections` in `src/dict/admin-sections-dict.ts`: `/admin/bots`, `/admin/settings`) and `/auth` redirects to `AdminHomePath` (`/admin/bots`). `/admin` itself only redirects there. People, logs and backups exist in code but are closed until added back to `AdminSections` and the aside dict
|
- No public pages: anything outside the live admin sections (`AdminSections` in `src/dict/admin-sections-dict.ts`: `/admin/bots`, `/admin/workspaces`, `/admin/settings`) and `/auth` redirects to `AdminHomePath` (`/admin/bots`). `/admin` itself only redirects there. People, logs and backups exist in code but are closed until added back to `AdminSections` and the aside dict
|
||||||
- No users at all: everything redirects to `/auth/signup` (first-run super admin setup, `grabHasUsers`); once a user exists `/auth/signup` redirects away
|
- No users at all: everything redirects to `/auth/signup` (first-run super admin setup, `grabHasUsers`); once a user exists `/auth/signup` redirects away
|
||||||
- Redirects unauthenticated `/admin/*` → `/auth/login`
|
- Redirects unauthenticated `/admin/*` → `/auth/login`
|
||||||
- Unknown `/admin/settings/*` pages (not in `SettingsPages`, `src/utils/is-unknown-settings-path.ts`) redirect to `/admin/settings/general`
|
- Unknown `/admin/settings/*` pages (not in `SettingsPages`, `src/utils/is-unknown-settings-path.ts`) redirect to `/admin/settings/general`
|
||||||
@@ -83,23 +83,25 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - a read-onl
|
|||||||
|
|
||||||
### Bots (the chat shell)
|
### Bots (the chat shell)
|
||||||
|
|
||||||
- **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`.
|
- **Tables:** `bots` (per user; `kind` worker|coordinator, `hue`, `role`, `face`, `status_line`, `system_prompt` (the bot's core instructions, markdown)), `bot_sessions`, `bot_messages`, `bot_tasks`, `bot_task_steps`. Enums in `src/dict/bot-dict.ts`.
|
||||||
|
- **Threads and sessions:** a bot has one conversation of its own and one per workspace it's in (the coordinator in every workspace). Each is split into `bot_sessions` (`bot_id`, `workspace_id` null for its own chat, `status` active|closed, `title` from the first user message, `summary`, `previous_session_id`, `unread`, `last_activity_at`); `bot_messages`, `bot_tasks` and `bots_queue` carry `session_id` and `workspace_id`. Helpers in `src/functions/backend/db/bot-sessions/`: `ensureBotSession` (the active one, else a new one; `rollover` starts the next once it has `AppData.BotSessionMaxMessages`), `startBotSession`, `reopenBotSession`, `grabBotSessions` (search over title, summary and message text), `grabUserConversation` (ownership and membership), `grabConversationProps` (the conversation pages' props). A new session's first reply gets the previous one's summary (`grab-session-carry-over.ts`, written once with the text harness). The header's sessions icon (`(partials)/bot-sessions-modal.tsx`) lists, searches, starts and reopens sessions, and the new-session icon beside it (`(partials)/new-session-button.tsx`) starts one (`api/admin/bots/list-sessions`, `new-session`, `reopen-session`). Unread, the rail's last message and activity are per thread, from its sessions. Job results go into the job's own session, even after it closes.
|
||||||
- **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.
|
- **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 the conversation (header, thread and composer, the last two held to 760px; there is no right 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.
|
- **Routes:** `/admin/bots` ensures the coordinator and redirects to its chat (`/admin/bots/1` on a fresh install); `/admin/bots/[bot_id]` renders the conversation (header, thread and composer, the last two held to 760px; there is no right panel); `/admin/bots/[bot_id]/models` is a worker bot's own model settings (the coordinator's redirects to its chat); `/admin/workspaces/[workspace_id]` is a workspace's page (`(sections)/workspace-overview.tsx`: context, bots with their per-workspace instructions and memory, computers, delete, models) and `/admin/workspaces/[workspace_id]/bots/[bot_id]` is the same `Conversation` section scoped to the workspace (`pageProps.workspace`; only the coordinator and member bots). All 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.
|
- **Server:** `src/functions/backend/db/bots/` (`grab-bots`, `grab-bot-messages`, `grab-active-bot-task`, `grab-user-bot`, `create-bot`, `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 a session's unread when a message arrives in the open conversation; `create-bot.ts` adds a worker (and puts it in a workspace when given one); `save-harness-settings.ts` saves a worker bot's models.
|
||||||
- **Message rendering:** bot messages are markdown, rendered with `react-markdown` + `remark-gfm` (raw HTML never rendered) through the twui-based map in `[bot_id]/(data)/message-markdown-components.tsx` (`markdown-link`, `markdown-list`, `markdown-code-block` partials). User messages stay plain text.
|
- **Message rendering:** bot messages are markdown, rendered with `react-markdown` + `remark-gfm` (raw HTML never rendered) through the twui-based map in `[bot_id]/(data)/message-markdown-components.tsx` (`markdown-link`, `markdown-list`, `markdown-code-block` partials). User messages stay plain text.
|
||||||
- **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; while a task runs it reads "<name> is working…" with the task's latest step. A coordinator with no workers gets `CoordinatorSoloNote` instead of `CoordinatorReplyNote`, so it never claims to have handed work to a worker. Live notices come from the process that ran the job at once, and from every other process through the live relay.
|
- **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; while a task runs it reads "<name> is working…" with the task's latest step. A coordinator with no workers gets `CoordinatorSoloNote` instead of `CoordinatorReplyNote`, so it never claims to have handed work to a worker. Live notices come from the process that ran the job at once, and from every other process through the live relay.
|
||||||
|
- **Pictures:** a bot or workspace can have a picture (`image_media_id`, a private `media` row, paradigm `bot-image`/`workspace-image`), set or removed through `api/admin/set-profile-image.ts` (`setProfileImage`, the old one deleted; served at `profileImageUrls`). `BotAvatar` shows a bot's picture as a rounded square, else its stone; its hue still applies. `WorkspaceAvatar` shows a workspace's, else the workspace icon. A bot is edited from the header avatar (`(partials)/bot-profile-modal.tsx`, the same `BotForm` as adding one, `api/admin/bots/update-bot.ts`; the coordinator keeps its name and stays hue-less).
|
||||||
- **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.
|
- **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).
|
- **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`.
|
- **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 (in a workspace only) `# Workspace: <name>` with its description and `system_prompt`, the bot's `# Your instructions in <name>` (`workspace_bots.system_prompt`), `# Memory`, then `# Memory in <name>` (`workspace_bots.memory`). In a workspace the bot learns into the workspace's instructions and memory by default (`scope: workspace` on `update_instructions`/`update_memory`, `WorkspaceLearningNote`) and into its core ones only when told to, or after suggesting it.
|
||||||
- **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 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`. Every model turn of a job is a step in `bots_queue_updates` (`kind` tool|note, one-line `text`, JSON `metadata`; tool names, never arguments; `describe-job-step.ts`), written through the job context's `report` (the text loop's `onStep`), which also sets `progress` and pushes `server:bot-job-update` (`BotJobUpdateData`). Steps are deleted with their job when it's pruned. The active task's latest `AppData.BotJobUpdatesShown` steps ride on `BotTaskWithSteps.updates` (`grabBotJobUpdates`); the conversation (`bot_task` page prop, `use-task-progress`) shows the latest one under `BotThinkingRow`'s "<name> is working…" while a task runs. Step text is the first line the model wrote, else plain labels for its tools (`JobStepToolLabels` in `src/dict/bot-queue-dict.ts`).
|
- **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 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`. Every model turn of a job is a step in `bots_queue_updates` (`kind` tool|note, one-line `text`, JSON `metadata`; tool names, never arguments; `describe-job-step.ts`), written through the job context's `report` (the text loop's `onStep`), which also sets `progress` and pushes `server:bot-job-update` (`BotJobUpdateData`). Steps are deleted with their job when it's pruned. The active task's latest `AppData.BotJobUpdatesShown` steps ride on `BotTaskWithSteps.updates` (`grabBotJobUpdates`); the conversation (`bot_task` page prop, `use-task-progress`) shows the latest one under `BotThinkingRow`'s "<name> is working…" while a task runs. Step text is the first line the model wrote, else plain labels for its tools (`JobStepToolLabels` in `src/dict/bot-queue-dict.ts`).
|
||||||
- **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`.
|
- **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. Every process also runs `liveRelay` (`src/live-relay/`): every `AppData.LiveRelayPollMs` it reads new `bot_messages`, new `bots_queue_updates` and changed bots from the database and pushes them to its own sockets, so work done by another process still shows live; clients drop what they already have. `server:bot-updated` carries the bot's `computer_id` when known, which keeps the header's computer in step across tabs. A rail row's subtitle is the bot's status line while it has one, else (coordinator) the crew counts while a worker is busy or needs you, else its last message as plain text (`src/utils/message-preview.ts`).
|
- **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. Every process also runs `liveRelay` (`src/live-relay/`): every `AppData.LiveRelayPollMs` it reads new `bot_messages`, new `bots_queue_updates` and changed bots from the database and pushes them to its own sockets, so work done by another process still shows live; clients drop what they already have. `server:bot-updated` carries the bot's `computer_id` when known, which keeps the header's computer in step across tabs. A rail row's subtitle is the bot's status line while it has one, else (coordinator) the crew counts while a worker is busy or needs you, else its last message as plain text (`src/utils/message-preview.ts`).
|
||||||
- **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.
|
- **Workspaces:** `workspaces` (per user, `name`, `description`, `system_prompt` shared by every bot in it, `computer_id` its default computer (not a foreign key), `archived`), with bots assigned through `workspace_bots` (plus that bot's `system_prompt` and `memory` there; the coordinator is an implicit member) and computers through `workspace_computers`. The first computer linked becomes the default; the user (workspace page, chat header), any bot there (`set_workspace_computer`) or the coordinator can switch it. A workspace with no computer has none: tasks needing one fail. Workspace models (`workspace_harness_settings`) win over a bot's own, then the defaults. Helpers in `src/functions/backend/db/workspaces/` (`create-workspace`, `update-workspace`, `delete-workspace`, `add/remove-bot-*`, `update-workspace-bot`, `add/remove-computer-*`, `set-workspace-computer`, `grab-user-workspaces`, `grab-workspace-bots`, `grab-workspace-computers`), all ownership-checked against the session user; API in `api/admin/workspaces/`. The coordinator sets a member's instructions there with `set_bot_workspace_instructions`, and changes any workspace's name, description or context with `update_workspace` (chat replies).
|
||||||
- **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 (the coordinator's are all of the user's computers), the one it's on first: `bots.computer_id` (not a foreign key; null or a lost grant means its main one), so `computers[0]` is where its tasks run by default. The user switches it from the chat header (`(partials)/bot-computers-modal.tsx`, a ghost icon beside the model icon; the header names the current computer and the text model and provider under the bot's name) or by clicking a computer the bot named in a message: bots write `[computer:ID]` (`src/utils/computer-ref.ts`, turned into `#computer-ID` links by `link-computer-refs.ts`, rendered by `(partials)/computer-ref-chip.tsx`), both sharing `BotComputersContext` / `use-bot-computers`, saved by `api/admin/bots/set-computer.ts` (`setBotComputer`). Enums in `src/dict/computer-dict.ts`.
|
- **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). The app creates and manages every computer's container over the docker socket (`src/functions/backend/docker/`; `DOCKER_SOCKET` or `AppData.DockerSocketPath`): `createComputer` records it and `provisionComputer` (in the background, status `creating`, then the container's or `failed` with `metadata.error`, pushed as `server:computer-updated`) pulls `AppData.ComputerImage`, binds the home dir and starts `<ComputerContainerPrefix>-<id>`. A computer's home (`/home/gem`) is `DATA_DIR/computers/<id>` (`AppData.ComputerDataDir`, owned by uid 1000); deleting the computer (`deleteComputer`, never the main one) removes the container and keeps that directory. Run in docker, the app resolves host paths from its own container's mounts and joins the network; outside docker the container is published on a free localhost port. Only super admins create, delete or retry computers (`api/admin/computers/`). The `sync-computers` cron records container states every five minutes. Each user has one main computer, created 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 grants the coordinator access; with `MAIN_COMPUTER_URL` set (dev) it isn't provisioned. 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`): in a workspace, the workspace's computers, its default first; otherwise its direct grants (the coordinator's are all of the user's computers), the one it's on first: `bots.computer_id` (not a foreign key; null or a lost grant means its main one), so `computers[0]` is where its tasks run by default. The user switches it from the chat header (`(partials)/bot-computers-modal.tsx`, a ghost icon beside the model icon; the header names the current computer and the text model and provider under the bot's name) or by clicking a computer the bot named in a message: bots write `[computer:ID]` (`src/utils/computer-ref.ts`, turned into `#computer-ID` links by `link-computer-refs.ts`, rendered by `(partials)/computer-ref-chip.tsx`), both sharing `BotComputersContext` / `use-bot-computers`, saved by `api/admin/bots/set-computer.ts` (`setBotComputer`). Enums in `src/dict/computer-dict.ts`.
|
||||||
- **Opening a computer:** the app serves each computer to its owner under `/computers/<id>/` (`ComputerProxyPrefix`): `src/server.ts` hands those paths to `handleComputerProxy` (`src/functions/server/computers/`: `userAuth`, ownership via `grabUserComputer`, then the sandbox's address from `grabComputerMcpUrl`), which fetches plain requests (app session cookies dropped, `X-Forwarded-Host/Proto/Prefix` set so the sandbox builds its links and CDP address under the prefix, framing headers and redirects fixed) and bridges websockets (`computerProxySocket`, dispatched from `src/websocket/` when `ws.data.proxy` is set). `ComputerView` (`src/components/general/computer-view.tsx`) shows a computer in one of `ComputerViewModes` (browser `/browser-ui`, terminal `/terminal`, code `/code-server/`; more of the sandbox's apps can be added there), keeping visited modes loaded. In the chat, `(partials)/computer-viewer-modal.tsx` shows it, opened from a computer card's open button in the computers modal or a take-over button (`openComputer` on `BotComputersContext`).
|
- **Opening a computer:** the app serves each computer to its owner under `/computers/<id>/` (`ComputerProxyPrefix`): `src/server.ts` hands those paths to `handleComputerProxy` (`src/functions/server/computers/`: `userAuth`, ownership via `grabUserComputer`, then the sandbox's address from `grabComputerMcpUrl`), which fetches plain requests (app session cookies dropped, `X-Forwarded-Host/Proto/Prefix` set so the sandbox builds its links and CDP address under the prefix, framing headers and redirects fixed) and bridges websockets (`computerProxySocket`, dispatched from `src/websocket/` when `ws.data.proxy` is set). `ComputerView` (`src/components/general/computer-view.tsx`) shows a computer in one of `ComputerViewModes` (browser `/browser-ui`, terminal `/terminal`, code `/code-server/`; more of the sandbox's apps can be added there), keeping visited modes loaded. In the chat, `(partials)/computer-viewer-modal.tsx` shows it, opened from a computer card's open button in the computers modal or a take-over button (`openComputer` on `BotComputersContext`).
|
||||||
- **Take-over:** a bot asks the user to take over a computer with `ask_user_to_take_over` (`src/harness/tools/computer/ask-to-take-over-tool.ts`, chat replies and tasks, defaulting to the task's computer). It writes `[take-over:ID:mode]` (`src/utils/take-over-ref.ts`; `withTakeOverRefs` adds it if the bot left it out and flags the message needs-you), linked by `link-computer-refs.ts` and rendered by `(partials)/take-over-chip.tsx`. The viewer then offers "Hand back to <bot>", which sends a chat message so the bot carries on; a task stops and reports instead of waiting.
|
- **Take-over:** a bot asks the user to take over a computer with `ask_user_to_take_over` (`src/harness/tools/computer/ask-to-take-over-tool.ts`, chat replies and tasks, defaulting to the task's computer). It writes `[take-over:ID:mode]` (`src/utils/take-over-ref.ts`; `withTakeOverRefs` adds it if the bot left it out and flags the message needs-you), linked by `link-computer-refs.ts` and rendered by `(partials)/take-over-chip.tsx`. The viewer then offers "Hand back to <bot>", which sends a chat message so the bot carries on; a task stops and reports instead of waiting.
|
||||||
- **Not built yet:** add-bot flow, routines, help, task steps (nothing writes `bot_task_steps` yet).
|
- **Not built yet:** routines, help, task steps (nothing writes `bot_task_steps` yet).
|
||||||
|
|
||||||
### Envs
|
### Envs
|
||||||
|
|
||||||
@@ -118,7 +120,7 @@ Envs are set in Settings, under Environment (super admin only), not in `.env`. T
|
|||||||
- **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.
|
- **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.
|
- **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`).
|
- **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 ghost model icon (`(partials)/header-icon-button.tsx`; the text model and provider show under the bot's name) that opens a modal with every harness (`(partials)/bot-models-modal.tsx`); the modal and `/admin/bots/[bot_id]/models` both use `(partials)/bot-harness-form.tsx`. On the coordinator the modal edits the defaults themselves (`api/admin/harness/save-settings`, the same settings as Settings, under Models, without the providers): super admins only, the icon is disabled for everyone else. Every one of these wraps `settings/models/(partials)/harness-settings-form.tsx`: per harness a provider select first (`harness-provider-select.tsx`: any provider, one provider, or on a bot "Same as the default"), then the model select narrowed to it. Removing a provider (`deleteHarnessProvider`) puts default and bot settings that picked it or its models back on any provider (`save` prop; "Same as the default" shows when the overview item has `default`).
|
- **Per-bot models:** in a workspace, its `workspace_harness_settings` row wins (`source: "workspace"`). Otherwise a worker bot inherits the defaults per harness unless it has a `bot_harness_settings` row (same fields as `harness_settings`, unique per bot and harness). `grabHarnessSetting({ harness, bot_id })` returns the bot's row (`source: "bot"`) or the default; the coordinator always gets the default. Every entry point takes `bot_id` and passes it on (text loop -> media reading/transcription), so a bot's run uses its own models throughout. Saved by `saveBotHarnessSetting` (`inherit` deletes the row); the page data is `grabBotHarnessOverview`. A bot's chat header has a ghost model icon (`(partials)/header-icon-button.tsx`; the text model and provider show under the bot's name) that opens a modal with every harness (`(partials)/bot-models-modal.tsx`); the modal and `/admin/bots/[bot_id]/models` both use `(partials)/bot-harness-form.tsx`. On the coordinator the modal edits the defaults themselves (`api/admin/harness/save-settings`, the same settings as Settings, under Models, without the providers): super admins only, the icon is disabled for everyone else. Every one of these wraps `settings/models/(partials)/harness-settings-form.tsx`: per harness a provider select first (`harness-provider-select.tsx`: any provider, one provider, or on a bot "Same as the default"), then the model select narrowed to it. Removing a provider (`deleteHarnessProvider`) puts default and bot settings that picked it or its models back on any provider (`save` prop; "Same as the default" shows when the overview item has `default`).
|
||||||
- **Tools:** `HarnessTool` (built per run with its bot bound): own tools validate with a zod `schema`; tools described elsewhere pass a JSON schema as `parameters` and validate themselves. `run-tool-call.ts` reports bad calls and failures back to the model as `Error: ...`. Bot tools in `src/harness/tools/bot/`: `update_memory`, `update_instructions` (`grabBotTools`, every run), `start_task` and, for the coordinator with workers, `delegate_task` (`grabBotChatTools`, chat replies only; the job's `requested_by_bot_id` is the coordinator, and `relayJobToRequester` posts the worker's result or failure in its conversation, pushed as `relay` by `pushBotNotice`), `ask_user_to_take_over` (see Take-over), plus for the coordinator `assign_computer` (grant or revoke a worker's direct access, `add/remove-bot-to/from-computer`; access through a workspace stays and the result says so). `search_computers` (`src/harness/tools/computer/search-computers-tool.ts`, chat replies and tasks) runs one read-only command on all of a bot's computers at once over the sandbox REST API (`run-computer-command.ts`), each output under its `[computer:ID]`, secrets redacted, capped by `AppData.ComputerSearch*`.
|
- **Tools:** `HarnessTool` (built per run with its bot bound): own tools validate with a zod `schema`; tools described elsewhere pass a JSON schema as `parameters` and validate themselves. `run-tool-call.ts` reports bad calls and failures back to the model as `Error: ...`. Bot tools in `src/harness/tools/bot/`: `update_memory`, `update_instructions` (`grabBotTools`, every run), `start_task` and, for the coordinator with workers, `delegate_task` (`grabBotChatTools`, chat replies only; the job's `requested_by_bot_id` is the coordinator, and `relayJobToRequester` posts the worker's result or failure in its conversation, pushed as `relay` by `pushBotNotice`), `ask_user_to_take_over` (see Take-over), plus for the coordinator `assign_computer` (grant or revoke a worker's direct access, `add/remove-bot-to/from-computer`; access through a workspace stays and the result says so). `search_computers` (`src/harness/tools/computer/search-computers-tool.ts`, chat replies and tasks) runs one read-only command on all of a bot's computers at once over the sandbox REST API (`run-computer-command.ts`), each output under its `[computer:ID]`, secrets redacted, capped by `AppData.ComputerSearch*`.
|
||||||
- **MCP:** `src/harness/mcp/`: `connectMcpServer` (Streamable HTTP, SSE fallback), `grabMcpTools` (each server tool becomes a `HarnessTool` with its JSON schema), `mcpResultToText` (text as is, images read by the vision harness, `isError` thrown). Computers are reached this way: `openComputerTools` (`src/harness/tools/computer/`) connects to the computer's AIO sandbox at `grabComputerMcpUrl` (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).
|
- **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`, the bot's computers (`compose-computers-note.ts`: what they are and that they're reached through `start_task`) 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`.
|
- **Replies and tasks:** `run-bot-reply.ts` answers from the last `BotReplyHistoryMessages` messages (`botMessagesToHarness`) with `ChatReplyNote`, the bot's computers (`compose-computers-note.ts`: what they are and that they're reached through `start_task`) 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`.
|
||||||
@@ -126,7 +128,7 @@ Envs are set in Settings, under Environment (super admin only), not in `.env`. T
|
|||||||
|
|
||||||
## Admin Route Hierarchy
|
## Admin Route Hierarchy
|
||||||
|
|
||||||
Live routes under `/admin` (see `AdminSections`): `/admin/bots` and `/admin/settings`. `/admin` redirects to `/admin/bots`; there is no dashboard. The rest below are built but closed for now (redirect to `/admin/bots`):
|
Live routes under `/admin` (see `AdminSections`): `/admin/bots`, `/admin/workspaces` (the index opens the first workspace) and `/admin/settings`. `/admin` redirects to `/admin/bots`; there is no dashboard. The rest below are built but closed for now (redirect to `/admin/bots`):
|
||||||
|
|
||||||
- `/admin/people` - admin users list, plus `create/`, `[user_id]/` (profile), `[user_id]/edit/`
|
- `/admin/people` - admin users list, plus `create/`, `[user_id]/` (profile), `[user_id]/edit/`
|
||||||
- `/admin/bots` (live) - the Cobble chat shell, plus `[bot_id]/`
|
- `/admin/bots` (live) - the Cobble chat shell, plus `[bot_id]/`
|
||||||
@@ -134,7 +136,7 @@ Live routes under `/admin` (see `AdminSections`): `/admin/bots` and `/admin/sett
|
|||||||
- `/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/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), `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
|
- `/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`).
|
Every `/admin/*` page renders in the bots layout (`src/layouts/bots/`); there is no separate admin layout or aside. The rail header's Add icon opens the Add flow (`(partials)/add-flow.tsx`, `AddFlowContext`): a bot, a workspace or (super admins) a computer, each form in a modal stacked over the choice, so closing it goes back there (the Bots and Workspaces labels have a "+" that opens the form directly); on a workspace's pages new bots and computers join it. The rail shows the crew (`(partials)/rail-crew-list.tsx`, needs `bots` and `workspaces` in page props): the coordinator, the latest `AppData.RailRecentBots` workers (plus any open, unread or needing you, `pick-rail-bots`), "See all bots" with a few stones opening every bot in a modal, then Workspaces with a "+". On `/admin/workspaces/*` it shows the workspace's crew instead (`(partials)/rail-workspace-list.tsx`: back link, the workspace, its coordinator, "Workspace bots"), 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`).
|
||||||
|
|
||||||
## Page Directory Rules
|
## Page Directory Rules
|
||||||
|
|
||||||
|
|||||||
+245
-13
@@ -5,6 +5,7 @@ import {
|
|||||||
BotHues,
|
BotHues,
|
||||||
BotKinds,
|
BotKinds,
|
||||||
BotMessageAuthors,
|
BotMessageAuthors,
|
||||||
|
BotSessionStatuses,
|
||||||
BotTaskStatuses,
|
BotTaskStatuses,
|
||||||
BotTaskStepStates,
|
BotTaskStepStates,
|
||||||
} from "@/src/dict/bot-dict";
|
} from "@/src/dict/bot-dict";
|
||||||
@@ -261,20 +262,14 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
|
|||||||
fieldDescription: `What the bot must always remember, in markdown. The bot keeps it up to date`,
|
fieldDescription: `What the bot must always remember, in markdown. The bot keeps it up to date`,
|
||||||
dataType: "TEXT",
|
dataType: "TEXT",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
fieldName: "unread",
|
|
||||||
dataType: "INTEGER",
|
|
||||||
options: [0, 1],
|
|
||||||
defaultValue: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
fieldName: "last_activity_at",
|
|
||||||
fieldDescription: `Unix timestamp of the latest message, for rail ordering`,
|
|
||||||
dataType: "INTEGER",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
fieldName: "computer_id",
|
fieldName: "computer_id",
|
||||||
fieldDescription: `The computer the bot works on now. Not a foreign key: null, or a computer it lost access to, means its main one`,
|
fieldDescription: `The computer the bot works on in its own chat. Not a foreign key: null, or a computer it lost access to, means its main one. In a workspace it works on the workspace's computer`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "image_media_id",
|
||||||
|
fieldDescription: `A custom picture (a private \`media\` row, paradigm \`bot-image\`) shown instead of the stone. The hue still applies. Not a foreign key: a missing row means the stone`,
|
||||||
dataType: "INTEGER",
|
dataType: "INTEGER",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -285,6 +280,79 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
tableName: "bot_sessions",
|
||||||
|
tableDescription: `A stretch of one conversation: a bot in its own chat (no workspace) or in one workspace. Only the active session's messages go to the model; closed ones can be searched and reopened`,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
fieldName: "bot_id",
|
||||||
|
fieldDescription: `The bot`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
foreignKey: {
|
||||||
|
cascadeDelete: true,
|
||||||
|
destinationTableName: "bots",
|
||||||
|
destinationTableColumnName: "id",
|
||||||
|
destinationTableColumnType: "INTEGER",
|
||||||
|
foreignKeyName: "bot_session_bot_fk",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "workspace_id",
|
||||||
|
fieldDescription: `The workspace the conversation is in. Null: the bot's own chat`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
foreignKey: {
|
||||||
|
cascadeDelete: true,
|
||||||
|
destinationTableName: "workspaces",
|
||||||
|
destinationTableColumnName: "id",
|
||||||
|
destinationTableColumnType: "INTEGER",
|
||||||
|
foreignKeyName: "bot_session_workspace_fk",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "title",
|
||||||
|
fieldDescription: `From the first user message`,
|
||||||
|
dataType: "TEXT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "summary",
|
||||||
|
fieldDescription: `What happened in the session (markdown), written by the bot when the next one starts and carried into it`,
|
||||||
|
dataType: "TEXT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "previous_session_id",
|
||||||
|
fieldDescription: `The session this one carries on from (its summary goes into the prompt). Not a foreign key`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "status",
|
||||||
|
fieldDescription: `One active session per conversation`,
|
||||||
|
dataType: "TEXT",
|
||||||
|
options: BotSessionStatuses.map((s) => s.value),
|
||||||
|
defaultValue: "active",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "unread",
|
||||||
|
dataType: "INTEGER",
|
||||||
|
options: [0, 1],
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "last_activity_at",
|
||||||
|
fieldDescription: `Unix timestamp of the latest message, for ordering`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
indexName: "idx_bot_sessions_bot_id_workspace_id",
|
||||||
|
indexTableFields: ["bot_id", "workspace_id", "status"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
indexName: "idx_bot_sessions_workspace_id",
|
||||||
|
indexTableFields: ["workspace_id"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
tableName: "bot_messages",
|
tableName: "bot_messages",
|
||||||
fields: [
|
fields: [
|
||||||
@@ -299,6 +367,30 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
|
|||||||
foreignKeyName: "bot_message_bot_fk",
|
foreignKeyName: "bot_message_bot_fk",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
fieldName: "session_id",
|
||||||
|
fieldDescription: `The session the message is in`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
foreignKey: {
|
||||||
|
cascadeDelete: true,
|
||||||
|
destinationTableName: "bot_sessions",
|
||||||
|
destinationTableColumnName: "id",
|
||||||
|
destinationTableColumnType: "INTEGER",
|
||||||
|
foreignKeyName: "bot_message_session_fk",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "workspace_id",
|
||||||
|
fieldDescription: `The session's workspace, kept here to route the message without a join. Null: the bot's own chat`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
foreignKey: {
|
||||||
|
cascadeDelete: true,
|
||||||
|
destinationTableName: "workspaces",
|
||||||
|
destinationTableColumnName: "id",
|
||||||
|
destinationTableColumnType: "INTEGER",
|
||||||
|
foreignKeyName: "bot_message_workspace_fk",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
fieldName: "author",
|
fieldName: "author",
|
||||||
dataType: "TEXT",
|
dataType: "TEXT",
|
||||||
@@ -321,6 +413,10 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
|
|||||||
indexName: "idx_bot_messages_bot_id_created_at",
|
indexName: "idx_bot_messages_bot_id_created_at",
|
||||||
indexTableFields: ["bot_id", "created_at"],
|
indexTableFields: ["bot_id", "created_at"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
indexName: "idx_bot_messages_session_id_created_at",
|
||||||
|
indexTableFields: ["session_id", "created_at"],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -337,6 +433,30 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
|
|||||||
foreignKeyName: "bot_task_bot_fk",
|
foreignKeyName: "bot_task_bot_fk",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
fieldName: "session_id",
|
||||||
|
fieldDescription: `The session the task was started from; its result is posted there`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
foreignKey: {
|
||||||
|
cascadeDelete: true,
|
||||||
|
destinationTableName: "bot_sessions",
|
||||||
|
destinationTableColumnName: "id",
|
||||||
|
destinationTableColumnType: "INTEGER",
|
||||||
|
foreignKeyName: "bot_task_session_fk",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "workspace_id",
|
||||||
|
fieldDescription: `The session's workspace. Null: the bot's own chat`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
foreignKey: {
|
||||||
|
cascadeDelete: true,
|
||||||
|
destinationTableName: "workspaces",
|
||||||
|
destinationTableColumnName: "id",
|
||||||
|
destinationTableColumnType: "INTEGER",
|
||||||
|
foreignKeyName: "bot_task_workspace_fk",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
fieldName: "title",
|
fieldName: "title",
|
||||||
dataType: "TEXT",
|
dataType: "TEXT",
|
||||||
@@ -424,6 +544,30 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
|
|||||||
foreignKeyName: "bots_queue_bot_fk",
|
foreignKeyName: "bots_queue_bot_fk",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
fieldName: "session_id",
|
||||||
|
fieldDescription: `The session the result is posted in`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
foreignKey: {
|
||||||
|
cascadeDelete: true,
|
||||||
|
destinationTableName: "bot_sessions",
|
||||||
|
destinationTableColumnName: "id",
|
||||||
|
destinationTableColumnType: "INTEGER",
|
||||||
|
foreignKeyName: "bots_queue_session_fk",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "workspace_id",
|
||||||
|
fieldDescription: `The session's workspace: its context, computers and models apply. Null: the bot's own chat`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
foreignKey: {
|
||||||
|
cascadeDelete: true,
|
||||||
|
destinationTableName: "workspaces",
|
||||||
|
destinationTableColumnName: "id",
|
||||||
|
destinationTableColumnType: "INTEGER",
|
||||||
|
foreignKeyName: "bots_queue_workspace_fk",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
fieldName: "message_id",
|
fieldName: "message_id",
|
||||||
fieldDescription: `The user message that asked for the work, if any`,
|
fieldDescription: `The user message that asked for the work, if any`,
|
||||||
@@ -458,6 +602,11 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
|
|||||||
fieldDescription: `The bot that handed this job over (the coordinator, through delegate_task). It gets the result or failure in its own conversation. Not a foreign key, so the job outlives it`,
|
fieldDescription: `The bot that handed this job over (the coordinator, through delegate_task). It gets the result or failure in its own conversation. Not a foreign key, so the job outlives it`,
|
||||||
dataType: "INTEGER",
|
dataType: "INTEGER",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
fieldName: "requested_by_session_id",
|
||||||
|
fieldDescription: `The requesting bot's session the job was handed over from. The report goes to that conversation's active session. Not a foreign key`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
fieldName: "title",
|
fieldName: "title",
|
||||||
fieldDescription: `Short label for the job`,
|
fieldDescription: `Short label for the job`,
|
||||||
@@ -585,9 +734,19 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
fieldName: "system_prompt",
|
fieldName: "system_prompt",
|
||||||
fieldDescription: `Shared context in markdown, appended to the prompt of every bot in the workspace`,
|
fieldDescription: `Shared context in markdown, in the prompt of every bot working in the workspace`,
|
||||||
dataType: "TEXT",
|
dataType: "TEXT",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
fieldName: "computer_id",
|
||||||
|
fieldDescription: `The default computer: where the workspace's tasks run. Set to the first computer linked; null when none is. Not a foreign key`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "image_media_id",
|
||||||
|
fieldDescription: `The workspace's picture (a private \`media\` row, paradigm \`workspace-image\`). Not a foreign key: a missing row means the default icon`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
fieldName: "archived",
|
fieldName: "archived",
|
||||||
dataType: "INTEGER",
|
dataType: "INTEGER",
|
||||||
@@ -627,6 +786,16 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
|
|||||||
foreignKeyName: "workspace_bot_bot_fk",
|
foreignKeyName: "workspace_bot_bot_fk",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
fieldName: "system_prompt",
|
||||||
|
fieldDescription: `The bot's instructions for this workspace (markdown), after the workspace's own. The bot, the coordinator and the user can rewrite them`,
|
||||||
|
dataType: "TEXT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "memory",
|
||||||
|
fieldDescription: `What the bot must remember in this workspace (markdown), after its core memory`,
|
||||||
|
dataType: "TEXT",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
uniqueConstraints: [
|
uniqueConstraints: [
|
||||||
{
|
{
|
||||||
@@ -1144,6 +1313,69 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
tableName: "workspace_harness_settings",
|
||||||
|
tableDescription: `A workspace's models. They win over the bot's own and the defaults for every run in the workspace`,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
fieldName: "workspace_id",
|
||||||
|
fieldDescription: `The workspace`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
foreignKey: {
|
||||||
|
cascadeDelete: true,
|
||||||
|
destinationTableName: "workspaces",
|
||||||
|
destinationTableColumnName: "id",
|
||||||
|
destinationTableColumnType: "INTEGER",
|
||||||
|
foreignKeyName: "workspace_harness_settings_workspace_fk",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "harness",
|
||||||
|
dataType: "TEXT",
|
||||||
|
options: Harnesses.map((h) => h.value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "model_id",
|
||||||
|
fieldDescription: `harness_models row picked for this workspace. Null picks automatically. Not a foreign key, like harness_settings`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "provider_id",
|
||||||
|
fieldDescription: `harness_providers row this workspace'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 in this workspace. 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_workspace_harness",
|
||||||
|
constraintName: "A workspace overrides a harness once",
|
||||||
|
constraintTableFields: [
|
||||||
|
{ value: "workspace_id" },
|
||||||
|
{ value: "harness" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
tableName: "logs",
|
tableName: "logs",
|
||||||
fields: [
|
fields: [
|
||||||
|
|||||||
+139
-10
@@ -6,6 +6,7 @@ export const BunSQLiteTables = [
|
|||||||
"media",
|
"media",
|
||||||
"media_paradigms",
|
"media_paradigms",
|
||||||
"bots",
|
"bots",
|
||||||
|
"bot_sessions",
|
||||||
"bot_messages",
|
"bot_messages",
|
||||||
"bot_tasks",
|
"bot_tasks",
|
||||||
"bot_task_steps",
|
"bot_task_steps",
|
||||||
@@ -22,6 +23,7 @@ export const BunSQLiteTables = [
|
|||||||
"harness_models",
|
"harness_models",
|
||||||
"harness_settings",
|
"harness_settings",
|
||||||
"bot_harness_settings",
|
"bot_harness_settings",
|
||||||
|
"workspace_harness_settings",
|
||||||
"logs",
|
"logs",
|
||||||
"envs",
|
"envs",
|
||||||
] as const
|
] as const
|
||||||
@@ -140,7 +142,7 @@ export type BUN_SQLITE_COBBLE_MEDIA_PARADIGMS = {
|
|||||||
*/
|
*/
|
||||||
updated_at?: number | "";
|
updated_at?: number | "";
|
||||||
media_id?: number | "";
|
media_id?: number | "";
|
||||||
media_paradigm?: "user-profile-image" | "generic" | "event" | "bot-attachment" | "";
|
media_paradigm?: "user-profile-image" | "generic" | "event" | "bot-attachment" | "bot-image" | "workspace-image" | "";
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BUN_SQLITE_COBBLE_BOTS = {
|
export type BUN_SQLITE_COBBLE_BOTS = {
|
||||||
@@ -189,15 +191,58 @@ export type BUN_SQLITE_COBBLE_BOTS = {
|
|||||||
* What the bot must always remember, in markdown. The bot keeps it up to date
|
* What the bot must always remember, in markdown. The bot keeps it up to date
|
||||||
*/
|
*/
|
||||||
memory?: string;
|
memory?: string;
|
||||||
unread?: 0 | 1 | "";
|
|
||||||
/**
|
/**
|
||||||
* Unix timestamp of the latest message, for rail ordering
|
* The computer the bot works on in its own chat. Not a foreign key: null, or a computer it lost access to, means its main one. In a workspace it works on the workspace's computer
|
||||||
*/
|
|
||||||
last_activity_at?: number | "";
|
|
||||||
/**
|
|
||||||
* The computer the bot works on now. Not a foreign key: null, or a computer it lost access to, means its main one
|
|
||||||
*/
|
*/
|
||||||
computer_id?: number | "";
|
computer_id?: number | "";
|
||||||
|
/**
|
||||||
|
* A custom picture (a private `media` row, paradigm `bot-image`) shown instead of the stone. The hue still applies. Not a foreign key: a missing row means the stone
|
||||||
|
*/
|
||||||
|
image_media_id?: number | "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BUN_SQLITE_COBBLE_BOT_SESSIONS = {
|
||||||
|
/**
|
||||||
|
* 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 bot
|
||||||
|
*/
|
||||||
|
bot_id?: number | "";
|
||||||
|
/**
|
||||||
|
* The workspace the conversation is in. Null: the bot's own chat
|
||||||
|
*/
|
||||||
|
workspace_id?: number | "";
|
||||||
|
/**
|
||||||
|
* From the first user message
|
||||||
|
*/
|
||||||
|
title?: string;
|
||||||
|
/**
|
||||||
|
* What happened in the session (markdown), written by the bot when the next one starts and carried into it
|
||||||
|
*/
|
||||||
|
summary?: string;
|
||||||
|
/**
|
||||||
|
* The session this one carries on from (its summary goes into the prompt). Not a foreign key
|
||||||
|
*/
|
||||||
|
previous_session_id?: number | "";
|
||||||
|
/**
|
||||||
|
* One active session per conversation
|
||||||
|
*/
|
||||||
|
status?: "active" | "closed" | "";
|
||||||
|
unread?: 0 | 1 | "";
|
||||||
|
/**
|
||||||
|
* Unix timestamp of the latest message, for ordering
|
||||||
|
*/
|
||||||
|
last_activity_at?: number | "";
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BUN_SQLITE_COBBLE_BOT_MESSAGES = {
|
export type BUN_SQLITE_COBBLE_BOT_MESSAGES = {
|
||||||
@@ -214,6 +259,14 @@ export type BUN_SQLITE_COBBLE_BOT_MESSAGES = {
|
|||||||
*/
|
*/
|
||||||
updated_at?: number | "";
|
updated_at?: number | "";
|
||||||
bot_id?: number | "";
|
bot_id?: number | "";
|
||||||
|
/**
|
||||||
|
* The session the message is in
|
||||||
|
*/
|
||||||
|
session_id?: number | "";
|
||||||
|
/**
|
||||||
|
* The session's workspace, kept here to route the message without a join. Null: the bot's own chat
|
||||||
|
*/
|
||||||
|
workspace_id?: number | "";
|
||||||
author?: "bot" | "user" | "";
|
author?: "bot" | "user" | "";
|
||||||
text?: string;
|
text?: string;
|
||||||
/**
|
/**
|
||||||
@@ -236,6 +289,14 @@ export type BUN_SQLITE_COBBLE_BOT_TASKS = {
|
|||||||
*/
|
*/
|
||||||
updated_at?: number | "";
|
updated_at?: number | "";
|
||||||
bot_id?: number | "";
|
bot_id?: number | "";
|
||||||
|
/**
|
||||||
|
* The session the task was started from; its result is posted there
|
||||||
|
*/
|
||||||
|
session_id?: number | "";
|
||||||
|
/**
|
||||||
|
* The session's workspace. Null: the bot's own chat
|
||||||
|
*/
|
||||||
|
workspace_id?: number | "";
|
||||||
title?: string;
|
title?: string;
|
||||||
status?: "active" | "done" | "";
|
status?: "active" | "done" | "";
|
||||||
/**
|
/**
|
||||||
@@ -284,6 +345,14 @@ export type BUN_SQLITE_COBBLE_BOTS_QUEUE = {
|
|||||||
* The bot doing the work
|
* The bot doing the work
|
||||||
*/
|
*/
|
||||||
bot_id?: number | "";
|
bot_id?: number | "";
|
||||||
|
/**
|
||||||
|
* The session the result is posted in
|
||||||
|
*/
|
||||||
|
session_id?: number | "";
|
||||||
|
/**
|
||||||
|
* The session's workspace: its context, computers and models apply. Null: the bot's own chat
|
||||||
|
*/
|
||||||
|
workspace_id?: number | "";
|
||||||
/**
|
/**
|
||||||
* The user message that asked for the work, if any
|
* The user message that asked for the work, if any
|
||||||
*/
|
*/
|
||||||
@@ -300,6 +369,10 @@ export type BUN_SQLITE_COBBLE_BOTS_QUEUE = {
|
|||||||
* The bot that handed this job over (the coordinator, through delegate_task). It gets the result or failure in its own conversation. Not a foreign key, so the job outlives it
|
* The bot that handed this job over (the coordinator, through delegate_task). It gets the result or failure in its own conversation. Not a foreign key, so the job outlives it
|
||||||
*/
|
*/
|
||||||
requested_by_bot_id?: number | "";
|
requested_by_bot_id?: number | "";
|
||||||
|
/**
|
||||||
|
* The requesting bot's session the job was handed over from. The report goes to that conversation's active session. Not a foreign key
|
||||||
|
*/
|
||||||
|
requested_by_session_id?: number | "";
|
||||||
/**
|
/**
|
||||||
* Short label for the job
|
* Short label for the job
|
||||||
*/
|
*/
|
||||||
@@ -413,9 +486,17 @@ export type BUN_SQLITE_COBBLE_WORKSPACES = {
|
|||||||
name?: string;
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
/**
|
/**
|
||||||
* Shared context in markdown, appended to the prompt of every bot in the workspace
|
* Shared context in markdown, in the prompt of every bot working in the workspace
|
||||||
*/
|
*/
|
||||||
system_prompt?: string;
|
system_prompt?: string;
|
||||||
|
/**
|
||||||
|
* The default computer: where the workspace's tasks run. Set to the first computer linked; null when none is. Not a foreign key
|
||||||
|
*/
|
||||||
|
computer_id?: number | "";
|
||||||
|
/**
|
||||||
|
* The workspace's picture (a private `media` row, paradigm `workspace-image`). Not a foreign key: a missing row means the default icon
|
||||||
|
*/
|
||||||
|
image_media_id?: number | "";
|
||||||
archived?: 0 | 1 | "";
|
archived?: 0 | 1 | "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,6 +515,14 @@ export type BUN_SQLITE_COBBLE_WORKSPACE_BOTS = {
|
|||||||
updated_at?: number | "";
|
updated_at?: number | "";
|
||||||
workspace_id?: number | "";
|
workspace_id?: number | "";
|
||||||
bot_id?: number | "";
|
bot_id?: number | "";
|
||||||
|
/**
|
||||||
|
* The bot's instructions for this workspace (markdown), after the workspace's own. The bot, the coordinator and the user can rewrite them
|
||||||
|
*/
|
||||||
|
system_prompt?: string;
|
||||||
|
/**
|
||||||
|
* What the bot must remember in this workspace (markdown), after its core memory
|
||||||
|
*/
|
||||||
|
memory?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BUN_SQLITE_COBBLE_BOT_MESSAGE_MEDIA = {
|
export type BUN_SQLITE_COBBLE_BOT_MESSAGE_MEDIA = {
|
||||||
@@ -494,7 +583,7 @@ export type BUN_SQLITE_COBBLE_CONTAINERS = {
|
|||||||
/**
|
/**
|
||||||
* Last known docker state
|
* Last known docker state
|
||||||
*/
|
*/
|
||||||
status?: "created" | "running" | "paused" | "restarting" | "exited" | "removing" | "dead" | "unknown" | "";
|
status?: "creating" | "failed" | "created" | "running" | "paused" | "restarting" | "exited" | "removing" | "dead" | "unknown" | "";
|
||||||
/**
|
/**
|
||||||
* Extra docker details (ports, volumes, env names, labels)
|
* Extra docker details (ports, volumes, env names, labels)
|
||||||
*/
|
*/
|
||||||
@@ -742,6 +831,46 @@ export type BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS = {
|
|||||||
options?: string;
|
options?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BUN_SQLITE_COBBLE_WORKSPACE_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 workspace
|
||||||
|
*/
|
||||||
|
workspace_id?: number | "";
|
||||||
|
harness?: "text" | "vision" | "transcription" | "speech" | "image" | "";
|
||||||
|
/**
|
||||||
|
* harness_models row picked for this workspace. Null picks automatically. Not a foreign key, like harness_settings
|
||||||
|
*/
|
||||||
|
model_id?: number | "";
|
||||||
|
/**
|
||||||
|
* harness_providers row this workspace'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 in this workspace. Text is always on
|
||||||
|
*/
|
||||||
|
enabled?: 0 | 1 | "";
|
||||||
|
/**
|
||||||
|
* Harness options, e.g. {"voice":"george"} for speech
|
||||||
|
*/
|
||||||
|
options?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type BUN_SQLITE_COBBLE_LOGS = {
|
export type BUN_SQLITE_COBBLE_LOGS = {
|
||||||
/**
|
/**
|
||||||
* The unique identifier of the record.
|
* The unique identifier of the record.
|
||||||
@@ -836,4 +965,4 @@ export type BUN_SQLITE_COBBLE_ENVS = {
|
|||||||
expose_to_computers?: 0 | 1 | "";
|
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_BOTS_QUEUE_UPDATES & 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
|
export type BUN_SQLITE_COBBLE_ALL_TYPEDEFS = BUN_SQLITE_COBBLE_USERS & BUN_SQLITE_COBBLE_USER_TYPES & BUN_SQLITE_COBBLE_SSO_LOGIN_CODES & BUN_SQLITE_COBBLE_USER_PASSWORDS & BUN_SQLITE_COBBLE_MEDIA & BUN_SQLITE_COBBLE_MEDIA_PARADIGMS & BUN_SQLITE_COBBLE_BOTS & BUN_SQLITE_COBBLE_BOT_SESSIONS & BUN_SQLITE_COBBLE_BOT_MESSAGES & BUN_SQLITE_COBBLE_BOT_TASKS & BUN_SQLITE_COBBLE_BOT_TASK_STEPS & BUN_SQLITE_COBBLE_BOTS_QUEUE & BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES & 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_WORKSPACE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_LOGS & BUN_SQLITE_COBBLE_ENVS
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 9.4 KiB |
@@ -10,6 +10,11 @@ type Params = {
|
|||||||
* The coordinator can use every computer and give the crew access.
|
* The coordinator can use every computer and give the crew access.
|
||||||
*/
|
*/
|
||||||
is_coordinator?: boolean;
|
is_coordinator?: boolean;
|
||||||
|
/**
|
||||||
|
* In a workspace the computers are the workspace's, and its default
|
||||||
|
* is where tasks run.
|
||||||
|
*/
|
||||||
|
in_workspace?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,9 +22,11 @@ type Params = {
|
|||||||
* one it's on, and how it reaches them (markdown). Without it a bot asked
|
* one it's on, and how it reaches them (markdown). Without it a bot asked
|
||||||
* "can you see the computer?" says it can't.
|
* "can you see the computer?" says it can't.
|
||||||
*/
|
*/
|
||||||
export default function composeComputersNote({ computers, is_coordinator }: Params) {
|
export default function composeComputersNote({ computers, is_coordinator, in_workspace }: Params) {
|
||||||
if (!computers.length) {
|
if (!computers.length) {
|
||||||
return `# Your computers\n\nYou have no computer, so you can't run commands, open files or browse. If the user asks for that, say so.`;
|
return in_workspace
|
||||||
|
? `# Your computers\n\nThis workspace has no computer, so you can't run commands, open files or browse here. If the user asks for that, say so: they can add a computer to the workspace and set it as its default.`
|
||||||
|
: `# Your computers\n\nYou have no computer, so you can't run commands, open files or browse. If the user asks for that, say so.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const listed = computers
|
const listed = computers
|
||||||
@@ -38,10 +45,14 @@ export default function composeComputersNote({ computers, is_coordinator }: Para
|
|||||||
|
|
||||||
${listed}
|
${listed}
|
||||||
|
|
||||||
Each is a Linux sandbox of your own (a shell, files, code runners and a browser), set up for the crew. It isn't the user's desktop: you can't see their screen or their own files unless they're put on it. The user picks which one you're on; your tasks run there.${
|
Each is a Linux sandbox of your own (a shell, files, code runners and a browser), set up for the crew. It isn't the user's desktop: you can't see their screen or their own files unless they're put on it. ${
|
||||||
|
in_workspace
|
||||||
|
? `These are this workspace's computers. Its tasks run on the default one (the first, "you're on it"); you can switch the default with set_workspace_computer when the work needs another.`
|
||||||
|
: `The user picks which one you're on; your tasks run there.${
|
||||||
is_coordinator
|
is_coordinator
|
||||||
? ` As the coordinator you can use every computer the user has, and you decide which ones each crew member can use with assign_computer.`
|
? ` As the coordinator you can use every computer the user has, and you decide which ones each crew member can use with assign_computer.`
|
||||||
: ""
|
: ""
|
||||||
|
}`
|
||||||
}
|
}
|
||||||
|
|
||||||
- To find which computer holds something (a file, a running app, a logged-in session), use search_computers: it runs one read-only command on all of them at once.
|
- To find which computer holds something (a file, a running app, a logged-in session), use search_computers: it runs one read-only command on all of them at once.
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { BUN_SQLITE_COBBLE_BOT_SESSIONS } from "@/db/types/db";
|
||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import { PreviousSessionNote, SessionSummaryPrompt } from "@/src/data/harness-prompts";
|
||||||
|
import grabBotMessages from "@/src/functions/backend/db/bots/grab-bot-messages";
|
||||||
|
import generateText from "@/src/harness/text/generate-text";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
session_id: number | string;
|
||||||
|
bot_id: number | string;
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The previous session's summary for a session's prompt (markdown), or ""
|
||||||
|
* when it doesn't carry on from one. The summary is written the first
|
||||||
|
* time it's needed and kept on that session.
|
||||||
|
*/
|
||||||
|
export default async function grabSessionCarryOver({
|
||||||
|
session_id,
|
||||||
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
|
signal,
|
||||||
|
}: Params) {
|
||||||
|
const previous = (
|
||||||
|
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_SESSIONS>({
|
||||||
|
sql: `SELECT p.* FROM bot_sessions s JOIN bot_sessions p ON p.id = s.previous_session_id
|
||||||
|
WHERE s.id = ? AND p.bot_id = s.bot_id LIMIT 1`,
|
||||||
|
values: [session_id],
|
||||||
|
})
|
||||||
|
).payload?.[0];
|
||||||
|
|
||||||
|
if (!previous?.id) return "";
|
||||||
|
|
||||||
|
if (previous.summary?.trim()) {
|
||||||
|
return `${PreviousSessionNote}\n\n${previous.summary.trim()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = await grabBotMessages({
|
||||||
|
session_id: previous.id,
|
||||||
|
limit: AppData.BotSessionSummaryMessages,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!messages.length) return "";
|
||||||
|
|
||||||
|
const transcript = messages
|
||||||
|
.map((message) => `${message.author == "user" ? "User" : "You"}: ${message.text || "(files only)"}`)
|
||||||
|
.join("\n\n");
|
||||||
|
|
||||||
|
const result = await generateText({
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: SessionSummaryPrompt },
|
||||||
|
{ role: "user", content: transcript },
|
||||||
|
],
|
||||||
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const summary = (result.text || "").trim().slice(0, AppData.BotSessionSummaryMaxChars);
|
||||||
|
|
||||||
|
if (!summary) return "";
|
||||||
|
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE bot_sessions SET summary = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
values: [summary, Date.now(), previous.id],
|
||||||
|
});
|
||||||
|
|
||||||
|
return `${PreviousSessionNote}\n\n${summary}`;
|
||||||
|
}
|
||||||
@@ -84,7 +84,11 @@ export default async function processBotJob({
|
|||||||
bot_id,
|
bot_id,
|
||||||
data: {
|
data: {
|
||||||
event: "server:bot-job-update",
|
event: "server:bot-job-update",
|
||||||
data: { ...row, task_id: job.task_id || null } satisfies BotJobUpdateData,
|
data: {
|
||||||
|
...row,
|
||||||
|
task_id: job.task_id || null,
|
||||||
|
session_id: job.session_id || null,
|
||||||
|
} satisfies BotJobUpdateData,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
||||||
import { AppData } from "@/src/data/app-data";
|
import { AppData } from "@/src/data/app-data";
|
||||||
import { ChatReplyNote, CoordinatorReplyNote, CoordinatorSoloNote } from "@/src/data/harness-prompts";
|
import {
|
||||||
|
ChatReplyNote,
|
||||||
|
CoordinatorReplyNote,
|
||||||
|
CoordinatorSoloNote,
|
||||||
|
WorkspaceLearningNote,
|
||||||
|
} from "@/src/data/harness-prompts";
|
||||||
import grabBotMessages from "@/src/functions/backend/db/bots/grab-bot-messages";
|
import grabBotMessages from "@/src/functions/backend/db/bots/grab-bot-messages";
|
||||||
import grabBotPrompt from "@/src/functions/backend/db/bots/grab-bot-prompt";
|
import grabBotPrompt from "@/src/functions/backend/db/bots/grab-bot-prompt";
|
||||||
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
|
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
|
||||||
@@ -8,6 +13,8 @@ import runTextLoop from "@/src/harness/text/run-text-loop";
|
|||||||
import grabBotChatTools from "@/src/harness/tools/bot/grab-bot-chat-tools";
|
import grabBotChatTools from "@/src/harness/tools/bot/grab-bot-chat-tools";
|
||||||
import botMessagesToHarness from "./bot-messages-to-harness";
|
import botMessagesToHarness from "./bot-messages-to-harness";
|
||||||
import composeComputersNote from "./compose-computers-note";
|
import composeComputersNote from "./compose-computers-note";
|
||||||
|
import grabSessionCarryOver from "./grab-session-carry-over";
|
||||||
|
import jobSessionId from "@/src/functions/backend/db/bots-queue/job-session-id";
|
||||||
import loadAttachmentParts from "./load-attachment-parts";
|
import loadAttachmentParts from "./load-attachment-parts";
|
||||||
import withTakeOverRefs from "./with-take-over-refs";
|
import withTakeOverRefs from "./with-take-over-refs";
|
||||||
import type { BotJobContext, BotJobOutcome } from "./types";
|
import type { BotJobContext, BotJobOutcome } from "./types";
|
||||||
@@ -17,11 +24,14 @@ type Params = BotJobContext & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Answers the user in chat: the bot reads the recent conversation (and
|
* Answers the user in chat: the bot reads the session's recent messages
|
||||||
* the files of the latest messages, `loadAttachmentParts`) and
|
* (and the files of the latest ones, `loadAttachmentParts`) and replies,
|
||||||
* replies, knowing its computers (`composeComputersNote`), with its chat tools (memory, instructions, start_task, and for
|
* knowing where it is (its workspace, if the session is in one), what
|
||||||
* the coordinator delegate_task). Finishes silently when the last message
|
* happened in the session before (`grabSessionCarryOver`) and its
|
||||||
* is already the bot's, i.e. an earlier reply answered everything.
|
* computers (`composeComputersNote`), 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({
|
export default async function runBotReply({
|
||||||
bot,
|
bot,
|
||||||
@@ -29,8 +39,11 @@ export default async function runBotReply({
|
|||||||
signal,
|
signal,
|
||||||
report,
|
report,
|
||||||
}: Params): Promise<BotJobOutcome> {
|
}: Params): Promise<BotJobOutcome> {
|
||||||
|
const session_id = await jobSessionId({ job });
|
||||||
|
const workspace_id = job.workspace_id || null;
|
||||||
|
|
||||||
const history = await grabBotMessages({
|
const history = await grabBotMessages({
|
||||||
bot_id: bot.id,
|
session_id,
|
||||||
limit: AppData.BotReplyHistoryMessages,
|
limit: AppData.BotReplyHistoryMessages,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -38,8 +51,19 @@ export default async function runBotReply({
|
|||||||
return { result: "", silent: true };
|
return { result: "", silent: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
const prompt = await grabBotPrompt({ bot_id: bot.id, user_id: bot.user_id });
|
const prompt = await grabBotPrompt({ bot_id: bot.id, user_id: bot.user_id, workspace_id });
|
||||||
const computers = await grabBotComputers({ bot_id: bot.id, user_id: bot.user_id });
|
const computers = await grabBotComputers({ bot_id: bot.id, user_id: bot.user_id, workspace_id });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A summary that can't be written now (no model free) is left out
|
||||||
|
* rather than holding up the reply.
|
||||||
|
*/
|
||||||
|
const carry_over = await grabSessionCarryOver({
|
||||||
|
session_id,
|
||||||
|
bot_id: bot.id,
|
||||||
|
workspace_id,
|
||||||
|
signal,
|
||||||
|
}).catch(() => "");
|
||||||
|
|
||||||
const take_overs: string[] = [];
|
const take_overs: string[] = [];
|
||||||
|
|
||||||
@@ -47,6 +71,8 @@ export default async function runBotReply({
|
|||||||
bot,
|
bot,
|
||||||
user_id: bot.user_id,
|
user_id: bot.user_id,
|
||||||
computers,
|
computers,
|
||||||
|
session_id,
|
||||||
|
workspace_id,
|
||||||
message_id: job.message_id || undefined,
|
message_id: job.message_id || undefined,
|
||||||
onTakeOver: ({ ref }) => take_overs.push(ref),
|
onTakeOver: ({ ref }) => take_overs.push(ref),
|
||||||
});
|
});
|
||||||
@@ -64,8 +90,14 @@ export default async function runBotReply({
|
|||||||
const result = await runTextLoop({
|
const result = await runTextLoop({
|
||||||
system: [
|
system: [
|
||||||
prompt,
|
prompt,
|
||||||
|
carry_over,
|
||||||
ChatReplyNote,
|
ChatReplyNote,
|
||||||
composeComputersNote({ computers, is_coordinator: bot.kind == "coordinator" }),
|
workspace_id ? WorkspaceLearningNote : "",
|
||||||
|
composeComputersNote({
|
||||||
|
computers,
|
||||||
|
is_coordinator: bot.kind == "coordinator",
|
||||||
|
in_workspace: Boolean(workspace_id),
|
||||||
|
}),
|
||||||
crew_note,
|
crew_note,
|
||||||
`Now: ${new Date().toUTCString()}.`,
|
`Now: ${new Date().toUTCString()}.`,
|
||||||
]
|
]
|
||||||
@@ -78,6 +110,7 @@ export default async function runBotReply({
|
|||||||
tools,
|
tools,
|
||||||
max_steps: AppData.BotReplyMaxToolSteps,
|
max_steps: AppData.BotReplyMaxToolSteps,
|
||||||
bot_id: bot.id,
|
bot_id: bot.id,
|
||||||
|
workspace_id,
|
||||||
signal,
|
signal,
|
||||||
onStep: report,
|
onStep: report,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { TaskRunNote } from "@/src/data/harness-prompts";
|
import { TaskRunNote, WorkspaceLearningNote } from "@/src/data/harness-prompts";
|
||||||
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
|
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
|
||||||
import grabBotPrompt from "@/src/functions/backend/db/bots/grab-bot-prompt";
|
import grabBotPrompt from "@/src/functions/backend/db/bots/grab-bot-prompt";
|
||||||
import runTextLoop from "@/src/harness/text/run-text-loop";
|
import runTextLoop from "@/src/harness/text/run-text-loop";
|
||||||
@@ -8,6 +8,8 @@ import askToTakeOverTool from "@/src/harness/tools/computer/ask-to-take-over-too
|
|||||||
import searchComputersTool from "@/src/harness/tools/computer/search-computers-tool";
|
import searchComputersTool from "@/src/harness/tools/computer/search-computers-tool";
|
||||||
import computerRef from "@/src/utils/computer-ref";
|
import computerRef from "@/src/utils/computer-ref";
|
||||||
import composeJobBrief from "./compose-job-brief";
|
import composeJobBrief from "./compose-job-brief";
|
||||||
|
import grabSessionCarryOver from "./grab-session-carry-over";
|
||||||
|
import jobSessionId from "@/src/functions/backend/db/bots-queue/job-session-id";
|
||||||
import withTakeOverRefs from "./with-take-over-refs";
|
import withTakeOverRefs from "./with-take-over-refs";
|
||||||
import type { BotJobContext, BotJobOutcome } from "./types";
|
import type { BotJobContext, BotJobOutcome } from "./types";
|
||||||
|
|
||||||
@@ -16,7 +18,8 @@ type Params = BotJobContext & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Works through a task: the bot follows its brief with its full prompt,
|
* Works through a task in its session's workspace (if any): the bot
|
||||||
|
* follows its brief with its full prompt,
|
||||||
* its own tools, search_computers across all its computers and, when the
|
* its own tools, search_computers across all its computers and, when the
|
||||||
* job has a computer, that computer's tools (over MCP), until it answers. That answer is the report for the user.
|
* job has a computer, that computer's tools (over MCP), until it answers. That answer is the report for the user.
|
||||||
* When only the user can go on (a login, a captcha), the bot asks them to
|
* When only the user can go on (a login, a captcha), the bot asks them to
|
||||||
@@ -30,13 +33,27 @@ export default async function runBotTask({
|
|||||||
signal,
|
signal,
|
||||||
report,
|
report,
|
||||||
}: Params): Promise<BotJobOutcome> {
|
}: Params): Promise<BotJobOutcome> {
|
||||||
|
const workspace_id = job.workspace_id || null;
|
||||||
|
|
||||||
const computer = job.computer_id
|
const computer = job.computer_id
|
||||||
? await openComputerTools({ computer_id: job.computer_id, bot_id: bot.id, signal })
|
? await openComputerTools({
|
||||||
|
computer_id: job.computer_id,
|
||||||
|
bot_id: bot.id,
|
||||||
|
workspace_id,
|
||||||
|
signal,
|
||||||
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const prompt = await grabBotPrompt({ bot_id: bot.id, user_id: bot.user_id });
|
const session_id = await jobSessionId({ job });
|
||||||
const computers = await grabBotComputers({ bot_id: bot.id, user_id: bot.user_id });
|
const prompt = await grabBotPrompt({ bot_id: bot.id, user_id: bot.user_id, workspace_id });
|
||||||
|
const computers = await grabBotComputers({ bot_id: bot.id, user_id: bot.user_id, workspace_id });
|
||||||
|
const carry_over = await grabSessionCarryOver({
|
||||||
|
session_id,
|
||||||
|
bot_id: bot.id,
|
||||||
|
workspace_id,
|
||||||
|
signal,
|
||||||
|
}).catch(() => "");
|
||||||
const take_overs: string[] = [];
|
const take_overs: string[] = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,9 +67,13 @@ export default async function runBotTask({
|
|||||||
const result = await runTextLoop({
|
const result = await runTextLoop({
|
||||||
system: [
|
system: [
|
||||||
prompt,
|
prompt,
|
||||||
|
carry_over,
|
||||||
TaskRunNote,
|
TaskRunNote,
|
||||||
|
workspace_id ? WorkspaceLearningNote : "",
|
||||||
computer
|
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. When only the user can go on (a login, a captcha, a code sent to them), ask them to take over with ask_user_to_take_over.`
|
? `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. When only the user can go on (a login, a captcha, a code sent to them), ask them to take over with ask_user_to_take_over.`
|
||||||
|
: workspace_id
|
||||||
|
? `You have no computer for this task: this workspace has none. If the task needs one, stop and say so in the report; the user can add a computer to the workspace.`
|
||||||
: `You have no computer for this task.`,
|
: `You have no computer for this task.`,
|
||||||
computers.length > 1
|
computers.length > 1
|
||||||
? `search_computers runs a read-only command on every computer you have. When you name one in the report, write its ref, e.g. ${computerRef({ id: computers[0]!.id || 0 })}: the user can click it to switch you to that computer.`
|
? `search_computers runs a read-only command on every computer you have. When you name one in the report, write its ref, e.g. ${computerRef({ id: computers[0]!.id || 0 })}: the user can click it to switch you to that computer.`
|
||||||
@@ -76,10 +97,10 @@ export default async function runBotTask({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
tools: [
|
tools: [
|
||||||
...grabBotTools({ user_id: bot.user_id, bot_id: bot.id }),
|
...grabBotTools({ user_id: bot.user_id, bot_id: bot.id, workspace_id, computers }),
|
||||||
...(computers.length
|
...(computers.length
|
||||||
? [
|
? [
|
||||||
searchComputersTool({ bot_id: bot.id, computers }),
|
searchComputersTool({ computers, workspace_id }),
|
||||||
askToTakeOverTool({
|
askToTakeOverTool({
|
||||||
computers: take_over_computers,
|
computers: take_over_computers,
|
||||||
in_task: true,
|
in_task: true,
|
||||||
@@ -90,6 +111,7 @@ export default async function runBotTask({
|
|||||||
...(computer?.tools || []),
|
...(computer?.tools || []),
|
||||||
],
|
],
|
||||||
bot_id: bot.id,
|
bot_id: bot.id,
|
||||||
|
workspace_id,
|
||||||
signal,
|
signal,
|
||||||
onStep: (step) =>
|
onStep: (step) =>
|
||||||
report({ ...step, text: step.text && computer ? computer.redact(step.text) : step.text }),
|
report({ ...step, text: step.text && computer ? computer.redact(step.text) : step.text }),
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type { ComponentProps } from "react";
|
||||||
|
import Img from "@moduletrace/twui/layout/Img";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import profileImageUrls from "@/src/utils/profile-image-urls";
|
||||||
|
import BotStone from "./bot-stone";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picture sizes, matching the stone sizes in use.
|
||||||
|
*/
|
||||||
|
const BotAvatarSizeClasses: Record<number, string> = {
|
||||||
|
18: "size-[18px] rounded-[4px]",
|
||||||
|
28: "size-7 rounded-default-sm",
|
||||||
|
32: "size-8 rounded-default-sm",
|
||||||
|
40: "size-10 rounded-default",
|
||||||
|
56: "size-14 rounded-default",
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = ComponentProps<typeof BotStone> & {
|
||||||
|
/**
|
||||||
|
* The bot's custom picture, shown instead of the stone.
|
||||||
|
*/
|
||||||
|
image_media_id?: number | string | null;
|
||||||
|
/**
|
||||||
|
* A picture to show instead, e.g. one just picked and not saved yet.
|
||||||
|
*/
|
||||||
|
src?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A bot's avatar: its custom picture when it has one (a rounded square,
|
||||||
|
* never a circle, so it isn't mistaken for a person), else its stone. Its
|
||||||
|
* hue still marks it everywhere else, e.g. the unread dot.
|
||||||
|
*/
|
||||||
|
export default function BotAvatar({ image_media_id, src: picked, ...stone }: Props) {
|
||||||
|
const saved = profileImageUrls({ media_id: image_media_id });
|
||||||
|
const thumbnail_src = picked || saved.thumbnail_src;
|
||||||
|
const src = picked || saved.src;
|
||||||
|
const size = stone.size || 32;
|
||||||
|
|
||||||
|
if (!thumbnail_src) return <BotStone {...stone} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Img
|
||||||
|
src={thumbnail_src}
|
||||||
|
imgErrSrc={src}
|
||||||
|
alt={stone.label || ""}
|
||||||
|
className={twMerge(
|
||||||
|
"shrink-0 object-cover bg-sunken",
|
||||||
|
BotAvatarSizeClasses[size] || BotAvatarSizeClasses[32],
|
||||||
|
stone.className,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { useRef, type ReactNode } from "react";
|
||||||
|
import Button from "@moduletrace/twui/layout/Button";
|
||||||
|
import Row from "@moduletrace/twui/layout/Row";
|
||||||
|
import Span from "@moduletrace/twui/layout/Span";
|
||||||
|
import Stack from "@moduletrace/twui/layout/Stack";
|
||||||
|
import imageInputToBase64 from "@moduletrace/twui/utils/form/imageInputToBase64";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/**
|
||||||
|
* The avatar as it looks now (or will, with the picked picture).
|
||||||
|
*/
|
||||||
|
preview: ReactNode;
|
||||||
|
has_image: boolean;
|
||||||
|
onPick: (params: { data_url: string }) => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
loading?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
note?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An avatar preview with Upload/Change and Remove. The picked picture comes
|
||||||
|
* back as a data URL; saving is up to the caller.
|
||||||
|
*/
|
||||||
|
export default function ImagePickField({
|
||||||
|
preview,
|
||||||
|
has_image,
|
||||||
|
onPick,
|
||||||
|
onRemove,
|
||||||
|
loading,
|
||||||
|
disabled,
|
||||||
|
note = "PNG, JPG or WebP. A square picture looks best.",
|
||||||
|
}: Props) {
|
||||||
|
const input_ref = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row className="w-full gap-4! flex-nowrap items-center">
|
||||||
|
{preview}
|
||||||
|
<Stack className="gap-2! min-w-0">
|
||||||
|
<Row className="gap-2!">
|
||||||
|
<Button
|
||||||
|
title={has_image ? "Change picture" : "Upload picture"}
|
||||||
|
type="button"
|
||||||
|
variant="outlined"
|
||||||
|
color="gray"
|
||||||
|
size="small"
|
||||||
|
loading={loading}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
onClick={() => input_ref.current?.click()}
|
||||||
|
>
|
||||||
|
{has_image ? "Change picture" : "Upload picture"}
|
||||||
|
</Button>
|
||||||
|
{has_image ? (
|
||||||
|
<Button
|
||||||
|
title="Remove the picture"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
color="gray"
|
||||||
|
size="small"
|
||||||
|
disabled={disabled || loading}
|
||||||
|
onClick={() => {
|
||||||
|
onRemove();
|
||||||
|
if (input_ref.current) input_ref.current.value = "";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Row>
|
||||||
|
<Span className="text-[13px] leading-[18px] text-ink-secondary">{note}</Span>
|
||||||
|
</Stack>
|
||||||
|
{/*
|
||||||
|
* Raw on purpose: twui `Input` writes `value` on change, which
|
||||||
|
* browsers forbid on file inputs, and `ImageUpload` is a full
|
||||||
|
* card widget. This is only the hidden picker behind the button.
|
||||||
|
*/}
|
||||||
|
<input
|
||||||
|
ref={input_ref}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||||
|
className="hidden"
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-hidden
|
||||||
|
onChange={(e) => {
|
||||||
|
if (!e.target.files?.[0]) return;
|
||||||
|
|
||||||
|
imageInputToBase64({ imageInput: e.target }).then((res) => {
|
||||||
|
if (res?.imageBase64Full) onPick({ data_url: res.imageBase64Full });
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import Img from "@moduletrace/twui/layout/Img";
|
||||||
|
import Span from "@moduletrace/twui/layout/Span";
|
||||||
|
import { Layers } from "lucide-react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import profileImageUrls from "@/src/utils/profile-image-urls";
|
||||||
|
|
||||||
|
const WorkspaceAvatarSizes = {
|
||||||
|
sm: { box: "size-8 rounded-default-sm", icon: 16 },
|
||||||
|
lg: { box: "size-14 rounded-default", icon: 24 },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
image_media_id?: number | string | null;
|
||||||
|
/**
|
||||||
|
* A picture to show instead, e.g. one just picked and not saved yet.
|
||||||
|
*/
|
||||||
|
src?: string;
|
||||||
|
size?: keyof typeof WorkspaceAvatarSizes;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A workspace's picture, or the workspace icon on a tile when it has none.
|
||||||
|
*/
|
||||||
|
export default function WorkspaceAvatar({ image_media_id, src: picked, size = "sm", className }: Props) {
|
||||||
|
const saved = profileImageUrls({ media_id: image_media_id });
|
||||||
|
const thumbnail_src = picked || saved.thumbnail_src;
|
||||||
|
const src = picked || saved.src;
|
||||||
|
const { box, icon } = WorkspaceAvatarSizes[size];
|
||||||
|
|
||||||
|
if (thumbnail_src) {
|
||||||
|
return (
|
||||||
|
<Img
|
||||||
|
src={thumbnail_src}
|
||||||
|
imgErrSrc={src}
|
||||||
|
alt=""
|
||||||
|
className={twMerge("shrink-0 object-cover bg-sunken", box, className)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Span
|
||||||
|
aria-hidden
|
||||||
|
className={twMerge(
|
||||||
|
"shrink-0 flex items-center justify-center border border-solid border-hairline bg-surface text-ink",
|
||||||
|
box,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Layers size={icon} aria-hidden />
|
||||||
|
</Span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import bunext from "@moduletrace/bunext";
|
|||||||
import cronHeartbeat from "./heartbeat";
|
import cronHeartbeat from "./heartbeat";
|
||||||
import pruneBotsQueue from "./prune-bots-queue";
|
import pruneBotsQueue from "./prune-bots-queue";
|
||||||
import cronSyncHarnessModels from "./sync-harness-models";
|
import cronSyncHarnessModels from "./sync-harness-models";
|
||||||
|
import syncComputers from "./sync-computers";
|
||||||
import registerCronJob from "./register-cron-job";
|
import registerCronJob from "./register-cron-job";
|
||||||
|
|
||||||
export default function cron() {
|
export default function cron() {
|
||||||
@@ -30,4 +31,11 @@ export default function cron() {
|
|||||||
task: cronSyncHarnessModels,
|
task: cronSyncHarnessModels,
|
||||||
run_on_start: true,
|
run_on_start: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
registerCronJob({
|
||||||
|
title: "sync-computers",
|
||||||
|
schedule: "*/5 * * * *",
|
||||||
|
task: syncComputers,
|
||||||
|
run_on_start: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import bunext from "@moduletrace/bunext";
|
||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { BUN_SQLITE_COBBLE_COMPUTERS, BUN_SQLITE_COBBLE_CONTAINERS } from "@/db/types/db";
|
||||||
|
import pushComputerUpdate from "@/src/functions/backend/db/computers/push-computer-update";
|
||||||
|
import inspectDockerContainer from "@/src/functions/backend/docker/inspect-docker-container";
|
||||||
|
|
||||||
|
type Row = Pick<BUN_SQLITE_COBBLE_CONTAINERS, "id" | "docker_id" | "status"> &
|
||||||
|
Pick<BUN_SQLITE_COBBLE_COMPUTERS, "user_id"> & { computer_id: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads every computer container's state from docker and records it
|
||||||
|
* (`containers.status`, `last_seen_at`), telling owners about changes. A
|
||||||
|
* container docker no longer has shows as `dead`. Containers still being
|
||||||
|
* set up, or that failed, are left alone.
|
||||||
|
*/
|
||||||
|
export default async function syncComputers() {
|
||||||
|
const rows =
|
||||||
|
(
|
||||||
|
await BunSQLite.sql<Row>({
|
||||||
|
sql: `SELECT co.id, co.docker_id, co.status, c.user_id, c.id AS computer_id
|
||||||
|
FROM containers co JOIN computers c ON c.container_id = co.id
|
||||||
|
WHERE co.docker_id IS NOT NULL AND co.status NOT IN ('creating', 'failed')`,
|
||||||
|
})
|
||||||
|
).payload || [];
|
||||||
|
|
||||||
|
let changed = 0;
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const state = await inspectDockerContainer({ container: row.docker_id! }).catch(() => undefined);
|
||||||
|
|
||||||
|
if (state === undefined) continue;
|
||||||
|
|
||||||
|
const status = state?.status || "dead";
|
||||||
|
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE containers SET status = ?, last_seen_at = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
values: [status, Date.now(), Date.now(), row.id!],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (status != row.status) {
|
||||||
|
changed++;
|
||||||
|
await pushComputerUpdate({ computer_id: row.computer_id }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bunext.bunextLog.info(`Cron: ${rows.length} computer(s) checked, ${changed} changed`);
|
||||||
|
}
|
||||||
+56
-3
@@ -27,15 +27,40 @@ export const AppData = {
|
|||||||
PasswordMaxLength: 128,
|
PasswordMaxLength: 128,
|
||||||
PasswordAttemptLimit: 10,
|
PasswordAttemptLimit: 10,
|
||||||
/**
|
/**
|
||||||
* Docker network shared by the main container and every computer. The
|
* Docker network shared by the app and every computer. The app creates
|
||||||
* installer creates it, and the main computer's container on it.
|
* it when it sets up its first computer and joins it when it runs in
|
||||||
|
* docker.
|
||||||
*/
|
*/
|
||||||
DockerNetworkName: SiteData["SiteSlug"],
|
DockerNetworkName: SiteData["SiteSlug"],
|
||||||
MainComputerContainerName: `${SiteData["SiteSlug"]}-computer`,
|
|
||||||
/**
|
/**
|
||||||
* Port the AIO sandbox listens on inside its container.
|
* Port the AIO sandbox listens on inside its container.
|
||||||
*/
|
*/
|
||||||
ComputerPort: 8080,
|
ComputerPort: 8080,
|
||||||
|
/**
|
||||||
|
* Every computer the app creates runs this image, named
|
||||||
|
* `<ComputerContainerPrefix>-<computer id>` on `DockerNetworkName`.
|
||||||
|
*/
|
||||||
|
ComputerImage: "ghcr.io/agent-infra/sandbox:latest",
|
||||||
|
ComputerContainerPrefix: `${SiteData["SiteSlug"]}-computer`,
|
||||||
|
/**
|
||||||
|
* A computer's home directory (`ComputerHomeDir` in its container) is
|
||||||
|
* kept at `DATA_DIR/<ComputerDataDir>/<id>`, owned by the sandbox's
|
||||||
|
* user. It stays when the computer is deleted.
|
||||||
|
*/
|
||||||
|
ComputerDataDir: "computers",
|
||||||
|
ComputerHomeDir: "/home/gem",
|
||||||
|
ComputerUserUid: 1000,
|
||||||
|
ComputerUserGid: 1000,
|
||||||
|
/**
|
||||||
|
* Per computer container.
|
||||||
|
*/
|
||||||
|
ComputerMemoryBytes: 4 * 1024 * 1024 * 1024,
|
||||||
|
ComputerCpus: 2,
|
||||||
|
ComputerShmBytes: 2 * 1024 * 1024 * 1024,
|
||||||
|
/**
|
||||||
|
* The docker engine API, reached over its unix socket.
|
||||||
|
*/
|
||||||
|
DockerSocketPath: "/var/run/docker.sock",
|
||||||
/**
|
/**
|
||||||
* Cap on a bot's `system_prompt` and `memory`, and on a workspace's
|
* Cap on a bot's `system_prompt` and `memory`, and on a workspace's
|
||||||
* `system_prompt`, so self-updates can't grow a prompt without bound.
|
* `system_prompt`, so self-updates can't grow a prompt without bound.
|
||||||
@@ -128,6 +153,30 @@ export const AppData = {
|
|||||||
* How much of the conversation a bot sees when it replies.
|
* How much of the conversation a bot sees when it replies.
|
||||||
*/
|
*/
|
||||||
BotReplyHistoryMessages: 40,
|
BotReplyHistoryMessages: 40,
|
||||||
|
/**
|
||||||
|
* A session with this many messages is closed when the user writes
|
||||||
|
* again, and the message starts the next one (carrying a summary).
|
||||||
|
*/
|
||||||
|
BotSessionMaxMessages: 120,
|
||||||
|
/**
|
||||||
|
* How much of a closed session the bot reads to summarize it, and how
|
||||||
|
* long the summary may be.
|
||||||
|
*/
|
||||||
|
BotSessionSummaryMessages: 80,
|
||||||
|
BotSessionSummaryMaxChars: 4000,
|
||||||
|
/**
|
||||||
|
* Sessions listed per page in a conversation's sessions modal.
|
||||||
|
*/
|
||||||
|
BotSessionsPageSize: 30,
|
||||||
|
/**
|
||||||
|
* Worker bots the rail lists, most recently active first, before
|
||||||
|
* "See all bots".
|
||||||
|
*/
|
||||||
|
RailRecentBots: 8,
|
||||||
|
/**
|
||||||
|
* Stones shown beside "See all bots".
|
||||||
|
*/
|
||||||
|
RailSeeAllStones: 4,
|
||||||
/**
|
/**
|
||||||
* Tool turns a chat reply may take. Long work goes to a task instead.
|
* Tool turns a chat reply may take. Long work goes to a task instead.
|
||||||
*/
|
*/
|
||||||
@@ -144,4 +193,8 @@ export const AppData = {
|
|||||||
* themselves to a reply; older ones are named.
|
* themselves to a reply; older ones are named.
|
||||||
*/
|
*/
|
||||||
BotReplyAttachmentMessages: 4,
|
BotReplyAttachmentMessages: 4,
|
||||||
|
/**
|
||||||
|
* Largest picture a bot or a workspace can have.
|
||||||
|
*/
|
||||||
|
ProfileImageMaxBytes: 5 * 1024 * 1024,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -47,3 +47,24 @@ export const CoordinatorSoloNote = `You have no worker bots yet: you do all the
|
|||||||
export const TaskRunNote = `# Working on a 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. If they need to do it on the computer themselves (log in, a captcha, a code), call ask_user_to_take_over first.`;
|
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. If they need to do it on the computer themselves (log in, a captcha, a code), call ask_user_to_take_over first.`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Added to a bot's prompt when it works in a workspace: what it learns
|
||||||
|
* there stays there unless it applies everywhere.
|
||||||
|
*/
|
||||||
|
export const WorkspaceLearningNote = `# Learning in this workspace
|
||||||
|
|
||||||
|
update_memory and update_instructions save to this workspace by default: what you learn here applies here. Save to your core memory or instructions (scope "core") only when the user asks for it to apply in every workspace, or when it's plainly about how you work everywhere, not about this workspace. When something learned here looks useful everywhere, suggest saving it to your core and do it once the user agrees.`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks the text model for a closed session's summary, carried into the
|
||||||
|
* next session's prompt.
|
||||||
|
*/
|
||||||
|
export const SessionSummaryPrompt = `Summarize this conversation for whoever picks it up next: what the user wanted, what was done, decisions made, open questions and anything still in progress. Keep names, numbers, paths and links exactly. Plain markdown bullets, no preamble.`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Heads the previous session's summary in a bot's prompt.
|
||||||
|
*/
|
||||||
|
export const PreviousSessionNote = `# Earlier in this conversation
|
||||||
|
|
||||||
|
This session carries on from an earlier one. What happened there:`;
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
* `AdminHomePath`. People, logs and backups exist in code but stay closed
|
* `AdminHomePath`. People, logs and backups exist in code but stay closed
|
||||||
* until they are added back here.
|
* until they are added back here.
|
||||||
*/
|
*/
|
||||||
export const AdminSections = ["/admin/bots", "/admin/settings"] as const;
|
export const AdminSections = ["/admin/bots", "/admin/workspaces", "/admin/settings"] as const;
|
||||||
|
|
||||||
export const AdminHomePath = "/admin/bots";
|
export const AdminHomePath = "/admin/bots";
|
||||||
|
|||||||
@@ -108,6 +108,24 @@ export const BotTaskStatuses = [
|
|||||||
|
|
||||||
export type BotTaskStatus = (typeof BotTaskStatuses)[number]["value"];
|
export type BotTaskStatus = (typeof BotTaskStatuses)[number]["value"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A conversation (one bot, in its own chat or one workspace) is split into
|
||||||
|
* sessions so no single history grows without bound. One is active per
|
||||||
|
* conversation; closed ones can be browsed and reopened.
|
||||||
|
*/
|
||||||
|
export const BotSessionStatuses = [
|
||||||
|
{
|
||||||
|
title: "Active",
|
||||||
|
value: "active",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Closed",
|
||||||
|
value: "closed",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type BotSessionStatus = (typeof BotSessionStatuses)[number]["value"];
|
||||||
|
|
||||||
export const BotTaskStepStates = [
|
export const BotTaskStepStates = [
|
||||||
{
|
{
|
||||||
title: "Done",
|
title: "Done",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* A computer is an AIO sandbox running in its own docker container on the
|
* A computer is an AIO sandbox running in its own docker container on the
|
||||||
* cobble network. The main computer is set up at install and recorded on
|
* cobble network, created by the app (`createComputer`), its home
|
||||||
* signup; sandboxes are created later by the main container.
|
* directory kept under `DATA_DIR/computers/<id>`.
|
||||||
*/
|
*/
|
||||||
export const ComputerKinds = [
|
export const ComputerKinds = [
|
||||||
{
|
{
|
||||||
@@ -20,9 +20,18 @@ export const MainComputerName = "Main computer";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Docker container state as reported by `docker inspect` (`State.Status`),
|
* Docker container state as reported by `docker inspect` (`State.Status`),
|
||||||
* plus `unknown` for a record that hasn't been inspected yet.
|
* plus `creating` while the app sets it up, `failed` when that didn't
|
||||||
|
* work, and `unknown` for a record that hasn't been inspected yet.
|
||||||
*/
|
*/
|
||||||
export const ContainerStatuses = [
|
export const ContainerStatuses = [
|
||||||
|
{
|
||||||
|
title: "Creating",
|
||||||
|
value: "creating",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Failed",
|
||||||
|
value: "failed",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Created",
|
title: "Created",
|
||||||
value: "created",
|
value: "created",
|
||||||
|
|||||||
@@ -34,4 +34,12 @@ export const MediaParadigms = [
|
|||||||
title: "Bot Attachment",
|
title: "Bot Attachment",
|
||||||
value: "bot-attachment",
|
value: "bot-attachment",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Bot Image",
|
||||||
|
value: "bot-image",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Workspace Image",
|
||||||
|
value: "workspace-image",
|
||||||
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type {
|
||||||
|
BUN_SQLITE_COBBLE_BOT_SESSIONS,
|
||||||
|
BunSQLiteTables,
|
||||||
|
} from "@/db/types/db";
|
||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
bot_id: number | string;
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
/**
|
||||||
|
* The session this one carries on from.
|
||||||
|
*/
|
||||||
|
previous_session_id?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts an active session in a conversation. Callers close the old one
|
||||||
|
* first (`startBotSession`).
|
||||||
|
*/
|
||||||
|
export default async function createBotSession({
|
||||||
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
|
previous_session_id,
|
||||||
|
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_SESSIONS> {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const row: BUN_SQLITE_COBBLE_BOT_SESSIONS = {
|
||||||
|
bot_id: Number(bot_id),
|
||||||
|
workspace_id: workspace_id ? Number(workspace_id) : undefined,
|
||||||
|
previous_session_id: previous_session_id || undefined,
|
||||||
|
status: "active",
|
||||||
|
unread: 0,
|
||||||
|
last_activity_at: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await BunSQLite.insert<
|
||||||
|
BUN_SQLITE_COBBLE_BOT_SESSIONS,
|
||||||
|
(typeof BunSQLiteTables)[number]
|
||||||
|
>({
|
||||||
|
table: "bot_sessions",
|
||||||
|
data: [row],
|
||||||
|
});
|
||||||
|
|
||||||
|
const id = res.singleRes?.id || res.postInsertReturn?.insertId;
|
||||||
|
|
||||||
|
if (!res.success || !id) {
|
||||||
|
throw new Error(`Couldn't start a session: ${res.msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...row, id, created_at: now, updated_at: now };
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { BUN_SQLITE_COBBLE_BOT_SESSIONS } from "@/db/types/db";
|
||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import createBotSession from "./create-bot-session";
|
||||||
|
import grabActiveBotSession from "./grab-active-bot-session";
|
||||||
|
import startBotSession from "./start-bot-session";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
bot_id: number | string;
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
/**
|
||||||
|
* Start the next session when the active one is full
|
||||||
|
* (`AppData.BotSessionMaxMessages`). Set when the user writes, so a
|
||||||
|
* message never lands in a session that's about to be cut.
|
||||||
|
*/
|
||||||
|
rollover?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The conversation's active session, started first when there is none
|
||||||
|
* (carrying on from its latest closed one) or, with `rollover`, when the
|
||||||
|
* active one is full.
|
||||||
|
*/
|
||||||
|
export default async function ensureBotSession({
|
||||||
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
|
rollover,
|
||||||
|
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_SESSIONS> {
|
||||||
|
const active = await grabActiveBotSession({ bot_id, workspace_id });
|
||||||
|
|
||||||
|
if (active?.id) {
|
||||||
|
if (!rollover) return active;
|
||||||
|
|
||||||
|
const count =
|
||||||
|
Number(
|
||||||
|
(
|
||||||
|
await BunSQLite.sql<{ n: number }>({
|
||||||
|
sql: `SELECT COUNT(*) AS n FROM bot_messages WHERE session_id = ?`,
|
||||||
|
values: [active.id],
|
||||||
|
})
|
||||||
|
).payload?.[0]?.n,
|
||||||
|
) || 0;
|
||||||
|
|
||||||
|
return count < AppData.BotSessionMaxMessages
|
||||||
|
? active
|
||||||
|
: await startBotSession({ bot_id, workspace_id });
|
||||||
|
}
|
||||||
|
|
||||||
|
const latest = (
|
||||||
|
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_SESSIONS>({
|
||||||
|
sql: `SELECT id FROM bot_sessions WHERE bot_id = ? AND workspace_id IS ?
|
||||||
|
ORDER BY last_activity_at DESC, id DESC LIMIT 1`,
|
||||||
|
values: [bot_id, workspace_id ? Number(workspace_id) : null],
|
||||||
|
})
|
||||||
|
).payload?.[0];
|
||||||
|
|
||||||
|
return await createBotSession({
|
||||||
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
|
previous_session_id: latest?.id || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { BUN_SQLITE_COBBLE_BOT_SESSIONS } from "@/db/types/db";
|
||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
bot_id: number | string;
|
||||||
|
/**
|
||||||
|
* Null or undefined: the bot's own chat.
|
||||||
|
*/
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The active session of one conversation (a bot in its own chat or in one
|
||||||
|
* workspace), or null when it has none yet.
|
||||||
|
*/
|
||||||
|
export default async function grabActiveBotSession({
|
||||||
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
|
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_SESSIONS | null> {
|
||||||
|
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_SESSIONS>({
|
||||||
|
sql: `SELECT * FROM bot_sessions
|
||||||
|
WHERE bot_id = ? AND workspace_id IS ? AND status = 'active'
|
||||||
|
ORDER BY id DESC LIMIT 1`,
|
||||||
|
values: [bot_id, workspace_id ? Number(workspace_id) : null],
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.payload?.[0] || null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import type { BotSessionListItem } from "@/src/types";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
bot_id: number | string;
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
/**
|
||||||
|
* Matches the session's title, summary or any of its messages.
|
||||||
|
*/
|
||||||
|
query?: string;
|
||||||
|
page?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A conversation's sessions for the sessions modal, latest activity first,
|
||||||
|
* each with its message count and latest message. Empty sessions are left
|
||||||
|
* out unless active.
|
||||||
|
*/
|
||||||
|
export default async function grabBotSessions({
|
||||||
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
|
query,
|
||||||
|
page = 1,
|
||||||
|
}: Params): Promise<BotSessionListItem[]> {
|
||||||
|
const search = query?.trim();
|
||||||
|
const like = search ? `%${search.replace(/[\\%_]/g, (c) => `\\${c}`)}%` : null;
|
||||||
|
const limit = AppData.BotSessionsPageSize;
|
||||||
|
|
||||||
|
let sql = ``;
|
||||||
|
|
||||||
|
sql += `SELECT s.*,\n`;
|
||||||
|
sql += ` (SELECT COUNT(*) FROM bot_messages m WHERE m.session_id = s.id) AS message_count,\n`;
|
||||||
|
sql += ` (SELECT m.text FROM bot_messages m WHERE m.session_id = s.id ORDER BY m.created_at DESC, m.id DESC LIMIT 1) AS last_message_text\n`;
|
||||||
|
sql += `FROM bot_sessions s\n`;
|
||||||
|
sql += `WHERE s.bot_id = ? AND s.workspace_id IS ?\n`;
|
||||||
|
sql += `AND (s.status = 'active' OR EXISTS (SELECT 1 FROM bot_messages m WHERE m.session_id = s.id))\n`;
|
||||||
|
|
||||||
|
if (like) {
|
||||||
|
sql += `AND (s.title LIKE ? ESCAPE '\\' OR s.summary LIKE ? ESCAPE '\\'\n`;
|
||||||
|
sql += ` OR EXISTS (SELECT 1 FROM bot_messages m WHERE m.session_id = s.id AND m.text LIKE ? ESCAPE '\\'))\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += `ORDER BY s.status = 'active' DESC, s.last_activity_at DESC, s.id DESC\n`;
|
||||||
|
sql += `LIMIT ? OFFSET ?\n`;
|
||||||
|
|
||||||
|
const res = await BunSQLite.sql<BotSessionListItem>({
|
||||||
|
sql,
|
||||||
|
values: [
|
||||||
|
bot_id,
|
||||||
|
workspace_id ? Number(workspace_id) : null,
|
||||||
|
...(like ? [like, like, like] : []),
|
||||||
|
limit,
|
||||||
|
(Math.max(1, page) - 1) * limit,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.payload || [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import type { PagePropsType } from "@/src/types";
|
||||||
|
import grabBots from "../bots/grab-bots";
|
||||||
|
import grabActiveBotTask from "../bots/grab-active-bot-task";
|
||||||
|
import grabBotMessages from "../bots/grab-bot-messages";
|
||||||
|
import grabBotComputers from "../computers/grab-bot-computers";
|
||||||
|
import grabBotHarnessOverview from "../harness/grab-bot-harness-overview";
|
||||||
|
import grabHarnessOverview from "../harness/grab-harness-overview";
|
||||||
|
import grabUserWorkspaces from "../workspaces/grab-user-workspaces";
|
||||||
|
import ensureBotSession from "./ensure-bot-session";
|
||||||
|
import grabUserConversation from "./grab-user-conversation";
|
||||||
|
import markBotSessionRead from "./mark-bot-session-read";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
user_id: number;
|
||||||
|
bot_id: number | string;
|
||||||
|
/**
|
||||||
|
* The workspace the conversation is in; none for the bot's own chat.
|
||||||
|
*/
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The page props of a conversation (`/admin/bots/<id>` or
|
||||||
|
* `/admin/workspaces/<id>/bots/<id>`): the rail's crew (the workspace's
|
||||||
|
* in a workspace) and workspaces, the bot, its active session (marked
|
||||||
|
* read) and its messages, its models, computers and active task there.
|
||||||
|
* Null when the bot isn't the user's or isn't in the workspace.
|
||||||
|
*/
|
||||||
|
export default async function grabConversationProps({
|
||||||
|
user_id,
|
||||||
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
|
}: Params): Promise<PagePropsType | null> {
|
||||||
|
const conversation = await grabUserConversation({ user_id, bot_id, workspace_id }).catch(
|
||||||
|
() => null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!conversation) return null;
|
||||||
|
|
||||||
|
const scope = conversation.workspace_id;
|
||||||
|
const session = await ensureBotSession({ bot_id: conversation.bot.id, workspace_id: scope });
|
||||||
|
|
||||||
|
if (session.unread) {
|
||||||
|
await markBotSessionRead({ session_id: session.id!, user_id });
|
||||||
|
session.unread = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [bots, workspaces] = await Promise.all([
|
||||||
|
grabBots({ user_id, workspace_id: scope }),
|
||||||
|
grabUserWorkspaces({ user_id }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const bot = bots.find((b) => b.id == conversation.bot.id);
|
||||||
|
|
||||||
|
if (!bot?.id) return null;
|
||||||
|
|
||||||
|
bot.unread = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The header's models modal: a worker's own settings, or for the
|
||||||
|
* coordinator the defaults it runs on (without the providers). In a
|
||||||
|
* workspace the workspace's own models come first, so what runs now
|
||||||
|
* shows its pick.
|
||||||
|
*/
|
||||||
|
const [bot_messages, bot_harness_overview, bot_computers, bot_task] = await Promise.all([
|
||||||
|
grabBotMessages({ session_id: session.id! }),
|
||||||
|
(bot.kind == "coordinator" && !scope
|
||||||
|
? grabHarnessOverview().then(({ harnesses }) => ({ harnesses }))
|
||||||
|
: grabBotHarnessOverview({
|
||||||
|
bot_id: bot.kind == "coordinator" ? undefined : bot.id,
|
||||||
|
workspace_id: scope || undefined,
|
||||||
|
})
|
||||||
|
).catch(() => null),
|
||||||
|
grabBotComputers({ bot_id: bot.id, user_id, workspace_id: scope }),
|
||||||
|
grabActiveBotTask({ bot_id: bot.id, session_id: session.id }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
bots,
|
||||||
|
bot,
|
||||||
|
workspaces,
|
||||||
|
workspace: conversation.workspace,
|
||||||
|
bot_session: session,
|
||||||
|
bot_messages,
|
||||||
|
bot_harness_overview,
|
||||||
|
bot_computers,
|
||||||
|
bot_task,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { BUN_SQLITE_COBBLE_BOT_SESSIONS } from "@/db/types/db";
|
||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
session_id: number | string;
|
||||||
|
user_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A session only if its bot belongs to the given owner.
|
||||||
|
*/
|
||||||
|
export default async function grabUserBotSession({
|
||||||
|
session_id,
|
||||||
|
user_id,
|
||||||
|
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_SESSIONS | null> {
|
||||||
|
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_SESSIONS>({
|
||||||
|
sql: `SELECT s.* FROM bot_sessions s JOIN bots b ON b.id = s.bot_id
|
||||||
|
WHERE s.id = ? AND b.user_id = ? LIMIT 1`,
|
||||||
|
values: [session_id, user_id],
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.payload?.[0] || null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import grabUserBot from "../bots/grab-user-bot";
|
||||||
|
import grabWorkspaceBot from "../workspaces/grab-workspace-bot";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
user_id: number | string;
|
||||||
|
bot_id: number | string;
|
||||||
|
/**
|
||||||
|
* Null or undefined: the bot's own chat.
|
||||||
|
*/
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserConversation = {
|
||||||
|
bot: BUN_SQLITE_COBBLE_BOTS & { id: number };
|
||||||
|
workspace: BUN_SQLITE_COBBLE_WORKSPACES | null;
|
||||||
|
workspace_id: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One of the user's conversations: their bot, in its own chat or in one of
|
||||||
|
* their workspaces it works in. Throws when the bot isn't theirs or isn't
|
||||||
|
* in that workspace.
|
||||||
|
*/
|
||||||
|
export default async function grabUserConversation({
|
||||||
|
user_id,
|
||||||
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
|
}: Params): Promise<UserConversation> {
|
||||||
|
if (workspace_id) {
|
||||||
|
const found = await grabWorkspaceBot({ workspace_id, bot_id, user_id });
|
||||||
|
|
||||||
|
if (!found?.bot.id || !found.workspace.id) {
|
||||||
|
throw new AppError(`That bot isn't in this workspace.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
bot: { ...found.bot, id: found.bot.id },
|
||||||
|
workspace: found.workspace,
|
||||||
|
workspace_id: found.workspace.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const bot = await grabUserBot({ bot_id, user_id });
|
||||||
|
|
||||||
|
if (!bot?.id) {
|
||||||
|
throw new AppError(`Bot not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { bot: { ...bot, id: bot.id }, workspace: null, workspace_id: null };
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
session_id: number | string;
|
||||||
|
user_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears a session's unread flag when its owner has the conversation open.
|
||||||
|
*/
|
||||||
|
export default async function markBotSessionRead({ session_id, user_id }: Params) {
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE bot_sessions SET unread = 0
|
||||||
|
WHERE id = ? AND unread = 1 AND bot_id IN (SELECT id FROM bots WHERE user_id = ?)`,
|
||||||
|
values: [session_id, user_id],
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { BUN_SQLITE_COBBLE_BOT_SESSIONS } from "@/db/types/db";
|
||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import grabUserBotSession from "./grab-user-bot-session";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
session_id: number | string;
|
||||||
|
user_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes a closed session of one of the user's conversations the active
|
||||||
|
* one again, so the bot carries on from where it left off. The session
|
||||||
|
* that was active is closed (an empty one is deleted).
|
||||||
|
*/
|
||||||
|
export default async function reopenBotSession({
|
||||||
|
session_id,
|
||||||
|
user_id,
|
||||||
|
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_SESSIONS> {
|
||||||
|
const session = await grabUserBotSession({ session_id, user_id });
|
||||||
|
|
||||||
|
if (!session?.id) {
|
||||||
|
throw new AppError(`Session not found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.status == "active") return session;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const workspace_id = session.workspace_id || null;
|
||||||
|
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `DELETE FROM bot_sessions WHERE bot_id = ? AND workspace_id IS ? AND status = 'active'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM bot_messages m WHERE m.session_id = bot_sessions.id)`,
|
||||||
|
values: [session.bot_id!, workspace_id],
|
||||||
|
});
|
||||||
|
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE bot_sessions SET status = 'closed', unread = 0, updated_at = ?
|
||||||
|
WHERE bot_id = ? AND workspace_id IS ? AND status = 'active'`,
|
||||||
|
values: [now, session.bot_id!, workspace_id],
|
||||||
|
});
|
||||||
|
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE bot_sessions SET status = 'active', updated_at = ? WHERE id = ?`,
|
||||||
|
values: [now, session.id],
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ...session, status: "active", updated_at: now };
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { BUN_SQLITE_COBBLE_BOT_SESSIONS } from "@/db/types/db";
|
||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import createBotSession from "./create-bot-session";
|
||||||
|
import grabActiveBotSession from "./grab-active-bot-session";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
bot_id: number | string;
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the conversation's active session and starts the next one, which
|
||||||
|
* carries on from it (its summary is written when the next reply runs).
|
||||||
|
* An active session with no messages yet is kept as it is.
|
||||||
|
*/
|
||||||
|
export default async function startBotSession({
|
||||||
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
|
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_SESSIONS> {
|
||||||
|
const active = await grabActiveBotSession({ bot_id, workspace_id });
|
||||||
|
|
||||||
|
if (active?.id) {
|
||||||
|
const has_messages = (
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `SELECT id FROM bot_messages WHERE session_id = ? LIMIT 1`,
|
||||||
|
values: [active.id],
|
||||||
|
})
|
||||||
|
).payload?.[0];
|
||||||
|
|
||||||
|
if (!has_messages) return active;
|
||||||
|
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE bot_sessions SET status = 'closed', unread = 0, updated_at = ? WHERE id = ?`,
|
||||||
|
values: [Date.now(), active.id],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return await createBotSession({
|
||||||
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
|
previous_session_id: active?.id || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import setBotFace from "../bots/set-bot-face";
|
|||||||
import countOpenBotJobs from "./count-open-bot-jobs";
|
import countOpenBotJobs from "./count-open-bot-jobs";
|
||||||
import grabLockedBotJob from "./grab-locked-bot-job";
|
import grabLockedBotJob from "./grab-locked-bot-job";
|
||||||
import relayJobToRequester from "./relay-job-to-requester";
|
import relayJobToRequester from "./relay-job-to-requester";
|
||||||
|
import jobSessionId from "./job-session-id";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
job_id: number | string;
|
job_id: number | string;
|
||||||
@@ -71,6 +72,8 @@ export default async function completeBotJob({
|
|||||||
? null
|
? null
|
||||||
: await insertBotMessage({
|
: await insertBotMessage({
|
||||||
bot_id: job.bot_id,
|
bot_id: job.bot_id,
|
||||||
|
session_id: await jobSessionId({ job }),
|
||||||
|
workspace_id: job.workspace_id || null,
|
||||||
text: result,
|
text: result,
|
||||||
needs_you,
|
needs_you,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { BotJobKind } from "@/src/dict/bot-queue-dict";
|
|||||||
import AppError from "@/src/utils/app-error";
|
import AppError from "@/src/utils/app-error";
|
||||||
import grabUserBot from "../bots/grab-user-bot";
|
import grabUserBot from "../bots/grab-user-bot";
|
||||||
import grabBotComputers from "../computers/grab-bot-computers";
|
import grabBotComputers from "../computers/grab-bot-computers";
|
||||||
|
import grabUserBotSession from "../bot-sessions/grab-user-bot-session";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
/**
|
/**
|
||||||
@@ -17,6 +18,11 @@ type Params = {
|
|||||||
kind?: BotJobKind;
|
kind?: BotJobKind;
|
||||||
user_id: number | string;
|
user_id: number | string;
|
||||||
bot_id: number | string;
|
bot_id: number | string;
|
||||||
|
/**
|
||||||
|
* The bot's session the result is posted in. The job runs in its
|
||||||
|
* workspace (context, computers, models), if it has one.
|
||||||
|
*/
|
||||||
|
session_id: number | string;
|
||||||
title: string;
|
title: string;
|
||||||
/**
|
/**
|
||||||
* The brief the bot writes for itself (markdown): what to do and how
|
* The brief the bot writes for itself (markdown): what to do and how
|
||||||
@@ -34,7 +40,8 @@ type Params = {
|
|||||||
*/
|
*/
|
||||||
task_id?: number | string;
|
task_id?: number | string;
|
||||||
/**
|
/**
|
||||||
* Must be one of the bot's computers (direct or through a workspace).
|
* Must be one of the bot's computers where the session is
|
||||||
|
* (`grabBotComputers`).
|
||||||
*/
|
*/
|
||||||
computer_id?: number | string;
|
computer_id?: number | string;
|
||||||
/**
|
/**
|
||||||
@@ -46,6 +53,10 @@ type Params = {
|
|||||||
* result or failure in its own conversation (`relayJobToRequester`).
|
* result or failure in its own conversation (`relayJobToRequester`).
|
||||||
*/
|
*/
|
||||||
requested_by_bot_id?: number | string;
|
requested_by_bot_id?: number | string;
|
||||||
|
/**
|
||||||
|
* The requesting bot's session; its report goes to that conversation.
|
||||||
|
*/
|
||||||
|
requested_by_session_id?: number | string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,6 +69,7 @@ export default async function enqueueBotJob({
|
|||||||
kind = "task",
|
kind = "task",
|
||||||
user_id,
|
user_id,
|
||||||
bot_id,
|
bot_id,
|
||||||
|
session_id,
|
||||||
title,
|
title,
|
||||||
instructions,
|
instructions,
|
||||||
params,
|
params,
|
||||||
@@ -66,6 +78,7 @@ export default async function enqueueBotJob({
|
|||||||
computer_id,
|
computer_id,
|
||||||
delay,
|
delay,
|
||||||
requested_by_bot_id,
|
requested_by_bot_id,
|
||||||
|
requested_by_session_id,
|
||||||
}: Params) {
|
}: Params) {
|
||||||
const bot = await grabUserBot({ bot_id, user_id });
|
const bot = await grabUserBot({ bot_id, user_id });
|
||||||
|
|
||||||
@@ -73,10 +86,26 @@ export default async function enqueueBotJob({
|
|||||||
throw new AppError(`Bot not found.`);
|
throw new AppError(`Bot not found.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const session = await grabUserBotSession({ session_id, user_id });
|
||||||
|
|
||||||
|
if (!session?.id || session.bot_id != bot.id) {
|
||||||
|
throw new AppError(`Session not found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspace_id = session.workspace_id || null;
|
||||||
|
|
||||||
if (requested_by_bot_id && !(await grabUserBot({ bot_id: requested_by_bot_id, user_id }))?.id) {
|
if (requested_by_bot_id && !(await grabUserBot({ bot_id: requested_by_bot_id, user_id }))?.id) {
|
||||||
throw new AppError(`Bot not found.`);
|
throw new AppError(`Bot not found.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (requested_by_session_id) {
|
||||||
|
const requested_from = await grabUserBotSession({ session_id: requested_by_session_id, user_id });
|
||||||
|
|
||||||
|
if (!requested_from?.id || requested_from.bot_id != Number(requested_by_bot_id)) {
|
||||||
|
throw new AppError(`Session not found.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (message_id && !(await belongsToBot({ table: "bot_messages", id: message_id, bot_id: bot.id }))) {
|
if (message_id && !(await belongsToBot({ table: "bot_messages", id: message_id, bot_id: bot.id }))) {
|
||||||
throw new AppError(`Message not found.`);
|
throw new AppError(`Message not found.`);
|
||||||
}
|
}
|
||||||
@@ -86,7 +115,7 @@ export default async function enqueueBotJob({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (computer_id) {
|
if (computer_id) {
|
||||||
const computers = await grabBotComputers({ bot_id: bot.id, user_id });
|
const computers = await grabBotComputers({ bot_id: bot.id, user_id, workspace_id });
|
||||||
|
|
||||||
if (!computers.some((c) => String(c.id) == String(computer_id))) {
|
if (!computers.some((c) => String(c.id) == String(computer_id))) {
|
||||||
throw new AppError(`This bot has no access to that computer.`);
|
throw new AppError(`This bot has no access to that computer.`);
|
||||||
@@ -96,7 +125,8 @@ export default async function enqueueBotJob({
|
|||||||
const final_task_id =
|
const final_task_id =
|
||||||
kind == "reply"
|
kind == "reply"
|
||||||
? undefined
|
? undefined
|
||||||
: task_id || (await createJobTask({ bot_id: bot.id, title }));
|
: task_id ||
|
||||||
|
(await createJobTask({ bot_id: bot.id, session_id: session.id, workspace_id, title }));
|
||||||
|
|
||||||
const res = await BunSQLite.queue.add<
|
const res = await BunSQLite.queue.add<
|
||||||
BUN_SQLITE_COBBLE_BOTS_QUEUE,
|
BUN_SQLITE_COBBLE_BOTS_QUEUE,
|
||||||
@@ -107,6 +137,8 @@ export default async function enqueueBotJob({
|
|||||||
{
|
{
|
||||||
kind,
|
kind,
|
||||||
bot_id: bot.id,
|
bot_id: bot.id,
|
||||||
|
session_id: session.id,
|
||||||
|
workspace_id: workspace_id || undefined,
|
||||||
title,
|
title,
|
||||||
instructions,
|
instructions,
|
||||||
params: params ? JSON.stringify(params) : undefined,
|
params: params ? JSON.stringify(params) : undefined,
|
||||||
@@ -114,6 +146,9 @@ export default async function enqueueBotJob({
|
|||||||
task_id: final_task_id ? Number(final_task_id) : undefined,
|
task_id: final_task_id ? Number(final_task_id) : undefined,
|
||||||
computer_id: computer_id ? Number(computer_id) : undefined,
|
computer_id: computer_id ? Number(computer_id) : undefined,
|
||||||
requested_by_bot_id: requested_by_bot_id ? Number(requested_by_bot_id) : undefined,
|
requested_by_bot_id: requested_by_bot_id ? Number(requested_by_bot_id) : undefined,
|
||||||
|
requested_by_session_id: requested_by_session_id
|
||||||
|
? Number(requested_by_session_id)
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
delay,
|
delay,
|
||||||
@@ -147,9 +182,13 @@ async function belongsToBot({
|
|||||||
|
|
||||||
async function createJobTask({
|
async function createJobTask({
|
||||||
bot_id,
|
bot_id,
|
||||||
|
session_id,
|
||||||
|
workspace_id,
|
||||||
title,
|
title,
|
||||||
}: {
|
}: {
|
||||||
bot_id: number;
|
bot_id: number;
|
||||||
|
session_id: number;
|
||||||
|
workspace_id: number | null;
|
||||||
title: string;
|
title: string;
|
||||||
}) {
|
}) {
|
||||||
const res = await BunSQLite.insert<
|
const res = await BunSQLite.insert<
|
||||||
@@ -157,7 +196,7 @@ async function createJobTask({
|
|||||||
(typeof BunSQLiteTables)[number]
|
(typeof BunSQLiteTables)[number]
|
||||||
>({
|
>({
|
||||||
table: "bot_tasks",
|
table: "bot_tasks",
|
||||||
data: [{ bot_id, title, status: "active" }],
|
data: [{ bot_id, session_id, workspace_id: workspace_id || undefined, title, status: "active" }],
|
||||||
});
|
});
|
||||||
|
|
||||||
const task_id = res.singleRes?.id || res.postInsertReturn?.insertId;
|
const task_id = res.singleRes?.id || res.postInsertReturn?.insertId;
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import { BotReplyJobTitle } from "@/src/dict/bot-queue-dict";
|
|||||||
type Params = {
|
type Params = {
|
||||||
user_id: number | string;
|
user_id: number | string;
|
||||||
bot_id: number | string;
|
bot_id: number | string;
|
||||||
|
/**
|
||||||
|
* The session the user wrote in.
|
||||||
|
*/
|
||||||
|
session_id: number | string;
|
||||||
/**
|
/**
|
||||||
* The user message to answer.
|
* The user message to answer.
|
||||||
*/
|
*/
|
||||||
@@ -13,21 +17,23 @@ type Params = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Queues the bot's chat reply to the user's latest message(s). A reply
|
* Queues the bot's chat reply to the user's latest message(s) in a
|
||||||
* reads the whole recent conversation when it runs, so a burst of
|
* session. A reply reads the session's recent messages when it runs, so a
|
||||||
* messages shares one reply: while one is still pending, it is reused.
|
* burst of messages shares one reply: while one is still pending in the
|
||||||
|
* session, it is reused.
|
||||||
*/
|
*/
|
||||||
export default async function enqueueBotReply({
|
export default async function enqueueBotReply({
|
||||||
user_id,
|
user_id,
|
||||||
bot_id,
|
bot_id,
|
||||||
|
session_id,
|
||||||
message_id,
|
message_id,
|
||||||
}: Params) {
|
}: Params) {
|
||||||
const pending = (
|
const pending = (
|
||||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS_QUEUE>({
|
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS_QUEUE>({
|
||||||
sql: `SELECT id FROM bots_queue
|
sql: `SELECT id FROM bots_queue
|
||||||
WHERE bot_id = ? AND kind = 'reply' AND status = 'pending'
|
WHERE bot_id = ? AND session_id = ? AND kind = 'reply' AND status = 'pending'
|
||||||
ORDER BY id DESC LIMIT 1`,
|
ORDER BY id DESC LIMIT 1`,
|
||||||
values: [bot_id],
|
values: [bot_id, session_id],
|
||||||
})
|
})
|
||||||
).payload?.[0];
|
).payload?.[0];
|
||||||
|
|
||||||
@@ -44,6 +50,7 @@ export default async function enqueueBotReply({
|
|||||||
kind: "reply",
|
kind: "reply",
|
||||||
user_id,
|
user_id,
|
||||||
bot_id,
|
bot_id,
|
||||||
|
session_id,
|
||||||
title: BotReplyJobTitle,
|
title: BotReplyJobTitle,
|
||||||
instructions: "Reply to the user's latest messages.",
|
instructions: "Reply to the user's latest messages.",
|
||||||
message_id,
|
message_id,
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { BUN_SQLITE_COBBLE_BOTS_QUEUE } from "@/db/types/db";
|
||||||
|
import ensureBotSession from "../bot-sessions/ensure-bot-session";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
job: Pick<BUN_SQLITE_COBBLE_BOTS_QUEUE, "bot_id" | "session_id" | "workspace_id">;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The session a job's result goes in: its own, or (for a job queued
|
||||||
|
* without one) the active session of its bot's conversation there.
|
||||||
|
*/
|
||||||
|
export default async function jobSessionId({ job }: Params) {
|
||||||
|
if (job.session_id) return Number(job.session_id);
|
||||||
|
|
||||||
|
const session = await ensureBotSession({
|
||||||
|
bot_id: job.bot_id!,
|
||||||
|
workspace_id: job.workspace_id || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return Number(session.id);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import type { BUN_SQLITE_COBBLE_BOTS_QUEUE } from "@/db/types/db";
|
|||||||
import insertBotMessage from "../bots/insert-bot-message";
|
import insertBotMessage from "../bots/insert-bot-message";
|
||||||
import setBotFace from "../bots/set-bot-face";
|
import setBotFace from "../bots/set-bot-face";
|
||||||
import relayJobToRequester from "./relay-job-to-requester";
|
import relayJobToRequester from "./relay-job-to-requester";
|
||||||
|
import jobSessionId from "./job-session-id";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
job: BUN_SQLITE_COBBLE_BOTS_QUEUE;
|
job: BUN_SQLITE_COBBLE_BOTS_QUEUE;
|
||||||
@@ -30,6 +31,8 @@ export default async function notifyBotJobFailed({
|
|||||||
|
|
||||||
const message = await insertBotMessage({
|
const message = await insertBotMessage({
|
||||||
bot_id: job.bot_id,
|
bot_id: job.bot_id,
|
||||||
|
session_id: await jobSessionId({ job }),
|
||||||
|
workspace_id: job.workspace_id || null,
|
||||||
text:
|
text:
|
||||||
failure_message ||
|
failure_message ||
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_BOTS_QUEUE } from "@/db/types/db";
|
import type {
|
||||||
|
BUN_SQLITE_COBBLE_BOT_SESSIONS,
|
||||||
|
BUN_SQLITE_COBBLE_BOTS,
|
||||||
|
BUN_SQLITE_COBBLE_BOTS_QUEUE,
|
||||||
|
} from "@/db/types/db";
|
||||||
import insertBotMessage from "../bots/insert-bot-message";
|
import insertBotMessage from "../bots/insert-bot-message";
|
||||||
|
import ensureBotSession from "../bot-sessions/ensure-bot-session";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
job: BUN_SQLITE_COBBLE_BOTS_QUEUE;
|
job: BUN_SQLITE_COBBLE_BOTS_QUEUE;
|
||||||
@@ -13,9 +18,11 @@ type Params = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Reports a handed-over job back to the bot that handed it over
|
* Reports a handed-over job back to the bot that handed it over
|
||||||
* (`requested_by_bot_id`, the coordinator): a message in its conversation
|
* (`requested_by_bot_id`, the coordinator): a message in the conversation
|
||||||
* with the worker's report, flagged needs-you when the job failed. Returns
|
* it was handed over from (the active session there, which may have moved
|
||||||
* the message for the live push, or null when nobody handed the job over.
|
* on since), with the worker's report, flagged needs-you when the job
|
||||||
|
* failed. Returns the message for the live push, or null when nobody
|
||||||
|
* handed the job over.
|
||||||
*/
|
*/
|
||||||
export default async function relayJobToRequester({ job, text, failed }: Params) {
|
export default async function relayJobToRequester({ job, text, failed }: Params) {
|
||||||
if (!job.requested_by_bot_id || !job.bot_id || job.requested_by_bot_id == job.bot_id) {
|
if (!job.requested_by_bot_id || !job.bot_id || job.requested_by_bot_id == job.bot_id) {
|
||||||
@@ -38,11 +45,25 @@ export default async function relayJobToRequester({ job, text, failed }: Params)
|
|||||||
|
|
||||||
if (!requester?.id) return null;
|
if (!requester?.id) return null;
|
||||||
|
|
||||||
|
const requested_from = job.requested_by_session_id
|
||||||
|
? (
|
||||||
|
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_SESSIONS>({
|
||||||
|
sql: `SELECT workspace_id FROM bot_sessions WHERE id = ? AND bot_id = ? LIMIT 1`,
|
||||||
|
values: [job.requested_by_session_id, requester.id],
|
||||||
|
})
|
||||||
|
).payload?.[0]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const workspace_id = requested_from ? requested_from.workspace_id || null : job.workspace_id || null;
|
||||||
|
const session = await ensureBotSession({ bot_id: requester.id, workspace_id });
|
||||||
|
|
||||||
const name = worker?.name || "A crew member";
|
const name = worker?.name || "A crew member";
|
||||||
const title = job.title || "the job";
|
const title = job.title || "the job";
|
||||||
|
|
||||||
const message = await insertBotMessage({
|
const message = await insertBotMessage({
|
||||||
bot_id: requester.id,
|
bot_id: requester.id,
|
||||||
|
session_id: Number(session.id),
|
||||||
|
workspace_id,
|
||||||
text: failed
|
text: failed
|
||||||
? `${name} couldn't finish **${title}**. ${text}`.trim()
|
? `${name} couldn't finish **${title}**. ${text}`.trim()
|
||||||
: `${name} finished **${title}**. Their report:\n\n${text}`,
|
: `${name} finished **${title}**. Their report:\n\n${text}`,
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { BUN_SQLITE_COBBLE_BOTS, BunSQLiteTables } from "@/db/types/db";
|
||||||
|
import type { BotHue } from "@/src/dict/bot-dict";
|
||||||
|
import assertPromptLength from "@/src/utils/assert-prompt-length";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
user_id: number | string;
|
||||||
|
name: string;
|
||||||
|
role?: string;
|
||||||
|
hue: BotHue;
|
||||||
|
/**
|
||||||
|
* Its core instructions (markdown).
|
||||||
|
*/
|
||||||
|
system_prompt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a worker bot to the user's crew and returns its id. It has no
|
||||||
|
* computer of its own until it's given one; in a workspace it uses the
|
||||||
|
* workspace's.
|
||||||
|
*/
|
||||||
|
export default async function createBot({ user_id, name, role, hue, system_prompt }: Params) {
|
||||||
|
assertPromptLength({ label: "Instructions", text: system_prompt });
|
||||||
|
|
||||||
|
const res = await BunSQLite.insert<
|
||||||
|
BUN_SQLITE_COBBLE_BOTS,
|
||||||
|
(typeof BunSQLiteTables)[number]
|
||||||
|
>({
|
||||||
|
table: "bots",
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
user_id: Number(user_id),
|
||||||
|
name,
|
||||||
|
kind: "worker",
|
||||||
|
hue,
|
||||||
|
role: role || undefined,
|
||||||
|
face: "idle",
|
||||||
|
status_line: "",
|
||||||
|
system_prompt,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const bot_id = res.singleRes?.id || res.postInsertReturn?.insertId;
|
||||||
|
|
||||||
|
if (!res.success || !bot_id) {
|
||||||
|
throw new Error(`Couldn't add the bot: ${res.msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Number(bot_id);
|
||||||
|
}
|
||||||
@@ -55,8 +55,6 @@ export default async function ensureCoordinatorBot({ user_id }: Params) {
|
|||||||
face: "idle",
|
face: "idle",
|
||||||
status_line: "",
|
status_line: "",
|
||||||
system_prompt: CoordinatorSystemPrompt,
|
system_prompt: CoordinatorSystemPrompt,
|
||||||
unread: 0,
|
|
||||||
last_activity_at: Date.now(),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,18 +8,25 @@ import grabBotJobUpdates from "../bots-queue/grab-bot-job-updates";
|
|||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
bot_id: number | string;
|
bot_id: number | string;
|
||||||
|
/**
|
||||||
|
* Only a task started from this session.
|
||||||
|
*/
|
||||||
|
session_id?: number | string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The bot's most recent active task, its steps and what its job has done
|
* The bot's most recent active task (in one session, when given), its
|
||||||
* so far, for the working-on panel.
|
* steps and what its job has done so far, for the working-on panel.
|
||||||
*/
|
*/
|
||||||
export default async function grabActiveBotTask({
|
export default async function grabActiveBotTask({
|
||||||
bot_id,
|
bot_id,
|
||||||
|
session_id,
|
||||||
}: Params): Promise<BotTaskWithSteps | null> {
|
}: Params): Promise<BotTaskWithSteps | null> {
|
||||||
const task_res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_TASKS>({
|
const task_res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_TASKS>({
|
||||||
sql: `SELECT * FROM bot_tasks WHERE bot_id = ? AND status = 'active' ORDER BY created_at DESC, id DESC LIMIT 1`,
|
sql: `SELECT * FROM bot_tasks WHERE bot_id = ? AND status = 'active'
|
||||||
values: [bot_id],
|
${session_id ? `AND session_id = ?` : ""}
|
||||||
|
ORDER BY created_at DESC, id DESC LIMIT 1`,
|
||||||
|
values: session_id ? [bot_id, session_id] : [bot_id],
|
||||||
});
|
});
|
||||||
|
|
||||||
const task = task_res.singleRes || task_res.payload?.[0];
|
const task = task_res.singleRes || task_res.payload?.[0];
|
||||||
|
|||||||
@@ -5,30 +5,29 @@ import grabMessagesMedia from "./grab-messages-media";
|
|||||||
import mediaToAttachment from "./media-to-attachment";
|
import mediaToAttachment from "./media-to-attachment";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
bot_id: number | string;
|
session_id: number | string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The latest messages of a bot's conversation, oldest first, with their
|
* The latest messages of a session, oldest first, with their attachments.
|
||||||
* attachments.
|
|
||||||
*/
|
*/
|
||||||
export default async function grabBotMessages({
|
export default async function grabBotMessages({
|
||||||
bot_id,
|
session_id,
|
||||||
limit = 100,
|
limit = 100,
|
||||||
}: Params): Promise<BotMessageWithAttachments[]> {
|
}: Params): Promise<BotMessageWithAttachments[]> {
|
||||||
let sql = ``;
|
let sql = ``;
|
||||||
|
|
||||||
sql += `SELECT * FROM (\n`;
|
sql += `SELECT * FROM (\n`;
|
||||||
sql += ` SELECT * FROM bot_messages\n`;
|
sql += ` SELECT * FROM bot_messages\n`;
|
||||||
sql += ` WHERE bot_id = ?\n`;
|
sql += ` WHERE session_id = ?\n`;
|
||||||
sql += ` ORDER BY created_at DESC, id DESC\n`;
|
sql += ` ORDER BY created_at DESC, id DESC\n`;
|
||||||
sql += ` LIMIT ?\n`;
|
sql += ` LIMIT ?\n`;
|
||||||
sql += `) ORDER BY created_at ASC, id ASC\n`;
|
sql += `) ORDER BY created_at ASC, id ASC\n`;
|
||||||
|
|
||||||
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_MESSAGES>({
|
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_MESSAGES>({
|
||||||
sql,
|
sql,
|
||||||
values: [bot_id, limit],
|
values: [session_id, limit],
|
||||||
});
|
});
|
||||||
|
|
||||||
const messages = res.payload || [];
|
const messages = res.payload || [];
|
||||||
|
|||||||
@@ -1,39 +1,42 @@
|
|||||||
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
|
||||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
|
||||||
import composeBotPrompt from "@/src/utils/compose-bot-prompt";
|
import composeBotPrompt from "@/src/utils/compose-bot-prompt";
|
||||||
import AppError from "@/src/utils/app-error";
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import grabWorkspaceBot from "../workspaces/grab-workspace-bot";
|
||||||
import grabUserBot from "./grab-user-bot";
|
import grabUserBot from "./grab-user-bot";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
bot_id: number | string;
|
bot_id: number | string;
|
||||||
user_id: number | string;
|
user_id: number | string;
|
||||||
|
/**
|
||||||
|
* The workspace the bot is working in. Its context and the bot's
|
||||||
|
* instructions and memory there join the prompt.
|
||||||
|
*/
|
||||||
|
workspace_id?: number | string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The full prompt for one of the user's bots: its core instructions, the
|
* The full prompt for one of the user's bots (`composeBotPrompt`): its
|
||||||
* system prompt of every non-archived workspace it's in, and its memory.
|
* core instructions and memory, and in a workspace that workspace's
|
||||||
|
* context and what the bot has for it.
|
||||||
*/
|
*/
|
||||||
export default async function grabBotPrompt({ bot_id, user_id }: Params) {
|
export default async function grabBotPrompt({ bot_id, user_id, workspace_id }: Params) {
|
||||||
const bot = await grabUserBot({ bot_id, user_id });
|
const bot = await grabUserBot({ bot_id, user_id });
|
||||||
|
|
||||||
if (!bot?.id) {
|
if (!bot?.id) {
|
||||||
throw new AppError(`Bot not found.`);
|
throw new AppError(`Bot not found.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspaces =
|
const found = workspace_id
|
||||||
(
|
? await grabWorkspaceBot({ workspace_id, bot_id: bot.id, user_id })
|
||||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_WORKSPACES>({
|
: null;
|
||||||
sql: `SELECT w.name, w.system_prompt FROM workspaces w
|
|
||||||
JOIN workspace_bots wb ON wb.workspace_id = w.id
|
if (workspace_id && !found) {
|
||||||
WHERE wb.bot_id = ? AND w.user_id = ? AND w.archived = 0
|
throw new AppError(`${bot.name || "This bot"} isn't in that workspace.`);
|
||||||
ORDER BY w.id ASC`,
|
}
|
||||||
values: [bot.id, user_id],
|
|
||||||
})
|
|
||||||
).payload || [];
|
|
||||||
|
|
||||||
return composeBotPrompt({
|
return composeBotPrompt({
|
||||||
system_prompt: bot.system_prompt,
|
system_prompt: bot.system_prompt,
|
||||||
memory: bot.memory,
|
memory: bot.memory,
|
||||||
workspaces,
|
workspace: found?.workspace,
|
||||||
|
workspace_bot: found?.membership,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,36 +3,57 @@ import BunSQLite from "@moduletrace/bun-sqlite";
|
|||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
user_id: number | string;
|
user_id: number | string;
|
||||||
|
/**
|
||||||
|
* A workspace's crew instead: the coordinator and the bots in it, with
|
||||||
|
* their conversations in that workspace.
|
||||||
|
*/
|
||||||
|
workspace_id?: number | string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A user's crew for the rail: the coordinator pinned first, then
|
* A user's crew for the rail: the coordinator pinned first, then
|
||||||
* workers by latest activity. Each row carries its latest message.
|
* workers by latest activity. Each row carries its conversation's unread
|
||||||
|
* flag, latest activity and latest message: in its own chat, or in the
|
||||||
|
* workspace when one is given.
|
||||||
*/
|
*/
|
||||||
export default async function grabBots({
|
export default async function grabBots({
|
||||||
user_id,
|
user_id,
|
||||||
|
workspace_id,
|
||||||
}: Params): Promise<BotWithLastMessage[]> {
|
}: Params): Promise<BotWithLastMessage[]> {
|
||||||
|
const scope = workspace_id ? Number(workspace_id) : null;
|
||||||
|
|
||||||
let sql = ``;
|
let sql = ``;
|
||||||
|
|
||||||
sql += `SELECT\n`;
|
sql += `SELECT\n`;
|
||||||
sql += ` b.*,\n`;
|
sql += ` b.*,\n`;
|
||||||
|
sql += ` COALESCE(t.unread, 0) AS unread,\n`;
|
||||||
|
sql += ` t.last_activity_at AS last_activity_at,\n`;
|
||||||
sql += ` lm.text AS last_message_text,\n`;
|
sql += ` lm.text AS last_message_text,\n`;
|
||||||
sql += ` lm.created_at AS last_message_at\n`;
|
sql += ` lm.created_at AS last_message_at\n`;
|
||||||
sql += `FROM bots b\n`;
|
sql += `FROM bots b\n`;
|
||||||
|
sql += `LEFT JOIN (\n`;
|
||||||
|
sql += ` SELECT bot_id, MAX(unread) AS unread, MAX(last_activity_at) AS last_activity_at\n`;
|
||||||
|
sql += ` FROM bot_sessions WHERE workspace_id IS ? GROUP BY bot_id\n`;
|
||||||
|
sql += `) t ON t.bot_id = b.id\n`;
|
||||||
sql += `LEFT JOIN bot_messages lm ON lm.id = (\n`;
|
sql += `LEFT JOIN bot_messages lm ON lm.id = (\n`;
|
||||||
sql += ` SELECT id FROM bot_messages\n`;
|
sql += ` SELECT id FROM bot_messages\n`;
|
||||||
sql += ` WHERE bot_id = b.id\n`;
|
sql += ` WHERE bot_id = b.id AND workspace_id IS ?\n`;
|
||||||
sql += ` ORDER BY created_at DESC, id DESC\n`;
|
sql += ` ORDER BY created_at DESC, id DESC\n`;
|
||||||
sql += ` LIMIT 1\n`;
|
sql += ` LIMIT 1\n`;
|
||||||
sql += `)\n`;
|
sql += `)\n`;
|
||||||
sql += `WHERE b.user_id = ?\n`;
|
sql += `WHERE b.user_id = ?\n`;
|
||||||
|
|
||||||
|
if (scope) {
|
||||||
|
sql += `AND (b.kind = 'coordinator' OR b.id IN (SELECT bot_id FROM workspace_bots WHERE workspace_id = ?))\n`;
|
||||||
|
}
|
||||||
|
|
||||||
sql += `ORDER BY\n`;
|
sql += `ORDER BY\n`;
|
||||||
sql += ` CASE WHEN b.kind = 'coordinator' THEN 0 ELSE 1 END,\n`;
|
sql += ` CASE WHEN b.kind = 'coordinator' THEN 0 ELSE 1 END,\n`;
|
||||||
sql += ` COALESCE(lm.created_at, b.last_activity_at, b.created_at) DESC\n`;
|
sql += ` COALESCE(lm.created_at, t.last_activity_at, b.created_at) DESC\n`;
|
||||||
|
|
||||||
const res = await BunSQLite.sql<BotWithLastMessage>({
|
const res = await BunSQLite.sql<BotWithLastMessage>({
|
||||||
sql,
|
sql,
|
||||||
values: [user_id],
|
values: [scope, scope, user_id, ...(scope ? [scope] : [])],
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.payload || [];
|
return res.payload || [];
|
||||||
|
|||||||
@@ -6,16 +6,23 @@ import BunSQLite from "@moduletrace/bun-sqlite";
|
|||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
bot_id: number;
|
bot_id: number;
|
||||||
|
/**
|
||||||
|
* The session it goes in, and that session's workspace.
|
||||||
|
*/
|
||||||
|
session_id: number;
|
||||||
|
workspace_id?: number | null;
|
||||||
text: string;
|
text: string;
|
||||||
needs_you?: boolean;
|
needs_you?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Appends a bot message to its conversation, marks the bot unread and
|
* Appends a bot message to a session, marks the session unread and bumps
|
||||||
* bumps its activity. Callers must check the bot first.
|
* its activity. Callers must check the bot and session first.
|
||||||
*/
|
*/
|
||||||
export default async function insertBotMessage({
|
export default async function insertBotMessage({
|
||||||
bot_id,
|
bot_id,
|
||||||
|
session_id,
|
||||||
|
workspace_id,
|
||||||
text,
|
text,
|
||||||
needs_you,
|
needs_you,
|
||||||
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_MESSAGES> {
|
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_MESSAGES> {
|
||||||
@@ -27,7 +34,16 @@ export default async function insertBotMessage({
|
|||||||
(typeof BunSQLiteTables)[number]
|
(typeof BunSQLiteTables)[number]
|
||||||
>({
|
>({
|
||||||
table: "bot_messages",
|
table: "bot_messages",
|
||||||
data: [{ bot_id, author: "bot", text, needs_you: needs_you_flag }],
|
data: [
|
||||||
|
{
|
||||||
|
bot_id,
|
||||||
|
session_id,
|
||||||
|
workspace_id: workspace_id || undefined,
|
||||||
|
author: "bot",
|
||||||
|
text,
|
||||||
|
needs_you: needs_you_flag,
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const id = res.postInsertReturn?.insertId;
|
const id = res.postInsertReturn?.insertId;
|
||||||
@@ -37,13 +53,15 @@ export default async function insertBotMessage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
await BunSQLite.sql({
|
await BunSQLite.sql({
|
||||||
sql: `UPDATE bots SET last_activity_at = ?, unread = 1 WHERE id = ?`,
|
sql: `UPDATE bot_sessions SET last_activity_at = ?, unread = 1 WHERE id = ?`,
|
||||||
values: [now, bot_id],
|
values: [now, session_id],
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
bot_id,
|
bot_id,
|
||||||
|
session_id,
|
||||||
|
workspace_id: workspace_id || undefined,
|
||||||
author: "bot",
|
author: "bot",
|
||||||
text,
|
text,
|
||||||
needs_you: needs_you_flag,
|
needs_you: needs_you_flag,
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ import mediaToAttachment from "./media-to-attachment";
|
|||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
bot_id: number;
|
bot_id: number;
|
||||||
|
/**
|
||||||
|
* The session it goes in, and that session's workspace.
|
||||||
|
*/
|
||||||
|
session_id: number;
|
||||||
|
workspace_id?: number | null;
|
||||||
text: string;
|
text: string;
|
||||||
/**
|
/**
|
||||||
* Files to attach, already checked to be the user's own
|
* Files to attach, already checked to be the user's own
|
||||||
@@ -18,12 +23,14 @@ type Params = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Appends a user message (and its files) to a bot's conversation and
|
* Appends a user message (and its files) to a session and bumps its
|
||||||
* bumps its activity. Callers must check the bot belongs to the session
|
* activity. The first one titles the session. Callers must check the bot
|
||||||
* user first.
|
* and session belong to the session user first.
|
||||||
*/
|
*/
|
||||||
export default async function insertUserMessage({
|
export default async function insertUserMessage({
|
||||||
bot_id,
|
bot_id,
|
||||||
|
session_id,
|
||||||
|
workspace_id,
|
||||||
text,
|
text,
|
||||||
media = [],
|
media = [],
|
||||||
}: Params): Promise<BotMessageWithAttachments> {
|
}: Params): Promise<BotMessageWithAttachments> {
|
||||||
@@ -34,7 +41,16 @@ export default async function insertUserMessage({
|
|||||||
(typeof BunSQLiteTables)[number]
|
(typeof BunSQLiteTables)[number]
|
||||||
>({
|
>({
|
||||||
table: "bot_messages",
|
table: "bot_messages",
|
||||||
data: [{ bot_id, author: "user", text, needs_you: 0 }],
|
data: [
|
||||||
|
{
|
||||||
|
bot_id,
|
||||||
|
session_id,
|
||||||
|
workspace_id: workspace_id || undefined,
|
||||||
|
author: "user",
|
||||||
|
text,
|
||||||
|
needs_you: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const id = res.postInsertReturn?.insertId;
|
const id = res.postInsertReturn?.insertId;
|
||||||
@@ -50,14 +66,20 @@ export default async function insertUserMessage({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const title = (text || media[0]?.title || "").replace(/\s+/g, " ").trim().slice(0, 80);
|
||||||
|
|
||||||
await BunSQLite.sql({
|
await BunSQLite.sql({
|
||||||
sql: `UPDATE bots SET last_activity_at = ? WHERE id = ?`,
|
sql: `UPDATE bot_sessions SET last_activity_at = ?,
|
||||||
values: [now, bot_id],
|
title = CASE WHEN COALESCE(title, '') = '' THEN ? ELSE title END
|
||||||
|
WHERE id = ?`,
|
||||||
|
values: [now, title, session_id],
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
bot_id,
|
bot_id,
|
||||||
|
session_id,
|
||||||
|
workspace_id: workspace_id || undefined,
|
||||||
author: "user",
|
author: "user",
|
||||||
text,
|
text,
|
||||||
needs_you: 0,
|
needs_you: 0,
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
|
||||||
|
|
||||||
type Params = {
|
|
||||||
bot_id: number | string;
|
|
||||||
user_id: number | string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clears a bot's unread flag when its owner opens the conversation.
|
|
||||||
*/
|
|
||||||
export default async function markBotRead({ bot_id, user_id }: Params) {
|
|
||||||
await BunSQLite.sql({
|
|
||||||
sql: `UPDATE bots SET unread = 0 WHERE id = ? AND user_id = ? AND unread = 1`,
|
|
||||||
values: [bot_id, user_id],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { BotHue } from "@/src/dict/bot-dict";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import assertPromptLength from "@/src/utils/assert-prompt-length";
|
||||||
|
import grabUserBot from "./grab-user-bot";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
user_id: number | string;
|
||||||
|
bot_id: number | string;
|
||||||
|
name: string;
|
||||||
|
role?: string;
|
||||||
|
hue: BotHue;
|
||||||
|
system_prompt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves one of the user's bots: name, job, hue and core instructions. The
|
||||||
|
* coordinator keeps its name and stays hue-less, so only its instructions
|
||||||
|
* change. Returns the saved bot.
|
||||||
|
*/
|
||||||
|
export default async function updateBot({ user_id, bot_id, name, role, hue, system_prompt }: Params) {
|
||||||
|
assertPromptLength({ label: "Instructions", text: system_prompt });
|
||||||
|
|
||||||
|
const bot = await grabUserBot({ bot_id, user_id });
|
||||||
|
|
||||||
|
if (!bot?.id) {
|
||||||
|
throw new AppError(`Bot not found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bot.kind == "coordinator") {
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE bots SET system_prompt = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
values: [system_prompt, Date.now(), bot.id],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE bots SET name = ?, role = ?, hue = ?, system_prompt = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
values: [name, role || null, hue, system_prompt, Date.now(), bot.id],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await grabUserBot({ bot_id, user_id }))!;
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type {
|
||||||
|
BUN_SQLITE_COBBLE_COMPUTERS,
|
||||||
|
BUN_SQLITE_COBBLE_CONTAINERS,
|
||||||
|
BunSQLiteTables,
|
||||||
|
} from "@/db/types/db";
|
||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import type { ComputerKind } from "@/src/dict/computer-dict";
|
||||||
|
import grabEnv from "../envs/grab-env";
|
||||||
|
import provisionComputer from "./provision-computer";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
user_id: number | string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
/**
|
||||||
|
* `main` only for the owner's one main computer (`ensureMainComputer`).
|
||||||
|
*/
|
||||||
|
kind?: ComputerKind;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records a new computer for the user and its container
|
||||||
|
* (`<AppData.ComputerContainerPrefix>-<id>`), then sets the container up
|
||||||
|
* in the background (`provisionComputer`). Returns the computer at once,
|
||||||
|
* still `creating`, and the setup to await when the caller must (a CLI
|
||||||
|
* script). The main computer of a server that reaches it at
|
||||||
|
* `MAIN_COMPUTER_URL` (a dev server) gets no container of its own.
|
||||||
|
*/
|
||||||
|
export default async function createComputer({
|
||||||
|
user_id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
kind = "sandbox",
|
||||||
|
}: Params) {
|
||||||
|
const external = kind == "main" && Boolean((await grabEnv({ key: "MAIN_COMPUTER_URL" }))?.trim());
|
||||||
|
|
||||||
|
const container_res = await BunSQLite.insert<
|
||||||
|
BUN_SQLITE_COBBLE_CONTAINERS,
|
||||||
|
(typeof BunSQLiteTables)[number]
|
||||||
|
>({
|
||||||
|
table: "containers",
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
name: `${AppData.ComputerContainerPrefix}-pending-${Date.now()}`,
|
||||||
|
purpose: "computer",
|
||||||
|
network: AppData.DockerNetworkName,
|
||||||
|
port: AppData.ComputerPort,
|
||||||
|
image: AppData.ComputerImage,
|
||||||
|
status: external ? "unknown" : "creating",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const container_id = container_res.singleRes?.id || container_res.postInsertReturn?.insertId;
|
||||||
|
|
||||||
|
if (!container_res.success || !container_id) {
|
||||||
|
throw new Error(`Couldn't record the computer's container: ${container_res.msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const computer_res = await BunSQLite.insert<
|
||||||
|
BUN_SQLITE_COBBLE_COMPUTERS,
|
||||||
|
(typeof BunSQLiteTables)[number]
|
||||||
|
>({
|
||||||
|
table: "computers",
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
user_id: Number(user_id),
|
||||||
|
container_id: Number(container_id),
|
||||||
|
name,
|
||||||
|
description: description || undefined,
|
||||||
|
kind,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const computer_id = computer_res.singleRes?.id || computer_res.postInsertReturn?.insertId;
|
||||||
|
|
||||||
|
if (!computer_res.success || !computer_id) {
|
||||||
|
await BunSQLite.sql({ sql: `DELETE FROM containers WHERE id = ?`, values: [container_id] });
|
||||||
|
throw new Error(`Couldn't create the computer: ${computer_res.msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const container_name = `${AppData.ComputerContainerPrefix}-${computer_id}`;
|
||||||
|
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE containers SET name = ?, hostname = ? WHERE id = ?`,
|
||||||
|
values: [container_name, container_name, container_id],
|
||||||
|
});
|
||||||
|
|
||||||
|
const provisioning = external
|
||||||
|
? Promise.resolve()
|
||||||
|
: provisionComputer({ computer_id: Number(computer_id), fresh: true });
|
||||||
|
|
||||||
|
return { computer_id: Number(computer_id), provisioning };
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { BUN_SQLITE_COBBLE_CONTAINERS } from "@/db/types/db";
|
||||||
|
import removeDockerContainer from "@/src/functions/backend/docker/remove-docker-container";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import grabUserComputer from "./grab-user-computer";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
user_id: number | string;
|
||||||
|
computer_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes one of the user's computers: removes its container and its
|
||||||
|
* records (its access grants and workspace links go with them). Its home
|
||||||
|
* directory is kept, so what it left stays on disk. Workspaces it was the
|
||||||
|
* default of fall back to their oldest computer still linked. The main
|
||||||
|
* computer can't be deleted.
|
||||||
|
*/
|
||||||
|
export default async function deleteComputer({ user_id, computer_id }: Params) {
|
||||||
|
const computer = await grabUserComputer({ computer_id, user_id });
|
||||||
|
|
||||||
|
if (!computer?.id) {
|
||||||
|
throw new AppError(`Computer not found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (computer.kind == "main") {
|
||||||
|
throw new AppError(`The main computer can't be deleted.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = computer.container_id
|
||||||
|
? (
|
||||||
|
await BunSQLite.sql<BUN_SQLITE_COBBLE_CONTAINERS>({
|
||||||
|
sql: `SELECT * FROM containers WHERE id = ? LIMIT 1`,
|
||||||
|
values: [computer.container_id],
|
||||||
|
})
|
||||||
|
).payload?.[0]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (container?.docker_id || container?.name) {
|
||||||
|
await removeDockerContainer({ container: container.docker_id || container.name! });
|
||||||
|
}
|
||||||
|
|
||||||
|
await BunSQLite.sql({ sql: `DELETE FROM computers WHERE id = ?`, values: [computer.id] });
|
||||||
|
|
||||||
|
if (container?.id) {
|
||||||
|
await BunSQLite.sql({ sql: `DELETE FROM containers WHERE id = ?`, values: [container.id] });
|
||||||
|
}
|
||||||
|
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE workspaces SET updated_at = ?, computer_id = (
|
||||||
|
SELECT wc.computer_id FROM workspace_computers wc WHERE wc.workspace_id = workspaces.id ORDER BY wc.id ASC LIMIT 1
|
||||||
|
) WHERE computer_id = ?`,
|
||||||
|
values: [Date.now(), computer.id],
|
||||||
|
});
|
||||||
|
|
||||||
|
return computer;
|
||||||
|
}
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
|
||||||
import type {
|
|
||||||
BUN_SQLITE_COBBLE_CONTAINERS,
|
|
||||||
BunSQLiteTables,
|
|
||||||
} from "@/db/types/db";
|
|
||||||
import { AppData } from "@/src/data/app-data";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the id of the main computer's container record, creating it if
|
|
||||||
* missing. The container itself is created at install, named
|
|
||||||
* `AppData.MainComputerContainerName` on the `AppData.DockerNetworkName`
|
|
||||||
* network; this only records it. Safe to call on every visit.
|
|
||||||
*/
|
|
||||||
export default async function ensureMainComputerContainer() {
|
|
||||||
const name = AppData.MainComputerContainerName;
|
|
||||||
|
|
||||||
const existing = (
|
|
||||||
await BunSQLite.select<
|
|
||||||
BUN_SQLITE_COBBLE_CONTAINERS,
|
|
||||||
(typeof BunSQLiteTables)[number]
|
|
||||||
>({
|
|
||||||
table: "containers",
|
|
||||||
query: {
|
|
||||||
query: {
|
|
||||||
name: { value: name },
|
|
||||||
},
|
|
||||||
limit: 1,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
).payload?.[0];
|
|
||||||
|
|
||||||
if (existing?.id) {
|
|
||||||
return existing.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await BunSQLite.insert<
|
|
||||||
BUN_SQLITE_COBBLE_CONTAINERS,
|
|
||||||
(typeof BunSQLiteTables)[number]
|
|
||||||
>({
|
|
||||||
table: "containers",
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
name,
|
|
||||||
purpose: "computer",
|
|
||||||
network: AppData.DockerNetworkName,
|
|
||||||
hostname: name,
|
|
||||||
port: AppData.ComputerPort,
|
|
||||||
status: "unknown",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const container_id = res.singleRes?.id || res.postInsertReturn?.insertId;
|
|
||||||
|
|
||||||
if (!res.success || !container_id) {
|
|
||||||
throw new Error(`Couldn't record the main computer's container: ${res.msg}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return container_id;
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
import type {
|
import type {
|
||||||
BUN_SQLITE_COBBLE_COMPUTERS,
|
BUN_SQLITE_COBBLE_COMPUTERS,
|
||||||
|
BUN_SQLITE_COBBLE_CONTAINERS,
|
||||||
BunSQLiteTables,
|
BunSQLiteTables,
|
||||||
} from "@/db/types/db";
|
} from "@/db/types/db";
|
||||||
import { MainComputerName } from "@/src/dict/computer-dict";
|
import { MainComputerName } from "@/src/dict/computer-dict";
|
||||||
import ensureMainComputerContainer from "./ensure-main-computer-container";
|
import grabEnv from "../envs/grab-env";
|
||||||
|
import createComputer from "./create-computer";
|
||||||
|
import provisionComputer from "./provision-computer";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
user_id: number | string;
|
user_id: number | string;
|
||||||
@@ -13,30 +16,26 @@ type Params = {
|
|||||||
* access to the main computer.
|
* access to the main computer.
|
||||||
*/
|
*/
|
||||||
coordinator_id: number | string;
|
coordinator_id: number | string;
|
||||||
|
/**
|
||||||
|
* Wait for a container being set up (a CLI script, which would
|
||||||
|
* otherwise exit first).
|
||||||
|
*/
|
||||||
|
wait?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the id of the user's main computer, recording it (and its
|
* Returns the id of the user's main computer, creating it first if
|
||||||
* container) first if missing, and makes sure the coordinator has access
|
* missing (`createComputer`, which sets its container up in the
|
||||||
* to it. Safe to call on every visit.
|
* background), and makes sure the coordinator has access to it. A main
|
||||||
|
* computer recorded before the app managed containers (no docker id, no
|
||||||
|
* `MAIN_COMPUTER_URL`) gets its container set up now. Safe to call on
|
||||||
|
* every visit.
|
||||||
*/
|
*/
|
||||||
export default async function ensureMainComputer({
|
export default async function ensureMainComputer({
|
||||||
user_id,
|
user_id,
|
||||||
coordinator_id,
|
coordinator_id,
|
||||||
|
wait,
|
||||||
}: Params) {
|
}: Params) {
|
||||||
const computer_id = await grabOrCreateMainComputer({ user_id });
|
|
||||||
|
|
||||||
await BunSQLite.sql({
|
|
||||||
sql: `INSERT OR IGNORE INTO bot_computers (bot_id, computer_id, created_at, updated_at) VALUES (?, ?, ?, ?)`,
|
|
||||||
values: [coordinator_id, computer_id, Date.now(), Date.now()],
|
|
||||||
});
|
|
||||||
|
|
||||||
return computer_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function grabOrCreateMainComputer({
|
|
||||||
user_id,
|
|
||||||
}: Pick<Params, "user_id">) {
|
|
||||||
const existing = (
|
const existing = (
|
||||||
await BunSQLite.select<
|
await BunSQLite.select<
|
||||||
BUN_SQLITE_COBBLE_COMPUTERS,
|
BUN_SQLITE_COBBLE_COMPUTERS,
|
||||||
@@ -54,32 +53,39 @@ async function grabOrCreateMainComputer({
|
|||||||
})
|
})
|
||||||
).payload?.[0];
|
).payload?.[0];
|
||||||
|
|
||||||
if (existing?.id) {
|
let computer_id = existing?.id ? Number(existing.id) : 0;
|
||||||
return existing.id;
|
let provisioning: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
if (!computer_id) {
|
||||||
|
const created = await createComputer({ user_id, name: MainComputerName, kind: "main" });
|
||||||
|
|
||||||
|
computer_id = created.computer_id;
|
||||||
|
provisioning = created.provisioning;
|
||||||
|
} else if (await needsContainer({ container_id: existing?.container_id })) {
|
||||||
|
provisioning = provisionComputer({ computer_id });
|
||||||
}
|
}
|
||||||
|
|
||||||
const container_id = await ensureMainComputerContainer();
|
await BunSQLite.sql({
|
||||||
|
sql: `INSERT OR IGNORE INTO bot_computers (bot_id, computer_id, created_at, updated_at) VALUES (?, ?, ?, ?)`,
|
||||||
const res = await BunSQLite.insert<
|
values: [coordinator_id, computer_id, Date.now(), Date.now()],
|
||||||
BUN_SQLITE_COBBLE_COMPUTERS,
|
|
||||||
(typeof BunSQLiteTables)[number]
|
|
||||||
>({
|
|
||||||
table: "computers",
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
user_id: Number(user_id),
|
|
||||||
container_id: Number(container_id),
|
|
||||||
name: MainComputerName,
|
|
||||||
kind: "main",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const computer_id = res.singleRes?.id || res.postInsertReturn?.insertId;
|
if (wait) await provisioning;
|
||||||
|
|
||||||
if (!res.success || !computer_id) {
|
|
||||||
throw new Error(`Couldn't create the main computer: ${res.msg}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return computer_id;
|
return computer_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function needsContainer({ container_id }: { container_id?: number | "" }) {
|
||||||
|
if ((await grabEnv({ key: "MAIN_COMPUTER_URL" }))?.trim()) return false;
|
||||||
|
|
||||||
|
const container = container_id
|
||||||
|
? (
|
||||||
|
await BunSQLite.sql<BUN_SQLITE_COBBLE_CONTAINERS>({
|
||||||
|
sql: `SELECT docker_id, status FROM containers WHERE id = ? LIMIT 1`,
|
||||||
|
values: [container_id],
|
||||||
|
})
|
||||||
|
).payload?.[0]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return Boolean(container && !container.docker_id && container.status == "unknown");
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,36 +4,53 @@ import BunSQLite from "@moduletrace/bun-sqlite";
|
|||||||
type Params = {
|
type Params = {
|
||||||
bot_id: number | string;
|
bot_id: number | string;
|
||||||
user_id: number | string;
|
user_id: number | string;
|
||||||
|
/**
|
||||||
|
* The workspace the bot is working in: then its computers are the
|
||||||
|
* workspace's, the default first, and nothing else.
|
||||||
|
*/
|
||||||
|
workspace_id?: number | string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every computer a bot can use: the ones it was given directly
|
* Every computer a bot can use where it's working. In a workspace, the
|
||||||
* (`bot_computers`) plus the ones assigned to any workspace it's in
|
* workspace's computers (none linked: none at all), its default first. In
|
||||||
* (`workspace_computers`); the coordinator can use all of them. Only the
|
* its own chat, the ones it was given directly (`bot_computers`); the
|
||||||
* owner's computers, each once. The
|
* coordinator can use all of the owner's. There the one it works on now
|
||||||
* one it works on now (`bots.computer_id`) comes first, else its main one,
|
* (`bots.computer_id`) comes first, else its main one, so `computers[0]`
|
||||||
* so `computers[0]` is where its tasks run by default.
|
* is where its tasks run by default.
|
||||||
*/
|
*/
|
||||||
export default async function grabBotComputers({
|
export default async function grabBotComputers({
|
||||||
bot_id,
|
bot_id,
|
||||||
user_id,
|
user_id,
|
||||||
|
workspace_id,
|
||||||
}: Params): Promise<BUN_SQLITE_COBBLE_COMPUTERS[]> {
|
}: Params): Promise<BUN_SQLITE_COBBLE_COMPUTERS[]> {
|
||||||
|
if (workspace_id) {
|
||||||
|
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_COMPUTERS>({
|
||||||
|
sql: `SELECT c.* FROM computers c
|
||||||
|
JOIN workspace_computers wc ON wc.computer_id = c.id AND wc.workspace_id = ?
|
||||||
|
JOIN workspaces w ON w.id = wc.workspace_id AND w.user_id = c.user_id AND w.archived = 0
|
||||||
|
WHERE c.user_id = ?
|
||||||
|
AND (
|
||||||
|
(SELECT kind FROM bots WHERE id = ? AND user_id = c.user_id) = 'coordinator'
|
||||||
|
OR ? IN (SELECT bot_id FROM workspace_bots WHERE workspace_id = w.id)
|
||||||
|
)
|
||||||
|
ORDER BY c.id = w.computer_id DESC, wc.id ASC`,
|
||||||
|
values: [workspace_id, user_id, bot_id, Number(bot_id)],
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.payload || [];
|
||||||
|
}
|
||||||
|
|
||||||
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_COMPUTERS>({
|
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_COMPUTERS>({
|
||||||
sql: `SELECT c.* FROM computers c
|
sql: `SELECT c.* FROM computers c
|
||||||
WHERE c.user_id = ?
|
WHERE c.user_id = ?
|
||||||
AND (
|
AND (
|
||||||
(SELECT kind FROM bots WHERE id = ? AND user_id = c.user_id) = 'coordinator'
|
(SELECT kind FROM bots WHERE id = ? AND user_id = c.user_id) = 'coordinator'
|
||||||
OR c.id IN (SELECT computer_id FROM bot_computers WHERE bot_id = ?)
|
OR c.id IN (SELECT computer_id FROM bot_computers WHERE bot_id = ?)
|
||||||
OR c.id IN (
|
|
||||||
SELECT wc.computer_id FROM workspace_computers wc
|
|
||||||
JOIN workspace_bots wb ON wb.workspace_id = wc.workspace_id
|
|
||||||
JOIN workspaces w ON w.id = wc.workspace_id
|
|
||||||
WHERE wb.bot_id = ? AND w.archived = 0
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
ORDER BY c.id = (SELECT computer_id FROM bots WHERE id = ?) DESC,
|
ORDER BY c.id = (SELECT computer_id FROM bots WHERE id = ?) DESC,
|
||||||
c.kind = 'main' DESC, c.id ASC`,
|
c.kind = 'main' DESC, c.id ASC`,
|
||||||
values: [user_id, bot_id, bot_id, bot_id, bot_id],
|
values: [user_id, bot_id, bot_id, bot_id],
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.payload || [];
|
return res.payload || [];
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import path from "path";
|
||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import grabDirNames from "@/src/utils/grab-dir-names";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
computer_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a computer's home directory is kept, as this app sees it:
|
||||||
|
* `DATA_DIR/<AppData.ComputerDataDir>/<id>`.
|
||||||
|
*/
|
||||||
|
export default function grabComputerDataDir({ computer_id }: Params) {
|
||||||
|
const { DATA_DIR } = grabDirNames();
|
||||||
|
|
||||||
|
return path.join(DATA_DIR, AppData.ComputerDataDir, String(computer_id));
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import parseJobJson from "@/src/bots-queue/parse-job-json";
|
||||||
|
import type { ComputerWithStatus } from "@/src/types";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
/**
|
||||||
|
* Every computer of this owner, main first.
|
||||||
|
*/
|
||||||
|
user_id?: number | string;
|
||||||
|
/**
|
||||||
|
* Or just this one.
|
||||||
|
*/
|
||||||
|
computer_id?: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computers with their container's state, and why setting one up failed.
|
||||||
|
*/
|
||||||
|
export default async function grabUserComputers({
|
||||||
|
user_id,
|
||||||
|
computer_id,
|
||||||
|
}: Params): Promise<ComputerWithStatus[]> {
|
||||||
|
if (!user_id && !computer_id) return [];
|
||||||
|
|
||||||
|
const rows =
|
||||||
|
(
|
||||||
|
await BunSQLite.sql<ComputerWithStatus & { metadata?: string | null }>({
|
||||||
|
sql: `SELECT c.*, co.status AS status, co.metadata AS metadata FROM computers c
|
||||||
|
LEFT JOIN containers co ON co.id = c.container_id
|
||||||
|
WHERE ${computer_id ? `c.id = ?` : `c.user_id = ?`}
|
||||||
|
ORDER BY c.kind = 'main' DESC, c.id ASC`,
|
||||||
|
values: [computer_id || user_id!],
|
||||||
|
})
|
||||||
|
).payload || [];
|
||||||
|
|
||||||
|
return rows.map(({ metadata, ...computer }) => {
|
||||||
|
const error = parseJobJson({ text: metadata })?.error;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...computer,
|
||||||
|
error: computer.status == "failed" && typeof error == "string" ? error : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import fs from "fs/promises";
|
||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import grabComputerDataDir from "./grab-computer-data-dir";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
computer_id: number | string;
|
||||||
|
/**
|
||||||
|
* A new computer: files already at its path (left by a deleted
|
||||||
|
* computer with the same id, e.g. after a restore) are moved aside to
|
||||||
|
* `<id>-<timestamp>` and kept, so it starts empty.
|
||||||
|
*/
|
||||||
|
fresh?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a computer's home directory, owned by the sandbox's user so it
|
||||||
|
* can write there, and returns its path. Docker would otherwise create a
|
||||||
|
* missing mount source owned by root.
|
||||||
|
*/
|
||||||
|
export default async function prepareComputerDataDir({ computer_id, fresh }: Params) {
|
||||||
|
const dir = grabComputerDataDir({ computer_id });
|
||||||
|
|
||||||
|
if (fresh) {
|
||||||
|
const existing = await fs.readdir(dir).catch(() => null);
|
||||||
|
|
||||||
|
if (existing?.length) {
|
||||||
|
await fs.rename(dir, `${dir}-${Date.now()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.mkdir(dir, { recursive: true });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only root can hand a directory to another user; a dev server
|
||||||
|
* running as that user already owns it.
|
||||||
|
*/
|
||||||
|
await fs.chown(dir, AppData.ComputerUserUid, AppData.ComputerUserGid).catch(() => {});
|
||||||
|
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { 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 createComputerContainer from "@/src/functions/backend/docker/create-computer-container";
|
||||||
|
import { logError } from "@/src/functions/backend/logger";
|
||||||
|
import prepareComputerDataDir from "./prepare-computer-data-dir";
|
||||||
|
import pushComputerUpdate from "./push-computer-update";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
computer_id: number;
|
||||||
|
/**
|
||||||
|
* A brand new computer: start from an empty home directory.
|
||||||
|
*/
|
||||||
|
fresh?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets up a computer's container: prepares its home directory, pulls the
|
||||||
|
* sandbox image and starts the container, then records it (`containers`:
|
||||||
|
* docker id, state, and where to reach it outside docker). A failure is
|
||||||
|
* recorded as `failed` with its reason, which the computer lists show. The
|
||||||
|
* owner's tabs hear about it either way. Never throws.
|
||||||
|
*/
|
||||||
|
export default async function provisionComputer({ computer_id, fresh }: Params) {
|
||||||
|
const row = (
|
||||||
|
await BunSQLite.sql<BUN_SQLITE_COBBLE_CONTAINERS>({
|
||||||
|
sql: `SELECT co.* FROM containers co JOIN computers c ON c.container_id = co.id WHERE c.id = ? LIMIT 1`,
|
||||||
|
values: [computer_id],
|
||||||
|
})
|
||||||
|
).payload?.[0];
|
||||||
|
|
||||||
|
if (!row?.id || !row.name) return;
|
||||||
|
|
||||||
|
const metadata = parseJobJson({ text: row.metadata }) || {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE containers SET status = 'creating', updated_at = ? WHERE id = ?`,
|
||||||
|
values: [Date.now(), row.id],
|
||||||
|
});
|
||||||
|
|
||||||
|
const data_dir = await prepareComputerDataDir({ computer_id, fresh });
|
||||||
|
const created = await createComputerContainer({ computer_id, name: row.name, data_dir });
|
||||||
|
|
||||||
|
const { error: _error, mcp_url: _mcp_url, ...kept } = metadata;
|
||||||
|
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE containers SET docker_id = ?, image = ?, network = ?, hostname = ?, port = ?,
|
||||||
|
status = ?, metadata = ?, last_seen_at = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
values: [
|
||||||
|
created.docker_id,
|
||||||
|
AppData.ComputerImage,
|
||||||
|
created.network,
|
||||||
|
row.name,
|
||||||
|
AppData.ComputerPort,
|
||||||
|
created.status,
|
||||||
|
JSON.stringify({
|
||||||
|
...kept,
|
||||||
|
data_dir,
|
||||||
|
host_dir: created.host_dir,
|
||||||
|
...(created.mcp_url ? { mcp_url: created.mcp_url } : {}),
|
||||||
|
}),
|
||||||
|
Date.now(),
|
||||||
|
Date.now(),
|
||||||
|
row.id,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE containers SET status = 'failed', metadata = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
values: [JSON.stringify({ ...metadata, error: message }), Date.now(), row.id],
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
await logError({ title: `Computer #${computer_id} couldn't be set up`, error }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
await pushComputerUpdate({ computer_id }).catch(() => {});
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { sendToUser } from "@/src/websocket/(functions)/send-to-user";
|
||||||
|
import grabUserComputers from "./grab-user-computers";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
computer_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pushes a computer's current state (`server:computer-updated`) to its
|
||||||
|
* owner's open tabs. Best effort.
|
||||||
|
*/
|
||||||
|
export default async function pushComputerUpdate({ computer_id }: Params) {
|
||||||
|
const computer = (await grabUserComputers({ computer_id }))[0];
|
||||||
|
|
||||||
|
if (!computer?.user_id) return;
|
||||||
|
|
||||||
|
sendToUser({
|
||||||
|
user_id: computer.user_id,
|
||||||
|
data: { event: "server:computer-updated", data: computer },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -10,8 +10,10 @@ type Params = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Switches the computer a bot works on (`bots.computer_id`) to one of its
|
* Switches the computer a bot works on in its own chat
|
||||||
* own (`grabBotComputers`). Returns its computers, the new one first.
|
* (`bots.computer_id`) to one of its own (`grabBotComputers`). In a
|
||||||
|
* workspace the default computer is switched instead
|
||||||
|
* (`setWorkspaceComputer`). Returns its computers, the new one first.
|
||||||
*/
|
*/
|
||||||
export default async function setBotComputer({ bot_id, user_id, computer_id }: Params) {
|
export default async function setBotComputer({ bot_id, user_id, computer_id }: Params) {
|
||||||
const bot = await grabUserBot({ bot_id, user_id });
|
const bot = await grabUserBot({ bot_id, user_id });
|
||||||
|
|||||||
@@ -5,32 +5,31 @@ import resolveScopedEnvs from "./resolve-scoped-envs";
|
|||||||
type Params = {
|
type Params = {
|
||||||
computer_id: number | string;
|
computer_id: number | string;
|
||||||
/**
|
/**
|
||||||
* The bot using the computer. Only workspaces it shares with the
|
* The workspace the run is in. Its envs apply when the computer is
|
||||||
* computer apply; without a bot, every workspace the computer is in.
|
* linked to it (and it isn't archived); outside a workspace only the
|
||||||
|
* computer's own and general envs do.
|
||||||
*/
|
*/
|
||||||
bot_id?: number | string;
|
workspace_id?: number | string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The envs a run on this computer gets (`resolveScopedEnvs`). Workspaces
|
* The envs a run on this computer gets (`resolveScopedEnvs`).
|
||||||
* are non-archived ones, oldest first, so the oldest wins a tie.
|
|
||||||
*/
|
*/
|
||||||
export default async function grabComputerEnvs({ computer_id, bot_id }: Params) {
|
export default async function grabComputerEnvs({ computer_id, workspace_id }: Params) {
|
||||||
const workspaces =
|
const linked = workspace_id
|
||||||
(
|
? (
|
||||||
await BunSQLite.sql<{ id: number }>({
|
await BunSQLite.sql<{ id: number }>({
|
||||||
sql: `SELECT w.id FROM workspaces w
|
sql: `SELECT w.id FROM workspaces w
|
||||||
JOIN workspace_computers wc ON wc.workspace_id = w.id AND wc.computer_id = ?
|
JOIN workspace_computers wc ON wc.workspace_id = w.id AND wc.computer_id = ?
|
||||||
WHERE w.archived = 0
|
WHERE w.id = ? AND w.archived = 0 LIMIT 1`,
|
||||||
${bot_id ? `AND w.id IN (SELECT workspace_id FROM workspace_bots WHERE bot_id = ?)` : ""}
|
values: [computer_id, workspace_id],
|
||||||
ORDER BY w.id ASC`,
|
|
||||||
values: bot_id ? [computer_id, bot_id] : [computer_id],
|
|
||||||
})
|
})
|
||||||
).payload || [];
|
).payload?.[0]
|
||||||
|
: null;
|
||||||
|
|
||||||
return resolveScopedEnvs({
|
return resolveScopedEnvs({
|
||||||
envs: await grabAllEnvs(),
|
envs: await grabAllEnvs(),
|
||||||
computer_id: Number(computer_id),
|
computer_id: Number(computer_id),
|
||||||
workspace_ids: workspaces.map((workspace) => Number(workspace.id)),
|
workspace_ids: linked?.id ? [Number(linked.id)] : [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ type Params = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes a provider and its models. Harnesses (default and per bot) that
|
* Removes a provider and its models. Harnesses (default, per bot and per workspace) that
|
||||||
* picked one of its models, or were limited to it, go back to automatic
|
* picked one of its models, or were limited to it, go back to automatic
|
||||||
* on any provider.
|
* on any provider.
|
||||||
*/
|
*/
|
||||||
@@ -18,7 +18,7 @@ export default async function deleteHarnessProvider({ provider_id }: Params) {
|
|||||||
throw new AppError(`Provider not found.`);
|
throw new AppError(`Provider not found.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const table of ["harness_settings", "bot_harness_settings"]) {
|
for (const table of ["harness_settings", "bot_harness_settings", "workspace_harness_settings"]) {
|
||||||
await BunSQLite.sql({
|
await BunSQLite.sql({
|
||||||
sql: `UPDATE ${table} SET model_id = NULL, provider_id = NULL, updated_at = ?
|
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 = ?)`,
|
WHERE provider_id = ? OR model_id IN (SELECT id FROM harness_models WHERE provider_id = ?)`,
|
||||||
|
|||||||
@@ -8,23 +8,44 @@ type Params = {
|
|||||||
/**
|
/**
|
||||||
* A worker bot the caller has checked belongs to the session user.
|
* A worker bot the caller has checked belongs to the session user.
|
||||||
*/
|
*/
|
||||||
bot_id: number;
|
bot_id?: number;
|
||||||
|
/**
|
||||||
|
* A workspace the caller has checked belongs to the session user. Then
|
||||||
|
* the settings shown are the workspace's own, over what it inherits:
|
||||||
|
* the bot's (when given) or the defaults.
|
||||||
|
*/
|
||||||
|
workspace_id?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A worker bot's model settings page: per harness, the bot's own setting
|
* A worker bot's (or a workspace's) model settings: per harness, its own
|
||||||
* (or the default it inherits), the models it can pick, what it runs on
|
* setting (or what it inherits), the models it can pick, what it runs on
|
||||||
* now, and what the default runs on.
|
* now, and what it would run on without its own setting.
|
||||||
*/
|
*/
|
||||||
export default async function grabBotHarnessOverview({
|
export default async function grabBotHarnessOverview({
|
||||||
bot_id,
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
}: Params): Promise<BotHarnessOverviewPayload> {
|
}: Params): Promise<BotHarnessOverviewPayload> {
|
||||||
|
/**
|
||||||
|
* In a workspace, "the default" is what a run there gets without the
|
||||||
|
* workspace's own setting: the bot's, else the defaults.
|
||||||
|
*/
|
||||||
|
const inherited_bot_id = workspace_id ? bot_id : undefined;
|
||||||
|
|
||||||
const [own, defaults] = await Promise.all([
|
const [own, defaults] = await Promise.all([
|
||||||
Promise.all(
|
Promise.all(
|
||||||
Harnesses.map((harness) => grabHarnessSetting({ harness: harness.value, bot_id })),
|
Harnesses.map((harness) =>
|
||||||
|
grabHarnessSetting({
|
||||||
|
harness: harness.value,
|
||||||
|
bot_id: workspace_id ? undefined : bot_id,
|
||||||
|
workspace_id,
|
||||||
|
}),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Promise.all(
|
Promise.all(
|
||||||
Harnesses.map((harness) => grabHarnessSetting({ harness: harness.value })),
|
Harnesses.map((harness) =>
|
||||||
|
grabHarnessSetting({ harness: harness.value, bot_id: inherited_bot_id }),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -38,11 +59,18 @@ export default async function grabBotHarnessOverview({
|
|||||||
harnesses: await Promise.all(
|
harnesses: await Promise.all(
|
||||||
own.map(async (setting, index) => {
|
own.map(async (setting, index) => {
|
||||||
const [item, default_item] = await Promise.all([
|
const [item, default_item] = await Promise.all([
|
||||||
grabHarnessOverviewItem({ harness: setting.harness, setting, entries, bot_id }),
|
grabHarnessOverviewItem({
|
||||||
|
harness: setting.harness,
|
||||||
|
setting,
|
||||||
|
entries,
|
||||||
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
|
}),
|
||||||
grabHarnessOverviewItem({
|
grabHarnessOverviewItem({
|
||||||
harness: setting.harness,
|
harness: setting.harness,
|
||||||
setting: defaults[index]!,
|
setting: defaults[index]!,
|
||||||
entries,
|
entries,
|
||||||
|
bot_id: inherited_bot_id,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type Params = {
|
|||||||
* Resolve what runs now for this bot instead of the default.
|
* Resolve what runs now for this bot instead of the default.
|
||||||
*/
|
*/
|
||||||
bot_id?: number | string;
|
bot_id?: number | string;
|
||||||
|
workspace_id?: number | string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,10 +26,11 @@ export default async function grabHarnessOverviewItem({
|
|||||||
setting,
|
setting,
|
||||||
entries,
|
entries,
|
||||||
bot_id,
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
}: Params): Promise<HarnessOverview> {
|
}: Params): Promise<HarnessOverview> {
|
||||||
const meta = Harnesses.find((h) => h.value == harness)!;
|
const meta = Harnesses.find((h) => h.value == harness)!;
|
||||||
const candidates = setting.enabled
|
const candidates = setting.enabled
|
||||||
? await resolveHarnessCandidates({ harness, bot_id }).catch(() => [])
|
? await resolveHarnessCandidates({ harness, bot_id, workspace_id }).catch(() => [])
|
||||||
: [];
|
: [];
|
||||||
const first = candidates[0];
|
const first = candidates[0];
|
||||||
const serving = entries.filter(({ model }) => modelServesHarness({ model, harness }));
|
const serving = entries.filter(({ model }) => modelServesHarness({ model, harness }));
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import BunSQLite from "@moduletrace/bun-sqlite";
|
|||||||
import type {
|
import type {
|
||||||
BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS,
|
BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS,
|
||||||
BUN_SQLITE_COBBLE_HARNESS_SETTINGS,
|
BUN_SQLITE_COBBLE_HARNESS_SETTINGS,
|
||||||
|
BUN_SQLITE_COBBLE_WORKSPACE_HARNESS_SETTINGS,
|
||||||
} from "@/db/types/db";
|
} from "@/db/types/db";
|
||||||
import type { HarnessKind } from "@/src/dict/harness-dict";
|
import type { HarnessKind } from "@/src/dict/harness-dict";
|
||||||
import parseJobJson from "@/src/bots-queue/parse-job-json";
|
import parseJobJson from "@/src/bots-queue/parse-job-json";
|
||||||
@@ -22,18 +23,24 @@ export type HarnessSetting = {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
options: Record<string, unknown>;
|
options: Record<string, unknown>;
|
||||||
/**
|
/**
|
||||||
* `bot` when a worker bot overrides the default for this harness.
|
* `workspace` when the call's workspace sets this harness, `bot` when a
|
||||||
|
* worker bot overrides the default for it.
|
||||||
*/
|
*/
|
||||||
source: "default" | "bot";
|
source: "default" | "bot" | "workspace";
|
||||||
};
|
};
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
harness: HarnessKind;
|
harness: HarnessKind;
|
||||||
/**
|
/**
|
||||||
* The bot the call is for. A worker bot's own setting wins; the
|
* The bot the call is for. A worker bot's own setting wins over the
|
||||||
* coordinator always uses the default.
|
* default; the coordinator always uses the default.
|
||||||
*/
|
*/
|
||||||
bot_id?: number | string;
|
bot_id?: number | string;
|
||||||
|
/**
|
||||||
|
* The workspace the call is in. Its setting wins over the bot's, for
|
||||||
|
* the coordinator too.
|
||||||
|
*/
|
||||||
|
workspace_id?: number | string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SettingRow = Pick<
|
type SettingRow = Pick<
|
||||||
@@ -42,13 +49,23 @@ type SettingRow = Pick<
|
|||||||
>;
|
>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A harness's setting for a call. With no row it is automatic on the
|
* A harness's setting for a call: the workspace's, else the bot's, else
|
||||||
* default provider (OpenRouter), on, with fallback. Text is always on.
|
* the default. With no row it is automatic on the default provider
|
||||||
|
* (OpenRouter), on, with fallback. Text is always on.
|
||||||
*/
|
*/
|
||||||
export default async function grabHarnessSetting({
|
export default async function grabHarnessSetting({
|
||||||
harness,
|
harness,
|
||||||
bot_id,
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
}: Params): Promise<HarnessSetting> {
|
}: Params): Promise<HarnessSetting> {
|
||||||
|
const workspace_row = workspace_id
|
||||||
|
? await grabWorkspaceOverride({ harness, workspace_id })
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (workspace_row) {
|
||||||
|
return toSetting({ harness, row: workspace_row, source: "workspace" });
|
||||||
|
}
|
||||||
|
|
||||||
const bot_row = bot_id
|
const bot_row = bot_id
|
||||||
? await grabBotOverride({ harness, bot_id })
|
? await grabBotOverride({ harness, bot_id })
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -74,6 +91,21 @@ export default async function grabHarnessSetting({
|
|||||||
return toSetting({ harness, row, source: "default" });
|
return toSetting({ harness, row, source: "default" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function grabWorkspaceOverride({
|
||||||
|
harness,
|
||||||
|
workspace_id,
|
||||||
|
}: {
|
||||||
|
harness: HarnessKind;
|
||||||
|
workspace_id: number | string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
await BunSQLite.sql<BUN_SQLITE_COBBLE_WORKSPACE_HARNESS_SETTINGS>({
|
||||||
|
sql: `SELECT * FROM workspace_harness_settings WHERE workspace_id = ? AND harness = ? LIMIT 1`,
|
||||||
|
values: [workspace_id, harness],
|
||||||
|
})
|
||||||
|
).payload?.[0];
|
||||||
|
}
|
||||||
|
|
||||||
async function grabBotOverride({
|
async function grabBotOverride({
|
||||||
harness,
|
harness,
|
||||||
bot_id,
|
bot_id,
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
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 workspace the caller has checked belongs to the session user.
|
||||||
|
*/
|
||||||
|
workspace_id: number;
|
||||||
|
harness: HarnessKind;
|
||||||
|
/**
|
||||||
|
* Drop the workspace's setting: each bot uses its own or 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 workspace's setting for one
|
||||||
|
* harness, which wins over its bots' own. 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 saveWorkspaceHarnessSetting({
|
||||||
|
workspace_id,
|
||||||
|
harness,
|
||||||
|
inherit,
|
||||||
|
model_id,
|
||||||
|
provider_id = null,
|
||||||
|
fallback,
|
||||||
|
enabled,
|
||||||
|
options,
|
||||||
|
}: Params) {
|
||||||
|
if (inherit) {
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `DELETE FROM workspace_harness_settings WHERE workspace_id = ? AND harness = ?`,
|
||||||
|
values: [workspace_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 workspace_harness_settings (created_at, updated_at, workspace_id, harness, model_id, provider_id, fallback, enabled, options)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(workspace_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,
|
||||||
|
workspace_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 workspace's model setting: ${res.error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import grabUserBot from "@/src/functions/backend/db/bots/grab-user-bot";
|
||||||
|
import deleteMedia from "@/src/functions/backend/db/media/delete-media";
|
||||||
|
import uploadAndRecordMedia from "@/src/functions/backend/db/users/media/upload-and-record-media";
|
||||||
|
import grabUserWorkspace from "@/src/functions/backend/db/workspaces/grab-user-workspace";
|
||||||
|
import type { ProfileImageTarget } from "@/src/schemas/profile-image-schema";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import bufferFromBase64 from "@/src/utils/buffer-from-base-64";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
user_id: number | string;
|
||||||
|
target: ProfileImageTarget;
|
||||||
|
id: number | string;
|
||||||
|
/**
|
||||||
|
* The new picture as a base64 data URL; null removes it.
|
||||||
|
*/
|
||||||
|
data_url: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Targets = {
|
||||||
|
bot: { table: "bots", paradigm: "bot-image" },
|
||||||
|
workspace: { table: "workspaces", paradigm: "workspace-image" },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets (or removes) the picture of one of the user's bots or workspaces:
|
||||||
|
* a private image, the old one deleted. Returns the new `image_media_id`.
|
||||||
|
*/
|
||||||
|
export default async function setProfileImage({ user_id, target, id, data_url }: Params) {
|
||||||
|
const record =
|
||||||
|
target == "bot"
|
||||||
|
? await grabUserBot({ bot_id: id, user_id })
|
||||||
|
: await grabUserWorkspace({ workspace_id: id, user_id });
|
||||||
|
|
||||||
|
if (!record?.id) {
|
||||||
|
throw new AppError(target == "bot" ? `Bot not found.` : `Workspace not found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { table, paradigm } = Targets[target];
|
||||||
|
let image_media_id: number | null = null;
|
||||||
|
|
||||||
|
if (data_url) {
|
||||||
|
if (!/^data:image\/(png|jpe?g|webp|gif)/i.test(data_url)) {
|
||||||
|
throw new AppError(`Pick a PNG, JPG, WebP or GIF image.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { buffer, ext } = await bufferFromBase64(data_url);
|
||||||
|
|
||||||
|
if (buffer.length > AppData.ProfileImageMaxBytes) {
|
||||||
|
throw new AppError(
|
||||||
|
`That picture is too big. Pictures can be up to ${Math.round(AppData.ProfileImageMaxBytes / 1024 / 1024)} MB.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await uploadAndRecordMedia({
|
||||||
|
data: buffer,
|
||||||
|
media_type: "image",
|
||||||
|
media_mime_type: ext,
|
||||||
|
media_paradigms: [paradigm],
|
||||||
|
media: { title: `${record.name || target} picture` },
|
||||||
|
is_private: true,
|
||||||
|
user: { id: Number(user_id) },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.singleRes?.id) {
|
||||||
|
throw new AppError(`Couldn't save the picture. Try another image.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
image_media_id = Number(res.singleRes.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE ${table} SET image_media_id = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
values: [image_media_id, Date.now(), record.id],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (record.image_media_id) {
|
||||||
|
await deleteMedia({ id: record.image_media_id, user_id: Number(user_id) }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { image_media_id };
|
||||||
|
}
|
||||||
@@ -10,8 +10,9 @@ type Params = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Assigns one of the user's computers to one of their workspaces, so every
|
* Links one of the user's computers to one of their workspaces, so every
|
||||||
* bot in the workspace gets access to it. Assigning it twice is a no-op.
|
* bot working there can use it. The first one linked becomes the
|
||||||
|
* workspace's default. Linking it twice is a no-op.
|
||||||
*/
|
*/
|
||||||
export default async function addComputerToWorkspace({
|
export default async function addComputerToWorkspace({
|
||||||
user_id,
|
user_id,
|
||||||
@@ -29,4 +30,11 @@ export default async function addComputerToWorkspace({
|
|||||||
sql: `INSERT OR IGNORE INTO workspace_computers (workspace_id, computer_id, created_at, updated_at) VALUES (?, ?, ?, ?)`,
|
sql: `INSERT OR IGNORE INTO workspace_computers (workspace_id, computer_id, created_at, updated_at) VALUES (?, ?, ?, ?)`,
|
||||||
values: [workspace.id, computer.id, Date.now(), Date.now()],
|
values: [workspace.id, computer.id, Date.now(), Date.now()],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!workspace.computer_id) {
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE workspaces SET computer_id = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
values: [computer.id, Date.now(), workspace.id],
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import deleteMedia from "@/src/functions/backend/db/media/delete-media";
|
||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import grabUserWorkspace from "./grab-user-workspace";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
user_id: number | string;
|
||||||
|
workspace_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes one of the user's workspaces with its conversations, its
|
||||||
|
* links and its models. Its bots and computers stay.
|
||||||
|
*/
|
||||||
|
export default async function deleteWorkspace({ user_id, workspace_id }: Params) {
|
||||||
|
const workspace = await grabUserWorkspace({ workspace_id, user_id });
|
||||||
|
|
||||||
|
if (!workspace?.id) {
|
||||||
|
throw new AppError(`Workspace not found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await BunSQLite.sql({
|
||||||
|
sql: `DELETE FROM workspaces WHERE id = ?`,
|
||||||
|
values: [workspace.id],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.success) {
|
||||||
|
throw new Error(`Couldn't delete the workspace: ${res.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workspace.image_media_id) {
|
||||||
|
await deleteMedia({ id: workspace.image_media_id, user_id: Number(user_id) }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
return workspace;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { WorkspaceWithActivity } from "@/src/types";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
user_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user's workspaces for the rail, not archived, oldest first, each
|
||||||
|
* unread while any conversation in it is.
|
||||||
|
*/
|
||||||
|
export default async function grabUserWorkspaces({
|
||||||
|
user_id,
|
||||||
|
}: Params): Promise<WorkspaceWithActivity[]> {
|
||||||
|
const res = await BunSQLite.sql<WorkspaceWithActivity>({
|
||||||
|
sql: `SELECT w.*,
|
||||||
|
COALESCE((SELECT MAX(s.unread) FROM bot_sessions s WHERE s.workspace_id = w.id), 0) AS unread,
|
||||||
|
(SELECT MAX(s.last_activity_at) FROM bot_sessions s WHERE s.workspace_id = w.id) AS last_activity_at
|
||||||
|
FROM workspaces w
|
||||||
|
WHERE w.user_id = ? AND w.archived = 0
|
||||||
|
ORDER BY w.id ASC`,
|
||||||
|
values: [user_id],
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.payload || [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type {
|
||||||
|
BUN_SQLITE_COBBLE_BOTS,
|
||||||
|
BUN_SQLITE_COBBLE_WORKSPACE_BOTS,
|
||||||
|
BUN_SQLITE_COBBLE_WORKSPACES,
|
||||||
|
} from "@/db/types/db";
|
||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
workspace_id: number | string;
|
||||||
|
bot_id: number | string;
|
||||||
|
user_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceBotMembership = {
|
||||||
|
workspace: BUN_SQLITE_COBBLE_WORKSPACES;
|
||||||
|
bot: BUN_SQLITE_COBBLE_BOTS;
|
||||||
|
/**
|
||||||
|
* Its `workspace_bots` row. Null for the coordinator, which works in
|
||||||
|
* every workspace without one until it's given instructions there.
|
||||||
|
*/
|
||||||
|
membership: BUN_SQLITE_COBBLE_WORKSPACE_BOTS | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One of the user's bots in one of their (non-archived) workspaces: a
|
||||||
|
* member, or the coordinator, which is in all of them. Null otherwise.
|
||||||
|
*/
|
||||||
|
export default async function grabWorkspaceBot({
|
||||||
|
workspace_id,
|
||||||
|
bot_id,
|
||||||
|
user_id,
|
||||||
|
}: Params): Promise<WorkspaceBotMembership | null> {
|
||||||
|
const workspace = (
|
||||||
|
await BunSQLite.sql<BUN_SQLITE_COBBLE_WORKSPACES>({
|
||||||
|
sql: `SELECT * FROM workspaces WHERE id = ? AND user_id = ? AND archived = 0 LIMIT 1`,
|
||||||
|
values: [workspace_id, user_id],
|
||||||
|
})
|
||||||
|
).payload?.[0];
|
||||||
|
|
||||||
|
const bot = (
|
||||||
|
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
|
||||||
|
sql: `SELECT * FROM bots WHERE id = ? AND user_id = ? LIMIT 1`,
|
||||||
|
values: [bot_id, user_id],
|
||||||
|
})
|
||||||
|
).payload?.[0];
|
||||||
|
|
||||||
|
if (!workspace?.id || !bot?.id) return null;
|
||||||
|
|
||||||
|
const membership =
|
||||||
|
(
|
||||||
|
await BunSQLite.sql<BUN_SQLITE_COBBLE_WORKSPACE_BOTS>({
|
||||||
|
sql: `SELECT * FROM workspace_bots WHERE workspace_id = ? AND bot_id = ? LIMIT 1`,
|
||||||
|
values: [workspace.id, bot.id],
|
||||||
|
})
|
||||||
|
).payload?.[0] || null;
|
||||||
|
|
||||||
|
if (!membership && bot.kind != "coordinator") return null;
|
||||||
|
|
||||||
|
return { workspace, bot, membership };
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { BUN_SQLITE_COBBLE_WORKSPACE_BOTS } from "@/db/types/db";
|
||||||
|
import type { WorkspaceBot } from "@/src/types";
|
||||||
|
import grabBots from "../bots/grab-bots";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
user_id: number | string;
|
||||||
|
workspace_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A workspace's crew (the coordinator first, then its bots by latest
|
||||||
|
* activity there), each with its instructions and memory for the
|
||||||
|
* workspace.
|
||||||
|
*/
|
||||||
|
export default async function grabWorkspaceBots({
|
||||||
|
user_id,
|
||||||
|
workspace_id,
|
||||||
|
}: Params): Promise<WorkspaceBot[]> {
|
||||||
|
const [bots, rows] = await Promise.all([
|
||||||
|
grabBots({ user_id, workspace_id }),
|
||||||
|
BunSQLite.sql<BUN_SQLITE_COBBLE_WORKSPACE_BOTS>({
|
||||||
|
sql: `SELECT bot_id, system_prompt, memory FROM workspace_bots WHERE workspace_id = ?`,
|
||||||
|
values: [workspace_id],
|
||||||
|
}).then((res) => res.payload || []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return bots.map((bot) => {
|
||||||
|
const row = rows.find((r) => r.bot_id == bot.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...bot,
|
||||||
|
workspace_system_prompt: row?.system_prompt || null,
|
||||||
|
workspace_memory: row?.memory || null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { ComputerWithStatus } from "@/src/types";
|
||||||
|
import parseJobJson from "@/src/bots-queue/parse-job-json";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
workspace_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The computers linked to a workspace, with their container's state, its
|
||||||
|
* default first.
|
||||||
|
*/
|
||||||
|
export default async function grabWorkspaceComputers({
|
||||||
|
workspace_id,
|
||||||
|
}: Params): Promise<ComputerWithStatus[]> {
|
||||||
|
const res = await BunSQLite.sql<ComputerWithStatus & { metadata?: string | null }>({
|
||||||
|
sql: `SELECT c.*, co.status AS status, co.metadata AS metadata FROM computers c
|
||||||
|
JOIN workspace_computers wc ON wc.computer_id = c.id AND wc.workspace_id = ?
|
||||||
|
JOIN workspaces w ON w.id = wc.workspace_id AND w.user_id = c.user_id
|
||||||
|
LEFT JOIN containers co ON co.id = c.container_id
|
||||||
|
ORDER BY c.id = w.computer_id DESC, wc.id ASC`,
|
||||||
|
values: [workspace_id],
|
||||||
|
});
|
||||||
|
|
||||||
|
return (res.payload || []).map(({ metadata, ...computer }) => {
|
||||||
|
const error = parseJobJson({ text: metadata })?.error;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...computer,
|
||||||
|
error: computer.status == "failed" && typeof error == "string" ? error : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -9,8 +9,9 @@ type Params = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Takes a computer off one of the user's workspaces. Its bots keep any
|
* Unlinks a computer from one of the user's workspaces; the computer
|
||||||
* access they were given directly.
|
* itself stays. When it was the default, the oldest computer still linked
|
||||||
|
* takes over (none: the workspace has no computer).
|
||||||
*/
|
*/
|
||||||
export default async function removeComputerFromWorkspace({
|
export default async function removeComputerFromWorkspace({
|
||||||
user_id,
|
user_id,
|
||||||
@@ -27,4 +28,13 @@ export default async function removeComputerFromWorkspace({
|
|||||||
sql: `DELETE FROM workspace_computers WHERE workspace_id = ? AND computer_id = ?`,
|
sql: `DELETE FROM workspace_computers WHERE workspace_id = ? AND computer_id = ?`,
|
||||||
values: [workspace.id, computer_id],
|
values: [workspace.id, computer_id],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (String(workspace.computer_id) == String(computer_id)) {
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE workspaces SET updated_at = ?, computer_id = (
|
||||||
|
SELECT computer_id FROM workspace_computers WHERE workspace_id = ? ORDER BY id ASC LIMIT 1
|
||||||
|
) WHERE id = ?`,
|
||||||
|
values: [Date.now(), workspace.id, workspace.id],
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import grabWorkspaceComputers from "./grab-workspace-computers";
|
||||||
|
import grabUserWorkspace from "./grab-user-workspace";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
user_id: number | string;
|
||||||
|
workspace_id: number | string;
|
||||||
|
computer_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes one of a workspace's computers its default, where its tasks run.
|
||||||
|
* Returns the workspace's computers, the new default first.
|
||||||
|
*/
|
||||||
|
export default async function setWorkspaceComputer({
|
||||||
|
user_id,
|
||||||
|
workspace_id,
|
||||||
|
computer_id,
|
||||||
|
}: Params) {
|
||||||
|
const workspace = await grabUserWorkspace({ workspace_id, user_id });
|
||||||
|
|
||||||
|
if (!workspace?.id) {
|
||||||
|
throw new AppError(`Workspace not found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const linked = await grabWorkspaceComputers({ workspace_id: workspace.id });
|
||||||
|
const computer = linked.find((c) => String(c.id) == String(computer_id));
|
||||||
|
|
||||||
|
if (!computer?.id) {
|
||||||
|
throw new AppError(`That computer isn't in ${workspace.name || "this workspace"}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE workspaces SET computer_id = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
values: [computer.id, Date.now(), workspace.id],
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
workspace: { ...workspace, computer_id: computer.id },
|
||||||
|
computer,
|
||||||
|
computers: [computer, ...linked.filter((c) => c.id != computer.id)],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import assertPromptLength from "@/src/utils/assert-prompt-length";
|
||||||
|
import grabWorkspaceBot from "./grab-workspace-bot";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
user_id: number | string;
|
||||||
|
workspace_id: number | string;
|
||||||
|
bot_id: number | string;
|
||||||
|
/**
|
||||||
|
* Only the fields given are changed: the bot's instructions and memory
|
||||||
|
* for this workspace (markdown, whole-text replace).
|
||||||
|
*/
|
||||||
|
system_prompt?: string;
|
||||||
|
memory?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrites what a bot has for one workspace alone: its instructions there
|
||||||
|
* and its memory there. The bot, the coordinator and the user all can. The
|
||||||
|
* coordinator gets its row on first write.
|
||||||
|
*/
|
||||||
|
export default async function updateWorkspaceBot({
|
||||||
|
user_id,
|
||||||
|
workspace_id,
|
||||||
|
bot_id,
|
||||||
|
system_prompt,
|
||||||
|
memory,
|
||||||
|
}: Params) {
|
||||||
|
assertPromptLength({ label: "Workspace instructions", text: system_prompt });
|
||||||
|
assertPromptLength({ label: "Workspace memory", text: memory });
|
||||||
|
|
||||||
|
const found = await grabWorkspaceBot({ workspace_id, bot_id, user_id });
|
||||||
|
|
||||||
|
if (!found) {
|
||||||
|
throw new AppError(`That bot isn't in this workspace.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `INSERT OR IGNORE INTO workspace_bots (created_at, updated_at, workspace_id, bot_id) VALUES (?, ?, ?, ?)`,
|
||||||
|
values: [now, now, found.workspace.id!, found.bot.id!],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (system_prompt !== undefined) {
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE workspace_bots SET system_prompt = ?, updated_at = ? WHERE workspace_id = ? AND bot_id = ?`,
|
||||||
|
values: [system_prompt, now, found.workspace.id!, found.bot.id!],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (memory !== undefined) {
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE workspace_bots SET memory = ?, updated_at = ? WHERE workspace_id = ? AND bot_id = ?`,
|
||||||
|
values: [memory, now, found.workspace.id!, found.bot.id!],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import { ComputerMcpPath } from "@/src/dict/computer-dict";
|
||||||
|
import dockerRequest from "./docker-request";
|
||||||
|
import ensureDockerNetwork from "./ensure-docker-network";
|
||||||
|
import grabAppContainer from "./grab-app-container";
|
||||||
|
import grabFreePort from "./grab-free-port";
|
||||||
|
import inspectDockerContainer from "./inspect-docker-container";
|
||||||
|
import pullDockerImage from "./pull-docker-image";
|
||||||
|
import removeDockerContainer from "./remove-docker-container";
|
||||||
|
import resolveHostPath from "./resolve-host-path";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
computer_id: number;
|
||||||
|
/**
|
||||||
|
* The container's name, also its hostname on the network.
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* The computer's home directory as this app sees it.
|
||||||
|
*/
|
||||||
|
data_dir: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates and starts an AIO sandbox container for a computer
|
||||||
|
* (`AppData.ComputerImage`) on the computers' network, with its home
|
||||||
|
* directory bind-mounted from `data_dir` and restarted with docker. A
|
||||||
|
* container left by an earlier try is replaced. When the app runs outside
|
||||||
|
* docker, the sandbox is also published on a localhost port, returned as
|
||||||
|
* `mcp_url`'s host.
|
||||||
|
*/
|
||||||
|
export default async function createComputerContainer({ computer_id, name, data_dir }: Params) {
|
||||||
|
const network = await ensureDockerNetwork();
|
||||||
|
const in_docker = Boolean(await grabAppContainer());
|
||||||
|
const host_dir = await resolveHostPath({ local_path: data_dir });
|
||||||
|
const port_key = `${AppData.ComputerPort}/tcp`;
|
||||||
|
const host_port = in_docker ? undefined : grabFreePort();
|
||||||
|
|
||||||
|
await pullDockerImage({ image: AppData.ComputerImage });
|
||||||
|
await removeDockerContainer({ container: name });
|
||||||
|
|
||||||
|
const created = await dockerRequest<{ Id: string }>({
|
||||||
|
method: "POST",
|
||||||
|
path: `/containers/create`,
|
||||||
|
query: { name },
|
||||||
|
body: {
|
||||||
|
Image: AppData.ComputerImage,
|
||||||
|
Hostname: name,
|
||||||
|
Labels: {
|
||||||
|
[`${AppData.DockerNetworkName}.computer_id`]: String(computer_id),
|
||||||
|
},
|
||||||
|
ExposedPorts: { [port_key]: {} },
|
||||||
|
HostConfig: {
|
||||||
|
Binds: [`${host_dir}:${AppData.ComputerHomeDir}`],
|
||||||
|
NetworkMode: network,
|
||||||
|
RestartPolicy: { Name: "unless-stopped" },
|
||||||
|
SecurityOpt: ["seccomp=unconfined"],
|
||||||
|
ShmSize: AppData.ComputerShmBytes,
|
||||||
|
Memory: AppData.ComputerMemoryBytes,
|
||||||
|
NanoCpus: AppData.ComputerCpus * 1e9,
|
||||||
|
PortBindings: host_port
|
||||||
|
? { [port_key]: [{ HostIp: "127.0.0.1", HostPort: String(host_port) }] }
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
NetworkingConfig: {
|
||||||
|
EndpointsConfig: { [network]: { Aliases: [name] } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!created.ok || !created.data?.Id) {
|
||||||
|
throw new Error(`Couldn't create the container: ${created.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const started = await dockerRequest({
|
||||||
|
method: "POST",
|
||||||
|
path: `/containers/${created.data.Id}/start`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!started.ok && started.status != 304) {
|
||||||
|
throw new Error(`Couldn't start the container: ${started.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = await inspectDockerContainer({ container: created.data.Id });
|
||||||
|
|
||||||
|
return {
|
||||||
|
docker_id: created.data.Id,
|
||||||
|
network,
|
||||||
|
status: state?.status || "unknown",
|
||||||
|
host_dir,
|
||||||
|
mcp_url: host_port ? `http://127.0.0.1:${host_port}${ComputerMcpPath}` : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
method?: "GET" | "POST" | "DELETE";
|
||||||
|
/**
|
||||||
|
* Docker engine API path, e.g. `/containers/json`.
|
||||||
|
*/
|
||||||
|
path: string;
|
||||||
|
query?: Record<string, string | number | boolean | undefined>;
|
||||||
|
body?: unknown;
|
||||||
|
timeout_ms?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DockerResponse<T> = {
|
||||||
|
status: number;
|
||||||
|
ok: boolean;
|
||||||
|
data: T | null;
|
||||||
|
/**
|
||||||
|
* The engine's own message when the call failed.
|
||||||
|
*/
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One call to the docker engine API over its unix socket
|
||||||
|
* (`AppData.DockerSocketPath`, or `DOCKER_SOCKET` in the process env).
|
||||||
|
* Never throws for an HTTP error; check `ok`. Throws when the engine
|
||||||
|
* can't be reached at all.
|
||||||
|
*/
|
||||||
|
export default async function dockerRequest<T = unknown>({
|
||||||
|
method = "GET",
|
||||||
|
path,
|
||||||
|
query,
|
||||||
|
body,
|
||||||
|
timeout_ms = 60 * 1000,
|
||||||
|
}: Params): Promise<DockerResponse<T>> {
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(query || {})) {
|
||||||
|
if (value !== undefined) search.set(key, String(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `http://docker${path}${search.size ? `?${search}` : ""}`;
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
unix: process.env.DOCKER_SOCKET || AppData.DockerSocketPath,
|
||||||
|
headers: body === undefined ? undefined : { "Content-Type": "application/json" },
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
signal: AbortSignal.timeout(timeout_ms),
|
||||||
|
}).catch((error) => {
|
||||||
|
throw new Error(
|
||||||
|
`Can't reach docker: ${error instanceof Error ? error.message : String(error)}. The app needs the docker socket to create computers.`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await res.text();
|
||||||
|
let data: unknown = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
data = text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
data = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: res.status,
|
||||||
|
ok: res.ok,
|
||||||
|
data: res.ok ? (data as T) : null,
|
||||||
|
message: res.ok
|
||||||
|
? undefined
|
||||||
|
: (data as { message?: string } | null)?.message || text || `HTTP ${res.status}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import dockerRequest from "./docker-request";
|
||||||
|
import grabAppContainer from "./grab-app-container";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes sure the network computers run on (`AppData.DockerNetworkName`)
|
||||||
|
* exists and, when the app runs in docker, that its container is on it,
|
||||||
|
* so it reaches computers by name.
|
||||||
|
*/
|
||||||
|
export default async function ensureDockerNetwork() {
|
||||||
|
const name = AppData.DockerNetworkName;
|
||||||
|
const existing = await dockerRequest({ path: `/networks/${encodeURIComponent(name)}` });
|
||||||
|
|
||||||
|
if (existing.status == 404) {
|
||||||
|
const created = await dockerRequest({
|
||||||
|
method: "POST",
|
||||||
|
path: `/networks/create`,
|
||||||
|
body: { Name: name, Driver: "bridge", CheckDuplicate: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!created.ok && created.status != 409) {
|
||||||
|
throw new Error(`Couldn't create the ${name} network: ${created.message}`);
|
||||||
|
}
|
||||||
|
} else if (!existing.ok) {
|
||||||
|
throw new Error(`Couldn't read the ${name} network: ${existing.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = await grabAppContainer();
|
||||||
|
|
||||||
|
if (!container || container.networks.includes(name)) return name;
|
||||||
|
|
||||||
|
const connected = await dockerRequest({
|
||||||
|
method: "POST",
|
||||||
|
path: `/networks/${encodeURIComponent(name)}/connect`,
|
||||||
|
body: { Container: container.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!connected.ok && connected.status != 403) {
|
||||||
|
throw new Error(`Couldn't connect the app to the ${name} network: ${connected.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
container.networks.push(name);
|
||||||
|
|
||||||
|
return name;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import fs from "fs";
|
||||||
|
import os from "os";
|
||||||
|
import dockerRequest from "./docker-request";
|
||||||
|
|
||||||
|
export type AppContainer = {
|
||||||
|
id: string;
|
||||||
|
mounts: { Source: string; Destination: string }[];
|
||||||
|
networks: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
let cached: Promise<AppContainer | null> | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The docker container this app runs in, or null when it runs straight on
|
||||||
|
* the host (a dev server). Read once per process: docker sets the
|
||||||
|
* container's hostname to its id.
|
||||||
|
*/
|
||||||
|
export default function grabAppContainer(): Promise<AppContainer | null> {
|
||||||
|
cached ??= inspectSelf();
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inspectSelf(): Promise<AppContainer | null> {
|
||||||
|
if (!fs.existsSync("/.dockerenv")) return null;
|
||||||
|
|
||||||
|
const res = await dockerRequest<{
|
||||||
|
Id: string;
|
||||||
|
Mounts?: AppContainer["mounts"];
|
||||||
|
NetworkSettings?: { Networks?: Record<string, unknown> };
|
||||||
|
}>({ path: `/containers/${encodeURIComponent(os.hostname())}/json` });
|
||||||
|
|
||||||
|
if (!res.ok || !res.data?.Id) {
|
||||||
|
throw new Error(`The app runs in docker but can't find its own container: ${res.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: res.data.Id,
|
||||||
|
mounts: res.data.Mounts || [],
|
||||||
|
networks: Object.keys(res.data.NetworkSettings?.Networks || {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* A port nothing on this host listens on right now, for publishing a
|
||||||
|
* computer when the app runs outside docker and can't reach it by name.
|
||||||
|
*/
|
||||||
|
export default function grabFreePort() {
|
||||||
|
const listener = Bun.listen({
|
||||||
|
hostname: "127.0.0.1",
|
||||||
|
port: 0,
|
||||||
|
socket: { data() {} },
|
||||||
|
});
|
||||||
|
const port = listener.port;
|
||||||
|
|
||||||
|
listener.stop(true);
|
||||||
|
|
||||||
|
return port;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { ContainerStatus } from "@/src/dict/computer-dict";
|
||||||
|
import dockerRequest from "./docker-request";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
/**
|
||||||
|
* The container's id or name.
|
||||||
|
*/
|
||||||
|
container: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DockerContainerState = {
|
||||||
|
id: string;
|
||||||
|
status: ContainerStatus;
|
||||||
|
/**
|
||||||
|
* Host ports published for each container port, e.g. `{ "8080/tcp": "8301" }`.
|
||||||
|
*/
|
||||||
|
ports: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A container's state, or null when docker has no such container.
|
||||||
|
*/
|
||||||
|
export default async function inspectDockerContainer({
|
||||||
|
container,
|
||||||
|
}: Params): Promise<DockerContainerState | null> {
|
||||||
|
const res = await dockerRequest<{
|
||||||
|
Id: string;
|
||||||
|
State?: { Status?: ContainerStatus };
|
||||||
|
NetworkSettings?: { Ports?: Record<string, { HostPort?: string }[] | null> };
|
||||||
|
}>({ path: `/containers/${encodeURIComponent(container)}/json` });
|
||||||
|
|
||||||
|
if (res.status == 404) return null;
|
||||||
|
|
||||||
|
if (!res.ok || !res.data) {
|
||||||
|
throw new Error(`Couldn't inspect ${container}: ${res.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: res.data.Id,
|
||||||
|
status: res.data.State?.Status || "unknown",
|
||||||
|
ports: Object.fromEntries(
|
||||||
|
Object.entries(res.data.NetworkSettings?.Ports || {}).flatMap(([port, bindings]) =>
|
||||||
|
bindings?.[0]?.HostPort ? [[port, bindings[0].HostPort]] : [],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import dockerRequest from "./docker-request";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
/**
|
||||||
|
* e.g. `ghcr.io/agent-infra/sandbox:latest`.
|
||||||
|
*/
|
||||||
|
image: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pulls an image unless docker already has it. Waits for the whole pull,
|
||||||
|
* which can take minutes for a large image.
|
||||||
|
*/
|
||||||
|
export default async function pullDockerImage({ image }: Params) {
|
||||||
|
const present = await dockerRequest({ path: `/images/${encodeURIComponent(image)}/json` });
|
||||||
|
|
||||||
|
if (present.ok) return;
|
||||||
|
|
||||||
|
const slash = image.lastIndexOf("/");
|
||||||
|
const colon = image.lastIndexOf(":");
|
||||||
|
const has_tag = colon > slash;
|
||||||
|
|
||||||
|
const res = await dockerRequest<string>({
|
||||||
|
method: "POST",
|
||||||
|
path: `/images/create`,
|
||||||
|
query: {
|
||||||
|
fromImage: has_tag ? image.slice(0, colon) : image,
|
||||||
|
tag: has_tag ? image.slice(colon + 1) : "latest",
|
||||||
|
},
|
||||||
|
timeout_ms: 30 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pull streams JSON lines; a failure mid-way is a line with
|
||||||
|
* `error`, under a 200.
|
||||||
|
*/
|
||||||
|
const failed = String(res.data || "")
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(line) as { error?: string };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.find((line) => line?.error);
|
||||||
|
|
||||||
|
if (!res.ok || failed) {
|
||||||
|
throw new Error(`Couldn't pull ${image}: ${failed?.error || res.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import dockerRequest from "./docker-request";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
/**
|
||||||
|
* The container's id or name.
|
||||||
|
*/
|
||||||
|
container: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops and removes a container. Its bind-mounted files stay on the host.
|
||||||
|
* A container docker doesn't have is already gone.
|
||||||
|
*/
|
||||||
|
export default async function removeDockerContainer({ container }: Params) {
|
||||||
|
const res = await dockerRequest({
|
||||||
|
method: "DELETE",
|
||||||
|
path: `/containers/${encodeURIComponent(container)}`,
|
||||||
|
query: { force: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok && res.status != 404) {
|
||||||
|
throw new Error(`Couldn't remove ${container}: ${res.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import path from "path";
|
||||||
|
import grabAppContainer from "./grab-app-container";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
/**
|
||||||
|
* A path as this app sees it.
|
||||||
|
*/
|
||||||
|
local_path: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same path as the docker host sees it, for a bind mount: docker
|
||||||
|
* resolves mount sources on the host, not in this app's container. In a
|
||||||
|
* container the path must sit under one of its mounts (the deepest one
|
||||||
|
* wins); on the host it's returned as it is.
|
||||||
|
*/
|
||||||
|
export default async function resolveHostPath({ local_path }: Params) {
|
||||||
|
const container = await grabAppContainer();
|
||||||
|
const target = path.resolve(local_path);
|
||||||
|
|
||||||
|
if (!container) return target;
|
||||||
|
|
||||||
|
const mount = container.mounts
|
||||||
|
.filter(
|
||||||
|
(m) => target == m.Destination || target.startsWith(`${m.Destination.replace(/\/+$/, "")}/`),
|
||||||
|
)
|
||||||
|
.sort((a, b) => b.Destination.length - a.Destination.length)[0];
|
||||||
|
|
||||||
|
if (!mount) {
|
||||||
|
throw new Error(
|
||||||
|
`${target} isn't on a volume, so computers can't keep their files there. Mount DATA_DIR into the app's container.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.join(mount.Source, path.relative(mount.Destination, target));
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||||
|
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||||
|
import type { SetProfileImageRequest } from "@/src/schemas/profile-image-schema";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets or removes a bot's or workspace's picture.
|
||||||
|
*/
|
||||||
|
export default async function setProfileImage(body: SetProfileImageRequest) {
|
||||||
|
return await fetchApi<SetProfileImageRequest, APIResponseObject<{ image_media_id: number | null }>>(
|
||||||
|
`/api/admin/set-profile-image`,
|
||||||
|
{ method: "POST", body },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,6 +15,10 @@ type Params = {
|
|||||||
* Uses this bot's speech model and voice when it has its own.
|
* Uses this bot's speech model and voice when it has its own.
|
||||||
*/
|
*/
|
||||||
bot_id?: number | string;
|
bot_id?: number | string;
|
||||||
|
/**
|
||||||
|
* The workspace the call is in: its models win over the bot's.
|
||||||
|
*/
|
||||||
|
workspace_id?: number | string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,10 +30,11 @@ export default async function speakText({
|
|||||||
format,
|
format,
|
||||||
signal,
|
signal,
|
||||||
bot_id,
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
}: Params) {
|
}: Params) {
|
||||||
const [candidates, setting] = await Promise.all([
|
const [candidates, setting] = await Promise.all([
|
||||||
resolveHarnessCandidates({ harness: "speech", bot_id }),
|
resolveHarnessCandidates({ harness: "speech", bot_id, workspace_id }),
|
||||||
grabHarnessSetting({ harness: "speech", bot_id }),
|
grabHarnessSetting({ harness: "speech", bot_id, workspace_id }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const { result } = await withCandidates({
|
const { result } = await withCandidates({
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ type Params = {
|
|||||||
* Uses this bot's transcription model when it has its own.
|
* Uses this bot's transcription model when it has its own.
|
||||||
*/
|
*/
|
||||||
bot_id?: number | string;
|
bot_id?: number | string;
|
||||||
|
/**
|
||||||
|
* The workspace the call is in: its models win over the bot's.
|
||||||
|
*/
|
||||||
|
workspace_id?: number | string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,6 +40,7 @@ export default async function transcribeAudio({
|
|||||||
language,
|
language,
|
||||||
signal,
|
signal,
|
||||||
bot_id,
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
}: Params) {
|
}: Params) {
|
||||||
if (audio.data.byteLength > AppData.HarnessMaxAudioBytes) {
|
if (audio.data.byteLength > AppData.HarnessMaxAudioBytes) {
|
||||||
throw new AppError(`That recording is too long to transcribe.`);
|
throw new AppError(`That recording is too long to transcribe.`);
|
||||||
@@ -44,6 +49,7 @@ export default async function transcribeAudio({
|
|||||||
const candidates = await resolveHarnessCandidates({
|
const candidates = await resolveHarnessCandidates({
|
||||||
harness: "transcription",
|
harness: "transcription",
|
||||||
bot_id,
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const final_signal = signal || new AbortController().signal;
|
const final_signal = signal || new AbortController().signal;
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ type Params = {
|
|||||||
* Uses this bot's image model when it has its own.
|
* Uses this bot's image model when it has its own.
|
||||||
*/
|
*/
|
||||||
bot_id?: number | string;
|
bot_id?: number | string;
|
||||||
|
/**
|
||||||
|
* The workspace the call is in: its models win over the bot's.
|
||||||
|
*/
|
||||||
|
workspace_id?: number | string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,8 +31,9 @@ export default async function generateImage({
|
|||||||
references,
|
references,
|
||||||
signal,
|
signal,
|
||||||
bot_id,
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
}: Params) {
|
}: Params) {
|
||||||
const candidates = await resolveHarnessCandidates({ harness: "image", bot_id });
|
const candidates = await resolveHarnessCandidates({ harness: "image", bot_id, workspace_id });
|
||||||
|
|
||||||
const { result } = await withCandidates({
|
const { result } = await withCandidates({
|
||||||
harness: "image",
|
harness: "image",
|
||||||
|
|||||||
@@ -18,16 +18,21 @@ type Params = {
|
|||||||
* Uses this bot's media reading model when it has its own.
|
* Uses this bot's media reading model when it has its own.
|
||||||
*/
|
*/
|
||||||
bot_id?: number | string;
|
bot_id?: number | string;
|
||||||
|
/**
|
||||||
|
* The workspace the call is in: its models win over the bot's.
|
||||||
|
*/
|
||||||
|
workspace_id?: number | string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads an image with the media reading (vision) harness and returns what
|
* Reads an image with the media reading (vision) harness and returns what
|
||||||
* it sees, as text for a model that can't see.
|
* it sees, as text for a model that can't see.
|
||||||
*/
|
*/
|
||||||
export default async function readImage({ url, focus, signal, bot_id }: Params) {
|
export default async function readImage({ url, focus, signal, bot_id, workspace_id }: Params) {
|
||||||
const candidates = await resolveHarnessCandidates({
|
const candidates = await resolveHarnessCandidates({
|
||||||
harness: "vision",
|
harness: "vision",
|
||||||
bot_id,
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { result } = await withCandidates({
|
const { result } = await withCandidates({
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ type Params = {
|
|||||||
* Reads with this bot's models when it has its own.
|
* Reads with this bot's models when it has its own.
|
||||||
*/
|
*/
|
||||||
bot_id?: number | string;
|
bot_id?: number | string;
|
||||||
|
/**
|
||||||
|
* The workspace the call is in: its models win over the bot's.
|
||||||
|
*/
|
||||||
|
workspace_id?: number | string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DocumentExtensions = ["pdf", "docx", "txt", "md", "csv", "json", "html", "xml", "yaml", "yml"];
|
const DocumentExtensions = ["pdf", "docx", "txt", "md", "csv", "json", "html", "xml", "yaml", "yml"];
|
||||||
@@ -43,6 +47,7 @@ export default async function readMedia({
|
|||||||
name,
|
name,
|
||||||
signal,
|
signal,
|
||||||
bot_id,
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
}: Params): Promise<string> {
|
}: Params): Promise<string> {
|
||||||
const type = media_type.toLowerCase().split(";")[0]!.trim();
|
const type = media_type.toLowerCase().split(";")[0]!.trim();
|
||||||
const ext = (
|
const ext = (
|
||||||
@@ -56,6 +61,7 @@ export default async function readMedia({
|
|||||||
url: `data:${type};base64,${Buffer.from(data).toString("base64")}`,
|
url: `data:${type};base64,${Buffer.from(data).toString("base64")}`,
|
||||||
signal,
|
signal,
|
||||||
bot_id,
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +70,7 @@ export default async function readMedia({
|
|||||||
audio: { data, format: audioFormat({ type, ext }) },
|
audio: { data, format: audioFormat({ type, ext }) },
|
||||||
signal,
|
signal,
|
||||||
bot_id,
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ type Params = {
|
|||||||
* The bot the call is for: a worker bot can override the default.
|
* The bot the call is for: a worker bot can override the default.
|
||||||
*/
|
*/
|
||||||
bot_id?: number | string;
|
bot_id?: number | string;
|
||||||
|
/**
|
||||||
|
* The workspace the call is in: its models win over the bot's.
|
||||||
|
*/
|
||||||
|
workspace_id?: number | string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CatalogEntry = HarnessCatalogModel & {
|
type CatalogEntry = HarnessCatalogModel & {
|
||||||
@@ -41,8 +45,9 @@ type CatalogEntry = HarnessCatalogModel & {
|
|||||||
export default async function resolveHarnessCandidates({
|
export default async function resolveHarnessCandidates({
|
||||||
harness,
|
harness,
|
||||||
bot_id,
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
}: Params): Promise<HarnessCandidate[]> {
|
}: Params): Promise<HarnessCandidate[]> {
|
||||||
const setting = await grabHarnessSetting({ harness, bot_id });
|
const setting = await grabHarnessSetting({ harness, bot_id, workspace_id });
|
||||||
|
|
||||||
if (!setting.enabled) {
|
if (!setting.enabled) {
|
||||||
const title = Harnesses.find((h) => h.value == harness)?.title;
|
const title = Harnesses.find((h) => h.value == harness)?.title;
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ export type GenerateTextParams = {
|
|||||||
* The bot the turn is for: its own models are used where it has them.
|
* The bot the turn is for: its own models are used where it has them.
|
||||||
*/
|
*/
|
||||||
bot_id?: number | string;
|
bot_id?: number | string;
|
||||||
|
/**
|
||||||
|
* The workspace the call is in: its models win over the bot's.
|
||||||
|
*/
|
||||||
|
workspace_id?: number | string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GenerateTextResult = HarnessChatResult & {
|
export type GenerateTextResult = HarnessChatResult & {
|
||||||
@@ -54,11 +58,12 @@ export default async function generateText({
|
|||||||
signal = new AbortController().signal,
|
signal = new AbortController().signal,
|
||||||
media_cache = new Map(),
|
media_cache = new Map(),
|
||||||
bot_id,
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
}: GenerateTextParams): Promise<GenerateTextResult> {
|
}: GenerateTextParams): Promise<GenerateTextResult> {
|
||||||
const { result, candidate } = await withCandidates({
|
const { result, candidate } = await withCandidates({
|
||||||
harness: "text",
|
harness: "text",
|
||||||
candidates:
|
candidates:
|
||||||
candidates || (await resolveHarnessCandidates({ harness: "text", bot_id })),
|
candidates || (await resolveHarnessCandidates({ harness: "text", bot_id, workspace_id })),
|
||||||
prefer_key,
|
prefer_key,
|
||||||
run: async (candidate) => {
|
run: async (candidate) => {
|
||||||
const answer = await grabHarnessAdapter({
|
const answer = await grabHarnessAdapter({
|
||||||
@@ -71,6 +76,7 @@ export default async function generateText({
|
|||||||
cache: media_cache,
|
cache: media_cache,
|
||||||
signal,
|
signal,
|
||||||
bot_id,
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
}),
|
}),
|
||||||
tools,
|
tools,
|
||||||
max_tokens,
|
max_tokens,
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ type Params = {
|
|||||||
* Reads media with this bot's models when it has its own.
|
* Reads media with this bot's models when it has its own.
|
||||||
*/
|
*/
|
||||||
bot_id?: number | string;
|
bot_id?: number | string;
|
||||||
|
/**
|
||||||
|
* The workspace the call is in: its models win over the bot's.
|
||||||
|
*/
|
||||||
|
workspace_id?: number | string | null;
|
||||||
read_image?: typeof readImage;
|
read_image?: typeof readImage;
|
||||||
transcribe?: typeof transcribeAudio;
|
transcribe?: typeof transcribeAudio;
|
||||||
};
|
};
|
||||||
@@ -37,6 +41,7 @@ export default async function prepareMessagesForModel({
|
|||||||
cache,
|
cache,
|
||||||
signal,
|
signal,
|
||||||
bot_id,
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
read_image = readImage,
|
read_image = readImage,
|
||||||
transcribe = transcribeAudio,
|
transcribe = transcribeAudio,
|
||||||
}: Params): Promise<HarnessMessage[]> {
|
}: Params): Promise<HarnessMessage[]> {
|
||||||
@@ -56,7 +61,7 @@ export default async function prepareMessagesForModel({
|
|||||||
label: `Image${part.name ? ` "${part.name}"` : ""}`,
|
label: `Image${part.name ? ` "${part.name}"` : ""}`,
|
||||||
cache,
|
cache,
|
||||||
signal,
|
signal,
|
||||||
read: () => read_image({ url: part.url, signal, bot_id }),
|
read: () => read_image({ url: part.url, signal, bot_id, workspace_id }),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -78,6 +83,7 @@ export default async function prepareMessagesForModel({
|
|||||||
},
|
},
|
||||||
signal,
|
signal,
|
||||||
bot_id,
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ type Params = {
|
|||||||
* The bot the run is for: its own models are used where it has them.
|
* The bot the run is for: its own models are used where it has them.
|
||||||
*/
|
*/
|
||||||
bot_id?: number | string;
|
bot_id?: number | string;
|
||||||
|
/**
|
||||||
|
* The workspace the call is in: its models win over the bot's.
|
||||||
|
*/
|
||||||
|
workspace_id?: number | string | null;
|
||||||
generate?: typeof generateText;
|
generate?: typeof generateText;
|
||||||
resolveCandidates?: () => Promise<HarnessCandidate[]>;
|
resolveCandidates?: () => Promise<HarnessCandidate[]>;
|
||||||
};
|
};
|
||||||
@@ -75,8 +79,9 @@ export default async function runTextLoop({
|
|||||||
signal = new AbortController().signal,
|
signal = new AbortController().signal,
|
||||||
onStep,
|
onStep,
|
||||||
bot_id,
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
generate = generateText,
|
generate = generateText,
|
||||||
resolveCandidates = () => resolveHarnessCandidates({ harness: "text", bot_id }),
|
resolveCandidates = () => resolveHarnessCandidates({ harness: "text", bot_id, workspace_id }),
|
||||||
}: Params): Promise<TextLoopResult> {
|
}: Params): Promise<TextLoopResult> {
|
||||||
const candidates = await resolveCandidates();
|
const candidates = await resolveCandidates();
|
||||||
const definitions = tools.map((tool) => toolDefinition({ tool }));
|
const definitions = tools.map((tool) => toolDefinition({ tool }));
|
||||||
@@ -120,6 +125,7 @@ export default async function runTextLoop({
|
|||||||
signal,
|
signal,
|
||||||
media_cache,
|
media_cache,
|
||||||
bot_id,
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
});
|
});
|
||||||
|
|
||||||
prefer_key = answer.candidate.key;
|
prefer_key = answer.candidate.key;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
|
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
|
||||||
import addBotToComputer from "@/src/functions/backend/db/computers/add-bot-to-computer";
|
import addBotToComputer from "@/src/functions/backend/db/computers/add-bot-to-computer";
|
||||||
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
|
|
||||||
import removeBotFromComputer from "@/src/functions/backend/db/computers/remove-bot-from-computer";
|
import removeBotFromComputer from "@/src/functions/backend/db/computers/remove-bot-from-computer";
|
||||||
import computerRef from "@/src/utils/computer-ref";
|
import computerRef from "@/src/utils/computer-ref";
|
||||||
import type { HarnessTool } from "../../types";
|
import type { HarnessTool } from "../../types";
|
||||||
@@ -30,8 +29,8 @@ type Args = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Lets the coordinator give a crew member access to one of the user's
|
* Lets the coordinator give a crew member access to one of the user's
|
||||||
* computers (`bot_computers`), or take a direct grant away. Access that
|
* computers in its own chat (`bot_computers`), or take it away. In a
|
||||||
* comes through a workspace stays, and the result says so.
|
* workspace, bots use the workspace's computers instead.
|
||||||
*/
|
*/
|
||||||
export default function assignComputerTool({
|
export default function assignComputerTool({
|
||||||
user_id,
|
user_id,
|
||||||
@@ -77,13 +76,7 @@ export default function assignComputerTool({
|
|||||||
|
|
||||||
await removeBotFromComputer({ user_id, bot_id: worker.id, computer_id: computer.id });
|
await removeBotFromComputer({ user_id, bot_id: worker.id, computer_id: computer.id });
|
||||||
|
|
||||||
const still = (await grabBotComputers({ bot_id: worker.id, user_id })).some(
|
return `${worker.name} can't use ${ref} in its own chat any more. In a workspace it still uses the workspace's computers.`;
|
||||||
(c) => c.id == computer.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
return still
|
|
||||||
? `Removed ${worker.name}'s own access to ${ref}, but it still has it through a workspace.`
|
|
||||||
: `${worker.name} can't use ${ref} any more.`;
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import pushBotNotice from "@/src/bots-queue/push-bot-notice";
|
|||||||
import enqueueBotJob from "@/src/functions/backend/db/bots-queue/enqueue-bot-job";
|
import enqueueBotJob from "@/src/functions/backend/db/bots-queue/enqueue-bot-job";
|
||||||
import insertBotMessage from "@/src/functions/backend/db/bots/insert-bot-message";
|
import insertBotMessage from "@/src/functions/backend/db/bots/insert-bot-message";
|
||||||
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
|
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
|
||||||
|
import ensureBotSession from "@/src/functions/backend/db/bot-sessions/ensure-bot-session";
|
||||||
import type { HarnessTool } from "../../types";
|
import type { HarnessTool } from "../../types";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
@@ -12,12 +13,21 @@ type Params = {
|
|||||||
* The coordinator: the worker's result comes back to its conversation.
|
* The coordinator: the worker's result comes back to its conversation.
|
||||||
*/
|
*/
|
||||||
coordinator_id: number | string;
|
coordinator_id: number | string;
|
||||||
|
/**
|
||||||
|
* The coordinator's session the task is handed over from.
|
||||||
|
*/
|
||||||
|
session_id: number | string;
|
||||||
|
/**
|
||||||
|
* The workspace the coordinator is working in: the worker works on it
|
||||||
|
* there, in its conversation in the workspace.
|
||||||
|
*/
|
||||||
|
workspace_id?: number | string | null;
|
||||||
/**
|
/**
|
||||||
* The coordinator's name, shown in the worker's conversation.
|
* The coordinator's name, shown in the worker's conversation.
|
||||||
*/
|
*/
|
||||||
coordinator_name: string;
|
coordinator_name: string;
|
||||||
/**
|
/**
|
||||||
* The user's worker bots.
|
* The worker bots it can hand work to (in a workspace, its members).
|
||||||
*/
|
*/
|
||||||
workers: BUN_SQLITE_COBBLE_BOTS[];
|
workers: BUN_SQLITE_COBBLE_BOTS[];
|
||||||
};
|
};
|
||||||
@@ -37,6 +47,8 @@ type Args = {
|
|||||||
export default function delegateTaskTool({
|
export default function delegateTaskTool({
|
||||||
user_id,
|
user_id,
|
||||||
coordinator_id,
|
coordinator_id,
|
||||||
|
session_id,
|
||||||
|
workspace_id,
|
||||||
coordinator_name,
|
coordinator_name,
|
||||||
workers,
|
workers,
|
||||||
}: Params): HarnessTool<Args> {
|
}: Params): HarnessTool<Args> {
|
||||||
@@ -59,21 +71,26 @@ export default function delegateTaskTool({
|
|||||||
throw new Error(`No crew member has id ${args.bot_id}. Crew: ${crew}.`);
|
throw new Error(`No crew member has id ${args.bot_id}. Crew: ${crew}.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const computers = await grabBotComputers({ bot_id: worker.id, user_id });
|
const computers = await grabBotComputers({ bot_id: worker.id, user_id, workspace_id });
|
||||||
|
const worker_session = await ensureBotSession({ bot_id: worker.id, workspace_id });
|
||||||
|
|
||||||
const job_id = await enqueueBotJob({
|
const job_id = await enqueueBotJob({
|
||||||
user_id,
|
user_id,
|
||||||
bot_id: worker.id,
|
bot_id: worker.id,
|
||||||
|
session_id: Number(worker_session.id),
|
||||||
title: args.title,
|
title: args.title,
|
||||||
instructions: args.instructions,
|
instructions: args.instructions,
|
||||||
computer_id: computers[0]?.id || undefined,
|
computer_id: computers[0]?.id || undefined,
|
||||||
requested_by_bot_id: coordinator_id,
|
requested_by_bot_id: coordinator_id,
|
||||||
|
requested_by_session_id: session_id,
|
||||||
});
|
});
|
||||||
|
|
||||||
await pushBotNotice({
|
await pushBotNotice({
|
||||||
bot_id: worker.id,
|
bot_id: worker.id,
|
||||||
message: await insertBotMessage({
|
message: await insertBotMessage({
|
||||||
bot_id: worker.id,
|
bot_id: worker.id,
|
||||||
|
session_id: Number(worker_session.id),
|
||||||
|
workspace_id: workspace_id ? Number(workspace_id) : null,
|
||||||
text: `${coordinator_name} asked me to: **${args.title}**`,
|
text: `${coordinator_name} asked me to: **${args.title}**`,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,15 +7,27 @@ import searchComputersTool from "../computer/search-computers-tool";
|
|||||||
import assignComputerTool from "./assign-computer-tool";
|
import assignComputerTool from "./assign-computer-tool";
|
||||||
import delegateTaskTool from "./delegate-task-tool";
|
import delegateTaskTool from "./delegate-task-tool";
|
||||||
import grabBotTools from "./grab-bot-tools";
|
import grabBotTools from "./grab-bot-tools";
|
||||||
|
import updateWorkspaceTool from "./update-workspace-tool";
|
||||||
|
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
|
||||||
|
import setWorkspaceInstructionsTool from "./set-workspace-instructions-tool";
|
||||||
import startTaskTool from "./start-task-tool";
|
import startTaskTool from "./start-task-tool";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
bot: BUN_SQLITE_COBBLE_BOTS;
|
bot: BUN_SQLITE_COBBLE_BOTS;
|
||||||
user_id: number | string;
|
user_id: number | string;
|
||||||
/**
|
/**
|
||||||
* The bot's computers, the current one first (`grabBotComputers`).
|
* The bot's computers where it runs, the current one first
|
||||||
|
* (`grabBotComputers`).
|
||||||
*/
|
*/
|
||||||
computers: BUN_SQLITE_COBBLE_COMPUTERS[];
|
computers: BUN_SQLITE_COBBLE_COMPUTERS[];
|
||||||
|
/**
|
||||||
|
* The session being answered; tasks report back to it.
|
||||||
|
*/
|
||||||
|
session_id: number | string;
|
||||||
|
/**
|
||||||
|
* The session's workspace, if any.
|
||||||
|
*/
|
||||||
|
workspace_id?: number | string | null;
|
||||||
/**
|
/**
|
||||||
* The user message being answered.
|
* The user message being answered.
|
||||||
*/
|
*/
|
||||||
@@ -27,32 +39,37 @@ type Params = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The tools a bot replies in chat with: its own (memory, instructions),
|
* The tools a bot replies in chat with: its own (memory, instructions, and
|
||||||
* start_task for longer work, search_computers to find what's on its
|
* in a workspace switching its default computer), start_task for longer
|
||||||
* computers, ask_user_to_take_over when only the user can do something on
|
* work, search_computers to find what's on its computers,
|
||||||
* one, and for the coordinator delegate_task and assign_computer
|
* ask_user_to_take_over when only the user can do something on one, and
|
||||||
* (which computers each crew member can use). Anything
|
* for the coordinator delegate_task (to the workspace's crew in a
|
||||||
* else on a computer goes through a task, never mid-reply.
|
* workspace) plus, in its own chat, assign_computer (which computers each
|
||||||
|
* crew member can use) or, in a workspace, set_bot_workspace_instructions.
|
||||||
|
* Anything else on a computer goes through a task, never mid-reply.
|
||||||
*/
|
*/
|
||||||
export default async function grabBotChatTools({
|
export default async function grabBotChatTools({
|
||||||
bot,
|
bot,
|
||||||
user_id,
|
user_id,
|
||||||
computers,
|
computers,
|
||||||
|
session_id,
|
||||||
|
workspace_id,
|
||||||
message_id,
|
message_id,
|
||||||
onTakeOver,
|
onTakeOver,
|
||||||
}: Params): Promise<HarnessTool[]> {
|
}: Params): Promise<HarnessTool[]> {
|
||||||
const bot_id = bot.id || 0;
|
const bot_id = bot.id || 0;
|
||||||
const tools = [
|
const tools = [
|
||||||
...grabBotTools({ user_id, bot_id }),
|
...grabBotTools({ user_id, bot_id, workspace_id, computers }),
|
||||||
startTaskTool({
|
startTaskTool({
|
||||||
user_id,
|
user_id,
|
||||||
bot_id,
|
bot_id,
|
||||||
|
session_id,
|
||||||
computers,
|
computers,
|
||||||
message_id,
|
message_id,
|
||||||
}),
|
}),
|
||||||
...(computers.length
|
...(computers.length
|
||||||
? [
|
? [
|
||||||
searchComputersTool({ bot_id, computers }),
|
searchComputersTool({ computers, workspace_id }),
|
||||||
askToTakeOverTool({ computers, onAsk: (params) => onTakeOver?.(params) }),
|
askToTakeOverTool({ computers, onAsk: (params) => onTakeOver?.(params) }),
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
@@ -60,24 +77,41 @@ export default async function grabBotChatTools({
|
|||||||
|
|
||||||
if (bot.kind != "coordinator") return tools;
|
if (bot.kind != "coordinator") return tools;
|
||||||
|
|
||||||
|
const workspaces = (await grabUserWorkspaces({ user_id })).filter((w) => !w.archived);
|
||||||
|
|
||||||
|
if (workspaces.length) {
|
||||||
|
tools.push(updateWorkspaceTool({ user_id, workspaces, workspace_id }));
|
||||||
|
}
|
||||||
|
|
||||||
const workers =
|
const workers =
|
||||||
(
|
(
|
||||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
|
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
|
||||||
sql: `SELECT * FROM bots WHERE user_id = ? AND kind = 'worker' ORDER BY id ASC`,
|
sql: workspace_id
|
||||||
values: [user_id],
|
? `SELECT b.* FROM bots b JOIN workspace_bots wb ON wb.bot_id = b.id AND wb.workspace_id = ?
|
||||||
|
WHERE b.user_id = ? AND b.kind = 'worker' ORDER BY b.id ASC`
|
||||||
|
: `SELECT * FROM bots WHERE user_id = ? AND kind = 'worker' ORDER BY id ASC`,
|
||||||
|
values: workspace_id ? [workspace_id, user_id] : [user_id],
|
||||||
})
|
})
|
||||||
).payload || [];
|
).payload || [];
|
||||||
|
|
||||||
if (workers.length) {
|
if (!workers.length) return tools;
|
||||||
|
|
||||||
tools.push(
|
tools.push(
|
||||||
delegateTaskTool({
|
delegateTaskTool({
|
||||||
user_id,
|
user_id,
|
||||||
coordinator_id: bot_id,
|
coordinator_id: bot_id,
|
||||||
|
session_id,
|
||||||
|
workspace_id,
|
||||||
coordinator_name: bot.name || "The coordinator",
|
coordinator_name: bot.name || "The coordinator",
|
||||||
workers,
|
workers,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (workspace_id) {
|
||||||
|
tools.push(setWorkspaceInstructionsTool({ user_id, workspace_id, workers }));
|
||||||
|
return tools;
|
||||||
|
}
|
||||||
|
|
||||||
if (computers.length) {
|
if (computers.length) {
|
||||||
const access = Object.fromEntries(
|
const access = Object.fromEntries(
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
@@ -90,7 +124,6 @@ export default async function grabBotChatTools({
|
|||||||
|
|
||||||
tools.push(assignComputerTool({ user_id, workers, computers, access }));
|
tools.push(assignComputerTool({ user_id, workers, computers, access }));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return tools;
|
return tools;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,38 @@
|
|||||||
|
import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
|
||||||
import type { HarnessTool } from "../../types";
|
import type { HarnessTool } from "../../types";
|
||||||
|
import setWorkspaceComputerTool from "./set-workspace-computer-tool";
|
||||||
import updateInstructionsTool from "./update-instructions-tool";
|
import updateInstructionsTool from "./update-instructions-tool";
|
||||||
import updateMemoryTool from "./update-memory-tool";
|
import updateMemoryTool from "./update-memory-tool";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
user_id: number | string;
|
user_id: number | string;
|
||||||
bot_id: number | string;
|
bot_id: number | string;
|
||||||
|
/**
|
||||||
|
* The workspace the run is in: memory and instructions save there by
|
||||||
|
* default, and the bot can switch its default computer.
|
||||||
|
*/
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
/**
|
||||||
|
* The bot's computers where it runs, the current one first.
|
||||||
|
*/
|
||||||
|
computers?: BUN_SQLITE_COBBLE_COMPUTERS[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The tools a bot works with, bound to that bot. Computer, delegation and
|
* The tools a bot works with on every run, bound to that bot and where it
|
||||||
* media tools join this list as they are built.
|
* works.
|
||||||
*/
|
*/
|
||||||
export default function grabBotTools({ user_id, bot_id }: Params): HarnessTool[] {
|
export default function grabBotTools({
|
||||||
|
user_id,
|
||||||
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
|
computers = [],
|
||||||
|
}: Params): HarnessTool[] {
|
||||||
return [
|
return [
|
||||||
updateMemoryTool({ user_id, bot_id }),
|
updateMemoryTool({ user_id, bot_id, workspace_id }),
|
||||||
updateInstructionsTool({ user_id, bot_id }),
|
updateInstructionsTool({ user_id, bot_id, workspace_id }),
|
||||||
|
...(workspace_id && computers.length > 1
|
||||||
|
? [setWorkspaceComputerTool({ user_id, workspace_id, computers })]
|
||||||
|
: []),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
|
||||||
|
import setWorkspaceComputer from "@/src/functions/backend/db/workspaces/set-workspace-computer";
|
||||||
|
import computerRef from "@/src/utils/computer-ref";
|
||||||
|
import type { HarnessTool } from "../../types";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
user_id: number | string;
|
||||||
|
workspace_id: number | string;
|
||||||
|
/**
|
||||||
|
* The workspace's computers, the default first.
|
||||||
|
*/
|
||||||
|
computers: BUN_SQLITE_COBBLE_COMPUTERS[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type Args = {
|
||||||
|
computer_id: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lets a bot working in a workspace switch the workspace's default
|
||||||
|
* computer, where its tasks run from then on.
|
||||||
|
*/
|
||||||
|
export default function setWorkspaceComputerTool({
|
||||||
|
user_id,
|
||||||
|
workspace_id,
|
||||||
|
computers,
|
||||||
|
}: Params): HarnessTool<Args> {
|
||||||
|
const listed = computers
|
||||||
|
.map((c) => `${computerRef({ id: c.id || 0 })} ${c.name || "Computer"}`)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: "set_workspace_computer",
|
||||||
|
description: `Make another of this workspace's computers its default, where tasks here run from now on (for the whole crew). Do it when the work needs that computer; say so to the user. Computers: ${listed}. The default now is ${computerRef({ id: computers[0]?.id || 0 })}.`,
|
||||||
|
schema: z.object({
|
||||||
|
computer_id: z.number().int(),
|
||||||
|
}),
|
||||||
|
run: async ({ args }) => {
|
||||||
|
const { computer } = await setWorkspaceComputer({
|
||||||
|
user_id,
|
||||||
|
workspace_id,
|
||||||
|
computer_id: args.computer_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return `${computerRef({ id: computer.id || 0 })} is the workspace's default computer now. Tasks you start from here on run there.`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import updateWorkspaceBot from "@/src/functions/backend/db/workspaces/update-workspace-bot";
|
||||||
|
import type { HarnessTool } from "../../types";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
user_id: number | string;
|
||||||
|
workspace_id: number | string;
|
||||||
|
/**
|
||||||
|
* The workspace's crew members (not the coordinator).
|
||||||
|
*/
|
||||||
|
workers: BUN_SQLITE_COBBLE_BOTS[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type Args = {
|
||||||
|
bot_id: number;
|
||||||
|
instructions: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lets the coordinator set a crew member's instructions for this
|
||||||
|
* workspace: how that bot should do its job here.
|
||||||
|
*/
|
||||||
|
export default function setWorkspaceInstructionsTool({
|
||||||
|
user_id,
|
||||||
|
workspace_id,
|
||||||
|
workers,
|
||||||
|
}: Params): HarnessTool<Args> {
|
||||||
|
const crew = workers.map((bot) => `${bot.id}: ${bot.name}`).join("; ");
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: "set_bot_workspace_instructions",
|
||||||
|
description: `Replace a crew member's instructions for this workspace with this full text (markdown): how they should do their job here, on top of their own instructions. Use it when the user tells you how someone should work in this workspace. It replaces what they had for it, so keep what still applies. Crew here: ${crew}.`,
|
||||||
|
schema: z.object({
|
||||||
|
bot_id: z.number().int(),
|
||||||
|
instructions: z.string().trim().max(AppData.PromptMaxLength),
|
||||||
|
}),
|
||||||
|
run: async ({ args }) => {
|
||||||
|
const worker = workers.find((bot) => bot.id == args.bot_id);
|
||||||
|
|
||||||
|
if (!worker?.id) {
|
||||||
|
throw new Error(`No crew member here has id ${args.bot_id}. Crew here: ${crew}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateWorkspaceBot({
|
||||||
|
user_id,
|
||||||
|
workspace_id,
|
||||||
|
bot_id: worker.id,
|
||||||
|
system_prompt: args.instructions,
|
||||||
|
});
|
||||||
|
|
||||||
|
return `${worker.name}'s instructions for this workspace are saved. They apply from their next run here.`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,6 +6,10 @@ import type { HarnessTool } from "../../types";
|
|||||||
type Params = {
|
type Params = {
|
||||||
user_id: number | string;
|
user_id: number | string;
|
||||||
bot_id: number | string;
|
bot_id: number | string;
|
||||||
|
/**
|
||||||
|
* The session the task is started from; its result is posted there.
|
||||||
|
*/
|
||||||
|
session_id: number | string;
|
||||||
/**
|
/**
|
||||||
* The bot's computers, the current one first (`grabBotComputers`).
|
* The bot's computers, the current one first (`grabBotComputers`).
|
||||||
*/
|
*/
|
||||||
@@ -29,6 +33,7 @@ type Args = {
|
|||||||
export default function startTaskTool({
|
export default function startTaskTool({
|
||||||
user_id,
|
user_id,
|
||||||
bot_id,
|
bot_id,
|
||||||
|
session_id,
|
||||||
computers,
|
computers,
|
||||||
message_id,
|
message_id,
|
||||||
}: Params): HarnessTool<Args> {
|
}: Params): HarnessTool<Args> {
|
||||||
@@ -54,6 +59,7 @@ export default function startTaskTool({
|
|||||||
const job_id = await enqueueBotJob({
|
const job_id = await enqueueBotJob({
|
||||||
user_id,
|
user_id,
|
||||||
bot_id,
|
bot_id,
|
||||||
|
session_id,
|
||||||
title: args.title,
|
title: args.title,
|
||||||
instructions: args.instructions,
|
instructions: args.instructions,
|
||||||
message_id,
|
message_id,
|
||||||
|
|||||||
@@ -1,33 +1,61 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { AppData } from "@/src/data/app-data";
|
import { AppData } from "@/src/data/app-data";
|
||||||
import updateBotSystemPrompt from "@/src/functions/backend/db/bots/update-bot-system-prompt";
|
import updateBotSystemPrompt from "@/src/functions/backend/db/bots/update-bot-system-prompt";
|
||||||
|
import updateWorkspaceBot from "@/src/functions/backend/db/workspaces/update-workspace-bot";
|
||||||
import type { HarnessTool } from "../../types";
|
import type { HarnessTool } from "../../types";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
user_id: number | string;
|
user_id: number | string;
|
||||||
bot_id: number | string;
|
bot_id: number | string;
|
||||||
|
/**
|
||||||
|
* The workspace the run is in: then the bot rewrites its instructions
|
||||||
|
* for the workspace unless it picks `core`.
|
||||||
|
*/
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Args = {
|
||||||
|
instructions: string;
|
||||||
|
scope?: "workspace" | "core";
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lets a bot rewrite its own core instructions as it adapts to the user.
|
* Lets a bot rewrite its own instructions as it adapts to the user: its
|
||||||
|
* core instructions, or in a workspace (by default) its instructions for
|
||||||
|
* that workspace.
|
||||||
*/
|
*/
|
||||||
export default function updateInstructionsTool({
|
export default function updateInstructionsTool({
|
||||||
user_id,
|
user_id,
|
||||||
bot_id,
|
bot_id,
|
||||||
}: Params): HarnessTool<{ instructions: string }> {
|
workspace_id,
|
||||||
|
}: Params): HarnessTool<Args> {
|
||||||
|
const instructions = z.string().trim().max(AppData.PromptMaxLength);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: "update_instructions",
|
name: "update_instructions",
|
||||||
description: `Replace your core instructions with this full text (markdown): the start of your prompt, before any workspace or memory. Change them only to work better for this user, such as a new standing rule they gave you. Keep everything that still applies.`,
|
description: workspace_id
|
||||||
schema: z.object({
|
? `Replace your instructions with this full text (markdown). scope "workspace" (the default) is your instructions for this workspace, under "# Your instructions in ..."; "core" is your core instructions, the start of your prompt in every workspace and your own chat. Change them only to work better for this user, such as a new standing rule they gave you. Keep everything that still applies.`
|
||||||
instructions: z.string().trim().min(1).max(AppData.PromptMaxLength),
|
: `Replace your core instructions with this full text (markdown): the start of your prompt, before any workspace or memory. Change them only to work better for this user, such as a new standing rule they gave you. Keep everything that still applies.`,
|
||||||
}),
|
schema: workspace_id
|
||||||
|
? z.object({ instructions, scope: z.enum(["workspace", "core"]).default("workspace") })
|
||||||
|
: z.object({ instructions: instructions.min(1) }),
|
||||||
run: async ({ args }) => {
|
run: async ({ args }) => {
|
||||||
|
if (workspace_id && args.scope != "core") {
|
||||||
|
await updateWorkspaceBot({
|
||||||
|
user_id,
|
||||||
|
workspace_id,
|
||||||
|
bot_id,
|
||||||
|
system_prompt: args.instructions,
|
||||||
|
});
|
||||||
|
return `Instructions for this workspace saved. They apply from your next run here.`;
|
||||||
|
}
|
||||||
|
|
||||||
await updateBotSystemPrompt({
|
await updateBotSystemPrompt({
|
||||||
user_id,
|
user_id,
|
||||||
bot_id,
|
bot_id,
|
||||||
system_prompt: args.instructions,
|
system_prompt: args.instructions,
|
||||||
});
|
});
|
||||||
return `Instructions saved. They apply from your next run.`;
|
return `Core instructions saved. They apply from your next run, everywhere.`;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user