From 86723f0f6fab796cb44ab9697536ac1dc7b93e08 Mon Sep 17 00:00:00 2001 From: cynarAI Date: Fri, 2 Oct 2026 20:49:36 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- apps/api/src/router.ts | 2 + apps/web/src/lib/heute-brief.test.ts | 7 + packages/contracts/src/domain.ts | 120 +++++++ .../migration.sql | 193 ++++++++++ .../migration.sql | 6 + packages/db/prisma/schema.prisma | 125 +++++++ packages/db/src/groups.test.ts | 2 + packages/db/src/groups.ts | 36 +- packages/db/src/index.ts | 1 + packages/db/src/main-agent.postgres.test.ts | 340 ++++++++++++++++++ packages/db/src/main-agent.ts | 287 +++++++++++++++ packages/db/src/repos.test.ts | 2 + packages/db/src/repos.ts | 20 ++ packages/testkit/src/cli/harness.ts | 1 + 14 files changed, 1132 insertions(+), 10 deletions(-) create mode 100644 packages/db/prisma/migrations/20261002200000_main_agent_foundation/migration.sql create mode 100644 packages/db/prisma/migrations/20261002200001_validate_main_agent_constraints/migration.sql create mode 100644 packages/db/src/main-agent.postgres.test.ts create mode 100644 packages/db/src/main-agent.ts 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",