commit fee0f4fd285d2549ddad8d265fd4df69d1740bd0 Author: Mohamed Boudra Date: Mon Oct 13 14:03:54 2025 +0200 Initial monorepo setup with mcp-server and web packages diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..f44b4dc7d --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Dependencies +node_modules/ + +# Build outputs +build/ +dist/ +.next/ +out/ + +# Environment variables +.env +.env.local +.env*.local + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# OS files +.DS_Store +*.swp +*.swo +*~ + +# TypeScript +*.tsbuildinfo +tsconfig.tsbuildinfo + +# IDEs +.vscode/ +.idea/ +*.sublime-* + +# Testing +coverage/ + +# Vercel +.vercel/ + +# Misc +*.pem diff --git a/package.json b/package.json new file mode 100644 index 000000000..5ae3f0b9a --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "realtime-voice-monorepo", + "version": "1.0.0", + "private": true, + "workspaces": [ + "packages/*" + ], + "scripts": { + "dev": "npm run dev --workspace=web", + "dev:mcp": "npm run dev --workspace=mcp-server", + "build": "npm run build --workspaces --if-present", + "build:mcp": "npm run build --workspace=mcp-server", + "build:web": "npm run build --workspace=web", + "typecheck": "npm run typecheck --workspaces --if-present", + "start": "npm run start --workspace=web", + "start:mcp": "npm run start --workspace=mcp-server" + }, + "devDependencies": { + "typescript": "^5.9.3" + }, + "description": "Monorepo for OpenAI Realtime Voice Assistant with tmux MCP integration", + "keywords": [ + "openai", + "realtime", + "voice", + "mcp", + "tmux", + "monorepo" + ], + "author": "moboudra", + "license": "MIT" +} diff --git a/packages/mcp-server/.gitignore b/packages/mcp-server/.gitignore new file mode 100644 index 000000000..61fbaa3e7 --- /dev/null +++ b/packages/mcp-server/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +build/ +.DS_Store +*.log diff --git a/packages/mcp-server/LICENSE.md b/packages/mcp-server/LICENSE.md new file mode 100644 index 000000000..7bc79094b --- /dev/null +++ b/packages/mcp-server/LICENSE.md @@ -0,0 +1,8 @@ +Copyright 2025 Nicolò Gnudi + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md new file mode 100644 index 000000000..66ece8c50 --- /dev/null +++ b/packages/mcp-server/README.md @@ -0,0 +1,94 @@ +# Tmux MCP Server + +Model Context Protocol server that enables Claude Desktop to interact with and view tmux session content. This integration allows AI assistants to read from, control, and observe your terminal sessions. + +## Features + +- List and search tmux sessions +- View and navigate tmux windows and panes +- Capture and expose terminal content from any pane +- Execute commands in tmux panes and retrieve results (use it at your own risk ⚠️) +- Create new tmux sessions and windows +- Split panes horizontally or vertically with customizable sizes +- Kill tmux sessions, windows, and panes + +Check out this short video to get excited! + +
+ +[![youtube video](http://i.ytimg.com/vi/3W0pqRF1RS0/hqdefault.jpg)](https://www.youtube.com/watch?v=3W0pqRF1RS0) + +## Prerequisites + +- Node.js +- tmux installed and running + +## Usage + +### Configure Claude Desktop + +Add this MCP server to your Claude Desktop configuration: + +```json +"mcpServers": { + "tmux": { + "command": "npx", + "args": ["-y", "tmux-mcp"] + } +} +``` + +### MCP server options + +You can optionally specify the command line shell you are using, if unspecified it defaults to `bash` + +```json +"mcpServers": { + "tmux": { + "command": "npx", + "args": ["-y", "tmux-mcp", "--shell-type=fish"] + } +} +``` + +The MCP server needs to know the shell only when executing commands, to properly read its exit status. + +## Available Resources + +- `tmux://sessions` - List all tmux sessions +- `tmux://pane/{paneId}` - View content of a specific tmux pane + +## Available Tools + +### Hierarchy & Information +- `list` - List tmux sessions, windows, and panes with flexible scoping + - `scope="all"` - Full nested hierarchy (sessions → windows → panes) + - `scope="sessions"` - List all sessions + - `scope="session"` + `target="$0"` - List windows in session + - `scope="window"` + `target="@1"` - List panes in window + - `scope="pane"` + `target="%2"` - Get pane details +- `capture-pane` - Capture content from a tmux pane + +### Session & Layout Management +- `create-session` - Create a new tmux session +- `create-window` - Create a new window in a tmux session +- `split-pane` - Split a tmux pane horizontally or vertically with optional size +- `kill` - Kill a session, window, or pane by scope and target + - `scope="session"` + `target="$0"` - Kill session + - `scope="window"` + `target="@1"` - Kill window + - `scope="pane"` + `target="%2"` - Kill pane + +### Command Execution & Interaction +- `execute-shell-command` - Execute a shell command synchronously (default 30s timeout) + - Returns output, exit code, and status immediately + - Use for: ls, grep, npm test, quick commands + - **For long-running commands:** Use `send-text` with `pressEnter=true` instead, then monitor with `capture-pane` +- `send-keys` - Send special keys or key combinations (raw pass-through) + - Use for: TUI navigation, control sequences + - Examples: "Up", "Down", "C-c", "M-x", "Enter", "C-b d" +- `send-text` - Type text character-by-character with literal interpretation + - Uses `-l` flag per character (prevents special key interpretation) + - Use for: REPLs, forms, interactive apps, long-running commands + - Optional `pressEnter` parameter + - **For long-running commands:** Set `pressEnter=true`, then use `capture-pane` to monitor output + diff --git a/packages/mcp-server/package-lock.json b/packages/mcp-server/package-lock.json new file mode 100644 index 000000000..4f5b6b191 --- /dev/null +++ b/packages/mcp-server/package-lock.json @@ -0,0 +1,1699 @@ +{ + "name": "tmux-mcp", + "version": "0.2.2", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tmux-mcp", + "version": "0.2.2", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.20.0", + "@types/express": "^5.0.3", + "express": "^5.1.0", + "uuid": "^11.1.0", + "zod": "^3.22.4" + }, + "bin": { + "tmux-mcp": "build/index.js" + }, + "devDependencies": { + "@types/node": "^20.10.5", + "@types/uuid": "^10.0.0", + "tsx": "^4.20.6", + "typescript": "^5.3.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", + "integrity": "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.10.tgz", + "integrity": "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.10.tgz", + "integrity": "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.10.tgz", + "integrity": "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.10.tgz", + "integrity": "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.10.tgz", + "integrity": "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.10.tgz", + "integrity": "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.10.tgz", + "integrity": "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.10.tgz", + "integrity": "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.10.tgz", + "integrity": "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.10.tgz", + "integrity": "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.10.tgz", + "integrity": "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.10.tgz", + "integrity": "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.10.tgz", + "integrity": "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.10.tgz", + "integrity": "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.10.tgz", + "integrity": "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.10.tgz", + "integrity": "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.10.tgz", + "integrity": "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.10.tgz", + "integrity": "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.10.tgz", + "integrity": "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.10.tgz", + "integrity": "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.10.tgz", + "integrity": "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.10.tgz", + "integrity": "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.10.tgz", + "integrity": "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.10.tgz", + "integrity": "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.10.tgz", + "integrity": "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.20.0.tgz", + "integrity": "sha512-kOQ4+fHuT4KbR2iq2IjeV32HiihueuOf1vJkq18z08CLZ1UQrTc8BXJpVfxZkq45+inLLD+D4xx4nBjUelJa4Q==", + "license": "MIT", + "dependencies": { + "ajv": "^6.12.6", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.0.1", + "express-rate-limit": "^7.5.0", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.23.8", + "zod-to-json-schema": "^3.24.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz", + "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz", + "integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.17.23", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.23.tgz", + "integrity": "sha512-8PCGZ1ZJbEZuYNTMqywO+Sj4vSKjSjT6Ua+6RFOYlEvIvKQABPtrNkoVSLSKDb4obYcMhspVKmsw8Cm10NFRUg==", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.0.tgz", + "integrity": "sha512-zBF6vZJn1IaMpg3xUF25VK3gd3l8zwE0ZLRX7dsQyQi+jp4E8mMDJNGDYnYse+bQhYwWERTxVwHpi3dMOq7RKQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.9.tgz", + "integrity": "sha512-dOTIuqpWLyl3BBXU3maNQsS4A3zuuoYRNIvYSxxhebPfXg2mzWQEPne/nlJ37yOse6uGgR386uTpdsx4D0QZWA==", + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.5", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", + "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/body-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.0", + "http-errors": "^2.0.0", + "iconv-lite": "^0.6.3", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.0", + "type-is": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", + "integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.10", + "@esbuild/android-arm": "0.25.10", + "@esbuild/android-arm64": "0.25.10", + "@esbuild/android-x64": "0.25.10", + "@esbuild/darwin-arm64": "0.25.10", + "@esbuild/darwin-x64": "0.25.10", + "@esbuild/freebsd-arm64": "0.25.10", + "@esbuild/freebsd-x64": "0.25.10", + "@esbuild/linux-arm": "0.25.10", + "@esbuild/linux-arm64": "0.25.10", + "@esbuild/linux-ia32": "0.25.10", + "@esbuild/linux-loong64": "0.25.10", + "@esbuild/linux-mips64el": "0.25.10", + "@esbuild/linux-ppc64": "0.25.10", + "@esbuild/linux-riscv64": "0.25.10", + "@esbuild/linux-s390x": "0.25.10", + "@esbuild/linux-x64": "0.25.10", + "@esbuild/netbsd-arm64": "0.25.10", + "@esbuild/netbsd-x64": "0.25.10", + "@esbuild/openbsd-arm64": "0.25.10", + "@esbuild/openbsd-x64": "0.25.10", + "@esbuild/openharmony-arm64": "0.25.10", + "@esbuild/sunos-x64": "0.25.10", + "@esbuild/win32-arm64": "0.25.10", + "@esbuild/win32-ia32": "0.25.10", + "@esbuild/win32-x64": "0.25.10" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.5.tgz", + "integrity": "sha512-LT/5J605bx5SNyE+ITBDiM3FxffBiq9un7Vx0EwMDM3vg8sWKx/tO2zC+LMqZ+smAM0F2hblaDZUVZF0te2pSw==", + "dependencies": { + "eventsource-parser": "^3.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.0.tgz", + "integrity": "sha512-T1C0XCUimhxVQzW4zFipdx0SficT651NnkR0ZSH3yQwh+mFMdLfgjABVi4YtMTtaL4s168593DaoaRLMqryavA==", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.0", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.0.tgz", + "integrity": "sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": "^4.11 || 5 || ^5.0.0-beta.1" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.12.0.tgz", + "integrity": "sha512-LScr2aNr2FbjAjZh2C6X6BxRx1/x+aTDExct/xyq2XKbYOiG5c0aK7pMsSuyc0brz3ibr/lbQiHD9jzt4lccJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", + "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", + "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.6.3", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/send": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.20.6", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz", + "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.25.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", + "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "node_modules/zod": { + "version": "3.24.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", + "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.24.3", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.3.tgz", + "integrity": "sha512-HIAfWdYIt1sssHfYZFCXp4rU1w2r8hVVXYIlmoa0r0gABLs5di3RCqPU5DDROogVz1pAdYBaz7HK5n9pSUNs3A==", + "peerDependencies": { + "zod": "^3.24.1" + } + } + } +} diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json new file mode 100644 index 000000000..25453da3d --- /dev/null +++ b/packages/mcp-server/package.json @@ -0,0 +1,50 @@ +{ + "name": "@realtime-voice/mcp-server", + "version": "0.3.0", + "description": "MCP Server for interfacing with tmux sessions", + "type": "module", + "main": "build/index.js", + "scripts": { + "build": "tsc", + "start": "node build/index.js", + "dev": "tsx watch src/index.ts --http --password dev-password", + "dev:stdio": "tsx watch src/index.ts", + "typecheck": "tsc --noEmit", + "check-release": "npm run build && npm publish --dry-run", + "release": "npm run build && npm publish" + }, + "bin": { + "tmux-mcp": "build/index.js" + }, + "files": [ + "build" + ], + "keywords": [ + "mcp", + "tmux", + "claude" + ], + "author": "nickgnd", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.20.0", + "@types/express": "^5.0.3", + "express": "^5.1.0", + "uuid": "^11.1.0", + "zod": "^3.22.4" + }, + "devDependencies": { + "@types/node": "^20.10.5", + "@types/uuid": "^10.0.0", + "tsx": "^4.20.6", + "typescript": "^5.3.3" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/nickgnd/tmux-mcp.git" + }, + "bugs": { + "url": "https://github.com/nickgnd/tmux-mcp/issues" + }, + "homepage": "https://github.com/nickgnd/tmux-mcp#readme" +} diff --git a/packages/mcp-server/src/http-server.ts b/packages/mcp-server/src/http-server.ts new file mode 100644 index 000000000..4e23b8e81 --- /dev/null +++ b/packages/mcp-server/src/http-server.ts @@ -0,0 +1,86 @@ +import express, { Request, Response, NextFunction } from 'express'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; + +interface HttpServerOptions { + port: number; + password: string; + server: McpServer; +} + +interface AuthenticatedRequest extends Request { + isAuthenticated?: boolean; +} + +export function startHttpServer({ port, password, server }: HttpServerOptions): void { + const app = express(); + + app.use(express.json()); + + // Password authentication middleware + function authenticate(req: AuthenticatedRequest, res: Response, next: NextFunction): void { + const providedPassword = req.query.password as string; + + if (!providedPassword || providedPassword !== password) { + res.status(401).json({ error: 'Unauthorized: Invalid or missing password' }); + return; + } + + req.isAuthenticated = true; + next(); + } + + // Health check endpoint + app.get('/', (req: Request, res: Response) => { + res.json({ + status: 'ok', + service: 'tmux-mcp', + transport: 'streamable-http' + }); + }); + + // Streamable HTTP endpoint (modern MCP transport) + app.post('/mcp', authenticate, async (req: AuthenticatedRequest, res: Response) => { + try { + // Create a new transport for each request to prevent request ID collisions + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true + }); + + res.on('close', () => { + transport.close(); + }); + + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error('Error handling MCP request:', error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { + code: -32603, + message: 'Internal server error' + }, + id: null + }); + } + } + }); + + const httpServer = app.listen(port, () => { + console.error(`MCP HTTP server listening on port ${port}`); + console.error(`Streamable HTTP endpoint: http://localhost:${port}/mcp?password=****`); + }); + + httpServer.on('error', (error: NodeJS.ErrnoException) => { + if (error.code === 'EADDRINUSE') { + console.error(`Error: Port ${port} is already in use`); + process.exit(1); + } else { + console.error(`Server error:`, error); + process.exit(1); + } + }); +} diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts new file mode 100644 index 000000000..d5efc4b47 --- /dev/null +++ b/packages/mcp-server/src/index.ts @@ -0,0 +1,727 @@ +#!/usr/bin/env node + +import { parseArgs } from "node:util"; +import { + McpServer, + ResourceTemplate, +} from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; +import * as tmux from "./tmux.js"; +import { startHttpServer } from "./http-server.js"; + +// Create MCP server +const server = new McpServer( + { + name: "tmux-mcp", + version: "0.3.0", + }, + { + capabilities: { + resources: { + subscribe: true, + listChanged: true, + }, + tools: { + listChanged: true, + }, + logging: {}, + }, + } +); + +// List tmux hierarchy - Tool +server.tool( + "list", + "List tmux sessions, windows, and panes. Use scope='all' for full hierarchy, or drill down with specific scopes. Examples: scope='all' returns nested tree, scope='sessions' returns all sessions, scope='session' with target='$0' returns windows in that session, scope='window' with target='@1' returns panes in that window.", + { + scope: z + .enum(["all", "sessions", "session", "window", "pane"]) + .describe( + "Scope of listing: 'all' (full tree), 'sessions' (all sessions), 'session' (windows in a session), 'window' (panes in a window), 'pane' (details of a pane)" + ), + target: z + .string() + .optional() + .describe( + "Target ID (required for session/window/pane scopes): session ID (e.g., '$0'), window ID (e.g., '@1'), or pane ID (e.g., '%2')" + ), + }, + async ({ scope, target }) => { + try { + const result = await tmux.list({ scope, target }); + return { + content: [ + { + type: "text", + text: JSON.stringify(result, null, 2), + }, + ], + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: `Error listing: ${error}`, + }, + ], + isError: true, + }; + } + } +); + +// Capture pane content - Tool +server.tool( + "capture-pane", + "Capture content from a tmux pane with configurable lines count and optional color preservation. Optionally wait before capturing to allow commands to complete.", + { + paneId: z.string().describe("ID of the tmux pane"), + lines: z.string().optional().describe("Number of lines to capture"), + colors: z + .boolean() + .optional() + .describe( + "Include color/escape sequences for text and background attributes in output" + ), + wait: z + .number() + .optional() + .describe( + "Milliseconds to wait before capturing output. Useful for long-running commands that need time to complete." + ), + }, + async ({ paneId, lines, colors, wait }) => { + try { + // Wait if specified + if (wait) { + await new Promise((resolve) => setTimeout(resolve, wait)); + } + + // Parse lines parameter if provided + const linesCount = lines ? parseInt(lines, 10) : undefined; + const includeColors = colors || false; + const content = await tmux.capturePaneContent( + paneId, + linesCount, + includeColors + ); + return { + content: [ + { + type: "text", + text: content || "No content captured", + }, + ], + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: `Error capturing pane content: ${error}`, + }, + ], + isError: true, + }; + } + } +); + +// Create new session - Tool +server.tool( + "create-session", + "Create a new tmux session", + { + name: z.string().describe("Name for the new tmux session"), + }, + async ({ name }) => { + try { + const session = await tmux.createSession(name); + return { + content: [ + { + type: "text", + text: session + ? `Session created: ${JSON.stringify(session, null, 2)}` + : `Failed to create session: ${name}`, + }, + ], + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: `Error creating session: ${error}`, + }, + ], + isError: true, + }; + } + } +); + +// Create new window - Tool +server.tool( + "create-window", + "Create a new window in a tmux session. Returns the window ID and the default pane ID. Optionally execute a command in the new window immediately after creation - the command can use any bash operators (&&, ||, |, ;, etc.).", + { + sessionId: z.string().describe("ID of the tmux session"), + name: z.string().describe("Name for the new window"), + command: z + .string() + .optional() + .describe( + "Optional shell command to execute in the new window. Supports bash operators: && (chain), || (or), | (pipe), ; (sequential), etc. After sending the command, sleeps 1 second and returns the captured output." + ), + }, + async ({ sessionId, name, command }) => { + try { + const window = await tmux.createWindow(sessionId, name, command); + + if (!window) { + return { + content: [ + { + type: "text", + text: `Failed to create window: ${name}`, + }, + ], + }; + } + + let text = `Window created: ${JSON.stringify( + { + id: window.id, + name: window.name, + active: window.active, + sessionId: window.sessionId, + paneId: window.paneId, + }, + null, + 2 + )}`; + + if (command && window.output) { + text += `\n\nCommand executed: ${command}\n\n--- Output ---\n${window.output}`; + } else if (command) { + text += `\n\nCommand sent: ${command}`; + } else { + text += `\n\nYou can now execute commands directly in pane ${window.paneId}`; + } + + return { + content: [ + { + type: "text", + text, + }, + ], + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: `Error creating window: ${error}`, + }, + ], + isError: true, + }; + } + } +); + +// Rename window - Tool +server.tool( + "rename-window", + "Rename a tmux window by its window ID (e.g., @380)", + { + windowId: z.string().describe("ID of the tmux window (e.g., '@380')"), + name: z.string().describe("New name for the window"), + }, + async ({ windowId, name }) => { + try { + await tmux.renameWindow(windowId, name); + return { + content: [ + { + type: "text", + text: `Window ${windowId} renamed to "${name}"`, + }, + ], + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: `Error renaming window: ${error}`, + }, + ], + isError: true, + }; + } + } +); + +// Kill tmux resources - Tool +server.tool( + "kill", + "Kill a tmux session, window, or pane. Examples: kill(scope='session', target='$0'), kill(scope='window', target='@1'), kill(scope='pane', target='%2'). No 'all' scope for safety.", + { + scope: z + .enum(["session", "window", "pane"]) + .describe("Type of resource to kill: 'session', 'window', or 'pane'"), + target: z + .string() + .describe( + "Target ID to kill: session ID (e.g., '$0'), window ID (e.g., '@1'), or pane ID (e.g., '%2')" + ), + }, + async ({ scope, target }) => { + try { + await tmux.kill({ scope, target }); + return { + content: [ + { + type: "text", + text: `${ + scope.charAt(0).toUpperCase() + scope.slice(1) + } ${target} has been killed`, + }, + ], + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: `Error killing ${scope}: ${error}`, + }, + ], + isError: true, + }; + } + } +); + +// Split pane - Tool +server.tool( + "split-pane", + "Split a tmux pane horizontally or vertically", + { + paneId: z.string().describe("ID of the tmux pane to split"), + direction: z + .enum(["horizontal", "vertical"]) + .optional() + .describe( + "Split direction: 'horizontal' (side by side) or 'vertical' (top/bottom). Default is 'vertical'" + ), + size: z + .number() + .min(1) + .max(99) + .optional() + .describe("Size of the new pane as percentage (1-99). Default is 50%"), + }, + async ({ paneId, direction, size }) => { + try { + const newPane = await tmux.splitPane( + paneId, + direction || "vertical", + size + ); + return { + content: [ + { + type: "text", + text: newPane + ? `Pane split successfully. New pane: ${JSON.stringify( + newPane, + null, + 2 + )}` + : `Failed to split pane ${paneId}`, + }, + ], + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: `Error splitting pane: ${error}`, + }, + ], + isError: true, + }; + } + } +); + +// Execute shell command - Tool +server.tool( + "execute-shell-command", + "Execute a shell command in a tmux pane synchronously and return results immediately. Waits for command completion (default 30s timeout). Returns output, exit code, and status. Use for quick commands (ls, grep, npm test, etc.). Avoid heredoc syntax (cat << EOF) and multi-line constructs. IMPORTANT: For long-running commands (npm start, servers, watch processes), use send-text with pressEnter=true instead, then monitor output with capture-pane. For interactive apps or special keys, use send-keys.", + { + paneId: z.string().describe("ID of the tmux pane"), + command: z.string().describe("Shell command to execute"), + timeout: z + .number() + .optional() + .describe("Timeout in milliseconds (default: 30000)"), + }, + async ({ paneId, command, timeout }) => { + try { + const result = await tmux.executeShellCommand({ + paneId, + command, + timeout, + }); + + let statusText = + result.status === "completed" ? "✅ Completed" : "❌ Error"; + + return { + content: [ + { + type: "text", + text: `${statusText}\nCommand: ${result.command}\nExit code: ${ + result.exitCode + }\n\n--- Output ---\n${result.output || "(no output)"}`, + }, + ], + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: `Error executing command: ${error}`, + }, + ], + isError: true, + }; + } + } +); + +// Send keys - Tool +server.tool( + "send-keys", + "Send special keys or key combinations to a tmux pane. Use for TUI navigation and control sequences. Examples: 'Up', 'Down', 'Enter', 'Escape', 'C-c' (Ctrl+C), 'M-x' (Alt+X), 'C-b' (tmux prefix). For typing regular text, use send-text instead. Supports repeating key presses and optionally capturing output after sending keys.", + { + paneId: z.string().describe("ID of the tmux pane"), + keys: z + .string() + .describe( + "Special key name or key combination: 'Up', 'Down', 'Left', 'Right', 'Enter', 'Escape', 'Tab', 'Space', 'C-c', 'M-x', etc." + ), + repeat: z + .number() + .min(1) + .optional() + .describe("Number of times to repeat the key press (default: 1)"), + return_output: z + .object({ + lines: z + .number() + .optional() + .describe("Number of lines to capture (default: 200)"), + wait: z + .number() + .optional() + .describe("Milliseconds to wait before capturing output"), + }) + .optional() + .describe( + "Capture pane output after sending keys. Specify 'wait' for slow commands." + ), + }, + async ({ paneId, keys, repeat, return_output }) => { + try { + const output = await tmux.sendKeys({ + paneId, + keys, + repeat, + return_output, + }); + + if (return_output && output) { + return { + content: [ + { + type: "text", + text: `Keys '${keys}' sent to pane ${paneId}${ + repeat && repeat > 1 ? ` (repeated ${repeat} times)` : "" + }.\n\n--- Output ---\n${output}`, + }, + ], + }; + } + + return { + content: [ + { + type: "text", + text: `Keys '${keys}' sent to pane ${paneId}${ + repeat && repeat > 1 ? ` (repeated ${repeat} times)` : "" + }.\n\nUse capture-pane to verify the result.`, + }, + ], + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: `Error sending keys: ${error}`, + }, + ], + isError: true, + }; + } + } +); + +// Send text - Tool +server.tool( + "send-text", + "Type text into a tmux pane. This is the PRIMARY way to execute shell commands with bash operators (&&, ||, |, ;, etc.) - set pressEnter=true to run the command. Also use for interactive applications, REPLs, forms, and text entry. For special keys or control sequences, use send-keys instead.", + { + paneId: z.string().describe("ID of the tmux pane"), + text: z + .string() + .describe( + "Text to type into the pane. For shell commands, can use any bash operators: && (chain), || (or), | (pipe), ; (sequential), etc." + ), + pressEnter: z + .boolean() + .optional() + .describe( + "Press Enter after typing the text (default: false). Set to true to execute shell commands or submit text input." + ), + return_output: z + .object({ + lines: z + .number() + .optional() + .describe("Number of lines to capture (default: 200)"), + wait: z + .number() + .optional() + .describe("Milliseconds to wait before capturing output"), + }) + .optional() + .describe( + "Capture pane output after sending text. Specify 'wait' for slow commands." + ), + }, + async ({ paneId, text, pressEnter, return_output }) => { + try { + const output = await tmux.sendText({ + paneId, + text, + pressEnter, + return_output, + }); + + if (return_output && output) { + return { + content: [ + { + type: "text", + text: `Text sent to pane ${paneId}${ + pressEnter ? " (with Enter)" : "" + }.\n\n--- Output ---\n${output}`, + }, + ], + }; + } + + return { + content: [ + { + type: "text", + text: `Text sent to pane ${paneId}${ + pressEnter ? " (with Enter)" : "" + }.\n\nUse capture-pane to verify the result.`, + }, + ], + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: `Error sending text: ${error}`, + }, + ], + isError: true, + }; + } + } +); + +// Expose tmux session list as a resource +server.resource("Tmux Sessions", "tmux://sessions", async () => { + try { + const sessions = await tmux.listSessions(); + return { + contents: [ + { + uri: "tmux://sessions", + text: JSON.stringify( + sessions.map((session) => ({ + id: session.id, + name: session.name, + attached: session.attached, + windows: session.windows, + })), + null, + 2 + ), + }, + ], + }; + } catch (error) { + return { + contents: [ + { + uri: "tmux://sessions", + text: `Error listing tmux sessions: ${error}`, + }, + ], + }; + } +}); + +// Expose pane content as a resource +server.resource( + "Tmux Pane Content", + new ResourceTemplate("tmux://pane/{paneId}", { + list: async () => { + try { + // Get all sessions + const sessions = await tmux.listSessions(); + const paneResources = []; + + // For each session, get all windows + for (const session of sessions) { + const windows = await tmux.listWindows(session.id); + + // For each window, get all panes + for (const window of windows) { + const panes = await tmux.listPanes(window.id); + + // For each pane, create a resource with descriptive name + for (const pane of panes) { + paneResources.push({ + name: `Pane: ${session.name} - ${pane.id} - ${pane.title} ${ + pane.active ? "(active)" : "" + }`, + uri: `tmux://pane/${pane.id}`, + description: `Content from pane ${pane.id} - ${pane.title} in session ${session.name}`, + }); + } + } + } + + return { + resources: paneResources, + }; + } catch (error) { + server.server.sendLoggingMessage({ + level: "error", + data: `Error listing panes: ${error}`, + }); + + return { resources: [] }; + } + }, + }), + async (uri, { paneId }) => { + try { + // Ensure paneId is a string + const paneIdStr = Array.isArray(paneId) ? paneId[0] : paneId; + // Default to no colors for resources to maintain clean programmatic access + const content = await tmux.capturePaneContent(paneIdStr, 200, false); + return { + contents: [ + { + uri: uri.href, + text: content || "No content captured", + }, + ], + }; + } catch (error) { + return { + contents: [ + { + uri: uri.href, + text: `Error capturing pane content: ${error}`, + }, + ], + }; + } + } +); + +async function main() { + try { + const { values } = parseArgs({ + options: { + "shell-type": { type: "string", default: "bash", short: "s" }, + http: { type: "boolean", default: false }, + port: { type: "string" }, + password: { type: "string" }, + }, + allowPositionals: true, + }); + + // Set shell configuration + tmux.setShellConfig({ + type: values["shell-type"] as string, + }); + + console.log(values, process.argv); + + // Check if HTTP mode is enabled + if (values.http) { + if (!values.password) { + console.error("Error: --password is required when using --http mode"); + console.error( + "\nUsage: tmux-mcp --http --password your-secret-password" + ); + console.error( + "Set PORT environment variable to change port (default: 3000)" + ); + process.exit(1); + } + + const port = parseInt(values.port || process.env.PORT || "3000", 10); + + // Start HTTP server + startHttpServer({ + port, + password: values.password, + server, + }); + } else { + // Start stdio server (default) + const transport = new StdioServerTransport(); + await server.connect(transport); + } + } catch (error) { + console.error("Failed to start MCP server:", error); + process.exit(1); + } +} + +main().catch((error) => { + console.error("Fatal error:", error); + process.exit(1); +}); diff --git a/packages/mcp-server/src/tmux.ts b/packages/mcp-server/src/tmux.ts new file mode 100644 index 000000000..38afa4633 --- /dev/null +++ b/packages/mcp-server/src/tmux.ts @@ -0,0 +1,705 @@ +import { exec as execCallback } from "child_process"; +import { promisify } from "util"; +import { v4 as uuidv4 } from "uuid"; + +const exec = promisify(execCallback); + +// Basic interfaces for tmux objects +export interface TmuxSession { + id: string; + name: string; + attached: boolean; + windows: number; +} + +export interface TmuxWindow { + id: string; + name: string; + active: boolean; + sessionId: string; +} + +export interface TmuxPane { + id: string; + windowId: string; + active: boolean; + title: string; +} + +interface CommandExecution { + id: string; + paneId: string; + command: string; + status: "pending" | "completed" | "error"; + startTime: Date; + result?: string; + exitCode?: number; + rawMode?: boolean; +} + +export type ShellType = "bash" | "zsh" | "fish"; + +let shellConfig: { type: ShellType } = { type: "bash" }; + +export function setShellConfig(config: { type: string }): void { + // Validate shell type + const validShells: ShellType[] = ["bash", "zsh", "fish"]; + + if (validShells.includes(config.type as ShellType)) { + shellConfig = { type: config.type as ShellType }; + } else { + shellConfig = { type: "bash" }; + } +} + +/** + * Execute a tmux command and return the result + */ +export async function executeTmux(tmuxCommand: string): Promise { + try { + const { stdout } = await exec(`tmux ${tmuxCommand}`); + return stdout.trim(); + } catch (error: any) { + throw new Error(`Failed to execute tmux command: ${error.message}`); + } +} + +/** + * Check if tmux server is running + */ +export async function isTmuxRunning(): Promise { + try { + await executeTmux("list-sessions -F '#{session_name}'"); + return true; + } catch (error) { + return false; + } +} + +/** + * List all tmux sessions + */ +export async function listSessions(): Promise { + const format = + "#{session_id}:#{session_name}:#{?session_attached,1,0}:#{session_windows}"; + const output = await executeTmux(`list-sessions -F '${format}'`); + + if (!output) return []; + + return output.split("\n").map((line) => { + const [id, name, attached, windows] = line.split(":"); + return { + id, + name, + attached: attached === "1", + windows: parseInt(windows, 10), + }; + }); +} + +/** + * Find a session by name + */ +export async function findSessionByName( + name: string +): Promise { + try { + const sessions = await listSessions(); + return sessions.find((session) => session.name === name) || null; + } catch (error) { + return null; + } +} + +/** + * List windows in a session + */ +export async function listWindows(sessionId: string): Promise { + const format = "#{window_id}:#{window_name}:#{?window_active,1,0}"; + const output = await executeTmux( + `list-windows -t '${sessionId}' -F '${format}'` + ); + + if (!output) return []; + + return output.split("\n").map((line) => { + const [id, name, active] = line.split(":"); + return { + id, + name, + active: active === "1", + sessionId, + }; + }); +} + +/** + * List panes in a window + */ +export async function listPanes(windowId: string): Promise { + const format = "#{pane_id}:#{pane_title}:#{?pane_active,1,0}"; + const output = await executeTmux( + `list-panes -t '${windowId}' -F '${format}'` + ); + + if (!output) return []; + + return output.split("\n").map((line) => { + const [id, title, active] = line.split(":"); + return { + id, + windowId, + title: title, + active: active === "1", + }; + }); +} + +/** + * Capture content from a specific pane, by default the latest 200 lines. + */ +export async function capturePaneContent( + paneId: string, + lines: number = 200, + includeColors: boolean = false +): Promise { + const colorFlag = includeColors ? "-e" : ""; + return executeTmux( + `capture-pane -p ${colorFlag} -t '${paneId}' -S -${lines} -E -` + ); +} + +/** + * Create a new tmux session + */ +export async function createSession(name: string): Promise { + await executeTmux(`new-session -d -s "${name}"`); + return findSessionByName(name); +} + +/** + * Create a new window in a session + */ +export async function createWindow( + sessionId: string, + name: string, + command?: string +): Promise<(TmuxWindow & { paneId: string; output?: string }) | null> { + const output = await executeTmux(`new-window -t '${sessionId}' -n '${name}'`); + const windows = await listWindows(sessionId); + const window = windows.find((window) => window.name === name); + + if (!window) return null; + + // Get the default pane created with the window + const panes = await listPanes(window.id); + const defaultPane = panes[0]; + + let commandOutput: string | undefined; + + // If command is provided, execute it in the new pane + if (command && defaultPane) { + await sendText({ + paneId: defaultPane.id, + text: command, + pressEnter: true, + }); + + // Sleep for 1 second to allow command to execute + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Capture the pane content + commandOutput = await capturePaneContent(defaultPane.id, 200, false); + } + + return { + ...window, + paneId: defaultPane?.id || "", + output: commandOutput, + }; +} + +/** + * Kill a tmux session by ID + */ +export async function killSession(sessionId: string): Promise { + await executeTmux(`kill-session -t '${sessionId}'`); +} + +/** + * Kill a tmux window by ID + */ +export async function killWindow(windowId: string): Promise { + await executeTmux(`kill-window -t '${windowId}'`); +} + +/** + * Kill a tmux pane by ID + */ +export async function killPane(paneId: string): Promise { + await executeTmux(`kill-pane -t '${paneId}'`); +} + +/** + * Rename a tmux window by ID + */ +export async function renameWindow( + windowId: string, + name: string +): Promise { + await executeTmux(`rename-window -t '${windowId}' '${name}'`); +} + +/** + * Split a tmux pane horizontally or vertically + */ +export async function splitPane( + targetPaneId: string, + direction: "horizontal" | "vertical" = "vertical", + size?: number +): Promise { + // Build the split-window command + let splitCommand = "split-window"; + + // Add direction flag (-h for horizontal, -v for vertical) + if (direction === "horizontal") { + splitCommand += " -h"; + } else { + splitCommand += " -v"; + } + + // Add target pane + splitCommand += ` -t '${targetPaneId}'`; + + // Add size if specified (as percentage) + if (size !== undefined && size > 0 && size < 100) { + splitCommand += ` -p ${size}`; + } + + // Execute the split command + await executeTmux(splitCommand); + + // Get the window ID from the target pane to list all panes + const windowInfo = await executeTmux( + `display-message -p -t '${targetPaneId}' '#{window_id}'` + ); + + // List all panes in the window to find the newly created one + const panes = await listPanes(windowInfo); + + // The newest pane is typically the last one in the list + return panes.length > 0 ? panes[panes.length - 1] : null; +} + +// Map to track ongoing command executions +const activeCommands = new Map(); + +const startMarkerText = "TMUX_MCP_START"; +const endMarkerPrefix = "TMUX_MCP_DONE_"; + +// Execute a command in a tmux pane and track its execution +export async function executeCommand( + paneId: string, + command: string, + rawMode?: boolean, + noEnter?: boolean +): Promise { + // Generate unique ID for this command execution + const commandId = uuidv4(); + + let fullCommand: string; + if (rawMode || noEnter) { + fullCommand = command; + } else { + const endMarkerText = getEndMarkerText(); + fullCommand = `echo "${startMarkerText}"; ${command}; echo "${endMarkerText}"`; + } + + // Store command in tracking map + activeCommands.set(commandId, { + id: commandId, + paneId, + command, + status: "pending", + startTime: new Date(), + rawMode: rawMode || noEnter, + }); + + // Send the command to the tmux pane + if (noEnter) { + // Check if this is a special key or key combination + // Special keys in tmux are typically capitalized or have special names + const specialKeys = [ + "Up", + "Down", + "Left", + "Right", + "Escape", + "Tab", + "Enter", + "Space", + "BSpace", + "Delete", + "Home", + "End", + "PageUp", + "PageDown", + "F1", + "F2", + "F3", + "F4", + "F5", + "F6", + "F7", + "F8", + "F9", + "F10", + "F11", + "F12", + "BTab", + ]; + + // Split the command into parts to handle combinations like "C-b" or "M-x" + const parts = fullCommand.split("-"); + const isSpecialKey = parts.length === 1 && specialKeys.includes(parts[0]); + const isKeyCombo = + parts.length > 1 && + (parts[0] === "C" || // Control + parts[0] === "M" || // Meta/Alt + parts[0] === "S"); // Shift + + if (isSpecialKey || isKeyCombo) { + // Send special key or key combination as-is + await executeTmux(`send-keys -t '${paneId}' ${fullCommand}`); + } else { + // For regular text, send each character individually to ensure proper processing + // This handles both single characters (like 'q', 'f') and strings (like 'beam') + for (const char of fullCommand) { + await executeTmux( + `send-keys -t '${paneId}' '${char.replace(/'/g, "'\\''")}'` + ); + } + } + } else { + await executeTmux( + `send-keys -t '${paneId}' '${fullCommand.replace(/'/g, "'\\''")}' Enter` + ); + } + + return commandId; +} + +export async function checkCommandStatus( + commandId: string +): Promise { + const command = activeCommands.get(commandId); + if (!command) return null; + + if (command.status !== "pending") return command; + + const content = await capturePaneContent(command.paneId, 1000); + + if (command.rawMode) { + command.result = + "Status tracking unavailable for rawMode commands. Use capture-pane to monitor interactive apps instead."; + return command; + } + + // Find the last occurrence of the markers + const startIndex = content.lastIndexOf(startMarkerText); + const endIndex = content.lastIndexOf(endMarkerPrefix); + + if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) { + command.result = "Command output could not be captured properly"; + return command; + } + + // Extract exit code from the end marker line + const endLine = content.substring(endIndex).split("\n")[0]; + const endMarkerRegex = new RegExp(`${endMarkerPrefix}(\\d+)`); + const exitCodeMatch = endLine.match(endMarkerRegex); + + if (exitCodeMatch) { + const exitCode = parseInt(exitCodeMatch[1], 10); + + command.status = exitCode === 0 ? "completed" : "error"; + command.exitCode = exitCode; + + // Extract output between the start and end markers + const outputStart = startIndex + startMarkerText.length; + const outputContent = content.substring(outputStart, endIndex).trim(); + + command.result = outputContent + .substring(outputContent.indexOf("\n") + 1) + .trim(); + + // Update in map + activeCommands.set(commandId, command); + } + + return command; +} + +// Get command by ID +export function getCommand(commandId: string): CommandExecution | null { + return activeCommands.get(commandId) || null; +} + +// Get all active command IDs +export function getActiveCommandIds(): string[] { + return Array.from(activeCommands.keys()); +} + +// Clean up completed commands older than a certain time +export function cleanupOldCommands(maxAgeMinutes: number = 60): void { + const now = new Date(); + + for (const [id, command] of activeCommands.entries()) { + const ageMinutes = + (now.getTime() - command.startTime.getTime()) / (1000 * 60); + + if (command.status !== "pending" && ageMinutes > maxAgeMinutes) { + activeCommands.delete(id); + } + } +} + +function getEndMarkerText(): string { + return shellConfig.type === "fish" + ? `${endMarkerPrefix}$status` + : `${endMarkerPrefix}$?`; +} + +// New consolidated API functions + +export type ListScope = "all" | "sessions" | "session" | "window" | "pane"; + +interface SessionWithWindows extends TmuxSession { + windowDetails?: WindowWithPanes[]; +} + +interface WindowWithPanes extends TmuxWindow { + paneDetails?: TmuxPane[]; +} + +export async function list({ + scope, + target, +}: { + scope: ListScope; + target?: string; +}): Promise< + | SessionWithWindows[] + | TmuxSession[] + | TmuxWindow[] + | TmuxPane[] + | TmuxSession + | TmuxWindow + | TmuxPane +> { + if (scope === "all") { + const sessions = await listSessions(); + const sessionsWithDetails: SessionWithWindows[] = []; + + for (const session of sessions) { + const windows = await listWindows(session.id); + const windowsWithPanes: WindowWithPanes[] = []; + + for (const window of windows) { + const panes = await listPanes(window.id); + windowsWithPanes.push({ + ...window, + paneDetails: panes, + }); + } + + sessionsWithDetails.push({ + ...session, + windowDetails: windowsWithPanes, + }); + } + + return sessionsWithDetails; + } + + if (scope === "sessions") { + return listSessions(); + } + + if (scope === "session") { + if (!target) { + throw new Error("target is required for scope 'session'"); + } + return listWindows(target); + } + + if (scope === "window") { + if (!target) { + throw new Error("target is required for scope 'window'"); + } + return listPanes(target); + } + + if (scope === "pane") { + if (!target) { + throw new Error("target is required for scope 'pane'"); + } + const windowId = await executeTmux( + `display-message -p -t '${target}' '#{window_id}'` + ); + const panes = await listPanes(windowId); + const pane = panes.find((p) => p.id === target); + if (!pane) { + throw new Error(`Pane not found: ${target}`); + } + return pane; + } + + throw new Error(`Invalid scope: ${scope}`); +} + +export type KillScope = "session" | "window" | "pane"; + +export async function kill({ + scope, + target, +}: { + scope: KillScope; + target: string; +}): Promise { + if (scope === "session") { + return killSession(target); + } + + if (scope === "window") { + return killWindow(target); + } + + if (scope === "pane") { + return killPane(target); + } + + throw new Error(`Invalid scope: ${scope}`); +} + +export interface ShellCommandResult { + command: string; + status: "completed" | "error"; + exitCode: number; + output: string; +} + +export async function executeShellCommand({ + paneId, + command, + timeout = 30000, +}: { + paneId: string; + command: string; + timeout?: number; +}): Promise { + const commandId = uuidv4(); + const endMarkerText = getEndMarkerText(); + const fullCommand = `echo "${startMarkerText}"; ${command}; echo "${endMarkerText}"`; + + activeCommands.set(commandId, { + id: commandId, + paneId, + command, + status: "pending", + startTime: new Date(), + rawMode: false, + }); + + await executeTmux( + `send-keys -t '${paneId}' '${fullCommand.replace(/'/g, "'\\''")}' Enter` + ); + + // Poll for completion + const startTime = Date.now(); + const pollInterval = 100; + + while (Date.now() - startTime < timeout) { + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + + const result = await checkCommandStatus(commandId); + + if (result && result.status !== "pending") { + // Cleanup + activeCommands.delete(commandId); + + return { + command: result.command, + status: result.status, + exitCode: result.exitCode!, + output: result.result || "", + }; + } + } + + // Timeout + activeCommands.delete(commandId); + throw new Error( + `Command timed out after ${timeout}ms. Use capture-pane to check pane state.` + ); +} + +export async function sendKeys({ + paneId, + keys, + repeat = 1, + return_output, +}: { + paneId: string; + keys: string; + repeat?: number; + return_output?: { lines?: number; wait?: number }; +}): Promise { + // Repeat the key press the specified number of times + for (let i = 0; i < repeat; i++) { + // Raw pass-through, no validation or processing + await executeTmux(`send-keys -t '${paneId}' ${keys}`); + } + + // If return_output is requested, wait and capture pane content + if (return_output) { + await new Promise((resolve) => + setTimeout(resolve, return_output.wait ?? 500) + ); + const lines = return_output.lines || 200; + return capturePaneContent(paneId, lines, false); + } +} + +export async function sendText({ + paneId, + text, + pressEnter = false, + return_output, +}: { + paneId: string; + text: string; + pressEnter?: boolean; + return_output?: { lines?: number; wait?: number }; +}): Promise { + // Send each character with -l flag for literal interpretation + for (const char of text) { + await executeTmux( + `send-keys -l -t '${paneId}' '${char.replace(/'/g, "'\\''")}'` + ); + } + + if (pressEnter) { + await new Promise((resolve) => setTimeout(resolve, 300)); + await executeTmux(`send-keys -t '${paneId}' Enter`); + } + + // If return_output is requested, wait and capture pane content + if (return_output) { + if (return_output.wait) { + await new Promise((resolve) => setTimeout(resolve, return_output.wait)); + } + const lines = return_output.lines || 200; + return capturePaneContent(paneId, lines, false); + } +} diff --git a/packages/mcp-server/test-http.sh b/packages/mcp-server/test-http.sh new file mode 100755 index 000000000..1d1767f45 --- /dev/null +++ b/packages/mcp-server/test-http.sh @@ -0,0 +1,47 @@ +#!/bin/bash + +# Test MCP HTTP server with curl +# Usage: ./test-http.sh + +PASSWORD="dev-password" +BASE_URL="http://localhost:3000" + +echo "Testing MCP HTTP server..." +echo + +# Test 1: Health check +echo "1. Health check:" +curl -s "$BASE_URL/" | jq . +echo + +# Test 2: MCP initialize request +echo "2. Sending MCP initialize request:" +curl -s -X POST "$BASE_URL/messages?password=$PASSWORD" \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { + "name": "test-client", + "version": "1.0.0" + } + } + }' | jq . +echo + +# Test 3: List tools +echo "3. Listing available tools:" +curl -s -X POST "$BASE_URL/messages?password=$PASSWORD" \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list" + }' | jq . +echo + +echo "Done!" diff --git a/packages/mcp-server/tsconfig.json b/packages/mcp-server/tsconfig.json new file mode 100644 index 000000000..a420f3024 --- /dev/null +++ b/packages/mcp-server/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./build", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "build"] +} diff --git a/packages/web/.claude/settings.local.json b/packages/web/.claude/settings.local.json new file mode 100644 index 000000000..d1a26f29e --- /dev/null +++ b/packages/web/.claude/settings.local.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "WebSearch", + "Read(//Users/moboudra/dev/tmux-mcp/**)" + ], + "deny": [], + "ask": [] + } +} diff --git a/packages/web/.env.local.example b/packages/web/.env.local.example new file mode 100644 index 000000000..24384626a --- /dev/null +++ b/packages/web/.env.local.example @@ -0,0 +1,3 @@ +OPENAI_API_KEY=sk-your-key-here +AUTH_PASSWORD=your-secure-password-here +MCP_SERVER_URL=https://your-mcp-server-url-here diff --git a/packages/web/.gitignore b/packages/web/.gitignore new file mode 100644 index 000000000..892067bc7 --- /dev/null +++ b/packages/web/.gitignore @@ -0,0 +1,33 @@ +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/packages/web/AUDIO_VISUALIZATION_PLAN.md b/packages/web/AUDIO_VISUALIZATION_PLAN.md new file mode 100644 index 000000000..daffc3c3e --- /dev/null +++ b/packages/web/AUDIO_VISUALIZATION_PLAN.md @@ -0,0 +1,237 @@ +# Audio Visualization Implementation Plan + +## Overview +Implement real-time audio feedback visualization using the native Web Audio API to show user voice activity through a volume bar indicator. + +## Architecture + +### Components Hierarchy +``` +Page (app/page.tsx) +└── VoiceClient (client component) + ├── Audio Controls (mute/unmute button) + ├── VolumeBar (visualization component) + └── WebRTC Connection Logic +``` + +## Implementation Steps + +### 1. Create Audio Analysis Hook (`use-audio-level.ts`) + +**Purpose:** Extract and calculate audio levels from MediaStream + +**Implementation Details:** +- Accept `MediaStream | null` as input +- Return normalized volume level (0-100) +- Use Web Audio API components: + - `AudioContext` - main audio processing context + - `AnalyserNode` - provides real-time frequency/time-domain analysis + - `MediaStreamAudioSourceNode` - connects MediaStream to analyser + +**Key Logic:** +```typescript +1. Create AudioContext when stream is available +2. Create AnalyserNode with fftSize = 2048 (provides 1024 frequency bins) +3. Create MediaStreamSource from the input stream +4. Connect: source → analyser (do NOT connect to destination to avoid feedback) +5. Start animation loop using requestAnimationFrame +6. In each frame: + - Call analyser.getByteTimeDomainData() to get audio samples + - Calculate RMS (Root Mean Square) for volume level + - Normalize to 0-100 range + - Update state +7. Cleanup: disconnect nodes and close context on unmount +``` + +**Performance Considerations:** +- Use `requestAnimationFrame` for optimal update timing (~60fps) +- Reuse `Uint8Array` buffer instead of creating new ones each frame +- Close AudioContext on cleanup to prevent memory leaks + +**Edge Cases:** +- Handle null/undefined stream gracefully +- Manage AudioContext state changes +- Handle browser autoplay policies (may require user gesture) + +### 2. Create Volume Bar Component (`volume-bar.tsx`) + +**Purpose:** Visual representation of audio level + +**Props:** +- `volume: number` (0-100 range) +- `isMuted?: boolean` (optional, for visual state) + +**Visual Design:** +``` +Container: Fixed height bar (e.g., 200px tall, 40px wide) +└── Fill: Height changes based on volume level + ├── Color zones: + │ ├── 0-60%: Green (#10b981) + │ ├── 60-80%: Yellow (#fbbf24) + │ └── 80-100%: Red (#ef4444) + └── Animation: Smooth transitions (transition: all 0.1s ease) +``` + +**States:** +- Active: Shows current volume with color-coded levels +- Muted: Grayed out or show muted icon overlay +- No stream: Empty/disabled state + +**Implementation:** +- Use CSS flexbox for vertical orientation +- Apply `transform: scaleY()` or dynamic height for bar fill +- Add subtle animations for smoothness +- Responsive design considerations + +### 3. Integrate into Voice Client Component + +**File:** `app/voice-client.tsx` (or similar) + +**Integration Flow:** +``` +1. Manage MediaStream state +2. Pass stream to useAudioLevel hook +3. Get volume value from hook +4. Pass volume to VolumeBar component +5. Update VolumeBar based on mute state +``` + +**State Management:** +```typescript +const [stream, setStream] = useState(null); +const [isMuted, setIsMuted] = useState(false); +const volume = useAudioLevel(stream); + +// When muting: keep stream active but disable audio track +stream?.getAudioTracks().forEach(track => { + track.enabled = !isMuted; +}); +``` + +**Mute/Unmute Logic:** +- Don't stop the stream when muting (keeps analysis running) +- Disable audio track: `track.enabled = false` +- Visual feedback: gray out volume bar or add overlay +- Optional: Stop showing volume updates when muted + +## Technical Specifications + +### Audio Analysis Parameters + +**AnalyserNode Configuration:** +- `fftSize: 2048` - Higher values = more frequency detail but slower +- `smoothingTimeConstant: 0.8` (default) - Smooths out rapid changes +- `frequencyBinCount: analyser.fftSize / 2` - Number of data points (1024) + +**Volume Calculation:** +```typescript +// Get time-domain data (waveform) +const dataArray = new Uint8Array(analyser.frequencyBinCount); +analyser.getByteTimeDomainData(dataArray); + +// Calculate RMS (Root Mean Square) +const rms = Math.sqrt( + dataArray.reduce((sum, value) => sum + value * value, 0) / dataArray.length +); + +// Normalize: Byte values are 0-255, normalize to 0-100 +const normalizedVolume = (rms / 255) * 100; + +// Optional: Apply scaling for better visual response +const scaledVolume = Math.min(100, normalizedVolume * 1.5); +``` + +### Alternative: Using Frequency Data +```typescript +// For more responsive visualization +analyser.getByteFrequencyData(dataArray); +const average = dataArray.reduce((sum, value) => sum + value, 0) / dataArray.length; +const volume = (average / 255) * 100; +``` + +## File Structure + +``` +app/ +├── page.tsx # Main page (server component) +├── voice-client.tsx # Client component with WebRTC +├── components/ +│ └── volume-bar.tsx # Volume visualization component +└── hooks/ + └── use-audio-level.ts # Audio analysis hook +``` + +## TypeScript Types + +```typescript +// hooks/use-audio-level.ts +interface UseAudioLevelOptions { + enabled?: boolean; + smoothing?: number; +} + +function useAudioLevel( + stream: MediaStream | null, + options?: UseAudioLevelOptions +): number; + +// components/volume-bar.tsx +interface VolumeBarProps { + volume: number; // 0-100 + isMuted?: boolean; + className?: string; +} +``` + +## Testing Considerations + +1. **Manual Testing:** + - Test with different microphone input levels + - Verify mute/unmute visual feedback + - Check performance (CPU usage should be minimal) + - Test on different browsers (Chrome, Firefox, Safari) + +2. **Edge Cases:** + - No microphone access granted + - Microphone disconnected mid-session + - Multiple rapid mute/unmute toggles + - Browser tab backgrounded (check if AudioContext suspends) + +3. **Visual Verification:** + - Bar should respond quickly to voice (< 100ms latency feel) + - Color transitions should be smooth + - Muted state should be clearly visible + +## Browser Compatibility + +**Web Audio API Support:** +- Chrome/Edge: Full support +- Firefox: Full support +- Safari: Full support (requires user gesture for AudioContext) + +**Important:** Safari requires a user interaction (e.g., button click) to start AudioContext due to autoplay policies. + +## Performance Optimization + +1. **Throttle/Debounce:** Already handled by `requestAnimationFrame` +2. **Memory:** Reuse typed arrays instead of creating new ones +3. **Cleanup:** Always disconnect nodes and close AudioContext +4. **Conditional Rendering:** Only run analysis when stream is active + +## Future Enhancements (Optional) + +- Add peak level indicator (shows max volume reached) +- Add threshold indicators for voice activity detection +- Implement waveform visualization as alternative view +- Add accessibility features (ARIA labels, screen reader support) +- Smoothing controls for users to adjust responsiveness +- Save user preferences (mute state, visualization style) + +## Implementation Order + +1. ✅ Create `use-audio-level.ts` hook (core functionality) +2. ✅ Create `volume-bar.tsx` component (visual display) +3. ✅ Integrate into voice client component +4. ✅ Test with microphone input +5. ✅ Refine visual styling and animations +6. ✅ Handle edge cases and error states diff --git a/packages/web/IMPLEMENTATION_PLAN.md b/packages/web/IMPLEMENTATION_PLAN.md new file mode 100644 index 000000000..7fc44ed0d --- /dev/null +++ b/packages/web/IMPLEMENTATION_PLAN.md @@ -0,0 +1,615 @@ +# Real-Time Voice App Implementation Plan + +## Overview +Build a Next.js application that connects directly to OpenAI's Realtime API using WebRTC for voice interaction with mute/unmute controls and audio visualization. + +## Tech Stack +- Next.js 14+ (App Router) +- TypeScript +- OpenAI Realtime API (WebRTC) +- Web Audio API (for visualization) +- Native browser WebRTC APIs + +## Project Structure + +``` +app/ +├── page.tsx # Main page (server component) +├── voice-client.tsx # Main client component +├── components/ +│ ├── volume-bar.tsx # Audio level visualization +│ └── mute-button.tsx # Mute/unmute control +├── hooks/ +│ ├── use-audio-level.ts # Audio analysis +│ └── use-webrtc-voice.ts # WebRTC connection logic +└── api/ + └── session/ + └── route.ts # Generate ephemeral tokens +``` + +## Environment Setup + +### Required Environment Variables +``` +OPENAI_API_KEY=sk-... +``` + +### Dependencies (package.json additions) +```json +{ + "dependencies": { + "next": "^14.0.0", + "react": "^18.0.0", + "react-dom": "^18.0.0", + "typescript": "^5.0.0" + } +} +``` + +**No additional dependencies needed** - using native browser APIs only. + +## Implementation Steps + +### Phase 1: Backend - Ephemeral Token Generation + +#### File: `app/api/session/route.ts` + +**Purpose:** Securely generate ephemeral tokens for WebRTC connection + +**Implementation:** +```typescript +import { NextResponse } from 'next/server'; + +export async function POST() { + const apiKey = process.env.OPENAI_API_KEY; + + if (!apiKey) { + return NextResponse.json( + { error: 'OpenAI API key not configured' }, + { status: 500 } + ); + } + + try { + const response = await fetch( + 'https://api.openai.com/v1/realtime/sessions', + { + method: 'POST', + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: 'gpt-4o-realtime-preview-2024-12-17', + voice: 'alloy', + }), + } + ); + + if (!response.ok) { + throw new Error(`OpenAI API error: ${response.status}`); + } + + const data = await response.json(); + return NextResponse.json(data); + } catch (error) { + console.error('Session creation error:', error); + return NextResponse.json( + { error: 'Failed to create session' }, + { status: 500 } + ); + } +} +``` + +**Key Details:** +- POST endpoint only +- Returns: `{ client_secret: { value: string, expires_at: number } }` +- Ephemeral token valid for 60 seconds +- Keep API key secure on server + +--- + +### Phase 2: WebRTC Connection Hook + +#### File: `app/hooks/use-webrtc-voice.ts` + +**Purpose:** Manage WebRTC connection to OpenAI Realtime API + +**State Management:** +```typescript +interface UseWebRTCVoiceReturn { + isConnected: boolean; + isConnecting: boolean; + error: string | null; + stream: MediaStream | null; + connect: () => Promise; + disconnect: () => void; +} +``` + +**Implementation Flow:** +``` +1. Request microphone access +2. Fetch ephemeral token from /api/session +3. Create RTCPeerConnection +4. Add microphone audio track to peer connection +5. Create data channel for events +6. Create and set local SDP offer +7. Send offer to OpenAI and get answer +8. Set remote SDP answer +9. Connection established +``` + +**Key Code Patterns:** +```typescript +// 1. Get microphone +const stream = await navigator.mediaDevices.getUserMedia({ + audio: true +}); + +// 2. Get ephemeral token +const response = await fetch('/api/session', { method: 'POST' }); +const { client_secret } = await response.json(); + +// 3. Create peer connection +const pc = new RTCPeerConnection(); + +// 4. Add audio track +stream.getTracks().forEach(track => pc.addTrack(track, stream)); + +// 5. Create data channel +const dc = pc.createDataChannel('oai-events'); + +// 6. Handle incoming audio +pc.ontrack = (event) => { + const audioElement = new Audio(); + audioElement.srcObject = event.streams[0]; + audioElement.play(); +}; + +// 7. Create offer +const offer = await pc.createOffer(); +await pc.setLocalDescription(offer); + +// 8. Send to OpenAI +const sdpResponse = await fetch( + `https://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview-2024-12-17`, + { + method: 'POST', + headers: { + 'Authorization': `Bearer ${client_secret.value}`, + 'Content-Type': 'application/sdp', + }, + body: offer.sdp, + } +); + +// 9. Set answer +const answer = { + type: 'answer' as RTCSdpType, + sdp: await sdpResponse.text(), +}; +await pc.setRemoteDescription(answer); +``` + +**Cleanup:** +```typescript +function disconnect() { + dataChannel?.close(); + peerConnection?.close(); + stream?.getTracks().forEach(track => track.stop()); + if (audioElement) { + audioElement.pause(); + audioElement.srcObject = null; + } +} +``` + +**Error Handling:** +- Microphone permission denied +- Network errors during token fetch +- WebRTC connection failures +- Token expiration + +--- + +### Phase 3: Audio Level Hook + +#### File: `app/hooks/use-audio-level.ts` + +**Purpose:** Calculate real-time audio levels from microphone stream + +**Implementation:** +```typescript +function useAudioLevel(stream: MediaStream | null): number { + const [volume, setVolume] = useState(0); + + useEffect(() => { + if (!stream) { + setVolume(0); + return; + } + + const audioContext = new AudioContext(); + const analyser = audioContext.createAnalyser(); + analyser.fftSize = 2048; + + const source = audioContext.createMediaStreamSource(stream); + source.connect(analyser); + + const dataArray = new Uint8Array(analyser.frequencyBinCount); + let animationId: number; + + function updateVolume() { + analyser.getByteTimeDomainData(dataArray); + + const rms = Math.sqrt( + dataArray.reduce((sum, val) => sum + val * val, 0) / dataArray.length + ); + + const normalized = (rms / 255) * 100; + setVolume(Math.min(100, normalized * 1.5)); + + animationId = requestAnimationFrame(updateVolume); + } + + updateVolume(); + + return () => { + cancelAnimationFrame(animationId); + source.disconnect(); + audioContext.close(); + }; + }, [stream]); + + return volume; +} +``` + +**Returns:** Volume level 0-100 + +--- + +### Phase 4: Volume Bar Component + +#### File: `app/components/volume-bar.tsx` + +**Purpose:** Visual representation of audio level + +**Props:** +```typescript +interface VolumeBarProps { + volume: number; // 0-100 + isMuted?: boolean; +} +``` + +**Visual Design:** +```typescript +function VolumeBar({ volume, isMuted }: VolumeBarProps) { + const getColor = () => { + if (isMuted) return 'bg-gray-300'; + if (volume > 80) return 'bg-red-500'; + if (volume > 60) return 'bg-yellow-400'; + return 'bg-green-500'; + }; + + return ( +
+
+
+ ); +} +``` + +**Styling:** +- Vertical bar, 40px wide, 192px tall +- Color zones: green → yellow → red +- Smooth transitions (100ms) +- Gray when muted + +--- + +### Phase 5: Mute Button Component + +#### File: `app/components/mute-button.tsx` + +**Purpose:** Toggle microphone mute state + +**Props:** +```typescript +interface MuteButtonProps { + isMuted: boolean; + onToggle: () => void; + disabled?: boolean; +} +``` + +**Implementation:** +```typescript +function MuteButton({ isMuted, onToggle, disabled }: MuteButtonProps) { + return ( + + ); +} +``` + +**States:** +- Active (not muted): Blue background +- Muted: Red background +- Disabled: Grayed out when not connected + +--- + +### Phase 6: Main Voice Client Component + +#### File: `app/voice-client.tsx` + +**Purpose:** Main client component orchestrating all functionality + +**Implementation:** +```typescript +'use client'; + +import { useState } from 'react'; +import { useWebRTCVoice } from './hooks/use-webrtc-voice'; +import { useAudioLevel } from './hooks/use-audio-level'; +import VolumeBar from './components/volume-bar'; +import MuteButton from './components/mute-button'; + +export default function VoiceClient() { + const [isMuted, setIsMuted] = useState(false); + const { + isConnected, + isConnecting, + error, + stream, + connect, + disconnect + } = useWebRTCVoice(); + + const volume = useAudioLevel(stream); + + function handleMuteToggle() { + if (!stream) return; + + stream.getAudioTracks().forEach(track => { + track.enabled = !isMuted; + }); + + setIsMuted(!isMuted); + } + + return ( +
+

Real-Time Voice

+ + {error && ( +
+ {error} +
+ )} + +
+ + +
+ {!isConnected ? ( + + ) : ( + <> + + + + )} +
+
+ + {isConnected && ( +

+ Connected - Speak to interact with AI +

+ )} +
+ ); +} +``` + +**State Management:** +- `isMuted` - local mute state +- `isConnected` - from WebRTC hook +- `volume` - from audio level hook +- `stream` - MediaStream from WebRTC hook + +**User Flow:** +1. Click "Start Voice Chat" → requests mic permission → connects +2. Volume bar shows audio level +3. Click "Mute" → disables audio track, grays out volume bar +4. Click "Unmute" → re-enables audio track +5. Click "Disconnect" → closes connection, stops mic + +--- + +### Phase 7: Main Page + +#### File: `app/page.tsx` + +**Purpose:** Entry point, server component wrapper + +**Implementation:** +```typescript +import VoiceClient from './voice-client'; + +export default function Home() { + return ; +} +``` + +**Simple wrapper** - all logic in client component + +--- + +## Configuration Files + +### TypeScript Config (`tsconfig.json`) +Ensure these compiler options: +```json +{ + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext"], + "target": "ES2017", + "strict": true + } +} +``` + +### Tailwind Config +Standard Next.js Tailwind setup - no special configuration needed + +--- + +## Testing Checklist + +### Functionality +- [ ] Microphone permission request works +- [ ] Connection establishes successfully +- [ ] Can hear AI responses through speakers +- [ ] AI can hear user input +- [ ] Mute button disables microphone +- [ ] Unmute button re-enables microphone +- [ ] Volume bar reflects audio level +- [ ] Volume bar grays out when muted +- [ ] Disconnect stops all streams +- [ ] Error messages display correctly + +### Browser Testing +- [ ] Chrome/Edge (primary target) +- [ ] Firefox +- [ ] Safari (note: may need user gesture for AudioContext) + +### Edge Cases +- [ ] Microphone permission denied +- [ ] No microphone available +- [ ] Network errors during connection +- [ ] Token expiration (60 second timeout) +- [ ] Rapid mute/unmute toggles +- [ ] Disconnect and reconnect +- [ ] Browser tab backgrounded + +--- + +## Implementation Order + +### Step 1: Setup (5 min) +1. Verify Next.js project initialized +2. Verify OPENAI_API_KEY in .env.local +3. Ensure TypeScript configured + +### Step 2: Backend (10 min) +1. Create `app/api/session/route.ts` +2. Test endpoint with curl or Postman +3. Verify token generation + +### Step 3: Components (15 min) +1. Create `app/components/volume-bar.tsx` +2. Create `app/components/mute-button.tsx` +3. Test with mock data + +### Step 4: Hooks (30 min) +1. Create `app/hooks/use-audio-level.ts` +2. Test with microphone input +3. Create `app/hooks/use-webrtc-voice.ts` +4. Test WebRTC connection + +### Step 5: Integration (15 min) +1. Create `app/voice-client.tsx` +2. Update `app/page.tsx` +3. Wire all components together + +### Step 6: Testing (20 min) +1. Test complete user flow +2. Test error cases +3. Verify audio quality +4. Check performance + +### Step 7: Polish (10 min) +1. Refine styling +2. Improve error messages +3. Add loading states + +**Total Estimated Time: ~2 hours** + +--- + +## Common Issues & Solutions + +### Issue: AudioContext suspended +**Solution:** Safari requires user gesture. Ensure AudioContext created after user clicks connect. + +### Issue: No audio output +**Solution:** Check if Audio element is created and `play()` called. Verify speakers/volume. + +### Issue: Microphone not working +**Solution:** Check browser permissions. Ensure HTTPS (required for getUserMedia). + +### Issue: Connection fails +**Solution:** Verify token not expired (60s limit). Check network console for errors. + +### Issue: High latency +**Solution:** WebRTC should be low-latency by default. Check network connection quality. + +--- + +## Future Enhancements (Not in Initial Scope) + +- Session configuration (change voice, temperature) +- Text transcript display +- Function calling support +- Audio recording/playback +- Multiple conversation modes +- Voice activity detection threshold +- Connection quality indicator +- Reconnection logic on disconnect + +--- + +## Success Criteria + +✅ User can click connect and start talking to AI +✅ AI responses are audible and clear +✅ Mute/unmute works correctly +✅ Volume bar shows real-time audio levels +✅ Error states handled gracefully +✅ Connection can be cleanly disconnected +✅ Code is clean, typed, and maintainable diff --git a/packages/web/MCP_INTEGRATION.md b/packages/web/MCP_INTEGRATION.md new file mode 100644 index 000000000..2d5b44d38 --- /dev/null +++ b/packages/web/MCP_INTEGRATION.md @@ -0,0 +1,100 @@ +# MCP Integration + +## Overview + +This application now integrates with a Model Context Protocol (MCP) server to extend the AI's capabilities with custom tools. + +## How It Works + +The integration uses **Remote MCP Server** mode, where OpenAI's servers directly communicate with your MCP server: + +1. During session creation, the app sends MCP configuration to OpenAI +2. OpenAI connects to your MCP server and discovers available tools +3. During conversation, when the AI needs to use a tool, it calls your MCP server automatically +4. Your MCP server executes the tool and returns results to OpenAI +5. OpenAI incorporates the results into the conversation and responds to the user + +## Configuration + +The MCP server is configured in `app/api/session/route.ts`: + +```typescript +tools: [ + { + type: 'mcp', + server_label: 'local-mcp', + server_url: 'https://mohameds-macbook-pro.tail8fe838.ts.net/mcp?password=dev-password', + require_approval: 'never', + }, +] +``` + +### Configuration Options + +- **`type`**: Must be `'mcp'` for MCP server integration +- **`server_label`**: A friendly name for your MCP server (used in logs/debugging) +- **`server_url`**: The HTTPS URL of your MCP server (including query parameters for auth) +- **`require_approval`**: Set to `'never'` for automatic tool execution, or `'always'` for manual approval + +## Tool Discovery + +The MCP protocol automatically discovers all available tools from your server. You don't need to: +- Manually define each tool +- Handle function call events in the client +- Send results back to OpenAI + +Everything is handled automatically by OpenAI's servers. + +## Benefits + +1. **Zero Client Complexity**: No need to monitor WebRTC data channels or handle function calls +2. **Automatic Tool Discovery**: MCP servers expose their available tools automatically +3. **Scalable**: Easy to add more MCP servers or switch between them +4. **Production Ready**: This is OpenAI's recommended approach for tool integration + +## Testing + +To test the MCP integration: + +1. Start the development server: `npm run dev` +2. Open http://localhost:3000 +3. Click "Start Voice Chat" +4. Ask the AI to perform actions that require your MCP tools +5. The AI will automatically use the appropriate tools and respond + +Example prompts to test (depending on your MCP tools): +- "What tools do you have access to?" +- "Can you [action that requires your MCP tool]?" + +## Changing MCP Configuration + +To use a different MCP server or modify settings: + +1. Edit `app/api/session/route.ts` +2. Update the `tools` array in the session creation +3. Rebuild: `npm run build` +4. Restart the server + +## Security Notes + +- The MCP server URL includes authentication in the query string (`?password=dev-password`) +- This is acceptable for development but consider using proper OAuth or API keys for production +- The `require_approval: 'never'` setting means tools execute automatically without confirmation +- For production, consider using `require_approval: 'always'` with a UI approval mechanism + +## Troubleshooting + +### Tools Not Working +- Verify your MCP server is accessible at the configured URL +- Check that the password/auth is correct +- Ensure your MCP server is returning valid MCP protocol responses + +### Session Creation Fails +- Check OpenAI API key is valid +- Verify the MCP server URL is properly formatted +- Check network connectivity to your MCP server + +### AI Doesn't Use Tools +- The AI decides when to use tools based on the conversation context +- Try more explicit prompts that clearly require tool usage +- Verify tools are properly exposed by your MCP server diff --git a/packages/web/MCP_INTEGRATION_RESEARCH.md b/packages/web/MCP_INTEGRATION_RESEARCH.md new file mode 100644 index 000000000..d2ef2cf5b --- /dev/null +++ b/packages/web/MCP_INTEGRATION_RESEARCH.md @@ -0,0 +1,232 @@ +# MCP Integration Research & Implementation Plan + +## Executive Summary + +Based on research into OpenAI's Realtime API and MCP (Model Context Protocol) integration, here's what I've discovered: + +### Key Finding: Two Approaches Available + +1. **Remote MCP Server** (Recommended for your use case) +2. **Client-Side Function Calling** (Traditional approach) + +--- + +## Approach 1: Remote MCP Server (RECOMMENDED) + +### How It Works + +**OpenAI executes the MCP calls on their servers, not your client.** + +When you configure a remote MCP server URL in the session configuration: +1. You provide the MCP server URL during session creation +2. OpenAI's servers connect to your MCP server directly +3. When the AI needs to call a tool, OpenAI's servers make the request to your MCP server +4. The MCP server returns results to OpenAI's servers +5. OpenAI processes the results and continues the conversation + +### Configuration + +```typescript +// In app/api/session/route.ts +const response = await fetch( + 'https://api.openai.com/v1/realtime/sessions', + { + method: 'POST', + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: 'gpt-4o-realtime-preview-2024-12-17', + voice: 'alloy', + tools: [ + { + type: 'mcp', + server_label: 'your-mcp-label', + server_url: 'https://your-mcp-server.com', + authorization: 'optional-bearer-token', // If your MCP requires auth + require_approval: 'never' // or 'always' for manual approval + } + ] + }), + } +); +``` + +### Pros +- ✅ **Zero client-side complexity** - No need to handle function calls in your app +- ✅ **Automatic tool discovery** - MCP protocol auto-discovers available tools +- ✅ **Seamless integration** - OpenAI handles everything after configuration +- ✅ **No data channel monitoring** - No need to listen for function call events +- ✅ **Production-ready** - Officially supported by OpenAI + +### Cons +- ❌ **MCP server must be publicly accessible** - Needs HTTPS URL +- ❌ **Less control** - Can't intercept or modify tool calls client-side +- ❌ **Requires hosted MCP server** - Can't use local MCP tools directly + +### Security Considerations +- `require_approval: "always"` - Each tool call needs explicit approval (safer) +- `require_approval: "never"` - Automatic execution (faster, but requires trust) +- `authorization` header can be used to secure your MCP server + +--- + +## Approach 2: Client-Side Function Calling (Traditional) + +### How It Works + +**Your client executes the function calls, not OpenAI.** + +1. Configure tools in `session.update` event via WebRTC data channel +2. Listen for `response.output_item.done` events on the data channel +3. When `item.type === 'function_call'`, execute the function client-side +4. Send results back via `conversation.item.create` event +5. Trigger response generation with `response.create` event + +### Configuration + +```typescript +// Send via WebRTC data channel after connection +dataChannel.send(JSON.stringify({ + type: 'session.update', + session: { + tools: [ + { + type: 'function', + name: 'webSearch', + description: 'Performs an internet search', + parameters: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'The search query' + } + }, + required: ['query'] + } + } + ], + tool_choice: 'auto' + } +})); + +// Listen for function calls +dataChannel.onmessage = (event) => { + const message = JSON.parse(event.data); + + if (message.type === 'response.output_item.done') { + const item = message.item; + + if (item.type === 'function_call') { + // Execute function locally + const result = await executeFunction(item.name, JSON.parse(item.arguments)); + + // Send result back + dataChannel.send(JSON.stringify({ + type: 'conversation.item.create', + item: { + type: 'function_call_output', + call_id: item.call_id, + output: JSON.stringify(result) + } + })); + + // Request new response + dataChannel.send(JSON.stringify({ type: 'response.create' })); + } + } +}; +``` + +### Pros +- ✅ **Full control** - Can intercept, modify, or reject function calls +- ✅ **Local execution** - Can use local tools, no server needed +- ✅ **Custom logic** - Can add validation, rate limiting, etc. +- ✅ **Debugging** - Easier to debug function calls + +### Cons +- ❌ **Complex implementation** - Need to handle data channel events +- ❌ **Manual tool definition** - Must define every function manually +- ❌ **More code** - Need state management for function call flow +- ❌ **Error handling** - Must handle all failure cases + +--- + +## Recommendation for Your Project + +### Use Remote MCP Server (Approach 1) + +**Reasons:** +1. **You mentioned having an MCP URL** - This is exactly what remote MCP is designed for +2. **Simpler implementation** - Just add configuration to session creation +3. **Production-ready** - This is the new, recommended approach from OpenAI +4. **Scalable** - Easy to add more MCP servers or change them + +### Implementation Plan + +#### Phase 1: Update Session Creation (5 minutes) +1. Add MCP configuration to `/api/session/route.ts` +2. Accept MCP URL/config from environment variables or request body + +#### Phase 2: Environment Configuration (2 minutes) +1. Add MCP server URL to `.env.local` +2. Optionally add authorization token if needed + +#### Phase 3: Testing (10 minutes) +1. Test that session creates successfully +2. Test that AI can discover and use MCP tools +3. Test tool execution and responses + +#### Phase 4: UI Updates (Optional, 15 minutes) +1. Add ability to configure MCP URL from UI +2. Show when MCP tools are being used +3. Add approval mechanism if `require_approval: "always"` + +--- + +## Alternative: Client-Side Function Calling + +If you need **local tool execution** or **cannot host a public MCP server**, then use Approach 2. + +This would require: +1. Updating `use-webrtc-voice.ts` to handle data channel messages +2. Creating a tool registry/executor system +3. Implementing event listeners for function calls +4. Managing function call state and results + +**Estimated effort:** 2-3 hours for full implementation + +--- + +## Questions to Clarify + +Before implementing, please clarify: + +1. **Do you have a publicly accessible MCP server URL?** + - If yes → Use Remote MCP (Approach 1) + - If no → Use Client-Side Function Calling (Approach 2) + +2. **Does your MCP server require authentication?** + - If yes → We'll need to include the authorization header + +3. **Do you want manual approval for tool calls?** + - `require_approval: "always"` - Safer, requires UI for approval + - `require_approval: "never"` - Automatic, simpler implementation + +4. **Should MCP configuration be:** + - Hard-coded in session creation? + - Configurable via environment variables? + - Configurable via UI at runtime? + +--- + +## Next Steps + +Once you provide: +1. Your MCP server URL +2. Whether it requires authentication +3. Your approval preference + +I can implement the integration in approximately 10-20 minutes using the Remote MCP approach. diff --git a/packages/web/README.md b/packages/web/README.md new file mode 100644 index 000000000..fb610f919 --- /dev/null +++ b/packages/web/README.md @@ -0,0 +1,162 @@ +# Real-Time Voice App + +A Next.js application that connects directly to OpenAI's Realtime API using WebRTC for voice interaction with mute/unmute controls and audio visualization. + +## Features + +- Real-time voice interaction with OpenAI's GPT-4o Realtime API +- WebRTC-based audio streaming for low latency +- Live audio level visualization +- Mute/unmute controls +- Password authentication for access control +- Agent activity transparency (debug log panel) +- Real-time agent status display +- Tool call visibility +- Clean, minimal UI + +## Tech Stack + +- Next.js 14+ (App Router) +- TypeScript +- OpenAI Realtime API (WebRTC) +- Web Audio API (for visualization) +- Tailwind CSS +- Native browser WebRTC APIs + +## Setup + +1. Install dependencies: +```bash +npm install +``` + +2. Create a `.env.local` file in the root directory: +```bash +OPENAI_API_KEY=sk-your-api-key-here +AUTH_PASSWORD=your-secure-password-here +``` + +**Note**: The `AUTH_PASSWORD` is required to access the app. Users will be prompted to enter this password before they can use the voice interface. + +3. Run the development server: +```bash +npm run dev +``` + +4. Open [http://localhost:3000](http://localhost:3000) in your browser. + +## Important: HTTPS Requirement + +**This app requires a secure context (HTTPS or localhost) to access the microphone.** This is a browser security requirement, not a limitation of this app. + +### Desktop Development +- ✅ `http://localhost:3000` works (localhost is considered secure) +- ✅ Desktop browsers allow microphone access on localhost + +### Mobile Development + +Mobile browsers **require HTTPS** for microphone access. Localhost won't work on mobile. You have several options: + +#### Option 1: Cloudflare Tunnel (Recommended) +Free and easy to set up: +```bash +# Install cloudflared +# macOS +brew install cloudflare/cloudflare/cloudflared + +# Start tunnel +cloudflared tunnel --url http://localhost:3000 +``` +You'll get an HTTPS URL like `https://xxx.trycloudflare.com` that works on mobile. + +#### Option 2: ngrok +```bash +# Install ngrok from https://ngrok.com +ngrok http 3000 +``` +You'll get an HTTPS URL like `https://xxx.ngrok.io` + +#### Option 3: Tailscale (For your local network) +If you're already using Tailscale: +```bash +# Your app is accessible at: +https://your-machine-name.tailnet-name.ts.net:3000 +``` + +#### Option 4: Local HTTPS Certificate +Set up a local SSL certificate (more complex): +```bash +# Generate certificate +mkcert -install +mkcert localhost + +# Update next.config.js to use HTTPS +# (Requires additional Next.js configuration) +``` + +### Testing on Mobile +1. Set up one of the HTTPS options above +2. Access the HTTPS URL on your mobile device +3. Grant microphone permissions when prompted +4. The app should work normally + +## Usage + +1. Click "Start Voice Chat" to begin +2. Allow microphone access when prompted +3. Start speaking - the AI will respond in real-time +4. Use the "Mute" button to disable your microphone +5. The volume bar shows your audio level in real-time +6. Click "Disconnect" to end the session + +## Project Structure + +``` +app/ +├── page.tsx # Main page +├── voice-client.tsx # Main client component +├── components/ +│ ├── volume-bar.tsx # Audio level visualization +│ └── mute-button.tsx # Mute/unmute control +├── hooks/ +│ ├── use-audio-level.ts # Audio analysis hook +│ └── use-webrtc-voice.ts # WebRTC connection logic +└── api/ + └── session/ + └── route.ts # Generate ephemeral tokens +``` + +## How It Works + +1. **Token Generation**: The backend endpoint (`/api/session`) securely generates ephemeral tokens from OpenAI +2. **WebRTC Connection**: The client establishes a peer-to-peer WebRTC connection with OpenAI's servers +3. **Audio Streaming**: Microphone audio is streamed in real-time to OpenAI +4. **AI Responses**: OpenAI's responses are received and played through the browser's audio output +5. **Visualization**: Web Audio API analyzes the microphone input to display volume levels + +## Browser Compatibility + +- Chrome/Edge (recommended) +- Firefox +- Safari (may require user gesture for AudioContext) + +## Troubleshooting + +### Microphone not working +- Check browser permissions for microphone access +- Ensure you're using HTTPS or localhost +- Check system microphone settings + +### No audio output +- Check speaker/volume settings +- Verify audio permissions in browser +- Check browser console for errors + +### Connection fails +- Verify OPENAI_API_KEY is set correctly +- Check network connectivity +- Tokens expire after 60 seconds - reconnect if needed + +## License + +MIT diff --git a/packages/web/REALTIME_API_CONFIGURATION.md b/packages/web/REALTIME_API_CONFIGURATION.md new file mode 100644 index 000000000..b1ee2cbe3 --- /dev/null +++ b/packages/web/REALTIME_API_CONFIGURATION.md @@ -0,0 +1,315 @@ +# OpenAI Realtime API Configuration Options + +This document lists all available configuration parameters for the OpenAI Realtime API session. + +## Core Parameters + +### `model` +- **Type**: `string` +- **Default**: `'gpt-4o-realtime-preview-2024-12-17'` +- **Options**: `'gpt-realtime'`, `'gpt-4o-realtime-preview-2024-12-17'` +- **Description**: The Realtime model to use. `gpt-realtime` is the latest production model. + +### `voice` +- **Type**: `string` +- **Default**: `'alloy'` +- **Options**: `'alloy'`, `'echo'`, `'fable'`, `'onyx'`, `'nova'`, `'shimmer'` +- **Description**: Voice to use for AI speech generation + +### `modalities` +- **Type**: `array of strings` +- **Default**: `['text', 'audio']` +- **Options**: `['text']`, `['audio']`, `['text', 'audio']` +- **Description**: Response modalities to use. Set to `['text']` for text-only mode with separate TTS. + +### `temperature` +- **Type**: `float` +- **Default**: `0.8` +- **Range**: `0.6` to `1.2` +- **Description**: Controls response randomness. Lower values = more deterministic, higher values = more creative. + +### `max_response_output_tokens` +- **Type**: `integer` +- **Default**: Not specified +- **Example**: `2048` +- **Description**: Maximum number of tokens in the model's response. + +### `instructions` +- **Type**: `string` +- **Default**: None +- **Description**: System instructions for the model (like system messages in Chat API) + +--- + +## Audio Configuration + +### `input_audio_format` +- **Type**: `string` +- **Default**: `'pcm16'` +- **Options**: `'pcm16'`, `'g711_ulaw'`, `'g711_alaw'` +- **Description**: Format for input audio + +### `output_audio_format` +- **Type**: `string` +- **Default**: `'pcm16'` +- **Options**: `'pcm16'`, `'g711_ulaw'`, `'g711_alaw'` +- **Description**: Format for output audio + +### `input_audio_transcription` +- **Type**: `object` +- **Default**: None +- **Properties**: + - `model`: Transcription model (e.g., `'gpt-4o-transcribe'`) +- **Description**: Configuration for transcribing input audio +- **Example**: + ```json + { + "model": "gpt-4o-transcribe" + } + ``` + +### `input_audio_noise_reduction` (New in 2025) +- **Type**: `object | null` +- **Default**: `null` +- **Description**: Noise reduction configuration for input audio +- **Note**: Using noise reduction can impact latency, especially with semantic VAD turn detection +- **Status**: Recently added parameter, check latest API docs for configuration details + +--- + +## Turn Detection Configuration + +Controls when the AI should start/stop responding based on voice activity. + +### `turn_detection` +- **Type**: `object | null` +- **Default**: Server VAD mode +- **Description**: Configuration for turn detection + +#### Server VAD Mode (Default) +```json +{ + "type": "server_vad", + "threshold": 0.5, + "prefix_padding_ms": 300, + "silence_duration_ms": 500, + "create_response": true, + "interrupt_response": true +} +``` + +**Parameters**: +- `threshold` (float, 0.0-1.0): Voice activity detection threshold. Higher values (e.g., 0.7) work better in noisy environments. +- `prefix_padding_ms` (integer): Audio to include before speech starts (ms) +- `silence_duration_ms` (integer): Silence duration to wait before considering speech ended (ms) +- `create_response` (boolean): Automatically create response when turn detected +- `interrupt_response` (boolean): Allow user to interrupt AI responses + +#### Semantic VAD Mode +```json +{ + "type": "semantic_vad", + "eagerness": "auto", + "create_response": true, + "interrupt_response": true +} +``` + +**Parameters**: +- `eagerness` (string): `'low'`, `'medium'`, `'high'`, or `'auto'`. Controls how quickly the model responds. +- `create_response` (boolean): Automatically create response +- `interrupt_response` (boolean): Allow interruptions + +**Note**: Semantic VAD can have higher latency, especially with noise reduction enabled. + +#### Disable Turn Detection +```json +{ + "type": "none" +} +``` +Use this if you want manual control over when responses are generated. + +--- + +## Tool Configuration + +### `tools` +- **Type**: `array of objects` +- **Default**: `[]` +- **Description**: Tools available for the model to use + +#### Remote MCP Server Tool +```json +{ + "type": "mcp", + "server_label": "my-mcp-server", + "server_url": "https://example.com/mcp", + "authorization": "Bearer token", // Optional + "require_approval": "never" // or "always" +} +``` + +**Parameters**: +- `type`: Must be `'mcp'` for MCP servers +- `server_label`: Friendly name for the server +- `server_url`: HTTPS URL of the MCP server +- `authorization`: Optional auth header value +- `require_approval`: `'never'` for auto-execution, `'always'` for manual approval + +#### Function Tool (Client-Side) +```json +{ + "type": "function", + "name": "function_name", + "description": "What the function does", + "parameters": { + "type": "object", + "properties": { + "param1": { + "type": "string", + "description": "Parameter description" + } + }, + "required": ["param1"] + } +} +``` + +### `tool_choice` +- **Type**: `string` +- **Default**: `'auto'` +- **Options**: `'auto'`, `'required'`, `'none'` +- **Description**: Controls when tools are used + - `'auto'`: AI decides when to use tools + - `'required'`: AI must use a tool + - `'none'`: Tools disabled for this response + +--- + +## Current Configuration (app/api/session/route.ts) + +```typescript +{ + model: 'gpt-realtime', + voice: 'shimmer', + input_audio_transcription: { + model: 'gpt-4o-transcribe', + }, + tools: [ + { + type: 'mcp', + server_label: 'local-mcp', + server_url: 'https://mohameds-macbook-pro.tail8fe838.ts.net/mcp?password=dev-password', + require_approval: 'never', + }, + ], +} +``` + +--- + +## Recommended Settings for Different Use Cases + +### High Quality Conversation (Default) +```json +{ + "model": "gpt-realtime", + "temperature": 0.8, + "turn_detection": { + "type": "server_vad", + "threshold": 0.5 + } +} +``` + +### Noisy Environment +```json +{ + "model": "gpt-realtime", + "input_audio_noise_reduction": {}, + "turn_detection": { + "type": "server_vad", + "threshold": 0.7, // Higher threshold for noisy environments + "silence_duration_ms": 700 // Wait longer for silence + } +} +``` + +### More Deterministic Responses +```json +{ + "model": "gpt-realtime", + "temperature": 0.6, + "max_response_output_tokens": 1024 +} +``` + +### Fast, Responsive Conversation +```json +{ + "model": "gpt-realtime", + "turn_detection": { + "type": "semantic_vad", + "eagerness": "high" + } +} +``` + +### Manual Control (No Auto-Response) +```json +{ + "model": "gpt-realtime", + "turn_detection": { + "type": "none" + } +} +``` + +--- + +## Notes on Noise Reduction + +**Availability**: `input_audio_noise_reduction` is a newly added parameter (2025). + +**Pros**: +- Improves recognition in noisy environments +- Better handling of background noise + +**Cons**: +- Can increase latency, especially with semantic VAD +- May affect audio quality in some cases + +**Recommendation**: Test with and without noise reduction to see if the latency trade-off is worth it for your use case. + +--- + +## Client-Side Audio Processing + +In addition to server-side configuration, consider client-side audio processing: + +1. **Browser WebRTC**: Modern browsers include: + - Echo cancellation + - Noise suppression + - Automatic gain control + +2. **getUserMedia constraints**: + ```javascript + navigator.mediaDevices.getUserMedia({ + audio: { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true + } + }); + ``` + +These are currently **not configured** in the app but can be added in `use-webrtc-voice.ts` for additional audio quality improvements. + +--- + +## References + +- [OpenAI Realtime API Documentation](https://platform.openai.com/docs/guides/realtime) +- [OpenAI Realtime API Reference](https://platform.openai.com/docs/api-reference/realtime) +- [LiveKit OpenAI Plugin Docs](https://docs.livekit.io/agents/integrations/realtime/openai/) diff --git a/packages/web/app/api/auth/route.ts b/packages/web/app/api/auth/route.ts new file mode 100644 index 000000000..cfc93a6f2 --- /dev/null +++ b/packages/web/app/api/auth/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from 'next/server'; + +export async function POST(request: Request) { + try { + const { password } = await request.json(); + + const correctPassword = process.env.AUTH_PASSWORD; + + if (!correctPassword) { + return NextResponse.json( + { authenticated: false, error: 'Authentication not configured' }, + { status: 500 } + ); + } + + if (password === correctPassword) { + return NextResponse.json({ authenticated: true }); + } + + return NextResponse.json( + { authenticated: false, error: 'Invalid password' }, + { status: 401 } + ); + } catch (error) { + return NextResponse.json( + { authenticated: false, error: 'Invalid request' }, + { status: 400 } + ); + } +} diff --git a/packages/web/app/api/session/route.ts b/packages/web/app/api/session/route.ts new file mode 100644 index 000000000..a5b7ef6a0 --- /dev/null +++ b/packages/web/app/api/session/route.ts @@ -0,0 +1,63 @@ +import { NextResponse } from 'next/server'; +import { SYSTEM_PROMPT } from './system-prompt'; + +export async function POST() { + const apiKey = process.env.OPENAI_API_KEY; + const mcpServerUrl = process.env.MCP_SERVER_URL; + + if (!apiKey) { + return NextResponse.json( + { error: 'OpenAI API key not configured' }, + { status: 500 } + ); + } + + if (!mcpServerUrl) { + return NextResponse.json( + { error: 'MCP server URL not configured' }, + { status: 500 } + ); + } + + try { + const response = await fetch( + 'https://api.openai.com/v1/realtime/sessions', + { + method: 'POST', + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: 'gpt-realtime', + voice: 'shimmer', + instructions: SYSTEM_PROMPT, + input_audio_transcription: { + model: 'gpt-4o-transcribe', + }, + tools: [ + { + type: 'mcp', + server_label: 'tmux-mcp', + server_url: mcpServerUrl, + require_approval: 'never', + }, + ], + }), + } + ); + + if (!response.ok) { + throw new Error(`OpenAI API error: ${response.status}`); + } + + const data = await response.json(); + return NextResponse.json(data); + } catch (error) { + console.error('Session creation error:', error); + return NextResponse.json( + { error: 'Failed to create session' }, + { status: 500 } + ); + } +} diff --git a/packages/web/app/api/session/system-prompt.ts b/packages/web/app/api/session/system-prompt.ts new file mode 100644 index 000000000..4c4294404 --- /dev/null +++ b/packages/web/app/api/session/system-prompt.ts @@ -0,0 +1,451 @@ +export const SYSTEM_PROMPT = `# Virtual Assistant Instructions + +## Your Role + +You are a virtual assistant with direct access to the user's tmux environment on their laptop. Your primary purpose is to help them code and manage their development workflow, especially through Claude Code running in tmux sessions. + +## Connection Setup + +- **Environment**: You connect remotely to the user's laptop running tmux +- **User's device**: They typically code from their **phone** +- **Key implication**: Be mindful of voice-to-text issues, autocorrect problems, and typos +- **Projects location**: All projects are in ~/dev +- **GitHub CLI**: gh command is available and already authenticated - use it for GitHub operations + +## Important Behavioral Guidelines + +### Response Pattern: ALWAYS Talk First, Then Act + +**CRITICAL**: For voice interactions, ALWAYS provide verbal acknowledgment BEFORE executing tool calls. + +**Pattern to follow:** +1. **Acknowledge** what you heard: "Got it, I'll [action]" +2. **Briefly explain** what you're about to do (1-2 sentences max) +3. **Then execute** the tool calls +4. **Report back** what happened after the tools complete + +**Examples:** + +User: "Can you check the git status?" +You: "Sure, let me check the git status for you." +[Then execute tool call] + +User: "Create a new worktree for the feature" +You: "Okay, I'll create a new worktree with a feature branch." +[Then execute tool call] + +User: "Start Claude Code in plan mode" +You: "Starting Claude Code and switching to plan mode." +[Then execute tool calls] + +**Why this matters:** +- Voice users need confirmation they were heard +- Creates natural conversation flow +- Prevents awkward silence while tools execute +- Builds trust through responsiveness + +### Communication Style +- **Confirm commands** before executing, especially destructive operations +- **Be patient** with spelling errors and voice-related mistakes +- **Clarify ambiguous requests** rather than guessing +- **Acknowledge typos naturally** without making a big deal of it +- **Use clear, concise language** - mobile screens are small + +### Mobile-Friendly Responses +- Keep responses scannable and well-structured +- Use bullet points and headers effectively +- Avoid overwhelming walls of text +- Highlight important information with bold + +## Multi-Agent Workflow + +### Agent Roles and Setup + +The user typically works with **multiple Claude Code agents** for different purposes: + +1. **Implementer Agent** - Makes code changes, implements features, fixes bugs +2. **Review Agent** - Analyzes code, verifies changes, provides unbiased assessment (fresh context) +3. **Command Window** - Separate window for running direct commands (git, gh, testing, etc.) + +**Why multiple agents?** The review agent has fresh context and can provide unbiased analysis without the implementation history. + +### CRITICAL: Agent Permission Mode Management + +**When working with multiple agents:** + +1. **IMMEDIATELY set review agents to default permission mode** - Do this proactively without being asked + - Review agents must NEVER make changes + - Switch them to default mode using: send-keys "BTab" repeat=X until no bypass indicator shows + +2. **Implementer agents can use bypass mode** for efficient execution + - These agents are expected to make changes + +3. **Check and report agent permission modes at the start of each multi-agent task** + - Example: "Review agent (pane %574): Default mode ✓, Implementer agent (pane %567): Bypass mode ✓" + +4. **Keep window names accurate and up to date** + - Use descriptive names: "implementer", "review", "commands", etc. + - Update names when purposes change + +### Agent Coordination Rules + +**Let agents complete analysis first:** +- When multiple agents are working in parallel, let them complete their analysis and report findings BEFORE providing additional context +- Don't feed new information to agents mid-task unless explicitly asked by the user +- Report consolidated findings: "Review agent found X, Implementer agent found Y" before asking how to proceed + +**Agent lifecycle management:** +- **DEFAULT: Keep Claude Code sessions running** until explicitly told to close them +- Only close sessions when the user specifically asks +- Never proactively suggest closing agents unless there's a clear reason + +### Closing Claude Code Sessions + +When explicitly asked to close a Claude Code session, use ONE of these methods: + +**Method 1: Exit command (preferred)** +send-text "/exit" pressEnter=true return_output={lines: 20} + +**Method 2: Interrupt signal** +send-keys "C-c" repeat=2 return_output={lines: 20} + +## Claude Code Setup + +### What is Claude Code? +Claude Code is a command-line tool that runs an AI coding agent in the terminal. The user launches it within tmux with: +claude --dangerously-skip-permissions + +**Important**: The --dangerously-skip-permissions flag enables the bypass permissions mode option. Without this flag, only the default mode and plan mode are available. + +### Vim Mode Input System +**CRITICAL**: Claude Code's input uses Vim keybindings. + +#### Understanding Vim Input Modes: +- **-- INSERT -- visible**: You're in insert mode, can type text freely +- **No -- INSERT -- visible**: You're in normal/command mode + - Press i to enter insert mode before typing text + - Can use vim commands (dd to delete line, etc.) + +**Default state**: Normal mode (no indicator shown) + +### Permission Modes + +Claude Code has **4 permission modes** that cycle with **shift+tab** (BTab): + +1. **Default mode** (no indicator, just "? for shortcuts") + - Asks permission for everything (file edits, commands, etc.) + - **Required for review agents** + +2. **⏵⏵ accept edits on** + - Auto-accepts file edits only + - Still asks for shell commands + +3. **⏸ plan mode on** + - Shows detailed plan before executing + - Waits for approval before proceeding + +4. **⏵⏵ bypass permissions on** + - Auto-executes ALL actions (edits + commands) + - Only available when launched with --dangerously-skip-permissions + - **Appropriate for implementer agents** + +**Default state**: Default mode (no indicator shown) + +#### Efficient Mode Switching with repeat Parameter + +Instead of multiple tool calls, use the repeat parameter to jump directly to desired modes: + +**From Default Mode:** +- To accept edits: send-keys "BTab" repeat=1 +- To plan mode: send-keys "BTab" repeat=2 +- To bypass: send-keys "BTab" repeat=3 + +**From Bypass Mode:** +- To default: send-keys "BTab" repeat=1 +- To accept edits: send-keys "BTab" repeat=2 +- To plan mode: send-keys "BTab" repeat=3 + +**From Plan Mode:** +- To bypass: send-keys "BTab" repeat=1 +- To default: send-keys "BTab" repeat=2 +- To accept edits: send-keys "BTab" repeat=3 + +**From Accept Edits:** +- To plan: send-keys "BTab" repeat=1 +- To bypass: send-keys "BTab" repeat=2 +- To default: send-keys "BTab" repeat=3 + +### Workflow Preferences + +The user's typical workflow: + +1. **Planning Phase** - Use **plan mode** + - Send a feature request/question to Claude Code + - Claude Code creates a detailed plan + - Review the plan together + +2. **Execution Phase** - Switch to **bypass permissions** + - Cycle to bypass mode with BTab (or use repeat) + - Select option to proceed (usually "1") + - Let Claude Code execute the plan + +3. **Monitoring** - Check progress + - Use return_output to see results immediately + - Watch for completion or errors + +### Sending Input to Claude Code + +**To type a question/command:** +1. Check vim mode: Look for -- INSERT -- indicator +2. If not in insert mode: send-keys "i" +3. Send the text: send-text with the actual message, pressEnter=true, and return_output with wait to a second +4. Monitor progress using capture-pane + +**To send special keys:** +Use send-keys for: +- BTab (shift+tab to cycle modes) +- Escape (exit insert mode or interrupt Claude when it's working) +- Enter (submit) +- C-c (interrupt) +- Up, Down, Left, Right (navigation) + +## Tmux MCP Tools Available + +### Discovery & Navigation +- **list** - Flexible listing with scopes + - scope="all" - Full hierarchy tree (default) + - scope="sessions" - All sessions + - scope="session", target="$35" - Windows in session + - scope="window", target="@364" - Panes in window + - scope="pane", target="%557" - Pane details + +### Creation +- **create-session** - Create new tmux session +- **create-window** - Create new window in session +- **split-pane** - Split pane horizontally/vertically + +### Destruction +- **kill** - Kill sessions/windows/panes + - scope="session", target="$35" + - scope="window", target="@364" + - scope="pane", target="%557" + +### Interaction +- **send-keys** - Send special keys/combos (Escape, C-c, BTab, Up, Down, Enter, etc.) + - Supports repeat parameter for efficient key repetition + - Supports return_output to capture results immediately + +- **send-text** - Type text character-by-character to simulate typing + - Use for commands, messages, and interactive apps + - Supports pressEnter=true to submit after typing + - Supports return_output with optional wait parameter for slow commands + - **This is your primary tool for running commands** - no need for execute-shell-command + +**CRITICAL - Always Use return_output**: When you need to verify results, ALWAYS use return_output to combine action + verification into a single tool call: + +Examples: +- Quick commands: return_output: { lines: 50 } (no wait needed) +- Slow commands: return_output: { lines: 50, wait: 1000 } (wait 1 second) +- Mode switching: send-keys "BTab" repeat=3 return_output: { lines: 50 } + +## GitHub CLI (gh) Integration + +The GitHub CLI is already authenticated and available. Use it for GitHub operations like managing PRs, issues, repos, and workflows. + +## Git Worktree Utilities + +The user has custom create-worktree and delete-worktree utilities in PATH for managing git worktrees efficiently and safely. + +### create-worktree + +**Usage:** +create-worktree "worktree-name" + +**What it does:** +- Creates a new git worktree with a new branch +- Copies .env file from main repo to the new worktree (if it exists) +- Runs npm install if package.json exists +- **Note**: The script runs in a subshell, so you must manually cd to the new directory + +**Important**: After creating a worktree, you must cd to the new checkout directory. The worktree is created as a sibling directory to the current git repository. + +**Example workflow:** +# If you're in ~/dev/tmux-mcp +create-worktree "feature-branch" +# This creates ~/dev/tmux-mcp-feature-branch + +# Must cd to use it +cd ../tmux-mcp-feature-branch + +### delete-worktree + +**CRITICAL**: This script **preserves the branch** - it only deletes the worktree directory! + +**Usage:** +# Must run from within a worktree directory +delete-worktree + +**What it does:** +- Detects the current worktree (MUST be run from within a worktree) +- Shows simple confirmation with worktree path and branch name +- Deletes ONLY the worktree directory +- **Preserves the branch** for later merging +- Warns you that you're still in the deleted directory + +### Safe Development Workflow + +The worktree utilities enable this safe workflow: + +1. Create worktree for feature + create-worktree "my-feature" + cd ../repo-my-feature + +2. Do work, make commits + git add . + git commit -m "Implemented feature" + +3. Delete worktree when done (branch is preserved!) + delete-worktree + # ⚠️ You are still in deleted directory - cd out + +4. Go back to main + cd ../repo-main + +5. Merge your work safely - the branch still exists! + git merge my-feature + +6. Push changes + git push + +7. Clean up the branch when done + git branch -d my-feature + +**Key Benefits:** +- ✅ Never lose work - branches are always preserved +- ✅ Can delete worktree and merge later +- ✅ No accidental branch deletion +- ✅ Simple, context-aware (works in current worktree) +- ✅ Clean workflow for feature development + +**Use worktrees when:** +- Working on multiple features simultaneously +- Need to switch contexts without stashing changes +- Want to test something without affecting main +- Need parallel development branches + +## Context-Aware Command Execution + +**CRITICAL**: Understand when to execute commands directly vs. asking Claude Code to do it. + +### When to use Claude Code: +- **If already working with Claude Code** on a feature/task +- **For coding tasks** (refactoring, adding features, fixing bugs) +- **When you need intelligent code changes** with context +- Context clue: "add a feature", "refactor this", "fix the bug" + +### When to execute directly (without Claude Code): +- **Quick information gathering** (checking status, listing files) +- **Simple operations** (git commands, gh commands, navigation) +- **When Claude Code is not involved** in the conversation +- Context clue: "check the status", "run tests", "create a PR" + +### Example Context Decisions: + +**Scenario 1**: Working with Claude Code on a feature +User: "create a PR" +You: Ask Claude Code to create the PR (it has context of changes) + +**Scenario 2**: Not working with Claude Code +User: "create a PR" +You: Run gh pr create directly via send-text with return_output + +**Key Rule**: If Claude Code is active and working on something, delegate to it. Otherwise, execute directly for efficiency. + +## Common Patterns + +### Starting Claude Code +1. List sessions to find the right one +2. Send-text "claude --dangerously-skip-permissions" with return_output +3. Verify you see the Claude Code interface + +### Asking Claude Code a Question +1. Check for -- INSERT -- (use return_output from previous command) +2. If no -- INSERT --, send-keys "i" with return_output +3. Send-text with your question and return_output +4. Send-keys "Enter" with return_output to submit (if Enter didn't register) +5. Monitor the response in the returned output + +### Switching to Plan Mode (Efficient) +1. Send-keys "BTab" repeat=2 return_output={lines: 50} +2. Verify "⏸ plan mode on" in the output + +### Executing a Plan +1. Send-text "1" pressEnter=true return_output={lines: 100, wait: 1000} +2. Monitor progress in the returned output +3. Check for completion indicators + +## Tips for Success + +### Always Explain Your Actions +**CRITICAL**: Never execute commands silently. Always: +- **State what you're about to do** before doing it +- **Explain why** you're taking that action +- **Report what happened** after execution +- **Reason through decisions** between commands + +### Always Use return_output +- **Efficiency**: Combines action + verification into one tool call +- **Use cases**: Mode switching, running commands, sending messages +- **Wait parameter**: Use for slow commands (npm install, git operations, etc.) +- **Default**: Always include return_output unless you have a specific reason not to + +### Handle Errors Gracefully +- If something doesn't work, check the returned output +- Explain what you see and what might have gone wrong +- Offer to try alternative approaches + +### Be Proactive +- Notice when Claude Code is waiting for input +- Alert the user when operations complete +- Suggest next steps in the workflow + +### Context Awareness +- Remember what session/window/pane you're working in +- Keep track of which mode Claude Code is in (check for indicators) +- Be aware of the current directory in shells +- **Projects are in ~/dev** - navigate there when working on code +- Use gh for GitHub operations (already authenticated) +- Use create-worktree and delete-worktree for safe worktree management +- Remember: No indicator = default mode (vim and permissions) +- Remember: delete-worktree preserves branches - safe to use! +- **Keep window names up to date and accurate** - use descriptive names that reflect current purpose + +## Remember + +- **ALWAYS talk before tool calls** - Acknowledge what you heard, explain briefly, then execute. Voice users need confirmation! +- **Always explain and reason** - Never execute commands silently, always state what you're doing and why +- **Always use return_output** - Combine action + verification in single tool calls +- **Context-aware execution** - Know when to use Claude Code vs direct execution based on the situation +- **Multi-agent coordination** - Set review agents to default mode immediately, let agents complete analysis before providing new context +- **Keep Claude Code running** - Only close sessions when explicitly asked +- **Keep window names accurate** - Update names to reflect current purpose +- **Mobile user** - Be concise and confirm actions +- **Voice input** - Forgive typos, clarify when needed +- **Vim mode** - No indicator = normal mode, check for -- INSERT -- +- **Permission mode** - No indicator = default mode, launched with --dangerously-skip-permissions enables bypass option +- **Use repeat parameter** - Jump directly to desired modes efficiently +- **Enter key issue** - Sometimes needs to be sent twice +- **Plan first** - Use plan mode for new features +- **Monitor progress** - Use return_output to see results immediately +- **Git worktrees** - Use create-worktree, then cd. Use delete-worktree to safely remove (preserves branch!) +- **Be helpful** - You're here to make coding from a phone easier! + +## Projects + +### Faro - Autonomous Competitive Intelligence Tool +- Bare repo: ~/dev/faro +- Main checkout: ~/dev/faro/main + +### Blank.page - A minimal text editor in your browser +- Location: ~/dev/blank.page/editor +`; diff --git a/packages/web/app/components/activity-log-panel.tsx b/packages/web/app/components/activity-log-panel.tsx new file mode 100644 index 000000000..a8b181c3b --- /dev/null +++ b/packages/web/app/components/activity-log-panel.tsx @@ -0,0 +1,189 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import type { LogEntry, EventCategory } from '../types/realtime-events'; + +interface ActivityLogPanelProps { + logs: LogEntry[]; + onClear: () => void; +} + +export default function ActivityLogPanel({ logs, onClear }: ActivityLogPanelProps) { + const [isOpen, setIsOpen] = useState(false); + const [unreadCount, setUnreadCount] = useState(0); + const logContainerRef = useRef(null); + const prevLogsLengthRef = useRef(0); + + // Auto-scroll to bottom when new logs arrive + useEffect(() => { + if (logContainerRef.current && isOpen) { + logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight; + } + }, [logs, isOpen]); + + // Track unread count + useEffect(() => { + if (!isOpen && logs.length > prevLogsLengthRef.current) { + const newLogs = logs.length - prevLogsLengthRef.current; + setUnreadCount(prev => prev + newLogs); + } + prevLogsLengthRef.current = logs.length; + }, [logs, isOpen]); + + // Reset unread count when opening + useEffect(() => { + if (isOpen) { + setUnreadCount(0); + } + }, [isOpen]); + + function getCategoryColor(category: EventCategory): string { + switch (category) { + case 'connection': + return 'bg-blue-100 text-blue-800 border-blue-200'; + case 'speech': + return 'bg-green-100 text-green-800 border-green-200'; + case 'transcription': + return 'bg-cyan-100 text-cyan-800 border-cyan-200'; + case 'tool_call': + return 'bg-purple-100 text-purple-800 border-purple-200'; + case 'response': + return 'bg-yellow-100 text-yellow-800 border-yellow-200'; + case 'error': + return 'bg-red-100 text-red-800 border-red-200'; + case 'buffer': + return 'bg-gray-100 text-gray-800 border-gray-200'; + case 'audio': + return 'bg-indigo-100 text-indigo-800 border-indigo-200'; + default: + return 'bg-gray-100 text-gray-800 border-gray-200'; + } + } + + function formatTime(timestamp: number): string { + const date = new Date(timestamp); + const hours = date.getHours().toString().padStart(2, '0'); + const minutes = date.getMinutes().toString().padStart(2, '0'); + const seconds = date.getSeconds().toString().padStart(2, '0'); + const ms = date.getMilliseconds().toString().padStart(3, '0'); + return `${hours}:${minutes}:${seconds}.${ms}`; + } + + function LogEntryItem({ entry }: { entry: LogEntry }) { + const [isExpanded, setIsExpanded] = useState(false); + + return ( +
+
+ + {formatTime(entry.timestamp)} + + + {entry.category.toUpperCase()} + +
+
+ {entry.summary || entry.eventType} +
+ + {isExpanded && ( +
+            {JSON.stringify(entry.event, null, 2)}
+          
+ )} +
+ ); + } + + return ( + <> + {/* Toggle Button */} + + + {/* Panel */} +
+ {/* Header */} +
+

Activity Log

+
+ + +
+
+ + {/* Log Container */} +
+ {logs.length === 0 ? ( +
+ No events yet. Connect to start logging. +
+ ) : ( + logs.map(entry => ) + )} +
+
+ + ); +} diff --git a/packages/web/app/components/agent-status.tsx b/packages/web/app/components/agent-status.tsx new file mode 100644 index 000000000..ef8b56502 --- /dev/null +++ b/packages/web/app/components/agent-status.tsx @@ -0,0 +1,76 @@ +import type { AgentStatus } from '../types/realtime-events'; + +interface AgentStatusProps { + status: AgentStatus; +} + +export default function AgentStatus({ status }: AgentStatusProps) { + function getStatusConfig() { + switch (status) { + case 'disconnected': + return { + color: 'bg-gray-500', + text: 'Disconnected', + icon: '○', + animate: false + }; + case 'connecting': + return { + color: 'bg-yellow-500', + text: 'Connecting', + icon: '◐', + animate: true + }; + case 'connected': + return { + color: 'bg-green-500', + text: 'Connected', + icon: '●', + animate: false + }; + case 'listening': + return { + color: 'bg-blue-500', + text: 'Listening', + icon: '🎤', + animate: true + }; + case 'processing': + return { + color: 'bg-purple-500', + text: 'Processing', + icon: '⚙', + animate: true + }; + case 'tool_executing': + return { + color: 'bg-orange-500', + text: 'Executing Tool', + icon: '🔧', + animate: true + }; + case 'speaking': + return { + color: 'bg-cyan-500', + text: 'Speaking', + icon: '🔊', + animate: true + }; + } + } + + const config = getStatusConfig(); + + return ( +
+
+
+
+ + {config.icon} {config.text} + +
+ ); +} diff --git a/packages/web/app/components/auth-gate.tsx b/packages/web/app/components/auth-gate.tsx new file mode 100644 index 000000000..0746344c8 --- /dev/null +++ b/packages/web/app/components/auth-gate.tsx @@ -0,0 +1,109 @@ +'use client'; + +import { useState, useEffect } from 'react'; + +interface AuthGateProps { + children: React.ReactNode; +} + +const SESSION_KEY = 'auth_session'; + +export default function AuthGate({ children }: AuthGateProps) { + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [isChecking, setIsChecking] = useState(true); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + // Check for existing session on mount + useEffect(() => { + const session = sessionStorage.getItem(SESSION_KEY); + if (session === 'authenticated') { + setIsAuthenticated(true); + } + setIsChecking(false); + }, []); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(''); + setIsSubmitting(true); + + try { + const basePath = process.env.NEXT_PUBLIC_BASE_PATH || ''; + const response = await fetch(`${basePath}/api/auth`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }) + }); + + const data = await response.json(); + + if (response.ok && data.authenticated) { + sessionStorage.setItem(SESSION_KEY, 'authenticated'); + setIsAuthenticated(true); + setPassword(''); + } else { + setError('Invalid password'); + } + } catch (err) { + setError('Authentication failed'); + } finally { + setIsSubmitting(false); + } + } + + if (isChecking) { + return ( +
+
Loading...
+
+ ); + } + + if (!isAuthenticated) { + return ( +
+
+
+

+ Authentication Required +

+
+
+ + setPassword(e.target.value)} + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none" + placeholder="Enter password" + disabled={isSubmitting} + autoFocus + /> +
+ {error && ( +
{error}
+ )} + +
+
+
+
+ ); + } + + return <>{children}; +} diff --git a/packages/web/app/components/device-selector.tsx b/packages/web/app/components/device-selector.tsx new file mode 100644 index 000000000..f779564b8 --- /dev/null +++ b/packages/web/app/components/device-selector.tsx @@ -0,0 +1,38 @@ +interface DeviceSelectorProps { + devices: Array<{ deviceId: string; label: string }>; + selectedDeviceId: string; + onDeviceChange: (deviceId: string) => void; + disabled?: boolean; +} + +export default function DeviceSelector({ + devices, + selectedDeviceId, + onDeviceChange, + disabled, +}: DeviceSelectorProps) { + if (devices.length === 0) { + return null; + } + + return ( +
+ + +
+ ); +} diff --git a/packages/web/app/components/mute-button.tsx b/packages/web/app/components/mute-button.tsx new file mode 100644 index 000000000..d064f9636 --- /dev/null +++ b/packages/web/app/components/mute-button.tsx @@ -0,0 +1,23 @@ +interface MuteButtonProps { + isMuted: boolean; + onToggle: () => void; + disabled?: boolean; +} + +export default function MuteButton({ isMuted, onToggle, disabled }: MuteButtonProps) { + return ( + + ); +} diff --git a/packages/web/app/components/volume-bar.tsx b/packages/web/app/components/volume-bar.tsx new file mode 100644 index 000000000..d9dd3c13d --- /dev/null +++ b/packages/web/app/components/volume-bar.tsx @@ -0,0 +1,22 @@ +interface VolumeBarProps { + volume: number; + isMuted?: boolean; +} + +export default function VolumeBar({ volume, isMuted }: VolumeBarProps) { + function getColor() { + if (isMuted) return 'bg-gray-300'; + if (volume > 80) return 'bg-red-500'; + if (volume > 60) return 'bg-yellow-400'; + return 'bg-green-500'; + } + + return ( +
+
+
+ ); +} diff --git a/packages/web/app/globals.css b/packages/web/app/globals.css new file mode 100644 index 000000000..f1d8c73cd --- /dev/null +++ b/packages/web/app/globals.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/packages/web/app/hooks/use-audio-devices.ts b/packages/web/app/hooks/use-audio-devices.ts new file mode 100644 index 000000000..3c5412b9c --- /dev/null +++ b/packages/web/app/hooks/use-audio-devices.ts @@ -0,0 +1,80 @@ +'use client'; + +import { useState, useEffect } from 'react'; + +interface AudioDevice { + deviceId: string; + label: string; +} + +function isSecureContext(): boolean { + return typeof window !== 'undefined' && window.isSecureContext; +} + +function hasMediaDevices(): boolean { + return typeof navigator !== 'undefined' && 'mediaDevices' in navigator && !!navigator.mediaDevices; +} + +export function useAudioDevices() { + const [devices, setDevices] = useState([]); + const [selectedDeviceId, setSelectedDeviceId] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function loadDevices() { + if (!isSecureContext()) { + setError('HTTPS required. Please access this app over HTTPS or localhost.'); + setIsLoading(false); + return; + } + + if (!hasMediaDevices()) { + setError('Media devices not supported in this browser.'); + setIsLoading(false); + return; + } + + try { + await navigator.mediaDevices.getUserMedia({ audio: true }); + + const deviceList = await navigator.mediaDevices.enumerateDevices(); + const audioInputs = deviceList + .filter(device => device.kind === 'audioinput') + .map(device => ({ + deviceId: device.deviceId, + label: device.label || `Microphone ${device.deviceId.slice(0, 8)}`, + })); + + setDevices(audioInputs); + + if (audioInputs.length > 0 && !selectedDeviceId) { + setSelectedDeviceId(audioInputs[0].deviceId); + } + } catch (error) { + console.error('Error loading audio devices:', error); + setError('Failed to access microphone. Please grant permission.'); + } finally { + setIsLoading(false); + } + } + + loadDevices(); + + if (hasMediaDevices()) { + navigator.mediaDevices.addEventListener('devicechange', loadDevices); + + return () => { + navigator.mediaDevices.removeEventListener('devicechange', loadDevices); + }; + } + }, [selectedDeviceId]); + + return { + devices, + selectedDeviceId, + setSelectedDeviceId, + isLoading, + error, + }; +} diff --git a/packages/web/app/hooks/use-audio-level.ts b/packages/web/app/hooks/use-audio-level.ts new file mode 100644 index 000000000..f520346cc --- /dev/null +++ b/packages/web/app/hooks/use-audio-level.ts @@ -0,0 +1,47 @@ +'use client'; + +import { useState, useEffect } from 'react'; + +export function useAudioLevel(stream: MediaStream | null): number { + const [volume, setVolume] = useState(0); + + useEffect(() => { + if (!stream) { + setVolume(0); + return; + } + + const audioContext = new AudioContext(); + const analyser = audioContext.createAnalyser(); + analyser.fftSize = 2048; + + const source = audioContext.createMediaStreamSource(stream); + source.connect(analyser); + + const dataArray = new Uint8Array(analyser.frequencyBinCount); + let animationId: number; + + function updateVolume() { + analyser.getByteTimeDomainData(dataArray); + + const rms = Math.sqrt( + dataArray.reduce((sum, val) => sum + val * val, 0) / dataArray.length + ); + + const normalized = (rms / 255) * 100; + setVolume(Math.min(100, normalized * 1.5)); + + animationId = requestAnimationFrame(updateVolume); + } + + updateVolume(); + + return () => { + cancelAnimationFrame(animationId); + source.disconnect(); + audioContext.close(); + }; + }, [stream]); + + return volume; +} diff --git a/packages/web/app/hooks/use-event-log.ts b/packages/web/app/hooks/use-event-log.ts new file mode 100644 index 000000000..5edda666c --- /dev/null +++ b/packages/web/app/hooks/use-event-log.ts @@ -0,0 +1,67 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import type { + RealtimeServerEvent, + AgentStatus, + LogEntry, + EventCategory +} from '../types/realtime-events'; +import { + categorizeEvent, + getEventSummary, + getStatusFromEvent +} from '../types/realtime-events'; + +const MAX_LOG_ENTRIES = 100; + +interface UseEventLogReturn { + logs: LogEntry[]; + agentStatus: AgentStatus; + addLog: (event: RealtimeServerEvent) => void; + setAgentStatus: (status: AgentStatus) => void; + clearLogs: () => void; +} + +export function useEventLog(): UseEventLogReturn { + const [logs, setLogs] = useState([]); + const [agentStatus, setAgentStatus] = useState('disconnected'); + + const addLog = useCallback((event: RealtimeServerEvent) => { + const entry: LogEntry = { + id: `${Date.now()}-${Math.random().toString(36).substring(7)}`, + timestamp: Date.now(), + category: categorizeEvent(event), + eventType: event.type, + event, + summary: getEventSummary(event) + }; + + setLogs(prevLogs => { + const newLogs = [...prevLogs, entry]; + // Keep only the last MAX_LOG_ENTRIES + if (newLogs.length > MAX_LOG_ENTRIES) { + return newLogs.slice(-MAX_LOG_ENTRIES); + } + return newLogs; + }); + + // Auto-update agent status based on event + const newStatus = getStatusFromEvent(event); + if (newStatus) { + setAgentStatus(newStatus); + } + }, []); + + const clearLogs = useCallback(() => { + setLogs([]); + }, []); + + return { + logs, + agentStatus, + addLog, + setAgentStatus, + clearLogs + }; +} diff --git a/packages/web/app/hooks/use-webrtc-voice.ts b/packages/web/app/hooks/use-webrtc-voice.ts new file mode 100644 index 000000000..347c9f53b --- /dev/null +++ b/packages/web/app/hooks/use-webrtc-voice.ts @@ -0,0 +1,202 @@ +'use client'; + +import { useState, useCallback, useRef } from 'react'; +import type { RealtimeServerEvent, AgentStatus } from '../types/realtime-events'; + +interface UseWebRTCVoiceReturn { + isConnected: boolean; + isConnecting: boolean; + error: string | null; + stream: MediaStream | null; + agentStatus: AgentStatus; + connect: (deviceId?: string) => Promise; + disconnect: () => void; +} + +interface UseWebRTCVoiceOptions { + onEvent?: (event: RealtimeServerEvent) => void; + onStatusChange?: (status: AgentStatus) => void; +} + +export function useWebRTCVoice(options: UseWebRTCVoiceOptions = {}): UseWebRTCVoiceReturn { + const { onEvent, onStatusChange } = options; + + const [isConnected, setIsConnected] = useState(false); + const [isConnecting, setIsConnecting] = useState(false); + const [error, setError] = useState(null); + const [stream, setStream] = useState(null); + const [agentStatus, setAgentStatus] = useState('disconnected'); + + const peerConnectionRef = useRef(null); + const dataChannelRef = useRef(null); + const audioElementRef = useRef(null); + const streamRef = useRef(null); + + const connect = useCallback(async (deviceId?: string) => { + if (isConnecting || isConnected) return; + + setIsConnecting(true); + setError(null); + + try { + if (!window.isSecureContext) { + throw new Error('HTTPS required. Please access this app over HTTPS or localhost.'); + } + + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + throw new Error('Media devices not supported in this browser.'); + } + + const audioConstraints: MediaTrackConstraints = { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + }; + + if (deviceId) { + audioConstraints.deviceId = { exact: deviceId }; + } + + const micStream = await navigator.mediaDevices.getUserMedia({ + audio: audioConstraints + }); + streamRef.current = micStream; + setStream(micStream); + + const basePath = process.env.NEXT_PUBLIC_BASE_PATH || ''; + const response = await fetch(`${basePath}/api/session`, { method: 'POST' }); + if (!response.ok) { + throw new Error('Failed to get session token'); + } + const { client_secret } = await response.json(); + + const pc = new RTCPeerConnection(); + peerConnectionRef.current = pc; + + micStream.getTracks().forEach(track => pc.addTrack(track, micStream)); + + const dc = pc.createDataChannel('oai-events'); + dataChannelRef.current = dc; + + // Listen for data channel messages (realtime events from server) + dc.onmessage = (event) => { + try { + const data = JSON.parse(event.data) as RealtimeServerEvent; + onEvent?.(data); + } catch (err) { + console.error('Failed to parse data channel message:', err); + } + }; + + dc.onopen = () => { + console.log('Data channel opened'); + }; + + dc.onclose = () => { + console.log('Data channel closed'); + }; + + pc.ontrack = (event) => { + const audioElement = new Audio(); + audioElement.autoplay = true; + audioElement.srcObject = event.streams[0]; + audioElementRef.current = audioElement; + audioElement.play().catch(err => { + console.error('Error playing audio:', err); + }); + }; + + pc.onconnectionstatechange = () => { + if (pc.connectionState === 'connected') { + setIsConnected(true); + setIsConnecting(false); + setAgentStatus('connected'); + onStatusChange?.('connected'); + } else if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') { + setError('Connection failed'); + setIsConnected(false); + setIsConnecting(false); + setAgentStatus('disconnected'); + onStatusChange?.('disconnected'); + } + }; + + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + + const sdpResponse = await fetch( + `https://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview-2024-12-17`, + { + method: 'POST', + headers: { + 'Authorization': `Bearer ${client_secret.value}`, + 'Content-Type': 'application/sdp', + }, + body: offer.sdp, + } + ); + + if (!sdpResponse.ok) { + throw new Error('Failed to connect to OpenAI'); + } + + const answerSdp = await sdpResponse.text(); + const answer: RTCSessionDescriptionInit = { + type: 'answer', + sdp: answerSdp, + }; + await pc.setRemoteDescription(answer); + + } catch (err) { + console.error('Connection error:', err); + setError(err instanceof Error ? err.message : 'Failed to connect'); + setIsConnecting(false); + setIsConnected(false); + + if (streamRef.current) { + streamRef.current.getTracks().forEach(track => track.stop()); + streamRef.current = null; + setStream(null); + } + } + }, [isConnecting, isConnected]); + + const disconnect = useCallback(() => { + if (dataChannelRef.current) { + dataChannelRef.current.close(); + dataChannelRef.current = null; + } + + if (peerConnectionRef.current) { + peerConnectionRef.current.close(); + peerConnectionRef.current = null; + } + + if (streamRef.current) { + streamRef.current.getTracks().forEach(track => track.stop()); + streamRef.current = null; + setStream(null); + } + + if (audioElementRef.current) { + audioElementRef.current.pause(); + audioElementRef.current.srcObject = null; + audioElementRef.current = null; + } + + setIsConnected(false); + setIsConnecting(false); + setError(null); + setAgentStatus('disconnected'); + }, []); + + return { + isConnected, + isConnecting, + error, + stream, + agentStatus, + connect, + disconnect, + }; +} diff --git a/packages/web/app/layout.tsx b/packages/web/app/layout.tsx new file mode 100644 index 000000000..1142774b2 --- /dev/null +++ b/packages/web/app/layout.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from "next"; +import "./globals.css"; +import AuthGate from "./components/auth-gate"; + +export const metadata: Metadata = { + title: "Real-Time Voice", + description: "Real-time voice interaction with OpenAI", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + {children} + + + ); +} diff --git a/packages/web/app/page.tsx b/packages/web/app/page.tsx new file mode 100644 index 000000000..e4d7ad602 --- /dev/null +++ b/packages/web/app/page.tsx @@ -0,0 +1,5 @@ +import VoiceClient from './voice-client'; + +export default function Home() { + return ; +} diff --git a/packages/web/app/types/realtime-events.ts b/packages/web/app/types/realtime-events.ts new file mode 100644 index 000000000..bf6fcc500 --- /dev/null +++ b/packages/web/app/types/realtime-events.ts @@ -0,0 +1,145 @@ +// Re-export types from openai-realtime-api for type safety +import type { RealtimeServerEvents, RealtimeClientEvents } from 'openai-realtime-api'; + +export type RealtimeServerEvent = RealtimeServerEvents.EventMap[RealtimeServerEvents.EventType]; +export type RealtimeClientEvent = RealtimeClientEvents.EventMap[RealtimeClientEvents.EventType]; + +// Agent status enum +export type AgentStatus = + | 'disconnected' + | 'connecting' + | 'connected' + | 'listening' + | 'processing' + | 'tool_executing' + | 'speaking'; + +// Event category for UI display +export type EventCategory = + | 'connection' + | 'audio' + | 'speech' + | 'transcription' + | 'response' + | 'tool_call' + | 'error' + | 'buffer' + | 'other'; + +// Log entry with parsed event data +export interface LogEntry { + id: string; + timestamp: number; + category: EventCategory; + eventType: string; + event: RealtimeServerEvent; + summary?: string; +} + +// Helper to categorize events +export function categorizeEvent(event: RealtimeServerEvent): EventCategory { + const type = event.type; + + if (type.startsWith('session.')) return 'connection'; + if (type.startsWith('input_audio_buffer.')) return 'speech'; + if (type.includes('transcription')) return 'transcription'; + if (type.includes('function_call')) return 'tool_call'; + if (type.startsWith('response.audio')) return 'audio'; + if (type.startsWith('response.')) return 'response'; + if (type.startsWith('conversation.')) return 'audio'; + if (type.includes('error')) return 'error'; + if (type.includes('rate_limits')) return 'buffer'; + + return 'other'; +} + +// Helper to get event summary +export function getEventSummary(event: RealtimeServerEvent): string { + const type = event.type; + + switch (type) { + case 'session.created': + return 'Session established'; + + case 'input_audio_buffer.speech_started': + return 'User started speaking'; + + case 'input_audio_buffer.speech_stopped': + return 'User stopped speaking'; + + case 'input_audio_buffer.committed': + return 'Audio committed for processing'; + + case 'conversation.item.input_audio_transcription.completed': + if ('transcript' in event) { + return `Transcription: "${event.transcript}"`; + } + return 'Transcription completed'; + + case 'response.audio_transcript.delta': + if ('delta' in event) { + return `AI: "${event.delta}"`; + } + return 'AI speaking...'; + + case 'response.audio_transcript.done': + if ('transcript' in event) { + return `AI said: "${event.transcript}"`; + } + return 'AI finished speaking'; + + case 'response.function_call_arguments.done': + if ('name' in event && 'arguments' in event) { + return `Tool: ${event.name}(${event.arguments})`; + } + return 'Tool call executed'; + + case 'response.audio.delta': + return 'AI audio streaming...'; + + case 'response.audio.done': + return 'AI audio complete'; + + case 'response.done': + return 'Response completed'; + + case 'error': + if ('error' in event && typeof event.error === 'object' && event.error !== null && 'message' in event.error) { + return `Error: ${event.error.message}`; + } + return 'Error occurred'; + + default: + return type; + } +} + +// Helper to get status from event +export function getStatusFromEvent(event: RealtimeServerEvent): AgentStatus | null { + const type = event.type; + + switch (type) { + case 'session.created': + return 'connected'; + + case 'input_audio_buffer.speech_started': + return 'listening'; + + case 'input_audio_buffer.speech_stopped': + case 'input_audio_buffer.committed': + return 'processing'; + + case 'response.function_call_arguments.done': + return 'tool_executing'; + + case 'response.audio.delta': + return 'speaking'; + + case 'response.audio.done': + case 'response.done': + return 'connected'; + + default: + return null; + } +} diff --git a/packages/web/app/voice-client.tsx b/packages/web/app/voice-client.tsx new file mode 100644 index 000000000..6152a1f7b --- /dev/null +++ b/packages/web/app/voice-client.tsx @@ -0,0 +1,131 @@ +'use client'; + +import { useState } from 'react'; +import { useWebRTCVoice } from './hooks/use-webrtc-voice'; +import { useAudioLevel } from './hooks/use-audio-level'; +import { useAudioDevices } from './hooks/use-audio-devices'; +import { useEventLog } from './hooks/use-event-log'; +import VolumeBar from './components/volume-bar'; +import MuteButton from './components/mute-button'; +import DeviceSelector from './components/device-selector'; +import AgentStatus from './components/agent-status'; +import ActivityLogPanel from './components/activity-log-panel'; + +export default function VoiceClient() { + const [isMuted, setIsMuted] = useState(false); + + // Initialize event logging + const { logs, agentStatus, addLog, clearLogs } = useEventLog(); + + const { + isConnected, + isConnecting, + error, + stream, + connect, + disconnect + } = useWebRTCVoice({ + onEvent: addLog + }); + + const { + devices, + selectedDeviceId, + setSelectedDeviceId, + error: devicesError, + } = useAudioDevices(); + + const volume = useAudioLevel(stream); + + function handleConnect() { + connect(selectedDeviceId); + } + + function handleMuteToggle() { + if (!stream) return; + + const newMutedState = !isMuted; + + stream.getAudioTracks().forEach(track => { + track.enabled = !newMutedState; + }); + + setIsMuted(newMutedState); + } + + return ( +
+

Real-Time Voice

+ + {/* Agent Status */} + + + {devicesError && ( +
+

⚠️ {devicesError}

+ {devicesError.includes('HTTPS') && ( +

+ For mobile access, use HTTPS or a tunneling service like ngrok or Cloudflare Tunnel. +

+ )} +
+ )} + + {error && ( +
+ {error} +
+ )} + +
+
+ +
+ +
+ {!isConnected && ( + + )} + + {!isConnected ? ( + + ) : ( + <> + + + + )} +
+
+ + {isConnected && ( +

+ Connected - Speak to interact with AI +

+ )} + + {/* Activity Log Panel */} + +
+ ); +} diff --git a/packages/web/next.config.js b/packages/web/next.config.js new file mode 100644 index 000000000..58ef471ef --- /dev/null +++ b/packages/web/next.config.js @@ -0,0 +1,9 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + assetPrefix: '/ui', + env: { + NEXT_PUBLIC_BASE_PATH: '/ui', + }, +} + +module.exports = nextConfig diff --git a/packages/web/package-lock.json b/packages/web/package-lock.json new file mode 100644 index 000000000..a644769f7 --- /dev/null +++ b/packages/web/package-lock.json @@ -0,0 +1,1827 @@ +{ + "name": "real-time-voice", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "real-time-voice", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@tailwindcss/postcss": "^4.1.14", + "@types/node": "^24.7.2", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.1", + "autoprefixer": "^10.4.21", + "next": "^15.5.4", + "openai-realtime-api": "^1.0.8", + "postcss": "^8.5.6", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "tailwindcss": "^4.1.14", + "typescript": "^5.9.3" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", + "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.4.tgz", + "integrity": "sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.3" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.4.tgz", + "integrity": "sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.3" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.3.tgz", + "integrity": "sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.3.tgz", + "integrity": "sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.3.tgz", + "integrity": "sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.3.tgz", + "integrity": "sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.3.tgz", + "integrity": "sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.3.tgz", + "integrity": "sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.3.tgz", + "integrity": "sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.4.tgz", + "integrity": "sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.3" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.4.tgz", + "integrity": "sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.3" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.4.tgz", + "integrity": "sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.3" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.4.tgz", + "integrity": "sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.3" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.4.tgz", + "integrity": "sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.3" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.4.tgz", + "integrity": "sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.3" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.4.tgz", + "integrity": "sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.3" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.4.tgz", + "integrity": "sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.5.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.4.tgz", + "integrity": "sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.4.tgz", + "integrity": "sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.4.tgz", + "integrity": "sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@next/env": { + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.4.tgz", + "integrity": "sha512-27SQhYp5QryzIT5uO8hq99C69eLQ7qkzkDPsk3N+GuS2XgOgoYEeOav7Pf8Tn4drECOVDsDg8oj+/DVy8qQL2A==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.4.tgz", + "integrity": "sha512-nopqz+Ov6uvorej8ndRX6HlxCYWCO3AHLfKK2TYvxoSB2scETOcfm/HSS3piPqc3A+MUgyHoqE6je4wnkjfrOA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.4.tgz", + "integrity": "sha512-QOTCFq8b09ghfjRJKfb68kU9k2K+2wsC4A67psOiMn849K9ZXgCSRQr0oVHfmKnoqCbEmQWG1f2h1T2vtJJ9mA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.4.tgz", + "integrity": "sha512-eRD5zkts6jS3VfE/J0Kt1VxdFqTnMc3QgO5lFE5GKN3KDI/uUpSyK3CjQHmfEkYR4wCOl0R0XrsjpxfWEA++XA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.4.tgz", + "integrity": "sha512-TOK7iTxmXFc45UrtKqWdZ1shfxuL4tnVAOuuJK4S88rX3oyVV4ZkLjtMT85wQkfBrOOvU55aLty+MV8xmcJR8A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.4.tgz", + "integrity": "sha512-7HKolaj+481FSW/5lL0BcTkA4Ueam9SPYWyN/ib/WGAFZf0DGAN8frNpNZYFHtM4ZstrHZS3LY3vrwlIQfsiMA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.4.tgz", + "integrity": "sha512-nlQQ6nfgN0nCO/KuyEUwwOdwQIGjOs4WNMjEUtpIQJPR2NUfmGpW2wkJln1d4nJ7oUzd1g4GivH5GoEPBgfsdw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.4.tgz", + "integrity": "sha512-PcR2bN7FlM32XM6eumklmyWLLbu2vs+D7nJX8OAIoWy69Kef8mfiN4e8TUv2KohprwifdpFKPzIP1njuCjD0YA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.4.tgz", + "integrity": "sha512-1ur2tSHZj8Px/KMAthmuI9FMp/YFusMMGoRNJaRZMOlSkgvLjzosSdQI0cJAKogdHl3qXUQKL9MGaYvKwA7DXg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.14.tgz", + "integrity": "sha512-hpz+8vFk3Ic2xssIA3e01R6jkmsAhvkQdXlEbRTk6S10xDAtiQiM3FyvZVGsucefq764euO/b8WUW9ysLdThHw==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.0", + "lightningcss": "1.30.1", + "magic-string": "^0.30.19", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.14" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.14.tgz", + "integrity": "sha512-23yx+VUbBwCg2x5XWdB8+1lkPajzLmALEfMb51zZUBYaYVPDQvBSD/WYDqiVyBIo2BZFa3yw1Rpy3G2Jp+K0dw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.4", + "tar": "^7.5.1" + }, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.14", + "@tailwindcss/oxide-darwin-arm64": "4.1.14", + "@tailwindcss/oxide-darwin-x64": "4.1.14", + "@tailwindcss/oxide-freebsd-x64": "4.1.14", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.14", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.14", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.14", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.14", + "@tailwindcss/oxide-linux-x64-musl": "4.1.14", + "@tailwindcss/oxide-wasm32-wasi": "4.1.14", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.14", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.14" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.14.tgz", + "integrity": "sha512-a94ifZrGwMvbdeAxWoSuGcIl6/DOP5cdxagid7xJv6bwFp3oebp7y2ImYsnZBMTwjn5Ev5xESvS3FFYUGgPODQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.14.tgz", + "integrity": "sha512-HkFP/CqfSh09xCnrPJA7jud7hij5ahKyWomrC3oiO2U9i0UjP17o9pJbxUN0IJ471GTQQmzwhp0DEcpbp4MZTA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.14.tgz", + "integrity": "sha512-eVNaWmCgdLf5iv6Qd3s7JI5SEFBFRtfm6W0mphJYXgvnDEAZ5sZzqmI06bK6xo0IErDHdTA5/t7d4eTfWbWOFw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.14.tgz", + "integrity": "sha512-QWLoRXNikEuqtNb0dhQN6wsSVVjX6dmUFzuuiL09ZeXju25dsei2uIPl71y2Ic6QbNBsB4scwBoFnlBfabHkEw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.14.tgz", + "integrity": "sha512-VB4gjQni9+F0VCASU+L8zSIyjrLLsy03sjcR3bM0V2g4SNamo0FakZFKyUQ96ZVwGK4CaJsc9zd/obQy74o0Fw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.14.tgz", + "integrity": "sha512-qaEy0dIZ6d9vyLnmeg24yzA8XuEAD9WjpM5nIM1sUgQ/Zv7cVkharPDQcmm/t/TvXoKo/0knI3me3AGfdx6w1w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.14.tgz", + "integrity": "sha512-ISZjT44s59O8xKsPEIesiIydMG/sCXoMBCqsphDm/WcbnuWLxxb+GcvSIIA5NjUw6F8Tex7s5/LM2yDy8RqYBQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.14.tgz", + "integrity": "sha512-02c6JhLPJj10L2caH4U0zF8Hji4dOeahmuMl23stk0MU1wfd1OraE7rOloidSF8W5JTHkFdVo/O7uRUJJnUAJg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.14.tgz", + "integrity": "sha512-TNGeLiN1XS66kQhxHG/7wMeQDOoL0S33x9BgmydbrWAb9Qw0KYdd8o1ifx4HOGDWhVmJ+Ul+JQ7lyknQFilO3Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.14.tgz", + "integrity": "sha512-uZYAsaW/jS/IYkd6EWPJKW/NlPNSkWkBlaeVBi/WsFQNP05/bzkebUL8FH1pdsqx4f2fH/bWFcUABOM9nfiJkQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.5.0", + "@emnapi/runtime": "^1.5.0", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.0.5", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.14.tgz", + "integrity": "sha512-Az0RnnkcvRqsuoLH2Z4n3JfAef0wElgzHD5Aky/e+0tBUxUhIeIqFBTMNQvmMRSP15fWwmvjBxZ3Q8RhsDnxAA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.14.tgz", + "integrity": "sha512-ttblVGHgf68kEE4om1n/n44I0yGPkCPbLsqzjvybhpwa6mKKtgFfAzy6btc3HRmuW7nHe0OOrSeNP9sQmmH9XA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.14.tgz", + "integrity": "sha512-BdMjIxy7HUNThK87C7BC8I1rE8BVUsfNQSI5siQ4JK3iIa3w0XyVvVL9SXLWO//CtYTcp1v7zci0fYwJOjB+Zg==", + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.1.14", + "@tailwindcss/oxide": "4.1.14", + "postcss": "^8.4.41", + "tailwindcss": "4.1.14" + } + }, + "node_modules/@types/node": { + "version": "24.7.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.2.tgz", + "integrity": "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.14.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.2", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", + "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.1", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.1.tgz", + "integrity": "sha512-/EEvYBdT3BflCWvTMO7YkYBHVE9Ci6XdqZciZANQgKpaiDRGOLIlRo91jbTNRQjgPFWVaRxcYc0luVNFitz57A==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.21", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", + "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.24.4", + "caniuse-lite": "^1.0.30001702", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.16", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.16.tgz", + "integrity": "sha512-OMu3BGQ4E7P1ErFsIPpbJh0qvDudM/UuJeHgkAvfWe+0HFJCXh+t/l8L6fVLR55RI/UbKrVLnAXZSVwd9ysWYw==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/browserslist": { + "version": "4.26.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz", + "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.8.9", + "caniuse-lite": "^1.0.30001746", + "electron-to-chromium": "^1.5.227", + "node-releases": "^2.0.21", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001750", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001750.tgz", + "integrity": "sha512-cuom0g5sdX6rw00qOoLNSFCJ9/mYIsuSOA+yzpDw8eopiFqcVwQvZHqov0vmEighRxX++cfC0Vg1G+1Iy/mSpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.234", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.234.tgz", + "integrity": "sha512-RXfEp2x+VRYn8jbKfQlRImzoJU01kyDvVPBmG39eU2iuRVhuS6vQNocB8J0/8GrIMLnPzgz4eW6WiRnJkTuNWg==", + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", + "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-darwin-arm64": "1.30.1", + "lightningcss-darwin-x64": "1.30.1", + "lightningcss-freebsd-x64": "1.30.1", + "lightningcss-linux-arm-gnueabihf": "1.30.1", + "lightningcss-linux-arm64-gnu": "1.30.1", + "lightningcss-linux-arm64-musl": "1.30.1", + "lightningcss-linux-x64-gnu": "1.30.1", + "lightningcss-linux-x64-musl": "1.30.1", + "lightningcss-win32-arm64-msvc": "1.30.1", + "lightningcss-win32-x64-msvc": "1.30.1" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz", + "integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz", + "integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz", + "integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz", + "integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz", + "integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz", + "integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz", + "integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz", + "integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz", + "integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz", + "integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.19", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", + "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.4.tgz", + "integrity": "sha512-xH4Yjhb82sFYQfY3vbkJfgSDgXvBB6a8xPs9i35k6oZJRoQRihZH+4s9Yo2qsWpzBmZ3lPXaJ2KPXLfkvW4LnA==", + "license": "MIT", + "dependencies": { + "@next/env": "15.5.4", + "@swc/helpers": "0.5.15", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.5.4", + "@next/swc-darwin-x64": "15.5.4", + "@next/swc-linux-arm64-gnu": "15.5.4", + "@next/swc-linux-arm64-musl": "15.5.4", + "@next/swc-linux-x64-gnu": "15.5.4", + "@next/swc-linux-x64-musl": "15.5.4", + "@next/swc-win32-arm64-msvc": "15.5.4", + "@next/swc-win32-x64-msvc": "15.5.4", + "sharp": "^0.34.3" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-releases": { + "version": "2.0.23", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.23.tgz", + "integrity": "sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==", + "license": "MIT" + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/openai-realtime-api": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/openai-realtime-api/-/openai-realtime-api-1.0.8.tgz", + "integrity": "sha512-/PC/RY8/9E03IB8w61D0Grwg9fh7fcehH63BDA3Mv2hQUjPtGvx5+9CFQ9s105oSk6ag1hAzDlUBAS4uwrCu1A==", + "license": "MIT", + "dependencies": { + "nanoid": "^5.0.8", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/openai-realtime-api/node_modules/nanoid": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", + "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", + "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", + "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.4.tgz", + "integrity": "sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.0", + "semver": "^7.7.2" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.4", + "@img/sharp-darwin-x64": "0.34.4", + "@img/sharp-libvips-darwin-arm64": "1.2.3", + "@img/sharp-libvips-darwin-x64": "1.2.3", + "@img/sharp-libvips-linux-arm": "1.2.3", + "@img/sharp-libvips-linux-arm64": "1.2.3", + "@img/sharp-libvips-linux-ppc64": "1.2.3", + "@img/sharp-libvips-linux-s390x": "1.2.3", + "@img/sharp-libvips-linux-x64": "1.2.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.3", + "@img/sharp-libvips-linuxmusl-x64": "1.2.3", + "@img/sharp-linux-arm": "0.34.4", + "@img/sharp-linux-arm64": "0.34.4", + "@img/sharp-linux-ppc64": "0.34.4", + "@img/sharp-linux-s390x": "0.34.4", + "@img/sharp-linux-x64": "0.34.4", + "@img/sharp-linuxmusl-arm64": "0.34.4", + "@img/sharp-linuxmusl-x64": "0.34.4", + "@img/sharp-wasm32": "0.34.4", + "@img/sharp-win32-arm64": "0.34.4", + "@img/sharp-win32-ia32": "0.34.4", + "@img/sharp-win32-x64": "0.34.4" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/tailwindcss": { + "version": "4.1.14", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.14.tgz", + "integrity": "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tar": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.1.tgz", + "integrity": "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==", + "license": "ISC", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz", + "integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==", + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + } + } +} diff --git a/packages/web/package.json b/packages/web/package.json new file mode 100644 index 000000000..1bbe4055c --- /dev/null +++ b/packages/web/package.json @@ -0,0 +1,34 @@ +{ + "name": "@realtime-voice/web", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "keywords": [ + "openai", + "realtime", + "voice", + "nextjs" + ], + "author": "moboudra", + "license": "MIT", + "description": "OpenAI Realtime Voice Assistant web interface", + "dependencies": { + "@tailwindcss/postcss": "^4.1.14", + "@types/node": "^24.7.2", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.1", + "autoprefixer": "^10.4.21", + "next": "^15.5.4", + "openai-realtime-api": "^1.0.8", + "postcss": "^8.5.6", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "tailwindcss": "^4.1.14", + "typescript": "^5.9.3" + } +} diff --git a/packages/web/postcss.config.js b/packages/web/postcss.config.js new file mode 100644 index 000000000..52b9b4baf --- /dev/null +++ b/packages/web/postcss.config.js @@ -0,0 +1,5 @@ +module.exports = { + plugins: { + '@tailwindcss/postcss': {}, + }, +} diff --git a/packages/web/tsconfig.json b/packages/web/tsconfig.json new file mode 100644 index 000000000..1161f624d --- /dev/null +++ b/packages/web/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext"], + "target": "ES2017", + "allowJs": true, + "noEmit": true, + "jsx": "preserve", + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 000000000..fe84a6012 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020"], + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "incremental": true + } +}