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

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

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

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

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

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

194 lines
8.4 KiB
SQL

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