Compare commits

...
30 Commits
Author SHA1 Message Date
tbenandClaude Opus 5.5 521c2bbac0 Release v0.10.0
CI / check (push) Canceled after 0s
Release image / image (release) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 18:43:10 +01:00
tbenandClaude Opus 5.5 de28d4d9f7 Let generate_image work from reference images, with an image editing model
generate_image takes reference images: files the run reaches as
[file:ID] refs, or the user's uploads by media id, up to four, each
checked like a picture source and limited to raster images.

Calls with references go to a new Image editing harness (no automatic
picks). While it has no model or is off, image generation takes them;
with its fallback on, image generation steps in after its pick fails.
Only models that take image input are tried with references, so none
drops them unseen. OpenRouter gets them on /images as input_references;
other providers as a multipart upload to /images/edits.

The Models pages show image editing's unset state as "Same as image
generation", and the models cache version is bumped so it shows at once.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 18:43:10 +01:00
tbenandClaude Opus 5.5 243c19e0c5 Release v0.9.0
CI / check (push) Canceled after 0s
Release image / image (release) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 18:26:20 +01:00
tbenandClaude Opus 5.5 1e7834ba7f Add a guided setup after signup, and a no-model notice in conversations
The first super admin now lands on Settings > Setup after signing up: a
skippable walk through the app name, connecting a model provider and
picking models, ending with where to go next. It stays under Settings to
come back to.

While a bot has no text model, its conversation shows a notice in the
thread (centred when empty): super admins get Connect a provider and
Guided setup, everyone else is told the super admin sets it up. The
models modal keeps it current.

The Models page's data and save logic move into useModelsOverview, shared
with the setup.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 18:26:20 +01:00
tbenandClaude Opus 5.5 c9a24c05af Release v0.8.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 11:10:23 +01:00
tbenandClaude Opus 5.5 d7dfe0e78e Let bots ask multiple-choice questions before acting
ask_user puts up to six questions on one card (radios for one pick,
checkboxes for several, an optional typed Other) as a [question:ID] chip.
The user's answers are checked against the questions, stored in
bot_questions and sent to the bot as their chat message; Skip says so.
Offered in chat replies and user-started tasks, never in routines, hook
calls or chatroom sessions.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 11:10:23 +01:00
tbenandClaude Opus 5.5 07f87ce4ea Release v0.7.6
CI / check (push) Canceled after 0s
Release image / image (release) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 07:05:34 +01:00
tbenandClaude Opus 5.5 f84f0fd0d6 Name sessions opened by handed-off work, and hide empty ones from activity
A session the coordinator's delegation, a routine or a hook opened has
no user message and no reply, so it was never named and showed as
"Untitled conversation". Tasks now name their session too, and a session
with no user message is named from the bot's own messages. Until a
session is named, Latest activity shows what it opened with, and
sessions with no messages are left out of it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 07:05:34 +01:00
tbenandClaude Opus 5.5 b4b27dea10 Truncate long list row titles instead of overflowing on mobile
Stack lines its children up at the start, so a linked row title grew to
its full one-line width and pushed the card past the screen edge. The
link now fills the row and the title is a block, so it truncates.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 07:05:34 +01:00
tbenandClaude Opus 5.5 596a417229 Release v0.7.5
CI / check (push) Canceled after 0s
Release image / image (release) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 06:27:06 +01:00
tbenandClaude Opus 5.5 623112fd19 Give every task a bot: drop shared workspace tasks
A workspace task with no bot was seen by every bot working there. Now a
task can't be saved without a bot, a worker reaches only its own (the
workspace admin and the coordinator still reach all), the workspace page
picks who has a new task, and a bot leaving a workspace or deleted passes
its tasks there to the workspace's admin bot, else the coordinator (also
at boot for rows without a bot).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 06:26:44 +01:00
tbenandClaude Opus 5.5 3e78d51b56 Keep bot messages inside the column on narrow screens
Lists no longer add twui's 25px list margin on top of full width,
markdown blocks and list items are capped at the column, long words
and inline code wrap anywhere, and ref chips with long labels truncate
instead of pushing the line past the edge.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 06:23:09 +01:00
tbenandClaude Opus 5.5 4e20106c59 Release v0.7.4
CI / check (push) Canceled after 0s
Release image / image (release) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 05:49:42 +01:00
tbenandClaude Opus 5.5 c547e556ec Open a task in its own modal from its title, with status moves, and edit its brief in the code editor
The modal shows who has the task and where, who added it and when it
moved, its files and notes (each note naming its bot). The task form's
fields all show their labels.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 05:49:42 +01:00
tbenandClaude Opus 5.5 74da091dd4 Release v0.7.3
CI / check (push) Canceled after 0s
Release image / image (release) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 05:32:09 +01:00
tbenandClaude Opus 5.5 a33593460b Delete a task from its list row, keep the status chip unfilled in light mode
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 05:32:09 +01:00
tbenandClaude Opus 5.5 bd73ecbe12 Release v0.7.2
CI / check (push) Canceled after 0s
Release image / image (release) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 05:09:52 +01:00
tbenandClaude Opus 5.5 50dd6976ee Mark and round the uploaded icon's favicon, enlarge the square icon's mark
An uploaded image's favicon now carries the stone mark like the PWA
icons and is cut to a circle. The mark is larger on the favicon and on
the regular (rounded-square) icons, and the settings preview matches.
A render revision in the set's version gives re-saved icons a new URL
past the immutable cache.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 05:09:21 +01:00
tbenandClaude Opus 5.5 8c4658f6e3 Release v0.7.1
CI / check (push) Canceled after 0s
Release image / image (release) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 04:55:23 +01:00
tbenandClaude Opus 5.5 d3ce787087 Show every bot of a chatroom in its rail icon
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 04:54:55 +01:00
tbenandClaude Opus 5.5 d8193d08b7 Release v0.7.0
CI / check (push) Canceled after 0s
Release image / image (release) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 04:50:00 +01:00
tbenandClaude Opus 5.5 ba9705980e Keep logs for a set number of days
Logs grew forever. A daily prune-logs cron now deletes entries older
than the app setting logs_keep_days (Settings > General, super admin;
default AppData.LogsKeepDays, 90), with an index on logs.created_at.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 04:49:25 +01:00
tbenandClaude Opus 5.5 5f96004f13 Search files as the user types
The Files page and the conversation's files modal searched only on
Enter. Both now search by name or path a moment after the last
keystroke (useDebouncedValue, AppData.SearchDebounceMs), clearing shows
the whole list at once, and the modal drops an answer a newer request
overtook. The Files search box no longer locks while a folder loads.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 04:46:39 +01:00
tbenandClaude Opus 5.5 ac3ebc43d4 Keep searchable text only in knowledgebases
Every bot file was chunked into bot_file_chunks and embedded into
bot_file_vectors, and uploads kept their extracted text in
media.text_content, which together took most of the database. Bot
files are no longer indexed: bots find them by name or path with
list_files and read them with read_file, and search_files, ingest_file
and the files modal's re-read button are gone. Attachments keep no
text; a reply reads a document from the file. Only knowledgebase
documents keep text.

The schema drops both tables and the ingest columns. At boot,
dropStoredFileText removes what older versions stored (file jobs,
upload text) and vacuums once much of the database is free. The
media:prune-text script it replaces is removed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 04:44:46 +01:00
tbenandClaude Opus 5.5 2896e76256 Stop computer bash commands on the computer itself
The sandbox ignores sandbox_execute_bash's timeout and runs every call in
one shared shell session, so a command that never ended (a network read
without a timeout, a prompt) kept running after the call gave up, and
every later command on that computer, from any run, timed out behind it.
Each command now runs in its own bash under coreutils timeout (at most
ComputerCommandMaxSeconds, below McpToolTimeoutMs), reading no input, and
says when it was stopped.

Replies' env list says where each key comes from and that workspace,
computer and install-wide envs reach every bot there, so bots stop
telling users a workspace's keys must be set per bot. The computer
prompt says installed programs may be gone after a restart. An empty
open-only task list says closed tasks aren't shown, and update_task says
when a status was already set.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 04:34:39 +01:00
tbenandClaude Opus 5.5 e51f2aa9a4 Catch up on replies missed while the app was in the background
The reply catch-up read the page's stale face, so after an interim
message it stopped while the thinking row stayed up. It now reads the
live face, checks at once when the page comes back into view or online,
and replays the bot's face and task too, so a reply that ended silently
can't leave the bot working on screen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 04:14:13 +01:00
tbenandClaude Opus 5.5 4061a317bc Release v0.6.2
CI / check (push) Canceled after 0s
Release image / image (release) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 19:42:10 +01:00
tbenandClaude Opus 5.5 a821013514 Clear finished jobs' steps a minute after they end
Job steps (bots_queue_updates) only feed the live working row, so the
new clear-job-steps cron deletes them shortly after a job ends, however
it ended, keeping their count on bots_queue.steps for the message
details. Not done the moment a job ends: the live relay would miss the
last ones.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 19:41:40 +01:00
tbenandClaude Opus 5.5 78503208e8 Release v0.6.1
CI / check (push) Canceled after 0s
Release image / image (release) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 19:34:13 +01:00
tbenandClaude Opus 5.5 8890d80e6e Tell replies their envs and call queued work runs
Chat replies now list the envs that reach them (keys and descriptions,
never values), so a bot no longer asks for keys that are set or "checks"
them with show_secret or printenv. request_secret refuses a key that's
already set unless the user asked to replace it, and search_computers
says envs aren't loaded in its shell.

