diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index 03e1a87..7067820 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -4885,6 +4885,7 @@ async function spaceNavigationDto( bots: spaceBots.map((bot) => ({ id: bot.id, spaceId: bot.spaceId, + kind: bot.kind, name: bot.name, title: bot.title, color: bot.color, @@ -4899,6 +4900,7 @@ async function spaceNavigationDto( groups: spaceGroups.map((group) => ({ id: group.id, spaceId: group.spaceId, + kind: group.kind, name: group.name, pinned: group.pinned, sectionId: group.sectionId, diff --git a/apps/web/src/lib/heute-brief.test.ts b/apps/web/src/lib/heute-brief.test.ts index e1b51e5..d023919 100644 --- a/apps/web/src/lib/heute-brief.test.ts +++ b/apps/web/src/lib/heute-brief.test.ts @@ -35,6 +35,11 @@ function bot(overrides: Partial = {}): Bot { teamChatRules: "", webhookConfigured: false, spawnKey: null, + kind: "specialist", + tags: [], + restrictBuiltinTools: false, + allowedBuiltinTools: [], + modelTier: null, ...overrides, }; } @@ -44,6 +49,8 @@ function group(overrides: Partial = {}): Group { id: "group-1", spaceId: "space-1", name: "Launch", + kind: "group", + leadBotId: null, pinned: false, sectionId: null, archivedAt: null, diff --git a/packages/contracts/src/domain.ts b/packages/contracts/src/domain.ts index 08859a9..18fe1c7 100644 --- a/packages/contracts/src/domain.ts +++ b/packages/contracts/src/domain.ts @@ -39,6 +39,16 @@ export const AgentSecretInputSchema = z.object({ }); export type AgentSecretInput = z.infer; +/** "main" is the user's single lead agent; every other bot is a "specialist". */ +export const BOT_KINDS = ["main", "specialist"] as const; +export const BotKindSchema = z.enum(BOT_KINDS); +export type BotKind = z.infer; + +/** Model tiers a bot or a single task can ask for; each maps to a connected model per space. */ +export const MODEL_TIERS = ["fast", "standard", "code"] as const; +export const ModelTierSchema = z.enum(MODEL_TIERS); +export type ModelTier = z.infer; + export const BotSchema = z.object({ id: Id, spaceId: Id, @@ -70,6 +80,13 @@ export const BotSchema = z.object({ webhookConfigured: z.boolean(), /** Present when created with an idempotency key (e.g. onboarding:first). */ spawnKey: z.string().nullable(), + kind: BotKindSchema, + /** Capability tags used to route delegated work. */ + tags: z.array(z.string()), + /** When true, only allowedBuiltinTools are exposed to this bot's runs. */ + restrictBuiltinTools: z.boolean(), + allowedBuiltinTools: z.array(z.string()), + modelTier: ModelTierSchema.nullable(), }); export type Bot = z.infer; @@ -95,10 +112,17 @@ export const REPLY_QUOTE_MAX_LENGTH = 2_000; export const GROUP_MEMBER_MIN = 2; export const GROUP_MEMBER_MAX = 6; +/** "main" is the user's main conversation, led by the main bot; it has no member limits. */ +export const GROUP_KINDS = ["group", "main"] as const; +export const GroupKindSchema = z.enum(GROUP_KINDS); +export type GroupKind = z.infer; + export const GroupSchema = z.object({ id: Id, spaceId: Id, name: z.string(), + kind: GroupKindSchema, + leadBotId: Id.nullable(), pinned: z.boolean(), sectionId: Id.nullable(), archivedAt: z.string().nullable(), @@ -154,6 +178,7 @@ export type BotSection = z.infer; export const SpaceBotSchema = BotSchema.pick({ id: true, spaceId: true, + kind: true, name: true, title: true, color: true, @@ -170,6 +195,7 @@ export type SpaceBot = z.infer; export const SpaceGroupSchema = GroupSchema.pick({ id: true, spaceId: true, + kind: true, name: true, pinned: true, sectionId: true, @@ -1224,3 +1250,97 @@ export const ExportManifestSchema = z.object({ history: z.array(ThreadMessageSchema), }); export type ExportManifest = z.infer; + +/** Categories of durable knowledge about the user. */ +export const USER_FACT_CATEGORIES = [ + "identity", + "work", + "goals", + "people", + "health", + "values", + "habits", + "preferences", + "tools", +] as const; +export const UserFactCategorySchema = z.enum(USER_FACT_CATEGORIES); +export type UserFactCategory = z.infer; + +/** Facts in these categories need the user's confirmation before agents may use them. */ +export const SENSITIVE_USER_FACT_CATEGORIES = [ + "health", + "values", +] as const satisfies readonly UserFactCategory[]; + +export const USER_FACT_SOURCES = [ + "onboarding", + "user", + "conversation", + "consolidation", + "connector", +] as const; +export const UserFactSourceSchema = z.enum(USER_FACT_SOURCES); +export type UserFactSource = z.infer; + +/** "pending" facts wait for the user's confirmation and are never shown to agents. */ +export const USER_FACT_STATUSES = ["active", "pending"] as const; +export const UserFactStatusSchema = z.enum(USER_FACT_STATUSES); +export type UserFactStatus = z.infer; + +export const UserProfileSchema = z.object({ + timeZone: z.string().nullable(), + locale: z.string().nullable(), + language: z.string().nullable(), + communicationStyle: z.string(), + summary: z.string(), + consolidatedAt: z.string().nullable(), + updatedAt: z.string().nullable(), +}); +export type UserProfile = z.infer; + +export const UserFactSchema = z.object({ + id: Id, + category: UserFactCategorySchema, + value: z.string(), + confidence: z.number().min(0).max(1), + source: UserFactSourceSchema, + status: UserFactStatusSchema, + sensitive: z.boolean(), + /** False when the fact is limited to spaceIds. */ + allSpaces: z.boolean(), + spaceIds: z.array(Id), + sourceBotId: Id.nullable(), + sourceThreadId: Id.nullable(), + createdAt: z.string(), + updatedAt: z.string(), +}); +export type UserFact = z.infer; + +export const DELEGATED_TASK_STATUSES = [ + "queued", + "running", + "waiting", + "done", + "failed", + "cancelled", +] as const; +export const DelegatedTaskStatusSchema = z.enum(DELEGATED_TASK_STATUSES); +export type DelegatedTaskStatus = z.infer; + +export const DelegatedTaskSchema = z.object({ + id: Id, + threadId: Id, + requesterBotId: Id, + assigneeBotId: Id, + assigneeSpaceId: Id, + title: z.string(), + brief: z.string(), + status: DelegatedTaskStatusSchema, + deadlineAt: z.string().nullable(), + result: z.string().nullable(), + runId: Id.nullable(), + createdAt: z.string(), + updatedAt: z.string(), + completedAt: z.string().nullable(), +}); +export type DelegatedTask = z.infer; diff --git a/packages/db/prisma/migrations/20261002200000_main_agent_foundation/migration.sql b/packages/db/prisma/migrations/20261002200000_main_agent_foundation/migration.sql new file mode 100644 index 0000000..11253a9 --- /dev/null +++ b/packages/db/prisma/migrations/20261002200000_main_agent_foundation/migration.sql @@ -0,0 +1,193 @@ +-- AlterTable +ALTER TABLE "bots" ADD COLUMN "allowedBuiltinTools" TEXT[] DEFAULT ARRAY[]::TEXT[], +ADD COLUMN "kind" TEXT NOT NULL DEFAULT 'specialist', +ADD COLUMN "modelTier" TEXT, +ADD COLUMN "restrictBuiltinTools" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "tags" TEXT[] DEFAULT ARRAY[]::TEXT[]; + +-- AlterTable +ALTER TABLE "chat_groups" ADD COLUMN "kind" TEXT NOT NULL DEFAULT 'group', +ADD COLUMN "leadBotId" TEXT; + +-- CreateTable +CREATE TABLE "space_model_tiers" ( + "id" TEXT NOT NULL, + "spaceId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "tier" TEXT NOT NULL, + "credentialId" TEXT NOT NULL, + "modelId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "space_model_tiers_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "user_profiles" ( + "userId" TEXT NOT NULL, + "timeZone" TEXT, + "locale" TEXT, + "language" TEXT, + "communicationStyle" TEXT NOT NULL DEFAULT '', + "summary" TEXT NOT NULL DEFAULT '', + "consolidatedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "user_profiles_pkey" PRIMARY KEY ("userId") +); + +-- CreateTable +CREATE TABLE "user_facts" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "category" TEXT NOT NULL, + "value" TEXT NOT NULL, + "confidence" DOUBLE PRECISION NOT NULL DEFAULT 1, + "source" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'active', + "sensitive" BOOLEAN NOT NULL DEFAULT false, + "allSpaces" BOOLEAN NOT NULL DEFAULT true, + "sourceBotId" TEXT, + "sourceThreadId" TEXT, + "sourceMessageId" TEXT, + "embedding" DOUBLE PRECISION[] DEFAULT ARRAY[]::DOUBLE PRECISION[], + "embeddingModel" TEXT, + "confirmedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "user_facts_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "user_fact_spaces" ( + "factId" TEXT NOT NULL, + "spaceId" TEXT NOT NULL, + + CONSTRAINT "user_fact_spaces_pkey" PRIMARY KEY ("factId","spaceId") +); + +-- CreateTable +CREATE TABLE "delegated_tasks" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "spaceId" TEXT NOT NULL, + "threadId" TEXT NOT NULL, + "requesterBotId" TEXT NOT NULL, + "assigneeBotId" TEXT NOT NULL, + "assigneeSpaceId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "brief" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'queued', + "deadlineAt" TIMESTAMP(3), + "result" TEXT, + "artifacts" JSONB NOT NULL DEFAULT '[]', + "runId" TEXT, + "messageId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "completedAt" TIMESTAMP(3), + + CONSTRAINT "delegated_tasks_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "space_model_tiers_credentialId_idx" ON "space_model_tiers"("credentialId"); + +-- CreateIndex +CREATE UNIQUE INDEX "space_model_tiers_spaceId_userId_tier_key" ON "space_model_tiers"("spaceId", "userId", "tier"); + +-- CreateIndex +CREATE INDEX "user_facts_userId_status_category_updatedAt_idx" ON "user_facts"("userId", "status", "category", "updatedAt"); + +-- CreateIndex +CREATE INDEX "user_fact_spaces_spaceId_idx" ON "user_fact_spaces"("spaceId"); + +-- CreateIndex +CREATE UNIQUE INDEX "delegated_tasks_runId_key" ON "delegated_tasks"("runId"); + +-- CreateIndex +CREATE INDEX "delegated_tasks_threadId_status_createdAt_idx" ON "delegated_tasks"("threadId", "status", "createdAt"); + +-- CreateIndex +CREATE INDEX "delegated_tasks_assigneeBotId_status_idx" ON "delegated_tasks"("assigneeBotId", "status"); + +-- CreateIndex +CREATE INDEX "delegated_tasks_userId_status_updatedAt_idx" ON "delegated_tasks"("userId", "status", "updatedAt"); + +-- CreateIndex +CREATE INDEX "chat_groups_leadBotId_idx" ON "chat_groups"("leadBotId"); + +-- AddForeignKey +ALTER TABLE "space_model_tiers" ADD CONSTRAINT "space_model_tiers_spaceId_userId_fkey" FOREIGN KEY ("spaceId", "userId") REFERENCES "space_members"("spaceId", "userId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "space_model_tiers" ADD CONSTRAINT "space_model_tiers_credentialId_userId_fkey" FOREIGN KEY ("credentialId", "userId") REFERENCES "user_model_credentials"("id", "userId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "chat_groups" ADD CONSTRAINT "chat_groups_leadBotId_fkey" FOREIGN KEY ("leadBotId") REFERENCES "bots"("id") ON DELETE SET NULL ON UPDATE CASCADE NOT VALID; + +-- AddForeignKey +ALTER TABLE "user_profiles" ADD CONSTRAINT "user_profiles_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "user_facts" ADD CONSTRAINT "user_facts_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "user_fact_spaces" ADD CONSTRAINT "user_fact_spaces_factId_fkey" FOREIGN KEY ("factId") REFERENCES "user_facts"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "user_fact_spaces" ADD CONSTRAINT "user_fact_spaces_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "spaces"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "delegated_tasks" ADD CONSTRAINT "delegated_tasks_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "spaces"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "delegated_tasks" ADD CONSTRAINT "delegated_tasks_threadId_fkey" FOREIGN KEY ("threadId") REFERENCES "threads"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- CHECK constraints on existing tables are added NOT VALID and validated in the next migration. +ALTER TABLE "bots" ADD CONSTRAINT "bots_kind_chk" CHECK ("kind" IN ('main', 'specialist')) NOT VALID; +ALTER TABLE "bots" ADD CONSTRAINT "bots_modelTier_chk" CHECK ("modelTier" IS NULL OR "modelTier" IN ('fast', 'standard', 'code')) NOT VALID; +ALTER TABLE "chat_groups" ADD CONSTRAINT "chat_groups_kind_chk" CHECK ("kind" IN ('group', 'main')) NOT VALID; + +-- New tables are empty, so their constraints apply immediately. +ALTER TABLE "space_model_tiers" ADD CONSTRAINT "space_model_tiers_tier_chk" CHECK ("tier" IN ('fast', 'standard', 'code')); +ALTER TABLE "user_facts" ADD CONSTRAINT "user_facts_category_chk" CHECK ("category" IN ('identity', 'work', 'goals', 'people', 'health', 'values', 'habits', 'preferences', 'tools')); +ALTER TABLE "user_facts" ADD CONSTRAINT "user_facts_source_chk" CHECK ("source" IN ('onboarding', 'user', 'conversation', 'consolidation', 'connector')); +ALTER TABLE "user_facts" ADD CONSTRAINT "user_facts_status_chk" CHECK ("status" IN ('active', 'pending')); +ALTER TABLE "user_facts" ADD CONSTRAINT "user_facts_confidence_chk" CHECK ("confidence" >= 0 AND "confidence" <= 1); +ALTER TABLE "delegated_tasks" ADD CONSTRAINT "delegated_tasks_status_chk" CHECK ("status" IN ('queued', 'running', 'waiting', 'done', 'failed', 'cancelled')); + +-- Promote one existing first bot per user to the main bot, preferring the user's home space +-- (the same order requireMembership uses to pick the default space). +UPDATE "bots" AS b +SET "kind" = 'main' +FROM ( + SELECT DISTINCT ON (fb."userId") fb."id" + FROM "bots" AS fb + JOIN "spaces" AS s ON s."id" = fb."spaceId" + LEFT JOIN "space_members" AS sm ON sm."spaceId" = fb."spaceId" AND sm."userId" = fb."userId" + WHERE fb."spawnKey" = 'onboarding:first' AND fb."archivedAt" IS NULL + ORDER BY fb."userId", s."isDefault" DESC, sm."createdAt" ASC NULLS LAST, fb."createdAt" ASC, fb."id" ASC +) AS first_bot +WHERE b."id" = first_bot."id"; + +-- Turn an existing client-created "Hauptthread" group that includes the main bot into the +-- main conversation, so its history stays where the user expects it. +UPDATE "chat_groups" AS g +SET "kind" = 'main', "leadBotId" = picked."botId" +FROM ( + SELECT DISTINCT ON (cg."userId") cg."id" AS "groupId", mb."id" AS "botId" + FROM "chat_groups" AS cg + JOIN "chat_group_members" AS cm ON cm."groupId" = cg."id" + JOIN "bots" AS mb ON mb."id" = cm."botId" AND mb."kind" = 'main' AND mb."spaceId" = cg."spaceId" + WHERE cg."archivedAt" IS NULL AND cg."name" IN ('Hauptthread', 'Team Chat') + ORDER BY cg."userId", cg."createdAt" ASC, cg."id" ASC +) AS picked +WHERE g."id" = picked."groupId"; + +-- One main bot and one main conversation per user. +CREATE UNIQUE INDEX "bots_userId_main_key" ON "bots"("userId") WHERE "kind" = 'main'; +CREATE UNIQUE INDEX "chat_groups_userId_main_key" ON "chat_groups"("userId") WHERE "kind" = 'main'; diff --git a/packages/db/prisma/migrations/20261002200001_validate_main_agent_constraints/migration.sql b/packages/db/prisma/migrations/20261002200001_validate_main_agent_constraints/migration.sql new file mode 100644 index 0000000..691e0d9 --- /dev/null +++ b/packages/db/prisma/migrations/20261002200001_validate_main_agent_constraints/migration.sql @@ -0,0 +1,6 @@ +-- Kept separate from creation so PostgreSQL validates existing rows with a SHARE UPDATE +-- EXCLUSIVE lock (reads and writes continue) after the NOT VALID constraints have committed. +ALTER TABLE "bots" VALIDATE CONSTRAINT "bots_kind_chk"; +ALTER TABLE "bots" VALIDATE CONSTRAINT "bots_modelTier_chk"; +ALTER TABLE "chat_groups" VALIDATE CONSTRAINT "chat_groups_kind_chk"; +ALTER TABLE "chat_groups" VALIDATE CONSTRAINT "chat_groups_leadBotId_fkey"; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index e628d7e..93f9a61 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -45,6 +45,8 @@ model User { createdSpaces Space[] @relation("SpaceCreator") botSecrets BotSecret[] agentSecrets AgentSecret[] + profile UserProfile? + facts UserFact[] @@unique([email]) @@map("user") @@ -211,6 +213,8 @@ model Space { botSecrets BotSecret[] externalConversations ExternalConversation[] agentSecrets AgentSecret[] + userFactVisibility UserFactSpace[] + delegatedTasks DelegatedTask[] @@unique([id, organizationId]) @@index([organizationId, createdAt]) @@ -228,6 +232,7 @@ model SpaceMember { role String @default("member") createdAt DateTime modelPreferences SpaceModelPreference[] + modelTiers SpaceModelTier[] voicePreferences SpaceVoicePreference[] aiDataConsents AiDataConsent[] @@ -281,6 +286,7 @@ model UserModelCredential { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt preferences SpaceModelPreference[] + tiers SpaceModelTier[] @@unique([id, userId]) @@index([userId, provider, updatedAt]) @@ -321,6 +327,25 @@ model SpaceModelPreference { @@map("space_model_preferences") } +/// Maps a model tier (fast, standard, code) to a connected model for one user in one space. +model SpaceModelTier { + id String @id @default(cuid()) + spaceId String + userId String + spaceMember SpaceMember @relation(fields: [spaceId, userId], references: [spaceId, userId], onDelete: Cascade) + tier String + credentialId String + credential UserModelCredential @relation(fields: [credentialId, userId], references: [id, userId], onDelete: Cascade) + modelId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([spaceId, userId, tier]) + @@index([credentialId]) + // CHECK constraint space_model_tiers_tier_chk is created in SQL. + @@map("space_model_tiers") +} + model SpaceVoicePreference { id String @id @default(cuid()) spaceId String @@ -376,6 +401,12 @@ model Bot { webhookSecretId String? teamChatAmbientEnabled Boolean @default(false) teamChatRules String @default("") + /// "main" for the user's single lead agent, "specialist" for every other bot. + kind String @default("specialist") + tags String[] @default([]) + restrictBuiltinTools Boolean @default(false) + allowedBuiltinTools String[] @default([]) + modelTier String? routines Routine[] scratchpadItems ScratchpadItem[] taughtSkills TaughtSkill[] @@ -388,11 +419,13 @@ model Bot { steeringMessages SteeringMessage[] mcpServers BotMcpServer[] groupMembers ChatGroupMember[] + ledGroups ChatGroup[] @relation("ChatGroupLead") botSecrets BotSecret[] externalConversations ExternalConversation[] @@unique([spaceId, spawnKey]) + // CHECK bots_kind_chk and partial unique index bots_userId_main_key are created in SQL. @@index([spaceId, userId, archivedAt, pinned, updatedAt]) @@index([sectionId]) @@index([computerId]) @@ -438,6 +471,10 @@ model ChatGroup { space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade) userId String name String + /// "main" for the user's main conversation led by the main bot, "group" otherwise. + kind String @default("group") + leadBotId String? + leadBot Bot? @relation("ChatGroupLead", fields: [leadBotId], references: [id], onDelete: SetNull) pinned Boolean @default(false) sectionId String? section BotSection? @relation(fields: [sectionId], references: [id], onDelete: SetNull) @@ -451,6 +488,8 @@ model ChatGroup { @@index([spaceId, userId, archivedAt, pinned, updatedAt]) @@index([sectionId]) + @@index([leadBotId]) + // CHECK chat_groups_kind_chk and partial unique index chat_groups_userId_main_key are created in SQL. @@map("chat_groups") } @@ -490,6 +529,7 @@ model Thread { tasks Task[] runs Run[] routines Routine[] + delegatedTasks DelegatedTask[] @@index([spaceId]) @@map("threads") @@ -1346,3 +1386,88 @@ model IntegrationProviderConfig { @@map("integration_provider_configs") } + +/// What the user's agents know about the user across spaces. One row per user. +model UserProfile { + userId String @id + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + timeZone String? + locale String? + language String? + communicationStyle String @default("") + summary String @default("") + consolidatedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("user_profiles") +} + +/// One durable statement about the user, with its origin and consent state. +model UserFact { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + category String + value String + confidence Float @default(1) + source String + status String @default("active") + sensitive Boolean @default(false) + allSpaces Boolean @default(true) + sourceBotId String? + sourceThreadId String? + sourceMessageId String? + embedding Float[] @default([]) + embeddingModel String? + confirmedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + visibleSpaces UserFactSpace[] + + @@index([userId, status, category, updatedAt]) + // CHECK constraints user_facts_category_chk, user_facts_source_chk and user_facts_status_chk are created in SQL. + @@map("user_facts") +} + +/// Spaces a fact is limited to when UserFact.allSpaces is false. +model UserFactSpace { + factId String + fact UserFact @relation(fields: [factId], references: [id], onDelete: Cascade) + spaceId String + space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade) + + @@id([factId, spaceId]) + @@index([spaceId]) + @@map("user_fact_spaces") +} + +/// Work the main bot hands to a specialist, reported back in the main conversation. +model DelegatedTask { + id String @id @default(cuid()) + userId String + spaceId String + space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade) + threadId String + thread Thread @relation(fields: [threadId], references: [id], onDelete: Cascade) + requesterBotId String + assigneeBotId String + assigneeSpaceId String + title String + brief String + status String @default("queued") + deadlineAt DateTime? + result String? + artifacts Json @default("[]") + runId String? @unique + messageId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + completedAt DateTime? + + @@index([threadId, status, createdAt]) + @@index([assigneeBotId, status]) + @@index([userId, status, updatedAt]) + // CHECK constraint delegated_tasks_status_chk is created in SQL. + @@map("delegated_tasks") +} diff --git a/packages/db/src/groups.test.ts b/packages/db/src/groups.test.ts index 6143105..d8c42e2 100644 --- a/packages/db/src/groups.test.ts +++ b/packages/db/src/groups.test.ts @@ -9,6 +9,7 @@ describe("listSpaceGroupsForSpaces", () => { { id: "group-1", spaceId: "workspace-2", + kind: "group", name: "Support crew", pinned: true, sectionId: null, @@ -42,6 +43,7 @@ describe("listSpaceGroupsForSpaces", () => { { id: "group-1", spaceId: "workspace-2", + kind: "group", name: "Support crew", pinned: true, sectionId: null, diff --git a/packages/db/src/groups.ts b/packages/db/src/groups.ts index e6b7ba0..36f0332 100644 --- a/packages/db/src/groups.ts +++ b/packages/db/src/groups.ts @@ -3,6 +3,7 @@ import { GROUP_MEMBER_MAX, GROUP_MEMBER_MIN, type Group, + type GroupKind, type GroupMember, type ProjectSession, type RunStatus, @@ -21,6 +22,8 @@ type GroupRecord = { spaceId: string; userId: string; name: string; + kind: string; + leadBotId: string | null; pinned: boolean; sectionId: string | null; archivedAt: Date | null; @@ -43,7 +46,7 @@ type GroupRecord = { type SpaceGroupRecord = Pick< GroupRecord, - "id" | "spaceId" | "name" | "pinned" | "sectionId" | "updatedAt" | "members" + "id" | "spaceId" | "kind" | "name" | "pinned" | "sectionId" | "updatedAt" | "members" > & { thread: { unread: boolean; @@ -67,6 +70,8 @@ function mapGroup(group: GroupRecord): Group { id: group.id, spaceId: group.spaceId, name: group.name, + kind: parseGroupKind(group.kind), + leadBotId: group.leadBotId, pinned: group.pinned, sectionId: group.sectionId, archivedAt: group.archivedAt?.toISOString() ?? null, @@ -102,6 +107,7 @@ function mapSpaceGroup(group: SpaceGroupRecord): SpaceGroup { return { id: group.id, spaceId: group.spaceId, + kind: parseGroupKind(group.kind), name: group.name, pinned: group.pinned, sectionId: group.sectionId, @@ -112,8 +118,14 @@ function mapSpaceGroup(group: SpaceGroupRecord): SpaceGroup { }; } -function hasMinimumActiveMembers(members: readonly unknown[]) { - return members.length >= GROUP_MEMBER_MIN; +function parseGroupKind(kind: string): GroupKind { + return kind === "main" ? "main" : "group"; +} + +/** The main conversation starts with only its lead, so the two-member floor does not apply. */ +function hasMinimumActiveMembers(group: { kind: string; members: readonly unknown[] }) { + if (group.kind === "main") return group.members.length >= 1; + return group.members.length >= GROUP_MEMBER_MIN; } async function assertOwnedBots( @@ -197,6 +209,7 @@ export function createGroupRepos(prisma: PrismaClient) { select: { id: true, spaceId: true, + kind: true, name: true, pinned: true, sectionId: true, @@ -216,7 +229,7 @@ export function createGroupRepos(prisma: PrismaClient) { orderBy: [{ pinned: "desc" }, { updatedAt: "desc" }], }); return groups - .filter((group) => hasMinimumActiveMembers(group.members)) + .filter((group) => hasMinimumActiveMembers(group)) .map((group) => mapSpaceGroup(group)); } @@ -232,7 +245,7 @@ export function createGroupRepos(prisma: PrismaClient) { orderBy: [{ pinned: "desc" }, { updatedAt: "desc" }], }); return groups - .filter((group) => hasMinimumActiveMembers(group.members)) + .filter((group) => hasMinimumActiveMembers(group)) .map((group) => mapGroup(group as GroupRecord)); }, @@ -248,7 +261,7 @@ export function createGroupRepos(prisma: PrismaClient) { }, include: groupInclude, }); - if (!group || !hasMinimumActiveMembers(group.members)) throw new IsolationError(); + if (!group || !hasMinimumActiveMembers(group)) throw new IsolationError(); return group as GroupRecord; }, @@ -262,7 +275,7 @@ export function createGroupRepos(prisma: PrismaClient) { }, include: groupTargetInclude, }); - if (!group || !hasMinimumActiveMembers(group.members)) throw new IsolationError(); + if (!group || !hasMinimumActiveMembers(group)) throw new IsolationError(); return group; }, @@ -324,11 +337,14 @@ export function createGroupRepos(prisma: PrismaClient) { }, }); if (!current?.thread) throw new IsolationError(); + // The main conversation's members change only through addMainConversationMembers. + if (current.kind === "main" && members) throw new IsolationError(); if ( !members && - !hasMinimumActiveMembers( - current.members.filter((member) => member.bot.archivedAt === null), - ) + !hasMinimumActiveMembers({ + kind: current.kind, + members: current.members.filter((member) => member.bot.archivedAt === null), + }) ) { throw new IsolationError(); } diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 9436814..7a2cf76 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -6,6 +6,7 @@ export * from "./credential-secrets.js"; export * from "./events.js"; export * from "./external-conversations.js"; export * from "./groups.js"; +export * from "./main-agent.js"; export * from "./memory-config.js"; export * from "./messages.js"; export * from "./messaging.js"; diff --git a/packages/db/src/main-agent.postgres.test.ts b/packages/db/src/main-agent.postgres.test.ts new file mode 100644 index 0000000..b5e603f --- /dev/null +++ b/packages/db/src/main-agent.postgres.test.ts @@ -0,0 +1,340 @@ +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 { createGroupRepos } from "./groups.js"; +import { + addMainConversationMembers, + ensureMainBot, + ensureMainConversation, + findMainBotId, + joinMainConversation, + loadUserContext, + MAIN_BOT_SPAWN_KEY, +} from "./main-agent.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("main agent and main conversation (PostgreSQL)", () => { + let prisma: PrismaClient; + let close: () => Promise; + const userIds: string[] = []; + + async function newUser(name = "Ada") { + // bootstrapUserSpace derives the organization slug from the first 12 characters. + const id = `ma${randomUUID().replaceAll("-", "")}`; + userIds.push(id); + await prisma.user.create({ data: { id, name, email: `${id}@rakazo.test` } }); + const { spaceId } = await bootstrapUserSpace(prisma, { id }, signupEnv, { + claimDeploymentOwner: false, + }); + return { userId: id, homeSpaceId: spaceId }; + } + + 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("creates one main bot in the home space, even under concurrent calls", async () => { + const { userId, homeSpaceId } = await newUser(); + const results = await Promise.all([ + ensureMainBot(prisma, userId), + ensureMainBot(prisma, userId), + ensureMainBot(prisma, userId), + ]); + expect(new Set(results.map((bot) => bot.id)).size).toBe(1); + const bots = await prisma.bot.findMany({ where: { userId } }); + expect(bots).toHaveLength(1); + expect(bots[0]).toMatchObject({ + kind: "main", + spaceId: homeSpaceId, + spawnKey: MAIN_BOT_SPAWN_KEY, + name: "Chief", + }); + expect(await findMainBotId(prisma, userId)).toBe(bots[0]?.id); + }); + + it("creates the main bot without the spawn key when another user holds it in a shared space", async () => { + const owner = await newUser(); + const ownerMain = await ensureMainBot(prisma, owner.userId); + const guestId = `ma${randomUUID().replaceAll("-", "")}`; + userIds.push(guestId); + await prisma.user.create({ + data: { id: guestId, name: "Guest", email: `${guestId}@rakazo.test` }, + }); + const organization = await prisma.space.findUniqueOrThrow({ + where: { id: owner.homeSpaceId }, + select: { organizationId: true }, + }); + // The organization's default-space trigger makes the guest a member of the owner's space. + await prisma.member.create({ + data: { + id: `member-${guestId}`, + organizationId: organization.organizationId, + userId: guestId, + role: "member", + createdAt: new Date(), + }, + }); + const guestMain = await ensureMainBot(prisma, guestId); + expect(guestMain.id).not.toBe(ownerMain.id); + const guestBot = await prisma.bot.findUniqueOrThrow({ where: { id: guestMain.id } }); + expect(guestBot).toMatchObject({ kind: "main", userId: guestId, spawnKey: null }); + }); + + it("promotes the first bot onboarding already created instead of adding another", async () => { + const { userId } = await newUser(); + const actor = await requireMembership(prisma, userId); + const first = await createRepos(prisma).createBot(actor, { + name: "Nova", + title: "Chief of Staff", + description: "", + instructions: "", + notifyOnFinish: true, + spawnKey: MAIN_BOT_SPAWN_KEY, + }); + expect(first.kind).toBe("specialist"); + const main = await ensureMainBot(prisma, userId); + expect(main).toMatchObject({ id: first.id, name: "Nova" }); + expect(await prisma.bot.count({ where: { userId } })).toBe(1); + }); + + it("restores an archived main bot rather than retiring it", async () => { + const { userId } = await newUser(); + const main = await ensureMainBot(prisma, userId); + await prisma.bot.update({ where: { id: main.id }, data: { archivedAt: new Date() } }); + const again = await ensureMainBot(prisma, userId); + expect(again.id).toBe(main.id); + const row = await prisma.bot.findUniqueOrThrow({ where: { id: main.id } }); + expect(row.archivedAt).toBeNull(); + }); + + it("creates one main conversation led by the main bot that lists with a single member", async () => { + const { userId } = await newUser(); + const [a, b] = await Promise.all([ + ensureMainConversation(prisma, userId), + ensureMainConversation(prisma, userId), + ]); + expect(a.groupId).toBe(b.groupId); + const main = await ensureMainBot(prisma, userId); + expect(a).toMatchObject({ leadBotId: main.id, spaceId: main.spaceId }); + + const actor = await requireMembership(prisma, userId); + const groups = await createGroupRepos(prisma).listGroups(actor); + expect(groups).toHaveLength(1); + expect(groups[0]).toMatchObject({ + id: a.groupId, + kind: "main", + leadBotId: main.id, + members: [expect.objectContaining({ botId: main.id })], + }); + await expect(createGroupRepos(prisma).getGroupTarget(actor, a.groupId)).resolves.toMatchObject({ + id: a.groupId, + }); + }); + + it("repairs a main conversation whose lead was lost or that was archived", async () => { + const { userId } = await newUser(); + const conversation = await ensureMainConversation(prisma, userId); + await prisma.chatGroup.update({ + where: { id: conversation.groupId }, + data: { leadBotId: null, archivedAt: new Date() }, + }); + const repaired = await ensureMainConversation(prisma, userId); + expect(repaired.groupId).toBe(conversation.groupId); + const row = await prisma.chatGroup.findUniqueOrThrow({ where: { id: conversation.groupId } }); + expect(row).toMatchObject({ leadBotId: conversation.leadBotId, archivedAt: null }); + }); + + it("adds only active same-space bots of the same user, once", async () => { + const { userId, homeSpaceId } = await newUser(); + const other = await newUser("Bo"); + const actor = await requireMembership(prisma, userId); + const repos = createRepos(prisma); + const specialist = await repos.createBot(actor, { + name: "Researcher", + title: "", + description: "", + instructions: "", + notifyOnFinish: true, + }); + const project = await createSpaceForMember(prisma, { + currentSpaceId: homeSpaceId, + userId, + name: "Project", + }); + const projectActor = await requireMembership(prisma, userId, project.id); + const elsewhere = await repos.createBot(projectActor, { + name: "Designer", + title: "", + description: "", + instructions: "", + notifyOnFinish: true, + }); + const foreignActor = await requireMembership(prisma, other.userId); + const foreign = await repos.createBot(foreignActor, { + name: "Intruder", + title: "", + description: "", + instructions: "", + notifyOnFinish: true, + }); + + const added = await addMainConversationMembers(prisma, { + userId, + botIds: [specialist.id, elsewhere.id, foreign.id, specialist.id], + }); + expect(added).toEqual([specialist.id]); + expect(await addMainConversationMembers(prisma, { userId, botIds: [specialist.id] })).toEqual( + [], + ); + + const conversation = await ensureMainConversation(prisma, userId); + const members = await prisma.chatGroupMember.findMany({ + where: { groupId: conversation.groupId }, + select: { botId: true }, + }); + expect(members.map((member) => member.botId).sort()).toEqual( + [conversation.leadBotId, specialist.id].sort(), + ); + }); + + it("announces joined members on the main conversation thread", async () => { + const { userId } = await newUser(); + const actor = await requireMembership(prisma, userId); + const specialist = await createRepos(prisma).createBot(actor, { + name: "Analyst", + title: "", + description: "", + instructions: "", + notifyOnFinish: true, + }); + const published: string[] = []; + const realtime = { + publish: async (topic: string) => { + published.push(topic); + }, + subscribe: async () => () => undefined, + }; + expect( + await joinMainConversation(prisma, { userId, botIds: [specialist.id] }, realtime as never), + ).toEqual([specialist.id]); + expect( + await joinMainConversation(prisma, { userId, botIds: [specialist.id] }, realtime as never), + ).toEqual([]); + const conversation = await ensureMainConversation(prisma, userId); + const events = await prisma.event.findMany({ + where: { threadId: conversation.threadId, type: "group.updated" }, + }); + expect(events).toHaveLength(1); + expect(events[0]?.payload).toEqual({ + groupId: conversation.groupId, + addedBotIds: [specialist.id], + }); + expect(published).toHaveLength(1); + }); + + it("refuses to replace the main conversation's members through a group update", async () => { + const { userId } = await newUser(); + const actor = await requireMembership(prisma, userId); + const repos = createRepos(prisma); + const one = await repos.createBot(actor, { + name: "One", + title: "", + description: "", + instructions: "", + notifyOnFinish: true, + }); + const two = await repos.createBot(actor, { + name: "Two", + title: "", + description: "", + instructions: "", + notifyOnFinish: true, + }); + const conversation = await ensureMainConversation(prisma, userId); + await expect( + createGroupRepos(prisma).updateGroup(actor, { + groupId: conversation.groupId, + botIds: [one.id, two.id], + }), + ).rejects.toThrow(); + await expect( + createGroupRepos(prisma).updateGroup(actor, { groupId: conversation.groupId, pinned: true }), + ).resolves.toMatchObject({ group: { kind: "main", pinned: true } }); + }); + + it("hands runs only active facts visible in their space", async () => { + const { userId, homeSpaceId } = await newUser(); + const project = await createSpaceForMember(prisma, { + currentSpaceId: homeSpaceId, + userId, + name: "Project", + }); + await prisma.userProfile.create({ + data: { userId, timeZone: "Europe/Berlin", locale: "de-DE", communicationStyle: "direct" }, + }); + await prisma.userFact.create({ + data: { userId, category: "work", value: "Builds evOo", source: "onboarding" }, + }); + await prisma.userFact.create({ + data: { + userId, + category: "health", + value: "Pending sensitive fact", + source: "conversation", + status: "pending", + sensitive: true, + }, + }); + await prisma.userFact.create({ + data: { + userId, + category: "goals", + value: "Ship the project", + source: "user", + allSpaces: false, + visibleSpaces: { create: [{ spaceId: project.id }] }, + }, + }); + + const home = await loadUserContext(prisma, { userId, spaceId: homeSpaceId }); + expect(home).toMatchObject({ + name: "Ada", + timeZone: "Europe/Berlin", + locale: "de-DE", + communicationStyle: "direct", + }); + expect(home.facts.map((fact) => fact.value)).toEqual(["Builds evOo"]); + + const inProject = await loadUserContext(prisma, { userId, spaceId: project.id }); + expect(inProject.facts.map((fact) => fact.value).sort()).toEqual( + ["Builds evOo", "Ship the project"].sort(), + ); + }); +}); diff --git a/packages/db/src/main-agent.ts b/packages/db/src/main-agent.ts new file mode 100644 index 0000000..6546da4 --- /dev/null +++ b/packages/db/src/main-agent.ts @@ -0,0 +1,287 @@ +import type { RealtimeFanout } from "@rakazo/adapter-kit"; +import type { UserFactCategory } from "@rakazo/contracts"; +import type { PrismaClient } from "./client.js"; +import { appendEvent } from "./events.js"; +import { createRepos } from "./repos.js"; +import { IsolationError, requireMembership } from "./scope.js"; +import { lockSpaceForContentCreation } from "./spaces.js"; + +/** Idempotency key of the first bot; the main bot keeps it in its home space. */ +export const MAIN_BOT_SPAWN_KEY = "onboarding:first"; + +/** Name and role of a freshly created main bot; onboarding lets the user rename it. */ +export const MAIN_BOT_DEFAULTS = { + name: "Chief", + title: "Chief of Staff", + description: "Coordinates specialists — routes work, never a do-everything generalist.", +} as const; + +/** Upper bound on facts handed to a run, so the user block stays small. */ +export const USER_CONTEXT_FACT_LIMIT = 60; + +export interface MainBotRef { + id: string; + spaceId: string; + threadId: string; + name: string; +} + +export interface MainConversation { + groupId: string; + threadId: string; + spaceId: string; + leadBotId: string; +} + +/** What every run may know about the user: the profile plus active facts visible in a space. */ +export interface UserContext { + name: string; + timeZone: string | null; + locale: string | null; + language: string | null; + communicationStyle: string; + summary: string; + facts: Array<{ category: UserFactCategory; value: string; confidence: number }>; +} + +function isUniqueViolation(error: unknown): boolean { + return Boolean(error && typeof error === "object" && "code" in error && error.code === "P2002"); +} + +const mainBotSelect = { + id: true, + spaceId: true, + name: true, + archivedAt: true, + thread: { select: { id: true } }, +} as const; + +async function findMainBot(prisma: PrismaClient, userId: string) { + return prisma.bot.findFirst({ where: { userId, kind: "main" }, select: mainBotSelect }); +} + +export async function findMainBotId(prisma: PrismaClient, userId: string): Promise { + const bot = await prisma.bot.findFirst({ + where: { userId, kind: "main", archivedAt: null }, + select: { id: true }, + }); + return bot?.id ?? null; +} + +/** + * Returns the user's main bot, creating it in the home space when it does not exist yet. + * A first bot that onboarding already created there is promoted instead of duplicated, and + * an archived main bot is restored, because the main bot cannot be retired. + */ +export async function ensureMainBot(prisma: PrismaClient, userId: string): Promise { + let main = await findMainBot(prisma, userId); + if (!main) { + const home = await requireMembership(prisma, userId); + const first = await prisma.bot.findFirst({ + where: { + userId, + spaceId: home.spaceId, + spawnKey: MAIN_BOT_SPAWN_KEY, + archivedAt: null, + }, + select: { id: true }, + }); + try { + if (first) { + await prisma.bot.update({ where: { id: first.id }, data: { kind: "main" } }); + } else { + // spawnKey is unique per space, so in a space shared with another user who already + // holds it, the main bot is created without one. + const keyHolder = await prisma.bot.findUnique({ + where: { spaceId_spawnKey: { spaceId: home.spaceId, spawnKey: MAIN_BOT_SPAWN_KEY } }, + select: { userId: true }, + }); + await createRepos(prisma).createBot(home, { + ...MAIN_BOT_DEFAULTS, + instructions: "", + notifyOnFinish: true, + ...(keyHolder && keyHolder.userId !== userId ? {} : { spawnKey: MAIN_BOT_SPAWN_KEY }), + kind: "main", + }); + } + } catch (error) { + // A concurrent call promoted or created the main bot first. + if (!isUniqueViolation(error)) throw error; + } + main = await findMainBot(prisma, userId); + if (!main) throw new IsolationError("Main bot could not be created"); + } + if (main.archivedAt) { + await prisma.bot.update({ where: { id: main.id }, data: { archivedAt: null } }); + } + if (!main.thread) throw new IsolationError("Main bot is missing its thread"); + return { id: main.id, spaceId: main.spaceId, threadId: main.thread.id, name: main.name }; +} + +async function findMainConversation(prisma: PrismaClient, userId: string, mainBotId: string) { + return prisma.chatGroup.findFirst({ + where: { userId, kind: "main" }, + select: { + id: true, + spaceId: true, + leadBotId: true, + archivedAt: true, + thread: { select: { id: true } }, + members: { where: { botId: mainBotId }, select: { id: true } }, + }, + }); +} + +/** + * Returns the user's main conversation: a group thread in the main bot's space whose lead and + * default responder is the main bot. It is created on first use and repaired when its lead + * was lost or it was archived. + */ +export async function ensureMainConversation( + prisma: PrismaClient, + userId: string, +): Promise { + const main = await ensureMainBot(prisma, userId); + let group = await findMainConversation(prisma, userId, main.id); + if (!group) { + try { + await prisma.$transaction(async (tx) => { + await lockSpaceForContentCreation(tx, { spaceId: main.spaceId, userId }); + const created = await tx.chatGroup.create({ + data: { + spaceId: main.spaceId, + userId, + name: main.name, + kind: "main", + leadBotId: main.id, + }, + }); + await tx.chatGroupMember.create({ data: { groupId: created.id, botId: main.id } }); + await tx.thread.create({ + data: { spaceId: main.spaceId, groupId: created.id, userId }, + }); + }); + } catch (error) { + // A concurrent call created the main conversation first. + if (!isUniqueViolation(error)) throw error; + } + group = await findMainConversation(prisma, userId, main.id); + } else { + const repairs: { leadBotId?: string; archivedAt?: null } = {}; + if (group.leadBotId !== main.id) repairs.leadBotId = main.id; + if (group.archivedAt) repairs.archivedAt = null; + if (repairs.leadBotId !== undefined || repairs.archivedAt !== undefined) { + await prisma.chatGroup.update({ where: { id: group.id }, data: repairs }); + } + if (group.members.length === 0) { + await prisma.chatGroupMember.createMany({ + data: [{ groupId: group.id, botId: main.id }], + skipDuplicates: true, + }); + } + } + if (!group?.thread) throw new IsolationError("Main conversation could not be created"); + return { + groupId: group.id, + threadId: group.thread.id, + spaceId: group.spaceId, + leadBotId: main.id, + }; +} + +/** + * Adds bots to the user's main conversation so they appear on its stage. Only active bots of + * the same user in the conversation's space qualify; others are skipped. Returns the ids that + * were not members before, so callers can announce the change. + */ +export async function addMainConversationMembers( + prisma: PrismaClient, + input: { userId: string; botIds: string[] }, +): Promise { + const requested = [...new Set(input.botIds)]; + if (requested.length === 0) return []; + const conversation = await ensureMainConversation(prisma, input.userId); + const bots = await prisma.bot.findMany({ + where: { + id: { in: requested }, + userId: input.userId, + spaceId: conversation.spaceId, + archivedAt: null, + }, + select: { id: true }, + }); + if (bots.length === 0) return []; + const existing = await prisma.chatGroupMember.findMany({ + where: { groupId: conversation.groupId, botId: { in: bots.map((bot) => bot.id) } }, + select: { botId: true }, + }); + const present = new Set(existing.map((member) => member.botId)); + const added = bots.map((bot) => bot.id).filter((botId) => !present.has(botId)); + if (added.length > 0) { + await prisma.chatGroupMember.createMany({ + data: added.map((botId) => ({ groupId: conversation.groupId, botId })), + skipDuplicates: true, + }); + } + return added; +} + +/** + * Adds bots to the main conversation and, when any joined, appends a `group.updated` event to + * its thread so open clients refresh the stage. Returns the ids that joined. + */ +export async function joinMainConversation( + prisma: PrismaClient, + input: { userId: string; botIds: string[] }, + realtime?: RealtimeFanout, +): Promise { + const added = await addMainConversationMembers(prisma, input); + if (added.length === 0) return added; + const conversation = await ensureMainConversation(prisma, input.userId); + await appendEvent( + prisma, + { + spaceId: conversation.spaceId, + threadId: conversation.threadId, + botId: conversation.leadBotId, + type: "group.updated", + payload: { groupId: conversation.groupId, addedBotIds: added }, + }, + realtime, + ); + return added; +} + +/** Profile and active facts a run in `spaceId` may use; pending and other-space facts stay out. */ +export async function loadUserContext( + prisma: PrismaClient, + input: { userId: string; spaceId: string }, +): Promise { + const [user, profile, facts] = await Promise.all([ + prisma.user.findUnique({ where: { id: input.userId }, select: { name: true } }), + prisma.userProfile.findUnique({ where: { userId: input.userId } }), + prisma.userFact.findMany({ + where: { + userId: input.userId, + status: "active", + OR: [{ allSpaces: true }, { visibleSpaces: { some: { spaceId: input.spaceId } } }], + }, + orderBy: [{ category: "asc" }, { updatedAt: "desc" }], + take: USER_CONTEXT_FACT_LIMIT, + select: { category: true, value: true, confidence: true }, + }), + ]); + return { + name: user?.name ?? "", + timeZone: profile?.timeZone ?? null, + locale: profile?.locale ?? null, + language: profile?.language ?? null, + communicationStyle: profile?.communicationStyle ?? "", + summary: profile?.summary ?? "", + facts: facts.map((fact) => ({ + category: fact.category as UserFactCategory, + value: fact.value, + confidence: fact.confidence, + })), + }; +} diff --git a/packages/db/src/repos.test.ts b/packages/db/src/repos.test.ts index 5432f3c..6c900dd 100644 --- a/packages/db/src/repos.test.ts +++ b/packages/db/src/repos.test.ts @@ -302,6 +302,7 @@ describe("createRepos.listSpaceBotsForSpaces", () => { { id: "bot-2", spaceId: "ws-2", + kind: "specialist", name: "Support", title: "Customer support", color: "#123456", @@ -322,6 +323,7 @@ describe("createRepos.listSpaceBotsForSpaces", () => { { id: "bot-2", spaceId: "ws-2", + kind: "specialist", name: "Support", title: "Customer support", color: "#123456", diff --git a/packages/db/src/repos.ts b/packages/db/src/repos.ts index 976565a..c8d1c0c 100644 --- a/packages/db/src/repos.ts +++ b/packages/db/src/repos.ts @@ -2,6 +2,7 @@ import { type Actor, BOT_COLORS, type Bot, + type BotKind, type BotSection, type MessageBlock, type SpaceBot, @@ -57,6 +58,11 @@ function mapBot( teamChatRules?: string; webhookSecretId?: string | null; spawnKey?: string | null; + kind?: string; + tags?: string[]; + restrictBuiltinTools?: boolean; + allowedBuiltinTools?: string[]; + modelTier?: string | null; }, preview = "", status = "idle", @@ -94,9 +100,18 @@ function mapBot( 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, @@ -128,6 +143,7 @@ export function createRepos(prisma: PrismaClient) { select: { id: true, spaceId: true, + kind: true, name: true, title: true, color: true, @@ -154,6 +170,7 @@ export function createRepos(prisma: PrismaClient) { return { id: bot.id, spaceId: bot.spaceId, + kind: bot.kind === "main" ? "main" : "specialist", name: bot.name, title: bot.title, color: bot.color, @@ -382,6 +399,8 @@ export function createRepos(prisma: PrismaClient) { 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; @@ -451,6 +470,7 @@ export function createRepos(prisma: PrismaClient) { parentBotId: input.parentBotId ?? null, computerId: teamComputer.id, spawnKey: input.spawnKey, + kind: input.kind ?? "specialist", modelProvider, modelId, thinkingLevel, diff --git a/packages/testkit/src/cli/harness.ts b/packages/testkit/src/cli/harness.ts index 25e36ce..272712a 100644 --- a/packages/testkit/src/cli/harness.ts +++ b/packages/testkit/src/cli/harness.ts @@ -104,6 +104,7 @@ async function main() { "packages/testkit/src/connections.test.ts", "packages/testkit/src/bot-secrets.test.ts", "packages/db/src/space-membership.postgres.test.ts", + "packages/db/src/main-agent.postgres.test.ts", "packages/db/src/messaging.postgres.test.ts", "packages/memory/src/commit.postgres.test.ts", "packages/adapters/src/wakeup.postgres.test.ts",