Compare commits

..

186 Commits

Author SHA1 Message Date
Claude
487fb62983
Merge main (#119 squash-merged) into claude/phase5-shell-split
main's tree equals the media branch merged above, so this keeps the
tree; the diff to main is now the Shell split alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-04 10:59:54 +00:00
Claude
f9b1a40663
Merge the final media branch (with #128) into claude/phase5-shell-split
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-04 10:59:54 +00:00
Claude
91e560a0e6
Merge d2/hooks: ShellPage hooks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u

# Conflicts:
#	apps/web/src/pages/Shell.tsx
2026-10-04 10:27:11 +00:00
Claude
a72adac613
Merge d2/components: ShellPage JSX components
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-04 10:26:15 +00:00
Claude
ebb3cecd97
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
2026-10-04 10:08:48 +00:00
Claude
0ab148ba03
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
2026-10-04 10:08:48 +00:00
Claude
3c678519a6
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
2026-10-04 10:08:43 +00:00
Claude
8148e78c6c
Merge remote-tracking branch 'origin/main' into claude/phase3-media
# Conflicts:
#	apps/web/src/locales/de/messages.po
#	apps/web/src/locales/en/messages.po
#	apps/web/src/locales/es/messages.po
#	apps/web/src/locales/hi/messages.po
#	apps/web/src/locales/ko/messages.po
#	apps/web/src/locales/pt-BR/messages.po
#	apps/web/src/locales/ru/messages.po
#	apps/web/src/locales/tr/messages.po
#	apps/web/src/locales/zh-CN/messages.po
2026-10-04 10:08:36 +00:00
Claude
cf3f1ecc3f
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
2026-10-04 10:04:41 +00:00
Claude
2b27d5c5da
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
2026-10-04 10:03:23 +00:00
Claude
a1bf392298
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
2026-10-04 10:01:09 +00:00
Claude
307c095bb6
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
2026-10-04 09:59:38 +00:00
Claude
a0060b8df7
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
2026-10-04 09:38:16 +00:00
Claude
3f22ef691f
Merge remote-tracking branch 'origin/main' into claude/phase3-media 2026-10-04 09:27:53 +00:00
Claude
08cff7b26a
Merge branch 'claude/phase2-coach' into claude/phase3-media
# Conflicts:
#	apps/mobile/lib/api.test.ts
#	apps/web/src/locales/de/messages.po
#	apps/web/src/locales/en/messages.po
#	apps/web/src/locales/es/messages.po
#	apps/web/src/locales/hi/messages.po
#	apps/web/src/locales/ko/messages.po
#	apps/web/src/locales/pt-BR/messages.po
#	apps/web/src/locales/ru/messages.po
#	apps/web/src/locales/tr/messages.po
#	apps/web/src/locales/zh-CN/messages.po
#	apps/web/src/pages/SettingsOverlay.tsx
#	apps/web/src/pages/shell/message-cards.tsx
#	packages/adapter-kit/src/index.ts
#	packages/adapters/src/builtin-tools.ts
#	packages/adapters/src/executor-tools.ts
#	packages/adapters/src/index.ts
2026-10-04 08:10:33 +00:00
Claude
86465067d6
Merge remote-tracking branch 'origin/main' into claude/phase2-coach
# Conflicts:
#	apps/web/e2e/heute-brief.spec.ts
#	apps/web/src/locales/de/messages.po
#	apps/web/src/locales/en/messages.po
#	apps/web/src/locales/es/messages.po
#	apps/web/src/locales/hi/messages.po
#	apps/web/src/locales/ko/messages.po
#	apps/web/src/locales/pt-BR/messages.po
#	apps/web/src/locales/ru/messages.po
#	apps/web/src/locales/tr/messages.po
#	apps/web/src/locales/zh-CN/messages.po
2026-10-04 08:05:32 +00:00
Claude
9d6d85cb52
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
2026-10-04 04:34:57 +00:00
Claude
3e996b6c12
Merge f/media-stream: stream large media between the store and local computers
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-04 04:20:07 +00:00
Claude
81991251a9
Merge f/comfy-progress: live ComfyUI progress over its websocket
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-04 04:20:07 +00:00
Claude
6af1be020e
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
2026-10-04 04:18:12 +00:00
Claude
31dcdf5293
Merge f/main-preview: localized main conversation preview
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-04 04:18:08 +00:00
Claude
a7ae619b28
Merge f/tz-rhythm: move check-ins when the time zone changes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-04 04:18:08 +00:00
Claude
5362b4a796
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
2026-10-04 04:17:54 +00:00
Claude
a62e09f36b
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
2026-10-04 04:17:32 +00:00
Claude
68f9dfb34b
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
2026-10-04 04:08:05 +00:00
Claude
aea604f7ca
Merge main (light palette) into claude/phase2-coach
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-04 03:54:55 +00:00
Claude
182a9288c2
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
2026-10-04 00:41:43 +00:00
Claude
55bfe9d291
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
2026-10-03 23:46:36 +00:00
Claude
f071c2257c
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
2026-10-03 23:46:36 +00:00
Claude
59811952ec
Merge the updated i18n branch (#111, with main) into the media integration branch
Catalogs are regenerated with the repo's extractor and keep both sides'
German translations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 23:26:12 +00:00
Claude
681dec2f3f
Merge main into the media integration branch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 23:25:48 +00:00
Claude
52bbbbfb00
Merge the updated i18n branch (#111, with main) into the Phase 2 integration branch
Catalogs are regenerated with the repo's extractor and keep both sides'
German translations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 23:25:08 +00:00
Claude
77e822b9a7
Merge main into the Phase 2 integration branch
Main now gives every project its own main agent. Coaching stays with the
user's personal agent, the main bot of the home space: only it gets the
goal, habit, check-in, brief and nudge tools, the coach summary and the
open-onboarding note, and the tools refuse a project's main bot too. The
onboarding questions start with the personal agent only. A project the
scan creates gets its main agent like any other, and the inbox lists asks
from another space's main conversation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 23:24:21 +00:00
Claude
c7d543132d
Merge main into the i18n branch
The project and sidebar specs follow main's project main agents with the
English source labels; the project conversation label is translated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 23:16:21 +00:00
Claude
0fd62b9823
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
2026-10-03 21:57:20 +00:00
Claude
c782750245
Merge the updated i18n branch (#111, with main) into the media integration branch
Catalogs are regenerated with the repo's extractor and keep both sides'
German translations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 20:47:35 +00:00
Claude
93919ad3c2
Merge the updated legacy cleanup (#109, with main) into the media integration branch
The artifact pane from main loads and previews files through the streamed
artifact route like the file card, so its hook takes only the artifact id.
attach_file keeps media types and gains present, which marks image and
file blocks for the pane.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 20:46:57 +00:00
Claude
6e54c873c3
Merge the updated i18n branch (#111, with main) into the Phase 2 integration branch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 20:40:37 +00:00
Claude
e1cd9be8a6
Merge the updated legacy cleanup (#109, with main) into the Phase 2 integration branch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 20:40:03 +00:00
Claude
82fad0c9ac
Merge main into the i18n branch
Catalogs are extracted again; the artifact pane's row count is translated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 20:39:38 +00:00
Claude
3edce64bad
Merge main into the legacy cleanup
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUTKejm92a8Z7bgSunJChV
2026-10-03 20:34:35 +00:00
Claude
858bbc4e8e
Merge the updated i18n branch (#111, with main) into the media integration branch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 18:52:19 +00:00
Claude
452106d7e5
Merge the updated legacy cleanup (#109, with main) into the media integration branch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 18:52:19 +00:00
Claude
c7510fa9b3
Merge the updated i18n branch (#111, with main) into the Phase 2 integration branch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 18:52:01 +00:00
Claude
adb4dff052
Merge the updated legacy cleanup (#109, with main) into the Phase 2 integration branch
The onboarding scan keeps the consent-first rewrite. Since main's folder
check moved out of wireProjectWorkspace, applying a scan now checks that
each chosen project is still a folder (not only inside the scan folder)
before anything is written; a discovery test covers a project replaced by
a file after the scan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 18:51:57 +00:00
Claude
3a72b80122
Merge main into the i18n branch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 18:49:55 +00:00
Claude
ee2c5fc3b9
Merge main into the legacy cleanup
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 18:49:00 +00:00
Claude
31bad706c7
Merge the updated i18n branch (#111, with main) into the media integration branch
Catalogs are regenerated with the repo's extractor and keep both sides'
German translations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 18:31:37 +00:00
Claude
d143054966
Merge the updated legacy cleanup (#109, with main) into the media integration branch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 18:31:09 +00:00
Claude
7532f17319
Merge the updated i18n branch (#111, with main) into the Phase 2 integration branch
Catalogs are regenerated with the repo's extractor and keep both sides'
German translations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 18:27:17 +00:00
Claude
9eb4545793
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
2026-10-03 18:24:48 +00:00
Claude
e2cf548d18
Merge main into the i18n branch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 18:24:25 +00:00
Claude
09ed6c2136
Merge the updated legacy cleanup (#109, with main) into the Phase 2 integration branch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 18:23:22 +00:00
Claude
c641936a2e
Merge main into the legacy cleanup
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 18:19:09 +00:00
Claude
f4b00cba76
Merge the updated i18n branch (#111, with main) into the media integration branch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 17:09:07 +00:00
Claude
8eb7ff0098
Merge the updated legacy cleanup (#109, with main) into the media integration branch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 17:09:07 +00:00
Claude
2273e8f82b
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
2026-10-03 17:08:39 +00:00
Claude
28206f21ca
Merge the updated i18n branch (#111, with main) into the Phase 2 integration branch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 17:02:11 +00:00
Claude
ade6c5d116
Merge the updated legacy cleanup (#109, with main) into the Phase 2 integration branch
The consent-first scan replaces the automatic project creation that main
fixed in #116; main's git checkout fix and sidebar classes are kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 17:01:13 +00:00
Claude
f66ca4b7e6
Merge main into the i18n branch
main's shell contrast test finds the sidebar Today button by its English
label here; the sidebar keeps main's token classes with this branch's
English sources.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 16:56:28 +00:00
Claude
dd5080ea36
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
2026-10-03 16:53:20 +00:00
Claude
bf23bd43c6
Merge main into the legacy cleanup
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 16:51:55 +00:00
Claude
ef15ec61b1
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
2026-10-03 15:57:06 +00:00
Claude
7a32100b53
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
2026-10-03 14:55:01 +00:00
Claude
27f02c05af
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
2026-10-03 14:53:03 +00:00
Claude
cf797ce6a5
Merge the updated i18n branch (#111) into the Phase 2 integration branch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:52:41 +00:00
Claude
06b05711fd
Merge the updated legacy cleanup (#109) into the Phase 2 integration branch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:52:41 +00:00
Claude
056cabd4ae
Merge web-localized onboarding cards, closing note and check-in names
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:52:40 +00:00
Claude
d3689b88fc
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
2026-10-03 14:51:59 +00:00
Claude
89330f9508
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
2026-10-03 14:51:11 +00:00
Claude
b2cb681003
Merge the updated i18n branch (#111) into the media integration branch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:50:24 +00:00
Claude
d444d174d0
Merge the updated legacy cleanup (#109) into the media integration branch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:50:24 +00:00
Claude
c17445874d
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
2026-10-03 14:50:16 +00:00
Claude
05196fd20e
Merge media cards that work from any of the user's spaces
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:46:20 +00:00
Claude
613cc2ba6a
Merge snake_case media tool inputs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:45:52 +00:00
Claude
969c641bb4
Merge long-upload timeouts and the stall abort
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:45:52 +00:00
Claude
0682f9b6f4
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
2026-10-03 14:43:55 +00:00
Claude
7c17694f5b
Merge main into the i18n branch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:43:31 +00:00
Claude
14e1af6649
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
2026-10-03 14:40:50 +00:00
Claude
6a450bb694
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
2026-10-03 14:39:36 +00:00
Claude
2c3e8c612f
Merge main into the legacy cleanup
The shell contrast test added on main measured the sidebar trading status,
which this branch removes; it now measures the rows that remain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:39:27 +00:00
Claude
081bc3f956
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
2026-10-03 14:39:02 +00:00
Claude
54d6e84fb7
Merge complete German and stable catalogs (#111) into the media integration branch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:34:51 +00:00
Claude
f81000a5dd
Merge the legacy cleanup (#109) into the media integration branch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:34:42 +00:00
Claude
07c37858ce
Merge complete German and stable catalogs (#111) into the Phase 2 integration branch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:33:45 +00:00
Claude
a09e29d65a
Merge the legacy cleanup (#109) into the Phase 2 integration branch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:33:11 +00:00
Claude
48b8acc0f7
Merge media job crash safety, the media settings section and its E2E
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:32:47 +00:00
Claude
0c2b6dd666
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
2026-10-03 14:31:15 +00:00
Claude
46349acbd1
Merge transcription, subtitles, the Studio specialist and the media card
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:29:36 +00:00
Claude
99c8ac14b5
Merge the media pipeline: inline players, streamed uploads, attached media
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:27:55 +00:00
Claude
ebac3ef9d1
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
2026-10-03 14:26:54 +00:00
Claude
e72b169ca4
Merge the conversational onboarding intake
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:26:11 +00:00
Claude
17d285f87e
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
2026-10-03 14:26:11 +00:00
Claude
96799f577e
Merge claude/phase3-media (video editing and export) into the media jobs runtime
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:25:49 +00:00
Claude
f407829524
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
2026-10-03 14:25:43 +00:00
Claude
8d6ed7952f
Merge the coach: goals, habits, check-ins, rhythm and the Heute page
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:25:28 +00:00
Claude
97c7fd3a34
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
2026-10-03 14:24:03 +00:00
Claude
f9ffa5a961
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
2026-10-03 14:18:56 +00:00
Claude
d67a6275a4
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
2026-10-03 14:18:06 +00:00
Claude
4fbd087ec8
Merge branch 'claude/phase2-coach' into p2/a1-onboarding
Brings in profile editing, the cross-space inbox and consent-first discovery. The wizard
keeps both changes: no preferences step, and the new scan step without the main bot lookup.
The harness lists both new onboarding Postgres suites.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:13:16 +00:00
Claude
a61aa02a8c
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
2026-10-03 14:13:12 +00:00
Claude
245efc88b1
Merge web push, quiet hours and nudges
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:12:33 +00:00
Claude
6d7d46024d
Merge image edit titles in the user's language
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:11:48 +00:00
Claude
31f4c2531d
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
2026-10-03 14:11:27 +00:00
Claude
59d02c46fb
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
2026-10-03 14:11:10 +00:00
Claude
97210805be
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
2026-10-03 14:09:56 +00:00
Claude
74eb27447e
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
2026-10-03 14:09:51 +00:00
Claude
a02cbb5374
Merge claude/phase3-media into p3/b5-transcribe-studio
Brings in ffmpeg (probing, extractAudio) and the video operations. The
transcribe registration now gets the ffmpeg audio tools, so video is
transcribed through extractAudio with the demuxer forced from the MIME type,
and long recordings are converted and uploaded in ranges.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:09:45 +00:00
Claude
74c3685397
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
2026-10-03 14:08:37 +00:00
Claude
4e88a9dfe6
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
2026-10-03 14:07:53 +00:00
Claude
4cda7f79f7
Merge video editing and export with ffmpeg
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:05:48 +00:00
Claude
0b3e0638ca
Merge media job fixes: GPU floor, settings test, data-sharing recipients
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:05:48 +00:00
Claude
97cede6c9c
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
2026-10-03 14:02:06 +00:00
Claude
9b4b9c8465
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
2026-10-03 14:01:14 +00:00
Claude
e66ce42d73
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
2026-10-03 14:00:34 +00:00
Claude
469e6ec5d7
Merge consent-first discovery of projects and connected apps
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:00:25 +00:00
Claude
2c91a80f69
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
2026-10-03 13:58:39 +00:00
Claude
4191a4b6e6
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
2026-10-03 13:58:29 +00:00
Claude
4e545c20f4
Merge the media integration branch into video editing again
Picks up ComfyUI generation, media tool dispatch and the streaming runner.
The video tools now share the image tools' schema helper, and both sets of
operations join the runtime's operation list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:57:18 +00:00
Claude
147611a8a1
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
2026-10-03 13:56:47 +00:00
Claude
114103579f
Merge claude/phase3-media into p3/b5-transcribe-studio
Brings in the media job RPCs, generic media tool dispatch and the image
operations; transcribe joins the operation list and the tool catalog next to
them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:54:34 +00:00
Claude
cc85ba7195
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
2026-10-03 13:54:11 +00:00
Claude
c008112ab4
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
2026-10-03 13:53:55 +00:00
Claude
4d75144cb8
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
2026-10-03 13:53:48 +00:00
Claude
4cdc49e6d8
Merge claude/phase3-media (ComfyUI operations) into the media jobs runtime
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:53:47 +00:00
Claude
01eea83b05
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
2026-10-03 13:53:47 +00:00
Claude
7491b38e8c
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
2026-10-03 13:51:53 +00:00
Claude
f14d91d24a
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
2026-10-03 13:51:44 +00:00
Claude
533f82d856
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
2026-10-03 13:51:13 +00:00
Claude
faab1256c9
Merge media job RPCs, tool dispatch and the streaming runner
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:50:39 +00:00
Claude
0cac4a4b92
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
2026-10-03 13:50:33 +00:00
Claude
979034a8a6
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
2026-10-03 13:50:02 +00:00
Claude
2d95db70f2
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
2026-10-03 13:49:49 +00:00
Claude
c380e7820a
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
2026-10-03 13:47:49 +00:00
Claude
2796965256
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
2026-10-03 13:47:44 +00:00
Claude
3e046142d5
Merge ComfyUI image and video generation and image edits
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:47:44 +00:00
Claude
66a257b083
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
2026-10-03 13:46:34 +00:00
Claude
44702e3770
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
2026-10-03 13:45:50 +00:00
Claude
3b97805f20
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
2026-10-03 13:45:50 +00:00
Claude
dab9398c25
Merge memory append, profile editing and the cross-space inbox
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:43:12 +00:00
Claude
4217e4b4af
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
2026-10-03 13:42:35 +00:00
Claude
7a58bc7ba9
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
2026-10-03 13:42:24 +00:00
Claude
e0277985f9
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
2026-10-03 13:37:19 +00:00
Claude
24c9245af1
Merge claude/phase3-media into the media jobs runtime
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:36:03 +00:00
Claude
fad25678ac
Merge the media integration branch into the ComfyUI workstream
Brings in artifact streaming and the media job runtime, settings and tool
helpers that the ComfyUI operations register with.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:35:47 +00:00
Claude
d8c324a7af
Merge the media integration branch into video editing
Brings in artifact streaming and the media job runtime that the video
operations register with.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:35:46 +00:00
Claude
1722ac1a0f
Merge claude/phase3-media into p3/b5-transcribe-studio
Brings in streamed artifacts with artifactUrl (replacing the local stand-in)
and the media runtime, settings and operation registry the transcribe
operation plugs into.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:35:36 +00:00
Claude
485090c391
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
2026-10-03 13:35:34 +00:00
Claude
8b2dcaa767
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
2026-10-03 13:35:21 +00:00
Claude
5e0b09d9c1
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
2026-10-03 13:35:21 +00:00
Claude
7185dcdd3c
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
2026-10-03 13:34:38 +00:00
Claude
d63f7b3bf1
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
2026-10-03 13:34:14 +00:00
Claude
d54d8bff88
Merge the media job runtime into the media integration branch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:32:20 +00:00
Claude
1f33b48362
Merge claude/phase3-media (artifact streaming) into the media jobs runtime
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:32:17 +00:00
Claude
d49bec4f8d
Merge artifact streaming into the media integration branch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:31:58 +00:00
Claude
c60c693d13
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
2026-10-03 13:31:53 +00:00
Claude
62e9bc67f1
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
2026-10-03 13:31:39 +00:00
Claude
3db0f0cfbd
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
2026-10-03 13:28:59 +00:00
Claude
1a3829031f
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
2026-10-03 13:27:50 +00:00
Claude
e13dbe5ffa
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
2026-10-03 13:27:39 +00:00
Claude
ddf13adf08
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
2026-10-03 13:27:38 +00:00
Claude
84dff4d846
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
2026-10-03 13:27:38 +00:00
Claude
fa0986bd2d
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
2026-10-03 13:26:59 +00:00
Claude
dc259f0491
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
2026-10-03 13:24:43 +00:00
Claude
8ae07cdf11
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
2026-10-03 13:22:37 +00:00
Claude
fa97f39570
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
2026-10-03 13:21:56 +00:00
Claude
a99e58be00
Merge the Phase 2 integration branch into the coach workstream
Picks up the shared clock-time schema and the notification interfaces so
later merges of the coach work stay conflict-free.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:20:58 +00:00
Claude
98b0798f60
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
2026-10-03 13:20:45 +00:00
Claude
ebcbf4c05f
Merge the Phase 2 integration branch for the shared clock-time contract
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:19:22 +00:00
Claude
a094c23b47
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
2026-10-03 13:19:12 +00:00
Claude
2c88d09eea
Merge branch 'claude/phase2-coach' into p2/a1-onboarding
Brings in the coach and notification interfaces the onboarding goal, rhythm, quiet-hours
and notification steps write to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:17:20 +00:00
Claude
3f85cb0420
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
2026-10-03 13:17:17 +00:00
Claude
f4e02eb248
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
2026-10-03 13:17:06 +00:00
Claude
74503ad663
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
2026-10-03 13:16:27 +00:00
Claude
e1ce6f4090
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
2026-10-03 13:15:06 +00:00
Claude
b124d2d7e5
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
2026-10-03 13:14:17 +00:00
Claude
6ad9be6c1e
Merge notification interfaces into the Phase 2 integration branch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:14:07 +00:00
Claude
a163447461
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
2026-10-03 13:13:22 +00:00
Claude
4b59b0e8b0
Merge coach interfaces into the Phase 2 integration branch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:12:22 +00:00
Claude
7d7af8f3e9
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
2026-10-03 13:12:08 +00:00
Claude
4875753d9c
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
2026-10-03 13:10:25 +00:00
Claude
84ee83d7bd
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
2026-10-03 13:09:57 +00:00
Claude
105a3094b1
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
2026-10-03 13:08:58 +00:00
Claude
c22a25d7ab
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
2026-10-03 13:06:50 +00:00
Claude
7ff93f08bb
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
2026-10-03 13:04:16 +00:00
Claude
693ab5e4ce
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
2026-10-03 13:04:16 +00:00
765 changed files with 14631 additions and 91505 deletions

View File

@ -99,12 +99,8 @@ 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).
@ -206,10 +202,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/cynarai/evoo-local/app
RAKAZO_IMAGE=ghcr.io/elie222/rakazo/app
RAKAZO_IMAGE_TAG=local
RAKAZO_IMAGE_TAG_PREVIOUS=
RAKAZO_UPDATER_IMAGE=ghcr.io/cynarai/evoo-local/updater
RAKAZO_UPDATER_IMAGE=ghcr.io/elie222/rakazo/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,

View File

@ -1,5 +1,5 @@
name: Bug report
description: Something is broken in evOo
description: Something is broken in Rakazo
title: "[Bug]: "
labels:
- bug
@ -7,7 +7,7 @@ body:
- type: markdown
attributes:
value: |
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).
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).
- type: checkboxes
id: not-security
@ -55,7 +55,7 @@ body:
- type: input
id: version
attributes:
label: evOo version or commit
label: Rakazo version or commit
placeholder: v0.1.0 or abc1234
validations:
required: true

View File

@ -1,5 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: Security vulnerabilities
url: https://github.com/cynarAI/evOo-local/security/advisories/new
about: Report security issues privately. Do not open a public issue.
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.

View File

@ -1,5 +1,5 @@
name: Self-host setup
description: Cannot start evOo, run compose, migrate, or build the sandbox
description: Cannot start Rakazo, 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**, 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).
For **security vulnerabilities**, email [security@rakazo.com](mailto:security@rakazo.com). See [SECURITY.md](https://github.com/elie222/rakazo/blob/main/SECURITY.md).
Setup steps are in [README.md](https://github.com/cynarAI/evOo-local/blob/main/README.md). Do not paste `.env` contents or API keys.
Setup steps are in [README.md](https://github.com/elie222/rakazo/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: evOo version or commit
label: Rakazo 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 report handoff | Soft-fail (`continue-on-error`); the report stays on the evo runner host for `publish Playwright report` |
| Playwright / desktop artifact uploads | Soft-fail (`continue-on-error`) when GitHub artifact quota is exhausted |
| `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,10 +71,7 @@ jobs:
HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
run: |
set -euo pipefail
conclusions=()
while IFS= read -r conclusion; do
[[ -n "$conclusion" ]] && conclusions+=("$conclusion")
done < <(
mapfile -t conclusions < <(
gh api "repos/${GITHUB_REPOSITORY}/commits/${HEAD_SHA}/check-runs" --paginate \
--jq '.check_runs[] | select(.name == "Product CI") | .conclusion'
)

View File

@ -5,87 +5,19 @@ 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
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
runs-on: [self-hosted, evoo-ci, Linux]
steps:
- uses: actions/checkout@v5
with:
@ -113,15 +45,13 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
check:
name: Typecheck
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
runs-on: [self-hosted, evoo-ci, Linux]
steps:
- uses: actions/checkout@v5
with:
@ -149,6 +79,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm db:generate
env:
@ -157,8 +88,6 @@ 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:
@ -188,6 +117,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
env:
@ -196,13 +126,18 @@ 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:
@ -230,6 +165,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm db:generate
env:
@ -238,8 +174,6 @@ 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:
@ -269,28 +203,24 @@ 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:
hand_off_report: true
upload_artifacts: true
product-gate:
name: Product CI
if: always()
needs: [changes, lint, check, build, test, test-integration, test-e2e]
runs-on: [self-hosted, evoo-ci]
timeout-minutes: 5
needs: [lint, check, build, test, test-integration, test-e2e]
runs-on: [self-hosted, evoo-ci, Linux]
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 }}
@ -299,23 +229,11 @@ 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" == "skipped" ]]; then
echo "$name was skipped although product paths changed." >&2
failed=1
elif [[ "$result" != "success" ]]; then
if [[ "$result" != "success" ]]; then
failed=1
fi
done
@ -327,9 +245,9 @@ jobs:
publish-mobile-update:
name: Publish compatible mobile update
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.changes.outputs.product == 'true'
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
continue-on-error: true
needs: [changes, lint, check, build, test, test-integration, test-e2e]
needs: [lint, check, build, test, test-integration, test-e2e]
runs-on: [self-hosted, evoo-ci, Linux]
timeout-minutes: 20
concurrency:
@ -470,6 +388,7 @@ jobs:
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' && steps.expo_token.outputs.ready == 'true'
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # v9
@ -477,9 +396,6 @@ jobs:
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' && steps.expo_token.outputs.ready == 'true'
run: pnpm install --frozen-lockfile
@ -506,3 +422,46 @@ 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

@ -1,82 +0,0 @@
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,6 +50,7 @@ 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
@ -76,3 +77,14 @@ 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,39 +39,41 @@ jobs:
with:
persist-credentials: false
# Runner-host caches only: never GitHub's cache or artifact storage.
- name: Restore screenshot APK
id: screenshot-apk
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: 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/**') }}
- 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
@ -86,10 +88,8 @@ 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,14 +112,10 @@ jobs:
- name: Save screenshot APK
if: steps.screenshot-apk.outputs.cache-hit != 'true'
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"
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 }}
- name: Enable KVM
run: |
@ -155,3 +151,12 @@ 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
hand_off_report:
description: Leave the report on the runner host for the trusted publish workflow
upload_artifacts:
description: Retain the report and diagnostics as GitHub artifacts
required: false
type: boolean
default: false
@ -54,7 +54,7 @@ jobs:
playwright:
name: Web E2E
runs-on: [self-hosted, evoo-ci, Linux]
timeout-minutes: 60
timeout-minutes: 30
concurrency:
group: ${{ inputs.publish_report && 'playwright-publication' || format('playwright-{0}', github.run_id) }}
cancel-in-progress: false
@ -83,12 +83,16 @@ 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
@ -145,7 +149,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.hand_off_report
if: always() && inputs.upload_artifacts
env:
PLAYWRIGHT_RESULT: ${{ steps.playwright_tests.outcome }}
WWW_PLAYWRIGHT_RESULT: ${{ steps.www_playwright_tests.outcome }}
@ -158,28 +162,19 @@ jobs:
else
printf 'failure\n' > test-report/e2e/outcome.txt
fi
- name: Hand off Playwright report on the runner host
if: always() && inputs.hand_off_report
- name: Upload Playwright artifacts
if: always() && inputs.upload_artifacts
continue-on-error: true
# 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"
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
- name: Publish Playwright visual report
if: always() && inputs.publish_report
env:

View File

@ -37,17 +37,16 @@ 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: |
# 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" \
result="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}/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) ;;
@ -76,31 +75,38 @@ jobs:
echo "::warning::Could not resolve a pull request for ${HEAD_OWNER}:${HEAD_BRANCH} at ${HEAD_SHA}."
fi
echo "number=$number" >> "$GITHUB_OUTPUT"
- name: Take over Playwright report from the runner host
- name: Check for Playwright artifact
id: artifact
env:
HANDOFF_ARCHIVE: ${{ github.event.workflow_run.id }}-${{ github.event.workflow_run.run_attempt }}.zip
ARTIFACT_NAME: playwright-artifacts-${{ github.event.workflow_run.id }}-${{ github.event.workflow_run.run_attempt }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PLAYWRIGHT_JOB_RESULT: ${{ steps.playwright.outputs.job_result }}
RUN_ID: ${{ github.event.workflow_run.id }}
shell: bash
run: |
# 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"
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
echo "exists=true" >> "$GITHUB_OUTPUT"
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 "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."
echo "exists=false" >> "$GITHUB_OUTPUT"
else
echo "Missing Playwright report $handoff/$HANDOFF_ARCHIVE on this runner host." >&2
echo "Expected exactly one unexpired Playwright artifact; found $count." >&2
exit 1
fi
- name: Extract Playwright report
- name: Download Playwright artifacts
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,26 +3,9 @@ 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:
@ -44,55 +27,12 @@ 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.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
group: publish-server-image-${{ github.ref }}
cancel-in-progress: false
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'
@ -130,16 +70,7 @@ jobs:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with:
persist-credentials: false
- 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 }}
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Resolve the lowercase image namespace
id: ns
# GHCR rejects repository names with capitals; the GitHub repository slug has them.
@ -149,37 +80,28 @@ 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) || '' }}
# Pull requests only read the cache main writes.
cache-from: type=local,src=${{ steps.cache.outputs.dir }}
cache-from: type=gha,scope=${{ matrix.name }}
cache-to: type=gha,mode=max,scope=pr-${{ github.event.pull_request.number }}-${{ matrix.name }}
provenance: false
sbom: false
build:
name: Build ${{ matrix.name }} ${{ matrix.arch }}
needs: changes
if: github.event_name != 'pull_request' && needs.changes.outputs.publish == 'true'
if: github.event_name != 'pull_request'
# 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
@ -206,16 +128,7 @@ jobs:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with:
persist-credentials: false
- 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 }}
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Log in to GHCR
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
@ -232,14 +145,9 @@ 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 }}
@ -248,37 +156,33 @@ 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=local,src=${{ steps.cache.outputs.dir }}
cache-to: type=local,mode=max,dest=${{ steps.cache.outputs.dir }}-new
cache-from: type=gha,scope=${{ matrix.name }}-${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=${{ matrix.name }}-${{ matrix.arch }}
provenance: mode=max
sbom: true
- name: Replace the build cache
# A local cache only grows; swapping in the fresh export drops layers this build no longer uses.
env:
CACHE_DIR: ${{ steps.cache.outputs.dir }}
run: |
set -euo pipefail
rm -rf "$CACHE_DIR"
mv "$CACHE_DIR-new" "$CACHE_DIR"
- name: Tag digest for publish job handoff
- name: Record the architecture digest
id: digest
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}"
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
publish:
name: Publish ${{ matrix.name }} image
if: github.event_name != 'pull_request' && needs.changes.outputs.publish == 'true'
needs: [changes, build]
if: github.event_name != 'pull_request'
needs: 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
@ -292,16 +196,15 @@ jobs:
- name: updater
- name: computer
steps:
- 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
- name: Prepare an isolated digest directory
id: digest_dir
run: echo "path=${RUNNER_TEMP}/digests" >> "$GITHUB_OUTPUT"
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: ci-${{ github.run_id }}-${{ github.run_attempt }}-publish-${{ matrix.name }}
pattern: digest-${{ matrix.name }}-*
path: ${{ steps.digest_dir.outputs.path }}
merge-multiple: true
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Log in to GHCR
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
@ -312,21 +215,6 @@ 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:

View File

@ -18,8 +18,6 @@ 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 }}
@ -97,32 +95,17 @@ 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
# 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")"
# No self-hosted Windows runner; keep GitHub-hosted only when signing credentials exist.
include="$(jq -c '. + [{"runner":"windows-2022","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:
@ -189,8 +172,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: cynarAI" "$app/Contents/Resources/app-update.yml"
grep -Fqx "repo: evOo-local" "$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"
- name: Require Windows signing credentials
if: runner.os == 'Windows'
@ -231,7 +214,7 @@ jobs:
throw "Windows update config is not bound to the signing publisher."
}
$feed = Get-Content -Raw $config
foreach ($expected in @("provider: github", "owner: cynarAI", "repo: evOo-local")) {
foreach ($expected in @("provider: github", "owner: elie222", "repo: rakazo")) {
if ($feed -notmatch "(?m)^$([regex]::Escape($expected))\r?$") {
throw "Windows update config missing '$expected'."
}
@ -256,32 +239,25 @@ jobs:
test -n "$config"
test -f "$config"
grep -Fqx "provider: github" "$config"
grep -Fqx "owner: cynarAI" "$config"
grep -Fqx "repo: evOo-local" "$config"
grep -Fqx "owner: elie222" "$config"
grep -Fqx "repo: rakazo" "$config"
test -f apps/desktop/out/latest-linux.yml
grep -Fqx "version: $RELEASE_VERSION" apps/desktop/out/latest-linux.yml
- 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=(
- name: Retain installers and updater metadata
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: desktop-${{ matrix.artifact }}-${{ needs.validate.outputs.sha }}
path: |
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 [[ ${#files[@]} -eq 0 ]]; then
echo "The build produced no release files." >&2
exit 1
fi
bash scripts/upload-release-assets.sh "${files[@]}"
if-no-files-found: error
compression-level: 0
retention-days: 7
publish:
name: Attest and publish complete release
@ -299,11 +275,11 @@ jobs:
fetch-depth: 0
persist-credentials: false
ref: ${{ needs.validate.outputs.sha }}
- 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
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
pattern: desktop-*-${{ needs.validate.outputs.sha }}
path: release-artifacts
merge-multiple: true
- name: Verify the complete stable update feed
env:
RELEASE_SHA: ${{ needs.validate.outputs.sha }}
@ -347,34 +323,26 @@ jobs:
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
with:
subject-path: release-artifacts/*
- name: Add checksums to the draft
- name: Create draft and upload every platform
id: create_release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ needs.validate.outputs.tag }}
run: gh release upload "$RELEASE_TAG" release-artifacts/SHA256SUMS --clobber
run: >-
gh release create "$RELEASE_TAG" release-artifacts/*
--draft --generate-notes --title "Rakazo $RELEASE_TAG" --verify-tag
- 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
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
- name: Remove an incomplete draft
if: failure() && steps.create_release.outcome != 'skipped'
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?per_page=100" \
--jq "[.[] | select(.draft and .tag_name == \"${RELEASE_TAG}\")] | length")" != "0" ]]; then
if [[ "$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}" --jq .draft 2>/dev/null)" == "true" ]]; then
gh release delete "$RELEASE_TAG" --yes
fi

View File

@ -19,6 +19,7 @@ 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

View File

@ -11,7 +11,6 @@
- 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,114 +0,0 @@
# 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

@ -49,7 +49,7 @@ 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)
- Pi model routing, Docker sandboxes, Voice (Kokoro local / BYO keys)
## Antigravity tools kept on this branch
@ -68,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
```
German voices come from a local Piper server (`PIPER_BASE_URL`). Details: [docs/kokoro-voice.md](./docs/kokoro-voice.md)
Details: [docs/kokoro-voice.md](./docs/kokoro-voice.md)
## Tech debt (brand / packages)

View File

@ -1,135 +0,0 @@
# 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
```

View File

@ -1,123 +0,0 @@
# 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,8 +60,6 @@ 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

@ -26,13 +26,9 @@ function savedSkill(name: string, source = "user") {
}
function setup(rows: ReturnType<typeof savedSkill>[] = []) {
type Where = { id?: string; spaceId?: string; userId: string; source?: string; name?: unknown };
type Where = { id?: string; spaceId: string; userId: string; source?: string };
const matches = (row: ReturnType<typeof savedSkill>, where: Where) =>
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,
);
Object.entries(where).every(([key, value]) => row[key as keyof typeof row] === value);
const agentSkill = {
findMany: vi.fn(async ({ where }: { where: Where }) =>
rows.filter((row) => matches(row, where)),
@ -59,19 +55,15 @@ 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)]);
expect(interrogate(await service.list(actor))).toEqual([
await expect(service.list(actor)).resolves.toEqual([
expect.objectContaining({ id: "saved-1", name, readOnly: false }),
]);
expect(interrogate(await service.listWithContent(actor))).toEqual([
await expect(service.listWithContent(actor)).resolves.toEqual([
expect.objectContaining({ id: "saved-1", content: expect.stringContaining("Saved steps") }),
]);
await expect(service.get(actor, { name: " INTERROGATE " })).resolves.toMatchObject({
@ -105,23 +97,7 @@ describe("built-in skill precedence in the API", () => {
expect(agentSkill.deleteMany).not.toHaveBeenCalled();
});
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" }])(
it.each([{ spaceId: "other-space" }, { userId: "other-user" }])(
"does not let foreign skills shadow the builtin: %j",
async (foreignOwner) => {
const { service } = setup([{ ...savedSkill(" Interrogate "), ...foreignOwner }]);
@ -129,7 +105,7 @@ describe("built-in skill precedence in the API", () => {
id: "builtin:Interrogate",
readOnly: true,
});
expect(interrogate(await service.list(actor))).toEqual([
await expect(service.list(actor)).resolves.toEqual([
expect.objectContaining({ id: "builtin:Interrogate" }),
]);
await expect(service.get(actor, { skillId: "saved-1" })).rejects.toThrow();

View File

@ -7,7 +7,6 @@ import {
isSkillReadOnly,
mergeBuiltinSkills,
parseSkillMd,
preferSpaceSkills,
type SkillSource,
} from "@rakazo/core";
import { IsolationError, type PrismaClient } from "@rakazo/db";
@ -122,7 +121,11 @@ 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, userId: actor.userId },
where: {
id: skillId,
spaceId: actor.spaceId,
userId: actor.userId,
},
});
if (!row) throw new IsolationError();
return row;
@ -131,24 +134,20 @@ export function createAgentSkillsService(prisma: PrismaClient) {
return {
async list(actor: Actor): Promise<Omit<AgentSkill, "content">[]> {
const rows = await prisma.agentSkill.findMany({
where: { userId: actor.userId },
where: { spaceId: actor.spaceId, userId: actor.userId },
orderBy: [{ name: "asc" }, { id: "asc" }],
});
return mergeBuiltinSkills(
builtinCatalog(),
preferSpaceSkills(rows, actor.spaceId).map(mapAgentSkill),
).map(({ content: _content, ...entry }) => entry);
return mergeBuiltinSkills(builtinCatalog(), rows.map(mapAgentSkill)).map(
({ content: _content, ...entry }) => entry,
);
},
async listWithContent(actor: Actor): Promise<AgentSkill[]> {
const rows = await prisma.agentSkill.findMany({
where: { userId: actor.userId },
where: { spaceId: actor.spaceId, userId: actor.userId },
orderBy: [{ name: "asc" }, { id: "asc" }],
});
return mergeBuiltinSkills(
builtinCatalog(),
preferSpaceSkills(rows, actor.spaceId).map(mapAgentSkill),
);
return mergeBuiltinSkills(builtinCatalog(), rows.map(mapAgentSkill));
},
async get(actor: Actor, input: { skillId?: string; name?: string }): Promise<AgentSkill> {
@ -162,10 +161,13 @@ export function createAgentSkillsService(prisma: PrismaClient) {
}
const name = input.name?.trim() ?? "";
const rows = await prisma.agentSkill.findMany({
where: { userId: actor.userId },
where: {
spaceId: actor.spaceId,
userId: actor.userId,
},
orderBy: [{ name: "asc" }, { id: "asc" }],
});
const row = findSkillByName(preferSpaceSkills(rows, actor.spaceId), name);
const row = findSkillByName(rows, name);
if (row) return mapAgentSkill(row);
const builtin = findSkillByName(builtinCatalog(), name);
if (!builtin) throw new IsolationError();
@ -179,6 +181,7 @@ 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" },
},
@ -231,6 +234,7 @@ 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 },
@ -246,7 +250,12 @@ 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, userId: actor.userId, source: "user" },
where: {
id: existing.id,
spaceId: actor.spaceId,
userId: actor.userId,
source: "user",
},
data: {
name: resolved.name,
description: resolved.description,
@ -266,7 +275,11 @@ export function createAgentSkillsService(prisma: PrismaClient) {
throw error;
}
const row = await prisma.agentSkill.findFirst({
where: { id: existing.id, userId: actor.userId },
where: {
id: existing.id,
spaceId: actor.spaceId,
userId: actor.userId,
},
});
if (!row) throw new IsolationError();
return mapAgentSkill(row);
@ -278,7 +291,12 @@ 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, userId: actor.userId, source: "user" },
where: {
id: existing.id,
spaceId: actor.spaceId,
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: "kokoro" }, voiceId: "af_heart" })),
findMany: vi.fn(async () => [{ credential: { provider: "kokoro" } }]),
findFirst: vi.fn(async () => ({ credential: { provider: "openai" }, voiceId: "alloy" })),
findMany: vi.fn(async () => [{ credential: { provider: "openai" } }]),
},
spaceMemoryConfig: {
findUnique: vi.fn(async () => null),
@ -104,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: "kokoro" } },
{ credential: { provider: "scripted" } },
{ credential: { provider: "openai" } },
{ credential: { provider: "elevenlabs" } },
] as never);
const status = await aiConsentStatus(deps, actor, { uses: ["voice"] });
expect(status.recipients).toHaveLength(1);
expect(status.recipients[0]?.name).toBe("Local voice");
expect(status.recipients[0]?.name).toBe("OpenAI");
expect(deps.prisma.spaceVoicePreference.findMany).not.toHaveBeenCalled();
});
it("discloses memory provider details to space owners", async () => {
@ -155,12 +155,7 @@ describe("consent grants", () => {
]);
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)"]);
expect((await aiConsentStatus(deps, actor, { uses: ["voice"] })).recipients).toHaveLength(1);
});
it("discloses the transcription server for dictation when the voice provider cannot transcribe", async () => {
const { deps, actor } = setup();

View File

@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
import { ORPCError, onError, ValidationError } from "@orpc/server";
import { ORPCError, onError } from "@orpc/server";
import { RPCHandler } from "@orpc/server/fetch";
import type {
AgentRuntime,
@ -29,14 +29,12 @@ import {
createMediaRuntime,
createMessagingContextLoader,
createMessagingTeamChatSender,
createPostgresReconciliationLeadership,
createProfileConsolidationScheduler,
createRunExecutor,
createRunSandbox,
createRunSecretWriter,
createWebProvider,
destroyBot,
destroySpaceSandboxes,
EmailEmulator,
EncryptedSecretStore,
ExpoPushSender,
@ -85,7 +83,6 @@ import {
parsePositiveInteger,
provisionMessagingIdentity,
requireMembership,
spaceIdsForSandboxTeardownAfterAccountDelete,
} from "@rakazo/db";
import type { Logger } from "@rakazo/logging";
import {
@ -121,7 +118,6 @@ import {
TEAM_CHAT_STARTUP_SHUTDOWN_MS,
} from "./team-chat-startup.js";
import { mountVoiceHttpRoutes } from "./voice.js";
import { isLoopbackHost, isPrivateOrLocalHost, requestWebOrigin } from "./web-origin.js";
import { mountWebhookHttpRoutes } from "./webhook.js";
export interface AppHandles {
@ -361,45 +357,30 @@ export async function createApp(
"https://172.*:*",
"http://*.local:*",
"https://*.local:*",
...(env.extraTrustedOrigins ?? []),
...(process.env.EXTRA_TRUSTED_ORIGINS?.split(",").map((s) => s.trim()) ?? []),
],
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(
lifecycleDeps,
{ prisma, sandbox, home, jobs, artifacts, dataDir: env.dataDir },
bot,
{ ...deleteContext, spaceId: bot.spaceId, botId: bot.id },
{
operationId: `account-delete:${userId}`,
traceId: `account-delete:${userId}`,
spaceId: bot.spaceId,
userId,
botId: bot.id,
signal: new AbortController().signal,
},
{ 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);
},
});
@ -475,30 +456,26 @@ export async function createApp(
cloudAgent,
media,
});
let graphileWorkerHost: GraphileJobWorkerHost | undefined;
if (inMemoryJobs) {
await inMemoryJobs.start(jobHandlers);
} else if (jobPool) {
graphileWorkerHost = new GraphileJobWorkerHost(jobPool, {
concurrency: parsePositiveInteger(process.env.GRAPHILE_WORKER_CONCURRENCY, 4),
const graphileWorkerHost = new GraphileJobWorkerHost(jobPool, {
concurrency: 4,
pollInterval: 500,
});
await graphileWorkerHost.start(jobHandlers);
}
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;
const reconciler = inMemoryJobs
? createJobReconciler({
prisma,
jobs,
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({
@ -613,12 +590,7 @@ export async function createApp(
}
const { matched, response } = await rpc.handle(c.req.raw, {
prefix: "/rpc",
context: {
actor,
signal: c.req.raw.signal,
userAgent: c.req.header("user-agent"),
webOrigin: requestWebOrigin(c.req.header("origin"), env.webOrigin, env.extraTrustedOrigins),
},
context: { actor, signal: c.req.raw.signal, userAgent: c.req.header("user-agent") },
});
if (matched) return c.newResponse(response.body, response);
await next();
@ -1011,7 +983,6 @@ 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();
@ -1027,7 +998,6 @@ 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;
@ -1037,6 +1007,20 @@ 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");
@ -1055,18 +1039,7 @@ 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

@ -175,7 +175,7 @@ describe("upload timeouts", () => {
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.requestTimeout).toBe(30 * 60 * 1000);
expect(server.headersTimeout).toBe(createServer().headersTimeout);
});

View File

@ -1,8 +1,8 @@
import type { AdapterContext, ArtifactByteRange, ArtifactStore } from "@rakazo/adapter-kit";
import { ArtifactTooLargeError } from "@rakazo/adapter-kit";
import { storeArtifactStream } from "@rakazo/adapters";
import { artifactMaxBytes, storeArtifactStream } from "@rakazo/adapters";
import type { Actor } from "@rakazo/contracts";
import { artifactMaxBytes, attachmentKindOf, isAllowedAttachmentMimeType } from "@rakazo/contracts";
import { 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";
@ -11,11 +11,11 @@ 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.
* Node's default 5-minute requestTimeout cuts off a 500 MiB video upload on any uplink slower than
* about 14 Mbit/s; 30 minutes fits it at about 2.5 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 };
export const API_SERVER_OPTIONS = { requestTimeout: 30 * 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;

View File

@ -1,106 +0,0 @@
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

@ -20,7 +20,6 @@ export interface AppEnv {
authSecret: string;
authUrl: string;
webOrigin: string;
extraTrustedOrigins: string[];
privacyPolicyUrl?: string;
apiUrl: string;
apiHost: string;
@ -110,10 +109,6 @@ 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",

View File

@ -1,10 +1,8 @@
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";
@ -293,45 +291,4 @@ describe("buildInboxItems", () => {
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,5 +1,5 @@
import type { Actor, InboxItem, InboxThreadTarget } from "@rakazo/contracts";
import { MessageBlock, SUGGESTION_TTL_MS } from "@rakazo/contracts";
import { MessageBlock } from "@rakazo/contracts";
import { isApprovalAskBlock } from "@rakazo/core";
import type { PrismaClient } from "@rakazo/db";
import { promptSnippet } from "./runs.js";
@ -10,7 +10,6 @@ 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"]>;
@ -43,18 +42,6 @@ export interface InboxUnreadThreadRow {
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 : [];
@ -111,8 +98,7 @@ function byCreatedAt(left: InboxItem, right: InboxItem): number {
}
/**
* Turns waiting runs, their ask and takeover messages, open suggestions and unread threads into
* inbox items:
* Turns waiting runs, their ask and takeover messages, and unread threads into inbox items:
* everything that waits on the user first, then unread threads not already covered, each
* oldest first. Items from another space name it.
*/
@ -121,8 +107,6 @@ export function buildInboxItems(input: {
/** 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[] {
@ -172,23 +156,6 @@ 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) {
@ -213,19 +180,13 @@ export function buildInboxItems(input: {
const botSelect = { id: true, name: true, color: true } as const;
/**
* 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.
* The other spaces whose items the inbox shows: every space the user belongs to, except spaces
* being deleted.
*/
async function otherInboxSpaces(
prisma: PrismaClient,
actor: Actor,
): Promise<Map<string, InboxSpace>> {
const 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 } } },
@ -242,12 +203,7 @@ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<Inb
const otherSpaces = await otherInboxSpaces(prisma, actor);
const spaceIds = [actor.spaceId, ...otherSpaces.keys()];
const ownBot = { userId: actor.userId, archivedAt: null };
const threadSelect = {
botId: true,
groupId: true,
group: { select: { kind: true, archivedAt: true } },
} as const;
const [runs, unreadThreads, suggestionEffects] = await Promise.all([
const [runs, unreadThreads] = await Promise.all([
prisma.run.findMany({
where: {
spaceId: { in: spaceIds },
@ -263,7 +219,13 @@ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<Inb
threadId: true,
updatedAt: true,
bot: { select: botSelect },
thread: { select: threadSelect },
thread: {
select: {
botId: true,
groupId: true,
group: { select: { kind: true, archivedAt: true } },
},
},
},
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
take: INBOX_RUN_LIMIT,
@ -290,54 +252,7 @@ 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
? []
@ -354,5 +269,5 @@ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<Inb
orderBy: { seq: "desc" },
select: { id: true, runId: true, blocks: true, createdAt: true },
});
return buildInboxItems({ runs, messages, unreadThreads, suggestions, otherSpaces });
return buildInboxItems({ runs, messages, unreadThreads, otherSpaces });
}

View File

@ -1,4 +1,4 @@
import { ORPCError, ValidationError } from "@orpc/server";
import { ORPCError } from "@orpc/server";
import { createLogger, createTestSink, installLogger } from "@rakazo/logging";
import { afterEach, describe, expect, it } from "vitest";
import { logUnexpectedRpcError } from "./app.js";
@ -20,28 +20,6 @@ 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

@ -1,6 +1,5 @@
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";
@ -29,7 +28,7 @@ describe("media capabilities", () => {
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)).toHaveLength(6);
expect(Object.values(capabilities).every((value) => value === false)).toBe(true);
});
});

View File

@ -1,82 +0,0 @@
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

@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest";
import {
closingNote,
focusCard,
focusFactValue,
intakeContext,
intakeStep,
nextIntakeStep,
@ -15,13 +14,6 @@ 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");

View File

@ -43,39 +43,13 @@ interface IntakeStep {
save: (effects: IntakeEffects, answer: IntakeAnswer) => Promise<void>;
}
export type FocusOptionId = "day" | "inbox" | "research" | "crew";
export interface FocusOption {
id: FocusOptionId;
id: string;
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];
/** Stored as a `goals` fact, so it stays a plain English statement. */
fact: string;
}
export const FOCUS_OPTIONS: FocusOption[] = [
@ -84,24 +58,28 @@ export const FOCUS_OPTIONS: FocusOption[] = [
letter: "A",
label: "Day-to-day work",
apps: ["slack", "gmail", "googlecalendar"],
fact: "Wants help with day-to-day work first",
},
{
id: "inbox",
letter: "B",
label: "Inbox & email",
apps: ["gmail", "googlecalendar", "slack"],
fact: "Wants help with inbox and email first",
},
{
id: "research",
letter: "C",
label: "Research & writing",
apps: ["hackernews", "notion", "googledocs"],
fact: "Wants help with research and writing first",
},
{
id: "crew",
letter: "D",
label: "Specialist crew",
apps: ["slack", "gmail", "googlecalendar"],
fact: "Wants a coordinated crew of specialist bots",
},
];
@ -139,16 +117,9 @@ export const INTAKE_STEPS: IntakeStep[] = [
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" },
],
addFacts: [{ category: "goals", value: option.fact, source: "onboarding" }],
});
},
},

View File

@ -1,43 +0,0 @@
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

@ -1,95 +0,0 @@
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

@ -1,114 +0,0 @@
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

@ -1,200 +0,0 @@
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

@ -224,12 +224,7 @@ describe("model setup gate", () => {
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
json: expect.objectContaining({
needsModel: false,
modelsManaged: true,
defaultProvider: "openrouter",
defaultModel: "test-model",
}),
json: expect.objectContaining({ needsModel: false }),
});
});
@ -243,13 +238,7 @@ describe("model setup gate", () => {
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
// 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",
}),
json: expect.objectContaining({ needsModel: true }),
});
});
@ -638,11 +627,7 @@ describe("computer screen url", () => {
controlRunId: null,
};
const callScreenUrl = async (
connectScreen: () => Promise<unknown>,
updateMany = vi.fn(),
webOrigin?: string,
) => {
const callScreenUrl = async (connectScreen: () => Promise<unknown>, updateMany = vi.fn()) => {
const prisma = {
bot: {
findFirst: vi.fn().mockResolvedValue({
@ -675,7 +660,7 @@ describe("computer screen url", () => {
headers: { "content-type": "application/json" },
body: JSON.stringify({ json: { botId: "bot-1" } }),
}),
{ prefix: "/rpc", context: { actor, webOrigin } },
{ prefix: "/rpc", context: { actor } },
);
return { response, updateMany };
};
@ -700,16 +685,6 @@ 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",
@ -1002,52 +977,3 @@ 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();
});
});

View File

@ -45,12 +45,10 @@ import {
clearInactiveUserComputerControl,
computerSupportsUpdate,
computerUpdateView,
createBrowserProvider,
createVoiceProvider,
deploymentAutoReviewDefault,
destroyBot,
displayBotWorkspacePath,
engageBrowserCowork,
enqueueTakeoverContinuation,
expireComputerControl,
filterTeamWorkspaceEntries,
@ -73,9 +71,7 @@ import {
probeOpenAiCompatibleModels,
provisionComputer,
queueComputerUpdate,
RECOMMENDED_MODEL,
readProjectWorkspaceFile,
recordBrowserUserAction,
releaseComputerExecutionLease,
replaceComputer,
resolveAutoReviewChecker,
@ -114,15 +110,10 @@ import {
AttachmentValidationError,
containsSecret,
expandSkillReferencesInPrompt,
GITHUB_MCP_SOURCE,
hasMixedOneShotSchedule,
isOneShotRoutineCrons,
isProjectColor,
isProjectIcon,
isValidAvatarValue,
nextCronDateAcrossStrict,
} from "@rakazo/core";
import { readProjectSummary } from "@rakazo/core/node/project-summary";
import type { PrismaClient, ThreadEvents } from "@rakazo/db";
import {
BotSectionNameConflictError,
@ -135,7 +126,6 @@ import {
createProjectGoalRepos,
createRepos,
createSpaceForMember,
DuplicateSpaceNameError,
deleteEmptySpaceForMember,
deleteUnreferencedCredentialSecret,
ensureMainConversation,
@ -151,7 +141,6 @@ import {
isProjectGoalStatus,
issueMessagingLinkCode,
listSpaceUserBotIds,
markRoutineNoticesRemoved,
newestModelCredentialOrder,
newestVoiceCredentialOrder,
ownMainAgentsBlockingSpaceDeletion,
@ -172,9 +161,7 @@ import {
savePushSubscription,
selectSpaceModelPreference,
selectSpaceVoicePreference,
syncProjectMembers,
updateNotificationSettings,
updateSpaceForOwner,
wireProjectWorkspace,
} from "@rakazo/db";
import { getLogger } from "@rakazo/logging";
@ -182,13 +169,6 @@ import { deleteAgentSecret, listAgentSecrets, putAgentSecret } from "./agent-sec
import { createAgentSkillsService } from "./agent-skills.js";
import { aiConsentStatus, allowAiConsent } from "./ai-consent.js";
import { createOwnedArtifact, getOwnedArtifact, getSpaceArtifact } from "./artifacts.js";
import {
getThreadAutopilot,
listRecentAutopilots,
resumeThreadAutopilot,
startThreadAutopilot,
stopThreadAutopilot,
} from "./autopilot.js";
import { importableRoutines, relativeImportedFiles, safeImportPath } from "./bot-import.js";
import { botProfileLabelsChanged, commitBotUpdate } from "./bot-update.js";
import { createCoachService } from "./coach.js";
@ -215,12 +195,6 @@ import {
startMediaJob,
updateMediaSettings,
} from "./media-jobs.js";
import {
addMediaLibrarySource,
listMediaLibrarySources,
removeMediaLibrarySource,
syncMediaLibrarySourceNow,
} from "./media-library.js";
import {
disconnectMemoryProvider,
getMemoryProviderConfig,
@ -241,9 +215,7 @@ import {
onboardingScanStatus,
startOnboardingScan,
} from "./onboarding-scan.js";
import { listHostDirectories } from "./project-directories.js";
import { listUserGithubRepos } from "./project-github.js";
import { listUserRuns } from "./runs.js";
import { listSpaceRuns } from "./runs.js";
import { addScreenProxyCapability } from "./screen-proxy.js";
import { querySpaceSearch } from "./search.js";
import { withSerializableRetry } from "./serializable-retry.js";
@ -260,7 +232,6 @@ import {
assertTeachingTakeoverAllowed,
createTaughtSkillsService,
} from "./taught-skills.js";
import { actOnThreadCard } from "./thread-cards.js";
import {
isPeerRun,
loadAllMessages,
@ -271,10 +242,7 @@ import {
followUpGroupThreadMessage,
groupDefaultBotId,
reactToThreadMessage,
removeQueuedMessage,
resolveThreadTarget,
resumeThreadRuns,
sendQueuedThreadMessagesNow,
sendThreadMessage,
setThreadUnreadState,
stopThreadRuns,
@ -603,11 +571,8 @@ export function createRouter(deps: RouterDeps) {
actor: Actor | null;
signal?: AbortSignal;
userAgent?: string;
/** Browser origin for URLs handed back to this request; see requestWebOrigin. */
webOrigin?: string;
}>();
const repos = createRepos(deps.prisma);
const webOriginFor = (context: { webOrigin?: string }) => context.webOrigin ?? deps.env.webOrigin;
const onboardingDeps = {
prisma: deps.prisma,
events: deps.events,
@ -722,12 +687,6 @@ export function createRouter(deps: RouterDeps) {
spaceNavigationDto(deps, context.actor, repos, groupRepos),
),
create: authed.spaces.create.handler(async ({ context, input }) => {
if (input.icon !== undefined && !isProjectIcon(input.icon)) {
throw new ORPCError("BAD_REQUEST", { message: "Unknown project icon" });
}
if (input.color !== undefined && !isProjectColor(input.color)) {
throw new ORPCError("BAD_REQUEST", { message: "Unknown project color" });
}
// A linked folder lives on the host, and the host is the deployment owner's machine.
if (input.workspacePath && !context.actor.isDeploymentOwner) {
throw new ORPCError("FORBIDDEN");
@ -750,9 +709,6 @@ export function createRouter(deps: RouterDeps) {
currentSpaceId: context.actor.spaceId,
userId: context.actor.userId,
name: input.name,
icon: input.icon,
color: input.color,
gitRemoteUrl: input.gitRemoteUrl,
});
} catch (error) {
if (error instanceof SpaceLimitError || error instanceof InvalidSpaceNameError) {
@ -760,54 +716,9 @@ export function createRouter(deps: RouterDeps) {
}
throw error;
}
const wiredWorkspace = workspacePath
const workspace = workspacePath
? await wireProjectWorkspace(deps.prisma, { spaceId: space.id, workspacePath })
: null;
if (input.gitRemoteUrl && !wiredWorkspace?.git?.remoteUrl) {
await deps.prisma.space.update({
where: { id: space.id },
data: { gitRemoteUrl: input.gitRemoteUrl },
});
}
const workspace =
wiredWorkspace ??
(input.gitRemoteUrl
? {
path: null,
git: {
remoteUrl: input.gitRemoteUrl,
defaultBranch: "main",
headCommit: null,
dirty: false,
dirtyPaths: [],
},
}
: null);
// Without a stated goal, the linked folder's README or manifest says what this is.
const goal =
input.goal?.trim() || (workspacePath ? await readProjectSummary(workspacePath) : null);
if (goal) {
const existingGoal = await deps.prisma.memoryDocument.findFirst({
where: { spaceId: space.id, scope: "user", path: "PROJECT_GOAL.md" },
select: { id: true },
});
if (existingGoal) {
await deps.prisma.memoryDocument.update({
where: { id: existingGoal.id },
data: { content: goal },
});
} else {
await deps.prisma.memoryDocument.create({
data: {
spaceId: space.id,
userId: context.actor.userId,
scope: "user",
path: "PROJECT_GOAL.md",
content: goal,
},
});
}
}
// A project starts as a conversation with its own main agent.
await ensureMainConversation(deps.prisma, context.actor.userId, space.id);
const spaceActor = await requireMembership(deps.prisma, context.actor.userId, space.id);
@ -818,14 +729,10 @@ export function createRouter(deps: RouterDeps) {
return {
id: space.id,
name: space.name,
icon: input.icon ?? null,
color: input.color ?? null,
isDefault: false,
hasContent: false,
canDelete: true,
canEdit: true,
workspace,
goal,
bots: bots.map(spaceBotDto),
groups: groups.map(spaceGroupDto),
externalConversations: [],
@ -846,81 +753,6 @@ export function createRouter(deps: RouterDeps) {
spaceId: conversation.spaceId,
};
}),
update: authed.spaces.update.handler(async ({ context, input }) => {
if (input.icon !== undefined && input.icon !== null && !isProjectIcon(input.icon)) {
throw new ORPCError("BAD_REQUEST", { message: "Unknown project icon" });
}
if (input.color !== undefined && input.color !== null && !isProjectColor(input.color)) {
throw new ORPCError("BAD_REQUEST", { message: "Unknown project color" });
}
if (input.workspacePath && !context.actor.isDeploymentOwner) {
throw new ORPCError("FORBIDDEN");
}
let workspacePath: string | null | undefined;
if (input.workspacePath) {
try {
workspacePath = await resolveProjectWorkspaceDirectory(input.workspacePath);
} catch (error) {
if (error instanceof InvalidProjectWorkspacePathError) {
throw new ORPCError("BAD_REQUEST", { message: error.message });
}
throw error;
}
} else if (input.workspacePath === null) {
workspacePath = null;
}
try {
await updateSpaceForOwner(deps.prisma, {
userId: context.actor.userId,
spaceId: input.spaceId,
name: input.name,
icon: input.icon,
color: input.color,
workspacePath,
gitRemoteUrl: input.gitRemoteUrl,
});
if (workspacePath) {
await wireProjectWorkspace(deps.prisma, { spaceId: input.spaceId, workspacePath });
}
} catch (error) {
if (error instanceof SpaceNotFoundError) {
throw new ORPCError("NOT_FOUND", { message: error.message });
}
if (error instanceof IsolationError) throw new ORPCError("FORBIDDEN");
if (error instanceof InvalidSpaceNameError || error instanceof DuplicateSpaceNameError) {
throw new ORPCError("BAD_REQUEST", { message: error.message });
}
throw error;
}
if (input.goal !== undefined) {
const existingGoal = await deps.prisma.memoryDocument.findFirst({
where: { spaceId: input.spaceId, scope: "user", path: "PROJECT_GOAL.md" },
});
if (input.goal?.trim()) {
if (existingGoal) {
await deps.prisma.memoryDocument.update({
where: { id: existingGoal.id },
data: { content: input.goal.trim() },
});
} else {
await deps.prisma.memoryDocument.create({
data: {
spaceId: input.spaceId,
userId: context.actor.userId,
scope: "user",
path: "PROJECT_GOAL.md",
content: input.goal.trim(),
},
});
}
} else if (existingGoal) {
await deps.prisma.memoryDocument.delete({ where: { id: existingGoal.id } });
}
}
return { ok: true as const };
}),
remove: authed.spaces.remove.handler(async ({ context, input }) => {
let claimId: string | null = null;
let claimActive = false;
@ -1080,15 +912,6 @@ export function createRouter(deps: RouterDeps) {
if (claimRenewal) clearInterval(claimRenewal);
}
}),
listDirectories: authed.spaces.listDirectories.handler(async ({ context, input }) => {
if (!context.actor.isDeploymentOwner) {
throw new ORPCError("FORBIDDEN");
}
return listHostDirectories(input.path);
}),
listGithubRepos: authed.spaces.listGithubRepos.handler(async ({ input }) =>
listUserGithubRepos(deps.prisma, input, deps.secrets),
),
},
bootstrap: authed.bootstrap.handler(async ({ context, input }) => {
const actor = context.actor;
@ -1124,7 +947,7 @@ export function createRouter(deps: RouterDeps) {
try {
const { AppBootstrapSchema } = await import("@rakazo/contracts");
AppBootstrapSchema.parse(output);
} catch (_e) {}
} catch (_e: any) {}
return output;
}),
@ -1376,7 +1199,6 @@ export function createRouter(deps: RouterDeps) {
}),
create: authed.bots.create.handler(async ({ context, input }) => {
assertBuiltinToolNames(input.allowedBuiltinTools);
assertAvatarValue(input.color);
try {
return await repos.createBot(context.actor, input);
} catch (error) {
@ -1531,7 +1353,6 @@ export function createRouter(deps: RouterDeps) {
update: authed.bots.update.handler(async ({ context, input }) => {
const existing = await repos.getBot(context.actor, input.botId);
assertBuiltinToolNames(input.allowedBuiltinTools);
assertAvatarValue(input.color);
if (input.sectionId) {
const section = await deps.prisma.botSection.findFirst({
where: {
@ -1664,10 +1485,6 @@ export function createRouter(deps: RouterDeps) {
input.name,
);
}
await syncProjectMembers(deps.prisma, {
userId: context.actor.userId,
libraryBotId: existing.id,
});
const bots = await repos.listBots(context.actor);
const bot = bots.find((b) => b.id === input.botId);
if (!bot) throw new IsolationError();
@ -2048,59 +1865,11 @@ export function createRouter(deps: RouterDeps) {
}
return { ok: true as const };
}),
act: authed.threads.act.handler(async ({ context, input }) => {
const target = await resolveThreadTarget(deps.prisma, context.actor, input);
return actOnThreadCard(deps, context.actor, target, input, {
requireModel: async () => {
if ((await modelSetup(deps, context.actor)).needsModel) {
throw new ORPCError("BAD_REQUEST", { message: "Connect a model to start a run." });
}
},
});
}),
stop: authed.threads.stop.handler(async ({ context, input }) => {
const target = await resolveThreadTarget(deps.prisma, context.actor, input);
// Stopping the thread also stops its autopilot, or the next run would start right away.
await stopThreadAutopilot(deps, context.actor, target);
await stopThreadRuns(deps, context.actor, target);
return { ok: true as const };
}),
resume: authed.threads.resume.handler(async ({ context, input }) => {
if ((await modelSetup(deps, context.actor)).needsModel) {
throw new ORPCError("BAD_REQUEST", { message: "Connect a model to start a run." });
}
const target = await resolveThreadTarget(deps.prisma, context.actor, input);
if (target.kind === "bot") {
await assertTeachingSendAllowed(deps.prisma, context.actor.spaceId, target.botId);
}
return resumeThreadRuns(deps, context.actor, target);
}),
sendNow: authed.threads.sendNow.handler(async ({ context, input }) => {
if ((await modelSetup(deps, context.actor)).needsModel) {
throw new ORPCError("BAD_REQUEST", { message: "Connect a model to start a run." });
}
const target = await resolveThreadTarget(deps.prisma, context.actor, input);
if (target.kind === "bot") {
await assertTeachingSendAllowed(deps.prisma, context.actor.spaceId, target.botId);
}
return sendQueuedThreadMessagesNow(deps, context.actor, target, {
messageId: input.messageId,
});
}),
removeQueuedMessage: authed.threads.removeQueuedMessage.handler(
async ({ context, input }) => {
const target = await resolveThreadTarget(deps.prisma, context.actor, input);
if (target.kind === "bot") {
await assertTeachingSendAllowed(deps.prisma, context.actor.spaceId, target.botId);
}
return removeQueuedMessage(
{ prisma: deps.prisma, events: deps.events },
context.actor,
target,
input.messageId,
);
},
),
clear: authed.threads.clear.handler(async ({ context, input }) => {
const target = await resolveThreadTarget(deps.prisma, context.actor, input);
const contextBotId = target.kind === "bot" ? target.botId : groupDefaultBotId(target);
@ -2504,33 +2273,18 @@ export function createRouter(deps: RouterDeps) {
select: { botId: true, status: true },
})
: null;
const leaseRunWaits =
const waitingForTakeover =
executionRun?.botId === bot.id &&
(executionRun.status === "waiting_takeover" ||
bot.computer.controlRunId === executionLease?.runId);
const leaseBusy = executionBlocksUserTakeover({
hasLease: Boolean(executionLease),
leaseExpiresAt: executionLease?.expiresAt,
runStatus: executionRun?.status,
});
// A run whose setup failed while it waited keeps waiting_takeover after its execution
// lease is released, or behind an older run's inactive lease. Bind it anyway, or handing
// control back never resumes it.
const leaselessWaitingRun =
leaseRunWaits || leaseBusy
? null
: await deps.prisma.run.findFirst({
where: {
botId: bot.id,
spaceId: context.actor.spaceId,
status: "waiting_takeover",
},
orderBy: { createdAt: "desc" },
select: { id: true },
});
const waitingRunId = leaseRunWaits ? executionLease?.runId : leaselessWaitingRun?.id;
const waitingForTakeover = leaseRunWaits || Boolean(leaselessWaitingRun);
if (leaseBusy && !waitingForTakeover) {
if (
executionBlocksUserTakeover({
hasLease: Boolean(executionLease),
leaseExpiresAt: executionLease?.expiresAt,
runStatus: executionRun?.status,
takeoverRequested: waitingForTakeover,
})
) {
throw new ORPCError("CONFLICT", { message: "Stop the bot first" });
}
// Keep an inactive lease as a fencing tombstone. The next run reclaims it
@ -2551,7 +2305,7 @@ export function createRouter(deps: RouterDeps) {
controlLeaseId: leaseId,
controlLeaseExpiresAt: expiresAt,
controlBotId: bot.id,
controlRunId: waitingForTakeover ? waitingRunId : null,
controlRunId: waitingForTakeover ? executionLease?.runId : null,
state: "running",
},
});
@ -2861,18 +2615,13 @@ export function createRouter(deps: RouterDeps) {
!(hasActiveComputerControl(bot.computer) && bot.computer.controlBotId === bot.id),
);
return {
url: addScreenProxyCapability(
viewUrl,
deps.env.screenProxySecret,
webOriginFor(context),
{
botId: bot.id,
computerId: computer.id,
botGeneration: bot.screenGeneration,
computerGeneration: computer.screenGeneration,
controlLeaseId: computer.controlLeaseId,
},
),
url: addScreenProxyCapability(viewUrl, deps.env.screenProxySecret, deps.env.webOrigin, {
botId: bot.id,
computerId: computer.id,
botGeneration: bot.screenGeneration,
computerGeneration: computer.screenGeneration,
controlLeaseId: computer.controlLeaseId,
}),
};
}),
heartbeat: authed.computer.heartbeat.handler(async ({ context, input }) => {
@ -2894,77 +2643,6 @@ export function createRouter(deps: RouterDeps) {
}
return { ok: true as const };
}),
browserChromeState: authed.computer.browserChromeState.handler(async ({ context, input }) => {
const bot = await repos.getBot(context.actor, input.botId);
if (!bot.computer?.providerRef || bot.computer.state !== "running") {
throw new ORPCError("BAD_REQUEST", { message: "computer must be running" });
}
return readBrowserChrome(deps, bot, "state", context);
}),
browserNavigate: authed.computer.browserNavigate.handler(async ({ context, input }) => {
const bot = await repos.getBot(context.actor, input.botId);
if (!bot.computer?.providerRef || bot.computer.state !== "running") {
throw new ORPCError("BAD_REQUEST", { message: "computer must be running" });
}
const browser = createBrowserProvider(undefined, { sandbox: deps.sandbox });
const result = await browser.navigate(
toComputerRef(bot.computer),
{ url: input.url },
computerContext(context.actor, bot.id, "browser.navigate"),
);
if (result.fallback) {
throw new ORPCError("CONFLICT", { message: result.error ?? "Browser unavailable" });
}
await noteBrowserNavigate(deps, context.actor, bot, input.url);
return readBrowserChrome(deps, bot, "state", context);
}),
browserBack: authed.computer.browserBack.handler(async ({ context, input }) => {
const bot = await repos.getBot(context.actor, input.botId);
if (!bot.computer?.providerRef || bot.computer.state !== "running") {
throw new ORPCError("BAD_REQUEST", { message: "computer must be running" });
}
return readBrowserChrome(deps, bot, "back", context);
}),
browserForward: authed.computer.browserForward.handler(async ({ context, input }) => {
const bot = await repos.getBot(context.actor, input.botId);
if (!bot.computer?.providerRef || bot.computer.state !== "running") {
throw new ORPCError("BAD_REQUEST", { message: "computer must be running" });
}
return readBrowserChrome(deps, bot, "forward", context);
}),
browserReload: authed.computer.browserReload.handler(async ({ context, input }) => {
const bot = await repos.getBot(context.actor, input.botId);
if (!bot.computer?.providerRef || bot.computer.state !== "running") {
throw new ORPCError("BAD_REQUEST", { message: "computer must be running" });
}
return readBrowserChrome(deps, bot, "reload", context);
}),
browserCoworkEngage: authed.computer.browserCoworkEngage.handler(
async ({ context, input }) => {
const bot = await repos.getBot(context.actor, input.botId);
if (!bot.computer) throw new IsolationError();
const engaged = await engageBrowserCowork(
deps,
context.actor,
bot,
computerContext(context.actor, bot.id, "browser.cowork"),
);
return engaged;
},
),
browserUserAction: authed.computer.browserUserAction.handler(async ({ context, input }) => {
const bot = await repos.getBot(context.actor, input.botId);
const threadId = bot.thread?.id;
if (!threadId) throw new IsolationError();
await recordBrowserUserAction(deps, context.actor, {
botId: bot.id,
threadId,
kind: input.kind,
detail: input.detail,
passwordField: input.passwordField,
});
return { ok: true as const };
}),
},
memory: {
list: authed.memory.list.handler(async ({ context, input }) => {
@ -3228,8 +2906,6 @@ export function createRouter(deps: RouterDeps) {
if (!existing) throw new IsolationError();
await deps.prisma.routine.delete({ where: { id: existing.id } });
await deps.jobs.cancel(routineJobKey(existing.id));
const marked = await markRoutineNoticesRemoved(deps.prisma, existing).catch(() => null);
if (marked) await deps.events.notify(marked.threadId, marked.seq).catch(() => undefined);
return { ok: true as const };
}),
testRun: authed.routines.testRun.handler(async ({ context, input }) => {
@ -3424,9 +3100,8 @@ export function createRouter(deps: RouterDeps) {
},
capabilities: {
list: authed.capabilities.list.handler(async ({ context }) => {
// Tool sources and plugins follow the user into every space.
const rows = await deps.prisma.capabilityInstall.findMany({
where: { userId: context.actor.userId },
where: { spaceId: context.actor.spaceId, userId: context.actor.userId },
});
return rows.map((row) => ({
id: row.id,
@ -3491,10 +3166,6 @@ export function createRouter(deps: RouterDeps) {
source = "https://treg.to/mcp/";
config = { ...config, preset: "treg", auth: { type: "bearer" } };
}
if (config.preset === "github") {
source = GITHUB_MCP_SOURCE;
config = { preset: "github", auth: { type: "bearer" } };
}
const verified = await verifyMcpInstall({
source,
config,
@ -3585,7 +3256,11 @@ export function createRouter(deps: RouterDeps) {
remove: authed.capabilities.remove.handler(async ({ context, input }) => {
await deps.prisma.$transaction(async (tx) => {
const existing = await tx.capabilityInstall.findFirst({
where: { id: input.id, userId: context.actor.userId },
where: {
id: input.id,
spaceId: context.actor.spaceId,
userId: context.actor.userId,
},
});
if (!existing) return;
await tx.capabilityInstall.delete({ where: { id: existing.id } });
@ -3595,7 +3270,11 @@ export function createRouter(deps: RouterDeps) {
});
if (shared === 0) {
await tx.secret.deleteMany({
where: { id: existing.secretId, userId: context.actor.userId },
where: {
id: existing.secretId,
spaceId: context.actor.spaceId,
userId: context.actor.userId,
},
});
}
}
@ -3781,7 +3460,11 @@ export function createRouter(deps: RouterDeps) {
});
} else if (clearing && existing.secretId) {
await tx.secret.deleteMany({
where: { id: existing.secretId, userId: context.actor.userId },
where: {
id: existing.secretId,
spaceId: context.actor.spaceId,
userId: context.actor.userId,
},
});
}
return updated;
@ -3938,10 +3621,7 @@ export function createRouter(deps: RouterDeps) {
oauth: {
begin: authed.mcp.oauth.begin.handler(async ({ context, input }) => {
try {
const expectedRedirect = new URL(
"/mcp/oauth/callback",
webOriginFor(context),
).toString();
const expectedRedirect = new URL("/mcp/oauth/callback", deps.env.webOrigin).toString();
if (new URL(input.redirectUri).toString() !== expectedRedirect) {
throw new Error("MCP OAuth redirect URI is not allowed");
}
@ -4036,7 +3716,7 @@ export function createRouter(deps: RouterDeps) {
return {
canConfigure,
needsSetup: canConfigure && !providers.some((provider) => provider.configured),
webUrl: new URL("/integrations/setup", webOriginFor(context)).toString(),
webUrl: new URL("/integrations/setup", deps.env.webOrigin).toString(),
providers,
};
}),
@ -4079,20 +3759,6 @@ export function createRouter(deps: RouterDeps) {
cancelMediaJob(deps, context.actor, input.jobId),
),
},
library: {
list: authed.media.library.list.handler(({ context }) =>
listMediaLibrarySources(deps, context.actor),
),
add: authed.media.library.add.handler(({ context, input }) =>
addMediaLibrarySource(deps, context.actor, input),
),
remove: authed.media.library.remove.handler(({ context, input }) =>
removeMediaLibrarySource(deps, context.actor, input.sourceId),
),
sync: authed.media.library.sync.handler(({ context, input }) =>
syncMediaLibrarySourceNow(deps, context.actor, input.sourceId),
),
},
},
connections: {
catalog: authed.connections.catalog.handler(async ({ context, input }) => {
@ -4174,7 +3840,7 @@ export function createRouter(deps: RouterDeps) {
});
try {
const auth = await connector.begin(
{ provider: input.provider, redirectUrl: `${webOriginFor(context)}/app` },
{ provider: input.provider, redirectUrl: `${deps.env.webOrigin}/app` },
connectionContext(context.actor, "connections.begin", context.signal),
);
// Re-take the provider lock and only advance still-pending rows so a
@ -5435,7 +5101,7 @@ export function createRouter(deps: RouterDeps) {
},
runs: {
list: authed.runs.list.handler(async ({ context, input }) => ({
runs: await listUserRuns(deps.prisma, context.actor, input.filter),
runs: await listSpaceRuns(deps.prisma, context.actor, input.filter),
})),
},
tasks: {
@ -5448,33 +5114,6 @@ export function createRouter(deps: RouterDeps) {
items: await listInbox(deps.prisma, context.actor),
})),
},
autopilot: {
get: authed.autopilot.get.handler(async ({ context, input }) => {
const target = await resolveThreadTarget(deps.prisma, context.actor, input);
return getThreadAutopilot(deps, context.actor, target);
}),
start: authed.autopilot.start.handler(async ({ context, input }) => {
if ((await modelSetup(deps, context.actor)).needsModel) {
throw new ORPCError("BAD_REQUEST", { message: "Connect a model to start a run." });
}
const target = await resolveThreadTarget(deps.prisma, context.actor, input);
return startThreadAutopilot(deps, context.actor, target, input);
}),
stop: authed.autopilot.stop.handler(async ({ context, input }) => {
const target = await resolveThreadTarget(deps.prisma, context.actor, input);
return stopThreadAutopilot(deps, context.actor, target);
}),
resume: authed.autopilot.resume.handler(async ({ context, input }) => {
if ((await modelSetup(deps, context.actor)).needsModel) {
throw new ORPCError("BAD_REQUEST", { message: "Connect a model to start a run." });
}
const target = await resolveThreadTarget(deps.prisma, context.actor, input);
return resumeThreadAutopilot(deps, context.actor, target);
}),
recent: authed.autopilot.recent.handler(async ({ context }) => ({
items: await listRecentAutopilots(deps, context.actor),
})),
},
voice: {
catalog: authed.voice.catalog.handler(async () => listVoiceCatalog()),
status: authed.voice.status.handler(async ({ context }) => {
@ -5631,8 +5270,6 @@ async function spaceNavigationDto(
space: {
select: {
name: true,
icon: true,
color: true,
isDefault: true,
deletingAt: true,
workspacePath: true,
@ -5660,7 +5297,6 @@ async function spaceNavigationDto(
externalConversations,
contentBots,
contentGroups,
goalDocuments,
] = await Promise.all([
repos.listBots(actor),
groupRepos.listGroups(actor),
@ -5674,29 +5310,19 @@ async function spaceNavigationDto(
// The user's own main agent of a project is part of the project, not content to clear.
deps.prisma.bot.findMany({
where: { spaceId: { in: spaceIds }, NOT: ownProjectMain },
select: { spaceId: true, userId: true },
distinct: ["spaceId", "userId"],
select: { spaceId: true },
distinct: ["spaceId"],
}),
deps.prisma.chatGroup.findMany({
where: { spaceId: { in: spaceIds }, NOT: ownProjectMain },
select: { spaceId: true, userId: true },
distinct: ["spaceId", "userId"],
}),
deps.prisma.memoryDocument.findMany({
where: { spaceId: { in: spaceIds }, scope: "user", path: "PROJECT_GOAL.md" },
select: { spaceId: true, content: true },
select: { spaceId: true },
distinct: ["spaceId"],
}),
]);
const spacesWithContent = new Set([
...contentBots.map((row) => row.spaceId),
...contentGroups.map((row) => row.spaceId),
]);
const spacesBlockedByOtherMember = new Set(
[...contentBots, ...contentGroups]
.filter((row) => row.userId !== actor.userId)
.map((row) => row.spaceId),
);
const goalBySpaceId = new Map(goalDocuments.map((doc) => [doc.spaceId, doc.content]));
const currentMembership = memberships.find((membership) => membership.spaceId === actor.spaceId);
if (!currentMembership) throw new IsolationError();
const botsBySpace = partitionBySpace([...currentBots, ...inactiveBots]);
@ -5724,20 +5350,16 @@ async function spaceNavigationDto(
return {
id: membership.spaceId,
name: membership.space.name,
icon: membership.space.icon,
color: membership.space.color,
isDefault: membership.space.isDefault,
hasContent: spacesWithContent.has(membership.spaceId),
canDelete:
membership.role === "owner" &&
!membership.space.isDefault &&
memberships.length > 1 &&
!spacesBlockedByOtherMember.has(membership.spaceId) &&
!spacesWithContent.has(membership.spaceId) &&
(membership.space.deletingAt === null || membership.space.deletingAt < staleClaimBefore),
canEdit: membership.role === "owner",
// A linked folder is on the owner's host; other members see neither its path nor remote.
workspace: actor.isDeploymentOwner ? projectWorkspaceDto(membership.space) : null,
goal: goalBySpaceId.get(membership.spaceId) ?? null,
bots: spaceBots.map(spaceBotDto),
groups: spaceGroups.map(spaceGroupDto),
externalConversations: externalConversations.filter(
@ -5825,10 +5447,6 @@ async function meDto(deps: RouterDeps, actor: Actor): Promise<Me> {
deps.prisma.user.findUniqueOrThrow({ where: { id: actor.userId } }),
modelSetup(deps, actor),
]);
// Without a key the deployment default cannot run, so the connect flow opens on the recommendation.
const fallback = setup.needsModel
? RECOMMENDED_MODEL
: { provider: deps.env.defaultProvider, model: deps.env.defaultModel };
return {
userId: actor.userId,
email: user.email,
@ -5836,11 +5454,12 @@ async function meDto(deps: RouterDeps, actor: Actor): Promise<Me> {
spaceId: actor.spaceId,
isDeploymentOwner: actor.isDeploymentOwner,
needsModel: setup.needsModel,
modelsManaged: Boolean(deps.env.deploymentModelKey),
defaultProvider:
setup.credential?.provider ?? setup.settings?.defaultModelProvider ?? fallback.provider,
setup.credential?.provider ??
setup.settings?.defaultModelProvider ??
deps.env.defaultProvider,
defaultModel:
setup.credential?.defaultModel ?? setup.settings?.defaultModelId ?? fallback.model,
setup.credential?.defaultModel ?? setup.settings?.defaultModelId ?? deps.env.defaultModel,
computerHost: computerHostFor(setup.settings?.computerHost, deps.env.sandboxProvider),
canChooseHostComputer: actor.isDeploymentOwner && deps.env.sandboxProvider === "docker",
sandboxProvider: deps.env.sandboxProvider,
@ -6135,55 +5754,6 @@ async function persistModelCredential(
return modelCredentialDto(cred, input.plaintext);
}
async function readBrowserChrome(
deps: RouterDeps,
bot: {
id: string;
computer: { homeKey: string; kind: string; providerRef: string | null } | null;
},
command: "state" | "back" | "forward" | "reload",
context: { actor: Actor },
) {
if (!bot.computer?.providerRef) {
throw new ORPCError("BAD_REQUEST", { message: "computer must be running" });
}
const browser = createBrowserProvider(undefined, { sandbox: deps.sandbox });
const chrome = browser as InstanceType<typeof import("@rakazo/adapters").ComputerBrowserProvider>;
const result = await chrome.chrome(
toComputerRef(bot.computer),
command,
computerContext(context.actor, bot.id, `browser.${command}`),
);
if (!result.ok) {
throw new ORPCError("CONFLICT", {
message: "error" in result && result.error ? result.error : "Browser unavailable",
});
}
return {
url: result.url,
title: result.title,
loading: result.loading,
canGoBack: result.canGoBack,
canGoForward: result.canGoForward,
};
}
async function noteBrowserNavigate(
deps: RouterDeps,
actor: Actor,
bot: { id: string; thread: { id: string } | null },
url: string,
) {
const threadId = bot.thread?.id;
if (!threadId) return;
await recordBrowserUserAction(deps, actor, {
botId: bot.id,
threadId,
kind: "navigate",
detail: url,
});
}
function throwIfAborted(signal?: AbortSignal) {
if (signal?.aborted) throw signal.reason ?? new Error("Request cancelled");
}
@ -6260,12 +5830,6 @@ function assertBuiltinToolNames(names: readonly string[] | undefined) {
}
}
function assertAvatarValue(value: string | undefined) {
if (value !== undefined && !isValidAvatarValue(value)) {
throw new ORPCError("BAD_REQUEST", { message: "Invalid avatar" });
}
}
function duplicateBotName(name: string) {
return `${name.slice(0, 75)} copy`;
}

View File

@ -20,16 +20,6 @@ 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,10 +26,6 @@ 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;
@ -49,19 +45,15 @@ export function activityNotificationsEnabled(
return groupId !== null || notifyOnFinish;
}
/**
* 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(
export async function listSpaceRuns(
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] } }
@ -92,7 +84,6 @@ export async function listUserRuns(
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,7 +1,6 @@
import type { Actor, MessageBlock, SearchHit } from "@rakazo/contracts";
import { extractLinksFromText, matchesSearchQuery, snippetAroundMatch } from "@rakazo/core";
import type { PrismaClient } from "@rakazo/db";
import { Prisma } from "@rakazo/db";
import { Prisma, type PrismaClient } from "@rakazo/db";
const SEARCH_LIMIT = 25;
/** Cap name matches so content hits (messages/files/links/routines) keep most of the budget. */
@ -65,65 +64,19 @@ export async function querySpaceSearch(
bucket.push(hit);
}
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,
}),
]);
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,
});
for (const bot of bots) {
pushInto(
conversationHits,
@ -138,6 +91,14 @@ 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,
@ -153,72 +114,27 @@ 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);
@ -239,6 +155,24 @@ 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);
@ -259,6 +193,19 @@ 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,
@ -274,6 +221,29 @@ 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,
@ -285,6 +255,28 @@ 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/cynarai/evoo-local/app",
image: "ghcr.io/elie222/rakazo/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/cynarAI/evOo-local",
remoteUrl: "https://github.com/elie222/rakazo",
dirty: false,
dirtyPaths: [],
},
@ -134,7 +134,7 @@ describe("server update install kind", () => {
fromTag: "sha-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
toTag: "sha-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
strategy: "pull",
repoUrl: "https://github.com/cynarAI/evOo-local",
repoUrl: "https://github.com/elie222/rakazo",
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/cynarai/evoo-local/app",
image: "ghcr.io/elie222/rakazo/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/cynarAI/evOo-local",
remoteUrl: "https://github.com/elie222/rakazo",
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 evOo for details.`
? `${target.name} could not complete the delegated request. Open Rakazo 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 evOo for details.`,
content: `${this.target?.name ?? "The agent"} could not complete that request. Open Rakazo for details.`,
idempotencyKey: `external-message:${message.id}:failure`,
});
}

View File

@ -88,77 +88,4 @@ 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,15 +1,14 @@
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 type { PrismaClient } from "@rakazo/db";
import { findDefaultModelCredential, findModelCredential, findSpaceModelTier } from "@rakazo/db";
import { findDefaultModelCredential, findModelCredential, type PrismaClient } from "@rakazo/db";
import { getLogger } from "@rakazo/logging";
const MAX_RULES_CHARS = 4_000;
@ -192,39 +191,27 @@ 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 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 defaultCredential = await findDefaultModelCredential(this.deps.prisma, scope);
const requestedProvider = this.deps.providerOverride ?? bot.modelProvider;
const requestedModel = this.deps.modelOverride ?? bot.modelId;
const overrideCredential =
!fastTier && requestedProvider
? await findModelCredential(this.deps.prisma, scope, requestedProvider)
: null;
const overrideCredential = requestedProvider
? await findModelCredential(this.deps.prisma, scope, requestedProvider)
: null;
const explicitJudgeOverride = Boolean(this.deps.providerOverride && this.deps.modelOverride);
const useOverride = Boolean(
requestedProvider && requestedModel && (explicitJudgeOverride || overrideCredential),
);
const credential =
fastTier?.credential ?? (useOverride ? overrideCredential : defaultCredential);
const settings = fastTier
? null
: await this.deps.prisma.deploymentSettings.findUnique({
where: { id: "default" },
});
const credential = useOverride ? overrideCredential : defaultCredential;
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

@ -1,275 +0,0 @@
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

@ -1,242 +0,0 @@
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

@ -2,17 +2,15 @@ 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";
@ -213,13 +211,7 @@ describe("threadSnapshot", () => {
expect.objectContaining({
where: expect.objectContaining({
type: {
in: [
"thread.message.created",
"thread.progress",
"thread.subagent",
"agent.tool.called",
"agent.tool.completed",
],
in: ["thread.progress", "thread.subagent", "agent.tool.called", "agent.tool.completed"],
},
}),
}),
@ -238,69 +230,6 @@ 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",
@ -1032,13 +961,10 @@ function groupTarget() {
}
describe("sendThreadMessage", () => {
it("steers a waiting-input run instead of refusing the message", async () => {
it("rejects a new bot message while a run is waiting on input", async () => {
const tx = {
thread: {
update: vi
.fn()
.mockResolvedValueOnce({ nextMessageSeq: 2 })
.mockResolvedValueOnce({ nextEventSeq: 3 }),
update: vi.fn().mockResolvedValue({ nextMessageSeq: 2 }),
},
message: {
create: vi.fn().mockResolvedValue({
@ -1046,7 +972,7 @@ describe("sendThreadMessage", () => {
threadId: "thread-1",
seq: 1,
role: "user",
blocks: [{ kind: "text", text: "more context" }],
blocks: [{ kind: "text", text: "hi" }],
botId: null,
replyToMessageId: null,
runId: null,
@ -1058,13 +984,9 @@ 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().mockResolvedValue({ seq: 2 }) },
event: { create: vi.fn() },
task: { create: vi.fn() },
};
const prisma = {
@ -1078,37 +1000,26 @@ describe("sendThreadMessage", () => {
threadId: "thread-1",
bot: { computer: null },
} as ThreadTarget;
const enqueue = vi.fn().mockResolvedValue(undefined);
await expect(
sendThreadMessage(
{
prisma,
events: { notify: vi.fn().mockResolvedValue(undefined) } as never,
jobs: { enqueue } as never,
events: { notify: vi.fn() } as never,
jobs: { enqueue: vi.fn() } as never,
},
actor,
target,
{
text: "more context",
text: "hi",
clientNonce: "nonce-1",
},
),
).resolves.toMatchObject({
runId: "run-waiting",
taskId: "task-1",
seq: 1,
runIds: ["run-waiting"],
).rejects.toMatchObject({
code: "CONFLICT",
message: "Answer the pending ask first.",
});
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" }));
expect(tx.steeringMessage.create).not.toHaveBeenCalled();
});
it("steers a waiting-takeover run instead of refusing the message", async () => {
@ -2120,192 +2031,4 @@ 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,10 +1,5 @@
import { ORPCError } from "@orpc/server";
import {
type JobPublisher,
runContinueJob,
runJobKey,
type SandboxProvider,
} from "@rakazo/adapter-kit";
import { type JobPublisher, runContinueJob, type SandboxProvider } from "@rakazo/adapter-kit";
import { cancelComputerRunWork, screenLeaseIdForRun, toComputerRef } from "@rakazo/adapters";
import {
type Actor,
@ -165,15 +160,9 @@ function quoteAppearsInBlocks(quote: string, blocks: MessageBlock[]): boolean {
}
const THREAD_MESSAGE_PAGE_SIZE = 100;
const RUNS_NEEDING_CONTINUE = new Set(["queued", "waiting_input", "waiting_takeover"]);
const RUNS_NEEDING_CONTINUE = new Set(["queued", "waiting_takeover"]);
const STEERABLE_RUN_STATUSES = new Set([
"queued",
"leased",
"running",
"waiting_input",
"waiting_takeover",
]);
const STEERABLE_RUN_STATUSES = new Set(["queued", "leased", "running", "waiting_takeover"]);
type MentionTargetInput = string | { kind: "bot" | "group" | "routine" | "connector"; id: string };
@ -502,7 +491,6 @@ export async function threadSnapshot(
runId: currentRun.id,
type: {
in: [
"thread.message.created",
"thread.progress",
"thread.subagent",
"agent.tool.called",
@ -568,7 +556,6 @@ export async function threadSnapshot(
runId: { in: activeRuns.map((run) => run.id) },
type: {
in: [
"thread.message.created",
"thread.progress",
"thread.subagent",
"agent.tool.called",
@ -644,12 +631,8 @@ 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.id.startsWith("progress:") &&
message.blocks.some((block) => block.kind === "progress" || block.kind === "steps")
) {
if (message.blocks.some((block) => block.kind === "progress" || block.kind === "steps")) {
return true;
}
if (!message.id.startsWith("subagent:")) return false;
@ -1156,50 +1139,63 @@ export async function reactToThreadMessage(
});
}
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(
export async function stopThreadRuns(
deps: {
prisma: PrismaClient;
sandbox: SandboxProvider;
},
actor: Actor,
snapshot: Awaited<ReturnType<typeof snapshotCancelledRunTeardown>>,
target: ThreadTarget,
) {
const { runIds, computers, leases } = snapshot;
if (runIds.length === 0) return;
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 };
});
// 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]));
@ -1277,381 +1273,6 @@ async function teardownCancelledRuns(
});
}
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

@ -13,18 +13,18 @@ import {
describe("toVoiceStatus", () => {
it("treats a saved key without a voice as configured but not ready", () => {
expect(toVoiceStatus({ provider: "kokoro", voiceId: "" })).toEqual({
expect(toVoiceStatus({ provider: "elevenlabs", voiceId: "" })).toEqual({
configured: true,
ready: false,
transcribe: false,
provider: "kokoro",
transcribe: true,
provider: "elevenlabs",
voiceId: "",
});
});
it("is ready once a voice is chosen", () => {
expect(toVoiceStatus({ provider: "kokoro", voiceId: "df_eva" }).ready).toBe(true);
expect(toVoiceStatus({ provider: "kokoro", voiceId: "df_eva" }).transcribe).toBe(false);
expect(toVoiceStatus({ provider: "cartesia", voiceId: "katie" }).ready).toBe(true);
expect(toVoiceStatus({ provider: "cartesia", voiceId: "katie" }).transcribe).toBe(false);
});
it("is off when nothing is connected", () => {
@ -39,7 +39,7 @@ describe("toVoiceStatus", () => {
});
describe("voice HTTP routes", () => {
it("rejects unauthenticated speak, transcribe, and transcribe-stream", async () => {
it("rejects unauthenticated speak and transcribe", async () => {
const app = new Hono();
mountVoiceHttpRoutes(app, {} as VoiceDeps, async () => null);
const speak = await app.request("/api/voice/speak", {
@ -52,20 +52,13 @@ 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) => {
@ -119,22 +112,6 @@ 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

@ -25,7 +25,7 @@ import {
selectSpaceVoicePreference,
} from "@rakazo/db";
import type { Context, Hono } from "hono";
import { cancelBody, readBoundedBody } from "./http-body.js";
import { readBoundedBody } from "./http-body.js";
import { withSerializableRetry } from "./serializable-retry.js";
export interface VoiceDeps {
@ -96,8 +96,7 @@ export async function loadVoiceCredential(deps: VoiceDeps, actor: Actor, provide
const cred = provider
? await findVoiceCredential(deps.prisma, actor, provider)
: await findDefaultVoiceCredential(deps.prisma, actor);
// Credentials of providers that were removed from the catalog no longer speak.
if (!cred || !isVoiceProviderId(cred.provider)) return null;
if (!cred) return null;
const secret = await deps.prisma.secret.findFirst({
where: { id: cred.secretId, userId: actor.userId, spaceId: null },
});
@ -147,11 +146,7 @@ export async function persistVoiceCredential(
let voiceId = input.voiceId?.trim() ?? "";
if (!voiceId) {
const voices = await provider.listVoices(input.plaintext, voiceContext(actor, input.signal));
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 ?? "";
voiceId = voices[0]?.id ?? "";
}
const stored = await deps.secrets.put(input.plaintext, voiceContext(actor, input.signal));
const cred = await withSerializableRetry(() =>
@ -380,113 +375,6 @@ 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

@ -1,36 +0,0 @@
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

@ -1,35 +0,0 @@
/** 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

@ -177,7 +177,7 @@ test("This computer installs and starts the stack, then opens the app", async ()
const appWindowPromise = app.waitForEvent("window");
await setup.getByRole("button", { name: "Continue" }).click();
await expect(setup.locator("#stack-phase")).toHaveText("Downloading evOo…");
await expect(setup.locator("#stack-phase")).toHaveText("Downloading Rakazo…");
await expect(setup.getByRole("button", { name: "Continue" })).toBeDisabled();
// Docker output stays behind the details toggle; the phase, the bar, and the size show by default.
await expect(setup.locator("#stack-detail")).toHaveText("412 MB downloaded");
@ -249,7 +249,7 @@ test("switching to Existing instance while the stack starts keeps that choice",
const setup = await app.firstWindow();
await setup.getByRole("button", { name: "Continue" }).click();
await expect(setup.locator("#stack-phase")).toHaveText("Downloading evOo…");
await expect(setup.locator("#stack-phase")).toHaveText("Downloading Rakazo…");
// Fake docker sleeps during pull; leave This computer before ready so followStack must not save.
await setup.getByRole("radio", { name: /Existing instance/ }).check();

View File

@ -83,7 +83,7 @@ test("first run asks whether to use a local or existing instance", async () => {
}
const setup = await app.firstWindow();
await expect(setup.getByRole("heading", { name: "Welcome to evOo" })).toBeVisible();
await expect(setup.getByRole("heading", { name: "Welcome to Rakazo" })).toBeVisible();
await expect(setup.getByText("Choose which server this app should use.")).toBeVisible();
await expect(setup.getByText("This computer")).toBeVisible();
await expect(setup.getByText("Existing instance")).toBeVisible();
@ -139,7 +139,7 @@ test("connecting to an existing instance verifies, saves, and opens it", async (
await setup.locator("#server-url").fill(serverUrl);
await setup.getByRole("button", { name: "Check connection" }).click();
await expect(setup.locator("#status")).toHaveText(`evOo answered at ${serverUrl}.`);
await expect(setup.locator("#status")).toHaveText(`Rakazo answered at ${serverUrl}.`);
await expect(setup.locator("#status")).toHaveAttribute("data-tone", "ok");
await setup.screenshot({
@ -437,7 +437,7 @@ test("a malformed address is rejected before anything is written", async () => {
}).toPass();
});
test("a generic web page is not accepted as an evOo server", async () => {
test("a generic web page is not accepted as a Rakazo server", async () => {
const plain = createServer((_request, response) => {
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
response.end("<!doctype html><p>not Rakazo</p>");
@ -454,7 +454,7 @@ test("a generic web page is not accepted as an evOo server", async () => {
await setup.getByRole("button", { name: "Continue" }).click();
await expect(setup.locator("#status")).toHaveText(
"That address did not respond like an evOo server.",
"That address did not respond like a Rakazo server.",
);
await expect(async () => {
await expect(readFile(path.join(userData, "setup.json"), "utf8")).rejects.toThrow();
@ -507,7 +507,7 @@ test("an unreachable saved server falls back to setup with a recovery message",
app = await launch();
const setup = await app.firstWindow();
await expect(setup.getByRole("heading", { name: "Welcome to evOo" })).toBeVisible();
await expect(setup.getByRole("heading", { name: "Welcome to Rakazo" })).toBeVisible();
await expect(setup.getByRole("radio", { name: /Existing instance/ })).toBeChecked();
await expect(setup.locator("#server-url")).toHaveValue(closedUrl);
await expect(setup.locator("#status")).toContainText("Could not reconnect to the saved server.");
@ -529,7 +529,7 @@ test("the native application menu can reopen setup without exposing setup IPC to
});
const setup = await setupPromise;
await expect(setup.getByRole("heading", { name: "Welcome to evOo" })).toBeVisible();
await expect(setup.getByRole("heading", { name: "Welcome to Rakazo" })).toBeVisible();
await expect(setup.locator("#status")).toBeEmpty();
// Closing setup without saving restores the connected instance.

View File

@ -80,8 +80,8 @@
"publish": [
{
"provider": "github",
"owner": "cynarAI",
"repo": "evOo-local"
"owner": "elie222",
"repo": "rakazo"
}
]
}

View File

@ -79,7 +79,7 @@ describe("renderStackEnv", () => {
expect(lines.some((line) => line.startsWith("RAKAZO_IMAGE_TAG="))).toBe(false);
expect(lines.some((line) => line.startsWith("RAKAZO_COMPUTER_IMAGE_TAG="))).toBe(false);
// Everything else, including the image names and empty optional keys, stays verbatim.
expect(lines).toContain("RAKAZO_IMAGE=ghcr.io/cynarai/evoo-local/app");
expect(lines).toContain("RAKAZO_IMAGE=ghcr.io/elie222/rakazo/app");
expect(lines).toContain("SANDBOX_PROVIDER=docker");
expect(lines).toContain("OPENROUTER_API_KEY=");
expect(rendered.endsWith("\n")).toBe(template.endsWith("\n"));

View File

@ -711,7 +711,7 @@ function installApplicationMenu() {
};
const changeServer: Electron.MenuItemConstructorOptions = {
id: "change-rakazo-server",
label: "Change evOo Server…",
label: "Change Rakazo Server…",
accelerator: "CmdOrCtrl+Shift+K",
click: () => showSetupWindow(),
};
@ -789,7 +789,7 @@ async function probeServer(rawUrl: string, signal?: AbortSignal): Promise<Deskto
ok: false,
status: response.status,
url,
error: "That address redirects elsewhere. Enter the final evOo server address.",
error: "That address redirects elsewhere. Enter the final Rakazo server address.",
};
}
if (!response.ok) {
@ -806,7 +806,7 @@ async function probeServer(rawUrl: string, signal?: AbortSignal): Promise<Deskto
ok: false,
status: response.status,
url,
error: "That address did not respond like an evOo server.",
error: "That address did not respond like a Rakazo server.",
};
}
return {
@ -1249,7 +1249,7 @@ app.whenReady().then(async () => {
if (managedUrl === null || !(await localStack.matchesDesiredStack())) {
return {
ok: false,
error: "The app-managed evOo services are not ready. Retry setup.",
error: "The app-managed Rakazo services are not ready. Retry setup.",
};
}
openSetup = { mode: "new", serverUrl: managedUrl };

View File

@ -66,9 +66,9 @@ describe("desktop release workflow", () => {
`include='[{"runner":["self-hosted","evoo-ci","macOS"],"artifact":"macos"},{"runner":["self-hosted","evoo-ci","Linux"],"artifact":"linux"}]'`,
);
expect(workflow).toMatch(
/if \[\[ "\$WINDOWS_SIGNING" == "true" \]\]; then\n(?:\s*#[^\n]*\n)*\s+include=.*\{"runner":\["self-hosted","evoo-ci","Windows"\],"artifact":"windows"\}/,
/if \[\[ "\$WINDOWS_SIGNING" == "true" \]\]; then\n(?:\s*#[^\n]*\n)*\s+include=.*\{"runner":"windows-2022","artifact":"windows"\}/,
);
expect(workflow).not.toMatch(/windows-\d|windows-latest/);
expect(workflow.split("windows-2022")).toHaveLength(2);
expect(workflow).toContain(`WINDOWS_BUILT: ${expression("needs.validate.outputs.windows")}`);
expect(workflow).toContain('if [[ "$WINDOWS_BUILT" == "true" ]]; then');
expect(workflow).toContain("feeds+=(release-artifacts/latest.yml)");
@ -76,8 +76,8 @@ describe("desktop release workflow", () => {
it("pins every platform update feed to the official GitHub owner and repo", () => {
expect(workflow).toContain('grep -Fqx "provider: github"');
expect(workflow).toContain('grep -Fqx "owner: cynarAI"');
expect(workflow).toContain('grep -Fqx "repo: evOo-local"');
expect(workflow).toContain('grep -Fqx "owner: elie222"');
expect(workflow).toContain('grep -Fqx "repo: rakazo"');
expect(workflow).toContain("Verify Linux update feed is pinned to the official GitHub channel");
expect(workflow).toContain("Windows update config missing");
expect(workflow).toContain("RELEASE_VERSION:");
@ -94,11 +94,4 @@ describe("desktop release workflow", () => {
expect(workflow).toContain("--draft --generate-notes");
expect(workflow).toContain("--draft=false --latest");
});
it("hands platform builds to the draft release, never to Actions artifact storage", () => {
expect(workflow).not.toMatch(/actions\/(?:upload|download)-artifact@/);
expect(workflow).toContain("bash scripts/upload-release-assets.sh");
expect(workflow).toContain('gh release download "$RELEASE_TAG" --dir release-artifacts');
expect(workflow).toContain("needs: [validate, build, publish]");
});
});

View File

@ -6,18 +6,18 @@
http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; form-action 'none'; base-uri 'none'"
/>
<title>Set up evOo</title>
<title>Set up Rakazo</title>
<link rel="stylesheet" href="tokens.css" />
<link rel="stylesheet" href="setup.css" />
</head>
<body>
<header class="titlebar">
<span class="titlebar-name">evOo</span>
<button id="quit" class="titlebar-quit" type="button" title="Quit evOo">Quit</button>
<span class="titlebar-name">Rakazo</span>
<button id="quit" class="titlebar-quit" type="button" title="Quit Rakazo">Quit</button>
</header>
<main class="shell">
<h1 class="heading">Welcome to evOo</h1>
<h1 class="heading">Welcome to Rakazo</h1>
<p class="subheading">Choose which server this app should use.</p>
<form id="setup" novalidate>

View File

@ -23,10 +23,10 @@
const PHASE_LABELS = {
"checking-docker": "Getting ready…",
preparing: "Getting ready…",
pulling: "Downloading evOo…",
starting: "Starting evOo…",
pulling: "Downloading Rakazo…",
starting: "Starting Rakazo…",
"waiting-healthy": "Almost ready…",
ready: "evOo is ready.",
ready: "Rakazo is ready.",
};
const TERMINAL_PHASES = new Set([
"idle",
@ -266,7 +266,7 @@
const result = await bridge.test(value);
if (result.ok) {
serverUrl.value = result.url;
setStatus(`evOo answered at ${result.url}.`, "ok");
setStatus(`Rakazo answered at ${result.url}.`, "ok");
} else {
setStatus(result.error ?? "Could not reach that address.", "error");
}
@ -362,7 +362,7 @@
continueButton.focus();
}
} catch {
setStatus("Setup could not start. Quit evOo and try again.", "error");
setStatus("Setup could not start. Quit Rakazo and try again.", "error");
setBusy(true);
}
}

View File

@ -7,7 +7,7 @@ name: Android live working status demonstration
notifications: allow
# A cold API 34 emulator can show a launcher ANR over the healthy app.
- extendedWaitUntil:
visible: "Sign up for evOo|Pixel Launcher isn't responding"
visible: "Sign up for Rakazo|Pixel Launcher isn't responding"
timeout: 30000
- runFlow:
when:
@ -15,7 +15,7 @@ name: Android live working status demonstration
commands:
- tapOn: "Close app"
- extendedWaitUntil:
visible: "Sign up for evOo"
visible: "Sign up for Rakazo"
timeout: 30000
- tapOn: "Sign in"
- tapOn: "Email"

View File

@ -10,7 +10,7 @@ tags:
# A cold API 34 emulator can show a launcher ANR over the healthy app.
- extendedWaitUntil:
visible: "Sign up for evOo|Pixel Launcher isn't responding"
visible: "Sign up for Rakazo|Pixel Launcher isn't responding"
timeout: 30000
- runFlow:
when:
@ -18,11 +18,11 @@ tags:
commands:
- tapOn: "Close app"
- extendedWaitUntil:
visible: "Sign up for evOo"
visible: "Sign up for Rakazo"
timeout: 30000
- takeScreenshot: "02-sign-up"
- tapOn: "Sign in"
- assertVisible: "Sign in to evOo"
- assertVisible: "Sign in to Rakazo"
- takeScreenshot: "01-sign-in"
- tapOn: "Use a custom server"

View File

@ -4,18 +4,18 @@ name: Rakazo mobile smoke
- launchApp:
clearState: true
- assertVisible: "Sign in to evOo"
- assertVisible: "Sign in to Rakazo"
- assertVisible: "Sign up"
- tapOn: "Sign up"
- assertVisible: "Sign up for evOo"
- assertVisible: "Sign up for Rakazo"
- assertVisible: "Name"
- tapOn: "Sign in"
- assertVisible: "Sign in to evOo"
- assertVisible: "Sign in to Rakazo"
- tapOn: "Email"
- inputText: ${RAKAZO_E2E_EMAIL}
- tapOn: "Password"
- inputText: ${RAKAZO_E2E_PASSWORD}
- tapOn: "Sign in to evOo"
- tapOn: "Sign in to Rakazo"
- tapOn: "Sign in"
- extendedWaitUntil:

View File

@ -1,6 +1,6 @@
{
"expo": {
"name": "evOo",
"name": "Rakazo",
"slug": "rakazo",
"owner": "inbox-zero",
"scheme": "rakazo",
@ -33,8 +33,8 @@
[
"expo-image-picker",
{
"photosPermission": "evOo uses your photo library so you can attach images to a bot message.",
"cameraPermission": "evOo uses the camera so you can attach photos to a bot message.",
"photosPermission": "Rakazo uses your photo library so you can attach images to a bot message.",
"cameraPermission": "Rakazo uses the camera so you can attach photos to a bot message.",
"microphonePermission": false
}
],
@ -51,9 +51,9 @@
"usesNonExemptEncryption": false
},
"infoPlist": {
"NSCameraUsageDescription": "evOo uses the camera so you can attach photos to a bot message.",
"NSPhotoLibraryUsageDescription": "evOo uses your photo library so you can attach images to a bot message.",
"NSLocalNetworkUsageDescription": "evOo connects to a self-hosted evOo server on your local network.",
"NSCameraUsageDescription": "Rakazo uses the camera so you can attach photos to a bot message.",
"NSPhotoLibraryUsageDescription": "Rakazo uses your photo library so you can attach images to a bot message.",
"NSLocalNetworkUsageDescription": "Rakazo connects to a self-hosted Rakazo server on your local network.",
"NSAppTransportSecurity": {
"NSAllowsLocalNetworking": true
}

View File

@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
import { View } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { AvatarStyleProvider } from "../components/avatar-style";
import { ComputerUpdateProgress } from "../components/computer-update-progress";
import { currentApiBase, loadApiBase, loadSessionToken, selectedSpaceId } from "../lib/api";
import { loadAppearancePreference, mobileTokens } from "../lib/appearance";
@ -56,72 +57,74 @@ export default function Layout() {
<GestureHandlerRootView style={{ flex: 1 }}>
<KeyboardProvider>
{ready ? (
<ThemeProvider value={navigationTheme}>
<StatusBar style={resolved === "light" ? "dark" : "light"} />
<Stack
screenOptions={{
headerStyle: { backgroundColor: navigationTheme.colors.background },
headerTintColor: navigationTheme.colors.text,
headerShadowVisible: false,
headerBackButtonDisplayMode: "minimal",
contentStyle: { backgroundColor: String(native.page) },
}}
>
<Stack.Screen name="index" options={{ headerShown: false, title: "evOo" }} />
<Stack.Screen name="sign-in" options={{ headerShown: false }} />
<Stack.Screen
name="integration-setup"
options={{ title: t("Server integrations") }}
/>
<Stack.Screen name="ai-data-sharing" options={{ title: "AI data sharing" }} />
<Stack.Screen name="account" options={{ title: t("Account") }} />
<Stack.Screen
name="change-password"
options={{
title: t("Change password"),
presentation: "formSheet",
sheetAllowedDetents: [0.6, 1],
sheetGrabberVisible: true,
<AvatarStyleProvider>
<ThemeProvider value={navigationTheme}>
<StatusBar style={resolved === "light" ? "dark" : "light"} />
<Stack
screenOptions={{
headerStyle: { backgroundColor: navigationTheme.colors.background },
headerTintColor: navigationTheme.colors.text,
headerShadowVisible: false,
headerBackButtonDisplayMode: "minimal",
contentStyle: { backgroundColor: String(native.page) },
}}
/>
<Stack.Screen name="models" options={{ title: t("Models") }} />
<Stack.Screen name="voice" options={{ title: t("Voice") }} />
<Stack.Screen name="integrations" options={{ title: t("Integrations") }} />
<Stack.Screen
name="new"
options={{
title: t("New bot"),
presentation: "modal",
gestureEnabled: true,
headerBackVisible: false,
}}
/>
<Stack.Screen
name="new-group"
options={{
title: t("New group"),
presentation: "modal",
gestureEnabled: true,
}}
/>
<Stack.Screen
name="new-space"
options={{
title: t("New space"),
presentation: "modal",
gestureEnabled: true,
headerBackVisible: false,
}}
/>
<Stack.Screen name="group-thread" options={{ title: t("Group") }} />
<Stack.Screen name="group-settings" options={{ title: t("Group settings") }} />
<Stack.Screen name="bot-settings" options={{ title: t("Chat settings") }} />
<Stack.Screen name="thread" options={{ title: t("Thread") }} />
<Stack.Screen name="routine" options={{ title: t("Routine") }} />
<Stack.Screen name="computer" options={{ title: t("Computer") }} />
</Stack>
<ComputerUpdateProgress />
</ThemeProvider>
>
<Stack.Screen name="index" options={{ headerShown: false, title: "Rakazo" }} />
<Stack.Screen name="sign-in" options={{ headerShown: false }} />
<Stack.Screen
name="integration-setup"
options={{ title: t("Server integrations") }}
/>
<Stack.Screen name="ai-data-sharing" options={{ title: "AI data sharing" }} />
<Stack.Screen name="account" options={{ title: t("Account") }} />
<Stack.Screen
name="change-password"
options={{
title: t("Change password"),
presentation: "formSheet",
sheetAllowedDetents: [0.6, 1],
sheetGrabberVisible: true,
}}
/>
<Stack.Screen name="models" options={{ title: t("Models") }} />
<Stack.Screen name="voice" options={{ title: t("Voice") }} />
<Stack.Screen name="integrations" options={{ title: t("Integrations") }} />
<Stack.Screen
name="new"
options={{
title: t("New bot"),
presentation: "modal",
gestureEnabled: true,
headerBackVisible: false,
}}
/>
<Stack.Screen
name="new-group"
options={{
title: t("New group"),
presentation: "modal",
gestureEnabled: true,
}}
/>
<Stack.Screen
name="new-space"
options={{
title: t("New space"),
presentation: "modal",
gestureEnabled: true,
headerBackVisible: false,
}}
/>
<Stack.Screen name="group-thread" options={{ title: t("Group") }} />
<Stack.Screen name="group-settings" options={{ title: t("Group settings") }} />
<Stack.Screen name="bot-settings" options={{ title: t("Chat settings") }} />
<Stack.Screen name="thread" options={{ title: t("Thread") }} />
<Stack.Screen name="routine" options={{ title: t("Routine") }} />
<Stack.Screen name="computer" options={{ title: t("Computer") }} />
</Stack>
<ComputerUpdateProgress />
</ThemeProvider>
</AvatarStyleProvider>
) : (
<View style={{ flex: 1, backgroundColor: String(native.page) }} />
)}

View File

@ -1,3 +1,4 @@
import type { AvatarStyle } from "@rakazo/contracts";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useEffect, useState } from "react";
import {
@ -14,6 +15,8 @@ import {
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useAvatarStyle } from "../components/avatar-style";
import { BotAvatar } from "../components/bot-avatar";
import type { MobileBot, MobileMe } from "../lib/api";
import {
currentApiBase,
@ -46,7 +49,6 @@ import { native, useResolvedAppearance, useThemedStyles } from "../lib/native";
import { registerPushToken } from "../lib/push";
import type { AccountUiLocale } from "../lib/ui-locale";
import { ACCOUNT_UI_LOCALES, UI_LOCALE_LABELS } from "../lib/ui-locale";
import { alignVoiceWithLocale } from "../lib/voice";
/** Render account settings, including the entry point for voice configuration. */
export default function Account() {
@ -59,6 +61,8 @@ export default function Account() {
const [localeSaving, setLocaleSaving] = useState(false);
const [localeError, setLocaleError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
const [avatarPending, setAvatarPending] = useState(false);
const [avatarError, setAvatarError] = useState<string | null>(null);
const [notifications, setNotifications] = useState<LiveNotificationSettings>(
DEFAULT_LIVE_NOTIFICATION_SETTINGS,
);
@ -72,6 +76,7 @@ export default function Account() {
inputTokens: number;
outputTokens: number;
} | null>(null);
const { avatarStyle, updateAvatarStyle } = useAvatarStyle();
const appearance = getCachedAppearancePreference();
const styles = useThemedStyles(createAccountStyles);
const versionInfo = getAppVersionInfo();
@ -122,6 +127,19 @@ export default function Account() {
}
}
async function selectAvatarStyle(next: AvatarStyle) {
if (next === avatarStyle) return;
setAvatarPending(true);
setAvatarError(null);
try {
await updateAvatarStyle(next);
} catch {
setAvatarError(t("Couldn't update avatars"));
} finally {
setAvatarPending(false);
}
}
async function handleSignOut() {
setPending(true);
setError(null);
@ -184,7 +202,6 @@ export default function Account() {
setLocaleSaving(true);
setLocaleError(null);
void setUiLocale(code)
.then((activated) => alignVoiceWithLocale(activated).catch(() => undefined))
.catch(() => {
setLocaleError(t("Could not change language"));
})
@ -243,7 +260,7 @@ export default function Account() {
<Text style={styles.chevron}>›</Text>
</Pressable>
<View accessibilityLabel={t("Appearance")} style={styles.appearanceSection}>
<View accessibilityLabel={t("Appearance")} style={styles.avatarSection}>
<Text style={styles.settingsTitle}>{t("Appearance")}</Text>
<View style={styles.appearanceOptions}>
{(
@ -275,6 +292,40 @@ export default function Account() {
</View>
</View>
<View accessibilityLabel={t("Avatar style")} style={styles.avatarSection}>
<Text style={styles.settingsTitle}>{t("Avatars")}</Text>
<View style={styles.avatarOptions}>
{(["robot", "organic"] as const).map((style) => {
const selected = avatarStyle === style;
const styleLabel = style === "robot" ? t("Robot") : t("Organic");
return (
<Pressable
key={style}
accessibilityLabel={t("{style} avatars", { style: styleLabel })}
accessibilityRole="button"
accessibilityState={{ selected, disabled: avatarPending }}
disabled={avatarPending}
onPress={() => void selectAvatarStyle(style)}
style={({ pressed }) => [
styles.avatarOption,
selected && styles.avatarOptionSelected,
pressed && styles.pressed,
]}
>
<BotAvatar
color={style === "robot" ? "#8B5CF6" : "#D62F8B"}
identity="avatar-preview"
size={42}
variant={style}
/>
<Text style={styles.avatarLabel}>{styleLabel}</Text>
</Pressable>
);
})}
</View>
{avatarError ? <Text style={styles.error}>{avatarError}</Text> : null}
</View>
<Pressable
accessibilityRole="button"
accessibilityLabel={t("Language")}
@ -353,17 +404,15 @@ export default function Account() {
</View>
) : null}
{me?.modelsManaged ? null : (
<Pressable
accessibilityRole="button"
disabled={pending}
onPress={() => router.push("/models")}
style={({ pressed }) => [styles.settingsButton, pressed && styles.pressed]}
>
<Text style={styles.settingsTitle}>{t("Models")}</Text>
<Text style={styles.chevron}>›</Text>
</Pressable>
)}
<Pressable
accessibilityRole="button"
disabled={pending}
onPress={() => router.push("/models")}
style={({ pressed }) => [styles.settingsButton, pressed && styles.pressed]}
>
<Text style={styles.settingsTitle}>{t("Models")}</Text>
<Text style={styles.chevron}>›</Text>
</Pressable>
<Pressable
accessibilityRole="button"
@ -600,7 +649,7 @@ function createAccountStyles() {
alignItems: "center",
justifyContent: "space-between",
},
appearanceSection: {
avatarSection: {
borderRadius: 16,
backgroundColor: native.fill,
padding: 18,
@ -628,6 +677,29 @@ function createAccountStyles() {
fontSize: 14,
fontWeight: "600",
},
avatarOptions: {
flexDirection: "row",
gap: 12,
},
avatarOption: {
flex: 1,
minHeight: 86,
borderRadius: 14,
borderWidth: StyleSheet.hairlineWidth,
borderColor: native.tertiaryLabel,
alignItems: "center",
justifyContent: "center",
gap: 8,
},
avatarOptionSelected: {
borderColor: native.label,
backgroundColor: native.fillPressed,
},
avatarLabel: {
color: native.label,
fontSize: 14,
fontWeight: "600",
},
settingsTitle: {
color: native.label,
fontSize: 17,

View File

@ -12,7 +12,6 @@ import { useEffect, useMemo, useState } from "react";
import { Pressable, ScrollView, Text, TextInput, View } from "react-native";
import { BotAvatar } from "../components/bot-avatar";
import { ComputerModePicker } from "../components/computer-mode-picker";
import { OoAtelier } from "../components/oo-atelier";
import {
type MobileBot,
type MobileMe,
@ -349,31 +348,53 @@ export default function BotSettingsScreen() {
textAlignVertical: "top",
}}
/>
<View style={{ marginTop: 4 }}>
<OoAtelier value={color} identity={bot?.id ?? ""} onChange={setColor} />
</View>
<Text style={{ color: tokens.mutedForeground, marginTop: 16, fontSize: 14 }}>
{t("Color")}
</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ gap: 10, marginTop: 8 }}
accessibilityRole="radiogroup"
>
{BOT_COLORS.map((option, index) => (
<Pressable
key={option}
accessibilityRole="radio"
accessibilityLabel={t("Color {number}", { number: index + 1 })}
accessibilityState={{ checked: color === option }}
onPress={() => setColor(option)}
style={{
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: option,
borderWidth: 3,
borderColor: color === option ? tokens.foreground : "transparent",
}}
/>
))}
</ScrollView>
<ComputerModePicker value={computerMode} onChange={setComputerMode} />
{me?.modelsManaged ? null : (
<Pressable
accessibilityRole="button"
accessibilityLabel={t("Advanced")}
accessibilityState={{ expanded: advancedOpen }}
onPress={() => setAdvancedOpen((open) => !open)}
style={{
marginTop: 20,
minHeight: 44,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
}}
>
<Text style={{ color: tokens.mutedForeground, fontSize: 14 }}>{t("Advanced")}</Text>
<Text style={{ color: tokens.mutedForeground, fontSize: 18 }}>
{advancedOpen ? "⌃" : "⌄"}
</Text>
</Pressable>
)}
{advancedOpen && !me?.modelsManaged ? (
<Pressable
accessibilityRole="button"
accessibilityLabel={t("Advanced")}
accessibilityState={{ expanded: advancedOpen }}
onPress={() => setAdvancedOpen((open) => !open)}
style={{
marginTop: 20,
minHeight: 44,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
}}
>
<Text style={{ color: tokens.mutedForeground, fontSize: 14 }}>{t("Advanced")}</Text>
<Text style={{ color: tokens.mutedForeground, fontSize: 18 }}>
{advancedOpen ? "⌃" : "⌄"}
</Text>
</Pressable>
{advancedOpen ? (
<View>
<Text
style={{

View File

@ -3,15 +3,8 @@ import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import { useEffect, useState } from "react";
import { Alert, Pressable, ScrollView, Text, TextInput } from "react-native";
import { BotMemberPicker } from "../components/bot-member-picker";
import {
type MobileBot,
type MobileGroup,
type MobileSpace,
type MobileSpaceNavigation,
rpc,
} from "../lib/api";
import { type MobileBot, type MobileGroup, rpc } from "../lib/api";
import { useI18n } from "../lib/i18n";
import { canDeleteInboxSpace, removeInboxSpace } from "../lib/inbox-spaces";
import { useMobileTokens } from "../lib/native";
export default function GroupSettingsScreen() {
@ -20,16 +13,12 @@ export default function GroupSettingsScreen() {
const router = useRouter();
const { groupId } = useLocalSearchParams<{ groupId: string }>();
const [group, setGroup] = useState<MobileGroup | null>(null);
const [projectSpace, setProjectSpace] = useState<MobileSpace | null>(null);
const [bots, setBots] = useState<MobileBot[]>([]);
const [name, setName] = useState("");
const [selected, setSelected] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
const isMainConversation = group?.kind === "main";
const isProjectMain = isMainConversation && projectSpace !== null && !projectSpace.isDefault;
useEffect(() => {
if (!groupId) return;
void Promise.all([
@ -37,19 +26,11 @@ export default function GroupSettingsScreen() {
(groups) => groups.find((row) => row.id === groupId) ?? null,
),
rpc<MobileBot[]>("bots/list"),
rpc<MobileSpaceNavigation>("spaces/list").catch(() => null),
])
.then(([nextGroup, nextBots, navigation]) => {
.then(([nextGroup, nextBots]) => {
if (!nextGroup) throw new Error(t("Group not found"));
setGroup(nextGroup);
const space =
navigation?.spaces.find(
(item) => item.id === nextGroup.spaceId || item.id === navigation.current.id,
) ?? null;
setProjectSpace(space);
setName(
nextGroup.kind === "main" && space && !space.isDefault ? space.name : nextGroup.name,
);
setName(nextGroup.name);
setSelected(nextGroup.members.map((member) => member.botId));
setBots(nextBots.filter((bot) => !bot.archivedAt));
})
@ -61,20 +42,10 @@ export default function GroupSettingsScreen() {
setPending(true);
setError(null);
try {
const trimmed = name.trim();
if (isProjectMain && projectSpace) {
if (trimmed && trimmed !== projectSpace.name) {
await rpc("spaces/update", { spaceId: projectSpace.id, name: trimmed });
}
router.back();
return;
}
const input: { groupId: string; name?: string; botIds?: string[] } = { groupId };
if (trimmed !== group.name) input.name = trimmed;
if (!isMainConversation) {
const memberIds = group.members.map((member) => member.botId).join(",");
if (selected.join(",") !== memberIds) input.botIds = selected;
}
if (name.trim() !== group.name) input.name = name.trim();
const memberIds = group.members.map((member) => member.botId).join(",");
if (selected.join(",") !== memberIds) input.botIds = selected;
if (input.name || input.botIds) await rpc("groups/update", input);
router.back();
} catch (err) {
@ -86,29 +57,6 @@ export default function GroupSettingsScreen() {
function remove() {
if (!groupId || !group) return;
if (isProjectMain && projectSpace) {
Alert.alert(
t("Delete {name}?", { name: projectSpace.name }),
t("This removes the empty space for everyone."),
[
{ text: t("Cancel"), style: "cancel" },
{
text: t("Delete"),
style: "destructive",
onPress: () =>
void removeInboxSpace(projectSpace.id, async () => {})
.then(() => router.replace("/"))
.catch((err) =>
Alert.alert(
t("Could not delete space"),
err instanceof Error ? err.message : t("Try again."),
),
),
},
],
);
return;
}
Alert.alert(group.name, t("Delete this group? Bots and their solo threads are kept."), [
{ text: t("Cancel"), style: "cancel" },
{
@ -127,12 +75,6 @@ export default function GroupSettingsScreen() {
]);
}
const saveDisabled =
!name.trim() ||
pending ||
(!isMainConversation &&
(selected.length < GROUP_MEMBER_MIN || selected.length > GROUP_MEMBER_MAX));
return (
<>
<Stack.Screen options={{ title: t("Group settings") }} />
@ -155,27 +97,34 @@ export default function GroupSettingsScreen() {
fontSize: 16,
}}
/>
{!isMainConversation ? (
<>
<Text style={{ color: tokens.mutedForeground, fontSize: 14, marginTop: 20 }}>
{t("Members")}
</Text>
<BotMemberPicker
bots={bots}
selected={selected}
onChange={setSelected}
disabled={pending}
/>
</>
) : null}
<Text style={{ color: tokens.mutedForeground, fontSize: 14, marginTop: 20 }}>
{t("Members")}
</Text>
<BotMemberPicker
bots={bots}
selected={selected}
onChange={setSelected}
disabled={pending}
/>
{error ? <Text style={{ color: tokens.destructive, marginTop: 12 }}>{error}</Text> : null}
<Pressable
onPress={() => void save()}
disabled={saveDisabled}
disabled={
!name.trim() ||
selected.length < GROUP_MEMBER_MIN ||
selected.length > GROUP_MEMBER_MAX ||
pending
}
style={{
marginTop: 24,
backgroundColor: tokens.primary,
opacity: saveDisabled ? 0.5 : 1,
opacity:
!name.trim() ||
selected.length < GROUP_MEMBER_MIN ||
selected.length > GROUP_MEMBER_MAX ||
pending
? 0.5
: 1,
borderRadius: 11,
padding: 14,
alignItems: "center",
@ -185,35 +134,19 @@ export default function GroupSettingsScreen() {
{pending ? t("Saving…") : t("Save")}
</Text>
</Pressable>
{isProjectMain && projectSpace && canDeleteInboxSpace(projectSpace) ? (
<Pressable
onPress={remove}
style={{
marginTop: 16,
borderRadius: 11,
borderWidth: 1,
borderColor: tokens.border,
padding: 14,
alignItems: "center",
}}
>
<Text style={{ color: tokens.destructive, fontSize: 16 }}>{t("Delete space")}</Text>
</Pressable>
) : !isMainConversation ? (
<Pressable
onPress={remove}
style={{
marginTop: 16,
borderRadius: 11,
borderWidth: 1,
borderColor: tokens.border,
padding: 14,
alignItems: "center",
}}
>
<Text style={{ color: tokens.destructive, fontSize: 16 }}>{t("Delete group")}</Text>
</Pressable>
) : null}
<Pressable
onPress={remove}
style={{
marginTop: 16,
borderRadius: 11,
borderWidth: 1,
borderColor: tokens.border,
padding: 14,
alignItems: "center",
}}
>
<Text style={{ color: tokens.destructive, fontSize: 16 }}>{t("Delete group")}</Text>
</Pressable>
</ScrollView>
</>
);

View File

@ -1,4 +1,3 @@
import type { AutopilotRecent } from "@rakazo/contracts";
import {
normalizeCreateBotProfile,
type RunActivityRow,
@ -6,7 +5,6 @@ import {
type SpaceBot,
type SpaceGroup,
} from "@rakazo/contracts";
import { projectLook, runChatTitle } from "@rakazo/core";
import { botColors } from "@rakazo/ui-tokens";
import { Redirect, useFocusEffect, useRouter } from "expo-router";
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
@ -45,11 +43,9 @@ import {
rpc,
selectedSpaceId,
selectInitialSpace,
selectLibrarySpace,
selectSpace,
} from "../lib/api";
import { mobileShellIconSizes, mobileTokens, resolveMobileAppearance } from "../lib/appearance";
import { autopilotSummary } from "../lib/autopilot";
import { mobileTokens, resolveMobileAppearance } from "../lib/appearance";
import { allowFocusPrompt, scheduleFocusPrompt } from "../lib/focus-prompt";
import { t, useI18n } from "../lib/i18n";
import { botTag, filterBots, formatThreadTime, userInitials } from "../lib/inbox";
@ -91,7 +87,6 @@ export default function Home() {
const [botSections, setBotSections] = useState<MobileBotSection[]>([]);
const [spaces, setSpaces] = useState<MobileSpace[]>([]);
const [me, setMe] = useState<MobileMe | null>(null);
const [autopilots, setAutopilots] = useState<AutopilotRecent[]>([]);
const [error, setError] = useState<string | null>(null);
const [ready, setReady] = useState(false);
const [hasSession, setHasSession] = useState(false);
@ -136,10 +131,9 @@ export default function Home() {
const requestId = ++inboxRequestId.current;
setError(null);
try {
const [navigation, nextMe, recentAutopilots] = await Promise.all([
const [navigation, nextMe] = await Promise.all([
rpc<MobileSpaceNavigation>("spaces/list"),
rpc<MobileMe>("me"),
rpc<{ items: AutopilotRecent[] }>("autopilot/recent").catch(() => null),
]);
if (requestId !== inboxRequestId.current) return;
if (!(await selectInitialSpace(nextMe.spaceId))) {
@ -151,7 +145,6 @@ export default function Home() {
setGroups(navigation.current.groups);
setSpaces(navigation.spaces);
setMe(nextMe);
if (recentAutopilots) setAutopilots(recentAutopilots.items);
} catch (err) {
if (requestId !== inboxRequestId.current) return;
setError(err instanceof Error ? err.message : t("Could not load bots"));
@ -412,7 +405,6 @@ export default function Home() {
return;
creatingBotRef.current = true;
try {
await selectLibrarySpace();
// Authoritative roster so a slow home fetch does not treat later bots as first.
// A failed list is unknown — use the delayed path rather than assuming first.
const existing = await rpc<MobileBot[]>("bots/list").catch(() => null);
@ -464,7 +456,7 @@ export default function Home() {
<NativeSymbol
ios={activityMode ? "bell.fill" : "bell"}
android={activityMode ? "notifications" : "notifications-outline"}
size={mobileShellIconSizes.md}
size={17}
color={activityMode ? tokens.primaryForeground : tokens.foreground}
/>
</CircleButton>
@ -478,7 +470,7 @@ export default function Home() {
})
}
>
<NativeSymbol ios="magnifyingglass" android="search" size={mobileShellIconSizes.md} />
<NativeSymbol ios="magnifyingglass" android="search" size={17} />
</CircleButton>
<CircleButton
accessibilityLabel={t("Create")}
@ -492,7 +484,7 @@ export default function Home() {
]);
}}
>
<NativeSymbol ios="plus" android="add" size={mobileShellIconSizes.lg} />
<NativeSymbol ios="plus" android="add" size={18} />
</CircleButton>
</View>
</View>
@ -551,15 +543,11 @@ export default function Home() {
/>
}
ListHeaderComponent={
!searching && !query.trim() ? (
<>
{autopilots.length > 0 ? (
<AutopilotSection autopilots={autopilots} bots={bots} groups={groups} />
) : null}
{activityMode && (activity.active.length > 0 || activity.recent.length > 0) ? (
<ActivitySection activity={activity} bots={bots} />
) : null}
</>
activityMode &&
!searching &&
!query.trim() &&
(activity.active.length > 0 || activity.recent.length > 0) ? (
<ActivitySection activity={activity} bots={bots} />
) : null
}
ListEmptyComponent={
@ -597,25 +585,10 @@ export default function Home() {
}}
disabled={spaceBusy}
onPress={() => {
if (!item.space) return;
const targetSpace = item.space;
const main = !targetSpace.isDefault
? targetSpace.groups.find((group) => group.kind === "main")
: undefined;
if (main) {
void openMobileSpace(targetSpace.id, () =>
router.push({
pathname: "/group-thread",
params: { groupId: main.id, name: targetSpace.name },
}),
);
return;
}
void chooseInboxSpace(targetSpace.id);
if (item.space) void chooseInboxSpace(item.space.id);
}}
style={({ pressed }) => [styles.spaceSelect, pressed && styles.rowPressed]}
>
<ProjectTile space={item.space} />
<Text style={styles.spaceTitle}>{item.title}</Text>
</Pressable>
{canDeleteInboxSpace(item.space) ? (
@ -628,11 +601,7 @@ export default function Home() {
}}
style={({ pressed }) => [styles.spaceActions, pressed && styles.rowPressed]}
>
<NativeSymbol
ios="ellipsis"
android="ellipsis-horizontal"
size={mobileShellIconSizes.lg}
/>
<NativeSymbol ios="ellipsis" android="ellipsis-horizontal" size={20} />
</Pressable>
) : null}
</View>
@ -730,17 +699,16 @@ function ActivitySection({
const { t } = useI18n();
const router = useRouter();
const botsById = useMemo(() => new Map(bots.map((bot) => [bot.id, bot])), [bots]);
const openRun = (run: RunActivityRow) =>
void openMobileSpace(run.spaceId, () => {
if (run.groupId) {
router.push({
pathname: "/group-thread",
params: { groupId: run.groupId, name: run.groupName ?? t("Group") },
});
return;
}
router.push({ pathname: "/thread", params: { botId: run.botId, name: run.botName } });
});
const openRun = (run: RunActivityRow) => {
if (run.groupId) {
router.push({
pathname: "/group-thread",
params: { groupId: run.groupId, name: run.groupName ?? t("Group") },
});
return;
}
router.push({ pathname: "/thread", params: { botId: run.botId, name: run.botName } });
};
return (
<View style={styles.activitySection}>
@ -776,67 +744,6 @@ function ActivitySection({
);
}
/** The night's work, like Today on web: each autopilot that runs, waits, or ended within a day. */
function AutopilotSection({
autopilots,
bots,
groups,
}: {
autopilots: AutopilotRecent[];
bots: MobileBot[];
groups: MobileGroup[];
}) {
const styles = useThemedStyles(createHomeStyles);
const { t } = useI18n();
const router = useRouter();
const botsById = useMemo(() => new Map(bots.map((bot) => [bot.id, bot])), [bots]);
const open = (autopilot: AutopilotRecent) =>
void openMobileSpace(autopilot.spaceId, () =>
router.push(
autopilot.groupId
? {
pathname: "/group-thread",
params: {
groupId: autopilot.groupId,
name: groups.find((group) => group.id === autopilot.groupId)?.name ?? t("Group"),
},
}
: {
pathname: "/thread",
params: {
botId: autopilot.botId,
name: botsById.get(autopilot.botId)?.name ?? "",
},
},
),
);
return (
<View style={styles.activitySection} testID="home-autopilot">
<Text style={styles.sectionHeading}>{t("Autopilot")}</Text>
{autopilots.map((autopilot) => {
const { detail, trail } = autopilotSummary(autopilot);
return (
<ConversationRow
key={autopilot.id}
title={autopilot.goal}
preview={detail}
time={trail}
accessibilityLabel={`${autopilot.goal}, ${trail}`}
onPress={() => open(autopilot)}
avatar={
<BotAvatar
identity={autopilot.botId}
color={botsById.get(autopilot.botId)?.color ?? FALLBACK_COLOR}
/>
}
/>
);
})}
</View>
);
}
function ActivityRow({
run,
bot,
@ -846,7 +753,7 @@ function ActivityRow({
bot?: MobileBot;
onPress: () => void;
}) {
const title = runChatTitle(run);
const title = run.groupName ? `${run.botName} · ${run.groupName}` : run.botName;
const status = activityStatusLabel(run.status);
const preview = run.promptSnippet ? `${run.promptSnippet} · ${status}` : status;
return (
@ -924,16 +831,6 @@ function ConversationRow({
);
}
function ProjectTile({ space }: { space: Pick<InboxSpace, "icon" | "color"> }) {
const styles = useThemedStyles(createHomeStyles);
const { icon, color } = projectLook(space);
return (
<View style={[styles.projectTile, { backgroundColor: color.hex }]}>
<NativeSymbol ios={icon.ios} android={icon.android as never} size={15} color={color.ink} />
</View>
);
}
function CircleButton({
children,
onPress,
@ -1091,9 +988,9 @@ function createHomeStyles() {
gap: 12,
},
circleButton: {
width: 44,
height: 44,
borderRadius: 22,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: native.fillPressed,
alignItems: "center",
justifyContent: "center",
@ -1219,15 +1116,6 @@ function createHomeStyles() {
spaceSelect: {
flex: 1,
minHeight: 44,
flexDirection: "row",
alignItems: "center",
gap: 10,
},
projectTile: {
width: 28,
height: 28,
borderRadius: 8,
alignItems: "center",
justifyContent: "center",
},
spaceTitle: {

View File

@ -5,10 +5,7 @@ import {
CONNECTION_CATALOG_PAGE_SIZE,
EMPTY_PLUGIN_CATALOG_MESSAGE,
filterConnectionCatalogItems,
GITHUB_MCP_SOURCE,
GITHUB_TOKEN_URL,
humanizeToolName,
isGithubInstall,
} from "@rakazo/core";
import { useEffect, useMemo, useRef, useState } from "react";
import {
@ -87,11 +84,6 @@ export default function Integrations() {
const [query, setQuery] = useState("");
const [visibleCount, setVisibleCount] = useState(CONNECTION_CATALOG_PAGE_SIZE);
const [sources, setSources] = useState<CapabilityInstall[]>([]);
const github = sources.find(isGithubInstall);
const customSources = sources.filter((source) => !isGithubInstall(source));
const [githubOpen, setGithubOpen] = useState(false);
const [githubToken, setGithubToken] = useState("");
const [githubError, setGithubError] = useState<string | null>(null);
const [sourceKind, setSourceKind] = useState<SourceKind | null>(null);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [name, setName] = useState("");
@ -396,27 +388,6 @@ export default function Integrations() {
}
}
async function connectGithub() {
setPending("github");
setGithubError(null);
try {
await rpc("capabilities/install", {
kind: "mcp",
name: "GitHub",
source: GITHUB_MCP_SOURCE,
credential: githubToken.trim(),
config: { preset: "github" },
});
setGithubToken("");
setGithubOpen(false);
await refresh();
} catch (reason) {
setGithubError(reason instanceof Error ? reason.message : t("Could not connect GitHub"));
} finally {
setPending(null);
}
}
async function removeSource(source: CapabilityInstall) {
setPending(source.id);
setSourceError(null);
@ -621,63 +592,6 @@ export default function Integrations() {
renderDetail(detailItem)
) : (
<>
<View testID="github-connect" style={styles.card}>
<View style={styles.detailTitleRow}>
<ConnectorLogo label="GitHub" styles={styles} />
<Text style={[styles.title, styles.grow]}>GitHub</Text>
{github ? (
<Pressable
accessibilityRole="button"
disabled={pending === github.id}
onPress={() => void removeSource(github)}
>
<Text style={styles.remove}>{t("Disconnect")}</Text>
</Pressable>
) : githubOpen ? null : (
<Pressable
accessibilityRole="button"
onPress={() => setGithubOpen(true)}
style={styles.smallButton}
>
<Text style={styles.buttonLabel}>{t("Connect")}</Text>
</Pressable>
)}
</View>
{!github && githubOpen ? (
<>
<Pressable
accessibilityRole="link"
onPress={() => void Linking.openURL(GITHUB_TOKEN_URL)}
style={styles.cardButton}
>
<Text style={styles.buttonLabel}>{t("Create token on GitHub")}</Text>
</Pressable>
<TextInput
value={githubToken}
onChangeText={setGithubToken}
accessibilityLabel={t("GitHub token")}
placeholder="ghp_…"
placeholderTextColor={native.tertiaryLabel}
autoCapitalize="none"
autoCorrect={false}
secureTextEntry
style={styles.input}
/>
<Pressable
accessibilityRole="button"
disabled={pending === "github" || !githubToken.trim()}
onPress={() => void connectGithub()}
style={styles.cardButton}
>
<Text style={styles.buttonLabel}>
{pending === "github" ? t("Connecting…") : t("Connect")}
</Text>
</Pressable>
</>
) : null}
{githubError ? <Text style={styles.error}>{githubError}</Text> : null}
</View>
{!catalogReady ? <ActivityIndicator color={native.fillPressed} /> : null}
{catalogReady && catalog.length === 0 ? (
@ -868,10 +782,10 @@ export default function Integrations() {
) : null}
<Text style={styles.section}>{t("Tool sources")}</Text>
{customSources.length === 0 ? (
{sources.length === 0 ? (
<Text style={styles.secondary}>{t("No custom sources installed.")}</Text>
) : null}
{customSources.map((source) => (
{sources.map((source) => (
<View key={source.id} style={styles.row}>
<View style={styles.grow}>
<Text style={styles.title}>{source.name}</Text>

View File

@ -931,7 +931,7 @@ export default function Models() {
{selected.auth === "oauth" && !subscriptionSignIn ? (
<Text style={styles.secondary}>
{t(
"This subscription sign-in is not available in evOo yet. Use a deployment credential or choose another provider.",
"This subscription sign-in is not available in Rakazo yet. Use a deployment credential or choose another provider.",
)}
</Text>
) : null}

View File

@ -9,7 +9,7 @@ import { Stack, useRouter } from "expo-router";
import { useState } from "react";
import { Pressable, ScrollView, Text, TextInput } from "react-native";
import { ComputerModePicker } from "../components/computer-mode-picker";
import { type MobileBot, rpc, selectLibrarySpace } from "../lib/api";
import { type MobileBot, rpc } from "../lib/api";
import { allowFocusPrompt, scheduleFocusPrompt } from "../lib/focus-prompt";
import { useI18n } from "../lib/i18n";
import { useMobileTokens } from "../lib/native";
@ -42,7 +42,6 @@ export default function NewBot() {
setPending(true);
setError(null);
try {
await selectLibrarySpace();
// Failed list is unknown — delay focus rather than treating the bot as first.
const existing = await rpc<MobileBot[]>("bots/list").catch(() => null);
const isFirstBot = existing !== null && existing.length === 0;

View File

@ -166,9 +166,9 @@ export default function SignIn() {
{resetSent
? t("Check your email")
: mode === "in"
? t("Sign in to evOo")
? t("Sign in to Rakazo")
: mode === "up"
? t("Sign up for evOo")
? t("Sign up for Rakazo")
: t("Reset your password")}
</Text>
{resetSent ? (
@ -457,7 +457,7 @@ function ServerSheet({
<Text
style={{ color: tokens.mutedForeground, marginTop: 28, fontSize: 15, lineHeight: 22 }}
>
{t("Enter your evOo server address.")}
{t("Enter your Rakazo server address.")}
</Text>
<TextInput
autoCapitalize="none"

File diff suppressed because it is too large Load Diff

View File

@ -1,51 +1,76 @@
import { voicesForLocale } from "@rakazo/core";
import { useFocusEffect } from "expo-router";
import { useCallback, useState } from "react";
import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
import {
ActivityIndicator,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { NativeSymbol } from "../components/native-symbol";
import { rpc } from "../lib/api";
import { mobileTokens } from "../lib/appearance";
import { useI18n } from "../lib/i18n";
import { native, useThemedStyles } from "../lib/native";
import {
alignVoiceWithLocale,
KOKORO_LOCAL_TOKEN,
LOCAL_VOICE_PROVIDER,
speakText,
} from "../lib/voice";
import { speakText } from "../lib/voice";
const KOKORO_LOCAL_TOKEN = "local-kokoro";
type VoiceCatalogEntry = {
id: string;
name: string;
description: string;
transcribe: boolean;
};
type VoiceCredential = {
id: string;
provider: string;
voiceId: string;
};
type VoiceStatus = {
configured: boolean;
ready: boolean;
provider: string | null;
voiceId: string;
};
type VoiceInfo = { id: string; label: string; description?: string };
export default function VoiceSettings() {
const styles = useThemedStyles(createVoiceStyles);
const { locale, t } = useI18n();
const [connected, setConnected] = useState(false);
const { t } = useI18n();
const [catalog, setCatalog] = useState<VoiceCatalogEntry[]>([]);
const [credentials, setCredentials] = useState<VoiceCredential[]>([]);
const [status, setStatus] = useState<VoiceStatus | null>(null);
const [voices, setVoices] = useState<VoiceInfo[]>([]);
const [provider, setProvider] = useState("");
const [apiKey, setApiKey] = useState("");
const [voiceId, setVoiceId] = useState("");
const [loading, setLoading] = useState(true);
const [pending, setPending] = useState<"connect" | "disconnect" | "voice" | null>(null);
const [playing, setPlaying] = useState<string | null>(null);
const [pending, setPending] = useState<"connect" | "disconnect" | "voice" | "test" | null>(null);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const load = useCallback(async () => {
await alignVoiceWithLocale(locale).catch(() => undefined);
const credentials = await rpc<VoiceCredential[]>("voice/credentials");
const cred = credentials.find((entry) => entry.provider === LOCAL_VOICE_PROVIDER);
setConnected(Boolean(cred));
const load = useCallback(async (nextProvider?: string) => {
const [nextCatalog, nextCredentials, nextStatus] = await Promise.all([
rpc<VoiceCatalogEntry[]>("voice/catalog"),
rpc<VoiceCredential[]>("voice/credentials"),
rpc<VoiceStatus>("voice/status"),
]);
const selected = nextProvider || nextStatus.provider || nextCatalog[0]?.id || "";
setCatalog(nextCatalog);
setCredentials(nextCredentials);
setStatus(nextStatus);
setProvider(selected);
const cred = nextCredentials.find((entry) => entry.provider === selected);
setVoiceId(cred?.voiceId ?? "");
if (cred) {
const listed = await rpc<VoiceInfo[]>("voice/voices", { provider: LOCAL_VOICE_PROVIDER });
setVoices(voicesForLocale(listed, locale));
setVoices(await rpc<VoiceInfo[]>("voice/voices", { provider: selected }));
} else {
setVoices([]);
}
}, [locale]);
}, []);
useFocusEffect(
useCallback(() => {
@ -58,12 +83,24 @@ export default function VoiceSettings() {
}, [load]),
);
const selected = catalog.find((entry) => entry.id === provider);
const isKokoro = selected?.id === "kokoro";
const credential = credentials.find((entry) => entry.provider === provider);
const canConnect = isKokoro || apiKey.trim().length >= 8;
async function connect() {
if (!selected || !canConnect) return;
setPending("connect");
setError(null);
try {
await rpc("voice/connect", { provider: LOCAL_VOICE_PROVIDER, apiKey: KOKORO_LOCAL_TOKEN });
await load();
await rpc("voice/connect", {
provider: selected.id,
apiKey: isKokoro ? KOKORO_LOCAL_TOKEN : apiKey.trim(),
voiceId: voiceId || undefined,
});
setApiKey("");
await load(selected.id);
setNotice(t("Connected {name}.", { name: selected.name }));
} catch (err) {
setError(err instanceof Error ? err.message : t("Could not connect"));
} finally {
@ -72,11 +109,14 @@ export default function VoiceSettings() {
}
async function disconnect() {
if (!credential) return;
setPending("disconnect");
setError(null);
setNotice(null);
try {
await rpc("voice/disconnect", { provider: LOCAL_VOICE_PROVIDER });
await load();
await rpc("voice/disconnect", { provider: credential.provider });
setApiKey("");
await load(credential.provider);
} catch (err) {
setError(err instanceof Error ? err.message : t("Could not disconnect"));
} finally {
@ -87,145 +127,173 @@ export default function VoiceSettings() {
async function chooseVoice(nextVoiceId: string) {
setVoiceId(nextVoiceId);
setPending("voice");
setError(null);
try {
await rpc("voice/setVoice", { voiceId: nextVoiceId, provider: LOCAL_VOICE_PROVIDER });
await rpc("voice/setVoice", { voiceId: nextVoiceId, provider: selected?.id });
await load(selected?.id);
} catch (err) {
setError(err instanceof Error ? err.message : t("Could not save that voice"));
await load().catch(() => undefined);
} finally {
setPending(null);
}
}
async function playVoice(voice: VoiceInfo) {
setPlaying(voice.id);
async function testVoice() {
setPending("test");
setError(null);
try {
await speakText(t("Hi, this is how I'll sound when I read replies out loud."), {
voiceId: voice.id,
});
const ready = await speakText(t("Hi, this is how I'll sound when I read replies out loud."));
if (!ready) {
throw new Error(t("Connect a voice provider first."));
}
} catch (err) {
setError(err instanceof Error ? err.message : t("Could not play a sample"));
} finally {
setPlaying((current) => (current === voice.id ? null : current));
setPending(null);
}
}
const busy = pending !== null;
return (
<SafeAreaView edges={["bottom"]} style={styles.screen}>
<ScrollView contentContainerStyle={styles.content}>
{loading ? <ActivityIndicator color={native.secondaryLabel} /> : null}
{error ? <Text style={styles.error}>{error}</Text> : null}
{!loading && !connected ? (
<Pressable
disabled={busy}
onPress={() => void connect()}
style={[styles.button, busy && styles.disabled]}
>
<Text style={styles.buttonLabel}>{t("Connect")}</Text>
</Pressable>
) : null}
{voices.length ? (
<ScrollView
horizontal
accessibilityRole="radiogroup"
showsHorizontalScrollIndicator={false}
snapToInterval={CARD_WIDTH + CARD_GAP}
decelerationRate="fast"
style={styles.carousel}
contentContainerStyle={styles.carouselContent}
>
{voices.map((voice) => {
const active = voiceId === voice.id;
return (
<Pressable
key={voice.id}
accessibilityRole="radio"
accessibilityState={{ checked: active, disabled: busy }}
disabled={busy}
onPress={() => void chooseVoice(voice.id)}
style={[styles.card, active && styles.cardActive]}
>
<View style={styles.cardHeader}>
<Text numberOfLines={1} style={styles.cardTitle}>
{voice.label}
</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel={t("Play {name}", { name: voice.label })}
disabled={playing !== null}
hitSlop={8}
onPress={() => void playVoice(voice)}
style={styles.play}
>
{playing === voice.id ? (
<ActivityIndicator size="small" color={native.secondaryLabel} />
) : (
<NativeSymbol ios="play.fill" android="play" size={14} />
)}
</Pressable>
</View>
{voice.description ? (
<Text style={styles.cardMeta}>{voice.description}</Text>
) : null}
</Pressable>
);
})}
</ScrollView>
) : null}
{connected ? (
<Pressable
disabled={busy}
onPress={() => void disconnect()}
style={[styles.secondary, busy && styles.disabled]}
>
<Text style={styles.secondaryLabel}>
{pending === "disconnect" ? t("Disconnecting…") : t("Disconnect")}
</Text>
</Pressable>
{notice ? <Text style={styles.notice}>{notice}</Text> : null}
{catalog.map((entry) => {
const connected = credentials.some((cred) => cred.provider === entry.id);
return (
<Pressable
key={entry.id}
disabled={pending !== null}
onPress={() => {
setProvider(entry.id);
setPending("voice");
void load(entry.id)
.catch((err: unknown) =>
setError(
err instanceof Error ? err.message : t("Could not load voice settings"),
),
)
.finally(() => setPending(null));
}}
style={[
styles.card,
provider === entry.id && styles.cardActive,
pending !== null && styles.disabled,
]}
>
<Text style={styles.cardTitle}>{entry.name}</Text>
<Text style={styles.cardMeta}>
{connected
? t("Connected")
: entry.transcribe
? t("Speak + transcribe")
: t("Speak only")}
</Text>
</Pressable>
);
})}
{selected ? (
<>
{isKokoro ? (
<Text style={styles.help}>
{t("Local voice runs on your server. Tap Connect when it is ready.")}
</Text>
) : (
<TextInput
accessibilityLabel={t("API key")}
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
importantForAutofill="no"
value={apiKey}
onChangeText={setApiKey}
placeholder={credential ? t("Paste a replacement key") : t("Paste your API key")}
placeholderTextColor={native.tertiaryLabel}
secureTextEntry
style={styles.input}
textContentType="none"
/>
)}
<Pressable
disabled={pending !== null || !canConnect}
onPress={() => void connect()}
style={[styles.button, (pending !== null || !canConnect) && styles.disabled]}
>
<Text style={styles.buttonLabel}>
{credential ? (isKokoro ? t("Reconnect") : t("Replace key")) : t("Connect")}
</Text>
</Pressable>
{credential ? (
<Pressable
disabled={pending !== null}
onPress={() => void disconnect()}
style={[styles.secondary, pending !== null && styles.disabled]}
>
<Text style={styles.secondaryLabel}>
{pending === "disconnect" ? t("Disconnecting…") : t("Disconnect")}
</Text>
</Pressable>
) : null}
{voices.length ? (
<View style={styles.voices}>
{voices.map((voice) => (
<Pressable
key={voice.id}
disabled={pending !== null}
onPress={() => void chooseVoice(voice.id)}
style={[styles.voiceRow, pending !== null && styles.disabled]}
>
<Text style={styles.voiceLabel}>{voice.label}</Text>
{voiceId === voice.id ? <Text style={styles.check}>✓</Text> : null}
</Pressable>
))}
</View>
) : null}
{status?.ready ? (
<Pressable
disabled={pending !== null}
onPress={() => void testVoice()}
style={styles.secondary}
>
<Text style={styles.secondaryLabel}>{t("Hear a sample")}</Text>
</Pressable>
) : null}
</>
) : null}
</ScrollView>
</SafeAreaView>
);
}
const CARD_WIDTH = 160;
const CARD_GAP = 12;
function createVoiceStyles() {
const tokens = mobileTokens();
return StyleSheet.create({
screen: { flex: 1, backgroundColor: native.page },
content: { paddingVertical: 20, gap: 10 },
error: { color: tokens.destructive, marginBottom: 8, marginHorizontal: 20 },
carousel: { flexGrow: 0 },
carouselContent: { paddingHorizontal: 20, gap: CARD_GAP },
content: { padding: 20, gap: 10 },
error: { color: tokens.destructive, marginBottom: 8 },
notice: { color: tokens.success, marginBottom: 8 },
card: {
width: CARD_WIDTH,
borderRadius: 14,
borderWidth: 1,
borderColor: tokens.border,
paddingHorizontal: 14,
paddingVertical: 12,
padding: 14,
backgroundColor: tokens.card,
},
cardActive: { borderColor: tokens.primary, borderWidth: 2, backgroundColor: tokens.muted },
cardHeader: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" },
cardTitle: { flexShrink: 1, color: native.label, fontSize: 16, fontWeight: "600" },
cardMeta: { color: native.tertiaryLabel, marginTop: 4, fontSize: 13 },
play: {
width: 30,
height: 30,
borderRadius: 15,
alignItems: "center",
justifyContent: "center",
backgroundColor: tokens.muted,
cardActive: { borderColor: tokens.ring, backgroundColor: tokens.muted },
cardTitle: { color: native.label, fontSize: 16 },
cardMeta: { color: native.tertiaryLabel, marginTop: 4, fontSize: 12 },
help: { marginTop: 8, color: native.secondaryLabel, fontSize: 14, lineHeight: 20 },
input: {
marginTop: 8,
borderRadius: 12,
borderWidth: 1,
borderColor: tokens.border,
color: native.label,
paddingHorizontal: 14,
paddingVertical: 12,
},
button: {
marginHorizontal: 20,
marginTop: 8,
backgroundColor: tokens.primary,
borderRadius: 12,
paddingVertical: 12,
@ -233,6 +301,17 @@ function createVoiceStyles() {
},
disabled: { opacity: 0.4 },
buttonLabel: { color: tokens.primaryForeground, fontWeight: "600" },
voices: { marginTop: 12, borderRadius: 12, borderWidth: 1, borderColor: tokens.border },
voiceRow: {
flexDirection: "row",
justifyContent: "space-between",
paddingHorizontal: 14,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: tokens.border,
},
voiceLabel: { color: native.label },
check: { color: tokens.success },
secondary: { marginTop: 16, alignItems: "center" },
secondaryLabel: { color: native.secondaryLabel, fontSize: 15 },
});

View File

@ -1,183 +0,0 @@
import type { AutopilotBlock } from "@rakazo/contracts";
import { formatAutopilotDuration } from "@rakazo/core";
import { useState } from "react";
import { Pressable, Text, View } from "react-native";
import { dateLocaleForUi, useI18n } from "../lib/i18n";
import { useMobileTokens } from "../lib/native";
import { NativeSymbol } from "./native-symbol";
export type AutopilotAction = "stop" | "resume";
const ICONS = {
"autopilot.started": { ios: "play.fill", android: "play" },
"autopilot.paused": { ios: "pause.fill", android: "pause" },
"autopilot.stopped": { ios: "stop.fill", android: "stop" },
"autopilot.finished": { ios: "checkmark.circle", android: "checkmark-circle-outline" },
} as const;
/**
* One quiet line when the autopilot starts, pauses, stops or finishes. The start line counts the
* steps and folds open to the latest checkpoints; a finished autopilot's result reads as a card.
*/
export function AutopilotLine({
block,
onAction,
}: {
block: AutopilotBlock;
onAction?: (action: AutopilotAction) => Promise<void>;
}) {
const tokens = useMobileTokens();
const { t, locale } = useI18n();
const [busy, setBusy] = useState(false);
const [failed, setFailed] = useState(false);
const [expanded, setExpanded] = useState(false);
const open = !block.ended && Boolean(onAction);
const muted = { color: tokens.mutedForeground, fontSize: 13.5 };
async function act(action: AutopilotAction) {
if (!onAction) return;
setBusy(true);
setFailed(false);
try {
await onAction(action);
} catch {
setFailed(true);
} finally {
setBusy(false);
}
}
const parts: string[] = [];
let label = t("Autopilot");
if (block.event === "autopilot.started") {
parts.push(block.goal);
if (block.until) {
const until = new Intl.DateTimeFormat(dateLocaleForUi(locale), {
hour: "numeric",
minute: "2-digit",
}).format(new Date(block.until));
parts.push(t("until {until}", { until }));
}
if (block.focus) parts.push(block.focus);
if (block.allowed?.length) {
parts.push(t("allowed: {allowed}", { allowed: block.allowed.join(", ") }));
}
} else if (block.event === "autopilot.paused") {
label = t("Autopilot paused");
if (block.text) parts.push(block.text);
} else {
label = block.event === "autopilot.stopped" ? t("Autopilot stopped") : t("Autopilot finished");
if (block.durationMs !== undefined) parts.push(formatAutopilotDuration(block.durationMs));
}
const icon = ICONS[block.event];
const checkpoints = block.event === "autopilot.started" ? (block.checkpoints ?? []) : [];
const latest = checkpoints.at(-1)?.text;
const time = new Intl.DateTimeFormat(dateLocaleForUi(locale), {
hour: "numeric",
minute: "2-digit",
});
const link = (text: string, action: AutopilotAction) => (
<Pressable
accessibilityRole="button"
hitSlop={10}
disabled={busy}
onPress={() => void act(action)}
style={{ opacity: busy ? 0.5 : 1 }}
>
<Text style={[muted, { fontWeight: "600" }]}>{text}</Text>
</Pressable>
);
return (
<View style={{ width: "100%", paddingVertical: 4, alignItems: "center", gap: 4 }}>
<View
style={{
flexDirection: "row",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "center",
columnGap: 6,
rowGap: 2,
}}
>
<NativeSymbol
ios={icon.ios}
android={icon.android}
size={13}
color={tokens.mutedForeground}
/>
<Text style={[muted, { color: tokens.foreground, fontWeight: "500" }]}>{label}</Text>
{parts.map((part, index) => (
<Text key={index} style={muted}>
· {part}
</Text>
))}
{block.steps && block.event === "autopilot.started" ? (
<>
<Text style={muted}>·</Text>
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded }}
hitSlop={10}
onPress={() => setExpanded((value) => !value)}
>
<Text style={[muted, { fontWeight: "600" }]}>
{block.steps === 1 ? t("1 step") : t("{count} steps", { count: block.steps })}
</Text>
</Pressable>
{latest && !expanded ? (
<Text style={muted} numberOfLines={1}>
· {t("last: {latest}", { latest })}
</Text>
) : null}
</>
) : null}
{open && block.event === "autopilot.paused" ? (
<>
<Text style={muted}>·</Text>
{link(t("Continue"), "resume")}
</>
) : null}
{open && (block.event === "autopilot.started" || block.event === "autopilot.paused") ? (
<>
<Text style={muted}>·</Text>
{link(failed ? t("Try again") : t("Stop"), "stop")}
</>
) : null}
</View>
{expanded && checkpoints.length > 0 ? (
<View style={{ width: "100%", maxWidth: 420, gap: 4, paddingHorizontal: 16 }}>
{checkpoints.map((checkpoint, index) => (
<View key={`${checkpoint.at}-${index}`} style={{ flexDirection: "row", gap: 8 }}>
<Text
style={[muted, { width: 64, textAlign: "right", fontVariant: ["tabular-nums"] }]}
>
{time.format(new Date(checkpoint.at))}
</Text>
<Text style={{ flex: 1, color: tokens.foreground, fontSize: 13.5 }}>
{checkpoint.text}
</Text>
</View>
))}
</View>
) : null}
{block.event === "autopilot.finished" && block.text ? (
<View
style={{
width: "100%",
maxWidth: 520,
marginTop: 4,
borderRadius: 12,
borderWidth: 1,
borderColor: tokens.border,
backgroundColor: tokens.card,
paddingHorizontal: 14,
paddingVertical: 10,
}}
>
<Text style={{ color: tokens.foreground, fontSize: 14 }}>{block.text}</Text>
</View>
) : null}
</View>
);
}

View File

@ -1,71 +0,0 @@
import { DateTimePicker } from "@expo/ui/community/datetime-picker";
import { useState } from "react";
import { Modal, Platform, Pressable, Text, View } from "react-native";
import { autopilotTimeOfDay } from "../lib/autopilot";
import { useI18n } from "../lib/i18n";
import { useMobileTokens, useResolvedAppearance } from "../lib/native";
/**
* The native time picker for "Until a time". Android opens its clock dialog; iOS shows the wheel in
* a sheet with Start. Either way the caller gets "HH:MM" and unmounts the picker.
*/
export function AutopilotTimePicker({
onPick,
onClose,
}: {
onPick: (time: string) => void;
onClose: () => void;
}) {
const tokens = useMobileTokens();
const colorScheme = useResolvedAppearance();
const { t } = useI18n();
const [value, setValue] = useState(() => {
const morning = new Date();
morning.setHours(8, 0, 0, 0);
return morning;
});
if (Platform.OS === "android") {
return (
<DateTimePicker
mode="time"
value={value}
onValueChange={(_event, date) => onPick(autopilotTimeOfDay(date))}
onDismiss={onClose}
/>
);
}
return (
<Modal visible animationType="slide" presentationStyle="pageSheet" onRequestClose={onClose}>
<View style={{ flex: 1, backgroundColor: tokens.background, padding: 20, gap: 12 }}>
<View
style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between" }}
>
<Pressable accessibilityRole="button" onPress={onClose} hitSlop={12}>
<Text style={{ color: tokens.mutedForeground, fontSize: 17 }}>{t("Cancel")}</Text>
</Pressable>
<Text style={{ color: tokens.foreground, fontSize: 17, fontWeight: "600" }}>
{t("Until when?")}
</Text>
<Pressable
accessibilityRole="button"
onPress={() => onPick(autopilotTimeOfDay(value))}
hitSlop={12}
>
<Text style={{ color: tokens.foreground, fontSize: 17, fontWeight: "600" }}>
{t("Start")}
</Text>
</Pressable>
</View>
<DateTimePicker
mode="time"
display="spinner"
value={value}
themeVariant={colorScheme}
onValueChange={(_event, date) => setValue(date)}
/>
</View>
</Modal>
);
}

View File

@ -0,0 +1,52 @@
import type { AvatarStyle, Me } from "@rakazo/contracts";
import { usePathname } from "expo-router";
import { createContext, type ReactNode, useContext, useEffect, useRef, useState } from "react";
import { rpc } from "../lib/api";
const AvatarStyleContext = createContext<{
avatarStyle: AvatarStyle;
updateAvatarStyle: (avatarStyle: AvatarStyle) => Promise<void>;
}>({
avatarStyle: "robot",
updateAvatarStyle: async () => undefined,
});
export function AvatarStyleProvider({ children }: { children: ReactNode }) {
const [avatarStyle, setAvatarStyle] = useState<AvatarStyle>("robot");
const pathname = usePathname();
const requestIdRef = useRef(0);
const updatePromiseRef = useRef<Promise<void> | null>(null);
useEffect(() => {
const requestId = ++requestIdRef.current;
void rpc<Me>("me")
.then((me) => {
if (requestId !== requestIdRef.current) return;
setAvatarStyle(me.avatarStyle);
})
.catch(() => undefined);
}, [pathname]);
function updateAvatarStyle(next: AvatarStyle): Promise<void> {
if (updatePromiseRef.current) return updatePromiseRef.current;
const requestId = ++requestIdRef.current;
const update = rpc<Me>("preferences/update", { avatarStyle: next })
.then((me) => {
if (requestId !== requestIdRef.current) return;
setAvatarStyle(me.avatarStyle);
})
.finally(() => {
updatePromiseRef.current = null;
});
updatePromiseRef.current = update;
return update;
}
return (
<AvatarStyleContext value={{ avatarStyle, updateAvatarStyle }}>{children}</AvatarStyleContext>
);
}
export function useAvatarStyle() {
return useContext(AvatarStyleContext);
}

View File

@ -1,76 +1,88 @@
import type { OoMoment } from "@rakazo/core";
import {
ACTIVE_RUN_STATUSES,
DEFAULT_OO_AVATAR,
decodeOoAvatar,
OO_SUCCESS_SECONDS,
ooIdentityShapeIndex,
ooMomentAfterStatus,
ooMomentForStatus,
resolveOoColor,
} from "@rakazo/core";
import { memo, useEffect, useMemo, useRef, useState } from "react";
import { Image, View } from "react-native";
import type { AvatarStyle } from "@rakazo/contracts";
import { ACTIVE_RUN_STATUSES, avatarIdentitySeed, organicAvatarPath } from "@rakazo/core";
import { memo, useEffect } from "react";
import { View } from "react-native";
import Animated, {
cancelAnimation,
Easing,
useAnimatedProps,
useAnimatedStyle,
useReducedMotion,
useSharedValue,
withRepeat,
withTiming,
} from "react-native-reanimated";
import Svg, { G, Path, Rect } from "react-native-svg";
import { workingAvatarDuration, workingAvatarFrame } from "../lib/avatar-motion";
import { useI18n } from "../lib/i18n";
import { useAvatarStyle } from "./avatar-style";
import { NativeSymbol } from "./native-symbol";
import { OoFigure } from "./oo-figure";
/** A short moment after a status change, such as joy when a run finishes. */
function useOoStatusMoment(status?: string): OoMoment | null {
const previous = useRef(status);
const [moment, setMoment] = useState<OoMoment | null>(null);
useEffect(() => {
const next = ooMomentAfterStatus(previous.current, status);
previous.current = status;
setMoment(next);
if (!next) return;
const timer = setTimeout(() => setMoment(null), OO_SUCCESS_SECONDS * 1000);
return () => clearTimeout(timer);
}, [status]);
return moment;
}
const AnimatedRect = Animated.createAnimatedComponent(Rect);
export const BotAvatar = memo(function BotAvatar({
color,
size = 54,
status,
identity,
moment,
still,
variant,
muted = false,
}: {
color: string;
size?: number;
status?: string;
identity?: string;
/** Explicit moment; wins over `status`. */
moment?: OoMoment;
still?: boolean;
variant?: AvatarStyle;
muted?: boolean;
}) {
const { t } = useI18n();
const isWorking = ACTIVE_RUN_STATUSES.some((activeStatus) => activeStatus === status);
const picture = color.startsWith("data:image/") ? color : null;
const look = useMemo(() => decodeOoAvatar(color) ?? DEFAULT_OO_AVATAR, [color]);
const seed = identity || (picture ? "agent" : color) || "agent";
const body = resolveOoColor(seed, look.color).hex;
const statusMoment = useOoStatusMoment(status);
const { avatarStyle } = useAvatarStyle();
const visorW = Math.round(size * 0.68);
const visorH = Math.round(size * 0.44);
const eyeW = Math.max(3, Math.round(size * 0.11));
const eyeH = Math.max(4, Math.round(size * 0.17));
const gap = Math.max(3, Math.round(size * 0.11));
return (
<View style={{ width: size, height: size }}>
{picture ? (
<Image
source={{ uri: picture }}
style={{ width: size, height: size, borderRadius: size / 2 }}
/>
{(variant ?? avatarStyle) === "organic" ? (
<OrganicAvatar color={color} identity={identity} size={size} isWorking={isWorking} />
) : (
<OoFigure
avatar={look}
color={body}
fallbackShape={look.shape ?? ooIdentityShapeIndex(seed)}
moment={moment ?? statusMoment ?? ooMomentForStatus(status)}
size={size}
still={still}
/>
<View
style={{
width: size,
height: size,
borderRadius: size / 2,
backgroundColor: color,
alignItems: "center",
justifyContent: "center",
}}
>
<View
style={{
width: visorW,
height: visorH,
borderRadius: Math.round(visorH * 0.52),
backgroundColor: "#0C0C0E",
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap,
}}
>
{[0, 1].map((eye) => (
<View
key={eye}
style={{
width: eyeW,
height: eyeH,
borderRadius: Math.max(2, Math.round(eyeW * 0.6)),
backgroundColor: "#fff",
}}
/>
))}
</View>
</View>
)}
{isWorking ? (
<View
@ -116,3 +128,81 @@ export const BotAvatar = memo(function BotAvatar({
</View>
);
});
function OrganicAvatar({
color,
identity,
size,
isWorking,
}: {
color: string;
identity?: string;
size: number;
isWorking: boolean;
}) {
const seed = avatarIdentitySeed(identity || color || "#8B5CF6");
const progress = useSharedValue(0);
const reducedMotion = useReducedMotion();
useEffect(() => {
cancelAnimation(progress);
progress.value = 0;
if (isWorking && !reducedMotion) {
progress.value = withRepeat(
withTiming(1, {
duration: workingAvatarDuration(seed),
easing: Easing.linear,
}),
-1,
);
}
return () => cancelAnimation(progress);
}, [isWorking, progress, reducedMotion, seed]);
const bodyStyle = useAnimatedStyle(() => {
const frame = workingAvatarFrame(seed, progress.value);
return {
transform: [
{ translateX: (frame.translationX * size) / 120 },
{ translateY: (frame.translationY * size) / 120 },
{ rotate: `${frame.rotation}deg` },
{ scaleX: frame.scaleX },
{ scaleY: frame.scaleY },
],
};
});
const leftEyeProps = useAnimatedProps(() => {
const frame = workingAvatarFrame(seed, progress.value);
return { x: -14 + frame.eyeOffsetX, y: -12 + frame.eyeOffsetY };
});
const rightEyeProps = useAnimatedProps(() => {
const frame = workingAvatarFrame(seed, progress.value);
return { x: 7 + frame.eyeOffsetX, y: -12 + frame.eyeOffsetY };
});
return (
<View style={{ width: size, height: size }}>
<Animated.View style={[{ width: size, height: size }, bodyStyle]}>
<Svg width={size} height={size} viewBox="-60 -60 120 120">
<Path d={organicAvatarPath(seed)} fill={color} />
<G transform={`rotate(${(seed % 9) - 4})`}>
<AnimatedRect
animatedProps={leftEyeProps}
width={7}
height={24}
rx={3.5}
fill="#101014"
/>
<AnimatedRect
animatedProps={rightEyeProps}
width={7}
height={24}
rx={3.5}
fill="#101014"
/>
</G>
</Svg>
</Animated.View>
</View>
);
}

View File

@ -1,173 +0,0 @@
import type { CardAct, CardSection } from "@rakazo/contracts";
import { cardTimerRemaining, formatCardClock } from "@rakazo/core";
import { useKeepAwake } from "expo-keep-awake";
import { useEffect, useState } from "react";
import { Modal, Pressable, SafeAreaView, StyleSheet, Text, View } from "react-native";
import { mobileTokens } from "../lib/appearance";
import { useI18n } from "../lib/i18n";
import { NativeSymbol } from "./native-symbol";
type Steps = Extract<CardSection, { type: "steps" }>;
/** The current time, ticking every second while `active`. */
export function useNow(active: boolean): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!active) return;
setNow(Date.now());
const timer = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(timer);
}, [active]);
return now;
}
/**
* A card's steps one at a time in full screen, large enough to read from the stove, with the
* screen kept on. The step and any timer live on the card, so the web and a reload agree.
*/
export function CookingMode({
title,
steps,
now,
busy,
onAct,
onClose,
}: {
title: string;
steps: Steps;
now: number;
busy: boolean;
onAct: (act: CardAct) => Promise<void>;
onClose: () => void;
}) {
useKeepAwake();
const tokens = mobileTokens();
const { t } = useI18n();
const index = Math.min(steps.current ?? 0, steps.steps.length - 1);
const step = steps.steps[index];
const last = index === steps.steps.length - 1;
const remaining = cardTimerRemaining(step?.timerEndsAt, now);
const running = remaining !== null && remaining > 0;
const go = (next: number) => void onAct({ type: "step", sectionId: steps.id, index: next });
return (
<Modal animationType="slide" presentationStyle="fullScreen" onRequestClose={onClose}>
<SafeAreaView style={[styles.screen, { backgroundColor: tokens.background }]}>
<View style={styles.top}>
<Text numberOfLines={1} style={[styles.title, { color: tokens.mutedForeground }]}>
{title}
</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel={t("Close")}
onPress={onClose}
hitSlop={12}
>
<NativeSymbol ios="xmark" android="close" size={20} />
</Pressable>
</View>
<View style={styles.middle}>
<Text style={[styles.counter, { color: tokens.mutedForeground }]}>
{t("Step {position} of {total}", {
position: index + 1,
total: steps.steps.length,
})}
</Text>
<Text style={[styles.step, { color: tokens.foreground }]}>{step?.text}</Text>
{step?.timerSeconds ? (
<Pressable
accessibilityRole="button"
accessibilityLabel={running ? t("Stop timer") : t("Start timer")}
disabled={busy}
onPress={() =>
void onAct({
type: "timer",
sectionId: steps.id,
stepId: step.id,
running: !running,
})
}
style={[
styles.timer,
{
borderColor: running ? tokens.primary : tokens.border,
backgroundColor: running ? tokens.primary : "transparent",
},
]}
>
<NativeSymbol
ios={running ? "pause.fill" : "play.fill"}
android={running ? "pause" : "play"}
size={20}
color={running ? tokens.primaryForeground : tokens.foreground}
/>
<Text
style={[
styles.clock,
{ color: running ? tokens.primaryForeground : tokens.foreground },
]}
>
{formatCardClock(remaining ?? step.timerSeconds)}
</Text>
</Pressable>
) : null}
</View>
<View style={styles.bottom}>
<Pressable
accessibilityRole="button"
disabled={busy || index === 0}
onPress={() => go(index - 1)}
style={[styles.nav, { borderColor: tokens.border, opacity: index === 0 ? 0.4 : 1 }]}
>
<Text style={[styles.navText, { color: tokens.foreground }]}>{t("Back")}</Text>
</Pressable>
<Pressable
accessibilityRole="button"
disabled={busy && !last}
onPress={() => (last ? onClose() : go(index + 1))}
style={[styles.nav, { borderColor: tokens.primary, backgroundColor: tokens.primary }]}
>
<Text style={[styles.navText, { color: tokens.primaryForeground }]}>
{last ? t("Done") : t("Next")}
</Text>
</Pressable>
</View>
</SafeAreaView>
</Modal>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, paddingHorizontal: 24 },
top: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
paddingTop: 16,
},
title: { flex: 1, fontSize: 15, fontWeight: "600" },
middle: { flex: 1, justifyContent: "center", gap: 28 },
counter: { fontSize: 15 },
step: { fontSize: 30, lineHeight: 40, fontWeight: "600" },
timer: {
alignSelf: "flex-start",
flexDirection: "row",
alignItems: "center",
gap: 10,
borderWidth: 1,
borderRadius: 16,
paddingHorizontal: 18,
paddingVertical: 12,
},
clock: { fontSize: 26, fontWeight: "600", fontVariant: ["tabular-nums"] },
bottom: { flexDirection: "row", justifyContent: "space-between", gap: 12, paddingBottom: 16 },
nav: {
flex: 1,
alignItems: "center",
borderWidth: 1,
borderRadius: 14,
paddingVertical: 14,
},
navText: { fontSize: 16, fontWeight: "600" },
});

View File

@ -1,410 +0,0 @@
import type { OoAvatar } from "@rakazo/core";
import {
encodeOoAvatar,
OO_ARMS,
OO_BROWS,
OO_CHEEKS,
OO_CLASSIC_SHAPE_COUNT,
OO_EYESHADOWS,
OO_EYEWEAR,
OO_HEADWEAR,
OO_IRISES,
OO_LASHES,
OO_LINERS,
OO_NECKWEAR,
OO_PALETTE_HEXES,
OO_SPARKLES,
OO_SPECIES,
ooLookForEditing,
ooRandomAvatar,
} from "@rakazo/core";
import { useState } from "react";
import { Pressable, ScrollView, Text, View } from "react-native";
import { useI18n } from "../lib/i18n";
import { useMobileTokens } from "../lib/native";
import { OoFigure } from "./oo-figure";
const THUMB = 52;
/** Temperament in three taps; web offers the same range as sliders. */
const ENERGY_STEPS = [0.75, 1, 1.35] as const;
const PLAYFUL_STEPS = [0.2, 0.5, 0.85] as const;
function nearest(steps: readonly number[], value: number): number {
return steps.reduce((best, step) =>
Math.abs(step - value) < Math.abs(best - value) ? step : best,
);
}
function useLabels() {
const { t } = useI18n();
const none = t("None");
return {
species: {
classic: t("Classic"),
cat: t("Cat"),
dog: t("Dog"),
bunny: t("Bunny"),
bear: t("Bear"),
robot: t("Robot"),
ghost: t("Ghost"),
frog: t("Frog"),
penguin: t("Penguin"),
},
lashes: { none, soft: t("Soft"), full: t("Full"), flick: t("Flick") },
liner: { none, wing: t("Winged") },
eyeshadow: { none, rose: t("Rose"), lilac: t("Lilac"), gold: t("Gold"), mint: t("Mint") },
iris: {
classic: t("Classic"),
brown: t("Brown"),
blue: t("Blue"),
green: t("Green"),
violet: t("Violet"),
},
sparkle: { dot: t("Dot"), star: t("Star"), heart: t("Heart") },
brows: { auto: t("When needed"), fine: t("Fine"), bushy: t("Bushy") },
cheeks: { none, pink: t("Pink"), peach: t("Peach"), freckles: t("Freckles") },
eyewear: {
none,
round: t("Round"),
nerd: t("Nerd"),
sun: t("Sunglasses"),
heart: t("Heart"),
monocle: t("Monocle"),
reading: t("Reading glasses"),
},
head: {
none,
chef: t("Chef's hat"),
crown: t("Crown"),
headphones: t("Headphones"),
bow: t("Bow"),
beanie: t("Beanie"),
flower: t("Flower"),
party: t("Party hat"),
},
neck: { none, bowtie: t("Bow tie"), scarf: t("Scarf"), bell: t("Bell") },
arms: { glass: t("Glass"), stub: t("Stub"), paw: t("Paw"), robot: t("Robot"), none },
energy: [t("Calm"), t("Steady"), t("Lively")],
} as const;
}
function Row<T extends string | number>({
title,
values,
selected,
label,
look,
patch,
focus = "body",
onSelect,
}: {
title: string;
values: readonly T[];
selected: T;
label: (value: T) => string;
look: OoAvatar;
patch: (value: T) => Partial<OoAvatar>;
focus?: "body" | "face";
onSelect: (value: T) => void;
}) {
const tokens = useMobileTokens();
return (
<View style={{ marginTop: 14 }}>
<Text style={{ color: tokens.mutedForeground, fontSize: 13 }}>{title}</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ gap: 8, marginTop: 6 }}
accessibilityRole="radiogroup"
accessibilityLabel={title}
>
{values.map((value) => {
const next = { ...look, ...patch(value) };
const active = value === selected;
return (
<Pressable
key={value}
accessibilityRole="radio"
accessibilityLabel={label(value)}
accessibilityState={{ checked: active }}
onPress={() => onSelect(value)}
style={{
width: THUMB + 8,
height: THUMB + 8,
borderRadius: 14,
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
backgroundColor: tokens.muted,
borderWidth: 2,
borderColor: active ? tokens.foreground : "transparent",
}}
>
<OoFigure
avatar={next}
color={next.color}
fallbackShape={next.shape ?? 0}
moment="idle"
size={THUMB}
focus={focus}
still
/>
</Pressable>
);
})}
</ScrollView>
</View>
);
}
/** The Oo look editor: species and colour first, everything else one tap further. */
export function OoAtelier({
value,
identity,
onChange,
}: {
value: string;
identity: string;
onChange: (value: string) => void;
}) {
const { t } = useI18n();
const tokens = useMobileTokens();
const labels = useLabels();
const [moreOpen, setMoreOpen] = useState(false);
const look = ooLookForEditing(value, identity);
const set = <K extends keyof OoAvatar>(field: K, next: OoAvatar[K]) =>
onChange(encodeOoAvatar({ ...look, [field]: next }));
const chip = (active: boolean) => ({
minHeight: 36,
paddingHorizontal: 14,
borderRadius: 18,
justifyContent: "center" as const,
backgroundColor: active ? tokens.foreground : tokens.muted,
});
const chipText = (active: boolean) => ({
color: active ? tokens.background : tokens.foreground,
fontSize: 14,
});
return (
<View>
<Row
title={t("Species")}
values={OO_SPECIES}
selected={look.species}
label={(species) => labels.species[species]}
look={look}
patch={(species) => ({ species, arms: species === "robot" ? "robot" : look.arms })}
onSelect={(species) =>
onChange(
encodeOoAvatar({
...look,
species,
arms: species === "robot" ? "robot" : look.arms === "robot" ? "glass" : look.arms,
}),
)
}
/>
{look.species === "classic" ? (
<Row
title={t("Shape")}
values={Array.from({ length: OO_CLASSIC_SHAPE_COUNT }, (_, index) => index)}
selected={look.shape ?? -1}
label={(shape) => t("Shape {number}", { number: shape + 1 })}
look={look}
patch={(shape) => ({ shape })}
onSelect={(shape) => set("shape", shape)}
/>
) : null}
<Text style={{ color: tokens.mutedForeground, marginTop: 14, fontSize: 13 }}>
{t("Color")}
</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ gap: 10, marginTop: 6 }}
accessibilityRole="radiogroup"
>
{OO_PALETTE_HEXES.map((option, index) => {
const checked = look.color.toLowerCase() === option.toLowerCase();
return (
<Pressable
key={option}
accessibilityRole="radio"
accessibilityLabel={t("Color {number}", { number: index + 1 })}
accessibilityState={{ checked }}
onPress={() => set("color", option)}
style={{
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: option,
borderWidth: 3,
borderColor: checked ? tokens.foreground : tokens.border,
}}
/>
);
})}
</ScrollView>
<View style={{ flexDirection: "row", gap: 8, marginTop: 14 }}>
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded: moreOpen }}
onPress={() => setMoreOpen((open) => !open)}
style={chip(moreOpen)}
>
<Text style={chipText(moreOpen)}>{t("Customize")}</Text>
</Pressable>
<Pressable
accessibilityRole="button"
onPress={() => onChange(encodeOoAvatar(ooRandomAvatar()))}
style={chip(false)}
>
<Text style={chipText(false)}>{t("Surprise me")}</Text>
</Pressable>
</View>
{moreOpen ? (
<View>
<Row
title={t("Lashes")}
values={OO_LASHES}
selected={look.lashes}
label={(lashes) => labels.lashes[lashes]}
look={look}
patch={(lashes) => ({ lashes })}
focus="face"
onSelect={(lashes) => set("lashes", lashes)}
/>
<Row
title={t("Eyeliner")}
values={OO_LINERS}
selected={look.liner}
label={(liner) => labels.liner[liner]}
look={look}
patch={(liner) => ({ liner })}
focus="face"
onSelect={(liner) => set("liner", liner)}
/>
<Row
title={t("Eyeshadow")}
values={OO_EYESHADOWS}
selected={look.eyeshadow}
label={(eyeshadow) => labels.eyeshadow[eyeshadow]}
look={look}
patch={(eyeshadow) => ({ eyeshadow })}
focus="face"
onSelect={(eyeshadow) => set("eyeshadow", eyeshadow)}
/>
<Row
title={t("Iris")}
values={OO_IRISES}
selected={look.iris}
label={(iris) => labels.iris[iris]}
look={look}
patch={(iris) => ({ iris })}
focus="face"
onSelect={(iris) => set("iris", iris)}
/>
<Row
title={t("Sparkle")}
values={OO_SPARKLES}
selected={look.sparkle}
label={(sparkle) => labels.sparkle[sparkle]}
look={look}
patch={(sparkle) => ({ sparkle })}
focus="face"
onSelect={(sparkle) => set("sparkle", sparkle)}
/>
<Row
title={t("Brows")}
values={OO_BROWS}
selected={look.brows}
label={(brows) => labels.brows[brows]}
look={look}
patch={(brows) => ({ brows })}
focus="face"
onSelect={(brows) => set("brows", brows)}
/>
<Row
title={t("Cheeks")}
values={OO_CHEEKS}
selected={look.cheeks}
label={(cheeks) => labels.cheeks[cheeks]}
look={look}
patch={(cheeks) => ({ cheeks })}
focus="face"
onSelect={(cheeks) => set("cheeks", cheeks)}
/>
<Row
title={t("Glasses")}
values={OO_EYEWEAR}
selected={look.eyewear}
label={(eyewear) => labels.eyewear[eyewear]}
look={look}
patch={(eyewear) => ({ eyewear })}
focus="face"
onSelect={(eyewear) => set("eyewear", eyewear)}
/>
<Row
title={t("Hat")}
values={OO_HEADWEAR}
selected={look.head}
label={(head) => labels.head[head]}
look={look}
patch={(head) => ({ head })}
onSelect={(head) => set("head", head)}
/>
<Row
title={t("Neck")}
values={OO_NECKWEAR}
selected={look.neck}
label={(neck) => labels.neck[neck]}
look={look}
patch={(neck) => ({ neck })}
onSelect={(neck) => set("neck", neck)}
/>
<Row
title={t("Arms")}
values={OO_ARMS}
selected={look.arms}
label={(arms) => labels.arms[arms]}
look={look}
patch={(arms) => ({ arms })}
onSelect={(arms) => set("arms", arms)}
/>
{(
[
["energy", t("Energy"), ENERGY_STEPS],
["playful", t("Playful"), PLAYFUL_STEPS],
] as const
).map(([field, title, steps]) => {
const current = nearest(steps, look[field]);
return (
<View key={field} style={{ marginTop: 14 }}>
<Text style={{ color: tokens.mutedForeground, fontSize: 13 }}>{title}</Text>
<View
accessibilityRole="radiogroup"
accessibilityLabel={title}
style={{ flexDirection: "row", gap: 8, marginTop: 6 }}
>
{steps.map((step, index) => (
<Pressable
key={step}
accessibilityRole="radio"
accessibilityState={{ checked: step === current }}
onPress={() => set(field, step)}
style={chip(step === current)}
>
<Text style={chipText(step === current)}>{labels.energy[index]}</Text>
</Pressable>
))}
</View>
</View>
);
})}
</View>
) : null}
</View>
);
}

View File

@ -1,223 +0,0 @@
import type { OoAvatar, OoFrame, OoLife, OoMoment, OoPaint, OoShape } from "@rakazo/core";
import {
buildOoRig,
createOoLife,
OO_LIST_MOMENTS,
OO_RIG_FACE_VIEWBOX,
OO_RIG_INSET,
OO_RIG_SIZE,
OO_STILL_TIME,
ooFrame,
ooLodForSize,
ooSeededRandom,
} from "@rakazo/core";
import type { ReactNode } from "react";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { useReducedMotion } from "react-native-reanimated";
import Svg, {
Circle,
Defs,
Ellipse,
G,
LinearGradient,
Path,
RadialGradient,
Rect,
Stop,
} from "react-native-svg";
const VIEWBOX = `${OO_RIG_INSET} ${OO_RIG_INSET + 4} ${OO_RIG_SIZE - 2 * OO_RIG_INSET} ${OO_RIG_SIZE - 2 * OO_RIG_INSET}`;
const FACE_VIEWBOX = OO_RIG_FACE_VIEWBOX;
/** Native frames redraw the SVG tree, so they run at half the display rate. */
const FRAME_SECONDS = 1 / 30;
/** Above this size an Oo always moves; smaller ones only for list moments. */
const LIVELY_SIZE = 56;
type Tick = (now: number) => void;
const ticks = new Set<Tick>();
let frameRequest = 0;
function loop(now: number) {
for (const tick of ticks) tick(now / 1000);
frameRequest = ticks.size ? requestAnimationFrame(loop) : 0;
}
function subscribe(tick: Tick): () => void {
ticks.add(tick);
if (!frameRequest) frameRequest = requestAnimationFrame(loop);
return () => {
ticks.delete(tick);
};
}
function paintProps(paint: OoPaint, gradient: string) {
const fill =
paint.fill === "$body"
? `url(#${gradient}-body)`
: paint.fill === "$shine"
? `url(#${gradient}-shine)`
: paint.fill;
return {
fill: fill ?? (paint.stroke ? "none" : undefined),
stroke: paint.stroke,
strokeWidth: paint.strokeWidth,
opacity: paint.opacity,
fillOpacity: paint.fillOpacity,
strokeOpacity: paint.strokeOpacity,
strokeLinecap: paint.round ? ("round" as const) : undefined,
strokeLinejoin: paint.round ? ("round" as const) : undefined,
strokeDasharray: paint.dash,
};
}
function draw(
shape: OoShape,
key: number,
gradient: string,
frame: OoFrame,
mouthColor: string,
): ReactNode {
switch (shape.type) {
case "path":
return <Path key={key} d={shape.d} {...paintProps(shape, gradient)} />;
case "circle":
return (
<Circle
key={key}
cx={shape.cx}
cy={shape.cy}
r={shape.r}
{...paintProps(shape, gradient)}
/>
);
case "ellipse":
return (
<Ellipse
key={key}
cx={shape.cx}
cy={shape.cy}
rx={shape.rx}
ry={shape.ry}
{...paintProps(shape, gradient)}
/>
);
case "rect":
return (
<Rect
key={key}
x={shape.x}
y={shape.y}
width={shape.width}
height={shape.height}
rx={shape.rx}
{...paintProps(shape, gradient)}
/>
);
case "group": {
const { part, fx } = shape;
if (part === "mouth") {
return (
<Path
key={key}
d={frame.mouth.d}
fill={frame.mouth.filled ? mouthColor : "none"}
stroke={mouthColor}
strokeWidth={3.2}
strokeLinecap="round"
strokeLinejoin="round"
/>
);
}
if (part === "tongue") {
return frame.tongue ? <Path key={key} d={frame.tongue} fill="#FF7FA6" /> : null;
}
const state = fx
? frame.fx(fx.kind, fx.index)
: part && part !== "fx"
? frame.parts[part]
: undefined;
if (state?.hidden ?? shape.hidden) return null;
return (
<G key={key} transform={state?.transform ?? shape.transform} opacity={state?.opacity}>
{shape.children.map((child, index) => draw(child, index, gradient, frame, mouthColor))}
</G>
);
}
}
}
export function OoFigure({
avatar,
color,
fallbackShape,
moment,
size,
still = false,
focus = "body",
}: {
avatar: OoAvatar;
color: string;
fallbackShape: number;
moment: OoMoment;
size: number;
still?: boolean;
/** `face` crops to the eyes, for pickers of eye styles and eyewear. */
focus?: "body" | "face";
}) {
const gradient = `oo${useId().replace(/[^a-zA-Z0-9-_]/g, "")}`;
const lod = ooLodForSize(size);
const rig = useMemo(
() => buildOoRig({ avatar, color, fallbackShape, moment, lod }),
[avatar, color, fallbackShape, moment, lod],
);
const reducedMotion = useReducedMotion();
const lively =
!still && !reducedMotion && (size > LIVELY_SIZE || OO_LIST_MOMENTS.includes(moment));
const lifeRef = useRef<OoLife | null>(null);
const [frame, setFrame] = useState<OoFrame | null>(null);
useEffect(() => {
setFrame(null);
if (!lively) return;
if (!lifeRef.current) lifeRef.current = createOoLife();
const life = lifeRef.current;
const start = performance.now() / 1000 - Math.random() * 10;
let last = 0;
return subscribe((now) => {
if (now - last < FRAME_SECONDS) return;
last = now;
setFrame(ooFrame({ rig, avatar, moment, t: now - start, life }));
});
}, [rig, avatar, moment, lively]);
const stillFrame = useMemo(
() =>
ooFrame({
rig,
avatar,
moment,
t: OO_STILL_TIME,
life: createOoLife(ooSeededRandom(1)),
still: true,
}),
[rig, avatar, moment],
);
const shown = frame ?? stillFrame;
const { colors } = rig;
return (
<Svg width={size} height={size} viewBox={focus === "face" ? FACE_VIEWBOX : VIEWBOX}>
<Defs>
<LinearGradient id={`${gradient}-body`} x1="0" y1="0" x2="1" y2="1">
<Stop offset="0" stopColor={colors.light} />
<Stop offset="0.45" stopColor={colors.base} />
<Stop offset="1" stopColor={colors.dark} />
</LinearGradient>
<RadialGradient id={`${gradient}-shine`} cx="0.3" cy="0.22" r="0.55">
<Stop offset="0" stopColor="#FFFFFF" stopOpacity="0.55" />
<Stop offset="1" stopColor="#FFFFFF" stopOpacity="0" />
</RadialGradient>
</Defs>
{rig.shapes.map((shape, index) => draw(shape, index, gradient, shown, rig.mouthColor))}
</Svg>
);
}

View File

@ -1,105 +0,0 @@
import type { RoutineEventBlock } from "@rakazo/contracts";
import { isOneShotRoutineCrons } from "@rakazo/core";
import { useRouter } from "expo-router";
import { useState } from "react";
import { Pressable, Text, View } from "react-native";
import { rpc } from "../lib/api";
import { useI18n } from "../lib/i18n";
import { useMobileTokens } from "../lib/native";
import { routineLineSchedule } from "../lib/routine-line";
import { NativeSymbol } from "./native-symbol";
/** A routine the bot set up from the conversation: one quiet line with Edit and Undo. */
export function RoutineEventLine({ block }: { block: RoutineEventBlock }) {
const tokens = useMobileTokens();
const { t } = useI18n();
const router = useRouter();
const [undoing, setUndoing] = useState(false);
const [failed, setFailed] = useState(false);
const oneShot = isOneShotRoutineCrons(block.crons);
const schedule = routineLineSchedule(block);
const muted = { color: tokens.mutedForeground, fontSize: 13.5 };
async function undo() {
setUndoing(true);
setFailed(false);
try {
await rpc("routines/remove", { routineId: block.routineId });
} catch {
setFailed(true);
} finally {
setUndoing(false);
}
}
return (
<View
style={{
width: "100%",
paddingVertical: 4,
flexDirection: "row",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "center",
columnGap: 6,
rowGap: 2,
}}
>
<View
accessible
accessibilityRole="image"
accessibilityLabel={oneShot ? t("Reminder") : t("Routine")}
>
<NativeSymbol
ios={oneShot ? "bell" : "repeat"}
android={oneShot ? "notifications-outline" : "repeat"}
size={14}
color={tokens.mutedForeground}
/>
</View>
<Text
style={[
muted,
block.removed
? { textDecorationLine: "line-through" }
: { color: tokens.foreground, fontWeight: "500" },
]}
>
{block.name}
</Text>
{schedule ? <Text style={muted}>· {schedule}</Text> : null}
{block.removed ? (
<Text style={muted}>· {t("Removed")}</Text>
) : (
<>
{block.event === "routine.updated" ? <Text style={muted}>· {t("Updated")}</Text> : null}
<Text style={muted}>·</Text>
<Pressable
accessibilityRole="button"
hitSlop={10}
onPress={() =>
router.push({
pathname: "/routine",
params: { botId: block.botId, routineId: block.routineId },
})
}
>
<Text style={[muted, { fontWeight: "600" }]}>{t("Edit")}</Text>
</Pressable>
<Text style={muted}>·</Text>
<Pressable
accessibilityRole="button"
accessibilityState={{ disabled: undoing }}
disabled={undoing}
hitSlop={10}
onPress={() => void undo()}
>
<Text style={[muted, { fontWeight: "600", opacity: undoing ? 0.5 : 1 }]}>
{failed ? t("Try again") : t("Undo")}
</Text>
</Pressable>
</>
)}
</View>
);
}

View File

@ -1,52 +0,0 @@
import { useState } from "react";
import { Pressable, Text, View } from "react-native";
import type { MobileMessage } from "../lib/api";
import { useI18n } from "../lib/i18n";
import { useMobileTokens } from "../lib/native";
import { NativeSymbol } from "./native-symbol";
/** A run's earlier step messages, folded behind one line above its latest message. */
export function RunSteps({ steps }: { steps: readonly MobileMessage[] }) {
const tokens = useMobileTokens();
const { t } = useI18n();
const [open, setOpen] = useState(false);
const label = steps.length === 1 ? t("1 step") : t("{count} steps", { count: steps.length });
return (
<View style={{ marginBottom: 6 }}>
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded: open }}
onPress={() => setOpen((current) => !current)}
hitSlop={8}
style={{ flexDirection: "row", alignItems: "center", gap: 4, alignSelf: "flex-start" }}
>
<NativeSymbol
ios={open ? "chevron.down" : "chevron.right"}
android={open ? "chevron-down" : "chevron-forward"}
size={12}
color={tokens.mutedForeground}
/>
<Text style={{ color: tokens.mutedForeground, fontSize: 13.5 }}>{label}</Text>
</Pressable>
{open ? (
<View
style={{
marginTop: 4,
gap: 6,
borderLeftWidth: 1,
borderLeftColor: tokens.border,
paddingLeft: 10,
}}
>
{steps.map((step) => (
<Text key={step.id} style={{ color: tokens.mutedForeground, fontSize: 14 }}>
{step.blocks
.flatMap((block) => (block.kind === "text" ? [block.text] : []))
.join("\n")}
</Text>
))}
</View>
) : null}
</View>
);
}

View File

@ -1,423 +0,0 @@
import type { CardAct, CardBlock, CardSection } from "@rakazo/contracts";
import { CARD_LIMITS } from "@rakazo/contracts";
import {
applyCardAct,
cardScale,
cardTimerRemaining,
checklistItemDisplay,
formatCardClock,
formatCardTimerLength,
suggestionStatus,
} from "@rakazo/core";
import { useState } from "react";
import { Alert, Pressable, StyleSheet, Text, View } from "react-native";
import { mobileTokens } from "../lib/appearance";
import { dateLocaleForUi, useI18n } from "../lib/i18n";
import { BotAvatar } from "./bot-avatar";
import { CookingMode, useNow } from "./cooking-mode";
import { NativeSymbol } from "./native-symbol";
type Section<T extends CardSection["type"]> = Extract<CardSection, { type: T }>;
/** A bot's interactive card. Taps go to the server, which owns the card's state. */
export function ThreadCard({
block: stored,
createdAt,
onAct,
}: {
block: CardBlock;
/** When the card was posted; an open suggestion expires after a while. */
createdAt?: string;
onAct?: (act: CardAct) => Promise<void>;
}) {
const tokens = mobileTokens();
const { t, locale } = useI18n();
const [pending, setPending] = useState<CardAct | null>(null);
const [expanded, setExpanded] = useState<string[]>([]);
const [cooking, setCooking] = useState(false);
// Show the tap in flight on top of the stored card, so the card answers at once.
const preview = pending ? applyCardAct(stored, pending) : null;
const block = preview?.ok ? preview.block : stored;
const suggestion = block.suggestion
? suggestionStatus(block, createdAt ?? new Date())
: undefined;
// A suggestion takes one tap; once it moved on, only its outcome shows.
const answered = Boolean(block.answer) || (suggestion !== undefined && suggestion !== "proposed");
const busy = !onAct || pending !== null;
const factor = cardScale(block);
const header = block.sections?.find((part): part is Section<"header"> => part.type === "header");
const steps = block.sections?.find((part): part is Section<"steps"> => part.type === "steps");
const now = useNow(Boolean(steps?.steps.some((step) => step.timerEndsAt)));
async function act(next: CardAct) {
if (!onAct || pending) return;
setPending(next);
try {
await onAct(next);
} catch (error) {
Alert.alert(
t("Could not submit answer"),
error instanceof Error ? error.message : t("Please try again."),
);
} finally {
setPending(null);
}
}
function visible<T>(id: string, entries: T[]): { shown: T[]; hidden: number } {
if (expanded.includes(id) || entries.length <= CARD_LIMITS.collapsedEntries + 1) {
return { shown: entries, hidden: 0 };
}
return {
shown: entries.slice(0, CARD_LIMITS.collapsedEntries),
hidden: entries.length - CARD_LIMITS.collapsedEntries,
};
}
function showMore(id: string, hidden: number) {
if (!hidden) return null;
return (
<Pressable
accessibilityRole="button"
onPress={() => setExpanded((ids) => [...ids, id])}
style={{ paddingVertical: 6 }}
>
<Text style={[styles.small, { color: tokens.mutedForeground }]}>
{t("Show {count} more", { count: hidden })}
</Text>
</Pressable>
);
}
const roundButton = (
label: string,
symbol: { ios: string; android: "remove" | "add" },
disabled: boolean,
onPress: () => void,
) => (
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
accessibilityState={{ disabled }}
disabled={disabled}
onPress={onPress}
style={[styles.round, { borderColor: tokens.border, opacity: disabled ? 0.4 : 1 }]}
>
<NativeSymbol ios={symbol.ios} android={symbol.android} size={16} />
</Pressable>
);
const select = block.sections?.find((part): part is Section<"select"> => part.type === "select");
const nothingChosen = Boolean(select?.multiple && !select.selected?.length);
return (
<View
testID="thread-card"
style={[styles.card, { borderColor: tokens.border, backgroundColor: tokens.card }]}
>
{block.sections?.map((part, index) => {
if (part.type === "header") {
return (
<View key={index} style={styles.header}>
{part.avatar ? <BotAvatar color={part.avatar} size={56} /> : null}
<View style={styles.headerText}>
<Text style={[styles.title, { color: tokens.foreground }]}>{part.title}</Text>
{part.subtitle ? (
<Text style={[styles.small, { color: tokens.mutedForeground }]}>
{part.subtitle}
</Text>
) : null}
{part.meta?.length ? (
<Text style={[styles.small, { color: tokens.mutedForeground }]}>
{part.meta.join(" · ")}
</Text>
) : null}
</View>
</View>
);
}
if (part.type === "facts") {
return (
<View key={index} style={{ gap: 6 }}>
{part.rows.map((row) => (
<View key={row.label} style={styles.factRow}>
<Text style={[styles.body, { color: tokens.mutedForeground }]}>{row.label}</Text>
<Text style={[styles.body, styles.factValue, { color: tokens.foreground }]}>
{row.value}
</Text>
</View>
))}
</View>
);
}
if (part.type === "stepper") {
const label = part.label;
return (
<View key={index} style={styles.stepperRow}>
<Text style={[styles.body, { color: tokens.mutedForeground }]}>{label}</Text>
<View style={styles.stepperControls}>
{roundButton(
t("Fewer {label}", { label }),
{ ios: "minus", android: "remove" },
busy || part.value <= 1,
() => void act({ type: "stepper", sectionId: part.id, value: part.value - 1 }),
)}
<Text
testID="card-stepper-value"
style={[styles.body, styles.stepperValue, { color: tokens.foreground }]}
>
{part.value}
</Text>
{roundButton(
t("More {label}", { label }),
{ ios: "plus", android: "add" },
busy || part.value >= CARD_LIMITS.stepperMax,
() => void act({ type: "stepper", sectionId: part.id, value: part.value + 1 }),
)}
</View>
</View>
);
}
if (part.type === "steps") {
const { shown, hidden } = visible(part.id, part.steps);
return (
<View key={index} style={{ gap: 10 }}>
{shown.map((step, position) => {
const remaining = cardTimerRemaining(step.timerEndsAt, now);
return (
<View key={step.id} style={{ flexDirection: "row", gap: 10 }}>
<Text
style={[styles.body, styles.stepNumber, { color: tokens.mutedForeground }]}
>
{position + 1}
</Text>
<View style={{ flex: 1, gap: 2 }}>
<Text style={[styles.body, { color: tokens.foreground }]}>{step.text}</Text>
{step.timerSeconds ? (
<Text style={[styles.small, { color: tokens.mutedForeground }]}>
{remaining !== null
? formatCardClock(remaining)
: formatCardTimerLength(step.timerSeconds, dateLocaleForUi(locale))}
</Text>
) : null}
</View>
</View>
);
})}
{showMore(part.id, hidden)}
</View>
);
}
if (part.type === "checklist") {
const { shown, hidden } = visible(part.id, part.items);
return (
<View key={index}>
{shown.map((item) => {
const checked = Boolean(item.checked);
const display = checklistItemDisplay(item, factor);
return (
<Pressable
key={item.id}
accessibilityRole="checkbox"
accessibilityState={{ checked, disabled: busy }}
disabled={busy}
onPress={() =>
void act({
type: "toggle",
sectionId: part.id,
itemId: item.id,
checked: !checked,
})
}
style={styles.checkRow}
>
<NativeSymbol
ios={checked ? "checkmark.circle.fill" : "circle"}
android={checked ? "checkmark-circle" : "ellipse-outline"}
size={20}
color={checked ? tokens.primary : tokens.mutedForeground}
/>
<Text
style={[
styles.body,
{
flexShrink: 1,
color: checked ? tokens.mutedForeground : tokens.foreground,
},
checked && { textDecorationLine: "line-through" },
]}
>
{display.amount ? (
<Text style={{ fontWeight: "600" }}>{display.amount} </Text>
) : null}
{display.label}
</Text>
</Pressable>
);
})}
{showMore(part.id, hidden)}
</View>
);
}
if (part.type === "select") {
return (
<View key={index} style={part.multiple ? styles.chips : { gap: 6 }}>
{part.options.map((option) => {
const chosen = part.selected?.includes(option.id) ?? false;
return (
<Pressable
key={option.id}
accessibilityRole="button"
accessibilityState={{ selected: chosen, disabled: busy || answered }}
disabled={busy || answered}
onPress={() =>
void act({
type: "select",
sectionId: part.id,
optionId: option.id,
...(part.multiple ? { selected: !chosen } : {}),
})
}
style={[
styles.option,
part.multiple ? null : { alignSelf: "stretch" },
{
borderColor: chosen ? tokens.primary : tokens.border,
backgroundColor: chosen ? tokens.primary : "transparent",
opacity: answered && !chosen ? 0.5 : 1,
},
]}
>
<Text
style={[
styles.body,
{ color: chosen ? tokens.primaryForeground : tokens.foreground },
]}
>
{option.label}
</Text>
</Pressable>
);
})}
</View>
);
}
const actions = part.actions.filter((action) => action.kind === "steps" || !answered);
if (!actions.length) return null;
return (
<View key={index} style={styles.chips}>
{actions.map((action, position) => {
const primary = position === 0;
const opensSteps = action.kind === "steps";
const disabled = opensSteps ? !steps : busy || nothingChosen;
return (
<Pressable
key={action.id}
accessibilityRole="button"
accessibilityState={{ disabled }}
disabled={disabled}
onPress={() =>
opensSteps
? setCooking(true)
: void act({ type: "action", actionId: action.id })
}
style={[
styles.option,
{
borderColor: primary ? tokens.primary : tokens.border,
backgroundColor: primary ? tokens.primary : "transparent",
opacity: disabled ? 0.5 : 1,
},
]}
>
<Text
style={[
styles.body,
{
fontWeight: "600",
color: primary ? tokens.primaryForeground : tokens.foreground,
},
]}
>
{action.kind === "accept"
? t("Accept")
: action.kind === "dismiss"
? t("Dismiss")
: action.label}
</Text>
</Pressable>
);
})}
</View>
);
})}
{suggestion && suggestion !== "proposed" ? (
<Text
style={[
styles.small,
{
color:
suggestion === "failed"
? tokens.destructive
: suggestion === "done"
? tokens.success
: tokens.mutedForeground,
},
]}
>
{suggestion === "failed"
? (block.suggestion?.error ?? t("Failed"))
: suggestion === "running"
? t("Running")
: suggestion === "done"
? t("Done")
: suggestion === "dismissed"
? t("Dismissed")
: t("Expired")}
</Text>
) : null}
{cooking && steps ? (
<CookingMode
title={header?.title ?? ""}
steps={steps}
now={now}
busy={busy}
onAct={act}
onClose={() => setCooking(false)}
/>
) : null}
</View>
);
}
const styles = StyleSheet.create({
card: {
width: "90%",
gap: 14,
borderRadius: 18,
borderWidth: 1,
paddingHorizontal: 16,
paddingVertical: 14,
},
header: { flexDirection: "row", alignItems: "center", gap: 12 },
headerText: { flex: 1, gap: 2 },
title: { fontSize: 16, fontWeight: "600" },
body: { fontSize: 15.5, lineHeight: 22 },
small: { fontSize: 13.5 },
factRow: { flexDirection: "row", gap: 12 },
factValue: { flex: 1 },
checkRow: { flexDirection: "row", alignItems: "center", gap: 10, paddingVertical: 6 },
chips: { flexDirection: "row", flexWrap: "wrap", gap: 6 },
option: { borderRadius: 12, borderWidth: 1, paddingHorizontal: 14, paddingVertical: 10 },
stepperRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" },
stepperControls: { flexDirection: "row", alignItems: "center", gap: 8 },
stepperValue: { minWidth: 28, textAlign: "center", fontWeight: "600" },
round: {
width: 34,
height: 34,
borderRadius: 17,
borderWidth: 1,
alignItems: "center",
justifyContent: "center",
},
stepNumber: { width: 16, textAlign: "right" },
});

View File

@ -144,16 +144,6 @@ export function selectedSpaceId(): string | null {
return cachedSpaceId || null;
}
/**
* Agents live in the home library, where a project's coordinator recruits them from; select it
* before creating one so the new agent and its chat share the request scope.
*/
export async function selectLibrarySpace(): Promise<void> {
const navigation = await rpc<MobileSpaceNavigation>("spaces/list");
const home = navigation.spaces.find((space) => space.isDefault)?.id;
if (home && home !== cachedSpaceId) await selectSpace(home);
}
/** Keep requests usable after the server deleted the selected Space but native
* storage could not replace it. Neutralize any same-endpoint rollback before
* writing the replacement selection so a cleanup failure cannot leave the new
@ -750,7 +740,6 @@ export type MobileMe = Pick<
| "defaultProvider"
| "defaultModel"
| "needsModel"
| "modelsManaged"
| "avatarStyle"
| "isDeploymentOwner"
>;
@ -784,7 +773,7 @@ export type MobileGroup = Pick<
| "updatedAt"
| "members"
> &
Partial<Pick<Group, "spaceId" | "kind">>;
Partial<Pick<Group, "spaceId">>;
export type MobileSpace = Space;
export type MobileSpaceNavigation = SpaceNavigation;
@ -797,14 +786,8 @@ export type MobileSnapshot = {
cursor?: number;
messages: MobileMessage[];
olderCursor: number | null;
run: {
id: string;
botId?: string;
status: string;
error?: string | null;
createdAt?: string;
} | null;
activeRuns?: Array<{ id: string; botId?: string; status: string; createdAt?: string }>;
run: { id: string; botId?: string; status: string; error?: string | null } | null;
activeRuns?: Array<{ id: string; botId?: string; status: string }>;
members?: MobileGroup["members"];
computer?: {
kind?: string;
@ -918,14 +901,6 @@ export function blockText(message: MobileMessage) {
return answer ? `${block.question}\n${answer}` : block.question;
}
if (block.kind === "chart") return `[chart: ${block.name ?? "chart"}]`;
if (block.kind === "event") return block.name;
if (block.kind === "autopilot") return block.text ?? block.goal;
if (block.kind === "card") {
const header = block.sections?.find((part) => part.type === "header");
return header?.type === "header"
? header.title
: (block.lines ?? []).map((line) => `${line.k}: ${line.v}`).join("\n");
}
if (block.kind === "image") return `[image: ${block.name ?? "attachment"}]`;
if (block.kind === "file") {
return `[file: ${block.name ?? "attachment"}${block.size ? ` (${block.size} bytes)` : ""}]`;

View File

@ -60,12 +60,6 @@ export function mobileTokens(
return tokensForAppearance(resolveMobileAppearance(preference, scheme));
}
/** Header and inbox nav glyphs — aligned with web `--icon-*` scale (~25% larger). */
export const mobileShellIconSizes = {
md: 22,
lg: 26,
} as const;
export function subscribeAppearance(listener: () => void): () => void {
listeners.add(listener);
return () => {

View File

@ -1,50 +0,0 @@
import type { AutopilotRecent } from "@rakazo/contracts";
import { describe, expect, it, vi } from "vitest";
import { autopilotSummary, autopilotTimeOfDay } from "./autopilot";
vi.mock("./i18n", () => ({
t: (message: string, values?: Record<string, string | number>) =>
message.replace(/\{(\w+)\}/g, (_, key: string) => String(values?.[key] ?? "")),
}));
function autopilot(patch: Partial<AutopilotRecent> = {}): AutopilotRecent {
return {
id: "autopilot-1",
botId: "bot-1",
threadId: "thread-1",
spaceId: "space-1",
groupId: null,
goal: "Tidy the tickets",
focus: null,
until: null,
status: "active",
pauseReason: null,
lastCheckpoint: null,
nextStep: null,
result: null,
stepCount: 0,
startedAt: "2026-10-05T00:00:00.000Z",
endedAt: null,
...patch,
};
}
describe("autopilot on mobile", () => {
it("hands the picked time to autopilotUntil as HH:MM", () => {
expect(autopilotTimeOfDay(new Date(2026, 9, 5, 6, 5))).toBe("06:05");
expect(autopilotTimeOfDay(new Date(2026, 9, 5, 23, 30))).toBe("23:30");
});
it("sums up the night like Today on web", () => {
expect(
autopilotSummary(autopilot({ stepCount: 2, lastCheckpoint: "Ticket 2 tidied" })),
).toEqual({ detail: "last: Ticket 2 tidied", trail: "2 steps" });
expect(
autopilotSummary(autopilot({ status: "finished", stepCount: 1, result: "All tidy." })),
).toEqual({ detail: "All tidy.", trail: "1 step" });
expect(
autopilotSummary(autopilot({ status: "paused", stepCount: 3, pauseReason: "Need a key." })),
).toEqual({ detail: "Need a key.", trail: "Paused" });
expect(autopilotSummary(autopilot())).toEqual({ detail: "", trail: "0 steps" });
});
});

View File

@ -1,30 +0,0 @@
import type { AutopilotRecent } from "@rakazo/contracts";
import { t } from "./i18n";
/** "HH:MM" for autopilotUntil's time choice. */
export function autopilotTimeOfDay(date: Date): string {
const pad = (value: number) => String(value).padStart(2, "0");
return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
/**
* One autopilot in the home list, like Today on web: how it ended or where it stands, and its step
* count, or Paused when it waits on the user.
*/
export function autopilotSummary(autopilot: AutopilotRecent): { detail: string; trail: string } {
const detail =
autopilot.status === "finished"
? autopilot.result
: autopilot.status === "paused"
? autopilot.pauseReason
: autopilot.lastCheckpoint
? t("last: {latest}", { latest: autopilot.lastCheckpoint })
: null;
const trail =
autopilot.status === "paused"
? t("Paused")
: autopilot.stepCount === 1
? t("1 step")
: t("{count} steps", { count: autopilot.stepCount });
return { detail: detail ?? "", trail };
}

View File

@ -0,0 +1,16 @@
import { workingAvatarDuration, workingAvatarFrame } from "@rakazo/core";
import { describe, expect, it } from "vitest";
describe("working avatar motion", () => {
it("reuses shared core choreography", () => {
const start = workingAvatarFrame(0, 0);
const end = workingAvatarFrame(0, 1);
expect(end.translationY).toBeCloseTo(start.translationY);
expect(end.scaleX).toBeCloseTo(start.scaleX);
expect(end.eyeOffsetX).toBeCloseTo(start.eyeOffsetX);
expect(end.eyeOffsetY).toBeCloseTo(start.eyeOffsetY);
expect(workingAvatarFrame(0, 0.5)).not.toEqual(workingAvatarFrame(0, 0));
expect(workingAvatarFrame(2, 0.5)).not.toEqual(workingAvatarFrame(0, 0.5));
expect(workingAvatarDuration(6)).toBe(1100);
});
});

View File

@ -0,0 +1,10 @@
/**
* Re-export shared choreography for Reanimated worklets.
* Source of truth: `@rakazo/core` (`avatar-motion.ts`), mirrored by web CSS.
*/
export {
WORKING_AVATAR_DURATIONS_MS,
type WorkingAvatarFrame,
workingAvatarDuration,
workingAvatarFrame,
} from "@rakazo/core";

View File

@ -79,7 +79,7 @@ export async function probeApiBase(
);
if (!res.ok) {
cancelResponseBody(res);
return { ok: false, error: t("That URL did not look like an evOo server") };
return { ok: false, error: t("That URL did not look like a Rakazo server") };
}
let body: HealthResponse;
try {
@ -93,7 +93,7 @@ export async function probeApiBase(
body = {};
}
if (body.error || body.json?.ok !== true) {
return { ok: false, error: t("That URL did not look like an evOo server") };
return { ok: false, error: t("That URL did not look like a Rakazo server") };
}
return parsed;
} catch {

View File

@ -51,7 +51,7 @@ describe("mobile i18n", () => {
const { resetI18nForTests, t } = await import("./i18n");
resetI18nForTests("zh-CN");
expect(t("Account")).toBe("账户");
expect(t("Sign in to evOo")).toBe("登录 evOo");
expect(t("Sign in to Rakazo")).toBe("登录 Rakazo");
expect(t("New bot")).toBe("新建 Bot");
expect(t("{runs} runs · {tokens} tokens", { runs: 3, tokens: 12 })).toBe(
"3 次运行 · 12 个 token",
@ -60,26 +60,18 @@ describe("mobile i18n", () => {
resetI18nForTests("ru");
expect(t("Account")).toBe("Аккаунт");
expect(t("Sign in to evOo")).toBe("Войти в evOo");
expect(t("Sign in to Rakazo")).toBe("Войти в Rakazo");
expect(t("New bot")).toBe("Новый бот");
expect(t("{runs} runs · {tokens} tokens", { runs: 3, tokens: 12 })).toBe(
"Запусков: 3 · токенов: 12",
);
expect(t("Delete {name}?", { name: "Scout" })).toBe("Удалить Scout?");
resetI18nForTests("de");
expect(t("Account")).toBe("Konto");
expect(t("Sign in to evOo")).toBe("Bei evOo anmelden");
expect(t("New bot")).toBe("Neuer Bot");
expect(t("{runs} runs · {tokens} tokens", { runs: 3, tokens: 12 })).toBe("3 Läufe · 12 Tokens");
expect(t("Delete {name}?", { name: "Scout" })).toBe("Scout löschen?");
});
it("preserves interpolations in every non-English catalog", async () => {
it("preserves interpolations in the Chinese and Russian catalogs", async () => {
const { ZH_MESSAGES } = await import("./locales/zh");
const { RU_MESSAGES } = await import("./locales/ru");
const { DE_MESSAGES } = await import("./locales/de");
for (const messages of [ZH_MESSAGES, RU_MESSAGES, DE_MESSAGES]) {
for (const messages of [ZH_MESSAGES, RU_MESSAGES]) {
const empty = Object.entries(messages).filter(([, value]) => !value.trim());
const interpolationMismatches = Object.entries(messages).filter(([id, value]) => {
const tokens = (message: string) =>
@ -91,10 +83,9 @@ describe("mobile i18n", () => {
}
});
it("translates every mobile chrome t() id in every non-English catalog", async () => {
it("translates every mobile chrome t() id in both non-English catalogs", async () => {
const { ZH_MESSAGES } = await import("./locales/zh");
const { RU_MESSAGES } = await import("./locales/ru");
const { DE_MESSAGES } = await import("./locales/de");
const { EMPTY_PLUGIN_CATALOG_MESSAGE, SLASH_ACTIONS } = await import("@rakazo/core");
const { OPENAI_COMPATIBLE_BASE_URL_HINT } = await import("@rakazo/contracts");
const mobileRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
@ -113,7 +104,7 @@ describe("mobile i18n", () => {
ids.add(JSON.parse(`"${match[1]}"`) as string);
}
}
for (const messages of [ZH_MESSAGES, RU_MESSAGES, DE_MESSAGES]) {
for (const messages of [ZH_MESSAGES, RU_MESSAGES]) {
const missing = [...ids].filter((id) => !messages[id]?.trim()).sort();
expect(missing).toEqual([]);
}
@ -148,17 +139,6 @@ describe("mobile i18n", () => {
expect(dateLocaleForUi()).toBe("ru");
});
it("normalizes a regional German stored locale before activation", async () => {
const { getItemAsync } = await import("expo-secure-store");
vi.mocked(getItemAsync).mockResolvedValue("de-AT");
const { bootstrapI18n, dateLocaleForUi, getActiveUiLocale, t } = await import("./i18n");
await expect(bootstrapI18n()).resolves.toBe("de");
expect(getActiveUiLocale()).toBe("de");
expect(t("Language")).toBe("Sprache");
expect(dateLocaleForUi()).toBe("de");
});
it("keeps the last locale when rapid setUiLocale calls finish out of order", async () => {
const { setItemAsync } = await import("expo-secure-store");
const { applyMobileUiDirection } = await import("./ui-direction");

View File

@ -1,5 +1,4 @@
import { useSyncExternalStore } from "react";
import { DE_MESSAGES } from "./locales/de";
import { RU_MESSAGES } from "./locales/ru";
import { ZH_MESSAGES } from "./locales/zh";
import {
@ -10,7 +9,6 @@ import {
} from "./ui-locale";
const catalogs: Partial<Record<UiLocale, Record<string, string>>> = {
de: DE_MESSAGES,
"zh-CN": ZH_MESSAGES,
ru: RU_MESSAGES,
};
@ -67,7 +65,7 @@ export function activateUiLocale(locale: UiLocale): UiLocale {
async function applyDirection(locale: UiLocale): Promise<void> {
try {
// Direction follows the active UI locale only (en / de / zh-CN / ru → LTR), never the
// Direction follows the active UI locale only (en / zh-CN / ru → LTR), never the
// device language. Applying device RTL here would fight this and reload-loop.
const { applyMobileUiDirection } = await import("./ui-direction");
applyMobileUiDirection(htmlLangForLocale(locale));

View File

@ -147,18 +147,12 @@ describe("spaceInboxItems", () => {
expect(canDeleteInboxSpace(space({ canDelete }))).toBe(false);
});
it("offers deletion for an explicitly authorized non-default space", () => {
it("offers deletion for an explicitly authorized empty non-default space", () => {
expect(canDeleteInboxSpace(space())).toBe(true);
expect(canDeleteInboxSpace(space({ hasContent: true, canDelete: true }))).toBe(true);
});
it("deduplicates same-named projects into a single project entry", () => {
const first = space({ id: "space-work-1", name: "Work", hasContent: false });
const second = space({ id: "space-work-2", name: " work ", hasContent: true, bots: [bot] });
const items = spaceInboxItems([first, second]);
const headings = items.filter((item) => item.type === "heading" && item.space);
expect(headings).toHaveLength(1);
expect(headings[0]).toMatchObject({ key: "space-work-2", title: " work " });
it("hides deletion while a space still has content", () => {
expect(canDeleteInboxSpace(space({ hasContent: true }))).toBe(false);
});
});

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