cynarAI 86723f0f6f
feat(db): main agent, main conversation, user model and delegated task foundation
The product needs one main agent that leads a main conversation, knows the
user across spaces, and hands work to visible specialists. Today none of
that exists in the data model: "Chief" is a client-side naming convention,
the "Hauptthread" is a group the web client creates by name, and nothing
stores what the agents know about the user.

This adds the shared foundation the Phase 1 features build on:

- Bot.kind ("main" | "specialist", one main bot per user) plus capability
  fields (tags, builtin tool allowlist, model tier).
- ChatGroup.kind ("group" | "main", one main conversation per user) and
  ChatGroup.leadBotId.
- UserProfile and UserFact with category, confidence, source, consent
  status and per-space visibility; embeddings are stored as float arrays
  because none of the Postgres images ship pgvector.
- DelegatedTask for work the main bot hands to a specialist.
- SpaceModelTier to map a model tier to a connected model per space.

The migration backfills each user's onboarding bot in the home space as the
main bot and turns an existing "Hauptthread" group containing it into the
main conversation. CHECK and foreign key constraints on existing tables are
added NOT VALID and validated in a separate migration, as elsewhere in this
repo.

New db helpers ensure the main bot and the main conversation (idempotent and
race-safe, repairing a lost lead), add same-space specialists to the main
conversation and announce it with a group.updated event, and load the user
context a run may see. The main conversation is exempt from the two-member
floor and cannot have its members replaced through a group update. The
contracts expose the new fields and the user model and delegated task
schemas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-02 20:49:36 +00:00

610 lines
20 KiB
TypeScript

