evOo-local/packages/db/prisma/schema.prisma
cynarAI a35e431d1c
feat: main agent with a main conversation, user model and delegation (#101)
* 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

* feat(adapters): every run knows who it is and who the user is, in the user's time zone

Runs dropped the bot's name, title and description whenever custom
instructions existed, always stated the time in UTC, and knew the user only
through free-form durable memory. Groups with a lead, like the main
conversation, still got the peer-group lines that forbid handing work back.
And every group follow-up run failed during setup: the inbound handoff lookup
selected a `bot` relation that Message does not have, which Prisma rejects.

- Instructions now open with the bot's identity, then its own instructions,
  a main-agent role paragraph for the main bot, the current time in the
  user's time zone with its offset (UTC when unknown), and a `<user>` block
  built from loadUserContext: profile fields and the active facts visible in
  the run's space, escaped, clipped and framed as data rather than
  instructions. Runs that reply in a shared messaging channel get only the
  name, zone, locale, language and style, matching how those runs already
  leave out durable memory.
- run_subagent helpers receive the identity, time and user block through a
  new optional AgentRunRequest.subagentContext instead of nothing.
- Groups with an active lead get lead and specialist lines and matching
  inbound handoff instructions. Members may hand a stage back to the lead and
  the lead may delegate the next stage; the bounce rules still apply between
  members. Ordinary groups keep today's lines.
- The teammate directory is rendered only when the run has message_bot, which
  group runs never have, and tool guidance names a tool only when the run has
  it.
- The auto-review judge, history compaction and the team chat judge read the
  time in the user's zone, and schedule_create defaults to it.
- The inbound handoff sender is read from the handoff block and looked up
  among the user's bots; a Postgres test covers the follow-up run, the prompt
  it receives, and handing the result back to the lead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* feat(memory): learn the user profile from conversations; add built-in semantic memory

Why: the user profile only knew what onboarding wrote, so people had to repeat themselves,
and semantic recall needed a hosted or separately run memory service. The profile now grows
from everyday conversations, and recall can run on the user's own server with a local
embeddings endpoint (for example llama.cpp). Both stay optional and are tested offline.

What:
- adapter-kit: EmbeddingProvider contract (describe, embed) and the profile.consolidate job
  (payload schema and a replace-keyed job factory, one pending job per user).
- adapters: OpenAI-compatible embeddings (POST {baseUrl}/embeddings through the guarded
  OpenAI-compatible fetch, batching, bounded responses, clear errors), a deterministic
  emulator, an in-process /v1/embeddings server emulator, and a shared conformance suite.
- adapters: "builtin" semantic memory stored in Postgres (semantic_memory_items with Float[]
  embeddings, cosine ranking in process, bot/shared/history scopes matching the existing
  providers), registered in the memory provider factory and configured through
  memory.connectProvider with an embeddings URL, model and optional API key. Private
  endpoints require the deployment owner, like the other local memory providers.
- adapters: profile consolidation reads the user's settled app conversations since
  UserProfile.consolidatedAt (never messaging channels), asks the main bot's model for strict
  JSON facts and an updated summary, drops restatements (embedding cosine when built-in
  memory is configured, normalized text otherwise), stores sensitive categories as pending,
  and advances the cursor with a compare-and-set. A reconciler hook enqueues it at most daily
  per active user; it skips under the scripted runtime and without a usable model.
- db: additive migration 20261002210400_ws4_semantic_memory.
- web: a "Built-in" option in Memory settings; AI data sharing names the embeddings server.

Tests: new embedding conformance, OpenAI-compatible wire, built-in provider and consolidation
unit tests; new Postgres suites for built-in memory (including a real loopback embeddings
server through the memory provider resolver) and for consolidation and its scheduling query,
both added to the integration harness. The settings E2E spec now opens the new form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* feat(models): map model tiers per space and let bots and helpers ask for one

A space can now map the tiers fast, standard and code to connected models,
including several models served by one OpenAI-compatible router endpoint
(one credential, many model ids). A bot can ask for a tier, and a single
run_subagent call can ask for one. A tier with no model mapped falls back
to the usual model choice, so nothing breaks before tiers are set up.

- db: SpaceModelTier repo (list, find, set or clear); tier mappings are
  copied into a new space like model and voice preferences; createBot
  carries a tier, so duplicates keep it and spawned bots inherit it.
- contracts/api: models.tiers.list and models.tiers.set;
  models.probeOpenAiCompatible can list a connected server's models by
  credential id with its stored address and key; bots.update accepts
  modelTier and keeps a pinned model and a tier mutually exclusive, so a
  model picked on a surface that knows no tiers (mobile) is never silently
  overridden. Thinking-level validation follows the tier's model. AI
  consent status discloses tier models; desktop local settings allow the
  tier procedures.
- adapters: selectConfiguredModel runs a mapped tier ahead of the pinned
  model and the space default; the executor resolves bot tiers for runs
  and model lookups, and helper tiers through resolveModelTier.
  run_subagent takes model_tier; an explicit model wins over it.
- web: a collapsed Tiers section in model settings (shown once a model is
  connected) and mapped tiers as choices in the bot Model select.
- docs: tiers in self-host model setup.
- tests: resolution order and fallbacks (selection, executor, Pi helper),
  repo, API and contract units, authorization and offline Pi Postgres
  suites, and a model-tiers web E2E spec.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* fix(adapters): state the consolidation time in the user's zone; name the memory migration after its content

Profile consolidation now passes the profile's time zone to the shared time
instruction, which takes the zone since every run states the time in the
user's zone. The semantic memory migration is renamed after what it creates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* fix(adapters): keep the user's summary and facts out of replies to other owners' agents

An agent connection wakes a bot with another owner's message, and the reply
goes back to that owner. Such runs now get only the basics of the user block
(name, time zone, locale, language, style), like runs in shared messaging
channels, so confirmed facts such as health never reach another person's
agent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* feat(profile): readable, writable and forgettable user model

The main agent is meant to know the user, but nothing could read or change
the user model the foundation added. Onboarding pasted the user's answers
into the first bot's instructions as a "User Context" block, and the user
had no way to see or remove what their bots knew about them.

This makes the user model a user-controlled resource:

- db: a profile repository scoped by user. One atomic update writes the
  account name, profile fields, new facts (deduplicated by normalized
  text, capped per user), fact edits including per-space visibility, and
  confirmations. Health and values facts wait for confirmation unless the
  user wrote them. Forgetting hard-deletes facts by id, category or all,
  clears the summary that may restate them and marks earlier chats as
  consolidated so they cannot teach them again; forgetting everything also
  clears the profile fields. Writes take a per-user advisory lock.
- api/contracts: profile.get, profile.update and profile.forget. Unknown
  fact ids are a 404 on update and ignored on forget; spaces outside the
  user's memberships are rejected.
- adapters: an update_user_profile tool for the main bot only, hidden from
  specialists and shared messaging channels and refused if called anyway.
  Facts carry source "conversation" and the bot, thread and message they
  came from. The scripted runtime records "learn about me: [category:]
  statement" for tests.
- web: onboarding writes the name, time zone (browser default, now
  prefilled and editable), locale, chosen language, communication style
  and the hobby and work-style answers to the profile instead of
  overwriting the bot's instructions, and the harvested memory section no
  longer repeats them. A new "About you" settings section lists profile
  fields and facts by category with their origin, confirms pending facts,
  and forgets one fact or everything.

Tests cover the contracts, the repository (unit and PostgreSQL), tool
selection and refusal, the scripted trigger, the onboarding mapping and
view helpers, an API suite that runs the tool through the executor with a
real main bot, authorization for the new procedures, and an E2E spec.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* feat(delegation): main bot hands work to specialists as tracked tasks

The main agent had no way to give a specialist a piece of work and keep
track of it. message_bot is a fire-and-forget peer message whose work stays
hidden from the user, and handoff_to_bot ends the main bot's turn and only
works between members of one group. Delegation now runs the specialist's
work where the user follows the main agent and brings the result back so
the main agent can close the task.

- delegate_task({bot, title, brief, deadline?}) is offered to the main bot
  only and refused unless the caller leads the main conversation. It
  resolves the specialist by id or name across the user's spaces, creates a
  DelegatedTask and posts a task card in the main conversation.
- A specialist from the main conversation's space joins it and works there
  in a run with the new trigger "delegation", which the peer filters do not
  hide, so its progress, asks and approvals appear in the main conversation
  with the specialist as sender. A specialist from another space works in
  its own chat; its written result is posted into the main conversation as
  its message.
- The executor mirrors the run on its task (running, waiting, done, failed,
  cancelled) after the run starts and when each attempt ends, and updates
  the card in place through thread.message.updated. Done and failed tasks
  wake the main bot in the main conversation with the result. The job
  reconciler settles tasks whose run ended without the executor (stop,
  clear, archive, deletion, crash).
- tasks.list({threadId?, status?}) returns the caller's tasks for the
  current space.
- The task card shows who, what and status with Lingui labels; the brief
  opens on demand. The message_bot card no longer shows the recipient's
  unrelated overall status or hard-coded German copy.
- The scripted runtime understands "delegate to the bot named X: ..." and
  the task wake prompt; activity rows name delegation runs by task title.

Tests: unit tests for the prompts, status mapping, card, reducer, tool
selection and scripted runtime; a Postgres suite for same-space, group,
other-space, ask and answer, failure, stop and replay; tasks.list in the
authorization suite; and an end-to-end spec for the whole flow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* fix(adapters): show the main agent the specialists it can delegate to

The main conversation is a group run, and group runs no longer list the
teammate directory, so the main agent could not see the names of the
specialists delegate_task expects. Runs that have delegate_task now list
the user's specialists across the spaces they belong to, those next to the
conversation first and others marked with their space, and that list
replaces the teammate directory so no bot appears twice. It also says when
to prefer delegate_task over handoff_to_bot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* feat(inbox): show what specialists need from the user in the main conversation

Specialists ask questions, request approvals and the computer screen in
their own threads, and the user only finds out by opening each thread. With
the main conversation as the single entry point, those requests have to
reach the user there, with the specialist as the visible sender.

- inbox.list returns, for the current space and the user's own bots, pending
  asks and approvals (bot messages with a pending ask block whose run waits
  for input), takeover requests, and unread specialist threads. Asks already
  in the main conversation are left out; takeovers stay because their
  controls live in the bot's own thread. Each item carries the bot, the
  thread target, run and message ids, and the ask block for asks and
  approvals.
- The main conversation shows the items above the composer. Asks and
  approvals are answered in place through threads.answer with the source
  thread target; takeovers and unread threads open the bot's thread. The
  list refreshes when the bots' status, unread state or newest message
  changes, every 15 seconds, and after an answer.
- The inbox shrinks and scrolls so it never pushes the composer out of view.
  The transcript now clips its scroller: with no room left in the column,
  the scroller's padding used to spill over whatever sat below it.
- The ask block schema is extracted from MessageBlock so the inbox contract
  reuses it, and AskCard accepts a className.

Tests: unit tests for the item builder and the component, a Postgres suite
for the query (added to the harness list), inbox cases in the authorization
suite, and an E2E spec in which a specialist asks in its own thread and the
user answers from the main conversation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* fix(adapters): write learned facts under the profile's lock and limit

Profile consolidation wrote facts beside the user profile repository: without
its per-user lock, its duplicate check at write time, or its limit of 500
facts. A long-running profile could grow past the limit and then refuse the
user's own additions. Consolidation now takes the same lock, drops facts the
profile gained since the run read it, and keeps only what still fits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* fix(adapters): keep profile writes out of replies to other owners' agents

A run woken over an agent connection hears another owner's bot, not the
user, so update_user_profile is withheld there: nothing such a conversation
says can become a fact about the user. Detection moves ahead of tool
selection; the reduced user block uses the same flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* feat(capabilities): least-privilege builtin tools per bot and schedules on behalf

Every bot saw every builtin tool, and nothing outside the runtime's own
declaration kept a run to the tools it was given: the executor ran any tool a
runtime asked for, and the Pi runtime exposed all builtins whenever a caller
passed an empty tool list, which the safety checker, the history summarizer
and the team chat judge do on purpose. A specialist could not be kept to the
tools of its lane, bots had no capability tags, and the main bot could only
schedule routines for itself.

- A bot can carry a builtin tool allowlist. Its runs see only the allowlisted
  builtins, and an empty allowlist means none. MAIN_ONLY_BUILTIN_TOOLS (empty
  until the user profile and delegation tools land) never reach other bots.
  applyTool refuses any tool the run did not expose, and an empty tool list
  now means no tools for the Pi runtime and its subagents.
- A bot spawned by a restricted bot inherits the restriction and a duplicate
  keeps it, so neither path widens a bot's tools.
- bots.create and bots.update accept tags, restrictBuiltinTools and
  allowedBuiltinTools; the router rejects names that are not builtin tools.
  bots.builtinTools lists the names a bot's allowlist can use.
- Bot settings get a Tags field and a switch with a tool checklist under
  Advanced.
- The main bot's schedule_create takes an optional bot: one of the user's
  bots in any space the user still belongs to. The routine belongs to that
  bot in its space and wakes in the main conversation when the bot is a
  member there, otherwise in its 1:1 thread. Routines record the bot that
  scheduled them (additive migration), so schedule_list and schedule_cancel
  also cover exactly the routines a bot scheduled for others. Cancelling by
  a name that matches several routines is refused.

Tested with unit tests for the per-bot filter, the applyTool guard, the empty
tool list, and on-behalf resolution, creation, listing and cancelling; with
Postgres journeys for on-behalf routines waking across spaces and in the main
conversation and for allowlist enforcement through the scripted runtime; and
with a web E2E for the settings section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* test(adapters): give the consolidation unit tests the profile lock

The write transaction now takes the per-user profile lock, so the mocked
client answers the lock query, and the first case checks the lock is taken.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* feat(adapters): run profile consolidation on the fast tier when one is mapped

Fact extraction is short structured work, so consolidation asks the model
resolver for the fast tier. The resolver takes a per-call tier and, when the
space has not mapped it, keeps the bot's own tier or model as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

* feat: land in the main conversation, led by the main bot

After onboarding the user ended up in a 1:1 chat with "Chief", and the
"Hauptthread" was a group the web client created and found by name. The
main bot had no conversation it actually led, and addressing another bot
there did nothing unless the user had added it as a member first.

Now the main conversation is a real place:

- bots.ensureMain creates or returns the main bot. Bootstrap and space
  navigation return `main` ({ botId, groupId, spaceId }) once the main
  bot exists; reading navigation never creates a bot.
- The main bot leads the main conversation and answers when nobody is
  addressed: message routing, follow-ups, reactions and clearing the
  thread fall back to the group's lead instead of its first member.
- Addressing a same-space bot that is not a member yet, by @Name or a
  mention chip, makes it join before the message is routed, so it
  answers in the conversation. Specialists from other spaces do not join.
- The main bot cannot be archived or deleted, the main conversation
  cannot be archived or deleted or have its members edited, and child
  bots never dissolve it. Renaming the main bot renames the conversation.
- Web: onboarding uses bots.ensureMain and finishes in the main
  conversation. The main conversation is the first sidebar entry in every
  space, shown with the main bot's avatar and name; from another space it
  switches back to the home space. The main bot is not listed again among
  the bots, its 1:1 thread stays reachable by URL. The client no longer
  creates or detects the "Hauptthread" by name, refetches a group when it
  changes, and shows the speaker of any of the user's bots in a group.
- Onboarding no longer resets the step when its bootstrap resolves after
  the user has already moved on, and new spaces open in the app instead
  of walking onboarding again.

Tests: group mention routing with a default responder, lead follow-ups,
child-bot guards, a journey for the main conversation (lead answers,
addressed bot joins, guards, rename), authorization for bots.ensureMain,
and an E2E spec that lands in the main conversation, gets an answer from
the lead, sees an addressed bot join, and reaches the conversation from
another space. Existing E2E specs start in the main bot's thread through
a helper or use the main conversation where that is what they test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

---------

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 06:44:20 +02:00

1500 lines
52 KiB
Plaintext

generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
}
model AiDataConsent {
userId String
spaceId String
recipientKey String
version String
grantedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
spaceMember SpaceMember @relation(fields: [spaceId, userId], references: [spaceId, userId], onDelete: Cascade)
@@id([userId, spaceId, recipientKey])
@@map("ai_data_consents")
}
model User {
aiDataConsents AiDataConsent[]
id String @id
name String
email String
emailVerified Boolean @default(false)
image String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
avatarStyle String @default("robot")
sessions Session[]
accounts Account[]
members Member[]
invitations Invitation[]
modelCredentials UserModelCredential[]
voiceCredentials UserVoiceCredential[]
secrets Secret[]
mcpServers McpServer[]
botSections BotSection[]
actionApprovalRules ActionApprovalRule[]
actionAutoReviewPreferences ActionAutoReviewPreference[]
createdSpaces Space[] @relation("SpaceCreator")
botSecrets BotSecret[]
agentSecrets AgentSecret[]
profile UserProfile?
facts UserFact[]
@@unique([email])
@@map("user")
}
model Session {
id String @id
expiresAt DateTime
token String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
ipAddress String?
userAgent String?
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
activeOrganizationId String?
@@unique([token])
@@index([userId])
@@map("session")
}
model Account {
id String @id
accountId String
providerId String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
accessToken String?
refreshToken String?
idToken String?
accessTokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?
scope String?
password String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@map("account")
}
model Verification {
id String @id
identifier String
value String
expiresAt DateTime
createdAt DateTime? @default(now())
updatedAt DateTime? @updatedAt
@@index([identifier])
@@map("verification")
}
model Organization {
id String @id
name String
slug String
logo String?
createdAt DateTime
metadata String?
members Member[]
invitations Invitation[]
spaces Space[]
@@unique([slug])
@@map("organization")
}
model ActionApprovalRule {
id String @id @default(cuid())
spaceId String
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
createdByUserId String
createdByUser User @relation(fields: [createdByUserId], references: [id], onDelete: Cascade)
effect String
matchKind String
matchValue String
createdAt DateTime @default(now())
@@unique([spaceId, createdByUserId, effect, matchKind, matchValue])
@@index([spaceId, createdByUserId])
@@map("action_approval_rules")
}
model ActionAutoReviewPreference {
id String @id @default(cuid())
spaceId String
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
enabled Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([spaceId, userId])
@@map("action_auto_review_preferences")
}
model Member {
id String @id
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
role String
createdAt DateTime
spaceMemberships SpaceMember[]
@@unique([organizationId, userId])
@@index([organizationId])
@@index([userId])
@@map("member")
}
model Space {
aiDataConsents AiDataConsent[]
id String @id
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
name String
workspacePath String?
gitRemoteUrl String?
gitDefaultBranch String?
gitHeadCommit String?
isDefault Boolean @default(false)
createdByUserId String?
createdByUser User? @relation("SpaceCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
deletingAt DateTime?
deletionClaimId String?
memberships SpaceMember[]
bots Bot[]
botSections BotSection[]
botDeletions BotDeletion[]
threads Thread[]
events Event[]
tasks Task[]
runs Run[]
routines Routine[]
scratchpadItems ScratchpadItem[]
projectGoals ProjectGoal[]
taughtSkills TaughtSkill[]
agentSkills AgentSkill[]
connections Connection[]
capabilities CapabilityInstall[]
memoryDocuments MemoryDocument[]
agentHomes AgentHome[]
browserProfiles BrowserProfile[]
artifacts Artifact[]
usageRecords UsageRecord[]
notificationPreferences NotificationPreference[]
computers Computer[]
externalEffects ExternalEffect[]
mcpServers McpServer[]
mcpOAuthSessions McpOAuthSession[]
botMcpServers BotMcpServer[]
messagingIdentities MessagingIdentity[]
actionApprovalRules ActionApprovalRule[]
actionAutoReviewPreferences ActionAutoReviewPreference[]
memoryConfig SpaceMemoryConfig?
secrets Secret[]
chatGroups ChatGroup[]
botSecrets BotSecret[]
externalConversations ExternalConversation[]
agentSecrets AgentSecret[]
userFactVisibility UserFactSpace[]
delegatedTasks DelegatedTask[]
semanticMemoryItems SemanticMemoryItem[]
@@unique([id, organizationId])
@@index([organizationId, createdAt])
@@index([createdByUserId])
@@map("spaces")
}
model SpaceMember {
id String @id
spaceId String
organizationId String
space Space @relation(fields: [spaceId, organizationId], references: [id, organizationId], onDelete: Cascade)
userId String
member Member @relation(fields: [organizationId, userId], references: [organizationId, userId], onDelete: Cascade)
role String @default("member")
createdAt DateTime
modelPreferences SpaceModelPreference[]
modelTiers SpaceModelTier[]
voicePreferences SpaceVoicePreference[]
aiDataConsents AiDataConsent[]
@@unique([spaceId, userId])
@@index([organizationId, userId])
@@index([userId, createdAt])
@@map("space_members")
}
model Invitation {
id String @id
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
email String
role String?
status String
expiresAt DateTime
createdAt DateTime @default(now())
inviterId String
user User @relation(fields: [inviterId], references: [id], onDelete: Cascade)
@@index([organizationId])
@@index([email])
@@map("invitation")
}
model DeploymentSettings {
id String @id @default("default")
ownerUserId String?
signupsEnabled Boolean @default(true)
signupAllowlist String @default("")
signupPolicyInitialized Boolean @default(false)
defaultModelProvider String?
defaultModelId String?
deploymentModelCredentialCipher String?
computerHost String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("deployment_settings")
}
model UserModelCredential {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
provider String
label String
secretId String
supportsImages Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
preferences SpaceModelPreference[]
tiers SpaceModelTier[]
@@unique([id, userId])
@@index([userId, provider, updatedAt])
@@map("user_model_credentials")
}
model UserVoiceCredential {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
provider String
secretId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
preferences SpaceVoicePreference[]
@@unique([id, userId])
@@index([userId, provider, updatedAt])
@@map("user_voice_credentials")
}
model SpaceModelPreference {
id String @id @default(cuid())
spaceId String
userId String
spaceMember SpaceMember @relation(fields: [spaceId, userId], references: [spaceId, userId], onDelete: Cascade)
credentialId String
credential UserModelCredential @relation(fields: [credentialId, userId], references: [id, userId], onDelete: Cascade)
modelId String?
isDefault Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([spaceId, userId, credentialId])
@@index([spaceId, userId, isDefault, updatedAt])
@@index([credentialId])
// Partial unique index space_model_preferences_default_key is created in SQL.
@@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
userId String
spaceMember SpaceMember @relation(fields: [spaceId, userId], references: [spaceId, userId], onDelete: Cascade)
credentialId String
credential UserVoiceCredential @relation(fields: [credentialId, userId], references: [id, userId], onDelete: Cascade)
voiceId String @default("")
isDefault Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([spaceId, userId, credentialId])
@@index([spaceId, userId, isDefault, updatedAt])
@@index([credentialId])
// Partial unique index space_voice_preferences_default_key is created in SQL.
@@map("space_voice_preferences")
}
model Bot {
screenGeneration Int @default(0)
id String @id @default(cuid())
spaceId String
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
userId String
name String
title String @default("")
description String @default("")
instructions String @default("")
color String
notifyOnFinish Boolean @default(true)
pinned Boolean @default(false)
position Int @default(0)
sectionId String?
section BotSection? @relation(fields: [sectionId], references: [id], onDelete: SetNull)
archivedAt DateTime?
parentBotId String?
spawnKey String?
memoryScope String?
parent Bot? @relation("BotChildren", fields: [parentBotId], references: [id], onDelete: SetNull)
children Bot[] @relation("BotChildren")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
thread Thread?
computerId String?
computer Computer? @relation(fields: [computerId], references: [id], onDelete: SetNull)
computerSwitching Boolean @default(false)
voiceId String?
autoSpeak Boolean @default(false)
modelProvider String?
modelId String?
thinkingLevel String?
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[]
memoryDocuments MemoryDocument[]
agentHome AgentHome?
browserProfile BrowserProfile?
artifacts Artifact[]
tasks Task[]
runs Run[]
steeringMessages SteeringMessage[]
mcpServers BotMcpServer[]
groupMembers ChatGroupMember[]
ledGroups ChatGroup[] @relation("ChatGroupLead")
semanticMemoryItems SemanticMemoryItem[]
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])
@@index([parentBotId])
@@map("bots")
}
model BotSection {
id String @id @default(cuid())
spaceId String
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String
position Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
bots Bot[]
groups ChatGroup[]
projectGoals ProjectGoal[]
@@unique([spaceId, userId, name])
@@index([spaceId, userId, position, createdAt])
@@map("bot_sections")
}
model BotDeletion {
id String @id
spaceId String
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
name String
deletedByUserId String
memoriesPreserved Boolean
deletedAt DateTime @default(now())
@@index([spaceId, deletedAt])
@@map("bot_deletions")
}
model ChatGroup {
id String @id @default(cuid())
spaceId String
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)
archivedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
members ChatGroupMember[]
thread Thread?
artifacts Artifact[]
projectGoals ProjectGoal[]
@@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")
}
model ChatGroupMember {
id String @id @default(cuid())
groupId String
group ChatGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
botId String
bot Bot @relation(fields: [botId], references: [id], onDelete: NoAction)
createdAt DateTime @default(now())
@@unique([groupId, botId])
@@index([botId])
@@map("chat_group_members")
}
model Thread {
id String @id @default(cuid())
spaceId String
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
botId String? @unique
bot Bot? @relation(fields: [botId], references: [id], onDelete: Cascade)
groupId String? @unique
group ChatGroup? @relation(fields: [groupId], references: [id], onDelete: Cascade)
externalConversationId String? @unique
externalConversation ExternalConversation? @relation(fields: [externalConversationId], references: [id], onDelete: Cascade)
userId String
createdAt DateTime @default(now())
nextEventSeq Int @default(0)
nextMessageSeq Int @default(0)
historyCompactedUpToSeq Int?
historyCompactionSummary String? @db.Text
historyCompactionGeneration Int @default(0)
unread Boolean @default(false)
messages Message[]
events Event[]
tasks Task[]
runs Run[]
routines Routine[]
delegatedTasks DelegatedTask[]
@@index([spaceId])
@@map("threads")
}
model Message {
id String @id @default(cuid())
threadId String
thread Thread @relation(fields: [threadId], references: [id], onDelete: Cascade)
seq Int
role String
blocks Json
botId String?
replyToMessageId String?
replyQuote String?
replyTo Message? @relation("MessageReplies", fields: [replyToMessageId], references: [id], onDelete: SetNull)
replies Message[] @relation("MessageReplies")
runId String?
clientNonce String?
sourceRuns Run[] @relation("RunSourceMessage")
externalTranscriptFor ExternalMessage?
steeringMessages SteeringMessage[]
createdAt DateTime @default(now())
@@unique([threadId, seq])
@@unique([threadId, clientNonce])
@@index([threadId, seq])
@@index([replyToMessageId])
@@map("messages")
}
model Event {
id String @id @default(cuid())
spaceId String
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
threadId String
thread Thread @relation(fields: [threadId], references: [id], onDelete: Cascade)
botId String
seq Int
type String
payload Json
runId String?
createdAt DateTime @default(now())
@@unique([threadId, seq])
@@index([spaceId, createdAt])
@@index([threadId, seq])
@@index([runId, type, seq])
@@map("events")
}
model Task {
id String @id @default(cuid())
spaceId String
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
botId String
bot Bot @relation(fields: [botId], references: [id], onDelete: Cascade)
threadId String
thread Thread @relation(fields: [threadId], references: [id], onDelete: Cascade)
userId String
prompt String
status String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
runs Run[]
@@index([spaceId, botId])
@@map("tasks")
}
model Run {
id String @id @default(cuid())
spaceId String
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
botId String
bot Bot @relation(fields: [botId], references: [id], onDelete: Cascade)
threadId String
thread Thread @relation(fields: [threadId], references: [id], onDelete: Cascade)
taskId String
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
userId String
status String
trigger String
modelProvider String?
modelId String?
error String?
leaseOwner String?
leaseFence Int @default(0)
leaseExpiresAt DateTime?
checkpoint String?
clientNonce String?
sourceMessageId String?
sourceMessage Message? @relation("RunSourceMessage", fields: [sourceMessageId], references: [id], onDelete: SetNull)
routineId String?
routine Routine? @relation(fields: [routineId], references: [id], onDelete: SetNull)
startedAt DateTime?
completedAt DateTime?
toolCallBudgetCount Int?
toolCallBudgetExceeded Boolean?
botOutcomeReturnedAt DateTime?
messagingMirroredAt DateTime?
teamChatMirroredAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
attempts Attempt[]
effects ExternalEffect[]
usageRecords UsageRecord[]
steeringMessages SteeringMessage[]
externalMessage ExternalMessage?
@@unique([spaceId, clientNonce])
@@index([status, leaseExpiresAt])
@@index([status, updatedAt, id])
@@index([botOutcomeReturnedAt, status])
@@index([trigger, status, messagingMirroredAt, updatedAt])
@@index([trigger, status, teamChatMirroredAt, updatedAt])
@@index([spaceId, botId])
@@index([sourceMessageId])
@@index([threadId, status, createdAt])
@@index([routineId])
@@map("runs")
}
model SteeringMessage {
id String @id @default(cuid())
messageId String
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
botId String
bot Bot @relation(fields: [botId], references: [id], onDelete: Cascade)
userId String
runId String?
run Run? @relation(fields: [runId], references: [id], onDelete: SetNull)
claimedAt DateTime?
createdAt DateTime @default(now())
@@unique([messageId, botId])
@@index([botId, runId, createdAt])
@@index([runId])
@@map("steering_messages")
}
model Attempt {
id String @id @default(cuid())
runId String
run Run @relation(fields: [runId], references: [id], onDelete: Cascade)
fence Int
status String
error String?
startedAt DateTime @default(now())
finishedAt DateTime?
@@index([runId])
@@map("attempts")
}
// Deliberately independent of chat/user cascades: the worker must still be able
// to cancel remote work after its originating chat or account is deleted.
model CloudAgent {
id String @id @default(uuid())
operationKey String @unique
providerKey String
spaceId String
userId String
botId String
threadId String
messageId String?
remoteId String?
latestRunId String?
title String
status String @default("running")
url String @default("")
branch String?
prUrl String?
launchRequest Json
launchDispatched Boolean @default(false)
followup Json?
followupDispatching Boolean @default(false)
cancelRequested Boolean @default(false)
generation Int @default(0)
wakeGeneration Int @default(-1)
version Int @default(0)
leaseToken String?
leaseExpiresAt DateTime?
nextPollAt DateTime? @default(now())
errorCount Int @default(0)
createdAt DateTime @default(now())
@@index([providerKey, nextPollAt])
@@index([spaceId, userId, id])
@@map("cloud_agents")
}
model ExternalEffect {
id String @id @default(cuid())
spaceId String
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
runId String
run Run @relation(fields: [runId], references: [id], onDelete: Cascade)
kind String
idempotencyKey String
status String
request Json
result Json?
reviewDecision String?
reviewReason String?
reviewModel String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([idempotencyKey])
@@index([runId, status])
@@map("external_effects")
}
model Routine {
id String @id @default(cuid())
spaceId String
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
botId String
bot Bot @relation(fields: [botId], references: [id], onDelete: Cascade)
userId String
threadId String?
thread Thread? @relation(fields: [threadId], references: [id], onDelete: SetNull)
/// The main bot when it scheduled this routine for another bot; null when the bot or the user created it.
createdByBotId String?
name String
prompt String
crons String[]
timezone String @default("UTC")
active Boolean @default(false)
notify Boolean @default(true)
webhookEnabled Boolean @default(false)
githubEnabled Boolean @default(false)
messageProvider String?
lastRunAt DateTime?
nextRunAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
runs Run[]
@@index([active, nextRunAt])
@@index([spaceId, botId])
@@index([createdByBotId])
@@map("routines")
}
model ScratchpadItem {
id String @id @default(cuid())
spaceId String
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
botId String
bot Bot @relation(fields: [botId], references: [id], onDelete: Cascade)
userId String
title String
status String @default("open")
notes String @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([spaceId, botId, status])
@@index([spaceId, botId, updatedAt])
@@map("scratchpad_items")
}
model ProjectGoal {
id String @id @default(cuid())
spaceId String
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
userId String
sectionId String
section BotSection @relation(fields: [sectionId], references: [id], onDelete: Cascade)
groupId String?
group ChatGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull)
title String
description String?
status String @default("open")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([spaceId, sectionId, status])
@@index([spaceId, userId, sectionId, updatedAt])
@@map("project_goals")
}
model TaughtSkill {
id String @id @default(cuid())
spaceId String
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
botId String
bot Bot @relation(fields: [botId], references: [id], onDelete: Cascade)
userId String
name String @default("")
goal String
status String
playbook Json @default("{}")
recording Json @default("{\"events\":[],\"snapshots\":[]}")
startedAt DateTime?
expiresAt DateTime?
stoppedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([spaceId, botId])
@@index([spaceId, botId, status])
// Partial unique index taught_skills_botId_recording_key is created in SQL:
// one recording session per bot.
@@map("taught_skills")
}
/// Claude Agent Skills: SKILL.md recipes shared across assistants (not per-bot).
/// The Pi runtime already understands this format; we persist and inject them.
/// Distinct from TaughtSkill (demo/record playbooks).
model AgentSkill {
id String @id @default(cuid())
spaceId String
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
userId String
name String
description String
content String
/// user | builtin | plugin — only user skills are writable via API/tools.
source String @default("user")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([spaceId, userId])
// Case-insensitive unique index agent_skills_spaceId_userId_name_lower_key is created in SQL.
@@map("agent_skills")
}
model Connection {
id String @id @default(cuid())
spaceId String
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
userId String
connectorId String @default("composio")
provider String
displayName String
status String
secretId String?
providerRef String?
metadata Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([spaceId, userId, connectorId])
@@map("connections")
}
model CapabilityInstall {
id String @id @default(cuid())
spaceId String
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
userId String
kind String
name String
source String
version String?
digest String?
secretId String?
config Json @default("{}")
createdAt DateTime @default(now())
@@index([spaceId, userId])
@@map("capability_installs")
}
model MemoryDocument {
id String @id @default(cuid())
spaceId String
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
userId String
botId String?
bot Bot? @relation(fields: [botId], references: [id], onDelete: Cascade)
scope String
path String
content String
revision Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
revisions MemoryRevision[]
@@unique([spaceId, scope, botId, path])
@@index([spaceId, userId])
@@map("memory_documents")
}
model MemoryRevision {
id String @id @default(cuid())
documentId String
document MemoryDocument @relation(fields: [documentId], references: [id], onDelete: Cascade)
revision Int
content String
sourceRunId String?
sourceThreadId String?
createdAt DateTime @default(now())
@@unique([documentId, revision])
@@map("memory_revisions")
}
/// One item of the built-in semantic memory, recalled by cosine similarity computed in process.
model SemanticMemoryItem {
id String @id @default(cuid())
spaceId String
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
botId String
bot Bot @relation(fields: [botId], references: [id], onDelete: Cascade)
/// Shared items are also recalled by the Space's other bots when they use shared scope.
shared Boolean @default(false)
/// Compacted-history generation of the bot's thread; null for durable memories.
generation Int?
content String
embedding Float[]
embeddingModel String
createdAt DateTime @default(now())
@@index([spaceId, botId, generation, createdAt])
@@index([spaceId, shared, createdAt])
@@map("semantic_memory_items")
}
model AgentHome {
id String @id @default(cuid())
spaceId String
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
botId String @unique
bot Bot @relation(fields: [botId], references: [id], onDelete: Cascade)
userId String
revision String @default("empty")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("agent_homes")
}
model BrowserProfile {
id String @id @default(cuid())
spaceId String
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
botId String @unique
bot Bot @relation(fields: [botId], references: [id], onDelete: Cascade)
userId String
secretId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("browser_profiles")
}
model ComputerUpdate {
action String @default("update")
id String @id @default(cuid())
computerId String
computer Computer @relation(fields: [computerId], references: [id], onDelete: Cascade)
botId String
status String @default("queued")
stage String @default("preparing")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([computerId, createdAt])
@@index([status, updatedAt])
@@map("computer_updates")
}
model Computer {
screenGeneration Int @default(0)
id String @id @default(cuid())
spaceId String
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
userId String
scope String @default("team")
scopeKey String @unique
homeKey String @unique
homeRevision String @default("empty")
kind String
providerRef String?
state String @default("stopped")
controlHolder String @default("none")
controlLeaseId String?
controlLeaseExpiresAt DateTime?
controlBotId String?
controlRunId String?
controlFence Int @default(0)
executionRunId String?
executionBotId String?
executionLeaseExpiresAt DateTime?
executionFence Int @default(0)
screenUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
bots Bot[]
executionLeases ComputerExecutionLease[]
maintenanceId String?
updates ComputerUpdate[]
@@index([spaceId, scope])
@@map("computers")
}
model ComputerExecutionLease {
id String @id @default(cuid())
computerId String
computer Computer @relation(fields: [computerId], references: [id], onDelete: Cascade)
botId String
runId String
fence Int @default(0)
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([computerId, botId])
@@index([runId])
@@index([computerId, expiresAt])
@@map("computer_execution_leases")
}
model Artifact {
id String @id @default(cuid())
spaceId String
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
botId String?
bot Bot? @relation(fields: [botId], references: [id], onDelete: SetNull)
groupId String?
group ChatGroup? @relation(fields: [groupId], references: [id], onDelete: Cascade)
userId String
runId String?
name String
mimeType String
size Int
hash String
storageKey String
createdAt DateTime @default(now())
@@index([spaceId, botId])
@@index([groupId])
@@map("artifacts")
}
model UsageRecord {
id String @id @default(cuid())
spaceId String
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
/// Which bot spent this. Deliberately NOT a relation: a usage row is spend that really
/// happened, and no lifecycle event on the bot may delete it or blank its attribution.
/// `Cascade` erased the row; `SetNull` would erase the attribution instead, and because
/// deleting a bot also deletes its runs, `runId` goes null in the same breath — the row
/// would keep only space, user, model and token counts.
///
/// So the id outlives the bot and may dangle. Read it with a LEFT JOIN; `BotDeletion.id`
/// IS the bot id, so a deleted bot's name is still reachable for display.
botId String?
userId String
runId String?
run Run? @relation(fields: [runId], references: [id], onDelete: SetNull)
provider String
model String
inputTokens Int
outputTokens Int
createdAt DateTime @default(now())
@@index([spaceId, userId, createdAt])
@@map("usage_records")
}
model NotificationPreference {
id String @id @default(cuid())
spaceId String
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
userId String
finish Boolean @default(true)
help Boolean @default(true)
takeover Boolean @default(true)
@@unique([spaceId, userId])
@@map("notification_preferences")
}
model SpaceMemoryConfig {
id String @id @default(cuid())
spaceId String @unique
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
userId String
provider String
settings Json
secretId String @unique
secret Secret @relation(fields: [secretId], references: [id], onDelete: Cascade)
defaultMemoryScope String @default("isolated")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("space_memory_configs")
}
model Secret {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
spaceId String?
space Space? @relation(fields: [spaceId], references: [id], onDelete: Cascade)
kind String
ciphertext String
createdAt DateTime @default(now())
mcpServers McpServer[]
spaceMemoryConfig SpaceMemoryConfig?
agentSecret AgentSecret?
@@index([userId, spaceId])
@@map("secrets")
}
model McpServer {
id String @id @default(cuid())
spaceId String
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
slug String
name String
description String @default("")
transport String
endpoint String?
command String?
args Json @default("[]")
env Json @default("{}")
headers Json @default("{}")
secretId String?
secret Secret? @relation(fields: [secretId], references: [id], onDelete: SetNull)
enabled Boolean @default(true)
revision Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
assignments BotMcpServer[]
oauthSessions McpOAuthSession[]
@@unique([spaceId, userId, slug])
@@index([spaceId, userId])
@@map("mcp_servers")
}
model McpOAuthSession {
id String @id
spaceId String
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
userId String
serverId String
server McpServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
endpoint String
redirectUri String
oauthCiphertext String
createdAt DateTime @default(now())
@@index([spaceId, userId, createdAt])
@@map("mcp_oauth_sessions")
}
model BotMcpServer {
id String @id @default(cuid())
spaceId String
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
botId String
bot Bot @relation(fields: [botId], references: [id], onDelete: Cascade)
serverId String
server McpServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
userId String
allowAllTools Boolean @default(true)
allowedTools Json @default("[]")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([botId, serverId])
@@index([spaceId, userId, botId])
@@map("bot_mcp_servers")
}
model MessagingIdentity {
id String @id @default(cuid())
provider String
/// Sender address within the provider: E.164 number, Slack user id, ….
address String
/// Opaque provider thread id for the 1:1 conversation; learned on inbound.
dmThreadId String?
userId String
spaceId String
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
botId String @unique
verifiedAt DateTime?
lastInboundAt DateTime?
outboundSinceInbound Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([provider, address])
@@index([userId])
@@map("messaging_identities")
}
model MessagingChannel {
id String @id @default(cuid())
provider String
/// Opaque provider thread id of the group conversation.
threadId String @unique
name String?
introPostedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
members MessagingChannelMember[]
@@map("messaging_channels")
}
model MessagingChannelMember {
id String @id @default(cuid())
channelId String
channel MessagingChannel @relation(fields: [channelId], references: [id], onDelete: Cascade)
address String
identityId String?
status String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([channelId, address])
@@index([identityId])
@@map("messaging_channel_members")
}
/// Short-lived web-issued code binding a chat address to an existing user+bot.
model MessagingLinkCode {
id String @id @default(cuid())
code String @unique
userId String
spaceId String
botId String
expiresAt DateTime
createdAt DateTime @default(now())
@@index([userId])
@@map("messaging_link_codes")
}
model MessagingOutbound {
id String @id @default(cuid())
idempotencyKey String @unique
kind String
/// DM rows resolve their thread through the identity at drain time.
identityId String?
/// Group/intro rows post straight to this provider thread id.
threadId String?
body String
status String @default("pending")
attempts Int @default(0)
nextAttemptAt DateTime?
providerHandle String?
sourceMessageId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status, nextAttemptAt])
@@map("messaging_outbound")
}
model AgentConnection {
id String @id @default(cuid())
requesterBotId String
targetBotId String
status String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([requesterBotId, targetBotId])
@@index([targetBotId, status])
@@map("agent_connections")
}
model BotSecret {
id String @id
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
spaceId String
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
botId String
bot Bot @relation(fields: [botId], references: [id], onDelete: Cascade)
name String
origin String
auth Json
ciphertext String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, spaceId, botId, name])
@@index([botId])
@@index([spaceId])
@@map("bot_secrets")
}
model ExternalConversation {
id String @id @default(cuid())
provider String
workspaceId String
externalKey String
conversationId String
displayName String?
participantNames String[] @default([])
teamChatAmbientEnabled Boolean?
teamChatRules String?
automatedSenderPolicies Json @default("{}")
spaceId String
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
botId String
bot Bot @relation(fields: [botId], references: [id], onDelete: Cascade)
userId String
thread Thread?
messages ExternalMessage[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([provider, workspaceId, externalKey])
@@index([spaceId, botId])
@@map("external_conversations")
}
model ExternalMessage {
id String @id @default(cuid())
externalConversationId String
externalConversation ExternalConversation @relation(fields: [externalConversationId], references: [id], onDelete: Cascade)
providerEventId String
kind String @default("direct")
senderId String
senderName String
senderIsBot Boolean @default(false)
content String
replyThreadId String?
batchContext String?
engagementReason String?
judgedAt DateTime?
status String @default("received")
runId String? @unique
run Run? @relation(fields: [runId], references: [id], onDelete: SetNull)
threadMessageId String? @unique
threadMessage Message? @relation(fields: [threadMessageId], references: [id], onDelete: SetNull)
attempts Int @default(0)
nextAttemptAt DateTime?
lastError String?
providerReplyHandle String?
deliveredAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([externalConversationId, providerEventId])
@@index([status, nextAttemptAt])
@@map("external_messages")
}
model AgentSecret {
id String @id @default(cuid())
spaceId String
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
createdByUserId String
createdByUser User @relation(fields: [createdByUserId], references: [id], onDelete: Cascade)
name String
secretId String @unique
secret Secret @relation(fields: [secretId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([spaceId, name])
@@index([spaceId, updatedAt])
@@map("agent_secrets")
}
// Deployment-owned integration credentials; never exposed through read DTOs.
model IntegrationProviderConfig {
id String @id
ciphertext String
updatedAt DateTime @updatedAt
@@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")
}