diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index f6b07c8b37..d81c036e62 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -169,6 +169,34 @@ jobs: NODE_OPTIONS: --max-old-space-size=${{ matrix.node_heap_mb }} run: | set -euo pipefail + export PASEO_REAL_XCRUN="$(command -v xcrun)" + stapler_bin="$(mktemp -d)" + trap 'rm -rf "$stapler_bin"' EXIT + cat > "$stapler_bin/xcrun" <<'STAPLER' + #!/bin/bash + if [[ "${1:-}" != stapler || "${2:-}" != staple ]]; then + exec "$PASEO_REAL_XCRUN" "$@" + fi + output="$(mktemp)" + trap 'rm -f "$output"' EXIT + for attempt in 1 2 3 4; do + if "$PASEO_REAL_XCRUN" "$@" >"$output" 2>&1; then + cat "$output" + exit 0 + else + status=$? + fi + cat "$output" + if [[ "$status" != 68 || "$attempt" == 4 ]] || + ! grep -Fq 'NSURLErrorDomain Code=-1005' "$output"; then + exit "$status" + fi + echo "::warning::Apple staple connection lost; retrying the same signed app ($attempt/4)" >&2 + sleep 30 + done + STAPLER + chmod +x "$stapler_bin/xcrun" + export PATH="$stapler_bin:$PATH" build_args=(-- --publish never --mac --${{ matrix.electron_arch }}) build_args+=("-c.publish.releaseType=$RELEASE_TYPE") build_args+=("-c.publish.channel=$RELEASE_CHANNEL") diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d48d9c289..98d1c76900 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## 0.13.2 - 2026-10-26 + +### Fixed + +- Fixed repeated Android animation exceptions for views that have already unmounted ([#7907](https://github.com/getpaseo/paseo/pull/7907)) +- Fixed the Android animation run loop continuing while the app is in the background ([#7914](https://github.com/getpaseo/paseo/pull/7914)) +- Fixed finished Android chat messages being remeasured during streaming updates ([#7914](https://github.com/getpaseo/paseo/pull/7914)) +- Fixed bottom sheets retaining window-size listeners after closing ([#7914](https://github.com/getpaseo/paseo/pull/7914)) + +## 0.13.1 - 2026-10-26 + +### Fixed + +- Fixed a startup crash with Show background enabled and eight or more workspaces in a project ([#7896](https://github.com/getpaseo/paseo/pull/7896) by [@budivoogt](https://github.com/budivoogt)) + ## 0.13.0 - 2026-10-24 Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations. diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index 09080be3bc..421da176dd 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -1,5 +1,20 @@ # 更新日志 +## 0.13.2 - 2026-10-26 + +### 修复 + +- 修复 Android 对已卸载视图反复抛出动画异常的问题 ([#7907](https://github.com/getpaseo/paseo/pull/7907)) +- 修复 Android 应用进入后台后动画运行循环仍持续执行的问题 ([#7914](https://github.com/getpaseo/paseo/pull/7914)) +- 修复 Android 在流式更新时反复测量已完成聊天消息的问题 ([#7914](https://github.com/getpaseo/paseo/pull/7914)) +- 修复底部面板关闭后仍保留窗口尺寸监听器的问题 ([#7914](https://github.com/getpaseo/paseo/pull/7914)) + +## 0.13.1 - 2026-10-26 + +### 修复 + +- 修复开启“显示后台工作区”且项目包含至少八个工作区时的启动崩溃([#7896](https://github.com/getpaseo/paseo/pull/7896),由 [@budivoogt](https://github.com/budivoogt) 贡献) + ## 0.13.0 - 2026-10-24 折叠屏手机布局、可将工作委派给其他机器的 Agent、插件文件面板,以及插件翻译。 diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 535a6dd712..719b0b5468 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -25,8 +25,54 @@ Reload releases the old runtime before resuming its durable session: an idle pro still own an exclusive writer. A close failure retains that runtime for cleanup and blocks the replacement. Once closure succeeds, a failed resume leaves the durable agent closed and retryable. -Idle agents remain resident indefinitely. Runtime closure happens only through an explicit lifecycle -action such as archive, replacement, reload, workspace teardown, or daemon shutdown. +Idle resumable runtimes are collected after one hour without activity, checked once a minute. +Collection releases the process and preserves the agent record, conversation, relationships, and +provider session. The next prompt resumes that session. Set `agents.idleRuntimeCleanup: false` in +host config to disable automatic collection. The host setting applies immediately; config-file edits +apply on reload. Disabling invalidates pending safety checks and close admission. A close already +started finishes. Re-enabling keeps the existing idle timestamps. + +Foreground runs, permissions and responses, queued deliveries, lifecycle operations, and working +managed or native descendants prevent collection. Outstanding local or remote finish subscriptions +also protect their caller, including registration, recovery and notification dispatch acknowledgment. +An idle managed child can still own background work. + +The daemon baseline always applies. An optional provider `canStopRuntime()` adds a veto: false, +errors and timeouts retain ownership, while true cannot override daemon blockers. Without a provider +check, the baseline decides collection after the same one-hour idle period. Once protected work +finishes, a fresh idle hour starts. There is no deadline that overrides a veto. + +The `idle-runtime-cleanup/` module owns the clocks, eligibility policy and sweep lifetime. +AgentManager supplies captured runtime capabilities and owns atomic revalidation and shutdown. +Finish subscriptions expose caller protection through their public capability; the collector never +reads their watches or durable records. + +The collector rechecks daemon state after asynchronous probes and event draining, in the existing +close/load lifecycle lane. A prompt admitted before closure prevents collection. A prompt arriving +after closure starts waits for release and resumes the saved session. + +Provider checks follow the signals their runtimes expose: + +- Claude uses its process-local background roster and pending completion notifications. A fresh + process starts empty. CLI 2.1.292 or newer is known to support the roster; older binaries must + demonstrate support by emitting one. Unknown versions stay resident until then. +- Codex queries background terminals for loaded threads, including native children, and protects + pending provider requests. App servers without the terminal-list RPC stay resident. +- OpenCode checks active executions and descendants. V1 checks PTYs; v2 also checks shell and + inbox inventories. The shared server remains leased while a session vetoes collection. +- Pi checks streaming, compaction, pending replies, and adapter-owned operations. Known native + subagent signals also protect their parent through the daemon's subagent store. +- OMP additionally requires `get_state.isSettled: true` and `hasPendingAsyncWork: false`. + These protect jobs with unsuppressed completion delivery, pending deliveries, queued result + injection, and admitted or scheduled continuations that can re-wake the session. Older OMP + runtimes without either field stay resident. These signals are not an inventory of arbitrary + processes. +- ACP checks adapter-owned foreground turns, permissions, buffered user messages, and running + ACP terminals, including quiet terminals. Exited or failed-to-spawn terminal records do not + prevent collection. This does not inspect processes launched internally by the provider. + +These checks do not discover arbitrary detached processes or work hidden by third-party +extensions. A provider plugin adds its own veto through the [runtime safety contract](../public-docs/plugins/providers.md#permit-idle-runtime-cleanup). A provider runtime can still die on its own — crash, OOM kill, host suspend. Work the agent parked inside that process dies with it: Claude Code's background Bash shells, `Monitor` watches, and @@ -242,7 +288,7 @@ parentAgentId === thisAgent.id AND !archivedAt - **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track. A descriptor's optional `parentSubagentId` identifies its direct provider-subagent parent; an absent value identifies a direct child of the managed agent. -Clicking either kind opens a workspace tab. On compact layouts it opens in a [sheet](explorer-sidebar.md#sheets) over the subagents list instead; the sheet hides the composer and shows the agent's model and thinking as one line in its place (`PaneSubtitle`, the row provider subagents already use), and a Paseo subagent's sheet offers **Open as tab** (the full-screen glyph in the header) to get the composer back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical. +Clicking either kind opens a workspace tab. On compact layouts it opens in a [sheet](explorer-sidebar.md#sheets) over the subagents list instead; the sheet hides the composer and shows the agent's model and thinking as one line in its place (`PaneSubtitle`, the row provider subagents already use), and a Paseo subagent's sheet offers **Open as tab** (the full-screen glyph in the header) to get the composer back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Every subagent pane links back to its parent (`packages/app/src/subagents/parent/`): a pill above the composer when there is one, a parent box in the composer's place when there is not. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical. Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only. diff --git a/docs/data-model.md b/docs/data-model.md index 8bed344aa1..dc65096b66 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -221,12 +221,6 @@ snapshot so a mixed edit can apply its live subset and still name the paths that } }, providers: { - openai: { - apiKey?: string, - baseUrl?: string, - stt?: { apiKey?: string, baseUrl?: string }, - tts?: { apiKey?: string, baseUrl?: string } - }, local: { modelsDir: string } }, agents: { @@ -244,8 +238,8 @@ snapshot so a mixed edit can apply its live subset and still name the paths that pluginsEnabled: boolean, plugins: Record, features: { - dictation: { enabled, stt: { provider, model, language, confidenceThreshold } }, - voiceMode: { enabled, llm, stt: { provider, model, language }, turnDetection, tts: { provider, model, voice, speakerId, speed } } + dictation: { enabled, stt: SpeechSttConfig }, + voiceMode: { enabled, llm, stt: SpeechSttConfig, turnDetection, tts: SpeechTtsConfig } }, log: { level, format, @@ -353,42 +347,27 @@ Environment variables override `config.json`: after changing `config.json`. Environment changes require a daemon restart; the launch environment remains authoritative during reload. -Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-int8`, TTS uses `kokoro-en-v0_19`, and turn detection uses the bundled Silero VAD model. +### Speech configuration -Set these to select OpenAI instead of local speech: +Each use site owns its provider settings: `features.dictation.stt`, +`features.voiceMode.stt`, and `features.voiceMode.tts`. `provider` selects a strict +`local` or `openai` variant. Both STT variants carry `language`; normalization preserves +it regardless of provider. `providers.local.modelsDir` remains shared storage. +See [Voice configuration](../public-docs/voice.md) for fields, examples and env precedence. -| Env var | Applies to | -| ------------------------------ | ------------------------------- | -| `PASEO_VOICE_STT_PROVIDER` | Voice mode STT provider | -| `PASEO_DICTATION_STT_PROVIDER` | Composer dictation STT provider | -| `PASEO_VOICE_TTS_PROVIDER` | Voice mode TTS provider | +On load, `persisted-config.ts` folds legacy `providers.openai` credentials and URLs +into OpenAI sites that lack their own values, using endpoint settings before shared +settings. A provider-less site becomes local; provider names are lower-cased, and +fields ignored by the selected speech provider are discarded. The next save writes only the +inline shape. A legacy key with no site to receive it is discarded with a warning +that names its path, without logging its value. -OpenAI speech can be configured under `providers.openai`. STT and TTS resolve independently, so they can point at different endpoints: - -```json -{ - "providers": { - "openai": { - "stt": { - "apiKey": "sk-...", - "baseUrl": "https://stt.example.com/v1" - }, - "tts": { - "apiKey": "sk-...", - "baseUrl": "https://api.openai.com/v1" - } - } - } -} -``` - -`providers.openai.stt` is used for both composer dictation and voice mode speech-to-text; `providers.openai.tts` is used for voice mode text-to-speech. The equivalent env vars are `OPENAI_STT_API_KEY`/`OPENAI_STT_BASE_URL` and `OPENAI_TTS_API_KEY`/`OPENAI_TTS_BASE_URL`. Each feature falls back to `providers.openai.apiKey`/`providers.openai.baseUrl`, then `OPENAI_API_KEY`/`OPENAI_BASE_URL`, when its own fields are unset. These settings apply only to Paseo OpenAI speech features, not to Codex or other OpenAI-backed tools. - -Paseo uses these paths under the configured OpenAI base URL: - -- dictation STT: `/v1/audio/transcriptions` -- voice mode STT: `/v1/audio/transcriptions` -- voice mode TTS: `/v1/audio/speech` +`speech-settings.ts` owns mutable config reads and patches. The wire uses the same +`features` structure. Reads replace OpenAI keys with `hasApiKey` (saved in config) +and report effective env sources in `envOverrides`. A site patch that changes +`provider` replaces the entire object. The same or omitted `provider` merges fields +into the current variant, preserving the write-only key when a form re-saves it. `null` clears a value. Switching providers discards credentials and +options from the previous variant. --- diff --git a/docs/design.md b/docs/design.md index ce45577b69..fe60cdc53b 100644 --- a/docs/design.md +++ b/docs/design.md @@ -70,7 +70,7 @@ the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control als Sizes: `xs` for ultra-tight inline triggers. `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs. -Sizes are a shared contract across control kinds, defined once in `control-geometry.ts`: `xs` = 28px tall with `fontSize.sm` labels, `sm` = 32px with `fontSize.base`, `md`/`lg` = 44px with `fontSize.base`. `` (`packages/app/src/components/ui/segmented-control.tsx`) takes the same `xs`/`sm`/`md` sizes — a segmented control next to a ` + + ) : null} + {showEmptyState ? : null} + + ), + [showLoadMore, handleLoadMore, isRefreshing, t, showEmptyState, emptyStateTitle], + ); return ( - - {showFilter ? ( - - - {selectedProvider === ALL_FILTER_VALUE ? ( - - ) : ( - - )} - - {selectedProviderLabel} - - - - - - ) : null} - 0} - importErrored={importMutation.isError} - /> - {providerErrorRows.length > 0 ? ( - - ) : null} - {visibleEntries.length > 0 ? ( - - ) : null} - {showLoadMore ? ( - - - - ) : null} - {showEmptyState ? : null} - + listHeader={listHeader} + listFooter={listFooter} + serverId={serverId} + entries={visibleEntries} + disabled={importMutation.isPending} + importingSessionKey={importingSessionKey} + resolveFolder={resolveFolder} + onImportSession={handleImportSession} + /> ); } @@ -853,9 +973,31 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.medium, }, + listBody: { + gap: 0, + }, list: { + flex: 1, + minHeight: 0, + marginHorizontal: -theme.spacing[2], + }, + listHeader: { + marginHorizontal: theme.spacing[2], + paddingBottom: theme.spacing[4], + }, + listHeaderContent: { + gap: theme.spacing[4], + }, + smallList: { gap: theme.spacing[1], }, + listFooter: { + marginHorizontal: theme.spacing[2], + }, + listRow: { + marginHorizontal: theme.spacing[2], + paddingBottom: theme.spacing[1], + }, footer: { paddingTop: theme.spacing[2], alignItems: "center", diff --git a/packages/app/src/components/indent-rail.tsx b/packages/app/src/components/indent-rail.tsx new file mode 100644 index 0000000000..b54ac1ca9b --- /dev/null +++ b/packages/app/src/components/indent-rail.tsx @@ -0,0 +1,32 @@ +import type { ReactNode } from "react"; +import { View, type StyleProp, type ViewStyle } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; + +/** + * Collapsed chat rows badge their summary with an icon whose center sits 7px into the content + * column (`ExpandableBadge` bleeds 13px left, then 1px border, 8px padding, 22px icon). The rail + * hangs from that center so opened rows read as children of the line that opened them. + */ +const RAIL_LEFT = 7; +const RAIL_WIDTH = 1; +export const INDENT_RAIL_CONTENT_OFFSET = 21; + +/** Indents opened chat rows under the summary that collapsed them, behind a vertical line. */ +export function IndentRail({ + children, + style, +}: { + children?: ReactNode; + style?: StyleProp; +}) { + return {children}; +} + +const styles = StyleSheet.create((theme) => ({ + rail: { + marginLeft: RAIL_LEFT, + paddingLeft: INDENT_RAIL_CONTENT_OFFSET - RAIL_LEFT - RAIL_WIDTH, + borderLeftWidth: RAIL_WIDTH, + borderLeftColor: theme.colors.border, + }, +})); diff --git a/packages/app/src/components/markdown/renderer.test.ts b/packages/app/src/components/markdown/renderer.test.ts index 95590470c6..bb5679f6f8 100644 --- a/packages/app/src/components/markdown/renderer.test.ts +++ b/packages/app/src/components/markdown/renderer.test.ts @@ -804,3 +804,18 @@ it("makes the web Markdown container selectable in Safari as well as unprefixed WebkitUserSelect: "text", }); }); + +it.each([false, true])( + "scales inline code from theme tokens with its surrounding block (compact: %s)", + (compact) => { + const theme = { + ...darkTheme, + fontSize: { ...darkTheme.fontSize, code: 13, content: 17, base: 16 }, + }; + const { markdownStyle } = createMarkdownStyles(theme, { compact, blockGap: 16 }); + const bodySize = compact ? theme.fontSize.base : theme.fontSize.content; + expect(markdownStyle.code).not.toHaveProperty("fontSize"); + expect(markdownStyle.code).toHaveProperty("fontScale", theme.fontSize.code / bodySize); + expect(markdownStyle.codeBlock?.fontSize).toBe(theme.fontSize.code); + }, +); diff --git a/packages/app/src/components/markdown/renderer.tsx b/packages/app/src/components/markdown/renderer.tsx index 6d79beb01f..8e51495369 100644 --- a/packages/app/src/components/markdown/renderer.tsx +++ b/packages/app/src/components/markdown/renderer.tsx @@ -23,14 +23,29 @@ const ThemedMarkdown = withUnistyles(EnrichedMarkdownText); const MD4C_FLAGS = { latexMath: false, hardSoftBreaks: true, admonitions: true }; // Reading the breakpoint here makes Unistyles restyle the text when the layout crosses it. -const markdownStyle = (compact: boolean) => (theme: Theme, rt: { breakpoint?: string }) => - createMarkdownStyles(theme, { compact, blockGap: resolveDensity(rt, density.blockGap) }); +const markdownStyle = + (compact: boolean, muted = false) => + (theme: Theme, rt: { breakpoint?: string }) => + createMarkdownStyles(theme, { + compact, + muted, + blockGap: resolveDensity(rt, density.blockGap), + }); const regularStyle = markdownStyle(false); const compactStyle = markdownStyle(true); +const compactMutedStyle = markdownStyle(true, true); + +function pickMarkdownStyle(compact: boolean, muted: boolean) { + if (!compact) return regularStyle; + return muted ? compactMutedStyle : compactStyle; +} export interface MarkdownRendererProps extends MarkdownImageContext { text: string; compact?: boolean; + /** Muted text colour. Compact surfaces only. */ + muted?: boolean; + selectable?: boolean; streaming?: boolean; onLinkPress?: (url: string) => unknown; } @@ -56,6 +71,8 @@ export const MarkdownRenderer = memo(function MarkdownRenderer(props: MarkdownRe const MarkdownPart = memo(function MarkdownPart({ text, compact = false, + muted = false, + selectable = true, streaming = false, onLinkPress, ...imageContext @@ -98,9 +115,9 @@ const MarkdownPart = memo(function MarkdownPart({ ({ @@ -68,7 +72,7 @@ export function createMarkdownStyles( }, code: { fontFamily: theme.fontFamily.mono === "ui-monospace" && !isWeb ? "" : theme.fontFamily.mono, - fontSize: theme.fontSize.code, + fontScale: theme.fontSize.code / fontSize, color: theme.colors.foreground, backgroundColor: theme.colors.surface2, borderColor: "transparent", diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index d5a2244505..9430aa3a12 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -971,6 +971,10 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({ borderBottomLeftRadius: 0, borderBottomRightRadius: 0, }, + // Details that do not sit in a card leave the header as a lone highlighted pill. + pressableExpandedDetached: { + borderRadius: theme.borderRadius.full, + }, detailWrapperBorderless: { borderWidth: 0, }, @@ -2048,7 +2052,10 @@ export const ExpandableBadge = memo(function ExpandableBadge({ expandableBadgeStylesheet.pressable, isPressed && isInteractive ? expandableBadgeStylesheet.pressablePressed : null, isExpanded && expandableBadgeStylesheet.pressableExpanded, - isExpanded && !borderlessWhenExpanded && expandableBadgeStylesheet.pressableExpandedAttached, + isExpanded && + (borderlessWhenExpanded + ? expandableBadgeStylesheet.pressableExpandedDetached + : expandableBadgeStylesheet.pressableExpandedAttached), ], [borderlessWhenExpanded, isExpanded, isInteractive, isPressed], ); diff --git a/packages/app/src/components/question-form-card.browser.test.tsx b/packages/app/src/components/question-form-card.browser.test.tsx index 83120931d1..0b2a5df25d 100644 --- a/packages/app/src/components/question-form-card.browser.test.tsx +++ b/packages/app/src/components/question-form-card.browser.test.tsx @@ -10,6 +10,12 @@ import { QuestionFormCard } from "./question-form-card"; // Load translations so controls expose their real accessible names. void testI18n; +// The Markdown engine does not load in this harness; question and option text are covered by +// e2e/browser/question-prompt-pagination.spec.ts. Plain text keeps these tests on the answers. +vi.mock("@/components/markdown/renderer", () => ({ + MarkdownRenderer: ({ text }: { text: string }) => {text}, +})); + // App sources compile against the classic JSX runtime, which expects React on the global. beforeEach(() => vi.stubGlobal("React", React)); diff --git a/packages/app/src/components/question-form-card.tsx b/packages/app/src/components/question-form-card.tsx index 7014de89d1..8ff81963d1 100644 --- a/packages/app/src/components/question-form-card.tsx +++ b/packages/app/src/components/question-form-card.tsx @@ -1,7 +1,13 @@ import { Alert } from "@/components/ui/alert"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useState, useCallback, useMemo, useRef, type RefObject } from "react"; -import { View, Text, Pressable, type PressableStateCallbackType } from "react-native"; +import { + View, + Text, + Pressable, + type GestureResponderEvent, + type PressableStateCallbackType, +} from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { Check, X } from "lucide-react-native"; @@ -12,6 +18,7 @@ import { isWeb } from "@/constants/platform"; import { EditingTextInput as TextInput } from "@/components/ui/text-input"; import type { EditingTextInputHandle } from "@/components/ui/text-input/types"; import { ScrollableCodeSurface } from "@/components/ui/scrollable-code-surface"; +import { MarkdownRenderer } from "@/components/markdown/renderer"; import { areQuestionsAnswered, buildQuestionFormAnswers, @@ -36,6 +43,20 @@ const IS_WEB = isWeb; // drag to the preview's nested scroll, so lines past a cap would be unreachable. const OPTION_PREVIEW_MAX_HEIGHT = IS_WEB ? 240 : undefined; +// Web clicks that must not answer: a link in the label, anything in the description, and the +// second click of a double-click, which would otherwise land on the next question's options. +function isNonAnsweringClick(event: GestureResponderEvent, description: View | null): boolean { + if (!IS_WEB) return false; + const click = event.nativeEvent as unknown as MouseEvent; + if (click.detail > 1) return true; + const target = click.target as Element | null; + const descriptionElement = description as unknown as Element | null; + return Boolean(target?.closest("a") || descriptionElement?.contains(target)); +} + +// The description is selectable text, so a touch on it belongs to the text, not the option. +const claimResponder = () => true; + function getQuestionInputPlaceholder({ question, answerPlaceholder, @@ -71,9 +92,14 @@ function QuestionOptionRow({ }: QuestionOptionRowProps) { const { theme } = useUnistyles(); - const handlePress = useCallback(() => { - onToggle(qIndex, optIndex, multiSelect); - }, [onToggle, qIndex, optIndex, multiSelect]); + const descriptionRef = useRef(null); + const handlePress = useCallback( + (event: GestureResponderEvent) => { + if (isNonAnsweringClick(event, descriptionRef.current)) return; + onToggle(qIndex, optIndex, multiSelect); + }, + [onToggle, qIndex, optIndex, multiSelect], + ); const pressableStyle = useCallback( ({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [ @@ -86,17 +112,6 @@ function QuestionOptionRow({ [isSelected, theme.colors.surface2], ); - const optionLabelStyle = useMemo( - () => [ - styles.optionLabel, - { color: isSelected ? theme.colors.foreground : theme.colors.foregroundMuted }, - ], - [isSelected, theme.colors.foreground, theme.colors.foregroundMuted], - ); - const optionDescriptionStyle = useMemo( - () => [styles.optionDescription, { color: theme.colors.foregroundMuted }], - [theme.colors.foregroundMuted], - ); const accessibilityState = useMemo(() => ({ checked: isSelected }), [isSelected]); // Static left-side control: square for multi-select, circle for single-select. @@ -135,9 +150,15 @@ function QuestionOptionRow({ {isSelected && !multiSelect ? : null} - {option.label} + {option.description ? ( - {option.description} + + + ) : null} {option.preview ? ( // Shown for every option at once so they can be compared before picking. @@ -522,10 +543,6 @@ export function QuestionFormCard({ ], [theme.colors.surface1, theme.colors.border], ); - const questionTextStyle = useMemo( - () => [styles.questionText, { color: theme.colors.foreground }], - [theme.colors.foreground], - ); // Single-select radios need a group; checkboxes are valid standalone. const optionsGroupAccessibility = useMemo( () => @@ -570,9 +587,9 @@ export function QuestionFormCard({ onSelect={handleSelectQuestion} /> - - {activeQuestion?.question} - + + + {activeQuestion ? ( @@ -675,9 +692,6 @@ const styles = StyleSheet.create((theme) => ({ }, questionText: { flex: 1, - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.normal, - lineHeight: 22, }, optionsWrap: { gap: theme.spacing[1], @@ -723,15 +737,6 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, gap: theme.spacing[1], }, - optionLabel: { - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.normal, - lineHeight: 22, - }, - optionDescription: { - fontSize: theme.fontSize.base, - lineHeight: 20, - }, optionPreviewText: { lineHeight: 20, ...(isWeb ? { whiteSpace: "pre-wrap", overflowWrap: "break-word" } : null), diff --git a/packages/app/src/components/settings/collapsible-row.tsx b/packages/app/src/components/settings/collapsible-row.tsx index 219c19776c..d219c3abd5 100644 --- a/packages/app/src/components/settings/collapsible-row.tsx +++ b/packages/app/src/components/settings/collapsible-row.tsx @@ -1,20 +1,18 @@ -import { ChevronRight } from "lucide-react-native"; import { Children, isValidElement, useCallback, useMemo, useState, type ReactNode } from "react"; -import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; -import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { mutedIconColorMapping } from "@/components/ui/icon-color"; +import { View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; import { isWeb } from "@/constants/platform"; import { settingsStyles } from "@/styles/settings"; -import { ICON_SIZE, type Theme } from "@/styles/theme"; - -const ThemedChevron = withUnistyles(ChevronRight); -const hoveredIconColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); +import { NestedSettingsRows } from "./row-density"; +import { SettingsRowHeader } from "./row-header"; interface SettingsCollapsibleRowProps { label: string; + /** A short status shown before the chevron, so the row says something while collapsed. */ + summary?: string; defaultExpanded?: boolean; testID?: string; - /** The rows revealed underneath, separated like the rows of a `SettingsCard`. */ + /** The rows revealed underneath, separated like the rows of a `SettingsCard` and laid out as nested rows. */ children: ReactNode; } @@ -25,6 +23,7 @@ interface SettingsCollapsibleRowProps { */ export function SettingsCollapsibleRow({ label, + summary, defaultExpanded = false, testID, children, @@ -37,66 +36,39 @@ export function SettingsCollapsibleRow({ () => (isWeb ? ({ "aria-expanded": expanded } as const) : null), [expanded], ); - const headerStyle = useCallback( - ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ - settingsStyles.row, - hovered && styles.headerHovered, - pressed && styles.headerPressed, - ], - [], - ); - const chevronStyle = useMemo( - () => [styles.chevron, expanded && styles.chevronExpanded], - [expanded], - ); return ( - - {({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => ( - <> - - {label} - - - - - - )} - - {expanded - ? Children.toArray(children).map((child, index) => ( - - {child} - - )) - : null} + /> + {expanded ? ( + + + {Children.toArray(children).map((child, index) => ( + + {child} + + ))} + + + ) : null} ); } const styles = StyleSheet.create((theme) => ({ - headerHovered: { - backgroundColor: theme.colors.surface2, - }, - headerPressed: { - backgroundColor: theme.colors.surface3, - }, - // Optical: the chevron's ink sits about 4px inside its box whether it points right or down; this - // puts the ink on the card's trailing rail, where the switch and the reports' refresh buttons end. - chevron: { marginRight: -theme.spacing[1] }, - chevronExpanded: { - transform: [{ rotate: "90deg" }], + // The revealed rows keep the card's rails; the tint is what groups them under their header. + children: { + backgroundColor: theme.colors.surface0, }, })); diff --git a/packages/app/src/components/settings/detail-row.tsx b/packages/app/src/components/settings/detail-row.tsx new file mode 100644 index 0000000000..f256babe50 --- /dev/null +++ b/packages/app/src/components/settings/detail-row.tsx @@ -0,0 +1,33 @@ +import { SettingsRowHeader } from "./row-header"; + +interface SettingsDetailRowProps { + label: string; + /** What the detail is set to, so the row says it without being opened. */ + summary?: string; + onPress: () => void; + /** A row that cannot be opened keeps its summary and drops the chevron. */ + disabled?: boolean; + testID?: string; +} + +/** + * A settings row that opens a detail, such as a sheet, for one setting. The label, a one-line + * summary of the current value and a chevron pointing right. Sits inside a `SettingsCard`. + */ +export function SettingsDetailRow({ + label, + summary, + onPress, + disabled, + testID, +}: SettingsDetailRowProps) { + return ( + + ); +} diff --git a/packages/app/src/components/settings/headings/settings-info-tip.tsx b/packages/app/src/components/settings/headings/settings-info-tip.tsx index c8d36df74f..7242247583 100644 --- a/packages/app/src/components/settings/headings/settings-info-tip.tsx +++ b/packages/app/src/components/settings/headings/settings-info-tip.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from "react"; +import type { ComponentType, ReactNode } from "react"; import { Pressable, Text } from "react-native"; import { useTranslation } from "react-i18next"; import { Info } from "lucide-react-native"; @@ -6,7 +6,13 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { ICON_SIZE, type Theme } from "@/styles/theme"; -const ThemedInfo = withUnistyles(Info); +type TipIconComponent = ComponentType<{ size: number; color: string }>; + +function TipIcon({ icon: Icon, color }: { icon: TipIconComponent; color: string }) { + return ; +} + +const ThemedTipIcon = withUnistyles(TipIcon); const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); @@ -14,27 +20,37 @@ interface SettingsInfoTipProps { /** Header the tip belongs to; used for the accessibility label. */ title: string; info: ReactNode; + /** Defaults to the info icon. */ + icon?: TipIconComponent; + /** Defaults to "About {title}". */ + accessibilityLabel?: string; testID?: string; } /** * The one way a settings group or section explains itself: an info icon on the * header that opens a tooltip. Never a paragraph under the header — see - * docs/design.md §7. + * docs/design.md §7. Rows use it the same way to keep secondary details on one line. */ -export function SettingsInfoTip({ title, info, testID }: SettingsInfoTipProps) { +export function SettingsInfoTip({ + title, + info, + icon = Info, + accessibilityLabel, + testID, +}: SettingsInfoTipProps) { const { t } = useTranslation(); return ( - + diff --git a/packages/app/src/components/settings/index.tsx b/packages/app/src/components/settings/index.tsx index f31a0e7b38..567b13bd4e 100644 --- a/packages/app/src/components/settings/index.tsx +++ b/packages/app/src/components/settings/index.tsx @@ -24,15 +24,23 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/component import { DropdownTrigger } from "@/components/ui/dropdown-trigger"; import { useIsCompactFormFactor } from "@/constants/layout"; import { settingsStyles } from "@/styles/settings"; +import { ControlSizeProvider } from "@/components/ui/control-size"; +import { useSettingsRowLayout } from "./row-density"; export { SettingsGroup } from "./headings/settings-group"; export { SettingsSection } from "./headings/settings-section"; export { SettingsCollapsibleRow } from "./collapsible-row"; +export { SettingsDetailRow } from "./detail-row"; +export { SettingsInfoTip } from "./headings/settings-info-tip"; -interface AppSettingsRowProps extends Omit { - hint?: ReactNode; +/** What app screens can add to a plugin-facing row: an accessory beside the label, such as an info tip. */ +interface AppRowExtras { labelAccessory?: ReactNode; } +interface AppSettingsRowProps extends Omit, AppRowExtras { + hint?: ReactNode; +} + export function SettingsCard({ children, testID }: { children: ReactNode; testID?: string }) { return ( @@ -57,26 +65,32 @@ export function SettingsRow({ testID, }: AppSettingsRowProps) { const compact = useIsCompactFormFactor(); - const rowStyle = useMemo(() => [settingsStyles.row, compact && styles.compactRow], [compact]); + const layout = useSettingsRowLayout(); + const rowStyle = useMemo(() => [layout.row, compact && styles.compactRow], [compact, layout]); + const accessoryTitleStyle = useMemo(() => [layout.title, styles.accessoryLabel], [layout]); return ( {labelAccessory ? ( - {label} + {label} {labelAccessory} ) : ( - {label} + {label} )} - {typeof hint === "string" ? {hint} : hint} + {typeof hint === "string" ? {hint} : hint} {error ? ( {error} ) : null} - {children ? {children} : null} + {children ? ( + + {children} + + ) : null} ); } @@ -117,7 +131,7 @@ export function SettingsSelect({ onValueChange, disabled, ...row -}: SettingsSelectProps) { +}: SettingsSelectProps & AppRowExtras) { const selectedLabel = options.find((option) => option.value === value)?.label ?? value; return ( @@ -152,7 +166,7 @@ export function SettingsInput({ secureTextEntry, ref, ...row -}: SettingsInputProps) { +}: SettingsInputProps & AppRowExtras) { const compact = useIsCompactFormFactor(); const input = useRef(null); useImperativeHandle( @@ -175,7 +189,7 @@ export function SettingsInput({ editable={!disabled} secureTextEntry={secureTextEntry} accessibilityLabel={row.label} - size={compact ? "md" : "sm"} + size={compact ? "md" : undefined} style={styles.input} /> @@ -185,7 +199,7 @@ export function SettingsInput({ export function SettingsAction({ actionLabel, onPress, disabled, ...row }: SettingsActionProps) { return ( - diff --git a/packages/app/src/components/settings/row-density.browser.test.tsx b/packages/app/src/components/settings/row-density.browser.test.tsx new file mode 100644 index 0000000000..d2742e3172 --- /dev/null +++ b/packages/app/src/components/settings/row-density.browser.test.tsx @@ -0,0 +1,77 @@ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it } from "vitest"; +import { Button } from "@/components/ui/button"; +import { SettingsCard, SettingsCollapsibleRow, SettingsRow } from "@/components/settings"; + +let root: Root | null = null; +let container: HTMLDivElement | null = null; + +afterEach(() => { + act(() => root?.unmount()); + container?.remove(); + root = null; + container = null; +}); + +function mountRows() { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => + root?.render( + + + + + + + + + + + , + ), + ); +} + +function element(testID: string): HTMLElement { + const found = document.querySelector(`[data-testid="${testID}"]`); + if (!(found instanceof HTMLElement)) throw new Error(`Nothing rendered as ${testID}`); + return found; +} + +function heightOf(testID: string): number { + return element(testID).getBoundingClientRect().height; +} + +function titleFontSize(rowTestID: string, label: string): number { + const title = [...element(rowTestID).querySelectorAll("div")].find( + (node) => node.textContent === label && node.children.length === 0, + ); + if (!title) throw new Error(`No title ${label}`); + return Number.parseFloat(getComputedStyle(title).fontSize); +} + +describe("settings row density", () => { + it("lines up nested rows with and without a button, tighter than card rows", () => { + mountRows(); + + expect(heightOf("nested-with-button")).toBe(heightOf("nested-without-button")); + expect(heightOf("nested-with-button")).toBeLessThan(heightOf("card-row")); + expect(heightOf("group-toggle")).toBeGreaterThan(heightOf("nested-without-button")); + }); + + it("sizes nested text and controls one step below card rows", () => { + mountRows(); + + expect(heightOf("nested-button")).toBeLessThan(heightOf("card-button")); + expect(titleFontSize("nested-with-button", "Nested with a button")).toBeLessThan( + titleFontSize("card-row", "Card row"), + ); + }); +}); diff --git a/packages/app/src/components/settings/row-density.tsx b/packages/app/src/components/settings/row-density.tsx new file mode 100644 index 0000000000..d4bcdce33e --- /dev/null +++ b/packages/app/src/components/settings/row-density.tsx @@ -0,0 +1,57 @@ +import { createContext, useContext, useMemo, type ReactNode } from "react"; +import type { StyleProp, TextStyle, ViewStyle } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { CONTROL_HEIGHTS } from "@/components/ui/control-geometry"; +import type { ContainedControlSize } from "@/components/ui/control-size"; +import { settingsStyles } from "@/styles/settings"; + +// Rows revealed under a collapsible row belong to it, so they sit one step smaller than the rows +// of the card: tighter padding, smaller text, smaller controls. The container decides; a row +// never knows on its own that it is nested. +const NestedRowsContext = createContext(false); + +export function NestedSettingsRows({ children }: { children: ReactNode }) { + return {children}; +} + +export interface SettingsRowLayout { + row: StyleProp; + title: StyleProp; + hint: StyleProp; + /** The size of the controls in the row's trailing slot. */ + controlSize: ContainedControlSize; +} + +/** How a row lays out where it is rendered: in a card, or nested under a collapsible row. */ +export function useSettingsRowLayout(): SettingsRowLayout { + const nested = useContext(NestedRowsContext); + return useMemo( + () => + nested + ? { + row: [settingsStyles.row, styles.nestedRow], + title: [settingsStyles.rowTitle, styles.nestedText], + hint: [settingsStyles.rowHint, styles.nestedText], + controlSize: "xs", + } + : { + row: settingsStyles.row, + title: settingsStyles.rowTitle, + hint: settingsStyles.rowHint, + controlSize: "sm", + }, + [nested], + ); +} + +const styles = StyleSheet.create((theme) => ({ + // Every nested row is as tall as one holding an `xs` control, so rows with and without a + // trailing button line up. + nestedRow: { + paddingVertical: theme.spacing[2], + minHeight: CONTROL_HEIGHTS.tight + theme.spacing[2] * 2, + }, + nestedText: { + fontSize: theme.fontSize.sm, + }, +})); diff --git a/packages/app/src/components/settings/row-header.tsx b/packages/app/src/components/settings/row-header.tsx new file mode 100644 index 0000000000..9c3c415c34 --- /dev/null +++ b/packages/app/src/components/settings/row-header.tsx @@ -0,0 +1,113 @@ +import { ChevronRight } from "lucide-react-native"; +import { useCallback, useMemo } from "react"; +import { Text, View, type AccessibilityState, type PressableStateCallbackType } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { mutedIconColorMapping } from "@/components/ui/icon-color"; +import { PressHighlight } from "@/components/ui/press-highlight"; +import { settingsStyles } from "@/styles/settings"; +import { ICON_SIZE, type Theme } from "@/styles/theme"; +import { useSettingsRowLayout } from "./row-density"; + +const ThemedChevron = withUnistyles(ChevronRight); +const hoveredIconColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); + +type HoverState = PressableStateCallbackType & { hovered?: boolean }; + +export interface SettingsRowHeaderProps { + label: string; + /** A short status shown before the chevron. */ + summary?: string; + onPress: () => void; + disabled?: boolean; + /** Turns the chevron down, for a row whose children are revealed in place. */ + expanded?: boolean; + accessibilityState?: AccessibilityState; + /** Props React Native Web does not derive from `accessibilityState`, such as `aria-expanded`. */ + webAccessibilityProps?: Record | null; + testID?: string; +} + +/** + * The pressable line shared by settings rows that lead somewhere: a label, an optional summary and + * a trailing chevron. A collapsible row turns the chevron down; a row that opens a sheet keeps it + * pointing right. + */ +export function SettingsRowHeader({ + label, + summary, + onPress, + disabled, + expanded = false, + accessibilityState, + webAccessibilityProps, + testID, +}: SettingsRowHeaderProps) { + const layout = useSettingsRowLayout(); + const rowStyle = useCallback( + ({ hovered, pressed }: HoverState) => [ + layout.row, + !disabled && hovered && styles.hovered, + !disabled && pressed && styles.pressed, + ], + [disabled, layout], + ); + const chevronStyle = useMemo( + () => [styles.chevron, expanded && styles.chevronExpanded], + [expanded], + ); + return ( + + {({ hovered }: HoverState) => ( + <> + + {label} + + {summary ? ( + + {summary} + + ) : null} + {disabled ? null : ( + + + + )} + + )} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + summary: { + flexShrink: 1, + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + marginRight: theme.spacing[2], + }, + hovered: { + backgroundColor: theme.colors.surface2, + }, + pressed: { + backgroundColor: theme.colors.surface3, + }, + // Optical: the chevron's ink sits about 4px inside its box whether it points right or down; this + // puts the ink on the card's trailing rail, where the switch and the reports' refresh buttons end. + chevron: { marginRight: -theme.spacing[1] }, + chevronExpanded: { + transform: [{ rotate: "90deg" }], + }, +})); diff --git a/packages/app/src/components/sidebar-workspace-list.test.tsx b/packages/app/src/components/sidebar-workspace-list.test.tsx index 0e1d7d28ab..fc57dc2dce 100644 --- a/packages/app/src/components/sidebar-workspace-list.test.tsx +++ b/packages/app/src/components/sidebar-workspace-list.test.tsx @@ -2,6 +2,7 @@ * @vitest-environment jsdom */ import { act, renderHook } from "@testing-library/react"; +import { createWorkspaceVisibilityPolicy } from "@/components/sidebar/sidebar-group-visibility"; import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { WorkspaceScriptPayload } from "@getpaseo/protocol/messages"; @@ -486,6 +487,20 @@ describe("sidebar group visibility", () => { }, ); + it("treats placements without a hydrated entry as Done", () => { + const items = entries([...Array(8).fill("done"), "running", "done"]); + const hydrated = new Map(items.slice(0, 9).map((item) => [item.workspaceKey, item])); + for (const entriesByKey of [new Map(), hydrated]) { + const { result } = renderHook(() => + useLimitedSidebarGroup(items, createWorkspaceVisibilityPolicy(entriesByKey)), + ); + expect(result.current.canToggle).toBe(true); + expect(names(result.current.visibleItems)).toEqual( + entriesByKey.size === 0 ? names(items.slice(0, 7)) : [...names(items.slice(0, 7)), "row-9"], + ); + } + }); + it("shows eight active rows once with no redundant toggle", () => { const items = entries([ "running", diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index c3048e6d9a..333eb68221 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -30,7 +30,7 @@ import { type ActiveWorkspaceSelection, } from "@/stores/navigation-active-workspace-store"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { density, densityIconBox } from "@/styles/density"; +import { density, densityIconBox, densityIconProps } from "@/styles/density"; import type { Theme } from "@/styles/theme"; import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop"; import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop"; @@ -99,6 +99,7 @@ import { import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction"; import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header"; import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row"; +import { createWorkspaceVisibilityPolicy } from "@/components/sidebar/sidebar-group-visibility"; import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group"; import { SidebarWorkspaceRowFrame, @@ -462,12 +463,12 @@ const openInNewWindowLeadingIcon = ( ); +const kebabTriggerIconProps = densityIconProps("sm", foregroundMutedColorMapping); +const kebabTriggerIconHoveredProps = densityIconProps("sm", foregroundColorMapping); + function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) { return ( - + ); } @@ -1593,11 +1594,7 @@ function ProjectBlock({ onToggleWorkspacePin: ToggleSidebarWorkspacePin; }) { const visibilityPolicy = useMemo( - () => ({ - kind: "workspaces" as const, - getStatusBucket: (workspace: SidebarWorkspacePlacement) => - workspaceEntriesByKey.get(workspace.workspaceKey)!.statusBucket, - }), + () => createWorkspaceVisibilityPolicy(workspaceEntriesByKey), [workspaceEntriesByKey], ); const { @@ -2708,8 +2705,12 @@ const styles = StyleSheet.create((theme) => ({ marginRight: -6, }, projectKebabButton: { - width: 24, - height: 24, + // Larger on compact so the kebab is an easy tap target. The negative margins keep its + // dots on the same rail as the 24px desktop box and the row at its 44px height. + width: { xs: 32, md: 24 }, + height: { xs: 32, md: 24 }, + marginRight: { xs: -4, md: 0 }, + marginVertical: { xs: -4, md: 0 }, borderRadius: theme.borderRadius.md, alignItems: "center", justifyContent: "center", diff --git a/packages/app/src/components/sidebar/sidebar-group-visibility.ts b/packages/app/src/components/sidebar/sidebar-group-visibility.ts index ce9f47671c..1bc3018e9a 100644 --- a/packages/app/src/components/sidebar/sidebar-group-visibility.ts +++ b/packages/app/src/components/sidebar/sidebar-group-visibility.ts @@ -4,6 +4,18 @@ export type SidebarGroupVisibilityPolicy = | { kind: "pinned" } | { kind: "workspaces"; getStatusBucket: (item: T) => SidebarStateBucket }; +// A placement can outrun its entry: a retained sidebar that is inactive (compact layout with the +// sidebar closed) or still waiting for sessions keeps an empty entry map. A missing entry renders +// no row, so it counts as Done and claims no slot past the limit. +export function createWorkspaceVisibilityPolicy( + entriesByKey: ReadonlyMap, +): SidebarGroupVisibilityPolicy { + return { + kind: "workspaces", + getStatusBucket: (item) => entriesByKey.get(item.workspaceKey)?.statusBucket ?? "done", + }; +} + export function selectSidebarGroupVisibility( items: readonly T[], expanded: boolean, diff --git a/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx b/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx index e1543fac41..f74c43c980 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx @@ -17,6 +17,7 @@ import { isWeb } from "@/constants/platform"; import { getForgePresentation, normalizeForge } from "@/git/forge"; import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; import { useAppSettings } from "@/hooks/use-settings"; +import { densityIconProps } from "@/styles/density"; import type { Theme } from "@/styles/theme"; import type { ShortcutKey } from "@/utils/format-shortcut"; import { @@ -70,13 +71,11 @@ const archiveLeadingIcon = ; const unpinLeadingIcon = ; +const triggerIconProps = densityIconProps("sm", foregroundMutedColorMapping); +const triggerIconHoveredProps = densityIconProps("sm", foregroundColorMapping); + function renderTriggerIcon({ hovered }: { hovered?: boolean }) { - return ( - - ); + return ; } export interface SidebarWorkspaceMenuProps { @@ -439,12 +438,18 @@ function triggerStyle({ hovered = false }: PressableStateCallbackType & { hovere const styles = StyleSheet.create((theme) => ({ trigger: { - padding: 2, + // Larger on compact so the kebab is an easy tap target. + width: { xs: 32, md: 18 }, + height: { xs: 32, md: 18 }, + alignItems: "center", + justifyContent: "center", borderRadius: 4, marginLeft: 2, + // Center the compact box on the 20px title line it is anchored to. + marginTop: { xs: -6, md: 0 }, // MoreVertical paints only around the center of its SVG. Keep the padded hit box, but // pull the painted dots through that unused view-box space onto the trailing-content rail. - marginRight: -7, + marginRight: { xs: -14, md: -7 }, }, triggerHovered: { backgroundColor: theme.colors.surface2, diff --git a/packages/app/src/components/ui/autocomplete-popover.tsx b/packages/app/src/components/ui/autocomplete-popover.tsx index fa5e724944..e74c068658 100644 --- a/packages/app/src/components/ui/autocomplete-popover.tsx +++ b/packages/app/src/components/ui/autocomplete-popover.tsx @@ -5,9 +5,9 @@ import { useRef, useState, type ReactElement, - type RefObject, + type ReactNode, } from "react"; -import { View, useWindowDimensions } from "react-native"; +import { View, useWindowDimensions, type StyleProp, type ViewStyle } from "react-native"; import { Portal } from "@gorhom/portal"; import Animated, { useAnimatedReaction, useAnimatedStyle } from "react-native-reanimated"; import { scheduleOnRN } from "react-native-worklets"; @@ -32,7 +32,8 @@ interface RelativeAnchorRect { interface AutocompletePopoverProps { visible: boolean; - anchorRef: RefObject; + children: ReactNode; + anchorStyle?: StyleProp; options: readonly AutocompleteOption[]; selectedIndex: number; onSelect: (option: AutocompleteOption) => void; @@ -44,7 +45,8 @@ interface AutocompletePopoverProps { export function AutocompletePopover({ visible, - anchorRef, + children, + anchorStyle, options, selectedIndex, onSelect, @@ -55,6 +57,7 @@ export function AutocompletePopover({ }: AutocompletePopoverProps): ReactElement | null { "use no memo"; // React Compiler memoizes effect captures by reading SharedValue.value during render. + const anchorRef = useRef(null); const [relativeAnchorRect, setRelativeAnchorRect] = useState(null); const windowDimensions = useWindowDimensions(); const safeAreaInsets = useSafeAreaInsets(); @@ -137,25 +140,29 @@ export function AutocompletePopover({ [anchorY, reservedLift, safeAreaInsets.top], ); - if (!visible || !relativeAnchorRect || !baseStyle) return null; - if (options.length > 0 && selectedIndex < 0) return null; + const showPopover = canMeasure && relativeAnchorRect && baseStyle; // The translate view is the popover's own frame, so it lifts only the popover. return ( - - - - - - - + + {children} + {showPopover ? ( + + + + + + + + ) : null} + ); } diff --git a/packages/app/src/components/ui/autocomplete.tsx b/packages/app/src/components/ui/autocomplete.tsx index 7b5b99b316..a9cb10f33e 100644 --- a/packages/app/src/components/ui/autocomplete.tsx +++ b/packages/app/src/components/ui/autocomplete.tsx @@ -247,14 +247,16 @@ export function Autocomplete({ {selectedOption?.kind === "command" && selectedOption.description ? ( - + {removeBoltGlyphs(selectedOption.label) ?? selectedOption.label} - + {removeBoltGlyphs(selectedOption.description)} {selectedOption.detail ? ( - {removeBoltGlyphs(selectedOption.detail)} + + {removeBoltGlyphs(selectedOption.detail)} + ) : null} ) : null} @@ -293,6 +295,9 @@ const styles = StyleSheet.create((theme: Theme) => ({ gap: theme.spacing[1], }, detailCard: { + flexShrink: 1, + minHeight: 0, + overflow: "hidden", backgroundColor: theme.colors.surface1, borderWidth: theme.borderWidth[1], borderColor: theme.colors.borderAccent, @@ -318,7 +323,8 @@ const styles = StyleSheet.create((theme: Theme) => ({ }, container: { flexShrink: 1, - minHeight: 0, + // Keep a complete selectable row (36), list padding (8), and borders (2). + minHeight: 46, backgroundColor: theme.colors.surface1, borderWidth: theme.borderWidth[1], borderColor: theme.colors.borderAccent, diff --git a/packages/app/src/components/ui/button.tsx b/packages/app/src/components/ui/button.tsx index 707ce60648..abcaf240d6 100644 --- a/packages/app/src/components/ui/button.tsx +++ b/packages/app/src/components/ui/button.tsx @@ -1,4 +1,5 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { useContainedControlSize } from "@/components/ui/control-size"; import { default as React, useCallback, @@ -162,7 +163,7 @@ const styles = StyleSheet.create((theme) => { export function Button({ children, variant = "secondary", - size = "md", + size: sizeProp, leftIcon, trailing, style, @@ -185,6 +186,8 @@ export function Button({ } >) { const [hovered, setHovered] = useState(false); + const containedSize = useContainedControlSize(); + const size = sizeProp ?? containedSize ?? "md"; const isDisabled = disabled || loading; let variantStyle: ViewStyle; diff --git a/packages/app/src/components/ui/control-geometry.test.ts b/packages/app/src/components/ui/control-geometry.test.ts index 0b0675596f..3544d9f940 100644 --- a/packages/app/src/components/ui/control-geometry.test.ts +++ b/packages/app/src/components/ui/control-geometry.test.ts @@ -81,6 +81,8 @@ describe("control geometry", () => { it("keeps field text sizing tied to control size", () => { const geometry = createControlGeometry(theme); + expect(geometry.fieldTextXs.fontSize).toBe(12); + expect(geometry.fieldTextXs.lineHeight).toBe(17); expect(geometry.fieldTextSm.fontSize).toBe(14); expect(geometry.fieldTextSm.lineHeight).toBe(20); expect(geometry.fieldTextMd.fontSize).toBe(14); @@ -95,6 +97,13 @@ describe("control geometry", () => { const geometry = createControlGeometry(theme); const borderWidth = theme.borderWidth[1]; + expect(geometry.fieldControlXs.minHeight).toBe(28); + expect( + geometry.fieldTextXs.lineHeight + + geometry.fieldControlXs.paddingVertical * 2 + + borderWidth * 2, + ).toBe(geometry.fieldControlXs.minHeight); + expect(geometry.fieldControlSm.minHeight).toBe(32); expect(geometry.fieldControlSm.paddingVertical).toBe(5); expect( diff --git a/packages/app/src/components/ui/control-geometry.ts b/packages/app/src/components/ui/control-geometry.ts index d56200887e..0a41f80039 100644 --- a/packages/app/src/components/ui/control-geometry.ts +++ b/packages/app/src/components/ui/control-geometry.ts @@ -2,7 +2,7 @@ import type { StyleProp, ViewStyle } from "react-native"; import { ICON_SIZE, type Theme } from "@/styles/theme"; export type ButtonControlSize = "xs" | "sm" | "md" | "lg"; -export type FieldControlSize = "sm" | "md"; +export type FieldControlSize = "xs" | "sm" | "md"; export type SegmentedControlSize = "xs" | "sm" | "md"; export type ControlInteractionPhase = "rest" | "hover" | "active"; @@ -188,8 +188,19 @@ export function resolveControlInteractionStyles( export function createControlGeometry(theme: Theme) { const controlBorderWidth = theme.borderWidth[1]; + const fieldTextXsLineHeight = fieldLineHeight(theme.fontSize.sm); const fieldTextSmLineHeight = fieldLineHeight(theme.fontSize.base); const fieldTextMdLineHeight = fieldLineHeight(theme.fontSize.base); + const fieldControlXs = { + minHeight: CONTROL_HEIGHTS.tight, + paddingHorizontal: theme.spacing[2], + paddingVertical: fieldVerticalPadding( + CONTROL_HEIGHTS.tight, + fieldTextXsLineHeight, + controlBorderWidth, + ), + borderRadius: theme.borderRadius.md, + }; const fieldControlSm = { minHeight: CONTROL_HEIGHTS.compact, paddingHorizontal: theme.spacing[3], @@ -210,6 +221,10 @@ export function createControlGeometry(theme: Theme) { ), borderRadius: theme.borderRadius.lg, }; + const fieldTextXs = { + fontSize: theme.fontSize.sm, + lineHeight: fieldTextXsLineHeight, + }; const fieldTextSm = { fontSize: theme.fontSize.base, lineHeight: fieldTextSmLineHeight, @@ -252,6 +267,10 @@ export function createControlGeometry(theme: Theme) { fontSize: theme.fontSize.sm, }, alert, + formTextInputXs: { + ...fieldControlXs, + ...fieldTextXs, + }, formTextInputSm: { ...fieldControlSm, ...fieldTextSm, @@ -264,8 +283,10 @@ export function createControlGeometry(theme: Theme) { ...fieldControlMd, ...fieldTextMd, }, + fieldControlXs, fieldControlSm, fieldControlMd, + fieldTextXs, fieldTextSm, fieldTextMd, controlRest: { diff --git a/packages/app/src/components/ui/control-size.tsx b/packages/app/src/components/ui/control-size.tsx new file mode 100644 index 0000000000..c6bcbbea78 --- /dev/null +++ b/packages/app/src/components/ui/control-size.tsx @@ -0,0 +1,23 @@ +import { createContext, useContext, type ReactNode } from "react"; + +/** The control sizes a container can ask of the controls placed in it. */ +export type ContainedControlSize = "xs" | "sm"; + +// A row decides how big its controls are, so a button or field placed in it needs no size of +// its own. A size passed to the control still wins. +const ControlSizeContext = createContext(null); + +export function ControlSizeProvider({ + size, + children, +}: { + size: ContainedControlSize; + children: ReactNode; +}) { + return {children}; +} + +/** The size the surrounding container asks for, or null outside one. */ +export function useContainedControlSize(): ContainedControlSize | null { + return useContext(ControlSizeContext); +} diff --git a/packages/app/src/components/ui/dropdown-trigger.tsx b/packages/app/src/components/ui/dropdown-trigger.tsx index 2fc1eb26ac..a276e40652 100644 --- a/packages/app/src/components/ui/dropdown-trigger.tsx +++ b/packages/app/src/components/ui/dropdown-trigger.tsx @@ -9,6 +9,7 @@ import { } from "@/components/ui/control-geometry"; import type { Theme } from "@/styles/theme"; import { DropdownMenuTrigger, type DropdownMenuTriggerProps } from "@/components/ui/dropdown-menu"; +import { useContainedControlSize } from "@/components/ui/control-size"; const ThemedChevronDown = withUnistyles(ChevronDown); @@ -32,10 +33,12 @@ interface DropdownTriggerProps extends Omit; +const FIELD_TEXT = { xs: "inputXs", sm: "inputSm", md: "inputMd" } as const satisfies Record< + FieldControlSize, + string +>; interface FieldProps { label: string; @@ -159,11 +169,13 @@ function assignTextInputRef( } export const FormTextInput = forwardRef( - function FormTextInput({ size = "md", style, onFocus, onBlur, editable, ...props }, ref) { + function FormTextInput({ size: sizeProp, style, onFocus, onBlur, editable, ...props }, ref) { const [focused, setFocused] = useState(false); + const containedSize = useContainedControlSize(); + const size = sizeProp ?? containedSize ?? "md"; const isDisabled = editable === false; - const chromeSizeStyle = size === "sm" ? formInputStyles.chromeSm : formInputStyles.chromeMd; - const inputSizeStyle = size === "sm" ? formInputStyles.inputSm : formInputStyles.inputMd; + const chromeSizeStyle = formInputStyles[FIELD_CHROME[size]]; + const inputSizeStyle = formInputStyles[FIELD_TEXT[size]]; const splitStyle = useMemo(() => splitFormTextInputStyle(style), [style]); const setInputRef = useCallback( (node: EditingTextInputHandle | null) => { @@ -262,6 +274,9 @@ const formInputStyles = StyleSheet.create((theme) => { chrome: { backgroundColor: theme.colors.surface2, }, + chromeXs: { + ...geometry.fieldControlXs, + }, chromeSm: { ...geometry.fieldControlSm, }, @@ -289,6 +304,9 @@ const formInputStyles = StyleSheet.create((theme) => { outlineColor: "transparent", outlineWidth: 0, }, + inputXs: { + ...geometry.fieldTextXs, + }, inputSm: { ...geometry.fieldTextSm, }, diff --git a/packages/app/src/components/ui/icon-color.ts b/packages/app/src/components/ui/icon-color.ts index 1093064423..f7891dabc4 100644 --- a/packages/app/src/components/ui/icon-color.ts +++ b/packages/app/src/components/ui/icon-color.ts @@ -1,5 +1,9 @@ import type { Theme } from "@/styles/theme"; +export const foregroundIconColorMapping = (theme: Theme) => ({ + color: theme.colors.foreground, +}); + export const mutedIconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, }); diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.test.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.test.tsx index cf820ac86c..6637c5e339 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.test.tsx +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.test.tsx @@ -58,6 +58,7 @@ vi.mock("@gorhom/bottom-sheet", async () => { }); import { IsolatedBottomSheetModal, type ContextBridge } from "."; +import { useAnimatedLayout } from "@gorhom/bottom-sheet/src/hooks/useAnimatedLayout"; import { useIsInsideBottomSheet } from "@/components/ui/bottom-sheet-scope"; /** Renders whatever the portal is currently holding, from its own place in the tree. */ @@ -200,3 +201,35 @@ describe("IsolatedBottomSheetModal context bridging", () => { ).toEqual(["false", "true"]); }); }); + +describe("bottom sheet layout subscription", () => { + it("stops updating a dismissed sheet when the window resizes", () => { + const container = document.createElement("div"); + const root = createRoot(container); + let layout: ReturnType | undefined; + const viewport = document.documentElement; + const originalWidth = Object.getOwnPropertyDescriptor(viewport, "clientWidth"); + function resize(width: number) { + Object.defineProperty(viewport, "clientWidth", { configurable: true, value: width }); + act(() => window.dispatchEvent(new Event("resize"))); + } + function LayoutProbe() { + layout = useAnimatedLayout(undefined, 0, 0); + return null; + } + try { + act(() => root.render()); + if (!layout) throw new Error("Sheet layout did not mount"); + const mountedLayout = layout; + resize(640); + expect(mountedLayout.get().window.width).toBe(640); + act(() => root.render(null)); + resize(960); + expect(mountedLayout.get().window.width).toBe(640); + } finally { + act(() => root.unmount()); + if (originalWidth) Object.defineProperty(viewport, "clientWidth", originalWidth); + else Reflect.deleteProperty(viewport, "clientWidth"); + } + }); +}); diff --git a/packages/app/src/components/ui/segmented-control.tsx b/packages/app/src/components/ui/segmented-control.tsx index e0bbdf3f7b..d828c26c4d 100644 --- a/packages/app/src/components/ui/segmented-control.tsx +++ b/packages/app/src/components/ui/segmented-control.tsx @@ -7,6 +7,7 @@ import { segmentedIconSize, type SegmentedControlSize, } from "@/components/ui/control-geometry"; +import { useContainedControlSize } from "@/components/ui/control-size"; import type { Theme } from "@/styles/theme"; type SegmentedControlIconRenderer = (props: { color: string; size: number }) => ReactNode; @@ -48,11 +49,13 @@ export function SegmentedControl({ options, value, onValueChange, - size = "md", + size: sizeProp, hideLabels = false, style, testID, }: SegmentedControlProps) { + const containedSize = useContainedControlSize(); + const size = sizeProp ?? containedSize ?? "md"; const sizeStyles = { xs: { container: styles.containerXs, segment: styles.segmentXs, label: styles.labelXs }, sm: { container: styles.containerSm, segment: styles.segmentSm, label: styles.labelSm }, diff --git a/packages/app/src/components/ui/tooltip.tsx b/packages/app/src/components/ui/tooltip.tsx index ae00768a34..697a80fdcf 100644 --- a/packages/app/src/components/ui/tooltip.tsx +++ b/packages/app/src/components/ui/tooltip.tsx @@ -27,6 +27,7 @@ import { FadeIn, FadeOut } from "react-native-reanimated"; import { StyleSheet } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { FloatingSurface } from "@/components/ui/floating"; +import { useRetainedPanelActive } from "@/components/retained-panel"; import { isNative, isWeb } from "@/constants/platform"; import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root"; @@ -256,8 +257,15 @@ export function Tooltip({ }); const isCompact = useIsCompactFormFactor(); + const active = useRetainedPanelActive(); const opensOnPress = isNative || isCompact; - const enabled = opensOnPress ? enabledOnMobile : enabledOnDesktop; + const enabled = active && (opensOnPress ? enabledOnMobile : enabledOnDesktop); + + // Remove the portal in the inactive commit, before the retained chat freezes. + // Closing also prevents the tooltip from returning when its panel is shown again. + useEffect(() => { + if (!active && isOpen) setIsOpen(false); + }, [active, isOpen, setIsOpen]); const value = useMemo( () => ({ @@ -329,6 +337,10 @@ export function TooltipTrigger({ }; }, [clearOpenTimer]); + useEffect(() => { + if (!ctx.enabled) clearOpenTimer(); + }, [ctx.enabled, clearOpenTimer]); + const handleHoverIn = useCallback( (e?: unknown) => { if (isCallable(onHoverIn)) onHoverIn(e); diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index ed9ccca4f7..d000c269af 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -55,7 +55,11 @@ import { } from "@/composer/agent-controls"; import { ContextWindowMeter } from "@/components/context-window-meter"; import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker"; -import { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-store"; +import { + selectAgentSentMessages, + selectAgentTurnPresentation, + useSessionStore, +} from "@/stores/session-store"; import { useFilePicker } from "@/hooks/use-file-picker"; import { useFileDrop } from "@/components/file-drop/use-file-drop"; import type { DroppedItem } from "@/components/file-drop/types"; @@ -118,6 +122,7 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import type { MessageInputKeyboardActionKind } from "@/keyboard/actions"; import { submitAgentInput } from "@/composer/submit"; +import { ComposerRecall } from "@/composer/recall"; import { createMessageSubmissionWriter } from "@/composer/submission/writer"; import { ComposerKeyboardScopeProvider, useComposerKeyboardScope } from "@/composer/keyboard-scope"; import { useAppSettings } from "@/hooks/use-settings"; @@ -995,20 +1000,24 @@ interface ComposerAutocompleteHandle { function ComposerAutocompleteBinding({ text, cursor, + recall, configuration, inputRef, - anchorRef, + children, + anchorStyle, show, ref, }: { text: ComposerTextSource; cursor: StoreApi; + recall: ComposerRecall; configuration: Omit< Parameters[0], "userInput" | "cursorIndex" | "onAutocompleteApplied" >; inputRef: React.RefObject; - anchorRef: React.RefObject; + children: React.ReactNode; + anchorStyle: React.ComponentProps["style"]; show: boolean; ref: React.Ref; }) { @@ -1020,9 +1029,11 @@ function ComposerAutocompleteBinding({ cursorIndex: Math.min(cursorIndex, userInput.length), onAutocompleteApplied: () => inputRef.current?.focus(), }); + // A recalled message is sent as is; its leading `/command` must not open the picker. + const isShown = show && !recall.isShowing(userInput); const handleKeyPress = autocomplete.onKeyPress; - useImperativeHandle(ref, () => ({ onKeyPress: (event) => show && handleKeyPress(event) }), [ - show, + useImperativeHandle(ref, () => ({ onKeyPress: (event) => isShown && handleKeyPress(event) }), [ + isShown, handleKeyPress, ]); const selectOption = autocomplete.onSelectOption; @@ -1032,8 +1043,8 @@ function ComposerAutocompleteBinding({ ); return ( + > + {children} + ); } @@ -1362,6 +1375,7 @@ function ComposerContentImpl({ ); useEffect(() => () => cursorPublication.cancel(), [cursorPublication]); const autocompleteRef = useRef(null); + const recall = useMemo(() => new ComposerRecall(), []); const [isProcessing, setIsProcessing] = useState(false); const [pendingFiles, setPendingFiles] = useState([]); const nextPendingFileId = useRef(0); @@ -1981,10 +1995,19 @@ function ComposerContentImpl({ const hasSendableContent = hasText || selectedAttachments.length > 0; - // Handle keyboard navigation for command autocomplete. + // An open autocomplete owns the arrows; otherwise they may recall sent messages. const handleCommandKeyPress = useCallback( - (event: ComposerKeyPressEvent) => autocompleteRef.current?.onKeyPress(event) ?? false, - [], + (event: ComposerKeyPressEvent) => { + if (autocompleteRef.current?.onKeyPress(event)) return true; + const recalledText = recall.handleKeyPress(event, () => + selectAgentSentMessages(useSessionStore.getState().sessions[serverId], agentId), + ); + if (recalledText === null) return false; + event.preventDefault(); + replaceUserInput(recalledText); + return true; + }, + [agentId, recall, replaceUserInput, serverId], ); const cancelButtonStyle = useMemo( @@ -2360,7 +2383,6 @@ function ComposerContentImpl({ pluginClientSlashCommands, ], ); - const messageInputContainerRef = useRef(null); const isSubmitLoadingVisible = isProcessing || isSubmitLoading || isUploadingFile || pendingNativeImagePastes > 0; @@ -2419,16 +2441,16 @@ function ComposerContentImpl({ {isFullscreen ? null : queueList} {!isFullscreen && sendErrorNode} - - + {pluginAttachments.picker} - + diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index 2c104924cc..73c54331d1 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -27,6 +27,7 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; import { ICON_SIZE, type Theme } from "@/styles/theme"; import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native"; +import * as Haptics from "expo-haptics"; import { useDictation } from "@/hooks/use-dictation"; import { hasFinePointer } from "@/hooks/use-fine-pointer"; import { DictationOverlay } from "@/components/dictation-controls"; @@ -57,7 +58,7 @@ import { formatShortcut, type ShortcutKey } from "@/utils/format-shortcut"; import { getShortcutOs } from "@/utils/shortcut-platform"; import type { MessageInputKeyboardActionKind } from "@/keyboard/actions"; import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime"; -import { isWeb } from "@/constants/platform"; +import { isNative, isWeb } from "@/constants/platform"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useComposerKeyboardScope } from "@/composer/keyboard-scope"; import { RenderProfile } from "@/utils/render-profiler"; @@ -76,6 +77,7 @@ const ComposerTextInput = withUnistyles(EditingTextInput, (theme) => ({ })); import { resolveSendTooltipLabel, + resolveSubmitAccessibilityHint, resolveSubmitAccessibilityLabel, resolveVoiceAccessibilityLabel, resolveVoiceTooltipText, @@ -83,6 +85,7 @@ import { import { applyDictationTranscript, computeCanStartDictation, + resolveAlternateSendAction, resolveComposerSurfacePresentation, resolveComposerSurfaceText, resolveRetainedSelection, @@ -110,6 +113,8 @@ export interface ComposerInputSnapshot { export interface ComposerKeyPressEvent { key: string; + /** Shift, Alt, Ctrl, or Meta was held. */ + hasModifier: boolean; preventDefault: () => void; input: ComposerInputSnapshot; } @@ -210,6 +215,7 @@ type WebTextInputKeyPressEvent = NativeSyntheticEvent< metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean; + altKey?: boolean; // Web-only: present on DOM KeyboardEvent during IME composition (CJK input). isComposing?: boolean; keyCode?: number; @@ -396,8 +402,6 @@ interface DesktopKeyPressContext { onKeyPressCallback: ((event: ComposerKeyPressEvent) => boolean) | undefined; input: ComposerKeyPressEvent["input"]; submitOnEnter: boolean; - isAgentRunning: boolean; - onQueue: ((payload: MessagePayload) => void) | undefined; isSubmitDisabled: boolean; isSubmitLoading: boolean; disabled: boolean; @@ -411,30 +415,28 @@ function handleDesktopKeyPressImpl( ): void { if (isImeComposingKeyboardEvent(event.nativeEvent)) return; + const { shiftKey, metaKey, ctrlKey, altKey } = event.nativeEvent; + if (ctx.onKeyPressCallback) { const handled = ctx.onKeyPressCallback({ key: event.nativeEvent.key, + hasModifier: Boolean(shiftKey || metaKey || ctrlKey || altKey), preventDefault: () => event.preventDefault(), input: ctx.input, }); if (handled) return; } - const { shiftKey, metaKey, ctrlKey } = event.nativeEvent; - if (event.nativeEvent.key !== "Enter") return; if (!ctx.submitOnEnter) return; if (shiftKey) return; - if ((metaKey || ctrlKey) && ctx.isAgentRunning && ctx.onQueue) { - if (ctx.isSubmitDisabled || ctx.isSubmitLoading || ctx.disabled) return; - event.preventDefault(); + if (ctx.isSubmitDisabled || ctx.isSubmitLoading || ctx.disabled) return; + event.preventDefault(); + if (metaKey || ctrlKey) { ctx.handleAlternateSendAction(); return; } - - if (ctx.isSubmitDisabled || ctx.isSubmitLoading || ctx.disabled) return; - event.preventDefault(); ctx.handleDefaultSendAction(); } @@ -765,8 +767,10 @@ function SendButtonTooltip({ canPressLoadingButton, onSubmitLoadingPress, onDefaultSendAction, + onLongPressSend, isSendButtonDisabled, submitAccessibilityLabel, + submitAccessibilityHint, sendButtonCombinedStyle, isSubmitLoading, submitIcon, @@ -780,8 +784,10 @@ function SendButtonTooltip({ canPressLoadingButton: boolean; onSubmitLoadingPress: (() => void) | undefined; onDefaultSendAction: () => void; + onLongPressSend: () => void; isSendButtonDisabled: boolean; submitAccessibilityLabel: string; + submitAccessibilityHint: string | undefined; sendButtonCombinedStyle: React.ComponentProps["style"]; isSubmitLoading: boolean; submitIcon: "arrow" | "return"; @@ -796,8 +802,10 @@ function SendButtonTooltip({ ( }); }, [defaultSendBehavior, isAgentRunning, handleSendMessage, handleQueueMessage, onQueue]); + const alternateSendAction = resolveAlternateSendAction({ + defaultSendBehavior, + isAgentRunning, + onQueue, + }); + + const handleLongPressSend = useCallback(() => { + if (alternateSendAction && isNative) { + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {}); + } + handleAlternateSendAction(); + }, [alternateSendAction, handleAlternateSendAction]); + usePasteImagesEffect({ textarea: webTextArea, isConnected, @@ -1734,8 +1755,6 @@ export const MessageInput = forwardRef( selectionRef.current, ), submitOnEnter: shouldSubmitOnEnter, - isAgentRunning, - onQueue, isSubmitDisabled, isSubmitLoading, disabled, @@ -1747,8 +1766,6 @@ export const MessageInput = forwardRef( shouldHandleWebKeyPress, onKeyPressCallback, shouldSubmitOnEnter, - isAgentRunning, - onQueue, isSubmitDisabled, isSubmitLoading, disabled, @@ -1788,6 +1805,11 @@ export const MessageInput = forwardRef( isAgentRunning, t, }); + const submitAccessibilityHint = resolveSubmitAccessibilityHint({ + canPressLoadingButton, + alternateSendAction, + t, + }); const voiceButtonAccessibilityLabel = resolveVoiceAccessibilityLabel({ isRealtimeVoiceForCurrentAgent, @@ -1948,8 +1970,10 @@ export const MessageInput = forwardRef( canPressLoadingButton={canPressLoadingButton} onSubmitLoadingPress={onSubmitLoadingPress} onDefaultSendAction={handleDefaultSendAction} + onLongPressSend={handleLongPressSend} isSendButtonDisabled={isPresentedSendDisabled} submitAccessibilityLabel={submitAccessibilityLabel} + submitAccessibilityHint={submitAccessibilityHint} sendButtonCombinedStyle={sendButtonCombinedStyle} isSubmitLoading={isSubmitLoading} submitIcon={submitIcon} @@ -1967,8 +1991,10 @@ export const MessageInput = forwardRef( canPressLoadingButton, onSubmitLoadingPress, handleDefaultSendAction, + handleLongPressSend, isPresentedSendDisabled, submitAccessibilityLabel, + submitAccessibilityHint, sendButtonCombinedStyle, isSubmitLoading, submitIcon, diff --git a/packages/app/src/composer/input/labels.test.ts b/packages/app/src/composer/input/labels.test.ts index 15aa16b8e0..90c60d2781 100644 --- a/packages/app/src/composer/input/labels.test.ts +++ b/packages/app/src/composer/input/labels.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { resolveSendTooltipLabel, + resolveSubmitAccessibilityHint, resolveSubmitAccessibilityLabel, resolveVoiceAccessibilityLabel, resolveVoiceTooltipText, @@ -12,6 +13,8 @@ const translations: Record = { "composer.input.sendAndInterrupt": "Send and interrupt", "composer.input.sendAndSteer": "Send and steer", "composer.input.sendMessage": "Send message", + "composer.input.longPressToQueue": "Long press to queue", + "composer.input.longPressToSendNow": "Long press to send now", "composer.input.queue": "Queue", "composer.input.send": "Send", "composer.voice.unmuteVoiceMode": "Unmute Voice mode", @@ -26,6 +29,37 @@ const translations: Record = { const t = ((key: string) => translations[key] ?? key) as never; describe("composer input labels", () => { + it("names the long-press action in the submit hint only when one exists", () => { + expect( + resolveSubmitAccessibilityHint({ + canPressLoadingButton: false, + alternateSendAction: "queue", + t, + }), + ).toBe("Long press to queue"); + expect( + resolveSubmitAccessibilityHint({ + canPressLoadingButton: false, + alternateSendAction: "send", + t, + }), + ).toBe("Long press to send now"); + expect( + resolveSubmitAccessibilityHint({ + canPressLoadingButton: false, + alternateSendAction: null, + t, + }), + ).toBeUndefined(); + expect( + resolveSubmitAccessibilityHint({ + canPressLoadingButton: true, + alternateSendAction: "queue", + t, + }), + ).toBeUndefined(); + }); + it("resolves submit accessibility labels from translations", () => { expect( resolveSubmitAccessibilityLabel({ diff --git a/packages/app/src/composer/input/labels.ts b/packages/app/src/composer/input/labels.ts index 0820cd7ecb..50ac3eb8bf 100644 --- a/packages/app/src/composer/input/labels.ts +++ b/packages/app/src/composer/input/labels.ts @@ -1,5 +1,6 @@ import type { TFunction } from "i18next"; import type { SendBehavior } from "@/hooks/use-settings/storage"; +import type { AlternateSendAction } from "./state"; export function resolveSubmitAccessibilityLabel(input: { submitButtonAccessibilityLabel: string | undefined; @@ -22,6 +23,19 @@ export function resolveSubmitAccessibilityLabel(input: { return input.t("composer.input.sendMessage"); } +export function resolveSubmitAccessibilityHint(input: { + canPressLoadingButton: boolean; + alternateSendAction: AlternateSendAction | null; + t: TFunction; +}): string | undefined { + if (input.canPressLoadingButton || !input.alternateSendAction) return undefined; + return input.t( + input.alternateSendAction === "queue" + ? "composer.input.longPressToQueue" + : "composer.input.longPressToSendNow", + ); +} + export function resolveVoiceAccessibilityLabel(input: { isRealtimeVoiceForCurrentAgent: boolean; isMuted: boolean; diff --git a/packages/app/src/composer/input/state.test.ts b/packages/app/src/composer/input/state.test.ts index 6f2cdef74d..82f30da364 100644 --- a/packages/app/src/composer/input/state.test.ts +++ b/packages/app/src/composer/input/state.test.ts @@ -7,6 +7,7 @@ import { resolveComposerSurfaceText, resolveGlyphRailOffset, resolveRetainedSelection, + resolveAlternateSendAction, runAlternateSendAction, runDefaultSendAction, runMessageInputKeyboardAction, @@ -276,6 +277,47 @@ describe("composer send behavior", () => { expect(defaultAction.calls).toEqual(["queue"]); expect(alternateAction.calls).toEqual(["send"]); }); + + it("sends with the alternate action while no turn is running", () => { + for (const defaultSendBehavior of ["steer", "interrupt", "queue"] as const) { + const alternateAction = actions(); + runAlternateSendAction({ + defaultSendBehavior, + isAgentRunning: false, + onQueue: alternateAction.onQueue, + handleSendMessage: alternateAction.handleSendMessage, + handleQueueMessage: alternateAction.handleQueueMessage, + }); + expect(alternateAction.calls).toEqual(["send"]); + } + }); + + it("offers an alternate action only while a turn runs and the composer can queue", () => { + const onQueue = () => undefined; + expect( + resolveAlternateSendAction({ defaultSendBehavior: "steer", isAgentRunning: true, onQueue }), + ).toBe("queue"); + expect( + resolveAlternateSendAction({ + defaultSendBehavior: "interrupt", + isAgentRunning: true, + onQueue, + }), + ).toBe("queue"); + expect( + resolveAlternateSendAction({ defaultSendBehavior: "queue", isAgentRunning: true, onQueue }), + ).toBe("send"); + expect( + resolveAlternateSendAction({ defaultSendBehavior: "steer", isAgentRunning: false, onQueue }), + ).toBeNull(); + expect( + resolveAlternateSendAction({ + defaultSendBehavior: "queue", + isAgentRunning: true, + onQueue: undefined, + }), + ).toBeNull(); + }); }); describe("stopRealtimeVoice", () => { diff --git a/packages/app/src/composer/input/state.ts b/packages/app/src/composer/input/state.ts index 698965b5ac..9cf6279482 100644 --- a/packages/app/src/composer/input/state.ts +++ b/packages/app/src/composer/input/state.ts @@ -125,14 +125,30 @@ export function runDefaultSendAction(ctx: SendActionContext): void { ctx.handleSendMessage(); } +export type AlternateSendAction = "queue" | "send"; + +/** + * What Mod+Enter and a long-press on Send do instead of the default send. Null while + * no turn is running, where both fall back to the default send. + */ +export function resolveAlternateSendAction( + ctx: Pick, +): AlternateSendAction | null { + if (!ctx.isAgentRunning || !ctx.onQueue) return null; + return ctx.defaultSendBehavior === "queue" ? "send" : "queue"; +} + export function runAlternateSendAction(ctx: SendActionContext): void { - if (ctx.defaultSendBehavior === "queue") { + const action = resolveAlternateSendAction(ctx); + if (action === "queue") { + ctx.handleQueueMessage(); + return; + } + if (action === "send") { ctx.handleSendMessage(); return; } - if (ctx.isAgentRunning && ctx.onQueue) { - ctx.handleQueueMessage(); - } + runDefaultSendAction(ctx); } export function runMessageInputKeyboardAction( diff --git a/packages/app/src/composer/recall.test.ts b/packages/app/src/composer/recall.test.ts new file mode 100644 index 0000000000..a1c6b618fc --- /dev/null +++ b/packages/app/src/composer/recall.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import type { ComposerKeyPressEvent } from "@/composer/input/input"; +import { ComposerRecall } from "@/composer/recall"; + +const SENT = ["add tests", "fix the auth bug"]; + +function press( + key: string, + text: string, + options: { caret?: number; selectionEnd?: number; hasModifier?: boolean } = {}, +): ComposerKeyPressEvent { + const caret = options.caret ?? text.length; + return { + key, + hasModifier: options.hasModifier ?? false, + preventDefault: () => undefined, + input: { text, selection: { start: caret, end: options.selectionEnd ?? caret } }, + }; +} + +/** Drives recall the way the composer does: each result becomes the composer text, caret at the end. */ +function composerWithRecall(sent: readonly string[] = SENT) { + const recall = new ComposerRecall(); + let text = ""; + return { + recall, + get text() { + return text; + }, + type(next: string) { + text = next; + }, + press(key: string, options: { caret?: number; hasModifier?: boolean } = {}) { + const result = recall.handleKeyPress(press(key, text, options), () => sent); + if (result !== null) text = result; + return result; + }, + }; +} + +describe("ComposerRecall", () => { + it("walks back through sent messages and forward to an empty composer", () => { + const composer = composerWithRecall(); + + expect(composer.press("ArrowUp")).toBe("add tests"); + expect(composer.press("ArrowUp")).toBe("fix the auth bug"); + expect(composer.press("ArrowDown")).toBe("add tests"); + expect(composer.press("ArrowDown")).toBe(""); + expect(composer.recall.isShowing(composer.text)).toBe(false); + }); + + it("leaves the caret to the input past the oldest message", () => { + const composer = composerWithRecall(); + composer.press("ArrowUp"); + composer.press("ArrowUp"); + + expect(composer.press("ArrowUp")).toBeNull(); + expect(composer.text).toBe("fix the auth bug"); + }); + + it("only starts from an empty composer", () => { + const composer = composerWithRecall(); + composer.type("half written"); + + expect(composer.press("ArrowUp")).toBeNull(); + expect(composer.press("ArrowDown")).toBeNull(); + }); + + it("ignores Down from an empty composer and Up with no history", () => { + expect(composerWithRecall().press("ArrowDown")).toBeNull(); + expect(composerWithRecall([]).press("ArrowUp")).toBeNull(); + }); + + it("hands the arrows back to the caret once the recalled message is edited", () => { + const composer = composerWithRecall(); + composer.press("ArrowUp"); + composer.type("add tests for login"); + + expect(composer.recall.isShowing(composer.text)).toBe(false); + expect(composer.press("ArrowUp")).toBeNull(); + expect(composer.press("ArrowDown")).toBeNull(); + }); + + it("moves between lines of a multi-line message before changing message", () => { + const composer = composerWithRecall(["first line\nsecond line", "older"]); + composer.press("ArrowUp"); + + expect(composer.press("ArrowUp", { caret: "first line\nsec".length })).toBeNull(); + expect(composer.press("ArrowUp", { caret: 3 })).toBe("older"); + expect(composer.press("ArrowDown")).toBe("first line\nsecond line"); + expect(composer.press("ArrowDown", { caret: 3 })).toBeNull(); + expect(composer.press("ArrowDown")).toBe(""); + }); + + it("ignores modified arrows, other keys, and a selection", () => { + const composer = composerWithRecall(); + + expect(composer.press("ArrowUp", { hasModifier: true })).toBeNull(); + expect(composer.press("Enter")).toBeNull(); + composer.press("ArrowUp"); + const recall = composer.recall; + expect( + recall.handleKeyPress( + press("ArrowUp", "add tests", { caret: 0, selectionEnd: 3 }), + () => SENT, + ), + ).toBeNull(); + }); +}); diff --git a/packages/app/src/composer/recall.ts b/packages/app/src/composer/recall.ts new file mode 100644 index 0000000000..24984d39e1 --- /dev/null +++ b/packages/app/src/composer/recall.ts @@ -0,0 +1,68 @@ +import type { ComposerKeyPressEvent } from "@/composer/input/input"; + +interface RecalledMessage { + index: number; + text: string; +} + +/** + * Walks the messages already sent to an agent from an empty composer, like a + * shell's Up/Down history. A message stays recalled only while the composer + * still holds its exact text; the first edit hands the arrows back to the caret. + */ +export class ComposerRecall { + private recalled: RecalledMessage | null = null; + + /** True while the composer shows a recalled message the user has not edited. */ + isShowing(text: string): boolean { + return this.recalled !== null && this.recalled.text === text; + } + + /** + * Returns the text the composer should show for this key press, or null to + * leave the key to the input. `readSentMessages` is newest first and is only + * read for keys recall acts on. + */ + handleKeyPress( + event: ComposerKeyPressEvent, + readSentMessages: () => readonly string[], + ): string | null { + if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return null; + if (event.hasModifier) return null; + const { text, selection } = event.input; + if (selection.start !== selection.end) return null; + + const recalled = this.isShowing(text) ? this.recalled : null; + if (!recalled) { + this.recalled = null; + if (event.key !== "ArrowUp" || text.length > 0) return null; + return this.show(readSentMessages(), 0); + } + + if (event.key === "ArrowUp") { + if (!isCaretOnFirstLine(text, selection.start)) return null; + return this.show(readSentMessages(), recalled.index + 1); + } + if (!isCaretOnLastLine(text, selection.start)) return null; + if (recalled.index === 0) { + this.recalled = null; + return ""; + } + return this.show(readSentMessages(), recalled.index - 1); + } + + private show(messages: readonly string[], index: number): string | null { + const text = messages[index]; + if (text === undefined) return null; + this.recalled = { index, text }; + return text; + } +} + +function isCaretOnFirstLine(text: string, caret: number): boolean { + return !text.slice(0, caret).includes("\n"); +} + +function isCaretOnLastLine(text: string, caret: number): boolean { + return !text.slice(caret).includes("\n"); +} diff --git a/packages/app/src/composer/tracks-panel.browser.test.tsx b/packages/app/src/composer/tracks-panel.browser.test.tsx index 9ebc92ed20..b02a1e5e28 100644 --- a/packages/app/src/composer/tracks-panel.browser.test.tsx +++ b/packages/app/src/composer/tracks-panel.browser.test.tsx @@ -162,12 +162,34 @@ describe("composer track panel", () => { ); openPanel(); + expect(document.querySelector("button button")).toBeNull(); + expect(row("row")?.getAttribute("role")).toBe("menuitem"); click(row("row-archive") as Element); expect(onAction).toHaveBeenCalledTimes(1); expect(onPress).not.toHaveBeenCalled(); expect(isPanelOpen()).toBe(true); }); + + it.each(["Enter", " "])("selects a track row with %j", (key) => { + const select = vi.fn(); + mount( + + + Subagent one + + , + ); + openPanel(); + const target = row("row") as HTMLElement; + act(() => { + target.focus(); + target.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true })); + target.dispatchEvent(new KeyboardEvent("keyup", { key, bubbles: true })); + }); + expect(select).toHaveBeenCalledTimes(1); + expect(isPanelOpen()).toBe(false); + }); }); describe("composer track pill status mark", () => { diff --git a/packages/app/src/composer/tracks.tsx b/packages/app/src/composer/tracks.tsx index 3a4541d285..4e06266058 100644 --- a/packages/app/src/composer/tracks.tsx +++ b/packages/app/src/composer/tracks.tsx @@ -233,6 +233,11 @@ export function ComposerTrackRow({ [closeOnSelect, onPress, selectItem], ); + const itemDataSet = useMemo( + () => ({ menuItem: "true", menuDisabled: disabled ? "true" : "false" }), + [disabled], + ); + const renderRow = useCallback( (active: boolean) => ( @@ -253,7 +258,8 @@ export function ComposerTrackRow({ return ( Promise; + listTargets?: (input?: { workspacePath?: string }) => Promise; openTarget?: (input: DesktopEditorOpenTargetInput) => Promise; } diff --git a/packages/app/src/git/pull-request-panel/checks-ring.tsx b/packages/app/src/git/pull-request-panel/checks-ring.tsx index d5ae3e6226..75427adabc 100644 --- a/packages/app/src/git/pull-request-panel/checks-ring.tsx +++ b/packages/app/src/git/pull-request-panel/checks-ring.tsx @@ -1,3 +1,4 @@ +import { isNative } from "@/constants/platform"; import Svg, { Circle } from "react-native-svg"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import type { Theme } from "@/styles/theme"; @@ -75,8 +76,9 @@ function ChecksRingSvg({ height={size} viewBox={`0 0 ${VIEW_BOX} ${VIEW_BOX}`} style={ringStyles.svg} - accessibilityElementsHidden - importantForAccessibility="no-hide-descendants" + {...(isNative + ? { accessibilityElementsHidden: true, importantForAccessibility: "no-hide-descendants" } + : { "aria-hidden": true })} > { + let root: Root | null = null; + afterEach(() => { + act(() => root?.unmount()); + root = null; + vi.restoreAllMocks(); + }); + + it("demands the host directory while mounted and releases it on unmount", () => { + const release = vi.fn(); + const acquire = vi + .spyOn(getHostRuntimeStore(), "acquireDirectoryDemand") + .mockImplementation(() => release); + const container = document.createElement("div"); + root = createRoot(container); + + act(() => root?.render()); + expect(acquire).toHaveBeenCalledTimes(1); + expect(acquire).toHaveBeenCalledWith("srv_a"); + expect(release).not.toHaveBeenCalled(); + + act(() => root?.render()); + expect(release).toHaveBeenCalledTimes(1); + expect(acquire).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/app/src/hooks/use-directory-demand.ts b/packages/app/src/hooks/use-directory-demand.ts new file mode 100644 index 0000000000..f4d2953ab0 --- /dev/null +++ b/packages/app/src/hooks/use-directory-demand.ts @@ -0,0 +1,16 @@ +import { useEffect } from "react"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; + +/** + * Keeps a host's directory demanded while the caller is mounted. + * + * The sidebar only demands the directory while its panel is active, so a compact app that + * restores straight into a workspace or agent route has nothing else asking the host for + * workspaces and agents. Without demand the route waits on hydration that never starts. + */ +export function useDirectoryDemand(serverId: string | null | undefined): void { + useEffect(() => { + if (!serverId) return; + return getHostRuntimeStore().acquireDirectoryDemand(serverId); + }, [serverId]); +} diff --git a/packages/app/src/hooks/use-preferred-editor.test.ts b/packages/app/src/hooks/use-preferred-editor.test.ts index 49a3c4c18f..120be39fb1 100644 --- a/packages/app/src/hooks/use-preferred-editor.test.ts +++ b/packages/app/src/hooks/use-preferred-editor.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; -import { resolvePreferredEditorId } from "./use-preferred-editor"; +import { QueryClient } from "@tanstack/react-query"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { createPreferredEditorPreference, resolvePreferredEditorId } from "./use-preferred-editor"; describe("resolvePreferredEditorId", () => { it("keeps the stored editor when it is still available", () => { @@ -30,3 +32,78 @@ describe("resolvePreferredEditorId", () => { expect(resolvePreferredEditorId([], "cursor")).toBeNull(); }); }); + +describe("project editor preferences", () => { + it("changing one project leaves another project's cached and persisted choice unchanged", async () => { + await AsyncStorage.clear(); + await AsyncStorage.setItem("@paseo:preferred-editor", "vscode"); + const queryClient = new QueryClient(); + const projectA = createPreferredEditorPreference({ + scope: { serverId: "host", projectId: "project-a" }, + queryClient, + storage: AsyncStorage, + }); + const projectB = createPreferredEditorPreference({ + scope: { serverId: "host", projectId: "project-b" }, + queryClient, + storage: AsyncStorage, + }); + expect(await queryClient.fetchQuery(projectA.queryOptions)).toBe("vscode"); + expect(await queryClient.fetchQuery(projectB.queryOptions)).toBe("vscode"); + await projectA.update("cursor"); + expect(await queryClient.fetchQuery(projectA.queryOptions)).toBe("cursor"); + expect(await queryClient.fetchQuery(projectB.queryOptions)).toBe("vscode"); + queryClient.clear(); + expect(await queryClient.fetchQuery(projectA.queryOptions)).toBe("cursor"); + expect(await queryClient.fetchQuery(projectB.queryOptions)).toBe("vscode"); + expect(await AsyncStorage.getItem("@paseo:preferred-editor")).toBe("vscode"); + queryClient.clear(); + }); + it("shares a choice across workspaces of the same project while isolating hosts", async () => { + await AsyncStorage.clear(); + const queryClient = new QueryClient(); + const root = createPreferredEditorPreference({ + scope: { serverId: "host-a", projectId: "project" }, + queryClient, + storage: AsyncStorage, + }); + const worktree = createPreferredEditorPreference({ + scope: { serverId: "host-a", projectId: "project" }, + queryClient, + storage: AsyncStorage, + }); + const otherHost = createPreferredEditorPreference({ + scope: { serverId: "host-b", projectId: "project" }, + queryClient, + storage: AsyncStorage, + }); + await root.update("zed"); + expect(await queryClient.fetchQuery(worktree.queryOptions)).toBe("zed"); + expect(await queryClient.fetchQuery(otherHost.queryOptions)).toBeNull(); + queryClient.clear(); + expect(await queryClient.fetchQuery(worktree.queryOptions)).toBe("zed"); + expect(await queryClient.fetchQuery(otherHost.queryOptions)).toBeNull(); + queryClient.clear(); + }); + + it("preserves the previous choice when saving fails", async () => { + await AsyncStorage.clear(); + await AsyncStorage.setItem("@paseo:preferred-editor", "vscode"); + const queryClient = new QueryClient(); + const preference = createPreferredEditorPreference({ + scope: { serverId: "host", projectId: "project" }, + queryClient, + storage: { + getItem: AsyncStorage.getItem, + removeItem: AsyncStorage.removeItem, + async setItem() { + throw new Error("Storage is full"); + }, + }, + }); + expect(await queryClient.fetchQuery(preference.queryOptions)).toBe("vscode"); + await expect(preference.update("cursor")).rejects.toThrow("Storage is full"); + expect(await queryClient.fetchQuery(preference.queryOptions)).toBe("vscode"); + queryClient.clear(); + }); +}); diff --git a/packages/app/src/hooks/use-preferred-editor.ts b/packages/app/src/hooks/use-preferred-editor.ts index b216185182..234ff4a0f8 100644 --- a/packages/app/src/hooks/use-preferred-editor.ts +++ b/packages/app/src/hooks/use-preferred-editor.ts @@ -1,16 +1,57 @@ -import { useCallback } from "react"; +import { useMemo } from "react"; import AsyncStorage from "@react-native-async-storage/async-storage"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { QueryClient, useQuery, useQueryClient } from "@tanstack/react-query"; import { z } from "zod"; -import { readValidatedString } from "@/storage/validated-storage"; +import { readValidatedString, type ValidatedStorage } from "@/storage/validated-storage"; type EditorTargetId = string; const PREFERRED_EDITOR_STORAGE_KEY = "@paseo:preferred-editor"; -const PREFERRED_EDITOR_QUERY_KEY = ["preferred-editor"]; -async function loadPreferredEditor(): Promise { - return readValidatedString(AsyncStorage, PREFERRED_EDITOR_STORAGE_KEY, z.string().trim().min(1)); +interface EditorPreferenceStorage extends ValidatedStorage { + setItem(key: string, value: string): Promise; +} + +interface ProjectEditorScope { + serverId: string; + projectId: string | null; +} + +export function createPreferredEditorPreference({ + scope, + queryClient, + storage, +}: { + scope: ProjectEditorScope; + queryClient: QueryClient; + storage: EditorPreferenceStorage; +}) { + const queryKey = ["preferred-editor", scope.serverId, scope.projectId]; + const storageKey = `${PREFERRED_EDITOR_STORAGE_KEY}:${JSON.stringify([scope.serverId, scope.projectId])}`; + async function load() { + if (scope.projectId === null) return null; + const projectEditor = await readValidatedString(storage, storageKey, z.string().trim().min(1)); + if (projectEditor !== null) return projectEditor; + return readValidatedString(storage, PREFERRED_EDITOR_STORAGE_KEY, z.string().trim().min(1)); + } + return { + queryOptions: { + queryKey, + queryFn: load, + enabled: scope.projectId !== null, + staleTime: Infinity, + gcTime: Infinity, + }, + async update(editorId: EditorTargetId | null) { + if (scope.projectId === null) return; + if (editorId) { + await storage.setItem(storageKey, editorId); + } else { + await storage.removeItem(storageKey); + } + queryClient.setQueryData(queryKey, await load()); + }, + }; } export function resolvePreferredEditorId( @@ -29,30 +70,24 @@ export function resolvePreferredEditorId( return availableEditorIds[0] ?? null; } -export function usePreferredEditor() { +export function usePreferredEditor(scope: ProjectEditorScope) { const queryClient = useQueryClient(); - const { data, isPending } = useQuery({ - queryKey: PREFERRED_EDITOR_QUERY_KEY, - queryFn: loadPreferredEditor, - staleTime: Infinity, - gcTime: Infinity, - }); - - const updatePreferredEditor = useCallback( - async (editorId: EditorTargetId | null) => { - queryClient.setQueryData(PREFERRED_EDITOR_QUERY_KEY, editorId); - if (editorId) { - await AsyncStorage.setItem(PREFERRED_EDITOR_STORAGE_KEY, editorId); - return; - } - await AsyncStorage.removeItem(PREFERRED_EDITOR_STORAGE_KEY); - }, - [queryClient], + const { serverId, projectId } = scope; + const preference = useMemo( + () => + createPreferredEditorPreference({ + scope: { serverId, projectId }, + queryClient, + storage: AsyncStorage, + }), + [queryClient, serverId, projectId], ); + const { data, isPending } = useQuery(preference.queryOptions); + const isLoading = projectId !== null && isPending; return { - preferredEditorId: isPending ? undefined : (data ?? null), - isLoading: isPending, - updatePreferredEditor, + preferredEditorId: isLoading ? undefined : (data ?? null), + isLoading, + updatePreferredEditor: preference.update, }; } diff --git a/packages/app/src/hooks/use-settings/index.ts b/packages/app/src/hooks/use-settings/index.ts index 66a8e767b1..9f6f22fb23 100644 --- a/packages/app/src/hooks/use-settings/index.ts +++ b/packages/app/src/hooks/use-settings/index.ts @@ -42,6 +42,7 @@ import { saveAppSettings as saveAppSettingsPure, type AppSettings, type AppSettingsUpdate, + type ChatDisplay, type OpenInSidePanePreferences, type PullRequestOpenLocation, type DesktopSettingsBridge, @@ -84,6 +85,7 @@ export { export type { AppSettings, AppSettingsUpdate, + ChatDisplay, AppLanguage, OpenInSidePanePreferences, PullRequestOpenLocation, diff --git a/packages/app/src/hooks/use-settings/storage.test.ts b/packages/app/src/hooks/use-settings/storage.test.ts index dadb912a24..fe2ffdd2a0 100644 --- a/packages/app/src/hooks/use-settings/storage.test.ts +++ b/packages/app/src/hooks/use-settings/storage.test.ts @@ -185,6 +185,7 @@ describe("loadAppSettingsFromStorage", () => { expect(result).toEqual(DEFAULT_CLIENT_SETTINGS); expect(DEFAULT_CLIENT_SETTINGS.language).toBe("system"); + expect(DEFAULT_CLIENT_SETTINGS.chatDisplay).toBe("collapseTurns"); expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null")).toEqual( DEFAULT_CLIENT_SETTINGS, ); @@ -454,6 +455,7 @@ describe("loadAppSettingsFromStorage", () => { ...DEFAULT_CLIENT_SETTINGS, theme: "dark", contentFontSize: DEFAULT_UI_BASE_FONT_SIZE, + chatDisplay: "full", }); expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null")).toEqual({ manageBuiltInDaemon: false, @@ -592,6 +594,7 @@ describe("loadSettingsFromStorage", () => { ...DEFAULT_APP_SETTINGS, theme: "light", contentFontSize: DEFAULT_UI_BASE_FONT_SIZE, + chatDisplay: "full", }); }); @@ -636,6 +639,7 @@ describe("loadSettingsFromStorage", () => { ...DEFAULT_APP_SETTINGS, theme: "light", contentFontSize: DEFAULT_UI_BASE_FONT_SIZE, + chatDisplay: "full", manageBuiltInDaemon: false, releaseChannel: "beta", }); @@ -657,6 +661,7 @@ describe("loadSettingsFromStorage", () => { ...DEFAULT_APP_SETTINGS, theme: "light", contentFontSize: DEFAULT_UI_BASE_FONT_SIZE, + chatDisplay: "full", }); }); }); @@ -732,7 +737,7 @@ describe("saveAppSettings", () => { ...DEFAULT_CLIENT_SETTINGS, theme: "light", contentFontSize: DEFAULT_UI_BASE_FONT_SIZE, - toolCallDetailLevel: "overview", + chatDisplay: "collapseToolCalls", }); }); @@ -787,27 +792,45 @@ describe("appearance settings", () => { expect(result.contentFontSize).toBe(DEFAULT_UI_BASE_FONT_SIZE); expect(result.codeFontSize).toBe(DEFAULT_CODE_FONT_SIZE); expect(result.syntaxTheme).toBe("one"); - expect(result.toolCallDetailLevel).toBe("detailed"); + expect(result.chatDisplay).toBe("full"); }); - it("migrates the enabled compact tool call preference to overview", async () => { + it("migrates the enabled compact tool call preference to collapsed tool calls", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ compactToolCalls: true }), }), }); - expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("overview"); + expect((await loadAppSettingsFromStorage(deps)).chatDisplay).toBe("collapseToolCalls"); }); - it("clears settings with an unrecognized tool call detail level", async () => { + it.each([ + ["overview", "collapseToolCalls"], + ["concise", "collapseToolCalls"], + ["detailed", "full"], + ["unknown", "full"], + ])("reads the %s tool call detail level as the %s chat display", async (legacy, display) => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ - [APP_SETTINGS_KEY]: JSON.stringify({ toolCallDetailLevel: "unknown" }), + [APP_SETTINGS_KEY]: JSON.stringify({ toolCallDetailLevel: legacy }), }), }); - expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("detailed"); + expect((await loadAppSettingsFromStorage(deps)).chatDisplay).toBe(display); + }); + + it("keeps a stored chat display over the tool call detail level it replaced", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ + chatDisplay: "collapseTurns", + toolCallDetailLevel: "detailed", + }), + }), + }); + + expect((await loadAppSettingsFromStorage(deps)).chatDisplay).toBe("collapseTurns"); }); it("migrates a switched-off checks row item to the hidden checks display", async () => { diff --git a/packages/app/src/hooks/use-settings/storage.ts b/packages/app/src/hooks/use-settings/storage.ts index 4fe08bb6d5..3a5be63441 100644 --- a/packages/app/src/hooks/use-settings/storage.ts +++ b/packages/app/src/hooks/use-settings/storage.ts @@ -40,7 +40,8 @@ export type WorkspaceTitleSource = "title" | "branch"; export type PullRequestOpenLocation = "main" | "side" | "explorer"; /** What a sidebar workspace row shows in the space to the right of its title. */ export type SidebarWorkspaceTrailing = "diff" | "timestamp" | "none"; -export type ToolCallDetailLevel = "overview" | "detailed"; +/** How much of an agent's work the chat shows between the user's message and the reply. */ +export type ChatDisplay = "full" | "collapseToolCalls" | "collapseTurns"; const ThemePreferenceSchema = z.enum([ ...THEME_OPTIONS.map((option) => option.name), @@ -100,7 +101,7 @@ export interface AppSettings { /** How usage reads and which windows the sidebar summary shows. */ usage: UsagePreferences; autoExpandReasoning: boolean; - toolCallDetailLevel: ToolCallDetailLevel; + chatDisplay: ChatDisplay; chatOutlineEnabled: boolean; vimKeybindings: boolean; filePanelAssociations: Record; @@ -156,7 +157,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = { sidebarFooterItems: [], usage: DEFAULT_USAGE_PREFERENCES, autoExpandReasoning: false, - toolCallDetailLevel: "detailed", + chatDisplay: "collapseTurns", chatOutlineEnabled: true, vimKeybindings: false, filePanelAssociations: {}, @@ -199,6 +200,7 @@ const SidebarRowItemsSchema = z type StoredAppSettingsFallback = AppSettings & { uiFontSize?: number; compactToolCalls?: boolean; + toolCallDetailLevel?: "overview" | "detailed"; manageBuiltInDaemon?: boolean; releaseChannel?: ReleaseChannel; needsWrite: boolean; @@ -251,11 +253,13 @@ const StoredAppSettingsSchema = z sidebarFooterItems: z.array(z.object({ key: z.string(), visible: z.boolean() })).catch([]), usage: UsagePreferencesSchema, autoExpandReasoning: z.boolean().catch(false), + chatDisplay: z.enum(["full", "collapseToolCalls", "collapseTurns"]).optional().catch("full"), + // COMPAT(toolCallDetailLevel): replaced by chatDisplay in v0.13, remove after 2027-04-24. toolCallDetailLevel: z .enum(["overview", "detailed"]) .or(z.literal("concise").transform(() => "overview" as const)) .optional() - .catch("detailed"), + .catch(undefined), // COMPAT(compactToolCalls): migrated in v0.1.105, remove after 2027-01-12. compactToolCalls: z.boolean().optional().catch(undefined), chatOutlineEnabled: z.boolean().catch(true), @@ -305,8 +309,10 @@ const StoredAppSettingsSchema = z (isChecksHiddenByLegacyRowItem(stored.sidebarRowItems) ? "none" : DEFAULT_SIDEBAR_CHECKS_DISPLAY); - const toolCallDetailLevel = - stored.toolCallDetailLevel ?? (stored.compactToolCalls ? "overview" : "detailed"); + const groupsToolCalls = stored.toolCallDetailLevel + ? stored.toolCallDetailLevel === "overview" + : stored.compactToolCalls === true; + const chatDisplay = stored.chatDisplay ?? (groupsToolCalls ? "collapseToolCalls" : "full"); return { ...stored, openInSidePane, @@ -321,7 +327,7 @@ const StoredAppSettingsSchema = z stored.sidebarRowItems.services ?? (stored.sidebarRowItems.scripts === false ? false : DEFAULT_SIDEBAR_ROW_ITEMS.services), }, - toolCallDetailLevel, + chatDisplay, needsWrite, }; }) @@ -449,6 +455,7 @@ export function normalizeAppSettings(value: unknown): AppSettings { manageBuiltInDaemon: _manageBuiltInDaemon, releaseChannel: _releaseChannel, compactToolCalls: _compactToolCalls, + toolCallDetailLevel: _toolCallDetailLevel, uiFontSize: _uiFontSize, ...settings } = StoredAppSettingsSchema.parse(value); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index c765f3b835..108fad41d4 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -133,6 +133,8 @@ export const ar: TranslationResources = { sendAndInterrupt: "إرسال ومقاطعة", sendAndSteer: "إرسال وتوجيه", sendMessage: "أرسل رسالة", + longPressToQueue: "اضغط مطولًا لإضافتها إلى الطابور", + longPressToSendNow: "اضغط مطولًا للإرسال الآن", queue: "طابور", send: "يرسل", }, @@ -1908,6 +1910,13 @@ export const ar: TranslationResources = { backdrop: "خلفية القائمة", }, subagents: { + parent: { + backToParent: "العودة إلى الوكيل الأصلي", + backTo: "العودة إلى {{label}}", + parent: "الأصل", + parentAgent: "الوكيل الأصلي", + openParent: "فتح الوكيل الأصلي", + }, title: "الوكلاء الفرعيون", pillLabelOne: "وكيل فرعي واحد", pillLabelMany: "{{count}} وكلاء فرعيين", @@ -2008,6 +2017,12 @@ export const ar: TranslationResources = { input: "مدخل", output: "الإخراج", }, + turnSummary: { + messages: { + one: "كتب {{count}} رسالة", + other: "كتب {{count}} رسائل", + }, + }, toolCallGroup: { editedFiles: { one: "حرّر {{count}} ملفًا", @@ -2245,6 +2260,7 @@ export const ar: TranslationResources = { connections: "اتصالات", agents: "Agents", metadata: "البيانات الوصفية", + voice: "الصوت", workspaces: "Workspaces", providers: "مقدمي الخدمات", usage: "الاستخدام", @@ -2267,6 +2283,69 @@ export const ar: TranslationResources = { docs: "الوثائق", saveError: "تعذر تحديث إنشاء البيانات الوصفية", }, + voice: { + offline: "اتصل بهذا المضيف لضبط الصوت.", + updateHost: "حدّث المضيف لضبط الصوت.", + saveError: "تعذّر تحديث إعدادات الصوت", + provider: "المزوّد", + providers: { + local: "محلي", + openai: "OpenAI", + }, + voice: "الصوت", + save: "حفظ", + retry: "إعادة المحاولة", + cancel: "إلغاء", + model: "النموذج", + language: "اللغة", + languagePlaceholder: "اكتشاف تلقائي", + setBy: "محدد بواسطة {{name}}", + unknownProvider: "حدّث التطبيق لإعداد {{provider}}", + sites: { + dictationStt: "تحويل الكلام إلى نص", + voiceModeStt: "الاستماع", + voiceModeTts: "التحدث", + }, + dictation: { + title: "الإملاء", + enabled: "الإملاء", + enabledHint: "تحدّث في مربع الرسالة بدلًا من الكتابة", + }, + voiceMode: { + title: "وضع الصوت", + info: "يحوّل وضع الصوت كلامك إلى نص ويقرأ ردود الوكيل بصوت عالٍ.", + enabled: "وضع الصوت", + enabledHint: "تحدّث مع وكلائك دون استخدام يديك", + }, + openai: { + apiKey: "مفتاح API", + apiKeyHint: "يُحفظ على المضيف ولا يُعرض مرة أخرى.", + keyPlaceholder: "sk-...", + savedKeyPlaceholder: "محفوظ", + missingKey: "مفتاح API مفقود", + baseUrl: "عنوان URL الأساسي", + baseUrlHint: "للخوادم المتوافقة مع OpenAI. اتركه فارغًا لاستخدام OpenAI.", + }, + models: { + loading: "جارٍ تحميل النماذج...", + loadError: "تعذّر تحميل النماذج المحلية", + none: "لا توجد نماذج متاحة", + downloaded: "منزّل", + inUse: "قيد الاستخدام", + recommended: "موصى به", + download: "تنزيل", + downloadAction: "تنزيل {{label}}", + downloading: "جارٍ التنزيل...", + downloadingPercent: "جارٍ التنزيل {{percent}}%", + extracting: "جارٍ الاستخراج...", + delete: "حذف", + deleteAction: "حذف {{label}}", + deleteTitle: "حذف {{label}}؟", + deleteMessage: "سيؤدي هذا إلى تحرير {{size}}. يمكنك تنزيله مرة أخرى لاحقًا.", + languages: "لغات {{label}}", + moreLanguages: "{{languages}} و{{count}} أخرى", + }, + }, general: { title: "عام", sending: "الإرسال", @@ -2312,12 +2391,13 @@ export const ar: TranslationResources = { label: "عرض التفكير دائماً", description: "إظهار تفكير الوكيل وخطوات الاستدلال بشكل كامل بشكل افتراضي", }, - toolCallDetail: { - label: "عرض استدعاءات الأدوات", - description: "كيفية ظهور استدعاءات الأدوات في المخطط الزمني", + chatDisplay: { + label: "عرض المحادثة", + description: "مقدار عمل الوكيل الذي تعرضه المحادثة", options: { - overview: "ملخص", - detailed: "التفاصيل الكاملة", + full: "كامل", + collapseToolCalls: "طيّ استدعاءات الأدوات", + collapseTurns: "طيّ الأدوار", }, }, language: { @@ -2684,6 +2764,11 @@ export const ar: TranslationResources = { }, agents: { unavailable: "Connect to this host to manage agents", + idleRuntimeCleanup: { + title: "تحرير الوكلاء الخاملين", + hint: "تحرير الذاكرة بعد ساعة من الخمول.", + info: "يوقف عملية الوكيل مع الاحتفاظ بمحادثته. أرسل رسالة لاستئنافها.\n\nيمنع العمل النشط والإشعارات المعلّقة وفحوصات أمان المزوّد عملية التنظيف.", + }, }, workspaces: { unavailable: "Connect to this host to manage workspaces", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 351fb33ac3..16bddd9cf9 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -129,6 +129,8 @@ export const en = { sendAndInterrupt: "Send and interrupt", sendAndSteer: "Send and steer", sendMessage: "Send message", + longPressToQueue: "Long press to queue", + longPressToSendNow: "Long press to send now", queue: "Queue", send: "Send", }, @@ -1933,6 +1935,13 @@ export const en = { backdrop: "Menu backdrop", }, subagents: { + parent: { + backToParent: "Back to parent", + backTo: "Back to {{label}}", + parent: "Parent", + parentAgent: "Parent agent", + openParent: "Open parent", + }, title: "Subagents", pillLabelOne: "1 subagent", pillLabelMany: "{{count}} subagents", @@ -2033,6 +2042,12 @@ export const en = { input: "Input", output: "Output", }, + turnSummary: { + messages: { + one: "wrote {{count}} message", + other: "wrote {{count}} messages", + }, + }, toolCallGroup: { editedFiles: { one: "edited {{count}} file", @@ -2272,6 +2287,7 @@ export const en = { connections: "Connections", agents: "Agents", metadata: "Metadata", + voice: "Voice", workspaces: "Workspaces", providers: "Providers", usage: "Usage", @@ -2356,6 +2372,69 @@ export const en = { docs: "Docs", saveError: "Unable to update metadata generation", }, + voice: { + offline: "Connect to this host to configure voice.", + updateHost: "Update the host to configure voice.", + saveError: "Unable to update voice settings", + provider: "Provider", + providers: { + local: "Local", + openai: "OpenAI", + }, + voice: "Voice", + save: "Save", + retry: "Retry", + cancel: "Cancel", + model: "Model", + language: "Language", + languagePlaceholder: "Auto-detect", + setBy: "Set by {{name}}", + unknownProvider: "Update the app to configure {{provider}}", + sites: { + dictationStt: "Speech-to-text", + voiceModeStt: "Listening", + voiceModeTts: "Speaking", + }, + dictation: { + title: "Dictation", + enabled: "Dictation", + enabledHint: "Speak into the message box instead of typing", + }, + voiceMode: { + title: "Voice mode", + info: "Voice mode transcribes what you say and reads agent replies aloud.", + enabled: "Voice mode", + enabledHint: "Talk with your agents hands-free", + }, + openai: { + apiKey: "API key", + apiKeyHint: "Saved on the host and never shown again.", + keyPlaceholder: "sk-...", + savedKeyPlaceholder: "Saved", + missingKey: "API key missing", + baseUrl: "Base URL", + baseUrlHint: "For OpenAI-compatible servers. Leave empty to use OpenAI.", + }, + models: { + loading: "Loading models...", + loadError: "Couldn't load local models", + none: "No models available", + downloaded: "Downloaded", + inUse: "In use", + recommended: "Recommended", + download: "Download", + downloadAction: "Download {{label}}", + downloading: "Downloading...", + downloadingPercent: "Downloading {{percent}}%", + extracting: "Extracting...", + delete: "Delete", + deleteAction: "Delete {{label}}", + deleteTitle: "Delete {{label}}?", + deleteMessage: "This frees {{size}}. You can download it again later.", + languages: "{{label}} languages", + moreLanguages: "{{languages}} and {{count}} more", + }, + }, general: { title: "General", sending: "Sending", @@ -2400,12 +2479,13 @@ export const en = { label: "Always expand reasoning", description: "Show agent thinking and chain-of-thought blocks fully expanded by default", }, - toolCallDetail: { - label: "Tool call display", - description: "How tool calls appear in the timeline", + chatDisplay: { + label: "Chat display", + description: "How much of an agent's work the chat shows", options: { - overview: "Summary", - detailed: "Full detail", + full: "Full", + collapseToolCalls: "Collapse tool calls", + collapseTurns: "Collapse turns", }, }, language: { @@ -2775,6 +2855,11 @@ export const en = { }, agents: { unavailable: "Connect to this host to manage agents", + idleRuntimeCleanup: { + title: "Release idle agents", + hint: "Free up memory after an hour of inactivity.", + info: "Stops the agent's process while keeping its conversation. Send a message to resume.\n\nActive work, pending notifications, and provider safety checks prevent cleanup.", + }, }, workspaces: { unavailable: "Connect to this host to manage workspaces", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index fd195ce599..2226583781 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -134,6 +134,8 @@ export const es: TranslationResources = { sendAndInterrupt: "Enviar e interrumpir", sendAndSteer: "Enviar y orientar", sendMessage: "enviar mensaje", + longPressToQueue: "Mantén pulsado para poner en cola", + longPressToSendNow: "Mantén pulsado para enviar ahora", queue: "Cola", send: "Enviar", }, @@ -1956,6 +1958,13 @@ export const es: TranslationResources = { backdrop: "Fondo del menú", }, subagents: { + parent: { + backToParent: "Volver al padre", + backTo: "Volver a {{label}}", + parent: "Padre", + parentAgent: "Agente padre", + openParent: "Abrir padre", + }, title: "Subagentes", pillLabelOne: "1 subagente", pillLabelMany: "{{count}} subagentes", @@ -2056,6 +2065,12 @@ export const es: TranslationResources = { input: "Aporte", output: "Producción", }, + turnSummary: { + messages: { + one: "escribió {{count}} mensaje", + other: "escribió {{count}} mensajes", + }, + }, toolCallGroup: { editedFiles: { one: "editó {{count}} archivo", @@ -2257,6 +2272,7 @@ export const es: TranslationResources = { connections: "Conexiones", agents: "Agents", metadata: "Metadatos", + voice: "Voz", workspaces: "Workspaces", providers: "Proveedores", usage: "Uso", @@ -2279,6 +2295,69 @@ export const es: TranslationResources = { docs: "Documentación", saveError: "No se pudo actualizar la generación de metadatos", }, + voice: { + offline: "Conéctate a este host para configurar la voz.", + updateHost: "Actualiza el host para configurar la voz.", + saveError: "No se pudo actualizar la configuración de voz", + provider: "Proveedor", + providers: { + local: "Local", + openai: "OpenAI", + }, + voice: "Voz", + save: "Guardar", + retry: "Reintentar", + cancel: "Cancelar", + model: "Modelo", + language: "Idioma", + languagePlaceholder: "Detección automática", + setBy: "Definido por {{name}}", + unknownProvider: "Actualiza la app para configurar {{provider}}", + sites: { + dictationStt: "Voz a texto", + voiceModeStt: "Escucha", + voiceModeTts: "Habla", + }, + dictation: { + title: "Dictado", + enabled: "Dictado", + enabledHint: "Habla en el cuadro de mensaje en lugar de escribir", + }, + voiceMode: { + title: "Modo de voz", + info: "El modo de voz transcribe lo que dices y lee en voz alta las respuestas del agente.", + enabled: "Modo de voz", + enabledHint: "Habla con tus agentes sin usar las manos", + }, + openai: { + apiKey: "Clave de API", + apiKeyHint: "Se guarda en el host y no se vuelve a mostrar.", + keyPlaceholder: "sk-...", + savedKeyPlaceholder: "Guardada", + missingKey: "Falta la clave de API", + baseUrl: "URL base", + baseUrlHint: "Para servidores compatibles con OpenAI. Déjala vacía para usar OpenAI.", + }, + models: { + loading: "Cargando modelos...", + loadError: "No se pudieron cargar los modelos locales", + none: "No hay modelos disponibles", + downloaded: "Descargado", + inUse: "En uso", + recommended: "Recomendado", + download: "Descargar", + downloadAction: "Descargar {{label}}", + downloading: "Descargando...", + downloadingPercent: "Descargando {{percent}} %", + extracting: "Extrayendo...", + delete: "Eliminar", + deleteAction: "Eliminar {{label}}", + deleteTitle: "¿Eliminar {{label}}?", + deleteMessage: "Esto libera {{size}}. Puedes volver a descargarlo más tarde.", + languages: "Idiomas de {{label}}", + moreLanguages: "{{languages}} y {{count}} más", + }, + }, general: { title: "General", sending: "Envío", @@ -2328,12 +2407,13 @@ export const es: TranslationResources = { description: "Mostrar los bloques de pensamiento y razonamiento del agente totalmente expandidos de forma predeterminada", }, - toolCallDetail: { - label: "Visualización de llamadas a herramientas", - description: "Cómo aparecen las llamadas a herramientas en la cronología", + chatDisplay: { + label: "Visualización del chat", + description: "Cuánto del trabajo del agente muestra el chat", options: { - overview: "Resumen", - detailed: "Detalle completo", + full: "Completo", + collapseToolCalls: "Contraer llamadas a herramientas", + collapseTurns: "Contraer turnos", }, }, language: { @@ -2704,6 +2784,11 @@ export const es: TranslationResources = { }, agents: { unavailable: "Connect to this host to manage agents", + idleRuntimeCleanup: { + title: "Liberar agentes inactivos", + hint: "Libera memoria tras una hora de inactividad.", + info: "Detiene el proceso del agente y conserva la conversación. Envía un mensaje para reanudarla.\n\nEl trabajo activo, las notificaciones pendientes y las comprobaciones de seguridad del proveedor impiden la limpieza.", + }, }, workspaces: { unavailable: "Connect to this host to manage workspaces", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 975d6a3736..65d72d405b 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -135,6 +135,8 @@ export const fr: TranslationResources = { sendAndInterrupt: "Envoyer et interrompre", sendAndSteer: "Envoyer et réorienter", sendMessage: "Envoyer le message", + longPressToQueue: "Appui long pour mettre en file d’attente", + longPressToSendNow: "Appui long pour envoyer maintenant", queue: "Mettre en file d’attente", send: "Envoyer", }, @@ -1954,6 +1956,13 @@ export const fr: TranslationResources = { backdrop: "Arrière-plan du menu", }, subagents: { + parent: { + backToParent: "Retour au parent", + backTo: "Retour à {{label}}", + parent: "Parent", + parentAgent: "Agent parent", + openParent: "Ouvrir le parent", + }, title: "Sous-agents", pillLabelOne: "1 sous-agent", pillLabelMany: "{{count}} sous-agents", @@ -2055,6 +2064,12 @@ export const fr: TranslationResources = { input: "Entrée", output: "Sortie", }, + turnSummary: { + messages: { + one: "{{count}} message écrit", + other: "{{count}} messages écrits", + }, + }, toolCallGroup: { editedFiles: { one: "{{count}} fichier modifié", @@ -2257,6 +2272,7 @@ export const fr: TranslationResources = { connections: "Connexions", agents: "Agents", metadata: "Métadonnées", + voice: "Voix", workspaces: "Espaces de travail", providers: "Fournisseurs", usage: "Utilisation", @@ -2279,6 +2295,69 @@ export const fr: TranslationResources = { docs: "Documentation", saveError: "Impossible de mettre à jour la génération de métadonnées", }, + voice: { + offline: "Connectez-vous à cet hôte pour configurer la voix.", + updateHost: "Mettez à jour l'hôte pour configurer la voix.", + saveError: "Impossible de mettre à jour les réglages vocaux", + provider: "Fournisseur", + providers: { + local: "Local", + openai: "OpenAI", + }, + voice: "Voix", + save: "Enregistrer", + retry: "Réessayer", + cancel: "Annuler", + model: "Modèle", + language: "Langue", + languagePlaceholder: "Détection automatique", + setBy: "Défini par {{name}}", + unknownProvider: "Mettez à jour l'app pour configurer {{provider}}", + sites: { + dictationStt: "Transcription", + voiceModeStt: "Écoute", + voiceModeTts: "Parole", + }, + dictation: { + title: "Dictée", + enabled: "Dictée", + enabledHint: "Parlez dans la zone de message au lieu de taper", + }, + voiceMode: { + title: "Mode vocal", + info: "Le mode vocal transcrit ce que vous dites et lit à voix haute les réponses de l'agent.", + enabled: "Mode vocal", + enabledHint: "Parlez à vos agents sans les mains", + }, + openai: { + apiKey: "Clé d'API", + apiKeyHint: "Enregistrée sur l'hôte, jamais réaffichée.", + keyPlaceholder: "sk-...", + savedKeyPlaceholder: "Enregistrée", + missingKey: "Clé d'API manquante", + baseUrl: "URL de base", + baseUrlHint: "Pour les serveurs compatibles OpenAI. Laissez vide pour utiliser OpenAI.", + }, + models: { + loading: "Chargement des modèles...", + loadError: "Impossible de charger les modèles locaux", + none: "Aucun modèle disponible", + downloaded: "Téléchargé", + inUse: "Utilisé", + recommended: "Recommandé", + download: "Télécharger", + downloadAction: "Télécharger {{label}}", + downloading: "Téléchargement...", + downloadingPercent: "Téléchargement {{percent}} %", + extracting: "Extraction...", + delete: "Supprimer", + deleteAction: "Supprimer {{label}}", + deleteTitle: "Supprimer {{label}} ?", + deleteMessage: "Cela libère {{size}}. Vous pourrez le retélécharger plus tard.", + languages: "Langues de {{label}}", + moreLanguages: "{{languages}} et {{count}} autres", + }, + }, general: { title: "Général", sending: "Envoi", @@ -2326,12 +2405,13 @@ export const fr: TranslationResources = { label: "Toujours afficher le raisonnement", description: "Afficher par défaut le raisonnement de l’agent entièrement déplié", }, - toolCallDetail: { - label: "Affichage des appels d’outils", - description: "Comment les appels d’outils apparaissent dans la chronologie", + chatDisplay: { + label: "Affichage du chat", + description: "La part du travail de l’agent affichée dans le chat", options: { - overview: "Résumé", - detailed: "Détails complets", + full: "Complet", + collapseToolCalls: "Réduire les appels d’outils", + collapseTurns: "Réduire les tours", }, }, language: { @@ -2707,6 +2787,11 @@ export const fr: TranslationResources = { }, agents: { unavailable: "Connectez-vous à cet hôte pour gérer les agents", + idleRuntimeCleanup: { + title: "Libérer les agents inactifs", + hint: "Libère de la mémoire après une heure d’inactivité.", + info: "Arrête le processus de l’agent tout en conservant sa conversation. Envoyez un message pour la reprendre.\n\nLe travail en cours, les notifications en attente et les vérifications de sécurité du fournisseur empêchent le nettoyage.", + }, }, workspaces: { unavailable: "Connectez-vous à cet hôte pour gérer les espaces de travail", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 60a207e1e1..5829bf4fa3 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -134,6 +134,8 @@ export const ja: TranslationResources = { sendAndInterrupt: "送信して中断", sendAndSteer: "送信して指示を追加", sendMessage: "メッセージを送信", + longPressToQueue: "長押しでキューに追加", + longPressToSendNow: "長押しで今すぐ送信", queue: "キュー", send: "送信", }, @@ -1927,6 +1929,13 @@ export const ja: TranslationResources = { backdrop: "メニューの背景", }, subagents: { + parent: { + backToParent: "親に戻る", + backTo: "{{label}} に戻る", + parent: "親", + parentAgent: "親エージェント", + openParent: "親を開く", + }, title: "サブエージェント", pillLabelOne: "サブエージェント 1 件", pillLabelMany: "サブエージェント {{count}} 件", @@ -2028,6 +2037,12 @@ export const ja: TranslationResources = { input: "入力", output: "出力", }, + turnSummary: { + messages: { + one: "{{count}}件のメッセージを送信", + other: "{{count}}件のメッセージを送信", + }, + }, toolCallGroup: { editedFiles: { one: "{{count}}個のファイルを編集", @@ -2230,6 +2245,7 @@ export const ja: TranslationResources = { connections: "接続", agents: "エージェント", metadata: "メタデータ", + voice: "音声", workspaces: "ワークスペース", providers: "プロバイダー", usage: "使用状況", @@ -2252,6 +2268,69 @@ export const ja: TranslationResources = { docs: "ドキュメント", saveError: "メタデータ生成を更新できません", }, + voice: { + offline: "音声を設定するには、このホストに接続してください。", + updateHost: "音声を設定するには、ホストを更新してください。", + saveError: "音声設定を更新できませんでした", + provider: "プロバイダー", + providers: { + local: "ローカル", + openai: "OpenAI", + }, + voice: "ボイス", + save: "保存", + retry: "再試行", + cancel: "キャンセル", + model: "モデル", + language: "言語", + languagePlaceholder: "自動検出", + setBy: "{{name}} で設定", + unknownProvider: "{{provider}} を設定するにはアプリを更新してください", + sites: { + dictationStt: "音声入力", + voiceModeStt: "聞き取り", + voiceModeTts: "読み上げ", + }, + dictation: { + title: "音声入力", + enabled: "音声入力", + enabledHint: "入力する代わりにメッセージ欄に話しかけます", + }, + voiceMode: { + title: "ボイスモード", + info: "ボイスモードは話した内容を文字起こしし、エージェントの返答を読み上げます。", + enabled: "ボイスモード", + enabledHint: "ハンズフリーでエージェントと会話します", + }, + openai: { + apiKey: "API キー", + apiKeyHint: "ホストに保存され、再表示されません。", + keyPlaceholder: "sk-...", + savedKeyPlaceholder: "保存済み", + missingKey: "API キーがありません", + baseUrl: "ベース URL", + baseUrlHint: "OpenAI 互換サーバー用です。OpenAI を使う場合は空欄のままにします。", + }, + models: { + loading: "モデルを読み込み中...", + loadError: "ローカルモデルを読み込めませんでした", + none: "利用できるモデルがありません", + downloaded: "ダウンロード済み", + inUse: "使用中", + recommended: "おすすめ", + download: "ダウンロード", + downloadAction: "{{label}} をダウンロード", + downloading: "ダウンロード中...", + downloadingPercent: "ダウンロード中 {{percent}}%", + extracting: "展開中...", + delete: "削除", + deleteAction: "{{label}} を削除", + deleteTitle: "{{label}} を削除しますか?", + deleteMessage: "{{size}} が解放されます。後で再ダウンロードできます。", + languages: "{{label}} の対応言語", + moreLanguages: "{{languages}} ほか {{count}} 言語", + }, + }, general: { title: "一般", sending: "送信", @@ -2296,12 +2375,13 @@ export const ja: TranslationResources = { label: "常に思考プロセスを展開", description: "デフォルトでAIのエージェント思考・推論ブロックを完全に展開して表示します", }, - toolCallDetail: { - label: "ツール呼び出しの表示", - description: "タイムラインでのツール呼び出しの表示方法", + chatDisplay: { + label: "チャットの表示", + description: "チャットに表示するエージェントの作業量", options: { - overview: "要約", - detailed: "すべての詳細", + full: "すべて", + collapseToolCalls: "ツール呼び出しを折りたたむ", + collapseTurns: "ターンを折りたたむ", }, }, language: { @@ -2673,6 +2753,11 @@ export const ja: TranslationResources = { }, agents: { unavailable: "エージェントを管理するにはこのホストに接続してください", + idleRuntimeCleanup: { + title: "アイドル状態のエージェントを解放", + hint: "1 時間のアイドル状態の後にメモリを解放します。", + info: "会話を保持したままエージェントのプロセスを停止します。メッセージを送信すると再開します。\n\n実行中の作業、保留中の通知、プロバイダーの安全性チェックがある場合は解放しません。", + }, }, workspaces: { unavailable: "ワークスペースを管理するにはこのホストに接続してください", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 4773625327..36a27fc84e 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -133,6 +133,8 @@ export const ko: TranslationResources = { sendAndInterrupt: "보내고 중단", sendAndSteer: "보내고 지시 추가", sendMessage: "메시지 보내기", + longPressToQueue: "길게 눌러 대기열에 추가", + longPressToSendNow: "길게 눌러 지금 보내기", queue: "대기열", send: "보내기", }, @@ -1918,6 +1920,13 @@ export const ko: TranslationResources = { backdrop: "메뉴 배경", }, subagents: { + parent: { + backToParent: "상위 에이전트로 돌아가기", + backTo: "{{label}}(으)로 돌아가기", + parent: "상위", + parentAgent: "상위 에이전트", + openParent: "상위 에이전트 열기", + }, title: "하위 에이전트", pillLabelOne: "하위 에이전트 1개", pillLabelMany: "하위 에이전트 {{count}}개", @@ -2018,6 +2027,12 @@ export const ko: TranslationResources = { input: "입력", output: "출력", }, + turnSummary: { + messages: { + one: "{{count}} 메시지를 작성했습니다.", + other: "{{count}} 메시지를 작성했습니다.", + }, + }, toolCallGroup: { editedFiles: { one: "{{count}} 파일을 편집했습니다.", @@ -2219,6 +2234,7 @@ export const ko: TranslationResources = { connections: "연결", agents: "에이전트", metadata: "메타데이터", + voice: "음성", workspaces: "워크스페이스", providers: "프로바이더", usage: "사용량", @@ -2241,6 +2257,69 @@ export const ko: TranslationResources = { docs: "문서", saveError: "메타데이터 생성을 업데이트할 수 없습니다", }, + voice: { + offline: "음성을 설정하려면 이 호스트에 연결하세요.", + updateHost: "음성을 설정하려면 호스트를 업데이트하세요.", + saveError: "음성 설정을 업데이트할 수 없습니다", + provider: "제공자", + providers: { + local: "로컬", + openai: "OpenAI", + }, + voice: "목소리", + save: "저장", + retry: "다시 시도", + cancel: "취소", + model: "모델", + language: "언어", + languagePlaceholder: "자동 감지", + setBy: "{{name}}에서 설정됨", + unknownProvider: "{{provider}}을(를) 설정하려면 앱을 업데이트하세요", + sites: { + dictationStt: "음성 텍스트 변환", + voiceModeStt: "듣기", + voiceModeTts: "말하기", + }, + dictation: { + title: "받아쓰기", + enabled: "받아쓰기", + enabledHint: "입력하는 대신 메시지 상자에 말하세요", + }, + voiceMode: { + title: "음성 모드", + info: "음성 모드는 말한 내용을 텍스트로 옮기고 에이전트의 답변을 소리 내어 읽어 줍니다.", + enabled: "음성 모드", + enabledHint: "손을 쓰지 않고 에이전트와 대화하세요", + }, + openai: { + apiKey: "API 키", + apiKeyHint: "호스트에 저장되며 다시 표시되지 않습니다.", + keyPlaceholder: "sk-...", + savedKeyPlaceholder: "저장됨", + missingKey: "API 키 없음", + baseUrl: "기본 URL", + baseUrlHint: "OpenAI 호환 서버용입니다. OpenAI를 사용하려면 비워 두세요.", + }, + models: { + loading: "모델을 불러오는 중...", + loadError: "로컬 모델을 불러올 수 없습니다", + none: "사용 가능한 모델 없음", + downloaded: "다운로드됨", + inUse: "사용 중", + recommended: "추천", + download: "다운로드", + downloadAction: "{{label}} 다운로드", + downloading: "다운로드 중...", + downloadingPercent: "다운로드 중 {{percent}}%", + extracting: "압축 해제 중...", + delete: "삭제", + deleteAction: "{{label}} 삭제", + deleteTitle: "{{label}}을(를) 삭제할까요?", + deleteMessage: "{{size}}가 확보됩니다. 나중에 다시 다운로드할 수 있습니다.", + languages: "{{label}} 지원 언어", + moreLanguages: "{{languages}} 외 {{count}}개", + }, + }, general: { title: "일반", sending: "전송", @@ -2287,12 +2366,13 @@ export const ko: TranslationResources = { label: "추론 항상 펼치기", description: "에이전트의 사고 및 추론 블록을 기본적으로 모두 펼쳐 표시합니다.", }, - toolCallDetail: { - label: "도구 호출 표시", - description: "타임라인에 도구 호출이 표시되는 방식", + chatDisplay: { + label: "채팅 표시", + description: "채팅에 표시할 에이전트 작업의 양", options: { - overview: "요약", - detailed: "전체 세부정보", + full: "전체", + collapseToolCalls: "도구 호출 접기", + collapseTurns: "턴 접기", }, }, language: { @@ -2659,6 +2739,11 @@ export const ko: TranslationResources = { }, agents: { unavailable: "에이전트를 관리하려면 이 호스트에 연결하세요", + idleRuntimeCleanup: { + title: "유휴 에이전트 해제", + hint: "1시간 동안 활동이 없으면 메모리를 확보합니다.", + info: "대화를 유지하면서 에이전트 프로세스를 중지합니다. 메시지를 보내면 다시 이어집니다.\n\n진행 중인 작업, 대기 중인 알림, 프로바이더 안전 검사가 있으면 정리하지 않습니다.", + }, }, workspaces: { unavailable: "워크스페이스를 관리하려면 이 호스트에 연결하세요", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 766c6d98ed..5a83b4345c 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -134,6 +134,8 @@ export const ptBR: TranslationResources = { sendAndInterrupt: "Enviar e interromper", sendAndSteer: "Enviar e orientar", sendMessage: "Enviar mensagem", + longPressToQueue: "Mantenha pressionado para enfileirar", + longPressToSendNow: "Mantenha pressionado para enviar agora", queue: "Fila", send: "Enviar", }, @@ -1941,6 +1943,13 @@ export const ptBR: TranslationResources = { backdrop: "Fundo do menu", }, subagents: { + parent: { + backToParent: "Voltar ao pai", + backTo: "Voltar para {{label}}", + parent: "Pai", + parentAgent: "Agente pai", + openParent: "Abrir pai", + }, title: "Subagentes", pillLabelOne: "1 subagente", pillLabelMany: "{{count}} subagentes", @@ -2041,6 +2050,12 @@ export const ptBR: TranslationResources = { input: "Entrada", output: "Saída", }, + turnSummary: { + messages: { + one: "escreveu {{count}} mensagem", + other: "escreveu {{count}} mensagens", + }, + }, toolCallGroup: { editedFiles: { one: "editou {{count}} arquivo", @@ -2242,6 +2257,7 @@ export const ptBR: TranslationResources = { connections: "Conexões", agents: "Agentes", metadata: "Metadados", + voice: "Voz", workspaces: "Workspaces", providers: "Provedores", usage: "Uso", @@ -2264,6 +2280,69 @@ export const ptBR: TranslationResources = { docs: "Documentação", saveError: "Não foi possível atualizar a geração de metadados", }, + voice: { + offline: "Conecte-se a este host para configurar a voz.", + updateHost: "Atualize o host para configurar a voz.", + saveError: "Não foi possível atualizar as configurações de voz", + provider: "Provedor", + providers: { + local: "Local", + openai: "OpenAI", + }, + voice: "Voz", + save: "Salvar", + retry: "Tentar novamente", + cancel: "Cancelar", + model: "Modelo", + language: "Idioma", + languagePlaceholder: "Detecção automática", + setBy: "Definido por {{name}}", + unknownProvider: "Atualize o app para configurar {{provider}}", + sites: { + dictationStt: "Fala para texto", + voiceModeStt: "Escuta", + voiceModeTts: "Fala", + }, + dictation: { + title: "Ditado", + enabled: "Ditado", + enabledHint: "Fale na caixa de mensagem em vez de digitar", + }, + voiceMode: { + title: "Modo de voz", + info: "O modo de voz transcreve o que você diz e lê em voz alta as respostas do agente.", + enabled: "Modo de voz", + enabledHint: "Converse com seus agentes sem usar as mãos", + }, + openai: { + apiKey: "Chave de API", + apiKeyHint: "Salva no host e nunca mostrada novamente.", + keyPlaceholder: "sk-...", + savedKeyPlaceholder: "Salva", + missingKey: "Chave de API ausente", + baseUrl: "URL base", + baseUrlHint: "Para servidores compatíveis com a OpenAI. Deixe vazio para usar a OpenAI.", + }, + models: { + loading: "Carregando modelos...", + loadError: "Não foi possível carregar os modelos locais", + none: "Nenhum modelo disponível", + downloaded: "Baixado", + inUse: "Em uso", + recommended: "Recomendado", + download: "Baixar", + downloadAction: "Baixar {{label}}", + downloading: "Baixando...", + downloadingPercent: "Baixando {{percent}}%", + extracting: "Extraindo...", + delete: "Excluir", + deleteAction: "Excluir {{label}}", + deleteTitle: "Excluir {{label}}?", + deleteMessage: "Isso libera {{size}}. Você pode baixá-lo de novo depois.", + languages: "Idiomas do {{label}}", + moreLanguages: "{{languages}} e mais {{count}}", + }, + }, general: { title: "Geral", sending: "Envio", @@ -2311,12 +2390,13 @@ export const ptBR: TranslationResources = { description: "Mostrar os blocos de pensamento e raciocínio do agente totalmente expandidos por padrão", }, - toolCallDetail: { - label: "Exibição de chamadas de ferramentas", - description: "Como as chamadas de ferramentas aparecem na linha do tempo", + chatDisplay: { + label: "Exibição do chat", + description: "Quanto do trabalho do agente o chat mostra", options: { - overview: "Resumo", - detailed: "Detalhes completos", + full: "Completo", + collapseToolCalls: "Recolher chamadas de ferramentas", + collapseTurns: "Recolher turnos", }, }, language: { @@ -2686,6 +2766,11 @@ export const ptBR: TranslationResources = { }, agents: { unavailable: "Conecte-se a este host para gerenciar agentes", + idleRuntimeCleanup: { + title: "Liberar agentes ociosos", + hint: "Libera memória após uma hora de inatividade.", + info: "Encerra o processo do agente e mantém a conversa. Envie uma mensagem para retomar.\n\nTrabalho ativo, notificações pendentes e verificações de segurança do provedor impedem a limpeza.", + }, }, workspaces: { unavailable: "Conecte-se a este host para gerenciar workspaces", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 23f3d810ee..135d5fe2f9 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -134,6 +134,8 @@ export const ru: TranslationResources = { sendAndInterrupt: "Отправить и прервать", sendAndSteer: "Отправить и направить", sendMessage: "Отправить сообщение", + longPressToQueue: "Удерживайте, чтобы поставить в очередь", + longPressToSendNow: "Удерживайте, чтобы отправить сейчас", queue: "Очередь", send: "Отправить", }, @@ -1940,6 +1942,13 @@ export const ru: TranslationResources = { backdrop: "Фон меню", }, subagents: { + parent: { + backToParent: "Назад к родителю", + backTo: "Назад к {{label}}", + parent: "Родитель", + parentAgent: "Родительский агент", + openParent: "Открыть родителя", + }, title: "Субагенты", pillLabelOne: "1 субагент", pillLabelMany: "Субагентов: {{count}}", @@ -2041,6 +2050,12 @@ export const ru: TranslationResources = { input: "Входные данные", output: "Выходные данные", }, + turnSummary: { + messages: { + one: "написано {{count}} сообщение", + other: "написаны сообщения ({{count}})", + }, + }, toolCallGroup: { editedFiles: { one: "изменён {{count}} файл", @@ -2242,6 +2257,7 @@ export const ru: TranslationResources = { connections: "Подключения", agents: "Агенты", metadata: "Метаданные", + voice: "Голос", workspaces: "Рабочие пространства", providers: "Провайдеры", usage: "Использование", @@ -2265,6 +2281,70 @@ export const ru: TranslationResources = { docs: "Документация", saveError: "Не удалось обновить настройки генерации метаданных", }, + voice: { + offline: "Подключитесь к этому хосту, чтобы настроить голос.", + updateHost: "Обновите хост, чтобы настроить голос.", + saveError: "Не удалось обновить голосовые настройки", + provider: "Провайдер", + providers: { + local: "Локально", + openai: "OpenAI", + }, + voice: "Голос", + save: "Сохранить", + retry: "Повторить", + cancel: "Отмена", + model: "Модель", + language: "Язык", + languagePlaceholder: "Автоопределение", + setBy: "Задано через {{name}}", + unknownProvider: "Обновите приложение, чтобы настроить {{provider}}", + sites: { + dictationStt: "Распознавание речи", + voiceModeStt: "Прослушивание", + voiceModeTts: "Озвучивание", + }, + dictation: { + title: "Диктовка", + enabled: "Диктовка", + enabledHint: "Говорите в поле сообщения вместо набора текста", + }, + voiceMode: { + title: "Голосовой режим", + info: "Голосовой режим расшифровывает вашу речь и зачитывает ответы агента вслух.", + enabled: "Голосовой режим", + enabledHint: "Общайтесь с агентами без рук", + }, + openai: { + apiKey: "API-ключ", + apiKeyHint: "Хранится на хосте и больше не показывается.", + keyPlaceholder: "sk-...", + savedKeyPlaceholder: "Сохранён", + missingKey: "Нет API-ключа", + baseUrl: "Базовый URL", + baseUrlHint: + "Для серверов, совместимых с OpenAI. Оставьте пустым, чтобы использовать OpenAI.", + }, + models: { + loading: "Загрузка моделей...", + loadError: "Не удалось загрузить локальные модели", + none: "Нет доступных моделей", + downloaded: "Скачана", + inUse: "Используется", + recommended: "Рекомендуется", + download: "Скачать", + downloadAction: "Скачать {{label}}", + downloading: "Скачивание...", + downloadingPercent: "Скачивание {{percent}}%", + extracting: "Распаковка...", + delete: "Удалить", + deleteAction: "Удалить {{label}}", + deleteTitle: "Удалить {{label}}?", + deleteMessage: "Освободится {{size}}. Позже её можно скачать снова.", + languages: "Языки {{label}}", + moreLanguages: "{{languages}} и ещё {{count}}", + }, + }, general: { title: "Основные", sending: "Отправка", @@ -2313,12 +2393,13 @@ export const ru: TranslationResources = { description: "По умолчанию полностью разворачивать блоки размышлений и хода рассуждений агента", }, - toolCallDetail: { - label: "Отображение вызовов инструментов", - description: "Как вызовы инструментов отображаются на временной шкале", + chatDisplay: { + label: "Отображение чата", + description: "Какую часть работы агента показывает чат", options: { - overview: "Сводка", - detailed: "Все подробности", + full: "Полностью", + collapseToolCalls: "Сворачивать вызовы инструментов", + collapseTurns: "Сворачивать ходы", }, }, language: { @@ -2695,6 +2776,11 @@ export const ru: TranslationResources = { }, agents: { unavailable: "Подключитесь к этому хосту, чтобы управлять агентами", + idleRuntimeCleanup: { + title: "Освобождать неактивных агентов", + hint: "Освобождает память после часа бездействия.", + info: "Останавливает процесс агента, сохраняя разговор. Отправьте сообщение, чтобы продолжить.\n\nАктивная работа, ожидающие уведомления и проверки безопасности провайдера предотвращают очистку.", + }, }, workspaces: { unavailable: "Подключитесь к этому хосту, чтобы управлять рабочими пространствами", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 43d8ceeef2..78b0f06442 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -133,6 +133,8 @@ export const zhCN: TranslationResources = { sendAndInterrupt: "发送并中断", sendAndSteer: "发送并引导", sendMessage: "发送消息", + longPressToQueue: "长按加入队列", + longPressToSendNow: "长按立即发送", queue: "排队", send: "发送", }, @@ -1886,6 +1888,13 @@ export const zhCN: TranslationResources = { backdrop: "菜单背景", }, subagents: { + parent: { + backToParent: "返回父代理", + backTo: "返回 {{label}}", + parent: "父代理", + parentAgent: "父代理", + openParent: "打开父代理", + }, title: "Subagent", pillLabelOne: "1 个 subagent", pillLabelMany: "{{count}} 个 subagent", @@ -1986,6 +1995,12 @@ export const zhCN: TranslationResources = { input: "输入", output: "输出", }, + turnSummary: { + messages: { + one: "写了 {{count}} 条消息", + other: "写了 {{count}} 条消息", + }, + }, toolCallGroup: { editedFiles: { one: "编辑了 {{count}} 个文件", @@ -2187,6 +2202,7 @@ export const zhCN: TranslationResources = { connections: "连接", agents: "Agents", metadata: "元数据", + voice: "语音", workspaces: "Workspaces", providers: "Providers", usage: "使用情况", @@ -2208,6 +2224,69 @@ export const zhCN: TranslationResources = { docs: "文档", saveError: "无法更新元数据生成设置", }, + voice: { + offline: "连接到此主机以配置语音。", + updateHost: "请更新主机以配置语音。", + saveError: "无法更新语音设置", + provider: "提供方", + providers: { + local: "本地", + openai: "OpenAI", + }, + voice: "音色", + save: "保存", + retry: "重试", + cancel: "取消", + model: "模型", + language: "语言", + languagePlaceholder: "自动检测", + setBy: "由 {{name}} 设置", + unknownProvider: "更新应用以配置 {{provider}}", + sites: { + dictationStt: "语音转文字", + voiceModeStt: "聆听", + voiceModeTts: "朗读", + }, + dictation: { + title: "听写", + enabled: "听写", + enabledHint: "对着消息框说话,无需打字", + }, + voiceMode: { + title: "语音模式", + info: "语音模式会转写你说的话,并朗读智能体的回复。", + enabled: "语音模式", + enabledHint: "免手动与智能体对话", + }, + openai: { + apiKey: "API 密钥", + apiKeyHint: "保存在主机上,不会再次显示。", + keyPlaceholder: "sk-...", + savedKeyPlaceholder: "已保存", + missingKey: "缺少 API 密钥", + baseUrl: "基础 URL", + baseUrlHint: "用于兼容 OpenAI 的服务器。留空则使用 OpenAI。", + }, + models: { + loading: "正在加载模型...", + loadError: "无法加载本地模型", + none: "没有可用的模型", + downloaded: "已下载", + inUse: "使用中", + recommended: "推荐", + download: "下载", + downloadAction: "下载 {{label}}", + downloading: "正在下载...", + downloadingPercent: "正在下载 {{percent}}%", + extracting: "正在解压...", + delete: "删除", + deleteAction: "删除 {{label}}", + deleteTitle: "删除 {{label}}?", + deleteMessage: "这将释放 {{size}}。之后可以重新下载。", + languages: "{{label}} 支持的语言", + moreLanguages: "{{languages}} 等另外 {{count}} 种", + }, + }, general: { title: "通用", sending: "发送", @@ -2251,12 +2330,13 @@ export const zhCN: TranslationResources = { label: "始终展开推理过程", description: "默认情况下完全展开 AI 的思考和推理过程", }, - toolCallDetail: { - label: "工具调用显示", - description: "工具调用在时间线中的显示方式", + chatDisplay: { + label: "聊天显示", + description: "聊天中显示多少智能体的工作", options: { - overview: "摘要", - detailed: "完整详情", + full: "完整", + collapseToolCalls: "折叠工具调用", + collapseTurns: "折叠轮次", }, }, language: { @@ -2620,6 +2700,11 @@ export const zhCN: TranslationResources = { }, agents: { unavailable: "连接到这个 Host 以管理 Agent", + idleRuntimeCleanup: { + title: "释放空闲 Agent", + hint: "空闲一小时后释放内存。", + info: "停止 Agent 进程,但保留对话。发送消息即可继续。\n\n进行中的工作、待处理的通知和 Provider 安全检查会阻止清理。", + }, }, workspaces: { unavailable: "连接到这个 Host 以管理 Workspace", diff --git a/packages/app/src/navigation/settings-presentation/index.ts b/packages/app/src/navigation/settings-presentation/index.ts new file mode 100644 index 0000000000..cdf86b0d19 --- /dev/null +++ b/packages/app/src/navigation/settings-presentation/index.ts @@ -0,0 +1,57 @@ +import { useCallback, useLayoutEffect, useRef } from "react"; +import { ScrollView, type NativeScrollEvent, type NativeSyntheticEvent } from "react-native"; +import { usePathname, useRootNavigationState } from "expo-router"; +import { useStore } from "zustand"; +import { + useIsLastWorkspaceSelectionHydrated, + useLastWorkspaceSelection, +} from "@/stores/navigation-active-workspace-store"; +import { createSettingsPresentation } from "./model"; + +const selection = createSettingsPresentation(); + +// Mount once above the sibling Settings routes so replacements and Back share +// one visit, and leaving Settings gives the next workspace entry a fresh default. +export function useSettingsRouteEntry(): void { + const pathname = usePathname(); + const navigationState = useRootNavigationState(); + const routeKey = navigationState?.routes[navigationState.index]?.key; + const workspace = useLastWorkspaceSelection(); + const isWorkspaceHydrated = useIsLastWorkspaceSelectionHydrated(); + const workspaceServerId = workspace?.serverId ?? null; + useLayoutEffect(() => { + if (isWorkspaceHydrated && routeKey) { + selection.enterRoute({ key: routeKey, pathname }, workspaceServerId); + } + }, [pathname, routeKey, workspaceServerId, isWorkspaceHydrated]); +} + +export function useSettingsHostSelection(): string | null { + return useStore(selection.store, (state) => state.selectedServerId); +} + +export const selectSettingsHost = selection.selectHost; + +// Sibling routes remount the wide sidebar. Only the current mount owns its +// offset: RN Web can deliver its final scroll callback after unmount. +export function useSettingsSidebarScroll(enabled: boolean) { + const ref = useRef(null); + const owner = useRef | null>(null); + const restore = useCallback(() => { + if (owner.current) ref.current?.scrollTo({ y: owner.current.offset, animated: false }); + }, []); + useLayoutEffect(() => { + if (!enabled) return; + const sidebar = selection.attachSidebar(); + owner.current = sidebar; + restore(); + return () => { + sidebar.detach(); + owner.current = null; + }; + }, [enabled, restore]); + const onScroll = useCallback((event: NativeSyntheticEvent) => { + owner.current?.remember(event.nativeEvent.contentOffset.y); + }, []); + return { ref, onScroll, onContentSizeChange: restore, scrollEventThrottle: 16 }; +} diff --git a/packages/app/src/navigation/settings-presentation/model.test.ts b/packages/app/src/navigation/settings-presentation/model.test.ts new file mode 100644 index 0000000000..a8970b17b4 --- /dev/null +++ b/packages/app/src/navigation/settings-presentation/model.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; +import { createSettingsPresentation } from "./model"; + +describe("Settings host selection", () => { + it("keeps picker choices across sections, replacement and Back", () => { + const settings = createSettingsPresentation(); + settings.enterRoute({ key: "/settings", pathname: "/settings" }, "workspace-host"); + expect(settings.store.getState().selectedServerId).toBe("workspace-host"); + settings.selectHost("chosen-host"); + settings.enterRoute( + { + key: "/settings/hosts/chosen-host/connections", + pathname: "/settings/hosts/chosen-host/connections", + }, + "workspace-host", + ); + settings.enterRoute({ key: "/settings", pathname: "/settings" }, "workspace-host"); + settings.enterRoute( + { key: "/settings/appearance", pathname: "/settings/appearance" }, + "workspace-host", + ); + settings.enterRoute( + { key: "/settings/general", pathname: "/settings/general" }, + "workspace-host", + ); + expect(settings.store.getState().selectedServerId).toBe("chosen-host"); + }); + + it("applies explicit host links once, including project and plugin screens", () => { + const settings = createSettingsPresentation(); + settings.enterRoute( + { + key: "/settings/hosts/linked-host/projects/project", + pathname: "/settings/hosts/linked-host/projects/project", + }, + "workspace-host", + ); + expect(settings.store.getState().selectedServerId).toBe("linked-host"); + settings.selectHost("chosen-host"); + settings.enterRoute( + { + key: "/settings/hosts/linked-host/projects/project", + pathname: "/settings/hosts/linked-host/projects/project", + }, + "workspace-host", + ); + expect(settings.store.getState().selectedServerId).toBe("chosen-host"); + settings.enterRoute( + { + key: "/settings/hosts/plugin-host/plugins/plugin/screen", + pathname: "/settings/hosts/plugin-host/plugins/plugin/screen", + }, + "workspace-host", + ); + expect(settings.store.getState().selectedServerId).toBe("plugin-host"); + }); + + it("seeds each new visit from the workspace, including a retained settings route", () => { + const settings = createSettingsPresentation(); + settings.enterRoute( + { key: "/settings/general", pathname: "/settings/general" }, + "first-workspace-host", + ); + settings.selectHost("chosen-host"); + settings.enterRoute( + { + key: "/h/second-workspace-host/workspace/workspace", + pathname: "/h/second-workspace-host/workspace/workspace", + }, + "second-workspace-host", + ); + settings.enterRoute( + { key: "/settings/general", pathname: "/settings/general" }, + "second-workspace-host", + ); + expect(settings.store.getState().selectedServerId).toBe("second-workspace-host"); + }); + + it("does not replay a retained host route after a newer picker choice", () => { + const settings = createSettingsPresentation(); + const oldRoute = { key: "old-host-screen", pathname: "/settings/hosts/first/host" }; + settings.enterRoute(oldRoute, "workspace-host"); + settings.selectHost("second"); + settings.enterRoute( + { key: "new-host-screen", pathname: "/settings/hosts/second/host" }, + "workspace-host", + ); + settings.enterRoute(oldRoute, "workspace-host"); + settings.enterRoute({ key: "app-screen", pathname: "/settings/appearance" }, "workspace-host"); + expect(settings.store.getState().selectedServerId).toBe("second"); + settings.enterRoute({ ...oldRoute, key: "explicit-new-entry" }, "workspace-host"); + expect(settings.store.getState().selectedServerId).toBe("first"); + }); + + it("does not reseed a visit when remembered workspace state changes", () => { + const settings = createSettingsPresentation(); + settings.enterRoute({ key: "/settings", pathname: "/settings" }, null); + settings.selectHost("chosen-host"); + settings.enterRoute({ key: "/settings", pathname: "/settings" }, "workspace-host"); + expect(settings.store.getState().selectedServerId).toBe("chosen-host"); + }); +}); + +describe("Settings sidebar presentation", () => { + it("restores across replacements and rejects callbacks from older sidebars", () => { + const settings = createSettingsPresentation(); + settings.enterRoute({ key: "app", pathname: "/settings/general" }, "host"); + const oldSidebar = settings.attachSidebar(); + oldSidebar.remember(246); + settings.enterRoute({ key: "host", pathname: "/settings/hosts/host/providers" }, "host"); + const currentSidebar = settings.attachSidebar(); + expect(currentSidebar.offset).toBe(246); + oldSidebar.remember(0); + oldSidebar.detach(); + expect(currentSidebar.offset).toBe(246); + currentSidebar.remember(190); + currentSidebar.detach(); + currentSidebar.remember(0); + expect(settings.attachSidebar().offset).toBe(190); + }); + + it("resets on leaving Settings and rejects callbacks from a previous visit", () => { + const settings = createSettingsPresentation(); + settings.enterRoute({ key: "app", pathname: "/settings/general" }, "host"); + const previousVisit = settings.attachSidebar(); + previousVisit.remember(246); + settings.enterRoute({ key: "workspace", pathname: "/h/host/workspace/workspace" }, "host"); + settings.enterRoute({ key: "app", pathname: "/settings/general" }, "host"); + previousVisit.remember(246); + expect(settings.attachSidebar().offset).toBe(0); + }); +}); diff --git a/packages/app/src/navigation/settings-presentation/model.ts b/packages/app/src/navigation/settings-presentation/model.ts new file mode 100644 index 0000000000..2a88e9d82b --- /dev/null +++ b/packages/app/src/navigation/settings-presentation/model.ts @@ -0,0 +1,66 @@ +import { createStore } from "zustand/vanilla"; + +interface SettingsRouteEntry { + key: string; + pathname: string; +} + +interface SettingsSelectionState { + selectedServerId: string | null; +} + +export function createSettingsPresentation() { + const store = createStore(() => ({ selectedServerId: null })); + const enteredRoutes = new Map(); + let inSettings = false; + let sidebarOffset = 0; + let sidebarOwner: object | null = null; + + function attachSidebar() { + const owner = {}; + sidebarOwner = owner; + return { + get offset() { + return sidebarOffset; + }, + remember(offset: number) { + if (sidebarOwner === owner) sidebarOffset = offset; + }, + detach() { + if (sidebarOwner === owner) sidebarOwner = null; + }, + }; + } + + function enterRoute( + { key, pathname }: SettingsRouteEntry, + workspaceServerId: string | null, + ): void { + const isSettings = pathname === "/settings" || pathname.startsWith("/settings/"); + if (!isSettings) { + sidebarOffset = 0; + sidebarOwner = null; + inSettings = false; + enteredRoutes.clear(); + return; + } + // A retained route returning through Back must not replay its old host intent. + if (enteredRoutes.get(key) === pathname) return; + const isNewVisit = !inSettings; + inSettings = true; + enteredRoutes.set(key, pathname); + + const hostMatch = /^\/settings\/hosts\/([^/]+)(?:\/|$)/.exec(pathname); + if (hostMatch) { + store.setState({ selectedServerId: decodeURIComponent(hostMatch[1]) }); + } else if (isNewVisit) { + store.setState({ selectedServerId: workspaceServerId }); + } + } + + function selectHost(serverId: string): void { + store.setState({ selectedServerId: serverId }); + } + + return { store, enterRoute, selectHost, attachSidebar }; +} diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index af14b94229..8139459b2b 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -20,6 +20,7 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles"; import invariant from "tiny-invariant"; import { shallow, useShallow } from "zustand/shallow"; import { useStoreWithEqualityFn } from "zustand/traditional"; +import { useAgentStreamTail } from "@/agent-stream/use-agent-stream-tail"; import { AgentStreamView, type AgentStreamViewHandle } from "@/agent-stream/view"; import { ArchivedAgentCallout } from "@/components/archived-agent-callout"; import { ComposerDock } from "@/composer/dock"; @@ -80,6 +81,7 @@ import { } from "@/screens/agent/agent-ready-screen-bottom-anchor"; import { WorkspaceDraftAgentTab } from "@/composer/draft/workspace-tab"; import { AgentTracks, hasAgentTracks } from "@/panels/agent-tracks"; +import { ParentAgentCallout, useParentAgentId } from "@/subagents/parent"; import { useCreateFlowStore } from "@/stores/create-flow-store"; import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys"; import { @@ -1109,7 +1111,11 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ }); const hasPluginComposerPills = useHasPluginComposerPills(serverId, workspaceId, agentId); const hasActiveComposer = !agentState.archivedAt && !isArchivingCurrentAgent; + const parentAgentId = useParentAgentId(serverId, agentId); + // With a composer the parent is a pill above it; without one, the box takes the composer's place. + const parentPillAgentId = showsComposer ? parentAgentId : null; const hasVisibleAgentTracks = hasAgentTracks({ + parentAgentId: parentPillAgentId, subagentRows, tasks, archiveFinishedStatus: archiveFinishedSubagents.status, @@ -1161,6 +1167,9 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ composerState, ], ); + const parentCallout = parentAgentId ? ( + + ) : null; const composerSection = showsComposer ? ( - ) : null; + ) : ( + parentCallout + ); const streamContent = ( {showsComposer ? null : } @@ -1203,6 +1214,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ serverId={serverId} workspaceId={workspaceId} agentId={agentId} + parentAgentId={parentPillAgentId} subagentRows={subagentRows} tasks={tasks} archiveFinishedStatus={archiveFinishedSubagents.status} @@ -1354,9 +1366,7 @@ const AgentStreamSection = memo(function AgentStreamSection({ const bottomOverlayControlClearance = hasVisibleComposerTracks ? resolveComposerTrackControlClearance(isCompactFormFactor) : 0; - const streamItemsRaw = useSessionStore((state) => - agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined, - ); + const streamItemsRaw = useAgentStreamTail(serverId, agentId); const pendingMessageSubmissions = useSessionStore( useShallow((state) => agentId diff --git a/packages/app/src/panels/agent-tracks.tsx b/packages/app/src/panels/agent-tracks.tsx index 3fc42030a7..f7c724133a 100644 --- a/packages/app/src/panels/agent-tracks.tsx +++ b/packages/app/src/panels/agent-tracks.tsx @@ -16,6 +16,7 @@ import { useSubagentOpensInSheet, type SubagentRow, } from "@/subagents"; +import { ParentAgentPill } from "@/subagents/parent"; import { SubagentsTrack } from "@/subagents/track"; import type { TodoEntry } from "@/types/stream"; import { navigateToAgent } from "@/utils/navigate-to-agent"; @@ -34,6 +35,7 @@ export const AgentTracks = memo(function AgentTracks({ serverId, workspaceId, agentId, + parentAgentId, subagentRows, tasks, archiveFinishedStatus, @@ -43,6 +45,7 @@ export const AgentTracks = memo(function AgentTracks({ serverId: string; workspaceId: string; agentId: string; + parentAgentId: string | null; subagentRows: SubagentRow[]; tasks: TodoEntry[] | undefined; archiveFinishedStatus: ArchiveFinishedStatus; @@ -88,8 +91,12 @@ export const AgentTracks = memo(function AgentTracks({ [openSubagentTarget, serverId, workspaceId], ); const handleOpenProviderSubagent = useCallback( - (parentAgentId: string, subagentId: string) => { - openSubagentTarget({ kind: "provider_subagent", parentAgentId, subagentId }); + (ownerAgentId: string, subagentId: string) => { + openSubagentTarget({ + kind: "provider_subagent", + parentAgentId: ownerAgentId, + subagentId, + }); }, [openSubagentTarget], ); @@ -108,6 +115,7 @@ export const AgentTracks = memo(function AgentTracks({ if ( !hasWorkspaceDiffStat && !hasAgentTracks({ + parentAgentId, subagentRows, tasks, archiveFinishedStatus, @@ -119,6 +127,7 @@ export const AgentTracks = memo(function AgentTracks({ return ( + {parentAgentId ? : null} 0 || Boolean(tasks?.length) || archiveFinishedStatus.kind !== "idle" || diff --git a/packages/app/src/panels/provider-subagent-panel.tsx b/packages/app/src/panels/provider-subagent-panel.tsx index ecd4a082bf..e36f7880ca 100644 --- a/packages/app/src/panels/provider-subagent-panel.tsx +++ b/packages/app/src/panels/provider-subagent-panel.tsx @@ -16,6 +16,7 @@ import { usePaneContext } from "@/panels/pane-context"; import { PaneSubtitle } from "@/panels/pane-subtitle"; import { definePanel, type PanelDescriptor } from "@/panels/panel-registry"; import { useSessionStore } from "@/stores/session-store"; +import { ParentAgentCallout } from "@/subagents/parent"; import { type SubagentRow, useSubagentsForParent } from "@/subagents/select"; import { useSubagentOpensInSheet } from "@/subagents/opens-in-sheet"; import { SubagentsTrack } from "@/subagents/track"; @@ -253,33 +254,37 @@ function ProviderSubagentPanel() { return ( {subtitle ? : null} - - + + + + + ); } const styles = StyleSheet.create((theme) => ({ container: { flex: 1, minHeight: 0 }, + stream: { flex: 1, minHeight: 0 }, unsupported: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 }, unsupportedText: { color: theme.colors.foregroundMuted, textAlign: "center" }, })); diff --git a/packages/app/src/panels/retained-chat-content.browser.test.tsx b/packages/app/src/panels/retained-chat-content.browser.test.tsx index 07b1ceca75..57977dd462 100644 --- a/packages/app/src/panels/retained-chat-content.browser.test.tsx +++ b/packages/app/src/panels/retained-chat-content.browser.test.tsx @@ -1,11 +1,15 @@ import React, { act, + Profiler, useCallback, useEffect, useLayoutEffect, useState, useSyncExternalStore, } from "react"; +import { useAgentStreamTail } from "@/agent-stream/use-agent-stream-tail"; +import { useSessionStore } from "@/stores/session-store"; +import type { StreamItem } from "@/types/stream"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { RetainedPanelActivity, useRetainedPanelActive } from "@/components/retained-panel"; @@ -220,3 +224,69 @@ describe("workspace sheet environment", () => { source.unmount(); }); }); + +for (const [runtime, Chat] of [ + ["native", NativeChat], + ["web", WebChat], +] as const) { + it(`${runtime} ignores hidden stream chunks and reads the latest transcript on reveal`, async () => { + const serverId = `hidden-stream-${runtime}`; + const agentId = "background-agent"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null); + const profile = { + commits: 0, + onRender() { + profile.commits++; + }, + }; + const publish = (text: string) => { + const item: StreamItem = { + id: "message", + kind: "assistant_message", + text, + timestamp: new Date(), + }; + store.setAgentStreamTail(serverId, new Map([[agentId, [item]]])); + }; + function Content() { + const items = useAgentStreamTail(serverId, agentId); + const [draft] = useState("unsent draft"); + return ( + + {draft}:{items?.[0]?.kind === "assistant_message" ? items[0].text : ""} + + ); + } + const show = (active: boolean) => + act(() => + root.render( + + + + + + + , + ), + ); + try { + publish("first chunk"); + show(true); + expect(container.textContent).toBe("unsent draft:first chunk"); + show(false); + const hiddenCommits = profile.commits; + for (let index = 0; index < 10; index++) { + await act(async () => publish(`chunk ${index}`)); + } + expect(profile.commits).toBe(hiddenCommits); + show(true); + expect(container.textContent).toBe("unsent draft:chunk 9"); + await act(async () => publish("live after reveal")); + expect(container.textContent).toBe("unsent draft:live after reveal"); + } finally { + act(() => root.render(null)); + store.clearSession(serverId); + } + }); +} diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index b90f5ccd24..b45b745212 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -37,6 +37,7 @@ import { Files, Smartphone, Sparkles, + Mic, Blocks, Globe, PanelLeft, @@ -118,6 +119,7 @@ import { PluginSettingsContent } from "@/plugins/settings"; import { useInstalledPlugins } from "@/plugins/registry"; import { HostPluginsPage } from "@/screens/settings/plugins-page"; import { MetadataGenerationPage } from "@/screens/settings/metadata-generation-page"; +import { HostVoicePage } from "@/screens/settings/voice/voice-page"; import ProjectsScreen from "@/screens/projects-screen"; import ProjectSettingsScreen from "@/screens/project-settings-screen"; import { SETTINGS_DESKTOP_SIDEBAR_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; @@ -133,7 +135,11 @@ import { type HostSectionSlug, type SettingsSectionSlug, } from "@/utils/host-routes"; -import { useLastWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; +import { + selectSettingsHost, + useSettingsHostSelection, + useSettingsSidebarScroll, +} from "@/navigation/settings-presentation"; import { returnFromSettings, type SettingsView } from "@/navigation/settings-navigation"; import { isWeb } from "@/constants/platform"; @@ -236,6 +242,7 @@ const HOST_SECTION_ITEMS: HostSectionItem[] = [ { id: "pair-device", labelKey: "openProject.tiles.pairDevice.title", icon: Smartphone }, { id: "agents", labelKey: "settings.hostSections.agents", icon: Bot }, { id: "metadata", labelKey: "settings.hostSections.metadata", icon: Sparkles }, + { id: "voice", labelKey: "settings.hostSections.voice", icon: Mic }, { id: "workspaces", labelKey: "settings.hostSections.workspaces", icon: FolderGit2 }, { id: "providers", labelKey: "settings.hostSections.providers", icon: Boxes }, { id: "usage", labelKey: "settings.hostSections.usage", icon: Gauge }, @@ -258,6 +265,8 @@ function renderHostSettingsContent( return ; case "metadata": return ; + case "voice": + return ; case "workspaces": return ; case "providers": @@ -945,6 +954,7 @@ function SettingsSidebar({ const items = SIDEBAR_SECTION_ITEMS.filter((item) => isSectionAvailable(item, isDesktopApp)); const insets = useSafeAreaInsets(); const isDesktop = layout === "desktop"; + const sidebarScroll = useSettingsSidebarScroll(isDesktop); const outerContainerStyle = useMemo( () => [isDesktop ? sidebarStyles.desktopContainer : sidebarStyles.mobileContainer], [isDesktop], @@ -1057,6 +1067,7 @@ function SettingsSidebar({ style={sidebarStyles.scrollBody} showsVerticalScrollIndicator={false} testID="settings-sidebar-scroll-body" + {...sidebarScroll} > {sidebarBody} @@ -1098,21 +1109,7 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti const hosts = useHosts(); const localServerId = useLocalDaemonServerId(); const sortedHosts = useSortedHosts(hosts, localServerId); - const lastWorkspaceSelection = useLastWorkspaceSelection(); - const routedSettingsHostServerId = - view.kind === "host" || view.kind === "project" || view.kind === "plugin" - ? view.serverId - : null; - const [selectedSettingsHostServerId, setSelectedSettingsHostServerId] = useState( - routedSettingsHostServerId ?? lastWorkspaceSelection?.serverId ?? null, - ); - useFocusEffect( - useCallback(() => { - setSelectedSettingsHostServerId( - routedSettingsHostServerId ?? lastWorkspaceSelection?.serverId ?? null, - ); - }, [lastWorkspaceSelection?.serverId, routedSettingsHostServerId]), - ); + const selectedSettingsHostServerId = useSettingsHostSelection(); // The host the four sections scope to: the host on the active view, otherwise // the picker choice, otherwise the connected local daemon, otherwise the first host. @@ -1232,7 +1229,7 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti // host detail route, keep that detail section and swap only the host segment. const handleSelectHost = useCallback( (serverId: string) => { - setSelectedSettingsHostServerId(serverId); + selectSettingsHost(serverId); if (view.kind === "project") { const target = buildSettingsHostSectionRoute(serverId, "projects"); if (isCompactLayout) { @@ -1471,7 +1468,11 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti - + {content} diff --git a/packages/app/src/screens/settings/chat/chat-section.tsx b/packages/app/src/screens/settings/chat/chat-section.tsx index 8a81d30073..4f4254cb8e 100644 --- a/packages/app/src/screens/settings/chat/chat-section.tsx +++ b/packages/app/src/screens/settings/chat/chat-section.tsx @@ -8,29 +8,25 @@ import { SettingsSwitch, } from "@/components/settings"; import { isNative } from "@/constants/platform"; -import { useAppSettings, type AppSettings } from "@/hooks/use-settings"; +import { useAppSettings, type ChatDisplay } from "@/hooks/use-settings"; -const TOOL_CALL_DETAIL_LEVELS: readonly AppSettings["toolCallDetailLevel"][] = [ - "detailed", - "overview", -]; +const CHAT_DISPLAYS: readonly ChatDisplay[] = ["full", "collapseToolCalls", "collapseTurns"]; export function ChatSection() { const { t } = useTranslation(); const { settings, updateSettings } = useAppSettings(); - const toolCallDetailOptions = useMemo( + const chatDisplayOptions = useMemo( () => - TOOL_CALL_DETAIL_LEVELS.map((value) => ({ + CHAT_DISPLAYS.map((value) => ({ value, - label: t(`settings.general.toolCallDetail.options.${value}`), + label: t(`settings.general.chatDisplay.options.${value}`), })), [t], ); - const changeToolCallDetailLevel = useCallback( - (toolCallDetailLevel: AppSettings["toolCallDetailLevel"]) => - void updateSettings({ toolCallDetailLevel }), + const changeChatDisplay = useCallback( + (chatDisplay: ChatDisplay) => void updateSettings({ chatDisplay }), [updateSettings], ); const changeAutoExpandReasoning = useCallback( @@ -53,11 +49,11 @@ export function ChatSection() { onValueChange={changeAutoExpandReasoning} /> {isNative ? null : ( + ) : ( diff --git a/packages/app/src/screens/settings/idle-runtime-cleanup-card.tsx b/packages/app/src/screens/settings/idle-runtime-cleanup-card.tsx new file mode 100644 index 0000000000..4d6fc355e8 --- /dev/null +++ b/packages/app/src/screens/settings/idle-runtime-cleanup-card.tsx @@ -0,0 +1,78 @@ +import { useCallback } from "react"; +import { Text, View } from "react-native"; +import { useMutation } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { StyleSheet } from "react-native-unistyles"; +import { SettingsInfoTip } from "@/components/settings/headings/settings-info-tip"; +import { Switch } from "@/components/ui/switch"; +import { useDaemonConfig } from "@/hooks/use-daemon-config"; +import { useHostFeature } from "@/runtime/host-features"; +import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { settingsStyles } from "@/styles/settings"; + +// Host-wide, persisted, and applied live by the daemon. +export function IdleRuntimeCleanupCard({ serverId }: { serverId: string }) { + const { t } = useTranslation(); + const isConnected = useHostRuntimeIsConnected(serverId); + const isSupported = useHostFeature(serverId, "idleRuntimeCleanupSetting"); + const { config, patchConfig } = useDaemonConfig(serverId); + const mutation = useMutation({ + mutationFn: async (next: boolean) => { + const result = await patchConfig({ idleRuntimeCleanup: next }); + if (!result) { + throw new Error(t("workspace.terminal.hostDisconnected")); + } + return result; + }, + }); + + const handleValueChange = useCallback( + (next: boolean) => { + mutation.mutate(next); + }, + [mutation], + ); + + if (!isConnected || !isSupported || !config) return null; + + return ( + + + + + + {t("settings.host.agents.idleRuntimeCleanup.title")} + + + + + {t("settings.host.agents.idleRuntimeCleanup.hint")} + + {mutation.error ? ( + + {mutation.error.message} + + ) : null} + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + titleRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, +})); diff --git a/packages/app/src/screens/settings/voice/speech-provider-form.test.ts b/packages/app/src/screens/settings/voice/speech-provider-form.test.ts new file mode 100644 index 0000000000..db88fc9bdd --- /dev/null +++ b/packages/app/src/screens/settings/voice/speech-provider-form.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from "vitest"; +import type { SpeechSettingsPatch } from "@getpaseo/protocol/speech/rpc-schemas"; +import { openSpeechProviderForm, type SpeechProviderFormSnapshot } from "./speech-provider-form"; + +function open(overrides: Partial = {}) { + return openSpeechProviderForm({ + siteId: "voiceMode.stt", + site: { provider: "local" }, + envOverrides: {}, + ...overrides, + }); +} + +async function savedPatch( + model: ReturnType, +): Promise { + let sent: SpeechSettingsPatch | undefined; + const result = await model.save(async (patch) => { + sent = patch; + }); + expect(result).toEqual({ status: "saved" }); + return sent; +} + +describe("speech provider form", () => { + it("edits and clears local STT language from the saved snapshot", async () => { + const form = open({ site: { provider: "local", language: "fr" } }); + expect(form.getState()).toMatchObject({ language: "fr", canSave: false }); + form.setLanguage(" de "); + expect(await savedPatch(form)).toEqual({ + voiceMode: { stt: { provider: "local", language: "de" } }, + }); + const clear = open({ site: { provider: "local", language: "fr" } }); + clear.setLanguage(""); + expect(await savedPatch(clear)).toEqual({ + voiceMode: { stt: { provider: "local", language: null } }, + }); + }); + + it("keeps STT language when switching either provider", async () => { + for (const provider of ["local", "openai"] as const) { + const form = open({ + site: + provider === "local" + ? { provider, language: "fr" } + : { provider, hasApiKey: false, language: "fr" }, + }); + form.setProvider(provider === "local" ? "openai" : "local"); + expect(await savedPatch(form)).toEqual({ + voiceMode: { stt: { provider: provider === "local" ? "openai" : "local", language: "fr" } }, + }); + } + }); + + it("keeps env-controlled language read-only after switching providers", async () => { + const form = open({ + site: { provider: "local", language: "fr" }, + envOverrides: { "features.voiceMode.stt.language": "PASEO_VOICE_LANGUAGE" }, + }); + form.setProvider("openai"); + expect(form.getState().fields.language).toEqual({ + envVar: "PASEO_VOICE_LANGUAGE", + readOnly: true, + }); + expect(await savedPatch(form)).toEqual({ voiceMode: { stt: { provider: "openai" } } }); + }); + + it("switches a site to OpenAI with a key and base URL", async () => { + const form = open(); + expect(form.getState().canSave).toBe(false); + + form.setProvider("openai"); + form.setApiKey(" sk-test "); + form.setBaseUrl("http://127.0.0.1:9999/v1"); + + expect(await savedPatch(form)).toEqual({ + voiceMode: { + stt: { provider: "openai", apiKey: "sk-test", baseUrl: "http://127.0.0.1:9999/v1" }, + }, + }); + }); + + it("re-saves OpenAI settings without touching the saved key", async () => { + const form = open({ + siteId: "dictation.stt", + site: { provider: "openai", hasApiKey: true, baseUrl: "https://a.example/v1" }, + }); + expect(form.getState()).toMatchObject({ apiKey: "", hasSavedApiKey: true, canSave: false }); + + form.setOpenAiModel("gpt-4o-transcribe"); + form.setBaseUrl(""); + + expect(await savedPatch(form)).toEqual({ + dictation: { stt: { provider: "openai", model: "gpt-4o-transcribe", baseUrl: null } }, + }); + }); + + it("selects a local model", async () => { + const form = open({ + siteId: "dictation.stt", + site: { provider: "local", model: "whisper-tiny" }, + }); + form.setLocalModel("whisper-tiny"); + expect(form.getState().canSave).toBe(false); + + form.setLocalModel("whisper-base"); + + expect(await savedPatch(form)).toEqual({ + dictation: { stt: { provider: "local", model: "whisper-base" } }, + }); + }); + + it("locks fields an env var decides and keeps fallback fields editable", async () => { + const form = open({ + site: { provider: "openai", hasApiKey: false }, + envOverrides: { + "features.voiceMode.stt.provider": "PASEO_VOICE_STT_PROVIDER", + "features.voiceMode.stt.model": "STT_MODEL", + "features.voiceMode.stt.apiKey": "OPENAI_API_KEY", + }, + }); + expect(form.getState().fields).toMatchObject({ + provider: { envVar: "PASEO_VOICE_STT_PROVIDER", readOnly: true }, + model: { envVar: "STT_MODEL", readOnly: true }, + apiKey: { envVar: "OPENAI_API_KEY", readOnly: false }, + baseUrl: { envVar: null, readOnly: false }, + }); + + form.setProvider("local"); + form.setOpenAiModel("gpt-4o-transcribe"); + form.setApiKey("sk-saved"); + + expect(form.getState().provider).toBe("openai"); + expect(await savedPatch(form)).toEqual({ + voiceMode: { stt: { provider: "openai", apiKey: "sk-saved" } }, + }); + }); + + it("does not apply the saved provider's env vars to another provider", () => { + const form = open({ + envOverrides: { "features.voiceMode.stt.model": "PASEO_VOICE_LOCAL_STT_MODEL" }, + }); + expect(form.getState().fields.model.readOnly).toBe(true); + + form.setProvider("openai"); + + expect(form.getState().fields.model).toEqual({ envVar: null, readOnly: false }); + }); + + it("keeps the input and allows a retry after a failed save", async () => { + const form = open({ siteId: "voiceMode.tts" }); + form.setProvider("openai"); + form.setApiKey("sk-test"); + form.setVoice("nova"); + const failure = new Error("disk full"); + + const result = await form.save(async () => { + expect(form.getState()).toMatchObject({ saving: true, canSave: false }); + throw failure; + }); + + expect(result).toEqual({ status: "failed", error: failure }); + expect(form.getState()).toMatchObject({ + provider: "openai", + apiKey: "sk-test", + voice: "nova", + saving: false, + canSave: true, + }); + expect(await savedPatch(form)).toEqual({ + voiceMode: { tts: { provider: "openai", apiKey: "sk-test", voice: "nova" } }, + }); + }); +}); diff --git a/packages/app/src/screens/settings/voice/speech-provider-form.ts b/packages/app/src/screens/settings/voice/speech-provider-form.ts new file mode 100644 index 0000000000..b8230104e5 --- /dev/null +++ b/packages/app/src/screens/settings/voice/speech-provider-form.ts @@ -0,0 +1,240 @@ +import type { SpeechSettingsPatch } from "@getpaseo/protocol/speech/rpc-schemas"; +import { + SPEECH_SITE_KIND, + buildSpeechSitePatch, + resolveOpenAiModel, + resolveOpenAiVoice, + speechSiteFieldPath, + type SpeechModelKind, + type SpeechProvider, + type SpeechSite, + type SpeechSiteId, + type SpeechSitePatch, +} from "./voice-settings"; + +// The provider sheet's form: one speech site, its provider and that provider's fields. +// Plain TypeScript so the save rules are testable without rendering (docs/forms.md). + +export interface SpeechProviderFormSnapshot { + siteId: SpeechSiteId; + site: Extract; + envOverrides: Record; +} + +export type SpeechProviderField = + | "provider" + | "model" + | "apiKey" + | "baseUrl" + | "language" + | "voice"; + +// An env var set for one of these is only a fallback: a value saved in the sheet wins, +// so the field stays editable. Every other field is decided by its env var. +const FALLBACK_FIELDS: ReadonlySet = new Set(["apiKey", "baseUrl"]); + +export interface SpeechProviderFieldSource { + /** The env var that decides this field's effective value, when one does. */ + envVar: string | null; + readOnly: boolean; +} + +export interface SpeechProviderFormState { + kind: SpeechModelKind; + provider: SpeechProvider; + /** The local model the user picked in this sheet; null keeps the saved or default model. */ + localModel: string | null; + savedLocalModel: string | undefined; + apiKey: string; + hasSavedApiKey: boolean; + baseUrl: string; + openAiModel: string; + language: string; + voice: string; + fields: Record; + saving: boolean; + canSave: boolean; +} + +export type SpeechProviderSaveResult = + | { status: "saved" } + | { status: "skipped" } + | { status: "failed"; error: unknown }; + +export interface SpeechProviderFormModel { + getState: () => SpeechProviderFormState; + subscribe: (listener: () => void) => () => void; + close: () => void; + setProvider: (provider: SpeechProvider) => void; + setLocalModel: (modelId: string) => void; + setApiKey: (value: string) => void; + setBaseUrl: (value: string) => void; + setOpenAiModel: (value: string) => void; + setLanguage: (value: string) => void; + setVoice: (value: string) => void; + /** Sends the changed fields through `save`; the inputs survive a failed save. */ + save: ( + save: (patch: SpeechSettingsPatch) => Promise, + ) => Promise; +} + +interface OpenAiValues { + baseUrl: string; + openAiModel: string; + voice: string; +} + +export function openSpeechProviderForm( + snapshot: SpeechProviderFormSnapshot, +): SpeechProviderFormModel { + const { siteId, site, envOverrides } = snapshot; + const kind = SPEECH_SITE_KIND[siteId]; + const savedOpenAi = site.provider === "openai" ? site : undefined; + const initialOpenAi: OpenAiValues = { + baseUrl: savedOpenAi?.baseUrl ?? "", + openAiModel: resolveOpenAiModel(kind, savedOpenAi?.model), + voice: resolveOpenAiVoice(savedOpenAi?.voice), + }; + const initialLanguage = site.language ?? ""; + // The daemon reports env vars for the provider that is in effect, so they only describe the + // provider-specific fields while that provider is selected. Language applies to either STT provider. + const savedFields = buildFieldSources(siteId, envOverrides); + const switchedFields = { + ...buildFieldSources(siteId, {}), + provider: savedFields.provider, + language: savedFields.language, + }; + const listeners = new Set<() => void>(); + let closed = false; + + let state: SpeechProviderFormState = derive({ + kind, + provider: site.provider, + localModel: null, + savedLocalModel: site.provider === "local" ? site.model : undefined, + apiKey: "", + hasSavedApiKey: savedOpenAi?.hasApiKey ?? false, + ...initialOpenAi, + language: initialLanguage, + fields: savedFields, + saving: false, + canSave: false, + }); + + function derive(next: SpeechProviderFormState): SpeechProviderFormState { + return { + ...next, + fields: next.provider === site.provider ? savedFields : switchedFields, + canSave: !next.saving && isChanged(next), + }; + } + + function isChanged(next: SpeechProviderFormState): boolean { + if (next.provider !== site.provider) return true; + if (kind === "stt" && next.language.trim() !== initialLanguage) return true; + if (next.provider === "local") { + return next.localModel !== null && next.localModel !== next.savedLocalModel; + } + return next.apiKey.trim().length > 0 || changedOpenAiFields(next).length > 0; + } + + function changedOpenAiFields(next: SpeechProviderFormState): (keyof OpenAiValues)[] { + const keys: (keyof OpenAiValues)[] = + kind === "stt" ? ["baseUrl", "openAiModel"] : ["baseUrl", "openAiModel", "voice"]; + return keys.filter((key) => next[key].trim() !== initialOpenAi[key]); + } + + function buildSitePatch(next: SpeechProviderFormState): SpeechSitePatch { + const language = next.language.trim(); + const languagePatch: Record = + kind === "stt" && + !next.fields.language.readOnly && + (language !== initialLanguage || (next.provider !== site.provider && language.length > 0)) + ? { language: language || null } + : {}; + if (next.provider === "local") { + return { + provider: "local", + ...languagePatch, + ...(next.localModel !== null && !next.fields.model.readOnly + ? { model: next.localModel } + : {}), + }; + } + const patch: Record = { provider: "openai", ...languagePatch }; + const apiKey = next.apiKey.trim(); + if (apiKey) patch.apiKey = apiKey; + for (const key of changedOpenAiFields(next)) { + const field = key === "openAiModel" ? "model" : key; + if (next.fields[field].readOnly) continue; + patch[field] = next[key].trim() || null; + } + return patch as SpeechSitePatch; + } + + function publish(next: SpeechProviderFormState): void { + if (closed) return; + state = derive(next); + for (const listener of listeners) listener(); + } + + function edit(change: Partial): void { + if (state.saving) return; + publish({ ...state, ...change }); + } + + return { + getState: () => state, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + close() { + closed = true; + listeners.clear(); + }, + setProvider: (provider) => { + if (!state.fields.provider.readOnly) edit({ provider }); + }, + setLocalModel: (localModel) => { + if (!state.fields.model.readOnly) edit({ localModel }); + }, + setApiKey: (apiKey) => edit({ apiKey }), + setBaseUrl: (baseUrl) => edit({ baseUrl }), + setOpenAiModel: (openAiModel) => edit({ openAiModel }), + setLanguage: (language) => edit({ language }), + setVoice: (voice) => edit({ voice }), + async save(save) { + if (!state.canSave) return { status: "skipped" }; + const patch = buildSpeechSitePatch(siteId, buildSitePatch(state)); + publish({ ...state, saving: true }); + try { + await save(patch); + return { status: "saved" }; + } catch (error) { + publish({ ...state, saving: false }); + return { status: "failed", error }; + } + }, + }; +} + +function buildFieldSources( + siteId: SpeechSiteId, + envOverrides: Record, +): Record { + const source = (field: SpeechProviderField): SpeechProviderFieldSource => { + const envVar = envOverrides[speechSiteFieldPath(siteId, field)] ?? null; + return { envVar, readOnly: envVar !== null && !FALLBACK_FIELDS.has(field) }; + }; + return { + provider: source("provider"), + model: source("model"), + apiKey: source("apiKey"), + baseUrl: source("baseUrl"), + language: source("language"), + voice: source("voice"), + }; +} diff --git a/packages/app/src/screens/settings/voice/speech-provider-sheet.tsx b/packages/app/src/screens/settings/voice/speech-provider-sheet.tsx new file mode 100644 index 0000000000..7c842053b3 --- /dev/null +++ b/packages/app/src/screens/settings/voice/speech-provider-sheet.tsx @@ -0,0 +1,585 @@ +import { + useCallback, + useEffect, + useMemo, + useState, + useSyncExternalStore, + type ReactNode, +} from "react"; +import { Text, View, type PressableStateCallbackType } from "react-native"; +import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { Check, Download, Languages, Trash2 } from "lucide-react-native"; +import type { LocalSpeechModel, SpeechSettingsPatch } from "@getpaseo/protocol/speech/rpc-schemas"; +import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; +import { + SettingsCard, + SettingsInfoTip, + SettingsInput, + SettingsRow, + SettingsSelect, +} from "@/components/settings"; +import { Button } from "@/components/ui/button"; +import { buttonControlHeight } from "@/components/ui/control-geometry"; +import { useContainedControlSize } from "@/components/ui/control-size"; +import { foregroundIconColorMapping } from "@/components/ui/icon-color"; +import { PressHighlight } from "@/components/ui/press-highlight"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { SegmentedControl } from "@/components/ui/segmented-control"; +import { StatusBadge } from "@/components/ui/status-badge"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { useToast } from "@/contexts/toast-api-context"; +import { ICON_SIZE } from "@/styles/theme"; +import { confirmDialog } from "@/utils/confirm-dialog"; +import type { LocalSpeechModels } from "./use-local-speech-models"; +import { + openSpeechProviderForm, + type SpeechProviderFieldSource, + type SpeechProviderFormModel, + type SpeechProviderFormSnapshot, + type SpeechProviderFormState, +} from "./speech-provider-form"; +import { + SPEECH_PROVIDERS, + buildOpenAiModelOptions, + buildOpenAiVoiceOptions, + formatLanguages, + formatModelSize, + getLocalModelState, + resolveLocalModel, + type LocalModelState, + type SpeechProvider, +} from "./voice-settings"; + +const ThemedCheck = withUnistyles(Check); + +export interface SpeechProviderSheetProps extends SpeechProviderFormSnapshot { + title: string; + localModels: LocalSpeechModels; + visible: boolean; + onClose: () => void; + onDismiss: () => void; + save: (patch: SpeechSettingsPatch) => Promise; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +// The form opens from the props of the first render; later config changes do not reset input. +function useSpeechProviderForm(snapshot: SpeechProviderFormSnapshot): SpeechProviderFormModel { + const [model] = useState(() => openSpeechProviderForm(snapshot)); + useEffect(() => () => model.close(), [model]); + return model; +} + +/** One speech site's provider and that provider's settings. Mount it fresh for each open. */ +export function SpeechProviderSheet({ + title, + siteId, + site, + envOverrides, + localModels, + visible, + onClose, + onDismiss, + save, +}: SpeechProviderSheetProps) { + const { t } = useTranslation(); + const toast = useToast(); + const model = useSpeechProviderForm({ siteId, site, envOverrides }); + const state = useSyncExternalStore(model.subscribe, model.getState, model.getState); + + const submit = useCallback(() => { + void model.save(save).then((result) => { + if (result.status === "saved") onClose(); + if (result.status === "failed") { + toast.error(`${t("settings.voice.saveError")}: ${errorMessage(result.error)}`); + } + return undefined; + }); + }, [model, onClose, save, t, toast]); + + const header = useMemo(() => ({ title }), [title]); + const footer = useMemo( + () => ( + + + + + ), + [onClose, state.canSave, state.saving, submit, t], + ); + + return ( + + + + + {state.provider === "local" ? ( + <> + {state.kind === "stt" ? ( + + + + ) : null} + + + ) : ( + + )} + + ); +} + +interface FormProps { + state: SpeechProviderFormState; + model: SpeechProviderFormModel; +} + +function setByHint(t: TFunction, source: SpeechProviderFieldSource): string | undefined { + return source.envVar ? t("settings.voice.setBy", { name: source.envVar }) : undefined; +} + +function ProviderRow({ state, model }: FormProps) { + const { t } = useTranslation(); + const source = state.fields.provider; + const options = useMemo( + () => + SPEECH_PROVIDERS.map((provider) => ({ + value: provider, + label: t(`settings.voice.providers.${provider}`), + disabled: source.readOnly, + })), + [source.readOnly, t], + ); + return ( + + + options={options} + value={state.provider} + onValueChange={model.setProvider} + testID="voice-provider-picker" + /> + + ); +} + +function LanguageField({ state, model }: FormProps) { + const { t } = useTranslation(); + return ( + + ); +} + +// --------------------------------------------------------------------------- +// OpenAI +// --------------------------------------------------------------------------- + +function OpenAiFields({ state, model }: FormProps) { + const { t } = useTranslation(); + const modelOptions = useMemo( + () => buildOpenAiModelOptions(state.kind, state.openAiModel), + [state.kind, state.openAiModel], + ); + const voiceOptions = useMemo(() => buildOpenAiVoiceOptions(state.voice), [state.voice]); + const apiKeyInfo = useMemo( + () => ( + + ), + [t], + ); + const baseUrlInfo = useMemo( + () => ( + + ), + [t], + ); + + return ( + + + + + {state.kind === "stt" ? ( + + ) : ( + + )} + + ); +} + +// --------------------------------------------------------------------------- +// Local: the catalog, with the selected model checked +// --------------------------------------------------------------------------- + +function LocalModelList({ + state, + model, + localModels, +}: FormProps & { localModels: LocalSpeechModels }) { + const { t } = useTranslation(); + const { models, isLoading, error, refresh, download, remove } = localModels; + const kindModels = useMemo( + () => models.filter((entry) => entry.kind === state.kind), + [models, state.kind], + ); + const selectedId = + state.localModel ?? + resolveLocalModel({ kind: state.kind, model: state.savedLocalModel, localModels: models })?.id; + const source = state.fields.model; + + if (kindModels.length === 0) { + if (error) { + return ( + + + + + + ); + } + return ( + + + + ); + } + return ( + + {kindModels.map((entry) => ( + + ))} + + ); +} + +function progressLabel( + t: TFunction, + state: Extract, +): string { + if (state.kind === "extracting") return t("settings.voice.models.extracting"); + if (state.percent === null) return t("settings.voice.models.downloading"); + return t("settings.voice.models.downloadingPercent", { percent: state.percent }); +} + +type HoverState = PressableStateCallbackType & { hovered?: boolean }; + +function LocalModelRow({ + model, + selected, + selectionLocked, + hint, + onSelect, + onDownload, + onDelete, +}: { + model: LocalSpeechModel; + selected: boolean; + selectionLocked: boolean; + /** Why the selection is fixed, shown on the selected row. */ + hint: string | undefined; + onSelect: (modelId: string) => void; + onDownload: (modelId: string) => Promise; + onDelete: (modelId: string) => Promise; +}) { + const { t, i18n } = useTranslation(); + const state = getLocalModelState(model); + const [actionError, setActionError] = useState(null); + + const select = useCallback(() => onSelect(model.id), [model.id, onSelect]); + const download = useCallback(() => { + setActionError(null); + onDownload(model.id).catch((error: unknown) => setActionError(errorMessage(error))); + }, [model.id, onDownload]); + const remove = useCallback(() => { + void confirmDialog({ + title: t("settings.voice.models.deleteTitle", { label: model.label }), + message: t("settings.voice.models.deleteMessage", { + size: formatModelSize(model.downloadSizeBytes), + }), + confirmLabel: t("settings.voice.models.delete"), + destructive: true, + }) + .then((confirmed) => { + if (!confirmed) return undefined; + setActionError(null); + return onDelete(model.id); + }) + .catch((error: unknown) => setActionError(errorMessage(error))); + }, [model.downloadSizeBytes, model.id, model.label, onDelete, t]); + + const languages = formatLanguages({ + languages: model.languages, + locale: i18n.language, + formatMore: (listed, count) => + t("settings.voice.models.moreLanguages", { languages: listed, count }), + }); + const details = useMemo( + () => ( + + + + {formatModelSize(model.downloadSizeBytes)} + {state.kind === "installed" ? ( + + ) : null} + {hint ? {hint} : null} + + ), + [ + hint, + languages, + model.description, + model.downloadSizeBytes, + model.id, + model.inUse, + model.label, + state.kind, + t, + ], + ); + + const recommended = useMemo( + () => + model.isDefault ? ( + + ) : undefined, + [model.isDefault, t], + ); + + // Phone rows have no room beside the label for a labelled button, so actions show their icon. + const compact = useIsCompactFormFactor(); + const downloadLabel = + state.kind === "failed" ? t("settings.voice.retry") : t("settings.voice.models.download"); + let control = null; + if (state.kind === "downloading" || state.kind === "extracting") { + control = ( + + + {progressLabel(t, state)} + + ); + } else if (state.kind === "installed") { + control = model.inUse ? null : ( + + ); + } else { + control = ( + + ); + } + + const rowStyle = useCallback( + ({ hovered, pressed }: HoverState) => [ + !selectionLocked && hovered && styles.rowHovered, + !selectionLocked && pressed && styles.rowPressed, + ], + [selectionLocked], + ); + const accessibilityState = useMemo( + () => ({ checked: selected, disabled: selectionLocked }), + [selected, selectionLocked], + ); + + return ( + + + + {control} + + {selected ? ( + + ) : null} + + + + + ); +} + +// Every catalog row is as tall as one holding a button, so a row does not shrink when its +// download finishes and the button goes away. +function TrailingControls({ children }: { children: ReactNode }) { + const size = useContainedControlSize() ?? "sm"; + const style = useMemo(() => [styles.trailing, { minHeight: buttonControlHeight[size] }], [size]); + return {children}; +} + +const styles = StyleSheet.create((theme) => ({ + footer: { + flex: 1, + flexDirection: "row", + gap: theme.spacing[3], + }, + footerButton: { + flex: 1, + }, + rowHovered: { + backgroundColor: theme.colors.surface2, + }, + rowPressed: { + backgroundColor: theme.colors.surface3, + }, + // The model's details sit on the hint line, so every catalog row has the same two lines. + details: { + flexDirection: "row", + flexWrap: "wrap", + alignItems: "center", + gap: theme.spacing[2], + marginTop: theme.spacing[1], + }, + metaText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + fontVariant: ["tabular-nums"], + }, + trailing: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + checkSlot: { + width: ICON_SIZE.sm, + alignItems: "center", + }, + progress: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + spinnerColor: { + color: theme.colors.foregroundMuted, + }, +})); diff --git a/packages/app/src/screens/settings/voice/use-local-speech-models.ts b/packages/app/src/screens/settings/voice/use-local-speech-models.ts new file mode 100644 index 0000000000..b68a7b8135 --- /dev/null +++ b/packages/app/src/screens/settings/voice/use-local-speech-models.ts @@ -0,0 +1,71 @@ +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import type { LocalSpeechModel } from "@getpaseo/protocol/speech/rpc-schemas"; +import { useFetchQuery } from "@/data/query"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { isLocalModelBusy } from "./voice-settings"; + +const DOWNLOAD_POLL_INTERVAL_MS = 1000; + +const localSpeechModelsQueryKey = (serverId: string) => ["speech-local-models", serverId] as const; + +export interface LocalSpeechModels { + models: LocalSpeechModel[]; + isLoading: boolean; + error: Error | null; + refresh: () => void; + download: (modelId: string) => Promise; + remove: (modelId: string) => Promise; +} + +// The daemon reports download progress through the list, so the list polls while any +// model is downloading or extracting. +export function useLocalSpeechModels(serverId: string): LocalSpeechModels { + const { t } = useTranslation(); + const client = useHostRuntimeClient(serverId); + const isConnected = useHostRuntimeIsConnected(serverId); + const query = useFetchQuery({ + queryKey: localSpeechModelsQueryKey(serverId), + enabled: Boolean(client && isConnected), + dataShape: "list", + staleTimeMs: 0, + queryFn: () => { + if (!client) throw new Error(t("workspace.terminal.hostDisconnected")); + return client.listLocalSpeechModels(); + }, + refetchInterval: (current) => + current.state.data?.some(isLocalModelBusy) ? DOWNLOAD_POLL_INTERVAL_MS : false, + }); + const { refetch } = query; + + const refresh = useCallback(() => { + void refetch(); + }, [refetch]); + + const download = useCallback( + async (modelId: string) => { + if (!client) return; + await client.downloadLocalSpeechModel(modelId); + await refetch(); + }, + [client, refetch], + ); + + const remove = useCallback( + async (modelId: string) => { + if (!client) return; + await client.deleteLocalSpeechModel(modelId); + await refetch(); + }, + [client, refetch], + ); + + return { + models: query.data ?? [], + isLoading: query.isLoading, + error: query.error, + refresh, + download, + remove, + }; +} diff --git a/packages/app/src/screens/settings/voice/voice-page.tsx b/packages/app/src/screens/settings/voice/voice-page.tsx new file mode 100644 index 0000000000..b8b6402b76 --- /dev/null +++ b/packages/app/src/screens/settings/voice/voice-page.tsx @@ -0,0 +1,252 @@ +import { useCallback, useState } from "react"; +import { View } from "react-native"; +import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; +import { StyleSheet } from "react-native-unistyles"; +import type { SpeechSettingsPatch } from "@getpaseo/protocol/speech/rpc-schemas"; +import { + SettingsCard, + SettingsDetailRow, + SettingsRow, + SettingsSection, +} from "@/components/settings"; +import { Alert } from "@/components/ui/alert"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { Switch } from "@/components/ui/switch"; +import { useToast } from "@/contexts/toast-api-context"; +import { useDaemonConfig } from "@/hooks/use-daemon-config"; +import { useHostFeature } from "@/runtime/host-features"; +import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { SpeechProviderSheet } from "./speech-provider-sheet"; +import { useLocalSpeechModels } from "./use-local-speech-models"; +import { + getSpeechSite, + summarizeSpeechSite, + type SpeechSiteId, + type SpeechSiteSummary, +} from "./voice-settings"; + +type SavePatch = (patch: SpeechSettingsPatch) => Promise; +const EMPTY_ENV_OVERRIDES: Record = {}; + +// The sheet mounts fresh for every open (docs/forms.md); `openCount` keys each mount. +interface OpenSheet { + siteId: SpeechSiteId; + visible: boolean; + openCount: number; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export function HostVoicePage({ serverId }: { serverId: string }) { + const { t } = useTranslation(); + const toast = useToast(); + const isConnected = useHostRuntimeIsConnected(serverId); + const supported = useHostFeature(serverId, "speechSettings"); + const { config, patchConfig } = useDaemonConfig(serverId); + const localModels = useLocalSpeechModels(serverId); + const { refresh: refreshModels } = localModels; + const [sheet, setSheet] = useState(null); + + // Selecting a model changes which models are in use, so the catalog is re-read after a save. + const save = useCallback( + async (patch) => { + await patchConfig({ features: patch }); + refreshModels(); + }, + [patchConfig, refreshModels], + ); + const saveSwitch = useCallback( + (patch: SpeechSettingsPatch) => { + save(patch).catch((error: unknown) => { + toast.error(`${t("settings.voice.saveError")}: ${errorMessage(error)}`); + }); + }, + [save, t, toast], + ); + const setDictationEnabled = useCallback( + (enabled: boolean) => saveSwitch({ dictation: { enabled } }), + [saveSwitch], + ); + const setVoiceModeEnabled = useCallback( + (enabled: boolean) => saveSwitch({ voiceMode: { enabled } }), + [saveSwitch], + ); + const openSheet = useCallback((siteId: SpeechSiteId) => { + setSheet((current) => ({ + siteId, + visible: true, + openCount: (current?.openCount ?? 0) + 1, + })); + }, []); + const closeSheet = useCallback(() => { + setSheet((current) => (current ? { ...current, visible: false } : null)); + }, []); + const dismissSheet = useCallback(() => setSheet(null), []); + + if (!isConnected) { + return ; + } + if (!supported) { + return ; + } + const features = config?.features; + if (!features) { + return ( + + + + ); + } + const envOverrides = config?.envOverrides ?? EMPTY_ENV_OVERRIDES; + const siteRow = (siteId: SpeechSiteId, label: string) => ( + + ); + const sheetSite = sheet ? getSpeechSite(features, sheet.siteId) : null; + + return ( + + + + + {siteRow("dictation.stt", t("settings.voice.sites.dictationStt"))} + + + + + + {siteRow("voiceMode.stt", t("settings.voice.sites.voiceModeStt"))} + {siteRow("voiceMode.tts", t("settings.voice.sites.voiceModeTts"))} + + + {sheet && sheetSite && sheetSite.provider !== "unknown" ? ( + + ) : null} + + ); +} + +function siteLabel(t: TFunction, siteId: SpeechSiteId): string { + switch (siteId) { + case "dictation.stt": + return t("settings.voice.sites.dictationStt"); + case "voiceMode.stt": + return t("settings.voice.sites.voiceModeStt"); + case "voiceMode.tts": + return t("settings.voice.sites.voiceModeTts"); + } +} + +function FeatureSwitchRow({ + label, + hint, + envVar, + value, + onChange, +}: { + label: string; + hint: string; + envVar: string | undefined; + value: boolean; + onChange: (value: boolean) => void; +}) { + const { t } = useTranslation(); + return ( + + + + ); +} + +function SpeechSiteRow({ + siteId, + label, + summary, + onOpen, +}: { + siteId: SpeechSiteId; + label: string; + summary: SpeechSiteSummary; + onOpen: (siteId: SpeechSiteId) => void; +}) { + const { t } = useTranslation(); + const open = useCallback(() => onOpen(siteId), [onOpen, siteId]); + return ( + + ); +} + +function summaryLabel(t: TFunction, summary: SpeechSiteSummary): string { + switch (summary.kind) { + case "local": + return summary.model + ? `${t("settings.voice.providers.local")} · ${summary.model}` + : t("settings.voice.providers.local"); + case "openai": + return `${t("settings.voice.providers.openai")} · ${ + summary.missingKey ? t("settings.voice.openai.missingKey") : summary.model + }`; + case "unknown": + return t("settings.voice.unknownProvider", { provider: summary.provider }); + } +} + +const styles = StyleSheet.create((theme) => ({ + loading: { + paddingVertical: theme.spacing[8], + alignItems: "center", + }, + spinnerColor: { + color: theme.colors.foregroundMuted, + }, +})); diff --git a/packages/app/src/screens/settings/voice/voice-settings.test.ts b/packages/app/src/screens/settings/voice/voice-settings.test.ts new file mode 100644 index 0000000000..2541c4892c --- /dev/null +++ b/packages/app/src/screens/settings/voice/voice-settings.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import type { LocalSpeechModel } from "@getpaseo/protocol/speech/rpc-schemas"; +import { + buildOpenAiModelOptions, + getSpeechSite, + formatLanguages, + formatModelSize, + getLocalModelState, + summarizeSpeechSite, +} from "./voice-settings"; + +function model(overrides: Partial): LocalSpeechModel { + return { + id: "parakeet-tdt-0.6b-v2-int8", + kind: "stt", + label: "Parakeet v2", + description: "Accurate English transcription.", + languages: ["en"], + downloadSizeBytes: 482_468_385, + isDefault: true, + inUse: true, + status: "installed", + ...overrides, + }; +} + +const catalog = [ + model({}), + model({ + id: "parakeet-tdt-110m-en-int8", + label: "Parakeet Small", + isDefault: false, + status: "not_installed", + inUse: false, + }), + model({ id: "kokoro-en-v0_19", kind: "tts", label: "Kokoro", inUse: false }), +]; + +describe("voice settings", () => { + it("keeps local STT language from the wire when opening a provider sheet", () => { + const features = { + dictation: { enabled: true, stt: { provider: "local", language: "fr" } }, + voiceMode: { + enabled: false, + stt: { provider: "local", language: "de" }, + tts: { provider: "local" }, + }, + }; + expect(getSpeechSite(features, "dictation.stt")).toMatchObject({ + provider: "local", + language: "fr", + }); + expect(getSpeechSite(features, "voiceMode.stt")).toMatchObject({ + provider: "local", + language: "de", + }); + }); + + it("summarizes a local site by its model, falling back to the catalog default", () => { + const summary = (modelId?: string) => + summarizeSpeechSite({ + siteId: "dictation.stt", + site: { provider: "local", model: modelId }, + envOverrides: {}, + localModels: catalog, + }); + expect(summary()).toEqual({ kind: "local", model: "Parakeet v2" }); + expect(summary("parakeet-tdt-110m-en-int8")).toEqual({ + kind: "local", + model: "Parakeet Small", + }); + }); + + it("flags an OpenAI site with no saved key and no env key", () => { + const summary = (envOverrides: Record) => + summarizeSpeechSite({ + siteId: "voiceMode.tts", + site: { provider: "openai", hasApiKey: false }, + envOverrides, + localModels: catalog, + }); + expect(summary({})).toEqual({ kind: "openai", model: "tts-1", missingKey: true }); + expect(summary({ "features.voiceMode.tts.apiKey": "OPENAI_API_KEY" })).toEqual({ + kind: "openai", + model: "tts-1", + missingKey: false, + }); + }); + + it("keeps a custom OpenAI-compatible model id selectable", () => { + expect(buildOpenAiModelOptions("stt", "my-whisper").at(-1)).toEqual({ + value: "my-whisper", + label: "my-whisper", + }); + }); + + it("reports download progress as a percentage", () => { + expect( + getLocalModelState(model({ status: "downloading", receivedBytes: 241, totalBytes: 482 })), + ).toEqual({ kind: "downloading", percent: 50 }); + expect(getLocalModelState(model({ status: "downloading" }))).toEqual({ + kind: "downloading", + percent: null, + }); + }); + + it("formats download sizes", () => { + expect(formatModelSize(482_468_385)).toBe("482 MB"); + expect(formatModelSize(1_250_000_000)).toBe("1.3 GB"); + }); + + it("names a short language list and counts the rest of a long one", () => { + const formatMore = (languages: string, count: number) => `${languages} and ${count} more`; + expect(formatLanguages({ languages: ["en", "de"], locale: "en", formatMore })).toBe( + "English, German", + ); + const whisper = ["en", "zh", "de", "es", "ru", "ko", "fr", "ja", "pt", "tr", "pl", "ca"]; + expect(formatLanguages({ languages: whisper, locale: "en", formatMore })).toBe( + "English, Chinese, German, Spanish, Russian, Korean, French, Japanese, Portuguese, Turkish and 2 more", + ); + }); +}); diff --git a/packages/app/src/screens/settings/voice/voice-settings.ts b/packages/app/src/screens/settings/voice/voice-settings.ts new file mode 100644 index 0000000000..79c1062b49 --- /dev/null +++ b/packages/app/src/screens/settings/voice/voice-settings.ts @@ -0,0 +1,240 @@ +import type { + LocalSpeechModel, + SpeechSettings, + SpeechSettingsPatch, +} from "@getpaseo/protocol/speech/rpc-schemas"; + +// What the Voice page needs to know about speech providers and models, kept free of React +// so the option lists and model states are testable on their own. + +export type SpeechProvider = "local" | "openai"; +export type SpeechModelKind = "stt" | "tts"; + +export const SPEECH_PROVIDERS: readonly SpeechProvider[] = ["local", "openai"]; + +// The three places that use speech. Each carries its own provider settings in config.json +// under `features.`. +export type SpeechSiteId = "dictation.stt" | "voiceMode.stt" | "voiceMode.tts"; + +export const SPEECH_SITE_KIND: Record = { + "dictation.stt": "stt", + "voiceMode.stt": "stt", + "voiceMode.tts": "tts", +}; + +/** The config path the daemon names in `envOverrides` for a field of a site. */ +export function speechSiteFieldPath(siteId: SpeechSiteId, field: string): string { + return `features.${siteId}.${field}`; +} + +/** One site's settings as the daemon reports them, narrowed to what the app can edit. */ +export type SpeechSite = + | { provider: "local"; model?: string; language?: string } + | { + provider: "openai"; + hasApiKey: boolean; + baseUrl?: string; + model?: string; + language?: string; + voice?: string; + } + | { provider: "unknown"; id: string }; + +type SpeechSiteSettings = SpeechSettings["dictation"]["stt"] | SpeechSettings["voiceMode"]["tts"]; + +export function getSpeechSite(features: SpeechSettings, siteId: SpeechSiteId): SpeechSite { + switch (siteId) { + case "dictation.stt": + return toSpeechSite(features.dictation.stt); + case "voiceMode.stt": + return toSpeechSite(features.voiceMode.stt); + case "voiceMode.tts": + return toSpeechSite(features.voiceMode.tts); + } +} + +function toSpeechSite(settings: SpeechSiteSettings): SpeechSite { + const text = (value: unknown) => (typeof value === "string" && value ? value : undefined); + if (settings.provider === "local") + return { provider: "local", model: text(settings.model), language: text(settings.language) }; + if (settings.provider === "openai") { + return { + provider: "openai", + hasApiKey: settings.hasApiKey === true, + baseUrl: text(settings.baseUrl), + model: text(settings.model), + language: text(settings.language), + voice: text(settings.voice), + }; + } + return { provider: "unknown", id: settings.provider }; +} + +export type SpeechSitePatch = NonNullable< + | NonNullable["stt"] + | NonNullable["tts"] +>; + +export function buildSpeechSitePatch( + siteId: SpeechSiteId, + patch: SpeechSitePatch, +): SpeechSettingsPatch { + switch (siteId) { + case "dictation.stt": + return { dictation: { stt: patch } }; + case "voiceMode.stt": + return { voiceMode: { stt: patch } }; + case "voiceMode.tts": + return { voiceMode: { tts: patch } }; + } +} + +// OpenAI accepts free-form ids so self-hosted servers can use their own; these are the +// ones the page offers. A saved id outside the list is still shown as an option. +const OPENAI_MODELS: Record = { + stt: { + ids: ["whisper-1", "gpt-4o-transcribe", "gpt-4o-mini-transcribe"], + defaultId: "whisper-1", + }, + tts: { ids: ["tts-1", "tts-1-hd", "gpt-4o-mini-tts"], defaultId: "tts-1" }, +}; + +const OPENAI_VOICES = ["alloy", "ash", "coral", "echo", "fable", "nova", "onyx", "sage", "shimmer"]; +const DEFAULT_OPENAI_VOICE = "alloy"; + +export interface SpeechModelOption { + value: string; + label: string; +} + +export function resolveOpenAiModel(kind: SpeechModelKind, model: string | undefined): string { + return model ?? OPENAI_MODELS[kind].defaultId; +} + +export function resolveLocalModel(input: { + kind: SpeechModelKind; + model: string | undefined; + localModels: readonly LocalSpeechModel[]; +}): LocalSpeechModel | undefined { + const kindModels = input.localModels.filter((model) => model.kind === input.kind); + if (input.model) return kindModels.find((model) => model.id === input.model); + return kindModels.find((model) => model.isDefault); +} + +export function buildOpenAiModelOptions( + kind: SpeechModelKind, + selected: string, +): SpeechModelOption[] { + return withSelected( + OPENAI_MODELS[kind].ids.map((id) => ({ value: id, label: id })), + selected, + ); +} + +export function resolveOpenAiVoice(voice: string | undefined): string { + return voice ?? DEFAULT_OPENAI_VOICE; +} + +export function buildOpenAiVoiceOptions(selected: string): SpeechModelOption[] { + return withSelected( + OPENAI_VOICES.map((voice) => ({ value: voice, label: voice })), + selected, + ); +} + +function withSelected(options: SpeechModelOption[], selected: string): SpeechModelOption[] { + if (!selected || options.some((option) => option.value === selected)) return options; + return [...options, { value: selected, label: selected }]; +} + +// What a page row says about a site before it is opened. +export type SpeechSiteSummary = + | { kind: "local"; model: string | null } + | { kind: "openai"; model: string; missingKey: boolean } + | { kind: "unknown"; provider: string }; + +export function summarizeSpeechSite(input: { + siteId: SpeechSiteId; + site: SpeechSite; + envOverrides: Record; + localModels: readonly LocalSpeechModel[]; +}): SpeechSiteSummary { + const { site, siteId } = input; + const kind = SPEECH_SITE_KIND[siteId]; + switch (site.provider) { + case "local": { + const model = resolveLocalModel({ kind, model: site.model, localModels: input.localModels }); + return { kind: "local", model: model?.label ?? site.model ?? null }; + } + case "openai": + return { + kind: "openai", + model: resolveOpenAiModel(kind, site.model), + missingKey: !site.hasApiKey && !input.envOverrides[speechSiteFieldPath(siteId, "apiKey")], + }; + case "unknown": + return { kind: "unknown", provider: site.id }; + } +} + +export type LocalModelState = + | { kind: "installed" } + | { kind: "not_installed" } + | { kind: "downloading"; percent: number | null } + | { kind: "extracting" } + | { kind: "failed"; error: string }; + +export function getLocalModelState(model: LocalSpeechModel): LocalModelState { + switch (model.status) { + case "installed": + return { kind: "installed" }; + case "downloading": { + const total = model.totalBytes ?? 0; + const received = model.receivedBytes ?? 0; + return { + kind: "downloading", + percent: total > 0 ? Math.min(100, Math.floor((received / total) * 100)) : null, + }; + } + case "extracting": + return { kind: "extracting" }; + case "failed": + return { kind: "failed", error: model.error ?? "" }; + default: + return { kind: "not_installed" }; + } +} + +export function isLocalModelBusy(model: LocalSpeechModel): boolean { + return model.status === "downloading" || model.status === "extracting"; +} + +export function formatModelSize(bytes: number): string { + const megabytes = bytes / 1_000_000; + if (megabytes >= 1000) return `${(megabytes / 1000).toFixed(1)} GB`; + return `${Math.round(megabytes)} MB`; +} + +// Multilingual models list close to a hundred languages; past this many the tooltip names +// the first ones and counts the rest. +const MAX_LISTED_LANGUAGES = 10; + +export function formatLanguages(input: { + languages: readonly string[]; + locale: string; + formatMore: (languages: string, count: number) => string; +}): string { + const names = languageNames(input.locale); + const listed = input.languages + .slice(0, MAX_LISTED_LANGUAGES) + .map((code) => names?.of(code) ?? code.toUpperCase()) + .join(", "); + const hidden = input.languages.length - MAX_LISTED_LANGUAGES; + return hidden > 0 ? input.formatMore(listed, hidden) : listed; +} + +// Hermes ships without Intl.DisplayNames; the language code is the fallback label there. +function languageNames(locale: string): Intl.DisplayNames | null { + if (typeof Intl.DisplayNames !== "function") return null; + return new Intl.DisplayNames([locale], { type: "language" }); +} diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index bcc2593611..3617c074e2 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -3187,6 +3187,7 @@ function WorkspaceScreenContent({ diff --git a/packages/app/src/stores/session-store.test.ts b/packages/app/src/stores/session-store.test.ts index 86ea81afc5..fb38605220 100644 --- a/packages/app/src/stores/session-store.test.ts +++ b/packages/app/src/stores/session-store.test.ts @@ -5,6 +5,7 @@ import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages"; import { normalizeWorkspaceDescriptor, + selectAgentSentMessages, selectAgentTurnPresentation, selectAgentTimelineState, useSessionStore, @@ -235,6 +236,25 @@ describe("agent timeline state", () => { ).toEqual({ status: "synced", items: [], range: null, older: "none", newer: "none" }); }); + it("lists sent messages newest first, including the live head", () => { + initializeTestSession(); + const sent = (id: string, text: string) => ({ ...submittedMessage(id), text }); + useSessionStore.getState().applyAgentTimelineResponseState("test-server", "agent-1", { + items: [sent("m1", "fix the auth bug"), sent("m2", "add tests"), sent("m3", "add tests")], + head: [sent("m4", " "), sent("m5", "refactor the wrong module")], + range: { epoch: "epoch-1", startSeq: 1, endSeq: 5 }, + older: "none", + newer: false, + synchronized: true, + acknowledgedClientMessageIds: [], + }); + + expect( + selectAgentSentMessages(useSessionStore.getState().sessions["test-server"], "agent-1"), + ).toEqual(["refactor the wrong module", "add tests", "fix the auth bug"]); + expect(selectAgentSentMessages(undefined, "agent-1")).toEqual([]); + }); + it("preserves older availability when an applied response leaves it unchanged", () => { initializeTestSession(); const store = useSessionStore.getState(); diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index aed06eef11..911573762f 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -332,6 +332,26 @@ export function selectAgentTimelineState( return items.length > 0 ? { status: "painted", items } : { status: "cold" }; } +/** Text of the loaded user messages, newest first, with back-to-back repeats collapsed. */ +export function selectAgentSentMessages( + session: SessionState | undefined, + agentId: string, +): string[] { + if (!session) return []; + const items = [ + ...(session.agentStreamTail.get(agentId) ?? []), + ...(session.agentStreamHead.get(agentId) ?? []), + ]; + const messages: string[] = []; + for (let index = items.length - 1; index >= 0; index -= 1) { + const item = items[index]; + if (item.kind !== "user_message" || item.text.trim().length === 0) continue; + if (messages[messages.length - 1] === item.text) continue; + messages.push(item.text); + } + return messages; +} + export function selectAgentTurnPresentation( session: SessionState | undefined, agentId: string, diff --git a/packages/app/src/styles/unistyles-props.test.ts b/packages/app/src/styles/unistyles-props.test.ts new file mode 100644 index 0000000000..05d66a7381 --- /dev/null +++ b/packages/app/src/styles/unistyles-props.test.ts @@ -0,0 +1,86 @@ +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { build } from "esbuild"; +import { describe, expect, it } from "vitest"; + +// Bypass the app test aliases: they replace all three libraries involved in the leak. +async function renderWebProbe(contents: string) { + const { outputFiles } = await build({ + stdin: { + contents, + resolveDir: path.resolve(import.meta.dirname, "../.."), + loader: "jsx", + }, + bundle: true, + platform: "node", + format: "cjs", + write: false, + mainFields: ["browser", "module", "main"], + conditions: ["browser"], + resolveExtensions: [".web.tsx", ".web.ts", ".web.js", ".tsx", ".ts", ".js", ".json"], + alias: { "react-native": "react-native-web", "@": path.resolve(import.meta.dirname, "..") }, + jsx: "automatic", + define: { __DEV__: "true", "process.env.NODE_ENV": '"development"' }, + }); + return spawnSync(process.execPath, ["-"], { + input: outputFiles[0].text, + encoding: "utf8", + }); +} + +describe("Unistyles web component props", () => { + it("consumes uniProps while preserving mapped colors and explicit overrides", async () => { + const result = await renderWebProbe(` + import React from 'react'; + import { renderToStaticMarkup } from 'react-dom/server'; + import { StyleSheet, UnistylesRuntime, withUnistyles } from 'react-native-unistyles'; + import { Server } from 'lucide-react-native'; + StyleSheet.configure({ + themes: { light: { color: 'red' }, dark: { color: 'blue' } }, + settings: { initialTheme: 'light' }, + }); + const Icon = withUnistyles(Server); + const mapping = theme => ({ color: theme.color }); + const render = props => renderToStaticMarkup(React.createElement(Icon, { uniProps: mapping, ...props })); + const light = render(); + UnistylesRuntime.setTheme('dark'); + const dark = render(); + const overridden = render({ color: 'green' }); + const received = []; + const Probe = withUnistyles(props => { received.push(props); return null; }); + renderToStaticMarkup(React.createElement(Probe, { uniProps: mapping, id: 'probe' })); + console.log(JSON.stringify({ light, dark, overridden, keys: Object.keys(received[0]).sort() })); + `); + expect(result.status, result.stderr).toBe(0); + expect(result.stderr).not.toContain("React does not recognize"); + const rendered = JSON.parse(result.stdout); + expect(rendered.keys).toEqual(["color", "id", "ref"]); + expect(rendered.light).toContain('stroke="red"'); + expect(rendered.dark).toContain('stroke="blue"'); + expect(rendered.overridden).toContain('stroke="green"'); + }); +}); + +describe("decorative checks ring", () => { + it("hides its SVG from web accessibility without forwarding native props", async () => { + const result = await renderWebProbe(` + import React from 'react'; + import { renderToStaticMarkup } from 'react-dom/server'; + import { StyleSheet } from 'react-native-unistyles'; + import { ChecksRing } from './src/git/pull-request-panel/checks-ring'; + StyleSheet.configure({ + themes: { light: { colors: { surface3: 'gray', statusSuccess: 'green' } } }, + settings: { initialTheme: 'light' }, + }); + console.log(renderToStaticMarkup(React.createElement(ChecksRing, { + size: 20, summary: { total: 1, parts: [{ status: 'success', count: 1 }] }, + }))); + `); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain('aria-hidden="true"'); + expect(result.stdout).not.toContain("importantForAccessibility"); + expect(result.stdout).not.toContain("accessibilityElementsHidden"); + expect(result.stdout).toContain('stroke="green"'); + expect(result.stderr).not.toContain("React does not recognize"); + }); +}); diff --git a/packages/app/src/subagents/parent/callout.tsx b/packages/app/src/subagents/parent/callout.tsx new file mode 100644 index 0000000000..f76e1267e3 --- /dev/null +++ b/packages/app/src/subagents/parent/callout.tsx @@ -0,0 +1,116 @@ +import type { ReactElement } from "react"; +import { Text, View } from "react-native"; +import { useTranslation } from "react-i18next"; +import { CornerUpLeft } from "lucide-react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { Button } from "@/components/ui/button"; +import { ComposerTrackMark } from "@/composer/tracks"; +import { FOOTER_HEIGHT } from "@/constants/layout"; +import type { Theme } from "@/styles/theme"; +import { useParentAgent, type ParentAgent } from "./use-parent-agent"; + +function ParentIcon({ + icon: Icon, + size, + color = "", +}: { + icon: ParentAgent["icon"]; + size: number; + color?: string; +}) { + return ; +} + +const ThemedParentIcon = withUnistyles(ParentIcon); +const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); + +/** Stands in the composer's place on a subagent shown without one: who the parent is, and the way back. */ +export function ParentAgentCallout({ + serverId, + parentAgentId, +}: { + serverId: string; + parentAgentId: string; +}): ReactElement { + const { t } = useTranslation(); + const parent = useParentAgent(serverId, parentAgentId); + + return ( + + + + + + + {t("subagents.parent.parent")} + + + {parent.label ?? t("subagents.parent.parentAgent")} + + + + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + rail: { + minHeight: FOOTER_HEIGHT, + alignItems: "center", + width: "100%", + padding: theme.spacing[4], + }, + callout: { + width: "100%", + maxWidth: theme.contentMaxWidth, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[3], + backgroundColor: theme.colors.surface1, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.borderAccent, + borderRadius: theme.borderRadius["2xl"], + padding: theme.spacing[3], + paddingRight: { xs: theme.spacing[3], md: theme.spacing[4] }, + }, + avatar: { + width: 36, + height: 36, + borderRadius: theme.borderRadius.lg, + backgroundColor: theme.colors.surface2, + alignItems: "center", + justifyContent: "center", + }, + body: { + flex: 1, + minWidth: 0, + gap: theme.spacing[0.5], + }, + eyebrow: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + }, + titleRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + minWidth: 0, + }, + title: { + flexShrink: 1, + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.medium, + color: theme.colors.foreground, + }, +})); diff --git a/packages/app/src/subagents/parent/index.ts b/packages/app/src/subagents/parent/index.ts new file mode 100644 index 0000000000..daca7d3d16 --- /dev/null +++ b/packages/app/src/subagents/parent/index.ts @@ -0,0 +1,3 @@ +export { ParentAgentCallout } from "./callout"; +export { ParentAgentPill } from "./pill"; +export { useParentAgentId } from "./use-parent-agent"; diff --git a/packages/app/src/subagents/parent/pill.tsx b/packages/app/src/subagents/parent/pill.tsx new file mode 100644 index 0000000000..63f9407f8b --- /dev/null +++ b/packages/app/src/subagents/parent/pill.tsx @@ -0,0 +1,79 @@ +import { useCallback, useMemo, useState, type ReactElement } from "react"; +import { Text } from "react-native"; +import { useTranslation } from "react-i18next"; +import { ChevronLeft } from "lucide-react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { PressHighlight } from "@/components/ui/press-highlight"; +import { composerPillStyles } from "@/composer/pill-styles"; +import { ComposerTrackMark } from "@/composer/tracks"; +import type { Theme } from "@/styles/theme"; +import { useParentAgent } from "./use-parent-agent"; + +const ThemedChevronLeft = withUnistyles(ChevronLeft); +const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); +const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); + +/** + * The way back from a subagent to the agent that started it. It leads the pill rail and names + * the parent rather than saying "parent": in a stack of subagents the title is what tells you + * where you will land. + */ +export function ParentAgentPill({ + serverId, + parentAgentId, +}: { + serverId: string; + parentAgentId: string; +}): ReactElement { + const { t } = useTranslation(); + const parent = useParentAgent(serverId, parentAgentId); + const [isHovered, setIsHovered] = useState(false); + const handleHoverIn = useCallback(() => setIsHovered(true), []); + const handleHoverOut = useCallback(() => setIsHovered(false), []); + const bodyStyle = useMemo( + () => [composerPillStyles.body, styles.body, isHovered && composerPillStyles.bodyActive], + [isHovered], + ); + const labelStyle = useMemo( + () => [composerPillStyles.label, styles.label, isHovered && composerPillStyles.labelActive], + [isHovered], + ); + const label = parent.label ?? t("subagents.parent.backToParent"); + + return ( + + + + + {label} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + // The chevron's box is wider than its stroke, so the pill pulls it toward the edge to keep the + // visible glyph on the same inset as other pills' leading marks. + body: { + flexShrink: 1, + minWidth: 0, + gap: theme.spacing[1.5], + paddingLeft: theme.spacing[2], + }, + label: { + flexShrink: 1, + maxWidth: { xs: 140, md: 240 }, + }, +})); diff --git a/packages/app/src/subagents/parent/use-parent-agent.ts b/packages/app/src/subagents/parent/use-parent-agent.ts new file mode 100644 index 0000000000..e3916e6bbe --- /dev/null +++ b/packages/app/src/subagents/parent/use-parent-agent.ts @@ -0,0 +1,38 @@ +import { useCallback } from "react"; +import { useAgentDescriptor } from "@/agent-presentation"; +import type { PanelDescriptor } from "@/panels/panel-registry"; +import { useSessionStore } from "@/stores/session-store"; +import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; +import { navigateToAgent } from "@/utils/navigate-to-agent"; + +export interface ParentAgent { + /** The parent's title, or null while it has none — callers fall back to a generic name. */ + label: string | null; + icon: PanelDescriptor["icon"]; + statusBucket: SidebarStateBucket | null; + open: () => void; +} + +/** The parent of a managed agent, or null for a root or detached agent. */ +export function useParentAgentId(serverId: string, agentId: string): string | null { + return useSessionStore((state) => { + const session = state.sessions[serverId]; + const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId); + return agent?.parentAgentId ?? null; + }); +} + +/** The parent as its own tab presents it, and the way back to it. */ +export function useParentAgent(serverId: string, parentAgentId: string): ParentAgent { + const descriptor = useAgentDescriptor({ kind: "agent", agentId: parentAgentId }, { serverId }); + // The parent may live in another workspace; navigateToAgent opens it wherever it is. + const open = useCallback(() => { + navigateToAgent({ serverId, agentId: parentAgentId }); + }, [parentAgentId, serverId]); + return { + label: descriptor.label || null, + icon: descriptor.icon, + statusBucket: descriptor.statusBucket, + open, + }; +} diff --git a/packages/app/src/tool-calls/detail-level/overview/label.ts b/packages/app/src/tool-calls/detail-level/overview/label.ts new file mode 100644 index 0000000000..762e530172 --- /dev/null +++ b/packages/app/src/tool-calls/detail-level/overview/label.ts @@ -0,0 +1,45 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import type { OverviewSummary } from "./model"; + +function joinSummaryParts(parts: string[], conjunction: string): string { + if (parts.length === 0) { + return ""; + } + let joined = parts[0] ?? ""; + if (parts.length === 2) { + joined = `${parts[0]} ${conjunction} ${parts[1]}`; + } else if (parts.length > 2) { + joined = `${parts.slice(0, -1).join(", ")}, ${conjunction} ${parts.at(-1)}`; + } + const firstCharacter = joined[0]; + return firstCharacter ? `${firstCharacter.toLocaleUpperCase()}${joined.slice(1)}` : joined; +} + +/** + * One sentence of what a stretch of agent work did, such as "Edited 2 files and ran 3 commands". + * Leading parts come first, for summaries that count more than tool calls. + */ +export function useOverviewSummaryLabel( + summary: OverviewSummary, + leadingParts: readonly string[] = [], +): string { + const { t } = useTranslation(); + return useMemo(() => { + const parts = [...leadingParts]; + const entries = [ + [summary.editedFileCount, "toolCallGroup.editedFiles"], + [summary.commandCount, "toolCallGroup.commands"], + [summary.readFileCount, "toolCallGroup.readFiles"], + [summary.searchCount, "toolCallGroup.searches"], + [summary.otherToolCount, "toolCallGroup.otherTools"], + [summary.paseoCallCount, "toolCallGroup.paseoCalls"], + ] as const; + for (const [count, key] of entries) { + if (count > 0) { + parts.push(t(`${key}.${count === 1 ? "one" : "other"}`, { count })); + } + } + return joinSummaryParts(parts, t("toolCallGroup.and")); + }, [leadingParts, summary, t]); +} diff --git a/packages/app/src/tool-calls/detail-level/overview/model.ts b/packages/app/src/tool-calls/detail-level/overview/model.ts index f41790461c..0fb638c012 100644 --- a/packages/app/src/tool-calls/detail-level/overview/model.ts +++ b/packages/app/src/tool-calls/detail-level/overview/model.ts @@ -1,4 +1,5 @@ import { isPaseoToolName } from "@getpaseo/protocol/tool-name-normalization"; +import type { ToolCallItem } from "@/types/stream"; import { describeToolCall, type ToolCallRun } from "../grouping"; const DIRECT_PASEO_TOOL_PREFIX = "paseo_"; @@ -28,19 +29,18 @@ function isSearchCall(name: string): boolean { return DIRECT_SEARCH_TOOL_SUFFIX_PATTERN.test(name); } -export function buildOverviewGroup(run: ToolCallRun): OverviewToolCallGroup { +/** Counts tool calls by what they did, for any summary of a stretch of agent work. */ +export function summarizeToolCalls(calls: readonly ToolCallItem[]): OverviewSummary { const editedFiles = new Set(); const readFiles = new Set(); - let isLoading = false; let commandCount = 0; let searchCount = 0; let otherToolCount = 0; let paseoCallCount = 0; - for (const call of run.calls) { + for (const call of calls) { const descriptor = describeToolCall(call); const normalizedName = descriptor.name.trim().toLowerCase(); - isLoading ||= descriptor.status === "running" || descriptor.status === "executing"; if (isPaseoCall(descriptor.name, normalizedName)) { paseoCallCount += 1; } else if (descriptor.detail.type === "edit" || descriptor.detail.type === "write") { @@ -56,7 +56,7 @@ export function buildOverviewGroup(run: ToolCallRun): OverviewToolCallGroup { } } - const summary = { + return { editedFileCount: editedFiles.size, commandCount, readFileCount: readFiles.size, @@ -64,10 +64,17 @@ export function buildOverviewGroup(run: ToolCallRun): OverviewToolCallGroup { otherToolCount, paseoCallCount, }; +} + +export function buildOverviewGroup(run: ToolCallRun): OverviewToolCallGroup { + const isLoading = run.calls.some((call) => { + const status = describeToolCall(call).status; + return status === "running" || status === "executing"; + }); return { mode: "overview", run, isLoading, - summary, + summary: summarizeToolCalls(run.calls), }; } diff --git a/packages/app/src/tool-calls/detail-level/overview/view.tsx b/packages/app/src/tool-calls/detail-level/overview/view.tsx index 326cc172b2..b11e82e4f7 100644 --- a/packages/app/src/tool-calls/detail-level/overview/view.tsx +++ b/packages/app/src/tool-calls/detail-level/overview/view.tsx @@ -1,11 +1,12 @@ -import React, { memo, useCallback, useMemo, useRef, type ReactNode } from "react"; +import React, { memo, useCallback, useRef, type ReactNode } from "react"; import { ScrollView } from "react-native"; -import { useTranslation } from "react-i18next"; import { Wrench } from "lucide-react-native"; import { StyleSheet } from "react-native-unistyles"; +import { IndentRail } from "@/components/indent-rail"; import { ExpandableBadge } from "@/components/message"; import { useIsCompactFormFactor } from "@/constants/layout"; -import { type OverviewSummary, type OverviewToolCallGroup } from "./model"; +import { useOverviewSummaryLabel } from "./label"; +import type { OverviewToolCallGroup } from "./model"; import { OverviewToolCallGroupSheet } from "./sheet"; interface OverviewGroupProps { @@ -18,41 +19,6 @@ interface OverviewGroupProps { const TOOL_CALL_GROUP_MAX_HEIGHT = 400; -function joinSummaryParts(parts: string[], conjunction: string): string { - if (parts.length === 0) { - return ""; - } - let joined = parts[0] ?? ""; - if (parts.length === 2) { - joined = `${parts[0]} ${conjunction} ${parts[1]}`; - } else if (parts.length > 2) { - joined = `${parts.slice(0, -1).join(", ")}, ${conjunction} ${parts.at(-1)}`; - } - const firstCharacter = joined[0]; - return firstCharacter ? `${firstCharacter.toLocaleUpperCase()}${joined.slice(1)}` : joined; -} - -function useOverviewSummary(summary: OverviewSummary): string { - const { t } = useTranslation(); - return useMemo(() => { - const parts: string[] = []; - const entries = [ - [summary.editedFileCount, "toolCallGroup.editedFiles"], - [summary.commandCount, "toolCallGroup.commands"], - [summary.readFileCount, "toolCallGroup.readFiles"], - [summary.searchCount, "toolCallGroup.searches"], - [summary.otherToolCount, "toolCallGroup.otherTools"], - [summary.paseoCallCount, "toolCallGroup.paseoCalls"], - ] as const; - for (const [count, key] of entries) { - if (count > 0) { - parts.push(t(`${key}.${count === 1 ? "one" : "other"}`, { count })); - } - } - return joinSummaryParts(parts, t("toolCallGroup.and")); - }, [summary, t]); -} - export const OverviewToolCallGroupView = memo(function OverviewToolCallGroupView({ group, expanded, @@ -62,7 +28,7 @@ export const OverviewToolCallGroupView = memo(function OverviewToolCallGroupView }: OverviewGroupProps) { const scrollRef = useRef(null); const isCompact = useIsCompactFormFactor(); - const aggregateSummary = useOverviewSummary(group.summary); + const aggregateSummary = useOverviewSummaryLabel(group.summary); const scrollToLatest = useCallback(() => { scrollRef.current?.scrollToEnd({ animated: false }); }, []); @@ -82,7 +48,7 @@ export const OverviewToolCallGroupView = memo(function OverviewToolCallGroupView showsVerticalScrollIndicator onContentSizeChange={scrollToLatest} > - {children} + {children} ), [children, scrollToLatest], diff --git a/packages/app/src/tool-calls/detail-level/projection.ts b/packages/app/src/tool-calls/detail-level/projection.ts index 5dc1067914..9d57391b2e 100644 --- a/packages/app/src/tool-calls/detail-level/projection.ts +++ b/packages/app/src/tool-calls/detail-level/projection.ts @@ -1,5 +1,4 @@ import type { StreamItem } from "@/types/stream"; -import type { ToolCallDetailLevel } from "@/hooks/use-settings/storage"; import { groupLiveToolCalls, prepareGroupedHistory, @@ -8,7 +7,8 @@ import { } from "./grouping"; import { buildOverviewGroup, type OverviewToolCallGroup } from "./overview/model"; -export type { ToolCallDetailLevel } from "@/hooks/use-settings/storage"; +/** Whether consecutive tool calls show one by one or as one summary row. */ +export type ToolCallDetailLevel = "overview" | "detailed"; export type ToolCallDetailGroup = OverviewToolCallGroup; export interface PreparedToolCallHistory { diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 9425cabbed..657d218b35 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -771,6 +771,12 @@ export type AgentToolCallItem = ToolCallItem & { payload: { source: "agent"; data: AgentToolCallData }; }; +/** Find and scrolling address a plugin row by the source it replaces. */ +export function getStreamItemMessageId(item: StreamItem): string { + if (item.kind === "plugin") return item.sourceId ?? item.id; + return item.id; +} + export function isAgentToolCallItem(item: StreamItem): item is AgentToolCallItem { return item.kind === "tool_call" && item.payload.source === "agent"; } diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index cb828f9e5a..c5913366d2 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -518,6 +518,7 @@ export const HOST_SECTION_SLUGS = [ "pair-device", "agents", "metadata", + "voice", "workspaces", "providers", "usage", diff --git a/packages/app/src/workspace/desktop-open-targets.ts b/packages/app/src/workspace/desktop-open-targets.ts index 54730c2dfb..5f4d03e869 100644 --- a/packages/app/src/workspace/desktop-open-targets.ts +++ b/packages/app/src/workspace/desktop-open-targets.ts @@ -56,12 +56,14 @@ export function hasDesktopOpenTargetsBridge(): boolean { return getDesktopEditorBridge() !== null; } -export async function listDesktopOpenTargets(): Promise { +export async function listDesktopOpenTargets( + input: { workspacePath?: string } = {}, +): Promise { const bridge = getDesktopEditorBridge(); if (!bridge) { return []; } - return await bridge.listTargets(); + return await bridge.listTargets(input); } export async function openDesktopTarget(input: OpenDesktopTargetInput): Promise { @@ -72,15 +74,24 @@ export async function openDesktopTarget(input: OpenDesktopTargetInput): Promise< await bridge.openTarget(input); } -export function useDesktopOpenTargets(input: { isLocalExecution: boolean }) { +/** + * Pass `workspacePath` whenever it is known. Project-specific targets such as + * Xcode are only listed for workspaces they can actually open, so callers that + * omit it get the workspace-agnostic targets only. + */ +export function useDesktopOpenTargets(input: { + isLocalExecution: boolean; + workspacePath?: string; +}) { const hasBridge = hasDesktopOpenTargetsBridge(); const canListTargets = hasBridge && input.isLocalExecution; + const workspacePath = input.workspacePath?.trim() ?? ""; const query = useQuery({ - queryKey: ["desktop-open-targets"], + queryKey: ["desktop-open-targets", workspacePath], enabled: canListTargets, staleTime: 60_000, retry: false, - queryFn: listDesktopOpenTargets, + queryFn: () => listDesktopOpenTargets(workspacePath ? { workspacePath } : {}), }); const targets = selectDesktopOpenTargets({ canListTargets, diff --git a/packages/app/src/workspace/open-in-editor/button.tsx b/packages/app/src/workspace/open-in-editor/button.tsx index 384ffe72c6..fbcef49b83 100644 --- a/packages/app/src/workspace/open-in-editor/button.tsx +++ b/packages/app/src/workspace/open-in-editor/button.tsx @@ -33,6 +33,7 @@ import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome"; interface WorkspaceOpenInEditorButtonProps { serverId: string; cwd: string; + projectId: string | null; activeFile?: WorkspaceFileLocation | null; hideLabels?: boolean; } @@ -83,6 +84,7 @@ function OpenTargetMenuItem({ target, isPreferred, onSelect }: OpenTargetMenuIte export function WorkspaceOpenInEditorButton({ serverId, cwd, + projectId, activeFile, hideLabels, }: WorkspaceOpenInEditorButtonProps) { @@ -90,10 +92,11 @@ export function WorkspaceOpenInEditorButton({ const toast = useToast(); const isConnected = useHostRuntimeIsConnected(serverId); const isLocalDaemon = useIsLocalDaemon(serverId); - const { preferredEditorId, updatePreferredEditor } = usePreferredEditor(); + const { preferredEditorId, updatePreferredEditor } = usePreferredEditor({ serverId, projectId }); const { targets: desktopOpenTargets, isAvailable: isDesktopOpenAvailable } = useDesktopOpenTargets({ isLocalExecution: isLocalDaemon, + workspacePath: cwd, }); const resolvedFile = useMemo( @@ -185,9 +188,11 @@ export function WorkspaceOpenInEditorButton({ const handleSelectTarget = useCallback( (target: OpenTarget) => { - void updatePreferredEditor(target.id).catch(() => undefined); + void updatePreferredEditor(target.id).catch((error: unknown) => { + toast.error(error instanceof Error ? error.message : t("common.errors.unableToSave")); + }); }, - [updatePreferredEditor], + [updatePreferredEditor, toast, t], ); const primaryPressableStyle = useCallback( diff --git a/packages/app/src/workspace/open-in-editor/directory.ts b/packages/app/src/workspace/open-in-editor/directory.ts index e4fcca74d7..bf90a29e36 100644 --- a/packages/app/src/workspace/open-in-editor/directory.ts +++ b/packages/app/src/workspace/open-in-editor/directory.ts @@ -9,6 +9,7 @@ import { planWorkspaceOpenTargets } from "@/workspace/open-in-editor/planner"; interface UseOpenDirectoryInEditorInput { serverId: string; workspaceDirectory: string; + projectId: string | null; } interface OpenDirectoryInEditorAction { @@ -19,11 +20,12 @@ interface OpenDirectoryInEditorAction { export function useOpenDirectoryInEditor({ serverId, workspaceDirectory, + projectId, }: UseOpenDirectoryInEditorInput): OpenDirectoryInEditorAction | null { const { t } = useTranslation(); const toast = useToast(); const isLocalExecution = useIsLocalDaemon(serverId); - const { preferredEditorId } = usePreferredEditor(); + const { preferredEditorId } = usePreferredEditor({ serverId, projectId }); const { targets, isAvailable } = useDesktopOpenTargets({ isLocalExecution }); const editorTargets = useMemo( () => targets.filter((target) => target.kind === "editor"), diff --git a/packages/app/test-stubs/expo-clipboard.ts b/packages/app/test-stubs/expo-clipboard.ts new file mode 100644 index 0000000000..9663320efb --- /dev/null +++ b/packages/app/test-stubs/expo-clipboard.ts @@ -0,0 +1,10 @@ +// The package ships JSX in .js files, which the dependency optimizer cannot parse. +export async function setStringAsync(): Promise { + return true; +} +export async function getStringAsync(): Promise { + return ""; +} +export async function hasStringAsync(): Promise { + return false; +} diff --git a/packages/app/test-stubs/lucide-react-native.ts b/packages/app/test-stubs/lucide-react-native.ts index 200048ad53..a85976e1bf 100644 --- a/packages/app/test-stubs/lucide-react-native.ts +++ b/packages/app/test-stubs/lucide-react-native.ts @@ -111,3 +111,8 @@ export const Upload = StubIcon; export const Wrench = StubIcon; export const X = StubIcon; export const XCircle = StubIcon; +export const Code = StubIcon; +export const Maximize2 = StubIcon; +export const Workflow = StubIcon; +export const ZoomIn = StubIcon; +export const ZoomOut = StubIcon; diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts index 2d7149442e..3254fc5e0c 100644 --- a/packages/app/vitest.config.ts +++ b/packages/app/vitest.config.ts @@ -19,6 +19,20 @@ const resolvePackageEntry = (packageName: string) => { export default defineConfig({ plugins: [ + { + name: "markdown-wasm-commonjs-export", + transform(source, id) { + // The ESM package contains a CommonJS WASM factory. Metro handles the + // interop; Vite needs an ESM export instead of the CommonJS trailer. + if (!id.endsWith("/lib/module/web/wasm/md4c.js")) return; + const trailer = source.lastIndexOf("\nif (typeof exports === 'object'"); + if (trailer < 0) throw new Error("Markdown WASM CommonJS trailer was not found"); + return { + code: `${source.slice(0, trailer)}\nexport default createMd4cModule;`, + map: null, + }; + }, + }, { name: "compile-usage-components", enforce: "pre", @@ -147,15 +161,19 @@ export default defineConfig({ "i18next", "@marijn/find-cluster-break", "tiny-invariant", + "react/jsx-dev-runtime", "react-native-gesture-handler > hoist-non-react-statics", "react-native-gesture-handler > invariant", ], exclude: [ + "react-native-enriched-markdown", "react-native-reanimated", "react-native-gesture-handler", "react-native-keyboard-controller", ], }, + // babel-preset-expo compiles JSX with the automatic runtime, so app modules don't import React. + esbuild: { jsx: "automatic" }, // The globals a React Native bundler defines, which esbuild is no longer there to supply for // the package excluded above. define: { @@ -254,6 +272,10 @@ export default defineConfig({ find: /^react-native-reanimated\/scripts\/validate-worklets-version$/, replacement: path.resolve(__dirname, "test-stubs/reanimated-validate-worklets-version.ts"), }, + { + find: /^expo-clipboard$/, + replacement: path.resolve(__dirname, "test-stubs/expo-clipboard.ts"), + }, { find: /^expo-linking$/, replacement: path.resolve(__dirname, "test-stubs/expo-linking.ts"), diff --git a/packages/cli/package.json b/packages/cli/package.json index dd4ee4686d..be8188fff3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.13.0", + "version": "0.13.2", "description": "Paseo CLI - control your AI coding agents from the command line", "bin": { "paseo": "bin/paseo" @@ -28,9 +28,9 @@ }, "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.13.0", - "@getpaseo/protocol": "0.13.0", - "@getpaseo/server": "0.13.0", + "@getpaseo/client": "0.13.2", + "@getpaseo/protocol": "0.13.2", + "@getpaseo/server": "0.13.2", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", diff --git a/packages/client/package.json b/packages/client/package.json index 64c559cdab..df80ba532c 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/client", - "version": "0.13.0", + "version": "0.13.2", "description": "Paseo client SDK package", "files": [ "dist", @@ -47,8 +47,8 @@ "test": "vitest run" }, "dependencies": { - "@getpaseo/protocol": "0.13.0", - "@getpaseo/relay": "0.13.0", + "@getpaseo/protocol": "0.13.2", + "@getpaseo/relay": "0.13.2", "ws": "^8.20.0", "zod": "^4.4.3" }, diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 524df56704..59d928283f 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -19,6 +19,7 @@ import { CreationClient } from "./creation/index.js"; import type { CreationSnapshot } from "@getpaseo/protocol/messages"; import type { z } from "zod"; import type { SessionEventSubscription } from "@getpaseo/protocol/messages"; +import type { LocalSpeechModel } from "@getpaseo/protocol/speech/rpc-schemas"; import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities"; import type { AgentAttentionNotificationPayload } from "@getpaseo/protocol/agent-attention-notification"; import { MAX_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit"; @@ -5547,6 +5548,34 @@ export class DaemonClient { }); } + async listLocalSpeechModels(requestId?: string): Promise { + const payload = await this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "speech.local_models.list.request" }, + responseType: "speech.local_models.list.response", + }); + if (payload.error) throw new Error(payload.error); + return payload.models; + } + + async downloadLocalSpeechModel(modelId: string, requestId?: string): Promise { + const payload = await this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "speech.local_models.download.request", modelId }, + responseType: "speech.local_models.download.response", + }); + if (payload.error) throw new Error(payload.error); + } + + async deleteLocalSpeechModel(modelId: string, requestId?: string): Promise { + const payload = await this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "speech.local_models.delete.request", modelId }, + responseType: "speech.local_models.delete.response", + }); + if (payload.error) throw new Error(payload.error); + } + sendBrowserAutomationExecuteResponse(response: BrowserAutomationExecuteResponse): void { this.sendSessionMessageStrict(response); } diff --git a/packages/client/src/node/ssh-tunnel.test.ts b/packages/client/src/node/ssh-tunnel.test.ts new file mode 100644 index 0000000000..6bdfe27fe4 --- /dev/null +++ b/packages/client/src/node/ssh-tunnel.test.ts @@ -0,0 +1,55 @@ +import { execFile, spawnSync } from "node:child_process"; +import { promisify } from "node:util"; +import { expect, test } from "vitest"; + +const run = promisify(execFile); +const hasSsh = !spawnSync("ssh", ["-V"]).error; + +test.skipIf(!hasSsh)( + "an SSH peer abort during a pending write closes the tunnel without crashing its caller", + async () => { + // Isolate the caller so an unhandled stream error fails this assertion instead + // of crashing the test worker. Both the TCP peer and OpenSSH are real. + const { stdout } = await run( + process.execPath, + [ + "--import", + "tsx", + "--input-type=module", + "-e", + ` + import { createServer, createConnection } from 'node:net'; + import { once } from 'node:events'; + import { createSshTunnel } from ${JSON.stringify(new URL("./ssh-tunnel.ts", import.meta.url).href)}; + + const remote = createServer(socket => socket.destroy()); + remote.listen(0, '127.0.0.1'); + await once(remote, 'listening'); + const tunnel = await createSshTunnel({ + host: '127.0.0.1', sshPort: remote.address().port, daemonPort: 6767, + }); + const [host, port] = tunnel.endpoint.split(':'); + const client = createConnection({ host, port: Number(port) }); + client.on('error', () => undefined); + try { + const closed = new Promise(resolve => client.once('close', resolve)); + await once(client, 'connect'); + client.write(Buffer.alloc(8 * 1024 * 1024, 65)); + await closed; + console.log(JSON.stringify({ closed: client.destroyed, failure: tunnel.failureDetail() })); + } finally { + client.destroy(); + tunnel.close(); + await new Promise(resolve => remote.close(resolve)); + } + `, + ], + { timeout: 10_000 }, + ); + expect(JSON.parse(stdout)).toEqual({ + closed: true, + failure: expect.any(String), + }); + expect(JSON.parse(stdout).failure).not.toBe(""); + }, +); diff --git a/packages/client/src/node/ssh-tunnel.ts b/packages/client/src/node/ssh-tunnel.ts index 33900d0baa..a67c0a91c4 100644 --- a/packages/client/src/node/ssh-tunnel.ts +++ b/packages/client/src/node/ssh-tunnel.ts @@ -54,10 +54,12 @@ export function createSshTunnel(target: SshTransportTarget): Promise child.stderr.on("data", (chunk: Buffer | string) => { stderr = `${stderr}${chunk.toString()}`.slice(-SSH_STDERR_LIMIT); }); - child.on("error", (error) => { + const fail = (error: Error) => { failure = error.message; acceptedSocket.destroy(error); - }); + }; + child.on("error", fail); + child.stdin.on("error", fail); child.on("exit", (code, signal) => { if (code !== 0 || signal) failure = formatSshFailure(stderr, code, signal); acceptedSocket.destroy(failure ? new Error(failure) : undefined); diff --git a/packages/desktop/assets/editor-targets/xcode.png b/packages/desktop/assets/editor-targets/xcode.png new file mode 100644 index 0000000000..1c1ba1549d Binary files /dev/null and b/packages/desktop/assets/editor-targets/xcode.png differ diff --git a/packages/desktop/e2e/browser-tabs.e2e.mjs b/packages/desktop/e2e/browser-tabs.e2e.mjs index d403bfdfc3..78d2fa978e 100644 --- a/packages/desktop/e2e/browser-tabs.e2e.mjs +++ b/packages/desktop/e2e/browser-tabs.e2e.mjs @@ -14,6 +14,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/ import { chromium } from "playwright"; import { hideDeveloperToasts } from "../../app/e2e/support/developer-ui.mjs"; import { runAppearanceFontSizeRegression } from "./appearance-font-size.electron.mjs"; +import { runSettingsScrollRegression } from "./settings-scroll.electron.mjs"; import { runSettingsMemoryRegression } from "./settings-memory.electron.mjs"; import { seedPluginLinks, runPluginLinksRegression } from "./plugin-links.electron.mjs"; @@ -455,17 +456,17 @@ function recordViewportMismatch(failures, label, actual, expected) { ); } -async function setWindowHidden(inspectorPort, hidden) { +async function evaluateDesktopWindow(inspectorPort, expression) { const [target] = await (await fetch(`http://127.0.0.1:${inspectorPort}/json/list`)).json(); const socket = new WebSocket(target.webSocketDebuggerUrl); try { - await new Promise((resolve, reject) => { + return await new Promise((resolve, reject) => { socket.addEventListener("error", reject, { once: true }); socket.addEventListener("message", ({ data }) => { const response = JSON.parse(data); if (response.id !== 1) return; if (response.error || response.result?.exceptionDetails) reject(new Error(data)); - else resolve(); + else resolve(response.result.result.value); }); socket.addEventListener( "open", @@ -475,7 +476,8 @@ async function setWindowHidden(inspectorPort, hidden) { id: 1, method: "Runtime.evaluate", params: { - expression: `(() => { const win = process.mainModule.require('electron').BrowserWindow.getAllWindows().find(win => win.webContents.getURL().includes('localhost:')); win.${hidden ? "hide" : "show"}(); if (win.isVisible() !== ${!hidden}) throw new Error('Window visibility did not change'); })()`, + expression: `(() => { const win = process.mainModule.require('electron').BrowserWindow.getAllWindows().find(win => win.webContents.getURL().includes('localhost:')); ${expression} })()`, + returnByValue: true, }, }), ), @@ -487,6 +489,20 @@ async function setWindowHidden(inspectorPort, hidden) { } } +async function setWindowHidden(inspectorPort, hidden) { + await evaluateDesktopWindow( + inspectorPort, + `win.${hidden ? "hide" : "show"}(); if (win.isVisible() !== ${!hidden}) throw new Error('Window visibility did not change');`, + ); +} + +async function resizeDesktopWindow(inspectorPort, { width, height }) { + return evaluateDesktopWindow( + inspectorPort, + `const [width, height] = win.getContentSize(); win.setContentSize(${width}, ${height}); return { width, height };`, + ); +} + async function verifyHiddenBrowserScreenshots({ page, client, @@ -1208,6 +1224,14 @@ async function main() { console.log("Plugin external links and workspace browser passed."); return; } + const settingsScroll = await runSettingsScrollRegression(page, (size) => + resizeDesktopWindow(inspectorPort, size), + ); + if (process.env.PASEO_DESKTOP_SETTINGS_SCROLL_ONLY === "1") { + writeJson(path.join(artifactDir, "result.json"), { settingsScroll }); + console.log("Desktop Settings scroll regression passed."); + return; + } const settingsMemory = await runSettingsMemoryRegression(page); if (process.env.PASEO_DESKTOP_SETTINGS_MEMORY_ONLY === "1") { writeJson(path.join(artifactDir, "result.json"), { settingsMemory }); diff --git a/packages/desktop/e2e/open-in-editor.spec.ts b/packages/desktop/e2e/open-in-editor.spec.ts index 739974ff0e..7521f7655d 100644 --- a/packages/desktop/e2e/open-in-editor.spec.ts +++ b/packages/desktop/e2e/open-in-editor.spec.ts @@ -3,6 +3,13 @@ import path from "node:path"; import { expect, test, type Page } from "../../app/e2e/support/fixtures"; import { gotoAppShell, openSettings } from "../../app/e2e/support/helpers/app"; import { expandFolder, openFileExplorer } from "../../app/e2e/support/helpers/file-explorer"; +import { + connectNewWorkspaceDaemonClient, + createWorktreeViaDaemon, + archiveWorkspaceFromDaemon, +} from "../../app/e2e/support/helpers/new-workspace"; +import type { WithWorkspace } from "../../app/e2e/support/helpers/with-workspace"; +import { selectWorkspaceInSidebar } from "../../app/e2e/support/helpers/sidebar"; import { installDesktopRuntime } from "./support/runtime"; import { clickSettingsBackToWorkspace } from "../../app/e2e/support/helpers/settings"; @@ -38,14 +45,20 @@ async function readEditorOpenRecords(recordPath: string): Promise { - await expect(page.getByTestId("workspace-open-in-editor-primary")).toBeVisible({ + await expect(page.getByRole("button", { name: /^Open workspace in / })).toBeVisible({ timeout: 30_000, }); - await page.getByTestId("workspace-open-in-editor-caret").click(); + await page.getByRole("button", { name: "Choose editor", exact: true }).click(); await expect(page.getByTestId("workspace-open-in-editor-menu")).toBeVisible(); await page.getByTestId(`workspace-open-in-editor-item-${targetId}`).click(); } +async function expectPreferredEditor(page: Page, label: string): Promise { + await expect( + page.getByRole("button", { name: `Open workspace in ${label}`, exact: true }), + ).toBeVisible(); +} + async function expectEditorOpened(input: { recordPath: string; editorId: string; @@ -67,7 +80,82 @@ async function expectEditorOpened(input: { .toBe(true); } +async function createEditorPreferenceFixture(page: Page, withWorkspace: WithWorkspace) { + const serverId = requireE2EEnv("E2E_SERVER_ID"); + await installDesktopRuntime(page, { + serverId, + editorTargets: [ + { + id: "cursor", + label: "Cursor", + kind: "editor", + icon: { kind: "symbol", name: "terminal" }, + }, + { + id: "vscode", + label: "VS Code", + kind: "editor", + icon: { kind: "symbol", name: "terminal" }, + }, + ], + }); + const projectA = await withWorkspace({ prefix: "editor-project-a-" }); + const projectB = await withWorkspace({ prefix: "editor-project-b-" }); + const client = await connectNewWorkspaceDaemonClient(); + const worktree = await createWorktreeViaDaemon(client, { + cwd: projectA.repoPath, + slug: "editor-preference-sibling", + }); + return { + projectA, + projectB, + worktree, + async close() { + try { + await archiveWorkspaceFromDaemon(client, worktree.workspaceDirectory); + } finally { + await client.close(); + } + }, + }; +} + test.describe("Workspace open in editor", () => { + test("keeps editor choices per project across switching, reload and worktrees", async ({ + page, + withWorkspace, + }) => { + test.setTimeout(120_000); + const fixture = await createEditorPreferenceFixture(page, withWorkspace); + const { projectA, projectB, worktree } = fixture; + try { + await projectA.navigateTo(); + await selectEditorTarget(page, "vscode"); + await expectPreferredEditor(page, "VS Code"); + await projectB.navigateTo(); + await expectPreferredEditor(page, "Cursor"); + await selectEditorTarget(page, "cursor"); + await projectA.navigateTo(); + await expectPreferredEditor(page, "VS Code"); + await selectWorkspaceInSidebar(page, worktree.workspaceId); + await expectPreferredEditor(page, "VS Code"); + await selectEditorTarget(page, "cursor"); + await projectA.navigateTo(); + await expectPreferredEditor(page, "Cursor"); + await selectEditorTarget(page, "vscode"); + await page.reload(); + await expectPreferredEditor(page, "VS Code"); + await projectB.navigateTo(); + await expectPreferredEditor(page, "Cursor"); + await page.reload(); + await expectPreferredEditor(page, "Cursor"); + await projectA.navigateTo(); + await expectPreferredEditor(page, "VS Code"); + } finally { + await fixture.close(); + } + }); + test("opens a nested folder in the preferred editor", async ({ page, withWorkspace }) => { test.setTimeout(90_000); diff --git a/packages/desktop/e2e/settings-scroll.electron.mjs b/packages/desktop/e2e/settings-scroll.electron.mjs new file mode 100644 index 0000000000..e72656db02 --- /dev/null +++ b/packages/desktop/e2e/settings-scroll.electron.mjs @@ -0,0 +1,68 @@ +import { expect } from "@playwright/test"; + +export async function runSettingsScrollRegression(page, resizeWindow) { + // Device-metrics emulation also pins later webview guests. Resize the real + // window so the following browser regression starts without an override. + const original = await resizeWindow({ width: 1000, height: 500 }); + const sidebar = () => page.locator('[data-testid="settings-sidebar-scroll-body"]:visible'); + const offset = () => sidebar().evaluate((node) => node.scrollTop); + const observations = []; + try { + await expect + .poll(() => page.evaluate(() => ({ width: innerWidth, height: innerHeight }))) + .toEqual({ width: 1000, height: 500 }); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await sidebar().hover(); + await page.mouse.wheel(0, 2000); + await expect.poll(offset).toBeGreaterThan(0); + const saved = await offset(); + for (const title of ["Providers", "Terminals", "Providers", "Terminals"]) { + const button = page + .locator('[data-testid="settings-sidebar"]:visible') + .getByRole("button", { name: title, exact: true }); + await expect(button).toBeInViewport(); + await button.click(); + await expect(page.locator('[data-testid="settings-detail-header-title"]:visible')).toHaveText( + title, + ); + await expect.poll(offset).toBe(saved); + observations.push({ title, offset: await offset() }); + } + const detail = page.locator('[data-testid="settings-detail-scroll-body"]:visible'); + await page.getByRole("button", { name: "Providers", exact: true }).click(); + await detail.hover(); + await page.mouse.wheel(0, 1000); + await expect.poll(() => detail.evaluate((node) => node.scrollTop)).toBeGreaterThan(0); + await expect.poll(offset).toBe(saved); + observations.push({ + independentDetailOffset: await detail.evaluate((node) => node.scrollTop), + sidebarOffset: await offset(), + }); + await sidebar().hover(); + await page.mouse.wheel(0, -2000); + await expect.poll(offset).toBe(0); + await page.mouse.wheel(0, 60); + await expect.poll(offset).toBe(60); + for (const title of ["Sidebar", "Appearance", "Sidebar"]) { + const button = page + .getByRole("navigation", { name: "Settings" }) + .getByRole("button", { name: title, exact: true }); + await expect(button).toBeInViewport(); + await button.click(); + await expect(page.getByTestId("settings-detail-header-title")).toHaveText(title); + await expect.poll(offset).toBe(60); + observations.push({ title, offset: await offset() }); + } + await page.locator('[data-testid="settings-back-to-workspace"]:visible').click(); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await expect.poll(offset).toBe(0); + observations.push({ reentryOffset: await offset() }); + await page.locator('[data-testid="settings-back-to-workspace"]:visible').click(); + return observations; + } finally { + await resizeWindow(original); + await expect + .poll(() => page.evaluate(() => ({ width: innerWidth, height: innerHeight }))) + .toEqual(original); + } +} diff --git a/packages/desktop/package.json b/packages/desktop/package.json index b96b9f88e6..4735fb5192 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.13.0", + "version": "0.13.2", "private": true, "description": "Paseo desktop app (Electron wrapper)", "homepage": "https://paseo.sh", diff --git a/packages/desktop/src/features/editor-targets/ipc.ts b/packages/desktop/src/features/editor-targets/ipc.ts index 4737f4f328..3bed68c583 100644 --- a/packages/desktop/src/features/editor-targets/ipc.ts +++ b/packages/desktop/src/features/editor-targets/ipc.ts @@ -9,6 +9,10 @@ interface IpcHandlerRegistry { handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): void; } +const EditorTargetListInputSchema = z.object({ + workspacePath: z.string().trim().min(1).optional(), +}); + const EditorTargetLaunchInputSchema = z.object({ editorId: z.string().trim().min(1), workspacePath: z.string().trim().min(1), @@ -27,9 +31,10 @@ export function registerEditorTargetHandlers( const ipc = options.ipc ?? ipcMain; const runtime = options.runtime ?? createEditorTargetRuntime(); - ipc.handle("paseo:editor:listTargets", () => - listAvailableEditorTargets(runtime, options.targets), - ); + ipc.handle("paseo:editor:listTargets", (_event, payload: unknown) => { + const { workspacePath } = EditorTargetListInputSchema.parse(payload ?? {}); + return listAvailableEditorTargets(runtime, options.targets, { workspacePath }); + }); ipc.handle("paseo:editor:openTarget", async (_event, payload: unknown) => { const input = EditorTargetLaunchInputSchema.parse(payload); await openEditorTarget(input, runtime, options.targets); diff --git a/packages/desktop/src/features/editor-targets/registry.test.ts b/packages/desktop/src/features/editor-targets/registry.test.ts index 4a11594b4f..4d8c4d80c7 100644 --- a/packages/desktop/src/features/editor-targets/registry.test.ts +++ b/packages/desktop/src/features/editor-targets/registry.test.ts @@ -1,13 +1,18 @@ import { describe, expect, it } from "vitest"; import { listAvailableEditorTargets, openEditorTarget } from "./registry.js"; -import type { EditorTargetIcon, EditorTargetRuntime } from "./target.js"; +import type { + EditorTargetDirectoryEntry, + EditorTargetIcon, + EditorTargetRuntime, +} from "./target.js"; import { cursorTarget } from "./targets/cursor.js"; import { explorerTarget, fileManagerTarget, finderTarget } from "./targets/file-manager.js"; import { intellijIdeaTarget } from "./targets/intellij-idea.js"; import { pycharmTarget } from "./targets/pycharm.js"; import { vscodeTarget } from "./targets/vscode.js"; import { webstormTarget } from "./targets/webstorm.js"; +import { xcodeTarget } from "./targets/xcode.js"; import { zedTarget } from "./targets/zed.js"; interface RecordedLaunch { @@ -15,6 +20,14 @@ interface RecordedLaunch { args: string[]; } +function testDirectoryEntry(name: string): EditorTargetDirectoryEntry { + if (name === "Package.swift") return { name, kind: "file" }; + if (name.endsWith(".xcodeproj") || name.endsWith(".xcworkspace")) { + return { name, kind: "directory" }; + } + return { name, kind: "other" }; +} + class FakeEditorTargets implements EditorTargetRuntime { readonly launches: RecordedLaunch[] = []; readonly openedPaths: string[] = []; @@ -29,6 +42,8 @@ class FakeEditorTargets implements EditorTargetRuntime { private readonly paths = new Set(); private readonly commands = new Map(); private readonly macApplications = new Set(); + private readonly directories = new Map(); + private readonly directoryErrors = new Map(); constructor(platform: NodeJS.Platform = "linux", env: NodeJS.ProcessEnv = {}) { this.platform = platform; @@ -47,6 +62,26 @@ class FakeEditorTargets implements EditorTargetRuntime { this.macApplications.add(applicationName); } + addDirectory(targetPath: string, entries: readonly string[]): void { + this.paths.add(targetPath); + this.directories.set(targetPath, entries.map(testDirectoryEntry)); + } + + addDirectoryEntries(targetPath: string, entries: readonly EditorTargetDirectoryEntry[]): void { + this.paths.add(targetPath); + this.directories.set(targetPath, [...entries]); + } + + listDirectory(targetPath: string): EditorTargetDirectoryEntry[] { + const error = this.directoryErrors.get(targetPath); + if (error) throw error; + return this.directories.get(targetPath) ?? []; + } + + failDirectoryRead(targetPath: string, error: Error): void { + this.directoryErrors.set(targetPath, error); + } + pathExists(targetPath: string): boolean { return this.paths.has(targetPath); } @@ -83,8 +118,13 @@ class FakeEditorTargets implements EditorTargetRuntime { return this.macApplications.has(applicationName); } + getMacApplicationBundle(applicationName: string): string | null { + return this.hasMacApplication(applicationName) ? `/Applications/${applicationName}.app` : null; + } + async openMacApplication(input: { applicationName: string; + applicationPath?: string; paths: readonly string[]; }): Promise { this.openedMacApplications.push({ @@ -268,6 +308,170 @@ describe("editor target registry", () => { ]); }); + it("lists Xcode only for workspaces that hold something Xcode can open", async () => { + const runtime = new FakeEditorTargets("darwin"); + runtime.installMacApplication("Xcode"); + runtime.addDirectory("/repo/ios-app", ["ios-app.xcodeproj", "Sources"]); + runtime.addDirectory("/repo/web-app", ["package.json", "src"]); + + const iosTargets = await listAvailableEditorTargets(runtime, [xcodeTarget], { + workspacePath: "/repo/ios-app", + }); + const webTargets = await listAvailableEditorTargets(runtime, [xcodeTarget], { + workspacePath: "/repo/web-app", + }); + const unscopedTargets = await listAvailableEditorTargets(runtime, [xcodeTarget]); + + expect(iosTargets).toEqual([ + { + id: "xcode", + label: "Xcode", + kind: "editor", + icon: { kind: "image", dataUrl: "data:image/png;base64,xcode.png" }, + }, + ]); + expect(webTargets).toEqual([]); + expect(unscopedTargets).toEqual([]); + }); + + it("does not mistake similarly named files and directories for Xcode documents", async () => { + const runtime = new FakeEditorTargets("darwin"); + runtime.installMacApplication("Xcode"); + runtime.addDirectoryEntries("/repo/not-an-xcode-project", [ + { name: "Notes.xcodeproj", kind: "file" }, + { name: "App.xcworkspace", kind: "file" }, + { name: "Package.swift", kind: "directory" }, + ]); + + await expect( + listAvailableEditorTargets(runtime, [xcodeTarget], { + workspacePath: "/repo/not-an-xcode-project", + }), + ).resolves.toEqual([]); + }); + + it("hands Xcode the umbrella workspace and the requested file", async () => { + const runtime = new FakeEditorTargets("darwin"); + runtime.installMacApplication("Xcode"); + runtime.addDirectory("/repo/Tickets", [ + "Package.swift", + "Widgets.xcworkspace", + "Tickets.xcworkspace", + "Tickets.xcodeproj", + ]); + runtime.addPath("/repo/Tickets/Sources/App.swift"); + + await openEditorTarget( + { + editorId: "xcode", + workspacePath: "/repo/Tickets", + filePath: "/repo/Tickets/Sources/App.swift", + line: 12, + }, + runtime, + [xcodeTarget], + ); + + expect(runtime.openedMacApplications).toEqual([ + { + applicationName: "Xcode", + paths: ["/repo/Tickets/Tickets.xcworkspace", "/repo/Tickets/Sources/App.swift"], + }, + ]); + }); + + it("falls back from Xcode workspaces to projects to Swift packages", async () => { + const runtime = new FakeEditorTargets("darwin"); + runtime.installMacApplication("Xcode"); + runtime.addDirectory("/repo/project-only", ["Beta.xcodeproj", "Alpha.xcodeproj"]); + runtime.addDirectory("/repo/package-only", ["Package.swift", "Sources"]); + + await openEditorTarget({ editorId: "xcode", workspacePath: "/repo/project-only" }, runtime); + await openEditorTarget({ editorId: "xcode", workspacePath: "/repo/package-only" }, runtime); + + expect(runtime.openedMacApplications.map((entry) => entry.paths)).toEqual([ + ["/repo/project-only/Alpha.xcodeproj"], + ["/repo/package-only/Package.swift"], + ]); + }); + + it("refuses to launch Xcode for a directory it cannot open", async () => { + const runtime = new FakeEditorTargets("darwin"); + runtime.installMacApplication("Xcode"); + runtime.addDirectory("/repo/web-app", ["package.json"]); + + await expect( + openEditorTarget({ editorId: "xcode", workspacePath: "/repo/web-app" }, runtime), + ).rejects.toThrow(/No Xcode project/u); + expect(runtime.openedMacApplications).toEqual([]); + }); + + it("keeps installed editors in registration order when Xcode matches", async () => { + const runtime = new FakeEditorTargets("darwin"); + runtime.installCommand("code"); + runtime.installMacApplication("Xcode"); + runtime.addDirectory("/repo", ["App.xcodeproj"]); + + const targets = await listAvailableEditorTargets(runtime, [vscodeTarget, xcodeTarget], { + workspacePath: "/repo", + }); + + expect(targets.map((target) => target.id)).toEqual(["vscode", "xcode"]); + }); + + it.each(["EACCES", "EPERM", "ENOENT", "ENOTDIR"])( + "keeps general editor targets when Xcode discovery fails with %s", + async (code) => { + const runtime = new FakeEditorTargets("darwin"); + runtime.installCommand("code"); + runtime.installMacApplication("Xcode"); + runtime.addPath("/repo"); + const error = Object.assign(new Error("Cannot read directory"), { code }); + runtime.failDirectoryRead("/repo", error); + + const targets = await listAvailableEditorTargets( + runtime, + [vscodeTarget, xcodeTarget, finderTarget], + { + workspacePath: "/repo", + }, + ); + + expect(targets.map((target) => target.id)).toEqual(["vscode", "finder"]); + await expect( + openEditorTarget({ editorId: "xcode", workspacePath: "/repo" }, runtime), + ).rejects.toBe(error); + }, + ); + + it("propagates unexpected Xcode discovery failures", async () => { + const runtime = new FakeEditorTargets("darwin"); + runtime.installMacApplication("Xcode"); + const error = Object.assign(new Error("I/O failure"), { code: "EIO" }); + runtime.failDirectoryRead("/repo", error); + + await expect( + listAvailableEditorTargets(runtime, [xcodeTarget], { workspacePath: "/repo" }), + ).rejects.toBe(error); + }); + + it("does not discover nested Xcode documents or list an uninstalled Xcode", async () => { + const runtime = new FakeEditorTargets("darwin"); + runtime.addDirectory("/repo", ["App.xcodeproj"]); + const withoutXcode = await listAvailableEditorTargets(runtime, [xcodeTarget], { + workspacePath: "/repo", + }); + runtime.installMacApplication("Xcode"); + runtime.addDirectory("/nested-repo", ["ios"]); + runtime.addDirectory("/nested-repo/ios", ["App.xcworkspace"]); + const nested = await listAvailableEditorTargets(runtime, [xcodeTarget], { + workspacePath: "/nested-repo", + }); + + expect(withoutXcode).toEqual([]); + expect(nested).toEqual([]); + }); + it("detects Cursor's installed Windows command when it is absent from PATH", async () => { const runtime = new FakeEditorTargets("win32", { LOCALAPPDATA: "C:/Users/me/AppData/Local", diff --git a/packages/desktop/src/features/editor-targets/registry.ts b/packages/desktop/src/features/editor-targets/registry.ts index 060302c729..1e589f7a64 100644 --- a/packages/desktop/src/features/editor-targets/registry.ts +++ b/packages/desktop/src/features/editor-targets/registry.ts @@ -25,6 +25,7 @@ import { vscodiumTarget } from "./targets/vscodium.js"; import { vscodeInsidersTarget } from "./targets/vscode-insiders.js"; import { vscodeTarget } from "./targets/vscode.js"; import { webstormTarget } from "./targets/webstorm.js"; +import { xcodeTarget } from "./targets/xcode.js"; import { zedTarget } from "./targets/zed.js"; export const EDITOR_TARGETS: readonly EditorTarget[] = [ @@ -37,6 +38,7 @@ export const EDITOR_TARGETS: readonly EditorTarget[] = [ zedTarget, antigravityTarget, androidStudioTarget, + xcodeTarget, intellijIdeaTarget, aquaTarget, clionTarget, @@ -54,15 +56,33 @@ export const EDITOR_TARGETS: readonly EditorTarget[] = [ fileManagerTarget, ]; +export interface ListEditorTargetsOptions { + /** + * Absolute workspace root. Without it, targets that declare + * `supportsWorkspace` are left out — there is nothing to match them against. + */ + workspacePath?: string; +} + export async function listAvailableEditorTargets( runtime: EditorTargetRuntime, targets: readonly EditorTarget[] = EDITOR_TARGETS, + options: ListEditorTargetsOptions = {}, ): Promise { + const workspacePath = + options.workspacePath && runtime.isAbsolutePath(options.workspacePath) + ? options.workspacePath + : null; const descriptors: EditorTargetDescriptor[] = []; for (const target of targets) { - if (await target.isInstalled(runtime)) { + if (!(await target.isInstalled(runtime))) continue; + if (!target.supportsWorkspace) { descriptors.push(await target.describe(runtime)); + continue; } + if (!workspacePath) continue; + if (!(await target.supportsWorkspace(workspacePath, runtime))) continue; + descriptors.push(await target.describe(runtime)); } return descriptors; } diff --git a/packages/desktop/src/features/editor-targets/runtime.posix.test.ts b/packages/desktop/src/features/editor-targets/runtime.posix.test.ts new file mode 100644 index 0000000000..591bffe9f1 --- /dev/null +++ b/packages/desktop/src/features/editor-targets/runtime.posix.test.ts @@ -0,0 +1,55 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, test } from "vitest"; + +import { listAvailableEditorTargets, openEditorTarget } from "./registry.js"; +import { createEditorTargetRuntime } from "./runtime.js"; +import { xcodeTarget } from "./targets/xcode.js"; + +// The macOS runtime interprets real fixture paths as POSIX paths. +const it = test.runIf(process.platform !== "win32"); + +describe("editor target POSIX filesystem", () => { + it("discovers and opens Xcode documents from the real filesystem", async () => { + const workspacePath = mkdtempSync(path.join(os.tmpdir(), "paseo-xcode-")); + const commands: Array<{ command: string; args: string[] }> = []; + const runtime = createEditorTargetRuntime({ + platform: "darwin", + env: {}, + pathExists: (targetPath) => + targetPath === "/Applications/Xcode.app" || existsSync(targetPath), + loadIcon: async () => ({ kind: "symbol", name: "terminal" }), + spawn: (command, args) => { + commands.push({ command, args }); + const child = { + once(event: "error" | "spawn", handler: (error?: Error) => void) { + if (event === "spawn") queueMicrotask(() => handler()); + return child; + }, + unref() {}, + }; + return child; + }, + }); + try { + writeFileSync(path.join(workspacePath, "Notes.xcodeproj"), "not a project directory"); + mkdirSync(path.join(workspacePath, "Package.swift")); + expect(await listAvailableEditorTargets(runtime, [xcodeTarget], { workspacePath })).toEqual( + [], + ); + + const documentPath = path.join(workspacePath, "App.xcworkspace"); + mkdirSync(documentPath); + const targets = await listAvailableEditorTargets(runtime, [xcodeTarget], { workspacePath }); + expect(targets.map((target) => target.id)).toEqual(["xcode"]); + await openEditorTarget({ editorId: "xcode", workspacePath }, runtime); + + expect(commands).toEqual([ + { command: "/usr/bin/open", args: ["-a", "/Applications/Xcode.app", documentPath] }, + ]); + } finally { + rmSync(workspacePath, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/desktop/src/features/editor-targets/runtime.test.ts b/packages/desktop/src/features/editor-targets/runtime.test.ts index 6a2342e901..2fa123d37a 100644 --- a/packages/desktop/src/features/editor-targets/runtime.test.ts +++ b/packages/desktop/src/features/editor-targets/runtime.test.ts @@ -57,4 +57,86 @@ describe("editor target runtime", () => { }, ]); }); + + it("launches an explicitly selected macOS application bundle", async () => { + const records: SpawnRecord[] = []; + const runtime = createEditorTargetRuntime({ + platform: "darwin", + env: {}, + homeDirectory: "/Users/me", + // A version manager leaves Xcode.app as a symlink beside the real + // bundles, so `open -a Xcode` finds no uniquely named application. + pathExists: (targetPath) => targetPath === "/Applications/Xcode.app", + spawn: (command, args, options) => { + const record = { command, args, options, unrefed: false }; + records.push(record); + const child = { + once(event: "error" | "spawn", handler: (error?: Error) => void) { + if (event === "spawn") queueMicrotask(() => handler()); + return child; + }, + unref() { + record.unrefed = true; + }, + }; + return child; + }, + }); + + expect(runtime.hasMacApplication("Xcode")).toBe(true); + const applicationPath = runtime.getMacApplicationBundle("Xcode"); + if (!applicationPath) throw new Error("Expected Xcode to resolve"); + await runtime.openMacApplication({ + applicationName: "Xcode", + applicationPath, + paths: ["/repo/App.xcworkspace"], + }); + + expect(records.map((record) => ({ command: record.command, args: record.args }))).toEqual([ + { + command: "/usr/bin/open", + args: ["-a", "/Applications/Xcode.app", "/repo/App.xcworkspace"], + }, + ]); + }); + + it("uses LaunchServices application-name resolution by default", async () => { + const records: SpawnRecord[] = []; + const runtime = createEditorTargetRuntime({ + platform: "darwin", + env: {}, + pathExists: (targetPath) => targetPath === "/Applications/Cursor.app", + spawn: (command, args, options) => { + const record = { command, args, options, unrefed: false }; + records.push(record); + const child = { + once(event: "error" | "spawn", handler: (error?: Error) => void) { + if (event === "spawn") queueMicrotask(() => handler()); + return child; + }, + unref() { + record.unrefed = true; + }, + }; + return child; + }, + }); + + await runtime.openMacApplication({ applicationName: "Cursor", paths: ["/repo"] }); + + expect(records[0]?.args).toEqual(["-a", "Cursor", "/repo"]); + }); + + it("surfaces directory read errors", () => { + const readError = new Error("Permission denied"); + const runtime = createEditorTargetRuntime({ + platform: "darwin", + env: {}, + readDirectory: () => { + throw readError; + }, + }); + + expect(() => runtime.listDirectory("/repo")).toThrow(readError); + }); }); diff --git a/packages/desktop/src/features/editor-targets/runtime.ts b/packages/desktop/src/features/editor-targets/runtime.ts index dcd5b514c4..9562b27977 100644 --- a/packages/desktop/src/features/editor-targets/runtime.ts +++ b/packages/desktop/src/features/editor-targets/runtime.ts @@ -1,12 +1,16 @@ import type { ChildProcess, SpawnOptions } from "node:child_process"; import { spawn as nodeSpawn } from "node:child_process"; -import { existsSync as nodeExistsSync } from "node:fs"; +import { existsSync as nodeExistsSync, readdirSync as nodeReaddirSync } from "node:fs"; import { readFile } from "node:fs/promises"; import os from "node:os"; import path, { posix, win32 } from "node:path"; import { app, shell } from "electron"; -import type { EditorTargetIcon, EditorTargetRuntime } from "./target.js"; +import type { + EditorTargetDirectoryEntry, + EditorTargetIcon, + EditorTargetRuntime, +} from "./target.js"; interface SpawnedProcess { once(event: "error", handler: (error: Error) => void): SpawnedProcess; @@ -24,6 +28,7 @@ export interface EditorTargetRuntimeOptions { platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv; pathExists?: (path: string) => boolean; + readDirectory?: (path: string) => EditorTargetDirectoryEntry[]; spawn?: (command: string, args: string[], options: SpawnOptions) => SpawnedProcess; openPath?: (path: string) => Promise; revealPath?: (path: string) => void; @@ -107,6 +112,14 @@ function escapeWindowsCmdValue(value: string): string { return `"${quoted}"`; } +function readDirectory(targetPath: string): EditorTargetDirectoryEntry[] { + return nodeReaddirSync(targetPath, { withFileTypes: true }).map((entry) => { + if (entry.isFile()) return { name: entry.name, kind: "file" }; + if (entry.isDirectory()) return { name: entry.name, kind: "directory" }; + return { name: entry.name, kind: "other" }; + }); +} + function spawnProcess(command: string, args: string[], options: SpawnOptions): SpawnedProcess { return nodeSpawn(command, args, options) as ChildProcess as SpawnedProcess; } @@ -129,17 +142,30 @@ export function createEditorTargetRuntime( const platform = options.platform ?? process.platform; const env = options.env ?? process.env; const pathExists = options.pathExists ?? nodeExistsSync; + const listDirectory = options.readDirectory ?? readDirectory; const spawn = options.spawn ?? spawnProcess; const openPath = options.openPath ?? ((targetPath) => shell.openPath(targetPath)); const revealPath = options.revealPath ?? ((targetPath) => shell.showItemInFolder(targetPath)); const loadIcon = options.loadIcon ?? loadBundledIcon; const homeDirectory = options.homeDirectory ?? os.homedir(); + function resolveMacApplicationBundle(applicationName: string): string | null { + if (platform !== "darwin") return null; + return ( + [ + `/Applications/${applicationName}.app`, + `${homeDirectory}/Applications/${applicationName}.app`, + `/System/Applications/${applicationName}.app`, + ].find(pathExists) ?? null + ); + } + return { platform, env, pathExists, isAbsolutePath: (targetPath) => isAbsolutePath(targetPath, platform), + listDirectory, resolveCommand: (commands) => resolveExecutable(commands, { env, pathExists, platform }), async spawnDetached({ command, args }) { const commandScript = isWindowsCommandScript(command, platform); @@ -172,17 +198,13 @@ export function createEditorTargetRuntime( revealPath, loadIcon, hasMacApplication(applicationName) { - if (platform !== "darwin") return false; - return [ - `/Applications/${applicationName}.app`, - `${homeDirectory}/Applications/${applicationName}.app`, - `/System/Applications/${applicationName}.app`, - ].some(pathExists); + return resolveMacApplicationBundle(applicationName) !== null; }, - async openMacApplication({ applicationName, paths }) { + getMacApplicationBundle: resolveMacApplicationBundle, + async openMacApplication({ applicationName, applicationPath, paths }) { await this.spawnDetached({ command: "/usr/bin/open", - args: ["-a", applicationName, ...paths], + args: ["-a", applicationPath ?? applicationName, ...paths], }); }, }; diff --git a/packages/desktop/src/features/editor-targets/target.ts b/packages/desktop/src/features/editor-targets/target.ts index 6e92d71b54..f7111662c5 100644 --- a/packages/desktop/src/features/editor-targets/target.ts +++ b/packages/desktop/src/features/editor-targets/target.ts @@ -1,5 +1,10 @@ export type EditorTargetKind = "editor" | "file-manager"; +export interface EditorTargetDirectoryEntry { + name: string; + kind: "file" | "directory" | "other"; +} + export type EditorTargetIcon = | { kind: "image"; dataUrl: string } | { kind: "symbol"; name: "folder" | "terminal" }; @@ -24,13 +29,19 @@ export interface EditorTargetRuntime { pathExists(path: string): boolean; isAbsolutePath(path: string): boolean; + listDirectory(path: string): EditorTargetDirectoryEntry[]; resolveCommand(commands: readonly string[]): string | null; spawnDetached(input: { command: string; args: readonly string[] }): Promise; openPath(path: string): Promise; revealPath(path: string): void; loadIcon(fileName: string): Promise; hasMacApplication(applicationName: string): boolean; - openMacApplication(input: { applicationName: string; paths: readonly string[] }): Promise; + getMacApplicationBundle(applicationName: string): string | null; + openMacApplication(input: { + applicationName: string; + applicationPath?: string; + paths: readonly string[]; + }): Promise; } export interface EditorTarget { @@ -38,5 +49,11 @@ export interface EditorTarget { describe(runtime: EditorTargetRuntime): Promise; isInstalled(runtime: EditorTargetRuntime): Promise; + /** + * Targets that only make sense for a specific kind of project implement this. + * They are listed only when the caller knows the workspace path and this + * returns true, so a general-purpose repo never shows Xcode-shaped entries. + */ + supportsWorkspace?(workspacePath: string, runtime: EditorTargetRuntime): Promise; launch(input: EditorTargetLaunchInput, runtime: EditorTargetRuntime): Promise; } diff --git a/packages/desktop/src/features/editor-targets/targets/xcode.ts b/packages/desktop/src/features/editor-targets/targets/xcode.ts new file mode 100644 index 0000000000..2acdf993ee --- /dev/null +++ b/packages/desktop/src/features/editor-targets/targets/xcode.ts @@ -0,0 +1,101 @@ +import type { EditorTarget, EditorTargetDirectoryEntry, EditorTargetRuntime } from "../target.js"; + +const XCODE_APPLICATION_NAME = "Xcode"; +const SWIFT_PACKAGE_MANIFEST = "Package.swift"; + +function joinPath(directory: string, entry: string): string { + return `${directory.replace(/\/+$/u, "")}/${entry}`; +} + +function directoryName(directory: string): string { + const segments = directory.split("/").filter(Boolean); + return segments[segments.length - 1] ?? ""; +} + +function isUnavailableDirectoryError(error: unknown): boolean { + if (!(error instanceof Error) || !("code" in error)) return false; + switch (error.code) { + case "EACCES": + case "EPERM": + case "ENOENT": + case "ENOTDIR": + return true; + default: + return false; + } +} + +function pickByExtension( + entries: readonly EditorTargetDirectoryEntry[], + directory: string, + extension: string, +): string | null { + const matches = entries + .filter((entry) => entry.kind === "directory" && entry.name.endsWith(extension)) + .map((entry) => entry.name) + .sort(); + if (matches.length === 0) return null; + // A repo can hold several projects; the one named after its directory is the + // umbrella project far more often than not, so prefer it before falling back + // to alphabetical order. + const preferred = `${directoryName(directory)}${extension}`; + return matches.includes(preferred) ? preferred : matches[0]; +} + +/** + * Resolves what Xcode should actually be handed. Xcode cannot open a plain + * directory, so the workspace root is searched for a document it understands: + * an `.xcworkspace` first (it supersedes the projects it contains), then an + * `.xcodeproj`, then a Swift package manifest. + */ +function findXcodeDocument(workspacePath: string, runtime: EditorTargetRuntime): string | null { + const entries = runtime.listDirectory(workspacePath); + const workspace = pickByExtension(entries, workspacePath, ".xcworkspace"); + if (workspace) return joinPath(workspacePath, workspace); + const project = pickByExtension(entries, workspacePath, ".xcodeproj"); + if (project) return joinPath(workspacePath, project); + if (entries.some((entry) => entry.kind === "file" && entry.name === SWIFT_PACKAGE_MANIFEST)) { + return joinPath(workspacePath, SWIFT_PACKAGE_MANIFEST); + } + return null; +} + +export const xcodeTarget: EditorTarget = { + id: "xcode", + async describe(runtime) { + return { + id: this.id, + label: "Xcode", + kind: "editor", + icon: await runtime.loadIcon("xcode.png"), + }; + }, + async isInstalled(runtime) { + return runtime.hasMacApplication(XCODE_APPLICATION_NAME); + }, + async supportsWorkspace(workspacePath, runtime) { + try { + return findXcodeDocument(workspacePath, runtime) !== null; + } catch (error) { + // Discovery is optional; an unavailable directory does not prevent the + // user from choosing another installed editor. Explicit launches still fail. + if (isUnavailableDirectoryError(error)) return false; + throw error; + } + }, + async launch(input, runtime) { + const document = findXcodeDocument(input.workspacePath, runtime); + if (!document) { + throw new Error(`No Xcode project, workspace, or Swift package in ${input.workspacePath}`); + } + const applicationPath = runtime.getMacApplicationBundle(XCODE_APPLICATION_NAME); + if (!applicationPath) throw new Error("Xcode is not installed"); + // Pass the requested file alongside the project. This launch path does not + // support line or column navigation. + await runtime.openMacApplication({ + applicationName: XCODE_APPLICATION_NAME, + applicationPath, + paths: input.filePath ? [document, input.filePath] : [document], + }); + }, +}; diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index 6075fbe64c..0942134521 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -95,7 +95,8 @@ contextBridge.exposeInMainWorld("paseoDesktop", { openUrl: (url: string) => ipcRenderer.invoke("paseo:opener:openUrl", url), }, editor: { - listTargets: () => ipcRenderer.invoke("paseo:editor:listTargets"), + listTargets: (input?: { workspacePath?: string }) => + ipcRenderer.invoke("paseo:editor:listTargets", input), openTarget: (input: { editorId: string; workspacePath: string; diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index b2da02d6e0..1a38528c25 100644 --- a/packages/expo-two-way-audio/package.json +++ b/packages/expo-two-way-audio/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/expo-two-way-audio", - "version": "0.13.0", + "version": "0.13.2", "description": "Native module for two way audio streaming", "keywords": [ "ExpoTwoWayAudio", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index c1dd7c9381..d7708e5925 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.13.0", + "version": "0.13.2", "files": [ "dist", "!dist/**/*.map", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index b6967d6fc1..18421b41e1 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/plugin", - "version": "0.13.0", + "version": "0.13.2", "description": "Paseo plugin SDK package", "files": [ "dist", @@ -72,8 +72,8 @@ "use-sync-external-store": "^1.6.0" }, "devDependencies": { - "@getpaseo/client": "0.13.0", - "@getpaseo/protocol": "0.13.0", + "@getpaseo/client": "0.13.2", + "@getpaseo/protocol": "0.13.2", "@types/node": "^20.9.0", "@types/react": "~19.2.0", "react": "19.2.3", @@ -83,8 +83,8 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@getpaseo/client": "0.13.0", - "@getpaseo/protocol": "0.13.0", + "@getpaseo/client": "0.13.2", + "@getpaseo/protocol": "0.13.2", "react": ">=19.1.0 <20", "react-native": ">=0.81.5", "zod": "^4.4.3" diff --git a/packages/plugin/src/server/provider.ts b/packages/plugin/src/server/provider.ts index 1646e8473b..ee9fbc21c2 100644 --- a/packages/plugin/src/server/provider.ts +++ b/packages/plugin/src/server/provider.ts @@ -9,6 +9,7 @@ export const PROVIDER_CAPABILITIES = [ "prompt.image", "prompt.output_schema", "prompt.steer", + "session.can_stop_runtime", "session.archive", "session.configure", "session.list", @@ -277,6 +278,7 @@ export type ProviderInput = } | { type: "session.archive"; requestId: string; persistence: ProviderPersistence } | { type: "session.unarchive"; requestId: string; persistence: ProviderPersistence } + | { type: "session.can_stop_runtime"; requestId: string; sessionId: string } | { type: "session.close"; requestId: string; sessionId: string }; export interface ProviderThinkingOption { @@ -552,6 +554,7 @@ export type ProviderTimelineItem = }); export type ProviderEvent = + | { type: "session.can_stop_runtime"; requestId: string; sessionId: string; canStop: boolean } | { type: "catalog"; requestId: string; catalog: ProviderCatalog } | { type: "sessions"; requestId: string; sessions: ProviderSessionSummary[] } | { type: "request.completed"; requestId: string } @@ -634,6 +637,8 @@ function requiredPromptCapabilities(prompt: ProviderPrompt): readonly ProviderCa export function requiredProviderCapabilities(input: ProviderInput): readonly ProviderCapability[] { switch (input.type) { + case "session.can_stop_runtime": + return ["session.can_stop_runtime"]; case "catalog": case "session.interrupt": case "session.close": @@ -955,6 +960,13 @@ export const ProviderInputSchema: z.ZodType = z.discriminatedUnio persistence: persistenceSchema, }) .strict(), + z + .object({ + type: z.literal("session.can_stop_runtime"), + requestId: idSchema, + sessionId: idSchema, + }) + .strict(), z.object({ type: z.literal("session.close"), requestId: idSchema, sessionId: idSchema }).strict(), ]); @@ -1326,6 +1338,14 @@ export const ProviderEventSchema: z.ZodType = z.discriminatedUnio sessionId: idSchema, }) .strip(), + z + .object({ + type: z.literal("session.can_stop_runtime"), + requestId: idSchema, + sessionId: idSchema, + canStop: z.boolean(), + }) + .strip(), z .object({ type: z.literal("session.closed"), diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 90ba994043..e463e681e7 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/protocol", - "version": "0.13.0", + "version": "0.13.2", "description": "Paseo shared protocol schemas and wire types", "files": [ "dist", diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 4beaf0a0b2..3034598f1e 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -82,6 +82,16 @@ import { ScheduleRunOnceResponseSchema, ScheduleUpdateResponseSchema, } from "./schedule/rpc-schemas.js"; +import { + SpeechLocalModelsDeleteRequestSchema, + SpeechLocalModelsDeleteResponseSchema, + SpeechLocalModelsDownloadRequestSchema, + SpeechLocalModelsDownloadResponseSchema, + SpeechLocalModelsListRequestSchema, + SpeechLocalModelsListResponseSchema, + SpeechSettingsPatchSchema, + SpeechSettingsSchema, +} from "./speech/rpc-schemas.js"; import { LoopRunRequestSchema, LoopListRequestSchema, @@ -224,12 +234,18 @@ export const MutableDaemonConfigSchema = z metadataGeneration: MutableMetadataGenerationConfigSchema.default({ providers: [] }), autoArchiveAfterMerge: z.boolean().default(false), enableTerminalAgentHooks: z.boolean().default(false), + // COMPAT(idleRuntimeCleanupSetting): added in v0.13.0, keep optional while old daemons are supported. + // Persisted host-wide value, applied live by the daemon. + idleRuntimeCleanup: z.boolean().optional(), appendSystemPrompt: z.string().default(""), terminalProfiles: z.array(TerminalProfileSchema).optional(), agentProfiles: z.array(AgentProfileSchema).optional(), skills: z.object({ selection: AgentSkillSelectionSchema.optional() }).strict().optional(), pluginsEnabled: z.boolean().optional(), plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(), + // COMPAT(speechSettings): added in v0.12.0, optional because older daemons omit it. + features: SpeechSettingsSchema.optional(), + envOverrides: z.record(z.string(), z.string()).optional(), }) .passthrough(); @@ -245,11 +261,13 @@ export const MutableDaemonConfigPatchSchema = z metadataGeneration: MutableMetadataGenerationConfigSchema.partial().optional(), autoArchiveAfterMerge: z.boolean().optional(), enableTerminalAgentHooks: z.boolean().optional(), + idleRuntimeCleanup: z.boolean().optional(), appendSystemPrompt: z.string().optional(), terminalProfiles: z.array(TerminalProfileSchema).optional(), agentProfiles: z.array(AgentProfileSchema).optional(), pluginsEnabled: z.boolean().optional(), plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(), + features: SpeechSettingsPatchSchema.optional(), }) .partial() .passthrough(); @@ -3436,6 +3454,9 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ ChatWaitRequestSchema, ScheduleCreateRequestSchema, ScheduleListRequestSchema, + SpeechLocalModelsListRequestSchema, + SpeechLocalModelsDownloadRequestSchema, + SpeechLocalModelsDeleteRequestSchema, ScheduleInspectRequestSchema, ScheduleLogsRequestSchema, SchedulePauseRequestSchema, @@ -3670,6 +3691,8 @@ export const ServerInfoStatusPayloadSchema = z daemonStatusRpc: z.boolean().optional(), // COMPAT(daemonConfigReload): added in v0.4.0, remove gate after 2027-02-14. daemonConfigReload: z.boolean().optional(), + // COMPAT(speechSettings): added in v0.12.0, remove gate after 2027-04-18. + speechSettings: z.boolean().optional(), // COMPAT(relayConfig): added in v0.2.6, remove gate after 2027-01-31. relayConfig: z.boolean().optional(), // COMPAT(pushTokenRevocation): added in v0.3.2, remove gate after 2027-02-10. @@ -3694,6 +3717,8 @@ export const ServerInfoStatusPayloadSchema = z pluginTimelineItems: z.boolean().optional(), // COMPAT(skillManagement): added in v0.4.0, remove gate after 2027-08-16. skillManagement: z.boolean().optional(), + // COMPAT(idleRuntimeCleanupSetting): added in v0.13.0, remove gate after 2027-04-26. + idleRuntimeCleanupSetting: z.boolean().optional(), // COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23. "terminal-restore-modes": z.boolean().optional(), // COMPAT(terminalInputModeReplay): added in v0.2.6, remove gate after 2027-02-02. @@ -7146,6 +7171,9 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ChatWaitResponseSchema, ScheduleCreateResponseSchema, ScheduleListResponseSchema, + SpeechLocalModelsListResponseSchema, + SpeechLocalModelsDownloadResponseSchema, + SpeechLocalModelsDeleteResponseSchema, ScheduleInspectResponseSchema, ScheduleLogsResponseSchema, SchedulePauseResponseSchema, diff --git a/packages/protocol/src/messages.wire-compat.test.ts b/packages/protocol/src/messages.wire-compat.test.ts index d3b3ce26df..d6b2bc02ff 100644 --- a/packages/protocol/src/messages.wire-compat.test.ts +++ b/packages/protocol/src/messages.wire-compat.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from "vitest"; import { z } from "zod"; import { + MutableDaemonConfigSchema, + MutableDaemonConfigPatchSchema, UsageReportEntrySchema, AgentSnapshotPayloadSchema, AgentTimelineItemPayloadSchema, @@ -525,3 +527,50 @@ test("released setup clients parse empty combined logs and retain command output }).detail.log, ).toBe("old daemon combined output"); }); + +test("local STT language survives wire reads and patches", () => { + const patch = { + features: { + dictation: { stt: { provider: "local", language: "fr" } }, + voiceMode: { stt: { provider: "local", language: null } }, + }, + }; + expect(MutableDaemonConfigPatchSchema.parse(patch)).toEqual(patch); +}); + +test("speech config reads remain compatible with clients before features and future providers", () => { + const oldSchema = MutableDaemonConfigSchema.omit({ features: true, envOverrides: true }); + const oldRead = oldSchema.parse({ + providers: {}, + relay: { enabled: false }, + mcp: { enabled: true, injectIntoAgents: true }, + }); + const read = { + ...oldRead, + features: { + dictation: { enabled: true, stt: { provider: "local", language: "fr" } }, + voiceMode: { + enabled: false, + stt: { provider: "future", model: "future-model", region: "eu" }, + tts: { provider: "openai", hasApiKey: false }, + }, + }, + envOverrides: { "features.voiceMode.stt.provider": "PASEO_VOICE_STT_PROVIDER" }, + }; + expect(oldSchema.parse(read)).toEqual(read); + expect(MutableDaemonConfigSchema.parse(oldRead)).toEqual(oldRead); + expect(MutableDaemonConfigSchema.parse(read).features?.dictation.stt).toEqual( + read.features.dictation.stt, + ); + expect(MutableDaemonConfigSchema.parse(read).features?.voiceMode.stt).toEqual( + read.features.voiceMode.stt, + ); + const oldPatchSchema = MutableDaemonConfigPatchSchema.omit({ features: true }); + const oldPatch = oldPatchSchema.parse({ relay: { enabled: true } }); + expect(MutableDaemonConfigPatchSchema.parse(oldPatch)).toEqual(oldPatch); + expect( + MutableDaemonConfigPatchSchema.parse({ + features: { voiceMode: { stt: { provider: "future", region: "eu" } } }, + }).features?.voiceMode?.stt, + ).toEqual({ provider: "future", region: "eu" }); +}); diff --git a/packages/protocol/src/speech/rpc-schemas.ts b/packages/protocol/src/speech/rpc-schemas.ts new file mode 100644 index 0000000000..437560c7e8 --- /dev/null +++ b/packages/protocol/src/speech/rpc-schemas.ts @@ -0,0 +1,202 @@ +import { z } from "zod"; + +// Per-site settings mirror config.json. Unknown providers remain parseable by older +// clients; the feature UI decides whether it knows how to edit them. +const ModelFields = { model: z.string().optional() }; +const OpenAiReadFields = { + ...ModelFields, + hasApiKey: z.boolean(), + baseUrl: z.string().optional(), +}; +const SttReadFields = { + language: z.string().optional(), + confidenceThreshold: z.number().optional(), +}; +const LocalTtsFields = { speakerId: z.number().int().optional(), speed: z.number().optional() }; + +export const SpeechSttSettingsSchema = z.union([ + z + .object({ provider: z.literal("local"), ...ModelFields, language: z.string().optional() }) + .passthrough(), + z.object({ provider: z.literal("openai"), ...OpenAiReadFields, ...SttReadFields }).passthrough(), + z.object({ provider: z.string(), ...ModelFields }).passthrough(), +]); +export const SpeechTtsSettingsSchema = z.union([ + z.object({ provider: z.literal("local"), ...ModelFields, ...LocalTtsFields }).passthrough(), + z + .object({ provider: z.literal("openai"), ...OpenAiReadFields, voice: z.string().optional() }) + .passthrough(), + z.object({ provider: z.string(), ...ModelFields }).passthrough(), +]); + +export const SpeechSettingsSchema = z + .object({ + dictation: z.object({ enabled: z.boolean(), stt: SpeechSttSettingsSchema }).passthrough(), + voiceMode: z + .object({ + enabled: z.boolean(), + stt: SpeechSttSettingsSchema, + tts: SpeechTtsSettingsSchema, + llm: z + .object({ provider: z.string().optional(), model: z.string().optional() }) + .passthrough() + .optional(), + turnDetection: z.object({ provider: z.string().optional() }).passthrough().optional(), + }) + .passthrough(), + }) + .passthrough(); + +const ModelPatchFields = { model: z.string().nullable().optional() }; +const OpenAiPatchFields = { + ...ModelPatchFields, + apiKey: z.string().nullable().optional(), + baseUrl: z.string().nullable().optional(), +}; +const SttPatchFields = { + language: z.string().nullable().optional(), + confidenceThreshold: z.number().nullable().optional(), +}; +const LocalTtsPatchFields = { + speakerId: z.number().int().nullable().optional(), + speed: z.number().nullable().optional(), +}; +const SpeechSttSettingsPatchSchema = z.union([ + z + .object({ + provider: z.literal("local"), + ...ModelPatchFields, + language: z.string().nullable().optional(), + }) + .passthrough(), + z + .object({ provider: z.literal("openai"), ...OpenAiPatchFields, ...SttPatchFields }) + .passthrough(), + z.object({ provider: z.string().nullable() }).passthrough(), + z + .object({ provider: z.undefined().optional(), ...OpenAiPatchFields, ...SttPatchFields }) + .passthrough(), +]); +const SpeechTtsSettingsPatchSchema = z.union([ + z + .object({ provider: z.literal("local"), ...ModelPatchFields, ...LocalTtsPatchFields }) + .passthrough(), + z + .object({ + provider: z.literal("openai"), + ...OpenAiPatchFields, + voice: z.string().nullable().optional(), + }) + .passthrough(), + z.object({ provider: z.string().nullable() }).passthrough(), + z + .object({ + provider: z.undefined().optional(), + ...OpenAiPatchFields, + ...LocalTtsPatchFields, + voice: z.string().nullable().optional(), + }) + .passthrough(), +]); +export const SpeechSettingsPatchSchema = z + .object({ + dictation: z + .object({ + enabled: z.boolean().nullable().optional(), + stt: SpeechSttSettingsPatchSchema.optional(), + }) + .passthrough() + .optional(), + voiceMode: z + .object({ + enabled: z.boolean().nullable().optional(), + stt: SpeechSttSettingsPatchSchema.optional(), + tts: SpeechTtsSettingsPatchSchema.optional(), + llm: z + .object({ + provider: z.string().nullable().optional(), + model: z.string().nullable().optional(), + }) + .passthrough() + .optional(), + turnDetection: z + .object({ provider: z.string().nullable().optional() }) + .passthrough() + .optional(), + }) + .passthrough() + .optional(), + }) + .passthrough(); + +export type SpeechSettings = z.infer; +export type SpeechSettingsPatch = z.infer; + +// --------------------------------------------------------------------------- +// Local speech models: catalog, install state, download, delete. +// --------------------------------------------------------------------------- + +export const LocalSpeechModelSchema = z + .object({ + id: z.string(), + // "stt" or "tts". + kind: z.string(), + label: z.string(), + description: z.string(), + languages: z.array(z.string()), + downloadSizeBytes: z.number(), + isDefault: z.boolean(), + // Selected by an enabled feature; the daemon keeps it installed. + inUse: z.boolean(), + // "installed" | "not_installed" | "downloading" | "extracting" | "failed". + status: z.string(), + receivedBytes: z.number().optional(), + totalBytes: z.number().optional(), + error: z.string().optional(), + }) + .passthrough(); + +export type LocalSpeechModel = z.infer; + +export const SpeechLocalModelsListRequestSchema = z.object({ + type: z.literal("speech.local_models.list.request"), + requestId: z.string(), +}); + +export const SpeechLocalModelsDownloadRequestSchema = z.object({ + type: z.literal("speech.local_models.download.request"), + requestId: z.string(), + modelId: z.string(), +}); + +export const SpeechLocalModelsDeleteRequestSchema = z.object({ + type: z.literal("speech.local_models.delete.request"), + requestId: z.string(), + modelId: z.string(), +}); + +export const SpeechLocalModelsListResponseSchema = z.object({ + type: z.literal("speech.local_models.list.response"), + payload: z.object({ + requestId: z.string(), + models: z.array(LocalSpeechModelSchema), + error: z.string().nullable(), + }), +}); + +// Resolves once the download starts; poll the list for progress. +export const SpeechLocalModelsDownloadResponseSchema = z.object({ + type: z.literal("speech.local_models.download.response"), + payload: z.object({ + requestId: z.string(), + error: z.string().nullable(), + }), +}); + +export const SpeechLocalModelsDeleteResponseSchema = z.object({ + type: z.literal("speech.local_models.delete.response"), + payload: z.object({ + requestId: z.string(), + error: z.string().nullable(), + }), +}); diff --git a/packages/relay/package.json b/packages/relay/package.json index 4f0aec8a03..b2e0c5187f 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.13.0", + "version": "0.13.2", "description": "Paseo relay for bridging daemon and client connections", "files": [ "dist", diff --git a/packages/server/package.json b/packages/server/package.json index dbba1c4aa8..4aeb01dc3f 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.13.0", + "version": "0.13.2", "description": "Paseo backend server", "files": [ "dist/server", @@ -107,11 +107,11 @@ "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "0.3.246", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.13.0", - "@getpaseo/highlight": "0.13.0", - "@getpaseo/plugin": "0.13.0", - "@getpaseo/protocol": "0.13.0", - "@getpaseo/relay": "0.13.0", + "@getpaseo/client": "0.13.2", + "@getpaseo/highlight": "0.13.2", + "@getpaseo/plugin": "0.13.2", + "@getpaseo/protocol": "0.13.2", + "@getpaseo/relay": "0.13.2", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/sdk": "1.14.46", diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 24b672a3cc..ca6591b7ad 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -1,3 +1,4 @@ +import { createIdleRuntimeCleanup } from "./idle-runtime-cleanup/index.js"; import { prepareAgentMessage, formatAgentMessage, @@ -10135,6 +10136,47 @@ test("closeAgent persists one final closed snapshot", async () => { } }); +test("idle runtime cleanup honors a provider veto and resumes the saved conversation", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-cleanup-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + let safe = false; + const client = new (class extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + const session = await super.createSession(config); + session.canStopRuntime = async () => safe; + return session; + } + })(); + const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + const collect = runtimeCleanup(manager); + try { + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + const now = new Date(Date.now() + 61 * 60_000); + expect(await collect({ now })).toEqual({ collected: [], failures: [] }); + safe = true; + expect(await collect({ now })).toEqual({ collected: [], failures: [] }); + const later = new Date(now.getTime() + 61 * 60_000); + expect((await collect({ now: later })).collected).toEqual([ + { agentId: agent.id, provider: "codex", sessionId: agent.persistence?.sessionId }, + ]); + expect(manager.getAgent(agent.id)).toBeNull(); + const resumed = await ensureAgentLoaded(agent.id, { + agentManager: manager, + agentStorage: storage, + logger, + }); + expect(resumed.id).toBe(agent.id); + expect(resumed.persistence).toEqual(agent.persistence); + await manager.runAgent(agent.id, "Continue the saved conversation"); + } finally { + await manager.closeAgent(manager.listAgents()[0]?.id ?? "missing").catch(() => undefined); + await storage.flush(); + rmSync(workdir, { recursive: true, force: true }); + } +}); + test("idle agents remain resident until an explicit lifecycle action closes them", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-residency-")); let closeCount = 0; @@ -11079,12 +11121,12 @@ test("tryRunOutOfBand gives providers the command another agent sent, not its en try { const fromAgent = prepareAgentMessage("/handled", { kind: "agent-message", agentId: "sender" }); - expect(manager.tryRunOutOfBand(agent.id, fromAgent.prompt)).toBe(true); + expect(await manager.tryRunOutOfBand(agent.id, fromAgent.prompt)).toBe(true); expect(seenPrompts).toEqual(["/handled"]); // A human who pastes an envelope gets it quoted, and it stays a plain prompt. const pasted = prepareAgentMessage(String(fromAgent.prompt)); - expect(manager.tryRunOutOfBand(agent.id, pasted.prompt)).toBe(false); + expect(await manager.tryRunOutOfBand(agent.id, pasted.prompt)).toBe(false); expect(seenPrompts.at(-1)).toBe(pasted.prompt); } finally { await manager.closeAgent(agent.id); @@ -12211,3 +12253,176 @@ test("reads the last durable assistant response without loading a stored-only ch ); expect(manager.getAgent("stored-child")).toBeNull(); }); + +function runtimeCleanup(manager: AgentManager) { + let now = Date.now(); + let protectedCaller = (_agentId: string) => false; + const cleanup = createIdleRuntimeCleanup({ + enabled: true, + runtimes: () => manager.getRuntimeCleanupCandidates(), + logger, + now: () => now, + isProtected: (agentId) => protectedCaller(agentId), + schedule: () => () => {}, + }); + onTestFinished(() => cleanup.close()); + return (options: { now: Date; isProtected?: (agentId: string) => boolean }) => { + now = options.now.getTime(); + protectedCaller = options.isProtected ?? (() => false); + return cleanup.sweep(); + }; +} + +function idleCleanupHarness() { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-protection-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const commands: AgentPromptInput[] = []; + const prompts: AgentPromptInput[] = []; + const recordPrompts = (session: AgentSession) => { + session.tryHandleOutOfBand = (prompt) => + prompt === "/maintenance" + ? { + run: async () => { + commands.push(prompt); + }, + } + : null; + const start = session.startTurn.bind(session); + session.startTurn = (prompt, options) => { + prompts.push(prompt); + return start(prompt, options); + }; + return session; + }; + const sessions: AgentSession[] = []; + const probes: Array<(() => Promise) | undefined> = []; + const closeEntered = deferred(); + let closeGate: Promise | undefined; + let closes = 0; + const client = new (class extends TestAgentClient { + override async resumeSession( + handle: AgentPersistenceHandle, + config?: Partial, + ): Promise { + const session = await super.resumeSession(handle, config); + session.describePersistence = () => handle; + return recordPrompts(session); + } + override async createSession(config: AgentSessionConfig): Promise { + const index = sessions.length; + const session: AgentSession = await super.createSession(config); + session.canStopRuntime = async () => probes[index]?.() ?? false; + session.close = async () => { + closes++; + closeEntered.resolve(); + await closeGate; + }; + sessions.push(session); + return recordPrompts(session); + } + })(); + const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + onTestFinished(async () => { + closeGate = undefined; + await Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id))); + await storage.flush(); + rmSync(workdir, { recursive: true, force: true }); + }); + return { + commands, + sweep: runtimeCleanup(manager), + manager, + storage, + sessions, + probes, + prompts, + closeEntered, + get closes() { + return closes; + }, + holdClose: (gate: Promise) => { + closeGate = gate; + }, + create: (labels?: Record) => + manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + labels, + }), + later: (minutes: number) => new Date(Date.now() + minutes * 60_000), + }; +} + +test("idle cleanup rechecks new prompt activity after an asynchronous safety probe", async () => { + const h = idleCleanupHarness(); + const agent = await h.create(); + const entered = deferred(); + const probe = deferred(); + h.probes[0] = () => { + entered.resolve(); + return probe.promise; + }; + const sweep = h.sweep({ now: h.later(61) }); + await entered.promise; + await h.manager.runAgent(agent.id, "arrived during safety query"); + probe.resolve(true); + expect((await sweep).collected).toEqual([]); + expect(h.closes).toBe(0); +}); + +test("idle cleanup retains a parent whose idle managed child owns background work", async () => { + const h = idleCleanupHarness(); + const parent = await h.create(); + await h.create({ [PARENT_AGENT_ID_LABEL]: parent.id }); + h.probes[0] = async () => true; + h.probes[1] = async () => false; + expect((await h.sweep({ now: h.later(61) })).collected).toEqual([]); + expect(h.closes).toBe(0); +}); + +test("idle cleanup rechecks queued deliveries admitted during the safety probe", async () => { + const h = idleCleanupHarness(); + await h.create(); + let queued = false; + h.probes[0] = async () => { + queued = true; + return true; + }; + expect((await h.sweep({ now: h.later(61), isProtected: () => queued })).collected).toEqual([]); + expect(h.closes).toBe(0); +}); + +test("a prompt racing idle cleanup waits for close and resumes exactly once", async () => { + const h = idleCleanupHarness(); + const agent = await h.create(); + h.probes[0] = async () => true; + const gate = deferred(); + h.holdClose(gate.promise); + const sweep = h.sweep({ now: h.later(61) }); + await h.closeEntered.promise; + const run = h.manager.runAgent(agent.id, "arrived while closing"); + gate.resolve(); + expect((await sweep).collected).toHaveLength(1); + const result = await run; + expect(result.sessionId).toBe(agent.persistence?.sessionId); + expect(h.closes).toBe(1); + expect(h.prompts).toEqual(["arrived while closing"]); +}); + +test("an out-of-band command racing idle cleanup waits for the resumed runtime", async () => { + const h = idleCleanupHarness(); + const agent = await h.create(); + h.probes[0] = async () => true; + const gate = deferred(); + h.holdClose(gate.promise); + const sweep = h.sweep({ now: h.later(61) }); + await h.closeEntered.promise; + const command = startAgentRun(h.manager, agent.id, "/maintenance", logger); + await new Promise((resolve) => setImmediate(resolve)); + const deliveredWhileClosing = [...h.commands]; + gate.resolve(); + await sweep; + expect(deliveredWhileClosing).toEqual([]); + expect(await command).toEqual({ disposition: "out_of_band" }); + expect(h.commands).toEqual(["/maintenance"]); + expect(h.prompts).toEqual([]); +}); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 6f931a4a4b..323b6acd8e 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1,3 +1,5 @@ +import type { RuntimeCleanupCandidate } from "./idle-runtime-cleanup/index.js"; +import { ensureAgentLoaded } from "./agent-loading.js"; import { projectTimelineRows } from "./timeline-projection.js"; import type { PluginLifecycle } from "../plugins/lifecycle/index.js"; import { describeHookAgent, publishAgentStream } from "../plugins/lifecycle/index.js"; @@ -760,6 +762,8 @@ export class AgentManager { private readonly agentRegistrationTasks = new Set>(); private readonly inFlightAgentCloses = new Map>(); private readonly reloadedSessionCloses = new WeakMap>(); + private readonly runtimeCleanupIdentities = new WeakMap(); + private readonly collectingRuntimes = new Set(); private readonly lifecycleMutationTails = new Map>(); private readonly agentStreamCoalescer: AgentStreamCoalescer; private mcpBaseUrl: string | null; @@ -1753,6 +1757,64 @@ export class AgentManager { }); } + /** Capture runtime capabilities without handing lifecycle ownership to the collector. */ + getRuntimeCleanupCandidates(): RuntimeCleanupCandidate[] { + if (!this.registry) return []; + const candidates: RuntimeCleanupCandidate[] = []; + for (const agent of this.agents.values()) { + const updatedAt = agent.updatedAt; + let identity = this.runtimeCleanupIdentities.get(agent.session); + if (!identity) { + identity = Object.freeze({}); + this.runtimeCleanupIdentities.set(agent.session, identity); + } + const isEligible = () => + this.agents.get(agent.id) === agent && + agent.updatedAt === updatedAt && + this.runtimeHasNoPendingWork(agent); + candidates.push({ + identity, + agentId: agent.id, + parentAgentId: getParentAgentIdFromLabels(agent.labels) ?? undefined, + provider: agent.provider, + sessionId: agent.persistence?.sessionId ?? null, + lastActivityAt: updatedAt.getTime(), + isEligible, + canStopRuntime: agent.session.canStopRuntime?.bind(agent.session), + closeIfEligible: async (revalidate) => { + if (!agent.persistence?.sessionId) return false; + // Block prompt admission before entering the close lane. A skipped close also + // releases this barrier, so the waiting prompt keeps the existing runtime. + this.collectingRuntimes.add(agent.id); + try { + return await this.runLifecycleMutation(agent.id, () => + this.closeAgentRuntime(agent.id, () => isEligible() && revalidate()), + ); + } finally { + this.collectingRuntimes.delete(agent.id); + } + }, + }); + } + return candidates; + } + + private runtimeHasNoPendingWork(agent: LiveManagedAgent): boolean { + return ( + agent.lifecycle === "idle" && + !agent.internal && + agent.activeForegroundTurnId === null && + !this.runs.hasRun(agent.id) && + !agent.pendingReplacement && + agent.pendingPermissions.size === 0 && + agent.inFlightPermissionResponses.size === 0 && + (this.collectingRuntimes.has(agent.id) || !this.lifecycleMutationTails.has(agent.id)) && + !this.foregroundMutationTails.has(agent.id) && + !this.sessionEventTails.has(agent.id) && + !this.providerSubagents.list(agent.id).some((child) => child.status === "running") + ); + } + closeAgent(agentId: string): Promise { const existing = this.inFlightAgentCloses.get(agentId); if (existing) { @@ -1773,7 +1835,7 @@ export class AgentManager { return close; } - private async closeAgentRuntime(agentId: string): Promise { + private async closeAgentRuntime(agentId: string, canClose?: () => boolean): Promise { const agent = this.requireAgent(agentId); this.logger.trace( { @@ -1788,6 +1850,7 @@ export class AgentManager { "agent.manager.close.start", ); await this.drainSessionEvents(agentId); + if (canClose && !canClose()) return false; // Retain ownership until shutdown succeeds. A failed close may still own a // native writer, so publishing a resumable closed snapshot would orphan it. await agent.session.close(); @@ -1813,6 +1876,7 @@ export class AgentManager { if (persistError !== undefined) { throw persistError; } + return true; } private cancelRunningProviderSubagents(parentAgentId: string): void { @@ -2465,7 +2529,19 @@ export class AgentManager { * emitted by the handler flow through dispatchStream so they persist and * broadcast like normal timeline events. */ - tryRunOutOfBand(agentId: string, prompt: AgentPromptInput, options?: AgentRunOptions): boolean { + async tryRunOutOfBand( + agentId: string, + prompt: AgentPromptInput, + options?: AgentRunOptions, + ): Promise { + if (this.collectingRuntimes.has(agentId) || !this.agents.has(agentId)) { + await this.waitForAgentClose(agentId); + await ensureAgentLoaded(agentId, { + agentManager: this, + agentStorage: this.requireRegistry(), + logger: this.logger, + }); + } const agent = this.requireSessionAgent(agentId); // Providers parse slash commands from the raw text, so a command another agent sent // must reach them without its sender envelope. @@ -2476,6 +2552,7 @@ export class AgentManager { if (!handler) { return false; } + this.touchUpdatedAt(agent); if (options?.clientMessageId) { this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, "accepted"); this.emitState(agent); @@ -2495,7 +2572,7 @@ export class AgentManager { } this.dispatchStream(agent.id, event, { timestamp: new Date().toISOString() }); }; - void (async () => { + void this.runForegroundMutation(agentId, async () => { try { await handler.run({ emit: dispatch }); } catch (error) { @@ -2506,7 +2583,7 @@ export class AgentManager { item: { type: "assistant_message", text: `[Error] ${text}` }, }); } - })(); + }); return true; } @@ -2590,11 +2667,27 @@ export class AgentManager { } } + private async *streamAfterRuntimeCollection( + agentId: string, + prompt: AgentPromptInput, + options?: AgentRunOptions, + ): AsyncGenerator { + await this.waitForAgentClose(agentId); + await ensureAgentLoaded(agentId, { + agentManager: this, + agentStorage: this.requireRegistry(), + logger: this.logger, + }); + yield* this.streamAgent(agentId, prompt, options); + } + streamAgent( agentId: string, prompt: AgentPromptInput, options?: AgentRunOptions, ): AsyncGenerator { + if (this.collectingRuntimes.has(agentId) || (!this.agents.has(agentId) && this.registry)) + return this.streamAfterRuntimeCollection(agentId, prompt, options); const existingAgent = this.requireSessionAgent(agentId); this.logger.trace( { diff --git a/packages/server/src/server/agent/agent-messages/delivery.test.ts b/packages/server/src/server/agent/agent-messages/delivery.test.ts index 8cd480c1fa..bbe3d4fb3d 100644 --- a/packages/server/src/server/agent/agent-messages/delivery.test.ts +++ b/packages/server/src/server/agent/agent-messages/delivery.test.ts @@ -283,3 +283,33 @@ test("batches retain each sender's attachment context and image order", async () )?.map((message) => message.source?.agentId), ).toEqual(["one", "two"]); }); + +test("runtime safety sees queued messages until provider dispatch settles", async () => { + const clock = createClock(); + let release!: () => void; + const dispatched = new Promise((resolve) => { + release = resolve; + }); + const delivery = createAgentMessageDelivery({ + schedule: clock.schedule, + dispatch: async () => { + await dispatched; + return "steered"; + }, + onError: (error) => { + throw error; + }, + }); + expect(delivery.hasPending("parent")).toBe(false); + delivery.enqueue({ + recipientId: "parent", + prompt: "finished", + source: { kind: "agent-message", agentId: "child" }, + }); + expect(delivery.hasPending("parent")).toBe(true); + clock.advance(2000); + expect(delivery.hasPending("parent")).toBe(true); + release(); + await delivery.close(); + expect(delivery.hasPending("parent")).toBe(false); +}); diff --git a/packages/server/src/server/agent/agent-messages/delivery.ts b/packages/server/src/server/agent/agent-messages/delivery.ts index e9313903d2..f26d83ec50 100644 --- a/packages/server/src/server/agent/agent-messages/delivery.ts +++ b/packages/server/src/server/agent/agent-messages/delivery.ts @@ -43,6 +43,8 @@ interface Dependencies { } export interface AgentMessageDelivery { enqueue(message: Message): Receipt; + /** Queued messages and provider dispatches still owned by this recipient. */ + hasPending(recipientId: string): boolean; /** Stop admission, flush pending messages, and await provider dispatch acknowledgments. */ close(): Promise; /** Existing completion waits include agent messages admitted before the wait. */ @@ -138,6 +140,7 @@ export function createAgentMessageDelivery(dependencies: Dependencies): AgentMes } return { + hasPending: (recipientId) => pending.has(recipientId) || dispatching.has(recipientId), enqueue(message) { if (closed) throw new Error("Agent message delivery is closed"); let resolve!: Admitted["resolve"]; diff --git a/packages/server/src/server/agent/agent-prompt.ts b/packages/server/src/server/agent/agent-prompt.ts index a8fd2ebd4b..08aca8e626 100644 --- a/packages/server/src/server/agent/agent-prompt.ts +++ b/packages/server/src/server/agent/agent-prompt.ts @@ -110,7 +110,7 @@ export async function startAgentRun( // Out-of-band commands (e.g. /goal pause) must run WITHOUT canceling an // in-flight turn — replaceAgentRun would interrupt the running turn. The // intercept lives at this layer so it covers every prompt entrypoint. - if (agentManager.tryRunOutOfBand(agentId, prompt, options?.runOptions)) { + if (await agentManager.tryRunOutOfBand(agentId, prompt, options?.runOptions)) { options?.onDispatch?.("out_of_band"); return { disposition: "out_of_band" }; } diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 59fb6022e9..6818e65445 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -694,6 +694,10 @@ export interface AgentSession { * still uncertain. */ interrupt(): Promise; + /** True only when releasing this runtime loses no background work or pending replies. + * An optional additional veto: missing methods use daemon baseline eligibility; + * implemented checks that reject or return false keep the runtime resident. */ + canStopRuntime?(): Promise; /** Release live runtime resources without archiving or deleting the durable native session. */ close(): Promise; listCommands?(): Promise; diff --git a/packages/server/src/server/agent/finish-subscriptions/finish-subscriptions.test.ts b/packages/server/src/server/agent/finish-subscriptions/finish-subscriptions.test.ts index 872961111b..323e4be48f 100644 --- a/packages/server/src/server/agent/finish-subscriptions/finish-subscriptions.test.ts +++ b/packages/server/src/server/agent/finish-subscriptions/finish-subscriptions.test.ts @@ -1,3 +1,4 @@ +import { createIdleRuntimeCleanup } from "../idle-runtime-cleanup/index.js"; import { HostObservationError } from "../host-operations/index.js"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -67,7 +68,7 @@ afterEach(async () => { for (const service of services.splice(0)) await service.close(); await Promise.all(homes.splice(0).map((home) => rm(home, { recursive: true, force: true }))); }); -async function scenario(directory?: string, removalFailures = 0) { +async function scenario(directory?: string, removalFailures = 0, retryDelayMs = 1) { const home = directory ?? (await mkdtemp(join(tmpdir(), "finish-recovery-"))); if (!directory) homes.push(home); const durableStore = createJsonFinishSubscriptionStore(home); @@ -91,7 +92,7 @@ async function scenario(directory?: string, removalFailures = 0) { callerIsActive: async () => active, serverId: "origin", logger: createTestLogger(), - retryDelayMs: 1, + retryDelayMs, }); services.push(service); await service.start(); @@ -387,13 +388,13 @@ test("removed-host notice retries parent dispatch without observing again or del }); test("failed removal after dispatch acknowledgment retries only storage, preserving the accepted outcome", async () => { - const state = await scenario(undefined, 1); + const state = await scenario(undefined, 2, 1000); await state.service.register(intent); await until(() => state.observation.waits.length === 1); state.observation.waits[0]!.result.resolve(outcome("idle")); await until(() => state.delivery.prompts.length === 1); state.delivery.acknowledgments[0]!.resolve("steered"); - await expect.poll(async () => (await state.store.list()).length).toBe(0); + await expect.poll(async () => (await state.store.list()).length, { timeout: 10000 }).toBe(0); expect(state.delivery.prompts).toHaveLength(1); expect(state.delivery.prompts[0]).toContain("finished."); expect(state.observation.waits).toHaveLength(1); @@ -412,3 +413,87 @@ test("shutdown during observation setup owns rejection of the already-canceled n expect(await state.store.list()).toHaveLength(1); expect(state.delivery.prompts).toEqual([]); }); + +test("remote finish intent protects its caller through registration, observation and delivery", async () => { + const s = await scenario(); + let time = 61 * 60_000; + let collected = false; + const runtime = { + identity: {}, + agentId: "parent", + parentAgentId: undefined, + provider: "test", + sessionId: "parent", + lastActivityAt: 0, + isEligible: () => !collected, + closeIfEligible: async (revalidate: () => boolean) => { + if (!revalidate()) return false; + collected = true; + return true; + }, + }; + const cleanup = createIdleRuntimeCleanup({ + enabled: true, + logger: createTestLogger(), + now: () => time, + runtimes: () => (collected ? [] : [runtime]), + isProtected: (agentId) => s.service.hasPendingForCaller(agentId), + schedule: () => () => {}, + }); + const savedPut = s.store.put.bind(s.store); + const writing = deferred(); + s.store.put = async (record) => { + await writing.promise; + await savedPut(record); + }; + const registration = s.service.register({ ...intent, serverId: "remote" }, true); + const protectedDuringRegistration = s.service.hasPendingForCaller?.("parent") ?? false; + writing.resolve(); + const accepted = await registration; + expect(protectedDuringRegistration).toBe(true); + expect((await cleanup.sweep()).collected).toEqual([]); + expect(s.service.hasPendingForCaller("parent")).toBe(true); + accepted.accept(); + await until(() => s.observation.waits.length === 1); + expect(s.service.hasPendingForCaller("parent")).toBe(true); + s.observation.waits[0]!.result.resolve(outcome()); + await until(() => s.delivery.prompts.length === 1); + expect(s.service.hasPendingForCaller("parent")).toBe(true); + s.delivery.acknowledgments[0]!.resolve("steered"); + await until(() => !s.service.hasPendingForCaller("parent")); + expect(await s.store.list()).toEqual([]); + time = 62 * 60_000; + expect((await cleanup.sweep()).collected).toEqual([]); + time = 122 * 60_000; + expect((await cleanup.sweep()).collected).toHaveLength(1); + await cleanup.close(); +}); + +test("finish recovery protects callers before durable intent has loaded", async () => { + const s = await scenario(); + await s.service.register({ ...intent, serverId: "remote" }); + await s.service.close(); + const loading = deferred(); + const recovered = createFinishSubscriptions({ + store: { + ...s.store, + list: async () => { + await loading.promise; + return s.store.list(); + }, + }, + messages: () => s.delivery, + open: s.observation.open.bind(s.observation), + callerIsActive: async () => true, + serverId: "origin", + logger: createTestLogger(), + }); + services.push(recovered); + expect(recovered.hasPendingForCaller("parent")).toBe(true); + const starting = recovered.start(); + expect(recovered.hasPendingForCaller("parent")).toBe(true); + loading.resolve(); + await starting; + expect(recovered.hasPendingForCaller("parent")).toBe(true); + expect(recovered.hasPendingForCaller("unrelated")).toBe(false); +}); diff --git a/packages/server/src/server/agent/finish-subscriptions/index.ts b/packages/server/src/server/agent/finish-subscriptions/index.ts index 58c3c8eff1..22324f0ecc 100644 --- a/packages/server/src/server/agent/finish-subscriptions/index.ts +++ b/packages/server/src/server/agent/finish-subscriptions/index.ts @@ -49,6 +49,8 @@ export interface FinishRegistration { } export interface FinishSubscriptions { start(): Promise; + /** Pending notification intent, including registration, recovery and unacknowledged delivery. */ + hasPendingForCaller(agentId: string): boolean; register( input: Omit, pendingDispatch?: boolean, @@ -72,6 +74,8 @@ export function createFinishSubscriptions(deps: Dependencies): FinishSubscriptio const tasks = new Set>(); const watches = new Map>(); let closed = false; + let recovering = true; + const registering = new Map(); let mutations = Promise.resolve(); function serialize(action: () => Promise): Promise { const result = mutations.then(action); @@ -300,6 +304,13 @@ export function createFinishSubscriptions(deps: Dependencies): FinishSubscriptio } return { + hasPendingForCaller(agentId) { + if (recovering || registering.has(agentId)) return true; + for (const group of watches.values()) { + for (const watch of group) if (watch.records[0]!.callerAgentId === agentId) return true; + } + return false; + }, start: () => serialize(async () => { const groups = new Map(); @@ -309,9 +320,11 @@ export function createFinishSubscriptions(deps: Dependencies): FinishSubscriptio groups.set(key(record), records); } for (const records of groups.values()) attach(records, false); + recovering = false; }), - register: (input, pendingDispatch = false) => - serialize(async () => { + register: (input, pendingDispatch = false) => { + registering.set(input.callerAgentId, (registering.get(input.callerAgentId) ?? 0) + 1); + return serialize(async () => { if (closed) throw new Error("Finish subscriptions are closed"); const record: FinishSubscription = { ...input, @@ -337,13 +350,18 @@ export function createFinishSubscriptions(deps: Dependencies): FinishSubscriptio }, reject: () => serialize(async () => { - stop(watch, actualGroup); await remove(watch); + stop(watch, actualGroup); }), }; if (!pendingDispatch) registration.accept(); return registration; - }), + }).finally(() => { + const remaining = registering.get(input.callerAgentId)! - 1; + if (remaining) registering.set(input.callerAgentId, remaining); + else registering.delete(input.callerAgentId); + }); + }, async close() { closed = true; await mutations; diff --git a/packages/server/src/server/agent/idle-runtime-cleanup/idle-runtime-cleanup.test.ts b/packages/server/src/server/agent/idle-runtime-cleanup/idle-runtime-cleanup.test.ts new file mode 100644 index 0000000000..5ed1fa9b79 --- /dev/null +++ b/packages/server/src/server/agent/idle-runtime-cleanup/idle-runtime-cleanup.test.ts @@ -0,0 +1,294 @@ +import { expect, onTestFinished, test } from "vitest"; +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { createIdleRuntimeCleanup, type RuntimeCleanupCandidate } from "./index.js"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((yes) => { + resolve = yes; + }); + return { promise, resolve }; +} +class Runtime { + readonly identity = {}; + busy = false; + closed = false; + resumable = true; + activity = 0; + parentAgentId: string | undefined; + probe: (() => Promise) | undefined; + admitClose: (() => Promise) | undefined; + finishClose: (() => Promise) | undefined; + constructor(readonly id = "agent") {} + capture(): RuntimeCleanupCandidate { + const activity = this.activity; + return { + identity: this.identity, + agentId: this.id, + parentAgentId: this.parentAgentId, + provider: "test", + sessionId: this.resumable ? this.id : null, + lastActivityAt: activity, + isEligible: () => !this.closed && !this.busy && this.activity === activity, + canStopRuntime: this.probe, + closeIfEligible: async (eligible) => { + await this.admitClose?.(); + if (!eligible()) return false; + this.closed = true; + await this.finishClose?.(); + return true; + }, + }; + } +} +function scenario(enabled = true) { + const runtime = new Runtime(); + const runtimes = [runtime]; + let time = 0; + let protectedCaller = false; + let tick!: () => void; + const cleanup = createIdleRuntimeCleanup({ + enabled, + logger: createTestLogger(), + now: () => time, + runtimes: () => runtimes.filter((item) => !item.closed).map((item) => item.capture()), + isProtected: () => protectedCaller, + schedule: (callback, interval) => { + expect(interval).toBe(60_000); + tick = callback; + return () => {}; + }, + }); + onTestFinished(() => cleanup.close()); + return { + runtime, + runtimes, + cleanup, + tick: () => tick(), + protect: (value: boolean) => { + protectedCaller = value; + }, + sweep: (minutes: number) => { + time = minutes * 60_000; + return cleanup.sweep(); + }, + }; +} + +test("a runtime without a provider check is collected at exactly one hour", async () => { + const s = scenario(); + expect((await s.sweep(59)).collected).toEqual([]); + expect((await s.sweep(60)).collected).toEqual([ + { agentId: "agent", provider: "test", sessionId: "agent" }, + ]); +}); + +test("activity restarts the single one-hour idle period", async () => { + const s = scenario(); + await s.sweep(59); + s.runtime.activity = 59 * 60_000; + expect((await s.sweep(118)).collected).toEqual([]); + expect((await s.sweep(119)).collected).toHaveLength(1); +}); + +test.each(["foreground", "finish subscription"])( + "a positive provider check cannot override %s", + async (blocker) => { + const s = scenario(); + let calls = 0; + s.runtime.probe = async () => { + calls++; + return true; + }; + if (blocker === "foreground") s.runtime.busy = true; + else s.protect(true); + expect((await s.sweep(61)).collected).toEqual([]); + expect(calls).toBe(0); + s.runtime.busy = false; + s.protect(false); + expect((await s.sweep(62)).collected).toEqual([]); + expect((await s.sweep(121)).collected).toEqual([]); + expect((await s.sweep(122)).collected).toHaveLength(1); + }, +); + +test.each(["veto", "error"])( + "a supported provider %s retains the runtime until a fresh idle hour passes", + async (decision) => { + const s = scenario(); + s.runtime.probe = async () => { + if (decision === "error") throw new Error("Probe failed"); + return false; + }; + const blocked = await s.sweep(61); + expect(blocked.collected).toEqual([]); + expect(blocked.failures).toHaveLength(decision === "error" ? 1 : 0); + await s.sweep(119); + s.runtime.probe = async () => true; + expect((await s.sweep(120)).collected).toEqual([]); + expect((await s.sweep(179)).collected).toEqual([]); + expect((await s.sweep(180)).collected).toHaveLength(1); + }, +); + +test("an idle managed descendant with provider-held work vetoes its parent", async () => { + const s = scenario(); + const child = new Runtime("child"); + child.parentAgentId = s.runtime.id; + child.probe = async () => false; + s.runtimes.push(child); + expect((await s.sweep(61)).collected).toEqual([]); + child.probe = async () => true; + expect((await s.sweep(62)).collected).toEqual([]); + expect((await s.sweep(122)).collected).toHaveLength(2); +}); + +test("new caller protection during an async provider probe prevents stale collection", async () => { + const s = scenario(); + const probe = deferred(); + s.runtime.probe = () => probe.promise; + const collecting = s.sweep(61); + s.protect(true); + probe.resolve(true); + expect((await collecting).collected).toEqual([]); + expect(s.runtime.closed).toBe(false); + s.protect(false); + expect((await s.sweep(62)).collected).toEqual([]); + expect((await s.sweep(122)).collected).toHaveLength(1); +}); + +test("a supported provider timeout retains ownership even if it later returns true", async () => { + const s = scenario(); + const probe = deferred(); + s.runtime.probe = () => probe.promise; + const result = await s.sweep(61); + expect(result.collected).toEqual([]); + expect(result.failures[0]?.error).toEqual(new Error("Runtime safety probe timed out for agent")); + probe.resolve(true); + expect((await s.sweep(62)).collected).toEqual([]); + expect(s.runtime.closed).toBe(false); +}, 15_000); + +test("the host opt-out disables explicit sweeps and scheduled ticks", async () => { + const s = scenario(false); + let calls = 0; + s.runtime.probe = async () => { + calls++; + return true; + }; + expect((await s.sweep(61)).collected).toEqual([]); + s.tick(); + await s.cleanup.close(); + expect(calls).toBe(0); + expect(s.runtime.closed).toBe(false); +}); + +test("a managed descendant without persistence still protects its parent while working", async () => { + const s = scenario(); + const child = new Runtime("initializing-child"); + child.parentAgentId = s.runtime.id; + child.resumable = false; + child.busy = true; + s.runtimes.push(child); + expect((await s.sweep(61)).collected).toEqual([]); + child.busy = false; + expect((await s.sweep(62)).collected).toEqual([]); + expect((await s.sweep(122)).collected).toEqual([ + { agentId: "agent", provider: "test", sessionId: "agent" }, + ]); + expect(child.closed).toBe(false); +}); + +test("the live host switch retains eligible runtimes and re-enables with their existing idle clocks", async () => { + const s = scenario(); + s.cleanup.setEnabled(false); + expect((await s.sweep(61)).collected).toEqual([]); + expect(s.runtime.closed).toBe(false); + s.cleanup.setEnabled(true); + s.tick(); + expect((await s.cleanup.sweep()).collected).toHaveLength(1); +}); + +test.each([false, true])( + "disabling during a probe invalidates the sweep (re-enable: %s)", + async (reEnable) => { + const s = scenario(); + const probe = deferred(); + s.runtime.probe = () => probe.promise; + const collecting = s.sweep(61); + s.cleanup.setEnabled(false); + if (reEnable) s.cleanup.setEnabled(true); + probe.resolve(true); + expect((await collecting).collected).toEqual([]); + expect(s.runtime.closed).toBe(false); + s.cleanup.setEnabled(true); + expect((await s.sweep(62)).collected).toHaveLength(1); + }, +); + +test.each([false, true])( + "disabling while waiting for lifecycle admission prevents closure (re-enable: %s)", + async (reEnable) => { + const s = scenario(); + const admitted = deferred(); + const waiting = deferred(); + s.runtime.admitClose = () => { + waiting.resolve(); + return admitted.promise; + }; + const collecting = s.sweep(61); + await waiting.promise; + s.cleanup.setEnabled(false); + if (reEnable) s.cleanup.setEnabled(true); + admitted.resolve(); + expect((await collecting).collected).toEqual([]); + expect(s.runtime.closed).toBe(false); + s.cleanup.setEnabled(true); + expect((await s.sweep(62)).collected).toHaveLength(1); + }, +); + +test.each(["veto", "error"])( + "an invalidated provider %s does not reset the idle clock", + async (outcome) => { + const s = scenario(); + const probe = deferred(); + s.runtime.probe = async () => { + await probe.promise; + if (outcome === "error") throw new Error("Old probe failed"); + return false; + }; + const collecting = s.sweep(61); + s.cleanup.setEnabled(false); + s.cleanup.setEnabled(true); + probe.resolve(false); + expect(await collecting).toEqual({ collected: [], failures: [] }); + s.runtime.probe = async () => true; + expect((await s.sweep(62)).collected).toHaveLength(1); + }, +); + +test("disabling lets a started close finish but prevents the next closure in that sweep", async () => { + const s = scenario(); + const sibling = new Runtime("sibling"); + s.runtimes.push(sibling); + const closing = deferred(); + const finished = deferred(); + s.runtime.finishClose = () => { + closing.resolve(); + return finished.promise; + }; + const collecting = s.sweep(61); + await closing.promise; + expect(s.runtime.closed).toBe(true); + s.cleanup.setEnabled(false); + finished.resolve(); + expect((await collecting).collected).toEqual([ + { agentId: "agent", provider: "test", sessionId: "agent" }, + ]); + expect(sibling.closed).toBe(false); + s.cleanup.setEnabled(true); + expect((await s.sweep(62)).collected).toEqual([ + { agentId: "sibling", provider: "test", sessionId: "sibling" }, + ]); +}); diff --git a/packages/server/src/server/agent/idle-runtime-cleanup/index.ts b/packages/server/src/server/agent/idle-runtime-cleanup/index.ts new file mode 100644 index 0000000000..fddea0d25b --- /dev/null +++ b/packages/server/src/server/agent/idle-runtime-cleanup/index.ts @@ -0,0 +1,167 @@ +import type { Logger } from "pino"; +import { withTimeout } from "../../../utils/promise-timeout.js"; + +/** A captured runtime capability. Its lifecycle owner revalidates before releasing it. */ +export interface RuntimeCleanupCandidate { + readonly identity: object; + readonly agentId: string; + readonly parentAgentId: string | undefined; + readonly provider: string; + readonly sessionId: string | null; + readonly lastActivityAt: number; + isEligible(): boolean; + canStopRuntime?(): Promise; + closeIfEligible(revalidate: () => boolean): Promise; +} +interface Options { + enabled: boolean; + runtimes(): readonly RuntimeCleanupCandidate[]; + isProtected?(agentId: string): boolean; + logger: Pick; + now?: () => number; + schedule?: (tick: () => void, intervalMs: number) => () => void; +} +interface CollectionEntry { + agentId: string; + provider: string; + sessionId: string; +} +interface CollectionResult { + collected: CollectionEntry[]; + failures: Array<{ agentId: string; error: unknown }>; +} +const IDLE_PERIOD_MS = 60 * 60_000; + +/** Owns idle clocks, baseline eligibility, optional provider vetoes and the sweep lifetime. */ +export function createIdleRuntimeCleanup(options: Options) { + const protectedAt = new WeakMap(); + let collection: Promise | null = null; + let stopped = false; + let enabled = options.enabled; + let generation = 0; + const isProtected = options.isProtected ?? (() => false); + const now = options.now ?? Date.now; + + function familyOf(root: RuntimeCleanupCandidate, runtimes: readonly RuntimeCleanupCandidate[]) { + const family = new Map([[root.agentId, root]]); + for (const parent of family.values()) { + for (const child of runtimes) { + if (child.parentAgentId === parent.agentId) family.set(child.agentId, child); + } + } + return [...family.values()]; + } + function baselineAllows(family: readonly RuntimeCleanupCandidate[]) { + return family.every((runtime) => runtime.isEligible() && !isProtected(runtime.agentId)); + } + async function providersAllow( + family: readonly RuntimeCleanupCandidate[], + isCurrent: () => boolean, + ) { + for (const runtime of family) { + if (!isCurrent()) return false; + if (!runtime.canStopRuntime) continue; + const allowed = await withTimeout( + runtime.canStopRuntime(), + 10_000, + `Runtime safety probe timed out for ${runtime.agentId}`, + ); + if (allowed !== true) return false; + } + return true; + } + async function collect(): Promise { + const result: CollectionResult = { collected: [], failures: [] }; + const sweepGeneration = generation; + const isCurrent = () => enabled && !stopped && generation === sweepGeneration; + if (!isCurrent()) return result; + const timestamp = now(); + const runtimes = options.runtimes(); + for (const runtime of runtimes) { + if (!isCurrent()) break; + if (!runtime.sessionId) continue; + const family = familyOf(runtime, runtimes); + try { + const allowed = baselineAllows(family) && (await providersAllow(family, isCurrent)); + if (!isCurrent()) break; + if (!allowed) { + protectedAt.set(runtime.identity, { at: timestamp, protected: true }); + options.logger.debug( + { agentId: runtime.agentId }, + "Idle runtime retained by a baseline or provider veto", + ); + continue; + } + const previous = protectedAt.get(runtime.identity); + if (previous?.protected) + protectedAt.set(runtime.identity, { at: timestamp, protected: false }); + const idleSince = Math.max( + runtime.lastActivityAt, + protectedAt.get(runtime.identity)?.at ?? 0, + ); + if (timestamp - idleSince < IDLE_PERIOD_MS) continue; + const revalidate = () => { + const currentFamily = familyOf(runtime, options.runtimes()); + return ( + isCurrent() && + currentFamily.length === family.length && + currentFamily.every((current) => + family.some((captured) => captured.identity === current.identity), + ) && + baselineAllows(family) + ); + }; + if (await runtime.closeIfEligible(revalidate)) { + const entry = { + agentId: runtime.agentId, + provider: runtime.provider, + sessionId: runtime.sessionId, + }; + result.collected.push(entry); + options.logger.info(entry, "Collected idle agent runtime"); + } else if (isCurrent()) { + protectedAt.set(runtime.identity, { at: timestamp, protected: true }); + } + } catch (error) { + if (!isCurrent()) break; + protectedAt.set(runtime.identity, { at: timestamp, protected: true }); + result.failures.push({ agentId: runtime.agentId, error }); + options.logger.warn( + { agentId: runtime.agentId, err: error }, + "Idle runtime cleanup failed", + ); + } + } + return result; + } + function sweep(): Promise { + if (collection) return collection; + collection = collect().finally(() => { + collection = null; + }); + return collection; + } + const schedule = + options.schedule ?? + ((tick, intervalMs) => { + const timer = setInterval(tick, intervalMs); + timer.unref(); + return () => clearInterval(timer); + }); + const cancel = schedule(() => { + void sweep().catch((error) => options.logger.warn({ err: error }, "Idle runtime sweep failed")); + }, 60_000); + return { + sweep, + setEnabled(value: boolean) { + if (enabled === value) return; + enabled = value; + generation++; + }, + async close() { + stopped = true; + cancel(); + await collection; + }, + }; +} diff --git a/packages/server/src/server/agent/permission-response.test.ts b/packages/server/src/server/agent/permission-response.test.ts index a5cf477e68..5ac7374e88 100644 --- a/packages/server/src/server/agent/permission-response.test.ts +++ b/packages/server/src/server/agent/permission-response.test.ts @@ -32,7 +32,7 @@ class FakePermissionAgentManager { return this.permissionResult; } - tryRunOutOfBand(): boolean { + async tryRunOutOfBand(): Promise { return this.outOfBandHandled; } diff --git a/packages/server/src/server/agent/plugin-provider.test.ts b/packages/server/src/server/agent/plugin-provider.test.ts index aa0aa1a924..3131447098 100644 --- a/packages/server/src/server/agent/plugin-provider.test.ts +++ b/packages/server/src/server/agent/plugin-provider.test.ts @@ -1,3 +1,8 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { AgentStorage } from "./agent-storage.js"; +import { createIdleRuntimeCleanup } from "./idle-runtime-cleanup/index.js"; import { relative } from "node:path"; import type { ProviderConnection, @@ -1234,3 +1239,85 @@ test("config-required plugin membership is independent of enablement and other p await plugins.shutdown(); } }); + +test.each([true, false, "error", "wrong-session", "missing"] as const)( + "plugin runtime safety query: %s", + async (answer) => { + const harness = createProviderHarness({ + capabilities: + answer === "missing" ? CAPABILITIES : [...CAPABILITIES, "session.can_stop_runtime"], + handleInput: async (input, emit) => { + if (input.type !== "session.can_stop_runtime") return false; + if (answer === "error") + emit({ + type: "request.failed", + requestId: input.requestId, + error: { message: "Probe failed" }, + }); + else + emit({ + type: "session.can_stop_runtime", + requestId: input.requestId, + sessionId: answer === "wrong-session" ? "child-1" : input.sessionId, + canStop: answer === true, + }); + return true; + }, + }); + const registry = new PluginAgentClientRegistry(createTestLogger()); + registry.replace([harness.registration]); + const client = registry.clients()[harness.registration.id]!; + const session = await client.createSession({ provider: harness.registration.id, cwd: "/tmp" }); + try { + if (answer === "error" || answer === "wrong-session") { + await expect(session.canStopRuntime!()).rejects.toThrow( + answer === "error" ? "Probe failed" : "another session", + ); + } else if (answer === "missing") expect(session.canStopRuntime).toBeUndefined(); + else expect(await session.canStopRuntime!()).toBe(answer === true); + const requests = harness.inputs.filter((input) => input.type === "session.can_stop_runtime"); + expect(requests).toHaveLength(answer === "missing" ? 0 : 1); + if (answer !== "missing") + expect(requests[0]).toMatchObject({ + sessionId: harness.inputs.find((input) => input.type === "session.open")?.sessionId, + }); + } finally { + await session.close(); + await registry.shutdown(); + } + }, +); + +test("plugin without a runtime safety capability uses baseline collection without a query", async () => { + const home = await mkdtemp(join(tmpdir(), "plugin-baseline-cleanup-")); + const logger = createTestLogger(); + const storage = new AgentStorage(join(home, "agents"), logger); + const harness = createProviderHarness(); + const registry = new PluginAgentClientRegistry(logger); + registry.replace([harness.registration]); + const manager = new AgentManager({ clients: registry.clients(), registry: storage, logger }); + const agent = await manager.createAgent( + { provider: harness.registration.id, cwd: home }, + undefined, + { workspaceId: undefined }, + ); + const cleanup = createIdleRuntimeCleanup({ + enabled: true, + logger, + runtimes: () => manager.getRuntimeCleanupCandidates(), + now: () => agent.updatedAt.getTime() + 60 * 60_000, + schedule: () => () => {}, + }); + try { + expect(agent.session?.canStopRuntime).toBeUndefined(); + expect((await cleanup.sweep()).collected).toHaveLength(1); + expect(manager.getAgent(agent.id)).toBeNull(); + expect(harness.inputs.filter((input) => input.type === "session.can_stop_runtime")).toEqual([]); + } finally { + await cleanup.close(); + await Promise.all(manager.listAgents().map((item) => manager.closeAgent(item.id))); + await registry.shutdown(); + await storage.flush(); + await rm(home, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/agent/plugin-provider.ts b/packages/server/src/server/agent/plugin-provider.ts index db5d7651a2..68e9778e43 100644 --- a/packages/server/src/server/agent/plugin-provider.ts +++ b/packages/server/src/server/agent/plugin-provider.ts @@ -113,9 +113,15 @@ function providerError(error: ProviderError): Error { function isProviderRequestReply( event: ProviderEvent, -): event is Extract { +): event is Extract< + ProviderEvent, + { type: "request.completed" | "catalog" | "sessions" | "session.can_stop_runtime" } +> { return ( - event.type === "request.completed" || event.type === "catalog" || event.type === "sessions" + event.type === "request.completed" || + event.type === "catalog" || + event.type === "sessions" || + event.type === "session.can_stop_runtime" ); } @@ -564,10 +570,21 @@ class ProviderRuntime { } private finishRequest( - event: Extract, + event: Extract< + ProviderEvent, + { type: "request.completed" | "catalog" | "sessions" | "session.can_stop_runtime" } + >, ): void { const request = this.requests.get(event.requestId); if (!request) return; + if ( + event.type === "session.can_stop_runtime" && + this.providerSessions.get(event.sessionId)?.id !== request.sessionId + ) { + this.requests.delete(event.requestId); + request.reject(new Error("Runtime safety response belongs to another session")); + return; + } this.requests.delete(event.requestId); request.resolve(event); } @@ -635,6 +652,27 @@ class ProviderRuntimeSession { } } + async canStopRuntime(): Promise { + if ( + this.terminal || + this.prompts.size || + this.activeTurnIds.size || + !this.capabilities.includes("session.can_stop_runtime") + ) + return false; + const event = await this.runtime.complete( + { + type: "session.can_stop_runtime", + requestId: randomUUID(), + sessionId: this.providerSessionId, + }, + AbortSignal.timeout(10_000), + ); + if (event.type !== "session.can_stop_runtime") + throw new Error("Invalid runtime safety response"); + return event.canStop; + } + async configure(changes: ProviderConfigChanges): Promise { const input: Extract = { type: "session.configure", @@ -1201,6 +1239,11 @@ class PluginAgentSession implements AgentSession { for (const event of this.history) yield event; } + get canStopRuntime(): (() => Promise) | undefined { + if (!this.bridge.negotiatedCapabilities.includes("session.can_stop_runtime")) return undefined; + return () => this.bridge.canStopRuntime(); + } + async getRuntimeInfo() { return { provider: this.provider, diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 9f85e4d37f..0f3c39868a 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -469,6 +469,7 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession describePersistence: () => mapPersistenceHandle(provider, inner.describePersistence()), interrupt: () => inner.interrupt(), close: () => inner.close(), + canStopRuntime: inner.canStopRuntime?.bind(inner), listCommands: inner.listCommands?.bind(inner), setModel: inner.setModel?.bind(inner), setThinkingOption: inner.setThinkingOption?.bind(inner), diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index a050aa9077..e902493dcb 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -35,6 +35,7 @@ import { resolveACPModelSelection, summarizeACPRequestError, } from "./acp-agent.js"; +import { createIdleRuntimeCleanup } from "../idle-runtime-cleanup/index.js"; import type { ProcessTerminator, TreeKillTarget } from "../../../utils/tree-kill.js"; import { COPILOT_AGENT_FEATURE_OPTION, @@ -810,6 +811,46 @@ describe("ACPAgentSession terminal tools", () => { truncated: false, }); }); + + test("trims large terminal output without rescanning the retained suffix", async () => { + const session = createSession(); + const terminal = await session.createTerminal({ + sessionId: "session-1", + command: process.execPath, + args: ["-e", 'process.stdout.write("x".repeat(1024 * 1024))'], + cwd: process.cwd(), + outputByteLimit: 1024, + }); + await session.waitForTerminalExit({ sessionId: "session-1", terminalId: terminal.terminalId }); + + await expect( + session.terminalOutput({ sessionId: "session-1", terminalId: terminal.terminalId }), + ).resolves.toEqual({ + output: "x".repeat(1024), + truncated: true, + exitStatus: { exitCode: 0, signal: null }, + }); + }, 1000); + + test("trims terminal output at a UTF-8 character boundary", async () => { + const session = createSession(); + const terminal = await session.createTerminal({ + sessionId: "session-1", + command: process.execPath, + args: ["-e", 'process.stdout.write("😀ab")'], + cwd: process.cwd(), + outputByteLimit: 3, + }); + await session.waitForTerminalExit({ sessionId: "session-1", terminalId: terminal.terminalId }); + + await expect( + session.terminalOutput({ sessionId: "session-1", terminalId: terminal.terminalId }), + ).resolves.toEqual({ + output: "ab", + truncated: true, + exitStatus: { exitCode: 0, signal: null }, + }); + }); }); describe("mapACPUsage", () => { @@ -4424,6 +4465,161 @@ new AgentSideConnection( ); `; +describe("ACP idle runtime cleanup", () => { + test("pending permissions veto cleanup until answered", async () => { + const session = createSession(); + try { + expect(await session.canStopRuntime()).toBe(true); + const permission = session.requestPermission({ + sessionId: "session-1", + toolCall: { toolCallId: "tool-1", title: "Run command", status: "pending" }, + options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }], + }); + expect(await session.canStopRuntime()).toBe(false); + await session.respondToPermission(session.getPendingPermissions()[0]!.id, { + behavior: "allow", + }); + await permission; + expect(await session.canStopRuntime()).toBe(true); + } finally { + await session.close(); + } + }); + + test("a failed terminal spawn does not pin the runtime or discard its diagnostic", async () => { + const session = createSession(); + try { + const terminal = await session.createTerminal({ + sessionId: "session-1", + command: path.join(tmpdir(), "paseo-no-such-command", "missing"), + }); + await expect( + session.waitForTerminalExit({ sessionId: "session-1", ...terminal }), + ).rejects.toThrow("ENOENT"); + expect(await session.canStopRuntime()).toBe(true); + expect( + (await session.terminalOutput({ sessionId: "session-1", ...terminal })).output, + ).toContain("ENOENT"); + await session.releaseTerminal({ sessionId: "session-1", ...terminal }); + expect(await session.canStopRuntime()).toBe(true); + } finally { + await session.close(); + } + }); + + test("retains a quiet terminal after a finished turn, then waits a fresh idle hour after exit", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "paseo-acp-idle-")); + const agentScript = path.join(dir, "agent.mjs"); + await writeFile(agentScript, PERMISSIVE_MODEL_ACP_AGENT); + const session = new ACPAgentSession( + { provider: "test-acp", cwd: dir }, + { + provider: "test-acp", + logger: createTestLogger(), + defaultCommand: [process.execPath, agentScript], + defaultModes: [], + capabilities: { supportsStreaming: true, supportsSessionPersistence: true }, + launchEnv: { + ACP_SDK_URL: pathToFileURL( + createRequire(import.meta.url).resolve("@agentclientprotocol/sdk"), + ).href, + }, + }, + ); + const hour = 60 * 60_000; + let now = 0; + let closed = false; + const candidate = { + identity: session, + agentId: "test-agent", + parentAgentId: undefined, + provider: session.provider, + sessionId: "permissive-session", + lastActivityAt: 0, + isEligible: () => !closed, + canStopRuntime: (session as AgentSession).canStopRuntime?.bind(session), + async closeIfEligible(revalidate: () => boolean) { + if (!revalidate()) return false; + await session.close(); + closed = true; + return true; + }, + }; + const cleanup = createIdleRuntimeCleanup({ + enabled: true, + now: () => now, + runtimes: () => (closed ? [] : [candidate]), + schedule: () => () => {}, + logger: createTestLogger(), + }); + try { + await session.initializeNewSession(); + const completed = new Promise((resolve) => + session.subscribe((event) => { + if (event.type === "turn_completed") resolve(); + }), + ); + let activeTurnSafety: Promise | undefined; + session.subscribe((event) => { + if (event.type === "turn_started") activeTurnSafety = session.canStopRuntime(); + }); + await session.startTurn("Finish this foreground turn"); + await expect(activeTurnSafety).resolves.toBe(false); + await completed; + expect(await session.canStopRuntime()).toBe(true); + await session.sessionUpdate({ + sessionId: candidate.sessionId, + update: { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "buffered" }, + }, + }); + expect(await session.canStopRuntime()).toBe(false); + await session.sessionUpdate({ + sessionId: candidate.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "flush" }, + }, + }); + expect(await session.canStopRuntime()).toBe(true); + const terminal = await session.createTerminal({ + sessionId: candidate.sessionId, + command: process.execPath, + args: ["-e", "setInterval(() => {}, 1000)"], + }); + now = 2 * hour; + expect((await cleanup.sweep()).collected).toEqual([]); + expect(closed).toBe(false); + expect(await session.terminalOutput({ sessionId: candidate.sessionId, ...terminal })).toEqual( + { + output: "", + truncated: false, + exitStatus: undefined, + }, + ); + await session.killTerminal({ sessionId: candidate.sessionId, ...terminal }); + await session.waitForTerminalExit({ sessionId: candidate.sessionId, ...terminal }); + expect((await cleanup.sweep()).collected).toEqual([]); + now += hour - 1; + expect((await cleanup.sweep()).collected).toEqual([]); + now += 1; + expect((await cleanup.sweep()).collected).toEqual([ + { + agentId: candidate.agentId, + provider: candidate.provider, + sessionId: candidate.sessionId, + }, + ]); + expect(closed).toBe(true); + } finally { + await cleanup.close(); + await session.close(); + await rm(dir, { recursive: true, force: true }); + } + }); +}); + describe("custom ACP provider with configured models", () => { let dir: string; let agentScript: string; diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 823f941de1..e26b6e4ade 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -649,6 +649,7 @@ interface TerminalExit { } interface TerminalEntry { + running: boolean; id: string; child: ChildProcess; output: string; @@ -2554,6 +2555,15 @@ export class ACPAgentSession implements AgentSession, ACPClient { } } + async canStopRuntime(): Promise { + return ( + this.activeForegroundTurnId === null && + this.pendingPermissions.size === 0 && + this.pendingUserMessage === null && + !Array.from(this.terminalEntries.values()).some((terminal) => terminal.running) + ); + } + async close(): Promise { if (this.closed) { return; @@ -2589,12 +2599,14 @@ export class ACPAgentSession implements AgentSession, ACPClient { } } - const terminalTerminations = Array.from(this.terminalEntries.values(), (terminal) => - this.terminateProcess(terminal.child, { - gracefulTimeoutMs: 2_000, - forceTimeoutMs: 2_000, - }), - ); + const terminalTerminations = Array.from(this.terminalEntries.values()) + .filter((terminal) => terminal.running) + .map((terminal) => + this.terminateProcess(terminal.child, { + gracefulTimeoutMs: 2_000, + forceTimeoutMs: 2_000, + }), + ); await Promise.all(terminalTerminations); this.terminalEntries.clear(); @@ -2796,6 +2808,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { waitForExit.catch(() => undefined); const entry: TerminalEntry = { + running: true, id: terminalId, child, output: "", @@ -2816,10 +2829,13 @@ export class ACPAgentSession implements AgentSession, ACPClient { child.once("error", (error) => { const spawnError = error instanceof Error ? error : new Error(String(error)); appendTerminalOutput(entry, `${spawnError.message}\n`); + // A failed spawn has no exit event. Other process errors do not prove exit. + if (child.pid === undefined) entry.running = false; rejectExit(spawnError); }); child.once("exit", (code, signal) => { const exit = { exitCode: code, signal }; + entry.running = false; entry.exit = exit; resolveExit(exit); }); @@ -2844,7 +2860,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { async releaseTerminal(params: { sessionId: string; terminalId: string }): Promise { const entry = this.getTerminalEntry(params.terminalId); - if (!entry.exit) { + if (entry.running) { await this.terminateProcess(entry.child, { gracefulTimeoutMs: 2_000, forceTimeoutMs: 2_000 }); } this.terminalEntries.delete(params.terminalId); @@ -2852,7 +2868,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { async killTerminal(params: KillTerminalRequest): Promise> { const entry = this.getTerminalEntry(params.terminalId); - if (!entry.exit) { + if (entry.running) { await this.terminateProcess(entry.child, { gracefulTimeoutMs: 2_000, forceTimeoutMs: 2_000 }); } return {}; @@ -4077,10 +4093,16 @@ function appendTerminalOutput(entry: TerminalEntry, chunk: string): void { if (!limit) { return; } - while (Buffer.byteLength(entry.output, "utf8") > limit && entry.output.length > 0) { - entry.output = entry.output.slice(1); - entry.truncated = true; + const output = Buffer.from(entry.output, "utf8"); + if (output.length <= limit) { + return; } + let start = output.length - limit; + while (start < output.length && (output[start] & 0xc0) === 0x80) { + start += 1; + } + entry.output = output.subarray(start).toString("utf8"); + entry.truncated = true; } function readRecord(value: unknown): Record | null { diff --git a/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts b/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts index 4cab1a5005..8530e76844 100644 --- a/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts @@ -1,3 +1,5 @@ +import { ClaudeRuntimeSafety } from "./runtime-safety.js"; +import type { Query, SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -706,3 +708,137 @@ describe("background Claude subagents", () => { }, ); }); + +test("Claude runtime safety protects silent shells, monitors and pending completion notifications", () => { + const safety = new ClaudeRuntimeSafety(); + const observe = (message: unknown) => safety.observe(message as SDKMessage); + observe({ type: "system", subtype: "init", claude_code_version: "2.1.292" }); + expect(safety.canStopRuntime()).toBe(true); + observe({ + type: "system", + subtype: "background_tasks_changed", + tasks: [ + { task_id: "quiet-shell", task_type: "local_bash", description: "sleep 7200" }, + { task_id: "monitor", task_type: "local_bash", description: "watch build" }, + { task_id: "native-child", task_type: "local_agent", description: "research" }, + ], + }); + expect(safety.canStopRuntime()).toBe(false); + observe({ type: "system", subtype: "background_tasks_changed", tasks: [] }); + expect(safety.canStopRuntime()).toBe(false); + for (const task_id of ["quiet-shell", "monitor"]) + observe({ type: "system", subtype: "task_notification", task_id }); + expect(safety.canStopRuntime()).toBe(false); + observe({ type: "system", subtype: "task_notification", task_id: "native-child" }); + expect(safety.canStopRuntime()).toBe(true); + safety.reset(); + observe({ type: "system", subtype: "init", claude_code_version: "2.1.220" }); + expect(safety.canStopRuntime()).toBe(false); + observe({ type: "system", subtype: "background_tasks_changed", tasks: [] }); + expect(safety.canStopRuntime()).toBe(true); +}); + +test("Claude session runtime safety observes pumped background work and autonomous replies", async () => { + let wake: (() => void) | undefined; + const channel = { closed: false }; + const queue: Array<{ message: SDKMessage; consumed(): void }> = []; + const pump = (async function* () { + while (!channel.closed) { + const entry = queue.shift(); + if (!entry) { + await new Promise((resolve) => { + wake = resolve; + }); + continue; + } + yield entry.message; + entry.consumed(); + } + })(); + const push = (message: unknown) => + new Promise((consumed) => { + queue.push({ message: message as SDKMessage, consumed }); + wake?.(); + }); + const end = () => { + channel.closed = true; + wake?.(); + }; + let nativeInput!: AsyncIterator; + let connected!: () => void; + const connection = new Promise((resolve) => { + connected = resolve; + }); + // The SDK Query is injected through the production adapter's existing launch port. + const query = { + next: () => pump.next(), + return: async () => { + end(); + return pump.return(undefined); + }, + close: end, + interrupt: async () => {}, + setPermissionMode: async () => {}, + setModel: async () => {}, + supportedModels: async () => [], + supportedCommands: async () => [], + [Symbol.asyncIterator]() { + return this; + }, + } as unknown as Query; + const session = await new ClaudeAgentClient({ + logger: createTestLogger(), + resolveBinary: async () => "/test/claude/bin", + queryFactory: ({ prompt }) => { + if (typeof prompt === "string") throw new Error("Expected streaming input"); + nativeInput = prompt[Symbol.asyncIterator](); + connected(); + return query; + }, + }).createSession({ provider: "claude", cwd: process.cwd() }); + const init = { + type: "system", + subtype: "init", + session_id: "runtime-safety", + claude_code_version: "2.1.292", + permissionMode: "default", + }; + const result = { + type: "result", + subtype: "success", + usage: { input_tokens: 1, output_tokens: 1 }, + total_cost_usd: 0, + }; + try { + await session.startTurn("start background work"); + await connection; + const input = await nativeInput.next(); + await push({ type: "command_lifecycle", command_uuid: input.value.uuid, state: "started" }); + await push(init); + await push(result); + expect(await session.canStopRuntime!()).toBe(true); + await push({ + type: "system", + subtype: "background_tasks_changed", + tasks: [{ task_id: "monitor", task_type: "local_bash", description: "quiet monitor" }], + }); + expect(await session.canStopRuntime!()).toBe(false); + await push({ type: "system", subtype: "background_tasks_changed", tasks: [] }); + expect(await session.canStopRuntime!()).toBe(false); + await push({ + type: "system", + subtype: "task_notification", + task_id: "monitor", + status: "completed", + output: "ready", + summary: "ready", + }); + // Completion can wake Claude autonomously after the foreground turn has ended. + await push(init); + expect(await session.canStopRuntime!()).toBe(false); + await push(result); + expect(await session.canStopRuntime!()).toBe(true); + } finally { + await session.close(); + } +}); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 71a279834d..201fa349eb 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -1,3 +1,4 @@ +import { ClaudeRuntimeSafety } from "./runtime-safety.js"; import { validateProviderOptions } from "../../provider-options.js"; import type { ChildProcess } from "node:child_process"; import { randomUUID } from "node:crypto"; @@ -2255,6 +2256,8 @@ class ClaudeAgentSession implements AgentSession { private readonly queryFactory?: ClaudeQueryFactory; private readonly resolveBinary: () => Promise; private query: Query | null = null; + private readonly runtimeSafety = new ClaudeRuntimeSafety(); + private processingSdkMessages = 0; private readonly harnessEnvironment: Record; private readonly usageSessionKey = randomUUID(); private childProcess: ChildProcess | null = null; @@ -2931,6 +2934,18 @@ class ClaudeAgentSession implements AgentSession { return this.persistence; } + async canStopRuntime(): Promise { + return ( + !this.activeForegroundTurnId && + !this.autonomousTurn && + !this.mainTurnInFlight && + this.unstartedMessageUuids.size === 0 && + this.pendingPermissions.size === 0 && + this.processingSdkMessages === 0 && + this.runtimeSafety.canStopRuntime() + ); + } + async close(): Promise { this.logger.trace( { @@ -3424,6 +3439,7 @@ class ClaudeAgentSession implements AgentSession { const options = await this.buildOptions(launchMode); this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query"); this.input = input; + this.runtimeSafety.reset(); // A fresh Claude process has no turn of its own in flight. this.mainTurnInFlight = false; this.query = claudeQuery( @@ -4079,13 +4095,17 @@ class ClaudeAgentSession implements AgentSession { ); }; const handlePumpedMessage = async (message: SDKMessage): Promise => { - logRawMessage(message); - consecutiveInterruptAbortRecoveries = 0; - if (await this.handleMissingResumedConversation(message, activeQuery)) { - return true; + this.processingSdkMessages++; + try { + this.runtimeSafety.observe(message); + logRawMessage(message); + consecutiveInterruptAbortRecoveries = 0; + if (await this.handleMissingResumedConversation(message, activeQuery)) return true; + await this.routeSdkMessageFromPump(message); + return false; + } finally { + this.processingSdkMessages--; } - await this.routeSdkMessageFromPump(message); - return false; }; const drainActiveQuery = async (): Promise => { for await (const message of activeQuery) { diff --git a/packages/server/src/server/agent/providers/claude/runtime-safety.ts b/packages/server/src/server/agent/providers/claude/runtime-safety.ts new file mode 100644 index 0000000000..0c32804a57 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/runtime-safety.ts @@ -0,0 +1,42 @@ +import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; +import { parseClaudeCodeVersion } from "./model-manifest.js"; + +/** Process-local background ownership. Completion delivery can follow an empty roster. */ +export class ClaudeRuntimeSafety { + private supported = false; + private liveTasks = new Set(); + private readonly awaitingNotifications = new Set(); + + reset(): void { + this.supported = false; + this.liveTasks.clear(); + this.awaitingNotifications.clear(); + } + + observe(message: SDKMessage): void { + if (message.type !== "system") return; + if (message.subtype === "init") { + // 2.1.292 is a known roster-capable release. Older binaries can demonstrate + // support by sending a roster; a fresh process starts with an empty roster. + const version = parseClaudeCodeVersion(message.claude_code_version ?? ""); + if ( + version && + (version[0] > 2 || + (version[0] === 2 && (version[1] > 1 || (version[1] === 1 && version[2] >= 292)))) + ) + this.supported = true; + } + if (message.subtype === "background_tasks_changed") { + this.supported = true; + this.liveTasks = new Set(message.tasks.map((task) => task.task_id)); + for (const id of this.liveTasks) this.awaitingNotifications.add(id); + } + if (message.subtype === "task_notification") { + this.awaitingNotifications.delete(message.task_id); + } + } + + canStopRuntime(): boolean { + return this.supported && this.liveTasks.size === 0 && this.awaitingNotifications.size === 0; + } +} diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 8fca5f24c0..8c61635d6f 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -7167,3 +7167,35 @@ describe("Codex interactive resume startup", () => { }, ); }); + +test("Codex runtime safety queries live terminals and retains unsupported app servers", async () => { + let response: unknown = { data: [], nextCursor: null }; + const appServer = createFakeCodexAppServer({ "thread/backgroundTerminals/list": () => response }); + const session = new CodexAppServerAgentSession( + createConfig(), + null, + createTestLogger(), + async () => appServer.child, + ); + try { + await session.startTurn("hello"); + await appServer.waitForTurnStart(); + expect(await session.canStopRuntime()).toBe(false); + appServer.completeTurn({ threadId: "thread-1" }); + await expect.poll(() => session.canStopRuntime()).toBe(true); + response = { data: [{ processId: "shell-1" }], nextCursor: null }; + expect(await session.canStopRuntime()).toBe(false); + response = {}; + await expect(session.canStopRuntime()).rejects.toThrow(); + response = { __jsonRpcError: { code: -32601, message: "Method not found" } }; + await expect(session.canStopRuntime()).rejects.toThrow("Method not found"); + expect( + appServer + .requests() + .findLast((request) => request.method === "thread/backgroundTerminals/list"), + ).toMatchObject({ params: { threadId: "thread-1" } }); + appServer.assertNoErrors(); + } finally { + await session.close(); + } +}); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 62ceb14c70..f438c98ec4 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -5029,6 +5029,34 @@ export class CodexAppServerAgentSession implements AgentSession { } } + async canStopRuntime(): Promise { + if ( + !this.client || + this.client.hasPendingRequests() || + !this.currentThreadId || + this.activeForegroundTurnId || + this.currentTurnId || + this.pendingForegroundStart || + this.pendingPermissionHandlers.size || + this.getPendingPermissions().length || + this.pendingSubAgentNotificationsByThreadId.size + ) + return false; + // Older app servers reject this request. A failed or malformed probe must retain + // the process: command completion alone does not mean its terminal has exited. + const loaded = z + .object({ data: z.array(z.string()) }) + .parse(await this.client.request("thread/loaded/list", {}, 10_000)); + const threadIds = new Set([this.currentThreadId, ...loaded.data]); + for (const threadId of threadIds) { + const response = z + .object({ data: z.array(z.unknown()), nextCursor: z.string().nullable() }) + .parse(await this.client.request("thread/backgroundTerminals/list", { threadId }, 10_000)); + if (response.data.length > 0 || response.nextCursor !== null) return false; + } + return !this.client.hasPendingRequests(); + } + async close(): Promise { this.closed = true; this.clearPendingPermissions(); diff --git a/packages/server/src/server/agent/providers/codex/app-server-transport.ts b/packages/server/src/server/agent/providers/codex/app-server-transport.ts index acb5e76341..29b8cf8880 100644 --- a/packages/server/src/server/agent/providers/codex/app-server-transport.ts +++ b/packages/server/src/server/agent/providers/codex/app-server-transport.ts @@ -202,6 +202,7 @@ function createNewlineLineReader( export class CodexAppServerClient { private readonly stdoutLineReader: { close(): void }; + private activeProviderRequests = 0; private readonly pending = new Map(); private readonly requestHandlers = new Map(); private notificationHandler: NotificationHandler | null = null; @@ -255,6 +256,10 @@ export class CodexAppServerClient { this.requestHandlers.set(method, handler); } + hasPendingRequests(): boolean { + return this.pending.size > 0 || this.activeProviderRequests > 0; + } + request(method: string, params?: unknown, timeoutMs = DEFAULT_TIMEOUT_MS): Promise { if (this.disposed) { return Promise.reject(new Error("Codex app-server client is closed")); @@ -392,6 +397,7 @@ export class CodexAppServerClient { this.traceRawEvent(request); const handler = this.requestHandlers.get(request.method); try { + this.activeProviderRequests++; const result = handler ? await handler(request.params, request.id) : {}; this.writeJsonRpcResponse({ id: request.id, result }); } catch (error) { @@ -399,6 +405,8 @@ export class CodexAppServerClient { id: request.id, error: { message: error instanceof Error ? error.message : String(error) }, }); + } finally { + this.activeProviderRequests--; } return; } diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.ts index b7b301540a..7055030dc0 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.ts @@ -295,6 +295,35 @@ function parseMockQuestionPrompt(prompt: AgentPromptInput): MockQuestionPromptRe return null; } + if (/with\s+links/i.test(text)) { + return { + questions: [ + { + question: + "Sign in at https://example.com/login, then follow [the setup guide](https://example.com/setup). Done?", + header: "signIn", + options: [ + { + label: "Done", + description: "Logs are at https://example.com/logs if sign-in fails.", + }, + { label: "Not yet, see [status](https://example.com/status)" }, + ], + multiSelect: false, + }, + { + question: "Which checks should run?", + header: "checks", + options: [ + { label: "Lint", description: "Rules: https://example.com/lint" }, + { label: "Typecheck" }, + ], + multiSelect: true, + }, + ], + }; + } + if (/free[-\s]?write|freeform|text[-\s]?only/i.test(text)) { return { questions: [ diff --git a/packages/server/src/server/agent/providers/omp/agent.test.ts b/packages/server/src/server/agent/providers/omp/agent.test.ts index c9911a64b8..983f9cf55f 100644 --- a/packages/server/src/server/agent/providers/omp/agent.test.ts +++ b/packages/server/src/server/agent/providers/omp/agent.test.ts @@ -10,7 +10,7 @@ import type { OmpAgentMessage } from "./rpc-types.js"; import type { OmpNoTurnScheduler, OmpProviderIdleScheduler } from "./agent.js"; import type { OmpUsagePollScheduler } from "./usage-poller.js"; import { resolveOmpProviderOptions } from "./provider-config.js"; -import { OmpRuntimeEventSchema } from "./rpc-types.js"; +import { OmpRuntimeEventSchema, OmpSessionStateSchema } from "./rpc-types.js"; import { OmpHarness } from "./test-utils/omp-harness.js"; import { OmpAgentClient } from "./agent.js"; import { FakeOmp } from "./test-utils/fake-omp.js"; @@ -1544,3 +1544,86 @@ describe("OMP agent client and session", () => { ]); }); }); + +test("omp runtime safety protects queued replies and runtime-owned activity", async () => { + const runtime = new FakeOmp(); + const session = await new OmpAgentClient({ logger: createTestLogger(), runtime }).createSession({ + provider: "omp", + cwd: process.cwd(), + }); + onTestFinished(() => session.close()); + const native = runtime.latestSession(); + native.state.isSettled = true; + native.state.hasPendingAsyncWork = false; + expect(await session.canStopRuntime!()).toBe(true); + native.state.queuedMessageCount = 1; + expect(await session.canStopRuntime!()).toBe(false); + native.state.queuedMessageCount = 0; + native.state.isStreaming = true; + expect(await session.canStopRuntime!()).toBe(false); + native.state.isStreaming = false; + native.state.isCompacting = true; + expect(await session.canStopRuntime!()).toBe(false); + native.state.isCompacting = false; + expect(await session.canStopRuntime!()).toBe(true); + native.getStateError = new Error("Unable to prove idle"); + await expect(session.canStopRuntime!()).rejects.toThrow("Unable to prove idle"); +}); + +test("OMP runtime safety retains async work after the foreground turn finishes", async () => { + const runtime = new FakeOmp(); + const session = await new OmpAgentClient({ logger: createTestLogger(), runtime }).createSession({ + provider: "omp", + cwd: process.cwd(), + }); + onTestFinished(() => session.close()); + const native = runtime.latestSession(); + native.state.hasPendingAsyncWork = true; + native.state.isSettled = false; + const prompted = native.nextPrompt(); + const run = session.run("start a background job"); + await prompted; + native.beginTurn(); + native.acceptPrompt("start a background job", "async-user"); + native.streamAssistantText("Job started in background"); + native.finishTurn(); + await run; + expect(native.state.isStreaming).toBe(false); + expect(native.state.queuedMessageCount).toBe(0); + expect(await session.canStopRuntime!()).toBe(false); + // The delivery is drained, but an admitted/scheduled continuation still prevents settlement. + native.state.hasPendingAsyncWork = false; + expect(await session.canStopRuntime!()).toBe(false); + native.state.isSettled = true; + expect(await session.canStopRuntime!()).toBe(true); +}); + +test.each([ + { hasPendingAsyncWork: true, isSettled: true, expected: false }, + { hasPendingAsyncWork: false, isSettled: false, expected: false }, + { hasPendingAsyncWork: false, isSettled: true, expected: true }, + { hasPendingAsyncWork: undefined, isSettled: true, expected: false }, + { hasPendingAsyncWork: false, isSettled: undefined, expected: false }, + { hasPendingAsyncWork: undefined, isSettled: undefined, expected: false }, +])( + "OMP runtime safety requires explicit settlement: %j", + async ({ hasPendingAsyncWork, isSettled, expected }) => { + const runtime = new FakeOmp(); + const session = await new OmpAgentClient({ logger: createTestLogger(), runtime }).createSession( + { + provider: "omp", + cwd: process.cwd(), + }, + ); + onTestFinished(() => session.close()); + const native = runtime.latestSession(); + const response = { ...native.state }; + if (hasPendingAsyncWork !== undefined) response.hasPendingAsyncWork = hasPendingAsyncWork; + if (isSettled !== undefined) response.isSettled = isSettled; + // Legacy get_state responses stay valid; missing safety fields never imply settlement. + native.state = OmpSessionStateSchema.parse(response); + expect(native.state.hasPendingAsyncWork).toBe(hasPendingAsyncWork); + expect(native.state.isSettled).toBe(isSettled); + expect(await session.canStopRuntime!()).toBe(expected); + }, +); diff --git a/packages/server/src/server/agent/providers/omp/agent.ts b/packages/server/src/server/agent/providers/omp/agent.ts index ab82df9475..434a49d89e 100644 --- a/packages/server/src/server/agent/providers/omp/agent.ts +++ b/packages/server/src/server/agent/providers/omp/agent.ts @@ -1172,6 +1172,27 @@ export class OmpAgentSession implements AgentSession { this.activeToolCalls.clear(); } + async canStopRuntime(): Promise { + if ( + this.closed || + this.activeTurnId || + this.activeToolCalls.size || + this.pendingExtensionUiRequests.size || + this.pendingClientMessages.length || + this.pendingNoTurnOutputs.length || + this.pendingNoTurnCompletionAbort + ) + return false; + const state = await this.runtimeSession.getState(); + return ( + state.isSettled === true && + state.hasPendingAsyncWork === false && + !state.isStreaming && + !state.isCompacting && + state.queuedMessageCount === 0 + ); + } + async close(): Promise { if (this.closed) { return; diff --git a/packages/server/src/server/agent/providers/omp/rpc-types.ts b/packages/server/src/server/agent/providers/omp/rpc-types.ts index 733dedf571..cfe0b4cbd6 100644 --- a/packages/server/src/server/agent/providers/omp/rpc-types.ts +++ b/packages/server/src/server/agent/providers/omp/rpc-types.ts @@ -152,6 +152,8 @@ export const OmpSessionStateSchema = z thinkingLevel: OmpThinkingLevelSchema.optional(), isStreaming: z.boolean(), isCompacting: z.boolean(), + hasPendingAsyncWork: z.boolean().optional(), + isSettled: z.boolean().optional(), autoCompactionEnabled: z.boolean().optional(), fastModeEnabled: z.boolean().optional(), fastModeActive: z.boolean().optional(), diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index d6b6c5513a..8beed24be0 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -7010,3 +7010,21 @@ describe("OpenCode session permission rules", () => { } }); }); + +test("OpenCode v1 runtime safety retains terminals and busy sessions on its shared server", async () => { + const { parent, openCode } = await createParentSession("runtime-safety"); + try { + expect(await parent.canStopRuntime!()).toBe(true); + openCode.ptyListResponse = { data: [{ id: "shell", status: "running" }] }; + expect(await parent.canStopRuntime!()).toBe(false); + openCode.ptyListResponse = { data: [] }; + openCode.sessionStatusResponse = { data: { "other-native-session": { type: "busy" } } }; + expect(await parent.canStopRuntime!()).toBe(false); + openCode.sessionStatusResponse = { data: {} }; + expect(await parent.canStopRuntime!()).toBe(true); + openCode.ptyListResponse = { error: "Inventory unavailable" }; + await expect(parent.canStopRuntime!()).rejects.toThrow("Unable to inspect OpenCode terminals"); + } finally { + await parent.close(); + } +}); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 7e49bc50d3..81288f3e4f 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -5055,6 +5055,43 @@ class OpenCodeAgentSession implements AgentSession { }; } + async canStopRuntime(): Promise { + if ( + this.closed || + this.serverExited || + this.reconnection || + this.turnState.status !== "idle" || + this.runningToolCalls.size || + this.pendingPermissions.size || + this.pendingSteerSubmissions.length + ) + return false; + const request = { signal: AbortSignal.timeout(10_000) }; + const family = new Map([[this.sessionId, this.config.cwd]]); + const inspected = new Set(); + for (const [sessionId, directory] of family) { + if (!inspected.has(directory)) { + const statuses = await this.client.session.status({ directory }, request); + if (statuses.error || !statuses.data) + throw new Error("Unable to inspect OpenCode executions"); + if (Object.values(statuses.data).some((status) => status.type !== "idle")) return false; + const terminals = await this.client.pty.list({ directory }, request); + if (terminals.error || !terminals.data) + throw new Error("Unable to inspect OpenCode terminals"); + if (terminals.data.some((terminal) => terminal.status === "running")) return false; + inspected.add(directory); + } + const response = await this.client.session.children( + { sessionID: sessionId, directory }, + request, + ); + const children = readOpenCodeChildSessionInfosFromResponse(response); + if (!children) throw new Error("Unable to inspect OpenCode child sessions"); + for (const child of children) family.set(child.id, child.directory ?? directory); + } + return true; + } + async close(): Promise { try { this.closed = true; diff --git a/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts b/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts index 7bad72b8b1..f9c1683a48 100644 --- a/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts +++ b/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts @@ -170,6 +170,7 @@ export class TestOpenCodeClient { null; sessionPromptAsyncEvents: unknown[] = [idleEvent()]; sessionPromptAsyncResponse: OpenCodeResponse = {}; + ptyListResponse: OpenCodeResponse = { data: [] }; sessionStatusResponse: OpenCodeResponse = { data: {} }; sessionStatusImplementation: | ((parameters: unknown, options: unknown) => Promise) @@ -195,6 +196,7 @@ export class TestOpenCodeClient { asSdkClient(): OpencodeClient { return { + pty: { list: async () => this.ptyListResponse }, app: { agents: async (parameters: unknown, options: unknown) => { this.calls.appAgents.push(parameters); diff --git a/packages/server/src/server/agent/providers/opencode/test-utils/v2-harness.ts b/packages/server/src/server/agent/providers/opencode/test-utils/v2-harness.ts index e2dc4f68a6..505955f64c 100644 --- a/packages/server/src/server/agent/providers/opencode/test-utils/v2-harness.ts +++ b/packages/server/src/server/agent/providers/opencode/test-utils/v2-harness.ts @@ -90,6 +90,9 @@ export class V2Harness { readonly api: V2Api = { server: { info: unexpected }, + shell: { + list: async () => ({ location: { directory: this.info.location.directory }, data: [] }), + }, plugin: { list: async () => ({ location: { directory: this.info.location.directory }, @@ -131,6 +134,7 @@ export class V2Harness { }, permission: { list: async () => [], reply: unexpected }, session: { + inbox: { list: async () => [] }, form: { list: async () => [], reply: unexpected, cancel: unexpected }, create: async (input = {}) => { this.creates.push(input); diff --git a/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts b/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts index 26cb65885d..564e1f46c5 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts @@ -1074,3 +1074,110 @@ describe("OpenCode v2 session lifecycle", () => { } }); }); + +test("OpenCode v2 runtime safety retains shells, queued reports and shared executions", async () => { + const harness = new V2Harness(); + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + try { + expect(await session.canStopRuntime!()).toBe(true); + harness.api.shell.list = async () => ({ + location: { directory: "/tmp/project" }, + data: [ + { + id: "quiet-shell", + status: "running", + command: "sleep 7200", + cwd: "/tmp/project", + shell: "sh", + file: "/tmp/out", + metadata: {}, + time: { started: 1 }, + }, + ], + }); + expect(await session.canStopRuntime!()).toBe(false); + harness.api.shell.list = async () => ({ location: { directory: "/tmp/project" }, data: [] }); + harness.api.session.inbox.list = async () => [ + { + id: "report", + sessionID: session.id!, + type: "synthetic", + delivery: "queue", + time: { created: 1 }, + payload: { text: "Background child finished" }, + }, + ]; + expect(await session.canStopRuntime!()).toBe(false); + harness.api.session.inbox.list = async () => []; + harness.api.session.active = async () => ({ + "unrelated-autonomous-session": { type: "running" }, + }); + expect(await session.canStopRuntime!()).toBe(false); + harness.api.session.active = async () => ({}); + expect(await session.canStopRuntime!()).toBe(true); + harness.api.shell.list = async () => { + throw new Error("No shell inventory"); + }; + await expect(session.canStopRuntime!()).rejects.toThrow("No shell inventory"); + } finally { + await session.close(); + } +}); + +test("OpenCode v2 runtime safety follows descendant locations before releasing the last lease", async () => { + const harness = new V2Harness(); + const child = { + ...harness.info, + id: "child", + parentID: harness.info.id, + location: { directory: "/tmp/child-worktree" }, + }; + const locations: string[] = []; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + // A completed native child can still own a quiet shell in another location. + harness.api.session.list = async (input) => ({ + data: input.parentID === session.id ? [child] : [], + cursor: {}, + }); + let running = true; + harness.api.shell.list = async (input) => { + const directory = input?.location?.directory ?? ""; + locations.push(directory); + return { + location: { directory }, + data: + directory === child.location.directory && running + ? [ + { + id: "child-shell", + status: "running", + command: "sleep 7200", + cwd: directory, + shell: "sh", + file: "/tmp/out", + metadata: {}, + time: { started: 1 }, + }, + ] + : [], + }; + }; + try { + expect(await session.canStopRuntime!()).toBe(false); + expect(locations).toEqual(["/tmp/project", "/tmp/child-worktree"]); + running = false; + expect(await session.canStopRuntime!()).toBe(true); + await session.close(); + expect(harness.releases).toBe(1); + } finally { + await session.close(); + } +}); diff --git a/packages/server/src/server/agent/providers/opencode/v2/api.ts b/packages/server/src/server/agent/providers/opencode/v2/api.ts index fe225cade2..bec40ffbcb 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/api.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/api.ts @@ -2,6 +2,7 @@ import type { OpenCodeClient } from "@opencode/client"; export interface V2Api { server: OpenCodeClient["server"]; + shell: Pick; session: Pick< OpenCodeClient["session"], | "create" @@ -20,6 +21,7 @@ export interface V2Api { | "revert" | "log" > & { + inbox: Pick; form: Pick; }; plugin: Pick; diff --git a/packages/server/src/server/agent/providers/opencode/v2/session.ts b/packages/server/src/server/agent/providers/opencode/v2/session.ts index 5cd45b1ff7..9358a14040 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/session.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/session.ts @@ -516,6 +516,36 @@ export class OpenCodeV2Session implements AgentSession { await delay(100, undefined, { signal }).catch(() => undefined); } } + async canStopRuntime(): Promise { + if (this.closed || this.exited || this.reconnecting || this.turns.id || this.dirty) + return false; + const request = { signal: AbortSignal.timeout(10_000) }; + // Active executions are server-wide. Releasing the last lease must not stop an + // autonomous execution merely because it belongs to another session or location. + if (Object.keys(await this.client.session.active(request)).length > 0) return false; + const family = new Map([[this.info.id, this.info]]); + const directories = new Set(); + for (const info of family.values()) { + if ((await this.client.session.inbox.list({ sessionID: info.id }, request)).length) + return false; + directories.add(info.location.directory); + let cursor: string | undefined; + do { + const page = await this.client.session.list( + { ...(cursor ? { cursor } : { parentID: info.id }), limit: 100 }, + request, + ); + for (const child of page.data) family.set(child.id, child); + cursor = page.cursor.next ?? undefined; + } while (cursor); + } + for (const directory of directories) { + const shells = await this.client.shell.list({ location: { directory } }, request); + if (shells.data.some((shell) => shell.status === "running")) return false; + } + return !this.turns.id && !this.dirty; + } + async close() { if (this.closed) return; this.closed = true; diff --git a/packages/server/src/server/agent/providers/pi/agent.test.ts b/packages/server/src/server/agent/providers/pi/agent.test.ts index 927a9defd2..b9f31b61b2 100644 --- a/packages/server/src/server/agent/providers/pi/agent.test.ts +++ b/packages/server/src/server/agent/providers/pi/agent.test.ts @@ -3063,6 +3063,81 @@ describe("PiRpcAgentClient", () => { ]); }); + test.each([ + { outcome: "successful", error: null }, + { outcome: "failed", error: new Error("summarizer failed") }, + ])("waits for $outcome manual compaction before sending a new prompt", async ({ error }) => { + const { pi, session, events } = await createSession(); + onTestFinished(() => session.close()); + const fakeSession = pi.latestSession(); + fakeSession.compactError = error; + const compactionFinished = Promise.withResolvers(); + fakeSession.compactCompletion = compactionFinished.promise; + const handler = session.tryHandleOutOfBand("/compact"); + const compaction = handler!.run({ emit: () => {} }); + + const { turnId } = await session.startTurn("continue after compaction"); + await flushTurnScheduling(); + expect(fakeSession.compactRequests).toEqual([{}]); + expect(fakeSession.prompts).toEqual([]); + + compactionFinished.resolve(); + await compaction; + await flushTurnScheduling(); + expect(fakeSession.prompts).toEqual([{ message: "continue after compaction", imageCount: 0 }]); + + fakeSession.finishTurn(); + await events.nextTurnCompletion(); + expect(events.turnLifecycleEvents()).toContainEqual({ type: "turn_completed", turnId }); + expect(events.eventTypes()).not.toContain("turn_failed"); + }); + + test.each(["interrupt", "close"] as const)( + "does not send a prompt waiting on compaction after %s", + async (action) => { + const { pi, session } = await createSession(); + onTestFinished(() => session.close()); + const fakeSession = pi.latestSession(); + const compactionFinished = Promise.withResolvers(); + fakeSession.compactCompletion = compactionFinished.promise; + const handler = session.tryHandleOutOfBand("/compact"); + const compaction = handler!.run({ emit: () => {} }); + + await session.startTurn("do not send this prompt"); + await session[action](); + compactionFinished.resolve(); + await compaction; + await flushTurnScheduling(); + + expect(fakeSession.prompts).toEqual([]); + }, + ); + + test("does not send a prompt when compaction finishes during interruption", async () => { + const { pi, session, events } = await createSession(); + onTestFinished(() => session.close()); + const fakeSession = pi.latestSession(); + const compactionFinished = Promise.withResolvers(); + fakeSession.compactCompletion = compactionFinished.promise; + const clearQueueFinished = Promise.withResolvers(); + fakeSession.clearQueueCompletion = clearQueueFinished.promise; + const handler = session.tryHandleOutOfBand("/compact"); + const compaction = handler!.run({ emit: () => {} }); + const { turnId } = await session.startTurn("do not send this prompt"); + + const interruption = session.interrupt(); + compactionFinished.resolve(); + await compaction; + await flushTurnScheduling(); + expect(fakeSession.controlRequests).toEqual(["clear_queue"]); + expect(fakeSession.prompts).toEqual([]); + + clearQueueFinished.resolve(); + await interruption; + expect(events.turnLifecycleEvents()).toEqual([{ type: "turn_canceled", turnId }]); + expect(fakeSession.prompts).toEqual([]); + }); + test("closes Pi compact loading marker when RPC rejects after compaction starts", async () => { const { pi, session } = await createSession(); const fakeSession = pi.latestSession(); @@ -3488,3 +3563,23 @@ describe("transformPiModels", () => { ]); }); }); + +test("pi runtime safety protects queued replies and runtime-owned activity", async () => { + const runtime = new FakePi(); + const session = await createClient(runtime).createSession(createConfig()); + onTestFinished(() => session.close()); + const native = runtime.latestSession(); + expect(await session.canStopRuntime!()).toBe(true); + native.state.pendingMessageCount = 1; + expect(await session.canStopRuntime!()).toBe(false); + native.state.pendingMessageCount = 0; + native.state.isStreaming = true; + expect(await session.canStopRuntime!()).toBe(false); + native.state.isStreaming = false; + native.state.isCompacting = true; + expect(await session.canStopRuntime!()).toBe(false); + native.state.isCompacting = false; + expect(await session.canStopRuntime!()).toBe(true); + native.getStateError = new Error("Unable to prove idle"); + await expect(session.canStopRuntime!()).rejects.toThrow("Unable to prove idle"); +}); diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index 146b56b36f..881f8cbb29 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -1189,6 +1189,7 @@ export class PiRpcAgentSession implements AgentSession { private readonly contextUserEntries: PiCapturedEntry[] = []; private readonly treeUserEntriesById = new Map(); private readonly pendingExtensionResults = new Map(); + private compactionPromise: Promise | null = null; private outOfBandCompactionEmit: ((event: AgentStreamEvent) => void) | null = null; private outOfBandCompactionStarted = false; private outOfBandCompactionCompleted = false; @@ -1296,6 +1297,15 @@ export class PiRpcAgentSession implements AgentSession { void (async () => { try { + if (this.compactionPromise) { + // Pi RPC rejects prompts until the manual compaction request settles. + await this.compactionPromise.catch(() => undefined); + const turnCanceled = + this.closed || this.activeTurnId !== turnId || this.interruptingTurn?.turnId === turnId; + if (turnCanceled) { + return; + } + } const ack = await this.runtimeSession.prompt(payload.text, payload.images); this.activePromptRequestId = ack.requestId ?? null; const correlatedResult = ack.requestId @@ -1581,6 +1591,22 @@ export class PiRpcAgentSession implements AgentSession { return await resultPromise; } + async canStopRuntime(): Promise { + if ( + this.closed || + this.activeTurnId || + this.pendingExtensionUiRequests.size || + this.pendingExtensionResults.size || + this.pendingExtensionHydrations.size || + this.pendingSteerSubmissions.length || + this.pendingNoTurnOutputs.length || + this.interruptingTurn + ) + return false; + const state = await this.runtimeSession.getState(); + return !state.isStreaming && !state.isCompacting && state.pendingMessageCount === 0; + } + async close(): Promise { if (this.closed) { return; @@ -1788,7 +1814,8 @@ export class PiRpcAgentSession implements AgentSession { this.outOfBandCompactionStarted = false; this.outOfBandCompactionCompleted = false; try { - await this.runtimeSession.compact(customInstructions); + this.compactionPromise = this.runtimeSession.compact(customInstructions); + await this.compactionPromise; } catch (error) { const message = error instanceof Error ? error.message : String(error); if ( @@ -1814,6 +1841,7 @@ export class PiRpcAgentSession implements AgentSession { }, }); } finally { + this.compactionPromise = null; if (this.outOfBandCompactionEmit === emit && !this.outOfBandCompactionStarted) { this.outOfBandCompactionEmit = null; this.outOfBandCompactionStarted = false; diff --git a/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts b/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts index b8c7930bc4..983b86c19d 100644 --- a/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts +++ b/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts @@ -108,6 +108,7 @@ export class FakePiSession implements PiRuntimeSession { steerError: Error | null = null; readonly controlRequests: string[] = []; clearQueueError: Error | null = null; + clearQueueCompletion: Promise | null = null; readonly compactRequests: Array<{ customInstructions?: string }> = []; readonly setAutoCompactionRequests: boolean[] = []; readonly subagentSubscriptionRequests: FakePiSubagentSubscriptionLevel[] = []; @@ -140,6 +141,7 @@ export class FakePiSession implements PiRuntimeSession { subagentSubscriptionError: Error | null = null; setSessionNameError: Error | null = null; compactError: Error | null = null; + compactCompletion: Promise | null = null; emitCompactEnd = true; getStateError: Error | null = null; getSessionStatsError: Error | null = null; @@ -233,6 +235,7 @@ export class FakePiSession implements PiRuntimeSession { async clearQueue(): Promise { this.controlRequests.push("clear_queue"); + await this.clearQueueCompletion; if (this.clearQueueError) { throw this.clearQueueError; } @@ -241,6 +244,7 @@ export class FakePiSession implements PiRuntimeSession { async compact(customInstructions?: string): Promise { this.compactRequests.push(customInstructions === undefined ? {} : { customInstructions }); this.emit({ type: "compaction_start", reason: "manual" }); + await this.compactCompletion; if (this.emitCompactEnd) { this.emit({ type: "compaction_end", reason: "manual" }); } diff --git a/packages/server/src/server/agent/stt-manager.test.ts b/packages/server/src/server/agent/stt-manager.test.ts index e3f6fb7d9c..5b1c036bc7 100644 --- a/packages/server/src/server/agent/stt-manager.test.ts +++ b/packages/server/src/server/agent/stt-manager.test.ts @@ -1,9 +1,12 @@ +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { describe, expect, it } from "vitest"; import pino from "pino"; import { EventEmitter } from "node:events"; import { STTManager } from "./stt-manager.js"; -import { PersistedConfigSchema } from "../persisted-config.js"; +import { loadPersistedConfig } from "../persisted-config.js"; import { resolveSpeechConfig } from "../speech/speech-config-resolver.js"; import type { SpeechToTextProvider, @@ -97,12 +100,18 @@ class SequencedFakeStt implements SpeechToTextProvider { describe("STTManager", () => { function resolveVoiceLanguage(params: { env?: NodeJS.ProcessEnv; persisted?: unknown }): string { - const result = resolveSpeechConfig({ - paseoHome: "/tmp/paseo-home", - env: params.env ?? ({} as NodeJS.ProcessEnv), - persisted: PersistedConfigSchema.parse(params.persisted ?? {}), - }); - return result.speech.sttLanguages.voice; + const home = mkdtempSync(path.join(tmpdir(), "stt-language-")); + try { + writeFileSync(path.join(home, "config.json"), JSON.stringify(params.persisted ?? {})); + const result = resolveSpeechConfig({ + paseoHome: home, + env: params.env ?? {}, + persisted: loadPersistedConfig(home), + }); + return result.speech.sttLanguages.voice; + } finally { + rmSync(home, { recursive: true, force: true }); + } } async function transcribeWithResolvedVoiceLanguage(params: { diff --git a/packages/server/src/server/atomic-file.test.ts b/packages/server/src/server/atomic-file.test.ts new file mode 100644 index 0000000000..a521a1cc3c --- /dev/null +++ b/packages/server/src/server/atomic-file.test.ts @@ -0,0 +1,147 @@ +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, expect, it } from "vitest"; +import { writeFileAtomic, writeJsonFileAtomic } from "./atomic-file.js"; + +let directory: string; +beforeEach(async () => { + directory = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-atomic-file-")); +}); +afterEach(async () => { + await fs.rm(directory, { recursive: true, force: true }); +}); + +it.each(["EPERM", "EACCES", "EBUSY"])( + "commits a completed receipt after transient Windows %s replace failures", + async (code) => { + const file = path.join(directory, "receipt.json"); + await writeJsonFileAtomic(file, { state: "pending" }); + let failures = 3; + let elapsed = 0; + await writeJsonFileAtomic( + file, + { state: "completed" }, + { + platform: "win32", + now: () => elapsed, + sleep: async (milliseconds) => { + elapsed += milliseconds; + }, + rename: async (source, destination) => { + if (failures-- > 0) { + expect(JSON.parse(await fs.readFile(destination, "utf8"))).toEqual({ + state: "pending", + }); + expect(JSON.parse(await fs.readFile(source, "utf8"))).toEqual({ state: "completed" }); + throw Object.assign(new Error("destination held open"), { code }); + } + await fs.rename(source, destination); + }, + }, + ); + expect(JSON.parse(await fs.readFile(file, "utf8"))).toEqual({ state: "completed" }); + expect(await fs.readdir(directory)).toEqual(["receipt.json"]); + expect(elapsed).toBeGreaterThan(0); + }, +); + +it.each(["EPERM", "EACCES", "EBUSY"])( + "preserves the previous file and removes the temp file when Windows %s persists", + async (code) => { + const file = path.join(directory, "receipt.json"); + await writeJsonFileAtomic(file, { state: "pending" }); + const error = Object.assign(new Error("destination held open"), { code }); + const waits: number[] = []; + let elapsed = 0; + const attempts: number[] = []; + await expect( + writeJsonFileAtomic( + file, + { state: "completed" }, + { + platform: "win32", + now: () => elapsed, + sleep: async (milliseconds) => { + waits.push(milliseconds); + elapsed += milliseconds; + }, + rename: async () => { + attempts.push(elapsed); + throw error; + }, + }, + ), + ).rejects.toBe(error); + expect(elapsed).toBe(1000); + expect(waits.slice(0, 5)).toEqual([10, 20, 40, 80, 100]); + expect(Math.max(...waits)).toBe(100); + expect(attempts.every((time) => time < 1000)).toBe(true); + expect(JSON.parse(await fs.readFile(file, "utf8"))).toEqual({ state: "pending" }); + expect(await fs.readdir(directory)).toEqual(["receipt.json"]); + }, +); + +it.each([ + ["linux", "EPERM"], + ["darwin", "EACCES"], + ["linux", "EBUSY"], + ["win32", "ENOENT"], + ["win32", "ENOSPC"], + ["win32", "EXDEV"], +] as const)("does not retry %s %s failures", async (platform, code) => { + const file = path.join(directory, "receipt.json"); + await writeJsonFileAtomic(file, { state: "pending" }); + const error = Object.assign(new Error("rename failed"), { code }); + let attempts = 0; + const waits: number[] = []; + await expect( + writeFileAtomic(file, "replacement", { + platform, + now: () => 0, + sleep: async (milliseconds) => { + waits.push(milliseconds); + }, + rename: async () => { + attempts++; + throw error; + }, + }), + ).rejects.toBe(error); + expect(attempts).toBe(1); + expect(waits).toEqual([]); + expect(JSON.parse(await fs.readFile(file, "utf8"))).toEqual({ state: "pending" }); + expect(await fs.readdir(directory)).toEqual(["receipt.json"]); +}); + +it("does not retry after a rename consumes the Windows retry budget", async () => { + const file = path.join(directory, "receipt.json"); + const error = Object.assign(new Error("slow rename failed"), { code: "EPERM" }); + let elapsed = 0; + const waits: number[] = []; + await expect( + writeFileAtomic(file, "replacement", { + platform: "win32", + now: () => elapsed, + sleep: async (milliseconds) => { + waits.push(milliseconds); + }, + rename: async () => { + elapsed += 1000; + throw error; + }, + }), + ).rejects.toBe(error); + expect(waits).toEqual([]); + expect(await fs.readdir(directory)).toEqual([]); +}); + +it("creates parent directories and replaces binary and JSON files through the default runtime", async () => { + const file = path.join(directory, "nested", "file"); + const bytes = Buffer.from([0, 255, 128, 1]); + await writeFileAtomic(file, bytes); + expect(await fs.readFile(file)).toEqual(bytes); + await writeJsonFileAtomic(file, { state: "completed" }); + expect(await fs.readFile(file, "utf8")).toBe('{\n "state": "completed"\n}'); + expect(await fs.readdir(path.dirname(file))).toEqual(["file"]); +}); diff --git a/packages/server/src/server/atomic-file.ts b/packages/server/src/server/atomic-file.ts index cedc8d51ea..9f9c9f9568 100644 --- a/packages/server/src/server/atomic-file.ts +++ b/packages/server/src/server/atomic-file.ts @@ -1,10 +1,26 @@ import { randomUUID } from "node:crypto"; import { promises as fs } from "node:fs"; import path from "node:path"; +import { setTimeout as sleep } from "node:timers/promises"; + +interface AtomicFileRuntime { + platform: NodeJS.Platform; + rename: (source: string, destination: string) => Promise; + now: () => number; + sleep: (milliseconds: number) => Promise; +} + +const runtime: AtomicFileRuntime = { + platform: process.platform, + rename: fs.rename, + now: () => performance.now(), + sleep, +}; export async function writeFileAtomic( filePath: string, data: string | NodeJS.ArrayBufferView, + operations: AtomicFileRuntime = runtime, ): Promise { await fs.mkdir(path.dirname(filePath), { recursive: true }); const tempPath = path.join( @@ -13,13 +29,48 @@ export async function writeFileAtomic( ); try { await fs.writeFile(tempPath, data, "utf8"); - await fs.rename(tempPath, filePath); + await replaceFile(tempPath, filePath, operations); } catch (error) { await fs.rm(tempPath, { force: true }); throw error; } } -export async function writeJsonFileAtomic(filePath: string, value: unknown): Promise { - await writeFileAtomic(filePath, JSON.stringify(value, null, 2)); +async function replaceFile( + source: string, + destination: string, + operations: AtomicFileRuntime, +): Promise { + // Windows readers/scanners can briefly deny replacement. Yield with backoff, + // but keep interactive persistence failures bounded to a one-second retry budget. + const deadline = operations.now() + 1000; + let delay = 10; + for (;;) { + try { + await operations.rename(source, destination); + return; + } catch (error) { + if ( + operations.platform !== "win32" || + !(error instanceof Error) || + !("code" in error) || + !["EPERM", "EACCES", "EBUSY"].includes(String(error.code)) + ) { + throw error; + } + const remaining = deadline - operations.now(); + if (remaining <= 0) throw error; + await operations.sleep(Math.min(delay, remaining)); + if (operations.now() >= deadline) throw error; + delay = Math.min(delay * 2, 100); + } + } +} + +export async function writeJsonFileAtomic( + filePath: string, + value: unknown, + operations: AtomicFileRuntime = runtime, +): Promise { + await writeFileAtomic(filePath, JSON.stringify(value, null, 2), operations); } diff --git a/packages/server/src/server/authorization/operation-permissions.ts b/packages/server/src/server/authorization/operation-permissions.ts index 80599a74a3..f7a5b17857 100644 --- a/packages/server/src/server/authorization/operation-permissions.ts +++ b/packages/server/src/server/authorization/operation-permissions.ts @@ -182,6 +182,9 @@ const INBOUND_PERMISSION = { set_agent_model_request: "workspace.write", set_agent_thinking_request: "workspace.write", set_daemon_config_request: "daemon.manage", + "speech.local_models.list.request": "daemon.read", + "speech.local_models.download.request": "daemon.manage", + "speech.local_models.delete.request": "daemon.manage", set_voice_mode: "workspace.write", shutdown_server_request: "daemon.manage", start_workspace_script_request: "workspace.write", @@ -417,6 +420,9 @@ const OUTBOUND_PERMISSION = { set_agent_model_response: "workspace.write", set_agent_thinking_response: "workspace.write", set_daemon_config_response: "daemon.manage", + "speech.local_models.list.response": "daemon.read", + "speech.local_models.download.response": "daemon.manage", + "speech.local_models.delete.response": "daemon.manage", set_voice_mode_response: "workspace.write", start_workspace_script_response: "workspace.write", stash_list_response: "workspace.read", diff --git a/packages/server/src/server/bootstrap.test.ts b/packages/server/src/server/bootstrap.test.ts index 6f705ec559..0881e828d0 100644 --- a/packages/server/src/server/bootstrap.test.ts +++ b/packages/server/src/server/bootstrap.test.ts @@ -1,6 +1,17 @@ -import { expect, test } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, test, onTestFinished } from "vitest"; +import { MutableDaemonConfigSchema } from "@getpaseo/protocol/messages"; +import { DaemonConfigStore } from "./daemon-config-store.js"; +import { loadPersistedConfig } from "./persisted-config.js"; +import { + createIdleRuntimeCleanup, + type RuntimeCleanupCandidate, +} from "./agent/idle-runtime-cleanup/index.js"; +import { createTestLogger } from "../test-utils/test-logger.js"; -import { fanOutReconciledWorkspaceUpdates } from "./bootstrap.js"; +import { fanOutReconciledWorkspaceUpdates, connectIdleRuntimeCleanupConfig } from "./bootstrap.js"; test("reconciliation emits workspace updates when observer sync fails", async () => { const emittedWorkspaceIds: string[][] = []; @@ -54,3 +65,132 @@ test("reconciliation isolates workspace update failures between sessions", async expect(emittedWorkspaceIds).toEqual([["ws-reclassified"]]); expect(warnings).toHaveLength(1); }); + +// Exercise bootstrap's actual live-config binding with the real collector and store. + +function liveCleanupHost() { + const home = mkdtempSync(path.join(tmpdir(), "paseo-live-cleanup-")); + writeFileSync(path.join(home, "config.json"), JSON.stringify({ version: 1 })); + const store = new DaemonConfigStore( + home, + MutableDaemonConfigSchema.parse({ + relay: { enabled: true }, + mcp: { injectIntoAgents: false }, + idleRuntimeCleanup: true, + }), + undefined, + { + reloadSource: { + resolve: (persisted) => ({ + mutable: MutableDaemonConfigSchema.parse({ + relay: { enabled: true }, + mcp: { injectIntoAgents: false }, + idleRuntimeCleanup: persisted.agents?.idleRuntimeCleanup ?? true, + }), + overrideControlledPaths: [], + }), + }, + }, + ); + let minutes = 61; + let closed = false; + let protectedCaller = false; + const runtime: RuntimeCleanupCandidate = { + identity: {}, + agentId: "agent", + provider: "test", + sessionId: "session", + parentAgentId: undefined, + lastActivityAt: 0, + isEligible: () => !closed, + closeIfEligible: async (eligible) => { + if (!eligible()) return false; + closed = true; + return true; + }, + }; + const cleanup = createIdleRuntimeCleanup({ + enabled: true, + now: () => minutes * 60_000, + runtimes: () => [runtime], + isProtected: () => protectedCaller, + logger: createTestLogger(), + schedule: () => () => {}, + }); + const unsubscribe = connectIdleRuntimeCleanupConfig(store, cleanup); + onTestFinished(async () => { + unsubscribe(); + await cleanup.close(); + rmSync(home, { recursive: true, force: true }); + }); + return { + home, + store, + cleanup, + unsubscribe, + protect: (value: boolean) => { + protectedCaller = value; + }, + sweep: (at = minutes) => { + minutes = at; + return cleanup.sweep(); + }, + }; +} + +test("bootstrap's host toggle prevents collection live, persists, and leaves other hosts enabled", async () => { + const host = liveCleanupHost(); + const other = liveCleanupHost(); + host.store.patch({ idleRuntimeCleanup: false }); + expect(loadPersistedConfig(host.home).agents?.idleRuntimeCleanup).toBe(false); + expect((await host.sweep()).collected).toEqual([]); + expect((await other.sweep()).collected).toHaveLength(1); + host.store.patch({ idleRuntimeCleanup: true }); + host.protect(true); + expect((await host.sweep()).collected).toEqual([]); + host.protect(false); + expect((await host.sweep(62)).collected).toEqual([]); + expect((await host.sweep(122)).collected).toHaveLength(1); +}); + +test("bootstrap's config-file reload binding disables and enables eligible collection live", async () => { + const host = liveCleanupHost(); + for (const enabled of [false, true]) { + writeFileSync( + path.join(host.home, "config.json"), + JSON.stringify({ version: 1, agents: { idleRuntimeCleanup: enabled } }), + ); + expect(host.store.reload()).toEqual({ + appliedPaths: ["agents.idleRuntimeCleanup"], + restartRequiredPaths: [], + overrideControlledPaths: [], + }); + expect((await host.sweep()).collected).toHaveLength(enabled ? 1 : 0); + } +}); + +test.each([false, true])( + "failed config apply restores prior idle cleanup behavior (previous: %s)", + async (previous) => { + const host = liveCleanupHost(); + host.store.patch({ idleRuntimeCleanup: previous }); + const persistedBefore = loadPersistedConfig(host.home); + const refuse = host.store.onApply(() => { + throw new Error("Another live owner refused"); + }); + expect(() => host.store.patch({ idleRuntimeCleanup: !previous })).toThrow( + "Another live owner refused", + ); + expect(host.store.get().idleRuntimeCleanup).toBe(previous); + expect(loadPersistedConfig(host.home)).toEqual(persistedBefore); + expect((await host.sweep()).collected).toHaveLength(previous ? 1 : 0); + refuse(); + }, +); + +test("disposing bootstrap's binding stops configuration delivery", async () => { + const host = liveCleanupHost(); + host.unsubscribe(); + host.store.patch({ idleRuntimeCleanup: false }); + expect((await host.sweep()).collected).toHaveLength(1); +}); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 03decb5f67..2eeccfb653 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -1,3 +1,4 @@ +import { createIdleRuntimeCleanup } from "./agent/idle-runtime-cleanup/index.js"; import { createFinishSubscriptions, createFinishObservation, @@ -137,6 +138,7 @@ import type { OpenAiSpeechProviderConfig } from "./speech/providers/openai/confi import type { LocalSpeechProviderConfig } from "./speech/providers/local/config.js"; import type { RequestedSpeechProviders } from "./speech/speech-types.js"; import { createSpeechService } from "./speech/speech-runtime.js"; +import { createSpeechSettingsOwner } from "./speech/speech-settings.js"; import { AgentManager } from "./agent/agent-manager.js"; import { AgentStorage } from "./agent/agent-storage.js"; import { attachAgentStoragePersistence } from "./persistence-hooks.js"; @@ -378,6 +380,8 @@ export interface PaseoSpeechSttLanguages { export interface PaseoSpeechConfig { providers: RequestedSpeechProviders; + // Where local models live, whether or not a feature currently uses the local provider. + localModelsDir: string; sttLanguages?: PaseoSpeechSttLanguages; local?: PaseoLocalSpeechConfig; } @@ -453,6 +457,7 @@ export interface PaseoDaemonConfig { dictationFinalTimeoutMs?: number; downloadTokenTtlMs?: number; agentProviderSettings?: AgentProviderRuntimeSettingsMap; + idleRuntimeCleanup?: boolean; providerCatalogRefreshTimeoutMs?: number; metadataGeneration?: { providers?: Array<{ @@ -553,6 +558,10 @@ function createInitialRelayConfig(config: PaseoDaemonConfig): MutableDaemonConfi }; } +function resolveReloadEnv(config: PaseoDaemonConfig): NodeJS.ProcessEnv { + return config.configReload?.env ?? process.env; +} + function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDaemonConfig { const providers = config.providerOverrides ?? {}; @@ -577,6 +586,7 @@ function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDae }, autoArchiveAfterMerge: config.autoArchiveAfterMerge ?? false, enableTerminalAgentHooks: config.enableTerminalAgentHooks ?? false, + idleRuntimeCleanup: config.idleRuntimeCleanup !== false, appendSystemPrompt: config.appendSystemPrompt ?? "", pluginsEnabled: config.pluginsEnabled ?? false, plugins: config.plugins ?? {}, @@ -612,13 +622,24 @@ export async function createPaseoDaemon( const elapsed = () => `${(performance.now() - bootstrapStart).toFixed(0)}ms`; const daemonVersion = config.daemonVersion ?? resolveDaemonVersion(import.meta.url); const initialMutableConfig = createInitialMutableDaemonConfig(config); + const speechService = createSpeechService({ + logger, + openaiConfig: config.openai, + speechConfig: config.speech, + }); + logger.info({ elapsed: elapsed() }, "Speech service created"); const daemonConfigStore = new DaemonConfigStore(config.paseoHome, initialMutableConfig, logger, { relayEnabledMutable: config.relayEnabledMutable ?? true, startupPersisted: config.configReload?.startupPersisted, + speech: createSpeechSettingsOwner({ + paseoHome: config.paseoHome, + env: resolveReloadEnv(config), + speechService, + }), reloadSource: { resolve: (persisted) => { const reloaded = resolveConfigFromPersisted(config.paseoHome, persisted, { - env: config.configReload?.env ?? process.env, + env: resolveReloadEnv(config), cli: config.configReload?.cli, relayEnabledFallback: config.configReload?.relayEnabledFallback, }); @@ -1398,6 +1419,7 @@ export async function createPaseoDaemon( archiveWorkspace: archiveScheduleWorkspaceExternal, }); await scheduleService.start(); + agentManager.setAgentArchivedCallback(async (agentId) => { try { await scheduleService.completeForAgent(agentId); @@ -1445,6 +1467,20 @@ export async function createPaseoDaemon( }), }); + const idleRuntimeCleanup = createIdleRuntimeCleanup({ + enabled: daemonConfigStore.get().idleRuntimeCleanup !== false, + runtimes: () => agentManager.getRuntimeCleanupCandidates(), + isProtected: (agentId) => + finishSubscriptions.hasPendingForCaller(agentId) || + (wsServer?.agentMessages.hasPending(agentId) ?? false), + logger, + }); + + const unsubscribeIdleRuntimeCleanup = connectIdleRuntimeCleanupConfig( + daemonConfigStore, + idleRuntimeCleanup, + ); + const createAgentToolHostDependencies = ( runtime: PaseoToolRuntimeContext, ): PaseoToolHostDependencies => ({ @@ -1659,13 +1695,6 @@ export async function createPaseoDaemon( logger.info({ route: agentMcpRoute, enabled: mcpEnabled }, "Agent MCP route mounted"); } - const speechService = createSpeechService({ - logger, - openaiConfig: config.openai, - speechConfig: config.speech, - }); - logger.info({ elapsed: elapsed() }, "Speech service created"); - logger.info({ elapsed: elapsed() }, "Bootstrap complete, ready to start listening"); const start = async () => { @@ -1907,6 +1936,8 @@ export async function createPaseoDaemon( await hubRelationships.stop(); workspaceReconciliation.dispose(); scriptHealthMonitor.stop(); + unsubscribeIdleRuntimeCleanup(); + await idleRuntimeCleanup.close(); // Freeze both ingress and registration before taking the agent closure snapshot. wsServer?.prepareForShutdown(); await finishSubscriptions.close(); @@ -1997,3 +2028,11 @@ async function closeAllAgents(logger: Logger, agentManager: AgentManager): Promi }), ); } + +export function connectIdleRuntimeCleanupConfig( + store: DaemonConfigStore, + cleanup: Pick, "setEnabled">, +): () => void { + cleanup.setEnabled(store.get().idleRuntimeCleanup !== false); + return store.onFieldChange("idleRuntimeCleanup", (value) => cleanup.setEnabled(value !== false)); +} diff --git a/packages/server/src/server/config.test.ts b/packages/server/src/server/config.test.ts index a71b5621e8..e8c5f2faa0 100644 --- a/packages/server/src/server/config.test.ts +++ b/packages/server/src/server/config.test.ts @@ -27,6 +27,17 @@ describe("server config", () => { expect(standaloneConfig.desktopManaged).toBe(false); }); + test("idle runtime cleanup defaults on and supports a host-wide opt-out", async () => { + const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-idle-cleanup-")); + roots.push(paseoHome); + expect(loadConfig(paseoHome, { env: {} }).idleRuntimeCleanup).toBe(true); + await writeFile( + path.join(paseoHome, "config.json"), + JSON.stringify({ agents: { idleRuntimeCleanup: false } }), + ); + expect(loadConfig(paseoHome, { env: {} }).idleRuntimeCleanup).toBe(false); + }); + test("loads the provider catalog refresh timeout", async () => { const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-provider-timeout-")); roots.push(paseoHome); @@ -104,9 +115,13 @@ describe("server config", () => { name: "OpenAI speech providers", providers: { dictation: "openai", voiceStt: "openai", voiceTts: "openai" }, expected: [ + "features.dictation.stt.apiKey", "features.dictation.stt.confidenceThreshold", "features.dictation.stt.model", + "features.voiceMode.stt.apiKey", + "features.voiceMode.stt.confidenceThreshold", "features.voiceMode.stt.model", + "features.voiceMode.tts.apiKey", "features.voiceMode.tts.model", "features.voiceMode.tts.voice", ], @@ -115,8 +130,9 @@ describe("server config", () => { name: "mixed local and OpenAI speech providers", providers: { dictation: "local", voiceStt: "openai", voiceTts: "local" }, expected: [ - "features.dictation.stt.confidenceThreshold", "features.dictation.stt.model", + "features.voiceMode.stt.apiKey", + "features.voiceMode.stt.confidenceThreshold", "features.voiceMode.stt.model", "features.voiceMode.tts.model", ], diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index cc4eee4fec..74a8037d07 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -22,7 +22,7 @@ import { ProviderOverrideSchema } from "./agent/provider-launch-config.js"; import { AgentProviderSchema } from "@getpaseo/protocol/provider-manifest"; import { hashDaemonPassword } from "./auth.js"; import { resolveSpeechConfig } from "./speech/speech-config-resolver.js"; -import type { RequestedSpeechProviders } from "./speech/speech-types.js"; +import { readSpeechSettings } from "./speech/speech-settings.js"; import { mergeHostnames, parseHostnamesEnv, type HostnamesConfig } from "./hostnames.js"; import { resolveGitProcessPolicy } from "../utils/git-process-scheduler.js"; @@ -614,7 +614,7 @@ export function resolveConfigFromPersisted( persisted.agents?.providers as Record | undefined, ); - const overrideControlledPaths = resolveOverrideControlledPaths(env, cli, speech.providers); + const overrideControlledPaths = resolveOverrideControlledPaths(env, cli, persisted); return { listen, @@ -660,6 +660,7 @@ export function resolveConfigFromPersisted( voiceLlmProviderExplicit: voiceLlm.providerExplicit, voiceLlmModel: voiceLlm.model, agentProviderSettings: extractAgentProviderSettings(providerOverrides), + idleRuntimeCleanup: persisted.agents?.idleRuntimeCleanup ?? true, providerCatalogRefreshTimeoutMs: persisted.agents?.catalogRefreshTimeoutMs, metadataGeneration: persisted.agents?.metadataGeneration, providerOverrides, @@ -691,13 +692,13 @@ function parsePositiveGitOverride(value: string | undefined): boolean { function resolveOverrideControlledPaths( env: NodeJS.ProcessEnv, cli: CliConfigOverrides | undefined, - speechProviders: RequestedSpeechProviders, + persisted: PersistedConfig, ): string[] { return Array.from( new Set([ ...resolveDaemonOverrideControlledPaths(env, cli), ...resolveLogOverrideControlledPaths(env), - ...resolveSpeechOverrideControlledPaths(env, speechProviders), + ...resolveSpeechOverrideControlledPaths(env, persisted), ]), ).sort(); } @@ -806,67 +807,12 @@ function resolveLogOverrideControlledPaths(env: NodeJS.ProcessEnv): string[] { return paths; } -function isEnabledSpeechProvider( - provider: RequestedSpeechProviders[keyof RequestedSpeechProviders], - expected: "local" | "openai", -): boolean { - return provider.enabled !== false && provider.provider === expected; -} - function resolveSpeechOverrideControlledPaths( env: NodeJS.ProcessEnv, - providers: RequestedSpeechProviders, + persisted: PersistedConfig, ): string[] { - const paths: string[] = []; - const add = (envName: string, ...configPaths: string[]) => { - if (env[envName] !== undefined) paths.push(...configPaths); - }; - - add("PASEO_DICTATION_ENABLED", "features.dictation.enabled"); - add("PASEO_DICTATION_STT_PROVIDER", "features.dictation.stt.provider"); - if ( - env.PASEO_DICTATION_LOCAL_STT_MODEL !== undefined && - isEnabledSpeechProvider(providers.dictationStt, "local") - ) { - paths.push("features.dictation.stt.model"); - } - add("PASEO_DICTATION_LANGUAGE", "features.dictation.stt.language"); - add("PASEO_VOICE_MODE_ENABLED", "features.voiceMode.enabled"); - add("PASEO_VOICE_LLM_PROVIDER", "features.voiceMode.llm.provider"); - add("PASEO_VOICE_STT_PROVIDER", "features.voiceMode.stt.provider"); - if ( - env.PASEO_VOICE_LOCAL_STT_MODEL !== undefined && - isEnabledSpeechProvider(providers.voiceStt, "local") - ) { - paths.push("features.voiceMode.stt.model"); - } - add("PASEO_VOICE_LANGUAGE", "features.voiceMode.stt.language"); - add("PASEO_VOICE_TURN_DETECTION_PROVIDER", "features.voiceMode.turnDetection.provider"); - add("PASEO_VOICE_TTS_PROVIDER", "features.voiceMode.tts.provider"); - if ( - env.PASEO_VOICE_LOCAL_TTS_MODEL !== undefined && - isEnabledSpeechProvider(providers.voiceTts, "local") - ) { - paths.push("features.voiceMode.tts.model"); - } - add("PASEO_VOICE_LOCAL_TTS_SPEAKER_ID", "features.voiceMode.tts.speakerId"); - add("PASEO_VOICE_LOCAL_TTS_SPEED", "features.voiceMode.tts.speed"); - add("PASEO_LOCAL_MODELS_DIR", "providers.local.modelsDir"); - const openAiDictationStt = isEnabledSpeechProvider(providers.dictationStt, "openai"); - const openAiVoiceStt = isEnabledSpeechProvider(providers.voiceStt, "openai"); - if (env.STT_CONFIDENCE_THRESHOLD !== undefined && (openAiDictationStt || openAiVoiceStt)) { - paths.push("features.dictation.stt.confidenceThreshold"); - } - if (env.STT_MODEL !== undefined) { - if (openAiDictationStt) paths.push("features.dictation.stt.model"); - if (openAiVoiceStt) paths.push("features.voiceMode.stt.model"); - } - if (isEnabledSpeechProvider(providers.voiceTts, "openai")) { - add("TTS_MODEL", "features.voiceMode.tts.model"); - add("TTS_VOICE", "features.voiceMode.tts.voice"); - } - if (env.PASEO_DICTATION_LANGUAGE !== undefined && env.PASEO_VOICE_LANGUAGE === undefined) { - paths.push("features.voiceMode.stt.language"); - } + const paths = Object.keys(readSpeechSettings(persisted, env).envOverrides); + if (env.PASEO_VOICE_LLM_PROVIDER !== undefined) paths.push("features.voiceMode.llm.provider"); + if (env.PASEO_LOCAL_MODELS_DIR !== undefined) paths.push("providers.local.modelsDir"); return paths; } diff --git a/packages/server/src/server/connected-hosts/hosts.e2e.test.ts b/packages/server/src/server/connected-hosts/hosts.e2e.test.ts index 8a2d4f9c39..b6765051f6 100644 --- a/packages/server/src/server/connected-hosts/hosts.e2e.test.ts +++ b/packages/server/src/server/connected-hosts/hosts.e2e.test.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import pino from "pino"; import { createTestAgentClients } from "../test-utils/fake-agent-client.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; @@ -9,6 +10,47 @@ import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo import type { HostDirectory } from "@getpaseo/protocol/hosts"; const cleanup: (() => Promise)[] = []; + +// These asynchronous failures otherwise disappear into the fixture's silent logger. +// Only completion errors and filesystem metadata belong in CI, never prompts or credentials. +function completionLogger() { + return pino( + { + level: "warn", + serializers: { + err: (error) => ({ + type: error.name, + code: error.code, + syscall: error.syscall, + path: typeof error.path === "string" ? basename(error.path) : undefined, + dest: typeof error.dest === "string" ? basename(error.dest) : undefined, + }), + }, + }, + { + write(line) { + const record = JSON.parse(line); + if ( + record.context !== "Failed to dispatch queued agent message" && + ![ + "Agent message batch dispatch failed", + "Retrying finish subscription", + "Finish subscription failed", + ].includes(record.msg) + ) + return; + console.error("Completion diagnostic", { + time: record.time, + message: record.msg, + agentId: record.agentId, + messageIds: record.messageIds, + subscription: record.subscription, + error: record.err, + }); + }, + }, + ); +} afterEach(async () => { for (const close of cleanup.splice(0).toReversed()) await close(); }); @@ -1502,7 +1544,7 @@ test( ); test( - "remote agent reports, local CLI sends and automatic completion share the recipient batch", + "remote agent reports, local CLI sends and automatic completion reach the same recipient", { timeout: 30000 }, async () => { const parentPrompts: string[] = []; @@ -1512,6 +1554,7 @@ test( finishChild.resolve(); }); const a = await daemon({ + logger: completionLogger(), agentClients: createTestAgentClients({ onStartTurn: (prompt, config) => { if (config.title === "Batch parent") @@ -1590,7 +1633,10 @@ test( }); expect(report.isError).not.toBe(true); expect(report.structuredContent).toMatchObject({ deliveryStatus: "queued", serverId: aId }); - expect(parentPrompts).toEqual([]); + // A CLI process can take longer than the two-second quiet window to start. + // Exercise each delivery across windows; delivery.test.ts owns batching with a clock. + await expect.poll(() => parentPrompts, { timeout: 10000 }).toHaveLength(1); + expect(parentPrompts[0]).toContain("REMOTE_REPORT <ready>"); const cli = await promisify(execFile)( process.execPath, @@ -1613,7 +1659,8 @@ test( }, ); expect(JSON.parse(cli.stdout)).toMatchObject({ status: "queued" }); - expect(parentPrompts).toEqual([]); + await expect.poll(() => parentPrompts, { timeout: 10000 }).toHaveLength(2); + expect(parentPrompts[1]).toContain("LOCAL_CLI_REPORT"); const receiptStates = async () => { const directory = join(a.paseoHome, "agent-requests"); const receipts = await Promise.all( @@ -1628,14 +1675,16 @@ test( .map((receipt) => receipt.state) .sort(); }; - expect(await receiptStates()).toEqual(["pending", "pending"]); finishChild.resolve(); - await expect.poll(() => parentPrompts, { timeout: 10000 }).toHaveLength(1); - expect(parentPrompts[0]).toContain("REMOTE_REPORT <ready>"); - expect(parentPrompts[0]).toContain("LOCAL_CLI_REPORT"); - expect(parentPrompts[0]).toContain('event="finished"'); - expect(parentPrompts[0]).toContain(`source-agent-id="${child.id}"`); - await expect.poll(receiptStates, { timeout: 10000 }).toEqual(["completed", "completed"]); + await expect.poll(() => parentPrompts, { timeout: 10000 }).toHaveLength(3); + expect(parentPrompts[2]).toContain('event="finished"'); + expect(parentPrompts[2]).toContain(`source-agent-id="${child.id}"`); + await expect + .poll(receiptStates, { + timeout: 10000, + message: "Parent accepted the batch; waiting for durable receipt commits", + }) + .toEqual(["completed", "completed"]); const timeline = await origin.client.fetchAgentTimeline(parent.id); const messages = timeline.entries.flatMap((row) => row.item.type === "tool_call" && row.item.agentMessage ? [row.item.agentMessage] : [], @@ -1822,6 +1871,7 @@ test( }); const a = await daemon({ mcpEnabled: true, + logger: completionLogger(), agentClients: createTestAgentClients({ onStartTurn: (prompt) => { if (String(prompt).includes('kind="agent-notification"')) @@ -1883,8 +1933,14 @@ test( await expect.poll(() => parentPrompts, { timeout: 10000 }).toHaveLength(2); expect(parentPrompts[1]).toContain('event="finished"'); await expect - .poll(async () => - JSON.parse(await readFile(join(a.paseoHome, "finish-subscriptions.json"), "utf8")), + // Provider start precedes dispatch acknowledgment and the durable removal commit. + .poll( + async () => + JSON.parse(await readFile(join(a.paseoHome, "finish-subscriptions.json"), "utf8")), + { + timeout: 10000, + message: "Parent accepted completion; waiting for durable intent removal", + }, ) .toEqual([]); }, @@ -1957,6 +2013,7 @@ test.each(["removed", "disabled"] as const)( cleanup.push(async () => finish.resolve()); const a = await daemon({ mcpEnabled: true, + logger: completionLogger(), agentClients: createTestAgentClients({ onStartTurn: (prompt) => { if (String(prompt).includes('kind="agent-notification"')) @@ -2015,8 +2072,10 @@ test.each(["removed", "disabled"] as const)( expect(parentPrompts[0]).toContain("is unknown."); expect(parentPrompts[0]).not.toContain("finished."); await expect - .poll(async () => - JSON.parse(await readFile(join(a.paseoHome, "finish-subscriptions.json"), "utf8")), + .poll( + async () => + JSON.parse(await readFile(join(a.paseoHome, "finish-subscriptions.json"), "utf8")), + { timeout: 10000 }, ) .toEqual([]); if (availability === "removed") diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index b6518d773d..a442dfe6a9 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -1318,9 +1318,9 @@ test("emits disabled voice capability reasons on fresh daemon startup", async () expect(voice).toBeTruthy(); expect(voice?.dictation.enabled).toBe(false); - expect(voice?.dictation.reason).toBe("Dictation is disabled in daemon config."); + expect(voice?.dictation.reason).toBe("Dictation is off. Turn it on in Settings > Voice."); expect(voice?.voice.enabled).toBe(false); - expect(voice?.voice.reason).toBe("Realtime voice is disabled in daemon config."); + expect(voice?.voice.reason).toBe("Voice mode is off. Turn it on in Settings > Voice."); } finally { await client.close().catch(() => undefined); await isolatedCtx.cleanup(); diff --git a/packages/server/src/server/daemon-config-store.test.ts b/packages/server/src/server/daemon-config-store.test.ts index c68cda02d2..f4c1a02de9 100644 --- a/packages/server/src/server/daemon-config-store.test.ts +++ b/packages/server/src/server/daemon-config-store.test.ts @@ -1,10 +1,12 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, test } from "vitest"; import { DaemonConfigStore, applyMutableProviderConfigToOverrides } from "./daemon-config-store.js"; +import { loadConfig } from "./config.js"; import { loadPersistedConfig } from "./persisted-config.js"; +import { readSpeechSettings } from "./speech/speech-settings.js"; import type { PersistedConfig } from "./persisted-config.js"; import type { MutableDaemonConfig } from "@getpaseo/protocol/messages"; @@ -26,6 +28,7 @@ function reloadableConfig( metadataGeneration: { providers: agents.metadataGeneration?.providers ?? [] }, autoArchiveAfterMerge: daemon.autoArchiveAfterMerge ?? false, enableTerminalAgentHooks: daemon.enableTerminalAgentHooks ?? false, + idleRuntimeCleanup: agents.idleRuntimeCleanup !== false, appendSystemPrompt: daemon.appendSystemPrompt ?? "", terminalProfiles: daemon.terminalProfiles, agentProfiles: daemon.agentProfiles, @@ -902,6 +905,43 @@ describe("DaemonConfigStore", () => { expect(persisted.agents?.metadataGeneration).toEqual({ providers: [] }); }); + test("patch persists the host idle runtime cleanup opt-out for the next daemon start", () => { + const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); + tempDirs.push(paseoHome); + writeFileSync( + path.join(paseoHome, "config.json"), + `${JSON.stringify({ + version: 1, + agents: { metadataGeneration: { providers: [{ provider: "claude" }] } }, + })}\n`, + ); + const store = new DaemonConfigStore( + paseoHome, + { + mcp: { injectIntoAgents: false }, + browserTools: { enabled: false }, + providers: {}, + autoArchiveAfterMerge: false, + enableTerminalAgentHooks: false, + idleRuntimeCleanup: true, + appendSystemPrompt: "", + metadataGeneration: { providers: [{ provider: "claude" }] }, + }, + undefined, + ); + + expect(store.patch({ idleRuntimeCleanup: false }).idleRuntimeCleanup).toBe(false); + + expect(loadPersistedConfig(paseoHome).agents).toEqual({ + metadataGeneration: { providers: [{ provider: "claude" }] }, + idleRuntimeCleanup: false, + }); + expect(loadConfig(paseoHome, { env: {} }).idleRuntimeCleanup).toBe(false); + + store.patch({ idleRuntimeCleanup: true }); + expect(loadConfig(paseoHome, { env: {} }).idleRuntimeCleanup).toBe(true); + }); + test("patch persists custom ACP provider overrides into config.json", () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); @@ -1013,6 +1053,29 @@ describe("DaemonConfigStore reload", () => { expect(store.get().git).toEqual({ maxProcessesPerSecond: 12, maxProcessConcurrency: 3 }); }); + test("reloads idle runtime cleanup in both directions without requiring a restart", () => { + const { paseoHome, store, persisted } = createReloadableStore(); + const changes: unknown[] = []; + store.onFieldChange("idleRuntimeCleanup", (value) => changes.push(value)); + for (const enabled of [false, true]) { + writeConfig(paseoHome, { ...persisted, agents: { idleRuntimeCleanup: enabled } }); + expect(store.reload()).toEqual({ + appliedPaths: ["agents.idleRuntimeCleanup"], + restartRequiredPaths: [], + overrideControlledPaths: [], + }); + expect(store.get().idleRuntimeCleanup).toBe(enabled); + } + expect(changes).toEqual([false, true]); + store.patch({ idleRuntimeCleanup: false }); + expect(store.reload()).toEqual({ + appliedPaths: [], + restartRequiredPaths: [], + overrideControlledPaths: [], + }); + expect(store.get().idleRuntimeCleanup).toBe(false); + }); + test("applies the global plugin switch in both directions", () => { const { paseoHome, store, persisted } = createReloadableStore({ initialPersisted: { version: 1, pluginsEnabled: false }, @@ -1222,3 +1285,239 @@ describe("DaemonConfigStore reload", () => { }); }); }); + +describe("DaemonConfigStore speech settings", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs) rmSync(dir, { recursive: true, force: true }); + }); + + function createSpeechStore(initialPersisted?: unknown, env: NodeJS.ProcessEnv = {}) { + const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-speech-")); + tempDirs.push(paseoHome); + if (initialPersisted) { + writeFileSync( + path.join(paseoHome, "config.json"), + `${JSON.stringify(initialPersisted, null, 2)}\n`, + ); + } + const persisted = loadPersistedConfig(paseoHome); + const applied: PersistedConfig[] = []; + const speech = { + read: (next: PersistedConfig) => readSpeechSettings(next, env), + apply: (next: PersistedConfig) => applied.push(next), + }; + const store = new DaemonConfigStore(paseoHome, reloadableConfig(persisted), undefined, { + speech, + reloadSource: { + resolve: (next) => ({ mutable: reloadableConfig(next), overrideControlledPaths: [] }), + }, + }); + return { paseoHome, store, applied, persisted }; + } + + test("reads, patches, and clears local STT language without losing it on other saves", () => { + const { paseoHome, store } = createSpeechStore({ + features: { dictation: { stt: { language: "fr" } } }, + }); + expect(store.get().features?.dictation.stt).toEqual({ provider: "local", language: "fr" }); + store.patch({ features: { voiceMode: { enabled: true } } }); + expect(loadPersistedConfig(paseoHome).features?.dictation?.stt).toEqual({ + provider: "local", + language: "fr", + }); + const patched = store.patch({ + features: { dictation: { stt: { provider: "local", language: "de" } } }, + }); + expect(patched.features?.dictation.stt).toEqual({ provider: "local", language: "de" }); + store.patch({ features: { dictation: { stt: { language: null } } } }); + expect(loadPersistedConfig(paseoHome).features?.dictation?.stt).toEqual({ provider: "local" }); + }); + + test("reports language env overrides for both local STT sites", () => { + const { store } = createSpeechStore( + {}, + { PASEO_DICTATION_LANGUAGE: "fr", PASEO_VOICE_LANGUAGE: "de" }, + ); + expect(store.get().envOverrides).toMatchObject({ + "features.dictation.stt.language": "PASEO_DICTATION_LANGUAGE", + "features.voiceMode.stt.language": "PASEO_VOICE_LANGUAGE", + }); + }); + + test("keeps voice mode off until it is turned on", () => { + const { paseoHome, store } = createSpeechStore(); + + expect(store.get().features?.dictation.enabled).toBe(true); + expect(store.get().features?.voiceMode.enabled).toBe(false); + + const result = store.patch({ features: { voiceMode: { enabled: true } } }); + + expect(result.features?.voiceMode.enabled).toBe(true); + expect(loadPersistedConfig(paseoHome).features?.voiceMode?.enabled).toBe(true); + }); + + test("stores the OpenAI key without ever returning it", () => { + const { paseoHome, store, applied } = createSpeechStore(); + + const result = store.patch({ + features: { dictation: { stt: { provider: "openai", apiKey: " sk-test " } } }, + }); + + expect(result.features?.dictation.stt).toEqual({ provider: "openai", hasApiKey: true }); + expect(JSON.stringify(result)).not.toContain("sk-test"); + expect(loadPersistedConfig(paseoHome).features?.dictation?.stt).toMatchObject({ + apiKey: "sk-test", + }); + expect(applied).toHaveLength(1); + }); + + test("applies a replaced key even though the visible settings do not change", () => { + const { store, applied } = createSpeechStore({ + version: 1, + features: { dictation: { stt: { provider: "openai", apiKey: "sk-old" } } }, + }); + + store.patch({ features: { dictation: { stt: { apiKey: "sk-new" } } } }); + + expect(applied.map((next) => next.features?.dictation?.stt)).toEqual([ + { provider: "openai", apiKey: "sk-new" }, + ]); + }); + + test("switching provider clears the previous provider's model", () => { + const { paseoHome, store } = createSpeechStore({ + version: 1, + features: { dictation: { stt: { provider: "local", model: "parakeet-tdt-0.6b-v3-int8" } } }, + }); + + const result = store.patch({ features: { dictation: { stt: { provider: "openai" } } } }); + + expect(result.features?.dictation.stt).toEqual({ provider: "openai", hasApiKey: false }); + expect(loadPersistedConfig(paseoHome).features?.dictation?.stt).toEqual({ + provider: "openai", + }); + }); + + test("rejects a local model id outside the catalog without persisting it", () => { + const { paseoHome, store, applied } = createSpeechStore(); + + expect(() => + store.patch({ features: { voiceMode: { tts: { provider: "local", model: "nope" } } } }), + ).toThrow("Unknown local voice model: nope"); + expect(loadPersistedConfig(paseoHome).features?.voiceMode).toBeUndefined(); + expect(applied).toEqual([]); + }); + + test("reload applies hand-edited speech settings without a restart", () => { + const { paseoHome, store, applied, persisted } = createSpeechStore(); + writeFileSync( + path.join(paseoHome, "config.json"), + `${JSON.stringify({ ...persisted, features: { voiceMode: { enabled: false } } }, null, 2)}\n`, + ); + + const result = store.reload(); + + expect(result.appliedPaths).toContain("features.voiceMode"); + expect(result.restartRequiredPaths).toEqual([]); + expect(store.get().features?.voiceMode.enabled).toBe(false); + expect(applied).toHaveLength(1); + }); + test("merges same-provider fields and replaces the whole variant when provider changes", () => { + const { paseoHome, store } = createSpeechStore(); + store.patch({ + features: { + voiceMode: { + stt: { provider: "openai", apiKey: "secret", baseUrl: "https://site/v1", model: "first" }, + }, + }, + }); + store.patch({ features: { voiceMode: { stt: { model: "second", baseUrl: null } } } }); + expect(loadPersistedConfig(paseoHome).features?.voiceMode?.stt).toEqual({ + provider: "openai", + apiKey: "secret", + model: "second", + }); + store.patch({ features: { voiceMode: { stt: { provider: "openai", model: "third" } } } }); + expect(store.get().features?.voiceMode.stt).toEqual({ + provider: "openai", + model: "third", + hasApiKey: true, + }); + store.patch({ features: { voiceMode: { stt: { apiKey: "new-secret" } } } }); + store.patch({ features: { voiceMode: { stt: { provider: "local" } } } }); + store.patch({ features: { voiceMode: { stt: { provider: "openai" } } } }); + expect(store.get().features?.voiceMode.stt).toEqual({ provider: "openai", hasApiKey: false }); + }); + + test("keeps a saved OpenAI key when a full variant patch omits the write-only key", () => { + const { paseoHome, store } = createSpeechStore({ + features: { voiceMode: { stt: { provider: "openai", apiKey: "saved-secret" } } }, + }); + const result = store.patch({ + features: { voiceMode: { stt: { provider: "openai", model: "whisper-1" } } }, + }); + expect(result.features?.voiceMode.stt).toEqual({ + provider: "openai", + model: "whisper-1", + hasApiKey: true, + }); + expect(loadPersistedConfig(paseoHome).features?.voiceMode?.stt).toEqual({ + provider: "openai", + apiKey: "saved-secret", + model: "whisper-1", + }); + expect(JSON.stringify(result)).not.toContain("saved-secret"); + }); + + test("loads a legacy host, redacts its key and writes the normalized config after any save", () => { + const { paseoHome, store } = createSpeechStore({ + providers: { openai: { apiKey: "legacy-secret", stt: { baseUrl: "https://old/v1" } } }, + features: { voiceMode: { enabled: true, stt: { provider: "openai" } } }, + }); + expect(store.get().features?.voiceMode.stt).toEqual({ + provider: "openai", + baseUrl: "https://old/v1", + hasApiKey: true, + }); + expect(JSON.stringify(store.get())).not.toContain("legacy-secret"); + store.patch({ autoArchiveAfterMerge: true }); + const disk = JSON.parse(readFileSync(path.join(paseoHome, "config.json"), "utf8")); + expect(disk.providers).not.toHaveProperty("openai"); + expect(disk.features.voiceMode.stt.apiKey).toBe("legacy-secret"); + }); + + test("reports env overrides and removes fallback provenance after saving a key", () => { + const { store } = createSpeechStore( + { features: { voiceMode: { stt: { provider: "openai", model: "configured" } } } }, + { + PASEO_VOICE_STT_PROVIDER: "local", + OPENAI_API_KEY: "env-secret", + }, + ); + expect(store.get().features?.voiceMode.stt.provider).toBe("local"); + expect(store.get().envOverrides).toEqual({ + "features.voiceMode.stt.provider": "PASEO_VOICE_STT_PROVIDER", + }); + const other = createSpeechStore( + { features: { voiceMode: { stt: { provider: "openai" } } } }, + { + OPENAI_API_KEY: "env-secret", + OPENAI_STT_BASE_URL: "https://env/v1", + STT_MODEL: "override", + }, + ).store; + expect(other.get().envOverrides).toEqual({ + "features.voiceMode.stt.apiKey": "OPENAI_API_KEY", + "features.voiceMode.stt.baseUrl": "OPENAI_STT_BASE_URL", + "features.voiceMode.stt.model": "STT_MODEL", + }); + expect(other.get().features?.voiceMode.stt.hasApiKey).toBe(false); + other.patch({ + features: { voiceMode: { stt: { apiKey: "saved-secret", baseUrl: "https://saved/v1" } } }, + }); + expect(other.get().envOverrides).toEqual({ "features.voiceMode.stt.model": "STT_MODEL" }); + expect(JSON.stringify(other.get())).not.toContain("secret"); + }); +}); diff --git a/packages/server/src/server/daemon-config-store.ts b/packages/server/src/server/daemon-config-store.ts index 489f68ed5d..51957e15f6 100644 --- a/packages/server/src/server/daemon-config-store.ts +++ b/packages/server/src/server/daemon-config-store.ts @@ -9,6 +9,8 @@ import { MutableDaemonConfigPatchSchema, } from "@getpaseo/protocol/messages"; import type { AgentSkillSelection } from "@getpaseo/protocol/messages"; +import type { SpeechSettingsPatch } from "@getpaseo/protocol/speech/rpc-schemas"; +import { mergeSpeechSettingsPatch, type SpeechSettingsOwner } from "./speech/speech-settings.js"; export type { MutableDaemonConfig, MutableDaemonConfigPatch } from "@getpaseo/protocol/messages"; @@ -25,14 +27,18 @@ interface SupportedMutableConfigPatch { metadataGeneration?: MutableDaemonConfig["metadataGeneration"]; autoArchiveAfterMerge?: boolean; enableTerminalAgentHooks?: boolean; + idleRuntimeCleanup?: boolean; appendSystemPrompt?: string; terminalProfiles?: MutableDaemonConfig["terminalProfiles"]; agentProfiles?: MutableDaemonConfig["agentProfiles"]; skills?: MutableDaemonConfig["skills"]; pluginsEnabled?: boolean; plugins?: MutableDaemonConfig["plugins"]; + features?: SpeechSettingsPatch; } +const SPEECH_PERSISTED_PATHS = ["features.dictation", "features.voiceMode"]; + interface LoggerLike { child(bindings: Record): LoggerLike; info(...args: unknown[]): void; @@ -185,6 +191,7 @@ const RELOADABLE_PATHS = [ "daemon.terminalProfiles", "daemon.agentProfiles", "app.baseUrl", + "agents.idleRuntimeCleanup", "agents.providers", "agents.catalogRefreshTimeoutMs", "agents.metadataGeneration", @@ -209,6 +216,7 @@ const PERSISTED_TO_MUTABLE_PATH = new Map([ ["daemon.terminalProfiles", "terminalProfiles"], ["daemon.agentProfiles", "agentProfiles"], ["app.baseUrl", "app.baseUrl"], + ["agents.idleRuntimeCleanup", "idleRuntimeCleanup"], ["agents.providers", "providers"], ["agents.catalogRefreshTimeoutMs", "catalogRefreshTimeoutMs"], ["agents.metadataGeneration", "metadataGeneration"], @@ -283,6 +291,9 @@ function pickSupportedPatchFields(patch: MutableDaemonConfigPatch): SupportedMut ...(patch.enableTerminalAgentHooks !== undefined ? { enableTerminalAgentHooks: patch.enableTerminalAgentHooks } : {}), + ...(patch.idleRuntimeCleanup !== undefined + ? { idleRuntimeCleanup: patch.idleRuntimeCleanup } + : {}), ...(patch.appendSystemPrompt !== undefined ? { appendSystemPrompt: patch.appendSystemPrompt } : {}), @@ -290,6 +301,7 @@ function pickSupportedPatchFields(patch: MutableDaemonConfigPatch): SupportedMut ...(patch.agentProfiles !== undefined ? { agentProfiles: patch.agentProfiles } : {}), ...(patch.pluginsEnabled !== undefined ? { pluginsEnabled: patch.pluginsEnabled } : {}), ...(patch.plugins !== undefined ? { plugins: patch.plugins } : {}), + ...(patch.features !== undefined ? { features: patch.features } : {}), }; } @@ -332,6 +344,7 @@ export class DaemonConfigStore { private readonly relayEnabledMutable: boolean; private readonly reloadSource: DaemonConfigReloadSource | undefined; private readonly startupPersisted: PersistedConfig; + private readonly speechOwner: SpeechSettingsOwner | undefined; private lastKnownPersisted: PersistedConfig; constructor( @@ -342,18 +355,21 @@ export class DaemonConfigStore { relayEnabledMutable?: boolean; reloadSource?: DaemonConfigReloadSource; startupPersisted?: PersistedConfig; + speech?: SpeechSettingsOwner; } = {}, ) { this.paseoHome = paseoHome; this.logger = getLogger(logger); + this.relayEnabledMutable = options.relayEnabledMutable ?? true; + this.reloadSource = options.reloadSource; + this.speechOwner = options.speech; + this.startupPersisted = options.startupPersisted ?? loadPersistedConfig(paseoHome, this.logger); + this.lastKnownPersisted = this.startupPersisted; this.current = MutableDaemonConfigSchema.parse({ ...initial, relay: initial.relay ?? { enabled: true }, + ...this.readSpeech(this.startupPersisted), }); - this.relayEnabledMutable = options.relayEnabledMutable ?? true; - this.reloadSource = options.reloadSource; - this.startupPersisted = options.startupPersisted ?? loadPersistedConfig(paseoHome, this.logger); - this.lastKnownPersisted = this.startupPersisted; } public get(): MutableDaemonConfig { @@ -375,9 +391,11 @@ export class DaemonConfigStore { "Relay is controlled by a daemon launch override. Remove PASEO_RELAY_ENABLED or the relay CLI flag before changing it here.", ); } - const { removeProviders = [], ...configPatch } = parsedPatch; + const { removeProviders = [], ...patch } = parsedPatch; + const { features: speechPatch, ...configPatch } = patch; const removedProviders = Array.from(new Set(removeProviders)); const merged = deepMerge(this.current, configPatch); + if (speechPatch !== undefined) Object.assign(merged, this.readPatchedSpeech(speechPatch)); if (parsedPatch.skills?.selection !== undefined) { merged.skills = { selection: parsedPatch.skills.selection }; } @@ -391,30 +409,51 @@ export class DaemonConfigStore { const configChanged = !isEqualValue(this.current, next); - if (!configChanged && removedProviders.length === 0) { + // A replaced API key leaves the speech view unchanged, so a speech patch always persists. + if (!configChanged && removedProviders.length === 0 && speechPatch === undefined) { return this.current; } const { previous: persistedBeforePatch, knownNext } = this.persistConfig( - configPatch, + patch, removedProviders, ); - if (!configChanged) { - this.lastKnownPersisted = knownNext; - return this.current; - } - - try { - this.applyReplacement(next, { removedProviders }); - this.lastKnownPersisted = knownNext; - } catch (error) { - savePersistedConfig(this.paseoHome, persistedBeforePatch, this.logger); - throw error; + const speechChanged = this.speechPersistedChanged(this.lastKnownPersisted, knownNext); + if (configChanged) { + try { + this.applyReplacement(next, { removedProviders }); + } catch (error) { + savePersistedConfig(this.paseoHome, persistedBeforePatch, this.logger); + throw error; + } } + this.lastKnownPersisted = knownNext; + if (speechChanged) this.speechOwner?.apply(knownNext); return this.current; } + private readSpeech( + persisted: PersistedConfig, + ): Pick { + return this.speechOwner ? this.speechOwner.read(persisted) : {}; + } + + private readPatchedSpeech( + speechPatch: SpeechSettingsPatch, + ): Pick { + if (!this.speechOwner) { + throw new Error("Speech settings are unavailable for this daemon instance"); + } + return this.speechOwner.read(mergeSpeechSettingsPatch(this.lastKnownPersisted, speechPatch)); + } + + private speechPersistedChanged(previous: PersistedConfig, next: PersistedConfig): boolean { + return diffPaths(previous, next).some((path) => + SPEECH_PERSISTED_PATHS.some((owner) => pathBelongsTo(path, owner)), + ); + } + public reload(): DaemonConfigReloadResult { if (!this.reloadSource) { throw new Error("Daemon config reload is unavailable for this daemon instance"); @@ -427,6 +466,7 @@ export class DaemonConfigStore { const desired = MutableDaemonConfigSchema.parse({ ...resolved.mutable, plugins: this.current.plugins, + ...this.readSpeech(persisted), }); const changedSinceLastApply = diffPaths(this.lastKnownPersisted, persisted); const overrideControlledPaths = compactOwnedPaths( @@ -435,7 +475,7 @@ export class DaemonConfigStore { ), resolved.overrideControlledPaths, ); - const appliedPaths = RELOADABLE_PATHS.filter((persistedPath) => { + const appliedPaths: string[] = RELOADABLE_PATHS.filter((persistedPath) => { if (resolved.overrideControlledPaths.some((owner) => pathBelongsTo(persistedPath, owner))) { return false; } @@ -452,6 +492,8 @@ export class DaemonConfigStore { diffPaths(this.startupPersisted, persisted).filter((path) => { if (path === "$schema" || path === "version") return false; if (RELOADABLE_PATHS.some((owner) => pathBelongsTo(path, owner))) return false; + if (this.speechOwner && SPEECH_PERSISTED_PATHS.some((owner) => pathBelongsTo(path, owner))) + return false; return !resolved.overrideControlledPaths.some((owner) => pathBelongsTo(path, owner)); }), [], @@ -460,8 +502,17 @@ export class DaemonConfigStore { const removedProviders = Object.keys(this.current.providers).filter( (provider) => !(provider in desired.providers), ); + const speechChanged = this.speechPersistedChanged(this.lastKnownPersisted, persisted); this.applyReplacement(desired, { removedProviders }); this.lastKnownPersisted = persisted; + if (speechChanged && this.speechOwner) { + this.speechOwner.apply(persisted); + appliedPaths.push( + ...SPEECH_PERSISTED_PATHS.filter((owner) => + changedSinceLastApply.some((path) => pathBelongsTo(path, owner)), + ), + ); + } return { appliedPaths: [...appliedPaths].sort(), @@ -598,8 +649,11 @@ function mergeMutablePatchIntoPersistedConfig(params: { const { persisted, patch, removeProviders, persistRelayEnabled } = params; const daemon = mergeMutableDaemonPatch(persisted.daemon, patch, persistRelayEnabled); const agents = mergeMutableAgentPatch(persisted.agents, patch, removeProviders); + const withSpeech = patch.features + ? mergeSpeechSettingsPatch(persisted, patch.features) + : persisted; return { - ...persisted, + ...withSpeech, ...(patch.pluginsEnabled !== undefined ? { pluginsEnabled: patch.pluginsEnabled } : {}), ...(patch.plugins !== undefined ? { plugins: patch.plugins } : {}), ...(daemon ? { daemon } : { daemon: undefined }), @@ -616,6 +670,7 @@ function mergeMutableAgentPatch( patch.providers === undefined && patch.metadataGeneration === undefined && patch.skills === undefined && + patch.idleRuntimeCleanup === undefined && removeProviders.length === 0 ) { return persistedAgents; @@ -648,6 +703,10 @@ function mergeMutableAgentPatch( next["skills"] = { selection: patch.skills.selection }; } + if (patch.idleRuntimeCleanup !== undefined) { + next["idleRuntimeCleanup"] = patch.idleRuntimeCleanup; + } + return Object.keys(next).length > 0 ? (next as PersistedConfig["agents"]) : undefined; } diff --git a/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts index 984aa2fdcc..d2d839122e 100644 --- a/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts @@ -219,7 +219,7 @@ test( ); test( - "real Pi daemon executes manual compact out-of-band instead of prompt text", + "real Pi daemon accepts a prompt after manual compact", async () => { const cwd = tmpCwd("pi-manual-compact-"); @@ -234,20 +234,24 @@ test( cwd, title: "pi-manual-compact", provider: "pi", - model: PI_COMPACTION_TEST_MODEL, + model: PI_REAL_TEST_MODEL, }); await client.sendMessage(agent.id, "Reply exactly: compact-ready"); const finish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS); - expect(finish.status).toBe("idle"); + expect(finish.status, JSON.stringify(await fetchCanonicalTimeline(client, agent.id))).toBe( + "idle", + ); await client.sendMessage(agent.id, "/compact summarize this e2e run"); - await waitForTimelineItem( client, agent.id, (item) => item.type === "compaction" && item.status === "completed", ); + await client.sendMessage(agent.id, "Reply exactly: after-compaction"); + const followUpFinish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS); + expect(followUpFinish.status).toBe("idle"); const items = await fetchCanonicalTimeline(client, agent.id); expect(items).toEqual( @@ -263,9 +267,12 @@ test( }), ]), ); - expect( - items.some((item) => item.type === "user_message" && item.text.includes("/compact")), - ).toBe(false); + expect(items).toContainEqual( + expect.objectContaining({ + type: "assistant_message", + text: "after-compaction", + }), + ); expect( items .filter((item) => item.type === "assistant_message") diff --git a/packages/server/src/server/dictation/dictation-stream-manager.test.ts b/packages/server/src/server/dictation/dictation-stream-manager.test.ts index 975d6677b8..1d258553ca 100644 --- a/packages/server/src/server/dictation/dictation-stream-manager.test.ts +++ b/packages/server/src/server/dictation/dictation-stream-manager.test.ts @@ -1,9 +1,12 @@ +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { EventEmitter } from "node:events"; import pino from "pino"; import { DictationStreamManager } from "./dictation-stream-manager.js"; -import { PersistedConfigSchema } from "../persisted-config.js"; +import { loadPersistedConfig } from "../persisted-config.js"; import { resolveSpeechConfig } from "../speech/speech-config-resolver.js"; import type { SpeechToTextProvider, @@ -131,12 +134,18 @@ describe("DictationStreamManager (provider-agnostic provider)", () => { env?: NodeJS.ProcessEnv; persisted?: unknown; }): string { - const result = resolveSpeechConfig({ - paseoHome: "/tmp/paseo-home", - env: params.env ?? ({} as NodeJS.ProcessEnv), - persisted: PersistedConfigSchema.parse(params.persisted ?? {}), - }); - return result.speech.sttLanguages.dictation; + const home = mkdtempSync(path.join(tmpdir(), "stt-language-")); + try { + writeFileSync(path.join(home, "config.json"), JSON.stringify(params.persisted ?? {})); + const result = resolveSpeechConfig({ + paseoHome: home, + env: params.env ?? {}, + persisted: loadPersistedConfig(home), + }); + return result.speech.sttLanguages.dictation; + } finally { + rmSync(home, { recursive: true, force: true }); + } } async function startWithResolvedDictationLanguage(params: { diff --git a/packages/server/src/server/persisted-config-edit.test.ts b/packages/server/src/server/persisted-config-edit.test.ts index 4b4bf4c0db..c838601255 100644 --- a/packages/server/src/server/persisted-config-edit.test.ts +++ b/packages/server/src/server/persisted-config-edit.test.ts @@ -43,3 +43,19 @@ test("configuration edits validate before writing and preserve unrelated setting await rm(root, { recursive: true, force: true }); } }); + +test("configuration paths edit fields inside speech provider variants", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-speech-edit-")); + try { + editPersistedConfig(home, "features.voiceMode.stt", { value: { provider: "openai" } }); + editPersistedConfig(home, "features.voiceMode.stt.apiKey", { value: "site-secret" }); + const config = readPersistedConfig(home); + expect(getPersistedConfigValue(config, "features.voiceMode.stt.apiKey")).toBe("site-secret"); + editPersistedConfig(home, "features.voiceMode.stt.apiKey", { unset: true }); + expect( + getPersistedConfigValue(readPersistedConfig(home), "features.voiceMode.stt.apiKey"), + ).toBeUndefined(); + } finally { + await rm(home, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/persisted-config.test.ts b/packages/server/src/server/persisted-config.test.ts index 6b23a6f365..019bcbb0f8 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -1,3 +1,4 @@ +import { resolveSpeechConfig } from "./speech/speech-config-resolver.js"; import { chmodSync, lstatSync, @@ -149,26 +150,33 @@ describe("PersistedConfigSchema worktrees config", () => { }); describe("PersistedConfigSchema provider credentials", () => { - test("accepts separate OpenAI STT and TTS credentials", () => { + test("accepts separate inline STT and TTS credentials", () => { const parsed = PersistedConfigSchema.parse({ - providers: { - openai: { + features: { + voiceMode: { stt: { + provider: "openai", apiKey: " stt-secret ", baseUrl: " https://stt.example.com/v1 ", }, tts: { + provider: "openai", apiKey: " tts-secret ", baseUrl: " https://tts.example.com/v1 ", }, }, }, }); - - expect(parsed.providers?.openai?.stt?.apiKey).toBe("stt-secret"); - expect(parsed.providers?.openai?.stt?.baseUrl).toBe("https://stt.example.com/v1"); - expect(parsed.providers?.openai?.tts?.apiKey).toBe("tts-secret"); - expect(parsed.providers?.openai?.tts?.baseUrl).toBe("https://tts.example.com/v1"); + expect(parsed.features?.voiceMode?.stt).toEqual({ + provider: "openai", + apiKey: "stt-secret", + baseUrl: "https://stt.example.com/v1", + }); + expect(parsed.features?.voiceMode?.tts).toEqual({ + provider: "openai", + apiKey: "tts-secret", + baseUrl: "https://tts.example.com/v1", + }); }); }); @@ -660,19 +668,21 @@ describe("PersistedConfigSchema voice mode config", () => { features: { dictation: { stt: { + provider: "openai", language: " fr ", }, }, voiceMode: { stt: { + provider: "openai", language: " de ", }, }, }, }); - expect(parsed.features?.dictation?.stt?.language).toBe("fr"); - expect(parsed.features?.voiceMode?.stt?.language).toBe("de"); + expect(parsed.features?.dictation?.stt).toMatchObject({ language: "fr" }); + expect(parsed.features?.voiceMode?.stt).toMatchObject({ language: "de" }); }); test("accepts a voice and model from a self-hosted OpenAI-compatible server", () => { @@ -685,7 +695,7 @@ describe("PersistedConfigSchema voice mode config", () => { }); expect(parsed.features?.voiceMode?.tts?.model).toBe("kokoro-v1:Q8_0"); - expect(parsed.features?.voiceMode?.tts?.voice).toBe("aiden"); + expect(parsed.features?.voiceMode?.tts).toMatchObject({ voice: "aiden" }); }); }); @@ -754,10 +764,7 @@ describe("loadPersistedConfig", () => { const config = loadPersistedConfig(home); - expect(config.providers?.openai?.apiKey).toBe("global-key"); - expect((config.providers?.openai as Record)?.voice).toBeUndefined(); - expect(config.providers?.openai?.stt).toBeUndefined(); - expect(config.providers?.openai?.tts).toBeUndefined(); + expect(config.providers).not.toHaveProperty("openai"); } finally { rmSync(home, { recursive: true, force: true }); } @@ -852,11 +859,7 @@ describe.skipIf(process.platform === "win32")("persisted config file permissions const home = path.join(parent, "home"); try { savePersistedConfig(home, { - providers: { - openai: { - apiKey: "secret", - }, - }, + features: { dictation: { stt: { provider: "openai", apiKey: "secret" } } }, }); expect(modeOf(home)).toBe(PRIVATE_DIRECTORY_MODE); @@ -898,3 +901,173 @@ describe.skipIf(process.platform === "win32")( }); }, ); + +describe("speech provider config normalization", () => { + test.each([undefined, "local"])( + "preserves local STT language on load and save (provider: %s)", + (provider) => { + const home = createTempHome(); + try { + writeFileSync( + path.join(home, "config.json"), + JSON.stringify({ + features: { dictation: { stt: { provider, language: "fr" } } }, + }), + ); + const loaded = loadPersistedConfig(home); + expect(loaded.features?.dictation?.stt).toEqual({ provider: "local", language: "fr" }); + expect( + resolveSpeechConfig({ paseoHome: home, env: {}, persisted: loaded }).speech.sttLanguages + .dictation, + ).toBe("fr"); + savePersistedConfig(home, loaded); + expect(loadPersistedConfig(home).features?.dictation?.stt).toEqual({ + provider: "local", + language: "fr", + }); + expect( + JSON.parse(readFileSync(path.join(home, "config.json"), "utf8")).features.dictation.stt, + ).toEqual({ provider: "local", language: "fr" }); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }, + ); + + test("loads and resolves legacy fields ignored by each site's provider", () => { + const home = mkdtempSync(path.join(tmpdir(), "speech-unused-options-")); + try { + const legacy = { + features: { + dictation: { stt: { provider: "local", language: "fr", confidenceThreshold: -1 } }, + voiceMode: { + enabled: true, + tts: { provider: "local", voice: "alloy", speakerId: 3, speed: 1.2 }, + }, + }, + }; + writeFileSync(path.join(home, "config.json"), JSON.stringify(legacy)); + const local = loadPersistedConfig(home); + expect(local.features?.dictation?.stt).toEqual({ provider: "local", language: "fr" }); + expect(local.features?.voiceMode?.tts).toEqual({ + provider: "local", + speakerId: 3, + speed: 1.2, + }); + expect( + resolveSpeechConfig({ paseoHome: home, env: {}, persisted: local }).speech.local?.models, + ).toMatchObject({ voiceTtsSpeakerId: 3, voiceTtsSpeed: 1.2 }); + writeFileSync( + path.join(home, "config.json"), + JSON.stringify({ + features: { + voiceMode: { + enabled: true, + tts: { provider: "openai", apiKey: "key", voice: "alloy", speakerId: 3, speed: 1.2 }, + }, + }, + }), + ); + const openai = loadPersistedConfig(home); + expect(openai.features?.voiceMode?.tts).toEqual({ + provider: "openai", + apiKey: "key", + voice: "alloy", + }); + expect( + resolveSpeechConfig({ paseoHome: home, env: {}, persisted: openai }).openai?.tts, + ).toMatchObject({ apiKey: "key", voice: "alloy" }); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test("warns with the path, never the discarded legacy key", () => { + const home = mkdtempSync(path.join(tmpdir(), "speech-warning-")); + try { + writeFileSync( + path.join(home, "config.json"), + JSON.stringify({ + providers: { openai: { apiKey: "discarded-secret" } }, + features: { voiceMode: { enabled: true, tts: { provider: "local" } } }, + }), + ); + const warnings: unknown[][] = []; + const logger = { + child: () => logger, + info: () => {}, + warn: (...args: unknown[]) => { + warnings.push(args); + }, + }; + const config = loadPersistedConfig(home, logger); + expect(config.providers).not.toHaveProperty("openai"); + expect(warnings).toHaveLength(1); + expect(JSON.stringify(warnings)).toContain("providers.openai.apiKey"); + expect(JSON.stringify(warnings)).toContain("no use site in config.json selects OpenAI"); + const resolved = resolveSpeechConfig({ + paseoHome: home, + persisted: config, + env: { PASEO_VOICE_TTS_PROVIDER: "openai" }, + }); + expect(resolved.speech.providers.voiceTts.provider).toBe("openai"); + expect(resolved.openai).toBeUndefined(); + expect(JSON.stringify(warnings)).not.toContain("discarded-secret"); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test("folds legacy endpoint credentials into OpenAI sites on load and saves inline", () => { + const home = mkdtempSync(path.join(tmpdir(), "speech-normalize-")); + try { + writeFileSync( + path.join(home, "config.json"), + JSON.stringify({ + providers: { + openai: { + apiKey: "global-key", + baseUrl: "https://global/v1", + stt: { apiKey: "stt-key" }, + }, + }, + features: { + dictation: { stt: { model: "whisper-base", language: "en" } }, + voiceMode: { + enabled: true, + stt: { provider: "OPENAI", baseUrl: "https://site/v1" }, + tts: { provider: "openai" }, + }, + }, + }), + ); + const config = loadPersistedConfig(home); + expect(config.features?.dictation?.stt).toEqual({ + provider: "local", + model: "whisper-base", + language: "en", + }); + expect(config.features?.voiceMode?.stt).toEqual({ + provider: "openai", + apiKey: "stt-key", + baseUrl: "https://site/v1", + }); + expect(config.features?.voiceMode?.tts).toEqual({ + provider: "openai", + apiKey: "global-key", + baseUrl: "https://global/v1", + }); + expect(config.providers).not.toHaveProperty("openai"); + const resolved = resolveSpeechConfig({ paseoHome: home, env: {}, persisted: config }); + expect(resolved.openai?.stt?.apiKey).toBe("stt-key"); + expect(resolved.speech.providers.voiceStt).toMatchObject({ + provider: "openai", + enabled: true, + }); + savePersistedConfig(home, config); + expect(JSON.parse(readFileSync(path.join(home, "config.json"), "utf8"))).toEqual(config); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index cccc0228d8..c6c3f9c7e1 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -49,22 +49,6 @@ const LogConfigSchema = z }) .strict(); -const OpenAiSpeechEndpointSchema = z - .object({ - apiKey: z.string().trim().min(1).optional(), - baseUrl: z.string().trim().min(1).optional(), - }) - .strict(); - -const OpenAiProviderSchema = z - .object({ - apiKey: z.string().min(1).optional(), - baseUrl: z.string().trim().min(1).optional(), - stt: OpenAiSpeechEndpointSchema.optional(), - tts: OpenAiSpeechEndpointSchema.optional(), - }) - .strict(); - const LocalSpeechProviderSchema = z .object({ modelsDir: z.string().min(1).optional(), @@ -73,7 +57,6 @@ const LocalSpeechProviderSchema = z const ProvidersSchema = z .object({ - openai: OpenAiProviderSchema.optional(), local: LocalSpeechProviderSchema.optional(), }) .strict(); @@ -101,18 +84,54 @@ const SpeechProviderIdSchema = z .toLowerCase() .pipe(z.enum(["openai", "local"])); +const SpeechModelSchema = z.string().min(1).optional(); +const OpenAiConnectionFields = { + apiKey: z.string().trim().min(1).optional(), + baseUrl: z.string().trim().min(1).optional(), +}; + +export const SpeechSttConfigSchema = z.discriminatedUnion("provider", [ + z + .object({ + provider: z.literal("local"), + model: SpeechModelSchema, + language: z.string().trim().min(1).optional(), + }) + .strict(), + z + .object({ + provider: z.literal("openai"), + ...OpenAiConnectionFields, + model: SpeechModelSchema, + language: z.string().trim().min(1).optional(), + confidenceThreshold: z.number().optional(), + }) + .strict(), +]); + +export const SpeechTtsConfigSchema = z.discriminatedUnion("provider", [ + z + .object({ + provider: z.literal("local"), + model: SpeechModelSchema, + speakerId: z.number().int().optional(), + speed: z.number().optional(), + }) + .strict(), + z + .object({ + provider: z.literal("openai"), + ...OpenAiConnectionFields, + model: SpeechModelSchema, + voice: z.string().trim().min(1).optional(), + }) + .strict(), +]); + const FeatureDictationSchema = z .object({ enabled: z.boolean().optional(), - stt: z - .object({ - provider: SpeechProviderIdSchema.optional(), - model: z.string().min(1).optional(), - language: z.string().trim().min(1).optional(), - confidenceThreshold: z.number().optional(), - }) - .strict() - .optional(), + stt: SpeechSttConfigSchema.optional(), }) .strict(); @@ -126,31 +145,14 @@ const FeatureVoiceModeSchema = z }) .strict() .optional(), - stt: z - .object({ - provider: SpeechProviderIdSchema.optional(), - model: z.string().min(1).optional(), - language: z.string().trim().min(1).optional(), - }) - .strict() - .optional(), + stt: SpeechSttConfigSchema.optional(), turnDetection: z .object({ provider: SpeechProviderIdSchema.optional(), }) .strict() .optional(), - tts: z - .object({ - provider: SpeechProviderIdSchema.optional(), - model: z.string().min(1).optional(), - // Free-form: self-hosted OpenAI-compatible servers expose their own voice names. - voice: z.string().trim().min(1).optional(), - speakerId: z.number().int().optional(), - speed: z.number().optional(), - }) - .strict() - .optional(), + tts: SpeechTtsConfigSchema.optional(), }) .strict(); @@ -339,6 +341,7 @@ export const PersistedConfigSchema = z agents: z .object({ providers: z.preprocess(normalizeAgentProviders, ProviderOverridesSchema).optional(), + idleRuntimeCleanup: z.boolean().optional(), catalogRefreshTimeoutMs: z.number().int().positive().max(2_147_483_647).optional(), metadataGeneration: AgentMetadataGenerationSchema.optional(), skills: z.object({ selection: AgentSkillSelectionSchema.optional() }).strict().optional(), @@ -386,6 +389,7 @@ const DEFAULT_PERSISTED_CONFIG = PersistedConfigSchema.parse({ interface LoggerLike { child(bindings: Record): LoggerLike; info(...args: unknown[]): void; + warn?(...args: unknown[]): void; } function getConfigPath(paseoHome: string): string { @@ -396,45 +400,96 @@ function getLogger(logger: LoggerLike | undefined): LoggerLike | undefined { return logger?.child({ module: "config" }); } -// Removed config fields are stripped before parsing so the strict schema does not -// reject a config written by an older release. The stripped values are discarded, -// not migrated — there is no back-compat for the removed `providers.openai.voice` -// block (use `providers.openai.stt` / `providers.openai.tts`). -function stripRemovedConfigFields(parsed: unknown): unknown { - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return parsed; +function isConfigRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function configRecord(value: unknown): Record | undefined { + return isConfigRecord(value) ? value : undefined; +} + +// COMPAT(speechProviderConfig): added in v0.12.0, remove after 2027-04-26. +// Fold shared credentials once at the file boundary; consumers see only the strict +// per-site shape. Never include credential values in diagnostics. +function normalizeSpeechProviderConfig(parsed: unknown, logger?: LoggerLike): unknown { + if (!isConfigRecord(parsed)) return parsed; + const root = structuredClone(parsed); + const providers = configRecord(root.providers); + const legacy = configRecord(providers?.openai); + const features = configRecord(root.features); + const foldedKeys = new Set(); + for (const [feature, kind] of [ + ["dictation", "stt"], + ["voiceMode", "stt"], + ["voiceMode", "tts"], + ] as const) { + const site = configRecord(configRecord(features?.[feature])?.[kind]); + if (site) normalizeSpeechSite(site, kind, legacy, foldedKeys); } - - const root = { ...(parsed as Record) }; - const providers = root.providers; - if (!providers || typeof providers !== "object" || Array.isArray(providers)) { - return root; + if (legacy) warnDiscardedSpeechKeys(legacy, foldedKeys, logger); + if (providers) { + delete providers.openai; + const local = configRecord(providers.local); + if (local) delete local.autoDownload; } - - const providersRecord = { ...(providers as Record) }; - - const local = providersRecord.local; - if (local && typeof local === "object" && !Array.isArray(local)) { - const localRecord = { ...(local as Record) }; - delete localRecord.autoDownload; - providersRecord.local = localRecord; - } - - const openai = providersRecord.openai; - if (openai && typeof openai === "object" && !Array.isArray(openai)) { - const openaiRecord = { ...(openai as Record) }; - // COMPAT(openaiVoiceConfig): added 2026-06-30, remove after 2026-12-30. - // Drop a `providers.openai.voice` block left by an older release so the strict - // schema doesn't reject it. The value is discarded, not migrated — there is no - // back-compat; configure `providers.openai.stt` / `providers.openai.tts` instead. - delete openaiRecord.voice; - providersRecord.openai = openaiRecord; - } - - root.providers = providersRecord; return root; } +function normalizeSpeechSite( + site: Record, + kind: "stt" | "tts", + legacy: Record | undefined, + foldedKeys: Set, +) { + site.provider = + typeof site.provider === "string" + ? site.provider.trim().toLowerCase() + : (site.provider ?? "local"); + if (kind === "stt" && site.provider === "local") { + delete site.confidenceThreshold; + } + if (kind === "tts") { + if (site.provider === "local") delete site.voice; + if (site.provider === "openai") { + delete site.speakerId; + delete site.speed; + } + } + if (site.provider !== "openai" || !legacy) return; + const endpoint = configRecord(legacy[kind]); + for (const field of ["apiKey", "baseUrl"] as const) { + if (site[field] !== undefined) continue; + if (endpoint?.[field] !== undefined) { + site[field] = endpoint[field]; + foldedKeys.add(`providers.openai.${kind}.${field}`); + } else if (legacy[field] !== undefined) { + site[field] = legacy[field]; + foldedKeys.add(`providers.openai.${field}`); + } + } +} + +function warnDiscardedSpeechKeys( + legacy: Record, + foldedKeys: Set, + logger?: LoggerLike, +) { + const keys = [ + ["providers.openai.apiKey", legacy.apiKey], + ...["stt", "tts"].map((kind) => [ + `providers.openai.${kind}.apiKey`, + configRecord(legacy[kind])?.apiKey, + ]), + ] as const; + for (const [keyPath, key] of keys) { + if (key && typeof keyPath === "string" && !foldedKeys.has(keyPath)) { + const message = `Dropped ${keyPath}: no use site in config.json selects OpenAI; re-enter it when selecting OpenAI.`; + if (logger?.warn) logger.warn(message); + else console.warn(message); + } + } +} + export function loadPersistedConfig(paseoHome: string, logger?: LoggerLike): PersistedConfig { const log = getLogger(logger); const configPath = getConfigPath(paseoHome); @@ -463,7 +518,7 @@ export function loadPersistedConfig(paseoHome: string, logger?: LoggerLike): Per }); } - const config = parseConfigFile(configPath, raw); + const config = parseConfigFile(configPath, raw, log); log?.info(`Loaded from ${configPath}`); return config; } @@ -485,7 +540,7 @@ export function readPersistedConfig( return parseConfigFile(configPath, raw); } -function parseConfigFile(configPath: string, raw: string): PersistedConfig { +function parseConfigFile(configPath: string, raw: string, logger?: LoggerLike): PersistedConfig { let parsed: unknown; try { parsed = parseConfigText(raw); @@ -496,7 +551,7 @@ function parseConfigFile(configPath: string, raw: string): PersistedConfig { }); } - const result = PersistedConfigSchema.safeParse(stripRemovedConfigFields(parsed)); + const result = PersistedConfigSchema.safeParse(normalizeSpeechProviderConfig(parsed, logger)); if (!result.success) { const issues = result.error.issues .map((i) => ` - ${i.path.join(".")}: ${i.message}`) @@ -524,6 +579,12 @@ function configPathParts(field: string): string[] { schema instanceof z.ZodPipe ) schema = (schema instanceof z.ZodPipe ? schema.in : schema.unwrap()) as z.ZodType; + if (schema instanceof z.ZodUnion) { + const variant = schema.options.find( + (option) => option instanceof z.ZodObject && Object.hasOwn(option.shape, part), + ); + if (variant instanceof z.ZodObject) schema = variant; + } if (!(schema instanceof z.ZodObject) || !Object.hasOwn(schema.shape, part)) { throw new Error( `Unknown configuration path: ${field}. Replace its containing object with JSON for dynamic keys.`, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 9a2b2c0820..f573b1dd95 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -182,6 +182,10 @@ import { import { ScheduleSession } from "./session/schedule/schedule-session.js"; import { ProviderCatalogSession } from "./session/provider/provider-catalog-session.js"; import { UsageSession } from "./session/usage/usage-session.js"; +import { + SpeechModelsSession, + type SpeechModelsSessionOptions, +} from "./session/speech/speech-models-session.js"; import { WorkspaceFilesSession } from "./session/files/workspace-files-session.js"; import { AgentConfigSession } from "./session/agent-config/agent-config-session.js"; import { ProjectConfigSession } from "./session/project-config/project-config-session.js"; @@ -528,6 +532,7 @@ export interface SessionOptions { stt: Resolvable; sttLanguage?: string; tts: Resolvable; + speechModels?: SpeechModelsSessionOptions["runtime"]; terminalManager: TerminalManager | null; providerSnapshotManager: ProviderSnapshotManager; hubExecutionAgents?: HubExecutionAgents; @@ -811,6 +816,7 @@ export class Session { private readonly scheduleSession: ScheduleSession; private readonly providerCatalogSession: ProviderCatalogSession; private readonly usageSession: UsageSession; + private readonly speechModelsSession: SpeechModelsSession; private readonly workspaceFilesSession: WorkspaceFilesSession; private readonly agentConfigSession: AgentConfigSession; private readonly projectConfigSession: ProjectConfigSession; @@ -861,6 +867,7 @@ export class Session { stt, sttLanguage, tts, + speechModels, terminalManager, providerSnapshotManager, serviceProxy, @@ -1037,6 +1044,10 @@ export class Session { runtime: pluginRuntime, logger: this.sessionLogger, }); + this.speechModelsSession = new SpeechModelsSession({ + emit: (msg) => this.emit(msg), + runtime: speechModels, + }); this.agentConfigSession = new AgentConfigSession({ host: { emit: (msg) => this.emit(msg), @@ -2421,8 +2432,7 @@ export class Session { this.dispatchOrchestrationSkillsMessage(msg) ?? this.dispatchPluginDirectoryMessage(msg) ?? this.dispatchPluginMessage(msg) ?? - this.dispatchTerminalMessage(msg) ?? - this.dispatchScheduleMessage(msg) ?? + this.dispatchHostServiceMessage(msg) ?? this.dispatchMiscMessage(msg); if (promise) await promise; } @@ -2438,6 +2448,14 @@ export class Session { ); } + private dispatchHostServiceMessage(msg: SessionInboundMessage): Promise | undefined { + return ( + this.dispatchTerminalMessage(msg) ?? + this.dispatchScheduleMessage(msg) ?? + this.speechModelsSession.dispatch(msg) + ); + } + private dispatchWorkspaceLifecycleMessage(msg: SessionInboundMessage): Promise | undefined { return ( this.dispatchWorkspaceStateMessage(msg) ?? diff --git a/packages/server/src/server/session/speech/speech-models-session.test.ts b/packages/server/src/server/session/speech/speech-models-session.test.ts new file mode 100644 index 0000000000..9624475ad4 --- /dev/null +++ b/packages/server/src/server/session/speech/speech-models-session.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from "vitest"; + +import type { SessionOutboundMessage } from "../../messages.js"; +import { + listLocalSpeechModels, + type LocalSpeechModelId, +} from "../../speech/providers/local/models.js"; +import { + LocalSpeechModelInUseError, + type LocalSpeechModelStatus, +} from "../../speech/speech-runtime.js"; +import { SpeechModelsSession } from "./speech-models-session.js"; + +const [firstModel] = listLocalSpeechModels(); + +function createSession(models: LocalSpeechModelStatus[], inUse: LocalSpeechModelId[] = []) { + const emitted: SessionOutboundMessage[] = []; + const downloads: LocalSpeechModelId[] = []; + const session = new SpeechModelsSession({ + emit: (message) => emitted.push(message), + runtime: { + listLocalModels: async () => models, + downloadLocalModel: (modelId) => { + downloads.push(modelId); + }, + deleteLocalModel: async (modelId) => { + if (inUse.includes(modelId)) throw new LocalSpeechModelInUseError(modelId); + }, + }, + }); + return { session, emitted, downloads }; +} + +describe("SpeechModelsSession", () => { + test("lists catalog details and download progress before anything is installed", async () => { + const { session, emitted } = createSession([ + { + spec: firstModel, + state: { status: "downloading", receivedBytes: 10, totalBytes: 40 }, + inUse: true, + }, + ]); + + await session.dispatch({ type: "speech.local_models.list.request", requestId: "r1" }); + + expect(emitted).toEqual([ + { + type: "speech.local_models.list.response", + payload: { + requestId: "r1", + error: null, + models: [ + { + id: firstModel.id, + kind: "stt", + label: firstModel.label, + description: firstModel.description, + languages: [...firstModel.languages], + downloadSizeBytes: firstModel.downloadSizeBytes, + isDefault: true, + inUse: true, + status: "downloading", + receivedBytes: 10, + totalBytes: 40, + }, + ], + }, + }, + ]); + }); + + test("rejects a model id outside the catalog without starting a download", () => { + const { session, emitted, downloads } = createSession([]); + + void session.dispatch({ + type: "speech.local_models.download.request", + requestId: "r2", + modelId: "../../etc", + }); + + expect(downloads).toEqual([]); + expect(emitted).toEqual([ + { + type: "speech.local_models.download.response", + payload: { requestId: "r2", error: "Unknown local speech model: ../../etc" }, + }, + ]); + }); + + test("reports why an in-use model cannot be deleted", async () => { + const { session, emitted } = createSession([], [firstModel.id]); + + await session.dispatch({ + type: "speech.local_models.delete.request", + requestId: "r3", + modelId: firstModel.id, + }); + + expect(emitted).toEqual([ + { + type: "speech.local_models.delete.response", + payload: { requestId: "r3", error: new LocalSpeechModelInUseError(firstModel.id).message }, + }, + ]); + }); +}); diff --git a/packages/server/src/server/session/speech/speech-models-session.ts b/packages/server/src/server/session/speech/speech-models-session.ts new file mode 100644 index 0000000000..d987623a8d --- /dev/null +++ b/packages/server/src/server/session/speech/speech-models-session.ts @@ -0,0 +1,124 @@ +import type { LocalSpeechModel } from "@getpaseo/protocol/speech/rpc-schemas"; + +import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js"; +import { LocalSpeechModelIdSchema } from "../../speech/providers/local/models.js"; +import type { LocalSpeechModelStatus, SpeechService } from "../../speech/speech-runtime.js"; + +type SpeechModelsRuntime = Pick< + SpeechService, + "listLocalModels" | "downloadLocalModel" | "deleteLocalModel" +>; + +export interface SpeechModelsSessionOptions { + emit(message: SessionOutboundMessage): void; + // Absent when the daemon runs without speech. + runtime?: SpeechModelsRuntime | null; +} + +export class SpeechModelsSession { + constructor(private readonly options: SpeechModelsSessionOptions) {} + + // Handles the speech.local_models.* requests; returns undefined for any other message. + dispatch(msg: SessionInboundMessage): Promise | undefined { + switch (msg.type) { + case "speech.local_models.list.request": + return this.handleList(msg); + case "speech.local_models.download.request": + this.handleDownload(msg); + return Promise.resolve(); + case "speech.local_models.delete.request": + return this.handleDelete(msg); + default: + return undefined; + } + } + + private async handleList( + msg: Extract, + ): Promise { + try { + const models = await this.requireRuntime().listLocalModels(); + this.options.emit({ + type: "speech.local_models.list.response", + payload: { requestId: msg.requestId, models: models.map(toWireModel), error: null }, + }); + } catch (error) { + this.options.emit({ + type: "speech.local_models.list.response", + payload: { requestId: msg.requestId, models: [], error: describeError(error) }, + }); + } + } + + private handleDownload( + msg: Extract, + ): void { + try { + this.requireRuntime().downloadLocalModel(parseModelId(msg.modelId)); + this.options.emit({ + type: "speech.local_models.download.response", + payload: { requestId: msg.requestId, error: null }, + }); + } catch (error) { + this.options.emit({ + type: "speech.local_models.download.response", + payload: { requestId: msg.requestId, error: describeError(error) }, + }); + } + } + + private async handleDelete( + msg: Extract, + ): Promise { + try { + await this.requireRuntime().deleteLocalModel(parseModelId(msg.modelId)); + this.options.emit({ + type: "speech.local_models.delete.response", + payload: { requestId: msg.requestId, error: null }, + }); + } catch (error) { + this.options.emit({ + type: "speech.local_models.delete.response", + payload: { requestId: msg.requestId, error: describeError(error) }, + }); + } + } + + private requireRuntime(): SpeechModelsRuntime { + if (!this.options.runtime) { + throw new Error("Speech is unavailable on this daemon"); + } + return this.options.runtime; + } +} + +function parseModelId(modelId: string) { + const parsed = LocalSpeechModelIdSchema.safeParse(modelId); + if (!parsed.success) { + throw new Error(`Unknown local speech model: ${modelId}`); + } + return parsed.data; +} + +function toWireModel(model: LocalSpeechModelStatus): LocalSpeechModel { + const { spec, state, inUse } = model; + return { + id: spec.id, + kind: spec.kind === "tts" ? "tts" : "stt", + label: spec.label, + description: spec.description, + languages: [...spec.languages], + downloadSizeBytes: spec.downloadSizeBytes, + isDefault: spec.defaultFor !== undefined, + inUse, + status: state.status, + ...(state.status === "downloading" + ? { receivedBytes: state.receivedBytes, totalBytes: state.totalBytes } + : {}), + ...(state.status === "failed" ? { error: state.error } : {}), + }; +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/server/src/server/speech/providers/local/config.ts b/packages/server/src/server/speech/providers/local/config.ts index 8e7d500dc2..dd52537796 100644 --- a/packages/server/src/server/speech/providers/local/config.ts +++ b/packages/server/src/server/speech/providers/local/config.ts @@ -29,6 +29,7 @@ export interface LocalSpeechProviderConfig { export interface ResolvedLocalSpeechConfig { local: LocalSpeechProviderConfig | undefined; + modelsDir: string; sttLanguages: LocalSpeechSttLanguageConfig; } @@ -68,12 +69,12 @@ const LocalSpeechResolutionSchema = z.object({ function persistedLocalFeatureModel( provider: RequestedSpeechProviders[keyof RequestedSpeechProviders]["provider"], enabled: boolean | undefined, - model: string | undefined, + site: { provider: string; model?: string } | undefined, ): string | undefined { if (provider !== "local" || enabled === false) { return undefined; } - return model; + return site?.provider === "local" ? site.model : undefined; } function shouldIncludeLocalProviderConfig(params: { @@ -143,6 +144,8 @@ function buildLocalSpeechResolutionInput(params: { includeProviderConfig: boolean; }): Record { const { paseoHome, env, persisted, providers, includeProviderConfig } = params; + const tts = persisted.features?.voiceMode?.tts; + const localTts = tts?.provider === "local" ? tts : undefined; return { includeProviderConfig, modelsDir: firstDefinedValue([ @@ -155,7 +158,7 @@ function buildLocalSpeechResolutionInput(params: { persistedLocalFeatureModel( providers.dictationStt.provider, providers.dictationStt.enabled, - persisted.features?.dictation?.stt?.model, + persisted.features?.dictation?.stt, ), DEFAULT_LOCAL_STT_MODEL, ]), @@ -164,7 +167,7 @@ function buildLocalSpeechResolutionInput(params: { persistedLocalFeatureModel( providers.voiceStt.provider, providers.voiceStt.enabled, - persisted.features?.voiceMode?.stt?.model, + persisted.features?.voiceMode?.stt, ), DEFAULT_LOCAL_STT_MODEL, ]), @@ -173,18 +176,18 @@ function buildLocalSpeechResolutionInput(params: { persistedLocalFeatureModel( providers.voiceTts.provider, providers.voiceTts.enabled, - persisted.features?.voiceMode?.tts?.model, + persisted.features?.voiceMode?.tts, ), DEFAULT_LOCAL_TTS_MODEL, ]), ...buildLocalSpeechLanguageResolutionInput({ env, persisted }), voiceLocalTtsSpeakerId: firstDefinedValue([ env.PASEO_VOICE_LOCAL_TTS_SPEAKER_ID, - persisted.features?.voiceMode?.tts?.speakerId, + localTts?.speakerId, ]), voiceLocalTtsSpeed: firstDefinedValue([ env.PASEO_VOICE_LOCAL_TTS_SPEED, - persisted.features?.voiceMode?.tts?.speed, + localTts?.speed, ]), }; } @@ -200,11 +203,10 @@ export function resolveLocalSpeechConfig(params: { buildLocalSpeechResolutionInput({ ...params, includeProviderConfig }), ); - const resolvedVoiceTtsSpeakerId = - parsed.voiceLocalTtsSpeakerId ?? - (parsed.voiceLocalTtsModel === "kokoro-en-v0_19" ? 0 : undefined); + const resolvedVoiceTtsSpeakerId = parsed.voiceLocalTtsSpeakerId ?? 0; return { + modelsDir: parsed.modelsDir, sttLanguages: { dictation: parsed.dictationLanguage, voice: parsed.voiceLanguage, diff --git a/packages/server/src/server/speech/providers/local/model-library.ts b/packages/server/src/server/speech/providers/local/model-library.ts new file mode 100644 index 0000000000..4ef19e3fa9 --- /dev/null +++ b/packages/server/src/server/speech/providers/local/model-library.ts @@ -0,0 +1,137 @@ +import type { Logger } from "pino"; + +import { + deleteSherpaOnnxModel, + ensureSherpaOnnxModel, + isSherpaOnnxModelInstalled, +} from "./sherpa/model-downloader.js"; +import { + listLocalSpeechModels, + type LocalSpeechModelId, + type LocalSpeechModelSpec, +} from "./models.js"; + +export type LocalSpeechModelInstallState = + | { status: "installed" } + | { status: "not_installed" } + | { status: "downloading"; receivedBytes: number; totalBytes: number } + | { status: "extracting" } + | { status: "failed"; error: string }; + +export interface LocalSpeechModelEntry { + spec: LocalSpeechModelSpec; + state: LocalSpeechModelInstallState; +} + +type InFlightState = Extract< + LocalSpeechModelInstallState, + { status: "downloading" | "extracting" } +>; + +interface InFlightDownload { + promise: Promise; + state: InFlightState; +} + +export class LocalSpeechModelBusyError extends Error { + constructor(public readonly modelId: LocalSpeechModelId) { + super(`${modelId} is downloading. Wait for the download to finish before deleting it.`); + this.name = "LocalSpeechModelBusyError"; + } +} + +// Owns the models directory: which catalog models are installed, which are downloading, +// and the last failure per model. Concurrent downloads of one model share a single transfer. +export class LocalSpeechModelLibrary { + private readonly inFlight = new Map(); + private readonly failures = new Map(); + private readonly abortController = new AbortController(); + private readonly logger: Logger; + + constructor( + private readonly modelsDir: string, + logger: Logger, + ) { + this.logger = logger.child({ module: "speech", component: "local-model-library" }); + } + + getModelsDir(): string { + return this.modelsDir; + } + + async list(): Promise { + return Promise.all( + listLocalSpeechModels().map(async (spec) => ({ spec, state: await this.getState(spec.id) })), + ); + } + + async listMissing(modelIds: LocalSpeechModelId[]): Promise { + const installed = await Promise.all( + modelIds.map((modelId) => isSherpaOnnxModelInstalled(this.modelsDir, modelId)), + ); + return modelIds.filter((_modelId, index) => !installed[index]); + } + + download(modelId: LocalSpeechModelId): Promise { + const existing = this.inFlight.get(modelId); + if (existing) { + return existing.promise; + } + this.failures.delete(modelId); + const entry: InFlightDownload = { + state: { status: "downloading", receivedBytes: 0, totalBytes: 0 }, + promise: Promise.resolve(""), + }; + entry.promise = ensureSherpaOnnxModel({ + modelsDir: this.modelsDir, + modelId, + logger: this.logger, + signal: this.abortController.signal, + onProgress: (progress) => { + entry.state = + progress.phase === "downloading" + ? { + status: "downloading", + receivedBytes: progress.receivedBytes, + totalBytes: progress.totalBytes, + } + : { status: "extracting" }; + }, + }) + .catch((error: unknown) => { + this.failures.set(modelId, error instanceof Error ? error.message : String(error)); + throw error; + }) + .finally(() => { + this.inFlight.delete(modelId); + }); + this.inFlight.set(modelId, entry); + return entry.promise; + } + + async delete(modelId: LocalSpeechModelId): Promise { + if (this.inFlight.has(modelId)) { + throw new LocalSpeechModelBusyError(modelId); + } + this.failures.delete(modelId); + await deleteSherpaOnnxModel(this.modelsDir, modelId); + this.logger.info({ modelId }, "Deleted local speech model"); + } + + async stop(): Promise { + this.abortController.abort(); + await Promise.allSettled(Array.from(this.inFlight.values(), (entry) => entry.promise)); + } + + private async getState(modelId: LocalSpeechModelId): Promise { + const inFlight = this.inFlight.get(modelId); + if (inFlight) { + return inFlight.state; + } + if (await isSherpaOnnxModelInstalled(this.modelsDir, modelId)) { + return { status: "installed" }; + } + const failure = this.failures.get(modelId); + return failure ? { status: "failed", error: failure } : { status: "not_installed" }; + } +} diff --git a/packages/server/src/server/speech/providers/local/models.test.ts b/packages/server/src/server/speech/providers/local/models.test.ts index e40cc25384..be010dcae9 100644 --- a/packages/server/src/server/speech/providers/local/models.test.ts +++ b/packages/server/src/server/speech/providers/local/models.test.ts @@ -13,12 +13,8 @@ import path from "node:path"; import pino from "pino"; import { describe, expect, onTestFinished, test } from "vitest"; -import { - ensureLocalSpeechModels, - getLocalSpeechModelDir, - listLocalSpeechModels, - listMissingLocalSpeechModels, -} from "./models.js"; +import { LocalSpeechModelLibrary } from "./model-library.js"; +import { getLocalSpeechModelDir, listLocalSpeechModels } from "./models.js"; const logger = pino({ level: "silent" }); const modelId = "parakeet-tdt-0.6b-v2-int8"; @@ -55,16 +51,31 @@ describe("local speech models", () => { const encoder = path.join(modelDir, spec.requiredFiles[0]); truncateSync(encoder, 1_000); - expect(await listMissingLocalSpeechModels({ modelsDir, modelIds: [modelId] })).toEqual([ - modelId, - ]); + const library = new LocalSpeechModelLibrary(modelsDir, logger); + expect(await library.listMissing([modelId])).toEqual([modelId]); - await ensureLocalSpeechModels({ modelsDir, modelIds: [modelId], logger }); + await library.download(modelId); expect(readFileSync(encoder, "utf8")).toBe( `complete ${spec.requiredFiles[0]}`.padEnd(64_000, "."), ); expect(existsSync(archive)).toBe(false); - expect(await listMissingLocalSpeechModels({ modelsDir, modelIds: [modelId] })).toEqual([]); + expect(await library.listMissing([modelId])).toEqual([]); + }); + + test("lists install state and deletes an installed model", async () => { + const { modelsDir } = setupModelsDirWithArchive(); + const library = new LocalSpeechModelLibrary(modelsDir, logger); + const stateOf = async () => + (await library.list()).find((entry) => entry.spec.id === modelId)?.state; + + expect(await stateOf()).toEqual({ status: "not_installed" }); + await library.download(modelId); + expect(await stateOf()).toEqual({ status: "installed" }); + + await library.delete(modelId); + + expect(await stateOf()).toEqual({ status: "not_installed" }); + expect(existsSync(getLocalSpeechModelDir(modelsDir, modelId))).toBe(false); }); }); diff --git a/packages/server/src/server/speech/providers/local/models.ts b/packages/server/src/server/speech/providers/local/models.ts index a884be86d7..7d4c7f56b5 100644 --- a/packages/server/src/server/speech/providers/local/models.ts +++ b/packages/server/src/server/speech/providers/local/models.ts @@ -1,11 +1,8 @@ -import { - ensureSherpaOnnxModels, - getSherpaOnnxModelDir, - isSherpaOnnxModelInstalled, -} from "./sherpa/model-downloader.js"; +import { ensureSherpaOnnxModels, getSherpaOnnxModelDir } from "./sherpa/model-downloader.js"; import { DEFAULT_LOCAL_STT_MODEL, DEFAULT_LOCAL_TTS_MODEL, + LocalSpeechModelIdSchema, LocalSttModelIdSchema, LocalTtsModelIdSchema, listSherpaOnnxModels, @@ -17,6 +14,7 @@ import { export { DEFAULT_LOCAL_STT_MODEL, DEFAULT_LOCAL_TTS_MODEL, + LocalSpeechModelIdSchema, LocalSttModelIdSchema, LocalTtsModelIdSchema, type LocalSpeechModelId, @@ -34,16 +32,6 @@ export function getLocalSpeechModelDir(modelsDir: string, modelId: LocalSpeechMo return getSherpaOnnxModelDir(modelsDir, modelId); } -export async function listMissingLocalSpeechModels(options: { - modelsDir: string; - modelIds: LocalSpeechModelId[]; -}): Promise { - const installed = await Promise.all( - options.modelIds.map((modelId) => isSherpaOnnxModelInstalled(options.modelsDir, modelId)), - ); - return options.modelIds.filter((_modelId, index) => !installed[index]); -} - export async function ensureLocalSpeechModels(options: { modelsDir: string; modelIds: LocalSpeechModelId[]; diff --git a/packages/server/src/server/speech/providers/local/sherpa/model-catalog.ts b/packages/server/src/server/speech/providers/local/sherpa/model-catalog.ts index 06d838c49a..b142f52dd6 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/model-catalog.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/model-catalog.ts @@ -4,43 +4,467 @@ export type SherpaOnnxModelKind = "stt-offline" | "tts"; type DefaultModelRole = "stt" | "tts"; -interface SherpaOnnxCatalogEntry { - kind: SherpaOnnxModelKind; +// How sherpa-onnx loads a model, with file names relative to the extracted directory. +export type SttEngine = + | { family: "transducer"; encoder: string; decoder: string; joiner: string; tokens: string } + | { family: "whisper"; encoder: string; decoder: string; tokens: string } + | { family: "sense_voice"; model: string; tokens: string } + | { family: "dolphin"; model: string; tokens: string } + | { family: "nemo_ctc"; model: string; tokens: string }; + +export type TtsEngine = + | { + family: "kokoro"; + model: string; + voices: string; + tokens: string; + dataDir: string; + // Multi-language Kokoro models read pronunciations from these instead of espeak alone. + lexicons?: readonly string[]; + dictDir?: string; + ruleFsts?: readonly string[]; + } + | { family: "kitten"; model: string; voices: string; tokens: string; dataDir: string } + | { family: "vits"; model: string; tokens: string; dataDir: string }; + +interface SherpaOnnxCatalogEntryBase { + label: string; + // One user-facing sentence shown before download. + description: string; + // BCP 47 language tags the model transcribes or speaks. + languages: readonly string[]; + // Exact size of the release archive. The downloader rejects any other size. + downloadSizeBytes: number; archiveUrl: string; extractedDir: string; - requiredFiles: string[]; - description: string; defaultFor?: DefaultModelRole; } +type SherpaOnnxCatalogEntry = + | (SherpaOnnxCatalogEntryBase & { kind: "stt-offline"; engine: SttEngine }) + | (SherpaOnnxCatalogEntryBase & { kind: "tts"; engine: TtsEngine }); + +// Every sherpa-onnx release archive extracts to a directory named after the archive. +function releaseArchive(tag: "asr-models" | "tts-models", name: string) { + return { + archiveUrl: `https://github.com/k2-fsa/sherpa-onnx/releases/download/${tag}/${name}.tar.bz2`, + extractedDir: name, + }; +} + +const PARAKEET_INT8_ENGINE = { + family: "transducer", + encoder: "encoder.int8.onnx", + decoder: "decoder.int8.onnx", + joiner: "joiner.int8.onnx", + tokens: "tokens.txt", +} as const satisfies SttEngine; + +function whisperEngine(size: string): SttEngine { + return { + family: "whisper", + encoder: `${size}-encoder.int8.onnx`, + decoder: `${size}-decoder.int8.onnx`, + tokens: `${size}-tokens.txt`, + }; +} + +function piperEngine(voice: string): TtsEngine { + return { + family: "vits", + model: `${voice}.onnx`, + tokens: "tokens.txt", + dataDir: "espeak-ng-data", + }; +} + +// Parakeet TDT v3 transcribes these 25 European languages and detects which one is spoken. +const PARAKEET_V3_LANGUAGES = [ + "bg", + "hr", + "cs", + "da", + "nl", + "en", + "et", + "fi", + "fr", + "de", + "el", + "hu", + "it", + "lv", + "lt", + "mt", + "pl", + "pt", + "ro", + "sk", + "sl", + "es", + "sv", + "ru", + "uk", +] as const; + +// The 99 languages multilingual Whisper models transcribe, detecting which one is spoken. +const WHISPER_LANGUAGES = [ + "en", + "zh", + "de", + "es", + "ru", + "ko", + "fr", + "ja", + "pt", + "tr", + "pl", + "ca", + "nl", + "ar", + "sv", + "it", + "id", + "hi", + "fi", + "vi", + "he", + "uk", + "el", + "ms", + "cs", + "ro", + "da", + "hu", + "ta", + "no", + "th", + "ur", + "hr", + "bg", + "lt", + "la", + "mi", + "ml", + "cy", + "sk", + "te", + "fa", + "lv", + "bn", + "sr", + "az", + "sl", + "kn", + "et", + "mk", + "br", + "eu", + "is", + "hy", + "ne", + "mn", + "bs", + "kk", + "sq", + "sw", + "gl", + "mr", + "pa", + "si", + "km", + "sn", + "yo", + "so", + "af", + "oc", + "ka", + "be", + "tg", + "sd", + "gu", + "am", + "yi", + "lo", + "uz", + "fo", + "ht", + "ps", + "tk", + "nn", + "mt", + "sa", + "lb", + "my", + "bo", + "tl", + "mg", + "as", + "tt", + "haw", + "ln", + "ha", + "ba", + "jv", + "su", +] as const; + +// Dolphin's 40 Eastern languages. It also covers 22 Chinese dialects under "zh". +const DOLPHIN_LANGUAGES = [ + "zh", + "ja", + "th", + "ru", + "ko", + "id", + "vi", + "yue", + "hi", + "ur", + "ms", + "uz", + "ar", + "fa", + "bn", + "ta", + "te", + "ug", + "gu", + "my", + "tl", + "kk", + "or", + "ne", + "mn", + "km", + "jv", + "lo", + "si", + "fil", + "ps", + "pa", + "kab", + "ba", + "ks", + "tg", + "su", + "mr", + "ky", + "az", +] as const; + export const SHERPA_ONNX_MODEL_CATALOG = { "parakeet-tdt-0.6b-v2-int8": { kind: "stt-offline", - archiveUrl: - "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8.tar.bz2", - extractedDir: "sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8", - requiredFiles: ["encoder.int8.onnx", "decoder.int8.onnx", "joiner.int8.onnx", "tokens.txt"], - description: "NVIDIA Parakeet TDT v2 (offline NeMo transducer, English).", + label: "Parakeet v2", + description: "Accurate English transcription that runs well on a laptop CPU.", + languages: ["en"], + downloadSizeBytes: 482_468_385, + ...releaseArchive("asr-models", "sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8"), + engine: PARAKEET_INT8_ENGINE, defaultFor: "stt", }, + "parakeet-tdt-110m-en-int8": { + kind: "stt-offline", + label: "Parakeet Small", + description: "Small, fast English model with punctuation. A quick download for any laptop.", + languages: ["en"], + downloadSizeBytes: 108_035_095, + ...releaseArchive("asr-models", "sherpa-onnx-nemo-parakeet_tdt_transducer_110m-en-36000-int8"), + engine: PARAKEET_INT8_ENGINE, + }, "parakeet-tdt-0.6b-v3-int8": { kind: "stt-offline", - archiveUrl: - "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8.tar.bz2", - extractedDir: "sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8", - requiredFiles: ["encoder.int8.onnx", "decoder.int8.onnx", "joiner.int8.onnx", "tokens.txt"], - description: - "NVIDIA Parakeet TDT v3 (offline NeMo transducer, 25 European languages, auto-detected).", + label: "Parakeet v3", + description: "Parakeet for 25 European languages, detecting which one you speak.", + languages: PARAKEET_V3_LANGUAGES, + downloadSizeBytes: 487_170_055, + ...releaseArchive("asr-models", "sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8"), + engine: PARAKEET_INT8_ENGINE, + }, + "whisper-tiny-en": { + kind: "stt-offline", + label: "Whisper Tiny (English)", + description: "Small English Whisper model. Quick to download, less accurate than Parakeet.", + languages: ["en"], + downloadSizeBytes: 118_071_777, + ...releaseArchive("asr-models", "sherpa-onnx-whisper-tiny.en"), + engine: whisperEngine("tiny.en"), + }, + "whisper-base": { + kind: "stt-offline", + label: "Whisper Base", + description: "Compact Whisper model for 99 languages, detecting which one you speak.", + languages: WHISPER_LANGUAGES, + downloadSizeBytes: 207_557_382, + ...releaseArchive("asr-models", "sherpa-onnx-whisper-base"), + engine: whisperEngine("base"), + }, + "whisper-turbo": { + kind: "stt-offline", + label: "Whisper Turbo", + description: "Whisper's most accurate model for 99 languages and Cantonese. Slower on a CPU.", + languages: [...WHISPER_LANGUAGES, "yue"], + downloadSizeBytes: 563_790_207, + ...releaseArchive("asr-models", "sherpa-onnx-whisper-turbo"), + engine: whisperEngine("turbo"), + }, + "sense-voice-int8": { + kind: "stt-offline", + label: "SenseVoice", + description: "Chinese, Cantonese, English, Japanese and Korean, detecting which one you speak.", + languages: ["zh", "yue", "en", "ja", "ko"], + downloadSizeBytes: 165_783_878, + ...releaseArchive("asr-models", "sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2025-09-09"), + engine: { family: "sense_voice", model: "model.int8.onnx", tokens: "tokens.txt" }, + }, + "dolphin-base-int8": { + kind: "stt-offline", + label: "Dolphin", + description: "Small model for 40 Asian and Middle Eastern languages and Chinese dialects.", + languages: DOLPHIN_LANGUAGES, + downloadSizeBytes: 80_671_385, + ...releaseArchive("asr-models", "sherpa-onnx-dolphin-base-ctc-multi-lang-int8-2025-04-02"), + engine: { family: "dolphin", model: "model.int8.onnx", tokens: "tokens.txt" }, + }, + "giga-am-v3-ru": { + kind: "stt-offline", + label: "GigaAM v3 (Russian)", + description: "Russian transcription with punctuation.", + languages: ["ru"], + downloadSizeBytes: 170_197_019, + ...releaseArchive( + "asr-models", + "sherpa-onnx-nemo-transducer-punct-giga-am-v3-russian-2025-12-16", + ), + engine: { + family: "transducer", + encoder: "encoder.int8.onnx", + decoder: "decoder.onnx", + joiner: "joiner.onnx", + tokens: "tokens.txt", + }, + }, + "parakeet-tdt-ctc-0.6b-ja-int8": { + kind: "stt-offline", + label: "Parakeet (Japanese)", + description: "Japanese transcription with punctuation.", + languages: ["ja"], + downloadSizeBytes: 489_389_564, + ...releaseArchive("asr-models", "sherpa-onnx-nemo-parakeet-tdt_ctc-0.6b-ja-35000-int8"), + engine: { family: "nemo_ctc", model: "model.int8.onnx", tokens: "tokens.txt" }, }, "kokoro-en-v0_19": { kind: "tts", - archiveUrl: - "https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/kokoro-en-v0_19.tar.bz2", - extractedDir: "kokoro-en-v0_19", - requiredFiles: ["model.onnx", "voices.bin", "tokens.txt", "espeak-ng-data"], - description: "Kokoro TTS (higher quality; larger).", + label: "Kokoro (English)", + description: "Natural-sounding English voice for voice mode replies.", + languages: ["en"], + downloadSizeBytes: 319_625_534, + ...releaseArchive("tts-models", "kokoro-en-v0_19"), + engine: { + family: "kokoro", + model: "model.onnx", + voices: "voices.bin", + tokens: "tokens.txt", + dataDir: "espeak-ng-data", + }, defaultFor: "tts", }, + "kokoro-int8-en-v0_19": { + kind: "tts", + label: "Kokoro Lite (English)", + description: "The same Kokoro English voices, quantized to a third of the download.", + languages: ["en"], + downloadSizeBytes: 103_248_205, + ...releaseArchive("tts-models", "kokoro-int8-en-v0_19"), + engine: { + family: "kokoro", + model: "model.int8.onnx", + voices: "voices.bin", + tokens: "tokens.txt", + dataDir: "espeak-ng-data", + }, + }, + "kokoro-int8-multi-lang-v1_1": { + kind: "tts", + label: "Kokoro (Chinese and English)", + description: "Kokoro voices that speak both Mandarin Chinese and English.", + languages: ["zh", "en"], + downloadSizeBytes: 147_031_220, + ...releaseArchive("tts-models", "kokoro-int8-multi-lang-v1_1"), + engine: { + family: "kokoro", + model: "model.int8.onnx", + voices: "voices.bin", + tokens: "tokens.txt", + dataDir: "espeak-ng-data", + lexicons: ["lexicon-us-en.txt", "lexicon-zh.txt"], + dictDir: "dict", + ruleFsts: ["date-zh.fst", "phone-zh.fst", "number-zh.fst"], + }, + }, + // Kitten v0.8 needs a newer sherpa-onnx than we pin; v0.2 loads on 1.12.28. + "kitten-nano-en-v0_2-fp16": { + kind: "tts", + label: "Kitten Nano (English)", + description: "Tiny, very fast English voice.", + languages: ["en"], + downloadSizeBytes: 26_586_708, + ...releaseArchive("tts-models", "kitten-nano-en-v0_2-fp16"), + engine: { + family: "kitten", + model: "model.fp16.onnx", + voices: "voices.bin", + tokens: "tokens.txt", + dataDir: "espeak-ng-data", + }, + }, + "piper-en-us-libritts-r": { + kind: "tts", + label: "Piper LibriTTS (US English)", + description: "Lightweight American English voice.", + languages: ["en-US"], + downloadSizeBytes: 23_398_348, + ...releaseArchive("tts-models", "vits-piper-en_US-libritts_r-medium-int8"), + engine: piperEngine("en_US-libritts_r-medium"), + }, + "piper-en-gb-alba": { + kind: "tts", + label: "Piper Alba (British English)", + description: "Lightweight British English voice.", + languages: ["en-GB"], + downloadSizeBytes: 21_104_326, + ...releaseArchive("tts-models", "vits-piper-en_GB-alba-medium-int8"), + engine: piperEngine("en_GB-alba-medium"), + }, + "piper-de-thorsten": { + kind: "tts", + label: "Piper Thorsten (German)", + description: "Lightweight German voice.", + languages: ["de"], + downloadSizeBytes: 20_949_833, + ...releaseArchive("tts-models", "vits-piper-de_DE-thorsten-medium-int8"), + engine: piperEngine("de_DE-thorsten-medium"), + }, + "piper-fr-siwis": { + kind: "tts", + label: "Piper Siwis (French)", + description: "Lightweight French voice.", + languages: ["fr"], + downloadSizeBytes: 20_914_888, + ...releaseArchive("tts-models", "vits-piper-fr_FR-siwis-medium-int8"), + engine: piperEngine("fr_FR-siwis-medium"), + }, + "piper-es-davefx": { + kind: "tts", + label: "Piper Davefx (Spanish)", + description: "Lightweight Spanish voice.", + languages: ["es"], + downloadSizeBytes: 21_171_632, + ...releaseArchive("tts-models", "vits-piper-es_ES-davefx-medium-int8"), + engine: piperEngine("es_ES-davefx-medium"), + }, } as const satisfies Record; export type SherpaOnnxModelId = keyof typeof SHERPA_ONNX_MODEL_CATALOG; @@ -97,24 +521,44 @@ function createModelIdSchema(modelIds: readonly T[]): z.ZodTyp .transform((value) => value as T); } +export const LocalSpeechModelIdSchema = createModelIdSchema(ALL_MODEL_IDS); export const LocalSttModelIdSchema = createModelIdSchema(LOCAL_STT_MODEL_IDS); export const LocalTtsModelIdSchema = createModelIdSchema(LOCAL_TTS_MODEL_IDS); export type SherpaOnnxModelSpec = SherpaOnnxCatalogEntry & { id: SherpaOnnxModelId; + // Every file and directory that must exist for the model to count as installed. + requiredFiles: string[]; }; export function listSherpaOnnxModels(): SherpaOnnxModelSpec[] { - return ALL_MODEL_IDS.map((id) => Object.assign({ id }, SHERPA_ONNX_MODEL_CATALOG[id])); + return ALL_MODEL_IDS.map(getSherpaOnnxModelSpec); } export function getSherpaOnnxModelSpec(id: SherpaOnnxModelId): SherpaOnnxModelSpec { - const spec = SHERPA_ONNX_MODEL_CATALOG[id]; + const spec: SherpaOnnxCatalogEntry | undefined = SHERPA_ONNX_MODEL_CATALOG[id]; if (!spec) { throw new Error(`Unknown local speech model id: ${id}`); } return { id, ...spec, + requiredFiles: engineFiles(spec.engine), }; } + +// Every path an engine names, so the downloader can verify an install. +export function engineFiles(engine: SttEngine | TtsEngine): string[] { + return Object.entries(engine).flatMap(([key, value]) => { + if (key === "family") return []; + return typeof value === "string" ? [value] : (value as readonly string[]); + }); +} + +export function getLocalSttEngine(id: LocalSttModelId): SttEngine { + return SHERPA_ONNX_MODEL_CATALOG[id].engine; +} + +export function getLocalTtsEngine(id: LocalTtsModelId): TtsEngine { + return SHERPA_ONNX_MODEL_CATALOG[id].engine; +} diff --git a/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts b/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts index cdd286389f..b7ba511224 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/model-downloader.ts @@ -8,11 +8,16 @@ import type pino from "pino"; import { getSherpaOnnxModelSpec, type SherpaOnnxModelId } from "./model-catalog.js"; import { spawnProcess } from "../../../../../utils/spawn.js"; +export type SherpaOnnxModelInstallProgress = + | { phase: "downloading"; receivedBytes: number; totalBytes: number } + | { phase: "extracting" }; + export interface EnsureSherpaOnnxModelOptions { modelsDir: string; modelId: SherpaOnnxModelId; logger: pino.Logger; signal?: AbortSignal; + onProgress?: (progress: SherpaOnnxModelInstallProgress) => void; } export function getSherpaOnnxModelDir(modelsDir: string, modelId: SherpaOnnxModelId): string { @@ -41,7 +46,9 @@ async function hasRequiredFiles(modelDir: string, requiredFiles: string[]): Prom interface DownloadToFileOptions { url: string; outputPath: string; + expectedBytes: number; signal?: AbortSignal; + onProgress?: (receivedBytes: number, totalBytes: number) => void; } async function downloadToFile(options: DownloadToFileOptions): Promise { @@ -60,6 +67,13 @@ async function downloadToFile(options: DownloadToFileOptions): Promise { // The fetch ReadableStream type is slightly different from what Readable.fromWeb expects // eslint-disable-next-line @typescript-eslint/no-explicit-any const nodeStream = Readable.fromWeb(res.body as any); + const contentLength = Number(res.headers.get("content-length")); + const totalBytes = contentLength > 0 ? contentLength : options.expectedBytes; + let receivedBytes = 0; + nodeStream.on("data", (chunk: Buffer) => { + receivedBytes += chunk.length; + options.onProgress?.(receivedBytes, totalBytes); + }); try { await pipeline(nodeStream, createWriteStream(tmpPath), { signal: options.signal }); @@ -157,9 +171,13 @@ export async function ensureSherpaOnnxModel( await downloadToFile({ url: spec.archiveUrl, outputPath: archivePath, + expectedBytes: spec.downloadSizeBytes, signal: options.signal, + onProgress: (receivedBytes, totalBytes) => + options.onProgress?.({ phase: "downloading", receivedBytes, totalBytes }), }); } + options.onProgress?.({ phase: "extracting" }); logger.info( { @@ -213,6 +231,16 @@ export async function ensureSherpaOnnxModel( } } +export async function deleteSherpaOnnxModel( + modelsDir: string, + modelId: SherpaOnnxModelId, +): Promise { + const { modelDir, archivePath, extractionDir } = getSherpaOnnxModelPaths(modelsDir, modelId); + await rm(modelDir, { recursive: true, force: true }); + await rm(archivePath, { force: true }); + await rm(extractionDir, { recursive: true, force: true }); +} + export async function ensureSherpaOnnxModels(options: { modelsDir: string; modelIds: SherpaOnnxModelId[]; diff --git a/packages/server/src/server/speech/providers/local/sherpa/sherpa-offline-recognizer.ts b/packages/server/src/server/speech/providers/local/sherpa/sherpa-offline-recognizer.ts index cfde73addf..5137f51810 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/sherpa-offline-recognizer.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/sherpa-offline-recognizer.ts @@ -1,6 +1,8 @@ import { existsSync } from "node:fs"; +import path from "node:path"; import type pino from "pino"; +import { engineFiles, type SttEngine } from "./model-catalog.js"; import { loadSherpaOnnxNode } from "./sherpa-onnx-node-loader.js"; function assertFileExists(filePath: string, label: string): void { @@ -9,16 +11,43 @@ function assertFileExists(filePath: string, label: string): void { } } -export interface SherpaOfflineRecognizerModel { - kind: "nemo_transducer"; - encoder: string; - decoder: string; - joiner: string; - tokens: string; +// Each engine family takes its own block under sherpa's modelConfig. +function buildModelConfig(modelDir: string, engine: SttEngine): Record { + const file = (name: string) => path.join(modelDir, name); + switch (engine.family) { + case "transducer": + return { + transducer: { + encoder: file(engine.encoder), + decoder: file(engine.decoder), + joiner: file(engine.joiner), + }, + modelType: "nemo_transducer", + }; + case "whisper": + // An empty language makes Whisper detect the spoken language. + return { + whisper: { + encoder: file(engine.encoder), + decoder: file(engine.decoder), + language: "", + task: "transcribe", + }, + }; + case "sense_voice": + return { + senseVoice: { model: file(engine.model), language: "", useInverseTextNormalization: 1 }, + }; + case "dolphin": + return { dolphin: { model: file(engine.model) } }; + case "nemo_ctc": + return { nemoCtc: { model: file(engine.model) } }; + } } export interface SherpaOfflineRecognizerConfig { - model: SherpaOfflineRecognizerModel; + modelDir: string; + engine: SttEngine; numThreads?: number; provider?: "cpu"; debug?: 0 | 1; @@ -54,10 +83,9 @@ export class SherpaOfflineRecognizerEngine { component: "offline-recognizer", }); - assertFileExists(config.model.encoder, "offline encoder"); - assertFileExists(config.model.decoder, "offline decoder"); - assertFileExists(config.model.joiner, "offline joiner"); - assertFileExists(config.model.tokens, "tokens"); + for (const name of engineFiles(config.engine)) { + assertFileExists(path.join(config.modelDir, name), `${config.engine.family} model file`); + } const sherpa = loadSherpaOnnxNode(); @@ -67,13 +95,8 @@ export class SherpaOfflineRecognizerEngine { featureDim: config.featureDim ?? 80, }, modelConfig: { - transducer: { - encoder: config.model.encoder, - decoder: config.model.decoder, - joiner: config.model.joiner, - }, - tokens: config.model.tokens, - modelType: "nemo_transducer", + ...buildModelConfig(config.modelDir, config.engine), + tokens: path.join(config.modelDir, config.engine.tokens), numThreads: config.numThreads ?? 1, provider: config.provider ?? "cpu", debug: config.debug ?? 0, diff --git a/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.test.ts b/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.test.ts index 06d4a5b41d..0ae51f86ee 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.test.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import pino from "pino"; +import { engineFiles, getLocalTtsEngine, type LocalTtsModelId } from "./model-catalog.js"; import { SherpaOnnxTTS } from "./sherpa-tts.js"; const directories: string[] = []; @@ -11,17 +12,28 @@ afterEach(() => { rmSync(directory, { recursive: true, force: true }); }); -function createTts(numThreads?: number, beforeGenerate?: () => Promise) { +function createTts( + numThreads?: number, + beforeGenerate?: () => Promise, + modelId: LocalTtsModelId = "kokoro-en-v0_19", +) { const modelDir = mkdtempSync(join(tmpdir(), "paseo-tts-")); directories.push(modelDir); - for (const name of ["model.onnx", "voices.bin", "tokens.txt"]) - writeFileSync(join(modelDir, name), ""); - mkdirSync(join(modelDir, "espeak-ng-data")); + // Data directories have no extension; everything else is a model file. + for (const name of engineFiles(getLocalTtsEngine(modelId))) { + if (name.includes(".")) writeFileSync(join(modelDir, name), ""); + else mkdirSync(join(modelDir, name)); + } let nativeConfig: unknown; const requests: unknown[] = []; const released: boolean[] = []; const tts = new SherpaOnnxTTS( - { preset: "kokoro-en-v0_19", modelDir, numThreads }, + { + modelId, + modelDir, + engine: getLocalTtsEngine(modelId), + numThreads, + }, pino({ level: "silent" }), () => ({ OfflineTts: class { @@ -138,4 +150,31 @@ describe("SherpaOnnxTTS", () => { expect(nativeConfig).not.toHaveProperty("provider"); }, ); + + it("loads Piper voices through the vits engine", () => { + const { nativeConfig } = createTts(undefined, undefined, "piper-de-thorsten"); + expect(nativeConfig).toMatchObject({ + model: { + vits: { + model: expect.stringMatching(/de_DE-thorsten-medium\.onnx$/), + tokens: expect.stringMatching(/tokens\.txt$/), + dataDir: expect.stringMatching(/espeak-ng-data$/), + }, + }, + }); + expect(nativeConfig).not.toHaveProperty("model.kokoro"); + }); + + it("gives multi-language Kokoro its lexicons, dictionary and number rules", () => { + const { nativeConfig } = createTts(undefined, undefined, "kokoro-int8-multi-lang-v1_1"); + expect(nativeConfig).toMatchObject({ + model: { + kokoro: { + lexicon: expect.stringMatching(/lexicon-us-en\.txt,.*lexicon-zh\.txt$/), + dictDir: expect.stringMatching(/dict$/), + }, + }, + ruleFsts: expect.stringMatching(/date-zh\.fst,.*phone-zh\.fst,.*number-zh\.fst$/), + }); + }); }); diff --git a/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.ts b/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.ts index a078f81f51..12c86b8777 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.ts @@ -1,16 +1,17 @@ import type pino from "pino"; import { Readable } from "node:stream"; import { existsSync } from "node:fs"; +import path from "node:path"; import type { SpeechStreamResult, TextToSpeechProvider } from "../../../speech-provider.js"; import { chunkBuffer, float32ToPcm16le } from "../../../audio.js"; import { loadSherpaOnnxNode } from "./sherpa-onnx-node-loader.js"; - -export type SherpaTtsPreset = "kokoro-en-v0_19"; +import { engineFiles, type LocalTtsModelId, type TtsEngine } from "./model-catalog.js"; export interface SherpaTtsConfig { - preset: SherpaTtsPreset; + modelId: LocalTtsModelId; modelDir: string; + engine: TtsEngine; speakerId?: number; speed?: number; lengthScale?: number; @@ -23,6 +24,53 @@ function assertFileExists(filePath: string, label: string): void { } } +// Each engine family takes its own block under sherpa's TTS model config. +function buildTtsModelConfig( + modelDir: string, + engine: TtsEngine, + lengthScale: number, +): Record { + const file = (name: string) => path.join(modelDir, name); + switch (engine.family) { + case "kokoro": + return { + kokoro: { + model: file(engine.model), + voices: file(engine.voices), + tokens: file(engine.tokens), + dataDir: file(engine.dataDir), + lexicon: (engine.lexicons ?? []).map(file).join(","), + dictDir: engine.dictDir ? file(engine.dictDir) : "", + lengthScale, + }, + }; + case "kitten": + return { + kitten: { + model: file(engine.model), + voices: file(engine.voices), + tokens: file(engine.tokens), + dataDir: file(engine.dataDir), + lengthScale, + }, + }; + case "vits": + return { + vits: { + model: file(engine.model), + tokens: file(engine.tokens), + dataDir: file(engine.dataDir), + lengthScale, + }, + }; + } +} + +function ruleFsts(modelDir: string, engine: TtsEngine): string { + if (engine.family !== "kokoro" || !engine.ruleFsts) return ""; + return engine.ruleFsts.map((name) => path.join(modelDir, name)).join(","); +} + interface SherpaOfflineTtsNative { sampleRate?: number; generateAsync: (args: { @@ -59,39 +107,28 @@ export class SherpaOnnxTTS implements TextToSpeechProvider { throw new Error("sherpa-onnx-node OfflineTts is unavailable"); } - const modelPath = `${config.modelDir}/model.onnx`; - const voicesPath = `${config.modelDir}/voices.bin`; - const tokensPath = `${config.modelDir}/tokens.txt`; - const dataDir = `${config.modelDir}/espeak-ng-data`; - - assertFileExists(modelPath, "TTS model"); - assertFileExists(voicesPath, "TTS voices"); - assertFileExists(tokensPath, "TTS tokens"); - assertFileExists(dataDir, "TTS espeak-ng dataDir"); + for (const name of engineFiles(config.engine)) { + assertFileExists(path.join(config.modelDir, name), `${config.engine.family} TTS file`); + } const modelConfig = { // The native parser reads these under model, despite the upstream JS typedef. numThreads: config.numThreads ?? 2, provider: "cpu", - kokoro: { - model: modelPath, - voices: voicesPath, - tokens: tokensPath, - dataDir, - lengthScale: config.lengthScale ?? 1.0, - }, + ...buildTtsModelConfig(config.modelDir, config.engine, config.lengthScale ?? 1.0), }; const offlineTtsConfig = { model: modelConfig, maxNumSentences: 1, + ruleFsts: ruleFsts(config.modelDir, config.engine), }; this.tts = new ( sherpa as unknown as { OfflineTts: new (config: unknown) => SherpaOfflineTtsNative } ).OfflineTts(offlineTtsConfig); this.logger.info( - { preset: config.preset, modelDir: config.modelDir }, + { modelId: config.modelId, modelDir: config.modelDir }, "Sherpa offline TTS initialized", ); } diff --git a/packages/server/src/server/speech/providers/local/sherpa/speech-download.e2e.test.ts b/packages/server/src/server/speech/providers/local/sherpa/speech-download.e2e.test.ts index ab2eefef55..43ab8af01a 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/speech-download.e2e.test.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/speech-download.e2e.test.ts @@ -7,6 +7,7 @@ import pino from "pino"; import { ensureSherpaOnnxModels, getSherpaOnnxModelDir } from "./model-downloader.js"; import { createDaemonTestContext } from "../../../../test-utils/index.js"; import { parsePcm16MonoWav, wordSimilarity } from "../../../../test-utils/dictation-e2e.js"; +import { getLocalSttEngine, getLocalTtsEngine } from "./model-catalog.js"; import { SherpaOnnxTTS } from "./sherpa-tts.js"; import { SherpaOfflineRecognizerEngine } from "./sherpa-offline-recognizer.js"; import { SherpaOnnxParakeetSTT } from "./sherpa-parakeet-stt.js"; @@ -192,7 +193,12 @@ downloadTest( const ttsText = "This is a voice note."; const ttsModelDir = getSherpaOnnxModelDir(modelsDir, "kokoro-en-v0_19"); const tts = new SherpaOnnxTTS( - { preset: "kokoro-en-v0_19", modelDir: ttsModelDir, speakerId: 0 }, + { + modelId: "kokoro-en-v0_19", + modelDir: ttsModelDir, + engine: getLocalTtsEngine("kokoro-en-v0_19"), + speakerId: 0, + }, logger, ); const { stream, format: ttsFormat } = await tts.synthesizeSpeech(ttsText); @@ -206,13 +212,8 @@ downloadTest( const sttModelDir = getSherpaOnnxModelDir(modelsDir, "parakeet-tdt-0.6b-v2-int8"); const engine = new SherpaOfflineRecognizerEngine( { - model: { - kind: "nemo_transducer", - encoder: `${sttModelDir}/encoder.int8.onnx`, - decoder: `${sttModelDir}/decoder.int8.onnx`, - joiner: `${sttModelDir}/joiner.int8.onnx`, - tokens: `${sttModelDir}/tokens.txt`, - }, + modelDir: sttModelDir, + engine: getLocalSttEngine("parakeet-tdt-0.6b-v2-int8"), numThreads: 2, debug: 0, }, diff --git a/packages/server/src/server/speech/providers/local/worker-process.ts b/packages/server/src/server/speech/providers/local/worker-process.ts index 957ee6aaa2..2afa3d336a 100644 --- a/packages/server/src/server/speech/providers/local/worker-process.ts +++ b/packages/server/src/server/speech/providers/local/worker-process.ts @@ -3,6 +3,7 @@ import pino from "pino"; import type { StreamingTranscriptionSession } from "../../speech-provider.js"; import type { TurnDetectionSession } from "../../turn-detection-provider.js"; import { getLocalSpeechModelDir, type LocalSttModelId, type LocalTtsModelId } from "./models.js"; +import { getLocalSttEngine, getLocalTtsEngine } from "./sherpa/model-catalog.js"; import { SherpaOfflineRecognizerEngine } from "./sherpa/sherpa-offline-recognizer.js"; import { SherpaOnnxParakeetSTT } from "./sherpa/sherpa-parakeet-stt.js"; import { SherpaParakeetRealtimeTranscriptionSession } from "./sherpa/sherpa-parakeet-realtime-session.js"; @@ -85,13 +86,8 @@ function getSttEngine( const modelDir = getLocalSpeechModelDir(config.modelsDir, modelId); const created = new SherpaOfflineRecognizerEngine( { - model: { - kind: "nemo_transducer", - encoder: `${modelDir}/encoder.int8.onnx`, - decoder: `${modelDir}/decoder.int8.onnx`, - joiner: `${modelDir}/joiner.int8.onnx`, - tokens: `${modelDir}/tokens.txt`, - }, + modelDir, + engine: getLocalSttEngine(modelId), numThreads: 2, debug: 0, }, @@ -125,8 +121,9 @@ function getTtsProvider(config: LocalSpeechWorkerConfig): SherpaOnnxTTS { const modelDir = getLocalSpeechModelDir(config.modelsDir, ttsModelId(config)); const created = new SherpaOnnxTTS( { - preset: ttsModelId(config), + modelId: ttsModelId(config), modelDir, + engine: getLocalTtsEngine(ttsModelId(config)), speakerId: config.voiceTtsSpeakerId, speed: config.voiceTtsSpeed, }, diff --git a/packages/server/src/server/speech/providers/openai/config.test.ts b/packages/server/src/server/speech/providers/openai/config.test.ts index 1f3433a347..dd465dd2f9 100644 --- a/packages/server/src/server/speech/providers/openai/config.test.ts +++ b/packages/server/src/server/speech/providers/openai/config.test.ts @@ -1,5 +1,4 @@ import { describe, expect, test } from "vitest"; - import { PersistedConfigSchema } from "../../../persisted-config.js"; import { resolveOpenAiSpeechConfig } from "./config.js"; import type { RequestedSpeechProviders } from "../../speech-types.js"; @@ -10,233 +9,139 @@ const ALL_OPENAI: RequestedSpeechProviders = { voiceStt: { provider: "openai", explicit: true }, voiceTts: { provider: "openai", explicit: true }, }; +function resolve(input: unknown, env: NodeJS.ProcessEnv = {}) { + return resolveOpenAiSpeechConfig({ + persisted: PersistedConfigSchema.parse(input), + env, + providers: ALL_OPENAI, + }); +} describe("resolveOpenAiSpeechConfig", () => { - test("treats empty OPENAI_API_KEY as unset", () => { - const persisted = PersistedConfigSchema.parse({}); - const env = { - OPENAI_API_KEY: "", - } as NodeJS.ProcessEnv; - - const resolved = resolveOpenAiSpeechConfig({ - env, - persisted, - providers: { - ...ALL_OPENAI, - dictationStt: { provider: "local", explicit: false }, - voiceStt: { provider: "local", explicit: false }, - voiceTts: { provider: "local", explicit: false }, + test("treats empty credentials as unset", () => { + expect(resolve({}, { OPENAI_API_KEY: "", OPENAI_STT_API_KEY: " " })).toBeUndefined(); + }); + test("uses endpoint env before global env and ignores empty fallbacks", () => { + const result = resolve( + {}, + { + OPENAI_API_KEY: " global ", + OPENAI_BASE_URL: " https://global/v1 ", + OPENAI_STT_API_KEY: " stt ", + OPENAI_STT_BASE_URL: " https://stt/v1 ", + OPENAI_TTS_API_KEY: " ", + OPENAI_TTS_BASE_URL: "", }, + ); + expect(result?.dictationStt).toEqual({ apiKey: "stt", baseUrl: "https://stt/v1" }); + expect(result?.stt).toEqual({ apiKey: "stt", baseUrl: "https://stt/v1" }); + expect(result?.tts).toEqual({ + apiKey: "global", + baseUrl: "https://global/v1", + model: "tts-1", + voice: "alloy", + responseFormat: "pcm", }); - - expect(resolved).toBeUndefined(); }); - - test("applies trimmed OPENAI_API_KEY to both STT and TTS", () => { - const persisted = PersistedConfigSchema.parse({}); - const env = { - OPENAI_API_KEY: " sk-test ", - } as NodeJS.ProcessEnv; - - const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI }); - - expect(resolved?.stt?.apiKey).toBe("sk-test"); - expect(resolved?.tts?.apiKey).toBe("sk-test"); - }); - - test("resolves distinct endpoints for STT and TTS", () => { - const persisted = PersistedConfigSchema.parse({ - providers: { - openai: { - stt: { - apiKey: "stt-key", - baseUrl: " https://stt.example.com/v1 ", + test("each site wins over endpoint and global env credentials and URL", () => { + const result = resolve( + { + features: { + dictation: { + stt: { + provider: "openai", + apiKey: "dictation", + baseUrl: "https://dictation/v1", + model: "dictation-model", + confidenceThreshold: -1, + }, }, - tts: { - apiKey: "tts-key", - baseUrl: " https://tts.example.com/v1 ", + voiceMode: { + stt: { + provider: "openai", + apiKey: "voice", + baseUrl: "https://voice/v1", + model: "voice-model", + confidenceThreshold: -2, + }, + tts: { + provider: "openai", + apiKey: "tts", + baseUrl: "https://tts/v1", + model: "kokoro-v1:Q8_0", + voice: "Aiden", + }, }, }, }, + { + OPENAI_API_KEY: "global", + OPENAI_BASE_URL: "https://global/v1", + OPENAI_STT_API_KEY: "endpoint", + OPENAI_STT_BASE_URL: "https://endpoint/v1", + OPENAI_TTS_API_KEY: "endpoint", + OPENAI_TTS_BASE_URL: "https://endpoint/v1", + }, + ); + expect(result?.dictationStt).toEqual({ + apiKey: "dictation", + baseUrl: "https://dictation/v1", + model: "dictation-model", + confidenceThreshold: -1, }); - - const resolved = resolveOpenAiSpeechConfig({ - env: {} as NodeJS.ProcessEnv, - persisted, - providers: ALL_OPENAI, + expect(result?.stt).toEqual({ + apiKey: "voice", + baseUrl: "https://voice/v1", + model: "voice-model", + confidenceThreshold: -2, + }); + expect(result?.tts).toEqual({ + apiKey: "tts", + baseUrl: "https://tts/v1", + model: "kokoro-v1:Q8_0", + voice: "Aiden", + responseFormat: "pcm", }); - - expect(resolved?.stt?.apiKey).toBe("stt-key"); - expect(resolved?.stt?.baseUrl).toBe("https://stt.example.com/v1"); - expect(resolved?.tts?.apiKey).toBe("tts-key"); - expect(resolved?.tts?.baseUrl).toBe("https://tts.example.com/v1"); }); - - test("prefers nested STT/TTS config over env and global fallbacks", () => { - const persisted = PersistedConfigSchema.parse({ - providers: { - openai: { - apiKey: "fallback-config-key", - baseUrl: "https://global-config.example.com/v1", - stt: { apiKey: "stt-config-key", baseUrl: " https://stt.example.com/v1 " }, - tts: { apiKey: "tts-config-key", baseUrl: " https://tts.example.com/v1 " }, + test("env overrides model, voice and threshold at every OpenAI site", () => { + const result = resolve( + { + features: { + dictation: { stt: { provider: "openai", model: "dictation", confidenceThreshold: -1 } }, + voiceMode: { + stt: { provider: "openai", model: "voice", confidenceThreshold: -2 }, + tts: { provider: "openai", model: "configured", voice: "Aiden" }, + }, }, }, - }); - const env = { - OPENAI_API_KEY: "env-key", - OPENAI_STT_API_KEY: "stt-env-key", - OPENAI_STT_BASE_URL: "https://stt-env.example.com/v1", - OPENAI_TTS_API_KEY: "tts-env-key", - OPENAI_TTS_BASE_URL: "https://tts-env.example.com/v1", - OPENAI_BASE_URL: "https://env.example.com/v1", - } as NodeJS.ProcessEnv; - - const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI }); - - expect(resolved?.stt?.apiKey).toBe("stt-config-key"); - expect(resolved?.stt?.baseUrl).toBe("https://stt.example.com/v1"); - expect(resolved?.tts?.apiKey).toBe("tts-config-key"); - expect(resolved?.tts?.baseUrl).toBe("https://tts.example.com/v1"); - }); - - test("uses STT/TTS env config when nested config is unset", () => { - const persisted = PersistedConfigSchema.parse({}); - const env = { - OPENAI_API_KEY: "sk-test", - OPENAI_STT_API_KEY: "stt-env-key", - OPENAI_STT_BASE_URL: " https://stt-env.example.com/v1 ", - OPENAI_TTS_API_KEY: "tts-env-key", - OPENAI_TTS_BASE_URL: " https://tts-env.example.com/v1 ", - OPENAI_BASE_URL: "https://env.example.com/v1", - } as NodeJS.ProcessEnv; - - const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI }); - - expect(resolved?.stt?.apiKey).toBe("stt-env-key"); - expect(resolved?.stt?.baseUrl).toBe("https://stt-env.example.com/v1"); - expect(resolved?.tts?.apiKey).toBe("tts-env-key"); - expect(resolved?.tts?.baseUrl).toBe("https://tts-env.example.com/v1"); - }); - - test("falls back to global OpenAI config for both STT and TTS", () => { - const persisted = PersistedConfigSchema.parse({ - providers: { - openai: { - apiKey: "fallback-config-key", - baseUrl: " https://global-config.example.com/v1 ", - }, + { + OPENAI_API_KEY: "key", + STT_MODEL: "override", + TTS_MODEL: "tts-override", + TTS_VOICE: "Nova", + STT_CONFIDENCE_THRESHOLD: "-3", }, + ); + expect(result?.dictationStt).toEqual({ + apiKey: "key", + model: "override", + confidenceThreshold: -3, }); - const env = {} as NodeJS.ProcessEnv; - - const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI }); - - expect(resolved?.stt?.apiKey).toBe("fallback-config-key"); - expect(resolved?.stt?.baseUrl).toBe("https://global-config.example.com/v1"); - expect(resolved?.tts?.apiKey).toBe("fallback-config-key"); - expect(resolved?.tts?.baseUrl).toBe("https://global-config.example.com/v1"); + expect(result?.stt).toEqual({ apiKey: "key", model: "override", confidenceThreshold: -3 }); + expect(result?.tts).toMatchObject({ model: "tts-override", voice: "Nova" }); }); - - test("falls back to global OpenAI env config when feature inputs are unset", () => { - const persisted = PersistedConfigSchema.parse({}); - const env = { - OPENAI_API_KEY: "env-key", - OPENAI_BASE_URL: " https://env.example.com/v1 ", - } as NodeJS.ProcessEnv; - - const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI }); - - expect(resolved?.stt?.apiKey).toBe("env-key"); - expect(resolved?.stt?.baseUrl).toBe("https://env.example.com/v1"); - expect(resolved?.tts?.apiKey).toBe("env-key"); - expect(resolved?.tts?.baseUrl).toBe("https://env.example.com/v1"); + test("only configures endpoints with credentials and ignores unused TTS options", () => { + const result = resolve( + { features: { dictation: { stt: { provider: "openai", apiKey: "dictation" } } } }, + { TTS_VOICE: "arbitrary", TTS_MODEL: "arbitrary" }, + ); + expect(result).toEqual({ dictationStt: { apiKey: "dictation" } }); }); - - test("ignores empty endpoint env vars and falls back to OPENAI_API_KEY", () => { - const persisted = PersistedConfigSchema.parse({}); - const env = { - OPENAI_API_KEY: "global-key", - OPENAI_STT_API_KEY: "", - OPENAI_STT_BASE_URL: " ", - OPENAI_TTS_API_KEY: "", - OPENAI_TTS_BASE_URL: "", - } as NodeJS.ProcessEnv; - - const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI }); - - expect(resolved?.stt?.apiKey).toBe("global-key"); - expect(resolved?.tts?.apiKey).toBe("global-key"); - }); - - test("omits TTS when only an STT key is configured", () => { - const persisted = PersistedConfigSchema.parse({ - providers: { - openai: { - stt: { apiKey: "stt-only-key" }, - }, - }, + test("does not borrow another site's key", () => { + const result = resolve({ + features: { voiceMode: { stt: { provider: "openai", apiKey: "voice-only" } } }, }); - - const resolved = resolveOpenAiSpeechConfig({ - env: {} as NodeJS.ProcessEnv, - persisted, - providers: ALL_OPENAI, - }); - - expect(resolved?.stt?.apiKey).toBe("stt-only-key"); - expect(resolved?.tts).toBeUndefined(); - }); - - test("resolves STT even when an unused TTS env var is invalid", () => { - const persisted = PersistedConfigSchema.parse({ - providers: { - openai: { - stt: { apiKey: "stt-only-key" }, - }, - }, - }); - const env = { TTS_VOICE: "not-a-real-voice", TTS_MODEL: "bogus-model" } as NodeJS.ProcessEnv; - - const resolved = resolveOpenAiSpeechConfig({ env, persisted, providers: ALL_OPENAI }); - - expect(resolved?.stt?.apiKey).toBe("stt-only-key"); - expect(resolved?.tts).toBeUndefined(); - }); - - test("sends the model and voice an OpenAI-compatible server serves, as written", () => { - const persisted = PersistedConfigSchema.parse({ - providers: { - openai: { tts: { apiKey: "tts-key", baseUrl: "https://tts.example.com/v1" } }, - }, - features: { - voiceMode: { tts: { provider: "openai", model: "kokoro-v1:Q8_0", voice: "Aiden" } }, - }, - }); - - const resolved = resolveOpenAiSpeechConfig({ - env: {} as NodeJS.ProcessEnv, - persisted, - providers: ALL_OPENAI, - }); - - expect(resolved?.tts?.model).toBe("kokoro-v1:Q8_0"); - expect(resolved?.tts?.voice).toBe("Aiden"); - }); - - test("accepts OpenAI's own gpt-4o-mini-tts model", () => { - const persisted = PersistedConfigSchema.parse({ - providers: { openai: { tts: { apiKey: "tts-key" } } }, - features: { voiceMode: { tts: { provider: "openai", model: "gpt-4o-mini-tts" } } }, - }); - - const resolved = resolveOpenAiSpeechConfig({ - env: {} as NodeJS.ProcessEnv, - persisted, - providers: ALL_OPENAI, - }); - - expect(resolved?.tts?.model).toBe("gpt-4o-mini-tts"); - expect(resolved?.tts?.voice).toBe("alloy"); + expect(result?.dictationStt).toBeUndefined(); + expect(result?.stt).toEqual({ apiKey: "voice-only" }); }); }); diff --git a/packages/server/src/server/speech/providers/openai/config.ts b/packages/server/src/server/speech/providers/openai/config.ts index efb25bffaf..37813571f4 100644 --- a/packages/server/src/server/speech/providers/openai/config.ts +++ b/packages/server/src/server/speech/providers/openai/config.ts @@ -6,147 +6,61 @@ import type { STTConfig } from "./stt.js"; import type { TTSConfig } from "./tts.js"; export const DEFAULT_OPENAI_TTS_MODEL = "tts-1"; +export type OpenAiSttFeature = "dictation" | "voiceMode"; export interface OpenAiSpeechProviderConfig { - stt?: Partial & { apiKey?: string }; - tts?: Partial & { apiKey?: string }; + dictationStt?: Partial; + stt?: Partial; + tts?: Partial; } -const NumberLikeSchema = z.union([z.number(), z.string().trim().min(1)]); +const OptionalFiniteNumberSchema = z + .union([z.number(), z.string().trim().min(1)]) + .pipe(z.coerce.number().finite()) + .optional(); -const OptionalFiniteNumberSchema = NumberLikeSchema.pipe( - z.coerce.number().finite(), -).optional(); - -const OptionalTrimmedStringSchema = z - .string() - .trim() - .optional() - .transform((value) => (value && value.length > 0 ? value : undefined)); - -// Endpoint credentials only — plain trimmed strings, so this never throws on a -// malformed value. The STT/TTS option groups parse separately and only for the -// endpoint that is actually configured, so a stale env var for an unused endpoint -// (e.g. a leftover TTS_VOICE in an STT-only setup) can't break the other one. -const OpenAiEndpointKeysSchema = z.object({ - sttApiKey: OptionalTrimmedStringSchema, - sttBaseUrl: OptionalTrimmedStringSchema, - ttsApiKey: OptionalTrimmedStringSchema, - ttsBaseUrl: OptionalTrimmedStringSchema, -}); - -const OpenAiSttOptionsSchema = z.object({ - sttConfidenceThreshold: OptionalFiniteNumberSchema, - sttModel: OptionalTrimmedStringSchema, -}); - -// Voice and model are free-form so a self-hosted server can use its own ids; the -// OpenAI catalog is only the default. Both are sent as written, because the -// server owns those ids and their case (rosters use ids like kokoro-v1:Q8_0). -const OpenAiTtsOptionsSchema = z.object({ - ttsVoice: z.string().trim().default("alloy"), - ttsModel: z.string().trim().default(DEFAULT_OPENAI_TTS_MODEL), -}); - -function isOpenAiProviderActive(provider: { enabled?: boolean; provider: string }): boolean { - return provider.enabled !== false && provider.provider === "openai"; +function firstString(...values: (string | undefined)[]): string | undefined { + return values.map((value) => value?.trim()).find((value) => Boolean(value)); } -function pickIfOpenAi( - provider: { enabled?: boolean; provider: string }, - value: T | undefined, -): T | undefined { - return isOpenAiProviderActive(provider) ? value : undefined; -} +type Features = NonNullable; +type SttSite = NonNullable["stt"]>; +type TtsSite = NonNullable["tts"]>; -function firstDefined(values: Array): T | undefined { - for (const value of values) { - if (value === undefined || value === null) { - continue; - } - // Empty/whitespace env vars (e.g. a copied .env.example with OPENAI_STT_API_KEY=) - // must not shadow a later fallback such as OPENAI_API_KEY. - if (typeof value === "string" && value.trim().length === 0) { - continue; - } - return value; - } - return undefined; -} - -function buildOpenAiSttInput(params: { - env: NodeJS.ProcessEnv; - persisted: PersistedConfig; - providers: RequestedSpeechProviders; -}): Record { - const { env, persisted, providers } = params; +function resolveStt( + site: SttSite | undefined, + env: NodeJS.ProcessEnv, +): Partial | undefined { + const config = site?.provider === "openai" ? site : undefined; + const apiKey = firstString(config?.apiKey, env.OPENAI_STT_API_KEY, env.OPENAI_API_KEY); + if (!apiKey) return undefined; + const baseUrl = firstString(config?.baseUrl, env.OPENAI_STT_BASE_URL, env.OPENAI_BASE_URL); + const model = firstString(env.STT_MODEL, config?.model); + const confidenceThreshold = OptionalFiniteNumberSchema.parse( + firstString(env.STT_CONFIDENCE_THRESHOLD) ?? config?.confidenceThreshold, + ); return { - sttConfidenceThreshold: firstDefined([ - env.STT_CONFIDENCE_THRESHOLD, - persisted.features?.dictation?.stt?.confidenceThreshold, - ]), - sttModel: firstDefined([ - env.STT_MODEL, - pickIfOpenAi(providers.voiceStt, persisted.features?.voiceMode?.stt?.model), - pickIfOpenAi(providers.dictationStt, persisted.features?.dictation?.stt?.model), - ]), + apiKey, + ...(baseUrl ? { baseUrl } : {}), + ...(model ? { model } : {}), + ...(confidenceThreshold !== undefined ? { confidenceThreshold } : {}), }; } -function buildOpenAiTtsInput(params: { - env: NodeJS.ProcessEnv; - persisted: PersistedConfig; - providers: RequestedSpeechProviders; -}): Record { - const { env, persisted, providers } = params; +function resolveTts( + site: TtsSite | undefined, + env: NodeJS.ProcessEnv, +): Partial | undefined { + const config = site?.provider === "openai" ? site : undefined; + const apiKey = firstString(config?.apiKey, env.OPENAI_TTS_API_KEY, env.OPENAI_API_KEY); + if (!apiKey) return undefined; + const baseUrl = firstString(config?.baseUrl, env.OPENAI_TTS_BASE_URL, env.OPENAI_BASE_URL); return { - ttsVoice: firstDefined([ - env.TTS_VOICE, - pickIfOpenAi(providers.voiceTts, persisted.features?.voiceMode?.tts?.voice), - "alloy", - ]), - ttsModel: firstDefined([ - env.TTS_MODEL, - pickIfOpenAi(providers.voiceTts, persisted.features?.voiceMode?.tts?.model), - DEFAULT_OPENAI_TTS_MODEL, - ]), - }; -} - -function buildOpenAiResolutionInput(params: { - env: NodeJS.ProcessEnv; - persisted: PersistedConfig; - providers: RequestedSpeechProviders; -}): Record { - const { env } = params; - const openai = params.persisted.providers?.openai; - return { - sttApiKey: firstDefined([ - openai?.stt?.apiKey, - env.OPENAI_STT_API_KEY, - openai?.apiKey, - env.OPENAI_API_KEY, - ]), - sttBaseUrl: firstDefined([ - openai?.stt?.baseUrl, - env.OPENAI_STT_BASE_URL, - openai?.baseUrl, - env.OPENAI_BASE_URL, - ]), - ttsApiKey: firstDefined([ - openai?.tts?.apiKey, - env.OPENAI_TTS_API_KEY, - openai?.apiKey, - env.OPENAI_API_KEY, - ]), - ttsBaseUrl: firstDefined([ - openai?.tts?.baseUrl, - env.OPENAI_TTS_BASE_URL, - openai?.baseUrl, - env.OPENAI_BASE_URL, - ]), - ...buildOpenAiSttInput(params), - ...buildOpenAiTtsInput(params), + apiKey, + ...(baseUrl ? { baseUrl } : {}), + model: firstString(env.TTS_MODEL, config?.model) ?? DEFAULT_OPENAI_TTS_MODEL, + voice: firstString(env.TTS_VOICE, config?.voice) ?? "alloy", + responseFormat: "pcm", }; } @@ -155,46 +69,14 @@ export function resolveOpenAiSpeechConfig(params: { persisted: PersistedConfig; providers: RequestedSpeechProviders; }): OpenAiSpeechProviderConfig | undefined { - const input = buildOpenAiResolutionInput(params); - const keys = OpenAiEndpointKeysSchema.parse(input); - - if (!keys.sttApiKey && !keys.ttsApiKey) { - return undefined; - } - + const { env, persisted } = params; + const dictationStt = resolveStt(persisted.features?.dictation?.stt, env); + const stt = resolveStt(persisted.features?.voiceMode?.stt, env); + const tts = resolveTts(persisted.features?.voiceMode?.tts, env); + if (!dictationStt && !stt && !tts) return undefined; return { - ...(keys.sttApiKey ? { stt: buildSttConfig(keys.sttApiKey, keys.sttBaseUrl, input) } : {}), - ...(keys.ttsApiKey ? { tts: buildTtsConfig(keys.ttsApiKey, keys.ttsBaseUrl, input) } : {}), - }; -} - -function buildSttConfig( - apiKey: string, - baseUrl: string | undefined, - input: Record, -): OpenAiSpeechProviderConfig["stt"] { - const options = OpenAiSttOptionsSchema.parse(input); - return { - apiKey, - ...(baseUrl ? { baseUrl } : {}), - ...(options.sttConfidenceThreshold !== undefined - ? { confidenceThreshold: options.sttConfidenceThreshold } - : {}), - ...(options.sttModel ? { model: options.sttModel } : {}), - }; -} - -function buildTtsConfig( - apiKey: string, - baseUrl: string | undefined, - input: Record, -): OpenAiSpeechProviderConfig["tts"] { - const options = OpenAiTtsOptionsSchema.parse(input); - return { - apiKey, - ...(baseUrl ? { baseUrl } : {}), - voice: options.ttsVoice, - model: options.ttsModel, - responseFormat: "pcm", + ...(dictationStt ? { dictationStt } : {}), + ...(stt ? { stt } : {}), + ...(tts ? { tts } : {}), }; } diff --git a/packages/server/src/server/speech/providers/openai/runtime.test.ts b/packages/server/src/server/speech/providers/openai/runtime.test.ts index c947d86600..81b14ecaa0 100644 --- a/packages/server/src/server/speech/providers/openai/runtime.test.ts +++ b/packages/server/src/server/speech/providers/openai/runtime.test.ts @@ -1,6 +1,12 @@ +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; import pino from "pino"; -import { describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test } from "vitest"; +import { mergeSpeechSettingsPatch } from "../../speech-settings.js"; +import { PersistedConfigSchema } from "../../../persisted-config.js"; +import type { SpeechToTextProvider } from "../../speech-provider.js"; +import { resolveOpenAiSpeechConfig } from "./config.js"; import { initializeOpenAiSpeechServices } from "./runtime.js"; import { OpenAISTT } from "./stt.js"; import { OpenAITTS } from "./tts.js"; @@ -16,6 +22,7 @@ describe("initializeOpenAiSpeechServices", () => { }, openaiConfig: { stt: { apiKey: "sk-test" }, + dictationStt: { apiKey: "sk-test" }, tts: { apiKey: "sk-test" }, }, existing: { @@ -32,3 +39,116 @@ describe("initializeOpenAiSpeechServices", () => { expect(services.ttsService).toBeInstanceOf(OpenAITTS); }); }); + +// An OpenAI-compatible transcription endpoint that records the model each request asks for. +async function startTranscriptionServer(): Promise<{ + server: Server; + baseUrl: string; + models: string[]; + keys: (string | undefined)[]; + paths: (string | undefined)[]; +}> { + const models: string[] = []; + const keys: (string | undefined)[] = []; + const paths: (string | undefined)[] = []; + const server = createServer((request, response) => { + let body = ""; + request.on("data", (chunk: Buffer) => { + body += chunk.toString("latin1"); + }); + request.on("end", () => { + const model = /name="model"\r\n\r\n([^\r]*)/.exec(body)?.[1]; + models.push(model ?? ""); + keys.push(request.headers.authorization); + paths.push(request.url); + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ text: "hello" })); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as AddressInfo; + return { server, baseUrl: `http://127.0.0.1:${port}/v1`, models, keys, paths }; +} + +async function transcribeOnce(stt: SpeechToTextProvider | null): Promise { + if (!stt) throw new Error("STT service was not created"); + const session = stt.createSession({ logger: pino({ level: "silent" }), language: "en" }); + const transcript = new Promise((resolve, reject) => { + session.on("transcript", (event) => { + if (event.isFinal) resolve(); + }); + session.on("error", (error) => + reject(error instanceof Error ? error : new Error(String(error))), + ); + }); + await session.connect(); + session.appendPcm16(Buffer.alloc(3200)); + session.commit(); + await transcript; + session.close(); +} + +describe("OpenAI speech-to-text models", () => { + let server: Server | null = null; + + afterEach(async () => { + await new Promise((resolve) => (server ? server.close(() => resolve()) : resolve())); + server = null; + }); + + test("dictation and voice mode each transcribe with the model chosen for them", async () => { + const endpoint = await startTranscriptionServer(); + server = endpoint.server; + let persisted = PersistedConfigSchema.parse({ + features: { + dictation: { + stt: { + provider: "openai", + apiKey: "dictation-key", + baseUrl: `${endpoint.baseUrl}/dictation`, + model: "gpt-4o-mini-transcribe", + }, + }, + voiceMode: { + enabled: true, + stt: { + provider: "openai", + apiKey: "listening-key", + baseUrl: `${endpoint.baseUrl}/listening`, + model: "whisper-1", + }, + }, + }, + }); + persisted = mergeSpeechSettingsPatch(persisted, { + voiceMode: { stt: { provider: "openai", model: "whisper-1" } }, + }); + const providers = { + dictationStt: { provider: "openai", explicit: true }, + voiceTurnDetection: { provider: "local", explicit: false, enabled: false }, + voiceStt: { provider: "openai", explicit: true }, + voiceTts: { provider: "local", explicit: false }, + } as const; + + const services = initializeOpenAiSpeechServices({ + providers, + openaiConfig: resolveOpenAiSpeechConfig({ env: {}, persisted, providers }), + existing: { + turnDetectionService: null, + sttService: null, + ttsService: null, + dictationSttService: null, + }, + logger: pino({ level: "silent" }), + }); + await transcribeOnce(services.dictationSttService); + await transcribeOnce(services.sttService); + + expect(endpoint.models).toEqual(["gpt-4o-mini-transcribe", "whisper-1"]); + expect(endpoint.keys).toEqual(["Bearer dictation-key", "Bearer listening-key"]); + expect(endpoint.paths).toEqual([ + "/v1/dictation/audio/transcriptions", + "/v1/listening/audio/transcriptions", + ]); + }); +}); diff --git a/packages/server/src/server/speech/providers/openai/runtime.ts b/packages/server/src/server/speech/providers/openai/runtime.ts index a1231ddbe6..ce11c8879c 100644 --- a/packages/server/src/server/speech/providers/openai/runtime.ts +++ b/packages/server/src/server/speech/providers/openai/runtime.ts @@ -3,7 +3,11 @@ import type { Logger } from "pino"; import type { SpeechToTextProvider, TextToSpeechProvider } from "../../speech-provider.js"; import type { RequestedSpeechProviders } from "../../speech-types.js"; import type { TurnDetectionProvider } from "../../turn-detection-provider.js"; -import { DEFAULT_OPENAI_TTS_MODEL, type OpenAiSpeechProviderConfig } from "./config.js"; +import { + DEFAULT_OPENAI_TTS_MODEL, + type OpenAiSpeechProviderConfig, + type OpenAiSttFeature, +} from "./config.js"; import { OpenAISTT } from "./stt.js"; import { OpenAITTS } from "./tts.js"; @@ -33,7 +37,7 @@ function resolveOpenAiCredentials( return { openaiSttApiKey: sttApiKey, openaiTtsApiKey: openaiConfig?.tts?.apiKey, - openaiDictationApiKey: sttApiKey, + openaiDictationApiKey: openaiConfig?.dictationStt?.apiKey, }; } @@ -94,13 +98,16 @@ export function validateOpenAiCredentialRequirements(params: { } } -function createOpenAiStt( - apiKey: string, - openaiConfig: OpenAiSpeechProviderConfig | undefined, - logger: Logger, -): SpeechToTextProvider { - const { apiKey: _sttApiKey, ...sttConfig } = openaiConfig?.stt ?? {}; - return new OpenAISTT({ apiKey, ...sttConfig }, logger); +function createOpenAiStt(params: { + apiKey: string; + openaiConfig: OpenAiSpeechProviderConfig | undefined; + feature: OpenAiSttFeature; + logger: Logger; +}): SpeechToTextProvider { + const site = + params.feature === "dictation" ? params.openaiConfig?.dictationStt : params.openaiConfig?.stt; + const { apiKey: _sttApiKey, ...sttConfig } = site ?? {}; + return new OpenAISTT({ apiKey: params.apiKey, ...sttConfig }, params.logger); } function createOpenAiTts( @@ -154,7 +161,12 @@ export function initializeOpenAiSpeechServices(params: { logger.info("OpenAI speech provider initialized"); if (needsOpenAiStt && openAiCredentials.openaiSttApiKey) { - sttService = createOpenAiStt(openAiCredentials.openaiSttApiKey, openaiConfig, logger); + sttService = createOpenAiStt({ + apiKey: openAiCredentials.openaiSttApiKey, + openaiConfig, + feature: "voiceMode", + logger, + }); } if (needsOpenAiTts && openAiCredentials.openaiTtsApiKey) { @@ -162,11 +174,12 @@ export function initializeOpenAiSpeechServices(params: { } if (needsOpenAiDictation && openAiCredentials.openaiDictationApiKey) { - dictationSttService = createOpenAiStt( - openAiCredentials.openaiDictationApiKey, + dictationSttService = createOpenAiStt({ + apiKey: openAiCredentials.openaiDictationApiKey, openaiConfig, + feature: "dictation", logger, - ); + }); } } else if (needsAnyOpenAi) { // validateOpenAiCredentialRequirements already warned about missing credentials diff --git a/packages/server/src/server/speech/speech-config-resolver.test.ts b/packages/server/src/server/speech/speech-config-resolver.test.ts index 0ee9df888a..c83d2c45fa 100644 --- a/packages/server/src/server/speech/speech-config-resolver.test.ts +++ b/packages/server/src/server/speech/speech-config-resolver.test.ts @@ -6,7 +6,7 @@ import { PersistedConfigSchema } from "../persisted-config.js"; import { resolveSpeechConfig } from "./speech-config-resolver.js"; describe("resolveSpeechConfig", () => { - test("resolves local-first defaults without env overrides", () => { + test("resolves local-first defaults with voice mode off until turned on", () => { const paseoHome = "/tmp/paseo-home"; const persisted = PersistedConfigSchema.parse({}); const env = {} as NodeJS.ProcessEnv; @@ -26,17 +26,17 @@ describe("resolveSpeechConfig", () => { expect(result.speech.providers.voiceTurnDetection).toEqual({ provider: "local", explicit: false, - enabled: true, + enabled: false, }); expect(result.speech.providers.voiceStt).toEqual({ provider: "local", explicit: false, - enabled: true, + enabled: false, }); expect(result.speech.providers.voiceTts).toEqual({ provider: "local", explicit: false, - enabled: true, + enabled: false, }); expect(result.speech.local).toEqual({ modelsDir: path.join(paseoHome, "models", "local-speech"), @@ -61,13 +61,11 @@ describe("resolveSpeechConfig", () => { const persisted = PersistedConfigSchema.parse({ features: { voiceMode: { + enabled: true, turnDetection: { provider: "local" }, - stt: { provider: "openai", model: "gpt-4o-transcribe" }, + stt: { provider: "openai", apiKey: "persisted-key", model: "gpt-4o-transcribe" }, }, }, - providers: { - openai: { apiKey: "persisted-key" }, - }, }); const env = { PASEO_DICTATION_LOCAL_STT_MODEL: "parakeet-tdt-0.6b-v2-int8", @@ -130,40 +128,45 @@ describe("resolveSpeechConfig", () => { voice: "pt", }); expect(result.openai?.stt?.apiKey).toBe("persisted-key"); - expect(result.openai?.tts?.apiKey).toBe("persisted-key"); + expect(result.openai?.tts?.apiKey).toBe("env-key"); expect(result.openai?.stt?.model).toBe("gpt-4o-transcribe"); }); - test("resolves STT language from env, settings, and voice-to-dictation fallback", () => { - const persisted = PersistedConfigSchema.parse({ - features: { - dictation: { - stt: { - language: "fr", + test.each(["local", "openai"])( + "resolves STT language from env, settings, and voice-to-dictation fallback (%s)", + (provider) => { + const persisted = PersistedConfigSchema.parse({ + features: { + dictation: { + stt: { + provider, + language: "fr", + }, + }, + voiceMode: { + stt: { + provider, + language: "de", + }, }, }, - voiceMode: { - stt: { - language: "de", - }, - }, - }, - }); + }); - const result = resolveSpeechConfig({ - paseoHome: "/tmp/paseo-home", - env: { - PASEO_DICTATION_LANGUAGE: "es", - PASEO_VOICE_LANGUAGE: " ", - } as NodeJS.ProcessEnv, - persisted, - }); + const result = resolveSpeechConfig({ + paseoHome: "/tmp/paseo-home", + env: { + PASEO_DICTATION_LANGUAGE: "es", + PASEO_VOICE_LANGUAGE: " ", + } as NodeJS.ProcessEnv, + persisted, + }); - expect(result.speech.sttLanguages).toEqual({ - dictation: "es", - voice: "es", - }); - }); + expect(result.speech.sttLanguages).toEqual({ + dictation: "es", + voice: "es", + }); + }, + ); test("respects disabled dictation and voice mode feature flags", () => { const persisted = PersistedConfigSchema.parse({ diff --git a/packages/server/src/server/speech/speech-config-resolver.ts b/packages/server/src/server/speech/speech-config-resolver.ts index 95c78707a5..1257f46d4d 100644 --- a/packages/server/src/server/speech/speech-config-resolver.ts +++ b/packages/server/src/server/speech/speech-config-resolver.ts @@ -43,8 +43,13 @@ const RequestedSpeechProvidersSchema = z.object({ voiceTts: OptionalSpeechProviderSchema.default("local"), }); -function resolveOptionalBooleanFlag(value: unknown): boolean { - return OptionalBooleanFlagSchema.parse(value) ?? true; +// Dictation is on unless turned off. Voice mode is opt-in: it downloads its own models and +// runs a hidden agent, so a host only pays for it once someone turns it on. +export const DICTATION_ENABLED_BY_DEFAULT = true; +export const VOICE_MODE_ENABLED_BY_DEFAULT = false; + +function resolveOptionalBooleanFlag(value: unknown, fallback: boolean): boolean { + return OptionalBooleanFlagSchema.parse(value) ?? fallback; } interface FeatureProviderInputs { @@ -70,6 +75,7 @@ function buildFeatureProviderInputs(params: { params.env.PASEO_VOICE_MODE_ENABLED, params.persisted.features?.voiceMode?.enabled, ]), + VOICE_MODE_ENABLED_BY_DEFAULT, ); return { dictationStt: { @@ -82,6 +88,7 @@ function buildFeatureProviderInputs(params: { params.env.PASEO_DICTATION_ENABLED, params.persisted.features?.dictation?.enabled, ]), + DICTATION_ENABLED_BY_DEFAULT, ), }, voiceTurnDetection: { @@ -119,7 +126,7 @@ function buildRequestedFeatureProvider( }; } -function resolveRequestedSpeechProviders(params: { +export function resolveRequestedSpeechProviders(params: { env: NodeJS.ProcessEnv; persisted: PersistedConfig; }): RequestedSpeechProviders { @@ -173,6 +180,7 @@ export function resolveSpeechConfig(params: { openai, speech: { providers, + localModelsDir: local.modelsDir, sttLanguages: local.sttLanguages, ...(local.local ? { local: local.local } : {}), }, diff --git a/packages/server/src/server/speech/speech-runtime.test.ts b/packages/server/src/server/speech/speech-runtime.test.ts index c9c21b3e89..cd3580e64a 100644 --- a/packages/server/src/server/speech/speech-runtime.test.ts +++ b/packages/server/src/server/speech/speech-runtime.test.ts @@ -2,13 +2,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import pino from "pino"; import type { PaseoSpeechConfig } from "../bootstrap.js"; +import type { LocalSpeechModelId } from "./providers/local/models.js"; import type { InitializedLocalSpeech } from "./providers/local/runtime.js"; import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech-provider.js"; import type { TurnDetectionProvider } from "./turn-detection-provider.js"; -import { createSpeechService } from "./speech-runtime.js"; +import { createSpeechService, LocalSpeechModelInUseError } from "./speech-runtime.js"; -const { ensureLocalSpeechModelsMock, initializeLocalSpeechServicesMock } = vi.hoisted(() => ({ - ensureLocalSpeechModelsMock: vi.fn(async () => ({})), +const { ensureSherpaOnnxModelMock, initializeLocalSpeechServicesMock } = vi.hoisted(() => ({ + ensureSherpaOnnxModelMock: vi.fn(async (_options: { signal?: AbortSignal }) => ""), initializeLocalSpeechServicesMock: vi.fn<(args: unknown) => Promise>(), })); @@ -34,9 +35,10 @@ vi.mock("./providers/openai/runtime.js", () => ({ validateOpenAiCredentialRequirements: () => {}, })); -vi.mock("./providers/local/models.js", () => ({ - ensureLocalSpeechModels: ensureLocalSpeechModelsMock, - listMissingLocalSpeechModels: vi.fn(async ({ modelIds }: { modelIds: string[] }) => modelIds), +vi.mock("./providers/local/sherpa/model-downloader.js", () => ({ + ensureSherpaOnnxModel: ensureSherpaOnnxModelMock, + isSherpaOnnxModelInstalled: vi.fn(async () => false), + deleteSherpaOnnxModel: vi.fn(async () => {}), })); function createStubStt(id: string): SpeechToTextProvider { @@ -75,6 +77,7 @@ function rejectWhenAborted(signal?: AbortSignal): Promise { function createSpeechConfig(providers: PaseoSpeechConfig["providers"]): PaseoSpeechConfig { return { providers, + localModelsDir: "/tmp/missing-local-speech-models", sttLanguages: { dictation: "en", voice: "en", @@ -84,8 +87,8 @@ function createSpeechConfig(providers: PaseoSpeechConfig["providers"]): PaseoSpe describe("createSpeechService readiness", () => { beforeEach(() => { - ensureLocalSpeechModelsMock.mockReset(); - ensureLocalSpeechModelsMock.mockResolvedValue({}); + ensureSherpaOnnxModelMock.mockReset(); + ensureSherpaOnnxModelMock.mockResolvedValue(""); initializeLocalSpeechServicesMock.mockReset(); }); @@ -169,7 +172,7 @@ describe("createSpeechService readiness", () => { it("aborts and joins an in-flight model download when stopped", async () => { let downloadSignal: AbortSignal | undefined; - ensureLocalSpeechModelsMock.mockImplementation(({ signal }: { signal?: AbortSignal }) => { + ensureSherpaOnnxModelMock.mockImplementation(({ signal }: { signal?: AbortSignal }) => { downloadSignal = signal; return rejectWhenAborted(signal); }); @@ -201,10 +204,104 @@ describe("createSpeechService readiness", () => { }); runtime.start(); await runtime.ready; - expect(ensureLocalSpeechModelsMock).toHaveBeenCalledOnce(); + expect(ensureSherpaOnnxModelMock).toHaveBeenCalledOnce(); await runtime.stop(); expect(downloadSignal?.aborted).toBe(true); }); }); + +describe("createSpeechService settings changes", () => { + const dictationOnly = createSpeechConfig({ + dictationStt: { provider: "local", enabled: true, explicit: true }, + voiceTurnDetection: { provider: "local", enabled: false, explicit: true }, + voiceStt: { provider: "local", enabled: false, explicit: true }, + voiceTts: { provider: "local", enabled: false, explicit: true }, + }); + const nothingEnabled = createSpeechConfig({ + dictationStt: { provider: "local", enabled: false, explicit: true }, + voiceTurnDetection: { provider: "local", enabled: false, explicit: true }, + voiceStt: { provider: "local", enabled: false, explicit: true }, + voiceTts: { provider: "local", enabled: false, explicit: true }, + }); + + function localSpeech(input: { + dictationStt: SpeechToTextProvider | null; + defaultModelIds: LocalSpeechModelId[]; + }): InitializedLocalSpeech { + return { + turnDetectionService: null, + sttService: null, + ttsService: null, + dictationSttService: input.dictationStt, + localVoiceTtsProvider: null, + localModelConfig: { + modelsDir: "/tmp/missing-local-speech-models", + defaultModelIds: input.defaultModelIds, + }, + availability: { configured: true, modelsDir: "/tmp/missing-local-speech-models" }, + cleanup: () => {}, + }; + } + + beforeEach(() => { + ensureSherpaOnnxModelMock.mockReset(); + ensureSherpaOnnxModelMock.mockResolvedValue(""); + initializeLocalSpeechServicesMock.mockReset(); + }); + + it("applies new providers without a restart", async () => { + initializeLocalSpeechServicesMock.mockResolvedValue( + localSpeech({ dictationStt: createStubStt("dictation-local"), defaultModelIds: [] }), + ); + const runtime = createSpeechService({ + logger: pino({ level: "silent" }), + speechConfig: dictationOnly, + }); + runtime.start(); + await runtime.ready; + expect(runtime.getReadiness().dictation.available).toBe(true); + + const disabled = new Promise((resolve) => { + runtime.onReadinessChange((snapshot) => { + if (snapshot.dictation.reasonCode === "disabled") resolve(); + }); + }); + initializeLocalSpeechServicesMock.mockResolvedValue( + localSpeech({ dictationStt: null, defaultModelIds: [] }), + ); + runtime.reconfigure({ speechConfig: nothingEnabled }); + await disabled; + + expect(runtime.resolveDictationStt()).toBeNull(); + await runtime.stop(); + }); + + it("refuses to delete a model an enabled feature uses", async () => { + initializeLocalSpeechServicesMock.mockResolvedValue( + localSpeech({ + dictationStt: createStubStt("dictation-local"), + defaultModelIds: ["parakeet-tdt-0.6b-v2-int8"], + }), + ); + ensureSherpaOnnxModelMock.mockImplementation(({ signal }) => rejectWhenAborted(signal)); + const runtime = createSpeechService({ + logger: pino({ level: "silent" }), + speechConfig: dictationOnly, + }); + runtime.start(); + await runtime.ready; + + const models = await runtime.listLocalModels(); + expect(models.filter((model) => model.inUse).map((model) => model.spec.id)).toEqual([ + "parakeet-tdt-0.6b-v2-int8", + ]); + await expect(runtime.deleteLocalModel("parakeet-tdt-0.6b-v2-int8")).rejects.toBeInstanceOf( + LocalSpeechModelInUseError, + ); + await expect(runtime.deleteLocalModel("kokoro-int8-en-v0_19")).resolves.toBeUndefined(); + + await runtime.stop(); + }); +}); diff --git a/packages/server/src/server/speech/speech-runtime.ts b/packages/server/src/server/speech/speech-runtime.ts index 932f87c077..270b13b03c 100644 --- a/packages/server/src/server/speech/speech-runtime.ts +++ b/packages/server/src/server/speech/speech-runtime.ts @@ -2,7 +2,10 @@ import type { Logger } from "pino"; import type { PaseoOpenAIConfig, PaseoSpeechConfig } from "../bootstrap.js"; import type { LocalSpeechModelId } from "./providers/local/config.js"; -import { ensureLocalSpeechModels, listMissingLocalSpeechModels } from "./providers/local/models.js"; +import { + LocalSpeechModelLibrary, + type LocalSpeechModelEntry, +} from "./providers/local/model-library.js"; import { initializeLocalSpeechServices } from "./providers/local/runtime.js"; import { getOpenAiSpeechAvailability, @@ -92,7 +95,7 @@ function buildRealtimeVoiceReadiness(params: { enabled: false, available: false, reasonCode: "disabled", - message: "Realtime voice is disabled in daemon config.", + message: "Voice mode is off. Turn it on in Settings > Voice.", retryable: false, missingModelIds: [], }; @@ -147,7 +150,7 @@ function buildDictationReadiness(params: { enabled: false, available: false, reasonCode: "disabled", - message: "Dictation is disabled in daemon config.", + message: "Dictation is off. Turn it on in Settings > Voice.", retryable: false, missingModelIds: [], }; @@ -292,6 +295,23 @@ function resolveEffectiveProviderIds(params: { }; } +export interface LocalSpeechModelStatus extends LocalSpeechModelEntry { + // Selected by an enabled feature, so the runtime keeps it installed. + inUse: boolean; +} + +export class LocalSpeechModelInUseError extends Error { + constructor(public readonly modelId: LocalSpeechModelId) { + super(`${modelId} is selected for dictation or voice mode. Choose another model first.`); + this.name = "LocalSpeechModelInUseError"; + } +} + +export interface SpeechServiceConfig { + openaiConfig?: PaseoOpenAIConfig; + speechConfig?: PaseoSpeechConfig; +} + export interface SpeechService { resolveStt: () => SpeechToTextProvider | null; resolveSttLanguage: () => string; @@ -301,37 +321,49 @@ export interface SpeechService { resolveDictationSttLanguage: () => string; getReadiness: () => SpeechReadinessSnapshot; onReadinessChange: (listener: (snapshot: SpeechReadinessSnapshot) => void) => () => void; + // Applies new provider/model settings without restarting the daemon. + reconfigure: (config: SpeechServiceConfig) => void; + listLocalModels: () => Promise; + downloadLocalModel: (modelId: LocalSpeechModelId) => void; + deleteLocalModel: (modelId: LocalSpeechModelId) => Promise; start: () => void; stop: () => Promise; ready: Promise; } -export function createSpeechService(params: { - logger: Logger; - openaiConfig?: PaseoOpenAIConfig; - speechConfig?: PaseoSpeechConfig; -}): SpeechService { +export function createSpeechService( + params: SpeechServiceConfig & { + logger: Logger; + }, +): SpeechService { const logger = params.logger.child({ module: "speech-runtime" }); - const speechConfig = params.speechConfig ?? null; - const openaiConfig = params.openaiConfig; - const providers = resolveRequestedSpeechProviders(speechConfig); - const requestedProviders = describeRequestedProviders(providers); + let speechConfig = params.speechConfig ?? null; + let openaiConfig = params.openaiConfig; + let providers = resolveRequestedSpeechProviders(speechConfig); + let requestedProviders = describeRequestedProviders(providers); + // The models directory is fixed for the daemon's lifetime; settings changes only pick + // which of its models are used. + const modelLibrary = speechConfig + ? new LocalSpeechModelLibrary(speechConfig.localModelsDir, logger) + : null; - validateOpenAiCredentialRequirements({ - providers, - openaiConfig, - logger, - }); - - logger.info( - { - requestedProviders, - availability: { - openai: getOpenAiSpeechAvailability(openaiConfig), + const logReconciliationStart = (): void => { + validateOpenAiCredentialRequirements({ + providers, + openaiConfig, + logger, + }); + logger.info( + { + requestedProviders, + availability: { + openai: getOpenAiSpeechAvailability(openaiConfig), + }, }, - }, - "Speech provider reconciliation started", - ); + "Speech provider reconciliation started", + ); + }; + logReconciliationStart(); let sttService: SpeechToTextProvider | null = null; let ttsService: TextToSpeechProvider | null = null; @@ -346,12 +378,11 @@ export function createSpeechService(params: { let missingLocalModelIds: LocalSpeechModelId[] = []; let backgroundDownloadInProgress = false; - let backgroundDownloadAbortController: AbortController | null = null; let backgroundDownloadPromise: Promise | null = null; let backgroundDownloadError: string | null = null; let stopped = false; let monitorTimeout: ReturnType | null = null; - let reconcileInFlight: Promise | null = null; + let reconcileQueue: Promise = Promise.resolve(); const readinessListeners = new Set<(snapshot: SpeechReadinessSnapshot) => void>(); let lastReadinessFingerprint: string | null = null; let lastPublishedReadinessSnapshot: SpeechReadinessSnapshot | null = null; @@ -445,12 +476,10 @@ export function createSpeechService(params: { }; const refreshMissingLocalModels = async (): Promise => { - missingLocalModelIds = localModelConfig - ? await listMissingLocalSpeechModels({ - modelsDir: localModelConfig.modelsDir, - modelIds: localModelConfig.defaultModelIds, - }) - : []; + missingLocalModelIds = + localModelConfig && modelLibrary + ? await modelLibrary.listMissing(localModelConfig.defaultModelIds) + : []; }; const reconcileServices = async (): Promise => { @@ -520,16 +549,12 @@ export function createSpeechService(params: { } }; + // Reconciles run one after another, and each starts after it was requested, so a + // reconfigure that lands mid-reconcile is applied instead of joining the stale run. const runReconcile = async (): Promise => { - if (reconcileInFlight) { - await reconcileInFlight; - publishReadinessIfChanged(); - return; - } - reconcileInFlight = reconcileServices().finally(() => { - reconcileInFlight = null; - }); - await reconcileInFlight; + const next = reconcileQueue.catch(() => undefined).then(reconcileServices); + reconcileQueue = next; + await next; publishReadinessIfChanged(); }; @@ -544,12 +569,12 @@ export function createSpeechService(params: { }; const startBackgroundDownload = (): void => { - if (stopped || backgroundDownloadInProgress) { + if (stopped || backgroundDownloadInProgress || !modelLibrary) { return; } - const modelsDir = localModelConfig?.modelsDir ?? null; + const modelsDir = modelLibrary.getModelsDir(); const modelIds = [...missingLocalModelIds]; - if (!modelsDir || modelIds.length === 0) { + if (modelIds.length === 0) { return; } @@ -565,16 +590,9 @@ export function createSpeechService(params: { "Starting background download for missing local speech models", ); - const abortController = new AbortController(); - backgroundDownloadAbortController = abortController; backgroundDownloadPromise = (async () => { try { - await ensureLocalSpeechModels({ - modelsDir, - modelIds, - logger, - signal: abortController.signal, - }); + await Promise.all(modelIds.map((modelId) => modelLibrary.download(modelId))); await runReconcile(); backgroundDownloadError = null; } catch (error) { @@ -588,7 +606,6 @@ export function createSpeechService(params: { "Background local speech model download failed", ); } finally { - backgroundDownloadAbortController = null; backgroundDownloadInProgress = false; await refreshMissingLocalModels().catch((error) => { logger.warn({ err: error }, "Failed to refresh local speech model status after download"); @@ -630,6 +647,62 @@ export function createSpeechService(params: { } }; + const reconcileAndFetchMissingModels = async (): Promise => { + await runReconcile(); + const snapshot = computeReadinessSnapshot(); + if (snapshot.voiceFeature.enabled && !snapshot.voiceFeature.available) { + if (missingLocalModelIds.length > 0) { + startBackgroundDownload(); + } + scheduleMonitor(); + } + }; + + const reconfigure = (next: SpeechServiceConfig): void => { + speechConfig = next.speechConfig ?? null; + openaiConfig = next.openaiConfig; + providers = resolveRequestedSpeechProviders(speechConfig); + requestedProviders = describeRequestedProviders(providers); + // A failure for the previous selection must not block downloading the new one. + backgroundDownloadError = null; + logReconciliationStart(); + if (!started || stopped) { + return; + } + void reconcileAndFetchMissingModels().catch((error) => { + logger.error({ err: error }, "Speech runtime failed to apply new configuration"); + }); + }; + + const requireModelLibrary = (): LocalSpeechModelLibrary => { + if (!modelLibrary) { + throw new Error("Local speech models are unavailable on this daemon"); + } + return modelLibrary; + }; + + const listLocalModels = async (): Promise => { + const inUse = new Set(localModelConfig?.defaultModelIds ?? []); + const entries = await requireModelLibrary().list(); + return entries.map(({ spec, state }) => ({ spec, state, inUse: inUse.has(spec.id) })); + }; + + const downloadLocalModel = (modelId: LocalSpeechModelId): void => { + void requireModelLibrary() + .download(modelId) + .then(() => runMonitorTick()) + .catch((error) => { + logger.warn({ err: error, modelId }, "Local speech model download failed"); + }); + }; + + const deleteLocalModel = async (modelId: LocalSpeechModelId): Promise => { + if (localModelConfig?.defaultModelIds.includes(modelId)) { + throw new LocalSpeechModelInUseError(modelId); + } + await requireModelLibrary().delete(modelId); + }; + const start = (): void => { if (started || stopped) { return; @@ -637,14 +710,7 @@ export function createSpeechService(params: { started = true; void (async () => { try { - await runReconcile(); - const snapshot = computeReadinessSnapshot(); - if (snapshot.voiceFeature.enabled && !snapshot.voiceFeature.available) { - if (missingLocalModelIds.length > 0) { - startBackgroundDownload(); - } - scheduleMonitor(); - } + await reconcileAndFetchMissingModels(); if (!readySettled) { readySettled = true; resolveReady(); @@ -666,7 +732,7 @@ export function createSpeechService(params: { monitorTimeout = null; } localCleanup(); - backgroundDownloadAbortController?.abort(); + await modelLibrary?.stop(); await backgroundDownloadPromise?.catch(() => undefined); backgroundDownloadPromise = null; }; @@ -680,6 +746,10 @@ export function createSpeechService(params: { resolveDictationSttLanguage: () => speechConfig?.sttLanguages?.dictation ?? "en", getReadiness: () => lastPublishedReadinessSnapshot ?? computeReadinessSnapshot(), onReadinessChange: subscribeSpeechReadiness, + reconfigure, + listLocalModels, + downloadLocalModel, + deleteLocalModel, start, stop, ready, diff --git a/packages/server/src/server/speech/speech-settings.ts b/packages/server/src/server/speech/speech-settings.ts new file mode 100644 index 0000000000..31995c46f6 --- /dev/null +++ b/packages/server/src/server/speech/speech-settings.ts @@ -0,0 +1,202 @@ +import type { SpeechSettings, SpeechSettingsPatch } from "@getpaseo/protocol/speech/rpc-schemas"; + +import { + PersistedConfigSchema, + SpeechSttConfigSchema, + SpeechTtsConfigSchema, + type PersistedConfig, +} from "../persisted-config.js"; +import { resolveRequestedSpeechProviders, resolveSpeechConfig } from "./speech-config-resolver.js"; +import type { SpeechService } from "./speech-runtime.js"; +import { LocalSttModelIdSchema, LocalTtsModelIdSchema } from "./providers/local/models.js"; + +interface SpeechRead { + features: SpeechSettings; + envOverrides: Record; +} + +// Owns the per-site wire boundary: credential redaction, env provenance and patch +// semantics. The store never assembles or merges provider objects itself. +export interface SpeechSettingsOwner { + read(persisted: PersistedConfig): SpeechRead; + apply(persisted: PersistedConfig): void; +} + +export function createSpeechSettingsOwner(params: { + paseoHome: string; + env: NodeJS.ProcessEnv; + speechService: Pick; +}): SpeechSettingsOwner { + const { paseoHome, env, speechService } = params; + return { + read: (persisted) => readSpeechSettings(persisted, env), + apply: (persisted) => { + const resolved = resolveSpeechConfig({ paseoHome, env, persisted }); + speechService.reconfigure({ openaiConfig: resolved.openai, speechConfig: resolved.speech }); + }, + }; +} + +export function readSpeechSettings(persisted: PersistedConfig, env: NodeJS.ProcessEnv): SpeechRead { + const dictation = persisted.features?.dictation; + const voiceMode = persisted.features?.voiceMode; + const providers = resolveRequestedSpeechProviders({ persisted, env }); + const envOverrides: Record = {}; + const recordOverride = (path: string, names: string[], fallback?: unknown) => { + if (fallback !== undefined) return; + const name = names.find((candidate) => env[candidate]?.trim()); + if (name) envOverrides[path] = name; + }; + recordOverride("features.dictation.enabled", ["PASEO_DICTATION_ENABLED"]); + recordOverride("features.voiceMode.enabled", ["PASEO_VOICE_MODE_ENABLED"]); + recordOverride("features.voiceMode.turnDetection.provider", [ + "PASEO_VOICE_TURN_DETECTION_PROVIDER", + ]); + + function readSite( + site: Record | undefined, + provider: string, + path: string, + kind: "stt" | "tts", + providerEnv: string, + localModelEnv: string, + ) { + recordOverride(`${path}.provider`, [providerEnv]); + const configured = site?.provider === provider ? site : undefined; + const { apiKey, ...fields } = configured ?? {}; + const openaiModelEnv = kind === "stt" ? "STT_MODEL" : "TTS_MODEL"; + recordOverride(`${path}.model`, [provider === "openai" ? openaiModelEnv : localModelEnv]); + if (kind === "stt") { + const languageVars = + path === "features.dictation.stt" + ? ["PASEO_DICTATION_LANGUAGE"] + : ["PASEO_VOICE_LANGUAGE", "PASEO_DICTATION_LANGUAGE"]; + recordOverride(`${path}.language`, languageVars); + } + if (provider === "openai") { + for (const [field, suffix] of [ + ["apiKey", "API_KEY"], + ["baseUrl", "BASE_URL"], + ] as const) { + recordOverride( + `${path}.${field}`, + [`OPENAI_${kind.toUpperCase()}_${suffix}`, `OPENAI_${suffix}`], + configured?.[field], + ); + } + if (kind === "stt") { + recordOverride(`${path}.confidenceThreshold`, ["STT_CONFIDENCE_THRESHOLD"]); + } else recordOverride(`${path}.voice`, ["TTS_VOICE"]); + } else if (kind === "tts") { + recordOverride(`${path}.speakerId`, ["PASEO_VOICE_LOCAL_TTS_SPEAKER_ID"]); + recordOverride(`${path}.speed`, ["PASEO_VOICE_LOCAL_TTS_SPEED"]); + } + return { + ...fields, + provider, + ...(provider === "openai" + ? { hasApiKey: typeof apiKey === "string" && apiKey.trim().length > 0 } + : {}), + }; + } + + return { + features: { + dictation: { + enabled: providers.dictationStt.enabled !== false, + stt: readSite( + dictation?.stt, + providers.dictationStt.provider, + "features.dictation.stt", + "stt", + "PASEO_DICTATION_STT_PROVIDER", + "PASEO_DICTATION_LOCAL_STT_MODEL", + ), + }, + voiceMode: { + enabled: providers.voiceStt.enabled !== false, + ...(voiceMode?.llm ? { llm: voiceMode.llm } : {}), + ...(voiceMode?.turnDetection + ? { + turnDetection: { + ...voiceMode.turnDetection, + provider: providers.voiceTurnDetection.provider, + }, + } + : {}), + stt: readSite( + voiceMode?.stt, + providers.voiceStt.provider, + "features.voiceMode.stt", + "stt", + "PASEO_VOICE_STT_PROVIDER", + "PASEO_VOICE_LOCAL_STT_MODEL", + ), + tts: readSite( + voiceMode?.tts, + providers.voiceTts.provider, + "features.voiceMode.tts", + "tts", + "PASEO_VOICE_TTS_PROVIDER", + "PASEO_VOICE_LOCAL_TTS_MODEL", + ), + }, + }, + envOverrides, + }; +} + +function mergeFields(current: Record | undefined, patch: Record) { + const next = { ...current }; + for (const [key, value] of Object.entries(patch)) { + if (value === undefined) continue; + if (value === null || (typeof value === "string" && !value.trim())) delete next[key]; + else next[key] = typeof value === "string" ? value.trim() : value; + } + return next; +} + +function mergeSite( + current: Record | undefined, + patch: Record, + kind: "stt" | "tts", +) { + const providerChanged = + patch.provider !== undefined && patch.provider !== (current?.provider ?? "local"); + const next = mergeFields(providerChanged ? undefined : current, patch); + next.provider ??= "local"; + const site = (kind === "stt" ? SpeechSttConfigSchema : SpeechTtsConfigSchema).parse(next); + if (site.provider === "local" && site.model !== undefined) { + const schema = kind === "stt" ? LocalSttModelIdSchema : LocalTtsModelIdSchema; + if (!schema.safeParse(site.model).success) { + throw new Error( + `Unknown local ${kind === "stt" ? "speech-to-text" : "voice"} model: ${site.model}`, + ); + } + } + return site; +} + +export function mergeSpeechSettingsPatch( + persisted: PersistedConfig, + patch: SpeechSettingsPatch, +): PersistedConfig { + const features = { ...persisted.features }; + if (patch.dictation) { + const { stt, ...rest } = patch.dictation; + const next = mergeFields(features.dictation, rest); + if (stt) next.stt = mergeSite(features.dictation?.stt, stt, "stt"); + features.dictation = PersistedConfigSchema.shape.features.unwrap().shape.dictation.parse(next); + } + if (patch.voiceMode) { + const { stt, tts, llm, turnDetection, ...rest } = patch.voiceMode; + const next = mergeFields(features.voiceMode, rest); + if (stt) next.stt = mergeSite(features.voiceMode?.stt, stt, "stt"); + if (tts) next.tts = mergeSite(features.voiceMode?.tts, tts, "tts"); + if (llm) next.llm = mergeFields(features.voiceMode?.llm, llm); + if (turnDetection) + next.turnDetection = mergeFields(features.voiceMode?.turnDetection, turnDetection); + features.voiceMode = PersistedConfigSchema.shape.features.unwrap().shape.voiceMode.parse(next); + } + return { ...persisted, features }; +} diff --git a/packages/server/src/server/test-utils/paseo-daemon.ts b/packages/server/src/server/test-utils/paseo-daemon.ts index 289d556bec..a1f8369b59 100644 --- a/packages/server/src/server/test-utils/paseo-daemon.ts +++ b/packages/server/src/server/test-utils/paseo-daemon.ts @@ -39,7 +39,8 @@ interface TestPaseoDaemonOptions { staticDir?: string; cleanup?: boolean; openai?: PaseoOpenAIConfig; - speech?: PaseoSpeechConfig; + // localModelsDir defaults to the local provider's modelsDir, then to a dir under paseoHome. + speech?: Omit & { localModelsDir?: string }; voiceLlmProvider?: PaseoDaemonConfig["voiceLlmProvider"]; voiceLlmProviderExplicit?: boolean; voiceLlmModel?: string | null; @@ -209,7 +210,15 @@ async function prepareTestDaemonConfig( webUi: options.webUi, trustedProxies: options.trustedProxies, openai: options.openai, - speech: options.speech, + speech: options.speech + ? { + ...options.speech, + localModelsDir: + options.speech.localModelsDir ?? + options.speech.local?.modelsDir ?? + path.join(paseoHome, "models", "local-speech"), + } + : undefined, voiceLlmProvider: options.voiceLlmProvider ?? null, voiceLlmProviderExplicit: options.voiceLlmProviderExplicit ?? false, voiceLlmModel: options.voiceLlmModel ?? null, diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 88fb2c327d..53172b2835 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1620,6 +1620,7 @@ export class VoiceAssistantWebSocketServer { stt: () => this.speech?.resolveStt() ?? null, sttLanguage: this.speech?.resolveSttLanguage() ?? "en", tts: () => this.speech?.resolveTts() ?? null, + speechModels: this.speech, terminalManager: this.terminalManager, providerSnapshotManager: this.providerSnapshotManager, hubExecutionAgents: options.hubExecutionAgents, @@ -1991,6 +1992,8 @@ export class VoiceAssistantWebSocketServer { ...(this.advertiseDaemonStatusRpc ? { daemonStatusRpc: true } : {}), // COMPAT(daemonConfigReload): added in v0.4.0, remove gate after 2027-02-14. daemonConfigReload: true, + // COMPAT(speechSettings): added in v0.12.0, remove gate after 2027-04-18. + speechSettings: true, // COMPAT(relayConfig): added in v0.2.6, remove gate after 2027-01-31. ...(this.advertiseRelayConfig ? { relayConfig: true } : {}), // COMPAT(pushTokenRevocation): added in v0.3.2, remove gate after 2027-02-10. @@ -2008,6 +2011,8 @@ export class VoiceAssistantWebSocketServer { pluginTimelineItems: true, // COMPAT(skillManagement): added in v0.4.0, remove gate after 2027-08-16. skillManagement: true, + // COMPAT(idleRuntimeCleanupSetting): added in v0.13.0, remove gate after 2027-04-26. + idleRuntimeCleanupSetting: true, // COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23. "terminal-restore-modes": true, // COMPAT(terminalInputModeReplay): added in v0.2.6, remove gate after 2027-02-02. diff --git a/packages/website/package.json b/packages/website/package.json index 852d40d0ab..2166f1a22e 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.13.0", + "version": "0.13.2", "private": true, "type": "module", "scripts": { diff --git a/packages/website/public/schemas/paseo.config.v1.json b/packages/website/public/schemas/paseo.config.v1.json index 7e34e35e5c..c1c32ed20a 100644 --- a/packages/website/public/schemas/paseo.config.v1.json +++ b/packages/website/public/schemas/paseo.config.v1.json @@ -257,48 +257,6 @@ "providers": { "type": "object", "properties": { - "openai": { - "type": "object", - "properties": { - "apiKey": { - "type": "string", - "minLength": 1 - }, - "baseUrl": { - "type": "string", - "minLength": 1 - }, - "stt": { - "type": "object", - "properties": { - "apiKey": { - "type": "string", - "minLength": 1 - }, - "baseUrl": { - "type": "string", - "minLength": 1 - } - }, - "additionalProperties": false - }, - "tts": { - "type": "object", - "properties": { - "apiKey": { - "type": "string", - "minLength": 1 - }, - "baseUrl": { - "type": "string", - "minLength": 1 - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, "local": { "type": "object", "properties": { @@ -552,6 +510,9 @@ } } }, + "idleRuntimeCleanup": { + "type": "boolean" + }, "catalogRefreshTimeoutMs": { "type": "integer", "exclusiveMinimum": 0, @@ -636,24 +597,57 @@ "type": "boolean" }, "stt": { - "type": "object", - "properties": { - "provider": { - "type": "string" + "oneOf": [ + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "const": "local" + }, + "model": { + "type": "string", + "minLength": 1 + }, + "language": { + "type": "string", + "minLength": 1 + } + }, + "required": ["provider"], + "additionalProperties": false }, - "model": { - "type": "string", - "minLength": 1 - }, - "language": { - "type": "string", - "minLength": 1 - }, - "confidenceThreshold": { - "type": "number" + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "const": "openai" + }, + "apiKey": { + "type": "string", + "minLength": 1 + }, + "baseUrl": { + "type": "string", + "minLength": 1 + }, + "model": { + "type": "string", + "minLength": 1 + }, + "language": { + "type": "string", + "minLength": 1 + }, + "confidenceThreshold": { + "type": "number" + } + }, + "required": ["provider"], + "additionalProperties": false } - }, - "additionalProperties": false + ] } }, "additionalProperties": false @@ -678,21 +672,57 @@ "additionalProperties": false }, "stt": { - "type": "object", - "properties": { - "provider": { - "type": "string" + "oneOf": [ + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "const": "local" + }, + "model": { + "type": "string", + "minLength": 1 + }, + "language": { + "type": "string", + "minLength": 1 + } + }, + "required": ["provider"], + "additionalProperties": false }, - "model": { - "type": "string", - "minLength": 1 - }, - "language": { - "type": "string", - "minLength": 1 + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "const": "openai" + }, + "apiKey": { + "type": "string", + "minLength": 1 + }, + "baseUrl": { + "type": "string", + "minLength": 1 + }, + "model": { + "type": "string", + "minLength": 1 + }, + "language": { + "type": "string", + "minLength": 1 + }, + "confidenceThreshold": { + "type": "number" + } + }, + "required": ["provider"], + "additionalProperties": false } - }, - "additionalProperties": false + ] }, "turnDetection": { "type": "object", @@ -704,29 +734,58 @@ "additionalProperties": false }, "tts": { - "type": "object", - "properties": { - "provider": { - "type": "string" + "oneOf": [ + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "const": "local" + }, + "model": { + "type": "string", + "minLength": 1 + }, + "speakerId": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "speed": { + "type": "number" + } + }, + "required": ["provider"], + "additionalProperties": false }, - "model": { - "type": "string", - "minLength": 1 - }, - "voice": { - "type": "string", - "minLength": 1 - }, - "speakerId": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991 - }, - "speed": { - "type": "number" + { + "type": "object", + "properties": { + "provider": { + "type": "string", + "const": "openai" + }, + "apiKey": { + "type": "string", + "minLength": 1 + }, + "baseUrl": { + "type": "string", + "minLength": 1 + }, + "model": { + "type": "string", + "minLength": 1 + }, + "voice": { + "type": "string", + "minLength": 1 + } + }, + "required": ["provider"], + "additionalProperties": false } - }, - "additionalProperties": false + ] } }, "additionalProperties": false diff --git a/patches/@gorhom+bottom-sheet+5.2.14.patch b/patches/@gorhom+bottom-sheet+5.2.14.patch index f84d46f8fd..e0af7b8dcb 100644 --- a/patches/@gorhom+bottom-sheet+5.2.14.patch +++ b/patches/@gorhom+bottom-sheet+5.2.14.patch @@ -13,6 +13,27 @@ index ee51e76..dc1bc17 100644 if (_providedOnChange) { _providedOnChange(_index, _position, _type); } +diff --git a/node_modules/@gorhom/bottom-sheet/lib/commonjs/hooks/useAnimatedLayout.js b/node_modules/@gorhom/bottom-sheet/lib/commonjs/hooks/useAnimatedLayout.js +index 973d05c..b484b5f 100644 +--- a/node_modules/@gorhom/bottom-sheet/lib/commonjs/hooks/useAnimatedLayout.js ++++ b/node_modules/@gorhom/bottom-sheet/lib/commonjs/hooks/useAnimatedLayout.js +@@ -86,7 +86,7 @@ function useAnimatedLayout(containerLayoutState, topInset, bottomInset, modal, s + }); + }, [state, verticalInset, modal]); + (0, _react.useEffect)(() => { +- _reactNative.Dimensions.addEventListener('change', ({ ++ const subscription = _reactNative.Dimensions.addEventListener('change', ({ + window + }) => { + state.modify(_state => { +@@ -96,6 +96,7 @@ function useAnimatedLayout(containerLayoutState, topInset, bottomInset, modal, s + return _state; + }); + }); ++ return () => subscription.remove(); + }, [state]); + //#endregion + diff --git a/node_modules/@gorhom/bottom-sheet/lib/commonjs/index.js b/node_modules/@gorhom/bottom-sheet/lib/commonjs/index.js index e44ad86..96555ef 100644 --- a/node_modules/@gorhom/bottom-sheet/lib/commonjs/index.js @@ -54,6 +75,27 @@ index 94fabdb..60ca66a 100644 if (_providedOnChange) { _providedOnChange(_index, _position, _type); } +diff --git a/node_modules/@gorhom/bottom-sheet/lib/module/hooks/useAnimatedLayout.js b/node_modules/@gorhom/bottom-sheet/lib/module/hooks/useAnimatedLayout.js +index b0eec93..67713a2 100644 +--- a/node_modules/@gorhom/bottom-sheet/lib/module/hooks/useAnimatedLayout.js ++++ b/node_modules/@gorhom/bottom-sheet/lib/module/hooks/useAnimatedLayout.js +@@ -82,7 +82,7 @@ export function useAnimatedLayout(containerLayoutState, topInset, bottomInset, m + }); + }, [state, verticalInset, modal]); + useEffect(() => { +- Dimensions.addEventListener('change', ({ ++ const subscription = Dimensions.addEventListener('change', ({ + window + }) => { + state.modify(_state => { +@@ -92,6 +92,7 @@ export function useAnimatedLayout(containerLayoutState, topInset, bottomInset, m + return _state; + }); + }); ++ return () => subscription.remove(); + }, [state]); + //#endregion + diff --git a/node_modules/@gorhom/bottom-sheet/lib/module/index.js b/node_modules/@gorhom/bottom-sheet/lib/module/index.js index 8259637..eb057fe 100644 --- a/node_modules/@gorhom/bottom-sheet/lib/module/index.js @@ -101,6 +143,26 @@ index 2954b3f..7260656 100644 if (_providedOnChange) { _providedOnChange(_index, _position, _type); +diff --git a/node_modules/@gorhom/bottom-sheet/src/hooks/useAnimatedLayout.ts b/node_modules/@gorhom/bottom-sheet/src/hooks/useAnimatedLayout.ts +index 68c674d..564833c 100644 +--- a/node_modules/@gorhom/bottom-sheet/src/hooks/useAnimatedLayout.ts ++++ b/node_modules/@gorhom/bottom-sheet/src/hooks/useAnimatedLayout.ts +@@ -106,13 +106,14 @@ export function useAnimatedLayout( + [state, verticalInset, modal] + ); + useEffect(() => { +- Dimensions.addEventListener('change', ({ window }) => { ++ const subscription = Dimensions.addEventListener('change', ({ window }) => { + state.modify(_state => { + 'worklet'; + _state.window = window; + return _state; + }); + }); ++ return () => subscription.remove(); + }, [state]); + //#endregion + diff --git a/node_modules/@gorhom/bottom-sheet/src/index.ts b/node_modules/@gorhom/bottom-sheet/src/index.ts index 7aaea57..6a5f5a4 100644 --- a/node_modules/@gorhom/bottom-sheet/src/index.ts diff --git a/patches/react-native-enriched-markdown+1.2.0-nightly-20261014-766fc0e67.patch b/patches/react-native-enriched-markdown+1.2.0-nightly-20261014-766fc0e67.patch index 3a61dc1d94..7ec1ec33b5 100644 --- a/patches/react-native-enriched-markdown+1.2.0-nightly-20261014-766fc0e67.patch +++ b/patches/react-native-enriched-markdown+1.2.0-nightly-20261014-766fc0e67.patch @@ -1,4 +1,3 @@ -// UPSTREAM: https://github.com/software-mansion/enriched-markdown/pull/997 diff --git a/node_modules/react-native-enriched-markdown/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/EventEmitters.cpp b/node_modules/react-native-enriched-markdown/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/EventEmitters.cpp index 77465ad..94997f7 100644 --- a/node_modules/react-native-enriched-markdown/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/EventEmitters.cpp @@ -40,9 +39,10 @@ index 0a1c6c2..5935112 100644 struct OnLinkLongPress { diff --git a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt +index c12937c..8105833 100644 --- a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt +++ b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt -@@ -95,7 +95,7 @@ +@@ -95,7 +95,7 @@ class EnrichedMarkdown( private var accessibilityLabels = AccessibilityLabels() private var textBreakStrategy: String = BreakStrategyUtils.DEFAULT_STRATEGY @@ -51,7 +51,7 @@ diff --git a/node_modules/react-native-enriched-markdown/android/src/main/java/c private var onLinkLongPressCallback: ((String) -> Unit)? = null private var onTaskListItemPressCallback: ((Int, Boolean, String) -> Unit)? = null private var onLatexErrorCallback: LatexErrorReporter? = null -@@ -296,7 +296,7 @@ +@@ -296,7 +296,7 @@ class EnrichedMarkdown( } } @@ -60,7 +60,7 @@ diff --git a/node_modules/react-native-enriched-markdown/android/src/main/java/c onLinkPressCallback = callback } -@@ -405,7 +405,7 @@ +@@ -405,7 +405,7 @@ class EnrichedMarkdown( val codeLinkRegex = inlineCodeLinkRegex val renderId = ++currentRenderId @@ -69,7 +69,7 @@ diff --git a/node_modules/react-native-enriched-markdown/android/src/main/java/c try { val filtered = if (isStreaming) { -@@ -418,13 +418,13 @@ +@@ -418,13 +418,13 @@ class EnrichedMarkdown( if (renderableMarkdown.isEmpty()) { postToMain(renderId) { applyRenderedSegments(emptyList(), false) } @@ -85,7 +85,19 @@ diff --git a/node_modules/react-native-enriched-markdown/android/src/main/java/c } val segments = splitASTIntoSegments(ast) -@@ -646,8 +646,12 @@ +@@ -478,6 +478,11 @@ class EnrichedMarkdown( + if (forceHeight || topologyChanged || heightBefore != heightAfter) { + MeasurementStore.invalidate(id) + requestLayout() ++ stateWrapper?.let { ++ val state = Arguments.createMap() ++ state.putInt("forceHeightRecalculationCounter", ++forceHeightRecalculationCounter) ++ it.updateState(state) ++ } + } + } + } +@@ -646,8 +651,12 @@ class EnrichedMarkdown( renderId: Long, action: () -> Unit, ) { @@ -320,19 +332,6 @@ index 0cfad74..df88c5d 100644 node: MarkdownASTNode, builder: SpannableStringBuilder, - onLinkPress: ((String) -> Unit)?, -+ onLinkPress: ((String, String) -> Unit)?, - onLinkLongPress: ((String) -> Unit)?, - factory: RendererFactory, - ) { -diff --git a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/LinkRenderer.kt b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/LinkRenderer.kt -index 360a436..2bca5be 100644 ---- a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/LinkRenderer.kt -+++ b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/LinkRenderer.kt -@@ -19,7 +19,7 @@ class LinkRenderer( - override fun render( - node: MarkdownASTNode, - builder: SpannableStringBuilder, -- onLinkPress: ((String) -> Unit)?, + onLinkPress: ((String, String) -> Unit)?, onLinkLongPress: ((String) -> Unit)?, factory: RendererFactory, @@ -593,29 +592,6 @@ index e190548..b2e6cf2 100644 isHorizontalScrollBarEnabled = true overScrollMode = View.OVER_SCROLL_NEVER importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO -diff --git a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/LinkSpan.kt b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/LinkSpan.kt -index ff00069..f3e13aa 100644 ---- a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/LinkSpan.kt -+++ b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/LinkSpan.kt -@@ -12,7 +12,7 @@ import com.swmansion.enriched.markdown.utils.text.extensions.applyBlockStyleFont - - class LinkSpan( - val url: String, -- private val onLinkPress: ((String) -> Unit)?, -+ private val onLinkPress: ((String, String) -> Unit)?, - private val onLinkLongPress: ((String) -> Unit)?, - private val styleCache: SpanStyleCache, - private val blockStyle: BlockStyle, -@@ -30,7 +30,8 @@ class LinkSpan( - return - } - -- onLinkPress?.invoke(url) ?: (widget as? EnrichedMarkdownText)?.emitOnLinkPress(url) -+ val sourceType = if (keepsCodeFont) "inline-code" else if (recognizedLink) "text" else "markdown" -+ onLinkPress?.invoke(url, sourceType) ?: (widget as? EnrichedMarkdownText)?.emitOnLinkPress(url, sourceType) - } - - fun onLongClick(widget: View): Boolean { diff --git a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/MarkdownViewManagerUtils.kt b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/MarkdownViewManagerUtils.kt index b4d3cdc..4652b10 100644 --- a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/MarkdownViewManagerUtils.kt @@ -1918,10 +1894,17 @@ index 75b509cd2..c68da0c05 100644 +{"version":3,"names":["createMd4cModule","_scriptName","document","currentScript","src","undefined","moduleArg","moduleRtn","Module","ENVIRONMENT_IS_WEB","ENVIRONMENT_IS_WORKER","arguments_","thisProgram","scriptDirectory","readAsync","readBinary","URL","href","url","response","fetch","credentials","ok","arrayBuffer","Error","status","out","console","log","bind","err","error","wasmBinary","ABORT","readyPromiseResolve","readyPromiseReject","wasmMemory","HEAP8","HEAPU8","HEAP16","HEAPU16","HEAP32","HEAPU32","HEAPF32","HEAPF64","HEAP64","HEAPU64","runtimeInitialized","updateMemoryViews","b","buffer","Int8Array","Int16Array","Uint8Array","Uint16Array","Int32Array","Uint32Array","Float32Array","Float64Array","BigInt64Array","BigUint64Array","preRun","length","addOnPreRun","shift","callRuntimeCallbacks","onPreRuns","initRuntime","wasmExports","postRun","addOnPostRun","onPostRuns","runDependencies","dependenciesFulfilled","addRunDependency","id","removeRunDependency","callback","abort","what","e","WebAssembly","RuntimeError","wasmBinaryFile","findWasmBinary","base64Decode","getBinarySync","file","ArrayBuffer","isView","getWasmBinary","binaryFile","instantiateArrayBuffer","imports","binary","instance","instantiate","reason","instantiateAsync","getWasmImports","env","wasmImports","wasi_snapshot_preview1","createWasm","receiveInstance","module","exports","assignWasmExports","receiveInstantiationResult","result","info","Promise","resolve","reject","mod","inst","ExitStatus","name","constructor","message","callbacks","cb","push","b64","b1","b2","i","j","bLength","output","base64ReverseLookup","charCodeAt","noExitRuntime","stackRestore","val","__emscripten_stack_restore","stackSave","_emscripten_stack_get_current","UTF8Decoder","TextDecoder","UTF8ArrayToString","heapOrArray","idx","maxBytesToRead","NaN","endIdx","endPtr","decode","subarray","str","u0","String","fromCharCode","u1","u2","ch","UTF8ToString","ptr","___assert_fail","condition","filename","line","func","ExceptionInfo","excPtr","set_type","type","get_type","set_destructor","destructor","get_destructor","set_caught","caught","get_caught","set_rethrown","rethrown","get_rethrown","init","set_adjusted_ptr","adjustedPtr","get_adjusted_ptr","exceptionLast","uncaughtExceptionCount","___cxa_throw","__abort_js","getHeapMax","alignMemory","size","alignment","Math","ceil","growMemory","pages","byteLength","grow","_emscripten_resize_heap","requestedSize","oldSize","maxHeapSize","cutDown","overGrownHeapSize","min","newSize","max","replacement","getCFunc","ident","writeArrayToMemory","array","set","lengthBytesUTF8","len","c","stringToUTF8Array","heap","outIdx","maxBytesToWrite","startIdx","u","codePointAt","stringToUTF8","outPtr","stackAlloc","sz","__emscripten_stack_alloc","stringToUTF8OnStack","ret","ccall","returnType","argTypes","args","opts","toC","string","arr","convertReturnValue","Boolean","cArgs","stack","converter","onDone","cwrap","numericArgs","every","numericRet","_parseMarkdown","__assert_fail","__cxa_throw","_abort_js","emscripten_resize_heap","run","doRun","setTimeout","preInit","default","define"],"sourceRoot":"../../../src","sources":["web/wasm/md4c.js"],"sourcesContent":["\"use strict\";\nvar createMd4cModule = (() => {\n var _scriptName = typeof document != 'undefined' ? document.currentScript?.src : undefined;\n return (\nasync function(moduleArg = {}) {\n var moduleRtn;\n\nvar Module=moduleArg;var ENVIRONMENT_IS_WEB=true;var ENVIRONMENT_IS_WORKER=false;var arguments_=[];var thisProgram=\"./this.program\";var scriptDirectory=\"\";var readAsync,readBinary;if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){try{scriptDirectory=new URL(\".\",_scriptName).href}catch{}{readAsync=async url=>{var response=await fetch(url,{credentials:\"same-origin\"});if(response.ok){return response.arrayBuffer()}throw new Error(response.status+\" : \"+response.url)}}}else{}var out=console.log.bind(console);var err=console.error.bind(console);var wasmBinary;var ABORT=false;var readyPromiseResolve,readyPromiseReject;var wasmMemory;var HEAP8,HEAPU8,HEAP16,HEAPU16,HEAP32,HEAPU32,HEAPF32,HEAPF64;var HEAP64,HEAPU64;var runtimeInitialized=false;function updateMemoryViews(){var b=wasmMemory.buffer;HEAP8=new Int8Array(b);HEAP16=new Int16Array(b);HEAPU8=new Uint8Array(b);HEAPU16=new Uint16Array(b);HEAP32=new Int32Array(b);HEAPU32=new Uint32Array(b);HEAPF32=new Float32Array(b);HEAPF64=new Float64Array(b);HEAP64=new BigInt64Array(b);HEAPU64=new BigUint64Array(b)}function preRun(){if(Module[\"preRun\"]){if(typeof Module[\"preRun\"]==\"function\")Module[\"preRun\"]=[Module[\"preRun\"]];while(Module[\"preRun\"].length){addOnPreRun(Module[\"preRun\"].shift())}}callRuntimeCallbacks(onPreRuns)}function initRuntime(){runtimeInitialized=true;wasmExports[\"__wasm_call_ctors\"]()}function postRun(){if(Module[\"postRun\"]){if(typeof Module[\"postRun\"]==\"function\")Module[\"postRun\"]=[Module[\"postRun\"]];while(Module[\"postRun\"].length){addOnPostRun(Module[\"postRun\"].shift())}}callRuntimeCallbacks(onPostRuns)}var runDependencies=0;var dependenciesFulfilled=null;function addRunDependency(id){runDependencies++;Module[\"monitorRunDependencies\"]?.(runDependencies)}function removeRunDependency(id){runDependencies--;Module[\"monitorRunDependencies\"]?.(runDependencies);if(runDependencies==0){if(dependenciesFulfilled){var callback=dependenciesFulfilled;dependenciesFulfilled=null;callback()}}}function abort(what){Module[\"onAbort\"]?.(what);what=\"Aborted(\"+what+\")\";err(what);ABORT=true;what+=\". Build with -sASSERTIONS for more info.\";var e=new WebAssembly.RuntimeError(what);readyPromiseReject?.(e);throw e}var wasmBinaryFile;function findWasmBinary(){return base64Decode(\"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NCAAQoCAgPwDNwIsIABCADcCGCAAQgA3AiAgAEEANgIoIABBDGohBgJAIAFFDQACQAJAIAEoAgQiBARAIAEoAgAiBw0BCyADQQA2AugBIANCADcD4AEMAQsgBEH4////B08NDwJAIARBC08EQCAEQQdyIghBAWoQfyEFIAMgCEH/////B2s2AugBIAMgBTYC4AEgAyAENgLkAQwBCyADIAQ6AOsBIANB4AFqIQULIAQEQCAFIAcgBPwKAAALIAQgBWpBADoAACADKALkASADLQDrASIEIATAQQBIG0UNACAGQcDcACADQeABahAaCwJAAkAgASgCFCIEBEAgASgCECIFDQELIANBADYC2AEgA0IANwPQAQwBCyAEQfj///8HTw0PAkAgBEELTwRAIARBB3IiB0EBahB/IQEgAyAHQf////8HazYC2AEgAyABNgLQASADIAQ2AtQBDAELIAMgBDoA2wEgA0HQAWohAQsgBARAIAEgBSAE/AoAAAsgASAEakEAOgAAIAMoAtQBIAMtANsBIgEgAcBBAEgbRQ0AIAZBzNwAIANB0AFqEBoLIAMsANsBQQBIBEAgAygC2AEaIAMoAtABEHILIAMsAOsBQQBODQAgAygC6AEaIAMoAuABEHILIAMgADYCzAEgAyAGNgLIASAAIAAoAgRBAWo2AgQgAyADKQLIATcDACACIAMQGCAAIAAoAgQiAkEBazYCBCACDQwgACAAKAIAKAIIEQEAIAAQfAwMC0E8EH8iAEIANwIQIABBBjYCDCAAQcwXNgIAIABCADcCBCAAQgA3AjQgAEKAgID8AzcCLCAAQgA3AhggAEIANwIgIABBADYCKCADIAA2AsQBIAMgAEEMajYCwAEgAyADKQLAATcDCCACIANBCGoQGAwLC0E8EH8iAEIANwIQIABBBzYCDCAAQcwXNgIAIABCADcCBCAAQgA3AjQgAEKAgID8AzcCLCAAQgA3AhggAEIANwIgIABBADYCKCADIAA2ArwBIAMgAEEMajYCuAEgAyADKQK4ATcDECACIANBEGoQGAwKC0E8EH8iAEIANwIQIABBCTYCDCAAQcwXNgIAIABCADcCBCAAQgA3AjQgAEKAgID8AzcCLCAAQgA3AhggAEIANwIgIABBADYCKCADIAA2ArQBIAMgAEEMajYCsAEgAyADKQKwATcDGCACIANBGGoQGAwJC0E8EH8iAEIANwIQIABBCjYCDCAAQcwXNgIAIABCADcCBCAAQgA3AjQgAEKAgID8AzcCLCAAQgA3AhggAEIANwIgIABBADYCKCADIAA2AqwBIAMgAEEMajYCqAEgAyADKQKoATcDICACIANBIGoQGAwIC0E8EH8iAEIANwIQIABBCDYCDCAAQcwXNgIAIABCADcCBCAAQgA3AjQgAEKAgID8AzcCLCAAQgA3AhggAEIANwIgIABBADYCKCADIAA2AqQBIAMgAEEMajYCoAEgAyADKQKgATcDKCACIANBKGoQGAwHC0E8EH8iAEIANwIQIABBCzYCDCAAQcwXNgIAIABCADcCBCAAQgA3AjQgAEKAgID8AzcCLCAAQgA3AhggAEIANwIgIABBADYCKCAAQQxqIQYCQCABRQ0AAkACQCABKAIEIgQEQCABKAIAIgcNAQsgA0EANgLoASADQgA3A+ABDAELIARB+P///wdPDQkCQCAEQQtPBEAgBEEHciIIQQFqEH8hBSADIAhB/////wdrNgLoASADIAU2AuABIAMgBDYC5AEMAQsgAyAEOgDrASADQeABaiEFCyAEBEAgBSAHIAT8CgAACyAEIAVqQQA6AAAgAygC5AEgAy0A6wEiBCAEwEEASBtFDQAgBkHA3AAgA0HgAWoQGgsCQAJAIAEoAhQiBARAIAEoAhAiBQ0BCyADQQA2AtgBIANCADcD0AEMAQsgBEH4////B08NCQJAIARBC08EQCAEQQdyIgdBAWoQfyEBIAMgB0H/////B2s2AtgBIAMgATYC0AEgAyAENgLUAQwBCyADIAQ6ANsBIANB0AFqIQELIAQEQCABIAUgBPwKAAALIAEgBGpBADoAACADKALUASADLQDbASIBIAHAQQBIG0UNACAGQczcACADQdABahAaCyADLADbAUEASARAIAMoAtgBGiADKALQARByCyADLADrAUEATg0AIAMoAugBGiADKALgARByCyADIAA2ApwBIAMgBjYCmAEgACAAKAIEQQFqNgIEIAMgAykCmAE3AzAgAiADQTBqEBggACAAKAIEIgJBAWs2AgQgAg0GIAAgACgCACgCCBEBACAAEHwMBgtBPBB/IgBCADcCECAAQRg2AgwgAEHMFzYCACAAQgA3AgQgAEIANwI0IABCgICA/AM3AiwgAEIANwIYIABCADcCICAAQQA2AiggAyAANgKUASADIABBDGo2ApABIAMgAykCkAE3AzggAiADQThqEBgMBQtBPBB/IgBCADcCECAAQRk2AgwgAEHMFzYCACAAQgA3AgQgAEIANwI0IABCgICA/AM3AiwgAEIANwIYIABCADcCICAAQQA2AiggAyAANgKMASADIABBDGo2AogBIAMgAykCiAE3A0AgAiADQUBrEBgMBAtBPBB/IgBCADcCECAAQRo2AgwgAEHMFzYCACAAQgA3AgQgAEIANwI0IABCgICA/AM3AiwgAEIANwIYIABCADcCICAAQQA2AiggAyAANgKEASADIABBDGo2AoABIAMgAykCgAE3A0ggAiADQcgAahAYDAMLQTwQfyIAQgA3AhAgAEEbNgIMIABBzBc2AgAgAEIANwIEIABCADcCNCAAQoCAgPwDNwIsIABCADcCGCAAQgA3AiAgAEEANgIoIAMgADYCfCADIABBDGo2AnggAyADKQJ4NwNQIAIgA0HQAGoQGAwCC0E8EH8iAEIANwIQIABBHDYCDCAAQcwXNgIAIABCADcCBCAAQgA3AjQgAEKAgID8AzcCLCAAQgA3AhggAEIANwIgIABBADYCKCADIAA2AnQgAyAAQQxqNgJwIAMgAykCcDcDWCACIANB2ABqEBgMAQtBPBB/IgBCADcCECAAQR02AgwgAEHMFzYCACAAQgA3AgQgAEIANwI0IABCgICA/AM3AiwgAEIANwIYIABCADcCICAAQQA2AiggAyAANgJsIAMgAEEMajYCaCADIAMpAmg3A2AgAiADQeAAahAYCyADQfABaiQAIAkPCxAHAAvXGAEKfyMAQeAAayIDJAACQCACRQRAQQEhAAwBCwJAAkAgAA4JAgEBAQEBAQEAAQtBACEAIAJBADoAJCADQgA3AxggA0IANwMQIANCgICAgICAgMA/NwMgIANCADcDCAJAIAIoAiwgAiwAMyIEIARBAEgiARsiBUUNACACQShqIgYoAgAiByAGIAEbIQEDQCAAIAFqLQAAEFMEQCAAQQFqIgAgBUcNAQwCCwsgByAGIARBAEgbIABqLQAAQTxHDQACQCAAQQFqIgAgBU8NACAHIAYgBEEASBsiASAAai0AAEEvRg0BA0AgACABai0AABBTRQ0BIABBAWoiACAFRw0ACwwBCyAAIAVPDQAgByAGIARBAEgbIQcgACEBAkADQCABIAdqIgQtAAAQUw0BIAQtAABBPkYNASAELQAAQS9GDQEgAUEBaiIBIAVHDQALIAUhAQsgACABRg0AIANBCGogASAAaxCYASAAIAFJBEADQCADQQhqIAIoAiggBiACLAAzQQBIGyAAai0AABBiwBCdASAAQQFqIgAgAUcNAAsLIAEgAigCLCIIIAIsADMiCSAJQQBIGyIATw0AIAlBH3YhBSADQRRqIQogA0HQAGohCwNAIAYoAgAgBiAFQQFxGyEFA0AgASAFaiIELQAAEFMEQCABQQFqIgEgAEkNAQwDCwsgBC0AAEE+Rg0BIAQtAABBL0YNAQJAAkAgACABSwRAIAEhBAJAA0AgBCAFaiIHLQAAQT1GDQEgBy0AABBTDQEgBy0AAEE+Rg0BIActAABBL0YNASAEQQFqIgQgAEkNAAsgACEECyABIARHDQELIAFBAWohAQwBCyALQQA2AgAgA0IANwNIIANByABqIAQgAWsQmAEgASAESQRAA0AgA0HIAGogAigCKCAGIAIsADNBAEgbIAFqLQAAEGLAEJ0BIAFBAWoiASAESQ0ACwsCQAJ/AkAgBCACKAIsIAIsADMiACAAQQBIIgUbIgBPDQAgBigCACAGIAUbIQUCQANAIAQgBWoiAS0AABBTRQ0BIARBAWoiBCAARw0ACyAADAILIAEtAABBPUcNAAJAAkACQAJAAkADQCAAIAQiB0EBaiIETQ0BIAQgBWoiAS0AABBTDQALIANBQGsiCUEANgIAIANCADcDOAJAIAEtAABBIkYNACABLQAAQSdGDQAgBCEBA0AgASAFaiIJLQAAEFMNAyAJLQAAQT5GDQMgCS0AAEEvRg0DIAFBAWoiASAARw0ACyAAIQEMAgsgB0ECaiIHIQQCQCAAIAdNDQAgAS0AACEBA0AgBCAFai0AACABRg0BIARBAWoiBCAASQ0ACyAAIQQLIAAgB2siACAEIAdrIgEgACABSRsiAEH4////B08NAwJAAkAgAEELTwRAIABBB3IiCEEBahB/IQEgAyAIQf////8HazYCNCADIAE2AiwgAyAANgIwDAELIAMgADoANyADQSxqIQEgAEUNAQsgAEUNACABIAUgB2ogAPwKAAALIAAgAWpBADoAACADLABDQQBIBEAgAygCQBogAygCOBByCyAJIAMoAjQ2AgAgAyADKQIsNwM4IAQgBCACKAIsIAIsADMiACAAQQBIG0lqIQEMAgsgA0FAa0EANgIAIANCADcDOCAEIQELIAAgB00NAiAAIARrIgAgASAEayIHIAAgB0kbIgBB+P///wdPDQECQAJAIABBC08EQCAAQQdyIglBAWoQfyEHIAMgCUH/////B2s2AjQgAyAHNgIsIAMgADYCMAwBCyADIAA6ADcgA0EsaiEHIABFDQELIABFDQAgByAEIAVqIAD8CgAACyAAIAdqQQA6AAAgAywAQ0EASARAIAMoAkAaIAMoAjgQcgsgA0FAayADKAI0NgIAIAMgAykCLDcDOAsgAyADQcgAajYCXCADQSxqIAogA0HIAGogA0HcAGoQGQJAIAMoAiwiBUEUaiIAIANBOGpGDQAgAywAQyEEIAUsAB9BAE4EQCAEQQBOBEAgACADKQM4NwIAIAAgA0FAaygCADYCCAwHCyAAIAMoAjggAygCPBCcAQwBCyAAIAMoAjggA0E4aiAEQQBIIgUbIAMoAjwgBCAFGxCbAQsgAywAQ0EATg0EIAMoAkAaIAMoAjgQcgwECxAHAAtBCBCoAUGqDxCBASIAQdzbADYCACAAQejbAEEBEAAACyAECyEBIAMgA0HIAGo2AiwgA0E4aiAKIANByABqIANBLGoQGQJ/IAMoAjgiACwAH0EASARAIABBADYCGCAAKAIUDAELIABBADoAHyAAQRRqC0EAOgAACyADLABTQQBIBEAgAygCUBogAygCSBByCyACKAIsIQggAi0AMyEJCyABIAggCUH/AXEgCcBBAEgiBRsiAEkNAAsLQQAhAEEAIQUCQCADKAIMIAMsABMiASABQQBIG0EFRw0AIAMoAgggA0EIaiABQQBIG0H4DEEFEFUNACADQQM6AFMgA0EAOgBLIANBnxEvAAA7AUggA0GhES0AADoASgJ/IANBFGohCkEAIQkgA0HIAGoiBigCBCAGLAALIgggCEEASCIEGyIHIQggBigCACAGIAQbIgshBgJAIAciBEEESQ0AAn8gBEEEayIEQQRxBEAgByEBIAsMAQsgCygAAEGV08feBWwiBkEYdiAGc0GV08feBWwgB0GV08feBWxzIQggBCEBIAtBBGoLIQYgBEEESQ0AIAEhBANAIAYoAARBldPH3gVsIgFBGHYgAXNBldPH3gVsIAYoAABBldPH3gVsIgFBGHYgAXNBldPH3gVsIAhBldPH3gVsc0GV08feBWxzIQggBkEIaiEGIARBCGsiBEEDSw0ACwsCQAJAAkACQCAEQQFrDgMCAQADCyAGLQACQRB0IAhzIQgLIAYtAAFBCHQgCHMhCAsgCCAGLQAAc0GV08feBWwhCAsCQCAKKAIEIgFFDQAgCigCAAJ/IAhBDXYgCHNBldPH3gVsIgZBD3YgBnMiBCABQQFrcSABaSIIQQFNDQAaIAQgASAESw0AGiAEIAFwCyIMQQJ0aigCACIGRQ0AIAYoAgAiBkUNACABQQFrIQkgCEEBSyEKA0ACQAJAIAQgBigCBCIIRwRAAkAgCkUEQCAIIAlxIQgMAQsgASAISw0AIAggAXAhCAsgCCAMRw0CDAELIAYoAgwgBiwAEyIIIAhBAEgiCBsgB0cNACAGKAIIIAZBCGogCBsgCyAHEFUNACAGIQkMAwsgBigCACIGDQELC0EADAELIAkLIQEgAywAU0EASARAIAMoAlAaIAMoAkgQcgsgAUUNACABKAIYIAEsAB8iBCAEQQBIG0UNAEE8EH8iBUIANwIQIAVBITYCDCAFQcwXNgIAIAVCADcCBCAFQgA3AjQgBUKAgID8AzcCLCAFQgA3AhggBUIANwIgIAVBADYCKCADQQM6AFMgA0HyDS8AADsBSCADQfQNLQAAOgBKIANBADoASyADIANByABqNgIsIANBOGogBUEcaiADQcgAaiADQSxqEBkCQCADKAI4IgAgAUYNACABQRRqIQcgAEEUaiEEIAEsAB8hBiAALAAfQQBOBEAgBkEATgRAIAQgBykCADcCACAEIAcoAgg2AggMAgsgBCABKAIUIAEoAhgQnAEMAQsgBCABKAIUIAcgBkEASCIAGyABKAIYIAYgABsQmwELIAMsAFNBAEgEQCADKAJQGiADKAJIEHILIAVBDGohAAsgA0EUahAcIAMsABNBAEgEQCADKAIQGiADKAIIEHILAkAgAiwAM0EASARAIAIoAihBADoAACACQQA2AiwMAQsgAkEAOgAzIAJBADoAKAsCQCAARQ0AIAIoAgggAigCDCICRg0AIAJBCGsoAgAhAiADIAU2AgwgAyAANgIIIAJBJGogA0EIahAdAkAgAygCDCIARQ0AIAAgACgCBCICQQFrNgIEIAINACAAIAAoAgAoAggRAQAgABB8C0EAIQAMAgtBACEAIAVFDQEgBSAFKAIEIgJBAWs2AgQgAg0BIAUgBSgCACgCCBEBACAFEHwMAQtBACEAIAIoAgggAigCDEYNACACEBAgAigCDCIFIAIoAghrQQlJDQACQCAFQQRrKAIAIgFFDQAgASABKAIEIgRBAWs2AgQgBA0AIAEgASgCACgCCBEBACABEHwLIAIgBUEIazYCDAsgA0HgAGokACAAC5EmAQd/IwBBoAJrIgMkAAJAAkAgAkUEQEEBIQkMAQsCQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQCAAQQFrDhMCBAUGCAEHDwAKCwwNDg4QEAMJEAtBPBB/IgBCADcCECAAQQE2AgwgAEHMFzYCACAAQgA3AgQgAEIANwI0IABCgICA/AM3AiwgAEIANwIYIABCADcCICAAQQA2AiggAyAANgKMAiADIABBDGo2AogCIAMgAykCiAI3AwAgAiADEBgMDwtBPBB/IgBCADcCECAAQQQ2AgwgAEHMFzYCACAAQgA3AgQgAEIANwI0IABCgICA/AM3AiwgAEIANwIYIABCADcCICAAQQA2AigCQCABRQ0AIAEoAgAhASADQQA6AJUCIANBBkEBIAEgAUEBTBsiASABQQZOG0EwcjoAlAIgA0GUAmoQYSIBQfj///8HTw0QAkACQCABQQtPBEAgAUEHciIFQQFqEH8hBCADIAVB/////wdrNgKAAiADIAQ2AvgBIAMgATYC/AEMAQsgAyABOgCDAiADQfgBaiEEIAFFDQELIAFFDQAgBCADQZQCaiAB/AoAAAsgASAEakEAOgAAIANBtNwANgKYAiADQaQBaiAAQRxqQbTcACADQZgCahAZAkAgAygCpAEiBEEUaiIBIANB+AFqRg0AIAMsAIMCIQUgBCwAH0EATgRAIAVBAE4EQCABIAMpAvgBNwIAIAEgAygCgAI2AggMAgsgASADKAL4ASADKAL8ARCcAQwBCyABIAMoAvgBIANB+AFqIAVBAEgiBBsgAygC/AEgBSAEGxCbAQsgAywAgwJBAE4NACADKAKAAhogAygC+AEQcgsgAyAANgL0ASADIABBDGo2AvABIAAgACgCBEEBajYCBCADIAMpAvABNwMIIAIgA0EIahAYIAAgACgCBCICQQFrNgIEIAINDiAAIAAoAgAoAggRAQAgABB8DA4LQTwQfyIAQgA3AhAgAEEMNgIMIABBzBc2AgAgAEIANwIEIABCADcCNCAAQoCAgPwDNwIsIABCADcCGCAAQgA3AiAgAEEANgIoIAMgADYC7AEgAyAAQQxqNgLoASADIAMpAugBNwMQIAIgA0EQahAYDA0LQTwQfyIAQgA3AhAgAEEgNgIMIABBzBc2AgAgAEIANwIEIABCADcCNCAAQoCAgPwDNwIsIABCADcCGCAAQgA3AiAgAEEANgIoIABBDGohBAJAIAFFDQACQAJAIAEoAgQiBQRAIAEoAgAiBg0BCyADQQA2AoACIANCADcD+AEMAQsgBUH4////B08NDwJAIAVBC08EQCAFQQdyIgdBAWoQfyEBIAMgB0H/////B2s2AoACIAMgATYC+AEgAyAFNgL8AQwBCyADIAU6AIMCIANB+AFqIQELIAUEQCABIAYgBfwKAAALIAEgBWpBADoAACADKAL8ASADLQCDAiIBIAHAQQBIG0UNACAEQaTdACADQfgBahAaCyADLACDAkEATg0AIAMoAoACGiADKAL4ARByCyADIAA2AuQBIAMgBDYC4AEgACAAKAIEQQFqNgIEIAMgAykC4AE3AxggAiADQRhqEBggACAAKAIEIgJBAWs2AgQgAg0MIAAgACgCACgCCBEBACAAEHwMDAtBPBB/IgBCADcCECAAQQ02AgwgAEHMFzYCACAAQgA3AgQgAEIANwI0IABCgICA/AM3AiwgAEIANwIYIABCADcCICAAQQA2AiggAyAANgLcASADIABBDGo2AtgBIAMgAykC2AE3AyAgAiADQSBqEBgMCwtBPBB/IgBCADcCECAAQQ42AgwgAEHMFzYCACAAQgA3AgQgAEIANwI0IABCgICA/AM3AiwgAEIANwIYIABCADcCICAAQQA2AiggAEEMaiEEAkAgAUUNACABKAIAIgFBAUYNACADQfgBaiABEJ8BIARBjN0AIANB+AFqEBogAywAgwJBAE4NACADKAKAAhogAygC+AEQcgsgAyAANgLUASADIAQ2AtABIAAgACgCBEEBajYCBCADIAMpAtABNwMoIAIgA0EoahAYIAAgACgCBCICQQFrNgIEIAINCiAAIAAoAgAoAggRAQAgABB8DAoLQTwQfyIAQgA3AhAgAEEPNgIMIABBzBc2AgAgAEIANwIEIABCADcCNCAAQoCAgPwDNwIsIABCADcCGCAAQgA3AiAgAEEANgIoIABBDGohBAJAIAFFDQAgASgCAEUNACAEQfTcACADQfgBakHfDxAbIgUQGiAFLAALQQBIBEAgBSgCCBogBSgCABByCyAEQYDdACADQfgBakHfD0H/DyABLQAEQd8BcUHYAEYbEBsiARAaIAEsAAtBAE4NACABKAIIGiABKAIAEHILIAMgADYCzAEgAyAENgLIASAAIAAoAgRBAWo2AgQgAyADKQLIATcDMCACIANBMGoQGCAAIAAoAgQiAkEBazYCBCACDQkgACAAKAIAKAIIEQEAIAAQfAwJC0E8EH8iAEIANwIQIABBEDYCDCAAQcwXNgIAIABCADcCBCAAQgA3AjQgAEKAgID8AzcCLCAAQgA3AhggAEIANwIgIABBADYCKCAAQQxqIQQCQCABRQ0AAkAgAS0AICIFRQ0AIANBADoApQEgAyAFOgCkASAEQdjcACADQfgBaiADQaQBahAbIgUQGiAFLAALQQBODQAgBSgCCBogBSgCABByCwJAAkAgASgCFCIFBEAgASgCECIGDQELIANBADYCgAIgA0IANwP4AQwBCyAFQfj///8HTw0LAkAgBUELTwRAIAVBB3IiB0EBahB/IQEgAyAHQf////8HazYCgAIgAyABNgL4ASADIAU2AvwBDAELIAMgBToAgwIgA0H4AWohAQsgBQRAIAEgBiAF/AoAAAsgASAFakEAOgAAIAMoAvwBIAMtAIMCIgEgAcBBAEgbRQ0AIARB6NwAIANB+AFqEBoLIAMsAIMCQQBODQAgAygCgAIaIAMoAvgBEHILIAMgADYCxAEgAyAENgLAASAAIAAoAgRBAWo2AgQgAyADKQLAATcDOCACIANBOGoQGCAAIAAoAgQiAkEBazYCBCACDQggACAAKAIAKAIIEQEAIAAQfAwIC0E8EH8iAEIANwIQIABBETYCDCAAQcwXNgIAIABCADcCBCAAQgA3AjQgAEKAgID8AzcCLCAAQgA3AhggAEIANwIgIABBADYCKCADIAA2ArwBIAMgAEEMajYCuAEgAyADKQK4ATcDQCACIANBQGsQGAwHC0E8EH8iAEIANwIQIABBHzYCDCAAQcwXNgIAIABCADcCBCAAQgA3AjQgAEKAgID8AzcCLCAAQgA3AhggAEIANwIgIABBADYCKCAAQQxqIQQCQCABRQ0AIANB+AFqIAEoAgAQnwEgA0GY3QA2ApgCIANBpAFqIABBHGpBmN0AIANBmAJqEBkCQCADKAKkASIFQRRqIgEgA0H4AWpGDQAgAywAgwIhBiAFLAAfQQBOBEAgBkEATgRAIAEgAykC+AE3AgAgASADKAKAAjYCCAwCCyABIAMoAvgBIAMoAvwBEJwBDAELIAEgAygC+AEgA0H4AWogBkEASCIFGyADKAL8ASAGIAUbEJsBCyADLACDAkEATg0AIAMoAoACGiADKAL4ARByCyADIAA2ArQBIAMgBDYCsAEgACAAKAIEQQFqNgIEIAMgAykCsAE3A0ggAiADQcgAahAYIAAgACgCBCICQQFrNgIEIAINBiAAIAAoAgAoAggRAQAgABB8DAYLQTwQfyIAQgA3AhAgAEESNgIMIABBzBc2AgAgAEIANwIEIABCADcCNCAAQoCAgPwDNwIsIABCADcCGCAAQgA3AiAgAEEANgIoIABBDGohBQJAIAFFDQAgA0EAOgCAAiADQuPesZv0rZ239AA3A/gBIANBCDoAgwIgA0GkAWogASgCABCfASADIANB+AFqNgKUAiADQZgCaiAAQRxqIgYgA0H4AWogA0GUAmoQGQJAIAMoApgCIgdBFGoiBCADQaQBakYNACADLACvASEIIAcsAB9BAE4EQCAIQQBOBEAgBCADKQKkATcCACAEIAMoAqwBNgIIDAILIAQgAygCpAEgAygCqAEQnAEMAQsgBCADKAKkASADQaQBaiAIQQBIIgcbIAMoAqgBIAggBxsQmwELIAMsAK8BQQBIBEAgAygCrAEaIAMoAqQBEHILIAMsAIMCQQBIBEAgAygCgAIaIAMoAvgBEHILIANBEBB/IgQ2AvgBIANCjICAgICCgICAfzcC/AEgBEH9CSgAADYACCAEQfUJKQAANwAAIARBADoADCADQaQBaiABKAIEEJ8BIAMgA0H4AWo2ApQCIANBmAJqIAYgA0H4AWogA0GUAmoQGQJAIAMoApgCIgdBFGoiBCADQaQBakYNACADLACvASEIIAcsAB9BAE4EQCAIQQBOBEAgBCADKQKkATcCACAEIAMoAqwBNgIIDAILIAQgAygCpAEgAygCqAEQnAEMAQsgBCADKAKkASADQaQBaiAIQQBIIgcbIAMoAqgBIAggBxsQmwELIAMsAK8BQQBIBEAgAygCrAEaIAMoAqQBEHILIAMsAIMCQQBIBEAgAygCgAIaIAMoAvgBEHILIANBEBB/IgQ2AvgBIANCjICAgICCgICAfzcC/AEgBEHwCSgAADYACCAEQegJKQAANwAAIARBADoADCADQaQBaiABKAIIEJ8BIAMgA0H4AWo2ApQCIANBmAJqIAYgA0H4AWogA0GUAmoQGQJAIAMoApgCIgRBFGoiASADQaQBakYNACADLACvASEGIAQsAB9BAE4EQCAGQQBOBEAgASADKQKkATcCACABIAMoAqwBNgIIDAILIAEgAygCpAEgAygCqAEQnAEMAQsgASADKAKkASADQaQBaiAGQQBIIgQbIAMoAqgBIAYgBBsQmwELIAMsAK8BQQBIBEAgAygCrAEaIAMoAqQBEHILIAMsAIMCQQBODQAgAygCgAIaIAMoAvgBEHILIAMgADYCoAEgAyAFNgKcASAAIAAoAgRBAWo2AgQgAyADKQKcATcDUCACIANB0ABqEBggACAAKAIEIgJBAWs2AgQgAg0FIAAgACgCACgCCBEBACAAEHwMBQtBPBB/IgBCADcCECAAQRM2AgwgAEHMFzYCACAAQgA3AgQgAEIANwI0IABCgICA/AM3AiwgAEIANwIYIABCADcCICAAQQA2AiggAyAANgKYASADIABBDGo2ApQBIAMgAykClAE3A1ggAiADQdgAahAYDAQLQTwQfyIAQgA3AhAgAEEUNgIMIABBzBc2AgAgAEIANwIEIABCADcCNCAAQoCAgPwDNwIsIABCADcCGCAAQgA3AiAgAEEANgIoIAMgADYCkAEgAyAAQQxqNgKMASADIAMpAowBNwNgIAIgA0HgAGoQGAwDC0E8EH8iAEIANwIQIABBFTYCDCAAQcwXNgIAIABCADcCBCAAQgA3AjQgAEKAgID8AzcCLCAAQgA3AhggAEIANwIgIABBADYCKCADIAA2AogBIAMgAEEMajYChAEgAyADKQKEATcDaCACIANB6ABqEBgMAgtBPBB/IgRCADcCECAEQcwXNgIAIARCADcCBCAEQgA3AjQgBEKAgID8AzcCLCAEQRZBFyAAQQ5GGzYCDCAEQgA3AhggBEIANwIgIARBADYCKAJAIAFFDQBBngohBSABKAIAQQFrIgBBAk0EQCAAQQJ0QbwYaigCACEFCyADQQA6AP0BIANByQ0tAAA6APwBIANBBToAgwIgA0HFDSgAADYC+AEgBRBhIgBB+P///wdPDQMCQAJAIABBC08EQCAAQQdyIgZBAWoQfyEBIAMgBkH/////B2s2AqwBIAMgATYCpAEgAyAANgKoAQwBCyADIAA6AK8BIANBpAFqIQEgAEUNAQsgAEUNACABIAUgAPwKAAALIAAgAWpBADoAACADIANB+AFqNgKUAiADQZgCaiAEQRxqIANB+AFqIANBlAJqEBkCQCADKAKYAiIBQRRqIgAgA0GkAWpGDQAgAywArwEhBSABLAAfQQBOBEAgBUEATgRAIAAgAykCpAE3AgAgACADKAKsATYCCAwCCyAAIAMoAqQBIAMoAqgBEJwBDAELIAAgAygCpAEgA0GkAWogBUEASCIBGyADKAKoASAFIAEbEJsBCyADLACvAUEASARAIAMoAqwBGiADKAKkARByCyADLACDAkEATg0AIAMoAoACGiADKAL4ARByCyADIAQ2AoABIAMgBEEMajYCfCAEIAQoAgRBAWo2AgQgAyADKQJ8NwNwIAIgA0HwAGoQGCAEIAQoAgQiAkEBazYCBCACDQEgBCAEKAIAKAIIEQEAIAQQfAwBCyACQQE6ACQgAiwAM0EASARAIAIoAihBADoAACACQQA2AiwMAQsgAkEAOgAzIAJBADoAKAsgA0GgAmokACAJDwsQBwALoAIBBH8jAEEQayICJAACQCAAKAIYIAAsAB8iASABQQBIG0UNACAAKAIMIgMgACgCCEYNAEE8EH8iAUIANwI0IAFCgICA/AM3AiwgAUECNgIMIAFBzBc2AgAgAUIANwIEIAFCADcCHCABQgA3AiQgASAAQRRqIgQpAgA3AhAgASAEKAIINgIYIABBADoAFCAAQQA6AB8gA0EIaygCACEDIAIgATYCDCACIAFBDGo2AgggA0EkaiACQQhqEB0CQCACKAIMIgFFDQAgASABKAIEIgNBAWs2AgQgAw0AIAEgASgCACgCCBEBACABEHwLIAAsAB9BAEgEQCAAKAIUQQA6AAAgAEEANgIYDAELIABBADoAHyAAQQA6ABQLIAJBEGokAAuqAQIDfwF+QQEhAwJAAkACQCAAKAIAQQJr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AgBBIUYNAQsgAUEIaiIBIARHDQEMAgsLIANBADYCHCADQgA3AhQgA0EANgIQIANCADcCCANAAkACQCAFKAIAIgEoAgBBD0cNACABKAIoIAEoAiQiAWtBCEcNACABKAIAKAIAQSFHDQAgAygCCCICIAMoAgwiBkcEQEE8EH8iAUIANwIQIAEgCTYCDCABQcwXNgIAIAFCADcCBCABQgA3AhggAUIANwIgIAFCgICAgICAgMA/NwIoIAMgATYCBCADIAFBDGo2AgAgASADQRBqIgcoAgA2AjggASAGNgI0IAEgAjYCMCAHQQA2AgAgA0IANwMIIANBFGogAxAdAkAgAygCBCIBRQ0AIAEgASgCBCICQQFrNgIEIAINACABIAEoAgAoAggRAQAgARB8CyAFKAIAKAIkIQELIAMoAhgiAiADKAIcIgdJBEAgAiABKAIANgIAIAIgASgCBCIBNgIEIAEEQCABIAEoAgRBAWo2AgQLIAMgAkEIajYCGAwCCyACIAMoAhQiBmsiAkEDdSIOQQFqIghBgICAgAJPDQdB/////wEgByAGayIHQQJ1IgsgCCAIIAtJGyAHQfj///8HTxsiCEGAgICAAk8NCCAIQQN0IgcQfyILIAJqIgggASgCADYCACAIIAEoAgQiATYCBCAHIAtqIQcgCCABBH8gASABKAIEQQFqNgIEIAMoAhwaIAMoAhggBmsiAkEDdQUgDgtBA3RrIQEgAgRAIAEgBiAC/AoAAAsgAyAHNgIcIAMgCEEIaiICNgIYIAMgATYCFCAGBEAgBhByCyADIAI2AhgMAQsgA0EIaiAFEB0LIAVBCGoiBSAERw0ACwJAIAMoAggiAiADKAIMIgRGDQBBPBB/IgFCADcCECABIAk2AgwgAUHMFzYCACABQgA3AgQgAUIANwIYIAFCADcCICABQoCAgICAgIDAPzcCKCADIAE2AgQgAyABQQxqNgIAIAEgAygCEDYCOCABIAQ2AjQgASACNgIwIANBADYCECADQgA3AwggA0EUaiADEB0gAygCBCIBRQ0AIAEgASgCBCICQQFrNgIEIAINACABIAEoAgAoAggRAQAgARB8CyAAKAIkIA1qIgkhASAJQQhqIgQgACgCKCICRwRAA0AgBCkCACEPIARCADcCACABKAIEIQUgASAPNwIAAkAgBUUNACAFIAUoAgQiBkEBazYCBCAGDQAgBSAFKAIAKAIIEQEAIAUQfAsgAUEIaiEBIARBCGoiBCACRw0ACyAAKAIoIQILIAEgAkcEQANAAkAgAkEEaygCACIERQ0AIAQgBCgCBCIFQQFrNgIEIAUNACAEIAQoAgAoAggRAQAgBBB8CyABIAJBCGsiAkcNAAsLIAAgATYCKCAMIAkgAygCFCIBIAMoAhgiAiACIAFrQQN1EBUgAygCFCIJIQQgAygCGCAJayEGIAMoAggiBQRAIAUiAiADKAIMIgFHBEADQAJAIAFBBGsoAgAiAkUNACACIAIoAgQiBEEBazYCBCAEDQAgAiACKAIAKAIIEQEAIAIQfAsgBSABQQhrIgFHDQALIAMoAgghAgsgAyAFNgIMIAMoAhAaIAIQciADKAIUIQQLIAQEQCAEIgIgAygCGCIBRwRAA0ACQCABQQRrKAIAIgJFDQAgAiACKAIEIgVBAWs2AgQgBQ0AIAIgAigCACgCCBEBACACEHwLIAQgAUEIayIBRw0ACyADKAIUIQILIAMgBDYCGCADKAIcGiACEHILIAZBA3UhCCAAKAIkIQYgACgCKCEHCyAIIApqCyEKIAogByAGa0EDdUkNAAsLIANBIGokAA8LEBMACxAUAAveAQEFfyMAQRBrIgIkACAAEBACQCABKAIAIgRFDQAgACgCDCIDIAAoAghGDQAgA0EIaygCACEFIAEoAgQiAwRAIAMgAygCBEEBajYCBAsgAEEIaiEGIAIgAzYCDCACIAQ2AgggBUEkaiACQQhqEB0CQCACKAIMIgBFDQAgACAAKAIEIgRBAWs2AgQgBA0AIAAgACgCACgCCBEBACAAEHwLIAYgARAdCwJAIAEoAgQiAUUNACABIAEoAgQiAEEBazYCBCAADQAgASABKAIAKAIIEQEAIAEQfAsgAkEQaiQAC6IMAgl/An0gAigCBCACLAALIgUgBUEASCIEGyIGIQUgAigCACACIAQbIgkhAgJAIAYiBEEESQ0AAn8gBEEEayIEQQRxBEAgBiEHIAkMAQsgCSgAAEGV08feBWwiAkEYdiACc0GV08feBWwgBkGV08feBWxzIQUgBCEHIAlBBGoLIQIgBEEESQ0AIAchBANAIAIoAARBldPH3gVsIgdBGHYgB3NBldPH3gVsIAIoAABBldPH3gVsIgdBGHYgB3NBldPH3gVsIAVBldPH3gVsc0GV08feBWxzIQUgAkEIaiECIARBCGsiBEEDSw0ACwsCQAJAAkACQCAEQQFrDgMCAQADCyACLQACQRB0IAVzIQULIAItAAFBCHQgBXMhBQsgBSACLQAAc0GV08feBWwhBQsgBUENdiAFc0GV08feBWwiAkEPdiACcyEHIAACfwJAIAEoAgQiBEUNACABKAIAAn8gByAEQQFrcSAEaSIFQQFNDQAaIAcgBCAHSw0AGiAHIARwCyIIQQJ0aigCACICRQ0AIAIoAgAiAkUNACAEQQFrIQogBUEBSyELA0ACQCAHIAIoAgQiBUcEQAJAIAtFBEAgBSAKcSEFDAELIAQgBUsNACAFIARwIQULIAUgCEYNAQwDCyACKAIMIAIsABMiBSAFQQBIGyAGRw0AQQAgAigCCCACQQhqIAVBAEgbIAkgBhBVRQ0DGgsgAigCACICDQALC0EgEH8iAiAHNgIEIAJBADYCACACQQhqIQYCQCADKAIAIgUsAAtBAE4EQCAGIAUpAgA3AgAgBiAFKAIINgIIDAELIAUoAgAhAyAFKAIEIQojAEEQayILJAACQAJAAkAgChCWAQRAIAYiBSAKEJcBDAELIApB9////wdLDQEgC0EIaiAKEI0BQQFqEJABIAsoAgwaIAYgCygCCCIFEJIBIAYgCygCDBCTASAGIAoQlAELIAUgAyAKQQFqEJEBIAtBEGokAAwBCxCVAQALCyACQgA3AhQgAkEANgIcIAEqAhAhDSABKAIMQQFqsyEOAkAgBARAIA0gBLOUIA5dRQ0BCwJAAn9BAiAEIARBAWtxQQBHIARBA0lyIARBAXRyIgQgDiANlY38ASIGIAQgBksbIgRBAUYNABogBCAEIARBAWtxRQ0AGiAEEHcLIgUgASgCBCIETQRAIAQgBU0NASABKAIMsyABKgIQlY38ASEGIAUCfwJAIARBA0kNACAEaUEBSw0AIAZBAUEgIAZBAWtna3QgBkECSRsMAQsgBhB3CyIGIAUgBksbIgUgBE8NAQsgASEEAkACQCAFIggEQCAFQYCAgIAETw0BIAVBAnQiAxB/IQwgBCgCACEGIAQgDDYCACAGBEAgBCgCBBogBhByIAQoAgAhDAsgBCAINgIEIAMEQCAMQQAgA/wLAAsgBCgCCCIDRQ0CIARBCGohBCADKAIEIQUCQCAIaSIJQQFNBEAgBSAIQQFrcSEFDAELIAUgCEkNACAFIAhwIQULIAwgBUECdGogBDYCACADKAIAIgZFDQIgCEEBayEKIAlBAUshCwNAIAYoAgQhBAJAIAtFBEAgBCAKcSEEDAELIAQgCEkNACAEIAhwIQQLAkAgBCAFRgRAIAYhAwwBCyAMIARBAnRqIgkoAgBFBEAgCSADNgIAIAYhAyAEIQUMAQsgAyAGKAIANgIAIAYgCSgCACgCADYCACAJKAIAIAY2AgALIAMoAgAiBg0ACwwCCyAEKAIAIQYgBEEANgIAIAYEQCAEKAIEGiAGEHILIARBADYCBAwBCxAUAAsLIAEoAgQiBCAEQQFrIgVxRQRAIAUgB3EhCAwBCyAEIAdLBEAgByEIDAELIAcgBHAhCAsCQCABKAIAIgYgCEECdGoiBygCACIFRQRAIAIgASgCCDYCACABIAI2AgggByABQQhqNgIAIAIoAgAiBUUNASAFKAIEIQUCQCAEIARBAWsiB3FFBEAgBSAHcSEFDAELIAQgBUsNACAFIARwIQULIAYgBUECdGogAjYCAAwBCyACIAUoAgA2AgAgBSACNgIACyABIAEoAgxBAWo2AgxBAQs6AAQgACACNgIAC6EBAQJ/IwBBEGsiAyQAIAMgATYCBCADQQhqIABBEGogASADQQRqEBkCQCADKAIIIgBBFGoiASACRg0AIAIsAAshBCAALAAfQQBOBEAgBEEATgRAIAEgAikCADcCACABIAIoAgg2AggMAgsgASACKAIAIAIoAgQQnAEMAQsgASACKAIAIAIgBEEASCIAGyACKAIEIAQgABsQmwELIANBEGokAAt9AQN/IAEQYSICQfj///8HSQRAAkACQCACQQtPBEAgAkEHciIEQQFqEH8hAyAAIARB/////wdrNgIIIAAgAzYCACAAIAI2AgQMAQsgACACOgALIAAhAyACRQ0BCyACRQ0AIAMgASAC/AoAAAsgAiADakEAOgAAIAAPCxAHAAtvAQJ/IAAoAggiAQRAA0AgASgCACECIAEsAB9BAEgEQCABKAIcGiABKAIUEHILIAEsABNBAEgEQCABKAIQGiABKAIIEHILIAEQciACIgENAAsLIAAoAgAhASAAQQA2AgAgAQRAIAAoAgQaIAEQcgsLjQIBBn8gACgCBCICIAAoAggiA0kEQCACIAEoAgA2AgAgAiABKAIENgIEIAFCADcCACAAIAJBCGo2AgQPCwJAIAIgACgCACIEayIFQQN1IgdBAWoiAkGAgICAAkkEQEH/////ASADIARrIgZBAnUiAyACIAIgA0kbIAZB+P///wdPGyIDBH8gA0GAgICAAk8NAiADQQN0EH8FQQALIgYgBWoiAiABKAIANgIAIAIgASgCBDYCBCABQgA3AgAgAiAHQQN0ayEBIAUEQCABIAQgBfwKAAALIAAgBiADQQN0ajYCCCAAIAJBCGoiAjYCBCAAIAE2AgAgBARAIAQQcgsgACACNgIEDwsQEwALEBQAC84BAQN/IwBBEGsiAiQAIAAQEAJAIAEoAgAiA0UNACAAKAIIIAAoAgwiAEYNACAAQQhrKAIAIQQgASgCBCIABEAgACAAKAIEQQFqNgIECyACIAA2AgwgAiADNgIIIARBJGogAkEIahAdIAIoAgwiAEUNACAAIAAoAgQiA0EBazYCBCADDQAgACAAKAIAKAIIEQEAIAAQfAsCQCABKAIEIgFFDQAgASABKAIEIgBBAWs2AgQgAA0AIAEgASgCACgCCBEBACABEHwLIAJBEGokAAvdBQEHfyMAQTBrIgEkACAAKAIAIgMoAgAiBCADKAIEIgJHBEAgBCEDAkACQANAIAMoAgAQEQRAIAIgA0EIaiIDRw0BDAILC0E8EH8iAkIANwIQIAJBATYCDCACQcwXNgIAIAJCADcCBCACQgA3AjQgAkKAgID8AzcCLCACQgA3AhggAkIANwIgIAJBADYCKCABIAI2AhwgASACQQxqNgIYIAFBqgotAAA6ABAgAUEFOgAXIAFBpgooAAA2AgwgAUEAOgARIAFBADoABCABQfTk1asGNgIAIAFBBDoACyABIAFBDGo2AiQgAUEoaiACQRxqIAFBDGogAUEkahAZAkAgASgCKCIDQRRqIgQgAUYNACADLAAfQQBOBEAgBCABKQIANwIAIAQgASgCCDYCCAwBCyAEIAFBBBCbASABLAALQQBODQAgASgCCBogASgCABByCyABLAAXQQBIBEAgASgCFBogASgCDBByCyAAKAIAIQUgAigCMCIHBEAgByIDIAIoAjQiBEcEQANAAkAgBEEEaygCACIDRQ0AIAMgAygCBCIGQQFrNgIEIAYNACADIAMoAgAoAggRAQAg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IAZBAmoiDiEGIAcgDk0NAAJAA0AgBiAKai0AACIJQQlrIg9BF01BAEEBIA90QY2AgARxGw0BAkAgCUHbAGsOAwIAAgALIAZBAWoiBiAHRw0ACyAHIQYLIAYgDk0NACAGQQFqIgkgB08NACAGIApqLQAAQd0ARw0AIAkgCmotAABBOkYNAgsgAUEBaiIBIAhHDQALIAghAQsCQAJAAkAgAUEBayIJIAEgAUEBSxsgASACIARPGyIIQQN0IgYQcSIHRQRAIAAoAiQiAUUNAUHEFCAAKAIsIAERAAAMAQsCQCABIAhLBEAgBkUNASAHIAxBCGogBvwKAAAMAQsgByAENgIEIAcgAjYCACABQQJJDQAgCUEDdCICRQ0AIAdBCGogDEEIaiAC/AoAAAsgACgCWCECIAAoAmAiBiAAKAJkIgRJDQIgAiAEQQF2IARqQQggBBsiBCAAKAJcbBBzIgINASAAKAIkIgFFDQBB1RQgACgCLCABEQAACyAHEHJBfyERDAgLIAAgBDYCZCAAIAI2AlggACgCYCEGCyADIA1rIQQgCiANaiENIAIgACgCXCIJIAZsaiEDIAkEQCADQQAgCfwLAAsgAyAENgIEIAMgDTYCACADIA0gBBA2NgIIIAAgACgCYEEBajYCYCADIAg2AhggAyAHNgIUIAFBAEgEQEF/IREMBwsgASALaiELDAELIAEgCmotAABB2wBHDQFBACETIBIgC2siFUEBayIDQQAgAyAVTRshFyABQQFqIQhBACEOQQAhCUEAIRBBACEGA0BB5wcgBiAGQecHTBshGCAMIBBBA3RqKAIEIQ8DQAJ/AkACQAJAAkAgCCAPSQRAAkACQAJAIAggCmoiAS0AAEHbAGsOAwwABAELIAhBAWoiAiAAKAIEIgNPDQECQCACIApqLQAAIgJBIWtB/wFxQQ9JDQAgAkH/AXFBCmsOdQACAgACAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgAAAAAAAAACAgICAgICAgICAgICAgICAgICAgICAgICAgAAAAAAAAICAgICAgICAgICAgICAgICAgICAgICAgICAAAAAAILIA4gCCAJGyEOIBMgECAJGyETIAhBAmoiCQwHCyAAKAIEIQMLIAEgAyAIayAFQSxqEDciBEH/AE0NAkEAIQNB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IghrSg0AIABBICACIAggA0ECaiAHIAxBEGprIgYgBkECayADSBsgBiADGyIDaiIHIAQQbCAAIA4gDRBoIABBMCACIAcgBEGAgARzEGwgACAMQRBqIAYQaCAAQTAgAyAGa0EAQQAQbCAAIA8gCRBoIABBICACIAcgBEGAwABzEGwgAiAHIAIgB0obIQoLIAxBsARqJAAgCgutBQIGfgR/IAEgASgCAEEHakF4cSIBQRBqNgIAIAAgASkDACECIAEpAwghBiMAQSBrIgEkACAGQv///////z+DIQMCfiAGQjCIQv//AYMiBKciAEGB+ABrQf0PTQRAIANCBIYgAkI8iIQhAyAAQYD4AGutIQQCQCACQv//////////D4MiAkKBgICAgICAgAhaBEAgA0IBfCEDDAELIAJCgICAgICAgIAIUg0AIANCAYMgA3whAwtCACADIANC/////////wdWIgAbIQIgAK0gBHwMAQsCQCACIAOEUA0AIARC//8BUg0AIANCBIYgAkI8iIRCgICAgICAgASEIQJC/w8MAQsgAEH+hwFLBEBCACECQv8PDAELQYD4AEGB+AAgBFAiCBsiCyAAayIJQfAASgRAQgAhAkIADAELIAFBEGohCiACIQQgAyADQoCAgICAgMAAhCAIGyIDIQUCQEGAASAJayIIQcAAcQRAIAIgCEFAaq2GIQVCACEEDAELIAhFDQAgBSAIrSIHhiAEQcAAIAhrrYiEIQUgBCAHhiEECyAKIAQ3AwAgCiAFNwMIAkAgCUHAAHEEQCADIAlBQGqtiCECQgAhAwwBCyAJRQ0AIANBwAAgCWuthiACIAmtIgSIhCECIAMgBIghAwsgASACNwMAIAEgAzcDCCABKQMIQgSGIAEpAwAiA0I8iIQhAgJAIAAgC0cgASkDECABKQMYhEIAUnGtIANC//////////8Pg4QiA0KBgICAgICAgAhaBEAgAkIBfCECDAELIANCgICAgICAgIAIUg0AIAJCAYMgAnwhAgsgAkKAgICAgICACIUgAiACQv////////8HViIAGyECIACtCyEDIAFBIGokACAGQoCAgICAgICAgH+DIANCNIaEIAKEvzkDAAumAQEFfyAAKAJUIgMoAgAhBSADKAIEIgQgACgCFCAAKAIcIgdrIgYgBCAGSRsiBgRAIAUgByAGEFYgAyADKAIAIAZqIgU2AgAgAyADKAIEIAZrIgQ2AgQLIAQgAiACIARLGyIEBEAgBSABIAQQViADIAMoAgAgBGoiBTYCACADIAMoAgQgBGs2AgQLIAVBADoAACAAIAAoAiwiAzYCHCAAIAM2AhQgAguiAgAgAEUEQEEADwsCfwJAIAAEfyABQf8ATQ0BAkBBzN4AKAIAKAIARQRAIAFBgH9xQYC/A0YNA0Gw3QBBGTYCAAwBCyABQf8PTQRAIAAgAUE/cUGAAXI6AAEgACABQQZ2QcABcjoAAEECDAQLIAFBgEBxQYDAA0cgAUGAsANPcUUEQCAAIAFBP3FBgAFyOgACIAAgAUEMdkHgAXI6AAAgACABQQZ2QT9xQYABcjoAAUEDDAQLIAFBgIAEa0H//z9NBEAgACABQT9xQYABcjoAAyAAIAFBEnZB8AFyOgAAIAAgAUEGdkE/cUGAAXI6AAIgACABQQx2QT9xQYABcjoAAUEEDAQLQbDdAEEZNgIAC0F/BUEBCwwBCyAAIAE6AABBAQsL8SgBC38jAEEQayIKJAACQAJAAkACQAJAAkACQAJAAkACQCAAQfQBTQRAQfDeACgCACIGQRAgAEELakH4A3EgAEELSRsiBEEDdiIBdiIAQQNxBEACQCAAQX9zQQFxIAFqIgRBA3QiAUGY3wBqIgAgAUGg3wBqKAIAIgEoAggiA0YEQEHw3gAgBkF+IAR3cTYCAAwBCyADIAA2AgwgACADNgIICyABQQhqIQAgASAEQQN0IgRBA3I2AgQgASAEaiIBIAEoAgRBAXI2AgQMCwsgBEH43gAoAgAiCE0NASAABEACQCAAIAF0QQIgAXQiAEEAIABrcnFoIgFBA3QiAEGY3wBqIgMgAEGg3wBqKAIAIgAoAggiAkYEQEHw3gAgBkF+IAF3cSIGNgIADAELIAIgAzYCDCADIAI2AggLIAAgBEEDcjYCBCAAIARqIgIgAUEDdCIBIARrIgRBAXI2AgQgACABaiAENgIAIAgEQCAIQXhxQZjfAGohA0GE3wAoAgAhAQJ/IAZBASAIQQN2dCIFcUUEQEHw3gAgBSAGcjYCACADDAELIAMoAggLIQUgAyABNgIIIAUgATYCDCABIAM2AgwgASAFNgIICyAAQQhqIQBBhN8AIAI2AgBB+N4AIAQ2AgAMCwtB9N4AKAIAIgtFDQEgC2hBAnRBoOEAaigCACICKAIEQXhxIARrIQEgAiEDA0ACQCADKAIQIgBFBEAgAygCFCIARQ0BCyAAKAIEQXhxIARrIgMgASABIANLIgMbIQEgACACIAMbIQIgACEDDAELCyACKAIYIQkgAiACKAIMIgBHBEAgAigCCCIDIAA2AgwgACADNgIIDAoLIAIoAhQiAwR/IAJBFGoFIAIoAhAiA0UNAyACQRBqCyEFA0AgBSEHIAMiAEEUaiEFIAAoAhQiAw0AIABBEGohBSAAKAIQIgMNAAsgB0EANgIADAkLQX8hBCAAQb9/Sw0AIABBC2oiAUF4cSEEQfTeACgCACIJRQ0AQR8hCCAAQfT//wdNBEAgBEEmIAFBCHZnIgBrdkEBcSAAQQF0a0E+aiEIC0EAIARrIQECQAJAAkAgCEECdEGg4QBqKAIAIgNFBEBBACEADAELQQAhACAEQRkgCEEBdmtBACAIQR9HG3QhAgNAAkAgAygCBEF4cSAEayIGIAFPDQAgAyEFIAYiAQ0AQQAhASAFIQAMAwsgACADKAIUIgYgBiADIAJBHXZBBHFqKAIQIgdGGyAAIAYbIQAgAkEBdCECIAciAw0ACwsgACAFckUEQEEAIQVBAiAIdCIAQQAgAGtyIAlxIgBFDQMgAGhBAnRBoOEAaigCACEACyAARQ0BCwNAIAAoAgRBeHEgBGsiBiABSSECIAYgASACGyEBIAAgBSACGyEFIAAoAhAiAwR/IAMFIAAoAhQLIgANAAsLIAVFDQAgAUH43gAoAgAgBGtPDQAgBSgCGCEHIAUgBSgCDCIARwRAIAUoAggiAyAANgIMIAAgAzYCCAwICyAFKAIUIgMEfyAFQRRqBSAFKAIQIgNFDQMgBUEQagshAgNAIAIhBiADIgBBFGohAiAAKAIUIgMNACAAQRBqIQIgACgCECIDDQALIAZBADYCAAwHCyAEQfjeACgCACIATQRAQYTfACgCACEBAkAgACAEayIDQRBPBEAgASAEaiICIANBAXI2AgQgACABaiADNgIAIAEgBEEDcjYCBAwBCyABIABBA3I2AgQgACABaiIAIAAoAgRBAXI2AgRBACECQQAhAwtB+N4AIAM2AgBBhN8AIAI2AgAgAUEIaiEADAkLIARB/N4AKAIAIgJJBEBB/N4AIAIgBGsiATYCAEGI3wBBiN8AKAIAIgAgBGoiAzYCACADIAFBAXI2AgQgACAEQQNyNgIEIABBCGohAAwJC0EAIQAgBEEvaiIIAn9ByOIAKAIABEBB0OIAKAIADAELQdTiAEJ/NwIAQcziAEKAoICAgIAENwIAQcjiACAKQQxqQXBxQdiq1aoFczYCAEHc4gBBADYCAEGs4gBBADYCAEGAIAsiAWoiBkEAIAFrIgdxIgUgBE0NCEGo4gAoAgAiAQRAQaDiACgCACIDIAVqIgkgA00NCSABIAlJDQkLAkBBrOIALQAAQQRxRQRAAkACQAJAAkBBiN8AKAIAIgEEQEGw4gAhAANAIAAoAgAiAyABTQRAIAEgAyAAKAIEakkNAwsgACgCCCIADQALC0EAEHUiAkF/Rg0DIAUhBkHM4gAoAgAiAEEBayIBIAJxBEAgBSACayABIAJqQQAgAGtxaiEGCyAEIAZPDQNBqOIAKAIAIgAEQEGg4gAoAgAiASAGaiIDIAFNDQQgACADSQ0ECyAGEHUiACACRw0BDAULIAYgAmsgB3EiBhB1IgIgACgCACAAKAIEakYNASACIQALIABBf0YNASAEQTBqIAZNBEAgACECDAQLQdDiACgCACIBIAggBmtqQQAgAWtxIgEQdUF/Rg0BIAEgBmohBiAAIQIMAwsgAkF/Rw0CC0Gs4gBBrOIAKAIAQQRyNgIACyAFEHUhAkEAEHUhACACQX9GDQUgAEF/Rg0FIAAgAk0NBSAAIAJrIgYgBEEoak0NBQtBoOIAQaDiACgCACAGaiIANgIAQaTiACgCACAASQRAQaTiACAANgIACwJAQYjfACgCACIBBEBBsOIAIQADQCACIAAoAgAiAyAAKAIEIgVqRg0CIAAoAggiAA0ACwwEC0GA3wAoAgAiAEEAIAAgAk0bRQRAQYDfACACNgIAC0EAIQBBtOIAIAY2AgBBsOIAIAI2AgBBkN8AQX82AgBBlN8AQcjiACgCADYCAEG84gBBADYCAANAIABBA3QiAUGg3wBqIAFBmN8AaiIDNgIAIAFBpN8AaiADNgIAIABBAWoiAEEgRw0AC0H83gAgBkEoayIAQXggAmtBB3EiAWsiAzYCAEGI3wAgASACaiIBNgIAIAEgA0EBcjYCBCAAIAJqQSg2AgRBjN8AQdjiACgCADYCAAwECyABIAJPDQIgASADSQ0CIAAoAgxBCHENAiAAIAUgBmo2AgRBiN8AIAFBeCABa0EHcSIAaiIDNgIAQfzeAEH83gAoAgAgBmoiAiAAayIANgIAIAMgAEEBcjYCBCABIAJqQSg2AgRBjN8AQdjiACgCADYCAAwDC0EAIQAMBgtBACEADAQLQYDfACgCACACSwRAQYDfACACNgIACyACIAZqIQNBsOIAIQACQANAIAMgACgCACIFRwRAIAAoAggiAA0BDAILCyAALQAMQQhxRQ0DC0Gw4gAhAANAAkAgACgCACIDIAFNBEAgASADIAAoAgRqIgNJDQELIAAoAgghAAwBCwtB/N4AIAZBKGsiAEF4IAJrQQdxIgVrIgc2AgBBiN8AIAIgBWoiBTYCACAFIAdBAXI2AgQgACACakEoNgIEQYzfAEHY4gAoAgA2AgAgASADQScgA2tBB3FqQS9rIgAgACABQRBqSRsiBUEbNgIEIAVBuOIAKQIANwIQIAVBsOIAKQIANwIIQbjiACAFQQhqNgIAQbTiACAGNgIAQbDiACACNgIAQbziAEEANgIAIAVBGGohAANAIABBBzYCBCAAQQhqIQIgAEEEaiEAIAIgA0kNAAsgASAFRg0AIAUgBSgCBEF+cTYCBCABIAUgAWsiAkEBcjYCBCAFIAI2AgACfyACQf8BTQRAIAJBeHFBmN8AaiEAAn9B8N4AKAIAIgNBASACQQN2dCICcUUEQEHw3gAgAiADcjYCACAADAELIAAoAggLIQMgACABNgIIIAMgATYCDEEMIQJBCAwBC0EfIQAgAkH///8HTQRAIAJBJiACQQh2ZyIAa3ZBAXEgAEEBdGtBPmohAAsgASAANgIcIAFCADcCECAAQQJ0QaDhAGohAwJAAkBB9N4AKAIAIgVBASAAdCIGcUUEQEH03gAgBSAGcjYCACADIAE2AgAgASADNgIYDAELIAJBGSAAQQF2a0EAIABBH0cbdCEAIAMoAgAhBQNAIAUiAygCBEF4cSACRg0CIABBHXYhBSAAQQF0IQAgAyAFQQRxaiIGKAIQIgUNAAsgBiABNgIQIAEgAzYCGAtBCCECIAEhAyABIQBBDAwBCyADKAIIIgAgATYCDCADIAE2AgggASAANgIIQQAhAEEYIQJBDAsgAWogAzYCACABIAJqIAA2AgALQfzeACgCACIAIARNDQBB/N4AIAAgBGsiATYCAEGI3wBBiN8AKAIAIgAgBGoiAzYCACADIAFBAXI2AgQgACAEQQNyNgIEIABBCGohAAwEC0Gw3QBBMDYCAEEAIQAMAwsgACACNgIAIAAgACgCBCAGajYCBCACQXggAmtBB3FqIgkgBEEDcjYCBCAFQXggBWtBB3FqIgYgBCAJaiIBayECAkBBiN8AKAIAIAZGBEBBiN8AIAE2AgBB/N4AQfzeACgCACACaiIENgIAIAEgBEEBcjYCBAwBC0GE3wAoAgAgBkYEQEGE3wAgATYCAEH43gBB+N4AKAIAIAJqIgQ2AgAgASAEQQFyNgIEIAEgBGogBDYCAAwBCyAGKAIEIgVBA3FBAUYEQCAFQXhxIQggBigCDCEEAkAgBUH/AU0EQCAGKAIIIgAgBEYEQEHw3gBB8N4AKAIAQX4gBUEDdndxNgIADAILIAAgBDYCDCAEIAA2AggMAQsgBigCGCEHAkAgBCAGRwRAIAYoAggiBSAENgIMIAQgBTYCCAwBCwJAIAYoAhQiBQR/IAZBFGoFIAYoAhAiBUUNASAGQRBqCyEAA0AgACEDIAUiBEEUaiEAIAQoAhQiBQ0AIARBEGohACAEKAIQIgUNAAsgA0EANgIADAELQQAhBAsgB0UNAAJAIAYoAhwiAEECdEGg4QBqIgUoAgAgBkYEQCAFIAQ2AgAgBA0BQfTeAEH03gAoAgBBfiAAd3E2AgAMAgsCQCAGIAcoAhBGBEAgByAENgIQDAELIAcgBDYCFAsgBEUNAQsgBCAHNgIYIAYoAhAiBQRAIAQgBTYCECAFIAQ2AhgLIAYoAhQiBUUNACAEIAU2AhQgBSAENgIYCyAGIAhqIgYoAgQhBSACIAhqIQILIAYgBUF+cTYCBCABIAJBAXI2AgQgASACaiACNgIAIAJB/wFNBEAgAkF4cUGY3wBqIQQCf0Hw3gAoAgAiBUEBIAJBA3Z0IgJxRQRAQfDeACACIAVyNgIAIAQMAQsgBCgCCAshAiAEIAE2AgggAiABNgIMIAEgBDYCDCABIAI2AggMAQtBHyEEIAJB////B00EQCACQSYgAkEIdmciBGt2QQFxIARBAXRrQT5qIQQLIAEgBDYCHCABQgA3AhAgBEECdEGg4QBqIQUCQAJAQfTeACgCACIAQQEgBHQiBnFFBEBB9N4AIAAgBnI2AgAgBSABNgIAIAEgBTYCGAwBCyACQRkgBEEBdmtBACAEQR9HG3QhBCAFKAIAIQADQCAAIgUoAgRBeHEgAkYNAiAEQR12IQAgBEEBdCEEIAUgAEEEcWoiBigCECIADQALIAYgATYCECABIAU2AhgLIAEgATYCDCABIAE2AggMAQsgBSgCCCIEIAE2AgwgBSABNgIIIAFBADYCGCABIAU2AgwgASAENgIICyAJQQhqIQAMAgsCQCAHRQ0AAkAgBSgCHCICQQJ0QaDhAGoiAygCACAFRgRAIAMgADYCACAADQFB9N4AIAlBfiACd3EiCTYCAAwCCwJAIAUgBygCEEYEQCAHIAA2AhAMAQsgByAANgIUCyAARQ0BCyAAIAc2AhggBSgCECIDBEAgACADNgIQIAMgADYCGAsgBSgCFCIDRQ0AIAAgAzYCFCADIAA2AhgLAkAgAUEPTQRAIAUgASAEaiIAQQNyNgIEIAAgBWoiACAAKAIEQQFyNgIEDAELIAUgBEEDcjYCBCAEIAVqIgIgAUEBcjYCBCABIAJqIAE2AgAgAUH/AU0EQCABQXhxQZjfAGohAAJ/QfDeACgCACIEQQEgAUEDdnQiAXFFBEBB8N4AIAEgBHI2AgAgAAwBCyAAKAIICyEBIAAgAjYCCCABIAI2AgwgAiAANgIMIAIgATYCCAwBC0EfIQAgAUH///8HTQRAIAFBJiABQQh2ZyIAa3ZBAXEgAEEBdGtBPmohAAsgAiAANgIcIAJCADcCECAAQQJ0QaDhAGohBAJAAkAgCUEBIAB0IgNxRQRAQfTeACADIAlyNgIAIAQgAjYCACACIAQ2AhgMAQsgAUEZIABBAXZrQQAgAEEfRxt0IQAgBCgCACEDA0AgAyIEKAIEQXhxIAFGDQIgAEEddiEDIABBAXQhACAEIANBBHFqIgYoAhAiAw0ACyAGIAI2AhAgAiAENgIYCyACIAI2AgwgAiACNgIIDAELIAQoAggiACACNgIMIAQgAjYCCCACQQA2AhggAiAENgIMIAIgADYCCAsgBUEIaiEADAELAkAgCUUNAAJAIAIoAhwiBUECdEGg4QBqIgMoAgAgAkYEQCADIAA2AgAgAA0BQfTeACALQX4gBXdxNgIADAILAkAgAiAJKAIQRgRAIAkgADYCEAwBCyAJIAA2AhQLIABFDQELIAAgCTYCGCACKAIQIgMEQCAAIAM2AhAgAyAANgIYCyACKAIUIgNFDQAgACADNgIUIAMgADYCGAsCQCABQQ9NBEAgAiABIARqIgBBA3I2AgQgACACaiIAIAAoAgRBAXI2AgQMAQsgAiAEQQNyNgIEIAIgBGoiBCABQQFyNgIEIAEgBGogATYCACAIBEAgCEF4cUGY3wBqIQNBhN8AKAIAIQACf0EBIAhBA3Z0IgUgBnFFBEBB8N4AIAUgBnI2AgAgAwwBCyADKAIICyEFIAMgADYCCCAFIAA2AgwgACADNgIMIAAgBTYCCAtBhN8AIAQ2AgBB+N4AIAE2AgALIAJBCGohAAsgCkEQaiQAIAAL/wsBCH8CQCAARQ0AIABBCGsiAyAAQQRrKAIAIgFBeHEiAGohBAJAIAFBAXENACABQQJxRQ0BIAMgAygCACICayIDQYDfACgCAEkNASAAIAJqIQACQAJAAkBBhN8AKAIAIANHBEAgAygCDCEBIAJB/wFNBEAgASADKAIIIgVHDQJB8N4AQfDeACgCAEF+IAJBA3Z3cTYCAAwFCyADKAIYIQYgASADRwRAIAMoAggiAiABNgIMIAEgAjYCCAwECyADKAIUIgIEfyADQRRqBSADKAIQIgJFDQMgA0EQagshBQNAIAUhCCACIgFBFGohBSABKAIUIgINACABQRBqIQUgASgCECICDQALIAhBADYCAAwDCyAEKAIEIgFBA3FBA0cNA0H43gAgADYCACAEIAFBfnE2AgQgAyAAQQFyNgIEIAQgADYCAA8LIAUgATYCDCABIAU2AggMAgtBACEBCyAGRQ0AAkAgAygCHCIFQQJ0QaDhAGoiAigCACADRgRAIAIgATYCACABDQFB9N4AQfTeACgCAEF+IAV3cTYCAAwCCwJAIAMgBigCEEYEQCAGIAE2AhAMAQsgBiABNgIUCyABRQ0BCyABIAY2AhggAygCECICBEAgASACNgIQIAIgATYCGAsgAygCFCICRQ0AIAEgAjYCFCACIAE2AhgLIAMgBE8NACAEKAIEIgJBAXFFDQACQAJAAkACQCACQQJxRQRAQYjfACgCACAERgRAQYjfACADNgIAQfzeAEH83gAoAgAgAGoiADYCACADIABBAXI2AgQgA0GE3wAoAgBHDQZB+N4AQQA2AgBBhN8AQQA2AgAPC0GE3wAoAgAiBiAERgRAQYTfACADNgIAQfjeAEH43gAoAgAgAGoiADYCACADIABBAXI2AgQgACADaiAANgIADwsgAkF4cSAAaiEAIAQoAgwhASACQf8BTQRAIAQoAggiBSABRgRAQfDeAEHw3gAoAgBBfiACQQN2d3E2AgAMBQsgBSABNgIMIAEgBTYCCAwECyAEKAIYIQcgASAERwRAIAQoAggiAiABNgIMIAEgAjYCCAwDCyAEKAIUIgIEfyAEQRRqBSAEKAIQIgJFDQIgBEEQagshBQNAIAUhCCACIgFBFGohBSABKAIUIgINACABQRBqIQUgASgCECICDQALIAhBADYCAAwCCyAEIAJBfnE2AgQgAyAAQQFyNgIEIAAgA2ogADYCAAwDC0EAIQELIAdFDQACQCAEKAIcIgVBAnRBoOEAaiICKAIAIARGBEAgAiABNgIAIAENAUH03gBB9N4AKAIAQX4gBXdxNgIADAILAkAgBCAHKAIQRgRAIAcgATYCEAwBCyAHIAE2AhQLIAFFDQELIAEgBzYCGCAEKAIQIgIEQCABIAI2AhAgAiABNgIYCyAEKAIUIgJFDQAgASACNgIUIAIgATYCGAsgAyAAQQFyNgIEIAAgA2ogADYCACADIAZHDQBB+N4AIAA2AgAPCyAAQf8BTQRAIABBeHFBmN8AaiEBAn9B8N4AKAIAIgJBASAAQQN2dCIAcUUEQEHw3gAgACACcjYCACABDAELIAEoAggLIQAgASADNgIIIAAgAzYCDCADIAE2AgwgAyAANgIIDwtBHyEBIABB////B00EQCAAQSYgAEEIdmciAWt2QQFxIAFBAXRrQT5qIQELIAMgATYCHCADQgA3AhAgAUECdEGg4QBqIQUCfwJAAn9B9N4AKAIAIgJBASABdCIEcUUEQEH03gAgAiAEcjYCACAFIAM2AgBBGCEBQQgMAQsgAEEZIAFBAXZrQQAgAUEfRxt0IQEgBSgCACEFA0AgBSICKAIEQXhxIABGDQIgAUEddiEFIAFBAXQhASACIAVBBHFqIgQoAhAiBQ0ACyAEIAM2AhBBGCEBIAIhBUEICyEAIAMhAiADDAELIAIoAggiBSADNgIMIAIgAzYCCEEYIQBBCCEBQQALIQQgASADaiAFNgIAIAMgAjYCDCAAIANqIAQ2AgBBkN8AQZDfACgCAEEBayIDQX8gAxs2AgALC4gIAQt/IABFBEAgARBxDwsgAUFATwRAQbDdAEEwNgIAQQAPCwJ/QRAgAUELakF4cSABQQtJGyEDIABBCGsiBSgCBCIHQXhxIQICQCAHQQNxRQRAIANBgAJJDQEgA0EEaiACTQRAIAUhBCACIANrQdDiACgCAEEBdE0NAgtBAAwCCyACIAVqIQYCQCACIANPBEAgAiADayICQRBJDQEgBSADIAdBAXFyQQJyNgIEIAMgBWoiAyACQQNyNgIEIAYgBigCBEEBcjYCBCADIAIQdAwBC0GI3wAoAgAgBkYEQEH83gAoAgAgAmoiAiADTQ0CIAUgAyAHQQFxckECcjYCBCADIAVqIgcgAiADayIDQQFyNgIEQfzeACADNgIAQYjfACAHNgIADAELQYTfACgCACAGRgRAQfjeACgCACACaiICIANJDQICQCACIANrIgRBEE8EQCAFIAMgB0EBcXJBAnI2AgQgAyAFaiIDIARBAXI2AgQgAiAFaiICIAQ2AgAgAiACKAIEQX5xNgIEDAELIAUgB0EBcSACckECcjYCBCACIAVqIgMgAygCBEEBcjYCBEEAIQRBACEDC0GE3wAgAzYCAEH43gAgBDYCAAwBCyAGKAIEIghBAnENASAIQXhxIAJqIgogA0kNASAKIANrIQsgBigCDCECAkAgCEH/AU0EQCAGKAIIIgQgAkYEQEHw3gBB8N4AKAIAQX4gCEEDdndxNgIADAILIAQgAjYCDCACIAQ2AggMAQsgBigCGCEJAkAgAiAGRwRAIAYoAggiBCACNgIMIAIgBDYCCAwBCwJAIAYoAhQiBAR/IAZBFGoFIAYoAhAiBEUNASAGQRBqCyEIA0AgCCEMIAQiAkEUaiEIIAIoAhQiBA0AIAJBEGohCCACKAIQIgQNAAsgDEEANgIADAELQQAhAgsgCUUNAAJAIAYoAhwiCEECdEGg4QBqIgQoAgAgBkYEQCAEIAI2AgAgAg0BQfTeAEH03gAoAgBBfiAId3E2AgAMAgsCQCAGIAkoAhBGBEAgCSACNgIQDAELIAkgAjYCFAsgAkUNAQsgAiAJNgIYIAYoAhAiBARAIAIgBDYCECAEIAI2AhgLIAYoAhQiBEUNACACIAQ2AhQgBCACNgIYCyALQQ9NBEAgBSAHQQFxIApyQQJyNgIEIAUgCmoiAyADKAIEQQFyNgIEDAELIAUgAyAHQQFxckECcjYCBCADIAVqIgMgC0EDcjYCBCAFIApqIgIgAigCBEEBcjYCBCADIAsQdAsgBSEECyAECyIEBEAgBEEIag8LIAEQcSIERQRAQQAPCyAEIABBfEF4IABBBGsoAgAiBUEDcRsgBUF4cWoiBSABIAEgBUsbEFYgABByIAQLqAsBB38gACABaiEEAkACQCAAKAIEIgJBAXENACACQQJxRQ0BIAAoAgAiAyABaiEBAkACQAJAIAAgA2siAEGE3wAoAgBHBEAgACgCDCECIANB/wFNBEAgAiAAKAIIIgVHDQJB8N4AQfDeACgCAEF+IANBA3Z3cTYCAAwFCyAAKAIYIQYgACACRwRAIAAoAggiAyACNgIMIAIgAzYCCAwECyAAKAIUIgMEfyAAQRRqBSAAKAIQIgNFDQMgAEEQagshBQNAIAUhCCADIgJBFGohBSACKAIUIgMNACACQRBqIQUgAigCECIDDQALIAhBADYCAAwDCyAEKAIEIgJBA3FBA0cNA0H43gAgATYCACAEIAJBfnE2AgQgACABQQFyNgIEIAQgATYCAA8LIAUgAjYCDCACIAU2AggMAgtBACECCyAGRQ0AAkAgACgCHCIFQQJ0QaDhAGoiAygCACAARgRAIAMgAjYCACACDQFB9N4AQfTeACgCAEF+IAV3cTYCAAwCCwJAIAAgBigCEEYEQCAGIAI2AhAMAQsgBiACNgIUCyACRQ0BCyACIAY2AhggACgCECIDBEAgAiADNgIQIAMgAjYCGAsgACgCFCIDRQ0AIAIgAzYCFCADIAI2AhgLAkACQAJAAkAgBCgCBCIDQQJxRQRAQYjfACgCACAERgRAQYjfACAANgIAQfzeAEH83gAoAgAgAWoiATYCACAAIAFBAXI2AgQgAEGE3wAoAgBHDQZB+N4AQQA2AgBBhN8AQQA2AgAPC0GE3wAoAgAiBiAERgRAQYTfACAANgIAQfjeAEH43gAoAgAgAWoiATYCACAAIAFBAXI2AgQgACABaiABNgIADwsgA0F4cSABaiEBIAQoAgwhAiADQf8BTQRAIAQoAggiBSACRgRAQfDeAEHw3gAoAgBBfiADQQN2d3E2AgAMBQsgBSACNgIMIAIgBTYCCAwECyAEKAIYIQcgAiAERwRAIAQoAggiAyACNgIMIAIgAzYCCAwDCyAEKAIUIgMEfyAEQRRqBSAEKAIQIgNFDQIgBEEQagshBQNAIAUhCCADIgJBFGohBSACKAIUIgMNACACQRBqIQUgAigCECIDDQALIAhBADYCAAwCCyAEIANBfnE2AgQgACABQQFyNgIEIAAgAWogATYCAAwDC0EAIQILIAdFDQACQCAEKAIcIgVBAnRBoOEAaiIDKAIAIARGBEAgAyACNgIAIAINAUH03gBB9N4AKAIAQX4gBXdxNgIADAILAkAgBCAHKAIQRgRAIAcgAjYCEAwBCyAHIAI2AhQLIAJFDQELIAIgBzYCGCAEKAIQIgMEQCACIAM2AhAgAyACNgIYCyAEKAIUIgNFDQAgAiADNgIUIAMgAjYCGAsgACABQQFyNgIEIAAgAWogATYCACAAIAZHDQBB+N4AIAE2AgAPCyABQf8BTQRAIAFBeHFBmN8AaiECAn9B8N4AKAIAIgNBASABQQN2dCIBcUUEQEHw3gAgASADcjYCACACDAELIAIoAggLIQEgAiAANgIIIAEgADYCDCAAIAI2AgwgACABNgIIDwtBHyECIAFB////B00EQCABQSYgAUEIdmciAmt2QQFxIAJBAXRrQT5qIQILIAAgAjYCHCAAQgA3AhAgAkECdEGg4QBqIQMCQAJAQfTeACgCACIFQQEgAnQiBHFFBEBB9N4AIAQgBXI2AgAgAyAANgIAIAAgAzYCGAwBCyABQRkgAkEBdmtBACACQR9HG3QhAiADKAIAIQUDQCAFIgMoAgRBeHEgAUYNAiACQR12IQUgAkEBdCECIAMgBUEEcWoiBCgCECIFDQALIAQgADYCECAAIAM2AhgLIAAgADYCDCAAIAA2AggPCyADKAIIIgEgADYCDCADIAA2AgggAEEANgIYIAAgAzYCDCAAIAE2AggLC1IBAn9BpNwAKAIAIgEgAEEHakF4cSICaiEAAkAgAkEAIAAgAU0bRQRAIAA/AEEQdE0NASAAEAINAQtBsN0AQTA2AgBBfw8LQaTcACAANgIAIAELBQAQAwALtAwBB38jAEEQayIEJAAgBCAANgIMAkAgAEHTAU0EQEHA0QBBgNMAIARBDGoQeCgCACEADAELIABBfE8EQBB5AAsgBCAAIABB0gFuIgZB0gFsIgNrNgIIQYDTAEHA1AAgBEEIahB4QYDTAGtBAnUhBQNAIAVBAnRBgNMAaigCACADaiEAQQUhAgJAAkADQCACIgNBL0YNASAAIANBAnRBwNEAaigCACIBbiIHIAFJDQQgA0EBaiECIAAgASAHbEcNAAsgA0EvSQ0BC0HTASEDA0AgACADbiIBIANJDQMgACABIANsRg0BIAAgA0EKaiIBbiICIAFJDQMgACABIAJsRg0BIAAgA0EMaiIBbiICIAFJDQMgACABIAJsRg0BIAAgA0EQaiIBbiICIAFJDQMgACABIAJsRg0BIAAgA0ESaiIBbiICIAFJDQMgACABIAJsRg0BIAAgA0EWaiIBbiICIAFJDQMgACABIAJsRg0BIAAgA0EcaiIBbiICIAFJDQMgACABIAJsRg0BIAAgA0EeaiIBbiICIAFJDQMgACABIAJsRg0BIAAgA0EkaiIBbiICIAFJDQMgACABIAJsRg0BIAAgA0EoaiIBbiICIAFJDQMgACABIAJsRg0BIAAgA0EqaiIBbiICIAFJDQMgACABIAJsRg0BIAAgA0EuaiIBbiICIAFJDQMgACABIAJsRg0BIAAgA0E0aiIBbiICIAFJDQMgACABIAJsRg0BIAAgA0E6aiIBbiICIAFJDQMgACABIAJsRg0BIAAgA0E8aiIBbiICIAFJDQMgACABIAJsRg0BIAAgA0HCAGoiAW4iAiABSQ0DIAAgASACbEYNASAAIANBxgBqIgFuIgIgAUkNAyAAIAEgAmxGDQEgACADQcgAaiIBbiICIAFJDQMgACABIAJsRg0BIAAgA0HOAGoiAW4iAiABSQ0DIAAgASACbEYNASAAIANB0gBqIgFuIgIgAUkNAyAAIAEgAmxGDQEgACADQdgAaiIBbiICIAFJDQMgACABIAJsRg0BIAAgA0HgAGoiAW4iAiABSQ0DIAAgASACbEYNASAAIANB5ABqIgFuIgIgAUkNAyAAIAEgAmxGDQEgACADQeYAaiIBbiICIAFJDQMgACABIAJsRg0BIAAgA0HqAGoiAW4iAiABSQ0DIAAgASACbEYNASAAIANB7ABqIgFuIgIgAUkNAyAAIAEgAmxGDQEgACADQfAAaiIBbiICIAFJDQMgACABIAJsRg0BIAAgA0H4AGoiAW4iAiABSQ0DIAAgASACbEYNASAAIANB/gBqIgFuIgIgAUkNAyAAIAEgAmxGDQEgACADQYIBaiIBbiICIAFJDQMgACABIAJsRg0BIAAgA0GIAWoiAW4iAiABSQ0DIAAgASACbEYNASAAIANBigFqIgFuIgIgAUkNAyAAIAEgAmxGDQEgACADQY4BaiIBbiICIAFJDQMgACABIAJsRg0BIAAgA0GUAWoiAW4iAiABSQ0DIAAgASACbEYNASAAIANBlgFqIgFuIgIgAUkNAyAAIAEgAmxGDQEgACADQZwBaiIBbiICIAFJDQMgACABIAJsRg0BIAAgA0GiAWoiAW4iAiABSQ0DIAAgASACbEYNASAAIANBpgFqIgFuIgIgAUkNAyAAIAEgAmxGDQEgACADQagBaiIBbiICIAFJDQMgACABIAJsRg0BIAAgA0GsAWoiAW4iAiABSQ0DIAAgASACbEYNASAAIANBsgFqIgFuIgIgAUkNAyAAIAEgAmxGDQEgACADQbQBaiIBbiICIAFJDQMgACABIAJsRg0BIAAgA0G6AWoiAW4iAiABSQ0DIAAgASACbEYNASAAIANBvgFqIgFuIgIgAUkNAyAAIAEgAmxGDQEgACADQcABaiIBbiICIAFJDQMgACABIAJsRg0BIAAgA0HEAWoiAW4iAiABSQ0DIAAgASACbEYNASAAIANBxgFqIgFuIgIgAUkNAyAAIAEgAmxGDQEgACADQdABaiIBbiICIAFJDQMgA0HSAWohAyAAIAEgAmxHDQALC0EAIAVBAWoiACAAQTBGIgAbIQUgACAGaiIGQdIBbCEDDAALAAsgBEEQaiQAIAALiQEBA38jAEEQayIFJAAgBUEAOgAOIAEgAGtBAnUhBCMAQRBrIgMkAANAIAQEQCADIAA2AgwgA0EMaiIBIAEoAgAgBEEBdiIBQQJ0ajYCACAEIAFBf3NqIAEgAygCDCACEHoiARshBCADKAIMQQRqIAAgARshAAwBCwsgA0EQaiQAIAVBEGokACAACwUAEHYACw0AIAAoAgAgASgCAEkLBAAgAAsqAQF/AkAgAEEIaiIBKAIABEAgARB9QX9HDQELIAAgACgCACgCEBEBAAsLEwAgACAAKAIAQQFrIgA2AgAgAAsEAEEACz0BAn9BASAAIABBAU0bIQEDQAJAIAEQcSICDQBB4OIAKAIAIgBFDQAgABEJAAwBCwsgAiIARQRAEHkLIAALDQAgAEGI2gA2AgAgAAtdAQN/IAAQgAEiAEH42gA2AgAgAEEEaiEEIAEQYSIDQQ1qEH8iAkEANgIIIAIgAzYCBCACIAM2AgAgAkEMaiECIANBAWoiAwRAIAIgASAD/AoAAAsgBCACNgIAIAALCgAgAC0AC0EHdgsVACAAEIIBBEAgACgCBA8LIAAQhwELGwEBf0EKIQEgABCCAQR/IAAQiAFBAWsFIAELCxIAIAAQggEEQCAAKAIADwsgAAuoAgEDfyMAQSBrIggkAEH3////ByIJIAFBf3NqIAJPBEAgABCFASEKIAFB8////wNJBEAgCCABQQF0NgIcIAggASACajYCECAIQRBqIAhBHGoQjAEoAgAQjQFBAWohCQsgCEEcaiAIQRhqIAAQjgEoAgAQjwEgCEEQaiAJEJABIAgoAhAhCSAIKAIUGiAEBEAgCSAKIAQQkQELIAYEQCAEIAlqIAcgBhCRAQsgAyAEIAVqIgdrIQIgAyAHRwRAIAQgCWogBmogBCAKaiAFaiACEJEBCyABQQpHBEAgChByCyAAIAkQkgEgACAIKAIUEJMBIAAgBCAGaiACaiIEEJQBIAhBADoADyAEIAlqIAhBD2oQiwEgCEEcahCaASAIQSBqJAAPCxCVAQALCwAgAC0AC0H/AHELDgAgACgCCEH/////B3ELGQACQCACRQ0AIAJFDQAgACABIAL8CgAACwsZACAAEIIBBEAgACABEJQBDwsgACABEJcBCwwAIAAgAS0AADoAAAsjAQJ/IwBBEGsiAiQAIAAgARB6IQMgAkEQaiQAIAEgACADGwskACAAQQtPBH8gAEEIakF4cSIAIABBAWsiACAAQQtGGwVBCgsLCwAgACABNgIAIAALCgAgACABEI4BGgsYAQF/IAEQfyECIAAgATYCBCAAIAI2AgALCwAgACABIAIQiQELCQAgACABNgIACxAAIAAgAUGAgICAeHI2AggLCQAgACABNgIECwUAEHkACwcAIABBC0kLDQAgACABQf8AcToACwuuAgEGfyABQff///8HTQRAIAAQhAEgAUkEQCABEI0BIQIjAEEQayIBJAAgAUEMaiABQQhqIAAQjgEoAgAQjwEgABCEASEEIAAQgwEhBgJAAn8gAhCWASIHBEBBASEEIAJBAWohBSAAIQIgACgCAAwBCyACQQFqIQMCQCACIARLBEAgASADEJABIAEoAgQhBSABKAIAIQIMAQsgASADEJABIAEoAgQhAyAAEIQBIQQgASgCBCEFIAEoAgAhAiADQQFrIARN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cmVmZXJlbmNlL3JuZW0vcGFja2FnZXMvY29yZS9jcHAvd2FzbS9BU1RTZXJpYWxpemVyLmNwcAAvZW1zZGsvZW1zY3JpcHRlbi9zeXN0ZW0vbGliL2xpYmN4eGFiaS9zcmMvcHJpdmF0ZV90eXBlaW5mby5jcHAAdGlwAHZpZGVvAFZpZGVvAGNhdXRpb24Ab3B0aW9uAHN0ZDo6ZXhjZXB0aW9uAGZpZ2NhcHRpb24AQWRtb25pdGlvbgBzZWN0aW9uAG1haW4AYWxpZ24AbmFuAFxuAGZvcm0AbWVudWl0ZW0ATGlzdEl0ZW0AcGFyYW0AdWwAdXJsAGNvbABodG1sAFRhYmxlSGVhZGVyQ2VsbABUYWJsZUNlbGwAbGV2ZWwAZGwAaXNUYXNrAGxpbmsATGluawBDb2RlQmxvY2sAdHJhY2sAU29mdEJyZWFrAExpbmVCcmVhawBUaGVtYXRpY0JyZWFrAGxpAGJhZF9hcnJheV9uZXdfbGVuZ3RoAFBhcmFncmFwaABTdHJpa2V0aHJvdWdoAHNlYXJjaABkaWFsb2cAU3Ryb25nAGJhc2ljX3N0cmluZwBub2RlVHlwZVRvU3RyaW5nAHdhcm5pbmcASGVhZGluZwBpbmYAXGYAdHJ1ZQBibG9ja3F1b3RlAEJsb2NrcXVvdGUAbm90ZQBmYWxzZQBiYXNlAGZpZ3VyZQBhZG1vbml0aW9uVHlwZQBVbmRlcmxpbmUATGF0ZXhNYXRoSW5saW5lAEJsYW5rTGluZQBpZnJhbWUAdGl0bGUAYXJ0aWNsZQB0YWJsZQBUYWJsZQBJbWFnZQBDb2RlAGFzaWRlAHRkAGxlZ2VuZAB0YXNrQ2hlY2tlZABkZAB0aGVhZABUYWJsZUhlYWQAc3JjAHN0ZDo6YmFkX2FsbG9jAFxiACInPTw+YAAtXwBcXAAoe1sALCJjaGlsZHJlbiI6WwBOQU4ASU5GAGNhdGNoaW5nIGEgY2xhc3Mgd2l0aG91dCBhbiBvYmplY3Q/AF1dPgAtLT4AOipfficiPABtYWlsdG86ACl9XS4hPyw7OgAsImNvbnRlbnQiOgBoNgBoNQBoNABoMwBoMgBoMQBodHRwOi8vAFRvbyBtYW55IGxpbmsgcmVmZXJlbmNlIGRlZmluaXRpb24gaW5zdGFudGlhdGlvbnMuAFVuc3Vw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AHcBAAD/AAAAegEAAH4BAABzAAAAUwIAAIMBAACFAQAAVAIAAIgBAABWAgAAVwIAAIwBAADdAQAAWQIAAFsCAACSAQAAYAIAAGMCAABpAgAAaAIAAJkBAABvAgAAcgIAAHUCAAChAQAApQEAAIACAACoAQAAgwIAAK0BAACIAgAAsAEAAIoCAACLAgAAtAEAALYBAACSAgAAuQEAAL0BAADGAQAAxgEAAMkBAADJAQAAzAEAAMwBAADcAQAA3wEAAO8BAADzAQAA8wEAAPUBAACVAQAAvwEAAPkBAAAfAgAAngEAACMCAAAzAgAAZSwAADwCAACaAQAAZiwAAEICAACAAQAAiQIAAIwCAABHAgAATwIAALkDAABxAwAAcwMAAHcDAADzAwAArAMAAK0DAACvAwAAzAMAAM0DAADOAwAAsQMAAMEDAADDAwAAywMAAMMDAADXAwAAsgMAALgDAADGAwAAwAMAANkDAADvAwAAugMAAMEDAAC4AwAAtQMAAPgDAADyAwAA+wMAAHsDAAB9AwAAUAQAAF8EAAAwBAAATwQAAGEEAACBBAAAiwQAAL8EAADPBAAAwgQAAM4EAADRBAAALwUAAGEFAACGBQAAAC0AACUtAAAnLQAALS0AAPATAAD1EwAAMgQAADQEAAA+BAAAQQQAAEIEAABCBAAASgQAAGMEAABLpgAAihwAANAQAAD6EAAA/RAAAP8QAAABHgAAlR4AAGEeAAChHgAA/x4AAAAfAAAHHwAAEB8AABUfAAAgHwAAJx8AADAfAAA3HwAAQB8AAEUfAABRHwAAUx8AAFUfAABXHwAAYB8AAGcfAACwHwAAsR8AAHAfAABxHwAAuQMAAHIfAAB1HwAA0B8AANEfAAB2HwAAdx8AAOAfAADhHwAAeh8AAHsfAADlHwAAeB8AAHkfAAB8HwAAfR8AAMkDAABrAAAA5QAAAE4hAABwIQAAfyEAAIQhAADQJAAA6SQAADAsAABfLAAAYSwAAGsCAAB9HQAAfQIAAGgsAABsLAAAUQIAAHECAABQAgAAUgIAAHMsAAB2LAAAPwIAAEACAACBLAAA4ywAAOwsAADuLAAA8ywAAEGmAABtpgAAgaYAAJumAAAjpwAAL6cAADOnAABvpwAAeqcAAHynAAB5HQAAf6cAAIenAACMpwAAZQIAAJGnAACTpwAAl6cAAKmnAABmAgAAXAIAAGECAABsAgAAagIAAJ4CAACHAgAAnQIAAFOrAAC1pwAAw6cAAJSnAACCAgAAjh0AAMinAADKpwAAZAIAAM2nAADbpwAAmwEAAHcCAAB8AgAA9qcAAEurAABMqwAAoBMAAO8TAABB/wAAWv8AACgEAQBPBAEA2AQBAPsEAQCXBQEAoQUBAKMFAQCxBQEAswUBALkFAQC7BQEAvAUBAMAMAQDyDAEAcA0BAIUNAQDAGAEA3xgBAGBuAQB/bgEAu24BANNuAQBB3wEASd8BAEvfAQBO3wEAUt8BAGnfAQBv3wEAc98BAH/fAQAi6QEAQ+kBAEGwMgvTAd8AAAAwAQAASQEAAPABAACHBQAAlh4AAJceAACYHgAAmR4AAJoeAACeHgAAUB8AAIAfAECHHwCAiB8AQI8fAICQHwBAlx8AgJgfAECfHwCAoB8AQKcfAICoHwBArx8AgLIfAACzHwAAtB8AALYfAAC8HwAAwh8AAMMfAADEHwAAxh8AAMwfAADWHwAA5B8AAOYfAADyHwAA8x8AAPQfAAD2HwAA/B8AAAD7AAAB+wAAAvsAAAX7AAAG+wAAE/sAABT7AAAV+wAAFvsAABf7AACV3wEAQZA0C6UDcwAAAHMAAABpAAAABwMAALwCAABuAAAAagAAAAwDAABlBQAAggUAAGgAAAAxAwAAdAAAAAgDAAB3AAAACgMAAHkAAAAKAwAAYQAAAL4CAABzAAAAcwAAAMUDAAATAwAAAB8AALkDAAAHHwAAuQMAAAAfAAC5AwAABx8AALkDAAAgHwAAuQMAACcfAAC5AwAAIB8AALkDAAAnHwAAuQMAAGAfAAC5AwAAZx8AALkDAABgHwAAuQMAAGcfAAC5AwAAcB8AALkDAACxAwAAuQMAAKwDAAC5AwAAsQMAAEIDAACxAwAAuQMAAHQfAAC5AwAAtwMAALkDAACuAwAAuQMAALcDAABCAwAAtwMAALkDAAC5AwAAQgMAAMEDAAATAwAAxQMAAEIDAAB8HwAAuQMAAMkDAAC5AwAAzgMAALkDAADJAwAAQgMAAMkDAAC5AwAAZgAAAGYAAABmAAAAaQAAAGYAAABsAAAAcwAAAHQAAABzAAAAdAAAAHQFAAB2BQAAdAUAAGUFAAB0BQAAawUAAH4FAAB2BQAAdAUAAG0FAABzAAAAcwBBwDcLwAKQAwAAsAMAAFIfAABUHwAAVh8AALcfAADHHwAA0h8AANMfAADXHwAA4h8AAOMfAADnHwAA9x8AAAP7AAAE+wAAuQMAAAgDAAABAwAAxQMAAAgDAAABAwAAxQMAABMDAAAAAwAAxQMAABMDAAABAwAAxQMAABMDAABCAwAAsQMAAEIDAAC5AwAAtwMAAEIDAAC5AwAAuQMAAAgDAAAAAwAAuQMAAAgDAAABAwAAuQMAAAgDAABCAwAAxQMAAAgDAAAAAwAAxQMAAAgDAAABAwAAxQMAAAgDAABCAwAAyQMAAEIDAAC5AwAAZgAAAGYAAABpAAAAZgAAAGYAAABsAAAAsA8AAHAUAAAuAQAAAQAAADAZAAAQGgAANQAAAAIAAADAGwAAABwAABAAAAADAAAAICAgICAgICAgICAgICAgIABBlDoLjRQBAAAAAwAAAAIAAAAhAABALwAAgDoAAEBAAACAWwAAQGAAAIB7AABAfgAAgKEAAECpAACAqwAAQKwAAICuAABAsQAAgLQAAAC2AABAuAAAgLsAAAC/AAAA1wAAAPcAAADCAgBAxQIAgNICAEDfAgCA5QIAQOsCAIDtAgAA7wIAQP8CAIB1AwAAfgMAAIQDAECFAwCAhwMAAPYDAACCBAAAWgUAQF8FAICJBQBAigUAgI0FAECPBQCAvgUAAMAFAADDBQAAxgUAAPMFAED0BQCABgYAQA8GAIAbBgAAHQYAQB8GAIBqBgBAbQYAgNQGAADeBgAA6QYAAP0GAED+BgCAAAcAQA0HAID2BwBA+QcAgP4HAED/BwCAMAgAQD4IAIBeCAAAiAgAAGQJAEBlCQCAcAkAAPIJAEDzCQCA+gkAQPsJAID9CQAAdgoAAPAKAEDxCgCAcAsAAPMLAED6CwCAdwwAAH8MAACEDAAATw0AAHkNAAD0DQAAPw4AAE8OAABaDgBAWw4AgAEPAEAXDwCAGg8AQB8PAIA0DwAANg8AADgPAAA6DwBAPQ8AgIUPAAC+DwBAxQ8AgMcPAEDMDwCAzg8AQNoPAIBKEABATxAAgJ4QAECfEACA+xAAAGATAEBoEwCAkBMAQJkTAIAAFAAAbRYAQG4WAICbFgBAnBYAgOsWAEDtFgCANRcAQDYXAIDUFwBA1hcAgNgXAEDbFwCAABgAQAoYAIBAGQAARBkAQEUZAIDeGQBA/xkAgB4aAEAfGgCAoBoAQKYaAICoGgBArRoAgE4bAEBPGwCAWhsAQGobAIB0GwBAfxsAgPwbAED/GwCAOxwAQD8cAIB+HABAfxwAgMAcAEDHHACA0xwAAL0fAAC/HwBAwR8AgM0fAEDPHwCA3R8AQN8fAIDtHwBA7x8AgP0fAED+HwCAECAAQCcgAIAwIABAXiAAgHogAEB+IACAiiAAQI4gAICgIABAxCAAgAAhAEABIQCAAyEAQAYhAIAIIQBACSEAgBQhAAAWIQBAGCEAgB4hAEAjIQCAJSEAACchAAApIQAALiEAADohAEA7IQCAQCEAQEQhAIBKIQBATSEAgE8hAACKIQBAiyEAgJAhAEApJACAQCQAQEokAICcJABA6SQAgAAlAEB1JwCAlCcAQHMrAIB2KwBA/ysAgOUsAEDqLACA+SwAQPwsAID+LABA/ywAgHAtAAAALgBALi4AgDAuAEBdLgCAYC4AQGMuAICALgBAmS4AgJsuAEDzLgCAAC8AQNUvAIDwLwBA/y8AgAEwAEAEMACACDAAQCAwAIAwMAAANjAAQDcwAIA9MABAPzAAgJswAECcMACAoDAAAPswAACQMQBAkTEAgJYxAECfMQCAwDEAQOUxAIDvMQAAADIAQB4yAIAqMgBARzIAgFAyAABgMgBAfzIAgIoyAECwMgCAwDIAQP8zAIDATQBA/00AgJCkAEDGpACA/qQAQP+kAIANpgBAD6YAgHOmAAB+pgAA8qYAQPemAIAApwBAFqcAgCCnAEAhpwCAiacAQIqnAIAoqABAK6gAgDaoAEA5qACAdKgAQHeoAIDOqABAz6gAgPioAED6qACA/KgAAC6pAEAvqQCAX6kAAMGpAEDNqQCA3qkAQN+pAIBcqgBAX6oAgHeqAEB5qgCA3qoAQN+qAIDwqgBA8aoAgFurAABqqwBAa6sAgOurAAAp+wAAsvsAQNL7AIA+/QBAT/0AgJD9AECR/QCAyP0AQM/9AID8/QBA//0AgBD+AEAZ/gCAMP4AQFL+AIBU/gBAZv4AgGj+AEBr/gCAAf8AQA//AIAa/wBAIP8AgDv/AEBA/wCAW/8AQGX/AIDg/wBA5v8AgOj/AEDu/wCA/P8AQP3/AIAAAQFAAgEBgDcBAUA/AQGAeQEBQIkBAYCMAQFAjgEBgJABAUCcAQGAoAEBANABAUD8AQGAnwMBANADAQBvBQEAVwgBAHcIAUB4CAGAHwkBAD8JAQBQCgFAWAoBgH8KAQDICgEA8AoBQPYKAYA5CwFAPwsBgJkLAUCcCwGAbg0BAI4NAUCPDQGArQ4BAMkOAUDKDgGA0A4BQNgOAYBVDwFAWQ8BgIYPAUCJDwGARxABQE0QAYC7EAFAvBABgL4QAUDBEAGAQBEBQEMRAYB0EQFAdREBgMURAUDIEQGAzREBANsRAQDdEQFA3xEBgDgSAUA9EgGAqRIBANQTAUDVEwGA1xMBQNgTAYBLFAFATxQBgFoUAUBbFAGAXRQBAMYUAQDBFQFA1xUBgEEWAUBDFgGAYBYBQGwWAYC5FgEAPBcBQD8XAYA7GAEARBkBQEYZAYDiGQEAPxoBQEYaAYCaGgFAnBoBgJ4aAUCiGgGAABsBQAkbAYDhGwEAQRwBQEUcAYBwHAFAcRwBgPceAUD4HgGAQx8BQE8fAYDVHwFA8R8BgP8fAQBwJAFAdCQBgPEvAUDyLwGAbmoBQG9qAYD1agEAN2sBQD9rAYBEawFARWsBgG1tAUBvbQGAl24BQJpuAYDibwEAnLwBAJ+8AQAAzAFA78wBgPrMAUD8zAGAAM0BQLPOAYC6zgFA0M4BgNLOAUDUzgGA3c4BQP3OAYBQzwFAw88BgADQAUD10AGAANEBQCbRAYAp0QFAZNEBgGrRAUBs0QGAg9EBQITRAYCM0QFAqdEBgK7RAUBB0gGARdIBAFPSAUBa0gGAXdIBQF7SAYBg0gFAf9IBgADTAUBW0wGAwdYBANvWAQD71gEAFdcBADXXAQBP1wEAb9cBAInXAQCp1wEAw9cBAADYAUD/2QGAN9oBQDraAYBt2gFAdNoBgHbaAUCD2gGAhdoBQIvaAYAA2wFAHNsBgE/hAQD/4gEA/+UBAF7pAUBf6QGArOwBALDsAQAu7QEA8O4BQPHuAYAA8AFAK/ABgDDwAUCT8AGAoPABQK7wAYCx8AFAv/ABgMHwAUDP8AGA0fABQPXwAYAN8QFArvEBgObxAUAC8gGAEPIBQDvyAYBA8gFASPIBgFDyAUBR8gGAYPIBQGXyAYAA8wFA2fYBgNz2AUDs9gGA8PYBQPz2AYAA9wFA2/cBgOD3AUDr9wGA8PcBQAv4AYAQ+AFAR/gBgFD4AUBZ+AGAYPgBQIf4AYCQ+AFArfgBgLD4AUC7+AGAwPgBQMH4AYDQ+AFA2PgBgAD5AUBX+gGAYPoBQG36AYBw+gFAfPoBgID6AUDG+gGAyPoBAMz6AUDd+gGA3/oBQOv6AYDv+gFA+voBgAD7AUCS+wGAlPsBQO/7AYD6+wEAPCFbQ0RBVEFbAAAABQAAAAYAAAAGAAAAAAAAAAcAAAAHAAAACAAAAAkAAAAZAAsAGRkZAAAAAAUAAAAAAAAJAAAAAAsAAAAAAAAAABkACgoZGRkDCgcAAQAJCxgAAAkGCwAACwAGGQAAABkZGQBBsc4ACyEOAAAAAAAAAAAZAAsNGRkZAA0AAAIACQ4AAAAJAA4AAA4AQevOAAsBDABB984ACxUTAAAAABMAAAAACQwAAAAAAAwAAAwAQaXPAAsBEABBsc8ACxUPAAAABA8AAAAACRAAAAAAABAAABAAQd/PAAsBEgBB688ACx4RAAAAABEAAAAACRIAAAAAABIAABIAABoAAAAaGhoAQaLQAAsOGgAAABoaGgAAAAAAAAkAQdPQAAsBFABB39AACxUXAAAAABcAAAAACRQAAAAAABQAABQAQY3RAAsBFgBBmdEAC4AEFQAAAAAVAAAAAAkWAAAAAAAWAAAWAAAwMTIzNDU2Nzg5QUJDREVGAAAAAAIAAAADAAAABQAAAAcAAAALAAAADQAAABEAAAATAAAAFwAAAB0AAAAfAAAAJQAAACkAAAArAAAALwAAADUAAAA7AAAAPQAAAEMAAABHAAAASQAAAE8AAABTAAAAWQAAAGEAAABlAAAAZwAAAGsAAABtAAAAcQAAAH8AAACDAAAAiQAAAIsAAACVAAAAlwAAAJ0AAACjAAAApwAAAK0AAACzAAAAtQAAAL8AAADBAAAAxQAAAMcAAADTAAAAAQAAAAsAAAANAAAAEQAAABMAAAAXAAAAHQAAAB8AAAAlAAAAKQAAACsAAAAvAAAANQAAADsAAAA9AAAAQwAAAEcAAABJAAAATwAAAFMAAABZAAAAYQAAAGUAAABnAAAAawAAAG0AAABxAAAAeQAAAH8AAACDAAAAiQAAAIsAAACPAAAAlQAAAJcAAACdAAAAowAAAKcAAACpAAAArQAAALMAAAC1AAAAuwAAAL8AAADBAAAAxQAAAMcAAADRAAAAACwAAEgqAABOU3QzX18yMTRfX3NoYXJlZF9jb3VudEUAAAAAhCwAAHwqAAAAAAAAAQAAAEAqAAAAAAAATlN0M19fMjE5X19zaGFyZWRfd2Vha19jb3VudEUAQaTVAAv4BgoAAABkAAAA6AMAABAnAACghgEAQEIPAICWmAAA4fUFAMqaOwAAAAAAAAAAMDAwMTAyMDMwNDA1MDYwNzA4MDkxMDExMTIxMzE0MTUxNjE3MTgxOTIwMjEyMjIzMjQyNTI2MjcyODI5MzAzMTMyMzMzNDM1MzYzNzM4Mzk0MDQxNDI0MzQ0NDU0NjQ3NDg0OTUwNTE1MjUzNTQ1NTU2NTc1ODU5NjA2MTYyNjM2NDY1NjY2NzY4Njk3MDcxNzI3Mzc0NzU3Njc3Nzg3OTgwODE4MjgzODQ4NTg2ODc4ODg5OTA5MTkyOTM5NDk1OTY5Nzk4OTkoLAAApCsAAAguAABOMTBfX2N4eGFiaXYxMTZfX3NoaW1fdHlwZV9pbmZvRQAAAAAoLAAA1CsAAJgrAABOMTBfX2N4eGFiaXYxMTdfX2NsYXNzX3R5cGVfaW5mb0UAAAAAAAAAyCsAAB8AAAAgAAAAIQAAACIAAAAjAAAAJAAAACUAAAAmAAAAAAAAAEgsAAAfAAAAJwAAACEAAAAiAAAAIwAAACgAAAApAAAAKgAAACgsAABULAAAyCsAAE4xMF9fY3h4YWJpdjEyMF9fc2lfY2xhc3NfdHlwZV9pbmZvRQAAAAAAAAAApCwAAB8AAAArAAAAIQAAACIAAAAjAAAALAAAAC0AAAAuAAAAKCwAALAsAADIKwAATjEwX19jeHhhYml2MTIxX192bWlfY2xhc3NfdHlwZV9pbmZvRQAAAAAAAAAsLQAACAAAAC8AAAAwAAAAAAAAAEgtAAAIAAAAMQAAADIAAAAAAAAAFC0AAAgAAAAzAAAANAAAAAAsAAAcLQAAU3Q5ZXhjZXB0aW9uAAAAACgsAAA4LQAAFC0AAFN0OWJhZF9hbGxvYwAAAAAoLAAAVC0AACwtAABTdDIwYmFkX2FycmF5X25ld19sZW5ndGgAAAAAAAAAAIQtAAABAAAANQAAADYAAAAoLAAAkC0AABQtAABTdDExbG9naWNfZXJyb3IAAAAAALQtAAABAAAANwAAADYAAAAoLAAAwC0AAIQtAABTdDEybGVuZ3RoX2Vycm9yAAAAAAAAAADoLQAAAQAAADgAAAA2AAAAKCwAAPQtAACELQAAU3QxMm91dF9vZl9yYW5nZQAAAAAALAAAEC4AAFN0OXR5cGVfaW5mbwBBodwACwYgAABwMYA=\")}function getBinarySync(file){if(ArrayBuffer.isView(file)){return file}if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}if(readBinary){return readBinary(file)}throw\"both async and sync fetching of the wasm failed\"}async function getWasmBinary(binaryFile){return getBinarySync(binaryFile)}async function instantiateArrayBuffer(binaryFile,imports){try{var binary=await getWasmBinary(binaryFile);var instance=await WebAssembly.instantiate(binary,imports);return instance}catch(reason){err(`failed to asynchronously prepare wasm: ${reason}`);abort(reason)}}async function instantiateAsync(binary,binaryFile,imports){return instantiateArrayBuffer(binaryFile,imports)}function getWasmImports(){return{env:wasmImports,wasi_snapshot_preview1:wasmImports}}async function createWasm(){function receiveInstance(instance,module){wasmExports=instance.exports;wasmMemory=wasmExports[\"memory\"];updateMemoryViews();assignWasmExports(wasmExports);removeRunDependency(\"wasm-instantiate\");return wasmExports}addRunDependency(\"wasm-instantiate\");function receiveInstantiationResult(result){return receiveInstance(result[\"instance\"])}var info=getWasmImports();if(Module[\"instantiateWasm\"]){return new Promise((resolve,reject)=>{Module[\"instantiateWasm\"](info,(mod,inst)=>{resolve(receiveInstance(mod,inst))})})}wasmBinaryFile??=findWasmBinary();var result=await instantiateAsync(wasmBinary,wasmBinaryFile,info);var exports=receiveInstantiationResult(result);return exports}class ExitStatus{name=\"ExitStatus\";constructor(status){this.message=`Program terminated with exit(${status})`;this.status=status}}var callRuntimeCallbacks=callbacks=>{while(callbacks.length>0){callbacks.shift()(Module)}};var onPostRuns=[];var addOnPostRun=cb=>onPostRuns.push(cb);var onPreRuns=[];var addOnPreRun=cb=>onPreRuns.push(cb);var base64Decode=b64=>{var b1,b2,i=0,j=0,bLength=b64.length;var output=new Uint8Array((bLength*3>>2)-(b64[bLength-2]==\"=\")-(b64[bLength-1]==\"=\"));for(;i>4;output[j+1]=b1<<4|b2>>2;output[j+2]=b2<<6|base64ReverseLookup[b64.charCodeAt(i+3)]}return output};var noExitRuntime=true;var stackRestore=val=>__emscripten_stack_restore(val);var stackSave=()=>_emscripten_stack_get_current();var UTF8Decoder=typeof TextDecoder!=\"undefined\"?new TextDecoder:undefined;var UTF8ArrayToString=(heapOrArray,idx=0,maxBytesToRead=NaN)=>{var endIdx=idx+maxBytesToRead;var endPtr=idx;while(heapOrArray[endPtr]&&!(endPtr>=endIdx))++endPtr;if(endPtr-idx>16&&heapOrArray.buffer&&UTF8Decoder){return UTF8Decoder.decode(heapOrArray.subarray(idx,endPtr))}var str=\"\";while(idx>10,56320|ch&1023)}}return str};var UTF8ToString=(ptr,maxBytesToRead)=>ptr?UTF8ArrayToString(HEAPU8,ptr,maxBytesToRead):\"\";var ___assert_fail=(condition,filename,line,func)=>abort(`Assertion failed: ${UTF8ToString(condition)}, at: `+[filename?UTF8ToString(filename):\"unknown filename\",line,func?UTF8ToString(func):\"unknown function\"]);class ExceptionInfo{constructor(excPtr){this.excPtr=excPtr;this.ptr=excPtr-24}set_type(type){HEAPU32[this.ptr+4>>2]=type}get_type(){return HEAPU32[this.ptr+4>>2]}set_destructor(destructor){HEAPU32[this.ptr+8>>2]=destructor}get_destructor(){return HEAPU32[this.ptr+8>>2]}set_caught(caught){caught=caught?1:0;HEAP8[this.ptr+12]=caught}get_caught(){return HEAP8[this.ptr+12]!=0}set_rethrown(rethrown){rethrown=rethrown?1:0;HEAP8[this.ptr+13]=rethrown}get_rethrown(){return HEAP8[this.ptr+13]!=0}init(type,destructor){this.set_adjusted_ptr(0);this.set_type(type);this.set_destructor(destructor)}set_adjusted_ptr(adjustedPtr){HEAPU32[this.ptr+16>>2]=adjustedPtr}get_adjusted_ptr(){return HEAPU32[this.ptr+16>>2]}}var exceptionLast=0;var uncaughtExceptionCount=0;var ___cxa_throw=(ptr,type,destructor)=>{var info=new ExceptionInfo(ptr);info.init(type,destructor);exceptionLast=ptr;uncaughtExceptionCount++;throw exceptionLast};var __abort_js=()=>abort(\"\");var getHeapMax=()=>536870912;var alignMemory=(size,alignment)=>Math.ceil(size/alignment)*alignment;var growMemory=size=>{var b=wasmMemory.buffer;var pages=(size-b.byteLength+65535)/65536|0;try{wasmMemory.grow(pages);updateMemoryViews();return 1}catch(e){}};var _emscripten_resize_heap=requestedSize=>{var oldSize=HEAPU8.length;requestedSize>>>=0;var maxHeapSize=getHeapMax();if(requestedSize>maxHeapSize){return false}for(var cutDown=1;cutDown<=4;cutDown*=2){var overGrownHeapSize=oldSize*(1+.2/cutDown);overGrownHeapSize=Math.min(overGrownHeapSize,requestedSize+100663296);var newSize=Math.min(maxHeapSize,alignMemory(Math.max(requestedSize,overGrownHeapSize),65536));var replacement=growMemory(newSize);if(replacement){return true}}return false};var getCFunc=ident=>{var func=Module[\"_\"+ident];return func};var writeArrayToMemory=(array,buffer)=>{HEAP8.set(array,buffer)};var lengthBytesUTF8=str=>{var len=0;for(var i=0;i=55296&&c<=57343){len+=4;++i}else{len+=3}}return len};var stringToUTF8Array=(str,heap,outIdx,maxBytesToWrite)=>{if(!(maxBytesToWrite>0))return 0;var startIdx=outIdx;var endIdx=outIdx+maxBytesToWrite-1;for(var i=0;i=endIdx)break;heap[outIdx++]=u}else if(u<=2047){if(outIdx+1>=endIdx)break;heap[outIdx++]=192|u>>6;heap[outIdx++]=128|u&63}else if(u<=65535){if(outIdx+2>=endIdx)break;heap[outIdx++]=224|u>>12;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}else{if(outIdx+3>=endIdx)break;heap[outIdx++]=240|u>>18;heap[outIdx++]=128|u>>12&63;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63;i++}}heap[outIdx]=0;return outIdx-startIdx};var stringToUTF8=(str,outPtr,maxBytesToWrite)=>stringToUTF8Array(str,HEAPU8,outPtr,maxBytesToWrite);var stackAlloc=sz=>__emscripten_stack_alloc(sz);var stringToUTF8OnStack=str=>{var size=lengthBytesUTF8(str)+1;var ret=stackAlloc(size);stringToUTF8(str,ret,size);return ret};var ccall=(ident,returnType,argTypes,args,opts)=>{var toC={string:str=>{var ret=0;if(str!==null&&str!==undefined&&str!==0){ret=stringToUTF8OnStack(str)}return ret},array:arr=>{var ret=stackAlloc(arr.length);writeArrayToMemory(arr,ret);return ret}};function convertReturnValue(ret){if(returnType===\"string\"){return UTF8ToString(ret)}if(returnType===\"boolean\")return Boolean(ret);return ret}var func=getCFunc(ident);var cArgs=[];var stack=0;if(args){for(var i=0;i{var numericArgs=!argTypes||argTypes.every(type=>type===\"number\"||type===\"boolean\");var numericRet=returnType!==\"string\";if(numericRet&&numericArgs&&!opts){return getCFunc(ident)}return(...args)=>ccall(ident,returnType,argTypes,args,opts)};for(var base64ReverseLookup=new Uint8Array(123),i=25;i>=0;--i){base64ReverseLookup[48+i]=52+i;base64ReverseLookup[65+i]=i;base64ReverseLookup[97+i]=26+i}base64ReverseLookup[43]=62;base64ReverseLookup[47]=63;{if(Module[\"noExitRuntime\"])noExitRuntime=Module[\"noExitRuntime\"];if(Module[\"print\"])out=Module[\"print\"];if(Module[\"printErr\"])err=Module[\"printErr\"];if(Module[\"wasmBinary\"])wasmBinary=Module[\"wasmBinary\"];if(Module[\"arguments\"])arguments_=Module[\"arguments\"];if(Module[\"thisProgram\"])thisProgram=Module[\"thisProgram\"]}Module[\"ccall\"]=ccall;Module[\"cwrap\"]=cwrap;Module[\"UTF8ToString\"]=UTF8ToString;var _parseMarkdown,__emscripten_stack_restore,__emscripten_stack_alloc,_emscripten_stack_get_current;function assignWasmExports(wasmExports){Module[\"_parseMarkdown\"]=_parseMarkdown=wasmExports[\"parseMarkdown\"];__emscripten_stack_restore=wasmExports[\"_emscripten_stack_restore\"];__emscripten_stack_alloc=wasmExports[\"_emscripten_stack_alloc\"];_emscripten_stack_get_current=wasmExports[\"emscripten_stack_get_current\"]}var wasmImports={__assert_fail:___assert_fail,__cxa_throw:___cxa_throw,_abort_js:__abort_js,emscripten_resize_heap:_emscripten_resize_heap};var wasmExports=await createWasm();function run(){if(runDependencies>0){dependenciesFulfilled=run;return}preRun();if(runDependencies>0){dependenciesFulfilled=run;return}function doRun(){Module[\"calledRun\"]=true;if(ABORT)return;initRuntime();readyPromiseResolve?.(Module);Module[\"onRuntimeInitialized\"]?.();postRun()}if(Module[\"setStatus\"]){Module[\"setStatus\"](\"Running...\");setTimeout(()=>{setTimeout(()=>Module[\"setStatus\"](\"\"),1);doRun()},1)}else{doRun()}}function preInit(){if(Module[\"preInit\"]){if(typeof Module[\"preInit\"]==\"function\")Module[\"preInit\"]=[Module[\"preInit\"]];while(Module[\"preInit\"].length>0){Module[\"preInit\"].shift()()}}}preInit();run();if(runtimeInitialized){moduleRtn=Module}else{moduleRtn=new Promise((resolve,reject)=>{readyPromiseResolve=resolve;readyPromiseReject=reject})}\n\n\n return moduleRtn;\n}\n);\n})();\nif (typeof exports === 'object' && typeof module === 'object') {\n module.exports = createMd4cModule;\n // This default export looks redundant, but it allows TS to import this\n // commonjs style module.\n module.exports.default = createMd4cModule;\n} else if (typeof define === 'function' && define['amd'])\n define([], () => createMd4cModule);\n"],"mappings":"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","ignoreList":[]} \ No newline at end of file diff --git a/node_modules/react-native-enriched-markdown/lib/typescript/src/EnrichedMarkdownNativeComponent.d.ts b/node_modules/react-native-enriched-markdown/lib/typescript/src/EnrichedMarkdownNativeComponent.d.ts -index 1ee4eda..b415d18 100644 --- a/node_modules/react-native-enriched-markdown/lib/typescript/src/EnrichedMarkdownNativeComponent.d.ts +++ b/node_modules/react-native-enriched-markdown/lib/typescript/src/EnrichedMarkdownNativeComponent.d.ts -@@ -235,6 +235,7 @@ export interface MarkdownStyleInternal { +@@ -114,6 +114,7 @@ + interface CodeStyleInternal { + fontFamily: string; + fontSize: CodegenTypes.Float; ++ fontScale: CodegenTypes.Float; + color: ColorValue; + backgroundColor: ColorValue; + borderColor: ColorValue; +@@ -235,6 +236,7 @@ } export interface LinkPressEvent { url: string; @@ -1939,10 +1922,17 @@ index 8e380e9..5f342bd 100644 +{"version":3,"file":"EnrichedMarkdownNativeComponent.d.ts","sourceRoot":"","sources":["../../../src/EnrichedMarkdownNativeComponent.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/node_modules/react-native-enriched-markdown/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts b/node_modules/react-native-enriched-markdown/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts -index bcde9a7..247d757 100644 --- a/node_modules/react-native-enriched-markdown/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts +++ b/node_modules/react-native-enriched-markdown/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts -@@ -235,6 +235,7 @@ export interface MarkdownStyleInternal { +@@ -114,6 +114,7 @@ + interface CodeStyleInternal { + fontFamily: string; + fontSize: CodegenTypes.Float; ++ fontScale: CodegenTypes.Float; + color: ColorValue; + backgroundColor: ColorValue; + borderColor: ColorValue; +@@ -235,6 +236,7 @@ } export interface LinkPressEvent { url: string; @@ -2177,10 +2167,17 @@ index a32ca4e..279b944 100644 +{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../../src/web/types.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/node_modules/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts b/node_modules/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts -index 32ff768..8e89249 100644 --- a/node_modules/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts +++ b/node_modules/react-native-enriched-markdown/src/EnrichedMarkdownNativeComponent.ts -@@ -275,6 +275,7 @@ export interface MarkdownStyleInternal { +@@ -138,6 +138,7 @@ + interface CodeStyleInternal { + fontFamily: string; + fontSize: CodegenTypes.Float; ++ fontScale: CodegenTypes.Float; + color: ColorValue; + backgroundColor: ColorValue; + borderColor: ColorValue; +@@ -275,6 +276,7 @@ export interface LinkPressEvent { url: string; @@ -2189,10 +2186,17 @@ index 32ff768..8e89249 100644 export interface LinkLongPressEvent { diff --git a/node_modules/react-native-enriched-markdown/src/EnrichedMarkdownTextNativeComponent.ts b/node_modules/react-native-enriched-markdown/src/EnrichedMarkdownTextNativeComponent.ts -index 6f31ccc..7b08b62 100644 --- a/node_modules/react-native-enriched-markdown/src/EnrichedMarkdownTextNativeComponent.ts +++ b/node_modules/react-native-enriched-markdown/src/EnrichedMarkdownTextNativeComponent.ts -@@ -276,6 +276,7 @@ export interface MarkdownStyleInternal { +@@ -138,6 +138,7 @@ + interface CodeStyleInternal { + fontFamily: string; + fontSize: CodegenTypes.Float; ++ fontScale: CodegenTypes.Float; + color: ColorValue; + backgroundColor: ColorValue; + borderColor: ColorValue; +@@ -276,6 +277,7 @@ export interface LinkPressEvent { url: string; @@ -3253,3 +3257,516 @@ new file mode 100644 + } + } +} +diff --git a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/StrongSpan.kt b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/StrongSpan.kt +--- a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/StrongSpan.kt ++++ b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/StrongSpan.kt +@@ -23,8 +23,7 @@ + } + + private fun applyStrongStyle(tp: TextPaint) { +- tp.textSize = blockStyle.fontSize +- ++ // Weight composes with the current run; its size belongs to the block or code span. + val currentTypeface = tp.typeface ?: Typeface.DEFAULT + val isItalic = (currentTypeface.style) and Typeface.ITALIC != 0 + val style = if (isItalic) Typeface.BOLD_ITALIC else Typeface.BOLD +diff --git a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/LinkSpan.kt b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/LinkSpan.kt +--- a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/LinkSpan.kt ++++ b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/LinkSpan.kt +@@ -12,14 +12,14 @@ + + class LinkSpan( + val url: String, +- private val onLinkPress: ((String) -> Unit)?, ++ private val onLinkPress: ((String, String) -> Unit)?, + private val onLinkLongPress: ((String) -> Unit)?, + private val styleCache: SpanStyleCache, + private val blockStyle: BlockStyle, + private val context: Context, + val recognizedLink: Boolean = false, +- // A recognized code span keeps its code font unless a link or variant font is set. +- private val keepsCodeFont: Boolean = false, ++ // Syntax context for recognized inline-code links is independent of typography. ++ private val recognizedInlineCodeLink: Boolean = false, + ) : ClickableSpan() { + @Volatile + private var longPressTriggered = false +@@ -30,7 +30,8 @@ + return + } + +- onLinkPress?.invoke(url) ?: (widget as? EnrichedMarkdownText)?.emitOnLinkPress(url) ++ val sourceType = if (recognizedInlineCodeLink) "inline-code" else if (recognizedLink) "text" else "markdown" ++ onLinkPress?.invoke(url, sourceType) ?: (widget as? EnrichedMarkdownText)?.emitOnLinkPress(url, sourceType) + } + + fun onLongClick(widget: View): Boolean { +@@ -50,11 +51,7 @@ + + val fontFamily = variant?.fontFamily?.takeIf { it.isNotEmpty() } ?: styleCache.linkFontFamily + if (fontFamily.isNotEmpty()) { +- textPaint.textSize = blockStyle.fontSize + textPaint.applyBlockStyleFont(blockStyle.copy(fontFamily = fontFamily), context) +- } else if (!keepsCodeFont) { +- textPaint.textSize = blockStyle.fontSize +- textPaint.applyBlockStyleFont(blockStyle, context) + } + + textPaint.color = variant?.color ?: styleCache.linkColor +diff --git a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/LinkRenderer.kt b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/LinkRenderer.kt +--- a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/LinkRenderer.kt ++++ b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/LinkRenderer.kt +@@ -19,7 +19,7 @@ + override fun render( + node: MarkdownASTNode, + builder: SpannableStringBuilder, +- onLinkPress: ((String) -> Unit)?, ++ onLinkPress: ((String, String) -> Unit)?, + onLinkLongPress: ((String) -> Unit)?, + factory: RendererFactory, + ) { +@@ -56,7 +56,7 @@ + blockStyle, + factory.context, + recognizedLink = recognizedLink, +- keepsCodeFont = recognizedLink && node.children.any { it.type == MarkdownASTNode.NodeType.Code }, ++ recognizedInlineCodeLink = recognizedLink && node.children.any { it.type == MarkdownASTNode.NodeType.Code }, + ), + start, + end, +diff --git a/node_modules/react-native-enriched-markdown/android/src/test/java/com/swmansion/enriched/markdown/InlineCodeFormattingTest.kt b/node_modules/react-native-enriched-markdown/android/src/test/java/com/swmansion/enriched/markdown/InlineCodeFormattingTest.kt +new file mode 100644 +--- /dev/null ++++ b/node_modules/react-native-enriched-markdown/android/src/test/java/com/swmansion/enriched/markdown/InlineCodeFormattingTest.kt +@@ -0,0 +1,89 @@ ++package com.swmansion.enriched.markdown ++ ++import android.text.TextPaint ++import com.facebook.react.bridge.JavaOnlyMap ++import com.swmansion.enriched.markdown.renderer.BlockStyle ++import com.swmansion.enriched.markdown.renderer.SpanStyleCache ++import com.swmansion.enriched.markdown.spans.CodeSpan ++import com.swmansion.enriched.markdown.spans.StrongSpan ++import com.swmansion.enriched.markdown.spans.EmphasisSpan ++import com.swmansion.enriched.markdown.styles.StyleConfig ++import org.junit.Assert.* ++import org.junit.Test ++import org.junit.runner.RunWith ++import org.robolectric.RobolectricTestRunner ++import org.robolectric.RuntimeEnvironment ++import org.robolectric.annotation.Config ++ ++@RunWith(RobolectricTestRunner::class) ++@org.robolectric.annotation.GraphicsMode(org.robolectric.annotation.GraphicsMode.Mode.NATIVE) ++@Config(manifest = Config.NONE, sdk = [34]) ++class InlineCodeFormattingTest { ++ private fun cache(): SpanStyleCache { ++ com.facebook.react.uimanager.DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(RuntimeEnvironment.getApplication()) ++ val common = JavaOnlyMap.of( ++ "fontFamily", "", "fontWeight", "bold", "fontStyle", "italic", ++ "fontSize", 12.0, "color", -16777216.0, "backgroundColor", 0.0, ++ "borderColor", 0.0, "underline", false, ++ "marginTop", 0.0, "marginBottom", 0.0, "lineHeight", 20.0, "textAlign", "left", ++ "checkedColor", 0.0, "checkmarkColor", 0.0, "checkedTextColor", 0.0, ++ "checkboxSize", 18.0, "checkboxBorderRadius", 2.0, "checkedStrikethrough", false, ++ "particles", JavaOnlyMap.of("density", 1.0, "speed", 1.0), ++ "solid", JavaOnlyMap.of("borderRadius", 0.0), ++ "fontScale", 0.7, "baselineOffsetScale", 0.3, ++ ) ++ val styles = JavaOnlyMap() ++ listOf("paragraph", "strong", "em", "strikethrough", "link", "code", "taskList", ++ "spoiler", "superscript", "subscript", "highlight").forEach { styles.putMap(it, common) } ++ styles.putMap("code", JavaOnlyMap.of("fontFamily", "", "fontSize", 0.0, "fontScale", 0.75, "color", -16777216.0, "backgroundColor", 0.0, "borderColor", 0.0)) ++ return SpanStyleCache(StyleConfig(styles, RuntimeEnvironment.getApplication(), false, 1f)) ++ } ++ ++ @Test fun boldPreservesInlineCodeSizeInMeasurementAndDrawing() { ++ val cache = cache() ++ for (blockSize in listOf(16f, 28f)) { ++ val block = BlockStyle(blockSize, "", "normal", -16777216) ++ for (draw in listOf(false, true)) { ++ for (boldFirst in listOf(false, true)) { ++ val paint = TextPaint().apply { textSize = blockSize } ++ val code = CodeSpan(cache, block) ++ val bold = StrongSpan(cache, block) ++ val spans = if (boldFirst) listOf(bold, code) else listOf(code, bold) ++ spans.forEach { if (draw) it.updateDrawState(paint) else it.updateMeasureState(paint) } ++ assertEquals("Inline code size must survive bold in either span order", blockSize * 0.75f, paint.textSize, 0f) ++ assertTrue(paint.typeface.isBold) ++ } ++ } ++ } ++ } ++ ++ @Test fun boldPreservesExistingSizeAndItalic() { ++ val cache = cache() ++ val block = BlockStyle(28f, "", "normal", -16777216) ++ val paint = TextPaint().apply { textSize = 16f } ++ EmphasisSpan(cache, block).updateMeasureState(paint) ++ StrongSpan(cache, block).updateMeasureState(paint) ++ assertEquals(16f, paint.textSize, 0f) ++ assertTrue(paint.typeface.isBold) ++ assertTrue(paint.typeface.isItalic) ++ } ++ ++ @Test fun linksPreserveCodeAndEmphasizedProseFonts() { ++ val cache = cache() ++ val block = BlockStyle(28f, "", "normal", -16777216) ++ for (code in listOf(false, true)) { ++ val paint = TextPaint().apply { textSize = 16f } ++ if (code) CodeSpan(cache, block).updateDrawState(paint) ++ EmphasisSpan(cache, block).updateDrawState(paint) ++ StrongSpan(cache, block).updateDrawState(paint) ++ val size = paint.textSize ++ val font = paint.typeface ++ com.swmansion.enriched.markdown.spans.LinkSpan( ++ "https://example.com", null, null, cache, block, RuntimeEnvironment.getApplication(), ++ ).updateDrawState(paint) ++ assertEquals(size, paint.textSize, 0f) ++ assertEquals(font, paint.typeface) ++ } ++ } ++ ++} +diff --git a/node_modules/react-native-enriched-markdown/src/types/MarkdownStyle.ts b/node_modules/react-native-enriched-markdown/src/types/MarkdownStyle.ts +--- a/node_modules/react-native-enriched-markdown/src/types/MarkdownStyle.ts ++++ b/node_modules/react-native-enriched-markdown/src/types/MarkdownStyle.ts +@@ -197,6 +197,8 @@ + interface CodeStyle { + fontFamily?: string; + fontSize?: number; ++ /** Scales the inherited block size when fontSize is unset. */ ++ fontScale?: number; + color?: string; + backgroundColor?: string; + borderColor?: string; +diff --git a/node_modules/react-native-enriched-markdown/lib/typescript/src/types/MarkdownStyle.d.ts b/node_modules/react-native-enriched-markdown/lib/typescript/src/types/MarkdownStyle.d.ts +--- a/node_modules/react-native-enriched-markdown/lib/typescript/src/types/MarkdownStyle.d.ts ++++ b/node_modules/react-native-enriched-markdown/lib/typescript/src/types/MarkdownStyle.d.ts +@@ -179,6 +179,8 @@ + interface CodeStyle { + fontFamily?: string; + fontSize?: number; ++ /** Scales the inherited block size when fontSize is unset. */ ++ fontScale?: number; + color?: string; + backgroundColor?: string; + borderColor?: string; +diff --git a/node_modules/react-native-enriched-markdown/src/types/MarkdownStyleInternal.ts b/node_modules/react-native-enriched-markdown/src/types/MarkdownStyleInternal.ts +--- a/node_modules/react-native-enriched-markdown/src/types/MarkdownStyleInternal.ts ++++ b/node_modules/react-native-enriched-markdown/src/types/MarkdownStyleInternal.ts +@@ -150,6 +150,7 @@ + interface CodeStyleInternal { + fontFamily: string; + fontSize: number; ++ fontScale: number; + color: string; + backgroundColor: string; + borderColor: string; +diff --git a/node_modules/react-native-enriched-markdown/lib/typescript/src/types/MarkdownStyleInternal.d.ts b/node_modules/react-native-enriched-markdown/lib/typescript/src/types/MarkdownStyleInternal.d.ts +--- a/node_modules/react-native-enriched-markdown/lib/typescript/src/types/MarkdownStyleInternal.d.ts ++++ b/node_modules/react-native-enriched-markdown/lib/typescript/src/types/MarkdownStyleInternal.d.ts +@@ -117,6 +117,7 @@ + interface CodeStyleInternal { + fontFamily: string; + fontSize: number; ++ fontScale: number; + color: string; + backgroundColor: string; + borderColor: string; +diff --git a/node_modules/react-native-enriched-markdown/src/normalizeMarkdownStyle.ts b/node_modules/react-native-enriched-markdown/src/normalizeMarkdownStyle.ts +--- a/node_modules/react-native-enriched-markdown/src/normalizeMarkdownStyle.ts ++++ b/node_modules/react-native-enriched-markdown/src/normalizeMarkdownStyle.ts +@@ -203,6 +203,7 @@ + // code doesn't fall back to the browser's default proportional font. + fontFamily: Platform.select({ web: getMonospaceFont(), default: '' })!, + fontSize: 0, ++ fontScale: 1, + color: normalizeColor('#E01E5A')!, + backgroundColor: normalizeColor('#FDF2F4')!, + borderColor: normalizeColor('#F8D7DA')!, +diff --git a/node_modules/react-native-enriched-markdown/src/normalizeMarkdownStyle.web.ts b/node_modules/react-native-enriched-markdown/src/normalizeMarkdownStyle.web.ts +--- a/node_modules/react-native-enriched-markdown/src/normalizeMarkdownStyle.web.ts ++++ b/node_modules/react-native-enriched-markdown/src/normalizeMarkdownStyle.web.ts +@@ -165,6 +165,7 @@ + code: { + fontFamily: MONOSPACE_FONT, + fontSize: 0, ++ fontScale: 1, + color: '#E01E5A', + backgroundColor: '#FDF2F4', + borderColor: '#F8D7DA', +diff --git a/node_modules/react-native-enriched-markdown/lib/module/normalizeMarkdownStyle.js b/node_modules/react-native-enriched-markdown/lib/module/normalizeMarkdownStyle.js +--- a/node_modules/react-native-enriched-markdown/lib/module/normalizeMarkdownStyle.js ++++ b/node_modules/react-native-enriched-markdown/lib/module/normalizeMarkdownStyle.js +@@ -226,6 +226,7 @@ + default: '' + }), + fontSize: 0, ++ fontScale: 1, + color: normalizeColor('#E01E5A'), + backgroundColor: normalizeColor('#FDF2F4'), + borderColor: normalizeColor('#F8D7DA') +diff --git a/node_modules/react-native-enriched-markdown/lib/module/normalizeMarkdownStyle.web.js b/node_modules/react-native-enriched-markdown/lib/module/normalizeMarkdownStyle.web.js +--- a/node_modules/react-native-enriched-markdown/lib/module/normalizeMarkdownStyle.web.js ++++ b/node_modules/react-native-enriched-markdown/lib/module/normalizeMarkdownStyle.web.js +@@ -150,6 +150,7 @@ + code: { + fontFamily: MONOSPACE_FONT, + fontSize: 0, ++ fontScale: 1, + color: '#E01E5A', + backgroundColor: '#FDF2F4', + borderColor: '#F8D7DA' +diff --git a/node_modules/react-native-enriched-markdown/src/web/styles.ts b/node_modules/react-native-enriched-markdown/src/web/styles.ts +--- a/node_modules/react-native-enriched-markdown/src/web/styles.ts ++++ b/node_modules/react-native-enriched-markdown/src/web/styles.ts +@@ -290,7 +290,7 @@ + const code = style.code; + return { + fontFamily: normalizeFontFamily(code.fontFamily), +- fontSize: code.fontSize || undefined, ++ fontSize: code.fontSize || `${code.fontScale}em`, + color: code.color, + backgroundColor: code.backgroundColor, + border: `1px solid ${code.borderColor}`, +diff --git a/node_modules/react-native-enriched-markdown/lib/module/web/styles.js b/node_modules/react-native-enriched-markdown/lib/module/web/styles.js +--- a/node_modules/react-native-enriched-markdown/lib/module/web/styles.js ++++ b/node_modules/react-native-enriched-markdown/lib/module/web/styles.js +@@ -271,7 +271,7 @@ + const code = style.code; + return { + fontFamily: normalizeFontFamily(code.fontFamily), +- fontSize: code.fontSize || undefined, ++ fontSize: code.fontSize || `${code.fontScale}em`, + color: code.color, + backgroundColor: code.backgroundColor, + border: `1px solid ${code.borderColor}`, +diff --git a/node_modules/react-native-enriched-markdown/ios/styles/StyleConfig.h b/node_modules/react-native-enriched-markdown/ios/styles/StyleConfig.h +--- a/node_modules/react-native-enriched-markdown/ios/styles/StyleConfig.h ++++ b/node_modules/react-native-enriched-markdown/ios/styles/StyleConfig.h +@@ -226,6 +226,8 @@ + // Code properties + - (NSString *)codeFontFamily; + - (void)setCodeFontFamily:(NSString *)newValue; ++- (CGFloat)codeFontScale; ++- (void)setCodeFontScale:(CGFloat)newValue; + - (CGFloat)codeFontSize; + - (void)setCodeFontSize:(CGFloat)newValue; + - (RCTUIColor *)codeColor; +diff --git a/node_modules/react-native-enriched-markdown/ios/styles/StyleConfig.mm b/node_modules/react-native-enriched-markdown/ios/styles/StyleConfig.mm +--- a/node_modules/react-native-enriched-markdown/ios/styles/StyleConfig.mm ++++ b/node_modules/react-native-enriched-markdown/ios/styles/StyleConfig.mm +@@ -139,6 +139,7 @@ + // Code properties + NSString *_codeFontFamily; + CGFloat _codeFontSize; ++ CGFloat _codeFontScale; + RCTUIColor *_codeColor; + RCTUIColor *_codeBackgroundColor; + RCTUIColor *_codeBorderColor; +@@ -273,6 +274,7 @@ + { + self = [super init]; + _allowFontScaling = YES; ++ _codeFontScale = 1; + _maxFontSizeMultiplier = 0; + _paragraphTextAlign = NSTextAlignmentNatural; + _h1TextAlign = NSTextAlignmentNatural; +@@ -442,6 +444,7 @@ + copy->_underlineColor = [_underlineColor copy]; + copy->_codeFontFamily = [_codeFontFamily copy]; + copy->_codeFontSize = _codeFontSize; ++ copy->_codeFontScale = _codeFontScale; + copy->_codeColor = [_codeColor copy]; + copy->_codeBackgroundColor = [_codeBackgroundColor copy]; + copy->_codeBorderColor = [_codeBorderColor copy]; +@@ -1513,6 +1516,16 @@ + _codeFontFamily = newValue; + } + ++- (CGFloat)codeFontScale ++{ ++ return _codeFontScale; ++} ++ ++- (void)setCodeFontScale:(CGFloat)newValue ++{ ++ _codeFontScale = newValue; ++} ++ + - (CGFloat)codeFontSize + { + return _codeFontSize; +diff --git a/node_modules/react-native-enriched-markdown/ios/utils/StylePropsUtils.h b/node_modules/react-native-enriched-markdown/ios/utils/StylePropsUtils.h +--- a/node_modules/react-native-enriched-markdown/ios/utils/StylePropsUtils.h ++++ b/node_modules/react-native-enriched-markdown/ios/utils/StylePropsUtils.h +@@ -713,6 +713,11 @@ + changed = YES; + } + ++ if (newStyle.code.fontScale != oldStyle.code.fontScale) { ++ [config setCodeFontScale:newStyle.code.fontScale]; ++ changed = YES; ++ } ++ + if (newStyle.code.fontSize != oldStyle.code.fontSize) { + [config setCodeFontSize:newStyle.code.fontSize]; + changed = YES; +diff --git a/node_modules/react-native-enriched-markdown/ios/internals/MeasurementCache.h b/node_modules/react-native-enriched-markdown/ios/internals/MeasurementCache.h +--- a/node_modules/react-native-enriched-markdown/ios/internals/MeasurementCache.h ++++ b/node_modules/react-native-enriched-markdown/ios/internals/MeasurementCache.h +@@ -143,7 +143,7 @@ + hashFields(s.codeBlock.fontFamily, s.codeBlock.fontSize, s.codeBlock.fontWeight, s.codeBlock.marginTop, + s.codeBlock.marginBottom, s.codeBlock.lineHeight, s.codeBlock.padding, s.codeBlock.borderRadius, + s.codeBlock.borderWidth); +- hashFields(s.code.fontFamily, s.code.fontSize); ++ hashFields(s.code.fontFamily, s.code.fontSize, s.code.fontScale); + hashFields(s.link.fontFamily, s.strong.fontFamily, s.strong.fontWeight, s.em.fontFamily, s.em.fontStyle); + // Variants change link metrics: a font family, or a pill box around the label. + for (const auto &variant : s.linkVariants) { +diff --git a/node_modules/react-native-enriched-markdown/ios/renderer/CodeRenderer.m b/node_modules/react-native-enriched-markdown/ios/renderer/CodeRenderer.m +--- a/node_modules/react-native-enriched-markdown/ios/renderer/CodeRenderer.m ++++ b/node_modules/react-native-enriched-markdown/ios/renderer/CodeRenderer.m +@@ -24,7 +24,7 @@ + UIFontDescriptorSymbolicTraits traits = blockFont.fontDescriptor.symbolicTraits; + UIFontWeight weight = (traits & UIFontDescriptorTraitBold) ? UIFontWeightBold : UIFontWeightRegular; + +- CGFloat codeFontSize = _config.codeFontSize > 0 ? _config.codeFontSize : blockStyle.fontSize; ++ CGFloat codeFontSize = _config.codeFontSize > 0 ? _config.codeFontSize : blockStyle.fontSize * _config.codeFontScale; + + // Both sizes above are unscaled; apply the same multiplier as the surrounding block font. + CGFloat fontScale = context.allowFontScaling ? RCTFontSizeMultiplierWithMax(context.maxFontSizeMultiplier) : 1.0; +diff --git a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/styles/CodeStyle.kt b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/styles/CodeStyle.kt +--- a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/styles/CodeStyle.kt ++++ b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/styles/CodeStyle.kt +@@ -5,6 +5,7 @@ + data class CodeStyle( + val fontFamily: String, + val fontSize: Float, ++ val fontScale: Float, + val color: Int, + val backgroundColor: Int, + val borderColor: Int, +@@ -17,10 +18,11 @@ + val fontFamily = parser.parseString(map, "fontFamily") + val fontSizeRaw = parser.parseOptionalDouble(map, "fontSize").toFloat() + val fontSize = if (fontSizeRaw > 0) parser.toPixelFromSP(fontSizeRaw) else 0f ++ val fontScale = parser.parseOptionalDouble(map, "fontScale", 1.0).toFloat() + val color = parser.parseColor(map, "color") + val backgroundColor = parser.parseColor(map, "backgroundColor") + val borderColor = parser.parseColor(map, "borderColor") +- return CodeStyle(fontFamily, fontSize, color, backgroundColor, borderColor) ++ return CodeStyle(fontFamily, fontSize, fontScale, color, backgroundColor, borderColor) + } + } + } +diff --git a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/SpanStyleCache.kt b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/SpanStyleCache.kt +--- a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/SpanStyleCache.kt ++++ b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/SpanStyleCache.kt +@@ -40,6 +40,7 @@ + + val codeFontFamily: String = style.codeStyle.fontFamily + val codeFontSize: Float = style.codeStyle.fontSize ++ val codeFontScale: Float = style.codeStyle.fontScale + val codeColor: Int = style.codeStyle.color + val spoilerColor: Int = style.spoilerStyle.color + val spoilerParticleDensity: Float = style.spoilerStyle.particleDensity +diff --git a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/CodeSpan.kt b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/CodeSpan.kt +--- a/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/CodeSpan.kt ++++ b/node_modules/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/spans/CodeSpan.kt +@@ -20,7 +20,7 @@ + } + + private fun applyMonospacedFont(paint: TextPaint) { +- paint.textSize = if (styleCache.codeFontSize > 0) styleCache.codeFontSize else blockStyle.fontSize ++ paint.textSize = if (styleCache.codeFontSize > 0) styleCache.codeFontSize else blockStyle.fontSize * styleCache.codeFontScale + val preservedStyle = (paint.typeface?.style ?: 0) and (Typeface.BOLD or Typeface.ITALIC) + paint.typeface = + if (styleCache.codeFontFamily.isNotEmpty()) { +diff --git a/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownContainerShadowNode.cpp b/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownContainerShadowNode.cpp +index a430d1a..b30a017 100644 +--- a/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownContainerShadowNode.cpp ++++ b/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownContainerShadowNode.cpp +@@ -22,6 +22,14 @@ void MarkdownContainerShadowNode::dirtyLayoutIfNeeded() { + } + } + ++// Only new props change the rendered markdown. When a parent is cloned, React Native's adoptYogaChild ++// re-clones its children without new props, and dirtying those clones re-measures every settled message ++// whenever the list above grows. ++bool MarkdownContainerShadowNode::shouldNewRevisionDirtyMeasurement(const ShadowNode & /*sourceShadowNode*/, ++ const ShadowNodeFragment &fragment) const { ++ return fragment.props != nullptr; ++} ++ + Size MarkdownContainerShadowNode::measureContent(const LayoutContext &layoutContext, + const LayoutConstraints &layoutConstraints) const { + return measurementsManager_->measure(getSurfaceId(), getTag(), getConcreteProps(), layoutConstraints); +diff --git a/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownContainerShadowNode.h b/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownContainerShadowNode.h +index dea8bb2..6c4bd20 100644 +--- a/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownContainerShadowNode.h ++++ b/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownContainerShadowNode.h +@@ -19,6 +19,8 @@ public: + + MarkdownContainerShadowNode(ShadowNode const &sourceShadowNode, ShadowNodeFragment const &fragment) + : ConcreteViewShadowNode(sourceShadowNode, fragment) { ++ forceHeightRecalculationCounter_ = ++ static_cast(sourceShadowNode).forceHeightRecalculationCounter_; + dirtyLayoutIfNeeded(); + } + +@@ -35,6 +37,10 @@ public: + + Size measureContent(const LayoutContext &layoutContext, const LayoutConstraints &layoutConstraints) const override; + ++protected: ++ bool shouldNewRevisionDirtyMeasurement(const ShadowNode &sourceShadowNode, ++ const ShadowNodeFragment &fragment) const override; ++ + private: + int forceHeightRecalculationCounter_{0}; + std::shared_ptr measurementsManager_; +diff --git a/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.cpp b/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.cpp +index 3ca259b..ab51055 100644 +--- a/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.cpp ++++ b/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.cpp +@@ -22,6 +22,14 @@ void MarkdownTextShadowNode::dirtyLayoutIfNeeded() { + } + } + ++// Only new props change the rendered markdown. When a parent is cloned, React Native's adoptYogaChild ++// re-clones its children without new props, and dirtying those clones re-measures every settled message ++// whenever the list above grows. ++bool MarkdownTextShadowNode::shouldNewRevisionDirtyMeasurement(const ShadowNode & /*sourceShadowNode*/, ++ const ShadowNodeFragment &fragment) const { ++ return fragment.props != nullptr; ++} ++ + Size MarkdownTextShadowNode::measureContent(const LayoutContext &layoutContext, + const LayoutConstraints &layoutConstraints) const { + return measurementsManager_->measure(getSurfaceId(), getTag(), getConcreteProps(), layoutConstraints); +diff --git a/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.h b/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.h +index eaa3279..05a02eb 100644 +--- a/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.h ++++ b/node_modules/react-native-enriched-markdown/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.h +@@ -22,6 +22,8 @@ public: + + MarkdownTextShadowNode(ShadowNode const &sourceShadowNode, ShadowNodeFragment const &fragment) + : ConcreteViewShadowNode(sourceShadowNode, fragment) { ++ forceHeightRecalculationCounter_ = ++ static_cast(sourceShadowNode).forceHeightRecalculationCounter_; + dirtyLayoutIfNeeded(); + } + +@@ -39,6 +41,10 @@ public: + + Size measureContent(const LayoutContext &layoutContext, const LayoutConstraints &layoutConstraints) const override; + ++protected: ++ bool shouldNewRevisionDirtyMeasurement(const ShadowNode &sourceShadowNode, ++ const ShadowNodeFragment &fragment) const override; ++ + private: + int forceHeightRecalculationCounter_{0}; + std::shared_ptr measurementsManager_; diff --git a/patches/react-native-reanimated+4.5.5.patch b/patches/react-native-reanimated+4.5.5.patch new file mode 100644 index 0000000000..b1d3218add --- /dev/null +++ b/patches/react-native-reanimated+4.5.5.patch @@ -0,0 +1,35 @@ +diff --git a/node_modules/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/NativeProxy.kt b/node_modules/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/NativeProxy.kt +index 48e3771..0fba1af 100644 +--- a/node_modules/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/NativeProxy.kt ++++ b/node_modules/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/NativeProxy.kt +@@ -245,6 +245,15 @@ open class NativeProxy { + }.apply { isAccessible = true } + } + ++ // Backport of https://github.com/software-mansion/react-native-reanimated/pull/10435 ++ // (shipped in Reanimated 4.7.1). The reflective updatePropsSynchronously path skips RN's ++ // view-existence guard, so a tag that unmounted with buffered updates throws ++ // RetryableMountingLayerException on every event dispatch and Log.w printed the full stack ++ // on the UI thread inside the draw pass. Remove once the installed Reanimated includes the fix. ++ private val getViewExistsMethod by lazy { ++ runCatching { mountingManager.javaClass.getMethod("getViewExists", Int::class.javaPrimitiveType) }.getOrNull() ++ } ++ + @DoNotStrip + fun synchronouslyUpdateUIProps( + intBuffer: IntArray, +@@ -253,9 +262,13 @@ open class NativeProxy { + SynchronousPropsBufferParser.parse(intBuffer, doubleBuffer) { viewTag, props -> + if (BuildConfig.IS_REACT_NATIVE_86_OR_NEWER) { + try { ++ // The props stay in the registry, and the commit hook applies them to the shadow tree. ++ if (getViewExistsMethod?.invoke(mountingManager, viewTag) == false) { ++ return@parse ++ } + updatePropsSynchronouslyMethod.invoke(mountingManager, viewTag, props) + } catch (e: Exception) { +- Log.w("Reanimated", "synchronouslyUpdateUIProps failed for tag $viewTag", e) ++ Log.w("Reanimated", "synchronouslyUpdateUIProps failed for tag $viewTag: ${e.cause ?: e}") + } + } else { + mFabricUIManager.synchronouslyUpdateViewOnUIThread(viewTag, props) diff --git a/patches/react-native-unistyles+3.2.4.patch b/patches/react-native-unistyles+3.2.4.patch index 0195868340..7f40053f19 100644 --- a/patches/react-native-unistyles+3.2.4.patch +++ b/patches/react-native-unistyles+3.2.4.patch @@ -329,3 +329,160 @@ index 195954b..c4ac28c 100644 namespace "com.unistyles" defaultConfig { +diff --git a/node_modules/react-native-unistyles/src/core/withUnistyles/withUnistyles.tsx b/node_modules/react-native-unistyles/src/core/withUnistyles/withUnistyles.tsx +--- a/node_modules/react-native-unistyles/src/core/withUnistyles/withUnistyles.tsx ++++ b/node_modules/react-native-unistyles/src/core/withUnistyles/withUnistyles.tsx +@@ -28,13 +28,14 @@ + + return forwardRef, PropsWithUnistyles>((props, ref) => { + const narrowedProps = props as PropsWithUnistyles & UnistyleStyles ++ const { uniProps, ...componentProps } = narrowedProps + const styleClassNames = getClassName(narrowedProps.style, true) + const contentContainerStyleClassNames = getClassName(narrowedProps.contentContainerStyle) + const { proxifiedRuntime, proxifiedTheme } = useProxifiedUnistyles() + + const { key: mappingsKey, ...mappingsProps } = mappings ? mappings(proxifiedTheme, proxifiedRuntime) : {} +- const { key: uniPropsKey, ...unistyleProps } = narrowedProps.uniProps +- ? narrowedProps.uniProps(proxifiedTheme, proxifiedRuntime) ++ const { key: uniPropsKey, ...unistyleProps } = uniProps ++ ? uniProps(proxifiedTheme, proxifiedRuntime) + : {} + + const emptyStyles = narrowedProps.style +@@ -46,7 +47,7 @@ + : undefined + + const combinedProps = { +- ...deepMergeObjects(mappingsProps, unistyleProps, props), ++ ...deepMergeObjects(mappingsProps, unistyleProps, componentProps), + ...(narrowedProps.style + ? { + // Override default component styles with undefined values to reset them +diff --git a/node_modules/react-native-unistyles/src/core/withUnistyles/withUnistyles.native.tsx b/node_modules/react-native-unistyles/src/core/withUnistyles/withUnistyles.native.tsx +--- a/node_modules/react-native-unistyles/src/core/withUnistyles/withUnistyles.native.tsx ++++ b/node_modules/react-native-unistyles/src/core/withUnistyles/withUnistyles.native.tsx +@@ -63,6 +63,7 @@ + + return forwardRef, PropsWithUnistyles>((props, ref) => { + const narrowedProps = props as PropsWithUnistyles & UnistyleStyles ++ const { uniProps, ...componentProps } = narrowedProps + const NativeComponent = Component as ComponentType + + // @ts-ignore we don't know the type of the component +@@ -89,15 +90,15 @@ + }, [narrowedProps.style, narrowedProps.contentContainerStyle]) + + const { key: mappingsKey, ...mappingsProps } = mappings ? mappings(proxifiedTheme, proxifiedRuntime) : {} +- const { key: uniPropsKey, ...unistyleProps } = narrowedProps.uniProps +- ? narrowedProps.uniProps(proxifiedTheme, proxifiedRuntime) ++ const { key: uniPropsKey, ...unistyleProps } = uniProps ++ ? uniProps(proxifiedTheme, proxifiedRuntime) + : {} + + const styleSecrets = getSecrets(narrowedProps.style) + const contentContainerStyleSecrets = getSecrets(narrowedProps.contentContainerStyle) + + const finalProps = { +- ...deepMergeObjects(mappingsProps, unistyleProps, props), ++ ...deepMergeObjects(mappingsProps, unistyleProps, componentProps), + ...(narrowedProps.style + ? { + style: styleSecrets.styles, +diff --git a/node_modules/react-native-unistyles/lib/module/core/withUnistyles/withUnistyles.js b/node_modules/react-native-unistyles/lib/module/core/withUnistyles/withUnistyles.js +--- a/node_modules/react-native-unistyles/lib/module/core/withUnistyles/withUnistyles.js ++++ b/node_modules/react-native-unistyles/lib/module/core/withUnistyles/withUnistyles.js +@@ -13,6 +13,7 @@ + export const withUnistyles = (Component, mappings) => { + return /*#__PURE__*/forwardRef((props, ref) => { + const narrowedProps = props; ++ const { uniProps, ...componentProps } = narrowedProps; + const styleClassNames = getClassName(narrowedProps.style, true); + const contentContainerStyleClassNames = getClassName(narrowedProps.contentContainerStyle); + const { +@@ -26,10 +27,10 @@ + const { + key: uniPropsKey, + ...unistyleProps +- } = narrowedProps.uniProps ? narrowedProps.uniProps(proxifiedTheme, proxifiedRuntime) : {}; ++ } = uniProps ? uniProps(proxifiedTheme, proxifiedRuntime) : {}; + const emptyStyles = narrowedProps.style ? Object.fromEntries(Object.entries(Object.getOwnPropertyDescriptors(narrowedProps.style)).filter(([key]) => !key.startsWith('unistyles') && !key.startsWith('_')).map(([key]) => [key, undefined])) : undefined; + const combinedProps = { +- ...deepMergeObjects(mappingsProps, unistyleProps, props), ++ ...deepMergeObjects(mappingsProps, unistyleProps, componentProps), + ...(narrowedProps.style ? { + // Override default component styles with undefined values to reset them + style: emptyStyles +diff --git a/node_modules/react-native-unistyles/lib/module/core/withUnistyles/withUnistyles.native.js b/node_modules/react-native-unistyles/lib/module/core/withUnistyles/withUnistyles.native.js +--- a/node_modules/react-native-unistyles/lib/module/core/withUnistyles/withUnistyles.native.js ++++ b/node_modules/react-native-unistyles/lib/module/core/withUnistyles/withUnistyles.native.js +@@ -27,6 +27,7 @@ + }; + return /*#__PURE__*/forwardRef((props, ref) => { + const narrowedProps = props; ++ const { uniProps, ...componentProps } = narrowedProps; + const NativeComponent = Component; + + // @ts-ignore we don't know the type of the component +@@ -57,11 +58,11 @@ + const { + key: uniPropsKey, + ...unistyleProps +- } = narrowedProps.uniProps ? narrowedProps.uniProps(proxifiedTheme, proxifiedRuntime) : {}; ++ } = uniProps ? uniProps(proxifiedTheme, proxifiedRuntime) : {}; + const styleSecrets = getSecrets(narrowedProps.style); + const contentContainerStyleSecrets = getSecrets(narrowedProps.contentContainerStyle); + const finalProps = { +- ...deepMergeObjects(mappingsProps, unistyleProps, props), ++ ...deepMergeObjects(mappingsProps, unistyleProps, componentProps), + ...(narrowedProps.style ? { + style: styleSecrets.styles + } : {}), +diff --git a/node_modules/react-native-unistyles/lib/commonjs/core/withUnistyles/withUnistyles.js b/node_modules/react-native-unistyles/lib/commonjs/core/withUnistyles/withUnistyles.js +--- a/node_modules/react-native-unistyles/lib/commonjs/core/withUnistyles/withUnistyles.js ++++ b/node_modules/react-native-unistyles/lib/commonjs/core/withUnistyles/withUnistyles.js +@@ -18,6 +18,7 @@ + const withUnistyles = (Component, mappings) => { + return /*#__PURE__*/(0, _react.forwardRef)((props, ref) => { + const narrowedProps = props; ++ const { uniProps, ...componentProps } = narrowedProps; + const styleClassNames = (0, _getClassname.getClassName)(narrowedProps.style, true); + const contentContainerStyleClassNames = (0, _getClassname.getClassName)(narrowedProps.contentContainerStyle); + const { +@@ -31,10 +32,10 @@ + const { + key: uniPropsKey, + ...unistyleProps +- } = narrowedProps.uniProps ? narrowedProps.uniProps(proxifiedTheme, proxifiedRuntime) : {}; ++ } = uniProps ? uniProps(proxifiedTheme, proxifiedRuntime) : {}; + const emptyStyles = narrowedProps.style ? Object.fromEntries(Object.entries(Object.getOwnPropertyDescriptors(narrowedProps.style)).filter(([key]) => !key.startsWith('unistyles') && !key.startsWith('_')).map(([key]) => [key, undefined])) : undefined; + const combinedProps = { +- ...(0, _utils.deepMergeObjects)(mappingsProps, unistyleProps, props), ++ ...(0, _utils.deepMergeObjects)(mappingsProps, unistyleProps, componentProps), + ...(narrowedProps.style ? { + // Override default component styles with undefined values to reset them + style: emptyStyles +diff --git a/node_modules/react-native-unistyles/lib/commonjs/core/withUnistyles/withUnistyles.native.js b/node_modules/react-native-unistyles/lib/commonjs/core/withUnistyles/withUnistyles.native.js +--- a/node_modules/react-native-unistyles/lib/commonjs/core/withUnistyles/withUnistyles.native.js ++++ b/node_modules/react-native-unistyles/lib/commonjs/core/withUnistyles/withUnistyles.native.js +@@ -32,6 +32,7 @@ + }; + return /*#__PURE__*/(0, _react.forwardRef)((props, ref) => { + const narrowedProps = props; ++ const { uniProps, ...componentProps } = narrowedProps; + const NativeComponent = Component; + + // @ts-ignore we don't know the type of the component +@@ -62,11 +63,11 @@ + const { + key: uniPropsKey, + ...unistyleProps +- } = narrowedProps.uniProps ? narrowedProps.uniProps(proxifiedTheme, proxifiedRuntime) : {}; ++ } = uniProps ? uniProps(proxifiedTheme, proxifiedRuntime) : {}; + const styleSecrets = getSecrets(narrowedProps.style); + const contentContainerStyleSecrets = getSecrets(narrowedProps.contentContainerStyle); + const finalProps = { +- ...(0, _utils.deepMergeObjects)(mappingsProps, unistyleProps, props), ++ ...(0, _utils.deepMergeObjects)(mappingsProps, unistyleProps, componentProps), + ...(narrowedProps.style ? { + style: styleSecrets.styles + } : {}), diff --git a/plugins/package.json b/plugins/package.json index de80b5b54c..30d9929eef 100644 --- a/plugins/package.json +++ b/plugins/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/builtin-plugins", - "version": "0.13.0", + "version": "0.13.2", "private": true, "type": "module", "scripts": { diff --git a/public-docs/configuration.md b/public-docs/configuration.md index a11c4c69ba..f89a12dcf7 100644 --- a/public-docs/configuration.md +++ b/public-docs/configuration.md @@ -74,7 +74,7 @@ Runtime-safe settings include relay enablement, MCP settings, browser tools, hos New homes keep relay disabled when you remove `daemon.relay.enabled`. A daemon whose config already omitted this field when it started keeps the legacy relay-enabled behavior for compatibility. Set `daemon.relay.enabled` explicitly when editing an older config. -Listen addresses, authentication, relay endpoints and TLS, worktree allocation, service-proxy addresses, the bundled web UI, logging, speech, voice, credentials, and local model settings require a restart. Reload applies other valid edits in the same file before reporting those paths. +Idle runtime cleanup, listen addresses, authentication, relay endpoints and TLS, worktree allocation, service-proxy addresses, the bundled web UI, logging, speech, voice, credentials, and local model settings require a restart. Reload applies other valid edits in the same file before reporting those paths. Deployment environment variables and legacy supervisor flags remain authoritative. Reload reports a changed file setting under `overrideControlledPaths` when a launch override prevents it from taking effect. This includes startup settings such as listen addresses, passwords, relay endpoints and TLS, service-proxy and web UI settings, logging, speech, and voice configuration. List settings such as hostnames and CORS origins still append across sources, so values from `config.json` continue to apply. Stop and relaunch the deployment without the override to make the file value authoritative. @@ -84,6 +84,22 @@ Agent providers, both the first-class ones Paseo ships with and custom entries y See [Providers](/docs/providers) for the mental model and [Supported providers](/docs/supported-providers) for the full list of agents Paseo can launch. For pointing Claude at Anthropic-compatible endpoints (Z.AI, Alibaba/Qwen), multiple profiles, custom binaries, ACP agents, and the `additionalModels` merge behavior, see [Custom providers](/docs/custom-providers). The full field reference lives on GitHub at [docs/custom-providers.md](https://github.com/getpaseo/paseo/blob/main/docs/custom-providers.md). +## Idle runtimes + +Paseo releases resumable idle agent processes after one hour when its daemon baseline allows +cleanup and any implemented provider check agrees. Your next prompt resumes the same conversation. Disable this +for the entire host by turning off **Release idle agents** in **Settings → your host → Agents**, or with: + +```json +{ "agents": { "idleRuntimeCleanup": false } } +``` + +This setting defaults to `true`. Changes in Settings apply immediately; config-file edits apply +on config reload. Re-enabling keeps existing idle timestamps. There is no per-agent setting. Providers without a runtime safety check use the same daemon baseline and one-hour idle +period. Active work, descendants and pending finish notifications prevent cleanup. ACP adapters +also retain runtimes with running ACP terminals, including silent ones; exited terminals do not +prevent cleanup. This check does not inspect processes launched internally by the provider. + ## Worktrees New worktrees are created under `$PASEO_HOME/worktrees` by default. To place new worktrees somewhere else, set `worktrees.root`: @@ -100,7 +116,7 @@ Relative paths are resolved against `PASEO_HOME`. Existing worktrees remain wher ## Voice -Voice is configured through `features.dictation` and `features.voiceMode`, with provider credentials under `providers`. +Voice is configured through `features.dictation` and `features.voiceMode`. Provider credentials and options live inline at `features.dictation.stt`, `features.voiceMode.stt`, and `features.voiceMode.tts`. For voice philosophy, architecture, and complete local/OpenAI setup examples, see [Voice docs](/docs/voice). @@ -253,9 +269,9 @@ Set the persisted value in `config.json`: - `PASEO_LOG_FILE_ROTATE_SIZE`, override `log.file.rotate.maxSize` - `PASEO_LOG_FILE_ROTATE_COUNT`, override `log.file.rotate.maxFiles` - `PASEO_LOG`, `PASEO_LOG_FORMAT`, legacy log overrides (still supported) -- `OPENAI_API_KEY`, override OpenAI provider key -- `OPENAI_STT_API_KEY`, `OPENAI_STT_BASE_URL`, OpenAI speech-to-text endpoint (dictation + voice mode STT) -- `OPENAI_TTS_API_KEY`, `OPENAI_TTS_BASE_URL`, OpenAI text-to-speech endpoint (voice mode TTS) +- `OPENAI_API_KEY`, `OPENAI_BASE_URL`, fallback speech credentials and base URL when neither a site value nor an endpoint env value is set +- `OPENAI_STT_API_KEY`, `OPENAI_STT_BASE_URL`, speech-to-text key and base URL fallbacks when no value is saved at the dictation or voice-mode STT site +- `OPENAI_TTS_API_KEY`, `OPENAI_TTS_BASE_URL`, text-to-speech key and base URL fallbacks when no value is saved at the voice-mode TTS site - `PASEO_VOICE_LLM_PROVIDER`, override voice LLM provider (`claude`, `codex`, `opencode`) - `PASEO_DICTATION_STT_PROVIDER`, `PASEO_VOICE_STT_PROVIDER`, `PASEO_VOICE_TTS_PROVIDER`, override voice provider selection (`local` or `openai`) - `PASEO_LOCAL_MODELS_DIR`, control local model directory diff --git a/public-docs/configuration.zh-CN.md b/public-docs/configuration.zh-CN.md index 4f904db8d8..532b763907 100644 --- a/public-docs/configuration.zh-CN.md +++ b/public-docs/configuration.zh-CN.md @@ -72,7 +72,7 @@ paseo daemon restart 当你删除 `daemon.relay.enabled` 时,新的主目录会保持中继关闭。如果 daemon 启动时其配置就已经省略了这个字段,它会为了兼容而保持旧版的中继开启行为。编辑较旧的配置时,请显式设置 `daemon.relay.enabled`。 -监听地址、身份验证、中继端点和 TLS、worktree 分配、服务代理地址、内置的 Web UI、日志、语音合成与识别、语音、凭据以及本地模型设置都需要重启。重新加载会先应用同一文件中的其他有效编辑,再报告这些路径。 +空闲运行时清理、监听地址、身份验证、中继端点和 TLS、worktree 分配、服务代理地址、内置的 Web UI、日志、语音合成与识别、语音、凭据以及本地模型设置都需要重启。重新加载会先应用同一文件中的其他有效编辑,再报告这些路径。 部署环境变量和旧版进程管理器参数仍然具有最终决定权。当某个启动覆盖项阻止了文件中已更改的设置生效时,重新加载会在 `overrideControlledPaths` 下报告它。这包括启动设置,例如监听地址、密码、中继端点和 TLS、服务代理和 Web UI 设置、日志、语音合成与识别以及语音配置。主机名和 CORS 来源等列表设置仍然在各个来源之间追加,因此 `config.json` 中的值继续生效。要让文件中的值具有决定权,请停止该部署并在不带覆盖项的情况下重新启动。 @@ -82,6 +82,16 @@ Agent Provider,包括 Paseo 自带的一等 Provider 和你在 `agents.provide 心智模型见 [Provider](/docs/providers),Paseo 可以启动的全部 Agent 见[支持的 Provider](/docs/supported-providers)。要让 Claude 指向兼容 Anthropic 的端点(Z.AI、Alibaba/Qwen)、使用多个配置、自定义二进制文件、ACP Agent,以及了解 `additionalModels` 的合并行为,见[自定义 Provider](/docs/custom-providers)。完整的字段参考在 GitHub 上的 [docs/custom-providers.md](https://github.com/getpaseo/paseo/blob/main/docs/custom-providers.md)。 +## 空闲运行时 {#idle-runtimes} + +当 daemon 的基础条件允许清理,且 Provider 已实现的安全检查也允许时,Paseo 会在可恢复的 Agent 空闲一小时后释放其进程。你的下一条提示词会恢复同一个会话。要为整个主机禁用此功能,请在 **设置 → 你的主机 → Agents** 中关闭 **释放空闲 Agent**,或设置: + +```json +{ "agents": { "idleRuntimeCleanup": false } } +``` + +此设置默认为 `true`。在设置中更改后立即生效;配置文件中的更改在重新加载配置后生效。重新启用会保留现有的空闲计时。没有单个 Agent 的设置。不提供运行时安全检查的 Provider 使用相同的 daemon 基础条件和一小时空闲期。活动中的工作、后代 Agent 和待发送的完成通知都会阻止清理。ACP 适配器还会在 ACP 终端仍运行时保留运行时,即使终端没有输出;已退出的终端不会阻止清理。此检查不检查 Provider 内部启动的进程。 + ## worktree {#worktrees} 新的 worktree 默认创建在 `$PASEO_HOME/worktrees` 下。要把新的 worktree 放到其他位置,请设置 `worktrees.root`: diff --git a/public-docs/plugins/providers.md b/public-docs/plugins/providers.md index 2f4951669c..6fbe766c61 100644 --- a/public-docs/plugins/providers.md +++ b/public-docs/plugins/providers.md @@ -306,6 +306,52 @@ turn or finish as a side effect with a `completed` prompt result. Advertise only capabilities you implement. If steering is unsupported, omit `prompt.steer`; Paseo can replace the active turn instead. +## Permit idle runtime cleanup + +Paseo can release an idle resumable session after one hour when its daemon baseline allows cleanup. +Add a provider-owned veto by advertising `session.can_stop_runtime` on the connection and the +session's `session.opened` capabilities. Without this capability, the same baseline and one-hour +idle period apply, and the plugin receives no safety request. + +Handle the safety query in your existing `send(input)` handler. It addresses the provider's own +session ID, including when one connection serves several sessions: + +```ts +case "session.can_stop_runtime": { + try { + const session = sessions.get(input.sessionId); + if (!session) throw new Error("Session not found"); + emit({ + type: "session.can_stop_runtime", + requestId: input.requestId, + sessionId: input.sessionId, + canStop: await session.canStopRuntime(), + }); + } catch (error) { + emit({ + type: "request.failed", + requestId: input.requestId, + error: { message: error instanceof Error ? error.message : String(error) }, + }); + } + return; +} +``` + +`canStopRuntime(): Promise` belongs to your provider's session implementation. Return +`true` only when releasing the runtime loses no background shells, monitors, native children, +queued completion reports, or pending autonomous replies. This query must not stop or mutate the +session. Return `false` while work remains. A rejected query, invalid response, or timeout also +keeps the runtime alive. Never respond with `request.completed` for this query. + +Paseo always checks foreground work, permissions, queued deliveries, lifecycle operations, +working managed/native descendants, and outstanding local or remote finish subscriptions for the +caller. Subscription protection lasts through registration, recovery and notification dispatch +acknowledgment. A positive provider response never overrides these blockers. Paseo rechecks +activity before the existing `session.close` operation. After protected work finishes, a fresh +one-hour idle period starts. The saved provider persistence must resume the same conversation. +The host setting `agents.idleRuntimeCleanup: false` disables collection for every provider. + ## Publish timeline items Publish complete snapshots through `timeline.item`. Reuse an item `id` when updating streamed text, diff --git a/public-docs/plugins/providers.zh-CN.md b/public-docs/plugins/providers.zh-CN.md index d49e559d49..6cc0a2cb14 100644 --- a/public-docs/plugins/providers.zh-CN.md +++ b/public-docs/plugins/providers.zh-CN.md @@ -269,6 +269,38 @@ Paseo 刷新 Agent 的方式是关闭它当前的 Provider 会话,然后用当 只声明你实现了的能力。如果不支持引导,请省略 `prompt.steer`;Paseo 可以改为替换当前轮次。 +## 允许清理空闲运行时 {#permit-idle-runtime-cleanup} + +当 daemon 的基础条件允许清理时,Paseo 可以在可恢复的会话空闲一小时后释放其运行时。在连接和会话的 `session.opened` capabilities 中声明 `session.can_stop_runtime`,即可添加 Provider 自己的否决检查。没有此能力时,会话仍使用相同的基础条件和一小时空闲期,插件不会收到安全查询。 + +在现有的 `send(input)` 处理函数中处理安全查询。请求使用 Provider 自己的会话 ID,包括同一连接服务多个会话的情况: + +```ts +case "session.can_stop_runtime": { + try { + const session = sessions.get(input.sessionId); + if (!session) throw new Error("Session not found"); + emit({ + type: "session.can_stop_runtime", + requestId: input.requestId, + sessionId: input.sessionId, + canStop: await session.canStopRuntime(), + }); + } catch (error) { + emit({ + type: "request.failed", + requestId: input.requestId, + error: { message: error instanceof Error ? error.message : String(error) }, + }); + } + return; +} +``` + +`canStopRuntime(): Promise` 由你的 Provider 会话实现。只有释放运行时不会丢失后台 shell、监控任务、原生子 Agent、排队的完成报告或待处理的自主回复时,才返回 `true`。此查询不得停止或修改会话。仍有工作时返回 `false`。查询拒绝、响应无效或超时也会保留运行时。不要用 `request.completed` 响应此查询。 + +Paseo 始终检查前台工作、权限、排队投递、生命周期操作、工作中的托管或原生后代 Agent,以及调用方尚未完成的本地或远程完成通知订阅。订阅保护覆盖注册、恢复,直到通知投递获确认。Provider 返回 `true` 也不能绕过这些阻止条件。Paseo 会在调用现有的 `session.close` 操作之前重新检查活动。受保护的工作完成后,会重新开始一小时的空闲期。保存的 Provider 持久化数据必须能恢复同一个会话。主机设置 `agents.idleRuntimeCleanup: false` 会禁用所有 Provider 的清理。 + ## 发布时间线条目 {#publish-timeline-items} 通过 `timeline.item` 发布完整的快照。更新流式文本、运行中的工具或待办列表时,复用条目的 `id`。Paseo 会推导出实时增量,并保持正常的时间线。 diff --git a/public-docs/voice.md b/public-docs/voice.md index 79022c15ad..c90e5196bc 100644 --- a/public-docs/voice.md +++ b/public-docs/voice.md @@ -27,27 +27,24 @@ This keeps credentials and execution in your environment and avoids introducing Local speech defaults to model IDs `parakeet-tdt-0.6b-v2-int8` (STT) and `kokoro-en-v0_19` (TTS, speaker 0 / voice 00). -Missing models are downloaded at daemon startup into `$PASEO_HOME/models/local-speech`. Downloads happen only for missing files. +Dictation is on by default. Voice mode is off until you turn it on in **Settings > Voice** or set `features.voiceMode.enabled: true`. + +Missing models for enabled features are downloaded at daemon startup into `$PASEO_HOME/models/local-speech`. Downloads happen only for missing files. **Settings > Voice** lists every local model with its size and languages, and lets you download or delete each one. ### Local STT models and language support -| Model ID | Languages | -| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `parakeet-tdt-0.6b-v2-int8` | English only (default). Includes punctuation and capitalization. | -| `parakeet-tdt-0.6b-v3-int8` | 25 European languages, auto-detected: Bulgarian, Croatian, Czech, Danish, Dutch, English, Estonian, Finnish, French, German, Greek, Hungarian, Italian, Latvian, Lithuanian, Maltese, Polish, Portuguese, Romanian, Russian, Slovak, Slovenian, Spanish, Swedish, Ukrainian. | - -**To use a non-English language, switch the local STT model to `parakeet-tdt-0.6b-v3-int8`.** v3 detects the spoken language automatically — there is no per-language setting for it. The `language` field below does **not** steer the local Parakeet model (v2 is English-only, v3 auto-detects); it only applies to the OpenAI STT provider. +**Settings > Voice** lists every local STT model with the languages it supports. The default, `parakeet-tdt-0.6b-v2-int8`, is English-only. For other languages, pick a multilingual model such as `parakeet-tdt-0.6b-v3-int8` (25 European languages) or `whisper-base` (99 languages). The `language` field is passed to STT sessions for both providers. Local recognizers currently detect the spoken language themselves; setting `language` does not expand a model’s supported languages. ```json { "version": 1, "features": { "dictation": { - "stt": { "provider": "local", "model": "parakeet-tdt-0.6b-v2-int8", "language": "en" } + "stt": { "provider": "local", "model": "parakeet-tdt-0.6b-v2-int8" } }, "voiceMode": { "llm": { "provider": "claude", "model": "haiku" }, - "stt": { "provider": "local", "model": "parakeet-tdt-0.6b-v2-int8", "language": "en" }, + "stt": { "provider": "local", "model": "parakeet-tdt-0.6b-v2-int8" }, "tts": { "provider": "local", "model": "kokoro-en-v0_19", "speakerId": 0 } } }, @@ -59,7 +56,7 @@ Missing models are downloaded at daemon startup into `$PASEO_HOME/models/local-s } ``` -For multilingual local dictation, set the model to v3 — it auto-detects the language, so no `language` field is needed: +For multilingual local dictation, set a multilingual model. No `language` field is needed: ```json { @@ -72,38 +69,53 @@ For multilingual local dictation, set the model to v3 — it auto-detects the la } ``` -The `language` field applies only to the OpenAI STT provider: set `features.dictation.stt.language` for dictation and `features.voiceMode.stt.language` for voice mode. If voice language is omitted, Paseo uses the dictation language before falling back to `en`. It has no effect on the local Parakeet models. +For either STT provider, set `features.dictation.stt.language` for dictation and `features.voiceMode.stt.language` for voice mode. If voice language is omitted, Paseo uses the dictation language before falling back to `en`. ## OpenAI Voice Option -You can switch dictation, voice STT, and voice TTS to OpenAI by setting provider fields to `openai` and providing OpenAI credentials. +Set `provider` to `openai` and enter credentials at each use site that needs them. +Dictation, listening and speaking each own their key, base URL and model: ```json { "version": 1, "features": { - "dictation": { "stt": { "provider": "openai" } }, + "dictation": { + "stt": { "provider": "openai", "apiKey": "sk-...", "model": "gpt-4o-mini-transcribe" } + }, "voiceMode": { - "stt": { "provider": "openai" }, - "tts": { "provider": "openai" } - } - }, - "providers": { - "openai": { + "enabled": true, "stt": { - "apiKey": "...", - "baseUrl": "https://api.openai.com/v1" + "provider": "openai", + "apiKey": "sk-...", + "baseUrl": "https://api.openai.com/v1", + "model": "gpt-4o-transcribe", + "language": "en" }, - "tts": { - "apiKey": "...", - "baseUrl": "https://api.openai.com/v1" - } + "tts": { "provider": "openai", "apiKey": "sk-...", "model": "tts-1", "voice": "alloy" } } } } ``` -`providers.openai.stt` covers dictation and voice mode speech-to-text, and `providers.openai.tts` covers voice mode text-to-speech. Because they resolve independently, you can point STT and TTS at different endpoints. Each falls back to `providers.openai.apiKey`/`baseUrl`, then `OPENAI_API_KEY`/`OPENAI_BASE_URL`, when unset. These settings configure only Paseo OpenAI speech traffic, without changing Codex or other OpenAI-backed tools. +| Provider | Speech-to-text fields | Text-to-speech fields | +| -------- | --------------------------------------------------------------- | ------------------------------------- | +| `local` | `model`, `language` | `model`, `speakerId`, `speed` | +| `openai` | `apiKey`, `baseUrl`, `model`, `language`, `confidenceThreshold` | `apiKey`, `baseUrl`, `model`, `voice` | + +Fields other than `provider` are optional. Omit `model` to use the provider's default. +OpenAI model and voice names accept custom IDs from compatible servers. Keys saved +on the host are write-only in the app. + +A saved `apiKey` or `baseUrl` wins over env settings. Without a saved value, STT +uses `OPENAI_STT_API_KEY` / `OPENAI_STT_BASE_URL`, TTS uses +`OPENAI_TTS_API_KEY` / `OPENAI_TTS_BASE_URL`, and both fall back to +`OPENAI_API_KEY` / `OPENAI_BASE_URL`. These settings affect Paseo speech traffic. + +Existing `providers.openai` settings load automatically into OpenAI use sites that +lack inline values. Endpoint settings take precedence over the old shared settings. +The next save writes the inline shape. A legacy key without an OpenAI site to +receive it is dropped with a warning; enter a key when you later select OpenAI. Paseo uses these paths under the configured OpenAI base URL: @@ -113,6 +125,14 @@ Paseo uses these paths under the configured OpenAI base URL: ## Environment Variables +Provider, enablement, model, language, voice and tuning env vars override config. +Keys and base URLs follow the fallback rule above. + +- `PASEO_DICTATION_ENABLED`, `PASEO_VOICE_MODE_ENABLED`, feature enablement +- `STT_MODEL`, model override for both OpenAI STT sites +- `STT_CONFIDENCE_THRESHOLD`, confidence threshold for both OpenAI STT sites +- `TTS_MODEL`, `TTS_VOICE`, OpenAI speaking model and voice + - `PASEO_VOICE_LLM_PROVIDER`, voice agent provider override - `PASEO_DICTATION_STT_PROVIDER`, `PASEO_VOICE_STT_PROVIDER`, `PASEO_VOICE_TTS_PROVIDER`, speech provider selection (`local` or `openai`) - `OPENAI_STT_API_KEY`, `OPENAI_STT_BASE_URL`, OpenAI speech-to-text endpoint (dictation + voice mode STT) @@ -120,8 +140,8 @@ Paseo uses these paths under the configured OpenAI base URL: - `PASEO_LOCAL_MODELS_DIR`, local model storage directory - `PASEO_DICTATION_LOCAL_STT_MODEL`, local dictation STT model ID - `PASEO_VOICE_LOCAL_STT_MODEL`, `PASEO_VOICE_LOCAL_TTS_MODEL`, local voice STT/TTS model IDs -- `PASEO_DICTATION_LANGUAGE`, dictation STT language (OpenAI STT only; ignored by local Parakeet) -- `PASEO_VOICE_LANGUAGE`, voice mode STT language; falls back to `PASEO_DICTATION_LANGUAGE` when unset (OpenAI STT only; ignored by local Parakeet) +- `PASEO_DICTATION_LANGUAGE`, dictation STT language +- `PASEO_VOICE_LANGUAGE`, voice mode STT language; falls back to `PASEO_DICTATION_LANGUAGE` when unset - `PASEO_VOICE_LOCAL_TTS_SPEAKER_ID`, `PASEO_VOICE_LOCAL_TTS_SPEED`, optional local voice TTS tuning ## Operational Notes diff --git a/scripts/ci-workflow.test.mjs b/scripts/ci-workflow.test.mjs index c16e772172..4842870cc3 100644 --- a/scripts/ci-workflow.test.mjs +++ b/scripts/ci-workflow.test.mjs @@ -1,5 +1,8 @@ import assert from "node:assert/strict"; -import { readFileSync, readdirSync } from "node:fs"; +import { readFileSync, readdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { relative as relativePath } from "node:path"; import test from "node:test"; import { runInNewContext } from "node:vm"; @@ -530,3 +533,111 @@ test("Windows units and integration run independently with complete routed cover assert.equal(selected(integrationCondition, outputs, true), false); } }); + +test("macOS release retries only transient stapling failures without rebuilding the signed app", async (t) => { + const workflow = readFileSync(new URL(".github/workflows/desktop-release.yml", repoRoot), "utf8"); + const wrapper = workflow + .match(/<<'STAPLER'\n([\s\S]*?)\n STAPLER/)[1] + .replace(/^ /gm, ""); + const networkError = + 'Error Domain=NSURLErrorDomain Code=-1005 "The network connection was lost."'; + const cases = [ + { + name: "success", + outcomes: [0], + output: "stapled", + command: "stapler", + expected: 0, + attempts: 1, + }, + { + name: "transient then success", + outcomes: [68, 0], + output: networkError, + command: "stapler", + expected: 0, + attempts: 2, + }, + { + name: "exhausted network retries", + outcomes: [68, 68, 68, 68, 0], + output: networkError, + command: "stapler", + expected: 68, + attempts: 4, + }, + { + name: "non-network staple error", + outcomes: [68, 0], + output: "ticket not found", + command: "stapler", + expected: 68, + attempts: 1, + }, + { + name: "different exit code", + outcomes: [1, 0], + output: networkError, + command: "stapler", + expected: 1, + attempts: 1, + }, + { + name: "notary submission is not retried", + outcomes: [68, 0], + output: networkError, + command: "notarytool", + expected: 68, + attempts: 1, + }, + ]; + for (const scenario of cases) { + await t.test(scenario.name, () => { + const directory = mkdtempSync(join(tmpdir(), "stapler-retry-")); + try { + const statePath = join(directory, "state.json"); + const realXcrun = join(directory, "real-xcrun"); + const wrapperPath = join(directory, "xcrun"); + writeFileSync(wrapperPath, wrapper, { mode: 0o755 }); + writeFileSync(join(directory, "sleep"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + writeFileSync( + statePath, + JSON.stringify({ outcomes: scenario.outcomes, output: scenario.output, calls: [] }), + ); + writeFileSync( + realXcrun, + `#!/usr/bin/env node +const fs = require("node:fs"); +const state = JSON.parse(fs.readFileSync(process.env.STAPLER_STATE, "utf8")); +state.calls.push(process.argv.slice(2)); +const code = state.outcomes.shift(); +fs.writeFileSync(process.env.STAPLER_STATE, JSON.stringify(state)); +console.log(state.output); +process.exit(code); +`, + { mode: 0o755 }, + ); + const args = [scenario.command, "staple", "-v", "Paseo signed.app"]; + const result = spawnSync("bash", [wrapperPath, ...args], { + encoding: "utf8", + env: { + ...process.env, + PASEO_REAL_XCRUN: realXcrun, + STAPLER_STATE: statePath, + PATH: `${directory}:${process.env.PATH}`, + }, + timeout: 5000, + }); + assert.equal(result.status, scenario.expected, result.stdout + result.stderr); + const state = JSON.parse(readFileSync(statePath, "utf8")); + assert.deepEqual( + state.calls, + Array.from({ length: scenario.attempts }, () => args), + ); + assert.match(result.stdout, /stapled|network connection was lost|ticket not found/); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); + } +}); diff --git a/scripts/postinstall-patches.mjs b/scripts/postinstall-patches.mjs index d82af94788..435f6331e0 100644 --- a/scripts/postinstall-patches.mjs +++ b/scripts/postinstall-patches.mjs @@ -14,17 +14,29 @@ const patchedPackages = [ nodeModulesPath: "node_modules/expo-modules-core", patchPrefix: "expo-modules-core+", }, + // Backport https://github.com/software-mansion/enriched-markdown/pull/924: preserve settled + // Android measurements across parent clones. Remove those hunks when upgrading past + // 1.2.0-nightly-20261019-926ef06f0; reconcile the other local patch hunks separately. { nodeModulesPath: "packages/app/node_modules/react-native-enriched-markdown", patchPrefix: "react-native-enriched-markdown+", cwd: "packages/app", }, - // Remove after react-native-unistyles ships + // Registry cleanup: remove after react-native-unistyles ships // https://github.com/jpudysz/react-native-unistyles/pull/1203. + // withUnistyles: also keep the uniProps filter until upstream consumes its control prop. { nodeModulesPath: "node_modules/react-native-unistyles", patchPrefix: "react-native-unistyles+", }, + // Backport of https://github.com/software-mansion/react-native-reanimated/pull/10435 + // (Reanimated 4.7.1): skip synchronous Android prop updates for unmounted view tags + // instead of throwing and printing a full stack trace on the UI thread per tag per event. + // Remove when the installed Reanimated is >= 4.7.1. + { + nodeModulesPath: "node_modules/react-native-reanimated", + patchPrefix: "react-native-reanimated+", + }, { nodeModulesPath: "node_modules/react-native-draggable-flatlist", patchPrefix: "react-native-draggable-flatlist+", @@ -37,6 +49,9 @@ const patchedPackages = [ // modal stack (see packages/app/src/components/ui/isolated-bottom-sheet-modal/host-scope.tsx), and // keeps a dismissing modal's status through its final onChange(-1), so a modal with // enableDismissOnClose=false that unmounts while presented still leaves the portal. + // Dimensions subscription cleanup: https://github.com/gorhom/react-native-bottom-sheet/issues/2734 + // and https://github.com/gorhom/react-native-bottom-sheet/pull/2751. Remove the useAnimatedLayout + // hunks once an installed release includes the cleanup; retain the other local hunks. { nodeModulesPath: "node_modules/@gorhom/bottom-sheet", patchPrefix: "@gorhom+bottom-sheet+",