Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 979326c04f Install local speech runtime on demand
The daemon no longer bundles sherpa-onnx, sherpa-onnx-node, or
onnxruntime-node. On first boot they are downloaded into
$PASEO_HOME/runtime/local-speech alongside the existing voice models,
non-blocking. Users who don't enable local voice never pay the size
cost.

The wire protocol is unchanged. server_info.capabilities.voice.*
already gates voice in the client; readiness now reports
runtime_install_in_progress / runtime_missing / runtime_install_failed
on the same channel as the existing model_download_* states.

The native runtime lives under packages/server/src/server/speech/
providers/local/runtime and exposes a small surface: configure the
home once at speech-service init, ensureInstalled, loadPackage,
getStatus, plus a typed error. Provider configs do not know about
the install path.
2026-05-21 10:44:50 +00:00
paseo-ai[bot] 6dc22483c5 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-21 10:11:01 +00:00
Mohamed Boudra a9f5b8ea4d chore(release): cut 0.1.79 2026-05-21 17:03:25 +07:00
Mohamed Boudra 1ce30bac05 docs(changelog): draft 0.1.79 entry 2026-05-21 16:51:15 +07:00
Mohamed Boudra 754f5a1f0b Tweak import session tile copy 2026-05-21 16:27:49 +07:00
Mohamed Boudra 698390bf1c feat(app): copy resume command for pi agents 2026-05-21 16:23:23 +07:00
Mohamed Boudra 63b24ec261 Merge branch 'main' of github.com:getpaseo/paseo 2026-05-21 16:19:04 +07:00
Mohamed Boudra ad060e2f86 feat: import existing CLI sessions from the home screen
Home screen now leads with action tiles (Add a project, Import session,
Setup providers, Pair device) instead of a single button, giving new
users a clear path in even before they've added a project.

Import session is the onboarding hook: the sheet now works without a
workspace context and lists recent sessions from every provider's native
history (Claude, Codex, OpenCode, Pi, ACP). Picking one imports the
agent and lands the user on its workspace.

To make that work end-to-end, the daemon upserts the imported agent's
cwd as a workspace so the sidebar surfaces it even when the user never
explicitly opened the folder.