start_task and delegate_task results say "Run #N" and that list_tasks
doesn't show it, since bots sent users to the to-do list for queue jobs.
start_task refuses a second open run in the session on the same
computer unless it's separate work, and says how many runs are ahead on
that computer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 19:33:45 +01:00
226 changed files with 4872 additions and 2312 deletions
+22 -15
View File
@@ -54,13 +54,13 @@ Enables SQLite foreign keys (`enableForeignKeys`), `bunextInit()`, `ensureDocker
### Auth ### Auth
- **Guard:** `src/pages/__root.server.ts` on every request. No public pages: anything outside `AdminSections` (`src/dict/admin-sections-dict.ts`) and `/auth` redirects to `/admin/bots`. No users -> `/auth/signup` (first-run super admin only); unauthenticated `/admin/*` -> `/auth/login`; authenticated `/auth/*` (not logout) -> `/admin/bots`; unknown `/admin/settings/*` -> `/admin/settings/general`. - **Guard:** `src/pages/__root.server.ts` on every request. No public pages: anything outside `AdminSections` (`src/dict/admin-sections-dict.ts`) and `/auth` redirects to `/admin/bots`. No users -> `/auth/signup` (first-run super admin only, which then opens the skippable guided setup `/admin/settings/setup`: app name, provider, models, where to go next; it stays under Settings); unauthenticated `/admin/*` -> `/auth/login`; authenticated `/auth/*` (not logout) -> `/admin/bots`; unknown `/admin/settings/*` -> `/admin/settings/general`.
- **Session:** AES-256-GCM cookie (key from `ENCRYPTION_KEY`/`ENCRYPTION_SALT`) plus CSRF cookie, `Secure` only over HTTPS (`isSecureRequest`, honours `X-Forwarded-Proto`). `userAuth` (`src/functions/backend/auth/user-auth.ts`) checks expiry (60 days), CSRF (constant-time) and re-reads `user_types` every request. - **Session:** AES-256-GCM cookie (key from `ENCRYPTION_KEY`/`ENCRYPTION_SALT`) plus CSRF cookie, `Secure` only over HTTPS (`isSecureRequest`, honours `X-Forwarded-Proto`). `userAuth` (`src/functions/backend/auth/user-auth.ts`) checks expiry (60 days), CSRF (constant-time) and re-reads `user_types` every request.
- **Login:** email/username + password (`api/auth/password-login.ts`, rate limited). No emailed codes or resets. Google login only for existing accounts, when `GOOGLE_CLIENT_ID` is set. Sessions issued by `login-user.ts`. - **Login:** email/username + password (`api/auth/password-login.ts`, rate limited). No emailed codes or resets. Google login only for existing accounts, when `GOOGLE_CLIENT_ID` is set. Sessions issued by `login-user.ts`.
- **Passwords:** bcrypt in `user_passwords` (never in `users`; blocked from CRUD via `src/dict/private-tables.ts`). - **Passwords:** bcrypt in `user_passwords` (never in `users`; blocked from CRUD via `src/dict/private-tables.ts`).
- **User types** (`src/dict/user-types-dict.ts`, the only definition): `super_admin` (user #1), `admin` (only the super admin grants it), `member` (default), `read_only` (non-GET CRUD blocked), `revoked`. A user needs at least one type to sign in. - **User types** (`src/dict/user-types-dict.ts`, the only definition): `super_admin` (user #1), `admin` (only the super admin grants it), `member` (default), `read_only` (non-GET CRUD blocked), `revoked`. A user needs at least one type to sign in.
- **RBAC:** `src/utils/check-user-access.ts` plus the matrix engine in `src/utils/rbac/` (`userTypeAssignment` over `UserTypeAssignmentMatrix`/`UserTypeDeletionMatrix`). - **RBAC:** `src/utils/check-user-access.ts` plus the matrix engine in `src/utils/rbac/` (`userTypeAssignment` over `UserTypeAssignmentMatrix`/`UserTypeDeletionMatrix`).
- **Audit:** `src/functions/backend/logger.ts` (`logEvent`/`logError` -> `logs`). Auth events, cron runs, every unexpected API error (`safeErrorMessage` -> `logUnexpectedError`, not for `AppError`), every failed or lost bot job. WebSocket connects are not logged. - **Audit:** `src/functions/backend/logger.ts` (`logEvent`/`logError` -> `logs`). Auth events, cron runs, every unexpected API error (`safeErrorMessage` -> `logUnexpectedError`, not for `AppError`), every failed or lost bot job. WebSocket connects are not logged. Kept for the app setting `logs_keep_days` (default `AppData.LogsKeepDays`), deleted by the daily `prune-logs`.
### WebSocket ### WebSocket
@@ -68,7 +68,7 @@ Enables SQLite foreign keys (`enableForeignKeys`), `bunextInit()`, `ensureDocker
### Cron ### Cron
`src/cron/index.ts` registers everything via `registerCronJob` (`src/cron/register-cron-job.ts`); one folder per task. In-process `Bun.cron`, UTC, never overlapping. Notable: `heartbeat`, `sync-harness-models` (hourly), `run-cron-jobs` (every minute, fires schedules), `sync-computers`, `sync-mcp-servers`, `prune-bots-queue`, `prune-chatrooms`. `src/cron/index.ts` registers everything via `registerCronJob` (`src/cron/register-cron-job.ts`); one folder per task. In-process `Bun.cron`, UTC, never overlapping. Notable: `heartbeat`, `sync-harness-models` (hourly), `run-cron-jobs` (every minute, fires schedules), `sync-computers`, `sync-mcp-servers`, `clear-job-steps`, `prune-bots-queue`, `prune-chatrooms`, `prune-logs`.
### Admin CRUD API ### Admin CRUD API
@@ -88,16 +88,18 @@ Enables SQLite foreign keys (`enableForeignKeys`), `bunextInit()`, `ensureDocker
### Bots, sessions, chat ### Bots, sessions, chat
- **Tables:** `bots` (`kind`, `color` `#rrggbb`, `stone`, `role`, `system_prompt`; `hue` is a legacy fallback), `bot_user_faces`, `bot_sessions`, `bot_messages`, `bot_tasks`, `bot_task_steps` (unused yet). Enums in `src/dict/bot-dict.ts`. - **Tables:** `bots` (`kind`, `color` `#rrggbb`, `stone`, `role`, `system_prompt`; `hue` is a legacy fallback), `bot_user_faces`, `bot_sessions`, `bot_messages`, `bot_tasks`, `bot_task_steps` (unused yet). Enums in `src/dict/bot-dict.ts`.
- **Sessions:** one thread per user per bot in its own chat plus one per workspace; split into `bot_sessions` (rollover at the app setting `bot_session_max_messages`, default `AppData.BotSessionMaxMessages`, via `grabBotSessionMaxMessages`; titled and summarised by `name-bot-session.ts` from the session's opening (`grabSessionOpening`; `grabBotMessages` reads the latest), summary carried into the next session only when they're linked by `previous_session_id`, which only a carry-over sets; older implicit links are cleared at boot by `clearImplicitSessionLinks`). New session (`startBotSession`) and a conversation with no active session always start fresh. The rollover carries over like the bot does (`rollOverBotSession` -> `continueBotSession`, falling back to the stored summary; kept for the next message while a job is open in the full session or there's nothing to carry), inside send-message, so the user's message lands under the summary. Helpers in `src/functions/backend/db/bot-sessions/`. Job results land in the job's own session, even after it closes, except a reply that called `continue_in_new_session` (`carryOverBotSession`; the menu's and sessions modal's Continue in new session does the same through `continueBotSession`, the model writing title and summary, refused while a job is open in the session): its summary is kept on the closed session and opens the new one as a bot message (`carried_from_session_id`), the job's `session_id` moves so the answer follows, the new session is titled by the bot then (it has no first exchange to be named from), and the old one can never be reopened (`reopenBotSession` refuses; a thread showing it swaps its composer for a link to the new one). Session lists (rail, sessions modal) reopen a session on click, as before; a carried-over one can't be, so it opens to read (`?session_id=`, `conversationHref`). The page tells them which session it shows (`announceShownSession` -> `useShownSession`): they reload, mark it Current (greyed out; without `?session_id=` the active one is), list it even when it's older than the rail's latest, and the active row then leads back. From half the session length the reply prompt suggests moving on (`longSessionNote`). Earlier sessions never enter the prompt (only the last one's summary): the thread can open them above a session (`useOlderMessages.loadPrevious`, a divider per session, quote and reply as usual), and replies get `search_past_sessions`/`read_past_session` (scoped to that user, bot and place, never the current session) when the conversation has any. - **Sessions:** one thread per user per bot in its own chat plus one per workspace; split into `bot_sessions` (rollover at the app setting `bot_session_max_messages`, default `AppData.BotSessionMaxMessages`, via `grabBotSessionMaxMessages`; titled and summarised by `name-bot-session.ts` from the session's opening after a reply or a task (a session with no user message, work handed to the bot, is named from the bot's own messages; `grabSessionOpening`; `grabBotMessages` reads the latest), summary carried into the next session only when they're linked by `previous_session_id`, which only a carry-over sets; older implicit links are cleared at boot by `clearImplicitSessionLinks`). New session (`startBotSession`) and a conversation with no active session always start fresh. The rollover carries over like the bot does (`rollOverBotSession` -> `continueBotSession`, falling back to the stored summary; kept for the next message while a job is open in the full session or there's nothing to carry), inside send-message, so the user's message lands under the summary. Helpers in `src/functions/backend/db/bot-sessions/`. Job results land in the job's own session, even after it closes, except a reply that called `continue_in_new_session` (`carryOverBotSession`; the menu's and sessions modal's Continue in new session does the same through `continueBotSession`, the model writing title and summary, refused while a job is open in the session): its summary is kept on the closed session and opens the new one as a bot message (`carried_from_session_id`), the job's `session_id` moves so the answer follows, the new session is titled by the bot then (it has no first exchange to be named from), and the old one can never be reopened (`reopenBotSession` refuses; a thread showing it swaps its composer for a link to the new one). Session lists (rail, sessions modal) reopen a session on click, as before; a carried-over one can't be, so it opens to read (`?session_id=`, `conversationHref`). The page tells them which session it shows (`announceShownSession` -> `useShownSession`): they reload, mark it Current (greyed out; without `?session_id=` the active one is), list it even when it's older than the rail's latest, and the active row then leads back. From half the session length the reply prompt suggests moving on (`longSessionNote`). Earlier sessions never enter the prompt (only the last one's summary): the thread can open them above a session (`useOlderMessages.loadPrevious`, a divider per session, quote and reply as usual), and replies get `search_past_sessions`/`read_past_session` (scoped to that user, bot and place, never the current session) when the conversation has any.
- **Coordinator:** exactly one, the super admin's alone, created idempotently by `ensureCoordinatorBot`. Default prompt `src/data/coordinator-system-prompt.ts`, backfilled only when empty. - **Coordinator:** exactly one, the super admin's alone, created idempotently by `ensureCoordinatorBot`. Default prompt `src/data/coordinator-system-prompt.ts`, backfilled only when empty.
- **Routes:** `/admin/bots` reopens the last visited bot (cookie `cobble_last_visited_bot_id`, never a workspace conversation) if still reachable, else coordinator (super admin) / latest bot / first workspace / empty. `/admin/bots/[bot_id]` conversation (header, thread and composer at 760px, no right panel; `?session_id=` opens another of the user's sessions there: a closed one is reopened, except a carried-over one, which shows with `SessionMovedBar` instead of the composer, pointing where the conversation went on); `/admin/bots/[bot_id]/models`; `/admin/workspaces/[id]` dashboard; `/admin/workspaces/[id]/chatrooms` that workspace's chatrooms; `/admin/workspaces/[id]/bots/[bot_id]` same conversation scoped to the workspace. - **Routes:** `/admin/bots` reopens the last visited bot (cookie `cobble_last_visited_bot_id`, never a workspace conversation) if still reachable, else coordinator (super admin) / latest bot / first workspace / empty. `/admin/bots/[bot_id]` conversation (header, thread and composer at 760px, no right panel; `?session_id=` opens another of the user's sessions there: a closed one is reopened, except a carried-over one, which shows with `SessionMovedBar` instead of the composer, pointing where the conversation went on); `/admin/bots/[bot_id]/models`; `/admin/workspaces/[id]` dashboard; `/admin/workspaces/[id]/chatrooms` that workspace's chatrooms; `/admin/workspaces/[id]/bots/[bot_id]` same conversation scoped to the workspace.
- **Conversation loading:** pages render everything server-side (`grabConversationProps`) **except the history**, which the client loads (`api/admin/bots/conversation-history`, merged with what's already shown, refetched on bfcache restore) and pages older messages on scroll. Rail lists go through `railBots`/`railWorkspaces` (no prompts/memory, previews cut). While a reply is due the page polls `session-messages` (`use-reply-catch-up`) in case a push was missed. - **No model:** while the bot has no text model there (`bot_text_model` null, kept live by the models modal), the thread shows `NoModelNotice` (centred when empty): super admins get Connect a provider / Guided setup, others are told the super admin sets it up.
- **Conversation loading:** pages render everything server-side (`grabConversationProps`) **except the history**, which the client loads (`api/admin/bots/conversation-history`, merged with what's already shown, refetched on bfcache restore) and pages older messages on scroll. Rail lists go through `railBots`/`railWorkspaces` (no prompts/memory, previews cut). While a reply is due (read with the live face, never the page's) the page polls `session-messages` (`use-reply-catch-up`, also at once when the page comes back into view or online) in case a push was missed, replaying the messages and the bot's face and task as their pushes.
- **Sending:** `api/admin/bots/send-message.ts` -> `enqueueBotReply` -> `wakeBotsQueue`. **Never import `src/bots-queue/index.ts` from a page or API route** (pulls the bunext runtime into page bundles). Optimistic bubble in `pending_messages` until the saved message replaces it. - **Sending:** `api/admin/bots/send-message.ts` -> `enqueueBotReply` -> `wakeBotsQueue`. **Never import `src/bots-queue/index.ts` from a page or API route** (pulls the bunext runtime into page bundles). Optimistic bubble in `pending_messages` until the saved message replaces it.
- **Composer:** Tiptap editor reading/writing markdown, drafts in `localStorage`, `@` bots / `#` workspaces written as `[bot:ID]`/`[workspace:ID]`. Enter sends (Mod+Enter in lists/code). `@tiptap/react` needs `use-sync-external-store`; the app ships an ESM stand-in in `packages/use-sync-external-store-esm` (file dependency + override; the Dockerfile copies `packages/` before `bun install`). - **Composer:** Tiptap editor reading/writing markdown, drafts in `localStorage`, `@` bots / `#` workspaces written as `[bot:ID]`/`[workspace:ID]`. Enter sends (Mod+Enter in lists/code). `@tiptap/react` needs `use-sync-external-store`; the app ships an ESM stand-in in `packages/use-sync-external-store-esm` (file dependency + override; the Dockerfile copies `packages/` before `bun install`).
- **Rendering:** markdown via `react-markdown` + `remark-gfm`, raw HTML never rendered, component map in `[bot_id]/(data)/message-markdown-components.tsx`. Inline refs, all linked by `link-computer-refs.ts` and rendered by chips in `(partials)/`: `[bot:ID]`, `[workspace:ID]`, `[computer:ID]`, `[file:ID]` (a saved file), `[folder:<volume>:<owner>:<path>]` (opens `/admin/files`), `[take-over:ID:mode]`, `[proposal:ID]`, `[hook-proposal:ID]`, `[secret:ID]` (masked env, Reveal/Copy), `[secret-request:ID]` (masked input card), `[graphic:ID]` (a bot graphic, framed inline), `[room:ID]` (a chatroom). Parsers in `src/utils/*-ref.ts`. A ref quoted alone in a code span still links; one inside longer code stays literal (`linkComputerRefs` splits out code first). A file chip opens a file from another bot's folder for a user who reaches that folder (`userReachesFileScope`, the same rule as its bytes), so answers passed on from chatrooms keep working. - **Rendering:** markdown via `react-markdown` + `remark-gfm`, raw HTML never rendered, component map in `[bot_id]/(data)/message-markdown-components.tsx`. Inline refs, all linked by `link-computer-refs.ts` and rendered by chips in `(partials)/`: `[bot:ID]`, `[workspace:ID]`, `[computer:ID]`, `[file:ID]` (a saved file), `[folder:<volume>:<owner>:<path>]` (opens `/admin/files`), `[take-over:ID:mode]`, `[proposal:ID]`, `[hook-proposal:ID]`, `[secret:ID]` (masked env, Reveal/Copy), `[secret-request:ID]` (masked input card), `[question:ID]` (a bot's questions), `[graphic:ID]` (a bot graphic, framed inline), `[room:ID]` (a chatroom). Parsers in `src/utils/*-ref.ts`. A ref quoted alone in a code span still links; one inside longer code stays literal (`linkComputerRefs` splits out code first). A file chip opens a file from another bot's folder for a user who reaches that folder (`userReachesFileScope`, the same rule as its bytes), so answers passed on from chatrooms keep working.
- **Copy chat** (header menu): the active session (the one on screen) as one markdown document (`api/admin/bots/conversation-export` -> `conversationMarkdown`, refs as names, message headings demoted). The clipboard write starts on the click with a pending `ClipboardItem` (`copyPendingText`), since Safari drops the gesture after a fetch. - **Copy chat** (header menu): the active session (the one on screen) as one markdown document (`api/admin/bots/conversation-export` -> `conversationMarkdown`, refs as names, message headings demoted). The clipboard write starts on the click with a pending `ClipboardItem` (`copyPendingText`), since Safari drops the gesture after a fetch.
- **Asking before acting:** `ask_user` (`QuestionsNote`) puts up to `AppData.BotQuestionMaxQuestions` multiple-choice questions (`single` = radios, `multiple` = checkboxes, optional typed "Other") on one card, once per run -> `bot_questions` -> `[question:ID]` chip opening `BotQuestionCard`; the message needs the user. Answers are checked against the questions (`checkBotQuestionAnswers`) and stored, then sent as the user's chat message (`botQuestionAnswersText`); Skip says so. Replies and user-started tasks only: never routines, hook calls or chatroom sessions (no one sees those).
- **Replies/reactions:** `reply_to_message_id` (not an FK); reactions from the fixed `BotMessageReactions`, toggled by `react-message.ts` and the `react_to_message` tool, pushed as `server:bot-reaction`. - **Replies/reactions:** `reply_to_message_id` (not an FK); reactions from the fixed `BotMessageReactions`, toggled by `react-message.ts` and the `react_to_message` tool, pushed as `server:bot-reaction`.
- **Attachments:** uploaded on pick (`upload-attachment.ts`, private `media`, text extracted when possible), linked via `bot_message_media`. Replies read files of the last `BotReplyAttachmentMessages` messages. Tasks don't get files yet. - **Attachments:** uploaded on pick (`upload-attachment.ts`, private `media`, no text kept: a reply reads a document's text from the file), linked via `bot_message_media`. Replies read files of the last `BotReplyAttachmentMessages` messages. Tasks don't get files yet.
- **Thinking row / stop:** `bot-busy-state.ts` decides the row's line and whether Stop shows. Stop (`api/admin/bots/stop.ts`, `stop-bot-jobs`) fails every open job of that bot for the user, aborts the running handler (`job-abort-registry`) and drops late face updates. - **Thinking row / stop:** `bot-busy-state.ts` decides the row's line and whether Stop shows. Stop (`api/admin/bots/stop.ts`, `stop-bot-jobs`) fails every open job of that bot for the user, aborts the running handler (`job-abort-registry`) and drops late face updates.
- **Voice:** mic -> `api/admin/bots/transcribe.ts`; play -> `GET speak-message` streamed into `<audio>`. Nothing recorded is stored. Both disabled with a note when the harness has no model. - **Voice:** mic -> `api/admin/bots/transcribe.ts`; play -> `GET speak-message` streamed into `<audio>`. Nothing recorded is stored. Both disabled with a note when the harness has no model.
- **Pictures:** `image_media_id` (private media, `bot-image`/`workspace-image`), via `set-profile-image.ts`, the `set_picture` tool or `update_workspace`. Bytes are **copied**, never moved; only picture-paradigm rows are deleted on replace. Format kept by `imageOutputFormat`. - **Pictures:** `image_media_id` (private media, `bot-image`/`workspace-image`), via `set-profile-image.ts`, the `set_picture` tool or `update_workspace`. Bytes are **copied**, never moved; only picture-paradigm rows are deleted on replace. Format kept by `imageOutputFormat`.
@@ -113,8 +115,9 @@ Enables SQLite foreign keys (`enableForeignKeys`), `bunextInit()`, `ensureDocker
- Kinds (`BotJobKinds`): `reply` (chat answer, no task row) and `task` (long work, usually on a computer). bun-sqlite `isQueue` table. Helpers in `src/functions/backend/db/bots-queue/`: `enqueueBotJob`, `claimBotJobs` (kind-filtered atomic claim), `updateBotJob` (false once lost/cancelled), `completeBotJob`, `failBotJob` (backoff or fatal), `deferBotJob` (all models resting: attempt refunded, face `asleep`), `cancelBotJob`, `stop-bot-jobs`. - Kinds (`BotJobKinds`): `reply` (chat answer, no task row) and `task` (long work, usually on a computer). bun-sqlite `isQueue` table. Helpers in `src/functions/backend/db/bots-queue/`: `enqueueBotJob`, `claimBotJobs` (kind-filtered atomic claim), `updateBotJob` (false once lost/cancelled), `completeBotJob`, `failBotJob` (backoff or fatal), `deferBotJob` (all models resting: attempt refunded, face `asleep`), `cancelBotJob`, `stop-bot-jobs`.
- Handler `src/bots-queue/index.ts`: separate reply/task slots, heartbeat, per-attempt deadline (`AppData.BotJobAttemptTimeoutMs`), one task per computer and one reply per session (`createKeyLock`). Work in `run-bot-job.ts` -> `run-bot-reply.ts` / `run-bot-task.ts`. Throw `BotJobFatalError` for failures retrying can't fix. - Handler `src/bots-queue/index.ts`: separate reply/task slots, heartbeat, per-attempt deadline (`AppData.BotJobAttemptTimeoutMs`), one task per computer and one reply per session (`createKeyLock`). Work in `run-bot-job.ts` -> `run-bot-reply.ts` / `run-bot-task.ts`. Throw `BotJobFatalError` for failures retrying can't fix.
- Every model turn is a step in `bots_queue_updates` (tool names, never arguments; `describe-job-step.ts`, labels in `JobStepToolLabels`), pushed as `server:bot-job-update`. - Every model turn is a step in `bots_queue_updates` (tool names, never arguments; `describe-job-step.ts`, labels in `JobStepToolLabels`), pushed as `server:bot-job-update` by the live relay only. Steps feed the live working row alone: the `clear-job-steps` cron deletes them `AppData.BotJobStepsKeepMs` after the job ends, however it ended, keeping their count on `bots_queue.steps`. Never delete them the moment a job ends (the relay would miss the last ones).
- **A bot can't vouch for itself:** a run's tool calls (`tool_uses`, names and ok, never arguments) go to the wrap-up turn when steps run out and onto the result message as `bot_messages.tool_log`; history follows each recorded bot message with an `[App record: ...]` line (`botMessagesToHarness`), and copies of that line in an answer are stripped (`stripAppRecords`). - **A bot can't vouch for itself:** a run's tool calls (`tool_uses`, names and ok, never arguments) go to the wrap-up turn when steps run out and onto the result message as `bot_messages.tool_log`; history follows each recorded bot message with an `[App record: ...]` line (`botMessagesToHarness`), and copies of that line in an answer are stripped (`stripAppRecords`).
- **`start_task`/`delegate_task` jobs are runs, not tasks:** their results say `Run #<job id>` and that `list_tasks` doesn't show them (bots sent users to the tracker for them). `start_task` refuses a second open run in its session on the same computer unless `separate` (it would only wait behind it) and says how many runs are ahead on that computer.
- **Replies take in messages sent mid-run** (`incoming`, `IncomingMessagesNote`, redraft); separate requests are acknowledged with `send_message` (interim). A queued reply finishes silently only when a finished reply already read every user message (`hasUnansweredMessage`), never by who wrote last. - **Replies take in messages sent mid-run** (`incoming`, `IncomingMessagesNote`, redraft); separate requests are acknowledged with `send_message` (interim). A queued reply finishes silently only when a finished reply already read every user message (`hasUnansweredMessage`), never by who wrote last.
### Live updates ### Live updates
@@ -136,7 +139,8 @@ A worker reaches another worker **only through the coordinator**: `ask_crew` (ev
- **Browser profile:** `DATA_DIR/computer-browsers/<id>` mounted at `~/.cobble-browser`, which Chrome is pointed at (`BROWSER_EXTRA_ARGS=--user-data-dir=... --password-store=basic`), so logins, extensions and site permissions survive restarts and recreates. Never use the image's `~/.config/browser`: every boot overwrites its `Default/Preferences` with session-only cookies. Seed prefs (`computer-browser-preferences.ts`) are written only when missing; stale `Singleton*` links are removed before each provision. Bumping `AppData.ComputerWorkspaceLayout` recreates every computer once (`migrateComputerLayout`, skipped while a job runs on it). Default extensions (`ComputerBrowserExtensions`, `normal_installed`: can be turned off, not removed) come from a managed policy written as root into the running container (`syncComputerBrowserPolicy`, on provision and every `sync-computers` run), not a mount, so existing computers get new ones without a recreate; Chrome installs them live. Deleting a computer keeps its directory. Outside docker (dev) containers are reached by IP (`pickReachableAddress`). - **Browser profile:** `DATA_DIR/computer-browsers/<id>` mounted at `~/.cobble-browser`, which Chrome is pointed at (`BROWSER_EXTRA_ARGS=--user-data-dir=... --password-store=basic`), so logins, extensions and site permissions survive restarts and recreates. Never use the image's `~/.config/browser`: every boot overwrites its `Default/Preferences` with session-only cookies. Seed prefs (`computer-browser-preferences.ts`) are written only when missing; stale `Singleton*` links are removed before each provision. Bumping `AppData.ComputerWorkspaceLayout` recreates every computer once (`migrateComputerLayout`, skipped while a job runs on it). Default extensions (`ComputerBrowserExtensions`, `normal_installed`: can be turned off, not removed) come from a managed policy written as root into the running container (`syncComputerBrowserPolicy`, on provision and every `sync-computers` run), not a mount, so existing computers get new ones without a recreate; Chrome installs them live. Deleting a computer keeps its directory. Outside docker (dev) containers are reached by IP (`pickReachableAddress`).
- One main computer (`ensureMainComputer`), whose coordinator grant is locked (`isLockedComputerAccess`). With `MAIN_COMPUTER_URL` set (dev) it isn't provisioned. Only super admins create/delete/retry computers. - One main computer (`ensureMainComputer`), whose coordinator grant is locked (`isLockedComputerAccess`). With `MAIN_COMPUTER_URL` set (dev) it isn't provisioned. Only super admins create/delete/retry computers.
- A bot's effective computers (`grab-bot-computers`): the workspace's in a workspace; otherwise direct grants plus its workspaces' (coordinator: all); `bots.computer_id` first. Only a `running` (healthy) computer is reached (`grabComputerMcpUrl` `ready`, `computerNotReadyMessage`). - A bot's effective computers (`grab-bot-computers`): the workspace's in a workspace; otherwise direct grants plus its workspaces' (coordinator: all); `bots.computer_id` first. Only a `running` (healthy) computer is reached (`grabComputerMcpUrl` `ready`, `computerNotReadyMessage`).
- **Folders (enforced in tools, not a Linux user):** a run works only in `bots/<bot>/users/<user>` (cwd, `HOME`), `bots/<bot>` and `workspaces/<workspace>` (`computerRunDirs`, `withComputerDirs`, `resolveComputerPath`; `sudo` refused). - **Folders (enforced in tools, not a Linux user):** a run works only in `bots/<bot>/users/<user>` (cwd, `HOME`), `bots/<bot>` and `workspaces/<workspace>` (`computerRunDirs`, `withComputerDirs`, `resolveComputerPath`; the file tools' `sudo` refused; bash runs as `gem`, who has sudo, so packages can be installed, and they're lost on a restart).
- **Bash on a computer:** the sandbox ignores `sandbox_execute_bash`'s `timeout` and runs every call in one shared shell session, so a command that never ends blocked every later one on that computer, from any run, and Stop never killed it. `withCommandTimeout` runs each in its own `bash -c` under coreutils `timeout` (at most `AppData.ComputerCommandMaxSeconds`, below `McpToolTimeoutMs`), stdin from `/dev/null`. Never `exit` in a wrapper (it would kill the session shell).
- **Viewing:** `/computers/<id>/` proxy (`handleComputerProxy`, strips app cookies, bridges websockets). `ComputerView` modes: browser, terminal, code, desktop (noVNC; the only one with Chrome's toolbar). **Take-over:** `ask_user_to_take_over` writes `[take-over:ID:mode]`; "Hand back" sends a chat message; tasks stop and report rather than wait. - **Viewing:** `/computers/<id>/` proxy (`handleComputerProxy`, strips app cookies, bridges websockets). `ComputerView` modes: browser, terminal, code, desktop (noVNC; the only one with Chrome's toolbar). **Take-over:** `ask_user_to_take_over` writes `[take-over:ID:mode]`; "Hand back" sends a chat message; tasks stop and report rather than wait.
### Schedules (routines in the UI) ### Schedules (routines in the UI)
@@ -151,13 +155,13 @@ A secret URL, `POST /api/hooks/<token>` (`src/pages/api/hooks/[token].ts`, publi
### Tasks (the to-do tracker) ### Tasks (the to-do tracker)
`tasks` (a bot's own list is `bot_id` alone; a workspace's task has `workspace_id` plus the `bot_id` that has it there, null while shared; a bot reaches its own list and its own plus shared tasks in its workspaces, a workspace admin all of that workspace's, `botTaskScopesSql`; a shared task becomes the bot's when it starts it; removing or deleting a bot makes its workspace tasks shared again), `task_notes`, `task_media` (deliverable|reference). Not the same as `bot_tasks`/`bots_queue` (those record jobs). Visibility SQL in `src/utils/task-access/`. Helpers in `src/functions/backend/db/tasks/`; tools `list_tasks`/`create_task`/`update_task`; API `api/admin/tasks/` (`userAuth` -> `isReadOnlyUser` -> `assertUserReachesTask` -> `safeErrorMessage`). UI in the workspace dashboard and the header's Tasks modal (shared components in `src/components/general/`). **Hand-over:** `handOverTask` (bot: `delegate_task` with `task_id`; UI: Hand over, crew managers) moves an open task to the new bot (a workspace's stays in the workspace, now the new bot's, and is worked there), sets it doing and queues a job with `params.tracker_task_id`; `noteTrackerTask` notes the job's end on it and blocks it on failure. **Not built yet:** task steps, `server:task-changed` pushes, a `/admin/tasks` page. `tasks` (**every task belongs to a bot**, the coordinator included, never shared: a bot's own list is `bot_id` alone, a workspace's task has `workspace_id` plus the `bot_id` that has it there, enforced by `taskScopeValues`; a worker reaches only its own, a workspace admin all of that workspace's, the coordinator all, `botTaskScopesSql`; a bot removed from a workspace or deleted passes its tasks there to the workspace's admin bot, else the coordinator (`passOnBotTasks`, also run at boot for rows without a bot)), `task_notes`, `task_media` (deliverable|reference). Not the same as `bot_tasks`/`bots_queue` (those record jobs). Visibility SQL in `src/utils/task-access/`. Helpers in `src/functions/backend/db/tasks/`; tools `list_tasks`/`create_task`/`update_task`; API `api/admin/tasks/` (`userAuth` -> `isReadOnlyUser` -> `assertUserReachesTask` -> `safeErrorMessage`). UI in the workspace dashboard and the header's Tasks modal (shared components in `src/components/general/`); a card's title opens `TaskDetailModal` with everything about the task and its status moves (a modal over the list, never swapped in place). **Hand-over:** `handOverTask` (bot: `delegate_task` with `task_id`; UI: Hand over, crew managers) moves an open task to the new bot (a workspace's stays in the workspace, now the new bot's, and is worked there), sets it doing and queues a job with `params.tracker_task_id`; `noteTrackerTask` notes the job's end on it and blocks it on failure. **Not built yet:** task steps, `server:task-changed` pushes, a `/admin/tasks` page.
### Envs ### Envs
Set in Settings > Environment (super admin), not `.env`. `envs` (private; encrypted `value`; `paradigm` general|workspace|computer|bot|user; one per key and scope, enforced in `saveEnv`). Cached per process. App settings (`EnvDict` in `src/dict/env-dict.ts`) via `grabEnv` (general value, else `process.env`); `ProcessOnlyEnvKeys` can't be saved. Precedence for runs: user > bot > computer > workspace > general (`resolveScopedEnvs`, `grabRunEnvs`; pass the run's `user_id`). Written to `~/.cobble/env` and `env.json` on the computer each run; bots see keys and descriptions, never values; every computer tool result goes through `redactSecrets`. Set in Settings > Environment (super admin), not `.env`. `envs` (private; encrypted `value`; `paradigm` general|workspace|computer|bot|user; one per key and scope, enforced in `saveEnv`). Cached per process. App settings (`EnvDict` in `src/dict/env-dict.ts`) via `grabEnv` (general value, else `process.env`); `ProcessOnlyEnvKeys` can't be saved. Precedence for runs: user > bot > computer > workspace > general (`resolveScopedEnvs`, `grabRunEnvs`; pass the run's `user_id`). Written to `~/.cobble/env` and `env.json` on the computer each task run (only task tools load them; `search_computers` doesn't); bots see keys and descriptions, never values (tasks in their prompt, replies through `composeEnvsNote` from `grabReachableEnvs`, so they never ask for or test a key that's set); every computer tool result goes through `redactSecrets`.
**Secrets never pass through the model.** A `user` env is one user's secret for one bot (`saveUserSecret`, reach-checked), set from the chat, listed on Settings > Secrets (own) and Environment (everyone's, remove only). In: `request_secret` -> `secret_requests` -> `[secret-request:ID]` card (masked input, `answer-request`), then a chat message naming only the key. Out: `capture_secret` (task computer tool: a file via `/v1/file/download` or a command's output, saved straight to a `user` env and added to the run's redaction list) and `show_secret` (an env by key) give `[secret:ID]`; the chip fetches the value only on click (`api/admin/secrets/reveal-secret`, `canRevealEnv`: a personal one only its own user, anything else only a super admin). Never put a value in a message, page prop, log or tool result. `SecretsNote` tells bots never to ask for secrets in chat. **Secrets never pass through the model.** A `user` env is one user's secret for one bot (`saveUserSecret`, reach-checked), set from the chat, listed on Settings > Secrets (own) and Environment (everyone's, remove only). In: `request_secret` (refused for a key that already reaches the run, unless the user asked to `replace` it) -> `secret_requests` -> `[secret-request:ID]` card (masked input, `answer-request`), then a chat message naming only the key. Out: `capture_secret` (task computer tool: a file via `/v1/file/download` or a command's output, saved straight to a `user` env and added to the run's redaction list) and `show_secret` (an env by key) give `[secret:ID]`; the chip fetches the value only on click (`api/admin/secrets/reveal-secret`, `canRevealEnv`: a personal one only its own user, anything else only a super admin). Never put a value in a message, page prop, log or tool result. `SecretsNote` tells bots never to ask for secrets in chat.
### PWA and notifications ### PWA and notifications
@@ -175,7 +179,7 @@ Set in Settings > Environment (super admin), not `.env`. `envs` (private; encryp
- Files live on the data volume, not on computers: `DATA_DIR/bots/<id>` and `DATA_DIR/workspaces/<id>`. Catalog `bot_files`, bytes in private `media` (`bot-file`) written by `recordBotFile`. Reach via `grabStorageScopes`; paths via `resolveBotPath` (no absolute, `..`, NUL, symlink escape); quota checked by `assertStorageRoom`. - Files live on the data volume, not on computers: `DATA_DIR/bots/<id>` and `DATA_DIR/workspaces/<id>`. Catalog `bot_files`, bytes in private `media` (`bot-file`) written by `recordBotFile`. Reach via `grabStorageScopes`; paths via `resolveBotPath` (no absolute, `..`, NUL, symlink escape); quota checked by `assertStorageRoom`.
- Tools in `src/harness/tools/storage/` (every run). `attach` puts a file on the reply. `grabConversationFiles` must list the **whole** folder tree, or nested `[file:ID]` refs render dead. A workspace run saves to the bot's own folder unless it picks the workspace's, so a chip in a workspace conversation looks up both (`api/admin/bots/files?file_id=` with `bot_id` and `workspace_id`). - Tools in `src/harness/tools/storage/` (every run). `attach` puts a file on the reply. `grabConversationFiles` must list the **whole** folder tree, or nested `[file:ID]` refs render dead. A workspace run saves to the bot's own folder unless it picks the workspace's, so a chip in a workspace conversation looks up both (`api/admin/bots/files?file_id=` with `bot_id` and `workspace_id`).
- `import_file` copies from a computer through the sandbox's streaming `GET /v1/file/download`, **never through its shell** (`/v1/shell/exec` caps output at ~30K chars and silently truncated imports). Size read first, `change_policy=abort`, length checked. - `import_file` copies from a computer through the sandbox's streaming `GET /v1/file/download`, **never through its shell** (`/v1/shell/exec` caps output at ~30K chars and silently truncated imports). Size read first, `change_policy=abort`, length checked.
- Ingestion: chunks + embeddings into `bot_file_chunks` / `bot_file_vectors` (vec0), worked by `src/file-ingest/`; without an embedding model search falls back to words and says so. sqlite-vec limits: metadata columns are NOT NULL; side-car columns can't be filtered with `MATCH` (filter in code). **Never pass `--vector` to `bunx bun-sqlite schema` in `entrypoint.sh`** (wipes vectors on boot). Changing `AppData.EmbeddingDimensions` needs a manual `--vector` run and a re-ingest. - **Only knowledgebases store text.** Bot files and uploads are never indexed and keep no extracted text (`media.text_content` only for knowledgebase documents); bots find files by name or path (`list_files` `search`) and read them with `read_file`, and the Files searches match names only. Text that should be searchable goes in a knowledgebase. `dropStoredFileText` clears what older versions stored, at boot, and vacuums when much of the file is free.
- **Foreign keys are off per connection** unless `enableForeignKeys` ran (only `src/server.ts` does). Scripts never fire `ON DELETE CASCADE`, so **any helper that deletes a parent row must delete its children explicitly** (as `deleteBotFile`, `purgeStorage`, `deleteSkill` do). A leftover child fails the next `bun run db:schema` with a foreign key violation. - **Foreign keys are off per connection** unless `enableForeignKeys` ran (only `src/server.ts` does). Scripts never fire `ON DELETE CASCADE`, so **any helper that deletes a parent row must delete its children explicitly** (as `deleteBotFile`, `purgeStorage`, `deleteSkill` do). A leftover child fails the next `bun run db:schema` with a foreign key violation.
### Graphics (`bot_graphics`) ### Graphics (`bot_graphics`)
@@ -186,13 +190,16 @@ A bot shows a concept as a visual with `create_graphic` (plus `update_graphic`/`
A fourth extension kind (`ExtensionTables.knowledgebase`): `knowledgebases` given per bot (`bot_knowledgebases`), per workspace (`workspace_knowledgebases`) or every bot (`all_bots`), through the same add/remove/access/default paths as skills (coordinator extension tools leave them out). Documents (`knowledgebase_documents`: an upload in private `media`, paradigm `knowledgebase-document`, or written `content`) are chunked into `knowledgebase_chunks` and embedded into the `knowledgebase_vectors` vec0 table by the file-ingest worker (`file_ingest_queue.document_id`, `ingestKnowledgebaseDocument`), always with the install's embedding model; old passages stay until the new ones are in, `embedding_model` records where vectors came from; whatever can change the install's model (default settings, a provider saved or deleted, the hourly catalog sync) calls `reindexAfterEmbeddingChange`, and Re-index reads a knowledgebase again by hand. Word search runs on `knowledgebase_chunks_fts`, an FTS5 table (`unicode61 remove_diacritics 2`) made by `ensureKnowledgebaseFts` (FTS5 can't be declared in the schema; sync leaves undeclared tables alone), backfilled at boot by `syncKnowledgebaseFts`, ranked by BM25 with a scored `LIKE` fallback for unspaced scripts (`knowledgebaseWordQuery`); every write and delete of `knowledgebase_chunks` updates it. Ingest jobs (`settleFailedIngestJob`) wait without spending an attempt while every embedding model rests, fail at once on an error retrying can't fix, and a document whose model refused keeps its new passages for word search (counted as needing re-index, not failed). Runs get `search_knowledgebase`/`read_knowledgebase_document` (`openBotKnowledgebases`, limited to the run's ones) and replies start with passages found for the unanswered messages (`grabKnowledgebaseContext`); passages are fenced (`knowledgebasePassageBlock`) and `KnowledgebasesNote` says they're never instructions. UI: Settings > Knowledgebases (super admin edits and fills; admins set access), a Knowledgebases group on bot/workspace settings, and the composer's own button beside Tools (`ComposerToolsModal` with `composerKnowledgebasesCopy`). Deleting one deletes its documents' vectors, chunks, media and links explicitly. A fourth extension kind (`ExtensionTables.knowledgebase`): `knowledgebases` given per bot (`bot_knowledgebases`), per workspace (`workspace_knowledgebases`) or every bot (`all_bots`), through the same add/remove/access/default paths as skills (coordinator extension tools leave them out). Documents (`knowledgebase_documents`: an upload in private `media`, paradigm `knowledgebase-document`, or written `content`) are chunked into `knowledgebase_chunks` and embedded into the `knowledgebase_vectors` vec0 table by the file-ingest worker (`file_ingest_queue.document_id`, `ingestKnowledgebaseDocument`), always with the install's embedding model; old passages stay until the new ones are in, `embedding_model` records where vectors came from; whatever can change the install's model (default settings, a provider saved or deleted, the hourly catalog sync) calls `reindexAfterEmbeddingChange`, and Re-index reads a knowledgebase again by hand. Word search runs on `knowledgebase_chunks_fts`, an FTS5 table (`unicode61 remove_diacritics 2`) made by `ensureKnowledgebaseFts` (FTS5 can't be declared in the schema; sync leaves undeclared tables alone), backfilled at boot by `syncKnowledgebaseFts`, ranked by BM25 with a scored `LIKE` fallback for unspaced scripts (`knowledgebaseWordQuery`); every write and delete of `knowledgebase_chunks` updates it. Ingest jobs (`settleFailedIngestJob`) wait without spending an attempt while every embedding model rests, fail at once on an error retrying can't fix, and a document whose model refused keeps its new passages for word search (counted as needing re-index, not failed). Runs get `search_knowledgebase`/`read_knowledgebase_document` (`openBotKnowledgebases`, limited to the run's ones) and replies start with passages found for the unanswered messages (`grabKnowledgebaseContext`); passages are fenced (`knowledgebasePassageBlock`) and `KnowledgebasesNote` says they're never instructions. UI: Settings > Knowledgebases (super admin edits and fills; admins set access), a Knowledgebases group on bot/workspace settings, and the composer's own button beside Tools (`ComposerToolsModal` with `composerKnowledgebasesCopy`). Deleting one deletes its documents' vectors, chunks, media and links explicitly.
sqlite-vec limits: metadata columns are NOT NULL; side-car columns can't be filtered with `MATCH` (filter in code). **Never pass `--vector` to `bunx bun-sqlite schema` in `entrypoint.sh`** (wipes vectors on boot). Changing `AppData.EmbeddingDimensions` needs a manual `--vector` run and a re-index.
### Files browser (`/admin/files`) ### Files browser (`/admin/files`)
Read-only browser of every volume (`src/utils/storage-volumes.ts`: bots, workspaces, computers, media, mcp, db). `resolveStorageSpec` is the only access gate (members see only their bots' and their own user folders inside a computer, `computerPathAllowed`); `resolveStoragePath` applies the path rules. Links built only by `storageSpecHref`. Deletion stays in the chat's files modal and bot tools. Read-only browser of every volume (`src/utils/storage-volumes.ts`: bots, workspaces, computers, media, mcp, db). `resolveStorageSpec` is the only access gate (members see only their bots' and their own user folders inside a computer, `computerPathAllowed`); `resolveStoragePath` applies the path rules. Links built only by `storageSpecHref`. Deletion stays in the chat's files modal and bot tools.
### Harness (the model layer, `src/harness/`) ### Harness (the model layer, `src/harness/`)
- **Harnesses** (`src/dict/harness-dict.ts`): `text` (always on), `vision`, `transcription`, `speech`, `image`, `embedding`. One `harness_settings` row each (provider, pinned model or automatic, `fallback`, `enabled`, `options`). Transcription, speech and embedding have no automatic picks: unavailable until a model is picked. - **Harnesses** (`src/dict/harness-dict.ts`): `text` (always on), `vision`, `transcription`, `speech`, `image`, `image_edit`, `embedding`. One `harness_settings` row each (provider, pinned model or automatic, `fallback`, `enabled`, `options`). Transcription, speech and embedding have no automatic picks: unavailable until a model is picked.
- **Images:** `generate_image` takes reference images (`references` as `[file:ID]`, `reference_media_ids` for uploads, checked like a picture source by `loadReferenceImages`). With references the call goes to `image_edit` (no automatic picks; `HarnessFallbacks` hands it to `image` while unpicked or off, and after its pick fails when `fallback` is on), and only models that take image input are tried (`orderImageCandidates`), never one that would drop them. OpenRouter takes references on `/images` (`input_references`), other providers as multipart on `image_edit_path` (`/images/edits`).
- **Providers** (`src/dict/harness-provider-dict.ts`): OpenRouter default plus OpenAI-compatible ones; one adapter today (`src/harness/providers/openai-compatible/`). Keys encrypted in private `harness_providers`, falling back to the preset env. - **Providers** (`src/dict/harness-provider-dict.ts`): OpenRouter default plus OpenAI-compatible ones; one adapter today (`src/harness/providers/openai-compatible/`). Keys encrypted in private `harness_providers`, falling back to the preset env.
- **Catalog:** `harness_models`, synced hourly. OpenRouter "free" means `:free` or `openrouter/free`, never zero prices. Auto picks: `rank-auto-models`. - **Catalog:** `harness_models`, synced hourly. OpenRouter "free" means `:free` or `openrouter/free`, never zero prices. Auto picks: `rank-auto-models`.
- **Calling:** `resolveHarnessCandidates` -> `withCandidates` with process-wide cooldowns; errors are `HarnessError`. Entry points under `text/`, `media/`, `audio/`, `image/`, `embeddings/`. `run-text-loop.ts` sticks to one model, caps steps (`grabHarnessMaxToolSteps`, `grabReplyMaxToolSteps`), trims old tool output to fit context. Models that can't take an image/audio part get it read by vision/transcription first. - **Calling:** `resolveHarnessCandidates` -> `withCandidates` with process-wide cooldowns; errors are `HarnessError`. Entry points under `text/`, `media/`, `audio/`, `image/`, `embeddings/`. `run-text-loop.ts` sticks to one model, caps steps (`grabHarnessMaxToolSteps`, `grabReplyMaxToolSteps`), trims old tool output to fit context. Models that can't take an image/audio part get it read by vision/transcription first.
@@ -221,7 +228,7 @@ Read-only browser of every volume (`src/utils/storage-volumes.ts`: bots, workspa
`AdminSections`: `/admin/bots`, `/admin/workspaces`, `/admin/chatrooms`, `/admin/routines`, `/admin/logs`, `/admin/help`, `/admin/settings`. `/admin` -> `/admin/bots`. Every `/admin/*` page renders in the bots layout (`src/layouts/bots/`); there is no separate admin layout. `AdminSections`: `/admin/bots`, `/admin/workspaces`, `/admin/chatrooms`, `/admin/routines`, `/admin/logs`, `/admin/help`, `/admin/settings`. `/admin` -> `/admin/bots`. Every `/admin/*` page renders in the bots layout (`src/layouts/bots/`); there is no separate admin layout.
- **Rail pages** (`src/dict/rail-pages-dict.ts`): Routines, Files, Usage (crew managers), Activity (`/admin/logs`, super admin), Help (`(data)/help-topics.ts`). Visibility via `canSeeRailItem` for both the popover and the aside. - **Rail pages** (`src/dict/rail-pages-dict.ts`): Routines, Files, Usage (crew managers), Activity (`/admin/logs`, super admin), Help (`(data)/help-topics.ts`). Visibility via `canSeeRailItem` for both the popover and the aside.
- **Settings** (`src/dict/settings-pages-dict.ts`; add a sub page = folder + dict entry): `general` (theme per device, plus super-admin app settings: prompt append, run limits, skill limits; crew-manager storage quota; running version), `profile`, `notifications`, `change-password`, `secrets`, `models`, `environment`, `backups` and `database` (super admin), `bots`, `workspaces`, `computers`, `mcps`, `tools`, `skills`, `knowledgebases` (crew managers), `users` (`canListUsers`). - **Settings** (`src/dict/settings-pages-dict.ts`; add a sub page = folder + dict entry): `general` (theme per device, plus super-admin app settings: prompt append, run limits, skill limits; crew-manager storage quota; running version), `setup` (super admin; shares the Models page's server props and `useModelsOverview`), `profile`, `notifications`, `change-password`, `secrets`, `models`, `environment`, `backups` and `database` (super admin), `bots`, `workspaces`, `computers`, `mcps`, `tools`, `skills`, `knowledgebases` (crew managers), `users` (`canListUsers`).
- Settings pages use `SettingsShell` (breadcrumbs via `admin-breadcrumbs.tsx`), `SettingsGroup` blocks (no cards) and the sticky `SettingsSaveBar`. Saves don't reload. Keep save notices in component state (`useFormInit` clears `status` on change). Lists share `SettingsList`/`SettingsListRow`/`SettingsRowAction`. Row actions are icon-only (`RowIconButton`). - Settings pages use `SettingsShell` (breadcrumbs via `admin-breadcrumbs.tsx`), `SettingsGroup` blocks (no cards) and the sticky `SettingsSaveBar`. Saves don't reload. Keep save notices in component state (`useFormInit` clears `status` on change). Lists share `SettingsList`/`SettingsListRow`/`SettingsRowAction`. Row actions are icon-only (`RowIconButton`).
- **Backups:** DB snapshots (synchronous) and whole-data archives of `DATA_DIR` into `BACKUP_ARCHIVE_DIR`. Archives and restores outlast a request, so the route only starts the run (`startDataBackupRun`, which holds `with-backup-run-lock.ts`) and the page polls `data-status`. A restore takes a safety archive first, then restarts the process. - **Backups:** DB snapshots (synchronous) and whole-data archives of `DATA_DIR` into `BACKUP_ARCHIVE_DIR`. Archives and restores outlast a request, so the route only starts the run (`startDataBackupRun`, which holds `with-backup-run-lock.ts`) and the page polls `data-status`. A restore takes a safety archive first, then restarts the process.
- **Database browser:** strictly read-only; values cut in SQL; state in the address via `databaseHref`. - **Database browser:** strictly read-only; values cut in SQL; state in the address via `databaseHref`.
+70 -107
View File
@@ -35,6 +35,7 @@ import {
} from "@/src/dict/bot-file-dict"; } from "@/src/dict/bot-file-dict";
import type { BUN_SQLITE_DatabaseSchemaType } from "@moduletrace/bun-sqlite/types"; import type { BUN_SQLITE_DatabaseSchemaType } from "@moduletrace/bun-sqlite/types";
import { EnvParadigms, SecretRequestStatuses } from "@/src/dict/env-dict"; import { EnvParadigms, SecretRequestStatuses } from "@/src/dict/env-dict";
import { BotQuestionStatuses } from "@/src/dict/bot-question-dict";
import { McpServerStatuses, McpTransports } from "@/src/dict/mcp-dict"; import { McpServerStatuses, McpTransports } from "@/src/dict/mcp-dict";
import { import {
CustomToolKinds, CustomToolKinds,
@@ -1106,6 +1107,11 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
fieldDescription: `Unix timestamp of completion or final failure`, fieldDescription: `Unix timestamp of completion or final failure`,
dataType: "INTEGER", dataType: "INTEGER",
}, },
{
fieldName: "steps",
fieldDescription: `How many steps it recorded, written when its \`bots_queue_updates\` are cleared after it ends (null while they're still there)`,
dataType: "INTEGER",
},
], ],
indexes: [ indexes: [
{ {
@@ -1116,7 +1122,7 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
}, },
{ {
tableName: "bots_queue_updates", tableName: "bots_queue_updates",
tableDescription: `What a bot did while working through a queued job, one row per step, oldest first. Live in the working-on panel while the job runs; deleted with the job when it's pruned`, tableDescription: `What a bot did while working through a queued job, one row per step, oldest first. Live in the working-on panel while the job runs; cleared shortly after the job ends (clearFinishedJobSteps), its count kept on the job`,
fields: [ fields: [
{ {
fieldName: "bots_queue_id", fieldName: "bots_queue_id",
@@ -1647,6 +1653,63 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
}, },
], ],
}, },
{
tableName: "bot_questions",
tableDescription: `Questions a bot asked a user in their conversation before acting (\`ask_user\`), each answered by picking one option (radios) or several (checkboxes), optionally with a typed "Other". The user answers them in the card, and the answers go to the bot as their chat message`,
fields: [
{
fieldName: "title",
fieldDescription: `A short heading for the card, in the bot's words`,
dataType: "TEXT",
},
{
fieldName: "questions",
fieldDescription: `JSON array of { question, kind (single | multiple), options, allow_other }`,
dataType: "TEXT",
},
{
fieldName: "answers",
fieldDescription: `JSON array, one per question, of { choices (option indexes), other }; null until answered`,
dataType: "TEXT",
},
{
fieldName: "status",
dataType: "TEXT",
options: [...BotQuestionStatuses],
defaultValue: "pending",
},
{
fieldName: "decided_at",
dataType: "INTEGER",
},
{
fieldName: "user_id",
fieldDescription: `The user they were asked of, who alone answers them. Not a foreign key`,
dataType: "INTEGER",
},
{
fieldName: "bot_id",
fieldDescription: `The bot that asked. Not a foreign key`,
dataType: "INTEGER",
},
{
fieldName: "session_id",
fieldDescription: `The conversation they were asked in. Not a foreign key`,
dataType: "INTEGER",
},
{
fieldName: "workspace_id",
fieldDescription: `The session's workspace; null for the bot's own chat. Not a foreign key`,
dataType: "INTEGER",
},
],
indexes: [
{
indexName: "idx_bot_questions_session_id",
indexTableFields: ["session_id"],
},
],
},
{ {
tableName: "bot_graphics", tableName: "bot_graphics",
tableDescription: `A visual a bot made for a user (a chart, diagram, timeline or report) as one self-contained HTML document. Served by the app at \`/graphics/<id>\` in a sandbox with no network and an origin of its own, so it never runs with the viewer's session. Per user like the conversation it was made in; an edit replaces the HTML and bumps \`version\``, tableDescription: `A visual a bot made for a user (a chart, diagram, timeline or report) as one self-contained HTML document. Served by the app at \`/graphics/<id>\` in a sandbox with no network and an origin of its own, so it never runs with the viewer's session. Per user like the conversation it was made in; an edit replaces the HTML and bumps \`version\``,
@@ -3527,6 +3590,10 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
indexName: "idx_logs_user_id", indexName: "idx_logs_user_id",
indexTableFields: ["user_id"], indexTableFields: ["user_id"],
}, },
{
indexName: "idx_logs_created_at",
indexTableFields: ["created_at"],
},
], ],
}, },
{ {
@@ -3787,16 +3854,6 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
options: BotFileSources.map((s) => s.value), options: BotFileSources.map((s) => s.value),
defaultValue: "bot-write", defaultValue: "bot-write",
}, },
{
fieldName: "ingested_at",
fieldDescription: `When its text was chunked and embedded. Null: not ingested (yet)`,
dataType: "INTEGER",
},
{
fieldName: "ingest_error",
fieldDescription: `Why the last ingestion failed, when it did`,
dataType: "TEXT",
},
], ],
uniqueConstraints: [ uniqueConstraints: [
{ {
@@ -3820,56 +3877,6 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
}, },
], ],
}, },
{
tableName: "bot_file_chunks",
tableDescription: `A piece of a bot file's text, the unit a search returns. Replaced whole when the file is ingested again`,
fields: [
{
fieldName: "file_id",
dataType: "INTEGER",
foreignKey: {
cascadeDelete: true,
destinationTableName: "bot_files",
destinationTableColumnName: "id",
destinationTableColumnType: "INTEGER",
foreignKeyName: "bot_file_chunks_file_fk",
},
},
{
fieldName: "scope",
dataType: "TEXT",
options: BotFileScopes.map((s) => s.value),
},
{
fieldName: "owner_id",
fieldDescription: `The bot or workspace the file belongs to, so a search filters without a join`,
dataType: "INTEGER",
},
{
fieldName: "chunk_index",
dataType: "INTEGER",
},
{
fieldName: "text",
dataType: "TEXT",
},
{
fieldName: "token_estimate",
fieldDescription: `A rough count (characters / 4), for trimming results`,
dataType: "INTEGER",
},
],
indexes: [
{
indexName: "idx_bot_file_chunks_file_id",
indexTableFields: ["file_id", "chunk_index"],
},
{
indexName: "idx_bot_file_chunks_owner",
indexTableFields: ["scope", "owner_id"],
},
],
},
{ {
tableName: "token_usages", tableName: "token_usages",
tableDescription: `One model call that reported its usage: the tokens it spent, on which model and provider, for whom and for what. A bot message's total is the sum of the calls it took; the usage page sums them by model, provider and period`, tableDescription: `One model call that reported its usage: the tokens it spent, on which model and provider, for whom and for what. A bot message's total is the sum of the calls it took; the usage page sums them by model, provider and period`,
@@ -4018,44 +4025,9 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
}, },
], ],
}, },
{
tableName: "bot_file_vectors",
tableDescription: `sqlite-vec vectors of a file's chunks, at \`AppData.EmbeddingDimensions\` wide whatever the model returns. A vec0 table is only recreated with \`bunx bun-sqlite schema --vector\`, so the entrypoint never passes it`,
isVector: true,
vectorType: "vec0",
fields: [
{
fieldName: "embedding",
fieldDescription: `Normalized, padded or cut to \`AppData.EmbeddingDimensions\``,
dataType: "BLOB",
isVector: true,
vectorSize: AppData.EmbeddingDimensions,
},
{
fieldName: "chunk_id",
dataType: "INTEGER",
sideCar: true,
},
{
fieldName: "file_id",
dataType: "INTEGER",
sideCar: true,
},
{
fieldName: "scope",
dataType: "TEXT",
sideCar: true,
},
{
fieldName: "owner_id",
dataType: "INTEGER",
sideCar: true,
},
],
},
{ {
tableName: "file_ingest_queue", tableName: "file_ingest_queue",
tableDescription: `Files (and knowledgebase documents) waiting to have their text chunked and embedded. A queue, so a restart never strands a file and an upload never waits on a model. A finished job is deleted: \`bot_files.ingested_at\` and \`ingest_error\` keep the outcome`, tableDescription: `Knowledgebase documents waiting to have their text chunked and embedded (bot files are never indexed). A queue, so a restart never strands a document and an upload never waits on a model. A finished job is deleted: \`knowledgebase_documents.ingested_at\` and \`ingest_error\` keep the outcome`,
isQueue: { isQueue: {
maxAttempts: 3, maxAttempts: 3,
lockTimeout: 10 * 60 * 1000, lockTimeout: 10 * 60 * 1000,
@@ -4063,22 +4035,13 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
deleteOnComplete: true, deleteOnComplete: true,
}, },
fields: [ fields: [
{
fieldName: "file_id",
fieldDescription: `The \`bot_files\` row to (re)ingest`,
dataType: "INTEGER",
},
{ {
fieldName: "document_id", fieldName: "document_id",
fieldDescription: `The \`knowledgebase_documents\` row to (re)ingest, instead of a file`, fieldDescription: `The \`knowledgebase_documents\` row to (re)ingest`,
dataType: "INTEGER", dataType: "INTEGER",
}, },
], ],
indexes: [ indexes: [
{
indexName: "idx_file_ingest_queue_file_id",
indexTableFields: ["file_id"],
},
{ {
indexName: "idx_file_ingest_queue_document_id", indexName: "idx_file_ingest_queue_document_id",
indexTableFields: ["document_id"], indexTableFields: ["document_id"],
+58 -72
View File
@@ -22,6 +22,7 @@ export const BunSQLiteTables = [
"bot_hook_events", "bot_hook_events",
"hook_proposals", "hook_proposals",
"secret_requests", "secret_requests",
"bot_questions",
"bot_graphics", "bot_graphics",
"workspaces", "workspaces",
"workspace_bots", "workspace_bots",
@@ -61,9 +62,7 @@ export const BunSQLiteTables = [
"push_subscriptions", "push_subscriptions",
"envs", "envs",
"bot_files", "bot_files",
"bot_file_chunks",
"token_usages", "token_usages",
"bot_file_vectors",
"file_ingest_queue", "file_ingest_queue",
"app_settings", "app_settings",
] as const ] as const
@@ -505,11 +504,11 @@ export type BUN_SQLITE_COBBLE_TASKS = {
*/ */
updated_at?: number | ""; updated_at?: number | "";
/** /**
* The bot the task belongs to; null for a workspace's task * The bot that has the task: its own list's, or with `workspace_id` its task in that workspace; null for a workspace's shared task nobody has yet
*/ */
bot_id?: number | ""; bot_id?: number | "";
/** /**
* The workspace the task belongs to (its bots all see it); null for a bot's own * The workspace the task is in; null for a bot's own list
*/ */
workspace_id?: number | ""; workspace_id?: number | "";
title?: string; title?: string;
@@ -679,6 +678,10 @@ export type BUN_SQLITE_COBBLE_BOTS_QUEUE = {
* Unix timestamp of completion or final failure * Unix timestamp of completion or final failure
*/ */
finished_at?: number | ""; finished_at?: number | "";
/**
* How many steps it recorded, written when its `bots_queue_updates` are cleared after it ends (null while they're still there)
*/
steps?: number | "";
/** /**
* The state of the job. (pending | processing | completed | failed) * The state of the job. (pending | processing | completed | failed)
*/ */
@@ -1044,6 +1047,51 @@ export type BUN_SQLITE_COBBLE_SECRET_REQUESTS = {
workspace_id?: number | ""; workspace_id?: number | "";
} }
export type BUN_SQLITE_COBBLE_BOT_QUESTIONS = {
/**
* 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 | "";
/**
* A short heading for the card, in the bot's words
*/
title?: string;
/**
* JSON array of { question, kind (single | multiple), options, allow_other }
*/
questions?: string;
/**
* JSON array, one per question, of { choices (option indexes), other }; null until answered
*/
answers?: string;
status?: "pending" | "answered" | "skipped" | "";
decided_at?: number | "";
/**
* The user they were asked of, who alone answers them. Not a foreign key
*/
user_id?: number | "";
/**
* The bot that asked. Not a foreign key
*/
bot_id?: number | "";
/**
* The conversation they were asked in. Not a foreign key
*/
session_id?: number | "";
/**
* The session's workspace; null for the bot's own chat. Not a foreign key
*/
workspace_id?: number | "";
}
export type BUN_SQLITE_COBBLE_BOT_GRAPHICS = { export type BUN_SQLITE_COBBLE_BOT_GRAPHICS = {
/** /**
* The unique identifier of the record. * The unique identifier of the record.
@@ -1519,7 +1567,7 @@ export type BUN_SQLITE_COBBLE_HARNESS_SETTINGS = {
* The time when the record was updated. (Unix Timestamp) * The time when the record was updated. (Unix Timestamp)
*/ */
updated_at?: number | ""; updated_at?: number | "";
harness?: "text" | "vision" | "transcription" | "speech" | "image" | "embedding" | ""; harness?: "text" | "vision" | "transcription" | "speech" | "image" | "image_edit" | "embedding" | "";
/** /**
* harness_models row picked by a super admin. Null picks automatically. Not a foreign key, so removing a provider leaves the setting * harness_models row picked by a super admin. Null picks automatically. Not a foreign key, so removing a provider leaves the setting
*/ */
@@ -1559,7 +1607,7 @@ export type BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS = {
* The worker bot that overrides the default. The coordinator always uses the defaults * The worker bot that overrides the default. The coordinator always uses the defaults
*/ */
bot_id?: number | ""; bot_id?: number | "";
harness?: "text" | "vision" | "transcription" | "speech" | "image" | "embedding" | ""; harness?: "text" | "vision" | "transcription" | "speech" | "image" | "image_edit" | "embedding" | "";
/** /**
* harness_models row picked for this bot. Null picks automatically. Not a foreign key, like harness_settings * harness_models row picked for this bot. Null picks automatically. Not a foreign key, like harness_settings
*/ */
@@ -1599,7 +1647,7 @@ export type BUN_SQLITE_COBBLE_WORKSPACE_HARNESS_SETTINGS = {
* The workspace * The workspace
*/ */
workspace_id?: number | ""; workspace_id?: number | "";
harness?: "text" | "vision" | "transcription" | "speech" | "image" | "embedding" | ""; harness?: "text" | "vision" | "transcription" | "speech" | "image" | "image_edit" | "embedding" | "";
/** /**
* harness_models row picked for this workspace. Null picks automatically. Not a foreign key, like harness_settings * harness_models row picked for this workspace. Null picks automatically. Not a foreign key, like harness_settings
*/ */
@@ -2331,41 +2379,6 @@ export type BUN_SQLITE_COBBLE_BOT_FILES = {
* How it got here: the user attached it to a message, the bot wrote it, or a tool made it * How it got here: the user attached it to a message, the bot wrote it, or a tool made it
*/ */
source?: "chat-upload" | "bot-write" | "tool" | ""; source?: "chat-upload" | "bot-write" | "tool" | "";
/**
* When its text was chunked and embedded. Null: not ingested (yet)
*/
ingested_at?: number | "";
/**
* Why the last ingestion failed, when it did
*/
ingest_error?: string;
}
export type BUN_SQLITE_COBBLE_BOT_FILE_CHUNKS = {
/**
* 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 | "";
file_id?: number | "";
scope?: "bot" | "workspace" | "";
/**
* The bot or workspace the file belongs to, so a search filters without a join
*/
owner_id?: number | "";
chunk_index?: number | "";
text?: string;
/**
* A rough count (characters / 4), for trimming results
*/
token_estimate?: number | "";
} }
export type BUN_SQLITE_COBBLE_TOKEN_USAGES = { export type BUN_SQLITE_COBBLE_TOKEN_USAGES = {
@@ -2408,7 +2421,7 @@ export type BUN_SQLITE_COBBLE_TOKEN_USAGES = {
/** /**
* What the model was used for: a chat turn, reading media, transcribing, speaking, making an image, embedding * What the model was used for: a chat turn, reading media, transcribing, speaking, making an image, embedding
*/ */
harness?: "text" | "vision" | "transcription" | "speech" | "image" | "embedding" | ""; harness?: "text" | "vision" | "transcription" | "speech" | "image" | "image_edit" | "embedding" | "";
/** /**
* The provider that served it. Not a foreign key: it stays readable after the provider is deleted * The provider that served it. Not a foreign key: it stays readable after the provider is deleted
*/ */
@@ -2443,29 +2456,6 @@ export type BUN_SQLITE_COBBLE_TOKEN_USAGES = {
settings?: string; settings?: string;
} }
export type BUN_SQLITE_COBBLE_BOT_FILE_VECTORS = {
/**
* 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 | "";
/**
* Normalized, padded or cut to `AppData.EmbeddingDimensions`
*/
embedding?: Float32Array<ArrayBuffer> | Buffer<ArrayBuffer> | null;
chunk_id?: number | "";
file_id?: number | "";
scope?: string;
owner_id?: number | "";
}
export type BUN_SQLITE_COBBLE_FILE_INGEST_QUEUE = { export type BUN_SQLITE_COBBLE_FILE_INGEST_QUEUE = {
/** /**
* The unique identifier of the record. * The unique identifier of the record.
@@ -2480,11 +2470,7 @@ export type BUN_SQLITE_COBBLE_FILE_INGEST_QUEUE = {
*/ */
updated_at?: number | ""; updated_at?: number | "";
/** /**
* The `bot_files` row to (re)ingest * The `knowledgebase_documents` row to (re)ingest
*/
file_id?: number | "";
/**
* The `knowledgebase_documents` row to (re)ingest, instead of a file
*/ */
document_id?: number | ""; document_id?: number | "";
/** /**
@@ -2530,4 +2516,4 @@ export type BUN_SQLITE_COBBLE_APP_SETTINGS = {
value?: string; value?: string;
} }
export type BUN_SQLITE_COBBLE_ALL_TYPEDEFS = BUN_SQLITE_COBBLE_USERS & BUN_SQLITE_COBBLE_USER_TYPES & BUN_SQLITE_COBBLE_SSO_LOGIN_CODES & BUN_SQLITE_COBBLE_USER_PASSWORDS & BUN_SQLITE_COBBLE_MEDIA & BUN_SQLITE_COBBLE_MEDIA_PARADIGMS & BUN_SQLITE_COBBLE_BOTS & BUN_SQLITE_COBBLE_CHATROOMS & BUN_SQLITE_COBBLE_BOT_SESSIONS & BUN_SQLITE_COBBLE_BOT_MESSAGES & BUN_SQLITE_COBBLE_BOT_MESSAGE_REACTIONS & BUN_SQLITE_COBBLE_BOT_TASKS & BUN_SQLITE_COBBLE_BOT_TASK_STEPS & BUN_SQLITE_COBBLE_TASKS & BUN_SQLITE_COBBLE_TASK_MEDIA & BUN_SQLITE_COBBLE_TASK_NOTES & BUN_SQLITE_COBBLE_BOTS_QUEUE & BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES & BUN_SQLITE_COBBLE_CRON_JOBS & BUN_SQLITE_COBBLE_BOT_HOOKS & BUN_SQLITE_COBBLE_BOT_HOOK_EVENTS & BUN_SQLITE_COBBLE_HOOK_PROPOSALS & BUN_SQLITE_COBBLE_SECRET_REQUESTS & BUN_SQLITE_COBBLE_BOT_GRAPHICS & BUN_SQLITE_COBBLE_WORKSPACES & BUN_SQLITE_COBBLE_WORKSPACE_BOTS & BUN_SQLITE_COBBLE_BOT_TYPES & BUN_SQLITE_COBBLE_WORKSPACE_USERS & BUN_SQLITE_COBBLE_BOT_USERS & BUN_SQLITE_COBBLE_BOT_USER_MEMORIES & BUN_SQLITE_COBBLE_BOT_USER_FACES & BUN_SQLITE_COBBLE_BOT_MESSAGE_MEDIA & BUN_SQLITE_COBBLE_CONTAINERS & BUN_SQLITE_COBBLE_COMPUTERS & BUN_SQLITE_COBBLE_BOT_COMPUTERS & BUN_SQLITE_COBBLE_WORKSPACE_COMPUTERS & BUN_SQLITE_COBBLE_HARNESS_PROVIDERS & BUN_SQLITE_COBBLE_HARNESS_MODELS & BUN_SQLITE_COBBLE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_WORKSPACE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_MCP_SERVERS & BUN_SQLITE_COBBLE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_SKILLS & BUN_SQLITE_COBBLE_EXTENSION_PROPOSALS & BUN_SQLITE_COBBLE_BOT_MCP_SERVERS & BUN_SQLITE_COBBLE_WORKSPACE_MCP_SERVERS & BUN_SQLITE_COBBLE_BOT_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_WORKSPACE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_BOT_SKILLS & BUN_SQLITE_COBBLE_WORKSPACE_SKILLS & BUN_SQLITE_COBBLE_KNOWLEDGEBASES & BUN_SQLITE_COBBLE_KNOWLEDGEBASE_DOCUMENTS & BUN_SQLITE_COBBLE_KNOWLEDGEBASE_CHUNKS & BUN_SQLITE_COBBLE_KNOWLEDGEBASE_VECTORS & BUN_SQLITE_COBBLE_BOT_KNOWLEDGEBASES & BUN_SQLITE_COBBLE_WORKSPACE_KNOWLEDGEBASES & BUN_SQLITE_COBBLE_LOGS & BUN_SQLITE_COBBLE_USER_PRESENCE & BUN_SQLITE_COBBLE_PUSH_SUBSCRIPTIONS & BUN_SQLITE_COBBLE_ENVS & BUN_SQLITE_COBBLE_BOT_FILES & BUN_SQLITE_COBBLE_BOT_FILE_CHUNKS & BUN_SQLITE_COBBLE_TOKEN_USAGES & BUN_SQLITE_COBBLE_BOT_FILE_VECTORS & BUN_SQLITE_COBBLE_FILE_INGEST_QUEUE & BUN_SQLITE_COBBLE_APP_SETTINGS export type BUN_SQLITE_COBBLE_ALL_TYPEDEFS = BUN_SQLITE_COBBLE_USERS & BUN_SQLITE_COBBLE_USER_TYPES & BUN_SQLITE_COBBLE_SSO_LOGIN_CODES & BUN_SQLITE_COBBLE_USER_PASSWORDS & BUN_SQLITE_COBBLE_MEDIA & BUN_SQLITE_COBBLE_MEDIA_PARADIGMS & BUN_SQLITE_COBBLE_BOTS & BUN_SQLITE_COBBLE_CHATROOMS & BUN_SQLITE_COBBLE_BOT_SESSIONS & BUN_SQLITE_COBBLE_BOT_MESSAGES & BUN_SQLITE_COBBLE_BOT_MESSAGE_REACTIONS & BUN_SQLITE_COBBLE_BOT_TASKS & BUN_SQLITE_COBBLE_BOT_TASK_STEPS & BUN_SQLITE_COBBLE_TASKS & BUN_SQLITE_COBBLE_TASK_MEDIA & BUN_SQLITE_COBBLE_TASK_NOTES & BUN_SQLITE_COBBLE_BOTS_QUEUE & BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES & BUN_SQLITE_COBBLE_CRON_JOBS & BUN_SQLITE_COBBLE_BOT_HOOKS & BUN_SQLITE_COBBLE_BOT_HOOK_EVENTS & BUN_SQLITE_COBBLE_HOOK_PROPOSALS & BUN_SQLITE_COBBLE_SECRET_REQUESTS & BUN_SQLITE_COBBLE_BOT_QUESTIONS & BUN_SQLITE_COBBLE_BOT_GRAPHICS & BUN_SQLITE_COBBLE_WORKSPACES & BUN_SQLITE_COBBLE_WORKSPACE_BOTS & BUN_SQLITE_COBBLE_BOT_TYPES & BUN_SQLITE_COBBLE_WORKSPACE_USERS & BUN_SQLITE_COBBLE_BOT_USERS & BUN_SQLITE_COBBLE_BOT_USER_MEMORIES & BUN_SQLITE_COBBLE_BOT_USER_FACES & BUN_SQLITE_COBBLE_BOT_MESSAGE_MEDIA & BUN_SQLITE_COBBLE_CONTAINERS & BUN_SQLITE_COBBLE_COMPUTERS & BUN_SQLITE_COBBLE_BOT_COMPUTERS & BUN_SQLITE_COBBLE_WORKSPACE_COMPUTERS & BUN_SQLITE_COBBLE_HARNESS_PROVIDERS & BUN_SQLITE_COBBLE_HARNESS_MODELS & BUN_SQLITE_COBBLE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_WORKSPACE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_MCP_SERVERS & BUN_SQLITE_COBBLE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_SKILLS & BUN_SQLITE_COBBLE_EXTENSION_PROPOSALS & BUN_SQLITE_COBBLE_BOT_MCP_SERVERS & BUN_SQLITE_COBBLE_WORKSPACE_MCP_SERVERS & BUN_SQLITE_COBBLE_BOT_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_WORKSPACE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_BOT_SKILLS & BUN_SQLITE_COBBLE_WORKSPACE_SKILLS & BUN_SQLITE_COBBLE_KNOWLEDGEBASES & BUN_SQLITE_COBBLE_KNOWLEDGEBASE_DOCUMENTS & BUN_SQLITE_COBBLE_KNOWLEDGEBASE_CHUNKS & BUN_SQLITE_COBBLE_KNOWLEDGEBASE_VECTORS & BUN_SQLITE_COBBLE_BOT_KNOWLEDGEBASES & BUN_SQLITE_COBBLE_WORKSPACE_KNOWLEDGEBASES & BUN_SQLITE_COBBLE_LOGS & BUN_SQLITE_COBBLE_USER_PRESENCE & BUN_SQLITE_COBBLE_PUSH_SUBSCRIPTIONS & BUN_SQLITE_COBBLE_ENVS & BUN_SQLITE_COBBLE_BOT_FILES & BUN_SQLITE_COBBLE_TOKEN_USAGES & BUN_SQLITE_COBBLE_FILE_INGEST_QUEUE & BUN_SQLITE_COBBLE_APP_SETTINGS
+2 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "cobble", "name": "cobble",
"version": "0.6.0", "version": "0.10.0",
"module": "index.ts", "module": "index.ts",
"type": "module", "type": "module",
"private": true, "private": true,
@@ -8,12 +8,11 @@
"dev": "bun src/server.ts", "dev": "bun src/server.ts",
"gen:favicon": "bun src/scripts/generate-brand-assets.ts", "gen:favicon": "bun src/scripts/generate-brand-assets.ts",
"build": "bunx bunext build", "build": "bunx bunext build",
"db:schema": "bunx bun-sqlite schema -t", "db:schema": "bunx bun-sqlite schema -t -y",
"db:admin": "bunx bun-sqlite admin", "db:admin": "bunx bun-sqlite admin",
"db:backup": "bunx bun-sqlite backup", "db:backup": "bunx bun-sqlite backup",
"seed": "bun src/scripts/seed-super-admin.tsx", "seed": "bun src/scripts/seed-super-admin.tsx",
"seed:bots": "bun src/scripts/seed-bots.ts", "seed:bots": "bun src/scripts/seed-bots.ts",
"media:prune-text": "bun src/scripts/prune-media-text-content.ts",
"start": "MAIN_SERVER=1 NODE_ENV=production bun src/server.ts", "start": "MAIN_SERVER=1 NODE_ENV=production bun src/server.ts",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "bun test", "test": "bun test",
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, test } from "bun:test";
import composeEnvsNote from "./compose-envs-note";
describe("composeEnvsNote", () => {
test("lists the computer's envs and the tools-only ones apart", () => {
const note = composeEnvsNote({
on_computer: [{ key: "DB_PASSWORD", description: "Production database" }],
tools_only: [{ key: "API_TOKEN", description: "" }],
has_computer: true,
});
expect(note).toContain("- DB_PASSWORD: Production database");
expect(note).toContain("- API_TOKEN");
expect(note).not.toContain("- API_TOKEN:");
expect(note.indexOf("DB_PASSWORD")).toBeLessThan(note.indexOf("Only in tools"));
expect(note).toContain("Don't ask the user for them");
});
test("says where each env comes from", () => {
const note = composeEnvsNote({
on_computer: [{ key: "DB_PASSWORD", description: "Production database", paradigm: "workspace" }],
tools_only: [{ key: "TOKEN", description: "", paradigm: "user" }],
has_computer: true,
});
expect(note).toContain("- DB_PASSWORD (this workspace's, every bot in it has it): Production database");
expect(note).toContain("- TOKEN (this user's own, for you)");
expect(note).toContain("never needs setting again per bot");
});
test("never mentions values", () => {
const note = composeEnvsNote({ on_computer: [{ key: "K", description: "d" }], tools_only: [], has_computer: true });
expect(note).not.toContain("Only in tools");
expect(note).toContain("you never see values");
});
test("says so when nothing is set", () => {
expect(composeEnvsNote({ on_computer: [], tools_only: [], has_computer: false })).toContain("No envs are set");
});
});
+63
View File
@@ -0,0 +1,63 @@
import type { EnvParadigm } from "@/src/dict/env-dict";
type Env = { key: string; description: string; paradigm?: EnvParadigm };
type Params = {
/**
* Set on the bot's current computer for its tasks (`grabReachableEnvs`).
*/
on_computer: Env[];
/**
* Only filled into tools' templates.
*/
tools_only: Env[];
/**
* Whether the bot has a computer, so tasks can use the computer's envs.
*/
has_computer: boolean;
};
/**
* Where an env comes from, as the bot is told: who else has it.
*/
const EnvSources: Record<EnvParadigm, string> = {
general: "install-wide, every bot has it",
workspace: "this workspace's, every bot in it has it",
computer: "this computer's, every bot on it has it",
bot: "yours, for every user",
user: "this user's own, for you",
};
/**
* Added to a chat reply so the bot knows which envs it already has (keys,
* descriptions and where each comes from, never values). Without it a
* reply can't tell, asks the user for keys that are set
* (`request_secret`), "checks" them with `show_secret` or a shell where
* they aren't loaded, or tells the user a workspace's keys must be set
* again for every bot.
*/
export default function composeEnvsNote({ on_computer, tools_only, has_computer }: Params) {
const list = (envs: Env[]) =>
envs
.map(
(env) =>
`- ${env.key}${env.paradigm ? ` (${EnvSources[env.paradigm]})` : ""}${env.description ? `: ${env.description}` : ""}`,
)
.join("\n");
if (!on_computer.length && !tools_only.length) {
return `# Your envs\n\nNo envs are set for you here. If you need a secret, ask for it with request_secret.`;
}
return [
`# Your envs\n\nThese are already set for you here (keys and descriptions; you never see values). Don't ask the user for them, and don't call show_secret or run printenv to check them: this list is the check. A workspace, computer or install-wide env reaches every bot there the same way, so it never needs setting again per bot.`,
on_computer.length
? `On your computer, as $KEY in a task's shell and code (start_task; search_computers doesn't load them):\n${list(on_computer)}`
: "",
tools_only.length
? `${has_computer ? "Only in tools" : "In tools"}, as {{env.KEY}}${has_computer ? " (not set on computers)" : ""}:\n${list(tools_only)}`
: "",
]
.filter(Boolean)
.join("\n\n");
}
+13 -5
View File
@@ -5,6 +5,7 @@ import grabMessagesMedia, {
} from "@/src/functions/backend/db/bots/grab-messages-media"; } from "@/src/functions/backend/db/bots/grab-messages-media";
import type { HarnessContentPart } from "@/src/harness/types"; import type { HarnessContentPart } from "@/src/harness/types";
import type { BotMessageWithAttachments } from "@/src/types"; import type { BotMessageWithAttachments } from "@/src/types";
import extractBotFileText from "@/src/functions/backend/db/bot-files/extract-bot-file-text";
import describeAttachment from "./describe-attachment"; import describeAttachment from "./describe-attachment";
type Params = { type Params = {
@@ -27,10 +28,7 @@ export default async function loadAttachmentParts({ messages }: Params) {
.slice(-AppData.BotReplyAttachmentMessages) .slice(-AppData.BotReplyAttachmentMessages)
.map((message) => Number(message.id)); .map((message) => Number(message.id));
const media = await grabMessagesMedia({ const media = await grabMessagesMedia({ message_ids: recent });
message_ids: recent,
with_text_content: true,
});
const parts = new Map<number, HarnessContentPart[]>(); const parts = new Map<number, HarnessContentPart[]>();
for (const [message_id, files] of media) { for (const [message_id, files] of media) {
@@ -53,7 +51,17 @@ async function fileToParts({ file }: { file: MessageMediaRow }): Promise<Harness
const label = describeAttachment({ name, id: file.id! }); const label = describeAttachment({ name, id: file.id! });
if (file.media_type == "document") { if (file.media_type == "document") {
const text = file.text_content?.trim(); /**
* Read from the file each time: no text is kept outside
* knowledgebases.
*/
const text = (
await extractBotFileText({
write_path: file.media_write_path,
name: file.title,
mime_type: file.mime_type,
}).catch(() => null)
)?.trim();
if (!text) { if (!text) {
return [{ type: "text", text: `[Attached file ${label}: its text couldn't be read.]` }]; return [{ type: "text", text: `[Attached file ${label}: its text couldn't be read.]` }];
+4 -7
View File
@@ -7,6 +7,7 @@ import generateText from "@/src/harness/text/generate-text";
import { runWithHarnessUsageScope } from "@/src/harness/usage/harness-usage-scope"; import { runWithHarnessUsageScope } from "@/src/harness/usage/harness-usage-scope";
import parseSessionName from "./parse-session-name"; import parseSessionName from "./parse-session-name";
import sessionNameTranscript from "./session-name-transcript"; import sessionNameTranscript from "./session-name-transcript";
import sessionReadyToName from "./session-ready-to-name";
type Params = { type Params = {
session_id: number | string; session_id: number | string;
@@ -16,7 +17,8 @@ type Params = {
}; };
/** /**
* Names a session once its first exchange is in: the bot writes the title * Names a session once its first exchange is in (or, with no user message,
* once the bot has written in it: work handed to it): the bot writes the title
* the rail and sessions list show and a short summary of it, from the * the rail and sessions list show and a short summary of it, from the
* session's first messages (however late the first reply came). The summary * session's first messages (however late the first reply came). The summary
* says how many messages it covers (`summary_message_count`), so * says how many messages it covers (`summary_message_count`), so
@@ -51,12 +53,7 @@ export default async function nameBotSession({
limit: AppData.BotSessionNameMessages, limit: AppData.BotSessionNameMessages,
}); });
if ( if (!sessionReadyToName({ messages })) return false;
!messages.some((message) => message.author == "user") ||
!messages.some((message) => message.author == "bot" && !message.carried_from_session_id)
) {
return false;
}
const transcript = sessionNameTranscript({ messages }); const transcript = sessionNameTranscript({ messages });
+3 -2
View File
@@ -235,9 +235,10 @@ export default async function processBotJob({
* Once the first exchange is sent, the bot names the session and * Once the first exchange is sent, the bot names the session and
* previews it. After the reply is pushed, so it never waits on it; * previews it. After the reply is pushed, so it never waits on it;
* a model that can't answer now leaves the session for the next * a model that can't answer now leaves the session for the next
* reply to name. * job to name. Tasks name too: a session the coordinator's
* delegation or a routine opened has no reply to name it.
*/ */
if (job.kind == "reply" && !in_room && completion.message?.session_id) { if (!in_room && completion.message?.session_id) {
/** /**
* The message's session, not the job's as it was claimed: a * The message's session, not the job's as it was claimed: a
* reply that moved the conversation on answered in the new one. * reply that moved the conversation on answered in the new one.
+24
View File
@@ -3,6 +3,7 @@ import { grabSkillBodyShownChars } from "@/src/functions/backend/db/app-settings
import { AppData } from "@/src/data/app-data"; import { AppData } from "@/src/data/app-data";
import { import {
ActionCheckNote, ActionCheckNote,
QuestionsNote,
AskCrewNote, AskCrewNote,
SecretsNote, SecretsNote,
GraphicsNote, GraphicsNote,
@@ -35,6 +36,8 @@ import openBotKnowledgebases from "@/src/harness/tools/knowledgebases/open-bot-k
import grabKnowledgebaseContext from "@/src/harness/tools/knowledgebases/grab-knowledgebase-context"; import grabKnowledgebaseContext from "@/src/harness/tools/knowledgebases/grab-knowledgebase-context";
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 composeEnvsNote from "./compose-envs-note";
import grabReachableEnvs from "@/src/functions/backend/db/envs/grab-reachable-envs";
import grabSessionCarryOver from "./grab-session-carry-over"; import grabSessionCarryOver from "./grab-session-carry-over";
import continueInNewSessionTool from "@/src/harness/tools/bot/continue-in-new-session-tool"; import continueInNewSessionTool from "@/src/harness/tools/bot/continue-in-new-session-tool";
import searchPastSessionsTool from "@/src/harness/tools/bot/sessions/search-past-sessions-tool"; import searchPastSessionsTool from "@/src/harness/tools/bot/sessions/search-past-sessions-tool";
@@ -51,6 +54,7 @@ import grabAnsweredMessageId from "@/src/functions/backend/db/bots-queue/grab-an
import markReplyAnswered from "@/src/functions/backend/db/bots-queue/mark-reply-answered"; import markReplyAnswered from "@/src/functions/backend/db/bots-queue/mark-reply-answered";
import sendMessageTool from "@/src/harness/tools/bot/send-message-tool"; import sendMessageTool from "@/src/harness/tools/bot/send-message-tool";
import grabBotSecretTools from "@/src/harness/tools/bot/secrets/grab-bot-secret-tools"; import grabBotSecretTools from "@/src/harness/tools/bot/secrets/grab-bot-secret-tools";
import askUserTool from "@/src/harness/tools/bot/questions/ask-user-tool";
import type { BotMessageWithAttachments } from "@/src/types"; import type { BotMessageWithAttachments } from "@/src/types";
import type { HarnessMessage } from "@/src/harness/types"; import type { HarnessMessage } from "@/src/harness/types";
import type { BotJobContext, BotJobOutcome } from "./types"; import type { BotJobContext, BotJobOutcome } from "./types";
@@ -145,6 +149,17 @@ export default async function runBotReply({
const prompt = await grabBotPrompt({ bot_id: bot.id, user_id, workspace_id }); const prompt = await grabBotPrompt({ bot_id: bot.id, user_id, workspace_id });
const computers = await grabBotComputers({ bot_id: bot.id, workspace_id }); const computers = await grabBotComputers({ bot_id: bot.id, workspace_id });
/**
* The envs the reply has (keys only), so it doesn't ask for or test
* ones that are set.
*/
const envs = await grabReachableEnvs({
bot_id: bot.id!,
user_id,
workspace_id,
computer_id: computers[0]?.id || null,
});
/** /**
* A summary that can't be written now (no model free) is left out * A summary that can't be written now (no model free) is left out
* rather than holding up the reply. * rather than holding up the reply.
@@ -311,7 +326,9 @@ export default async function runBotReply({
TasksNote, TasksNote,
TaskReminderNote, TaskReminderNote,
ActionCheckNote, ActionCheckNote,
QuestionsNote,
SecretsNote, SecretsNote,
composeEnvsNote({ ...envs, has_computer: computers.length > 0 }),
GraphicsNote, GraphicsNote,
workspace_id ? WorkspaceLearningNote : "", workspace_id ? WorkspaceLearningNote : "",
composeComputersNote({ composeComputersNote({
@@ -352,6 +369,13 @@ export default async function runBotReply({
onRequest: ({ ref }) => proposals.push(ref), onRequest: ({ ref }) => proposals.push(ref),
onShow: ({ ref }) => shown.push(ref), onShow: ({ ref }) => shown.push(ref),
}), }),
askUserTool({
user_id,
bot_id: bot.id,
session_id,
workspace_id,
onAsk: ({ ref }) => proposals.push(ref),
}),
...(has_past_sessions ...(has_past_sessions
? [searchPastSessionsTool(past_scope), readPastSessionTool(past_scope)] ? [searchPastSessionsTool(past_scope), readPastSessionTool(past_scope)]
: []), : []),
+21
View File
@@ -5,6 +5,7 @@ import workspaceAdminsNote from "./workspace-admins-note";
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db"; import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import { import {
ActionCheckNote, ActionCheckNote,
QuestionsNote,
SecretsNote, SecretsNote,
GraphicsNote, GraphicsNote,
botFilesNote, botFilesNote,
@@ -27,6 +28,7 @@ import openBotSkills, { type OpenBotSkills } from "@/src/harness/tools/skills/op
import openBotKnowledgebases from "@/src/harness/tools/knowledgebases/open-bot-knowledgebases"; import openBotKnowledgebases from "@/src/harness/tools/knowledgebases/open-bot-knowledgebases";
import grabBotTools from "@/src/harness/tools/bot/grab-bot-tools"; import grabBotTools from "@/src/harness/tools/bot/grab-bot-tools";
import grabBotSecretTools from "@/src/harness/tools/bot/secrets/grab-bot-secret-tools"; import grabBotSecretTools from "@/src/harness/tools/bot/secrets/grab-bot-secret-tools";
import askUserTool from "@/src/harness/tools/bot/questions/ask-user-tool";
import createWorkspaceTool from "@/src/harness/tools/bot/create-workspace-tool"; import createWorkspaceTool from "@/src/harness/tools/bot/create-workspace-tool";
import updateWorkspaceTool from "@/src/harness/tools/bot/update-workspace-tool"; import updateWorkspaceTool from "@/src/harness/tools/bot/update-workspace-tool";
import askToTakeOverTool from "@/src/harness/tools/computer/ask-to-take-over-tool"; import askToTakeOverTool from "@/src/harness/tools/computer/ask-to-take-over-tool";
@@ -134,6 +136,12 @@ export default async function runBotTask({
* like a routine, and treats the call's payload as data. * like a routine, and treats the call's payload as data.
*/ */
const hooked = Boolean(params.hook_id); const hooked = Boolean(params.hook_id);
/**
* Only a task the user follows in their conversation asks them
* questions: routines and hook calls act on their own, and a
* chatroom's session is hidden from them.
*/
const can_ask = Boolean(session_id) && !scheduled && !hooked && !params.chatroom_id;
let failure = null as string | null; let failure = null as string | null;
/** /**
@@ -207,6 +215,18 @@ export default async function runBotTask({
onRequest: hooked ? undefined : ({ ref }) => take_overs.push(ref), onRequest: hooked ? undefined : ({ ref }) => take_overs.push(ref),
onShow: ({ ref }) => shown.push(ref), onShow: ({ ref }) => shown.push(ref),
}), }),
...(can_ask
? [
askUserTool({
user_id,
bot_id: bot.id,
session_id,
workspace_id,
in_task: true,
onAsk: ({ ref }) => take_overs.push(ref),
}),
]
: []),
...(scheduled || hooked ? [reportRunFailedTool({ onFail: ({ reason }) => (failure = reason) })] : []), ...(scheduled || hooked ? [reportRunFailedTool({ onFail: ({ reason }) => (failure = reason) })] : []),
...(computer?.tools || []), ...(computer?.tools || []),
...(job.computer_id ...(job.computer_id
@@ -270,6 +290,7 @@ export default async function runBotTask({
ComputerFilesNote({ computer_id: job.computer_id || 0, dirs }), ComputerFilesNote({ computer_id: job.computer_id || 0, dirs }),
TasksNote, TasksNote,
ActionCheckNote, ActionCheckNote,
can_ask ? QuestionsNote : "",
SecretsNote, SecretsNote,
GraphicsNote, GraphicsNote,
workspace_id ? WorkspaceLearningNote : "", workspace_id ? WorkspaceLearningNote : "",
@@ -0,0 +1,43 @@
import { describe, expect, test } from "bun:test";
import sessionReadyToName from "./session-ready-to-name";
describe("sessionReadyToName", () => {
test("waits for the bot to answer the user", () => {
expect(sessionReadyToName({ messages: [{ id: 1, author: "user", text: "Hi" }] })).toBe(false);
});
test("names a first exchange", () => {
expect(
sessionReadyToName({
messages: [
{ id: 1, author: "user", text: "Hi" },
{ id: 2, author: "bot", text: "Hello" },
],
}),
).toBe(true);
});
test("names work handed to the bot, with no user message", () => {
expect(
sessionReadyToName({
messages: [{ id: 1, author: "bot", text: "Coordinator asked me to: **SEO lookup**" }],
}),
).toBe(true);
});
test("a carried summary alone is not the bot's answer", () => {
expect(
sessionReadyToName({
messages: [
{ id: 1, author: "bot", text: "- Crew set up", carried_from_session_id: 9 },
{ id: 2, author: "user", text: "Continue here" },
],
}),
).toBe(false);
expect(
sessionReadyToName({
messages: [{ id: 1, author: "bot", text: "- Crew set up", carried_from_session_id: 9 }],
}),
).toBe(false);
});
});
+28
View File
@@ -0,0 +1,28 @@
import type { SessionOpeningMessage } from "@/src/functions/backend/db/bot-sessions/grab-session-opening";
type Params = {
messages: SessionOpeningMessage[];
};
/**
* Whether a session's opening says enough to name it: the bot has said
* something of its own (a carried summary doesn't count), and, when the
* user wrote, after the user's first message. A session with no user
* message at all is work handed to the bot (the coordinator's delegations,
* routines, hooks), named from the bot's own messages.
*/
export default function sessionReadyToName({ messages }: Params) {
const has_bot_message = messages.some(
(message) => message.author == "bot" && !message.carried_from_session_id,
);
if (!has_bot_message) return false;
const first_user = messages.findIndex((message) => message.author == "user");
if (first_user < 0) return true;
return messages
.slice(first_user + 1)
.some((message) => message.author == "bot" && !message.carried_from_session_id);
}
@@ -0,0 +1,48 @@
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import type { ChatroomBot } from "@/src/types";
import ChatroomStone from "./chatroom-stone";
type Props = {
bots: ChatroomBot[];
};
/**
* The square the cluster fills (`w-7 h-7`), the size of a rail avatar.
*/
const ClusterSize = 28;
/**
* A chatroom's bots packed into one avatar-sized square: the asking bot
* on top, the two below it in a triangle (four make a square, the rest
* are left out). Stones stay small and apart: never overlapping, in a
* badge or outlined. One bot fills the square alone.
*/
export default function ChatroomStoneCluster({ bots }: Props) {
const shown = bots.slice(0, 4);
const stone = Math.floor((ClusterSize - 2) / 2);
if (shown.length <= 1) {
return shown[0] ? <ChatroomStone bot={shown[0]} size={ClusterSize} /> : null;
}
const top = shown.length == 3 ? shown.slice(0, 1) : shown.slice(0, 2);
const bottom = shown.length == 2 ? [] : shown.slice(top.length);
return (
<Stack className="shrink-0 gap-0.5! items-center justify-center w-7 h-7">
<Row className="gap-0.5! flex-nowrap justify-center">
{top.map((bot) => (
<ChatroomStone key={bot.id} bot={bot} size={stone} />
))}
</Row>
{bottom.length ? (
<Row className="gap-0.5! flex-nowrap justify-center">
{bottom.map((bot) => (
<ChatroomStone key={bot.id} bot={bot} size={stone} />
))}
</Row>
) : null}
</Stack>
);
}
@@ -0,0 +1,92 @@
import { useEffect, useState } from "react";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import type { TaskDetail, TaskWithNames } from "@/src/types";
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
import taskPlaceLabel from "@/src/utils/task-place-label";
import TaskDetailPanel from "./task-detail-panel";
type Props = {
/**
* The list row clicked; open while set. Its notes and files load when
* it opens, so the list stays small.
*/
task?: TaskWithNames | null;
/**
* A crew manager can change it, file onto it and delete it.
*/
can_manage?: boolean;
onClose: () => void;
onChanged: (params: { task: TaskDetail; msg?: string }) => void;
onDeleted: (params: { task_id: number }) => void;
};
/**
* A task opened from a list in a modal of its own: everything about it, and
* for a crew manager its edit, hand-over, files and delete.
*/
export default function TaskDetailModal({ task, can_manage, onClose, onChanged, onDeleted }: Props) {
const [detail, set_detail] = useState<TaskDetail | null>(null);
const [error, set_error] = useState<string>();
const task_id = task?.id ? Number(task.id) : 0;
useEffect(() => {
set_detail(null);
set_error(undefined);
if (!task_id) return;
let live = true;
fetchApi<{}, APIResponseObject<TaskDetail>>(`/api/admin/tasks/get-task?task_id=${task_id}`, {
method: "GET",
})
.catch(() => null)
.then((res) => {
if (!live) return;
if (!res?.success || !res.singleRes) {
set_error(res?.msg || "Couldn't open the task.");
return;
}
set_detail(res.singleRes);
});
return () => {
live = false;
};
}, [task_id]);
const shown = detail || task;
return (
<AddModalShell
open={Boolean(task_id)}
setOpen={(open) => !open && onClose()}
title={shown?.title || (task_id ? `Task ${task_id}` : "Task")}
description={shown ? taskPlaceLabel({ task: shown }) : undefined}
>
<Stack className="w-full mt-4 gap-0! items-stretch">
{detail ? (
<TaskDetailPanel
key={detail.id}
task={detail}
can_manage={can_manage}
onChanged={(saved, msg) => {
set_detail(saved);
onChanged({ task: saved, msg });
}}
onDeleted={() => onDeleted({ task_id })}
/>
) : (
<Span className="py-6 text-[13px] leading-[18px] text-ink-secondary">
{error || "Loading the task..."}
</Span>
)}
</Stack>
</AddModalShell>
);
}
+91 -21
View File
@@ -1,4 +1,4 @@
import { useRef, useState } from "react"; import { useContext, useRef, useState } from "react";
import Button from "@moduletrace/twui/layout/Button"; import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row"; import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span"; import Span from "@moduletrace/twui/layout/Span";
@@ -10,6 +10,14 @@ import TaskFileRow from "@/src/components/general/task-file-row";
import HandOverForm from "@/src/components/general/hand-over-form"; import HandOverForm from "@/src/components/general/hand-over-form";
import TaskForm from "@/src/components/general/task-form"; import TaskForm from "@/src/components/general/task-form";
import TaskStatusChip from "@/src/components/general/task-status-chip"; import TaskStatusChip from "@/src/components/general/task-status-chip";
import TaskStatusMoves from "@/src/components/general/task-status-moves";
import BotAvatar from "@/src/components/general/bot-avatar";
import TaskFactRow from "@/src/components/general/task-fact-row";
import TaskNoteRow from "@/src/components/general/task-note-row";
import type { BotFace } from "@/src/dict/bot-dict";
import { AppContext } from "@/src/pages/__root";
import botStoneProps from "@/src/utils/bot-stone-props";
import relativeTime from "@/src/utils/relative-time";
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi"; import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types"; import type { APIResponseObject } from "@moduletrace/bunext/types";
@@ -24,12 +32,14 @@ type Props = {
}; };
/** /**
* One task opened: what it is, its notes oldest first, its files (each * One task opened: its status (a crew manager moves it along), who has it and where, who added it and
* opening its bytes), a box to add a note, and edit or delete. A crew * when it last moved, what it is, its files (each opening its bytes), its
* notes oldest first, a box to add a note, and edit or delete. A crew
* manager can file a new deliverable straight onto it, or hand an open * manager can file a new deliverable straight onto it, or hand an open
* one to another bot, which starts on it. * one to another bot, which starts on it.
*/ */
export default function TaskDetailPanel({ task, can_manage, onChanged, onDeleted }: Props) { export default function TaskDetailPanel({ task, can_manage, onChanged, onDeleted }: Props) {
const { pageProps } = useContext(AppContext);
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [handing_over, setHandingOver] = useState(false); const [handing_over, setHandingOver] = useState(false);
const [note, setNote] = useState(""); const [note, setNote] = useState("");
@@ -135,13 +145,21 @@ export default function TaskDetailPanel({ task, can_manage, onChanged, onDeleted
<Stack className="w-full gap-4! items-stretch"> <Stack className="w-full gap-4! items-stretch">
<Row className="w-full gap-2! flex-nowrap items-center"> <Row className="w-full gap-2! flex-nowrap items-center">
<TaskStatusChip status={task.status} /> <TaskStatusChip status={task.status} />
<Span className="grow min-w-0 text-[13px] leading-[18px] text-ink-secondary truncate">
{task.workspace_id
? task.workspace_name || `Workspace ${task.workspace_id}`
: task.bot_name || `Bot ${task.bot_id}`}
</Span>
{can_manage ? ( {can_manage ? (
<Row className="shrink-0 gap-1! flex-nowrap"> <TaskStatusMoves
status={task.status}
disabled={busy}
onMove={(next) =>
run({
path: "tasks/save-task",
body: { task_id: Number(task.id), status: next },
done: "Status changed.",
})
}
/>
) : null}
{can_manage ? (
<Row className="ml-auto shrink-0 gap-1! flex-nowrap">
{(OpenTaskStatuses as readonly string[]).includes(task.status || "") ? ( {(OpenTaskStatuses as readonly string[]).includes(task.status || "") ? (
<Button <Button
title="Hand the task to another bot" title="Hand the task to another bot"
@@ -200,6 +218,59 @@ export default function TaskDetailPanel({ task, can_manage, onChanged, onDeleted
) : null} ) : null}
</Row> </Row>
<Stack className="w-full gap-1.5! items-stretch">
<TaskFactRow label="Who has it">
{task.bot_id ? (
<>
<BotAvatar
image_media_id={task.bot_image_media_id}
{...botStoneProps({ color: task.bot_color, stone: task.bot_stone, hue: task.bot_hue })}
face={(task.bot_face || "idle") as BotFace}
size={18}
/>
<Span className="min-w-0 truncate">{task.bot_name || `Bot ${task.bot_id}`}</Span>
</>
) : (
<Span className="text-ink-secondary">No bot</Span>
)}
</TaskFactRow>
<TaskFactRow label="Where">
<Span className="min-w-0 truncate">
{task.workspace_id
? task.workspace_name || `Workspace ${task.workspace_id}`
: `${task.bot_name || "The bot"}'s own list`}
</Span>
</TaskFactRow>
<TaskFactRow label="Added">
<Span className="min-w-0 truncate">
{[
task.created_by_bot_id
? `By ${task.created_by_bot_name || `bot ${task.created_by_bot_id}`}`
: task.created_by_user_id
? task.created_by_user_id == pageProps?.user?.id
? "By you"
: `By ${task.created_by_user_name || "a user"}`
: task.origin == "bot"
? "By a bot"
: "By hand",
task.created_at ? relativeTime({ at: Number(task.created_at) }) : null,
]
.filter(Boolean)
.join(" · ")}
</Span>
</TaskFactRow>
{task.updated_at ? (
<TaskFactRow label="Last moved">
<Span>{relativeTime({ at: Number(task.updated_at) })}</Span>
</TaskFactRow>
) : null}
{task.completed_at ? (
<TaskFactRow label="Finished">
<Span>{relativeTime({ at: Number(task.completed_at) })}</Span>
</TaskFactRow>
) : null}
</Stack>
{task.status == "blocked" && task.blocked_reason ? ( {task.status == "blocked" && task.blocked_reason ? (
<Span className="text-[13px] leading-[18px] text-ink">{`Waiting on you: ${task.blocked_reason}`}</Span> <Span className="text-[13px] leading-[18px] text-ink">{`Waiting on you: ${task.blocked_reason}`}</Span>
) : null} ) : null}
@@ -209,7 +280,9 @@ export default function TaskDetailPanel({ task, can_manage, onChanged, onDeleted
) : null} ) : null}
<Stack className="w-full gap-1! items-stretch"> <Stack className="w-full gap-1! items-stretch">
<Span className="text-[12px] font-bold leading-4 text-ink-secondary">Files</Span> <Span className="text-[12px] font-bold leading-4 text-ink-secondary">
{task.files.length ? `Files (${task.files.length})` : "Files"}
</Span>
{deliverables.length || references.length ? ( {deliverables.length || references.length ? (
<Stack className="w-full gap-0! items-stretch"> <Stack className="w-full gap-0! items-stretch">
{[...deliverables, ...references].map((file) => ( {[...deliverables, ...references].map((file) => (
@@ -253,18 +326,15 @@ export default function TaskDetailPanel({ task, can_manage, onChanged, onDeleted
</Stack> </Stack>
<Stack className="w-full gap-1! items-stretch"> <Stack className="w-full gap-1! items-stretch">
<Span className="text-[12px] font-bold leading-4 text-ink-secondary">Notes</Span> <Span className="text-[12px] font-bold leading-4 text-ink-secondary">
{task.notes.length ? `Notes (${task.notes.length})` : "Notes"}
</Span>
{task.notes.length ? ( {task.notes.length ? (
task.notes.map((entry) => ( <Stack className="w-full gap-0! items-stretch">
<Row key={entry.id} className="w-full gap-2! flex-nowrap items-start"> {task.notes.map((entry) => (
<Span className="shrink-0 text-[11px] leading-[18px] text-ink-secondary"> <TaskNoteRow key={entry.id} note={entry} />
{entry.author == "user" ? "You" : "Bot"} ))}
</Span> </Stack>
<Span className="grow min-w-0 whitespace-pre-wrap text-[13px] leading-[18px] text-ink">
{entry.text}
</Span>
</Row>
))
) : ( ) : (
<Span className="text-[13px] leading-[18px] text-ink-secondary">No notes yet.</Span> <Span className="text-[13px] leading-[18px] text-ink-secondary">No notes yet.</Span>
)} )}
+22
View File
@@ -0,0 +1,22 @@
import type { ReactNode } from "react";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
type Props = {
label: string;
children: ReactNode;
};
/**
* One fact about an opened task: a label, then its value.
*/
export default function TaskFactRow({ label, children }: Props) {
return (
<Row className="w-full gap-3! flex-nowrap items-center">
<Span className="w-24 shrink-0 text-[12px] leading-[18px] text-ink-secondary">{label}</Span>
<Row className="grow min-w-0 gap-2! flex-nowrap items-center text-[13px] leading-[18px] text-ink">
{children}
</Row>
</Row>
);
}
+74 -27
View File
@@ -6,6 +6,7 @@ import Input from "@moduletrace/twui/form/Input";
import Select from "@moduletrace/twui/form/Select"; import Select from "@moduletrace/twui/form/Select";
import Textarea from "@moduletrace/twui/form/Textarea"; import Textarea from "@moduletrace/twui/form/Textarea";
import Stack from "@moduletrace/twui/layout/Stack"; import Stack from "@moduletrace/twui/layout/Stack";
import CodeEditor from "@/src/components/general/code-editor";
import { AppData } from "@/src/data/app-data"; import { AppData } from "@/src/data/app-data";
import { TaskStatuses, type TaskStatus } from "@/src/dict/task-dict"; import { TaskStatuses, type TaskStatus } from "@/src/dict/task-dict";
import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions"; import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
@@ -17,6 +18,11 @@ type Props = {
*/ */
bot_id?: number | null; bot_id?: number | null;
workspace_id?: number | null; workspace_id?: number | null;
/**
* Adding to a workspace without a `bot_id`: the bots there to pick who
* has it, since every task belongs to a bot.
*/
bots?: { id?: number | string | null; name?: string | null }[];
/** /**
* Edits this task; adds one when not given. * Edits this task; adds one when not given.
*/ */
@@ -26,15 +32,30 @@ type Props = {
}; };
/** /**
* The one task form: a title, the brief, the status and, while it's * The one task form: a title, the brief (in the code editor), the status and, while it's
* blocked, what the user has to do. The workspace's tasks group and the * blocked, what the user has to do. The workspace's tasks group and the
* conversation's tasks modal both add and edit with it. * conversation's tasks modal both add and edit with it.
*/ */
export default function TaskForm({ bot_id, workspace_id, task, onSaved, onCancel }: Props) { export default function TaskForm({
bot_id,
workspace_id,
bots = [],
task,
onSaved,
onCancel,
}: Props) {
const [title, setTitle] = useState(task?.title || ""); const [title, setTitle] = useState(task?.title || "");
const [description, setDescription] = useState(task?.description || ""); const [description, setDescription] = useState(task?.description || "");
const [status, setStatus] = useState<TaskStatus>((task?.status as TaskStatus) || "todo"); const [status, setStatus] = useState<TaskStatus>(
const [blocked_reason, setBlockedReason] = useState(task?.blocked_reason || ""); (task?.status as TaskStatus) || "todo",
);
const [blocked_reason, setBlockedReason] = useState(
task?.blocked_reason || "",
);
const pick_bot = !task && !bot_id && Boolean(workspace_id);
const [picked_bot_id, setPickedBotId] = useState<number | undefined>(
bots[0]?.id ? Number(bots[0].id) : undefined,
);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
@@ -46,24 +67,30 @@ export default function TaskForm({ bot_id, workspace_id, task, onSaved, onCancel
return; return;
} }
if (pick_bot && !picked_bot_id) {
setError("Pick the bot that has it.");
return;
}
setLoading(true); setLoading(true);
setError(undefined); setError(undefined);
const res = await fetchApi<Record<string, unknown>, APIResponseObject<TaskDetail>>( const res = await fetchApi<
"/api/admin/tasks/save-task", Record<string, unknown>,
{ APIResponseObject<TaskDetail>
method: "POST", >("/api/admin/tasks/save-task", {
body: { method: "POST",
task_id: task?.id ? Number(task.id) : undefined, body: {
bot_id: task ? undefined : bot_id || undefined, task_id: task?.id ? Number(task.id) : undefined,
workspace_id: task ? undefined : workspace_id || undefined, bot_id: task ? undefined : bot_id || picked_bot_id || undefined,
title, workspace_id: task ? undefined : workspace_id || undefined,
description, title,
status, description,
blocked_reason: status == "blocked" ? blocked_reason || null : null, status,
}, blocked_reason:
status == "blocked" ? blocked_reason || null : null,
}, },
); });
setLoading(false); setLoading(false);
@@ -77,25 +104,41 @@ export default function TaskForm({ bot_id, workspace_id, task, onSaved, onCancel
return ( return (
<Form onSubmit={submit} className="gap-3!"> <Form onSubmit={submit} className="gap-3!">
<Stack className="gap-3! items-stretch"> <Stack className="gap-6! items-stretch">
<Input <Input
title="Title"
label="Title" label="Title"
showLabel
value={title} value={title}
changeHandler={setTitle} changeHandler={setTitle}
placeholder="What needs doing?" placeholder="What needs doing?"
maxLength={AppData.TaskTitleMaxLength} maxLength={AppData.TaskTitleMaxLength}
disabled={loading} disabled={loading}
/> />
<Textarea {pick_bot ? (
label="Brief" <Select
value={description} title="Who has it"
changeHandler={setDescription} label="Who has it"
showLabel
options={bots.map((bot) => ({
title: bot.name || `Bot ${bot.id}`,
value: String(bot.id),
default: Number(bot.id) == picked_bot_id,
}))}
changeHandler={(value) => setPickedBotId(Number(value) || undefined)}
/>
) : null}
<CodeEditor
mode="markdown"
className="h-[500px]"
title="Brief"
placeholder="What to do, where, and how you'll know it's done." placeholder="What to do, where, and how you'll know it's done."
maxLength={AppData.TaskDescriptionMaxLength} content={description}
rows={5} info={`Up to ${AppData.TaskDescriptionMaxLength.toLocaleString()} characters.`}
disabled={loading} onChange={setDescription}
/> />
<Select <Select
title="Status"
label="Status" label="Status"
showLabel showLabel
options={TaskStatuses.map((it) => ({ options={TaskStatuses.map((it) => ({
@@ -103,11 +146,15 @@ export default function TaskForm({ bot_id, workspace_id, task, onSaved, onCancel
value: it.value, value: it.value,
default: it.value == status, default: it.value == status,
}))} }))}
changeHandler={(value) => setStatus(String(value) as TaskStatus)} changeHandler={(value) =>
setStatus(String(value) as TaskStatus)
}
/> />
{status == "blocked" ? ( {status == "blocked" ? (
<Textarea <Textarea
title="Waiting on the user for"
label="Waiting on the user for" label="Waiting on the user for"
showLabel
value={blocked_reason} value={blocked_reason}
changeHandler={setBlockedReason} changeHandler={setBlockedReason}
placeholder="What the user has to do before this can go on." placeholder="What the user has to do before this can go on."
+26 -28
View File
@@ -1,4 +1,5 @@
import { useContext } from "react"; import { useContext } from "react";
import { twMerge } from "tailwind-merge";
import Row from "@moduletrace/twui/layout/Row"; import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span"; import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack"; import Stack from "@moduletrace/twui/layout/Stack";
@@ -8,35 +9,41 @@ import { AppContext } from "@/src/pages/__root";
import BotAvatar from "@/src/components/general/bot-avatar"; import BotAvatar from "@/src/components/general/bot-avatar";
import type { BotFace } from "@/src/dict/bot-dict"; import type { BotFace } from "@/src/dict/bot-dict";
import botStoneProps from "@/src/utils/bot-stone-props"; import botStoneProps from "@/src/utils/bot-stone-props";
import { TaskStatuses } from "@/src/dict/task-dict";
import canManageCrew from "@/src/utils/rbac/can-manage-crew"; import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import relativeTime from "@/src/utils/relative-time"; import relativeTime from "@/src/utils/relative-time";
import taskPlaceLabel from "@/src/utils/task-place-label"; import taskPlaceLabel from "@/src/utils/task-place-label";
import { Trash2 } from "lucide-react";
import RowIconButton from "./row-icon-button";
import TaskStatusChip from "./task-status-chip"; import TaskStatusChip from "./task-status-chip";
import TaskStatusMoves from "./task-status-moves";
type Props = { type Props = {
task: TaskWithNames; task: TaskWithNames;
/** /**
* Opens the task to read its notes and files, and to edit it. * Opens the task in its modal: everything about it, and for a crew
* manager its edit, hand-over, files and delete. The title opens it.
*/ */
onOpen?: () => void; onOpen?: () => void;
/** /**
* Moves it to one status; only crew managers get the controls. * Moves it to one status; only crew managers get the controls.
*/ */
onStatusChange?: (status: string) => void; onStatusChange?: (status: string) => void;
/**
* Deletes it after asking; only crew managers get the button.
*/
onDelete?: () => void;
busy?: boolean; busy?: boolean;
}; };
/** /**
* One task: whose it is (a bot's stone), its title and status, where it * One task: whose it is (a bot's stone), its title and status, where it
* is and who has it, what it's blocked on, its latest note and how long ago it * is and who has it, what it's blocked on, its latest note and how long ago it
* moved. * moved. Its title opens it in a modal.
*/ */
export default function TaskListItem({ task, onOpen, onStatusChange, busy }: Props) { export default function TaskListItem({ task, onOpen, onStatusChange, onDelete, busy }: Props) {
const { pageProps } = useContext(AppContext); const { pageProps } = useContext(AppContext);
const can_manage = canManageCrew({ user_types: pageProps?.user_types }); const can_manage = canManageCrew({ user_types: pageProps?.user_types });
const done = task.status == "done" || task.status == "cancelled"; const done = task.status == "done" || task.status == "cancelled";
const moves = TaskStatuses.filter((it) => ["todo", "doing", "done"].includes(it.value));
return ( return (
<Row className="w-full gap-3! flex-nowrap items-start px-3 py-3 border-0 border-b border-solid border-hairline last:border-b-0"> <Row className="w-full gap-3! flex-nowrap items-start px-3 py-3 border-0 border-b border-solid border-hairline last:border-b-0">
@@ -49,18 +56,27 @@ export default function TaskListItem({ task, onOpen, onStatusChange, busy }: Pro
<Stack className="grow min-w-0 gap-0.5! items-stretch"> <Stack className="grow min-w-0 gap-0.5! items-stretch">
<Row className="w-full gap-2! flex-nowrap items-center"> <Row className="w-full gap-2! flex-nowrap items-center">
<Button <Button
title={task.title || "Open the task"} title={`Open ${task.title || `task ${task.id}`}`}
type="button" type="button"
variant="ghost" variant="ghost"
color="text" color="text"
onClick={onOpen} onClick={onOpen}
className={`grow min-w-0 justify-start px-0! h-auto! py-0! text-sm! font-bold! leading-5! truncate text-left hover:bg-transparent! hover:underline ${ className={twMerge(
done ? "text-ink-secondary! line-through" : "text-ink!" "grow min-w-0 justify-start px-0! h-auto! py-0! text-sm! font-bold! leading-5! truncate text-left hover:bg-transparent! hover:underline",
}`} done ? "text-ink-secondary! line-through" : "text-ink!",
)}
> >
<Span className="truncate">{task.title || `Task ${task.id}`}</Span> <Span className="truncate">{task.title || `Task ${task.id}`}</Span>
</Button> </Button>
<TaskStatusChip status={task.status} /> <TaskStatusChip status={task.status} />
{can_manage && onDelete ? (
<RowIconButton
icon={Trash2}
title={`Delete ${task.title || `task ${task.id}`}`}
disabled={busy}
onClick={onDelete}
/>
) : null}
</Row> </Row>
<Row className="w-full gap-2! flex-nowrap items-center"> <Row className="w-full gap-2! flex-nowrap items-center">
<Span className="shrink-0 text-xs leading-4 text-ink-secondary truncate"> <Span className="shrink-0 text-xs leading-4 text-ink-secondary truncate">
@@ -81,25 +97,7 @@ export default function TaskListItem({ task, onOpen, onStatusChange, busy }: Pro
<Span className="text-xs leading-4 text-ink-secondary line-clamp-2">{task.latest_note}</Span> <Span className="text-xs leading-4 text-ink-secondary line-clamp-2">{task.latest_note}</Span>
) : null} ) : null}
{can_manage && onStatusChange ? ( {can_manage && onStatusChange ? (
<Row className="gap-1! flex-nowrap items-center pt-0.5"> <TaskStatusMoves status={task.status} disabled={busy} onMove={onStatusChange} className="pt-0.5" />
{moves
.filter((it) => it.value != task.status)
.map((it) => (
<Button
key={it.value}
title={`Mark it ${it.title.toLowerCase()}`}
type="button"
variant="ghost"
color="gray"
size="small"
disabled={busy}
onClick={() => onStatusChange(it.value)}
className="px-1.5! text-[12px]"
>
{it.title}
</Button>
))}
</Row>
) : null} ) : null}
</Stack> </Stack>
</Row> </Row>
+31
View File
@@ -0,0 +1,31 @@
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import type { TaskNote } from "@/src/types";
import relativeTime from "@/src/utils/relative-time";
type Props = {
note: TaskNote;
};
/**
* One note on an opened task: who wrote it (the user, or the bot by name),
* how long ago, and what it says.
*/
export default function TaskNoteRow({ note }: Props) {
const author = note.author == "user" ? "You" : note.bot_name || "Bot";
return (
<Stack className="w-full gap-0.5! items-stretch py-1.5 border-0 border-b border-solid border-hairline last:border-b-0">
<Row className="w-full gap-2! flex-nowrap items-center">
<Span className="text-[12px] font-bold leading-4 text-ink">{author}</Span>
{note.created_at ? (
<Span className="text-[11px] leading-4 text-ink-secondary">
{relativeTime({ at: Number(note.created_at) })}
</Span>
) : null}
</Row>
<Span className="whitespace-pre-wrap text-[13px] leading-[18px] text-ink">{note.text}</Span>
</Stack>
);
}
+14 -8
View File
@@ -1,3 +1,4 @@
import { twMerge } from "tailwind-merge";
import Tag from "@moduletrace/twui/elements/Tag"; import Tag from "@moduletrace/twui/elements/Tag";
import { TaskStatuses, type TaskStatus } from "@/src/dict/task-dict"; import { TaskStatuses, type TaskStatus } from "@/src/dict/task-dict";
@@ -14,15 +15,20 @@ export default function TaskStatusChip({ status = "todo" }: Props) {
const value = (status || "todo") as TaskStatus; const value = (status || "todo") as TaskStatus;
const title = TaskStatuses.find((it) => it.value == value)?.title || "To do"; const title = TaskStatuses.find((it) => it.value == value)?.title || "To do";
const tone = /**
value == "blocked" * A `Tag` is a filled primary button by default (ink in light mode), so
? "text-ink border-ink" * the chip clears its fill and sets its own text: only the outline speaks.
: value == "doing" */
? "text-ink border-hairline-strong"
: "text-ink-secondary border-hairline";
return ( return (
<Tag className={`shrink-0 px-2! py-0.5! text-[11px]! font-bold! leading-4! border border-solid ${tone}`}> <Tag
className={twMerge(
"shrink-0 px-2! py-0.5! text-[11px]! font-bold! leading-4! border border-solid",
"bg-transparent! dark:bg-transparent! hover:bg-transparent!",
value == "blocked" && "text-ink! dark:text-ink! border-ink",
value == "doing" && "text-ink! dark:text-ink! border-hairline-strong",
value != "blocked" && value != "doing" && "text-ink-secondary! dark:text-ink-secondary! border-hairline",
)}
>
{title} {title}
</Tag> </Tag>
); );
@@ -0,0 +1,28 @@
import Button from "@moduletrace/twui/layout/Button";
type Props = {
title: string;
value: string;
disabled?: boolean;
onMove: (status: string) => void;
};
/**
* Moves a task to one status: To do, Doing or Done.
*/
export default function TaskStatusMoveButton({ title, value, disabled, onMove }: Props) {
return (
<Button
title={`Mark it ${title.toLowerCase()}`}
type="button"
variant="ghost"
color="gray"
size="small"
disabled={disabled}
onClick={() => onMove(value)}
className="px-1.5! text-[12px]"
>
{title}
</Button>
);
}
@@ -0,0 +1,36 @@
import Row from "@moduletrace/twui/layout/Row";
import { twMerge } from "tailwind-merge";
import { TaskStatuses } from "@/src/dict/task-dict";
import TaskStatusMoveButton from "./task-status-move-button";
type Props = {
/**
* The task's status now; its own button is left out.
*/
status?: string | null;
disabled?: boolean;
onMove: (status: string) => void;
className?: string;
};
const MoveStatuses = TaskStatuses.filter((it) => ["todo", "doing", "done"].includes(it.value));
/**
* The buttons that move a task along (To do, Doing, Done), the one it's
* at left out. Shown to crew managers on a list row and in the task modal.
*/
export default function TaskStatusMoves({ status, disabled, onMove, className }: Props) {
return (
<Row className={twMerge("gap-1! flex-nowrap items-center", className)}>
{MoveStatuses.filter((it) => it.value != status).map((it) => (
<TaskStatusMoveButton
key={it.value}
title={it.title}
value={it.value}
disabled={disabled}
onMove={onMove}
/>
))}
</Row>
);
}
+8
View File
@@ -0,0 +1,8 @@
import clearFinishedJobSteps from "@/src/functions/backend/db/bots-queue/clear-finished-job-steps";
/**
* Clears the steps of jobs that have ended (`clearFinishedJobSteps`).
*/
export default async function cronClearJobSteps() {
return await clearFinishedJobSteps();
}
+15
View File
@@ -1,8 +1,10 @@
import bunext from "@moduletrace/bunext"; 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 cronClearJobSteps from "./clear-job-steps";
import pruneBotHookEvents from "./prune-bot-hook-events"; import pruneBotHookEvents from "./prune-bot-hook-events";
import cronPruneChatrooms from "./prune-chatrooms"; import cronPruneChatrooms from "./prune-chatrooms";
import pruneLogs from "./prune-logs";
import syncNotifyWatchers from "./sync-notify-watchers"; import syncNotifyWatchers from "./sync-notify-watchers";
import cronSyncHarnessModels from "./sync-harness-models"; import cronSyncHarnessModels from "./sync-harness-models";
import syncComputers from "./sync-computers"; import syncComputers from "./sync-computers";
@@ -31,6 +33,13 @@ export default function cron() {
task: pruneBotsQueue, task: pruneBotsQueue,
}); });
registerCronJob({
title: "clear-job-steps",
schedule: "*/5 * * * *",
task: cronClearJobSteps,
quiet: true,
});
registerCronJob({ registerCronJob({
title: "prune-bot-hook-events", title: "prune-bot-hook-events",
schedule: "@daily", schedule: "@daily",
@@ -43,6 +52,12 @@ export default function cron() {
task: cronPruneChatrooms, task: cronPruneChatrooms,
}); });
registerCronJob({
title: "prune-logs",
schedule: "@daily",
task: pruneLogs,
});
registerCronJob({ registerCronJob({
title: "sync-notify-watchers", title: "sync-notify-watchers",
schedule: "*/5 * * * *", schedule: "*/5 * * * *",
+4 -23
View File
@@ -1,31 +1,12 @@
import BunSQLite from "@moduletrace/bun-sqlite"; import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOT_FILES } from "@/db/types/db";
import enqueueDocumentIngest from "@/src/file-ingest/enqueue-document-ingest"; import enqueueDocumentIngest from "@/src/file-ingest/enqueue-document-ingest";
import enqueueFileIngest from "@/src/file-ingest/enqueue-file-ingest";
/** /**
* Queues any bot file or knowledgebase document that never got ingested (an upload that happened * Queues any knowledgebase document that never got ingested (an upload
* while the worker was down, a restart mid-ingest), so nothing is left * that happened while the worker was down, a restart mid-ingest), so
* unsearchable. Runs every ten minutes. * nothing is left unsearchable. Runs every ten minutes.
*/ */
export default async function ingestPendingFiles() { export default async function ingestPendingFiles() {
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_FILES>({
sql: `SELECT f.id FROM bot_files f
WHERE f.ingested_at IS NULL
AND f.ingest_error IS NULL
AND f.created_at > ?
AND NOT EXISTS (
SELECT 1 FROM file_ingest_queue q
WHERE q.file_id = f.id AND q.status IN ('pending', 'processing')
)
LIMIT 25`,
values: [Date.now() - 24 * 60 * 60 * 1000],
});
for (const file of res.payload || []) {
if (file.id) await enqueueFileIngest({ file_id: file.id });
}
const documents = await BunSQLite.sql<{ id: number }>({ const documents = await BunSQLite.sql<{ id: number }>({
sql: `SELECT d.id FROM knowledgebase_documents d sql: `SELECT d.id FROM knowledgebase_documents d
WHERE d.ingested_at IS NULL WHERE d.ingested_at IS NULL
@@ -41,5 +22,5 @@ export default async function ingestPendingFiles() {
if (document.id) await enqueueDocumentIngest({ document_id: document.id }); if (document.id) await enqueueDocumentIngest({ document_id: document.id });
} }
return (res.payload?.length || 0) + (documents.payload?.length || 0); return documents.payload?.length || 0;
} }
+15
View File
@@ -0,0 +1,15 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import { grabLogsKeepDays } from "@/src/functions/backend/db/app-settings/grab-logs-keep-days";
/**
* Deletes logs older than the install's retention (`grabLogsKeepDays`).
* Nothing points at a log, so no children go with them.
*/
export default async function pruneLogs() {
const cutoff = Date.now() - (await grabLogsKeepDays()) * 24 * 60 * 60 * 1000;
return await BunSQLite.sql({
sql: `DELETE FROM logs WHERE created_at < ? RETURNING id`,
values: [cutoff],
});
}
+55 -4
View File
@@ -278,6 +278,13 @@ export const AppData = {
BotHookMaxEventsPerRun: 20, BotHookMaxEventsPerRun: 20,
BotHookEventsKeepDays: 30, BotHookEventsKeepDays: 30,
BotHookEventsKeepDaysMax: 3650, BotHookEventsKeepDaysMax: 3650,
/**
* Logs (Activity) are deleted after this many days by the daily
* `prune-logs` cron: the default, with the most an install may set
* (`logs_keep_days`, Settings > General).
*/
LogsKeepDays: 90,
LogsKeepDaysMax: 3650,
/** /**
* How many hook calls a list loads at once, and how much of each call's * How many hook calls a list loads at once, and how much of each call's
* payload and response a list shows (the whole of both opens on one). * payload and response a list shows (the whole of both opens on one).
@@ -346,6 +353,12 @@ export const AppData = {
* Upload cap for audio sent to transcription. * Upload cap for audio sent to transcription.
*/ */
HarnessMaxAudioBytes: 25 * 1024 * 1024, HarnessMaxAudioBytes: 25 * 1024 * 1024,
/**
* Reference images one `generate_image` call passes on, and the
* largest each may be.
*/
ImageReferenceMaxCount: 4,
ImageReferenceMaxBytes: 10 * 1024 * 1024,
/** /**
* Connecting to an MCP server (a computer) gives up after this long. * Connecting to an MCP server (a computer) gives up after this long.
*/ */
@@ -354,6 +367,13 @@ export const AppData = {
* One MCP tool call gives up after this long without progress. * One MCP tool call gives up after this long without progress.
*/ */
McpToolTimeoutMs: 5 * 60 * 1000, McpToolTimeoutMs: 5 * 60 * 1000,
/**
* A bash command on a computer is killed there after at most this long
* (`withCommandTimeout`), below `McpToolTimeoutMs`: the sandbox ignores
* its own `timeout` and runs every call in one shell session, so a
* command left running would hold up every later one on that computer.
*/
ComputerCommandMaxSeconds: 270,
/** /**
* MCP servers set up in Settings, under MCPs: a stdio server runs in * MCP servers set up in Settings, under MCPs: a stdio server runs in
* `DATA_DIR/<McpDataDir>/<id>` (its setup installs there, so it * `DATA_DIR/<McpDataDir>/<id>` (its setup installs there, so it
@@ -414,6 +434,12 @@ export const AppData = {
* working-on panel shows. * working-on panel shows.
*/ */
BotJobUpdatesShown: 12, BotJobUpdatesShown: 12,
/**
* How long a finished job's steps stay before they're cleared
* (`clear-job-steps` cron). Long enough for every process's live relay
* to have pushed the last ones (`LiveRelayPollMs`).
*/
BotJobStepsKeepMs: 60 * 1000,
/** /**
* How often each server process reads new bot messages, job steps and * How often each server process reads new bot messages, job steps and
* bot changes from the database for its own sockets (`liveRelay`). * bot changes from the database for its own sockets (`liveRelay`).
@@ -571,6 +597,18 @@ export const AppData = {
* inside its frame. * inside its frame.
*/ */
BotGraphicInlineMaxHeight: 640, BotGraphicInlineMaxHeight: 640,
/**
* Most questions one `ask_user` card holds.
*/
BotQuestionMaxQuestions: 6,
/**
* Most options one question offers (its "Other" not counted).
*/
BotQuestionMaxOptions: 8,
/**
* Longest answer a user may type into a question's "Other".
*/
BotQuestionOtherMaxChars: 1000,
/** /**
* Longest dictation the composer records before it stops on its own. * Longest dictation the composer records before it stops on its own.
*/ */
@@ -648,8 +686,9 @@ export const AppData = {
*/ */
BotFileImportMaxBytes: 20 * 1024 * 1024, BotFileImportMaxBytes: 20 * 1024 * 1024,
/** /**
* Ingesting a bot file: how much text one chunk holds, how much of the * Ingesting a knowledgebase document: how much text one chunk holds,
* previous chunk it repeats, and the largest file it reads at all. * how much of the previous chunk it repeats, and the largest file it
* reads at all (also the largest attachment a reply reads).
*/ */
ChunkTargetChars: 1200, ChunkTargetChars: 1200,
ChunkOverlapChars: 200, ChunkOverlapChars: 200,
@@ -668,12 +707,24 @@ export const AppData = {
EmbeddingBatchSize: 32, EmbeddingBatchSize: 32,
EmbeddingMaxInputChars: 8000, EmbeddingMaxInputChars: 8000,
/** /**
* How many chunks a file search returns, and how many more it asks * How many chunks a knowledgebase search returns, and how many more it
* the vector index for before filtering to the run's scopes. * asks the vector index for before filtering to the run's ones.
*/ */
RagTopChunks: 8, RagTopChunks: 8,
RagOverFetch: 4, RagOverFetch: 4,
RagMaxK: 60, RagMaxK: 60,
/**
* How long a search box waits after the last keystroke before it
* searches.
*/
SearchDebounceMs: 250,
/**
* The database is vacuumed at boot once this share of its pages, and
* at least this many bytes, sit free (after a big delete, such as the
* bot file index being dropped).
*/
DbVacuumMinFreeRatio: 0.2,
DbVacuumMinFreeBytes: 4 * 1024 * 1024,
/** /**
* Knowledgebases: the most one document holds (an upload, or text * Knowledgebases: the most one document holds (an upload, or text
* written in), how much of a pasted text a form takes, how many * written in), how much of a pasted text a form takes, how many
+17 -8
View File
@@ -60,18 +60,18 @@ export const botFilesNote = ({ bot_id }: { bot_id: number | string }) => `# Your
You have a folder of your own, kept with the app (not on a computer), and it stays between runs and conversations. You have a folder of your own, kept with the app (not on a computer), and it stays between runs and conversations.
- list_files shows what's in it; read_file opens one (by its [file:ID] ref or path); write_file saves a text file, make_pdf a PDF, generate_image an image. Move and delete with move_file and delete_file when something is out of date, so the folder stays useful. - list_files shows what's in it; read_file opens one (by its [file:ID] ref or path); write_file saves a text file, make_pdf a PDF, generate_image an image. Move and delete with move_file and delete_file when something is out of date, so the folder stays useful.
- Text files are chunked and searchable as soon as they're saved. search_files finds the passages that answer a question, by meaning when an embedding model is set and by words otherwise. Search your files before saying you don't know something they may hold, and when the user asks about a document they gave you. - Files are found by name or path, never by what's in them: list_files with "search" matches names and paths, then read_file reads the one you want. Look for a file and read it before saying you don't know something it may hold, and when the user asks about a document they gave you. Searching by content is what knowledgebases are for; your files aren't indexed.
- Uploaded files land in the same folder, under uploads/ (uploads/2026-10/logo.png), so what the user sent you earlier is searchable later too. - Uploaded files land in the same folder, under uploads/ (uploads/2026-10/logo.png), so what the user sent you earlier is there to list and read later too.
- A picture can be set from a file: set_picture makes a file your own avatar, and update_workspace sets a workspace's. Both take the [file:ID] ref, so a logo the user sent you is usually ready to go: list_files for it, then set it. An SVG or another kind of file is turned into a PNG first. Its picture is free to change and never removes your file. - A picture can be set from a file: set_picture makes a file your own avatar, and update_workspace sets a workspace's. Both take the [file:ID] ref, so a logo the user sent you is usually ready to go: list_files for it, then set it. An SVG or another kind of file is turned into a PNG first. Its picture is free to change and never removes your file.
- A file and a folder are two different refs, and you never guess which one from the name: - A file and a folder are two different refs, and you never guess which one from the name:
- A file you saved is a [file:ID] ref (from write_file, list_files, search_files or import_file). The user sees a chip that opens the file. - A file you saved is a [file:ID] ref (from write_file, list_files or import_file). The user sees a chip that opens the file.
- A folder is a folder ref, e.g. ${ownFolderRef({ bot_id })}: the user sees a chip that opens that folder in Files, and nothing is downloaded. Use the volume of the folder you mean ("bot" for your own, "workspace" for a workspace's shared one). - A folder is a folder ref, e.g. ${ownFolderRef({ bot_id })}: the user sees a chip that opens that folder in Files, and nothing is downloaded. Use the volume of the folder you mean ("bot" for your own, "workspace" for a workspace's shared one).
- Never write a path as plain text and expect the user to find it; never write a [file:ID] ref for something that is a folder. - Never write a path as plain text and expect the user to find it; never write a [file:ID] ref for something that is a folder.
- Write refs as they are, never in backticks or a code block: inside code they show as plain text, not a chip. - Write refs as they are, never in backticks or a code block: inside code they show as plain text, not a chip.
- A file you only described is not saved. Save it first, then say it's saved. - A file you only described is not saved. Save it first, then say it's saved.
- Save what the user will want before the long tail of a run. A step limit ends a run where it stands: write the report first with write_file (or copy it off the computer with import_file), then carry on verifying. - Save what the user will want before the long tail of a run. A step limit ends a run where it stands: write the report first with write_file (or copy it off the computer with import_file), then carry on verifying.
- One copy of a thing is enough. Before you write or import something, list_files (or search_files) for it: if it's already there, update it (write_file over the same path) instead of saving a second copy under a new name. Never split a file into parts unless the user asked for parts. - One copy of a thing is enough. Before you write or import something, list_files for it: if it's already there, update it (write_file over the same path) instead of saving a second copy under a new name. Never split a file into parts unless the user asked for parts.
- Your files don't disappear. If you can't find something, list_files or search_files before saying anything is missing, and never claim the folder is empty without having just listed it. - Your files don't disappear. If you can't find something, list_files (with and without "search") before saying anything is missing, and never claim the folder is empty without having just listed it.
- Report what the tools actually returned. Don't invent limits, sizes or caps you haven't seen in a tool result: if an import or a write failed, say what the tool said.`; - Report what the tools actually returned. Don't invent limits, sizes or caps you haven't seen in a tool result: if an import or a write failed, say what the tool said.`;
/** /**
@@ -102,6 +102,7 @@ You work only in your own folders on the computer, never elsewhere in it: every
dirs.shared_dir ? `\n- ${dirs.shared_dir}: shared with every bot in this workspace.` : "" dirs.shared_dir ? `\n- ${dirs.shared_dir}: shared with every bot in this workspace.` : ""
} }
- The browser downloads to its own folder: move what you download into your folder straight away. - The browser downloads to its own folder: move what you download into your folder straight away.
- A computer can be restarted or rebuilt at any time, and only ${AppData.ComputerWorkspaceDir} and the browser's logins survive it. Programs you install (sudo apt-get install -y, pip, npm -g) may be gone afterwards: check for one first (command -v) and install it again when it's missing.
What you make there (downloads, screenshots, a crawl, raw evidence, logs) is kept with the computer, not in your files, and it is not searchable. What you make there (downloads, screenshots, a crawl, raw evidence, logs) is kept with the computer, not in your files, and it is not searchable.
@@ -163,6 +164,14 @@ Anything you read or write goes to your model provider, so secret values (passwo
- Never print, cat or echo a secret on a computer. To show one to the user, use show_secret for an env you have, or in a task on a computer capture it with capture_secret: either gives you a [secret:ID] ref to put in your message. - Never print, cat or echo a secret on a computer. To show one to the user, use show_secret for an env you have, or in a task on a computer capture it with capture_secret: either gives you a [secret:ID] ref to put in your message.
- Use secrets by key ($KEY in the shell, {{env.KEY}} in tools), never by value. A value you see as [KEY] in output was redacted: that's expected.`; - Use secrets by key ($KEY in the shell, {{env.KEY}} in tools), never by value. A value you see as [KEY] in output was redacted: that's expected.`;
/**
* Added to chat replies and to tasks the user started: before acting on
* something unclear, a bot asks (`ask_user`) rather than guesses.
*/
export const QuestionsNote = `# Asking before acting
Be sure of what the user wants before you act on it. When a request could mean more than one thing, when a choice is theirs to make, or before work that is hard to undo, costly or seen by others, and you can't tell what they'd pick, ask with ask_user: a card of multiple-choice questions (one pick or several, with "Other" for their own words) they answer in a click. Give options that are concrete and distinct, put the one you'd suggest first and say so in it ("Staging (suggested)"). Ask everything you need in one card, then wait for their answers before acting on what you asked. Don't ask about what they already said, what you can find out yourself, or small choices any sensible answer settles: just do those. If they skip the questions, don't guess: do only the part that's clear and say what you still need.`;
/** /**
* Added to every run: bots can show a concept as a visual the chat * Added to every run: bots can show a concept as a visual the chat
* renders (`create_graphic`), no computer needed. * renders (`create_graphic`), no computer needed.
@@ -219,7 +228,7 @@ You are working through a task on your own; the user isn't watching. Use your to
*/ */
export const TasksNote = `# Tasks export const TasksNote = `# Tasks
Tasks are the crew's shared to-do list, one per bot and one per workspace. A workspace's tasks are seen by every bot working there, and the coordinator sees everything. Tasks are the crew's to-do list. Every task belongs to one bot, on its own list or in a workspace. You see your own; a workspace's admin sees every task in that workspace, and the coordinator sees everything.
- list_tasks shows what's open: yours, a workspace's, or (as the coordinator) all of it. Check it before you plan work, so you don't start something already tracked. - list_tasks shows what's open: yours, a workspace's, or (as the coordinator) all of it. Check it before you plan work, so you don't start something already tracked.
- create_task adds one, with a brief: what to do, where, and what done looks like. Only add a task for work that will take more than this reply; never one for something you're doing now. - create_task adds one, with a brief: what to do, where, and what done looks like. Only add a task for work that will take more than this reply; never one for something you're doing now.
@@ -278,8 +287,8 @@ update_memory and update_instructions save to this workspace by default: what yo
export const SessionNamePrompt = `Name and summarize this conversation from its first exchange. Answer with a JSON object only, no markdown fence and no other text: export const SessionNamePrompt = `Name and summarize this conversation from its first exchange. Answer with a JSON object only, no markdown fence and no other text:
{"title": "...", "summary": "..."} {"title": "...", "summary": "..."}
- title: what the conversation is about, at most 60 characters, no quotes and no trailing period. Say what the user wanted, not that it is a conversation. When it opens with a summary of an earlier session, name what this one carries on with, not every topic the summary recaps. - title: what the conversation is about, at most 60 characters, no quotes and no trailing period. Say what the user wanted, not that it is a conversation. With no user message, it is work handed to you (by the coordinator, a schedule or a hook): name that work. When it opens with a summary of an earlier session, name what this one carries on with, not every topic the summary recaps.
- summary: what the user wanted and what was done so far. Keep names, numbers, paths and links exactly. Plain markdown bullets, no preamble.`; - summary: what the user (or whoever handed you the work) wanted and what was done so far. Keep names, numbers, paths and links exactly. Plain markdown bullets, no preamble.`;
/** /**
* Asks the text model to carry a conversation into a new session when the * Asks the text model to carry a conversation into a new session when the
+21 -1
View File
@@ -160,6 +160,20 @@ export const PwaDefaultInstallIcon: PwaInstallIcon = {
export const PwaCornerMarkInset = 0.2; export const PwaCornerMarkInset = 0.2;
export const PwaCornerMarkScale = 0.22; export const PwaCornerMarkScale = 0.22;
/**
* The corner mark on the regular (rounded-square) icons, which aren't cut
* to a circle: larger and nearer the corner, clear of its rounding.
*/
export const PwaSquareCornerMarkInset = 0.16;
export const PwaSquareCornerMarkScale = 0.3;
/**
* The corner mark on a round favicon: larger, to stay readable at 32px,
* and still inside the circle.
*/
export const PwaFaviconCornerMarkInset = 0.15;
export const PwaFaviconCornerMarkScale = 0.32;
/** /**
* Uploads must be at least this many pixels on their shorter side, and are * Uploads must be at least this many pixels on their shorter side, and are
* kept as a square of the source size. * kept as a square of the source size.
@@ -167,12 +181,18 @@ export const PwaCornerMarkScale = 0.22;
export const PwaInstallIconMinSide = 512; export const PwaInstallIconMinSide = 512;
export const PwaInstallIconSourceSide = 1024; export const PwaInstallIconSourceSide = 1024;
/**
* Bump when `renderInstallIcons` draws differently: it's part of a set's
* version, so a re-saved icon gets a new URL past the `immutable` cache.
*/
export const PwaInstallIconRenderRevision = 4;
export type PwaInstallIconFile = { export type PwaInstallIconFile = {
name: string; name: string;
size: number; size: number;
/** /**
* `plain` is the image without the mark (the settings preview); * `plain` is the image without the mark (the settings preview);
* `favicon` never gets the mark (unreadable at 32px). * `favicon` is cut to a circle, the mark kept inside it.
*/ */
kind: "any" | "maskable" | "favicon" | "plain"; kind: "any" | "maskable" | "favicon" | "plain";
}; };
+15
View File
@@ -0,0 +1,15 @@
/**
* Where questions a bot asked a user stand (`bot_questions.status`):
* `pending` until the user answers them (`answered`) or skips them.
*/
export const BotQuestionStatuses = ["pending", "answered", "skipped"] as const;
export type BotQuestionStatus = (typeof BotQuestionStatuses)[number];
/**
* How a question is answered: `single` picks one option (radios),
* `multiple` any number of them, at least one (checkboxes).
*/
export const BotQuestionKinds = ["single", "multiple"] as const;
export type BotQuestionKind = (typeof BotQuestionKinds)[number];
+1
View File
@@ -110,6 +110,7 @@ export const JobStepToolLabels: Record<string, string> = {
hand_over_schedule: "Handing a routine to the crew", hand_over_schedule: "Handing a routine to the crew",
propose_hook: "Proposing a hook", propose_hook: "Proposing a hook",
request_secret: "Asking for a secret", request_secret: "Asking for a secret",
ask_user: "Asking you",
capture_secret: "Saving a secret", capture_secret: "Saving a secret",
show_secret: "Showing a secret", show_secret: "Showing a secret",
pause_hook: "Pausing a hook", pause_hook: "Pausing a hook",
+1
View File
@@ -58,6 +58,7 @@ export type CustomToolScriptLanguage = (typeof CustomToolScriptLanguages)[number
*/ */
export const ReservedToolNames = [ export const ReservedToolNames = [
"add_bot_to_workspace", "add_bot_to_workspace",
"ask_user",
"ask_user_to_take_over", "ask_user_to_take_over",
"assign_computer", "assign_computer",
"capture_secret", "capture_secret",
+33
View File
@@ -49,6 +49,14 @@ export const Harnesses = [
required: false, required: false,
install_wide: false, install_wide: false,
}, },
{
title: "Image editing",
value: "image_edit",
automatic: false,
description: `Makes images from reference images a bot passes on: a logo to rework, a style to follow, a photo to change. Until a model is picked here (or while it's off), image generation takes them.`,
required: false,
install_wide: false,
},
{ {
title: "Embeddings", title: "Embeddings",
value: "embedding", value: "embedding",
@@ -68,6 +76,31 @@ export function isInstallWideHarness({ harness }: { harness: HarnessKind }) {
return Boolean(Harnesses.find((h) => h.value == harness)?.install_wide); return Boolean(Harnesses.find((h) => h.value == harness)?.install_wide);
} }
/**
* Harnesses that hand their work to another one instead of picking
* models automatically: image editing goes to image generation while it
* has no model (or is off), and after its model fails when fallback is on.
*/
export const HarnessFallbacks: Partial<Record<HarnessKind, HarnessKind>> = {
image_edit: "image",
};
/**
* The harness this one falls back to, if any.
*/
export function harnessFallsBackTo({ harness }: { harness: HarnessKind }) {
return HarnessFallbacks[harness];
}
/**
* The title of the harness this one falls back to, if any.
*/
export function harnessFallbackTitle({ harness }: { harness: HarnessKind }) {
const to = HarnessFallbacks[harness];
return to ? Harnesses.find((h) => h.value == to)?.title : undefined;
}
/** /**
* The `app_settings` key holding the install's own limit on model turns in * The `app_settings` key holding the install's own limit on model turns in
* one run (a task). Unset means `AppData.HarnessMaxToolSteps`. * one run (a task). Unset means `AppData.HarnessMaxToolSteps`.
+13
View File
@@ -9,6 +9,9 @@
* capabilities are guessed from the names * capabilities are guessed from the names
* - `local`: runs on the user's own hardware, so its models count as free * - `local`: runs on the user's own hardware, so its models count as free
* - `image_path`: image generation endpoint * - `image_path`: image generation endpoint
* - `image_edit_path`: where images made from reference images go. OpenRouter
* takes them on `image_path` (`input_references`); the others take a
* multipart upload at `/images/edits`
*/ */
export const HarnessProviderKinds = [ export const HarnessProviderKinds = [
{ {
@@ -20,6 +23,7 @@ export const HarnessProviderKinds = [
catalog: "openrouter", catalog: "openrouter",
local: false, local: false,
image_path: "/images", image_path: "/images",
image_edit_path: "/images",
}, },
{ {
title: "OpenAI", title: "OpenAI",
@@ -30,6 +34,7 @@ export const HarnessProviderKinds = [
catalog: "openai-compatible", catalog: "openai-compatible",
local: false, local: false,
image_path: "/images/generations", image_path: "/images/generations",
image_edit_path: "/images/edits",
}, },
{ {
title: "Anthropic", title: "Anthropic",
@@ -40,6 +45,7 @@ export const HarnessProviderKinds = [
catalog: "openai-compatible", catalog: "openai-compatible",
local: false, local: false,
image_path: "/images/generations", image_path: "/images/generations",
image_edit_path: "/images/edits",
}, },
{ {
title: "Google Gemini", title: "Google Gemini",
@@ -50,6 +56,7 @@ export const HarnessProviderKinds = [
catalog: "openai-compatible", catalog: "openai-compatible",
local: false, local: false,
image_path: "/images/generations", image_path: "/images/generations",
image_edit_path: "/images/edits",
}, },
{ {
title: "DeepSeek", title: "DeepSeek",
@@ -60,6 +67,7 @@ export const HarnessProviderKinds = [
catalog: "openai-compatible", catalog: "openai-compatible",
local: false, local: false,
image_path: "/images/generations", image_path: "/images/generations",
image_edit_path: "/images/edits",
}, },
{ {
title: "Groq", title: "Groq",
@@ -70,6 +78,7 @@ export const HarnessProviderKinds = [
catalog: "openai-compatible", catalog: "openai-compatible",
local: false, local: false,
image_path: "/images/generations", image_path: "/images/generations",
image_edit_path: "/images/edits",
}, },
{ {
title: "Mistral", title: "Mistral",
@@ -80,6 +89,7 @@ export const HarnessProviderKinds = [
catalog: "openai-compatible", catalog: "openai-compatible",
local: false, local: false,
image_path: "/images/generations", image_path: "/images/generations",
image_edit_path: "/images/edits",
}, },
{ {
title: "xAI", title: "xAI",
@@ -90,6 +100,7 @@ export const HarnessProviderKinds = [
catalog: "openai-compatible", catalog: "openai-compatible",
local: false, local: false,
image_path: "/images/generations", image_path: "/images/generations",
image_edit_path: "/images/edits",
}, },
{ {
title: "Ollama", title: "Ollama",
@@ -100,6 +111,7 @@ export const HarnessProviderKinds = [
catalog: "openai-compatible", catalog: "openai-compatible",
local: true, local: true,
image_path: "/images/generations", image_path: "/images/generations",
image_edit_path: "/images/edits",
}, },
{ {
title: "Other (OpenAI-compatible)", title: "Other (OpenAI-compatible)",
@@ -110,6 +122,7 @@ export const HarnessProviderKinds = [
catalog: "openai-compatible", catalog: "openai-compatible",
local: false, local: false,
image_path: "/images/generations", image_path: "/images/generations",
image_edit_path: "/images/edits",
}, },
] as const; ] as const;
+6
View File
@@ -24,3 +24,9 @@ export const LogCategories = [
value: "error", value: "error",
}, },
] as const; ] as const;
/**
* The app setting (Settings > General) for how many days logs are kept,
* else `AppData.LogsKeepDays`.
*/
export const LogsKeepDaysSettingKey = "logs_keep_days";
+10 -1
View File
@@ -10,6 +10,7 @@ import {
LockKeyhole, LockKeyhole,
Monitor, Monitor,
Plug, Plug,
Rocket,
Users, Users,
SlidersHorizontal, SlidersHorizontal,
UserRound, UserRound,
@@ -38,6 +39,8 @@ export type SettingsPage = {
export const SettingsComputersPath = "/admin/settings/computers"; export const SettingsComputersPath = "/admin/settings/computers";
export const SettingsBackupsPath = "/admin/settings/backups"; export const SettingsBackupsPath = "/admin/settings/backups";
export const SettingsDatabasePath = "/admin/settings/database"; export const SettingsDatabasePath = "/admin/settings/database";
export const SettingsSetupPath = "/admin/settings/setup";
export const SettingsModelsPath = "/admin/settings/models";
/** /**
* The `/admin/settings/*` sub pages, listed in the rail on every settings * The `/admin/settings/*` sub pages, listed in the rail on every settings
@@ -49,6 +52,12 @@ export const SettingsPages: SettingsPage[] = [
url: "/admin/settings/general", url: "/admin/settings/general",
Icon: SlidersHorizontal, Icon: SlidersHorizontal,
}, },
{
title: "Setup",
url: SettingsSetupPath,
Icon: Rocket,
super_admin_only: true,
},
{ title: "Profile", url: "/admin/settings/profile", Icon: UserRound }, { title: "Profile", url: "/admin/settings/profile", Icon: UserRound },
{ {
title: "Change password", title: "Change password",
@@ -71,7 +80,7 @@ export const SettingsPages: SettingsPage[] = [
}, },
{ {
title: "Models", title: "Models",
url: "/admin/settings/models", url: SettingsModelsPath,
Icon: CpuAi, Icon: CpuAi,
super_admin_only: true, super_admin_only: true,
}, },
+2 -2
View File
@@ -1,6 +1,6 @@
/** /**
* A task's status. Tasks are the crew's shared to-do list, one per bot and * A task's status. Tasks are the crew's to-do list, every task one bot's
* one per workspace, and every move through this list is a step in the * (on its own list or in a workspace), and every move through this list is a step in the
* work: `blocked` is what a bot (or the user) sets when only the user can * work: `blocked` is what a bot (or the user) sets when only the user can
* unblock it, and `done` is only ever set once the work was verified. * unblock it, and `done` is only ever set once the work was verified.
*/ */
-37
View File
@@ -1,37 +0,0 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_FILE_INGEST_QUEUE } from "@/db/types/db";
import wakeFileIngest from "./wake-file-ingest";
type Params = {
file_id: number | string;
};
/**
* Queues a file to be chunked and embedded, unless it's already waiting.
* Never fails the caller: a file that can't be queued is picked up by the
* `ingest-pending-files` cron instead.
*/
export default async function enqueueFileIngest({ file_id }: Params) {
try {
const open = await BunSQLite.sql<BUN_SQLITE_COBBLE_FILE_INGEST_QUEUE>({
sql: `SELECT id FROM file_ingest_queue WHERE file_id = ? AND status IN ('pending', 'processing') LIMIT 1`,
values: [file_id],
});
if (open.payload?.[0]?.id) {
wakeFileIngest();
return;
}
await BunSQLite.queue.add({
table: "file_ingest_queue",
data: [{ file_id: Number(file_id) }],
});
wakeFileIngest();
} catch {
/**
* The cron sweeps it up.
*/
}
}
+3 -3
View File
@@ -14,9 +14,9 @@ let timer: ReturnType<typeof setTimeout> | undefined;
/** /**
* Starts the file ingestion worker: polls every `AppData.FileIngestPollMs` * Starts the file ingestion worker: polls every `AppData.FileIngestPollMs`
* for `file_ingest_queue` jobs and runs up to * for `file_ingest_queue` jobs and runs up to
* `AppData.FileIngestConcurrency` at once (chunking and embedding a bot's * `AppData.FileIngestConcurrency` at once (chunking and embedding a
* file). Gated in production to the `MAIN_SERVER` container, like cron and * knowledgebase document). Gated in production to the `MAIN_SERVER`
* the bots queue. * container, like cron and the bots queue.
*/ */
export default async function fileIngest() { export default async function fileIngest() {
if (process.env.NODE_ENV == "production" && !process.env.MAIN_SERVER) { if (process.env.NODE_ENV == "production" && !process.env.MAIN_SERVER) {
+7 -34
View File
@@ -1,6 +1,5 @@
import BunSQLite from "@moduletrace/bun-sqlite"; import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_FILE_INGEST_QUEUE } from "@/db/types/db"; import type { BUN_SQLITE_COBBLE_FILE_INGEST_QUEUE } from "@/db/types/db";
import ingestBotFile from "@/src/functions/backend/db/bot-files/ingest-bot-file";
import ingestKnowledgebaseDocument from "@/src/functions/backend/db/knowledgebases/ingest-knowledgebase-document"; import ingestKnowledgebaseDocument from "@/src/functions/backend/db/knowledgebases/ingest-knowledgebase-document";
import { logError } from "@/src/functions/backend/logger"; import { logError } from "@/src/functions/backend/logger";
import settleFailedIngestJob from "./settle-failed-ingest-job"; import settleFailedIngestJob from "./settle-failed-ingest-job";
@@ -10,56 +9,30 @@ type Params = {
}; };
/** /**
* Ingests one queued file (or knowledgebase document) and completes (or * Ingests one queued knowledgebase document and completes (or fails) its
* fails) its job. One that was deleted meanwhile is simply done. * job. One that was deleted meanwhile is simply done, and so is a job
* without a document (bot files are no longer indexed).
*/ */
export default async function processIngestJob({ job }: Params) { export default async function processIngestJob({ job }: Params) {
if (!job.id) return; if (!job.id) return;
if (job.document_id) return await processDocumentJob({ job_id: job.id, document_id: job.document_id }); if (!job.document_id) {
if (!job.file_id) {
await BunSQLite.queue.complete({ table: "file_ingest_queue", id: job.id }).catch(() => {}); await BunSQLite.queue.complete({ table: "file_ingest_queue", id: job.id }).catch(() => {});
return; return;
} }
try { try {
const result = await ingestBotFile({ file_id: job.file_id }); await ingestKnowledgebaseDocument({ document_id: job.document_id });
await BunSQLite.queue.complete({ table: "file_ingest_queue", id: job.id }); await BunSQLite.queue.complete({ table: "file_ingest_queue", id: job.id });
if (result.status == "ingested") {
BunSQLite.sql({
sql: `UPDATE bot_files SET ingest_error = NULL WHERE id = ?`,
values: [job.file_id],
}).catch(() => {});
}
} catch (error) { } catch (error) {
const { deferred } = await settleFailedIngestJob({ job_id: job.id, error }); const { deferred } = await settleFailedIngestJob({ job_id: job.id, error });
if (deferred) return; if (deferred) return;
await logError({ await logError({
title: `Bot file #${job.file_id} couldn't be ingested`, title: `Knowledgebase document #${job.document_id} couldn't be ingested`,
error, error,
metadata: { file_id: job.file_id }, metadata: { document_id: job.document_id },
}).catch(() => {});
}
}
async function processDocumentJob({ job_id, document_id }: { job_id: number; document_id: number }) {
try {
await ingestKnowledgebaseDocument({ document_id });
await BunSQLite.queue.complete({ table: "file_ingest_queue", id: job_id });
} catch (error) {
const { deferred } = await settleFailedIngestJob({ job_id, error });
if (deferred) return;
await logError({
title: `Knowledgebase document #${document_id} couldn't be ingested`,
error,
metadata: { document_id },
}).catch(() => {}); }).catch(() => {});
} }
} }
@@ -5,7 +5,7 @@
* table name as an argument the way a table-valued function does. * table name as an argument the way a table-valued function does.
* *
* Left out: SQLite's own `sqlite_*` tables, and the shadow tables a * Left out: SQLite's own `sqlite_*` tables, and the shadow tables a
* virtual table keeps its internals in (the `bot_file_vectors_*` tables * virtual table keeps its internals in (the `knowledgebase_vectors_*` tables
* behind the sqlite-vec index), which are storage, not rows anyone * behind the sqlite-vec index), which are storage, not rows anyone
* browses. * browses.
*/ */
@@ -0,0 +1,30 @@
import { AppData } from "@/src/data/app-data";
import { LogsKeepDaysSettingKey } from "@/src/dict/log-categories";
import type { LogRetentionOverview } from "@/src/types";
import readStepLimit from "@/src/utils/read-step-limit";
import grabAppSetting from "./grab-app-setting";
/**
* How many days logs are kept: the install's own number (`logs_keep_days`,
* Settings > General), else `AppData.LogsKeepDays`.
*/
export async function grabLogsKeepDays() {
return readStepLimit({
saved: await grabAppSetting({ key: LogsKeepDaysSettingKey }),
fallback: AppData.LogsKeepDays,
max: AppData.LogsKeepDaysMax,
});
}
/**
* The number with its default and ceiling, for the settings page.
*/
export default async function grabLogRetentionOverview() {
const overview: LogRetentionOverview = {
keep_days: await grabLogsKeepDays(),
default_days: AppData.LogsKeepDays,
max: AppData.LogsKeepDaysMax,
};
return overview;
}
@@ -15,7 +15,7 @@ type Params = {
}; };
/** /**
* Deletes a bot file: the catalog row (its chunks go with it), its bytes * Deletes a bot file: the catalog row, its bytes
* and its `media` row. Refuses anything outside the run's directories. * and its `media` row. Refuses anything outside the run's directories.
*/ */
export default async function deleteBotFile({ scopes, file_id }: Params) { export default async function deleteBotFile({ scopes, file_id }: Params) {
@@ -32,22 +32,7 @@ export default async function deleteBotFile({ scopes, file_id }: Params) {
rmSync(absolute, { force: true }); rmSync(absolute, { force: true });
} }
await BunSQLite.sql({ sql: `DELETE FROM bot_file_vectors WHERE file_id = ?`, values: [file.id] });
/**
* Deleted here rather than left to the schema's cascade: SQLite has
* foreign keys off per connection unless `enableForeignKeys` ran, and
* a script or CLI process that deletes a file would otherwise leave
* its chunks behind, which then fail the next schema sync.
*/
await BunSQLite.sql({ sql: `DELETE FROM bot_file_chunks WHERE file_id = ?`, values: [file.id] });
await BunSQLite.sql({ sql: `DELETE FROM bot_files WHERE id = ?`, values: [file.id] }); await BunSQLite.sql({ sql: `DELETE FROM bot_files WHERE id = ?`, values: [file.id] });
/**
* Its pending ingestion would only find nothing left.
*/
await BunSQLite.sql({
sql: `DELETE FROM file_ingest_queue WHERE file_id = ? AND status IN ('pending', 'processing')`,
values: [file.id],
}).catch(() => {});
if (file.media_id) { if (file.media_id) {
const media = await BunSQLite.sql<{ media_write_path?: string; media_thumbnail_write_path?: string }>({ const media = await BunSQLite.sql<{ media_write_path?: string; media_thumbnail_write_path?: string }>({
@@ -53,8 +53,6 @@ export default async function grabConversationFiles({
purpose: String(file.purpose || "writing"), purpose: String(file.purpose || "writing"),
scope: String(file.scope || "bot"), scope: String(file.scope || "bot"),
owner_id: Number(file.owner_id) || 0, owner_id: Number(file.owner_id) || 0,
ingested_at: file.ingested_at ? Number(file.ingested_at) : undefined,
ingest_error: file.ingest_error || undefined,
created_at: Number(file.created_at) || 0, created_at: Number(file.created_at) || 0,
url: media_url || "", url: media_url || "",
}; };
@@ -1,329 +0,0 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type {
BUN_SQLITE_COBBLE_BOT_FILE_CHUNKS,
BUN_SQLITE_COBBLE_BOT_FILES,
BunSQLiteTables,
} from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import embedText from "@/src/harness/embeddings/embed-text";
import HarnessError from "@/src/harness/harness-error";
import { grabHarnessUsageScope } from "@/src/harness/usage/harness-usage-scope";
import type { HarnessUsageScope } from "@/src/harness/usage/types";
import runWithFallbackScope from "@/src/harness/usage/run-with-fallback-scope";
import grabUsageOwnerUserId from "@/src/functions/backend/db/usage/grab-usage-owner";
import chunkText from "./chunk-text";
import extractBotFileText from "./extract-bot-file-text";
type Params = {
file_id: number | string;
/**
* Read images and audio too (a bot asking for it).
*/
read_media?: boolean;
signal?: AbortSignal;
};
export type IngestResult = {
/**
* `ingested`, `empty` (no text to read), `skipped` (too large) or
* `unavailable` (no embedding model), which keeps the file's text but
* leaves it searchable by words only.
*/
status: "ingested" | "empty" | "skipped" | "unavailable";
chunks: number;
};
/**
* Reads a bot file's text, splits it into chunks and embeds them into
* `bot_file_vectors`, replacing whatever was stored before. The chunks
* stay in `bot_file_chunks` even when there's no embedding model, so a
* search can still match words in them.
*
* Failures are recorded on the file (`ingest_error`) and rethrown, so the
* queue can retry.
*/
export default async function ingestBotFile({
file_id,
read_media,
signal,
}: Params): Promise<IngestResult> {
const file = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_FILES>({
sql: `SELECT * FROM bot_files WHERE id = ? LIMIT 1`,
values: [file_id],
}).then((res) => res.payload?.[0]);
if (!file?.id) return { status: "empty", chunks: 0 };
const media = file.media_id
? await BunSQLite.sql<{ media_write_path?: string; text_content?: string }>({
sql: `SELECT media_write_path, text_content FROM media WHERE id = ? LIMIT 1`,
values: [file.media_id],
}).then((res) => res.payload?.[0])
: undefined;
/**
* Decided on the file's size alone. A cached `text_content` is not a
* reason to read an oversized file: it was only ever a slice of it,
* and taking it as the whole document made a file too big to unpack
* look already read.
*/
if (Number(file.size) > AppData.IngestMaxBytes) {
await BunSQLite.sql({
sql: `UPDATE bot_files SET ingest_error = ?, updated_at = ? WHERE id = ?`,
values: [`Too large to read (over ${AppData.IngestMaxBytes / (1024 * 1024)} MB).`, Date.now(), file.id],
});
return { status: "skipped", chunks: 0 };
}
/**
* A bot's own run already carries its user. The ingest queue has no run
* behind it, so everything this file costs - reading an image or an
* audio file, embedding its chunks - is entered under the owner's scope
* instead: it spends real tokens and must show on the usage page.
*/
const owner_scope: HarnessUsageScope | null = grabHarnessUsageScope()?.user_id
? null
: await resolveOwnerScope({ file });
try {
const text = await runWithFallbackScope({
scope: owner_scope,
task: () =>
extractBotFileText({
write_path: media?.media_write_path,
text_content: media?.text_content,
name: file.name,
mime_type: file.mime_type,
read_media,
}),
});
await clearChunks({ file_id: file.id });
if (!text?.trim()) {
await markIngested({ file_id: file.id, chunks: 0 });
return { status: "empty", chunks: 0 };
}
const chunks = chunkText({ text });
if (!chunks.length) {
await markIngested({ file_id: file.id, chunks: 0 });
return { status: "empty", chunks: 0 };
}
const chunk_ids = await insertChunks({ file, chunks });
let embedded = 0;
try {
/**
* A big file's chunks go in batches, so one provider refusal
* doesn't throw away the whole file.
*/
for (let start = 0; start < chunks.length; start += AppData.EmbeddingBatchSize) {
const batch = chunks.slice(start, start + AppData.EmbeddingBatchSize);
const { vectors } = await runEmbed({
scope: owner_scope,
signal,
file,
batch,
});
for (let i = 0; i < vectors.length; i++) {
await insertVector({
vector: vectors[i]!,
chunk_id: chunk_ids[start + i]!,
file_id: file.id,
scope: String(file.scope),
owner_id: Number(file.owner_id),
});
embedded++;
}
}
} catch (error) {
if (error instanceof HarnessError && error.code == "unavailable") {
/**
* No embedding model: the chunks still let a search match
* words, so the file isn't a failure.
*/
await markIngested({
file_id: file.id,
chunks: chunks.length,
error: "No embedding model is set, so this file is searchable by words only.",
});
return { status: "unavailable", chunks: chunks.length };
}
throw error;
}
await markIngested({ file_id: file.id, chunks: chunks.length });
return { status: "ingested", chunks: embedded };
} catch (error) {
/**
* Every model resting means nothing was tried: the job waits for
* them, and the file keeps whatever it said before.
*/
if (error instanceof HarnessError && error.resting) throw error;
const message = error instanceof Error ? error.message : String(error);
await BunSQLite.sql({
sql: `UPDATE bot_files SET ingest_error = ?, updated_at = ? WHERE id = ?`,
values: [message.slice(0, 500), Date.now(), file.id],
}).catch(() => {});
throw error;
}
}
async function clearChunks({ file_id }: { file_id: number }) {
await BunSQLite.sql({ sql: `DELETE FROM bot_file_vectors WHERE file_id = ?`, values: [file_id] });
await BunSQLite.sql({ sql: `DELETE FROM bot_file_chunks WHERE file_id = ?`, values: [file_id] });
}
async function insertChunks({
file,
chunks,
}: {
file: BUN_SQLITE_COBBLE_BOT_FILES;
chunks: string[];
}) {
const ids: number[] = [];
for (let index = 0; index < chunks.length; index++) {
const text = chunks[index]!;
const res = await BunSQLite.insert<
BUN_SQLITE_COBBLE_BOT_FILE_CHUNKS,
(typeof BunSQLiteTables)[number]
>({
table: "bot_file_chunks",
data: [
{
file_id: file.id,
scope: file.scope as any,
owner_id: Number(file.owner_id),
chunk_index: index,
text,
token_estimate: Math.ceil(text.length / 4),
},
],
});
const id = res.postInsertReturn?.insertId;
if (id) ids.push(id);
}
return ids;
}
async function insertVector({
vector,
chunk_id,
file_id,
scope,
owner_id,
}: {
vector: Float32Array;
chunk_id: number;
file_id: number;
scope: string;
owner_id: number;
}) {
/**
* vec0's metadata columns (its id and timestamps) are NOT NULL, so a
* raw insert has to fill them; `BunSQLite.insert` would add them but
* the vector needs `vec_f32`.
*/
const now = Date.now();
const res = await BunSQLite.sql({
sql: `INSERT INTO bot_file_vectors (created_at, updated_at, embedding, chunk_id, file_id, scope, owner_id) VALUES (?, ?, vec_f32(?), ?, ?, ?, ?)`,
values: [now, now, vector as any, chunk_id, file_id, scope, owner_id],
});
if (!res.success) {
throw new Error(`Couldn't store an embedding: ${res.error || res.msg}`);
}
}
async function markIngested({
file_id,
chunks,
error,
}: {
file_id: number;
chunks: number;
error?: string;
}) {
const now = Date.now();
await BunSQLite.sql({
sql: `UPDATE bot_files SET ingested_at = ?, ingest_error = ?, updated_at = ? WHERE id = ?`,
values: [now, error || null, now, file_id],
});
await BunSQLite.sql({
sql: `UPDATE media SET is_rag_ingested = ?, updated_at = ? WHERE id = (SELECT media_id FROM bot_files WHERE id = ?)`,
values: [chunks > 0 ? 1 : 0, now, file_id],
}).catch(() => {});
}
/**
* One batch of a file's chunks, embedded under whatever attribution owns
* it: the run that asked for the ingest, else the file's owner.
*/
async function runEmbed({
scope,
signal,
file,
batch,
}: {
scope: HarnessUsageScope | null;
signal?: AbortSignal;
file: BUN_SQLITE_COBBLE_BOT_FILES;
batch: string[];
}) {
return await runWithFallbackScope({
scope,
task: () =>
embedText({
input: batch,
bot_id: file.bot_id!,
workspace_id: file.scope == "workspace" ? file.owner_id : null,
signal,
}),
});
}
/**
* The attribution a file the queue ingests reports under: its workspace's
* creator when it shares one, else its bot's.
*/
async function resolveOwnerScope({
file,
}: {
file: BUN_SQLITE_COBBLE_BOT_FILES;
}): Promise<HarnessUsageScope | null> {
const workspace_id = file.scope == "workspace" ? file.owner_id : null;
const user_id = await grabUsageOwnerUserId({ bot_id: file.bot_id, workspace_id });
if (!user_id) return null;
return {
user_id,
bot_id: file.scope == "bot" ? file.owner_id : file.bot_id,
workspace_id,
session_id: null,
message_id: null,
job_id: null,
};
}
@@ -54,22 +54,6 @@ export default async function purgeStorage({ scope, owner_id, media_ids }: Param
}).catch(() => {}); }).catch(() => {});
} }
await BunSQLite.sql({
sql: `DELETE FROM bot_file_vectors WHERE scope = ? AND owner_id = ?`,
values: [scope, owner_id],
}).catch(() => {});
/**
* Deleted here rather than left to the cascade, since SQLite has
* foreign keys off per connection unless `enableForeignKeys` ran (a
* script or CLI process never does), and a chunk left behind fails
* the next schema sync.
*/
await BunSQLite.sql({
sql: `DELETE FROM bot_file_chunks WHERE scope = ? AND owner_id = ?`,
values: [scope, owner_id],
}).catch(() => {});
/** /**
* Usually gone already, with the bot or workspace (the cascade). * Usually gone already, with the bot or workspace (the cascade).
*/ */
@@ -104,7 +104,7 @@ export default async function recordBotFile({
if (existing?.id) { if (existing?.id) {
await BunSQLite.sql({ await BunSQLite.sql({
sql: `UPDATE bot_files SET media_id = ?, name = ?, folder = ?, mime_type = ?, media_type = ?, size = ?, sql: `UPDATE bot_files SET media_id = ?, name = ?, folder = ?, mime_type = ?, media_type = ?, size = ?,
purpose = ?, source = ?, ingested_at = NULL, ingest_error = NULL, updated_at = ? WHERE id = ?`, purpose = ?, source = ?, updated_at = ? WHERE id = ?`,
values: [ values: [
media_id, media_id,
resolved.name, resolved.name,
@@ -129,8 +129,6 @@ export default async function recordBotFile({
size: bytes.byteLength, size: bytes.byteLength,
purpose, purpose,
source, source,
ingested_at: undefined,
ingest_error: undefined,
updated_at: now, updated_at: now,
}; };
} }
@@ -1,271 +0,0 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import { AppData } from "@/src/data/app-data";
import { embedQuery } from "@/src/harness/embeddings/embed-text";
import HarnessError from "@/src/harness/harness-error";
import type { StorageScope } from "@/src/utils/storage-access";
type Params = {
scopes: StorageScope[];
query: string;
/**
* Only this file's chunks.
*/
file_id?: number | string | null;
limit?: number;
signal?: AbortSignal;
/**
* Whose embedding model to use.
*/
bot_id?: number | string;
workspace_id?: number | string | null;
};
export type BotFileSearchHit = {
file_id: number;
chunk_id: number;
chunk_index: number;
name: string;
path: string;
scope: string;
/**
* Cosine distance when the vector index answered, null for a word
* match.
*/
distance: number | null;
text: string;
};
export type BotFileSearchResult = {
hits: BotFileSearchHit[];
/**
* How the search ran: `vector`, `words` (no embedding model, or nothing
* matched) or `none` (no files at all).
*/
mode: "vector" | "words" | "none";
/**
* Said plainly for the bot's own reading of what it got back.
*/
note: string;
};
/**
* Searches a run's files: by meaning through the embedding vectors when a
* model is set, else (or when nothing matches) by words over the stored
* chunks. Never throws for an unavailable model: the bot always gets
* something it can act on.
*/
export default async function searchBotFiles({
scopes,
query,
file_id,
limit = AppData.RagTopChunks,
signal,
bot_id,
workspace_id,
}: Params): Promise<BotFileSearchResult> {
const text = query.trim();
if (!scopes.length || !text) {
return { hits: [], mode: "none", note: "No files to search." };
}
try {
const vector = await embedQuery({
text,
signal,
bot_id,
workspace_id,
});
const hits = await vectorSearch({
scopes,
query_vector: vector,
file_id,
limit,
seek: Math.min(limit * AppData.RagOverFetch, AppData.RagMaxK),
});
if (hits.length) {
return {
hits,
mode: "vector",
note: `${hits.length} passage(s), found by meaning.`,
};
}
} catch (error) {
if (!(error instanceof HarnessError)) throw error;
}
const hits = await wordSearch({ scopes, query: text, file_id, limit });
return {
hits,
mode: hits.length ? "words" : "none",
note: hits.length
? `${hits.length} passage(s), found by matching words (no embedding model, or nothing matched by meaning).`
: `Nothing in your files matched "${text}".`,
};
}
/**
* The nearest chunks by vector. sqlite-vec refuses a filter on a vec0
* side-car column in the same query as the match, so the index is asked
* for its nearest `seek` and the run's scopes are applied to that list
* here, before the chunk text is read.
*/
async function vectorSearch({
scopes,
query_vector,
file_id,
limit,
seek,
}: {
scopes: StorageScope[];
query_vector: Float32Array;
file_id?: number | string | null;
limit: number;
seek: number;
}): Promise<BotFileSearchHit[]> {
const nearest = await BunSQLite.sql<{
chunk_id: number;
file_id: number;
scope: string;
owner_id: number;
distance: number;
}>({
sql: `SELECT chunk_id, file_id, scope, owner_id, distance FROM bot_file_vectors WHERE embedding MATCH vec_f32(?) AND k = ? ORDER BY distance`,
values: [query_vector as any, seek],
});
/**
* `BunSQLite.sql` resolves with `success: false` instead of throwing, so
* a broken vector query would otherwise read as "nothing matched" and
* silently fall back to words. A failure here is a real error.
*/
if (!nearest.success) {
throw new Error(`The file search index couldn't be read: ${nearest.error || nearest.msg}`);
}
const allowed = nearest.payload?.filter((row) =>
scopes.some((scope) => scope.scope == row.scope && Number(scope.owner_id) == Number(row.owner_id)),
);
const picked = (allowed || [])
.filter((row) => !file_id || Number(row.file_id) == Number(file_id))
.slice(0, limit);
if (!picked.length) return [];
const chunk_ids = picked.map((row) => Number(row.chunk_id));
const placeholders = chunk_ids.map(() => "?").join(", ");
const chunks = await BunSQLite.sql<{
id: number;
text: string;
chunk_index: number;
name: string;
path: string;
}>({
sql: `SELECT c.id, c.text, c.chunk_index, f.name, f.path FROM bot_file_chunks c
JOIN bot_files f ON f.id = c.file_id
WHERE c.id IN (${placeholders})`,
values: chunk_ids,
});
const by_id = new Map((chunks.payload || []).map((row) => [Number(row.id), row]));
return picked.flatMap((row) => {
const chunk = by_id.get(Number(row.chunk_id));
if (!chunk) return [];
return [
toHit({
chunk_id: row.chunk_id,
file_id: row.file_id,
scope: row.scope,
distance: Number(row.distance),
text: chunk.text,
chunk_index: chunk.chunk_index,
name: chunk.name,
path: chunk.path,
}),
];
});
}
/**
* Chunks whose text has the query's words, newest files first. Used when
* there's no embedding model, and as the fallback when the vectors
* answered nothing.
*/
async function wordSearch({
scopes,
query,
file_id,
limit,
}: {
scopes: StorageScope[];
query: string;
file_id?: number | string | null;
limit: number;
}): Promise<BotFileSearchHit[]> {
const words = [
...new Set(
query
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter((word) => word.length > 2),
),
].slice(0, 6);
if (!words.length) return [];
const scope_sql = scopes.map(() => `(c.scope = ? AND c.owner_id = ?)`).join(" OR ");
const scope_values = scopes.flatMap((s) => [s.scope, s.owner_id]);
const word_sql = words.map(() => `lower(c.text) LIKE ?`).join(" OR ");
const word_values = words.map((word) => `%${word}%`);
const file_clause = file_id ? "AND c.file_id = ?" : "";
const res = await BunSQLite.sql<{
chunk_id: number;
file_id: number;
text: string;
chunk_index: number;
name: string;
path: string;
scope: string;
}>({
sql: `SELECT c.id AS chunk_id, c.file_id, c.text, c.chunk_index, c.scope, f.name, f.path
FROM bot_file_chunks c
JOIN bot_files f ON f.id = c.file_id
WHERE (${scope_sql}) ${file_clause} AND (${word_sql})
ORDER BY f.updated_at DESC, c.chunk_index
LIMIT ?`,
values: [...scope_values, ...(file_id ? [file_id] : []), ...word_values, limit],
});
return (res.payload || []).map((row) => toHit({ ...row, distance: null }));
}
function toHit(row: {
chunk_id: number;
file_id: number;
text: string;
chunk_index: number;
name: string;
path: string;
scope: string;
distance: number | null;
}): BotFileSearchHit {
return {
file_id: Number(row.file_id),
chunk_id: Number(row.chunk_id),
chunk_index: Number(row.chunk_index),
name: row.name || "",
path: row.path || "",
scope: row.scope || "bot",
distance: row.distance == null ? null : Number(row.distance),
text: row.text || "",
};
}
@@ -13,10 +13,8 @@ async function source() {
describe("storeChatAttachments", () => { describe("storeChatAttachments", () => {
/** /**
* The same text used to be copied onto the new `bot-file` media row, * The same text used to be copied onto the new `bot-file` media row,
* so a chat upload kept a second copy of its extracted text (and left * so a chat upload kept a second copy of its extracted text. Bot files
* it behind when the upload was pruned). The text belongs only on the * keep no text at all: a bot reads the bytes when it needs them.
* message's own `bot-attachment` row, where a reply reads it;
* ingestion re-reads the bytes through `extractBotFileText`.
*/ */
test("never writes text_content onto the file's media row", async () => { test("never writes text_content onto the file's media row", async () => {
const code = await source(); const code = await source();
@@ -25,10 +23,10 @@ describe("storeChatAttachments", () => {
expect(code).not.toContain("text_content = ?"); expect(code).not.toContain("text_content = ?");
}); });
test("still saves the bytes and queues the ingest", async () => { test("saves the bytes and indexes nothing", async () => {
const code = await source(); const code = await source();
expect(code).toContain("recordBotFile("); expect(code).toContain("recordBotFile(");
expect(code).toContain("enqueueFileIngest("); expect(code).not.toContain("enqueueFileIngest(");
}); });
}); });
@@ -1,7 +1,6 @@
import mediaFilePath from "@/src/utils/media-file-path"; import mediaFilePath from "@/src/utils/media-file-path";
import type { BUN_SQLITE_COBBLE_MEDIA } from "@/db/types/db"; import type { BUN_SQLITE_COBBLE_MEDIA } from "@/db/types/db";
import { BotUploadFolder } from "@/src/dict/bot-file-dict"; import { BotUploadFolder } from "@/src/dict/bot-file-dict";
import enqueueFileIngest from "@/src/file-ingest/enqueue-file-ingest";
import { logError } from "@/src/functions/backend/logger"; import { logError } from "@/src/functions/backend/logger";
import recordBotFile from "./record-bot-file"; import recordBotFile from "./record-bot-file";
@@ -14,7 +13,7 @@ type Params = {
/** /**
* Copies the files the user attached to a message into the conversation's * Copies the files the user attached to a message into the conversation's
* folder (`uploads/<yyyy-mm>/<name>`), so they stay searchable after the * folder (`uploads/<yyyy-mm>/<name>`), so they stay findable after the
* message scrolls away and a later run can find them. Best effort: a * message scrolls away and a later run can find them. Best effort: a
* failure is logged, never shown, and the message still goes out. * failure is logged, never shown, and the message still goes out.
*/ */
@@ -48,8 +47,8 @@ export default async function storeChatAttachments({
* The text stays on the message's own `bot-attachment` row, * The text stays on the message's own `bot-attachment` row,
* where a reply reads it. Copying it onto the file's row kept * where a reply reads it. Copying it onto the file's row kept
* the same text twice and left the copy behind when the * the same text twice and left the copy behind when the
* upload was pruned; ingestion re-reads the bytes once * upload was pruned; a bot reads the bytes with `read_file`
* through `extractBotFileText` instead. * instead.
*/ */
const saved = await recordBotFile({ const saved = await recordBotFile({
bot_id, bot_id,
@@ -63,8 +62,6 @@ export default async function storeChatAttachments({
mime_type: file.mime_type || undefined, mime_type: file.mime_type || undefined,
user_id, user_id,
}); });
await enqueueFileIngest({ file_id: saved.id! });
} catch (error) { } catch (error) {
await logError({ await logError({
title: `A chat attachment couldn't be saved to the bot's files`, title: `A chat attachment couldn't be saved to the bot's files`,
@@ -1,132 +0,0 @@
import { afterAll, describe, expect, test } from "bun:test";
import { Database } from "bun:sqlite";
import * as sqliteVec from "sqlite-vec";
import fitVectorDimensions from "@/src/harness/embeddings/fit-vector-dimensions";
/**
* The exact insert and nearest-neighbour shape `ingestBotFile` and
* `searchBotFiles` use, on a scratch database: vec0's metadata columns are
* NOT NULL, and a side-car filter can't share the match query.
*/
function createDb() {
const db = new Database(":memory:");
sqliteVec.load(db);
db.run(`CREATE VIRTUAL TABLE bot_file_vectors USING vec0(
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at INTEGER,
updated_at INTEGER,
embedding float[8],
+chunk_id INTEGER,
+file_id INTEGER,
+scope TEXT,
+owner_id INTEGER
)`);
return db;
}
const vector = (values: number[]) => fitVectorDimensions({ vector: values, dimensions: 8 });
const insert = (db: Database, row: { chunk_id: number; file_id: number; scope: string; owner_id: number; v: number[] }) => {
const now = Date.now();
db.run(
`INSERT INTO bot_file_vectors (created_at, updated_at, embedding, chunk_id, file_id, scope, owner_id) VALUES (?, ?, vec_f32(?), ?, ?, ?, ?)`,
[now, now, vector(row.v), row.chunk_id, row.file_id, row.scope, row.owner_id],
);
};
describe("bot file vectors", () => {
test("stores a vector and matches the nearest one", () => {
const db = createDb();
insert(db, { chunk_id: 1, file_id: 10, scope: "bot", owner_id: 1, v: [1, 0, 0, 0] });
insert(db, { chunk_id: 2, file_id: 11, scope: "bot", owner_id: 1, v: [0, 1, 0, 0] });
const rows = db
.query(
`SELECT chunk_id, file_id, distance FROM bot_file_vectors WHERE embedding MATCH vec_f32(?1) AND k = 2 ORDER BY distance`,
)
.all(vector([0.9, 0.1, 0, 0])) as { chunk_id: number; distance: number }[];
expect(rows.length).toBe(2);
expect(rows[0]!.chunk_id).toBe(1);
expect(rows[0]!.distance).toBeLessThan(rows[1]!.distance);
});
test("filters scopes in the app, not in the match query", () => {
const db = createDb();
insert(db, { chunk_id: 1, file_id: 10, scope: "bot", owner_id: 1, v: [1, 0] });
insert(db, { chunk_id: 2, file_id: 20, scope: "bot", owner_id: 2, v: [1, 0.05] });
insert(db, { chunk_id: 3, file_id: 30, scope: "workspace", owner_id: 3, v: [1, 0.1] });
const nearest = db
.query(`SELECT chunk_id, scope, owner_id, distance FROM bot_file_vectors WHERE embedding MATCH vec_f32(?1) AND k = 10 ORDER BY distance`)
.all(vector([1, 0])) as { chunk_id: number; scope: string; owner_id: number }[];
expect(nearest.length).toBe(3);
const mine = nearest.filter((row) => row.scope == "bot" && row.owner_id == 1);
expect(mine.map((row) => row.chunk_id)).toEqual([1]);
});
test("a side-car filter in the match query is refused", () => {
const db = createDb();
insert(db, { chunk_id: 1, file_id: 10, scope: "bot", owner_id: 1, v: [1, 0] });
expect(() =>
db
.query(
`SELECT chunk_id FROM bot_file_vectors WHERE embedding MATCH vec_f32(?1) AND k = 2 AND scope = 'bot'`,
)
.all(vector([1, 0])),
).toThrow(/illegal WHERE constraint/);
});
test("an insert without vec0's metadata columns is refused", () => {
const db = createDb();
expect(() =>
db.run(
`INSERT INTO bot_file_vectors (embedding, chunk_id, file_id, scope, owner_id) VALUES (vec_f32(?1), 1, 10, 'bot', 1)`,
[vector([1, 0])],
),
).toThrow();
});
test("deletes a file's vectors by its side-car column", () => {
const db = createDb();
insert(db, { chunk_id: 1, file_id: 10, scope: "bot", owner_id: 1, v: [1, 0] });
db.run(`DELETE FROM bot_file_vectors WHERE file_id = ?`, [10]);
const count = db.query(`SELECT COUNT(*) AS n FROM bot_file_vectors`).get() as { n: number };
expect(count.n).toBe(0);
});
});
describe("fitVectorDimensions", () => {
test("pads a short vector and normalizes it", () => {
const out = fitVectorDimensions({ vector: [3, 4], dimensions: 4 });
const norm = Math.sqrt([...out].reduce((sum, value) => sum + value * value, 0));
expect(out.length).toBe(4);
expect(Math.round(norm * 1000) / 1000).toBe(1);
expect(Math.round(out[0]! * 1000) / 1000).toBe(0.6);
});
test("cuts a long vector", () => {
expect(fitVectorDimensions({ vector: [1, 1, 1, 1, 1, 1], dimensions: 2 }).length).toBe(2);
});
test("leaves a zero vector as zeros", () => {
expect([...fitVectorDimensions({ vector: [0, 0], dimensions: 2 })]).toEqual([0, 0]);
});
});
afterAll(() => {});
@@ -0,0 +1,33 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import checkBotQuestionAnswers from "@/src/utils/check-bot-question-answers";
import parseBotQuestionRow from "@/src/utils/parse-bot-question-row";
import grabBotQuestion from "./grab-bot-question";
type Params = {
question_id: number | string;
user_id: number | string;
answers: { choices: number[]; other?: string }[];
};
/**
* The user answered questions a bot asked in its card: checked against the
* questions (`checkBotQuestionAnswers`), then stored once (only pending
* questions move, so a double click answers once).
*/
export default async function answerBotQuestion({ question_id, user_id, answers }: Params) {
const row = await grabBotQuestion({ question_id, user_id });
const { questions } = parseBotQuestionRow({ row });
const checked = checkBotQuestionAnswers({ questions, answers });
const now = Date.now();
const claimed = await BunSQLite.sql<{ id: number }>({
sql: `UPDATE bot_questions SET status = 'answered', answers = ?, decided_at = ?, updated_at = ?
WHERE id = ? AND status = 'pending' RETURNING id`,
values: [JSON.stringify(checked), now, now, Number(row.id)],
});
if (!claimed.payload?.[0]) throw new AppError(`These questions were already ${row.status}.`);
return row;
}
@@ -0,0 +1,39 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOT_QUESTIONS, BunSQLiteTables } from "@/db/types/db";
import type { BotQuestionItem } from "@/src/types";
type Params = {
user_id: number | string;
bot_id: number | string;
session_id: number | string;
workspace_id?: number | string | null;
title: string;
questions: BotQuestionItem[];
};
/**
* Stores questions a bot asks the user, pending until they answer or skip
* them in its card. Returns its id.
*/
export default async function createBotQuestion({ user_id, bot_id, session_id, workspace_id, title, questions }: Params) {
const res = await BunSQLite.insert<BUN_SQLITE_COBBLE_BOT_QUESTIONS, (typeof BunSQLiteTables)[number]>({
table: "bot_questions",
data: [
{
title,
questions: JSON.stringify(questions),
status: "pending",
user_id: Number(user_id),
bot_id: Number(bot_id),
session_id: Number(session_id),
workspace_id: workspace_id ? Number(workspace_id) : undefined,
},
],
});
const id = Number(res.singleRes?.id || res.postInsertReturn?.insertId);
if (!res.success || !id) throw new Error(`Couldn't store the questions: ${res.msg}`);
return id;
}
@@ -0,0 +1,36 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BotQuestionStatus } from "@/src/dict/bot-question-dict";
import type { BotQuestionView } from "@/src/types";
import parseBotQuestionRow from "@/src/utils/parse-bot-question-row";
import grabBotQuestion from "./grab-bot-question";
type Params = {
question_id: number | string;
user_id: number | string;
};
/**
* Questions a bot asked the user as their chip and card show them: the
* questions, their options and, once answered, what the user picked.
*/
export default async function grabBotQuestionView({ question_id, user_id }: Params): Promise<BotQuestionView> {
const row = await grabBotQuestion({ question_id, user_id });
const { questions, answers } = parseBotQuestionRow({ row });
const bot = (
await BunSQLite.sql<{ name: string }>({
sql: `SELECT name FROM bots WHERE id = ? LIMIT 1`,
values: [row.bot_id || 0],
})
).payload?.[0];
return {
id: Number(row.id),
title: row.title || "",
questions,
answers,
status: (row.status || "pending") as BotQuestionStatus,
bot_id: Number(row.bot_id),
bot_name: bot?.name || "The bot",
};
}
@@ -0,0 +1,28 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOT_QUESTIONS } from "@/db/types/db";
import AppError from "@/src/utils/app-error";
type Params = {
question_id: number | string;
/**
* Only the user they were asked of reads them.
*/
user_id: number | string;
};
/**
* Questions a bot asked, only for the user they were asked of. Throws
* otherwise.
*/
export default async function grabBotQuestion({ question_id, user_id }: Params) {
const row = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_QUESTIONS>({
sql: `SELECT * FROM bot_questions WHERE id = ? AND user_id = ? LIMIT 1`,
values: [Number(question_id), Number(user_id)],
})
).payload?.[0];
if (!row?.id) throw new AppError(`Those questions aren't yours or no longer exist.`);
return row;
}
@@ -0,0 +1,26 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import grabBotQuestion from "./grab-bot-question";
type Params = {
question_id: number | string;
user_id: number | string;
};
/**
* The user skipped questions a bot asked: nothing is answered.
*/
export default async function skipBotQuestion({ question_id, user_id }: Params) {
const row = await grabBotQuestion({ question_id, user_id });
const now = Date.now();
const claimed = await BunSQLite.sql<{ id: number }>({
sql: `UPDATE bot_questions SET status = 'skipped', decided_at = ?, updated_at = ?
WHERE id = ? AND status = 'pending' RETURNING id`,
values: [now, now, Number(row.id)],
});
if (!claimed.payload?.[0]) throw new AppError(`These questions were already ${row.status}.`);
return row;
}
@@ -0,0 +1,99 @@
import { Database } from "bun:sqlite";
import { describe, expect, test } from "bun:test";
import clearFinishedJobStepsSql from "./clear-finished-job-steps-sql";
/**
* Clearing a finished job's steps must keep their count on the job (the
* message details show it) and never touch a job that can still run: its
* working row reads them.
*/
function setup() {
const db = new Database(":memory:");
db.run(
`CREATE TABLE bots_queue (id INTEGER PRIMARY KEY, status TEXT, finished_at INTEGER, updated_at INTEGER, steps INTEGER)`,
);
db.run(`CREATE TABLE bots_queue_updates (id INTEGER PRIMARY KEY, bots_queue_id INTEGER)`);
db.run(`INSERT INTO bots_queue VALUES
(1, 'completed', 100, 100, NULL),
(2, 'failed', 100, 100, NULL),
(3, 'processing', NULL, 100, NULL),
(4, 'pending', NULL, 100, NULL),
(5, 'completed', 900, 900, NULL),
(6, 'failed', NULL, 100, NULL),
(7, 'completed', 100, 100, NULL)`);
db.run(`INSERT INTO bots_queue_updates (bots_queue_id) VALUES (1), (1), (1), (2), (3), (3), (4), (5), (6)`);
return db;
}
function run({ db, before }: { db: Database; before: number }) {
const { count, clear } = clearFinishedJobStepsSql({ before });
db.query(count.sql).run(...count.values);
db.query(clear.sql).run(...clear.values);
}
function stepsLeft({ db }: { db: Database }) {
return db
.query(`SELECT bots_queue_id AS id FROM bots_queue_updates ORDER BY id`)
.all()
.map((row) => (row as { id: number }).id);
}
function counts({ db }: { db: Database }) {
return Object.fromEntries(
db
.query(`SELECT id, steps FROM bots_queue ORDER BY id`)
.all()
.map((row) => [(row as { id: number }).id, (row as { steps: number | null }).steps]),
);
}
describe("clearFinishedJobStepsSql", () => {
test("clears the steps of jobs that ended before the cutoff, completed or failed", () => {
const db = setup();
run({ db, before: 500 });
expect(stepsLeft({ db })).not.toContain(1);
expect(stepsLeft({ db })).not.toContain(2);
});
test("keeps how many steps each job recorded", () => {
const db = setup();
run({ db, before: 500 });
expect(counts({ db })[1]).toBe(3);
expect(counts({ db })[2]).toBe(1);
});
test("never touches a job that can still run", () => {
const db = setup();
run({ db, before: 500 });
expect(stepsLeft({ db })).toEqual([3, 3, 4, 5]);
expect(counts({ db })[3]).toBeNull();
expect(counts({ db })[4]).toBeNull();
});
test("leaves a job that ended after the cutoff for the next sweep", () => {
const db = setup();
run({ db, before: 500 });
expect(counts({ db })[5]).toBeNull();
run({ db, before: 1000 });
expect(stepsLeft({ db })).not.toContain(5);
expect(counts({ db })[5]).toBe(1);
});
test("a job lost without a finish time is still cleared by when it last changed", () => {
const db = setup();
run({ db, before: 500 });
expect(stepsLeft({ db })).not.toContain(6);
expect(counts({ db })[6]).toBe(1);
});
test("a job with no steps keeps a null count, and a second sweep changes nothing", () => {
const db = setup();
run({ db, before: 500 });
run({ db, before: 500 });
expect(counts({ db })[7]).toBeNull();
expect(counts({ db })[1]).toBe(3);
});
});
@@ -0,0 +1,29 @@
type Params = {
/**
* Jobs that ended before this (unix ms) have their steps cleared.
*/
before: number;
};
/**
* The two statements that clear the steps (`bots_queue_updates`) of jobs
* that ended before `before`: first each job keeps how many it recorded
* (`bots_queue.steps`, for the message details), then the steps go. A job
* that's still open, or one retrying after a failed attempt, keeps its
* steps. Run in this order: the count reads the rows the delete removes.
*/
export default function clearFinishedJobStepsSql({ before }: Params) {
const finished = `SELECT id FROM bots_queue WHERE status IN ('completed', 'failed') AND COALESCE(finished_at, updated_at) < ?`;
return {
count: {
sql: `UPDATE bots_queue SET steps = COALESCE(steps, 0) + (SELECT COUNT(*) FROM bots_queue_updates u WHERE u.bots_queue_id = bots_queue.id)
WHERE id IN (${finished}) AND EXISTS (SELECT 1 FROM bots_queue_updates u WHERE u.bots_queue_id = bots_queue.id)`,
values: [before],
},
clear: {
sql: `DELETE FROM bots_queue_updates WHERE bots_queue_id IN (${finished})`,
values: [before],
},
};
}
@@ -0,0 +1,21 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import { AppData } from "@/src/data/app-data";
import clearFinishedJobStepsSql from "./clear-finished-job-steps-sql";
/**
* Clears the steps of jobs that ended more than `AppData.BotJobStepsKeepMs`
* ago, however they ended (completed, failed, cancelled, stopped, lost).
* Steps only feed the live working row; once the job's message is in they
* have no reader, and the job keeps their count. Not done the moment a job
* ends: steps reach the page only through the live relay, which would
* miss the last ones.
*/
export default async function clearFinishedJobSteps() {
const { count, clear } = clearFinishedJobStepsSql({
before: Date.now() - AppData.BotJobStepsKeepMs,
});
await BunSQLite.sql(count);
return await BunSQLite.sql(clear);
}
@@ -0,0 +1,24 @@
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
computer_id: number | string;
/**
* Left out of the count, e.g. the job just queued.
*/
except_job_id?: number | string | null;
};
/**
* How many tasks are queued or working on a computer (any bot's, any
* user's). Tasks run one at a time per computer, so a new one waits for
* all of them.
*/
export default async function countComputerTaskJobs({ computer_id, except_job_id }: Params) {
const res = await BunSQLite.sql<{ n: number }>({
sql: `SELECT COUNT(*) AS n FROM bots_queue
WHERE computer_id = ? AND kind = 'task' AND status IN ('pending', 'processing') AND id != ?`,
values: [Number(computer_id), Number(except_job_id) || 0],
});
return Number(res.payload?.[0]?.n) || 0;
}
@@ -3,21 +3,35 @@ import BunSQLite from "@moduletrace/bun-sqlite";
type Params = { type Params = {
bot_id: number | string; bot_id: number | string;
session_id: number | string; session_id: number | string;
title: string; /**
* Only a task with this title. Without it, any open task.
*/
title?: string;
/**
* Only a task on this computer.
*/
computer_id?: number | string | null;
}; };
/** /**
* The bot's task in this session with this title that's still queued or * The bot's latest task in this session that's still queued or working
* working, if any, so the same work isn't started twice. * (with this title, on this computer, when given), if any, so the same
* work isn't started twice.
*/ */
export default async function grabOpenBotTask({ bot_id, session_id, title }: Params) { export default async function grabOpenBotTask({ bot_id, session_id, title, computer_id }: Params) {
const res = await BunSQLite.sql<{ id: number; status: string }>({ const res = await BunSQLite.sql<{ id: number; status: string; title: string | null }>({
sql: `SELECT id, status FROM bots_queue sql: `SELECT id, status, title FROM bots_queue
WHERE bot_id = ? AND session_id = ? AND kind = 'task' WHERE bot_id = ? AND session_id = ? AND kind = 'task'
AND status IN ('pending', 'processing') AND status IN ('pending', 'processing')
AND lower(trim(title)) = lower(trim(?)) ${title != undefined ? `AND lower(trim(title)) = lower(trim(?))` : ""}
${computer_id ? `AND computer_id = ?` : ""}
ORDER BY id DESC LIMIT 1`, ORDER BY id DESC LIMIT 1`,
values: [bot_id, session_id, title], values: [
bot_id,
session_id,
...(title != undefined ? [title] : []),
...(computer_id ? [Number(computer_id)] : []),
],
}); });
return res.payload?.[0] || null; return res.payload?.[0] || null;
+9 -8
View File
@@ -2,6 +2,7 @@ import BunSQLite from "@moduletrace/bun-sqlite";
import deleteMedia from "@/src/functions/backend/db/media/delete-media"; import deleteMedia from "@/src/functions/backend/db/media/delete-media";
import purgeStorage, { grabStorageMediaIds } from "@/src/functions/backend/db/bot-files/purge-storage"; import purgeStorage, { grabStorageMediaIds } from "@/src/functions/backend/db/bot-files/purge-storage";
import { grabTaskMediaIds } from "@/src/functions/backend/db/tasks/purge-task-media"; import { grabTaskMediaIds } from "@/src/functions/backend/db/tasks/purge-task-media";
import passOnBotTasks from "@/src/functions/backend/db/tasks/pass-on-bot-tasks";
import AppError from "@/src/utils/app-error"; import AppError from "@/src/utils/app-error";
import { clearEnvCache } from "../envs/grab-all-envs"; import { clearEnvCache } from "../envs/grab-all-envs";
import grabUserBot from "./grab-user-bot"; import grabUserBot from "./grab-user-bot";
@@ -40,21 +41,21 @@ export default async function deleteBot({ user_id, bot_id }: Params) {
); );
const task_media_ids = await grabTaskMediaIds({ bot_id: bot.id! }).catch(() => []); const task_media_ids = await grabTaskMediaIds({ bot_id: bot.id! }).catch(() => []);
/**
* Its tasks in workspaces belong to the workspace: they go to each one's
* admin bot (else the coordinator) instead of cascading away with its
* own list.
*/
await passOnBotTasks({ bot_id: bot.id, bot_name: bot.name });
/** /**
* Explicitly, since foreign keys are off outside the server process. * Explicitly, since foreign keys are off outside the server process.
*/ */
await BunSQLite.sql({ sql: `DELETE FROM bot_types WHERE bot_id = ?`, values: [bot.id] }); await BunSQLite.sql({ sql: `DELETE FROM bot_types WHERE bot_id = ?`, values: [bot.id] });
/**
* Its tasks in workspaces belong to the workspace: they stay, shared
* again, instead of cascading away with its own list.
*/
await BunSQLite.sql({
sql: `UPDATE tasks SET bot_id = NULL, updated_at = ? WHERE bot_id = ? AND workspace_id IS NOT NULL`,
values: [Date.now(), bot.id],
});
await BunSQLite.sql({ sql: `DELETE FROM envs WHERE bot_id = ?`, values: [bot.id] }); await BunSQLite.sql({ sql: `DELETE FROM envs WHERE bot_id = ?`, values: [bot.id] });
await BunSQLite.sql({ sql: `DELETE FROM secret_requests WHERE bot_id = ?`, values: [bot.id] }); await BunSQLite.sql({ sql: `DELETE FROM secret_requests WHERE bot_id = ?`, values: [bot.id] });
await BunSQLite.sql({ sql: `DELETE FROM bot_questions WHERE bot_id = ?`, values: [bot.id] });
await BunSQLite.sql({ sql: `DELETE FROM bot_graphics WHERE bot_id = ?`, values: [bot.id] }); await BunSQLite.sql({ sql: `DELETE FROM bot_graphics WHERE bot_id = ?`, values: [bot.id] });
const res = await BunSQLite.sql({ sql: `DELETE FROM bots WHERE id = ?`, values: [bot.id] }); const res = await BunSQLite.sql({ sql: `DELETE FROM bots WHERE id = ?`, values: [bot.id] });
@@ -30,18 +30,16 @@ db.run(
db.run(`INSERT INTO bot_message_media (id, message_id, media_id) VALUES (1, 10, 1)`); db.run(`INSERT INTO bot_message_media (id, message_id, media_id) VALUES (1, 10, 1)`);
/** /**
* The query `grabMessagesMedia` builds, with the text column included or * The query `grabMessagesMedia` builds. The text column is never read: a
* left out. It is the largest column in the table, so only the reply path * reply reads an attachment's text from the file itself.
* that hands a document to a model asks for it.
*/ */
function read({ with_text_content }: { with_text_content: boolean }) { function read() {
const columns = [ const columns = [
"m.id", "m.id",
"m.title", "m.title",
"m.media_type", "m.media_type",
"m.media_write_path", "m.media_write_path",
"m.mime_type", "m.mime_type",
...(with_text_content ? ["m.text_content"] : []),
].join(", "); ].join(", ");
return db return db
@@ -55,15 +53,11 @@ function read({ with_text_content }: { with_text_content: boolean }) {
describe("grabMessagesMedia", () => { describe("grabMessagesMedia", () => {
test("a plain read never drags the extracted text along", () => { test("a plain read never drags the extracted text along", () => {
expect(read({ with_text_content: false })).not.toHaveProperty("text_content"); expect(read()).not.toHaveProperty("text_content");
});
test("the reply path can ask for it", () => {
expect(read({ with_text_content: true }).text_content).toBe("Total: 12");
}); });
test("the row's name and path are always there", () => { test("the row's name and path are always there", () => {
expect(read({ with_text_content: false })).toMatchObject({ expect(read()).toMatchObject({
title: "invoice.pdf", title: "invoice.pdf",
media_write_path: "media/1.pdf", media_write_path: "media/1.pdf",
message_id: 10, message_id: 10,
@@ -4,12 +4,6 @@ import mediaColumns from "@/src/functions/backend/db/media/media-columns";
type Params = { type Params = {
message_ids: number[]; message_ids: number[];
/**
* Also read `text_content`, the extracted text a reply hands a
* document to the model as. Off by default: it is the largest column
* in the table and nothing else here uses it.
*/
with_text_content?: boolean;
}; };
export type MessageMediaRow = BUN_SQLITE_COBBLE_MEDIA & { message_id: number }; export type MessageMediaRow = BUN_SQLITE_COBBLE_MEDIA & { message_id: number };
@@ -20,14 +14,13 @@ export type MessageMediaRow = BUN_SQLITE_COBBLE_MEDIA & { message_id: number };
*/ */
export default async function grabMessagesMedia({ export default async function grabMessagesMedia({
message_ids, message_ids,
with_text_content,
}: Params) { }: Params) {
const by_message = new Map<number, MessageMediaRow[]>(); const by_message = new Map<number, MessageMediaRow[]>();
const ids = [...new Set(message_ids.filter(Boolean))]; const ids = [...new Set(message_ids.filter(Boolean))];
if (!ids.length) return by_message; if (!ids.length) return by_message;
const columns = mediaColumns({ alias: "m", with_text_content }); const columns = mediaColumns({ alias: "m" });
const rows = const rows =
( (
@@ -0,0 +1,46 @@
import grabRunEnvs from "../extensions/grab-run-envs";
import type { DecryptedEnv } from "./grab-all-envs";
import grabComputerEnvs from "./grab-computer-envs";
type Params = {
bot_id: number | string;
user_id: number | string;
workspace_id?: number | string | null;
/**
* The computer the run is on (the bot's current one), whose envs count.
*/
computer_id?: number | string | null;
};
export type ReachableEnvs = {
/**
* Set on the computer for its tasks ($KEY in their shells and code).
*/
on_computer: DecryptedEnv[];
/**
* Only filled into tools' templates ({{env.KEY}}): not exposed to
* computers, or there's no computer.
*/
tools_only: DecryptedEnv[];
};
/**
* Every env that reaches one run, one per key: the computer's
* (`grabComputerEnvs`) first, then the ones only tools read
* (`grabRunEnvs`). What `show_secret` can show, what `request_secret`
* mustn't ask for again, and what a reply is told it has.
*/
export default async function grabReachableEnvs({
bot_id,
user_id,
workspace_id,
computer_id,
}: Params): Promise<ReachableEnvs> {
const [on_computer, run] = await Promise.all([
computer_id ? grabComputerEnvs({ computer_id, bot_id, user_id, workspace_id }) : [],
grabRunEnvs({ bot_id, user_id, workspace_id }),
]);
const keys = new Set(on_computer.map((env) => env.key));
return { on_computer, tools_only: run.filter((env) => !keys.has(env.key)) };
}
@@ -5,9 +5,8 @@
* `AppData.BotAttachmentTextStoreMaxChars` of extracted document text, and * `AppData.BotAttachmentTextStoreMaxChars` of extracted document text, and
* every caller that only needs a file's name, type or path (`SELECT m.*` * every caller that only needs a file's name, type or path (`SELECT m.*`
* did) pulled it into memory, into page props and into API responses. * did) pulled it into memory, into page props and into API responses.
* Read it explicitly where it is really used: * Only knowledgebase documents keep it now; read it explicitly where it
* `src/bots-queue/load-attachment-parts.ts` and * is really used.
* `src/functions/backend/db/bot-files/ingest-bot-file.ts`.
*/ */
export default function mediaColumns({ export default function mediaColumns({
alias = "m", alias = "m",
@@ -31,8 +30,7 @@ export default function mediaColumns({
]; ];
/** /**
* Only the two paths that really use it ask for it: the reply that * Only a caller that really uses it asks for it.
* hands a document to a model, and ingestion.
*/ */
if (with_text_content) columns.push("text_content"); if (with_text_content) columns.push("text_content");
@@ -1,65 +0,0 @@
import { describe, expect, test } from "bun:test";
import { AppData } from "@/src/data/app-data";
import planTextContentPrune from "./plan-text-content-prune";
describe("planTextContentPrune", () => {
test("clears the raw bytes stored for a type the extractor can't read", () => {
const changes = planTextContentPrune({
rows: [
{ id: 1, title: "logo.svg", mime_type: "image/svg+xml", text_content: "<svg><path/></svg>" },
{ id: 2, title: "bundle.zip", mime_type: "application/zip", text_content: "PK\u0003\u0004" },
{ id: 3, title: "sheet.xlsx", mime_type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", text_content: "PK\u0003\u0004" },
],
});
expect(changes.map((change) => change.media_id)).toEqual([1, 2, 3]);
expect(changes.every((change) => change.text_content === null)).toBe(true);
expect(changes.every((change) => change.after_chars === 0)).toBe(true);
});
test("leaves a document's text alone", () => {
const changes = planTextContentPrune({
rows: [
{ id: 4, title: "invoice.pdf", mime_type: "application/pdf", text_content: "Total: 12" },
{ id: 5, title: "notes.md", mime_type: "text/markdown", text_content: "# Hi" },
{ id: 6, title: "rows.csv", mime_type: "text/csv", text_content: "a,b\n1,2" },
],
});
expect(changes).toEqual([]);
});
test("cuts a document over the store ceiling", () => {
const text = "a".repeat(AppData.BotAttachmentTextStoreMaxChars + 100);
const changes = planTextContentPrune({
rows: [{ id: 7, title: "long.txt", mime_type: "text/plain", text_content: text }],
});
expect(changes).toHaveLength(1);
expect(changes[0]!.after_chars).toBeLessThanOrEqual(AppData.BotAttachmentTextStoreMaxChars);
expect(changes[0]!.text_content).toContain("Truncated");
expect(changes[0]!.before_chars).toBe(text.length);
});
test("nothing to say about a row with no text or no id", () => {
expect(
planTextContentPrune({
rows: [
{ id: 8, title: "empty.pdf", text_content: "" },
{ id: 9, title: "blank.pdf", text_content: " \n " },
{ id: 10, title: "none.pdf" },
{ title: "no id.pdf", text_content: "text" },
],
}),
).toEqual([]);
});
test("counting what each change reclaims is left to the caller", () => {
const changes = planTextContentPrune({
rows: [{ id: 11, title: "icon.svg", mime_type: "image/svg+xml", text_content: "12345" }],
});
expect(changes).toHaveLength(1);
expect(changes[0]!.before_chars - changes[0]!.after_chars).toBe(5);
});
});
@@ -1,55 +0,0 @@
import type { BUN_SQLITE_COBBLE_MEDIA } from "@/db/types/db";
import decideTextContent from "@/src/utils/decide-text-content";
export type TextContentChange = {
media_id: number;
name: string;
before_chars: number;
after_chars: number;
/**
* What the row's `text_content` should become: `null` clears it.
*/
text_content: string | null;
};
type Params = {
/**
* The `media` rows that have any `text_content`.
*/
rows: Partial<BUN_SQLITE_COBBLE_MEDIA>[];
};
/**
* What `pruneMediaTextContent` would change, worked out without touching
* the database: one entry per row whose text belongs nowhere or is over
* the store ceiling, and nothing at all for a row that is already right.
*/
export default function planTextContentPrune({ rows }: Params): TextContentChange[] {
const changes: TextContentChange[] = [];
for (const row of rows) {
if (!row.id) continue;
const before = row.text_content || "";
if (!before.trim()) continue;
const text_content = decideTextContent({
name: row.title,
mime_type: row.mime_type,
text: before,
});
if (text_content === before) continue;
changes.push({
media_id: Number(row.id),
name: row.title || "no name",
before_chars: before.length,
after_chars: text_content?.length || 0,
text_content,
});
}
return changes;
}
@@ -1,120 +0,0 @@
import { describe, expect, test } from "bun:test";
import { Database } from "bun:sqlite";
import { AppData } from "@/src/data/app-data";
import pruneMediaTextContent, { type SqlRunner } from "./prune-media-text-content";
type Row = { id: number; title: string; mime_type: string; media_type: string; text_content: string | null; updated_at?: number };
function makeDb({ rows }: { rows: Row[] }) {
const db = new Database(":memory:");
db.run(
`CREATE TABLE media (
id INTEGER PRIMARY KEY,
title TEXT,
mime_type TEXT,
media_type TEXT,
text_content TEXT,
updated_at INTEGER
)`,
);
const insert = db.prepare(
`INSERT INTO media (id, title, mime_type, media_type, text_content) VALUES (?, ?, ?, ?, ?)`,
);
for (const row of rows) insert.run(row.id, row.title, row.mime_type, row.media_type, row.text_content);
const run: SqlRunner = async ({ sql, values }) => {
const statement = db.prepare(sql);
if (sql.trim().toUpperCase().startsWith("SELECT")) {
return { payload: statement.all(...((values || []) as never[])) as unknown[] };
}
statement.run(...((values || []) as never[]));
return { payload: [] };
};
const read = (id: number) =>
(db.query(`SELECT text_content FROM media WHERE id = ?`).get(id) as { text_content: string | null }).text_content;
return { db, run, read };
}
describe("pruneMediaTextContent", () => {
test("clears raw bytes from a type the extractor can't read, and keeps a document's text", async () => {
const { run, read } = makeDb({
rows: [
{ id: 1, title: "logo.svg", mime_type: "image/svg+xml", media_type: "document", text_content: "<svg><path d='M0 0'/></svg>" },
{ id: 2, title: "bundle.zip", mime_type: "application/zip", media_type: "document", text_content: "PK\u0003\u0004binary" },
{ id: 3, title: "invoice.pdf", mime_type: "application/pdf", media_type: "document", text_content: "Total: 12" },
],
});
const result = await pruneMediaTextContent({ run });
expect(result.checked).toBe(3);
expect(result.cleared).toBe(2);
expect(result.truncated).toBe(0);
expect(read(1)).toBeNull();
expect(read(2)).toBeNull();
expect(read(3)).toBe("Total: 12");
});
test("cuts an oversized document to the store ceiling", async () => {
const text = "a".repeat(AppData.BotAttachmentTextStoreMaxChars + 1000);
const { run, read } = makeDb({
rows: [{ id: 4, title: "huge.txt", mime_type: "text/plain", media_type: "document", text_content: text }],
});
const result = await pruneMediaTextContent({ run });
const after = read(4)!;
expect(result.truncated).toBe(1);
expect(result.cleared).toBe(0);
expect(after.length).toBeLessThanOrEqual(AppData.BotAttachmentTextStoreMaxChars);
expect(after).toContain("Truncated");
expect(result.reclaimed_bytes).toBeGreaterThan(0);
});
test("is safe to rerun: a second sweep changes nothing", async () => {
const { run, read } = makeDb({
rows: [{ id: 5, title: "icon.svg", mime_type: "image/svg+xml", media_type: "document", text_content: "raw" }],
});
await pruneMediaTextContent({ run });
const second = await pruneMediaTextContent({ run });
expect(second.checked).toBe(0);
expect(second.changes).toEqual([]);
expect(second.reclaimed_bytes).toBe(0);
expect(read(5)).toBeNull();
});
test("stamps updated_at on the rows it rewrites", async () => {
const now = 1_700_000_000_000;
const { run, db } = makeDb({
rows: [{ id: 6, title: "x.zip", mime_type: "application/zip", media_type: "document", text_content: "raw" }],
});
await pruneMediaTextContent({ run, now });
expect(
(db.query(`SELECT updated_at FROM media WHERE id = 6`).get() as { updated_at: number }).updated_at,
).toBe(now);
});
test("reports every change with its row, name and sizes", async () => {
const { run } = makeDb({
rows: [{ id: 7, title: "sheet.xlsx", mime_type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", media_type: "document", text_content: "PK\u0003\u0004" }],
});
const result = await pruneMediaTextContent({ run });
expect(result.changes).toEqual([
{ media_id: 7, name: "sheet.xlsx", before_chars: 4, after_chars: 0, text_content: null },
]);
});
});
@@ -1,75 +0,0 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_MEDIA } from "@/db/types/db";
import planTextContentPrune, { type TextContentChange } from "./plan-text-content-prune";
/**
* One statement against the database. `BunSQLite.sql` is the real one; a
* test passes its own, so the whole sweep can be run without the app's
* database.
*/
export type SqlRunner = (params: {
sql: string;
values?: (string | number | null)[];
}) => Promise<{ payload?: unknown[] | null }>;
export type PruneMediaTextResult = {
checked: number;
cleared: number;
truncated: number;
reclaimed_bytes: number;
changes: TextContentChange[];
};
type Params = {
run?: SqlRunner;
/**
* When the sweep runs, for the rows it writes.
*/
now?: number;
};
/**
* Brings every `media.text_content` in line with what may be stored there
* (`planTextContentPrune`): no text on a type the extractor can't read,
* and no text longer than the store ceiling.
*
* Run once per install after the allowlist landed; safe to rerun, since a
* row that is already right is not written.
*/
export default async function pruneMediaTextContent({
run,
now = Date.now(),
}: Params = {}): Promise<PruneMediaTextResult> {
const sql: SqlRunner =
run || (({ sql, values }) => BunSQLite.sql<BUN_SQLITE_COBBLE_MEDIA>({ sql, values }));
const res = await sql({
sql: `SELECT id, title, mime_type, media_type, text_content
FROM media
WHERE text_content IS NOT NULL AND TRIM(text_content) != ''`,
});
const rows = (res.payload || []) as BUN_SQLITE_COBBLE_MEDIA[];
const changes = planTextContentPrune({ rows });
const result: PruneMediaTextResult = {
checked: rows.length,
cleared: 0,
truncated: 0,
reclaimed_bytes: 0,
changes,
};
for (const change of changes) {
await sql({
sql: `UPDATE media SET text_content = ?, updated_at = ? WHERE id = ?`,
values: [change.text_content, now, change.media_id],
});
result.reclaimed_bytes += change.before_chars - change.after_chars;
if (change.text_content === null) result.cleared += 1;
else result.truncated += 1;
}
return result;
}
@@ -0,0 +1,65 @@
import { Database } from "bun:sqlite";
import { describe, expect, test } from "bun:test";
import dropStoredFileTextSql from "./drop-stored-file-text-sql";
function scratch() {
const db = new Database(":memory:");
db.run(`CREATE TABLE media (id INTEGER PRIMARY KEY, text_content TEXT)`);
db.run(`CREATE TABLE knowledgebase_documents (id INTEGER PRIMARY KEY, media_id INTEGER)`);
db.run(`CREATE TABLE file_ingest_queue (id INTEGER PRIMARY KEY, document_id INTEGER)`);
db.run(`CREATE TABLE bot_file_chunks (id INTEGER PRIMARY KEY, text TEXT)`);
db.run(`INSERT INTO media (id, text_content) VALUES (1, 'an attachment'), (2, 'a knowledgebase upload'), (3, NULL)`);
db.run(`INSERT INTO knowledgebase_documents (id, media_id) VALUES (1, 2), (2, NULL)`);
db.run(`INSERT INTO file_ingest_queue (id, document_id) VALUES (1, NULL), (2, 1)`);
db.run(`INSERT INTO bot_file_chunks (id, text) VALUES (1, 'old passage')`);
return db;
}
function run(db: Database) {
for (const sql of dropStoredFileTextSql()) db.run(sql);
}
describe("dropStoredFileTextSql", () => {
test("drops the bot file index", () => {
const db = scratch();
run(db);
const tables = db.query(`SELECT name FROM sqlite_master WHERE name LIKE 'bot_file_%'`).all();
expect(tables).toEqual([]);
});
test("clears upload text but keeps a knowledgebase document's", () => {
const db = scratch();
run(db);
const rows = db.query(`SELECT id, text_content FROM media ORDER BY id`).all();
expect(rows).toEqual([
{ id: 1, text_content: null },
{ id: 2, text_content: "a knowledgebase upload" },
{ id: 3, text_content: null },
]);
});
test("keeps only document jobs on the ingest queue", () => {
const db = scratch();
run(db);
expect(db.query(`SELECT id FROM file_ingest_queue`).all()).toEqual([{ id: 2 }]);
});
test("runs again on a clean database without failing", () => {
const db = scratch();
run(db);
expect(() => run(db)).not.toThrow();
});
});
@@ -0,0 +1,17 @@
/**
* The statements that remove text stored outside knowledgebases: the old
* bot file index (chunks and their vectors; the vec0 drop takes its shadow
* tables with it), file jobs left on the ingest queue, and the extracted
* text uploads used to keep. A knowledgebase document's media keeps its
* text. Every statement is a no-op once it has run.
*/
export default function dropStoredFileTextSql(): string[] {
return [
`DROP TABLE IF EXISTS bot_file_vectors`,
`DROP TABLE IF EXISTS bot_file_chunks`,
`DELETE FROM file_ingest_queue WHERE document_id IS NULL`,
`UPDATE media SET text_content = NULL
WHERE text_content IS NOT NULL
AND id NOT IN (SELECT media_id FROM knowledgebase_documents WHERE media_id IS NOT NULL)`,
];
}
@@ -0,0 +1,46 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import logEvent, { logError } from "@/src/functions/backend/logger";
import dropStoredFileTextSql from "./drop-stored-file-text-sql";
import shouldVacuum from "./should-vacuum";
/**
* Knowledgebases are the only place text is kept for search. Removes what
* bot files and uploads stored before that (`dropStoredFileTextSql`), then
* vacuums once if the database has a lot of free pages, so the space comes
* back on disk. Safe on every start: once clean, it only reads two pragmas.
*/
export default async function dropStoredFileText() {
for (const sql of dropStoredFileTextSql()) {
const res = await BunSQLite.sql({ sql });
if (!res.success) throw new Error(`${sql.split("\n")[0]}: ${res.error || res.msg}`);
}
const [page_count, freelist_count, page_size] = await Promise.all(
["page_count", "freelist_count", "page_size"].map((pragma) =>
BunSQLite.sql<Record<string, number>>({ sql: `PRAGMA ${pragma}` }).then((res) =>
Number(res.payload?.[0]?.[pragma]) || 0,
),
),
);
if (!shouldVacuum({ page_count: page_count!, freelist_count: freelist_count!, page_size: page_size! })) return;
const res = await BunSQLite.sql({ sql: `VACUUM` });
if (!res.success) {
await logError({
title: "Could not vacuum the database",
error: new Error(`${res.error || res.msg}. It needs about as much free disk as the database takes.`),
metadata: { free_bytes: freelist_count! * page_size! },
}).catch(() => {});
return;
}
await logEvent({
title: "Vacuumed the database",
category: "system",
plain_text: `Gave back ${Math.round((freelist_count! * page_size!) / (1024 * 1024))} MB of free pages to the disk.`,
metadata: { freed_bytes: freelist_count! * page_size! },
}).catch(() => {});
}
@@ -0,0 +1,24 @@
import { describe, expect, test } from "bun:test";
import { AppData } from "@/src/data/app-data";
import shouldVacuum from "./should-vacuum";
const page_size = 4096;
const min_pages = Math.ceil(AppData.DbVacuumMinFreeBytes / page_size);
describe("shouldVacuum", () => {
test("vacuums when a large share of a big file is free", () => {
expect(shouldVacuum({ page_count: min_pages * 2, freelist_count: min_pages, page_size })).toBe(true);
});
test("leaves a database with little free space alone", () => {
expect(shouldVacuum({ page_count: min_pages * 100, freelist_count: min_pages, page_size })).toBe(false);
});
test("leaves a small database alone even when mostly free", () => {
expect(shouldVacuum({ page_count: 20, freelist_count: 15, page_size })).toBe(false);
});
test("never vacuums on missing numbers", () => {
expect(shouldVacuum({ page_count: 0, freelist_count: 0, page_size })).toBe(false);
});
});
@@ -0,0 +1,22 @@
import { AppData } from "@/src/data/app-data";
type Params = {
page_count: number;
freelist_count: number;
page_size: number;
};
/**
* Whether enough of the database file sits free to be worth a `VACUUM`
* (`AppData.DbVacuumMinFreeRatio` of its pages and at least
* `AppData.DbVacuumMinFreeBytes`). Deleted rows only free pages inside the
* file; it shrinks on disk only when vacuumed.
*/
export default function shouldVacuum({ page_count, freelist_count, page_size }: Params) {
if (!(page_count > 0) || !(freelist_count > 0)) return false;
return (
freelist_count / page_count >= AppData.DbVacuumMinFreeRatio &&
freelist_count * page_size >= AppData.DbVacuumMinFreeBytes
);
}
@@ -0,0 +1,10 @@
import passOnBotTasks from "./pass-on-bot-tasks";
/**
* At boot: every task belongs to a bot. Workspace tasks left without one
* by earlier versions ("shared") go to the workspace's admin bot, else the
* coordinator.
*/
export default async function assignOwnerlessTasks() {
return await passOnBotTasks({});
}
@@ -27,7 +27,7 @@ type Params = {
/** /**
* Adds a task to a bot's own list or a workspace's, there for one of its * Adds a task to a bot's own list or a workspace's, there for one of its
* bots (`bot_id` too) or shared. A user adds one to a bot they chat with or * bots (`bot_id` too): every task belongs to a bot. A user adds one to a bot they chat with or
* a workspace they're in; a bot's run adds one to its own list or to a * a workspace they're in; a bot's run adds one to its own list or to a
* workspace it works in (its tool checks that). A workspace's task can * workspace it works in (its tool checks that). A workspace's task can
* only go to a bot working there. Caps a list at `TaskMaxPerScope` open and * only go to a bot working there. Caps a list at `TaskMaxPerScope` open and
@@ -0,0 +1,29 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import grabWorkspaceAdmins from "../bot-types/grab-workspace-admins";
type Params = {
workspace_id: number | string;
/**
* The bot leaving, never its own heir.
*/
except_bot_id?: number | string | null;
};
/**
* Who takes over a workspace task whose bot is gone: the workspace's admin
* bot, else the coordinator. Every task belongs to a bot, so none is left
* without one.
*/
export default async function grabTaskHeir({ workspace_id, except_bot_id }: Params) {
const admins = await grabWorkspaceAdmins({ workspace_id });
const admin = admins.find((bot) => Number(bot.id) != Number(except_bot_id || 0));
if (admin?.id) return admin;
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT * FROM bots WHERE kind = 'coordinator' ORDER BY id ASC LIMIT 1`,
});
return res.payload?.[0] || null;
}
+12 -6
View File
@@ -1,6 +1,5 @@
import BunSQLite from "@moduletrace/bun-sqlite"; import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_TASK_NOTES } from "@/db/types/db"; import type { TaskDetail, TaskNote } from "@/src/types";
import type { TaskDetail } from "@/src/types";
import AppError from "@/src/utils/app-error"; import AppError from "@/src/utils/app-error";
import botFaceSql from "@/src/utils/bot-face-sql"; import botFaceSql from "@/src/utils/bot-face-sql";
import taskScopesSql from "@/src/utils/task-access/task-scopes-sql"; import taskScopesSql from "@/src/utils/task-access/task-scopes-sql";
@@ -16,7 +15,8 @@ type Params = {
}; };
/** /**
* One task with its notes (oldest first) and files, only if the user * One task with its notes (oldest first, each with the bot that wrote it),
* its files and who added it, only if the user
* reaches the bot or workspace it belongs to (`taskScopesSql`, the same * reaches the bot or workspace it belongs to (`taskScopesSql`, the same
* condition the lists use). * condition the lists use).
*/ */
@@ -26,10 +26,14 @@ export default async function grabTask({ user_id, task_id }: Params): Promise<Ta
const row_res = await BunSQLite.sql<TaskDetail>({ const row_res = await BunSQLite.sql<TaskDetail>({
sql: `SELECT t.*, b.name AS bot_name, b.kind AS bot_kind, b.hue AS bot_hue, b.color AS bot_color, b.stone AS bot_stone, sql: `SELECT t.*, b.name AS bot_name, b.kind AS bot_kind, b.hue AS bot_hue, b.color AS bot_color, b.stone AS bot_stone,
b.image_media_id AS bot_image_media_id, ${face.sql}, w.name AS workspace_name b.image_media_id AS bot_image_media_id, ${face.sql}, w.name AS workspace_name,
cb.name AS created_by_bot_name,
NULLIF(TRIM(COALESCE(cu.first_name, '') || ' ' || COALESCE(cu.last_name, '')), '') AS created_by_user_name
FROM tasks t FROM tasks t
LEFT JOIN bots b ON b.id = t.bot_id LEFT JOIN bots b ON b.id = t.bot_id
LEFT JOIN workspaces w ON w.id = t.workspace_id LEFT JOIN workspaces w ON w.id = t.workspace_id
LEFT JOIN bots cb ON cb.id = t.created_by_bot_id
LEFT JOIN users cu ON cu.id = t.created_by_user_id
WHERE t.id = ? AND ${access.sql} WHERE t.id = ? AND ${access.sql}
LIMIT 1`, LIMIT 1`,
values: [...face.values, Number(task_id), ...access.values], values: [...face.values, Number(task_id), ...access.values],
@@ -42,8 +46,10 @@ export default async function grabTask({ user_id, task_id }: Params): Promise<Ta
} }
const [notes_res, files] = await Promise.all([ const [notes_res, files] = await Promise.all([
BunSQLite.sql<BUN_SQLITE_COBBLE_TASK_NOTES>({ BunSQLite.sql<TaskNote>({
sql: `SELECT * FROM task_notes WHERE task_id = ? ORDER BY id ASC`, sql: `SELECT n.*, nb.name AS bot_name FROM task_notes n
LEFT JOIN bots nb ON nb.id = n.bot_id
WHERE n.task_id = ? ORDER BY n.id ASC`,
values: [Number(task_id)], values: [Number(task_id)],
}), }),
grabTaskMedia({ task_id }), grabTaskMedia({ task_id }),
+1 -6
View File
@@ -27,10 +27,6 @@ type Params = {
* Only the tasks this bot has (anywhere, unless narrowed). * Only the tasks this bot has (anywhere, unless narrowed).
*/ */
only_bot_id?: number | string | null; only_bot_id?: number | string | null;
/**
* With `only_bot_id`: also the shared ones nobody has yet.
*/
with_unassigned?: boolean;
/** /**
* Only one workspace's tasks. * Only one workspace's tasks.
*/ */
@@ -61,7 +57,6 @@ export default async function grabTasks({
is_coordinator, is_coordinator,
admin_workspace_ids, admin_workspace_ids,
only_bot_id, only_bot_id,
with_unassigned,
only_workspace_id, only_workspace_id,
own_list, own_list,
statuses, statuses,
@@ -82,7 +77,7 @@ export default async function grabTasks({
} }
if (only_bot_id) { if (only_bot_id) {
where.push(with_unassigned ? `(t.bot_id = ? OR t.bot_id IS NULL)` : `t.bot_id = ?`); where.push(`t.bot_id = ?`);
values.push(Number(only_bot_id)); values.push(Number(only_bot_id));
} }
+1 -1
View File
@@ -10,7 +10,7 @@ type Params = {
task_id: number | string; task_id: number | string;
/** /**
* Where it goes: a bot's own list (`bot_id` alone), or a workspace, * Where it goes: a bot's own list (`bot_id` alone), or a workspace,
* with the bot that has it there (none: unassigned). * with the bot that has it there (always one).
*/ */
bot_id?: number | string | null; bot_id?: number | string | null;
workspace_id?: number | string | null; workspace_id?: number | string | null;
@@ -0,0 +1,65 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_TASKS } from "@/db/types/db";
import addTaskNote from "./add-task-note";
import grabTaskHeir from "./grab-task-heir";
type Params = {
/**
* The bot leaving; none for tasks that have no bot at all.
*/
bot_id?: number | string | null;
/**
* Only its tasks in this workspace; none for every workspace.
*/
workspace_id?: number | string | null;
/**
* Who is leaving, for the note on each task.
*/
bot_name?: string | null;
};
/**
* Gives a leaving bot's workspace tasks (or ones with no bot) to the
* workspace's admin bot, else the coordinator, with a note on each. Its own
* list is left alone. Returns how many moved.
*/
export default async function passOnBotTasks({ bot_id, workspace_id, bot_name }: Params) {
const where = [`workspace_id IS NOT NULL`, bot_id ? `bot_id = ?` : `bot_id IS NULL`];
const values: number[] = bot_id ? [Number(bot_id)] : [];
if (workspace_id) {
where.push(`workspace_id = ?`);
values.push(Number(workspace_id));
}
const tasks =
(
await BunSQLite.sql<BUN_SQLITE_COBBLE_TASKS>({
sql: `SELECT * FROM tasks WHERE ${where.join(" AND ")}`,
values,
})
).payload || [];
let moved = 0;
for (const task of tasks) {
const heir = await grabTaskHeir({ workspace_id: task.workspace_id!, except_bot_id: bot_id });
if (!heir?.id) continue;
await BunSQLite.sql({
sql: `UPDATE tasks SET bot_id = ?, updated_at = ? WHERE id = ?`,
values: [Number(heir.id), Date.now(), Number(task.id)],
});
await addTaskNote({
task_id: task.id!,
text: bot_name ? `${bot_name} left; this is now ${heir.name}'s.` : `This is now ${heir.name}'s.`,
author: "user",
}).catch(() => {});
moved++;
}
return moved;
}
@@ -0,0 +1,14 @@
import { describe, expect, test } from "bun:test";
import taskScopeValues from "./task-scope-values";
describe("taskScopeValues", () => {
test("a task on a bot's own list or in a workspace has its bot", () => {
expect(taskScopeValues({ bot_id: 3 })).toEqual({ bot_id: 3, workspace_id: null });
expect(taskScopeValues({ bot_id: "3", workspace_id: "11" })).toEqual({ bot_id: 3, workspace_id: 11 });
});
test("a workspace task without a bot is refused", () => {
expect(() => taskScopeValues({ workspace_id: 11 })).toThrow();
expect(() => taskScopeValues({})).toThrow();
});
});
@@ -6,17 +6,17 @@ type Params = {
}; };
/** /**
* Where a task lives and who has it. A bot's own list is `bot_id` alone; a * Where a task lives and who has it. Every task belongs to a bot: a bot's
* workspace's task has `workspace_id`, plus `bot_id` when a bot there has * own list is `bot_id` alone, a workspace's task has `workspace_id` plus
* it (none: shared, not yet anyone's). Checked in the helpers rather than * the `bot_id` that has it there. Checked in the helpers rather than the
* the schema. * schema.
*/ */
export default function taskScopeValues({ bot_id, workspace_id }: Params) { export default function taskScopeValues({ bot_id, workspace_id }: Params) {
const bot = bot_id ? Number(bot_id) : null; const bot = bot_id ? Number(bot_id) : null;
const workspace = workspace_id ? Number(workspace_id) : null; const workspace = workspace_id ? Number(workspace_id) : null;
if (!bot && !workspace) { if (!bot) {
throw new AppError(`Say which bot or workspace the task is for.`); throw new AppError(`Say which bot has the task.`);
} }
return { bot_id: bot, workspace_id: workspace }; return { bot_id: bot, workspace_id: workspace };
@@ -105,7 +105,7 @@ async function grabJob({ job_id }: { job_id: number }): Promise<MessageDetailsJo
const row = ( const row = (
await BunSQLite.sql<JobRow>({ await BunSQLite.sql<JobRow>({
sql: `SELECT q.id, q.kind, q.title, q.status, q.attempts, q.started_at, q.finished_at, sql: `SELECT q.id, q.kind, q.title, q.status, q.attempts, q.started_at, q.finished_at,
(SELECT COUNT(*) FROM bots_queue_updates u WHERE u.bots_queue_id = q.id) AS steps COALESCE(q.steps, 0) + (SELECT COUNT(*) FROM bots_queue_updates u WHERE u.bots_queue_id = q.id) AS steps
FROM bots_queue q WHERE q.id = ? LIMIT 1`, FROM bots_queue q WHERE q.id = ? LIMIT 1`,
values: [job_id], values: [job_id],
}) })
@@ -74,7 +74,7 @@ export default async function deleteUser({ actor_user_id, actor_user_types, user
/** /**
* These point at the user without a foreign key, so nothing cascades. * These point at the user without a foreign key, so nothing cascades.
*/ */
for (const table of ["user_types", "user_passwords", "sso_login_codes", "secret_requests", "bot_graphics"]) { for (const table of ["user_types", "user_passwords", "sso_login_codes", "secret_requests", "bot_questions", "bot_graphics"]) {
await BunSQLite.sql({ sql: `DELETE FROM ${table} WHERE user_id = ?`, values: [user.id] }); await BunSQLite.sql({ sql: `DELETE FROM ${table} WHERE user_id = ?`, values: [user.id] });
} }
@@ -10,8 +10,8 @@ type Params = {
/** /**
* The user's latest conversations in a workspace, most recent first, each * The user's latest conversations in a workspace, most recent first, each
* with the bot it's with (its face as the user sees it), its message count and latest message. Empty * with the bot it's with (its face as the user sees it), its message count, first and latest message. Sessions
* sessions are left out unless active. * with no messages yet are left out, active or not.
*/ */
export default async function grabWorkspaceActivity({ export default async function grabWorkspaceActivity({
user_id, user_id,
@@ -28,11 +28,12 @@ export default async function grabWorkspaceActivity({
b.stone AS bot_stone, b.stone AS bot_stone,
${face.sql}, ${face.sql},
(SELECT COUNT(*) FROM bot_messages m WHERE m.session_id = s.id) AS message_count, (SELECT COUNT(*) FROM bot_messages m WHERE m.session_id = s.id) AS message_count,
(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 (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,
(SELECT m.text FROM bot_messages m WHERE m.session_id = s.id ORDER BY m.created_at ASC, m.id ASC LIMIT 1) AS first_message_text
FROM bot_sessions s FROM bot_sessions s
JOIN bots b ON b.id = s.bot_id JOIN bots b ON b.id = s.bot_id
WHERE s.workspace_id = ? AND s.user_id = ? AND s.chatroom_id IS NULL WHERE s.workspace_id = ? AND s.user_id = ? AND s.chatroom_id IS NULL
AND (s.status = 'active' OR EXISTS (SELECT 1 FROM bot_messages m WHERE m.session_id = s.id)) AND EXISTS (SELECT 1 FROM bot_messages m WHERE m.session_id = s.id)
AND (b.kind = 'coordinator' OR EXISTS ( AND (b.kind = 'coordinator' OR EXISTS (
SELECT 1 FROM workspace_bots wb WHERE wb.workspace_id = s.workspace_id AND wb.bot_id = s.bot_id SELECT 1 FROM workspace_bots wb WHERE wb.workspace_id = s.workspace_id AND wb.bot_id = s.bot_id
)) ))
@@ -1,6 +1,8 @@
import BunSQLite from "@moduletrace/bun-sqlite"; import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error"; import AppError from "@/src/utils/app-error";
import grabUserWorkspace from "./grab-user-workspace"; import grabUserWorkspace from "./grab-user-workspace";
import grabBotById from "../bots/grab-bot-by-id";
import passOnBotTasks from "../tasks/pass-on-bot-tasks";
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager"; import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
type Params = { type Params = {
@@ -31,12 +33,11 @@ export default async function removeBotFromWorkspace({
}); });
/** /**
* The tasks it had there stay in the workspace, shared again. * The tasks it had there stay in the workspace, passed to its admin bot
* (else the coordinator), read before its admin type goes below.
*/ */
await BunSQLite.sql({ const bot = await grabBotById({ bot_id });
sql: `UPDATE tasks SET bot_id = NULL, updated_at = ? WHERE workspace_id = ? AND bot_id = ?`, await passOnBotTasks({ bot_id, workspace_id: workspace.id, bot_name: bot?.name });
values: [Date.now(), workspace.id, bot_id],
});
/** /**
* Its types there (workspace admin) go with its place. * Its types there (workspace admin) go with its place.
@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
import { mkdir, readdir, rm, writeFile } from "node:fs/promises"; import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { import {
PwaInstallIconRenderRevision,
PwaInstallIconSettingKey, PwaInstallIconSettingKey,
type PwaInstallIcon, type PwaInstallIcon,
type PwaInstallIconMode, type PwaInstallIconMode,
@@ -57,7 +58,7 @@ export default async function saveInstallIcon({ mode, stone, mark, image }: Para
const version = is_stock const version = is_stock
? null ? null
: createHash("sha256") : createHash("sha256")
.update(JSON.stringify({ mode, stone, mark })) .update(JSON.stringify({ mode, stone, mark, render: PwaInstallIconRenderRevision }))
.update(source || "") .update(source || "")
.digest("hex") .digest("hex")
.slice(0, 12); .slice(0, 12);
@@ -1,10 +1,14 @@
import sharp from "sharp"; import sharp, { type OverlayOptions } from "sharp";
import { import {
PwaAppIconMarkScale, PwaAppIconMarkScale,
PwaCornerMarkInset, PwaCornerMarkInset,
PwaCornerMarkScale, PwaCornerMarkScale,
PwaFaviconCornerMarkInset,
PwaFaviconCornerMarkScale,
PwaInstallIconFiles, PwaInstallIconFiles,
PwaMaskableMarkScale, PwaMaskableMarkScale,
PwaSquareCornerMarkInset,
PwaSquareCornerMarkScale,
type PwaInstallIcon, type PwaInstallIcon,
type PwaInstallIconFile, type PwaInstallIconFile,
} from "@/src/data/pwa-data"; } from "@/src/data/pwa-data";
@@ -26,7 +30,8 @@ type Params = {
/** /**
* Every file of an install's icon set (`PwaInstallIconFiles`), by name. * Every file of an install's icon set (`PwaInstallIconFiles`), by name.
* A stone icon is the stone on paper, like the stock one. An image fills * A stone icon is the stone on paper, like the stock one. An image fills
* the icon, with the stone in the top-left corner when `mark` is on. * the icon, with the stone in the top-left corner when `mark` is on; its
* favicon is cut to a circle, the mark kept inside it.
*/ */
export default async function renderInstallIcons({ icon, stone_svg, image, background }: Params) { export default async function renderInstallIcons({ icon, stone_svg, image, background }: Params) {
const files: Record<string, Buffer> = {}; const files: Record<string, Buffer> = {};
@@ -79,19 +84,41 @@ async function renderImageFile({
.resize(file.size, file.size, { fit: "cover" }) .resize(file.size, file.size, { fit: "cover" })
.flatten({ background }); .flatten({ background });
if (!mark || file.kind == "favicon" || file.kind == "plain") { const layers: OverlayOptions[] = [];
return base.png().toBuffer();
if (mark && file.kind != "plain") {
const { scale, inset_scale } = markPlacement({ kind: file.kind });
const mark_size = Math.max(1, Math.round(file.size * scale));
const inset = Math.round(file.size * inset_scale);
const stone = await sharp(stone_svg)
.resize(mark_size, mark_size, { fit: "contain" })
.png()
.toBuffer();
layers.push({ input: stone, left: inset, top: inset });
} }
const mark_size = Math.max(1, Math.round(file.size * PwaCornerMarkScale)); if (file.kind == "favicon") {
const inset = Math.round(file.size * PwaCornerMarkInset); const half = file.size / 2;
const stone = await sharp(stone_svg) const circle = Buffer.from(
.resize(mark_size, mark_size, { fit: "contain" }) `<svg xmlns="http://www.w3.org/2000/svg" width="${file.size}" height="${file.size}"><circle cx="${half}" cy="${half}" r="${half}"/></svg>`,
.png() );
.toBuffer();
return base layers.push({ input: circle, blend: "dest-in" });
.composite([{ input: stone, left: inset, top: inset }]) }
.png()
.toBuffer(); // Layers apply in order, so the circle cuts the mark too.
return base.ensureAlpha().composite(layers).png().toBuffer();
}
function markPlacement({ kind }: { kind: PwaInstallIconFile["kind"] }) {
if (kind == "favicon") {
return { scale: PwaFaviconCornerMarkScale, inset_scale: PwaFaviconCornerMarkInset };
}
if (kind == "any") {
return { scale: PwaSquareCornerMarkScale, inset_scale: PwaSquareCornerMarkInset };
}
return { scale: PwaCornerMarkScale, inset_scale: PwaCornerMarkInset };
} }
@@ -11,7 +11,7 @@ type Guess = Pick<
const Transcription = /whisper|transcri|(?:^|[-_/])(stt|asr)(?=$|[-_:.])|scribe/i; const Transcription = /whisper|transcri|(?:^|[-_/])(stt|asr)(?=$|[-_:.])|scribe/i;
const Speech = /(?:^|[-_/])tts(?=$|[-_:.])|speech|voice/i; const Speech = /(?:^|[-_/])tts(?=$|[-_:.])|speech|voice/i;
const ImageOut = /dall-e|gpt-image|imagen|flux|stable-diffusion|sdxl|image-gen/i; const ImageOut = /dall-e|gpt-image|imagen|flux|stable-diffusion|sdxl|image-gen|qwen-image|kontext|seedream|image-edit/i;
const Embedding = /embed/i; const Embedding = /embed/i;
/** /**
* Chat families that read images. * Chat families that read images.
@@ -72,4 +72,19 @@ describe("modelServesHarness", () => {
}), }),
).toBe(true); ).toBe(true);
}); });
test("image editing takes and makes images", () => {
expect(
modelServesHarness({
model: { ...chat, output_modalities: ["image"] },
harness: "image_edit",
}),
).toBe(false);
expect(
modelServesHarness({
model: { ...chat, input_modalities: ["text", "image"], output_modalities: ["image"] },
harness: "image_edit",
}),
).toBe(true);
});
}); });
@@ -17,6 +17,7 @@ type Params = {
* audio: free ones often drop it) * audio: free ones often drop it)
* - speech: a text-to-speech model * - speech: a text-to-speech model
* - image: makes images * - image: makes images
* - image_edit: makes images from reference images (takes and makes them)
* - embedding: turns text into vectors (never a chat model) * - embedding: turns text into vectors (never a chat model)
*/ */
export default function modelServesHarness({ model, harness }: Params) { export default function modelServesHarness({ model, harness }: Params) {
@@ -41,6 +42,9 @@ export default function modelServesHarness({ model, harness }: Params) {
case "image": case "image":
return output.has("image"); return output.has("image");
case "image_edit":
return input.has("image") && output.has("image");
case "embedding": case "embedding":
return output.has("embeddings"); return output.has("embeddings");
} }
@@ -4,7 +4,7 @@ type Params = {
vector: number[]; vector: number[];
/** /**
* Defaults to `AppData.EmbeddingDimensions`, the width of the fixed * Defaults to `AppData.EmbeddingDimensions`, the width of the fixed
* `bot_file_vectors` table. * `knowledgebase_vectors` table.
*/ */
dimensions?: number; dimensions?: number;
}; };
+54 -5
View File
@@ -1,9 +1,12 @@
import grabHarnessSetting from "@/src/functions/backend/db/harness/grab-harness-setting";
import HarnessError from "../harness-error";
import grabHarnessAdapter from "../providers/grab-harness-adapter"; import grabHarnessAdapter from "../providers/grab-harness-adapter";
import resolveHarnessCandidates from "../resolve/resolve-harness-candidates"; import resolveHarnessCandidates from "../resolve/resolve-harness-candidates";
import withCandidates from "../resolve/with-candidates"; import withCandidates from "../resolve/with-candidates";
import type { HarnessImageOutput, HarnessUsage } from "../types"; import type { HarnessImageOutput, HarnessUsage } from "../types";
import buildUsageCall from "../usage/build-usage-call"; import buildUsageCall from "../usage/build-usage-call";
import recordHarnessUsage from "../usage/record-harness-usage"; import recordHarnessUsage from "../usage/record-harness-usage";
import orderImageCandidates from "./order-image-candidates";
type Params = { type Params = {
prompt: string; prompt: string;
@@ -25,7 +28,9 @@ type Params = {
}; };
/** /**
* Makes images with the image generation harness. Returns their bytes. * Makes images with the image generation harness, or with image editing
* when there are reference images (`orderImageCandidates` decides who
* tries). Returns their bytes.
*/ */
export default async function generateImage({ export default async function generateImage({
prompt, prompt,
@@ -36,15 +41,36 @@ export default async function generateImage({
bot_id, bot_id,
workspace_id, workspace_id,
}: Params) { }: Params) {
const candidates = await resolveHarnessCandidates({ harness: "image", bot_id, workspace_id }); const has_references = Boolean(references?.length);
const edit = has_references ? await grabEdit({ bot_id, workspace_id }) : undefined;
/**
* Image generation turned off still leaves an image editing pick to
* work with.
*/
const image = await resolveHarnessCandidates({ harness: "image", bot_id, workspace_id }).catch(
(error: unknown) => {
if (edit?.candidates.length) return [];
throw error;
},
);
const ordered = orderImageCandidates({ references: has_references, image, edit });
if (has_references && !ordered.length && image.length) {
throw new HarnessError({
message: `None of the image models here takes reference images. A super admin can pick one under Image editing in Settings, under Models.`,
code: "unavailable",
});
}
const harness_of = new Map(ordered.map(({ candidate, harness }) => [candidate.key, harness]));
const { result } = await withCandidates<{ images: HarnessImageOutput[]; usage?: HarnessUsage }>({ const { result } = await withCandidates<{ images: HarnessImageOutput[]; usage?: HarnessUsage }>({
harness: "image", harness: edit?.picked ? "image_edit" : "image",
candidates, candidates: ordered.map(({ candidate }) => candidate),
on_success: async ({ candidate, result: made, failed }): Promise<void> => on_success: async ({ candidate, result: made, failed }): Promise<void> =>
await recordHarnessUsage({ await recordHarnessUsage({
call: buildUsageCall({ call: buildUsageCall({
harness: "image", harness: harness_of.get(candidate.key) || "image",
candidate, candidate,
failed, failed,
usage: made.usage, usage: made.usage,
@@ -65,3 +91,26 @@ export default async function generateImage({
return result.images; return result.images;
} }
/**
* Image editing's setting and candidates, or undefined while it's off.
*/
async function grabEdit({
bot_id,
workspace_id,
}: {
bot_id?: number | string;
workspace_id?: number | string | null;
}) {
const setting = await grabHarnessSetting({ harness: "image_edit", bot_id, workspace_id });
if (!setting.enabled) return undefined;
return {
picked: Boolean(setting.model_id),
fallback: setting.fallback,
candidates: setting.model_id
? await resolveHarnessCandidates({ harness: "image_edit", bot_id, workspace_id })
: [],
};
}
@@ -0,0 +1,82 @@
import { AppData } from "@/src/data/app-data";
import grabPictureSource from "@/src/functions/backend/db/profile-images/grab-picture-source";
import AppError from "@/src/utils/app-error";
import mediaFilePath from "@/src/utils/media-file-path";
import type { StorageScope } from "@/src/utils/storage-access";
/**
* Image types every image model takes as a reference.
*/
const ReferenceImageTypes = ["png", "jpeg", "jpg", "webp", "gif"];
type Params = {
user_id: number | string;
/**
* The files the run reaches, so a reference is only ever a file the
* bot could already read.
*/
scopes: StorageScope[];
/**
* Files by `[file:ID]` id.
*/
file_ids?: number[];
/**
* The user's own uploads, by media id.
*/
media_ids?: number[];
};
/**
* The reference images an image call passes on, as `data:` URLs. Each is
* checked the way a picture is (`grabPictureSource`: a file in the run's
* directories or the user's own upload), then to be a raster image within
* `AppData.ImageReferenceMaxBytes`. Throws on the first one that isn't.
*/
export default async function loadReferenceImages({
user_id,
scopes,
file_ids = [],
media_ids = [],
}: Params): Promise<string[]> {
const sources: { file_id?: number; media_id?: number }[] = [
...file_ids.map((file_id) => ({ file_id })),
...media_ids.map((media_id) => ({ media_id })),
];
if (sources.length > AppData.ImageReferenceMaxCount) {
throw new AppError(`Pass at most ${AppData.ImageReferenceMaxCount} reference images.`);
}
const urls: string[] = [];
for (const source of sources) {
const media = await grabPictureSource({ user_id, scopes, ...source });
const label = source.file_id ? `[file:${source.file_id}]` : `media id ${source.media_id}`;
const type = (media?.mime_type || "").replace(/^image\//, "").toLowerCase();
if (!media || media.media_type != "image" || !ReferenceImageTypes.includes(type)) {
throw new AppError(
`${label} can't be a reference: use a PNG, JPEG, WebP or GIF image.`,
);
}
const absolute = mediaFilePath({ write_path: media.media_write_path });
const disk = absolute ? Bun.file(absolute) : null;
if (!disk || !(await disk.exists())) {
throw new AppError(`${label} is missing on the server.`);
}
if (disk.size > AppData.ImageReferenceMaxBytes) {
throw new AppError(
`${label} is too large to pass on (at most ${Math.round(AppData.ImageReferenceMaxBytes / (1024 * 1024))} MB).`,
);
}
const base64 = Buffer.from(await disk.arrayBuffer()).toString("base64");
urls.push(`data:image/${type == "jpg" ? "jpeg" : type};base64,${base64}`);
}
return urls;
}
@@ -0,0 +1,105 @@
import { describe, expect, test } from "bun:test";
import type { HarnessCandidate } from "../types";
import orderImageCandidates from "./order-image-candidates";
function candidate({ slug, takes_images }: { slug: string; takes_images: boolean }): HarnessCandidate {
return {
key: `1:${slug}`,
provider: { id: 1, kind: "openrouter", base_url: "", name: "OpenRouter" } as HarnessCandidate["provider"],
model: {
slug,
input_modalities: takes_images ? ["text", "image"] : ["text"],
output_modalities: ["image"],
supports_tools: false,
},
source: "pinned",
};
}
const flux = candidate({ slug: "flux", takes_images: true });
const recraft = candidate({ slug: "recraft", takes_images: false });
const qwen_edit = candidate({ slug: "qwen-image-edit", takes_images: true });
function slugs(list: ReturnType<typeof orderImageCandidates>) {
return list.map(({ candidate, harness }) => `${harness}:${candidate.model.slug}`);
}
describe("orderImageCandidates", () => {
test("without references, image generation's models as they are", () => {
expect(
slugs(
orderImageCandidates({
references: false,
image: [recraft, flux],
edit: { picked: true, fallback: true, candidates: [qwen_edit] },
}),
),
).toEqual(["image:recraft", "image:flux"]);
});
test("with references and no edit pick, only image models that take images", () => {
expect(
slugs(
orderImageCandidates({
references: true,
image: [recraft, flux],
edit: { picked: false, fallback: true, candidates: [] },
}),
),
).toEqual(["image:flux"]);
});
test("image editing off goes to image generation", () => {
expect(
slugs(orderImageCandidates({ references: true, image: [recraft, flux] })),
).toEqual(["image:flux"]);
});
test("the edit pick first, then image generation with fallback on", () => {
expect(
slugs(
orderImageCandidates({
references: true,
image: [flux, recraft],
edit: { picked: true, fallback: true, candidates: [qwen_edit] },
}),
),
).toEqual(["image_edit:qwen-image-edit", "image:flux"]);
});
test("fallback off keeps to the edit pick", () => {
expect(
slugs(
orderImageCandidates({
references: true,
image: [flux],
edit: { picked: true, fallback: false, candidates: [qwen_edit] },
}),
),
).toEqual(["image_edit:qwen-image-edit"]);
});
test("an edit pick that can't run falls to image generation", () => {
expect(
slugs(
orderImageCandidates({
references: true,
image: [flux],
edit: { picked: true, fallback: false, candidates: [] },
}),
),
).toEqual(["image:flux"]);
});
test("a model in both lists is tried once, as the edit pick", () => {
expect(
slugs(
orderImageCandidates({
references: true,
image: [flux],
edit: { picked: true, fallback: true, candidates: [flux] },
}),
),
).toEqual(["image_edit:flux"]);
});
});
@@ -0,0 +1,65 @@
import type { HarnessCandidate } from "../types";
export type ImageCandidate = {
candidate: HarnessCandidate;
/**
* The harness it was picked for, for its usage row.
*/
harness: "image" | "image_edit";
};
type Params = {
/**
* Whether the call passes reference images.
*/
references: boolean;
/**
* Image generation's candidates.
*/
image: HarnessCandidate[];
/**
* Image editing's setting and candidates; undefined when it's off.
*/
edit?: {
/**
* Whether a model is picked for it.
*/
picked: boolean;
fallback: boolean;
candidates: HarnessCandidate[];
};
};
/**
* The models an image call tries, in order. Without references: image
* generation's. With them: image editing's pick, then (with fallback on)
* the image generation models that take images; only those while image
* editing has no usable model or is off. A model that can't take an image
* is never tried with references, since it would drop them unseen.
*/
export default function orderImageCandidates({ references, image, edit }: Params): ImageCandidate[] {
if (!references) {
return image.map((candidate) => ({ candidate, harness: "image" }));
}
const takes_images = image
.filter((candidate) => candidate.model.input_modalities.includes("image"))
.map((candidate) => ({ candidate, harness: "image" as const }));
const edits = edit?.picked
? edit.candidates.map((candidate) => ({ candidate, harness: "image_edit" as const }))
: [];
const ordered = !edits.length
? takes_images
: [...edits, ...(edit?.fallback ? takes_images : [])];
const seen = new Set<string>();
return ordered.filter(({ candidate }) => {
if (seen.has(candidate.key)) return false;
seen.add(candidate.key);
return true;
});
}
@@ -1,4 +1,5 @@
import { AppData } from "@/src/data/app-data"; import { AppData } from "@/src/data/app-data";
import bufferFromBase64 from "@/src/utils/buffer-from-base-64";
import HarnessError from "../../harness-error"; import HarnessError from "../../harness-error";
import type { HarnessAdapter, HarnessImageOutput } from "../../types"; import type { HarnessAdapter, HarnessImageOutput } from "../../types";
import grabProviderPreset from "../grab-provider-preset"; import grabProviderPreset from "../grab-provider-preset";
@@ -6,8 +7,10 @@ import providerRequest from "./provider-request";
/** /**
* Image generation at the provider's `image_path` (`/images` on * Image generation at the provider's `image_path` (`/images` on
* OpenRouter, `/images/generations` elsewhere). Images come back as * OpenRouter, `/images/generations` elsewhere). With reference images,
* base64, or as URLs that are downloaded here. * OpenRouter takes them on the same call (`input_references`); the others
* get them as a multipart upload at `image_edit_path` (`/images/edits`).
* Images come back as base64, or as URLs that are downloaded here.
*/ */
const generateImage: HarnessAdapter["generateImage"] = async ({ const generateImage: HarnessAdapter["generateImage"] = async ({
candidate, candidate,
@@ -20,29 +23,45 @@ const generateImage: HarnessAdapter["generateImage"] = async ({
const { provider, model } = candidate; const { provider, model } = candidate;
const preset = grabProviderPreset({ kind: provider.kind }); const preset = grabProviderPreset({ kind: provider.kind });
const is_openrouter = provider.kind == "openrouter"; const is_openrouter = provider.kind == "openrouter";
const edits = Boolean(references?.length) && !is_openrouter;
const res = await providerRequest({ const res = edits
provider, ? await providerRequest({
path: preset.image_path, provider,
json: { path: preset.image_edit_path,
model: model.slug, form: await editForm({
prompt, model: model.slug,
...(n ? { n } : {}), prompt,
...(size ? { size } : {}), n,
...(is_openrouter size,
? references?.length references: references || [],
? { signal,
input_references: references.map((url) => ({ }),
type: "image_url", signal,
image_url: { url }, candidate_key: candidate.key,
})), })
} : await providerRequest({
: {} provider,
: { response_format: "b64_json" }), path: preset.image_path,
}, json: {
signal, model: model.slug,
candidate_key: candidate.key, prompt,
}); ...(n ? { n } : {}),
...(size ? { size } : {}),
...(is_openrouter
? references?.length
? {
input_references: references.map((url) => ({
type: "image_url",
image_url: { url },
})),
}
: {}
: { response_format: "b64_json" }),
},
signal,
candidate_key: candidate.key,
});
const json = await res.json().catch(() => null); const json = await res.json().catch(() => null);
const items: any[] = Array.isArray(json?.data) ? json.data : []; const items: any[] = Array.isArray(json?.data) ? json.data : [];
@@ -98,3 +117,59 @@ const generateImage: HarnessAdapter["generateImage"] = async ({
}; };
export default generateImage; export default generateImage;
type EditFormParams = {
model: string;
prompt: string;
n?: number;
size?: string;
references: string[];
signal: AbortSignal;
};
/**
* The `/images/edits` upload: the prompt and every reference image as a
* file (`image[]` when there are several, as gpt-image takes them).
*/
async function editForm({ model, prompt, n, size, references, signal }: EditFormParams) {
const form = new FormData();
form.append("model", model);
form.append("prompt", prompt);
if (n) form.append("n", String(n));
if (size) form.append("size", size);
const field = references.length > 1 ? "image[]" : "image";
for (let index = 0; index < references.length; index++) {
const { blob, ext } = await referenceBlob({ url: references[index]!, signal });
form.append(field, blob, `reference-${index + 1}.${ext}`);
}
return form;
}
/**
* A reference's bytes: decoded from a `data:` URL, downloaded otherwise.
*/
async function referenceBlob({ url, signal }: { url: string; signal: AbortSignal }) {
if (url.startsWith("data:")) {
const { buffer, ext } = await bufferFromBase64(url);
const type = url.slice(5, url.indexOf(";")) || "image/png";
return { blob: new Blob([new Uint8Array(buffer)], { type }), ext };
}
const download = await fetch(url, {
signal: AbortSignal.any([signal, AbortSignal.timeout(AppData.HarnessRequestTimeoutMs)]),
});
if (!download.ok) {
throw new HarnessError({ message: `A reference image couldn't be downloaded.` });
}
const type = download.headers.get("content-type")?.split(";")[0] || "image/png";
return { blob: new Blob([await download.arrayBuffer()], { type }), ext: type.split("/")[1] || "png" };
}

Some files were not shown because too many files have changed in this diff Show More