evOo-local/packages/db/prisma/schema.prisma
cynarAI 6135bd8d63
feat: the main agent as a coach — conversational onboarding, goals, check-ins and push (#118)
* chore: remove legacy orchestrator, trading and one-off script leftovers

The repository still carried the pre-evOo prototype: a Python orchestrator,
the old HTML "evoo-platform" cockpit (whose test ran in the root unit
suite), an apex-trading package, the hot-reload deploy.sh for it, a
permissions manifest, an architecture note with local file links, agent
handoff notes, a Steve Jobs persona template and about ninety one-off
scripts that probe a developer database with hard-coded local URLs. None
of it is used by the product, CI or the real deploy in ops/evo-deploy.

Delete those files, drop the evoo-platform test glob from the root vitest
config, the Cockpit section from .env.example and the dead biome and
gitignore entries, and update the docs that listed the removed tools.
scripts/start-tmux.sh no longer starts the removed sentinel and resolves
the checkout from its own location instead of a machine-specific path;
scripts/seed-ollama.ts requires DATABASE_URL instead of falling back to a
local URL with a username.

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

* fix(web): drop trading and persona prompts from the conversation shell

The web shell still showed leftovers from the trading prototype: composer
chips with German trading and "Steve Jobs" prompts, trading suggestion
chips under the empty conversation line, a German trading hero for the
empty transcript, and a German "data protection" status panel in the
sidebar. They suggested actions the product does not have and added
persistent chrome.

Remove the composer chips and their helper, the empty-state chips and the
hero (the empty conversation keeps its existing one-line start hint), the
sidebar trading indicator and the data trust panel. The new-bot E2E now
asserts the plain empty state; the sidebar spec no longer expects the
trading status, and the placeholder Steve Jobs spec with no assertions is
gone.

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

* refactor: remove the trading bot status integration

With the sidebar trading indicator gone, the only remaining consumer of
the trading bot status was a banner in General settings that reused the
same component. evOo is a personal assistant; a status proxy and poller
for one external trading service, configured through service-specific
TRADING_BOT_URL and VITE_TRADING_BOT_URL variables, does not belong in
the core product.

Remove the settings banner and its prop plumbing, the web poller, the
indicator component, the /api/trading-bot/status proxy, the shared core
contract, the env loading and both variables from .env.example, together
with their tests.

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

* chore: remove one-off database scripts and a stale review note

packages/db carried four hand-run scripts outside the package build that
query or seed a developer database with a hard-coded connection string,
one of them for a specific account. docs/VOICE-FIRST-REVIEW.md was a
one-time verification note for a workflow that no longer exists and
named private machines and local paths. Nothing references them.

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

* feat(memory): remember appends dated notes instead of replacing memory

The remember tool rewrote the bot's whole MEMORY.md with each call, so every
new fact erased the earlier ones unless the model re-sent the full document.

- MemoryCommitRequest gains mode "append" (the store joins the note onto the
  current content inside the same serializable transaction and still writes a
  revision row) and every commit is capped at 16 KB per document.
- remember appends a dated bullet ("- YYYY-MM-DD: ...", the user's local date)
  by default and rewrites only with mode "replace"; a full document returns an
  error that tells the bot to condense it. Retries of the same tool call stay
  single appends through the existing effect record.
- Scripted runtime memory entries can append too; the scripted "remember"
  script now appends instead of overwriting the bot's memory.
- When memory does not fit the run's 32 KB budget, the prompt keeps a
  document's newest lines instead of its oldest.
- memory.update maps an oversized save to BAD_REQUEST.
- docs/PRODUCT.md records that UserFact is the cross-space user model and the
  space MEMORY.md stays space notes (no migration).

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

* feat(coach): goals, habits, check-ins, daily briefs and check-in rhythm

The main agent should coach the user, but nothing stored what the user
wants to achieve or keep doing, and no routine ever checked in. This adds
the data model and the interfaces the onboarding intake and the coach
tools build on.

- Schema: UserGoal, Habit, CheckIn (one per habit, goal or day and date)
  and DailyBrief, all keyed to the user's local calendar date, plus a
  coachKey on Routine that marks the main bot's coach check-ins.
- Contracts (coach.ts): CoachRhythm, Goal, Habit, CheckIn, DailyBrief and
  CoachToday DTOs and the RPC inputs.
- Repository (@rakazo/db coach.ts): goal, habit and check-in CRUD with
  per-user limits, upsertDailyBrief and loadCoachToday with streaks in
  the user's time zone.
- coach-rhythm.ts: setCoachRhythm/getCoachRhythm keep up to three main
  bot routines (morning brief, evening check-in, weekly review) in the
  main conversation in step with the user's chosen times.

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

* feat(notifications): add push subscriptions, notification settings and web push interfaces

Push could only reach one mobile device per user (a token file), quiet hours did not exist,
and the web app had no service worker, so a closed tab never heard about finished work.

This lands the shared interfaces first so onboarding can build on them:
- push_subscriptions (web push endpoints and Expo tokens, one row per device) and
  user_notification_settings (quiet hours as minutes in the profile time zone, nudges, a
  per-day nudge counter), with CHECK constraints.
- NotificationSettingsSchema and web push contracts, notifications.settings.get/update and
  notifications.webPush.config/subscribe/unsubscribe (owner-scoped, https endpoints only).
- getNotificationSettings/updateNotificationSettings plus the policy and nudge-cap helpers.
- VAPID keys from VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY or generated once and stored encrypted,
  and an SSRF-guarded web push sender built on the web-push package.
- The web app links its manifest, registers a push-only service worker where supported (secure
  context, not the desktop app) and exposes webPushSupported/webPushEnabled/enableWebPush/
  disableWebPush.

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

* chore: replace machine-specific paths and addresses with placeholders

ORIGIN.md pinned the intended checkout to an absolute path under a
personal home directory, docs/LOCAL-SETUP.md listed folders on one
developer's machine, and two tests used what looks like a real tailnet
address. Use a home-relative path, drop the machine-specific folder list,
and use 100.100.100.100 in the tests; it is still inside 100.64.0.0/10,
so both tests check the same thing as before.

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

* test(coach): repository, streak and rhythm suites

Covers what the coach interfaces promise before anything builds on them:
goal and habit ownership and limits, one check-in per habit, goal and day
dated in the user's time zone, streaks across weekends and weeks, and a
rhythm that keeps exactly one main-bot routine per check-in and is
idempotent when set again.

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

* refactor(contracts): share one clock-time schema

The coach rhythm and the quiet hours both parse "HH:MM" wall-clock times
and each defined its own ClockTimeSchema, which the contracts index cannot
export twice. Both now import it from one module.

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

* feat(onboarding): ask the onboarding questions in the main conversation

The wizard asked for the account name again and a "Tell us about yourself" form, and the
focus answer in the main conversation was never saved. The main bot now gets to know the
user in the main conversation instead, one card after each answer.

- UserOnboarding keeps the pending step, the focus and when onboarding finished.
- onboarding-intake.ts declares the steps (focus, goal, rhythm, quiet hours, notifications)
  as cards plus what their answers change; onboarding.ts commits an answer, the focus app
  suggestions and the next card or the closing line in one transaction.
- The choice block gains a step, an optional text input and the typed answer; choose takes
  the step, the text and whether the device can still turn push on.
- The focus answer becomes a goals fact; the goal, rhythm and quiet-hours effects and the
  push seam are placeholders until the coach and notification modules are merged.
- The wizard drops the preferences step and fills time zone, locale and language from the
  browser; the main bot's prompt notes open onboarding cards; agent history shows a card's
  question and answer.

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

* feat(notifications): fan push out to every device, honour quiet hours, add notify_user

Run notifications reached at most one phone and nothing could hold them back at night.

- PushNotificationProvider sends each notification to all of the user's registered devices
  (web push and Expo), skips everything during quiet hours in the profile time zone, drops
  devices the push service reports gone (404/410, DeviceNotRegistered), records lastUsedAt and
  links each push to its conversation.
- Expo tokens move from DATA_DIR/push-tokens files into push_subscriptions: the API imports and
  deletes the legacy files at start; notifications.registerPush/unregisterPush keep their
  contract for the mobile app (tokens must look like Expo tokens).
- NotificationMessage gains the nudge kind, and the main bot gets notify_user: a short push to
  the main conversation that respects the nudge switch, quiet hours and a cap of three per day.
  It is withheld from shared messaging channels and from runs answering another owner's agent.

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

* feat(coach): main-bot coach tools and goals in its prompt

The main agent had no way to record what the user wants to achieve or how
their day went, and its prompt never showed their goals or habits.

- update_goal, update_habit, log_check_in and write_daily_brief, main-bot
  only and, like update_user_profile, withheld from shared channels and
  from runs that answer another owner's agent. Goals and habits can be
  named by id or exact title.
- The main bot's own runs list active goals and habits (with ids, done
  state and streak) in the <user> block; the basics-only block and every
  other bot never see them. The main agent role adds a short coaching
  stance and names each coach tool only when the run has it, so the
  check-in routine prompts stay tool-agnostic.
- The scripted runtime understands a few coach commands and the morning
  check-in, so tests can drive the tools end to end.

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

* fix(web): use English Lingui sources for German UI text

Several web surfaces hard-coded German (the project card, roster and
activity status labels, the sidebar "Heute" label) or used German text as
the Lingui source message (sidebar sections, project timeline, bot picker,
voice entry, group stage, copy and read-aloud buttons). English users saw
German, and because production builds keep only message ids, none of these
strings could be translated per locale.

Convert them to English source messages, keeping the meaning:
- ProjectCreatedCard: Lingui Trans/Plural instead of literals.
- agent-activity: labels go through i18n._ with literal ids so the module
  stays importable from plain unit tests; the extractor still collects them.
- Shell, ProjectSessionTimeline, bot-picker, StageVoiceEntry, OoStage:
  English sources, reusing existing messages where one already fit
  ("just now", "{n}m ago", "Search", "No bots match", "Set up voice",
  "Group conversation", "Transcript").
- The conversation picker matches its create entry against the translated
  label instead of a fixed German string.

The German wording returns through the de catalog in the next commit.
Unit and E2E tests now assert the English labels.

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

* feat(web): keep Lingui catalogs complete and in sync

The committed catalogs were last extracted weeks ago: about 330 source
messages were missing from every locale, and production builds keep only
message ids, so those strings rendered as hash ids (sidebar sections,
privacy settings, profile, timeline, ...). German also had 226 empty
entries, and every extraction rewrote the files because references carried
line numbers.

- lingui.config.ts: po references without line numbers, and a stable sort
  that breaks Lingui's ordering ties (explicit ids, equal text) by id, so
  the output no longer depends on file-system order.
- Re-extract all catalogs with --clean (drops 25-44 obsolete entries per
  locale; no existing translation changed).
- Translate every German entry (du form, consistent with the existing
  catalog). Other locales keep empty entries and fall back to English.
- i18n-catalog-sync.test.ts extracts into a copy of the catalogs and fails
  when the committed files differ or a German entry is empty, so stale
  catalogs fail the unit test job (offline, about 5 s).

Adds @lingui/format-po (already installed through @lingui/cli) as a direct
dev dependency to configure the formatter.

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

* refactor(memory): derive the run's memory budget from the document limit

The prompt budget was a separate 32 KB constant; it is now twice the
per-document limit, so a full bot document and a full space document keep
fitting if either number changes.

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

* feat(profile): edit facts in place in About you

About you could only confirm or delete what the bots know about the user, so a
fact that was slightly wrong or filed under the wrong category had to be
deleted and taught again, and a fact could not be limited to some spaces from
the app even though the profile supports it.

- Each fact gets an Edit button that turns its row into an inline editor:
  wording, category, and (when the user has more than one space, or the fact
  is already limited) which spaces it shows in. Escape or Cancel leaves it
  unchanged; only changed fields are sent through profile.update editFacts.
- A limited fact names its spaces next to its source.
- The repository validates edits like new facts: a wording whose normalized
  form is empty or equals another fact of the user is refused, all edits of
  one update apply atomically, and the stored embedding is only dropped when
  the wording really changes.

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

* feat(inbox): list and answer items from all of the user's spaces

The inbox in the main conversation only showed asks, approvals, takeovers and
unread threads of the current space. The main conversation lives in the home
space, so anything a bot needed in a project space never reached it.

- inbox.list now covers every space the user belongs to (except spaces being
  deleted), still only the user's own bots, runs and threads. Items from
  another space carry that space's id and name.
- The web inbox shows the space name next to the bot for those items, answers
  them with that space's header so the existing thread procedures check
  membership and ownership for that space, and opens their thread in it.

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

* feat(coach): coach RPCs for goals, habits, check-ins, today and rhythm

The web app needs to show and change the user's coach data, and the
check-in times need a way to change after onboarding.

- coach.today, coach.goals.*, coach.habits.*, coach.checkIns.* and
  coach.rhythm.get/set, owner-only and not space-scoped; another user's
  ids read as not found, invalid input as a bad request.
- coach-rhythm reuses the shared clock-time helpers.
- Tests: an API suite covering ownership and validation for every
  procedure, the coach tools driven through the main bot (and refused
  for a specialist), and a morning check-in routine that fires in the
  main conversation and writes today's brief; the unauthenticated
  authorization sweep lists the new procedures.

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

* feat(onboarding): save goal, check-in rhythm, quiet hours and push from the cards

With the coach and notification modules merged, the onboarding answers now take effect:

- the goal card creates a goal (source onboarding) through createGoal;
- the rhythm card sets the main bot's check-in routines through setCoachRhythm, and the
  closing line names the first check-in from the routines that were saved;
- the quiet-hours card updates the notification settings;
- the choice card tells the server whether this browser can still turn push on, so the
  notifications card is offered only there, and "Turn on" subscribes before answering.

The thread lock becomes NO KEY UPDATE so a rhythm routine, which references the thread, can
be written while the answer holds the lock. The goal answer is capped at the coach title
length. A Postgres suite walks the questions end to end: answers, skips, a dismissed card, a
stale and a doubled answer, another bot's focus card, and another user's access.

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

* test(notifications): cover quiet hours, delivery, nudges and the notification RPCs

The quiet-hours CHECK accepted a row with only one bound set, because a NULL comparison made
the whole condition NULL; it now requires both bounds explicitly. The migration has not shipped
yet, so it is corrected in place.

- Unit: quiet-hours math across midnight, time zones and daylight saving; VAPID key handling
  (configured, generated once and shared, invalid); the web push sender (encrypted VAPID-signed
  request, gone on 404/410, no private or non-https endpoints); the fan-out with fake senders.
- PostgreSQL: settings and subscription repositories (constraints, per-user removal, endpoint
  hand-over, device cap, concurrent nudge cap, cascade on account deletion), delivery and
  notify_user against a real main conversation, and every notification RPC through the API
  including cross-user isolation and endpoint validation.
- .env.example documents the optional VAPID keys and VAPID_SUBJECT.

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

* feat(web): notification settings, and sign-out stops this browser's push

Users had no way to turn on push for a browser, set quiet hours or opt out of nudges.

Settings gains a compact Notifications section: a switch for push on this device (only where
web push works: a secure context outside the desktop app), quiet hours with from/to times
(22:00-07:00 when first turned on) and a switch for nudges. Changes save in order as they are
made. A browser that blocked notifications says so instead of offering a dead switch. Logging
out unregisters the browser first, so a shared computer stops receiving the account's pushes.

The E2E stubs the Push API (headless Chromium has no push service), walks all three controls,
checks they survive a reload and captures the notification-settings screenshot.

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

* fix(profile): keep the fact editor's Save label visible

Small buttons in ui-web merge their text-ui-label size over the default
variant's text color, so the small Save button rendered ink on ink. The
editor now uses default-size controls, with the category select at the same
height.

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

* refactor(notifications): keep the push sender contract next to the fan-out

The payload, target and outcome types are shared by the Expo and web push senders, so they now
live with PushSender in push-notifications.ts instead of in the web push adapter. The settings
E2E also stubs the Notification permission (headless Chromium reports it as denied even when
granted), and the quiet-hours inputs are wide enough for 12-hour times.

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

* fix(core): load child_process normally in the git checkout reader

readGitCheckout loaded node:child_process through require(), which does
not exist in the ESM API runtime. Linking a project space to a folder
that is a git checkout therefore failed after the space was created.
The lazy load is no longer needed: the reader is only reachable through
the server-only "@rakazo/core/git-checkout" subpath, never through the
package entry the web app bundles. Import spawn at the top instead.

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

* feat(onboarding): ask before discovering projects and let the user choose

The onboarding scan started on its own, discarded what it found, claimed
to read email and calendars, simulated progress for everyone who could
not scan, and its project creation always failed (wrong Space fields and
a space lookup by title across tenants).

Discovery now asks first and creates nothing without a choice:

- The scan step appears only for the deployment owner when
  ONBOARDING_SCAN_HOME is set; everyone else skips it. The simulated
  scan is gone.
- It shows the folder and asks. Only directory listings are read: git
  projects up to four levels deep, their languages and a few tools
  (Docker, GitHub Actions, Terraform). Symlinks are never followed and
  each chosen folder is resolved again before use, so nothing outside
  the folder is reached. Results stay with the account that scanned.
- Found projects are a checklist (none preselected); found languages and
  tools are facts (preselected). "Add" creates one project space per
  chosen project with createSpaceForMember and wireProjectWorkspace in
  the user's own organization, reusing the user's own space already
  linked to that folder, and saves the chosen facts as onboarding facts
  under the profile lock and fact limit.
- When a connection becomes connected (direct connect, authorization
  round trip, or catalog reconciliation), "Uses <App>" is remembered as
  a tools fact from the connector. Revoking keeps it.

The E2E harness now points ONBOARDING_SCAN_HOME at a fixture folder and
starts without a deployment owner, so owner-only onboarding steps appear
only in specs that take the seat through /__e2e/deployment-owner.

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

* feat(web): coach on the Heute page

Heute only listed runs and linked to a hard-coded local Mission Control
address, so the coach's work had nowhere to show.

- Today's brief at the top, habits with a check-off and their streak,
  and active goals, each only when there is something to show; then the
  existing needs-you, doing and done lists.
- One icon button opens the check-in times (morning, evening, weekly),
  loaded when opened and saved on every change.
- The page reads coach.today, refreshes it when a run finishes or the
  tab becomes visible, and checks habits off optimistically; the date
  header uses the user's time zone from the profile.
- Removes the Mission Control link and its constant.
- Tests: helper unit tests and a web E2E that writes the brief through
  the main bot, checks a habit off across a reload and sets check-in
  times (screenshots heute-coach and heute-rhythm).

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

* test(onboarding): walk the onboarding questions end to end; mobile reads cards as text

- onboarding-intake.spec walks focus, goal (after typing into the composer, which still
  works), check-ins, quiet hours and the notifications card when the browser offers it,
  with the onboarding-intake-goal, -rhythm and -done screenshots.
- completeOnboarding and the wizard specs drop the preferences step; the scan step is
  optional (skipped when it asks, otherwise it advances by itself).
- The user-profile spec now checks the browser-filled profile and the focus fact; the
  conversation spec checks the shorter app suggestion and the following goal question.
- The hover spec measures a reply that fills its row (a bot row's rail sits at the row's
  end, so the old long onboarding line was what made it pass) and scopes Send to the
  composer, since an open goal card has its own.
- Mobile shows a choice card as its question and answer instead of an empty bubble.
- The focus options drop their unused summary.

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

* test(web): stub the Notification permission without a static-only class

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

* test(web): find the sidebar Today button by its English label

The shell contrast test added on main looks up the sidebar button by the
old German source text, which this branch replaces with "Today".

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

* feat(onboarding): show the onboarding cards and check-in names in the user's language

The onboarding cards, the closing line and the coach routine names were English server text
for every user. The server keeps the state and an English fallback; the web now words them
through Lingui, so there is one translation source.

- Choice cards: the web says the question, the option labels and the placeholder from the
  step and option ids, and keeps the server text for anything it does not know (quiet-hour
  ranges are already written in the user's locale). Another bot's focus card is recognized
  by its option ids. Mobile and agent history keep the server text.
- The "Got it. … are a good place to start." line is gone: the app cards speak for
  themselves, and with no cards nothing is said.
- The closing line becomes an intake_note block (note "done", nextCheckInAt, English text).
  The web words it with the first check-in in the user's clock; mobile blockText and
  blocksToAgentHistoryText read its text through their generic fallback.
- Routines carry coachKey. The routine list and editor show a check-in under its label in
  the UI language until the user renames it; saving an untouched label keeps the stored
  name. The English names move to contracts (COACH_ROUTINE_NAMES); prompts stay English.

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

* style: use top-level type imports in the coach changes

AGENTS.md asks for top-level `import type` instead of inline `type`
specifiers; this applies it to the import statements the coach work added
or changed.

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

* chore(web): extract catalogs and translate the coach strings to German

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

* test(web): look for the bot reply in the transcript only

The main conversation's sidebar row now previews the latest reply, so the
unscoped text lookup matched the sidebar too and failed in strict mode.

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

* test(web): drop the trading status from the shell contrast test

main's contrast test now waits for the sidebar trading status before
measuring; this branch removes that status.

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

* test(onboarding): link the foreign folder while its tenant owns the deployment

Since #112 only the deployment owner can link a host folder, so the
discovery test creates the other tenant's linked space while that tenant
holds the deployment, then hands the deployment to the scanning owner.

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

* chore(web): extract catalogs and translate the in-app browser strings

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

* test: look up the home main conversation in the scheduling journey

Since every project has its own main conversation, the journey that
schedules for a specialist found the user's main conversation with
findFirst over all of them. With the Lab project's conversation also
matching, it picked either one, so the check failed about one run in
three. The lookup now names the home space, where the specialist joined
and the routine is created.

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

* test: expect the home main conversation's ask in another space's inbox

Every project now has a main conversation of its own, so an ask waiting in
the home main conversation is not visible from a project and its inbox
lists it, answered in the home space.

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

* fix(coach): move check-ins when the user's time zone changes

The coach check-ins are main-bot routines scheduled in the profile time zone, but a later
zone change (About you, onboarding, the main bot's update_user_profile tool, or forgetting
the whole profile) left them in the old zone until the rhythm was saved again, so they fired
at the wrong local time.

syncCoachTimeZone, next to the rhythm code, moves the main bot's coach routines that are not
in the current profile zone into it, recomputes the next run of armed ones and re-arms their
wakeup. Paused check-ins move but stay paused, routines the user made are never touched, users
without check-ins get none, and a save that leaves the zone as it is changes nothing. The
profile update and forget RPCs and the profile tool call it whenever they write the zone.

Quiet hours, the daily nudge cap and coach dates are evaluated in the profile zone when they
are used, so they need no migration.

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

* fix(web): word the main conversation preview in the user's language during onboarding

While onboarding wrote the main conversation, its sidebar row, project rows and Heute showed the
server preview: nothing for a pending card (a card has no text of its own, so the row fell back
to "Ready") and the English fallback of the closing note, even for a German user.

Groups now carry `previewBlock` next to `preview`: the latest message when it is an onboarding
card or note, validated with the same block schemas the messages use (now named exports). The
web words it with the helpers that render the card and the note, so no copy is duplicated: the
question while a card waits, the answer once given, and the closing note in the user's language
and clock. A dismissed card keeps the plain preview, as the conversation no longer shows it.
The field is left out for every other message, and mobile keeps using the plain `preview`.

The chosen-answer lookup moves from the card into onboarding-copy so the card and the preview
share it.

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

* test(web): look for the focus question in the conversation, not the sidebar

The main conversation's sidebar row now shows the pending intake question,
so a page-wide text match finds it twice (strict mode) or never reaches zero.
The specs mean the card in the open conversation, so they look there.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-04 11:26:26 +02:00

1658 lines
58 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?
onboarding UserOnboarding?
facts UserFact[]
goals UserGoal[]
habits Habit[]
checkIns CheckIn[]
dailyBriefs DailyBrief[]
pushSubscriptions PushSubscription[]
notificationSettings UserNotificationSettings?
@@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[]
userGoals UserGoal[]
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 lead agent of the space (one per user and space), "specialist" otherwise.
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_spaceId_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 space's main conversation led by its 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_spaceId_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?
/// One of the user's coach check-ins (morning, evening, weekly) on the main bot; null otherwise.
coachKey String?
lastRunAt DateTime?
nextRunAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
runs Run[]
@@unique([botId, coachKey])
@@index([active, nextRunAt])
@@index([spaceId, botId])
@@index([createdByBotId])
// CHECK constraint routines_coachKey_chk is created in SQL.
@@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")
}
/// One device that receives the user's push notifications: a browser (web push) or the mobile
/// app (an Expo push token). An endpoint belongs to the account that registered it last.
model PushSubscription {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
kind String
/// The web push endpoint URL or the Expo push token.
endpoint String @unique
p256dh String?
auth String?
userAgent String @default("")
createdAt DateTime @default(now())
lastUsedAt DateTime?
@@index([userId])
// CHECK constraints push_subscriptions_kind_chk and push_subscriptions_webpush_keys_chk are created in SQL.
@@map("push_subscriptions")
}
/// When the user may be interrupted. No row means no quiet hours and nudges on.
model UserNotificationSettings {
userId String @id
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// Minutes after local midnight in the profile time zone; both set or both null.
quietHoursStart Int?
quietHoursEnd Int?
nudges Boolean @default(true)
/// The user's local date of the last nudge and how many were sent that day (daily cap).
nudgeDate DateTime? @db.Date
nudgeCount Int @default(0)
updatedAt DateTime @updatedAt
// CHECK constraints user_notification_settings_quiet_hours_chk and _nudge_count_chk are created in SQL.
@@map("user_notification_settings")
}
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")
}
/// Where the user is in the onboarding questions the main bot asks in the main conversation.
model UserOnboarding {
userId String @id
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// The step whose card is waiting for an answer; null once onboarding is complete.
step String?
focus String?
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// CHECK constraint user_onboarding_step_chk is created in SQL.
@@map("user_onboarding")
}
/// 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")
}
/// Something the user wants to get done; the main bot coaches them toward it.
model UserGoal {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
title String
detail String @default("")
status String @default("active")
/// The user's local date the goal is due, if any.
targetDate DateTime? @db.Date
spaceId String?
space Space? @relation(fields: [spaceId], references: [id], onDelete: SetNull)
source String
/// Like the other provenance ids it has no foreign key.
createdByBotId String?
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
habits Habit[]
checkIns CheckIn[]
@@index([userId, status, createdAt])
@@index([spaceId])
// CHECK constraints user_goals_status_chk and user_goals_source_chk are created in SQL.
@@map("user_goals")
}
/// A recurring practice the user wants to keep, checked off per day or week.
model Habit {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
title String
cadence String @default("daily")
goalId String?
goal UserGoal? @relation(fields: [goalId], references: [id], onDelete: SetNull)
active Boolean @default(true)
source String
createdByBotId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
checkIns CheckIn[]
@@index([userId, active, createdAt])
@@index([goalId])
// CHECK constraints habits_cadence_chk and habits_source_chk are created in SQL.
@@map("habits")
}
/// What the user reported for one day: a habit kept or missed, goal progress, or how the day went.
model CheckIn {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
kind String
habitId String?
habit Habit? @relation(fields: [habitId], references: [id], onDelete: Cascade)
goalId String?
goal UserGoal? @relation(fields: [goalId], references: [id], onDelete: Cascade)
/// The user's local date.
date DateTime @db.Date
done Boolean?
mood Int?
energy Int?
note String @default("")
createdByBotId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([habitId, date])
@@unique([goalId, date])
@@index([userId, date])
// CHECK constraints check_ins_kind_chk, check_ins_target_chk, check_ins_mood_chk and
// check_ins_energy_chk and the partial unique index check_ins_userId_date_daily_key are
// created in SQL.
@@map("check_ins")
}
/// The main bot's short brief for one of the user's days.
model DailyBrief {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// The user's local date.
date DateTime @db.Date
text String
botId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, date])
@@map("daily_briefs")
}
/// 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")
}