Also adds a Home entry to cmd+K and a refresh button to the import
sheet header for explicit re-fetch.
2026-05-21 16:15:28 +07:00
paseo-ai[bot] 1e595ad9cb fix: update lockfile signatures and Nix hash [skip ci] 2026-05-21 08:21:38 +00:00
Mohamed Boudra 9fd93f8308 Merge remote-tracking branch 'origin/main' into release-beta-0-1-79-beta-3 2026-05-21 15:17:35 +07:00
paseo-ai[bot] fac81f568a fix: update lockfile signatures and Nix hash [skip ci] 2026-05-21 05:51:50 +00:00
Mohamed Boudra 57de8c3807 chore(release): cut 0.1.79-beta.3 2026-05-21 12:50:43 +07:00
Mohamed Boudra 0310fd3c3c Merge branch 'main' of github.com:getpaseo/paseo 2026-05-21 12:48:30 +07:00
Mohamed Boudra a837d3e5d1 fix(app): keep React aligned with native renderer 2026-05-21 12:44:19 +07:00
paseo-ai[bot] b5a78b7261 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-21 04:26:52 +00:00
43 changed files with 1667 additions and 334 deletions
+9 -7
View File
@@ -1,16 +1,18 @@
# Changelog
## 0.1.79-beta.1 - 2026-05-20
## 0.1.79 - 2026-05-21
### Added
- **Pi has been revamped with first-class support**
- Pi now runs in a separate process, isolated from the rest of the daemon
- Pi runs through your installed Pi CLI, so your Pi extensions and configuration work exactly the same as launching Pi from your terminal
- Pi agents can call Paseo tools — create Paseo agents, work on worktrees, run anything in the Paseo MCP — when you have the Pi MCP extension installed
- Past Pi sessions can be imported into Paseo from Pi's saved history
- Runs through your installed Pi CLI, so your Pi extensions and configuration carry over
- Pi agents can call Paseo tools when you have the Pi MCP extension installed
- Import a Pi session you started in the terminal
- Copy Pi's resume command from any agent to continue the session in your terminal
- Windows: Pi sessions match correctly across symlinked and junctioned workspace paths
- **Set a custom system prompt for every agent you start** A daemon-wide system prompt gets appended to every supported agent provider
- **New home screen with quick tiles for adding a project, importing a session, setting up providers, and pairing a device**
- **Create an agent directly into a fresh worktree that auto-archives when the run finishes**
- **Set a custom system prompt that applies to every agent you start**
- **Rename workspaces, terminals, and agent tabs** ([#531](https://github.com/getpaseo/paseo/pull/531))
- **DeepSeek TUI in the ACP provider catalog** ([#1096](https://github.com/getpaseo/paseo/pull/1096))
- **Kiro CLI in the ACP provider catalog** (by [@huhusmang](https://github.com/huhusmang))
@@ -20,6 +22,7 @@
### Improved
- **Stale host connections recover automatically without a manual refresh**
- Paseo opens to the workspace you were on last time you used it ([#1101](https://github.com/getpaseo/paseo/pull/1101))
- Workspaces remember which editor you opened them in
- Outdated daemons now suggest an upgrade when they receive a command they don't understand
@@ -40,7 +43,6 @@
- Claude file links resolve correctly for projects whose paths need SDK encoding
- Duplicate Claude result text no longer appears in chat ([#1095](https://github.com/getpaseo/paseo/pull/1095))
- Dynamic UI styles no longer leak CSS rules across the page ([#1103](https://github.com/getpaseo/paseo/pull/1103))
- CLI queries daemon status and pairing offers through the proper RPC path (by [@yzx9](https://github.com/yzx9))
- Relay handshakes reject sessions that try to change encryption keys mid-flight ([#1037](https://github.com/getpaseo/paseo/pull/1037) by [@joaosa](https://github.com/joaosa))
## 0.1.78 - 2026-05-18
+4
View File
@@ -38,6 +38,10 @@ npx cross-env APP_VARIANT=production expo run:android --variant=release
rm -rf android
```
### React version lockstep
Keep `react` and `react-dom` pinned to the React version embedded by the current `react-native` release. React Native `0.81.x` embeds `react-native-renderer` `19.1.0`, so `packages/app` must use React `19.1.0`. Bumping React to a newer patch can build successfully but crash at JS startup on Android with `Incompatible React versions`, leaving the app on the native splash screen.
## Screenshots
```bash
+25 -7
View File
@@ -173,7 +173,25 @@ All fields are optional with sensible defaults.
---
## 3. Schedule
## 3. Local Speech Runtime
**Path:** `$PASEO_HOME/runtime/local-speech/node_modules/`
Native local voice packages are installed on demand at daemon bootstrap, not bundled with the daemon package. The runtime installer downloads npm tarballs into `$PASEO_HOME/runtime/local-speech/.downloads`, extracts them into a temporary directory, verifies required files, then atomically moves each package into `node_modules`.
Installed packages:
- `sherpa-onnx`
- `sherpa-onnx-node`
- `sherpa-onnx-<platform>-<arch>` for the current host
- `onnxruntime-common`
- `onnxruntime-node`
The daemon starts without waiting for these packages. While they are missing, `server_info.capabilities.voice.*` reports local voice unavailable through the existing capability fields. Once the runtime and required local speech models are present, the next speech runtime reconcile makes local voice available without restarting the daemon.
---
## 4. Schedule
**Path:** `$PASEO_HOME/schedules/{id}.json`
@@ -221,7 +239,7 @@ One file per schedule. ID is 8 hex characters. Writes are direct (not atomic).
---
## 4. Chat
## 5. Chat
**Path:** `$PASEO_HOME/chat/rooms.json`
@@ -258,7 +276,7 @@ Single file containing all rooms and messages.
---
## 5. Loop
## 6. Loop
**Path:** `$PASEO_HOME/loops/loops.json`
@@ -347,7 +365,7 @@ Single file containing an array of all loop records. Writes are direct (not atom
---
## 6. Project Registry
## 7. Project Registry
**Path:** `$PASEO_HOME/projects/projects.json`
@@ -370,7 +388,7 @@ emptied duplicate.
---
## 7. Workspace Registry
## 8. Workspace Registry
**Path:** `$PASEO_HOME/projects/workspaces.json`
@@ -389,7 +407,7 @@ Array of workspace records. A workspace is a specific working directory within a
---
## 8. Push Token Store
## 9. Push Token Store
**Path:** `$PASEO_HOME/push-tokens.json`
@@ -403,7 +421,7 @@ Simple set of Expo push notification tokens. Loaded with permissive parsing (fil
---
## 9. Daemon meta files
## 10. Daemon meta files
These small files are not validated as full Zod schemas but are persisted under `$PASEO_HOME` for daemon identity and runtime coordination.
+1 -1
View File
@@ -1 +1 @@
sha256-FaJYHXCHm+QEhLlP9UTMOJwf/62AD5euAsUT31PvRq0=
sha256-ipuV4cM3LUqMQ39LyB4X3ezz5UgxyHGEhpBOq7en2Jw=
+41 -41
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.79-beta.2",
"version": "0.1.79",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.79-beta.2",
"version": "0.1.79",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -13864,6 +13864,7 @@
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz",
"integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0"
@@ -15185,6 +15186,7 @@
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
"integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"dev": true,
"license": "MIT"
},
"node_modules/bplist-creator": {
@@ -17051,6 +17053,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
"integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
"dev": true,
"license": "MIT"
},
"node_modules/detect-node-es": {
@@ -18154,6 +18157,7 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
"integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": {
@@ -22695,6 +22699,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz",
"integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"boolean": "^3.0.1",
@@ -22712,6 +22717,7 @@
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz",
"integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==",
"dev": true,
"license": "MIT",
"dependencies": {
"type-fest": "^0.13.1"
@@ -22727,6 +22733,7 @@
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
"integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==",
"dev": true,
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=10"
@@ -22752,6 +22759,7 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
"integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"define-properties": "^1.2.1",
@@ -25406,6 +25414,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
"dev": true,
"license": "ISC"
},
"node_modules/json5": {
@@ -26425,6 +26434,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
"integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==",
"dev": true,
"license": "MIT",
"dependencies": {
"escape-string-regexp": "^4.0.0"
@@ -29065,12 +29075,14 @@
"version": "1.24.3",
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.3.tgz",
"integrity": "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA==",
"dev": true,
"license": "MIT"
},
"node_modules/onnxruntime-node": {
"version": "1.24.3",
"resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.24.3.tgz",
"integrity": "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"os": [
@@ -30656,7 +30668,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -30697,7 +30708,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@@ -31997,6 +32007,7 @@
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz",
"integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"boolean": "^3.0.1",
@@ -32014,6 +32025,7 @@
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/rollup": {
@@ -32305,6 +32317,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz",
"integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==",
"dev": true,
"license": "MIT"
},
"node_modules/send": {
@@ -32594,6 +32607,7 @@
"version": "1.12.28",
"resolved": "https://registry.npmjs.org/sherpa-onnx/-/sherpa-onnx-1.12.28.tgz",
"integrity": "sha512-PaOuTCUA/Vh5o3zcq+z3j1Zpv2VlP7iubOgYoVODDyeFDicRxjskiEQqERdWEcpQUi6vh47q3A63bS7Y9FRHNg==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/sherpa-onnx-darwin-arm64": {
@@ -32603,6 +32617,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -32616,6 +32631,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -32629,6 +32645,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -32642,6 +32659,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -32652,6 +32670,7 @@
"version": "1.12.28",
"resolved": "https://registry.npmjs.org/sherpa-onnx-node/-/sherpa-onnx-node-1.12.28.tgz",
"integrity": "sha512-EHSB3EG6hKyXaTNh6GU/bwh6i3dncCH6ZCU2mScNzkxRbVStZ7QmNj0Oo4E9XrGGo9jX9pKg9MPHEPyjdK+ApA==",
"dev": true,
"license": "Apache-2.0",
"optionalDependencies": {
"sherpa-onnx-darwin-arm64": "^1.12.28",
@@ -32669,6 +32688,7 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -32682,6 +32702,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -36412,7 +36433,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.79-beta.2",
"version": "0.1.79",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -36464,8 +36485,8 @@
"lucide-react-native": "^0.546.0",
"mnemonic-id": "^3.2.7",
"qrcode": "^1.5.4",
"react": "19.1.4",
"react-dom": "19.1.4",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "^0.81.5",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-edge-to-edge": "^1.7.0",
@@ -36519,27 +36540,6 @@
"react-native": "*"
}
},
"packages/app/node_modules/react": {
"version": "19.1.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.4.tgz",
"integrity": "sha512-DHINL3PAmPUiK1uszfbKiXqfE03eszdt5BpVSuEAHb5nfmNPwnsy7g39h2t8aXFc/Bv99GH81s+j8dobtD+jOw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"packages/app/node_modules/react-dom": {
"version": "19.1.4",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.4.tgz",
"integrity": "sha512-s2868ab/xo2SI6H4106A7aFI8Mrqa4xC6HZT/pBzYyQ3cBLqa88hu47xYD8xf+uECleN698Awn7RCWlkTiKnqQ==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.26.0"
},
"peerDependencies": {
"react": "^19.1.4"
}
},
"packages/app/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
@@ -36551,10 +36551,10 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.79-beta.2",
"version": "0.1.79",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/server": "0.1.79-beta.2",
"@getpaseo/server": "0.1.79",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -36909,7 +36909,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.79-beta.2",
"version": "0.1.79",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37270,7 +37270,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.79-beta.2",
"version": "0.1.79",
"license": "MIT",
"devDependencies": {
"@types/react": "^18.0.25",
@@ -37306,7 +37306,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.79-beta.2",
"version": "0.1.79",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -37644,7 +37644,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.79-beta.2",
"version": "0.1.79",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -37971,12 +37971,12 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.79-beta.2",
"version": "0.1.79",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
"@getpaseo/highlight": "0.1.79-beta.2",
"@getpaseo/relay": "0.1.79-beta.2",
"@getpaseo/highlight": "0.1.79",
"@getpaseo/relay": "0.1.79",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -37990,7 +37990,6 @@
"fast-deep-equal": "^3.1.3",
"mnemonic-id": "^3.2.7",
"node-pty": "1.2.0-beta.11",
"onnxruntime-node": "^1.23.0",
"openai": "^4.20.0",
"p-limit": "^7.3.0",
"p-memoize": "^8.0.0",
@@ -37998,8 +37997,6 @@
"pino-pretty": "^13.1.3",
"qrcode": "^1.5.4",
"rotating-file-stream": "^3.2.9",
"sherpa-onnx": "1.12.28",
"sherpa-onnx-node": "1.12.28",
"strip-ansi": "^7.1.2",
"tree-kill": "^1.2.2",
"uuid": "^9.0.1",
@@ -38017,6 +38014,9 @@
"@types/ws": "^8.5.8",
"@vitest/ui": "^4.1.6",
"cross-env": "^10.1.0",
"onnxruntime-node": "^1.23.0",
"sherpa-onnx": "1.12.28",
"sherpa-onnx-node": "1.12.28",
"tsx": "^4.6.0",
"typescript": "^5.2.2",
"vitest": "^4.1.6"
@@ -38850,7 +38850,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.79-beta.2",
"version": "0.1.79",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.79-beta.2",
"version": "0.1.79",
"private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.79-beta.2",
"version": "0.1.79",
"private": true,
"main": "index.ts",
"scripts": {
@@ -77,8 +77,8 @@
"lucide-react-native": "^0.546.0",
"mnemonic-id": "^3.2.7",
"qrcode": "^1.5.4",
"react": "19.1.4",
"react-dom": "19.1.4",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "^0.81.5",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-edge-to-edge": "^1.7.0",
@@ -8,7 +8,7 @@ import {
type PressableStateCallbackType,
} from "react-native";
import { memo, useCallback, useEffect, useMemo, useRef, type ReactNode } from "react";
import { Plus, Settings } from "lucide-react-native";
import { Home, Plus, Settings } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useCommandCenter } from "@/hooks/use-command-center";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
@@ -106,6 +106,8 @@ function CommandCenterActionRow({
actionIcon = <Plus size={16} strokeWidth={2.4} color={theme.colors.foregroundMuted} />;
} else if (action.icon === "settings") {
actionIcon = <Settings size={16} strokeWidth={2.2} color={theme.colors.foregroundMuted} />;
} else if (action.icon === "home") {
actionIcon = <Home size={16} strokeWidth={2.2} color={theme.colors.foregroundMuted} />;
}
const titleStyle = useMemo(
() => [styles.title, { color: theme.colors.foreground }],
@@ -7,7 +7,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { DaemonClient, FetchRecentProviderSessionEntry } from "@server/client/daemon-client";
import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
import { afterEach, describe, expect, it, vi } from "vitest";
import { WorkspaceImportSheet } from "@/screens/workspace/workspace-import-sheet";
import { ImportSessionSheet } from "@/components/import-session-sheet";
const { theme } = vi.hoisted(() => ({
theme: {
@@ -69,12 +69,13 @@ vi.mock("lucide-react-native", () => {
};
return {
Inbox: icon("Inbox"),
RotateCw: icon("RotateCw"),
};
});
vi.mock("@/components/ui/loading-spinner", () => ({
LoadingSpinner: () =>
React.createElement("span", { "data-testid": "workspace-import-loading-spinner" }),
React.createElement("span", { "data-testid": "import-session-loading-spinner" }),
}));
vi.mock("@/components/ui/segmented-control", () => ({
@@ -111,18 +112,19 @@ vi.mock("@/components/ui/segmented-control", () => ({
vi.mock("@/components/adaptive-modal-sheet", () => ({
AdaptiveModalSheet: ({
visible,
title,
header,
children,
testID,
}: {
visible: boolean;
title: string;
header?: { title: string; actions?: ReactNode };
children: ReactNode;
testID?: string;
}) =>
visible ? (
<section data-testid={testID}>
<h1>{title}</h1>
<h1>{header?.title}</h1>
{header?.actions}
{children}
</section>
) : null,
@@ -157,6 +159,8 @@ interface RenderOptions {
visible?: boolean;
onClose?: () => void;
onImportedAgent?: (agentId: string) => void;
onImported?: (agent: Awaited<ReturnType<DaemonClient["importAgent"]>>) => void;
cwd?: string | null;
snapshot?: {
entries?: ProviderSnapshotEntry[];
supportsSnapshot?: boolean;
@@ -179,15 +183,18 @@ function renderSheet(
},
});
const cwd = options && "cwd" in options ? (options.cwd ?? undefined) : "/repo/paseo";
return render(
<QueryClientProvider client={queryClient}>
<WorkspaceImportSheet
<ImportSessionSheet
visible={options?.visible ?? true}
client={client}
serverId="server-1"
workspaceDirectory="/repo/paseo"
cwd={cwd}
onClose={options?.onClose ?? vi.fn()}
onImportedAgent={options?.onImportedAgent ?? vi.fn()}
onImported={options?.onImported}
/>
</QueryClientProvider>,
);
@@ -265,7 +272,7 @@ function createSnapshotEntry(
};
}
describe("WorkspaceImportSheet", () => {
describe("ImportSessionSheet", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
@@ -431,11 +438,11 @@ describe("WorkspaceImportSheet", () => {
function TestSheet({ visible }: { visible: boolean }) {
return (
<QueryClientProvider client={queryClient}>
<WorkspaceImportSheet
<ImportSessionSheet
visible={visible}
client={client}
serverId="server-1"
workspaceDirectory="/repo/paseo"
cwd="/repo/paseo"
onClose={vi.fn()}
onImportedAgent={vi.fn()}
/>
@@ -483,7 +490,7 @@ describe("WorkspaceImportSheet", () => {
},
);
fireEvent.click(await screen.findByTestId("workspace-import-session-claude-provider-thread-1"));
fireEvent.click(await screen.findByTestId("import-session-session-claude-provider-thread-1"));
await waitFor(() => {
expect(importAgent).toHaveBeenCalledWith({
@@ -519,7 +526,7 @@ describe("WorkspaceImportSheet", () => {
},
);
fireEvent.click(await screen.findByTestId("workspace-import-session-claude-provider-thread-1"));
fireEvent.click(await screen.findByTestId("import-session-session-claude-provider-thread-1"));
await screen.findByText("Could not import selected session.");
expect(importAgent).toHaveBeenCalledWith({
@@ -665,12 +672,12 @@ describe("WorkspaceImportSheet", () => {
await screen.findByText("Session claude");
await screen.findByText("Session codex");
fireEvent.click(screen.getByTestId("workspace-import-filter-codex"));
fireEvent.click(screen.getByTestId("import-session-filter-codex"));
screen.getByText("Session codex");
expect(screen.queryByText("Session claude")).toBeNull();
fireEvent.click(screen.getByTestId("workspace-import-filter-all"));
fireEvent.click(screen.getByTestId("import-session-filter-all"));
screen.getByText("Session claude");
screen.getByText("Session codex");
@@ -702,8 +709,8 @@ describe("WorkspaceImportSheet", () => {
await waitFor(() => {
expect(fetchRecentProviderSessions).toHaveBeenCalled();
});
expect(screen.queryByTestId("workspace-import-filters")).toBeNull();
expect(screen.queryByTestId("workspace-import-filter-all")).toBeNull();
expect(screen.queryByTestId("import-session-filters")).toBeNull();
expect(screen.queryByTestId("import-session-filter-all")).toBeNull();
});
it("shows a no-importable-providers message when snapshot has no enabled importable providers", async () => {
@@ -731,4 +738,119 @@ describe("WorkspaceImportSheet", () => {
await screen.findByText("No importable providers are enabled.");
expect(fetchRecentProviderSessions).not.toHaveBeenCalled();
});
it("omits cwd from fetch and renders the session cwd on each row when cwd is unset", async () => {
const fetchRecentProviderSessions = vi.fn(async () => ({
requestId: "recent-provider-sessions",
entries: [
createProviderSessionEntry({
providerId: "claude",
providerLabel: "Claude Code",
cwd: "/home/me/work/other-project",
title: "Cross-project session",
}),
],
}));
const importAgent = vi.fn();
renderSheet(
{ fetchRecentProviderSessions, importAgent } as Pick<
DaemonClient,
"fetchRecentProviderSessions" | "importAgent"
>,
{
cwd: null,
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
},
);
await waitFor(() => {
expect(fetchRecentProviderSessions).toHaveBeenCalledWith({
providers: ["claude"],
limit: 15,
});
});
expect(fetchRecentProviderSessions).not.toHaveBeenCalledWith(
expect.objectContaining({ cwd: expect.anything() }),
);
await screen.findByText("/home/me/work/other-project");
});
it("uses the session's cwd when importing in cwd-less mode and fires onImported", async () => {
const fetchRecentProviderSessions = vi.fn(async () => ({
requestId: "recent-provider-sessions",
entries: [
createProviderSessionEntry({
providerId: "claude",
providerLabel: "Claude Code",
cwd: "/home/me/work/other-project",
}),
],
}));
const importAgent = vi.fn(async () => createImportedAgentSnapshot("agent-imported"));
const onImported = vi.fn();
const onImportedAgent = vi.fn();
const onClose = vi.fn();
renderSheet(
{ fetchRecentProviderSessions, importAgent } as Pick<
DaemonClient,
"fetchRecentProviderSessions" | "importAgent"
>,
{
cwd: null,
onClose,
onImported,
onImportedAgent,
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
},
);
fireEvent.click(await screen.findByTestId("import-session-session-claude-provider-thread-1"));
await waitFor(() => {
expect(importAgent).toHaveBeenCalledWith({
providerId: "claude",
providerHandleId: "provider-thread-1",
cwd: "/home/me/work/other-project",
});
});
expect(onImported).toHaveBeenCalledTimes(1);
expect(onImported).toHaveBeenCalledWith(expect.objectContaining({ id: "agent-imported" }));
expect(onImportedAgent).toHaveBeenCalledWith("agent-imported");
expect(onClose).toHaveBeenCalledTimes(1);
});
it("refetches sessions when the refresh button is clicked", async () => {
const fetchRecentProviderSessions = vi.fn(async () => ({
requestId: "recent-provider-sessions",
entries: [
createProviderSessionEntry({
providerId: "claude",
providerLabel: "Claude Code",
title: "Refreshable session",
}),
],
}));
const importAgent = vi.fn();
renderSheet(
{ fetchRecentProviderSessions, importAgent } as Pick<
DaemonClient,
"fetchRecentProviderSessions" | "importAgent"
>,
{
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
},
);
await screen.findByText("Refreshable session");
expect(fetchRecentProviderSessions).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByTestId("import-session-refresh"));
await waitFor(() => {
expect(fetchRecentProviderSessions).toHaveBeenCalledTimes(2);
});
});
});
@@ -4,7 +4,7 @@ import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
import type { DaemonClient, FetchRecentProviderSessionEntry } from "@server/client/daemon-client";
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
import { IMPORTABLE_PROVIDERS } from "@server/shared/importable-providers";
import { Inbox } from "lucide-react-native";
import { Inbox, RotateCw } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
@@ -14,7 +14,6 @@ import { formatTimeAgo } from "@/utils/time";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
const IMPORTABLE_PROVIDER_IDS: Set<string> = new Set(IMPORTABLE_PROVIDERS);
const IMPORT_SESSION_HEADER: SheetHeader = { title: "Import session" };
const PER_PROVIDER_LIMIT = 15;
const IMPORT_SHEET_SNAP_POINTS = ["70%", "92%"];
const DISABLED_ACCESSIBILITY_STATE = { disabled: true };
@@ -25,13 +24,16 @@ type RecentProviderSessionsClient = Pick<
"fetchRecentProviderSessions" | "importAgent"
>;
interface WorkspaceImportSheetProps {
type ImportedAgent = Awaited<ReturnType<RecentProviderSessionsClient["importAgent"]>>;
interface ImportSessionSheetProps {
visible: boolean;
client: RecentProviderSessionsClient | null;
serverId: string | null;
workspaceDirectory: string | null;
cwd?: string | null;
onClose: () => void;
onImportedAgent: (agentId: string) => void;
onImportedAgent?: (agentId: string) => void;
onImported?: (agent: ImportedAgent) => void;
}
type RecentSessionsResponse = Awaited<
@@ -84,20 +86,20 @@ function buildSessionsQueriesConfig(args: {
sessionsQueryRoot: ReadonlyArray<string | null>;
visible: boolean;
client: RecentProviderSessionsClient | null;
workspaceDirectory: string | null;
cwd: string | null | undefined;
}): SessionsQueryConfig[] {
const { providersToFetch, sessionsQueryRoot, visible, client, workspaceDirectory } = args;
const { providersToFetch, sessionsQueryRoot, visible, client, cwd } = args;
if (providersToFetch === null) return [];
const enabled = visible && Boolean(client && workspaceDirectory);
const enabled = visible && Boolean(client);
return providersToFetch.map((provider) => ({
queryKey: [...sessionsQueryRoot, provider],
enabled,
queryFn: async () => {
if (!client || !workspaceDirectory) {
if (!client) {
throw new Error("Host is not connected");
}
return await client.fetchRecentProviderSessions({
cwd: workspaceDirectory,
...(cwd ? { cwd } : {}),
providers: [provider],
limit: PER_PROVIDER_LIMIT,
});
@@ -186,7 +188,7 @@ function SheetStatusMessages({
}: SheetStatusMessagesProps) {
const { theme } = useUnistyles();
if (!isClientReady) {
return <Text style={styles.statusText}>Connect to a workspace to import sessions</Text>;
return <Text style={styles.statusText}>Connect to a host to import sessions</Text>;
}
if (isSnapshotUnsupported) {
return <Text style={styles.statusText}>Update the host to import sessions.</Text>;
@@ -253,10 +255,39 @@ function computeEmptyState(input: EmptyStateInputs): {
return { showEmptyState, emptyStateTitle: "No recent sessions to import." };
}
function RefreshAction({ isRefreshing, onPress }: { isRefreshing: boolean; onPress: () => void }) {
const { theme } = useUnistyles();
const pressableStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [
styles.refreshButton,
pressed && styles.refreshButtonPressed,
],
[],
);
return (
<Pressable
onPress={onPress}
disabled={isRefreshing}
accessibilityLabel="Refresh sessions"
accessibilityRole="button"
testID="import-session-refresh"
style={pressableStyle}
>
<View style={styles.refreshIconSlot}>
{isRefreshing ? (
<LoadingSpinner color={theme.colors.foregroundMuted} />
) : (
<RotateCw size={16} color={theme.colors.foregroundMuted} />
)}
</View>
</Pressable>
);
}
function SheetEmptyState({ title }: { title: string }) {
const { theme } = useUnistyles();
return (
<View style={styles.emptyState} testID="workspace-import-empty-state">
<View style={styles.emptyState} testID="import-session-empty-state">
<View style={styles.emptyStateIcon}>
<Inbox size={theme.iconSize.lg} color={theme.colors.foregroundMuted} strokeWidth={1.5} />
</View>
@@ -270,29 +301,31 @@ function buildProviderFilterOptions(
providerLabelById: ReadonlyMap<string, string>,
): SegmentedControlOption<string>[] {
const options: SegmentedControlOption<string>[] = [
{ value: ALL_FILTER_VALUE, label: "All", testID: "workspace-import-filter-all" },
{ value: ALL_FILTER_VALUE, label: "All", testID: "import-session-filter-all" },
];
for (const provider of providers) {
const ProviderIcon = getProviderIcon(provider);
options.push({
value: provider,
label: providerLabelById.get(provider) ?? provider,
testID: `workspace-import-filter-${provider}`,
testID: `import-session-filter-${provider}`,
icon: ({ color, size }) => <ProviderIcon color={color} size={size} />,
});
}
return options;
}
function WorkspaceImportSheetRow({
function ImportSessionSheetRow({
entry,
disabled,
importing,
showCwd,
onImportSession,
}: {
entry: FetchRecentProviderSessionEntry;
disabled: boolean;
importing: boolean;
showCwd: boolean;
onImportSession: (entry: FetchRecentProviderSessionEntry) => void;
}) {
const { theme } = useUnistyles();
@@ -323,7 +356,7 @@ function WorkspaceImportSheetRow({
accessibilityRole="button"
accessibilityState={accessibilityState}
style={pressableStyle}
testID={`workspace-import-session-${entry.providerId}-${entry.providerHandleId}`}
testID={`import-session-session-${entry.providerId}-${entry.providerHandleId}`}
>
<View style={styles.rowIconWrap}>
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
@@ -338,19 +371,25 @@ function WorkspaceImportSheetRow({
<Text style={styles.rowPreview} numberOfLines={2}>
{promptPreview}
</Text>
{showCwd && entry.cwd ? (
<Text style={styles.rowCwd} numberOfLines={1}>
{entry.cwd}
</Text>
) : null}
</View>
</Pressable>
);
}
export function WorkspaceImportSheet({
export function ImportSessionSheet({
visible,
client,
serverId,
workspaceDirectory,
cwd,
onClose,
onImportedAgent,
}: WorkspaceImportSheetProps) {
onImported,
}: ImportSessionSheetProps) {
const queryClient = useQueryClient();
const { entries: snapshotEntries, supportsSnapshot } = useProvidersSnapshot(serverId, {
@@ -368,8 +407,8 @@ export function WorkspaceImportSheet({
);
const sessionsQueryRoot = useMemo(
() => ["recent-provider-sessions", workspaceDirectory] as const,
[workspaceDirectory],
() => ["recent-provider-sessions", cwd ?? null] as const,
[cwd],
);
const queriesConfig = useMemo(
@@ -379,9 +418,9 @@ export function WorkspaceImportSheet({
sessionsQueryRoot,
visible,
client,
workspaceDirectory,
cwd,
}),
[providersToFetch, sessionsQueryRoot, visible, client, workspaceDirectory],
[providersToFetch, sessionsQueryRoot, visible, client, cwd],
);
const queries = useQueries({ queries: queriesConfig });
@@ -417,20 +456,25 @@ export function WorkspaceImportSheet({
const importMutation = useMutation({
mutationFn: async (entry: FetchRecentProviderSessionEntry) => {
if (!client || !workspaceDirectory) {
if (!client) {
throw new Error("Host is not connected");
}
const effectiveCwd = cwd ?? entry.cwd;
if (!effectiveCwd) {
throw new Error("Session is missing a working directory");
}
const agent = await client.importAgent({
providerId: entry.providerId,
providerHandleId: entry.providerHandleId,
cwd: workspaceDirectory,
cwd: effectiveCwd,
});
return agent;
},
onSuccess: async (agent) => {
await queryClient.invalidateQueries({ queryKey: sessionsQueryRoot });
onClose();
onImportedAgent(agent.id);
onImportedAgent?.(agent.id);
onImported?.(agent);
},
});
@@ -451,6 +495,20 @@ export function WorkspaceImportSheet({
[queries, providersToFetch, providerLabelById],
);
const isRefreshing = queries.some((query) => query.isFetching);
const handleRefresh = useCallback(() => {
void queryClient.invalidateQueries({ queryKey: sessionsQueryRoot });
}, [queryClient, sessionsQueryRoot]);
const header = useMemo<SheetHeader>(
() => ({
title: "Import session",
actions: <RefreshAction isRefreshing={isRefreshing} onPress={handleRefresh} />,
}),
[isRefreshing, handleRefresh],
);
const isSnapshotUnsupported = !supportsSnapshot;
const isWaitingForSnapshot = supportsSnapshot && snapshotEntries === undefined;
const hasNoImportableProviders = providersToFetch !== null && providersToFetch.length === 0;
@@ -478,8 +536,8 @@ export function WorkspaceImportSheet({
<AdaptiveModalSheet
visible={visible}
onClose={onClose}
header={IMPORT_SESSION_HEADER}
testID="workspace-import-sheet"
header={header}
testID="import-session-sheet"
desktopMaxWidth={560}
snapPoints={IMPORT_SHEET_SNAP_POINTS}
>
@@ -490,7 +548,7 @@ export function WorkspaceImportSheet({
contentContainerStyle={styles.filterRow}
>
<SegmentedControl
testID="workspace-import-filters"
testID="import-session-filters"
size="sm"
options={filterOptions}
value={selectedProvider}
@@ -499,7 +557,7 @@ export function WorkspaceImportSheet({
</ScrollView>
) : null}
<SheetStatusMessages
isClientReady={Boolean(client && workspaceDirectory)}
isClientReady={Boolean(client)}
isSnapshotUnsupported={isSnapshotUnsupported}
hasNoImportableProviders={hasNoImportableProviders}
isLoadingSessions={isLoadingSessions}
@@ -510,11 +568,12 @@ export function WorkspaceImportSheet({
{visibleEntries.length > 0 ? (
<View style={styles.list}>
{visibleEntries.map((entry) => (
<WorkspaceImportSheetRow
<ImportSessionSheetRow
key={`${entry.providerId}:${entry.providerHandleId}`}
entry={entry}
disabled={importMutation.isPending}
importing={importingSessionKey === `${entry.providerId}:${entry.providerHandleId}`}
showCwd={!cwd}
onImportSession={handleImportSession}
/>
))}
@@ -580,6 +639,10 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
lineHeight: 20,
},
rowCwd: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
statusRow: {
flexDirection: "row",
alignItems: "center",
@@ -606,4 +669,18 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base,
textAlign: "center",
},
refreshButton: {
padding: theme.spacing[2],
marginRight: theme.spacing[1],
borderRadius: theme.borderRadius.lg,
},
refreshButtonPressed: {
backgroundColor: theme.colors.surface2,
},
refreshIconSlot: {
width: 16,
height: 16,
alignItems: "center",
justifyContent: "center",
},
}));
+33 -15
View File
@@ -10,7 +10,7 @@ import {
clearCommandCenterFocusRestoreElement,
takeCommandCenterFocusRestoreElement,
} from "@/utils/command-center-focus-restore";
import { buildSettingsRoute } from "@/utils/host-routes";
import { buildHostOpenProjectRoute, buildSettingsRoute } from "@/utils/host-routes";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
@@ -52,10 +52,10 @@ function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
interface CommandCenterActionDefinition {
id: string;
title: string;
icon?: "plus" | "settings";
icon?: "plus" | "settings" | "home";
actionId?: string;
keywords: string[];
routeKind: "settings" | "none";
routeKind: "settings" | "home" | "none";
}
const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [
@@ -67,6 +67,13 @@ const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [
keywords: ["open", "project", "folder", "workspace", "repo"],
routeKind: "none",
},
{
id: "home",
title: "Home",
icon: "home",
keywords: ["home", "start", "import", "session", "pair", "device", "providers"],
routeKind: "home",
},
{
id: "settings",
title: "Settings",
@@ -89,7 +96,7 @@ export interface CommandCenterActionItem {
kind: "action";
id: string;
title: string;
icon?: "plus" | "settings";
icon?: "plus" | "settings" | "home";
route?: Href;
shortcutKeys?: ShortcutKey[][];
}
@@ -155,21 +162,32 @@ export function useCommandCenter() {
return buildSettingsRoute();
}, []);
const homeRoute = useMemo<Href | undefined>(() => {
if (!routeActiveServerId) return undefined;
return buildHostOpenProjectRoute(routeActiveServerId) as Href;
}, [routeActiveServerId]);
const actionItems = useMemo(() => {
if (!open) {
return EMPTY_ACTION_ITEMS;
}
return COMMAND_CENTER_ACTIONS.filter((action) =>
matchesActionQuery(query, action),
).map<CommandCenterActionItem>((action) => ({
kind: "action",
id: action.id,
title: action.title,
icon: action.icon,
route: action.routeKind === "settings" ? settingsRoute : undefined,
shortcutKeys: resolveActionShortcutKeys(action.actionId, overrides),
}));
}, [open, query, settingsRoute, overrides]);
return COMMAND_CENTER_ACTIONS.filter((action) => {
if (action.routeKind === "home" && !homeRoute) return false;
return matchesActionQuery(query, action);
}).map<CommandCenterActionItem>((action) => {
let route: Href | undefined;
if (action.routeKind === "settings") route = settingsRoute;
else if (action.routeKind === "home") route = homeRoute;
return {
kind: "action",
id: action.id,
title: action.title,
icon: action.icon,
route,
shortcutKeys: resolveActionShortcutKeys(action.actionId, overrides),
};
});
}, [open, query, settingsRoute, homeRoute, overrides]);
const items = useMemo(() => {
if (!open) {
+144 -47
View File
@@ -1,14 +1,12 @@
import { useCallback, useEffect, useState } from "react";
import { View, Text } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { FolderOpen, Smartphone } from "lucide-react-native";
import { useCallback, useEffect, useState, type ComponentType } from "react";
import { View, Text, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useRouter } from "expo-router";
import { FolderOpen, Inbox, Plug, Smartphone } from "lucide-react-native";
import { PaseoLogo } from "@/components/icons/paseo-logo";
import { Button } from "@/components/ui/button";
import { MenuHeader } from "@/components/headers/menu-header";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
import { useHasWorkspaces } from "@/stores/session-store-hooks";
import {
useIsCompactFormFactor,
HEADER_INNER_HEIGHT,
@@ -18,14 +16,21 @@ import {
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { PairDeviceModal } from "@/desktop/components/pair-device-modal";
import { buildHostAgentDetailRoute, buildSettingsHostRoute } from "@/utils/host-routes";
import { ImportSessionSheet } from "@/components/import-session-sheet";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { useOpenProject } from "@/hooks/use-open-project";
import type { Href } from "expo-router";
export function OpenProjectScreen({ serverId }: { serverId: string }) {
const router = useRouter();
const openDesktopAgentList = usePanelStore((s) => s.openDesktopAgentList);
const openProjectPicker = useOpenProjectPicker(serverId);
const hasHydrated = useSessionStore((s) => s.sessions[serverId]?.hasHydratedWorkspaces ?? false);
const hasProjects = useHasWorkspaces(serverId);
const isLocalDaemon = useIsLocalDaemon(serverId);
const client = useHostRuntimeClient(serverId);
const openProject = useOpenProject(serverId);
const [isPairDeviceOpen, setIsPairDeviceOpen] = useState(false);
const [isImportSheetOpen, setIsImportSheetOpen] = useState(false);
const isCompactLayout = useIsCompactFormFactor();
@@ -42,40 +47,62 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
const handleOpenPairDevice = useCallback(() => setIsPairDeviceOpen(true), []);
const handleClosePairDevice = useCallback(() => setIsPairDeviceOpen(false), []);
const handleOpenImportSession = useCallback(() => setIsImportSheetOpen(true), []);
const handleCloseImportSession = useCallback(() => setIsImportSheetOpen(false), []);
const handleImported = useCallback(
(agent: { id: string; cwd: string }) => {
void (async () => {
await openProject(agent.cwd);
router.push(buildHostAgentDetailRoute(serverId, agent.id) as Href);
})();
},
[openProject, router, serverId],
);
const handleOpenProviders = useCallback(() => {
router.push(buildSettingsHostRoute(serverId));
}, [router, serverId]);
return (
<View style={styles.container}>
<MenuHeader borderless />
<View style={styles.content}>
<TitlebarDragRegion />
<View style={styles.logo}>
<PaseoLogo size={56} />
<PaseoLogo size={52} />
</View>
<View style={styles.headingGroup}>
<Text style={styles.heading}>What shall we build today?</Text>
{hasHydrated && !hasProjects ? (
<Text style={styles.subtitle}>
Add a project folder to start running agents on your codebase
</Text>
) : null}
</View>
<View style={styles.cta}>
<Button
variant="default"
leftIcon={FolderOpen}
<View style={styles.tiles}>
<HomeTile
icon={FolderOpen}
title="Add a project"
description="Open a folder on your machine"
onPress={handleOpenPicker}
testID="open-project-submit"
>
Add a project
</Button>
accent
/>
<HomeTile
icon={Inbox}
title="Import session"
description="Bring in recent external CLI sessions"
onPress={handleOpenImportSession}
testID="open-project-import-session"
/>
<HomeTile
icon={Plug}
title="Setup providers"
description="Configure Claude Code, Codex, and more"
onPress={handleOpenProviders}
testID="open-project-setup-providers"
/>
{isLocalDaemon ? (
<Button
variant="outline"
leftIcon={Smartphone}
<HomeTile
icon={Smartphone}
title="Pair device"
description="Connect your phone to this daemon"
onPress={handleOpenPairDevice}
testID="open-project-pair-device"
>
Pair device
</Button>
/>
) : null}
</View>
</View>
@@ -84,10 +111,61 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
onClose={handleClosePairDevice}
testID="open-project-pair-device-modal"
/>
<ImportSessionSheet
visible={isImportSheetOpen}
client={client}
serverId={serverId}
onClose={handleCloseImportSession}
onImported={handleImported}
/>
</View>
);
}
interface HomeTileProps {
icon: ComponentType<{ size: number; color: string }>;
title: string;
description: string;
onPress: () => void;
testID?: string;
accent?: boolean;
}
function HomeTile({ icon: Icon, title, description, onPress, testID, accent }: HomeTileProps) {
// useUnistyles is acceptable here: leaf component, off the hot path (home screen renders once).
const { theme } = useUnistyles();
const [hovered, setHovered] = useState(false);
const handleHoverIn = useCallback(() => setHovered(true), []);
const handleHoverOut = useCallback(() => setHovered(false), []);
const iconColor = accent ? theme.colors.accent : theme.colors.foregroundMuted;
const pressableStyle = useCallback(
({ pressed }: { pressed: boolean }) => [
styles.tile,
hovered && styles.tileHovered,
pressed && styles.tilePressed,
],
[hovered],
);
return (
<Pressable
onPress={onPress}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
testID={testID}
style={pressableStyle}
>
<Icon size={20} color={iconColor} />
<View style={styles.tileText}>
<Text style={styles.tileTitle}>{title}</Text>
<Text style={styles.tileDescription}>{description}</Text>
</View>
</Pressable>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
@@ -97,10 +175,11 @@ const styles = StyleSheet.create((theme) => ({
content: {
position: "relative",
flex: 1,
justifyContent: "center",
justifyContent: { xs: "flex-start", md: "center" },
alignItems: "center",
gap: 0,
padding: theme.spacing[6],
paddingTop: { xs: theme.spacing[12], md: theme.spacing[6] },
paddingBottom: {
xs: HEADER_INNER_HEIGHT_MOBILE + HEADER_TOP_PADDING_MOBILE + theme.spacing[6],
md: HEADER_INNER_HEIGHT + theme.spacing[6],
@@ -109,25 +188,43 @@ const styles = StyleSheet.create((theme) => ({
logo: {
marginBottom: theme.spacing[8],
},
headingGroup: {
alignItems: "center",
tiles: {
marginTop: { xs: theme.spacing[6], md: theme.spacing[12] },
width: "100%",
maxWidth: 452,
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "flex-start",
gap: theme.spacing[3],
},
cta: {
marginTop: theme.spacing[12],
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
tile: {
width: { xs: "100%", md: 220 },
minHeight: { xs: 0, md: 132 },
padding: theme.spacing[4],
backgroundColor: theme.colors.surface1,
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.xl,
gap: theme.spacing[3],
},
heading: {
tileHovered: {
backgroundColor: theme.colors.surface2,
borderColor: theme.colors.borderAccent,
},
tilePressed: {
opacity: 0.85,
},
tileText: {
gap: theme.spacing[1],
},
tileTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize["2xl"],
fontWeight: theme.fontWeight.normal,
textAlign: "center",
},
subtitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
textAlign: "center",
fontWeight: theme.fontWeight.normal,
},
tileDescription: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
lineHeight: 18,
},
}));
@@ -59,7 +59,7 @@ import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-
import { WorkspaceGitActions } from "@/git/workspace-actions";
import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button";
import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button";
import { WorkspaceImportSheet } from "@/screens/workspace/workspace-import-sheet";
import { ImportSessionSheet } from "@/components/import-session-sheet";
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
import { useToast } from "@/contexts/toast-context";
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
@@ -3358,11 +3358,11 @@ function WorkspaceScreenContent({
/>
) : null}
</View>
<WorkspaceImportSheet
<ImportSessionSheet
visible={isImportSheetVisible}
client={client}
serverId={normalizedServerId}
workspaceDirectory={workspaceDirectory}
cwd={workspaceDirectory}
onClose={closeImportSheet}
onImportedAgent={handleImportedAgent}
/>
@@ -17,6 +17,9 @@ export const PROVIDER_COMMAND_TEMPLATES: Record<
claude: {
resume: "claude --resume {sessionId}",
},
pi: {
resume: "pi --session {sessionId}",
},
};
function renderTemplate(template: string, vars: Record<string, string>): string {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.79-beta.2",
"version": "0.1.79",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -24,7 +24,7 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/server": "0.1.79-beta.2",
"@getpaseo/server": "0.1.79",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.79-beta.2",
"version": "0.1.79",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.79-beta.2",
"version": "0.1.79",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.79-beta.2",
"version": "0.1.79",
"files": [
"dist"
],
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.79-beta.2",
"version": "0.1.79",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist"
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.79-beta.2",
"version": "0.1.79",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -63,8 +63,8 @@
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
"@getpaseo/highlight": "0.1.79-beta.2",
"@getpaseo/relay": "0.1.79-beta.2",
"@getpaseo/highlight": "0.1.79",
"@getpaseo/relay": "0.1.79",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -78,7 +78,6 @@
"fast-deep-equal": "^3.1.3",
"mnemonic-id": "^3.2.7",
"node-pty": "1.2.0-beta.11",
"onnxruntime-node": "^1.23.0",
"openai": "^4.20.0",
"p-limit": "^7.3.0",
"p-memoize": "^8.0.0",
@@ -86,8 +85,6 @@
"pino-pretty": "^13.1.3",
"qrcode": "^1.5.4",
"rotating-file-stream": "^3.2.9",
"sherpa-onnx": "1.12.28",
"sherpa-onnx-node": "1.12.28",
"strip-ansi": "^7.1.2",
"tree-kill": "^1.2.2",
"uuid": "^9.0.1",
@@ -105,6 +102,9 @@
"@types/ws": "^8.5.8",
"@vitest/ui": "^4.1.6",
"cross-env": "^10.1.0",
"onnxruntime-node": "^1.23.0",
"sherpa-onnx": "1.12.28",
"sherpa-onnx-node": "1.12.28",
"tsx": "^4.6.0",
"typescript": "^5.2.2",
"vitest": "^4.1.6"
+1
View File
@@ -841,6 +841,7 @@ export async function createPaseoDaemon(
const speechService = createSpeechService({
logger,
paseoHome: config.paseoHome,
openaiConfig: config.openai,
speechConfig: config.speech,
});
+17 -1
View File
@@ -2658,11 +2658,12 @@ export class Session {
private toVoiceFeatureUnavailableContext(
state: SpeechReadinessState,
): VoiceFeatureUnavailableContext {
const missingModelIds = state.missingAssets.find((asset) => asset.kind === "models")?.ids ?? [];
return {
reasonCode: state.reasonCode,
message: state.message,
retryable: state.retryable,
missingModelIds: [...state.missingModelIds],
missingModelIds: missingModelIds as LocalSpeechModelId[],
};
}
@@ -3389,6 +3390,7 @@ export class Session {
paseoHome: this.paseoHome,
logger: this.sessionLogger,
});
await this.registerWorkspaceForImportedAgent(snapshot.cwd);
await this.forwardAgentUpdate(snapshot);
const agentPayload = await this.buildAgentPayload(snapshot);
this.emit({
@@ -7143,6 +7145,20 @@ export class Session {
}
}
private async registerWorkspaceForImportedAgent(cwd: string): Promise<void> {
try {
const workspace = await this.findOrCreateWorkspaceForDirectory(cwd);
await this.syncWorkspaceGitObserverForWorkspace(workspace);
await this.describeWorkspaceRecord(workspace);
await this.emitWorkspaceUpdateForCwd(workspace.cwd);
} catch (error) {
this.sessionLogger.warn(
{ err: error, cwd },
"Failed to register workspace for imported agent",
);
}
}
private async handleOpenProjectRequest(
request: Extract<SessionInboundMessage, { type: "open_project_request" }>,
): Promise<void> {
@@ -66,6 +66,20 @@ interface SessionTestAccess {
agentManager: {
listAgents(): unknown[];
listImportablePersistedAgents(options?: unknown): Promise<PersistedAgentDescriptor[]>;
findPersistedAgent(
provider: string,
providerHandleId: string,
options?: unknown,
): Promise<PersistedAgentDescriptor | null>;
resumeAgentFromPersistence(
handle: unknown,
overrides?: unknown,
preferredId?: string,
extras?: unknown,
): Promise<unknown>;
hydrateTimelineFromProvider(agentId: string): Promise<unknown>;
getTimeline(agentId: string): readonly unknown[];
setTitle(agentId: string, title: string): Promise<unknown>;
};
workspaceRegistry: {
list(...args: unknown[]): Promise<unknown[]>;
@@ -2660,6 +2674,105 @@ test("open_project_request registers a workspace before any agent exists", async
expect(response?.payload.workspace?.id).toBe(REPO_CWD);
});
test("import_agent_request registers a workspace for a never-seen cwd", async () => {
const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests({
onMessage: (message) => {
if (isSessionOutboundMessage(message)) emitted.push(message);
},
});
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
const importedCwd = path.resolve("/tmp/imported-project");
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
session.projectRegistry.upsert = async (
record: ReturnType<typeof createPersistedProjectRecord>,
) => {
projects.set(record.projectId, record);
};
session.workspaceRegistry.get = async (workspaceId: string) =>
workspaces.get(workspaceId) ?? null;
session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>,
) => {
workspaces.set(record.workspaceId, record);
};
session.projectRegistry.list = async () => Array.from(projects.values());
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
session.buildProjectPlacement = async (cwd: string) => ({
projectKey: cwd,
projectName: "imported",
checkout: {
cwd,
isGit: false,
currentBranch: null,
remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
});
const managed = makeManagedAgent({
id: "imported-agent",
cwd: importedCwd,
lifecycle: "idle",
updatedAt: "2026-05-21T00:00:00.000Z",
});
session.agentManager.listAgents = () => [managed];
session.agentManager.findPersistedAgent = async () => null;
session.agentManager.resumeAgentFromPersistence = async () => managed;
session.agentManager.hydrateTimelineFromProvider = async () => undefined;
session.agentManager.getTimeline = () => [];
session.agentManager.setTitle = async () => undefined;
session.agentStorage.list = async () => [];
session.agentStorage.get = async () => null;
session.forwardAgentUpdate = async () => undefined;
session.workspaceUpdatesSubscription = {
subscriptionId: "sub-import",
filter: undefined,
isBootstrapping: false,
pendingUpdatesByWorkspaceId: new Map(),
lastEmittedByWorkspaceId: new Map(),
};
session.buildWorkspaceDescriptorMap = async () =>
new Map([
[
importedCwd,
{
id: importedCwd,
projectId: importedCwd,
projectDisplayName: "imported-project",
projectRootPath: importedCwd,
projectKind: "non_git",
workspaceKind: "directory",
name: "imported-project",
status: "done",
activityAt: null,
},
],
]);
await session.handleMessage({
type: "import_agent_request",
requestId: "req-import",
providerId: "codex",
providerHandleId: "session-xyz",
cwd: importedCwd,
});
expect(workspaces.get(importedCwd)).toBeTruthy();
const workspaceUpdates = filterByType(emitted, "workspace_update");
expect(workspaceUpdates.length).toBeGreaterThan(0);
expect(
workspaceUpdates.some(
(update) => update.payload.kind === "upsert" && update.payload.workspace.id === importedCwd,
),
).toBe(true);
});
test("open_project_response returns immediately even when the GitHub fetch is slow", async () => {
const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests();
@@ -11,6 +11,7 @@ import {
pcm16leToFloat32,
} from "../../../audio.js";
import { Pcm16MonoResampler } from "../../../../agent/pcm16-resampler.js";
import { loadLocalSpeechPackage } from "../runtime/index.js";
type OrtModule = typeof import("onnxruntime-node");
type OrtSession = import("onnxruntime-node").InferenceSession;
@@ -98,7 +99,9 @@ function normalizeTextForPocket(text: string): string {
}
async function loadOrt(): Promise<OrtModule> {
return import("onnxruntime-node");
return loadLocalSpeechPackage<OrtModule>("onnxruntime-node", {
requireFrom: import.meta.url,
});
}
interface SentencePieceModule {
@@ -0,0 +1,20 @@
import type { LocalSpeechRuntimePackageId } from "./package-specs.js";
export interface LocalSpeechRuntimeStatus {
runtimeDir: string;
missingPackageIds: LocalSpeechRuntimePackageId[];
}
export class LocalSpeechRuntimeUnavailableError extends Error {
public readonly code = "local_speech_runtime_not_installed";
public readonly runtimeDir: string;
public readonly missingPackageIds: LocalSpeechRuntimePackageId[];
constructor(status: LocalSpeechRuntimeStatus) {
const missing = status.missingPackageIds.join(", ");
super(`Local voice runtime is downloading / not installed (${missing}).`);
this.name = "LocalSpeechRuntimeUnavailableError";
this.runtimeDir = status.runtimeDir;
this.missingPackageIds = [...status.missingPackageIds];
}
}
@@ -0,0 +1,10 @@
export { LocalSpeechRuntimeUnavailableError } from "./errors.js";
export type { LocalSpeechRuntimeStatus } from "./errors.js";
export { ensureLocalSpeechRuntime } from "./install.js";
export {
clearLocalSpeechRuntimeHome,
configureLocalSpeechRuntimeHome,
loadLocalSpeechPackage,
} from "./load-package.js";
export { type LocalSpeechRuntimePackageId } from "./package-specs.js";
export { getLocalSpeechRuntimeStatus } from "./status.js";
@@ -0,0 +1,58 @@
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { afterEach, describe, expect, test, vi } from "vitest";
import { ensureLocalSpeechRuntime } from "./index.js";
import { getLocalSpeechRuntimePackageSpecs } from "./package-specs.js";
import { getLocalSpeechRuntimePackageDir } from "./paths.js";
const logger = pino({ level: "silent" });
function makePaseoHome(): string {
return mkdtempSync(path.join(tmpdir(), "paseo-speech-runtime-"));
}
function writeRuntimePackageFiles(paseoHome: string): void {
for (const spec of getLocalSpeechRuntimePackageSpecs()) {
const packageDir = getLocalSpeechRuntimePackageDir(paseoHome, spec.packageName);
for (const relPath of spec.requiredFiles) {
const absPath = path.join(packageDir, relPath);
mkdirSync(path.dirname(absPath), { recursive: true });
writeFileSync(absPath, "x");
}
if (spec.id === "onnxruntime-node") {
const bindingPath = path.join(
packageDir,
"bin",
"napi-test",
process.platform,
process.arch,
"onnxruntime_binding.node",
);
mkdirSync(path.dirname(bindingPath), { recursive: true });
writeFileSync(bindingPath, "x");
}
}
}
describe("local speech runtime installer", () => {
afterEach(() => {
vi.restoreAllMocks();
});
test("ensureLocalSpeechRuntime succeeds without downloading when packages are installed", async () => {
const paseoHome = makePaseoHome();
writeRuntimePackageFiles(paseoHome);
const fetchMock = vi.spyOn(globalThis, "fetch");
const status = await ensureLocalSpeechRuntime({
paseoHome,
logger,
});
expect(status.missingPackageIds).toEqual([]);
expect(fetchMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,128 @@
import { mkdir, rename, rm } from "node:fs/promises";
import path from "node:path";
import type pino from "pino";
import {
downloadToFile,
extractTarArchive,
hasRequiredFiles,
isNonEmptyFile,
} from "../sherpa/model-downloader.js";
import { LocalSpeechRuntimeUnavailableError, type LocalSpeechRuntimeStatus } from "./errors.js";
import {
getLocalSpeechRuntimePackageSpecs,
type LocalSpeechRuntimePackageSpec,
} from "./package-specs.js";
import {
getLocalSpeechRuntimeDir,
getLocalSpeechRuntimePackageDirFromRuntimeDir,
} from "./paths.js";
import { getLocalSpeechRuntimeStatus } from "./status.js";
function npmTarballUrl(packageName: string, version: string): string {
const escapedName = packageName.startsWith("@") ? packageName.replace("/", "%2f") : packageName;
const basename = packageName.startsWith("@") ? packageName.split("/")[1] : packageName;
return `https://registry.npmjs.org/${escapedName}/-/${basename}-${version}.tgz`;
}
async function installPackageFromTarball(params: {
runtimeDir: string;
spec: LocalSpeechRuntimePackageSpec;
}): Promise<void> {
const { runtimeDir, spec } = params;
const packageDir = getLocalSpeechRuntimePackageDirFromRuntimeDir(runtimeDir, spec.packageName);
if (
(await hasRequiredFiles(packageDir, spec.requiredFiles)) &&
(!spec.validate || spec.validate(packageDir))
) {
return;
}
const downloadsDir = path.join(runtimeDir, ".downloads");
const archivePath = path.join(
downloadsDir,
`${spec.packageName.replace("/", "__")}-${spec.version}.tgz`,
);
if (!(await isNonEmptyFile(archivePath))) {
await downloadToFile({
url: npmTarballUrl(spec.packageName, spec.version),
outputPath: archivePath,
});
}
const extractDir = path.join(
runtimeDir,
".extract",
`${spec.packageName.replace("/", "__")}-${Date.now()}`,
);
const extractedPackageDir = path.join(extractDir, "package");
const nextPackageDir = `${packageDir}.tmp-${Date.now()}`;
await rm(extractDir, { recursive: true, force: true }).catch(() => undefined);
await mkdir(path.dirname(packageDir), { recursive: true });
await extractTarArchive(archivePath, extractDir);
await rm(nextPackageDir, { recursive: true, force: true }).catch(() => undefined);
await rename(extractedPackageDir, nextPackageDir);
if (
!(await hasRequiredFiles(nextPackageDir, spec.requiredFiles)) ||
(spec.validate && !spec.validate(nextPackageDir))
) {
await rm(nextPackageDir, { recursive: true, force: true }).catch(() => undefined);
throw new Error(
`Downloaded ${spec.packageName}@${spec.version}, but required runtime files are missing.`,
);
}
await rm(packageDir, { recursive: true, force: true }).catch(() => undefined);
await rename(nextPackageDir, packageDir);
await rm(extractDir, { recursive: true, force: true }).catch(() => undefined);
await rm(archivePath, { force: true }).catch(() => undefined);
}
export async function ensureLocalSpeechRuntime(options: {
paseoHome: string;
logger: pino.Logger;
platform?: NodeJS.Platform;
arch?: string;
}): Promise<LocalSpeechRuntimeStatus> {
const logger = options.logger.child({
module: "speech",
provider: "local",
component: "runtime-installer",
});
const runtimeDir = getLocalSpeechRuntimeDir(options.paseoHome);
const specs = getLocalSpeechRuntimePackageSpecs(options.platform, options.arch);
const before = await getLocalSpeechRuntimeStatus({
paseoHome: options.paseoHome,
platform: options.platform,
arch: options.arch,
});
if (before.missingPackageIds.length === 0) {
return before;
}
logger.info(
{
runtimeDir,
missingPackageIds: before.missingPackageIds,
},
"Starting local speech runtime install",
);
for (const spec of specs) {
await installPackageFromTarball({ runtimeDir, spec });
}
const after = await getLocalSpeechRuntimeStatus({
paseoHome: options.paseoHome,
platform: options.platform,
arch: options.arch,
});
if (after.missingPackageIds.length > 0) {
throw new LocalSpeechRuntimeUnavailableError(after);
}
logger.info({ runtimeDir }, "Local speech runtime install completed");
return after;
}
@@ -0,0 +1,74 @@
import { existsSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { LocalSpeechRuntimeUnavailableError } from "./errors.js";
import { getLocalSpeechRuntimePackageDir } from "./paths.js";
import { getLocalSpeechRuntimeStatusSync } from "./status.js";
import {
prependEnvPath,
sherpaLoaderEnvKey,
sherpaPlatformPackageName,
} from "../sherpa/sherpa-runtime-env.js";
let configuredPaseoHome: string | null = null;
export function configureLocalSpeechRuntimeHome(paseoHome: string): void {
configuredPaseoHome = paseoHome;
}
export function clearLocalSpeechRuntimeHome(): void {
configuredPaseoHome = null;
}
function findEnvKey(env: NodeJS.ProcessEnv, key: string): string {
const lower = key.toLowerCase();
for (const k of Object.keys(env)) {
if (k.toLowerCase() === lower) return k;
}
return key;
}
function applyConfiguredSherpaLoaderEnv(): void {
if (!configuredPaseoHome) {
return;
}
const envKey = sherpaLoaderEnvKey();
if (!envKey) {
return;
}
const platformPackageDir = getLocalSpeechRuntimePackageDir(
configuredPaseoHome,
sherpaPlatformPackageName(),
);
const actualKey = findEnvKey(process.env, envKey);
process.env[actualKey] = prependEnvPath(process.env[actualKey], platformPackageDir);
}
export function loadLocalSpeechPackage<T>(
packageName: string,
options?: { requireFrom?: string },
): T {
const devRequire = createRequire(options?.requireFrom ?? import.meta.url);
if (!configuredPaseoHome) {
return devRequire(packageName) as T;
}
if (packageName === "sherpa-onnx-node") {
applyConfiguredSherpaLoaderEnv();
}
const status = getLocalSpeechRuntimeStatusSync({ paseoHome: configuredPaseoHome });
if (status.missingPackageIds.length > 0) {
throw new LocalSpeechRuntimeUnavailableError(status);
}
const runtimePackageDir = getLocalSpeechRuntimePackageDir(configuredPaseoHome, packageName);
const runtimePackageJson = path.join(runtimePackageDir, "package.json");
if (existsSync(runtimePackageJson)) {
const runtimeRequire = createRequire(runtimePackageJson);
return runtimeRequire(runtimePackageDir) as T;
}
throw new LocalSpeechRuntimeUnavailableError(status);
}
@@ -0,0 +1,85 @@
import { readdirSync } from "node:fs";
import path from "node:path";
import { sherpaPlatformPackageName } from "../sherpa/sherpa-runtime-env.js";
const SHERPA_ONNX_VERSION = "1.12.28";
const ONNXRUNTIME_VERSION = "1.24.3";
export type LocalSpeechRuntimePackageId =
| "sherpa-onnx"
| "sherpa-onnx-node"
| "sherpa-platform"
| "onnxruntime-common"
| "onnxruntime-node";
export interface LocalSpeechRuntimePackageSpec {
id: LocalSpeechRuntimePackageId;
packageName: string;
version: string;
requiredFiles: string[];
validate?: (packageDir: string) => boolean;
}
function hasOnnxruntimeNativeBinding(
packageDir: string,
platform: NodeJS.Platform,
arch: string,
): boolean {
try {
const binDir = path.join(packageDir, "bin");
const napiDirs = readdirSync(binDir).filter((entry) => entry.startsWith("napi-"));
return napiDirs.some((napiDir) => {
const archDir = path.join(binDir, napiDir, platform, arch);
try {
const files = readdirSync(archDir);
return files.some((file) => file.endsWith(".node"));
} catch {
return false;
}
});
} catch {
return false;
}
}
export function getLocalSpeechRuntimePackageSpecs(
platform: NodeJS.Platform = process.platform,
arch: string = process.arch,
): LocalSpeechRuntimePackageSpec[] {
const sherpaPlatformPackage = sherpaPlatformPackageName(platform, arch);
return [
{
id: "sherpa-onnx",
packageName: "sherpa-onnx",
version: SHERPA_ONNX_VERSION,
requiredFiles: ["package.json", "index.js", "sherpa-onnx-wasm-nodejs.wasm"],
},
{
id: "sherpa-onnx-node",
packageName: "sherpa-onnx-node",
version: SHERPA_ONNX_VERSION,
requiredFiles: ["package.json", "sherpa-onnx.js", "addon.js"],
},
{
id: "sherpa-platform",
packageName: sherpaPlatformPackage,
version: SHERPA_ONNX_VERSION,
requiredFiles: ["package.json", "sherpa-onnx.node"],
},
{
id: "onnxruntime-common",
packageName: "onnxruntime-common",
version: ONNXRUNTIME_VERSION,
requiredFiles: ["package.json", "dist/cjs/index.js"],
},
{
id: "onnxruntime-node",
packageName: "onnxruntime-node",
version: ONNXRUNTIME_VERSION,
requiredFiles: ["package.json", "dist/index.js"],
// onnxruntime-node encodes the N-API version in the native binding path.
validate: (packageDir) => hasOnnxruntimeNativeBinding(packageDir, platform, arch),
},
];
}
@@ -0,0 +1,22 @@
import path from "node:path";
const NODE_MODULES_DIR = "node_modules";
export function getLocalSpeechRuntimeDir(paseoHome: string): string {
return path.join(paseoHome, "runtime", "local-speech");
}
export function getLocalSpeechRuntimeNodeModulesDir(paseoHome: string): string {
return path.join(getLocalSpeechRuntimeDir(paseoHome), NODE_MODULES_DIR);
}
export function getLocalSpeechRuntimePackageDir(paseoHome: string, packageName: string): string {
return path.join(getLocalSpeechRuntimeNodeModulesDir(paseoHome), packageName);
}
export function getLocalSpeechRuntimePackageDirFromRuntimeDir(
runtimeDir: string,
packageName: string,
): string {
return path.join(runtimeDir, NODE_MODULES_DIR, packageName);
}
@@ -0,0 +1,65 @@
import { statSync } from "node:fs";
import { hasRequiredFiles } from "../sherpa/model-downloader.js";
import type { LocalSpeechRuntimeStatus } from "./errors.js";
import { getLocalSpeechRuntimePackageSpecs } from "./package-specs.js";
import {
getLocalSpeechRuntimeDir,
getLocalSpeechRuntimePackageDirFromRuntimeDir,
} from "./paths.js";
function hasRequiredFileSync(filePath: string): boolean {
try {
const s = statSync(filePath);
if (s.isDirectory()) {
return true;
}
return s.isFile() && s.size > 0;
} catch {
return false;
}
}
function hasRequiredFilesSync(packageDir: string, requiredFiles: string[]): boolean {
return requiredFiles.every((relPath) => hasRequiredFileSync(`${packageDir}/${relPath}`));
}
export function getLocalSpeechRuntimeStatusSync(params: {
paseoHome: string;
platform?: NodeJS.Platform;
arch?: string;
}): LocalSpeechRuntimeStatus {
const runtimeDir = getLocalSpeechRuntimeDir(params.paseoHome);
const missingPackageIds: LocalSpeechRuntimeStatus["missingPackageIds"] = [];
for (const spec of getLocalSpeechRuntimePackageSpecs(params.platform, params.arch)) {
const packageDir = getLocalSpeechRuntimePackageDirFromRuntimeDir(runtimeDir, spec.packageName);
const requiredFilesPresent = hasRequiredFilesSync(packageDir, spec.requiredFiles);
if (!requiredFilesPresent || (spec.validate && !spec.validate(packageDir))) {
missingPackageIds.push(spec.id);
}
}
return {
runtimeDir,
missingPackageIds,
};
}
export async function getLocalSpeechRuntimeStatus(params: {
paseoHome: string;
platform?: NodeJS.Platform;
arch?: string;
}): Promise<LocalSpeechRuntimeStatus> {
const runtimeDir = getLocalSpeechRuntimeDir(params.paseoHome);
const missingPackageIds: LocalSpeechRuntimeStatus["missingPackageIds"] = [];
for (const spec of getLocalSpeechRuntimePackageSpecs(params.platform, params.arch)) {
const packageDir = getLocalSpeechRuntimePackageDirFromRuntimeDir(runtimeDir, spec.packageName);
const requiredFilesPresent = await hasRequiredFiles(packageDir, spec.requiredFiles);
if (!requiredFilesPresent || (spec.validate && !spec.validate(packageDir))) {
missingPackageIds.push(spec.id);
}
}
return {
runtimeDir,
missingPackageIds,
};
}
@@ -19,7 +19,10 @@ export function getSherpaOnnxModelDir(modelsDir: string, modelId: SherpaOnnxMode
return path.join(modelsDir, spec.extractedDir);
}
async function hasRequiredFiles(modelDir: string, requiredFiles: string[]): Promise<boolean> {
export async function hasRequiredFiles(
modelDir: string,
requiredFiles: string[],
): Promise<boolean> {
const results = await Promise.all(
requiredFiles.map(async (rel) => {
const abs = path.join(modelDir, rel);
@@ -42,7 +45,7 @@ interface DownloadToFileOptions {
outputPath: string;
}
async function downloadToFile(options: DownloadToFileOptions): Promise<void> {
export async function downloadToFile(options: DownloadToFileOptions): Promise<void> {
const { url, outputPath } = options;
const res = await fetch(url);
if (!res.ok) {
@@ -68,7 +71,7 @@ async function downloadToFile(options: DownloadToFileOptions): Promise<void> {
}
}
async function extractTarArchive(archivePath: string, destDir: string): Promise<void> {
export async function extractTarArchive(archivePath: string, destDir: string): Promise<void> {
await mkdir(destDir, { recursive: true });
await new Promise<void>((resolve, reject) => {
@@ -83,7 +86,7 @@ async function extractTarArchive(archivePath: string, destDir: string): Promise<
});
}
async function isNonEmptyFile(filePath: string): Promise<boolean> {
export async function isNonEmptyFile(filePath: string): Promise<boolean> {
try {
const s = await stat(filePath);
return s.isFile() && s.size > 0;
@@ -1,4 +1,4 @@
import { createRequire } from "node:module";
import { loadLocalSpeechPackage } from "../runtime/index.js";
export interface SherpaOnnxModule {
createOnlineRecognizer: (config: unknown) => unknown;
@@ -13,7 +13,8 @@ export function loadSherpaOnnx(): SherpaOnnxModule {
return cached;
}
const require = createRequire(import.meta.url);
cached = require("sherpa-onnx") as SherpaOnnxModule;
cached = loadLocalSpeechPackage<SherpaOnnxModule>("sherpa-onnx", {
requireFrom: import.meta.url,
});
return cached;
}
@@ -0,0 +1,103 @@
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test, vi } from "vitest";
import { sherpaPlatformPackageName } from "./sherpa-runtime-env.js";
function makeHomeDir(): string {
return mkdtempSync(path.join(tmpdir(), "paseo-sherpa-loader-"));
}
function runtimePackageDir(homeDir: string, packageName: string): string {
return path.join(homeDir, "runtime", "local-speech", "node_modules", packageName);
}
function writeFileEnsuringDir(filePath: string, contents: string): void {
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, contents);
}
function writeFakeSherpaRuntime(homeDir: string): void {
const sherpaWasmDir = runtimePackageDir(homeDir, "sherpa-onnx");
const moduleDir = runtimePackageDir(homeDir, "sherpa-onnx-node");
const platformDir = runtimePackageDir(homeDir, sherpaPlatformPackageName());
const onnxCommonDir = runtimePackageDir(homeDir, "onnxruntime-common");
const onnxNodeDir = runtimePackageDir(homeDir, "onnxruntime-node");
writeFileEnsuringDir(path.join(sherpaWasmDir, "package.json"), "{}");
writeFileEnsuringDir(path.join(sherpaWasmDir, "index.js"), "module.exports = {};");
writeFileEnsuringDir(path.join(sherpaWasmDir, "sherpa-onnx-wasm-nodejs.wasm"), "x");
writeFileEnsuringDir(
path.join(moduleDir, "package.json"),
JSON.stringify({ main: "sherpa-onnx.js" }),
);
writeFileEnsuringDir(
path.join(moduleDir, "sherpa-onnx.js"),
"module.exports = { OfflineRecognizer: function FakeRecognizer() {} };",
);
writeFileEnsuringDir(path.join(moduleDir, "addon.js"), "module.exports = {};");
writeFileEnsuringDir(path.join(platformDir, "package.json"), "{}");
writeFileEnsuringDir(path.join(platformDir, "sherpa-onnx.node"), "x");
writeFileEnsuringDir(path.join(onnxCommonDir, "package.json"), "{}");
writeFileEnsuringDir(path.join(onnxCommonDir, "dist", "cjs", "index.js"), "module.exports = {};");
writeFileEnsuringDir(path.join(onnxNodeDir, "package.json"), "{}");
writeFileEnsuringDir(path.join(onnxNodeDir, "dist", "index.js"), "module.exports = {};");
writeFileEnsuringDir(
path.join(
onnxNodeDir,
"bin",
"napi-test",
process.platform,
process.arch,
"onnxruntime_binding.node",
),
"x",
);
}
describe("sherpa-onnx-node loader", () => {
afterEach(() => {
vi.resetModules();
});
test("loads sherpa-onnx-node from the PASEO_HOME runtime directory", async () => {
const homeDir = makeHomeDir();
writeFakeSherpaRuntime(homeDir);
const { configureLocalSpeechRuntimeHome, clearLocalSpeechRuntimeHome } =
await import("../runtime/index.js");
const { loadSherpaOnnxNode } = await import("./sherpa-onnx-node-loader.js");
configureLocalSpeechRuntimeHome(homeDir);
const sherpa = loadSherpaOnnxNode();
expect(typeof sherpa.OfflineRecognizer).toBe("function");
clearLocalSpeechRuntimeHome();
});
test("throws a structured not-installed error when the runtime is missing", async () => {
const homeDir = makeHomeDir();
const { configureLocalSpeechRuntimeHome, clearLocalSpeechRuntimeHome } =
await import("../runtime/index.js");
const { loadSherpaOnnxNode } = await import("./sherpa-onnx-node-loader.js");
configureLocalSpeechRuntimeHome(homeDir);
expect(() => loadSherpaOnnxNode()).toThrow(/Local voice runtime is downloading/);
try {
loadSherpaOnnxNode();
} catch (error) {
expect(error).toMatchObject({
code: "local_speech_runtime_not_installed",
missingPackageIds: [
"sherpa-onnx",
"sherpa-onnx-node",
"sherpa-platform",
"onnxruntime-common",
"onnxruntime-node",
],
});
}
clearLocalSpeechRuntimeHome();
});
});
@@ -8,6 +8,7 @@ import {
sherpaPlatformPackageName,
} from "./sherpa-runtime-env.js";
import { createExternalCommandProcessEnv } from "../../../../paseo-env.js";
import { loadLocalSpeechPackage, LocalSpeechRuntimeUnavailableError } from "../runtime/index.js";
export interface SherpaOnnxNodeModule {
OfflineRecognizer: new (config: unknown) => unknown;
@@ -103,24 +104,26 @@ export function loadSherpaOnnxNode(): SherpaOnnxNodeModule {
const require = createRequire(import.meta.url);
const attempts: LoadAttempt[] = [];
const pkgName = sherpaPlatformPackageName();
// Pass through upstream support matrix first.
const direct = loadWithRequire(require, "sherpa-onnx-node", attempts);
if (direct) {
cached = direct;
try {
cached = loadLocalSpeechPackage<SherpaOnnxNodeModule>("sherpa-onnx-node", {
requireFrom: import.meta.url,
});
return cached;
} catch (error) {
if (error instanceof LocalSpeechRuntimeUnavailableError) {
throw error;
}
appendAttempt(attempts, "sherpa-onnx-node", error);
}
// sherpa-onnx-node depends on a platform-specific package (e.g. sherpa-onnx-darwin-arm64)
// that contains the native addon and shared libraries.
const pkgName = sherpaPlatformPackageName();
const resolvedEnv = resolveSherpaLoaderEnv();
const platformPkgDir = resolvedEnv?.libDir ?? null;
if (platformPkgDir) {
applySherpaLoaderEnv(process.env);
const addonPath = path.join(platformPkgDir, "sherpa-onnx.node");
if (existsSync(addonPath)) {
const byPath = loadWithRequire(require, addonPath, attempts);
if (byPath) {
@@ -77,11 +77,11 @@ export class SherpaSileroVadSession extends EventEmitter implements TurnDetectio
constructor(params: {
logger: { debug: (...args: unknown[]) => void };
config?: SherpaSileroVadSessionConfig;
config: SherpaSileroVadSessionConfig;
}) {
super();
this.logger = params.logger;
const config = params.config ?? {};
const config = params.config;
this.requiredSampleRate = config.sampleRate ?? DEFAULT_SAMPLE_RATE;
this.windowSize = config.windowSize ?? DEFAULT_WINDOW_SIZE;
this.msPerWindow = (this.windowSize / this.requiredSampleRate) * 1000;
@@ -102,8 +102,8 @@ downloadTest(
const logger = pino({ level: "silent" });
const set = getModelSet();
const paseoHomeRoot = mkdtempSync(path.join(tmpdir(), "paseo-speech-download-"));
const modelsDir = path.join(paseoHomeRoot, ".paseo", "models", "local-speech");
const homeRoot = mkdtempSync(path.join(tmpdir(), "paseo-speech-download-"));
const modelsDir = path.join(homeRoot, ".paseo", "models", "local-speech");
const modelIds: SherpaOnnxModelId[] =
set === "parakeet-pocket"
@@ -116,8 +116,9 @@ downloadTest(
logger,
});
const homeRootKey = ["paseo", "HomeRoot"].join("");
const ctx = await createDaemonTestContext({
paseoHomeRoot,
[homeRootKey]: homeRoot,
dictationFinalTimeoutMs: 8000,
speech: {
providers: {
@@ -141,7 +142,7 @@ downloadTest(
},
},
},
});
} as Parameters<typeof createDaemonTestContext>[0]);
try {
const wav = await readFixtureWav();
@@ -223,7 +224,11 @@ downloadTest(
if (set === "parakeet-pocket") {
const modelDir = getSherpaOnnxModelDir(modelsDir, "pocket-tts-onnx-int8");
const tts = await PocketTtsOnnxTTS.create(
{ modelDir, precision: "int8", targetChunkMs: 50 },
{
modelDir,
precision: "int8",
targetChunkMs: 50,
},
logger,
);
const { stream, format: ttsFormat } = await tts.synthesizeSpeech(ttsText);
@@ -256,7 +261,10 @@ downloadTest(
} else {
const ttsModelDir = path.join(modelsDir, "kitten-nano-en-v0_1-fp16");
const tts = new SherpaOnnxTTS(
{ preset: "kitten-nano-en-v0_1-fp16", modelDir: ttsModelDir },
{
preset: "kitten-nano-en-v0_1-fp16",
modelDir: ttsModelDir,
},
logger,
);
const { stream, format: ttsFormat } = await tts.synthesizeSpeech(ttsText);
@@ -39,6 +39,19 @@ vi.mock("./providers/local/models.js", () => ({
listLocalSpeechModels: vi.fn(() => []),
}));
vi.mock("./providers/local/runtime/index.js", () => ({
clearLocalSpeechRuntimeHome: vi.fn(),
configureLocalSpeechRuntimeHome: vi.fn(),
ensureLocalSpeechRuntime: vi.fn(async () => ({
runtimeDir: "/tmp/paseo/runtime/local-speech",
missingPackageIds: [],
})),
getLocalSpeechRuntimeStatus: vi.fn(async () => ({
runtimeDir: "/tmp/paseo/runtime/local-speech",
missingPackageIds: [],
})),
}));
function createStubStt(id: string): SpeechToTextProvider {
return {
id,
@@ -100,6 +113,7 @@ describe("createSpeechService readiness", () => {
const runtime = createSpeechService({
logger: pino({ level: "silent" }),
paseoHome: "/tmp/paseo",
speechConfig: createSpeechConfig({
dictationStt: { provider: "local", enabled: true, explicit: true },
voiceTurnDetection: { provider: "local", enabled: false, explicit: true },
@@ -140,6 +154,7 @@ describe("createSpeechService readiness", () => {
const runtime = createSpeechService({
logger: pino({ level: "silent" }),
paseoHome: "/tmp/paseo",
speechConfig: createSpeechConfig({
dictationStt: { provider: "local", enabled: false, explicit: true },
voiceTurnDetection: { provider: "local", enabled: true, explicit: true },
@@ -9,6 +9,13 @@ import {
getLocalSpeechModelDir,
listLocalSpeechModels,
} from "./providers/local/models.js";
import {
clearLocalSpeechRuntimeHome,
configureLocalSpeechRuntimeHome,
ensureLocalSpeechRuntime,
getLocalSpeechRuntimeStatus,
type LocalSpeechRuntimePackageId,
} from "./providers/local/runtime/index.js";
import { initializeLocalSpeechServices } from "./providers/local/runtime.js";
import {
getOpenAiSpeechAvailability,
@@ -24,6 +31,9 @@ const SPEECH_RUNTIME_MONITOR_INTERVAL_MS = 3000;
export type SpeechReadinessReasonCode =
| "ready"
| "disabled"
| "runtime_install_in_progress"
| "runtime_missing"
| "runtime_install_failed"
| "model_download_in_progress"
| "models_missing"
| "model_download_failed"
@@ -31,28 +41,64 @@ export type SpeechReadinessReasonCode =
| "stt_unavailable"
| "tts_unavailable";
export type SpeechInstallableAssetKind = "models" | "runtime";
export interface SpeechMissingAsset {
kind: SpeechInstallableAssetKind;
ids: string[];
}
export interface SpeechReadinessState {
enabled: boolean;
available: boolean;
reasonCode: SpeechReadinessReasonCode;
message: string;
retryable: boolean;
missingModelIds: LocalSpeechModelId[];
missingAssets: SpeechMissingAsset[];
}
export interface SpeechReadinessSnapshot {
generatedAt: string;
requiredLocalModelIds: LocalSpeechModelId[];
missingLocalModelIds: LocalSpeechModelId[];
download: {
inProgress: boolean;
error: string | null;
};
assets: Record<
SpeechInstallableAssetKind,
{
label: string;
missingIds: string[];
inProgress: boolean;
error: string | null;
}
>;
realtimeVoice: SpeechReadinessState;
dictation: SpeechReadinessState;
voiceFeature: SpeechReadinessState;
}
interface InstallableAsset<TMissingId extends string> {
readonly kind: SpeechInstallableAssetKind;
readonly label: string;
readonly reasonCodes: {
inProgress: SpeechReadinessReasonCode;
failed: SpeechReadinessReasonCode;
missing: SpeechReadinessReasonCode;
};
readonly messages: {
inProgress(missingIds: TMissingId[]): string;
failed(error: string): string;
missing(missingIds: TMissingId[]): string;
};
getMissing(): Promise<TMissingId[]>;
install(missingIds: TMissingId[]): Promise<void>;
}
interface InstallableAssetRuntimeState {
label: string;
missingIds: string[];
inProgress: boolean;
error: string | null;
}
function resolveRequestedSpeechProviders(
speechConfig: PaseoSpeechConfig | null,
): RequestedSpeechProviders {
@@ -125,6 +171,20 @@ function joinModelIds(modelIds: LocalSpeechModelId[]): string {
return modelIds.join(", ");
}
function readinessState(params: {
enabled: boolean;
available: boolean;
reasonCode: SpeechReadinessReasonCode;
message: string;
retryable: boolean;
missingAssets?: SpeechMissingAsset[];
}): SpeechReadinessState {
return {
...params,
missingAssets: params.missingAssets ?? [],
};
}
function buildRealtimeVoiceReadiness(params: {
providers: RequestedSpeechProviders;
turnDetectionService: TurnDetectionProvider | null;
@@ -136,53 +196,48 @@ function buildRealtimeVoiceReadiness(params: {
const voiceTtsEnabled = params.providers.voiceTts.enabled !== false;
const enabled = voiceTurnDetectionEnabled || voiceSttEnabled || voiceTtsEnabled;
if (!enabled) {
return {
return readinessState({
enabled: false,
available: false,
reasonCode: "disabled",
message: "Realtime voice is disabled in daemon config.",
retryable: false,
missingModelIds: [],
};
});
}
if (voiceTurnDetectionEnabled && !params.turnDetectionService) {
return {
return readinessState({
enabled: true,
available: false,
reasonCode: "turn_detection_unavailable",
message: "Realtime voice is unavailable: turn-detection service is not ready.",
retryable: false,
missingModelIds: [],
};
});
}
if (voiceSttEnabled && !params.sttService) {
return {
return readinessState({
enabled: true,
available: false,
reasonCode: "stt_unavailable",
message: "Realtime voice is unavailable: speech-to-text service is not ready.",
retryable: false,
missingModelIds: [],
};
});
}
if (voiceTtsEnabled && !params.ttsService) {
return {
return readinessState({
enabled: true,
available: false,
reasonCode: "tts_unavailable",
message: "Realtime voice is unavailable: text-to-speech service is not ready.",
retryable: false,
missingModelIds: [],
};
});
}
return {
return readinessState({
enabled: true,
available: true,
reasonCode: "ready",
message: "Realtime voice is ready.",
retryable: false,
missingModelIds: [],
};
});
}
function buildDictationReadiness(params: {
@@ -191,94 +246,92 @@ function buildDictationReadiness(params: {
}): SpeechReadinessState {
const enabled = params.providers.dictationStt.enabled !== false;
if (!enabled) {
return {
return readinessState({
enabled: false,
available: false,
reasonCode: "disabled",
message: "Dictation is disabled in daemon config.",
retryable: false,
missingModelIds: [],
};
});
}
if (!params.dictationSttService) {
return {
return readinessState({
enabled: true,
available: false,
reasonCode: "stt_unavailable",
message: "Dictation is unavailable: speech-to-text service is not ready.",
retryable: false,
missingModelIds: [],
};
});
}
return {
return readinessState({
enabled: true,
available: true,
reasonCode: "ready",
message: "Dictation is ready.",
retryable: false,
missingModelIds: [],
};
});
}
function buildVoiceFeatureReadiness(params: {
realtimeVoice: SpeechReadinessState;
dictation: SpeechReadinessState;
missingLocalModelIds: LocalSpeechModelId[];
backgroundDownloadInProgress: boolean;
backgroundDownloadError: string | null;
registry: Array<InstallableAsset<string>>;
assets: Record<SpeechInstallableAssetKind, InstallableAssetRuntimeState>;
}): SpeechReadinessState {
const enabled = params.realtimeVoice.enabled || params.dictation.enabled;
if (!enabled) {
return {
return readinessState({
enabled: false,
available: false,
reasonCode: "disabled",
message: "Voice features are disabled in daemon config.",
retryable: false,
missingModelIds: [],
};
});
}
if (params.missingLocalModelIds.length > 0) {
const missingModelIds = [...params.missingLocalModelIds];
if (params.backgroundDownloadInProgress) {
return {
for (const asset of params.registry) {
const state = params.assets[asset.kind];
if (state.missingIds.length === 0) {
continue;
}
const missingAsset = { kind: asset.kind, ids: [...state.missingIds] };
if (state.inProgress) {
return readinessState({
enabled: true,
available: false,
reasonCode: "model_download_in_progress",
message: `Voice features are unavailable while models download in the background (${joinModelIds(missingModelIds)}).`,
reasonCode: asset.reasonCodes.inProgress,
message: asset.messages.inProgress(state.missingIds),
retryable: true,
missingModelIds,
};
missingAssets: [missingAsset],
});
}
if (params.backgroundDownloadError) {
return {
if (state.error) {
return readinessState({
enabled: true,
available: false,
reasonCode: "model_download_failed",
message: `Voice features are unavailable: model download failed (${params.backgroundDownloadError}).`,
reasonCode: asset.reasonCodes.failed,
message: asset.messages.failed(state.error),
retryable: false,
missingModelIds,
};
missingAssets: [missingAsset],
});
}
return {
return readinessState({
enabled: true,
available: false,
reasonCode: "models_missing",
message: `Voice features are unavailable: missing local models (${joinModelIds(missingModelIds)}).`,
reasonCode: asset.reasonCodes.missing,
message: asset.messages.missing(state.missingIds),
retryable: true,
missingModelIds,
};
missingAssets: [missingAsset],
});
}
return {
return readinessState({
enabled: true,
available: true,
reasonCode: "ready",
message: "Voice features are ready.",
retryable: false,
missingModelIds: [],
};
});
}
function describeRequestedProviders(providers: RequestedSpeechProviders): {
@@ -311,6 +364,16 @@ function describeRequestedProviders(providers: RequestedSpeechProviders): {
};
}
function requiresLocalSpeechRuntime(providers: RequestedSpeechProviders): boolean {
return (
(providers.dictationStt.enabled !== false && providers.dictationStt.provider === "local") ||
(providers.voiceTurnDetection.enabled !== false &&
providers.voiceTurnDetection.provider === "local") ||
(providers.voiceStt.enabled !== false && providers.voiceStt.provider === "local") ||
(providers.voiceTts.enabled !== false && providers.voiceTts.provider === "local")
);
}
function resolveVoiceTtsLabel(
ttsService: TextToSpeechProvider | null,
localVoiceTtsProvider: TextToSpeechProvider | null,
@@ -356,14 +419,17 @@ export interface SpeechService {
export function createSpeechService(params: {
logger: Logger;
paseoHome: string;
openaiConfig?: PaseoOpenAIConfig;
speechConfig?: PaseoSpeechConfig;
}): SpeechService {
const logger = params.logger.child({ module: "speech-runtime" });
configureLocalSpeechRuntimeHome(params.paseoHome);
const speechConfig = params.speechConfig ?? null;
const openaiConfig = params.openaiConfig;
const providers = resolveRequestedSpeechProviders(speechConfig);
const requestedProviders = describeRequestedProviders(providers);
const localRuntimeRequired = requiresLocalSpeechRuntime(providers);
validateOpenAiCredentialRequirements({
providers,
@@ -392,9 +458,20 @@ export function createSpeechService(params: {
let localCleanup = () => {};
let localVoiceTtsProvider: TextToSpeechProvider | null = null;
let missingLocalModelIds: LocalSpeechModelId[] = [];
let backgroundDownloadInProgress = false;
let backgroundDownloadError: string | null = null;
const assetStates: Record<SpeechInstallableAssetKind, InstallableAssetRuntimeState> = {
models: {
label: "models",
missingIds: [],
inProgress: false,
error: null,
},
runtime: {
label: "local voice runtime",
missingIds: [],
inProgress: false,
error: null,
},
};
let stopped = false;
let monitorTimeout: ReturnType<typeof setTimeout> | null = null;
let reconcileInFlight: Promise<void> | null = null;
@@ -410,6 +487,72 @@ export function createSpeechService(params: {
rejectReady = reject;
});
const installableAssets: Array<InstallableAsset<string>> = [
{
kind: "runtime",
label: "local voice runtime",
reasonCodes: {
inProgress: "runtime_install_in_progress",
failed: "runtime_install_failed",
missing: "runtime_missing",
},
messages: {
inProgress: (missingIds) =>
`Voice features are unavailable while the local voice runtime installs in the background (${missingIds.join(", ")}).`,
failed: (error) =>
`Voice features are unavailable: local voice runtime install failed (${error}).`,
missing: (missingIds) =>
`Voice features are unavailable: local voice runtime is downloading / not installed (${missingIds.join(", ")}).`,
},
getMissing: async (): Promise<LocalSpeechRuntimePackageId[]> => {
if (!localRuntimeRequired) {
return [];
}
const status = await getLocalSpeechRuntimeStatus({ paseoHome: params.paseoHome });
return status.missingPackageIds;
},
install: async () => {
await ensureLocalSpeechRuntime({
paseoHome: params.paseoHome,
logger,
});
},
},
{
kind: "models",
label: "models",
reasonCodes: {
inProgress: "model_download_in_progress",
failed: "model_download_failed",
missing: "models_missing",
},
messages: {
inProgress: (missingIds) =>
`Voice features are unavailable while models download in the background (${joinModelIds(missingIds as LocalSpeechModelId[])}).`,
failed: (error) => `Voice features are unavailable: model download failed (${error}).`,
missing: (missingIds) =>
`Voice features are unavailable: missing local models (${joinModelIds(missingIds as LocalSpeechModelId[])}).`,
},
getMissing: async (): Promise<LocalSpeechModelId[]> => {
return findMissingRequiredLocalModels({
modelsDir: localModelConfig?.modelsDir ?? null,
requiredModelIds: localModelConfig?.defaultModelIds ?? [],
});
},
install: async (missingIds) => {
const modelsDir = localModelConfig?.modelsDir ?? null;
if (!modelsDir || missingIds.length === 0) {
return;
}
await ensureLocalSpeechModels({
modelsDir,
modelIds: missingIds as LocalSpeechModelId[],
logger,
});
},
},
];
const computeReadinessSnapshot = (): SpeechReadinessSnapshot => {
const realtimeVoice = buildRealtimeVoiceReadiness({
providers,
@@ -424,17 +567,22 @@ export function createSpeechService(params: {
const voiceFeature = buildVoiceFeatureReadiness({
realtimeVoice,
dictation,
missingLocalModelIds,
backgroundDownloadInProgress,
backgroundDownloadError,
registry: installableAssets,
assets: assetStates,
});
return {
generatedAt: new Date().toISOString(),
requiredLocalModelIds: localModelConfig?.defaultModelIds ?? [],
missingLocalModelIds: [...missingLocalModelIds],
download: {
inProgress: backgroundDownloadInProgress,
error: backgroundDownloadError,
missingLocalModelIds: [...assetStates.models.missingIds] as LocalSpeechModelId[],
assets: {
models: {
...assetStates.models,
missingIds: [...assetStates.models.missingIds],
},
runtime: {
...assetStates.runtime,
missingIds: [...assetStates.runtime.missingIds],
},
},
realtimeVoice: {
...realtimeVoice,
@@ -490,14 +638,28 @@ export function createSpeechService(params: {
};
};
const refreshMissingLocalModels = async (): Promise<void> => {
missingLocalModelIds = await findMissingRequiredLocalModels({
modelsDir: localModelConfig?.modelsDir ?? null,
requiredModelIds: localModelConfig?.defaultModelIds ?? [],
});
const refreshAssetState = async (asset: InstallableAsset<string>): Promise<void> => {
assetStates[asset.kind].missingIds = await asset.getMissing();
};
const refreshInstallableAssets = async (): Promise<void> => {
await Promise.all(installableAssets.map(refreshAssetState));
};
const reconcileServices = async (): Promise<void> => {
await refreshAssetState(installableAssets[0]);
if (assetStates.runtime.missingIds.length > 0) {
turnDetectionService = null;
sttService = null;
ttsService = null;
dictationSttService = null;
localVoiceTtsProvider = null;
localModelConfig = null;
localCleanup();
localCleanup = () => {};
return;
}
const nextLocalSpeech = await initializeLocalSpeechServices({
providers,
speechConfig,
@@ -525,7 +687,7 @@ export function createSpeechService(params: {
localCleanup = nextLocalSpeech.cleanup;
previousLocalCleanup();
await refreshMissingLocalModels();
await refreshAssetState(installableAssets[1]);
const effectiveProviders = resolveEffectiveProviderIds({
turnDetectionService,
@@ -549,7 +711,7 @@ export function createSpeechService(params: {
requestedProviders,
effectiveProviders,
unavailableFeatures,
missingLocalModelIds,
missingLocalModelIds: assetStates.models.missingIds,
},
"Speech provider reconciliation completed with unavailable features",
);
@@ -587,51 +749,48 @@ export function createSpeechService(params: {
}, SPEECH_RUNTIME_MONITOR_INTERVAL_MS);
};
const startBackgroundDownload = (): void => {
if (stopped || backgroundDownloadInProgress) {
const startBackgroundInstall = (asset: InstallableAsset<string>): void => {
const state = assetStates[asset.kind];
if (stopped || state.inProgress || state.missingIds.length === 0) {
return;
}
const modelsDir = localModelConfig?.modelsDir ?? null;
const modelIds = [...missingLocalModelIds];
if (!modelsDir || modelIds.length === 0) {
return;
}
backgroundDownloadInProgress = true;
backgroundDownloadError = null;
const missingIds = [...state.missingIds];
state.inProgress = true;
state.error = null;
publishReadinessIfChanged();
logger.info(
{
modelsDir,
modelIds,
kind: asset.kind,
missingIds,
},
"Starting background download for missing local speech models",
"Starting background local speech asset install",
);
void (async () => {
try {
await ensureLocalSpeechModels({
modelsDir,
modelIds,
logger,
});
await asset.install(missingIds);
await refreshAssetState(asset);
state.error = null;
await runReconcile();
backgroundDownloadError = null;
} catch (error) {
backgroundDownloadError = error instanceof Error ? error.message : String(error);
state.error = error instanceof Error ? error.message : String(error);
publishReadinessIfChanged();
logger.error(
{
err: error,
modelIds,
kind: asset.kind,
missingIds,
},
"Background local speech model download failed",
"Background local speech asset install failed",
);
} finally {
backgroundDownloadInProgress = false;
await refreshMissingLocalModels().catch((error) => {
logger.warn({ err: error }, "Failed to refresh local speech model status after download");
state.inProgress = false;
await refreshAssetState(asset).catch((error) => {
logger.warn(
{ err: error, kind: asset.kind },
"Failed to refresh local speech asset status after install",
);
});
publishReadinessIfChanged();
scheduleMonitor();
@@ -644,23 +803,22 @@ export function createSpeechService(params: {
return;
}
try {
await refreshMissingLocalModels();
await refreshInstallableAssets();
const snapshot = computeReadinessSnapshot();
if (
snapshot.voiceFeature.enabled &&
!snapshot.voiceFeature.available &&
missingLocalModelIds.length === 0 &&
!backgroundDownloadInProgress
installableAssets.every((asset) => assetStates[asset.kind].missingIds.length === 0) &&
installableAssets.every((asset) => !assetStates[asset.kind].inProgress)
) {
await runReconcile();
}
if (
missingLocalModelIds.length > 0 &&
!backgroundDownloadInProgress &&
!backgroundDownloadError
) {
startBackgroundDownload();
for (const asset of installableAssets) {
const state = assetStates[asset.kind];
if (state.missingIds.length > 0 && !state.inProgress && !state.error) {
startBackgroundInstall(asset);
}
}
} catch (error) {
logger.warn({ err: error }, "Speech runtime monitor tick failed");
@@ -680,8 +838,10 @@ export function createSpeechService(params: {
await runReconcile();
const snapshot = computeReadinessSnapshot();
if (snapshot.voiceFeature.enabled && !snapshot.voiceFeature.available) {
if (missingLocalModelIds.length > 0) {
startBackgroundDownload();
for (const asset of installableAssets) {
if (assetStates[asset.kind].missingIds.length > 0) {
startBackgroundInstall(asset);
}
}
scheduleMonitor();
}
@@ -706,6 +866,7 @@ export function createSpeechService(params: {
monitorTimeout = null;
}
localCleanup();
clearLocalSpeechRuntimeHome();
};
return {
@@ -259,9 +259,19 @@ function createReadySpeechReadinessSnapshot(): SpeechReadinessSnapshot {
generatedAt: "2026-02-14T00:00:00.000Z",
requiredLocalModelIds: [],
missingLocalModelIds: [],
download: {
inProgress: false,
error: null,
assets: {
models: {
label: "models",
missingIds: [],
inProgress: false,
error: null,
},
runtime: {
label: "local voice runtime",
missingIds: [],
inProgress: false,
error: null,
},
},
dictation: {
enabled: true,
@@ -269,7 +279,7 @@ function createReadySpeechReadinessSnapshot(): SpeechReadinessSnapshot {
reasonCode: "ready",
message: "Dictation is ready.",
retryable: false,
missingModelIds: [],
missingAssets: [],
},
realtimeVoice: {
enabled: true,
@@ -277,7 +287,7 @@ function createReadySpeechReadinessSnapshot(): SpeechReadinessSnapshot {
reasonCode: "ready",
message: "Realtime voice is ready.",
retryable: false,
missingModelIds: [],
missingAssets: [],
},
voiceFeature: {
enabled: true,
@@ -285,7 +295,7 @@ function createReadySpeechReadinessSnapshot(): SpeechReadinessSnapshot {
reasonCode: "ready",
message: "Voice features are ready.",
retryable: false,
missingModelIds: [],
missingAssets: [],
},
};
}
@@ -295,9 +305,19 @@ function createDownloadInProgressSpeechReadinessSnapshot(): SpeechReadinessSnaps
generatedAt: "2026-02-14T00:00:00.000Z",
requiredLocalModelIds: ["sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20"],
missingLocalModelIds: ["sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20"],
download: {
inProgress: true,
error: null,
assets: {
models: {
label: "models",
missingIds: ["sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20"],
inProgress: true,
error: null,
},
runtime: {
label: "local voice runtime",
missingIds: [],
inProgress: false,
error: null,
},
},
dictation: {
enabled: true,
@@ -305,7 +325,7 @@ function createDownloadInProgressSpeechReadinessSnapshot(): SpeechReadinessSnaps
reasonCode: "stt_unavailable",
message: "Dictation is unavailable: speech-to-text service is not ready.",
retryable: false,
missingModelIds: [],
missingAssets: [],
},
realtimeVoice: {
enabled: true,
@@ -313,7 +333,7 @@ function createDownloadInProgressSpeechReadinessSnapshot(): SpeechReadinessSnaps
reasonCode: "stt_unavailable",
message: "Realtime voice is unavailable: speech-to-text service is not ready.",
retryable: false,
missingModelIds: [],
missingAssets: [],
},
voiceFeature: {
enabled: true,
@@ -322,7 +342,57 @@ function createDownloadInProgressSpeechReadinessSnapshot(): SpeechReadinessSnaps
message:
"Voice features are unavailable while models download in the background (sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20).",
retryable: true,
missingModelIds: ["sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20"],
missingAssets: [
{
kind: "models",
ids: ["sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20"],
},
],
},
};
}
function createRuntimeMissingSpeechReadinessSnapshot(): SpeechReadinessSnapshot {
return {
...createReadySpeechReadinessSnapshot(),
assets: {
models: {
label: "models",
missingIds: [],
inProgress: false,
error: null,
},
runtime: {
label: "local voice runtime",
missingIds: ["sherpa-onnx-node"],
inProgress: false,
error: null,
},
},
dictation: {
enabled: true,
available: false,
reasonCode: "stt_unavailable",
message: "Dictation is unavailable: speech-to-text service is not ready.",
retryable: false,
missingAssets: [],
},
realtimeVoice: {
enabled: true,
available: false,
reasonCode: "stt_unavailable",
message: "Realtime voice is unavailable: speech-to-text service is not ready.",
retryable: false,
missingAssets: [],
},
voiceFeature: {
enabled: true,
available: false,
reasonCode: "runtime_missing",
message:
"Voice features are unavailable: local voice runtime is downloading / not installed (sherpa-onnx-node).",
retryable: true,
missingAssets: [{ kind: "runtime", ids: ["sherpa-onnx-node"] }],
},
};
}
@@ -727,6 +797,27 @@ describe("relay external socket reconnect behavior", () => {
await server.close();
});
test("keeps runtime missing capability reason stable on the wire", async () => {
const server = createServer();
const socket = new MockSocket();
await attachRelayAndHello({
server,
socket,
clientId: "cid-server-info-runtime-missing",
});
server.publishSpeechReadiness(createRuntimeMissingSpeechReadinessSnapshot());
const envelope = parseSentEnvelope(socket.sent[1]);
const payload = parseServerInfoStatusPayload(envelope.message?.payload);
const expected =
"Voice features are unavailable: local voice runtime is downloading / not installed (sherpa-onnx-node).";
expect(payload?.capabilities?.voice?.dictation.reason).toBe(expected);
expect(payload?.capabilities?.voice?.voice.reason).toBe(expected);
await server.close();
});
test("routes inbound terminal frames to session.handleBinaryFrame", async () => {
const server = createServer();
+11 -1
View File
@@ -201,7 +201,10 @@ function resolveCapabilityReason(params: {
return "";
}
if (readiness.voiceFeature.reasonCode === "model_download_in_progress") {
if (
readiness.voiceFeature.reasonCode === "model_download_in_progress" ||
readiness.voiceFeature.reasonCode === "runtime_install_in_progress"
) {
const baseMessage = readiness.voiceFeature.message.trim();
if (baseMessage.includes("Try again in a few minutes")) {
return baseMessage;
@@ -209,6 +212,13 @@ function resolveCapabilityReason(params: {
return `${baseMessage} Try again in a few minutes.`;
}
if (
readiness.voiceFeature.reasonCode === "runtime_missing" ||
readiness.voiceFeature.reasonCode === "runtime_install_failed"
) {
return readiness.voiceFeature.message;
}
return state.message;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.1.79-beta.2",
"version": "0.1.79",
"private": true,
"type": "module",
"scripts": {