Add workspaces, conversation sessions, app-managed computers and pictures

- Rail: Add icon with stacked form modals (bot, workspace, computer),
  coordinator, recent bots with "See all bots", Workspaces section, and
  a workspace mode listing only its crew
- Workspaces: page for context, bots (per-workspace instructions and
  memory), computers (default, link, unlink), models and delete;
  conversations scoped per bot per workspace
- Sessions: conversations split into searchable, reopenable sessions
  with carry-over summaries; sessions and new-session header icons
- Computers: created and provisioned over the docker socket, home dir
  kept at DATA_DIR/computers/<id> after delete; super admins only
- Models: workspace settings win over a bot's own, then the defaults
- Pictures for bots and workspaces; bots editable from the header
- Coordinator tools: update_workspace, set_bot_workspace_instructions

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-19 08:39:33 +01:00
co-authored by Claude Opus 5.5
parent 56daf0c462
commit 0ce61cdb49
223 changed files with 8166 additions and 585 deletions
+17 -6
View File
@@ -10,6 +10,11 @@ type Params = {
* The coordinator can use every computer and give the crew access.
*/
is_coordinator?: boolean;
/**
* In a workspace the computers are the workspace's, and its default
* is where tasks run.
*/
in_workspace?: boolean;
};
/**
@@ -17,9 +22,11 @@ type Params = {
* one it's on, and how it reaches them (markdown). Without it a bot asked
* "can you see the computer?" says it can't.
*/
export default function composeComputersNote({ computers, is_coordinator }: Params) {
export default function composeComputersNote({ computers, is_coordinator, in_workspace }: Params) {
if (!computers.length) {
return `# Your computers\n\nYou have no computer, so you can't run commands, open files or browse. If the user asks for that, say so.`;
return in_workspace
? `# Your computers\n\nThis workspace has no computer, so you can't run commands, open files or browse here. If the user asks for that, say so: they can add a computer to the workspace and set it as its default.`
: `# Your computers\n\nYou have no computer, so you can't run commands, open files or browse. If the user asks for that, say so.`;
}
const listed = computers
@@ -38,10 +45,14 @@ export default function composeComputersNote({ computers, is_coordinator }: Para
${listed}
Each is a Linux sandbox of your own (a shell, files, code runners and a browser), set up for the crew. It isn't the user's desktop: you can't see their screen or their own files unless they're put on it. The user picks which one you're on; your tasks run there.${
is_coordinator
? ` As the coordinator you can use every computer the user has, and you decide which ones each crew member can use with assign_computer.`
: ""
Each is a Linux sandbox of your own (a shell, files, code runners and a browser), set up for the crew. It isn't the user's desktop: you can't see their screen or their own files unless they're put on it. ${
in_workspace
? `These are this workspace's computers. Its tasks run on the default one (the first, "you're on it"); you can switch the default with set_workspace_computer when the work needs another.`
: `The user picks which one you're on; your tasks run there.${
is_coordinator
? ` As the coordinator you can use every computer the user has, and you decide which ones each crew member can use with assign_computer.`
: ""
}`
}
- To find which computer holds something (a file, a running app, a logged-in session), use search_computers: it runs one read-only command on all of them at once.
+71
View File
@@ -0,0 +1,71 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOT_SESSIONS } from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import { PreviousSessionNote, SessionSummaryPrompt } from "@/src/data/harness-prompts";
import grabBotMessages from "@/src/functions/backend/db/bots/grab-bot-messages";
import generateText from "@/src/harness/text/generate-text";
type Params = {
session_id: number | string;
bot_id: number | string;
workspace_id?: number | string | null;
signal?: AbortSignal;
};
/**
* The previous session's summary for a session's prompt (markdown), or ""
* when it doesn't carry on from one. The summary is written the first
* time it's needed and kept on that session.
*/
export default async function grabSessionCarryOver({
session_id,
bot_id,
workspace_id,
signal,
}: Params) {
const previous = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_SESSIONS>({
sql: `SELECT p.* FROM bot_sessions s JOIN bot_sessions p ON p.id = s.previous_session_id
WHERE s.id = ? AND p.bot_id = s.bot_id LIMIT 1`,
values: [session_id],
})
).payload?.[0];
if (!previous?.id) return "";
if (previous.summary?.trim()) {
return `${PreviousSessionNote}\n\n${previous.summary.trim()}`;
}
const messages = await grabBotMessages({
session_id: previous.id,
limit: AppData.BotSessionSummaryMessages,
});
if (!messages.length) return "";
const transcript = messages
.map((message) => `${message.author == "user" ? "User" : "You"}: ${message.text || "(files only)"}`)
.join("\n\n");
const result = await generateText({
messages: [
{ role: "system", content: SessionSummaryPrompt },
{ role: "user", content: transcript },
],
bot_id,
workspace_id,
signal,
});
const summary = (result.text || "").trim().slice(0, AppData.BotSessionSummaryMaxChars);
if (!summary) return "";
await BunSQLite.sql({
sql: `UPDATE bot_sessions SET summary = ?, updated_at = ? WHERE id = ?`,
values: [summary, Date.now(), previous.id],
});
return `${PreviousSessionNote}\n\n${summary}`;
}
+5 -1
View File
@@ -84,7 +84,11 @@ export default async function processBotJob({
bot_id,
data: {
event: "server:bot-job-update",
data: { ...row, task_id: job.task_id || null } satisfies BotJobUpdateData,
data: {
...row,
task_id: job.task_id || null,
session_id: job.session_id || null,
} satisfies BotJobUpdateData,
},
});
} catch (error) {
+43 -10
View File
@@ -1,6 +1,11 @@
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import { ChatReplyNote, CoordinatorReplyNote, CoordinatorSoloNote } from "@/src/data/harness-prompts";
import {
ChatReplyNote,
CoordinatorReplyNote,
CoordinatorSoloNote,
WorkspaceLearningNote,
} from "@/src/data/harness-prompts";
import grabBotMessages from "@/src/functions/backend/db/bots/grab-bot-messages";
import grabBotPrompt from "@/src/functions/backend/db/bots/grab-bot-prompt";
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
@@ -8,6 +13,8 @@ import runTextLoop from "@/src/harness/text/run-text-loop";
import grabBotChatTools from "@/src/harness/tools/bot/grab-bot-chat-tools";
import botMessagesToHarness from "./bot-messages-to-harness";
import composeComputersNote from "./compose-computers-note";
import grabSessionCarryOver from "./grab-session-carry-over";
import jobSessionId from "@/src/functions/backend/db/bots-queue/job-session-id";
import loadAttachmentParts from "./load-attachment-parts";
import withTakeOverRefs from "./with-take-over-refs";
import type { BotJobContext, BotJobOutcome } from "./types";
@@ -17,11 +24,14 @@ type Params = BotJobContext & {
};
/**
* Answers the user in chat: the bot reads the recent conversation (and
* the files of the latest messages, `loadAttachmentParts`) and
* replies, knowing its computers (`composeComputersNote`), with its chat tools (memory, instructions, start_task, and for
* the coordinator delegate_task). Finishes silently when the last message
* is already the bot's, i.e. an earlier reply answered everything.
* Answers the user in chat: the bot reads the session's recent messages
* (and the files of the latest ones, `loadAttachmentParts`) and replies,
* knowing where it is (its workspace, if the session is in one), what
* happened in the session before (`grabSessionCarryOver`) and its
* computers (`composeComputersNote`), with its chat tools (memory,
* instructions, start_task, and for the coordinator delegate_task).
* Finishes silently when the last message is already the bot's, i.e. an
* earlier reply answered everything.
*/
export default async function runBotReply({
bot,
@@ -29,8 +39,11 @@ export default async function runBotReply({
signal,
report,
}: Params): Promise<BotJobOutcome> {
const session_id = await jobSessionId({ job });
const workspace_id = job.workspace_id || null;
const history = await grabBotMessages({
bot_id: bot.id,
session_id,
limit: AppData.BotReplyHistoryMessages,
});
@@ -38,8 +51,19 @@ export default async function runBotReply({
return { result: "", silent: true };
}
const prompt = await grabBotPrompt({ bot_id: bot.id, user_id: bot.user_id });
const computers = await grabBotComputers({ bot_id: bot.id, user_id: bot.user_id });
const prompt = await grabBotPrompt({ bot_id: bot.id, user_id: bot.user_id, workspace_id });
const computers = await grabBotComputers({ bot_id: bot.id, user_id: bot.user_id, workspace_id });
/**
* A summary that can't be written now (no model free) is left out
* rather than holding up the reply.
*/
const carry_over = await grabSessionCarryOver({
session_id,
bot_id: bot.id,
workspace_id,
signal,
}).catch(() => "");
const take_overs: string[] = [];
@@ -47,6 +71,8 @@ export default async function runBotReply({
bot,
user_id: bot.user_id,
computers,
session_id,
workspace_id,
message_id: job.message_id || undefined,
onTakeOver: ({ ref }) => take_overs.push(ref),
});
@@ -64,8 +90,14 @@ export default async function runBotReply({
const result = await runTextLoop({
system: [
prompt,
carry_over,
ChatReplyNote,
composeComputersNote({ computers, is_coordinator: bot.kind == "coordinator" }),
workspace_id ? WorkspaceLearningNote : "",
composeComputersNote({
computers,
is_coordinator: bot.kind == "coordinator",
in_workspace: Boolean(workspace_id),
}),
crew_note,
`Now: ${new Date().toUTCString()}.`,
]
@@ -78,6 +110,7 @@ export default async function runBotReply({
tools,
max_steps: AppData.BotReplyMaxToolSteps,
bot_id: bot.id,
workspace_id,
signal,
onStep: report,
});
+30 -8
View File
@@ -1,4 +1,4 @@
import { TaskRunNote } from "@/src/data/harness-prompts";
import { TaskRunNote, WorkspaceLearningNote } from "@/src/data/harness-prompts";
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
import grabBotPrompt from "@/src/functions/backend/db/bots/grab-bot-prompt";
import runTextLoop from "@/src/harness/text/run-text-loop";
@@ -8,6 +8,8 @@ import askToTakeOverTool from "@/src/harness/tools/computer/ask-to-take-over-too
import searchComputersTool from "@/src/harness/tools/computer/search-computers-tool";
import computerRef from "@/src/utils/computer-ref";
import composeJobBrief from "./compose-job-brief";
import grabSessionCarryOver from "./grab-session-carry-over";
import jobSessionId from "@/src/functions/backend/db/bots-queue/job-session-id";
import withTakeOverRefs from "./with-take-over-refs";
import type { BotJobContext, BotJobOutcome } from "./types";
@@ -16,7 +18,8 @@ type Params = BotJobContext & {
};
/**
* Works through a task: the bot follows its brief with its full prompt,
* Works through a task in its session's workspace (if any): the bot
* follows its brief with its full prompt,
* its own tools, search_computers across all its computers and, when the
* job has a computer, that computer's tools (over MCP), until it answers. That answer is the report for the user.
* When only the user can go on (a login, a captcha), the bot asks them to
@@ -30,13 +33,27 @@ export default async function runBotTask({
signal,
report,
}: Params): Promise<BotJobOutcome> {
const workspace_id = job.workspace_id || null;
const computer = job.computer_id
? await openComputerTools({ computer_id: job.computer_id, bot_id: bot.id, signal })
? await openComputerTools({
computer_id: job.computer_id,
bot_id: bot.id,
workspace_id,
signal,
})
: null;
try {
const prompt = await grabBotPrompt({ bot_id: bot.id, user_id: bot.user_id });
const computers = await grabBotComputers({ bot_id: bot.id, user_id: bot.user_id });
const session_id = await jobSessionId({ job });
const prompt = await grabBotPrompt({ bot_id: bot.id, user_id: bot.user_id, workspace_id });
const computers = await grabBotComputers({ bot_id: bot.id, user_id: bot.user_id, workspace_id });
const carry_over = await grabSessionCarryOver({
session_id,
bot_id: bot.id,
workspace_id,
signal,
}).catch(() => "");
const take_overs: string[] = [];
/**
@@ -50,10 +67,14 @@ export default async function runBotTask({
const result = await runTextLoop({
system: [
prompt,
carry_over,
TaskRunNote,
workspace_id ? WorkspaceLearningNote : "",
computer
? `You're working on the computer "${computer.name}" (a Linux sandbox with a shell, files, code runners and a browser). Use its tools; don't ask the user to run anything. When only the user can go on (a login, a captcha, a code sent to them), ask them to take over with ask_user_to_take_over.`
: `You have no computer for this task.`,
: workspace_id
? `You have no computer for this task: this workspace has none. If the task needs one, stop and say so in the report; the user can add a computer to the workspace.`
: `You have no computer for this task.`,
computers.length > 1
? `search_computers runs a read-only command on every computer you have. When you name one in the report, write its ref, e.g. ${computerRef({ id: computers[0]!.id || 0 })}: the user can click it to switch you to that computer.`
: "",
@@ -76,10 +97,10 @@ export default async function runBotTask({
},
],
tools: [
...grabBotTools({ user_id: bot.user_id, bot_id: bot.id }),
...grabBotTools({ user_id: bot.user_id, bot_id: bot.id, workspace_id, computers }),
...(computers.length
? [
searchComputersTool({ bot_id: bot.id, computers }),
searchComputersTool({ computers, workspace_id }),
askToTakeOverTool({
computers: take_over_computers,
in_task: true,
@@ -90,6 +111,7 @@ export default async function runBotTask({
...(computer?.tools || []),
],
bot_id: bot.id,
workspace_id,
signal,
onStep: (step) =>
report({ ...step, text: step.text && computer ? computer.redact(step.text) : step.text }),
+54
View File
@@ -0,0 +1,54 @@
import type { ComponentProps } from "react";
import Img from "@moduletrace/twui/layout/Img";
import { twMerge } from "tailwind-merge";
import profileImageUrls from "@/src/utils/profile-image-urls";
import BotStone from "./bot-stone";
/**
* Picture sizes, matching the stone sizes in use.
*/
const BotAvatarSizeClasses: Record<number, string> = {
18: "size-[18px] rounded-[4px]",
28: "size-7 rounded-default-sm",
32: "size-8 rounded-default-sm",
40: "size-10 rounded-default",
56: "size-14 rounded-default",
};
type Props = ComponentProps<typeof BotStone> & {
/**
* The bot's custom picture, shown instead of the stone.
*/
image_media_id?: number | string | null;
/**
* A picture to show instead, e.g. one just picked and not saved yet.
*/
src?: string;
};
/**
* A bot's avatar: its custom picture when it has one (a rounded square,
* never a circle, so it isn't mistaken for a person), else its stone. Its
* hue still marks it everywhere else, e.g. the unread dot.
*/
export default function BotAvatar({ image_media_id, src: picked, ...stone }: Props) {
const saved = profileImageUrls({ media_id: image_media_id });
const thumbnail_src = picked || saved.thumbnail_src;
const src = picked || saved.src;
const size = stone.size || 32;
if (!thumbnail_src) return <BotStone {...stone} />;
return (
<Img
src={thumbnail_src}
imgErrSrc={src}
alt={stone.label || ""}
className={twMerge(
"shrink-0 object-cover bg-sunken",
BotAvatarSizeClasses[size] || BotAvatarSizeClasses[32],
stone.className,
)}
/>
);
}
@@ -0,0 +1,94 @@
import { useRef, type ReactNode } from "react";
import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import imageInputToBase64 from "@moduletrace/twui/utils/form/imageInputToBase64";
type Props = {
/**
* The avatar as it looks now (or will, with the picked picture).
*/
preview: ReactNode;
has_image: boolean;
onPick: (params: { data_url: string }) => void;
onRemove: () => void;
loading?: boolean;
disabled?: boolean;
note?: string;
};
/**
* An avatar preview with Upload/Change and Remove. The picked picture comes
* back as a data URL; saving is up to the caller.
*/
export default function ImagePickField({
preview,
has_image,
onPick,
onRemove,
loading,
disabled,
note = "PNG, JPG or WebP. A square picture looks best.",
}: Props) {
const input_ref = useRef<HTMLInputElement>(null);
return (
<Row className="w-full gap-4! flex-nowrap items-center">
{preview}
<Stack className="gap-2! min-w-0">
<Row className="gap-2!">
<Button
title={has_image ? "Change picture" : "Upload picture"}
type="button"
variant="outlined"
color="gray"
size="small"
loading={loading}
disabled={disabled || loading}
onClick={() => input_ref.current?.click()}
>
{has_image ? "Change picture" : "Upload picture"}
</Button>
{has_image ? (
<Button
title="Remove the picture"
type="button"
variant="ghost"
color="gray"
size="small"
disabled={disabled || loading}
onClick={() => {
onRemove();
if (input_ref.current) input_ref.current.value = "";
}}
>
Remove
</Button>
) : null}
</Row>
<Span className="text-[13px] leading-[18px] text-ink-secondary">{note}</Span>
</Stack>
{/*
* Raw on purpose: twui `Input` writes `value` on change, which
* browsers forbid on file inputs, and `ImageUpload` is a full
* card widget. This is only the hidden picker behind the button.
*/}
<input
ref={input_ref}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
className="hidden"
tabIndex={-1}
aria-hidden
onChange={(e) => {
if (!e.target.files?.[0]) return;
imageInputToBase64({ imageInput: e.target }).then((res) => {
if (res?.imageBase64Full) onPick({ data_url: res.imageBase64Full });
});
}}
/>
</Row>
);
}
@@ -0,0 +1,54 @@
import Img from "@moduletrace/twui/layout/Img";
import Span from "@moduletrace/twui/layout/Span";
import { Layers } from "lucide-react";
import { twMerge } from "tailwind-merge";
import profileImageUrls from "@/src/utils/profile-image-urls";
const WorkspaceAvatarSizes = {
sm: { box: "size-8 rounded-default-sm", icon: 16 },
lg: { box: "size-14 rounded-default", icon: 24 },
} as const;
type Props = {
image_media_id?: number | string | null;
/**
* A picture to show instead, e.g. one just picked and not saved yet.
*/
src?: string;
size?: keyof typeof WorkspaceAvatarSizes;
className?: string;
};
/**
* A workspace's picture, or the workspace icon on a tile when it has none.
*/
export default function WorkspaceAvatar({ image_media_id, src: picked, size = "sm", className }: Props) {
const saved = profileImageUrls({ media_id: image_media_id });
const thumbnail_src = picked || saved.thumbnail_src;
const src = picked || saved.src;
const { box, icon } = WorkspaceAvatarSizes[size];
if (thumbnail_src) {
return (
<Img
src={thumbnail_src}
imgErrSrc={src}
alt=""
className={twMerge("shrink-0 object-cover bg-sunken", box, className)}
/>
);
}
return (
<Span
aria-hidden
className={twMerge(
"shrink-0 flex items-center justify-center border border-solid border-hairline bg-surface text-ink",
box,
className,
)}
>
<Layers size={icon} aria-hidden />
</Span>
);
}
+8
View File
@@ -2,6 +2,7 @@ import bunext from "@moduletrace/bunext";
import cronHeartbeat from "./heartbeat";
import pruneBotsQueue from "./prune-bots-queue";
import cronSyncHarnessModels from "./sync-harness-models";
import syncComputers from "./sync-computers";
import registerCronJob from "./register-cron-job";
export default function cron() {
@@ -30,4 +31,11 @@ export default function cron() {
task: cronSyncHarnessModels,
run_on_start: true,
});
registerCronJob({
title: "sync-computers",
schedule: "*/5 * * * *",
task: syncComputers,
run_on_start: true,
});
}
+47
View File
@@ -0,0 +1,47 @@
import bunext from "@moduletrace/bunext";
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_COMPUTERS, BUN_SQLITE_COBBLE_CONTAINERS } from "@/db/types/db";
import pushComputerUpdate from "@/src/functions/backend/db/computers/push-computer-update";
import inspectDockerContainer from "@/src/functions/backend/docker/inspect-docker-container";
type Row = Pick<BUN_SQLITE_COBBLE_CONTAINERS, "id" | "docker_id" | "status"> &
Pick<BUN_SQLITE_COBBLE_COMPUTERS, "user_id"> & { computer_id: number };
/**
* Reads every computer container's state from docker and records it
* (`containers.status`, `last_seen_at`), telling owners about changes. A
* container docker no longer has shows as `dead`. Containers still being
* set up, or that failed, are left alone.
*/
export default async function syncComputers() {
const rows =
(
await BunSQLite.sql<Row>({
sql: `SELECT co.id, co.docker_id, co.status, c.user_id, c.id AS computer_id
FROM containers co JOIN computers c ON c.container_id = co.id
WHERE co.docker_id IS NOT NULL AND co.status NOT IN ('creating', 'failed')`,
})
).payload || [];
let changed = 0;
for (const row of rows) {
const state = await inspectDockerContainer({ container: row.docker_id! }).catch(() => undefined);
if (state === undefined) continue;
const status = state?.status || "dead";
await BunSQLite.sql({
sql: `UPDATE containers SET status = ?, last_seen_at = ?, updated_at = ? WHERE id = ?`,
values: [status, Date.now(), Date.now(), row.id!],
});
if (status != row.status) {
changed++;
await pushComputerUpdate({ computer_id: row.computer_id }).catch(() => {});
}
}
bunext.bunextLog.info(`Cron: ${rows.length} computer(s) checked, ${changed} changed`);
}
+56 -3
View File
@@ -27,15 +27,40 @@ export const AppData = {
PasswordMaxLength: 128,
PasswordAttemptLimit: 10,
/**
* Docker network shared by the main container and every computer. The
* installer creates it, and the main computer's container on it.
* Docker network shared by the app and every computer. The app creates
* it when it sets up its first computer and joins it when it runs in
* docker.
*/
DockerNetworkName: SiteData["SiteSlug"],
MainComputerContainerName: `${SiteData["SiteSlug"]}-computer`,
/**
* Port the AIO sandbox listens on inside its container.
*/
ComputerPort: 8080,
/**
* Every computer the app creates runs this image, named
* `<ComputerContainerPrefix>-<computer id>` on `DockerNetworkName`.
*/
ComputerImage: "ghcr.io/agent-infra/sandbox:latest",
ComputerContainerPrefix: `${SiteData["SiteSlug"]}-computer`,
/**
* A computer's home directory (`ComputerHomeDir` in its container) is
* kept at `DATA_DIR/<ComputerDataDir>/<id>`, owned by the sandbox's
* user. It stays when the computer is deleted.
*/
ComputerDataDir: "computers",
ComputerHomeDir: "/home/gem",
ComputerUserUid: 1000,
ComputerUserGid: 1000,
/**
* Per computer container.
*/
ComputerMemoryBytes: 4 * 1024 * 1024 * 1024,
ComputerCpus: 2,
ComputerShmBytes: 2 * 1024 * 1024 * 1024,
/**
* The docker engine API, reached over its unix socket.
*/
DockerSocketPath: "/var/run/docker.sock",
/**
* Cap on a bot's `system_prompt` and `memory`, and on a workspace's
* `system_prompt`, so self-updates can't grow a prompt without bound.
@@ -128,6 +153,30 @@ export const AppData = {
* How much of the conversation a bot sees when it replies.
*/
BotReplyHistoryMessages: 40,
/**
* A session with this many messages is closed when the user writes
* again, and the message starts the next one (carrying a summary).
*/
BotSessionMaxMessages: 120,
/**
* How much of a closed session the bot reads to summarize it, and how
* long the summary may be.
*/
BotSessionSummaryMessages: 80,
BotSessionSummaryMaxChars: 4000,
/**
* Sessions listed per page in a conversation's sessions modal.
*/
BotSessionsPageSize: 30,
/**
* Worker bots the rail lists, most recently active first, before
* "See all bots".
*/
RailRecentBots: 8,
/**
* Stones shown beside "See all bots".
*/
RailSeeAllStones: 4,
/**
* Tool turns a chat reply may take. Long work goes to a task instead.
*/
@@ -144,4 +193,8 @@ export const AppData = {
* themselves to a reply; older ones are named.
*/
BotReplyAttachmentMessages: 4,
/**
* Largest picture a bot or a workspace can have.
*/
ProfileImageMaxBytes: 5 * 1024 * 1024,
} as const;
+21
View File
@@ -47,3 +47,24 @@ export const CoordinatorSoloNote = `You have no worker bots yet: you do all the
export const TaskRunNote = `# Working on a task
You are working through a task on your own; the user isn't watching. Use your tools until the task is done, then answer with the report for the user: what you did, what you found, and anything left for them. Keep the report short and in markdown. If you're blocked on something only the user can give you, stop and ask for it in the report. If they need to do it on the computer themselves (log in, a captcha, a code), call ask_user_to_take_over first.`;
/**
* Added to a bot's prompt when it works in a workspace: what it learns
* there stays there unless it applies everywhere.
*/
export const WorkspaceLearningNote = `# Learning in this workspace
update_memory and update_instructions save to this workspace by default: what you learn here applies here. Save to your core memory or instructions (scope "core") only when the user asks for it to apply in every workspace, or when it's plainly about how you work everywhere, not about this workspace. When something learned here looks useful everywhere, suggest saving it to your core and do it once the user agrees.`;
/**
* Asks the text model for a closed session's summary, carried into the
* next session's prompt.
*/
export const SessionSummaryPrompt = `Summarize this conversation for whoever picks it up next: what the user wanted, what was done, decisions made, open questions and anything still in progress. Keep names, numbers, paths and links exactly. Plain markdown bullets, no preamble.`;
/**
* Heads the previous session's summary in a bot's prompt.
*/
export const PreviousSessionNote = `# Earlier in this conversation
This session carries on from an earlier one. What happened there:`;
+1 -1
View File
@@ -3,6 +3,6 @@
* `AdminHomePath`. People, logs and backups exist in code but stay closed
* until they are added back here.
*/
export const AdminSections = ["/admin/bots", "/admin/settings"] as const;
export const AdminSections = ["/admin/bots", "/admin/workspaces", "/admin/settings"] as const;
export const AdminHomePath = "/admin/bots";
+18
View File
@@ -108,6 +108,24 @@ export const BotTaskStatuses = [
export type BotTaskStatus = (typeof BotTaskStatuses)[number]["value"];
/**
* A conversation (one bot, in its own chat or one workspace) is split into
* sessions so no single history grows without bound. One is active per
* conversation; closed ones can be browsed and reopened.
*/
export const BotSessionStatuses = [
{
title: "Active",
value: "active",
},
{
title: "Closed",
value: "closed",
},
] as const;
export type BotSessionStatus = (typeof BotSessionStatuses)[number]["value"];
export const BotTaskStepStates = [
{
title: "Done",
+12 -3
View File
@@ -1,7 +1,7 @@
/**
* A computer is an AIO sandbox running in its own docker container on the
* cobble network. The main computer is set up at install and recorded on
* signup; sandboxes are created later by the main container.
* cobble network, created by the app (`createComputer`), its home
* directory kept under `DATA_DIR/computers/<id>`.
*/
export const ComputerKinds = [
{
@@ -20,9 +20,18 @@ export const MainComputerName = "Main computer";
/**
* Docker container state as reported by `docker inspect` (`State.Status`),
* plus `unknown` for a record that hasn't been inspected yet.
* plus `creating` while the app sets it up, `failed` when that didn't
* work, and `unknown` for a record that hasn't been inspected yet.
*/
export const ContainerStatuses = [
{
title: "Creating",
value: "creating",
},
{
title: "Failed",
value: "failed",
},
{
title: "Created",
value: "created",
+8
View File
@@ -34,4 +34,12 @@ export const MediaParadigms = [
title: "Bot Attachment",
value: "bot-attachment",
},
{
title: "Bot Image",
value: "bot-image",
},
{
title: "Workspace Image",
value: "workspace-image",
},
] as const;
@@ -0,0 +1,51 @@
import type {
BUN_SQLITE_COBBLE_BOT_SESSIONS,
BunSQLiteTables,
} from "@/db/types/db";
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
bot_id: number | string;
workspace_id?: number | string | null;
/**
* The session this one carries on from.
*/
previous_session_id?: number | null;
};
/**
* Starts an active session in a conversation. Callers close the old one
* first (`startBotSession`).
*/
export default async function createBotSession({
bot_id,
workspace_id,
previous_session_id,
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_SESSIONS> {
const now = Date.now();
const row: BUN_SQLITE_COBBLE_BOT_SESSIONS = {
bot_id: Number(bot_id),
workspace_id: workspace_id ? Number(workspace_id) : undefined,
previous_session_id: previous_session_id || undefined,
status: "active",
unread: 0,
last_activity_at: now,
};
const res = await BunSQLite.insert<
BUN_SQLITE_COBBLE_BOT_SESSIONS,
(typeof BunSQLiteTables)[number]
>({
table: "bot_sessions",
data: [row],
});
const id = res.singleRes?.id || res.postInsertReturn?.insertId;
if (!res.success || !id) {
throw new Error(`Couldn't start a session: ${res.msg}`);
}
return { ...row, id, created_at: now, updated_at: now };
}
@@ -0,0 +1,62 @@
import type { BUN_SQLITE_COBBLE_BOT_SESSIONS } from "@/db/types/db";
import BunSQLite from "@moduletrace/bun-sqlite";
import { AppData } from "@/src/data/app-data";
import createBotSession from "./create-bot-session";
import grabActiveBotSession from "./grab-active-bot-session";
import startBotSession from "./start-bot-session";
type Params = {
bot_id: number | string;
workspace_id?: number | string | null;
/**
* Start the next session when the active one is full
* (`AppData.BotSessionMaxMessages`). Set when the user writes, so a
* message never lands in a session that's about to be cut.
*/
rollover?: boolean;
};
/**
* The conversation's active session, started first when there is none
* (carrying on from its latest closed one) or, with `rollover`, when the
* active one is full.
*/
export default async function ensureBotSession({
bot_id,
workspace_id,
rollover,
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_SESSIONS> {
const active = await grabActiveBotSession({ bot_id, workspace_id });
if (active?.id) {
if (!rollover) return active;
const count =
Number(
(
await BunSQLite.sql<{ n: number }>({
sql: `SELECT COUNT(*) AS n FROM bot_messages WHERE session_id = ?`,
values: [active.id],
})
).payload?.[0]?.n,
) || 0;
return count < AppData.BotSessionMaxMessages
? active
: await startBotSession({ bot_id, workspace_id });
}
const latest = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_SESSIONS>({
sql: `SELECT id FROM bot_sessions WHERE bot_id = ? AND workspace_id IS ?
ORDER BY last_activity_at DESC, id DESC LIMIT 1`,
values: [bot_id, workspace_id ? Number(workspace_id) : null],
})
).payload?.[0];
return await createBotSession({
bot_id,
workspace_id,
previous_session_id: latest?.id || null,
});
}
@@ -0,0 +1,28 @@
import type { BUN_SQLITE_COBBLE_BOT_SESSIONS } from "@/db/types/db";
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
bot_id: number | string;
/**
* Null or undefined: the bot's own chat.
*/
workspace_id?: number | string | null;
};
/**
* The active session of one conversation (a bot in its own chat or in one
* workspace), or null when it has none yet.
*/
export default async function grabActiveBotSession({
bot_id,
workspace_id,
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_SESSIONS | null> {
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_SESSIONS>({
sql: `SELECT * FROM bot_sessions
WHERE bot_id = ? AND workspace_id IS ? AND status = 'active'
ORDER BY id DESC LIMIT 1`,
values: [bot_id, workspace_id ? Number(workspace_id) : null],
});
return res.payload?.[0] || null;
}
@@ -0,0 +1,59 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import { AppData } from "@/src/data/app-data";
import type { BotSessionListItem } from "@/src/types";
type Params = {
bot_id: number | string;
workspace_id?: number | string | null;
/**
* Matches the session's title, summary or any of its messages.
*/
query?: string;
page?: number;
};
/**
* A conversation's sessions for the sessions modal, latest activity first,
* each with its message count and latest message. Empty sessions are left
* out unless active.
*/
export default async function grabBotSessions({
bot_id,
workspace_id,
query,
page = 1,
}: Params): Promise<BotSessionListItem[]> {
const search = query?.trim();
const like = search ? `%${search.replace(/[\\%_]/g, (c) => `\\${c}`)}%` : null;
const limit = AppData.BotSessionsPageSize;
let sql = ``;
sql += `SELECT s.*,\n`;
sql += ` (SELECT COUNT(*) FROM bot_messages m WHERE m.session_id = s.id) AS message_count,\n`;
sql += ` (SELECT m.text FROM bot_messages m WHERE m.session_id = s.id ORDER BY m.created_at DESC, m.id DESC LIMIT 1) AS last_message_text\n`;
sql += `FROM bot_sessions s\n`;
sql += `WHERE s.bot_id = ? AND s.workspace_id IS ?\n`;
sql += `AND (s.status = 'active' OR EXISTS (SELECT 1 FROM bot_messages m WHERE m.session_id = s.id))\n`;
if (like) {
sql += `AND (s.title LIKE ? ESCAPE '\\' OR s.summary LIKE ? ESCAPE '\\'\n`;
sql += ` OR EXISTS (SELECT 1 FROM bot_messages m WHERE m.session_id = s.id AND m.text LIKE ? ESCAPE '\\'))\n`;
}
sql += `ORDER BY s.status = 'active' DESC, s.last_activity_at DESC, s.id DESC\n`;
sql += `LIMIT ? OFFSET ?\n`;
const res = await BunSQLite.sql<BotSessionListItem>({
sql,
values: [
bot_id,
workspace_id ? Number(workspace_id) : null,
...(like ? [like, like, like] : []),
limit,
(Math.max(1, page) - 1) * limit,
],
});
return res.payload || [];
}
@@ -0,0 +1,89 @@
import type { PagePropsType } from "@/src/types";
import grabBots from "../bots/grab-bots";
import grabActiveBotTask from "../bots/grab-active-bot-task";
import grabBotMessages from "../bots/grab-bot-messages";
import grabBotComputers from "../computers/grab-bot-computers";
import grabBotHarnessOverview from "../harness/grab-bot-harness-overview";
import grabHarnessOverview from "../harness/grab-harness-overview";
import grabUserWorkspaces from "../workspaces/grab-user-workspaces";
import ensureBotSession from "./ensure-bot-session";
import grabUserConversation from "./grab-user-conversation";
import markBotSessionRead from "./mark-bot-session-read";
type Params = {
user_id: number;
bot_id: number | string;
/**
* The workspace the conversation is in; none for the bot's own chat.
*/
workspace_id?: number | string | null;
};
/**
* The page props of a conversation (`/admin/bots/<id>` or
* `/admin/workspaces/<id>/bots/<id>`): the rail's crew (the workspace's
* in a workspace) and workspaces, the bot, its active session (marked
* read) and its messages, its models, computers and active task there.
* Null when the bot isn't the user's or isn't in the workspace.
*/
export default async function grabConversationProps({
user_id,
bot_id,
workspace_id,
}: Params): Promise<PagePropsType | null> {
const conversation = await grabUserConversation({ user_id, bot_id, workspace_id }).catch(
() => null,
);
if (!conversation) return null;
const scope = conversation.workspace_id;
const session = await ensureBotSession({ bot_id: conversation.bot.id, workspace_id: scope });
if (session.unread) {
await markBotSessionRead({ session_id: session.id!, user_id });
session.unread = 0;
}
const [bots, workspaces] = await Promise.all([
grabBots({ user_id, workspace_id: scope }),
grabUserWorkspaces({ user_id }),
]);
const bot = bots.find((b) => b.id == conversation.bot.id);
if (!bot?.id) return null;
bot.unread = 0;
/**
* The header's models modal: a worker's own settings, or for the
* coordinator the defaults it runs on (without the providers). In a
* workspace the workspace's own models come first, so what runs now
* shows its pick.
*/
const [bot_messages, bot_harness_overview, bot_computers, bot_task] = await Promise.all([
grabBotMessages({ session_id: session.id! }),
(bot.kind == "coordinator" && !scope
? grabHarnessOverview().then(({ harnesses }) => ({ harnesses }))
: grabBotHarnessOverview({
bot_id: bot.kind == "coordinator" ? undefined : bot.id,
workspace_id: scope || undefined,
})
).catch(() => null),
grabBotComputers({ bot_id: bot.id, user_id, workspace_id: scope }),
grabActiveBotTask({ bot_id: bot.id, session_id: session.id }),
]);
return {
bots,
bot,
workspaces,
workspace: conversation.workspace,
bot_session: session,
bot_messages,
bot_harness_overview,
bot_computers,
bot_task,
};
}
@@ -0,0 +1,23 @@
import type { BUN_SQLITE_COBBLE_BOT_SESSIONS } from "@/db/types/db";
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
session_id: number | string;
user_id: number | string;
};
/**
* A session only if its bot belongs to the given owner.
*/
export default async function grabUserBotSession({
session_id,
user_id,
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_SESSIONS | null> {
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_SESSIONS>({
sql: `SELECT s.* FROM bot_sessions s JOIN bots b ON b.id = s.bot_id
WHERE s.id = ? AND b.user_id = ? LIMIT 1`,
values: [session_id, user_id],
});
return res.payload?.[0] || null;
}
@@ -0,0 +1,52 @@
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import AppError from "@/src/utils/app-error";
import grabUserBot from "../bots/grab-user-bot";
import grabWorkspaceBot from "../workspaces/grab-workspace-bot";
type Params = {
user_id: number | string;
bot_id: number | string;
/**
* Null or undefined: the bot's own chat.
*/
workspace_id?: number | string | null;
};
export type UserConversation = {
bot: BUN_SQLITE_COBBLE_BOTS & { id: number };
workspace: BUN_SQLITE_COBBLE_WORKSPACES | null;
workspace_id: number | null;
};
/**
* One of the user's conversations: their bot, in its own chat or in one of
* their workspaces it works in. Throws when the bot isn't theirs or isn't
* in that workspace.
*/
export default async function grabUserConversation({
user_id,
bot_id,
workspace_id,
}: Params): Promise<UserConversation> {
if (workspace_id) {
const found = await grabWorkspaceBot({ workspace_id, bot_id, user_id });
if (!found?.bot.id || !found.workspace.id) {
throw new AppError(`That bot isn't in this workspace.`);
}
return {
bot: { ...found.bot, id: found.bot.id },
workspace: found.workspace,
workspace_id: found.workspace.id,
};
}
const bot = await grabUserBot({ bot_id, user_id });
if (!bot?.id) {
throw new AppError(`Bot not found`);
}
return { bot: { ...bot, id: bot.id }, workspace: null, workspace_id: null };
}
@@ -0,0 +1,17 @@
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
session_id: number | string;
user_id: number | string;
};
/**
* Clears a session's unread flag when its owner has the conversation open.
*/
export default async function markBotSessionRead({ session_id, user_id }: Params) {
await BunSQLite.sql({
sql: `UPDATE bot_sessions SET unread = 0
WHERE id = ? AND unread = 1 AND bot_id IN (SELECT id FROM bots WHERE user_id = ?)`,
values: [session_id, user_id],
});
}
@@ -0,0 +1,49 @@
import type { BUN_SQLITE_COBBLE_BOT_SESSIONS } from "@/db/types/db";
import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import grabUserBotSession from "./grab-user-bot-session";
type Params = {
session_id: number | string;
user_id: number | string;
};
/**
* Makes a closed session of one of the user's conversations the active
* one again, so the bot carries on from where it left off. The session
* that was active is closed (an empty one is deleted).
*/
export default async function reopenBotSession({
session_id,
user_id,
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_SESSIONS> {
const session = await grabUserBotSession({ session_id, user_id });
if (!session?.id) {
throw new AppError(`Session not found.`);
}
if (session.status == "active") return session;
const now = Date.now();
const workspace_id = session.workspace_id || null;
await BunSQLite.sql({
sql: `DELETE FROM bot_sessions WHERE bot_id = ? AND workspace_id IS ? AND status = 'active'
AND NOT EXISTS (SELECT 1 FROM bot_messages m WHERE m.session_id = bot_sessions.id)`,
values: [session.bot_id!, workspace_id],
});
await BunSQLite.sql({
sql: `UPDATE bot_sessions SET status = 'closed', unread = 0, updated_at = ?
WHERE bot_id = ? AND workspace_id IS ? AND status = 'active'`,
values: [now, session.bot_id!, workspace_id],
});
await BunSQLite.sql({
sql: `UPDATE bot_sessions SET status = 'active', updated_at = ? WHERE id = ?`,
values: [now, session.id],
});
return { ...session, status: "active", updated_at: now };
}
@@ -0,0 +1,43 @@
import type { BUN_SQLITE_COBBLE_BOT_SESSIONS } from "@/db/types/db";
import BunSQLite from "@moduletrace/bun-sqlite";
import createBotSession from "./create-bot-session";
import grabActiveBotSession from "./grab-active-bot-session";
type Params = {
bot_id: number | string;
workspace_id?: number | string | null;
};
/**
* Closes the conversation's active session and starts the next one, which
* carries on from it (its summary is written when the next reply runs).
* An active session with no messages yet is kept as it is.
*/
export default async function startBotSession({
bot_id,
workspace_id,
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_SESSIONS> {
const active = await grabActiveBotSession({ bot_id, workspace_id });
if (active?.id) {
const has_messages = (
await BunSQLite.sql({
sql: `SELECT id FROM bot_messages WHERE session_id = ? LIMIT 1`,
values: [active.id],
})
).payload?.[0];
if (!has_messages) return active;
await BunSQLite.sql({
sql: `UPDATE bot_sessions SET status = 'closed', unread = 0, updated_at = ? WHERE id = ?`,
values: [Date.now(), active.id],
});
}
return await createBotSession({
bot_id,
workspace_id,
previous_session_id: active?.id || null,
});
}
@@ -5,6 +5,7 @@ import setBotFace from "../bots/set-bot-face";
import countOpenBotJobs from "./count-open-bot-jobs";
import grabLockedBotJob from "./grab-locked-bot-job";
import relayJobToRequester from "./relay-job-to-requester";
import jobSessionId from "./job-session-id";
type Params = {
job_id: number | string;
@@ -71,6 +72,8 @@ export default async function completeBotJob({
? null
: await insertBotMessage({
bot_id: job.bot_id,
session_id: await jobSessionId({ job }),
workspace_id: job.workspace_id || null,
text: result,
needs_you,
});
@@ -8,6 +8,7 @@ import type { BotJobKind } from "@/src/dict/bot-queue-dict";
import AppError from "@/src/utils/app-error";
import grabUserBot from "../bots/grab-user-bot";
import grabBotComputers from "../computers/grab-bot-computers";
import grabUserBotSession from "../bot-sessions/grab-user-bot-session";
type Params = {
/**
@@ -17,6 +18,11 @@ type Params = {
kind?: BotJobKind;
user_id: number | string;
bot_id: number | string;
/**
* The bot's session the result is posted in. The job runs in its
* workspace (context, computers, models), if it has one.
*/
session_id: number | string;
title: string;
/**
* The brief the bot writes for itself (markdown): what to do and how
@@ -34,7 +40,8 @@ type Params = {
*/
task_id?: number | string;
/**
* Must be one of the bot's computers (direct or through a workspace).
* Must be one of the bot's computers where the session is
* (`grabBotComputers`).
*/
computer_id?: number | string;
/**
@@ -46,6 +53,10 @@ type Params = {
* result or failure in its own conversation (`relayJobToRequester`).
*/
requested_by_bot_id?: number | string;
/**
* The requesting bot's session; its report goes to that conversation.
*/
requested_by_session_id?: number | string;
};
/**
@@ -58,6 +69,7 @@ export default async function enqueueBotJob({
kind = "task",
user_id,
bot_id,
session_id,
title,
instructions,
params,
@@ -66,6 +78,7 @@ export default async function enqueueBotJob({
computer_id,
delay,
requested_by_bot_id,
requested_by_session_id,
}: Params) {
const bot = await grabUserBot({ bot_id, user_id });
@@ -73,10 +86,26 @@ export default async function enqueueBotJob({
throw new AppError(`Bot not found.`);
}
const session = await grabUserBotSession({ session_id, user_id });
if (!session?.id || session.bot_id != bot.id) {
throw new AppError(`Session not found.`);
}
const workspace_id = session.workspace_id || null;
if (requested_by_bot_id && !(await grabUserBot({ bot_id: requested_by_bot_id, user_id }))?.id) {
throw new AppError(`Bot not found.`);
}
if (requested_by_session_id) {
const requested_from = await grabUserBotSession({ session_id: requested_by_session_id, user_id });
if (!requested_from?.id || requested_from.bot_id != Number(requested_by_bot_id)) {
throw new AppError(`Session not found.`);
}
}
if (message_id && !(await belongsToBot({ table: "bot_messages", id: message_id, bot_id: bot.id }))) {
throw new AppError(`Message not found.`);
}
@@ -86,7 +115,7 @@ export default async function enqueueBotJob({
}
if (computer_id) {
const computers = await grabBotComputers({ bot_id: bot.id, user_id });
const computers = await grabBotComputers({ bot_id: bot.id, user_id, workspace_id });
if (!computers.some((c) => String(c.id) == String(computer_id))) {
throw new AppError(`This bot has no access to that computer.`);
@@ -96,7 +125,8 @@ export default async function enqueueBotJob({
const final_task_id =
kind == "reply"
? undefined
: task_id || (await createJobTask({ bot_id: bot.id, title }));
: task_id ||
(await createJobTask({ bot_id: bot.id, session_id: session.id, workspace_id, title }));
const res = await BunSQLite.queue.add<
BUN_SQLITE_COBBLE_BOTS_QUEUE,
@@ -107,6 +137,8 @@ export default async function enqueueBotJob({
{
kind,
bot_id: bot.id,
session_id: session.id,
workspace_id: workspace_id || undefined,
title,
instructions,
params: params ? JSON.stringify(params) : undefined,
@@ -114,6 +146,9 @@ export default async function enqueueBotJob({
task_id: final_task_id ? Number(final_task_id) : undefined,
computer_id: computer_id ? Number(computer_id) : undefined,
requested_by_bot_id: requested_by_bot_id ? Number(requested_by_bot_id) : undefined,
requested_by_session_id: requested_by_session_id
? Number(requested_by_session_id)
: undefined,
},
],
delay,
@@ -147,9 +182,13 @@ async function belongsToBot({
async function createJobTask({
bot_id,
session_id,
workspace_id,
title,
}: {
bot_id: number;
session_id: number;
workspace_id: number | null;
title: string;
}) {
const res = await BunSQLite.insert<
@@ -157,7 +196,7 @@ async function createJobTask({
(typeof BunSQLiteTables)[number]
>({
table: "bot_tasks",
data: [{ bot_id, title, status: "active" }],
data: [{ bot_id, session_id, workspace_id: workspace_id || undefined, title, status: "active" }],
});
const task_id = res.singleRes?.id || res.postInsertReturn?.insertId;
@@ -6,6 +6,10 @@ import { BotReplyJobTitle } from "@/src/dict/bot-queue-dict";
type Params = {
user_id: number | string;
bot_id: number | string;
/**
* The session the user wrote in.
*/
session_id: number | string;
/**
* The user message to answer.
*/
@@ -13,21 +17,23 @@ type Params = {
};
/**
* Queues the bot's chat reply to the user's latest message(s). A reply
* reads the whole recent conversation when it runs, so a burst of
* messages shares one reply: while one is still pending, it is reused.
* Queues the bot's chat reply to the user's latest message(s) in a
* session. A reply reads the session's recent messages when it runs, so a
* burst of messages shares one reply: while one is still pending in the
* session, it is reused.
*/
export default async function enqueueBotReply({
user_id,
bot_id,
session_id,
message_id,
}: Params) {
const pending = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS_QUEUE>({
sql: `SELECT id FROM bots_queue
WHERE bot_id = ? AND kind = 'reply' AND status = 'pending'
WHERE bot_id = ? AND session_id = ? AND kind = 'reply' AND status = 'pending'
ORDER BY id DESC LIMIT 1`,
values: [bot_id],
values: [bot_id, session_id],
})
).payload?.[0];
@@ -44,6 +50,7 @@ export default async function enqueueBotReply({
kind: "reply",
user_id,
bot_id,
session_id,
title: BotReplyJobTitle,
instructions: "Reply to the user's latest messages.",
message_id,
@@ -0,0 +1,21 @@
import type { BUN_SQLITE_COBBLE_BOTS_QUEUE } from "@/db/types/db";
import ensureBotSession from "../bot-sessions/ensure-bot-session";
type Params = {
job: Pick<BUN_SQLITE_COBBLE_BOTS_QUEUE, "bot_id" | "session_id" | "workspace_id">;
};
/**
* The session a job's result goes in: its own, or (for a job queued
* without one) the active session of its bot's conversation there.
*/
export default async function jobSessionId({ job }: Params) {
if (job.session_id) return Number(job.session_id);
const session = await ensureBotSession({
bot_id: job.bot_id!,
workspace_id: job.workspace_id || null,
});
return Number(session.id);
}
@@ -3,6 +3,7 @@ import type { BUN_SQLITE_COBBLE_BOTS_QUEUE } from "@/db/types/db";
import insertBotMessage from "../bots/insert-bot-message";
import setBotFace from "../bots/set-bot-face";
import relayJobToRequester from "./relay-job-to-requester";
import jobSessionId from "./job-session-id";
type Params = {
job: BUN_SQLITE_COBBLE_BOTS_QUEUE;
@@ -30,6 +31,8 @@ export default async function notifyBotJobFailed({
const message = await insertBotMessage({
bot_id: job.bot_id,
session_id: await jobSessionId({ job }),
workspace_id: job.workspace_id || null,
text:
failure_message ||
[
@@ -1,6 +1,11 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_BOTS_QUEUE } from "@/db/types/db";
import type {
BUN_SQLITE_COBBLE_BOT_SESSIONS,
BUN_SQLITE_COBBLE_BOTS,
BUN_SQLITE_COBBLE_BOTS_QUEUE,
} from "@/db/types/db";
import insertBotMessage from "../bots/insert-bot-message";
import ensureBotSession from "../bot-sessions/ensure-bot-session";
type Params = {
job: BUN_SQLITE_COBBLE_BOTS_QUEUE;
@@ -13,9 +18,11 @@ type Params = {
/**
* Reports a handed-over job back to the bot that handed it over
* (`requested_by_bot_id`, the coordinator): a message in its conversation
* with the worker's report, flagged needs-you when the job failed. Returns
* the message for the live push, or null when nobody handed the job over.
* (`requested_by_bot_id`, the coordinator): a message in the conversation
* it was handed over from (the active session there, which may have moved
* on since), with the worker's report, flagged needs-you when the job
* failed. Returns the message for the live push, or null when nobody
* handed the job over.
*/
export default async function relayJobToRequester({ job, text, failed }: Params) {
if (!job.requested_by_bot_id || !job.bot_id || job.requested_by_bot_id == job.bot_id) {
@@ -38,11 +45,25 @@ export default async function relayJobToRequester({ job, text, failed }: Params)
if (!requester?.id) return null;
const requested_from = job.requested_by_session_id
? (
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_SESSIONS>({
sql: `SELECT workspace_id FROM bot_sessions WHERE id = ? AND bot_id = ? LIMIT 1`,
values: [job.requested_by_session_id, requester.id],
})
).payload?.[0]
: null;
const workspace_id = requested_from ? requested_from.workspace_id || null : job.workspace_id || null;
const session = await ensureBotSession({ bot_id: requester.id, workspace_id });
const name = worker?.name || "A crew member";
const title = job.title || "the job";
const message = await insertBotMessage({
bot_id: requester.id,
session_id: Number(session.id),
workspace_id,
text: failed
? `${name} couldn't finish **${title}**. ${text}`.trim()
: `${name} finished **${title}**. Their report:\n\n${text}`,
@@ -0,0 +1,51 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS, BunSQLiteTables } from "@/db/types/db";
import type { BotHue } from "@/src/dict/bot-dict";
import assertPromptLength from "@/src/utils/assert-prompt-length";
type Params = {
user_id: number | string;
name: string;
role?: string;
hue: BotHue;
/**
* Its core instructions (markdown).
*/
system_prompt: string;
};
/**
* Adds a worker bot to the user's crew and returns its id. It has no
* computer of its own until it's given one; in a workspace it uses the
* workspace's.
*/
export default async function createBot({ user_id, name, role, hue, system_prompt }: Params) {
assertPromptLength({ label: "Instructions", text: system_prompt });
const res = await BunSQLite.insert<
BUN_SQLITE_COBBLE_BOTS,
(typeof BunSQLiteTables)[number]
>({
table: "bots",
data: [
{
user_id: Number(user_id),
name,
kind: "worker",
hue,
role: role || undefined,
face: "idle",
status_line: "",
system_prompt,
},
],
});
const bot_id = res.singleRes?.id || res.postInsertReturn?.insertId;
if (!res.success || !bot_id) {
throw new Error(`Couldn't add the bot: ${res.msg}`);
}
return Number(bot_id);
}
@@ -55,8 +55,6 @@ export default async function ensureCoordinatorBot({ user_id }: Params) {
face: "idle",
status_line: "",
system_prompt: CoordinatorSystemPrompt,
unread: 0,
last_activity_at: Date.now(),
},
],
});
@@ -8,18 +8,25 @@ import grabBotJobUpdates from "../bots-queue/grab-bot-job-updates";
type Params = {
bot_id: number | string;
/**
* Only a task started from this session.
*/
session_id?: number | string | null;
};
/**
* The bot's most recent active task, its steps and what its job has done
* so far, for the working-on panel.
* The bot's most recent active task (in one session, when given), its
* steps and what its job has done so far, for the working-on panel.
*/
export default async function grabActiveBotTask({
bot_id,
session_id,
}: Params): Promise<BotTaskWithSteps | null> {
const task_res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_TASKS>({
sql: `SELECT * FROM bot_tasks WHERE bot_id = ? AND status = 'active' ORDER BY created_at DESC, id DESC LIMIT 1`,
values: [bot_id],
sql: `SELECT * FROM bot_tasks WHERE bot_id = ? AND status = 'active'
${session_id ? `AND session_id = ?` : ""}
ORDER BY created_at DESC, id DESC LIMIT 1`,
values: session_id ? [bot_id, session_id] : [bot_id],
});
const task = task_res.singleRes || task_res.payload?.[0];
@@ -5,30 +5,29 @@ import grabMessagesMedia from "./grab-messages-media";
import mediaToAttachment from "./media-to-attachment";
type Params = {
bot_id: number | string;
session_id: number | string;
limit?: number;
};
/**
* The latest messages of a bot's conversation, oldest first, with their
* attachments.
* The latest messages of a session, oldest first, with their attachments.
*/
export default async function grabBotMessages({
bot_id,
session_id,
limit = 100,
}: Params): Promise<BotMessageWithAttachments[]> {
let sql = ``;
sql += `SELECT * FROM (\n`;
sql += ` SELECT * FROM bot_messages\n`;
sql += ` WHERE bot_id = ?\n`;
sql += ` WHERE session_id = ?\n`;
sql += ` ORDER BY created_at DESC, id DESC\n`;
sql += ` LIMIT ?\n`;
sql += `) ORDER BY created_at ASC, id ASC\n`;
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_MESSAGES>({
sql,
values: [bot_id, limit],
values: [session_id, limit],
});
const messages = res.payload || [];
@@ -1,39 +1,42 @@
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import BunSQLite from "@moduletrace/bun-sqlite";
import composeBotPrompt from "@/src/utils/compose-bot-prompt";
import AppError from "@/src/utils/app-error";
import grabWorkspaceBot from "../workspaces/grab-workspace-bot";
import grabUserBot from "./grab-user-bot";
type Params = {
bot_id: number | string;
user_id: number | string;
/**
* The workspace the bot is working in. Its context and the bot's
* instructions and memory there join the prompt.
*/
workspace_id?: number | string | null;
};
/**
* The full prompt for one of the user's bots: its core instructions, the
* system prompt of every non-archived workspace it's in, and its memory.
* The full prompt for one of the user's bots (`composeBotPrompt`): its
* core instructions and memory, and in a workspace that workspace's
* context and what the bot has for it.
*/
export default async function grabBotPrompt({ bot_id, user_id }: Params) {
export default async function grabBotPrompt({ bot_id, user_id, workspace_id }: Params) {
const bot = await grabUserBot({ bot_id, user_id });
if (!bot?.id) {
throw new AppError(`Bot not found.`);
}
const workspaces =
(
await BunSQLite.sql<BUN_SQLITE_COBBLE_WORKSPACES>({
sql: `SELECT w.name, w.system_prompt FROM workspaces w
JOIN workspace_bots wb ON wb.workspace_id = w.id
WHERE wb.bot_id = ? AND w.user_id = ? AND w.archived = 0
ORDER BY w.id ASC`,
values: [bot.id, user_id],
})
).payload || [];
const found = workspace_id
? await grabWorkspaceBot({ workspace_id, bot_id: bot.id, user_id })
: null;
if (workspace_id && !found) {
throw new AppError(`${bot.name || "This bot"} isn't in that workspace.`);
}
return composeBotPrompt({
system_prompt: bot.system_prompt,
memory: bot.memory,
workspaces,
workspace: found?.workspace,
workspace_bot: found?.membership,
});
}
+25 -4
View File
@@ -3,36 +3,57 @@ import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
user_id: number | string;
/**
* A workspace's crew instead: the coordinator and the bots in it, with
* their conversations in that workspace.
*/
workspace_id?: number | string | null;
};
/**
* A user's crew for the rail: the coordinator pinned first, then
* workers by latest activity. Each row carries its latest message.
* workers by latest activity. Each row carries its conversation's unread
* flag, latest activity and latest message: in its own chat, or in the
* workspace when one is given.
*/
export default async function grabBots({
user_id,
workspace_id,
}: Params): Promise<BotWithLastMessage[]> {
const scope = workspace_id ? Number(workspace_id) : null;
let sql = ``;
sql += `SELECT\n`;
sql += ` b.*,\n`;
sql += ` COALESCE(t.unread, 0) AS unread,\n`;
sql += ` t.last_activity_at AS last_activity_at,\n`;
sql += ` lm.text AS last_message_text,\n`;
sql += ` lm.created_at AS last_message_at\n`;
sql += `FROM bots b\n`;
sql += `LEFT JOIN (\n`;
sql += ` SELECT bot_id, MAX(unread) AS unread, MAX(last_activity_at) AS last_activity_at\n`;
sql += ` FROM bot_sessions WHERE workspace_id IS ? GROUP BY bot_id\n`;
sql += `) t ON t.bot_id = b.id\n`;
sql += `LEFT JOIN bot_messages lm ON lm.id = (\n`;
sql += ` SELECT id FROM bot_messages\n`;
sql += ` WHERE bot_id = b.id\n`;
sql += ` WHERE bot_id = b.id AND workspace_id IS ?\n`;
sql += ` ORDER BY created_at DESC, id DESC\n`;
sql += ` LIMIT 1\n`;
sql += `)\n`;
sql += `WHERE b.user_id = ?\n`;
if (scope) {
sql += `AND (b.kind = 'coordinator' OR b.id IN (SELECT bot_id FROM workspace_bots WHERE workspace_id = ?))\n`;
}
sql += `ORDER BY\n`;
sql += ` CASE WHEN b.kind = 'coordinator' THEN 0 ELSE 1 END,\n`;
sql += ` COALESCE(lm.created_at, b.last_activity_at, b.created_at) DESC\n`;
sql += ` COALESCE(lm.created_at, t.last_activity_at, b.created_at) DESC\n`;
const res = await BunSQLite.sql<BotWithLastMessage>({
sql,
values: [user_id],
values: [scope, scope, user_id, ...(scope ? [scope] : [])],
});
return res.payload || [];
@@ -6,16 +6,23 @@ import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
bot_id: number;
/**
* The session it goes in, and that session's workspace.
*/
session_id: number;
workspace_id?: number | null;
text: string;
needs_you?: boolean;
};
/**
* Appends a bot message to its conversation, marks the bot unread and
* bumps its activity. Callers must check the bot first.
* Appends a bot message to a session, marks the session unread and bumps
* its activity. Callers must check the bot and session first.
*/
export default async function insertBotMessage({
bot_id,
session_id,
workspace_id,
text,
needs_you,
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_MESSAGES> {
@@ -27,7 +34,16 @@ export default async function insertBotMessage({
(typeof BunSQLiteTables)[number]
>({
table: "bot_messages",
data: [{ bot_id, author: "bot", text, needs_you: needs_you_flag }],
data: [
{
bot_id,
session_id,
workspace_id: workspace_id || undefined,
author: "bot",
text,
needs_you: needs_you_flag,
},
],
});
const id = res.postInsertReturn?.insertId;
@@ -37,13 +53,15 @@ export default async function insertBotMessage({
}
await BunSQLite.sql({
sql: `UPDATE bots SET last_activity_at = ?, unread = 1 WHERE id = ?`,
values: [now, bot_id],
sql: `UPDATE bot_sessions SET last_activity_at = ?, unread = 1 WHERE id = ?`,
values: [now, session_id],
});
return {
id,
bot_id,
session_id,
workspace_id: workspace_id || undefined,
author: "bot",
text,
needs_you: needs_you_flag,
@@ -9,6 +9,11 @@ import mediaToAttachment from "./media-to-attachment";
type Params = {
bot_id: number;
/**
* The session it goes in, and that session's workspace.
*/
session_id: number;
workspace_id?: number | null;
text: string;
/**
* Files to attach, already checked to be the user's own
@@ -18,12 +23,14 @@ type Params = {
};
/**
* Appends a user message (and its files) to a bot's conversation and
* bumps its activity. Callers must check the bot belongs to the session
* user first.
* Appends a user message (and its files) to a session and bumps its
* activity. The first one titles the session. Callers must check the bot
* and session belong to the session user first.
*/
export default async function insertUserMessage({
bot_id,
session_id,
workspace_id,
text,
media = [],
}: Params): Promise<BotMessageWithAttachments> {
@@ -34,7 +41,16 @@ export default async function insertUserMessage({
(typeof BunSQLiteTables)[number]
>({
table: "bot_messages",
data: [{ bot_id, author: "user", text, needs_you: 0 }],
data: [
{
bot_id,
session_id,
workspace_id: workspace_id || undefined,
author: "user",
text,
needs_you: 0,
},
],
});
const id = res.postInsertReturn?.insertId;
@@ -50,14 +66,20 @@ export default async function insertUserMessage({
});
}
const title = (text || media[0]?.title || "").replace(/\s+/g, " ").trim().slice(0, 80);
await BunSQLite.sql({
sql: `UPDATE bots SET last_activity_at = ? WHERE id = ?`,
values: [now, bot_id],
sql: `UPDATE bot_sessions SET last_activity_at = ?,
title = CASE WHEN COALESCE(title, '') = '' THEN ? ELSE title END
WHERE id = ?`,
values: [now, title, session_id],
});
return {
id,
bot_id,
session_id,
workspace_id: workspace_id || undefined,
author: "user",
text,
needs_you: 0,
@@ -1,16 +0,0 @@
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
bot_id: number | string;
user_id: number | string;
};
/**
* Clears a bot's unread flag when its owner opens the conversation.
*/
export default async function markBotRead({ bot_id, user_id }: Params) {
await BunSQLite.sql({
sql: `UPDATE bots SET unread = 0 WHERE id = ? AND user_id = ? AND unread = 1`,
values: [bot_id, user_id],
});
}
@@ -0,0 +1,43 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BotHue } from "@/src/dict/bot-dict";
import AppError from "@/src/utils/app-error";
import assertPromptLength from "@/src/utils/assert-prompt-length";
import grabUserBot from "./grab-user-bot";
type Params = {
user_id: number | string;
bot_id: number | string;
name: string;
role?: string;
hue: BotHue;
system_prompt: string;
};
/**
* Saves one of the user's bots: name, job, hue and core instructions. The
* coordinator keeps its name and stays hue-less, so only its instructions
* change. Returns the saved bot.
*/
export default async function updateBot({ user_id, bot_id, name, role, hue, system_prompt }: Params) {
assertPromptLength({ label: "Instructions", text: system_prompt });
const bot = await grabUserBot({ bot_id, user_id });
if (!bot?.id) {
throw new AppError(`Bot not found.`);
}
if (bot.kind == "coordinator") {
await BunSQLite.sql({
sql: `UPDATE bots SET system_prompt = ?, updated_at = ? WHERE id = ?`,
values: [system_prompt, Date.now(), bot.id],
});
} else {
await BunSQLite.sql({
sql: `UPDATE bots SET name = ?, role = ?, hue = ?, system_prompt = ?, updated_at = ? WHERE id = ?`,
values: [name, role || null, hue, system_prompt, Date.now(), bot.id],
});
}
return (await grabUserBot({ bot_id, user_id }))!;
}
@@ -0,0 +1,96 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type {
BUN_SQLITE_COBBLE_COMPUTERS,
BUN_SQLITE_COBBLE_CONTAINERS,
BunSQLiteTables,
} from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import type { ComputerKind } from "@/src/dict/computer-dict";
import grabEnv from "../envs/grab-env";
import provisionComputer from "./provision-computer";
type Params = {
user_id: number | string;
name: string;
description?: string;
/**
* `main` only for the owner's one main computer (`ensureMainComputer`).
*/
kind?: ComputerKind;
};
/**
* Records a new computer for the user and its container
* (`<AppData.ComputerContainerPrefix>-<id>`), then sets the container up
* in the background (`provisionComputer`). Returns the computer at once,
* still `creating`, and the setup to await when the caller must (a CLI
* script). The main computer of a server that reaches it at
* `MAIN_COMPUTER_URL` (a dev server) gets no container of its own.
*/
export default async function createComputer({
user_id,
name,
description,
kind = "sandbox",
}: Params) {
const external = kind == "main" && Boolean((await grabEnv({ key: "MAIN_COMPUTER_URL" }))?.trim());
const container_res = await BunSQLite.insert<
BUN_SQLITE_COBBLE_CONTAINERS,
(typeof BunSQLiteTables)[number]
>({
table: "containers",
data: [
{
name: `${AppData.ComputerContainerPrefix}-pending-${Date.now()}`,
purpose: "computer",
network: AppData.DockerNetworkName,
port: AppData.ComputerPort,
image: AppData.ComputerImage,
status: external ? "unknown" : "creating",
},
],
});
const container_id = container_res.singleRes?.id || container_res.postInsertReturn?.insertId;
if (!container_res.success || !container_id) {
throw new Error(`Couldn't record the computer's container: ${container_res.msg}`);
}
const computer_res = await BunSQLite.insert<
BUN_SQLITE_COBBLE_COMPUTERS,
(typeof BunSQLiteTables)[number]
>({
table: "computers",
data: [
{
user_id: Number(user_id),
container_id: Number(container_id),
name,
description: description || undefined,
kind,
},
],
});
const computer_id = computer_res.singleRes?.id || computer_res.postInsertReturn?.insertId;
if (!computer_res.success || !computer_id) {
await BunSQLite.sql({ sql: `DELETE FROM containers WHERE id = ?`, values: [container_id] });
throw new Error(`Couldn't create the computer: ${computer_res.msg}`);
}
const container_name = `${AppData.ComputerContainerPrefix}-${computer_id}`;
await BunSQLite.sql({
sql: `UPDATE containers SET name = ?, hostname = ? WHERE id = ?`,
values: [container_name, container_name, container_id],
});
const provisioning = external
? Promise.resolve()
: provisionComputer({ computer_id: Number(computer_id), fresh: true });
return { computer_id: Number(computer_id), provisioning };
}
@@ -0,0 +1,57 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_CONTAINERS } from "@/db/types/db";
import removeDockerContainer from "@/src/functions/backend/docker/remove-docker-container";
import AppError from "@/src/utils/app-error";
import grabUserComputer from "./grab-user-computer";
type Params = {
user_id: number | string;
computer_id: number | string;
};
/**
* Deletes one of the user's computers: removes its container and its
* records (its access grants and workspace links go with them). Its home
* directory is kept, so what it left stays on disk. Workspaces it was the
* default of fall back to their oldest computer still linked. The main
* computer can't be deleted.
*/
export default async function deleteComputer({ user_id, computer_id }: Params) {
const computer = await grabUserComputer({ computer_id, user_id });
if (!computer?.id) {
throw new AppError(`Computer not found.`);
}
if (computer.kind == "main") {
throw new AppError(`The main computer can't be deleted.`);
}
const container = computer.container_id
? (
await BunSQLite.sql<BUN_SQLITE_COBBLE_CONTAINERS>({
sql: `SELECT * FROM containers WHERE id = ? LIMIT 1`,
values: [computer.container_id],
})
).payload?.[0]
: null;
if (container?.docker_id || container?.name) {
await removeDockerContainer({ container: container.docker_id || container.name! });
}
await BunSQLite.sql({ sql: `DELETE FROM computers WHERE id = ?`, values: [computer.id] });
if (container?.id) {
await BunSQLite.sql({ sql: `DELETE FROM containers WHERE id = ?`, values: [container.id] });
}
await BunSQLite.sql({
sql: `UPDATE workspaces SET updated_at = ?, computer_id = (
SELECT wc.computer_id FROM workspace_computers wc WHERE wc.workspace_id = workspaces.id ORDER BY wc.id ASC LIMIT 1
) WHERE computer_id = ?`,
values: [Date.now(), computer.id],
});
return computer;
}
@@ -1,60 +0,0 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type {
BUN_SQLITE_COBBLE_CONTAINERS,
BunSQLiteTables,
} from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
/**
* Returns the id of the main computer's container record, creating it if
* missing. The container itself is created at install, named
* `AppData.MainComputerContainerName` on the `AppData.DockerNetworkName`
* network; this only records it. Safe to call on every visit.
*/
export default async function ensureMainComputerContainer() {
const name = AppData.MainComputerContainerName;
const existing = (
await BunSQLite.select<
BUN_SQLITE_COBBLE_CONTAINERS,
(typeof BunSQLiteTables)[number]
>({
table: "containers",
query: {
query: {
name: { value: name },
},
limit: 1,
},
})
).payload?.[0];
if (existing?.id) {
return existing.id;
}
const res = await BunSQLite.insert<
BUN_SQLITE_COBBLE_CONTAINERS,
(typeof BunSQLiteTables)[number]
>({
table: "containers",
data: [
{
name,
purpose: "computer",
network: AppData.DockerNetworkName,
hostname: name,
port: AppData.ComputerPort,
status: "unknown",
},
],
});
const container_id = res.singleRes?.id || res.postInsertReturn?.insertId;
if (!res.success || !container_id) {
throw new Error(`Couldn't record the main computer's container: ${res.msg}`);
}
return container_id;
}
@@ -1,10 +1,13 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type {
BUN_SQLITE_COBBLE_COMPUTERS,
BUN_SQLITE_COBBLE_CONTAINERS,
BunSQLiteTables,
} from "@/db/types/db";
import { MainComputerName } from "@/src/dict/computer-dict";
import ensureMainComputerContainer from "./ensure-main-computer-container";
import grabEnv from "../envs/grab-env";
import createComputer from "./create-computer";
import provisionComputer from "./provision-computer";
type Params = {
user_id: number | string;
@@ -13,30 +16,26 @@ type Params = {
* access to the main computer.
*/
coordinator_id: number | string;
/**
* Wait for a container being set up (a CLI script, which would
* otherwise exit first).
*/
wait?: boolean;
};
/**
* Returns the id of the user's main computer, recording it (and its
* container) first if missing, and makes sure the coordinator has access
* to it. Safe to call on every visit.
* Returns the id of the user's main computer, creating it first if
* missing (`createComputer`, which sets its container up in the
* background), and makes sure the coordinator has access to it. A main
* computer recorded before the app managed containers (no docker id, no
* `MAIN_COMPUTER_URL`) gets its container set up now. Safe to call on
* every visit.
*/
export default async function ensureMainComputer({
user_id,
coordinator_id,
wait,
}: Params) {
const computer_id = await grabOrCreateMainComputer({ user_id });
await BunSQLite.sql({
sql: `INSERT OR IGNORE INTO bot_computers (bot_id, computer_id, created_at, updated_at) VALUES (?, ?, ?, ?)`,
values: [coordinator_id, computer_id, Date.now(), Date.now()],
});
return computer_id;
}
async function grabOrCreateMainComputer({
user_id,
}: Pick<Params, "user_id">) {
const existing = (
await BunSQLite.select<
BUN_SQLITE_COBBLE_COMPUTERS,
@@ -54,32 +53,39 @@ async function grabOrCreateMainComputer({
})
).payload?.[0];
if (existing?.id) {
return existing.id;
let computer_id = existing?.id ? Number(existing.id) : 0;
let provisioning: Promise<void> = Promise.resolve();
if (!computer_id) {
const created = await createComputer({ user_id, name: MainComputerName, kind: "main" });
computer_id = created.computer_id;
provisioning = created.provisioning;
} else if (await needsContainer({ container_id: existing?.container_id })) {
provisioning = provisionComputer({ computer_id });
}
const container_id = await ensureMainComputerContainer();
const res = await BunSQLite.insert<
BUN_SQLITE_COBBLE_COMPUTERS,
(typeof BunSQLiteTables)[number]
>({
table: "computers",
data: [
{
user_id: Number(user_id),
container_id: Number(container_id),
name: MainComputerName,
kind: "main",
},
],
await BunSQLite.sql({
sql: `INSERT OR IGNORE INTO bot_computers (bot_id, computer_id, created_at, updated_at) VALUES (?, ?, ?, ?)`,
values: [coordinator_id, computer_id, Date.now(), Date.now()],
});
const computer_id = res.singleRes?.id || res.postInsertReturn?.insertId;
if (!res.success || !computer_id) {
throw new Error(`Couldn't create the main computer: ${res.msg}`);
}
if (wait) await provisioning;
return computer_id;
}
async function needsContainer({ container_id }: { container_id?: number | "" }) {
if ((await grabEnv({ key: "MAIN_COMPUTER_URL" }))?.trim()) return false;
const container = container_id
? (
await BunSQLite.sql<BUN_SQLITE_COBBLE_CONTAINERS>({
sql: `SELECT docker_id, status FROM containers WHERE id = ? LIMIT 1`,
values: [container_id],
})
).payload?.[0]
: null;
return Boolean(container && !container.docker_id && container.status == "unknown");
}
@@ -4,36 +4,53 @@ import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
bot_id: number | string;
user_id: number | string;
/**
* The workspace the bot is working in: then its computers are the
* workspace's, the default first, and nothing else.
*/
workspace_id?: number | string | null;
};
/**
* Every computer a bot can use: the ones it was given directly
* (`bot_computers`) plus the ones assigned to any workspace it's in
* (`workspace_computers`); the coordinator can use all of them. Only the
* owner's computers, each once. The
* one it works on now (`bots.computer_id`) comes first, else its main one,
* so `computers[0]` is where its tasks run by default.
* Every computer a bot can use where it's working. In a workspace, the
* workspace's computers (none linked: none at all), its default first. In
* its own chat, the ones it was given directly (`bot_computers`); the
* coordinator can use all of the owner's. There the one it works on now
* (`bots.computer_id`) comes first, else its main one, so `computers[0]`
* is where its tasks run by default.
*/
export default async function grabBotComputers({
bot_id,
user_id,
workspace_id,
}: Params): Promise<BUN_SQLITE_COBBLE_COMPUTERS[]> {
if (workspace_id) {
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_COMPUTERS>({
sql: `SELECT c.* FROM computers c
JOIN workspace_computers wc ON wc.computer_id = c.id AND wc.workspace_id = ?
JOIN workspaces w ON w.id = wc.workspace_id AND w.user_id = c.user_id AND w.archived = 0
WHERE c.user_id = ?
AND (
(SELECT kind FROM bots WHERE id = ? AND user_id = c.user_id) = 'coordinator'
OR ? IN (SELECT bot_id FROM workspace_bots WHERE workspace_id = w.id)
)
ORDER BY c.id = w.computer_id DESC, wc.id ASC`,
values: [workspace_id, user_id, bot_id, Number(bot_id)],
});
return res.payload || [];
}
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_COMPUTERS>({
sql: `SELECT c.* FROM computers c
WHERE c.user_id = ?
AND (
(SELECT kind FROM bots WHERE id = ? AND user_id = c.user_id) = 'coordinator'
OR c.id IN (SELECT computer_id FROM bot_computers WHERE bot_id = ?)
OR c.id IN (
SELECT wc.computer_id FROM workspace_computers wc
JOIN workspace_bots wb ON wb.workspace_id = wc.workspace_id
JOIN workspaces w ON w.id = wc.workspace_id
WHERE wb.bot_id = ? AND w.archived = 0
)
)
ORDER BY c.id = (SELECT computer_id FROM bots WHERE id = ?) DESC,
c.kind = 'main' DESC, c.id ASC`,
values: [user_id, bot_id, bot_id, bot_id, bot_id],
values: [user_id, bot_id, bot_id, bot_id],
});
return res.payload || [];
@@ -0,0 +1,17 @@
import path from "path";
import { AppData } from "@/src/data/app-data";
import grabDirNames from "@/src/utils/grab-dir-names";
type Params = {
computer_id: number | string;
};
/**
* Where a computer's home directory is kept, as this app sees it:
* `DATA_DIR/<AppData.ComputerDataDir>/<id>`.
*/
export default function grabComputerDataDir({ computer_id }: Params) {
const { DATA_DIR } = grabDirNames();
return path.join(DATA_DIR, AppData.ComputerDataDir, String(computer_id));
}
@@ -0,0 +1,44 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import parseJobJson from "@/src/bots-queue/parse-job-json";
import type { ComputerWithStatus } from "@/src/types";
type Params = {
/**
* Every computer of this owner, main first.
*/
user_id?: number | string;
/**
* Or just this one.
*/
computer_id?: number | string;
};
/**
* Computers with their container's state, and why setting one up failed.
*/
export default async function grabUserComputers({
user_id,
computer_id,
}: Params): Promise<ComputerWithStatus[]> {
if (!user_id && !computer_id) return [];
const rows =
(
await BunSQLite.sql<ComputerWithStatus & { metadata?: string | null }>({
sql: `SELECT c.*, co.status AS status, co.metadata AS metadata FROM computers c
LEFT JOIN containers co ON co.id = c.container_id
WHERE ${computer_id ? `c.id = ?` : `c.user_id = ?`}
ORDER BY c.kind = 'main' DESC, c.id ASC`,
values: [computer_id || user_id!],
})
).payload || [];
return rows.map(({ metadata, ...computer }) => {
const error = parseJobJson({ text: metadata })?.error;
return {
...computer,
error: computer.status == "failed" && typeof error == "string" ? error : null,
};
});
}
@@ -0,0 +1,40 @@
import fs from "fs/promises";
import { AppData } from "@/src/data/app-data";
import grabComputerDataDir from "./grab-computer-data-dir";
type Params = {
computer_id: number | string;
/**
* A new computer: files already at its path (left by a deleted
* computer with the same id, e.g. after a restore) are moved aside to
* `<id>-<timestamp>` and kept, so it starts empty.
*/
fresh?: boolean;
};
/**
* Creates a computer's home directory, owned by the sandbox's user so it
* can write there, and returns its path. Docker would otherwise create a
* missing mount source owned by root.
*/
export default async function prepareComputerDataDir({ computer_id, fresh }: Params) {
const dir = grabComputerDataDir({ computer_id });
if (fresh) {
const existing = await fs.readdir(dir).catch(() => null);
if (existing?.length) {
await fs.rename(dir, `${dir}-${Date.now()}`);
}
}
await fs.mkdir(dir, { recursive: true });
/**
* Only root can hand a directory to another user; a dev server
* running as that user already owns it.
*/
await fs.chown(dir, AppData.ComputerUserUid, AppData.ComputerUserGid).catch(() => {});
return dir;
}
@@ -0,0 +1,81 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_CONTAINERS } from "@/db/types/db";
import parseJobJson from "@/src/bots-queue/parse-job-json";
import { AppData } from "@/src/data/app-data";
import createComputerContainer from "@/src/functions/backend/docker/create-computer-container";
import { logError } from "@/src/functions/backend/logger";
import prepareComputerDataDir from "./prepare-computer-data-dir";
import pushComputerUpdate from "./push-computer-update";
type Params = {
computer_id: number;
/**
* A brand new computer: start from an empty home directory.
*/
fresh?: boolean;
};
/**
* Sets up a computer's container: prepares its home directory, pulls the
* sandbox image and starts the container, then records it (`containers`:
* docker id, state, and where to reach it outside docker). A failure is
* recorded as `failed` with its reason, which the computer lists show. The
* owner's tabs hear about it either way. Never throws.
*/
export default async function provisionComputer({ computer_id, fresh }: Params) {
const row = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_CONTAINERS>({
sql: `SELECT co.* FROM containers co JOIN computers c ON c.container_id = co.id WHERE c.id = ? LIMIT 1`,
values: [computer_id],
})
).payload?.[0];
if (!row?.id || !row.name) return;
const metadata = parseJobJson({ text: row.metadata }) || {};
try {
await BunSQLite.sql({
sql: `UPDATE containers SET status = 'creating', updated_at = ? WHERE id = ?`,
values: [Date.now(), row.id],
});
const data_dir = await prepareComputerDataDir({ computer_id, fresh });
const created = await createComputerContainer({ computer_id, name: row.name, data_dir });
const { error: _error, mcp_url: _mcp_url, ...kept } = metadata;
await BunSQLite.sql({
sql: `UPDATE containers SET docker_id = ?, image = ?, network = ?, hostname = ?, port = ?,
status = ?, metadata = ?, last_seen_at = ?, updated_at = ? WHERE id = ?`,
values: [
created.docker_id,
AppData.ComputerImage,
created.network,
row.name,
AppData.ComputerPort,
created.status,
JSON.stringify({
...kept,
data_dir,
host_dir: created.host_dir,
...(created.mcp_url ? { mcp_url: created.mcp_url } : {}),
}),
Date.now(),
Date.now(),
row.id,
],
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await BunSQLite.sql({
sql: `UPDATE containers SET status = 'failed', metadata = ?, updated_at = ? WHERE id = ?`,
values: [JSON.stringify({ ...metadata, error: message }), Date.now(), row.id],
}).catch(() => {});
await logError({ title: `Computer #${computer_id} couldn't be set up`, error }).catch(() => {});
}
await pushComputerUpdate({ computer_id }).catch(() => {});
}
@@ -0,0 +1,21 @@
import { sendToUser } from "@/src/websocket/(functions)/send-to-user";
import grabUserComputers from "./grab-user-computers";
type Params = {
computer_id: number | string;
};
/**
* Pushes a computer's current state (`server:computer-updated`) to its
* owner's open tabs. Best effort.
*/
export default async function pushComputerUpdate({ computer_id }: Params) {
const computer = (await grabUserComputers({ computer_id }))[0];
if (!computer?.user_id) return;
sendToUser({
user_id: computer.user_id,
data: { event: "server:computer-updated", data: computer },
});
}
@@ -10,8 +10,10 @@ type Params = {
};
/**
* Switches the computer a bot works on (`bots.computer_id`) to one of its
* own (`grabBotComputers`). Returns its computers, the new one first.
* Switches the computer a bot works on in its own chat
* (`bots.computer_id`) to one of its own (`grabBotComputers`). In a
* workspace the default computer is switched instead
* (`setWorkspaceComputer`). Returns its computers, the new one first.
*/
export default async function setBotComputer({ bot_id, user_id, computer_id }: Params) {
const bot = await grabUserBot({ bot_id, user_id });
@@ -5,32 +5,31 @@ import resolveScopedEnvs from "./resolve-scoped-envs";
type Params = {
computer_id: number | string;
/**
* The bot using the computer. Only workspaces it shares with the
* computer apply; without a bot, every workspace the computer is in.
* The workspace the run is in. Its envs apply when the computer is
* linked to it (and it isn't archived); outside a workspace only the
* computer's own and general envs do.
*/
bot_id?: number | string;
workspace_id?: number | string | null;
};
/**
* The envs a run on this computer gets (`resolveScopedEnvs`). Workspaces
* are non-archived ones, oldest first, so the oldest wins a tie.
* The envs a run on this computer gets (`resolveScopedEnvs`).
*/
export default async function grabComputerEnvs({ computer_id, bot_id }: Params) {
const workspaces =
(
await BunSQLite.sql<{ id: number }>({
sql: `SELECT w.id FROM workspaces w
export default async function grabComputerEnvs({ computer_id, workspace_id }: Params) {
const linked = workspace_id
? (
await BunSQLite.sql<{ id: number }>({
sql: `SELECT w.id FROM workspaces w
JOIN workspace_computers wc ON wc.workspace_id = w.id AND wc.computer_id = ?
WHERE w.archived = 0
${bot_id ? `AND w.id IN (SELECT workspace_id FROM workspace_bots WHERE bot_id = ?)` : ""}
ORDER BY w.id ASC`,
values: bot_id ? [computer_id, bot_id] : [computer_id],
})
).payload || [];
WHERE w.id = ? AND w.archived = 0 LIMIT 1`,
values: [computer_id, workspace_id],
})
).payload?.[0]
: null;
return resolveScopedEnvs({
envs: await grabAllEnvs(),
computer_id: Number(computer_id),
workspace_ids: workspaces.map((workspace) => Number(workspace.id)),
workspace_ids: linked?.id ? [Number(linked.id)] : [],
});
}
@@ -7,7 +7,7 @@ type Params = {
};
/**
* Removes a provider and its models. Harnesses (default and per bot) that
* Removes a provider and its models. Harnesses (default, per bot and per workspace) that
* picked one of its models, or were limited to it, go back to automatic
* on any provider.
*/
@@ -18,7 +18,7 @@ export default async function deleteHarnessProvider({ provider_id }: Params) {
throw new AppError(`Provider not found.`);
}
for (const table of ["harness_settings", "bot_harness_settings"]) {
for (const table of ["harness_settings", "bot_harness_settings", "workspace_harness_settings"]) {
await BunSQLite.sql({
sql: `UPDATE ${table} SET model_id = NULL, provider_id = NULL, updated_at = ?
WHERE provider_id = ? OR model_id IN (SELECT id FROM harness_models WHERE provider_id = ?)`,
@@ -8,23 +8,44 @@ type Params = {
/**
* A worker bot the caller has checked belongs to the session user.
*/
bot_id: number;
bot_id?: number;
/**
* A workspace the caller has checked belongs to the session user. Then
* the settings shown are the workspace's own, over what it inherits:
* the bot's (when given) or the defaults.
*/
workspace_id?: number;
};
/**
* A worker bot's model settings page: per harness, the bot's own setting
* (or the default it inherits), the models it can pick, what it runs on
* now, and what the default runs on.
* A worker bot's (or a workspace's) model settings: per harness, its own
* setting (or what it inherits), the models it can pick, what it runs on
* now, and what it would run on without its own setting.
*/
export default async function grabBotHarnessOverview({
bot_id,
workspace_id,
}: Params): Promise<BotHarnessOverviewPayload> {
/**
* In a workspace, "the default" is what a run there gets without the
* workspace's own setting: the bot's, else the defaults.
*/
const inherited_bot_id = workspace_id ? bot_id : undefined;
const [own, defaults] = await Promise.all([
Promise.all(
Harnesses.map((harness) => grabHarnessSetting({ harness: harness.value, bot_id })),
Harnesses.map((harness) =>
grabHarnessSetting({
harness: harness.value,
bot_id: workspace_id ? undefined : bot_id,
workspace_id,
}),
),
),
Promise.all(
Harnesses.map((harness) => grabHarnessSetting({ harness: harness.value })),
Harnesses.map((harness) =>
grabHarnessSetting({ harness: harness.value, bot_id: inherited_bot_id }),
),
),
]);
@@ -38,11 +59,18 @@ export default async function grabBotHarnessOverview({
harnesses: await Promise.all(
own.map(async (setting, index) => {
const [item, default_item] = await Promise.all([
grabHarnessOverviewItem({ harness: setting.harness, setting, entries, bot_id }),
grabHarnessOverviewItem({
harness: setting.harness,
setting,
entries,
bot_id,
workspace_id,
}),
grabHarnessOverviewItem({
harness: setting.harness,
setting: defaults[index]!,
entries,
bot_id: inherited_bot_id,
}),
]);
@@ -14,6 +14,7 @@ type Params = {
* Resolve what runs now for this bot instead of the default.
*/
bot_id?: number | string;
workspace_id?: number | string;
};
/**
@@ -25,10 +26,11 @@ export default async function grabHarnessOverviewItem({
setting,
entries,
bot_id,
workspace_id,
}: Params): Promise<HarnessOverview> {
const meta = Harnesses.find((h) => h.value == harness)!;
const candidates = setting.enabled
? await resolveHarnessCandidates({ harness, bot_id }).catch(() => [])
? await resolveHarnessCandidates({ harness, bot_id, workspace_id }).catch(() => [])
: [];
const first = candidates[0];
const serving = entries.filter(({ model }) => modelServesHarness({ model, harness }));
@@ -2,6 +2,7 @@ import BunSQLite from "@moduletrace/bun-sqlite";
import type {
BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS,
BUN_SQLITE_COBBLE_HARNESS_SETTINGS,
BUN_SQLITE_COBBLE_WORKSPACE_HARNESS_SETTINGS,
} from "@/db/types/db";
import type { HarnessKind } from "@/src/dict/harness-dict";
import parseJobJson from "@/src/bots-queue/parse-job-json";
@@ -22,18 +23,24 @@ export type HarnessSetting = {
enabled: boolean;
options: Record<string, unknown>;
/**
* `bot` when a worker bot overrides the default for this harness.
* `workspace` when the call's workspace sets this harness, `bot` when a
* worker bot overrides the default for it.
*/
source: "default" | "bot";
source: "default" | "bot" | "workspace";
};
type Params = {
harness: HarnessKind;
/**
* The bot the call is for. A worker bot's own setting wins; the
* coordinator always uses the default.
* The bot the call is for. A worker bot's own setting wins over the
* default; the coordinator always uses the default.
*/
bot_id?: number | string;
/**
* The workspace the call is in. Its setting wins over the bot's, for
* the coordinator too.
*/
workspace_id?: number | string | null;
};
type SettingRow = Pick<
@@ -42,13 +49,23 @@ type SettingRow = Pick<
>;
/**
* A harness's setting for a call. With no row it is automatic on the
* default provider (OpenRouter), on, with fallback. Text is always on.
* A harness's setting for a call: the workspace's, else the bot's, else
* the default. With no row it is automatic on the default provider
* (OpenRouter), on, with fallback. Text is always on.
*/
export default async function grabHarnessSetting({
harness,
bot_id,
workspace_id,
}: Params): Promise<HarnessSetting> {
const workspace_row = workspace_id
? await grabWorkspaceOverride({ harness, workspace_id })
: undefined;
if (workspace_row) {
return toSetting({ harness, row: workspace_row, source: "workspace" });
}
const bot_row = bot_id
? await grabBotOverride({ harness, bot_id })
: undefined;
@@ -74,6 +91,21 @@ export default async function grabHarnessSetting({
return toSetting({ harness, row, source: "default" });
}
async function grabWorkspaceOverride({
harness,
workspace_id,
}: {
harness: HarnessKind;
workspace_id: number | string;
}) {
return (
await BunSQLite.sql<BUN_SQLITE_COBBLE_WORKSPACE_HARNESS_SETTINGS>({
sql: `SELECT * FROM workspace_harness_settings WHERE workspace_id = ? AND harness = ? LIMIT 1`,
values: [workspace_id, harness],
})
).payload?.[0];
}
async function grabBotOverride({
harness,
bot_id,
@@ -0,0 +1,87 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { HarnessKind } from "@/src/dict/harness-dict";
import AppError from "@/src/utils/app-error";
import assertHarnessModel from "./assert-harness-model";
import assertHarnessProvider from "./assert-harness-provider";
type Params = {
/**
* A workspace the caller has checked belongs to the session user.
*/
workspace_id: number;
harness: HarnessKind;
/**
* Drop the workspace's setting: each bot uses its own or the default
* again.
*/
inherit: boolean;
model_id: number | null;
/**
* Limits the harness to one provider; null: any.
*/
provider_id?: number | null;
fallback: boolean;
enabled: boolean;
options?: Record<string, unknown>;
};
/**
* Saves (or, with `inherit`, removes) a workspace's setting for one
* harness, which wins over its bots' own. Same rules as the default: the
* model must be available and fit the job, and text can't be turned off.
*/
export default async function saveWorkspaceHarnessSetting({
workspace_id,
harness,
inherit,
model_id,
provider_id = null,
fallback,
enabled,
options,
}: Params) {
if (inherit) {
await BunSQLite.sql({
sql: `DELETE FROM workspace_harness_settings WHERE workspace_id = ? AND harness = ?`,
values: [workspace_id, harness],
});
return;
}
if (harness == "text" && !enabled) {
throw new AppError(`The text model can't be turned off.`);
}
if (provider_id) {
await assertHarnessProvider({ provider_id });
}
if (model_id) {
await assertHarnessModel({ harness, model_id, provider_id });
}
const now = Date.now();
const res = await BunSQLite.sql({
sql: `INSERT INTO workspace_harness_settings (created_at, updated_at, workspace_id, harness, model_id, provider_id, fallback, enabled, options)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(workspace_id, harness) DO UPDATE SET updated_at = excluded.updated_at,
model_id = excluded.model_id, provider_id = excluded.provider_id, fallback = excluded.fallback,
enabled = excluded.enabled, options = excluded.options`,
values: [
now,
now,
workspace_id,
harness,
model_id,
provider_id,
fallback ? 1 : 0,
enabled ? 1 : 0,
JSON.stringify(options || {}),
],
});
if (!res.success) {
throw new Error(`Couldn't save the workspace's model setting: ${res.error}`);
}
}
@@ -0,0 +1,83 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import { AppData } from "@/src/data/app-data";
import grabUserBot from "@/src/functions/backend/db/bots/grab-user-bot";
import deleteMedia from "@/src/functions/backend/db/media/delete-media";
import uploadAndRecordMedia from "@/src/functions/backend/db/users/media/upload-and-record-media";
import grabUserWorkspace from "@/src/functions/backend/db/workspaces/grab-user-workspace";
import type { ProfileImageTarget } from "@/src/schemas/profile-image-schema";
import AppError from "@/src/utils/app-error";
import bufferFromBase64 from "@/src/utils/buffer-from-base-64";
type Params = {
user_id: number | string;
target: ProfileImageTarget;
id: number | string;
/**
* The new picture as a base64 data URL; null removes it.
*/
data_url: string | null;
};
const Targets = {
bot: { table: "bots", paradigm: "bot-image" },
workspace: { table: "workspaces", paradigm: "workspace-image" },
} as const;
/**
* Sets (or removes) the picture of one of the user's bots or workspaces:
* a private image, the old one deleted. Returns the new `image_media_id`.
*/
export default async function setProfileImage({ user_id, target, id, data_url }: Params) {
const record =
target == "bot"
? await grabUserBot({ bot_id: id, user_id })
: await grabUserWorkspace({ workspace_id: id, user_id });
if (!record?.id) {
throw new AppError(target == "bot" ? `Bot not found.` : `Workspace not found.`);
}
const { table, paradigm } = Targets[target];
let image_media_id: number | null = null;
if (data_url) {
if (!/^data:image\/(png|jpe?g|webp|gif)/i.test(data_url)) {
throw new AppError(`Pick a PNG, JPG, WebP or GIF image.`);
}
const { buffer, ext } = await bufferFromBase64(data_url);
if (buffer.length > AppData.ProfileImageMaxBytes) {
throw new AppError(
`That picture is too big. Pictures can be up to ${Math.round(AppData.ProfileImageMaxBytes / 1024 / 1024)} MB.`,
);
}
const res = await uploadAndRecordMedia({
data: buffer,
media_type: "image",
media_mime_type: ext,
media_paradigms: [paradigm],
media: { title: `${record.name || target} picture` },
is_private: true,
user: { id: Number(user_id) },
});
if (!res.singleRes?.id) {
throw new AppError(`Couldn't save the picture. Try another image.`);
}
image_media_id = Number(res.singleRes.id);
}
await BunSQLite.sql({
sql: `UPDATE ${table} SET image_media_id = ?, updated_at = ? WHERE id = ?`,
values: [image_media_id, Date.now(), record.id],
});
if (record.image_media_id) {
await deleteMedia({ id: record.image_media_id, user_id: Number(user_id) }).catch(() => {});
}
return { image_media_id };
}
@@ -10,8 +10,9 @@ type Params = {
};
/**
* Assigns one of the user's computers to one of their workspaces, so every
* bot in the workspace gets access to it. Assigning it twice is a no-op.
* Links one of the user's computers to one of their workspaces, so every
* bot working there can use it. The first one linked becomes the
* workspace's default. Linking it twice is a no-op.
*/
export default async function addComputerToWorkspace({
user_id,
@@ -29,4 +30,11 @@ export default async function addComputerToWorkspace({
sql: `INSERT OR IGNORE INTO workspace_computers (workspace_id, computer_id, created_at, updated_at) VALUES (?, ?, ?, ?)`,
values: [workspace.id, computer.id, Date.now(), Date.now()],
});
if (!workspace.computer_id) {
await BunSQLite.sql({
sql: `UPDATE workspaces SET computer_id = ?, updated_at = ? WHERE id = ?`,
values: [computer.id, Date.now(), workspace.id],
});
}
}
@@ -0,0 +1,36 @@
import deleteMedia from "@/src/functions/backend/db/media/delete-media";
import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import grabUserWorkspace from "./grab-user-workspace";
type Params = {
user_id: number | string;
workspace_id: number | string;
};
/**
* Deletes one of the user's workspaces with its conversations, its
* links and its models. Its bots and computers stay.
*/
export default async function deleteWorkspace({ user_id, workspace_id }: Params) {
const workspace = await grabUserWorkspace({ workspace_id, user_id });
if (!workspace?.id) {
throw new AppError(`Workspace not found.`);
}
const res = await BunSQLite.sql({
sql: `DELETE FROM workspaces WHERE id = ?`,
values: [workspace.id],
});
if (!res.success) {
throw new Error(`Couldn't delete the workspace: ${res.error}`);
}
if (workspace.image_media_id) {
await deleteMedia({ id: workspace.image_media_id, user_id: Number(user_id) }).catch(() => {});
}
return workspace;
}
@@ -0,0 +1,26 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { WorkspaceWithActivity } from "@/src/types";
type Params = {
user_id: number | string;
};
/**
* The user's workspaces for the rail, not archived, oldest first, each
* unread while any conversation in it is.
*/
export default async function grabUserWorkspaces({
user_id,
}: Params): Promise<WorkspaceWithActivity[]> {
const res = await BunSQLite.sql<WorkspaceWithActivity>({
sql: `SELECT w.*,
COALESCE((SELECT MAX(s.unread) FROM bot_sessions s WHERE s.workspace_id = w.id), 0) AS unread,
(SELECT MAX(s.last_activity_at) FROM bot_sessions s WHERE s.workspace_id = w.id) AS last_activity_at
FROM workspaces w
WHERE w.user_id = ? AND w.archived = 0
ORDER BY w.id ASC`,
values: [user_id],
});
return res.payload || [];
}
@@ -0,0 +1,60 @@
import type {
BUN_SQLITE_COBBLE_BOTS,
BUN_SQLITE_COBBLE_WORKSPACE_BOTS,
BUN_SQLITE_COBBLE_WORKSPACES,
} from "@/db/types/db";
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
workspace_id: number | string;
bot_id: number | string;
user_id: number | string;
};
export type WorkspaceBotMembership = {
workspace: BUN_SQLITE_COBBLE_WORKSPACES;
bot: BUN_SQLITE_COBBLE_BOTS;
/**
* Its `workspace_bots` row. Null for the coordinator, which works in
* every workspace without one until it's given instructions there.
*/
membership: BUN_SQLITE_COBBLE_WORKSPACE_BOTS | null;
};
/**
* One of the user's bots in one of their (non-archived) workspaces: a
* member, or the coordinator, which is in all of them. Null otherwise.
*/
export default async function grabWorkspaceBot({
workspace_id,
bot_id,
user_id,
}: Params): Promise<WorkspaceBotMembership | null> {
const workspace = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_WORKSPACES>({
sql: `SELECT * FROM workspaces WHERE id = ? AND user_id = ? AND archived = 0 LIMIT 1`,
values: [workspace_id, user_id],
})
).payload?.[0];
const bot = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT * FROM bots WHERE id = ? AND user_id = ? LIMIT 1`,
values: [bot_id, user_id],
})
).payload?.[0];
if (!workspace?.id || !bot?.id) return null;
const membership =
(
await BunSQLite.sql<BUN_SQLITE_COBBLE_WORKSPACE_BOTS>({
sql: `SELECT * FROM workspace_bots WHERE workspace_id = ? AND bot_id = ? LIMIT 1`,
values: [workspace.id, bot.id],
})
).payload?.[0] || null;
if (!membership && bot.kind != "coordinator") return null;
return { workspace, bot, membership };
}
@@ -0,0 +1,37 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_WORKSPACE_BOTS } from "@/db/types/db";
import type { WorkspaceBot } from "@/src/types";
import grabBots from "../bots/grab-bots";
type Params = {
user_id: number | string;
workspace_id: number | string;
};
/**
* A workspace's crew (the coordinator first, then its bots by latest
* activity there), each with its instructions and memory for the
* workspace.
*/
export default async function grabWorkspaceBots({
user_id,
workspace_id,
}: Params): Promise<WorkspaceBot[]> {
const [bots, rows] = await Promise.all([
grabBots({ user_id, workspace_id }),
BunSQLite.sql<BUN_SQLITE_COBBLE_WORKSPACE_BOTS>({
sql: `SELECT bot_id, system_prompt, memory FROM workspace_bots WHERE workspace_id = ?`,
values: [workspace_id],
}).then((res) => res.payload || []),
]);
return bots.map((bot) => {
const row = rows.find((r) => r.bot_id == bot.id);
return {
...bot,
workspace_system_prompt: row?.system_prompt || null,
workspace_memory: row?.memory || null,
};
});
}
@@ -0,0 +1,33 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { ComputerWithStatus } from "@/src/types";
import parseJobJson from "@/src/bots-queue/parse-job-json";
type Params = {
workspace_id: number | string;
};
/**
* The computers linked to a workspace, with their container's state, its
* default first.
*/
export default async function grabWorkspaceComputers({
workspace_id,
}: Params): Promise<ComputerWithStatus[]> {
const res = await BunSQLite.sql<ComputerWithStatus & { metadata?: string | null }>({
sql: `SELECT c.*, co.status AS status, co.metadata AS metadata FROM computers c
JOIN workspace_computers wc ON wc.computer_id = c.id AND wc.workspace_id = ?
JOIN workspaces w ON w.id = wc.workspace_id AND w.user_id = c.user_id
LEFT JOIN containers co ON co.id = c.container_id
ORDER BY c.id = w.computer_id DESC, wc.id ASC`,
values: [workspace_id],
});
return (res.payload || []).map(({ metadata, ...computer }) => {
const error = parseJobJson({ text: metadata })?.error;
return {
...computer,
error: computer.status == "failed" && typeof error == "string" ? error : null,
};
});
}
@@ -9,8 +9,9 @@ type Params = {
};
/**
* Takes a computer off one of the user's workspaces. Its bots keep any
* access they were given directly.
* Unlinks a computer from one of the user's workspaces; the computer
* itself stays. When it was the default, the oldest computer still linked
* takes over (none: the workspace has no computer).
*/
export default async function removeComputerFromWorkspace({
user_id,
@@ -27,4 +28,13 @@ export default async function removeComputerFromWorkspace({
sql: `DELETE FROM workspace_computers WHERE workspace_id = ? AND computer_id = ?`,
values: [workspace.id, computer_id],
});
if (String(workspace.computer_id) == String(computer_id)) {
await BunSQLite.sql({
sql: `UPDATE workspaces SET updated_at = ?, computer_id = (
SELECT computer_id FROM workspace_computers WHERE workspace_id = ? ORDER BY id ASC LIMIT 1
) WHERE id = ?`,
values: [Date.now(), workspace.id, workspace.id],
});
}
}
@@ -0,0 +1,44 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import grabWorkspaceComputers from "./grab-workspace-computers";
import grabUserWorkspace from "./grab-user-workspace";
type Params = {
user_id: number | string;
workspace_id: number | string;
computer_id: number | string;
};
/**
* Makes one of a workspace's computers its default, where its tasks run.
* Returns the workspace's computers, the new default first.
*/
export default async function setWorkspaceComputer({
user_id,
workspace_id,
computer_id,
}: Params) {
const workspace = await grabUserWorkspace({ workspace_id, user_id });
if (!workspace?.id) {
throw new AppError(`Workspace not found.`);
}
const linked = await grabWorkspaceComputers({ workspace_id: workspace.id });
const computer = linked.find((c) => String(c.id) == String(computer_id));
if (!computer?.id) {
throw new AppError(`That computer isn't in ${workspace.name || "this workspace"}.`);
}
await BunSQLite.sql({
sql: `UPDATE workspaces SET computer_id = ?, updated_at = ? WHERE id = ?`,
values: [computer.id, Date.now(), workspace.id],
});
return {
workspace: { ...workspace, computer_id: computer.id },
computer,
computers: [computer, ...linked.filter((c) => c.id != computer.id)],
};
}
@@ -0,0 +1,59 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import assertPromptLength from "@/src/utils/assert-prompt-length";
import grabWorkspaceBot from "./grab-workspace-bot";
type Params = {
user_id: number | string;
workspace_id: number | string;
bot_id: number | string;
/**
* Only the fields given are changed: the bot's instructions and memory
* for this workspace (markdown, whole-text replace).
*/
system_prompt?: string;
memory?: string;
};
/**
* Rewrites what a bot has for one workspace alone: its instructions there
* and its memory there. The bot, the coordinator and the user all can. The
* coordinator gets its row on first write.
*/
export default async function updateWorkspaceBot({
user_id,
workspace_id,
bot_id,
system_prompt,
memory,
}: Params) {
assertPromptLength({ label: "Workspace instructions", text: system_prompt });
assertPromptLength({ label: "Workspace memory", text: memory });
const found = await grabWorkspaceBot({ workspace_id, bot_id, user_id });
if (!found) {
throw new AppError(`That bot isn't in this workspace.`);
}
const now = Date.now();
await BunSQLite.sql({
sql: `INSERT OR IGNORE INTO workspace_bots (created_at, updated_at, workspace_id, bot_id) VALUES (?, ?, ?, ?)`,
values: [now, now, found.workspace.id!, found.bot.id!],
});
if (system_prompt !== undefined) {
await BunSQLite.sql({
sql: `UPDATE workspace_bots SET system_prompt = ?, updated_at = ? WHERE workspace_id = ? AND bot_id = ?`,
values: [system_prompt, now, found.workspace.id!, found.bot.id!],
});
}
if (memory !== undefined) {
await BunSQLite.sql({
sql: `UPDATE workspace_bots SET memory = ?, updated_at = ? WHERE workspace_id = ? AND bot_id = ?`,
values: [memory, now, found.workspace.id!, found.bot.id!],
});
}
}
@@ -0,0 +1,93 @@
import { AppData } from "@/src/data/app-data";
import { ComputerMcpPath } from "@/src/dict/computer-dict";
import dockerRequest from "./docker-request";
import ensureDockerNetwork from "./ensure-docker-network";
import grabAppContainer from "./grab-app-container";
import grabFreePort from "./grab-free-port";
import inspectDockerContainer from "./inspect-docker-container";
import pullDockerImage from "./pull-docker-image";
import removeDockerContainer from "./remove-docker-container";
import resolveHostPath from "./resolve-host-path";
type Params = {
computer_id: number;
/**
* The container's name, also its hostname on the network.
*/
name: string;
/**
* The computer's home directory as this app sees it.
*/
data_dir: string;
};
/**
* Creates and starts an AIO sandbox container for a computer
* (`AppData.ComputerImage`) on the computers' network, with its home
* directory bind-mounted from `data_dir` and restarted with docker. A
* container left by an earlier try is replaced. When the app runs outside
* docker, the sandbox is also published on a localhost port, returned as
* `mcp_url`'s host.
*/
export default async function createComputerContainer({ computer_id, name, data_dir }: Params) {
const network = await ensureDockerNetwork();
const in_docker = Boolean(await grabAppContainer());
const host_dir = await resolveHostPath({ local_path: data_dir });
const port_key = `${AppData.ComputerPort}/tcp`;
const host_port = in_docker ? undefined : grabFreePort();
await pullDockerImage({ image: AppData.ComputerImage });
await removeDockerContainer({ container: name });
const created = await dockerRequest<{ Id: string }>({
method: "POST",
path: `/containers/create`,
query: { name },
body: {
Image: AppData.ComputerImage,
Hostname: name,
Labels: {
[`${AppData.DockerNetworkName}.computer_id`]: String(computer_id),
},
ExposedPorts: { [port_key]: {} },
HostConfig: {
Binds: [`${host_dir}:${AppData.ComputerHomeDir}`],
NetworkMode: network,
RestartPolicy: { Name: "unless-stopped" },
SecurityOpt: ["seccomp=unconfined"],
ShmSize: AppData.ComputerShmBytes,
Memory: AppData.ComputerMemoryBytes,
NanoCpus: AppData.ComputerCpus * 1e9,
PortBindings: host_port
? { [port_key]: [{ HostIp: "127.0.0.1", HostPort: String(host_port) }] }
: undefined,
},
NetworkingConfig: {
EndpointsConfig: { [network]: { Aliases: [name] } },
},
},
});
if (!created.ok || !created.data?.Id) {
throw new Error(`Couldn't create the container: ${created.message}`);
}
const started = await dockerRequest({
method: "POST",
path: `/containers/${created.data.Id}/start`,
});
if (!started.ok && started.status != 304) {
throw new Error(`Couldn't start the container: ${started.message}`);
}
const state = await inspectDockerContainer({ container: created.data.Id });
return {
docker_id: created.data.Id,
network,
status: state?.status || "unknown",
host_dir,
mcp_url: host_port ? `http://127.0.0.1:${host_port}${ComputerMcpPath}` : undefined,
};
}
@@ -0,0 +1,74 @@
import { AppData } from "@/src/data/app-data";
type Params = {
method?: "GET" | "POST" | "DELETE";
/**
* Docker engine API path, e.g. `/containers/json`.
*/
path: string;
query?: Record<string, string | number | boolean | undefined>;
body?: unknown;
timeout_ms?: number;
};
export type DockerResponse<T> = {
status: number;
ok: boolean;
data: T | null;
/**
* The engine's own message when the call failed.
*/
message?: string;
};
/**
* One call to the docker engine API over its unix socket
* (`AppData.DockerSocketPath`, or `DOCKER_SOCKET` in the process env).
* Never throws for an HTTP error; check `ok`. Throws when the engine
* can't be reached at all.
*/
export default async function dockerRequest<T = unknown>({
method = "GET",
path,
query,
body,
timeout_ms = 60 * 1000,
}: Params): Promise<DockerResponse<T>> {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(query || {})) {
if (value !== undefined) search.set(key, String(value));
}
const url = `http://docker${path}${search.size ? `?${search}` : ""}`;
const res = await fetch(url, {
method,
unix: process.env.DOCKER_SOCKET || AppData.DockerSocketPath,
headers: body === undefined ? undefined : { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(timeout_ms),
}).catch((error) => {
throw new Error(
`Can't reach docker: ${error instanceof Error ? error.message : String(error)}. The app needs the docker socket to create computers.`,
);
});
const text = await res.text();
let data: unknown = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = text;
}
return {
status: res.status,
ok: res.ok,
data: res.ok ? (data as T) : null,
message: res.ok
? undefined
: (data as { message?: string } | null)?.message || text || `HTTP ${res.status}`,
};
}
@@ -0,0 +1,45 @@
import { AppData } from "@/src/data/app-data";
import dockerRequest from "./docker-request";
import grabAppContainer from "./grab-app-container";
/**
* Makes sure the network computers run on (`AppData.DockerNetworkName`)
* exists and, when the app runs in docker, that its container is on it,
* so it reaches computers by name.
*/
export default async function ensureDockerNetwork() {
const name = AppData.DockerNetworkName;
const existing = await dockerRequest({ path: `/networks/${encodeURIComponent(name)}` });
if (existing.status == 404) {
const created = await dockerRequest({
method: "POST",
path: `/networks/create`,
body: { Name: name, Driver: "bridge", CheckDuplicate: true },
});
if (!created.ok && created.status != 409) {
throw new Error(`Couldn't create the ${name} network: ${created.message}`);
}
} else if (!existing.ok) {
throw new Error(`Couldn't read the ${name} network: ${existing.message}`);
}
const container = await grabAppContainer();
if (!container || container.networks.includes(name)) return name;
const connected = await dockerRequest({
method: "POST",
path: `/networks/${encodeURIComponent(name)}/connect`,
body: { Container: container.id },
});
if (!connected.ok && connected.status != 403) {
throw new Error(`Couldn't connect the app to the ${name} network: ${connected.message}`);
}
container.networks.push(name);
return name;
}
@@ -0,0 +1,41 @@
import fs from "fs";
import os from "os";
import dockerRequest from "./docker-request";
export type AppContainer = {
id: string;
mounts: { Source: string; Destination: string }[];
networks: string[];
};
let cached: Promise<AppContainer | null> | undefined;
/**
* The docker container this app runs in, or null when it runs straight on
* the host (a dev server). Read once per process: docker sets the
* container's hostname to its id.
*/
export default function grabAppContainer(): Promise<AppContainer | null> {
cached ??= inspectSelf();
return cached;
}
async function inspectSelf(): Promise<AppContainer | null> {
if (!fs.existsSync("/.dockerenv")) return null;
const res = await dockerRequest<{
Id: string;
Mounts?: AppContainer["mounts"];
NetworkSettings?: { Networks?: Record<string, unknown> };
}>({ path: `/containers/${encodeURIComponent(os.hostname())}/json` });
if (!res.ok || !res.data?.Id) {
throw new Error(`The app runs in docker but can't find its own container: ${res.message}`);
}
return {
id: res.data.Id,
mounts: res.data.Mounts || [],
networks: Object.keys(res.data.NetworkSettings?.Networks || {}),
};
}
@@ -0,0 +1,16 @@
/**
* A port nothing on this host listens on right now, for publishing a
* computer when the app runs outside docker and can't reach it by name.
*/
export default function grabFreePort() {
const listener = Bun.listen({
hostname: "127.0.0.1",
port: 0,
socket: { data() {} },
});
const port = listener.port;
listener.stop(true);
return port;
}
@@ -0,0 +1,47 @@
import type { ContainerStatus } from "@/src/dict/computer-dict";
import dockerRequest from "./docker-request";
type Params = {
/**
* The container's id or name.
*/
container: string;
};
export type DockerContainerState = {
id: string;
status: ContainerStatus;
/**
* Host ports published for each container port, e.g. `{ "8080/tcp": "8301" }`.
*/
ports: Record<string, string>;
};
/**
* A container's state, or null when docker has no such container.
*/
export default async function inspectDockerContainer({
container,
}: Params): Promise<DockerContainerState | null> {
const res = await dockerRequest<{
Id: string;
State?: { Status?: ContainerStatus };
NetworkSettings?: { Ports?: Record<string, { HostPort?: string }[] | null> };
}>({ path: `/containers/${encodeURIComponent(container)}/json` });
if (res.status == 404) return null;
if (!res.ok || !res.data) {
throw new Error(`Couldn't inspect ${container}: ${res.message}`);
}
return {
id: res.data.Id,
status: res.data.State?.Status || "unknown",
ports: Object.fromEntries(
Object.entries(res.data.NetworkSettings?.Ports || {}).flatMap(([port, bindings]) =>
bindings?.[0]?.HostPort ? [[port, bindings[0].HostPort]] : [],
),
),
};
}
@@ -0,0 +1,51 @@
import dockerRequest from "./docker-request";
type Params = {
/**
* e.g. `ghcr.io/agent-infra/sandbox:latest`.
*/
image: string;
};
/**
* Pulls an image unless docker already has it. Waits for the whole pull,
* which can take minutes for a large image.
*/
export default async function pullDockerImage({ image }: Params) {
const present = await dockerRequest({ path: `/images/${encodeURIComponent(image)}/json` });
if (present.ok) return;
const slash = image.lastIndexOf("/");
const colon = image.lastIndexOf(":");
const has_tag = colon > slash;
const res = await dockerRequest<string>({
method: "POST",
path: `/images/create`,
query: {
fromImage: has_tag ? image.slice(0, colon) : image,
tag: has_tag ? image.slice(colon + 1) : "latest",
},
timeout_ms: 30 * 60 * 1000,
});
/**
* The pull streams JSON lines; a failure mid-way is a line with
* `error`, under a 200.
*/
const failed = String(res.data || "")
.split("\n")
.map((line) => {
try {
return JSON.parse(line) as { error?: string };
} catch {
return null;
}
})
.find((line) => line?.error);
if (!res.ok || failed) {
throw new Error(`Couldn't pull ${image}: ${failed?.error || res.message}`);
}
}
@@ -0,0 +1,24 @@
import dockerRequest from "./docker-request";
type Params = {
/**
* The container's id or name.
*/
container: string;
};
/**
* Stops and removes a container. Its bind-mounted files stay on the host.
* A container docker doesn't have is already gone.
*/
export default async function removeDockerContainer({ container }: Params) {
const res = await dockerRequest({
method: "DELETE",
path: `/containers/${encodeURIComponent(container)}`,
query: { force: true },
});
if (!res.ok && res.status != 404) {
throw new Error(`Couldn't remove ${container}: ${res.message}`);
}
}
@@ -0,0 +1,36 @@
import path from "path";
import grabAppContainer from "./grab-app-container";
type Params = {
/**
* A path as this app sees it.
*/
local_path: string;
};
/**
* The same path as the docker host sees it, for a bind mount: docker
* resolves mount sources on the host, not in this app's container. In a
* container the path must sit under one of its mounts (the deepest one
* wins); on the host it's returned as it is.
*/
export default async function resolveHostPath({ local_path }: Params) {
const container = await grabAppContainer();
const target = path.resolve(local_path);
if (!container) return target;
const mount = container.mounts
.filter(
(m) => target == m.Destination || target.startsWith(`${m.Destination.replace(/\/+$/, "")}/`),
)
.sort((a, b) => b.Destination.length - a.Destination.length)[0];
if (!mount) {
throw new Error(
`${target} isn't on a volume, so computers can't keep their files there. Mount DATA_DIR into the app's container.`,
);
}
return path.join(mount.Source, path.relative(mount.Destination, target));
}
@@ -0,0 +1,13 @@
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import type { SetProfileImageRequest } from "@/src/schemas/profile-image-schema";
/**
* Sets or removes a bot's or workspace's picture.
*/
export default async function setProfileImage(body: SetProfileImageRequest) {
return await fetchApi<SetProfileImageRequest, APIResponseObject<{ image_media_id: number | null }>>(
`/api/admin/set-profile-image`,
{ method: "POST", body },
);
}
+7 -2
View File
@@ -15,6 +15,10 @@ type Params = {
* Uses this bot's speech model and voice when it has its own.
*/
bot_id?: number | string;
/**
* The workspace the call is in: its models win over the bot's.
*/
workspace_id?: number | string | null;
};
/**
@@ -26,10 +30,11 @@ export default async function speakText({
format,
signal,
bot_id,
workspace_id,
}: Params) {
const [candidates, setting] = await Promise.all([
resolveHarnessCandidates({ harness: "speech", bot_id }),
grabHarnessSetting({ harness: "speech", bot_id }),
resolveHarnessCandidates({ harness: "speech", bot_id, workspace_id }),
grabHarnessSetting({ harness: "speech", bot_id, workspace_id }),
]);
const { result } = await withCandidates({
+6
View File
@@ -24,6 +24,10 @@ type Params = {
* Uses this bot's transcription model when it has its own.
*/
bot_id?: number | string;
/**
* The workspace the call is in: its models win over the bot's.
*/
workspace_id?: number | string | null;
};
/**
@@ -36,6 +40,7 @@ export default async function transcribeAudio({
language,
signal,
bot_id,
workspace_id,
}: Params) {
if (audio.data.byteLength > AppData.HarnessMaxAudioBytes) {
throw new AppError(`That recording is too long to transcribe.`);
@@ -44,6 +49,7 @@ export default async function transcribeAudio({
const candidates = await resolveHarnessCandidates({
harness: "transcription",
bot_id,
workspace_id,
});
const final_signal = signal || new AbortController().signal;
+6 -1
View File
@@ -15,6 +15,10 @@ type Params = {
* Uses this bot's image model when it has its own.
*/
bot_id?: number | string;
/**
* The workspace the call is in: its models win over the bot's.
*/
workspace_id?: number | string | null;
};
/**
@@ -27,8 +31,9 @@ export default async function generateImage({
references,
signal,
bot_id,
workspace_id,
}: Params) {
const candidates = await resolveHarnessCandidates({ harness: "image", bot_id });
const candidates = await resolveHarnessCandidates({ harness: "image", bot_id, workspace_id });
const { result } = await withCandidates({
harness: "image",
+6 -1
View File
@@ -18,16 +18,21 @@ type Params = {
* Uses this bot's media reading model when it has its own.
*/
bot_id?: number | string;
/**
* The workspace the call is in: its models win over the bot's.
*/
workspace_id?: number | string | null;
};
/**
* Reads an image with the media reading (vision) harness and returns what
* it sees, as text for a model that can't see.
*/
export default async function readImage({ url, focus, signal, bot_id }: Params) {
export default async function readImage({ url, focus, signal, bot_id, workspace_id }: Params) {
const candidates = await resolveHarnessCandidates({
harness: "vision",
bot_id,
workspace_id,
});
const { result } = await withCandidates({
+7
View File
@@ -18,6 +18,10 @@ type Params = {
* Reads with this bot's models when it has its own.
*/
bot_id?: number | string;
/**
* The workspace the call is in: its models win over the bot's.
*/
workspace_id?: number | string | null;
};
const DocumentExtensions = ["pdf", "docx", "txt", "md", "csv", "json", "html", "xml", "yaml", "yml"];
@@ -43,6 +47,7 @@ export default async function readMedia({
name,
signal,
bot_id,
workspace_id,
}: Params): Promise<string> {
const type = media_type.toLowerCase().split(";")[0]!.trim();
const ext = (
@@ -56,6 +61,7 @@ export default async function readMedia({
url: `data:${type};base64,${Buffer.from(data).toString("base64")}`,
signal,
bot_id,
workspace_id,
});
}
@@ -64,6 +70,7 @@ export default async function readMedia({
audio: { data, format: audioFormat({ type, ext }) },
signal,
bot_id,
workspace_id,
});
}
@@ -23,6 +23,10 @@ type Params = {
* The bot the call is for: a worker bot can override the default.
*/
bot_id?: number | string;
/**
* The workspace the call is in: its models win over the bot's.
*/
workspace_id?: number | string | null;
};
type CatalogEntry = HarnessCatalogModel & {
@@ -41,8 +45,9 @@ type CatalogEntry = HarnessCatalogModel & {
export default async function resolveHarnessCandidates({
harness,
bot_id,
workspace_id,
}: Params): Promise<HarnessCandidate[]> {
const setting = await grabHarnessSetting({ harness, bot_id });
const setting = await grabHarnessSetting({ harness, bot_id, workspace_id });
if (!setting.enabled) {
const title = Harnesses.find((h) => h.value == harness)?.title;
+7 -1
View File
@@ -33,6 +33,10 @@ export type GenerateTextParams = {
* The bot the turn is for: its own models are used where it has them.
*/
bot_id?: number | string;
/**
* The workspace the call is in: its models win over the bot's.
*/
workspace_id?: number | string | null;
};
export type GenerateTextResult = HarnessChatResult & {
@@ -54,11 +58,12 @@ export default async function generateText({
signal = new AbortController().signal,
media_cache = new Map(),
bot_id,
workspace_id,
}: GenerateTextParams): Promise<GenerateTextResult> {
const { result, candidate } = await withCandidates({
harness: "text",
candidates:
candidates || (await resolveHarnessCandidates({ harness: "text", bot_id })),
candidates || (await resolveHarnessCandidates({ harness: "text", bot_id, workspace_id })),
prefer_key,
run: async (candidate) => {
const answer = await grabHarnessAdapter({
@@ -71,6 +76,7 @@ export default async function generateText({
cache: media_cache,
signal,
bot_id,
workspace_id,
}),
tools,
max_tokens,
@@ -21,6 +21,10 @@ type Params = {
* Reads media with this bot's models when it has its own.
*/
bot_id?: number | string;
/**
* The workspace the call is in: its models win over the bot's.
*/
workspace_id?: number | string | null;
read_image?: typeof readImage;
transcribe?: typeof transcribeAudio;
};
@@ -37,6 +41,7 @@ export default async function prepareMessagesForModel({
cache,
signal,
bot_id,
workspace_id,
read_image = readImage,
transcribe = transcribeAudio,
}: Params): Promise<HarnessMessage[]> {
@@ -56,7 +61,7 @@ export default async function prepareMessagesForModel({
label: `Image${part.name ? ` "${part.name}"` : ""}`,
cache,
signal,
read: () => read_image({ url: part.url, signal, bot_id }),
read: () => read_image({ url: part.url, signal, bot_id, workspace_id }),
}),
};
}
@@ -78,6 +83,7 @@ export default async function prepareMessagesForModel({
},
signal,
bot_id,
workspace_id,
}),
}),
};
+7 -1
View File
@@ -41,6 +41,10 @@ type Params = {
* The bot the run is for: its own models are used where it has them.
*/
bot_id?: number | string;
/**
* The workspace the call is in: its models win over the bot's.
*/
workspace_id?: number | string | null;
generate?: typeof generateText;
resolveCandidates?: () => Promise<HarnessCandidate[]>;
};
@@ -75,8 +79,9 @@ export default async function runTextLoop({
signal = new AbortController().signal,
onStep,
bot_id,
workspace_id,
generate = generateText,
resolveCandidates = () => resolveHarnessCandidates({ harness: "text", bot_id }),
resolveCandidates = () => resolveHarnessCandidates({ harness: "text", bot_id, workspace_id }),
}: Params): Promise<TextLoopResult> {
const candidates = await resolveCandidates();
const definitions = tools.map((tool) => toolDefinition({ tool }));
@@ -120,6 +125,7 @@ export default async function runTextLoop({
signal,
media_cache,
bot_id,
workspace_id,
});
prefer_key = answer.candidate.key;
+3 -10
View File
@@ -1,7 +1,6 @@
import { z } from "zod";
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
import addBotToComputer from "@/src/functions/backend/db/computers/add-bot-to-computer";
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
import removeBotFromComputer from "@/src/functions/backend/db/computers/remove-bot-from-computer";
import computerRef from "@/src/utils/computer-ref";
import type { HarnessTool } from "../../types";
@@ -30,8 +29,8 @@ type Args = {
/**
* Lets the coordinator give a crew member access to one of the user's
* computers (`bot_computers`), or take a direct grant away. Access that
* comes through a workspace stays, and the result says so.
* computers in its own chat (`bot_computers`), or take it away. In a
* workspace, bots use the workspace's computers instead.
*/
export default function assignComputerTool({
user_id,
@@ -77,13 +76,7 @@ export default function assignComputerTool({
await removeBotFromComputer({ user_id, bot_id: worker.id, computer_id: computer.id });
const still = (await grabBotComputers({ bot_id: worker.id, user_id })).some(
(c) => c.id == computer.id,
);
return still
? `Removed ${worker.name}'s own access to ${ref}, but it still has it through a workspace.`
: `${worker.name} can't use ${ref} any more.`;
return `${worker.name} can't use ${ref} in its own chat any more. In a workspace it still uses the workspace's computers.`;
},
};
}
+19 -2
View File
@@ -4,6 +4,7 @@ import pushBotNotice from "@/src/bots-queue/push-bot-notice";
import enqueueBotJob from "@/src/functions/backend/db/bots-queue/enqueue-bot-job";
import insertBotMessage from "@/src/functions/backend/db/bots/insert-bot-message";
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
import ensureBotSession from "@/src/functions/backend/db/bot-sessions/ensure-bot-session";
import type { HarnessTool } from "../../types";
type Params = {
@@ -12,12 +13,21 @@ type Params = {
* The coordinator: the worker's result comes back to its conversation.
*/
coordinator_id: number | string;
/**
* The coordinator's session the task is handed over from.
*/
session_id: number | string;
/**
* The workspace the coordinator is working in: the worker works on it
* there, in its conversation in the workspace.
*/
workspace_id?: number | string | null;
/**
* The coordinator's name, shown in the worker's conversation.
*/
coordinator_name: string;
/**
* The user's worker bots.
* The worker bots it can hand work to (in a workspace, its members).
*/
workers: BUN_SQLITE_COBBLE_BOTS[];
};
@@ -37,6 +47,8 @@ type Args = {
export default function delegateTaskTool({
user_id,
coordinator_id,
session_id,
workspace_id,
coordinator_name,
workers,
}: Params): HarnessTool<Args> {
@@ -59,21 +71,26 @@ export default function delegateTaskTool({
throw new Error(`No crew member has id ${args.bot_id}. Crew: ${crew}.`);
}
const computers = await grabBotComputers({ bot_id: worker.id, user_id });
const computers = await grabBotComputers({ bot_id: worker.id, user_id, workspace_id });
const worker_session = await ensureBotSession({ bot_id: worker.id, workspace_id });
const job_id = await enqueueBotJob({
user_id,
bot_id: worker.id,
session_id: Number(worker_session.id),
title: args.title,
instructions: args.instructions,
computer_id: computers[0]?.id || undefined,
requested_by_bot_id: coordinator_id,
requested_by_session_id: session_id,
});
await pushBotNotice({
bot_id: worker.id,
message: await insertBotMessage({
bot_id: worker.id,
session_id: Number(worker_session.id),
workspace_id: workspace_id ? Number(workspace_id) : null,
text: `${coordinator_name} asked me to: **${args.title}**`,
}),
});
+64 -31
View File
@@ -7,15 +7,27 @@ import searchComputersTool from "../computer/search-computers-tool";
import assignComputerTool from "./assign-computer-tool";
import delegateTaskTool from "./delegate-task-tool";
import grabBotTools from "./grab-bot-tools";
import updateWorkspaceTool from "./update-workspace-tool";
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
import setWorkspaceInstructionsTool from "./set-workspace-instructions-tool";
import startTaskTool from "./start-task-tool";
type Params = {
bot: BUN_SQLITE_COBBLE_BOTS;
user_id: number | string;
/**
* The bot's computers, the current one first (`grabBotComputers`).
* The bot's computers where it runs, the current one first
* (`grabBotComputers`).
*/
computers: BUN_SQLITE_COBBLE_COMPUTERS[];
/**
* The session being answered; tasks report back to it.
*/
session_id: number | string;
/**
* The session's workspace, if any.
*/
workspace_id?: number | string | null;
/**
* The user message being answered.
*/
@@ -27,32 +39,37 @@ type Params = {
};
/**
* The tools a bot replies in chat with: its own (memory, instructions),
* start_task for longer work, search_computers to find what's on its
* computers, ask_user_to_take_over when only the user can do something on
* one, and for the coordinator delegate_task and assign_computer
* (which computers each crew member can use). Anything
* else on a computer goes through a task, never mid-reply.
* The tools a bot replies in chat with: its own (memory, instructions, and
* in a workspace switching its default computer), start_task for longer
* work, search_computers to find what's on its computers,
* ask_user_to_take_over when only the user can do something on one, and
* for the coordinator delegate_task (to the workspace's crew in a
* workspace) plus, in its own chat, assign_computer (which computers each
* crew member can use) or, in a workspace, set_bot_workspace_instructions.
* Anything else on a computer goes through a task, never mid-reply.
*/
export default async function grabBotChatTools({
bot,
user_id,
computers,
session_id,
workspace_id,
message_id,
onTakeOver,
}: Params): Promise<HarnessTool[]> {
const bot_id = bot.id || 0;
const tools = [
...grabBotTools({ user_id, bot_id }),
...grabBotTools({ user_id, bot_id, workspace_id, computers }),
startTaskTool({
user_id,
bot_id,
session_id,
computers,
message_id,
}),
...(computers.length
? [
searchComputersTool({ bot_id, computers }),
searchComputersTool({ computers, workspace_id }),
askToTakeOverTool({ computers, onAsk: (params) => onTakeOver?.(params) }),
]
: []),
@@ -60,36 +77,52 @@ export default async function grabBotChatTools({
if (bot.kind != "coordinator") return tools;
const workspaces = (await grabUserWorkspaces({ user_id })).filter((w) => !w.archived);
if (workspaces.length) {
tools.push(updateWorkspaceTool({ user_id, workspaces, workspace_id }));
}
const workers =
(
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT * FROM bots WHERE user_id = ? AND kind = 'worker' ORDER BY id ASC`,
values: [user_id],
sql: workspace_id
? `SELECT b.* FROM bots b JOIN workspace_bots wb ON wb.bot_id = b.id AND wb.workspace_id = ?
WHERE b.user_id = ? AND b.kind = 'worker' ORDER BY b.id ASC`
: `SELECT * FROM bots WHERE user_id = ? AND kind = 'worker' ORDER BY id ASC`,
values: workspace_id ? [workspace_id, user_id] : [user_id],
})
).payload || [];
if (workers.length) {
tools.push(
delegateTaskTool({
user_id,
coordinator_id: bot_id,
coordinator_name: bot.name || "The coordinator",
workers,
}),
if (!workers.length) return tools;
tools.push(
delegateTaskTool({
user_id,
coordinator_id: bot_id,
session_id,
workspace_id,
coordinator_name: bot.name || "The coordinator",
workers,
}),
);
if (workspace_id) {
tools.push(setWorkspaceInstructionsTool({ user_id, workspace_id, workers }));
return tools;
}
if (computers.length) {
const access = Object.fromEntries(
await Promise.all(
workers.map(async (worker) => [
worker.id || 0,
await grabBotComputers({ bot_id: worker.id || 0, user_id }),
]),
),
);
if (computers.length) {
const access = Object.fromEntries(
await Promise.all(
workers.map(async (worker) => [
worker.id || 0,
await grabBotComputers({ bot_id: worker.id || 0, user_id }),
]),
),
);
tools.push(assignComputerTool({ user_id, workers, computers, access }));
}
tools.push(assignComputerTool({ user_id, workers, computers, access }));
}
return tools;
+24 -5
View File
@@ -1,19 +1,38 @@
import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
import type { HarnessTool } from "../../types";
import setWorkspaceComputerTool from "./set-workspace-computer-tool";
import updateInstructionsTool from "./update-instructions-tool";
import updateMemoryTool from "./update-memory-tool";
type Params = {
user_id: number | string;
bot_id: number | string;
/**
* The workspace the run is in: memory and instructions save there by
* default, and the bot can switch its default computer.
*/
workspace_id?: number | string | null;
/**
* The bot's computers where it runs, the current one first.
*/
computers?: BUN_SQLITE_COBBLE_COMPUTERS[];
};
/**
* The tools a bot works with, bound to that bot. Computer, delegation and
* media tools join this list as they are built.
* The tools a bot works with on every run, bound to that bot and where it
* works.
*/
export default function grabBotTools({ user_id, bot_id }: Params): HarnessTool[] {
export default function grabBotTools({
user_id,
bot_id,
workspace_id,
computers = [],
}: Params): HarnessTool[] {
return [
updateMemoryTool({ user_id, bot_id }),
updateInstructionsTool({ user_id, bot_id }),
updateMemoryTool({ user_id, bot_id, workspace_id }),
updateInstructionsTool({ user_id, bot_id, workspace_id }),
...(workspace_id && computers.length > 1
? [setWorkspaceComputerTool({ user_id, workspace_id, computers })]
: []),
];
}
@@ -0,0 +1,49 @@
import { z } from "zod";
import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
import setWorkspaceComputer from "@/src/functions/backend/db/workspaces/set-workspace-computer";
import computerRef from "@/src/utils/computer-ref";
import type { HarnessTool } from "../../types";
type Params = {
user_id: number | string;
workspace_id: number | string;
/**
* The workspace's computers, the default first.
*/
computers: BUN_SQLITE_COBBLE_COMPUTERS[];
};
type Args = {
computer_id: number;
};
/**
* Lets a bot working in a workspace switch the workspace's default
* computer, where its tasks run from then on.
*/
export default function setWorkspaceComputerTool({
user_id,
workspace_id,
computers,
}: Params): HarnessTool<Args> {
const listed = computers
.map((c) => `${computerRef({ id: c.id || 0 })} ${c.name || "Computer"}`)
.join(", ");
return {
name: "set_workspace_computer",
description: `Make another of this workspace's computers its default, where tasks here run from now on (for the whole crew). Do it when the work needs that computer; say so to the user. Computers: ${listed}. The default now is ${computerRef({ id: computers[0]?.id || 0 })}.`,
schema: z.object({
computer_id: z.number().int(),
}),
run: async ({ args }) => {
const { computer } = await setWorkspaceComputer({
user_id,
workspace_id,
computer_id: args.computer_id,
});
return `${computerRef({ id: computer.id || 0 })} is the workspace's default computer now. Tasks you start from here on run there.`;
},
};
}
@@ -0,0 +1,56 @@
import { z } from "zod";
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import updateWorkspaceBot from "@/src/functions/backend/db/workspaces/update-workspace-bot";
import type { HarnessTool } from "../../types";
type Params = {
user_id: number | string;
workspace_id: number | string;
/**
* The workspace's crew members (not the coordinator).
*/
workers: BUN_SQLITE_COBBLE_BOTS[];
};
type Args = {
bot_id: number;
instructions: string;
};
/**
* Lets the coordinator set a crew member's instructions for this
* workspace: how that bot should do its job here.
*/
export default function setWorkspaceInstructionsTool({
user_id,
workspace_id,
workers,
}: Params): HarnessTool<Args> {
const crew = workers.map((bot) => `${bot.id}: ${bot.name}`).join("; ");
return {
name: "set_bot_workspace_instructions",
description: `Replace a crew member's instructions for this workspace with this full text (markdown): how they should do their job here, on top of their own instructions. Use it when the user tells you how someone should work in this workspace. It replaces what they had for it, so keep what still applies. Crew here: ${crew}.`,
schema: z.object({
bot_id: z.number().int(),
instructions: z.string().trim().max(AppData.PromptMaxLength),
}),
run: async ({ args }) => {
const worker = workers.find((bot) => bot.id == args.bot_id);
if (!worker?.id) {
throw new Error(`No crew member here has id ${args.bot_id}. Crew here: ${crew}.`);
}
await updateWorkspaceBot({
user_id,
workspace_id,
bot_id: worker.id,
system_prompt: args.instructions,
});
return `${worker.name}'s instructions for this workspace are saved. They apply from their next run here.`;
},
};
}
+6
View File
@@ -6,6 +6,10 @@ import type { HarnessTool } from "../../types";
type Params = {
user_id: number | string;
bot_id: number | string;
/**
* The session the task is started from; its result is posted there.
*/
session_id: number | string;
/**
* The bot's computers, the current one first (`grabBotComputers`).
*/
@@ -29,6 +33,7 @@ type Args = {
export default function startTaskTool({
user_id,
bot_id,
session_id,
computers,
message_id,
}: Params): HarnessTool<Args> {
@@ -54,6 +59,7 @@ export default function startTaskTool({
const job_id = await enqueueBotJob({
user_id,
bot_id,
session_id,
title: args.title,
instructions: args.instructions,
message_id,
@@ -1,33 +1,61 @@
import { z } from "zod";
import { AppData } from "@/src/data/app-data";
import updateBotSystemPrompt from "@/src/functions/backend/db/bots/update-bot-system-prompt";
import updateWorkspaceBot from "@/src/functions/backend/db/workspaces/update-workspace-bot";
import type { HarnessTool } from "../../types";
type Params = {
user_id: number | string;
bot_id: number | string;
/**
* The workspace the run is in: then the bot rewrites its instructions
* for the workspace unless it picks `core`.
*/
workspace_id?: number | string | null;
};
type Args = {
instructions: string;
scope?: "workspace" | "core";
};
/**
* Lets a bot rewrite its own core instructions as it adapts to the user.
* Lets a bot rewrite its own instructions as it adapts to the user: its
* core instructions, or in a workspace (by default) its instructions for
* that workspace.
*/
export default function updateInstructionsTool({
user_id,
bot_id,
}: Params): HarnessTool<{ instructions: string }> {
workspace_id,
}: Params): HarnessTool<Args> {
const instructions = z.string().trim().max(AppData.PromptMaxLength);
return {
name: "update_instructions",
description: `Replace your core instructions with this full text (markdown): the start of your prompt, before any workspace or memory. Change them only to work better for this user, such as a new standing rule they gave you. Keep everything that still applies.`,
schema: z.object({
instructions: z.string().trim().min(1).max(AppData.PromptMaxLength),
}),
description: workspace_id
? `Replace your instructions with this full text (markdown). scope "workspace" (the default) is your instructions for this workspace, under "# Your instructions in ..."; "core" is your core instructions, the start of your prompt in every workspace and your own chat. Change them only to work better for this user, such as a new standing rule they gave you. Keep everything that still applies.`
: `Replace your core instructions with this full text (markdown): the start of your prompt, before any workspace or memory. Change them only to work better for this user, such as a new standing rule they gave you. Keep everything that still applies.`,
schema: workspace_id
? z.object({ instructions, scope: z.enum(["workspace", "core"]).default("workspace") })
: z.object({ instructions: instructions.min(1) }),
run: async ({ args }) => {
if (workspace_id && args.scope != "core") {
await updateWorkspaceBot({
user_id,
workspace_id,
bot_id,
system_prompt: args.instructions,
});
return `Instructions for this workspace saved. They apply from your next run here.`;
}
await updateBotSystemPrompt({
user_id,
bot_id,
system_prompt: args.instructions,
});
return `Instructions saved. They apply from your next run.`;
return `Core instructions saved. They apply from your next run, everywhere.`;
},
};
}
+29 -7
View File
@@ -1,29 +1,51 @@
import { z } from "zod";
import { AppData } from "@/src/data/app-data";
import updateBotMemory from "@/src/functions/backend/db/bots/update-bot-memory";
import updateWorkspaceBot from "@/src/functions/backend/db/workspaces/update-workspace-bot";
import type { HarnessTool } from "../../types";
type Params = {
user_id: number | string;
bot_id: number | string;
/**
* The workspace the run is in: then the bot rewrites its memory for
* the workspace unless it picks `core`.
*/
workspace_id?: number | string | null;
};
type Args = {
memory: string;
scope?: "workspace" | "core";
};
/**
* Lets a bot rewrite its own memory (shown to it under `# Memory`).
* Lets a bot rewrite its own memory: its core memory (under `# Memory`),
* or in a workspace (by default) its memory for that workspace.
*/
export default function updateMemoryTool({
user_id,
bot_id,
}: Params): HarnessTool<{ memory: string }> {
workspace_id,
}: Params): HarnessTool<Args> {
const memory = z.string().max(AppData.PromptMaxLength);
return {
name: "update_memory",
description: `Replace your memory with this full text (markdown). It's in your prompt under "# Memory" on every run. Keep what you must always remember: the user's preferences, facts about their work, decisions made. Anything you leave out is forgotten, so include the parts that still matter.`,
schema: z.object({
memory: z.string().max(AppData.PromptMaxLength),
}),
description: workspace_id
? `Replace a memory with this full text (markdown). scope "workspace" (the default) is your memory for this workspace, under "# Memory in ..."; "core" is your core memory, under "# Memory", in every workspace and your own chat. Keep what you must always remember: the user's preferences, facts about their work, decisions made. Anything you leave out is forgotten, so include the parts that still matter.`
: `Replace your memory with this full text (markdown). It's in your prompt under "# Memory" on every run. Keep what you must always remember: the user's preferences, facts about their work, decisions made. Anything you leave out is forgotten, so include the parts that still matter.`,
schema: workspace_id
? z.object({ memory, scope: z.enum(["workspace", "core"]).default("workspace") })
: z.object({ memory }),
run: async ({ args }) => {
if (workspace_id && args.scope != "core") {
await updateWorkspaceBot({ user_id, workspace_id, bot_id, memory: args.memory });
return `Memory for this workspace saved.`;
}
await updateBotMemory({ user_id, bot_id, memory: args.memory });
return `Memory saved.`;
return `Core memory saved.`;
},
};
}
@@ -0,0 +1,63 @@
import { z } from "zod";
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import updateWorkspace from "@/src/functions/backend/db/workspaces/update-workspace";
import type { HarnessTool } from "../../types";
type Params = {
user_id: number | string;
/**
* The user's workspaces the coordinator may change.
*/
workspaces: BUN_SQLITE_COBBLE_WORKSPACES[];
/**
* The workspace the conversation is in: the one changed when no id is
* given.
*/
workspace_id?: number | string | null;
};
type Args = {
workspace_id?: number;
name?: string;
description?: string;
context?: string;
};
/**
* Lets the coordinator change a workspace's record: its name, its
* one-line description and the context every bot working there gets.
*/
export default function updateWorkspaceTool({ user_id, workspaces, workspace_id }: Params): HarnessTool<Args> {
const list = workspaces.map((w) => `${w.id}: ${w.name}`).join("; ");
return {
name: "update_workspace",
description: `Change a workspace: its name, its one-line description, or its context (markdown every bot working there gets in its prompt). Only the fields you give change; \`context\` replaces the whole text, so keep what still applies. Use it when the user asks, or to record something every bot in that workspace must know.${workspace_id ? " Leave out workspace_id for this workspace." : ""} Workspaces: ${list}.`,
schema: z.object({
workspace_id: z.number().int().optional(),
name: z.string().trim().min(1).max(60).optional(),
description: z.string().trim().max(240).optional(),
context: z.string().trim().max(AppData.PromptMaxLength).optional(),
}),
run: async ({ args }) => {
const target = workspaces.find((w) => w.id == (args.workspace_id || workspace_id));
if (!target?.id) {
throw new Error(`No workspace has id ${args.workspace_id ?? "(none given)"}. Workspaces: ${list}.`);
}
if (args.name === undefined && args.description === undefined && args.context === undefined) {
throw new Error(`Give a name, a description or a context to change.`);
}
await updateWorkspace({
user_id,
workspace_id: target.id,
data: { name: args.name, description: args.description, system_prompt: args.context },
});
return `${args.name || target.name} is saved. Bots there get the change from their next run.`;
},
};
}
@@ -12,10 +12,14 @@ import writeComputerEnv from "./write-computer-env";
type Params = {
computer_id: number | string;
/**
* Screenshots are read with this bot's media reading model, and only
* workspaces it shares with the computer add envs.
* Screenshots are read with this bot's media reading model.
*/
bot_id?: number | string;
/**
* The workspace the run is in: its envs apply, and its models read
* screenshots.
*/
workspace_id?: number | string | null;
signal?: AbortSignal;
};
@@ -45,6 +49,7 @@ export type OpenComputer = {
export default async function openComputerTools({
computer_id,
bot_id,
workspace_id,
signal,
}: Params): Promise<OpenComputer> {
const target = await grabComputerMcpUrl({ computer_id });
@@ -53,7 +58,7 @@ export default async function openComputerTools({
throw new Error(`Computer #${computer_id} has no container to connect to.`);
}
const envs = await grabComputerEnvs({ computer_id, bot_id });
const envs = await grabComputerEnvs({ computer_id, workspace_id });
const paths = await writeComputerEnv({ base_url: target.base_url, envs, signal });
const secrets = envs.filter((env) => env.secret);
const redact = (text: string) => redactSecrets({ text, secrets });
@@ -71,6 +76,7 @@ export default async function openComputerTools({
focus: "This is a screenshot of a computer screen. Describe the page layout, text, buttons and form fields, and any errors or popups.",
signal,
bot_id,
workspace_id,
}),
});
@@ -9,7 +9,10 @@ import type { HarnessTool } from "../../types";
import runComputerCommand from "./run-computer-command";
type Params = {
bot_id: number | string;
/**
* The workspace the run is in: its envs apply.
*/
workspace_id?: number | string | null;
/**
* The bot's computers, the current one first (`grabBotComputers`).
*/
@@ -27,7 +30,7 @@ type Args = {
* so the bot can tell the user exactly which computer holds what.
*/
export default function searchComputersTool({
bot_id,
workspace_id,
computers,
}: Params): HarnessTool<Args> {
return {
@@ -53,7 +56,7 @@ export default function searchComputersTool({
timeout_ms: AppData.ComputerSearchTimeoutMs,
signal,
}),
grabComputerEnvs({ computer_id: computer.id || 0, bot_id }),
grabComputerEnvs({ computer_id: computer.id || 0, workspace_id }),
]);
const output = redactSecrets({

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