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
@@ -5,6 +5,10 @@ import type {
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
/**
* The user the conversation is with.
*/
user_id: number | string;
bot_id: number | string;
workspace_id?: number | string | null;
/**
@@ -14,10 +18,11 @@ type Params = {
};
/**
* Starts an active session in a conversation. Callers close the old one
* Starts an active session in a user's conversation. Callers close the old one
* first (`startBotSession`).
*/
export default async function createBotSession({
user_id,
bot_id,
workspace_id,
previous_session_id,
@@ -25,6 +30,7 @@ export default async function createBotSession({
const now = Date.now();
const row: BUN_SQLITE_COBBLE_BOT_SESSIONS = {
user_id: Number(user_id),
bot_id: Number(bot_id),
workspace_id: workspace_id ? Number(workspace_id) : undefined,
previous_session_id: previous_session_id || undefined,
@@ -6,6 +6,10 @@ import grabActiveBotSession from "./grab-active-bot-session";
import startBotSession from "./start-bot-session";
type Params = {
/**
* The user the conversation is with.
*/
user_id: number | string;
bot_id: number | string;
workspace_id?: number | string | null;
/**
@@ -17,16 +21,17 @@ type Params = {
};
/**
* The conversation's active session, started first when there is none
* The user's 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({
user_id,
bot_id,
workspace_id,
rollover,
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_SESSIONS> {
const active = await grabActiveBotSession({ bot_id, workspace_id });
const active = await grabActiveBotSession({ user_id, bot_id, workspace_id });
if (active?.id) {
if (!rollover) return active;
@@ -43,18 +48,19 @@ export default async function ensureBotSession({
return count < AppData.BotSessionMaxMessages
? active
: await startBotSession({ bot_id, workspace_id });
: await startBotSession({ user_id, 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 ?
sql: `SELECT id FROM bot_sessions WHERE user_id = ? AND 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],
values: [user_id, bot_id, workspace_id ? Number(workspace_id) : null],
})
).payload?.[0];
return await createBotSession({
user_id,
bot_id,
workspace_id,
previous_session_id: latest?.id || null,
@@ -2,6 +2,10 @@ import type { BUN_SQLITE_COBBLE_BOT_SESSIONS } from "@/db/types/db";
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
/**
* The user the conversation is with.
*/
user_id: number | string;
bot_id: number | string;
/**
* Null or undefined: the bot's own chat.
@@ -10,18 +14,19 @@ type Params = {
};
/**
* The active session of one conversation (a bot in its own chat or in one
* workspace), or null when it has none yet.
* The active session of one user's conversation (with a bot in its own
* chat or in one workspace), or null when it has none yet.
*/
export default async function grabActiveBotSession({
user_id,
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'
WHERE user_id = ? AND bot_id = ? AND workspace_id IS ? AND status = 'active'
ORDER BY id DESC LIMIT 1`,
values: [bot_id, workspace_id ? Number(workspace_id) : null],
values: [user_id, bot_id, workspace_id ? Number(workspace_id) : null],
});
return res.payload?.[0] || null;
@@ -3,6 +3,10 @@ import { AppData } from "@/src/data/app-data";
import type { BotSessionListItem } from "@/src/types";
type Params = {
/**
* The user the conversation is with.
*/
user_id: number | string;
bot_id: number | string;
workspace_id?: number | string | null;
/**
@@ -13,11 +17,12 @@ type Params = {
};
/**
* A conversation's sessions for the sessions modal, latest activity first,
* A user's 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({
user_id,
bot_id,
workspace_id,
query,
@@ -33,7 +38,7 @@ export default async function grabBotSessions({
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 += `WHERE s.user_id = ? AND 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) {
@@ -47,6 +52,7 @@ export default async function grabBotSessions({
const res = await BunSQLite.sql<BotSessionListItem>({
sql,
values: [
user_id,
bot_id,
workspace_id ? Number(workspace_id) : null,
...(like ? [like, like, like] : []),
@@ -24,7 +24,7 @@ type Params = {
* `/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.
* Null when the user can't chat with the bot there.
*/
export default async function grabConversationProps({
user_id,
@@ -38,7 +38,7 @@ export default async function grabConversationProps({
if (!conversation) return null;
const scope = conversation.workspace_id;
const session = await ensureBotSession({ bot_id: conversation.bot.id, workspace_id: scope });
const session = await ensureBotSession({ user_id, bot_id: conversation.bot.id, workspace_id: scope });
if (session.unread) {
await markBotSessionRead({ session_id: session.id!, user_id });
@@ -71,7 +71,7 @@ export default async function grabConversationProps({
workspace_id: scope || undefined,
})
).catch(() => null),
grabBotComputers({ bot_id: bot.id, user_id, workspace_id: scope }),
grabBotComputers({ bot_id: bot.id, workspace_id: scope }),
grabActiveBotTask({ bot_id: bot.id, session_id: session.id }),
]);
@@ -7,15 +7,14 @@ type Params = {
};
/**
* A session only if its bot belongs to the given owner.
* A session only if it's the given user's own.
*/
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`,
sql: `SELECT s.* FROM bot_sessions s WHERE s.id = ? AND s.user_id = ? LIMIT 1`,
values: [session_id, user_id],
});
@@ -1,6 +1,6 @@
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 grabChatBot from "../bots/grab-chat-bot";
import grabWorkspaceBot from "../workspaces/grab-workspace-bot";
type Params = {
@@ -19,9 +19,9 @@ export type UserConversation = {
};
/**
* 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.
* One of the user's conversations: a bot they chat with in its own chat
* (`grabChatBot`) or in a workspace they're in (`grabWorkspaceBot`).
* Throws when they can't.
*/
export default async function grabUserConversation({
user_id,
@@ -42,7 +42,7 @@ export default async function grabUserConversation({
};
}
const bot = await grabUserBot({ bot_id, user_id });
const bot = await grabChatBot({ bot_id, user_id });
if (!bot?.id) {
throw new AppError(`Bot not found`);
@@ -6,12 +6,12 @@ type Params = {
};
/**
* Clears a session's unread flag when its owner has the conversation open.
* Clears a session's unread flag when its user 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 = ?)`,
WHERE id = ? AND unread = 1 AND user_id = ?`,
values: [session_id, user_id],
});
}
@@ -29,15 +29,15 @@ export default async function reopenBotSession({
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'
sql: `DELETE FROM bot_sessions WHERE user_id = ? AND 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],
values: [session.user_id!, 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],
WHERE user_id = ? AND bot_id = ? AND workspace_id IS ? AND status = 'active'`,
values: [now, session.user_id!, session.bot_id!, workspace_id],
});
await BunSQLite.sql({
@@ -4,6 +4,10 @@ import createBotSession from "./create-bot-session";
import grabActiveBotSession from "./grab-active-bot-session";
type Params = {
/**
* The user the conversation is with.
*/
user_id: number | string;
bot_id: number | string;
workspace_id?: number | string | null;
};
@@ -14,10 +18,11 @@ type Params = {
* An active session with no messages yet is kept as it is.
*/
export default async function startBotSession({
user_id,
bot_id,
workspace_id,
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_SESSIONS> {
const active = await grabActiveBotSession({ bot_id, workspace_id });
const active = await grabActiveBotSession({ user_id, bot_id, workspace_id });
if (active?.id) {
const has_messages = (
@@ -36,6 +41,7 @@ export default async function startBotSession({
}
return await createBotSession({
user_id,
bot_id,
workspace_id,
previous_session_id: active?.id || null,