From 02fc536e85cde39a578157f52f0a512592c6fcb5 Mon Sep 17 00:00:00 2001 From: OpenCode Date: Mon, 17 Aug 2026 17:06:47 +0700 Subject: [PATCH] Add NexusAI: universal MCP service (TTS/STT/image/video/music/web-search) Monorepo-lite with shared @nexusai/core and @nexusai/mcp-server (stdio). Provider-agnostic tools with explicit provider selection; NordRouter adapter (media generate/poll/download, estimate, upload, models) and search via perplexity/sonar. Local STT via faster-whisper uv sidecar. SQLite journal of every generation for usage stats and future admin. 10 MCP tools. --- NexusAI/.env.example | 35 + NexusAI/.gitignore | 25 + NexusAI/.opencode/opencode.json | 18 + NexusAI/README.md | 135 ++ NexusAI/package-lock.json | 1563 +++++++++++++++++ NexusAI/package.json | 18 + NexusAI/packages/core/package.json | 22 + NexusAI/packages/core/src/config.ts | 58 + NexusAI/packages/core/src/db/journal.ts | 141 ++ NexusAI/packages/core/src/index.ts | 12 + NexusAI/packages/core/src/media/content.ts | 36 + NexusAI/packages/core/src/media/storage.ts | 120 ++ .../core/src/providers/local-stt/whisper.ts | 139 ++ .../core/src/providers/nordrouter/client.ts | 26 + .../core/src/providers/nordrouter/index.ts | 26 + .../core/src/providers/nordrouter/media.ts | 169 ++ .../core/src/providers/nordrouter/search.ts | 72 + .../packages/core/src/providers/registry.ts | 72 + NexusAI/packages/core/src/providers/types.ts | 106 ++ NexusAI/packages/core/src/shared/errors.ts | 53 + NexusAI/packages/core/src/shared/http.ts | 148 ++ NexusAI/packages/core/src/shared/logger.ts | 42 + NexusAI/packages/core/src/shared/polling.ts | 41 + NexusAI/packages/core/tsconfig.json | 18 + NexusAI/packages/mcp-server/package.json | 25 + NexusAI/packages/mcp-server/src/context.ts | 46 + NexusAI/packages/mcp-server/src/handlers.ts | 275 +++ NexusAI/packages/mcp-server/src/index.ts | 56 + NexusAI/packages/mcp-server/src/schemas.ts | 138 ++ NexusAI/packages/mcp-server/src/tools.ts | 78 + NexusAI/packages/mcp-server/tsconfig.json | 17 + NexusAI/sidecar/stt_server.py | 131 ++ 32 files changed, 3861 insertions(+) create mode 100644 NexusAI/.env.example create mode 100644 NexusAI/.gitignore create mode 100644 NexusAI/.opencode/opencode.json create mode 100644 NexusAI/README.md create mode 100644 NexusAI/package-lock.json create mode 100644 NexusAI/package.json create mode 100644 NexusAI/packages/core/package.json create mode 100644 NexusAI/packages/core/src/config.ts create mode 100644 NexusAI/packages/core/src/db/journal.ts create mode 100644 NexusAI/packages/core/src/index.ts create mode 100644 NexusAI/packages/core/src/media/content.ts create mode 100644 NexusAI/packages/core/src/media/storage.ts create mode 100644 NexusAI/packages/core/src/providers/local-stt/whisper.ts create mode 100644 NexusAI/packages/core/src/providers/nordrouter/client.ts create mode 100644 NexusAI/packages/core/src/providers/nordrouter/index.ts create mode 100644 NexusAI/packages/core/src/providers/nordrouter/media.ts create mode 100644 NexusAI/packages/core/src/providers/nordrouter/search.ts create mode 100644 NexusAI/packages/core/src/providers/registry.ts create mode 100644 NexusAI/packages/core/src/providers/types.ts create mode 100644 NexusAI/packages/core/src/shared/errors.ts create mode 100644 NexusAI/packages/core/src/shared/http.ts create mode 100644 NexusAI/packages/core/src/shared/logger.ts create mode 100644 NexusAI/packages/core/src/shared/polling.ts create mode 100644 NexusAI/packages/core/tsconfig.json create mode 100644 NexusAI/packages/mcp-server/package.json create mode 100644 NexusAI/packages/mcp-server/src/context.ts create mode 100644 NexusAI/packages/mcp-server/src/handlers.ts create mode 100644 NexusAI/packages/mcp-server/src/index.ts create mode 100644 NexusAI/packages/mcp-server/src/schemas.ts create mode 100644 NexusAI/packages/mcp-server/src/tools.ts create mode 100644 NexusAI/packages/mcp-server/tsconfig.json create mode 100644 NexusAI/sidecar/stt_server.py diff --git a/NexusAI/.env.example b/NexusAI/.env.example new file mode 100644 index 0000000..6f6b070 --- /dev/null +++ b/NexusAI/.env.example @@ -0,0 +1,35 @@ +# --- NordRouter --- +# API key from your NordRouter account (starts with sk-) +NORDROUTER_API_KEY=sk-your-key-here +NORDROUTER_BASE_URL=https://nordrouter.com + +# --- NexusAI general --- +NEXUS_DEFAULT_PROVIDER=nordrouter +# Where generated media files and the SQLite journal are stored +NEXUS_MEDIA_DIR=./media-output +# Optional: custom SQLite path (defaults to /nexus.sqlite) +# NEXUS_DB_PATH=./media-output/nexus.sqlite +# Max bytes for embedding image/audio directly in MCP results (default 10MB) +NEXUS_EMBED_MAX_BYTES=10485760 +# Log level: debug | info | warn | error (logs go to stderr) +NEXUS_LOG_LEVEL=info + +# --- Local STT (faster-whisper via uv sidecar) --- +# Launcher: "uv" (recommended, auto-installs deps) or a python interpreter path +NEXUS_STT_PYTHON=uv +# Whisper size (tiny|base|small|medium|large-v3) OR an absolute path to a +# pre-downloaded CTranslate2 model directory +NEXUS_STT_MODEL=small +NEXUS_STT_DEVICE=auto # auto | cpu | cuda +NEXUS_STT_COMPUTE=auto # auto | int8 | float16 +# Offline mode: require a locally present model (no downloads) +# NEXUS_STT_LOCAL_ONLY=1 +# Cache dir for downloaded models +# NEXUS_STT_DOWNLOAD_ROOT=./models +# Optional custom sidecar path +# NEXUS_STT_SIDECAR=/abs/path/to/sidecar/stt_server.py +# HuggingFace mirror (for restricted networks) +# HF_ENDPOINT=https://hf-mirror.com + +# --- Proxy (optional) --- +# NEXUS_HTTP_PROXY=http://proxy:8080 diff --git a/NexusAI/.gitignore b/NexusAI/.gitignore new file mode 100644 index 0000000..118649b --- /dev/null +++ b/NexusAI/.gitignore @@ -0,0 +1,25 @@ + +# secrets +api-sec.txt +.env +.env.* +!.env.example + +# node +node_modules/ +**/dist/ +*.tsbuildinfo + +# data / media +media-output/ +*.sqlite +*.sqlite-journal +*.db + +# python +sidecar/.venv/ +__pycache__/ +*.pyc + +# os +.DS_Store diff --git a/NexusAI/.opencode/opencode.json b/NexusAI/.opencode/opencode.json new file mode 100644 index 0000000..ab30cc4 --- /dev/null +++ b/NexusAI/.opencode/opencode.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "nexusai": { + "type": "local", + "command": [ + "node", + "/home/user/moder/mcp/NexusAI/packages/mcp-server/dist/index.js" + ], + "env": { + "NORDROUTER_API_KEY": "sk-your-key-here", + "NEXUS_MEDIA_DIR": "/home/user/moder/mcp/NexusAI/media-output", + "NEXUS_STT_MODEL": "small" + }, + "enabled": true + } + } +} diff --git a/NexusAI/README.md b/NexusAI/README.md new file mode 100644 index 0000000..38a2d3c --- /dev/null +++ b/NexusAI/README.md @@ -0,0 +1,135 @@ +# NexusAI — универсальный MCP-сервис для агентов + +Единый MCP-сервер (TypeScript, stdio), дающий агентам провайдер-абстрагированный доступ к: + +- **TTS** — озвучка текста +- **STT** — распознавание речи (локально, faster-whisper) +- **Изображения** — генерация и правка +- **Видео** — text→video / image→video +- **Музыка** — генерация треков +- **Веб-поиск** — ответ с цитатами (search-augmented модели) + +Первый провайдер — **NordRouter**. Архитектура расширяемая: RouterAI, GPTunnel, OpenRouter и облачные STT добавляются как новые адаптеры без изменения MCP-инструментов. Каждая операция логируется в SQLite (провайдер, модель, стоимость, статус, файл) — основа для будущей веб-админки. + +## Структура + +``` +NexusAI/ +├── packages/ +│ ├── core/ # провайдеры, media-storage, journal, типы, registry +│ └── mcp-server/ # stdio MCP-сервер (10 инструментов) +├── sidecar/ # faster-whisper STT (PEP 723, запуск через uv) +├── .env.example +└── .opencode/opencode.json +``` + +## Установка + +```bash +cd NexusAI +npm install +npm run build +``` + +> `better-sqlite3` собирается нативно. Если сборка прервалась: `npm rebuild better-sqlite3`. + +Для STT нужен [`uv`](https://docs.astral.sh/uv/) и `ffmpeg`. Зависимости whisper `uv` ставит автоматически при первом запуске. + +## Подключение к opencode + +`.opencode/opencode.json`: + +```json +{ + "mcp": { + "nexusai": { + "type": "local", + "command": ["node", "/абс/путь/NexusAI/packages/mcp-server/dist/index.js"], + "env": { + "NORDROUTER_API_KEY": "sk-...", + "NEXUS_MEDIA_DIR": "/абс/путь/NexusAI/media-output", + "NEXUS_STT_MODEL": "small" + } + } + } +} +``` + +Переменные окружения — см. `.env.example`. + +## Инструменты + +| Инструмент | Назначение | +|---|---| +| `nexus_tts` | текст → речь (`audio/elevenlabs-tts`, `audio/gemini-3.1-flash-tts`, …) | +| `nexus_image` | генерация/правка изображений (`image/*`) | +| `nexus_video` | видео из текста/фото (`video/*`) | +| `nexus_music` | музыка (`music/*`) | +| `nexus_stt` | речь → текст (локально) | +| `nexus_web_search` | веб-поиск с цитатами (`perplexity/sonar*`, `search/*`) | +| `nexus_media_models` | список моделей провайдера (id, тип, цена) | +| `nexus_media_estimate` | оценка стоимости до запуска | +| `nexus_media_upload` | загрузка исходника (фото/аудио/видео) | +| `nexus_usage_stats` | траты и история из журнала | + +### Единый формат вызова + +```json +{ + "provider": "nordrouter", + "model": "audio/elevenlabs-tts", + "input": { "text": "Привет, мир", "voice": "James · Husky, Engaging and Bold" }, + "output": { "save": true, "embed": false } +} +``` + +- `provider` опционален (default из `NEXUS_DEFAULT_PROVIDER`). +- `input` пропускает любые модель-специфичные поля (`aspect_ratio`, `resolution`, `duration`, `sound`, …). +- `output.save` — сохранять файл локально (по умолчанию да). +- `output.embed` — встроить image/audio прямо в ответ MCP (с лимитом размера). + +### Единый формат ответа + +```json +{ + "provider": "nordrouter", + "capability": "tts", + "model": "audio/elevenlabs-tts", + "status": "done", + "file_path": "/.../media-output/audio-...wav", + "mime_type": "audio/wav", + "result_url": "https://nordrouter.com/media/file/...", + "result_url_2": null, + "cost_usd": 0.0049 +} +``` + +`result_url_2` заполняется для музыкальных моделей, отдающих два трека (Suno). + +## Как это работает + +- **Media** асинхронно: `POST /media/generate` → polling `GET /media/job/:id` → скачивание `result_url` в `NEXUS_MEDIA_DIR`. Таймаут зависит от типа (картинки — быстро, видео/музыка — до минут). +- **Web search** через OpenAI-совместимый `/v1/chat/completions` с sonar/`search/*`; цитаты берутся из `message.annotations[].url_citation`. +- **STT** локально: TS запускает `uv run sidecar/stt_server.py`; faster-whisper декодирует аудио (mp3/wav/ogg/m4a/webm) и возвращает `{text, language, segments}`. +- **Журнал** SQLite (`nexus.sqlite` в media-dir) пишет каждую операцию; `nexus_usage_stats` агрегирует траты. +- MCP работает по stdio — **все логи идут в stderr**. + +## STT в ограниченных сетях + +faster-whisper скачивает модель с HuggingFace. Если `huggingface.co` недоступен: + +- задать зеркало: `HF_ENDPOINT=https://hf-mirror.com` +- либо предзагрузить модель и указать путь: `NEXUS_STT_MODEL=/abs/model-dir` + `NEXUS_STT_LOCAL_ONLY=1` +- либо кэш-директорию: `NEXUS_STT_DOWNLOAD_ROOT=./models` + +## Расширение: новый провайдер + +1. Реализовать `MediaProvider` / `SearchProvider` / `SttProvider` из `@nexusai/core`. +2. Зарегистрировать в `packages/mcp-server/src/context.ts`. + +Инструменты и схемы менять не нужно — они провайдер-агностичны. + +## Roadmap + +- Фаза 2: веб-админка (отдельный пакет `admin`, Hono/Fastify + лёгкий фронт) на общем `core`: галерея контента, дашборд трат, управление провайдерами. +- Доп. провайдеры: RouterAI, GPTunnel, OpenRouter; облачный STT. diff --git a/NexusAI/package-lock.json b/NexusAI/package-lock.json new file mode 100644 index 0000000..c8e11f5 --- /dev/null +++ b/NexusAI/package-lock.json @@ -0,0 +1,1563 @@ +{ + "name": "nexusai", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nexusai", + "version": "0.1.0", + "license": "MIT", + "workspaces": [ + "packages/core", + "packages/mcp-server" + ] + }, + "node_modules/@hono/node-server": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", + "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "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.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@nexusai/core": { + "resolved": "packages/core", + "link": true + }, + "node_modules/@nexusai/mcp-server": { + "resolved": "packages/mcp-server", + "link": true + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "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": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "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/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "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==", + "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==", + "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/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "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.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "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==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "engines": { + "node": ">=4.0.0" + } + }, + "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/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "engines": { + "node": ">=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==", + "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/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dependencies": { + "once": "^1.4.0" + } + }, + "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==", + "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==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.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": "8.6.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.2.tgz", + "integrity": "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "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==" + }, + "node_modules/fast-uri": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "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": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" + }, + "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==", + "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==", + "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==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "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==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.13.2", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.2.tgz", + "integrity": "sha512-JydRilDRkYBQMt9qR9U92mXxmbGqsqSn/IKOrh4e7/gEbn+0zSr8igTu0obwJoNGN4sez28DIql7FBHWydoJpA==", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "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/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" + }, + "node_modules/ip-address": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", + "engines": { + "node": ">= 12" + } + }, + "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==" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + }, + "node_modules/jose": { + "version": "6.2.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.9.tgz", + "integrity": "sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==" + }, + "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==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" + }, + "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==" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==" + }, + "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/node-abi": { + "version": "3.94.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", + "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "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==", + "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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "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/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "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/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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==", + "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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "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.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "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==", + "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==", + "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/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "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/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "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/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true + }, + "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/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "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==", + "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.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, + "packages/core": { + "name": "@nexusai/core", + "version": "0.1.0", + "dependencies": { + "better-sqlite3": "^11.8.0", + "undici": "^6.27.0", + "zod": "^3.25.0" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.0", + "@types/node": "^22.15.0", + "typescript": "^5.8.0" + } + }, + "packages/mcp-server": { + "version": "0.1.0", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.0", + "@nexusai/core": "*", + "zod": "^3.25.0", + "zod-to-json-schema": "^3.25.0" + }, + "bin": { + "nexusai-mcp": "dist/index.js" + }, + "devDependencies": { + "@types/node": "^22.15.0", + "typescript": "^5.8.0" + } + } + } +} diff --git a/NexusAI/package.json b/NexusAI/package.json new file mode 100644 index 0000000..b0a41a7 --- /dev/null +++ b/NexusAI/package.json @@ -0,0 +1,18 @@ +{ + "name": "nexusai", + "version": "0.1.0", + "private": true, + "description": "Universal MCP service for agents: TTS, STT, image/video/music generation and web search", + "workspaces": [ + "packages/core", + "packages/mcp-server" + ], + "scripts": { + "build": "npm run build --workspace @nexusai/core && npm run build --workspace @nexusai/mcp-server", + "build:core": "npm run build --workspace @nexusai/core", + "build:mcp": "npm run build --workspace @nexusai/mcp-server", + "start": "node packages/mcp-server/dist/index.js", + "dev": "npm run build && node packages/mcp-server/dist/index.js" + }, + "license": "MIT" +} diff --git a/NexusAI/packages/core/package.json b/NexusAI/packages/core/package.json new file mode 100644 index 0000000..eb15566 --- /dev/null +++ b/NexusAI/packages/core/package.json @@ -0,0 +1,22 @@ +{ + "name": "@nexusai/core", + "version": "0.1.0", + "description": "NexusAI shared core: providers, media storage, journal, types", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "type": "commonjs", + "scripts": { + "build": "tsc", + "dev": "tsc --watch" + }, + "dependencies": { + "better-sqlite3": "^11.8.0", + "undici": "^6.27.0", + "zod": "^3.25.0" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.0", + "@types/node": "^22.15.0", + "typescript": "^5.8.0" + } +} diff --git a/NexusAI/packages/core/src/config.ts b/NexusAI/packages/core/src/config.ts new file mode 100644 index 0000000..2ec364e --- /dev/null +++ b/NexusAI/packages/core/src/config.ts @@ -0,0 +1,58 @@ +import * as os from 'os'; +import * as path from 'path'; + +export interface NordRouterConfig { + apiKey?: string; + baseUrl: string; +} + +export interface SttConfig { + /** Path to python interpreter or "uv" launcher command. */ + pythonCmd: string; + sidecarPath?: string; + model: string; // whisper size: tiny/base/small/medium/large-v3 + device: string; // cpu / cuda / auto + computeType: string; // int8 / float16 / auto +} + +export interface NexusConfig { + defaultProvider: string; + mediaDir: string; + dbPath: string; + embedMaxBytes: number; + nordrouter: NordRouterConfig; + stt: SttConfig; +} + +function envInt(name: string, fallback: number): number { + const raw = process.env[name]; + if (!raw) return fallback; + const n = Number.parseInt(raw, 10); + return Number.isFinite(n) ? n : fallback; +} + +export function loadConfig(): NexusConfig { + const mediaDir = + process.env.NEXUS_MEDIA_DIR || path.join(process.cwd(), 'media-output'); + const dbPath = process.env.NEXUS_DB_PATH || path.join(mediaDir, 'nexus.sqlite'); + + return { + defaultProvider: process.env.NEXUS_DEFAULT_PROVIDER || 'nordrouter', + mediaDir, + dbPath, + embedMaxBytes: envInt('NEXUS_EMBED_MAX_BYTES', 10 * 1024 * 1024), + nordrouter: { + apiKey: process.env.NORDROUTER_API_KEY, + baseUrl: (process.env.NORDROUTER_BASE_URL || 'https://nordrouter.com').replace(/\/$/, ''), + }, + stt: { + pythonCmd: process.env.NEXUS_STT_PYTHON || 'uv', + sidecarPath: process.env.NEXUS_STT_SIDECAR, + model: process.env.NEXUS_STT_MODEL || 'small', + device: process.env.NEXUS_STT_DEVICE || 'auto', + computeType: process.env.NEXUS_STT_COMPUTE || 'auto', + }, + }; +} + +export const HOME_DIR = os.homedir(); diff --git a/NexusAI/packages/core/src/db/journal.ts b/NexusAI/packages/core/src/db/journal.ts new file mode 100644 index 0000000..8f24886 --- /dev/null +++ b/NexusAI/packages/core/src/db/journal.ts @@ -0,0 +1,141 @@ +import Database from 'better-sqlite3'; +import * as fs from 'fs'; +import * as path from 'path'; +import { createLogger } from '../shared/logger.js'; + +const log = createLogger('journal'); + +export interface GenerationRecord { + id: string; + provider: string; + capability: string; + model: string; + inputSummary: string; + status: 'done' | 'failed'; + costUsd: number | null; + filePath: string | null; + resultUrl: string | null; + error: string | null; + durationMs: number | null; + createdAt: string; +} + +export interface UsageStats { + totalCostUsd: number; + totalCount: number; + byProvider: Array<{ provider: string; count: number; costUsd: number }>; + byCapability: Array<{ capability: string; count: number; costUsd: number }>; + byDay: Array<{ day: string; count: number; costUsd: number }>; +} + +/** + * SQLite-backed journal of every generation. Enables usage stats now and a + * shared data source for the future admin package. + */ +export class Journal { + private readonly db: Database.Database; + + constructor(dbPath: string) { + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + this.db = new Database(dbPath); + this.db.pragma('journal_mode = WAL'); + this.init(); + } + + private init(): void { + this.db.exec(` + CREATE TABLE IF NOT EXISTS generations ( + id TEXT PRIMARY KEY, + provider TEXT NOT NULL, + capability TEXT NOT NULL, + model TEXT NOT NULL, + input_summary TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL, + cost_usd REAL, + file_path TEXT, + result_url TEXT, + error TEXT, + duration_ms INTEGER, + created_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_gen_created ON generations(created_at); + CREATE INDEX IF NOT EXISTS idx_gen_provider ON generations(provider); + CREATE INDEX IF NOT EXISTS idx_gen_capability ON generations(capability); + `); + } + + record(rec: GenerationRecord): void { + try { + this.db + .prepare( + `INSERT INTO generations + (id, provider, capability, model, input_summary, status, cost_usd, file_path, result_url, error, duration_ms, created_at) + VALUES (@id, @provider, @capability, @model, @inputSummary, @status, @costUsd, @filePath, @resultUrl, @error, @durationMs, @createdAt)` + ) + .run(rec); + } catch (err) { + // Journaling must never break a successful generation. + log.warn('failed to record generation', err instanceof Error ? err.message : String(err)); + } + } + + list(limit = 50, offset = 0, capability?: string): GenerationRecord[] { + const where = capability ? 'WHERE capability = ?' : ''; + const params = capability ? [capability, limit, offset] : [limit, offset]; + const rows = this.db + .prepare( + `SELECT id, provider, capability, model, input_summary as inputSummary, status, + cost_usd as costUsd, file_path as filePath, result_url as resultUrl, + error, duration_ms as durationMs, created_at as createdAt + FROM generations ${where} + ORDER BY created_at DESC LIMIT ? OFFSET ?` + ) + .all(...params); + return rows as GenerationRecord[]; + } + + stats(sinceDays?: number): UsageStats { + const filter = sinceDays + ? `WHERE created_at >= datetime('now', '-${Math.max(1, Math.floor(sinceDays))} days')` + : ''; + + const total = this.db + .prepare( + `SELECT COALESCE(SUM(cost_usd),0) as cost, COUNT(*) as cnt FROM generations ${filter}` + ) + .get() as { cost: number; cnt: number }; + + const byProvider = this.db + .prepare( + `SELECT provider, COUNT(*) as count, COALESCE(SUM(cost_usd),0) as costUsd + FROM generations ${filter} GROUP BY provider ORDER BY costUsd DESC` + ) + .all() as UsageStats['byProvider']; + + const byCapability = this.db + .prepare( + `SELECT capability, COUNT(*) as count, COALESCE(SUM(cost_usd),0) as costUsd + FROM generations ${filter} GROUP BY capability ORDER BY costUsd DESC` + ) + .all() as UsageStats['byCapability']; + + const byDay = this.db + .prepare( + `SELECT substr(created_at,1,10) as day, COUNT(*) as count, COALESCE(SUM(cost_usd),0) as costUsd + FROM generations ${filter} GROUP BY day ORDER BY day DESC LIMIT 30` + ) + .all() as UsageStats['byDay']; + + return { + totalCostUsd: total.cost, + totalCount: total.cnt, + byProvider, + byCapability, + byDay, + }; + } + + close(): void { + this.db.close(); + } +} diff --git a/NexusAI/packages/core/src/index.ts b/NexusAI/packages/core/src/index.ts new file mode 100644 index 0000000..6e4cb20 --- /dev/null +++ b/NexusAI/packages/core/src/index.ts @@ -0,0 +1,12 @@ +export * from './config.js'; +export * from './shared/errors.js'; +export * from './shared/logger.js'; +export * from './shared/http.js'; +export * from './shared/polling.js'; +export * from './db/journal.js'; +export * from './media/storage.js'; +export * from './media/content.js'; +export * from './providers/types.js'; +export * from './providers/registry.js'; +export * from './providers/nordrouter/index.js'; +export * from './providers/local-stt/whisper.js'; diff --git a/NexusAI/packages/core/src/media/content.ts b/NexusAI/packages/core/src/media/content.ts new file mode 100644 index 0000000..11e037d --- /dev/null +++ b/NexusAI/packages/core/src/media/content.ts @@ -0,0 +1,36 @@ +import * as fs from 'fs'; +import { extToMime } from './storage.js'; +import * as path from 'path'; + +/** + * MCP content block for embedding media directly in a tool result. + * The MCP SDK accepts `image` and `audio` blocks with base64 data + mimeType. + */ +export type EmbeddedContent = + | { type: 'image'; data: string; mimeType: string } + | { type: 'audio'; data: string; mimeType: string }; + +/** + * Build an embedded content block from a local file, respecting a byte cap. + * Returns null when the file is missing, too large, or not embeddable. + */ +export function buildEmbeddedContent( + filePath: string, + mimeType: string | undefined, + maxBytes: number +): EmbeddedContent | null { + try { + const st = fs.statSync(filePath); + if (!st.isFile() || st.size > maxBytes) return null; + + const mime = mimeType || extToMime(path.extname(filePath)); + const buf = fs.readFileSync(filePath); + const data = buf.toString('base64'); + + if (mime.startsWith('image/')) return { type: 'image', data, mimeType: mime }; + if (mime.startsWith('audio/')) return { type: 'audio', data, mimeType: mime }; + return null; // video/other are not embeddable as MCP content + } catch { + return null; + } +} diff --git a/NexusAI/packages/core/src/media/storage.ts b/NexusAI/packages/core/src/media/storage.ts new file mode 100644 index 0000000..2072d93 --- /dev/null +++ b/NexusAI/packages/core/src/media/storage.ts @@ -0,0 +1,120 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { fetch, ProxyAgent } from 'undici'; +import { NexusError } from '../shared/errors.js'; +import { createLogger } from '../shared/logger.js'; + +const log = createLogger('storage'); + +const EXT_BY_MIME: Record = { + 'audio/mpeg': 'mp3', + 'audio/mp3': 'mp3', + 'audio/wav': 'wav', + 'audio/x-wav': 'wav', + 'audio/ogg': 'ogg', + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/webp': 'webp', + 'video/mp4': 'mp4', + 'application/octet-stream': 'bin', +}; + +const MIME_BY_EXT: Record = { + mp3: 'audio/mpeg', + wav: 'audio/wav', + ogg: 'audio/ogg', + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + webp: 'image/webp', + mp4: 'video/mp4', +}; + +export function mimeToExt(mime?: string): string { + if (!mime) return 'bin'; + return EXT_BY_MIME[mime.toLowerCase()] || 'bin'; +} + +export function extToMime(ext: string): string { + return MIME_BY_EXT[ext.toLowerCase().replace(/^\./, '')] || 'application/octet-stream'; +} + +function proxyDispatcher(): ProxyAgent | undefined { + const url = + process.env.NEXUS_HTTP_PROXY || process.env.HTTPS_PROXY || process.env.HTTP_PROXY; + return url ? new ProxyAgent(url) : undefined; +} + +export interface SaveResult { + filePath: string; + mimeType: string; + bytes: number; +} + +export class MediaStorage { + constructor(private readonly baseDir: string) { + fs.mkdirSync(baseDir, { recursive: true }); + } + + private uniqueName(prefix: string, ext: string): string { + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + const rand = Math.random().toString(36).slice(2, 8); + return `${prefix}-${stamp}-${rand}.${ext}`; + } + + /** Download a remote URL to a local file. */ + async downloadUrl(url: string, prefix: string): Promise { + const dispatcher = proxyDispatcher(); + const res = await fetch(url, dispatcher ? { dispatcher } : {}); + if (!res.ok) { + throw new NexusError('IO', `Failed to download result (${res.status}) from ${url}`); + } + const mimeType = res.headers.get('content-type')?.split(';')[0]?.trim() || 'application/octet-stream'; + const urlExt = path.extname(new URL(url).pathname).replace(/^\./, ''); + const ext = urlExt || mimeToExt(mimeType); + const buf = Buffer.from(await res.arrayBuffer()); + const filePath = path.join(this.baseDir, this.uniqueName(prefix, ext)); + fs.writeFileSync(filePath, buf); + log.debug('saved file', { filePath, bytes: buf.length }); + return { filePath, mimeType: mimeType === 'application/octet-stream' ? extToMime(ext) : mimeType, bytes: buf.length }; + } + + /** Save a base64 (optionally data-URL) payload to disk. */ + saveBase64(data: string, prefix: string, mimeHint?: string): SaveResult { + let mimeType = mimeHint || 'application/octet-stream'; + let b64 = data; + const dataUrlMatch = /^data:([^;]+);base64,(.*)$/s.exec(data); + if (dataUrlMatch) { + mimeType = dataUrlMatch[1]; + b64 = dataUrlMatch[2]; + } + const buf = Buffer.from(b64, 'base64'); + const ext = mimeToExt(mimeType); + const filePath = path.join(this.baseDir, this.uniqueName(prefix, ext)); + fs.writeFileSync(filePath, buf); + return { filePath, mimeType, bytes: buf.length }; + } + + /** Remove files older than ttlDays. Best-effort. */ + cleanup(ttlDays: number): number { + let removed = 0; + const cutoff = Date.now() - ttlDays * 24 * 60 * 60 * 1000; + try { + for (const name of fs.readdirSync(this.baseDir)) { + const full = path.join(this.baseDir, name); + try { + const st = fs.statSync(full); + if (st.isFile() && st.mtimeMs < cutoff && !name.endsWith('.sqlite') && !name.includes('nexus.sqlite')) { + fs.unlinkSync(full); + removed += 1; + } + } catch { + /* ignore per-file errors */ + } + } + } catch { + /* ignore */ + } + return removed; + } +} diff --git a/NexusAI/packages/core/src/providers/local-stt/whisper.ts b/NexusAI/packages/core/src/providers/local-stt/whisper.ts new file mode 100644 index 0000000..7cb14fd --- /dev/null +++ b/NexusAI/packages/core/src/providers/local-stt/whisper.ts @@ -0,0 +1,139 @@ +import { spawn } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { fetch, ProxyAgent } from 'undici'; +import type { SttConfig } from '../../config.js'; +import { NexusError } from '../../shared/errors.js'; +import { createLogger } from '../../shared/logger.js'; +import type { SttInput, SttProvider, SttResult } from '../types.js'; + +const log = createLogger('local-stt'); + +interface SidecarResponse { + ok: boolean; + text?: string; + language?: string; + duration?: number; + segments?: Array<{ start: number; end: number; text: string }>; + error?: string; +} + +/** + * Local speech-to-text via a faster-whisper Python sidecar launched with `uv`. + * The sidecar reads a single JSON request on argv and prints a JSON response. + * Architecture allows swapping in cloud STT adapters later without MCP changes. + */ +export class LocalWhisperProvider implements SttProvider { + readonly name = 'local'; + + constructor(private readonly cfg: SttConfig) {} + + private resolveSidecar(): string { + if (this.cfg.sidecarPath) return this.cfg.sidecarPath; + // packages/core/dist/providers/local-stt/ -> repo/sidecar/stt_server.py + const guess = path.resolve(__dirname, '../../../../../sidecar/stt_server.py'); + return guess; + } + + private async materializeAudio(input: SttInput): Promise<{ file: string; cleanup: boolean }> { + if (input.audioPath) { + if (!fs.existsSync(input.audioPath)) { + throw new NexusError('IO', `Audio file not found: ${input.audioPath}`); + } + return { file: input.audioPath, cleanup: false }; + } + if (input.audioBase64) { + const m = /^data:([^;]+);base64,(.*)$/s.exec(input.audioBase64); + const b64 = m ? m[2] : input.audioBase64; + const tmp = path.join(os.tmpdir(), `nexus-stt-${Date.now()}-${Math.random().toString(36).slice(2)}.audio`); + fs.writeFileSync(tmp, Buffer.from(b64, 'base64')); + return { file: tmp, cleanup: true }; + } + if (input.audioUrl) { + const proxy = process.env.NEXUS_HTTP_PROXY || process.env.HTTPS_PROXY || process.env.HTTP_PROXY; + const res = await fetch(input.audioUrl, proxy ? { dispatcher: new ProxyAgent(proxy) } : {}); + if (!res.ok) throw new NexusError('IO', `Failed to download audio (${res.status})`); + const tmp = path.join(os.tmpdir(), `nexus-stt-${Date.now()}-${Math.random().toString(36).slice(2)}.audio`); + fs.writeFileSync(tmp, Buffer.from(await res.arrayBuffer())); + return { file: tmp, cleanup: true }; + } + throw new NexusError('BAD_REQUEST', 'Provide one of: audioPath, audioUrl, audioBase64'); + } + + async transcribe(input: SttInput): Promise { + const { file, cleanup } = await this.materializeAudio(input); + const sidecar = this.resolveSidecar(); + + const request = { + audio: file, + model: input.model || this.cfg.model, + device: this.cfg.device, + compute_type: this.cfg.computeType, + language: input.language, + }; + + // With uv: `uv run --with faster-whisper python sidecar/stt_server.py ` + const usingUv = /(^|\/)uv$/.test(this.cfg.pythonCmd) || this.cfg.pythonCmd === 'uv'; + const sidecarDir = path.dirname(sidecar); + const args = usingUv + ? ['run', sidecar, JSON.stringify(request)] + : [sidecar, JSON.stringify(request)]; + + log.debug('spawning stt sidecar', { cmd: this.cfg.pythonCmd, model: request.model }); + + try { + const response = await this.runSidecar(this.cfg.pythonCmd, args, sidecarDir); + if (!response.ok) { + throw new NexusError('PROVIDER', response.error || 'STT sidecar failed'); + } + return { + provider: this.name, + text: response.text || '', + language: response.language, + durationSec: response.duration, + segments: response.segments, + }; + } finally { + if (cleanup) { + try { + fs.unlinkSync(file); + } catch { + /* ignore */ + } + } + } + } + + private runSidecar(cmd: string, args: string[], cwd: string): Promise { + return new Promise((resolve, reject) => { + // Forward HF mirror / offline settings so the sidecar can locate models + // in restricted networks. + const env = { + ...process.env, + ...(process.env.HF_ENDPOINT ? { HF_ENDPOINT: process.env.HF_ENDPOINT } : {}), + }; + const child = spawn(cmd, args, { cwd, env }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (d) => (stdout += d.toString())); + child.stderr.on('data', (d) => (stderr += d.toString())); + child.on('error', (err) => + reject(new NexusError('CONFIG', `Failed to launch STT sidecar (${cmd}): ${err.message}`)) + ); + child.on('close', (code) => { + if (code !== 0 && !stdout.trim()) { + reject(new NexusError('PROVIDER', `STT sidecar exited ${code}: ${stderr.slice(-500)}`)); + return; + } + // The sidecar prints exactly one JSON line on stdout. + const line = stdout.trim().split('\n').filter(Boolean).pop() || ''; + try { + resolve(JSON.parse(line) as SidecarResponse); + } catch { + reject(new NexusError('PROVIDER', `Invalid STT sidecar output: ${stdout.slice(-500)} | stderr: ${stderr.slice(-300)}`)); + } + }); + }); + } +} diff --git a/NexusAI/packages/core/src/providers/nordrouter/client.ts b/NexusAI/packages/core/src/providers/nordrouter/client.ts new file mode 100644 index 0000000..847578e --- /dev/null +++ b/NexusAI/packages/core/src/providers/nordrouter/client.ts @@ -0,0 +1,26 @@ +import { HttpClient, RateLimiter } from '../../shared/http.js'; +import { NexusError } from '../../shared/errors.js'; + +/** + * Low-level NordRouter HTTP client. Shared by media, search, upload and models. + * Auth: `Authorization: Bearer sk-...`. Base: https://nordrouter.com + */ +export class NordRouterClient { + readonly http: HttpClient; + readonly baseUrl: string; + + constructor(apiKey: string | undefined, baseUrl: string) { + if (!apiKey) { + throw new NexusError('CONFIG', 'NORDROUTER_API_KEY is not set'); + } + this.baseUrl = baseUrl; + this.http = new HttpClient({ + baseUrl, + defaultHeaders: { Authorization: `Bearer ${apiKey}` }, + timeoutMs: 60_000, + maxRetries: 2, + // NordRouter rate-limits per key; keep it modest and let 429 retries handle bursts. + rateLimiter: new RateLimiter(30, 10_000), + }); + } +} diff --git a/NexusAI/packages/core/src/providers/nordrouter/index.ts b/NexusAI/packages/core/src/providers/nordrouter/index.ts new file mode 100644 index 0000000..71e817a --- /dev/null +++ b/NexusAI/packages/core/src/providers/nordrouter/index.ts @@ -0,0 +1,26 @@ +import type { NordRouterConfig } from '../../config.js'; +import type { MediaStorage } from '../../media/storage.js'; +import { NordRouterClient } from './client.js'; +import { NordRouterMediaProvider } from './media.js'; +import { NordRouterSearchProvider } from './search.js'; + +export { NordRouterClient } from './client.js'; +export { NordRouterMediaProvider } from './media.js'; +export { NordRouterSearchProvider } from './search.js'; + +export interface NordRouterFactoryOptions { + config: NordRouterConfig; + storage: MediaStorage; + save: boolean; +} + +export function createNordRouterProviders(opts: NordRouterFactoryOptions): { + media: NordRouterMediaProvider; + search: NordRouterSearchProvider; +} { + const client = new NordRouterClient(opts.config.apiKey, opts.config.baseUrl); + return { + media: new NordRouterMediaProvider(client, { save: opts.save, storage: opts.storage }), + search: new NordRouterSearchProvider(client), + }; +} diff --git a/NexusAI/packages/core/src/providers/nordrouter/media.ts b/NexusAI/packages/core/src/providers/nordrouter/media.ts new file mode 100644 index 0000000..a318cd9 --- /dev/null +++ b/NexusAI/packages/core/src/providers/nordrouter/media.ts @@ -0,0 +1,169 @@ +import * as fs from 'fs'; +import { NexusError } from '../../shared/errors.js'; +import { createLogger } from '../../shared/logger.js'; +import { pollUntilDone } from '../../shared/polling.js'; +import { MediaStorage } from '../../media/storage.js'; +import type { + Capability, + EstimateInput, + MediaProvider, + MediaRequest, + MediaResult, + ModelInfo, + UploadInput, + UploadResult, +} from '../types.js'; +import { NordRouterClient } from './client.js'; + +const log = createLogger('nordrouter:media'); + +interface JobCreateResponse { + id: string; + model: string; + status: string; + poll?: string; + webhook?: string; +} + +interface JobStatusResponse { + id: string; + status: 'processing' | 'done' | 'failed'; + result_url: string | null; + result_url_2?: string | null; + results?: Array<{ index: number; name: string; url: string }>; + cost_usd?: number | null; + error?: string | null; +} + +interface ModelsResponse { + models: Array<{ id: string; type?: string; label?: string; est_usd?: number; fields?: unknown }>; + retention_days?: number; +} + +/** Timeout per capability — images are quick, video/music take longer. */ +const TIMEOUT_MS: Record = { + image: 3 * 60 * 1000, + tts: 3 * 60 * 1000, + music: 8 * 60 * 1000, + video: 15 * 60 * 1000, + stt: 3 * 60 * 1000, + search: 60 * 1000, +}; + +const PREFIX: Record = { + image: 'image', + tts: 'audio', + music: 'music', + video: 'video', + stt: 'stt', + search: 'search', +}; + +export interface NordRouterMediaOptions { + save: boolean; + storage?: MediaStorage; + pollIntervalMs?: number; +} + +export class NordRouterMediaProvider implements MediaProvider { + readonly name = 'nordrouter'; + readonly capabilities: Capability[] = ['tts', 'image', 'video', 'music']; + + private modelsCache?: { at: number; models: ModelInfo[] }; + private readonly modelsTtlMs = 10 * 60 * 1000; + + constructor( + private readonly client: NordRouterClient, + private readonly defaults: NordRouterMediaOptions + ) {} + + async generate(capability: Capability, req: MediaRequest): Promise { + const created = await this.client.http.requestJson({ + method: 'POST', + path: '/media/generate', + body: { model: req.model, input: req.input }, + }); + + if (!created.id) { + throw new NexusError('PROVIDER', 'NordRouter did not return a job id', created); + } + log.debug('job created', { id: created.id, model: req.model }); + + const final = await pollUntilDone({ + intervalMs: this.defaults.pollIntervalMs ?? 3000, + timeoutMs: TIMEOUT_MS[capability], + poll: async () => { + const s = await this.client.http.requestJson({ + method: 'GET', + path: `/media/job/${encodeURIComponent(created.id)}`, + }); + if (s.status === 'failed') return { done: false, failed: true, error: s.error || 'job failed' }; + if (s.status === 'done') return { done: true, value: s }; + return { done: false }; + }, + }); + + const result: MediaResult = { + provider: this.name, + capability, + model: req.model, + status: 'done', + resultUrl: final.result_url ?? undefined, + resultUrl2: final.result_url_2 ?? undefined, + costUsd: final.cost_usd ?? undefined, + raw: final, + }; + + if (this.defaults.save && this.defaults.storage && final.result_url) { + const saved = await this.defaults.storage.downloadUrl(final.result_url, PREFIX[capability]); + result.filePath = saved.filePath; + result.mimeType = saved.mimeType; + } + + return result; + } + + async estimate(req: EstimateInput): Promise<{ usd: number }> { + return this.client.http.requestJson<{ usd: number }>({ + method: 'POST', + path: '/media/estimate', + body: { model: req.model, input: req.input }, + }); + } + + async upload(input: UploadInput): Promise { + if (!fs.existsSync(input.path)) { + throw new NexusError('IO', `Upload file not found: ${input.path}`); + } + const buf = fs.readFileSync(input.path); + const mime = input.mimeType || 'application/octet-stream'; + const name = input.name || input.path.split('/').pop() || 'file'; + const dataUrl = `data:${mime};base64,${buf.toString('base64')}`; + const res = await this.client.http.requestJson<{ url?: string }>({ + method: 'POST', + path: '/media/upload', + body: { data: dataUrl, name }, + }); + return { ref: res, url: res.url }; + } + + async listModels(): Promise { + const now = Date.now(); + if (this.modelsCache && now - this.modelsCache.at < this.modelsTtlMs) { + return this.modelsCache.models; + } + const res = await this.client.http.requestJson({ + method: 'GET', + path: '/media/models', + }); + const models: ModelInfo[] = (res.models || []).map((m) => ({ + id: m.id, + type: m.type, + label: m.label, + estUsd: m.est_usd, + fields: m.fields, + })); + this.modelsCache = { at: now, models }; + return models; + } +} diff --git a/NexusAI/packages/core/src/providers/nordrouter/search.ts b/NexusAI/packages/core/src/providers/nordrouter/search.ts new file mode 100644 index 0000000..9604876 --- /dev/null +++ b/NexusAI/packages/core/src/providers/nordrouter/search.ts @@ -0,0 +1,72 @@ +import { createLogger } from '../../shared/logger.js'; +import type { SearchInput, SearchProvider, SearchResult } from '../types.js'; +import { NordRouterClient } from './client.js'; + +const log = createLogger('nordrouter:search'); + +interface ChatResponse { + choices: Array<{ + message: { + content: string; + annotations?: Array<{ + type: string; + url_citation?: { url: string; title?: string }; + }>; + }; + }>; + usage?: { total_cost?: number }; +} + +const DEFAULT_MODEL = 'perplexity/sonar-pro'; + +/** + * Web search via NordRouter's OpenAI-compatible chat endpoint using + * Perplexity sonar / search-augmented models. Citations arrive in + * `message.annotations[].url_citation`. + */ +export class NordRouterSearchProvider implements SearchProvider { + readonly name = 'nordrouter'; + + constructor(private readonly client: NordRouterClient) {} + + async search(input: SearchInput): Promise { + const model = input.model || DEFAULT_MODEL; + let query = input.query; + if (input.recency) { + query += `\n\n(Focus on information from the last ${input.recency}.)`; + } + + const res = await this.client.http.requestJson({ + method: 'POST', + path: '/v1/chat/completions', + body: { model, messages: [{ role: 'user', content: query }] }, + timeoutMs: 90_000, + }); + + const choice = res.choices?.[0]?.message; + const answer = choice?.content ?? ''; + const seen = new Set(); + const citations: SearchResult['citations'] = []; + let idx = 1; + for (const a of choice?.annotations ?? []) { + const c = a.url_citation; + if (c?.url && !seen.has(c.url)) { + seen.add(c.url); + citations.push({ index: idx++, url: c.url, title: c.title }); + } + } + if (input.maxResults && citations.length > input.maxResults) { + citations.length = input.maxResults; + } + + log.debug('search done', { model, citations: citations.length }); + return { + provider: this.name, + model, + answer, + citations, + costUsd: res.usage?.total_cost, + raw: res, + }; + } +} diff --git a/NexusAI/packages/core/src/providers/registry.ts b/NexusAI/packages/core/src/providers/registry.ts new file mode 100644 index 0000000..dd1af90 --- /dev/null +++ b/NexusAI/packages/core/src/providers/registry.ts @@ -0,0 +1,72 @@ +import { NexusError } from '../shared/errors.js'; +import type { Capability, MediaProvider, SearchProvider, SttProvider } from './types.js'; + +/** + * Registry maps a provider name + capability to the concrete adapter. + * Adding RouterAI / GPTunnel / OpenRouter / cloud STT means registering a new + * adapter here — no changes needed in the MCP tool layer. + */ +export class ProviderRegistry { + private media = new Map(); + private search = new Map(); + private stt = new Map(); + + registerMedia(provider: MediaProvider): void { + this.media.set(provider.name, provider); + } + registerSearch(provider: SearchProvider): void { + this.search.set(provider.name, provider); + } + registerStt(provider: SttProvider): void { + this.stt.set(provider.name, provider); + } + + getMedia(name: string, capability: Capability): MediaProvider { + const p = this.media.get(name); + if (!p) { + throw new NexusError( + 'UNSUPPORTED', + `Unknown media provider "${name}". Available: ${[...this.media.keys()].join(', ') || 'none'}` + ); + } + if (!p.capabilities.includes(capability)) { + throw new NexusError( + 'UNSUPPORTED', + `Provider "${name}" does not support capability "${capability}"` + ); + } + return p; + } + + getSearch(name: string): SearchProvider { + const p = this.search.get(name); + if (!p) { + throw new NexusError( + 'UNSUPPORTED', + `Unknown search provider "${name}". Available: ${[...this.search.keys()].join(', ') || 'none'}` + ); + } + return p; + } + + getStt(name: string): SttProvider { + const p = this.stt.get(name); + if (!p) { + throw new NexusError( + 'UNSUPPORTED', + `Unknown STT provider "${name}". Available: ${[...this.stt.keys()].join(', ') || 'none'}` + ); + } + return p; + } + + listMediaProviders(): string[] { + return [...this.media.keys()]; + } + listSearchProviders(): string[] { + return [...this.search.keys()]; + } + listSttProviders(): string[] { + return [...this.stt.keys()]; + } +} diff --git a/NexusAI/packages/core/src/providers/types.ts b/NexusAI/packages/core/src/providers/types.ts new file mode 100644 index 0000000..27654d2 --- /dev/null +++ b/NexusAI/packages/core/src/providers/types.ts @@ -0,0 +1,106 @@ +/** + * Provider abstraction. Adapters (NordRouter, RouterAI, GPTunnel, OpenRouter, + * local STT) implement one or more of these capabilities. The MCP layer stays + * provider-agnostic and selects an adapter by an explicit `provider` argument. + */ + +export type Capability = 'tts' | 'stt' | 'image' | 'video' | 'music' | 'search'; + +export interface MediaRequest { + model: string; + input: Record; +} + +export interface MediaResult { + provider: string; + capability: Capability; + model: string; + status: 'done'; + /** Local saved file path (when storage enabled). */ + filePath?: string; + mimeType?: string; + /** Remote URL from provider (may expire). */ + resultUrl?: string; + /** Second track for music providers (Suno). */ + resultUrl2?: string; + costUsd?: number; + raw?: unknown; +} + +export interface SttInput { + /** One of: local path, remote URL, or base64 data URL. */ + audioPath?: string; + audioUrl?: string; + audioBase64?: string; + language?: string; + model?: string; +} + +export interface SttResult { + provider: string; + text: string; + language?: string; + durationSec?: number; + segments?: Array<{ start: number; end: number; text: string }>; +} + +export interface SearchInput { + query: string; + model?: string; + recency?: 'day' | 'week' | 'month' | 'year'; + maxResults?: number; +} + +export interface SearchResult { + provider: string; + model: string; + answer: string; + citations: Array<{ index?: number; url: string; title?: string }>; + costUsd?: number; + raw?: unknown; +} + +export interface ModelInfo { + id: string; + type?: string; + label?: string; + estUsd?: number; + fields?: unknown; +} + +export interface EstimateInput { + model: string; + input: Record; +} + +export interface UploadInput { + /** Local path to read and upload. */ + path: string; + name?: string; + mimeType?: string; +} + +export interface UploadResult { + /** Whatever the provider returns to reference the uploaded asset later. */ + ref: unknown; + url?: string; +} + +export interface MediaProvider { + readonly name: string; + readonly capabilities: Capability[]; + generate(capability: Capability, req: MediaRequest): Promise; + estimate?(req: EstimateInput): Promise<{ usd: number }>; + upload?(input: UploadInput): Promise; + listModels?(): Promise; +} + +export interface SearchProvider { + readonly name: string; + search(input: SearchInput): Promise; +} + +export interface SttProvider { + readonly name: string; + transcribe(input: SttInput): Promise; +} diff --git a/NexusAI/packages/core/src/shared/errors.ts b/NexusAI/packages/core/src/shared/errors.ts new file mode 100644 index 0000000..fb8c50b --- /dev/null +++ b/NexusAI/packages/core/src/shared/errors.ts @@ -0,0 +1,53 @@ +/** + * Unified error type for NexusAI. Every provider adapter should throw NexusError + * so the MCP layer can render a consistent { code, message } shape. + */ +export type NexusErrorCode = + | 'CONFIG' + | 'AUTH' + | 'BAD_REQUEST' + | 'NOT_FOUND' + | 'RATE_LIMIT' + | 'INSUFFICIENT_BALANCE' + | 'PROVIDER' + | 'TIMEOUT' + | 'UNSUPPORTED' + | 'IO' + | 'UNKNOWN'; + +export class NexusError extends Error { + readonly code: NexusErrorCode; + readonly details?: unknown; + + constructor(code: NexusErrorCode, message: string, details?: unknown) { + super(message); + this.name = 'NexusError'; + this.code = code; + this.details = details; + } + + static from(error: unknown, fallbackCode: NexusErrorCode = 'UNKNOWN'): NexusError { + if (error instanceof NexusError) return error; + const message = error instanceof Error ? error.message : String(error); + return new NexusError(fallbackCode, message); + } +} + +/** Map an HTTP status code to a NexusErrorCode. */ +export function codeFromHttpStatus(status: number): NexusErrorCode { + switch (status) { + case 400: + return 'BAD_REQUEST'; + case 401: + case 403: + return 'AUTH'; + case 402: + return 'INSUFFICIENT_BALANCE'; + case 404: + return 'NOT_FOUND'; + case 429: + return 'RATE_LIMIT'; + default: + return status >= 500 ? 'PROVIDER' : 'UNKNOWN'; + } +} diff --git a/NexusAI/packages/core/src/shared/http.ts b/NexusAI/packages/core/src/shared/http.ts new file mode 100644 index 0000000..5104a6b --- /dev/null +++ b/NexusAI/packages/core/src/shared/http.ts @@ -0,0 +1,148 @@ +import { fetch, ProxyAgent, type RequestInit, type Response } from 'undici'; +import { NexusError, codeFromHttpStatus } from './errors.js'; +import { sleep } from './polling.js'; + +function getProxyUrl(): string | undefined { + return ( + process.env.NEXUS_HTTP_PROXY || + process.env.HTTPS_PROXY || + process.env.https_proxy || + process.env.HTTP_PROXY || + process.env.http_proxy + ); +} + +function isRetryableError(error: unknown): boolean { + if (error instanceof Error) { + const msg = error.message.toLowerCase(); + return ( + msg.includes('timeout') || + msg.includes('econnreset') || + msg.includes('socket') || + msg.includes('fetch failed') || + msg.includes('und_err') + ); + } + return false; +} + +/** Simple sliding-window rate limiter. */ +export class RateLimiter { + private timestamps: number[] = []; + constructor(private readonly maxRequests: number, private readonly windowMs: number) {} + + async acquire(): Promise { + while (true) { + const now = Date.now(); + this.timestamps = this.timestamps.filter((ts) => now - ts < this.windowMs); + if (this.timestamps.length < this.maxRequests) { + this.timestamps.push(now); + return; + } + const oldest = this.timestamps[0]; + await sleep(Math.max(0, this.windowMs - (now - oldest))); + } + } +} + +export interface HttpClientOptions { + baseUrl: string; + defaultHeaders?: Record; + timeoutMs?: number; + maxRetries?: number; + rateLimiter?: RateLimiter; +} + +export interface RequestOptions { + method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; + path: string; + headers?: Record; + body?: unknown; + timeoutMs?: number; + /** Skip JSON parsing and return raw Response (for binary downloads). */ + raw?: boolean; +} + +export class HttpClient { + private readonly proxyAgent?: ProxyAgent; + + constructor(private readonly opts: HttpClientOptions) { + const proxyUrl = getProxyUrl(); + if (proxyUrl) this.proxyAgent = new ProxyAgent(proxyUrl); + } + + async requestRaw(options: RequestOptions): Promise { + if (this.opts.rateLimiter) await this.opts.rateLimiter.acquire(); + + const url = options.path.startsWith('http') + ? options.path + : `${this.opts.baseUrl}${options.path}`; + const method = options.method ?? 'GET'; + const timeoutMs = options.timeoutMs ?? this.opts.timeoutMs ?? 60_000; + const maxRetries = this.opts.maxRetries ?? 2; + + const headers: Record = { ...this.opts.defaultHeaders, ...options.headers }; + let bodyStr: string | undefined; + if (options.body !== undefined) { + bodyStr = typeof options.body === 'string' ? options.body : JSON.stringify(options.body); + if (!headers['Content-Type'] && !headers['content-type']) { + headers['Content-Type'] = 'application/json'; + } + } + + let lastError: Error | undefined; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const init: RequestInit & { dispatcher?: ProxyAgent } = { + method, + headers, + body: bodyStr, + signal: AbortSignal.timeout(timeoutMs), + }; + if (this.proxyAgent) init.dispatcher = this.proxyAgent; + + const response = await fetch(url, init); + + if ((response.status === 429 || response.status >= 500) && attempt < maxRetries) { + await sleep(Math.min(1000 * 2 ** attempt, 8000)); + continue; + } + return response; + } catch (error: unknown) { + lastError = error instanceof Error ? error : new Error(String(error)); + if (isRetryableError(lastError) && attempt < maxRetries) { + await sleep(Math.min(1000 * 2 ** attempt, 8000)); + continue; + } + if (lastError.name === 'TimeoutError' || lastError.message.includes('aborted')) { + throw new NexusError('TIMEOUT', `Request to ${url} timed out after ${timeoutMs}ms`); + } + throw NexusError.from(lastError, 'PROVIDER'); + } + } + throw NexusError.from(lastError ?? new Error('Request failed'), 'PROVIDER'); + } + + async requestJson(options: RequestOptions): Promise { + const response = await this.requestRaw(options); + const text = await response.text(); + + if (!response.ok) { + let message = `HTTP ${response.status}`; + try { + const parsed = JSON.parse(text) as { message?: string; error?: { message?: string }; msg?: string }; + message = parsed.message || parsed.error?.message || parsed.msg || message; + } catch { + if (text) message = `${message}: ${text.slice(0, 300)}`; + } + throw new NexusError(codeFromHttpStatus(response.status), message, { status: response.status }); + } + + if (!text) return undefined as T; + try { + return JSON.parse(text) as T; + } catch { + throw new NexusError('PROVIDER', `Invalid JSON response: ${text.slice(0, 300)}`); + } + } +} diff --git a/NexusAI/packages/core/src/shared/logger.ts b/NexusAI/packages/core/src/shared/logger.ts new file mode 100644 index 0000000..469b379 --- /dev/null +++ b/NexusAI/packages/core/src/shared/logger.ts @@ -0,0 +1,42 @@ +/** + * MCP servers communicate over stdio, so ALL logs must go to stderr. + * Never write logs to stdout — it corrupts the JSON-RPC stream. + */ +type Level = 'debug' | 'info' | 'warn' | 'error'; + +const LEVELS: Record = { debug: 10, info: 20, warn: 30, error: 40 }; + +function currentThreshold(): number { + const env = (process.env.NEXUS_LOG_LEVEL || 'info').toLowerCase() as Level; + return LEVELS[env] ?? LEVELS.info; +} + +function emit(level: Level, scope: string, message: string, extra?: unknown): void { + if (LEVELS[level] < currentThreshold()) return; + const ts = new Date().toISOString(); + let line = `[${ts}] ${level.toUpperCase()} (${scope}) ${message}`; + if (extra !== undefined) { + try { + line += ` ${typeof extra === 'string' ? extra : JSON.stringify(extra)}`; + } catch { + line += ' [unserializable extra]'; + } + } + process.stderr.write(line + '\n'); +} + +export interface Logger { + debug(message: string, extra?: unknown): void; + info(message: string, extra?: unknown): void; + warn(message: string, extra?: unknown): void; + error(message: string, extra?: unknown): void; +} + +export function createLogger(scope: string): Logger { + return { + debug: (m, e) => emit('debug', scope, m, e), + info: (m, e) => emit('info', scope, m, e), + warn: (m, e) => emit('warn', scope, m, e), + error: (m, e) => emit('error', scope, m, e), + }; +} diff --git a/NexusAI/packages/core/src/shared/polling.ts b/NexusAI/packages/core/src/shared/polling.ts new file mode 100644 index 0000000..5238dcc --- /dev/null +++ b/NexusAI/packages/core/src/shared/polling.ts @@ -0,0 +1,41 @@ +import { NexusError } from './errors.js'; + +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export interface PollOptions { + /** Called every interval. Return { done: true, value } to stop. */ + poll: () => Promise<{ done: boolean; value?: T; failed?: boolean; error?: string }>; + intervalMs?: number; + timeoutMs?: number; + onTick?: (attempt: number, elapsedMs: number) => void; +} + +/** + * Generic polling loop with wall-clock timeout. Used for async media jobs. + */ +export async function pollUntilDone(options: PollOptions): Promise { + const intervalMs = options.intervalMs ?? 3000; + const timeoutMs = options.timeoutMs ?? 10 * 60 * 1000; + const start = Date.now(); + let attempt = 0; + + while (true) { + attempt += 1; + const result = await options.poll(); + options.onTick?.(attempt, Date.now() - start); + + if (result.failed) { + throw new NexusError('PROVIDER', result.error || 'Job failed'); + } + if (result.done) { + return result.value as T; + } + + if (Date.now() - start > timeoutMs) { + throw new NexusError('TIMEOUT', `Polling timed out after ${Math.round(timeoutMs / 1000)}s`); + } + await sleep(intervalMs); + } +} diff --git a/NexusAI/packages/core/tsconfig.json b/NexusAI/packages/core/tsconfig.json new file mode 100644 index 0000000..ad59e93 --- /dev/null +++ b/NexusAI/packages/core/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "node", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"] +} diff --git a/NexusAI/packages/mcp-server/package.json b/NexusAI/packages/mcp-server/package.json new file mode 100644 index 0000000..beb62c4 --- /dev/null +++ b/NexusAI/packages/mcp-server/package.json @@ -0,0 +1,25 @@ +{ + "name": "@nexusai/mcp-server", + "version": "0.1.0", + "description": "NexusAI MCP server (stdio): TTS, STT, image/video/music generation, web search", + "main": "dist/index.js", + "type": "commonjs", + "bin": { + "nexusai-mcp": "dist/index.js" + }, + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "start": "node dist/index.js" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.0", + "@nexusai/core": "*", + "zod": "^3.25.0", + "zod-to-json-schema": "^3.25.0" + }, + "devDependencies": { + "@types/node": "^22.15.0", + "typescript": "^5.8.0" + } +} diff --git a/NexusAI/packages/mcp-server/src/context.ts b/NexusAI/packages/mcp-server/src/context.ts new file mode 100644 index 0000000..92a2037 --- /dev/null +++ b/NexusAI/packages/mcp-server/src/context.ts @@ -0,0 +1,46 @@ +import { + Journal, + MediaStorage, + ProviderRegistry, + createNordRouterProviders, + loadConfig, + LocalWhisperProvider, + createLogger, + type NexusConfig, +} from '@nexusai/core'; + +const log = createLogger('context'); + +export interface AppContext { + config: NexusConfig; + registry: ProviderRegistry; + storage: MediaStorage; + journal: Journal; +} + +/** + * Wire up all providers, storage and the journal once at startup. + */ +export function buildContext(): AppContext { + const config = loadConfig(); + const storage = new MediaStorage(config.mediaDir); + const journal = new Journal(config.dbPath); + const registry = new ProviderRegistry(); + + // NordRouter (media + search). Registered even without a key so error messages + // are clear when a tool is actually invoked. + try { + const nr = createNordRouterProviders({ config: config.nordrouter, storage, save: true }); + registry.registerMedia(nr.media); + registry.registerSearch(nr.search); + log.info('registered nordrouter provider (media + search)'); + } catch (err) { + log.warn('nordrouter not registered', err instanceof Error ? err.message : String(err)); + } + + // Local STT (faster-whisper sidecar). + registry.registerStt(new LocalWhisperProvider(config.stt)); + log.info('registered local STT provider'); + + return { config, registry, storage, journal }; +} diff --git a/NexusAI/packages/mcp-server/src/handlers.ts b/NexusAI/packages/mcp-server/src/handlers.ts new file mode 100644 index 0000000..b5d0315 --- /dev/null +++ b/NexusAI/packages/mcp-server/src/handlers.ts @@ -0,0 +1,275 @@ +import { randomUUID } from 'crypto'; +import { + NexusError, + buildEmbeddedContent, + createLogger, + type Capability, + type MediaResult, +} from '@nexusai/core'; +import type { AppContext } from './context.js'; +import { + ImageSchema, + MediaEstimateSchema, + MediaModelsSchema, + MediaUploadSchema, + MusicSchema, + SttSchema, + TtsSchema, + UsageStatsSchema, + VideoSchema, + WebSearchSchema, +} from './schemas.js'; + +const log = createLogger('handlers'); + +type McpContent = + | { type: 'text'; text: string } + | { type: 'image'; data: string; mimeType: string } + | { type: 'audio'; data: string; mimeType: string }; + +export interface McpResult { + content: McpContent[]; + isError?: boolean; +} + +function jsonBlock(obj: unknown): McpContent { + return { type: 'text', text: JSON.stringify(obj, null, 2) }; +} + +function summarize(input: Record): string { + const s = JSON.stringify(input); + return s.length > 200 ? s.slice(0, 197) + '...' : s; +} + +export class Handlers { + constructor(private readonly ctx: AppContext) {} + + private providerName(explicit?: string): string { + return explicit || this.ctx.config.defaultProvider; + } + + async handle(name: string, args: Record): Promise { + switch (name) { + case 'nexus_tts': + return this.media('tts', TtsSchema.parse(args)); + case 'nexus_image': + return this.media('image', ImageSchema.parse(args)); + case 'nexus_video': + return this.media('video', VideoSchema.parse(args)); + case 'nexus_music': + return this.media('music', MusicSchema.parse(args)); + case 'nexus_stt': + return this.stt(SttSchema.parse(args)); + case 'nexus_web_search': + return this.search(WebSearchSchema.parse(args)); + case 'nexus_media_models': + return this.models(MediaModelsSchema.parse(args)); + case 'nexus_media_estimate': + return this.estimate(MediaEstimateSchema.parse(args)); + case 'nexus_media_upload': + return this.upload(MediaUploadSchema.parse(args)); + case 'nexus_usage_stats': + return this.usage(UsageStatsSchema.parse(args)); + default: + throw new NexusError('NOT_FOUND', `Unknown tool: ${name}`); + } + } + + private async media( + capability: Capability, + args: { provider?: string; model: string; input: Record; output?: { save?: boolean; embed?: boolean } } + ): Promise { + const providerName = this.providerName(args.provider); + const provider = this.ctx.registry.getMedia(providerName, capability); + const started = Date.now(); + const id = randomUUID(); + + try { + const result = await provider.generate(capability, { model: args.model, input: args.input }); + this.ctx.journal.record({ + id, + provider: providerName, + capability, + model: args.model, + inputSummary: summarize(args.input), + status: 'done', + costUsd: result.costUsd ?? null, + filePath: result.filePath ?? null, + resultUrl: result.resultUrl ?? null, + error: null, + durationMs: Date.now() - started, + createdAt: new Date().toISOString(), + }); + return this.mediaResult(result, args.output?.embed ?? false); + } catch (err) { + const e = NexusError.from(err); + this.ctx.journal.record({ + id, + provider: providerName, + capability, + model: args.model, + inputSummary: summarize(args.input), + status: 'failed', + costUsd: null, + filePath: null, + resultUrl: null, + error: `${e.code}: ${e.message}`, + durationMs: Date.now() - started, + createdAt: new Date().toISOString(), + }); + throw e; + } + } + + private mediaResult(result: MediaResult, embed: boolean): McpResult { + const content: McpContent[] = [ + jsonBlock({ + provider: result.provider, + capability: result.capability, + model: result.model, + status: result.status, + file_path: result.filePath, + mime_type: result.mimeType, + result_url: result.resultUrl, + result_url_2: result.resultUrl2, + cost_usd: result.costUsd, + }), + ]; + + if (embed && result.filePath) { + const block = buildEmbeddedContent(result.filePath, result.mimeType, this.ctx.config.embedMaxBytes); + if (block) content.push(block); + else content.push({ type: 'text', text: '(embed skipped: file too large or not embeddable)' }); + } + return { content }; + } + + private async stt(args: { + provider?: string; + input: { audioPath?: string; audioUrl?: string; audioBase64?: string; language?: string; model?: string }; + }): Promise { + const providerName = args.provider || 'local'; + const provider = this.ctx.registry.getStt(providerName); + const started = Date.now(); + const id = randomUUID(); + try { + const res = await provider.transcribe(args.input); + this.ctx.journal.record({ + id, + provider: providerName, + capability: 'stt', + model: args.input.model || 'default', + inputSummary: summarize({ ...args.input, audioBase64: args.input.audioBase64 ? '[base64]' : undefined }), + status: 'done', + costUsd: 0, + filePath: args.input.audioPath ?? null, + resultUrl: null, + error: null, + durationMs: Date.now() - started, + createdAt: new Date().toISOString(), + }); + return { + content: [ + jsonBlock({ + provider: res.provider, + language: res.language, + duration_sec: res.durationSec, + text: res.text, + segments: res.segments, + }), + ], + }; + } catch (err) { + const e = NexusError.from(err); + this.ctx.journal.record({ + id, + provider: providerName, + capability: 'stt', + model: args.input.model || 'default', + inputSummary: 'stt', + status: 'failed', + costUsd: null, + filePath: null, + resultUrl: null, + error: `${e.code}: ${e.message}`, + durationMs: Date.now() - started, + createdAt: new Date().toISOString(), + }); + throw e; + } + } + + private async search(args: { + provider?: string; + query: string; + model?: string; + recency?: 'day' | 'week' | 'month' | 'year'; + maxResults?: number; + }): Promise { + const providerName = this.providerName(args.provider); + const provider = this.ctx.registry.getSearch(providerName); + const started = Date.now(); + const id = randomUUID(); + const res = await provider.search({ + query: args.query, + model: args.model, + recency: args.recency, + maxResults: args.maxResults, + }); + this.ctx.journal.record({ + id, + provider: providerName, + capability: 'search', + model: res.model, + inputSummary: summarize({ query: args.query }), + status: 'done', + costUsd: res.costUsd ?? null, + filePath: null, + resultUrl: null, + error: null, + durationMs: Date.now() - started, + createdAt: new Date().toISOString(), + }); + return { + content: [jsonBlock({ provider: res.provider, model: res.model, answer: res.answer, citations: res.citations })], + }; + } + + private async models(args: { provider?: string; type?: string; search?: string }): Promise { + const providerName = this.providerName(args.provider); + // any capability that this provider supports is fine to fetch the catalog + const provider = this.ctx.registry.getMedia(providerName, 'image'); + if (!provider.listModels) throw new NexusError('UNSUPPORTED', `Provider ${providerName} cannot list models`); + let models = await provider.listModels(); + if (args.type) models = models.filter((m) => m.type === args.type); + if (args.search) { + const q = args.search.toLowerCase(); + models = models.filter((m) => m.id.toLowerCase().includes(q) || (m.label || '').toLowerCase().includes(q)); + } + return { content: [jsonBlock({ count: models.length, models })] }; + } + + private async estimate(args: { provider?: string; model: string; input: Record }): Promise { + const providerName = this.providerName(args.provider); + const provider = this.ctx.registry.getMedia(providerName, 'image'); + if (!provider.estimate) throw new NexusError('UNSUPPORTED', `Provider ${providerName} cannot estimate`); + const est = await provider.estimate({ model: args.model, input: args.input }); + return { content: [jsonBlock({ model: args.model, usd: est.usd })] }; + } + + private async upload(args: { provider?: string; path: string; name?: string; mimeType?: string }): Promise { + const providerName = this.providerName(args.provider); + const provider = this.ctx.registry.getMedia(providerName, 'image'); + if (!provider.upload) throw new NexusError('UNSUPPORTED', `Provider ${providerName} cannot upload`); + const res = await provider.upload({ path: args.path, name: args.name, mimeType: args.mimeType }); + return { content: [jsonBlock({ url: res.url, ref: res.ref })] }; + } + + private async usage(args: { sinceDays?: number; list?: boolean; limit: number }): Promise { + const stats = this.ctx.journal.stats(args.sinceDays); + const payload: Record = { stats }; + if (args.list) payload.recent = this.ctx.journal.list(args.limit); + log.debug('usage stats requested'); + return { content: [jsonBlock(payload)] }; + } +} diff --git a/NexusAI/packages/mcp-server/src/index.ts b/NexusAI/packages/mcp-server/src/index.ts new file mode 100644 index 0000000..5c06bcd --- /dev/null +++ b/NexusAI/packages/mcp-server/src/index.ts @@ -0,0 +1,56 @@ +#!/usr/bin/env node +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + type Tool, +} from '@modelcontextprotocol/sdk/types.js'; +import { NexusError, createLogger } from '@nexusai/core'; +import { buildContext } from './context.js'; +import { Handlers } from './handlers.js'; +import { TOOLS } from './tools.js'; + +const log = createLogger('server'); + +function main(): void { + const ctx = buildContext(); + const handlers = new Handlers(ctx); + + const server = new Server( + { name: 'nexusai-mcp-server', version: '0.1.0' }, + { capabilities: { tools: {} } } + ); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: TOOLS.map((t) => ({ + name: t.name, + description: t.description, + inputSchema: t.inputSchema, + })) as Tool[], + })); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + try { + return (await handlers.handle(name, args ?? {})) as never; + } catch (error) { + const e = NexusError.from(error); + log.error(`tool ${name} failed`, `${e.code}: ${e.message}`); + return { + content: [{ type: 'text', text: `Error [${e.code}]: ${e.message}` }], + isError: true, + } as never; + } + }); + + const transport = new StdioServerTransport(); + server.connect(transport).catch((error: unknown) => { + log.error('failed to start MCP server', error instanceof Error ? error.message : String(error)); + process.exit(1); + }); + + log.info(`NexusAI MCP server started with ${TOOLS.length} tools`); +} + +main(); diff --git a/NexusAI/packages/mcp-server/src/schemas.ts b/NexusAI/packages/mcp-server/src/schemas.ts new file mode 100644 index 0000000..595b920 --- /dev/null +++ b/NexusAI/packages/mcp-server/src/schemas.ts @@ -0,0 +1,138 @@ +import { z } from 'zod'; + +/** Common output options for media tools. */ +const OutputOptions = z + .object({ + save: z.boolean().default(true).describe('Save the result to a local file.'), + embed: z + .boolean() + .default(false) + .describe('Also embed image/audio directly in the MCP result (size-limited).'), + }) + .partial() + .default({}); + +const provider = z + .string() + .optional() + .describe('Provider name. Defaults to the configured default provider (e.g. "nordrouter").'); + +export const TtsSchema = z.object({ + provider, + model: z + .string() + .default('audio/elevenlabs-tts') + .describe('TTS model id, e.g. audio/elevenlabs-tts, audio/elevenlabs-multilingual, audio/gemini-3.1-flash-tts.'), + input: z + .object({ + text: z.string().describe('Text to synthesize.'), + voice: z.string().optional().describe('Voice name (see nexus_media_models for options).'), + voice_id: z.string().optional(), + speed: z.union([z.string(), z.number()]).optional().describe('Speed, e.g. "0.8" | "1.0" | "1.2".'), + }) + .passthrough() + .describe('Provider input. Extra model-specific fields are passed through.'), + output: OutputOptions, +}); + +export const ImageSchema = z.object({ + provider, + model: z + .string() + .default('image/nano-banana-2') + .describe('Image model id, e.g. image/nano-banana-2, image/flux2-pro, image/gpt-image-2, image/z-image. Use *-edit models for editing.'), + input: z + .object({ + prompt: z.string().describe('Image description. For edits also pass "image".'), + image: z.string().optional().describe('Source image URL for edit/i2i models.'), + aspect_ratio: z.string().optional(), + resolution: z.string().optional().describe('1K | 2K | 4K (model dependent).'), + }) + .passthrough(), + output: OutputOptions, +}); + +export const VideoSchema = z.object({ + provider, + model: z + .string() + .default('video/kling-2.6-t2v') + .describe('Video model id, e.g. video/kling-2.6-t2v, video/veo-3.1-fast, video/seedance-2. Use i2v models with an "image".'), + input: z + .object({ + prompt: z.string().describe('Video description.'), + image: z.string().optional().describe('First-frame image URL for i2v models.'), + image2: z.string().optional().describe('Last-frame image URL (transition models).'), + duration: z.union([z.string(), z.number()]).optional(), + resolution: z.string().optional(), + aspect_ratio: z.string().optional(), + sound: z.boolean().optional(), + }) + .passthrough(), + output: OutputOptions, +}); + +export const MusicSchema = z.object({ + provider, + model: z + .string() + .default('music/suno-v5') + .describe('Music model id, e.g. music/suno-v5, music/suno-cover, music/suno-sounds.'), + input: z + .object({ + title: z.string().optional(), + style: z.string().optional(), + prompt: z.string().optional().describe('Lyrics/prompt.'), + instrumental: z.boolean().optional(), + version: z.string().optional().describe('V4 | V4_5 | V4_5PLUS | V4_5ALL | V5 | V5_5.'), + }) + .passthrough(), + output: OutputOptions, +}); + +export const SttSchema = z.object({ + provider: z.string().optional().describe('STT provider. Defaults to "local" (faster-whisper).'), + input: z.object({ + audioPath: z.string().optional().describe('Local audio file path.'), + audioUrl: z.string().optional().describe('Remote audio URL.'), + audioBase64: z.string().optional().describe('Base64 or data-URL audio.'), + language: z.string().optional().describe('Language hint, e.g. "ru", "en". Auto-detect if omitted.'), + model: z.string().optional().describe('Whisper size: tiny|base|small|medium|large-v3.'), + }), +}); + +export const WebSearchSchema = z.object({ + provider, + query: z.string().describe('Search query / question.'), + model: z + .string() + .optional() + .describe('Search model, e.g. perplexity/sonar, perplexity/sonar-pro, or search/.'), + recency: z.enum(['day', 'week', 'month', 'year']).optional(), + maxResults: z.number().int().positive().max(50).optional(), +}); + +export const MediaModelsSchema = z.object({ + provider, + type: z.enum(['image', 'video', 'music', 'audio', 'upscale']).optional().describe('Filter by model type.'), + search: z.string().optional().describe('Substring filter on model id/label.'), +}); + +export const MediaEstimateSchema = z.object({ + provider, + model: z.string().describe('Model id to estimate.'), + input: z.record(z.unknown()).default({}).describe('Same input you would pass to generate.'), +}); + +export const MediaUploadSchema = z.object({ + provider, + path: z.string().describe('Local file path to upload.'), + name: z.string().optional(), + mimeType: z.string().optional(), +}); + +export const UsageStatsSchema = z.object({ + sinceDays: z.number().int().positive().optional().describe('Limit stats to the last N days.'), + list: z.boolean().default(false).describe('Also return recent generations.'), + limit: z.number().int().positive().max(200).default(20), +}); diff --git a/NexusAI/packages/mcp-server/src/tools.ts b/NexusAI/packages/mcp-server/src/tools.ts new file mode 100644 index 0000000..351cbd8 --- /dev/null +++ b/NexusAI/packages/mcp-server/src/tools.ts @@ -0,0 +1,78 @@ +import { zodToJsonSchema } from 'zod-to-json-schema'; +import { + ImageSchema, + MediaEstimateSchema, + MediaModelsSchema, + MediaUploadSchema, + MusicSchema, + SttSchema, + TtsSchema, + UsageStatsSchema, + VideoSchema, + WebSearchSchema, +} from './schemas.js'; + +export interface ToolDef { + name: string; + description: string; + inputSchema: object; +} + +function make(name: string, description: string, schema: unknown): ToolDef { + const json = zodToJsonSchema(schema as any, { name, $refStrategy: 'none' }) as any; + const def = json.definitions?.[name] ?? json; + return { name, description, inputSchema: def }; +} + +export const TOOLS: ToolDef[] = [ + make( + 'nexus_tts', + 'Text-to-speech. Generates spoken audio from text via a provider. Returns a saved audio file path and optional embedded audio.', + TtsSchema + ), + make( + 'nexus_image', + 'Generate or edit images from a text prompt (and optional source image URL). Returns a saved image file and optional embedded image.', + ImageSchema + ), + make( + 'nexus_video', + 'Generate video from text or an image (t2v / i2v). Async, may take minutes. Returns a saved MP4 file path.', + VideoSchema + ), + make( + 'nexus_music', + 'Generate music from a style/prompt. Returns saved audio file(s); some models produce two tracks.', + MusicSchema + ), + make( + 'nexus_stt', + 'Speech-to-text. Transcribe local/remote/base64 audio using a local whisper model. Returns text, language and segments.', + SttSchema + ), + make( + 'nexus_web_search', + 'Web search that returns an answer with cited source URLs (via search-augmented models).', + WebSearchSchema + ), + make( + 'nexus_media_models', + 'List available media models (id, type, label, estimated price) for a provider. Use to discover valid model ids and parameters.', + MediaModelsSchema + ), + make( + 'nexus_media_estimate', + 'Estimate the USD cost of a media generation before running it.', + MediaEstimateSchema + ), + make( + 'nexus_media_upload', + 'Upload a local media file to the provider and get a reference URL for use as input (image/audio/video).', + MediaUploadSchema + ), + make( + 'nexus_usage_stats', + 'Show usage and spend recorded in the local journal (totals by provider, capability, day) and optionally recent generations.', + UsageStatsSchema + ), +]; diff --git a/NexusAI/packages/mcp-server/tsconfig.json b/NexusAI/packages/mcp-server/tsconfig.json new file mode 100644 index 0000000..3869c10 --- /dev/null +++ b/NexusAI/packages/mcp-server/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "node", + "declaration": false, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"] +} diff --git a/NexusAI/sidecar/stt_server.py b/NexusAI/sidecar/stt_server.py new file mode 100644 index 0000000..2775d0d --- /dev/null +++ b/NexusAI/sidecar/stt_server.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.9" +# dependencies = [ +# "faster-whisper>=1.0.0", +# ] +# /// +""" +NexusAI local STT sidecar (faster-whisper). + +Invoked by the TypeScript MCP server as: + uv run sidecar/stt_server.py '' + +Request JSON: + { "audio": "/path/to/file", "model": "small", "device": "auto", + "compute_type": "auto", "language": "ru" } + +Prints exactly ONE JSON line on stdout: + { "ok": true, "text": "...", "language": "ru", "duration": 12.3, + "segments": [ { "start": 0.0, "end": 2.1, "text": "..." } ] } +On error: + { "ok": false, "error": "message" } + +All diagnostics go to stderr so stdout stays a clean single JSON line. +faster-whisper decodes audio via bundled ffmpeg/pyav, so no manual conversion +is required for common formats (mp3/wav/ogg/m4a/webm...). +""" +import json +import sys + + +def log(msg: str) -> None: + print(msg, file=sys.stderr, flush=True) + + +def resolve_device(device: str, compute_type: str): + if device and device != "auto": + ct = compute_type if compute_type and compute_type != "auto" else ( + "float16" if device == "cuda" else "int8" + ) + return device, ct + # auto-detect: try cuda, fall back to cpu + try: + import ctranslate2 # noqa: F401 + cuda_count = 0 + try: + cuda_count = ctranslate2.get_cuda_device_count() + except Exception: + cuda_count = 0 + if cuda_count > 0: + return "cuda", (compute_type if compute_type != "auto" else "float16") + except Exception: + pass + return "cpu", (compute_type if compute_type != "auto" else "int8") + + +def main() -> int: + if len(sys.argv) < 2: + print(json.dumps({"ok": False, "error": "missing request argument"})) + return 1 + + try: + req = json.loads(sys.argv[1]) + except Exception as exc: + print(json.dumps({"ok": False, "error": f"invalid request json: {exc}"})) + return 1 + + audio = req.get("audio") + if not audio: + print(json.dumps({"ok": False, "error": "no audio path provided"})) + return 1 + + model_size = req.get("model") or "small" + device, compute_type = resolve_device(req.get("device", "auto"), req.get("compute_type", "auto")) + language = req.get("language") or None + + try: + from faster_whisper import WhisperModel + except Exception as exc: + print(json.dumps({"ok": False, "error": f"faster-whisper not available: {exc}"})) + return 1 + + # Allow pointing at a local model dir (offline) or a mirror via env. + # NEXUS_STT_MODEL may be a whisper size name OR an absolute path to a + # pre-downloaded CTranslate2 model directory. + import os + model_ref = model_size + local_only = os.environ.get("NEXUS_STT_LOCAL_ONLY", "").lower() in ("1", "true", "yes") + download_root = os.environ.get("NEXUS_STT_DOWNLOAD_ROOT") or None + + try: + log(f"loading model={model_ref} device={device} compute={compute_type} local_only={local_only}") + model = WhisperModel( + model_ref, + device=device, + compute_type=compute_type, + download_root=download_root, + local_files_only=local_only, + ) + segments_iter, info = model.transcribe(audio, language=language, vad_filter=True) + + segments = [] + parts = [] + for seg in segments_iter: + parts.append(seg.text) + segments.append({"start": round(seg.start, 3), "end": round(seg.end, 3), "text": seg.text.strip()}) + + result = { + "ok": True, + "text": "".join(parts).strip(), + "language": info.language, + "duration": round(getattr(info, "duration", 0.0) or 0.0, 3), + "segments": segments, + } + print(json.dumps(result, ensure_ascii=False)) + return 0 + except Exception as exc: + hint = "" + msg = str(exc) + if "connect" in msg.lower() or "internet" in msg.lower() or "Hub" in msg: + hint = ( + " | Model download failed. Options: set HF_ENDPOINT to a reachable mirror, " + "pre-download the model and set NEXUS_STT_MODEL to its local path with " + "NEXUS_STT_LOCAL_ONLY=1, or set NEXUS_STT_DOWNLOAD_ROOT to a cache dir." + ) + print(json.dumps({"ok": False, "error": f"transcription failed: {exc}{hint}"}, ensure_ascii=False)) + return 1 + + +if __name__ == "__main__": + sys.exit(main())