diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index ab3ad15..bf00631 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -122,6 +122,7 @@ import { isValidAvatarValue, nextCronDateAcrossStrict, } from "@rakazo/core"; +import { readProjectSummary } from "@rakazo/core/node/project-summary"; import type { PrismaClient, ThreadEvents } from "@rakazo/db"; import { BotSectionNameConflictError, @@ -170,6 +171,7 @@ import { savePushSubscription, selectSpaceModelPreference, selectSpaceVoicePreference, + syncProjectMembers, updateNotificationSettings, updateSpaceForOwner, wireProjectWorkspace, @@ -777,14 +779,17 @@ export function createRouter(deps: RouterDeps) { }, } : null); - if (input.goal?.trim()) { + // Without a stated goal, the linked folder's README or manifest says what this is. + const goal = + input.goal?.trim() || (workspacePath ? await readProjectSummary(workspacePath) : null); + if (goal) { await deps.prisma.memoryDocument.create({ data: { spaceId: space.id, userId: context.actor.userId, scope: "user", path: "PROJECT_GOAL.md", - content: input.goal.trim(), + content: goal, }, }); } @@ -805,7 +810,7 @@ export function createRouter(deps: RouterDeps) { canDelete: true, canEdit: true, workspace, - goal: input.goal?.trim() ?? null, + goal, bots: bots.map(spaceBotDto), groups: groups.map(spaceGroupDto), externalConversations: [], @@ -1644,6 +1649,10 @@ export function createRouter(deps: RouterDeps) { input.name, ); } + await syncProjectMembers(deps.prisma, { + userId: context.actor.userId, + libraryBotId: existing.id, + }); const bots = await repos.listBots(context.actor); const bot = bots.find((b) => b.id === input.botId); if (!bot) throw new IsolationError(); diff --git a/apps/mobile/app/index.tsx b/apps/mobile/app/index.tsx index fcb84a0..361d4b4 100644 --- a/apps/mobile/app/index.tsx +++ b/apps/mobile/app/index.tsx @@ -45,6 +45,7 @@ import { rpc, selectedSpaceId, selectInitialSpace, + selectLibrarySpace, selectSpace, } from "../lib/api"; import { mobileShellIconSizes, mobileTokens, resolveMobileAppearance } from "../lib/appearance"; @@ -411,6 +412,7 @@ export default function Home() { return; creatingBotRef.current = true; try { + await selectLibrarySpace(); // Authoritative roster so a slow home fetch does not treat later bots as first. // A failed list is unknown — use the delayed path rather than assuming first. const existing = await rpc("bots/list").catch(() => null); diff --git a/apps/mobile/app/new.tsx b/apps/mobile/app/new.tsx index 90dbb17..535cca6 100644 --- a/apps/mobile/app/new.tsx +++ b/apps/mobile/app/new.tsx @@ -9,7 +9,7 @@ import { Stack, useRouter } from "expo-router"; import { useState } from "react"; import { Pressable, ScrollView, Text, TextInput } from "react-native"; import { ComputerModePicker } from "../components/computer-mode-picker"; -import { type MobileBot, rpc } from "../lib/api"; +import { type MobileBot, rpc, selectLibrarySpace } from "../lib/api"; import { allowFocusPrompt, scheduleFocusPrompt } from "../lib/focus-prompt"; import { useI18n } from "../lib/i18n"; import { useMobileTokens } from "../lib/native"; @@ -42,6 +42,7 @@ export default function NewBot() { setPending(true); setError(null); try { + await selectLibrarySpace(); // Failed list is unknown — delay focus rather than treating the bot as first. const existing = await rpc("bots/list").catch(() => null); const isFirstBot = existing !== null && existing.length === 0; diff --git a/apps/mobile/lib/api.ts b/apps/mobile/lib/api.ts index 7b1d3e3..66b196b 100644 --- a/apps/mobile/lib/api.ts +++ b/apps/mobile/lib/api.ts @@ -144,6 +144,16 @@ export function selectedSpaceId(): string | null { return cachedSpaceId || null; } +/** + * Agents live in the home library, where a project's coordinator recruits them from; select it + * before creating one so the new agent and its chat share the request scope. + */ +export async function selectLibrarySpace(): Promise { + const navigation = await rpc("spaces/list"); + const home = navigation.spaces.find((space) => space.isDefault)?.id; + if (home && home !== cachedSpaceId) await selectSpace(home); +} + /** Keep requests usable after the server deleted the selected Space but native * storage could not replace it. Neutralize any same-endpoint rollback before * writing the replacement selection so a cleanup failure cannot leave the new diff --git a/apps/web/e2e/project-main-agent.spec.ts b/apps/web/e2e/project-main-agent.spec.ts index 5d7ca20..4d9cd56 100644 --- a/apps/web/e2e/project-main-agent.spec.ts +++ b/apps/web/e2e/project-main-agent.spec.ts @@ -45,12 +45,20 @@ test("a new project opens straight into the conversation with its own main agent await captureScreenshot(page, testInfo, "project-main-conversation"); }); -test("a project's main agent hands work to the project's specialist", async ({ +test("a project's main agent recruits a library agent into the project conversation", async ({ page, }, testInfo) => { const stamp = Date.now(); await signup(page, `project-delegate-${stamp}@rakazo.test`, "password12", "Project Delegate"); await completeOnboarding(page); + // The agent is created once, in the home library. + const designer = await rpc<{ id: string }>(page, "bots/create", { + name: "Designer", + title: "", + description: "", + instructions: "", + notifyOnFinish: true, + }); const projekte = page.locator(".sidebar-projekte"); await projekte.getByTestId("create-project-trigger").click(); @@ -61,13 +69,6 @@ test("a project's main agent hands work to the project's specialist", async ({ await waitForChatComposer(page); const groupId = new URL(page.url()).pathname.split("/").at(-1)!; - const designer = await rpc<{ id: string }>(page, "bots/create", { - name: "Designer", - title: "", - description: "", - instructions: "", - notifyOnFinish: true, - }); const composer = page.getByRole("combobox", { name: "Message" }); await composer.fill("delegate to the bot named Designer: sketch a landing page"); await composer.press("Enter"); @@ -80,9 +81,14 @@ test("a project's main agent hands work to the project's specialist", async ({ await expect( transcript.getByText("closing the task: sketch a landing page.").first(), ).toBeVisible({ timeout: 60_000 }); + // The project's own Designer joined the conversation; the library agent stayed home. + const projectBots = await rpc>(page, "bots/list", {}); + const member = projectBots.find((bot) => bot.name === "Designer"); + expect(member?.id).toBeTruthy(); + expect(member?.id).not.toBe(designer.id); const snapshot = await rpc<{ members?: Array<{ botId: string }> }>(page, "threads/get", { groupId, }); - expect(snapshot.members?.map((member) => member.botId)).toContain(designer.id); + expect(snapshot.members?.map((entry) => entry.botId)).toContain(member?.id); await captureScreenshot(page, testInfo, "project-main-agent-delegates"); }); diff --git a/apps/web/src/components/GithubRepoPicker.test.tsx b/apps/web/src/components/GithubRepoPicker.test.tsx index 09b480b..a4d71f6 100644 --- a/apps/web/src/components/GithubRepoPicker.test.tsx +++ b/apps/web/src/components/GithubRepoPicker.test.tsx @@ -110,6 +110,7 @@ describe("GithubRepoPicker", () => { expect(onChange).toHaveBeenCalledWith("https://github.com/octocat/Spoon-Knife", { name: "Spoon-Knife", fullName: "octocat/Spoon-Knife", + description: "This repo is for spoon knife", }); expect(onSuggestProjectName).toHaveBeenCalledWith("Spoon-Knife"); expect(onSuggestWorkspacePath).toHaveBeenCalledWith("~/Projects/Spoon-Knife"); diff --git a/apps/web/src/components/GithubRepoPicker.tsx b/apps/web/src/components/GithubRepoPicker.tsx index 33b46c2..7074a81 100644 --- a/apps/web/src/components/GithubRepoPicker.tsx +++ b/apps/web/src/components/GithubRepoPicker.tsx @@ -15,7 +15,10 @@ export interface GithubRepoItem { export interface GithubRepoPickerProps { value: string; - onChange: (url: string, repo?: { name: string; fullName: string }) => void; + onChange: ( + url: string, + repo?: { name: string; fullName: string; description?: string | null }, + ) => void; onSuggestProjectName?: (name: string) => void; onSuggestWorkspacePath?: (path: string) => void; disabled?: boolean; @@ -81,7 +84,11 @@ export function GithubRepoPicker({ }, [repos, searchQuery]); const handleSelectRepo = (repo: GithubRepoItem) => { - onChange(repo.url, { name: repo.name, fullName: repo.fullName }); + onChange(repo.url, { + name: repo.name, + fullName: repo.fullName, + description: repo.description, + }); if (onSuggestProjectName) { onSuggestProjectName(repo.name); } diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index 422f8b3..df30c10 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -2705,11 +2705,24 @@ export function ShellPage() { computerMode: ComputerMode; }) { const isFirstBot = botsRef.current.length === 0; - const bot = await rpc.bots.create({ - ...normalizeCreateBotProfile(input), - notifyOnFinish: true, - computerMode: input.computerMode, - }); + // Agents live in the home library; a project's coordinator recruits them from there. + const bot = await rpc.bots.create( + { + ...normalizeCreateBotProfile(input), + notifyOnFinish: true, + computerMode: input.computerMode, + }, + { context: { spaceId: homeSpaceId } }, + ); + if (bot.spaceId !== (selectedSpaceId() ?? bootstrapMe?.spaceId)) { + // A project was open; follow the new agent into the library. + setPanel(null); + if (selectSpace(bot.spaceId)) { + await rpc.onboarding.start({ botId: bot.id }).catch(() => undefined); + window.location.assign(`/app/${bot.id}`); + } + return; + } setBots((current) => current.some((item) => item.id === bot.id) ? current : [bot, ...current], ); diff --git a/apps/web/src/pages/shell/dialogs.tsx b/apps/web/src/pages/shell/dialogs.tsx index c3569c4..b1ddabf 100644 --- a/apps/web/src/pages/shell/dialogs.tsx +++ b/apps/web/src/pages/shell/dialogs.tsx @@ -259,6 +259,10 @@ export function NewSpaceDialog({ if (repo?.name && !name.trim()) { setName(repo.name); } + // The repo's own description tells the coordinator what this project is. + if (repo?.description?.trim() && !goal.trim()) { + setGoal(repo.description.trim()); + } }} onSuggestProjectName={(suggestedName) => { if (!name.trim()) setName(suggestedName); diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 4577151..4ca910c 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -21,7 +21,13 @@ The product is organized as **Unterhaltungen** (see `VOICE.md`). **Team** lists |------|-----| | 1:1 Unterhaltung | One Oo | | Gruppenunterhaltung | Multiple Oos (+ humans later) — **fun factor essential** (see `docs/GROUP-CONVERSATIONS.md`) | -| Projekte | Group conversations in **Ordner** (`BotSection` folders) | +| Projekte | One group conversation per project, led by its **Projektkoordinator** | + +### Projects recruit from the library + +- Oos are created once, in the home space: that is the **library**. Each has one job. +- A project is a space with a fixed coordinator (`Bot.kind="main"`) and one main group conversation. A linked folder's README or manifest, or the GitHub repo's description, tells the coordinator what the project is (`PROJECT_GOAL.md`) when the user gave no goal. +- The coordinator recruits the Oos a request needs with `delegate_task`, briefing them on the task card. A recruited Oo gets a project member (`spawnKey` `recruit:`, `packages/db/src/project-recruits.ts`) that joins the conversation. The member keeps the Oo's persona and picks up later edits to it. It works with the project's files, memory, computer and connections. The Oo's home memory stays home. ## Deepest personalization diff --git a/packages/adapters/src/delegated-tasks.ts b/packages/adapters/src/delegated-tasks.ts index a9bb0fd..91c83dc 100644 --- a/packages/adapters/src/delegated-tasks.ts +++ b/packages/adapters/src/delegated-tasks.ts @@ -21,6 +21,8 @@ import { createThreadMessageInTransaction, ensureMainConversation, joinMainConversation, + listDelegationCandidates, + recruitIntoProject, touchGroupUpdatedAt, withTransactionRetry, } from "@rakazo/db"; @@ -54,9 +56,10 @@ async function notifyThreads(deps: DelegationDeps, seqs: ThreadSeq[]) { /** * Hands work from a main bot to a specialist as a tracked task. The card is posted in the main - * conversation of the main bot's space; a specialist of that space joins it and works there, one - * of another space works in its own chat. Replays of the same tool call return the task created - * first. + * conversation of the main bot's space; a specialist of that space joins it and works there. A + * project recruits a library agent from the home space: its project member joins and works there. + * A specialist of another project works in its own chat. Replays of the same tool call return the + * task created first. */ export async function delegateTask( deps: DelegationDeps, @@ -114,32 +117,32 @@ export async function delegateTask( }; } - const candidates = await deps.prisma.bot.findMany({ - where: { - userId: run.userId, - // Main bots lead their own space and are never delegated to. - kind: "specialist", - archivedAt: null, - thread: { isNot: null }, - space: { memberships: { some: { userId: run.userId } } }, - }, - select: { id: true, name: true, spaceId: true, thread: { select: { id: true } } }, - orderBy: { createdAt: "asc" }, - }); // A name shared across spaces resolves to the specialist next to the main conversation. - const ordered = [ - ...candidates.filter((bot) => bot.spaceId === conversation.spaceId), - ...candidates.filter((bot) => bot.spaceId !== conversation.spaceId), - ]; - const target = + const ordered = await listDelegationCandidates(deps.prisma, { + userId: run.userId, + spaceId: conversation.spaceId, + }); + const resolved = ordered.find((bot) => bot.id === requested) ?? resolveBotAddress(ordered, { name: requested }); - if (!target?.thread) { + if (!resolved?.thread) { if (requested === sender.id || requested.toLowerCase() === sender.name.toLowerCase()) { return { error: "you cannot delegate to yourself" }; } return { error: "no specialist found with that name or id" }; } - const targetThreadId = target.thread.id; + // A project recruits a library agent: its project member joins and works in this conversation. + const recruited = + resolved.spaceId !== conversation.spaceId && resolved.space.isDefault + ? await recruitIntoProject(deps.prisma, { + userId: run.userId, + spaceId: conversation.spaceId, + libraryBotId: resolved.id, + }) + : null; + const target = recruited + ? { id: recruited.id, name: recruited.name, spaceId: recruited.spaceId } + : resolved; + const targetThreadId = recruited ? recruited.threadId : resolved.thread.id; const inConversation = target.spaceId === conversation.spaceId; if (inConversation) { await joinMainConversation(deps.prisma, { diff --git a/packages/adapters/src/executor.ts b/packages/adapters/src/executor.ts index 6d90ae2..3c22ead 100644 --- a/packages/adapters/src/executor.ts +++ b/packages/adapters/src/executor.ts @@ -86,6 +86,7 @@ import { findSpaceModelTier, InvalidSpaceNameError, isTooManyDatabaseConnections, + listDelegationCandidates, listSpaceUserBotIds, loadCoachSummary, loadRunHistoryMessages, @@ -4040,39 +4041,20 @@ export function createRunExecutor(deps: ExecutorDeps) { const botDirectory = exposedToolNames.has("delegate_task") ? renderSpecialistDirectory( ( - await deps.prisma.bot.findMany({ - where: { - userId: run.userId, - // Main bots lead their own space and are never delegated to. - kind: "specialist", - archivedAt: null, - thread: { isNot: null }, - space: { memberships: { some: { userId: run.userId } } }, - }, - select: { - id: true, - name: true, - title: true, - description: true, - spaceId: true, - space: { select: { name: true } }, - }, - orderBy: { createdAt: "asc" }, + await listDelegationCandidates(deps.prisma, { + userId: run.userId, + spaceId: run.spaceId, take: BOT_DIRECTORY_LIMIT * 2, }) ) - // Specialists next to this conversation first, as delegate_task resolves names. - .sort( - (left, right) => - Number(right.spaceId === run.spaceId) - Number(left.spaceId === run.spaceId), - ) .slice(0, BOT_DIRECTORY_LIMIT) .map((peer) => ({ id: peer.id, name: peer.name, title: peer.title, description: peer.description, - ...(peer.spaceId === run.spaceId ? {} : { spaceName: peer.space.name }), + // Library agents join this conversation; only other projects' carry a label. + ...(peer.joinsHere ? {} : { spaceName: peer.space.name }), })), { handoff: exposedToolNames.has("handoff_to_bot"), diff --git a/packages/core/package.json b/packages/core/package.json index 20c2709..f064746 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -29,6 +29,11 @@ "development": "./src/git-checkout.ts", "default": "./src/git-checkout.ts" }, + "./node/project-summary": { + "types": "./src/project-summary.ts", + "development": "./src/project-summary.ts", + "default": "./src/project-summary.ts" + }, "./plot": { "types": "./src/plot/plot-spec.ts", "development": "./src/plot/plot-spec.ts", diff --git a/packages/core/src/project-summary.test.ts b/packages/core/src/project-summary.test.ts new file mode 100644 index 0000000..ec7897f --- /dev/null +++ b/packages/core/src/project-summary.test.ts @@ -0,0 +1,71 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + PROJECT_SUMMARY_MAX_LENGTH, + readProjectSummary, + summarizeProjectFiles, +} from "./project-summary.js"; + +describe("summarizeProjectFiles", () => { + it("takes the README's first prose paragraph and skips headings, badges and code", () => { + const readme = [ + "# Sugar Rush", + "", + "[![CI](https://ci.example/badge.svg)](https://ci.example)", + "", + "```sh", + "pnpm install", + "```", + "", + "A **candy** shop game for [kids](https://example.com),", + "built with React.", + "", + "## Setup", + ].join("\n"); + expect(summarizeProjectFiles({ readme })).toBe("A candy shop game for kids, built with React."); + }); + + it("falls back to a manifest description", () => { + expect( + summarizeProjectFiles({ readme: "# Title only", packageJson: '{"description":"A CLI"}' }), + ).toBe("A CLI"); + expect(summarizeProjectFiles({ pyproject: '[project]\ndescription = "Data tools"\n' })).toBe( + "Data tools", + ); + expect(summarizeProjectFiles({ cargoToml: '[package]\ndescription = "Fast parser"' })).toBe( + "Fast parser", + ); + }); + + it("returns null when nothing describes the project", () => { + expect(summarizeProjectFiles({})).toBeNull(); + expect(summarizeProjectFiles({ readme: "# Title", packageJson: "not json" })).toBeNull(); + }); + + it("clamps long descriptions", () => { + const summary = summarizeProjectFiles({ readme: "word ".repeat(400) }); + expect(summary?.length).toBe(PROJECT_SUMMARY_MAX_LENGTH); + expect(summary?.endsWith("…")).toBe(true); + }); +}); + +describe("readProjectSummary", () => { + let directory: string | undefined; + afterEach(async () => { + if (directory) await rm(directory, { recursive: true, force: true }); + directory = undefined; + }); + + it("reads the README of a folder", async () => { + directory = await mkdtemp(path.join(tmpdir(), "project-summary-")); + await writeFile(path.join(directory, "README.md"), "# App\n\nTracks habits.\n"); + expect(await readProjectSummary(directory)).toBe("Tracks habits."); + }); + + it("returns null for an empty folder", async () => { + directory = await mkdtemp(path.join(tmpdir(), "project-summary-")); + expect(await readProjectSummary(directory)).toBeNull(); + }); +}); diff --git a/packages/core/src/project-summary.ts b/packages/core/src/project-summary.ts new file mode 100644 index 0000000..9d75e6e --- /dev/null +++ b/packages/core/src/project-summary.ts @@ -0,0 +1,105 @@ +// Server-only. Kept out of the package index so browser bundles never load `node:fs`. +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +/** Upper bound on a project summary, so it stays a short brief for the project's main agent. */ +export const PROJECT_SUMMARY_MAX_LENGTH = 600; +const FILE_READ_LIMIT = 64 * 1024; +const README_NAMES = ["README.md", "README.markdown", "README.rst", "README.txt", "README"]; + +export interface ProjectFiles { + readme?: string | null; + packageJson?: string | null; + pyproject?: string | null; + cargoToml?: string | null; +} + +function clamp(text: string): string { + const flat = text.replace(/\s+/g, " ").trim(); + if (flat.length <= PROJECT_SUMMARY_MAX_LENGTH) return flat; + return `${flat.slice(0, PROJECT_SUMMARY_MAX_LENGTH - 1).trimEnd()}…`; +} + +/** The first prose paragraph of a README: no headings, badges, images, HTML, or code. */ +function readmeParagraph(readme: string): string | null { + let inFence = false; + const paragraph: string[] = []; + for (const raw of readme.split(/\r?\n/)) { + const line = raw.trim(); + if (line.startsWith("```") || line.startsWith("~~~")) { + inFence = !inFence; + if (paragraph.length) break; + continue; + } + if (inFence) continue; + const prose = + line && + !/^(#|=+$|-+$|<|!\[|\[!\[|\||>)/.test(line) && + // A line of only links is navigation, not a description. + line.replace(/\[[^\]]*\]\([^)]*\)/g, "").replace(/[\s|·•-]/g, "") !== ""; + if (prose) { + paragraph.push(line); + } else if (paragraph.length) { + break; + } + } + if (!paragraph.length) return null; + const text = paragraph + .join(" ") + .replace(/!\[[^\]]*\]\([^)]*\)/g, "") + .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/[*_`]/g, ""); + return text.trim() || null; +} + +function manifestDescription(files: ProjectFiles): string | null { + if (files.packageJson) { + try { + const parsed = JSON.parse(files.packageJson) as { description?: unknown }; + if (typeof parsed.description === "string" && parsed.description.trim()) { + return parsed.description.trim(); + } + } catch { + // An unreadable manifest has no description. + } + } + for (const toml of [files.pyproject, files.cargoToml]) { + const match = toml?.match(/^\s*description\s*=\s*"([^"\n]+)"/m); + if (match?.[1]?.trim()) return match[1].trim(); + } + return null; +} + +/** + * What a project is about, from the files a folder already has: the README's first paragraph, + * else a manifest description. Returns null when neither says anything. + */ +export function summarizeProjectFiles(files: ProjectFiles): string | null { + const summary = + (files.readme ? readmeParagraph(files.readme) : null) ?? manifestDescription(files); + return summary ? clamp(summary) : null; +} + +async function readSmallFile(file: string): Promise { + try { + const content = await readFile(file, "utf8"); + return content.slice(0, FILE_READ_LIMIT); + } catch { + return null; + } +} + +/** Reads a linked project folder's README and manifests; nothing else in it is opened. */ +export async function readProjectSummary(directory: string): Promise { + let readme: string | null = null; + for (const name of README_NAMES) { + readme = await readSmallFile(path.join(directory, name)); + if (readme) break; + } + const [packageJson, pyproject, cargoToml] = await Promise.all([ + readSmallFile(path.join(directory, "package.json")), + readSmallFile(path.join(directory, "pyproject.toml")), + readSmallFile(path.join(directory, "Cargo.toml")), + ]); + return summarizeProjectFiles({ readme, packageJson, pyproject, cargoToml }); +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 6378608..4c35b2d 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -18,6 +18,7 @@ export * from "./model-credentials.js"; export * from "./model-tiers.js"; export * from "./notification-settings.js"; export * from "./project-goals.js"; +export * from "./project-recruits.js"; export * from "./project-workspace.js"; export * from "./push-subscriptions.js"; export * from "./repos.js"; diff --git a/packages/db/src/project-recruits.postgres.test.ts b/packages/db/src/project-recruits.postgres.test.ts new file mode 100644 index 0000000..6d97ce5 --- /dev/null +++ b/packages/db/src/project-recruits.postgres.test.ts @@ -0,0 +1,148 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { bootstrapUserSpace } from "./bootstrap-user.js"; +import { createDb, type PrismaClient } from "./client.js"; +import { + listDelegationCandidates, + recruitIntoProject, + recruitSpawnKey, + syncProjectMembers, +} from "./project-recruits.js"; +import { createRepos } from "./repos.js"; +import { requireMembership } from "./scope.js"; +import { createSpaceForMember } from "./spaces.js"; + +const databaseUrl = process.env.DATABASE_URL; +const describePostgres = + process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip; + +const signupEnv = { signupsEnabled: "true", signupAllowlist: "" }; + +describePostgres("project recruits (PostgreSQL)", () => { + let prisma: PrismaClient; + let close: () => Promise; + const userIds: string[] = []; + + async function newUserWithProject() { + const id = `pr${randomUUID().replaceAll("-", "")}`; + userIds.push(id); + await prisma.user.create({ data: { id, name: "Ada", email: `${id}@rakazo.test` } }); + const { spaceId: homeSpaceId } = await bootstrapUserSpace(prisma, { id }, signupEnv, { + claimDeploymentOwner: false, + }); + const project = await createSpaceForMember(prisma, { + currentSpaceId: homeSpaceId, + userId: id, + name: "Sugar Rush", + }); + const library = await createRepos(prisma).createBot( + await requireMembership(prisma, id, homeSpaceId), + { + name: "Pixel", + title: "Designer", + description: "Draws things", + instructions: "Be bold.", + notifyOnFinish: true, + }, + ); + return { userId: id, homeSpaceId, projectSpaceId: project.id, library }; + } + + beforeAll(() => { + const db = createDb(databaseUrl!); + prisma = db.prisma; + close = async () => { + await db.prisma.$disconnect(); + await db.pool.end(); + }; + }); + + afterAll(async () => { + if (!prisma) return; + const memberships = await prisma.member.findMany({ + where: { userId: { in: userIds } }, + select: { organizationId: true }, + }); + await prisma.organization.deleteMany({ + where: { id: { in: memberships.map((member) => member.organizationId) } }, + }); + await prisma.user.deleteMany({ where: { id: { in: userIds } } }); + await close(); + }); + + it("gives a project one member per library agent, carrying its persona", async () => { + const { userId, projectSpaceId, library } = await newUserWithProject(); + const [first, again] = await Promise.all([ + recruitIntoProject(prisma, { userId, spaceId: projectSpaceId, libraryBotId: library.id }), + recruitIntoProject(prisma, { userId, spaceId: projectSpaceId, libraryBotId: library.id }), + ]); + expect(first?.id).toBe(again?.id); + const member = await prisma.bot.findUniqueOrThrow({ where: { id: first!.id } }); + expect(member).toMatchObject({ + spaceId: projectSpaceId, + name: "Pixel", + title: "Designer", + instructions: "Be bold.", + kind: "specialist", + spawnKey: recruitSpawnKey(library.id), + }); + // The agent's home memory stays home; the member starts with its own. + const memory = await prisma.memoryDocument.findMany({ where: { botId: member.id } }); + expect(memory.map((doc) => doc.spaceId)).toEqual([projectSpaceId]); + }); + + it("refuses to recruit into the home space or from a project", async () => { + const { userId, homeSpaceId, projectSpaceId, library } = await newUserWithProject(); + expect( + await recruitIntoProject(prisma, { userId, spaceId: homeSpaceId, libraryBotId: library.id }), + ).toBeNull(); + const member = await recruitIntoProject(prisma, { + userId, + spaceId: projectSpaceId, + libraryBotId: library.id, + }); + const other = await createSpaceForMember(prisma, { + currentSpaceId: homeSpaceId, + userId, + name: "Garden", + }); + expect( + await recruitIntoProject(prisma, { userId, spaceId: other.id, libraryBotId: member!.id }), + ).toBeNull(); + }); + + it("mirrors later edits of the library agent onto its members", async () => { + const { userId, projectSpaceId, library } = await newUserWithProject(); + const member = await recruitIntoProject(prisma, { + userId, + spaceId: projectSpaceId, + libraryBotId: library.id, + }); + await prisma.bot.update({ where: { id: library.id }, data: { instructions: "Be calm." } }); + await syncProjectMembers(prisma, { userId, libraryBotId: library.id }); + const synced = await prisma.bot.findUniqueOrThrow({ where: { id: member!.id } }); + expect(synced.instructions).toBe("Be calm."); + }); + + it("offers a recruited agent once per project and never another project's member", async () => { + const { userId, homeSpaceId, projectSpaceId, library } = await newUserWithProject(); + const before = await listDelegationCandidates(prisma, { userId, spaceId: projectSpaceId }); + expect(before.map((bot) => [bot.id, bot.joinsHere])).toEqual([[library.id, true]]); + + const member = await recruitIntoProject(prisma, { + userId, + spaceId: projectSpaceId, + libraryBotId: library.id, + }); + const inProject = await listDelegationCandidates(prisma, { userId, spaceId: projectSpaceId }); + expect(inProject.map((bot) => bot.id)).toEqual([member!.id]); + + const other = await createSpaceForMember(prisma, { + currentSpaceId: homeSpaceId, + userId, + name: "Garden", + }); + const elsewhere = await listDelegationCandidates(prisma, { userId, spaceId: other.id }); + expect(elsewhere.map((bot) => bot.id)).toEqual([library.id]); + }); +}); diff --git a/packages/db/src/project-recruits.ts b/packages/db/src/project-recruits.ts new file mode 100644 index 0000000..1358cb7 --- /dev/null +++ b/packages/db/src/project-recruits.ts @@ -0,0 +1,137 @@ +import type { Prisma, PrismaClient } from "./client.js"; +import { createRepos } from "./repos.js"; +import { requireMembership } from "./scope.js"; + +/** + * Agents are created once, in the user's home space: that is their library. A project's main + * agent recruits one by giving the project its own member, linked to the library agent through a + * spawn key. The member carries the agent's persona and works with the project's files, memory, + * computer and connections; the agent's home memory never crosses into the project. + */ +const RECRUIT_SPAWN_KEY_PREFIX = "recruit:"; + +export function recruitSpawnKey(libraryBotId: string): string { + return `${RECRUIT_SPAWN_KEY_PREFIX}${libraryBotId}`; +} + +/** The persona a project member mirrors from its library agent. */ +const personaSelect = { + name: true, + title: true, + description: true, + instructions: true, + color: true, + notifyOnFinish: true, + tags: true, + restrictBuiltinTools: true, + allowedBuiltinTools: true, + modelProvider: true, + modelId: true, + thinkingLevel: true, + modelTier: true, +} as const satisfies Prisma.BotSelect; + +/** + * Specialists the main agent of `spaceId` can hand work to, those of that space first: its own + * specialists, library agents, and specialists of other projects. A project member of a library + * agent stands in for that agent in its project and is never offered anywhere else. + * `joinsHere` marks those that work in this space's conversation when delegated to. + */ +export async function listDelegationCandidates( + prisma: Pick, + input: { userId: string; spaceId: string; take?: number }, +) { + const bots = await prisma.bot.findMany({ + where: { + userId: input.userId, + // Main bots lead their own space and are never delegated to. + kind: "specialist", + archivedAt: null, + thread: { isNot: null }, + space: { memberships: { some: { userId: input.userId } } }, + OR: [ + { spaceId: input.spaceId }, + { spawnKey: null }, + { NOT: { spawnKey: { startsWith: RECRUIT_SPAWN_KEY_PREFIX } } }, + ], + }, + select: { + id: true, + name: true, + title: true, + description: true, + spaceId: true, + spawnKey: true, + space: { select: { name: true, isDefault: true } }, + thread: { select: { id: true } }, + }, + orderBy: { createdAt: "asc" }, + ...(input.take ? { take: input.take } : {}), + }); + const here = bots.filter((bot) => bot.spaceId === input.spaceId); + const recruited = new Set(here.map((bot) => bot.spawnKey)); + const elsewhere = bots.filter( + (bot) => bot.spaceId !== input.spaceId && !recruited.has(recruitSpawnKey(bot.id)), + ); + return [...here, ...elsewhere].map((bot) => ({ + ...bot, + joinsHere: bot.spaceId === input.spaceId || bot.space.isDefault, + })); +} + +/** + * The project member of library agent `libraryBotId` in project `spaceId`, created or restored + * with the agent's current persona. Returns null when the agent is not an active library + * specialist of the user or `spaceId` is the home space itself. + */ +export async function recruitIntoProject( + prisma: PrismaClient, + input: { userId: string; spaceId: string; libraryBotId: string }, +): Promise<{ id: string; name: string; spaceId: string; threadId: string } | null> { + const project = await requireMembership(prisma, input.userId, input.spaceId); + const library = await prisma.bot.findFirst({ + where: { + id: input.libraryBotId, + userId: input.userId, + kind: "specialist", + archivedAt: null, + space: { isDefault: true }, + spaceId: { not: project.spaceId }, + }, + select: personaSelect, + }); + if (!library) return null; + const spawnKey = recruitSpawnKey(input.libraryBotId); + const where = { spaceId_spawnKey: { spaceId: project.spaceId, spawnKey } }; + const existing = await prisma.bot.findUnique({ where, select: { id: true, userId: true } }); + if (existing && existing.userId !== input.userId) return null; + if (existing) { + // A member the user archived earlier comes back with the agent's current persona. + await prisma.bot.update({ where: { id: existing.id }, data: { ...library, archivedAt: null } }); + } else { + // createBot is idempotent per spawn key, so concurrent recruits share one member. + await createRepos(prisma).createBot(project, { ...library, spawnKey }); + } + const member = await prisma.bot.findUnique({ + where, + select: { id: true, name: true, spaceId: true, thread: { select: { id: true } } }, + }); + if (!member?.thread) return null; + return { id: member.id, name: member.name, spaceId: member.spaceId, threadId: member.thread.id }; +} + +/** Copies a library agent's persona onto its project members after the agent was edited. */ +export async function syncProjectMembers( + prisma: PrismaClient, + input: { userId: string; libraryBotId: string }, +): Promise { + const library = await prisma.bot.findFirst({ + where: { id: input.libraryBotId, userId: input.userId, space: { isDefault: true } }, + select: personaSelect, + }); + if (!library) return; + await prisma.bot.updateMany({ + where: { userId: input.userId, spawnKey: recruitSpawnKey(input.libraryBotId) }, + data: library, + }); +}