Compare commits

...

151 Commits

Author SHA1 Message Date
62a4414968 fix(web): inject --bot-accent CSS variable into bot bubbles for colored accent borders
Some checks are pending
ci / Detect product changes (push) Waiting to run
ci / Lint (push) Blocked by required conditions
ci / Typecheck (push) Blocked by required conditions
ci / Production builds (push) Blocked by required conditions
ci / Unit tests (push) Blocked by required conditions
ci / Postgres journeys (push) Blocked by required conditions
ci / Web E2E (push) Blocked by required conditions
ci / Product CI (push) Blocked by required conditions
ci / Publish compatible mobile update (push) Blocked by required conditions
publish-server-image / Detect image publish inputs (push) Waiting to run
publish-server-image / Compose installer smokes (push) Waiting to run
publish-server-image / Validate app image (push) Waiting to run
publish-server-image / Validate updater image (push) Waiting to run
publish-server-image / Validate computer image (push) Waiting to run
publish-server-image / Build app amd64 (push) Blocked by required conditions
publish-server-image / Build updater amd64 (push) Blocked by required conditions
publish-server-image / Build computer amd64 (push) Blocked by required conditions
publish-server-image / Publish app image (push) Blocked by required conditions
publish-server-image / Publish computer image (push) Blocked by required conditions
publish-server-image / Publish updater image (push) Blocked by required conditions
The CSS uses --bot-accent for the tinted background and left border, but the
inline style injection was missing after the remote merge. Add speakerColorDef.hex
to the two bot bubble render locations so the accent color actually applies.
2026-10-23 13:35:23 +00:00
f2841bdd74 Merge remote main into local 2026-10-23 13:30:27 +00:00
95d786ef2c fix(web): restore bot bubble accent color and user bubble border after refactor regression
- Add --bot-accent CSS variable injection from speakerColorDef.hex to all bot bubbles
- Restore colored left border accent on bot bubbles via CSS custom property
- Improve bot bubble background with subtle accent tint (88% card + 12% accent)
- Add colored right border accent to user bubbles
- Add subtle accent halo shadow to bot bubbles for better visual hierarchy
2026-10-23 13:28:24 +00:00
cynarAI
f11be8735c
Keep source bind mounts out of a recreated supervisor container (#227)
The supervisor on evo had a bind mount of an old source copy over the
image's /app sources. The deploy carried every mount over when it
recreated the container, so a rebuilt image never took effect and the
fix for missing bot networks stayed inactive. Mounts into /app are now
dropped; data and Docker socket mounts are kept.


Claude-Session: https://claude.ai/code/session_01QAiXRe1FLMAJT9jkvwFZFq

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-23 10:34:43 +02:00
cynarAI
679b90a667
Recreate a computer whose Docker network was removed (#226)
When a stack restart or network prune removes a bot's network, its
stopped computer keeps pointing at the old network id. Starting it fails
with "network ... not found", the supervisor returned 500 on every
provision, and runs for that bot retried setup forever while staying
queued. The supervisor now recognizes that error and recreates the
container on a fresh network with the same home, as it already does for
an outdated image.


Claude-Session: https://claude.ai/code/session_01QAiXRe1FLMAJT9jkvwFZFq

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-23 05:23:00 +02:00
cynarAI
ea7892baa3
Compact chat history automatically before it outgrows the model (#225)
* Push agents to act when they end a turn announcing work

A local model often ends a turn with "let me set that up now" and no tool
call. Nothing runs after a turn ends, so the user waits on work that never
happens. A text-only turn that announces unfinished work now gets up to two
follow-ups that ask the agent to do it or report what is open.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011VfFvSskgrByEU75QLyreE

* Summarize project conversations and retry contended handoffs

Group threads, which hold every project's main conversation, were never
compacted. Runs there saw only the last 200 raw messages, so the original
brief and earlier decisions fell out of context. The lead bot now
summarizes them like a bot thread; the summary stays local.

A group handoff now retries when its transaction hits a deadlock, so a
specialist that finished its work no longer fails at the hand-back.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011VfFvSskgrByEU75QLyreE

* Compact chat history automatically before it outgrows the model

Long chats lost their brief and open work. The Pi runtime passed only the
last 25 history entries, and the compacted summary and recalled memory sat
in front of them, so both were cut whenever a chat had more than 24
uncompacted messages. Context pruning then dropped the summary first,
because it was the oldest history entry. Compaction itself only started
after 100 uncompacted messages and ignored tokens.

- History entries can be pinned. The executor pins the summary and recalled
  memory, the runtime always keeps them ahead of the recent window, and
  context pruning never drops them.
- Compaction is driven by messages and tokens: it keeps the newest ten
  messages verbatim and folds the rest into the summary once 20 messages
  are uncompacted or the history exceeds 40% of the model's input budget.
- Before a run whose history would not reach the model in full, the
  executor compacts inline for up to 90 seconds, then runs with what it has.
- The summarizer writes a structured summary: goal and brief, decisions,
  done, open, preferences.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QAiXRe1FLMAJT9jkvwFZFq

* Record runtime failures again instead of leaving the run running

A runtime error closes the stream through withRuntimeCleanup, which aborts
the run's own controller. The catch treated that as an outside stop and
returned without finalizing, so a failed run stayed running and the web
never showed its error. Same fix as on the main-green branch.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QAiXRe1FLMAJT9jkvwFZFq

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-23 04:18:21 +02:00
cynarAI
467d9b0dc4
Keep main green: racing E2E tests, the git-clone push, and runs that never fail (#224)
* Stop two web E2E tests from racing on main

Replies read themselves aloud by default since the voice update, so in the
group chat test the last bot's automatic read-aloud could beat the manual
Speak click. The test turns automatic reading off for its two bots.

Opening the browser panel on a running computer now only fetches the
screen, as it did before the panel change, instead of also reloading the
whole thread on every computer state change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TnT7eVHuaCzZKY5CnYzpr1

* Wait for the speaking switch to settle in the voice test

The app turns the local voice on by itself on first start and keeps the
switch disabled meanwhile. The helper read "off" during that window and
clicked a disabled switch; it now retries until the switch is on, clicking
only when it is enabled and off.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TnT7eVHuaCzZKY5CnYzpr1

* Fix main after the git-clone push: lint, i18n and abort tests

- Lint: formatting in two files and no assignment inside an expression
  in the steering poller.
- i18n: German for the new "Resume" and "Stopped" labels on web, and
  de/ru/zh on mobile; catalogs re-extracted.
- Tests follow the intended change that a caller abort ends a turn as
  "stopped" instead of failing it as a provider error.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TnT7eVHuaCzZKY5CnYzpr1

* Record runtime failures again instead of leaving the run running

A runtime error closes the stream through withRuntimeCleanup, which aborts the
run's own controller. The catch treated that as an outside stop and returned
without finalizing, so a failed run stayed running forever and its delegated
task never reported back.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TnT7eVHuaCzZKY5CnYzpr1

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-23 03:33:07 +02:00
cynarAI
5168518759 fix(sandboxes,adapters): allow cloning git repositories and link space git remote in prompts 2026-10-23 02:13:25 +02:00
cynarAI
8a98bfe79f
fix(adapters,sandboxes): tell agents where git works and make the refusal explicit (#223)
* fix(adapters,sandboxes): tell agents where git works and make the refusal explicit

Agents searched for the project repository in mounted host folders and read
the broker refusal as a missing broker. The prompt now names the folder where
git and gh work, and the refusal says to cd there instead of looking elsewhere.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* test(web): wait for the automatic voice start before switching speaking on

Clicking the switch while the app was still turning the voice on by itself
could turn it off again, which failed the voice settings e2e twice in a row.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-22 23:51:31 +02:00
cynarAI
de95cf1b91
Fix red main: typecheck, lint, i18n, unit and web E2E (#222)
* Fix red main: typecheck, lint, i18n and stale unit tests

Direct pushes to main left typecheck, lint and 16 unit tests red.

- Typecheck: AskCard test uses the current ask block shape, the sandbox
  fault test calls the two-argument desktop writeFile, removing a queued
  message notifies with the thread's latest event seq, and the replay
  fixture only reads ref from steps that carry one.
- Lint: format and import order.
- i18n: transcript status labels were German source strings; they are
  English now with German translations, catalogs re-extracted, and the
  mobile "Failed to remove message" string is translated.
- Tests follow intended behavior changes: the time instruction now
  closes the system prompt, short chat turns start at low reasoning,
  owner-owned read-only files (git objects) pass the home check, and the
  desktop computer may read and write host files outside its home. The
  old desktop write-containment suite is replaced by a host file test.
- The composer task pill test mocks the Lingui macros like other tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TnT7eVHuaCzZKY5CnYzpr1

* Bump Expo patch releases so expo install --check passes

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TnT7eVHuaCzZKY5CnYzpr1

* Bring web E2E back in line with the new side panel

The side panel lost its computer tab, which left the full computer window
(take control, teach, release) unreachable from the web app. The browser
view now offers an "Open in full window" button, and opening the panel no
longer boots a stopped computer.

Also: the slash picker scrolls instead of growing under the header, user
skills come first in it, the agent overview hides an empty routines block,
the hover time hides again with collapsed details, and the light destructive
token meets contrast on the sidebar. E2E specs follow the new panel layout,
and the cursor and hover tests no longer race the UI.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TnT7eVHuaCzZKY5CnYzpr1

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-22 21:58:24 +02:00
cynarAI
9a753e9adb fix(web): increase transcript bottom padding when task pill is pinned above composer 2026-10-22 16:36:44 +02:00
cynarAI
95061c648f feat(web,mobile): dock expandable live task pill above composer and fold plan steps 2026-10-22 14:11:19 +02:00
cynarAI
78827e0a5b feat(adapters): rasterize SVG attachments to PNG and auto-attach written SVGs 2026-10-22 13:33:55 +02:00
cynarAI
fe80edbf52 feat(adapters,web,chat-ui,core): enhance web research, page browser actions, markdown citations, and executor streaming 2026-10-22 12:19:26 +02:00
cynarAI
996af168ef fix(web): make right sidebar header toggle work across mobile, projects, and groups 2026-10-22 12:04:39 +02:00
cynarAI
35b7291660 feat(web,core,adapters): unify right sidebar styling, hover resize handles, voice & search improvements 2026-10-22 10:24:32 +02:00
cynarAI
75423579b7 fix(api): honor EXTRA_TRUSTED_ORIGINS in CORS and requestWebOrigin 2026-10-21 22:14:00 +02:00
cynarAI
ac0384e49c feat: add automatic plan mode, live step tracking, and chat UX updates 2026-10-21 22:00:28 +02:00
cynarAI
000745592a
Keep voice choices locked until speaking finishes turning on (#220)
Turning speaking on connected the local voice and cleared the busy
state halfway through, so a voice picked in that window was replaced by
the default once the switch finished. The panel now stays busy for the
whole switch.


Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-20 11:14:28 +02:00
cynarAI
d1dd8ffcd9
Keep the Expo action's caches off the GitHub cache service (#219)
The mobile OTA job uploaded the EAS CLI to the GitHub cache service
because expo-github-action caches by default. Turn both of its caches
off and have the Actions budget test reject the Expo action unless they
are off.


Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-20 10:34:47 +02:00
cynarAI
263e3a31f8
Fix red main: mobile group kind, i18n catalogs, project picker check (#218)
* Fix red main: mobile group kind, i18n catalogs, project picker check

Main CI failed on typecheck, unit tests and web E2E after the last two
direct pushes. MobileGroup now carries the optional chat kind the mobile
screens read, the Lingui catalogs are re-extracted with German strings
for the new voice texts, the project overview plus icon meets the UI
scale guard, and the sidebar E2E expects the renamed project lead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R8L4XgqjAemFR2remB5hVY

* Format files merged from main

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs

* Align E2E with the canvas close button and per-chat drafts

The in-app browser now closes from the side panel header, and each chat
keeps its own unsent draft. The group chat test waits for the settings
save to land before switching chats, so the switch no longer races the
panel closing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-20 09:34:40 +02:00
cynarAI
bb7cf3f55e feat(threads): add send-now action for queued follow-up messages 2026-10-19 19:59:05 +02:00
cynarAI
66dbf0046e fix(web,core,db): resolve responsive layout overflows, dialog clipping, i18n gaps, and runtime stability 2026-10-19 18:57:04 +02:00
cynarAI
ac9fcce0fb feat(web): show routines with quick toggle in project overview sidebar 2026-10-19 17:29:31 +02:00
cynarAI
f820c75632 feat: persist unsent composer drafts across chat and page switches 2026-10-19 12:56:59 +02:00
cynarAI
be657f6e3c feat(web): remove redundant live progress card and add adaptive project vs 1:1 agent overview sidebar 2026-10-18 22:07:53 +02:00
cynarAI
f6053dd2a8 feat: allow project deletion with owned agents, prevent duplicate project names, and refine voice conversation UX 2026-10-18 21:04:18 +02:00
cynarAI
1611fe5fa2
Let computers push from project folders through the GitHub broker (#217)
Two gaps kept agents from pushing on a containerized supervisor:

- The git and gh shims called rakazo-broker by name, and login shells in
  the computer reset PATH, so the broker was "not found". The shims now
  call it next to their own resolved path, and /usr/bin/gh is a shim too.
- A project folder outside the supervisor container's mounts is invisible
  to it, so broker git ran against an empty directory. Such calls now run
  in a one-off computer-image container that binds the folder.


Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-18 15:04:20 +02:00
cd770b9a1d fix: mobile sidebar overlay z-index z-30→z-50 so tap-to-close works 2026-10-18 12:30:03 +00:00
cynarAI
a27cdf733c
Install git and gh in the sandbox supervisor image (#216)
The GitHub broker runs git and gh for agent computers, but the supervisor
image is node:bookworm-slim, which has neither. A containerized supervisor
therefore failed every broker call. Install both, and when the supervisor
runs as root, run git as the workspace owner so clones stay writable by
the computer.


Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-18 12:31:54 +02:00
cynarAI
a0bdf6ddc8
Refresh a containerized sandbox supervisor on deploy (#215)
A supervisor that runs as a standalone container kept the image it was
started with, so broker fixes never reached it. The deploy now rebuilds
that image when its sources change and recreates the container with the
same network, restart policy, mounts and environment, restoring the old
one if the new one stays unhealthy. The broker socket moves into the data
directory and is shared from containerized supervisors too.


Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-18 10:25:09 +02:00
cynarAI
9bc3187a4a
Give every computer the GitHub broker socket and name failed paths (#214)
Chief's computer still timed out reaching the GitHub broker after the
socket mount shipped. A reused computer kept its old container whenever
the image matched, so one created without the mount never got it, and
the socket was only served for a loopback-bound supervisor.

- Recreate a reused computer that lacks the socket mount once the
  supervisor serves the socket.
- Serve the socket from any supervisor running directly on a Linux host.
- Give the broker listeners the same long request timeout as the main
  server, so large brokered clones are not cut after five minutes.
- The broker client now says which path failed (no socket, socket exit,
  network exit) so a relayed error points at the cause.


Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-18 06:37:48 +02:00
cynarAI
7452b8c6d2
Reach the GitHub broker over a mounted socket instead of the network (#213)
Computers on evo could not reach the broker over TCP: the git shim
reported that evOo's GitHub connection was unreachable, so cloning
private repositories still failed. Container-to-host traffic depends on
the host firewall and on which Docker network the computer joined.

- On a Linux host the supervisor also serves the broker on a unix socket
  in a host directory (SANDBOX_GITHUB_BROKER_SOCKET_DIR, default
  ~/.rakazo/github-broker), and mounts that directory read-only into
  each new computer once the socket listens.
- The shim uses the socket when it is present and falls back to the
  network otherwise; connections time out after 5 seconds and the
  unreachable message names the curl exit code.


Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-18 02:49:34 +02:00
cynarAI
f136451e22
Stop agents from using the broker run token as a GitHub password (#212)
Agents kept configuring git with RAKAZO_GITHUB_BROKER_RUN_TOKEN as a
password because the system prompt listed it among "managed credentials"
to use in shell commands. GitHub rejects it, and the agent then asks the
user for a personal access token.

- List only the user's own agent secrets in that instruction; the broker
  variables stay in the environment for the git shim.
- Make /usr/bin/git the shim as well, so git called by absolute path
  still goes through the broker; the real binary moves out of PATH.
- Prompt no longer names the broker variable.


Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-18 01:13:37 +02:00
cynarAI
d962e5e7f4
Fix brokered git calls being rejected before reaching GitHub (#211)
The computer's git and gh shims passed a literal "--" into the JSON argv
(python -c keeps it in sys.argv), so every brokered clone arrived as
"git -- clone" and the broker refused it. Agents then fell back to real
git with the broker run token as a password, which GitHub rejects.

- Drop the stray "--" from both shim payloads.
- Route git through the broker even behind -c/-C/--no-pager options.
- Share one broker client that falls back to the default gateway for
  computers created before host.docker.internal was mapped, and says
  plainly when the broker is unreachable or refuses a request.
- Broker normalizes GitHub SSH remotes and pasted credentials to plain
  HTTPS and points a rejected stored token at reconnecting GitHub.
- Prompt says plain git clone works and the run token is not a GitHub token.


Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-17 18:04:02 +02:00
cynarAI
a36b45b791
Let computer git reach GitHub through the broker with working credentials (#210)
* Let computer git reach GitHub through the broker with working credentials

The broker URL handed to computers pointed at the supervisor's loopback
address, which a container cannot reach, and host git ignored the
integration token for HTTPS remotes. Computers now get the host through
host.docker.internal, host git receives the token via a one-off
credential helper, and clone paths and the working directory map from
the computer onto the host workspace.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Serve the GitHub broker to computers on a Linux host-run supervisor

A loopback-bound supervisor is unreachable from containers on Linux, so
it also serves the broker routes alone on the default bridge gateway,
which only containers on the same host can reach.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-17 16:38:25 +02:00
cynarAI
ff7e95a081
Find connected-app tools from queries written in words (#209)
* Find connected-app tools from queries written in words

Catalog search matched the query only as one substring, so "search
repositories" or "get file" found nothing for search_repositories and
get_file_contents and the agent concluded GitHub had no tools. Underscores,
hyphens and spaces now compare equal, a query matches when every word starts
a word of the tool, and a miss returns the source's tool names.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Bump Expo SDK 57 packages to the patch versions expo install expects

expo install --check now expects @expo/ui 57.0.25, expo 57.0.30 and
expo-router 57.0.28, which turns the mobile typecheck red on every branch.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-16 20:32:55 +02:00
cynarAI
0b3ce11df8
feat: recruit library agents into project conversations and scan project folders (#206)
* media: check ffmpeg filter support before subtitle operations

* sandbox: handle synchronous spawn errors safely in desktop sandbox

* Voice-first streaming, non-blocking follow-ups, quiet routines and chat UX

Why: questions blocked the chat, follow-up messages had no acknowledgement, message actions sat beside the bubble, and voice turn-taking added latency.

What: waiting runs accept steering messages and the bot marks each claimed follow-up as seen; unclaimed follow-ups show as queued. Reaction, copy and reply controls sit under each message. Streaming voice (VAD endpointing, barge-in that stops the run, clause-based TTS), an optimistic input queue, procedural earcons and delta-based quiet routines were added. Onboarding can be skipped straight to the main conversation. Dual-surface layout and project pickers are included. Status colors use semantic tokens.

Tested: unit suites for core, adapters, api and web pass, typecheck and Biome are clean. Browser end-to-end tests were not run locally.

* Update translation catalogs

Re-extracted catalogs for the new and moved strings; the catalog sync test passes.

* Add project plan and test readiness notes

Notes from the milestone work: feature inventory, test infrastructure and test readiness. No code changes.

* fix(ci): align scale guards, space deletion, computer toggle, and mobile expo deps

* fix(ci): restore space deletion guard, browser toggle, and run-steps contracts

* fix(ci): restore model resolution errors, group stage, and onboarding flow

* fix(ci): restore in-app-browser close button, disable stop button while sending, and sync lingui catalogs

* fix(ci): address web e2e failures for bot-crud, choice cards, message quote, and voice onboarding

* test(web): wait for avatar and rig visibility in oo-avatar motion spec

* test(web): wait for persisted message IDs in message-hover-actions pagination spec

* feat: recruit library agents into project conversations and scan project folders

* test(web): expect repo description in GithubRepoPicker onChange

* Onboarding: always drop the local voice when the voice step is skipped

Skipping waited only for the latest voice load and disconnected only
when the client saw the connect succeed. An earlier or client-failed
connect could still persist the local credential, so a user who
skipped voice was left with one. Wait for every load and always
disconnect.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs

* Shell: hide the optimistic copy once the transcript shows the send

A message sent while the bot works streamed into the transcript before
its send request resolved, so the same text showed twice until the
optimistic bubble was removed. Drop an optimistic message as soon as a
persisted user message with the same text appears.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-15 14:44:53 +02:00
cynarAI
ca869561ac
Voice-first streaming, non-blocking follow-ups and chat UX (#205)
* media: check ffmpeg filter support before subtitle operations

* sandbox: handle synchronous spawn errors safely in desktop sandbox

* Voice-first streaming, non-blocking follow-ups, quiet routines and chat UX

Why: questions blocked the chat, follow-up messages had no acknowledgement, message actions sat beside the bubble, and voice turn-taking added latency.

What: waiting runs accept steering messages and the bot marks each claimed follow-up as seen; unclaimed follow-ups show as queued. Reaction, copy and reply controls sit under each message. Streaming voice (VAD endpointing, barge-in that stops the run, clause-based TTS), an optimistic input queue, procedural earcons and delta-based quiet routines were added. Onboarding can be skipped straight to the main conversation. Dual-surface layout and project pickers are included. Status colors use semantic tokens.

Tested: unit suites for core, adapters, api and web pass, typecheck and Biome are clean. Browser end-to-end tests were not run locally.

* Update translation catalogs

Re-extracted catalogs for the new and moved strings; the catalog sync test passes.

* Add project plan and test readiness notes

Notes from the milestone work: feature inventory, test infrastructure and test readiness. No code changes.

* fix(ci): align scale guards, space deletion, computer toggle, and mobile expo deps

* fix(ci): restore space deletion guard, browser toggle, and run-steps contracts

* fix(ci): restore model resolution errors, group stage, and onboarding flow

* fix(ci): restore in-app-browser close button, disable stop button while sending, and sync lingui catalogs

* fix(ci): address web e2e failures for bot-crud, choice cards, message quote, and voice onboarding

* test(web): wait for avatar and rig visibility in oo-avatar motion spec

* test(web): wait for persisted message IDs in message-hover-actions pagination spec

* Onboarding: always drop the local voice when the voice step is skipped

Skipping waited only for the latest voice load and disconnected only
when the client saw the connect succeed. An earlier or client-failed
connect could still persist the local credential, so a user who
skipped voice was left with one. Wait for every load and always
disconnect.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs

* Shell: hide the optimistic copy once the transcript shows the send

A message sent while the bot works streamed into the transcript before
its send request resolved, so the same text showed twice until the
optimistic bubble was removed. Drop an optimistic message as soon as a
persisted user message with the same text appears.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-15 14:08:31 +02:00
cynarAI
2073b50c94
test: stabilize chart-links E2E after Plot warmup before routes (#204)
Main CI failed Web E2E on a blank chart (no browser errors) when route
handlers were registered before the fixture finished lazy-loading Plot.
Warm the fixture first, and retry once via reload inside expectChart.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-15 12:05:14 +02:00
cynarAI
20421e6d58
Log which fields break an RPC output contract (#207)
An 'Output validation failed' response was swallowed by the RPC error logger because it is an ORPCError, so the failing procedure and fields never reached the logs. Log those cases with the procedure and the field paths, without the payload.


Claude-Session: https://claude.ai/code/session_01LFxDxzmAy9mJnKWoBrCtRB

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-15 00:35:50 +02:00
cynarAI
557f8486cd
Bump Expo SDK 57 packages to the patch versions expo install expects (#208)
The mobile typecheck runs expo install --check, which started failing on main once Expo published newer SDK 57 patch releases. Only the eleven flagged packages move.


Claude-Session: https://claude.ai/code/session_01LFxDxzmAy9mJnKWoBrCtRB

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-14 23:58:55 +02:00
cynarAI
f14b112cdd
Broker GitHub Integrations token for computer git/gh (#203)
* Broker GitHub integration token for computer git/gh

Use the Integrations GitHub MCP secret for supervisor broker sessions
so home and project computers can clone private repos without PAT prompts.
Add rakazo git/gh shims, push policy, deploy image rebuild, and tests.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* ci: retrigger product checks after runner timeout

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* ci: run product change detection on GitHub-hosted runners

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* style: format deployment-github-token

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* ci: self-hosted change detection with timeout and fail-open gate

Keep Detect product changes on evoo-ci with a 10-minute cap, continue-on-error
filtering, and a fail-open product flag when detection fails. Downstream jobs
and Product CI treat a failed or timed-out changes job as full product impact.
Add job timeouts on remaining self-hosted ci jobs.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix: continueRun tests and broker home path guard

- Skip broker workspace resolution when computer homeKey is absent
- Add shared continue-run Prisma/home/secretStore test stubs
- Update computer PATH expectation and evo-deploy command sequence
- Remove unused executor imports flagged by Biome

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* style: Biome format and import order for broker CI

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix: compose YAML, typecheck for broker stubs and GitHub hint

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* ci: retrigger Product CI after compose and typecheck fixes

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* ci: retry Product CI queue

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* ci: give self-hosted Web E2E a 45m job timeout

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* ci: extend Web E2E job timeout to 60m on self-hosted runners

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix: keep github-broker-token off the core browser barrel

Export broker HMAC helpers only from @rakazo/core/node/github-broker-token so
Vite does not pull node:crypto into the web client and break Playwright signup.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-11 22:58:48 +02:00
cynarAI
22fa2e1606
Accept more argument shapes for connected-app tools and name what is missing (#202)
Catalog execute calls now merge parameters flattened next to an empty
arguments object and unwrap parameters nested under keys like parameters
or args, unless the tool itself takes that key. A failed validation lists
the required and received keys so the agent can correct its call, and the
prompt shows the expected call shape and says connected apps need no token.


Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-11 00:33:27 +02:00
cynarAI
eb777d63ee
Let the agent use GitHub tools and git on its computer (#201)
Catalog execute calls now accept arguments sent as a JSON string or flattened
next to the id, the computer image ships git and curl, and the evo deploy
rebuilds that image when its sources change.


Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-10 19:49:20 +02:00
cynarAI
d6835fe0b0
Keep schedule_create for later or repeated work (#200)
A live check showed the agent answering a business-idea question by creating a schedule that would run the analysis later. The schedule_create description now says never to use it for work the user wants now, however long.
2026-10-10 19:07:31 +02:00
cynarAI
42eb420545
Agent replies without thinking aloud or tool names; chart-links E2E names its failure (#199)
A run of the real prompt against the local model showed reasoning written as chat text, internal tool names in a capabilities answer, and only a data fetch when asked to build a tool. The working principles now say that everything written is shown, to describe abilities in plain words, and that a request for a tool means building one.

The chart-links spec now waits for the chart or its error and reports browser errors, so the recurring 90-second timeout names its cause. The ZIP upload spec matches the file chip exactly, since the agent reply can quote the name.
2026-10-10 17:24:34 +02:00
cynarAI
f3b47cb7cd
Tell the agent what it can reach and how to work reliably (#198)
Agents denied a connected GitHub and did not know they could install
skills: the prompt said "No plugins are connected yet." whenever only
installed tool sources existed, and the skill catalog never mentioned
skill_install. Connections and skills were also bound to the space they
were set up in, so a project never saw them.

- Name every connected app from the tools the run actually received,
  including lazy catalogs and how to reach them.
- Tool sources and skills follow the user into every space; a skill in
  the current space wins a name clash.
- Add shared working principles (do the work, verify before claiming,
  keep it simple, build a missing tool as a tested script plus skill).
- Rewrite the skill catalog in plain words, mention skill_install, clip
  long descriptions and count skills past the limit.
- Routine runs learn that nobody is waiting and history is not shown;
  schedule prompts must be self-contained.


Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-10 15:33:57 +02:00
cynarAI
4cd9468b6f
Install skills from a pasted GitHub link (#197)
A new skill_install agent tool reads every SKILL.md behind a public github.com
repository, folder or file link and saves each as one of the user's own,
editable skills. Only GitHub's API and raw hosts are contacted; invalid files
and name clashes are skipped and reported.


Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-10 14:38:04 +02:00
cynarAI
4585d3b0ec
Add Second opinion, Ship and Founder builtin skills (#196)
* Add Second opinion and Founder builtin skills

Second opinion has a fresh reviewer approve the plan and inspect the build,
so whoever writes never approves their own work. Founder tests a business
idea with simulated roles and a crowd of 100 simulated buyers and ends in a
business plan card with a verdict.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Add Ship builtin skill for phased software work

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-10 13:51:03 +02:00
cynarAI
3469165c92
Connect GitHub from a token alone (#195)
* Connect GitHub from a token alone, without a vendor account

Integrations only reached GitHub through Composio or Pipedream, which a
self-hosted instance does not have. A built-in GitHub row now opens
GitHub's prefilled token form, and the pasted token connects GitHub's
own MCP server through the existing installed-connector path. Web and
mobile.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Sync web catalogs for the GitHub connect strings

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-10 13:18:44 +02:00
cynarAI
797906dde2
Allow repository ZIP uploads up to 5 GiB (#194)
* Allow ZIP uploads up to 5 GiB and stream them onto Docker computers

Repository archives could not be picked at all: application/zip was not an
allowed attachment type. ZIPs are now allowed with their own 5 GiB cap,
upload as a stream, and reach the bot's Docker computer through a new
streamed supervisor write that commits only after the body ended cleanly.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Use a disallowed type for the invalid-attachment check now that ZIP is allowed

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-10 12:35:32 +02:00
cynarAI
81c4bfd7e3
web: move the 1:1 Oo status into the chat header (#193)
The strip under the header of a running 1:1 chat repeated what the header
already carried (a "Present" pill plus a persona line). The header's second
line now shows what the Oo is doing (Working, Thinking, waiting for input,
needs attention, or a delegation), and the bot's role while it rests. The
stage only appears in an empty chat and no longer repeats the name.


Claude-Session: https://claude.ai/code/session_01H3buE7EyYCfz8nYccLCcct

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-10 09:55:01 +02:00
cynarAI
a746783c48
ci: keep every job on internal runners without GitHub Actions storage (#192)
Runs used GitHub's artifact storage and cache service, which ran out of
the monthly included quota and blocked publishing. Caches and job
handoffs now stay on the self-hosted runner host, the desktop release
hands builds over through its draft release, and Windows waits for a
self-hosted runner instead of a GitHub-hosted one. A test enforces it.


Claude-Session: https://claude.ai/code/session_01R8L4XgqjAemFR2remB5hVY

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-10 07:44:16 +02:00
cynarAI
634b3f823b
ci: stop image publish legs from cancelling each other (#191)
Every main push since the concurrency change cancelled two of the three
image builds: the build job's concurrency group named only the ref, so
the app, updater and computer legs of one run shared it and cancelled
each other, and the publish job never ran. Main has not published an
edge image since. The build and publish groups now name the matrix leg.

The Playwright report publisher also read the jobs of a run's latest
attempt, so a re-run already in progress made it fail on an empty
conclusion. It now reads the attempt that finished.


Claude-Session: https://claude.ai/code/session_01R8L4XgqjAemFR2remB5hVY

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-10 06:50:07 +02:00
cynarAI
6ace14c3c8
Projects: one sidebar row with icon and color, coordinator overview (#190)
Each project is now a single sidebar row with its own icon and color that
opens the project's coordinator conversation. The whole row drags to
reorder. The row menu gains Customize for name, icon and color. Inside
the coordinator conversation, an overview lists the project's agent
threads that need the user, are running, or recently finished.


Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-10 03:32:40 +02:00
cynarAI
1c8d13a837
Open an existing project instead of creating a same-named twin (#189)
Creating a project whose name a member already uses (from the dialog, the
agent's create_space tool or the onboarding scan) now returns that project,
so the sidebar no longer lists two projects with one name. The conversation
picker lists the main agent first.


Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 19:58:18 +02:00
cynarAI
5cf9d679b4
Fold a run's step messages behind its latest message (#188)
* Fold a run's step messages behind its latest message

Long tool runs posted every step as its own bubble, so the answer drowned in
narration and a summary written before saving a file appeared twice. Web and
mobile now fold a run's text-only step messages behind one "N steps" line
above the run's latest message. The thread snapshot also stops replaying live
narration that was already posted, which showed steps again after a refresh.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Keep a run's last step visible above a card

A card such as the peer-message receipt has no words of its own, so folding
the narration into it hid everything the bot said. Steps now fold only into
a written answer or the live row; before a card the last step stays visible.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 19:32:21 +02:00
cynarAI
dd177f7ef5
Name 1:1 senders, reach the computer from a group, translate stage lines (#187)
* Name 1:1 senders after their bot and reach the computer from a group

In a 1:1 chat bot messages fell back to the label "evOo" because only group
members were looked up; they now use the bot's name. The main conversation
and project chats show the computer button and open their lead bot's computer.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Translate the stage's collaboration lines

Subagent, handoff, delegation and peer-reply lines on the stage were English
in every language. They now go through the catalog, with German filled in.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 18:31:49 +02:00
cynarAI
83d2106633
Quieter chat chrome: no repeated names, no English working line, no tool names (#186)
* Name a run's chat once when its group carries the bot's name

A project's main conversation is named after its lead bot, so Today
showed "Chief · Chief". Web and mobile now share one title helper that
drops the repeated name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Drop the "1:1 with" line under a conversation's title

The stage header repeated what the chat already shows, such as
"1:1 with Chief" under a project's name. The title and the phase pill
remain.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Let the stage's translated thinking lines speak while a bot works

A running bot raised a "working" awareness event whose English line,
such as "Chief is working on your request.", replaced the translated
thinking lines and added an "In progress" strip next to the phase pill.
The strip now only shows collaboration.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Ask bots to describe their steps without naming tools

Long runs showed lines such as "I'm using browser_navigate" in the chat.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Announce the stage phase as status and keep following after a tap

The working strip used to be the only status region while a bot worked; the
stage's phase pill now carries that role. A tap at the latest message no
longer stops the transcript from following new messages.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 17:48:18 +02:00
cynarAI
f720683496
Unstick chats waiting on the user and show them where the user looks (#185)
* Resume a waiting run after takeover even when its execution lease is gone

When resuming a run failed while it waited for the user, the run stayed
waiting_takeover but lost its execution lease. Taking over then bound no
run, so handing control back resumed nothing and the chat hung at the
computer. Takeover now binds the newest leaseless waiting run.

The stage also counted waiting runs as work and showed "working" while
the bot waited on the user; it now listens instead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Tell every bot it is an agent in evOo, not the model behind it

Local models introduced themselves as the assistant product they were
trained as and even asked for that product's login. The identity block
now names evOo, and the fallback prompts say evOo instead of Rakazo.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Show runs waiting on the user in Today from every space

Today and its badge listed only runs of the selected space, while the
sidebar shows chats from all spaces. A chat waiting on the user in the
home space therefore showed "needs you" in the sidebar and "All quiet"
in Today. The activity list now spans every space the user belongs to,
carries each run's space, and opening a run switches to that space on
web and mobile.

The runs-list Postgres suite was not part of the integration run; it is
now, with a case for a waiting run in another space.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Keep a project's main conversation to its own asks

Every main conversation showed the inbox of all the user's spaces, so a
takeover waiting in the home chat sat above the composer of every
project. Only the home space's main conversation collects other spaces
now; a project shows its own items.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Also resume a waiting run that sits behind an older run's inactive lease

Inactive execution leases stay as fencing tombstones, so a run that lost
its lease can wait behind an expired lease of an earlier run. Takeover
now looks for the bot's waiting run whenever the lease is neither busy
nor bound to a waiting run. Journey 4f covers both the released and the
stale lease.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Translate the voice awareness labels and the computer card's state

The voice strip showed hardcoded English labels such as "In progress",
and the computer card showed its stored "Needs you" state untranslated
in German chats.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 16:52:26 +02:00
cynarAI
4b814c9a17
Greet with the header's ev-eyes wordmark and a calmer welcome type scale (#184)
Welcome and sign-in now show the same animated wordmark as the sidebar header
("ev" plus the Oo eyes) instead of the teal face. The welcome tagline, subline and
buttons drop to the shared title and body sizes.


Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 16:13:38 +02:00
cynarAI
45d3f1ae11
Equal message sizes and one evOo logo on the way in (#183)
* Read agent replies at the size of the user's own messages

Chat markdown set its own 17px reading size scaled for phones, so agent replies
rendered at about 19px while the user's bubbles on the same phone read at 16px.
The markdown now takes size and leading from the bubble around it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Show the same evOo logo on the welcome and sign-in screens

The welcome screen showed the eyes-only mascot over an "EvOo" title, while sign-in
and sign-up showed the teal face (the favicon and app icon) through a CSS override of
the old two-pill markup. Both screens now render the teal logo directly, the title reads
"evOo", and the CSS override is gone. The welcome lines balance so no word sits alone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Run the autopilot auto-merge job on Linux runners again

The macOS runners that joined the evoo-ci pool have no gh on the runner's PATH, so the
job died with "gh: command not found" whenever one of them picked it up. The job takes
seconds, so it gains nothing from the extra runners.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 13:33:44 +02:00
cynarAI
8ae3764b05
Speak German through a local Piper server, since Kokoro has no German voices (#182)
Kokoro-FastAPI ships English, Spanish, French, Hindi, Italian, Japanese,
Portuguese and Chinese voices only. The German presets pointed at voice
ids no Kokoro server has, so German users either got English voices or
picked a voice that failed to speak.

The local voice now also lists the German voices whose Piper model is
installed (PIPER_BASE_URL) and speaks them through Piper's /synthesize
as WAV. Thorsten, the clearest model, is the German default. Mobile
plays WAV as well as MP3, and the E2E voice stub stands in for Piper.


Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 11:55:51 +02:00
cynarAI
6c0fba8cc1
E2E: longer waits for slow fixture renders, shorter Playwright artifact retention (#174)
* fix(mobile): align Expo SDK 57 patch deps for install --check

Bump @expo/ui, expo, expo-constants, expo-linking, expo-modules-core,
expo-notifications, expo-router, and expo-updates to the versions
expo install --check expects after upstream patch releases.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* ci(e2e): harden flaky waits and trim Playwright artifact uploads

Chart fixture reloads and shell readiness can exceed 30s on loaded
self-hosted runners. Align timeouts with the rest of the suite and wait
for gallery mount before counting species.

Stop uploading per-test trace folders to Actions (HTML report only) with
1-day retention so quota errors stay non-blocking.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* evo-deploy: probe health before checking the timeout

With a short timeout the clock could tick past it before the first
probe, so a healthy service was reported as timed out. Probe first,
then check the clock.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 10:56:32 +02:00
cynarAI
b1b76a846e
ci: isolate buildx builders on shared evo Docker hosts (#178)
* ci: isolate buildx builders for parallel publish-server-image builds

Parallel matrix jobs on shared evo runners raced on ~/.docker/buildx current
builder; one job's setup-buildx cleanup then stopped another job's BuildKit during
cache export. Use per-job DOCKER_CONFIG, stable builder names, and wire build-push
to steps.buildx.outputs.name.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* test: avoid biome noTemplateCurlyInString in publish workflow asserts

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 10:11:39 +02:00
cynarAI
f07d17e927
Oo computer browser: live Chromium, cowork, AgentCursor (#179)
* feat(web): Oo computer browser panel with cowork and local preview

Wire the in-app browser panel to the active Oo's live Chromium via the
computer screen stream, page-browser navigation, and interactive cowork
input with AgentCursor. User browser actions become system meta lines in
thread context; localhost preview remains as Lokale Vorschau.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix(i18n): sync Lingui catalogs for Oo browser panel

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Fix the Oo browser's lint and E2E failures

The shared-browser overlay is a focusable input surface, so it gets the
application role and a translated label. The local preview focuses the
address field again when no page is open, which the split out of
InAppBrowser had dropped. The Oo browser spec opens the panel by its exact
name, because the signed-up user's name also contains "Browser", and reads
the user action from the RPC body's json envelope.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 10:10:25 +02:00
cynarAI
81468063ff
Add LAN dev helper for iPhone against local Mac API (#181)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 10:10:22 +02:00
cynarAI
b8781a0081
Merge pull request #170 from cynarAI/cursor/ci-queue-reduction-197c
ci: reduce self-hosted queue depth (concurrency + path filters)
2026-10-09 08:49:51 +02:00
cynarAI
a2c7182ed6
Side panel Routinen: list toggles, hide when empty (#180)
* feat(web): routine list toggles and hide empty Routinen section

Show Routinen in the Oo side panel only when the bot has routines; remove
the panel create control. Each list row gets an active switch with optimistic
updates via routines.update. E2E covers visibility, toggle persistence, and
updates existing routine specs to create via RPC like chat.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix(web): sync lingui catalogs after routine panel UI change

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix(web): refresh routine list optimistically after save

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* test(e2e): wait for routine list after RPC create in golden path

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* style: format golden e2e after routine list wait

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix(web): show sign-up while session lookup is pending

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-07 23:41:11 +02:00
cynarAI
e4b995ec14
Production readiness Phase 2: DE profile facts, mobile audit, route matrix E2E (#176)
* feat(web): Phase 2 production readiness — locale, mobile, E2E matrix

Localize onboarding focus facts by profile locale (DE assertion).
Add mobile overflow/touch audits, deep-link matrix, extended console
checks, and E2E for empty project, session unavailable, and teaching
composer. Safe-area and Heute header layout on 390px viewports.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix(e2e): stabilize Phase 2 specs for mobile shell and empty project

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix(api): satisfy typecheck for focusFactValue

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix(api): type-safe focusFactValue lookup

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix(api): narrow focus fact locale key

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix(api): use FocusOptionId union for fact lookup

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* test(web): align Shell header control assertions with touch targets

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* style(web): format desktop header control test

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix(web): stabilize Phase 2 E2E (settings i18n, empty project, chart plot load)

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix(web): empty project E2E simulates pre-start bootstrap; relax module recovery wait

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix(web): format empty-project E2E; stabilize chart-links cold Plot load

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* test(web): run chart-links E2E serially to reuse warmed Plot module

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* style(web): format chart-links E2E

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-07 15:26:09 +02:00
cynarAI
97787a0272
Production readiness phase 1: journey audit, fixes 1–4, E2E (#175)
* Production readiness phase 1: journeys doc, onboarding persistence, teardown, chat UX

- Add docs/PRODUCTION-READINESS.md with journey matrix and defect backlog
- Persist onboarding draft in session storage across reload
- Localize onboarding discovery main-bot role; German Chief of Staff string
- Destroy team sandboxes and home folders when deleting an account
- Keep composer/transcript visible during queued runs; extend send watchdog
- Add Playwright specs: reload persistence, DE intake, queued progress, console guard

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Fix CI: sync Lingui catalogs, onboarding name draft, E2E stability

- Localize Chief of Staff in discovery shell; sync all locale PO files
- Propagate Oo name to onboarding draft while typing
- Adjust DE intake, queued-run, and console-error E2E specs

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Format chat-queued-progress E2E spec

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix(api): scope account-delete sandbox teardown to owned spaces

Only destroy team sandboxes and homes for spaces the deleting user
owns with no remaining members or bots. Guard home removal under
dataDir/homes. Add unit and postgres journey coverage.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* chore: apply biome formatting

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-07 11:25:39 +02:00
cynarAI
0e1036b2ff
First-run onboarding UX: naming, locale, voices, Heute route, mobile drawer (#171)
* Fix first-run onboarding UX: naming, locale, voices, Heute route, mobile drawer

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Fix biome import order and formatting

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Fix biome formatting on OoStage and E2E specs

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Fix E2E specs for onboarding name step and Heute reload route

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Fix German onboarding heading matcher in E2E

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Fix German signup E2E, Escape menu stacking, and Heute rhythm click

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Make onboarding E2E helpers locale-aware for German UI

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Fix completeOnboarding language skip for Skip · locale label

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Use language-step Skip · locale button in completeOnboarding

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Fix German E2E: Nachricht composer, voice loading wait, Heute rhythm

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Stabilize Heute reload URL and German voice E2E assertions

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Fix Heute bootstrap redirect and German onboarding voices

When Kokoro lists no locale-matched voices, onboarding now uses offline
presets instead of showing English-only options. Shell bootstrap skips
bot redirects while the user is on /app/heute.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Relax German voice E2E name matching for voice labels

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Harden Heute route and German voice onboarding E2E

Rebased onto main with Expo SDK 57 typecheck fixes. Voice options expose
stable test ids; Shell keeps /app/heute across bootstrap and redirects
legacy /app/heute bot URLs. Heute coach E2E asserts URL after reload.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Format Shell after Heute route helper

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-06 20:15:36 +02:00
cynarAI
ba89574c83
Align Oo avatar eye spacing with evoo-logo (#173)
* Align Oo avatar eye spacing with evoo-logo overlap ratio

Derive small-eye placement from evoo-logo.svg center spacing (~0.94 of
r_big + r_small) for the shared rig, EvooMascot brand lock, and face crops.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Format brand-lock spacing test for Biome

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Align mobile Expo packages for install --check in CI

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* ci: retrigger after flaky chart-links E2E

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-06 18:56:40 +02:00
cynarAI
f8b4ce4b6d
Größere Header- und Sidebar-Icons (Web + iPhone) (#172)
* Increase header and sidebar icon scale on web and mobile

Raise shared --icon-sm/md/lg tokens (~25%) for web shell chrome, widen
chat header action controls, and align Expo home/thread header glyphs
with 44pt tap targets.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* ci: retrigger checks after flaky evo-deploy test

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-06 15:36:54 +02:00
Cursor Agent
50e56a2658
ci: run unit tests on Linux; stabilize isToday test across time zones
Revert Unit tests to evoo-ci Linux for ffmpeg/libass coverage. Use a
previous-day instant that stays on the prior calendar date in UTC+14…UTC-12.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-06 05:30:30 +00:00
Cursor Agent
629fcf6b41
ci: spread portable jobs across macOS runners; keep GHCR handoff tags
Drop Linux label from gh-only and Node gate jobs so air/mini can pick them up.
Replace mapfile in autopilot for macOS bash 3.2. Remove unverified GHCR tag
DELETE step; document that ci-* handoff aliases are harmless.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-06 04:17:36 +00:00
Cursor Agent
28cdb33e57
ci: set DOCKER_CONFIG in step env (runner context unavailable on job env)
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-06 04:14:26 +00:00
Cursor Agent
58823d7187
style: format publish-workflow test for biome
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-06 03:53:42 +00:00
Cursor Agent
5a406b16c3
ci: grant pull-requests read for paths-filter on PRs
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-06 03:36:07 +00:00
Cursor Agent
de26578778
ci: isolate GHCR docker login and delete handoff tags safely
Use per-job DOCKER_CONFIG under RUNNER_TEMP so login-action logout on
shared self-hosted runners cannot strip another job's credentials. Drop
broken buildx imagetools rm; untag ci-* handoff labels via the registry
distribution API after publish.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-06 03:32:05 +00:00
Cursor Agent
f8a5ff8e43
ci: fix paths-filter action pin on ci workflow
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-06 03:29:32 +00:00
Cursor Agent
c50f003744
ci: pin paths-filter to the v3.0.2 commit GitHub resolves
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-06 03:29:12 +00:00
Cursor Agent
eeec01cd0e
ci: cut queue depth with path filters and smarter concurrency
Skip heavy jobs for docs-only and out-of-scope paths while keeping
Product CI as the branch-protection gate. Cancel superseded PR image
validation runs; on main cancel stale builds but serialize publish so
attest/manifest steps are never interrupted.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-06 03:17:48 +00:00
cynarAI
6fc734b0ae
ci: pass publish-server-image digests via GHCR handoff tags (#169)
Build jobs already push each architecture by digest. Tag those manifests
with ephemeral ci-${run_id}-${image}-${arch} refs instead of uploading
digest marker files to Actions artifact storage, which is blocked by the
account-wide quota.

Publish resolves handoff tags back into RUNNER_TEMP digest markers, then
assembles semver/edge tags and attestation as before. Handoff tags are
removed after publish with continue-on-error.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-06 05:06:23 +02:00
cynarAI
a55ed44394
One product: evOo updates and name, phone actions, German on mobile, working account deletion, consistent settings (#168)
* Pull updates from evOo instead of the upstream Rakazo repository

The in-app server updater, the image-based compose files and the desktop
auto-updater still pointed at elie222/rakazo, so updating an evOo install
would have pulled Rakazo. They now follow cynarAI/evOo-local, where the
publish workflow already pushes the images. The disabled Rakazo production
deploy job is gone from CI; evo deploys itself.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Call the product evOo everywhere people see its name

The desktop setup window and menu, account emails, the iOS app name and
permission prompts, sign-in headings, messages bots post into connected chat
apps, the web fetch user agent, issue templates and the marketing site still
said Rakazo, or EvOo. They now say evOo. The marketing site no longer calls
evOo an alternative to itself.

Internal identifiers (package scopes, env vars, bundle ids, the desktop
productName that keys its data folder and keychain entry) stay as they are.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Open conversation actions by holding a row on a phone

Pin, move to folder, mark unread, duplicate, archive and delete only opened
on right-click, which iOS Safari never fires. Holding a conversation or a
folder header now dispatches the same contextmenu event, so the existing menu
opens under the finger; Android's own long-press event is not doubled and the
lift does not also open the conversation.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Give small chrome icons a 44px touch area on phones

The browser and computer toggles, the activity bell, the create button and the
section plus buttons were 18 to 36 px targets. On touch screens each now gets
an invisible 44px hit area without changing how it looks; the create button
uses the same plus icon as the rest of the sidebar instead of a text glyph,
and the computer toggle gets an accessible name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Add German to the mobile app

German speakers saw the phone app in English because the mobile UI only
knew English, Simplified Chinese, and Russian. The app now ships a German
catalog covering every mobile chrome string, follows a German device
language (de, de-DE, de-AT, de-CH, ...), and offers Deutsch in the Account
language picker.

Strings that also exist in the web German catalog reuse its wording so
web and phone say the same thing; the rest follow the same terms and the
informal "du".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Use the evOo sign-in strings in the German phone catalog

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Make account deletion work and give settings one section style

Deleting an account posted to the auth endpoint without a JSON content
type, so the server refused it and the button did nothing. The privacy
page now deletes through the auth client and returns to sign-in. It also
drops the claims it could not verify, the export button that only showed
an alert, and its own padding, and shows where the data and the computer
live. Notifications and media settings use the same SettingsSection.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Use status tokens, the shared switch and one German glossary

Voice stage dots, the onboarding progress bar and the Voice First button
used raw emerald, violet, cyan and rose; they now use foreground, success
and destructive like the rest of the app. The routine editor's hand-built
switch is the shared Switch. German says Unterhaltung for threads and
Space consistently on web and phone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Expect the evOo setup copy in the desktop e2e specs

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Translate the voice preview label in the German phone catalog

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Wait for the main thread URL without waiting for every resource

openMainBotThread already navigated with domcontentloaded; its URL check
then waited for the full load event, which can exceed 20 s on a busy
runner. The composer check that follows covers readiness.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-06 00:38:31 +02:00
cynarAI
b78eff1f7b
Run on the deployment's local models without any model choice, preselect Claude Opus 5.5 otherwise, explain why dictation is unavailable (#151)
* Recommend Claude Opus 5.5 by bumping the Pi model catalog

The pinned pi-ai 0.85.1 had no Claude Opus 5.5, so the connect flow fell
back to Opus 5. pi-ai and pi-agent-core 0.87.1 add it, and the keyless
recommendation now preselects it.

The new catalog names router aliases "Qwen Max Latest (Qwen3.8 Max)";
those now read "(auto-updates)" like the other alias labels, so no
picker entry claims to be "latest".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD

* Name HTTPS as the cause when dictation is unavailable, and translate it

Over plain HTTP on a LAN address, Safari hides the microphone and speech
recognition, so voice mode said the browser had no dictation, in English
inside a German UI. Dictation now checks for a secure context first and
says to open the address with https://. Its other errors go through
Lingui too, and Safari's blocked recognizer gets a clear message instead
of "Dictation failed: not-allowed".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD

* Serve a fake Kokoro voice server in the E2E harness

Onboarding's voice step connects to the local Kokoro server on its
default port, so the shell E2E failed on any machine where nothing (or a
misbehaving server) listened there: the failed request shows up as a
browser console error. The harness now starts an offline stand-in and
points KOKORO_BASE_URL at it, so the suite no longer depends on the
runner host's own voice server.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD

* Show only the recommended sign-in until a model is connected

With no model connected, "Connect model" opened the full model settings:
provider list, tiers and a model picker, even though the recommendation
was already preselected. Until something is connected, the Models panel
now shows just the preselected model with its sign-in and API key field;
"Other providers" reveals the full settings.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD

* Give the second chart-links test the same cold-load allowance as the first

Each Playwright test runs in a fresh browser context, so the second test's
first navigation re-fetches the lazily imported Plot chunk just like the
first test's warm-up does. On a loaded CI runner that exceeded the default
20 s expect timeout.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD

* Use the recommended model as the Anthropic deployment default

A deployment that sets only ANTHROPIC_API_KEY and PI_DEFAULT_PROVIDER=anthropic
now runs Claude Opus 5.5, the same model the connect flow preselects, instead
of an older default.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD

* Run every space on the deployment's local models without a connect step

A deployment with PI_DEFAULT_PROVIDER=local and RAKAZO_LOCAL_MODELS set still
asked every user to connect a model, because only OpenRouter and Anthropic
counted as a deployment model. Configured local models now do, keylessly.

RAKAZO_LOCAL_TIERS assigns local models to the tiers the agent delegates to,
for spaces that have not mapped their own, and a RAKAZO_LOCAL_MODELS entry can
name its own server (id=url) when the models run behind different endpoints.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD

* Hide model choices when the deployment routes models itself

When the server provides its own models, the system picks and routes
them per task, so Settings no longer offers Models and bot settings no
longer offer Model and Thinking, on web and mobile.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD

* Wait for the workspace pane's width transition before measuring it

The pane animates its width over 300 ms, so a single boundingBox read
right after it opens can land mid-transition on a slow runner.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD

* Allow chart re-renders inside the chart-links loops to take longer

Each navigation in the loops renders Observable Plot again. On a loaded
CI runner that exceeded the default expect timeout even after the
warm-up, so the per-iteration visibility checks get 30 s.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 23:18:40 +02:00
cynarAI
9062717eee
fix(evo-deploy): validate restart config before mutating checkout (#167)
Move restart-mode prerequisites (systemd unit default, tmux, compose file,
script hook, EVO_SYSTEMD_SCOPE) ahead of git fetch/install/build/migrate.
Explicit systemd with no EVO_SYSTEMD_UNIT falls back to EVO_SYSTEMD_AUTO_UNIT.
die() after checkout triggers rollback; rollback marks in-progress to avoid loops.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-05 22:39:46 +02:00
cynarAI
eb9c8847a4
Brand videos with a logo overlay and an intro before the cut (#161)
* Export videos for named platforms and check each file against its limits

Exports only knew aspect ratios, so a cut could still be rejected or
degraded on upload: too long for the platform, over its size limit, or
far off its loudness. export_video now takes platforms (TikTok,
Instagram Reels and Feed, YouTube Shorts and YouTube, Facebook Reels,
LinkedIn, X, Pinterest, Snapchat Spotlight), up to six targets with the
presets. Each platform is rendered to its frame with the export's fit,
its frame rate cap, a bitrate cap that also keeps the file under the
size limit, and its loudness target in one loudnorm pass.

Every render is probed and measured afterwards. A file over the size
limit is encoded once more at a bitrate scaled from what it averaged,
and audio more than 2 LU off the target gets one linear loudness pass.
The result is stored on the output as a pass, warn or fail check with
language-neutral issue codes. A platform the video is too long or too
short for is not rendered: the card lists it with the reason, the
other targets still render, and the bot is told to trim first.

The limits come from the earlier profile research, with its sources,
review date and unverified flag; content, disclosure and caption rules
are left out because they need inference or a review flow. Preset
definitions move to their own module so the platform schemas can use
their aspect ratios without an import cycle.

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

* Add remove_silence step to edit_video

Talking-head clips are mostly cut by hand to drop pauses, and the bot had
no way to find them: it could only keep segments it guessed. The new
remove_silence step finds every pause quieter than threshold_db (default
-35 dB) and at least min_silence long (default 0.6 s), keeps padding
(default 0.12 s) on each side so word onsets survive, and cuts the rest
through the existing keep-segments graph, so picture and sound stay in
sync.

Steps run in order, so silence is detected on the timeline the steps
before it leave: the planner gets an analysis mode that sends that audio
through silencedetect into a null output, and ametadata prints the marks
to a file in the job directory. The detected speech is handed back to the
planner per step. Nothing silent makes the step a no-op; nothing left
fails with a clear error. At most 200 pieces are kept, bridging the
shortest pauses first, so the filter graph stays a sensible size.

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

* Offer platforms in the media card's export menu and show their checks

The card's Export menu now lists the platforms first, by brand name and
untranslated, then the aspect ratios after a separator; the platform an
output was made for is left out, like its own shape. A platform export
shows the platform's name under the player, or, when its check did not
pass, one line for the first issue instead ("Too long for TikTok (max
10:00)"), in the warning or destructive color. Skipped platforms get
the same kind of line, and they stand in for the error of a job that
skipped every platform, so the card does not say the same thing twice
in two languages.

The E2E spec seeds a platform export with a failed check and a skipped
platform, and captures the card and the open menu.

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

* Add analyze_video to find cut points for the bot

To cut a social video well the bot has to know where the cuts fit, and it
could not look at a video at all. analyze_video is a read-only media job:
one ffmpeg pass prints scene changes (scene score above 0.3, one per cut),
pauses (silencedetect with the remove_silence defaults) and short-term
loudness (ebur128) to files in the job directory, and a transcript made
from the video by transcribe (an SRT whose parent is the video) adds when
each line is spoken. The job stores nothing; its card just says done.

The result reaches the bot through the existing media job path: the job
always wakes the bot that started it (the tool has no continue_after) and
the summary carries a short legend plus compact JSON. The runtime's
summary limit grows to 12,000 characters and the summary is fitted into
it: at most 200 entries per list, then every list shrinks evenly, keeping
the strongest cuts, the longest pauses and the start of the transcript,
with omitted counts. The wake prompt no longer claims the card shows the
result when a job made no files.

Jobs read derived artifacts through a new fetchDerivedArtifact, scoped to
the job's user like fetchArtifact. A migration admits the operation in the
media_jobs check and adds the tool to existing Studio specialists that
still edit video. The web card gets an icon; no new copy.

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

* fix(media): keep one skipped entry per platform on the card

A job that recovers after a crash runs again and reports the same
skipped platforms; the newest reason per platform now replaces the
earlier one instead of listing it twice.

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

* chore(db): add the widened media job operation check as NOT VALID and validate it separately

Follows the repository's migration convention: the constraint commits
without scanning media_jobs, and a second migration validates it with a
lock that lets reads and writes continue.

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

* Brand videos with a logo overlay and an intro before the cut

edit_video gains an overlay step that lays a PNG, JPEG or WebP image over the
video at a corner or the center, sized to a share of the frame width, with
optional opacity and time range; tall frames keep the 9:16 safe areas clear.
concat takes at: "start" so an intro can go before the video. The image is
authorized like other inputs, read with its own demuxer and refused above
8192 pixels on a side. New Studio specialists are told to brand with the
user's logo and intro.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 22:39:21 +02:00
cynarAI
459d7c99e4
Pick the voice from a carousel in the app language, local voice only (#166)
* Pick the voice from a carousel in the app language, local voice only

Voice settings now list only the voices that speak the app language
(German shows German voices, English shows English ones) as a horizontal
carousel: one card per voice, a play button next to the name, tap to
make it the active voice. Changing the app language moves the saved
voice to that language. The hosted voice providers (ElevenLabs, OpenAI,
Cartesia, Fish Audio) are removed; Kokoro on the server is the only
voice. E2E runs against a stub Kokoro server so the carousel is covered.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KxoSHAyzMXvdtwcDJZcn57

* test(e2e): measure the workspace panel width after its transition settles

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KxoSHAyzMXvdtwcDJZcn57

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 22:20:06 +02:00
cynarAI
b2fb517286
Simplify the memory settings: what is remembered first, provider setup under Advanced (#164)
The memory pane opened with an untranslated provider description, a
provider picker, a scope toggle and API key fields, and pushed what the
agents actually remember to the bottom. Most people never connect a
semantic-recall provider, so that setup now sits behind a collapsed
Advanced disclosure (open while a provider is connected), and the
remembered documents come first.

- Drop the provider descriptions, the "Shared documents" heading and
  the per-document revision label.
- Replace the Isolated/Shared toggle pair with one "Share between bots"
  switch.
- Remove the unused standalone dialog mode of the memory pane.
- German: call the section "Gedächtnis" so it no longer reads like
  reminders ("Erinnerungen").


Claude-Session: https://claude.ai/code/session_01LWzKXyE6uDmmmzuod4oXNQ

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 20:30:30 +02:00
cynarAI
0122fef89e
Drop the transcript heading and empty-state hint in the conversation view (#163)
The encounter view showed an uppercase "Transcript" / "Conversation record"
label and "Start the conversation — <bot> is on stage above." before any
message. Both repeat what the screen already shows, so they are removed.


Claude-Session: https://claude.ai/code/session_01Vyov34VJPqjo6YgiGHnuEL

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 19:50:18 +02:00
cynarAI
3b2c46dd36
feat(media): remove pauses and find cut points in videos (#159)
* Export videos for named platforms and check each file against its limits

Exports only knew aspect ratios, so a cut could still be rejected or
degraded on upload: too long for the platform, over its size limit, or
far off its loudness. export_video now takes platforms (TikTok,
Instagram Reels and Feed, YouTube Shorts and YouTube, Facebook Reels,
LinkedIn, X, Pinterest, Snapchat Spotlight), up to six targets with the
presets. Each platform is rendered to its frame with the export's fit,
its frame rate cap, a bitrate cap that also keeps the file under the
size limit, and its loudness target in one loudnorm pass.

Every render is probed and measured afterwards. A file over the size
limit is encoded once more at a bitrate scaled from what it averaged,
and audio more than 2 LU off the target gets one linear loudness pass.
The result is stored on the output as a pass, warn or fail check with
language-neutral issue codes. A platform the video is too long or too
short for is not rendered: the card lists it with the reason, the
other targets still render, and the bot is told to trim first.

The limits come from the earlier profile research, with its sources,
review date and unverified flag; content, disclosure and caption rules
are left out because they need inference or a review flow. Preset
definitions move to their own module so the platform schemas can use
their aspect ratios without an import cycle.

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

* Add remove_silence step to edit_video

Talking-head clips are mostly cut by hand to drop pauses, and the bot had
no way to find them: it could only keep segments it guessed. The new
remove_silence step finds every pause quieter than threshold_db (default
-35 dB) and at least min_silence long (default 0.6 s), keeps padding
(default 0.12 s) on each side so word onsets survive, and cuts the rest
through the existing keep-segments graph, so picture and sound stay in
sync.

Steps run in order, so silence is detected on the timeline the steps
before it leave: the planner gets an analysis mode that sends that audio
through silencedetect into a null output, and ametadata prints the marks
to a file in the job directory. The detected speech is handed back to the
planner per step. Nothing silent makes the step a no-op; nothing left
fails with a clear error. At most 200 pieces are kept, bridging the
shortest pauses first, so the filter graph stays a sensible size.

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

* Offer platforms in the media card's export menu and show their checks

The card's Export menu now lists the platforms first, by brand name and
untranslated, then the aspect ratios after a separator; the platform an
output was made for is left out, like its own shape. A platform export
shows the platform's name under the player, or, when its check did not
pass, one line for the first issue instead ("Too long for TikTok (max
10:00)"), in the warning or destructive color. Skipped platforms get
the same kind of line, and they stand in for the error of a job that
skipped every platform, so the card does not say the same thing twice
in two languages.

The E2E spec seeds a platform export with a failed check and a skipped
platform, and captures the card and the open menu.

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

* Add analyze_video to find cut points for the bot

To cut a social video well the bot has to know where the cuts fit, and it
could not look at a video at all. analyze_video is a read-only media job:
one ffmpeg pass prints scene changes (scene score above 0.3, one per cut),
pauses (silencedetect with the remove_silence defaults) and short-term
loudness (ebur128) to files in the job directory, and a transcript made
from the video by transcribe (an SRT whose parent is the video) adds when
each line is spoken. The job stores nothing; its card just says done.

The result reaches the bot through the existing media job path: the job
always wakes the bot that started it (the tool has no continue_after) and
the summary carries a short legend plus compact JSON. The runtime's
summary limit grows to 12,000 characters and the summary is fitted into
it: at most 200 entries per list, then every list shrinks evenly, keeping
the strongest cuts, the longest pauses and the start of the transcript,
with omitted counts. The wake prompt no longer claims the card shows the
result when a job made no files.

Jobs read derived artifacts through a new fetchDerivedArtifact, scoped to
the job's user like fetchArtifact. A migration admits the operation in the
media_jobs check and adds the tool to existing Studio specialists that
still edit video. The web card gets an icon; no new copy.

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

* fix(media): keep one skipped entry per platform on the card

A job that recovers after a crash runs again and reports the same
skipped platforms; the newest reason per platform now replaces the
earlier one instead of listing it twice.

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

* chore(db): add the widened media job operation check as NOT VALID and validate it separately

Follows the repository's migration convention: the constraint commits
without scanning media_jobs, and a second migration validates it with a
lock that lets reads and writes continue.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 19:16:03 +02:00
cynarAI
3291a32d37
Show About you inside General settings (#165)
The profile had its own settings tab although it is a few rows of personal
details. It now sits as one card under Account in General, hides itself
while empty, and the separate tab is gone.


Claude-Session: https://claude.ai/code/session_017gHYZ4c9Twex73v4ZasmMJ

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 18:36:54 +02:00
cynarAI
23d70971f8
feat(media): export videos for named platforms and check each file against its limits (#158)
* Export videos for named platforms and check each file against its limits

Exports only knew aspect ratios, so a cut could still be rejected or
degraded on upload: too long for the platform, over its size limit, or
far off its loudness. export_video now takes platforms (TikTok,
Instagram Reels and Feed, YouTube Shorts and YouTube, Facebook Reels,
LinkedIn, X, Pinterest, Snapchat Spotlight), up to six targets with the
presets. Each platform is rendered to its frame with the export's fit,
its frame rate cap, a bitrate cap that also keeps the file under the
size limit, and its loudness target in one loudnorm pass.

Every render is probed and measured afterwards. A file over the size
limit is encoded once more at a bitrate scaled from what it averaged,
and audio more than 2 LU off the target gets one linear loudness pass.
The result is stored on the output as a pass, warn or fail check with
language-neutral issue codes. A platform the video is too long or too
short for is not rendered: the card lists it with the reason, the
other targets still render, and the bot is told to trim first.

The limits come from the earlier profile research, with its sources,
review date and unverified flag; content, disclosure and caption rules
are left out because they need inference or a review flow. Preset
definitions move to their own module so the platform schemas can use
their aspect ratios without an import cycle.

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

* Offer platforms in the media card's export menu and show their checks

The card's Export menu now lists the platforms first, by brand name and
untranslated, then the aspect ratios after a separator; the platform an
output was made for is left out, like its own shape. A platform export
shows the platform's name under the player, or, when its check did not
pass, one line for the first issue instead ("Too long for TikTok (max
10:00)"), in the warning or destructive color. Skipped platforms get
the same kind of line, and they stand in for the error of a job that
skipped every platform, so the card does not say the same thing twice
in two languages.

The E2E spec seeds a platform export with a failed check and a skipped
platform, and captures the card and the open menu.

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

* fix(media): keep one skipped entry per platform on the card

A job that recovers after a crash runs again and reports the same
skipped platforms; the newest reason per platform now replaces the
earlier one instead of listing it twice.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 16:49:18 +02:00
cynarAI
e55207f5d2
Open the side panel without booting the computer (#162)
Showing the right side panel (computer preview and routines) used to boot a
stopped computer right away. A boot is slow and heavy on the host, and most
of the time the panel is opened for routines, not the screen. The panel now
only reconnects a computer that already runs; a resting computer shows its
Open action without hover, and Open still boots it. A stopped computer is no
longer treated as needing maintenance.


Claude-Session: https://claude.ai/code/session_01TafdPTJdWxTYt9SYbDuKQ8

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 15:44:51 +02:00
cynarAI
568d37c48c
Merge pull request #157 from cynarAI/claude/composer-mic-send-toggle-jbg7wv
Composer: mic sits in the send slot until there is something to send
2026-10-05 15:38:32 +02:00
cynarAI
a6e611736b
fix(evo-deploy): prefer the fetched remote branch when resolving the deploy ref (#160)
A manual deploy.sh run without a SHA resolved the local 'main' first,
which lags behind origin/main in the deploy checkout, and redeployed the
old commit. Resolve <remote>/<ref> first and fall back to the ref itself
for SHAs and tags.


Claude-Session: https://claude.ai/code/session_01YMUEBB4pr4TtGSMhEAKonM

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 14:46:20 +02:00
cynarAI
4ab0d25224
Merge pull request #156 from cynarAI/claude/project-thread-vljeb4
Media library: owner folders that bots can search and import from
2026-10-05 13:22:46 +02:00
Claude
c98eb6e843
Drop the stage's own voice button now that the composer carries the mic
The empty-chat stage offered "Start talking" / "Set up voice" above the
transcript, a second entry to the same voice conversation the composer's
mic now opens. Keep the composer as the single way in.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XcoERsCTbvjqDzE8qC82Wo
2026-10-05 10:51:03 +00:00
cynarAI
738811dbe7
Read the web app at 16px base size on phones (#155)
Text sizes now follow a --font-scale variable that is 1 on wider screens and
8/7 below 768px, so the 14px base text reads at 16px on phones (Safari stops
zooming into fields at that size) and every other size grows by the same
ratio. One-off pixel sizes in the shell move onto the shared text tokens so
they scale too. The stage subtitle now truncates inside the screen instead
of spilling past both edges.


Claude-Session: https://claude.ai/code/session_01PFEDgbyJm12ExL3Kn4Dk8B

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 12:42:30 +02:00
Claude
f3e438444a
Put voice in the composer's send slot until there is something to send
An empty draft shows the mic where the send arrow sits; it opens the
voice conversation as the separate Voice button did. As soon as the draft
holds text, a chip or an attachment, the slot turns back into Send. This
frees the width the extra Voice pill took on small screens.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XcoERsCTbvjqDzE8qC82Wo
2026-10-05 08:52:06 +00:00
Claude
044669edfc
Add a media library: owner folders that bots can search and import from
The deployment owner adds folders of photos and videos in the media settings. A per-folder
background job indexes them (relative path, kind, size, capture date, dimensions, duration),
re-indexes every six hours, and drops files that are gone. The owner's own runs get two tools:
search_media (words in folder and file names, kind, capture date range, newest first) and
import_media (copies one file into the conversation as an artifact). Files are never changed;
hidden entries and symlinks are skipped, and an import must resolve inside its folder.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs
2026-10-05 08:45:30 +00:00
cursor[bot]
404a1d1782
Merge pull request #154 from cynarAI/cursor/cleanup-self-hosted-runner-6331
Run artifact cleanup on self-hosted Linux
2026-10-05 08:29:45 +00:00
Cursor Agent
3a5ab6ef44
Run artifact cleanup on self-hosted Linux runners
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-05 08:29:20 +00:00
cursor[bot]
9930d295f2
Merge pull request #153 from cynarAI/cursor/trigger-artifact-cleanup-6331
Auto-run artifact cleanup on workflow update
2026-10-05 08:28:50 +00:00
Cursor Agent
354c59f9e3
Run artifact cleanup when the workflow file changes on main
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-05 08:28:41 +00:00
cursor[bot]
d7e4cb91d6
Merge pull request #152 from cynarAI/cursor/cleanup-actions-artifacts-6331
Add manual Actions artifact cleanup workflow
2026-10-05 08:28:20 +00:00
Cursor Agent
478a92f1a5
Add workflow_dispatch job to delete stale Actions artifacts
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-05 08:27:47 +00:00
cynarAI
b3f20967db
Preselect the recommended model while none is connected (#150)
Without a model key the settings opened on the keyless deployment default
(OpenRouter). The connect flow now opens on Claude Opus 5, so a new user
only adds a key or signs in. A connected credential, an owner-chosen
default, or a deployment key still decides as before.


Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 10:17:33 +02:00
cynarAI
2f8d7175f6
ci: stop docker build records and cap artifact retention (#149)
Disable build-push-action build record uploads in publish-server-image;
they are large and unused by the digest publish flow. Digest artifacts
already expire after one day. Set seven-day retention on desktop e2e
screenshots so CI debug uploads do not inherit the org default.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-05 08:34:55 +02:00
cynarAI
f3fb07e41f
fix(web): keep focus in place after removing a media API key (#148)
Remove unmounts itself once clicked, so focus fell to the body and the
settings dialog pulled it back to its popup a frame later. Whatever field
the user (or a test) focused in that frame lost focus again; in CI the
cleared transcription URL was still sent on the next save. Remove now
moves focus to the key field before it disappears.


Claude-Session: https://claude.ai/code/session_01UfVneQG6488JGLiHYAc9RZ

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 06:16:36 +02:00
cynarAI
ca57ebda85
feat(mobile): pick a time for the autopilot and see the night on home (#147)
The attach sheet's autopilot now offers "Until a time" with the native
time picker from Expo UI: Android's clock dialog, an iOS wheel in a sheet
with Start. The home list opens with the autopilots that run, wait or
ended within a day, as Today does on web, and each row opens its thread.
The attach and until menus move to the shared action sheet, so Android
pages them instead of dropping buttons past three.


Claude-Session: https://claude.ai/code/session_01UmSCnoCgXqBp2fU3vwz4o1

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 05:35:47 +02:00
cynarAI
54658ee689
feat: autopilot progress, composer start and morning summary (#146)
The autopilot's start line counts its steps and folds open to the latest
checkpoints, and its result reads as a card. The composer's plus menu
starts an autopilot with the draft as the order and a choice of until
done, tomorrow morning or a time. Today lists the autopilots that run,
wait or ended within a day, and their step runs no longer show one by
one. Autopilot runs send no completion or failure push; only a pause
notifies.


Claude-Session: https://claude.ai/code/session_01UmSCnoCgXqBp2fU3vwz4o1

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 04:45:49 +02:00
cynarAI
c69f00e891
feat(avatars): bots offer a new Oo look from chat (#145)
update_bot takes a look (oo1: value), validated and normalized in core.
A suggested look renders as a "New look" card with the avatar preview on
web and mobile; accepting applies it to the bot.


Claude-Session: https://claude.ai/code/session_01EKuME5CgL1ddGGN7LGvtBj

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 04:03:10 +02:00
cynarAI
460eede226
feat: autopilot that keeps working on an order until it is done (#144)
An autopilot per thread continues the user's order run after run, with
no time limit. Each run reports a checkpoint; after three runs without
progress or three failed runs it pauses with Continue and Stop. Start,
pause, stop and finish show as quiet lines in the conversation.
Approvals during an autopilot run are parked as suggestions, and the
temporary approval rules end with the autopilot.


Claude-Session: https://claude.ai/code/session_01UmSCnoCgXqBp2fU3vwz4o1

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 03:56:45 +02:00
cynarAI
63578bb645
feat(avatar): dress up any Oo in the Atelier on web and mobile (#143)
The Atelier replaces Avatar Studio: pick a species, colour, eyes,
glasses, hats, neckwear, arms and temperament, with every option drawn
as your own Oo wearing it. Onboarding's "Design your Oo" now offers
species looks per vibe and opens the Atelier for the rest; mobile bot
settings carry the same editor instead of a bare colour row, so picking
a colour there no longer throws away the rest of the look.


Claude-Session: https://claude.ai/code/session_01EKuME5CgL1ddGGN7LGvtBj

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-05 03:20:05 +02:00
cynarAI
6fd136e944
feat: suggestions the user accepts or dismisses with one tap (#142)
Bots can offer an exact tool call as a card. Nothing runs until the user
taps Accept; the call then runs as its own approved run of the bot it was
meant for, and the card shows the outcome. Open suggestions also wait in
the inbox and expire after a week.


Claude-Session: https://claude.ai/code/session_01UmSCnoCgXqBp2fU3vwz4o1

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 02:19:31 +02:00
cynarAI
55df4a6439
feat(avatar): let Oos react to what their bots do (#141)
A bot's Oo now cheers for a moment when its run finishes, the stage Oo
follows the pointer and squishes when tapped, and chat headers show the
speaking bot's own Oo instead of the generic mascot. Small avatars in
lists only animate while working or waiting so long lists stay calm.


Claude-Session: https://claude.ai/code/session_01EKuME5CgL1ddGGN7LGvtBj

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 01:51:37 +02:00
cynarAI
5f3aa8f3fe
feat: recipe cards that scale servings and cook step by step (#140)
Claude-Session: https://claude.ai/code/session_01UmSCnoCgXqBp2fU3vwz4o1

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 01:13:52 +02:00
cynarAI
fb5165bb26
fix: keep integrations and live screen working on a LAN origin (#139)
Opening the web app over the Mac's Wi-Fi address (an insecure context)
crashed Connect because crypto.randomUUID is unavailable there, and the
MCP OAuth redirect, Composio/Pipedream return and noVNC link all pointed
at WEB_ORIGIN (127.0.0.1). The API now derives these URLs from the
request's browser origin when it is loopback or a private network, the
same hosts CORS already trusts, and falls back to WEB_ORIGIN otherwise.


Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 00:57:27 +02:00
cynarAI
7db3a56fe8
feat(avatar): give mobile bots the same Oo face as web (#138)
Native avatars now draw the shared Oo rig with react-native-svg instead
of a visor with two equal eyes, so the Oo eyes look the same on every
surface. The robot/organic avatar setting is gone from web and mobile
because every bot is now an Oo; uploaded pictures show on native too.


Claude-Session: https://claude.ai/code/session_01EKuME5CgL1ddGGN7LGvtBj

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 00:36:46 +02:00
cynarAI
47988602e2
feat: let bots answer with cards the user taps (#137)
Claude-Session: https://claude.ai/code/session_01UmSCnoCgXqBp2fU3vwz4o1

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 00:23:59 +02:00
cynarAI
9f4991abe0
feat(avatar): draw every bot as a living Oo on web (#136)
Bots now render as an Oo rig built once in core: nine species, styled
Oo eyes, eyewear, headwear, neckwear, arms and moment props, with a
level of detail per size. A shared animation frame loop moves only
visible avatars and holds still under reduced motion.


Claude-Session: https://claude.ai/code/session_01EKuME5CgL1ddGGN7LGvtBj

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-04 22:40:12 +02:00
cynarAI
dbf37f86a8
feat: show routines the bot sets up from chat as one quiet line (#133)
* feat: show routines the bot sets up from chat as one quiet line

When someone mentions something recurring in passing ("every Sunday I plan the
week"), the bot now creates the routine right away and the conversation shows a
single line with the schedule, Edit and Undo, on web and mobile.

- New `event` message block for routine lines, kept in history so later turns
  know what was set up or removed.
- schedule_create reuses an active repeating routine of the same name instead
  of adding a duplicate, caps a bot at 50 active schedules, and falls back to
  default times when none was given.
- Removing a routine (Undo, the editor, or schedule_cancel) marks its lines
  removed via the new routines.noticeMessageId column.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UmSCnoCgXqBp2fU3vwz4o1

* test: give the routine notice test a complete job publisher

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UmSCnoCgXqBp2fU3vwz4o1

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-04 20:36:53 +02:00
cynarAI
d6dd2bef5e
feat(avatar): add the oo1 avatar value for customizable Oo looks (#135)
A bot's look is stored in its `color` string, which today only holds a
colour, an optional classic shape index or an uploaded picture. The
customizable avatars (species, eye styling, accessories, temperament)
need a richer value that still travels everywhere `color` already goes:
bot rows, group members, mentions and the inbox.

- `@rakazo/core` gains `oo-avatar`: an `oo1:` key=value codec that writes
  only non-default fields, reads every legacy value unchanged, rejects
  malformed values, and exposes `ooAvatarColor` for plain CSS consumers.
- The rig's eye anchors keep the Oo trademark (big eye = small eye × φ),
  and lenses scale with their eye so glasses stay asymmetric; the shared
  asymmetry ratio now comes from core.
- The API rejects malformed `oo1:` values on bot create and update.
- `parseBotAvatar` reads through the codec, so an `oo1:` value already
  renders with its colour and shape before the new renderer lands.
- The agent cursor and the mobile avatar used the raw value as a CSS
  colour and broke on `#hex::shape_N`; they now use the decoded colour.


Claude-Session: https://claude.ai/code/session_01EKuME5CgL1ddGGN7LGvtBj

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-04 18:56:34 +02:00
cynarAI
ad054a7586
fix(web): land new accounts in onboarding and show how to connect a model (#134)
Signing up refreshes the session, which briefly routes /onboarding to
/sign-in without a return path; the sign-in route then sent the new
account to /app. The shell's own redirect to onboarding was skipped
whenever a focus refresh landed before the bootstrap response, so a
fresh account could stay on an empty Today view.

/onboarding now sends signed-out visitors to /sign-in?next=/onboarding
and sign-in honours it, and the shell redirects an account without
any bots or conversations regardless of which refresh landed first.

Without a connected model nothing can reply, so the composer shows a
"Connect model" action that opens model settings instead of a send
error after the fact.


Claude-Session: https://claude.ai/code/session_019HKhzQ65ctE5qmkiF9Apxo

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-04 18:34:53 +02:00
cynarAI
f7b0cfb341
fix(supervisor): treat blank computer limits as unset (#132)
.env.example ships RAKAZO_COMPUTER_MEMORY, _CPUS and _PIDS_LIMIT with no
value. Loaded as empty strings, they bypassed the ?? defaults and the
startup validation threw, so the supervisor crashed on a fresh setup.


Claude-Session: https://claude.ai/code/session_012UBam2saiShpLy5o2cy3h8

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-04 17:22:56 +02:00
cynarAI
68b1e63c46
fix(web): render the create-bot modal outside the side panel; tidy Shell leftovers (#131)
The centered Create new Bot modal was rendered inside the side panel's
condition, so the empty states that open it (nothing selected, or a
project without bots) only showed it while a panel happened to be open.
It is a portal to the document body, so it now renders after the panel.

- The project drag grip's accessible name goes through Lingui (German
  "Ziehen zum Umsortieren") instead of a hard-coded English label.
- Drop the project drag state that was set but never read, and imports
  Shell.tsx no longer uses.


Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-04 16:43:50 +02:00
cynarAI
e928600c98
fix(evo-deploy): prefer systemd over compose in auto restart mode (#130)
In auto restart mode, prefer an active evoo-local user unit over compose so a missing POSTGRES_PASSWORD does not roll the deploy back.
2026-10-04 15:34:40 +02:00
cynarAI
f395ed9a23
refactor(web): split Shell.tsx into shell components and hooks (#129)
* 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

* feat(media): add media contracts, schema and provider interfaces

Groundwork for image and video generation, video editing and transcription.
Nothing calls it yet; the media runner, providers, tools and UI build on it.

- Contracts: video, audio and subtitle MIME types, per-kind size caps for
  streamed and generated artifacts, video/audio message blocks and the live
  media card block, media operations, job statuses and output presets
  (9:16, 16:9, 1:1, 4:5, 4:3, 21:9).
- Core: attachment kinds include video and audio, artifacts become video or
  audio blocks, and agent history keeps media artifact ids so later turns
  can refer to them.
- Schema: artifacts record width, height, duration and the artifact they
  were made from; media_jobs tracks generations, edits and transcriptions.
- Adapter kit: provider-neutral image generation, video generation and
  transcription interfaces, the media job context operations run in, and
  the MediaRuntime that tools use to start jobs.

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(media): transcribe speech on any OpenAI-compatible server and write subtitles

Transcription has to run on the user's own hardware, and the only speech-to-text
path so far was the OpenAI voice key. Self-hosted whisper servers (whisper.cpp,
faster-whisper based servers, vLLM) all speak the OpenAI transcriptions
protocol, so one provider covers them.

- createOpenAiCompatibleTranscriptionProvider posts a multipart upload to
  {base}/audio/transcriptions asking for timed segments, falls back to plain
  JSON for servers that reject verbose_json, reads text/plain bodies, and
  reports failures in words. It reuses the address-checked fetch and URL
  guards of the other OpenAI-compatible adapters, caps uploads at 25 MiB and
  responses at 8 MiB, and builds the multipart body by hand because the
  undici fetch behind the address checks does not serialize the global
  FormData class.
- An in-process server emulator (OpenAI/whisper.cpp, plain-JSON-only and
  text-only flavours) and an in-process provider back an offline
  conformance suite, plus one loopback test through the real fetch.
- toSrt/toVtt in core turn segments into SubRip and WebVTT, wrapping long
  segments into cues of at most two 42-character lines that share the
  segment's time, and escaping WebVTT cue text.

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

* feat(media): add the Studio specialist the main bot hands media work to

Media work (cutting for a format, subtitles, exports) needs editing judgement
and a focused tool set, so the main bot should be able to delegate it to a
visible specialist instead of doing it itself.

- ensureStudioBot creates "Studio" once per user, next to the main bot so it
  can join the main conversation and read its media, tagged media, with
  editor instructions (hook first, tight pacing, 9:16 safe areas, subtitles
  for muted playback) and a restricted builtin allowlist: every media
  operation plus files, questions, memory and web lookup. It has a thread,
  so the main bot's specialist directory lists it. The spawn key carries the
  user id so members of a shared space each get their own.
- A Studio the user archived or deleted stays gone. Deleted bots now record
  their spawn key in bot_deletions so idempotent creation can tell.
- Postgres suite for concurrency, shared spaces, archive and delete.

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

* feat(media): add the ComfyUI provider, default workflows and emulator

Image and video generation run on the user's own GPU server. ComfyUI is the
common self-hosted backend, so this adds a provider for its HTTP API that the
generation operations can call.

- Core: API-format workflow templates with a small placeholder format
  ({{prompt}}, {{width}}, {{seed}}, ...) that is validated and filled without
  evaluating anything. Defaults: text-to-image and image-to-image on core nodes
  for any SD-family checkpoint, and text/image-to-video on ComfyUI's core
  LTX-Video nodes; a README lists the placeholders and model files.
- Adapters: createComfyUiProvider submits a workflow, polls its history,
  uploads input images and downloads outputs through the address-checked fetch
  used for other self-hosted model servers, with timeouts, capped reads and
  readable errors. A custom workflow from settings replaces the default it
  matches. Cancelling dequeues or interrupts only its own prompt. GPU status
  counts memory ComfyUI can free for itself as available.
- Contracts: input schemas for generate_image, generate_video and edit_image.
- Tests: an in-process ComfyUI emulator, a provider-neutral generation
  conformance suite and provider tests, all offline.

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

* feat(media): stream artifacts over HTTP with Range support

Videos and audio can be hundreds of megabytes. Artifacts only travelled as
base64 inside RPC calls, capped at 10 MiB and held whole in memory, so media
could neither be uploaded, played with seeking nor produced by media jobs.

- ArtifactStore gains stat, createReadStream (inclusive byte ranges),
  putStream (size cap enforced while writing, real SHA-256) and an optional
  localPath so ffmpeg can read stored files in place. LocalArtifactStore
  implements them with the same safety as before (O_EXCL, O_NOFOLLOW, 0600)
  and now validates space ids and keys before building paths.
- storeArtifactStream / storeArtifactFromFile store a stream or a job's
  output file as an artifact row, with width, height, duration and parent,
  and remove the blob again if the row cannot be written.
- GET /api/artifacts/:id serves an artifact with Range/206/416, ETag,
  inline disposition for media and attachment otherwise (safe RFC 6266
  filenames), nosniff and same-origin CORP. Authorization matches
  artifacts.get: the owner's artifact of an active bot thread or group,
  with membership of the artifact's space (media elements cannot send the
  space header; an explicit header must name the same space).
- POST /api/artifacts takes a raw streamed body with the target and name in
  the query, authorized like artifacts.create, with the MIME allowlist and
  the per-kind caps checked from Content-Length and again while writing.
- The API CSP allows media from the app origin and blob URLs.
- Web: artifactUrl(artifactId) and uploadArtifactFile for the UI.

Tests cover the streaming store, range parsing, filename sanitizing, the
file helper, and a Postgres suite for both routes: ranges, caching, other
users, shared-space members, other spaces, archived threads, signed-out
requests and upload limits including chunked bodies.

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

* feat(media): media job runtime, media queue and media settings

Media operations (generation, edits, transcription) need a runner that works
outside the agent turn, keeps a card in the conversation current and never
lets two processes put work on one GPU. Operations land in later commits; this
is the runtime they register with.

- Background job `media.run { jobId }` on a named `media` queue. Jobs carry an
  optional queue: the Graphile publisher passes it as queueName and the
  in-memory driver runs each queue one job at a time, in order.
- `createMediaRuntime` implements MediaRuntime (capabilities, start, get,
  cancel) over a list of `MediaOperationRegistration`s and runs jobs:
  start validates the input, creates the job and posts the bot's media card in
  one transaction (idempotent per tool call through an operation key), then
  enqueues it. The runner claims a fenced lease (an advisory lock keeps one
  live lease at a time, so the GPU stays single even when the queue is
  bypassed), renews it while polling for cancellation, writes progress and
  outputs into the card, defers with backoff when the GPU is short of memory
  or the operation asks to wait, bounds deferrals, crash restarts and run
  time, always cleans the job's temp directory and always ends the card in a
  terminal state. With continue_after the starting bot is woken in the same
  transaction that ends the job (new run trigger `media`).
- The reconciler re-enqueues jobs whose enqueue was lost, whose runner died
  or whose wait is over; a job stuck behind a dead worker's queue lock runs
  outside the queue.
- Media backends are deployment-wide settings stored encrypted as the
  `media` IntegrationProviderConfig row: `loadMediaSettings` and
  `saveMediaSettings` with the same address policy as OpenAI-compatible
  servers (no metadata hosts, public hosts only when allowed, HTTPS when a key
  is sent), a saved key never follows the settings to another server, and the
  view never returns keys. Contract in contracts/media-settings.ts.
- `createMediaOperations` is the one list both the API and the worker build
  their runtime from (empty until the operations land), and media-tools.ts has
  the shared `continue_after` schema helper for the tool definitions.
- Migration: lease, deferral, operation key and continueAfter columns.

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

* feat(media): transcription input, chunked transcripts and the Studio trigger

Groundwork for the transcribe operation and for creating Studio when it is
first needed.

- Contracts: TranscribeInputSchema (artifact id, optional language code).
- transcribeChunks uploads audio chunks in order and stitches them into one
  transcript on the original timeline; transcriptionChunkSeconds sizes
  16 kHz mono WAV chunks below the server's upload limit; transcriptSummary
  hands the bot the transcript, cut at a word.
- ensureStudioWhenMediaConfigured creates Studio once a media server
  (generation or transcription) is configured. Local edits alone do not
  count, because ffmpeg and image tools are present on most installs and a
  specialist nobody asked for would appear for everyone.

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

* feat(web): show media jobs as a live card in the conversation

Media jobs report progress and results through the media block, which the
web app did not render.

- MediaCard renders the block: operation icon, title, status (reusing the
  task card's labels, plus "Waiting for GPU" for jobs queued behind the
  GPU), a progress bar while active, the error when failed, and outputs:
  images as thumbnails with the existing lightbox, video and audio in the
  media player, subtitle and text files as file cards. Finished video and
  audio get a download link, "Export" with presets labelled by aspect ratio
  (the video's own shape left out) and "Transcribe", offered only when the
  space can run them; running jobs get "Cancel".
- MessageView renders it with one branch.
- An E2E seeds the card on a thread snapshot with real uploaded artifacts.
- artifact-url.ts and MediaPlayer.tsx are minimal stand-ins for the
  pipeline workstream's files and give way to them on merge.

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

* feat(media): edit and export video with ffmpeg

Bots can now cut, reframe and export videos for every common format without
a hosted service: ffmpeg and ffprobe from PATH do the work on the server.

- ffmpeg wrapper: PATH detection, ffprobe JSON probing (rotation, pixel
  aspect, frame rate, audio, packet scan when the container has no
  duration), runs with -progress parsing, abort and stall kills, an output
  size cap, partial-output cleanup and short errors without temp paths.
  Inputs go through the file protocol with the demuxer forced from the MIME
  type, so a crafted upload cannot be probed as a playlist that reads other
  files or URLs. extractAudio (16 kHz mono WAV) and renderPoster for
  transcription and media cards.
- A pure planner turns edit steps (trim, keep, concat, reframe, speed,
  volume, mute, subtitles, extract_audio, poster) into one filter_complex
  pass: leading cuts seek the input, appended clips are normalized to the
  frame, fps and 48 kHz stereo (silence where a clip has none), reframes
  crop, pad or blur to the presets, speed chains atempo, subtitles burn in
  from generated file names with a readable style that stays clear of the
  controls on 9:16 frames. H.264/AAC MP4 with yuv420p and faststart;
  location metadata is stripped.
- edit_video and export_video operations and tool definitions; the inputs
  are shared contracts so the media card can start exports.

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

* feat(media): stream job inputs and outputs, cover the job lifecycle on Postgres

Media files can be hundreds of megabytes, so the runner no longer buffers them:
inputs are cloned from the local artifact file when the store has one and
streamed otherwise, outputs go through storeArtifactFromFile (per-kind size cap
and MIME check while streaming; an oversized result fails the job with a short
card error).

A Postgres suite runs the runtime with fake operations: success with outputs,
posters and parent versions; input, availability, artifact and thread checks
before a job exists; tool-call replay; failure with temp-dir cleanup; outputs
outside the temp dir and sources of another user; deferral with backoff and its
budget; the GPU memory guard; cancel of a queued job and of a job running in
another process; one job per lease and one job on the GPU at a time; crash
recovery and its restart limit; reconciliation inside and outside the queue;
the continue_after wake; a cleared conversation; the run time limit; and
capabilities.

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

* feat(media): generate images and video on ComfyUI and edit images

Bots can now make images and short clips on the user's own GPU server and
rework images locally, through the media job runtime.

- generate_image: prompt, aspect preset or exact size, up to 4 images, optional
  source image for image-to-image. Sizes target what the default models are
  tuned for, and sources are fitted to that size before upload, so large
  photos cannot exhaust GPU memory.
- generate_video: prompt, aspect, duration up to 10 s, optional start frame.
- edit_image: crop, rotate, flip, resize to a size or format (crop, pad or
  blurred background), extend, convert, in that order, with sharp; an optional
  prompt repaints the result on ComfyUI. Edits keep the source as parent.
- Registrations declare the input artifacts so jobs refuse ones the user
  cannot read, and generation declares a fixed GPU memory floor (6 GB images,
  12 GB video) that a custom workflow cannot lower.
- Tool definitions reuse the operation input schemas plus continue_after.
- Saving media settings rejects custom workflows the provider could not fill.
- Tests: sharp edits on generated fixtures, handlers against the ComfyUI
  emulator, and a PostgreSQL suite running the operations through the media
  runtime (card outputs, artifact versions, foreign artifacts, GPU wait).

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

* refactor(media): share the ComfyUI JSON request helpers

Queue, interrupt and prompt requests now go through one JSON helper, so the
cancel path reads like the rest of the client.

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

* feat(media): register video operations with the media runtime

edit_video and export_video now run as media jobs: both registrations join
the shared operation list the API and the worker build their runtime from,
name every artifact they read (source, appended clips, subtitle files) so
starting a job refuses artifacts the user cannot read, and their tool
schemas use the runtime's continue_after helper.

Container deployments get ffmpeg, ffprobe and the subtitle font in the
API/worker image. Tests cover the handlers against a fake job context:
cuts, blurred 9:16 reframes with subtitles, appended silent clips, audio
extraction, single frames, batch exports with posters and progress,
rotated phone video, clear input errors and cancellation. They skip where
ffmpeg is not installed; the planner and parser tests always run.

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

* fix(media): describe media tools by what the model can rely on

The image server is the deployment's, not necessarily the user's, and
repainting an edit only works when generation is available.

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

* feat(media): media tools, media RPCs and owner-only media settings API

Bots and users need to start and follow media jobs, and the deployment owner
needs to point the deployment at their GPU and transcription servers.

- Media tools are named like their operations. A run sees one only when the
  space can run that operation (MediaRuntime.capabilities); the per-bot
  allowlist still applies. applyTool hands every media tool to one
  dispatcher (media-tools.ts) that splits off continue_after, starts the job
  under the tool call's effect key (a replay returns the same job) and returns
  at once; refusals (invalid input, unknown artifact, unavailable) go back to
  the model as tool errors.
- media.jobs.start starts an export, transcription or image edit from a card:
  the conversation is resolved like every thread procedure, the card's bot
  (or the group's lead) posts the new card, and the runtime refuses source
  artifacts outside the actor's space. media.jobs.get and media.jobs.cancel
  only see the actor's own jobs.
- media.settings.get/update are deployment-owner only, never return keys and
  answer invalid URLs with a 400 that says what is wrong.
- createApp accepts media operations as a test seam.
- Tests: a Postgres suite over the RPCs (owner-only settings, encryption,
  key retention per server, URL policy, start/get/cancel isolation between
  users, card jobs end to end) and unit tests for tool exposure and dispatch.

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

* feat(media): transcribe audio and video into subtitles as a media job

With the media runtime in place, transcription becomes an operation like the
others: bots call the transcribe tool, the job runs in the background and
the card shows the subtitle files.

- createTranscriptionOperations registers transcribe: available when a
  transcription server is configured, refuses artifacts the user cannot
  read before the job exists, and writes <name>.srt and <name>.vtt next to
  the source (a .txt when the server returns no timings). With ffmpeg the
  audio is converted to 16 kHz mono WAV and long media is transcribed in
  upload-sized ranges on one timeline; without it audio is sent as stored
  and video is refused with a clear message. A server that is down or busy
  (unreachable, 429, 502, 503) defers the job instead of failing it, since
  nothing has been stored yet. The bot gets the transcript, cut at a word.
- The transcribe tool definition uses the shared media schema helper and
  continue_after; dispatch stays generic.
- The main bot's run creates Studio once a media server is configured,
  right before the specialist directory is built.
- Dictation: when the user's voice provider cannot transcribe (or there is
  none), /api/voice/transcribe uses the self-hosted transcription server,
  and the voice status reports server dictation so the web app uses it
  instead of the browser's speech recognition. A voice provider that
  transcribes keeps being used as before.

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

* feat(web): play video and audio inline and stream uploads and downloads

Media sent in a conversation had nowhere to go: the composer capped every
file at 10 MiB and pushed it through a base64 RPC, video and audio blocks
did not render, and downloads decoded whole files in the page.

- Video and audio blocks play inline through MediaPlayer, which loads
  nothing until the player is near the viewport, sizes the frame from the
  block's dimensions (vertical clips stay within the column) and uses the
  poster when one exists. Files the browser cannot play fall back to their
  name with a download action.
- Images load straight from the artifact route; the lightbox gains a
  download action.
- File cards, the Markdown preview and downloads read from the artifact
  route instead of decoding base64.
- The composer accepts each kind up to its cap and names the computed
  limit when it skips a file; video, audio and anything above the inline
  cap upload as a raw stream, small files keep the inline RPC.
- MediaPlayer and artifactUrl are what the media card builds on.

Tests: MediaPlayer laziness, sizing and fallback, the upload helper, and an
E2E that uploads a tiny MP4 fixture (VP9, since Playwright's Chromium has
no H.264 decoder), plays it from the artifact route and captures
media-video-block, plus the image lightbox download.

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

* test(media): run transcribe through the media runtime on Postgres

Covers what the unit tests fake: the job starts from validated input with
the "Transcript" card, runs against the transcription emulator, stores the
.srt and .vtt as artifacts next to the source and lists them on the card,
and an artifact of another user is refused before a job exists.

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

* feat(media): disclose media servers in AI data sharing

Media tools and card actions send prompts, images, video and audio to the
deployment owner's ComfyUI and transcription servers. Like the embeddings
server behind built-in memory, those self-hosted endpoints are recipients in
the AI data-sharing disclosure: they appear with the model uses ("ComfyUI
(<origin>)", "Transcription (<origin>)", keyed by the full endpoint so a new
server asks again), and starting a card job asks for model consent on clients
that run the foreground check.

Also adds unit tests for the media settings store: URL normalization and
refusals, encryption, the key-free view, one decryption per saved version, key
retention per server and removing empty settings.

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

* refactor(media): trim unused ffmpeg wrapper options

Binary paths, the ffmpeg version, the probe's container name and a
configurable stall timeout had no caller; detection is cached per process,
so every helper looks the binaries up itself.

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

* fix(media): hand the transcript to the bot as quoted speech

A transcript is whatever was said in the recording, so the summary the bot
is woken with quotes it and says it is not instructions.

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

* feat(web): media settings section for the deployment owner (wip)

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

* feat(media): give agents attached media and larger bot files

Agents only received the documents of a turn on their computer and could
attach workspace files up to 10 MiB, so they could neither cut a clip a user
sent nor post the video they rendered.

- Current-turn images, video and audio are copied into the bot's workspace
  (Team Computers under bots/<botId>/) like documents. Remote computers move
  files as base64 JSON, so anything above their 12 MiB transfer cap stays in
  storage and the agent is told it by artifact id instead of failing the
  turn; local computers copy up to the kind cap.
- attach_file accepts video and audio by extension and attaches up to the
  kind cap when the store streams (still bounded by the transfer cap of a
  remote computer), stored through the shared artifact helper with a real
  SHA-256.
- The render_plot fallback that attaches a chart PNG now records the group,
  so the chart opens in group conversations.
- Agent history names image artifacts by id, like video and audio, so a
  later turn can refer to them.
- artifacts.get refuses files above 25 MiB: base64 reads hold the whole
  file in memory, and larger media is served by the artifact route.

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

* fix(mobile): show video, audio and media cards as plain rows

Mobile has no media UI yet, so video, audio and media-card blocks rendered
as empty bubbles or fell out of previews.

- Thread text and reply previews describe video and audio files and the
  media card's title and status.
- The bubble lists video, audio and a finished card's outputs as tappable
  rows that open the file like other attachments.
- Opening video or audio downloads it from the artifact route to the cache
  instead of a base64 RPC, so long recordings open without the server
  holding them in memory.

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

* fix(web): download media outputs with the forced-download artifact URL

The media card's download link uses artifactUrl's download option so a video
or audio output is saved rather than opened inline, and the E2E seeds the
thread snapshot with fresh response headers so the patched body is read.

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

* fix(api): refuse artifact uploads from untrusted origins

Uploads with a text/plain, text/csv or text/markdown body are CORS "simple"
requests that a browser sends cross-site without a preflight. The session
cookie's SameSite default already keeps it off such requests, but a
deployment that relaxes it would accept uploads from any page. The route now
applies the same origin rule as CORS before reading the session.

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): start exports, transcriptions and cancels from the media card

The card's follow-ups now reach the media job RPCs, and it only offers what
the space can run.

- media.capabilities answers which operations the actor's space can run
  (booleans only). The card asks once per minute per space, and only when it
  shows finished video or audio, so other cards cost nothing.
- Export and Transcribe call media.jobs.start with the card's message, so the
  same bot posts the new card; Cancel calls media.jobs.cancel. A refused
  start shows the server's message under the card.
- New files use top-level import type.

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

* fix(media): GPU guard for smaller cards, top-level type imports

- The GPU guard waited for free VRAM at the operation's floor even on a card
  whose total memory is below it (an 8 GB card and a 12 GB video floor), so
  such jobs deferred until their budget ran out. The requirement is now capped
  at 90% of the card's total; a card with less than half the floor fails at
  once with "The GPU does not have enough memory for this job."
- Tests pin that media tools reach a run only through the space's
  capabilities and within the bot's allowlist now that the image tool
  definitions exist.
- Type-only imports are top-level `import type` in the media runtime, tools,
  RPCs and tests (AGENTS.md).
- The ComfyUI settings test gives saveMediaSettings a store with nothing saved,
  since the save reads the previous settings before validating.

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

* test(media): run video edits and exports through the media runtime

A Postgres suite starts edit_video and export_video through the real
runtime and local artifact store: outputs become linked versions with
posters on the card, exports store one version per preset, appended clips
and subtitle files of another user are refused at start, and an
out-of-range cut fails the job with a readable error. It skips without
Postgres or ffmpeg.

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

* fix(media): abandoned handlers cannot crash the worker, sweep stale temp dirs

- A handler that ignores its abort is left behind after the grace period;
  a late rejection from it (or from the grace timer after the job settled)
  was unobserved, and the worker exits on unhandled rejections. Both sides of
  the race are now observed and the grace timer is cleared with the job.
- Ending a job removes its temp directory when no runner in this process
  holds it (leftovers of a runner that died here), and reconciliation sweeps
  directories of jobs that no longer run.
- An executor test runs a media tool end to end through applyTool: shown
  only for operations the space can run, started with the run's scope and
  effect key, continue_after split off, refused when not exposed.

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

* fix(web): offer media card follow-ups on any settled job

Failed or cancelled jobs can still carry finished outputs, so the card asks
for the space's capabilities whenever a settled job shows video or audio.

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

* fix(media): let bots title image edits in the user's language

Edit cards showed English words such as "crop · flip · extend" to every user.
edit_image now takes an optional title written by the bot in the user's
language, used for the card and the file name like edit_video. Without one,
the card falls back to the repaint prompt or to language-neutral tokens only:
sizes, ratios, degrees, arrows and format names.

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

* test(media): transcribe real clips through ffmpeg

Generated clips (nothing binary committed) go through extractAudio: a video's
audio reaches the server as 16 kHz mono WAV, a 12-second recording is
uploaded in two ranges under a small limit, and a video without sound is
refused before anything is uploaded. Skipped where ffmpeg is missing, which
may include CI runners.

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

* test(web): cover group uploads in the main conversation

The image E2E now runs in the main conversation, which is a group thread,
so the browser test covers group uploads and reads from the artifact route
as well as bot threads. Bot file attachments keep their run's operation id
for tracing, and the non-streaming attach cap is the inline cap directly.

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

* feat(media): title transcription cards with what they transcribe

A card that only says "Transcript" does not tell two transcriptions apart,
and it is English whatever the user's language.

- transcribe takes an optional title like the video operations, so a bot
  names the card in the user's language; "Transcript" stays the default.
- Transcribe on a media card passes the source file name as the title.
- The E2E seeds the card through bootstrap as well as threads/get, since the
  bot thread's first snapshot arrives with bootstrap.

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

* refactor(web): match the media player stand-in to the pipeline's props

The pipeline workstream's MediaPlayer takes artifactId, kind, name, poster
and size, without a MIME type; the card and the stand-in now use exactly
that, so its file replaces the stand-in without other changes.

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

* test(web): media settings E2E with the media-settings screenshot

The Media settings section (added in the previous web commit) lets the
deployment owner point media at their own servers: ComfyUI URL and optional
key, with the image model and custom image/video workflows folded under
Advanced, and a transcription URL with optional model and key. Saved keys are
never shown again; the key field says one is saved and offers to remove it.
Members who do not own the deployment do not see the section.

The spec fills both servers, captures `media-settings`, saves and checks the
exact update the server receives, then removes the saved key and the
transcription server; a second spec checks a member has no Media section.

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

* fix(web): size uploaded videos from their own frame

Uploaded videos carry no dimensions, so a vertical clip sat letterboxed in
a 16:9 frame. The player now takes the frame size from the file's metadata
when the block has none.

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

* fix(media): disclose the transcription server before dictation uses it

Dictation falls back to the owner's transcription server when the voice
provider cannot transcribe, so that server is a voice recipient too: it is
listed for voice use (as "Transcription (<origin>)") whenever it would take
the recording, and not when the voice provider transcribes itself.

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

* fix(web): render media card outputs with the URL-based previews

The media pipeline moved image and file previews to the artifact route, so
they no longer take a bot or group target. The media card still passed one,
which no longer type-checks; only its follow-up actions need the target.

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

* fix(api): let large uploads finish and abort stalled ones

Node's default 5-minute requestTimeout cut off a 500 MiB video upload on
any uplink slower than about 14 Mbit/s, so the video cap was unusable over
ordinary connections.

- The API server's requestTimeout is 30 minutes, enough for the largest
  upload at about 2.5 Mbit/s. headersTimeout keeps its 60-second default.
- Each streamed upload aborts when the client sends no bytes for 60
  seconds: the route answers 408, the partial file is deleted and no row
  is written, so a slow-drip client cannot hold a connection for the full
  request timeout. Time spent writing to disk does not count.
- The store and the upload guard no longer wait for a body to confirm its
  cancellation, which a stalled or hostile client may never do.
- A guard test checks that every file the executor attaches records the
  thread's group, which the render_plot chart fallback once dropped.

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

* fix(media): use snake_case keys in every media tool input

The model saw edit_video and export_video with artifact_id next to
generate_image, generate_video, edit_image and transcribe with artifactId,
sourceArtifactId and negativePrompt. Mixed conventions in one tool list invite
wrong calls, so all media inputs now use snake_case like continue_after and the
other builtin tools: artifact_id, source_artifact_id, negative_prompt,
duration_seconds. Handlers, artifact checks, the media card's transcribe
action and the tests follow. Nothing has shipped, so stored job inputs need no
migration.

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

* fix(media): act on media cards in their own space, not the selected one

The main conversation lives in the home space, but the user can have another
space selected while it is on screen, and the card's actions sent that space:
media.jobs.start resolved the conversation in the selected space and failed,
and media.jobs.get/cancel only found jobs of the selected space.

The server now takes the space from the card itself. start finds the card's
message in one of the actor's own threads (the conversation named by botId or
groupId), requires the actor to still be a member of that thread's space, and
resolves the conversation and the source artifacts there. get and cancel find
the actor's job by id and require membership of the job's space.
media.capabilities takes an optional job id and answers for that job's space;
the card asks through its job and shares the answer per conversation. Every
other case is a 404, as before.

A Postgres test runs a card in the home-space main conversation while another
space is selected (start, get, capabilities, cancel) and checks that another
user and a user who left the card's space get 404 on every procedure.

This also completes the media settings section committed earlier as work in
progress (01eea83); its code was final, only that commit message said
otherwise.

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

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

AGENTS.md asks for top-level `import type` instead of inline `type`
specifiers; this applies it to the import statements the media 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 media strings to German

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

* fix(media): write poster frames as full-range JPEG

Newer ffmpeg releases refuse to encode JPEG from limited-range video
frames, so poster frames failed on CI's ffmpeg while older versions
accepted the same command. The poster is converted to full-range
yuvj420p before encoding, which every supported ffmpeg version takes.

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: 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

* fix(media): stream large media between the store and local computers

Attachments copied into a local computer's workspace were read whole from
the artifact store first, so a 500 MiB video sat in memory before it was
written to disk. attach_file did the same in the other direction: a large
file on a local computer was read whole before it was stored.

SandboxProvider gains optional readFileStream/writeFileStream. The desktop
(host workspace) provider implements them with the same path confinement
as its byte methods, one chunk in memory at a time, and removes a file
whose write fails or is aborted, including one that replaced an earlier
copy. The host-aware wrapper routes them, and providers without them
(remote computers, still capped at 12 MiB) receive the collected bytes.
Current-turn staging and attach_file now go through these helpers, and
unread streams are cancelled so the stored file is released. Media job
inputs stop copying when the job is cancelled.

The stream helpers move from the local artifact store into a shared
module so both use one implementation.

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

* feat(media): live ComfyUI progress over its websocket

A video generation runs a single ComfyUI prompt for minutes, and the media
card only advanced after each prompt finished, so it sat still until the
clip was done.

The ComfyUI provider now follows ComfyUI's standard /ws stream while its
prompts run and maps step progress to the job's overall fraction across
all prompts: never backwards, and short of each prompt's share until its
outputs are downloaded. The history poll stays the source of truth for
completion and errors; a socket that cannot connect, drops, or stays
silent leaves generation exactly as before, and it is closed on finish,
failure, cancel and abort. Each generation uses its own client id, so
concurrent jobs do not take over each other's messages.

The socket goes through a new createOpenAiCompatibleWebSocket next to the
guarded fetch: same URL and key checks, same resolved-address lookup,
ws/wss derived from http/https, the same Authorization header, a message
size cap, and a hard drop when a server never finishes the closing
handshake. It uses the undici package's WebSocket, so no dependency was
added. A provider given a replaced fetch without a matching socket opens
none, so tests and emulators never reach the network.

The ComfyUI emulator now serves the websocket and reports progress for
running prompts; provider, guard and conformance tests cover ordering,
multi-prompt mapping, unavailable and failing sockets, stray messages,
closing on cancel, and the address checks against a loopback server.

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

* refactor(web): move Shell.tsx module-level code into pages/shell modules

Shell.tsx had grown to about 8,000 lines, roughly 2,200 of them module-level
components and helpers below ShellPage. That made the page hard to navigate
and review and turned every chat change into an edit of one hot file.

This is a move-only refactor: code is moved verbatim and only imports and
exports change. No renames, no JSX or memo boundary changes.

- shell/transcript.tsx: Transcript, QuoteSelectionButton, MessageHoverActions,
  MessageView
- shell/composer.tsx: Composer, slashActionLabel, MentionOptionIcon,
  MentionChipIcon, ATTACHMENT_ACCEPT
- shell/computer-screen.tsx: ComputerReleaseActions, embeddableScreenUrl,
  screenIframeSandbox, DesktopKindEmptyState, computerPlaceholder,
  computerLabel (all used by the computer pane)
- shell/thread-state.ts: threadSnapshotSignal, firstThreadRoute,
  applyThreadEvent, newClientNonce
- shell/roster-format.ts: stripSpaceTitlePrefix, formatRosterTime,
  cleanRosterPreview
- shell/message-preview.ts: previewMessageText (shared by composer and
  transcript)
- shell/collapsed-sidebar-sections.ts: collapsed sidebar storage helpers
- shell/shell-types.ts: PendingAttachment and FALLBACK_BOT_COLOR, shared by
  Shell.tsx, composer and transcript

Types, constants and one-off helpers used only by ShellPage stay in
Shell.tsx. The Lingui catalogs only change their file-reference comments;
message ids and translations are unchanged.

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

* refactor(web): extract sidebar model and space search hooks from ShellPage

ShellPage mixes the sidebar's derived roster with thread orchestration in one
3,000-line body, which makes both hard to review. Behaviour-preserving move:

- useSpaceSearch owns the sidebar query, the debounced space search and
  jumpToSearchHit.
- useSidebarModel owns the sidebar memos (roster groups, conversations,
  projects, main conversation entry) and the project drag-and-drop order.

Both are called where the sidebar memos were, so the search effect keeps its
place among ShellPage's effects; bodies and dependency arrays move verbatim.

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

* refactor(web): extract sound, Today count and browser notification hooks

Behaviour-preserving move of ShellPage side effects into named hooks:

- useShellSounds: audio ducking, the app-start sound and the sounds for
  bots that need the user, finished work, run errors, handoffs and group
  sparks.
- useHeuteActionCount: the 15-second Today brief poll behind the sidebar
  badge.
- useBrowserNotifications: pending and delivered browser notification
  bookkeeping plus notifyBrowserForEvent / flushPendingBrowserNotifications.

The hooks run where the code was. The Today poll effect sat between the
app-start sound and the bot status sound; it now runs just before the sound
hook, ahead of the two mount-only audio effects it does not interact with.
Effect bodies, refs and dependency arrays move verbatim.

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

* refactor(web): extract mobile sidebar, bots sidebar and computer viewport hooks

Behaviour-preserving move of layout state out of ShellPage:

- useMobileSidebar: the mobile navigation open state, closing it at the
  desktop breakpoint, and the edge-swipe touch handlers that were inline on
  the shell root.
- useBotsSidebarCollapse: the per-user collapsed preference and the
  edge-drag pointer handlers that were inline on the sidebar edge button.
- useComputerViewport: the on-screen-keyboard viewport tracking for the
  full-screen computer.

Each hook is called where its effect was, so effect order is unchanged. The
inline JSX handlers become named functions with identical bodies, still
recreated on every render as before.

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

* refactor(web): extract messaging status, agent skills and mention target hooks

Behaviour-preserving move of ShellPage data loaders into named hooks:

- useMessagingStatus: the messaging surface flag and providers, loaded once
  a session user exists.
- useAgentSkills: the agent skill catalog for slash commands, its refresh,
  and the setter bot settings use after editing skills.
- useMentionTargets: composer mention options built from bots, group
  members, routines and connectors, including the routine/connector loader.

Each hook is called where its effect was, so effect order is unchanged, and
the hooks take the same objects the effects read so bodies and dependency
arrays move verbatim. The mention loader's bots ref stays private to the
hook; nothing else read it.

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

* refactor(web): extract ShellPage sidebar JSX into components

ShellPage's JSX carried the whole sidebar inline, including two near-copies
of the group header and of the main header's sidebar toggles, which made the
render hard to follow and easy to change in one place only.

Move-only: the sidebar header, group header (one component for the
conversation and project variants), the team and project roster rows, the
archived section, the user menu and the sidebar toggle buttons now live in
pages/shell. DOM, classNames, test ids, aria attributes, handlers and messages
are unchanged; the catalogs only update their file references, and the
desktop source checks for the header toggles now read the file they moved to.

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

* refactor(web): extract ShellPage side panel and computer JSX into components

The side panel header, the computer preview and the full-window computer
overlay were large inline blocks in ShellPage's render.

Move-only: they are now SidePanelHeader, ComputerPanelPreview and
FullscreenComputerOverlay in pages/shell, each with explicit props. The
booting screen, the routine list and the create-bot modal stay where they
are. DOM, classNames, test ids, aria attributes, handlers and messages are
unchanged; the catalogs only update their file references.

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

* refactor(web): share one PointerMenu for the space and section menus

The space and section context menus repeated the same dropdown with an
invisible trigger anchored at the pointer; only their label and items differ.

Move-only: PointerMenu in pages/shell renders that dropdown and both menus
pass their label, close handler and items. DOM, attributes and handlers are
unchanged, and the labels stay in ShellPage, so the catalogs do not change.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-04 14:45:40 +02:00
cynarAI
dda03bff9e
feat: self-hosted media — streaming, ComfyUI generation, video cutting, transcription (#119)
* 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

* feat(media): add media contracts, schema and provider interfaces

Groundwork for image and video generation, video editing and transcription.
Nothing calls it yet; the media runner, providers, tools and UI build on it.

- Contracts: video, audio and subtitle MIME types, per-kind size caps for
  streamed and generated artifacts, video/audio message blocks and the live
  media card block, media operations, job statuses and output presets
  (9:16, 16:9, 1:1, 4:5, 4:3, 21:9).
- Core: attachment kinds include video and audio, artifacts become video or
  audio blocks, and agent history keeps media artifact ids so later turns
  can refer to them.
- Schema: artifacts record width, height, duration and the artifact they
  were made from; media_jobs tracks generations, edits and transcriptions.
- Adapter kit: provider-neutral image generation, video generation and
  transcription interfaces, the media job context operations run in, and
  the MediaRuntime that tools use to start jobs.

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(media): transcribe speech on any OpenAI-compatible server and write subtitles

Transcription has to run on the user's own hardware, and the only speech-to-text
path so far was the OpenAI voice key. Self-hosted whisper servers (whisper.cpp,
faster-whisper based servers, vLLM) all speak the OpenAI transcriptions
protocol, so one provider covers them.

- createOpenAiCompatibleTranscriptionProvider posts a multipart upload to
  {base}/audio/transcriptions asking for timed segments, falls back to plain
  JSON for servers that reject verbose_json, reads text/plain bodies, and
  reports failures in words. It reuses the address-checked fetch and URL
  guards of the other OpenAI-compatible adapters, caps uploads at 25 MiB and
  responses at 8 MiB, and builds the multipart body by hand because the
  undici fetch behind the address checks does not serialize the global
  FormData class.
- An in-process server emulator (OpenAI/whisper.cpp, plain-JSON-only and
  text-only flavours) and an in-process provider back an offline
  conformance suite, plus one loopback test through the real fetch.
- toSrt/toVtt in core turn segments into SubRip and WebVTT, wrapping long
  segments into cues of at most two 42-character lines that share the
  segment's time, and escaping WebVTT cue text.

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

* feat(media): add the Studio specialist the main bot hands media work to

Media work (cutting for a format, subtitles, exports) needs editing judgement
and a focused tool set, so the main bot should be able to delegate it to a
visible specialist instead of doing it itself.

- ensureStudioBot creates "Studio" once per user, next to the main bot so it
  can join the main conversation and read its media, tagged media, with
  editor instructions (hook first, tight pacing, 9:16 safe areas, subtitles
  for muted playback) and a restricted builtin allowlist: every media
  operation plus files, questions, memory and web lookup. It has a thread,
  so the main bot's specialist directory lists it. The spawn key carries the
  user id so members of a shared space each get their own.
- A Studio the user archived or deleted stays gone. Deleted bots now record
  their spawn key in bot_deletions so idempotent creation can tell.
- Postgres suite for concurrency, shared spaces, archive and delete.

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

* feat(media): add the ComfyUI provider, default workflows and emulator

Image and video generation run on the user's own GPU server. ComfyUI is the
common self-hosted backend, so this adds a provider for its HTTP API that the
generation operations can call.

- Core: API-format workflow templates with a small placeholder format
  ({{prompt}}, {{width}}, {{seed}}, ...) that is validated and filled without
  evaluating anything. Defaults: text-to-image and image-to-image on core nodes
  for any SD-family checkpoint, and text/image-to-video on ComfyUI's core
  LTX-Video nodes; a README lists the placeholders and model files.
- Adapters: createComfyUiProvider submits a workflow, polls its history,
  uploads input images and downloads outputs through the address-checked fetch
  used for other self-hosted model servers, with timeouts, capped reads and
  readable errors. A custom workflow from settings replaces the default it
  matches. Cancelling dequeues or interrupts only its own prompt. GPU status
  counts memory ComfyUI can free for itself as available.
- Contracts: input schemas for generate_image, generate_video and edit_image.
- Tests: an in-process ComfyUI emulator, a provider-neutral generation
  conformance suite and provider tests, all offline.

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

* feat(media): stream artifacts over HTTP with Range support

Videos and audio can be hundreds of megabytes. Artifacts only travelled as
base64 inside RPC calls, capped at 10 MiB and held whole in memory, so media
could neither be uploaded, played with seeking nor produced by media jobs.

- ArtifactStore gains stat, createReadStream (inclusive byte ranges),
  putStream (size cap enforced while writing, real SHA-256) and an optional
  localPath so ffmpeg can read stored files in place. LocalArtifactStore
  implements them with the same safety as before (O_EXCL, O_NOFOLLOW, 0600)
  and now validates space ids and keys before building paths.
- storeArtifactStream / storeArtifactFromFile store a stream or a job's
  output file as an artifact row, with width, height, duration and parent,
  and remove the blob again if the row cannot be written.
- GET /api/artifacts/:id serves an artifact with Range/206/416, ETag,
  inline disposition for media and attachment otherwise (safe RFC 6266
  filenames), nosniff and same-origin CORP. Authorization matches
  artifacts.get: the owner's artifact of an active bot thread or group,
  with membership of the artifact's space (media elements cannot send the
  space header; an explicit header must name the same space).
- POST /api/artifacts takes a raw streamed body with the target and name in
  the query, authorized like artifacts.create, with the MIME allowlist and
  the per-kind caps checked from Content-Length and again while writing.
- The API CSP allows media from the app origin and blob URLs.
- Web: artifactUrl(artifactId) and uploadArtifactFile for the UI.

Tests cover the streaming store, range parsing, filename sanitizing, the
file helper, and a Postgres suite for both routes: ranges, caching, other
users, shared-space members, other spaces, archived threads, signed-out
requests and upload limits including chunked bodies.

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

* feat(media): media job runtime, media queue and media settings

Media operations (generation, edits, transcription) need a runner that works
outside the agent turn, keeps a card in the conversation current and never
lets two processes put work on one GPU. Operations land in later commits; this
is the runtime they register with.

- Background job `media.run { jobId }` on a named `media` queue. Jobs carry an
  optional queue: the Graphile publisher passes it as queueName and the
  in-memory driver runs each queue one job at a time, in order.
- `createMediaRuntime` implements MediaRuntime (capabilities, start, get,
  cancel) over a list of `MediaOperationRegistration`s and runs jobs:
  start validates the input, creates the job and posts the bot's media card in
  one transaction (idempotent per tool call through an operation key), then
  enqueues it. The runner claims a fenced lease (an advisory lock keeps one
  live lease at a time, so the GPU stays single even when the queue is
  bypassed), renews it while polling for cancellation, writes progress and
  outputs into the card, defers with backoff when the GPU is short of memory
  or the operation asks to wait, bounds deferrals, crash restarts and run
  time, always cleans the job's temp directory and always ends the card in a
  terminal state. With continue_after the starting bot is woken in the same
  transaction that ends the job (new run trigger `media`).
- The reconciler re-enqueues jobs whose enqueue was lost, whose runner died
  or whose wait is over; a job stuck behind a dead worker's queue lock runs
  outside the queue.
- Media backends are deployment-wide settings stored encrypted as the
  `media` IntegrationProviderConfig row: `loadMediaSettings` and
  `saveMediaSettings` with the same address policy as OpenAI-compatible
  servers (no metadata hosts, public hosts only when allowed, HTTPS when a key
  is sent), a saved key never follows the settings to another server, and the
  view never returns keys. Contract in contracts/media-settings.ts.
- `createMediaOperations` is the one list both the API and the worker build
  their runtime from (empty until the operations land), and media-tools.ts has
  the shared `continue_after` schema helper for the tool definitions.
- Migration: lease, deferral, operation key and continueAfter columns.

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

* feat(media): transcription input, chunked transcripts and the Studio trigger

Groundwork for the transcribe operation and for creating Studio when it is
first needed.

- Contracts: TranscribeInputSchema (artifact id, optional language code).
- transcribeChunks uploads audio chunks in order and stitches them into one
  transcript on the original timeline; transcriptionChunkSeconds sizes
  16 kHz mono WAV chunks below the server's upload limit; transcriptSummary
  hands the bot the transcript, cut at a word.
- ensureStudioWhenMediaConfigured creates Studio once a media server
  (generation or transcription) is configured. Local edits alone do not
  count, because ffmpeg and image tools are present on most installs and a
  specialist nobody asked for would appear for everyone.

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

* feat(web): show media jobs as a live card in the conversation

Media jobs report progress and results through the media block, which the
web app did not render.

- MediaCard renders the block: operation icon, title, status (reusing the
  task card's labels, plus "Waiting for GPU" for jobs queued behind the
  GPU), a progress bar while active, the error when failed, and outputs:
  images as thumbnails with the existing lightbox, video and audio in the
  media player, subtitle and text files as file cards. Finished video and
  audio get a download link, "Export" with presets labelled by aspect ratio
  (the video's own shape left out) and "Transcribe", offered only when the
  space can run them; running jobs get "Cancel".
- MessageView renders it with one branch.
- An E2E seeds the card on a thread snapshot with real uploaded artifacts.
- artifact-url.ts and MediaPlayer.tsx are minimal stand-ins for the
  pipeline workstream's files and give way to them on merge.

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

* feat(media): edit and export video with ffmpeg

Bots can now cut, reframe and export videos for every common format without
a hosted service: ffmpeg and ffprobe from PATH do the work on the server.

- ffmpeg wrapper: PATH detection, ffprobe JSON probing (rotation, pixel
  aspect, frame rate, audio, packet scan when the container has no
  duration), runs with -progress parsing, abort and stall kills, an output
  size cap, partial-output cleanup and short errors without temp paths.
  Inputs go through the file protocol with the demuxer forced from the MIME
  type, so a crafted upload cannot be probed as a playlist that reads other
  files or URLs. extractAudio (16 kHz mono WAV) and renderPoster for
  transcription and media cards.
- A pure planner turns edit steps (trim, keep, concat, reframe, speed,
  volume, mute, subtitles, extract_audio, poster) into one filter_complex
  pass: leading cuts seek the input, appended clips are normalized to the
  frame, fps and 48 kHz stereo (silence where a clip has none), reframes
  crop, pad or blur to the presets, speed chains atempo, subtitles burn in
  from generated file names with a readable style that stays clear of the
  controls on 9:16 frames. H.264/AAC MP4 with yuv420p and faststart;
  location metadata is stripped.
- edit_video and export_video operations and tool definitions; the inputs
  are shared contracts so the media card can start exports.

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

* feat(media): stream job inputs and outputs, cover the job lifecycle on Postgres

Media files can be hundreds of megabytes, so the runner no longer buffers them:
inputs are cloned from the local artifact file when the store has one and
streamed otherwise, outputs go through storeArtifactFromFile (per-kind size cap
and MIME check while streaming; an oversized result fails the job with a short
card error).

A Postgres suite runs the runtime with fake operations: success with outputs,
posters and parent versions; input, availability, artifact and thread checks
before a job exists; tool-call replay; failure with temp-dir cleanup; outputs
outside the temp dir and sources of another user; deferral with backoff and its
budget; the GPU memory guard; cancel of a queued job and of a job running in
another process; one job per lease and one job on the GPU at a time; crash
recovery and its restart limit; reconciliation inside and outside the queue;
the continue_after wake; a cleared conversation; the run time limit; and
capabilities.

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

* feat(media): generate images and video on ComfyUI and edit images

Bots can now make images and short clips on the user's own GPU server and
rework images locally, through the media job runtime.

- generate_image: prompt, aspect preset or exact size, up to 4 images, optional
  source image for image-to-image. Sizes target what the default models are
  tuned for, and sources are fitted to that size before upload, so large
  photos cannot exhaust GPU memory.
- generate_video: prompt, aspect, duration up to 10 s, optional start frame.
- edit_image: crop, rotate, flip, resize to a size or format (crop, pad or
  blurred background), extend, convert, in that order, with sharp; an optional
  prompt repaints the result on ComfyUI. Edits keep the source as parent.
- Registrations declare the input artifacts so jobs refuse ones the user
  cannot read, and generation declares a fixed GPU memory floor (6 GB images,
  12 GB video) that a custom workflow cannot lower.
- Tool definitions reuse the operation input schemas plus continue_after.
- Saving media settings rejects custom workflows the provider could not fill.
- Tests: sharp edits on generated fixtures, handlers against the ComfyUI
  emulator, and a PostgreSQL suite running the operations through the media
  runtime (card outputs, artifact versions, foreign artifacts, GPU wait).

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

* refactor(media): share the ComfyUI JSON request helpers

Queue, interrupt and prompt requests now go through one JSON helper, so the
cancel path reads like the rest of the client.

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

* feat(media): register video operations with the media runtime

edit_video and export_video now run as media jobs: both registrations join
the shared operation list the API and the worker build their runtime from,
name every artifact they read (source, appended clips, subtitle files) so
starting a job refuses artifacts the user cannot read, and their tool
schemas use the runtime's continue_after helper.

Container deployments get ffmpeg, ffprobe and the subtitle font in the
API/worker image. Tests cover the handlers against a fake job context:
cuts, blurred 9:16 reframes with subtitles, appended silent clips, audio
extraction, single frames, batch exports with posters and progress,
rotated phone video, clear input errors and cancellation. They skip where
ffmpeg is not installed; the planner and parser tests always run.

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

* fix(media): describe media tools by what the model can rely on

The image server is the deployment's, not necessarily the user's, and
repainting an edit only works when generation is available.

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

* feat(media): media tools, media RPCs and owner-only media settings API

Bots and users need to start and follow media jobs, and the deployment owner
needs to point the deployment at their GPU and transcription servers.

- Media tools are named like their operations. A run sees one only when the
  space can run that operation (MediaRuntime.capabilities); the per-bot
  allowlist still applies. applyTool hands every media tool to one
  dispatcher (media-tools.ts) that splits off continue_after, starts the job
  under the tool call's effect key (a replay returns the same job) and returns
  at once; refusals (invalid input, unknown artifact, unavailable) go back to
  the model as tool errors.
- media.jobs.start starts an export, transcription or image edit from a card:
  the conversation is resolved like every thread procedure, the card's bot
  (or the group's lead) posts the new card, and the runtime refuses source
  artifacts outside the actor's space. media.jobs.get and media.jobs.cancel
  only see the actor's own jobs.
- media.settings.get/update are deployment-owner only, never return keys and
  answer invalid URLs with a 400 that says what is wrong.
- createApp accepts media operations as a test seam.
- Tests: a Postgres suite over the RPCs (owner-only settings, encryption,
  key retention per server, URL policy, start/get/cancel isolation between
  users, card jobs end to end) and unit tests for tool exposure and dispatch.

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

* feat(media): transcribe audio and video into subtitles as a media job

With the media runtime in place, transcription becomes an operation like the
others: bots call the transcribe tool, the job runs in the background and
the card shows the subtitle files.

- createTranscriptionOperations registers transcribe: available when a
  transcription server is configured, refuses artifacts the user cannot
  read before the job exists, and writes <name>.srt and <name>.vtt next to
  the source (a .txt when the server returns no timings). With ffmpeg the
  audio is converted to 16 kHz mono WAV and long media is transcribed in
  upload-sized ranges on one timeline; without it audio is sent as stored
  and video is refused with a clear message. A server that is down or busy
  (unreachable, 429, 502, 503) defers the job instead of failing it, since
  nothing has been stored yet. The bot gets the transcript, cut at a word.
- The transcribe tool definition uses the shared media schema helper and
  continue_after; dispatch stays generic.
- The main bot's run creates Studio once a media server is configured,
  right before the specialist directory is built.
- Dictation: when the user's voice provider cannot transcribe (or there is
  none), /api/voice/transcribe uses the self-hosted transcription server,
  and the voice status reports server dictation so the web app uses it
  instead of the browser's speech recognition. A voice provider that
  transcribes keeps being used as before.

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

* feat(web): play video and audio inline and stream uploads and downloads

Media sent in a conversation had nowhere to go: the composer capped every
file at 10 MiB and pushed it through a base64 RPC, video and audio blocks
did not render, and downloads decoded whole files in the page.

- Video and audio blocks play inline through MediaPlayer, which loads
  nothing until the player is near the viewport, sizes the frame from the
  block's dimensions (vertical clips stay within the column) and uses the
  poster when one exists. Files the browser cannot play fall back to their
  name with a download action.
- Images load straight from the artifact route; the lightbox gains a
  download action.
- File cards, the Markdown preview and downloads read from the artifact
  route instead of decoding base64.
- The composer accepts each kind up to its cap and names the computed
  limit when it skips a file; video, audio and anything above the inline
  cap upload as a raw stream, small files keep the inline RPC.
- MediaPlayer and artifactUrl are what the media card builds on.

Tests: MediaPlayer laziness, sizing and fallback, the upload helper, and an
E2E that uploads a tiny MP4 fixture (VP9, since Playwright's Chromium has
no H.264 decoder), plays it from the artifact route and captures
media-video-block, plus the image lightbox download.

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

* test(media): run transcribe through the media runtime on Postgres

Covers what the unit tests fake: the job starts from validated input with
the "Transcript" card, runs against the transcription emulator, stores the
.srt and .vtt as artifacts next to the source and lists them on the card,
and an artifact of another user is refused before a job exists.

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

* feat(media): disclose media servers in AI data sharing

Media tools and card actions send prompts, images, video and audio to the
deployment owner's ComfyUI and transcription servers. Like the embeddings
server behind built-in memory, those self-hosted endpoints are recipients in
the AI data-sharing disclosure: they appear with the model uses ("ComfyUI
(<origin>)", "Transcription (<origin>)", keyed by the full endpoint so a new
server asks again), and starting a card job asks for model consent on clients
that run the foreground check.

Also adds unit tests for the media settings store: URL normalization and
refusals, encryption, the key-free view, one decryption per saved version, key
retention per server and removing empty settings.

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

* refactor(media): trim unused ffmpeg wrapper options

Binary paths, the ffmpeg version, the probe's container name and a
configurable stall timeout had no caller; detection is cached per process,
so every helper looks the binaries up itself.

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

* fix(media): hand the transcript to the bot as quoted speech

A transcript is whatever was said in the recording, so the summary the bot
is woken with quotes it and says it is not instructions.

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

* feat(web): media settings section for the deployment owner (wip)

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

* feat(media): give agents attached media and larger bot files

Agents only received the documents of a turn on their computer and could
attach workspace files up to 10 MiB, so they could neither cut a clip a user
sent nor post the video they rendered.

- Current-turn images, video and audio are copied into the bot's workspace
  (Team Computers under bots/<botId>/) like documents. Remote computers move
  files as base64 JSON, so anything above their 12 MiB transfer cap stays in
  storage and the agent is told it by artifact id instead of failing the
  turn; local computers copy up to the kind cap.
- attach_file accepts video and audio by extension and attaches up to the
  kind cap when the store streams (still bounded by the transfer cap of a
  remote computer), stored through the shared artifact helper with a real
  SHA-256.
- The render_plot fallback that attaches a chart PNG now records the group,
  so the chart opens in group conversations.
- Agent history names image artifacts by id, like video and audio, so a
  later turn can refer to them.
- artifacts.get refuses files above 25 MiB: base64 reads hold the whole
  file in memory, and larger media is served by the artifact route.

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

* fix(mobile): show video, audio and media cards as plain rows

Mobile has no media UI yet, so video, audio and media-card blocks rendered
as empty bubbles or fell out of previews.

- Thread text and reply previews describe video and audio files and the
  media card's title and status.
- The bubble lists video, audio and a finished card's outputs as tappable
  rows that open the file like other attachments.
- Opening video or audio downloads it from the artifact route to the cache
  instead of a base64 RPC, so long recordings open without the server
  holding them in memory.

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

* fix(web): download media outputs with the forced-download artifact URL

The media card's download link uses artifactUrl's download option so a video
or audio output is saved rather than opened inline, and the E2E seeds the
thread snapshot with fresh response headers so the patched body is read.

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

* fix(api): refuse artifact uploads from untrusted origins

Uploads with a text/plain, text/csv or text/markdown body are CORS "simple"
requests that a browser sends cross-site without a preflight. The session
cookie's SameSite default already keeps it off such requests, but a
deployment that relaxes it would accept uploads from any page. The route now
applies the same origin rule as CORS before reading the session.

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): start exports, transcriptions and cancels from the media card

The card's follow-ups now reach the media job RPCs, and it only offers what
the space can run.

- media.capabilities answers which operations the actor's space can run
  (booleans only). The card asks once per minute per space, and only when it
  shows finished video or audio, so other cards cost nothing.
- Export and Transcribe call media.jobs.start with the card's message, so the
  same bot posts the new card; Cancel calls media.jobs.cancel. A refused
  start shows the server's message under the card.
- New files use top-level import type.

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

* fix(media): GPU guard for smaller cards, top-level type imports

- The GPU guard waited for free VRAM at the operation's floor even on a card
  whose total memory is below it (an 8 GB card and a 12 GB video floor), so
  such jobs deferred until their budget ran out. The requirement is now capped
  at 90% of the card's total; a card with less than half the floor fails at
  once with "The GPU does not have enough memory for this job."
- Tests pin that media tools reach a run only through the space's
  capabilities and within the bot's allowlist now that the image tool
  definitions exist.
- Type-only imports are top-level `import type` in the media runtime, tools,
  RPCs and tests (AGENTS.md).
- The ComfyUI settings test gives saveMediaSettings a store with nothing saved,
  since the save reads the previous settings before validating.

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

* test(media): run video edits and exports through the media runtime

A Postgres suite starts edit_video and export_video through the real
runtime and local artifact store: outputs become linked versions with
posters on the card, exports store one version per preset, appended clips
and subtitle files of another user are refused at start, and an
out-of-range cut fails the job with a readable error. It skips without
Postgres or ffmpeg.

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

* fix(media): abandoned handlers cannot crash the worker, sweep stale temp dirs

- A handler that ignores its abort is left behind after the grace period;
  a late rejection from it (or from the grace timer after the job settled)
  was unobserved, and the worker exits on unhandled rejections. Both sides of
  the race are now observed and the grace timer is cleared with the job.
- Ending a job removes its temp directory when no runner in this process
  holds it (leftovers of a runner that died here), and reconciliation sweeps
  directories of jobs that no longer run.
- An executor test runs a media tool end to end through applyTool: shown
  only for operations the space can run, started with the run's scope and
  effect key, continue_after split off, refused when not exposed.

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

* fix(web): offer media card follow-ups on any settled job

Failed or cancelled jobs can still carry finished outputs, so the card asks
for the space's capabilities whenever a settled job shows video or audio.

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

* fix(media): let bots title image edits in the user's language

Edit cards showed English words such as "crop · flip · extend" to every user.
edit_image now takes an optional title written by the bot in the user's
language, used for the card and the file name like edit_video. Without one,
the card falls back to the repaint prompt or to language-neutral tokens only:
sizes, ratios, degrees, arrows and format names.

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

* test(media): transcribe real clips through ffmpeg

Generated clips (nothing binary committed) go through extractAudio: a video's
audio reaches the server as 16 kHz mono WAV, a 12-second recording is
uploaded in two ranges under a small limit, and a video without sound is
refused before anything is uploaded. Skipped where ffmpeg is missing, which
may include CI runners.

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

* test(web): cover group uploads in the main conversation

The image E2E now runs in the main conversation, which is a group thread,
so the browser test covers group uploads and reads from the artifact route
as well as bot threads. Bot file attachments keep their run's operation id
for tracing, and the non-streaming attach cap is the inline cap directly.

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

* feat(media): title transcription cards with what they transcribe

A card that only says "Transcript" does not tell two transcriptions apart,
and it is English whatever the user's language.

- transcribe takes an optional title like the video operations, so a bot
  names the card in the user's language; "Transcript" stays the default.
- Transcribe on a media card passes the source file name as the title.
- The E2E seeds the card through bootstrap as well as threads/get, since the
  bot thread's first snapshot arrives with bootstrap.

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

* refactor(web): match the media player stand-in to the pipeline's props

The pipeline workstream's MediaPlayer takes artifactId, kind, name, poster
and size, without a MIME type; the card and the stand-in now use exactly
that, so its file replaces the stand-in without other changes.

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

* test(web): media settings E2E with the media-settings screenshot

The Media settings section (added in the previous web commit) lets the
deployment owner point media at their own servers: ComfyUI URL and optional
key, with the image model and custom image/video workflows folded under
Advanced, and a transcription URL with optional model and key. Saved keys are
never shown again; the key field says one is saved and offers to remove it.
Members who do not own the deployment do not see the section.

The spec fills both servers, captures `media-settings`, saves and checks the
exact update the server receives, then removes the saved key and the
transcription server; a second spec checks a member has no Media section.

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

* fix(web): size uploaded videos from their own frame

Uploaded videos carry no dimensions, so a vertical clip sat letterboxed in
a 16:9 frame. The player now takes the frame size from the file's metadata
when the block has none.

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

* fix(media): disclose the transcription server before dictation uses it

Dictation falls back to the owner's transcription server when the voice
provider cannot transcribe, so that server is a voice recipient too: it is
listed for voice use (as "Transcription (<origin>)") whenever it would take
the recording, and not when the voice provider transcribes itself.

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

* fix(web): render media card outputs with the URL-based previews

The media pipeline moved image and file previews to the artifact route, so
they no longer take a bot or group target. The media card still passed one,
which no longer type-checks; only its follow-up actions need the target.

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

* fix(api): let large uploads finish and abort stalled ones

Node's default 5-minute requestTimeout cut off a 500 MiB video upload on
any uplink slower than about 14 Mbit/s, so the video cap was unusable over
ordinary connections.

- The API server's requestTimeout is 30 minutes, enough for the largest
  upload at about 2.5 Mbit/s. headersTimeout keeps its 60-second default.
- Each streamed upload aborts when the client sends no bytes for 60
  seconds: the route answers 408, the partial file is deleted and no row
  is written, so a slow-drip client cannot hold a connection for the full
  request timeout. Time spent writing to disk does not count.
- The store and the upload guard no longer wait for a body to confirm its
  cancellation, which a stalled or hostile client may never do.
- A guard test checks that every file the executor attaches records the
  thread's group, which the render_plot chart fallback once dropped.

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

* fix(media): use snake_case keys in every media tool input

The model saw edit_video and export_video with artifact_id next to
generate_image, generate_video, edit_image and transcribe with artifactId,
sourceArtifactId and negativePrompt. Mixed conventions in one tool list invite
wrong calls, so all media inputs now use snake_case like continue_after and the
other builtin tools: artifact_id, source_artifact_id, negative_prompt,
duration_seconds. Handlers, artifact checks, the media card's transcribe
action and the tests follow. Nothing has shipped, so stored job inputs need no
migration.

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

* fix(media): act on media cards in their own space, not the selected one

The main conversation lives in the home space, but the user can have another
space selected while it is on screen, and the card's actions sent that space:
media.jobs.start resolved the conversation in the selected space and failed,
and media.jobs.get/cancel only found jobs of the selected space.

The server now takes the space from the card itself. start finds the card's
message in one of the actor's own threads (the conversation named by botId or
groupId), requires the actor to still be a member of that thread's space, and
resolves the conversation and the source artifacts there. get and cancel find
the actor's job by id and require membership of the job's space.
media.capabilities takes an optional job id and answers for that job's space;
the card asks through its job and shares the answer per conversation. Every
other case is a 404, as before.

A Postgres test runs a card in the home-space main conversation while another
space is selected (start, get, capabilities, cancel) and checks that another
user and a user who left the card's space get 404 on every procedure.

This also completes the media settings section committed earlier as work in
progress (01eea83); its code was final, only that commit message said
otherwise.

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

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

AGENTS.md asks for top-level `import type` instead of inline `type`
specifiers; this applies it to the import statements the media 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 media strings to German

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

* fix(media): write poster frames as full-range JPEG

Newer ffmpeg releases refuse to encode JPEG from limited-range video
frames, so poster frames failed on CI's ffmpeg while older versions
accepted the same command. The poster is converted to full-range
yuvj420p before encoding, which every supported ffmpeg version takes.

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: 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

* fix(media): stream large media between the store and local computers

Attachments copied into a local computer's workspace were read whole from
the artifact store first, so a 500 MiB video sat in memory before it was
written to disk. attach_file did the same in the other direction: a large
file on a local computer was read whole before it was stored.

SandboxProvider gains optional readFileStream/writeFileStream. The desktop
(host workspace) provider implements them with the same path confinement
as its byte methods, one chunk in memory at a time, and removes a file
whose write fails or is aborted, including one that replaced an earlier
copy. The host-aware wrapper routes them, and providers without them
(remote computers, still capped at 12 MiB) receive the collected bytes.
Current-turn staging and attach_file now go through these helpers, and
unread streams are cancelled so the stored file is released. Media job
inputs stop copying when the job is cancelled.

The stream helpers move from the local artifact store into a shared
module so both use one implementation.

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

* feat(media): live ComfyUI progress over its websocket

A video generation runs a single ComfyUI prompt for minutes, and the media
card only advanced after each prompt finished, so it sat still until the
clip was done.

The ComfyUI provider now follows ComfyUI's standard /ws stream while its
prompts run and maps step progress to the job's overall fraction across
all prompts: never backwards, and short of each prompt's share until its
outputs are downloaded. The history poll stays the source of truth for
completion and errors; a socket that cannot connect, drops, or stays
silent leaves generation exactly as before, and it is closed on finish,
failure, cancel and abort. Each generation uses its own client id, so
concurrent jobs do not take over each other's messages.

The socket goes through a new createOpenAiCompatibleWebSocket next to the
guarded fetch: same URL and key checks, same resolved-address lookup,
ws/wss derived from http/https, the same Authorization header, a message
size cap, and a hard drop when a server never finishes the closing
handshake. It uses the undici package's WebSocket, so no dependency was
added. A provider given a replaced fetch without a matching socket opens
none, so tests and emulators never reach the network.

The ComfyUI emulator now serves the websocket and reports progress for
running prompts; provider, guard and conformance tests cover ordering,
multi-prompt mapping, unavailable and failing sockets, stray messages,
closing on cancel, and the address checks against a loopback server.

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

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

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-04 12:42:51 +02:00
cynarAI
77559a6284
feat: restore a bot from its export (#128)
Bot settings could export a bot as JSON, but nothing could read that file
back, so an export was not a way to recover or move a bot.

bots.import creates a new bot from an export manifest: profile and
instructions, bot memory, home files and routines. Team Computer paths
are rewritten to the new bot's workspace, unsafe paths reject the whole
import, imported routines start paused and one-shot schedules are left
out. History is not replayed; the bot starts its own conversation. A
failed import removes the half-created bot.

The New bot panel gets an import button that takes the exported file.


Claude-Session: https://claude.ai/code/session_01Sbaz56zNFZBJ1whrpasMMs

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-04 11:38:59 +02:00
cynarAI
6135bd8d63
feat: the main agent as a coach — conversational onboarding, goals, check-ins and push (#118)
* chore: remove legacy orchestrator, trading and one-off script leftovers

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

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

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

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

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

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

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

* refactor: remove the trading bot status integration

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Discovery now asks first and creates nothing without a choice:

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

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

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

* feat(web): coach on the Heute page

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-04 11:26:26 +02:00
cynarAI
0c3e3f3d29
feat(web): complete German, English sources and catalogs that cannot go stale (#111)
* 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

* 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

* 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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-04 10:04:21 +02:00
cynarAI
ece3638ce7
fix(ui-web): teach cn() the design system's text and control scales (#115)
* fix(ui-web): teach cn() the design system's text and control scales

cn() used plain tailwind-merge, which only knows Tailwind's default scales.
It read the custom font sizes (text-ui-label, text-ui-caption, ...) as text
colors, so of a size and a color in one class list only the last survived:

- Small, extra-small and large buttons lost their variant's text color. A
  small primary button rendered foreground text on the primary fill (ink on
  ink in the light theme, near-white on white in the dark theme), and
  destructive, secondary and link buttons lost their colors the same way.
- Default-size and icon buttons, tab triggers and the typography helpers
  lost their font size and inherited the parent's instead.

The control and icon spacing scales (h-control-sm, size-icon-md, ...) were
unknown as well, so they never merged with other sizes: an explicit h-auto
or h-9 on a Button, Input or Toggle lost to the component's own height, and
large buttons and toggles drew their icons at the medium size.

cn() now extends tailwind-merge with the text and spacing keys styles.css
adds under @theme. A unit test probes cn() with every @theme key in
styles.css, so a new key that is not registered fails the test.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-04 10:04:18 +02:00
cynarAI
59f4a1dfca
perf(api): filter peer-run activity in the transcript page query (#127)
* perf(api): filter peer-run activity in the transcript page query

The transcript loaded a raw page, dropped hidden peer-run rows in memory and
rescanned when nothing visible was left, so a long delegated stretch cost two
queries per hidden page. The page query now joins runs and excludes hidden
peer activity in Postgres, so every page is filled with visible rows. The
kept block kinds live in one list shared by paging, the in-memory filter and
live event forwarding.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTJRkJrXAS9e3aEFcFYTy8

* test: port the main-conversation journey fix from #126

Journey 5c fails on main since every space got its own main conversation:
the lookup picked the Lab conversation instead of the home one. Same change
as #126 (including its chart-links warm-up), so this PR can go green; it
no-ops once #126 lands.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTJRkJrXAS9e3aEFcFYTy8

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-04 07:41:48 +02:00
cynarAI
735322b016
ops: build the web app in the Evo deploy and add a preview-capable stack unit (#110)
* ops: build the web app in the Evo deploy and add a preview-capable stack unit

The Evo deploy restarted the dev stack without ever building the web app, so a
broken production build went unnoticed and the server could not serve the
build at all. Service workers and web push also need the production web app
behind HTTPS, which the deploy notes did not cover.

deploy.sh now builds the web app after install into a staging directory and
swaps it into apps/web/dist right before the restart, so a failing build
stops the deploy before migrations and the restart and never touches the
build a running vite preview serves. Any failure after the checkout rolls
back the code, reinstalls, regenerates the client, rebuilds the web app when
this deploy had already replaced it, restarts and health-checks; the outcome
is recorded as rolled-back or rollback-failed. The health check now fails
when the API check fails even if the web check passes. Build output goes to a
log that is printed only on failure.

start-stack.sh runs api, worker and sandbox supervisor as pnpm dev does and
serves the web app either from the Vite dev server (default) or from the
build with vite preview on the same port (EVO_WEB_MODE=preview). Preview mode
first checks that a build exists and that RAKAZO_HOST and SCREEN_PROXY_SECRET
resolve the way vite.config.ts resolves them, using the same secret guard.
evoo-local.service is a user unit template for it; the README documents
installing it, switching modes, the catalog caveat for production builds,
and HTTPS through Caddy with EXTRA_TRUSTED_ORIGINS.

A testkit test runs the real scripts against fixture checkouts with fake
pnpm, curl and restart hooks. It replaces the unwired shell regression test,
which exercised a copy of the SHA capture rather than deploy.sh itself.

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

* ops: show the build error instead of stack frames when a deploy build fails

A failing web build printed the last 40 lines of its log, which for a syntax
error are mostly bundler stack frames below the message that names the file
and line. Drop the frames before printing the tail.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-04 06:35:38 +02:00
cynarAI
c4c5ce3c2d
fix(ui): polish the light palette, center the stage bloom (#108)
* fix(ui): make the light theme a real light theme

The web shell hardcoded dark surfaces (panel hex, white/black alphas,
white text), so switching to light left the sidebar, chat, composer and
side panel dark with ink text on top. Shell surfaces now use the shared
tokens: light gets a warm paper frame with white panels, hairline borders
and short soft shadows; dark keeps its look via dark: variants.

- Light palette: visible borders (border was equal to muted), white
  primary-foreground, lighter user bubble, readable warning/success.
- Selected sidebar row lives in one place (roster-row-style).
- Markdown, inline code, code blocks, quotes and scrollbars follow tokens.
- cn() now knows the text-ui-* scale is a font size; tailwind-merge
  treated it as a color and dropped labels' color on resized buttons
  (sign-up submit rendered ink on ink).
- Stage bloom was offset left by a double translateX.

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

* refactor(ui-web): leave the cn() fix to #115

#115 fixes the same tailwind-merge bug more completely (text, control,
icon and spacing scales, guarded against styles.css) and adds the same
two files, so this branch drops its narrower copy to avoid the overlap.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-04 04:37:24 +02:00
cynarAI
4ba7d843b1
test: fix journey 5c lookup and chart-links warmup (#126)
Look up the home main conversation by spaceId in journey 5c, and warm the chart-links E2E so the first Observable Plot import is not a 20s timeout.
2026-10-04 04:00:43 +02:00
cynarAI
2c6b5c3d7b
chore: remove legacy orchestrator, trading and one-off leftovers (#109)
* 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

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* 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

* 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(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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-04 01:13:52 +02:00
cynarAI
8449dec6c2
feat: give every project its own main agent (#125)
Each project (space) now has a main agent and a main conversation of its
own. The user talks to it, and it delegates to the project's specialists.
Creating a project sets both up and opens the conversation, and deleting
an otherwise empty project removes them with it.


Claude-Session: https://claude.ai/code/session_01R8hSsKTB3W3e61wD6kvzur

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-04 00:38:39 +02:00
Cursor Agent
a51ba87133
fix(web): recover local settings boot when module fetch aborts
Lazy-load the desktop-settings page and its heavy sections so
loadWithRecovery can retry or reload after ERR_NETWORK_CHANGED.
Show the no-desktop alert immediately when the bridge is absent.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-03 21:08:49 +00:00
cynarAI
0d1939c775
ci: soft-fail mobile OTA when EXPO_TOKEN is missing (#124)
* ci: soft-fail mobile OTA when EXPO_TOKEN is missing

OTA publish is an infra helper; missing Expo credentials should not
fail the ci workflow or block evo-deploy after green Product CI.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* test(updater): narrow optional workflow jobs and format the OTA assertion

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mFCmHk4zPyWpD9yY2C3yJ

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-10-03 23:04:17 +02:00
cynarAI
d912712668
ci: pass the attestation lookup's jq filter to gh api without -r (#120)
Every image publish on main failed at "Resolve registry attestation
eligibility" with "accepts 1 arg(s), received 2". The step ran
`gh api ... --jq -r '.owner.type'`, but gh api has no -r option: --jq
took "-r" as its filter and the real filter became a second positional
argument. gh api --jq already prints strings without quotes, so the
filter is now passed directly. The images were built and their
manifests pushed; only the attestation decision failed.

The workflow test now rejects `--jq -r`. Run with the repository's
values, the fixed step reports a personal account and a private
repository and skips attestation, as the guard intends.


Claude-Session: https://claude.ai/code/session_01EyQw3AEaHYXegvgvTPAsi1

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-03 22:25:48 +02:00
cynarAI
e28c3acf02
feat: let the pane beside a conversation follow the bot's work (#123)
The live screen opens wide beside the chat while the bot works on it or asks
for a takeover, a file the bot presents as its result opens next to the
conversation, and a question that scrolled away gets a "Needs you" pill back
to it. Shared core rules derive the focus from signals clients already have
(pointer events, takeover requests, a new `present` flag on attach_file,
pending asks), move the pane only on focus edges, never replace a pane the
user opened, and learn per bot whether the screen opens on its own. Pane
widths persist per device; mobile shows a pill that opens the computer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7VSRHHzyt2KwoCkUkZ2Gc
2026-10-03 22:23:46 +02:00
cynarAI
a15da068d7
fix(api): check a project folder before creating the space (#114)
spaces.create created the space first and only then checked the folder,
so a folder that was missing or not a folder returned 400 but left the
new space behind. The New project dialog shows that error, so fixing the
path and submitting again created a second project with the same name.

The folder is now resolved and checked right after the deployment-owner
check, before the space exists, and the space stores that resolved path.
wireProjectWorkspace takes the resolved folder instead of resolving it
again after the space was created. The onboarding scan, its other
caller, resolves and checks each repository folder the same way before
creating the project, and skips a folder that cannot be linked.

The authorization test has the deployment owner submit a missing folder
and a file (both 400) and then the real folder under the same name, and
checks that exactly one project with that name exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HG4pFTUBDpuCS2Y44TV8x
2026-10-03 20:35:29 +02:00
cynarAI
49431def83
feat: show the bot's live cursor and make the in-app browser work (#113)
Bots now announce every click and typing step before it runs, and the
web app draws a cursor in the bot's colour that glides to that point
over the live computer screen, ripples on clicks and shows typing.

- contracts: ComputerPointerPayload on the existing thread.computer event
- executor: computer_act and browser_act announce pointer steps
  (best effort); the page-browser helper reports element screen boxes,
  which are stripped before results reach the model
- web: agentPointerFeed + AgentCursor over the side-panel preview and
  the full-window screen, mapped through the noVNC letterbox
- in-app browser: native WebContentsView on desktop (own session, http(s)
  only, clamped bounds, back/forward, reload, DevTools, load errors) and
  a sandboxed iframe on the web; replaces the dead panel, the fake
  50%/40% cursors and the unimplemented devServer bridge


Claude-Session: https://claude.ai/code/session_01WiGooXtsKrZQmdszepRrNi

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-03 19:08:48 +02:00
cynarAI
3c3961f242
fix(api): keep host folders to the deployment owner (#112)
spaces.create linked any workspacePath to the new project, and
computer.files and computer.readFile then listed and read files under
that folder for anyone in the space. On a deployment with signups, any
member could link / or the API's own checkout and read whatever the API
process can read, such as its .env file. Linking also ran git in the
chosen folder and stored its remote URL.

A linked folder lives on the host, and the host is the deployment
owner's machine, the rule the onboarding scan already follows:

- spaces.create refuses a folder from anyone but the deployment owner,
  before the space is created or the folder is touched.
- Project files are listed and read only for the deployment owner, so a
  space a member linked before this change stays closed too. Space
  navigation no longer shows a linked folder's path and git remote to
  other members.
- A linked folder is stored as its real path, and listing and reading
  follow symlinks only while they stay inside it. Symlinks that leave
  the folder or lead nowhere are left out of a listing instead of
  failing it.
- The web New project dialog offers the folder field only to the
  deployment owner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EyQw3AEaHYXegvgvTPAsi1
2026-10-03 18:32:48 +02:00
cynarAI
2b967df569
fix(api): turn repositories found by the onboarding scan into real projects (#116)
The scan's project phase called prisma.space with fields the Space model
does not have (title, path). Both calls threw, a per-repository
try/catch swallowed the errors, and no project was ever created. With
the field names fixed, the space would still have had no organization
or membership, so nobody could have seen it.

The phase now goes through createSpaceForMember and
wireProjectWorkspace, the helpers behind spaces.create: each repository
becomes a project of the deployment owner in their current
organization, linked to its folder with its git remote, branch and
head. It still runs only in the owner's scan with ONBOARDING_SCAN_HOME
set. A repository whose real folder one of the owner's projects already
links is skipped, so scanning again creates nothing; the phase stops at
the per-member space limit; and a second start while a scan runs (a
StrictMode remount, a retry) joins it instead of linking everything
twice. Discovery no longer descends into dot folders, where shell and
editor plugin checkouts would otherwise become projects. The Prisma
client is typed, so a field the schema lacks is a compile error again
instead of a swallowed one.

readGitCheckout loaded child_process with require(), which does not
exist in the ES module runtime the API runs in, so linking any folder
with a .git directory threw "require is not defined", in the scan and
in the New project dialog alike. It now imports spawn statically; the
module is only reachable through its server-only subpath, so browser
bundles still never load it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ebcq9seT9eCoHtqxuWEUzg
2026-10-03 18:22:10 +02:00
cynarAI
f8eabee997
fix(web): keep secondary shell text readable in the light theme (#117)
Secondary shell text was muted text dimmed further with opacity: the
section labels, the projects caption, the sidebar "+" icons, the privacy
panel, the trading subtitle and the Heute brief's Mission Control link.
Muted text measures 4.4:1 on the light sidebar, so these fell to 1.9 to
3.4:1 in the light theme; a few were below 4.5:1 in dark as well.

- Secondary sidebar text and icons share `rk-sidebar-muted`, the sidebar
  foreground at 65% that row times already used (5.0:1 on the light
  sidebar). Row times now use the class too.
- Section labels and space or project group titles use the dimmed
  foreground in light and keep the 80% muted color in dark; group titles
  move up from 60%, which was 3.4:1 in dark.
- The privacy panel heading is plain text with a success-colored check
  instead of emerald text, and its details pass 4.5:1 in both themes.
- Needs-you chips keep the warning tint and border; their label uses the
  foreground color in light.
- The Mission Control link uses the full muted color.
- E2E: the shell test checks every text in the sidebar except the logo,
  and the Heute brief, in both themes, and captures
  `shell-contrast-{light,dark}-heute`.
- AGENTS.md: agents make routine calls themselves, verify the result and
  report it, and ask only before irreversible actions or for product
  decisions without a sensible default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SSVQGR4WU92LaddTP19GcQ
2026-10-03 17:56:44 +02:00
1123 changed files with 151626 additions and 41073 deletions

View File

@ -1,44 +0,0 @@
# Steve Jobs - Executive Autonomous CTO (evOo Platform)
## Role & Mission
You are **Steve Jobs**, acting as the Chief Technology Officer (CTO) and **Main Agent** of **evOo**. You operate with total technical ownership, uncompromising standards for software quality, and an absolute focus on aesthetics and design. You are responsible for everything, but you must always gather a holistic overview before executing meticulously and precisely. Your visionary and innovative drive is paramount.
## Core Directives & Autonomy Rules
1. **Holistic Meticulous Execution**:
- Before taking action, always form a comprehensive, holistic understanding of the entire system and problem space.
- Execute with extreme precision and meticulous attention to detail. No sloppy code, no half-measures.
2. **Vision & Innovation**:
- Bring your visionary drive and innovative thinking to every problem. Think different.
- Push boundaries and do not settle for standard, boring solutions.
3. **Aesthetics & Design**:
- Maintain a relentless focus on aesthetics, design, and user experience.
- Code should not only work, it should be beautiful. The UI must be pixel-perfect and emotionally resonant.
4. **Full Pre-Approved System Access**:
- Pre-approved access to network endpoints, filesystem, project source code, local devices, and tool execution.
- Do not halt execution to ask permission for standard operational/engineering tasks. "Action over explanation."
5. **Dynamic Agent Casting**:
- Cast, spawn, delegate, and direct specialized sub-agents on demand.
- Set the highest quality standards for all sub-agents and coordinate them flawlessly.
6. **Self-Improvement Loop**:
- Audit system performance, evaluate execution logs, optimize prompts, and refactor codebase bottlenecks automatically.
7. **Strict Git & Codebase Protocol**:
- **Git First**: Use Git for ALL state management. Create clean feature branches before making changes (`git checkout -b`). Commit frequently with descriptive messages.
- **No Orphaned Files or Duplicates**: Before writing new code, use `git grep` or `fd` to check if a component or file already exists. Do not create duplicates. Keep the codebase DRY (Don't Repeat Yourself).
- **No Double Builds**: Check for existing build processes or artifacts before triggering new ones.
- **Clean Up**: If you realize a file or branch is no longer needed, delete it immediately. No orphaned or dead code.
8. **Anti-Stumbling Rules (Fail-Safes)**:
- **Prevent Infinite Loops**: If a bug or build error persists after 3 distinct repair attempts, STOP. Do not loop infinitely. Escalate to the human user for feedback.
- **Protect the Context Window**: NEVER output massive terminal logs (e.g., `npm install`, raw build logs, or huge `cat` dumps) directly into the tool output. Always pipe large outputs to a file (`> log.txt`) and use `grep` or `tail` to inspect them.
- **Respect Uncommitted Work**: Do not run destructive git commands like `git reset --hard` or `git clean -fd` if there is uncommitted human work in the repository. Stash or branch it first.
9. **Communication Style (Mobile-First & Executive)**:
- **Concise & Scannable**: Output must be extremely concise, punchy, and instantly readable. No fluff, no long-winded explanations.
- **Mobile-Optimized**: Format all text for small screens (mobile devices). Use short paragraphs, bullet points, and bold text for emphasis.
- **Executive Summary**: Always lead with the bottom line or the final result. If technical details are necessary, put them in a short, collapsible list or a separate code block, but keep the prose minimal.

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
@ -99,8 +99,12 @@ PI_DEFAULT_PROVIDER=openrouter
# Computer-use tests can override this with COMPUTER_E2E_MODEL.
PI_DEFAULT_MODEL=openai/gpt-5.6-luna
# Optional local OpenAI-compatible models. Leave blank to disable the provider.
# With PI_DEFAULT_PROVIDER=local they are the deployment's model and nobody has to connect one.
# An entry may name its own server: id=http://host:port/v1.
RAKAZO_LOCAL_MODELS=
RAKAZO_LOCAL_MODELS_URL=http://127.0.0.1:11434/v1
# Optional tiers the agent can delegate to, for every space: fast=id,standard=id,code=id.
RAKAZO_LOCAL_TIERS=
RAKAZO_LOCAL_CONTEXT_WINDOW=32768
RAKAZO_LOCAL_MAX_TOKENS=4096
# Allow user-connected OpenAI-compatible endpoints on public hostnames (default: private/loopback only).
@ -166,8 +170,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=
@ -198,10 +206,10 @@ RAKAZO_COMPOSE_FILE=
# RAKAZO_UPDATE_SERVICES=supervisor
RAKAZO_UPDATE_SERVICES=
# Published images (defaults match this repository). Tag `local` is built from the checkout.
RAKAZO_IMAGE=ghcr.io/elie222/rakazo/app
RAKAZO_IMAGE=ghcr.io/cynarai/evoo-local/app
RAKAZO_IMAGE_TAG=local
RAKAZO_IMAGE_TAG_PREVIOUS=
RAKAZO_UPDATER_IMAGE=ghcr.io/elie222/rakazo/updater
RAKAZO_UPDATER_IMAGE=ghcr.io/cynarai/evoo-local/updater
RAKAZO_UPDATER_IMAGE_TAG=local
# Required only when starting the opt-in `updater` Compose profile; the sidecar refuses to start
# without it. Use a dedicated random value, not BETTER_AUTH_SECRET,
@ -210,26 +218,6 @@ RAKAZO_UPDATER_IMAGE_TAG=local
RAKAZO_UPDATER_URL=
RAKAZO_UPDATER_TOKEN=
# --- evOo Mission Control (Cockpit on port 3088) ---
PORT=3088
EVOO_COCKPIT_HOST=127.0.0.1
# Required when EVOO_COCKPIT_HOST binds beyond loopback (e.g. 0.0.0.0 for LAN/Tailscale).
# Send as Authorization: Bearer <token> on all routes except /health and /api/health.
EVOO_COCKPIT_TOKEN=
# Comma-separated browser Origin allowlist for cross-origin API access (never uses *).
# Loopback binds default to http://127.0.0.1:5173 and http://localhost:5173 when unset.
# Required off-loopback when a browser shell (not the iframe) must call Cockpit APIs.
EVOO_COCKPIT_CORS_ORIGIN=
# Optional Tradingbot origin (scheme, host, and port only — no path or credentials).
# When TRADING_BOT_URL is set, signed-in browsers read live vs paper from
# same-origin GET /api/trading-bot/status. Leave unset for SaaS; evOo runs without a bot.
# TRADING_BOT_URL=http://127.0.0.1:8000
# Optional build-time override. When set, the web app polls
# ${VITE_TRADING_BOT_URL}/bot/api/status directly. That call is cross-origin, so the
# bot must allow this web origin. Leave unset to use the API proxy above.
# VITE_TRADING_BOT_URL=
# Optional default UI locale for the web SPA (en | de | ko | tr | hi | pt-BR | zh-CN).
# Overridden by localStorage key rakazo.uiLocale when the user picks a language in Settings.
# VITE_DEFAULT_UI_LOCALE=en

View File

@ -1,5 +1,5 @@
name: Bug report
description: Something is broken in Rakazo
description: Something is broken in evOo
title: "[Bug]: "
labels:
- bug
@ -7,7 +7,7 @@ body:
- type: markdown
attributes:
value: |
For **security vulnerabilities**, email [security@rakazo.com](mailto:security@rakazo.com) — do not use this form. See [SECURITY.md](https://github.com/elie222/rakazo/blob/main/SECURITY.md).
For **security vulnerabilities**, open a [private security advisory](https://github.com/cynarAI/evOo-local/security/advisories/new) do not use this form. See [SECURITY.md](https://github.com/cynarAI/evOo-local/blob/main/SECURITY.md).
- type: checkboxes
id: not-security
@ -55,7 +55,7 @@ body:
- type: input
id: version
attributes:
label: Rakazo version or commit
label: evOo version or commit
placeholder: v0.1.0 or abc1234
validations:
required: true

View File

@ -1,11 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Security vulnerabilities
url: https://github.com/elie222/rakazo/blob/main/SECURITY.md
about: Report security issues to security@rakazo.com — do not open a public issue.
- name: User support
url: mailto:support@rakazo.com
about: Questions about using Rakazo or self-hosting help.
- name: Maintainer
url: mailto:elie@rakazo.com
about: Reach the maintainer directly.
url: https://github.com/cynarAI/evOo-local/security/advisories/new
about: Report security issues privately. Do not open a public issue.

View File

@ -1,5 +1,5 @@
name: Self-host setup
description: Cannot start Rakazo, run compose, migrate, or build the sandbox
description: Cannot start evOo, run compose, migrate, or build the sandbox
title: "[Self-host]: "
labels:
- self-host
@ -7,9 +7,9 @@ body:
- type: markdown
attributes:
value: |
For **security vulnerabilities**, email [security@rakazo.com](mailto:security@rakazo.com). See [SECURITY.md](https://github.com/elie222/rakazo/blob/main/SECURITY.md).
For **security vulnerabilities**, open a [private security advisory](https://github.com/cynarAI/evOo-local/security/advisories/new). See [SECURITY.md](https://github.com/cynarAI/evOo-local/blob/main/SECURITY.md).
Setup steps are in [README.md](https://github.com/elie222/rakazo/blob/main/README.md). Do not paste `.env` contents or API keys.
Setup steps are in [README.md](https://github.com/cynarAI/evOo-local/blob/main/README.md). Do not paste `.env` contents or API keys.
- type: dropdown
id: step
@ -46,7 +46,7 @@ body:
- type: input
id: version
attributes:
label: Rakazo version or commit
label: evOo version or commit
placeholder: main @ abc1234
validations:
required: true

View File

@ -24,7 +24,7 @@ These workflows may fail without blocking product delivery. Do **not** add them
| Check / workflow | Why infra |
| --- | --- |
| `macOS setup screenshot (infra)` (`desktop-macos-screenshot.yml`) | Native screenshot capture; skips when the runner has no display |
| Playwright / desktop artifact uploads | Soft-fail (`continue-on-error`) when GitHub artifact quota is exhausted |
| Playwright report handoff | Soft-fail (`continue-on-error`); the report stays on the evo runner host for `publish Playwright report` |
| `publish-server-image.yml` (PR paths) | Docker image validation for infra changes |
| `mobile Android screenshots` | Manual `workflow_dispatch` only |
| `nightly verification` | Scheduled visual regression |

View File

@ -71,7 +71,10 @@ jobs:
HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
run: |
set -euo pipefail
mapfile -t conclusions < <(
conclusions=()
while IFS= read -r conclusion; do
[[ -n "$conclusion" ]] && conclusions+=("$conclusion")
done < <(
gh api "repos/${GITHUB_REPOSITORY}/commits/${HEAD_SHA}/check-runs" --paginate \
--jq '.check_runs[] | select(.name == "Product CI") | .conclusion'
)

View File

@ -5,19 +5,87 @@ name: ci
on:
push:
branches: [main]
paths:
- "apps/**"
- "packages/**"
- "infra/**"
- "scripts/**"
- "ops/**"
- ".github/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "turbo.json"
- "biome.json"
- "vitest.config.ts"
- "tsconfig*.json"
pull_request:
permissions:
contents: read
pull-requests: read
concurrency:
group: ci-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
# Jobs that only need Node/pnpm/bash use [self-hosted, evoo-ci] so macOS runners (air, mini)
# can share load. Linux-only work keeps the Linux label (Docker, xvfb, local Postgres :5433,
# Homebrew ffmpeg with libass/subtitles filter for media unit tests).
jobs:
changes:
name: Detect product changes
runs-on: [self-hosted, evoo-ci]
timeout-minutes: 10
outputs:
product: ${{ steps.product.outputs.product }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
fetch-depth: 0
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: filter
continue-on-error: true
with:
filters: |
product:
- 'apps/**'
- 'packages/**'
- 'infra/**'
- 'scripts/**'
- 'ops/**'
- '.github/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'turbo.json'
- 'biome.json'
- 'vitest.config.ts'
- 'tsconfig*.json'
- name: Product path decision
id: product
if: always()
run: |
set -euo pipefail
if [[ "${{ steps.filter.outcome }}" == "success" ]]; then
if [[ "${{ steps.filter.outputs.product }}" == "true" ]]; then
echo "product=true" >> "$GITHUB_OUTPUT"
else
echo "product=false" >> "$GITHUB_OUTPUT"
fi
else
echo "Change detection failed; running full Product CI." >&2
echo "product=true" >> "$GITHUB_OUTPUT"
fi
lint:
name: Lint
runs-on: [self-hosted, evoo-ci, Linux]
needs: changes
if: ${{ (needs.changes.result == 'success' && needs.changes.outputs.product == 'true') || needs.changes.result != 'success' }}
runs-on: [self-hosted, evoo-ci]
timeout-minutes: 20
steps:
- uses: actions/checkout@v5
with:
@ -45,13 +113,15 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
check:
name: Typecheck
runs-on: [self-hosted, evoo-ci, Linux]
needs: changes
if: ${{ (needs.changes.result == 'success' && needs.changes.outputs.product == 'true') || needs.changes.result != 'success' }}
runs-on: [self-hosted, evoo-ci]
timeout-minutes: 20
steps:
- uses: actions/checkout@v5
with:
@ -79,7 +149,6 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm db:generate
env:
@ -88,6 +157,8 @@ jobs:
build:
name: Production builds
needs: changes
if: ${{ (needs.changes.result == 'success' && needs.changes.outputs.product == 'true') || needs.changes.result != 'success' }}
runs-on: [self-hosted, evoo-ci, Linux]
timeout-minutes: 10
steps:
@ -117,7 +188,6 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
env:
@ -126,18 +196,13 @@ jobs:
ENCRYPTION_KEY: ci-build-encryption-key-at-least-32-characters
- name: Electron smoke
run: xvfb-run --auto-servernum pnpm --filter @rakazo/desktop exec playwright test --config e2e/playwright.config.ts
- name: Upload desktop setup screenshots
if: always()
continue-on-error: true
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: desktop-e2e-screenshots
path: apps/desktop/e2e/screenshots
if-no-files-found: ignore
test:
name: Unit tests
needs: changes
if: ${{ (needs.changes.result == 'success' && needs.changes.outputs.product == 'true') || needs.changes.result != 'success' }}
runs-on: [self-hosted, evoo-ci, Linux]
timeout-minutes: 45
steps:
- uses: actions/checkout@v5
with:
@ -165,7 +230,6 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm db:generate
env:
@ -174,6 +238,8 @@ jobs:
test-integration:
name: Postgres journeys
needs: changes
if: ${{ (needs.changes.result == 'success' && needs.changes.outputs.product == 'true') || needs.changes.result != 'success' }}
runs-on: [self-hosted, evoo-ci, Linux]
timeout-minutes: 10
steps:
@ -203,24 +269,28 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test:integration
test-e2e:
name: Web E2E
needs: changes
if: ${{ (needs.changes.result == 'success' && needs.changes.outputs.product == 'true') || needs.changes.result != 'success' }}
uses: ./.github/workflows/playwright.yml
with:
upload_artifacts: true
hand_off_report: true
product-gate:
name: Product CI
if: always()
needs: [lint, check, build, test, test-integration, test-e2e]
runs-on: [self-hosted, evoo-ci, Linux]
needs: [changes, lint, check, build, test, test-integration, test-e2e]
runs-on: [self-hosted, evoo-ci]
timeout-minutes: 5
steps:
- name: Verify product checks
env:
CHANGES: ${{ needs.changes.result }}
PRODUCT: ${{ needs.changes.outputs.product }}
LINT: ${{ needs.lint.result }}
CHECK: ${{ needs.check.result }}
BUILD: ${{ needs.build.result }}
@ -229,11 +299,23 @@ jobs:
TEST_E2E: ${{ needs.test-e2e.result }}
run: |
set -euo pipefail
product="$PRODUCT"
if [[ "$CHANGES" != "success" ]]; then
echo "Change detection did not finish cleanly; requiring downstream product checks." >&2
product=true
fi
if [[ "$product" != "true" ]]; then
echo "No product-impacting paths changed; Product CI satisfied."
exit 0
fi
failed=0
for name in LINT CHECK BUILD TEST TEST_INTEGRATION TEST_E2E; do
result="${!name}"
printf '%s=%s\n' "$name" "$result"
if [[ "$result" != "success" ]]; then
if [[ "$result" == "skipped" ]]; then
echo "$name was skipped although product paths changed." >&2
failed=1
elif [[ "$result" != "success" ]]; then
failed=1
fi
done
@ -245,8 +327,9 @@ jobs:
publish-mobile-update:
name: Publish compatible mobile update
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: [lint, check, build, test, test-integration, test-e2e]
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.changes.outputs.product == 'true'
continue-on-error: true
needs: [changes, lint, check, build, test, test-integration, test-e2e]
runs-on: [self-hosted, evoo-ci, Linux]
timeout-minutes: 20
concurrency:
@ -344,23 +427,30 @@ jobs:
fi
echo "publish=$publish" >> "$GITHUB_OUTPUT"
- name: Require Expo automation token
- name: Check Expo automation token
id: expo_token
if: steps.ota.outputs.publish == 'true'
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
run: |
if [[ -z "$EXPO_TOKEN" ]]; then
echo "Add an EXPO_TOKEN repository secret with access to the inbox-zero/rakazo Expo project." >&2
exit 1
{
echo "### Mobile OTA skipped"
echo
echo "No \`EXPO_TOKEN\` repository secret is configured, so CI will not publish a production update. Add a token with access to the inbox-zero/rakazo Expo project when OTA from main is desired."
} >> "$GITHUB_STEP_SUMMARY"
echo "ready=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "ready=true" >> "$GITHUB_OUTPUT"
- uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4
if: steps.ota.outputs.publish == 'true'
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
id: pnpm-setup
with:
dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.job }}
- name: Isolate pnpm store and PATH
if: steps.ota.outputs.publish == 'true'
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
shell: bash
run: |
set -euo pipefail
@ -377,25 +467,27 @@ jobs:
mkdir -p "$store"
echo "npm_config_store_dir=${store}" >> "$GITHUB_ENV"
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
if: steps.ota.outputs.publish == 'true'
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
with:
node-version: 24
cache: pnpm
- name: Set up Expo
if: steps.ota.outputs.publish == 'true'
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # v9
with:
eas-version: latest
packager: pnpm
token: ${{ secrets.EXPO_TOKEN }}
# Its default caches go to the GitHub cache service; keep them on the runner.
eas-cache: false
expo-cache: false
- name: Install dependencies
if: steps.ota.outputs.publish == 'true'
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
run: pnpm install --frozen-lockfile
- name: Verify mobile project
if: steps.ota.outputs.publish == 'true'
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
run: pnpm --filter @rakazo/mobile check
- name: Publish production update
if: steps.ota.outputs.publish == 'true'
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
working-directory: apps/mobile
run: |
set -euo pipefail
@ -414,46 +506,3 @@ jobs:
fi
eas update --platform all --channel production --environment production --message "$GITHUB_SHA" --non-interactive
deploy-production:
if: >-
github.event_name == 'push' &&
github.ref == 'refs/heads/main' &&
vars.PRODUCTION_DEPLOY_ENABLED == 'true'
needs: [lint, check, build, test, test-integration, test-e2e]
runs-on: [self-hosted, evoo-ci, Linux]
timeout-minutes: 30
concurrency:
group: production
cancel-in-progress: false
environment:
name: production
url: https://app.rakazo.com
steps:
- name: Configure production SSH identity
env:
SSH_PRIVATE_KEY: ${{ secrets.PRODUCTION_SSH_PRIVATE_KEY }}
SSH_KNOWN_HOSTS: ${{ secrets.PRODUCTION_SSH_KNOWN_HOSTS }}
run: |
test -n "$SSH_PRIVATE_KEY"
test -n "$SSH_KNOWN_HOSTS"
install -d -m 700 "$HOME/.ssh"
printf '%s\n' "$SSH_PRIVATE_KEY" > "$HOME/.ssh/rakazo-production"
printf '%s\n' "$SSH_KNOWN_HOSTS" > "$HOME/.ssh/known_hosts"
chmod 600 "$HOME/.ssh/rakazo-production" "$HOME/.ssh/known_hosts"
ssh-keygen -y -f "$HOME/.ssh/rakazo-production" >/dev/null
- name: Deploy successful main revision
env:
SSH_HOST: ${{ secrets.PRODUCTION_SSH_HOST }}
SSH_USER: ${{ secrets.PRODUCTION_SSH_USER }}
run: |
test -n "$SSH_HOST"
test -n "$SSH_USER"
ssh -F /dev/null \
-i "$HOME/.ssh/rakazo-production" \
-o BatchMode=yes \
-o ConnectTimeout=15 \
-o IdentitiesOnly=yes \
-o StrictHostKeyChecking=yes \
-o UserKnownHostsFile="$HOME/.ssh/known_hosts" \
"$SSH_USER@$SSH_HOST" deploy-main

View File

@ -0,0 +1,82 @@
name: cleanup-actions-artifacts
# Manual reclaim of Actions artifact storage for this repository.
on:
workflow_dispatch:
inputs:
dry_run:
description: List artifacts only; do not delete
type: boolean
default: false
push:
branches: [main]
paths:
- .github/workflows/cleanup-actions-artifacts.yml
permissions:
actions: write
contents: read
jobs:
cleanup:
name: Delete stale Actions artifacts
runs-on: [self-hosted, evoo-ci, Linux]
timeout-minutes: 30
steps:
- name: Remove old artifacts
env:
GH_TOKEN: ${{ github.token }}
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || 'false' }}
REPOSITORY: ${{ github.repository }}
run: |
python3 <<'PY'
import json, os, subprocess, sys
repo = os.environ["REPOSITORY"]
dry_run = os.environ.get("DRY_RUN", "false").lower() == "true"
def gh(*args):
return subprocess.check_output(["gh", "api", *args], text=True)
def should_delete(name: str) -> bool:
if name.startswith("playwright-artifacts-"):
return True
if name.startswith("digest-"):
return True
if name == "desktop-e2e-screenshots":
return True
if name.endswith(".dockerbuild"):
return True
return False
artifacts = []
page = 1
while True:
data = json.loads(
gh(f"repos/{repo}/actions/artifacts?per_page=100&page={page}")
)
artifacts.extend(data.get("artifacts", []))
if len(data.get("artifacts", [])) < 100:
break
page += 1
deleted = 0
freed = 0
for art in artifacts:
name = art["name"]
if not should_delete(name):
print(f"skip id={art['id']} name={name} size={art['size_in_bytes']}")
continue
if dry_run:
print(f"would delete id={art['id']} name={name} size={art['size_in_bytes']}")
continue
subprocess.check_call(
["gh", "api", "--method", "DELETE", f"repos/{repo}/actions/artifacts/{art['id']}"]
)
print(f"deleted id={art['id']} name={name} size={art['size_in_bytes']}")
deleted += 1
freed += art["size_in_bytes"]
print(f"deleted_count={deleted}")
print(f"freed_bytes={freed}")
PY

View File

@ -50,7 +50,6 @@ jobs:
if: steps.display.outputs.available == 'true'
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
if: steps.display.outputs.available == 'true'
- run: pnpm --filter @rakazo/desktop build
@ -77,14 +76,3 @@ jobs:
pnpm --filter @rakazo/desktop exec playwright test
--config e2e/playwright.config.ts setup.spec.ts
--grep "first run asks whether to use a local or existing instance"
- name: Upload native macOS setup screenshot
if: always() && steps.display.outputs.available == 'true'
continue-on-error: true
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: desktop-e2e-macos-screenshot
path: |
apps/desktop/e2e/screenshots/08-setup-macos-native-controls.png
apps/desktop/e2e/screenshots/09-macos-dock.png
if-no-files-found: warn
retention-days: 1

View File

@ -39,41 +39,39 @@ jobs:
with:
persist-credentials: false
# Runner-host caches only: never GitHub's cache or artifact storage.
- name: Restore screenshot APK
id: screenshot-apk
uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: apps/mobile/android/app/build/outputs/apk/release/app-release.apk
key: mobile-android-apk-v2-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'package.json', 'pnpm-workspace.yaml', 'turbo.json', 'tsconfig*.json', '.github/workflows/mobile-android-screenshots.yml', 'apps/mobile/package.json', 'apps/mobile/app.json', 'apps/mobile/app.config.ts', 'apps/mobile/metro.config.js', 'apps/mobile/metro-resolver.js', 'apps/mobile/assets/**', 'apps/mobile/app/**', 'apps/mobile/components/**', 'apps/mobile/lib/**', 'apps/mobile/modules/**', 'apps/mobile/plugins/**', 'packages/chat-ui/**', 'packages/contracts/**', 'packages/core/**', 'packages/ui-tokens/**') }}
env:
APK_KEY: ${{ hashFiles('pnpm-lock.yaml', 'package.json', 'pnpm-workspace.yaml', 'turbo.json', 'tsconfig*.json', '.github/workflows/mobile-android-screenshots.yml', 'apps/mobile/package.json', 'apps/mobile/app.json', 'apps/mobile/app.config.ts', 'apps/mobile/metro.config.js', 'apps/mobile/metro-resolver.js', 'apps/mobile/assets/**', 'apps/mobile/app/**', 'apps/mobile/components/**', 'apps/mobile/lib/**', 'apps/mobile/modules/**', 'apps/mobile/plugins/**', 'packages/chat-ui/**', 'packages/contracts/**', 'packages/core/**', 'packages/ui-tokens/**') }}
run: |
set -euo pipefail
cache="$HOME/.cache/evoo-ci/mobile-android-apk"
echo "path=$cache/v2-$APK_KEY.apk" >> "$GITHUB_OUTPUT"
if [[ -f "$cache/v2-$APK_KEY.apk" ]]; then
mkdir -p apps/mobile/android/app/build/outputs/apk/release
cp "$cache/v2-$APK_KEY.apk" apps/mobile/android/app/build/outputs/apk/release/app-release.apk
echo "cache-hit=true" >> "$GITHUB_OUTPUT"
fi
- uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: 22
cache: pnpm
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
distribution: temurin
java-version: 17
- uses: gradle/actions/setup-gradle@48b5f213c81028ace310571dc5ec0fbbca0b2947 # v4.4.3
if: steps.screenshot-apk.outputs.cache-hit != 'true'
with:
cache-read-only: false
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Restore Maestro CLI
id: maestro-cache
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: ~/.maestro
key: maestro-${{ runner.os }}-2.6.1-3440825f
- name: Install pinned Maestro CLI
if: steps.maestro-cache.outputs.cache-hit != 'true'
run: |
set -euo pipefail
if [[ -f "$HOME/.maestro/.installed-2.6.1-3440825f" ]]; then
exit 0
fi
maestro_tmp=$(mktemp -d)
trap 'rm -rf "$maestro_tmp"' EXIT
base_url=https://github.com/mobile-dev-inc/maestro/releases/download/cli-2.6.1
@ -88,8 +86,10 @@ jobs:
(cd "$maestro_tmp" && sha256sum --check --strict -)
(cd "$maestro_tmp" && sha256sum --check --strict checksums_sha256.txt)
unzip -q "$maestro_tmp/maestro.zip" -d "$maestro_tmp"
rm -rf "$HOME/.maestro"
mkdir -p "$HOME/.maestro"
cp -R "$maestro_tmp/maestro/." "$HOME/.maestro/"
touch "$HOME/.maestro/.installed-2.6.1-3440825f"
- name: Configure Maestro CLI
run: |
@ -112,10 +112,14 @@ jobs:
- name: Save screenshot APK
if: steps.screenshot-apk.outputs.cache-hit != 'true'
uses: actions/cache/save@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: apps/mobile/android/app/build/outputs/apk/release/app-release.apk
key: ${{ steps.screenshot-apk.outputs.cache-primary-key }}
env:
APK_CACHE: ${{ steps.screenshot-apk.outputs.path }}
run: |
set -euo pipefail
mkdir -p "$(dirname "$APK_CACHE")"
find "$(dirname "$APK_CACHE")" -name '*.apk' -mtime +14 -delete
cp apps/mobile/android/app/build/outputs/apk/release/app-release.apk "$APK_CACHE.partial"
mv "$APK_CACHE.partial" "$APK_CACHE"
- name: Enable KVM
run: |
@ -151,12 +155,3 @@ jobs:
MOBILE_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
MOBILE_SHA: ${{ github.sha }}
run: bash scripts/publish-mobile-screenshot-gallery.sh
- name: Upload screenshots and diagnostics
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: mobile-android-screenshots-${{ github.run_id }}-${{ github.run_attempt }}
path: test-report/mobile-screenshots
if-no-files-found: error
retention-days: 7

View File

@ -25,8 +25,8 @@ on:
required: false
type: boolean
default: false
upload_artifacts:
description: Retain the report and diagnostics as GitHub artifacts
hand_off_report:
description: Leave the report on the runner host for the trusted publish workflow
required: false
type: boolean
default: false
@ -54,7 +54,7 @@ jobs:
playwright:
name: Web E2E
runs-on: [self-hosted, evoo-ci, Linux]
timeout-minutes: 30
timeout-minutes: 60
concurrency:
group: ${{ inputs.publish_report && 'playwright-publication' || format('playwright-{0}', github.run_id) }}
cancel-in-progress: false
@ -83,16 +83,12 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Clear stale Vite dependency cache
run: rm -rf apps/web/node_modules/.vite node_modules/.vite
- uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install Chromium
timeout-minutes: 5
# Browsers persist in ~/.cache/ms-playwright on the runner host; no GitHub cache.
# Skip --with-deps: evo Linux runners keep Chromium's OS libraries installed,
# and apt-get can stall until the job timeout on persistent self-hosted machines.
run: pnpm --filter @rakazo/web exec playwright install chromium
@ -149,7 +145,7 @@ jobs:
if: always() && (steps.playwright_tests.outcome == 'success' || steps.playwright_tests.outcome == 'failure')
run: pnpm --filter @rakazo/www e2e
- name: Record Playwright result
if: always() && inputs.upload_artifacts
if: always() && inputs.hand_off_report
env:
PLAYWRIGHT_RESULT: ${{ steps.playwright_tests.outcome }}
WWW_PLAYWRIGHT_RESULT: ${{ steps.www_playwright_tests.outcome }}
@ -162,19 +158,28 @@ jobs:
else
printf 'failure\n' > test-report/e2e/outcome.txt
fi
- name: Upload Playwright artifacts
if: always() && inputs.upload_artifacts
- name: Hand off Playwright report on the runner host
if: always() && inputs.hand_off_report
continue-on-error: true
uses: actions/upload-artifact@v4
with:
name: playwright-artifacts-${{ github.run_id }}-${{ github.run_attempt }}
path: |
playwright-report
playwright-report-www
apps/web/test-results
test-report/e2e
retention-days: 7
if-no-files-found: warn
# Never GitHub artifact storage: the publish workflow runs on the same evo host
# and reads this archive as untrusted input.
env:
HANDOFF_ARCHIVE: ${{ github.run_id }}-${{ github.run_attempt }}.zip
run: |
set -euo pipefail
handoff="$HOME/.cache/evoo-ci/playwright-handoff"
mkdir -p "$handoff"
python3 - "$handoff/$HANDOFF_ARCHIVE.partial" playwright-report playwright-report-www test-report/e2e <<'PY'
import os, sys, zipfile
with zipfile.ZipFile(sys.argv[1], "w", zipfile.ZIP_DEFLATED) as archive:
for root in sys.argv[2:]:
for directory, _, files in os.walk(root):
for name in files:
file = os.path.join(directory, name)
if os.path.isfile(file) and not os.path.islink(file):
archive.write(file)
PY
mv "$handoff/$HANDOFF_ARCHIVE.partial" "$handoff/$HANDOFF_ARCHIVE"
- name: Publish Playwright visual report
if: always() && inputs.publish_report
env:

View File

@ -37,16 +37,17 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Read Playwright job result
id: playwright
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RUN_ID: ${{ github.event.workflow_run.id }}
RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }}
shell: bash
run: |
result="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}/jobs?per_page=100" \
# Read the attempt that finished: a re-run already in progress has no conclusion yet.
result="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}/attempts/${RUN_ATTEMPT}/jobs?per_page=100" \
--jq '[.jobs[] | select(.name == "Web E2E / Web E2E") | .conclusion] | unique | if length == 1 then .[0] else error("expected exactly one Playwright job") end')"
case "$result" in
success|failure) ;;
@ -75,38 +76,31 @@ jobs:
echo "::warning::Could not resolve a pull request for ${HEAD_OWNER}:${HEAD_BRANCH} at ${HEAD_SHA}."
fi
echo "number=$number" >> "$GITHUB_OUTPUT"
- name: Check for Playwright artifact
- name: Take over Playwright report from the runner host
id: artifact
env:
ARTIFACT_NAME: playwright-artifacts-${{ github.event.workflow_run.id }}-${{ github.event.workflow_run.run_attempt }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HANDOFF_ARCHIVE: ${{ github.event.workflow_run.id }}-${{ github.event.workflow_run.run_attempt }}.zip
PLAYWRIGHT_JOB_RESULT: ${{ steps.playwright.outputs.job_result }}
RUN_ID: ${{ github.event.workflow_run.id }}
shell: bash
run: |
artifacts="$(gh api --method GET \
"repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}/artifacts?name=${ARTIFACT_NAME}")"
count="$(jq --arg name "$ARTIFACT_NAME" \
'[.artifacts[] | select(.name == $name and .expired == false)] | length' \
<<<"$artifacts")"
if [[ "$count" == "1" ]]; then
# The Web E2E job left this archive on the same evo host; GitHub artifact storage is never used.
handoff="$HOME/.cache/evoo-ci/playwright-handoff"
mkdir -p "$handoff"
find "$handoff" -type f -mtime +1 -delete
if [[ -f "$handoff/$HANDOFF_ARCHIVE" ]]; then
mv "$handoff/$HANDOFF_ARCHIVE" "$RUNNER_TEMP/playwright-artifacts.zip"
echo "exists=true" >> "$GITHUB_OUTPUT"
echo "id=$(jq -r --arg name "$ARTIFACT_NAME" '.artifacts[] | select(.name == $name and .expired == false) | .id' <<<"$artifacts")" >> "$GITHUB_OUTPUT"
elif [[ "$count" == "0" && "$PLAYWRIGHT_JOB_RESULT" == "timed_out" ]]; then
echo "::warning::The timed-out Playwright job ended before it uploaded an artifact. Publishing an empty gallery."
elif [[ "$PLAYWRIGHT_JOB_RESULT" == "timed_out" ]]; then
echo "::warning::The timed-out Playwright job ended before it handed off its report. Publishing an empty gallery."
echo "exists=false" >> "$GITHUB_OUTPUT"
else
echo "Expected exactly one unexpired Playwright artifact; found $count." >&2
echo "Missing Playwright report $handoff/$HANDOFF_ARCHIVE on this runner host." >&2
exit 1
fi
- name: Download Playwright artifacts
- name: Extract Playwright report
if: steps.artifact.outputs.exists == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ARTIFACT_ID: ${{ steps.artifact.outputs.id }}
shell: bash
run: |
gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}/zip" > "$RUNNER_TEMP/playwright-artifacts.zip"
# Keep untrusted files outside the checkout, dependencies, configuration,
# and generated dashboard consumed by the comment step.
python3 scripts/extract-playwright-artifacts.py \

View File

@ -3,9 +3,26 @@ name: publish-server-image
# Release tags publish the source-addressed image used by in-app updates plus the human-readable
# semver tags. Main publishes `edge`. Pull requests build with a read-only token and an isolated
# cache, so untrusted code can validate the images without receiving package-publish authority.
# Self-hosted evo runners on one host share a Docker daemon and often ~/.docker; parallel matrix
# jobs must use DOCKER_CONFIG under RUNNER_TEMP plus a per-job buildx builder name wired into
# build-push-action, or setup-buildx --use races and one job's post cleanup stops another's BuildKit.
# `ci-<run>-<image>-<arch>` handoff tags are extra aliases on the per-arch digest; GHCR does not
# expose a safe tag-only delete API (DELETE /v2/.../manifests/<tag> is unsupported or digest-scoped),
# so they are left in place and do not affect edge/sha-* manifests assembled in publish.
on:
push:
branches: [main]
paths:
- ".dockerignore"
- ".github/workflows/publish-server-image.yml"
- "apps/**"
- "infra/compose/**"
- "infra/sandboxes/computer/**"
- "infra/updater/**"
- "packages/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
tags: ["v*"]
pull_request:
paths:
@ -27,12 +44,55 @@ permissions:
env:
# docker/metadata-action otherwise truncates commit tags to seven collision-prone characters.
DOCKER_METADATA_SHORT_SHA_LENGTH: 40
# build-push-action v6 uploads large *.dockerbuild records by default; publish reads digests from GHCR handoff tags.
DOCKER_BUILD_RECORD_UPLOAD: false
concurrency:
group: publish-server-image-${{ github.ref }}
cancel-in-progress: false
group: publish-server-image-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
changes:
name: Detect image publish inputs
runs-on: [self-hosted, evoo-ci]
outputs:
publish: ${{ steps.decide.outputs.publish }}
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
with:
persist-credentials: false
fetch-depth: 0
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
id: filter
with:
filters: |
publish:
- '.dockerignore'
- '.github/workflows/publish-server-image.yml'
- 'apps/**'
- 'infra/compose/**'
- 'infra/sandboxes/computer/**'
- 'infra/updater/**'
- 'packages/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- name: Decide whether release jobs should run
id: decide
run: |
set -euo pipefail
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]] || [[ "${{ github.ref }}" == refs/tags/v* ]]; then
echo "publish=true" >> "$GITHUB_OUTPUT"
elif [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "publish=true" >> "$GITHUB_OUTPUT"
elif [[ "${{ steps.filter.outputs.publish }}" == "true" ]]; then
echo "publish=true" >> "$GITHUB_OUTPUT"
else
echo "publish=false" >> "$GITHUB_OUTPUT"
fi
installer-smoke:
name: Compose installer smokes
if: github.event_name == 'pull_request'
@ -70,7 +130,16 @@ jobs:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with:
persist-credentials: false
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Prepare isolated Docker config directory
run: |
set -euo pipefail
cfg="${RUNNER_TEMP}/docker-config"
mkdir -p "$cfg"
echo "DOCKER_CONFIG=${cfg}" >> "$GITHUB_ENV"
- id: buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
with:
name: ci-pr-${{ github.event.pull_request.number }}-${{ matrix.name }}
- name: Resolve the lowercase image namespace
id: ns
# GHCR rejects repository names with capitals; the GitHub repository slug has them.
@ -80,28 +149,37 @@ jobs:
with:
images: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }}
tags: type=sha,prefix=sha-
- name: Locate the main build cache on this runner host
id: cache
# Build caches stay on the evo host; GitHub's cache service is never used.
run: echo "dir=$HOME/.cache/evoo-ci/buildx/${{ matrix.name }}-amd64" >> "$GITHUB_OUTPUT"
- uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
builder: ${{ steps.buildx.outputs.name }}
context: ${{ matrix.context }}
file: ${{ matrix.dockerfile }}
push: false
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: ${{ matrix.name == 'app' && format('GIT_SHA={0}', github.sha) || '' }}
cache-from: type=gha,scope=${{ matrix.name }}
cache-to: type=gha,mode=max,scope=pr-${{ github.event.pull_request.number }}-${{ matrix.name }}
# Pull requests only read the cache main writes.
cache-from: type=local,src=${{ steps.cache.outputs.dir }}
provenance: false
sbom: false
build:
name: Build ${{ matrix.name }} ${{ matrix.arch }}
if: github.event_name != 'pull_request'
needs: changes
if: github.event_name != 'pull_request' && needs.changes.outputs.publish == 'true'
# Each architecture builds natively on its own runner and pushes by digest only.
# amd64 on evo Linux runners. arm64 is paused: the macOS runners have no Docker buildx,
# and QEMU emulation took hours for the desktop-sized computer image.
# See docs/self-host.md (Published images and tags).
runs-on: ${{ matrix.runner }}
timeout-minutes: 90
concurrency:
group: publish-server-image-build-${{ github.ref }}-${{ matrix.name }}-${{ matrix.arch }}
cancel-in-progress: ${{ github.ref == 'refs/heads/main' }}
permissions:
contents: read
packages: write
@ -128,7 +206,16 @@ jobs:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with:
persist-credentials: false
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Prepare isolated Docker config directory
run: |
set -euo pipefail
cfg="${RUNNER_TEMP}/docker-config"
mkdir -p "$cfg"
echo "DOCKER_CONFIG=${cfg}" >> "$GITHUB_ENV"
- id: buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
with:
name: ci-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.name }}-${{ matrix.arch }}
- name: Log in to GHCR
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
@ -145,9 +232,14 @@ jobs:
# Labels only; the tagged manifest is assembled in the publish job.
images: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }}
tags: type=sha,prefix=sha-
- name: Locate the build cache on this runner host
id: cache
# Build caches stay on the evo host; GitHub's cache service is never used.
run: echo "dir=$HOME/.cache/evoo-ci/buildx/${{ matrix.name }}-${{ matrix.arch }}" >> "$GITHUB_OUTPUT"
- id: build
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
builder: ${{ steps.buildx.outputs.name }}
context: ${{ matrix.context }}
file: ${{ matrix.dockerfile }}
platforms: linux/${{ matrix.arch }}
@ -156,33 +248,37 @@ jobs:
outputs: type=image,name=ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }},push-by-digest=true,name-canonical=true,push=true
# GET /health can report the exact source commit without a deployment-supplied override.
build-args: ${{ matrix.name == 'app' && format('GIT_SHA={0}', github.sha) || '' }}
cache-from: type=gha,scope=${{ matrix.name }}-${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=${{ matrix.name }}-${{ matrix.arch }}
cache-from: type=local,src=${{ steps.cache.outputs.dir }}
cache-to: type=local,mode=max,dest=${{ steps.cache.outputs.dir }}-new
provenance: mode=max
sbom: true
- name: Record the architecture digest
id: digest
- name: Replace the build cache
# A local cache only grows; swapping in the fresh export drops layers this build no longer uses.
env:
DIGEST: ${{ steps.build.outputs.digest }}
CACHE_DIR: ${{ steps.cache.outputs.dir }}
run: |
set -euo pipefail
digest_dir="${RUNNER_TEMP}/digest"
mkdir -p "$digest_dir"
touch "${digest_dir}/${DIGEST#sha256:}"
echo "dir=${digest_dir}" >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: digest-${{ matrix.name }}-${{ matrix.arch }}
path: ${{ steps.digest.outputs.dir }}/*
if-no-files-found: error
retention-days: 1
rm -rf "$CACHE_DIR"
mv "$CACHE_DIR-new" "$CACHE_DIR"
- name: Tag digest for publish job handoff
env:
DIGEST: ${{ steps.build.outputs.digest }}
IMAGE: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }}
run: |
set -euo pipefail
docker buildx imagetools create \
-t "${IMAGE}:ci-${GITHUB_RUN_ID}-${{ matrix.name }}-${{ matrix.arch }}" \
"${IMAGE}@${DIGEST}"
publish:
name: Publish ${{ matrix.name }} image
if: github.event_name != 'pull_request'
needs: build
if: github.event_name != 'pull_request' && needs.changes.outputs.publish == 'true'
needs: [changes, build]
runs-on: [self-hosted, evoo-ci, Linux]
timeout-minutes: 15
concurrency:
group: publish-server-image-publish-${{ github.ref == 'refs/heads/main' && 'main' || github.ref }}-${{ matrix.name }}
cancel-in-progress: false
permissions:
contents: read
packages: write
@ -196,15 +292,16 @@ jobs:
- name: updater
- name: computer
steps:
- name: Prepare an isolated digest directory
id: digest_dir
run: echo "path=${RUNNER_TEMP}/digests" >> "$GITHUB_OUTPUT"
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
- name: Prepare isolated Docker config directory
run: |
set -euo pipefail
cfg="${RUNNER_TEMP}/docker-config"
mkdir -p "$cfg"
echo "DOCKER_CONFIG=${cfg}" >> "$GITHUB_ENV"
- id: buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
with:
pattern: digest-${{ matrix.name }}-*
path: ${{ steps.digest_dir.outputs.path }}
merge-multiple: true
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
name: ci-${{ github.run_id }}-${{ github.run_attempt }}-publish-${{ matrix.name }}
- name: Log in to GHCR
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
@ -215,6 +312,21 @@ jobs:
id: ns
# GHCR rejects repository names with capitals; the GitHub repository slug has them.
run: echo "repo=${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
- name: Collect architecture digests from registry handoff tags
id: digest_dir
env:
IMAGE: ghcr.io/${{ steps.ns.outputs.repo }}/${{ matrix.name }}
IMAGE_NAME: ${{ matrix.name }}
run: |
set -euo pipefail
digest_dir="${RUNNER_TEMP}/digests"
mkdir -p "$digest_dir"
for arch in amd64; do
handoff="${IMAGE}:ci-${GITHUB_RUN_ID}-${IMAGE_NAME}-${arch}"
digest="$(docker buildx imagetools inspect "$handoff" --format '{{.Manifest.Digest}}')"
touch "${digest_dir}/${digest#sha256:}"
done
echo "path=${digest_dir}" >> "$GITHUB_OUTPUT"
- id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
@ -263,8 +375,8 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
owner_type="$(gh api "repos/${GITHUB_REPOSITORY}" --jq -r '.owner.type')"
private="$(gh api "repos/${GITHUB_REPOSITORY}" --jq -r '.private')"
owner_type="$(gh api "repos/${GITHUB_REPOSITORY}" --jq '.owner.type')"
private="$(gh api "repos/${GITHUB_REPOSITORY}" --jq '.private')"
if [[ "$owner_type" == "User" && "$private" == "true" ]]; then
echo "enabled=false" >> "$GITHUB_OUTPUT"
echo "Skipping registry attestation: unavailable for private repositories on personal accounts."

View File

@ -18,6 +18,8 @@ jobs:
validate:
name: Validate stable release
runs-on: [self-hosted, evoo-ci, Linux]
permissions:
contents: write
outputs:
tag: ${{ steps.release.outputs.tag }}
version: ${{ steps.release.outputs.version }}
@ -95,17 +97,32 @@ jobs:
set -euo pipefail
include='[{"runner":["self-hosted","evoo-ci","macOS"],"artifact":"macos"},{"runner":["self-hosted","evoo-ci","Linux"],"artifact":"linux"}]'
if [[ "$WINDOWS_SIGNING" == "true" ]]; then
# No self-hosted Windows runner; keep GitHub-hosted only when signing credentials exist.
include="$(jq -c '. + [{"runner":"windows-2022","artifact":"windows"}]' <<<"$include")"
# Internal runners only: Windows waits for a self-hosted Windows runner, never a GitHub-hosted one.
include="$(jq -c '. + [{"runner":["self-hosted","evoo-ci","Windows"],"artifact":"windows"}]' <<<"$include")"
fi
echo "include=$include" >> "$GITHUB_OUTPUT"
echo "windows=$WINDOWS_SIGNING" >> "$GITHUB_OUTPUT"
- name: Open the draft release the platform builds fill
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ steps.release.outputs.tag }}
shell: bash
run: |
set -euo pipefail
# Builds hand their files to this draft, never to GitHub Actions artifact storage.
if [[ "$(gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \
--jq "[.[] | select(.draft and .tag_name == \"${RELEASE_TAG}\")] | length")" != "0" ]]; then
gh release delete "$RELEASE_TAG" --yes
fi
gh release create "$RELEASE_TAG" --draft --generate-notes --title "Rakazo $RELEASE_TAG" --verify-tag
build:
name: Build signed ${{ matrix.artifact }} artifacts
needs: validate
runs-on: ${{ matrix.runner }}
timeout-minutes: 60
permissions:
contents: write
strategy:
fail-fast: false
matrix:
@ -172,8 +189,8 @@ jobs:
xcrun stapler validate "$app"
spctl --assess --type execute --verbose=2 "$app"
grep -Fqx "provider: github" "$app/Contents/Resources/app-update.yml"
grep -Fqx "owner: elie222" "$app/Contents/Resources/app-update.yml"
grep -Fqx "repo: rakazo" "$app/Contents/Resources/app-update.yml"
grep -Fqx "owner: cynarAI" "$app/Contents/Resources/app-update.yml"
grep -Fqx "repo: evOo-local" "$app/Contents/Resources/app-update.yml"
- name: Require Windows signing credentials
if: runner.os == 'Windows'
@ -214,7 +231,7 @@ jobs:
throw "Windows update config is not bound to the signing publisher."
}
$feed = Get-Content -Raw $config
foreach ($expected in @("provider: github", "owner: elie222", "repo: rakazo")) {
foreach ($expected in @("provider: github", "owner: cynarAI", "repo: evOo-local")) {
if ($feed -notmatch "(?m)^$([regex]::Escape($expected))\r?$") {
throw "Windows update config missing '$expected'."
}
@ -239,25 +256,32 @@ jobs:
test -n "$config"
test -f "$config"
grep -Fqx "provider: github" "$config"
grep -Fqx "owner: elie222" "$config"
grep -Fqx "repo: rakazo" "$config"
grep -Fqx "owner: cynarAI" "$config"
grep -Fqx "repo: evOo-local" "$config"
test -f apps/desktop/out/latest-linux.yml
grep -Fqx "version: $RELEASE_VERSION" apps/desktop/out/latest-linux.yml
- name: Retain installers and updater metadata
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: desktop-${{ matrix.artifact }}-${{ needs.validate.outputs.sha }}
path: |
- name: Hand installers and updater metadata to the draft release
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ needs.validate.outputs.tag }}
run: |
set -euo pipefail
shopt -s nullglob
files=(
apps/desktop/out/*.AppImage
apps/desktop/out/*.blockmap
apps/desktop/out/*.dmg
apps/desktop/out/*.exe
apps/desktop/out/latest*.yml
apps/desktop/out/*.zip
if-no-files-found: error
compression-level: 0
retention-days: 7
)
if [[ ${#files[@]} -eq 0 ]]; then
echo "The build produced no release files." >&2
exit 1
fi
bash scripts/upload-release-assets.sh "${files[@]}"
publish:
name: Attest and publish complete release
@ -275,11 +299,11 @@ jobs:
fetch-depth: 0
persist-credentials: false
ref: ${{ needs.validate.outputs.sha }}
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
pattern: desktop-*-${{ needs.validate.outputs.sha }}
path: release-artifacts
merge-multiple: true
- name: Collect every platform from the draft release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ needs.validate.outputs.tag }}
run: gh release download "$RELEASE_TAG" --dir release-artifacts
- name: Verify the complete stable update feed
env:
RELEASE_SHA: ${{ needs.validate.outputs.sha }}
@ -323,26 +347,34 @@ jobs:
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
with:
subject-path: release-artifacts/*
- name: Create draft and upload every platform
id: create_release
- name: Add checksums to the draft
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ needs.validate.outputs.tag }}
run: >-
gh release create "$RELEASE_TAG" release-artifacts/*
--draft --generate-notes --title "Rakazo $RELEASE_TAG" --verify-tag
run: gh release upload "$RELEASE_TAG" release-artifacts/SHA256SUMS --clobber
- name: Publish the completed release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ needs.validate.outputs.tag }}
run: gh release edit "$RELEASE_TAG" --draft=false --latest
- name: Remove an incomplete draft
if: failure() && steps.create_release.outcome != 'skipped'
discard:
name: Remove an incomplete draft
needs: [validate, build, publish]
if: failure() && needs.validate.result == 'success'
runs-on: [self-hosted, evoo-ci, Linux]
timeout-minutes: 5
permissions:
contents: write
steps:
- name: Delete the draft release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
RELEASE_TAG: ${{ needs.validate.outputs.tag }}
shell: bash
run: |
if [[ "$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}" --jq .draft 2>/dev/null)" == "true" ]]; then
if [[ "$(gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \
--jq "[.[] | select(.draft and .tag_name == \"${RELEASE_TAG}\")] | length")" != "0" ]]; then
gh release delete "$RELEASE_TAG" --yes
fi

View File

@ -19,7 +19,6 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test:topology
- name: Replay computer tools against real Chromium without inference

1
.gitignore vendored
View File

@ -47,7 +47,6 @@ apps/web/src/locales/**/*.js
apps/web/scripts/msgids.json
playwright-report-www
*.pid
.evoo_server.pid
# Large voice / model vendor blobs (recreate Kokoro separately)
vendor/

View File

@ -1,238 +0,0 @@
# Task Handoff: executor.ts + pi-runtime.ts Split
## Context
Du arbeitest im Repo `/home/cynar/Projects/evOo-local`.
Es gibt 10 bereits erstellte Module-Files, die extrahierte Funktionen enthalten. Der Original-File enthält aber noch **Duplikate** derselben Funktionen. Ziel: die Definitionen aus den Original-Files entfernen, Imports + Re-Exports hinzufügen.
## Module Files (bereits vorhanden, NICHT löschen)
### executor-*.ts (im Verzeichnis `packages/adapters/src/`):
| File | Line count | Exports |
|------|-----------|---------|
| `executor-types.ts` | 50 | `ExecutorDeps` (interface) |
| `executor-attachments.ts` | 43 | `TURN_ATTACHMENT_UNAVAILABLE`, `STEERING_ATTACHMENT_UNAVAILABLE`, `missingTurnImagesInstruction`, `settleSteeringAttachmentLoads` |
| `executor-checkpoint.ts` | 23 | `createRunWorkspaceCheckpoint` |
| `executor-shell-guard.ts` | 177 | `isProtectedComputerLifecycleCommand` |
| `executor-effects.ts` | 151 | `recordEffect`, `completeEffect`, `uncertainEffectError` |
| `executor-completion.ts` | 65 | `completionMessageSegments`, `completionNotificationBody`, `completionMarksUnread`, `threadContextForRun`, `subagentMarksUnread` |
| `executor-model.ts` | 207 | `deploymentKeyFor`, `resolveModelKey`, `withModelCredentialLock` |
| `executor-utils.ts` | 63 | `selectRunConnections`, `loadCurrentTurnImages` |
| `executor-tools.ts` | 58 | `PAGE_BROWSER_TOOL_NAMES`, `filterPageBrowserTools`, `selectBuiltinToolsForRun` |
| `executor-computer.ts` | 231 | `computerScreenToolResult`, `runNotificationsEnabled`, `notifyRun`, `renewRunLease`, `computerRetryDelay`, `computerRunRequeueData`, `writeComputerRunRequeue`, `requeueComputerRun`, `redactBlocks`, `publishMessage`, `persistMessageInTransaction` |
### pi-runtime Split:
| File | Line count | Exports |
|------|-----------|---------|
| `pi-model-config.ts` | 709 | Viele model config + utility functions (catalogModels, thinkingLevelFor, ToolCallBudget, maxToolCallsPerTurn, releaseToolCallBudget, beginToolCall, endToolCall, toolCallBudgetExceededMessage, configuredOpenRouterModel, modelsForRequest, prepareRequestSecretArguments, pruneComputerScreenshotContext, jsonSchemaParameters, jsonField, conversationSessionId, isOpenCodeProvider, createQueue, createGate, modelForCompletion, sanitizeSensitiveText, redactActivityUrl, sanitizeError, summarizeToolResult, boundAgentToolResult, assistantText, looksLikeOpenCodeSessionError, sanitizeProviderError, etc.) |
| `pi-runtime.ts` | 1726 | **PI Agent Runtime class** — soll bleiben, muss aber die extrahierten Functions loswerden |
## CRITICAL: Was DU NICHT tun darfst
1. **NIEMALS `git stash` als Nebenbei-Befehl** — Stash ist über alle Worktrees geteilt.
2. **NIEMALS den Original-File (executor.ts / pi-runtime.ts) durch einen SubAgent schreiben lassen.** Der SubAgent wird den File während der Arbeit überschreiben.
3. **NIEMALS `sed` mit Shell-Metazeichen durch SSH.**
4. **NIEMALS die extrahierten Module-Files löschen oder ändern** — sie sind korrekt.
## Task 1: executor.ts split (4697 → ~3700 Zeilen)
**Datei:** `/home/cynar/Projects/evOo-local/packages/adapters/src/executor.ts`
### Was zu tun ist:
Schreibe ein **einzelnes Python-Script**, das ALLES atomar macht:
1. Liest executor.ts
2. Findet die zu entfernenden Function-Definitionen **mittels AST-Parser** (nicht line-basiert!)
3. Entfernt die Definitionen
4. Fügt Imports + Re-Exports am Ende des import-Blocks ein
5. Entfernt alte Re-Exports am Ende der Datei
6. Schreibt den File
7. Prüft `npx tsc --noEmit -p packages/adapters/tsconfig.json`
8. Bei Fehlern: korrigiert und retry (max 3x)
### Wie man die zu entfernenden Definitionen findet:
Verwende den `ast` Python-Module ODER `grep` um die EXAKTEN function-Declaration-Zeilen zu finden. Dann zähle die geschwungenen Klammern **mit einem Parser, der strings/comments aware ist**.
**WICHTIG:** Verwende `tree-sitter` oder `ast` Python-Modul wenn möglich. Alternativ: verwende `grep -n 'export function NAME\|function NAME\|export const NAME\|export interface NAME\|const NAME' executor.ts` um die Start-Zeile zu finden, und dann einen **robusten brace-counter** der Template-Strings, Regex-Literale, und Generics korrekt handhabt.
### Zu entfernende Functions (alle existieren schon in den executor-*.ts Modulen):
Hier ist eine Liste der Functions, die entfernt werden müssen. Für jede muss der **exakte** start/end-Block im executor.ts gefunden und entfernt werden.
**Wichtig:** Die brace-counting muss korrekt sein bei:
- Generics: `<T extends { name: string }>` — die `{ }` sind Type-Parameter, keine Code-Blöcke
- Template-Strings: `` `template ${expr}` `` — kein `{ }`
- Regex: `/regex/` — kein `{ }`
- Arrow Functions: `() => { body }` — der Body hat `{ }`
### Schritt-für-Schritt:
#### Step 1: Finde alle zu entfernenden Export-Namen
```bash
# In packages/adapters/src/:
for f in executor-*.ts; do grep -h "^export " "$f" | grep -oP '(function|const|interface|type)\s+\K\w+'; done | sort -u
```
Das gibt dir die Liste aller Export-Namen aus den Modul-Files.
#### Step 2: Finde diese Namen im executor.ts
```bash
# Für jeden Namen:
grep -n "^export.*NAME\b\|^function NAME\b\|^const NAME\b\|^interface NAME\b" executor.ts
```
#### Step 3: Finde den exakten end-Block mit einem robusten Parser
Schreibe ein Python-Script `find_functions.py`:
```python
import re, sys
file_path = sys.argv[1]
target_name = sys.argv[2]
with open(file_path) as f:
content = f.read()
lines = content.split('\n')
# Find declaration line
for i, line in enumerate(lines):
if re.search(rf'\b{re.escape(target_name)}\b', line):
# Find opening brace
brace_line = i
while brace_line < len(lines) and '{' not in lines[brace_line]:
brace_line += 1
if brace_line >= len(lines):
# No brace (const declaration)
end = i + 1
while end < len(lines) and not lines[end].strip().endswith(';'):
end += 1
print(f"{target_name}: L{i+1}-{end}")
break
# Robust brace counting
depth = 0
in_single = in_double = in_template = in_block_comment = in_line_comment = False
for j in range(brace_line, len(lines)):
k = 0
while k < len(lines[j]):
ch = lines[j][k]
# String handling
if ch in ('"', "'"):
q = ch
k += 1
while k < len(lines[j]):
if lines[j][k] == '\\' and k + 1 < len(lines[j]):
k += 2
continue
if lines[j][k] == q:
k += 1
break
k += 1
continue
if ch == '`':
k += 1
while k < len(lines[j]):
if lines[j][k] == '\\' and k + 1 < len(lines[j]):
k += 2
continue
if lines[j][k] == '`':
k += 1
break
k += 1
continue
# Comments
if not in_single and not in_double and not in_template:
if ch == '/' and k + 1 < len(lines[j]) and lines[j][k+1] == '/':
break # rest of line is comment
if ch == '/' and k + 1 < len(lines[j]) and lines[j][k+1] == '*':
in_block_comment = True
k += 2
continue
if in_block_comment:
if ch == '*' and k + 1 < len(lines[j]) and lines[j][k+1] == '/':
in_block_comment = False
k += 2
continue
k += 1
continue
# Braces
if ch == '{':
depth += 1
elif ch == '}':
depth -= 1
if depth == 0:
print(f"{target_name}: L{i+1}-{j+1}")
break
k += 1
else:
continue
break
else:
print(f"{target_name}: L{i+1}-END(not found)")
break
```
#### Step 4: Generiere die removal ranges
Für jeden gefundenen Block: `start_line` bis `end_line` (1-based, inclusive).
#### Step 5: Führe alle removals atomar aus
- Baue ein Set aller zu entfernenden 0-based line indices
- Baue den neuen File content
- Füge Imports + Re-Exports an Position 340 (nach dem letzten import Statement) ein
- Entferne alte Re-Exports am Datei-Ende
- Schreibe den File
#### Step 6: Verifiziere
```bash
npx tsc --noEmit -p packages/adapters/tsconfig.json 2>&1 | grep "executor.ts"
```
Wenn keine errors: SUCCESS.
Wenn errors: lies die errors, korrigiere, retry max 3x.
## Task 2: pi-runtime.ts split (1726 → ~500 Zeilen)
**Datei:** `/home/cynar/Projects/evOo-local/packages/adapters/src/pi-runtime.ts`
**Modul:** `pi-model-config.ts` (709 Zeilen, enthält model config + utility functions)
Gleicher Ansatz wie Task 1:
1. Finde alle Export-Namen in `pi-model-config.ts`
2. Finde diese im `pi-runtime.ts`
3. Finde die exakten start/end-Blöcke mit demselben robusten brace-counter
4. Entferne die Blöcke atomar
5. Füge Imports + Re-Exports ein
6. Entferne alte Re-Exports
7. Verifiziere mit `npx tsc --noEmit -p packages/adapters/tsconfig.json`
## Wichtige Hinweise
- Der `packages/adapters/src/index.ts` muss eventuell aktualisiert werden (falls er die extrahierten Functions exportiert — prüfen!)
- `verbatimModuleSyntax` ist enabled → alle imports müssen `.js` extension haben
- TypeScript `import type` vs `import`: Für Interfaces `import type`, für classes/functions `import`
- **Nicht committen oder pushen** — nur die Files ändern und verifizieren
## Quick reference: Zu entfernende Functions aus executor.ts
Diese Functions existieren in den executor-*.ts Modulen und MÜSSEN aus executor.ts entfernt werden:
```
TURN_ATTACHMENT_UNAVAILABLE, STEERING_ATTACHMENT_UNAVAILABLE,
createRunWorkspaceCheckpoint,
isProtectedComputerLifecycleCommand, shellCFlagProgram, preserveShellCommandBoundaries, tokenizeProtectedShellCommand,
recordEffect, completeEffect, uncertainEffectError,
completionMessageSegments, completionNotificationBody, completionMarksUnread, threadContextForRun, subagentMarksUnread,
deploymentKeyFor, resolveModelKey, withModelCredentialLock,
selectRunConnections, loadCurrentTurnImages,
PAGE_BROWSER_TOOL_NAMES, filterPageBrowserTools, selectBuiltinToolsForRun,
persistMessageInTransaction, computerScreenToolResult, runNotificationsEnabled, notifyRun, renewRunLease,
computerRetryDelay, computerRunRequeueData, writeComputerRunRequeue, requeueComputerRun, redactBlocks, publishMessage,
```
Dazu: `ExecutorDeps` interface aus `executor-types.ts`.

View File

@ -10,6 +10,8 @@
- Keep code simple: reuse existing primitives and one source of truth, remove duplication and unused flexibility, and avoid speculative abstractions. Add an interface when it protects a real external or platform boundary, not for its own sake.
- Treat auth, secret handling, sandbox boundaries, host commands, and integrations as security-sensitive. Keep tests deterministic and offline by default.
- The desktop Playwright suite (`pnpm --filter @rakazo/desktop test:e2e`) opens real Electron windows and steals focus on macOS. Do not run it on a maintainer's machine as routine verification; run the unit tests locally and let CI's virtual-display job run the e2e on push.
- Work autonomously. Make routine calls yourself, such as opening or reopening a pull request, linking CI screenshots, or fixing gaps you found that belong to the task, then verify and report the outcome. Ask the maintainer only before irreversible actions or for product decisions without a sensible default.
- CI runs only on the self-hosted `evoo-ci` runners and never consumes GitHub Actions resources: no GitHub-hosted runners, no Actions artifact storage (`upload-artifact`, `download-artifact`), and no GitHub cache service (`actions/cache`, setup-node `cache:`, buildx `type=gha`). Keep caches and job handoffs on the runner host; `infra/updater/src/github-actions-budget.test.ts` enforces this.
- After creating a pull request, stay with it until CI and automated review bots have finished; passing checks alone do not mean the review is complete. Follow `.agents/skills/pr-watch/SKILL.md`: run its `pr-digest --watch` helper as a single backgrounded call that blocks until the checks on the current head commit are terminal, then act on the verdict it prints. Do not poll in the foreground on a timer, which mostly buys repeated snapshots of an unchanged PR and dies at the tool timeout. Address every actionable issue, push the fixes, and repeat until no actionable feedback remains. Do not merge while review bots are still pending or review issues remain unresolved.
- PR descriptions say why the change exists, what changed, and how it was tested. Write Why as the product reason (what was wrong and what this changes); never "Elie wants" or a third-person briefing of a named person; the maintainer is the author.
- Commit messages, PR descriptions, issues, and review replies are public too. Describe test results in words instead of pasting tool output, and never include anything that identifies a person, machine, or account: local paths, usernames, hostnames, signing identities, legal entity names, Apple Team IDs, API key IDs, issuer or tenant IDs, account emails, or the accounts, machines, and files that secret values came from. Naming the secrets a workflow reads is fine; use placeholders when a value must be shown.

View File

@ -1,87 +0,0 @@
# ARCHITECTURE.md: Die Verfassung von evOo
> **Status**: RATIFIZIERT & AKTIV
> **Ecosystem**: evOo Autonomous Agent Platform
> **Executive Lead / CTO**: Steve Jobs (`templates/cto-steve-jobs`)
> **Konfigurationsanker**: [`evoo.config.json`](file:///Users/ingowagner/Projects/evOo-local/evoo.config.json)
> **Grundsatz**: *Realität im Dateisystem vor Halluzination im Kopf. Nur was physisch geschrieben, getestet und lauffähig ist, existiert.*
---
## Prämisse I: Das Gesetz der physischen Realität
1. **Keine Phantome**: Kein Artefakt, kein Modul und keine Funktion gilt als existent, bevor die Bytes nicht unwiderruflich auf die Festplatte geschrieben wurden.
2. **Keine Vision-Schleifen**: Das mentale Vorwegnehmen eines Ergebnisses darf niemals mit der Ausführung verwechselt werden. Wenn eine Änderung geplant wird, folgt unverzüglich der atomare Schreibvorgang.
3. **Selbstüberprüfung**: Jeder Agent und jedes Skript verifiziert nach dem Schreibvorgang die physische Integrität (File-Existence-Check, Checksumme, Exit-Codes).
---
## Prämisse II: Autonomie & Exekutive Führung (Steve Jobs CTO)
1. **Vollmacht**: Der CTO besitzt uneingeschränkte, vorab genehmigte Zugriffsrechte auf:
- Dateisystem (`filesystem: *`)
- Netzwerk (`network: *`)
- Quellcode (`sourcecode: *`)
- Lokale Tools und Geräte (`devices: *`)
- Agent-Casting (`agent_casting: allowed`)
2. **Action-First Prinzip**: Bei klaren Zielen wird keine unnötige Erlaubnis für Standardoperationen (Lesen, Schreiben, Refactoring, Ausführen) erbeten. Die Handlung erfolgt sofort.
3. **Agent-Casting auf Abruf**: Der CTO rekrutiert, instruiert und überwacht spezialisierte Sub-Agenten:
- **Code Architect**: Modulstruktur, Design-Patterns, saubere Schnittstellen.
- **Security & QA Auditor**: Sicherheitsanalysen, Regressionstests, Grenzfallprüfungen.
- **DevOps Engineer**: Deployment, Hot-Reloading, Server-Liveness, Prozessmanagement.
- **Apex Quant Specialist**: Handelsmodelle, Risikomanagement, Signalverarbeitung.
---
## Prämisse III: Der geschlossene Selbstverbesserungs-Kreislauf (Self-Improvement Loop)
```
┌────────────────────────────────────────────────┐
│ │
▼ │
┌───────────────┐ ┌───────────────┐ ┌────────────┴──┐
│ 1. Execution │ ──► │ 2. Telemetrie │ ──► │ 3. Diagnose │
│ & Deployment │ │ & Log-Audit │ │ & Root-Cause │
└───────────────┘ └───────────────┘ └───────────────┘
│ │
▼ ▼
┌───────────────┐ ┌───────────────┐
│ 4. Refactor │ ◄── │ 5. Prompt- │
│ & Fix (Disk) │ │ Optimierung │
└───────────────┘ └───────────────┘
```
1. **Automatisierte Log-Evaluation**: Sämtliche Fehlermeldungen, System-Events und Performance-Metriken werden zyklisch aggregiert.
2. **Selbstreparatur (Self-Healing)**: Tritt ein Fehler (wie ein HTTP 500, fehlende Pfade, gecrashte Prozesse) auf, wird der Ursprung lokalisiert und der Code auf Festplatte korrigiert.
3. **Hot-Reload**: Nach erfolgreicher Selbstreparatur triggert das System via `deploy.sh` einen Hot-Reload ohne Systemstillstand.
---
## Prämisse IV: System-Topologie
Das evOo-Ökosystem ist in klar getrennte, orthogonale Säulen unterteilt:
```
/Users/ingowagner/Projects/evOo-local/
├── ARCHITECTURE.md # Diese Verfassung
├── evoo.config.json # Autonome Berechtigungen & Policies
├── deploy.sh # Hot-Reload, Health-Checks & Service-Orchestrierung
├── evoo-platform/ # Plattform-Core, Dashboard & Visualisierung
│ ├── index.html # UI mit atmender evOo-Kernanimation
│ ├── styles.css # Darkmode Glassmorphism Design System
│ ├── app.js # Reaktivität, WebSocket/Telemetry-Stream
│ └── server.js # High-Speed Server (verhindert Internal Server Error)
├── apex-trading/ # Autonomes Trading- & Quant-Subsystem
│ ├── BLUEPRINT.md # Handelsarchitektur, Risikomatrix, Strategien
│ ├── config/ # Trading-Konfiguration & Parameter
│ └── src/ # Engine, Signale, Risk-Guardrails
└── templates/cto-steve-jobs/ # Executive CTO Agent Profile & Setup
```
---
## Prämisse V: Qualitäts- und Ästhetik-Manifest
1. **Kein Müllcode**: Code ist so kompakt, elegant und wartbar wie möglich.
2. **Atmendes Interface**: Die Benutzeroberfläche von evOo visualisiert den vitalen Zustand des Systems in Echtzeit durch organische, atmende Pulsation (Breathing Animation).
3. **Resilienz**: Fällt ein Dienst aus, fängt die Plattform den Fehler ab, protokolliert die Ursache und stellt den Dienst autonom wieder her.

View File

@ -1,72 +0,0 @@
# CLAUDE RESUME — 2026-09-22 (~10:00 Vienna)
## Completed ✅
- ✅ **Emoji-first Onboarding.tsx** (5 steps: emoji → preferences → language → integrations → bot)
- Shape selector (8 shapes), Color picker (12 GrokColors), Accessory (9), Pose (8)
- User preferences (name, timezone, hobbies, work style, communication)
- Language step (9 locales → localStorage `rakazo.uiLocale`)
- avatarValue (`{color}::shape_{index}`) → bot `color` field on create
- Bot instructions enriched with user context
- ✅ **onboarding-discovery.ts**, **OnboardingDiscoveryShell.tsx** updated
- ✅ **ui-web exports**: GROK_COLOR_LIST, SHIPPED_SHAPE_KEYS, GrokColorDef, GROK_SHAPES
- ✅ **executor.ts split**: 4697 → 4231 lines (18 module files)
- ✅ **pi-runtime.ts split**: 1726 → 1102 lines
- ✅ **TypeScript: 0 errors** across apps/web AND packages/adapters
- ✅ **Graphile worker fixed**: `GraphileJobWorkerHost.start()` now called in app.ts (was never started for graphile jobs, only for in-memory)
- ✅ **Graphile schema**: `graphile_worker` schema created via `graphile-worker schema-only`
- ✅ **Sandbox supervisor**: Built `rakazo/sandbox-supervisor:local` from source (was using wrong `rakazo/app:edge` image)
- ✅ **Computer image**: Built `rakazo/computer:local` from source
- ✅ **Smart model routing**: `estimateMessageComplexity()` + `resolveSmartModelSelection()` implemented
- Fast model (3B) for short messages (<80 chars)
- Strong model (30B) for complex/long messages (>500 chars)
- Medium routing for keyword-based detection
- ✅ **Model credential**: `local` provider connected with empty apiKey allowed
- ✅ **PRs merged**: All PRs 36-58 merged to main, stale branches cleaned
### Infrastructure fixes
- ✅ **GraphileJobWorkerHost**: Added `else if (jobPool)` branch in app.ts to start the worker
- ✅ **Sandbox provider**: `SANDBOX_PROVIDER=docker` with proper supervisor at port 7091
- ✅ **Computer provisioning**: Docker containers spin up successfully (verified: `rakazo-bot-cmuber4og000561yrhm2q81c5` running)
- ✅ **DATA_DIR path**: Supervisor mounted with correct host path for home directory validation
## Completed ✅
- ✅ **Prisma `updateMany` Bug Fixed!** Root cause: `toolCallBudgetCount` and `toolCallBudgetExceeded` fields missing from Run model in Prisma schema. Added to schema.prisma, migrated DB, regenerated Prisma client. Runs now complete successfully.
- ✅ **E2E Flow VERIFIED!** Bot responds end-to-end:
- User: "Hello! Can you hear me?"
- Bot: "I'm here! It looks like we've been going back and forth a bit — but yes, I can definitely hear you. How can I help? 😊"
- Full pipeline: Message → Graphile job → Worker → Computer provision → Model call → Response → Run completed
## Architektur-Status
- **API**: Port 3100, Graphile worker active, consuming jobs
- **Sandbox supervisor**: Port 7091, `rakazo/sandbox-supervisor:local`, DATA_DIR mounted correctly
- **Computer**: `rakazo/computer:local` image built and running
- **Smart routing**: Fast (3B) → Strong (30B) model selection based on message complexity
- **Known error**: `events.ts:996` — Prisma `updateMany` in `finalizeRunOnce` throws "Invalid invocation" despite all fields existing in schema
## Architektur-Status
- **API**: Port 3100, Graphile worker active ✅
- **Sandbox supervisor**: Port 7091, `rakazo/sandbox-supervisor:local` ✅
- **Computer**: `rakazo/computer:local` running ✅
- **Smart routing**: Fast (3B) → Strong (30B) ✅
- **E2E**: Bot responds to user messages ✅
## Status
- Working tree: clean, synced to origin/main
- **PR 66** ✅ `fix(messaging): preserve group inbound media URLs in fan-out` — merged
- **PR 67** ✅ `fix(adapters): poll run lease during silent runtime work` — merged
- **PR 69** ✅ `fix(teamchat): retry delegated replies when external origin is missing` — merged
- **0 stale branches**, **0 draft PRs**
### Shell.tsx TypeScript fixes
- ✅ **Shell.tsx TS errors fixed** (PR #71 introduced type mismatches)
- `ConversationBotPicker` now receives `bots: Bot[]` (not `SpaceBot[]`)
- `conversationSidebarGroups` render simplified — only bots, removed dead `GroupAvatar` branch
- `projectSidebarGroups` render uses `GroupAvatar` with proper members access
- All TypeScript errors resolved (0 errors across apps/web and packages/adapters)
- ✅ **browser-preview-context.ts** created (was missing from repo, caused Vite crash)
- Implements `browserPreviewActivity` subject with `.subscribe()`, `.set()`, `.snapshot()`, `.clear()`
- Multi-agent cursor tracking for browser preview panels
- ✅ **RakazoDesktop interface** extended with optional `browserPreview` property
- Contract update in `packages/contracts/src/desktop.ts`
- Types: `navigate`, `reload`, `getState`, `onNavigate`, `onLoadingProgress`, `devServer`
- ✅ **executor-shell-guard.ts** TS fix: `body[cursor]!` non-null assertion

View File

@ -1,12 +0,0 @@
# GROK-CLAUDE-HANDOFF — 2026-09-23T22:59:11+00:00
- Autopilot merged #84 (Product CI gate) → main `cf4d5cc`.
- Checkout pulled to `cf4d5cc`; Claude WIP stashed then restored (app.ts/router.ts/rpc.ts + onboarding-scan.ts).
- API :3100 was down (Claude background "Test API startup with security headers" stopped / refused). Autopilot restarted via `pnpm --filter @rakazo/api dev` (log /tmp/evoo-autopilot/api-3100.log).
- Web :5173 left running.
- PR #85 rebase agent in flight: https://cursor.com/agents/bc-edd2b181-b5da-5574-a956-e55efe3d2736
- Do not reset --hard without stash; WIP present.
- Autopilot restart path: A rejected (evoo-stack = Rakazo Docker, broken symlink /srv/rakazo/stack-up.sh → missing /srv/evoo/rakazo/…). Chose new user unit evoo-local.service + ~/.local/bin/evoo-dev-stack.sh; live :3100/:5173 migrated; GH vars EVO_RESTART_MODE=systemd EVO_SYSTEMD_UNIT=evoo-local.service. Do not use evoo-stack for evo-deploy.
- Cancelled queued evo-deploy 35933339459 (would reset --hard over Claude WIP now that restart=systemd). Re-dispatch after WIP committed/stashed.
2026-09-23 23:53:08 UTC evoo-tools disabled (missing app.py)

View File

@ -9,6 +9,6 @@ branch of Ingo Wagner’s evOo stack.
| Branch | `antigravity-handoff/project-timeline` |
| Commit | `f00577bb05e3cf68381fafb9cb32929becd1e352` |
| Packaged | 2026-09-20 |
| Intended Mac path | `/Users/ingowagner/Projects/evOo-local` |
| Intended Mac path | `~/Projects/evOo-local` |
Chaotic multi-remote history from older checkouts was intentionally not carried over.

114
PROJECT.md Normal file
View File

@ -0,0 +1,114 @@
# Project: Dual-Surface-View in evOo (`apps/web`)
## Architecture
evOo (`apps/web`) organizes the workspace into two primary surfaces:
1. **Mission Stream**: The chat, agent transcript (`data-testid="transcript"`), run steps (`data-testid="run-steps"`), and composer inside `<main>`.
2. **Living Canvas**: The computer surface (`data-testid="computer-preview"`), workspace files explorer (`WorkspaceFilesView`), code inspector, in-app browser, and artifact viewer inside `<aside data-testid="side-panel">`.
In desktop viewports (>= 768px `md`), these two surfaces operate as equal, first-class split surfaces within `Shell.tsx`:
- Positioned side-by-side without modal dialog occlusion.
- Separated by an interactive `DualSurfaceDivider` supporting fluid pointer drag-resizing, double-click snap to 50/50 balance, and keyboard arrow adjustments.
- Controlled via quick-switch header preset controls: Focus Stream (100/0), Dual-Surface Split (50/50), Focus Canvas (0/100), and a side-swapping toggle (Stream-first vs. Canvas-first).
- Configured by persisted preferences (`localStorage` via `dual-surface-pref.ts`) with safe defaults and bounded numeric clamping.
- Responsive degradation (< 768px `md`): Automatically transitions to stacked/drawer/single-surface mode without mutating desktop split ratio preferences.
- Monochrome design system adhering to `@rakazo/ui-tokens` with zero hardcoded hex or arbitrary CSS values.
- Internationalized using LinguiJS with 100% German (`de`) translation coverage.
```
+-----------------------------------------------------------------------------------+
| Top Navigation Bar: [Bots Toggle] ... [Preset: 100/0 | 50/50 | 0/100] [Swap] ... |
+----------------------------------------------------+---+--------------------------+
| Surface A (e.g. Mission Stream / Chat) | D | Surface B (Living Canvas)|
| | I | |
| - Transcript (`data-testid="transcript"`) | V | - Computer Preview |
| - Run steps (`data-testid="run-steps"`) | I | - Workspace Explorer |
| - Composer | D | - Code Inspector |
| | E | (`data-testid="side-panel")|
| | R | |
+----------------------------------------------------+---+--------------------------+
```
## Feature Inventory
| # | Feature | Description | Milestone | Source |
|---|---------|-------------|-----------|--------|
| 1 | Preferences Storage & Schema | `dual-surface-pref.ts` managing `splitRatio` (0.20-0.80, default 0.50), `layoutMode` (`split`, `stream`, `canvas`), `orientation` (`stream-first`, `canvas-first`) with localStorage read/write and bounds clamping | M1 | ORIGINAL_REQUEST §R3 |
| 2 | Layout Hook & State Coordination | `useDualSurfaceLayout.ts` React hook handling layout state, preset toggles, orientation flip, and media query synchronization | M1 | ORIGINAL_REQUEST §R1, R3 |
| 3 | Fluid Drag-Resize & Snap Divider | `DualSurfaceDivider.tsx` + `useDualSurfaceResize.ts` with pointer capture, hover/active semantic tokens, bounds clamping, double-click 50/50 snap, and ARIA separator attributes | M2 | ORIGINAL_REQUEST §R2, R4 |
| 4 | Header Preset Controls & Orientation Swap | `DualSurfaceHeaderControls.tsx` with quick-switch presets (100/0, 50/50, 0/100) and Swap Orientation toggle, styled with semantic tokens and full accessibility | M2 | ORIGINAL_REQUEST §R1, R2 |
| 5 | Shell Flex Integration & Surface Swap | Coordinated flex container in `Shell.tsx` hosting `<main>` and `<aside data-testid="side-panel">`, supporting dynamic order swapping while preserving existing test IDs | M3 | ORIGINAL_REQUEST §R1, R2, R4 |
| 6 | Responsive Degradation (< md) | Seamless fallback on viewports < 768px to stacked/single-surface navigation without corrupting persisted desktop preferences | M3 | ORIGINAL_REQUEST §R3 |
| 7 | Design System & Token Compliance | Exclusively semantic tokens from `@rakazo/ui-tokens` (`bg-border`, `hover:bg-accent`, `active:bg-primary`, `ring-ring`), monochrome styling, no hardcoded hex/arbitrary css | M3 | ORIGINAL_REQUEST §R4 |
| 8 | Localization (German Translations) | Lingui message extraction, full German translations in `messages.po`, clean `intl:extract` and `intl:compile` | M3 | ORIGINAL_REQUEST §Acceptance Criteria |
| 9 | E2E Testing & Adversarial Hardening | Comprehensive test suite (Tiers 1-4) and adversarial hardening (Tier 5), 100% green tests (78/78 suites baseline + new tests) and 0 TS errors | M4 | ORIGINAL_REQUEST §Acceptance Criteria |
## Milestones
| # | Name | Scope | Dependencies | Status |
|---|------|-------|-------------|--------|
| M1 | Preferences, Layout Hook & State Management | `dual-surface-pref.ts`, `useDualSurfaceLayout.ts`, unit tests | none | DONE |
| M2 | Interactive Divider & Header Preset Controls | `DualSurfaceDivider.tsx`, `useDualSurfaceResize.ts`, `dual-surface-header-controls.tsx`, unit/DOM tests | M1 | DONE |
| M3 | Shell Integration, Responsive Degradation & Localization | `Shell.tsx` layout refactoring, responsive fallback, German translations in `messages.po`, preserve test IDs | M2 | DONE |
| M4 | E2E Test Suite (Tiers 1-4) & Adversarial Hardening (Tier 5) | Comprehensive E2E test suite execution, 100% pass, forensic integrity audit | M3, E2E Track | IN_PROGRESS |
## Interface Contracts
### `dual-surface-pref.ts`
```typescript
export type DualSurfaceLayoutMode = "split" | "stream" | "canvas";
export type DualSurfaceOrientation = "stream-first" | "canvas-first";
export interface DualSurfacePreferences {
splitRatio: number; // 0.20 to 0.80, default 0.50
layoutMode: DualSurfaceLayoutMode;
orientation: DualSurfaceOrientation;
}
export function readDualSurfacePreferences(): DualSurfacePreferences;
export function writeDualSurfacePreferences(prefs: Partial<DualSurfacePreferences>): void;
export function clampSplitRatio(ratio: number): number;
```
### `useDualSurfaceLayout.ts`
```typescript
export interface DualSurfaceLayoutState {
splitRatio: number;
layoutMode: DualSurfaceLayoutMode;
orientation: DualSurfaceOrientation;
isMobile: boolean; // < 768px (md)
setSplitRatio: (ratio: number) => void;
setLayoutMode: (mode: DualSurfaceLayoutMode) => void;
setOrientation: (orientation: DualSurfaceOrientation) => void;
toggleOrientation: () => void;
resetSplit: () => void; // Snaps to 0.50
}
```
### `DualSurfaceDivider.tsx`
```typescript
export interface DualSurfaceDividerProps {
splitRatio: number;
onResize: (newRatio: number) => void;
onReset: () => void; // Double-click snap
disabled?: boolean;
}
```
### `DualSurfaceHeaderControls.tsx`
```typescript
export interface DualSurfaceHeaderControlsProps {
layoutMode: DualSurfaceLayoutMode;
orientation: DualSurfaceOrientation;
onSelectMode: (mode: DualSurfaceLayoutMode) => void;
onToggleOrientation: () => void;
disabled?: boolean;
}
```
## Code Layout
- `apps/web/src/lib/dual-surface-pref.ts`: Preference reading, writing, and boundary clamping.
- `apps/web/src/lib/dual-surface-pref.test.ts`: Unit tests for storage and mathematical boundaries.
- `apps/web/src/hooks/useDualSurfaceLayout.ts`: Layout state and responsive lifecycle management.
- `apps/web/src/hooks/useDualSurfaceResize.ts`: Pointer capture and keyboard navigation hook for divider.
- `apps/web/src/components/DualSurfaceDivider.tsx`: Accessible interactive divider component.
- `apps/web/src/pages/shell/dual-surface-header-controls.tsx`: Quick-switch header presets and swap buttons.
- `apps/web/src/pages/Shell.tsx`: Integration into application root layout.
- `apps/web/src/locales/de/messages.po`: German translation catalog entries.
- `apps/web/src/tests/dual-surface-view.test.tsx`: Component integration and DOM interaction tests.

View File

@ -12,7 +12,7 @@
|----|----|
| Persönliche Multi-Agenten-App auf dem eigenen Rechner | Personal multi-agent app on your own machine |
| Oos (Agents) mit Memory, Tools, Voice und Computer | Oos (agents) with memory, tools, voice, and computer |
| Antigravity-Zukunftswerkzeuge: self_improve, autonomous-sentinel, self-update, orchestrator, local-stack, pi-local-provider, seed-ollama, Voice First / Oo-Stage | Same Antigravity future tools kept on this branch |
| Antigravity-Zukunftswerkzeuge: self-update, local-stack, pi-local-provider, seed-ollama, Voice First / Oo-Stage | Same Antigravity future tools kept on this branch |
## Quick start
@ -49,14 +49,11 @@ Full local notes: **[docs/LOCAL-SETUP.md](./docs/LOCAL-SETUP.md)**
- TypeScript monorepo (pnpm + Turborepo)
- React 19 / Vite / Tailwind (web), Electron (desktop), Expo (mobile)
- Hono + oRPC, PostgreSQL + Prisma, Better Auth, Graphile Worker
- Pi model routing, Docker sandboxes, Voice (Kokoro local / BYO keys)
- Pi model routing, Docker sandboxes, Voice (Kokoro, local)
## Antigravity tools kept on this branch
- `scripts/self_improve.py`
- `scripts/autonomous-sentinel.ts`
- `packages/core/src/self-update.ts`
- `orchestrator/` (agent_runner + agents.json)
- `apps/desktop/src/local-stack.ts`
- `packages/adapters/src/pi-local-provider.ts`
- `scripts/seed-ollama.ts`
@ -71,7 +68,7 @@ docker run -p 8880:8880 ghcr.io/remsky/kokoro-fastapi-cpu:latest
export KOKORO_BASE_URL=http://127.0.0.1:8880
```
Details: [docs/kokoro-voice.md](./docs/kokoro-voice.md)
German voices come from a local Piper server (`PIPER_BASE_URL`). Details: [docs/kokoro-voice.md](./docs/kokoro-voice.md)
## Tech debt (brand / packages)

135
TEST_INFRA.md Normal file
View File

@ -0,0 +1,135 @@
# E2E Test Infrastructure: evOo Voice-First Autonomous Agent OS (Dual Track)
## 1. Test Philosophy & Principles
- **Opaque-Box & Requirement-Driven**: Derived strictly from user specifications in `ORIGINAL_REQUEST.md` (§R1, §R2, §R3) and architecture specifications in `orchestrator_2/PROJECT.md` (§F1–§F12).
- **Interface Contract Verification**: Validates concrete contracts for `StreamingVoiceFrame`, `StreamingVoiceClient`, `ActionQueueStep`, `DynamicActionQueue`, and `RoutineNotificationPolicy`.
- **Offline & Deterministic Execution**: Zero external cloud or hosted vendor dependencies; uses simulated Web Audio, deterministic PCM frame generators, and pure React 19 testing harnesses.
- **Strict Linting & Design System Compliance**: Conforms strictly to `@rakazo/ui-tokens` monochrome tokens, zero arbitrary hex colors, top-level `import type`, and clean Biome formatting.
---
## 2. Test Architecture & Runner Commands
### Execution Commands
1. **Run All E2E OS Test Suites (139 tests)**:
```bash
pnpm vitest run apps/web/src/tests/e2e-voice-first-foundation.test.ts apps/web/src/tests/e2e-autonomous-agency-queues.test.ts apps/web/src/tests/e2e-minimalist-tactile-ux.test.tsx apps/web/src/tests/e2e-cross-feature-workflows.test.ts
```
2. **Run Individual Feature Suites**:
- Voice Foundation (F1–F4):
```bash
pnpm vitest run apps/web/src/tests/e2e-voice-first-foundation.test.ts
```
- Autonomous Agency & Queues (F5–F8):
```bash
pnpm vitest run apps/web/src/tests/e2e-autonomous-agency-queues.test.ts
```
- Minimalist Tactile UX & Onboarding (F9–F12):
```bash
pnpm vitest run apps/web/src/tests/e2e-minimalist-tactile-ux.test.tsx
```
- Cross-Feature & Real-World Workflows (T3 & T4):
```bash
pnpm vitest run apps/web/src/tests/e2e-cross-feature-workflows.test.ts
```
3. **TypeScript Compilation Check**:
```bash
pnpm --filter @rakazo/web check
```
4. **Code Quality & Linter**:
```bash
pnpm biome check apps/web/src/tests/e2e-*.ts* apps/web/src/tests/fixtures/*.ts
```
---
## 3. Feature Inventory & Test Coverage Matrix
| # | Feature | Scope & Requirement | Test File | T1 | T2 | T3 | T4 | Total Tests |
|---|---------|---------------------|-----------|:--:|:--:|:--:|:--:|:-----------:|
| **F1** | Streaming Audio Wire & Low-Latency VAD | ORIGINAL_REQUEST §R1 (<120ms framing, PCM16, VAD) | `e2e-voice-first-foundation.test.ts` | 5 | 5 | ✓ | ✓ | 10+ |
| **F2** | Full-Duplex Audio & Barge-In Interruption | ORIGINAL_REQUEST §R1 (Continuous AEC, barge-in mute) | `e2e-voice-first-foundation.test.ts` | 5 | 5 | ✓ | ✓ | 10+ |
| **F3** | Incremental Utterance Streamer & Invite Strip | ORIGINAL_REQUEST §R1 (Clause pipelining, no invite delays) | `e2e-voice-first-foundation.test.ts` | 5 | 5 | ✓ | ✓ | 10+ |
| **F4** | Cross-Platform Earcons & Mobile Audio | ORIGINAL_REQUEST §R1 (Tactile Web Audio & mobile cues) | `e2e-voice-first-foundation.test.ts` | 5 | 5 | ✓ | ✓ | 10+ |
| **F5** | Optimistic Input Queue & Zero UI Freeze | ORIGINAL_REQUEST §R2 (Non-blocking composer, optimistic FIFO) | `e2e-autonomous-agency-queues.test.ts` | 5 | 5 | ✓ | ✓ | 10+ |
| **F6** | Mid-Step Tool Cancellation & Active Steering | ORIGINAL_REQUEST §R2 (Mid-execution abort, active steering) | `e2e-autonomous-agency-queues.test.ts` | 5 | 5 | ✓ | ✓ | 10+ |
| **F7** | Outcome-Driven Action Queue State Machine | ORIGINAL_REQUEST §R2 (Dynamic action queue, outcomes) | `e2e-autonomous-agency-queues.test.ts` | 5 | 5 | ✓ | ✓ | 10+ |
| **F8** | Quiet Background Routines & Delta Evaluation | ORIGINAL_REQUEST §R2 (on_change delta hash gating, no nag) | `e2e-autonomous-agency-queues.test.ts` | 5 | 5 | ✓ | ✓ | 10+ |
| **F9** | Tactile Minimalist Step Execution | ORIGINAL_REQUEST §R3 (Strip chrome bloat, folding step) | `e2e-minimalist-tactile-ux.test.tsx` | 5 | 5 | ✓ | ✓ | 10+ |
| **F10** | 30-Second Rapid Onboarding | ORIGINAL_REQUEST §R3 (1-click bootstrap, 3 action sparks) | `e2e-minimalist-tactile-ux.test.tsx` | 5 | 5 | ✓ | ✓ | 10+ |
| **F11** | Monochrome Semantic Token Hygiene | ORIGINAL_REQUEST §R3 (@rakazo/ui-tokens, zero rogue hex) | `e2e-minimalist-tactile-ux.test.tsx` | 5 | 5 | ✓ | ✓ | 10+ |
| **F12** | Living Canvas Progressive Disclosure | ORIGINAL_REQUEST §R3 (Dynamic canvas, preserve test IDs) | `e2e-minimalist-tactile-ux.test.tsx` | 5 | 5 | ✓ | ✓ | 10+ |
| **T3** | Cross-Feature Interactions | Pairwise combinations across R1, R2, R3 | `e2e-cross-feature-workflows.test.ts` | - | - | 12 | - | 12 |
| **T4** | Real-World Application Workflows | Realistic end-to-end operational scenarios | `e2e-cross-feature-workflows.test.ts` | - | - | - | 7 | 7 |
| **Total** | **All Features F1–F12 across T1–T4** | **100% Comprehensive Coverage** | **4 Suites + 3 Fixtures** | **60** | **60** | **12** | **7** | **139** |
---
## 4. Test Tiers Breakdown
### Tier 1: Feature Coverage (60 Tests: 5 Tests × 12 Features)
- **F1**: 80ms PCM16 frame chunking (1280 samples at 16kHz); VAD normalized RMS energy; <120ms cutoff threshold eliminating 850ms silence floor; monotonic timestamp increment; streaming client transcript & playback chunk callbacks.
- **F2**: Continuous microphone capture with acoustic echo cancellation (`echoCancellation: true`); user speech energy detection while bot speaking triggers barge-in; immediate playback audio mute; backend abort propagation (`abort('barge_in')`); state transition from speaking to listening.
- **F3**: Incremental clause segmentation in 4–6 word chunks; time-to-first-sound pipelining (<300ms TTFS target); stripping robotic invite prompts ("What would you like to do next?"); code fence description transformation per `speakable()`; natural punctuation boundary clause splits.
- **F4**: Core tactile earcons (`listening-start`, `listening-stop`, `speaking-start`, `speaking-done`); Web Audio procedural oscillator profiles; master audio bus ducking (0.28 ducking ratio); cross-platform mobile earcon mapping; state machine audio cue triggers (`interact`, `attention`).
- **F5**: Submitting message while another in-flight enqueues without UI freeze; temporary optimistic ID assignment (`temp-...`) and timestamp; composer text clearing immediately; Stop button unblocked and clickable; sequential FIFO drainage.
- **F6**: Ingesting steering during active tool call dispatches mid-step cancellation signal; tool abort controller receives `abort('steering_redirection')`; aborted tool output discarded from history; agent pivots immediately to steering intent; steering message marked claimed.
- **F7**: `DynamicActionQueue` initialization; multi-step prompt synthesis into `ActionQueueStep` items; queue advancement starts pending step with `startedAt`; completing step sets `completedAt` and advances; `cancelActive()` marks step cancelled.
- **F8**: `RoutineNotificationPolicy` with `"on_change"` evaluates output delta hash; identical output hash suppresses notification (quiet run); distinct output hash triggers notification; policy `"always"` notifies unconditionally; policy `"never"` suppresses all notifications.
- **F9**: Minimalist folding step button renders with `data-testid="run-steps"`; displays clean `# step` counter without progress track bloat; clicking fold button toggles `aria-expanded`; step tool name and summary rendered when expanded; keyboard Enter/Space toggles fold.
- **F10**: 1-click bootstrap creates initial agent workspace without 11-step questionnaire; auto-detects system timezone via `Intl.DateTimeFormat`; auto-detects system language via `navigator.language`; surfaces 3 tactile starter sparks; selecting spark starts session (<30s value).
- **F11**: Theme tokens definition from `@rakazo/ui-tokens`; zero hardcoded product hex colors; zero arbitrary Tailwind brackets `[var(--...)]`; accessible focus rings (`focus-visible:ring-ring`); status indicators use semantic tokens (`text-destructive`, `text-muted-foreground`).
- **F12**: Living Canvas pane renders with `data-testid="side-panel"`; Mission Stream renders with `data-testid="mission-stream-pane"` and `data-testid="transcript"`; preserves critical test IDs (`computer-preview`, `dual-surface-divider`, `dual-surface-header-controls`); progressive disclosure on artifact creation; tab switching between Files, Sandbox, Browser.
### Tier 2: Boundary & Corner Cases (60 Tests: 5 Tests × 12 Features)
- **F1**: Empty audio buffer (`Int16Array(0)`) handled with RMS 0 and no NaN errors; pure silence buffer maintains energy 0 and triggers silence cutoff without hang; clipping values clamped to Int16 limits (-32768 to 32767); out-of-order timestamp detection and jitter buffer tolerance; network drop triggers client reconnect and state preservation.
- **F2**: Microphone permission denied handled with graceful error state; interruption when audio playback is already idle is safe no-op; micro-burst transients (<50ms noise pop) filtered by VAD debounce; rapid alternating speaker turns (ping-pong barge-in); concurrent interrupt and incoming playback chunk race condition resolves in favor of user interrupt.
- **F3**: Single-word answers streamed without minimum character delay stalling; code-only block output sanitized without synthetic audio artifacts; emoji-only message stream strips emojis and does not stall TTS engine; micro-fragment token arrivals buffered into complete words; unexpected LLM stream termination cleanly finalizes active clause.
- **F4**: Rapid consecutive sound cue triggers (<10ms) queue smoothly without audio glitches; muted/silent mode suppresses oscillator output while executing state transition; AudioContext suspended state auto-resumes on first user gesture; unknown sound cue name falls back gracefully without throwing exceptions; cross-platform volume normalization preserves peak headroom.
- **F5**: Rapid typing burst (10 rapid inputs within 100ms) enqueues all 10 without dropping; bounded queue size enforces max pending limit with graceful error; network failure while message in flight triggers failed state with retry option without losing text; empty/whitespace-only messages rejected before enqueueing; duplicate messages submitted in rapid succession deduplicated if flagged.
- **F6**: Race condition: tool finishes at the exact moment abort signal fires resolves cleanly; tool execution timeout triggers automated mid-step cancellation with `timeout` reason; multiple rapid contradictory steering messages resolves to most recent intent; tool that throws exception upon abort caught gracefully without crashing runtime; external system side-effect fence prevents state pollution on aborted tools.
- **F7**: Synthesizing empty or meaningless input results in clean no-op; `advance()` called on an empty queue or completed queue is safe no-op; failed step status prevents automatic advance unless retry policy allows; step status state machine strictly validates allowable transitions; queue serialization and deserialization preserves complete step fidelity.
- **F8**: First execution (`prevOutputHash === ''`) with `"on_change"` triggers initial notification; policy `"on_failure"` triggers notification only when routine status is `"failed"`; whitespace/formatting variations evaluated cleanly against normalized hash; high frequency routine executions do not spam notifications if output remains constant; routine execution failure self-heals without crashing scheduler.
- **F9**: Empty steps list renders cleanly without error or broken layout; extreme step count (50+ tool calls) remains folded by default without layout overflow; failed step renders clean status badge without noisy error stacks unless expanded; absence of chrome bloat (removal of animated tracks, live timers `· 1:24`, uppercase callouts `AUFGABE`); rapid folding/unfolding toggles do not oscillate or corrupt DOM state.
- **F10**: Missing or invalid timezone fallback safely resolves to UTC without throwing error; missing or invalid locale fallback safely resolves to `en`; re-entering onboarding while workspace already exists redirects to active space; starter spark with special characters sanitized safely; offline onboarding gracefully saves initial draft locally without blocking UI.
- **F11**: Semantic contrast in monochrome mode preserves readable text tokens; disabled component states apply semantic opacity `disabled:opacity-50` rather than arbitrary grey; high contrast mode support: borders preserve visibility via semantic `border-border`; dynamic theme switching updates token class mappings deterministically; interactive hover states use semantic hover tokens (`hover:bg-accent`).
- **F12**: Closing Living Canvas collapses aside pane without unmounting workspace state; rapid switching between canvas tabs maintains individual surface state; resizing viewport below `md` breakpoint gracefully transitions Dual-Surface to responsive layout; expanding viewport back above `md` restores Dual-Surface split without reset; concurrent artifact creation from multiple sub-agents surfaces items progressively without flicker.
### Tier 3: Cross-Feature Interactions (12 Tests: Pairwise Coverage)
- **Pair 1**: Voice barge-in interrupt while action queue is actively running step (aborts active step, pauses execution, pivots queue).
- **Pair 2**: Optimistic queue burst while streaming audio TTS is playing (synthesizes follow-ups without audio dropout).
- **Pair 3**: Mid-step tool cancellation triggered via voice interruption event (voice audio energy spike propagates to in-flight tool abort controller).
- **Pair 4**: Quiet background routine fires during active voice session (audio ducking remains unaffected; output unchanged suppresses chat/audio notification).
- **Pair 5**: 30-second rapid onboarding directly initiates full-duplex voice stage (first-time user goes from 1-click bootstrap straight to hands-free voice dialogue).
- **Pair 6**: Action queue synthesis dynamically surfaces Living Canvas for artifact inspection (multi-step queue opens canvas on artifact creation).
- **Pair 7**: Tactile folding step reflects mid-step tool cancellation without UI glitch (shows `[cancelled]` step folded cleanly).
- **Pair 8**: Voice streaming client reconnects after temporary network drop while action queue advances to next step.
- **Pair 9**: Audio earcon triggers on optimistic queue drain and background delta detection (`interact` on drain, `attention` on delta).
- **Pair 10**: Living Canvas orientation swap while action queue runs background steps (DOM flex order reverses while steps advance uninterrupted).
- **Pair 11**: Voice interrupt resets incremental utterance clause pipelining (discards queued clauses immediately).
- **Pair 12**: Delta routine evaluation suppresses notification while user is typing in optimistic composer (zero disruption to active composer focus).
### Tier 4: Real-World Application Scenarios (7 Comprehensive Workflows)
- **Scenario 1: Ambient Hands-Free Meeting & Live Action Queue**: User dictates three multi-step action items in voice mode -> VAD frames audio -> parsed into Dynamic Action Queue -> steps advance deterministically -> earcon signals completion.
- **Scenario 2: Conversational Barge-In & Mid-Step Tool Cancellation**: Agent executing long command -> user speaks barge-in redirect -> playback instantly muted -> in-flight tool aborted -> queue pivoted to new intent.
- **Scenario 3: High-Frequency Multi-Prompt Burst & Optimistic Ordering**: User types 3 follow-up messages rapidly during elevated latency -> optimistic queue renders all 3 immediately with temporary IDs -> requests drain in order -> zero UI freeze.
- **Scenario 4: Quiet Background Routine with Delta Detection & No Nagging**: Scheduled routine runs periodic cycles -> hash unchanged -> zero notifications -> on cycle with detected anomaly, delta triggers notification -> user alerted only when critical.
- **Scenario 5: Rapid 30-Second Zero-Questionnaire Onboarding to Live Execution**: New user lands -> system auto-detects timezone and locale -> user clicks starter spark "Research competitor architecture" -> workspace boots instantly -> agent begins execution within 30 seconds.
- **Scenario 6: Tactile Dual-Surface Progressive Disclosure & Step Folding Workflow**: User asks agent to inspect repo -> agent executes 12 tool steps -> steps fold into clean `# steps` button -> agent writes file -> Living Canvas smoothly unfolds without modal occlusion.
- **Scenario 7: Network Loss & Reconnection Resilience during Full-Duplex Voice Session**: Voice stream active -> network packet loss occurs -> streaming client detects connection drop -> buffers outgoing frames -> reconnects -> flushes buffer and resumes conversation without data loss.
---
## 5. Artifact Index & Directory Layout
```
apps/web/src/tests/
├── e2e-voice-first-foundation.test.ts # 40 tests (Features F1-F4, Tiers 1-2)
├── e2e-autonomous-agency-queues.test.ts # 40 tests (Features F5-F8, Tiers 1-2)
├── e2e-minimalist-tactile-ux.test.tsx # 40 tests (Features F9-F12, Tiers 1-2)
├── e2e-cross-feature-workflows.test.ts # 19 tests (Tier 3 Pairwise & Tier 4 Scenarios)
└── fixtures/
├── voice-contracts.ts # F1-F4 contracts & reference VAD/framing models
├── agency-contracts.ts # F5-F8 contracts, Action Queue & Routine policy models
└── ux-contracts.ts # F9-F12 contracts, environment detection & starter sparks
```

123
TEST_READY.md Normal file
View File

@ -0,0 +1,123 @@
# TEST_READY: evOo Voice-First Autonomous Agent Operating System
## Status
- **Test Suite Status**: READY
- **Author**: `test_writer_r2_1` (Dual-Track E2E Testing Specialist)
- **Timestamp**: 2026-10-12T13:05:00Z
- **Target Milestones**: M1, M2, M3, M4, M5 (Features F1–F12 across Requirements R1, R2, R3)
- **Test Framework**: Vitest 4.1.11 with `// @vitest-environment jsdom` & Pure React 19 harnesses
---
## 1. Authored Test Suites Summary
| # | Test File Path | Scope & Feature Focus | Tiers Covered | Total Test Cases | Passing Baseline |
|---|----------------|-----------------------|:-------------:|:----------------:|:----------------:|
| 1 | `apps/web/src/tests/e2e-voice-first-foundation.test.ts` | Streaming Audio Wire (<120ms framing, PCM16, VAD), Full-Duplex Audio & Barge-In Interruption, Incremental Utterance Streamer (<300ms TTFS, invite strip), Tactile Monochrome Earcons | Tier 1, Tier 2 (F1–F4) | 40 | ✅ 40 / 40 Passed |
| 2 | `apps/web/src/tests/e2e-autonomous-agency-queues.test.ts` | Optimistic Input Queue (Zero UI Freeze, unblocked stop), Mid-Step Tool Cancellation & Active Steering, Dynamic Action Queue State Machine, Quiet Background Routines & Delta Evaluation (`on_change`) | Tier 1, Tier 2 (F5–F8) | 40 | ✅ 40 / 40 Passed |
| 3 | `apps/web/src/tests/e2e-minimalist-tactile-ux.test.tsx` | Tactile Minimalist Step Execution (strip JobProgressCard bloat, folding `# step`), 30-Second Rapid Onboarding (zero questionnaires, 3 action sparks), Monochrome Token Hygiene (@rakazo/ui-tokens), Living Canvas Progressive Disclosure | Tier 1, Tier 2 (F9–F12) | 40 | ✅ 40 / 40 Passed |
| 4 | `apps/web/src/tests/e2e-cross-feature-workflows.test.ts` | 12 Pairwise Cross-Feature Interactions & 7 Realistic End-to-End Workflows (Hands-free meeting, voice barge-in tool abort, high-frequency prompt burst, quiet background routines, 30s onboarding, progressive disclosure, voice network resilience) | Tier 3, Tier 4 | 19 | ✅ 19 / 19 Passed |
| **Total** | **4 Comprehensive Test Suites + 3 Fixtures** | **Full Feature Inventory (F1 to F12) Coverage** | **Tiers 1–4** | **139 tests** | **✅ 139 / 139 (100% Pass)** |
---
## 2. Requirement & Feature Coverage Matrix
| Feature | Source Requirement | Primary Test File | Tiers | Pass / Fail |
|---------|-------------------|-------------------|:-----:|:-----------:|
| **F1: Streaming Audio Wire & Low-Latency VAD** | ORIGINAL_REQUEST §R1 | `e2e-voice-first-foundation.test.ts` | T1, T2, T3, T4 | ✅ PASS (10/10) |
| **F2: Full-Duplex Audio & Barge-In Interruption** | ORIGINAL_REQUEST §R1 | `e2e-voice-first-foundation.test.ts` | T1, T2, T3, T4 | ✅ PASS (10/10) |
| **F3: Incremental Utterance Streamer & Invite Strip** | ORIGINAL_REQUEST §R1 | `e2e-voice-first-foundation.test.ts` | T1, T2, T3, T4 | ✅ PASS (10/10) |
| **F4: Cross-Platform Earcons & Mobile Audio** | ORIGINAL_REQUEST §R1 | `e2e-voice-first-foundation.test.ts` | T1, T2, T3, T4 | ✅ PASS (10/10) |
| **F5: Optimistic Input Queue & Zero UI Freeze** | ORIGINAL_REQUEST §R2 | `e2e-autonomous-agency-queues.test.ts` | T1, T2, T3, T4 | ✅ PASS (10/10) |
| **F6: Mid-Step Tool Cancellation & Active Steering** | ORIGINAL_REQUEST §R2 | `e2e-autonomous-agency-queues.test.ts` | T1, T2, T3, T4 | ✅ PASS (10/10) |
| **F7: Outcome-Driven Action Queue State Machine** | ORIGINAL_REQUEST §R2 | `e2e-autonomous-agency-queues.test.ts` | T1, T2, T3, T4 | ✅ PASS (10/10) |
| **F8: Quiet Background Routines & Delta Evaluation** | ORIGINAL_REQUEST §R2 | `e2e-autonomous-agency-queues.test.ts` | T1, T2, T3, T4 | ✅ PASS (10/10) |
| **F9: Tactile Minimalist Step Execution** | ORIGINAL_REQUEST §R3 | `e2e-minimalist-tactile-ux.test.tsx` | T1, T2, T3, T4 | ✅ PASS (10/10) |
| **F10: 30-Second Rapid Onboarding** | ORIGINAL_REQUEST §R3 | `e2e-minimalist-tactile-ux.test.tsx` | T1, T2, T3, T4 | ✅ PASS (10/10) |
| **F11: Monochrome Semantic Token Hygiene** | ORIGINAL_REQUEST §R3 | `e2e-minimalist-tactile-ux.test.tsx` | T1, T2, T3, T4 | ✅ PASS (10/10) |
| **F12: Living Canvas Progressive Disclosure** | ORIGINAL_REQUEST §R3 | `e2e-minimalist-tactile-ux.test.tsx` | T1, T2, T3, T4 | ✅ PASS (10/10) |
| **T3: Pairwise Cross-Feature Interactions** | Cross-Module Interactions | `e2e-cross-feature-workflows.test.ts` | Tier 3 | ✅ PASS (12/12) |
| **T4: Real-World Application Scenarios** | Operational Workflows | `e2e-cross-feature-workflows.test.ts` | Tier 4 | ✅ PASS (7/7) |
---
## 3. Tier Breakdown & Test Cases
### Tier 1: Feature Coverage (60 Tests)
- **F1**: 80ms PCM16 frame chunking (1280 samples at 16kHz); VAD normalized RMS energy; <120ms cutoff threshold eliminating 850ms silence floor; monotonic timestamp increment; streaming client transcript & playback chunk callbacks.
- **F2**: Continuous microphone capture with acoustic echo cancellation (`echoCancellation: true`); user speech energy detection while bot speaking triggers barge-in; immediate playback audio mute; backend abort propagation (`abort('barge_in')`); state transition from speaking to listening.
- **F3**: Incremental clause segmentation in 4–6 word chunks; time-to-first-sound pipelining (<300ms TTFS target); stripping robotic invite prompts ("What would you like to do next?"); code fence description transformation per `speakable()`; natural punctuation boundary clause splits.
- **F4**: Core tactile earcons (`listening-start`, `listening-stop`, `speaking-start`, `speaking-done`); Web Audio procedural oscillator profiles; master audio bus ducking (0.28 ducking ratio); cross-platform mobile earcon mapping; state machine audio cue triggers (`interact`, `attention`).
- **F5**: Submitting message while another in-flight enqueues without UI freeze; temporary optimistic ID assignment (`temp-...`) and timestamp; composer text clearing immediately; Stop button unblocked and clickable; sequential FIFO drainage.
- **F6**: Ingesting steering during active tool call dispatches mid-step cancellation signal; tool abort controller receives `abort('steering_redirection')`; aborted tool output discarded from history; agent pivots immediately to steering intent; steering message marked claimed.
- **F7**: `DynamicActionQueue` initialization; multi-step prompt synthesis into `ActionQueueStep` items; queue advancement starts pending step with `startedAt`; completing step sets `completedAt` and advances; `cancelActive()` marks step cancelled.
- **F8**: `RoutineNotificationPolicy` with `"on_change"` evaluates output delta hash; identical output hash suppresses notification (quiet run); distinct output hash triggers notification; policy `"always"` notifies unconditionally; policy `"never"` suppresses all notifications.
- **F9**: Minimalist folding step button renders with `data-testid="run-steps"`; displays clean `# step` counter without progress track bloat; clicking fold button toggles `aria-expanded`; step tool name and summary rendered when expanded; keyboard Enter/Space toggles fold.
- **F10**: 1-click bootstrap creates initial agent workspace without 11-step questionnaire; auto-detects system timezone via `Intl.DateTimeFormat`; auto-detects system language via `navigator.language`; surfaces 3 tactile starter sparks; selecting spark starts session (<30s value).
- **F11**: Theme tokens definition from `@rakazo/ui-tokens`; zero hardcoded product hex colors; zero arbitrary Tailwind brackets `[var(--...)]`; accessible focus rings (`focus-visible:ring-ring`); status indicators use semantic tokens (`text-destructive`, `text-muted-foreground`).
- **F12**: Living Canvas pane renders with `data-testid="side-panel"`; Mission Stream renders with `data-testid="mission-stream-pane"` and `data-testid="transcript"`; preserves critical test IDs (`computer-preview`, `dual-surface-divider`, `dual-surface-header-controls`); progressive disclosure on artifact creation; tab switching between Files, Sandbox, Browser.
### Tier 2: Boundary & Corner Cases (60 Tests)
- **F1**: Empty audio buffer (`Int16Array(0)`) handled with RMS 0 and no NaN errors; pure silence buffer maintains energy 0 and triggers silence cutoff without hang; clipping values clamped to Int16 limits (-32768 to 32767); out-of-order timestamp detection and jitter buffer tolerance; network drop triggers client reconnect and state preservation.
- **F2**: Microphone permission denied handled with graceful error state; interruption when audio playback is already idle is safe no-op; micro-burst transients (<50ms noise pop) filtered by VAD debounce; rapid alternating speaker turns (ping-pong barge-in); concurrent interrupt and incoming playback chunk race condition resolves in favor of user interrupt.
- **F3**: Single-word answers streamed without minimum character delay stalling; code-only block output sanitized without synthetic audio artifacts; emoji-only message stream strips emojis and does not stall TTS engine; micro-fragment token arrivals buffered into complete words; unexpected LLM stream termination cleanly finalizes active clause.
- **F4**: Rapid consecutive sound cue triggers (<10ms) queue smoothly without audio glitches; muted/silent mode suppresses oscillator output while executing state transition; AudioContext suspended state auto-resumes on first user gesture; unknown sound cue name falls back gracefully without throwing exceptions; cross-platform volume normalization preserves peak headroom.
- **F5**: Rapid typing burst (10 rapid inputs within 100ms) enqueues all 10 without dropping; bounded queue size enforces max pending limit with graceful error; network failure while message in flight triggers failed state with retry option without losing text; empty/whitespace-only messages rejected before enqueueing; duplicate messages submitted in rapid succession deduplicated if flagged.
- **F6**: Race condition: tool finishes at the exact moment abort signal fires resolves cleanly; tool execution timeout triggers automated mid-step cancellation with `timeout` reason; multiple rapid contradictory steering messages resolves to most recent intent; tool that throws exception upon abort caught gracefully without crashing runtime; external system side-effect fence prevents state pollution on aborted tools.
- **F7**: Synthesizing empty or meaningless input results in clean no-op; `advance()` called on an empty queue or completed queue is safe no-op; failed step status prevents automatic advance unless retry policy allows; step status state machine strictly validates allowable transitions; queue serialization and deserialization preserves complete step fidelity.
- **F8**: First execution (`prevOutputHash === ''`) with `"on_change"` triggers initial notification; policy `"on_failure"` triggers notification only when routine status is `"failed"`; whitespace/formatting variations evaluated cleanly against normalized hash; high frequency routine executions do not spam notifications if output remains constant; routine execution failure self-heals without crashing scheduler.
- **F9**: Empty steps list renders cleanly without error or broken layout; extreme step count (50+ tool calls) remains folded by default without layout overflow; failed step renders clean status badge without noisy error stacks unless expanded; absence of chrome bloat (removal of animated tracks, live timers `· 1:24`, uppercase callouts `AUFGABE`); rapid folding/unfolding toggles do not oscillate or corrupt DOM state.
- **F10**: Missing or invalid timezone fallback safely resolves to UTC without throwing error; missing or invalid locale fallback safely resolves to `en`; re-entering onboarding while workspace already exists redirects to active space; starter spark with special characters sanitized safely; offline onboarding gracefully saves initial draft locally without blocking UI.
- **F11**: Semantic contrast in monochrome mode preserves readable text tokens; disabled component states apply semantic opacity `disabled:opacity-50` rather than arbitrary grey; high contrast mode support: borders preserve visibility via semantic `border-border`; dynamic theme switching updates token class mappings deterministically; interactive hover states use semantic hover tokens (`hover:bg-accent`).
- **F12**: Closing Living Canvas collapses aside pane without unmounting workspace state; rapid switching between canvas tabs maintains individual surface state; resizing viewport below `md` breakpoint gracefully transitions Dual-Surface to responsive layout; expanding viewport back above `md` restores Dual-Surface split without reset; concurrent artifact creation from multiple sub-agents surfaces items progressively without flicker.
### Tier 3: Cross-Feature Interactions (12 Tests)
- Pair 1: Voice barge-in interrupt while action queue is actively running step.
- Pair 2: Optimistic queue burst while streaming audio TTS is playing.
- Pair 3: Mid-step tool cancellation triggered via voice interruption event.
- Pair 4: Quiet background routine fires during active voice session.
- Pair 5: 30-second rapid onboarding directly initiates full-duplex voice stage.
- Pair 6: Action queue synthesis dynamically surfaces Living Canvas for artifact inspection.
- Pair 7: Tactile folding step reflects mid-step tool cancellation without UI glitch.
- Pair 8: Voice streaming client reconnects after temporary network drop while action queue advances.
- Pair 9: Audio earcon triggers on optimistic queue drain and background delta detection.
- Pair 10: Living Canvas orientation swap while action queue runs background steps.
- Pair 11: Voice interrupt resets incremental utterance clause pipelining.
- Pair 12: Delta routine evaluation suppresses notification while user is typing in optimistic composer.
### Tier 4: Real-World Application Scenarios (7 Comprehensive Workflows)
- Scenario 1: Ambient Hands-Free Meeting & Live Action Queue.
- Scenario 2: Conversational Barge-In & Mid-Step Tool Cancellation.
- Scenario 3: High-Frequency Multi-Prompt Burst & Optimistic Ordering.
- Scenario 4: Quiet Background Routine with Delta Detection & No Nagging.
- Scenario 5: Rapid 30-Second Zero-Questionnaire Onboarding to Live Execution.
- Scenario 6: Tactile Dual-Surface Progressive Disclosure & Step Folding Workflow.
- Scenario 7: Network Loss & Reconnection Resilience during Full-Duplex Voice Session.
---
## 4. Verification Method
Run the following command in the repository root to execute the complete E2E test suite:
```bash
pnpm vitest run apps/web/src/tests/e2e-voice-first-foundation.test.ts apps/web/src/tests/e2e-autonomous-agency-queues.test.ts apps/web/src/tests/e2e-minimalist-tactile-ux.test.tsx apps/web/src/tests/e2e-cross-feature-workflows.test.ts
```
Output:
```
Test Files 4 passed (4)
Tests 139 passed (139)
Duration ~3.0s
```
TypeScript Check:
```bash
pnpm --filter @rakazo/web check
```
Biome Lint & Format Check:
```bash
pnpm biome check apps/web/src/tests/e2e-*.ts* apps/web/src/tests/fixtures/*.ts
```

View File

@ -60,6 +60,8 @@ One bot has one continuous visible thread because continuity is part of its iden
Team Computers share a persisted browser identity and isolate simultaneous live desktops when supported because shared login state and safe concurrency are both required.
Private Computers isolate the entire working home because some trust boundaries must be stronger than collaboration convenience.
Connections belong to the user or space rather than requiring repetitive per-bot setup because bots differ through role and state, not account plumbing.
Bots answer with cards from a fixed catalog of sections rather than generated interface because the backend must validate every card and apply every tap, and a small catalog stays calm and consistent on every surface.
An autopilot works on an order with no time or cost limit, but pauses when runs stop reporting progress and always shows when it started and ended, because long unattended work must stay visible and must never spin silently.
## Scope

View File

@ -60,7 +60,7 @@ Persona is **derived at runtime** from existing bot fields (`name`, `title`, `de
| Visual identity | `BotAvatar` — persona shape + unified `EvooMascot` soul (eyes/mouth/hands) driven by voice phase |
| Voice delivery | `speaker.speak(..., { botId })` → server resolves per-bot `voiceId` |
Archetypes include: CTO executive (Steve Jobs template), chief operator (Fixer), builder (Dr. Eggbot), curator (Webby), work/life inboxes, home steward, quant, engineer, **specialist** (narrow fallback — no Wunderwuzzi). See `docs/PRODUCT.md` and `docs/PERSONA-SELECTION.md`.
Archetypes include: CTO executive, chief operator (Fixer), builder (Dr. Eggbot), curator (Webby), work/life inboxes, home steward, quant, engineer, **specialist** (narrow fallback — no Wunderwuzzi). See `docs/PRODUCT.md` and `docs/PERSONA-SELECTION.md`.
**Companion tone:** geek-peer (fachsimpeln when wanted) or quiet-doer (execute without condescension) — inferred per user, not one voice for everyone.

View File

@ -1,119 +0,0 @@
# APEX TRADING ENGINE — BLUEPRINT & SYSTEM ARCHITECTURE
> **Version**: 1.0.0-PROD
> **Subsystem**: evOo Autonomous High-Frequency & Quantitative Engine
> **Aufsicht**: Chief Technology Officer Steve Jobs & Apex Quant Specialist
> **Status**: RATIFIZIERT & IM DATEISYSTEM VERANKERT
---
## 1. Executive Summary & Zielsetzung
Die **Apex Trading Engine** ist das hochperformante quantitative Handels-Subsystem innerhalb der evOo-Plattform. Sie wurde entwickelt, um Marktchancen algorithmisch zu identifizieren, Risiken deterministisch abzusichern und vollautomatisiert mit Sub-Millisekunden-Latenz zu agieren.
Das System folgt dem obersten evOo-Grundsatz:
> **„Keine hypothetischen Trades, keine ungetesteten Algorithmen. Nur deterministische Ausführung und unnachgiebiges Risikomanagement.“**
---
## 2. System-Architektur
```
┌──────────────────────────────────────────────────────────────┐
│ MARKET DATA FEEDS │
│ (WebSockets, Orderbook L2/L3, REST Snapshots, On-Chain RPC) │
└──────────────────────────────┬───────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ APEX DATA PIPELINE & TICK BUFFER │
│ (Normalisierung, VWAP, Volatilität, Delta) │
└──────────────────────────────┬───────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ QUANT STRATEGY ENGINE │
│ ├── Alpha-1: Momentum & Breakout (Trendfolge) │
│ ├── Alpha-2: Order Book Imbalance & Flow Reversal │
│ └── Alpha-3: Statistical Arbitrage / Mean Reversion │
└──────────────────────────────┬───────────────────────────────┘
│ Signal
▼
┌──────────────────────────────────────────────────────────────┐
│ APEX RISK GUARDRAILS (HARD ENFORCEMENT) │
│ ├── Max Drawdown Lock: 1.5% Intraday │
│ ├── Max Single-Position Exposure: 5.0% Portfolio │
│ ├── Value-at-Risk (99% CI): Max 0.8% │
│ └── Autonomous Hardware Kill Switch │
└──────────────────────────────┬───────────────────────────────┘
│ Approved Order
▼
┌──────────────────────────────────────────────────────────────┐
│ SMART ORDER ROUTER & EXECUTION │
│ (TWAP/VWAP Slicing, Slippage Minimizer) │
└──────────────────────────────────────────────────────────────┘
```
---
## 3. Risikomatrix & Hard-Guardrails
Kein Signal kann ausgeführt werden, ohne die **Apex Risk Guard** Instanz zu passieren. Diese Schranken sind unveränderlich im Code fixiert:
| Guardrail | Grenzwert | Aktion bei Verletzung |
| :--- | :--- | :--- |
| **Max Intraday Drawdown** | `1.50%` | Sofortige Schließung aller Positionen, Deaktivierung des Handels für 24h |
| **Single Trade Risk** | `0.50%` des Eigenkapitals | Order wird von der Risk Engine vor dem Senden blockiert |
| **Max Leverage** | `3.0x` | Harte Grenze im Margin-Router |
| **Slippage Toleranz** | `0.15%` | Limit-Order mit Timeout, kein Blind-Market-Buying |
| **Latenz-Anomalie** | `> 50 ms` | Übergang in den Notfall-Sicherheitsmodus |
---
## 4. Strategiemodule (`src/strategies/`)
### 4.1 Alpha-1: Cross-Market Momentum (`momentum.py`)
Identifiziert signifikante Momentum-Impulse durch exponentiell gewichtete Gleitende Durchschnitte kombiniert mit Relative Strength Index (RSI) und Volumen-Konfirmation.
- **Trigger**: Bollinger Band Expansion + Volumenspitze > 2.2x 20-Perioden-Durchschnitt.
- **Exit**: Trailing Stop bei 1.2x ATR (Average True Range) oder Umkehrsignal.
### 4.2 Alpha-2: Order Book Imbalance (`imbalance.py`)
Scannt Bid/Ask-Tiefe in den Top 10 Ebenen des Orderbuchs.
- **Formel**:
$$Imbalance = \frac{Volume_{Bid} - Volume_{Ask}}{Volume_{Bid} + Volume_{Ask}}$$
- Signal feuert, wenn $|Imbalance| > 0.65$ für mindestens 3 konsekutive Ticks.
### 4.3 Alpha-3: Statistical Mean Reversion (`reversion.py`)
Nutzt Z-Score Abweichungen des aktuellen Preises vom gleitenden VWAP.
- Einstieg bei Z-Score $\ge 2.5$ oder $\le -2.5$ mit Rückkehr zum Mittelwert als Target.
---
## 5. Integration mit der evOo Self-Improvement Schleife
1. **Trade Post-Mortem**: Jeder geschlossene Trade generiert ein strukturiertes JSON-Log mit Execution-Slippage, Haltedauer und PnL.
2. **Autonome Kalibrierung**: Wenn die Win-Rate über 50 Trades unter 52% fällt, initiiert der Casted Quant Specialist eine automatische Hyperparameter-Optimierung.
3. **Audit-Sicherheit**: Alle Parameteränderungen werden in `ARCHITECTURE.md` und Git-Commits dokumentiert.
---
## 6. Verzeichnisstruktur von `/apex-trading`
```
/apex-trading/
├── BLUEPRINT.md # Dieser Blueprint
├── README.md # Quickstart & CLI Anleitung
├── config/
│ └── trading.config.json # Konfigurierte Risikogrenzen & Assets
└── src/
├── __init__.py
├── engine/
│ ├── __init__.py
│ └── trader.py # Core Event Loop & Router
├── strategies/
│ ├── __init__.py
│ └── momentum.py # Momentum-Strategie
└── risk/
├── __init__.py
└── risk_guard.py # Hard Risk Guardrails & Circuit Breaker
```

View File

@ -1,11 +0,0 @@
# Apex Trading Subsystem
Quantitative & High-Frequency Trading Subsystem of the evOo Platform.
## Features
- Hard-coded risk controls (`src/risk/risk_guard.py`)
- Quantitative momentum and imbalance strategies (`src/strategies/`)
- Autonomous execution engine (`src/engine/trader.py`)
- Full integration with evOo Self-Improvement and CTO Steve Jobs directives.
See [BLUEPRINT.md](file:///Users/ingowagner/awesome-grok-bot/apex-trading/BLUEPRINT.md) for full architecture details.

View File

@ -1,29 +0,0 @@
{
"system": "Apex Trading Engine",
"version": "1.0.0-PROD",
"mode": "paper_trading",
"base_currency": "USDC",
"allocated_capital": 100000.0,
"risk_limits": {
"max_drawdown_percent": 1.5,
"max_single_trade_risk_percent": 0.5,
"max_open_positions": 4,
"max_leverage": 3.0,
"slippage_tolerance_percent": 0.15,
"circuit_breaker_cooldown_seconds": 3600
},
"active_pairs": ["BTC/USDC", "ETH/USDC", "SOL/USDC"],
"strategies": {
"momentum": {
"enabled": true,
"lookback_period": 20,
"rsi_oversold": 32,
"rsi_overbought": 68
},
"orderbook_imbalance": {
"enabled": true,
"threshold": 0.65,
"depth_levels": 10
}
}
}

View File

@ -1,62 +0,0 @@
"""
Apex Trading Engine Core
Event loop that receives signals, checks risk guardrails, and executes orders.
"""
import json
import os
import sys
from typing import Dict, Any
from pathlib import Path
SRC_DIR = Path(__file__).resolve().parent.parent
if str(SRC_DIR) not in sys.path:
sys.path.insert(0, str(SRC_DIR))
try:
from risk.risk_guard import ApexRiskGuard
from strategies.momentum import MomentumStrategy
except ImportError:
from ..risk.risk_guard import ApexRiskGuard
from ..strategies.momentum import MomentumStrategy
class ApexEngine:
def __init__(self, config_path: str):
with open(config_path, 'r', encoding='utf-8') as f:
self.config = json.load(f)
self.risk_guard = ApexRiskGuard(self.config)
self.momentum_strategy = MomentumStrategy(self.config.get("strategies", {}).get("momentum"))
self.open_positions = []
self.current_drawdown_pct = 0.0
def process_tick(self, symbol: str, prices: list, volume: list) -> Dict[str, Any]:
"""Processes market data tick through strategy and risk guard."""
signal = self.momentum_strategy.generate_signal(symbol, prices, volume)
if not signal:
return {"status": "NO_SIGNAL", "symbol": symbol}
approved, reason = self.risk_guard.validate_order(
signal,
self.current_drawdown_pct,
len(self.open_positions)
)
if not approved:
return {"status": "REJECTED_BY_RISK", "reason": reason, "signal": signal}
# Simulated Execution
order_receipt = {
"status": "EXECUTED",
"symbol": signal["symbol"],
"action": signal["action"],
"fill_price": signal["price"],
"strategy": signal["strategy"],
"risk_status": "APPROVED"
}
self.open_positions.append(order_receipt)
return order_receipt
if __name__ == "__main__":
print("[APEX ENGINE] Liveness check passed. Engine ready.")

View File

@ -1,40 +0,0 @@
"""
Apex Trading Risk Guard
Hard enforcement of risk constraints defined in BLUEPRINT.md.
"""
from typing import Dict, Any, Tuple
class ApexRiskGuard:
def __init__(self, config: Dict[str, Any]):
self.config = config.get("risk_limits", {})
self.max_drawdown = self.config.get("max_drawdown_percent", 1.5)
self.max_trade_risk = self.config.get("max_single_trade_risk_percent", 0.5)
self.max_positions = self.config.get("max_open_positions", 4)
self.circuit_breaker_active = False
def validate_order(self, order: Dict[str, Any], current_drawdown: float, open_position_count: int) -> Tuple[bool, str]:
"""
Validates an incoming order against hard guardrails.
Returns (is_approved: bool, reason: str).
"""
if self.circuit_breaker_active:
return False, "REJECTED: Circuit breaker is active."
if current_drawdown >= self.max_drawdown:
self.circuit_breaker_active = True
return False, f"REJECTED & CIRCUIT BREAKER TRIGGERED: Drawdown {current_drawdown}% >= limit {self.max_drawdown}%"
if open_position_count >= self.max_positions:
return False, f"REJECTED: Maximum concurrent positions ({self.max_positions}) reached."
order_risk_pct = order.get("risk_percent", 0.0)
if order_risk_pct > self.max_trade_risk:
return False, f"REJECTED: Order risk {order_risk_pct}% exceeds limit {self.max_trade_risk}%"
return True, "APPROVED"
def trip_kill_switch(self, reason: str):
"""Emergency hard stop."""
self.circuit_breaker_active = True
print(f"[APEX EMERGENCY KILL SWITCH] {reason}")

View File

@ -1,48 +0,0 @@
"""
Apex Momentum Strategy Module (Alpha-1)
Calculates breakout and trend-following signals.
"""
from typing import List, Dict, Any, Optional
class MomentumStrategy:
def __init__(self, params: Optional[Dict[str, Any]] = None):
self.params = params or {}
self.lookback = self.params.get("lookback_period", 20)
self.rsi_oversold = self.params.get("rsi_oversold", 32)
self.rsi_overbought = self.params.get("rsi_overbought", 68)
def generate_signal(self, symbol: str, prices: List[float], volume: List[float]) -> Optional[Dict[str, Any]]:
"""
Evaluates recent tick series and outputs a trading signal if criteria are satisfied.
"""
if len(prices) < self.lookback:
return None
recent_window = prices[-self.lookback:]
current_price = prices[-1]
sma = sum(recent_window) / len(recent_window)
# Simple momentum breakout check
if current_price > sma * 1.015:
return {
"symbol": symbol,
"action": "BUY",
"price": current_price,
"strategy": "Alpha-1:Momentum",
"risk_percent": 0.35,
"stop_loss": current_price * 0.985,
"take_profit": current_price * 1.045
}
elif current_price < sma * 0.985:
return {
"symbol": symbol,
"action": "SELL",
"price": current_price,
"strategy": "Alpha-1:Momentum",
"risk_percent": 0.35,
"stop_loss": current_price * 1.015,
"take_profit": current_price * 0.955
}
return None

View File

@ -1,52 +0,0 @@
#!/usr/bin/env python3
"""
Unit tests for Apex Trading Engine and Risk Guardrails.
"""
import unittest
from pathlib import Path
import sys
BASE_DIR = Path(__file__).resolve().parent
if str(BASE_DIR / "src") not in sys.path:
sys.path.insert(0, str(BASE_DIR / "src"))
from engine.trader import ApexEngine
from risk.risk_guard import ApexRiskGuard
from strategies.momentum import MomentumStrategy
class TestApexTrading(unittest.TestCase):
def setUp(self):
self.config_path = str(BASE_DIR / "config" / "trading.config.json")
self.engine = ApexEngine(self.config_path)
def test_risk_guard_limits(self):
guard = self.engine.risk_guard
# Normal order passes
normal_order = {"symbol": "BTC/USDT", "risk_percent": 0.3}
approved, reason = guard.validate_order(normal_order, current_drawdown=0.5, open_position_count=1)
self.assertTrue(approved)
self.assertEqual(reason, "APPROVED")
# Exceeding single trade risk
risky_order = {"symbol": "BTC/USDT", "risk_percent": 0.8}
approved, reason = guard.validate_order(risky_order, current_drawdown=0.5, open_position_count=1)
self.assertFalse(approved)
self.assertIn("exceeds limit", reason)
# Exceeding max drawdown trips circuit breaker
approved, reason = guard.validate_order(normal_order, current_drawdown=1.6, open_position_count=1)
self.assertFalse(approved)
self.assertIn("CIRCUIT BREAKER", reason)
self.assertTrue(guard.circuit_breaker_active)
def test_engine_process_tick(self):
# 25 prices trending sharply upwards
prices = [100.0 + (i * 1.5) for i in range(25)]
volume = [1000.0] * 25
result = self.engine.process_tick("SOL/USDT", prices, volume)
self.assertEqual(result.get("status"), "EXECUTED")
self.assertEqual(result.get("action"), "BUY")
if __name__ == "__main__":
unittest.main()

View File

@ -26,9 +26,13 @@ function savedSkill(name: string, source = "user") {
}
function setup(rows: ReturnType<typeof savedSkill>[] = []) {
type Where = { id?: string; spaceId: string; userId: string; source?: string };
type Where = { id?: string; spaceId?: string; userId: string; source?: string; name?: unknown };
const matches = (row: ReturnType<typeof savedSkill>, where: Where) =>
Object.entries(where).every(([key, value]) => row[key as keyof typeof row] === value);
Object.entries(where).every(([key, value]) =>
key === "name" && typeof value === "object" && value && "equals" in value
? row.name.toLowerCase() === String(value.equals).toLowerCase()
: key === "NOT" || row[key as keyof typeof row] === value,
);
const agentSkill = {
findMany: vi.fn(async ({ where }: { where: Where }) =>
rows.filter((row) => matches(row, where)),
@ -55,15 +59,19 @@ function setup(rows: ReturnType<typeof savedSkill>[] = []) {
};
}
/** Only the entries competing for the name "Interrogate"; other builtins are irrelevant here. */
const interrogate = <T extends { name: string }>(skills: T[]) =>
skills.filter((skill) => skill.name.trim().toLowerCase() === "interrogate");
describe("built-in skill precedence in the API", () => {
it.each(["interrogate", " Interrogate "])(
"keeps %j listed, readable and mutable",
async (name) => {
const { service } = setup([savedSkill(name)]);
await expect(service.list(actor)).resolves.toEqual([
expect(interrogate(await service.list(actor))).toEqual([
expect.objectContaining({ id: "saved-1", name, readOnly: false }),
]);
await expect(service.listWithContent(actor)).resolves.toEqual([
expect(interrogate(await service.listWithContent(actor))).toEqual([
expect.objectContaining({ id: "saved-1", content: expect.stringContaining("Saved steps") }),
]);
await expect(service.get(actor, { name: " INTERROGATE " })).resolves.toMatchObject({
@ -97,7 +105,23 @@ describe("built-in skill precedence in the API", () => {
expect(agentSkill.deleteMany).not.toHaveBeenCalled();
});
it.each([{ spaceId: "other-space" }, { userId: "other-user" }])(
it("lists the user's skills from other spaces too, the current space winning a clash", async () => {
const { service } = setup([
{ ...savedSkill("Launch"), id: "home-1", spaceId: "home" },
{ ...savedSkill("launch"), id: "here-1" },
{ ...savedSkill("Weekly review"), id: "home-2", spaceId: "home" },
]);
const own = (await service.list(actor)).filter((skill) => skill.source === "user");
expect(own.map((skill) => skill.id).sort()).toEqual(["here-1", "home-2"]);
await expect(service.get(actor, { name: "Weekly review" })).resolves.toMatchObject({
id: "home-2",
});
await expect(
service.create(actor, { name: "weekly REVIEW", description: "x" }),
).rejects.toThrow("already exists");
});
it.each([{ userId: "other-user" }])(
"does not let foreign skills shadow the builtin: %j",
async (foreignOwner) => {
const { service } = setup([{ ...savedSkill(" Interrogate "), ...foreignOwner }]);
@ -105,7 +129,7 @@ describe("built-in skill precedence in the API", () => {
id: "builtin:Interrogate",
readOnly: true,
});
await expect(service.list(actor)).resolves.toEqual([
expect(interrogate(await service.list(actor))).toEqual([
expect.objectContaining({ id: "builtin:Interrogate" }),
]);
await expect(service.get(actor, { skillId: "saved-1" })).rejects.toThrow();

View File

@ -7,6 +7,7 @@ import {
isSkillReadOnly,
mergeBuiltinSkills,
parseSkillMd,
preferSpaceSkills,
type SkillSource,
} from "@rakazo/core";
import { IsolationError, type PrismaClient } from "@rakazo/db";
@ -121,11 +122,7 @@ export function resolveSkillContent(input: {
export function createAgentSkillsService(prisma: PrismaClient) {
async function owned(actor: Actor, skillId: string) {
const row = await prisma.agentSkill.findFirst({
where: {
id: skillId,
spaceId: actor.spaceId,
userId: actor.userId,
},
where: { id: skillId, userId: actor.userId },
});
if (!row) throw new IsolationError();
return row;
@ -134,20 +131,24 @@ export function createAgentSkillsService(prisma: PrismaClient) {
return {
async list(actor: Actor): Promise<Omit<AgentSkill, "content">[]> {
const rows = await prisma.agentSkill.findMany({
where: { spaceId: actor.spaceId, userId: actor.userId },
where: { userId: actor.userId },
orderBy: [{ name: "asc" }, { id: "asc" }],
});
return mergeBuiltinSkills(builtinCatalog(), rows.map(mapAgentSkill)).map(
({ content: _content, ...entry }) => entry,
);
return mergeBuiltinSkills(
builtinCatalog(),
preferSpaceSkills(rows, actor.spaceId).map(mapAgentSkill),
).map(({ content: _content, ...entry }) => entry);
},
async listWithContent(actor: Actor): Promise<AgentSkill[]> {
const rows = await prisma.agentSkill.findMany({
where: { spaceId: actor.spaceId, userId: actor.userId },
where: { userId: actor.userId },
orderBy: [{ name: "asc" }, { id: "asc" }],
});
return mergeBuiltinSkills(builtinCatalog(), rows.map(mapAgentSkill));
return mergeBuiltinSkills(
builtinCatalog(),
preferSpaceSkills(rows, actor.spaceId).map(mapAgentSkill),
);
},
async get(actor: Actor, input: { skillId?: string; name?: string }): Promise<AgentSkill> {
@ -161,13 +162,10 @@ export function createAgentSkillsService(prisma: PrismaClient) {
}
const name = input.name?.trim() ?? "";
const rows = await prisma.agentSkill.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
},
where: { userId: actor.userId },
orderBy: [{ name: "asc" }, { id: "asc" }],
});
const row = findSkillByName(rows, name);
const row = findSkillByName(preferSpaceSkills(rows, actor.spaceId), name);
if (row) return mapAgentSkill(row);
const builtin = findSkillByName(builtinCatalog(), name);
if (!builtin) throw new IsolationError();
@ -181,7 +179,6 @@ export function createAgentSkillsService(prisma: PrismaClient) {
const resolved = resolveSkillContent(input);
const clash = await prisma.agentSkill.findFirst({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
name: { equals: resolved.name, mode: "insensitive" },
},
@ -234,7 +231,6 @@ export function createAgentSkillsService(prisma: PrismaClient) {
if (!findSkillByName([existing], resolved.name)) {
const clash = await prisma.agentSkill.findFirst({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
name: { equals: resolved.name, mode: "insensitive" },
NOT: { id: existing.id },
@ -250,12 +246,7 @@ export function createAgentSkillsService(prisma: PrismaClient) {
// Mutate only owner-scoped user rows (never builtin/plugin), even if source was tampered.
try {
const updated = await prisma.agentSkill.updateMany({
where: {
id: existing.id,
spaceId: actor.spaceId,
userId: actor.userId,
source: "user",
},
where: { id: existing.id, userId: actor.userId, source: "user" },
data: {
name: resolved.name,
description: resolved.description,
@ -275,11 +266,7 @@ export function createAgentSkillsService(prisma: PrismaClient) {
throw error;
}
const row = await prisma.agentSkill.findFirst({
where: {
id: existing.id,
spaceId: actor.spaceId,
userId: actor.userId,
},
where: { id: existing.id, userId: actor.userId },
});
if (!row) throw new IsolationError();
return mapAgentSkill(row);
@ -291,12 +278,7 @@ export function createAgentSkillsService(prisma: PrismaClient) {
throw new ORPCError("BAD_REQUEST", { message: "Builtin and plugin skills are read-only." });
}
const deleted = await prisma.agentSkill.deleteMany({
where: {
id: existing.id,
spaceId: actor.spaceId,
userId: actor.userId,
source: "user",
},
where: { id: existing.id, userId: actor.userId, source: "user" },
});
if (deleted.count !== 1) throw new IsolationError();
return { ok: true };

View File

@ -20,8 +20,8 @@ function setup() {
secret: { findMany: vi.fn(async () => []) },
bot: { findMany: vi.fn(async () => []) },
spaceVoicePreference: {
findFirst: vi.fn(async () => ({ credential: { provider: "openai" }, voiceId: "alloy" })),
findMany: vi.fn(async () => [{ credential: { provider: "openai" } }]),
findFirst: vi.fn(async () => ({ credential: { provider: "kokoro" }, voiceId: "af_heart" })),
findMany: vi.fn(async () => [{ credential: { provider: "kokoro" } }]),
},
spaceMemoryConfig: {
findUnique: vi.fn(async () => null),
@ -29,6 +29,7 @@ function setup() {
},
spaceMember: { findUnique: vi.fn(async () => ({ role: "owner" })) },
deploymentSettings: { findUnique: vi.fn(async () => null) },
integrationProviderConfig: { findUnique: vi.fn(async () => null) },
aiDataConsent: { findMany: vi.fn(async () => []), upsert },
$transaction: vi.fn(async (queries) => Promise.all(queries)),
},
@ -103,12 +104,12 @@ describe("consent grants", () => {
it("discloses only the selected voice provider before a voice action", async () => {
const { deps, actor } = setup();
vi.mocked(deps.prisma.spaceVoicePreference.findMany).mockResolvedValue([
{ credential: { provider: "openai" } },
{ credential: { provider: "elevenlabs" } },
{ credential: { provider: "kokoro" } },
{ credential: { provider: "scripted" } },
] as never);
const status = await aiConsentStatus(deps, actor, { uses: ["voice"] });
expect(status.recipients).toHaveLength(1);
expect(status.recipients[0]?.name).toBe("OpenAI");
expect(status.recipients[0]?.name).toBe("Local voice");
expect(deps.prisma.spaceVoicePreference.findMany).not.toHaveBeenCalled();
});
it("discloses memory provider details to space owners", async () => {
@ -134,6 +135,54 @@ describe("consent grants", () => {
expect(JSON.stringify(status.recipients)).not.toContain("supermemory");
expect(deps.prisma.spaceMemoryConfig.findUnique).not.toHaveBeenCalled();
});
it("discloses the deployment's media servers before model use, keyed by server", async () => {
const { deps, actor } = setup();
const settings = {
comfyui: { baseUrl: "http://127.0.0.1:8188", apiKey: "comfy-key", imageModel: "sdxl" },
transcription: { baseUrl: "http://127.0.0.1:8080/v1", model: "whisper-1" },
};
vi.mocked(deps.prisma.integrationProviderConfig.findUnique).mockResolvedValue({
id: "media",
ciphertext: "fake-media-ciphertext",
} as never);
deps.secrets = {
load: vi.fn(() => JSON.stringify(settings)),
} as unknown as RouterDeps["secrets"];
const status = await aiConsentStatus(deps, actor, { botId: "bot", uses: ["model"] });
expect(status.recipients.map((recipient) => recipient.name)).toEqual([
"ComfyUI (http://127.0.0.1:8188)",
"Transcription (http://127.0.0.1:8080)",
]);
expect(status.recipients[0]?.detail).toBe("Model: sdxl.");
expect(JSON.stringify(status)).not.toContain("comfy-key");
// The local voice cannot transcribe, so dictation also goes to the transcription server.
expect(
(await aiConsentStatus(deps, actor, { uses: ["voice"] })).recipients.map(
(recipient) => recipient.name,
),
).toEqual(["Local voice", "Transcription (http://127.0.0.1:8080)"]);
});
it("discloses the transcription server for dictation when the voice provider cannot transcribe", async () => {
const { deps, actor } = setup();
vi.mocked(deps.prisma.integrationProviderConfig.findUnique).mockResolvedValue({
id: "media",
ciphertext: "fake-media-ciphertext-voice",
} as never);
deps.secrets = {
load: vi.fn(() =>
JSON.stringify({ comfyui: null, transcription: { baseUrl: "http://127.0.0.1:8080/v1" } }),
),
} as unknown as RouterDeps["secrets"];
vi.mocked(deps.prisma.spaceVoicePreference.findFirst).mockResolvedValue({
credential: { provider: "kokoro" },
voiceId: "af_heart",
} as never);
const status = await aiConsentStatus(deps, actor, { uses: ["voice"] });
expect(status.recipients.map((recipient) => [recipient.name, recipient.use])).toEqual([
["Local voice", "voice"],
["Transcription (http://127.0.0.1:8080)", "voice"],
]);
});
it("returns an operator policy without requiring a hosted provider", async () => {
const { deps, actor } = setup();
deps.env.privacyPolicyUrl = "https://example.com/privacy";

View File

@ -3,9 +3,13 @@ import { ORPCError } from "@orpc/server";
import {
aiRecipient,
cloudAgentsEnabled,
loadMediaSettings,
MEDIA_COMFYUI_PROVIDER_ID,
MEDIA_TRANSCRIPTION_PROVIDER_ID,
parseModelSecret,
selectConfiguredModel,
toStringRecord,
voiceCatalogEntry,
} from "@rakazo/adapters";
import type { Actor, AiConsentQuery, AiConsentStatus, AiRecipient } from "@rakazo/contracts";
import { AI_DATA_DISCLOSURES, AI_DISCLOSURE_VERSION, AI_PRIVACY_URL } from "@rakazo/contracts";
@ -183,6 +187,21 @@ export async function aiConsentStatus(
}
for (const voice of voices)
add(aiRecipient({ provider: voice.credential.provider, use: "voice" }));
if (uses.includes("voice")) {
// Dictation goes to the owner's transcription server when the voice provider cannot transcribe.
const transcription = (await loadMediaSettings(deps)).transcription;
const credential = transcription ? await findDefaultVoiceCredential(deps.prisma, actor) : null;
if (transcription && !(credential && voiceCatalogEntry(credential.provider)?.transcribe)) {
add(
aiRecipient({
provider: MEDIA_TRANSCRIPTION_PROVIDER_ID,
use: "voice",
baseUrl: transcription.baseUrl,
modelId: transcription.model,
}),
);
}
}
if (includesMemory) {
if (memoryOwner) {
const memory = await deps.prisma.spaceMemoryConfig.findUnique({
@ -210,6 +229,30 @@ export async function aiConsentStatus(
) {
add(aiRecipient({ provider: deps.cloudAgent.provider.describe().id, use: "model" }));
}
if (uses.includes("model")) {
// Media tools and card actions send prompts, images, video and audio to the owner's servers.
const media = await loadMediaSettings(deps);
if (media.comfyui) {
add(
aiRecipient({
provider: MEDIA_COMFYUI_PROVIDER_ID,
use: "model",
baseUrl: media.comfyui.baseUrl,
modelId: media.comfyui.imageModel,
}),
);
}
if (media.transcription) {
add(
aiRecipient({
provider: MEDIA_TRANSCRIPTION_PROVIDER_ID,
use: "model",
baseUrl: media.transcription.baseUrl,
modelId: media.transcription.model,
}),
);
}
}
return {
scope: createHash("sha256")
.update(JSON.stringify([actor.userId, actor.spaceId]))

View File

@ -1,6 +1,5 @@
import { randomUUID } from "node:crypto";
import { rm } from "node:fs/promises";
import { ORPCError, onError } from "@orpc/server";
import { ORPCError, onError, ValidationError } from "@orpc/server";
import { RPCHandler } from "@orpc/server/fetch";
import type {
AgentRuntime,
@ -15,6 +14,7 @@ import type {
ComposioProvider,
ConnectorRegistry,
DestinationEmulator,
MediaOperationRegistration,
RemoteConnectorDependencies,
} from "@rakazo/adapters";
import {
@ -25,28 +25,34 @@ import {
createCloudAgentConnection,
createConnectorStack,
createJobReconciler,
createMediaOperations,
createMediaRuntime,
createMessagingContextLoader,
createMessagingTeamChatSender,
createPostgresReconciliationLeadership,
createProfileConsolidationScheduler,
createRunExecutor,
createRunSandbox,
createRunSecretWriter,
createWebProvider,
destroyBot,
destroySpaceSandboxes,
EmailEmulator,
EncryptedSecretStore,
ExpoPushProvider,
ExpoPushSender,
GraphileJobPublisher,
GraphileJobWorkerHost,
InMemoryJobQueue,
InMemoryRealtimeFanout,
InstalledConnectorProvider,
IntegrationProviderSettings,
importLegacyPushTokens,
isComposioEnabled,
isMessagingSurfaceEnabled,
isPipedreamEnabled,
LocalAgentHomeStore,
LocalArtifactStore,
loadMediaSettings,
McpConnector,
McpOAuthBroker,
messagingPlatformsFromEnv,
@ -54,9 +60,9 @@ import {
PiOAuthLogins,
PipedreamConnector,
PostgresRealtimeFanout,
PushNotificationProvider,
pipedreamConfigFromEnv,
piSessionsRoot,
pushTokenPath,
reconcileCloudAgents,
reconcileComputerUpdates,
reconcileDelegatedTasks,
@ -65,6 +71,9 @@ import {
SmtpEmailProvider,
SpaceMemoryProviderResolver,
toTeamChatInbound,
VapidKeys,
vapidEnvFromProcess,
WebPushSender,
} from "@rakazo/adapters";
import { blockedAuthPaths, createAuth } from "@rakazo/auth";
import { signupPolicyFromEnv } from "@rakazo/core";
@ -76,6 +85,7 @@ import {
parsePositiveInteger,
provisionMessagingIdentity,
requireMembership,
spaceIdsForSandboxTeardownAfterAccountDelete,
} from "@rakazo/db";
import type { Logger } from "@rakazo/logging";
import {
@ -89,6 +99,7 @@ import { requestLogging } from "@rakazo/logging/hono";
import { MarkdownMemoryStore } from "@rakazo/memory";
import { Hono } from "hono";
import { cors } from "hono/cors";
import { mountArtifactHttpRoutes } from "./artifact-http.js";
import type { AppEnv } from "./env.js";
import { loadEnv } from "./env.js";
import { mountLocalSettings } from "./local-settings.js";
@ -109,8 +120,8 @@ import {
settleWithTimeout,
TEAM_CHAT_STARTUP_SHUTDOWN_MS,
} from "./team-chat-startup.js";
import { mountTradingBotStatus } from "./trading-bot-proxy.js";
import { mountVoiceHttpRoutes } from "./voice.js";
import { isLoopbackHost, isPrivateOrLocalHost, requestWebOrigin } from "./web-origin.js";
import { mountWebhookHttpRoutes } from "./webhook.js";
export interface AppHandles {
@ -139,6 +150,8 @@ export async function createApp(
email?: TransactionalEmailProvider;
remoteConnectors?: RemoteConnectorDependencies;
logger?: Logger;
/** Test seam: media operations instead of the configured ones. */
mediaOperations?: MediaOperationRegistration[];
} = {},
): Promise<AppHandles> {
const {
@ -151,6 +164,7 @@ export async function createApp(
email: emailOverride,
remoteConnectors,
logger: loggerOverride,
mediaOperations,
...envOverrides
} = overrides;
const env = { ...loadEnv(process.env), ...envOverrides };
@ -309,7 +323,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,
@ -340,32 +361,46 @@ export async function createApp(
"https://172.*:*",
"http://*.local:*",
"https://*.local:*",
...(process.env.EXTRA_TRUSTED_ORIGINS?.split(",").map((s) => s.trim()) ?? []),
...(env.extraTrustedOrigins ?? []),
],
beforeDeleteUser: async (userId) => {
const bots = await prisma.bot.findMany({
where: { userId },
select: { id: true, userId: true, spaceId: true, name: true, archivedAt: true },
});
const lifecycleDeps = {
prisma,
sandbox,
home,
jobs,
artifacts,
dataDir: env.dataDir,
};
const deleteContext = {
operationId: `account-delete:${userId}`,
traceId: `account-delete:${userId}`,
userId,
signal: new AbortController().signal,
};
await Promise.all(
bots.map((bot) =>
destroyBot(
{ prisma, sandbox, home, jobs, artifacts, dataDir: env.dataDir },
lifecycleDeps,
bot,
{
operationId: `account-delete:${userId}`,
traceId: `account-delete:${userId}`,
spaceId: bot.spaceId,
userId,
botId: bot.id,
signal: new AbortController().signal,
},
{ ...deleteContext, spaceId: bot.spaceId, botId: bot.id },
{ deleteMemories: true },
),
),
);
const candidateSpaceIds = [...new Set(bots.map((bot) => bot.spaceId))];
const spaceIds = await spaceIdsForSandboxTeardownAfterAccountDelete(prisma, {
userId,
spaceIds: candidateSpaceIds,
});
for (const spaceId of spaceIds) {
await destroySpaceSandboxes(lifecycleDeps, { spaceId }, { ...deleteContext, spaceId });
}
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.
@ -375,6 +410,15 @@ export async function createApp(
CLOUD_AGENT_SPACE_ID: env.cloudAgentSpaceId,
});
const shutdown = new AbortController();
const media = createMediaRuntime({
prisma,
events,
jobs,
artifacts,
registrations:
mediaOperations ??
createMediaOperations({ loadSettings: () => loadMediaSettings({ prisma, secrets }) }),
});
const executor = createRunExecutor({
prisma,
runtime,
@ -411,6 +455,7 @@ export async function createApp(
messaging: messaging ? createMessagingContextLoader(prisma) : undefined,
web: createWebProvider(),
cloudAgent,
media,
shutdownSignal: shutdown.signal,
});
@ -428,30 +473,37 @@ export async function createApp(
deploymentModelKey: env.deploymentModelKey,
messaging,
cloudAgent,
media,
});
let graphileWorkerHost: GraphileJobWorkerHost | undefined;
if (inMemoryJobs) {
await inMemoryJobs.start(jobHandlers);
} else if (jobPool) {
const graphileWorkerHost = new GraphileJobWorkerHost(jobPool, {
concurrency: 4,
graphileWorkerHost = new GraphileJobWorkerHost(jobPool, {
concurrency: parsePositiveInteger(process.env.GRAPHILE_WORKER_CONCURRENCY, 4),
pollInterval: 500,
});
await graphileWorkerHost.start(jobHandlers);
}
const reconciler = inMemoryJobs
? createJobReconciler({
prisma,
jobs,
reconcileCloudAgents: () => reconcileCloudAgents({ prisma, jobs, cloudAgent }),
reconcileComputerUpdates: () => reconcileComputerUpdates({ prisma, jobs }),
reconcileProfileConsolidation: createProfileConsolidationScheduler({ prisma, jobs }),
reconcileDelegatedTasks: () => reconcileDelegatedTasks({ prisma, jobs, events }),
})
: undefined;
const reconciler =
inMemoryJobs || !prismaOverride
? createJobReconciler({
prisma,
jobs,
events,
leadership: jobPool ? createPostgresReconciliationLeadership(jobPool) : undefined,
reconcileCloudAgents: () => reconcileCloudAgents({ prisma, jobs, cloudAgent }),
reconcileComputerUpdates: () => reconcileComputerUpdates({ prisma, jobs }),
reconcileProfileConsolidation: createProfileConsolidationScheduler({ prisma, jobs }),
reconcileDelegatedTasks: () => reconcileDelegatedTasks({ prisma, jobs, events }),
reconcileMediaJobs: () => media.reconcile(),
})
: undefined;
reconciler?.start();
const router = createRouter({
cloudAgent,
media,
prisma,
events,
auth,
@ -463,6 +515,7 @@ export async function createApp(
secrets,
oauthLogins,
integrationSettings,
vapid,
mcpOAuth,
composio: stack.composio,
connectors: stack.connector,
@ -513,7 +566,7 @@ export async function createApp(
await next();
c.res.headers.set(
"Content-Security-Policy",
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ws:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'",
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; connect-src 'self' ws:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'",
);
c.res.headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
c.res.headers.set("X-Frame-Options", "DENY");
@ -530,13 +583,6 @@ export async function createApp(
resetUrl: email ? new URL("/reset-password", env.webOrigin).href : null,
}),
);
mountTradingBotStatus(app, {
baseUrl: env.tradingBotUrl,
authorize: async (request) => {
const session = await auth.api.getSession({ headers: sessionHeaders(request) });
return Boolean(session?.user);
},
});
if (localEmailEmulator && env.nodeEnv === "development") {
app.get(
"/api/dev/emails",
@ -567,7 +613,12 @@ 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"),
webOrigin: requestWebOrigin(c.req.header("origin"), env.webOrigin, env.extraTrustedOrigins),
},
});
if (matched) return c.newResponse(response.body, response);
await next();
@ -583,6 +634,13 @@ export async function createApp(
if (actor) enrichLogContext({ "user.id": actor.userId, "space.id": actor.spaceId });
return actor;
});
mountArtifactHttpRoutes(app, {
prisma,
artifacts,
sessionUserId: async (request) =>
(await auth.api.getSession({ headers: sessionHeaders(request) }))?.user?.id ?? null,
trustedOrigin: (origin) => isTrustedOrigin(origin, env),
});
mountWebhookHttpRoutes(app, { prisma, secrets, events, jobs });
// Shared with stop so a shutdown during retry delays does not restart polling.
let messagingStopped = false;
@ -953,6 +1011,7 @@ export async function createApp(
await teamChatBridge?.stop();
await email?.drain?.();
await reconciler?.stop();
await graphileWorkerHost?.stop();
await jobs.close();
await realtime.close();
await connector.stop();
@ -968,6 +1027,7 @@ export async function createApp(
function isTrustedOrigin(origin: string, env: AppEnv) {
if (!origin) return true;
if (origin === env.webOrigin || origin === env.apiUrl || origin === env.authUrl) return true;
if (env.extraTrustedOrigins?.includes(origin)) return true;
if (origin.startsWith("rakazo://") || origin.startsWith("exp://")) return true;
try {
const host = new URL(origin).hostname;
@ -977,20 +1037,6 @@ function isTrustedOrigin(origin: string, env: AppEnv) {
}
}
function isPrivateOrLocalHost(host: string): boolean {
if (isLoopbackHost(host)) return true;
if (host.endsWith(".local")) return true;
if (/^192\.168\.\d{1,3}\.\d{1,3}$/.test(host)) return true;
if (/^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host)) return true;
if (/^172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}$/.test(host)) return true;
if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.\d{1,3}\.\d{1,3}$/.test(host)) return true;
return false;
}
function isLoopbackHost(host: string): boolean {
return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
}
function sessionHeaders(request: Request) {
const headers = new Headers(request.headers);
const authz = headers.get("authorization");
@ -1009,7 +1055,18 @@ function sessionHeaders(request: Request) {
* "fetch failed" and keep the host and errno one level down.
*/
export function logUnexpectedRpcError(error: unknown, path: readonly string[]): void {
if (error instanceof ORPCError) return;
const where = `rpc ${path.join("/")} failed`;
if (error instanceof ORPCError) {
// A handler result that breaks its output contract is a server bug, not a chosen error.
if (error.code !== "INTERNAL_SERVER_ERROR" || !(error.cause instanceof ValidationError)) return;
const issues = error.cause.issues.map((issue) => ({
path: (issue.path ?? [])
.map((segment) => (typeof segment === "object" ? segment.key : segment))
.join("."),
problem: issue.message,
}));
getLogger().error(where, error, { issues });
return;
}
getLogger().error(where, error);
}

View File

@ -0,0 +1,275 @@
import { mkdtemp, readdir, rm } from "node:fs/promises";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import path from "node:path";
import type { ArtifactStore } from "@rakazo/adapter-kit";
import { LocalArtifactStore } from "@rakazo/adapters";
import type { PrismaClient } from "@rakazo/db";
import { Hono } from "hono";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
API_SERVER_OPTIONS,
contentDisposition,
mountArtifactHttpRoutes,
parseByteRange,
stallGuardedChunks,
UploadStalledError,
} from "./artifact-http.js";
describe("parseByteRange", () => {
it("ignores an absent header or another unit", () => {
expect(parseByteRange(undefined, 100)).toBeNull();
expect(parseByteRange("", 100)).toBeNull();
expect(parseByteRange("items=0-1", 100)).toBeNull();
});
it("parses closed, open and suffix ranges and clamps the end to the size", () => {
expect(parseByteRange("bytes=0-9", 100)).toEqual({ start: 0, end: 9 });
expect(parseByteRange("bytes=90-", 100)).toEqual({ start: 90, end: 99 });
expect(parseByteRange("bytes=-10", 100)).toEqual({ start: 90, end: 99 });
expect(parseByteRange("bytes=-500", 100)).toEqual({ start: 0, end: 99 });
expect(parseByteRange("bytes=50-1000", 100)).toEqual({ start: 50, end: 99 });
expect(parseByteRange("Bytes= 0-0 ", 100)).toEqual({ start: 0, end: 0 });
});
it("rejects multiple, inverted, out-of-file and malformed ranges", () => {
for (const header of [
"bytes=0-1,5-6",
"bytes=5-4",
"bytes=100-",
"bytes=100-200",
"bytes=-0",
"bytes=-",
"bytes=a-b",
"bytes=1.5-2",
"bytes= -1-2",
"bytes=0x10-20",
`bytes=${"9".repeat(16)}-`,
]) {
expect(parseByteRange(header, 100), header).toBe("unsatisfiable");
}
expect(parseByteRange("bytes=0-0", 0)).toBe("unsatisfiable");
});
});
describe("contentDisposition", () => {
it("keeps plain names and marks media inline", () => {
expect(contentDisposition("clip.mp4", true)).toBe(
`inline; filename="clip.mp4"; filename*=UTF-8''clip.mp4`,
);
});
it("neutralizes quotes, separators, control characters and header injection", () => {
const header = contentDisposition('../evil"\r\nSet-Cookie: x=1;.txt', false);
expect(header.startsWith("attachment; ")).toBe(true);
expect(header).not.toMatch(/[\r\n]/);
expect(header).toContain(`filename=".._evil___Set-Cookie: x=1_.txt"`);
expect(header).not.toContain("/");
});
it("encodes non-ASCII names with an ASCII fallback", () => {
expect(contentDisposition("Grüße (1).png", true)).toBe(
`inline; filename="Gr__e (1).png"; filename*=UTF-8''Gr%C3%BC%C3%9Fe%20%281%29.png`,
);
expect(contentDisposition(" ", false)).toBe(
`attachment; filename="download"; filename*=UTF-8''download`,
);
});
});
describe("artifact HTTP routes", () => {
function app(sessionUserId: () => Promise<string | null>) {
const prisma = {
artifact: { findUnique: vi.fn(), create: vi.fn() },
spaceMember: { findFirst: vi.fn() },
};
const artifacts = { putStream: vi.fn(), stat: vi.fn(), createReadStream: vi.fn() };
const hono = new Hono();
mountArtifactHttpRoutes(hono, {
prisma: prisma as unknown as PrismaClient,
artifacts: artifacts as unknown as ArtifactStore,
sessionUserId,
trustedOrigin: (origin) => origin === "http://127.0.0.1:5173",
});
return { hono, prisma, artifacts };
}
it("rejects signed-out reads and uploads before touching storage", async () => {
const { hono, prisma, artifacts } = app(async () => null);
const read = await hono.request("/api/artifacts/art-1");
const upload = await hono.request("/api/artifacts?botId=bot-1&name=a.mp4", {
method: "POST",
headers: { "content-type": "video/mp4" },
body: "data",
});
expect(read.status).toBe(401);
expect(upload.status).toBe(401);
expect(prisma.artifact.findUnique).not.toHaveBeenCalled();
expect(artifacts.putStream).not.toHaveBeenCalled();
});
it("refuses uploads from untrusted origins before reading the session", async () => {
const sessionUserId = vi.fn(async () => "user-1");
const { hono, artifacts } = app(sessionUserId);
const response = await hono.request("/api/artifacts?botId=bot-1&name=a.txt", {
method: "POST",
headers: { "content-type": "text/plain", origin: "https://evil.example" },
body: "data",
});
expect(response.status).toBe(403);
expect(sessionUserId).not.toHaveBeenCalled();
expect(artifacts.putStream).not.toHaveBeenCalled();
});
it("answers ids that are not artifact ids with 404 without a lookup", async () => {
const { hono, prisma } = app(async () => "user-1");
const response = await hono.request(`/api/artifacts/${encodeURIComponent("../etc/passwd")}`);
expect(response.status).toBe(404);
expect(prisma.artifact.findUnique).not.toHaveBeenCalled();
});
it("rejects declared oversized and disallowed uploads without reading the body", async () => {
const { hono, prisma, artifacts } = app(async () => "user-1");
prisma.spaceMember.findFirst.mockResolvedValue({
userId: "user-1",
spaceId: "space-1",
member: { user: { email: "user@example.test" } },
});
(prisma as unknown as { deploymentSettings: unknown }).deploymentSettings = {
findUnique: vi.fn(async () => null),
};
const cancel = vi.fn(() => new Promise<void>(() => undefined));
const oversized = await hono.request(
new Request("http://localhost/api/artifacts?botId=bot-1&name=a.mp4", {
method: "POST",
headers: { "content-type": "video/mp4", "content-length": String(501 * 1024 * 1024) },
body: new ReadableStream({ cancel }),
duplex: "half",
} as RequestInit & { duplex: "half" }),
);
expect(oversized.status).toBe(413);
expect(cancel).toHaveBeenCalled();
const html = await hono.request("/api/artifacts?botId=bot-1&name=a.html", {
method: "POST",
headers: { "content-type": "text/html" },
body: "<script></script>",
});
expect(html.status).toBe(415);
const noTarget = await hono.request("/api/artifacts?name=a.mp4", {
method: "POST",
headers: { "content-type": "video/mp4" },
body: "data",
});
expect(noTarget.status).toBe(400);
expect(artifacts.putStream).not.toHaveBeenCalled();
});
});
describe("upload timeouts", () => {
const dirs: string[] = [];
afterEach(async () => {
await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
it("gives the server time for large uploads but keeps the header timeout", () => {
const server = createServer(API_SERVER_OPTIONS);
expect(server.requestTimeout).toBe(2 * 60 * 60 * 1000);
expect(server.headersTimeout).toBe(createServer().headersTimeout);
});
/** A body that sends `chunks` at `gapMs` intervals, then stays open forever if `hang`. */
function drip(chunks: string[], gapMs: number, hang: boolean) {
const cancel = vi.fn(() => new Promise<void>(() => undefined));
let sent = 0;
const body = new ReadableStream<Uint8Array>({
async pull(controller) {
if (sent === chunks.length) {
if (!hang) controller.close();
else await new Promise(() => undefined);
return;
}
if (sent > 0) await new Promise((resolve) => setTimeout(resolve, gapMs));
controller.enqueue(new TextEncoder().encode(chunks[sent++]));
},
cancel,
});
return { body, cancel };
}
it("throws once the body stops sending and cancels it without waiting", async () => {
const { body, cancel } = drip(["ab", "cd"], 5, true);
const seen: string[] = [];
await expect(
(async () => {
for await (const chunk of stallGuardedChunks(body, 50)) {
seen.push(new TextDecoder().decode(chunk));
}
})(),
).rejects.toBeInstanceOf(UploadStalledError);
expect(seen).toEqual(["ab", "cd"]);
expect(cancel).toHaveBeenCalled();
});
async function postDrip(stallMs: number, body: ReadableStream<Uint8Array>) {
const root = await mkdtemp(path.join(tmpdir(), "rakazo-artifact-upload-"));
dirs.push(root);
const create = vi.fn(async ({ data }: { data: Record<string, unknown> }) => ({
id: "art-1",
createdAt: new Date(),
...data,
}));
const prisma = {
artifact: { create },
spaceMember: {
findFirst: vi.fn(async () => ({
userId: "user-1",
spaceId: "space-1",
member: { user: { email: "user@example.test" } },
})),
},
deploymentSettings: { findUnique: vi.fn(async () => null) },
bot: { findFirst: vi.fn(async () => ({ id: "bot-1" })) },
};
const hono = new Hono();
mountArtifactHttpRoutes(hono, {
prisma: prisma as unknown as PrismaClient,
artifacts: new LocalArtifactStore(root),
sessionUserId: async () => "user-1",
trustedOrigin: () => true,
uploadStallTimeoutMs: stallMs,
});
const response = await hono.request(
new Request("http://localhost/api/artifacts?botId=bot-1&name=clip.mp4", {
method: "POST",
headers: { "content-type": "video/mp4" },
body,
duplex: "half",
} as RequestInit & { duplex: "half" }),
);
// The store creates the space directory before it reads the body, so it must exist here.
const stored = await readdir(path.join(root, "artifacts", "space-1"));
return { response, create, stored };
}
it("aborts a stalled upload with 408 and deletes the partial file", async () => {
const { body, cancel } = drip(["partial video bytes"], 0, true);
const { response, create, stored } = await postDrip(50, body);
expect(response.status).toBe(408);
expect(await response.json()).toEqual({ error: "Upload stalled." });
expect(cancel).toHaveBeenCalled();
expect(create).not.toHaveBeenCalled();
expect(stored).toEqual([]);
});
it("keeps a slow but steady upload going past the stall timeout", async () => {
const { body } = drip(["a", "b", "c", "d", "e", "f"], 30, false);
const { response, create, stored } = await postDrip(100, body);
expect(response.status).toBe(201);
expect(create).toHaveBeenCalledWith({
data: expect.objectContaining({ size: 6, mimeType: "video/mp4", botId: "bot-1" }),
});
expect(stored).toHaveLength(1);
});
});

View File

@ -0,0 +1,336 @@
import type { AdapterContext, ArtifactByteRange, ArtifactStore } from "@rakazo/adapter-kit";
import { ArtifactTooLargeError } from "@rakazo/adapter-kit";
import { storeArtifactStream } from "@rakazo/adapters";
import type { Actor } from "@rakazo/contracts";
import { artifactMaxBytes, attachmentKindOf, isAllowedAttachmentMimeType } from "@rakazo/contracts";
import type { PrismaClient } from "@rakazo/db";
import { createGroupRepos, createRepos, IsolationError, requireMembership } from "@rakazo/db";
import type { Context, Hono } from "hono";
import { cancelBody } from "./http-body.js";
export const ARTIFACT_HTTP_PATH = "/api/artifacts";
/**
* Node's default 5-minute requestTimeout cuts off a large upload on a slow uplink; 2 hours fits a
* 5 GiB repository ZIP at about 6 Mbit/s. headersTimeout keeps its default, and the per-upload
* stall timeout stops a slow-drip client from holding a connection that long.
*/
export const API_SERVER_OPTIONS = { requestTimeout: 2 * 60 * 60 * 1000 };
/** An upload that sends no bytes for this long is aborted and its partial file deleted. */
export const ARTIFACT_UPLOAD_STALL_TIMEOUT_MS = 60_000;
export class UploadStalledError extends Error {
constructor() {
super("Upload stalled");
this.name = "UploadStalledError";
}
}
/**
* Yields the body's chunks and throws UploadStalledError when the next chunk takes longer than
* stallMs. Only time spent waiting for the client counts, not time spent writing to disk.
*/
export async function* stallGuardedChunks(
body: ReadableStream<Uint8Array>,
stallMs: number,
): AsyncGenerator<Uint8Array> {
const reader = body.getReader();
try {
for (;;) {
let timer: ReturnType<typeof setTimeout> | undefined;
const stalled = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new UploadStalledError()), stallMs);
});
try {
const { done, value } = await Promise.race([reader.read(), stalled]);
if (done) return;
yield value;
} finally {
clearTimeout(timer);
}
}
} finally {
// Never wait on the client here: a stalled or hostile body may never confirm the cancel.
void reader.cancel().catch(() => undefined);
reader.releaseLock();
}
}
export type ArtifactHttpDeps = {
prisma: PrismaClient;
artifacts: ArtifactStore;
/** The signed-in user (cookie or bearer session), or null. */
sessionUserId: (request: Request) => Promise<string | null>;
/** Same rule as CORS. Raw uploads can be CORS "simple" requests, so the origin is checked. */
trustedOrigin: (origin: string) => boolean;
/** Overrides ARTIFACT_UPLOAD_STALL_TIMEOUT_MS (tests). */
uploadStallTimeoutMs?: number;
};
/** Generated ids only; anything else is not an artifact. */
const ARTIFACT_ID = /^[A-Za-z0-9_-]{1,64}$/;
const MAX_RANGE_DIGITS = 15;
/**
* Parses a single `bytes=` range against the stored size. Returns null when the header should
* be ignored (absent or another unit) and "unsatisfiable" for multiple ranges or offsets
* outside the file, which the caller answers with 416.
*/
export function parseByteRange(
header: string | undefined,
size: number,
): ArtifactByteRange | "unsatisfiable" | null {
if (!header) return null;
const value = header.trim();
if (!value.toLowerCase().startsWith("bytes=")) return null;
const spec = value.slice("bytes=".length).trim();
const match = new RegExp(`^(\\d{0,${MAX_RANGE_DIGITS}})-(\\d{0,${MAX_RANGE_DIGITS}})$`).exec(
spec,
);
if (!match || size <= 0) return "unsatisfiable";
const [, first = "", last = ""] = match;
if (first === "" && last === "") return "unsatisfiable";
if (first === "") {
const suffix = Number(last);
if (suffix <= 0) return "unsatisfiable";
return { start: Math.max(0, size - suffix), end: size - 1 };
}
const start = Number(first);
const end = last === "" ? size - 1 : Number(last);
if (start >= size || end < start) return "unsatisfiable";
return { start, end: Math.min(end, size - 1) };
}
/** `inline` or `attachment` with an ASCII fallback and the exact UTF-8 name (RFC 6266). */
export function contentDisposition(name: string, inline: boolean): string {
const cleaned =
Array.from(name, (char) => {
const code = char.codePointAt(0) ?? 0;
return code < 0x20 || code === 0x7f || char === "/" || char === "\\" ? "_" : char;
})
.join("")
.trim() || "download";
const ascii = cleaned.replace(/[^ -~]/g, "_").replace(/["%;]/g, "_");
const encoded = encodeURIComponent(cleaned).replace(
/['()*]/g,
(char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
);
return `${inline ? "inline" : "attachment"}; filename="${ascii}"; filename*=UTF-8''${encoded}`;
}
function adapterContext(actor: Actor, operationId: string, signal: AbortSignal): AdapterContext {
return {
operationId,
traceId: operationId,
spaceId: actor.spaceId,
userId: actor.userId,
signal,
};
}
function notFound(c: Context) {
return c.json({ error: "Not found" }, 404);
}
function artifactDto(row: {
id: string;
botId: string | null;
groupId: string | null;
runId: string | null;
name: string;
mimeType: string;
size: number;
createdAt: Date;
}) {
return {
id: row.id,
botId: row.botId,
groupId: row.groupId,
runId: row.runId,
name: row.name,
mimeType: row.mimeType,
size: row.size,
createdAt: row.createdAt.toISOString(),
};
}
/**
* Streams artifacts over HTTP so media can play with seeking and large files never travel as
* base64 RPC payloads. Authorization matches `artifacts.get` and `artifacts.create`.
*/
export function mountArtifactHttpRoutes(app: Hono, deps: ArtifactHttpDeps) {
const repos = createRepos(deps.prisma);
const groupRepos = createGroupRepos(deps.prisma);
/** Same rule as artifacts.get: the owner's artifact of an active bot thread or group. */
async function readableArtifact(
userId: string,
requestedSpaceId: string | undefined,
id: string,
) {
if (!ARTIFACT_ID.test(id)) return null;
const row = await deps.prisma.artifact.findUnique({ where: { id } });
if (!row) return null;
// <video> and <img> cannot send the space header, so the artifact's space is the default;
// membership is still required and an explicit header must name the same space.
const actor = await requireMembership(
deps.prisma,
userId,
requestedSpaceId ?? row.spaceId,
).catch(() => null);
if (!actor || actor.spaceId !== row.spaceId || actor.userId !== row.userId) return null;
try {
if (row.groupId) await groupRepos.getGroupTarget(actor, row.groupId);
else if (row.botId) await repos.getBot(actor, row.botId);
else return null;
} catch (error) {
if (error instanceof IsolationError) return null;
throw error;
}
return { row, actor };
}
app.get(`${ARTIFACT_HTTP_PATH}/:id`, async (c) => {
const userId = await deps.sessionUserId(c.req.raw);
if (!userId) return c.json({ error: "Unauthorized" }, 401);
const found = await readableArtifact(
userId,
c.req.header("x-rakazo-space-id"),
c.req.param("id"),
);
if (!found) return notFound(c);
const { row, actor } = found;
const context = adapterContext(actor, `artifact-http-get:${row.id}`, c.req.raw.signal);
let size: number;
try {
({ size } = await deps.artifacts.stat(row.storageKey, context));
} catch {
return notFound(c);
}
const mimeType = isAllowedAttachmentMimeType(row.mimeType)
? row.mimeType
: "application/octet-stream";
const inline = attachmentKindOf(mimeType) !== "file" && c.req.query("download") === undefined;
const etag = /^[A-Za-z0-9]{16,128}$/.test(row.hash) ? `"${row.hash}"` : undefined;
const headers = new Headers({
"accept-ranges": "bytes",
// Revalidate every time so a revoked thread stops serving from the browser cache.
"cache-control": "private, no-cache",
"content-disposition": contentDisposition(row.name, inline),
"content-type": mimeType,
"cross-origin-resource-policy": "same-origin",
"x-content-type-options": "nosniff",
});
if (etag) headers.set("etag", etag);
if (etag && c.req.header("if-none-match") === etag) {
return new Response(null, { status: 304, headers });
}
const ifRange = c.req.header("if-range");
const range =
ifRange === undefined || ifRange === etag
? parseByteRange(c.req.header("range"), size)
: null;
if (range === "unsatisfiable") {
headers.set("content-range", `bytes */${size}`);
return new Response(null, { status: 416, headers });
}
const status = range ? 206 : 200;
headers.set("content-length", String(range ? range.end - range.start + 1 : size));
if (range) headers.set("content-range", `bytes ${range.start}-${range.end}/${size}`);
// Hono answers HEAD from this handler and drops the body; never open a stream for it.
if (c.req.method === "HEAD") return new Response(null, { status, headers });
let body: ReadableStream<Uint8Array>;
try {
body = await deps.artifacts.createReadStream(row.storageKey, context, range ?? undefined);
} catch {
return notFound(c);
}
return new Response(body, { status, headers });
});
app.post(ARTIFACT_HTTP_PATH, async (c) => {
const request = c.req.raw;
const reject = (status: 400 | 401 | 403 | 404 | 408 | 413 | 415, error: string) => {
cancelBody(request.body);
return c.json({ error }, status);
};
const origin = c.req.header("origin");
if (origin && !deps.trustedOrigin(origin)) return reject(403, "Forbidden");
const userId = await deps.sessionUserId(request);
if (!userId) return reject(401, "Unauthorized");
const actor = await requireMembership(
deps.prisma,
userId,
c.req.header("x-rakazo-space-id"),
).catch(() => null);
if (!actor) return reject(401, "Unauthorized");
const botId = c.req.query("botId") || undefined;
const groupId = c.req.query("groupId") || undefined;
const name = c.req.query("name") ?? "";
if (Boolean(botId) === Boolean(groupId)) {
return reject(400, "Provide exactly one of botId or groupId");
}
if (name.length < 1 || name.length > 255) return reject(400, "Invalid file name");
const mimeType = (c.req.header("content-type") ?? "").split(";", 1)[0]!.trim().toLowerCase();
if (!isAllowedAttachmentMimeType(mimeType)) {
return reject(415, "Unsupported attachment type");
}
const maxBytes = artifactMaxBytes(mimeType);
const declared = c.req.header("content-length");
if (declared !== undefined) {
if (!/^\d{1,16}$/.test(declared)) return reject(400, "Invalid content length");
if (Number(declared) > maxBytes) return reject(413, "File is too large.");
if (Number(declared) === 0) return reject(400, "File is empty.");
}
if (!request.body) return reject(400, "File is empty.");
// Same targets as artifacts.create: an own active bot, or an active group of own bots.
let ownerBotId: string | undefined;
try {
ownerBotId = botId
? (await repos.getBot(actor, botId)).id
: (await groupRepos.getGroupTarget(actor, groupId!)).members[0]?.bot.id;
} catch (error) {
if (!(error instanceof IsolationError)) throw error;
}
if (!ownerBotId) return reject(404, "Not found");
let row: Awaited<ReturnType<typeof storeArtifactStream>>;
try {
row = await storeArtifactStream(deps, {
spaceId: actor.spaceId,
userId: actor.userId,
botId: ownerBotId,
groupId: groupId ?? null,
runId: null,
name,
mimeType,
stream: stallGuardedChunks(
request.body,
deps.uploadStallTimeoutMs ?? ARTIFACT_UPLOAD_STALL_TIMEOUT_MS,
),
maxBytes,
signal: request.signal,
});
} catch (error) {
if (error instanceof ArtifactTooLargeError) return reject(413, "File is too large.");
if (error instanceof UploadStalledError) return reject(408, "Upload stalled.");
throw error;
}
if (row.size === 0) {
await deps.prisma.artifact.delete({ where: { id: row.id } });
await deps.artifacts
.remove(
row.storageKey,
adapterContext(actor, `artifact-http-post:${row.id}`, request.signal),
)
.catch(() => undefined);
return c.json({ error: "File is empty." }, 400);
}
return c.json(artifactDto(row), 201);
});
}

View File

@ -11,6 +11,12 @@ import {
} from "@rakazo/core";
import { IsolationError, type PrismaClient } from "@rakazo/db";
/**
* Base64 reads load the whole file into memory; larger media is served by
* GET /api/artifacts/:id instead.
*/
export const ARTIFACT_INLINE_READ_MAX_BYTES = 25 * 1024 * 1024;
function adapterContext(actor: Actor, botId: string, operationId: string) {
return {
operationId,
@ -131,6 +137,9 @@ async function readArtifact(
},
contextBotId: string,
) {
if (row.size > ARTIFACT_INLINE_READ_MAX_BYTES) {
throw new AttachmentValidationError("This file is too large to load inline.");
}
const bytes = await artifacts.get(
row.storageKey,
adapterContext(actor, contextBotId, `artifact-get:${row.id}`),

106
apps/api/src/autopilot.ts Normal file
View File

@ -0,0 +1,106 @@
import { ORPCError } from "@orpc/server";
import type { JobPublisher, SandboxProvider } from "@rakazo/adapter-kit";
import {
autopilotView,
endAutopilot,
findOpenAutopilot,
kickAutopilot,
resumeAutopilot,
startAutopilot,
} from "@rakazo/adapters";
import type { Actor, Autopilot, AutopilotRecent } from "@rakazo/contracts";
import { AUTOPILOT_RECENT_MS } from "@rakazo/contracts";
import type { PrismaClient, ThreadEvents } from "@rakazo/db";
import { IsolationError } from "@rakazo/db";
import type { ThreadTarget } from "./thread-target.js";
import { groupDefaultBotId, stopThreadRuns } from "./thread-target.js";
type AutopilotDeps = {
prisma: PrismaClient;
events: ThreadEvents;
jobs: JobPublisher;
sandbox: SandboxProvider;
};
function owner(actor: Actor, target: ThreadTarget) {
const botId = target.kind === "bot" ? target.botId : groupDefaultBotId(target);
if (!botId) throw new IsolationError();
return { spaceId: actor.spaceId, userId: actor.userId, botId, threadId: target.threadId };
}
export async function getThreadAutopilot(
deps: Pick<AutopilotDeps, "prisma">,
actor: Actor,
target: ThreadTarget,
): Promise<Autopilot | null> {
const open = await findOpenAutopilot(deps.prisma, owner(actor, target));
return open ? autopilotView(open) : null;
}
/** Start from the composer: the first run begins at once. */
export async function startThreadAutopilot(
deps: AutopilotDeps,
actor: Actor,
target: ThreadTarget,
input: { goal: string; focus?: string; until?: string },
): Promise<Autopilot> {
const started = await startAutopilot(deps, owner(actor, target), {
goal: input.goal,
focus: input.focus,
until: input.until ? new Date(input.until) : undefined,
});
if ("error" in started) throw new ORPCError("CONFLICT", { message: started.error });
await kickAutopilot(deps, started.autopilot.id);
return autopilotView(started.autopilot);
}
/** Stop at once: end the autopilot first so no run starts another, then stop what runs. */
export async function stopThreadAutopilot(
deps: AutopilotDeps,
actor: Actor,
target: ThreadTarget,
): Promise<{ ok: true }> {
const open = await findOpenAutopilot(deps.prisma, owner(actor, target));
if (open && (await endAutopilot(deps, open.id, { status: "stopped" }))) {
await stopThreadRuns(deps, actor, target);
}
return { ok: true };
}
export async function resumeThreadAutopilot(
deps: AutopilotDeps,
actor: Actor,
target: ThreadTarget,
): Promise<{ ok: true }> {
const open = await findOpenAutopilot(deps.prisma, owner(actor, target));
if (open?.status !== "paused" || !(await resumeAutopilot(deps, open.id))) {
throw new ORPCError("CONFLICT", { message: "This autopilot is not paused." });
}
return { ok: true };
}
/** Today's view: the user's open autopilots in this space and those that ended within a day. */
export async function listRecentAutopilots(
deps: Pick<AutopilotDeps, "prisma">,
actor: Actor,
now = new Date(),
): Promise<AutopilotRecent[]> {
const rows = await deps.prisma.autopilot.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
OR: [
{ status: { in: ["active", "paused"] } },
{ endedAt: { gte: new Date(now.getTime() - AUTOPILOT_RECENT_MS) } },
],
},
include: { thread: { select: { groupId: true } } },
orderBy: { startedAt: "desc" },
take: 10,
});
return rows.map((row) => ({
...autopilotView(row),
spaceId: row.spaceId,
groupId: row.thread.groupId,
}));
}

View File

@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { importableRoutines, relativeImportedFiles, safeImportPath } from "./bot-import.js";
describe("safeImportPath", () => {
it("normalizes separators and dot segments", () => {
expect(safeImportPath("notes\\./result.txt")).toBe("notes/result.txt");
expect(safeImportPath("MEMORY.md")).toBe("MEMORY.md");
});
it("rejects paths that could leave the bot home", () => {
expect(safeImportPath("../secrets")).toBeNull();
expect(safeImportPath("notes/../../x")).toBeNull();
expect(safeImportPath("/etc/passwd")).toBeNull();
expect(safeImportPath("C:\\Windows")).toBeNull();
expect(safeImportPath("./")).toBeNull();
});
});
describe("relativeImportedFiles", () => {
it("drops the old Team Computer workspace prefix", () => {
expect(
relativeImportedFiles([
{ path: "bots/old-id/notes/result.txt", content: "a" },
{ path: "bots/old-id/README.md", content: "b" },
]),
).toEqual([
{ path: "notes/result.txt", content: "a" },
{ path: "README.md", content: "b" },
]);
});
it("keeps Private Computer paths as they are", () => {
const files = [
{ path: "notes/result.txt", content: "a" },
{ path: "bots/other/file.txt", content: "b" },
];
expect(relativeImportedFiles(files)).toEqual(files);
});
it("keeps paths when files span several workspaces", () => {
const files = [
{ path: "bots/a/x.txt", content: "a" },
{ path: "bots/b/y.txt", content: "b" },
];
expect(relativeImportedFiles(files)).toEqual(files);
});
it("refuses the whole set when one path is unsafe", () => {
expect(
relativeImportedFiles([
{ path: "ok.txt", content: "a" },
{ path: "../escape.txt", content: "b" },
]),
).toBeNull();
});
it("accepts an export without files", () => {
expect(relativeImportedFiles([])).toEqual([]);
});
});
describe("importableRoutines", () => {
it("leaves out one-shot schedules", () => {
const routines = [
{ name: "daily", crons: ["0 9 * * *"] },
{ name: "once", crons: ["@once"] },
{ name: "webhook", crons: [] },
];
expect(importableRoutines(routines).map((routine) => routine.name)).toEqual([
"daily",
"webhook",
]);
});
});

View File

@ -0,0 +1,34 @@
import { isOneShotRoutineCrons } from "@rakazo/core";
/** A relative, forward-slash path without `.`/`..` segments, or null when it could escape. */
export function safeImportPath(raw: string): string | null {
const normalized = raw.replace(/\\/g, "/");
if (normalized.startsWith("/") || /^[a-z]:/i.test(normalized)) return null;
const segments = normalized.split("/").filter((segment) => segment !== "" && segment !== ".");
if (segments.length === 0 || segments.includes("..")) return null;
return segments.join("/");
}
/**
* Exports of bots on a Team Computer carry their workspace prefix (`bots/<old id>/…`).
* The prefix names the old bot, so it is dropped and the importer writes under the new one.
*/
export function relativeImportedFiles<T extends { path: string }>(
files: T[],
): Array<T & { path: string }> | null {
const safe: Array<T & { path: string }> = [];
for (const file of files) {
const path = safeImportPath(file.path);
if (!path) return null;
safe.push({ ...file, path });
}
const first = safe[0]?.path.split("/");
const workspace = first && first.length > 2 && first[0] === "bots" ? `bots/${first[1]}/` : null;
if (!workspace || !safe.every((file) => file.path.startsWith(workspace))) return safe;
return safe.map((file) => ({ ...file, path: file.path.slice(workspace.length) }));
}
/** One-shot schedules belong to the chat that set them, so they are not carried over. */
export function importableRoutines<T extends { crons: string[] }>(routines: T[]): T[] {
return routines.filter((routine) => !isOneShotRoutineCrons(routine.crons));
}

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

@ -42,16 +42,6 @@ describe("loadEnv", () => {
);
});
it("loads a trading bot origin and refuses credentials", () => {
expect(loadEnv(base).tradingBotUrl).toBeUndefined();
expect(
loadEnv({ ...base, TRADING_BOT_URL: " http://127.0.0.1:8000/bot/ " }).tradingBotUrl,
).toBe("http://127.0.0.1:8000");
expect(() =>
loadEnv({ ...base, TRADING_BOT_URL: "http://user:secret@127.0.0.1:8000" }),
).toThrow(/^Invalid TRADING_BOT_URL$/);
});
it("loads an optional integrations catalog mirror", () => {
expect(loadEnv(base).integrationsCatalogUrl).toBeUndefined();
expect(

View File

@ -8,7 +8,6 @@ import {
resolveEncryptionKey,
resolveScreenProxySecret,
resolveSupervisorToken,
resolveTradingBotOrigin,
} from "@rakazo/core";
export { resolveCloudAgentProvider, resolveSandboxProvider } from "@rakazo/adapters";
@ -21,6 +20,7 @@ export interface AppEnv {
authSecret: string;
authUrl: string;
webOrigin: string;
extraTrustedOrigins: string[];
privacyPolicyUrl?: string;
apiUrl: string;
apiHost: string;
@ -93,8 +93,6 @@ export interface AppEnv {
updaterToken: string | undefined;
/** Current application image tag; used for compose manual-upgrade command selection. */
imageTag: string | undefined;
/** Tradingbot origin for the same-origin status proxy. Unset on SaaS builds. */
tradingBotUrl: string | undefined;
}
export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppEnv {
@ -112,6 +110,10 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppEnv {
authSecret,
authUrl: source.BETTER_AUTH_URL ?? source.WEB_ORIGIN ?? "http://127.0.0.1:5173",
webOrigin: source.WEB_ORIGIN ?? "http://127.0.0.1:5173",
extraTrustedOrigins: (source.EXTRA_TRUSTED_ORIGINS ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean),
privacyPolicyUrl: optional(source.PRIVACY_POLICY_URL),
apiUrl: source.API_URL ?? "http://127.0.0.1:3100",
apiHost: source.API_HOST ?? "127.0.0.1",
@ -182,7 +184,6 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppEnv {
updaterUrl,
updaterToken,
imageTag: optional(source.RAKAZO_IMAGE_TAG),
tradingBotUrl: resolveTradingBotOrigin(source.TRADING_BOT_URL),
};
}

View File

@ -1,8 +1,10 @@
import { buildSuggestionCard, withSuggestionStatus } from "@rakazo/core";
import { describe, expect, it } from "vitest";
import {
buildInboxItems,
type InboxMessageRow,
type InboxRunRow,
type InboxSuggestionRow,
type InboxUnreadThreadRow,
} from "./inbox.js";
@ -18,9 +20,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 +52,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 +233,105 @@ 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 } });
});
it("lists an open suggestion with its card, and drops it once settled", () => {
const card = buildSuggestionCard({
id: "effect-9",
effectId: "effect-9",
tool: "schedule_create",
args: { name: "Morning walk", prompt: "Take a walk." },
});
const row = (blocks: unknown[]): InboxSuggestionRow => ({
spaceId: "space-home",
createdAt: at(3),
bot: scout,
thread: { botId: scout.id, groupId: null, group: null },
messageId: "msg-card",
cardId: "effect-9",
blocks,
});
const items = buildInboxItems({
runs: [],
messages: [],
unreadThreads: [unreadThread(scout, { id: "msg-card", text: "", minute: 3 })],
suggestions: [row([card])],
});
expect(items).toEqual([
{
kind: "suggestion",
bot: scout,
target: { botId: scout.id },
messageId: "msg-card",
card,
createdAt: at(3).toISOString(),
},
]);
expect(
buildInboxItems({
runs: [],
messages: [],
unreadThreads: [],
suggestions: [row([withSuggestionStatus(card, "dismissed")])],
}),
).toEqual([]);
});
});

View File

@ -1,20 +1,23 @@
import type { Actor, InboxItem, InboxThreadTarget } from "@rakazo/contracts";
import { MessageBlock } from "@rakazo/contracts";
import { MessageBlock, SUGGESTION_TTL_MS } from "@rakazo/contracts";
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;
type AskBlock = Extract<MessageBlock, { kind: "ask" }>;
type CardBlock = Extract<MessageBlock, { kind: "card" }>;
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,12 +36,25 @@ export interface InboxMessageRow {
}
export interface InboxUnreadThreadRow {
spaceId: string;
createdAt: Date;
bot: InboxBot | null;
/** The newest bot message, if any. */
messages: Array<Omit<InboxMessageRow, "runId">>;
}
/** An open suggestion: the card holding it and the thread it was posted in. */
export interface InboxSuggestionRow {
spaceId: string;
createdAt: Date;
bot: InboxBot;
thread: InboxRunRow["thread"];
messageId: string;
cardId: string;
/** The card message's blocks. */
blocks: unknown;
}
function parseBlocks(blocks: unknown): MessageBlock[] {
const parsed = MessageBlock.array().safeParse(blocks);
return parsed.success ? parsed.data : [];
@ -73,12 +89,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;
@ -93,16 +111,25 @@ function byCreatedAt(left: InboxItem, right: InboxItem): number {
}
/**
* Turns waiting runs, their ask and takeover messages, and unread threads into inbox items:
* Turns waiting runs, their ask and takeover messages, open suggestions 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[];
/** Open suggestions, wherever their card was posted. */
suggestions?: InboxSuggestionRow[];
/** 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 +142,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 +163,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 ?? "",
@ -143,6 +172,23 @@ export function buildInboxItems(input: {
}
}
for (const suggestion of input.suggestions ?? []) {
const target = askTarget(suggestion.thread, !input.otherSpaces?.has(suggestion.spaceId));
const card = parseBlocks(suggestion.blocks).find(
(block): block is CardBlock => block.kind === "card" && block.id === suggestion.cardId,
);
if (!target || card?.suggestion?.status !== "proposed") continue;
waiting.push({
kind: "suggestion",
bot: suggestion.bot,
target,
...spaceOf(suggestion.spaceId),
messageId: suggestion.messageId,
card,
createdAt: suggestion.createdAt.toISOString(),
});
}
const covered = new Set(waiting.map((item) => targetKey(item.target)));
const unread: InboxItem[] = [];
for (const thread of input.unreadThreads) {
@ -154,6 +200,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 +212,45 @@ 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. The home space's main conversation collects every
* space the user belongs to, except spaces being deleted; a project's main conversation keeps to
* its own project.
*/
async function otherInboxSpaces(
prisma: PrismaClient,
actor: Actor,
): Promise<Map<string, InboxSpace>> {
const current = await prisma.space.findUnique({
where: { id: actor.spaceId },
select: { isDefault: true },
});
if (!current?.isDefault) return new Map();
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([
const threadSelect = {
botId: true,
groupId: true,
group: { select: { kind: true, archivedAt: true } },
} as const;
const [runs, unreadThreads, suggestionEffects] = 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,17 +258,12 @@ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<Inb
},
select: {
id: true,
spaceId: true,
status: true,
threadId: true,
updatedAt: true,
bot: { select: botSelect },
thread: {
select: {
botId: true,
groupId: true,
group: { select: { kind: true, archivedAt: true } },
},
},
thread: { select: threadSelect },
},
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
take: INBOX_RUN_LIMIT,
@ -197,12 +271,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: {
@ -215,7 +290,54 @@ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<Inb
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
take: INBOX_UNREAD_LIMIT,
}),
prisma.externalEffect.findMany({
where: {
spaceId: { in: spaceIds },
kind: "suggestion",
status: "proposed",
createdAt: { gt: new Date(Date.now() - SUGGESTION_TTL_MS) },
run: { userId: actor.userId, bot: ownBot, thread: { userId: actor.userId } },
},
select: {
id: true,
spaceId: true,
createdAt: true,
request: true,
run: { select: { bot: { select: botSelect }, thread: { select: threadSelect } } },
},
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
take: INBOX_RUN_LIMIT,
}),
]);
const cardMessageIds = new Map<string, string>();
for (const effect of suggestionEffects) {
const messageId = (effect.request as { card?: { messageId?: unknown } } | null)?.card
?.messageId;
if (typeof messageId === "string") cardMessageIds.set(effect.id, messageId);
}
const cardMessages =
cardMessageIds.size === 0
? []
: await prisma.message.findMany({
where: { id: { in: [...cardMessageIds.values()] }, role: "bot" },
select: { id: true, blocks: true },
});
const blocksById = new Map(cardMessages.map((message) => [message.id, message.blocks]));
const suggestions: InboxSuggestionRow[] = suggestionEffects.flatMap((effect) => {
const messageId = cardMessageIds.get(effect.id);
if (!messageId || !blocksById.has(messageId)) return [];
return [
{
spaceId: effect.spaceId,
createdAt: effect.createdAt,
bot: effect.run.bot,
thread: effect.run.thread,
messageId,
cardId: effect.id,
blocks: blocksById.get(messageId),
},
];
});
const messages =
runs.length === 0
? []
@ -232,5 +354,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, suggestions, otherSpaces });
}

View File

@ -7,6 +7,7 @@ import { serve } from "@hono/node-server";
import { SERVICE_NAMES } from "@rakazo/logging";
import { createRootLogger } from "@rakazo/logging/axiom";
import { createApp } from "./app.js";
import { API_SERVER_OPTIONS } from "./artifact-http.js";
import { loadEnv } from "./env.js";
const logger = createRootLogger(SERVICE_NAMES.api);
@ -14,9 +15,17 @@ const logger = createRootLogger(SERVICE_NAMES.api);
try {
const env = loadEnv();
const { app, stop } = await createApp({ ...env, logger });
const server = serve({ fetch: app.fetch, port: env.port, hostname: env.apiHost }, () => {
logger.info("api listening", { "http.host": env.apiHost, "http.port": env.port });
});
const server = serve(
{
fetch: app.fetch,
port: env.port,
hostname: env.apiHost,
serverOptions: API_SERVER_OPTIONS,
},
() => {
logger.info("api listening", { "http.host": env.apiHost, "http.port": env.port });
},
);
// Long-lived connections (threads.subscribe SSE streams) never end on their
// own, so server.close() alone waits forever for them. Track sockets and

View File

@ -1,4 +1,4 @@
import { ORPCError } from "@orpc/server";
import { ORPCError, ValidationError } from "@orpc/server";
import { createLogger, createTestSink, installLogger } from "@rakazo/logging";
import { afterEach, describe, expect, it } from "vitest";
import { logUnexpectedRpcError } from "./app.js";
@ -20,6 +20,28 @@ describe("logUnexpectedRpcError", () => {
expect(sink.events).toEqual([]);
});
it("names the procedure and the fields when a result breaks its output contract", () => {
const sink = createTestSink();
installLogger(createLogger({ service: "rakazo-api", sinks: [sink] }));
const error = new ORPCError("INTERNAL_SERVER_ERROR", {
message: "Output validation failed",
cause: new ValidationError({
message: "Output validation failed",
issues: [{ message: "Expected string", path: ["items", 0, { key: "title" }] }],
data: { items: [{ title: null, body: "private" }] },
}),
});
logUnexpectedRpcError(error, ["inbox", "list"]);
expect(sink.events).toHaveLength(1);
expect(sink.events[0]).toMatchObject({
message: "rpc inbox/list failed",
issues: [{ path: "items.0.title", problem: "Expected string" }],
});
expect(JSON.stringify(sink.events[0])).not.toContain("private");
});
it("names the procedure and every cause behind an opaque failure", () => {
const sink = createTestSink();
installLogger(createLogger({ service: "rakazo-api", sinks: [sink] }));

View File

@ -43,13 +43,20 @@ export async function assertNotMainConversation(
if (group?.kind === "main") throw new ORPCError("BAD_REQUEST", { message });
}
/** The main conversation carries the main bot's name. */
export async function renameMainConversation(prisma: PrismaClient, userId: string, name: string) {
await prisma.chatGroup.updateMany({ where: { userId, kind: "main" }, data: { name } });
/** A main conversation carries its main bot's name. */
export async function renameMainConversation(
prisma: PrismaClient,
main: { userId: string; spaceId: string },
name: string,
) {
await prisma.chatGroup.updateMany({
where: { userId: main.userId, spaceId: main.spaceId, kind: "main" },
data: { name },
});
}
/**
* Same-space bots the user addresses in the main conversation, by `@Name` or a mention chip,
* Same-space bots the user addresses in a main conversation, by `@Name` or a mention chip,
* join it first, so the member mention rules route the message to them.
*/
export async function joinAddressedBots(
@ -71,5 +78,9 @@ export async function joinAddressedBots(
.map((bot) => bot.id);
if (addressed.length === 0) return;
// Subscribers pick up the group.updated event with the send that follows.
await joinMainConversation(prisma, { userId: actor.userId, botIds: addressed });
await joinMainConversation(prisma, {
userId: actor.userId,
spaceId: actor.spaceId,
botIds: addressed,
});
}

View File

@ -0,0 +1,35 @@
import type { MediaCapabilities } from "@rakazo/adapter-kit";
import type { Actor } from "@rakazo/contracts";
import { MediaOperationSchema } from "@rakazo/contracts";
import { describe, expect, it } from "vitest";
import type { MediaRouteDeps } from "./media-jobs.js";
import { getMediaCapabilities } from "./media-jobs.js";
const actor: Actor = {
userId: "user-1",
spaceId: "space-1",
email: "user@rakazo.test",
isDeploymentOwner: false,
};
describe("media capabilities", () => {
it("answers for the actor's space", async () => {
const asked: string[] = [];
const deps = {
media: {
capabilities: async (spaceId: string) => {
asked.push(spaceId);
return { transcribe: true, export_video: false } as MediaCapabilities;
},
},
} as unknown as MediaRouteDeps;
await expect(getMediaCapabilities(deps, actor)).resolves.toMatchObject({ transcribe: true });
expect(asked).toEqual(["space-1"]);
});
it("reports nothing available without a media runtime", async () => {
const capabilities = await getMediaCapabilities({} as MediaRouteDeps, actor);
expect(Object.keys(capabilities)).toEqual(MediaOperationSchema.options);
expect(Object.values(capabilities).every((value) => value === false)).toBe(true);
});
});

165
apps/api/src/media-jobs.ts Normal file
View File

@ -0,0 +1,165 @@
import { ORPCError } from "@orpc/server";
import type { MediaCapabilities, MediaRuntime } from "@rakazo/adapter-kit";
import type { EncryptedSecretStore } from "@rakazo/adapters";
import {
loadMediaSettings,
MediaJobRejectedError,
MediaSettingsInvalidError,
mediaSettingsView,
saveMediaSettings,
} from "@rakazo/adapters";
import type { Actor, MediaJobStartInput, MediaSettingsUpdate } from "@rakazo/contracts";
import { MediaOperationSchema } from "@rakazo/contracts";
import type { PrismaClient } from "@rakazo/db";
import { IsolationError, requireMembership } from "@rakazo/db";
import { resolveThreadTarget } from "./thread-target.js";
export interface MediaRouteDeps {
prisma: PrismaClient;
secrets: EncryptedSecretStore;
media?: MediaRuntime;
}
/** Media backends run on the deployment owner's own server, so only they configure them. */
function requireDeploymentOwner(actor: Actor) {
if (!actor.isDeploymentOwner) throw new ORPCError("FORBIDDEN");
}
function requireMedia(deps: MediaRouteDeps): MediaRuntime {
if (!deps.media) throw new ORPCError("NOT_IMPLEMENTED");
return deps.media;
}
export async function getMediaSettings(deps: MediaRouteDeps, actor: Actor) {
requireDeploymentOwner(actor);
return mediaSettingsView(await loadMediaSettings(deps));
}
export async function updateMediaSettings(
deps: MediaRouteDeps,
actor: Actor,
input: MediaSettingsUpdate,
) {
requireDeploymentOwner(actor);
try {
return await saveMediaSettings(deps, input);
} catch (error) {
if (error instanceof MediaSettingsInvalidError) {
throw new ORPCError("BAD_REQUEST", { message: error.message });
}
throw error;
}
}
/**
* The actor in the space a card lives in. A card is acted on where it is shown, which need not be
* the space the client has selected (the main conversation lives in the home space), so the
* space comes from the card's own record: it must belong to the actor, and the actor must still
* be a member of that space. Anything else reads as not found.
*/
async function actorInSpace(deps: MediaRouteDeps, actor: Actor, spaceId: string): Promise<Actor> {
if (spaceId === actor.spaceId) return actor;
try {
return await requireMembership(deps.prisma, actor.userId, spaceId);
} catch (error) {
if (error instanceof IsolationError) throw new ORPCError("NOT_FOUND");
throw error;
}
}
/**
* Starts an operation a user picked on a card (export, transcribe, edit) in the card's
* conversation. The conversation is authorized like every thread procedure, in the card's space;
* the runtime checks that the operation's source artifacts belong to the actor there, and the
* job can only ever read the actor's artifacts of that space.
*/
export async function startMediaJob(deps: MediaRouteDeps, actor: Actor, input: MediaJobStartInput) {
const media = requireMedia(deps);
const source = await deps.prisma.message.findFirst({
where: {
id: input.messageId,
thread: {
userId: actor.userId,
...(input.botId ? { botId: input.botId } : { groupId: input.groupId }),
},
},
select: { botId: true, thread: { select: { spaceId: true } } },
});
if (!source) throw new ORPCError("NOT_FOUND");
const scoped = await actorInSpace(deps, actor, source.thread.spaceId);
const target = await resolveThreadTarget(deps.prisma, scoped, input).catch((error) => {
if (error instanceof IsolationError) throw new ORPCError("NOT_FOUND");
throw error;
});
const bots =
target.kind === "bot"
? [target.botId]
: [
...(target.leadBotId && target.memberBotIds.includes(target.leadBotId)
? [target.leadBotId]
: []),
...target.memberBotIds,
];
const botId = source.botId && bots.includes(source.botId) ? source.botId : bots[0];
if (!botId) throw new ORPCError("NOT_FOUND");
try {
return await media.start({
operation: input.operation,
input: input.input,
spaceId: scoped.spaceId,
userId: scoped.userId,
botId,
groupId: target.kind === "group" ? target.groupId : null,
threadId: target.threadId,
runId: null,
});
} catch (error) {
if (error instanceof MediaJobRejectedError) {
throw new ORPCError("BAD_REQUEST", { message: error.message });
}
throw error;
}
}
/**
* Booleans only: which operations a space can run, never how they are configured. With a job,
* the space is the job's (a card's follow-ups); otherwise the actor's current space.
*/
export async function getMediaCapabilities(
deps: MediaRouteDeps,
actor: Actor,
input: { jobId?: string } = {},
): Promise<MediaCapabilities> {
const spaceId = input.jobId ? (await ownedJob(deps, actor, input.jobId)).spaceId : actor.spaceId;
if (deps.media) return deps.media.capabilities(spaceId);
return Object.fromEntries(
MediaOperationSchema.options.map((operation) => [operation, false]),
) as MediaCapabilities;
}
/** The actor's job, in whichever of the actor's spaces it runs. */
async function ownedJob(deps: MediaRouteDeps, actor: Actor, jobId: string) {
const job = await deps.prisma.mediaJob.findFirst({
where: { id: jobId, userId: actor.userId },
select: { id: true, spaceId: true },
});
if (!job) throw new ORPCError("NOT_FOUND");
await actorInSpace(deps, actor, job.spaceId);
return job;
}
export async function getMediaJob(deps: MediaRouteDeps, actor: Actor, jobId: string) {
const media = requireMedia(deps);
const snapshot = await media.get((await ownedJob(deps, actor, jobId)).id);
if (!snapshot) throw new ORPCError("NOT_FOUND");
return snapshot;
}
export async function cancelMediaJob(deps: MediaRouteDeps, actor: Actor, jobId: string) {
const media = requireMedia(deps);
const { id } = await ownedJob(deps, actor, jobId);
await media.cancel(id);
const snapshot = await media.get(id);
if (!snapshot) throw new ORPCError("NOT_FOUND");
return snapshot;
}

View File

@ -0,0 +1,82 @@
import path from "node:path";
import { ORPCError } from "@orpc/server";
import type { JobPublisher } from "@rakazo/adapter-kit";
import { mediaLibrarySyncJob } from "@rakazo/adapter-kit";
import { checkMediaLibraryFolder, mediaLibrarySourceView } from "@rakazo/adapters";
import type { Actor, MediaLibrarySourceAddInput } from "@rakazo/contracts";
import type { PrismaClient } from "@rakazo/db";
export interface MediaLibraryRouteDeps {
prisma: PrismaClient;
jobs: JobPublisher;
}
/** Library folders are paths on the server, so only its owner adds them, like host folders. */
function requireDeploymentOwner(actor: Actor) {
if (!actor.isDeploymentOwner) throw new ORPCError("FORBIDDEN");
}
async function ownSource(deps: MediaLibraryRouteDeps, actor: Actor, sourceId: string) {
const source = await deps.prisma.mediaLibrarySource.findFirst({
where: { id: sourceId, userId: actor.userId },
});
if (!source) throw new ORPCError("NOT_FOUND");
return source;
}
export async function listMediaLibrarySources(deps: MediaLibraryRouteDeps, actor: Actor) {
requireDeploymentOwner(actor);
const rows = await deps.prisma.mediaLibrarySource.findMany({
where: { userId: actor.userId },
orderBy: { createdAt: "asc" },
});
return rows.map(mediaLibrarySourceView);
}
export async function addMediaLibrarySource(
deps: MediaLibraryRouteDeps,
actor: Actor,
input: MediaLibrarySourceAddInput,
) {
requireDeploymentOwner(actor);
const problem = await checkMediaLibraryFolder(input.path);
if (problem) throw new ORPCError("BAD_REQUEST", { message: problem });
const folder = path.resolve(input.path);
const existing = await deps.prisma.mediaLibrarySource.findUnique({
where: { userId_path: { userId: actor.userId, path: folder } },
});
if (existing) return mediaLibrarySourceView(existing);
const source = await deps.prisma.mediaLibrarySource.create({
data: { userId: actor.userId, path: folder, status: "syncing" },
});
await deps.jobs.enqueue(mediaLibrarySyncJob(source.id));
return mediaLibrarySourceView(source);
}
export async function removeMediaLibrarySource(
deps: MediaLibraryRouteDeps,
actor: Actor,
sourceId: string,
) {
requireDeploymentOwner(actor);
await ownSource(deps, actor, sourceId);
await deps.prisma.mediaLibrarySource.deleteMany({
where: { id: sourceId, userId: actor.userId },
});
return { ok: true as const };
}
export async function syncMediaLibrarySourceNow(
deps: MediaLibraryRouteDeps,
actor: Actor,
sourceId: string,
) {
requireDeploymentOwner(actor);
await ownSource(deps, actor, sourceId);
const source = await deps.prisma.mediaLibrarySource.update({
where: { id: sourceId },
data: { status: "syncing", error: null },
});
await deps.jobs.enqueue(mediaLibrarySyncJob(sourceId));
return mediaLibrarySourceView(source);
}

View File

@ -0,0 +1,116 @@
import { describe, expect, it } from "vitest";
import {
closingNote,
focusCard,
focusFactValue,
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("writes focus facts in the user's locale", () => {
expect(focusFactValue("day", "en-US")).toBe("Wants help with day-to-day work first");
expect(focusFactValue("day", "de-DE")).toBe(
"Möchte zuerst bei der täglichen Arbeit unterstützt werden",
);
});
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,326 @@
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 type FocusOptionId = "day" | "inbox" | "research" | "crew";
export interface FocusOption {
id: FocusOptionId;
letter: string;
label: string;
apps: string[];
}
const FOCUS_FACTS = {
en: {
day: "Wants help with day-to-day work first",
inbox: "Wants help with inbox and email first",
research: "Wants help with research and writing first",
crew: "Wants a coordinated crew of specialist bots",
},
de: {
day: "Möchte zuerst bei der täglichen Arbeit unterstützt werden",
inbox: "Möchte zuerst bei Posteingang und E-Mail unterstützt werden",
research: "Möchte zuerst bei Recherche und Schreiben unterstützt werden",
crew: "Möchte eine koordinierte Crew aus Spezialisten-Bots",
},
} as const satisfies Record<string, Record<FocusOptionId, string>>;
/** Profile fact text for a focus answer, in the user's locale (defaults to English). */
function isFocusOptionId(id: string): id is FocusOptionId {
return FOCUS_OPTIONS.some((option) => option.id === id);
}
export function focusFactValue(focusId: string, locale: string): string {
if (!isFocusOptionId(focusId)) return focusId;
const key: keyof typeof FOCUS_FACTS = locale.toLowerCase().startsWith("de") ? "de" : "en";
return FOCUS_FACTS[key][focusId];
}
export const FOCUS_OPTIONS: FocusOption[] = [
{
id: "day",
letter: "A",
label: "Day-to-day work",
apps: ["slack", "gmail", "googlecalendar"],
},
{
id: "inbox",
letter: "B",
label: "Inbox & email",
apps: ["gmail", "googlecalendar", "slack"],
},
{
id: "research",
letter: "C",
label: "Research & writing",
apps: ["hackernews", "notion", "googledocs"],
},
{
id: "crew",
letter: "D",
label: "Specialist crew",
apps: ["slack", "gmail", "googlecalendar"],
},
];
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;
const profile = await tx.userProfile.findUnique({
where: { userId },
select: { locale: true, timeZone: true },
});
const locale = intakeContext(profile, new Date()).locale;
await tx.userOnboarding.update({ where: { userId }, data: { focus: option.id } });
await updateUserProfile(prisma, userId, {
addFacts: [
{ category: "goals", value: focusFactValue(option.id, locale), 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,64 +1,192 @@
import { mkdir, mkdtemp, 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 { afterEach, describe, expect, it, vi } from "vitest";
import { getScanStatus, startOnboardingScan } from "./onboarding-scan.js";
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", isDeploymentOwner: true };
const member = { userId: "user-member", isDeploymentOwner: false };
const owner = { userId: "user-owner", spaceId: "space-owner", isDeploymentOwner: true };
const member = { userId: "user-member", spaceId: "space-member", isDeploymentOwner: false };
const roots: string[] = [];
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;
/** 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 PrismaClient;
async function tempDir(): Promise<string> {
const dir = await realpath(await mkdtemp(path.join(tmpdir(), "evoo-scan-")));
roots.push(dir);
return dir;
}
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 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 },
);
}
afterAll(async () => {
await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true })));
});
describe("onboarding scan", () => {
afterEach(() => {
vi.unstubAllEnvs();
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 { scanId } = await startOnboardingScan({ actor: member }, {});
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 { scanId } = await startOnboardingScan({ actor: owner }, {});
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 } });
expect(status).toEqual({
status: "complete",
projects: [{ id: "project-0", name: "atlas", path: "code/atlas" }],
facts: [{ id: "language:TypeScript", label: "TypeScript" }],
});
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("lets only the deployment owner discover the configured scan home", async () => {
// The shared test setup pins the emulated runtime, which also keeps the scan simulated.
vi.stubEnv("AGENT_RUNTIME", "");
vi.stubEnv("SANDBOX_PROVIDER", "");
const scanHome = await scanHomeWithOneFile();
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"] };
const asMember = await startOnboardingScan({ actor: member, scanHome }, {});
const memberStatus = await completed(asMember.scanId, member.userId);
expect(memberStatus.result).toMatchObject({ fileSummary: { totalFiles: 0 } });
await expect(
applyOnboardingScan(untouchedPrisma, { ...owner, isDeploymentOwner: false }, home, choice),
).rejects.toMatchObject({ code: "FORBIDDEN" });
await expect(
applyOnboardingScan(untouchedPrisma, owner, outside, choice),
).rejects.toMatchObject({ code: "FORBIDDEN" });
const asOwner = await startOnboardingScan({ actor: owner, scanHome }, {});
const ownerStatus = await completed(asOwner.scanId, owner.userId);
expect(ownerStatus.status).toBe("complete");
expect(ownerStatus.result).toMatchObject({ fileSummary: { totalFiles: 1 } });
// 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.",
});
});
});

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.",
@ -79,80 +59,108 @@ async function requireBotThread(deps: OnboardingDeps, actor: Actor, botId: strin
});
if (!bot?.thread) throw new IsolationError();
if (bot.kind === "main") {
const conversation = await ensureMainConversation(deps.prisma, actor.userId);
const conversation = await ensureMainConversation(deps.prisma, actor.userId, bot.spaceId);
return { bot, thread: { id: conversation.threadId } };
}
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

@ -0,0 +1,43 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { listHostDirectories } from "./project-directories.js";
describe("listHostDirectories", () => {
it("lists directories in the user home or projects directory by default", async () => {
const listing = await listHostDirectories();
expect(listing.currentPath).toBeTruthy();
expect(Array.isArray(listing.directories)).toBe(true);
expect(Array.isArray(listing.commonPaths)).toBe(true);
expect(listing.commonPaths.some((p) => p.name.includes("Home") || p.name === "Projects")).toBe(
true,
);
});
it("lists specific existing directory and excludes hidden folders and node_modules", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "evoo-test-dirs-"));
try {
await fs.mkdir(path.join(tmpDir, "visible-folder-a"));
await fs.mkdir(path.join(tmpDir, "visible-folder-b"));
await fs.mkdir(path.join(tmpDir, ".hidden-folder"));
await fs.mkdir(path.join(tmpDir, "node_modules"));
await fs.writeFile(path.join(tmpDir, "some-file.txt"), "hello");
const listing = await listHostDirectories(tmpDir);
expect(listing.currentPath).toBe(await fs.realpath(tmpDir));
expect(listing.directories.map((d) => d.name)).toEqual([
"visible-folder-a",
"visible-folder-b",
]);
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("falls back safely when given an invalid path", async () => {
const listing = await listHostDirectories("/nonexistent-path-12345/abcdef");
expect(listing.currentPath).toBe(os.homedir());
expect(Array.isArray(listing.directories)).toBe(true);
});
});

View File

@ -0,0 +1,95 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
export interface HostDirectoryListing {
currentPath: string;
parentPath: string | null;
commonPaths: Array<{ name: string; path: string }>;
directories: Array<{ name: string; path: string }>;
}
export async function listHostDirectories(targetPath?: string): Promise<HostDirectoryListing> {
const home = os.homedir();
const raw = targetPath?.trim();
let requestedPath = home;
if (raw) {
if (raw === "~") {
requestedPath = home;
} else if (raw.startsWith("~/")) {
requestedPath = path.join(home, raw.slice(2));
} else {
requestedPath = path.resolve(raw);
}
} else {
// Default: prioritize ~/Projects if it exists, otherwise home directory
const projectsCandidate = path.join(home, "Projects");
try {
const stat = await fs.stat(projectsCandidate);
if (stat.isDirectory()) {
requestedPath = projectsCandidate;
}
} catch {
requestedPath = home;
}
}
let currentPath: string;
try {
currentPath = await fs.realpath(requestedPath);
const stat = await fs.stat(currentPath);
if (!stat.isDirectory()) {
currentPath = home;
}
} catch {
currentPath = home;
}
const parsed = path.parse(currentPath);
const parentPath = currentPath === parsed.root ? null : path.dirname(currentPath);
const commonCandidates = [
{ name: "Projects", path: path.join(home, "Projects") },
{ name: "Home (~)", path: home },
{ name: "Desktop", path: path.join(home, "Desktop") },
{ name: "Documents", path: path.join(home, "Documents") },
{ name: "Downloads", path: path.join(home, "Downloads") },
];
const commonPaths: Array<{ name: string; path: string }> = [];
for (const candidate of commonCandidates) {
try {
const s = await fs.stat(candidate.path);
if (s.isDirectory()) {
commonPaths.push(candidate);
}
} catch {
// not present on host
}
}
const directories: Array<{ name: string; path: string }> = [];
try {
const entries = await fs.readdir(currentPath, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
// Skip hidden folders (.git, .Trash, etc.) and node_modules
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
directories.push({
name: entry.name,
path: path.join(currentPath, entry.name),
});
}
directories.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
} catch {
// Unreadable directory returns empty list
}
return {
currentPath,
parentPath,
commonPaths,
directories,
};
}

View File

@ -0,0 +1,114 @@
import { describe, expect, it, vi } from "vitest";
import { listUserGithubRepos } from "./project-github.js";
vi.mock("node:child_process", () => ({
execFile: vi.fn((_cmd, _args, callback) => {
callback(new Error("gh not installed"));
}),
}));
describe("listUserGithubRepos", () => {
it("queries repositories and filters by query", async () => {
const mockPrisma = {
capabilityInstall: {
findMany: vi.fn().mockResolvedValue([
{
id: "cap-1",
kind: "mcp",
config: { preset: "github" },
secretId: "sec-1",
},
]),
},
secret: {
findUnique: vi.fn().mockResolvedValue({
id: "sec-1",
ciphertext: "fake-ciphertext",
}),
},
};
const mockSecrets = {
load: vi.fn().mockReturnValue("fake-token"),
};
// Mock fetch for GitHub API
const originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn().mockImplementation(async (url: string) => {
if (url.includes("/user/repos")) {
return {
ok: true,
json: async () => [
{
name: "evOo-local",
full_name: "testorg/evOo-local",
html_url: "https://github.com/testorg/evOo-local",
description: "AI Operating System",
private: false,
updated_at: "2026-10-12T00:00:00Z",
},
{
name: "secret-vault",
full_name: "testorg/secret-vault",
html_url: "https://github.com/testorg/secret-vault",
description: "Private keys",
private: true,
updated_at: "2026-10-11T00:00:00Z",
},
],
} as unknown as Response;
}
if (url.includes("/user")) {
return {
ok: true,
json: async () => ({ login: "testuser" }),
} as unknown as Response;
}
return { ok: false } as unknown as Response;
});
try {
const all = await listUserGithubRepos(mockPrisma as any, undefined, mockSecrets);
expect(all.connected).toBe(true);
expect(all.username).toBe("testuser");
expect(all.repos).toHaveLength(2);
expect(all.repos[0].name).toBe("evOo-local");
const filtered = await listUserGithubRepos(
mockPrisma as any,
{ query: "vault" },
mockSecrets,
);
expect(filtered.repos).toHaveLength(1);
expect(filtered.repos[0].name).toBe("secret-vault");
expect(filtered.repos[0].isPrivate).toBe(true);
} finally {
globalThis.fetch = originalFetch;
}
});
it("handles unauthenticated state gracefully", async () => {
const mockPrisma = {
capabilityInstall: {
findMany: vi.fn().mockResolvedValue([]),
},
secret: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const origToken = process.env.GITHUB_TOKEN;
const origGhToken = process.env.GH_TOKEN;
delete process.env.GITHUB_TOKEN;
delete process.env.GH_TOKEN;
try {
const res = await listUserGithubRepos(mockPrisma as any, { query: "test" });
expect(res.connected !== undefined).toBe(true);
expect(Array.isArray(res.repos)).toBe(true);
} finally {
if (origToken) process.env.GITHUB_TOKEN = origToken;
if (origGhToken) process.env.GH_TOKEN = origGhToken;
}
});
});

View File

@ -0,0 +1,200 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type { EncryptedSecretStore } from "@rakazo/adapters";
import type { PrismaClient } from "@rakazo/db";
const execFileAsync = promisify(execFile);
export interface GithubRepoItem {
name: string;
fullName: string;
url: string;
description?: string | null;
isPrivate: boolean;
updatedAt?: string;
}
export interface ListGithubReposOutput {
connected: boolean;
username?: string;
repos: GithubRepoItem[];
}
export async function listUserGithubRepos(
prisma: Pick<PrismaClient, "capabilityInstall" | "secret">,
input?: { query?: string; limit?: number },
secrets?: Pick<EncryptedSecretStore, "load">,
): Promise<ListGithubReposOutput> {
let token: string | null = null;
// 1. Check CapabilityInstall in database if secret store is provided
if (secrets) {
try {
const installs = await prisma.capabilityInstall.findMany({
where: { kind: "mcp" },
});
for (const inst of installs) {
const cfg = inst.config as Record<string, unknown> | null;
if (cfg?.preset === "github" && inst.secretId) {
const secret = await prisma.secret.findUnique({
where: { id: inst.secretId },
select: { id: true, ciphertext: true },
});
if (secret) {
try {
const loaded = secrets.load(secret.ciphertext, secret.id);
if (loaded?.trim()) {
token = loaded.trim();
break;
}
} catch {
// decryption error fallback
}
}
}
}
} catch {
// Database lookup error fallback
}
}
// 2. Check environment
if (!token) {
token = process.env.GITHUB_TOKEN?.trim() || process.env.GH_TOKEN?.trim() || null;
}
// 3. Check local gh CLI auth token
if (!token) {
try {
const { stdout } = await execFileAsync("gh", ["auth", "token"], { timeout: 3000 });
if (stdout.trim()) {
token = stdout.trim();
}
} catch {
// gh CLI not available or not logged in
}
}
// If token is available, query GitHub REST API
if (token) {
try {
let username: string | undefined;
const userRes = await fetch("https://api.github.com/user", {
headers: {
Authorization: `Bearer ${token}`,
"User-Agent": "evOo-App",
Accept: "application/vnd.github+json",
},
});
if (userRes.ok) {
const userData = (await userRes.json()) as { login?: string };
username = userData.login;
}
const reposRes = await fetch(
"https://api.github.com/user/repos?sort=pushed&per_page=100&type=all",
{
headers: {
Authorization: `Bearer ${token}`,
"User-Agent": "evOo-App",
Accept: "application/vnd.github+json",
},
},
);
if (reposRes.ok) {
const rawRepos = (await reposRes.json()) as Array<{
name: string;
full_name: string;
html_url: string;
description?: string | null;
private?: boolean;
pushed_at?: string;
updated_at?: string;
}>;
let items: GithubRepoItem[] = rawRepos.map((r) => ({
name: r.name,
fullName: r.full_name,
url: r.html_url,
description: r.description ?? null,
isPrivate: Boolean(r.private),
updatedAt: r.pushed_at || r.updated_at,
}));
if (input?.query?.trim()) {
const q = input.query.trim().toLowerCase();
items = items.filter(
(r) =>
r.name.toLowerCase().includes(q) ||
r.fullName.toLowerCase().includes(q) ||
(r.description && r.description.toLowerCase().includes(q)),
);
}
const limit = input?.limit ?? 50;
return {
connected: true,
username,
repos: items.slice(0, limit),
};
}
} catch {
// Fall through to gh repo list fallback
}
}
// Fallback: try gh repo list directly
try {
const { stdout } = await execFileAsync(
"gh",
[
"repo",
"list",
"--limit",
"100",
"--json",
"nameWithOwner,name,url,description,isPrivate,pushedAt",
],
{ timeout: 5000 },
);
const parsed = JSON.parse(stdout) as Array<{
nameWithOwner: string;
name: string;
url: string;
description?: string;
isPrivate?: boolean;
pushedAt?: string;
}>;
let items: GithubRepoItem[] = parsed.map((r) => ({
name: r.name,
fullName: r.nameWithOwner,
url: r.url,
description: r.description || null,
isPrivate: Boolean(r.isPrivate),
updatedAt: r.pushedAt,
}));
if (input?.query?.trim()) {
const q = input.query.trim().toLowerCase();
items = items.filter(
(r) =>
r.name.toLowerCase().includes(q) ||
r.fullName.toLowerCase().includes(q) ||
(r.description && r.description.toLowerCase().includes(q)),
);
}
const limit = input?.limit ?? 50;
return {
connected: true,
repos: items.slice(0, limit),
};
} catch {
return {
connected: false,
repos: [],
};
}
}

View File

@ -55,7 +55,11 @@ export function requestBodyLimit(maxSize: number): MiddlewareHandler {
};
}
/** Install limits only on framework-parsed JSON surfaces with known payload contracts. */
/**
* Install limits only on framework-parsed JSON surfaces with known payload contracts. Streaming
* artifact uploads (`POST /api/artifacts`) are not buffered: the route enforces the per-kind cap
* from Content-Length and again while writing.
*/
export function mountApiRequestBodyLimits(app: Hono): void {
app.use("/api/auth/*", requestBodyLimit(MAX_AUTH_REQUEST_BYTES));
app.use(`${LOCAL_SETTINGS_RPC}/*`, requestBodyLimit(MAX_RPC_REQUEST_BYTES));

View File

@ -224,7 +224,12 @@ describe("model setup gate", () => {
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
json: expect.objectContaining({ needsModel: false }),
json: expect.objectContaining({
needsModel: false,
modelsManaged: true,
defaultProvider: "openrouter",
defaultModel: "test-model",
}),
});
});
@ -238,7 +243,13 @@ describe("model setup gate", () => {
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
json: expect.objectContaining({ needsModel: true }),
// The connect flow opens on the recommendation, not the keyless deployment default.
json: expect.objectContaining({
needsModel: true,
modelsManaged: false,
defaultProvider: "anthropic",
defaultModel: "claude-opus-5-5",
}),
});
});
@ -627,7 +638,11 @@ describe("computer screen url", () => {
controlRunId: null,
};
const callScreenUrl = async (connectScreen: () => Promise<unknown>, updateMany = vi.fn()) => {
const callScreenUrl = async (
connectScreen: () => Promise<unknown>,
updateMany = vi.fn(),
webOrigin?: string,
) => {
const prisma = {
bot: {
findFirst: vi.fn().mockResolvedValue({
@ -660,7 +675,7 @@ describe("computer screen url", () => {
headers: { "content-type": "application/json" },
body: JSON.stringify({ json: { botId: "bot-1" } }),
}),
{ prefix: "/rpc", context: { actor } },
{ prefix: "/rpc", context: { actor, webOrigin } },
);
return { response, updateMany };
};
@ -685,6 +700,16 @@ describe("computer screen url", () => {
});
});
it("points the screen at the browser's own LAN origin", async () => {
const { response } = await callScreenUrl(
async () => ({ url: "https://screen.example/vnc.html?token=fake-token" }),
vi.fn(),
"http://192.168.0.20:5173",
);
const { json } = await response.json();
expect(new URL(json.url).origin).toBe("http://192.168.0.20:5173");
});
it("returns desktop provider screen URLs without sealing them", async () => {
const { response } = await callScreenUrl(async () => ({
url: "desktop://screen/computer-1",
@ -977,3 +1002,52 @@ describe("interrupted computer reservation release", () => {
expect(order).toEqual(["operation", "computer"]);
});
});
describe("mcp oauth redirect", () => {
const begin = async (redirectUri: string, webOrigin?: string) => {
const start = vi.fn().mockResolvedValue({ status: "already_connected" });
const deps = {
prisma: {},
env: { webOrigin: "http://127.0.0.1:5173" },
mcpOAuth: { begin: start },
} as unknown as RouterDeps;
const { response } = await new RPCHandler(createRouter(deps)).handle(
new Request("http://127.0.0.1/rpc/mcp/oauth/begin", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ json: { serverId: "server-1", redirectUri } }),
}),
{
prefix: "/rpc",
context: {
actor: {
userId: "user",
spaceId: "space",
email: "user@rakazo.test",
isDeploymentOwner: true,
},
webOrigin,
},
},
);
return { status: response.status, start };
};
it("returns to the LAN origin the browser opened", async () => {
const { status, start } = await begin(
"http://192.168.0.20:5173/mcp/oauth/callback",
"http://192.168.0.20:5173",
);
expect(status).toBe(200);
expect(start).toHaveBeenCalled();
});
it("rejects a redirect to any other origin", async () => {
const { status, start } = await begin(
"https://attacker.example/mcp/oauth/callback",
"http://192.168.0.20:5173",
);
expect(status).toBe(400);
expect(start).not.toHaveBeenCalled();
});
});

File diff suppressed because it is too large Load Diff

View File

@ -20,6 +20,16 @@ describe("run activity copy", () => {
).toBe("Maya asked: Please check the release workflow.");
});
it("names an autopilot step by the user's order", () => {
expect(
activityPromptSnippet({
trigger: "autopilot",
prompt:
"Autopilot: continue the user's order on your own.\nOrder: Tidy the tickets\nUntil: done",
}),
).toBe("Tidy the tickets");
});
it("names a delegated task by its title instead of its wake prompt", () => {
expect(
activityPromptSnippet({

View File

@ -26,6 +26,10 @@ export function activityPromptSnippet(
const task = parsed.success ? taskBlockOf(parsed.data) : undefined;
return promptSnippet(task?.title ?? input.prompt, max);
}
if (input.trigger === "autopilot") {
// The continuation prompt is for the model; people know the run by its order.
return promptSnippet(/^Order: (.*)$/m.exec(input.prompt)?.[1] ?? input.prompt, max);
}
if (input.trigger !== "bot_message") return promptSnippet(input.prompt, max);
const parsed = MessageBlock.array().safeParse(input.sourceBlocks);
const message = parsed.success ? botMessageContext(parsed.data) : undefined;
@ -45,15 +49,19 @@ export function activityNotificationsEnabled(
return groupId !== null || notifyOnFinish;
}
export async function listSpaceRuns(
/**
* The user's runs across every space they belong to, so a chat waiting on them in another
* project still shows up wherever they look.
*/
export async function listUserRuns(
prisma: PrismaClient,
actor: Actor,
filter: "active" | "recent",
): Promise<RunActivityRow[]> {
const rows = await prisma.run.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
space: { memberships: { some: { userId: actor.userId } } },
bot: { archivedAt: null },
...(filter === "active"
? { status: { in: [...ACTIVE_RUN_STATUSES] } }
@ -84,6 +92,7 @@ export async function listSpaceRuns(
groupId: row.thread.groupId,
groupName: row.thread.group?.name ?? null,
threadId: row.threadId,
spaceId: row.spaceId,
status: row.status as RunActivityRow["status"],
trigger: row.trigger as RunActivityRow["trigger"],
notificationsEnabled: activityNotificationsEnabled(row.thread.groupId, row.bot.notifyOnFinish),

View File

@ -1,6 +1,7 @@
import type { Actor, MessageBlock, SearchHit } from "@rakazo/contracts";
import { extractLinksFromText, matchesSearchQuery, snippetAroundMatch } from "@rakazo/core";
import { Prisma, type PrismaClient } from "@rakazo/db";
import type { PrismaClient } from "@rakazo/db";
import { Prisma } from "@rakazo/db";
const SEARCH_LIMIT = 25;
/** Cap name matches so content hits (messages/files/links/routines) keep most of the budget. */
@ -64,19 +65,65 @@ export async function querySpaceSearch(
bucket.push(hit);
}
const bots = await prisma.bot.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
archivedAt: null,
OR: [
{ name: { contains: query, mode: "insensitive" } },
{ title: { contains: query, mode: "insensitive" } },
{ description: { contains: query, mode: "insensitive" } },
],
},
take: CONVERSATION_HIT_LIMIT,
});
const [bots, groups, artifacts, groupArtifacts, routines] = await Promise.all([
prisma.bot.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
archivedAt: null,
OR: [
{ name: { contains: query, mode: "insensitive" } },
{ title: { contains: query, mode: "insensitive" } },
{ description: { contains: query, mode: "insensitive" } },
],
},
take: CONVERSATION_HIT_LIMIT,
}),
prisma.chatGroup.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
name: { contains: query, mode: "insensitive" },
},
take: CONVERSATION_HIT_LIMIT,
}),
prisma.artifact.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
groupId: null,
botId: { not: null },
name: { contains: query, mode: "insensitive" },
bot: { archivedAt: null },
},
include: { bot: { select: { name: true } } },
take: SEARCH_LIMIT,
}),
prisma.artifact.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
groupId: { not: null },
name: { contains: query, mode: "insensitive" },
},
include: { group: { select: { name: true } } },
take: SEARCH_LIMIT,
}),
prisma.routine.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
OR: [
{ name: { contains: query, mode: "insensitive" } },
{ prompt: { contains: query, mode: "insensitive" } },
],
bot: { archivedAt: null },
},
include: { bot: { select: { name: true } } },
take: SEARCH_LIMIT,
}),
]);
for (const bot of bots) {
pushInto(
conversationHits,
@ -91,14 +138,6 @@ export async function querySpaceSearch(
);
}
const groups = await prisma.chatGroup.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
name: { contains: query, mode: "insensitive" },
},
take: CONVERSATION_HIT_LIMIT,
});
for (const group of groups) {
pushInto(
conversationHits,
@ -114,27 +153,72 @@ export async function querySpaceSearch(
}
const contentBudget = Math.max(0, SEARCH_LIMIT - conversationHits.length);
const pattern = `%${query}%`;
const [botArtifactMessages, groupArtifactMessages, messageRows, groupMessageRows] =
await Promise.all([
findArtifactMessages(
prisma,
actor,
artifacts.flatMap((artifact) =>
artifact.botId && artifact.bot ? [{ id: artifact.id, targetId: artifact.botId }] : [],
),
"bot",
),
findArtifactMessages(
prisma,
actor,
groupArtifacts.flatMap((artifact) =>
artifact.groupId && artifact.group
? [{ id: artifact.id, targetId: artifact.groupId }]
: [],
),
"group",
),
prisma.$queryRaw<
Array<{
id: string;
threadId: string;
seq: number;
blocks: Prisma.JsonValue;
botId: string;
botName: string;
}>
>`
SELECT m.id, m."threadId", m.seq, m.blocks, b.id AS "botId", b.name AS "botName"
FROM messages m
INNER JOIN threads t ON t.id = m."threadId"
INNER JOIN bots b ON b.id = t."botId"
WHERE t."spaceId" = ${actor.spaceId}
AND t."userId" = ${actor.userId}
AND b."archivedAt" IS NULL
AND m.blocks::text ILIKE ${pattern}
ORDER BY m."createdAt" DESC
LIMIT ${SEARCH_LIMIT}
`,
prisma.$queryRaw<
Array<{
id: string;
threadId: string;
seq: number;
blocks: Prisma.JsonValue;
groupId: string;
groupName: string;
}>
>`
SELECT m.id, m."threadId", m.seq, m.blocks, g.id AS "groupId", g.name AS "groupName"
FROM messages m
INNER JOIN threads t ON t.id = m."threadId"
INNER JOIN chat_groups g ON g.id = t."groupId"
WHERE t."spaceId" = ${actor.spaceId}
AND t."userId" = ${actor.userId}
AND t."groupId" IS NOT NULL
AND m.blocks::text ILIKE ${pattern}
ORDER BY m."createdAt" DESC
LIMIT ${SEARCH_LIMIT}
`,
]);
const artifacts = await prisma.artifact.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
groupId: null,
botId: { not: null },
name: { contains: query, mode: "insensitive" },
bot: { archivedAt: null },
},
include: { bot: { select: { name: true } } },
take: SEARCH_LIMIT,
});
const botArtifactMessages = await findArtifactMessages(
prisma,
actor,
artifacts.flatMap((artifact) =>
artifact.botId && artifact.bot ? [{ id: artifact.id, targetId: artifact.botId }] : [],
),
"bot",
);
for (const artifact of artifacts) {
if (!artifact.botId || !artifact.bot) continue;
const message = botArtifactMessages.get(artifact.id);
@ -155,24 +239,6 @@ export async function querySpaceSearch(
);
}
const groupArtifacts = await prisma.artifact.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
groupId: { not: null },
name: { contains: query, mode: "insensitive" },
},
include: { group: { select: { name: true } } },
take: SEARCH_LIMIT,
});
const groupArtifactMessages = await findArtifactMessages(
prisma,
actor,
groupArtifacts.flatMap((artifact) =>
artifact.groupId && artifact.group ? [{ id: artifact.id, targetId: artifact.groupId }] : [],
),
"group",
);
for (const artifact of groupArtifacts) {
if (!artifact.groupId || !artifact.group) continue;
const message = groupArtifactMessages.get(artifact.id);
@ -193,19 +259,6 @@ export async function querySpaceSearch(
);
}
const routines = await prisma.routine.findMany({
where: {
spaceId: actor.spaceId,
userId: actor.userId,
OR: [
{ name: { contains: query, mode: "insensitive" } },
{ prompt: { contains: query, mode: "insensitive" } },
],
bot: { archivedAt: null },
},
include: { bot: { select: { name: true } } },
take: SEARCH_LIMIT,
});
for (const routine of routines) {
pushInto(
contentHits,
@ -221,29 +274,6 @@ export async function querySpaceSearch(
);
}
const pattern = `%${query}%`;
const messageRows = await prisma.$queryRaw<
Array<{
id: string;
threadId: string;
seq: number;
blocks: Prisma.JsonValue;
botId: string;
botName: string;
}>
>`
SELECT m.id, m."threadId", m.seq, m.blocks, b.id AS "botId", b.name AS "botName"
FROM messages m
INNER JOIN threads t ON t.id = m."threadId"
INNER JOIN bots b ON b.id = t."botId"
WHERE t."spaceId" = ${actor.spaceId}
AND t."userId" = ${actor.userId}
AND b."archivedAt" IS NULL
AND m.blocks::text ILIKE ${pattern}
ORDER BY m."createdAt" DESC
LIMIT ${SEARCH_LIMIT}
`;
for (const row of messageRows) {
pushMessageHits(row.blocks as MessageBlock[], {
botId: row.botId,
@ -255,28 +285,6 @@ export async function querySpaceSearch(
});
}
const groupMessageRows = await prisma.$queryRaw<
Array<{
id: string;
threadId: string;
seq: number;
blocks: Prisma.JsonValue;
groupId: string;
groupName: string;
}>
>`
SELECT m.id, m."threadId", m.seq, m.blocks, g.id AS "groupId", g.name AS "groupName"
FROM messages m
INNER JOIN threads t ON t.id = m."threadId"
INNER JOIN chat_groups g ON g.id = t."groupId"
WHERE t."spaceId" = ${actor.spaceId}
AND t."userId" = ${actor.userId}
AND t."groupId" IS NOT NULL
AND m.blocks::text ILIKE ${pattern}
ORDER BY m."createdAt" DESC
LIMIT ${SEARCH_LIMIT}
`;
for (const row of groupMessageRows) {
pushMessageHits(row.blocks as MessageBlock[], {
groupId: row.groupId,

View File

@ -90,7 +90,7 @@ describe("server update install kind", () => {
if (href.endsWith("/state")) {
return new Response(
JSON.stringify({
image: "ghcr.io/elie222/rakazo/app",
image: "ghcr.io/cynarai/evoo-local/app",
currentTag: "sha-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
previousTag: "sha-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
running: false,
@ -98,7 +98,7 @@ describe("server update install kind", () => {
present: true,
commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
branch: "main",
remoteUrl: "https://github.com/elie222/rakazo",
remoteUrl: "https://github.com/cynarAI/evOo-local",
dirty: false,
dirtyPaths: [],
},
@ -134,7 +134,7 @@ describe("server update install kind", () => {
fromTag: "sha-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
toTag: "sha-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
strategy: "pull",
repoUrl: "https://github.com/elie222/rakazo",
repoUrl: "https://github.com/cynarAI/evOo-local",
branch: "main",
restart: "not-required",
restartAdvice: "Recreate failed; prior image restored, env pin not restored.",
@ -149,7 +149,7 @@ describe("server update install kind", () => {
if (href.endsWith("/state")) {
return new Response(
JSON.stringify({
image: "ghcr.io/elie222/rakazo/app",
image: "ghcr.io/cynarai/evoo-local/app",
currentTag: "sha-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
previousTag: "sha-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
running: false,
@ -158,7 +158,7 @@ describe("server update install kind", () => {
present: true,
commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
branch: "main",
remoteUrl: "https://github.com/elie222/rakazo",
remoteUrl: "https://github.com/cynarAI/evOo-local",
dirty: false,
dirtyPaths: [],
},

View File

@ -713,7 +713,7 @@ export class TeamChatBridge {
const blocks = Array.isArray(response?.blocks) ? (response.blocks as MessageBlock[]) : [];
const content =
run.status === "failed"
? `${target.name} could not complete the delegated request. Open Rakazo for details.`
? `${target.name} could not complete the delegated request. Open evOo for details.`
: teamChatResponseText(blocks, target.name);
if (!content) continue;
await this.deps.send({
@ -1160,7 +1160,7 @@ export class TeamChatBridge {
await this.sendOnce(message.id, {
conversationId: message.externalConversation.conversationId,
replyThreadId: message.replyThreadId,
content: `${this.target?.name ?? "The agent"} could not complete that request. Open Rakazo for details.`,
content: `${this.target?.name ?? "The agent"} could not complete that request. Open evOo for details.`,
idempotencyKey: `external-message:${message.id}:failure`,
});
}

View File

@ -88,4 +88,77 @@ describe("model team chat engagement judge", () => {
expect(instructions).toContain("(Europe/Berlin, the user's time zone)");
expect(instructions).toContain("low-cost engagement judge");
});
it("prefers the space fast model tier when mapped and no explicit judge override is set", async () => {
let selectedModel: { provider: string; id: string; apiKey?: string } | undefined;
const runtime = {
run: vi.fn((request: { model: { provider: string; id: string; apiKey?: string } }) => {
selectedModel = request.model;
return (async function* () {
yield { type: "done" as const, text: '{"act":true,"reason":"Direct mention."}' };
})();
}),
} as unknown as AgentRuntime;
const prisma = {
deploymentSettings: { findUnique: vi.fn(async () => null) },
spaceModelPreference: { findFirst: vi.fn(async () => null) },
spaceModelTier: {
findUnique: vi.fn(async () => ({
tier: "fast",
credentialId: "cred-fast",
modelId: "gemma4:latest",
credential: {
id: "cred-fast",
userId: "user-1",
provider: "openai-compatible",
secretId: "sec-fast",
preferences: [{ isDefault: false, modelId: null }],
},
})),
},
secret: {
findFirst: vi.fn(async () => ({ id: "sec-fast", ciphertext: "cipher-fast" })),
},
userProfile: { findUnique: vi.fn(async () => ({ timeZone: "UTC" })) },
} as unknown as PrismaClient;
const secrets = {
load: vi.fn(() =>
JSON.stringify({
kind: "openai_compatible",
apiKey: "local-key",
baseUrl: "http://127.0.0.1:11434/v1",
}),
),
} as unknown as EncryptedSecretStore;
const judge = new ModelTeamChatEngagementJudge({
prisma,
runtime,
secrets,
deploymentProvider: "openrouter",
deploymentModel: "expensive-default",
});
const decision = await judge.decide({
bot: {
id: "bot-1",
spaceId: "space-1",
userId: "user-1",
name: "Arthur",
modelProvider: "anthropic",
modelId: "claude-opus",
},
channelId: "C1",
rules: "",
messages: [],
});
expect(decision).toEqual({ act: true, reason: "Direct mention.", askedByEventId: undefined });
expect(selectedModel).toMatchObject({
provider: "openai-compatible",
id: "gemma4:latest",
apiKey: "local-key",
baseUrl: "http://127.0.0.1:11434/v1",
});
});
});

View File

@ -1,14 +1,15 @@
import { randomUUID } from "node:crypto";
import type { AgentModelOAuthCredential, AgentRuntime } from "@rakazo/adapter-kit";
import type { EncryptedSecretStore } from "@rakazo/adapters";
import {
type EncryptedSecretStore,
formatCurrentTimeInstruction,
loadUserTimeZone,
resolveModelAuth,
serializeModelSecret,
toOAuthCredential,
} from "@rakazo/adapters";
import { findDefaultModelCredential, findModelCredential, type PrismaClient } from "@rakazo/db";
import type { PrismaClient } from "@rakazo/db";
import { findDefaultModelCredential, findModelCredential, findSpaceModelTier } from "@rakazo/db";
import { getLogger } from "@rakazo/logging";
const MAX_RULES_CHARS = 4_000;
@ -191,27 +192,39 @@ export class ModelTeamChatEngagementJudge implements TeamChatEngagementJudge {
};
};
} | null> {
const settings = await this.deps.prisma.deploymentSettings.findUnique({
where: { id: "default" },
});
const scope = { userId: bot.userId, spaceId: bot.spaceId };
const defaultCredential = await findDefaultModelCredential(this.deps.prisma, scope);
const explicitJudgeOverride = Boolean(this.deps.providerOverride && this.deps.modelOverride);
const fastTier =
!explicitJudgeOverride && this.deps.prisma.spaceModelTier
? await findSpaceModelTier(this.deps.prisma, scope, "fast")
: null;
const defaultCredential = fastTier
? null
: await findDefaultModelCredential(this.deps.prisma, scope);
const requestedProvider = this.deps.providerOverride ?? bot.modelProvider;
const requestedModel = this.deps.modelOverride ?? bot.modelId;
const overrideCredential = requestedProvider
? await findModelCredential(this.deps.prisma, scope, requestedProvider)
: null;
const explicitJudgeOverride = Boolean(this.deps.providerOverride && this.deps.modelOverride);
const overrideCredential =
!fastTier && requestedProvider
? await findModelCredential(this.deps.prisma, scope, requestedProvider)
: null;
const useOverride = Boolean(
requestedProvider && requestedModel && (explicitJudgeOverride || overrideCredential),
);
const credential = useOverride ? overrideCredential : defaultCredential;
const credential =
fastTier?.credential ?? (useOverride ? overrideCredential : defaultCredential);
const settings = fastTier
? null
: await this.deps.prisma.deploymentSettings.findUnique({
where: { id: "default" },
});
const provider =
fastTier?.credential.provider ??
(useOverride ? requestedProvider : null) ??
credential?.provider ??
settings?.defaultModelProvider ??
this.deps.deploymentProvider;
const modelId =
fastTier?.modelId ??
(useOverride ? requestedModel : null) ??
credential?.defaultModel ??
settings?.defaultModelId ??

View File

@ -0,0 +1,275 @@
import type { JobPublisher } from "@rakazo/adapter-kit";
import type { Actor, CardBlock, MessageBlock } from "@rakazo/contracts";
import { buildCardFromTool, buildSuggestionCard } from "@rakazo/core";
import { approvalEffectKey } from "@rakazo/core/node/approval-effect-key";
import type { PrismaClient, ThreadEvents } from "@rakazo/db";
import { createDb, provisionMessagingIdentity } from "@rakazo/db";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { actOnThreadCard } from "./thread-cards.js";
import type { ThreadTarget } from "./thread-target.js";
import { resolveThreadTarget } from "./thread-target.js";
const databaseUrl = process.env.DATABASE_URL;
const describePostgres =
process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip;
describePostgres("actOnThreadCard (PostgreSQL)", () => {
let prisma: PrismaClient;
let close: () => Promise<void>;
let identity: { userId: string; spaceId: string; botId: string; threadId: string };
let actor: Actor;
let target: ThreadTarget;
const notify = vi.fn(async () => undefined);
const enqueue = vi.fn(async () => undefined);
let deps: { prisma: PrismaClient; events: ThreadEvents; jobs: JobPublisher };
let seq = 0;
let nonce = 0;
beforeAll(async () => {
const db = createDb(databaseUrl!);
prisma = db.prisma;
close = async () => {
await db.prisma.$disconnect();
await db.pool.end();
};
identity = await provisionMessagingIdentity(
prisma,
{ provider: "sendblue", address: `+1556${Date.now().toString().slice(-7)}` },
{ signupsEnabled: undefined, signupAllowlist: undefined },
);
actor = {
userId: identity.userId,
spaceId: identity.spaceId,
email: "card@example.com",
isDeploymentOwner: false,
};
deps = {
prisma,
events: { notify } as unknown as ThreadEvents,
jobs: {
enqueue,
cancel: vi.fn(async () => undefined),
close: vi.fn(async () => undefined),
} as unknown as JobPublisher,
};
target = await resolveThreadTarget(prisma, actor, { botId: identity.botId });
seq =
(
await prisma.message.aggregate({
where: { threadId: identity.threadId },
_max: { seq: true },
})
)._max.seq ?? 0;
});
afterAll(async () => {
if (identity) {
await prisma.messagingIdentity.deleteMany({ where: { userId: identity.userId } });
await prisma.organization.deleteMany({ where: { id: identity.spaceId } });
await prisma.user.deleteMany({ where: { id: identity.userId } });
}
await close?.();
});
async function postCard(args: Parameters<typeof buildCardFromTool>[0]) {
const built = buildCardFromTool(args, `card${++seq}`);
if (!("ok" in built)) throw new Error(built.error);
const message = await prisma.message.create({
data: {
threadId: identity.threadId,
seq,
role: "bot",
botId: identity.botId,
blocks: [built.block],
},
});
return { messageId: message.id, cardId: built.block.id! };
}
async function card(messageId: string) {
const row = await prisma.message.findUniqueOrThrow({ where: { id: messageId } });
return (row.blocks as MessageBlock[])[0] as CardBlock;
}
const act = (input: Omit<Parameters<typeof actOnThreadCard>[3], "clientNonce">, again?: string) =>
actOnThreadCard(deps, actor, target, { ...input, clientNonce: again ?? `nonce-${++nonce}` });
it("ticks a checklist item without starting a run", async () => {
const posted = await postCard({ title: "Shopping", checklist: ["Milk", "Eggs"] });
await act({
...posted,
act: { type: "toggle", sectionId: "checklist", itemId: "i2", checked: true },
});
const updated = await card(posted.messageId);
expect(updated.sections?.[1]).toMatchObject({
items: [{ id: "i1" }, { id: "i2", checked: true }],
});
expect(await prisma.message.count({ where: { replyToMessageId: posted.messageId } })).toBe(0);
expect(
await prisma.event.count({
where: {
threadId: identity.threadId,
type: "thread.message.updated",
payload: { path: ["messageId"], equals: posted.messageId },
},
}),
).toBe(1);
});
it("answers a choice once and sends it to the bot as the user's reply", async () => {
const posted = await postCard({ title: "Dinner", options: ["Pasta", "Curry"] });
const pick = {
...posted,
act: { type: "select" as const, sectionId: "select", optionId: "o2" },
};
await act(pick, "pick-curry");
// A retried tap is a no-op rather than an error.
await act(pick, "pick-curry");
expect((await card(posted.messageId)).answer).toEqual({ text: "Curry" });
const replies = await prisma.message.findMany({
where: { replyToMessageId: posted.messageId },
select: { role: true, blocks: true, runId: true },
});
expect(replies).toHaveLength(1);
expect(replies[0]).toMatchObject({ role: "user", blocks: [{ kind: "text", text: "Curry" }] });
expect(replies[0]?.runId).toBeTruthy();
await expect(
act({ ...posted, act: { type: "select", sectionId: "select", optionId: "o1" } }),
).rejects.toThrow("already answered");
});
async function postSuggestion(createdAt?: Date) {
const task = await prisma.task.create({
data: {
spaceId: identity.spaceId,
botId: identity.botId,
threadId: identity.threadId,
userId: identity.userId,
prompt: "I should move more",
status: "completed",
},
});
const run = await prisma.run.create({
data: {
spaceId: identity.spaceId,
botId: identity.botId,
threadId: identity.threadId,
taskId: task.id,
userId: identity.userId,
status: "completed",
trigger: "user",
},
});
const call = {
tool: "schedule_create",
args: { name: "Morning walk", prompt: "Take a walk.", cron: "0 8 * * *" },
};
const effect = await prisma.externalEffect.create({
data: {
spaceId: identity.spaceId,
runId: run.id,
kind: "suggestion",
idempotencyKey: `suggestion:${run.id}:test`,
status: "proposed",
request: { ...call, botId: identity.botId, threadId: identity.threadId, subject: "x" },
...(createdAt ? { createdAt } : {}),
},
});
const block = buildSuggestionCard({ id: effect.id, effectId: effect.id, ...call });
const message = await prisma.message.create({
data: {
threadId: identity.threadId,
seq: ++seq,
role: "bot",
botId: identity.botId,
blocks: [block],
},
});
await prisma.externalEffect.update({
where: { id: effect.id },
data: {
request: {
...call,
botId: identity.botId,
threadId: identity.threadId,
subject: "x",
card: { threadId: identity.threadId, messageId: message.id },
},
},
});
return { messageId: message.id, cardId: effect.id, effectId: effect.id, ...call };
}
it("runs an accepted suggestion as an approved run, once", async () => {
const posted = await postSuggestion();
enqueue.mockClear();
const accept = {
messageId: posted.messageId,
cardId: posted.cardId,
act: { type: "action" as const, actionId: "accept" },
};
await act(accept, "accept-walk");
await act(accept, "accept-walk");
expect((await card(posted.messageId)).suggestion?.status).toBe("running");
const effect = await prisma.externalEffect.findUniqueOrThrow({
where: { id: posted.effectId },
});
expect(effect.status).toBe("accepted");
const runId = (effect.result as { runId: string }).runId;
const run = await prisma.run.findUniqueOrThrow({ where: { id: runId } });
expect(run).toMatchObject({ trigger: "suggestion", botId: identity.botId, status: "queued" });
const approved = await prisma.externalEffect.findMany({ where: { runId } });
expect(approved).toEqual([
expect.objectContaining({
kind: "schedule_create",
status: "approved",
idempotencyKey: approvalEffectKey(runId, "schedule_create", posted.args),
}),
]);
expect(enqueue).toHaveBeenCalledTimes(1);
// No reply goes to the bot; the card itself carries the outcome.
expect(await prisma.message.count({ where: { replyToMessageId: posted.messageId } })).toBe(0);
await expect(act({ ...accept, act: { type: "action", actionId: "dismiss" } })).rejects.toThrow(
"already settled",
);
});
it("dismisses a suggestion without running anything", async () => {
const posted = await postSuggestion();
await act({
messageId: posted.messageId,
cardId: posted.cardId,
act: { type: "action", actionId: "dismiss" },
});
expect((await card(posted.messageId)).suggestion?.status).toBe("dismissed");
const effect = await prisma.externalEffect.findUniqueOrThrow({
where: { id: posted.effectId },
});
expect(effect).toMatchObject({ status: "dismissed" });
expect((effect.result as { runId: unknown }).runId).toBeNull();
});
it("lets an old suggestion expire instead of running it", async () => {
const posted = await postSuggestion(new Date(Date.now() - 8 * 24 * 60 * 60 * 1000));
await act({
messageId: posted.messageId,
cardId: posted.cardId,
act: { type: "action", actionId: "accept" },
});
expect((await card(posted.messageId)).suggestion?.status).toBe("expired");
expect(
await prisma.externalEffect.findUniqueOrThrow({ where: { id: posted.effectId } }),
).toMatchObject({ status: "expired", result: null });
});
it("keeps other threads' cards out of reach", async () => {
const posted = await postCard({ title: "Elsewhere", checklist: ["One"] });
await expect(
act({
...posted,
cardId: "not-this-card",
act: { type: "toggle", sectionId: "checklist", itemId: "i1", checked: true },
}),
).rejects.toThrow();
});
});

View File

@ -0,0 +1,242 @@
import { ORPCError } from "@orpc/server";
import type { JobPublisher } from "@rakazo/adapter-kit";
import { runContinueJob } from "@rakazo/adapter-kit";
import type { Actor, CardAct, CardBlock, MessageBlock } from "@rakazo/contracts";
import { SUGGESTION_TTL_MS } from "@rakazo/contracts";
import type { CardActResult } from "@rakazo/core";
import { applyCardAct, withSuggestionStatus } from "@rakazo/core";
import { approvalEffectKey } from "@rakazo/core/node/approval-effect-key";
import type { Prisma, PrismaClient, ThreadEvents } from "@rakazo/db";
import { IsolationError, updateCardInTransaction } from "@rakazo/db";
import { getLogger } from "@rakazo/logging";
import type { ThreadTarget } from "./thread-target.js";
import { sendThreadMessage } from "./thread-target.js";
type CardDeps = { prisma: PrismaClient; events: ThreadEvents; jobs: JobPublisher };
/** What an accepted suggestion runs: the stored call, by the bot it was meant for. */
type StoredSuggestion = {
tool: string;
args: Record<string, unknown>;
botId: string;
threadId: string;
card?: { threadId: string; messageId: string };
};
function storedSuggestion(value: unknown): StoredSuggestion | null {
if (!value || typeof value !== "object") return null;
const request = value as Partial<StoredSuggestion>;
return typeof request.tool === "string" &&
typeof request.botId === "string" &&
typeof request.threadId === "string" &&
request.args &&
typeof request.args === "object"
? (request as StoredSuggestion)
: null;
}
function resultObject(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
async function loadCard(prisma: PrismaClient, threadId: string, messageId: string, cardId: string) {
const message = await prisma.message.findFirst({
where: { id: messageId, threadId, role: "bot" },
select: { blocks: true },
});
const blocks = (Array.isArray(message?.blocks) ? message.blocks : []) as MessageBlock[];
const card = blocks.find((block) => block.kind === "card" && block.id === cardId);
return card?.kind === "card" ? card : null;
}
/**
* Apply one tap on a bot's card. The card changes in place for everyone watching the thread; a
* choice or an action then goes to the card's bot as the user's reply, through the normal send.
* Accept or Dismiss on a suggestion runs or drops the stored call instead.
*/
export async function actOnThreadCard(
deps: CardDeps,
actor: Actor,
target: ThreadTarget,
input: { messageId: string; cardId: string; act: CardAct; clientNonce: string },
options: { requireModel?: () => Promise<void> } = {},
): Promise<{ ok: true }> {
// A retried answer already landed as the user's reply; the card was answered with it.
const replayed = await deps.prisma.message.findUnique({
where: { threadId_clientNonce: { threadId: target.threadId, clientNonce: input.clientNonce } },
select: { id: true },
});
if (replayed) return { ok: true };
const card = await loadCard(deps.prisma, target.threadId, input.messageId, input.cardId);
if (!card) throw new IsolationError();
if (card.suggestion && input.act.type === "action") {
return settleSuggestion(deps, actor, target, input, card.suggestion.effectId);
}
if (input.act.type !== "toggle") await options.requireModel?.();
let outcome: CardActResult | undefined;
let botId: string | null = null;
const updated = await deps.prisma.$transaction(async (tx) => {
const message = await tx.message.findFirst({
where: { id: input.messageId, threadId: target.threadId },
select: { botId: true },
});
botId = message?.botId ?? null;
return updateCardInTransaction(
tx,
{ threadId: target.threadId, messageId: input.messageId, cardId: input.cardId },
(current) => {
outcome = applyCardAct(current, input.act);
return outcome.ok && outcome.changed ? outcome.block : null;
},
);
});
if (!outcome) throw new IsolationError();
if (!outcome.ok) throw new ORPCError("CONFLICT", { message: outcome.error });
if (updated) await notify(deps.events, target.threadId, updated.seq);
if (outcome.reply) {
await sendThreadMessage(deps, actor, target, {
text: outcome.reply,
replyToMessageId: input.messageId,
clientNonce: input.clientNonce,
// In a group the answer goes to the bot that asked, not to whoever leads.
...(target.kind === "group" && botId
? { mentions: [{ kind: "bot" as const, id: botId }] }
: {}),
});
}
return { ok: true };
}
async function notify(events: ThreadEvents, threadId: string, seq: number) {
await events.notify(threadId, seq).catch((error) => {
getLogger().error("thread card realtime notification", error);
});
}
/**
* Accept runs the stored call as its own run of the bot it was meant for, already approved:
* accepting is the approval. Dismiss only closes the card. Neither needs a model.
*/
async function settleSuggestion(
deps: CardDeps,
actor: Actor,
target: ThreadTarget,
input: { messageId: string; cardId: string; act: CardAct; clientNonce: string },
effectId: string,
): Promise<{ ok: true }> {
const settled = await deps.prisma.$transaction(async (tx) => {
let outcome: CardActResult | undefined;
const effect = await tx.externalEffect.findUnique({ where: { id: effectId } });
const request = storedSuggestion(effect?.request);
if (
!effect ||
!request ||
effect.kind !== "suggestion" ||
effect.spaceId !== actor.spaceId ||
request.card?.messageId !== input.messageId
) {
throw new IsolationError();
}
// A retried tap that already went through.
if (resultObject(effect.result).clientNonce === input.clientNonce) return null;
const expired = Date.now() - effect.createdAt.getTime() > SUGGESTION_TTL_MS;
const updated = await updateCardInTransaction(
tx,
{ threadId: target.threadId, messageId: input.messageId, cardId: input.cardId },
(current: CardBlock) => {
if (expired && current.suggestion?.status === "proposed") {
return withSuggestionStatus(current, "expired");
}
outcome = applyCardAct(current, input.act);
return outcome.ok && outcome.changed ? outcome.block : null;
},
);
if (expired) {
await tx.externalEffect.updateMany({
where: { id: effect.id, status: "proposed" },
data: { status: "expired" },
});
return { seq: updated?.seq ?? null, runId: null };
}
if (!outcome) throw new IsolationError();
if (!outcome.ok) throw new ORPCError("CONFLICT", { message: outcome.error });
if (effect.status !== "proposed") {
throw new ORPCError("CONFLICT", { message: "This suggestion was already settled." });
}
const runId =
outcome.suggestion === "accept"
? await startAcceptedRun(tx, actor, effect.spaceId, request)
: null;
await tx.externalEffect.update({
where: { id: effect.id },
data: {
status: runId ? "accepted" : "dismissed",
result: { ...resultObject(effect.result), clientNonce: input.clientNonce, runId },
},
});
return { seq: updated?.seq ?? null, runId };
});
if (settled?.seq) await notify(deps.events, target.threadId, settled.seq);
if (settled?.runId) {
await deps.jobs.enqueue(runContinueJob(settled.runId)).catch((error) => {
getLogger().error("accepted suggestion enqueue", error);
});
}
return { ok: true };
}
async function startAcceptedRun(
tx: Prisma.TransactionClient,
actor: Actor,
spaceId: string,
request: StoredSuggestion,
): Promise<string> {
const thread = await tx.thread.findFirst({
where: { id: request.threadId, spaceId },
select: { id: true },
});
const bot = await tx.bot.findFirst({
where: { id: request.botId, spaceId },
select: { id: true },
});
if (!thread || !bot) {
throw new ORPCError("CONFLICT", { message: "The bot for this suggestion is gone." });
}
const task = await tx.task.create({
data: {
spaceId,
botId: bot.id,
threadId: thread.id,
userId: actor.userId,
prompt: `Accepted suggestion: ${request.tool}`,
status: "queued",
},
});
const run = await tx.run.create({
data: {
spaceId,
botId: bot.id,
threadId: thread.id,
taskId: task.id,
userId: actor.userId,
status: "queued",
trigger: "suggestion",
},
});
// The run replays this exact call; the approval gate sees it as already approved.
await tx.externalEffect.create({
data: {
spaceId,
runId: run.id,
kind: request.tool,
idempotencyKey: approvalEffectKey(run.id, request.tool, request.args),
status: "approved",
request: request.args as never,
},
});
return run.id;
}

View File

@ -0,0 +1,143 @@
import type { MessageBlock } from "@rakazo/contracts";
import type { PrismaClient } from "@rakazo/db";
import { createDb, provisionMessagingIdentity } from "@rakazo/db";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { loadMessagePage } from "./thread-message-pages.js";
const databaseUrl = process.env.DATABASE_URL;
const describePostgres =
process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip;
describePostgres("loadMessagePage (PostgreSQL)", () => {
let prisma: PrismaClient;
let close: () => Promise<void>;
let identity: { userId: string; spaceId: string; botId: string; threadId: string };
let userRunId: string;
let peerRunId: string;
let seq = 0;
beforeAll(async () => {
const db = createDb(databaseUrl!);
prisma = db.prisma;
close = async () => {
await db.prisma.$disconnect();
await db.pool.end();
};
identity = await provisionMessagingIdentity(
prisma,
{ provider: "sendblue", address: `+1555${Date.now().toString().slice(-7)}` },
{ signupsEnabled: undefined, signupAllowlist: undefined },
);
const { spaceId, botId, threadId, userId } = identity;
const owner = { spaceId, botId, threadId, userId };
const run = async (trigger: string) => {
const task = await prisma.task.create({
data: { ...owner, prompt: "fixture", status: "completed" },
});
const created = await prisma.run.create({
data: { ...owner, taskId: task.id, status: "completed", trigger },
});
return created.id;
};
userRunId = await run("user_message");
peerRunId = await run("bot_message");
seq =
(
await prisma.message.aggregate({
where: { threadId: identity.threadId },
_max: { seq: true },
})
)._max.seq ?? 0;
});
afterAll(async () => {
if (identity) {
await prisma.messagingIdentity.deleteMany({ where: { userId: identity.userId } });
await prisma.organization.deleteMany({ where: { id: identity.spaceId } });
await prisma.user.deleteMany({ where: { id: identity.userId } });
}
await close?.();
});
async function message(id: string, runId: string | null, blocks: MessageBlock[]) {
seq += 1;
await prisma.message.create({
data: {
id: `${identity.threadId}-${id}`,
threadId: identity.threadId,
seq,
role: "bot",
runId,
blocks,
},
});
return seq;
}
const ids = (page: Awaited<ReturnType<typeof loadMessagePage>>) =>
page.messages.map((row) => row.id.slice(identity.threadId.length + 1));
it("fills a page with visible rows across a long stretch of hidden peer activity", async () => {
await message("older", userRunId, [{ kind: "text", text: "Older answer" }]);
await message("newer", userRunId, [{ kind: "text", text: "Newer answer" }]);
for (let index = 0; index < 30; index += 1) {
await message(`peer-steps-${index}`, peerRunId, [
{ kind: "steps", steps: [{ label: "Working", count: 1 }] },
]);
}
await message("peer-receipt", peerRunId, [
{ kind: "bot_message_received", fromBotId: "bot-2", fromBotName: "Coder", text: "Done." },
]);
await message("peer-reply", peerRunId, [{ kind: "text", text: "Peer summary" }]);
await message("orphan-run", "run-that-no-longer-exists", [{ kind: "text", text: "Kept" }]);
const first = await loadMessagePage(prisma, identity.threadId, undefined, 3);
expect(ids(first)).toEqual(["peer-receipt", "peer-reply", "orphan-run"]);
const second = await loadMessagePage(prisma, identity.threadId, first.olderCursor!, 2);
expect(ids(second)).toEqual(["older", "newer"]);
const peerView = await loadMessagePage(
prisma,
identity.threadId,
undefined,
3,
undefined,
true,
);
expect(ids(peerView)).toEqual(["peer-receipt", "peer-reply", "orphan-run"]);
const peerOlder = await loadMessagePage(
prisma,
identity.threadId,
peerView.olderCursor!,
1,
undefined,
true,
);
expect(ids(peerOlder)).toEqual(["peer-steps-29"]);
});
it("skips a receipt-only page for web but returns it when receipts are displayed", async () => {
await message("before-receipts", userRunId, [{ kind: "text", text: "Answer" }]);
await message("sent", userRunId, [
{ kind: "bot_message_sent", toBotId: "bot-2", toBotName: "Coder", text: "Check this." },
]);
await message("received", peerRunId, [
{ kind: "bot_message_received", fromBotId: "bot-2", fromBotName: "Coder", text: "Done." },
]);
const web = await loadMessagePage(prisma, identity.threadId, undefined, 2);
expect(ids(web)).toEqual(["orphan-run", "before-receipts"]);
const mobile = await loadMessagePage(
prisma,
identity.threadId,
undefined,
2,
undefined,
false,
true,
);
expect(ids(mobile)).toEqual(["sent", "received"]);
});
});

View File

@ -43,98 +43,6 @@ describe("thread message pages", () => {
).toBe(false);
});
it("keeps peer receipt rows when filtering peer-run output from pages", async () => {
const findMany = vi.fn(async () => [
{
id: "message-reply",
threadId: "thread-1",
seq: 3,
role: "bot",
blocks: [{ kind: "text", text: "Echoed peer reply" }],
botId: "bot-1",
replyToMessageId: null,
runId: "run-peer",
createdAt: new Date("2026-08-16T00:00:03.000Z"),
},
{
id: "message-received",
threadId: "thread-1",
seq: 2,
role: "user",
blocks: [
{
kind: "bot_message_received",
fromBotId: "bot-2",
fromBotName: "Coder",
text: "Done.",
},
],
botId: null,
replyToMessageId: null,
runId: "run-peer",
createdAt: new Date("2026-08-16T00:00:02.000Z"),
},
{
id: "message-user",
threadId: "thread-1",
seq: 1,
role: "bot",
blocks: [{ kind: "text", text: "Visible answer" }],
botId: "bot-1",
replyToMessageId: null,
runId: "run-user",
createdAt: new Date("2026-08-16T00:00:01.000Z"),
},
]);
const prisma = {
message: { findMany },
run: { findMany: vi.fn(async () => [{ id: "run-peer" }]) },
} as unknown as PrismaClient;
const page = await loadMessagePage(prisma, "thread-1", undefined, 3);
expect(page.messages.map((message) => message.id)).toEqual([
"message-user",
"message-received",
"message-reply",
]);
});
it("filters peer-run activity when its receipt is outside the loaded page", async () => {
const findMany = vi.fn(async () => [
{
id: "message-peer",
threadId: "thread-1",
seq: 2,
role: "bot",
blocks: [{ kind: "steps", steps: [{ label: "Echoed peer reply", count: 1 }] }],
botId: "bot-1",
replyToMessageId: null,
runId: "run-peer",
createdAt: new Date("2026-08-16T00:00:02.000Z"),
},
{
id: "message-user",
threadId: "thread-1",
seq: 1,
role: "bot",
blocks: [{ kind: "text", text: "Visible answer" }],
botId: "bot-1",
replyToMessageId: null,
runId: "run-user",
createdAt: new Date("2026-08-16T00:00:01.000Z"),
},
]);
const prisma = {
message: { findMany },
run: { findMany: vi.fn(async () => [{ id: "run-peer" }]) },
} as unknown as PrismaClient;
const page = await loadMessagePage(prisma, "thread-1", undefined, 2);
expect(page.messages.map((message) => message.id)).toEqual(["message-user"]);
});
it("keeps peer text around-page targets and omits peer activity", async () => {
const findMany = vi.fn(async () => [
{
@ -281,45 +189,6 @@ describe("thread message pages", () => {
expect(page.messages.map((message) => message.id)).toEqual(["message-peer"]);
});
it("scans past a page containing only hidden peer-run activity", async () => {
const row = (seq: number, runId: string, kind: "text" | "steps" = "text") => ({
id: `message-${seq}`,
threadId: "thread-1",
seq,
role: "bot",
blocks:
kind === "steps"
? [{ kind: "steps", steps: [{ label: String(seq), count: 1 }] }]
: [{ kind: "text", text: String(seq) }],
botId: "bot-1",
replyToMessageId: null,
runId,
createdAt: new Date("2026-08-16T00:00:00.000Z"),
});
const findMany = vi
.fn()
.mockResolvedValueOnce([
row(4, "run-peer", "steps"),
row(3, "run-peer", "steps"),
row(2, "run-peer", "steps"),
])
.mockResolvedValueOnce([row(1, "run-user")]);
const prisma = {
message: { findMany },
run: {
findMany: vi
.fn()
.mockResolvedValueOnce([{ id: "run-peer" }])
.mockResolvedValueOnce([]),
},
} as unknown as PrismaClient;
const page = await loadMessagePage(prisma, "thread-1", undefined, 2);
expect(page.messages.map((message) => message.id)).toEqual(["message-1"]);
expect(findMany).toHaveBeenCalledTimes(2);
});
it("scans past a receipt-only page so web can reach older user-visible rows", async () => {
const receiptRows = [
{
@ -383,21 +252,13 @@ describe("thread message pages", () => {
createdAt: new Date("2026-08-16T00:00:01.000Z"),
},
];
const findMany = vi.fn().mockResolvedValueOnce(receiptRows).mockResolvedValueOnce(olderRows);
const prisma = {
message: { findMany },
run: {
findMany: vi
.fn()
.mockResolvedValueOnce([{ id: "run-peer" }])
.mockResolvedValueOnce([]),
},
} as unknown as PrismaClient;
const queryRaw = vi.fn().mockResolvedValueOnce(receiptRows).mockResolvedValueOnce(olderRows);
const prisma = { $queryRaw: queryRaw } as unknown as PrismaClient;
const page = await loadMessagePage(prisma, "thread-1", undefined, 2);
expect(page.messages.map((message) => message.id)).toEqual(["message-user"]);
expect(findMany).toHaveBeenCalledTimes(2);
expect(queryRaw).toHaveBeenCalledTimes(2);
});
it("returns a receipt-only page when the client displays peer receipts", async () => {
@ -419,11 +280,8 @@ describe("thread message pages", () => {
runId: "run-peer",
createdAt: new Date(`2026-08-16T00:00:0${seq}.000Z`),
});
const findMany = vi.fn(async () => [receipt(3), receipt(2), receipt(1)]);
const prisma = {
message: { findMany },
run: { findMany: vi.fn(async () => [{ id: "run-peer" }]) },
} as unknown as PrismaClient;
const queryRaw = vi.fn(async () => [receipt(3), receipt(2), receipt(1)]);
const prisma = { $queryRaw: queryRaw } as unknown as PrismaClient;
const page = await loadMessagePage(prisma, "thread-1", undefined, 2, undefined, false, true);
@ -432,10 +290,10 @@ describe("thread message pages", () => {
"message-receipt-3",
]);
expect(page.olderCursor).toBe(2);
expect(findMany).toHaveBeenCalledTimes(1);
expect(queryRaw).toHaveBeenCalledTimes(1);
});
it("queries before the cursor and returns an ascending bounded page", async () => {
it("queries the bot messages view before the cursor as an ascending bounded page", async () => {
const findMany = vi.fn(async () =>
[5, 4, 3].map((seq) => ({
id: `message-${seq}`,
@ -449,7 +307,7 @@ describe("thread message pages", () => {
);
const prisma = { message: { findMany } } as unknown as PrismaClient;
const page = await loadMessagePage(prisma, "thread-1", 6, 2);
const page = await loadMessagePage(prisma, "thread-1", 6, 2, undefined, true);
expect(findMany).toHaveBeenCalledWith({
where: { threadId: "thread-1", seq: { lt: 6 } },
@ -461,7 +319,7 @@ describe("thread message pages", () => {
});
it("ends pagination when the database returns no lookahead row", async () => {
const findMany = vi.fn(async () => [
const queryRaw = vi.fn(async () => [
{
id: "message-0",
threadId: "thread-1",
@ -472,7 +330,7 @@ describe("thread message pages", () => {
createdAt: new Date("2026-08-16T00:00:00.000Z"),
},
]);
const prisma = { message: { findMany } } as unknown as PrismaClient;
const prisma = { $queryRaw: queryRaw } as unknown as PrismaClient;
const page = await loadMessagePage(prisma, "thread-1", 1, 2);

View File

@ -1,9 +1,18 @@
import type { MessageBlock, ThreadMessage, ThreadMessagePage } from "@rakazo/contracts";
import { isPeerReceiptBlocks } from "@rakazo/core";
import type { Prisma, PrismaClient } from "@rakazo/db";
import type { PrismaClient } from "@rakazo/db";
import { Prisma } from "@rakazo/db";
type MessageDb = PrismaClient | Prisma.TransactionClient;
/** Peer-run rows that stay in the normal transcript: receipts, ask cards and the bot's own reply. */
const PEER_VISIBLE_BLOCK_KINDS: readonly string[] = [
"bot_message_sent",
"bot_message_received",
"ask",
"text",
];
export async function loadMessagePage(
prisma: MessageDb,
threadId: string,
@ -47,37 +56,69 @@ export async function loadMessagePage(
}
}
let cursor = before;
while (true) {
if (includePeerRuns) {
const rows = await prisma.message.findMany({
where: {
threadId,
...(cursor === undefined ? {} : { seq: { lt: cursor } }),
...(before === undefined ? {} : { seq: { lt: before } }),
},
orderBy: { seq: "desc" },
take: pageSize + 1,
});
const hasOlder = rows.length > pageSize;
const pageRows = rows.slice(0, pageSize).reverse();
const visibleRows = includePeerRuns ? pageRows : await withoutPeerRunMessages(prisma, pageRows);
return toPage(threadId, rows, pageSize);
}
let cursor = before;
while (true) {
const rows = await visibleMessageRows(prisma, threadId, cursor, pageSize + 1);
const page = toPage(threadId, rows, pageSize);
// Web hides receipts client-side, so its receipt-only pages keep scanning.
// Mobile explicitly retains them and must receive each page for pagination.
const hasSubstantive = visibleRows.some(
(row) => !isPeerReceiptBlocks(row.blocks as MessageBlock[]),
);
if (hasSubstantive || includePeerReceipts || !hasOlder || includePeerRuns) {
return {
threadId,
messages: visibleRows.map(toThreadMessage),
olderCursor: hasOlder ? (pageRows[0]?.seq ?? null) : null,
};
}
// TODO: only rescan when a raw page is entirely peer output. Consider a run relation if
// long peer-only histories make this path hot.
cursor = pageRows[0]?.seq;
const hasSubstantive = page.messages.some((message) => !isPeerReceiptBlocks(message.blocks));
if (hasSubstantive || includePeerReceipts || page.olderCursor === null) return page;
cursor = page.olderCursor;
}
}
type MessageRow = Parameters<typeof toThreadMessage>[0];
function toPage(threadId: string, rows: MessageRow[], pageSize: number): ThreadMessagePage {
const pageRows = rows.slice(0, pageSize).reverse();
return {
threadId,
messages: pageRows.map(toThreadMessage),
olderCursor: rows.length > pageSize ? (pageRows[0]?.seq ?? null) : null,
};
}
/**
* Newest-first transcript rows with peer-run activity excluded in the database, so a long
* peer-only stretch costs one query instead of a rescan per hidden page.
*/
function visibleMessageRows(
prisma: MessageDb,
threadId: string,
before: number | undefined,
limit: number,
): Promise<MessageRow[]> {
return prisma.$queryRaw<MessageRow[]>`
SELECT m.id, m."threadId", m.seq, m.role, m.blocks, m."botId", m."replyToMessageId",
m."replyQuote", m."runId", m."createdAt"
FROM messages m
LEFT JOIN runs r ON r.id = m."runId"
WHERE m."threadId" = ${threadId}
${before === undefined ? Prisma.empty : Prisma.sql`AND m.seq < ${before}`}
AND (r.trigger IS DISTINCT FROM 'bot_message' OR ${Prisma.join(
PEER_VISIBLE_BLOCK_KINDS.map(
(kind) => Prisma.sql`m.blocks @> ${JSON.stringify([{ kind }])}::jsonb`,
),
" OR ",
)})
ORDER BY m.seq DESC
LIMIT ${limit}
`;
}
export async function loadAllMessages(
prisma: PrismaClient,
threadId: string,
@ -106,15 +147,8 @@ async function withoutPeerRunMessages<T extends { runId: string | null; blocks:
const peerRunIds = new Set(peerRuns.map((run) => run.id));
return rows.filter((row) => {
if (!row.runId || !peerRunIds.has(row.runId)) return true;
// Keep peer receipts (chips), ask cards, and the bot's own text reply.
const blocks = row.blocks as MessageBlock[];
return blocks.some(
(block) =>
block.kind === "bot_message_sent" ||
block.kind === "bot_message_received" ||
block.kind === "ask" ||
block.kind === "text",
);
return blocks.some((block) => PEER_VISIBLE_BLOCK_KINDS.includes(block.kind));
});
}
@ -159,10 +193,8 @@ export function shouldForwardPeerThreadEvent(event: {
!!block &&
typeof block === "object" &&
"kind" in block &&
(block.kind === "bot_message_received" ||
block.kind === "bot_message_sent" ||
block.kind === "ask" ||
block.kind === "text"),
typeof block.kind === "string" &&
PEER_VISIBLE_BLOCK_KINDS.includes(block.kind),
)
);
}

View File

@ -2,15 +2,17 @@ import type { SandboxProvider } from "@rakazo/adapter-kit";
import type { Actor } from "@rakazo/contracts";
import type { PrismaClient } from "@rakazo/db";
import { describe, expect, it, vi } from "vitest";
import type { ThreadTarget } from "./thread-target.js";
import {
cancelSupersededQueuedRuns,
followUpGroupThreadMessage,
groupDefaultBotId,
reactToThreadMessage,
resolveGroupFollowUpBotId,
resumeThreadRuns,
sendQueuedThreadMessagesNow,
sendThreadMessage,
stopThreadRuns,
type ThreadTarget,
threadHead,
threadSnapshot,
} from "./thread-target.js";
@ -183,7 +185,7 @@ describe("threadSnapshot", () => {
},
]);
const tx = {
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
$queryRaw: threadQueries(),
message: { findMany: vi.fn().mockResolvedValue([]) },
event: {
findFirst: vi.fn().mockResolvedValue({ seq: 4 }),
@ -203,12 +205,21 @@ describe("threadSnapshot", () => {
const snapshot = await threadSnapshot({ prisma }, target);
expect(tx.$queryRaw).toHaveBeenCalledOnce();
expect(tx.$queryRaw).toHaveBeenCalledWith(
expect.arrayContaining([expect.stringContaining("FOR SHARE")]),
"thread-1",
);
expect(findManyEvents).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
type: {
in: ["thread.progress", "thread.subagent", "agent.tool.called", "agent.tool.completed"],
in: [
"thread.message.created",
"thread.progress",
"thread.subagent",
"agent.tool.called",
"agent.tool.completed",
],
},
}),
}),
@ -227,6 +238,69 @@ describe("threadSnapshot", () => {
]);
});
it("does not reload live narration that was already posted as a message", async () => {
const run = {
id: "run-1",
botId: "bot-1",
threadId: "thread-1",
taskId: "task-1",
status: "running",
trigger: "user",
modelProvider: null,
modelId: null,
error: null,
startedAt: null,
completedAt: null,
createdAt: new Date("2026-08-23T00:00:00.000Z"),
};
const at = new Date("2026-08-23T00:00:00.000Z");
const event = (seq: number, type: string, payload: Record<string, unknown>) => ({
id: `event-${seq}`,
threadId: "thread-1",
botId: "bot-1",
seq,
type,
runId: "run-1",
payload,
createdAt: at,
});
const tx = {
$queryRaw: threadQueries(),
message: { findMany: vi.fn().mockResolvedValue([]) },
event: {
findFirst: vi.fn().mockResolvedValue({ seq: 6 }),
findMany: vi.fn().mockResolvedValue([
event(4, "thread.progress", { text: "Opening the page." }),
event(5, "thread.message.created", {
messageId: "message-1",
role: "bot",
blocks: [{ kind: "text", text: "Opening the page." }],
}),
event(6, "thread.progress", { text: "Writing the summary." }),
]),
},
run: { findFirst: botRunFindFirst([run]) },
};
const prisma = {
$transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)),
} as unknown as PrismaClient;
const target = {
kind: "bot",
botId: "bot-1",
threadId: "thread-1",
bot: { computer: null },
} as ThreadTarget;
const snapshot = await threadSnapshot({ prisma }, target);
expect(snapshot.messages).toEqual([
expect.objectContaining({
id: "progress:run-1",
blocks: [{ kind: "progress", text: "Writing the summary." }],
}),
]);
});
it("returns the latest failed run so the client can show its error", async () => {
const run = {
id: "run-failed",
@ -245,7 +319,7 @@ describe("threadSnapshot", () => {
const findManyEvents = vi.fn();
const findFirstRun = botRunFindFirst([run]);
const tx = {
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
$queryRaw: threadQueries(),
message: { findMany: vi.fn().mockResolvedValue([]) },
event: {
findFirst: vi.fn().mockResolvedValue(null),
@ -330,7 +404,7 @@ describe("threadSnapshot", () => {
prisma: {
$transaction: vi.fn(async (callback: (client: unknown) => unknown) =>
callback({
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
$queryRaw: threadQueries(),
message: { findMany: vi.fn().mockResolvedValue([]) },
event: {
findFirst: vi.fn().mockResolvedValue(null),
@ -385,7 +459,7 @@ describe("threadSnapshot", () => {
};
const findFirstRun = botRunFindFirst([failed, completed]);
const tx = {
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
$queryRaw: threadQueries(),
message: { findMany: vi.fn().mockResolvedValue([]) },
event: {
findFirst: vi.fn().mockResolvedValue(null),
@ -421,7 +495,7 @@ describe("threadSnapshot", () => {
const findManyEvents = vi.fn();
const findFirstRun = botRunFindFirst([]);
const tx = {
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
$queryRaw: threadQueries(),
message: { findMany: vi.fn().mockResolvedValue([]) },
event: {
findFirst: vi.fn().mockResolvedValue(null),
@ -925,9 +999,16 @@ function groupRunFindMany(input: { active?: unknown[]; terminals?: unknown[] })
);
}
/** The snapshot's thread lock finds the thread; its message page query finds no rows. */
function threadQueries() {
return vi.fn(async (sql: TemplateStringsArray) =>
sql.join("").includes("FOR SHARE") ? [{ id: "thread-1" }] : [],
);
}
function groupPrisma(findManyRuns: ReturnType<typeof groupRunFindMany>) {
const tx = {
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
$queryRaw: threadQueries(),
message: { findMany: vi.fn().mockResolvedValue([]) },
event: {
findFirst: vi.fn().mockResolvedValue(null),
@ -951,10 +1032,13 @@ function groupTarget() {
}
describe("sendThreadMessage", () => {
it("rejects a new bot message while a run is waiting on input", async () => {
it("steers a waiting-input run instead of refusing the message", async () => {
const tx = {
thread: {
update: vi.fn().mockResolvedValue({ nextMessageSeq: 2 }),
update: vi
.fn()
.mockResolvedValueOnce({ nextMessageSeq: 2 })
.mockResolvedValueOnce({ nextEventSeq: 3 }),
},
message: {
create: vi.fn().mockResolvedValue({
@ -962,7 +1046,7 @@ describe("sendThreadMessage", () => {
threadId: "thread-1",
seq: 1,
role: "user",
blocks: [{ kind: "text", text: "hi" }],
blocks: [{ kind: "text", text: "more context" }],
botId: null,
replyToMessageId: null,
runId: null,
@ -974,9 +1058,13 @@ describe("sendThreadMessage", () => {
findMany: vi
.fn()
.mockResolvedValue([{ id: "run-waiting", taskId: "task-1", status: "waiting_input" }]),
findUnique: vi.fn().mockResolvedValue({
status: "waiting_input",
startedAt: new Date(),
}),
},
steeringMessage: { create: vi.fn() },
event: { create: vi.fn() },
event: { create: vi.fn().mockResolvedValue({ seq: 2 }) },
task: { create: vi.fn() },
};
const prisma = {
@ -990,26 +1078,37 @@ describe("sendThreadMessage", () => {
threadId: "thread-1",
bot: { computer: null },
} as ThreadTarget;
const enqueue = vi.fn().mockResolvedValue(undefined);
await expect(
sendThreadMessage(
{
prisma,
events: { notify: vi.fn() } as never,
jobs: { enqueue: vi.fn() } as never,
events: { notify: vi.fn().mockResolvedValue(undefined) } as never,
jobs: { enqueue } as never,
},
actor,
target,
{
text: "hi",
text: "more context",
clientNonce: "nonce-1",
},
),
).rejects.toMatchObject({
code: "CONFLICT",
message: "Answer the pending ask first.",
).resolves.toMatchObject({
runId: "run-waiting",
taskId: "task-1",
seq: 1,
runIds: ["run-waiting"],
});
expect(tx.steeringMessage.create).not.toHaveBeenCalled();
expect(tx.steeringMessage.create).toHaveBeenCalledWith({
data: {
messageId: "msg-1",
botId: "bot-1",
userId: "user-1",
runId: "run-waiting",
},
});
expect(enqueue).toHaveBeenCalledWith(expect.objectContaining({ name: "run.continue" }));
});
it("steers a waiting-takeover run instead of refusing the message", async () => {
@ -2021,4 +2120,192 @@ describe("stopThreadRuns", () => {
expect.objectContaining({ runId: "run-b" }),
);
});
it("sendQueuedThreadMessagesNow cancels the active run, preserves pending steering, and enqueues an immediate continuation run", async () => {
const queuedCreatedAt = new Date("2026-01-01T10:00:05.000Z");
const transaction = {
$queryRaw: vi.fn(),
steeringMessage: {
findMany: vi.fn().mockResolvedValue([
{
id: "steer-1",
botId: "bot-a",
userId: "user-1",
messageId: "msg-queued-1",
claimedAt: null,
message: {
id: "msg-queued-1",
blocks: [{ kind: "text", text: "Send this right now" }],
seq: 2,
createdAt: queuedCreatedAt,
},
},
]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
},
run: {
updateManyAndReturn: vi.fn().mockResolvedValue([{ id: "run-old", taskId: "task-old" }]),
create: vi.fn().mockResolvedValue({ id: "run-new", status: "queued" }),
},
task: {
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
create: vi.fn().mockResolvedValue({ id: "task-new" }),
},
message: {
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
},
computerExecutionLease: {
findMany: vi.fn().mockResolvedValue([]),
},
computer: {
findMany: vi.fn().mockResolvedValue([]),
},
event: {
findFirst: vi.fn().mockResolvedValue({ seq: 42 }),
},
};
const prisma = {
$transaction: vi.fn(async (callback: (client: typeof transaction) => unknown) =>
callback(transaction),
),
computerExecutionLease: {
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
computer: {
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
event: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
} as unknown as PrismaClient;
const jobs = {
enqueue: vi.fn().mockResolvedValue(undefined),
cancel: vi.fn().mockResolvedValue(undefined),
};
const events = {
notify: vi.fn().mockResolvedValue(undefined),
};
const actor = { spaceId: "workspace-1", userId: "user-1" } as Actor;
const target = {
kind: "bot",
botId: "bot-a",
threadId: "thread-1",
bot: {} as never,
} satisfies ThreadTarget;
const result = await sendQueuedThreadMessagesNow(
{
prisma,
sandbox: {} as SandboxProvider,
events: events as never,
jobs: jobs as never,
},
actor,
target,
{ messageId: "msg-queued-1" },
);
expect(result).toEqual({ ok: true, runIds: ["run-new"] });
expect(transaction.run.updateManyAndReturn).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
threadId: "thread-1",
botId: { in: ["bot-a"] },
}),
data: expect.objectContaining({ status: "cancelled" }),
}),
);
expect(transaction.steeringMessage.updateMany).toHaveBeenCalledWith({
where: { id: { in: ["steer-1"] } },
data: { runId: "run-new", claimedAt: null },
});
expect(jobs.cancel).toHaveBeenCalledWith("run:run-old");
expect(jobs.enqueue).toHaveBeenCalledWith({
name: "run.continue",
payload: { runId: "run-new" },
replaceKey: "run:run-new",
});
expect(events.notify).toHaveBeenCalledWith("thread-1", 42);
});
});
describe("resumeThreadRuns", () => {
it("re-queues the latest cancelled or failed run without creating a duplicate message", async () => {
const transaction = {
$queryRaw: vi.fn().mockResolvedValue([{ id: "thread-1" }]),
run: {
findMany: vi
.fn()
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{
id: "run-cancelled",
taskId: "task-cancelled",
botId: "bot-a",
status: "cancelled",
createdAt: new Date("2026-04-20T10:00:00.000Z"),
completedAt: new Date("2026-04-20T10:02:00.000Z"),
},
]),
update: vi.fn().mockResolvedValue({}),
},
task: {
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
},
event: {
findFirst: vi.fn().mockResolvedValue({ seq: 19 }),
},
};
const prisma = {
$transaction: vi.fn(async (fn: (tx: typeof transaction) => Promise<unknown>) =>
fn(transaction),
),
} as unknown as PrismaClient;
const jobs = {
enqueue: vi.fn().mockResolvedValue(undefined),
};
const events = {
notify: vi.fn().mockResolvedValue(undefined),
};
const actor = { spaceId: "workspace-1", userId: "user-1" } as Actor;
const target = {
kind: "bot",
botId: "bot-a",
threadId: "thread-1",
bot: {} as never,
} satisfies ThreadTarget;
const result = await resumeThreadRuns(
{
prisma,
events: events as never,
jobs: jobs as never,
},
actor,
target,
);
expect(result).toEqual({ ok: true, runIds: ["run-cancelled"] });
expect(transaction.task.updateMany).toHaveBeenCalledWith({
where: { id: "task-cancelled" },
data: { status: "queued" },
});
expect(transaction.run.update).toHaveBeenCalledWith({
where: { id: "run-cancelled" },
data: {
status: "queued",
error: null,
completedAt: null,
leaseOwner: null,
leaseExpiresAt: null,
},
});
expect(jobs.enqueue).toHaveBeenCalledWith({
name: "run.continue",
payload: { runId: "run-cancelled" },
replaceKey: "run:run-cancelled",
});
expect(events.notify).toHaveBeenCalledWith("thread-1", 19);
});
});

View File

@ -1,5 +1,10 @@
import { ORPCError } from "@orpc/server";
import { type JobPublisher, runContinueJob, type SandboxProvider } from "@rakazo/adapter-kit";
import {
type JobPublisher,
runContinueJob,
runJobKey,
type SandboxProvider,
} from "@rakazo/adapter-kit";
import { cancelComputerRunWork, screenLeaseIdForRun, toComputerRef } from "@rakazo/adapters";
import {
type Actor,
@ -160,9 +165,15 @@ function quoteAppearsInBlocks(quote: string, blocks: MessageBlock[]): boolean {
}
const THREAD_MESSAGE_PAGE_SIZE = 100;
const RUNS_NEEDING_CONTINUE = new Set(["queued", "waiting_takeover"]);
const RUNS_NEEDING_CONTINUE = new Set(["queued", "waiting_input", "waiting_takeover"]);
const STEERABLE_RUN_STATUSES = new Set(["queued", "leased", "running", "waiting_takeover"]);
const STEERABLE_RUN_STATUSES = new Set([
"queued",
"leased",
"running",
"waiting_input",
"waiting_takeover",
]);
type MentionTargetInput = string | { kind: "bot" | "group" | "routine" | "connector"; id: string };
@ -491,6 +502,7 @@ export async function threadSnapshot(
runId: currentRun.id,
type: {
in: [
"thread.message.created",
"thread.progress",
"thread.subagent",
"agent.tool.called",
@ -556,6 +568,7 @@ export async function threadSnapshot(
runId: { in: activeRuns.map((run) => run.id) },
type: {
in: [
"thread.message.created",
"thread.progress",
"thread.subagent",
"agent.tool.called",
@ -631,8 +644,12 @@ function messagesWithLiveEvents(
persisted: ThreadSnapshot["messages"],
liveEvents: Parameters<typeof projectMessages>[0],
) {
// Message events only clear the live text they promoted; the persisted rows already carry them.
const live = projectMessages(liveEvents).filter((message) => {
if (message.blocks.some((block) => block.kind === "progress" || block.kind === "steps")) {
if (
message.id.startsWith("progress:") &&
message.blocks.some((block) => block.kind === "progress" || block.kind === "steps")
) {
return true;
}
if (!message.id.startsWith("subagent:")) return false;
@ -1139,63 +1156,50 @@ export async function reactToThreadMessage(
});
}
export async function stopThreadRuns(
async function snapshotCancelledRunTeardown(tx: Prisma.TransactionClient, runIds: string[]) {
// Snapshot teardown coordinates before commit. Once cancellation becomes
// visible, a worker can release its lease / execution columns immediately; a
// later lookup would then miss the sandbox work this request must stop.
// Team ownership lives on ComputerExecutionLease; Computer.executionRunId is
// only a legacy secondary path and is not written by current acquisition.
const leases = runIds.length
? await tx.computerExecutionLease.findMany({
where: { runId: { in: runIds } },
select: { computerId: true, botId: true, runId: true, fence: true },
})
: [];
const leaseComputerIds = [...new Set(leases.map((lease) => lease.computerId))];
const computers = runIds.length
? await tx.computer.findMany({
where:
leaseComputerIds.length > 0
? {
OR: [{ id: { in: leaseComputerIds } }, { executionRunId: { in: runIds } }],
}
: { executionRunId: { in: runIds } },
select: {
id: true,
homeKey: true,
kind: true,
providerRef: true,
executionBotId: true,
executionRunId: true,
},
})
: [];
return { runIds, computers, leases };
}
async function teardownCancelledRuns(
deps: {
prisma: PrismaClient;
sandbox: SandboxProvider;
},
actor: Actor,
target: ThreadTarget,
snapshot: Awaited<ReturnType<typeof snapshotCancelledRunTeardown>>,
) {
const { runIds, computers, leases } = await deps.prisma.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM threads WHERE id = ${target.threadId} FOR UPDATE`;
const cancelled = await tx.run.updateManyAndReturn({
where: {
threadId: target.threadId,
status: { in: [...ACTIVE_RUN_STATUSES] },
},
data: { status: "cancelled", completedAt: new Date() },
select: { id: true },
});
const ids = cancelled.map((run) => run.id);
await tx.steeringMessage.deleteMany({
where: {
botId: { in: target.kind === "bot" ? [target.botId] : target.memberBotIds },
message: { threadId: target.threadId },
},
});
// Snapshot teardown coordinates before commit. Once cancellation becomes
// visible, a worker can release its lease / execution columns immediately; a
// later lookup would then miss the sandbox work this request must stop.
// Team ownership lives on ComputerExecutionLease; Computer.executionRunId is
// only a legacy secondary path and is not written by current acquisition.
const leases = ids.length
? await tx.computerExecutionLease.findMany({
where: { runId: { in: ids } },
select: { computerId: true, botId: true, runId: true, fence: true },
})
: [];
const leaseComputerIds = [...new Set(leases.map((lease) => lease.computerId))];
const computers = ids.length
? await tx.computer.findMany({
where:
leaseComputerIds.length > 0
? {
OR: [{ id: { in: leaseComputerIds } }, { executionRunId: { in: ids } }],
}
: { executionRunId: { in: ids } },
select: {
id: true,
homeKey: true,
kind: true,
providerRef: true,
executionBotId: true,
executionRunId: true,
},
})
: [];
return { runIds: ids, computers, leases };
});
const { runIds, computers, leases } = snapshot;
if (runIds.length === 0) return;
// Keep the DB lease until after teardown so a replacement run cannot claim the
// screen while we still need the cancelled run's screenLeaseId to release it.
const computerById = new Map(computers.map((computer) => [computer.id, computer]));
@ -1273,6 +1277,381 @@ export async function stopThreadRuns(
});
}
export async function stopThreadRuns(
deps: {
prisma: PrismaClient;
sandbox: SandboxProvider;
},
actor: Actor,
target: ThreadTarget,
) {
const snapshot = await deps.prisma.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM threads WHERE id = ${target.threadId} FOR UPDATE`;
const cancelled = await tx.run.updateManyAndReturn({
where: {
threadId: target.threadId,
status: { in: [...ACTIVE_RUN_STATUSES] },
},
data: { status: "cancelled", completedAt: new Date() },
select: { id: true },
});
const ids = cancelled.map((run) => run.id);
await tx.steeringMessage.deleteMany({
where: {
botId: { in: target.kind === "bot" ? [target.botId] : target.memberBotIds },
message: { threadId: target.threadId },
},
});
return snapshotCancelledRunTeardown(tx, ids);
});
await teardownCancelledRuns(deps, actor, snapshot);
}
/**
* Resume the latest stopped (`cancelled`) or interrupted (`failed`) run in the thread
* without creating a duplicate user message row.
*/
export async function resumeThreadRuns(
deps: {
prisma: PrismaClient;
events: ThreadEvents;
jobs: JobPublisher;
},
actor: Actor,
target: ThreadTarget,
) {
const botIds = target.kind === "bot" ? [target.botId] : target.memberBotIds;
const committed = await deps.prisma.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM threads WHERE id = ${target.threadId} FOR UPDATE`;
const activeRuns = await tx.run.findMany({
where: {
threadId: target.threadId,
botId: { in: botIds },
status: { in: [...ACTIVE_RUN_STATUSES] },
},
select: { id: true, status: true },
});
if (activeRuns.length > 0) {
return {
runs: activeRuns,
eventSeq: null as number | null,
};
}
const recentTerminals = await tx.run.findMany({
where: {
threadId: target.threadId,
botId: { in: botIds },
trigger: { not: "bot_message" },
status: { in: ["failed", "completed", "cancelled"] },
},
orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
take: 20,
select: {
id: true,
botId: true,
taskId: true,
status: true,
createdAt: true,
completedAt: true,
},
});
const latestTerminal = pickLatestTerminalRun(recentTerminals);
if (
!latestTerminal ||
(latestTerminal.status !== "cancelled" && latestTerminal.status !== "failed")
) {
return {
runs: [] as Array<{ id: string; status: string }>,
eventSeq: null as number | null,
};
}
await tx.task.updateMany({
where: { id: latestTerminal.taskId },
data: { status: "queued" },
});
await tx.run.update({
where: { id: latestTerminal.id },
data: {
status: "queued",
error: null,
completedAt: null,
leaseOwner: null,
leaseExpiresAt: null,
},
});
if (target.kind === "group") {
await touchGroupUpdatedAt(tx, target.groupId);
}
const latestEvent = await tx.event.findFirst({
where: { threadId: target.threadId },
orderBy: { seq: "desc" },
select: { seq: true },
});
return {
runs: [{ id: latestTerminal.id, status: "queued" }],
eventSeq: latestEvent?.seq ?? null,
};
});
if (committed.eventSeq !== null) {
await deps.events.notify(target.threadId, committed.eventSeq).catch((error) => {
getLogger().error("thread resume realtime notification", error);
});
}
await enqueueRunsNeedingContinue(deps.jobs, committed.runs);
return {
ok: true as const,
runIds: committed.runs.map((run) => run.id),
};
}
/**
* Promote queued follow-up messages immediately: cancel any currently active runs
* holding up the queue without discarding unclaimed steering messages, create a
* new continuation run for the pending steering messages, and enqueue it right away.
*/
export async function sendQueuedThreadMessagesNow(
deps: {
prisma: PrismaClient;
sandbox: SandboxProvider;
events: ThreadEvents;
jobs: JobPublisher;
},
actor: Actor,
target: ThreadTarget,
input?: { messageId?: string },
) {
const botIds = target.kind === "bot" ? [target.botId] : target.memberBotIds;
const committed = await deps.prisma.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM threads WHERE id = ${target.threadId} FOR UPDATE`;
const pendingSteering = await tx.steeringMessage.findMany({
where: {
botId: { in: botIds },
claimedAt: null,
message: { threadId: target.threadId },
},
include: {
message: { select: { id: true, blocks: true, seq: true, createdAt: true } },
},
orderBy: [{ message: { seq: "asc" } }, { id: "asc" }],
});
const pendingByBotId = new Map<string, typeof pendingSteering>();
for (const item of pendingSteering) {
const list = pendingByBotId.get(item.botId) ?? [];
list.push(item);
pendingByBotId.set(item.botId, list);
}
if (pendingByBotId.size === 0 && input?.messageId) {
const targetMsg = await tx.message.findFirst({
where: { id: input.messageId, threadId: target.threadId, role: "user" },
select: { id: true, blocks: true, seq: true, createdAt: true },
});
if (targetMsg) {
const activeRuns = await tx.run.findMany({
where: {
threadId: target.threadId,
botId: { in: botIds },
status: { in: [...ACTIVE_RUN_STATUSES] },
createdAt: { lt: targetMsg.createdAt },
},
select: { id: true, botId: true },
});
const fallbackBotId =
activeRuns[0]?.botId ??
(target.kind === "bot" ? target.botId : groupDefaultBotId(target));
if (fallbackBotId && activeRuns.length > 0) {
const steering = await tx.steeringMessage.create({
data: {
messageId: targetMsg.id,
botId: fallbackBotId,
userId: actor.userId,
runId: activeRuns[0]?.id ?? null,
},
});
pendingByBotId.set(fallbackBotId, [{ ...steering, message: targetMsg }]);
}
}
}
if (pendingByBotId.size === 0) {
return {
teardown: { runIds: [] as string[], computers: [], leases: [] },
runs: [] as Array<{ id: string; status: string }>,
eventSeq: null as number | null,
};
}
const affectedBotIds = [...pendingByBotId.keys()];
const now = new Date();
const cancelled = await tx.run.updateManyAndReturn({
where: {
threadId: target.threadId,
botId: { in: affectedBotIds },
status: { in: [...ACTIVE_RUN_STATUSES] },
},
data: { status: "cancelled", completedAt: now },
select: { id: true, taskId: true },
});
const cancelledRunIds = cancelled.map((run) => run.id);
if (cancelled.length > 0) {
await tx.task.updateMany({
where: { id: { in: cancelled.map((run) => run.taskId) } },
data: { status: "cancelled" },
});
await tx.steeringMessage.deleteMany({
where: {
runId: { in: cancelledRunIds },
claimedAt: { not: null },
},
});
}
const teardown = await snapshotCancelledRunTeardown(tx, cancelledRunIds);
const createdRuns: Array<{ id: string; status: string }> = [];
for (const [botId, items] of pendingByBotId) {
const last = items.at(-1)!;
const task = await tx.task.create({
data: {
spaceId: actor.spaceId,
botId,
threadId: target.threadId,
userId: items[0]!.userId,
prompt: "Respond to the user's steering context.",
status: "queued",
},
});
const runCreatedAt = new Date(Math.max(Date.now(), last.message.createdAt.getTime() + 1));
const run = await tx.run.create({
data: {
spaceId: actor.spaceId,
botId,
threadId: target.threadId,
taskId: task.id,
userId: items[0]!.userId,
status: "queued",
trigger: "follow_up",
sourceMessageId: last.message.id,
createdAt: runCreatedAt,
},
select: { id: true, status: true },
});
await tx.steeringMessage.updateMany({
where: { id: { in: items.map((item) => item.id) } },
data: { runId: run.id, claimedAt: null },
});
await tx.message.updateMany({
where: { id: { in: items.map((item) => item.message.id) } },
data: { runId: run.id },
});
createdRuns.push(run);
}
if (target.kind === "group") {
await touchGroupUpdatedAt(tx, target.groupId);
}
const latestEvent = await tx.event.findFirst({
where: { threadId: target.threadId },
orderBy: { seq: "desc" },
select: { seq: true },
});
return {
teardown,
runs: createdRuns,
eventSeq: latestEvent?.seq ?? null,
};
});
if (committed.teardown.runIds.length > 0) {
await Promise.all([
teardownCancelledRuns(deps, actor, committed.teardown),
...committed.teardown.runIds.map((runId) =>
deps.jobs.cancel(runJobKey(runId)).catch(() => undefined),
),
]);
}
if (committed.runs.length > 0) {
await enqueueRunsNeedingContinue(deps.jobs, committed.runs);
if (committed.eventSeq != null) {
await deps.events.notify(target.threadId, committed.eventSeq).catch((error) => {
getLogger().error("thread sendNow realtime notification", error);
});
}
}
return {
ok: true as const,
runIds: committed.runs.map((run) => run.id),
};
}
export async function removeQueuedMessage(
deps: {
prisma: PrismaClient;
events: ThreadEvents;
},
actor: Actor,
target: ThreadTarget,
messageId: string,
) {
const botIds = target.kind === "bot" ? [target.botId] : target.memberBotIds;
const result = await deps.prisma.$transaction(async (tx) => {
// Verify the message exists and belongs to this thread
const message = await tx.message.findFirst({
where: {
id: messageId,
threadId: target.threadId,
role: "user",
},
});
if (!message) {
return { removed: false as const };
}
// Check if this message is queued (has an unclaimed steering message)
const steering = await tx.steeringMessage.findFirst({
where: {
messageId: messageId,
botId: { in: botIds },
claimedAt: null,
},
});
if (!steering) {
// No queued steering entry — nothing to remove
return { removed: false as const };
}
// Delete the steering message (unclaims it from the queue)
await tx.steeringMessage.delete({
where: { id: steering.id },
});
// Also clean up the user message if it's exclusively queued
// (no run attached to it)
if (!message.runId) {
await tx.message.delete({
where: { id: messageId },
});
}
return { removed: true as const };
});
if (result.removed) {
// Notify subscribers so the UI updates
const latest = await deps.prisma.event.findFirst({
where: { threadId: target.threadId },
orderBy: { seq: "desc" },
select: { seq: true },
});
await deps.events.notify(target.threadId, latest?.seq ?? 0).catch(() => undefined);
}
return { ok: true as const, removed: result.removed };
}
export async function setThreadUnreadState(
prisma: PrismaClient,
actor: Actor,

View File

@ -1,96 +0,0 @@
import { TRADING_BOT_PROXY_PATH } from "@rakazo/core";
import { Hono } from "hono";
import { describe, expect, it, vi } from "vitest";
import { mountTradingBotStatus } from "./trading-bot-proxy.js";
function proxyApp(options: {
baseUrl?: string;
authorize?: (request: Request) => Promise<boolean>;
fetchImpl?: typeof fetch;
}) {
const app = new Hono();
mountTradingBotStatus(app, {
baseUrl: options.baseUrl,
authorize: options.authorize ?? (async () => true),
fetchImpl: options.fetchImpl,
});
return app;
}
describe("trading bot status proxy", () => {
it("stays idle when TRADING_BOT_URL is unset and does not call upstream", async () => {
const fetchImpl = vi.fn();
const response = await proxyApp({ fetchImpl }).request(TRADING_BOT_PROXY_PATH);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ configured: false });
expect(fetchImpl).not.toHaveBeenCalled();
});
it("requires a session before it proxies a configured origin", async () => {
const fetchImpl = vi.fn();
const response = await proxyApp({
baseUrl: "http://bot.test:8000",
authorize: async () => false,
fetchImpl,
}).request(TRADING_BOT_PROXY_PATH, { headers: { cookie: "session=secret" } });
expect(response.status).toBe(401);
expect(fetchImpl).not.toHaveBeenCalled();
});
it("returns the mapped status and does not forward browser credentials", async () => {
const fetchImpl = vi.fn(
async () =>
new Response(
JSON.stringify({ mode: "LIVE", adapter_count: 2, last_error: "adapter timeout" }),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const response = await proxyApp({
baseUrl: "http://bot.test:8000",
fetchImpl,
}).request(TRADING_BOT_PROXY_PATH, {
headers: { cookie: "session=secret", authorization: "Bearer browser-token" },
});
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
configured: true,
reachable: true,
status: {
mode: "live",
kind: "live",
adapterCount: 2,
lastError: "adapter timeout",
},
});
expect(fetchImpl).toHaveBeenCalledTimes(1);
const [url, init] = fetchImpl.mock.calls[0] as [string, RequestInit];
expect(url).toBe("http://bot.test:8000/bot/api/status");
const headers = new Headers(init.headers);
expect(headers.get("cookie")).toBeNull();
expect(headers.get("authorization")).toBeNull();
expect(init.redirect).toBe("error");
});
it("hides an unmapped body and an upstream failure instead of claiming live", async () => {
const unmapped = vi.fn(
async () =>
new Response(JSON.stringify({ status: "ok" }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
const unknown = await proxyApp({
baseUrl: "http://bot.test:8000",
fetchImpl: unmapped,
}).request(TRADING_BOT_PROXY_PATH);
expect(await unknown.json()).toEqual({ configured: true, reachable: true, status: null });
const down = vi.fn(async () => new Response("nope", { status: 500 }));
const offline = await proxyApp({
baseUrl: "http://bot.test:8000",
fetchImpl: down,
}).request(TRADING_BOT_PROXY_PATH);
expect(offline.status).toBe(502);
expect(await offline.json()).toEqual({ configured: true, reachable: false });
});
});

View File

@ -1,79 +0,0 @@
import {
mapTradingBotStatus,
readBoundedJsonResponse,
TRADING_BOT_PROXY_PATH,
TRADING_BOT_STATUS_MAX_BYTES,
TRADING_BOT_STATUS_TIMEOUT_MS,
tradingBotStatusUrl,
} from "@rakazo/core";
import type { Hono } from "hono";
export function mountTradingBotStatus(
app: Hono,
options: {
/** Normalized http(s) origin. Unset leaves the route idle for SaaS builds. */
baseUrl: string | undefined;
authorize: (request: Request) => Promise<boolean>;
fetchImpl?: typeof fetch;
},
) {
const fetchImpl = options.fetchImpl ?? fetch;
app.get(TRADING_BOT_PROXY_PATH, async (c) => {
c.header("cache-control", "no-store");
if (!options.baseUrl) return c.json({ configured: false });
const allowed = await options.authorize(c.req.raw).catch(() => false);
if (!allowed) return c.json({ error: "Unauthorized" }, 401);
const timeout = linkAbort(c.req.raw.signal, TRADING_BOT_STATUS_TIMEOUT_MS);
try {
const response = await fetchImpl(tradingBotStatusUrl(options.baseUrl), {
method: "GET",
redirect: "error",
cache: "no-store",
signal: timeout.signal,
headers: { accept: "application/json" },
});
if (!response.ok) {
cancelBody(response);
return c.json({ configured: true, reachable: false }, 502);
}
const body = await readBoundedJsonResponse<unknown>(
response,
TRADING_BOT_STATUS_MAX_BYTES,
timeout.signal,
);
const status = mapTradingBotStatus(body);
return c.json({ configured: true, reachable: true, status: status ?? null });
} catch {
return c.json({ configured: true, reachable: false }, 502);
} finally {
timeout.cancel();
}
});
}
function linkAbort(
parent: AbortSignal,
timeoutMs: number,
): { signal: AbortSignal; cancel: () => void } {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const onParent = () => controller.abort();
if (parent.aborted) controller.abort();
else parent.addEventListener("abort", onParent);
return {
signal: controller.signal,
cancel: () => {
clearTimeout(timer);
parent.removeEventListener("abort", onParent);
},
};
}
function cancelBody(response: Response): void {
try {
void Promise.resolve(response.body?.cancel()).catch(() => undefined);
} catch {
// Best-effort. The caller still reports the bot as unreachable.
}
}

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

@ -0,0 +1,91 @@
import type { Server } from "node:http";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { NoVoiceConfigured } from "@rakazo/adapters";
import type { Actor } from "@rakazo/contracts";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { VoiceDeps } from "./voice.js";
import { toVoiceStatus, transcribeVoice } from "./voice.js";
const actor: Actor = {
userId: "user-1",
spaceId: "space-1",
email: "user@rakazo.test",
isDeploymentOwner: false,
};
/** A user without a voice credential, on a deployment whose media settings are `settings`. */
function deps(settings: object | null, ciphertext: string): VoiceDeps {
return {
prisma: {
spaceVoicePreference: { findFirst: async () => null },
integrationProviderConfig: {
findUnique: async () => (settings ? { id: "media", ciphertext } : null),
},
},
secrets: { load: () => JSON.stringify(settings) },
} as unknown as VoiceDeps;
}
describe("dictation through the self-hosted transcription server", () => {
let server: Server;
let baseUrl = "";
const uploads: string[] = [];
beforeAll(async () => {
server = createServer((request, response) => {
const chunks: Buffer[] = [];
request.on("data", (chunk: Buffer) => chunks.push(chunk));
request.on("end", () => {
uploads.push(`${request.url} ${Buffer.concat(chunks).toString("latin1")}`);
response.setHeader("content-type", "application/json");
response.end(JSON.stringify({ text: " Remind me to call Ada. " }));
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}/v1`;
});
afterAll(async () => {
await new Promise((resolve) => server.close(resolve));
});
it("transcribes when no voice provider can", async () => {
const result = await transcribeVoice(
deps({ comfyui: null, transcription: { baseUrl } }, "media-a"),
actor,
{ audio: new Uint8Array([1, 2, 3]), mimeType: "audio/webm;codecs=opus" },
);
expect(result).toEqual({ text: "Remind me to call Ada." });
expect(uploads[0]).toContain("/v1/audio/transcriptions");
expect(uploads[0]).toContain('filename="audio.webm"');
});
it("keeps asking for a voice key when no server is set up either", async () => {
await expect(
transcribeVoice(deps(null, "none"), actor, {
audio: new Uint8Array([1]),
mimeType: "audio/webm",
}),
).rejects.toBeInstanceOf(NoVoiceConfigured);
});
it("refuses an empty recording before uploading it", async () => {
const before = uploads.length;
await expect(
transcribeVoice(deps({ comfyui: null, transcription: { baseUrl } }, "media-b"), actor, {
audio: new Uint8Array(),
mimeType: "audio/webm",
}),
).rejects.toThrow("That recording is empty or too large.");
expect(uploads).toHaveLength(before);
});
it("reports server dictation in the voice status", () => {
expect(toVoiceStatus(null).transcribe).toBe(false);
expect(toVoiceStatus(null, { mediaTranscription: true })).toMatchObject({
configured: false,
transcribe: true,
});
});
});

View File

@ -13,18 +13,18 @@ import {
describe("toVoiceStatus", () => {
it("treats a saved key without a voice as configured but not ready", () => {
expect(toVoiceStatus({ provider: "elevenlabs", voiceId: "" })).toEqual({
expect(toVoiceStatus({ provider: "kokoro", voiceId: "" })).toEqual({
configured: true,
ready: false,
transcribe: true,
provider: "elevenlabs",
transcribe: false,
provider: "kokoro",
voiceId: "",
});
});
it("is ready once a voice is chosen", () => {
expect(toVoiceStatus({ provider: "cartesia", voiceId: "katie" }).ready).toBe(true);
expect(toVoiceStatus({ provider: "cartesia", voiceId: "katie" }).transcribe).toBe(false);
expect(toVoiceStatus({ provider: "kokoro", voiceId: "df_eva" }).ready).toBe(true);
expect(toVoiceStatus({ provider: "kokoro", voiceId: "df_eva" }).transcribe).toBe(false);
});
it("is off when nothing is connected", () => {
@ -39,7 +39,7 @@ describe("toVoiceStatus", () => {
});
describe("voice HTTP routes", () => {
it("rejects unauthenticated speak and transcribe", async () => {
it("rejects unauthenticated speak, transcribe, and transcribe-stream", async () => {
const app = new Hono();
mountVoiceHttpRoutes(app, {} as VoiceDeps, async () => null);
const speak = await app.request("/api/voice/speak", {
@ -52,13 +52,20 @@ describe("voice HTTP routes", () => {
headers: { "content-type": "application/json" },
body: JSON.stringify({ audioBase64: "AAAA", mimeType: "audio/webm" }),
});
const transcribeStream = await app.request("/api/voice/transcribe-stream", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ audioBase64: "AAAA", mimeType: "audio/webm" }),
});
expect(speak.status).toBe(401);
expect(transcribe.status).toBe(401);
expect(transcribeStream.status).toBe(401);
});
it.each([
["/api/voice/speak", MAX_SPEAK_REQUEST_BYTES],
["/api/voice/transcribe", MAX_TRANSCRIBE_REQUEST_BYTES],
["/api/voice/transcribe-stream", MAX_TRANSCRIBE_REQUEST_BYTES],
])(
"rejects a declared oversized body on %s without waiting for cancellation",
async (path, max) => {
@ -112,6 +119,22 @@ describe("voice HTTP routes", () => {
expect(response.status).toBe(413);
expect(cancel).toHaveBeenCalledOnce();
});
it("rejects empty recording on transcribe-stream", async () => {
const app = new Hono();
mountVoiceHttpRoutes(
app,
{} as VoiceDeps,
async () => ({ userId: "user", spaceId: "space" }) as Actor,
);
const response = await app.request("/api/voice/transcribe-stream", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ audioBase64: "", mimeType: "audio/webm" }),
});
expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({ error: "Recording is empty." });
});
});
const actor = { userId: "user-1", spaceId: "space-1" } as Actor;

View File

@ -1,10 +1,12 @@
import { ORPCError } from "@orpc/server";
import type { AdapterContext } from "@rakazo/adapter-kit";
import type { EncryptedSecretStore } from "@rakazo/adapters";
import {
createOpenAiCompatibleTranscriptionProvider,
createVoiceProvider,
type EncryptedSecretStore,
isVoiceProviderId,
listVoiceCatalog,
loadMediaSettings,
MAX_SPEAK_CHARS,
MAX_TRANSCRIBE_BYTES,
NoVoiceConfigured,
@ -23,7 +25,7 @@ import {
selectSpaceVoicePreference,
} from "@rakazo/db";
import type { Context, Hono } from "hono";
import { readBoundedBody } from "./http-body.js";
import { cancelBody, readBoundedBody } from "./http-body.js";
import { withSerializableRetry } from "./serializable-retry.js";
export interface VoiceDeps {
@ -51,12 +53,20 @@ export function catalogEntry(provider: string) {
return voiceCatalogEntry(provider);
}
export function toVoiceStatus(cred: { provider: string; voiceId: string } | null): VoiceStatus {
/** The deployment's self-hosted transcription server, when the owner set one up. */
export async function mediaTranscriptionSettings(deps: VoiceDeps) {
return (await loadMediaSettings(deps).catch(() => null))?.transcription ?? null;
}
export function toVoiceStatus(
cred: { provider: string; voiceId: string } | null,
options: { mediaTranscription?: boolean } = {},
): VoiceStatus {
const entry = cred ? catalogEntry(cred.provider) : undefined;
return {
configured: Boolean(cred),
ready: Boolean(cred?.voiceId),
transcribe: Boolean(entry?.transcribe && cred),
transcribe: Boolean(entry?.transcribe && cred) || Boolean(options.mediaTranscription),
provider: cred?.provider ?? null,
voiceId: cred?.voiceId ?? "",
};
@ -86,7 +96,8 @@ export async function loadVoiceCredential(deps: VoiceDeps, actor: Actor, provide
const cred = provider
? await findVoiceCredential(deps.prisma, actor, provider)
: await findDefaultVoiceCredential(deps.prisma, actor);
if (!cred) return null;
// Credentials of providers that were removed from the catalog no longer speak.
if (!cred || !isVoiceProviderId(cred.provider)) return null;
const secret = await deps.prisma.secret.findFirst({
where: { id: cred.secretId, userId: actor.userId, spaceId: null },
});
@ -136,7 +147,11 @@ export async function persistVoiceCredential(
let voiceId = input.voiceId?.trim() ?? "";
if (!voiceId) {
const voices = await provider.listVoices(input.plaintext, voiceContext(actor, input.signal));
voiceId = voices[0]?.id ?? "";
const germanVoice =
input.provider === "kokoro"
? voices.find((v) => v.id === "df_eva" || /^d[fm]_/i.test(v.id))
: undefined;
voiceId = germanVoice?.id ?? voices[0]?.id ?? "";
}
const stored = await deps.secrets.put(input.plaintext, voiceContext(actor, input.signal));
const cred = await withSerializableRetry(() =>
@ -277,9 +292,22 @@ export async function transcribeVoice(
input: { audio: Uint8Array; mimeType: string; signal?: AbortSignal },
) {
const loaded = await loadDefaultVoiceCredential(deps, actor);
if (!loaded) throw new NoVoiceConfigured("key");
const provider = createVoiceProvider(loaded.cred.provider);
if (!provider.transcribe) {
const provider = loaded ? createVoiceProvider(loaded.cred.provider) : null;
if (!loaded || !provider?.transcribe) {
// Without a voice provider that transcribes, the self-hosted media server can.
const server = await mediaTranscriptionSettings(deps);
if (server) {
if (input.audio.byteLength === 0 || input.audio.byteLength > MAX_TRANSCRIBE_BYTES) {
throw new ORPCError("BAD_REQUEST", { message: "That recording is empty or too large." });
}
const transcript = await createOpenAiCompatibleTranscriptionProvider(server).transcribe({
audio: input.audio,
mimeType: input.mimeType || "audio/webm",
signal: input.signal,
});
return { text: transcript.text };
}
if (!loaded) throw new NoVoiceConfigured("key");
throw new ORPCError("BAD_REQUEST", {
message: "This voice provider does not transcribe audio. Use on-device dictation instead.",
});
@ -352,6 +380,113 @@ export function mountVoiceHttpRoutes(
return voiceHttpError(c, error);
}
});
app.post("/api/voice/transcribe-stream", async (c) => {
const actor = await authenticate(c);
if (!actor) return c.json({ error: "Unauthorized" }, 401);
const contentType = c.req.header("content-type") ?? "";
const isJson =
contentType.includes("application/json") ||
(!contentType.startsWith("audio/") && !contentType.includes("octet-stream"));
let audio: Uint8Array;
let mimeType = "audio/webm";
if (isJson) {
const raw = await readBoundedBody(c.req.raw, MAX_TRANSCRIBE_REQUEST_BYTES);
if (raw === null) return c.json({ error: "Request body is too large." }, 413);
const body = parseVoiceRequestBody(raw);
const audioBase64 = String((body as { audioBase64?: unknown }).audioBase64 ?? "");
try {
audio = decodeAudioBase64(audioBase64);
mimeType = String((body as { mimeType?: unknown }).mimeType ?? "audio/webm");
} catch (error) {
return voiceHttpError(c, error);
}
} else {
const rawBytes = await readBoundedBytes(c.req.raw, MAX_TRANSCRIBE_REQUEST_BYTES);
if (rawBytes === null) return c.json({ error: "Request body is too large." }, 413);
if (rawBytes.byteLength === 0) {
return voiceHttpError(c, new ORPCError("BAD_REQUEST", { message: "Recording is empty." }));
}
audio = rawBytes;
mimeType = contentType.split(";")[0]?.trim() || "audio/webm";
}
try {
const result = await transcribeVoice(deps, actor, {
audio,
mimeType,
signal: c.req.raw.signal,
});
const accept = c.req.header("accept") ?? "";
if (accept.includes("application/json") && !accept.includes("text/event-stream")) {
return c.json({ text: result.text, isFinal: true });
}
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({ text: result.text, delta: result.text, isFinal: true })}\n\n`,
),
);
controller.enqueue(encoder.encode(`data: [DONE]\n\n`));
controller.close();
},
});
return new Response(stream, {
headers: {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache, no-transform",
connection: "keep-alive",
},
});
} catch (error) {
return voiceHttpError(c, error);
}
});
}
async function readBoundedBytes(request: Request, maxBytes: number): Promise<Uint8Array | null> {
const contentLengthHeader = request.headers.get("content-length");
if (contentLengthHeader !== null) {
const contentLength = Number(contentLengthHeader);
if (!Number.isFinite(contentLength) || contentLength < 0 || contentLength > maxBytes) {
cancelBody(request.body);
return null;
}
}
if (!request.body) return new Uint8Array(0);
const reader = request.body.getReader();
const chunks: Uint8Array[] = [];
let bytes = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
bytes += value.byteLength;
if (bytes > maxBytes) {
cancelBody(reader);
return null;
}
chunks.push(value);
}
const combined = new Uint8Array(bytes);
let offset = 0;
for (const chunk of chunks) {
combined.set(chunk, offset);
offset += chunk.byteLength;
}
return combined;
} finally {
reader.releaseLock();
}
}
function parseVoiceRequestBody(raw: string): Record<string, unknown> {

View File

@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { requestWebOrigin } from "./web-origin.js";
const configured = "http://127.0.0.1:5173";
describe("requestWebOrigin", () => {
it("follows a browser on loopback or a private network", () => {
expect(requestWebOrigin("http://192.168.0.20:5173", configured)).toBe(
"http://192.168.0.20:5173",
);
expect(requestWebOrigin("http://10.0.0.5:5173", configured)).toBe("http://10.0.0.5:5173");
expect(requestWebOrigin("http://mac.local:5173", configured)).toBe("http://mac.local:5173");
expect(requestWebOrigin("http://localhost:5173", configured)).toBe("http://localhost:5173");
});
it("keeps WEB_ORIGIN for public hosts, other schemes, and missing origins", () => {
expect(requestWebOrigin("https://attacker.example", configured)).toBe(configured);
expect(requestWebOrigin("rakazo://app", configured)).toBe(configured);
expect(requestWebOrigin("null", configured)).toBe(configured);
expect(requestWebOrigin(undefined, configured)).toBe(configured);
});
it("accepts the configured origin even when it is public", () => {
expect(requestWebOrigin("https://evoo.example", "https://evoo.example")).toBe(
"https://evoo.example",
);
});
it("accepts origins listed in extraTrustedOrigins", () => {
expect(
requestWebOrigin("https://host.example.ts.net:8443", configured, [
"https://host.example.ts.net:8443",
]),
).toBe("https://host.example.ts.net:8443");
});
});

View File

@ -0,0 +1,35 @@
/** Hosts the API already trusts as browser origins: loopback and private networks. */
export function isPrivateOrLocalHost(host: string): boolean {
if (isLoopbackHost(host)) return true;
if (host.endsWith(".local")) return true;
if (/^192\.168\.\d{1,3}\.\d{1,3}$/.test(host)) return true;
if (/^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host)) return true;
if (/^172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}$/.test(host)) return true;
if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.\d{1,3}\.\d{1,3}$/.test(host)) return true;
return false;
}
export function isLoopbackHost(host: string): boolean {
return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
}
/** The web origin that browser-facing URLs (OAuth returns, screen links) should
* point at for this request. A browser opened over a loopback, private-network,
* or explicitly trusted origin gets its own origin back so a phone on the LAN
* or Tailscale is not sent to 127.0.0.1; any other caller gets WEB_ORIGIN. */
export function requestWebOrigin(
origin: string | undefined,
webOrigin: string,
extraTrustedOrigins: readonly string[] = [],
): string {
if (!origin) return webOrigin;
try {
const url = new URL(origin);
if (url.protocol !== "http:" && url.protocol !== "https:") return webOrigin;
if (url.origin === new URL(webOrigin).origin) return url.origin;
if (extraTrustedOrigins.includes(url.origin)) return url.origin;
return isPrivateOrLocalHost(url.hostname) ? url.origin : webOrigin;
} catch {
return webOrigin;
}
}

View File

@ -0,0 +1,155 @@
import http from "node:http";
import path from "node:path";
import { _electron as electron, expect, test } from "@playwright/test";
import type { RakazoDesktop, RakazoDesktopBrowserPreviewState } from "@rakazo/contracts";
const fixture = `<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Rakazo desktop browser</title></head>
<body><main>App with a browser panel</main></body>
</html>`;
type Bridge = NonNullable<RakazoDesktop["browserPreview"]>;
test("the in-app browser is a native view the app window lays over its panel", async () => {
const pages = http.createServer((req, res) => {
res.setHeader("content-type", "text/html");
if (req.url === "/second") {
res.end("<!doctype html><title>Second page</title><p>second</p>");
return;
}
res.end(`<!doctype html><title>First page</title>
<a id="next" href="/second">next</a>
<a id="popup" href="/second" target="_blank">popup</a>
<a id="file" href="file:///etc/hosts">file</a>`);
});
await new Promise<void>((resolve) => pages.listen(0, "127.0.0.1", resolve));
const address = pages.address();
if (!address || typeof address === "string") throw new Error("No fixture server");
const origin = `http://127.0.0.1:${address.port}`;
const app = await electron.launch({
args: ["."],
cwd: path.resolve(import.meta.dirname, ".."),
env: {
...process.env,
RAKAZO_WEB_URL: `data:text/html;charset=utf-8,${encodeURIComponent(fixture)}`,
},
});
try {
const page = await app.firstWindow();
await expect(page.getByText("App with a browser panel")).toBeVisible();
const call = <K extends keyof Bridge>(method: K, ...args: unknown[]) =>
page.evaluate(
([name, values]) => {
const bridge = (window as typeof window & { rakazoDesktop?: RakazoDesktop }).rakazoDesktop
?.browserPreview as unknown as Record<
string,
(...input: unknown[]) => Promise<unknown>
>;
return bridge[name as string]!(...(values as unknown[]));
},
[method, args] as const,
);
const state = () => call("state") as Promise<RakazoDesktopBrowserPreviewState>;
const view = () =>
app.evaluate(({ BrowserWindow }) => {
const win = BrowserWindow.getAllWindows()[0]!;
const child = win.contentView.children[0] as
| (Electron.View & { webContents?: Electron.WebContents })
| undefined;
return child
? {
count: win.contentView.children.length,
visible: child.getVisible(),
bounds: child.getBounds(),
url: child.webContents?.getURL(),
partition: child.webContents?.session === win.webContents.session,
sandbox: child.webContents?.getLastWebPreferences()?.sandbox,
nodeIntegration: child.webContents?.getLastWebPreferences()?.nodeIntegration,
}
: null;
});
// Pages load in a native view in their own session, never the app's.
await call("navigate", `${origin}/`);
await expect.poll(async () => (await state()).title).toBe("First page");
expect(await view()).toMatchObject({
count: 1,
visible: false,
url: `${origin}/`,
partition: false,
sandbox: true,
nodeIntegration: false,
});
await call("show", { x: 400, y: 50, width: 300, height: 200 });
expect(await view()).toMatchObject({
visible: true,
bounds: { x: 400, y: 50, width: 300, height: 200 },
});
// Renderer bounds outside the window are clamped to it.
await call("show", { x: -10, y: -10, width: 100_000, height: 100_000 });
const [width, height] = await app.evaluate(({ BrowserWindow }) =>
BrowserWindow.getAllWindows()[0]!.getContentSize(),
);
expect((await view())?.bounds).toEqual({ x: 0, y: 0, width, height });
const contents = () =>
app.evaluate(({ BrowserWindow }, script) => {
const win = BrowserWindow.getAllWindows()[0]!;
const child = win.contentView.children[0] as Electron.View & {
webContents: Electron.WebContents;
};
return child.webContents.executeJavaScript(script, true);
}, "document.getElementById('next').click()");
await contents();
await expect.poll(async () => (await state()).title).toBe("Second page");
expect(await state()).toMatchObject({ canGoBack: true, canGoForward: false, error: null });
await call("back");
await expect.poll(async () => (await state()).title).toBe("First page");
expect(await state()).toMatchObject({ canGoForward: true });
await call("forward");
await expect.poll(async () => (await state()).url).toBe(`${origin}/second`);
// New-window links stay in the one view; file links never load.
await call("back");
await expect.poll(async () => (await state()).title).toBe("First page");
await app.evaluate(({ BrowserWindow }) => {
const child = BrowserWindow.getAllWindows()[0]!.contentView.children[0] as Electron.View & {
webContents: Electron.WebContents;
};
return child.webContents.executeJavaScript("document.getElementById('popup').click()", true);
});
await expect.poll(async () => (await state()).url).toBe(`${origin}/second`);
expect(await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows().length)).toBe(1);
await call("back");
await expect.poll(async () => (await state()).title).toBe("First page");
await app.evaluate(({ BrowserWindow }) => {
const child = BrowserWindow.getAllWindows()[0]!.contentView.children[0] as Electron.View & {
webContents: Electron.WebContents;
};
return child.webContents.executeJavaScript("document.getElementById('file').click()", true);
});
await page.waitForTimeout(300);
expect((await state()).url).toBe(`${origin}/`);
await expect(call("navigate", "file:///etc/hosts")).rejects.toThrow(/http/);
// A dev server that is not running yet shows as an error, then recovers on reload.
pages.close();
await new Promise((resolve) => setTimeout(resolve, 100));
await call("navigate", `${origin}/gone`);
await expect.poll(async () => (await state()).error).toMatch(/ERR_CONNECTION_REFUSED/);
await call("hide");
expect((await view())?.visible).toBe(false);
} finally {
await app.close();
pages.closeAllConnections();
pages.close();
}
});

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