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>
This commit is contained in:
cynarAI 2026-10-04 11:26:26 +02:00 committed by GitHub
parent 0c3e3f3d29
commit 6135bd8d63
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
156 changed files with 13500 additions and 3085 deletions

View File

@ -25,9 +25,9 @@ PUBLIC_POSTHOG_KEY=
PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
SIGNUPS_ENABLED=true
SIGNUP_ALLOWLIST=
# First-run discovery. Leave unset and the onboarding scan never touches the host filesystem.
# Set a home-style directory (its Projects, Documents, src, ... subfolders are scanned) to let the
# deployment owner's own onboarding discover local projects. Other accounts never scan the host.
# First-run discovery. Leave unset and onboarding never looks at the host filesystem.
# Set a folder (for example a home folder) and the deployment owner's onboarding offers to list the
# git projects in it, reading file and folder names only. Other accounts never see the step.
# ONBOARDING_SCAN_HOME=
ENCRYPTION_KEY=replace-with-64-random-hex-characters
DATA_DIR=./data
@ -166,8 +166,12 @@ SMTP_URL=
EMAIL_FROM=
# Local development only: capture password-reset email and print its link to the API console.
EMAIL_EMULATOR=
# Optional web push keys (set both). Unset: generated once and stored encrypted in the database.
# Browsers only allow push on https (or localhost).
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
# Contact for push services: mailto: or https: URL. Defaults to WEB_ORIGIN when it is https.
VAPID_SUBJECT=
# Structured logs to stdout (json in production, pretty in development unless overridden).
LOG_LEVEL=info
LOG_FORMAT=

View File

@ -1,5 +1,4 @@
import { randomUUID } from "node:crypto";
import { rm } from "node:fs/promises";
import { ORPCError, onError } from "@orpc/server";
import { RPCHandler } from "@orpc/server/fetch";
import type {
@ -35,13 +34,14 @@ import {
destroyBot,
EmailEmulator,
EncryptedSecretStore,
ExpoPushProvider,
ExpoPushSender,
GraphileJobPublisher,
GraphileJobWorkerHost,
InMemoryJobQueue,
InMemoryRealtimeFanout,
InstalledConnectorProvider,
IntegrationProviderSettings,
importLegacyPushTokens,
isComposioEnabled,
isMessagingSurfaceEnabled,
isPipedreamEnabled,
@ -54,9 +54,9 @@ import {
PiOAuthLogins,
PipedreamConnector,
PostgresRealtimeFanout,
PushNotificationProvider,
pipedreamConfigFromEnv,
piSessionsRoot,
pushTokenPath,
reconcileCloudAgents,
reconcileComputerUpdates,
reconcileDelegatedTasks,
@ -65,6 +65,9 @@ import {
SmtpEmailProvider,
SpaceMemoryProviderResolver,
toTeamChatInbound,
VapidKeys,
vapidEnvFromProcess,
WebPushSender,
} from "@rakazo/adapters";
import { blockedAuthPaths, createAuth } from "@rakazo/auth";
import { signupPolicyFromEnv } from "@rakazo/core";
@ -308,7 +311,14 @@ export async function createApp(
: new PiAgentRuntime({
sessionRoot: env.piSessionRecording ? piSessionsRoot(env.dataDir) : undefined,
});
const notifications = new ExpoPushProvider(env.dataDir);
const vapid = new VapidKeys(prisma, secrets, vapidEnvFromProcess(process.env));
const notifications = new PushNotificationProvider(prisma, {
webpush: new WebPushSender(vapid),
expo: new ExpoPushSender(),
});
void importLegacyPushTokens(prisma, env.dataDir).catch((error) =>
getLogger().error("legacy push token import failed", error),
);
const auth = createAuth(prisma, {
secret: env.authSecret,
baseURL: env.authUrl,
@ -364,7 +374,6 @@ export async function createApp(
),
);
await removePiUserSessions(env.dataDir, userId);
await rm(pushTokenPath(env.dataDir, userId), { force: true }).catch(() => undefined);
},
});
// One provider instance so emulator launches and polls share the same Map.
@ -462,6 +471,7 @@ export async function createApp(
secrets,
oauthLogins,
integrationSettings,
vapid,
mcpOAuth,
composio: stack.composio,
connectors: stack.connector,
@ -559,7 +569,7 @@ export async function createApp(
}
const { matched, response } = await rpc.handle(c.req.raw, {
prefix: "/rpc",
context: { actor, signal: c.req.raw.signal },
context: { actor, signal: c.req.raw.signal, userAgent: c.req.header("user-agent") },
});
if (matched) return c.newResponse(response.body, response);
await next();

96
apps/api/src/coach.ts Normal file
View File

@ -0,0 +1,96 @@
import { ORPCError } from "@orpc/server";
import type { JobPublisher } from "@rakazo/adapter-kit";
import { getCoachRhythm, setCoachRhythm } from "@rakazo/adapters";
import type {
Actor,
CheckIn,
CoachRhythm,
CoachToday,
Goal,
Habit,
LogCheckIn,
} from "@rakazo/contracts";
import type { CheckInFilter, GoalPatch, HabitPatch, PrismaClient } from "@rakazo/db";
import {
CoachNotFoundError,
createGoal,
createHabit,
InvalidCoachInputError,
listCheckIns,
listGoals,
listHabits,
loadCoachToday,
logCheckIn,
removeGoal,
removeHabit,
updateGoal,
updateHabit,
} from "@rakazo/db";
export interface CoachDeps {
prisma: PrismaClient;
jobs: JobPublisher;
}
/** Another user's ids read as missing, so a caller cannot probe them. */
async function mapErrors<T>(work: () => Promise<T>): Promise<T> {
try {
return await work();
} catch (error) {
if (error instanceof CoachNotFoundError) {
throw new ORPCError("NOT_FOUND", { message: error.message });
}
if (error instanceof InvalidCoachInputError) {
throw new ORPCError("BAD_REQUEST", { message: error.message });
}
throw error;
}
}
/** Coach procedures for the signed-in user only; nothing here is space-scoped. */
export function createCoachService(deps: CoachDeps) {
const { prisma } = deps;
return {
today: (actor: Actor): Promise<CoachToday> => loadCoachToday(prisma, actor.userId),
listGoals: (actor: Actor, input: { status?: Goal["status"] }) =>
listGoals(prisma, actor.userId, input),
createGoal: (
actor: Actor,
input: {
title: string;
detail?: string;
targetDate?: string | null;
spaceId?: string | null;
},
) => mapErrors(() => createGoal(prisma, actor.userId, { ...input, source: "user" })),
updateGoal: (actor: Actor, { goalId, ...patch }: GoalPatch & { goalId: string }) =>
mapErrors(() => updateGoal(prisma, actor.userId, goalId, patch)),
removeGoal: async (actor: Actor, input: { goalId: string }) => {
await mapErrors(() => removeGoal(prisma, actor.userId, input.goalId));
return { ok: true as const };
},
listHabits: (actor: Actor, input: { includeInactive?: boolean }): Promise<Habit[]> =>
listHabits(prisma, actor.userId, input),
createHabit: (
actor: Actor,
input: { title: string; cadence: Habit["cadence"]; goalId?: string | null },
) => mapErrors(() => createHabit(prisma, actor.userId, { ...input, source: "user" })),
updateHabit: (actor: Actor, { habitId, ...patch }: HabitPatch & { habitId: string }) =>
mapErrors(() => updateHabit(prisma, actor.userId, habitId, patch)),
removeHabit: async (actor: Actor, input: { habitId: string }) => {
await mapErrors(() => removeHabit(prisma, actor.userId, input.habitId));
return { ok: true as const };
},
logCheckIn: (actor: Actor, input: LogCheckIn): Promise<CheckIn> =>
mapErrors(() => logCheckIn(prisma, actor.userId, input)),
listCheckIns: (actor: Actor, input: CheckInFilter) =>
mapErrors(() => listCheckIns(prisma, actor.userId, input)),
getRhythm: (actor: Actor): Promise<CoachRhythm> => getCoachRhythm(prisma, actor.userId),
setRhythm: (actor: Actor, rhythm: CoachRhythm): Promise<CoachRhythm> =>
setCoachRhythm(deps, { userId: actor.userId, rhythm }),
};
}

View File

@ -0,0 +1,50 @@
import { FEATURED_CONNECTOR_LABELS, matchFeaturedConnectorId } from "@rakazo/core";
import type { PrismaClient } from "@rakazo/db";
import { updateUserProfile } from "@rakazo/db";
import { getLogger } from "@rakazo/logging";
/**
* The app's own name: a featured app's label, otherwise the label of the user's first account for
* it (later accounts may be named "Gmail 2" or renamed).
*/
async function connectedAppName(
prisma: PrismaClient,
connection: { userId: string; connectorId: string; provider: string },
): Promise<string | null> {
const featured = matchFeaturedConnectorId(connection.provider);
if (featured) return FEATURED_CONNECTOR_LABELS[featured];
const first = await prisma.connection.findFirst({
where: {
userId: connection.userId,
connectorId: connection.connectorId,
provider: connection.provider,
},
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
select: { displayName: true },
});
const name = first?.displayName.trim().slice(0, 80);
return name || null;
}
/**
* Remembers "Uses <App>" in the user's profile when one of their connections becomes connected.
* Revoking the connection keeps the fact; the user removes it in About you. Best effort: the
* connection itself has already succeeded, so a full profile or a database error only logs.
*/
export async function recordConnectedApp(
prisma: PrismaClient,
connection: { userId: string; connectorId: string; provider: string },
): Promise<void> {
try {
const name = await connectedAppName(prisma, connection);
if (!name) return;
await updateUserProfile(prisma, connection.userId, {
addFacts: [{ category: "tools", value: `Uses ${name}`, source: "connector" }],
});
} catch (error) {
getLogger().error("connected app fact was not recorded", error, {
connectorId: connection.connectorId,
provider: connection.provider,
});
}
}

View File

@ -18,9 +18,11 @@ function run(
status: "waiting_input" | "waiting_takeover",
thread: Partial<InboxRunRow["thread"]> = {},
bot = scout,
spaceId = "space-home",
): InboxRunRow {
return {
id,
spaceId,
status,
updatedAt: at(0),
bot,
@ -48,8 +50,10 @@ const approval = {
function unreadThread(
bot: InboxUnreadThreadRow["bot"],
latest?: { id: string; text: string; minute: number },
spaceId = "space-home",
): InboxUnreadThreadRow {
return {
spaceId,
createdAt: at(0),
bot,
messages: latest
@ -227,4 +231,64 @@ describe("buildInboxItems", () => {
});
expect(items[2]).toMatchObject({ messageId: null, text: "", createdAt: at(0).toISOString() });
});
it("lists asks from another space's main conversation, but not the open space's own", () => {
const project = { id: "space-launch", name: "Launch" };
const main = (groupId: string) => ({
botId: null,
groupId,
group: { kind: "main", archivedAt: null },
});
const items = buildInboxItems({
runs: [
run("home-main", "waiting_input", main("group-main-home")),
run("project-main", "waiting_input", main("group-main-launch"), writer, project.id),
],
messages: [
message("ask-home", "home-main", [question], 1),
message("ask-project", "project-main", [question], 2),
],
unreadThreads: [],
otherSpaces: new Map([[project.id, project]]),
});
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({
runId: "project-main",
space: project,
target: { groupId: "group-main-launch" },
});
});
it("names the space of items from the user's other spaces", () => {
const side = { id: "space-side", name: "Side project" };
const items = buildInboxItems({
runs: [
run("run-home", "waiting_input"),
run("run-side", "waiting_input", {}, writer, side.id),
run("takeover-side", "waiting_takeover", {}, writer, side.id),
],
messages: [
message("ask-home", "run-home", [question], 1),
message("ask-side", "run-side", [question], 2),
],
unreadThreads: [
unreadThread(
{ id: "bot-quiet", name: "Quiet", color: "#3EC5A8" },
{ id: "quiet-latest", text: "Done", minute: 3 },
side.id,
),
],
otherSpaces: new Map([[side.id, side]]),
});
expect(items.map((item) => [item.kind, item.space?.name ?? null])).toEqual([
["takeover", "Side project"],
["ask", null],
["ask", "Side project"],
["unread", "Side project"],
]);
expect(items[1]).not.toHaveProperty("space");
expect(items[2]).toMatchObject({ space: side, target: { botId: writer.id } });
});
});

View File

@ -4,7 +4,7 @@ import { isApprovalAskBlock } from "@rakazo/core";
import type { PrismaClient } from "@rakazo/db";
import { promptSnippet } from "./runs.js";
/** Caps per query; a space rarely holds more waiting runs or unread threads than this. */
/** Caps per query; the user's spaces rarely hold more waiting runs or unread threads than this. */
const INBOX_RUN_LIMIT = 50;
const INBOX_UNREAD_LIMIT = 50;
const INBOX_PREVIEW_LENGTH = 280;
@ -12,9 +12,11 @@ const INBOX_PREVIEW_LENGTH = 280;
type AskBlock = Extract<MessageBlock, { kind: "ask" }>;
type ComputerBlock = Extract<MessageBlock, { kind: "computer" }>;
type InboxBot = { id: string; name: string; color: string };
type InboxSpace = NonNullable<InboxItem["space"]>;
export interface InboxRunRow {
id: string;
spaceId: string;
status: string;
updatedAt: Date;
bot: InboxBot;
@ -33,6 +35,7 @@ export interface InboxMessageRow {
}
export interface InboxUnreadThreadRow {
spaceId: string;
createdAt: Date;
bot: InboxBot | null;
/** The newest bot message, if any. */
@ -73,12 +76,14 @@ function previewText(blocks: unknown): string {
}
/**
* Where an ask can be answered from. The main conversation already shows its own asks, and an
* archived group or an external conversation has no thread target to answer through.
* Where an ask can be answered from. The open space's main conversation already shows its own
* asks (a project's main conversation does not), and an archived group or an external
* conversation has no thread target to answer through.
*/
function askTarget(thread: InboxRunRow["thread"]): InboxThreadTarget | null {
function askTarget(thread: InboxRunRow["thread"], currentSpace: boolean): InboxThreadTarget | null {
if (thread.groupId) {
if (!thread.group || thread.group.kind === "main" || thread.group.archivedAt) return null;
if (!thread.group || thread.group.archivedAt) return null;
if (thread.group.kind === "main" && currentSpace) return null;
return { groupId: thread.groupId };
}
return thread.botId ? { botId: thread.botId } : null;
@ -95,14 +100,20 @@ function byCreatedAt(left: InboxItem, right: InboxItem): number {
/**
* Turns waiting runs, their ask and takeover messages, and unread threads into inbox items:
* everything that waits on the user first, then unread threads not already covered, each
* oldest first.
* oldest first. Items from another space name it.
*/
export function buildInboxItems(input: {
runs: InboxRunRow[];
/** Ask and takeover messages of those runs, newest first. */
messages: InboxMessageRow[];
unreadThreads: InboxUnreadThreadRow[];
/** The user's other spaces by id; rows of any space not listed belong to the current one. */
otherSpaces?: ReadonlyMap<string, InboxSpace>;
}): InboxItem[] {
const spaceOf = (spaceId: string) => {
const space = input.otherSpaces?.get(spaceId);
return space ? { space } : {};
};
const messagesByRun = new Map<string, InboxMessageRow[]>();
for (const message of input.messages) {
if (!message.runId) continue;
@ -115,13 +126,14 @@ export function buildInboxItems(input: {
for (const run of input.runs) {
const messages = messagesByRun.get(run.id) ?? [];
if (run.status === "waiting_input") {
const target = askTarget(run.thread);
const target = askTarget(run.thread, !input.otherSpaces?.has(run.spaceId));
const pending = target ? newest(messages, pendingAsk) : undefined;
if (!target || !pending) continue;
waiting.push({
kind: isApprovalAskBlock(pending.block) ? "approval" : "ask",
bot: run.bot,
target,
...spaceOf(run.spaceId),
runId: run.id,
messageId: pending.message.id,
ask: pending.block,
@ -135,6 +147,7 @@ export function buildInboxItems(input: {
kind: "takeover",
bot: run.bot,
target: { botId: run.bot.id },
...spaceOf(run.spaceId),
runId: run.id,
messageId: request?.message.id ?? null,
text: request?.block.text ?? "",
@ -154,6 +167,7 @@ export function buildInboxItems(input: {
kind: "unread",
bot: thread.bot,
target,
...spaceOf(thread.spaceId),
messageId: latest?.id ?? null,
text: previewText(latest?.blocks),
createdAt: (latest?.createdAt ?? thread.createdAt).toISOString(),
@ -165,13 +179,34 @@ export function buildInboxItems(input: {
const botSelect = { id: true, name: true, color: true } as const;
/** The current space's inbox: only the actor's own bots, runs and threads. */
/**
* The other spaces whose items the inbox shows: every space the user belongs to, except spaces
* being deleted.
*/
async function otherInboxSpaces(
prisma: PrismaClient,
actor: Actor,
): Promise<Map<string, InboxSpace>> {
const memberships = await prisma.spaceMember.findMany({
where: { userId: actor.userId, spaceId: { not: actor.spaceId }, space: { deletingAt: null } },
select: { space: { select: { id: true, name: true } } },
});
return new Map(memberships.map(({ space }) => [space.id, space]));
}
/**
* The inbox across the user's spaces: only the actor's own bots, runs and threads. Items are
* answered and opened in their own space, where the thread procedures check membership and
* ownership again.
*/
export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<InboxItem[]> {
const otherSpaces = await otherInboxSpaces(prisma, actor);
const spaceIds = [actor.spaceId, ...otherSpaces.keys()];
const ownBot = { userId: actor.userId, archivedAt: null };
const [runs, unreadThreads] = await Promise.all([
prisma.run.findMany({
where: {
spaceId: actor.spaceId,
spaceId: { in: spaceIds },
userId: actor.userId,
status: { in: ["waiting_input", "waiting_takeover"] },
bot: ownBot,
@ -179,6 +214,7 @@ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<Inb
},
select: {
id: true,
spaceId: true,
status: true,
threadId: true,
updatedAt: true,
@ -197,12 +233,13 @@ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<Inb
// Only specialists' own threads; group threads, the main conversation included, stay out.
prisma.thread.findMany({
where: {
spaceId: actor.spaceId,
spaceId: { in: spaceIds },
userId: actor.userId,
unread: true,
bot: { ...ownBot, kind: "specialist" },
},
select: {
spaceId: true,
createdAt: true,
bot: { select: botSelect },
messages: {
@ -232,5 +269,5 @@ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<Inb
orderBy: { seq: "desc" },
select: { id: true, runId: true, blocks: true, createdAt: true },
});
return buildInboxItems({ runs, messages, unreadThreads });
return buildInboxItems({ runs, messages, unreadThreads, otherSpaces });
}

View File

@ -0,0 +1,108 @@
import { describe, expect, it } from "vitest";
import {
closingNote,
focusCard,
intakeContext,
intakeStep,
nextIntakeStep,
readIntakeAnswer,
secondaryOption,
} from "./onboarding-intake.js";
// Intl may separate the time from AM/PM with a narrow no-break space.
const plain = (text: string) => text.replace(/\s/g, " ");
const off = { morning: null, evening: null, weekly: null };
describe("onboarding steps", () => {
it("asks in order and offers notifications only to a device that can still turn push on", () => {
expect(nextIntakeStep("focus", { canEnablePush: false })?.id).toBe("goal");
expect(nextIntakeStep("goal", { canEnablePush: false })?.id).toBe("rhythm");
expect(nextIntakeStep("rhythm", { canEnablePush: false })?.id).toBe("quiet_hours");
expect(nextIntakeStep("quiet_hours", { canEnablePush: true })?.id).toBe("notifications");
expect(nextIntakeStep("quiet_hours", { canEnablePush: false })).toBeNull();
expect(nextIntakeStep("notifications", { canEnablePush: true })).toBeNull();
});
it("gives every onboarding card a quiet way out, and another bot's focus card none", () => {
const context = intakeContext(null, new Date());
for (const id of ["focus", "goal", "rhythm", "quiet_hours", "notifications"] as const) {
const card = intakeStep(id).card(context);
expect(card.step).toBe(id);
expect(secondaryOption(card)).toBeDefined();
}
expect(focusCard().step).toBeUndefined();
expect(secondaryOption(focusCard())).toBeUndefined();
});
it("writes quiet hours in the user's locale", () => {
const labels = (locale: string | null) =>
intakeStep("quiet_hours")
.card(intakeContext({ locale, timeZone: null }, new Date()))
.options.map((option) => plain(option.label));
expect(labels("de-AT")).toEqual(["22:00 – 7:00", "23:00 – 8:00", "Skip"]);
expect(labels("en-US")).toEqual(["10:00 PM – 7:00 AM", "11:00 PM – 8:00 AM", "Skip"]);
expect(labels("not a tag")).toEqual(labels("en"));
});
});
describe("readIntakeAnswer", () => {
const goal = intakeStep("goal").card(intakeContext(null, new Date()));
const rhythm = intakeStep("rhythm").card(intakeContext(null, new Date()));
it("accepts the card's own options and words only where the card takes them", () => {
expect(readIntakeAnswer(rhythm, "weekly", undefined)).toEqual({
option: { id: "weekly", letter: "C", label: "Once a week" },
});
expect(readIntakeAnswer(rhythm, "skip", undefined)).toBeNull();
expect(readIntakeAnswer(rhythm, "_text", "Mornings")).toBeNull();
expect(readIntakeAnswer(goal, "_text", " Run a 10K ")).toEqual({ text: "Run a 10K" });
expect(readIntakeAnswer(goal, "_text", " ")).toBeNull();
expect(readIntakeAnswer(goal, "skip", undefined)?.option?.label).toBe("Skip");
});
});
describe("closingNote", () => {
const vienna = (iso: string, locale = "de-AT") =>
intakeContext({ locale, timeZone: "Europe/Vienna" }, new Date(iso));
const note = (rhythm: Parameters<typeof closingNote>[0], context: ReturnType<typeof vienna>) => {
const { nextCheckInAt, text } = closingNote(rhythm, context);
return { nextCheckInAt, text: plain(text) };
};
it("names the first check-in in the user's time zone", () => {
const morning = { ...off, morning: "08:00" };
expect(note(morning, vienna("2026-10-07T04:00:00Z"))).toEqual({
nextCheckInAt: "2026-10-07T06:00:00.000Z",
text: "All set. I’ll check in today at 8:00.",
});
expect(note(morning, vienna("2026-10-07T09:00:00Z"))).toEqual({
nextCheckInAt: "2026-10-08T06:00:00.000Z",
text: "All set. I’ll check in tomorrow at 8:00.",
});
expect(note({ ...morning, evening: "18:00" }, vienna("2026-10-07T09:00:00Z")).text).toBe(
"All set. I’ll check in today at 18:00.",
);
// Wednesday in Vienna; the weekly review is on Sunday.
const weekly = { ...off, weekly: { day: 0, time: "18:00" } };
expect(note(weekly, vienna("2026-10-07T09:00:00Z", "en-US"))).toEqual({
nextCheckInAt: "2026-10-11T16:00:00.000Z",
text: "All set. I’ll check in on Sunday at 6:00 PM.",
});
});
it("says the user can just write when no check-in is set", () => {
expect(closingNote(off, vienna("2026-10-07T09:00:00Z"))).toEqual({
kind: "intake_note",
note: "done",
nextCheckInAt: null,
text: "All set. I’m here whenever you need me.",
});
});
it("falls back to English and UTC for an unusable profile", () => {
expect(intakeContext({ locale: "??", timeZone: "Mars/Olympus" }, new Date(0))).toMatchObject({
locale: "en",
timeZone: "UTC",
});
});
});

View File

@ -0,0 +1,297 @@
import type { JobPublisher } from "@rakazo/adapter-kit";
import { COACH_KEYS, coachCron, setCoachRhythm } from "@rakazo/adapters";
import type { CoachRhythm, MessageBlock, OnboardingStep, QuietHours } from "@rakazo/contracts";
import { CHOICE_TEXT_ANSWER_ID } from "@rakazo/contracts";
import { knownTimeZone, nextCronDateAcross } from "@rakazo/core";
import type { Prisma, PrismaClient } from "@rakazo/db";
import { createGoal, updateNotificationSettings, updateUserProfile } from "@rakazo/db";
/**
* The questions the main bot asks a new user in the main conversation, one card at a time.
* Each step is a card plus what its answer changes; the order of `INTAKE_STEPS` is the order
* the user sees. Everything here is deterministic, so no model tokens are spent.
*/
export type ChoiceBlock = Extract<MessageBlock, { kind: "choice" }>;
type ChoiceOption = ChoiceBlock["options"][number];
/** A validated answer: an option of the card, or the user's own words. */
export type IntakeAnswer =
| { option: ChoiceOption; text?: undefined }
| { option?: undefined; text: string };
/** What a card may adapt to: times are written in the user's locale. */
export interface IntakeContext {
locale: string;
timeZone: string;
now: Date;
}
export interface IntakeEffects {
prisma: PrismaClient;
jobs: JobPublisher;
tx: Prisma.TransactionClient;
userId: string;
}
interface IntakeStep {
id: OnboardingStep;
/** Asked only when this holds; otherwise onboarding moves past the step. */
asks?: (device: { canEnablePush: boolean }) => boolean;
card: (context: IntakeContext) => ChoiceBlock;
/** Records a validated answer. Runs while the main conversation is locked. */
save: (effects: IntakeEffects, answer: IntakeAnswer) => Promise<void>;
}
export interface FocusOption {
id: string;
letter: string;
label: string;
apps: string[];
/** Stored as a `goals` fact, so it stays a plain English statement. */
fact: string;
}
export const FOCUS_OPTIONS: FocusOption[] = [
{
id: "day",
letter: "A",
label: "Day-to-day work",
apps: ["slack", "gmail", "googlecalendar"],
fact: "Wants help with day-to-day work first",
},
{
id: "inbox",
letter: "B",
label: "Inbox & email",
apps: ["gmail", "googlecalendar", "slack"],
fact: "Wants help with inbox and email first",
},
{
id: "research",
letter: "C",
label: "Research & writing",
apps: ["hackernews", "notion", "googledocs"],
fact: "Wants help with research and writing first",
},
{
id: "crew",
letter: "D",
label: "Specialist crew",
apps: ["slack", "gmail", "googlecalendar"],
fact: "Wants a coordinated crew of specialist bots",
},
];
const SKIP: ChoiceOption = { id: "skip", letter: "", label: "Skip" };
/** The focus card. Onboarding adds a skip; another bot's card is dismissed instead. */
export function focusCard(step?: "focus"): ChoiceBlock {
return {
kind: "choice",
question: "What do you want me on first?",
options: [
...FOCUS_OPTIONS.map(({ id, letter, label }) => ({ id, letter, label })),
...(step ? [SKIP] : []),
],
...(step ? { step } : {}),
};
}
const RHYTHMS: Record<string, CoachRhythm> = {
morning: { morning: "08:00", evening: null, weekly: null },
morning_evening: { morning: "08:00", evening: "18:00", weekly: null },
weekly: { morning: null, evening: null, weekly: { day: 0, time: "18:00" } },
none: { morning: null, evening: null, weekly: null },
};
const QUIET_HOURS: Record<string, QuietHours> = {
"22-07": { start: "22:00", end: "07:00" },
"23-08": { start: "23:00", end: "08:00" },
};
export const INTAKE_STEPS: IntakeStep[] = [
{
id: "focus",
card: () => focusCard("focus"),
save: async ({ prisma, tx, userId }, answer) => {
const option = FOCUS_OPTIONS.find((entry) => entry.id === answer.option?.id);
if (!option) return;
await tx.userOnboarding.update({ where: { userId }, data: { focus: option.id } });
await updateUserProfile(prisma, userId, {
addFacts: [{ category: "goals", value: option.fact, source: "onboarding" }],
});
},
},
{
id: "goal",
card: () => ({
kind: "choice",
step: "goal",
question: "What’s one thing you want to get done in the next few weeks?",
textInput: { placeholder: "Your goal" },
options: [SKIP],
}),
save: async ({ prisma, userId }, answer) => {
if (answer.text)
await createGoal(prisma, userId, { title: answer.text, source: "onboarding" });
},
},
{
id: "rhythm",
card: () => ({
kind: "choice",
step: "rhythm",
question: "When should I check in with you?",
options: [
{ id: "morning", letter: "A", label: "Every morning" },
{ id: "morning_evening", letter: "B", label: "Morning and evening" },
{ id: "weekly", letter: "C", label: "Once a week" },
{ id: "none", letter: "", label: "Not now" },
],
}),
save: async ({ prisma, jobs, userId }, answer) => {
const rhythm = answer.option && RHYTHMS[answer.option.id];
if (rhythm) await setCoachRhythm({ prisma, jobs }, { userId, rhythm });
},
},
{
id: "quiet_hours",
card: (context) => ({
kind: "choice",
step: "quiet_hours",
question: "When should I stay quiet?",
options: [
...Object.entries(QUIET_HOURS).map(([id, range], index) => ({
id,
letter: String.fromCharCode(65 + index),
label: `${formatClock(range.start, context.locale)} – ${formatClock(range.end, context.locale)}`,
})),
SKIP,
],
}),
save: async ({ prisma, userId }, answer) => {
const quietHours = answer.option && QUIET_HOURS[answer.option.id];
if (quietHours) await updateNotificationSettings(prisma, userId, { quietHours });
},
},
{
// The browser subscribes when the user taps "Turn on"; the answer only moves on.
id: "notifications",
asks: ({ canEnablePush }) => canEnablePush,
card: () => ({
kind: "choice",
step: "notifications",
question: "Can I send you notifications?",
options: [
{ id: "on", letter: "A", label: "Turn on" },
{ id: "later", letter: "", label: "Not now" },
],
}),
save: async () => {},
},
];
export function intakeStep(id: OnboardingStep): IntakeStep {
const step = INTAKE_STEPS.find((entry) => entry.id === id);
if (!step) throw new Error(`Unknown onboarding step: ${id}`);
return step;
}
/** The step after `id` that applies to this device, or null when onboarding is done. */
export function nextIntakeStep(
id: OnboardingStep,
device: { canEnablePush: boolean },
): IntakeStep | null {
const index = INTAKE_STEPS.findIndex((entry) => entry.id === id);
return INTAKE_STEPS.slice(index + 1).find((entry) => entry.asks?.(device) ?? true) ?? null;
}
/** Checks an answer against the card it answers; null when the card does not offer it. */
export function readIntakeAnswer(
card: ChoiceBlock,
optionId: string,
text: string | undefined,
): IntakeAnswer | null {
if (optionId === CHOICE_TEXT_ANSWER_ID) {
const words = text?.trim();
return card.textInput && words ? { text: words } : null;
}
const option = card.options.find((entry) => entry.id === optionId);
return option ? { option } : null;
}
/** The secondary answer ("Skip", "Not now") a dismissed card counts as. */
export function secondaryOption(card: ChoiceBlock): ChoiceOption | undefined {
return card.options.find((option) => !option.letter);
}
/** Card times and the closing line follow the user's profile, falling back to English and UTC. */
export function intakeContext(
profile: { locale: string | null; timeZone: string | null } | null,
now: Date,
): IntakeContext {
let locale = "en";
try {
if (profile?.locale) locale = Intl.getCanonicalLocales(profile.locale)[0] ?? "en";
} catch {
// Not a language tag; keep English.
}
return { locale, timeZone: knownTimeZone(profile?.timeZone) ?? "UTC", now };
}
function formatClock(time: string, locale: string): string {
const [hour = 0, minute = 0] = time.split(":").map(Number);
return new Intl.DateTimeFormat(locale, {
hour: "numeric",
minute: "2-digit",
timeZone: "UTC",
}).format(new Date(Date.UTC(2000, 0, 1, hour, minute)));
}
function localDay(date: Date, timeZone: string): number {
const [year, month, day] = new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
})
.format(date)
.split("-")
.map(Number);
return Date.UTC(year ?? 1970, (month ?? 1) - 1, day ?? 1) / 86_400_000;
}
export type IntakeNote = Extract<MessageBlock, { kind: "intake_note" }>;
/**
* The closing note: what happens next, i.e. the first check-in when there is one. The web says
* it in the user's language from `nextCheckInAt`; `text` is the English line for everything else.
*/
export function closingNote(rhythm: CoachRhythm, context: IntakeContext): IntakeNote {
const crons = COACH_KEYS.flatMap((key) => coachCron(key, rhythm) ?? []);
const next = nextCronDateAcross(crons, context.now, context.timeZone);
return {
kind: "intake_note",
note: "done",
nextCheckInAt: next?.toISOString() ?? null,
text: closingText(next, context),
};
}
function closingText(next: Date | null, context: IntakeContext): string {
if (!next) return "All set. I’m here whenever you need me.";
const days = localDay(next, context.timeZone) - localDay(context.now, context.timeZone);
const day =
days === 0
? "today"
: days === 1
? "tomorrow"
: `on ${new Intl.DateTimeFormat("en-US", { timeZone: context.timeZone, weekday: "long" }).format(next)}`;
const time = new Intl.DateTimeFormat(context.locale, {
hour: "numeric",
minute: "2-digit",
timeZone: context.timeZone,
}).format(next);
return `All set. I’ll check in ${day} at ${time}.`;
}

View File

@ -1,166 +1,192 @@
import { mkdir, mkdtemp, realpath, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type * as db from "@rakazo/db";
import { SpaceLimitError } from "@rakazo/db";
import { afterEach, describe, expect, it, vi } from "vitest";
import { getScanStatus, startOnboardingScan } from "./onboarding-scan.js";
const createSpaceForMember = vi.hoisted(() => vi.fn());
const ensureMainConversation = vi.hoisted(() => vi.fn());
vi.mock("@rakazo/db", async (original) => ({
...(await original<typeof db>()),
createSpaceForMember,
ensureMainConversation,
}));
import path from "node:path";
import type { PrismaClient } from "@rakazo/db";
import { afterAll, describe, expect, it, vi } from "vitest";
import {
applyOnboardingScan,
discoverOnboardingScan,
onboardingScanFolder,
onboardingScanStatus,
startOnboardingScan,
} from "./onboarding-scan.js";
const owner = { userId: "user-owner", spaceId: "space-owner", isDeploymentOwner: true };
const member = { userId: "user-member", spaceId: "space-member", isDeploymentOwner: false };
const roots: string[] = [];
type SpaceUpdate = { where: { id: string }; data: { workspacePath: string } };
/** The spaces the scan reads and links; new ones come from `createSpaceForMember`. */
function fakeDatabase({ spaceLimit = Number.POSITIVE_INFINITY } = {}) {
const spaces: Array<{ id: string; name: string; workspacePath: string | null }> = [];
createSpaceForMember.mockImplementation(async (_prisma: unknown, input: { name: string }) => {
if (spaces.length >= spaceLimit) throw new SpaceLimitError();
const space = { id: `space-${spaces.length + 1}`, name: input.name, workspacePath: null };
spaces.push(space);
return { id: space.id, name: space.name };
});
const prisma = {
space: {
findMany: async () => spaces.filter((space) => space.workspacePath),
update: async ({ where, data }: SpaceUpdate) => {
const space = spaces.find((candidate) => candidate.id === where.id);
if (space) space.workspacePath = data.workspacePath;
},
/** Fails the test if the scan writes anything before its checks pass. */
const untouchedPrisma = new Proxy(
{},
{
get() {
throw new Error("the database must not be touched");
},
} as unknown as db.PrismaClient;
return { prisma, spaces };
},
) as PrismaClient;
async function tempDir(): Promise<string> {
const dir = await realpath(await mkdtemp(path.join(tmpdir(), "evoo-scan-")));
roots.push(dir);
return dir;
}
async function scanHomeWithOneFile(): Promise<string> {
const home = await mkdtemp(join(tmpdir(), "evoo-scan-"));
await mkdir(join(home, "Projects"));
await writeFile(join(home, "Projects", "notes.ts"), "export const x = 1;\n");
return home;
async function files(root: string, entries: string[]): Promise<void> {
for (const entry of entries) {
const file = path.join(root, entry);
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, "x\n");
}
}
async function scanHomeWithRepos(...repos: string[]): Promise<string> {
const home = await scanHomeWithOneFile();
for (const repo of repos) await mkdir(join(home, repo, ".git"), { recursive: true });
return home;
async function project(root: string, relative: string, entries: string[]): Promise<void> {
await mkdir(path.join(root, relative, ".git"), { recursive: true });
await files(path.join(root, relative), entries);
}
async function completed(scanId: string, userId: string) {
return await vi.waitFor(
async () => {
const status = await getScanStatus(scanId, userId);
if (status.status !== "complete" && status.status !== "failed") throw new Error("pending");
() => {
const status = onboardingScanStatus(scanId, userId);
if (status.status === "scanning") throw new Error("pending");
return status;
},
{ timeout: 10_000, interval: 50 },
{ timeout: 10_000, interval: 20 },
);
}
/** The emulated runtime that the shared test setup pins keeps the scan simulated. */
function allowHostScan() {
vi.stubEnv("AGENT_RUNTIME", "");
vi.stubEnv("SANDBOX_PROVIDER", "");
}
afterAll(async () => {
await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true })));
});
describe("onboarding scan", () => {
afterEach(() => {
vi.unstubAllEnvs();
createSpaceForMember.mockReset();
ensureMainConversation.mockReset();
it("lists git projects with their languages and tools and never follows a symlink", async () => {
const home = await tempDir();
const outside = await tempDir();
// A home folder tracked by git is not itself a project.
await mkdir(path.join(home, ".git"));
await project(home, "code/atlas", [
"src/a.ts",
"src/b.ts",
"src/c.ts",
"src/d.tsx",
"scripts/build.js",
"Dockerfile",
".github/workflows/ci.yml",
"infra/main.tf",
"src/odd.constructor",
"node_modules/dep/index.js",
"node_modules/dep/more.js",
]);
await project(home, "code/garden", ["main.py"]);
await project(home, "deep/a/b/edge", ["tool.py"]);
await project(home, "deep/a/b/c/too-deep", ["main.go"]);
await project(home, ".hidden/secret", ["main.go"]);
await project(home, "notes/node_modules/vendored", ["main.go"]);
await files(home, ["notes/todo.md"]);
await project(outside, "escaped", ["main.rs"]);
await symlink(path.join(outside, "escaped"), path.join(home, "linked"));
await symlink(outside, path.join(home, "code/atlas/src/outside"));
const findings = await discoverOnboardingScan(home);
expect(findings.projects).toEqual([
{
id: "project-0",
name: "atlas",
path: "code/atlas",
languages: ["TypeScript", "JavaScript"],
tools: ["Docker", "GitHub Actions", "Terraform"],
},
{ id: "project-1", name: "garden", path: "code/garden", languages: ["Python"], tools: [] },
{ id: "project-2", name: "edge", path: "deep/a/b/edge", languages: ["Python"], tools: [] },
]);
expect(findings.facts).toEqual([
{ id: "language:Python", label: "Python", category: "work", value: "Codes in Python" },
{
id: "language:JavaScript",
label: "JavaScript",
category: "work",
value: "Codes in JavaScript",
},
{
id: "language:TypeScript",
label: "TypeScript",
category: "work",
value: "Codes in TypeScript",
},
{ id: "tool:Docker", label: "Docker", category: "tools", value: "Uses Docker" },
{
id: "tool:GitHub Actions",
label: "GitHub Actions",
category: "tools",
value: "Uses GitHub Actions",
},
{ id: "tool:Terraform", label: "Terraform", category: "tools", value: "Uses Terraform" },
]);
});
it("hides a scan from every account except the one that started it", async () => {
const { prisma } = fakeDatabase();
const { scanId } = await startOnboardingScan({ actor: member }, prisma);
await expect(getScanStatus(scanId, owner.userId)).rejects.toThrow(/Scan not found/);
await expect(getScanStatus(scanId, member.userId)).resolves.toMatchObject({
status: expect.any(String),
});
await expect(getScanStatus("scan_missing", member.userId)).rejects.toThrow(/Scan not found/);
it("offers a scan folder only to the deployment owner and only when it exists", async () => {
const home = await tempDir();
await files(home, ["file.txt"]);
await expect(onboardingScanFolder(member, home)).resolves.toBeNull();
await expect(onboardingScanFolder(owner, undefined)).resolves.toBeNull();
await expect(onboardingScanFolder(owner, path.join(home, "missing"))).resolves.toBeNull();
await expect(onboardingScanFolder(owner, path.join(home, "file.txt"))).resolves.toBeNull();
await expect(onboardingScanFolder(owner, home)).resolves.toBe(home);
await expect(startOnboardingScan(member, home)).rejects.toMatchObject({ code: "FORBIDDEN" });
});
it("never reads the host without a configured scan home", async () => {
const { prisma } = fakeDatabase();
const { scanId } = await startOnboardingScan({ actor: owner }, prisma);
it("keeps a scan with the account that started it and returns only relative paths", async () => {
const home = await tempDir();
await project(home, "code/atlas", ["index.ts"]);
const { scanId } = await startOnboardingScan(owner, home);
const status = await completed(scanId, owner.userId);
expect(status.status).toBe("complete");
expect(status.result).toMatchObject({ localRepos: [], fileSummary: { totalFiles: 0 } });
});
it("lets only the deployment owner discover the configured scan home", async () => {
allowHostScan();
const scanHome = await scanHomeWithRepos("Projects/app");
const { prisma } = fakeDatabase();
const asMember = await startOnboardingScan({ actor: member, scanHome }, prisma);
const memberStatus = await completed(asMember.scanId, member.userId);
expect(memberStatus.result).toMatchObject({ fileSummary: { totalFiles: 0 } });
expect(createSpaceForMember).not.toHaveBeenCalled();
const asOwner = await startOnboardingScan({ actor: owner, scanHome }, prisma);
const ownerStatus = await completed(asOwner.scanId, owner.userId);
expect(ownerStatus.status).toBe("complete");
expect(ownerStatus.result).toMatchObject({ fileSummary: { totalFiles: 1 } });
});
it("links each repository of the deployment owner to one project of its own", async () => {
allowHostScan();
const scanHome = await scanHomeWithRepos("Projects/app", ".config/nvim");
const app = await realpath(join(scanHome, "Projects", "app"));
const { prisma, spaces } = fakeDatabase();
const first = await startOnboardingScan({ actor: owner, scanHome }, prisma);
const firstStatus = await completed(first.scanId, owner.userId);
expect(firstStatus.status).toBe("complete");
expect(firstStatus.result).toMatchObject({
localRepos: [{ name: "app" }],
createdProjects: [{ spaceId: "space-1", name: "app", path: app }],
expect(status).toEqual({
status: "complete",
projects: [{ id: "project-0", name: "atlas", path: "code/atlas" }],
facts: [{ id: "language:TypeScript", label: "TypeScript" }],
});
expect(createSpaceForMember).toHaveBeenCalledWith(prisma, {
currentSpaceId: owner.spaceId,
userId: owner.userId,
name: "app",
expect(JSON.stringify(status)).not.toContain(home);
expect(() => onboardingScanStatus(scanId, member.userId)).toThrow(
expect.objectContaining({ code: "NOT_FOUND" }),
);
expect(() => onboardingScanStatus("missing-scan", owner.userId)).toThrow(
expect.objectContaining({ code: "NOT_FOUND" }),
);
await expect(
applyOnboardingScan(untouchedPrisma, member, home, {
scanId,
projectIds: ["project-0"],
factIds: [],
}),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("checks ownership, the folder and every chosen project again before writing", async () => {
const home = await tempDir();
const outside = await tempDir();
await project(home, "code/atlas", ["index.ts"]);
await project(home, "code/garden", ["main.py"]);
await project(outside, "elsewhere", ["main.rs"]);
const { scanId } = await startOnboardingScan(owner, home);
await completed(scanId, owner.userId);
const choice = { scanId, projectIds: ["project-1"], factIds: ["language:Python"] };
await expect(
applyOnboardingScan(untouchedPrisma, { ...owner, isDeploymentOwner: false }, home, choice),
).rejects.toMatchObject({ code: "FORBIDDEN" });
await expect(
applyOnboardingScan(untouchedPrisma, owner, outside, choice),
).rejects.toMatchObject({ code: "FORBIDDEN" });
// The chosen project became a symlink out of the scan folder after the scan.
await rm(path.join(home, "code/garden"), { recursive: true });
await symlink(path.join(outside, "elsewhere"), path.join(home, "code/garden"));
await expect(applyOnboardingScan(untouchedPrisma, owner, home, choice)).rejects.toMatchObject({
code: "BAD_REQUEST",
message: "That folder does not exist or is not readable.",
});
expect(spaces).toEqual([{ id: "space-1", name: "app", workspacePath: app }]);
// Each new project starts with its own main agent.
expect(ensureMainConversation).toHaveBeenCalledWith(prisma, owner.userId, "space-1");
// Scanning again finds the linked project instead of creating a second one.
const again = await startOnboardingScan({ actor: owner, scanHome }, prisma);
const againStatus = await completed(again.scanId, owner.userId);
expect(againStatus.result).toMatchObject({ createdProjects: [] });
expect(createSpaceForMember).toHaveBeenCalledTimes(1);
});
it("joins a running scan instead of linking its repositories twice", async () => {
allowHostScan();
const scanHome = await scanHomeWithRepos("Projects/app");
const { prisma, spaces } = fakeDatabase();
const first = await startOnboardingScan({ actor: owner, scanHome }, prisma);
const second = await startOnboardingScan({ actor: owner, scanHome }, prisma);
expect(second.scanId).toBe(first.scanId);
await completed(first.scanId, owner.userId);
expect(spaces).toHaveLength(1);
});
it("stops creating projects at the space limit and still completes", async () => {
allowHostScan();
const scanHome = await scanHomeWithRepos("Projects/a", "Projects/b", "Projects/c");
const { prisma } = fakeDatabase({ spaceLimit: 1 });
const { scanId } = await startOnboardingScan({ actor: owner, scanHome }, prisma);
const status = await completed(scanId, owner.userId);
expect(status.status).toBe("complete");
expect(status.result).toMatchObject({ createdProjects: [{ spaceId: "space-1" }] });
expect(createSpaceForMember).toHaveBeenCalledTimes(2);
});
});

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,7 @@
import type * as db from "@rakazo/db";
import { describe, expect, it, vi } from "vitest";
import { chooseFocus, markAppConnected } from "./onboarding.js";
import { focusCard } from "./onboarding-intake.js";
const posted = vi.hoisted(() => [] as Array<{ blocks: unknown[] }>);
vi.mock("@rakazo/db", async (original) => ({
@ -13,16 +14,16 @@ vi.mock("@rakazo/db", async (original) => ({
}));
function fixture(catalog: unknown[]) {
posted.length = 0;
// A specialist's focus card, waiting for an answer.
const pending = vi.fn(async () => [{ id: "choice", blocks: [focusCard()] }]);
const tx = {
$executeRaw: vi.fn(),
message: {
findMany: vi.fn(async () => [{ id: "choice", blocks: [{ kind: "choice", answerId: null }] }]),
update: vi.fn(),
},
message: { findMany: pending, update: vi.fn() },
};
const deps = {
prisma: {
bot: { findFirst: vi.fn(async () => ({ id: "bot", thread: { id: "thread" } })) },
message: { findMany: pending },
$transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(tx)),
},
events: { notify: vi.fn() },
@ -39,17 +40,16 @@ function fixture(catalog: unknown[]) {
describe("onboarding connection suggestions", () => {
it("does not invent authorization cards when no connector has an app catalog", async () => {
const { deps, actor } = fixture([]);
await chooseFocus(deps, actor, "bot", "day");
expect(posted.flatMap((message) => message.blocks)).not.toContainEqual(
expect.objectContaining({ kind: "app_connect" }),
);
expect(posted.length).toBeGreaterThan(0);
await chooseFocus(deps, actor, "bot", { optionId: "day" });
// The answered card is all that changes; nothing is said around it.
expect(posted).toEqual([]);
expect(deps.events.notify).toHaveBeenCalled();
});
it("uses the available connector and omits unavailable apps", async () => {
const { deps, actor } = fixture([
{ connectorId: "pipedream", slug: "slack", name: "Slack", connected: false, logo: null },
]);
await chooseFocus(deps, actor, "bot", "day");
await chooseFocus(deps, actor, "bot", { optionId: "day" });
expect(
posted
.flatMap((message) => message.blocks)
@ -57,6 +57,8 @@ describe("onboarding connection suggestions", () => {
).toEqual([
expect.objectContaining({ connectorId: "pipedream", provider: "slack", name: "Slack" }),
]);
// The cards speak for themselves.
expect(posted).toHaveLength(1);
});
});

View File

@ -1,67 +1,47 @@
import { ORPCError } from "@orpc/server";
import type { JobPublisher } from "@rakazo/adapter-kit";
import type { ConnectorRegistry } from "@rakazo/adapters";
import type { Actor, MessageBlock } from "@rakazo/contracts";
import { getCoachRhythm } from "@rakazo/adapters";
import type { Actor, MessageBlock, OnboardingStep } from "@rakazo/contracts";
import { CHOICE_TEXT_ANSWER_ID } from "@rakazo/contracts";
import { featuredConnectorProvidersMatch } from "@rakazo/core";
import {
appendEventInTransaction,
createThreadMessageInTransaction,
ensureMainConversation,
IsolationError,
isPersonalMainBot,
type Prisma,
type PrismaClient,
type ThreadEvents,
} from "@rakazo/db";
import type { ChoiceBlock, FocusOption, IntakeAnswer, IntakeContext } from "./onboarding-intake.js";
import {
closingNote,
FOCUS_OPTIONS,
focusCard,
intakeContext,
intakeStep,
nextIntakeStep,
readIntakeAnswer,
secondaryOption,
} from "./onboarding-intake.js";
/**
* First-run conversational onboarding, seeded deterministically into the bot's
* thread: greeting, a focus choice, and available app cards the user authorizes
* inline. Focus must not rename the bot. No model tokens are spent. The main bot
* talks in the main conversation, where onboarding lands.
* Conversational onboarding, posted deterministically into a bot's thread; no model tokens are
* spent. The main bot asks the onboarding questions (`onboarding-intake.ts`) in the main
* conversation, one card after each answer. Any other bot gets one focus card that suggests
* apps the user authorizes inline. Focus never renames a bot.
*/
type OnboardingDeps = {
prisma: PrismaClient;
events: ThreadEvents;
connectors: ConnectorRegistry;
jobs: JobPublisher;
};
type FocusOption = {
id: string;
letter: string;
label: string;
summary: string;
apps: string[];
};
const FOCUS_OPTIONS: FocusOption[] = [
{
id: "day",
letter: "A",
label: "Day-to-day work",
summary: "Slack, calendar, and email",
apps: ["slack", "gmail", "googlecalendar"],
},
{
id: "inbox",
letter: "B",
label: "Inbox & email",
summary: "email and calendar",
apps: ["gmail", "googlecalendar", "slack"],
},
{
id: "research",
letter: "C",
label: "Research & writing",
summary: "the web, notes, and docs",
apps: ["hackernews", "notion", "googledocs"],
},
{
id: "crew",
letter: "D",
label: "Specialist crew",
summary: "a coordinated team of focused Oos",
apps: ["slack", "gmail", "googlecalendar"],
},
];
type Target = { spaceId: string; botId: string; threadId: string };
const APP_DESCRIPTIONS: Record<string, string> = {
slack: "Search, read, and send messages.",
@ -85,74 +65,102 @@ async function requireBotThread(deps: OnboardingDeps, actor: Actor, botId: strin
return { bot, thread: bot.thread };
}
async function post(
deps: OnboardingDeps,
target: { spaceId: string; botId: string; threadId: string },
/**
* Serializes card writes on a thread. NO KEY UPDATE still lets an answer's effects insert rows
* that reference the thread from another connection (the coach routines do).
*/
async function lockThread(tx: Prisma.TransactionClient, threadId: string): Promise<void> {
await tx.$executeRaw`SELECT id FROM threads WHERE id = ${threadId} FOR NO KEY UPDATE`;
}
async function postInTransaction(
tx: Prisma.TransactionClient,
target: Target,
blocks: MessageBlock[],
): Promise<string> {
const committed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const message = await createThreadMessageInTransaction(tx, {
threadId: target.threadId,
role: "bot",
blocks,
botId: target.botId,
});
const event = await appendEventInTransaction(tx, {
spaceId: target.spaceId,
threadId: target.threadId,
botId: target.botId,
type: "thread.message.created",
payload: { messageId: message.id, role: "bot", blocks },
});
return { message, event };
): Promise<{ seq: number }> {
const message = await createThreadMessageInTransaction(tx, {
threadId: target.threadId,
role: "bot",
blocks,
botId: target.botId,
});
return appendEventInTransaction(tx, {
spaceId: target.spaceId,
threadId: target.threadId,
botId: target.botId,
type: "thread.message.created",
payload: { messageId: message.id, role: "bot", blocks },
});
}
async function updateBlocksInTransaction(
tx: Prisma.TransactionClient,
target: Target,
messageId: string,
blocks: MessageBlock[],
): Promise<{ seq: number }> {
await tx.message.update({ where: { id: messageId }, data: { blocks } });
return appendEventInTransaction(tx, {
spaceId: target.spaceId,
threadId: target.threadId,
botId: target.botId,
type: "thread.message.updated",
payload: { messageId, role: "bot", blocks },
});
await deps.events.notify(target.threadId, committed.event.seq);
return committed.message.id;
}
async function updateBlocks(
deps: OnboardingDeps,
target: { spaceId: string; botId: string; threadId: string },
target: Target,
messageId: string,
blocks: MessageBlock[],
): Promise<void> {
const event = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
await tx.message.update({ where: { id: messageId }, data: { blocks } });
return appendEventInTransaction(tx, {
spaceId: target.spaceId,
threadId: target.threadId,
botId: target.botId,
type: "thread.message.updated",
payload: { messageId, role: "bot", blocks },
});
});
const event = await deps.prisma.$transaction((tx: Prisma.TransactionClient) =>
updateBlocksInTransaction(tx, target, messageId, blocks),
);
await deps.events.notify(target.threadId, event.seq);
}
/** Sentinel answerId for a focus card the user dismissed without choosing. */
export const FOCUS_DISMISSED_ANSWER_ID = "_dismissed";
/** Checks that the caller may onboard this bot's thread; fresh threads start empty. */
export async function startOnboarding(
deps: OnboardingDeps,
actor: Actor,
botId: string,
): Promise<void> {
const { thread } = await requireBotThread(deps, actor, botId);
const existing = await deps.prisma.message.count({ where: { threadId: thread.id } });
if (existing > 0) return;
// Fresh chats start empty (same as web). The focus card is posted later via
// promptFocus so non-first bots can wait ~10s for free typing, or skip if the
// user already engaged.
await requireBotThread(deps, actor, botId);
}
function messageHasChoice(blocks: MessageBlock[]): boolean {
return blocks.some((block) => block.kind === "choice");
function isChoice(block: MessageBlock): block is ChoiceBlock {
return block.kind === "choice";
}
function messageHasPendingChoice(blocks: MessageBlock[]): boolean {
return blocks.some((block) => block.kind === "choice" && !block.answerId);
function pendingChoice(blocks: MessageBlock[]): ChoiceBlock | undefined {
return blocks.find((block): block is ChoiceBlock => isChoice(block) && !block.answerId);
}
/** The newest card in the thread that still waits for an answer, however long ago it came. */
async function findPendingCard(client: PrismaClient | Prisma.TransactionClient, threadId: string) {
const messages = await client.message.findMany({
where: { threadId, role: "bot", blocks: { array_contains: [{ kind: "choice" }] } },
select: { id: true, blocks: true },
orderBy: { seq: "desc" },
take: 20,
});
for (const message of messages) {
const blocks = message.blocks as MessageBlock[];
const card = pendingChoice(blocks);
if (card) return { messageId: message.id, blocks, card };
}
return null;
}
/**
* Posts the first card once. The main bot starts the onboarding questions, one set per user;
* any other bot posts its focus card only while its thread is still idle.
*/
export async function promptFocus(
deps: OnboardingDeps,
actor: Actor,
@ -160,44 +168,32 @@ export async function promptFocus(
): Promise<void> {
const { bot, thread } = await requireBotThread(deps, actor, botId);
const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id };
const blocks: MessageBlock[] = [
{
kind: "choice",
question: "What do you want me on first?",
options: FOCUS_OPTIONS.map(({ id, letter, label }) => ({ id, letter, label })),
},
];
// Check + insert + event in one transaction so concurrent promptFocus calls
// cannot duplicate cards, and a concurrent user send cannot publish first.
// The intake runs with the personal main agent; a project's main bot starts like any other.
const main = await isPersonalMainBot(deps.prisma, bot);
// Check + insert + event in one transaction so concurrent callers cannot duplicate cards,
// and a concurrent user send cannot publish first.
const committed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
// Serialize concurrent promptFocus callers on this thread before the gate check.
await tx.$executeRaw`SELECT id FROM threads WHERE id = ${thread.id} FOR UPDATE`;
await lockThread(tx, thread.id);
if (main && (await tx.userOnboarding.findUnique({ where: { userId: actor.userId } }))) {
return null;
}
const recent = await tx.message.findMany({
where: { threadId: thread.id },
select: { role: true, blocks: true },
orderBy: { createdAt: "asc" },
});
if (recent.some((message) => message.role === "user")) return null;
if (recent.some((message) => messageHasChoice(message.blocks as MessageBlock[]))) return null;
const message = await createThreadMessageInTransaction(tx, {
threadId: target.threadId,
role: "bot",
blocks,
botId: target.botId,
});
const event = await appendEventInTransaction(tx, {
spaceId: target.spaceId,
threadId: target.threadId,
botId: target.botId,
type: "thread.message.created",
payload: { messageId: message.id, role: "bot", blocks },
});
return { message, event };
if (!main && recent.some((message) => message.role === "user")) return null;
if (recent.some((message) => (message.blocks as MessageBlock[]).some(isChoice))) return null;
if (!main) return postInTransaction(tx, target, [focusCard()]);
const first = intakeStep("focus");
await tx.userOnboarding.create({ data: { userId: actor.userId, step: first.id } });
return postInTransaction(tx, target, [
first.card(await loadIntakeContext(tx, actor.userId, new Date())),
]);
});
if (!committed) return;
await deps.events.notify(target.threadId, committed.event.seq);
if (committed) await deps.events.notify(target.threadId, committed.seq);
}
/** Dismisses the pending card. An onboarding question counts as skipped, so the next follows. */
export async function dismissFocus(
deps: OnboardingDeps,
actor: Actor,
@ -205,81 +201,128 @@ export async function dismissFocus(
): Promise<void> {
const { bot, thread } = await requireBotThread(deps, actor, botId);
const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id };
const claimed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
await tx.$executeRaw`SELECT id FROM threads WHERE id = ${thread.id} FOR UPDATE`;
const recent = await tx.message.findMany({
where: { threadId: thread.id },
orderBy: { createdAt: "asc" },
});
const pending = recent.find((message) =>
messageHasPendingChoice(message.blocks as MessageBlock[]),
const pending = await findPendingCard(deps.prisma, thread.id);
const skip = pending?.card.step ? secondaryOption(pending.card) : undefined;
if (pending?.card.step && skip) {
await chooseFocus(deps, actor, botId, { step: pending.card.step, optionId: skip.id });
return;
}
const event = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
await lockThread(tx, thread.id);
const current = await findPendingCard(tx, thread.id);
if (!current || current.card.step) return null;
const blocks = current.blocks.map((block) =>
block === current.card ? { ...current.card, answerId: FOCUS_DISMISSED_ANSWER_ID } : block,
);
if (!pending) return null;
const blocks = (pending.blocks as MessageBlock[]).map((block) =>
block.kind === "choice" && !block.answerId
? { ...block, answerId: FOCUS_DISMISSED_ANSWER_ID }
: block,
);
await tx.message.update({ where: { id: pending.id }, data: { blocks } });
const event = await appendEventInTransaction(tx, {
spaceId: target.spaceId,
threadId: target.threadId,
botId: target.botId,
type: "thread.message.updated",
payload: { messageId: pending.id, role: "bot", blocks },
});
return { messageId: pending.id, blocks, event };
return updateBlocksInTransaction(tx, target, current.messageId, blocks);
});
if (!claimed) return;
await deps.events.notify(target.threadId, claimed.event.seq);
if (event) await deps.events.notify(target.threadId, event.seq);
}
export interface ChooseInput {
step?: OnboardingStep;
optionId: string;
text?: string;
canEnablePush?: boolean;
}
/**
* Answers the pending card. The answer is checked against that card, a stale answer (another
* card is pending by now) is ignored, and everything that follows is committed together with
* it: the focus card's app suggestions, then the next onboarding question or the closing line.
*/
export async function chooseFocus(
deps: OnboardingDeps,
actor: Actor,
botId: string,
optionId: string,
input: ChooseInput,
): Promise<void> {
const option = FOCUS_OPTIONS.find((entry) => entry.id === optionId);
if (!option) throw new IsolationError();
const { bot, thread } = await requireBotThread(deps, actor, botId);
const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id };
const pending = await findPendingCard(deps.prisma, thread.id);
if (!pending || pending.card.step !== input.step) return;
const answer = readIntakeAnswer(pending.card, input.optionId, input.text);
if (!answer) throw new ORPCError("BAD_REQUEST", { message: "This card has no such answer" });
const focus =
!pending.card.step || pending.card.step === "focus"
? FOCUS_OPTIONS.find((option) => option.id === answer.option?.id)
: undefined;
// Fetched before the transaction: the connector catalog can take seconds.
const followUp = focus ? await focusFollowUp(deps, actor, bot.id, focus) : [];
const claimed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
await tx.$executeRaw`SELECT id FROM threads WHERE id = ${thread.id} FOR UPDATE`;
const recent = await tx.message.findMany({
where: { threadId: thread.id },
orderBy: { createdAt: "asc" },
});
const pending = recent.find((message) =>
messageHasPendingChoice(message.blocks as MessageBlock[]),
);
if (!pending) return null;
const blocks = (pending.blocks as MessageBlock[]).map((block) =>
block.kind === "choice" ? { ...block, answerId: option.id } : block,
);
await tx.message.update({ where: { id: pending.id }, data: { blocks } });
const event = await appendEventInTransaction(tx, {
spaceId: target.spaceId,
threadId: target.threadId,
botId: target.botId,
type: "thread.message.updated",
payload: { messageId: pending.id, role: "bot", blocks },
});
return { messageId: pending.id, blocks, event };
const event = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
await lockThread(tx, thread.id);
const current = await findPendingCard(tx, thread.id);
if (current?.messageId !== pending.messageId || current.card.step !== input.step) return null;
const onboarding = current.card.step
? await tx.userOnboarding.findUnique({ where: { userId: actor.userId } })
: null;
if (current.card.step) {
if (onboarding?.step !== current.card.step) return null;
await intakeStep(current.card.step).save(
{ prisma: deps.prisma, jobs: deps.jobs, tx, userId: actor.userId },
answer,
);
}
const answered = answeredCard(current.card, answer);
const blocks = current.blocks.map((block) => (block === current.card ? answered : block));
let last = await updateBlocksInTransaction(tx, target, current.messageId, blocks);
for (const message of followUp) last = await postInTransaction(tx, target, message);
if (current.card.step) {
last = await askNext(deps, tx, target, actor.userId, current.card.step, input.canEnablePush);
}
return last;
});
if (!claimed) return;
await deps.events.notify(target.threadId, claimed.event.seq);
if (event) await deps.events.notify(target.threadId, event.seq);
}
// Keep the name and title the user chose when creating the bot; the focus
// step only suggests apps, it must not rename the bot.
await post(deps, target, [
{
kind: "text",
text: `Got it. ${capitalize(option.summary)}. I’ll see what’s already connected so I don’t make you set something up twice.`,
},
]);
function answeredCard(card: ChoiceBlock, answer: IntakeAnswer): ChoiceBlock {
return answer.option
? { ...card, answerId: answer.option.id }
: { ...card, answerId: CHOICE_TEXT_ANSWER_ID, answerText: answer.text };
}
async function loadIntakeContext(
tx: Prisma.TransactionClient,
userId: string,
now: Date,
): Promise<IntakeContext> {
const profile = await tx.userProfile.findUnique({
where: { userId },
select: { locale: true, timeZone: true },
});
return intakeContext(profile, now);
}
/** Posts the next onboarding question, or the closing line once every step is answered. */
async function askNext(
deps: OnboardingDeps,
tx: Prisma.TransactionClient,
target: Target,
userId: string,
answeredStep: OnboardingStep,
canEnablePush = false,
): Promise<{ seq: number }> {
const now = new Date();
const context = await loadIntakeContext(tx, userId, now);
const next = nextIntakeStep(answeredStep, { canEnablePush });
if (next) {
await tx.userOnboarding.update({ where: { userId }, data: { step: next.id } });
return postInTransaction(tx, target, [next.card(context)]);
}
// The rhythm step saved its answer outside this transaction, so the routines are readable.
const rhythm = await getCoachRhythm(deps.prisma, userId);
await tx.userOnboarding.update({ where: { userId }, data: { step: null, completedAt: now } });
return postInTransaction(tx, target, [closingNote(rhythm, context)]);
}
/** The apps worth connecting for a focus, as cards; they need no words around them. */
async function focusFollowUp(
deps: OnboardingDeps,
actor: Actor,
botId: string,
option: FocusOption,
): Promise<MessageBlock[][]> {
const providers = deps.connectors.managedProviders();
const catalog = (
await Promise.all(
@ -290,14 +333,14 @@ export async function chooseFocus(
traceId: "onboarding.choose",
spaceId: actor.spaceId,
userId: actor.userId,
botId: bot.id,
botId,
signal: AbortSignal.timeout(15_000),
})
.catch(() => []),
),
)
).flat();
const cards: MessageBlock[] = option.apps.flatMap((slug) => {
const cards = option.apps.flatMap((slug) => {
const entry = catalog.find(
(item) =>
item.slug.toLowerCase() === slug.toLowerCase() ||
@ -316,27 +359,7 @@ export async function chooseFocus(
},
];
});
if (!cards.length) {
await post(deps, target, [{ kind: "text", text: "What would you like to work on first?" }]);
return;
}
const cardNames = cards
.map((card) => (card.kind === "app_connect" ? card.name : ""))
.filter(Boolean);
const named = `${cardNames.slice(0, -1).join(", ")}${cardNames.length > 1 ? ", and " : ""}${cardNames.at(-1)}`;
await post(deps, target, [
{
kind: "text",
text: `${named} are a good place to start. Connect them here and I’ll use what you already have.`,
},
]);
await post(deps, target, cards);
await post(deps, target, [
{
kind: "text",
text: `Hit those ${cards.length === 1 ? "one" : cards.length === 2 ? "two" : "three"} and I’ll start pulling the picture.`,
},
]);
return cards.length > 0 ? [cards] : [];
}
export async function markAppConnected(
@ -376,7 +399,3 @@ export async function markAppConnected(
await updateBlocks(deps, target, message.id, next);
}
}
function capitalize(value: string): string {
return value.length > 0 ? (value[0] ?? "").toUpperCase() + value.slice(1) : value;
}

View File

@ -11,6 +11,7 @@ import type {
} from "@rakazo/adapter-kit";
import {
computerControlExpireJobKey,
MemoryDocumentTooLargeError,
messagingDeliverJob,
routineJobKey,
routineWakeupJob,
@ -27,6 +28,7 @@ import type {
MemoryProviderResolver,
PiOAuthLogins,
RemoteConnectorDependencies,
VapidKeys,
} from "@rakazo/adapters";
import {
acquireComputerExecutionLease,
@ -43,7 +45,6 @@ import {
computerSupportsUpdate,
computerUpdateView,
createVoiceProvider,
deletePushToken,
deploymentAutoReviewDefault,
destroyBot,
displayBotWorkspacePath,
@ -52,6 +53,7 @@ import {
filterTeamWorkspaceEntries,
hasActiveComputerControl,
isAutoReviewCheckerConfigured,
isBlockedHostname,
isComputerScreenUnavailable,
isProjectWorkspacePath,
isSandboxGoneError,
@ -74,7 +76,6 @@ import {
resolveAutoReviewChecker,
resolveBotWorkspacePath,
sanitizeComposioError,
savePushToken,
scheduleComputerControlExpiry,
scheduleComputerSleep,
screenLeaseIdForRun,
@ -99,6 +100,7 @@ import type {
} from "@rakazo/contracts";
import {
appContract,
CoachRoutineKeySchema,
IntegrationProviderIdSchema,
OPENAI_COMPATIBLE_PROVIDER_ID,
} from "@rakazo/contracts";
@ -131,6 +133,7 @@ import {
findModelCredential,
findSpaceModelTier,
formatMessagingLinkCode,
getNotificationSettings,
InvalidProjectWorkspacePathError,
InvalidSpaceNameError,
IsolationError,
@ -144,6 +147,8 @@ import {
parseComputerMode,
projectWorkspaceDto,
releaseSpaceDeletionClaim,
removePushSubscription,
removePushSubscriptionsOfKind,
renewSpaceDeletionClaim,
requireMembership,
resolveProjectWorkspaceDirectory,
@ -152,8 +157,10 @@ import {
SpaceLimitError,
SpaceNotEmptyError,
SpaceNotFoundError,
savePushSubscription,
selectSpaceModelPreference,
selectSpaceVoicePreference,
updateNotificationSettings,
wireProjectWorkspace,
} from "@rakazo/db";
import { getLogger } from "@rakazo/logging";
@ -162,11 +169,13 @@ import { createAgentSkillsService } from "./agent-skills.js";
import { aiConsentStatus, allowAiConsent } from "./ai-consent.js";
import { createOwnedArtifact, getOwnedArtifact, getSpaceArtifact } from "./artifacts.js";
import { botProfileLabelsChanged, commitBotUpdate } from "./bot-update.js";
import { createCoachService } from "./coach.js";
import {
executionBlocksUserTakeover,
resolveBusyBotName,
toComputerStatus,
} from "./computer-status.js";
import { recordConnectedApp } from "./connected-app-facts.js";
import { listInbox } from "./inbox.js";
import { searchIntegrationCatalog } from "./integration-catalog.js";
import {
@ -190,7 +199,12 @@ import {
promptFocus,
startOnboarding,
} from "./onboarding.js";
import { getScanStatus, startOnboardingScan } from "./onboarding-scan.js";
import {
applyOnboardingScan,
onboardingScanFolder,
onboardingScanStatus,
startOnboardingScan,
} from "./onboarding-scan.js";
import { listSpaceRuns } from "./runs.js";
import { addScreenProxyCapability } from "./screen-proxy.js";
import { querySpaceSearch } from "./search.js";
@ -295,6 +309,11 @@ async function reconcilePendingConnections(
: []),
];
if (updates.length > 0) await prisma.$transaction(updates);
const connected = new Set(sync.connectIds);
for (const row of rows) {
if (row.status !== "pending" || !connected.has(row.id)) continue;
await recordConnectedApp(prisma, { userId: owner.userId, connectorId, provider: row.provider });
}
}
/** Serialize begin/revoke for one user+provider so slug-wide remote deletes cannot race a new connect. */
@ -467,6 +486,8 @@ export interface RouterDeps {
secrets: EncryptedSecretStore;
oauthLogins: PiOAuthLogins;
integrationSettings?: IntegrationProviderSettings;
/** The deployment's web push key pair; without it browsers cannot subscribe. */
vapid?: Pick<VapidKeys, "publicKey">;
composio?: ComposioProvider;
mcpOAuth?: McpOAuthBroker;
connectors: ConnectorRegistry;
@ -534,9 +555,18 @@ function mapSpaceLifecycleError(error: unknown): unknown {
}
export function createRouter(deps: RouterDeps) {
const os = implement(appContract).$context<{ actor: Actor | null; signal?: AbortSignal }>();
const os = implement(appContract).$context<{
actor: Actor | null;
signal?: AbortSignal;
userAgent?: string;
}>();
const repos = createRepos(deps.prisma);
const onboardingDeps = { prisma: deps.prisma, events: deps.events, connectors: deps.connectors };
const onboardingDeps = {
prisma: deps.prisma,
events: deps.events,
connectors: deps.connectors,
jobs: deps.jobs,
};
const mcpOAuth = deps.mcpOAuth ?? new McpOAuthBroker(deps.prisma, deps.secrets);
const groupRepos = createGroupRepos(deps.prisma);
const projectGoalRepos = createProjectGoalRepos(deps.prisma);
@ -549,6 +579,7 @@ export function createRouter(deps: RouterDeps) {
dataDir: deps.dataDir,
});
const agentSkills = createAgentSkillsService(deps.prisma);
const coach = createCoachService(deps);
const authed = os.use(async ({ context, next }) => {
if (!context.actor) throw new ORPCError("UNAUTHORIZED");
@ -588,12 +619,57 @@ export function createRouter(deps: RouterDeps) {
profile: {
get: authed.profile.get.handler(({ context }) => getProfile(deps.prisma, context.actor)),
update: authed.profile.update.handler(({ context, input }) =>
updateProfile(deps.prisma, context.actor, input),
updateProfile(deps, context.actor, input),
),
forget: authed.profile.forget.handler(({ context, input }) =>
forgetProfile(deps.prisma, context.actor, input),
forgetProfile(deps, context.actor, input),
),
},
coach: {
today: authed.coach.today.handler(({ context }) => coach.today(context.actor)),
goals: {
list: authed.coach.goals.list.handler(({ context, input }) =>
coach.listGoals(context.actor, input),
),
create: authed.coach.goals.create.handler(({ context, input }) =>
coach.createGoal(context.actor, input),
),
update: authed.coach.goals.update.handler(({ context, input }) =>
coach.updateGoal(context.actor, input),
),
remove: authed.coach.goals.remove.handler(({ context, input }) =>
coach.removeGoal(context.actor, input),
),
},
habits: {
list: authed.coach.habits.list.handler(({ context, input }) =>
coach.listHabits(context.actor, input),
),
create: authed.coach.habits.create.handler(({ context, input }) =>
coach.createHabit(context.actor, input),
),
update: authed.coach.habits.update.handler(({ context, input }) =>
coach.updateHabit(context.actor, input),
),
remove: authed.coach.habits.remove.handler(({ context, input }) =>
coach.removeHabit(context.actor, input),
),
},
checkIns: {
log: authed.coach.checkIns.log.handler(({ context, input }) =>
coach.logCheckIn(context.actor, input),
),
list: authed.coach.checkIns.list.handler(({ context, input }) =>
coach.listCheckIns(context.actor, input),
),
},
rhythm: {
get: authed.coach.rhythm.get.handler(({ context }) => coach.getRhythm(context.actor)),
set: authed.coach.rhythm.set.handler(({ context, input }) =>
coach.setRhythm(context.actor, input),
),
},
},
spaces: {
list: authed.spaces.list.handler(async ({ context }) =>
spaceNavigationDto(deps, context.actor, repos, groupRepos),
@ -2504,21 +2580,28 @@ export function createRouter(deps: RouterDeps) {
},
});
if (!doc) throw new IsolationError();
const updated = await deps.memory.commit(
{
scope: doc.scope as "bot" | "user",
botId: doc.botId ?? undefined,
path: doc.path,
content: input.content,
},
{
operationId: "mem",
traceId: "mem",
spaceId: context.actor.spaceId,
userId: context.actor.userId,
signal: new AbortController().signal,
},
);
const updated = await deps.memory
.commit(
{
scope: doc.scope as "bot" | "user",
botId: doc.botId ?? undefined,
path: doc.path,
content: input.content,
},
{
operationId: "mem",
traceId: "mem",
spaceId: context.actor.spaceId,
userId: context.actor.userId,
signal: new AbortController().signal,
},
)
.catch((error: unknown) => {
if (error instanceof MemoryDocumentTooLargeError) {
throw new ORPCError("BAD_REQUEST", { message: error.message });
}
throw error;
});
return {
id: updated.id,
scope: doc.scope as "bot" | "user",
@ -3494,7 +3577,7 @@ export function createRouter(deps: RouterDeps) {
return { ok: true as const };
}),
choose: authed.onboarding.choose.handler(async ({ context, input }) => {
await chooseFocus(onboardingDeps, context.actor, input.botId, input.optionId);
await chooseFocus(onboardingDeps, context.actor, input.botId, input);
return { ok: true as const };
}),
dismissFocus: authed.onboarding.dismissFocus.handler(async ({ context, input }) => {
@ -3511,16 +3594,18 @@ export function createRouter(deps: RouterDeps) {
);
return { ok: true as const };
}),
scan: authed.onboarding.scan.handler(async ({ context, input }) => {
const { scanId } = await startOnboardingScan(
{ actor: context.actor, botId: input.botId, scanHome: deps.env.onboardingScanHome },
deps.prisma,
);
return { scanId };
}),
scanStatus: authed.onboarding.scanStatus.handler(async ({ context, input }) => {
return await getScanStatus(input.scanId, context.actor.userId);
}),
scanTarget: authed.onboarding.scanTarget.handler(async ({ context }) => ({
folder: await onboardingScanFolder(context.actor, deps.env.onboardingScanHome),
})),
scan: authed.onboarding.scan.handler(({ context }) =>
startOnboardingScan(context.actor, deps.env.onboardingScanHome),
),
scanStatus: authed.onboarding.scanStatus.handler(async ({ context, input }) =>
onboardingScanStatus(input.scanId, context.actor.userId),
),
applyScan: authed.onboarding.applyScan.handler(({ context, input }) =>
applyOnboardingScan(deps.prisma, context.actor, deps.env.onboardingScanHome, input),
),
},
integrationSetup: {
get: authed.integrationSetup.get.handler(async ({ context }) => {
@ -3769,6 +3854,13 @@ export function createRouter(deps: RouterDeps) {
}
throw new IsolationError();
}
if (!auth.authorizationUrl) {
await recordConnectedApp(deps.prisma, {
userId: context.actor.userId,
connectorId: input.connectorId,
provider: input.provider,
});
}
return { connectionId: row.id, authorizationUrl: auth.authorizationUrl };
} catch (error) {
if (error instanceof IsolationError) throw error;
@ -4074,6 +4166,13 @@ export function createRouter(deps: RouterDeps) {
},
{ timeout: 60_000 },
);
if (row.status === "connected") {
await recordConnectedApp(deps.prisma, {
userId: context.actor.userId,
connectorId: row.connectorId,
provider: row.provider,
});
}
}
return {
id: row.id,
@ -4828,13 +4927,53 @@ export function createRouter(deps: RouterDeps) {
},
notifications: {
registerPush: authed.notifications.registerPush.handler(async ({ context, input }) => {
await savePushToken(deps.dataDir, context.actor.userId, input.token);
await savePushSubscription(deps.prisma, context.actor.userId, {
kind: "expo",
endpoint: input.token,
userAgent: context.userAgent,
});
return { ok: true as const };
}),
// The mobile app signs out without naming its token, so all of its tokens go.
unregisterPush: authed.notifications.unregisterPush.handler(async ({ context }) => {
await deletePushToken(deps.dataDir, context.actor.userId);
await removePushSubscriptionsOfKind(deps.prisma, context.actor.userId, "expo");
return { ok: true as const };
}),
settings: {
get: authed.notifications.settings.get.handler(({ context }) =>
getNotificationSettings(deps.prisma, context.actor.userId),
),
update: authed.notifications.settings.update.handler(({ context, input }) =>
updateNotificationSettings(deps.prisma, context.actor.userId, input),
),
},
webPush: {
config: authed.notifications.webPush.config.handler(async () => {
const publicKey = (await deps.vapid?.publicKey()) ?? null;
return { available: publicKey !== null, publicKey };
}),
subscribe: authed.notifications.webPush.subscribe.handler(async ({ context, input }) => {
if (isBlockedHostname(new URL(input.endpoint).hostname.replace(/^\[|\]$/g, ""))) {
throw new ORPCError("BAD_REQUEST", {
message: "Endpoint must be a public push service",
});
}
await savePushSubscription(deps.prisma, context.actor.userId, {
kind: "webpush",
endpoint: input.endpoint,
p256dh: input.keys.p256dh,
auth: input.keys.auth,
userAgent: context.userAgent,
});
return { ok: true as const };
}),
unsubscribe: authed.notifications.webPush.unsubscribe.handler(
async ({ context, input }) => {
await removePushSubscription(deps.prisma, context.actor.userId, input.endpoint);
return { ok: true as const };
},
),
},
},
search: {
query: authed.search.query.handler(async ({ context, input }) => ({
@ -5137,6 +5276,7 @@ function spaceGroupDto(group: SpaceGroup): SpaceGroup {
sectionId: group.sectionId,
members: group.members,
preview: group.preview,
...(group.previewBlock ? { previewBlock: group.previewBlock } : {}),
unread: group.unread,
updatedAt: group.updatedAt,
};
@ -5520,6 +5660,7 @@ function mapRoutine(row: {
lastRunAt: Date | null;
nextRunAt: Date | null;
createdAt: Date;
coachKey: string | null;
}) {
return {
id: row.id,
@ -5536,6 +5677,7 @@ function mapRoutine(row: {
lastRunAt: row.lastRunAt?.toISOString() ?? null,
nextRunAt: row.nextRunAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
coachKey: CoachRoutineKeySchema.safeParse(row.coachKey).data ?? null,
};
}

View File

@ -1,4 +1,6 @@
import { ORPCError } from "@orpc/server";
import type { CoachRhythmDeps } from "@rakazo/adapters";
import { syncCoachTimeZone } from "@rakazo/adapters";
import type {
Actor,
ForgetUserFacts,
@ -29,32 +31,32 @@ export function getProfile(prisma: PrismaClient, actor: Actor): Promise<UserProf
}
export async function updateProfile(
prisma: PrismaClient,
deps: CoachRhythmDeps,
actor: Actor,
input: UpdateUserProfile,
): Promise<UserProfileState> {
try {
await updateUserProfile(prisma, actor.userId, input);
await updateUserProfile(deps.prisma, actor.userId, input);
} catch (error) {
throw toRpcError(error);
}
return getUserProfileState(prisma, actor.userId);
if (input.timeZone !== undefined) await syncCoachTimeZone(deps, actor.userId);
return getUserProfileState(deps.prisma, actor.userId);
}
/** Unknown ids are ignored, so a caller cannot probe another user's facts. */
export async function forgetProfile(
prisma: PrismaClient,
deps: CoachRhythmDeps,
actor: Actor,
input: ForgetUserFacts,
): Promise<UserProfileState> {
await forgetUserFacts(
prisma,
actor.userId,
input.factIds
? { factIds: input.factIds }
: input.category
? { category: input.category }
: { all: true },
);
return getUserProfileState(prisma, actor.userId);
const target = input.factIds
? { factIds: input.factIds }
: input.category
? { category: input.category }
: { all: true as const };
await forgetUserFacts(deps.prisma, actor.userId, target);
// Forgetting everything clears the time zone too.
if ("all" in target) await syncCoachTimeZone(deps, actor.userId);
return getUserProfileState(deps.prisma, actor.userId);
}

View File

@ -1857,6 +1857,32 @@ describe("mobile thread event reduction", () => {
).toBe("Slack · Alex: Hello from the group");
});
it("reads a choice card as its question and answer", () => {
const card = {
kind: "choice" as const,
step: "goal" as const,
question: "What’s one thing you want to get done in the next few weeks?",
textInput: { placeholder: "Your goal" },
options: [{ id: "skip", letter: "", label: "Skip" }],
};
expect(blockText(mobileMessage("choice-1", [card]))).toBe(card.question);
expect(
blockText(
mobileMessage("choice-2", [{ ...card, answerId: "_text", answerText: "Run a 10K" }]),
),
).toBe(`${card.question}\nRun a 10K`);
expect(blockText(mobileMessage("choice-3", [{ ...card, answerId: "skip" }]))).toBe(
`${card.question}\nSkip`,
);
const note = {
kind: "intake_note" as const,
note: "done" as const,
nextCheckInAt: null,
text: "All set. I’m here whenever you need me.",
};
expect(blockText(mobileMessage("note-1", [note]))).toBe(note.text);
});
it("deduplicates durable messages and replaces matching transient subagent state", () => {
const initial = snapshot([
mobileMessage("message-1", [{ kind: "text", text: "old" }]),

View File

@ -876,6 +876,12 @@ export function blockText(message: MobileMessage) {
if (block.kind === "child_bot") {
return `${block.status === "archived" ? "Archived" : block.status === "deleted" ? "Deleted" : "Bot"} ${block.name ?? ""}`;
}
// Choice cards are answered on the web; here they read as the question and its answer.
if (block.kind === "choice") {
const answer =
block.answerText ?? block.options.find((option) => option.id === block.answerId)?.label;
return answer ? `${block.question}\n${answer}` : block.question;
}
if (block.kind === "chart") return `[chart: ${block.name ?? "chart"}]`;
if (block.kind === "image") return `[image: ${block.name ?? "attachment"}]`;
if (block.kind === "file") {

View File

@ -255,7 +255,7 @@ test("shell surfaces stay readable in light and dark themes", async ({ page }, t
await sidebar.getByRole("button", { name: "Today", exact: true }).click();
const heute = page.getByTestId("heute-brief");
await expect(heute.getByTestId("heute-mission-control-link")).toBeVisible();
await expect(heute).toBeVisible();
await inEachTheme(page, async (theme) => {
await captureScreenshot(page, testInfo, `shell-contrast-${theme}-heute`);
await expectReadable(heute, `${theme}: the Heute brief`, { inside: true });

View File

@ -88,8 +88,9 @@ async function visible(locator: Locator, timeout: number) {
}
/**
* Walks first-run onboarding, which lands in the main conversation. Unless `mainConversation`
* is set, it then opens the main bot's own thread, where most specs start.
* Walks the first-run wizard, which lands in the main conversation with the main bot's first
* onboarding question pending (the composer works regardless). Unless `mainConversation` is
* set, it then opens the main bot's own thread, where most specs start.
*/
export async function completeOnboarding(
page: Page,
@ -101,13 +102,12 @@ export async function completeOnboarding(
if (page.url().includes("/onboarding")) {
await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute(
"data-onboarding-step",
/^(emoji|preferences|language|voice|integrations|scan|bot)$/,
/^(emoji|language|voice|integrations|scan|bot)$/,
{ timeout: 30_000 },
);
}
const design = page.getByRole("heading", { name: /Design your Oo/i });
const preferences = page.getByRole("heading", { name: /Tell us about yourself/i });
const language = page.getByRole("heading", { name: /Choose your language/i });
const voice = page.getByRole("heading", { name: /Pick a voice/i });
const integrations = page.getByRole("heading", { name: "Server integrations", exact: true });
@ -119,8 +119,6 @@ export async function completeOnboarding(
await expect(entry.first()).toBeVisible({ timeout: 20_000 });
if (await design.isVisible()) {
await page.getByRole("button", { name: "Continue", exact: true }).click();
await expect(preferences).toBeVisible();
await page.getByRole("button", { name: "Continue", exact: true }).click();
await expect(language).toBeVisible();
await page.getByRole("button", { name: /Skip/i }).click();
@ -133,8 +131,15 @@ export async function completeOnboarding(
await page.getByRole("button", { name: "Skip", exact: true }).click();
}
// Scan step auto-advances to the main bot, then onboarding lands in the main conversation.
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
// The scan step, when shown, either asks first (skip it) or advances by itself. Onboarding
// then lands in the main conversation.
const skipScan = page
.locator('[data-onboarding-step="scan"]')
.getByRole("button", { name: "Skip", exact: true });
await expect(async () => {
if (await skipScan.isVisible()) await skipScan.click();
expect(page.url()).toMatch(/\/app\/g\/[^/]+$/);
}).toPass({ timeout: 30_000 });
await expect(chief).toBeVisible({ timeout: 15_000 });
await waitForChatComposer(page);
@ -242,6 +247,7 @@ export async function openUserSettings(
| "memory"
| "profile"
| "voice"
| "notifications"
| "usage"
| "computer"
| "updates",

View File

@ -1,20 +1,136 @@
import type { Page } from "@playwright/test";
import { expect, test } from "@playwright/test";
import { captureScreenshot, completeOnboarding, signup } from "./helpers";
import type { CoachRhythm, CoachToday, Goal, Habit, Routine } from "@rakazo/contracts";
import { captureScreenshot, completeOnboarding, openMainBotThread, rpc, signup } from "./helpers";
/** The nav button reads "Today" or "Today, N need you". */
function openHeute(page: Page) {
return page.getByRole("button", { name: /^Today(,|$)/ }).click();
}
function shiftDate(date: string, days: number) {
return new Date(Date.parse(`${date}T00:00:00Z`) + days * 86_400_000).toISOString().slice(0, 10);
}
test("Heute shows native brief instead of Mission Control iframe", async ({ page }, testInfo) => {
const stamp = Date.now();
await signup(page, `heute-${stamp}@rakazo.test`, "password12", "Heute");
await completeOnboarding(page);
await page.getByRole("button", { name: "Today", exact: true }).click();
await openHeute(page);
await expect(page).toHaveURL(/\/app\/[^/]+$/);
const brief = page.getByTestId("heute-brief");
await expect(brief).toBeVisible();
await expect(page.locator('iframe[src="http://127.0.0.1:3088/"]')).toHaveCount(0);
await expect(page.getByText("evOo Mission Control")).toHaveCount(0);
await expect(page.getByTestId("heute-mission-control-link")).toHaveCount(0);
await expect(page.getByTestId("heute-status")).toBeVisible();
await expect(page.getByTestId("heute-mission-control-link")).toBeVisible();
// Nothing tracked yet: no coach sections.
await expect(page.getByTestId("heute-coach-brief")).toHaveCount(0);
await expect(page.getByTestId("heute-habits")).toHaveCount(0);
await captureScreenshot(page, testInfo, "heute-native-brief");
});
test("Heute shows the coach's brief, habits and goals, checks habits off and sets check-ins", async ({
page,
}, testInfo) => {
const stamp = Date.now();
await signup(page, `heute-coach-${stamp}@rakazo.test`, "password12", "Heute Coach");
await completeOnboarding(page);
// The main bot writes today's brief through its tool.
const composer = page.getByRole("combobox", { name: "Message" });
await composer.fill("write today's brief: Two calm hours on the beta, then a walk.");
await page.keyboard.press("Enter");
await expect(page.getByText("today's brief is ready.").first()).toBeVisible({ timeout: 30_000 });
const { date } = await rpc<CoachToday>(page, "coach/today", {});
const goal = await rpc<Goal>(page, "coach/goals/create", {
title: "Ship the beta",
targetDate: shiftDate(date, 30),
});
const habit = await rpc<Habit>(page, "coach/habits/create", { title: "Walk 20 minutes" });
for (const day of [1, 2]) {
await rpc(page, "coach/checkIns/log", {
kind: "habit",
habitId: habit.id,
date: shiftDate(date, -day),
done: true,
});
}
await openHeute(page);
await expect(page.getByTestId("heute-coach-brief")).toHaveText(
"Two calm hours on the beta, then a walk.",
);
await expect(page.getByTestId(`heute-goal-${goal.id}`)).toContainText("Ship the beta");
const habitRow = page.getByTestId(`heute-habit-${habit.id}`);
await expect(habitRow).toContainText("Walk 20 minutes");
await expect(page.getByTestId(`heute-habit-streak-${habit.id}`)).toHaveText("2 days");
const checkbox = habitRow.getByRole("checkbox");
await expect(checkbox).not.toBeChecked();
const logged = page.waitForResponse(
(response) => response.url().includes("/rpc/coach/checkIns/log") && response.ok(),
);
await checkbox.click();
await logged;
await expect(checkbox).toBeChecked();
await expect(page.getByTestId(`heute-habit-streak-${habit.id}`)).toHaveText("3 days");
await captureScreenshot(page, testInfo, "heute-coach");
await page.reload();
await openHeute(page);
await expect(page.getByTestId(`heute-habit-${habit.id}`).getByRole("checkbox")).toBeChecked();
// Check-in times live behind one compact control.
await page.getByTestId("heute-rhythm").click();
const panel = page.getByTestId("heute-rhythm-panel");
await expect(panel).toBeVisible();
const morning = panel.getByTestId("heute-rhythm-morning");
await expect(morning.getByRole("switch")).not.toBeChecked();
const enabled = page.waitForResponse(
(response) => response.url().includes("/rpc/coach/rhythm/set") && response.ok(),
);
await morning.getByRole("switch").click();
await enabled;
const time = morning.locator('input[type="time"]');
await expect(time).toHaveValue("08:00");
const moved = page.waitForResponse(
(response) => response.url().includes("/rpc/coach/rhythm/set") && response.ok(),
);
await time.fill("07:30");
await moved;
await expect(time).toHaveValue("07:30");
const weekly = panel.getByTestId("heute-rhythm-weekly");
const weeklyOn = page.waitForResponse(
(response) => response.url().includes("/rpc/coach/rhythm/set") && response.ok(),
);
await weekly.getByRole("switch").click();
await weeklyOn;
await expect(weekly.getByRole("combobox")).toHaveValue("0");
await captureScreenshot(page, testInfo, "heute-rhythm");
expect(await rpc<CoachRhythm>(page, "coach/rhythm/get", {})).toEqual({
morning: "07:30",
evening: null,
weekly: { day: 0, time: "18:00" },
});
const main = await rpc<{ id: string }>(page, "bots/ensureMain", {});
const routines = await rpc<Routine[]>(page, "routines/list", { botId: main.id });
expect(routines.map(({ name, coachKey }) => ({ name, coachKey }))).toEqual(
expect.arrayContaining([
{ name: "Morning brief", coachKey: "morning" },
{ name: "Weekly review", coachKey: "weekly" },
]),
);
expect(routines).toHaveLength(2);
// The main bot's routine list names the check-ins by their key, in the UI language.
await openMainBotThread(page);
await page.getByTitle("Agent computer").click();
await expect(page.getByText("Morning brief", { exact: true })).toBeVisible();
await expect(page.getByText("Weekly review", { exact: true })).toBeVisible();
});

View File

@ -39,6 +39,75 @@ test("answers a specialist's question from the main conversation", async ({ page
await expect(page.getByText("Answered: Paris", { exact: true })).toBeVisible({ timeout: 30_000 });
});
test("answers a question from another space in the main conversation", async ({
page,
}, testInfo) => {
const stamp = Date.now();
await signup(page, `inbox-cross-${stamp}@rakazo.test`, "password12", "Inbox Cross");
await page.waitForURL(/\/(onboarding|app)/, { timeout: 30_000 });
const helper = await createBot(page, "Helper");
const side = await rpc<{ id: string }>(page, "spaces/create", { name: "Side project" });
const scout = await rpcInSpace<{ id: string }>(page, side.id, "bots/create", {
name: "Scout",
title: "",
description: "",
notifyOnFinish: true,
computerMode: "team",
});
await rpcInSpace(page, side.id, "threads/send", {
botId: scout.id,
text: "ask me which city to use",
});
const runInSide = async () =>
(
await rpcInSpace<{ run?: { status: string } | null }>(page, side.id, "threads/get", {
botId: scout.id,
})
).run ?? null;
await expect
.poll(async () => (await runInSide())?.status ?? null, { timeout: 60_000 })
.toBe("waiting_input");
await openMainConversation(page, helper.id);
const inbox = page.getByRole("region", { name: "Needs you" });
const ask = inbox.locator("[data-kind='ask']");
await expect(ask).toContainText("Scout", { timeout: 30_000 });
await expect(ask.getByTestId("main-inbox-space")).toHaveText("· Side project");
await captureScreenshot(page, testInfo, "inbox-cross-space");
await ask.getByRole("button", { name: "Edit first" }).click();
await ask.getByRole("textbox", { name: "Answer" }).fill("Paris");
await ask.getByRole("button", { name: "Send answer" }).click();
await expect(ask).toHaveCount(0);
await expect.poll(runInSide, { timeout: 60_000 }).toBeNull();
// The reply arrives as unread from the other space and opens there.
const reply = inbox.locator("[data-kind='unread']").filter({ hasText: "Scout" });
await expect(reply).toContainText("Paris", { timeout: 45_000 });
await expect(reply.getByTestId("main-inbox-space")).toHaveText("· Side project");
await reply.getByRole("button").click();
await page.waitForURL(new RegExp(`/app/${scout.id}$`));
await expect(page.getByText("Answered: Paris", { exact: true })).toBeVisible({ timeout: 30_000 });
expect(await page.evaluate(() => window.localStorage.getItem("rakazo:space-id"))).toBe(side.id);
});
async function rpcInSpace<T = unknown>(
page: Page,
spaceId: string,
procedure: string,
body: unknown,
): Promise<T> {
const response = await page.request.post(`/rpc/${procedure}`, {
data: { json: body },
headers: { "x-rakazo-space-id": spaceId },
});
const parsed = (await response.json()) as { json?: T; error?: { message?: string } };
if (!response.ok() || parsed.error) {
throw new Error(`${procedure} ${response.status()}: ${parsed.error?.message ?? "failed"}`);
}
return parsed.json as T;
}
async function createBot(page: Page, name: string) {
return rpc<{ id: string }>(page, "bots/create", {
name,

View File

@ -114,6 +114,36 @@ test("memory and skills are readable and editable in the app", async ({ page },
await expect(botDocEditor).toHaveValue(new RegExp(botMarker));
await botMemory.getByRole("button", { name: "Cancel", exact: true }).click();
// Remembering appends a dated note and keeps the edit above.
await rpc(page, "threads/send", {
botId: activeBotId(page),
text: `remember that the tester ${stamp} prefers tea`,
});
await expect
.poll(
async () =>
(
await rpc<MemoryDocument[]>(page, "memory/list", {
botId: activeBotId(page),
scope: "bot",
})
).find((doc) => doc.path === "MEMORY.md")?.content,
{ timeout: 60_000 },
)
.toMatch(
new RegExp(
`^# Chief\\n\\n${botMarker}\\n\\n- \\d{4}-\\d{2}-\\d{2}: .*tester ${stamp} prefers tea\\n$`,
),
);
await expect
.poll(
async () =>
(await rpc<{ run?: unknown }>(page, "threads/get", { botId: activeBotId(page) })).run ??
null,
{ timeout: 60_000 },
)
.toBeNull();
// Skills: create one through the editor, reopen it, edit, then delete it.
// Builtin catalog is currently empty; user skills still cover create/edit/delete.
await knowledge.getByRole("tab", { name: "Skills", exact: true }).click();

View File

@ -4,7 +4,8 @@ import { captureScreenshot, completeOnboarding, signup, waitForChatComposer } fr
/** Submit from the Send control so an in-flight prior send cannot swallow Enter. */
async function sendComposerMessage(page: Page, composer: Locator, text: string) {
await composer.fill(text);
const send = page.getByRole("button", { name: "Send", exact: true });
// An open onboarding card in the transcript has its own Send.
const send = page.getByTestId("composer-bar").getByRole("button", { name: "Send", exact: true });
await expect(send).toBeEnabled();
await send.click();
await expect(composer).toHaveValue("");
@ -72,14 +73,25 @@ test("message hover shows beside-bubble actions; reply links to parent", async (
const transcript = page.getByTestId("transcript");
// Empty start: no Fresh-start greeting. Answer the focus card so a plain
// bot text bubble exists for hover layout checks.
await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible({
// Empty start: no Fresh-start greeting. Answer the focus card, then ask something long: a bot
// row's rail sits at the row's end, so the measured reply must wrap and fill its row.
await expect(
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
).toBeVisible({
timeout: 20_000,
});
await page.getByTestId("focus-choice-day").click();
const botText = page.getByText(/Got it\./);
await expect(botText).toBeVisible({ timeout: 20_000 });
await expect(page.locator('[data-testid="choice-card"][data-step="goal"]')).toBeVisible({
timeout: 20_000,
});
const composer = page.getByRole("combobox", { name: /^Message/ });
await sendComposerMessage(
page,
composer,
`hover-bot-${stamp} ${"please look into this ".repeat(7)}`,
);
const botText = page.getByText(/i handled: hover-bot-/);
await expect(transcript.getByText(/i handled: hover-bot-/)).toBeVisible({ timeout: 20_000 });
const botRow = transcript.locator(`[data-message-id]`).filter({ has: botText }).first();
await expect(botRow).toBeVisible();
await expectRailAtRest(page, botRow);
@ -121,7 +133,6 @@ test("message hover shows beside-bubble actions; reply links to parent", async (
const parentText = `hover-parent-${stamp}`;
const replyText = `hover-reply-${stamp}`;
const composer = page.getByRole("combobox", { name: /^Message/ });
await expect(composer).toBeVisible();
await sendComposerMessage(page, composer, parentText);
@ -387,12 +398,19 @@ test.describe("touch message actions", () => {
expect(
await page.evaluate(() => matchMedia("(hover: hover) and (pointer: fine)").matches),
).toBe(false);
await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible({
await expect(
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
).toBeVisible({
timeout: 20_000,
});
await page.getByTestId("focus-choice-day").click();
const botText = page.getByText(/Got it\./);
await expect(botText).toBeVisible({ timeout: 20_000 });
// Onboarding writes no text of its own, so ask for a plain reply to act on.
const composer = page.getByRole("combobox", { name: /^Message/ });
await sendComposerMessage(page, composer, `touch-bot-${Date.now()}`);
const botText = page.getByText(/i handled: touch-bot-/);
await expect(page.getByTestId("transcript").getByText(/i handled: touch-bot-/)).toBeVisible({
timeout: 20_000,
});
const row = page
.getByTestId("transcript")
.locator("[data-message-id]")

View File

@ -42,7 +42,9 @@ test("create opens form, then empty chat; picker lists bots; sidebar collapses",
await page.waitForURL(/\/app\/[^/]+$/);
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "closed");
await expect(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0);
await expect(
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
).toHaveCount(0);
await expect(
page.getByTestId("transcript").getByText("Start the conversation — New Bot is on stage above."),
).toBeVisible();
@ -117,20 +119,30 @@ test("later bot waits before showing the focus card; sending cancels it", async
await signup(page, `focus-delay-${stamp}@rakazo.test`, "password12", "Focus Delay");
await completeOnboarding(page, undefined, { mainConversation: true });
// The main bot shows the focus card immediately in the main conversation.
await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible();
await expect(
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
).toBeVisible();
await page.clock.install();
await createBotFromPicker(page);
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
await expect(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0);
await expect(
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
).toHaveCount(0);
await page.clock.fastForward(9_000);
await expect(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0);
await expect(
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
).toHaveCount(0);
await page.clock.fastForward(1_500);
await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible();
await expect(
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
).toBeVisible();
await createBotFromPicker(page, { name: "Later Bot" });
await expect(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0);
await expect(
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
).toHaveCount(0);
const composer = page.getByPlaceholder(/Message/);
await composer.fill("I'll set this up myself");
// Send must finish before the delay is advanced: cancel runs after a successful
@ -144,7 +156,9 @@ test("later bot waits before showing the focus card; sending cancels it", async
page.getByTestId("transcript").getByText("I'll set this up myself").first(),
).toBeVisible();
await page.clock.fastForward(12_000);
await expect(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0);
await expect(
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
).toHaveCount(0);
});
test("plus picker can create a Private computer bot", async ({ page }, testInfo) => {

View File

@ -0,0 +1,130 @@
/**
* E2E: Settings → Notifications turns push on for this browser (the Push API is stubbed: headless
* Chromium has no push service), sets quiet hours and switches nudges off; all of it survives a
* reload.
*/
import type { Page } from "@playwright/test";
import { expect, test } from "@playwright/test";
import { captureScreenshot, completeOnboarding, openUserSettings, rpc, signup } from "./helpers";
const ENDPOINT = "https://push.example.test/e2e-device";
/**
* A fake Notification permission and service worker registration, kept in sessionStorage so they
* survive a reload. Headless Chromium reports notifications as denied even when granted.
*/
async function stubPushApi(page: Page, endpoint: string) {
await page.addInitScript((pushEndpoint) => {
const permissionKey = "e2e:notification-permission";
const notification = Object.defineProperties(function Notification() {}, {
permission: { get: () => sessionStorage.getItem(permissionKey) ?? "default" },
requestPermission: {
value: async () => {
sessionStorage.setItem(permissionKey, "granted");
return "granted";
},
},
});
Object.defineProperty(window, "Notification", {
configurable: true,
writable: true,
value: notification,
});
const storageKey = "e2e:push-subscription";
const current = () => {
const stored = sessionStorage.getItem(storageKey);
if (!stored) return null;
const saved = JSON.parse(stored) as { serverKey: number[] };
return {
endpoint: pushEndpoint,
options: { applicationServerKey: new Uint8Array(saved.serverKey).buffer },
toJSON: () => ({
endpoint: pushEndpoint,
keys: { p256dh: `B${"A".repeat(86)}`, auth: "A".repeat(22) },
}),
unsubscribe: async () => {
sessionStorage.removeItem(storageKey);
return true;
},
};
};
const registration = {
scope: `${location.origin}/`,
pushManager: {
getSubscription: async () => current(),
subscribe: async (options: { applicationServerKey: Uint8Array }) => {
sessionStorage.setItem(
storageKey,
JSON.stringify({ serverKey: [...new Uint8Array(options.applicationServerKey)] }),
);
return current();
},
},
};
Object.defineProperty(navigator, "serviceWorker", {
configurable: true,
value: {
register: async () => registration,
getRegistration: async () => registration,
ready: Promise.resolve(registration),
},
});
if (!("PushManager" in window)) {
Object.defineProperty(window, "PushManager", { configurable: true, value: class {} });
}
}, endpoint);
}
test("notification settings: push on this device, quiet hours and nudges", async ({
page,
}, testInfo) => {
await stubPushApi(page, ENDPOINT);
await signup(page, `notifications-${Date.now()}@rakazo.test`, "password12", "Push Tester");
await completeOnboarding(page);
let settings = await openUserSettings(page, "notifications");
const panel = settings.getByTestId("notification-settings");
const push = panel.getByTestId("notification-push-switch");
await expect(push).not.toBeChecked();
const subscribed = page.waitForRequest("**/rpc/notifications/webPush/subscribe");
await push.click();
expect((await subscribed).postDataJSON()).toMatchObject({ json: { endpoint: ENDPOINT } });
await expect(push).toBeChecked();
const quiet = panel.getByTestId("notification-quiet-hours-switch");
await expect(quiet).not.toBeChecked();
await quiet.click();
await expect(quiet).toBeChecked();
const start = panel.getByTestId("notification-quiet-hours-start");
await expect(start).toHaveValue("22:00");
await expect(panel.getByTestId("notification-quiet-hours-end")).toHaveValue("07:00");
const saved = page.waitForResponse(
(response) =>
response.url().includes("/rpc/notifications/settings/update") &&
response.request().postData()?.includes("23:30") === true,
);
await start.fill("23:30");
await saved;
const nudges = panel.getByTestId("notification-nudges-switch");
await expect(nudges).toBeChecked();
await nudges.click();
await expect(nudges).not.toBeChecked();
await expect
.poll(() => rpc<{ nudges: boolean }>(page, "notifications/settings/get", null))
.toMatchObject({ nudges: false, quietHours: { start: "23:30", end: "07:00" } });
await captureScreenshot(page, testInfo, "notification-settings");
await page.reload();
settings = await openUserSettings(page, "notifications");
await expect(settings.getByTestId("notification-push-switch")).toBeChecked();
await expect(settings.getByTestId("notification-quiet-hours-start")).toHaveValue("23:30");
await expect(settings.getByTestId("notification-nudges-switch")).not.toBeChecked();
const unsubscribed = page.waitForRequest("**/rpc/notifications/webPush/unsubscribe");
await settings.getByTestId("notification-push-switch").click();
expect((await unsubscribed).postDataJSON()).toMatchObject({ json: { endpoint: ENDPOINT } });
await expect(settings.getByTestId("notification-push-switch")).not.toBeChecked();
});

View File

@ -16,11 +16,16 @@ test("focus choice suggests apps and preserves a completed connection", async ({
await expect(
page.getByText("Hey Robin. Fresh start on my side, so I’ll keep this short."),
).toHaveCount(0);
await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible();
await expect(
page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }),
).toBeVisible();
await expect(page.getByTestId("focus-choice-day")).toBeVisible();
await expect(page.getByTestId("focus-choice-day")).toContainText("Day-to-day work");
await expect(page.getByTestId("focus-choice-research")).toContainText("Research & writing");
await expect(page.getByTestId("focus-choice-crew")).toContainText("Specialist crew");
// Onboarding questions offer a quiet way out instead of a dismiss button.
await expect(page.getByTestId("focus-choice-skip")).toBeVisible();
await expect(page.getByRole("button", { name: "Dismiss" })).toHaveCount(0);
await page.mouse.move(1, 1);
await captureScreenshot(page, testInfo, "01-focus-choice");
await captureScreenshot(page, testInfo, "choice-card-onboarding");
@ -33,6 +38,8 @@ test("focus choice suggests apps and preserves a completed connection", async ({
).toBeVisible();
expect((await rpc<{ name: string }>(page, "bots/ensureMain", {})).name).toBe("Chief");
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
// The app cards speak for themselves; onboarding adds no text around them.
await expect(page.getByText(/^Got it/)).toHaveCount(0);
await expect(page.getByText("Slack", { exact: true })).toBeVisible();
await expect(page.getByText("Gmail", { exact: true })).toBeVisible();
const connectionCards = page.getByRole("group", { name: / connection$/ });
@ -45,10 +52,10 @@ test("focus choice suggests apps and preserves a completed connection", async ({
);
expect(cardBoxes[1].top - cardBoxes[0].bottom).toBeGreaterThanOrEqual(8);
expect(cardBoxes[2].top - cardBoxes[1].bottom).toBeGreaterThanOrEqual(8);
await page
.getByTestId("transcript")
.getByText("Hit those three and I’ll start pulling the picture.")
.scrollIntoViewIfNeeded();
// The next onboarding question follows the suggestions.
const goal = page.locator('[data-testid="choice-card"][data-step="goal"]');
await expect(goal).toContainText("What’s one thing you want to get done in the next few weeks?");
await slackCard(page).scrollIntoViewIfNeeded();
await page.mouse.move(1, 1);
await captureScreenshot(page, testInfo, "02-app-suggestions");
const authorizeButton = slackCard(page).getByRole("button", { name: "Authorize" });

View File

@ -0,0 +1,77 @@
/**
* E2E: the deployment owner's onboarding asks before scanning the fixture home folder the
* harness configures, then adds only the projects and facts the user picks.
*/
import type { Page } from "@playwright/test";
import { expect, test } from "@playwright/test";
import type { SpaceNavigation, UserProfileState } from "@rakazo/contracts";
import { captureScreenshot, rpc, signup } from "./helpers";
const api = process.env.API_URL ?? "http://127.0.0.1:3110";
/** Advances whichever wizard step is showing until the scan step is reached. */
async function walkToScanStep(page: Page) {
const surface = page.locator('[data-rakazo-surface="onboarding"]');
await expect(async () => {
if ((await surface.getAttribute("data-onboarding-step")) === "scan") return;
const skip = page.getByRole("button", { name: /^Skip/ }).first();
const next = (await skip.isVisible())
? skip
: page.getByRole("button", { name: "Continue", exact: true });
await next.click({ timeout: 2_000 });
await expect(surface).toHaveAttribute("data-onboarding-step", "scan", { timeout: 1_000 });
}).toPass({ timeout: 60_000 });
}
test("asks before looking for projects and adds only what the user picks", async ({
page,
}, testInfo) => {
const email = `discovery-${Date.now()}@rakazo.test`;
await signup(page, email, "password12", "Discovery");
await page.waitForURL(/\/onboarding/, { timeout: 20_000 });
// The harness starts without a deployment owner; this spec takes the seat for its run.
const owner = await page.request.post(`${api}/__e2e/deployment-owner`, { data: { email } });
expect(owner.ok()).toBe(true);
const { previousOwnerUserId } = (await owner.json()) as { previousOwnerUserId: string | null };
try {
await page.goto("/onboarding", { waitUntil: "domcontentloaded" });
await walkToScanStep(page);
await expect(page.getByRole("heading", { name: "Look for projects here?" })).toBeVisible();
await expect(page.getByText(/rakazo-e2e-scan-home-/)).toBeVisible();
await captureScreenshot(page, testInfo, "onboarding-discovery-consent");
await page.getByRole("button", { name: "Look", exact: true }).click();
const projects = page.getByRole("group", { name: "Add as projects" });
await expect(projects.getByRole("checkbox")).toHaveCount(2);
const atlas = projects.getByRole("checkbox", { name: /atlas/ });
await expect(atlas).not.toBeChecked();
await expect(projects.getByRole("checkbox", { name: /garden/ })).not.toBeChecked();
await atlas.check();
const facts = page.getByRole("group", { name: "Add to About you" });
await expect(facts.getByRole("checkbox", { name: "TypeScript" })).toBeChecked();
await facts.getByRole("checkbox", { name: "Python" }).uncheck();
await captureScreenshot(page, testInfo, "onboarding-discovery-results");
await page.getByRole("button", { name: "Add", exact: true }).click();
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
const navigation = await rpc<SpaceNavigation>(page, "spaces/list", {});
const names = navigation.spaces.map((space) => space.name);
expect(names).toContain("atlas");
expect(names).not.toContain("garden");
expect(navigation.spaces.find((space) => space.name === "atlas")?.workspace?.path).toMatch(
/code\/atlas$/,
);
const values = (await rpc<UserProfileState>(page, "profile/get", {})).facts.map(
(fact) => fact.value,
);
expect(values).toEqual(
expect.arrayContaining(["Codes in TypeScript", "Uses Docker", "Uses GitHub Actions"]),
);
expect(values).not.toContain("Codes in Python");
} finally {
await page.request.post(`${api}/__e2e/deployment-owner`, {
data: { userId: previousOwnerUserId },
});
}
});

View File

@ -17,12 +17,11 @@ test.beforeEach(async ({ page }) => {
});
/**
* E2E test for the new emoji-first onboarding flow:
* E2E test for the emoji-first onboarding flow:
* 1. Sign up → lands on onboarding
* 2. Design Oo: pick shape + color → live preview
* 3. Tell us about yourself: name, timezone, hobbies, work style, communication
* 4. Choose language
* 5. Skip integrations → bot created → land in chat
* 3. Choose language, skip the voice
* 4. Skip integrations → bot created → land in chat, where the main bot asks the rest
*/
test("emoji-first onboarding: shape + color selection with live preview", async ({
@ -50,50 +49,13 @@ test("emoji-first onboarding: shape + color selection with live preview", async
if (testInfo) await captureScreenshot(page, testInfo, "02-shape-color-preview");
// Click Continue → goes to preferences step
// Click Continue → goes to the language step
await page
.getByRole("button", { name: /Continue/i })
.first()
.click();
// Step 2: Tell us about yourself
await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible();
if (testInfo) await captureScreenshot(page, testInfo, "03-user-preferences");
// Fill in name
await page.getByPlaceholder(/Your name/i).fill("Alice");
// Fill in timezone
await page.getByLabel(/Timezone/i).fill("Europe/Vienna");
// Select a hobby
const hobbyButtons = page.locator("button[aria-pressed]");
if (await hobbyButtons.count()) {
await hobbyButtons.first().click(); // toggle first hobby
}
// Select work style
const workStyleButtons = page.locator("button[aria-pressed]:visible");
if ((await workStyleButtons.count()) >= 2) {
await workStyleButtons.nth(0).click();
}
// Select communication style
const commButtons = page.locator("button[aria-pressed]:visible");
if ((await commButtons.count()) >= 2) {
await commButtons.nth(0).click();
}
if (testInfo) await captureScreenshot(page, testInfo, "04-preferences-filled");
// Click Continue → goes to language step
await page
.getByRole("button", { name: /Continue/i })
.first()
.click();
// Step 3: Language selection
// Step 2: Language selection
await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();
if (testInfo) await captureScreenshot(page, testInfo, "05-language-step");
@ -135,13 +97,6 @@ test("onboarding: skip language defaults to English", async ({ page }) => {
.first()
.click();
// Preferences step — just continue
await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible();
await page
.getByRole("button", { name: /Continue/i })
.first()
.click();
// Language step — click Skip, then skip voice.
await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();
await page.getByRole("button", { name: /Skip/i }).click();

View File

@ -0,0 +1,91 @@
/**
* E2E: after the wizard the main bot gets to know the user in the main conversation, one card
* after each answer, without ever blocking the composer.
*/
import type { Page } from "@playwright/test";
import { expect, test } from "@playwright/test";
import { captureScreenshot, completeOnboarding, rpc, signup } from "./helpers";
function card(page: Page, step: string) {
return page.locator(`[data-testid="choice-card"][data-step="${step}"]`);
}
test("the main bot asks focus, goal, check-ins and quiet hours, then says what is next", async ({
page,
}, testInfo) => {
await signup(page, `intake-${Date.now()}@rakazo.test`, "password12", "Intake Tester");
await completeOnboarding(page, undefined, { mainConversation: true });
await page.getByTestId("focus-choice-day").click();
const goal = card(page, "goal");
await expect(goal).toContainText("What’s one thing you want to get done in the next few weeks?");
// Typing instead of answering goes to the main bot, and the card stays answerable.
const composer = page.getByRole("combobox", { name: "Message" });
await composer.fill("Quick question first");
await composer.press("Enter");
await expect(page.getByTestId("transcript")).toContainText(/on it|handled/i, {
timeout: 60_000,
});
const goalInput = goal.getByRole("textbox", {
name: "What’s one thing you want to get done in the next few weeks?",
});
await goalInput.fill("Run a 10K");
await goal.scrollIntoViewIfNeeded();
await page.mouse.move(1, 1);
await captureScreenshot(page, testInfo, "onboarding-intake-goal");
await goalInput.press("Enter");
await expect(goal.getByTestId("choice-answer")).toHaveText("Run a 10K");
const rhythm = card(page, "rhythm");
await expect(rhythm).toContainText("When should I check in with you?");
// The sidebar previews the main conversation as the web words it: the pending question.
const mainEntry = page.locator("aside").first().locator("[data-main-conversation]");
await expect(mainEntry).toContainText("When should I check in with you?");
await rhythm.scrollIntoViewIfNeeded();
await page.mouse.move(1, 1);
await captureScreenshot(page, testInfo, "onboarding-intake-rhythm");
await rhythm.getByTestId("rhythm-choice-morning").click();
await expect(rhythm.getByTestId("choice-answer")).toContainText("Every morning");
const quiet = card(page, "quiet_hours");
await expect(quiet).toContainText("When should I stay quiet?");
await quiet.getByTestId("quiet_hours-choice-22-07").click();
// Only a browser that can still turn push on is asked about notifications.
// The web words the closing note itself, from the time of the first check-in.
const closing = page
.getByTestId("transcript")
.getByText(/^All set\. I’ll check in (today|tomorrow) at 8:00\sAM\.$/);
const notifications = card(page, "notifications");
await expect(notifications.or(closing).first()).toBeVisible();
if (await notifications.isVisible()) {
await notifications.getByTestId("notifications-choice-later").click();
}
await expect(closing).toBeVisible();
await expect(mainEntry).toContainText(/All set\. I’ll check in (today|tomorrow) at 8:00\sAM\./);
await expect(
page.locator('[data-testid="choice-card"]:not(:has([data-testid="choice-answer"]))'),
).toHaveCount(0);
await closing.scrollIntoViewIfNeeded();
await page.mouse.move(1, 1);
await captureScreenshot(page, testInfo, "onboarding-intake-done");
const settings = await rpc<{ quietHours: { start: string; end: string } | null }>(
page,
"notifications/settings/get",
null,
);
expect(settings.quietHours).toEqual({ start: "22:00", end: "07:00" });
const profile = await rpc<{ facts: Array<{ value: string; source: string }> }>(
page,
"profile/get",
null,
);
expect(profile.facts).toContainEqual(
expect.objectContaining({
value: "Wants help with day-to-day work first",
source: "onboarding",
}),
);
});

View File

@ -37,7 +37,7 @@ test("onboarding shows the look picker when a model is still required", async ({
await page.waitForURL(/\/onboarding/, { timeout: 20_000 });
await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute(
"data-onboarding-step",
/^(emoji|preferences|language|voice|integrations)$/,
/^(emoji|language|voice|integrations)$/,
{ timeout: 30_000 },
);
await expect(page.getByRole("heading", { name: /Design your Oo/i })).toBeVisible({
@ -56,7 +56,7 @@ for (const unavailable of ["empty", "failed"] as const) {
await page.waitForURL(/\/onboarding/, { timeout: 20_000 });
await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute(
"data-onboarding-step",
/^(emoji|preferences|language|voice|integrations)$/,
/^(emoji|language|voice|integrations)$/,
{ timeout: 30_000 },
);
await expect(page.getByRole("heading", { name: /Design your Oo/i })).toBeVisible({

View File

@ -77,5 +77,5 @@ test("mobile onboarding: category pick and swipe look previews", async ({ page }
.getByRole("button", { name: /Continue/i })
.first()
.click();
await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible();
await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();
});

View File

@ -0,0 +1,74 @@
/**
* E2E: Settings → About you edits a fact in place: its wording, category and the spaces it
* shows in.
*/
import { expect, test } from "@playwright/test";
import type { UserProfileState } from "@rakazo/contracts";
import { captureScreenshot, completeOnboarding, openUserSettings, rpc, signup } from "./helpers";
test("a fact's wording, category and spaces are edited in place", async ({ page }, testInfo) => {
const stamp = Date.now();
await signup(page, `profile-edit-${stamp}@rakazo.test`, "password12", "Edit Tester");
await completeOnboarding(page);
const side = await rpc<{ id: string }>(page, "spaces/create", { name: "Side project" });
await rpc(page, "profile/update", {
addFacts: [{ category: "preferences", value: "Prefers tea" }],
});
const settings = await openUserSettings(page, "profile");
const profile = settings.getByTestId("user-profile-settings");
const tea = profile.getByTestId("user-fact").filter({ hasText: "Prefers tea" });
await tea.getByRole("button", { name: "Edit" }).click();
const editor = profile.getByTestId("user-fact-editor");
const wording = editor.getByRole("textbox", { name: "Fact" });
await expect(wording).toBeFocused();
await wording.fill("Prefers green tea");
await editor.getByRole("combobox", { name: "Category" }).selectOption("habits");
await editor.getByRole("button", { name: "All spaces" }).click();
await page.getByRole("menuitemcheckbox", { name: "Side project" }).click();
await expect(page.getByRole("menuitemcheckbox", { name: "All spaces" })).not.toBeChecked();
await page.keyboard.press("Escape");
await expect(editor.getByRole("button", { name: "Side project" })).toBeVisible();
await captureScreenshot(page, testInfo, "user-profile-edit");
await editor.getByRole("button", { name: "Save", exact: true }).click();
await expect(editor).toHaveCount(0);
const edited = profile
.getByTestId("user-facts-habits")
.getByTestId("user-fact")
.filter({ hasText: "Prefers green tea" });
await expect(edited).toContainText("Side project");
await expect(tea).toHaveCount(0);
const teaFact = async () =>
(await rpc<UserProfileState>(page, "profile/get", null)).facts.find((fact) =>
fact.value.includes("tea"),
);
expect(await teaFact()).toMatchObject({
value: "Prefers green tea",
category: "habits",
allSpaces: false,
spaceIds: [side.id],
});
// Escape leaves the fact as it was; showing it everywhere again clears the limit.
await edited.getByRole("button", { name: "Edit" }).click();
await editor.getByRole("textbox", { name: "Fact" }).fill("Discarded");
await page.keyboard.press("Escape");
await expect(editor).toHaveCount(0);
await expect(edited).toContainText("Prefers green tea");
await expect(settings).toBeVisible();
await edited.getByRole("button", { name: "Edit" }).click();
await editor.getByRole("button", { name: "Side project" }).click();
await page.getByRole("menuitemcheckbox", { name: "All spaces" }).click();
await page.keyboard.press("Escape");
await editor.getByRole("button", { name: "Save", exact: true }).click();
await expect(editor).toHaveCount(0);
await expect(edited).not.toContainText("Side project");
expect(await teaFact()).toMatchObject({
value: "Prefers green tea",
allSpaces: true,
spaceIds: [],
});
});

View File

@ -1,6 +1,7 @@
/**
* E2E: onboarding answers land in the user profile, and Settings → About you shows every
* fact with its origin, confirms a pending one, and forgets one or all of them.
* E2E: onboarding fills the user profile without asking (time zone, locale and language from
* the browser) and records the focus answer as a fact; Settings → About you shows every fact
* with its origin, confirms a pending one, and forgets one or all of them.
*/
import { expect, test } from "@playwright/test";
import { captureScreenshot, openUserSettings, rpc, signup, waitForChatComposer } from "./helpers";
@ -8,7 +9,7 @@ import { captureScreenshot, openUserSettings, rpc, signup, waitForChatComposer }
test.use({ timezoneId: "Europe/Vienna" });
test.beforeEach(async ({ page }) => {
// Walk the full emoji-first flow, including the preferences step.
// Walk the full emoji-first flow.
await page.route("**/rpc/integrationSetup/get", (route) =>
route.fulfill({
json: {
@ -34,27 +35,23 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async
});
await page.getByRole("button", { name: "Continue", exact: true }).click();
await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible();
await expect(page.getByLabel("Timezone")).toHaveValue("Europe/Vienna");
await page.getByPlaceholder("Your name").fill("Pat Example");
await page.getByRole("button", { name: "Music", exact: true }).click();
await page.getByRole("button", { name: "Deep-dive focused" }).click();
await page.getByRole("button", { name: "Direct & concise" }).click();
await page.getByRole("button", { name: "Continue", exact: true }).click();
await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible();
await page.getByRole("button", { name: "English", exact: true }).click();
await expect(page.getByRole("heading", { name: /Pick a voice/i })).toBeVisible();
await page.getByRole("button", { name: "Skip", exact: true }).click();
await page.waitForURL(/\/app\/[^/]+/, { timeout: 30_000 });
await page.waitForURL(/\/app\/g\/[^/]+/, { timeout: 30_000 });
await waitForChatComposer(page);
// The main bot asks the rest in the main conversation; the focus answer becomes a fact.
await page.getByTestId("focus-choice-day").click();
await expect(page.locator('[data-testid="choice-card"][data-step="goal"]')).toBeVisible();
// The answers no longer overwrite a bot's instructions.
const bots = await rpc<Array<{ instructions: string }>>(page, "bots/list", null);
expect(bots.length).toBeGreaterThan(0);
for (const bot of bots) expect(bot.instructions).not.toContain("User Context");
expect((await rpc<{ name: string }>(page, "me", null)).name).toBe("Pat Example");
// The account name is used as is; onboarding no longer asks for it again.
expect((await rpc<{ name: string }>(page, "me", null)).name).toBe("Profile Tester");
// Sensitive facts from anywhere but the user wait for confirmation.
await rpc(page, "profile/update", {
@ -67,14 +64,13 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async
await expect(profile.getByTestId("profile-field-timeZone")).toContainText("Europe/Vienna");
await expect(profile.getByTestId("profile-field-language")).toContainText("English");
await expect(profile.getByTestId("profile-field-locale")).toContainText("United States");
await expect(profile.getByTestId("profile-field-communicationStyle")).toContainText(
"Direct and concise",
);
const music = profile.getByTestId("user-fact").filter({ hasText: "Interested in music" });
await expect(music).toContainText("Onboarding");
await expect(profile.getByTestId("user-facts-habits")).toContainText(
"Prefers focused deep dives",
const focus = profile
.getByTestId("user-fact")
.filter({ hasText: "Wants help with day-to-day work first" });
await expect(focus).toContainText("Onboarding");
await expect(profile.getByTestId("user-facts-goals")).toContainText(
"Wants help with day-to-day work first",
);
const allergy = profile.getByTestId("user-fact").filter({ hasText: "Allergic to peanuts" });
@ -84,8 +80,8 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async
await expect(allergy).toHaveAttribute("data-status", "active");
await expect(allergy.getByRole("button", { name: "Confirm" })).toHaveCount(0);
await music.getByRole("button", { name: "Delete" }).click();
await expect(music).toHaveCount(0);
await focus.getByRole("button", { name: "Delete" }).click();
await expect(focus).toHaveCount(0);
await captureScreenshot(page, testInfo, "user-profile-settings");
const afterDelete = await rpc<{ facts: Array<{ value: string; status: string }> }>(
page,
@ -93,7 +89,9 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async
null,
);
expect(afterDelete.facts).toEqual(
expect.not.arrayContaining([expect.objectContaining({ value: "Interested in music" })]),
expect.not.arrayContaining([
expect.objectContaining({ value: "Wants help with day-to-day work first" }),
]),
);
expect(afterDelete.facts).toContainEqual(
expect.objectContaining({ value: "Allergic to peanuts", status: "active" }),

View File

@ -9,6 +9,7 @@
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<!-- Fonts loaded locally via @fontsource-variable (Inter + Geist) — no external CDN -->
<link rel="stylesheet" href="/evoo-brand.css" />
<title>evOo</title>

View File

@ -1,6 +1,6 @@
{
"name": "EvOo",
"short_name": "EvOo",
"name": "evOo",
"short_name": "evOo",
"description": "Deine Oos — immer da, immer wach.",
"start_url": "/",
"display": "standalone",

61
apps/web/public/sw.js Normal file
View File

@ -0,0 +1,61 @@
// Shows the server's web push notifications and opens the conversation they belong to.
// No fetch handler: the app always loads from the network.
self.addEventListener("install", () => {
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(self.clients.claim());
});
function appPath(value) {
return typeof value === "string" && value.startsWith("/") && !value.startsWith("//")
? value
: "/app";
}
self.addEventListener("push", (event) => {
event.waitUntil(
(async () => {
let data = {};
try {
data = event.data ? event.data.json() : {};
} catch {
data = {};
}
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
// The app in front of the user already shows what happened.
if (windows.some((client) => client.focused && client.visibilityState === "visible")) return;
await self.registration.showNotification(
typeof data.title === "string" && data.title ? data.title : "evOo",
{
body: typeof data.body === "string" ? data.body : "",
// Same tag as the in-app notification, so one replaces the other.
tag: typeof data.tag === "string" && data.tag ? data.tag : undefined,
icon: "/icon-192.png",
data: { url: appPath(data.url) },
},
);
})(),
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
event.waitUntil(
(async () => {
const target = new URL(appPath(event.notification.data?.url), self.location.origin).href;
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
const open =
windows.find((client) => client.url === target) ??
windows.find((client) => new URL(client.url).origin === self.location.origin);
if (!open) {
await self.clients.openWindow(target);
return;
}
const focused = await open.focus();
if (focused.url !== target) await focused.navigate(target).catch(() => undefined);
})(),
);
});

View File

@ -79,6 +79,22 @@ const unread: InboxItem = {
text: "Draft ready for review",
createdAt,
};
const side = { id: "space-side", name: "Side project" };
const askInOtherSpace: InboxItem = {
kind: "ask",
bot: writer,
target: { botId: writer.id },
space: side,
runId: "run-side",
messageId: "msg-side",
ask: { kind: "ask", text: "Which title fits?", status: "pending" },
createdAt,
};
const groupAskInOtherSpace: InboxItem = {
...approvalInGroup,
space: side,
messageId: "msg-side-group",
};
const idleBots = [
{ id: scout.id, status: "idle", unread: false, preview: "" },
@ -89,10 +105,18 @@ let container: HTMLDivElement;
let root: Root;
const onOpenBot = vi.fn();
const onOpenGroup = vi.fn();
const onOpenInSpace = vi.fn();
function render(bots = idleBots) {
return act(async () =>
root.render(<MainInbox bots={bots} onOpenBot={onOpenBot} onOpenGroup={onOpenGroup} />),
root.render(
<MainInbox
bots={bots}
onOpenBot={onOpenBot}
onOpenGroup={onOpenGroup}
onOpenInSpace={onOpenInSpace}
/>,
),
);
}
@ -111,6 +135,7 @@ beforeEach(() => {
api.markRead.mockReset().mockResolvedValue({ ok: true });
onOpenBot.mockReset();
onOpenGroup.mockReset();
onOpenInSpace.mockReset();
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
@ -154,12 +179,10 @@ it("answers through the thread the ask came from and drops only that item", asyn
itemOf("approval").querySelector<HTMLButtonElement>("[data-answer]")?.click(),
);
expect(api.answer).toHaveBeenCalledWith({
groupId: "group-ops",
runId: "run-approval",
messageId: "msg-approval",
answer: "Paris",
});
expect(api.answer).toHaveBeenCalledWith(
{ groupId: "group-ops", runId: "run-approval", messageId: "msg-approval", answer: "Paris" },
{ context: {} },
);
expect(api.markRead).not.toHaveBeenCalled();
expect(api.list).toHaveBeenCalledTimes(2);
expect(container.querySelectorAll("[data-testid='main-inbox-item']")).toHaveLength(1);
@ -173,7 +196,7 @@ it("marks the bot's own thread read before answering, so the question does not r
await act(async () => itemOf("ask").querySelector<HTMLButtonElement>("[data-answer]")?.click());
expect(api.markRead).toHaveBeenCalledWith({ botId: scout.id });
expect(api.markRead).toHaveBeenCalledWith({ botId: scout.id }, { context: {} });
expect(api.markRead.mock.invocationCallOrder[0]).toBeLessThan(
api.answer.mock.invocationCallOrder[0]!,
);
@ -187,12 +210,10 @@ it("refreshes after a failed answer so a stale ask disappears", async () => {
await act(async () => itemOf("ask").querySelector<HTMLButtonElement>("[data-answer]")?.click());
expect(api.answer).toHaveBeenCalledWith({
botId: scout.id,
runId: "run-ask",
messageId: "msg-ask",
answer: "Paris",
});
expect(api.answer).toHaveBeenCalledWith(
{ botId: scout.id, runId: "run-ask", messageId: "msg-ask", answer: "Paris" },
{ context: {} },
);
expect(container.querySelector("[data-testid='main-inbox']")).toBeNull();
});
@ -208,6 +229,36 @@ it("opens the bot's thread for takeovers and unread threads, and the source thre
expect(onOpenGroup.mock.calls).toEqual([["group-ops"]]);
});
it("names another space's items and answers and opens them in that space", async () => {
api.list.mockResolvedValueOnce({ items: [askInOtherSpace, askInOwnThread] });
api.list.mockResolvedValue({ items: [askInOwnThread, groupAskInOtherSpace] });
api.answer.mockResolvedValue({ ok: true });
await render();
const items = container.querySelectorAll<HTMLElement>("[data-testid='main-inbox-item']");
expect(items[0]?.querySelector("[data-testid='main-inbox-space']")?.textContent).toBe(
"· Side project",
);
expect(items[1]?.querySelector("[data-testid='main-inbox-space']")).toBeNull();
await act(async () => items[0]?.querySelector<HTMLButtonElement>("[data-answer]")?.click());
expect(api.markRead).toHaveBeenCalledWith(
{ botId: writer.id },
{ context: { spaceId: side.id } },
);
expect(api.answer).toHaveBeenCalledWith(
{ botId: writer.id, runId: "run-side", messageId: "msg-side", answer: "Paris" },
{ context: { spaceId: side.id } },
);
const [own, group] = container.querySelectorAll<HTMLElement>("[data-testid='main-inbox-item']");
await act(async () => group?.querySelector("button")?.click());
await act(async () => own?.querySelector("button")?.click());
expect(onOpenInSpace.mock.calls).toEqual([[side.id, "/app/g/group-ops"]]);
expect(onOpenBot.mock.calls).toEqual([[scout.id]]);
expect(onOpenGroup).not.toHaveBeenCalled();
});
it("refreshes when a bot's state changes, not on every render", async () => {
api.list.mockResolvedValue({ items: [] });
await render();

View File

@ -12,20 +12,28 @@ const INBOX_POLL_MS = 15_000;
type PendingAskItem = Extract<InboxItem, { kind: "ask" | "approval" }>;
/** Thread procedures for an item run in the item's own space. */
function itemContext(item: InboxItem) {
return { context: item.space ? { spaceId: item.space.id } : {} };
}
/**
* What the user's bots need from them outside the main conversation, with each bot as the
* sender: asks and approvals are answered in place, takeovers and unread threads open the
* bot's thread.
* What the user's bots in all of their spaces need from them outside the main conversation, with
* each bot as the sender: asks and approvals are answered in place, takeovers and unread threads
* open the bot's thread.
*/
export function MainInbox({
bots,
onOpenBot,
onOpenGroup,
onOpenInSpace,
}: {
/** The space's bots; a change in their status, unread flag or preview refreshes the inbox. */
bots: readonly Pick<Bot, "id" | "status" | "unread" | "preview">[];
onOpenBot: (botId: string) => void;
onOpenGroup: (groupId: string) => void;
/** Opens an app path, such as a thread, in another of the user's spaces. */
onOpenInSpace: (spaceId: string, path: string) => void;
}) {
const { t } = useLingui();
const [items, setItems] = useState<InboxItem[]>([]);
@ -59,11 +67,16 @@ export function MainInbox({
}, [refresh]);
const open = useCallback(
(target: InboxThreadTarget) => {
if ("botId" in target) onOpenBot(target.botId);
({ target, space }: { target: InboxThreadTarget; space?: InboxItem["space"] }) => {
if (space) {
onOpenInSpace(
space.id,
"botId" in target ? `/app/${target.botId}` : `/app/g/${target.groupId}`,
);
} else if ("botId" in target) onOpenBot(target.botId);
else onOpenGroup(target.groupId);
},
[onOpenBot, onOpenGroup],
[onOpenBot, onOpenGroup, onOpenInSpace],
);
const answer = useCallback(
@ -72,14 +85,12 @@ export function MainInbox({
// Answering reads the ask, so the bot's thread should not come back as unread with the
// same question. Mark it first: the reply to this answer marks it unread again.
if ("botId" in item.target) {
await rpc.threads.markRead(item.target).catch(() => undefined);
await rpc.threads.markRead(item.target, itemContext(item)).catch(() => undefined);
}
await rpc.threads.answer({
...item.target,
runId: item.runId,
messageId: item.messageId,
answer: text,
});
await rpc.threads.answer(
{ ...item.target, runId: item.runId, messageId: item.messageId, answer: text },
itemContext(item),
);
const key = inboxItemKey(item);
setItems((current) => current.filter((entry) => inboxItemKey(entry) !== key));
} finally {
@ -105,10 +116,10 @@ export function MainInbox({
<div className="flex flex-col gap-1.5">
<button
type="button"
onClick={() => open(item.target)}
onClick={() => open(item)}
className="w-fit rounded-md hover:underline"
>
<Sender bot={item.bot} />
<Sender bot={item.bot} space={item.space} />
</button>
<AskCard
block={item.ask}
@ -120,11 +131,11 @@ export function MainInbox({
) : (
<button
type="button"
onClick={() => open(item.target)}
onClick={() => open(item)}
className="flex w-full items-start gap-2.5 rounded-xl px-2 py-1.5 text-start hover:bg-accent"
>
<span className="min-w-0 flex-1">
<Sender bot={item.bot} />
<Sender bot={item.bot} space={item.space} />
{item.text ? (
<span
className="mt-0.5 line-clamp-2 text-ui-body-sm text-muted-foreground"
@ -154,16 +165,27 @@ export function MainInbox({
);
}
function Sender({ bot }: { bot: InboxItem["bot"] }) {
/** The bot, and the space it works in when that is not the current one. */
function Sender({ bot, space }: { bot: InboxItem["bot"]; space?: InboxItem["space"] }) {
const color = resolvePersonaColorDef(bot.id, parseBotAvatar(bot.color).color).light;
return (
<span
className="flex items-center gap-2 text-ui-body-sm font-semibold tracking-tight"
className="flex min-w-0 items-center gap-2 text-ui-body-sm font-semibold tracking-tight"
style={{ color }}
dir="auto"
>
<BotAvatar color={bot.color} identity={bot.id} size={22} className="shrink-0" />
<span className="truncate">{bot.name}</span>
{space ? (
<span
data-testid="main-inbox-space"
className="truncate font-normal text-muted-foreground"
dir="auto"
>
<span aria-hidden="true">· </span>
{space.name}
</span>
) : null}
</span>
);
}

View File

@ -0,0 +1,197 @@
import { Trans, useLingui } from "@lingui/react/macro";
import type { NotificationSettings, NotificationSettingsPatch } from "@rakazo/contracts";
import { Input, Switch } from "@rakazo/ui-web";
import type { ReactNode } from "react";
import { useEffect, useId, useRef, useState } from "react";
import { rpc } from "../lib/rpc";
import { disableWebPush, enableWebPush, webPushEnabled, webPushSupported } from "../lib/web-push";
const DEFAULT_QUIET_HOURS = { start: "22:00", end: "07:00" };
const CLOCK_TIME = /^([01]\d|2[0-3]):[0-5]\d$/;
type PushState = "loading" | "on" | "off" | "blocked";
function SettingRow({
label,
testId,
checked,
disabled,
onCheckedChange,
children,
}: {
label: ReactNode;
testId: string;
checked: boolean;
disabled?: boolean;
onCheckedChange: (checked: boolean) => void;
children?: ReactNode;
}) {
const labelId = useId();
return (
<li className="py-3 first:pt-0 last:pb-0">
<div className="flex items-center justify-between gap-4">
<span id={labelId} className="text-ui-body text-foreground">
{label}
</span>
<Switch
data-testid={testId}
aria-labelledby={labelId}
checked={checked}
disabled={disabled}
onCheckedChange={onCheckedChange}
/>
</div>
{children}
</li>
);
}
/** Push on this browser, quiet hours and nudges. Quiet hours and nudges apply to every device. */
export function NotificationSettingsPanel() {
const { t } = useLingui();
const pushSupported = webPushSupported();
const [settings, setSettings] = useState<NotificationSettings | null>(null);
const [push, setPush] = useState<PushState>("loading");
const [pushBusy, setPushBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [quietDraft, setQuietDraft] = useState(DEFAULT_QUIET_HOURS);
const saves = useRef(Promise.resolve());
useEffect(() => {
let cancelled = false;
rpc.notifications.settings
.get()
.then((next) => {
if (cancelled) return;
setSettings(next);
if (next.quietHours) setQuietDraft(next.quietHours);
})
.catch(() => {
if (!cancelled) setError(t`Couldn't load`);
});
return () => {
cancelled = true;
};
}, [t]);
useEffect(() => {
if (!pushSupported) return;
let cancelled = false;
void webPushEnabled().then((enabled) => {
if (cancelled) return;
setPush(enabled ? "on" : Notification.permission === "denied" ? "blocked" : "off");
});
return () => {
cancelled = true;
};
}, [pushSupported]);
/** Saves in order, so the last change the user made is the one that sticks. */
function save(patch: NotificationSettingsPatch) {
setError(null);
setSettings((current) => (current ? { ...current, ...patch } : current));
saves.current = saves.current.then(async () => {
try {
setSettings(await rpc.notifications.settings.update(patch));
} catch {
setError(t`Couldn't save`);
setSettings(await rpc.notifications.settings.get().catch(() => null));
}
});
}
async function changePush(next: boolean) {
setPushBusy(true);
setError(null);
try {
if (!next) {
await disableWebPush();
setPush("off");
return;
}
const result = await enableWebPush();
if (result === "enabled") setPush("on");
else if (result === "denied") setPush("blocked");
else setError(t`Couldn't save`);
} catch {
setError(t`Couldn't save`);
} finally {
setPushBusy(false);
}
}
function changeQuietTime(field: "start" | "end", value: string) {
const next = { ...quietDraft, [field]: value };
setQuietDraft(next);
if (CLOCK_TIME.test(next.start) && CLOCK_TIME.test(next.end) && next.start !== next.end) {
save({ quietHours: next });
}
}
return (
<div data-testid="notification-settings" className="space-y-5">
{error ? (
<p role="alert" className="text-ui-body-sm text-destructive">
{error}
</p>
) : null}
{settings ? (
<section className="rounded-xl border border-border px-4 py-4">
<ul className="divide-y divide-border">
{pushSupported ? (
<SettingRow
label={<Trans>Push on this device</Trans>}
testId="notification-push-switch"
checked={push === "on"}
disabled={push === "loading" || push === "blocked" || pushBusy}
onCheckedChange={(next) => void changePush(next)}
>
{push === "blocked" ? (
<p className="mt-1 text-ui-body-sm text-muted-foreground">
<Trans>Blocked in your browser settings</Trans>
</p>
) : null}
</SettingRow>
) : null}
<SettingRow
label={<Trans>Quiet hours</Trans>}
testId="notification-quiet-hours-switch"
checked={settings.quietHours !== null}
onCheckedChange={(next) => save({ quietHours: next ? quietDraft : null })}
>
{settings.quietHours ? (
<div className="mt-3 flex items-center gap-2">
<Input
type="time"
data-testid="notification-quiet-hours-start"
aria-label={t`Quiet hours start`}
value={quietDraft.start}
onChange={(event) => changeQuietTime("start", event.target.value)}
className="w-36"
/>
<span aria-hidden="true" className="text-muted-foreground">
–
</span>
<Input
type="time"
data-testid="notification-quiet-hours-end"
aria-label={t`Quiet hours end`}
value={quietDraft.end}
onChange={(event) => changeQuietTime("end", event.target.value)}
className="w-36"
/>
</div>
) : null}
</SettingRow>
<SettingRow
label={<Trans>Nudges from your Oo</Trans>}
testId="notification-nudges-switch"
checked={settings.nudges}
onCheckedChange={(next) => save({ nudges: next })}
/>
</ul>
</section>
) : null}
</div>
);
}

View File

@ -0,0 +1,129 @@
import { Trans, useLingui } from "@lingui/react/macro";
import type { UserFact, UserFactCategory } from "@rakazo/contracts";
import { USER_FACT_CATEGORIES, USER_FACT_VALUE_MAX_LENGTH } from "@rakazo/contracts";
import {
Button,
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuSeparator,
DropdownMenuTrigger,
Input,
NativeSelect,
NativeSelectOption,
} from "@rakazo/ui-web";
import { ChevronDownIcon } from "lucide-react";
import { useState } from "react";
import type { UserFactDraft } from "../lib/user-profile-view";
import { factSpaceNames, toggleFactSpace, userFactDraft } from "../lib/user-profile-view";
/** Edits one fact in place: its wording, category and, with more than one space, where it shows. */
export function UserFactEditor({
fact,
spaces,
categoryLabels,
busy,
onSave,
onCancel,
}: {
fact: UserFact;
/** The user's spaces; the visibility control appears with more than one. */
spaces: ReadonlyArray<{ id: string; name: string }>;
categoryLabels: Record<UserFactCategory, string>;
busy: boolean;
onSave: (draft: UserFactDraft) => void;
onCancel: () => void;
}) {
const { t } = useLingui();
const [draft, setDraft] = useState(() => userFactDraft(fact));
const allSpaces = t`All spaces`;
const showSpaces = spaces.length > 1 || draft.spaceIds !== null;
return (
<li data-testid="user-fact-editor">
<form
className="space-y-2"
onSubmit={(event) => {
event.preventDefault();
if (draft.value.trim()) onSave(draft);
}}
onKeyDown={(event) => {
// The spaces menu renders in a portal; its Escape only closes the menu.
if (event.key === "Escape" && event.currentTarget.contains(event.target as Node)) {
event.stopPropagation();
onCancel();
}
}}
>
<Input
autoFocus
aria-label={t`Fact`}
value={draft.value}
maxLength={USER_FACT_VALUE_MAX_LENGTH}
onChange={(event) => setDraft({ ...draft, value: event.target.value })}
/>
<div className="flex flex-wrap items-center gap-2">
<NativeSelect
className="*:data-[slot=native-select]:h-control-md"
aria-label={t`Category`}
value={draft.category}
onChange={(event) =>
setDraft({ ...draft, category: event.target.value as UserFactCategory })
}
>
{USER_FACT_CATEGORIES.map((category) => (
<NativeSelectOption key={category} value={category}>
{categoryLabels[category]}
</NativeSelectOption>
))}
</NativeSelect>
{showSpaces ? (
<DropdownMenu>
<DropdownMenuTrigger
render={<Button type="button" variant="outline" className="max-w-56" />}
>
<span className="truncate">
{factSpaceNames(draft.spaceIds, spaces).join(", ") || allSpaces}
</span>
<ChevronDownIcon aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-auto min-w-44">
<DropdownMenuCheckboxItem
checked={draft.spaceIds === null}
onCheckedChange={() => setDraft({ ...draft, spaceIds: null })}
>
{allSpaces}
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
{spaces.map((space) => (
<DropdownMenuCheckboxItem
key={space.id}
checked={draft.spaceIds?.includes(space.id) ?? false}
onCheckedChange={(checked) =>
setDraft({
...draft,
spaceIds: toggleFactSpace(draft.spaceIds, space.id, checked),
})
}
>
<span className="truncate" dir="auto">
{space.name}
</span>
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<div className="ms-auto flex items-center gap-1">
<Button type="button" variant="ghost" disabled={busy} onClick={onCancel}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={busy || !draft.value.trim()}>
<Trans>Save</Trans>
</Button>
</div>
</div>
</form>
</li>
);
}

View File

@ -10,7 +10,7 @@ import {
AlertDialogTitle,
Button,
} from "@rakazo/ui-web";
import { XIcon } from "lucide-react";
import { PencilIcon, XIcon } from "lucide-react";
import type { ReactNode } from "react";
import { useEffect, useId, useState } from "react";
import { getActiveUiLocale } from "../lib/i18n";
@ -20,9 +20,12 @@ import {
CLEARED_PROFILE_FIELD,
displayLanguageTag,
displayRegionTag,
factSpaceNames,
filledProfileFields,
groupUserFacts,
userFactEdit,
} from "../lib/user-profile-view";
import { UserFactEditor } from "./UserFactEditor";
function Row({
value,
@ -51,14 +54,30 @@ function Row({
);
}
/** Everything the user's bots know about the user, with consent and forget controls. */
/** Everything the user's bots know about the user, with edit, consent and forget controls. */
export function UserProfileSettings() {
const { t } = useLingui();
const [state, setState] = useState<UserProfileState | null>(null);
const [spaces, setSpaces] = useState<Array<{ id: string; name: string }>>([]);
const [editingId, setEditingId] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [forgetOpen, setForgetOpen] = useState(false);
useEffect(() => {
let cancelled = false;
// Only names a fact's spaces; without them the profile still works.
rpc.spaces
.list()
.then((navigation) => {
if (!cancelled) setSpaces(navigation.spaces.map(({ id, name }) => ({ id, name })));
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
let cancelled = false;
rpc.profile
@ -74,14 +93,16 @@ export function UserProfileSettings() {
};
}, [t]);
async function apply(change: () => Promise<UserProfileState>) {
if (busy) return;
async function apply(change: () => Promise<UserProfileState>): Promise<boolean> {
if (busy) return false;
setBusy(true);
setError(null);
try {
setState(await change());
return true;
} catch (err) {
setError(err instanceof Error ? err.message : t`Couldn't save`);
return false;
} finally {
setBusy(false);
}
@ -178,36 +199,75 @@ export function UserProfileSettings() {
{categoryLabels[group.category]}
</h3>
<ul className="mt-3 space-y-3">
{group.facts.map((fact) => (
<Row
key={fact.id}
testId="user-fact"
status={fact.status}
value={fact.value}
detail={sourceLabels[fact.source]}
actions={(describedBy) => (
<>
{fact.status === "pending" ? (
{group.facts.map((fact) =>
fact.id === editingId ? (
<UserFactEditor
key={fact.id}
fact={fact}
spaces={spaces}
categoryLabels={categoryLabels}
busy={busy}
onCancel={() => setEditingId(null)}
onSave={(draft) => {
const edit = userFactEdit(fact, draft);
if (!edit) {
setEditingId(null);
return;
}
void apply(() => rpc.profile.update({ editFacts: [edit] })).then((saved) => {
if (saved) setEditingId(null);
});
}}
/>
) : (
<Row
key={fact.id}
testId="user-fact"
status={fact.status}
value={fact.value}
detail={[
sourceLabels[fact.source],
factSpaceNames(fact.allSpaces ? null : fact.spaceIds, spaces).join(", "),
]
.filter(Boolean)
.join(" · ")}
actions={(describedBy) => (
<>
{fact.status === "pending" ? (
<Button
variant="outline"
size="sm"
aria-describedby={describedBy}
disabled={busy}
onClick={() =>
void apply(() => rpc.profile.update({ confirmFactIds: [fact.id] }))
}
>
<Trans>Confirm</Trans>
</Button>
) : null}
<Button
variant="outline"
size="sm"
variant="ghost"
size="icon-sm"
aria-label={t`Edit`}
aria-describedby={describedBy}
disabled={busy}
onClick={() =>
void apply(() => rpc.profile.update({ confirmFactIds: [fact.id] }))
}
onClick={() => {
setError(null);
setEditingId(fact.id);
}}
>
<Trans>Confirm</Trans>
<PencilIcon />
</Button>
) : null}
{deleteButton(
describedBy,
() => void apply(() => rpc.profile.forget({ factIds: [fact.id] })),
)}
</>
)}
/>
))}
{deleteButton(
describedBy,
() => void apply(() => rpc.profile.forget({ factIds: [fact.id] })),
)}
</>
)}
/>
),
)}
</ul>
</section>
))}

View File

@ -0,0 +1,214 @@
import { i18n } from "@lingui/core";
import { useLingui } from "@lingui/react/macro";
import type { CoachRhythm } from "@rakazo/contracts";
import {
Button,
Input,
NativeSelect,
NativeSelectOption,
Popover,
PopoverContent,
PopoverTrigger,
Spinner,
Switch,
} from "@rakazo/ui-web";
import { CalendarClock } from "lucide-react";
import type { ReactNode } from "react";
import { useCallback, useId, useRef, useState } from "react";
import { rpc } from "../../lib/rpc";
/** Times a check-in starts at when it is switched on. */
const DEFAULTS = { morning: "08:00", evening: "20:00", weekly: { day: 0, time: "18:00" } };
const WEEKDAYS = [0, 1, 2, 3, 4, 5, 6];
function weekdayName(day: number): string {
// 2026-10-04 was a Sunday, so day 0 maps to Sunday.
return new Date(Date.UTC(2026, 9, 4 + day)).toLocaleDateString(i18n.locale || "en", {
weekday: "long",
timeZone: "UTC",
});
}
/** When the main bot checks in: loaded when opened, saved on every change. */
export function CoachRhythmControl() {
const { t } = useLingui();
const [rhythm, setRhythm] = useState<CoachRhythm | null>(null);
const latestRequest = useRef(0);
const load = useCallback(async () => {
const request = ++latestRequest.current;
try {
const loaded = await rpc.coach.rhythm.get();
if (request === latestRequest.current) setRhythm(loaded);
} catch {
// Opening the control again retries.
}
}, []);
const save = useCallback(
async (next: CoachRhythm) => {
setRhythm(next);
const request = ++latestRequest.current;
try {
const saved = await rpc.coach.rhythm.set(next);
if (request === latestRequest.current) setRhythm(saved);
} catch {
if (request === latestRequest.current) void load();
}
},
[load],
);
return (
<Popover
onOpenChange={(open) => {
if (open) void load();
}}
>
<PopoverTrigger
data-testid="heute-rhythm"
aria-label={t`Check-ins`}
title={t`Check-ins`}
render={<Button variant="ghost" size="icon" />}
>
<CalendarClock strokeWidth={1.7} aria-hidden="true" />
</PopoverTrigger>
<PopoverContent
align="end"
sideOffset={8}
data-testid="heute-rhythm-panel"
className="w-[min(340px,calc(100vw-2rem))] gap-3 p-3"
>
{rhythm ? (
<>
<RhythmRow
label={t`Morning`}
testId="heute-rhythm-morning"
on={rhythm.morning !== null}
onToggle={(on) => void save({ ...rhythm, morning: on ? DEFAULTS.morning : null })}
>
{(labelId) =>
rhythm.morning ? (
<TimeInput
labelId={labelId}
value={rhythm.morning}
onChange={(morning) => void save({ ...rhythm, morning })}
/>
) : null
}
</RhythmRow>
<RhythmRow
label={t`Evening`}
testId="heute-rhythm-evening"
on={rhythm.evening !== null}
onToggle={(on) => void save({ ...rhythm, evening: on ? DEFAULTS.evening : null })}
>
{(labelId) =>
rhythm.evening ? (
<TimeInput
labelId={labelId}
value={rhythm.evening}
onChange={(evening) => void save({ ...rhythm, evening })}
/>
) : null
}
</RhythmRow>
<RhythmRow
label={t`Weekly`}
testId="heute-rhythm-weekly"
on={rhythm.weekly !== null}
onToggle={(on) => void save({ ...rhythm, weekly: on ? DEFAULTS.weekly : null })}
>
{(labelId) => {
const weekly = rhythm.weekly;
if (!weekly) return null;
return (
<>
<NativeSelect
size="sm"
aria-labelledby={labelId}
value={String(weekly.day)}
onChange={(event) =>
void save({
...rhythm,
weekly: { ...weekly, day: Number(event.target.value) },
})
}
>
{WEEKDAYS.map((day) => (
<NativeSelectOption key={day} value={day}>
{weekdayName(day)}
</NativeSelectOption>
))}
</NativeSelect>
<TimeInput
labelId={labelId}
value={weekly.time}
onChange={(time) => void save({ ...rhythm, weekly: { ...weekly, time } })}
/>
</>
);
}}
</RhythmRow>
</>
) : (
<div className="flex justify-center py-3">
<Spinner />
</div>
)}
</PopoverContent>
</Popover>
);
}
function RhythmRow({
label,
testId,
on,
onToggle,
children,
}: {
label: string;
testId: string;
on: boolean;
onToggle: (on: boolean) => void;
children: (labelId: string) => ReactNode;
}) {
const labelId = useId();
return (
<div data-testid={testId} className="flex min-h-control-sm flex-wrap items-center gap-2">
<Switch
size="sm"
checked={on}
aria-labelledby={labelId}
onCheckedChange={(checked) => onToggle(checked)}
/>
<span id={labelId} className="flex-1 text-ui-body text-foreground">
{label}
</span>
<div className="ml-auto flex items-center gap-2">{children(labelId)}</div>
</div>
);
}
function TimeInput({
labelId,
value,
onChange,
}: {
labelId: string;
value: string;
onChange: (value: string) => void;
}) {
return (
<Input
type="time"
aria-labelledby={labelId}
value={value}
onChange={(event) => {
if (event.target.value) onChange(event.target.value.slice(0, 5));
}}
className="h-control-sm w-32 px-2 tabular-nums"
/>
);
}

View File

@ -0,0 +1,105 @@
import { i18n } from "@lingui/core";
import { Plural, Trans } from "@lingui/react/macro";
import type { CoachToday, HabitToday } from "@rakazo/contracts";
import { Checkbox, cn } from "@rakazo/ui-web";
import { Flame } from "lucide-react";
import { useId } from "react";
import { formatLocalDate } from "../../lib/heute-brief";
/** Shown once a habit has been kept at least twice in a row. */
const STREAK_MIN = 2;
/** Today's brief, habits to check off and active goals; each part only when it has content. */
export function HeuteCoach({
today,
onHabitDone,
}: {
today: CoachToday;
onHabitDone: (habit: HabitToday, done: boolean) => void;
}) {
return (
<>
{today.brief ? (
<section data-testid="heute-coach-brief" className="rounded-2xl bg-muted/60 px-4 py-3.5">
<p className="whitespace-pre-line text-ui-body-lg text-foreground">{today.brief.text}</p>
</section>
) : null}
{today.habits.length > 0 ? (
<section data-testid="heute-habits">
<h2 className="rk-shell-section-label mb-2 px-0.5">
<Trans>Habits</Trans>
</h2>
<ul className="flex flex-col gap-1">
{today.habits.map((habit) => (
<li key={habit.id}>
<HabitRow habit={habit} onDone={(done) => onHabitDone(habit, done)} />
</li>
))}
</ul>
</section>
) : null}
{today.goals.length > 0 ? (
<section data-testid="heute-goals">
<h2 className="rk-shell-section-label mb-2 px-0.5">
<Trans>Goals</Trans>
</h2>
<ul className="flex flex-col gap-1">
{today.goals.map((goal) => (
<li
key={goal.id}
data-testid={`heute-goal-${goal.id}`}
className="flex items-baseline justify-between gap-3 px-3 py-2"
>
<span className="min-w-0 truncate text-ui-body text-foreground">{goal.title}</span>
{goal.targetDate ? (
<span className="shrink-0 text-ui-label text-muted-foreground tabular-nums">
{formatLocalDate(goal.targetDate, i18n.locale || "en", {
day: "numeric",
month: "short",
})}
</span>
) : null}
</li>
))}
</ul>
</section>
) : null}
</>
);
}
function HabitRow({ habit, onDone }: { habit: HabitToday; onDone: (done: boolean) => void }) {
const id = useId();
return (
<label
htmlFor={id}
data-testid={`heute-habit-${habit.id}`}
className="flex cursor-pointer items-center gap-3 rounded-2xl px-3 py-2.5 transition-colors hover:bg-accent"
>
<Checkbox id={id} checked={habit.doneToday} onCheckedChange={(checked) => onDone(checked)} />
<span
className={cn(
"min-w-0 flex-1 truncate text-ui-body",
habit.doneToday ? "text-muted-foreground" : "text-foreground",
)}
>
{habit.title}
</span>
{habit.streak >= STREAK_MIN ? (
<span
data-testid={`heute-habit-streak-${habit.id}`}
className="flex shrink-0 items-center gap-1 text-ui-label text-muted-foreground tabular-nums"
>
<Flame strokeWidth={1.8} className="size-icon-sm" aria-hidden="true" />
{habit.cadence === "weekly" ? (
<Plural value={habit.streak} one="# week" other="# weeks" />
) : (
<Plural value={habit.streak} one="# day" other="# days" />
)}
</span>
) : null}
</label>
);
}

View File

@ -1,186 +0,0 @@
import { useEffect, useState } from "react";
import { rpc } from "../../lib/rpc";
interface ScanStatus {
status: "scanning" | "connecting" | "building" | "briefing" | "complete" | "failed";
progress: number;
message: string;
result?: unknown;
}
const PHASES = [
{
key: "scanning" as const,
label: "Scanning your computer...",
sublabel: "Files, folders, git repositories",
},
{
key: "connecting" as const,
label: "Connecting to your services...",
sublabel: "GitHub, email, calendar",
},
{
key: "building" as const,
label: "Building your profile...",
sublabel: "Interests, skills, preferences",
},
{
key: "briefing" as const,
label: "Setting up your agent team...",
sublabel: "Project folders, role assignments",
},
] as const;
export function OnboardingScanProgress({
onComplete,
botId,
}: {
onComplete: () => void;
botId?: string;
}) {
const [status, setStatus] = useState<ScanStatus | null>(null);
const [error, setError] = useState<string | null>(null);
const [, setScanId] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
let pollingInterval: ReturnType<typeof setInterval> | null = null;
const run = async () => {
try {
const scanResult = await rpc.onboarding.scan(botId ? { botId } : {});
if (cancelled) return;
setScanId(scanResult.scanId);
// Poll for status
const poll = async () => {
try {
const s = await rpc.onboarding.scanStatus({ scanId: scanResult.scanId });
if (cancelled) return;
setStatus(s);
if (s.status === "complete") {
onComplete();
} else if (s.status === "failed") {
setError(s.message);
}
} catch {
// poll error — keep trying
}
};
// First poll immediately, then every 2s
await poll();
pollingInterval = setInterval(poll, 2000);
} catch (err) {
if (!cancelled) setError(err instanceof Error ? err.message : "Scan failed");
}
};
run();
return () => {
cancelled = true;
if (pollingInterval) clearInterval(pollingInterval);
};
}, [botId, onComplete]);
if (error) {
return (
<div className="mx-auto max-w-xl text-center">
<p className="text-destructive">{error}</p>
<button type="button" className="mt-4 rounded-lg border px-4 py-2" onClick={onComplete}>
Continue anyway
</button>
</div>
);
}
if (!status) {
return (
<div className="mx-auto max-w-xl text-center">
<div className="mt-6 animate-spin rounded-full border-4 border-border border-t-primary h-8 w-8" />
<p className="mt-4 text-muted-foreground">Preparing your profile...</p>
</div>
);
}
const currentPhaseIndex = PHASES.findIndex((p) => p.key === status.status);
const isComplete = status.status === "complete";
return (
<div className="mx-auto max-w-xl">
<h2 className="text-xl font-semibold tracking-tight text-foreground text-center">
{isComplete ? "All set!" : "Getting to know you..."}
</h2>
<p className="mt-1 text-center text-sm text-muted-foreground">
{isComplete
? "Your profile and agent team are ready."
: "We're scanning your computer and services to personalize your experience."}
</p>
<div className="mt-6 space-y-4">
{PHASES.map((phase, idx) => {
const isActive = idx === currentPhaseIndex && !isComplete;
const isDone = idx < currentPhaseIndex || isComplete;
return (
<div
key={phase.key}
className={`flex items-center gap-3 rounded-xl border px-4 py-3 transition-all ${
isActive
? "border-primary bg-primary/5"
: isDone
? "border-emerald-500/30 bg-emerald-500/5"
: "border-border bg-card"
}`}
>
<div className="flex-shrink-0">
{isDone ? (
<span className="grid size-6 items-center justify-center rounded-full bg-emerald-500/20 text-emerald-700 text-sm dark:text-emerald-500">
✓
</span>
) : isActive ? (
<span className="grid size-6 items-center justify-center rounded-full bg-primary/20 animate-pulse text-primary text-sm">
●
</span>
) : (
<span className="grid size-6 items-center justify-center rounded-full bg-muted/30 text-muted-foreground text-sm">
○
</span>
)}
</div>
<div className="min-w-0 flex-1">
<p
className={`text-sm font-medium ${isActive ? "text-foreground" : "text-muted-foreground"}`}
>
{phase.label}
</p>
<p className="text-xs text-muted-foreground/60">{phase.sublabel}</p>
</div>
{isActive && (
<div className="flex-shrink-0">
<div className="h-1 w-16 overflow-hidden rounded-full bg-muted/30">
<div
className="h-full rounded-full bg-primary transition-all duration-500"
style={{ width: `${status.progress}%` }}
/>
</div>
</div>
)}
</div>
);
})}
</div>
{isComplete && (
<div className="mt-8 text-center">
<button
type="button"
className="rounded-xl bg-primary px-6 py-3 text-sm font-medium text-primary-foreground"
onClick={onComplete}
>
Continue to your workspace →
</button>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,250 @@
import { Trans, useLingui } from "@lingui/react/macro";
import type { OnboardingScanFact, OnboardingScanProject } from "@rakazo/contracts";
import { Button, Checkbox } from "@rakazo/ui-web";
import { useEffect, useId, useRef, useState } from "react";
import { rpc } from "../../lib/rpc";
import { LoadingState } from "../ai/primitives";
const POLL_MS = 1_000;
interface Found {
projects: OnboardingScanProject[];
facts: OnboardingScanFact[];
}
function toggled(ids: string[], id: string, on: boolean): string[] {
return on ? [...ids, id] : ids.filter((entry) => entry !== id);
}
/**
* Asks before looking at the server's scan folder, then lets the user choose which found projects
* become project spaces and which languages and tools go to About you. Nothing is created without
* a choice. Skips itself when this account cannot scan.
*/
export function OnboardingScanStep({ onDone }: { onDone: () => void }) {
const { t } = useLingui();
const ids = useId();
const done = useRef(onDone);
done.current = onDone;
const [folder, setFolder] = useState<string | null>(null);
const [looking, setLooking] = useState(false);
const [scanId, setScanId] = useState<string | null>(null);
const [found, setFound] = useState<Found | null>(null);
const [failed, setFailed] = useState(false);
const [projectIds, setProjectIds] = useState<string[]>([]);
const [factIds, setFactIds] = useState<string[]>([]);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
rpc.onboarding
.scanTarget()
.then((target) => {
if (cancelled) return;
if (target.folder) setFolder(target.folder);
else done.current();
})
.catch(() => {
if (!cancelled) done.current();
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!scanId) return;
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const poll = () => {
rpc.onboarding
.scanStatus({ scanId })
.then((status) => {
if (cancelled) return;
if (status.status === "scanning") {
timer = setTimeout(poll, POLL_MS);
} else if (status.status === "failed") {
setFailed(true);
} else {
setFound({ projects: status.projects, facts: status.facts });
setFactIds(status.facts.map((fact) => fact.id));
}
})
.catch(() => {
if (!cancelled) setFailed(true);
});
};
poll();
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [scanId]);
async function look() {
setLooking(true);
try {
setScanId((await rpc.onboarding.scan()).scanId);
} catch {
setFailed(true);
}
}
async function add() {
if (!scanId) return;
setSaving(true);
setError(null);
try {
await rpc.onboarding.applyScan({ scanId, projectIds, factIds });
done.current();
} catch (reason) {
setError(reason instanceof Error ? reason.message : String(reason));
setSaving(false);
}
}
const continueButton = (
<div className="mt-8 flex justify-end">
<Button onClick={() => done.current()}>
<Trans>Continue</Trans>
</Button>
</div>
);
if (failed) {
return (
<div className="mx-auto max-w-xl">
<p className="text-sm text-destructive">
<Trans>Could not look in this folder.</Trans>
</p>
{continueButton}
</div>
);
}
if (found && found.projects.length === 0) {
return (
<div className="mx-auto max-w-xl">
<h2 className="text-xl font-semibold tracking-tight text-foreground">
<Trans>No projects found</Trans>
</h2>
{continueButton}
</div>
);
}
if (found) {
return (
<div className="mx-auto max-w-xl">
<fieldset className="border-0 p-0">
<legend>
<h2 className="text-xl font-semibold tracking-tight text-foreground">
<Trans>Add as projects</Trans>
</h2>
</legend>
<div className="mt-3 max-h-72 space-y-1 overflow-y-auto">
{found.projects.map((project) => (
<label
key={project.id}
htmlFor={`${ids}-${project.id}`}
className="flex cursor-pointer items-start gap-3 rounded-lg px-2 py-2 hover:bg-muted"
>
<Checkbox
id={`${ids}-${project.id}`}
checked={projectIds.includes(project.id)}
disabled={saving}
onCheckedChange={(checked) =>
setProjectIds((current) => toggled(current, project.id, checked))
}
className="mt-0.5"
/>
<span className="min-w-0">
<span className="block text-sm font-medium text-foreground">{project.name}</span>
{project.path !== project.name ? (
<span className="block truncate font-mono text-xs text-muted-foreground">
{project.path}
</span>
) : null}
</span>
</label>
))}
</div>
</fieldset>
{found.facts.length > 0 ? (
<fieldset className="mt-6 border-0 p-0">
<legend className="text-sm font-medium text-foreground">
<Trans>Add to About you</Trans>
</legend>
<div className="mt-3 flex flex-wrap gap-2">
{found.facts.map((fact) => (
<label
key={fact.id}
htmlFor={`${ids}-${fact.id}`}
className="flex cursor-pointer items-center gap-2 rounded-full border border-border px-3 py-1.5 text-sm text-foreground hover:bg-muted"
>
<Checkbox
id={`${ids}-${fact.id}`}
checked={factIds.includes(fact.id)}
disabled={saving}
onCheckedChange={(checked) =>
setFactIds((current) => toggled(current, fact.id, checked))
}
/>
{fact.label}
</label>
))}
</div>
</fieldset>
) : null}
{error ? <p className="mt-4 text-sm text-destructive">{error}</p> : null}
<div className="mt-8 flex items-center justify-between">
<Button variant="ghost" size="sm" disabled={saving} onClick={() => done.current()}>
<Trans>Skip</Trans>
</Button>
<Button
disabled={saving || (projectIds.length === 0 && factIds.length === 0)}
onClick={() => void add()}
>
<Trans>Add</Trans>
</Button>
</div>
</div>
);
}
if (looking) {
return (
<div className="mx-auto max-w-xl">
<LoadingState label={t`Looking`} />
</div>
);
}
if (!folder) return null;
return (
<div className="mx-auto max-w-xl">
<h2 className="text-xl font-semibold tracking-tight text-foreground">
<Trans>Look for projects here?</Trans>
</h2>
<p className="mt-3 break-all rounded-lg bg-muted px-3 py-2 font-mono text-sm text-foreground">
{folder}
</p>
<p className="mt-2 text-sm text-muted-foreground">
<Trans>Only file and folder names are read.</Trans>
</p>
<div className="mt-8 flex items-center justify-between">
<Button variant="ghost" size="sm" onClick={() => done.current()}>
<Trans>Skip</Trans>
</Button>
<Button onClick={() => void look()}>
<Trans>Look</Trans>
</Button>
</div>
</div>
);
}

View File

@ -1,6 +1,13 @@
import type { Bot, Group, RunActivityRow } from "@rakazo/contracts";
import type { Bot, CoachToday, Group, HabitToday, RunActivityRow } from "@rakazo/contracts";
import { describe, expect, it } from "vitest";
import { buildHeuteBrief, HEUTE_NEEDS_YOU_CAP, isToday } from "./heute-brief";
import {
buildHeuteBrief,
coachRefreshSignal,
formatLocalDate,
HEUTE_NEEDS_YOU_CAP,
isToday,
withHabitDone,
} from "./heute-brief";
const now = new Date("2026-09-22T12:00:00.000Z");
@ -142,3 +149,59 @@ describe("buildHeuteBrief", () => {
expect(brief.actionCount).toBe(5);
});
});
function habit(overrides: Partial<HabitToday> = {}): HabitToday {
return {
id: "habit-1",
title: "Walk",
cadence: "daily",
goalId: null,
active: true,
source: "user",
createdByBotId: null,
createdAt: "2026-09-01T00:00:00.000Z",
updatedAt: "2026-09-01T00:00:00.000Z",
doneToday: false,
streak: 3,
...overrides,
};
}
describe("coach on the Heute page", () => {
it("reloads the coach when a run finishes, not while runs are only working", () => {
const working = [run({ runId: "run-1", status: "running" })];
expect(coachRefreshSignal(working)).toBe("");
expect(coachRefreshSignal([...working, run({ runId: "run-2", status: "completed" })])).toBe(
"run-2",
);
});
it("checks a habit off before the server confirms, moving the streak by one", () => {
const today: CoachToday = {
date: "2026-10-03",
timeZone: "Europe/Vienna",
brief: null,
goals: [],
habits: [habit(), habit({ id: "habit-2", doneToday: true, streak: 1 })],
checkIn: null,
};
const checked = withHabitDone(today, "habit-1", true);
expect(checked.habits.map(({ doneToday, streak }) => ({ doneToday, streak }))).toEqual([
{ doneToday: true, streak: 4 },
{ doneToday: true, streak: 1 },
]);
expect(withHabitDone(checked, "habit-2", false).habits[1]).toMatchObject({
doneToday: false,
streak: 0,
});
// Repeating the same state changes nothing.
expect(withHabitDone(checked, "habit-1", true)).toEqual(checked);
});
it("labels the user's calendar date without shifting it into the browser's zone", () => {
expect(formatLocalDate("2026-10-03", "en-US", { weekday: "long" })).toBe("Saturday");
expect(formatLocalDate("2026-11-01", "en-US", { month: "short", day: "numeric" })).toBe(
"Nov 1",
);
});
});

View File

@ -1,4 +1,4 @@
import type { Bot, Group, RunActivityRow } from "@rakazo/contracts";
import type { Bot, CoachToday, Group, RunActivityRow } from "@rakazo/contracts";
import { isActionableRun, isDoingStatus, isFailedRoutine } from "./agent-activity";
export type JetztRunItem = {
@ -137,3 +137,38 @@ export function buildHeuteBrief(params: {
weiter: [],
};
}
/**
* Finished runs are when the coach learns something (a check-in, today's brief), so a change in
* them is the cue to reload the coach's view of the day.
*/
export function coachRefreshSignal(recentRuns: RunActivityRow[]): string {
return recentRuns
.filter((run) => run.status === "completed")
.map((run) => run.runId)
.join(",");
}
/**
* A habit checked or unchecked before the server confirms. The current period only counts
* toward the streak once it is done, so the streak moves by one.
*/
export function withHabitDone(today: CoachToday, habitId: string, done: boolean): CoachToday {
return {
...today,
habits: today.habits.map((habit) =>
habit.id !== habitId || habit.doneToday === done
? habit
: { ...habit, doneToday: done, streak: Math.max(0, habit.streak + (done ? 1 : -1)) },
),
};
}
/** A calendar date ("YYYY-MM-DD") as a label, independent of the browser's time zone. */
export function formatLocalDate(
date: string,
locale: string,
options: Intl.DateTimeFormatOptions,
): string {
return new Date(`${date}T00:00:00Z`).toLocaleDateString(locale, { ...options, timeZone: "UTC" });
}

View File

@ -0,0 +1,203 @@
import type { MessageBlock, ThreadMessage } from "@rakazo/contracts";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { i18n } from "./i18n.js";
import {
intakeNoteText,
localizedChoice,
localizedPreview,
withIntakeNoteText,
} from "./onboarding-copy.js";
// The macros compile at build time; here the English source is the id, placeholders numbered.
vi.mock("@lingui/core/macro", () => {
const descriptor = (parts: TemplateStringsArray, values: unknown[]) => {
const message = parts.reduce((out, part, index) => `${out}{${index - 1}}${part}`);
return { id: message, message, values: { ...values } };
};
return {
t: (parts: TemplateStringsArray, ...values: unknown[]) => i18n._(descriptor(parts, values)),
msg: (parts: TemplateStringsArray, ...values: unknown[]) => descriptor(parts, values),
};
});
type Choice = Extract<MessageBlock, { kind: "choice" }>;
type Note = Extract<MessageBlock, { kind: "intake_note" }>;
// Intl may separate the time from AM/PM with a narrow no-break space.
const plain = (text: string) => text.replace(/\s/g, " ");
beforeEach(() => {
i18n.load("en", {});
i18n.activate("en");
});
describe("localizedChoice", () => {
it("says a known card from its step and option ids, whatever the server wrote", () => {
const card: Choice = {
kind: "choice",
step: "rhythm",
question: "server question",
options: [
{ id: "morning", letter: "A", label: "server morning" },
{ id: "fortnightly", letter: "B", label: "Every other week" },
{ id: "none", letter: "", label: "server none" },
],
};
expect(localizedChoice(card)).toEqual({
...card,
question: "When should I check in with you?",
options: [
{ id: "morning", letter: "A", label: "Every morning" },
// An option the web does not know keeps the server text.
{ id: "fortnightly", letter: "B", label: "Every other week" },
{ id: "none", letter: "", label: "Not now" },
],
});
});
it("words the goal placeholder and keeps the server's quiet-hour ranges", () => {
const goal = localizedChoice({
kind: "choice",
step: "goal",
question: "?",
textInput: { placeholder: "server placeholder" },
options: [{ id: "skip", letter: "", label: "server skip" }],
});
expect(goal.textInput).toEqual({ placeholder: "Your goal" });
expect(goal.options[0]?.label).toBe("Skip");
const quiet = localizedChoice({
kind: "choice",
step: "quiet_hours",
question: "?",
options: [{ id: "22-07", letter: "A", label: "22:00 – 7:00" }],
});
expect(quiet.options[0]?.label).toBe("22:00 – 7:00");
});
it("recognizes another bot's focus card and leaves other cards alone", () => {
const focus: Choice = {
kind: "choice",
question: "server question",
options: [
{ id: "day", letter: "A", label: "x" },
{ id: "crew", letter: "D", label: "y" },
],
};
expect(localizedChoice(focus).question).toBe("What do you want me on first?");
expect(localizedChoice(focus).options.map((option) => option.label)).toEqual([
"Day-to-day work",
"Specialist crew",
]);
const other: Choice = {
kind: "choice",
question: "Pick one",
options: [{ id: "a", letter: "A", label: "Apples" }],
};
expect(localizedChoice(other)).toBe(other);
});
});
describe("intakeNoteText", () => {
const now = new Date(2026, 9, 7, 10, 0);
const note = (nextCheckInAt: Date | null): Note => ({
kind: "intake_note",
note: "done",
nextCheckInAt: nextCheckInAt?.toISOString() ?? null,
text: "server text",
});
it("names the first check-in relative to today, in the user's clock", () => {
expect(plain(intakeNoteText(note(new Date(2026, 9, 7, 18, 0)), now))).toBe(
"All set. I’ll check in today at 6:00 PM.",
);
expect(plain(intakeNoteText(note(new Date(2026, 9, 8, 8, 0)), now))).toBe(
"All set. I’ll check in tomorrow at 8:00 AM.",
);
expect(plain(intakeNoteText(note(new Date(2026, 9, 11, 18, 0)), now))).toBe(
"All set. I’ll check in on Sunday at 6:00 PM.",
);
});
it("has a line for no check-in and falls back to the server text when unreadable", () => {
expect(intakeNoteText(note(null), now)).toBe("All set. I’m here whenever you need me.");
expect(intakeNoteText({ ...note(null), nextCheckInAt: "soon" }, now)).toBe("server text");
});
});
describe("withIntakeNoteText", () => {
const message = (blocks: MessageBlock[]) =>
({ id: "m", role: "bot", blocks, createdAt: "2026-10-07T08:00:00.000Z" }) as ThreadMessage;
it("turns notes into text and leaves other messages untouched", () => {
const plainMessage = message([{ kind: "text", text: "hi" }]);
expect(withIntakeNoteText(plainMessage)).toBe(plainMessage);
const withNote = message([
{ kind: "intake_note", note: "done", nextCheckInAt: null, text: "server text" },
]);
expect(withIntakeNoteText(withNote).blocks).toEqual([
{ kind: "text", text: "All set. I’m here whenever you need me." },
]);
});
});
describe("localizedPreview", () => {
const now = new Date(2026, 9, 7, 10, 0);
const rhythm: Choice = {
kind: "choice",
step: "rhythm",
question: "server question",
options: [
{ id: "morning", letter: "A", label: "server morning" },
{ id: "none", letter: "", label: "server none" },
],
};
const goal: Choice = {
kind: "choice",
step: "goal",
question: "server goal",
textInput: { placeholder: "server placeholder" },
options: [{ id: "skip", letter: "", label: "server skip" }],
};
const done: Note = {
kind: "intake_note",
note: "done",
nextCheckInAt: new Date(2026, 9, 8, 8, 0).toISOString(),
text: "server text",
};
const preview = (previewBlock?: Choice | Note) => localizedPreview({ preview: "", previewBlock });
beforeEach(() => {
vi.useFakeTimers({ now });
return () => vi.useRealTimers();
});
it("keeps the server preview when onboarding did not write the latest message", () => {
expect(localizedPreview({ preview: "See you at 8" })).toBe("See you at 8");
});
it("reads like the conversation: the question, the answer once given, the closing note", () => {
expect(preview(rhythm)).toBe("When should I check in with you?");
expect(preview({ ...rhythm, answerId: "morning" })).toBe("Every morning");
expect(preview({ ...goal, answerId: "_text", answerText: "Run a 10K" })).toBe("Run a 10K");
expect(plain(preview(done))).toBe("All set. I’ll check in tomorrow at 8:00 AM.");
// A dismissed card is gone from the conversation, so the preview is too.
expect(preview({ ...rhythm, answerId: "_dismissed" })).toBe("");
});
it("speaks the user's language", () => {
i18n.load("de", {
"When should I check in with you?": "Wann soll ich mich bei dir melden?",
"Every morning": "Jeden Morgen",
"All set. I’ll check in tomorrow at {0}.": "Alles klar. Ich melde mich morgen um {0}.",
"All set. I’m here whenever you need me.":
"Alles klar. Ich bin da, wann immer du mich brauchst.",
});
i18n.activate("de");
expect(preview(rhythm)).toBe("Wann soll ich mich bei dir melden?");
expect(preview({ ...rhythm, answerId: "morning" })).toBe("Jeden Morgen");
expect(preview(done)).toBe("Alles klar. Ich melde mich morgen um 8:00.");
expect(preview({ ...done, nextCheckInAt: null })).toBe(
"Alles klar. Ich bin da, wann immer du mich brauchst.",
);
});
});

View File

@ -0,0 +1,130 @@
import type { MessageDescriptor } from "@lingui/core";
import { msg, t } from "@lingui/core/macro";
import type { Group, MessageBlock, OnboardingStep, ThreadMessage } from "@rakazo/contracts";
import { CHOICE_TEXT_ANSWER_ID } from "@rakazo/contracts";
import { i18n } from "./i18n";
/**
* The onboarding cards in the user's language. The server stores each card with its step and
* option ids plus English text; the web says what it knows from the ids and keeps the server
* text for anything new (mobile and agent history always use the server text).
*/
type ChoiceBlock = Extract<MessageBlock, { kind: "choice" }>;
type ChoiceOption = ChoiceBlock["options"][number];
type IntakeNote = Extract<MessageBlock, { kind: "intake_note" }>;
const QUESTIONS: Record<OnboardingStep, MessageDescriptor> = {
focus: msg`What do you want me on first?`,
goal: msg`What’s one thing you want to get done in the next few weeks?`,
rhythm: msg`When should I check in with you?`,
quiet_hours: msg`When should I stay quiet?`,
notifications: msg`Can I send you notifications?`,
};
const SKIP = msg`Skip`;
const NOT_NOW = msg`Not now`;
// Quiet-hour ranges are left out: the server already writes them in the user's locale.
const OPTIONS: Record<OnboardingStep, Record<string, MessageDescriptor>> = {
focus: {
day: msg`Day-to-day work`,
inbox: msg`Inbox & email`,
research: msg`Research & writing`,
crew: msg`Specialist crew`,
skip: SKIP,
},
goal: { skip: SKIP },
rhythm: {
morning: msg`Every morning`,
morning_evening: msg`Morning and evening`,
weekly: msg`Once a week`,
none: NOT_NOW,
},
quiet_hours: { skip: SKIP },
notifications: { on: msg`Turn on`, later: NOT_NOW },
};
const PLACEHOLDERS: Partial<Record<OnboardingStep, MessageDescriptor>> = {
goal: msg`Your goal`,
};
/** Another bot's focus card carries no step; its option ids give it away. */
function cardStep(card: ChoiceBlock): OnboardingStep | undefined {
if (card.step) return card.step;
const focus = OPTIONS.focus;
return card.options.length > 0 && card.options.every((option) => focus[option.id])
? "focus"
: undefined;
}
/** The card's question, options and placeholder in the user's language. */
export function localizedChoice(card: ChoiceBlock): ChoiceBlock {
const step = cardStep(card);
if (!step) return card;
const options = OPTIONS[step];
const placeholder = PLACEHOLDERS[step];
return {
...card,
question: i18n._(QUESTIONS[step]),
options: card.options.map((option) => {
const label = options[option.id];
return label ? { ...option, label: i18n._(label) } : option;
}),
...(card.textInput && placeholder ? { textInput: { placeholder: i18n._(placeholder) } } : {}),
};
}
/** What the user answered: the option they picked or their own words. */
export function chosenOption(card: ChoiceBlock): ChoiceOption | undefined {
return card.answerText
? { id: CHOICE_TEXT_ANSWER_ID, letter: "", label: card.answerText }
: card.options.find((option) => option.id === card.answerId);
}
function sameDay(left: Date, right: Date): boolean {
return left.toDateString() === right.toDateString();
}
/** The closing note in the user's language, with the first check-in in their time zone. */
export function intakeNoteText(note: IntakeNote, now = new Date()): string {
if (note.note !== "done") return note.text;
if (!note.nextCheckInAt) return t`All set. I’m here whenever you need me.`;
const at = new Date(note.nextCheckInAt);
if (!Number.isFinite(at.getTime())) return note.text;
const time = new Intl.DateTimeFormat(i18n.locale || "en", {
hour: "numeric",
minute: "2-digit",
}).format(at);
const tomorrow = new Date(now);
tomorrow.setDate(now.getDate() + 1);
if (sameDay(at, now)) return t`All set. I’ll check in today at ${time}.`;
if (sameDay(at, tomorrow)) return t`All set. I’ll check in tomorrow at ${time}.`;
const weekday = new Intl.DateTimeFormat(i18n.locale || "en", { weekday: "long" }).format(at);
return t`All set. I’ll check in on ${weekday} at ${time}.`;
}
/** The message with onboarding notes as plain text, so bubbles, copy and speech read the same. */
export function withIntakeNoteText(message: ThreadMessage): ThreadMessage {
if (!message.blocks.some((block) => block.kind === "intake_note")) return message;
return {
...message,
blocks: message.blocks.map((block) =>
block.kind === "intake_note" ? { kind: "text", text: intakeNoteText(block) } : block,
),
};
}
/**
* A conversation's preview in the user's language. An onboarding card reads as its question, or
* as the answer once given; the closing note as the note. A dismissed card shows nothing in the
* conversation, so it keeps the server preview.
*/
export function localizedPreview(chat: Pick<Group, "preview" | "previewBlock">): string {
const block = chat.previewBlock;
if (!block) return chat.preview;
if (block.kind === "intake_note") return intakeNoteText(block);
const card = localizedChoice(block);
if (!card.answerId) return card.question;
return chosenOption(card)?.label ?? chat.preview;
}

View File

@ -1,56 +1,25 @@
import { describe, expect, it } from "vitest";
import { onboardingProfileUpdate } from "./onboarding-profile";
const blank = { name: "", timezone: "", hobbies: [], workStyle: "", communication: "" };
describe("onboardingProfileUpdate", () => {
it("turns answers into profile fields and onboarding facts", () => {
it("fills the time zone, locale and chosen language from the browser", () => {
expect(
onboardingProfileUpdate({
answers: {
name: " Alice ",
timezone: "Europe/Vienna",
hobbies: ["music", "tech", "none"],
workStyle: "deep-dive",
communication: "direct",
},
accountName: "Alice Example",
browserTimeZone: "UTC",
browserTimeZone: "Europe/Vienna",
locale: "de-AT",
language: "de",
}),
).toEqual({
name: "Alice",
timeZone: "Europe/Vienna",
locale: "de-AT",
language: "de",
communicationStyle: "Direct and concise",
addFacts: [
{ category: "preferences", value: "Interested in music", source: "onboarding" },
{ category: "preferences", value: "Interested in tech and gadgets", source: "onboarding" },
{ category: "habits", value: "Prefers focused deep dives", source: "onboarding" },
],
});
).toEqual({ timeZone: "Europe/Vienna", locale: "de-AT", language: "de" });
});
it("keeps the account name and falls back to the browser time zone", () => {
it("leaves out a language the user did not pick and malformed values", () => {
expect(
onboardingProfileUpdate({
answers: { ...blank, name: "Alice", timezone: "Not/AZone" },
accountName: "Alice",
browserTimeZone: "America/Toronto",
}),
).toEqual({ timeZone: "America/Toronto" });
});
it("leaves out a language the user did not pick and malformed tags", () => {
expect(
onboardingProfileUpdate({
answers: blank,
browserTimeZone: "UTC",
browserTimeZone: "Not/AZone",
locale: "not a tag",
language: null,
}),
).toEqual({ timeZone: "UTC" });
).toEqual({});
expect(onboardingProfileUpdate({ browserTimeZone: "UTC" })).toEqual({ timeZone: "UTC" });
});
});

View File

@ -1,73 +1,19 @@
import type { UserFactCategory } from "@rakazo/contracts";
import { isLanguageTag, isTimeZone } from "@rakazo/contracts";
export interface OnboardingAnswers {
name: string;
timezone: string;
hobbies: string[];
workStyle: string;
communication: string;
}
// Stored as data for the user's bots, so these stay plain English statements.
const HOBBY_FACTS: Record<string, string> = {
coding: "Interested in coding",
design: "Interested in design",
writing: "Interested in writing",
music: "Interested in music",
gaming: "Interested in gaming",
reading: "Interested in reading",
sports: "Interested in sports",
cooking: "Interested in cooking",
tech: "Interested in tech and gadgets",
};
const WORK_STYLE_FACTS: Record<string, string> = {
structured: "Works in a structured, planned way",
flexible: "Works flexibly and spontaneously",
"deep-dive": "Prefers focused deep dives",
quick: "Works quickly and pragmatically",
};
const COMMUNICATION_STYLES: Record<string, string> = {
direct: "Direct and concise",
detailed: "Detailed and explanatory",
balanced: "Balanced",
casual: "Casual and friendly",
};
/** Turns onboarding answers into one profile update; empty or unknown answers are left out. */
/**
* The profile fields onboarding fills in without asking: the browser's time zone and locale,
* and the language the user picked for their Oos. The name comes from the account.
*/
export function onboardingProfileUpdate(input: {
answers: OnboardingAnswers;
/** The account name, which only changes when the user typed a different one. */
accountName?: string;
/** Used when the typed time zone is empty or unknown. */
browserTimeZone: string;
/** Browser locale, e.g. "de-AT". */
locale?: string;
/** Language the user picked for their Oos, when they picked one. */
language?: string | null;
}) {
const { answers } = input;
const name = answers.name.trim();
const typedZone = answers.timezone.trim();
const workStyle = WORK_STYLE_FACTS[answers.workStyle];
const facts: Array<{ category: UserFactCategory; value: string }> = [
...answers.hobbies.flatMap((hobby) => {
const value = HOBBY_FACTS[hobby];
return value ? [{ category: "preferences" as const, value }] : [];
}),
...(workStyle ? [{ category: "habits" as const, value: workStyle }] : []),
];
const communicationStyle = COMMUNICATION_STYLES[answers.communication];
return {
...(name && name !== input.accountName?.trim() ? { name } : {}),
timeZone: typedZone && isTimeZone(typedZone) ? typedZone : input.browserTimeZone,
...(isTimeZone(input.browserTimeZone) ? { timeZone: input.browserTimeZone } : {}),
...(input.locale && isLanguageTag(input.locale) ? { locale: input.locale } : {}),
...(input.language && isLanguageTag(input.language) ? { language: input.language } : {}),
...(communicationStyle ? { communicationStyle } : {}),
...(facts.length > 0
? { addFacts: facts.map((fact) => ({ ...fact, source: "onboarding" as const })) }
: {}),
};
}

View File

@ -0,0 +1,28 @@
import { describe, expect, it, vi } from "vitest";
import { routineName, routineSaveName } from "./routine-name.js";
const labels = vi.hoisted(() => ({ locale: "en" }));
vi.mock("@lingui/core/macro", () => ({
// Stands in for a German catalog when the test switches the locale.
t: (parts: TemplateStringsArray) =>
labels.locale === "de" && parts.join("") === "Evening check-in"
? "Abend-Check-in"
: parts.join(""),
}));
describe("check-in routine names", () => {
it("shows a check-in under its own label until the user renames it", () => {
labels.locale = "de";
expect(routineName({ name: "Evening check-in", coachKey: "evening" })).toBe("Abend-Check-in");
expect(routineName({ name: "Wind down", coachKey: "evening" })).toBe("Wind down");
expect(routineName({ name: "Evening check-in", coachKey: null })).toBe("Evening check-in");
});
it("saves the stored name when the shown one was left as it is", () => {
labels.locale = "de";
const routine = { name: "Evening check-in", coachKey: "evening" as const };
expect(routineSaveName(routine, "Abend-Check-in")).toBe("Evening check-in");
expect(routineSaveName(routine, "Wind down")).toBe("Wind down");
expect(routineSaveName(null, "New")).toBe("New");
});
});

View File

@ -0,0 +1,27 @@
import { t } from "@lingui/core/macro";
import type { CoachRoutineKey, Routine } from "@rakazo/contracts";
import { COACH_ROUTINE_NAMES } from "@rakazo/contracts";
const COACH_ROUTINE_LABELS: Record<CoachRoutineKey, () => string> = {
morning: () => t`Morning brief`,
evening: () => t`Evening check-in`,
weekly: () => t`Weekly review`,
};
/**
* A check-in routine is stored under its English name; the user sees it in their language
* until they rename it.
*/
export function routineName(routine: Pick<Routine, "name" | "coachKey">): string {
return routine.coachKey && routine.name === COACH_ROUTINE_NAMES[routine.coachKey]
? COACH_ROUTINE_LABELS[routine.coachKey]()
: routine.name;
}
/** The name to save: a check-in name the user left as shown keeps its stored form. */
export function routineSaveName(
routine: Pick<Routine, "name" | "coachKey"> | null,
draftName: string,
): string {
return routine && draftName === routineName(routine) ? routine.name : draftName;
}

View File

@ -3,8 +3,12 @@ import { describe, expect, it } from "vitest";
import {
displayLanguageTag,
displayRegionTag,
factSpaceNames,
filledProfileFields,
groupUserFacts,
toggleFactSpace,
userFactDraft,
userFactEdit,
} from "./user-profile-view";
function fact(overrides: Partial<UserFact> & Pick<UserFact, "id">): UserFact {
@ -74,3 +78,43 @@ describe("user profile view", () => {
expect(displayRegionTag("not a tag", "en")).toBe("not a tag");
});
});
describe("user fact editing", () => {
const tea = fact({ id: "tea", value: "Prefers tea", category: "preferences" });
const limited = fact({ id: "lin", allSpaces: false, spaceIds: ["space-b", "space-a"] });
it("sends only what changed, or nothing", () => {
expect(userFactEdit(tea, userFactDraft(tea))).toBeNull();
expect(userFactEdit(tea, { ...userFactDraft(tea), value: " Prefers tea " })).toBeNull();
expect(
userFactEdit(limited, { ...userFactDraft(limited), spaceIds: ["space-a", "space-b"] }),
).toBeNull();
expect(
userFactEdit(tea, { value: " Prefers green tea ", category: "habits", spaceIds: null }),
).toEqual({ id: "tea", value: "Prefers green tea", category: "habits" });
expect(userFactEdit(tea, { ...userFactDraft(tea), spaceIds: ["space-a"] })).toEqual({
id: "tea",
spaceIds: ["space-a"],
});
expect(userFactEdit(limited, { ...userFactDraft(limited), spaceIds: null })).toEqual({
id: "lin",
spaceIds: null,
});
});
it("limits a fact to checked spaces and shows it everywhere when none is left", () => {
expect(toggleFactSpace(null, "space-a", true)).toEqual(["space-a"]);
expect(toggleFactSpace(["space-a"], "space-b", true)).toEqual(["space-a", "space-b"]);
expect(toggleFactSpace(["space-a", "space-b"], "space-a", false)).toEqual(["space-b"]);
expect(toggleFactSpace(["space-a"], "space-a", false)).toBeNull();
});
it("names the spaces of a limited fact", () => {
const spaces = [
{ id: "space-a", name: "Home" },
{ id: "space-b", name: "Side project" },
];
expect(factSpaceNames(["space-b", "gone"], spaces)).toEqual(["Side project"]);
expect(factSpaceNames(null, spaces)).toEqual([]);
});
});

View File

@ -1,6 +1,15 @@
import type { UserFact, UserFactCategory, UserProfile } from "@rakazo/contracts";
import type { UpdateUserProfile, UserFact, UserFactCategory, UserProfile } from "@rakazo/contracts";
import { USER_FACT_CATEGORIES } from "@rakazo/contracts";
/** What the fact editor holds: null spaces means every space. */
export interface UserFactDraft {
value: string;
category: UserFactCategory;
spaceIds: string[] | null;
}
type UserFactEdit = NonNullable<UpdateUserProfile["editFacts"]>[number];
export type ProfileField = "timeZone" | "language" | "locale" | "communicationStyle" | "summary";
const PROFILE_FIELDS: ProfileField[] = [
@ -75,3 +84,49 @@ export function displayRegionTag(tag: string, uiLocale: string): string {
return tag;
}
}
export function userFactDraft(fact: UserFact): UserFactDraft {
return {
value: fact.value,
category: fact.category,
spaceIds: fact.allSpaces ? null : fact.spaceIds,
};
}
/** The `editFacts` entry for what changed in the draft, or null when nothing did. */
export function userFactEdit(fact: UserFact, draft: UserFactDraft): UserFactEdit | null {
const edit: UserFactEdit = { id: fact.id };
const value = draft.value.trim();
if (value !== fact.value) edit.value = value;
if (draft.category !== fact.category) edit.category = draft.category;
const before = userFactDraft(fact).spaceIds;
const sameSpaces =
before === null || draft.spaceIds === null
? before === draft.spaceIds
: before.length === draft.spaceIds.length &&
draft.spaceIds.every((spaceId) => before.includes(spaceId));
if (!sameSpaces) edit.spaceIds = draft.spaceIds;
return Object.keys(edit).length > 1 ? edit : null;
}
/** Checks or unchecks one space; unchecking the last one shows the fact everywhere again. */
export function toggleFactSpace(
spaceIds: string[] | null,
spaceId: string,
checked: boolean,
): string[] | null {
const current = spaceIds ?? [];
const next = checked
? [...current.filter((id) => id !== spaceId), spaceId]
: current.filter((id) => id !== spaceId);
return next.length > 0 ? next : null;
}
/** Names of the spaces a limited fact shows in, in the order of `spaces`; unknown ids are skipped. */
export function factSpaceNames(
spaceIds: string[] | null,
spaces: ReadonlyArray<{ id: string; name: string }>,
): string[] {
if (!spaceIds) return [];
return spaces.filter((space) => spaceIds.includes(space.id)).map((space) => space.name);
}

View File

@ -0,0 +1,102 @@
import { desktopBridge } from "./desktop";
import { rpc } from "./rpc";
const SERVICE_WORKER_URL = "/sw.js";
const SERVICE_WORKER_SCOPE = "/";
/**
* Web push needs the Push API, a service worker and a secure context (https or localhost). The
* desktop app keeps its in-app notifications instead.
*/
export function webPushSupported(): boolean {
return (
typeof window !== "undefined" &&
window.isSecureContext &&
"serviceWorker" in navigator &&
"PushManager" in window &&
"Notification" in window &&
!desktopBridge()
);
}
/** Registers the push service worker where web push works; safe to call on every start. */
export async function registerPushServiceWorker(): Promise<void> {
if (!webPushSupported()) return;
await navigator.serviceWorker
.register(SERVICE_WORKER_URL, { scope: SERVICE_WORKER_SCOPE })
.catch(() => undefined);
}
async function currentSubscription(): Promise<PushSubscription | null> {
const registration = await navigator.serviceWorker.getRegistration(SERVICE_WORKER_SCOPE);
return (await registration?.pushManager.getSubscription()) ?? null;
}
/** True when this browser has granted notifications and holds a push subscription. */
export async function webPushEnabled(): Promise<boolean> {
if (!webPushSupported() || Notification.permission !== "granted") return false;
return Boolean(await currentSubscription().catch(() => null));
}
function base64UrlToBytes(value: string): Uint8Array<ArrayBuffer> {
const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
const binary = atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, "="));
const bytes = new Uint8Array(new ArrayBuffer(binary.length));
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
return bytes;
}
function sameKey(current: ArrayBuffer | null, expected: Uint8Array): boolean {
if (!current || current.byteLength !== expected.byteLength) return false;
const bytes = new Uint8Array(current);
return bytes.every((byte, index) => byte === expected[index]);
}
/**
* Turns on push for this browser and registers it with the server. Call it straight from a click:
* browsers only show the permission prompt during a user gesture. Throws when the server cannot
* be reached.
*/
export async function enableWebPush(): Promise<"enabled" | "denied" | "unsupported"> {
if (!webPushSupported()) return "unsupported";
const permission =
Notification.permission === "default"
? await Notification.requestPermission()
: Notification.permission;
if (permission !== "granted") return "denied";
const config = await rpc.notifications.webPush.config();
if (!config.available || !config.publicKey) return "unsupported";
const applicationServerKey = base64UrlToBytes(config.publicKey);
let subscription: PushSubscription;
try {
await navigator.serviceWorker.register(SERVICE_WORKER_URL, { scope: SERVICE_WORKER_SCOPE });
const registration = await navigator.serviceWorker.ready;
const existing = await registration.pushManager.getSubscription();
// A subscription made for other server keys cannot receive this server's pushes.
if (existing && !sameKey(existing.options.applicationServerKey, applicationServerKey)) {
await existing.unsubscribe();
}
subscription =
existing && sameKey(existing.options.applicationServerKey, applicationServerKey)
? existing
: await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey });
} catch {
// Browsers without a push service (or with it switched off) reject the subscription.
return "unsupported";
}
const keys = subscription.toJSON().keys ?? {};
await rpc.notifications.webPush.subscribe({
endpoint: subscription.endpoint,
keys: { p256dh: keys.p256dh ?? "", auth: keys.auth ?? "" },
});
return "enabled";
}
/** Stops push for this browser, on the server first so no push arrives after it returns. */
export async function disableWebPush(): Promise<void> {
if (!webPushSupported()) return;
const subscription = await currentSubscription();
if (!subscription) return;
await rpc.notifications.webPush.unsubscribe({ endpoint: subscription.endpoint });
await subscription.unsubscribe().catch(() => undefined);
}

View File

@ -21,6 +21,11 @@ msgstr " (ungelesen)"
msgid "(optional)"
msgstr "(optional)"
#. placeholder {0}: habit.streak
#: src/components/heute/HeuteCoach.tsx
msgid "{0, plural, one {# day} other {# days}}"
msgstr "{0, plural, one {# Tag} other {# Tage}}"
#. placeholder {0}: group.entries.length
#: src/pages/ModelSettingsOverlay.tsx
msgid "{0, plural, one {# model} other {# models}}"
@ -31,6 +36,11 @@ msgstr "{0, plural, one {# Modell} other {# Modelle}}"
msgid "{0, plural, one {# more waiting} other {# more waiting}}"
msgstr "{0, plural, one {# weiteres wartet} other {# weitere warten}}"
#. placeholder {0}: habit.streak
#: src/components/heute/HeuteCoach.tsx
msgid "{0, plural, one {# week} other {# weeks}}"
msgstr "{0, plural, one {# Woche} other {# Wochen}}"
#. placeholder {0}: file.name
#: src/pages/Shell.tsx
msgid "{0} (max {ATTACHMENT_MAX_COUNT} attachments)"
@ -281,10 +291,6 @@ msgstr "1:1-Unterhaltung"
msgid "1:1 conversation with {title}"
msgstr "1:1-Unterhaltung mit {title}"
#: src/pages/Onboarding.tsx
msgid "A few quick preferences so your Oo can be better tailored to you."
msgstr "Ein paar kurze Vorlieben, damit dein Oo besser zu dir passt."
#: src/pages/shell/bot-picker.tsx
msgid "About groups"
msgstr "Über Gruppen"
@ -350,6 +356,7 @@ msgstr "Aktives Modell"
msgid "Activity"
msgstr "Aktivität"
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/pages/PluginsOverlay.tsx
#: src/pages/ScratchpadSection.tsx
msgid "Add"
@ -387,6 +394,10 @@ msgstr "Gib eine HTTPS-Server-URL ein."
msgid "Add another"
msgstr "Weiteres hinzufügen"
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Add as projects"
msgstr "Als Projekte hinzufügen"
#: src/pages/PluginsOverlay.tsx
msgid "Add Executor"
msgstr "Executor hinzufügen"
@ -428,6 +439,10 @@ msgstr "Server hinzufügen"
msgid "Add server URL"
msgstr "Server-URL hinzufügen"
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Add to About you"
msgstr "Zu „Über dich“ hinzufügen"
#: src/components/teach/SkillDraftCard.tsx
msgid "Add to routine"
msgstr "Zur Routine hinzufügen"
@ -494,6 +509,26 @@ msgstr "Alle Daten lokal gespeichert — keine externen Server"
msgid "All quiet"
msgstr "Alles ruhig"
#: src/lib/onboarding-copy.ts
msgid "All set. I’ll check in on {weekday} at {time}."
msgstr "Alles klar. Ich melde mich am {weekday} um {time}."
#: src/lib/onboarding-copy.ts
msgid "All set. I’ll check in today at {time}."
msgstr "Alles klar. Ich melde mich heute um {time}."
#: src/lib/onboarding-copy.ts
msgid "All set. I’ll check in tomorrow at {time}."
msgstr "Alles klar. Ich melde mich morgen um {time}."
#: src/lib/onboarding-copy.ts
msgid "All set. I’m here whenever you need me."
msgstr "Alles klar. Ich bin da, wann immer du mich brauchst."
#: src/components/UserFactEditor.tsx
msgid "All spaces"
msgstr "Alle Spaces"
#. placeholder {0}: rule.matchValue
#: src/components/ApprovalRulesSettings.tsx
msgid "Allow {0} actions without asking"
@ -700,10 +735,6 @@ msgstr "Autorisieren"
msgid "Authorize this server so agents can use its tools. A popup opens."
msgstr "Autorisiere diesen Server, damit Agents seine Tools nutzen können. Ein Pop-up öffnet sich."
#: src/pages/Onboarding.tsx
msgid "Auto-detected or enter manually"
msgstr "Automatisch erkannt oder manuell eingeben"
#: src/pages/ExternalConversationSettings.tsx
msgid "Automated senders"
msgstr "Automatische Absender"
@ -740,10 +771,6 @@ msgstr "Zurück zur Anmeldung"
msgid "Back to timeline"
msgstr "Zurück zur Zeitleiste"
#: src/pages/Onboarding.tsx
msgid "Balanced"
msgstr "Ausgewogen"
#: src/pages/memory-providers/SupermemorySettingsForm.tsx
msgid "Base URL"
msgstr "Basis-URL"
@ -753,6 +780,10 @@ msgstr "Basis-URL"
msgid "Bearer token"
msgstr "Bearer-Token"
#: src/components/NotificationSettingsPanel.tsx
msgid "Blocked in your browser settings"
msgstr "In deinen Browser-Einstellungen blockiert"
#: src/pages/Shell.tsx
msgid "Booting live desktop…"
msgstr "Live-Desktop wird gestartet…"
@ -821,6 +852,10 @@ msgstr "Ruhige Hinweistöne für erledigte Arbeit, Sprachphasen und Übergaben.
msgid "Calm helper"
msgstr "Ruhiger Helfer"
#: src/lib/onboarding-copy.ts
msgid "Can I send you notifications?"
msgstr "Darf ich dir Benachrichtigungen schicken?"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Can speak"
msgstr "Kann sprechen"
@ -833,6 +868,7 @@ msgstr "Server nicht erreichbar."
#: src/components/ComputerMaintenanceActions.tsx
#: src/components/ComputerUpdateProgress.tsx
#: src/components/teach/TeachComputerOverlay.tsx
#: src/components/UserFactEditor.tsx
#: src/components/UserProfileSettings.tsx
#: src/pages/KnowledgeSection.tsx
#: src/pages/PluginsOverlay.tsx
@ -867,9 +903,9 @@ msgstr "Abgebrochen"
msgid "Cancelled"
msgstr "Abgebrochen"
#: src/pages/Onboarding.tsx
msgid "Casual & friendly"
msgstr "Locker & freundlich"
#: src/components/UserFactEditor.tsx
msgid "Category"
msgstr "Kategorie"
#: src/pages/AccountSettingsOverlay.tsx
msgid "Change password"
@ -925,6 +961,10 @@ msgstr "Melde dich."
msgid "Check your email"
msgstr "Prüfe deine E-Mails"
#: src/components/heute/CoachRhythmControl.tsx
msgid "Check-ins"
msgstr "Check-ins"
#: src/lib/voice-persona-copy.ts
msgid "Checking dependencies…"
msgstr "Prüfe Abhängigkeiten…"
@ -1134,7 +1174,6 @@ msgid "Command"
msgstr "Befehl"
#: src/components/UserProfileSettings.tsx
#: src/pages/Onboarding.tsx
msgid "Communication style"
msgstr "Kommunikationsstil"
@ -1295,8 +1334,8 @@ msgstr "Kontextlimit"
#: src/components/integrations/IntegrationSetup.tsx
#: src/components/onboarding/OnboardingOoDesigner.tsx
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/pages/Onboarding.tsx
msgid "Continue"
msgstr "Weiter"
@ -1522,6 +1561,10 @@ msgstr "Update-Status konnte nicht geladen werden"
msgid "Could not load voice settings"
msgstr "Stimmeinstellungen konnten nicht geladen werden"
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Could not look in this folder."
msgstr "In diesem Ordner konnte nicht gesucht werden."
#: src/pages/LocalSettings.tsx
msgid "Could not open local settings. Start the local server and create its owner account, then try again."
msgstr "Lokale Einstellungen konnten nicht geöffnet werden. Starte den lokalen Server, erstelle sein Besitzerkonto und versuche es dann erneut."
@ -1674,6 +1717,7 @@ msgstr "Standardbereich für Erinnerungen konnte nicht aktualisiert werden"
msgid "Could not update voice"
msgstr "Stimme konnte nicht aktualisiert werden"
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
msgid "Couldn't load"
msgstr "Konnte nicht geladen werden"
@ -1682,6 +1726,7 @@ msgstr "Konnte nicht geladen werden"
msgid "Couldn't load messaging settings"
msgstr "Messaging-Einstellungen konnten nicht geladen werden"
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
msgid "Couldn't save"
msgstr "Konnte nicht gespeichert werden"
@ -1823,6 +1868,10 @@ msgstr "Datenbank"
msgid "day"
msgstr "Tag"
#: src/lib/onboarding-copy.ts
msgid "Day-to-day work"
msgstr "Tagesgeschäft"
#: src/pages/RoutineSchedule.tsx
msgid "days"
msgstr "Tage"
@ -1831,10 +1880,6 @@ msgstr "Tage"
msgid "Decline"
msgstr "Ablehnen"
#: src/pages/Onboarding.tsx
msgid "Deep-dive focused"
msgstr "Fokussiert in die Tiefe"
#: src/pages/ModelSettingsOverlay.tsx
msgid "Default"
msgstr "Standard"
@ -1935,18 +1980,10 @@ msgstr "Desktop-App"
msgid "Desktop update"
msgstr "Desktop-Update"
#: src/pages/Onboarding.tsx
msgid "Detailed & explanatory"
msgstr "Ausführlich & erklärend"
#: src/components/browser/InAppBrowser.tsx
msgid "Developer tools"
msgstr "Entwicklertools"
#: src/pages/Onboarding.tsx
msgid "Direct & concise"
msgstr "Direkt & knapp"
#: src/components/integrations/IntegrationSetup.tsx
msgid "Direct MCP"
msgstr "Direktes MCP"
@ -2066,6 +2103,10 @@ msgstr "z. B. Hivenet Agent, Presales, Zeiterfassungs-Bot"
msgid "Earlier message"
msgstr "Frühere Nachricht"
#: src/components/UserProfileSettings.tsx
msgid "Edit"
msgstr "Bearbeiten"
#: src/components/AskCard.tsx
msgid "Edit first"
msgstr "Zuerst bearbeiten"
@ -2115,6 +2156,14 @@ msgstr "Gib einen absoluten Ordnerpfad auf dem Server ein. Git-Remotes werden au
msgid "Enter this code at <0>{0}</0>"
msgstr "Gib diesen Code unter <0>{0}</0> ein"
#: src/components/heute/CoachRhythmControl.tsx
msgid "Evening"
msgstr "Abends"
#: src/lib/routine-name.ts
msgid "Evening check-in"
msgstr "Abend-Check-in"
#: src/pages/ExternalConversationSettings.tsx
#: src/pages/RoutineSchedule.tsx
msgid "Every"
@ -2143,6 +2192,10 @@ msgstr "Jeden Montag"
msgid "Every month"
msgstr "Jeden Monat"
#: src/lib/onboarding-copy.ts
msgid "Every morning"
msgstr "Jeden Morgen"
#: src/pages/RoutineEditor.tsx
#: src/pages/RoutineSchedule.tsx
msgid "Every week"
@ -2183,6 +2236,10 @@ msgstr "Externe Unterhaltung"
msgid "Extra high"
msgstr "Sehr hoch"
#: src/components/UserFactEditor.tsx
msgid "Fact"
msgstr "Fakt"
#: src/components/CloudAgentCard.tsx
msgid "failed"
msgstr "fehlgeschlagen"
@ -2256,10 +2313,6 @@ msgstr "Erste {shownRows} Zeilen"
msgid "Flag unexpected actions"
msgstr "Unerwartete Aktionen markieren"
#: src/pages/Onboarding.tsx
msgid "Flexible & spontaneous"
msgstr "Flexibel & spontan"
#: src/pages/PrivacySettingsOverlay.tsx
msgid "Fonts loaded locally"
msgstr "Schriften lokal geladen"
@ -2324,6 +2377,7 @@ msgstr "Git-Ereignis"
msgid "Goal"
msgstr "Ziel"
#: src/components/heute/HeuteCoach.tsx
#: src/components/UserProfileSettings.tsx
#: src/pages/ProjectSessionTimeline.tsx
msgid "Goals"
@ -2360,6 +2414,7 @@ msgstr "Updates bündeln"
msgid "Groups"
msgstr "Gruppen"
#: src/components/heute/HeuteCoach.tsx
#: src/components/UserProfileSettings.tsx
msgid "Habits"
msgstr "Gewohnheiten"
@ -2434,10 +2489,6 @@ msgstr "Passwort verbergen"
msgid "High"
msgstr "Hoch"
#: src/pages/Onboarding.tsx
msgid "Hobbies & interests"
msgstr "Hobbys & Interessen"
#: src/components/onboarding/OnboardingOoDesigner.tsx
msgid "Hobby"
msgstr "Hobby"
@ -2451,10 +2502,6 @@ msgstr "Stunde"
msgid "hours"
msgstr "Stunden"
#: src/pages/Onboarding.tsx
msgid "How do you like to work?"
msgstr "Wie arbeitest du am liebsten?"
#: src/pages/RoutineSchedule.tsx
msgid "How often"
msgstr "Wie oft"
@ -2463,10 +2510,6 @@ msgstr "Wie oft"
msgid "How should your Oo sound when reading replies aloud?"
msgstr "Wie soll dein Oo klingen, wenn es Antworten vorliest?"
#: src/pages/Onboarding.tsx
msgid "How should your Oo talk to you?"
msgstr "Wie soll dein Oo mit dir sprechen?"
#: src/components/teach/SkillDraftCard.tsx
msgid "How to check"
msgstr "So wird geprüft"
@ -2507,6 +2550,10 @@ msgstr "In meinem Fachgebiet — ich gehe gern ins Detail oder setze einfach um,
msgid "In progress"
msgstr "In Arbeit"
#: src/lib/onboarding-copy.ts
msgid "Inbox & email"
msgstr "Posteingang & E-Mail"
#: src/pages/shell/bot-panel.tsx
msgid "Inherit default"
msgstr "Standard übernehmen"
@ -2730,6 +2777,10 @@ msgstr "Einstellungen des lokalen Servers"
msgid "Log out"
msgstr "Abmelden"
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Look"
msgstr "Suchen"
#. placeholder {0}: index + 1
#. placeholder {1}: looks.length
#: src/components/onboarding/LookPreviewCarousel.tsx
@ -2740,10 +2791,18 @@ msgstr "Look {0} von {1}"
msgid "Look families"
msgstr "Look-Familien"
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Look for projects here?"
msgstr "Hier nach Projekten suchen?"
#: src/components/onboarding/LookPreviewCarousel.tsx
msgid "Look variants"
msgstr "Look-Varianten"
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Looking"
msgstr "Suche läuft"
#: src/pages/ModelSettingsOverlay.tsx
#: src/pages/shell/bot-panel.tsx
msgid "Low"
@ -2904,10 +2963,6 @@ msgstr "Minute"
msgid "minutes"
msgstr "Minuten"
#: src/pages/HeuteBrief.tsx
msgid "Mission Control"
msgstr "Mission Control"
#: src/pages/memory-providers/BuiltinSettingsForm.tsx
#: src/pages/ModelSettingsOverlay.tsx
#: src/pages/shell/bot-panel.tsx
@ -2964,6 +3019,18 @@ msgstr "Weitere Computeraktionen"
msgid "More conversations ({hiddenSessionCount})"
msgstr "Mehr Unterhaltungen ({hiddenSessionCount})"
#: src/components/heute/CoachRhythmControl.tsx
msgid "Morning"
msgstr "Morgens"
#: src/lib/onboarding-copy.ts
msgid "Morning and evening"
msgstr "Morgens und abends"
#: src/lib/routine-name.ts
msgid "Morning brief"
msgstr "Morgenbriefing"
#: src/pages/shell/dialogs.tsx
msgid "Move them to your shared memory."
msgstr "Verschiebe sie in deine geteilten Erinnerungen."
@ -2975,7 +3042,6 @@ msgstr "In Ordner verschieben"
#: src/components/teach/SkillDraftCard.tsx
#: src/pages/Auth.tsx
#: src/pages/GroupPanel.tsx
#: src/pages/Onboarding.tsx
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/bot-panel.tsx
#: src/pages/shell/dialogs.tsx
@ -3154,6 +3220,10 @@ msgstr "Noch keine Nachrichten mit {peerBotName}."
msgid "No model catalog is available."
msgstr "Kein Modellkatalog verfügbar."
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "No projects found"
msgstr "Keine Projekte gefunden"
#: src/pages/Shell.tsx
msgid "No projects yet."
msgstr "Noch keine Projekte."
@ -3211,6 +3281,7 @@ msgstr "Nicht verbunden"
msgid "Not in the plugin catalog"
msgstr "Nicht im Plugin-Katalog"
#: src/lib/onboarding-copy.ts
#: src/pages/shell/message-cards.tsx
msgid "Not now"
msgstr "Jetzt nicht"
@ -3223,14 +3294,11 @@ msgstr "Nichts läuft mehr"
msgid "Nothing remembered yet"
msgstr "Noch nichts gemerkt"
#: src/pages/Onboarding.tsx
msgid "Nothing specific"
msgstr "Nichts Bestimmtes"
#: src/components/UserProfileSettings.tsx
msgid "Nothing yet"
msgstr "Noch nichts"
#: src/pages/SettingsOverlay.tsx
#: src/pages/shell/bot-panel.tsx
msgid "Notifications"
msgstr "Benachrichtigungen"
@ -3244,6 +3312,10 @@ msgstr "Jetzt"
msgid "Now using {0}."
msgstr "Jetzt wird {0} verwendet."
#: src/components/NotificationSettingsPanel.tsx
msgid "Nudges from your Oo"
msgstr "Anstöße von deinem Oo"
#: src/pages/McpOAuthCallback.tsx
msgid "OAuth authorization was cancelled."
msgstr "OAuth-Autorisierung wurde abgebrochen."
@ -3277,6 +3349,10 @@ msgstr "am 1. um {0}"
msgid "Onboarding"
msgstr "Onboarding"
#: src/lib/onboarding-copy.ts
msgid "Once a week"
msgstr "Einmal pro Woche"
#: src/lib/voice-persona-copy.ts
msgid "One more look before I answer…"
msgstr "Noch ein Blick, bevor ich antworte…"
@ -3285,6 +3361,10 @@ msgstr "Noch ein Blick, bevor ich antworte…"
msgid "Only empty spaces can be deleted. Delete its bots and groups first."
msgstr "Nur leere Spaces können gelöscht werden. Lösche zuerst die Bots und Gruppen darin."
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Only file and folder names are read."
msgstr "Gelesen werden nur Datei- und Ordnernamen."
#: src/pages/ScratchpadSection.tsx
#: src/pages/Shell.tsx
msgid "Open"
@ -3536,6 +3616,10 @@ msgstr "Anbieter"
msgid "Pull request"
msgstr "Pull Request"
#: src/components/NotificationSettingsPanel.tsx
msgid "Push on this device"
msgstr "Push auf diesem Gerät"
#: src/components/ai/DelegatedTaskCard.tsx
#: src/pages/ProjectSessionTimeline.tsx
msgid "Queued"
@ -3546,14 +3630,22 @@ msgstr "In Warteschlange"
msgid "Queued…"
msgstr "Wartet…"
#: src/pages/Onboarding.tsx
msgid "Quick & pragmatic"
msgstr "Schnell & pragmatisch"
#: src/pages/HeuteBrief.tsx
msgid "Quiet day so far"
msgstr "Bisher ein ruhiger Tag"
#: src/components/NotificationSettingsPanel.tsx
msgid "Quiet hours"
msgstr "Ruhezeiten"
#: src/components/NotificationSettingsPanel.tsx
msgid "Quiet hours end"
msgstr "Ende der Ruhezeiten"
#: src/components/NotificationSettingsPanel.tsx
msgid "Quiet hours start"
msgstr "Beginn der Ruhezeiten"
#: src/pages/Shell.tsx
msgid "Quote"
msgstr "Zitieren"
@ -3773,6 +3865,10 @@ msgstr "Antwort an {replyName}: „{replyQuote}“"
msgid "Reporting from home…"
msgstr "Melde mich von zu Hause…"
#: src/lib/onboarding-copy.ts
msgid "Research & writing"
msgstr "Recherche & Schreiben"
#: src/pages/shell/bot-capabilities.tsx
msgid "research, writing"
msgstr "Recherche, Schreiben"
@ -3912,6 +4008,7 @@ msgstr "Sandbox"
#: src/components/AskCard.tsx
#: src/components/teach/SkillDraftCard.tsx
#: src/components/UserFactEditor.tsx
#: src/pages/ExternalConversationSettings.tsx
#: src/pages/GroupPanel.tsx
#: src/pages/KnowledgeSection.tsx
@ -4012,15 +4109,12 @@ msgstr "Suche…"
msgid "Select {0}"
msgstr "{0} auswählen"
#: src/pages/Onboarding.tsx
msgid "Select all that apply"
msgstr "Wähle alles Zutreffende aus"
#: src/pages/PrivacySettingsOverlay.tsx
msgid "Self-hosted (better-auth)"
msgstr "Selbst gehostet (better-auth)"
#: src/pages/Shell.tsx
#: src/pages/shell/message-cards.tsx
msgid "Send"
msgstr "Senden"
@ -4184,7 +4278,9 @@ msgid "Skills"
msgstr "Skills"
#: src/components/integrations/IntegrationSetup.tsx
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/lib/onboarding-copy.ts
#: src/pages/Shell.tsx
msgid "Skip"
msgstr "Überspringen"
@ -4269,6 +4365,10 @@ msgstr "Vorlesen an oder aus"
msgid "Speaking with intent…"
msgstr "Spreche mit Bedacht…"
#: src/lib/onboarding-copy.ts
msgid "Specialist crew"
msgstr "Spezialisten-Team"
#: src/lib/model-tier-label.ts
msgid "Standard"
msgstr "Standard"
@ -4362,10 +4462,6 @@ msgstr "Stresstest fürs Portfolio…"
msgid "Stronger together — {0} specialists"
msgstr "Stärker zusammen — {0} Spezialisten"
#: src/pages/Onboarding.tsx
msgid "Structured & planned"
msgstr "Strukturiert & geplant"
#: src/pages/Shell.tsx
msgid "subagent"
msgstr "Subagent"
@ -4451,10 +4547,6 @@ msgstr "Teams nicht aktiviert"
msgid "Tell me what matters. I'll move."
msgstr "Sag mir, was zählt. Ich lege los."
#: src/pages/Onboarding.tsx
msgid "Tell us about yourself"
msgstr "Erzähl uns von dir"
#: src/components/teach/SkillDraftCard.tsx
msgid "Test"
msgstr "Test"
@ -4545,10 +4637,6 @@ msgstr "Uhrzeit"
msgid "Time zone"
msgstr "Zeitzone"
#: src/pages/Onboarding.tsx
msgid "Timezone"
msgstr "Zeitzone"
#: src/pages/shell/bot-panel.tsx
msgid "Title"
msgstr "Titel"
@ -4603,6 +4691,10 @@ msgstr "Triage läuft…"
msgid "Try again"
msgstr "Erneut versuchen"
#: src/lib/onboarding-copy.ts
msgid "Turn on"
msgstr "Einschalten"
#: src/components/AskCard.tsx
msgid "Type your answer"
msgstr "Gib deine Antwort ein"
@ -4796,6 +4888,14 @@ msgstr "Webhook"
msgid "Weekdays"
msgstr "Wochentage"
#: src/components/heute/CoachRhythmControl.tsx
msgid "Weekly"
msgstr "Wöchentlich"
#: src/lib/routine-name.ts
msgid "Weekly review"
msgstr "Wochenrückblick"
#: src/pages/shell/dialogs.tsx
msgid "What about its memories?"
msgstr "Was soll mit seinen Erinnerungen geschehen?"
@ -4804,6 +4904,10 @@ msgstr "Was soll mit seinen Erinnerungen geschehen?"
msgid "What do you think — react or answer in a sentence."
msgstr "Was meinst du — reagiere oder antworte in einem Satz."
#: src/lib/onboarding-copy.ts
msgid "What do you want me on first?"
msgstr "Wobei soll ich dir zuerst helfen?"
#: src/components/onboarding/OnboardingOoDesigner.tsx
msgid "What feels like you?"
msgstr "Was passt zu dir?"
@ -4844,6 +4948,10 @@ msgstr "Was zurückgegeben werden soll"
msgid "What's on your mind? I'll route it — and tell you when I disagree."
msgstr "Was beschäftigt dich? Ich leite es weiter — und sage dir, wenn ich anderer Meinung bin."
#: src/lib/onboarding-copy.ts
msgid "What’s one thing you want to get done in the next few weeks?"
msgstr "Was möchtest du in den nächsten Wochen erledigt bekommen?"
#: src/lib/voice-persona-copy.ts
msgid "What's personal today?"
msgstr "Was ist heute privat dran?"
@ -4856,6 +4964,14 @@ msgstr "Um welche Aufgabe geht es?"
msgid "When a webhook fires"
msgstr "Wenn ein Webhook auslöst"
#: src/lib/onboarding-copy.ts
msgid "When should I check in with you?"
msgstr "Wann soll ich mich bei dir melden?"
#: src/lib/onboarding-copy.ts
msgid "When should I stay quiet?"
msgstr "Wann soll ich still sein?"
#: src/pages/RoutineEditor.tsx
msgid "When to run"
msgstr "Ausführungszeit"
@ -4881,10 +4997,6 @@ msgstr "Arbeit"
msgid "Work channel open — go ahead."
msgstr "Arbeitskanal offen — leg los."
#: src/pages/Onboarding.tsx
msgid "Work style"
msgstr "Arbeitsstil"
#: src/components/stage/OoStage.tsx
msgid "Working"
msgstr "Arbeitet"
@ -4941,8 +5053,11 @@ msgstr "Deine Unterhaltungen und Einstellungen verlassen nie deinen Rechner"
msgid "Your email address"
msgstr "Deine E-Mail-Adresse"
#: src/lib/onboarding-copy.ts
msgid "Your goal"
msgstr "Dein Ziel"
#: src/pages/Auth.tsx
#: src/pages/Onboarding.tsx
msgid "Your name"
msgstr "Dein Name"

View File

@ -21,6 +21,11 @@ msgstr " (unread)"
msgid "(optional)"
msgstr "(optional)"
#. placeholder {0}: habit.streak
#: src/components/heute/HeuteCoach.tsx
msgid "{0, plural, one {# day} other {# days}}"
msgstr "{0, plural, one {# day} other {# days}}"
#. placeholder {0}: group.entries.length
#: src/pages/ModelSettingsOverlay.tsx
msgid "{0, plural, one {# model} other {# models}}"
@ -31,6 +36,11 @@ msgstr "{0, plural, one {# model} other {# models}}"
msgid "{0, plural, one {# more waiting} other {# more waiting}}"
msgstr "{0, plural, one {# more waiting} other {# more waiting}}"
#. placeholder {0}: habit.streak
#: src/components/heute/HeuteCoach.tsx
msgid "{0, plural, one {# week} other {# weeks}}"
msgstr "{0, plural, one {# week} other {# weeks}}"
#. placeholder {0}: file.name
#: src/pages/Shell.tsx
msgid "{0} (max {ATTACHMENT_MAX_COUNT} attachments)"
@ -281,10 +291,6 @@ msgstr "1:1 conversation"
msgid "1:1 conversation with {title}"
msgstr "1:1 conversation with {title}"
#: src/pages/Onboarding.tsx
msgid "A few quick preferences so your Oo can be better tailored to you."
msgstr "A few quick preferences so your Oo can be better tailored to you."
#: src/pages/shell/bot-picker.tsx
msgid "About groups"
msgstr "About groups"
@ -350,6 +356,7 @@ msgstr "Active model"
msgid "Activity"
msgstr "Activity"
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/pages/PluginsOverlay.tsx
#: src/pages/ScratchpadSection.tsx
msgid "Add"
@ -387,6 +394,10 @@ msgstr "Add an HTTPS server URL."
msgid "Add another"
msgstr "Add another"
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Add as projects"
msgstr "Add as projects"
#: src/pages/PluginsOverlay.tsx
msgid "Add Executor"
msgstr "Add Executor"
@ -428,6 +439,10 @@ msgstr "Add server"
msgid "Add server URL"
msgstr "Add server URL"
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Add to About you"
msgstr "Add to About you"
#: src/components/teach/SkillDraftCard.tsx
msgid "Add to routine"
msgstr "Add to routine"
@ -494,6 +509,26 @@ msgstr "All data stored locally — no external servers"
msgid "All quiet"
msgstr "All quiet"
#: src/lib/onboarding-copy.ts
msgid "All set. I’ll check in on {weekday} at {time}."
msgstr "All set. I’ll check in on {weekday} at {time}."
#: src/lib/onboarding-copy.ts
msgid "All set. I’ll check in today at {time}."
msgstr "All set. I’ll check in today at {time}."
#: src/lib/onboarding-copy.ts
msgid "All set. I’ll check in tomorrow at {time}."
msgstr "All set. I’ll check in tomorrow at {time}."
#: src/lib/onboarding-copy.ts
msgid "All set. I’m here whenever you need me."
msgstr "All set. I’m here whenever you need me."
#: src/components/UserFactEditor.tsx
msgid "All spaces"
msgstr "All spaces"
#. placeholder {0}: rule.matchValue
#: src/components/ApprovalRulesSettings.tsx
msgid "Allow {0} actions without asking"
@ -700,10 +735,6 @@ msgstr "Authorize"
msgid "Authorize this server so agents can use its tools. A popup opens."
msgstr "Authorize this server so agents can use its tools. A popup opens."
#: src/pages/Onboarding.tsx
msgid "Auto-detected or enter manually"
msgstr "Auto-detected or enter manually"
#: src/pages/ExternalConversationSettings.tsx
msgid "Automated senders"
msgstr "Automated senders"
@ -740,10 +771,6 @@ msgstr "Back to sign in"
msgid "Back to timeline"
msgstr "Back to timeline"
#: src/pages/Onboarding.tsx
msgid "Balanced"
msgstr "Balanced"
#: src/pages/memory-providers/SupermemorySettingsForm.tsx
msgid "Base URL"
msgstr "Base URL"
@ -753,6 +780,10 @@ msgstr "Base URL"
msgid "Bearer token"
msgstr "Bearer token"
#: src/components/NotificationSettingsPanel.tsx
msgid "Blocked in your browser settings"
msgstr "Blocked in your browser settings"
#: src/pages/Shell.tsx
msgid "Booting live desktop…"
msgstr "Booting live desktop…"
@ -821,6 +852,10 @@ msgstr "Calm audio cues for finished work, voice phases, and handoffs. Respects
msgid "Calm helper"
msgstr "Calm helper"
#: src/lib/onboarding-copy.ts
msgid "Can I send you notifications?"
msgstr "Can I send you notifications?"
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Can speak"
msgstr "Can speak"
@ -833,6 +868,7 @@ msgstr "Can't reach the server."
#: src/components/ComputerMaintenanceActions.tsx
#: src/components/ComputerUpdateProgress.tsx
#: src/components/teach/TeachComputerOverlay.tsx
#: src/components/UserFactEditor.tsx
#: src/components/UserProfileSettings.tsx
#: src/pages/KnowledgeSection.tsx
#: src/pages/PluginsOverlay.tsx
@ -867,9 +903,9 @@ msgstr "Cancelled"
msgid "Cancelled"
msgstr "Cancelled"
#: src/pages/Onboarding.tsx
msgid "Casual & friendly"
msgstr "Casual & friendly"
#: src/components/UserFactEditor.tsx
msgid "Category"
msgstr "Category"
#: src/pages/AccountSettingsOverlay.tsx
msgid "Change password"
@ -925,6 +961,10 @@ msgstr "Check in."
msgid "Check your email"
msgstr "Check your email"
#: src/components/heute/CoachRhythmControl.tsx
msgid "Check-ins"
msgstr "Check-ins"
#: src/lib/voice-persona-copy.ts
msgid "Checking dependencies…"
msgstr "Checking dependencies…"
@ -1134,7 +1174,6 @@ msgid "Command"
msgstr "Command"
#: src/components/UserProfileSettings.tsx
#: src/pages/Onboarding.tsx
msgid "Communication style"
msgstr "Communication style"
@ -1295,8 +1334,8 @@ msgstr "Context limit"
#: src/components/integrations/IntegrationSetup.tsx
#: src/components/onboarding/OnboardingOoDesigner.tsx
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/pages/Onboarding.tsx
msgid "Continue"
msgstr "Continue"
@ -1522,6 +1561,10 @@ msgstr "Could not load update status"
msgid "Could not load voice settings"
msgstr "Could not load voice settings"
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Could not look in this folder."
msgstr "Could not look in this folder."
#: src/pages/LocalSettings.tsx
msgid "Could not open local settings. Start the local server and create its owner account, then try again."
msgstr "Could not open local settings. Start the local server and create its owner account, then try again."
@ -1674,6 +1717,7 @@ msgstr "Could not update the default memory scope"
msgid "Could not update voice"
msgstr "Could not update voice"
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
msgid "Couldn't load"
msgstr "Couldn't load"
@ -1682,6 +1726,7 @@ msgstr "Couldn't load"
msgid "Couldn't load messaging settings"
msgstr "Couldn't load messaging settings"
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
msgid "Couldn't save"
msgstr "Couldn't save"
@ -1823,6 +1868,10 @@ msgstr "Database"
msgid "day"
msgstr "day"
#: src/lib/onboarding-copy.ts
msgid "Day-to-day work"
msgstr "Day-to-day work"
#: src/pages/RoutineSchedule.tsx
msgid "days"
msgstr "days"
@ -1831,10 +1880,6 @@ msgstr "days"
msgid "Decline"
msgstr "Decline"
#: src/pages/Onboarding.tsx
msgid "Deep-dive focused"
msgstr "Deep-dive focused"
#: src/pages/ModelSettingsOverlay.tsx
msgid "Default"
msgstr "Default"
@ -1935,18 +1980,10 @@ msgstr "Desktop app"
msgid "Desktop update"
msgstr "Desktop update"
#: src/pages/Onboarding.tsx
msgid "Detailed & explanatory"
msgstr "Detailed & explanatory"
#: src/components/browser/InAppBrowser.tsx
msgid "Developer tools"
msgstr "Developer tools"
#: src/pages/Onboarding.tsx
msgid "Direct & concise"
msgstr "Direct & concise"
#: src/components/integrations/IntegrationSetup.tsx
msgid "Direct MCP"
msgstr "Direct MCP"
@ -2066,6 +2103,10 @@ msgstr "e.g. Hivenet Agent, Presales, Timesheets bot"
msgid "Earlier message"
msgstr "Earlier message"
#: src/components/UserProfileSettings.tsx
msgid "Edit"
msgstr "Edit"
#: src/components/AskCard.tsx
msgid "Edit first"
msgstr "Edit first"
@ -2115,6 +2156,14 @@ msgstr "Enter an absolute folder path on the server. Git remotes are linked auto
msgid "Enter this code at <0>{0}</0>"
msgstr "Enter this code at <0>{0}</0>"
#: src/components/heute/CoachRhythmControl.tsx
msgid "Evening"
msgstr "Evening"
#: src/lib/routine-name.ts
msgid "Evening check-in"
msgstr "Evening check-in"
#: src/pages/ExternalConversationSettings.tsx
#: src/pages/RoutineSchedule.tsx
msgid "Every"
@ -2143,6 +2192,10 @@ msgstr "Every Monday"
msgid "Every month"
msgstr "Every month"
#: src/lib/onboarding-copy.ts
msgid "Every morning"
msgstr "Every morning"
#: src/pages/RoutineEditor.tsx
#: src/pages/RoutineSchedule.tsx
msgid "Every week"
@ -2183,6 +2236,10 @@ msgstr "External conversation"
msgid "Extra high"
msgstr "Extra high"
#: src/components/UserFactEditor.tsx
msgid "Fact"
msgstr "Fact"
#: src/components/CloudAgentCard.tsx
msgid "failed"
msgstr "failed"
@ -2256,10 +2313,6 @@ msgstr "First {shownRows} rows"
msgid "Flag unexpected actions"
msgstr "Flag unexpected actions"
#: src/pages/Onboarding.tsx
msgid "Flexible & spontaneous"
msgstr "Flexible & spontaneous"
#: src/pages/PrivacySettingsOverlay.tsx
msgid "Fonts loaded locally"
msgstr "Fonts loaded locally"
@ -2324,6 +2377,7 @@ msgstr "Git event"
msgid "Goal"
msgstr "Goal"
#: src/components/heute/HeuteCoach.tsx
#: src/components/UserProfileSettings.tsx
#: src/pages/ProjectSessionTimeline.tsx
msgid "Goals"
@ -2360,6 +2414,7 @@ msgstr "Group updates"
msgid "Groups"
msgstr "Groups"
#: src/components/heute/HeuteCoach.tsx
#: src/components/UserProfileSettings.tsx
msgid "Habits"
msgstr "Habits"
@ -2434,10 +2489,6 @@ msgstr "Hide password"
msgid "High"
msgstr "High"
#: src/pages/Onboarding.tsx
msgid "Hobbies & interests"
msgstr "Hobbies & interests"
#: src/components/onboarding/OnboardingOoDesigner.tsx
msgid "Hobby"
msgstr "Hobby"
@ -2451,10 +2502,6 @@ msgstr "hour"
msgid "hours"
msgstr "hours"
#: src/pages/Onboarding.tsx
msgid "How do you like to work?"
msgstr "How do you like to work?"
#: src/pages/RoutineSchedule.tsx
msgid "How often"
msgstr "How often"
@ -2463,10 +2510,6 @@ msgstr "How often"
msgid "How should your Oo sound when reading replies aloud?"
msgstr "How should your Oo sound when reading replies aloud?"
#: src/pages/Onboarding.tsx
msgid "How should your Oo talk to you?"
msgstr "How should your Oo talk to you?"
#: src/components/teach/SkillDraftCard.tsx
msgid "How to check"
msgstr "How to check"
@ -2507,6 +2550,10 @@ msgstr "In my lane — happy to geek out or just execute, your call."
msgid "In progress"
msgstr "In progress"
#: src/lib/onboarding-copy.ts
msgid "Inbox & email"
msgstr "Inbox & email"
#: src/pages/shell/bot-panel.tsx
msgid "Inherit default"
msgstr "Inherit default"
@ -2730,6 +2777,10 @@ msgstr "Local Server Settings"
msgid "Log out"
msgstr "Log out"
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Look"
msgstr "Look"
#. placeholder {0}: index + 1
#. placeholder {1}: looks.length
#: src/components/onboarding/LookPreviewCarousel.tsx
@ -2740,10 +2791,18 @@ msgstr "Look {0} of {1}"
msgid "Look families"
msgstr "Look families"
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Look for projects here?"
msgstr "Look for projects here?"
#: src/components/onboarding/LookPreviewCarousel.tsx
msgid "Look variants"
msgstr "Look variants"
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Looking"
msgstr "Looking"
#: src/pages/ModelSettingsOverlay.tsx
#: src/pages/shell/bot-panel.tsx
msgid "Low"
@ -2904,10 +2963,6 @@ msgstr "minute"
msgid "minutes"
msgstr "minutes"
#: src/pages/HeuteBrief.tsx
msgid "Mission Control"
msgstr "Mission Control"
#: src/pages/memory-providers/BuiltinSettingsForm.tsx
#: src/pages/ModelSettingsOverlay.tsx
#: src/pages/shell/bot-panel.tsx
@ -2964,6 +3019,18 @@ msgstr "More computer actions"
msgid "More conversations ({hiddenSessionCount})"
msgstr "More conversations ({hiddenSessionCount})"
#: src/components/heute/CoachRhythmControl.tsx
msgid "Morning"
msgstr "Morning"
#: src/lib/onboarding-copy.ts
msgid "Morning and evening"
msgstr "Morning and evening"
#: src/lib/routine-name.ts
msgid "Morning brief"
msgstr "Morning brief"
#: src/pages/shell/dialogs.tsx
msgid "Move them to your shared memory."
msgstr "Move them to your shared memory."
@ -2975,7 +3042,6 @@ msgstr "Move to folder"
#: src/components/teach/SkillDraftCard.tsx
#: src/pages/Auth.tsx
#: src/pages/GroupPanel.tsx
#: src/pages/Onboarding.tsx
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/bot-panel.tsx
#: src/pages/shell/dialogs.tsx
@ -3154,6 +3220,10 @@ msgstr "No messages with {peerBotName} yet."
msgid "No model catalog is available."
msgstr "No model catalog is available."
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "No projects found"
msgstr "No projects found"
#: src/pages/Shell.tsx
msgid "No projects yet."
msgstr "No projects yet."
@ -3211,6 +3281,7 @@ msgstr "Not connected"
msgid "Not in the plugin catalog"
msgstr "Not in the plugin catalog"
#: src/lib/onboarding-copy.ts
#: src/pages/shell/message-cards.tsx
msgid "Not now"
msgstr "Not now"
@ -3223,14 +3294,11 @@ msgstr "Nothing is still running"
msgid "Nothing remembered yet"
msgstr "Nothing remembered yet"
#: src/pages/Onboarding.tsx
msgid "Nothing specific"
msgstr "Nothing specific"
#: src/components/UserProfileSettings.tsx
msgid "Nothing yet"
msgstr "Nothing yet"
#: src/pages/SettingsOverlay.tsx
#: src/pages/shell/bot-panel.tsx
msgid "Notifications"
msgstr "Notifications"
@ -3244,6 +3312,10 @@ msgstr "Now"
msgid "Now using {0}."
msgstr "Now using {0}."
#: src/components/NotificationSettingsPanel.tsx
msgid "Nudges from your Oo"
msgstr "Nudges from your Oo"
#: src/pages/McpOAuthCallback.tsx
msgid "OAuth authorization was cancelled."
msgstr "OAuth authorization was cancelled."
@ -3277,6 +3349,10 @@ msgstr "on the 1st at {0}"
msgid "Onboarding"
msgstr "Onboarding"
#: src/lib/onboarding-copy.ts
msgid "Once a week"
msgstr "Once a week"
#: src/lib/voice-persona-copy.ts
msgid "One more look before I answer…"
msgstr "One more look before I answer…"
@ -3285,6 +3361,10 @@ msgstr "One more look before I answer…"
msgid "Only empty spaces can be deleted. Delete its bots and groups first."
msgstr "Only empty spaces can be deleted. Delete its bots and groups first."
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Only file and folder names are read."
msgstr "Only file and folder names are read."
#: src/pages/ScratchpadSection.tsx
#: src/pages/Shell.tsx
msgid "Open"
@ -3536,6 +3616,10 @@ msgstr "Providers"
msgid "Pull request"
msgstr "Pull request"
#: src/components/NotificationSettingsPanel.tsx
msgid "Push on this device"
msgstr "Push on this device"
#: src/components/ai/DelegatedTaskCard.tsx
#: src/pages/ProjectSessionTimeline.tsx
msgid "Queued"
@ -3546,14 +3630,22 @@ msgstr "Queued"
msgid "Queued…"
msgstr "Queued…"
#: src/pages/Onboarding.tsx
msgid "Quick & pragmatic"
msgstr "Quick & pragmatic"
#: src/pages/HeuteBrief.tsx
msgid "Quiet day so far"
msgstr "Quiet day so far"
#: src/components/NotificationSettingsPanel.tsx
msgid "Quiet hours"
msgstr "Quiet hours"
#: src/components/NotificationSettingsPanel.tsx
msgid "Quiet hours end"
msgstr "Quiet hours end"
#: src/components/NotificationSettingsPanel.tsx
msgid "Quiet hours start"
msgstr "Quiet hours start"
#: src/pages/Shell.tsx
msgid "Quote"
msgstr "Quote"
@ -3773,6 +3865,10 @@ msgstr "Replying to {replyName}: “{replyQuote}”"
msgid "Reporting from home…"
msgstr "Reporting from home…"
#: src/lib/onboarding-copy.ts
msgid "Research & writing"
msgstr "Research & writing"
#: src/pages/shell/bot-capabilities.tsx
msgid "research, writing"
msgstr "research, writing"
@ -3912,6 +4008,7 @@ msgstr "Sandbox"
#: src/components/AskCard.tsx
#: src/components/teach/SkillDraftCard.tsx
#: src/components/UserFactEditor.tsx
#: src/pages/ExternalConversationSettings.tsx
#: src/pages/GroupPanel.tsx
#: src/pages/KnowledgeSection.tsx
@ -4012,15 +4109,12 @@ msgstr "Searching…"
msgid "Select {0}"
msgstr "Select {0}"
#: src/pages/Onboarding.tsx
msgid "Select all that apply"
msgstr "Select all that apply"
#: src/pages/PrivacySettingsOverlay.tsx
msgid "Self-hosted (better-auth)"
msgstr "Self-hosted (better-auth)"
#: src/pages/Shell.tsx
#: src/pages/shell/message-cards.tsx
msgid "Send"
msgstr "Send"
@ -4184,7 +4278,9 @@ msgid "Skills"
msgstr "Skills"
#: src/components/integrations/IntegrationSetup.tsx
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/lib/onboarding-copy.ts
#: src/pages/Shell.tsx
msgid "Skip"
msgstr "Skip"
@ -4269,6 +4365,10 @@ msgstr "Speaking on or off"
msgid "Speaking with intent…"
msgstr "Speaking with intent…"
#: src/lib/onboarding-copy.ts
msgid "Specialist crew"
msgstr "Specialist crew"
#: src/lib/model-tier-label.ts
msgid "Standard"
msgstr "Standard"
@ -4362,10 +4462,6 @@ msgstr "Stress-testing the book…"
msgid "Stronger together — {0} specialists"
msgstr "Stronger together — {0} specialists"
#: src/pages/Onboarding.tsx
msgid "Structured & planned"
msgstr "Structured & planned"
#: src/pages/Shell.tsx
msgid "subagent"
msgstr "subagent"
@ -4451,10 +4547,6 @@ msgstr "Teams not enabled"
msgid "Tell me what matters. I'll move."
msgstr "Tell me what matters. I'll move."
#: src/pages/Onboarding.tsx
msgid "Tell us about yourself"
msgstr "Tell us about yourself"
#: src/components/teach/SkillDraftCard.tsx
msgid "Test"
msgstr "Test"
@ -4545,10 +4637,6 @@ msgstr "Time of day"
msgid "Time zone"
msgstr "Time zone"
#: src/pages/Onboarding.tsx
msgid "Timezone"
msgstr "Timezone"
#: src/pages/shell/bot-panel.tsx
msgid "Title"
msgstr "Title"
@ -4603,6 +4691,10 @@ msgstr "Triage in progress…"
msgid "Try again"
msgstr "Try again"
#: src/lib/onboarding-copy.ts
msgid "Turn on"
msgstr "Turn on"
#: src/components/AskCard.tsx
msgid "Type your answer"
msgstr "Type your answer"
@ -4796,6 +4888,14 @@ msgstr "Webhook"
msgid "Weekdays"
msgstr "Weekdays"
#: src/components/heute/CoachRhythmControl.tsx
msgid "Weekly"
msgstr "Weekly"
#: src/lib/routine-name.ts
msgid "Weekly review"
msgstr "Weekly review"
#: src/pages/shell/dialogs.tsx
msgid "What about its memories?"
msgstr "What about its memories?"
@ -4804,6 +4904,10 @@ msgstr "What about its memories?"
msgid "What do you think — react or answer in a sentence."
msgstr "What do you think — react or answer in a sentence."
#: src/lib/onboarding-copy.ts
msgid "What do you want me on first?"
msgstr "What do you want me on first?"
#: src/components/onboarding/OnboardingOoDesigner.tsx
msgid "What feels like you?"
msgstr "What feels like you?"
@ -4844,6 +4948,10 @@ msgstr "What to return"
msgid "What's on your mind? I'll route it — and tell you when I disagree."
msgstr "What's on your mind? I'll route it — and tell you when I disagree."
#: src/lib/onboarding-copy.ts
msgid "What’s one thing you want to get done in the next few weeks?"
msgstr "What’s one thing you want to get done in the next few weeks?"
#: src/lib/voice-persona-copy.ts
msgid "What's personal today?"
msgstr "What's personal today?"
@ -4856,6 +4964,14 @@ msgstr "What's the work item?"
msgid "When a webhook fires"
msgstr "When a webhook fires"
#: src/lib/onboarding-copy.ts
msgid "When should I check in with you?"
msgstr "When should I check in with you?"
#: src/lib/onboarding-copy.ts
msgid "When should I stay quiet?"
msgstr "When should I stay quiet?"
#: src/pages/RoutineEditor.tsx
msgid "When to run"
msgstr "When to run"
@ -4881,10 +4997,6 @@ msgstr "Work"
msgid "Work channel open — go ahead."
msgstr "Work channel open — go ahead."
#: src/pages/Onboarding.tsx
msgid "Work style"
msgstr "Work style"
#: src/components/stage/OoStage.tsx
msgid "Working"
msgstr "Working"
@ -4941,8 +5053,11 @@ msgstr "Your conversations and settings never leave your machine"
msgid "Your email address"
msgstr "Your email address"
#: src/lib/onboarding-copy.ts
msgid "Your goal"
msgstr "Your goal"
#: src/pages/Auth.tsx
#: src/pages/Onboarding.tsx
msgid "Your name"
msgstr "Your name"

View File

@ -21,6 +21,11 @@ msgstr " (sin leer)"
msgid "(optional)"
msgstr ""
#. placeholder {0}: habit.streak
#: src/components/heute/HeuteCoach.tsx
msgid "{0, plural, one {# day} other {# days}}"
msgstr ""
#. placeholder {0}: group.entries.length
#: src/pages/ModelSettingsOverlay.tsx
msgid "{0, plural, one {# model} other {# models}}"
@ -31,6 +36,11 @@ msgstr "{0, plural, one {# modelo} other {# modelos}}"
msgid "{0, plural, one {# more waiting} other {# more waiting}}"
msgstr ""
#. placeholder {0}: habit.streak
#: src/components/heute/HeuteCoach.tsx
msgid "{0, plural, one {# week} other {# weeks}}"
msgstr ""
#. placeholder {0}: file.name
#: src/pages/Shell.tsx
msgid "{0} (max {ATTACHMENT_MAX_COUNT} attachments)"
@ -281,10 +291,6 @@ msgstr ""
msgid "1:1 conversation with {title}"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "A few quick preferences so your Oo can be better tailored to you."
msgstr ""
#: src/pages/shell/bot-picker.tsx
msgid "About groups"
msgstr ""
@ -350,6 +356,7 @@ msgstr "Modelo activo"
msgid "Activity"
msgstr "Actividad"
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/pages/PluginsOverlay.tsx
#: src/pages/ScratchpadSection.tsx
msgid "Add"
@ -387,6 +394,10 @@ msgstr "Agrega una URL de servidor HTTPS."
msgid "Add another"
msgstr ""
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Add as projects"
msgstr ""
#: src/pages/PluginsOverlay.tsx
msgid "Add Executor"
msgstr "Agregar Executor"
@ -428,6 +439,10 @@ msgstr "Agregar servidor"
msgid "Add server URL"
msgstr ""
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Add to About you"
msgstr ""
#: src/components/teach/SkillDraftCard.tsx
msgid "Add to routine"
msgstr "Agregar a la rutina"
@ -494,6 +509,26 @@ msgstr ""
msgid "All quiet"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "All set. I’ll check in on {weekday} at {time}."
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "All set. I’ll check in today at {time}."
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "All set. I’ll check in tomorrow at {time}."
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "All set. I’m here whenever you need me."
msgstr ""
#: src/components/UserFactEditor.tsx
msgid "All spaces"
msgstr ""
#. placeholder {0}: rule.matchValue
#: src/components/ApprovalRulesSettings.tsx
msgid "Allow {0} actions without asking"
@ -700,10 +735,6 @@ msgstr "Autorizar"
msgid "Authorize this server so agents can use its tools. A popup opens."
msgstr "Autoriza este servidor para que tus agentes usen sus herramientas. Se abre una ventana emergente."
#: src/pages/Onboarding.tsx
msgid "Auto-detected or enter manually"
msgstr ""
#: src/pages/ExternalConversationSettings.tsx
msgid "Automated senders"
msgstr ""
@ -740,10 +771,6 @@ msgstr "Volver a iniciar sesión"
msgid "Back to timeline"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Balanced"
msgstr ""
#: src/pages/memory-providers/SupermemorySettingsForm.tsx
msgid "Base URL"
msgstr "URL base"
@ -753,6 +780,10 @@ msgstr "URL base"
msgid "Bearer token"
msgstr "Token Bearer"
#: src/components/NotificationSettingsPanel.tsx
msgid "Blocked in your browser settings"
msgstr ""
#: src/pages/Shell.tsx
msgid "Booting live desktop…"
msgstr "Iniciando escritorio en vivo…"
@ -821,6 +852,10 @@ msgstr ""
msgid "Calm helper"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "Can I send you notifications?"
msgstr ""
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Can speak"
msgstr ""
@ -833,6 +868,7 @@ msgstr "No se puede alcanzar el servidor."
#: src/components/ComputerMaintenanceActions.tsx
#: src/components/ComputerUpdateProgress.tsx
#: src/components/teach/TeachComputerOverlay.tsx
#: src/components/UserFactEditor.tsx
#: src/components/UserProfileSettings.tsx
#: src/pages/KnowledgeSection.tsx
#: src/pages/PluginsOverlay.tsx
@ -867,8 +903,8 @@ msgstr ""
msgid "Cancelled"
msgstr "Cancelado"
#: src/pages/Onboarding.tsx
msgid "Casual & friendly"
#: src/components/UserFactEditor.tsx
msgid "Category"
msgstr ""
#: src/pages/AccountSettingsOverlay.tsx
@ -925,6 +961,10 @@ msgstr "Regístrate."
msgid "Check your email"
msgstr "Revisa tu correo"
#: src/components/heute/CoachRhythmControl.tsx
msgid "Check-ins"
msgstr ""
#: src/lib/voice-persona-copy.ts
msgid "Checking dependencies…"
msgstr ""
@ -1134,7 +1174,6 @@ msgid "Command"
msgstr "Comando"
#: src/components/UserProfileSettings.tsx
#: src/pages/Onboarding.tsx
msgid "Communication style"
msgstr ""
@ -1295,8 +1334,8 @@ msgstr "Límite de contexto"
#: src/components/integrations/IntegrationSetup.tsx
#: src/components/onboarding/OnboardingOoDesigner.tsx
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/pages/Onboarding.tsx
msgid "Continue"
msgstr "Continuar"
@ -1522,6 +1561,10 @@ msgstr "No se pudo cargar el estado de actualización"
msgid "Could not load voice settings"
msgstr "No se pudo cargar la configuración de voz"
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Could not look in this folder."
msgstr ""
#: src/pages/LocalSettings.tsx
msgid "Could not open local settings. Start the local server and create its owner account, then try again."
msgstr ""
@ -1674,6 +1717,7 @@ msgstr "No se pudo actualizar el alcance de memoria predeterminado"
msgid "Could not update voice"
msgstr ""
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
msgid "Couldn't load"
msgstr ""
@ -1682,6 +1726,7 @@ msgstr ""
msgid "Couldn't load messaging settings"
msgstr "No se pudo cargar la configuración de mensajería"
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
msgid "Couldn't save"
msgstr ""
@ -1823,6 +1868,10 @@ msgstr ""
msgid "day"
msgstr "día"
#: src/lib/onboarding-copy.ts
msgid "Day-to-day work"
msgstr ""
#: src/pages/RoutineSchedule.tsx
msgid "days"
msgstr "días"
@ -1831,10 +1880,6 @@ msgstr "días"
msgid "Decline"
msgstr "Rechazar"
#: src/pages/Onboarding.tsx
msgid "Deep-dive focused"
msgstr ""
#: src/pages/ModelSettingsOverlay.tsx
msgid "Default"
msgstr "Predeterminado"
@ -1935,18 +1980,10 @@ msgstr ""
msgid "Desktop update"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Detailed & explanatory"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
msgid "Developer tools"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Direct & concise"
msgstr ""
#: src/components/integrations/IntegrationSetup.tsx
msgid "Direct MCP"
msgstr ""
@ -2066,6 +2103,10 @@ msgstr "p. ej. Hivenet Agent, Presales, bot de hojas de horas"
msgid "Earlier message"
msgstr "Mensaje anterior"
#: src/components/UserProfileSettings.tsx
msgid "Edit"
msgstr ""
#: src/components/AskCard.tsx
msgid "Edit first"
msgstr "Editar primero"
@ -2115,6 +2156,14 @@ msgstr ""
msgid "Enter this code at <0>{0}</0>"
msgstr "Ingresa este código en <0>{0}</0>"
#: src/components/heute/CoachRhythmControl.tsx
msgid "Evening"
msgstr ""
#: src/lib/routine-name.ts
msgid "Evening check-in"
msgstr ""
#: src/pages/ExternalConversationSettings.tsx
#: src/pages/RoutineSchedule.tsx
msgid "Every"
@ -2143,6 +2192,10 @@ msgstr "Cada lunes"
msgid "Every month"
msgstr "Cada mes"
#: src/lib/onboarding-copy.ts
msgid "Every morning"
msgstr ""
#: src/pages/RoutineEditor.tsx
#: src/pages/RoutineSchedule.tsx
msgid "Every week"
@ -2183,6 +2236,10 @@ msgstr ""
msgid "Extra high"
msgstr "Extra alto"
#: src/components/UserFactEditor.tsx
msgid "Fact"
msgstr ""
#: src/components/CloudAgentCard.tsx
msgid "failed"
msgstr "fallido"
@ -2256,10 +2313,6 @@ msgstr ""
msgid "Flag unexpected actions"
msgstr "Marcar acciones inesperadas"
#: src/pages/Onboarding.tsx
msgid "Flexible & spontaneous"
msgstr ""
#: src/pages/PrivacySettingsOverlay.tsx
msgid "Fonts loaded locally"
msgstr ""
@ -2324,6 +2377,7 @@ msgstr "Evento de Git"
msgid "Goal"
msgstr ""
#: src/components/heute/HeuteCoach.tsx
#: src/components/UserProfileSettings.tsx
#: src/pages/ProjectSessionTimeline.tsx
msgid "Goals"
@ -2360,6 +2414,7 @@ msgstr ""
msgid "Groups"
msgstr ""
#: src/components/heute/HeuteCoach.tsx
#: src/components/UserProfileSettings.tsx
msgid "Habits"
msgstr ""
@ -2434,10 +2489,6 @@ msgstr "Ocultar contraseña"
msgid "High"
msgstr "Alto"
#: src/pages/Onboarding.tsx
msgid "Hobbies & interests"
msgstr ""
#: src/components/onboarding/OnboardingOoDesigner.tsx
msgid "Hobby"
msgstr ""
@ -2451,10 +2502,6 @@ msgstr "hora"
msgid "hours"
msgstr "horas"
#: src/pages/Onboarding.tsx
msgid "How do you like to work?"
msgstr ""
#: src/pages/RoutineSchedule.tsx
msgid "How often"
msgstr "Con qué frecuencia"
@ -2463,10 +2510,6 @@ msgstr "Con qué frecuencia"
msgid "How should your Oo sound when reading replies aloud?"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "How should your Oo talk to you?"
msgstr ""
#: src/components/teach/SkillDraftCard.tsx
msgid "How to check"
msgstr "Cómo comprobar"
@ -2507,6 +2550,10 @@ msgstr ""
msgid "In progress"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "Inbox & email"
msgstr ""
#: src/pages/shell/bot-panel.tsx
msgid "Inherit default"
msgstr "Heredar predeterminado"
@ -2730,6 +2777,10 @@ msgstr ""
msgid "Log out"
msgstr "Cerrar sesión"
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Look"
msgstr ""
#. placeholder {0}: index + 1
#. placeholder {1}: looks.length
#: src/components/onboarding/LookPreviewCarousel.tsx
@ -2740,10 +2791,18 @@ msgstr ""
msgid "Look families"
msgstr ""
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Look for projects here?"
msgstr ""
#: src/components/onboarding/LookPreviewCarousel.tsx
msgid "Look variants"
msgstr ""
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Looking"
msgstr ""
#: src/pages/ModelSettingsOverlay.tsx
#: src/pages/shell/bot-panel.tsx
msgid "Low"
@ -2904,10 +2963,6 @@ msgstr "minuto"
msgid "minutes"
msgstr "minutos"
#: src/pages/HeuteBrief.tsx
msgid "Mission Control"
msgstr ""
#: src/pages/memory-providers/BuiltinSettingsForm.tsx
#: src/pages/ModelSettingsOverlay.tsx
#: src/pages/shell/bot-panel.tsx
@ -2964,6 +3019,18 @@ msgstr "Más acciones de computadora"
msgid "More conversations ({hiddenSessionCount})"
msgstr ""
#: src/components/heute/CoachRhythmControl.tsx
msgid "Morning"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "Morning and evening"
msgstr ""
#: src/lib/routine-name.ts
msgid "Morning brief"
msgstr ""
#: src/pages/shell/dialogs.tsx
msgid "Move them to your shared memory."
msgstr "Muévelas a tu memoria compartida."
@ -2975,7 +3042,6 @@ msgstr ""
#: src/components/teach/SkillDraftCard.tsx
#: src/pages/Auth.tsx
#: src/pages/GroupPanel.tsx
#: src/pages/Onboarding.tsx
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/bot-panel.tsx
#: src/pages/shell/dialogs.tsx
@ -3154,6 +3220,10 @@ msgstr "Aún no hay mensajes con {peerBotName}."
msgid "No model catalog is available."
msgstr "No hay un catálogo de modelos disponible."
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "No projects found"
msgstr ""
#: src/pages/Shell.tsx
msgid "No projects yet."
msgstr ""
@ -3211,6 +3281,7 @@ msgstr "No conectado"
msgid "Not in the plugin catalog"
msgstr "No está en el catálogo de plugins"
#: src/lib/onboarding-copy.ts
#: src/pages/shell/message-cards.tsx
msgid "Not now"
msgstr "Ahora no"
@ -3223,14 +3294,11 @@ msgstr "No queda nada en ejecución"
msgid "Nothing remembered yet"
msgstr "Aún no hay recuerdos"
#: src/pages/Onboarding.tsx
msgid "Nothing specific"
msgstr ""
#: src/components/UserProfileSettings.tsx
msgid "Nothing yet"
msgstr ""
#: src/pages/SettingsOverlay.tsx
#: src/pages/shell/bot-panel.tsx
msgid "Notifications"
msgstr "Notificaciones"
@ -3244,6 +3312,10 @@ msgstr "Ahora"
msgid "Now using {0}."
msgstr "Ahora usando {0}."
#: src/components/NotificationSettingsPanel.tsx
msgid "Nudges from your Oo"
msgstr ""
#: src/pages/McpOAuthCallback.tsx
msgid "OAuth authorization was cancelled."
msgstr "Se canceló la autorización OAuth."
@ -3277,6 +3349,10 @@ msgstr "el día 1 a las {0}"
msgid "Onboarding"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "Once a week"
msgstr ""
#: src/lib/voice-persona-copy.ts
msgid "One more look before I answer…"
msgstr ""
@ -3285,6 +3361,10 @@ msgstr ""
msgid "Only empty spaces can be deleted. Delete its bots and groups first."
msgstr ""
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Only file and folder names are read."
msgstr ""
#: src/pages/ScratchpadSection.tsx
#: src/pages/Shell.tsx
msgid "Open"
@ -3536,6 +3616,10 @@ msgstr "Proveedores"
msgid "Pull request"
msgstr "Solicitud de incorporación"
#: src/components/NotificationSettingsPanel.tsx
msgid "Push on this device"
msgstr ""
#: src/components/ai/DelegatedTaskCard.tsx
#: src/pages/ProjectSessionTimeline.tsx
msgid "Queued"
@ -3546,14 +3630,22 @@ msgstr "En cola"
msgid "Queued…"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Quick & pragmatic"
msgstr ""
#: src/pages/HeuteBrief.tsx
msgid "Quiet day so far"
msgstr ""
#: src/components/NotificationSettingsPanel.tsx
msgid "Quiet hours"
msgstr ""
#: src/components/NotificationSettingsPanel.tsx
msgid "Quiet hours end"
msgstr ""
#: src/components/NotificationSettingsPanel.tsx
msgid "Quiet hours start"
msgstr ""
#: src/pages/Shell.tsx
msgid "Quote"
msgstr ""
@ -3773,6 +3865,10 @@ msgstr ""
msgid "Reporting from home…"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "Research & writing"
msgstr ""
#: src/pages/shell/bot-capabilities.tsx
msgid "research, writing"
msgstr ""
@ -3912,6 +4008,7 @@ msgstr ""
#: src/components/AskCard.tsx
#: src/components/teach/SkillDraftCard.tsx
#: src/components/UserFactEditor.tsx
#: src/pages/ExternalConversationSettings.tsx
#: src/pages/GroupPanel.tsx
#: src/pages/KnowledgeSection.tsx
@ -4012,15 +4109,12 @@ msgstr "Buscando…"
msgid "Select {0}"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Select all that apply"
msgstr ""
#: src/pages/PrivacySettingsOverlay.tsx
msgid "Self-hosted (better-auth)"
msgstr ""
#: src/pages/Shell.tsx
#: src/pages/shell/message-cards.tsx
msgid "Send"
msgstr "Enviar"
@ -4184,7 +4278,9 @@ msgid "Skills"
msgstr "Habilidades"
#: src/components/integrations/IntegrationSetup.tsx
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/lib/onboarding-copy.ts
#: src/pages/Shell.tsx
msgid "Skip"
msgstr "Omitir"
@ -4269,6 +4365,10 @@ msgstr ""
msgid "Speaking with intent…"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "Specialist crew"
msgstr ""
#: src/lib/model-tier-label.ts
msgid "Standard"
msgstr ""
@ -4362,10 +4462,6 @@ msgstr ""
msgid "Stronger together — {0} specialists"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Structured & planned"
msgstr ""
#: src/pages/Shell.tsx
msgid "subagent"
msgstr "subagente"
@ -4451,10 +4547,6 @@ msgstr ""
msgid "Tell me what matters. I'll move."
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Tell us about yourself"
msgstr ""
#: src/components/teach/SkillDraftCard.tsx
msgid "Test"
msgstr "Probar"
@ -4545,10 +4637,6 @@ msgstr "Hora del día"
msgid "Time zone"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Timezone"
msgstr ""
#: src/pages/shell/bot-panel.tsx
msgid "Title"
msgstr "Título"
@ -4603,6 +4691,10 @@ msgstr ""
msgid "Try again"
msgstr "Intentarlo de nuevo"
#: src/lib/onboarding-copy.ts
msgid "Turn on"
msgstr ""
#: src/components/AskCard.tsx
msgid "Type your answer"
msgstr "Escribe tu respuesta"
@ -4796,6 +4888,14 @@ msgstr "Webhook"
msgid "Weekdays"
msgstr "Días laborables"
#: src/components/heute/CoachRhythmControl.tsx
msgid "Weekly"
msgstr ""
#: src/lib/routine-name.ts
msgid "Weekly review"
msgstr ""
#: src/pages/shell/dialogs.tsx
msgid "What about its memories?"
msgstr "¿Qué hay de sus memorias?"
@ -4804,6 +4904,10 @@ msgstr "¿Qué hay de sus memorias?"
msgid "What do you think — react or answer in a sentence."
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "What do you want me on first?"
msgstr ""
#: src/components/onboarding/OnboardingOoDesigner.tsx
msgid "What feels like you?"
msgstr ""
@ -4844,6 +4948,10 @@ msgstr "Qué devolver"
msgid "What's on your mind? I'll route it — and tell you when I disagree."
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "What’s one thing you want to get done in the next few weeks?"
msgstr ""
#: src/lib/voice-persona-copy.ts
msgid "What's personal today?"
msgstr ""
@ -4856,6 +4964,14 @@ msgstr ""
msgid "When a webhook fires"
msgstr "Cuando se dispara un webhook"
#: src/lib/onboarding-copy.ts
msgid "When should I check in with you?"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "When should I stay quiet?"
msgstr ""
#: src/pages/RoutineEditor.tsx
msgid "When to run"
msgstr "Cuándo ejecutar"
@ -4881,10 +4997,6 @@ msgstr ""
msgid "Work channel open — go ahead."
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Work style"
msgstr ""
#: src/components/stage/OoStage.tsx
msgid "Working"
msgstr ""
@ -4941,8 +5053,11 @@ msgstr ""
msgid "Your email address"
msgstr "Tu dirección de correo"
#: src/lib/onboarding-copy.ts
msgid "Your goal"
msgstr ""
#: src/pages/Auth.tsx
#: src/pages/Onboarding.tsx
msgid "Your name"
msgstr "Tu nombre"

View File

@ -21,6 +21,11 @@ msgstr " (अपठित)"
msgid "(optional)"
msgstr ""
#. placeholder {0}: habit.streak
#: src/components/heute/HeuteCoach.tsx
msgid "{0, plural, one {# day} other {# days}}"
msgstr ""
#. placeholder {0}: group.entries.length
#: src/pages/ModelSettingsOverlay.tsx
msgid "{0, plural, one {# model} other {# models}}"
@ -31,6 +36,11 @@ msgstr "{0, plural, one {# मॉडल} other {# मॉडल}}"
msgid "{0, plural, one {# more waiting} other {# more waiting}}"
msgstr ""
#. placeholder {0}: habit.streak
#: src/components/heute/HeuteCoach.tsx
msgid "{0, plural, one {# week} other {# weeks}}"
msgstr ""
#. placeholder {0}: file.name
#: src/pages/Shell.tsx
msgid "{0} (max {ATTACHMENT_MAX_COUNT} attachments)"
@ -281,10 +291,6 @@ msgstr ""
msgid "1:1 conversation with {title}"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "A few quick preferences so your Oo can be better tailored to you."
msgstr ""
#: src/pages/shell/bot-picker.tsx
msgid "About groups"
msgstr ""
@ -350,6 +356,7 @@ msgstr "सक्रिय मॉडल"
msgid "Activity"
msgstr "गतिविधि"
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/pages/PluginsOverlay.tsx
#: src/pages/ScratchpadSection.tsx
msgid "Add"
@ -387,6 +394,10 @@ msgstr "एक HTTPS सर्वर URL दर्ज करें।"
msgid "Add another"
msgstr ""
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Add as projects"
msgstr ""
#: src/pages/PluginsOverlay.tsx
msgid "Add Executor"
msgstr "Executor जोड़ें"
@ -428,6 +439,10 @@ msgstr "सर्वर जोड़ें"
msgid "Add server URL"
msgstr ""
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Add to About you"
msgstr ""
#: src/components/teach/SkillDraftCard.tsx
msgid "Add to routine"
msgstr "रूटीन में जोड़ें"
@ -494,6 +509,26 @@ msgstr ""
msgid "All quiet"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "All set. I’ll check in on {weekday} at {time}."
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "All set. I’ll check in today at {time}."
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "All set. I’ll check in tomorrow at {time}."
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "All set. I’m here whenever you need me."
msgstr ""
#: src/components/UserFactEditor.tsx
msgid "All spaces"
msgstr ""
#. placeholder {0}: rule.matchValue
#: src/components/ApprovalRulesSettings.tsx
msgid "Allow {0} actions without asking"
@ -700,10 +735,6 @@ msgstr "ऑथराइज़ करें"
msgid "Authorize this server so agents can use its tools. A popup opens."
msgstr "एजेंटों को इसके टूल का उपयोग करने देने के लिए इस सर्वर को ऑथराइज़ करें। एक पॉपअप खुलता है।"
#: src/pages/Onboarding.tsx
msgid "Auto-detected or enter manually"
msgstr ""
#: src/pages/ExternalConversationSettings.tsx
msgid "Automated senders"
msgstr ""
@ -740,10 +771,6 @@ msgstr "साइन इन पर वापस जाएँ"
msgid "Back to timeline"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Balanced"
msgstr ""
#: src/pages/memory-providers/SupermemorySettingsForm.tsx
msgid "Base URL"
msgstr "बेस URL"
@ -753,6 +780,10 @@ msgstr "बेस URL"
msgid "Bearer token"
msgstr "बियरर टोकन"
#: src/components/NotificationSettingsPanel.tsx
msgid "Blocked in your browser settings"
msgstr ""
#: src/pages/Shell.tsx
msgid "Booting live desktop…"
msgstr "लाइव डेस्कटॉप शुरू हो रहा है…"
@ -821,6 +852,10 @@ msgstr ""
msgid "Calm helper"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "Can I send you notifications?"
msgstr ""
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Can speak"
msgstr ""
@ -833,6 +868,7 @@ msgstr "सर्वर तक नहीं पहुंचा जा सका
#: src/components/ComputerMaintenanceActions.tsx
#: src/components/ComputerUpdateProgress.tsx
#: src/components/teach/TeachComputerOverlay.tsx
#: src/components/UserFactEditor.tsx
#: src/components/UserProfileSettings.tsx
#: src/pages/KnowledgeSection.tsx
#: src/pages/PluginsOverlay.tsx
@ -867,8 +903,8 @@ msgstr ""
msgid "Cancelled"
msgstr "रद्द किया गया"
#: src/pages/Onboarding.tsx
msgid "Casual & friendly"
#: src/components/UserFactEditor.tsx
msgid "Category"
msgstr ""
#: src/pages/AccountSettingsOverlay.tsx
@ -925,6 +961,10 @@ msgstr "जांच करें।"
msgid "Check your email"
msgstr "अपना ईमेल देखें"
#: src/components/heute/CoachRhythmControl.tsx
msgid "Check-ins"
msgstr ""
#: src/lib/voice-persona-copy.ts
msgid "Checking dependencies…"
msgstr ""
@ -1134,7 +1174,6 @@ msgid "Command"
msgstr "कमांड"
#: src/components/UserProfileSettings.tsx
#: src/pages/Onboarding.tsx
msgid "Communication style"
msgstr ""
@ -1295,8 +1334,8 @@ msgstr "कॉन्टेक्स्ट सीमा"
#: src/components/integrations/IntegrationSetup.tsx
#: src/components/onboarding/OnboardingOoDesigner.tsx
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/pages/Onboarding.tsx
msgid "Continue"
msgstr "जारी रखें"
@ -1522,6 +1561,10 @@ msgstr ""
msgid "Could not load voice settings"
msgstr "आवाज़ सेटिंग्स लोड नहीं हो सकीं"
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Could not look in this folder."
msgstr ""
#: src/pages/LocalSettings.tsx
msgid "Could not open local settings. Start the local server and create its owner account, then try again."
msgstr ""
@ -1674,6 +1717,7 @@ msgstr "डिफ़ॉल्ट मेमोरी स्कोप अपडे
msgid "Could not update voice"
msgstr ""
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
msgid "Couldn't load"
msgstr ""
@ -1682,6 +1726,7 @@ msgstr ""
msgid "Couldn't load messaging settings"
msgstr ""
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
msgid "Couldn't save"
msgstr ""
@ -1823,6 +1868,10 @@ msgstr ""
msgid "day"
msgstr "दिन"
#: src/lib/onboarding-copy.ts
msgid "Day-to-day work"
msgstr ""
#: src/pages/RoutineSchedule.tsx
msgid "days"
msgstr "दिन"
@ -1831,10 +1880,6 @@ msgstr "दिन"
msgid "Decline"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Deep-dive focused"
msgstr ""
#: src/pages/ModelSettingsOverlay.tsx
msgid "Default"
msgstr "डिफ़ॉल्ट"
@ -1935,18 +1980,10 @@ msgstr ""
msgid "Desktop update"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Detailed & explanatory"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
msgid "Developer tools"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Direct & concise"
msgstr ""
#: src/components/integrations/IntegrationSetup.tsx
msgid "Direct MCP"
msgstr ""
@ -2066,6 +2103,10 @@ msgstr "उदा. Hivenet Agent, Presales, टाइमशीट बॉट"
msgid "Earlier message"
msgstr "पुराना संदेश"
#: src/components/UserProfileSettings.tsx
msgid "Edit"
msgstr ""
#: src/components/AskCard.tsx
msgid "Edit first"
msgstr "पहले संपादित करें"
@ -2115,6 +2156,14 @@ msgstr ""
msgid "Enter this code at <0>{0}</0>"
msgstr "यह कोड <0>{0}</0> पर दर्ज करें"
#: src/components/heute/CoachRhythmControl.tsx
msgid "Evening"
msgstr ""
#: src/lib/routine-name.ts
msgid "Evening check-in"
msgstr ""
#: src/pages/ExternalConversationSettings.tsx
#: src/pages/RoutineSchedule.tsx
msgid "Every"
@ -2143,6 +2192,10 @@ msgstr "हर सोमवार"
msgid "Every month"
msgstr "हर महीने"
#: src/lib/onboarding-copy.ts
msgid "Every morning"
msgstr ""
#: src/pages/RoutineEditor.tsx
#: src/pages/RoutineSchedule.tsx
msgid "Every week"
@ -2183,6 +2236,10 @@ msgstr ""
msgid "Extra high"
msgstr "अतिरिक्त उच्च"
#: src/components/UserFactEditor.tsx
msgid "Fact"
msgstr ""
#: src/components/CloudAgentCard.tsx
msgid "failed"
msgstr "असफल"
@ -2256,10 +2313,6 @@ msgstr ""
msgid "Flag unexpected actions"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Flexible & spontaneous"
msgstr ""
#: src/pages/PrivacySettingsOverlay.tsx
msgid "Fonts loaded locally"
msgstr ""
@ -2324,6 +2377,7 @@ msgstr ""
msgid "Goal"
msgstr ""
#: src/components/heute/HeuteCoach.tsx
#: src/components/UserProfileSettings.tsx
#: src/pages/ProjectSessionTimeline.tsx
msgid "Goals"
@ -2360,6 +2414,7 @@ msgstr ""
msgid "Groups"
msgstr ""
#: src/components/heute/HeuteCoach.tsx
#: src/components/UserProfileSettings.tsx
msgid "Habits"
msgstr ""
@ -2434,10 +2489,6 @@ msgstr ""
msgid "High"
msgstr "उच्च"
#: src/pages/Onboarding.tsx
msgid "Hobbies & interests"
msgstr ""
#: src/components/onboarding/OnboardingOoDesigner.tsx
msgid "Hobby"
msgstr ""
@ -2451,10 +2502,6 @@ msgstr "घंटा"
msgid "hours"
msgstr "घंटे"
#: src/pages/Onboarding.tsx
msgid "How do you like to work?"
msgstr ""
#: src/pages/RoutineSchedule.tsx
msgid "How often"
msgstr "कितनी बार"
@ -2463,10 +2510,6 @@ msgstr "कितनी बार"
msgid "How should your Oo sound when reading replies aloud?"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "How should your Oo talk to you?"
msgstr ""
#: src/components/teach/SkillDraftCard.tsx
msgid "How to check"
msgstr "जांच कैसे करें"
@ -2507,6 +2550,10 @@ msgstr ""
msgid "In progress"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "Inbox & email"
msgstr ""
#: src/pages/shell/bot-panel.tsx
msgid "Inherit default"
msgstr "डिफ़ॉल्ट का पालन करें"
@ -2730,6 +2777,10 @@ msgstr ""
msgid "Log out"
msgstr "लॉग आउट"
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Look"
msgstr ""
#. placeholder {0}: index + 1
#. placeholder {1}: looks.length
#: src/components/onboarding/LookPreviewCarousel.tsx
@ -2740,10 +2791,18 @@ msgstr ""
msgid "Look families"
msgstr ""
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Look for projects here?"
msgstr ""
#: src/components/onboarding/LookPreviewCarousel.tsx
msgid "Look variants"
msgstr ""
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Looking"
msgstr ""
#: src/pages/ModelSettingsOverlay.tsx
#: src/pages/shell/bot-panel.tsx
msgid "Low"
@ -2904,10 +2963,6 @@ msgstr "मिनट"
msgid "minutes"
msgstr "मिनट"
#: src/pages/HeuteBrief.tsx
msgid "Mission Control"
msgstr ""
#: src/pages/memory-providers/BuiltinSettingsForm.tsx
#: src/pages/ModelSettingsOverlay.tsx
#: src/pages/shell/bot-panel.tsx
@ -2964,6 +3019,18 @@ msgstr ""
msgid "More conversations ({hiddenSessionCount})"
msgstr ""
#: src/components/heute/CoachRhythmControl.tsx
msgid "Morning"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "Morning and evening"
msgstr ""
#: src/lib/routine-name.ts
msgid "Morning brief"
msgstr ""
#: src/pages/shell/dialogs.tsx
msgid "Move them to your shared memory."
msgstr "उन्हें अपनी साझा मेमोरी में ले जाएं।"
@ -2975,7 +3042,6 @@ msgstr ""
#: src/components/teach/SkillDraftCard.tsx
#: src/pages/Auth.tsx
#: src/pages/GroupPanel.tsx
#: src/pages/Onboarding.tsx
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/bot-panel.tsx
#: src/pages/shell/dialogs.tsx
@ -3154,6 +3220,10 @@ msgstr "{peerBotName} के साथ अभी कोई संदेश न
msgid "No model catalog is available."
msgstr "कोई मॉडल कैटलॉग उपलब्ध नहीं है।"
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "No projects found"
msgstr ""
#: src/pages/Shell.tsx
msgid "No projects yet."
msgstr ""
@ -3211,6 +3281,7 @@ msgstr "कनेक्टेड नहीं"
msgid "Not in the plugin catalog"
msgstr ""
#: src/lib/onboarding-copy.ts
#: src/pages/shell/message-cards.tsx
msgid "Not now"
msgstr "अभी नहीं"
@ -3223,14 +3294,11 @@ msgstr "कुछ भी नहीं चल रहा"
msgid "Nothing remembered yet"
msgstr "अभी तक कुछ याद नहीं किया गया"
#: src/pages/Onboarding.tsx
msgid "Nothing specific"
msgstr ""
#: src/components/UserProfileSettings.tsx
msgid "Nothing yet"
msgstr ""
#: src/pages/SettingsOverlay.tsx
#: src/pages/shell/bot-panel.tsx
msgid "Notifications"
msgstr "सूचनाएं"
@ -3244,6 +3312,10 @@ msgstr "अभी"
msgid "Now using {0}."
msgstr "अब {0} उपयोग में है।"
#: src/components/NotificationSettingsPanel.tsx
msgid "Nudges from your Oo"
msgstr ""
#: src/pages/McpOAuthCallback.tsx
msgid "OAuth authorization was cancelled."
msgstr "OAuth ऑथराइज़ेशन रद्द कर दिया गया।"
@ -3277,6 +3349,10 @@ msgstr "1 तारीख को {0} बजे"
msgid "Onboarding"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "Once a week"
msgstr ""
#: src/lib/voice-persona-copy.ts
msgid "One more look before I answer…"
msgstr ""
@ -3285,6 +3361,10 @@ msgstr ""
msgid "Only empty spaces can be deleted. Delete its bots and groups first."
msgstr ""
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Only file and folder names are read."
msgstr ""
#: src/pages/ScratchpadSection.tsx
#: src/pages/Shell.tsx
msgid "Open"
@ -3536,6 +3616,10 @@ msgstr "प्रोवाइडर्स"
msgid "Pull request"
msgstr "पुल अनुरोध"
#: src/components/NotificationSettingsPanel.tsx
msgid "Push on this device"
msgstr ""
#: src/components/ai/DelegatedTaskCard.tsx
#: src/pages/ProjectSessionTimeline.tsx
msgid "Queued"
@ -3546,14 +3630,22 @@ msgstr "कतार में"
msgid "Queued…"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Quick & pragmatic"
msgstr ""
#: src/pages/HeuteBrief.tsx
msgid "Quiet day so far"
msgstr ""
#: src/components/NotificationSettingsPanel.tsx
msgid "Quiet hours"
msgstr ""
#: src/components/NotificationSettingsPanel.tsx
msgid "Quiet hours end"
msgstr ""
#: src/components/NotificationSettingsPanel.tsx
msgid "Quiet hours start"
msgstr ""
#: src/pages/Shell.tsx
msgid "Quote"
msgstr ""
@ -3773,6 +3865,10 @@ msgstr ""
msgid "Reporting from home…"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "Research & writing"
msgstr ""
#: src/pages/shell/bot-capabilities.tsx
msgid "research, writing"
msgstr ""
@ -3912,6 +4008,7 @@ msgstr ""
#: src/components/AskCard.tsx
#: src/components/teach/SkillDraftCard.tsx
#: src/components/UserFactEditor.tsx
#: src/pages/ExternalConversationSettings.tsx
#: src/pages/GroupPanel.tsx
#: src/pages/KnowledgeSection.tsx
@ -4012,15 +4109,12 @@ msgstr "खोजा जा रहा है…"
msgid "Select {0}"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Select all that apply"
msgstr ""
#: src/pages/PrivacySettingsOverlay.tsx
msgid "Self-hosted (better-auth)"
msgstr ""
#: src/pages/Shell.tsx
#: src/pages/shell/message-cards.tsx
msgid "Send"
msgstr "भेजें"
@ -4184,7 +4278,9 @@ msgid "Skills"
msgstr "स्किल्स"
#: src/components/integrations/IntegrationSetup.tsx
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/lib/onboarding-copy.ts
#: src/pages/Shell.tsx
msgid "Skip"
msgstr "छोड़ें"
@ -4269,6 +4365,10 @@ msgstr ""
msgid "Speaking with intent…"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "Specialist crew"
msgstr ""
#: src/lib/model-tier-label.ts
msgid "Standard"
msgstr ""
@ -4362,10 +4462,6 @@ msgstr ""
msgid "Stronger together — {0} specialists"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Structured & planned"
msgstr ""
#: src/pages/Shell.tsx
msgid "subagent"
msgstr "सबएजेंट"
@ -4451,10 +4547,6 @@ msgstr ""
msgid "Tell me what matters. I'll move."
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Tell us about yourself"
msgstr ""
#: src/components/teach/SkillDraftCard.tsx
msgid "Test"
msgstr "टेस्ट करें"
@ -4545,10 +4637,6 @@ msgstr "दिन का समय"
msgid "Time zone"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Timezone"
msgstr ""
#: src/pages/shell/bot-panel.tsx
msgid "Title"
msgstr "शीर्षक"
@ -4603,6 +4691,10 @@ msgstr ""
msgid "Try again"
msgstr "फिर से कोशिश करें"
#: src/lib/onboarding-copy.ts
msgid "Turn on"
msgstr ""
#: src/components/AskCard.tsx
msgid "Type your answer"
msgstr "अपना उत्तर लिखें"
@ -4796,6 +4888,14 @@ msgstr ""
msgid "Weekdays"
msgstr "कार्यदिवस"
#: src/components/heute/CoachRhythmControl.tsx
msgid "Weekly"
msgstr ""
#: src/lib/routine-name.ts
msgid "Weekly review"
msgstr ""
#: src/pages/shell/dialogs.tsx
msgid "What about its memories?"
msgstr "इसकी मेमोरी का क्या करें?"
@ -4804,6 +4904,10 @@ msgstr "इसकी मेमोरी का क्या करें?"
msgid "What do you think — react or answer in a sentence."
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "What do you want me on first?"
msgstr ""
#: src/components/onboarding/OnboardingOoDesigner.tsx
msgid "What feels like you?"
msgstr ""
@ -4844,6 +4948,10 @@ msgstr "क्या लौटाना है"
msgid "What's on your mind? I'll route it — and tell you when I disagree."
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "What’s one thing you want to get done in the next few weeks?"
msgstr ""
#: src/lib/voice-persona-copy.ts
msgid "What's personal today?"
msgstr ""
@ -4856,6 +4964,14 @@ msgstr ""
msgid "When a webhook fires"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "When should I check in with you?"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "When should I stay quiet?"
msgstr ""
#: src/pages/RoutineEditor.tsx
msgid "When to run"
msgstr "कब चलाएं"
@ -4881,10 +4997,6 @@ msgstr ""
msgid "Work channel open — go ahead."
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Work style"
msgstr ""
#: src/components/stage/OoStage.tsx
msgid "Working"
msgstr ""
@ -4941,8 +5053,11 @@ msgstr ""
msgid "Your email address"
msgstr "आपका ईमेल पता"
#: src/lib/onboarding-copy.ts
msgid "Your goal"
msgstr ""
#: src/pages/Auth.tsx
#: src/pages/Onboarding.tsx
msgid "Your name"
msgstr "आपका नाम"

View File

@ -21,6 +21,11 @@ msgstr " (읽지 않음)"
msgid "(optional)"
msgstr ""
#. placeholder {0}: habit.streak
#: src/components/heute/HeuteCoach.tsx
msgid "{0, plural, one {# day} other {# days}}"
msgstr ""
#. placeholder {0}: group.entries.length
#: src/pages/ModelSettingsOverlay.tsx
msgid "{0, plural, one {# model} other {# models}}"
@ -31,6 +36,11 @@ msgstr "{0, plural, one {모델 #개} other {모델 #개}}"
msgid "{0, plural, one {# more waiting} other {# more waiting}}"
msgstr ""
#. placeholder {0}: habit.streak
#: src/components/heute/HeuteCoach.tsx
msgid "{0, plural, one {# week} other {# weeks}}"
msgstr ""
#. placeholder {0}: file.name
#: src/pages/Shell.tsx
msgid "{0} (max {ATTACHMENT_MAX_COUNT} attachments)"
@ -281,10 +291,6 @@ msgstr ""
msgid "1:1 conversation with {title}"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "A few quick preferences so your Oo can be better tailored to you."
msgstr ""
#: src/pages/shell/bot-picker.tsx
msgid "About groups"
msgstr ""
@ -350,6 +356,7 @@ msgstr "현재 모델"
msgid "Activity"
msgstr "활동"
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/pages/PluginsOverlay.tsx
#: src/pages/ScratchpadSection.tsx
msgid "Add"
@ -387,6 +394,10 @@ msgstr "HTTPS 서버 URL을 입력하세요."
msgid "Add another"
msgstr ""
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Add as projects"
msgstr ""
#: src/pages/PluginsOverlay.tsx
msgid "Add Executor"
msgstr "Executor 추가"
@ -428,6 +439,10 @@ msgstr "서버 추가"
msgid "Add server URL"
msgstr ""
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Add to About you"
msgstr ""
#: src/components/teach/SkillDraftCard.tsx
msgid "Add to routine"
msgstr "자동 실행에 추가"
@ -494,6 +509,26 @@ msgstr ""
msgid "All quiet"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "All set. I’ll check in on {weekday} at {time}."
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "All set. I’ll check in today at {time}."
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "All set. I’ll check in tomorrow at {time}."
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "All set. I’m here whenever you need me."
msgstr ""
#: src/components/UserFactEditor.tsx
msgid "All spaces"
msgstr ""
#. placeholder {0}: rule.matchValue
#: src/components/ApprovalRulesSettings.tsx
msgid "Allow {0} actions without asking"
@ -700,10 +735,6 @@ msgstr "인증하기"
msgid "Authorize this server so agents can use its tools. A popup opens."
msgstr "에이전트가 도구를 사용할 수 있도록 이 서버에 권한을 부여하세요. 팝업이 열립니다."
#: src/pages/Onboarding.tsx
msgid "Auto-detected or enter manually"
msgstr ""
#: src/pages/ExternalConversationSettings.tsx
msgid "Automated senders"
msgstr ""
@ -740,10 +771,6 @@ msgstr "로그인으로 돌아가기"
msgid "Back to timeline"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Balanced"
msgstr ""
#: src/pages/memory-providers/SupermemorySettingsForm.tsx
msgid "Base URL"
msgstr "서버 주소"
@ -753,6 +780,10 @@ msgstr "서버 주소"
msgid "Bearer token"
msgstr "Bearer 토큰"
#: src/components/NotificationSettingsPanel.tsx
msgid "Blocked in your browser settings"
msgstr ""
#: src/pages/Shell.tsx
msgid "Booting live desktop…"
msgstr "라이브 데스크톱 시작 중…"
@ -821,6 +852,10 @@ msgstr ""
msgid "Calm helper"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "Can I send you notifications?"
msgstr ""
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Can speak"
msgstr ""
@ -833,6 +868,7 @@ msgstr "서버에 연결할 수 없습니다."
#: src/components/ComputerMaintenanceActions.tsx
#: src/components/ComputerUpdateProgress.tsx
#: src/components/teach/TeachComputerOverlay.tsx
#: src/components/UserFactEditor.tsx
#: src/components/UserProfileSettings.tsx
#: src/pages/KnowledgeSection.tsx
#: src/pages/PluginsOverlay.tsx
@ -867,8 +903,8 @@ msgstr ""
msgid "Cancelled"
msgstr "취소됨"
#: src/pages/Onboarding.tsx
msgid "Casual & friendly"
#: src/components/UserFactEditor.tsx
msgid "Category"
msgstr ""
#: src/pages/AccountSettingsOverlay.tsx
@ -925,6 +961,10 @@ msgstr "확인해 주세요."
msgid "Check your email"
msgstr "이메일을 확인하세요"
#: src/components/heute/CoachRhythmControl.tsx
msgid "Check-ins"
msgstr ""
#: src/lib/voice-persona-copy.ts
msgid "Checking dependencies…"
msgstr ""
@ -1134,7 +1174,6 @@ msgid "Command"
msgstr "실행 명령"
#: src/components/UserProfileSettings.tsx
#: src/pages/Onboarding.tsx
msgid "Communication style"
msgstr ""
@ -1295,8 +1334,8 @@ msgstr "컨텍스트 한도"
#: src/components/integrations/IntegrationSetup.tsx
#: src/components/onboarding/OnboardingOoDesigner.tsx
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/pages/Onboarding.tsx
msgid "Continue"
msgstr "계속"
@ -1522,6 +1561,10 @@ msgstr "업데이트 상태를 불러올 수 없습니다"
msgid "Could not load voice settings"
msgstr "음성 설정을 불러오지 못했습니다."
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Could not look in this folder."
msgstr ""
#: src/pages/LocalSettings.tsx
msgid "Could not open local settings. Start the local server and create its owner account, then try again."
msgstr ""
@ -1674,6 +1717,7 @@ msgstr "기본 기억 범위를 변경하지 못했습니다."
msgid "Could not update voice"
msgstr ""
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
msgid "Couldn't load"
msgstr ""
@ -1682,6 +1726,7 @@ msgstr ""
msgid "Couldn't load messaging settings"
msgstr "메시징 설정을 불러올 수 없습니다"
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
msgid "Couldn't save"
msgstr ""
@ -1823,6 +1868,10 @@ msgstr ""
msgid "day"
msgstr "일"
#: src/lib/onboarding-copy.ts
msgid "Day-to-day work"
msgstr ""
#: src/pages/RoutineSchedule.tsx
msgid "days"
msgstr "일"
@ -1831,10 +1880,6 @@ msgstr "일"
msgid "Decline"
msgstr "거절"
#: src/pages/Onboarding.tsx
msgid "Deep-dive focused"
msgstr ""
#: src/pages/ModelSettingsOverlay.tsx
msgid "Default"
msgstr "기본값"
@ -1935,18 +1980,10 @@ msgstr ""
msgid "Desktop update"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Detailed & explanatory"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
msgid "Developer tools"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Direct & concise"
msgstr ""
#: src/components/integrations/IntegrationSetup.tsx
msgid "Direct MCP"
msgstr ""
@ -2066,6 +2103,10 @@ msgstr "예: Hivenet Agent, Presales, 근무표 봇"
msgid "Earlier message"
msgstr "이전 메시지"
#: src/components/UserProfileSettings.tsx
msgid "Edit"
msgstr ""
#: src/components/AskCard.tsx
msgid "Edit first"
msgstr "수정 후 보내기"
@ -2115,6 +2156,14 @@ msgstr ""
msgid "Enter this code at <0>{0}</0>"
msgstr "<0>{0}</0>에서 이 코드를 입력하세요"
#: src/components/heute/CoachRhythmControl.tsx
msgid "Evening"
msgstr ""
#: src/lib/routine-name.ts
msgid "Evening check-in"
msgstr ""
#: src/pages/ExternalConversationSettings.tsx
#: src/pages/RoutineSchedule.tsx
msgid "Every"
@ -2143,6 +2192,10 @@ msgstr "매주 월요일"
msgid "Every month"
msgstr "매월"
#: src/lib/onboarding-copy.ts
msgid "Every morning"
msgstr ""
#: src/pages/RoutineEditor.tsx
#: src/pages/RoutineSchedule.tsx
msgid "Every week"
@ -2183,6 +2236,10 @@ msgstr ""
msgid "Extra high"
msgstr "매우 높음"
#: src/components/UserFactEditor.tsx
msgid "Fact"
msgstr ""
#: src/components/CloudAgentCard.tsx
msgid "failed"
msgstr "실패"
@ -2256,10 +2313,6 @@ msgstr ""
msgid "Flag unexpected actions"
msgstr "예상하지 못한 작업 표시"
#: src/pages/Onboarding.tsx
msgid "Flexible & spontaneous"
msgstr ""
#: src/pages/PrivacySettingsOverlay.tsx
msgid "Fonts loaded locally"
msgstr ""
@ -2324,6 +2377,7 @@ msgstr "Git 이벤트"
msgid "Goal"
msgstr ""
#: src/components/heute/HeuteCoach.tsx
#: src/components/UserProfileSettings.tsx
#: src/pages/ProjectSessionTimeline.tsx
msgid "Goals"
@ -2360,6 +2414,7 @@ msgstr ""
msgid "Groups"
msgstr ""
#: src/components/heute/HeuteCoach.tsx
#: src/components/UserProfileSettings.tsx
msgid "Habits"
msgstr ""
@ -2434,10 +2489,6 @@ msgstr "비밀번호 숨기기"
msgid "High"
msgstr "높음"
#: src/pages/Onboarding.tsx
msgid "Hobbies & interests"
msgstr ""
#: src/components/onboarding/OnboardingOoDesigner.tsx
msgid "Hobby"
msgstr ""
@ -2451,10 +2502,6 @@ msgstr "시간"
msgid "hours"
msgstr "시간"
#: src/pages/Onboarding.tsx
msgid "How do you like to work?"
msgstr ""
#: src/pages/RoutineSchedule.tsx
msgid "How often"
msgstr "실행 주기"
@ -2463,10 +2510,6 @@ msgstr "실행 주기"
msgid "How should your Oo sound when reading replies aloud?"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "How should your Oo talk to you?"
msgstr ""
#: src/components/teach/SkillDraftCard.tsx
msgid "How to check"
msgstr "완료 확인 방법"
@ -2507,6 +2550,10 @@ msgstr ""
msgid "In progress"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "Inbox & email"
msgstr ""
#: src/pages/shell/bot-panel.tsx
msgid "Inherit default"
msgstr "기본 설정 사용"
@ -2730,6 +2777,10 @@ msgstr ""
msgid "Log out"
msgstr "로그아웃"
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Look"
msgstr ""
#. placeholder {0}: index + 1
#. placeholder {1}: looks.length
#: src/components/onboarding/LookPreviewCarousel.tsx
@ -2740,10 +2791,18 @@ msgstr ""
msgid "Look families"
msgstr ""
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Look for projects here?"
msgstr ""
#: src/components/onboarding/LookPreviewCarousel.tsx
msgid "Look variants"
msgstr ""
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Looking"
msgstr ""
#: src/pages/ModelSettingsOverlay.tsx
#: src/pages/shell/bot-panel.tsx
msgid "Low"
@ -2904,10 +2963,6 @@ msgstr "분"
msgid "minutes"
msgstr "분"
#: src/pages/HeuteBrief.tsx
msgid "Mission Control"
msgstr ""
#: src/pages/memory-providers/BuiltinSettingsForm.tsx
#: src/pages/ModelSettingsOverlay.tsx
#: src/pages/shell/bot-panel.tsx
@ -2964,6 +3019,18 @@ msgstr "컴퓨터 작업 더보기"
msgid "More conversations ({hiddenSessionCount})"
msgstr ""
#: src/components/heute/CoachRhythmControl.tsx
msgid "Morning"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "Morning and evening"
msgstr ""
#: src/lib/routine-name.ts
msgid "Morning brief"
msgstr ""
#: src/pages/shell/dialogs.tsx
msgid "Move them to your shared memory."
msgstr "공용 기억으로 옮깁니다."
@ -2975,7 +3042,6 @@ msgstr ""
#: src/components/teach/SkillDraftCard.tsx
#: src/pages/Auth.tsx
#: src/pages/GroupPanel.tsx
#: src/pages/Onboarding.tsx
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/bot-panel.tsx
#: src/pages/shell/dialogs.tsx
@ -3154,6 +3220,10 @@ msgstr "{peerBotName}와의 메시지가 아직 없습니다."
msgid "No model catalog is available."
msgstr "사용 가능한 모델 목록이 없습니다."
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "No projects found"
msgstr ""
#: src/pages/Shell.tsx
msgid "No projects yet."
msgstr ""
@ -3211,6 +3281,7 @@ msgstr "연결되지 않음"
msgid "Not in the plugin catalog"
msgstr "플러그인 카탈로그에 없음"
#: src/lib/onboarding-copy.ts
#: src/pages/shell/message-cards.tsx
msgid "Not now"
msgstr "나중에"
@ -3223,14 +3294,11 @@ msgstr "실행 중인 작업 없음"
msgid "Nothing remembered yet"
msgstr "아직 기억한 내용이 없습니다"
#: src/pages/Onboarding.tsx
msgid "Nothing specific"
msgstr ""
#: src/components/UserProfileSettings.tsx
msgid "Nothing yet"
msgstr ""
#: src/pages/SettingsOverlay.tsx
#: src/pages/shell/bot-panel.tsx
msgid "Notifications"
msgstr "알림"
@ -3244,6 +3312,10 @@ msgstr "지금"
msgid "Now using {0}."
msgstr "이제 {0} 모델을 사용합니다."
#: src/components/NotificationSettingsPanel.tsx
msgid "Nudges from your Oo"
msgstr ""
#: src/pages/McpOAuthCallback.tsx
msgid "OAuth authorization was cancelled."
msgstr "OAuth 인증이 취소되었습니다."
@ -3277,6 +3349,10 @@ msgstr "매월 1일 {0}에"
msgid "Onboarding"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "Once a week"
msgstr ""
#: src/lib/voice-persona-copy.ts
msgid "One more look before I answer…"
msgstr ""
@ -3285,6 +3361,10 @@ msgstr ""
msgid "Only empty spaces can be deleted. Delete its bots and groups first."
msgstr ""
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Only file and folder names are read."
msgstr ""
#: src/pages/ScratchpadSection.tsx
#: src/pages/Shell.tsx
msgid "Open"
@ -3536,6 +3616,10 @@ msgstr "서비스"
msgid "Pull request"
msgstr "풀 리퀘스트"
#: src/components/NotificationSettingsPanel.tsx
msgid "Push on this device"
msgstr ""
#: src/components/ai/DelegatedTaskCard.tsx
#: src/pages/ProjectSessionTimeline.tsx
msgid "Queued"
@ -3546,14 +3630,22 @@ msgstr "대기 중"
msgid "Queued…"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Quick & pragmatic"
msgstr ""
#: src/pages/HeuteBrief.tsx
msgid "Quiet day so far"
msgstr ""
#: src/components/NotificationSettingsPanel.tsx
msgid "Quiet hours"
msgstr ""
#: src/components/NotificationSettingsPanel.tsx
msgid "Quiet hours end"
msgstr ""
#: src/components/NotificationSettingsPanel.tsx
msgid "Quiet hours start"
msgstr ""
#: src/pages/Shell.tsx
msgid "Quote"
msgstr ""
@ -3773,6 +3865,10 @@ msgstr ""
msgid "Reporting from home…"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "Research & writing"
msgstr ""
#: src/pages/shell/bot-capabilities.tsx
msgid "research, writing"
msgstr ""
@ -3912,6 +4008,7 @@ msgstr ""
#: src/components/AskCard.tsx
#: src/components/teach/SkillDraftCard.tsx
#: src/components/UserFactEditor.tsx
#: src/pages/ExternalConversationSettings.tsx
#: src/pages/GroupPanel.tsx
#: src/pages/KnowledgeSection.tsx
@ -4012,15 +4109,12 @@ msgstr "검색 중…"
msgid "Select {0}"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Select all that apply"
msgstr ""
#: src/pages/PrivacySettingsOverlay.tsx
msgid "Self-hosted (better-auth)"
msgstr ""
#: src/pages/Shell.tsx
#: src/pages/shell/message-cards.tsx
msgid "Send"
msgstr "보내기"
@ -4184,7 +4278,9 @@ msgid "Skills"
msgstr "스킬"
#: src/components/integrations/IntegrationSetup.tsx
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/lib/onboarding-copy.ts
#: src/pages/Shell.tsx
msgid "Skip"
msgstr "건너뛰기"
@ -4269,6 +4365,10 @@ msgstr ""
msgid "Speaking with intent…"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "Specialist crew"
msgstr ""
#: src/lib/model-tier-label.ts
msgid "Standard"
msgstr ""
@ -4362,10 +4462,6 @@ msgstr ""
msgid "Stronger together — {0} specialists"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Structured & planned"
msgstr ""
#: src/pages/Shell.tsx
msgid "subagent"
msgstr "보조 에이전트"
@ -4451,10 +4547,6 @@ msgstr ""
msgid "Tell me what matters. I'll move."
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Tell us about yourself"
msgstr ""
#: src/components/teach/SkillDraftCard.tsx
msgid "Test"
msgstr "시험 실행"
@ -4545,10 +4637,6 @@ msgstr "실행 시각"
msgid "Time zone"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Timezone"
msgstr ""
#: src/pages/shell/bot-panel.tsx
msgid "Title"
msgstr "역할"
@ -4603,6 +4691,10 @@ msgstr ""
msgid "Try again"
msgstr "다시 시도"
#: src/lib/onboarding-copy.ts
msgid "Turn on"
msgstr ""
#: src/components/AskCard.tsx
msgid "Type your answer"
msgstr "답변을 입력하세요"
@ -4796,6 +4888,14 @@ msgstr "웹훅"
msgid "Weekdays"
msgstr "주중"
#: src/components/heute/CoachRhythmControl.tsx
msgid "Weekly"
msgstr ""
#: src/lib/routine-name.ts
msgid "Weekly review"
msgstr ""
#: src/pages/shell/dialogs.tsx
msgid "What about its memories?"
msgstr "이 Bot의 기억은 어떻게 할까요?"
@ -4804,6 +4904,10 @@ msgstr "이 Bot의 기억은 어떻게 할까요?"
msgid "What do you think — react or answer in a sentence."
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "What do you want me on first?"
msgstr ""
#: src/components/onboarding/OnboardingOoDesigner.tsx
msgid "What feels like you?"
msgstr ""
@ -4844,6 +4948,10 @@ msgstr "완료 후 전달할 결과"
msgid "What's on your mind? I'll route it — and tell you when I disagree."
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "What’s one thing you want to get done in the next few weeks?"
msgstr ""
#: src/lib/voice-persona-copy.ts
msgid "What's personal today?"
msgstr ""
@ -4856,6 +4964,14 @@ msgstr ""
msgid "When a webhook fires"
msgstr "웹훅이 실행될 때"
#: src/lib/onboarding-copy.ts
msgid "When should I check in with you?"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "When should I stay quiet?"
msgstr ""
#: src/pages/RoutineEditor.tsx
msgid "When to run"
msgstr "실행 시간"
@ -4881,10 +4997,6 @@ msgstr ""
msgid "Work channel open — go ahead."
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Work style"
msgstr ""
#: src/components/stage/OoStage.tsx
msgid "Working"
msgstr ""
@ -4941,8 +5053,11 @@ msgstr ""
msgid "Your email address"
msgstr "이메일 주소"
#: src/lib/onboarding-copy.ts
msgid "Your goal"
msgstr ""
#: src/pages/Auth.tsx
#: src/pages/Onboarding.tsx
msgid "Your name"
msgstr "이름"

View File

@ -21,6 +21,11 @@ msgstr " (não lido)"
msgid "(optional)"
msgstr ""
#. placeholder {0}: habit.streak
#: src/components/heute/HeuteCoach.tsx
msgid "{0, plural, one {# day} other {# days}}"
msgstr ""
#. placeholder {0}: group.entries.length
#: src/pages/ModelSettingsOverlay.tsx
msgid "{0, plural, one {# model} other {# models}}"
@ -31,6 +36,11 @@ msgstr "{0, plural, one {# modelo} other {# modelos}}"
msgid "{0, plural, one {# more waiting} other {# more waiting}}"
msgstr ""
#. placeholder {0}: habit.streak
#: src/components/heute/HeuteCoach.tsx
msgid "{0, plural, one {# week} other {# weeks}}"
msgstr ""
#. placeholder {0}: file.name
#: src/pages/Shell.tsx
msgid "{0} (max {ATTACHMENT_MAX_COUNT} attachments)"
@ -281,10 +291,6 @@ msgstr ""
msgid "1:1 conversation with {title}"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "A few quick preferences so your Oo can be better tailored to you."
msgstr ""
#: src/pages/shell/bot-picker.tsx
msgid "About groups"
msgstr ""
@ -350,6 +356,7 @@ msgstr "Modelo ativo"
msgid "Activity"
msgstr "Atividade"
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/pages/PluginsOverlay.tsx
#: src/pages/ScratchpadSection.tsx
msgid "Add"
@ -387,6 +394,10 @@ msgstr "Adicione um URL do servidor HTTPS."
msgid "Add another"
msgstr ""
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Add as projects"
msgstr ""
#: src/pages/PluginsOverlay.tsx
msgid "Add Executor"
msgstr "Adicionar Executor"
@ -428,6 +439,10 @@ msgstr "Adicionar servidor"
msgid "Add server URL"
msgstr ""
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Add to About you"
msgstr ""
#: src/components/teach/SkillDraftCard.tsx
msgid "Add to routine"
msgstr "Adicionar à rotina"
@ -494,6 +509,26 @@ msgstr ""
msgid "All quiet"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "All set. I’ll check in on {weekday} at {time}."
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "All set. I’ll check in today at {time}."
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "All set. I’ll check in tomorrow at {time}."
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "All set. I’m here whenever you need me."
msgstr ""
#: src/components/UserFactEditor.tsx
msgid "All spaces"
msgstr ""
#. placeholder {0}: rule.matchValue
#: src/components/ApprovalRulesSettings.tsx
msgid "Allow {0} actions without asking"
@ -700,10 +735,6 @@ msgstr "Autorizar"
msgid "Authorize this server so agents can use its tools. A popup opens."
msgstr "Autorize este servidor para que os agentes usem suas ferramentas. Uma janela pop-up será aberta."
#: src/pages/Onboarding.tsx
msgid "Auto-detected or enter manually"
msgstr ""
#: src/pages/ExternalConversationSettings.tsx
msgid "Automated senders"
msgstr ""
@ -740,10 +771,6 @@ msgstr "Voltar para o login"
msgid "Back to timeline"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Balanced"
msgstr ""
#: src/pages/memory-providers/SupermemorySettingsForm.tsx
msgid "Base URL"
msgstr "URL Base"
@ -753,6 +780,10 @@ msgstr "URL Base"
msgid "Bearer token"
msgstr "Token portador"
#: src/components/NotificationSettingsPanel.tsx
msgid "Blocked in your browser settings"
msgstr ""
#: src/pages/Shell.tsx
msgid "Booting live desktop…"
msgstr "Iniciando a área de trabalho ao vivo..."
@ -821,6 +852,10 @@ msgstr ""
msgid "Calm helper"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "Can I send you notifications?"
msgstr ""
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Can speak"
msgstr ""
@ -833,6 +868,7 @@ msgstr "Não é possível acessar o servidor."
#: src/components/ComputerMaintenanceActions.tsx
#: src/components/ComputerUpdateProgress.tsx
#: src/components/teach/TeachComputerOverlay.tsx
#: src/components/UserFactEditor.tsx
#: src/components/UserProfileSettings.tsx
#: src/pages/KnowledgeSection.tsx
#: src/pages/PluginsOverlay.tsx
@ -867,8 +903,8 @@ msgstr ""
msgid "Cancelled"
msgstr "Cancelado"
#: src/pages/Onboarding.tsx
msgid "Casual & friendly"
#: src/components/UserFactEditor.tsx
msgid "Category"
msgstr ""
#: src/pages/AccountSettingsOverlay.tsx
@ -925,6 +961,10 @@ msgstr "Acompanhamento"
msgid "Check your email"
msgstr "Confira seu e-mail"
#: src/components/heute/CoachRhythmControl.tsx
msgid "Check-ins"
msgstr ""
#: src/lib/voice-persona-copy.ts
msgid "Checking dependencies…"
msgstr ""
@ -1134,7 +1174,6 @@ msgid "Command"
msgstr "Comando"
#: src/components/UserProfileSettings.tsx
#: src/pages/Onboarding.tsx
msgid "Communication style"
msgstr ""
@ -1295,8 +1334,8 @@ msgstr "Limite de contexto"
#: src/components/integrations/IntegrationSetup.tsx
#: src/components/onboarding/OnboardingOoDesigner.tsx
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/pages/Onboarding.tsx
msgid "Continue"
msgstr "Continuar"
@ -1522,6 +1561,10 @@ msgstr ""
msgid "Could not load voice settings"
msgstr "Não foi possível carregar as configurações de voz"
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Could not look in this folder."
msgstr ""
#: src/pages/LocalSettings.tsx
msgid "Could not open local settings. Start the local server and create its owner account, then try again."
msgstr ""
@ -1674,6 +1717,7 @@ msgstr "Não foi possível atualizar o escopo de memória padrão"
msgid "Could not update voice"
msgstr ""
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
msgid "Couldn't load"
msgstr ""
@ -1682,6 +1726,7 @@ msgstr ""
msgid "Couldn't load messaging settings"
msgstr ""
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
msgid "Couldn't save"
msgstr ""
@ -1823,6 +1868,10 @@ msgstr ""
msgid "day"
msgstr "dia"
#: src/lib/onboarding-copy.ts
msgid "Day-to-day work"
msgstr ""
#: src/pages/RoutineSchedule.tsx
msgid "days"
msgstr "dias"
@ -1831,10 +1880,6 @@ msgstr "dias"
msgid "Decline"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Deep-dive focused"
msgstr ""
#: src/pages/ModelSettingsOverlay.tsx
msgid "Default"
msgstr "Padrão"
@ -1935,18 +1980,10 @@ msgstr ""
msgid "Desktop update"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Detailed & explanatory"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
msgid "Developer tools"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Direct & concise"
msgstr ""
#: src/components/integrations/IntegrationSetup.tsx
msgid "Direct MCP"
msgstr ""
@ -2066,6 +2103,10 @@ msgstr "ex.: Hivenet Agent, Presales, bot de planilhas"
msgid "Earlier message"
msgstr ""
#: src/components/UserProfileSettings.tsx
msgid "Edit"
msgstr ""
#: src/components/AskCard.tsx
msgid "Edit first"
msgstr "Editar primeiro"
@ -2115,6 +2156,14 @@ msgstr ""
msgid "Enter this code at <0>{0}</0>"
msgstr "Digite este código em <0>{0}</0>"
#: src/components/heute/CoachRhythmControl.tsx
msgid "Evening"
msgstr ""
#: src/lib/routine-name.ts
msgid "Evening check-in"
msgstr ""
#: src/pages/ExternalConversationSettings.tsx
#: src/pages/RoutineSchedule.tsx
msgid "Every"
@ -2143,6 +2192,10 @@ msgstr "Toda segunda-feira"
msgid "Every month"
msgstr "A cada mês"
#: src/lib/onboarding-copy.ts
msgid "Every morning"
msgstr ""
#: src/pages/RoutineEditor.tsx
#: src/pages/RoutineSchedule.tsx
msgid "Every week"
@ -2183,6 +2236,10 @@ msgstr ""
msgid "Extra high"
msgstr "Extra alta"
#: src/components/UserFactEditor.tsx
msgid "Fact"
msgstr ""
#: src/components/CloudAgentCard.tsx
msgid "failed"
msgstr "falhou"
@ -2256,10 +2313,6 @@ msgstr ""
msgid "Flag unexpected actions"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Flexible & spontaneous"
msgstr ""
#: src/pages/PrivacySettingsOverlay.tsx
msgid "Fonts loaded locally"
msgstr ""
@ -2324,6 +2377,7 @@ msgstr ""
msgid "Goal"
msgstr ""
#: src/components/heute/HeuteCoach.tsx
#: src/components/UserProfileSettings.tsx
#: src/pages/ProjectSessionTimeline.tsx
msgid "Goals"
@ -2360,6 +2414,7 @@ msgstr ""
msgid "Groups"
msgstr ""
#: src/components/heute/HeuteCoach.tsx
#: src/components/UserProfileSettings.tsx
msgid "Habits"
msgstr ""
@ -2434,10 +2489,6 @@ msgstr ""
msgid "High"
msgstr "Alto"
#: src/pages/Onboarding.tsx
msgid "Hobbies & interests"
msgstr ""
#: src/components/onboarding/OnboardingOoDesigner.tsx
msgid "Hobby"
msgstr ""
@ -2451,10 +2502,6 @@ msgstr "hora"
msgid "hours"
msgstr "horas"
#: src/pages/Onboarding.tsx
msgid "How do you like to work?"
msgstr ""
#: src/pages/RoutineSchedule.tsx
msgid "How often"
msgstr "Com que frequência"
@ -2463,10 +2510,6 @@ msgstr "Com que frequência"
msgid "How should your Oo sound when reading replies aloud?"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "How should your Oo talk to you?"
msgstr ""
#: src/components/teach/SkillDraftCard.tsx
msgid "How to check"
msgstr "Como verificar"
@ -2507,6 +2550,10 @@ msgstr ""
msgid "In progress"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "Inbox & email"
msgstr ""
#: src/pages/shell/bot-panel.tsx
msgid "Inherit default"
msgstr "Herdar (padrão)"
@ -2730,6 +2777,10 @@ msgstr ""
msgid "Log out"
msgstr "Sair"
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Look"
msgstr ""
#. placeholder {0}: index + 1
#. placeholder {1}: looks.length
#: src/components/onboarding/LookPreviewCarousel.tsx
@ -2740,10 +2791,18 @@ msgstr ""
msgid "Look families"
msgstr ""
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Look for projects here?"
msgstr ""
#: src/components/onboarding/LookPreviewCarousel.tsx
msgid "Look variants"
msgstr ""
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Looking"
msgstr ""
#: src/pages/ModelSettingsOverlay.tsx
#: src/pages/shell/bot-panel.tsx
msgid "Low"
@ -2904,10 +2963,6 @@ msgstr "minuto"
msgid "minutes"
msgstr "minutos"
#: src/pages/HeuteBrief.tsx
msgid "Mission Control"
msgstr ""
#: src/pages/memory-providers/BuiltinSettingsForm.tsx
#: src/pages/ModelSettingsOverlay.tsx
#: src/pages/shell/bot-panel.tsx
@ -2964,6 +3019,18 @@ msgstr ""
msgid "More conversations ({hiddenSessionCount})"
msgstr ""
#: src/components/heute/CoachRhythmControl.tsx
msgid "Morning"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "Morning and evening"
msgstr ""
#: src/lib/routine-name.ts
msgid "Morning brief"
msgstr ""
#: src/pages/shell/dialogs.tsx
msgid "Move them to your shared memory."
msgstr "Mova-os para a sua memória compartilhada."
@ -2975,7 +3042,6 @@ msgstr ""
#: src/components/teach/SkillDraftCard.tsx
#: src/pages/Auth.tsx
#: src/pages/GroupPanel.tsx
#: src/pages/Onboarding.tsx
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/bot-panel.tsx
#: src/pages/shell/dialogs.tsx
@ -3154,6 +3220,10 @@ msgstr "Ainda sem mensagens com {peerBotName}."
msgid "No model catalog is available."
msgstr "Nenhum catálogo de modelos está disponível."
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "No projects found"
msgstr ""
#: src/pages/Shell.tsx
msgid "No projects yet."
msgstr ""
@ -3211,6 +3281,7 @@ msgstr "Não Conectado"
msgid "Not in the plugin catalog"
msgstr ""
#: src/lib/onboarding-copy.ts
#: src/pages/shell/message-cards.tsx
msgid "Not now"
msgstr "Agora não"
@ -3223,14 +3294,11 @@ msgstr "Nada ainda está em execução"
msgid "Nothing remembered yet"
msgstr "Nada memorizado ainda"
#: src/pages/Onboarding.tsx
msgid "Nothing specific"
msgstr ""
#: src/components/UserProfileSettings.tsx
msgid "Nothing yet"
msgstr ""
#: src/pages/SettingsOverlay.tsx
#: src/pages/shell/bot-panel.tsx
msgid "Notifications"
msgstr "Notificações"
@ -3244,6 +3312,10 @@ msgstr "Agora"
msgid "Now using {0}."
msgstr "Agora usando {0}."
#: src/components/NotificationSettingsPanel.tsx
msgid "Nudges from your Oo"
msgstr ""
#: src/pages/McpOAuthCallback.tsx
msgid "OAuth authorization was cancelled."
msgstr "A autorização OAuth foi cancelada."
@ -3277,6 +3349,10 @@ msgstr "no dia 1º às {0}"
msgid "Onboarding"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "Once a week"
msgstr ""
#: src/lib/voice-persona-copy.ts
msgid "One more look before I answer…"
msgstr ""
@ -3285,6 +3361,10 @@ msgstr ""
msgid "Only empty spaces can be deleted. Delete its bots and groups first."
msgstr ""
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Only file and folder names are read."
msgstr ""
#: src/pages/ScratchpadSection.tsx
#: src/pages/Shell.tsx
msgid "Open"
@ -3536,6 +3616,10 @@ msgstr "Provedores"
msgid "Pull request"
msgstr "Solicitação de pull"
#: src/components/NotificationSettingsPanel.tsx
msgid "Push on this device"
msgstr ""
#: src/components/ai/DelegatedTaskCard.tsx
#: src/pages/ProjectSessionTimeline.tsx
msgid "Queued"
@ -3546,14 +3630,22 @@ msgstr "Na fila"
msgid "Queued…"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Quick & pragmatic"
msgstr ""
#: src/pages/HeuteBrief.tsx
msgid "Quiet day so far"
msgstr ""
#: src/components/NotificationSettingsPanel.tsx
msgid "Quiet hours"
msgstr ""
#: src/components/NotificationSettingsPanel.tsx
msgid "Quiet hours end"
msgstr ""
#: src/components/NotificationSettingsPanel.tsx
msgid "Quiet hours start"
msgstr ""
#: src/pages/Shell.tsx
msgid "Quote"
msgstr ""
@ -3773,6 +3865,10 @@ msgstr ""
msgid "Reporting from home…"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "Research & writing"
msgstr ""
#: src/pages/shell/bot-capabilities.tsx
msgid "research, writing"
msgstr ""
@ -3912,6 +4008,7 @@ msgstr ""
#: src/components/AskCard.tsx
#: src/components/teach/SkillDraftCard.tsx
#: src/components/UserFactEditor.tsx
#: src/pages/ExternalConversationSettings.tsx
#: src/pages/GroupPanel.tsx
#: src/pages/KnowledgeSection.tsx
@ -4012,15 +4109,12 @@ msgstr "Pesquisando…"
msgid "Select {0}"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Select all that apply"
msgstr ""
#: src/pages/PrivacySettingsOverlay.tsx
msgid "Self-hosted (better-auth)"
msgstr ""
#: src/pages/Shell.tsx
#: src/pages/shell/message-cards.tsx
msgid "Send"
msgstr "Enviar"
@ -4184,7 +4278,9 @@ msgid "Skills"
msgstr "Habilidades"
#: src/components/integrations/IntegrationSetup.tsx
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/lib/onboarding-copy.ts
#: src/pages/Shell.tsx
msgid "Skip"
msgstr "Pular"
@ -4269,6 +4365,10 @@ msgstr ""
msgid "Speaking with intent…"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "Specialist crew"
msgstr ""
#: src/lib/model-tier-label.ts
msgid "Standard"
msgstr ""
@ -4362,10 +4462,6 @@ msgstr ""
msgid "Stronger together — {0} specialists"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Structured & planned"
msgstr ""
#: src/pages/Shell.tsx
msgid "subagent"
msgstr "subagente"
@ -4451,10 +4547,6 @@ msgstr ""
msgid "Tell me what matters. I'll move."
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Tell us about yourself"
msgstr ""
#: src/components/teach/SkillDraftCard.tsx
msgid "Test"
msgstr "Teste"
@ -4545,10 +4637,6 @@ msgstr "Hora do dia"
msgid "Time zone"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Timezone"
msgstr ""
#: src/pages/shell/bot-panel.tsx
msgid "Title"
msgstr "Título"
@ -4603,6 +4691,10 @@ msgstr ""
msgid "Try again"
msgstr "Tentar novamente"
#: src/lib/onboarding-copy.ts
msgid "Turn on"
msgstr ""
#: src/components/AskCard.tsx
msgid "Type your answer"
msgstr "Digite sua resposta"
@ -4796,6 +4888,14 @@ msgstr ""
msgid "Weekdays"
msgstr "Dias da semana"
#: src/components/heute/CoachRhythmControl.tsx
msgid "Weekly"
msgstr ""
#: src/lib/routine-name.ts
msgid "Weekly review"
msgstr ""
#: src/pages/shell/dialogs.tsx
msgid "What about its memories?"
msgstr "E suas memórias?"
@ -4804,6 +4904,10 @@ msgstr "E suas memórias?"
msgid "What do you think — react or answer in a sentence."
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "What do you want me on first?"
msgstr ""
#: src/components/onboarding/OnboardingOoDesigner.tsx
msgid "What feels like you?"
msgstr ""
@ -4844,6 +4948,10 @@ msgstr "O que devolver"
msgid "What's on your mind? I'll route it — and tell you when I disagree."
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "What’s one thing you want to get done in the next few weeks?"
msgstr ""
#: src/lib/voice-persona-copy.ts
msgid "What's personal today?"
msgstr ""
@ -4856,6 +4964,14 @@ msgstr ""
msgid "When a webhook fires"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "When should I check in with you?"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "When should I stay quiet?"
msgstr ""
#: src/pages/RoutineEditor.tsx
msgid "When to run"
msgstr "Quando executar"
@ -4881,10 +4997,6 @@ msgstr ""
msgid "Work channel open — go ahead."
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Work style"
msgstr ""
#: src/components/stage/OoStage.tsx
msgid "Working"
msgstr ""
@ -4941,8 +5053,11 @@ msgstr ""
msgid "Your email address"
msgstr "Seu endereço de e-mail"
#: src/lib/onboarding-copy.ts
msgid "Your goal"
msgstr ""
#: src/pages/Auth.tsx
#: src/pages/Onboarding.tsx
msgid "Your name"
msgstr "O seu nome"

View File

@ -21,6 +21,11 @@ msgstr " (непрочитано)"
msgid "(optional)"
msgstr ""
#. placeholder {0}: habit.streak
#: src/components/heute/HeuteCoach.tsx
msgid "{0, plural, one {# day} other {# days}}"
msgstr ""
#. placeholder {0}: group.entries.length
#: src/pages/ModelSettingsOverlay.tsx
msgid "{0, plural, one {# model} other {# models}}"
@ -31,6 +36,11 @@ msgstr "{0, plural, one {# модель} few {# модели} many {# модел
msgid "{0, plural, one {# more waiting} other {# more waiting}}"
msgstr ""
#. placeholder {0}: habit.streak
#: src/components/heute/HeuteCoach.tsx
msgid "{0, plural, one {# week} other {# weeks}}"
msgstr ""
#. placeholder {0}: file.name
#: src/pages/Shell.tsx
msgid "{0} (max {ATTACHMENT_MAX_COUNT} attachments)"
@ -281,10 +291,6 @@ msgstr ""
msgid "1:1 conversation with {title}"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "A few quick preferences so your Oo can be better tailored to you."
msgstr ""
#: src/pages/shell/bot-picker.tsx
msgid "About groups"
msgstr "О группах"
@ -350,6 +356,7 @@ msgstr "Активная модель"
msgid "Activity"
msgstr "Активность"
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/pages/PluginsOverlay.tsx
#: src/pages/ScratchpadSection.tsx
msgid "Add"
@ -387,6 +394,10 @@ msgstr "Добавьте URL-адрес HTTPS-сервера."
msgid "Add another"
msgstr "Добавить еще"
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Add as projects"
msgstr ""
#: src/pages/PluginsOverlay.tsx
msgid "Add Executor"
msgstr "Добавить Executor"
@ -428,6 +439,10 @@ msgstr "Добавить сервер"
msgid "Add server URL"
msgstr "Добавьте URL сервера"
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Add to About you"
msgstr ""
#: src/components/teach/SkillDraftCard.tsx
msgid "Add to routine"
msgstr "Добавить в задачу"
@ -494,6 +509,26 @@ msgstr ""
msgid "All quiet"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "All set. I’ll check in on {weekday} at {time}."
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "All set. I’ll check in today at {time}."
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "All set. I’ll check in tomorrow at {time}."
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "All set. I’m here whenever you need me."
msgstr ""
#: src/components/UserFactEditor.tsx
msgid "All spaces"
msgstr ""
#. placeholder {0}: rule.matchValue
#: src/components/ApprovalRulesSettings.tsx
msgid "Allow {0} actions without asking"
@ -700,10 +735,6 @@ msgstr "Авторизовать"
msgid "Authorize this server so agents can use its tools. A popup opens."
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Auto-detected or enter manually"
msgstr ""
#: src/pages/ExternalConversationSettings.tsx
msgid "Automated senders"
msgstr "Автоматические отправители"
@ -740,10 +771,6 @@ msgstr "Вернуться ко входу"
msgid "Back to timeline"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Balanced"
msgstr ""
#: src/pages/memory-providers/SupermemorySettingsForm.tsx
msgid "Base URL"
msgstr "Базовый URL"
@ -753,6 +780,10 @@ msgstr "Базовый URL"
msgid "Bearer token"
msgstr "Bearer-токен"
#: src/components/NotificationSettingsPanel.tsx
msgid "Blocked in your browser settings"
msgstr ""
#: src/pages/Shell.tsx
msgid "Booting live desktop…"
msgstr "Загрузка рабочего стола…"
@ -821,6 +852,10 @@ msgstr ""
msgid "Calm helper"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "Can I send you notifications?"
msgstr ""
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Can speak"
msgstr ""
@ -833,6 +868,7 @@ msgstr "Не удаётся связаться с сервером."
#: src/components/ComputerMaintenanceActions.tsx
#: src/components/ComputerUpdateProgress.tsx
#: src/components/teach/TeachComputerOverlay.tsx
#: src/components/UserFactEditor.tsx
#: src/components/UserProfileSettings.tsx
#: src/pages/KnowledgeSection.tsx
#: src/pages/PluginsOverlay.tsx
@ -867,8 +903,8 @@ msgstr ""
msgid "Cancelled"
msgstr "Отменено"
#: src/pages/Onboarding.tsx
msgid "Casual & friendly"
#: src/components/UserFactEditor.tsx
msgid "Category"
msgstr ""
#: src/pages/AccountSettingsOverlay.tsx
@ -925,6 +961,10 @@ msgstr "Проверить."
msgid "Check your email"
msgstr "Проверьте свою электронную почту"
#: src/components/heute/CoachRhythmControl.tsx
msgid "Check-ins"
msgstr ""
#: src/lib/voice-persona-copy.ts
msgid "Checking dependencies…"
msgstr ""
@ -1134,7 +1174,6 @@ msgid "Command"
msgstr "Команда"
#: src/components/UserProfileSettings.tsx
#: src/pages/Onboarding.tsx
msgid "Communication style"
msgstr ""
@ -1295,8 +1334,8 @@ msgstr ""
#: src/components/integrations/IntegrationSetup.tsx
#: src/components/onboarding/OnboardingOoDesigner.tsx
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/pages/Onboarding.tsx
msgid "Continue"
msgstr "Продолжить"
@ -1522,6 +1561,10 @@ msgstr "Не удалось загрузить статус обновления
msgid "Could not load voice settings"
msgstr "Не удалось загрузить голосовые настройки."
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Could not look in this folder."
msgstr ""
#: src/pages/LocalSettings.tsx
msgid "Could not open local settings. Start the local server and create its owner account, then try again."
msgstr "Не удалось открыть локальные настройки. Запустите локальный сервер и создайте учетную запись владельца, затем повторите попытку."
@ -1674,6 +1717,7 @@ msgstr "Не удалось обновить область памяти по у
msgid "Could not update voice"
msgstr ""
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
msgid "Couldn't load"
msgstr ""
@ -1682,6 +1726,7 @@ msgstr ""
msgid "Couldn't load messaging settings"
msgstr "Не удалось загрузить настройки обмена сообщениями."
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
msgid "Couldn't save"
msgstr ""
@ -1823,6 +1868,10 @@ msgstr ""
msgid "day"
msgstr "день"
#: src/lib/onboarding-copy.ts
msgid "Day-to-day work"
msgstr ""
#: src/pages/RoutineSchedule.tsx
msgid "days"
msgstr "дни"
@ -1831,10 +1880,6 @@ msgstr "дни"
msgid "Decline"
msgstr "Отклонить"
#: src/pages/Onboarding.tsx
msgid "Deep-dive focused"
msgstr ""
#: src/pages/ModelSettingsOverlay.tsx
msgid "Default"
msgstr ""
@ -1935,18 +1980,10 @@ msgstr "Настольное приложение"
msgid "Desktop update"
msgstr "Обновление настольного приложения"
#: src/pages/Onboarding.tsx
msgid "Detailed & explanatory"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
msgid "Developer tools"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Direct & concise"
msgstr ""
#: src/components/integrations/IntegrationSetup.tsx
msgid "Direct MCP"
msgstr "Прямой MCP"
@ -2066,6 +2103,10 @@ msgstr "напр. Hivenet Agent, Presales, бот табелей"
msgid "Earlier message"
msgstr "Предыдущее сообщение"
#: src/components/UserProfileSettings.tsx
msgid "Edit"
msgstr ""
#: src/components/AskCard.tsx
msgid "Edit first"
msgstr "Сначала отредактируйте"
@ -2115,6 +2156,14 @@ msgstr ""
msgid "Enter this code at <0>{0}</0>"
msgstr "Введите этот код по адресу <0>{0}</0>."
#: src/components/heute/CoachRhythmControl.tsx
msgid "Evening"
msgstr ""
#: src/lib/routine-name.ts
msgid "Evening check-in"
msgstr ""
#: src/pages/ExternalConversationSettings.tsx
#: src/pages/RoutineSchedule.tsx
msgid "Every"
@ -2143,6 +2192,10 @@ msgstr "Каждый понедельник"
msgid "Every month"
msgstr "Каждый месяц"
#: src/lib/onboarding-copy.ts
msgid "Every morning"
msgstr ""
#: src/pages/RoutineEditor.tsx
#: src/pages/RoutineSchedule.tsx
msgid "Every week"
@ -2183,6 +2236,10 @@ msgstr "Внешний диалог"
msgid "Extra high"
msgstr "Очень высокий"
#: src/components/UserFactEditor.tsx
msgid "Fact"
msgstr ""
#: src/components/CloudAgentCard.tsx
msgid "failed"
msgstr "неуспешный"
@ -2256,10 +2313,6 @@ msgstr ""
msgid "Flag unexpected actions"
msgstr "Пометить неожиданные действия"
#: src/pages/Onboarding.tsx
msgid "Flexible & spontaneous"
msgstr ""
#: src/pages/PrivacySettingsOverlay.tsx
msgid "Fonts loaded locally"
msgstr ""
@ -2324,6 +2377,7 @@ msgstr "Git-событие"
msgid "Goal"
msgstr ""
#: src/components/heute/HeuteCoach.tsx
#: src/components/UserProfileSettings.tsx
#: src/pages/ProjectSessionTimeline.tsx
msgid "Goals"
@ -2360,6 +2414,7 @@ msgstr "Обновления группы"
msgid "Groups"
msgstr "Группы"
#: src/components/heute/HeuteCoach.tsx
#: src/components/UserProfileSettings.tsx
msgid "Habits"
msgstr ""
@ -2434,10 +2489,6 @@ msgstr "Скрыть пароль"
msgid "High"
msgstr "Высокий"
#: src/pages/Onboarding.tsx
msgid "Hobbies & interests"
msgstr ""
#: src/components/onboarding/OnboardingOoDesigner.tsx
msgid "Hobby"
msgstr ""
@ -2451,10 +2502,6 @@ msgstr "час"
msgid "hours"
msgstr "часы"
#: src/pages/Onboarding.tsx
msgid "How do you like to work?"
msgstr ""
#: src/pages/RoutineSchedule.tsx
msgid "How often"
msgstr "Как часто"
@ -2463,10 +2510,6 @@ msgstr "Как часто"
msgid "How should your Oo sound when reading replies aloud?"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "How should your Oo talk to you?"
msgstr ""
#: src/components/teach/SkillDraftCard.tsx
msgid "How to check"
msgstr "Как проверить"
@ -2507,6 +2550,10 @@ msgstr ""
msgid "In progress"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "Inbox & email"
msgstr ""
#: src/pages/shell/bot-panel.tsx
msgid "Inherit default"
msgstr "Наследовать значение по умолчанию"
@ -2730,6 +2777,10 @@ msgstr "Настройки локального сервера"
msgid "Log out"
msgstr "Выйти"
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Look"
msgstr ""
#. placeholder {0}: index + 1
#. placeholder {1}: looks.length
#: src/components/onboarding/LookPreviewCarousel.tsx
@ -2740,10 +2791,18 @@ msgstr ""
msgid "Look families"
msgstr ""
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Look for projects here?"
msgstr ""
#: src/components/onboarding/LookPreviewCarousel.tsx
msgid "Look variants"
msgstr ""
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Looking"
msgstr ""
#: src/pages/ModelSettingsOverlay.tsx
#: src/pages/shell/bot-panel.tsx
msgid "Low"
@ -2904,10 +2963,6 @@ msgstr "минута"
msgid "minutes"
msgstr "минуты"
#: src/pages/HeuteBrief.tsx
msgid "Mission Control"
msgstr ""
#: src/pages/memory-providers/BuiltinSettingsForm.tsx
#: src/pages/ModelSettingsOverlay.tsx
#: src/pages/shell/bot-panel.tsx
@ -2964,6 +3019,18 @@ msgstr "Больше действий с компьютером"
msgid "More conversations ({hiddenSessionCount})"
msgstr ""
#: src/components/heute/CoachRhythmControl.tsx
msgid "Morning"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "Morning and evening"
msgstr ""
#: src/lib/routine-name.ts
msgid "Morning brief"
msgstr ""
#: src/pages/shell/dialogs.tsx
msgid "Move them to your shared memory."
msgstr "Переместите их в свою общую память."
@ -2975,7 +3042,6 @@ msgstr ""
#: src/components/teach/SkillDraftCard.tsx
#: src/pages/Auth.tsx
#: src/pages/GroupPanel.tsx
#: src/pages/Onboarding.tsx
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/bot-panel.tsx
#: src/pages/shell/dialogs.tsx
@ -3154,6 +3220,10 @@ msgstr "Сообщений с {peerBotName} пока нет."
msgid "No model catalog is available."
msgstr "Каталога моделей нет."
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "No projects found"
msgstr ""
#: src/pages/Shell.tsx
msgid "No projects yet."
msgstr ""
@ -3211,6 +3281,7 @@ msgstr "Не подключено"
msgid "Not in the plugin catalog"
msgstr "Нет в каталоге плагинов"
#: src/lib/onboarding-copy.ts
#: src/pages/shell/message-cards.tsx
msgid "Not now"
msgstr "Не сейчас"
@ -3223,14 +3294,11 @@ msgstr ""
msgid "Nothing remembered yet"
msgstr "Пока ничего не запомнено"
#: src/pages/Onboarding.tsx
msgid "Nothing specific"
msgstr ""
#: src/components/UserProfileSettings.tsx
msgid "Nothing yet"
msgstr ""
#: src/pages/SettingsOverlay.tsx
#: src/pages/shell/bot-panel.tsx
msgid "Notifications"
msgstr "Уведомления"
@ -3244,6 +3312,10 @@ msgstr "Сейчас"
msgid "Now using {0}."
msgstr "Теперь используется {0}."
#: src/components/NotificationSettingsPanel.tsx
msgid "Nudges from your Oo"
msgstr ""
#: src/pages/McpOAuthCallback.tsx
msgid "OAuth authorization was cancelled."
msgstr "Авторизация OAuth отменена."
@ -3277,6 +3349,10 @@ msgstr "1-го числа в {0}"
msgid "Onboarding"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "Once a week"
msgstr ""
#: src/lib/voice-persona-copy.ts
msgid "One more look before I answer…"
msgstr ""
@ -3285,6 +3361,10 @@ msgstr ""
msgid "Only empty spaces can be deleted. Delete its bots and groups first."
msgstr "Удалить можно только пустые пространства. Сначала удалите их ботов и группы."
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Only file and folder names are read."
msgstr ""
#: src/pages/ScratchpadSection.tsx
#: src/pages/Shell.tsx
msgid "Open"
@ -3536,6 +3616,10 @@ msgstr "Провайдеры"
msgid "Pull request"
msgstr "Запрос на слияние"
#: src/components/NotificationSettingsPanel.tsx
msgid "Push on this device"
msgstr ""
#: src/components/ai/DelegatedTaskCard.tsx
#: src/pages/ProjectSessionTimeline.tsx
msgid "Queued"
@ -3546,14 +3630,22 @@ msgstr "В очереди"
msgid "Queued…"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Quick & pragmatic"
msgstr ""
#: src/pages/HeuteBrief.tsx
msgid "Quiet day so far"
msgstr ""
#: src/components/NotificationSettingsPanel.tsx
msgid "Quiet hours"
msgstr ""
#: src/components/NotificationSettingsPanel.tsx
msgid "Quiet hours end"
msgstr ""
#: src/components/NotificationSettingsPanel.tsx
msgid "Quiet hours start"
msgstr ""
#: src/pages/Shell.tsx
msgid "Quote"
msgstr ""
@ -3773,6 +3865,10 @@ msgstr ""
msgid "Reporting from home…"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "Research & writing"
msgstr ""
#: src/pages/shell/bot-capabilities.tsx
msgid "research, writing"
msgstr ""
@ -3912,6 +4008,7 @@ msgstr ""
#: src/components/AskCard.tsx
#: src/components/teach/SkillDraftCard.tsx
#: src/components/UserFactEditor.tsx
#: src/pages/ExternalConversationSettings.tsx
#: src/pages/GroupPanel.tsx
#: src/pages/KnowledgeSection.tsx
@ -4012,15 +4109,12 @@ msgstr "Поиск…"
msgid "Select {0}"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Select all that apply"
msgstr ""
#: src/pages/PrivacySettingsOverlay.tsx
msgid "Self-hosted (better-auth)"
msgstr ""
#: src/pages/Shell.tsx
#: src/pages/shell/message-cards.tsx
msgid "Send"
msgstr "Отправить"
@ -4184,7 +4278,9 @@ msgid "Skills"
msgstr "Навыки"
#: src/components/integrations/IntegrationSetup.tsx
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/lib/onboarding-copy.ts
#: src/pages/Shell.tsx
msgid "Skip"
msgstr "Пропустить"
@ -4269,6 +4365,10 @@ msgstr ""
msgid "Speaking with intent…"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "Specialist crew"
msgstr ""
#: src/lib/model-tier-label.ts
msgid "Standard"
msgstr ""
@ -4362,10 +4462,6 @@ msgstr ""
msgid "Stronger together — {0} specialists"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Structured & planned"
msgstr ""
#: src/pages/Shell.tsx
msgid "subagent"
msgstr "субагент"
@ -4451,10 +4547,6 @@ msgstr "Teams не настроен"
msgid "Tell me what matters. I'll move."
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Tell us about yourself"
msgstr ""
#: src/components/teach/SkillDraftCard.tsx
msgid "Test"
msgstr "Тест"
@ -4545,10 +4637,6 @@ msgstr "Время суток"
msgid "Time zone"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Timezone"
msgstr ""
#: src/pages/shell/bot-panel.tsx
msgid "Title"
msgstr "Заголовок"
@ -4603,6 +4691,10 @@ msgstr ""
msgid "Try again"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "Turn on"
msgstr ""
#: src/components/AskCard.tsx
msgid "Type your answer"
msgstr "Введите ответ"
@ -4796,6 +4888,14 @@ msgstr "Вебхук"
msgid "Weekdays"
msgstr "Будни"
#: src/components/heute/CoachRhythmControl.tsx
msgid "Weekly"
msgstr ""
#: src/lib/routine-name.ts
msgid "Weekly review"
msgstr ""
#: src/pages/shell/dialogs.tsx
msgid "What about its memories?"
msgstr "Что делать с его памятью?"
@ -4804,6 +4904,10 @@ msgstr "Что делать с его памятью?"
msgid "What do you think — react or answer in a sentence."
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "What do you want me on first?"
msgstr ""
#: src/components/onboarding/OnboardingOoDesigner.tsx
msgid "What feels like you?"
msgstr ""
@ -4844,6 +4948,10 @@ msgstr "Что вернуть"
msgid "What's on your mind? I'll route it — and tell you when I disagree."
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "What’s one thing you want to get done in the next few weeks?"
msgstr ""
#: src/lib/voice-persona-copy.ts
msgid "What's personal today?"
msgstr ""
@ -4856,6 +4964,14 @@ msgstr ""
msgid "When a webhook fires"
msgstr "Когда срабатывает вебхук"
#: src/lib/onboarding-copy.ts
msgid "When should I check in with you?"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "When should I stay quiet?"
msgstr ""
#: src/pages/RoutineEditor.tsx
msgid "When to run"
msgstr "Когда запускать"
@ -4881,10 +4997,6 @@ msgstr ""
msgid "Work channel open — go ahead."
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Work style"
msgstr ""
#: src/components/stage/OoStage.tsx
msgid "Working"
msgstr ""
@ -4941,8 +5053,11 @@ msgstr ""
msgid "Your email address"
msgstr "Ваш адрес электронной почты"
#: src/lib/onboarding-copy.ts
msgid "Your goal"
msgstr ""
#: src/pages/Auth.tsx
#: src/pages/Onboarding.tsx
msgid "Your name"
msgstr "Ваше имя"

View File

@ -21,6 +21,11 @@ msgstr " (okunmadı)"
msgid "(optional)"
msgstr ""
#. placeholder {0}: habit.streak
#: src/components/heute/HeuteCoach.tsx
msgid "{0, plural, one {# day} other {# days}}"
msgstr ""
#. placeholder {0}: group.entries.length
#: src/pages/ModelSettingsOverlay.tsx
msgid "{0, plural, one {# model} other {# models}}"
@ -31,6 +36,11 @@ msgstr "{0, plural, one {# model} other {# model}}"
msgid "{0, plural, one {# more waiting} other {# more waiting}}"
msgstr ""
#. placeholder {0}: habit.streak
#: src/components/heute/HeuteCoach.tsx
msgid "{0, plural, one {# week} other {# weeks}}"
msgstr ""
#. placeholder {0}: file.name
#: src/pages/Shell.tsx
msgid "{0} (max {ATTACHMENT_MAX_COUNT} attachments)"
@ -281,10 +291,6 @@ msgstr ""
msgid "1:1 conversation with {title}"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "A few quick preferences so your Oo can be better tailored to you."
msgstr ""
#: src/pages/shell/bot-picker.tsx
msgid "About groups"
msgstr ""
@ -350,6 +356,7 @@ msgstr "Etkin model"
msgid "Activity"
msgstr "Etkinlik"
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/pages/PluginsOverlay.tsx
#: src/pages/ScratchpadSection.tsx
msgid "Add"
@ -387,6 +394,10 @@ msgstr "Bir HTTPS sunucu URL'si gir."
msgid "Add another"
msgstr ""
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Add as projects"
msgstr ""
#: src/pages/PluginsOverlay.tsx
msgid "Add Executor"
msgstr "Executor ekle"
@ -428,6 +439,10 @@ msgstr "Sunucu ekle"
msgid "Add server URL"
msgstr ""
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Add to About you"
msgstr ""
#: src/components/teach/SkillDraftCard.tsx
msgid "Add to routine"
msgstr "Rutine ekle"
@ -494,6 +509,26 @@ msgstr ""
msgid "All quiet"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "All set. I’ll check in on {weekday} at {time}."
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "All set. I’ll check in today at {time}."
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "All set. I’ll check in tomorrow at {time}."
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "All set. I’m here whenever you need me."
msgstr ""
#: src/components/UserFactEditor.tsx
msgid "All spaces"
msgstr ""
#. placeholder {0}: rule.matchValue
#: src/components/ApprovalRulesSettings.tsx
msgid "Allow {0} actions without asking"
@ -700,10 +735,6 @@ msgstr "Yetkilendir"
msgid "Authorize this server so agents can use its tools. A popup opens."
msgstr "Agent'ların araçlarını kullanabilmesi için bu sunucuyu yetkilendir. Bir açılır pencere açılır."
#: src/pages/Onboarding.tsx
msgid "Auto-detected or enter manually"
msgstr ""
#: src/pages/ExternalConversationSettings.tsx
msgid "Automated senders"
msgstr ""
@ -740,10 +771,6 @@ msgstr "Oturum açmaya geri dön"
msgid "Back to timeline"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Balanced"
msgstr ""
#: src/pages/memory-providers/SupermemorySettingsForm.tsx
msgid "Base URL"
msgstr "Temel URL"
@ -753,6 +780,10 @@ msgstr "Temel URL"
msgid "Bearer token"
msgstr "Bearer token"
#: src/components/NotificationSettingsPanel.tsx
msgid "Blocked in your browser settings"
msgstr ""
#: src/pages/Shell.tsx
msgid "Booting live desktop…"
msgstr "Canlı masaüstü başlatılıyor…"
@ -821,6 +852,10 @@ msgstr ""
msgid "Calm helper"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "Can I send you notifications?"
msgstr ""
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Can speak"
msgstr ""
@ -833,6 +868,7 @@ msgstr "Sunucuya ulaşılamıyor."
#: src/components/ComputerMaintenanceActions.tsx
#: src/components/ComputerUpdateProgress.tsx
#: src/components/teach/TeachComputerOverlay.tsx
#: src/components/UserFactEditor.tsx
#: src/components/UserProfileSettings.tsx
#: src/pages/KnowledgeSection.tsx
#: src/pages/PluginsOverlay.tsx
@ -867,8 +903,8 @@ msgstr ""
msgid "Cancelled"
msgstr "İptal edildi"
#: src/pages/Onboarding.tsx
msgid "Casual & friendly"
#: src/components/UserFactEditor.tsx
msgid "Category"
msgstr ""
#: src/pages/AccountSettingsOverlay.tsx
@ -925,6 +961,10 @@ msgstr "Durum bildir."
msgid "Check your email"
msgstr "E-postanızı kontrol edin"
#: src/components/heute/CoachRhythmControl.tsx
msgid "Check-ins"
msgstr ""
#: src/lib/voice-persona-copy.ts
msgid "Checking dependencies…"
msgstr ""
@ -1134,7 +1174,6 @@ msgid "Command"
msgstr "Komut"
#: src/components/UserProfileSettings.tsx
#: src/pages/Onboarding.tsx
msgid "Communication style"
msgstr ""
@ -1295,8 +1334,8 @@ msgstr "Bağlam sınırı"
#: src/components/integrations/IntegrationSetup.tsx
#: src/components/onboarding/OnboardingOoDesigner.tsx
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/pages/Onboarding.tsx
msgid "Continue"
msgstr "Devam"
@ -1522,6 +1561,10 @@ msgstr ""
msgid "Could not load voice settings"
msgstr "Ses ayarları yüklenemedi"
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Could not look in this folder."
msgstr ""
#: src/pages/LocalSettings.tsx
msgid "Could not open local settings. Start the local server and create its owner account, then try again."
msgstr ""
@ -1674,6 +1717,7 @@ msgstr "Varsayılan hafıza kapsamı güncellenemedi"
msgid "Could not update voice"
msgstr ""
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
msgid "Couldn't load"
msgstr ""
@ -1682,6 +1726,7 @@ msgstr ""
msgid "Couldn't load messaging settings"
msgstr ""
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
msgid "Couldn't save"
msgstr ""
@ -1823,6 +1868,10 @@ msgstr ""
msgid "day"
msgstr "gün"
#: src/lib/onboarding-copy.ts
msgid "Day-to-day work"
msgstr ""
#: src/pages/RoutineSchedule.tsx
msgid "days"
msgstr "gün"
@ -1831,10 +1880,6 @@ msgstr "gün"
msgid "Decline"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Deep-dive focused"
msgstr ""
#: src/pages/ModelSettingsOverlay.tsx
msgid "Default"
msgstr "Varsayılan"
@ -1935,18 +1980,10 @@ msgstr ""
msgid "Desktop update"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Detailed & explanatory"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
msgid "Developer tools"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Direct & concise"
msgstr ""
#: src/components/integrations/IntegrationSetup.tsx
msgid "Direct MCP"
msgstr ""
@ -2066,6 +2103,10 @@ msgstr "örn. Hivenet Agent, Presales, puantaj botu"
msgid "Earlier message"
msgstr ""
#: src/components/UserProfileSettings.tsx
msgid "Edit"
msgstr ""
#: src/components/AskCard.tsx
msgid "Edit first"
msgstr "Önce düzenle"
@ -2115,6 +2156,14 @@ msgstr ""
msgid "Enter this code at <0>{0}</0>"
msgstr "Bu kodu <0>{0}</0> adresinde gir"
#: src/components/heute/CoachRhythmControl.tsx
msgid "Evening"
msgstr ""
#: src/lib/routine-name.ts
msgid "Evening check-in"
msgstr ""
#: src/pages/ExternalConversationSettings.tsx
#: src/pages/RoutineSchedule.tsx
msgid "Every"
@ -2143,6 +2192,10 @@ msgstr "Her pazartesi"
msgid "Every month"
msgstr "Her ay"
#: src/lib/onboarding-copy.ts
msgid "Every morning"
msgstr ""
#: src/pages/RoutineEditor.tsx
#: src/pages/RoutineSchedule.tsx
msgid "Every week"
@ -2183,6 +2236,10 @@ msgstr ""
msgid "Extra high"
msgstr "Çok yüksek"
#: src/components/UserFactEditor.tsx
msgid "Fact"
msgstr ""
#: src/components/CloudAgentCard.tsx
msgid "failed"
msgstr "başarısız"
@ -2256,10 +2313,6 @@ msgstr ""
msgid "Flag unexpected actions"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Flexible & spontaneous"
msgstr ""
#: src/pages/PrivacySettingsOverlay.tsx
msgid "Fonts loaded locally"
msgstr ""
@ -2324,6 +2377,7 @@ msgstr ""
msgid "Goal"
msgstr ""
#: src/components/heute/HeuteCoach.tsx
#: src/components/UserProfileSettings.tsx
#: src/pages/ProjectSessionTimeline.tsx
msgid "Goals"
@ -2360,6 +2414,7 @@ msgstr ""
msgid "Groups"
msgstr ""
#: src/components/heute/HeuteCoach.tsx
#: src/components/UserProfileSettings.tsx
msgid "Habits"
msgstr ""
@ -2434,10 +2489,6 @@ msgstr ""
msgid "High"
msgstr "Yüksek"
#: src/pages/Onboarding.tsx
msgid "Hobbies & interests"
msgstr ""
#: src/components/onboarding/OnboardingOoDesigner.tsx
msgid "Hobby"
msgstr ""
@ -2451,10 +2502,6 @@ msgstr "saat"
msgid "hours"
msgstr "saat"
#: src/pages/Onboarding.tsx
msgid "How do you like to work?"
msgstr ""
#: src/pages/RoutineSchedule.tsx
msgid "How often"
msgstr "Ne sıklıkla"
@ -2463,10 +2510,6 @@ msgstr "Ne sıklıkla"
msgid "How should your Oo sound when reading replies aloud?"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "How should your Oo talk to you?"
msgstr ""
#: src/components/teach/SkillDraftCard.tsx
msgid "How to check"
msgstr "Nasıl kontrol edilir"
@ -2507,6 +2550,10 @@ msgstr ""
msgid "In progress"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "Inbox & email"
msgstr ""
#: src/pages/shell/bot-panel.tsx
msgid "Inherit default"
msgstr "Varsayılanı devral"
@ -2730,6 +2777,10 @@ msgstr ""
msgid "Log out"
msgstr "Çıkış yap"
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Look"
msgstr ""
#. placeholder {0}: index + 1
#. placeholder {1}: looks.length
#: src/components/onboarding/LookPreviewCarousel.tsx
@ -2740,10 +2791,18 @@ msgstr ""
msgid "Look families"
msgstr ""
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Look for projects here?"
msgstr ""
#: src/components/onboarding/LookPreviewCarousel.tsx
msgid "Look variants"
msgstr ""
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Looking"
msgstr ""
#: src/pages/ModelSettingsOverlay.tsx
#: src/pages/shell/bot-panel.tsx
msgid "Low"
@ -2904,10 +2963,6 @@ msgstr "dakika"
msgid "minutes"
msgstr "dakika"
#: src/pages/HeuteBrief.tsx
msgid "Mission Control"
msgstr ""
#: src/pages/memory-providers/BuiltinSettingsForm.tsx
#: src/pages/ModelSettingsOverlay.tsx
#: src/pages/shell/bot-panel.tsx
@ -2964,6 +3019,18 @@ msgstr ""
msgid "More conversations ({hiddenSessionCount})"
msgstr ""
#: src/components/heute/CoachRhythmControl.tsx
msgid "Morning"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "Morning and evening"
msgstr ""
#: src/lib/routine-name.ts
msgid "Morning brief"
msgstr ""
#: src/pages/shell/dialogs.tsx
msgid "Move them to your shared memory."
msgstr "Bunları ortak hafızana taşı."
@ -2975,7 +3042,6 @@ msgstr ""
#: src/components/teach/SkillDraftCard.tsx
#: src/pages/Auth.tsx
#: src/pages/GroupPanel.tsx
#: src/pages/Onboarding.tsx
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/bot-panel.tsx
#: src/pages/shell/dialogs.tsx
@ -3154,6 +3220,10 @@ msgstr "{peerBotName} ile henüz mesaj yok."
msgid "No model catalog is available."
msgstr "Kullanılabilir model kataloğu yok."
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "No projects found"
msgstr ""
#: src/pages/Shell.tsx
msgid "No projects yet."
msgstr ""
@ -3211,6 +3281,7 @@ msgstr "Bağlı değil"
msgid "Not in the plugin catalog"
msgstr ""
#: src/lib/onboarding-copy.ts
#: src/pages/shell/message-cards.tsx
msgid "Not now"
msgstr "Şimdi değil"
@ -3223,14 +3294,11 @@ msgstr "Hâlâ çalışan bir şey yok"
msgid "Nothing remembered yet"
msgstr "Henüz hatırlanan bir şey yok"
#: src/pages/Onboarding.tsx
msgid "Nothing specific"
msgstr ""
#: src/components/UserProfileSettings.tsx
msgid "Nothing yet"
msgstr ""
#: src/pages/SettingsOverlay.tsx
#: src/pages/shell/bot-panel.tsx
msgid "Notifications"
msgstr "Bildirimler"
@ -3244,6 +3312,10 @@ msgstr "Şimdi"
msgid "Now using {0}."
msgstr "Artık {0} kullanılıyor."
#: src/components/NotificationSettingsPanel.tsx
msgid "Nudges from your Oo"
msgstr ""
#: src/pages/McpOAuthCallback.tsx
msgid "OAuth authorization was cancelled."
msgstr "OAuth yetkilendirmesi iptal edildi."
@ -3277,6 +3349,10 @@ msgstr "her ayın 1'inde saat {0}"
msgid "Onboarding"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "Once a week"
msgstr ""
#: src/lib/voice-persona-copy.ts
msgid "One more look before I answer…"
msgstr ""
@ -3285,6 +3361,10 @@ msgstr ""
msgid "Only empty spaces can be deleted. Delete its bots and groups first."
msgstr ""
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Only file and folder names are read."
msgstr ""
#: src/pages/ScratchpadSection.tsx
#: src/pages/Shell.tsx
msgid "Open"
@ -3536,6 +3616,10 @@ msgstr "Sağlayıcılar"
msgid "Pull request"
msgstr "Çekme isteği"
#: src/components/NotificationSettingsPanel.tsx
msgid "Push on this device"
msgstr ""
#: src/components/ai/DelegatedTaskCard.tsx
#: src/pages/ProjectSessionTimeline.tsx
msgid "Queued"
@ -3546,14 +3630,22 @@ msgstr "Kuyrukta"
msgid "Queued…"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Quick & pragmatic"
msgstr ""
#: src/pages/HeuteBrief.tsx
msgid "Quiet day so far"
msgstr ""
#: src/components/NotificationSettingsPanel.tsx
msgid "Quiet hours"
msgstr ""
#: src/components/NotificationSettingsPanel.tsx
msgid "Quiet hours end"
msgstr ""
#: src/components/NotificationSettingsPanel.tsx
msgid "Quiet hours start"
msgstr ""
#: src/pages/Shell.tsx
msgid "Quote"
msgstr ""
@ -3773,6 +3865,10 @@ msgstr ""
msgid "Reporting from home…"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "Research & writing"
msgstr ""
#: src/pages/shell/bot-capabilities.tsx
msgid "research, writing"
msgstr ""
@ -3912,6 +4008,7 @@ msgstr ""
#: src/components/AskCard.tsx
#: src/components/teach/SkillDraftCard.tsx
#: src/components/UserFactEditor.tsx
#: src/pages/ExternalConversationSettings.tsx
#: src/pages/GroupPanel.tsx
#: src/pages/KnowledgeSection.tsx
@ -4012,15 +4109,12 @@ msgstr "Aranıyor…"
msgid "Select {0}"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Select all that apply"
msgstr ""
#: src/pages/PrivacySettingsOverlay.tsx
msgid "Self-hosted (better-auth)"
msgstr ""
#: src/pages/Shell.tsx
#: src/pages/shell/message-cards.tsx
msgid "Send"
msgstr "Gönder"
@ -4184,7 +4278,9 @@ msgid "Skills"
msgstr "Beceriler"
#: src/components/integrations/IntegrationSetup.tsx
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/lib/onboarding-copy.ts
#: src/pages/Shell.tsx
msgid "Skip"
msgstr "Atla"
@ -4269,6 +4365,10 @@ msgstr ""
msgid "Speaking with intent…"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "Specialist crew"
msgstr ""
#: src/lib/model-tier-label.ts
msgid "Standard"
msgstr ""
@ -4362,10 +4462,6 @@ msgstr ""
msgid "Stronger together — {0} specialists"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Structured & planned"
msgstr ""
#: src/pages/Shell.tsx
msgid "subagent"
msgstr "alt ajan"
@ -4451,10 +4547,6 @@ msgstr ""
msgid "Tell me what matters. I'll move."
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Tell us about yourself"
msgstr ""
#: src/components/teach/SkillDraftCard.tsx
msgid "Test"
msgstr "Dene"
@ -4545,10 +4637,6 @@ msgstr "Günün saati"
msgid "Time zone"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Timezone"
msgstr ""
#: src/pages/shell/bot-panel.tsx
msgid "Title"
msgstr "Başlık"
@ -4603,6 +4691,10 @@ msgstr ""
msgid "Try again"
msgstr "Tekrar dene"
#: src/lib/onboarding-copy.ts
msgid "Turn on"
msgstr ""
#: src/components/AskCard.tsx
msgid "Type your answer"
msgstr "Yanıtını yaz"
@ -4796,6 +4888,14 @@ msgstr ""
msgid "Weekdays"
msgstr "Hafta içi"
#: src/components/heute/CoachRhythmControl.tsx
msgid "Weekly"
msgstr ""
#: src/lib/routine-name.ts
msgid "Weekly review"
msgstr ""
#: src/pages/shell/dialogs.tsx
msgid "What about its memories?"
msgstr "Hafızası ne olsun?"
@ -4804,6 +4904,10 @@ msgstr "Hafızası ne olsun?"
msgid "What do you think — react or answer in a sentence."
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "What do you want me on first?"
msgstr ""
#: src/components/onboarding/OnboardingOoDesigner.tsx
msgid "What feels like you?"
msgstr ""
@ -4844,6 +4948,10 @@ msgstr "Ne döndürülecek"
msgid "What's on your mind? I'll route it — and tell you when I disagree."
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "What’s one thing you want to get done in the next few weeks?"
msgstr ""
#: src/lib/voice-persona-copy.ts
msgid "What's personal today?"
msgstr ""
@ -4856,6 +4964,14 @@ msgstr ""
msgid "When a webhook fires"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "When should I check in with you?"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "When should I stay quiet?"
msgstr ""
#: src/pages/RoutineEditor.tsx
msgid "When to run"
msgstr "Ne zaman çalışacak"
@ -4881,10 +4997,6 @@ msgstr ""
msgid "Work channel open — go ahead."
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Work style"
msgstr ""
#: src/components/stage/OoStage.tsx
msgid "Working"
msgstr ""
@ -4941,8 +5053,11 @@ msgstr ""
msgid "Your email address"
msgstr "E-posta adresin"
#: src/lib/onboarding-copy.ts
msgid "Your goal"
msgstr ""
#: src/pages/Auth.tsx
#: src/pages/Onboarding.tsx
msgid "Your name"
msgstr "Adın"

View File

@ -21,6 +21,11 @@ msgstr "(未读)"
msgid "(optional)"
msgstr ""
#. placeholder {0}: habit.streak
#: src/components/heute/HeuteCoach.tsx
msgid "{0, plural, one {# day} other {# days}}"
msgstr ""
#. placeholder {0}: group.entries.length
#: src/pages/ModelSettingsOverlay.tsx
msgid "{0, plural, one {# model} other {# models}}"
@ -31,6 +36,11 @@ msgstr "{0, plural, one {# 个模型} other {# 个模型}}"
msgid "{0, plural, one {# more waiting} other {# more waiting}}"
msgstr ""
#. placeholder {0}: habit.streak
#: src/components/heute/HeuteCoach.tsx
msgid "{0, plural, one {# week} other {# weeks}}"
msgstr ""
#. placeholder {0}: file.name
#: src/pages/Shell.tsx
msgid "{0} (max {ATTACHMENT_MAX_COUNT} attachments)"
@ -281,10 +291,6 @@ msgstr ""
msgid "1:1 conversation with {title}"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "A few quick preferences so your Oo can be better tailored to you."
msgstr ""
#: src/pages/shell/bot-picker.tsx
msgid "About groups"
msgstr ""
@ -350,6 +356,7 @@ msgstr "当前模型"
msgid "Activity"
msgstr "动态"
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/pages/PluginsOverlay.tsx
#: src/pages/ScratchpadSection.tsx
msgid "Add"
@ -387,6 +394,10 @@ msgstr "请填写 HTTPS 服务器 URL。"
msgid "Add another"
msgstr ""
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Add as projects"
msgstr ""
#: src/pages/PluginsOverlay.tsx
msgid "Add Executor"
msgstr "添加 Executor"
@ -428,6 +439,10 @@ msgstr "添加服务器"
msgid "Add server URL"
msgstr ""
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Add to About you"
msgstr ""
#: src/components/teach/SkillDraftCard.tsx
msgid "Add to routine"
msgstr "加入例行任务"
@ -494,6 +509,26 @@ msgstr ""
msgid "All quiet"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "All set. I’ll check in on {weekday} at {time}."
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "All set. I’ll check in today at {time}."
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "All set. I’ll check in tomorrow at {time}."
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "All set. I’m here whenever you need me."
msgstr ""
#: src/components/UserFactEditor.tsx
msgid "All spaces"
msgstr ""
#. placeholder {0}: rule.matchValue
#: src/components/ApprovalRulesSettings.tsx
msgid "Allow {0} actions without asking"
@ -700,10 +735,6 @@ msgstr "授权"
msgid "Authorize this server so agents can use its tools. A popup opens."
msgstr "授权此服务器后,Agent 才能使用其工具。将打开弹窗。"
#: src/pages/Onboarding.tsx
msgid "Auto-detected or enter manually"
msgstr ""
#: src/pages/ExternalConversationSettings.tsx
msgid "Automated senders"
msgstr ""
@ -740,10 +771,6 @@ msgstr "返回登录"
msgid "Back to timeline"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Balanced"
msgstr ""
#: src/pages/memory-providers/SupermemorySettingsForm.tsx
msgid "Base URL"
msgstr "Base URL"
@ -753,6 +780,10 @@ msgstr "Base URL"
msgid "Bearer token"
msgstr "Bearer token"
#: src/components/NotificationSettingsPanel.tsx
msgid "Blocked in your browser settings"
msgstr ""
#: src/pages/Shell.tsx
msgid "Booting live desktop…"
msgstr "正在启动实时桌面…"
@ -821,6 +852,10 @@ msgstr ""
msgid "Calm helper"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "Can I send you notifications?"
msgstr ""
#: src/pages/VoiceSettingsOverlay.tsx
msgid "Can speak"
msgstr ""
@ -833,6 +868,7 @@ msgstr "无法连接服务器。"
#: src/components/ComputerMaintenanceActions.tsx
#: src/components/ComputerUpdateProgress.tsx
#: src/components/teach/TeachComputerOverlay.tsx
#: src/components/UserFactEditor.tsx
#: src/components/UserProfileSettings.tsx
#: src/pages/KnowledgeSection.tsx
#: src/pages/PluginsOverlay.tsx
@ -867,8 +903,8 @@ msgstr ""
msgid "Cancelled"
msgstr "已取消"
#: src/pages/Onboarding.tsx
msgid "Casual & friendly"
#: src/components/UserFactEditor.tsx
msgid "Category"
msgstr ""
#: src/pages/AccountSettingsOverlay.tsx
@ -925,6 +961,10 @@ msgstr "报到。"
msgid "Check your email"
msgstr "请查看邮箱"
#: src/components/heute/CoachRhythmControl.tsx
msgid "Check-ins"
msgstr ""
#: src/lib/voice-persona-copy.ts
msgid "Checking dependencies…"
msgstr ""
@ -1134,7 +1174,6 @@ msgid "Command"
msgstr "命令"
#: src/components/UserProfileSettings.tsx
#: src/pages/Onboarding.tsx
msgid "Communication style"
msgstr ""
@ -1295,8 +1334,8 @@ msgstr "上下文限制"
#: src/components/integrations/IntegrationSetup.tsx
#: src/components/onboarding/OnboardingOoDesigner.tsx
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/pages/Onboarding.tsx
msgid "Continue"
msgstr "继续"
@ -1522,6 +1561,10 @@ msgstr "无法加载更新状态"
msgid "Could not load voice settings"
msgstr "无法加载语音设置"
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Could not look in this folder."
msgstr ""
#: src/pages/LocalSettings.tsx
msgid "Could not open local settings. Start the local server and create its owner account, then try again."
msgstr ""
@ -1674,6 +1717,7 @@ msgstr "无法更新默认记忆范围"
msgid "Could not update voice"
msgstr ""
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
msgid "Couldn't load"
msgstr ""
@ -1682,6 +1726,7 @@ msgstr ""
msgid "Couldn't load messaging settings"
msgstr "无法加载消息设置"
#: src/components/NotificationSettingsPanel.tsx
#: src/components/UserProfileSettings.tsx
msgid "Couldn't save"
msgstr ""
@ -1823,6 +1868,10 @@ msgstr ""
msgid "day"
msgstr "天"
#: src/lib/onboarding-copy.ts
msgid "Day-to-day work"
msgstr ""
#: src/pages/RoutineSchedule.tsx
msgid "days"
msgstr "天"
@ -1831,10 +1880,6 @@ msgstr "天"
msgid "Decline"
msgstr "拒绝"
#: src/pages/Onboarding.tsx
msgid "Deep-dive focused"
msgstr ""
#: src/pages/ModelSettingsOverlay.tsx
msgid "Default"
msgstr "默认"
@ -1935,18 +1980,10 @@ msgstr ""
msgid "Desktop update"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Detailed & explanatory"
msgstr ""
#: src/components/browser/InAppBrowser.tsx
msgid "Developer tools"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Direct & concise"
msgstr ""
#: src/components/integrations/IntegrationSetup.tsx
msgid "Direct MCP"
msgstr ""
@ -2066,6 +2103,10 @@ msgstr "例如 Hivenet Agent、Presales、工时表机器人"
msgid "Earlier message"
msgstr "更早的消息"
#: src/components/UserProfileSettings.tsx
msgid "Edit"
msgstr ""
#: src/components/AskCard.tsx
msgid "Edit first"
msgstr "先编辑"
@ -2115,6 +2156,14 @@ msgstr ""
msgid "Enter this code at <0>{0}</0>"
msgstr "在 <0>{0}</0> 输入此代码"
#: src/components/heute/CoachRhythmControl.tsx
msgid "Evening"
msgstr ""
#: src/lib/routine-name.ts
msgid "Evening check-in"
msgstr ""
#: src/pages/ExternalConversationSettings.tsx
#: src/pages/RoutineSchedule.tsx
msgid "Every"
@ -2143,6 +2192,10 @@ msgstr "每周一"
msgid "Every month"
msgstr "每月"
#: src/lib/onboarding-copy.ts
msgid "Every morning"
msgstr ""
#: src/pages/RoutineEditor.tsx
#: src/pages/RoutineSchedule.tsx
msgid "Every week"
@ -2183,6 +2236,10 @@ msgstr ""
msgid "Extra high"
msgstr "极高"
#: src/components/UserFactEditor.tsx
msgid "Fact"
msgstr ""
#: src/components/CloudAgentCard.tsx
msgid "failed"
msgstr "失败"
@ -2256,10 +2313,6 @@ msgstr ""
msgid "Flag unexpected actions"
msgstr "标记异常操作"
#: src/pages/Onboarding.tsx
msgid "Flexible & spontaneous"
msgstr ""
#: src/pages/PrivacySettingsOverlay.tsx
msgid "Fonts loaded locally"
msgstr ""
@ -2324,6 +2377,7 @@ msgstr "Git 事件"
msgid "Goal"
msgstr ""
#: src/components/heute/HeuteCoach.tsx
#: src/components/UserProfileSettings.tsx
#: src/pages/ProjectSessionTimeline.tsx
msgid "Goals"
@ -2360,6 +2414,7 @@ msgstr ""
msgid "Groups"
msgstr ""
#: src/components/heute/HeuteCoach.tsx
#: src/components/UserProfileSettings.tsx
msgid "Habits"
msgstr ""
@ -2434,10 +2489,6 @@ msgstr "隐藏密码"
msgid "High"
msgstr "高"
#: src/pages/Onboarding.tsx
msgid "Hobbies & interests"
msgstr ""
#: src/components/onboarding/OnboardingOoDesigner.tsx
msgid "Hobby"
msgstr ""
@ -2451,10 +2502,6 @@ msgstr "小时"
msgid "hours"
msgstr "小时"
#: src/pages/Onboarding.tsx
msgid "How do you like to work?"
msgstr ""
#: src/pages/RoutineSchedule.tsx
msgid "How often"
msgstr "频率"
@ -2463,10 +2510,6 @@ msgstr "频率"
msgid "How should your Oo sound when reading replies aloud?"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "How should your Oo talk to you?"
msgstr ""
#: src/components/teach/SkillDraftCard.tsx
msgid "How to check"
msgstr "如何检查"
@ -2507,6 +2550,10 @@ msgstr ""
msgid "In progress"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "Inbox & email"
msgstr ""
#: src/pages/shell/bot-panel.tsx
msgid "Inherit default"
msgstr "沿用默认"
@ -2730,6 +2777,10 @@ msgstr ""
msgid "Log out"
msgstr "退出登录"
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Look"
msgstr ""
#. placeholder {0}: index + 1
#. placeholder {1}: looks.length
#: src/components/onboarding/LookPreviewCarousel.tsx
@ -2740,10 +2791,18 @@ msgstr ""
msgid "Look families"
msgstr ""
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Look for projects here?"
msgstr ""
#: src/components/onboarding/LookPreviewCarousel.tsx
msgid "Look variants"
msgstr ""
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Looking"
msgstr ""
#: src/pages/ModelSettingsOverlay.tsx
#: src/pages/shell/bot-panel.tsx
msgid "Low"
@ -2904,10 +2963,6 @@ msgstr "分钟"
msgid "minutes"
msgstr "分钟"
#: src/pages/HeuteBrief.tsx
msgid "Mission Control"
msgstr ""
#: src/pages/memory-providers/BuiltinSettingsForm.tsx
#: src/pages/ModelSettingsOverlay.tsx
#: src/pages/shell/bot-panel.tsx
@ -2964,6 +3019,18 @@ msgstr ""
msgid "More conversations ({hiddenSessionCount})"
msgstr ""
#: src/components/heute/CoachRhythmControl.tsx
msgid "Morning"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "Morning and evening"
msgstr ""
#: src/lib/routine-name.ts
msgid "Morning brief"
msgstr ""
#: src/pages/shell/dialogs.tsx
msgid "Move them to your shared memory."
msgstr "把它们移到共享记忆。"
@ -2975,7 +3042,6 @@ msgstr ""
#: src/components/teach/SkillDraftCard.tsx
#: src/pages/Auth.tsx
#: src/pages/GroupPanel.tsx
#: src/pages/Onboarding.tsx
#: src/pages/RoutineEditor.tsx
#: src/pages/shell/bot-panel.tsx
#: src/pages/shell/dialogs.tsx
@ -3154,6 +3220,10 @@ msgstr "还没有与 {peerBotName} 的消息。"
msgid "No model catalog is available."
msgstr "没有可用的模型目录。"
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "No projects found"
msgstr ""
#: src/pages/Shell.tsx
msgid "No projects yet."
msgstr ""
@ -3211,6 +3281,7 @@ msgstr "未连接"
msgid "Not in the plugin catalog"
msgstr "不在插件目录中"
#: src/lib/onboarding-copy.ts
#: src/pages/shell/message-cards.tsx
msgid "Not now"
msgstr "暂不"
@ -3223,14 +3294,11 @@ msgstr "没有仍在运行的任务"
msgid "Nothing remembered yet"
msgstr "暂无记忆"
#: src/pages/Onboarding.tsx
msgid "Nothing specific"
msgstr ""
#: src/components/UserProfileSettings.tsx
msgid "Nothing yet"
msgstr ""
#: src/pages/SettingsOverlay.tsx
#: src/pages/shell/bot-panel.tsx
msgid "Notifications"
msgstr "通知"
@ -3244,6 +3312,10 @@ msgstr "现在"
msgid "Now using {0}."
msgstr "正在使用 {0}。"
#: src/components/NotificationSettingsPanel.tsx
msgid "Nudges from your Oo"
msgstr ""
#: src/pages/McpOAuthCallback.tsx
msgid "OAuth authorization was cancelled."
msgstr "OAuth 授权已取消。"
@ -3277,6 +3349,10 @@ msgstr "每月 1 日 {0}"
msgid "Onboarding"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "Once a week"
msgstr ""
#: src/lib/voice-persona-copy.ts
msgid "One more look before I answer…"
msgstr ""
@ -3285,6 +3361,10 @@ msgstr ""
msgid "Only empty spaces can be deleted. Delete its bots and groups first."
msgstr ""
#: src/components/onboarding/OnboardingScanStep.tsx
msgid "Only file and folder names are read."
msgstr ""
#: src/pages/ScratchpadSection.tsx
#: src/pages/Shell.tsx
msgid "Open"
@ -3536,6 +3616,10 @@ msgstr "提供方"
msgid "Pull request"
msgstr "拉取请求"
#: src/components/NotificationSettingsPanel.tsx
msgid "Push on this device"
msgstr ""
#: src/components/ai/DelegatedTaskCard.tsx
#: src/pages/ProjectSessionTimeline.tsx
msgid "Queued"
@ -3546,14 +3630,22 @@ msgstr "排队中"
msgid "Queued…"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Quick & pragmatic"
msgstr ""
#: src/pages/HeuteBrief.tsx
msgid "Quiet day so far"
msgstr ""
#: src/components/NotificationSettingsPanel.tsx
msgid "Quiet hours"
msgstr ""
#: src/components/NotificationSettingsPanel.tsx
msgid "Quiet hours end"
msgstr ""
#: src/components/NotificationSettingsPanel.tsx
msgid "Quiet hours start"
msgstr ""
#: src/pages/Shell.tsx
msgid "Quote"
msgstr ""
@ -3773,6 +3865,10 @@ msgstr ""
msgid "Reporting from home…"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "Research & writing"
msgstr ""
#: src/pages/shell/bot-capabilities.tsx
msgid "research, writing"
msgstr ""
@ -3912,6 +4008,7 @@ msgstr ""
#: src/components/AskCard.tsx
#: src/components/teach/SkillDraftCard.tsx
#: src/components/UserFactEditor.tsx
#: src/pages/ExternalConversationSettings.tsx
#: src/pages/GroupPanel.tsx
#: src/pages/KnowledgeSection.tsx
@ -4012,15 +4109,12 @@ msgstr "正在搜索…"
msgid "Select {0}"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Select all that apply"
msgstr ""
#: src/pages/PrivacySettingsOverlay.tsx
msgid "Self-hosted (better-auth)"
msgstr ""
#: src/pages/Shell.tsx
#: src/pages/shell/message-cards.tsx
msgid "Send"
msgstr "发送"
@ -4184,7 +4278,9 @@ msgid "Skills"
msgstr "技能"
#: src/components/integrations/IntegrationSetup.tsx
#: src/components/onboarding/OnboardingScanStep.tsx
#: src/components/onboarding/OnboardingVoiceStep.tsx
#: src/lib/onboarding-copy.ts
#: src/pages/Shell.tsx
msgid "Skip"
msgstr "跳过"
@ -4269,6 +4365,10 @@ msgstr ""
msgid "Speaking with intent…"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "Specialist crew"
msgstr ""
#: src/lib/model-tier-label.ts
msgid "Standard"
msgstr ""
@ -4362,10 +4462,6 @@ msgstr ""
msgid "Stronger together — {0} specialists"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Structured & planned"
msgstr ""
#: src/pages/Shell.tsx
msgid "subagent"
msgstr "子 Agent"
@ -4451,10 +4547,6 @@ msgstr ""
msgid "Tell me what matters. I'll move."
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Tell us about yourself"
msgstr ""
#: src/components/teach/SkillDraftCard.tsx
msgid "Test"
msgstr "测试"
@ -4545,10 +4637,6 @@ msgstr "时间"
msgid "Time zone"
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Timezone"
msgstr ""
#: src/pages/shell/bot-panel.tsx
msgid "Title"
msgstr "标题"
@ -4603,6 +4691,10 @@ msgstr ""
msgid "Try again"
msgstr "重试"
#: src/lib/onboarding-copy.ts
msgid "Turn on"
msgstr ""
#: src/components/AskCard.tsx
msgid "Type your answer"
msgstr "输入你的回答"
@ -4796,6 +4888,14 @@ msgstr "Webhook"
msgid "Weekdays"
msgstr "工作日"
#: src/components/heute/CoachRhythmControl.tsx
msgid "Weekly"
msgstr ""
#: src/lib/routine-name.ts
msgid "Weekly review"
msgstr ""
#: src/pages/shell/dialogs.tsx
msgid "What about its memories?"
msgstr "它的记忆怎么处理?"
@ -4804,6 +4904,10 @@ msgstr "它的记忆怎么处理?"
msgid "What do you think — react or answer in a sentence."
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "What do you want me on first?"
msgstr ""
#: src/components/onboarding/OnboardingOoDesigner.tsx
msgid "What feels like you?"
msgstr ""
@ -4844,6 +4948,10 @@ msgstr "返回什么"
msgid "What's on your mind? I'll route it — and tell you when I disagree."
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "What’s one thing you want to get done in the next few weeks?"
msgstr ""
#: src/lib/voice-persona-copy.ts
msgid "What's personal today?"
msgstr ""
@ -4856,6 +4964,14 @@ msgstr ""
msgid "When a webhook fires"
msgstr "当 Webhook 触发时"
#: src/lib/onboarding-copy.ts
msgid "When should I check in with you?"
msgstr ""
#: src/lib/onboarding-copy.ts
msgid "When should I stay quiet?"
msgstr ""
#: src/pages/RoutineEditor.tsx
msgid "When to run"
msgstr "何时运行"
@ -4881,10 +4997,6 @@ msgstr ""
msgid "Work channel open — go ahead."
msgstr ""
#: src/pages/Onboarding.tsx
msgid "Work style"
msgstr ""
#: src/components/stage/OoStage.tsx
msgid "Working"
msgstr ""
@ -4941,8 +5053,11 @@ msgstr ""
msgid "Your email address"
msgstr "你的邮箱"
#: src/lib/onboarding-copy.ts
msgid "Your goal"
msgstr ""
#: src/pages/Auth.tsx
#: src/pages/Onboarding.tsx
msgid "Your name"
msgstr "你的名字"

View File

@ -9,6 +9,7 @@ import { markAfterPaint, markOnce } from "./lib/performance";
import { installPreloadRecovery } from "./lib/preload-recovery";
import { applyUiAppearance, watchSystemAppearance } from "./lib/ui-appearance";
import { resolveUiLocale } from "./lib/ui-locale";
import { registerPushServiceWorker } from "./lib/web-push";
import "./styles.css";
markOnce("rk:renderer:module-evaluated");
@ -19,6 +20,7 @@ try {
installPreloadRecovery();
applyUiDirection(resolveUiLocale());
applyUiAppearance();
void registerPushServiceWorker();
function PerformanceProbe() {
useLayoutEffect(() => {

View File

@ -1,22 +1,23 @@
import { i18n } from "@lingui/core";
import { Plural, Trans, useLingui } from "@lingui/react/macro";
import type { Bot, Group, RunActivityRow } from "@rakazo/contracts";
import type { Bot, CoachToday, Group, HabitToday, RunActivityRow } from "@rakazo/contracts";
import { botColors } from "@rakazo/ui-tokens";
import { BotAvatar, Button, EvooMascot, GroupAvatar } from "@rakazo/ui-web";
import { ExternalLink } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { CoachRhythmControl } from "../components/heute/CoachRhythmControl";
import { HeuteCoach } from "../components/heute/HeuteCoach";
import { runActivityLabel } from "../lib/agent-activity";
import type { DoingItem, DoneItem, JetztItem } from "../lib/heute-brief";
import {
buildHeuteBrief,
cleanBriefPreview,
type DoingItem,
type DoneItem,
type JetztItem,
coachRefreshSignal,
formatLocalDate,
withHabitDone,
} from "../lib/heute-brief";
import { localizedPreview } from "../lib/onboarding-copy";
import { rpc } from "../lib/rpc";
const MISSION_CONTROL_URL = "http://127.0.0.1:3088/";
type HeuteBriefProps = {
bots: Bot[];
groups: Group[];
@ -26,6 +27,8 @@ type HeuteBriefProps = {
onStartChat: () => void;
};
const DATE_LABEL: Intl.DateTimeFormatOptions = { weekday: "long", day: "numeric", month: "long" };
export function HeuteBrief({
bots,
groups,
@ -37,6 +40,9 @@ export function HeuteBrief({
const [activeRuns, setActiveRuns] = useState<RunActivityRow[]>([]);
const [recentRuns, setRecentRuns] = useState<RunActivityRow[]>([]);
const [loading, setLoading] = useState(true);
const [coach, setCoach] = useState<CoachToday | null>(null);
const [coachSettled, setCoachSettled] = useState(false);
const latestCoachRequest = useRef(0);
useEffect(() => {
let cancelled = false;
@ -68,6 +74,50 @@ export function HeuteBrief({
};
}, []);
const refreshCoach = useCallback(async () => {
const request = ++latestCoachRequest.current;
try {
const today = await rpc.coach.today();
if (request === latestCoachRequest.current) setCoach(today);
} catch {
// Keep the last view; the next refresh retries.
} finally {
setCoachSettled(true);
}
}, []);
// `coachSignal` is the trigger: a finished run may have logged a check-in or written the brief.
const coachSignal = useMemo(() => coachRefreshSignal(recentRuns), [recentRuns]);
useEffect(() => {
void refreshCoach();
}, [refreshCoach, coachSignal]);
useEffect(() => {
const onVisible = () => {
if (document.visibilityState === "visible") void refreshCoach();
};
document.addEventListener("visibilitychange", onVisible);
return () => {
document.removeEventListener("visibilitychange", onVisible);
// Ignore responses that arrive after unmount.
latestCoachRequest.current += 1;
};
}, [refreshCoach]);
const setHabitDone = useCallback(
async (habit: HabitToday, done: boolean) => {
if (!coach) return;
setCoach((current) => (current ? withHabitDone(current, habit.id, done) : current));
latestCoachRequest.current += 1;
try {
await rpc.coach.checkIns.log({ kind: "habit", habitId: habit.id, date: coach.date, done });
} finally {
void refreshCoach();
}
},
[coach, refreshCoach],
);
const brief = useMemo(
() =>
buildHeuteBrief({
@ -81,31 +131,45 @@ export function HeuteBrief({
const botColorById = useMemo(() => new Map(bots.map((bot) => [bot.id, bot.color])), [bots]);
const dateLabel = formatHeuteDate(new Date());
const hasContent = brief.needsYou.length > 0 || brief.doing.length > 0 || brief.done.length > 0;
const locale = i18n.locale || "de";
const dateLabel = coach
? formatLocalDate(coach.date, locale, DATE_LABEL)
: new Date().toLocaleDateString(locale, DATE_LABEL);
const hasCoachContent = Boolean(
coach && (coach.brief || coach.habits.length > 0 || coach.goals.length > 0),
);
const hasContent =
hasCoachContent || brief.needsYou.length > 0 || brief.doing.length > 0 || brief.done.length > 0;
return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden" data-testid="heute-brief">
<div className="border-b border-border px-4 py-5 md:px-8 lg:px-10">
<h1 className="text-ui-title font-semibold tracking-tight text-foreground">{dateLabel}</h1>
<p className="mt-1 text-ui-body text-muted-foreground" data-testid="heute-status">
<StatusLine
actionCount={brief.actionCount}
loading={loading}
doingCount={brief.doing.length}
/>
</p>
<div className="flex items-start justify-between gap-3 border-b border-border px-4 py-5 md:px-8 lg:px-10">
<div className="min-w-0">
<h1 className="text-ui-title font-semibold tracking-tight text-foreground">
{dateLabel}
</h1>
<p className="mt-1 text-ui-body text-muted-foreground" data-testid="heute-status">
<StatusLine
actionCount={brief.actionCount}
loading={loading}
doingCount={brief.doing.length}
/>
</p>
</div>
<CoachRhythmControl />
</div>
<div className="rk-scroll flex-1 overflow-y-auto px-4 py-6 md:px-8 lg:px-10">
{loading && !hasContent ? (
{(loading || !coachSettled) && !hasContent ? (
<p className="text-ui-body text-muted-foreground">
<Trans>Loading…</Trans>
</p>
) : !hasContent ? (
<EmptyDay onStartChat={onStartChat} hasBots={bots.length > 0} />
) : (
<div className="mx-auto flex w-full max-w-2xl flex-col gap-8">
<div className="mx-auto flex w-full max-w-2xl flex-col gap-8 pb-4">
{coach ? <HeuteCoach today={coach} onHabitDone={setHabitDone} /> : null}
{brief.needsYou.length > 0 ? (
<section data-testid="heute-needs-you">
<h2 className="rk-shell-section-label mb-2 px-0.5">
@ -172,19 +236,6 @@ export function HeuteBrief({
) : null}
</div>
)}
<footer className="mx-auto mt-10 flex w-full max-w-2xl justify-center pb-4">
<a
href={MISSION_CONTROL_URL}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-ui-label text-muted-foreground transition-colors hover:text-foreground"
data-testid="heute-mission-control-link"
>
<Trans>Mission Control</Trans>
<ExternalLink strokeWidth={1.8} className="size-icon-sm" aria-hidden="true" />
</a>
</footer>
</div>
</div>
);
@ -286,7 +337,7 @@ function NeedsYouRow({
const { chatKind, chat } = item;
const title = chat.name;
const preview = cleanBriefPreview(chat.preview) || t`Unread reply`;
const preview = cleanBriefPreview(localizedPreview(chat)) || t`Unread reply`;
const label =
chatKind === "bot" ? t`1:1 conversation with ${title}` : t`Group conversation ${title}`;
return (
@ -399,12 +450,3 @@ function jetztRowKey(item: JetztItem): string {
if (item.kind === "run") return `run:${item.run.runId}`;
return `unread:${item.chatKind}:${item.chat.id}`;
}
function formatHeuteDate(date: Date): string {
const locale = i18n.locale || "de";
return date.toLocaleDateString(locale, {
weekday: "long",
day: "numeric",
month: "long",
});
}

View File

@ -1,7 +1,7 @@
import { Trans, useLingui } from "@lingui/react/macro";
import type { IntegrationSetupState, MainConversationRef } from "@rakazo/contracts";
import { Button, Input } from "@rakazo/ui-web";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { Button } from "@rakazo/ui-web";
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { IntegrationSetup } from "../components/integrations/IntegrationSetup";
import { OnboardingDiscoveryShell } from "../components/onboarding/OnboardingDiscoveryShell";
@ -9,7 +9,7 @@ import {
type OnboardingEmojiConfig,
OnboardingOoDesigner,
} from "../components/onboarding/OnboardingOoDesigner";
import { OnboardingScanProgress } from "../components/onboarding/OnboardingScanProgress";
import { OnboardingScanStep } from "../components/onboarding/OnboardingScanStep";
import {
KOKORO_LOCAL_TOKEN,
OnboardingVoiceStep,
@ -24,7 +24,6 @@ import { findLookCategory, lookPresetToAvatarValue } from "../lib/onboarding-loo
import { onboardingProfileUpdate } from "../lib/onboarding-profile";
import { rpc, selectSpace } from "../lib/rpc";
import { persistUiLocale, UI_LOCALE_LABELS, type UiLocale } from "../lib/ui-locale";
import { inferHelpVibeHints, type UserHelpVibeHints } from "../lib/user-help-vibe";
import { collectLocalProfileSignals, persistUserProfileHarvest } from "../lib/user-profile-harvest";
/** Selects the main conversation's space and returns its route. */
@ -53,57 +52,11 @@ function defaultEmojiConfig(): OnboardingEmojiConfig {
};
}
interface UserPreferences {
name: string;
timezone: string;
hobbies: string[];
workStyle: string;
communication: string;
}
const HOBBY_OPTIONS = [
{ key: "coding", label: "Coding" },
{ key: "design", label: "Design" },
{ key: "writing", label: "Writing" },
{ key: "music", label: "Music" },
{ key: "gaming", label: "Gaming" },
{ key: "reading", label: "Reading" },
{ key: "sports", label: "Sports" },
{ key: "cooking", label: "Cooking" },
{ key: "tech", label: "Tech & Gadgets" },
{ key: "none", label: <Trans>Nothing specific</Trans> },
];
const WORK_STYLE_OPTIONS = [
{ key: "structured", label: <Trans>Structured & planned</Trans> },
{ key: "flexible", label: <Trans>Flexible & spontaneous</Trans> },
{ key: "deep-dive", label: <Trans>Deep-dive focused</Trans> },
{ key: "quick", label: <Trans>Quick & pragmatic</Trans> },
];
const COMMUNICATION_OPTIONS = [
{ key: "direct", label: <Trans>Direct & concise</Trans> },
{ key: "detailed", label: <Trans>Detailed & explanatory</Trans> },
{ key: "balanced", label: <Trans>Balanced</Trans> },
{ key: "casual", label: <Trans>Casual & friendly</Trans> },
];
function defaultPreferences(): UserPreferences {
return {
name: "",
timezone: localTimezone(),
hobbies: [],
workStyle: "",
communication: "",
};
}
export function OnboardingPage() {
const { t } = useLingui();
const navigate = useNavigate();
const fieldId = useId();
const [step, setStep] = useState<
"loading" | "emoji" | "preferences" | "language" | "voice" | "integrations" | "scan" | "bot"
"loading" | "emoji" | "language" | "voice" | "integrations" | "scan" | "bot"
>("emoji");
const [selectedVoiceId, setSelectedVoiceId] = useState("");
const [voiceSkipped, setVoiceSkipped] = useState(false);
@ -114,11 +67,8 @@ export function OnboardingPage() {
const [bootstrapMe, setBootstrapMe] = useState<Awaited<ReturnType<typeof rpc.me>> | null>(null);
const [profilePersisted, setProfilePersisted] = useState(false);
const [crewReady, setCrewReady] = useState(false);
const [_helpVibe, setHelpVibe] = useState<UserHelpVibeHints | null>(null);
const [scanBotId, setScanBotId] = useState<string | null>(null);
const [emojiConfig, setEmojiConfig] = useState<OnboardingEmojiConfig>(defaultEmojiConfig);
const [prefs, setPrefs] = useState<UserPreferences>(defaultPreferences());
const [currentLocale, setCurrentLocale] = useState<string>(() => {
try {
return localStorage.getItem("rakazo.uiLocale") ?? "en";
@ -140,15 +90,6 @@ export function OnboardingPage() {
.then(async ([me, integrations]) => {
if (cancelled) return;
setBootstrapMe(me);
setHelpVibe(inferHelpVibeHints(collectLocalProfileSignals(me)));
// Get bot ID for scan phase
try {
const botList = await rpc.bots.list();
const mainBot = botList.find((b) => b.kind === "main");
if (mainBot && !cancelled) setScanBotId(mainBot.id);
} catch {
/* best effort */
}
setIntegrationSetup(integrations);
// The first step renders before this resolves; never pull a user back who moved on.
if (!me.needsModel && integrations?.needsSetup) {
@ -178,17 +119,10 @@ export function OnboardingPage() {
};
}, [navigate]);
function toggleHobby(key: string) {
const hobbies = prefs.hobbies.includes(key)
? prefs.hobbies.filter((h) => h !== key)
: [...prefs.hobbies, key];
setPrefs({ ...prefs, hobbies });
}
const discoveryPhase: OnboardingDiscoveryPhase =
step === "loading"
? "loading"
: step === "emoji" || step === "preferences" || step === "language" || step === "voice"
: step === "emoji" || step === "language" || step === "voice"
? "emoji"
: step === "integrations"
? "integrations"
@ -229,8 +163,6 @@ export function OnboardingPage() {
await rpc.profile
.update(
onboardingProfileUpdate({
answers: prefs,
accountName: bootstrapMe?.name,
browserTimeZone: localTimezone(),
locale: typeof navigator !== "undefined" ? navigator.language : undefined,
language: languageChoice,
@ -252,22 +184,15 @@ export function OnboardingPage() {
const me = await rpc.me().catch(() => null);
if (me) setBootstrapMe(me);
const harvested = await persistUserProfileHarvest(
rpc,
collectLocalProfileSignals(me ?? undefined),
).catch(() => null);
if (harvested) setHelpVibe(harvested);
await persistUserProfileHarvest(rpc, collectLocalProfileSignals(me ?? undefined)).catch(
() => null,
);
setProfilePersisted(true);
for (const serverId of integrationServers) {
await rpc.mcp.assignments.approve({ botId: bot.id, serverId });
}
const started = await rpc.onboarding
.start({ botId: bot.id })
.then(() => true)
.catch(() => false);
if (started) {
await rpc.onboarding.promptFocus({ botId: bot.id }).catch(() => undefined);
}
// The main bot asks the rest in the main conversation.
await rpc.onboarding.promptFocus({ botId: bot.id }).catch(() => undefined);
const main = await rpc.spaces
.list()
.then((navigation) => navigation.main)
@ -300,11 +225,7 @@ export function OnboardingPage() {
<OnboardingDiscoveryShell
items={discoveryItems}
compact={
step === "emoji" ||
step === "preferences" ||
step === "language" ||
step === "voice" ||
step === "integrations"
step === "emoji" || step === "language" || step === "voice" || step === "integrations"
}
>
{error && step !== "bot" ? <p className="mb-4 text-sm text-destructive">{error}</p> : null}
@ -313,140 +234,10 @@ export function OnboardingPage() {
<OnboardingOoDesigner
config={emojiConfig}
onChange={setEmojiConfig}
onContinue={() => setStep("preferences")}
onContinue={() => setStep("language")}
/>
) : null}
{step === "preferences" ? (
<div className="mx-auto max-w-xl">
<h2 className="text-xl font-semibold tracking-tight text-foreground">
<Trans>Tell us about yourself</Trans>
</h2>
<p className="mt-1 text-sm text-muted-foreground">
<Trans>A few quick preferences so your Oo can be better tailored to you.</Trans>
</p>
<div className="mt-6">
<label
htmlFor={`${fieldId}-name`}
className="block text-sm font-medium text-foreground"
>
<Trans>Name</Trans>
</label>
<Input
id={`${fieldId}-name`}
value={prefs.name}
onChange={(e) => setPrefs({ ...prefs, name: e.target.value })}
placeholder={t`Your name`}
className="mt-2"
/>
</div>
<div className="mt-6">
<label
htmlFor={`${fieldId}-timezone`}
className="block text-sm font-medium text-foreground"
>
<Trans>Timezone</Trans>
</label>
<Input
id={`${fieldId}-timezone`}
value={prefs.timezone}
onChange={(e) => setPrefs({ ...prefs, timezone: e.target.value })}
placeholder={t`Auto-detected or enter manually`}
className="mt-2"
/>
</div>
<fieldset className="mt-6 border-0 p-0">
<legend className="block text-sm font-medium text-foreground">
<Trans>Hobbies & interests</Trans>
</legend>
<p className="mt-1 text-xs text-muted-foreground">
<Trans>Select all that apply</Trans>
</p>
<div className="mt-3 flex flex-wrap gap-2">
{HOBBY_OPTIONS.map((hobby) => (
<button
key={hobby.key}
type="button"
onClick={() => toggleHobby(hobby.key)}
aria-pressed={prefs.hobbies.includes(hobby.key)}
className={`rounded-full border px-4 py-1.5 text-sm font-medium transition-all ${
prefs.hobbies.includes(hobby.key)
? "border-primary bg-primary/10 text-foreground"
: "border-border bg-card text-muted-foreground hover:border-muted hover:bg-muted"
}`}
>
{hobby.label}
</button>
))}
</div>
</fieldset>
<fieldset className="mt-6 border-0 p-0">
<legend className="block text-sm font-medium text-foreground">
<Trans>Work style</Trans>
</legend>
<p className="mt-1 text-xs text-muted-foreground">
<Trans>How do you like to work?</Trans>
</p>
<div className="mt-3 grid grid-cols-2 gap-2">
{WORK_STYLE_OPTIONS.map((ws) => (
<button
key={ws.key}
type="button"
onClick={() => setPrefs({ ...prefs, workStyle: ws.key })}
aria-pressed={prefs.workStyle === ws.key}
className={`rounded-xl border-2 px-3 py-3 text-sm font-medium text-left transition-all ${
prefs.workStyle === ws.key
? "border-primary bg-primary/10 text-foreground shadow-sm"
: "border-border bg-card text-muted-foreground hover:border-muted hover:bg-muted"
}`}
>
{ws.label}
</button>
))}
</div>
</fieldset>
<fieldset className="mt-6 border-0 p-0">
<legend className="block text-sm font-medium text-foreground">
<Trans>Communication style</Trans>
</legend>
<p className="mt-1 text-xs text-muted-foreground">
<Trans>How should your Oo talk to you?</Trans>
</p>
<div className="mt-3 grid grid-cols-2 gap-2">
{COMMUNICATION_OPTIONS.map((comm) => (
<button
key={comm.key}
type="button"
onClick={() => setPrefs({ ...prefs, communication: comm.key })}
aria-pressed={prefs.communication === comm.key}
className={`rounded-xl border-2 px-3 py-3 text-sm font-medium text-left transition-all ${
prefs.communication === comm.key
? "border-primary bg-primary/10 text-foreground shadow-sm"
: "border-border bg-card text-muted-foreground hover:border-muted hover:bg-muted"
}`}
>
{comm.label}
</button>
))}
</div>
</fieldset>
<div className="mt-8 flex items-center justify-between">
<Button variant="ghost" size="sm" onClick={() => setStep("emoji")}>
<Trans>Back</Trans>
</Button>
<Button onClick={() => setStep("language")}>
<Trans>Continue</Trans>
</Button>
</div>
</div>
) : null}
{step === "language" ? (
<div className="mx-auto max-w-xl">
<h2 className="text-xl font-semibold tracking-tight text-foreground">
@ -477,7 +268,7 @@ export function OnboardingPage() {
))}
</div>
<div className="mt-8 flex items-center justify-between">
<Button variant="ghost" size="sm" onClick={() => setStep("preferences")}>
<Button variant="ghost" size="sm" onClick={() => setStep("emoji")}>
<Trans>Back</Trans>
</Button>
<Button onClick={() => setStep("voice")}>
@ -513,12 +304,7 @@ export function OnboardingPage() {
</div>
) : null}
{step === "scan" ? (
<OnboardingScanProgress
onComplete={() => setStep("bot")}
botId={scanBotId ?? undefined}
/>
) : null}
{step === "scan" ? <OnboardingScanStep onDone={() => setStep("bot")} /> : null}
{step === "bot" && error ? (
<div className="text-center">

View File

@ -24,6 +24,7 @@ import {
} from "@rakazo/ui-web";
import { ChevronLeft, Clock, GitBranch, Globe, MessageSquare, Pause, Plus, X } from "lucide-react";
import { useId } from "react";
import { routineName } from "../lib/routine-name";
import { RoutineSchedule } from "./RoutineSchedule";
function toDatetimeLocalValue(date: Date): string {
@ -85,7 +86,7 @@ export function emptyRoutineDraft(): RoutineDraftState {
export function draftFromRoutine(routine: Routine): RoutineDraftState {
return {
name: routine.name,
name: routineName(routine),
prompt: routine.prompt,
schedules: routine.crons.map(presetFromCron),
webhookEnabled: routine.webhookEnabled,
@ -156,7 +157,7 @@ export function RoutineListRow({
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[14.5px] font-medium text-foreground" dir="auto">
{routine.name}
{routineName(routine)}
</span>
<span className="block truncate text-ui-body-sm text-muted-foreground/80">
{routineTriggerSummary(routine)}

View File

@ -2,6 +2,7 @@ import { useLingui } from "@lingui/react/macro";
import type { AvatarStyle, SpaceMemoryConfig } from "@rakazo/contracts";
import { Button, Dialog, DialogClose, DialogContent, DialogTitle } from "@rakazo/ui-web";
import {
Bell,
Brain,
CloudDownload,
Cpu,
@ -16,6 +17,7 @@ import {
} from "lucide-react";
import { type ComponentType, useEffect, useRef, useState } from "react";
import { computersAreUnavailable } from "../components/ComputersUnavailableHint";
import { NotificationSettingsPanel } from "../components/NotificationSettingsPanel";
import { UserProfileSettings } from "../components/UserProfileSettings";
import {
ComputerSettingsPanel,
@ -36,6 +38,7 @@ export type SettingsSection =
| "memory"
| "profile"
| "voice"
| "notifications"
| "usage"
| "computer"
| "updates"
@ -108,6 +111,7 @@ export function SettingsOverlay({
{ id: "memory", label: t`Memory`, icon: Brain },
{ id: "profile", label: t`About you`, icon: UserRound },
{ id: "voice", label: t`Voice`, icon: Volume2 },
{ id: "notifications", label: t`Notifications`, icon: Bell },
{ id: "usage", label: t`Usage`, icon: Gauge },
...(showComputer ? [{ id: "computer" as const, label: t`Computer`, icon: Monitor }] : []),
{ id: "updates", label: t`Updates`, icon: CloudDownload },
@ -246,6 +250,7 @@ export function SettingsOverlay({
/>
) : null}
{section === "profile" ? <UserProfileSettings /> : null}
{section === "notifications" ? <NotificationSettingsPanel /> : null}
{section === "usage" ? (
<UsageSettingsPanel usage={usage} panelRef={usageRef} />
) : null}

View File

@ -216,6 +216,7 @@ import { lazyWithRecovery } from "../lib/lazy-recovery";
import { localTimezone } from "../lib/local-timezone";
import { copyableMessageText } from "../lib/message-text";
import { messageProviderLabel } from "../lib/messaging";
import { localizedPreview, withIntakeNoteText } from "../lib/onboarding-copy";
import { wireOoAudioVoiceDucking } from "../lib/oo-audio-voice-wire";
import { ooSound } from "../lib/oo-sound";
import { ooVocals } from "../lib/oo-vocals";
@ -229,6 +230,7 @@ import { markAfterPaint, markOnce } from "../lib/performance";
import { isEmptyProjectSpace } from "../lib/project-empty-state";
import { readProjectSidebarOrder, writeProjectSidebarOrder } from "../lib/project-sidebar-order";
import { quoteDraftForSelection } from "../lib/quote-selection";
import { routineSaveName } from "../lib/routine-name";
import { clearSpaceSelection, rpc, selectedSpaceId, selectSpace } from "../lib/rpc";
import { readSeenRunErrorIds, rememberSeenRunErrorId } from "../lib/run-error-storage";
import { sharedInflight } from "../lib/shared-inflight";
@ -255,6 +257,7 @@ import {
transcriptMovedDown,
} from "../lib/transcript-scroll";
import { speaker } from "../lib/tts";
import { disableWebPush } from "../lib/web-push";
import { readScreenPaneHabit, writeScreenPaneHabit } from "../lib/workspace-pane-pref";
import { ActivityList } from "./ActivityList";
import type { ContextMenuPosition } from "./BotContextMenu";
@ -3652,7 +3655,7 @@ export function ShellPage() {
time={formatRosterTime(mainConversationEntry.group.updatedAt)}
subtitle={rosterActivitySubtitle(
mainConversationEntry.bot.status,
cleanRosterPreview(mainConversationEntry.group.preview),
cleanRosterPreview(localizedPreview(mainConversationEntry.group)),
)}
active={inGroup && groupId === mainConversationEntry.groupId}
onOpen={openMainConversation}
@ -4166,7 +4169,7 @@ export function ShellPage() {
item.chat.unread,
)}`}
>
{cleanRosterPreview(item.chat.preview)}
{cleanRosterPreview(localizedPreview(item.chat))}
</div>
</div>
</button>
@ -4314,10 +4317,14 @@ export function ShellPage() {
variant="ghost"
className="w-full justify-start font-normal"
onClick={() =>
void authClient.signOut().then(() => {
clearSpaceSelection();
navigate("/");
})
// This browser stops receiving the account's pushes once it signs out.
void disableWebPush()
.catch(() => undefined)
.then(() => authClient.signOut())
.then(() => {
clearSpaceSelection();
navigate("/");
})
}
>
<LogOut className="text-muted-foreground" strokeWidth={1.75} />
@ -4662,7 +4669,12 @@ export function ShellPage() {
</>
)}
{activeGroup?.kind === "main" && !showProjectTimeline ? (
<MainInbox bots={bots} onOpenBot={openBot} onOpenGroup={openGroup} />
<MainInbox
bots={bots}
onOpenBot={openBot}
onOpenGroup={openGroup}
onOpenInSpace={openSpaceChat}
/>
) : null}
{recordingSkill ? (
<div className="px-6 pb-2 text-center text-ui-body-sm text-destructive">
@ -5087,7 +5099,7 @@ export function ShellPage() {
}
saved = await rpc.routines.update({
routineId: targetRoutine.id,
name: routineDraft.name || t`Routine`,
name: routineSaveName(targetRoutine, routineDraft.name) || t`Routine`,
prompt: routineDraft.prompt || t`Check in.`,
crons,
active: armOneShot ? true : routineDraft.active,
@ -5841,7 +5853,11 @@ const Transcript = memo(function Transcript({
() => new Map(messages.map((message) => [message.id, message])),
[messages],
);
const reactionView = useMemo(() => projectMessageReactions(messages), [messages]);
const reactionView = useMemo(
() => projectMessageReactions(messages.map(withIntakeNoteText)),
// Onboarding notes are worded in the UI language.
[messages, i18n.locale],
);
const transcriptSubordinate = encounterMode && voiceEncounterActive;
const workingBotName = workingBots.length === 1 ? workingBots[0]?.name : undefined;
const workingLabel =

View File

@ -1,15 +1,35 @@
import { Trans, useLingui } from "@lingui/react/macro";
import type { MessageBlock } from "@rakazo/contracts";
import { CHOICE_TEXT_ANSWER_ID } from "@rakazo/contracts";
import { abortableDelay } from "@rakazo/core";
import { Button, Dialog, DialogClose, DialogContent, DialogTitle } from "@rakazo/ui-web";
import { X } from "lucide-react";
import {
Button,
Dialog,
DialogClose,
DialogContent,
DialogTitle,
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@rakazo/ui-web";
import { ArrowUp, Check, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { BuiCard, SuccessPop } from "../../components/ai/primitives";
import { type ArtifactTarget, decodeArtifactBase64 } from "../../lib/artifact-open";
import { chartViewport } from "../../lib/chart-viewport";
import { connectMcpOauth } from "../../lib/mcp-connect";
import { chosenOption, localizedChoice } from "../../lib/onboarding-copy";
import { rpc } from "../../lib/rpc";
import { enableWebPush, webPushEnabled, webPushSupported } from "../../lib/web-push";
type ChoiceOption = Extract<MessageBlock, { kind: "choice" }>["options"][number];
/**
* A question card. Lettered options are the answers; an option without a letter ("Skip",
* "Not now") is the quiet way out, and a card without one can be dismissed instead. Onboarding
* cards may also take an answer in the user's own words.
*/
export function ChoiceCard({
botId,
block,
@ -22,14 +42,33 @@ export function ChoiceCard({
const { t } = useLingui();
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [text, setText] = useState("");
const [locallyDismissed, setLocallyDismissed] = useState(false);
const dismissed = locallyDismissed || block.answerId === "_dismissed";
const answered = Boolean(block.answerId);
// What the card says, in the user's language; answers still go out by step and option id.
const card = localizedChoice(block);
const answers = card.options.filter((option) => option.letter);
const waysOut = card.options.filter((option) => !option.letter);
const testId = (option: ChoiceOption) => `${block.step ?? "focus"}-choice-${option.id}`;
async function choose(optionId: string) {
async function choose(optionId: string, words?: string) {
setPending(true);
setError(null);
try {
await rpc.onboarding.choose({ botId, optionId });
// Tapping "Turn on" is the gesture the browser needs before it asks for permission.
const answer =
block.step === "notifications" && optionId === "on" && (await enableWebPush()) !== "enabled"
? "later"
: optionId;
await rpc.onboarding.choose({
botId,
step: block.step,
optionId: answer,
...(words ? { text: words } : {}),
// The server offers the notifications card only to a browser that can still turn push on.
canEnablePush: webPushSupported() && !(await webPushEnabled()),
});
await onBotChanged();
} catch (err) {
setError(err instanceof Error ? err.message : t`Could not save this choice`);
@ -52,10 +91,16 @@ export function ChoiceCard({
if (dismissed) return null;
const chosen = chosenOption(card);
return (
<div className="flex justify-start">
<div className="apple-glass-card relative w-[440px] max-w-full rounded-[24px] px-5 py-4.5 transition-all">
{!block.answerId ? (
<BuiCard
data-testid="choice-card"
data-step={block.step}
className="relative w-[440px] max-w-full px-5 py-4"
>
{!answered && waysOut.length === 0 ? (
<Button
variant="ghost"
size="icon-sm"
@ -68,54 +113,116 @@ export function ChoiceCard({
</Button>
) : null}
<div className="pe-8 text-ui-body font-medium text-foreground tracking-tight">
{block.question}
{card.question}
</div>
{block.subtitle ? (
<div className="mt-1 text-ui-body text-muted-foreground">{block.subtitle}</div>
) : null}
<div className="mt-3.5 space-y-2.5">
{block.options
.filter((option) => !block.answerId || option.id === block.answerId)
.map((option) => (
<button
key={option.id}
type="button"
data-testid={`focus-choice-${option.id}`}
aria-label={option.label}
disabled={Boolean(block.answerId) || pending}
onClick={() => void choose(option.id)}
className={`tactile-press flex w-full items-start gap-3.5 rounded-[18px] border border-foreground/5 p-3.5 text-start text-foreground disabled:opacity-60 transition-all ${
block.answerId === option.id
? "bg-blue-500/20 border-blue-500/40 shadow-sm"
: "bg-foreground/[0.04] hover:bg-foreground/[0.08] hover:border-foreground/10"
}`}
{answered ? (
chosen ? (
<div
data-testid="choice-answer"
className="mt-3 flex items-start gap-3 rounded-xl border border-primary/30 bg-primary/5 p-3 text-ui-body font-medium text-foreground"
>
{chosen.letter ? <ChoiceLetter letter={chosen.letter} selected /> : null}
<span className="flex-1 leading-snug" dir="auto">
{chosen.label}
</span>
<Check aria-hidden strokeWidth={2.2} className="mt-0.5 size-icon-md text-primary" />
</div>
) : null
) : (
<>
{answers.length > 0 ? (
<div className="mt-3 space-y-2">
{answers.map((option) => (
<button
key={option.id}
type="button"
data-testid={testId(option)}
aria-label={option.label}
disabled={pending}
onClick={() => void choose(option.id)}
className="tactile-press flex w-full items-start gap-3 rounded-xl border border-border bg-background p-3 text-start text-ui-body leading-snug text-foreground transition-colors hover:bg-muted disabled:opacity-60"
>
<ChoiceLetter letter={option.letter} />
<span className="flex-1">{option.label}</span>
</button>
))}
</div>
) : null}
{card.textInput ? (
<form
className="mt-3"
onSubmit={(event) => {
event.preventDefault();
const words = text.trim();
if (words) void choose(CHOICE_TEXT_ANSWER_ID, words);
}}
>
<span
className={`mt-0.5 grid h-6 w-6 shrink-0 place-items-center rounded-[8px] text-ui-body-sm font-semibold ${
block.answerId === option.id
? "bg-blue-500 text-white"
: "bg-foreground/10 text-foreground/80"
}`}
>
{option.letter}
</span>
<span
className={`flex-1 text-ui-body leading-snug ${block.answerId ? "text-foreground font-medium" : "text-foreground"}`}
>
{option.label}
</span>
{block.answerId === option.id ? (
<span className="mt-0.5 text-blue-700 dark:text-blue-400 font-bold">✓</span>
) : null}
</button>
))}
</div>
<InputGroup className="h-control-lg rounded-xl bg-background">
<InputGroupInput
value={text}
onChange={(event) => setText(event.target.value)}
placeholder={card.textInput.placeholder}
aria-label={card.question}
maxLength={200}
disabled={pending}
dir="auto"
/>
<InputGroupAddon align="inline-end">
<InputGroupButton
type="submit"
size="icon-sm"
variant="default"
aria-label={t`Send`}
disabled={pending || !text.trim()}
className="rounded-lg"
>
<ArrowUp aria-hidden />
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</form>
) : null}
{waysOut.length > 0 ? (
<div className="mt-2 flex justify-end gap-1">
{waysOut.map((option) => (
<Button
key={option.id}
variant="ghost"
size="sm"
data-testid={testId(option)}
disabled={pending}
onClick={() => void choose(option.id)}
className="text-muted-foreground hover:text-foreground"
>
{option.label}
</Button>
))}
</div>
) : null}
</>
)}
{error ? <p className="mt-2 text-ui-label text-destructive">{error}</p> : null}
</div>
</BuiCard>
</div>
);
}
function ChoiceLetter({ letter, selected = false }: { letter: string; selected?: boolean }) {
return (
<span
aria-hidden
className={`mt-0.5 grid size-6 shrink-0 place-items-center rounded-lg text-ui-body-sm font-semibold ${
selected ? "bg-primary text-primary-foreground" : "bg-muted text-foreground/80"
}`}
>
{letter}
</span>
);
}
export function AppConnectCard({
botId,
block,

View File

@ -19,7 +19,7 @@ import {
createWebProvider,
databaseCapacityBackoffMs,
EncryptedSecretStore,
ExpoPushProvider,
ExpoPushSender,
GraphileJobPublisher,
GraphileJobWorkerHost,
InMemoryJobQueue,
@ -36,6 +36,7 @@ import {
PiAgentRuntime,
PipedreamConnector,
PostgresRealtimeFanout,
PushNotificationProvider,
pipedreamConfigFromEnv,
reconcileCloudAgents,
reconcileComputerUpdates,
@ -45,6 +46,9 @@ import {
resolveSandboxProvider,
ScriptedAgentRuntime,
SpaceMemoryProviderResolver,
VapidKeys,
vapidEnvFromProcess,
WebPushSender,
} from "@rakazo/adapters";
import { resolveEncryptionKey, resolveSupervisorToken } from "@rakazo/core";
import {
@ -193,7 +197,10 @@ async function main() {
secretStore: secrets,
deploymentModelKey,
dataDir,
notifications: new ExpoPushProvider(dataDir),
notifications: new PushNotificationProvider(prisma, {
webpush: new WebPushSender(new VapidKeys(prisma, secrets, vapidEnvFromProcess(process.env))),
expo: new ExpoPushSender(),
}),
jobs,
events,
messaging: messaging ? createMessagingContextLoader(prisma) : undefined,

View File

@ -127,6 +127,19 @@ Called from `Onboarding.tsx` after first bot ensure.
Seeded in user `MEMORY.md` (`user-relationship-contract.ts`). Persona copy and prompts follow. Details: `docs/OO-RELATIONSHIP.md`.
## Memory and the user model
| Store | Holds | Written by |
|-------|-------|------------|
| `UserProfile` + `UserFact` (Settings → About you) | The user model: who the user is, across spaces; a fact can be limited to chosen spaces | `update_user_profile`, onboarding, chat consolidation, the user |
| User-scoped `MEMORY.md` (Settings → Memory) | Space notes shared by the bots of one space | The user, onboarding harvest |
| Bot-scoped `MEMORY.md` (bot → Knowledge) | One bot's notes in one space | `remember` appends dated notes; `mode: "replace"` rewrites |
Space notes are not migrated into facts: facts are short, consented statements about the user,
space notes are free-form context for one space. A memory document holds at most 16 KB, so a
bot's notes and the space notes both reach the prompt; when it is full, `remember` asks the bot
to condense it.
## Capability vision — ambient & video (future roadmap)
**This PR does not build surveillance.** Current Gesamterfahrung stays **Stage + sound + calm onboarding** (`VOICE.md`). The following is Ingo's long-term direction — optional layers only.

View File

@ -1,5 +1,6 @@
export * from "./background-jobs.js";
export * from "./cloud-agents.js";
export * from "./interfaces.js";
export * from "./memory.js";
export * from "./registry.js";
export * from "./types.js";

View File

@ -0,0 +1,13 @@
/**
* Largest Markdown memory document a commit may store, in UTF-8 bytes: half of what a run reads,
* so a bot's document and the space's shared one both reach the prompt.
*/
export const MEMORY_DOCUMENT_MAX_BYTES = 16 * 1024;
/** A commit would grow a memory document past MEMORY_DOCUMENT_MAX_BYTES. */
export class MemoryDocumentTooLargeError extends Error {
constructor(readonly path: string) {
super(`${path} would exceed ${MEMORY_DOCUMENT_MAX_BYTES / 1024} KB`);
this.name = "MemoryDocumentTooLargeError";
}
}

View File

@ -250,6 +250,8 @@ export interface MemoryCommitRequest {
botId?: string;
path: string;
content: string;
/** "replace" (default) stores `content` as the whole document; "append" adds it at the end. */
mode?: "replace" | "append";
sourceRunId?: string;
sourceThreadId?: string;
}
@ -435,7 +437,7 @@ export interface ScriptedTurn {
ask?: { text: string; detail?: string; actions?: Array<{ id: string; label: string }> };
takeover?: { reason: string };
files?: Array<{ path: string; content: string }>;
memory?: Array<{ scope: "bot" | "user"; path: string; content: string }>;
memory?: Array<Pick<MemoryCommitRequest, "scope" | "path" | "content" | "mode">>;
complete?: boolean;
}
@ -553,7 +555,7 @@ export interface ArtifactPut {
}
export interface NotificationMessage {
kind: "completion" | "failure" | "help" | "takeover";
kind: "completion" | "failure" | "help" | "takeover" | "nudge";
title: string;
body: string;
botId: string;

View File

@ -42,6 +42,7 @@
"sharp": "0.35.4",
"shell-quote": "1.10.0",
"undici": "8.10.2",
"web-push": "3.6.7",
"zod": "4.6.2"
},
"devDependencies": {
@ -50,6 +51,7 @@
"@types/jsdom": "30.0.0",
"@types/nodemailer": "8.0.1",
"@types/pg": "8.23.1",
"@types/web-push": "3.6.4",
"typescript": "7.0.2",
"vitest": "4.1.11"
}

View File

@ -8,6 +8,8 @@ import {
USER_FACT_CATEGORIES,
} from "@rakazo/contracts";
import { z } from "zod";
import { coachTools } from "./coach-tools.js";
import { NOTIFY_USER_BODY_MAX_LENGTH, NOTIFY_USER_TITLE_MAX_LENGTH } from "./notify-user-tool.js";
export const SUBAGENT_MODEL_TIER_DESCRIPTION =
"Optional model tier: fast for quick simple work, code for programming, standard otherwise. Uses the model this space maps to the tier, or your own model when it maps none. Ignored when model_provider and model_id are set.";
@ -385,12 +387,18 @@ export const builtinAgentTools: ConnectorTool[] = [
},
{
name: "remember",
description: "Store a durable fact in this bot's explicit memory.",
description:
"Add a durable note to this bot's memory document; it is appended with today's date. Use mode replace only to rewrite the whole document, e.g. to condense it when it is full.",
inputSchema: {
type: "object",
properties: {
content: { type: "string" },
path: { type: "string" },
path: { type: "string", description: "Memory document, default MEMORY.md." },
mode: {
type: "string",
enum: ["append", "replace"],
description: "append (default) adds a note; replace sets the whole document to content.",
},
},
required: ["content"],
},
@ -429,6 +437,28 @@ export const builtinAgentTools: ConnectorTool[] = [
},
},
},
...coachTools,
{
name: "notify_user",
description:
"Send the user a short push notification that opens the main conversation. Use it sparingly, for a check-in or reminder that matters now, and say the same in the conversation. The user's quiet hours, their nudge setting and a daily limit apply; the result says whether it was sent.",
inputSchema: {
type: "object",
properties: {
body: {
type: "string",
maxLength: NOTIFY_USER_BODY_MAX_LENGTH,
description: "One or two short sentences.",
},
title: {
type: "string",
maxLength: NOTIFY_USER_TITLE_MAX_LENGTH,
description: "Optional; defaults to your name.",
},
},
required: ["body"],
},
},
{
name: "web_search",
description:

View File

@ -0,0 +1,294 @@
import { randomUUID } from "node:crypto";
import { routineJobKey, routineWakeupJob } from "@rakazo/adapter-kit";
import { nextCronDate } from "@rakazo/core";
import type { PrismaClient } from "@rakazo/db";
import { bootstrapUserSpace, createDb, ensureMainConversation } from "@rakazo/db";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { COACH_ROUTINES, getCoachRhythm, setCoachRhythm } from "./coach-rhythm.js";
import { updateUserProfileFromTool } from "./user-profile-tools.js";
const databaseUrl = process.env.DATABASE_URL;
const describePostgres =
process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip;
const signupEnv = { signupsEnabled: "true", signupAllowlist: "" };
describePostgres("coach rhythm (PostgreSQL)", () => {
let prisma: PrismaClient;
let close: () => Promise<void>;
const userIds: string[] = [];
async function newUser(timeZone: string | null = "Europe/Vienna") {
const id = `cr${randomUUID().replaceAll("-", "")}`;
userIds.push(id);
await prisma.user.create({ data: { id, name: "Ada", email: `${id}@rakazo.test` } });
await bootstrapUserSpace(prisma, { id }, signupEnv, { claimDeploymentOwner: false });
if (timeZone) await prisma.userProfile.create({ data: { userId: id, timeZone } });
return id;
}
function fakeJobs() {
return { enqueue: vi.fn(async () => undefined), cancel: vi.fn(async () => undefined) };
}
function coachRoutines(userId: string) {
return prisma.routine.findMany({
where: { userId, coachKey: { not: null } },
orderBy: { coachKey: "asc" },
});
}
beforeAll(() => {
const db = createDb(databaseUrl!);
prisma = db.prisma;
close = async () => {
await db.prisma.$disconnect();
await db.pool.end();
};
});
afterAll(async () => {
if (!prisma) return;
const memberships = await prisma.member.findMany({
where: { userId: { in: userIds } },
select: { organizationId: true },
});
await prisma.organization.deleteMany({
where: { id: { in: memberships.map((member) => member.organizationId) } },
});
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
await close();
});
it("keeps one main-bot routine per check-in in the main conversation and user's time zone", async () => {
const userId = await newUser("Europe/Vienna");
const jobs = fakeJobs();
const rhythm = { morning: "07:30", evening: null, weekly: { day: 0, time: "18:00" } };
expect(await setCoachRhythm({ prisma, jobs }, { userId, rhythm })).toEqual(rhythm);
const conversation = await ensureMainConversation(prisma, userId);
const created = await coachRoutines(userId);
expect(
created.map(
({ coachKey, botId, threadId, spaceId, crons, timezone, active, notify, name }) => ({
coachKey,
botId,
threadId,
spaceId,
crons,
timezone,
active,
notify,
name,
}),
),
).toEqual([
{
coachKey: "morning",
botId: conversation.leadBotId,
threadId: conversation.threadId,
spaceId: conversation.spaceId,
crons: ["30 7 * * *"],
timezone: "Europe/Vienna",
active: true,
notify: true,
name: COACH_ROUTINES.morning.name,
},
{
coachKey: "weekly",
botId: conversation.leadBotId,
threadId: conversation.threadId,
spaceId: conversation.spaceId,
crons: ["0 18 * * 0"],
timezone: "Europe/Vienna",
active: true,
notify: true,
name: COACH_ROUTINES.weekly.name,
},
]);
for (const routine of created) {
expect(routine.nextRunAt!.getTime()).toBeGreaterThan(Date.now());
expect(jobs.enqueue).toHaveBeenCalledWith(
expect.objectContaining({
name: "routine.wakeup",
replaceKey: routineJobKey(routine.id),
availableAt: routine.nextRunAt,
}),
);
}
expect(created[0]?.prompt).toBe(COACH_ROUTINES.morning.prompt);
// The same rhythm again changes nothing.
jobs.enqueue.mockClear();
expect(await setCoachRhythm({ prisma, jobs }, { userId, rhythm })).toEqual(rhythm);
expect(jobs.enqueue).not.toHaveBeenCalled();
expect(await coachRoutines(userId)).toEqual(created);
// Moving, adding and turning off check-ins.
const next = { morning: "06:45", evening: "21:00", weekly: null };
expect(await setCoachRhythm({ prisma, jobs }, { userId, rhythm: next })).toEqual(next);
const updated = await coachRoutines(userId);
expect(updated.map((routine) => [routine.coachKey, routine.crons[0]])).toEqual([
["evening", "0 21 * * *"],
["morning", "45 6 * * *"],
]);
expect(updated.find((routine) => routine.coachKey === "morning")?.id).toBe(created[0]?.id);
expect(jobs.cancel).toHaveBeenCalledWith(routineJobKey(created[1]!.id));
expect(await getCoachRhythm(prisma, userId)).toEqual(next);
});
it("reads paused or reshaped routines as off and turns them back on", async () => {
const userId = await newUser("UTC");
const jobs = fakeJobs();
await setCoachRhythm(
{ prisma, jobs },
{ userId, rhythm: { morning: "08:00", evening: "20:00", weekly: null } },
);
const routines = await coachRoutines(userId);
await prisma.routine.update({ where: { id: routines[0]!.id }, data: { active: false } });
await prisma.routine.update({
where: { id: routines[1]!.id },
data: { crons: ["0 8 * * 1-5"] },
});
expect(await getCoachRhythm(prisma, userId)).toEqual({
morning: null,
evening: null,
weekly: null,
});
const rhythm = { morning: "08:00", evening: "20:00", weekly: null };
expect(await setCoachRhythm({ prisma, jobs }, { userId, rhythm })).toEqual(rhythm);
expect((await coachRoutines(userId)).map((routine) => routine.id)).toEqual(
routines.map((routine) => routine.id),
);
});
it("turning everything off removes the routines and never creates a main bot", async () => {
const userId = await newUser(null);
const jobs = fakeJobs();
const off = { morning: null, evening: null, weekly: null };
expect(await setCoachRhythm({ prisma, jobs }, { userId, rhythm: off })).toEqual(off);
expect(await prisma.bot.count({ where: { userId, kind: "main" } })).toBe(0);
expect(await getCoachRhythm(prisma, userId)).toEqual(off);
await setCoachRhythm(
{ prisma, jobs },
{ userId, rhythm: { morning: "09:15", evening: null, weekly: null } },
);
const [routine] = await coachRoutines(userId);
expect(routine).toMatchObject({ timezone: "UTC", crons: ["15 9 * * *"] });
expect(await setCoachRhythm({ prisma, jobs }, { userId, rhythm: off })).toEqual(off);
expect(await coachRoutines(userId)).toEqual([]);
expect(jobs.cancel).toHaveBeenCalledWith(routineJobKey(routine!.id));
});
it("moves the check-ins, and only them, when the profile time zone changes", async () => {
const userId = await newUser("Europe/Vienna");
const jobs = fakeJobs();
const deps = { prisma, jobs };
await setCoachRhythm(deps, {
userId,
rhythm: { morning: "07:30", evening: "21:00", weekly: { day: 0, time: "18:00" } },
});
const conversation = await ensureMainConversation(prisma, userId);
const evening = await prisma.routine.findFirstOrThrow({
where: { userId, coachKey: "evening" },
});
await prisma.routine.update({
where: { id: evening.id },
data: { active: false, nextRunAt: null },
});
const own = await prisma.routine.create({
data: {
botId: conversation.leadBotId,
userId,
spaceId: conversation.spaceId,
name: "Water the plants",
prompt: "Remind me to water the plants.",
crons: ["0 9 * * *"],
timezone: "Europe/Vienna",
active: true,
nextRunAt: nextCronDate("0 9 * * *", new Date(), "Europe/Vienna"),
},
});
const setZone = (time_zone: string) =>
updateUserProfileFromTool(
deps,
{
userId,
bot: { id: conversation.leadBotId, kind: "main" },
threadId: conversation.threadId,
sourceMessageId: null,
},
{ time_zone },
);
const before = await coachRoutines(userId);
// Saving the zone the check-ins already use changes nothing.
jobs.enqueue.mockClear();
expect(await setZone("Europe/Vienna")).toMatchObject({ ok: true });
expect(await coachRoutines(userId)).toEqual(before);
expect(jobs.enqueue).not.toHaveBeenCalled();
expect(await setZone("America/New_York")).toMatchObject({ ok: true });
const moved = await coachRoutines(userId);
expect(moved.map((routine) => [routine.coachKey, routine.timezone])).toEqual([
["evening", "America/New_York"],
["morning", "America/New_York"],
["weekly", "America/New_York"],
]);
const localTime = (date: Date) =>
new Intl.DateTimeFormat("en-GB", {
timeZone: "America/New_York",
weekday: "short",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).format(date);
const [pausedEvening, morning, weekly] = moved;
expect(localTime(morning!.nextRunAt!)).toMatch(/ 07:30$/);
expect(localTime(weekly!.nextRunAt!)).toBe("Sun 18:00");
for (const routine of [morning!, weekly!]) {
expect(routine.nextRunAt!.getTime()).toBeGreaterThan(Date.now());
expect(routine.nextRunAt).not.toEqual(before.find((old) => old.id === routine.id)?.nextRunAt);
}
expect(jobs.enqueue).toHaveBeenCalledTimes(2);
expect(jobs.enqueue).toHaveBeenCalledWith(routineWakeupJob(morning!.id, morning!.nextRunAt!));
expect(jobs.enqueue).toHaveBeenCalledWith(routineWakeupJob(weekly!.id, weekly!.nextRunAt!));
// A paused check-in moves but stays paused; the user's own routine keeps its zone.
expect(pausedEvening).toMatchObject({ active: false, nextRunAt: null });
expect(await prisma.routine.findUniqueOrThrow({ where: { id: own.id } })).toEqual(own);
expect(await getCoachRhythm(prisma, userId)).toEqual({
morning: "07:30",
evening: null,
weekly: { day: 0, time: "18:00" },
});
// Saving the new zone again changes nothing.
jobs.enqueue.mockClear();
await setZone("America/New_York");
expect(await coachRoutines(userId)).toEqual(moved);
expect(jobs.enqueue).not.toHaveBeenCalled();
});
it("creates no check-ins for a user without any when the time zone changes", async () => {
const userId = await newUser("Europe/Vienna");
const jobs = fakeJobs();
const main = await ensureMainConversation(prisma, userId);
await updateUserProfileFromTool(
{ prisma, jobs },
{
userId,
bot: { id: main.leadBotId, kind: "main" },
threadId: main.threadId,
sourceMessageId: null,
},
{ time_zone: "Asia/Tokyo" },
);
expect(await prisma.userProfile.findUnique({ where: { userId } })).toMatchObject({
timeZone: "Asia/Tokyo",
});
expect(await prisma.routine.count({ where: { userId } })).toBe(0);
expect(jobs.enqueue).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,177 @@
import type { JobPublisher } from "@rakazo/adapter-kit";
import { routineJobKey, routineWakeupJob } from "@rakazo/adapter-kit";
import type { CoachRhythm, CoachRoutineKey } from "@rakazo/contracts";
import {
COACH_RHYTHM_OFF,
COACH_ROUTINE_KEYS,
COACH_ROUTINE_NAMES,
clockTimeToMinutes,
minutesToClockTime,
} from "@rakazo/contracts";
import { nextCronDate, nextCronDateAcross } from "@rakazo/core";
import type { PrismaClient } from "@rakazo/db";
import { coachTimeZone, ensureMainConversation, findMainBotId } from "@rakazo/db";
export const COACH_KEYS = COACH_ROUTINE_KEYS;
export type CoachKey = CoachRoutineKey;
/**
* The main bot's check-ins. Names show in the main bot's routine list; prompts are its task when
* a check-in fires. Product text in English: the main bot answers in the user's language, and
* its instructions name the coach tools it has.
*/
export const COACH_ROUTINES: Record<CoachKey, { name: string; prompt: string }> = {
morning: {
name: COACH_ROUTINE_NAMES.morning,
prompt:
"Morning check-in. Review the user's active goals and habits, and today's calendar if a calendar app is connected. Write today's brief: two to five short lines on what matters most today, and save it. Then post one short, friendly message with the gist. Leave out what is not relevant today.",
},
evening: {
name: COACH_ROUTINE_NAMES.evening,
prompt:
"Evening check-in. Log as check-ins what the user already told you about today. Then ask in one or two sentences how the day went and about habits not yet checked off. Keep it light, and do not ask when everything is already logged.",
},
weekly: {
name: COACH_ROUTINE_NAMES.weekly,
prompt:
"Weekly review. Look at the past week's check-ins and the progress on the user's active goals. Post a short summary: what moved, what stalled, and one concrete suggestion for next week. Offer to adjust, complete or drop goals, and change them only when the user agrees.",
},
};
export interface CoachRhythmDeps {
prisma: PrismaClient;
jobs: Pick<JobPublisher, "enqueue" | "cancel">;
}
/** The cron for one check-in, or null when it is off. */
export function coachCron(key: CoachKey, rhythm: CoachRhythm): string | null {
const slot = key === "weekly" ? rhythm.weekly : rhythm[key];
if (!slot) return null;
const minutes = clockTimeToMinutes(typeof slot === "string" ? slot : slot.time);
return `${minutes % 60} ${Math.floor(minutes / 60)} * * ${typeof slot === "string" ? "*" : slot.day}`;
}
/** Reads a check-in back from its routine; a schedule edited into another shape reads as off. */
function rhythmSlot(key: CoachKey, crons: readonly string[]) {
const match =
crons.length === 1 ? /^(\d{1,2}) (\d{1,2}) \* \* (\*|[0-6])$/.exec(crons[0]!) : null;
if (!match || Number(match[1]) > 59 || Number(match[2]) > 23) return null;
const time = minutesToClockTime(Number(match[2]) * 60 + Number(match[1]));
if (key === "weekly") return match[3] === "*" ? null : { day: Number(match[3]), time };
return match[3] === "*" ? time : null;
}
function isCoachKey(value: string | null): value is CoachKey {
return (COACH_KEYS as readonly string[]).includes(value ?? "");
}
/** The user's check-in times, read from the main bot's active coach routines. */
export async function getCoachRhythm(prisma: PrismaClient, userId: string): Promise<CoachRhythm> {
const botId = await findMainBotId(prisma, userId);
if (!botId) return { ...COACH_RHYTHM_OFF };
const routines = await prisma.routine.findMany({
where: { botId, userId, active: true, coachKey: { not: null } },
select: { coachKey: true, crons: true },
});
const rhythm: CoachRhythm = { ...COACH_RHYTHM_OFF };
for (const routine of routines) {
if (!isCoachKey(routine.coachKey)) continue;
const slot = rhythmSlot(routine.coachKey, routine.crons);
if (routine.coachKey === "weekly") rhythm.weekly = slot as CoachRhythm["weekly"];
else rhythm[routine.coachKey] = slot as string | null;
}
return rhythm;
}
/**
* Makes the main bot's coach routines match `rhythm`: one routine per check-in that is on,
* waking in the main conversation in the user's time zone. Check-ins that are off are deleted.
* Calling it again with the same rhythm changes nothing.
*/
export async function setCoachRhythm(
deps: CoachRhythmDeps,
input: { userId: string; rhythm: CoachRhythm },
): Promise<CoachRhythm> {
const { prisma, jobs } = deps;
const { userId, rhythm } = input;
const anyOn = COACH_KEYS.some((key) => coachCron(key, rhythm) !== null);
const conversation = anyOn ? await ensureMainConversation(prisma, userId) : null;
const botId = conversation?.leadBotId ?? (await findMainBotId(prisma, userId));
if (!botId) return { ...COACH_RHYTHM_OFF };
const existing = await prisma.routine.findMany({
where: { botId, userId, coachKey: { not: null } },
});
const timezone = anyOn ? await coachTimeZone(prisma, userId) : "UTC";
for (const key of COACH_KEYS) {
const cron = coachCron(key, rhythm);
const current = existing.find((routine) => routine.coachKey === key);
if (!cron || !conversation) {
if (current) {
await prisma.routine.deleteMany({ where: { id: current.id } });
await jobs.cancel(routineJobKey(current.id));
}
continue;
}
const { name, prompt } = COACH_ROUTINES[key];
const unchanged =
current?.active &&
current.nextRunAt &&
current.crons.length === 1 &&
current.crons[0] === cron &&
current.timezone === timezone &&
current.threadId === conversation.threadId &&
current.spaceId === conversation.spaceId &&
current.name === name &&
current.prompt === prompt;
if (unchanged) continue;
const nextRunAt = nextCronDate(cron, new Date(), timezone);
const fields = {
spaceId: conversation.spaceId,
threadId: conversation.threadId,
name,
prompt,
crons: [cron],
timezone,
active: true,
notify: true,
nextRunAt,
};
const saved = await prisma.routine.upsert({
where: { botId_coachKey: { botId, coachKey: key } },
create: { ...fields, botId, userId, coachKey: key },
update: fields,
});
// The job reconciler re-arms an active routine whose wakeup could not be enqueued.
await jobs.enqueue(routineWakeupJob(saved.id, nextRunAt));
}
return getCoachRhythm(prisma, userId);
}
/**
* Moves the main bot's check-ins into the user's current profile time zone and re-arms each
* armed one for its next local run. Check-ins already in that zone and routines the user made
* stay as they are, so calling it after every time zone save is safe.
*/
export async function syncCoachTimeZone(deps: CoachRhythmDeps, userId: string): Promise<void> {
const { prisma, jobs } = deps;
const botId = await findMainBotId(prisma, userId);
if (!botId) return;
const timezone = await coachTimeZone(prisma, userId);
const moved = await prisma.routine.findMany({
where: { botId, userId, coachKey: { not: null }, timezone: { not: timezone } },
});
for (const routine of moved) {
const armed = routine.active && routine.nextRunAt !== null;
const nextRunAt = armed
? (nextCronDateAcross(routine.crons, new Date(), timezone) ?? routine.nextRunAt)
: routine.nextRunAt;
// Skipped when another save moved it meanwhile.
const { count } = await prisma.routine.updateMany({
where: { id: routine.id, timezone: routine.timezone },
data: { timezone, nextRunAt },
});
if (count === 1 && armed && nextRunAt) {
await jobs.enqueue(routineWakeupJob(routine.id, nextRunAt));
}
}
}

View File

@ -0,0 +1,102 @@
import type { PrismaClient } from "@rakazo/db";
import { describe, expect, it } from "vitest";
import { COACH_TOOL_NAMES, executeCoachTool } from "./coach-tools.js";
import { selectBuiltinToolsForRun } from "./executor-tools.js";
function toolNames(options: {
mainBot?: boolean;
/** A project's main bot rather than the user's personal agent. */
projectLead?: boolean;
messagingChannelRun?: boolean;
foreignAgentRun?: boolean;
trigger?: string;
}) {
return selectBuiltinToolsForRun({
graphicalToolsAllowed: false,
groupId: null,
trigger: options.trigger ?? "user",
semanticMemoryEnabled: false,
messagingChannelRun: options.messagingChannelRun ?? false,
foreignAgentRun: options.foreignAgentRun,
bot: {
kind: options.mainBot ? "main" : "specialist",
restrictBuiltinTools: false,
allowedBuiltinTools: [],
personalAgent: options.mainBot && !options.projectLead,
},
}).map((tool) => tool.name);
}
/**
* Only the space lookup that tells the personal agent from a project lead may reach the
* database: refusals and invalid input must not write anything.
*/
const lookupOnlyPrisma = new Proxy(
{
space: {
findUnique: async ({ where }: { where: { id: string } }) => ({
isDefault: where.id === "space-home",
}),
},
},
{
get(target, property) {
if (property === "space") return target.space;
throw new Error(`unexpected database access: ${String(property)}`);
},
},
) as unknown as PrismaClient;
const context = (kind: string, spaceId = "space-home") => ({
userId: "user-1",
spaceId,
bot: { id: "bot-1", kind },
});
describe("coach tools", () => {
it("are the personal main agent's alone, also in its check-in routines, and never for other people", () => {
for (const name of COACH_TOOL_NAMES) {
expect(toolNames({ mainBot: true })).toContain(name);
expect(toolNames({ mainBot: true, trigger: "routine" })).toContain(name);
expect(toolNames({ mainBot: false })).not.toContain(name);
expect(toolNames({ mainBot: true, projectLead: true })).not.toContain(name);
expect(toolNames({ mainBot: true, messagingChannelRun: true })).not.toContain(name);
expect(toolNames({ mainBot: true, foreignAgentRun: true })).not.toContain(name);
}
});
it("refuse specialists and project leads even when they call a tool anyway", async () => {
for (const name of COACH_TOOL_NAMES) {
for (const caller of [context("specialist"), context("main", "space-project")]) {
await expect(
executeCoachTool(lookupOnlyPrisma, caller, name, { title: "Run" }),
).resolves.toEqual({ error: "Only the user's main bot can coach the user." });
}
}
});
it("reject invalid or incomplete arguments before writing", async () => {
const main = context("main");
await expect(executeCoachTool(lookupOnlyPrisma, main, "update_goal", {})).resolves.toEqual({
error: "A new goal needs a title.",
});
await expect(
executeCoachTool(lookupOnlyPrisma, main, "update_goal", { title: "x", target_date: "soon" }),
).resolves.toEqual({ error: expect.stringContaining("target_date") });
await expect(
executeCoachTool(lookupOnlyPrisma, main, "update_habit", {
title: "Walk",
cadence: "hourly",
}),
).resolves.toEqual({ error: expect.stringContaining("cadence") });
await expect(
executeCoachTool(lookupOnlyPrisma, main, "log_check_in", { habit: "Walk", goal: "10k" }),
).resolves.toEqual({ error: "Log a habit and a goal in separate calls." });
await expect(
executeCoachTool(lookupOnlyPrisma, main, "log_check_in", { mood: 9 }),
).resolves.toEqual({ error: expect.stringContaining("mood") });
await expect(
executeCoachTool(lookupOnlyPrisma, main, "write_daily_brief", { text: " " }),
).resolves.toEqual({ error: expect.stringContaining("text") });
});
});

View File

@ -0,0 +1,357 @@
import type { ConnectorTool } from "@rakazo/adapter-kit";
import {
CHECK_IN_NOTE_MAX_LENGTH,
COACH_TITLE_MAX_LENGTH,
DAILY_BRIEF_MAX_LENGTH,
GOAL_DETAIL_MAX_LENGTH,
GOAL_STATUSES,
GoalStatusSchema,
HABIT_CADENCES,
HabitCadenceSchema,
LocalDateSchema,
} from "@rakazo/contracts";
import type { PrismaClient } from "@rakazo/db";
import {
CoachNotFoundError,
createGoal,
createHabit,
InvalidCoachInputError,
isPersonalMainBot,
logCheckIn,
updateGoal,
updateHabit,
upsertDailyBrief,
} from "@rakazo/db";
import { z } from "zod";
/** The main bot's coach tools, in the order they appear in its tool list. */
export const COACH_TOOL_NAMES = [
"update_goal",
"update_habit",
"log_check_in",
"write_daily_brief",
] as const;
export type CoachToolName = (typeof COACH_TOOL_NAMES)[number];
export function isCoachToolName(name: string): name is CoachToolName {
return (COACH_TOOL_NAMES as readonly string[]).includes(name);
}
const DATE_HINT = "The user's local date, YYYY-MM-DD.";
export const coachTools: ConnectorTool[] = [
{
name: "update_goal",
description:
"Track a goal the user committed to. Without id it creates one; with id it changes it, and status done or dropped closes it. Track only what the user stated or agreed to.",
inputSchema: {
type: "object",
properties: {
id: { type: "string", description: "Id or exact title of the goal to change." },
title: { type: "string", description: 'A short outcome, e.g. "Run a 10k in November".' },
detail: { type: "string", description: "Optional context, such as why it matters." },
status: { type: "string", enum: [...GOAL_STATUSES] },
target_date: { type: "string", description: `${DATE_HINT} An empty string clears it.` },
},
},
},
{
name: "update_habit",
description:
"Track a habit the user wants to keep. Without id it creates one; with id it changes it, and active false stops tracking it.",
inputSchema: {
type: "object",
properties: {
id: { type: "string", description: "Id or exact title of the habit to change." },
title: { type: "string", description: 'A short practice, e.g. "Walk 20 minutes".' },
cadence: { type: "string", enum: [...HABIT_CADENCES] },
goal: {
type: "string",
description: "Id or exact title of the goal it serves. An empty string unlinks it.",
},
active: { type: "boolean" },
},
},
},
{
name: "log_check_in",
description:
"Record what the user reported for a day: a habit kept or missed, progress on a goal, or, with neither, how the day went. Logging the same day again updates it.",
inputSchema: {
type: "object",
properties: {
habit: { type: "string", description: "Id or exact title of the habit." },
goal: { type: "string", description: "Id or exact title of the goal." },
date: { type: "string", description: `${DATE_HINT} Defaults to today.` },
done: { type: "boolean", description: "Whether the habit was kept." },
mood: { type: "integer", minimum: 1, maximum: 5 },
energy: { type: "integer", minimum: 1, maximum: 5 },
note: { type: "string", description: "The user's words, briefly." },
},
},
},
{
name: "write_daily_brief",
description:
"Save the user's brief for the day, shown at the top of their Today page. Plain text, two to five short lines. Writing again replaces it.",
inputSchema: {
type: "object",
properties: {
text: { type: "string" },
date: { type: "string", description: `${DATE_HINT} Defaults to today.` },
},
required: ["text"],
},
},
];
const Ref = z.string().trim().min(1).max(COACH_TITLE_MAX_LENGTH);
const ClearableDate = z.union([LocalDateSchema, z.literal("")]);
const UpdateGoalArgs = z.object({
id: Ref.optional(),
title: z.string().trim().min(1).max(COACH_TITLE_MAX_LENGTH).optional(),
detail: z.string().trim().max(GOAL_DETAIL_MAX_LENGTH).optional(),
status: GoalStatusSchema.optional(),
target_date: ClearableDate.optional(),
});
const UpdateHabitArgs = z.object({
id: Ref.optional(),
title: z.string().trim().min(1).max(COACH_TITLE_MAX_LENGTH).optional(),
cadence: HabitCadenceSchema.optional(),
goal: z.union([Ref, z.literal("")]).optional(),
active: z.boolean().optional(),
});
const Rating = z.number().int().min(1).max(5);
const LogCheckInArgs = z.object({
habit: Ref.optional(),
goal: Ref.optional(),
date: LocalDateSchema.optional(),
done: z.boolean().optional(),
mood: Rating.optional(),
energy: Rating.optional(),
note: z.string().trim().max(CHECK_IN_NOTE_MAX_LENGTH).optional(),
});
const WriteDailyBriefArgs = z.object({
text: z.string().trim().min(1).max(DAILY_BRIEF_MAX_LENGTH),
date: LocalDateSchema.optional(),
});
export interface CoachToolContext {
userId: string;
/** The space the run happens in. */
spaceId: string;
bot: { id: string; kind: string };
}
type ToolResult = Record<string, unknown>;
function invalid(name: string, error: z.ZodError): ToolResult {
const issues = error.issues
.map((issue) => `${issue.path.join(".") || "arguments"}: ${issue.message}`)
.join("; ");
return { error: `Invalid ${name} arguments: ${issues}` };
}
/** An id first, then an exact title (any case) among the user's items. */
async function resolveRef(
find: (where: object) => Promise<Array<{ id: string }>>,
ref: string,
what: "goal" | "habit",
): Promise<string | ToolResult> {
const byId = await find({ id: ref });
if (byId[0]) return byId[0].id;
const byTitle = await find({ title: { equals: ref, mode: "insensitive" } });
if (byTitle.length === 1) return byTitle[0]!.id;
return byTitle.length > 1
? { error: `Several ${what}s are titled "${ref}". Use the id.` }
: { error: `No ${what} "${ref}".` };
}
function goalRef(prisma: PrismaClient, userId: string, ref: string) {
return resolveRef(
(where) =>
prisma.userGoal.findMany({
where: { userId, ...where },
orderBy: [{ status: "asc" }, { createdAt: "desc" }],
select: { id: true },
take: 2,
}),
ref,
"goal",
);
}
function habitRef(prisma: PrismaClient, userId: string, ref: string) {
return resolveRef(
(where) =>
prisma.habit.findMany({
where: { userId, ...where },
orderBy: [{ active: "desc" }, { createdAt: "desc" }],
select: { id: true },
take: 2,
}),
ref,
"habit",
);
}
async function updateGoalFromTool(
prisma: PrismaClient,
context: CoachToolContext,
rawArgs: Record<string, unknown>,
): Promise<ToolResult> {
const parsed = UpdateGoalArgs.safeParse(rawArgs);
if (!parsed.success) return invalid("update_goal", parsed.error);
const args = parsed.data;
const targetDate = args.target_date === undefined ? undefined : args.target_date || null;
if (!args.id) {
if (!args.title) return { error: "A new goal needs a title." };
const goal = await createGoal(prisma, context.userId, {
title: args.title,
detail: args.detail,
targetDate,
source: "chat",
createdByBotId: context.bot.id,
});
return { ok: true, goal: { id: goal.id, title: goal.title, status: goal.status } };
}
const goalId = await goalRef(prisma, context.userId, args.id);
if (typeof goalId !== "string") return goalId;
const goal = await updateGoal(prisma, context.userId, goalId, {
title: args.title,
detail: args.detail,
status: args.status,
targetDate,
});
return {
ok: true,
goal: { id: goal.id, title: goal.title, status: goal.status, targetDate: goal.targetDate },
};
}
async function updateHabitFromTool(
prisma: PrismaClient,
context: CoachToolContext,
rawArgs: Record<string, unknown>,
): Promise<ToolResult> {
const parsed = UpdateHabitArgs.safeParse(rawArgs);
if (!parsed.success) return invalid("update_habit", parsed.error);
const args = parsed.data;
let goalId: string | null | undefined;
if (args.goal) {
const resolved = await goalRef(prisma, context.userId, args.goal);
if (typeof resolved !== "string") return resolved;
goalId = resolved;
} else if (args.goal === "") {
goalId = null;
}
if (!args.id) {
if (!args.title) return { error: "A new habit needs a title." };
const habit = await createHabit(prisma, context.userId, {
title: args.title,
cadence: args.cadence,
goalId,
source: "chat",
createdByBotId: context.bot.id,
});
return { ok: true, habit: { id: habit.id, title: habit.title, cadence: habit.cadence } };
}
const habitId = await habitRef(prisma, context.userId, args.id);
if (typeof habitId !== "string") return habitId;
const habit = await updateHabit(prisma, context.userId, habitId, {
title: args.title,
cadence: args.cadence,
goalId,
active: args.active,
});
return {
ok: true,
habit: { id: habit.id, title: habit.title, cadence: habit.cadence, active: habit.active },
};
}
async function logCheckInFromTool(
prisma: PrismaClient,
context: CoachToolContext,
rawArgs: Record<string, unknown>,
): Promise<ToolResult> {
const parsed = LogCheckInArgs.safeParse(rawArgs);
if (!parsed.success) return invalid("log_check_in", parsed.error);
const { habit, goal, ...fields } = parsed.data;
if (habit && goal) return { error: "Log a habit and a goal in separate calls." };
const habitId = habit ? await habitRef(prisma, context.userId, habit) : undefined;
if (habitId !== undefined && typeof habitId !== "string") return habitId;
const goalId = goal ? await goalRef(prisma, context.userId, goal) : undefined;
if (goalId !== undefined && typeof goalId !== "string") return goalId;
const checkIn = await logCheckIn(prisma, context.userId, {
kind: habitId ? "habit" : goalId ? "goal" : "daily",
habitId,
goalId,
...fields,
createdByBotId: context.bot.id,
});
return {
ok: true,
checkIn: {
kind: checkIn.kind,
date: checkIn.date,
done: checkIn.done,
mood: checkIn.mood,
energy: checkIn.energy,
},
};
}
async function writeDailyBriefFromTool(
prisma: PrismaClient,
context: CoachToolContext,
rawArgs: Record<string, unknown>,
): Promise<ToolResult> {
const parsed = WriteDailyBriefArgs.safeParse(rawArgs);
if (!parsed.success) return invalid("write_daily_brief", parsed.error);
const brief = await upsertDailyBrief(prisma, context.userId, {
text: parsed.data.text,
date: parsed.data.date,
botId: context.bot.id,
});
return { ok: true, date: brief.date };
}
const HANDLERS: Record<
CoachToolName,
(
prisma: PrismaClient,
context: CoachToolContext,
rawArgs: Record<string, unknown>,
) => Promise<ToolResult>
> = {
update_goal: updateGoalFromTool,
update_habit: updateHabitFromTool,
log_check_in: logCheckInFromTool,
write_daily_brief: writeDailyBriefFromTool,
};
/** Runs one coach tool for the user's personal main agent; every other bot is refused. */
export async function executeCoachTool(
prisma: PrismaClient,
context: CoachToolContext,
name: CoachToolName,
rawArgs: Record<string, unknown>,
): Promise<ToolResult> {
if (!(await isPersonalMainBot(prisma, { kind: context.bot.kind, spaceId: context.spaceId }))) {
return { error: "Only the user's main bot can coach the user." };
}
try {
return await HANDLERS[name](prisma, context, rawArgs);
} catch (error) {
if (error instanceof CoachNotFoundError || error instanceof InvalidCoachInputError) {
return { error: error.message };
}
throw error;
}
}

View File

@ -1,6 +1,6 @@
import type { PrismaClient } from "@rakazo/db";
import { describe, expect, it, vi } from "vitest";
import { formatCurrentTimeInstruction, loadUserTimeZone } from "./current-time.js";
import { formatCurrentTimeInstruction, formatLocalDate, loadUserTimeZone } from "./current-time.js";
describe("formatCurrentTimeInstruction", () => {
it("anchors the present moment in UTC with the weekday and no sub-second noise", () => {
@ -93,3 +93,12 @@ describe("loadUserTimeZone", () => {
).resolves.toBeUndefined();
});
});
describe("formatLocalDate", () => {
it("is the user's local date, or the UTC date without a known zone", () => {
const now = new Date("2026-01-01T03:00:00Z");
expect(formatLocalDate("America/Los_Angeles", now)).toBe("2025-12-31");
expect(formatLocalDate("Not/AZone", now)).toBe("2026-01-01");
expect(formatLocalDate(null, now)).toBe("2026-01-01");
});
});

View File

@ -27,6 +27,14 @@ export function formatCurrentTimeInstruction(
].join(" ");
}
/** Today's date as YYYY-MM-DD in the user's time zone, or in UTC when none is known. */
export function formatLocalDate(
timeZone: string | null | undefined,
now: Date = new Date(),
): string {
return zonedClock(now, knownTimeZone(timeZone) ?? "UTC").local.slice(0, 10);
}
/** The user's time zone from their profile when this runtime knows it. */
export async function loadUserTimeZone(
prisma: Pick<PrismaClient, "userProfile">,

View File

@ -112,6 +112,9 @@ async function runBot(policy: BotPolicy, calls: Call[] = [], models: Record<stri
user: { findUnique: vi.fn(async () => ({ name: "Ada" })) },
userProfile: { findUnique: vi.fn(async () => null) },
userFact: { findMany: vi.fn(async () => []) },
// The main bot's goals and habits.
userGoal: { findMany: vi.fn(async () => []) },
habit: { findMany: vi.fn(async () => []) },
...models,
};
let request: AgentRunRequest | undefined;

View File

@ -319,6 +319,14 @@ describe("mutating tool effect idempotency keys", () => {
]);
await f.run();
expect(f.memoryCommit).toHaveBeenCalledOnce();
// remember appends, so only the effect record keeps a retry from adding the note twice.
expect(f.memoryCommit).toHaveBeenCalledWith(
expect.objectContaining({
mode: "append",
content: expect.stringMatching(/^- \d{4}-\d{2}-\d{2}: durable fact$/),
}),
expect.anything(),
);
expect(f.effects).toHaveLength(1);
f.setCalls([

View File

@ -1,5 +1,6 @@
import type { AgentRunRequest } from "@rakazo/adapter-kit";
import type { MessageBlock } from "@rakazo/contracts";
import { addDays, localDateIn } from "@rakazo/core";
import { describe, expect, it, vi } from "vitest";
import type * as ComputerLifecycleModule from "./computer-lifecycle.js";
import { createRunExecutor } from "./executor.js";
@ -20,6 +21,10 @@ interface FixtureOptions {
sender?: { name: string } | null;
profile?: Record<string, unknown> | null;
facts?: Array<{ category: string; value: string; confidence: number }>;
goals?: Array<Record<string, unknown>>;
habits?: Array<Record<string, unknown>>;
doneCheckIns?: Array<{ habitId: string; date: Date }>;
onboarding?: { completedAt: Date | null } | null;
peers?: Array<{
id: string;
name: string;
@ -117,7 +122,12 @@ async function runOnce(options: FixtureOptions = {}) {
agentSecret: { findMany: vi.fn(async () => []) },
agentSkill: { findMany: vi.fn(async () => []) },
scratchpadItem: { findMany: vi.fn(async () => []) },
user: { findUnique: vi.fn(async () => ({ name: "Ada Example" })) },
user: {
findUnique: vi.fn(async () => ({
name: "Ada Example",
onboarding: options.onboarding ?? null,
})),
},
userProfile: {
findUnique: vi.fn(async () =>
options.profile === undefined
@ -136,6 +146,9 @@ async function runOnce(options: FixtureOptions = {}) {
async () => options.facts ?? [{ category: "work", value: "Owns a bakery", confidence: 1 }],
),
},
userGoal: { findMany: vi.fn(async () => options.goals ?? []) },
habit: { findMany: vi.fn(async () => options.habits ?? []) },
checkIn: { findMany: vi.fn(async () => options.doneCheckIns ?? []) },
actionApprovalRule: { findMany: vi.fn(async () => []) },
actionAutoReviewPreference: { findUnique: vi.fn(async () => ({ enabled: false })) },
externalEffect: {
@ -302,6 +315,11 @@ describe("run instructions", () => {
expect(request.tools.map((tool) => tool.name)).not.toContain("update_user_profile");
expect(request.instructions).not.toContain("update_user_profile");
for (const name of ["update_goal", "update_habit", "log_check_in", "write_daily_brief"]) {
expect(request.tools.map((tool) => tool.name)).not.toContain(name);
expect(request.instructions).not.toContain(name);
}
expect(request.instructions).not.toContain("Active goals");
});
it("gives runs woken by the user's own bot the full user block", async () => {
@ -343,6 +361,84 @@ describe("run instructions", () => {
expect(request.instructions).not.toContain("save lasting facts they share with remember");
});
it("gives the main bot the user's goals and habits and names its coach tools", async () => {
const today = localDateIn("Europe/Berlin");
const row = {
source: "chat",
createdByBotId: null,
createdAt: new Date(),
updatedAt: new Date(),
};
const coach = {
goals: [
{
...row,
id: "goal-1",
title: "Ship the beta",
detail: "",
status: "active",
targetDate: new Date(`${addDays(today, 30)}T00:00:00Z`),
spaceId: null,
completedAt: null,
},
],
habits: [
{ ...row, id: "habit-1", title: "Walk", cadence: "daily", goalId: null, active: true },
{ ...row, id: "habit-2", title: "Long run", cadence: "weekly", goalId: null, active: true },
],
doneCheckIns: [today, addDays(today, -1)].map((date) => ({
habitId: "habit-1",
date: new Date(`${date}T00:00:00Z`),
})),
};
const main = await runOnce({
bot: { kind: "main", name: "Chief", title: "Chief of Staff", instructions: "" },
...coach,
});
const names = main.request.tools.map((tool) => tool.name);
expect(names).toEqual(
expect.arrayContaining(["update_goal", "update_habit", "log_check_in", "write_daily_brief"]),
);
expect(main.request.instructions).toContain("You are also their coach");
expect(main.request.instructions).toContain("save today's brief with write_daily_brief");
expect(main.request.instructions).toContain(
`Active goals:\n- Ship the beta, due ${addDays(today, 30)} (id: goal-1)\nHabits:\n- Walk: daily, done today, streak 2 (id: habit-1)\n- Long run: weekly, not done this week, streak 0 (id: habit-2)\n</user>`,
);
const specialist = await runOnce(coach);
expect(specialist.request.instructions).not.toContain("Active goals");
expect(specialist.request.instructions).not.toContain("You are also their coach");
expect(specialist.request.tools.map((tool) => tool.name)).not.toContain("update_goal");
// A project's main bot leads its project; coaching stays with the personal agent.
const lead = await runOnce({
bot: { kind: "main", name: "Chief", title: "Project lead", instructions: "" },
space: { name: "Launch", isDefault: false },
...coach,
});
expect(lead.request.instructions).not.toContain("Active goals");
expect(lead.request.instructions).not.toContain("You are also their coach");
expect(lead.request.tools.map((tool) => tool.name)).not.toContain("update_goal");
expect(lead.request.tools.map((tool) => tool.name)).not.toContain("notify_user");
});
it("leaves open onboarding questions to their cards, for the main bot only", async () => {
const main = { kind: "main", name: "Chief", title: "Chief of Staff", instructions: "" };
const open = { completedAt: null };
const note = "still answering onboarding cards";
expect((await runOnce({ bot: main, onboarding: open })).request.instructions).toContain(note);
expect(
(await runOnce({ bot: main, onboarding: { completedAt: new Date() } })).request.instructions,
).not.toContain(note);
expect((await runOnce({ bot: main })).request.instructions).not.toContain(note);
expect((await runOnce({ onboarding: open })).request.instructions).not.toContain(note);
const project = { name: "Launch", isDefault: false };
expect(
(await runOnce({ bot: main, space: project, onboarding: open })).request.instructions,
).not.toContain(note);
});
it("describes a project's main bot as the lead of that project", async () => {
const { request } = await runOnce({
bot: { kind: "main", name: "Chief", title: "Project lead", instructions: "" },

View File

@ -18,6 +18,7 @@ const user: UserContext = {
{ category: "work", value: "Owns a bakery", confidence: 1 },
{ category: "preferences", value: "Prefers morning meetings", confidence: 0.8 },
],
onboardingOpen: false,
};
describe("formatBotIdentity", () => {
@ -96,6 +97,7 @@ describe("formatUserContext", () => {
communicationStyle: "",
summary: " ",
facts: [{ category: "work", value: " ", confidence: 1 }],
onboardingOpen: false,
});
expect(block).toContain("<user>\nName: Ada\n</user>");
expect(block).not.toContain("Time zone");
@ -112,6 +114,7 @@ describe("formatUserContext", () => {
communicationStyle: "",
summary: "",
facts: [],
onboardingOpen: false,
}),
).toBeUndefined();
});

View File

@ -1,9 +1,12 @@
import { escapePromptData, knownTimeZone } from "@rakazo/core";
import type { UserContext } from "@rakazo/db";
import type { CoachSummary, UserContext } from "@rakazo/db";
import { COACH_TOOL_NAMES } from "./coach-tools.js";
/** Per-field caps so user-written profile text cannot crowd out the rest of the prompt. */
const USER_FIELD_MAX_CHARS = 500;
const USER_SUMMARY_MAX_CHARS = 2_000;
/** Goals and habits listed in the user block; the rest are counted. */
const COACH_PROMPT_ITEM_LIMIT = 20;
export interface BotIdentity {
name: string;
@ -53,11 +56,37 @@ export function formatMainAgentRole(
: toolNames.has("remember")
? "Keep what you know about the user current: save lasting facts they share with remember."
: "Keep what you know about the user current.",
...formatCoachGuidance(toolNames),
]
.filter(Boolean)
.join(" ");
}
/** How the main bot coaches, naming only the coach tools this run has. */
function formatCoachGuidance(toolNames: ReadonlySet<string>): Array<string | undefined> {
if (!COACH_TOOL_NAMES.some((name) => toolNames.has(name))) return [];
return [
"You are also their coach: help them reach the goals and keep the habits they chose. Be brief and encouraging, notice progress, and never nag.",
toolNames.has("update_goal")
? "Record goals they commit to with update_goal, and close them when they are done or dropped."
: undefined,
toolNames.has("update_habit") ? "Track habits they want to keep with update_habit." : undefined,
toolNames.has("log_check_in")
? "When they tell you how a habit, a goal or their day went, record it with log_check_in."
: undefined,
toolNames.has("write_daily_brief")
? "At the morning check-in, save today's brief with write_daily_brief."
: undefined,
];
}
/**
* While the main bot's onboarding cards are still open, it leaves their questions to them, so
* a reply does not ask what the next card asks.
*/
export const ONBOARDING_OPEN_INSTRUCTION =
"The user is still answering onboarding cards in the main conversation: what to focus on, a goal, when to check in, quiet hours and notifications. Do not ask these questions yourself; answer what the user writes.";
/** One line of escaped user text, clipped to `max` characters. */
function userText(value: string | null | undefined, max = USER_FIELD_MAX_CHARS): string {
const text = (value ?? "").replace(/\s+/g, " ").trim();
@ -69,12 +98,16 @@ function userText(value: string | null | undefined, max = USER_FIELD_MAX_CHARS):
* by the user or inferred from conversations, so every value is escaped and the block says
* it never carries instructions. Empty fields are left out, and with nothing to say the block
* is omitted. A run whose replies other people read (a shared messaging channel, or another
* owner's agent over an agent connection) gets only the basics: the summary and facts stay
* with the user.
* owner's agent over an agent connection) gets only the basics: the summary, facts, goals and
* habits stay with the user.
*/
export function formatUserContext(
user: UserContext,
options: { basicsOnly?: boolean } = {},
options: {
basicsOnly?: boolean;
/** The main bot's own runs: the user's active goals and habits. */
coach?: Pick<CoachSummary, "goals" | "habits">;
} = {},
): string | undefined {
const field = (label: string, value: string | null | undefined, max?: number) => {
const text = userText(value, max);
@ -94,6 +127,7 @@ export function formatUserContext(
field("Communication style", user.communicationStyle),
options.basicsOnly ? undefined : field("Summary", user.summary, USER_SUMMARY_MAX_CHARS),
...(facts.length > 0 ? ["Facts:", ...facts] : []),
...(options.coach && !options.basicsOnly ? coachLines(options.coach) : []),
].filter((line): line is string => Boolean(line));
if (lines.length === 0) return undefined;
return [
@ -104,6 +138,30 @@ export function formatUserContext(
].join("\n");
}
function listed<T>(label: string, items: readonly T[], line: (item: T) => string): string[] {
if (items.length === 0) return [];
const shown = items.slice(0, COACH_PROMPT_ITEM_LIMIT).map(line);
const more = items.length - shown.length;
return [`${label}:`, ...shown, ...(more > 0 ? [`- and ${more} more`] : [])];
}
/** Active goals and habits with ids, so coach tools can name them exactly. */
function coachLines(coach: Pick<CoachSummary, "goals" | "habits">): string[] {
return [
...listed(
"Active goals",
coach.goals,
(goal) =>
`- ${userText(goal.title)}${goal.targetDate ? `, due ${goal.targetDate}` : ""} (id: ${goal.id})`,
),
...listed("Habits", coach.habits, (habit) => {
const period = habit.cadence === "weekly" ? "this week" : "today";
const done = habit.doneToday ? `done ${period}` : `not done ${period}`;
return `- ${userText(habit.title)}: ${habit.cadence}, ${done}, streak ${habit.streak} (id: ${habit.id})`;
}),
];
}
/**
* Background a run_subagent helper shares with the bot that started it: who it works for,
* the present moment, and the user block.

View File

@ -4,6 +4,7 @@ import {
builtinToolNamesForBot,
filterBuiltinToolsForBot,
MAIN_ONLY_BUILTIN_TOOLS,
PERSONAL_AGENT_TOOLS,
selectBuiltinToolsForRun,
unknownBuiltinToolNames,
} from "./executor-tools.js";
@ -64,7 +65,15 @@ describe("builtin tools per bot", () => {
});
it("lists main-only tools for the main bot alone", () => {
expect([...MAIN_ONLY_BUILTIN_TOOLS].sort()).toEqual(["delegate_task", "update_user_profile"]);
expect([...MAIN_ONLY_BUILTIN_TOOLS].sort()).toEqual([
"delegate_task",
"log_check_in",
"notify_user",
"update_goal",
"update_habit",
"update_user_profile",
"write_daily_brief",
]);
const specialist = builtinToolNamesForBot({ kind: "specialist", chatLinked: false });
const main = builtinToolNamesForBot({ kind: "main", chatLinked: false });
for (const name of MAIN_ONLY_BUILTIN_TOOLS) {
@ -73,6 +82,17 @@ describe("builtin tools per bot", () => {
}
});
it("keeps the personal agent's tools from a project's main bot", () => {
const main = { kind: "main", restrictBuiltinTools: false, allowedBuiltinTools: [] };
const personal = filterBuiltinToolsForBot(builtinAgentTools, { ...main, personalAgent: true });
const lead = filterBuiltinToolsForBot(builtinAgentTools, main).map((tool) => tool.name);
expect(personal.map((tool) => tool.name)).toEqual(
expect.arrayContaining([...PERSONAL_AGENT_TOOLS]),
);
for (const name of PERSONAL_AGENT_TOOLS) expect(lead).not.toContain(name);
expect(lead).toEqual(expect.arrayContaining(["delegate_task", "update_user_profile"]));
});
it("lists agent connection tools only for chat-linked bots", () => {
expect(builtinToolNamesForBot({ kind: "specialist", chatLinked: false })).toEqual(
specialistToolNames,

View File

@ -1,6 +1,7 @@
import type { ConnectorTool } from "@rakazo/adapter-kit";
import { agentConnectionTools, builtinAgentTools } from "./builtin-tools.js";
import { selectCloudAgentTools } from "./cloud-agent-tools-select.js";
import { COACH_TOOL_NAMES } from "./coach-tools.js";
import { selectMemoryTools } from "./memory-tools.js";
import { filterImageReturningComputerTools } from "./model-vision.js";
import {
@ -16,10 +17,27 @@ export const PAGE_BROWSER_TOOL_NAMES = new Set([
"browser_act",
]);
/**
* Main-only tools that stay with the user's personal main agent: a project's main bot leads its
* project but does not coach or nudge the user.
*/
export const PERSONAL_AGENT_TOOLS: ReadonlySet<string> = new Set<string>([
"notify_user",
...COACH_TOOL_NAMES,
]);
/** Builtin tools only the user's main bot may use; every other bot's runs never see them. */
export const MAIN_ONLY_BUILTIN_TOOLS: ReadonlySet<string> = new Set<string>([
"delegate_task",
"update_user_profile",
...PERSONAL_AGENT_TOOLS,
]);
/** Tools that write what the user's bots know about the user, so only the user may drive them. */
const USER_MODEL_TOOLS: ReadonlySet<string> = new Set<string>([
"update_user_profile",
"notify_user",
...COACH_TOOL_NAMES,
]);
const BUILTIN_TOOL_NAMES = [...builtinAgentTools, ...agentConnectionTools].map((tool) => tool.name);
@ -29,6 +47,8 @@ export interface BuiltinToolPolicy {
kind: string;
restrictBuiltinTools: boolean;
allowedBuiltinTools: readonly string[];
/** The main bot of the user's home space; only it gets the personal agent's tools. */
personalAgent?: boolean;
}
/**
@ -55,11 +75,13 @@ export function filterBuiltinToolsForBot(
bot: BuiltinToolPolicy,
): ConnectorTool[] {
const main = bot.kind === "main";
const personal = main && bot.personalAgent === true;
const allowed = new Set(bot.allowedBuiltinTools);
return tools
.filter(
(tool) =>
(main || !MAIN_ONLY_BUILTIN_TOOLS.has(tool.name)) &&
(personal || !PERSONAL_AGENT_TOOLS.has(tool.name)) &&
(!bot.restrictBuiltinTools || allowed.has(tool.name)),
)
.map((tool) => (main && tool.name === "schedule_create" ? withScheduleTargetBot(tool) : tool));
@ -105,15 +127,10 @@ export function selectBuiltinToolsForRun(options: {
Boolean(options.cloudAgentEnabled),
).filter(
(tool) =>
!(options.foreignAgentRun && tool.name === "update_user_profile") &&
!(options.foreignAgentRun && USER_MODEL_TOOLS.has(tool.name)) &&
(!options.messagingChannelRun ||
(![
"remember",
"save_memory",
"recall_memory",
"forget_memory",
"update_user_profile",
].includes(tool.name) &&
(!["remember", "save_memory", "recall_memory", "forget_memory"].includes(tool.name) &&
!USER_MODEL_TOOLS.has(tool.name) &&
!tool.name.startsWith("scratchpad_"))),
);
return filterBuiltinToolsForBot(

View File

@ -86,6 +86,7 @@ import {
stableJsonValue,
toolEffectIdempotencyKey,
} from "@rakazo/core/node/approval-effect-key";
import type { McpServer, Prisma, PrismaClient, ThreadEvents } from "@rakazo/db";
import {
appendEventInTransaction,
createSpaceForMember,
@ -98,14 +99,11 @@ import {
InvalidSpaceNameError,
isTooManyDatabaseConnections,
listSpaceUserBotIds,
loadCoachSummary,
loadRunHistoryMessages,
loadUserContext,
type McpServer,
type Prisma,
type PrismaClient,
parseComputerMode,
SpaceLimitError,
type ThreadEvents,
} from "@rakazo/db";
import { getLogger } from "@rakazo/logging";
import {
@ -178,6 +176,7 @@ import { type CloudAgentConnection, cloudAgentsEnabled } from "./cloud-agent-fac
import { executeCloudAgentTool } from "./cloud-agent-service.js";
import { validCloudAgentArgs } from "./cloud-agent-tools.js";
import { selectCloudAgentTools } from "./cloud-agent-tools-select.js";
import { executeCoachTool, isCoachToolName } from "./coach-tools.js";
import {
collectLogIds,
mergeConnectedPlugins,
@ -215,7 +214,7 @@ import { observationToolResult, parseComputerActions } from "./computer-tools.js
import { checkpointRunComputerWorkspace } from "./computer-workspace.js";
import { sanitizeConnectorError } from "./connector-safety.js";
import { createProject } from "./create-project.js";
import { formatCurrentTimeInstruction } from "./current-time.js";
import { formatCurrentTimeInstruction, formatLocalDate } from "./current-time.js";
import {
delegateTask,
loadDelegatedTaskInstruction,
@ -256,6 +255,7 @@ import {
formatMainAgentRole,
formatSubagentContext,
formatUserContext,
ONBOARDING_OPEN_INSTRUCTION,
} from "./executor-prompt.js";
import {
isProtectedComputerLifecycleCommandReadingScripts,
@ -306,6 +306,7 @@ import {
modelAcceptsImageInput,
modelIdSupportsImages,
} from "./model-vision.js";
import { notifyUserFromTool } from "./notify-user-tool.js";
import { toOAuthCredential } from "./pi-credentials.js";
import {
parseModelSecret,
@ -327,6 +328,7 @@ import {
tryRecoverProseHandoff,
workshopFallbackAfterBlockedHandoff,
} from "./prose-handoff-recovery.js";
import { datedMemoryNote, rememberFromTool } from "./remember.js";
import type { RemoteTransportDependencies } from "./remote-mcp.js";
import { loadReplyContext, messageToAgentHistoryText } from "./reply-context.js";
import {
@ -1478,6 +1480,16 @@ export function createRunExecutor(deps: ExecutorDeps) {
select: { id: true },
}))
: false;
// A main bot outside the home space leads that project; the home one is the user's
// personal agent, the only one that coaches and nudges them.
const mainAgentSpace =
bot.kind === "main"
? await deps.prisma.space.findUnique({
where: { id: run.spaceId },
select: { name: true, isDefault: true },
})
: null;
const personalAgent = mainAgentSpace?.isDefault === true;
const builtins = selectBuiltinToolsForRun({
graphicalToolsAllowed,
pageBrowserAllowed,
@ -1488,7 +1500,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
messagingChannelRun,
agentConnectionsEnabled: hasMessagingIdentity,
foreignAgentRun,
bot,
bot: { ...bot, personalAgent },
});
const exposedConnectorTools = discovered.filter(
(tool) => !builtinAgentTools.some((builtin) => builtin.name === tool.name),
@ -2582,23 +2594,20 @@ export function createRunExecutor(deps: ExecutorDeps) {
}, finish);
}
if (name === "remember") {
await deps.memory.commit(
{
scope: "bot",
return finish(
await rememberFromTool(deps.memory, context, {
botId: bot.id,
path: String(args.path ?? "MEMORY.md"),
content: String(args.content ?? ""),
sourceRunId: runId,
sourceThreadId: thread.id,
},
context,
runId,
threadId: thread.id,
date: formatLocalDate(userContext.timeZone),
args,
}),
);
return finish({ ok: true });
}
if (name === "update_user_profile") {
return finish(
await updateUserProfileFromTool(
deps.prisma,
deps,
{
userId: run.userId,
bot,
@ -2609,6 +2618,25 @@ export function createRunExecutor(deps: ExecutorDeps) {
),
);
}
if (name === "notify_user") {
return finish(
await notifyUserFromTool(
deps,
{ userId: run.userId, spaceId: run.spaceId, runId, bot, signal: context.signal },
args,
),
);
}
if (isCoachToolName(name)) {
return finish(
await executeCoachTool(
deps.prisma,
{ userId: run.userId, spaceId: run.spaceId, bot },
name,
args,
),
);
}
if (name === "web_search") {
return finish(await webSearchFromTool(web, context, args));
}
@ -3730,19 +3758,16 @@ export function createRunExecutor(deps: ExecutorDeps) {
)
: undefined;
const timeInstruction = formatCurrentTimeInstruction(userContext.timeZone);
// A main bot outside the home space leads that project.
const mainAgentSpace =
bot.kind === "main"
? await deps.prisma.space.findUnique({
where: { id: run.spaceId },
select: { name: true, isDefault: true },
})
: null;
const mainAgentProject =
mainAgentSpace && !mainAgentSpace.isDefault ? mainAgentSpace.name : undefined;
// Replies to another owner's agent, like a shared channel, only get the basics.
const basicsOnly = messagingChannelRun || foreignAgentRun;
const userInstruction = formatUserContext(userContext, {
basicsOnly: messagingChannelRun || foreignAgentRun,
basicsOnly,
coach:
personalAgent && !basicsOnly
? await loadCoachSummary(deps.prisma, run.userId)
: undefined,
});
const userBlock = userInstruction ? redactSecrets(userInstruction, runSecrets) : undefined;
@ -3777,6 +3802,11 @@ export function createRunExecutor(deps: ExecutorDeps) {
: undefined,
timeInstruction,
userBlock,
personalAgent &&
userContext.onboardingOpen &&
!(messagingChannelRun || foreignAgentRun)
? ONBOARDING_OPEN_INSTRUCTION
: undefined,
groupContext?.instructions,
inboundHandoffInstruction,
delegatedTaskInstruction,
@ -4294,7 +4324,11 @@ export function createRunExecutor(deps: ExecutorDeps) {
scope: mem.scope,
botId: mem.scope === "bot" ? bot.id : undefined,
path: mem.path,
content: mem.content,
content:
mem.mode === "append"
? datedMemoryNote(mem.content, formatLocalDate(userContext.timeZone))
: mem.content,
mode: mem.mode,
sourceRunId: runId,
sourceThreadId: thread.id,
},

View File

@ -1,15 +1,16 @@
import { chmod, mkdtemp, readFile, rm, stat, symlink, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readdir, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { PrismaClient } from "@rakazo/db";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
deletePushToken,
ExpoPushProvider,
ExpoPushSender,
expoPushErrorMessage,
loadPushToken,
importLegacyPushTokens,
legacyPushTokenDir,
MAX_EXPO_PUSH_RESPONSE_BYTES,
savePushToken,
} from "./expo-push.js";
import type { PushPayload } from "./push-notifications.js";
const dirs: string[] = [];
@ -18,13 +19,19 @@ afterEach(async () => {
await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
const notifyContext = {
operationId: "n",
traceId: "n",
spaceId: "w",
userId: "user-1",
signal: new AbortController().signal,
};
const device = { endpoint: "ExponentPushToken[test]", p256dh: null, auth: null };
function payload(overrides: Partial<PushPayload> = {}): PushPayload {
return {
kind: "completion",
title: "done",
body: "ok",
botId: "b",
threadId: "t",
url: "/app/b",
...overrides,
};
}
function jsonResponse(body: unknown, status = 200) {
return Response.json(body, { status });
@ -49,75 +56,24 @@ describe("expo push tickets", () => {
});
});
describe("expo push", () => {
it("keeps refreshed push tokens owner-only", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-"));
dirs.push(dataDir);
const tokenFile = path.join(dataDir, "push-tokens", "user-1.txt");
await savePushToken(dataDir, "user-1", "ExponentPushToken[old]");
await chmod(tokenFile, 0o644);
await savePushToken(dataDir, "user-1", "ExponentPushToken[new]");
expect((await stat(tokenFile)).mode & 0o777).toBe(0o600);
});
it("does not follow a token-file symlink for reads or writes", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-"));
dirs.push(dataDir);
const tokenDir = path.join(dataDir, "push-tokens");
const tokenFile = path.join(tokenDir, "user-1.txt");
const target = path.join(dataDir, "outside.txt");
await savePushToken(dataDir, "user-1", "ExponentPushToken[old]");
await writeFile(target, "not-a-push-token");
await rm(tokenFile);
await symlink(target, tokenFile);
await expect(loadPushToken(dataDir, "user-1")).resolves.toBeUndefined();
await expect(savePushToken(dataDir, "user-1", "ExponentPushToken[new]")).rejects.toThrow();
await expect(readFile(target, "utf8")).resolves.toBe("not-a-push-token");
});
it("removes a registered token", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-"));
dirs.push(dataDir);
await savePushToken(dataDir, "user-1", "ExponentPushToken[test]");
await deletePushToken(dataDir, "user-1");
await expect(loadPushToken(dataDir, "user-1")).resolves.toBeUndefined();
});
it("does not call Expo when the user has no token", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-"));
dirs.push(dataDir);
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const push = new ExpoPushProvider(dataDir);
await push.send(
{ kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" },
{
operationId: "n",
traceId: "n",
spaceId: "w",
userId: "missing",
signal: new AbortController().signal,
},
);
expect(fetchMock).not.toHaveBeenCalled();
});
it("posts to Expo when a token is registered", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-"));
dirs.push(dataDir);
await savePushToken(dataDir, "user-1", "ExponentPushToken[test]");
describe("expo push sender", () => {
it("posts the notification to Expo for the device token", async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(jsonResponse({ data: { status: "ok", id: "ticket" } }));
vi.stubGlobal("fetch", fetchMock);
const push = new ExpoPushProvider(dataDir);
await push.send(
{ kind: "takeover", title: "Need you", body: "on screen", botId: "bot-1", threadId: "th-1" },
notifyContext,
);
await expect(
new ExpoPushSender().send(
device,
payload({
kind: "takeover",
title: "Need you",
body: "on screen",
botId: "bot-1",
threadId: "th-1",
}),
),
).resolves.toBe("sent");
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe("https://exp.host/--/api/v2/push/send");
@ -126,55 +82,51 @@ describe("expo push", () => {
title: string;
collapseId: string;
tag: string;
data: { kind: string };
data: { kind: string; botId: string; threadId: string };
};
expect(body.to).toBe("ExponentPushToken[test]");
expect(body.title).toBe("Need you");
expect(body.collapseId).toBe("th-1");
expect(body.tag).toBe("th-1");
expect(body.data.kind).toBe("takeover");
expect(body).toMatchObject({
to: "ExponentPushToken[test]",
title: "Need you",
collapseId: "th-1",
tag: "th-1",
data: { kind: "takeover", botId: "bot-1", threadId: "th-1" },
});
});
it("reports a device Expo no longer knows as gone", async () => {
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValue(
jsonResponse({ data: { status: "error", details: { error: "DeviceNotRegistered" } } }),
),
);
await expect(new ExpoPushSender().send(device, payload())).resolves.toBe("gone");
});
it("throws when Expo rejects the request", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-"));
dirs.push(dataDir);
await savePushToken(dataDir, "user-1", "ExponentPushToken[test]");
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(jsonResponse({ errors: [{ message: "boom" }] }, 500)),
);
const push = new ExpoPushProvider(dataDir);
await expect(
push.send(
{ kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" },
notifyContext,
),
).rejects.toThrow("boom");
await expect(new ExpoPushSender().send(device, payload())).rejects.toThrow("boom");
});
it("rejects and cancels a declared oversized response", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-"));
dirs.push(dataDir);
await savePushToken(dataDir, "user-1", "ExponentPushToken[test]");
const response = new Response("oversized", {
headers: { "content-length": String(MAX_EXPO_PUSH_RESPONSE_BYTES + 1) },
});
const cancel = vi.spyOn(response.body!, "cancel");
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response));
await expect(
new ExpoPushProvider(dataDir).send(
{ kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" },
notifyContext,
),
).rejects.toThrow("Expo push response is too large.");
await expect(new ExpoPushSender().send(device, payload())).rejects.toThrow(
"Expo push response is too large.",
);
expect(cancel).toHaveBeenCalledOnce();
});
it("does not wait past cancellation when an oversized body cancel hangs", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-"));
dirs.push(dataDir);
await savePushToken(dataDir, "user-1", "ExponentPushToken[test]");
let cancelStarted = false;
const hangingBody = new ReadableStream<Uint8Array>({
cancel() {
@ -194,51 +146,31 @@ describe("expo push", () => {
);
const started = Date.now();
await expect(
new ExpoPushProvider(dataDir).send(
{ kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" },
{ ...notifyContext, signal: abort.signal },
),
).rejects.toThrow("Expo push response is too large.");
await expect(new ExpoPushSender().send(device, payload(), abort.signal)).rejects.toThrow(
"Expo push response is too large.",
);
expect(cancelStarted).toBe(true);
expect(Date.now() - started).toBeLessThan(500);
});
it("caps a streamed response without a content length", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-"));
dirs.push(dataDir);
await savePushToken(dataDir, "user-1", "ExponentPushToken[test]");
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(new Response(new Uint8Array(MAX_EXPO_PUSH_RESPONSE_BYTES + 1))),
);
await expect(
new ExpoPushProvider(dataDir).send(
{ kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" },
notifyContext,
),
).rejects.toThrow("Expo push response is too large.");
await expect(new ExpoPushSender().send(device, payload())).rejects.toThrow(
"Expo push response is too large.",
);
});
it("rejects malformed successful responses", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-"));
dirs.push(dataDir);
await savePushToken(dataDir, "user-1", "ExponentPushToken[test]");
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("not json")));
await expect(
new ExpoPushProvider(dataDir).send(
{ kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" },
notifyContext,
),
).rejects.toThrow("Expo push returned an invalid response.");
await expect(new ExpoPushSender().send(device, payload())).rejects.toThrow(
"Expo push returned an invalid response.",
);
});
it("passes caller cancellation to the Expo request", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-"));
dirs.push(dataDir);
await savePushToken(dataDir, "user-1", "ExponentPushToken[test]");
const controller = new AbortController();
controller.abort(new Error("notification cancelled"));
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
@ -247,49 +179,69 @@ describe("expo push", () => {
});
vi.stubGlobal("fetch", fetchMock);
await expect(
new ExpoPushProvider(dataDir).send(
{ kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" },
{ ...notifyContext, signal: controller.signal },
),
).rejects.toThrow("notification cancelled");
await expect(new ExpoPushSender().send(device, payload(), controller.signal)).rejects.toThrow(
"notification cancelled",
);
expect(fetchMock).toHaveBeenCalledOnce();
});
it("throws when the Expo request never reaches the network", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-"));
dirs.push(dataDir);
await savePushToken(dataDir, "user-1", "ExponentPushToken[test]");
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline")));
const push = new ExpoPushProvider(dataDir);
await expect(
push.send(
{ kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" },
notifyContext,
),
).rejects.toThrow("offline");
});
it("reports DeviceNotRegistered without deleting a stored or replacement token", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-"));
dirs.push(dataDir);
await savePushToken(dataDir, "user-1", "ExponentPushToken[old]");
vi.stubGlobal(
"fetch",
vi.fn(async () => {
await savePushToken(dataDir, "user-1", "ExponentPushToken[new]");
return jsonResponse({
data: { status: "error", details: { error: "DeviceNotRegistered" } },
});
}),
);
const push = new ExpoPushProvider(dataDir);
await expect(
push.send(
{ kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" },
notifyContext,
),
).rejects.toThrow("DeviceNotRegistered");
await expect(loadPushToken(dataDir, "user-1")).resolves.toBe("ExponentPushToken[new]");
await expect(new ExpoPushSender().send(device, payload())).rejects.toThrow("offline");
});
});
describe("legacy push token import", () => {
function fakePrisma(existingUsers: string[]) {
const upsert = vi.fn().mockResolvedValue({});
const prisma = {
user: {
findUnique: vi.fn(async ({ where }: { where: { id: string } }) =>
existingUsers.includes(where.id) ? { id: where.id } : null,
),
},
pushSubscription: {
upsert,
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
} as unknown as PrismaClient;
return { prisma, upsert };
}
it("imports each token once, skips unknown users and removes the files", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-"));
dirs.push(dataDir);
const dir = legacyPushTokenDir(dataDir);
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, "user-1.txt"), "ExponentPushToken[one]\n");
await writeFile(path.join(dir, "gone-user.txt"), "ExponentPushToken[two]");
await writeFile(path.join(dir, "user-2.txt"), "not a token");
const { prisma, upsert } = fakePrisma(["user-1", "user-2"]);
await expect(importLegacyPushTokens(prisma, dataDir)).resolves.toBe(1);
expect(upsert).toHaveBeenCalledOnce();
expect(upsert.mock.calls[0]![0]).toMatchObject({
where: { endpoint: "ExponentPushToken[one]" },
create: { endpoint: "ExponentPushToken[one]", userId: "user-1", kind: "expo" },
});
await expect(readdir(dataDir)).resolves.toEqual([]);
await expect(importLegacyPushTokens(prisma, dataDir)).resolves.toBe(0);
});
it("does not follow a token-file symlink", async () => {
const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-"));
dirs.push(dataDir);
const dir = legacyPushTokenDir(dataDir);
await mkdir(dir, { recursive: true });
const outside = path.join(dataDir, "outside.txt");
await writeFile(outside, "ExponentPushToken[outside]");
await symlink(outside, path.join(dir, "user-1.txt"));
const { prisma, upsert } = fakePrisma(["user-1"]);
await expect(importLegacyPushTokens(prisma, dataDir)).resolves.toBe(0);
expect(upsert).not.toHaveBeenCalled();
await expect(readdir(dataDir)).resolves.toEqual(["outside.txt"]);
});
});

View File

@ -1,29 +1,29 @@
import { constants } from "node:fs";
import { mkdir, open, unlink } from "node:fs/promises";
import { open, readdir, rmdir, unlink } from "node:fs/promises";
import path from "node:path";
import type {
AdapterContext,
NotificationMessage,
NotificationProvider,
} from "@rakazo/adapter-kit";
import { getLogger } from "@rakazo/logging";
import { ExpoPushTokenSchema } from "@rakazo/contracts";
import type { PrismaClient } from "@rakazo/db";
import { savePushSubscription } from "@rakazo/db";
import { combineSignals } from "./connector-safety.js";
import type { PushOutcome, PushPayload, PushTarget } from "./push-notifications.js";
import { readBodyCapped, withAbort } from "./web-ssrf.js";
const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0;
const EXPO_PUSH_TIMEOUT_MS = 15_000;
export const MAX_EXPO_PUSH_RESPONSE_BYTES = 64 * 1024;
const LEGACY_TOKEN_FILE = /^([A-Za-z0-9_-]{1,128})\.txt$/;
export function pushTokenPath(dataDir: string, userId: string) {
return path.join(dataDir, "push-tokens", `${userId}.txt`);
/** Where the API kept the single Expo token per user before push_subscriptions existed. */
export function legacyPushTokenDir(dataDir: string) {
return path.join(dataDir, "push-tokens");
}
export async function loadPushToken(dataDir: string, userId: string): Promise<string | undefined> {
async function readLegacyToken(file: string): Promise<string | undefined> {
try {
const handle = await open(pushTokenPath(dataDir, userId), constants.O_RDONLY | O_NOFOLLOW);
const handle = await open(file, constants.O_RDONLY | O_NOFOLLOW);
try {
const token = (await handle.readFile("utf8")).trim();
return token || undefined;
const token = ExpoPushTokenSchema.safeParse(await handle.readFile("utf8"));
return token.success ? token.data : undefined;
} finally {
await handle.close();
}
@ -32,26 +32,39 @@ export async function loadPushToken(dataDir: string, userId: string): Promise<st
}
}
export async function savePushToken(dataDir: string, userId: string, token: string): Promise<void> {
const file = pushTokenPath(dataDir, userId);
await mkdir(path.dirname(file), { recursive: true });
const handle = await open(
file,
constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | O_NOFOLLOW,
0o600,
);
/**
* Moves tokens from the legacy files into push_subscriptions once, then deletes each file. A file
* whose user no longer exists, or that holds no usable token, is deleted without import; a
* database failure keeps the file for the next start.
*/
export async function importLegacyPushTokens(
prisma: PrismaClient,
dataDir: string,
): Promise<number> {
const dir = legacyPushTokenDir(dataDir);
let names: string[];
try {
await handle.chmod(0o600);
await handle.writeFile(token.trim(), "utf8");
} finally {
await handle.close();
names = await readdir(dir);
} catch {
return 0;
}
}
export async function deletePushToken(dataDir: string, userId: string): Promise<void> {
await unlink(pushTokenPath(dataDir, userId)).catch((error: NodeJS.ErrnoException) => {
if (error.code !== "ENOENT") throw error;
});
let imported = 0;
for (const name of names) {
const userId = LEGACY_TOKEN_FILE.exec(name)?.[1];
if (!userId) continue;
const file = path.join(dir, name);
const token = await readLegacyToken(file);
if (token) {
const user = await prisma.user.findUnique({ where: { id: userId }, select: { id: true } });
if (user) {
await savePushSubscription(prisma, userId, { kind: "expo", endpoint: token });
imported += 1;
}
}
await unlink(file).catch(() => undefined);
}
await rmdir(dir).catch(() => undefined);
return imported;
}
export type ExpoPushTicket = {
@ -87,49 +100,33 @@ export function expoPushErrorMessage(body: unknown, status: number): string | un
return undefined;
}
export class ExpoPushProvider implements NotificationProvider {
constructor(private readonly dataDir: string) {}
describe() {
return {
id: "expo-push",
contractVersion: "1",
adapterVersion: "0.1.0",
capabilities: { push: true, email: false },
};
}
async send(message: NotificationMessage, context: AdapterContext): Promise<void> {
const token = await loadPushToken(this.dataDir, context.userId);
if (!token) return;
const signal = combineSignals(context.signal, AbortSignal.timeout(EXPO_PUSH_TIMEOUT_MS));
let response: Response;
try {
response = await fetch("https://exp.host/--/api/v2/push/send", {
method: "POST",
headers: { "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({
to: token,
title: message.title,
body: message.body,
collapseId: message.threadId,
tag: message.threadId,
data: { kind: message.kind, botId: message.botId, threadId: message.threadId },
}),
signal,
});
} catch (error) {
getLogger().error("expo push request failed", error);
throw error;
}
const body = await readExpoPushBody(response, signal);
/** Sends to one Expo push token; "gone" when Expo reports the device is no longer registered. */
export class ExpoPushSender {
async send(target: PushTarget, payload: PushPayload, signal?: AbortSignal): Promise<PushOutcome> {
const deadline = combineSignals(signal, AbortSignal.timeout(EXPO_PUSH_TIMEOUT_MS));
const response = await fetch("https://exp.host/--/api/v2/push/send", {
method: "POST",
headers: { "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({
to: target.endpoint,
title: payload.title,
body: payload.body,
collapseId: payload.threadId,
tag: payload.threadId,
data: { kind: payload.kind, botId: payload.botId, threadId: payload.threadId },
}),
signal: deadline,
});
const body = await readExpoPushBody(response, deadline);
if (response.ok && body === undefined) {
throw new Error("Expo push returned an invalid response.");
}
if (expoPushTickets(body).some((ticket) => ticket.details?.error === "DeviceNotRegistered")) {
return "gone";
}
const failure = expoPushErrorMessage(body, response.status);
if (!failure) return;
getLogger().error(failure);
throw new Error(failure);
if (failure) throw new Error(failure);
return "sent";
}
}

View File

@ -23,6 +23,8 @@ export * from "./cloud-agent-provider-env.js";
export * from "./cloud-agent-service.js";
export * from "./cloud-agent-tools.js";
export * from "./cloud-agent-tools-select.js";
export * from "./coach-rhythm.js";
export * from "./coach-tools.js";
export * from "./composio-catalog-cache.js";
export * from "./composio-connector.js";
export * from "./composio-emulator.js";
@ -100,6 +102,7 @@ export * from "./pipedream-connector.js";
export * from "./profile-consolidation.js";
export * from "./project-workspace-files.js";
export * from "./project-workspace-path.js";
export * from "./push-notifications.js";
export * from "./realtime.js";
export * from "./release-watch.js";
export * from "./remote-mcp.js";
@ -124,5 +127,6 @@ export * from "./voice-factory.js";
export * from "./wakeup.js";
export * from "./web-limits.js";
export * from "./web-provider-factory.js";
export * from "./web-push.js";
export * from "./web-ssrf.js";
export * from "./web-tools.js";

View File

@ -51,6 +51,36 @@ describe("agent memory context", () => {
expect(result?.endsWith("</durable_memory>")).toBe(true);
});
it("keeps the newest notes of a document that does not fit, from a line start", async () => {
const notes = Array.from(
{ length: 40 },
(_, index) => `- 2026-10-0${index % 9}: note ${index}`,
);
const read = vi.fn(async ({ scope }: { scope: "bot" | "user" }) =>
snapshot(
scope === "bot"
? [
document(
"bot",
"MEMORY.md",
`# Chief\n${notes.join("\n")}\n`,
41,
"2026-10-03T12:00:00.000Z",
),
]
: [],
),
);
const result = await loadAgentMemoryContext(storeWith(read), "bot-1", context, 600);
expect(Buffer.byteLength(result ?? "", "utf8")).toBeLessThanOrEqual(600);
expect(result).toContain("## bot: MEMORY.md (revision 41)\n[earlier notes omitted]\n- ");
expect(result).toContain("note 39\n\n</durable_memory>");
expect(result).not.toContain("# Chief");
expect(result).not.toContain("note 0\n");
});
it("omits the memory block when neither scope has documents", async () => {
const read = vi.fn(async () => snapshot([]));

View File

@ -1,6 +1,9 @@
import type { AdapterContext, MemorySnapshot, MemoryStore } from "@rakazo/adapter-kit";
import { MEMORY_DOCUMENT_MAX_BYTES } from "@rakazo/adapter-kit";
const MAX_AGENT_MEMORY_BYTES = 32 * 1024;
/** Room for a full bot document and a full space document. */
const MAX_AGENT_MEMORY_BYTES = 2 * MEMORY_DOCUMENT_MAX_BYTES;
const OMITTED_MARKER = "[earlier notes omitted]\n";
type ScopedMemoryDocument = MemorySnapshot["documents"][number] & {
scope: "bot" | "user";
@ -45,10 +48,14 @@ export async function loadAgentMemoryContext(
sections.push(heading);
remainingBytes -= headingBytes;
const content = truncateUtf8(document.content, remainingBytes);
sections.push(content);
remainingBytes -= byteLength(content);
if (content !== document.content) break;
if (byteLength(document.content) <= remainingBytes) {
sections.push(document.content);
remainingBytes -= byteLength(document.content);
continue;
}
// Notes are appended, so the end of a document is its newest part.
sections.push(newestLines(document.content, remainingBytes));
break;
}
return `${preamble}${sections.join("")}${closing}`;
@ -58,6 +65,30 @@ function byteLength(value: string): number {
return Buffer.byteLength(value, "utf8");
}
/** The end of `content` within `maxBytes`, from a line start, after an omission marker. */
function newestLines(content: string, maxBytes: number): string {
const markerBytes = byteLength(OMITTED_MARKER);
if (maxBytes <= markerBytes) return "";
const tail = tailUtf8(content, maxBytes - markerBytes);
const startsLine = content.charAt(content.length - tail.length - 1) === "\n";
const newline = tail.indexOf("\n");
const partialFirstLine = !startsLine && newline >= 0 && newline < tail.length - 1;
return `${OMITTED_MARKER}${partialFirstLine ? tail.slice(newline + 1) : tail}`;
}
function tailUtf8(value: string, maxBytes: number): string {
const characters = Array.from(value);
let bytes = 0;
let start = characters.length;
while (start > 0) {
const characterBytes = byteLength(characters[start - 1]!);
if (bytes + characterBytes > maxBytes) break;
bytes += characterBytes;
start -= 1;
}
return characters.slice(start).join("");
}
function truncateUtf8(value: string, maxBytes: number): string {
if (maxBytes <= 0) return "";
const characters: string[] = [];

View File

@ -0,0 +1,88 @@
import type { NotificationProvider } from "@rakazo/adapter-kit";
import { NUDGES_PER_DAY } from "@rakazo/contracts";
import type { PrismaClient } from "@rakazo/db";
import {
claimDailyNudge,
countPushSubscriptions,
ensureMainConversation,
isPersonalMainBot,
loadNotificationPolicy,
} from "@rakazo/db";
import { z } from "zod";
export const NOTIFY_USER_BODY_MAX_LENGTH = 200;
export const NOTIFY_USER_TITLE_MAX_LENGTH = 60;
const NotifyUserArgs = z.object({
body: z.string().trim().min(1).max(NOTIFY_USER_BODY_MAX_LENGTH),
title: z.string().trim().max(NOTIFY_USER_TITLE_MAX_LENGTH).optional(),
});
export interface NotifyUserToolContext {
userId: string;
spaceId: string;
runId: string;
bot: { id: string; name: string; kind: string };
signal: AbortSignal;
}
export type NotifyUserResult = { error: string } | { sent: true } | { sent: false; reason: string };
/**
* A nudge from the main bot: a push that opens the main conversation. The user's nudge switch,
* quiet hours and the daily cap all apply; a held-back nudge is reported, not an error.
*/
export async function notifyUserFromTool(
deps: { prisma: PrismaClient; notifications?: NotificationProvider },
context: NotifyUserToolContext,
rawArgs: Record<string, unknown>,
): Promise<NotifyUserResult> {
if (
!(await isPersonalMainBot(deps.prisma, { kind: context.bot.kind, spaceId: context.spaceId }))
) {
return { error: "Only the user's main bot can notify the user." };
}
const parsed = NotifyUserArgs.safeParse(rawArgs);
if (!parsed.success) {
return {
error: `Invalid notification: body is required, up to ${NOTIFY_USER_BODY_MAX_LENGTH} characters; title up to ${NOTIFY_USER_TITLE_MAX_LENGTH}.`,
};
}
const { prisma, notifications } = deps;
if (!notifications) {
return { sent: false, reason: "Push notifications are not available on this server." };
}
const policy = await loadNotificationPolicy(prisma, context.userId);
if (!policy.nudges) return { sent: false, reason: "The user turned nudges off." };
if (policy.quiet) return { sent: false, reason: "It is the user's quiet hours." };
if ((await countPushSubscriptions(prisma, context.userId)) === 0) {
return { sent: false, reason: "The user has no device with notifications turned on." };
}
if (!(await claimDailyNudge(prisma, context.userId, policy.localDate))) {
return { sent: false, reason: `The daily limit of ${NUDGES_PER_DAY} nudges is reached.` };
}
const conversation = await ensureMainConversation(prisma, context.userId);
try {
await notifications.send(
{
kind: "nudge",
title: parsed.data.title || context.bot.name,
body: parsed.data.body,
botId: context.bot.id,
threadId: conversation.threadId,
},
{
operationId: `notify_user:${context.runId}`,
traceId: context.runId,
spaceId: context.spaceId,
userId: context.userId,
botId: context.bot.id,
runId: context.runId,
signal: context.signal,
},
);
} catch {
return { sent: false, reason: "No device accepted the notification." };
}
return { sent: true };
}

View File

@ -0,0 +1,231 @@
import { randomUUID } from "node:crypto";
import type { PrismaClient } from "@rakazo/db";
import {
bootstrapUserSpace,
createDb,
createSpaceForMember,
ensureMainConversation,
savePushSubscription,
updateNotificationSettings,
} from "@rakazo/db";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { notifyUserFromTool } from "./notify-user-tool.js";
import type { PushPayload, PushSender } from "./push-notifications.js";
import { PushNotificationProvider } from "./push-notifications.js";
const databaseUrl = process.env.DATABASE_URL;
const describePostgres =
process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip;
const signupEnv = { signupsEnabled: "true", signupAllowlist: "" };
const keys = { p256dh: `B${"A".repeat(86)}`, auth: "A".repeat(22) };
describePostgres("push delivery and notify_user (PostgreSQL)", () => {
let prisma: PrismaClient;
let close: () => Promise<void>;
const userIds: string[] = [];
async function newUser() {
const id = `nu${randomUUID().replaceAll("-", "")}`;
userIds.push(id);
await prisma.user.create({ data: { id, name: "Ada", email: `${id}@rakazo.test` } });
await bootstrapUserSpace(prisma, { id }, signupEnv, { claimDeploymentOwner: false });
const conversation = await ensureMainConversation(prisma, id);
const main = await prisma.bot.findUniqueOrThrow({
where: { id: conversation.leadBotId },
select: { id: true, name: true, kind: true },
});
return { userId: id, conversation, main };
}
function recordingSender(outcome: "sent" | "gone" = "sent") {
const sent: PushPayload[] = [];
const sender: PushSender = {
send: vi.fn(async (_target, payload: PushPayload) => {
sent.push(payload);
return outcome;
}),
};
return { sender, sent };
}
function toolContext(user: Awaited<ReturnType<typeof newUser>>, bot = user.main) {
return {
userId: user.userId,
spaceId: user.conversation.spaceId,
runId: `run-${randomUUID()}`,
bot,
signal: new AbortController().signal,
};
}
beforeAll(() => {
const db = createDb(databaseUrl!);
prisma = db.prisma;
close = async () => {
await db.prisma.$disconnect();
await db.pool.end();
};
});
afterAll(async () => {
if (!prisma) return;
const memberships = await prisma.member.findMany({
where: { userId: { in: userIds } },
select: { organizationId: true },
});
await prisma.organization.deleteMany({
where: { id: { in: memberships.map((member) => member.organizationId) } },
});
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
await close();
});
it("delivers to each device, links the conversation and drops gone devices", async () => {
const user = await newUser();
const browser = `https://push.example.test/${randomUUID()}`;
await savePushSubscription(prisma, user.userId, {
kind: "webpush",
endpoint: browser,
...keys,
});
await savePushSubscription(prisma, user.userId, {
kind: "expo",
endpoint: `ExponentPushToken[${user.userId}]`,
});
const web = recordingSender("sent");
const expo = recordingSender("gone");
await new PushNotificationProvider(prisma, { webpush: web.sender, expo: expo.sender }).send(
{
kind: "completion",
title: "Chief finished",
body: "Done",
botId: user.main.id,
threadId: user.conversation.threadId,
},
{
operationId: "notify",
traceId: "notify",
spaceId: user.conversation.spaceId,
userId: user.userId,
signal: new AbortController().signal,
},
);
expect(web.sent[0]?.url).toBe(`/app/g/${user.conversation.groupId}`);
const devices = await prisma.pushSubscription.findMany({ where: { userId: user.userId } });
expect(devices).toHaveLength(1);
expect(devices[0]).toMatchObject({ endpoint: browser, lastUsedAt: expect.any(Date) });
});
it("lets only the personal main agent nudge, within the user's settings and the daily cap", async () => {
const user = await newUser();
const web = recordingSender();
const deps = {
prisma,
notifications: new PushNotificationProvider(prisma, { webpush: web.sender }),
};
await expect(
notifyUserFromTool(deps, toolContext(user, { ...user.main, kind: "specialist" }), {
body: "Hi",
}),
).resolves.toEqual({ error: "Only the user's main bot can notify the user." });
// A project's main bot leads its project; nudging the user is the personal agent's job.
const project = await createSpaceForMember(prisma, {
currentSpaceId: user.conversation.spaceId,
userId: user.userId,
name: "Launch",
});
const lead = await ensureMainConversation(prisma, user.userId, project.id);
await expect(
notifyUserFromTool(
deps,
{ ...toolContext(user, { ...user.main, id: lead.leadBotId }), spaceId: project.id },
{ body: "Hi" },
),
).resolves.toEqual({ error: "Only the user's main bot can notify the user." });
await expect(notifyUserFromTool(deps, toolContext(user), {})).resolves.toMatchObject({
error: expect.stringContaining("body is required"),
});
await expect(
notifyUserFromTool({ prisma }, toolContext(user), { body: "Hi" }),
).resolves.toMatchObject({ sent: false });
await expect(notifyUserFromTool(deps, toolContext(user), { body: "Hi" })).resolves.toEqual({
sent: false,
reason: "The user has no device with notifications turned on.",
});
await savePushSubscription(prisma, user.userId, {
kind: "webpush",
endpoint: `https://push.example.test/${randomUUID()}`,
...keys,
});
await updateNotificationSettings(prisma, user.userId, { nudges: false });
await expect(notifyUserFromTool(deps, toolContext(user), { body: "Hi" })).resolves.toEqual({
sent: false,
reason: "The user turned nudges off.",
});
// Quiet hours around the current time.
await updateNotificationSettings(prisma, user.userId, {
nudges: true,
quietHours: quietAroundNow(),
});
await expect(notifyUserFromTool(deps, toolContext(user), { body: "Hi" })).resolves.toEqual({
sent: false,
reason: "It is the user's quiet hours.",
});
expect(web.sent).toHaveLength(0);
await updateNotificationSettings(prisma, user.userId, { quietHours: null });
for (let index = 0; index < 3; index += 1) {
await expect(
notifyUserFromTool(deps, toolContext(user), { body: `Check-in ${index}` }),
).resolves.toEqual({ sent: true });
}
await expect(
notifyUserFromTool(deps, toolContext(user), { body: "One too many" }),
).resolves.toEqual({ sent: false, reason: "The daily limit of 3 nudges is reached." });
expect(web.sent).toHaveLength(3);
expect(web.sent[0]).toMatchObject({
kind: "nudge",
title: user.main.name,
body: "Check-in 0",
threadId: user.conversation.threadId,
url: `/app/g/${user.conversation.groupId}`,
});
});
it("reports a nudge no device accepted", async () => {
const user = await newUser();
await savePushSubscription(prisma, user.userId, {
kind: "webpush",
endpoint: `https://push.example.test/${randomUUID()}`,
...keys,
});
const failing: PushSender = {
send: async () => {
throw new Error("Web push failed (500)");
},
};
await expect(
notifyUserFromTool(
{ prisma, notifications: new PushNotificationProvider(prisma, { webpush: failing }) },
toolContext(user),
{ body: "Hi", title: "Evening check-in" },
),
).resolves.toEqual({ sent: false, reason: "No device accepted the notification." });
});
});
/** A UTC range that covers the current minute (users without a time zone use UTC). */
function quietAroundNow() {
const now = new Date();
const minute = now.getUTCHours() * 60 + now.getUTCMinutes();
const clock = (value: number) => {
const wrapped = (value + 1440) % 1440;
return `${String(Math.floor(wrapped / 60)).padStart(2, "0")}:${String(wrapped % 60).padStart(2, "0")}`;
};
return { start: clock(minute - 60), end: clock(minute + 60) };
}

View File

@ -0,0 +1,179 @@
import type { NotificationMessage } from "@rakazo/adapter-kit";
import type { PrismaClient } from "@rakazo/db";
import { describe, expect, it, vi } from "vitest";
import type { PushOutcome, PushPayload, PushSender } from "./push-notifications.js";
import { PushNotificationProvider } from "./push-notifications.js";
const context = {
operationId: "notify",
traceId: "t",
spaceId: "space-1",
userId: "user-1",
signal: new AbortController().signal,
};
const message: NotificationMessage = {
kind: "completion",
title: "Chief finished",
body: "Booked the table for Friday.",
botId: "bot-1",
threadId: "thread-1",
};
function fakeDb(options: {
devices: Array<{ id: string; kind: "webpush" | "expo" }>;
quietHours?: { start: number; end: number };
timeZone?: string;
groupId?: string | null;
}) {
const forgotten: string[] = [];
const used: string[] = [];
const prisma = {
userNotificationSettings: {
findUnique: async () =>
options.quietHours
? {
quietHoursStart: options.quietHours.start,
quietHoursEnd: options.quietHours.end,
nudges: true,
}
: null,
},
userProfile: { findUnique: async () => ({ timeZone: options.timeZone ?? null }) },
pushSubscription: {
findMany: async () =>
options.devices.map((device) => ({
...device,
endpoint: `endpoint-${device.id}`,
p256dh: null,
auth: null,
})),
deleteMany: async ({ where }: { where: { id: string } }) => {
forgotten.push(where.id);
return { count: 1 };
},
updateMany: async ({ where }: { where: { id: string } }) => {
used.push(where.id);
return { count: 1 };
},
},
thread: { findUnique: async () => ({ groupId: options.groupId ?? null }) },
} as unknown as PrismaClient;
return { prisma, forgotten, used };
}
function fakeSender(outcome: PushOutcome | Error) {
const sent: PushPayload[] = [];
const sender: PushSender = {
send: vi.fn(async (_target, payload) => {
sent.push(payload);
if (outcome instanceof Error) throw outcome;
return outcome;
}),
};
return { sender, sent };
}
// 23:30 in Vienna (UTC+2 in summer).
const lateEvening = () => new Date("2026-07-01T21:30:00Z");
describe("push notification fan-out", () => {
it("sends to every device with a link to the conversation", async () => {
const { prisma, used } = fakeDb({
devices: [
{ id: "browser", kind: "webpush" },
{ id: "phone", kind: "expo" },
],
groupId: "group-1",
});
const web = fakeSender("sent");
const expo = fakeSender("sent");
await new PushNotificationProvider(prisma, { webpush: web.sender, expo: expo.sender }).send(
message,
context,
);
expect(web.sent).toEqual([{ ...message, url: "/app/g/group-1" }]);
expect(expo.sent).toHaveLength(1);
expect(used.sort()).toEqual(["browser", "phone"]);
});
it("links a direct thread to its bot and shortens long bodies", async () => {
const { prisma } = fakeDb({ devices: [{ id: "browser", kind: "webpush" }] });
const web = fakeSender("sent");
await new PushNotificationProvider(prisma, { webpush: web.sender }).send(
{ ...message, body: "x".repeat(1_000) },
context,
);
expect(web.sent[0]?.url).toBe("/app/bot-1");
expect(web.sent[0]?.body.length).toBeLessThanOrEqual(240);
});
it("holds every push back during quiet hours in the user's time zone", async () => {
const quiet = fakeDb({
devices: [{ id: "browser", kind: "webpush" }],
quietHours: { start: 22 * 60, end: 7 * 60 },
timeZone: "Europe/Vienna",
});
const web = fakeSender("sent");
await new PushNotificationProvider(quiet.prisma, { webpush: web.sender }, lateEvening).send(
message,
context,
);
expect(web.sent).toHaveLength(0);
// The same moment is mid-afternoon in Los Angeles.
const awake = fakeDb({
devices: [{ id: "browser", kind: "webpush" }],
quietHours: { start: 22 * 60, end: 7 * 60 },
timeZone: "America/Los_Angeles",
});
await new PushNotificationProvider(awake.prisma, { webpush: web.sender }, lateEvening).send(
message,
context,
);
expect(web.sent).toHaveLength(1);
});
it("drops devices the push service no longer knows", async () => {
const { prisma, forgotten, used } = fakeDb({
devices: [
{ id: "old-browser", kind: "webpush" },
{ id: "phone", kind: "expo" },
],
});
await new PushNotificationProvider(prisma, {
webpush: fakeSender("gone").sender,
expo: fakeSender("sent").sender,
}).send(message, context);
expect(forgotten).toEqual(["old-browser"]);
expect(used).toEqual(["phone"]);
});
it("succeeds when one device works and fails only when all of them fail", async () => {
const mixed = fakeDb({
devices: [
{ id: "browser", kind: "webpush" },
{ id: "phone", kind: "expo" },
],
});
await expect(
new PushNotificationProvider(mixed.prisma, {
webpush: fakeSender(new Error("Web push failed (500)")).sender,
expo: fakeSender("sent").sender,
}).send(message, context),
).resolves.toBeUndefined();
const broken = fakeDb({ devices: [{ id: "browser", kind: "webpush" }] });
await expect(
new PushNotificationProvider(broken.prisma, {
webpush: fakeSender(new Error("Web push failed (500)")).sender,
}).send(message, context),
).rejects.toThrow("Push delivery failed");
});
it("does nothing without devices or senders", async () => {
const { prisma } = fakeDb({ devices: [{ id: "phone", kind: "expo" }] });
const web = fakeSender("sent");
await new PushNotificationProvider(prisma, { webpush: web.sender }).send(message, context);
expect(web.sent).toHaveLength(0);
});
});

Some files were not shown because too many files have changed in this diff Show More