diff --git a/AGENTS.md b/AGENTS.md index 8e96fb3..77dd6d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,7 +99,7 @@ Inspect live data with `bun llm-code-assistant/query-db.ts ""` - a read-onl - **Prompt and memory (self-learning):** a bot has `system_prompt` (core instructions) and a separate core `memory` (markdown, general knowledge about its job, the same for every user it works for, never about one user). What it remembers about a user lives in `bot_user_memories` (one row per bot and user, seen only by that user's runs; `grab-bot-user-memory`, `update-bot-user-memory`), like workspace memory stays in its workspace. The bot rewrites them as it adapts (`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 for the run's user via `composeBotPrompt` (`src/utils/compose-bot-prompt.ts`): core instructions, then (in a workspace only) `# Workspace: ` with its description and `system_prompt`, the bot's `# Your instructions in ` (`workspace_bots.system_prompt`), `# Memory`, `# Memory about the user`, then `# Memory in ` (`workspace_bots.memory`). `update_memory` saves to `user` by default (`workspace` in a workspace) and to `core` only for what holds for everyone. 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. `src/scripts/move-bot-memory-to-owners.ts` (run once per install) moved core memory written before this into the owner's. - **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 " 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`. -- **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 bot that joins the crew or a workspace is pushed as `server:bot-created` (`BotCreatedData`: its rail row, the workspaces it's in, and whether it belongs in that user's own chat), from the tool that created it and from the live relay for other processes, so the rail lists it without a reload. 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` (`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 access-checked against the session user, the structural ones for super admins and admins only; 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` (the install's, `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 `-` limited to the computer's `cpus` and `memory_mb` (set at creation, default `AppData.ComputerDefaultCpus`/`ComputerDefaultMemoryMb`: 1 CPU, 2 GB), attached to the network in the same create call, with a docker health check (`AppData.ComputerHealthcheck`: the sandbox API on `ComputerPort` and its browser); the computer stays `creating` until it passes (`waitForHealthyContainer`), then records its IP address on the network (`containers.ip_address`, refreshed by `sync-computers`). `updateComputer` (`api/admin/computers/update-computer`) saves name, description and resources; new resources stop, remove and recreate the container (`provisionComputer`), the home dir kept. A computer's home (`/home/gem`) is `DATA_DIR/computers/` (`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 (dev) it can't resolve container hostnames, so it reaches them at their IP address (`pickReachableAddress`: hostname first, then the IP, the pick cached for `AppData.ComputerAddressCacheMs`). Only super admins create, delete or retry computers (`api/admin/computers/`). The `sync-computers` cron records container states every five minutes. The install has one main computer, created at signup (`bun run seed`) and as a fallback by the super admin's `/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 every computer), 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 whoever sees it (`computersSql`) under `/computers//` (`ComputerProxyPrefix`): `src/server.ts` hands those paths to `handleComputerProxy` (`src/functions/server/computers/`: `userAuth`, access 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/`, desktop: the sandbox's noVNC at `/vnc/vnc.html`, its socket pointed back through the proxy by the `{prefix}` placeholder that `computerViewUrl` fills in; desktop is the only view with Chrome's toolbar (extensions) and a clipboard panel, since the browser view streams pages over CDP and hides `chrome-extension://` targets; 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`). diff --git a/src/bots-queue/push-bot-created.ts b/src/bots-queue/push-bot-created.ts new file mode 100644 index 0000000..e70e142 --- /dev/null +++ b/src/bots-queue/push-bot-created.ts @@ -0,0 +1,59 @@ +import BunSQLite from "@moduletrace/bun-sqlite"; +import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db"; +import type { BotWithLastMessage } from "@/src/types"; +import grabBotAudience from "@/src/functions/backend/db/access/grab-bot-audience"; +import isBotInOwnChat from "@/src/functions/backend/db/bots/is-bot-in-own-chat"; +import { sendToUser } from "@/src/websocket/(functions)/send-to-user"; + +type Params = { + bot: BUN_SQLITE_COBBLE_BOTS; + /** + * Only these users (the connected ones, for the live relay); else + * everyone who sees the bot. + */ + users?: Set; +}; + +/** + * Pushes `server:bot-created` to everyone who sees a bot that just joined + * the crew or a workspace: its rail row, the workspaces it's in, and + * whether it also belongs in that user's own chat list. + */ +export default async function pushBotCreated({ bot, users }: Params) { + if (!bot.id) return; + + const bot_id = Number(bot.id); + + const workspace_ids = ( + ( + await BunSQLite.sql<{ workspace_id: number }>({ + sql: `SELECT workspace_id FROM workspace_bots WHERE bot_id = ?`, + values: [bot_id], + }) + ).payload || [] + ).map((row) => Number(row.workspace_id)); + + const data: BotWithLastMessage = { + ...bot, + unread: 0, + last_activity_at: null, + last_message_text: null, + last_message_at: null, + }; + + for (const user_id of await grabBotAudience({ bot_id })) { + if (users && !users.has(String(user_id))) continue; + + sendToUser({ + user_id, + data: { + event: "server:bot-created", + data: { + bot: data, + workspace_ids, + in_own_chat: await isBotInOwnChat({ bot_id, user_id }), + }, + }, + }); + } +} diff --git a/src/functions/backend/db/bots/is-bot-in-own-chat.ts b/src/functions/backend/db/bots/is-bot-in-own-chat.ts new file mode 100644 index 0000000..6f986ff --- /dev/null +++ b/src/functions/backend/db/bots/is-bot-in-own-chat.ts @@ -0,0 +1,24 @@ +import BunSQLite from "@moduletrace/bun-sqlite"; +import chatBotsSql from "@/src/utils/crew-access/chat-bots-sql"; +import grabCrewRole from "../access/grab-crew-role"; + +type Params = { + bot_id: number | string; + user_id: number | string; +}; + +/** + * Whether a bot belongs in a user's own chat list (`chatBotsSql`), as + * opposed to only a workspace they're in. Used to tell a client which + * rail a newly created bot shows up in. + */ +export default async function isBotInOwnChat({ bot_id, user_id }: Params) { + const access = chatBotsSql({ role: await grabCrewRole({ user_id }), user_id }); + + const res = await BunSQLite.sql<{ present: number }>({ + sql: `SELECT 1 AS present FROM bots b WHERE b.id = ? AND ${access.sql} LIMIT 1`, + values: [bot_id, ...access.values], + }); + + return Boolean(res.payload?.[0]?.present); +} diff --git a/src/harness/tools/bot/add-bot-to-workspace-tool.ts b/src/harness/tools/bot/add-bot-to-workspace-tool.ts index 41c8226..983fba3 100644 --- a/src/harness/tools/bot/add-bot-to-workspace-tool.ts +++ b/src/harness/tools/bot/add-bot-to-workspace-tool.ts @@ -1,6 +1,7 @@ import BunSQLite from "@moduletrace/bun-sqlite"; import { z } from "zod"; import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db"; +import pushBotCreated from "@/src/bots-queue/push-bot-created"; import addBotToWorkspace from "@/src/functions/backend/db/workspaces/add-bot-to-workspace"; import type { HarnessTool } from "../../types"; @@ -74,6 +75,8 @@ export default function addBotToWorkspaceTool({ bot_id: worker.id, }); + await pushBotCreated({ bot: worker }); + return `${worker.name} joined ${workspace.name}.`; }, }; diff --git a/src/harness/tools/bot/create-bot-tool.ts b/src/harness/tools/bot/create-bot-tool.ts index 38fc712..d6b4284 100644 --- a/src/harness/tools/bot/create-bot-tool.ts +++ b/src/harness/tools/bot/create-bot-tool.ts @@ -2,6 +2,7 @@ import BunSQLite from "@moduletrace/bun-sqlite"; import { z } from "zod"; import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db"; import { AppData } from "@/src/data/app-data"; +import pushBotCreated from "@/src/bots-queue/push-bot-created"; import createBot from "@/src/functions/backend/db/bots/create-bot"; import addBotToWorkspace from "@/src/functions/backend/db/workspaces/add-bot-to-workspace"; import pickFreeHue from "@/src/utils/pick-free-hue"; @@ -81,6 +82,18 @@ export default function createBotTool({ await addBotToWorkspace({ user_id, workspace_id: target.id, bot_id }); } + const created = + ( + await BunSQLite.sql({ + sql: `SELECT * FROM bots WHERE id = ?`, + values: [bot_id], + }) + ).payload?.[0] || null; + + if (created) { + await pushBotCreated({ bot: created }); + } + const where = target?.name ? ` and joins ${target.name}` : ""; return `${args.name} is on the crew (bot #${bot_id})${where}.${target ? "" : " It has no workspace yet: put it in one with add_bot_to_workspace, or give it a computer with assign_computer."}`; diff --git a/src/hooks/use-live-bots.ts b/src/hooks/use-live-bots.ts index 04e96ff..c4ca6d5 100644 --- a/src/hooks/use-live-bots.ts +++ b/src/hooks/use-live-bots.ts @@ -1,6 +1,6 @@ import { useState } from "react"; import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db"; -import type { BotUpdatedData, BotWithLastMessage } from "@/src/types"; +import type { BotCreatedData, BotUpdatedData, BotWithLastMessage } from "@/src/types"; import useServerEvent from "./use-server-event"; type Params = { @@ -23,6 +23,32 @@ type Params = { export default function useLiveBots({ bots, open_bot_id, workspace_id = null }: Params) { const [live, setLive] = useState(bots); + useServerEvent({ + event: "server:bot-created", + onData: (data: BotCreatedData) => { + /** + * In a workspace only its own crew is listed; outside one, the + * bots in the user's own chat (a member doesn't get a bot that + * only joined a workspace). + */ + const here = workspace_id + ? data.workspace_ids.includes(Number(workspace_id)) + : data.in_own_chat; + + if (!here) return; + + setLive((prev) => { + if (prev.some((bot) => bot.id == data.bot.id)) return prev; + + /** The coordinator stays pinned first; a new worker is the latest. */ + const first_worker = prev.findIndex((bot) => bot.kind != "coordinator"); + const at = first_worker < 0 ? prev.length : first_worker; + + return [...prev.slice(0, at), data.bot, ...prev.slice(at)]; + }); + }, + }); + useServerEvent({ event: "server:bot-updated", onData: (data: BotUpdatedData) => diff --git a/src/live-relay/index.ts b/src/live-relay/index.ts index 80943e5..31ec682 100644 --- a/src/live-relay/index.ts +++ b/src/live-relay/index.ts @@ -3,6 +3,7 @@ import { AppData } from "@/src/data/app-data"; import { logError } from "@/src/functions/backend/logger"; import grabConnectedUserIds from "@/src/websocket/(functions)/grab-connected-user-ids"; import relayChangedBots from "./relay-changed-bots"; +import relayCreatedBots from "./relay-created-bots"; import relayNewBotMessages from "./relay-new-bot-messages"; import relayNewJobUpdates from "./relay-new-job-updates"; import type { LiveRelayCursor } from "./types"; @@ -50,7 +51,10 @@ export default async function liveRelay() { * Overlap one interval, so a change written in the same * millisecond as the last look isn't missed. */ - await relayChangedBots({ since: cursor.bots_at - AppData.LiveRelayPollMs, users }); + const bots_since = cursor.bots_at - AppData.LiveRelayPollMs; + + await relayCreatedBots({ since: bots_since, users }); + await relayChangedBots({ since: bots_since, users }); cursor.bots_at = now; } catch (error) { await logError({ title: `Live relay failed`, error }).catch(() => {}); diff --git a/src/live-relay/relay-created-bots.ts b/src/live-relay/relay-created-bots.ts new file mode 100644 index 0000000..a2d5359 --- /dev/null +++ b/src/live-relay/relay-created-bots.ts @@ -0,0 +1,33 @@ +import BunSQLite from "@moduletrace/bun-sqlite"; +import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db"; +import pushBotCreated from "@/src/bots-queue/push-bot-created"; + +type Params = { + /** + * Unix ms: bots created since then are pushed. + */ + since: number; + users: Set; +}; + +/** + * Pushes `server:bot-created` for every bot created since `since` to the + * connected users who see it (`pushBotCreated`), so a bot another process + * added still shows in the rail. Pushes are idempotent, so overlapping + * windows only repeat them. + */ +export default async function relayCreatedBots({ since, users }: Params) { + if (!users.size) return; + + const bots = + ( + await BunSQLite.sql({ + sql: `SELECT * FROM bots WHERE created_at > ?`, + values: [since], + }) + ).payload || []; + + for (const bot of bots) { + await pushBotCreated({ bot, users }); + } +} diff --git a/src/types/index.ts b/src/types/index.ts index e1864dc..3dbc0f7 100755 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -124,6 +124,16 @@ export type BotJobUpdateData = BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES & { session_id: number | null; }; +/** + * `server:bot-created` data: a bot the user can now see, the workspaces + * it's in, and whether it belongs in their own chat list (the main rail). + */ +export type BotCreatedData = { + bot: BotWithLastMessage; + workspace_ids: number[]; + in_own_chat: boolean; +}; + /** * `server:bot-updated` data: the bot's new face and status line, and its * active task (null when there is none). @@ -535,6 +545,11 @@ export const WebSocketEvents = [ * `bot_messages` row) */ "server:bot-message", + /** + * A bot joined the crew or one of the user's workspaces (`data`: + * `BotCreatedData`) + */ + "server:bot-created", /** * A bot's face or status line changed (`data`: `BotUpdatedData`) */