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 -2
View File
@@ -6,7 +6,8 @@ describe("composeBotPrompt", () => {
expect(
composeBotPrompt({
system_prompt: "# You are Scout\n\nFind things.",
memory: "- The user prefers short replies.",
memory: "- Cite sources.",
user_memory: "- The user prefers short replies.",
workspace: {
name: "Launch",
description: "The spring launch.",
@@ -22,7 +23,8 @@ describe("composeBotPrompt", () => {
"# You are Scout\n\nFind things.",
`# Workspace: Launch\n\nYou're working in the workspace "Launch". Follow its context and your instructions for it below, over your general habits where they differ.\n\nThe spring launch.\n\nShip by Friday.`,
"# Your instructions in Launch\n\nOwn the changelog.",
"# Memory\n\n- The user prefers short replies.",
"# Memory\n\n- Cite sources.",
"# Memory about the user\n\n- The user prefers short replies.",
"# Memory in Launch\n\n- Marketing signs off on copy.",
].join("\n\n"),
);
@@ -33,6 +35,7 @@ describe("composeBotPrompt", () => {
composeBotPrompt({
system_prompt: "Core.",
memory: " ",
user_memory: "",
workspace_bot: { system_prompt: "Ignored." },
}),
).toBe("Core.");
+12 -3
View File
@@ -12,6 +12,10 @@ type WorkspaceBotPrompt = {
type Params = {
system_prompt?: string | null;
memory?: string | null;
/**
* What the bot remembers about the user it's working with.
*/
user_memory?: string | null;
/**
* The workspace the bot is working in, if any.
*/
@@ -25,13 +29,14 @@ type Params = {
/**
* The full prompt a bot runs with (markdown): its own core instructions,
* then, in a workspace, the workspace's context and the bot's
* instructions there, then its core memory and its memory there. Empty
* parts are left out; the workspace heading always shows, so the bot
* knows where it is.
* instructions there, then its core memory, its memory about the user and
* its memory in the workspace. Empty parts are left out; the workspace
* heading always shows, so the bot knows where it is.
*/
export default function composeBotPrompt({
system_prompt,
memory,
user_memory,
workspace,
workspace_bot,
}: Params) {
@@ -63,6 +68,10 @@ export default function composeBotPrompt({
parts.push(`# Memory\n\n${memory.trim()}`);
}
if (user_memory?.trim()) {
parts.push(`# Memory about the user\n\n${user_memory.trim()}`);
}
if (workspace && workspace_bot?.memory?.trim()) {
parts.push(`# Memory in ${name}\n\n${workspace_bot.memory.trim()}`);
}
+16
View File
@@ -0,0 +1,16 @@
import type { CrewAccessParams, SqlCondition } from "./types";
/**
* The bots a user chats with in their own chat (outside a workspace): the
* super admin all of them, an admin every worker, anyone else only the
* workers assigned to them. The coordinator is the super admin's alone.
*/
export default function chatBotsSql({ role, user_id, alias = "b" }: CrewAccessParams): SqlCondition {
if (role == "super_admin") return { sql: `1`, values: [] };
if (role == "admin") return { sql: `${alias}.kind != 'coordinator'`, values: [] };
return {
sql: `(${alias}.kind != 'coordinator' AND ${alias}.id IN (SELECT bot_id FROM bot_users WHERE user_id = ?))`,
values: [Number(user_id)],
};
}
+22
View File
@@ -0,0 +1,22 @@
import type { CrewAccessParams, SqlCondition } from "./types";
/**
* The computers a user opens and sees: super admins and admins every one,
* anyone else only those of the bots they chat with (the computers of
* their workspaces, and those their assigned workers were given).
*/
export default function computersSql({ role, user_id, alias = "c" }: CrewAccessParams): SqlCondition {
if (role != "member") return { sql: `1`, values: [] };
return {
sql: `${alias}.id IN (
SELECT wc.computer_id FROM workspace_computers wc
JOIN workspace_users wu ON wu.workspace_id = wc.workspace_id AND wu.user_id = ?
UNION
SELECT bc.computer_id FROM bot_computers bc
JOIN bot_users bu ON bu.bot_id = bc.bot_id AND bu.user_id = ?
JOIN bots b ON b.id = bc.bot_id AND b.kind != 'coordinator'
)`,
values: [Number(user_id), Number(user_id)],
};
}
@@ -0,0 +1,75 @@
import { Database } from "bun:sqlite";
import { beforeAll, describe, expect, test } from "bun:test";
import chatBotsSql from "./chat-bots-sql";
import computersSql from "./computers-sql";
import workspaceBotsSql from "./workspace-bots-sql";
import workspacesSql from "./workspaces-sql";
import type { SqlCondition } from "./types";
const db = new Database(":memory:");
beforeAll(() => {
db.run(`CREATE TABLE bots (id INTEGER PRIMARY KEY, kind TEXT)`);
db.run(`CREATE TABLE workspaces (id INTEGER PRIMARY KEY)`);
db.run(`CREATE TABLE bot_users (bot_id INTEGER, user_id INTEGER)`);
db.run(`CREATE TABLE workspace_users (workspace_id INTEGER, user_id INTEGER)`);
db.run(`CREATE TABLE workspace_bots (workspace_id INTEGER, bot_id INTEGER)`);
db.run(`INSERT INTO bots VALUES (1, 'coordinator'), (2, 'worker'), (3, 'worker')`);
db.run(`INSERT INTO workspaces VALUES (10), (11)`);
db.run(`INSERT INTO bot_users VALUES (2, 7), (1, 7)`);
db.run(`INSERT INTO workspace_users VALUES (11, 7)`);
db.run(`INSERT INTO workspace_bots VALUES (11, 3)`);
db.run(`CREATE TABLE computers (id INTEGER PRIMARY KEY)`);
db.run(`CREATE TABLE workspace_computers (workspace_id INTEGER, computer_id INTEGER)`);
db.run(`CREATE TABLE bot_computers (bot_id INTEGER, computer_id INTEGER)`);
db.run(`INSERT INTO computers VALUES (20), (21), (22), (23)`);
db.run(`INSERT INTO workspace_computers VALUES (11, 21), (10, 23)`);
db.run(`INSERT INTO bot_computers VALUES (1, 20), (2, 22)`);
});
function ids({ table, alias, condition }: { table: string; alias: string; condition: SqlCondition }) {
return db
.query(`SELECT ${alias}.id FROM ${table} ${alias} WHERE ${condition.sql} ORDER BY ${alias}.id`)
.all(...condition.values)
.map((row) => (row as { id: number }).id);
}
describe("chatBotsSql", () => {
test("the super admin chats with every bot, the coordinator too", () => {
expect(ids({ table: "bots", alias: "b", condition: chatBotsSql({ role: "super_admin", user_id: 1 }) })).toEqual([1, 2, 3]);
});
test("an admin chats with every worker, never the coordinator", () => {
expect(ids({ table: "bots", alias: "b", condition: chatBotsSql({ role: "admin", user_id: 5 }) })).toEqual([2, 3]);
});
test("a member only with workers assigned to them, even if the coordinator was", () => {
expect(ids({ table: "bots", alias: "b", condition: chatBotsSql({ role: "member", user_id: 7 }) })).toEqual([2]);
expect(ids({ table: "bots", alias: "b", condition: chatBotsSql({ role: "member", user_id: 8 }) })).toEqual([]);
});
});
describe("workspacesSql", () => {
test("managers reach every workspace, members only theirs", () => {
expect(ids({ table: "workspaces", alias: "w", condition: workspacesSql({ role: "admin", user_id: 5 }) })).toEqual([10, 11]);
expect(ids({ table: "workspaces", alias: "w", condition: workspacesSql({ role: "member", user_id: 7 }) })).toEqual([11]);
expect(ids({ table: "workspaces", alias: "w", condition: workspacesSql({ role: "member", user_id: 8 }) })).toEqual([]);
});
});
describe("workspaceBotsSql", () => {
test("the coordinator joins the workspace's workers for the super admin only", () => {
expect(ids({ table: "bots", alias: "b", condition: workspaceBotsSql({ role: "super_admin", workspace_id: 11 }) })).toEqual([1, 3]);
expect(ids({ table: "bots", alias: "b", condition: workspaceBotsSql({ role: "admin", workspace_id: 11 }) })).toEqual([3]);
expect(ids({ table: "bots", alias: "b", condition: workspaceBotsSql({ role: "member", workspace_id: 11 }) })).toEqual([3]);
expect(ids({ table: "bots", alias: "b", condition: workspaceBotsSql({ role: "member", workspace_id: 10 }) })).toEqual([]);
});
});
describe("computersSql", () => {
test("managers see every computer, members those of their bots, never the coordinator's", () => {
expect(ids({ table: "computers", alias: "c", condition: computersSql({ role: "admin", user_id: 5 }) })).toEqual([20, 21, 22, 23]);
expect(ids({ table: "computers", alias: "c", condition: computersSql({ role: "member", user_id: 7 }) })).toEqual([21, 22]);
expect(ids({ table: "computers", alias: "c", condition: computersSql({ role: "member", user_id: 8 }) })).toEqual([]);
});
});
+18
View File
@@ -0,0 +1,18 @@
import type { CrewRole } from "../rbac/crew-role";
/**
* A SQL condition with its values, to splice into a WHERE.
*/
export type SqlCondition = {
sql: string;
values: (string | number)[];
};
export type CrewAccessParams = {
role: CrewRole;
user_id: number | string;
/**
* The table alias the condition reads.
*/
alias?: string;
};
@@ -0,0 +1,20 @@
import type { CrewAccessParams, SqlCondition } from "./types";
type Params = Pick<CrewAccessParams, "role" | "alias"> & {
workspace_id: number | string;
};
/**
* The bots of one workspace a user chats with there (check the user is in
* the workspace first, `workspacesSql`): its workers, plus for the super
* admin the coordinator, which works in every workspace.
*/
export default function workspaceBotsSql({ role, workspace_id, alias = "b" }: Params): SqlCondition {
const members = `${alias}.id IN (SELECT bot_id FROM workspace_bots WHERE workspace_id = ?)`;
if (role == "super_admin") {
return { sql: `(${alias}.kind = 'coordinator' OR ${members})`, values: [Number(workspace_id)] };
}
return { sql: `(${alias}.kind != 'coordinator' AND ${members})`, values: [Number(workspace_id)] };
}
+14
View File
@@ -0,0 +1,14 @@
import type { CrewAccessParams, SqlCondition } from "./types";
/**
* The workspaces a user works in: super admins and admins every one,
* anyone else only those they were added to.
*/
export default function workspacesSql({ role, user_id, alias = "w" }: CrewAccessParams): SqlCondition {
if (role != "member") return { sql: `1`, values: [] };
return {
sql: `${alias}.id IN (SELECT workspace_id FROM workspace_users WHERE user_id = ?)`,
values: [Number(user_id)],
};
}
+10
View File
@@ -0,0 +1,10 @@
import { describe, expect, test } from "bun:test";
import parseIdList from "./parse-id-list";
describe("parseIdList", () => {
test("reads comma separated ids and drops the rest", () => {
expect(parseIdList({ ids: "3,10, 7" })).toEqual([3, 10, 7]);
expect(parseIdList({ ids: "x,0,-2,4" })).toEqual([4]);
expect(parseIdList({ ids: null })).toEqual([]);
});
});
+16
View File
@@ -0,0 +1,16 @@
type Params = {
/**
* Ids, comma separated (a `GROUP_CONCAT`).
*/
ids?: string | null;
};
/**
* Comma separated ids as numbers, leaving out anything that isn't one.
*/
export default function parseIdList({ ids }: Params): number[] {
return (ids || "")
.split(",")
.map((id) => Number(id.trim()))
.filter((id) => Number.isInteger(id) && id > 0);
}
+15
View File
@@ -0,0 +1,15 @@
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
import crewRole from "./crew-role";
type Params = {
user_types?: Pick<BUN_SQLITE_COBBLE_USER_TYPES, "user_type">[] | null;
};
/**
* Whether the user creates, edits and deletes workers and workspaces, and
* assigns them to users: super admins and admins. Only the super admin
* edits the coordinator.
*/
export default function canManageCrew({ user_types }: Params): boolean {
return crewRole({ user_types }) != "member";
}
+23
View File
@@ -0,0 +1,23 @@
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
type Params = {
user_types?: Pick<BUN_SQLITE_COBBLE_USER_TYPES, "user_type">[] | null;
};
export type CrewRole = "super_admin" | "admin" | "member";
/**
* How a user reaches the crew. `super_admin`: everything, the coordinator
* included (no one else ever reaches it). `admin`: every worker and
* workspace, and hands out access. `member` (everyone else): only the
* workers assigned to them (`bot_users`) and the workspaces they were
* added to (`workspace_users`).
*/
export default function crewRole({ user_types }: Params): CrewRole {
const types = new Set(user_types?.map((ut) => ut.user_type));
if (types.has("super_admin")) return "super_admin";
if (types.has("admin")) return "admin";
return "member";
}
+2 -1
View File
@@ -5,4 +5,5 @@ export { default as canManageUserTypes } from "./can-manage-user-types";
export { default as canRevokeUser } from "./can-revoke-user";
export { default as canUpdateUser } from "./can-update-user";
export { default as filterAssignableUserTypes } from "./filter-assignable-user-types";
export { default as isPassThroughUser } from "./is-pass-through-user";
export { default as isPassThroughUser } from "./is-pass-through-user";export { default as crewRole } from "./crew-role";
export { default as canManageCrew } from "./can-manage-crew";