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:
@@ -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,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()}`);
|
||||
}
|
||||
|
||||
@@ -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)],
|
||||
};
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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)] };
|
||||
}
|
||||
@@ -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)],
|
||||
};
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user