import {
type Actor,
BOT_COLORS,
type Bot,
type BotKind,
type BotSection,
type MessageBlock,
type SpaceBot,
} from "@rakazo/contracts";
import { userVisibleMessages } from "@rakazo/core";
import type { PrismaClient } from "./client.js";
import { type ComputerMode, ensureComputerRecord, parseComputerMode } from "./computers.js";
import { createThreadMessageInTransaction } from "./messages.js";
import { BotSectionNameConflictError, IsolationError } from "./scope.js";
import { lockSpaceForContentCreation } from "./spaces.js";
import { activeRunSelection, previewFromBlocks } from "./thread-listing.js";
/** Newest messages loaded for sidebar preview; enough to skip a short peer-run tail. */
const SIDEBAR_PREVIEW_MESSAGE_WINDOW = 16;
function isUniqueViolation(error: unknown): boolean {
return Boolean(error && typeof error === "object" && "code" in error && error.code === "P2002");
}
function isSpawnKeyConflict(error: unknown): boolean {
if (!isUniqueViolation(error)) return false;
const target = (error as { meta?: { target?: string[] | string } }).meta?.target;
if (target == null) return true;
const fields = Array.isArray(target) ? target : [target];
return fields.some((field) => field === "spawnKey" || field.includes("spawnKey"));
}
function mapBot(
bot: {
id: string;
spaceId: string;
name: string;
title: string;
description: string;
instructions: string;
color: string;
notifyOnFinish: boolean;
pinned: boolean;
sectionId: string | null;
archivedAt: Date | null;
parentBotId: string | null;
memoryScope: string | null;
createdAt: Date;
updatedAt: Date;
thread: { id: string; unread: boolean } | null;
computer: { scope: string } | null;
voiceId?: string | null;
autoSpeak?: boolean;
modelProvider?: string | null;
modelId?: string | null;
thinkingLevel?: string | null;
teamChatAmbientEnabled?: boolean;
teamChatRules?: string;
webhookSecretId?: string | null;
spawnKey?: string | null;
kind?: string;
tags?: string[];
restrictBuiltinTools?: boolean;
allowedBuiltinTools?: string[];
modelTier?: string | null;
},
preview = "",
status = "idle",
): Bot {
if (!bot.thread) {
throw new IsolationError("Bot is missing its thread");
}
return {
id: bot.id,
spaceId: bot.spaceId,
name: bot.name,
title: bot.title,
description: bot.description,
instructions: bot.instructions,
color: bot.color,
notifyOnFinish: bot.notifyOnFinish,
pinned: bot.pinned,
sectionId: bot.sectionId,
archivedAt: bot.archivedAt?.toISOString() ?? null,
unread: bot.thread.unread,
parentBotId: bot.parentBotId,
memoryScope: bot.memoryScope as "isolated" | "shared" | null,
threadId: bot.thread.id,
preview,
status,
computerMode: bot.computer ? parseComputerMode(bot.computer.scope) : "team",
createdAt: bot.createdAt.toISOString(),
updatedAt: bot.updatedAt.toISOString(),
voiceId: bot.voiceId ?? null,
autoSpeak: bot.autoSpeak ?? false,
modelProvider: bot.modelProvider ?? null,
modelId: bot.modelId ?? null,
thinkingLevel: (bot.thinkingLevel as Bot["thinkingLevel"]) ?? null,
teamChatAmbientEnabled: bot.teamChatAmbientEnabled ?? false,
teamChatRules: bot.teamChatRules ?? "",
webhookConfigured: Boolean(bot.webhookSecretId),
spawnKey: bot.spawnKey ?? null,
kind: bot.kind === "main" ? "main" : "specialist",
tags: bot.tags ?? [],
restrictBuiltinTools: bot.restrictBuiltinTools ?? false,
allowedBuiltinTools: bot.allowedBuiltinTools ?? [],
modelTier: parseModelTier(bot.modelTier),
};
}
function parseModelTier(value: string | null | undefined): Bot["modelTier"] {
return value === "fast" || value === "standard" || value === "code" ? value : null;
}
export function createRepos(prisma: PrismaClient) {
async function listBotSectionsForSpaces(
actor: Actor,
spaceIds: string[],
): Promise<Array<BotSection & { spaceId: string }>> {
if (spaceIds.length === 0) return [];
const sections = await prisma.botSection.findMany({
where: { spaceId: { in: spaceIds }, userId: actor.userId },
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
});
return sections.map((section) => ({
id: section.id,
spaceId: section.spaceId,
name: section.name,
position: section.position,
createdAt: section.createdAt.toISOString(),
updatedAt: section.updatedAt.toISOString(),
}));
}
async function listSpaceBotsForSpaces(actor: Actor, spaceIds: string[]): Promise<SpaceBot[]> {
if (spaceIds.length === 0) return [];
const bots = await prisma.bot.findMany({
where: {
spaceId: { in: spaceIds },
userId: actor.userId,
archivedAt: null,
},
select: {
id: true,
spaceId: true,
kind: true,
name: true,
title: true,
color: true,
notifyOnFinish: true,
pinned: true,
sectionId: true,
updatedAt: true,
thread: {
select: {
unread: true,
messages: {
orderBy: { seq: "desc" },
take: 1,
select: { blocks: true },
},
},
},
runs: activeRunSelection,
},
orderBy: [{ pinned: "desc" }, { position: "asc" }, { createdAt: "asc" }],
});
return bots.map((bot) => {
if (!bot.thread) throw new IsolationError("Bot is missing its thread");
return {
id: bot.id,
spaceId: bot.spaceId,
kind: bot.kind === "main" ? "main" : "specialist",
name: bot.name,
title: bot.title,
color: bot.color,
notifyOnFinish: bot.notifyOnFinish,
pinned: bot.pinned,
sectionId: bot.sectionId,
unread: bot.thread.unread,
preview: previewFromBlocks(bot.thread.messages[0]?.blocks),
status: bot.runs[0]?.status ?? "idle",
updatedAt: bot.updatedAt.toISOString(),
};
});
}
return {
async listBotSections(actor: Actor): Promise<BotSection[]> {
return listBotSectionsForSpaces(actor, [actor.spaceId]);
},
listBotSectionsForSpaces,
async createBotSection(
actor: Actor,
input: { botId?: string; groupId?: string; name: string },
) {
const { name } = input;
return prisma.$transaction(async (tx) => {
const target = input.botId
? await tx.bot.findFirst({
where: {
id: input.botId,
spaceId: actor.spaceId,
userId: actor.userId,
archivedAt: null,
},
select: { id: true },
})
: await tx.chatGroup.findFirst({
where: {
id: input.groupId,
spaceId: actor.spaceId,
userId: actor.userId,
archivedAt: null,
},
select: { id: true },
});
if (!target) throw new IsolationError();
const aggregate = await tx.botSection.aggregate({
where: { spaceId: actor.spaceId, userId: actor.userId },
_max: { position: true },
});
await tx.botSection.createMany({
data: {
spaceId: actor.spaceId,
userId: actor.userId,
name,
position: (aggregate._max.position ?? -1) + 1,
},
skipDuplicates: true,
});
const section = await tx.botSection.findUniqueOrThrow({
where: {
spaceId_userId_name: {
spaceId: actor.spaceId,
userId: actor.userId,
name,
},
},
});
if (input.botId) {
await tx.bot.update({ where: { id: target.id }, data: { sectionId: section.id } });
} else {
await tx.chatGroup.update({
where: { id: target.id },
data: { sectionId: section.id },
});
}
return {
id: section.id,
name: section.name,
position: section.position,
createdAt: section.createdAt.toISOString(),
updatedAt: section.updatedAt.toISOString(),
} satisfies BotSection;
});
},
async updateBotSection(actor: Actor, input: { sectionId: string; name: string }) {
const existing = await prisma.botSection.findFirst({
where: {
id: input.sectionId,
spaceId: actor.spaceId,
userId: actor.userId,
},
});
if (!existing) throw new IsolationError();
if (existing.name === input.name) {
return {
id: existing.id,
name: existing.name,
position: existing.position,
createdAt: existing.createdAt.toISOString(),
updatedAt: existing.updatedAt.toISOString(),
} satisfies BotSection;
}
try {
const section = await prisma.botSection.update({
where: { id: existing.id },
data: { name: input.name },
});
return {
id: section.id,
name: section.name,
position: section.position,
createdAt: section.createdAt.toISOString(),
updatedAt: section.updatedAt.toISOString(),
} satisfies BotSection;
} catch (error) {
if (isUniqueViolation(error)) throw new BotSectionNameConflictError();
throw error;
}
},
async listBots(actor: Actor, options: { archived?: boolean } = {}): Promise<Bot[]> {
const bots = await prisma.bot.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
archivedAt: options.archived ? { not: null } : null,
},
include: {
thread: {
include: {
messages: { orderBy: { seq: "desc" }, take: SIDEBAR_PREVIEW_MESSAGE_WINDOW },
},
},
runs: activeRunSelection,
computer: { select: { scope: true } },
},
orderBy: [{ pinned: "desc" }, { position: "asc" }, { createdAt: "asc" }],
});
const candidateRunIds = [
...new Set(
bots.flatMap((bot) =>
(bot.thread?.messages ?? []).flatMap((message) =>
message.runId ? [message.runId] : [],
),
),
),
];
const peerRuns = candidateRunIds.length
? await prisma.run.findMany({
where: { id: { in: candidateRunIds }, trigger: "bot_message" },
select: { id: true },
})
: [];
const peerRunIds = new Set(peerRuns.map((run) => run.id));
// Cache negative results too; ordinary runs were already checked in the batch above.
const checkedRunIds = new Set(candidateRunIds);
return Promise.all(
bots.map(async (bot) => {
let messages = bot.thread?.messages ?? [];
let preview = "";
for (let attempt = 0; attempt < 5; attempt++) {
const windowRunIds = [
...new Set(messages.flatMap((message) => (message.runId ? [message.runId] : []))),
].filter((runId) => !checkedRunIds.has(runId));
if (windowRunIds.length > 0) {
const morePeers = await prisma.run.findMany({
where: { id: { in: windowRunIds }, trigger: "bot_message" },
select: { id: true },
});
for (const run of morePeers) peerRunIds.add(run.id);
for (const runId of windowRunIds) checkedRunIds.add(runId);
}
const visible = userVisibleMessages(
messages.map((message) => ({
...message,
blocks: message.blocks as MessageBlock[],
runId: message.runId ?? undefined,
})),
{ knownPeerRunIds: peerRunIds, includeDelegatedReplyText: false },
);
preview = previewFromBlocks(visible[0]?.blocks);
if (preview || messages.length === 0 || !bot.thread || attempt === 4) break;
const oldest = messages[messages.length - 1];
if (!oldest) break;
messages = await prisma.message.findMany({
where: { threadId: bot.thread.id, seq: { lt: oldest.seq } },
orderBy: { seq: "desc" },
take: SIDEBAR_PREVIEW_MESSAGE_WINDOW,
});
if (messages.length === 0) break;
}
return mapBot(bot, preview, bot.runs[0]?.status ?? "idle");
}),
);
},
listSpaceBotsForSpaces,
async getBot(actor: Actor, botId: string, options: { includeArchived?: boolean } = {}) {
const bot = await prisma.bot.findFirst({
where: {
id: botId,
spaceId: actor.spaceId,
userId: actor.userId,
...(options.includeArchived ? {} : { archivedAt: null }),
},
include: { thread: true, computer: true },
});
if (!bot) throw new IsolationError();
return bot;
},
async createBot(
actor: Actor,
input: {
name: string;
title: string;
description: string;
instructions: string;
notifyOnFinish: boolean;
color?: string;
parentBotId?: string | null;
computerMode?: ComputerMode;
spawnKey?: string;
/** Only the main-agent helpers pass "main"; every other caller creates specialists. */
kind?: BotKind;
modelProvider?: string | null;
modelId?: string | null;
thinkingLevel?: string | null;
initialMessage?: {
role: "user" | "bot" | "system";
blocks: MessageBlock[];
runId?: string;
};
},
): Promise<Bot> {
let color = input.color;
if (color === undefined) {
const count = await prisma.bot.count({
where: { spaceId: actor.spaceId, userId: actor.userId },
});
color = BOT_COLORS[count % BOT_COLORS.length] ?? BOT_COLORS[0];
}
let modelProvider = input.modelProvider ?? null;
let modelId = input.modelId ?? null;
let thinkingLevel = input.thinkingLevel ?? null;
if (input.parentBotId) {
const parent = await prisma.bot.findFirst({
where: {
id: input.parentBotId,
spaceId: actor.spaceId,
userId: actor.userId,
},
});
if (!parent) throw new IsolationError();
if (!modelId) {
modelProvider = parent.modelProvider ?? null;
modelId = parent.modelId ?? null;
}
if (thinkingLevel == null) thinkingLevel = parent.thinkingLevel ?? null;
}
const settings = await prisma.deploymentSettings.findUnique({ where: { id: "default" } });
const envKind = process.env.SANDBOX_PROVIDER ?? "docker";
const kind =
envKind === "docker" && settings?.computerHost === "this-mac" ? "desktop" : envKind;
const insertBot = () =>
prisma.$transaction(async (tx) => {
await lockSpaceForContentCreation(tx, {
spaceId: actor.spaceId,
userId: actor.userId,
});
const positions = await tx.bot.aggregate({
where: { spaceId: actor.spaceId, userId: actor.userId },
_max: { position: true },
});
const teamComputer = await ensureComputerRecord(tx, {
mode: "team",
spaceId: actor.spaceId,
userId: actor.userId,
kind,
});
const created = await tx.bot.create({
data: {
spaceId: actor.spaceId,
userId: actor.userId,
name: input.name,
title: input.title,
description: input.description,
instructions: input.instructions,
notifyOnFinish: input.notifyOnFinish,
color,
position: (positions._max.position ?? -1) + 1,
parentBotId: input.parentBotId ?? null,
computerId: teamComputer.id,
spawnKey: input.spawnKey,
kind: input.kind ?? "specialist",
modelProvider,
modelId,
thinkingLevel,
},
});
const thread = await tx.thread.create({
data: {
spaceId: actor.spaceId,
botId: created.id,
userId: actor.userId,
},
});
if (input.initialMessage) {
await createThreadMessageInTransaction(tx, {
threadId: thread.id,
...input.initialMessage,
});
}
if (input.computerMode === "dedicated") {
const dedicated = await ensureComputerRecord(tx, {
mode: "dedicated",
spaceId: actor.spaceId,
userId: actor.userId,
botId: created.id,
kind,
});
await tx.bot.update({ where: { id: created.id }, data: { computerId: dedicated.id } });
}
await tx.browserProfile.create({
data: {
spaceId: actor.spaceId,
botId: created.id,
userId: actor.userId,
},
});
await tx.memoryDocument.create({
data: {
spaceId: actor.spaceId,
userId: actor.userId,
botId: created.id,
scope: "bot",
path: "MEMORY.md",
content: `# ${input.name}\n\n`,
},
});
return tx.bot.findFirstOrThrow({
where: { id: created.id },
include: { thread: true, computer: true },
});
});
const findBySpawnKey = async () => {
if (!input.spawnKey) return null;
return prisma.bot.findUnique({
where: {
spaceId_spawnKey: {
spaceId: actor.spaceId,
spawnKey: input.spawnKey,
},
},
include: { thread: true, computer: true },
});
};
let bot: Awaited<ReturnType<typeof insertBot>>;
try {
bot = await insertBot();
} catch (error) {
if (!input.spawnKey || !isSpawnKeyConflict(error)) throw error;
const existing = await findBySpawnKey();
if (!existing || existing.userId !== actor.userId) throw error;
if (!existing.archivedAt) {
bot = existing;
} else {
// Free the key from the archived bot so empty-space onboarding
// creates a fresh first bot (stale thread/runtime must not return).
await prisma.bot.update({
where: { id: existing.id },
data: { spawnKey: null },
});
try {
bot = await insertBot();
} catch (retryError) {
if (!isSpawnKeyConflict(retryError)) throw retryError;
const winner = await findBySpawnKey();
if (!winner || winner.userId !== actor.userId || winner.archivedAt) throw retryError;
bot = winner;
}
}
}
return mapBot(bot);
},
async reorderBots(actor: Actor, botIds: string[]): Promise<void> {
await prisma.$transaction(async (tx) => {
const bots = await tx.bot.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
archivedAt: null,
},
select: { id: true },
});
const existing = new Set(bots.map((bot) => bot.id));
if (existing.size !== botIds.length || botIds.some((id) => !existing.has(id))) {
throw new IsolationError();
}
// ponytail: rosters are small; switch to one SQL CASE update only if this becomes hot.
await Promise.all(
botIds.map((id, position) => tx.bot.update({ where: { id }, data: { position } })),
);
});
},
async setBotComputer(actor: Actor, botId: string, mode: ComputerMode): Promise<Bot> {
const bot = await prisma.bot.findFirst({
where: { id: botId, spaceId: actor.spaceId, userId: actor.userId },
include: { computer: true },
});
if (!bot?.computer) throw new IsolationError();
const computer = await ensureComputerRecord(prisma, {
mode,
spaceId: actor.spaceId,
userId: actor.userId,
botId,
kind: bot.computer.kind,
});
const updated = await prisma.bot.update({
where: { id: botId },
data: { computerId: computer.id },
include: { thread: true, computer: true },
});
return mapBot(updated);
},
};
}