feat(db): main agent, main conversation, user model and delegated task foundation

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
cynarAI 2026-10-02 20:49:36 +00:00
parent 4c619b25b9
commit 86723f0f6f
No known key found for this signature in database
14 changed files with 1132 additions and 10 deletions

View File

@ -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,

View File

@ -35,6 +35,11 @@ function bot(overrides: Partial<Bot> = {}): Bot {
teamChatRules: "",
webhookConfigured: false,
spawnKey: null,
kind: "specialist",
tags: [],
restrictBuiltinTools: false,
allowedBuiltinTools: [],
modelTier: null,
...overrides,
};
}
@ -44,6 +49,8 @@ function group(overrides: Partial<Group> = {}): Group {
id: "group-1",
spaceId: "space-1",
name: "Launch",
kind: "group",
leadBotId: null,
pinned: false,
sectionId: null,
archivedAt: null,

View File

@ -39,6 +39,16 @@ export const AgentSecretInputSchema = z.object({
});
export type AgentSecretInput = z.infer<typeof AgentSecretInputSchema>;
/** "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<typeof BotKindSchema>;
/** 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<typeof ModelTierSchema>;
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<typeof BotSchema>;
@ -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<typeof GroupKindSchema>;
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<typeof BotSectionSchema>;
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<typeof SpaceBotSchema>;
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<typeof ExportManifestSchema>;
/** 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<typeof UserFactCategorySchema>;
/** 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<typeof UserFactSourceSchema>;
/** "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<typeof UserFactStatusSchema>;
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<typeof UserProfileSchema>;
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<typeof UserFactSchema>;
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<typeof DelegatedTaskStatusSchema>;
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<typeof DelegatedTaskSchema>;

View File

@ -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';

View File

@ -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";

View File

@ -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")
}

View File

@ -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,

View File

@ -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();
}

View File

@ -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";

View File

@ -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<void>;
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(),
);
});
});

View File

@ -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<string | null> {
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<MainBotRef> {
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<MainConversation> {
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<string[]> {
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<string[]> {
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<UserContext> {
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,
})),
};
}

View File

@ -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",

View File

@ -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,

View File

@ -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",