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:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user