Share one crew across the install, keep user memory per user

Bots, workspaces and computers now belong to the install. Users reach
workers only through bot_users (assigned by a super admin or admin) and
workspace_users; the coordinator and main computer are one per install,
and only the super admin sees the coordinator. Conversations are per
user (bot_sessions.user_id), jobs run for the session's user, and live
updates go only to who should see them.

A bot's core memory is now general knowledge shared by every user it
works for; what it learns about a user lives in bot_user_memories.

One-off scripts move existing installs over: move-bot-memory-to-owners
(run first), then share-crew.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-19 11:37:14 +01:00
co-authored by Claude Opus 5.5
parent 4dd34ed251
commit e45d54d30b
133 changed files with 1854 additions and 436 deletions
+9 -1
View File
@@ -2,8 +2,12 @@ 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";
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
type Params = {
/**
* Who creates it (a super admin or admin). Bots belong to the install.
*/
user_id: number | string;
name: string;
role?: string;
@@ -15,11 +19,15 @@ type Params = {
};
/**
* Adds a worker bot to the user's crew and returns its id. It has no
* Adds a worker bot to the install and returns its id. Nobody but super
* admins and admins reaches it until it's assigned to a user or added to
* a workspace. 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) {
await assertCrewManager({ user_id });
assertPromptLength({ label: "Instructions", text: system_prompt });
const res = await BunSQLite.insert<
@@ -2,6 +2,7 @@ import BunSQLite from "@moduletrace/bun-sqlite";
import deleteMedia from "@/src/functions/backend/db/media/delete-media";
import AppError from "@/src/utils/app-error";
import grabUserBot from "./grab-user-bot";
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
type Params = {
user_id: number | string;
@@ -14,6 +15,8 @@ type Params = {
* its row. The coordinator can't be deleted.
*/
export default async function deleteBot({ user_id, bot_id }: Params) {
await assertCrewManager({ user_id });
const bot = await grabUserBot({ bot_id, user_id });
if (!bot?.id) {
@@ -4,12 +4,16 @@ import { CoordinatorBotName } from "@/src/dict/bot-dict";
import { CoordinatorSystemPrompt } from "@/src/data/coordinator-system-prompt";
type Params = {
/**
* Who it's created by when it's missing (the super admin).
*/
user_id: number | string;
};
/**
* Returns the id of the user's coordinator, creating it first (with the
* default `CoordinatorSystemPrompt`) if the user has none. A
* Returns the id of the install's one coordinator (the super admin's
* alone), creating it first (with the default `CoordinatorSystemPrompt`)
* if there is none. A
* coordinator without a prompt gets the default; an edited prompt is never
* overwritten. Safe to call on every visit.
*/
@@ -22,7 +26,6 @@ export default async function ensureCoordinatorBot({ user_id }: Params) {
table: "bots",
query: {
query: {
user_id: { value: user_id },
kind: { value: "coordinator" },
},
order: { field: "id", strategy: "ASC" },
@@ -12,21 +12,28 @@ type Params = {
* Only a task started from this session.
*/
session_id?: number | string | null;
/**
* Only a task in this user's conversations.
*/
user_id?: number | string | null;
};
/**
* The bot's most recent active task (in one session, when given), its
* The bot's most recent active task (in one session or one user's
* conversations, 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,
user_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'
${session_id ? `AND session_id = ?` : ""}
${user_id ? `AND session_id IN (SELECT id FROM bot_sessions WHERE user_id = ?)` : ""}
ORDER BY created_at DESC, id DESC LIMIT 1`,
values: session_id ? [bot_id, session_id] : [bot_id],
values: [bot_id, ...(session_id ? [session_id] : []), ...(user_id ? [user_id] : [])],
});
const task = task_res.singleRes || task_res.payload?.[0];
@@ -1,10 +1,14 @@
import composeBotPrompt from "@/src/utils/compose-bot-prompt";
import AppError from "@/src/utils/app-error";
import grabWorkspaceBot from "../workspaces/grab-workspace-bot";
import grabBotUserMemory from "./grab-bot-user-memory";
import grabUserBot from "./grab-user-bot";
type Params = {
bot_id: number | string;
/**
* The user the bot works with: only their memory joins the prompt.
*/
user_id: number | string;
/**
* The workspace the bot is working in. Its context and the bot's
@@ -15,8 +19,8 @@ type Params = {
/**
* 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.
* core instructions and memory, what it remembers about this user, and in
* a workspace that workspace's context and what the bot has for it.
*/
export default async function grabBotPrompt({ bot_id, user_id, workspace_id }: Params) {
const bot = await grabUserBot({ bot_id, user_id });
@@ -36,6 +40,7 @@ export default async function grabBotPrompt({ bot_id, user_id, workspace_id }: P
return composeBotPrompt({
system_prompt: bot.system_prompt,
memory: bot.memory,
user_memory: await grabBotUserMemory({ bot_id: bot.id, user_id }),
workspace: found?.workspace,
workspace_bot: found?.membership,
});
@@ -0,0 +1,20 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOT_USER_MEMORIES } from "@/db/types/db";
type Params = {
bot_id: number | string;
user_id: number | string;
};
/**
* What a bot remembers about one user, or null when it has nothing yet.
* The caller checks the user may talk to the bot.
*/
export default async function grabBotUserMemory({ bot_id, user_id }: Params) {
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_USER_MEMORIES>({
sql: `SELECT memory FROM bot_user_memories WHERE bot_id = ? AND user_id = ? LIMIT 1`,
values: [bot_id, user_id],
});
return res.payload?.[0]?.memory || null;
}
+19 -17
View File
@@ -1,26 +1,33 @@
import type { BotWithLastMessage } from "@/src/types";
import BunSQLite from "@moduletrace/bun-sqlite";
import chatBotsSql from "@/src/utils/crew-access/chat-bots-sql";
import workspaceBotsSql from "@/src/utils/crew-access/workspace-bots-sql";
import grabCrewRole from "../access/grab-crew-role";
type Params = {
user_id: number | string;
/**
* A workspace's crew instead: the coordinator and the bots in it, with
* their conversations in that workspace.
* A workspace's crew instead: the bots the user chats with there
* (check they're in the workspace first), with their conversations
* there.
*/
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 conversation's unread
* flag, latest activity and latest message: in its own chat, or in the
* workspace when one is given.
* The bots a user chats with, for the rail: in their own chat
* (`chatBotsSql`) or in one workspace (`workspaceBotsSql`). The
* coordinator is pinned first, then workers by latest activity. Each row
* carries the user's own conversation there: its unread flag, latest
* activity and latest message.
*/
export default async function grabBots({
user_id,
workspace_id,
}: Params): Promise<BotWithLastMessage[]> {
const scope = workspace_id ? Number(workspace_id) : null;
const role = await grabCrewRole({ user_id });
const access = scope ? workspaceBotsSql({ role, workspace_id: scope }) : chatBotsSql({ role, user_id });
let sql = ``;
@@ -33,27 +40,22 @@ export default async function grabBots({
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 += ` FROM bot_sessions WHERE workspace_id IS ? AND user_id = ? 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 AND workspace_id IS ?\n`;
sql += ` ORDER BY created_at DESC, id DESC\n`;
sql += ` SELECT m.id FROM bot_messages m JOIN bot_sessions s ON s.id = m.session_id\n`;
sql += ` WHERE m.bot_id = b.id AND m.workspace_id IS ? AND s.user_id = ?\n`;
sql += ` ORDER BY m.created_at DESC, m.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 += `WHERE ${access.sql}\n`;
sql += `ORDER BY\n`;
sql += ` CASE WHEN b.kind = 'coordinator' THEN 0 ELSE 1 END,\n`;
sql += ` COALESCE(lm.created_at, t.last_activity_at, b.created_at) DESC\n`;
const res = await BunSQLite.sql<BotWithLastMessage>({
sql,
values: [scope, scope, user_id, ...(scope ? [scope] : [])],
values: [scope, user_id, scope, user_id, ...access.values],
});
return res.payload || [];
@@ -0,0 +1,26 @@
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import BunSQLite from "@moduletrace/bun-sqlite";
import chatBotsSql from "@/src/utils/crew-access/chat-bots-sql";
import grabCrewRole from "../access/grab-crew-role";
type Params = {
bot_id: number | string;
user_id: number | string;
};
/**
* A bot only if the user chats with it in its own chat (`chatBotsSql`).
*/
export default async function grabChatBot({
bot_id,
user_id,
}: Params): Promise<BUN_SQLITE_COBBLE_BOTS | null> {
const access = chatBotsSql({ role: await grabCrewRole({ user_id }), user_id });
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT b.* FROM bots b WHERE b.id = ? AND ${access.sql} LIMIT 1`,
values: [bot_id, ...access.values],
});
return res.payload?.[0] || null;
}
+13 -3
View File
@@ -1,5 +1,7 @@
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import BunSQLite from "@moduletrace/bun-sqlite";
import chatBotsSql from "@/src/utils/crew-access/chat-bots-sql";
import grabCrewRole from "../access/grab-crew-role";
type Params = {
bot_id: number | string;
@@ -7,15 +9,23 @@ type Params = {
};
/**
* A bot only if it belongs to the given owner.
* A bot only if the user reaches it somewhere: in its own chat
* (`chatBotsSql`) or in a workspace they're in. Super admins and admins
* reach every worker; only the super admin reaches the coordinator.
*/
export default async function grabUserBot({
bot_id,
user_id,
}: Params): Promise<BUN_SQLITE_COBBLE_BOTS | null> {
const access = chatBotsSql({ role: await grabCrewRole({ user_id }), user_id });
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT * FROM bots WHERE id = ? AND user_id = ? LIMIT 1`,
values: [bot_id, user_id],
sql: `SELECT b.* FROM bots b WHERE b.id = ? AND (${access.sql} OR (b.kind != 'coordinator' AND b.id IN (
SELECT wb.bot_id FROM workspace_bots wb
JOIN workspace_users wu ON wu.workspace_id = wb.workspace_id AND wu.user_id = ?
JOIN workspaces w ON w.id = wb.workspace_id AND w.archived = 0
))) LIMIT 1`,
values: [bot_id, ...access.values, user_id],
});
return res.payload?.[0] || null;
@@ -0,0 +1,44 @@
import BunSQLite from "@moduletrace/bun-sqlite";
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;
/**
* The whole new memory about this user (markdown), replacing the old one.
*/
memory: string;
};
/**
* Replaces what a bot remembers about one user. Only that user's runs see
* it; the bot's core memory stays the same for everyone.
*/
export default async function updateBotUserMemory({
user_id,
bot_id,
memory,
}: Params) {
assertPromptLength({ label: "Memory", text: memory });
const bot = await grabUserBot({ bot_id, user_id });
if (!bot?.id) {
throw new AppError(`Bot not found.`);
}
const now = Date.now();
const res = await BunSQLite.sql({
sql: `INSERT INTO bot_user_memories (created_at, updated_at, bot_id, user_id, memory)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(bot_id, user_id) DO UPDATE SET updated_at = excluded.updated_at, memory = excluded.memory`,
values: [now, now, bot.id, user_id, memory],
});
if (!res.success) {
throw new Error(`Couldn't save the bot's memory: ${res.error}`);
}
}
@@ -3,6 +3,7 @@ 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";
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
type Params = {
user_id: number | string;
@@ -19,6 +20,8 @@ type Params = {
* change. Returns the saved bot.
*/
export default async function updateBot({ user_id, bot_id, name, role, hue, system_prompt }: Params) {
await assertCrewManager({ user_id });
assertPromptLength({ label: "Instructions", text: system_prompt });
const bot = await grabUserBot({ bot_id, user_id });