diff --git a/README.md b/README.md index d6577bf..c489fcc 100644 --- a/README.md +++ b/README.md @@ -90,9 +90,11 @@ Full-screen terminal interface with real-time streaming responses. ![CodeTyper Status View](assets/CodetyperView.png) **Key bindings:** + - `Enter` - Send message - `Shift+Enter` - New line -- `/` - Open command menu +- `@` - Open file picker (works anywhere in input) +- `/` - Open command menu (works anywhere in input) - `Ctrl+M` - Toggle interaction mode - `Ctrl+T` - Toggle todo panel - `Shift+Up/Down` - Scroll log panel @@ -103,6 +105,7 @@ Full-screen terminal interface with real-time streaming responses. Optional vim-style keyboard navigation for power users. Enable in settings. **Normal Mode:** + - `j/k` - Scroll down/up - `gg/G` - Jump to top/bottom - `Ctrl+d/u` - Half page scroll @@ -111,6 +114,7 @@ Optional vim-style keyboard navigation for power users. Enable in settings. - `:` - Command mode (`:q` quit, `:w` save) **Configuration:** + ```json { "vim": { @@ -128,26 +132,26 @@ Press `/` to access all commands organized by category. **Available Commands:** -| Category | Command | Description | -|----------|---------|-------------| -| General | `/help` | Show available commands | -| General | `/clear` | Clear conversation history | -| General | `/exit` | Exit the chat | -| Session | `/save` | Save current session | -| Session | `/context` | Show context information | -| Session | `/usage` | Show token usage statistics | -| Session | `/remember` | Save a learning about the project | -| Session | `/learnings` | Show saved learnings | -| Settings | `/model` | Select AI model | -| Settings | `/agent` | Select agent | -| Settings | `/mode` | Switch interaction mode | -| Settings | `/provider` | Switch LLM provider | -| Settings | `/status` | Show provider status | -| Settings | `/theme` | Change color theme | -| Settings | `/mcp` | Manage MCP servers | -| Account | `/whoami` | Show logged in account | -| Account | `/login` | Authenticate with provider | -| Account | `/logout` | Sign out from provider | +| Category | Command | Description | +| -------- | ------------ | --------------------------------- | +| General | `/help` | Show available commands | +| General | `/clear` | Clear conversation history | +| General | `/exit` | Exit the chat | +| Session | `/save` | Save current session | +| Session | `/context` | Show context information | +| Session | `/usage` | Show token usage statistics | +| Session | `/remember` | Save a learning about the project | +| Session | `/learnings` | Show saved learnings | +| Settings | `/model` | Select AI model | +| Settings | `/agent` | Select agent | +| Settings | `/mode` | Switch interaction mode | +| Settings | `/provider` | Switch LLM provider | +| Settings | `/status` | Show provider status | +| Settings | `/theme` | Change color theme | +| Settings | `/mcp` | Manage MCP servers | +| Account | `/whoami` | Show logged in account | +| Account | `/login` | Authenticate with provider | +| Account | `/logout` | Sign out from provider | ### Agent Mode with Diff View @@ -156,6 +160,7 @@ When CodeTyper modifies files, you see a clear diff view of changes. ![Agent Mode with Diffs](assets/CodetyperAgentMode.png) **Interaction Modes:** + - **Agent** - Full access, can modify files - **Ask** - Read-only, answers questions - **Code Review** - Review PRs and diffs @@ -167,6 +172,7 @@ Granular control over what CodeTyper can do. Every file operation requires appro ![Permission Modal](assets/CodetyperPermissionView.png) **Permission Scopes:** + - `[y]` Yes, this once - `[s]` Yes, for this session - `[a]` Always allow for this project @@ -180,6 +186,7 @@ Access to multiple AI models through GitHub Copilot. ![Model Selection](assets/CodetyperCopilotModels.png) **Available Models:** + - GPT-5, GPT-5-mini (Unlimited) - GPT-5.2-codex, GPT-5.1-codex - Grok-code-fast-1 @@ -187,19 +194,19 @@ Access to multiple AI models through GitHub Copilot. ### Theme System -14+ built-in themes to customize your experience. +15+ built-in themes to customize your experience. ![Theme Selection](assets/CodetyperThemes.png) **Available Themes:** -default, dracula, nord, tokyo-night, gruvbox, monokai, catppuccin, one-dark, solarized-dark, github-dark, rose-pine, kanagawa, ayu-dark, cargdev-cyberpunk +default, dracula, nord, tokyo-night, gruvbox, monokai, catppuccin, one-dark, solarized-dark, github-dark, rose-pine, kanagawa, ayu-dark, cargdev-cyberpunk, pink-purple ## Providers -| Provider | Models | Auth Method | Use Case | -|----------|--------|-------------|----------| -| **GitHub Copilot** | GPT-5, Claude, Gemini | OAuth (device flow) | Cloud-based, high quality | -| **Ollama** | Llama, DeepSeek, Qwen, etc. | Local server | Private, offline, zero-cost | +| Provider | Models | Auth Method | Use Case | +| ------------------ | --------------------------- | ------------------- | --------------------------- | +| **GitHub Copilot** | GPT-5, Claude, Gemini | OAuth (device flow) | Cloud-based, high quality | +| **Ollama** | Llama, DeepSeek, Qwen, etc. | Local server | Private, offline, zero-cost | ### Cascade Mode @@ -244,6 +251,7 @@ Settings are stored in `~/.config/codetyper/config.json`: ### Project Context CodeTyper reads project-specific context from: + - `.github/` - GitHub workflows and templates - `.codetyper/` - Project-specific rules and learnings - `rules.md` - Custom instructions for the AI @@ -274,18 +282,18 @@ codetyper --print "Explain this codebase" CodeTyper has access to these built-in tools: -| Tool | Description | -|------|-------------| -| `bash` | Execute shell commands | -| `read` | Read file contents | -| `write` | Create or overwrite files | -| `edit` | Find and replace in files | -| `glob` | Find files by pattern | -| `grep` | Search file contents | -| `lsp` | Language Server Protocol operations | -| `web_search` | Search the web | -| `todo-read` | Read current todo list | -| `todo-write` | Update todo list | +| Tool | Description | +| ------------ | ----------------------------------- | +| `bash` | Execute shell commands | +| `read` | Read file contents | +| `write` | Create or overwrite files | +| `edit` | Find and replace in files | +| `glob` | Find files by pattern | +| `grep` | Search file contents | +| `lsp` | Language Server Protocol operations | +| `web_search` | Search the web | +| `todo-read` | Read current todo list | +| `todo-write` | Update todo list | ### MCP Integration @@ -311,6 +319,7 @@ Connect external MCP (Model Context Protocol) servers for extended capabilities: ``` **MCP Browser Features:** + - Search by name, description, or tags - Filter by category (database, web, AI, etc.) - View server details and required environment variables @@ -324,6 +333,7 @@ Connect external MCP (Model Context Protocol) servers for extended capabilities: Lifecycle hooks for intercepting tool execution and session events. **Hook Events:** + - `PreToolUse` - Validate/modify before tool execution - `PostToolUse` - Side effects after tool execution - `SessionStart` - At session initialization @@ -332,16 +342,22 @@ Lifecycle hooks for intercepting tool execution and session events. - `Stop` - When execution stops **Configuration** (`.codetyper/hooks.json`): + ```json { "hooks": [ - { "event": "PreToolUse", "script": ".codetyper/hooks/validate.sh", "timeout": 5000 }, + { + "event": "PreToolUse", + "script": ".codetyper/hooks/validate.sh", + "timeout": 5000 + }, { "event": "PostToolUse", "script": ".codetyper/hooks/notify.sh" } ] } ``` **Exit Codes:** + - `0` - Allow (optionally output `{"updatedInput": {...}}` to modify args) - `1` - Warn but continue - `2` - Block execution @@ -351,6 +367,7 @@ Lifecycle hooks for intercepting tool execution and session events. Extend CodeTyper with custom tools, commands, and hooks. **Plugin Structure:** + ``` .codetyper/plugins/{name}/ ├── plugin.json # Manifest @@ -363,6 +380,7 @@ Extend CodeTyper with custom tools, commands, and hooks. ``` **Manifest** (`plugin.json`): + ```json { "name": "my-plugin", @@ -373,13 +391,18 @@ Extend CodeTyper with custom tools, commands, and hooks. ``` **Custom Tool Definition:** + ```typescript import { z } from "zod"; export default { name: "custom_tool", description: "Does something", parameters: z.object({ input: z.string() }), - execute: async (args, ctx) => ({ success: true, title: "Done", output: "..." }), + execute: async (args, ctx) => ({ + success: true, + title: "Done", + output: "...", + }), }; ``` @@ -404,12 +427,12 @@ Sessions are stored in `.codetyper/sessions/` with automatic commit message sugg The next major release focuses on production-ready autonomous agent execution: -| Feature | Issue | Status | -|---------|-------|--------| -| Plan Approval Gate | [#111](https://github.com/CarGDev/codetyper.cli/issues/111) | Planned | -| Diff Preview Before Write | [#112](https://github.com/CarGDev/codetyper.cli/issues/112) | Planned | -| Execution Control (Pause/Resume/Abort) | [#113](https://github.com/CarGDev/codetyper.cli/issues/113) | Planned | -| Consistent Model Behavior | [#114](https://github.com/CarGDev/codetyper.cli/issues/114) | Planned | +| Feature | Issue | Status | +| --------------------------------------- | ----------------------------------------------------------- | ------- | +| Plan Approval Gate | [#111](https://github.com/CarGDev/codetyper.cli/issues/111) | Planned | +| Diff Preview Before Write | [#112](https://github.com/CarGDev/codetyper.cli/issues/112) | Planned | +| Execution Control (Pause/Resume/Abort) | [#113](https://github.com/CarGDev/codetyper.cli/issues/113) | Planned | +| Consistent Model Behavior | [#114](https://github.com/CarGDev/codetyper.cli/issues/114) | Planned | | Quality Gates (TypeScript, Lint, Tests) | [#115](https://github.com/CarGDev/codetyper.cli/issues/115) | Planned | ### Known Issues @@ -439,11 +462,14 @@ bun test bun run lint ``` -## Recent Changes (v0.3.0) +## Recent Changes (v0.4.2) -- **System Prompt Builder**: New modular prompt system with modes, tiers, and providers -- **Module Restructure**: Consistent internal organization with improved imports -- **Solid.js TUI**: Fully migrated to Solid.js + OpenTUI (removed legacy React/Ink) +- **Pink Purple Theme**: New built-in color theme +- **Image Paste Fix**: Fixed race condition where pasted images were silently dropped +- **@ and / Anywhere**: File picker and command menu now work at any cursor position +- **Plan Approval Gate**: User confirmation before agent executes plans +- **Execution Control**: Pause, resume, and abort agent execution +- **Text Clipboard Copy/Read**: Cross-platform clipboard operations with mouse selection See [CHANGELOG](docs/CHANGELOG.md) for complete version history. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3679972..dd9c0c4 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Pink Purple Theme**: New built-in theme with hot pink primary, purple secondary, and deep magenta accent on a dark plum background + +### Fixed + +- **Image Paste Race Condition**: Fixed images being silently dropped when pasting via Ctrl+V. The `clearPastedImages()` call in the input area was racing with the async message handler, clearing images before they could be read and attached to the message +- **@ File Picker**: Now works at any cursor position in the input, not just when the input is empty +- **/ Command Menu**: Now works at any cursor position in the input, not just when the input is empty + ### Planned - **Diff Preview**: Show file changes before writing ([#112](https://github.com/CarGDev/codetyper.cli/issues/112)) @@ -292,12 +302,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Version History Summary -| Version | Date | Highlights | -|---------|------|------------| -| 0.4.0 | 2026-02-06 | Clipboard copy/read, plan approval, execution control, safety features | -| 0.3.0 | 2025-02-04 | System prompt builder, module restructure, legacy TUI removal | -| 0.2.x | 2025-01-28 - 02-01 | Hooks, plugins, session forks, vim motions, MCP browser | -| 0.1.x | 2025-01-16 - 01-27 | Initial release, TUI, agent system, providers, permissions | +| Version | Date | Highlights | +| ------- | ------------------ | ---------------------------------------------------------------------- | +| 0.4.0 | 2026-02-06 | Clipboard copy/read, plan approval, execution control, safety features | +| 0.3.0 | 2025-02-04 | System prompt builder, module restructure, legacy TUI removal | +| 0.2.x | 2025-01-28 - 02-01 | Hooks, plugins, session forks, vim motions, MCP browser | +| 0.1.x | 2025-01-16 - 01-27 | Initial release, TUI, agent system, providers, permissions | --- diff --git a/package-lock.json b/package-lock.json index e5cb23d..b34d59a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,13 +35,13 @@ }, "devDependencies": { "@eslint/eslintrc": "^3.3.3", - "@eslint/js": "^9.39.2", + "@eslint/js": "^10.0.1", "@types/inquirer": "^9.0.7", "@types/node": "^25.0.10", "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^8.53.1", "@typescript-eslint/parser": "^8.53.1", - "eslint": "^9.39.2", + "eslint": "^10.0.0", "eslint-config-standard": "^17.1.0", "eslint-config-standard-with-typescript": "^43.0.1", "eslint-plugin-import": "^2.32.0", @@ -71,9 +71,9 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", - "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", @@ -85,9 +85,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz", - "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -124,13 +124,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz", - "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==", + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -332,12 +332,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", - "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", "license": "MIT", "dependencies": { - "@babel/types": "^7.28.6" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -445,17 +445,17 @@ } }, "node_modules/@babel/traverse": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz", - "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/generator": "^7.28.6", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.6", + "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/types": "^7.29.0", "debug": "^4.3.1" }, "engines": { @@ -463,9 +463,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", - "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -483,9 +483,9 @@ "optional": true }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", - "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", "cpu": [ "ppc64" ], @@ -500,9 +500,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", - "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", "cpu": [ "arm" ], @@ -517,9 +517,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", - "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", "cpu": [ "arm64" ], @@ -534,9 +534,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", - "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", "cpu": [ "x64" ], @@ -551,9 +551,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", - "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", "cpu": [ "arm64" ], @@ -568,9 +568,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", - "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", "cpu": [ "x64" ], @@ -585,9 +585,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", - "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", "cpu": [ "arm64" ], @@ -602,9 +602,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", - "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", "cpu": [ "x64" ], @@ -619,9 +619,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", - "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", "cpu": [ "arm" ], @@ -636,9 +636,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", - "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", "cpu": [ "arm64" ], @@ -653,9 +653,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", - "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", "cpu": [ "ia32" ], @@ -670,9 +670,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", - "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", "cpu": [ "loong64" ], @@ -687,9 +687,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", - "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", "cpu": [ "mips64el" ], @@ -704,9 +704,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", - "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", "cpu": [ "ppc64" ], @@ -721,9 +721,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", - "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", "cpu": [ "riscv64" ], @@ -738,9 +738,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", - "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", "cpu": [ "s390x" ], @@ -755,9 +755,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", - "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", "cpu": [ "x64" ], @@ -772,9 +772,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", - "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", "cpu": [ "arm64" ], @@ -789,9 +789,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", - "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", "cpu": [ "x64" ], @@ -806,9 +806,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", - "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", "cpu": [ "arm64" ], @@ -823,9 +823,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", - "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", "cpu": [ "x64" ], @@ -840,9 +840,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", - "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", "cpu": [ "arm64" ], @@ -857,9 +857,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", - "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", "cpu": [ "x64" ], @@ -874,9 +874,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", - "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", "cpu": [ "arm64" ], @@ -891,9 +891,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", - "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", "cpu": [ "ia32" ], @@ -908,9 +908,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", - "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", "cpu": [ "x64" ], @@ -954,44 +954,86 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.1.tgz", + "integrity": "sha512-uVSdg/V4dfQmTjJzR0szNczjOH/J+FyUMMjYtr07xFRXR7EDf9i1qdxrD0VusZH9knj1/ecxzCQQxyic5NzAiA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.7", + "@eslint/object-schema": "^3.0.1", "debug": "^4.3.1", - "minimatch": "^3.1.2" + "minimatch": "^10.1.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.2.tgz", + "integrity": "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "jackspeak": "^4.2.3" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", + "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.0.tgz", + "integrity": "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.2.tgz", + "integrity": "sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0" + "@eslint/core": "^1.1.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.0.tgz", + "integrity": "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==", "dev": true, "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/eslintrc": { @@ -1032,40 +1074,48 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", - "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", "dev": true, "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } } }, "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.1.tgz", + "integrity": "sha512-P9cq2dpr+LU8j3qbLygLcSZrl2/ds/pUpfnHNNuk5HW7mnngHs+6WSq5C9mO3rqRX8A1poxqLTC9cu0KOyJlBg==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.0.tgz", + "integrity": "sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0", + "@eslint/core": "^1.1.0", "levn": "^0.4.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@humanfs/core": { @@ -1448,6 +1498,16 @@ } } }, + "node_modules/@isaacs/cliui": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/@jimp/core": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@jimp/core/-/core-1.6.0.tgz", @@ -2081,9 +2141,9 @@ } }, "node_modules/@opentui/core": { - "version": "0.1.75", - "resolved": "https://registry.npmjs.org/@opentui/core/-/core-0.1.75.tgz", - "integrity": "sha512-8ARRZxSG+BXkJmEVtM2DQ4se7DAF1ZCKD07d+AklgTr2mxCzmdxxPbOwRzboSQ6FM7qGuTVPVbV4O2W9DpUmoA==", + "version": "0.1.79", + "resolved": "https://registry.npmjs.org/@opentui/core/-/core-0.1.79.tgz", + "integrity": "sha512-job/t09w8A/aHb/WuaVbimu5fIffyN+PCuVO5cYhXEg/NkOkC/WdFi80B8bwncR/DBPyLAh6oJ3EG86grOVo5g==", "license": "MIT", "dependencies": { "bun-ffi-structs": "0.1.2", @@ -2094,12 +2154,12 @@ }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", - "@opentui/core-darwin-arm64": "0.1.75", - "@opentui/core-darwin-x64": "0.1.75", - "@opentui/core-linux-arm64": "0.1.75", - "@opentui/core-linux-x64": "0.1.75", - "@opentui/core-win32-arm64": "0.1.75", - "@opentui/core-win32-x64": "0.1.75", + "@opentui/core-darwin-arm64": "0.1.79", + "@opentui/core-darwin-x64": "0.1.79", + "@opentui/core-linux-arm64": "0.1.79", + "@opentui/core-linux-x64": "0.1.79", + "@opentui/core-win32-arm64": "0.1.79", + "@opentui/core-win32-x64": "0.1.79", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" @@ -2109,9 +2169,9 @@ } }, "node_modules/@opentui/core-darwin-arm64": { - "version": "0.1.75", - "resolved": "https://registry.npmjs.org/@opentui/core-darwin-arm64/-/core-darwin-arm64-0.1.75.tgz", - "integrity": "sha512-gGaGZjkFpqcXJk6321JzhRl66pM2VxBlI470L8W4DQUW4S6iDT1R9L7awSzGB4Cn9toUl7DTV8BemaXZYXV4SA==", + "version": "0.1.79", + "resolved": "https://registry.npmjs.org/@opentui/core-darwin-arm64/-/core-darwin-arm64-0.1.79.tgz", + "integrity": "sha512-kgsGniV+DM5G1P3GideyJhvfnthNKcVCAm2mPTIr9InQ3L0gS/Feh7zgwOS/jxDvdlQbOWGKMk2Z3JApeC1MLw==", "cpu": [ "arm64" ], @@ -2122,9 +2182,9 @@ ] }, "node_modules/@opentui/core-darwin-x64": { - "version": "0.1.75", - "resolved": "https://registry.npmjs.org/@opentui/core-darwin-x64/-/core-darwin-x64-0.1.75.tgz", - "integrity": "sha512-tPlvqQI0whZ76amHydpJs5kN+QeWAIcFbI8RAtlAo9baj2EbxTDC+JGwgb9Fnt0/YQx831humbtaNDhV2Jt1bw==", + "version": "0.1.79", + "resolved": "https://registry.npmjs.org/@opentui/core-darwin-x64/-/core-darwin-x64-0.1.79.tgz", + "integrity": "sha512-OpyAmFqAAKQ2CeFmf/oLWcNksmP6Ryx/3R5dbKXThOudMCeQvfvInJTRbc2jTn9VFpf+Qj4BgHkJg1h90tf/EA==", "cpu": [ "x64" ], @@ -2135,9 +2195,9 @@ ] }, "node_modules/@opentui/core-linux-arm64": { - "version": "0.1.75", - "resolved": "https://registry.npmjs.org/@opentui/core-linux-arm64/-/core-linux-arm64-0.1.75.tgz", - "integrity": "sha512-nVxIQ4Hqf84uBergDpWiVzU6pzpjy6tqBHRQpySxZ2flkJ/U6/aMEizVrQ1jcgIdxZtvqWDETZhzxhG0yDx+cw==", + "version": "0.1.79", + "resolved": "https://registry.npmjs.org/@opentui/core-linux-arm64/-/core-linux-arm64-0.1.79.tgz", + "integrity": "sha512-DCa5YaknS4bWhFt8TMEGH+qmTinyzuY8hoZbO4crtWXAxofPP7Pas76Cwxlvis/PyLffA+pPxAl1l5sUZpsvqw==", "cpu": [ "arm64" ], @@ -2148,9 +2208,9 @@ ] }, "node_modules/@opentui/core-linux-x64": { - "version": "0.1.75", - "resolved": "https://registry.npmjs.org/@opentui/core-linux-x64/-/core-linux-x64-0.1.75.tgz", - "integrity": "sha512-1CnApef4kxA+ORyLfbuCLgZfEjp4wr3HjFnt7FAfOb73kIZH82cb7JYixeqRyy9eOcKfKqxLmBYy3o8IDkc4Rg==", + "version": "0.1.79", + "resolved": "https://registry.npmjs.org/@opentui/core-linux-x64/-/core-linux-x64-0.1.79.tgz", + "integrity": "sha512-V6xjvFfHh3NGvsuuDae1KHPRZXHMEE8XL0A/GM6v4I4OCC23kDmkK60Vn6OptQwAzwwbz0X0IX+Ut/GQU9qGgA==", "cpu": [ "x64" ], @@ -2161,9 +2221,9 @@ ] }, "node_modules/@opentui/core-win32-arm64": { - "version": "0.1.75", - "resolved": "https://registry.npmjs.org/@opentui/core-win32-arm64/-/core-win32-arm64-0.1.75.tgz", - "integrity": "sha512-j0UB95nmkYGNzmOrs6GqaddO1S90R0YC6IhbKnbKBdjchFPNVLz9JpexAs6MBDXPZwdKAywMxtwG2h3aTJtxng==", + "version": "0.1.79", + "resolved": "https://registry.npmjs.org/@opentui/core-win32-arm64/-/core-win32-arm64-0.1.79.tgz", + "integrity": "sha512-sPRKnVzOdT5szI59tte7pxwwkYA+07EQN+6miFAvkFuiLmRUngONUD8HVjL7nCnxcPFqxaU4Rvl1y40ST86g8g==", "cpu": [ "arm64" ], @@ -2174,9 +2234,9 @@ ] }, "node_modules/@opentui/core-win32-x64": { - "version": "0.1.75", - "resolved": "https://registry.npmjs.org/@opentui/core-win32-x64/-/core-win32-x64-0.1.75.tgz", - "integrity": "sha512-ESpVZVGewe3JkB2TwrG3VRbkxT909iPdtvgNT7xTCIYH2VB4jqZomJfvERPTE0tvqAZJm19mHECzJFI8asSJgQ==", + "version": "0.1.79", + "resolved": "https://registry.npmjs.org/@opentui/core-win32-x64/-/core-win32-x64-0.1.79.tgz", + "integrity": "sha512-vmQcFTvKf9fqajnDtgU6/uAsiTGwx8//khqHVBmiTEXUsiT792Ki9l8sgNughbuldqG5iZOiF6IaAWU1H67UpA==", "cpu": [ "x64" ], @@ -2187,14 +2247,14 @@ ] }, "node_modules/@opentui/solid": { - "version": "0.1.75", - "resolved": "https://registry.npmjs.org/@opentui/solid/-/solid-0.1.75.tgz", - "integrity": "sha512-WjKsZIfrm29znfRlcD9w3uUn/+uvoy2MmeoDwTvg1YOa0OjCTCmjZ43L9imp0m9S4HmVU8ma6o2bR4COzcyDdg==", + "version": "0.1.79", + "resolved": "https://registry.npmjs.org/@opentui/solid/-/solid-0.1.79.tgz", + "integrity": "sha512-c5+0jexKxb8GwRDDkQ/U6isZZqClAzHccXmYiLYmSnqdoQQp2lIGHLartL+K8lfIQrsKClzP2ZHumN6nexRfRg==", "license": "MIT", "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", - "@opentui/core": "0.1.75", + "@opentui/core": "0.1.79", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" @@ -2204,9 +2264,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.0.tgz", - "integrity": "sha512-tPgXB6cDTndIe1ah7u6amCI1T0SsnlOuKgg10Xh3uizJk4e5M1JGaUMk7J4ciuAUcFpbOiNhm2XIjP9ON0dUqA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", "cpu": [ "arm" ], @@ -2218,9 +2278,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.0.tgz", - "integrity": "sha512-sa4LyseLLXr1onr97StkU1Nb7fWcg6niokTwEVNOO7awaKaoRObQ54+V/hrF/BP1noMEaaAW6Fg2d/CfLiq3Mg==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", "cpu": [ "arm64" ], @@ -2232,9 +2292,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.0.tgz", - "integrity": "sha512-/NNIj9A7yLjKdmkx5dC2XQ9DmjIECpGpwHoGmA5E1AhU0fuICSqSWScPhN1yLCkEdkCwJIDu2xIeLPs60MNIVg==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", "cpu": [ "arm64" ], @@ -2246,9 +2306,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.0.tgz", - "integrity": "sha512-xoh8abqgPrPYPr7pTYipqnUi1V3em56JzE/HgDgitTqZBZ3yKCWI+7KUkceM6tNweyUKYru1UMi7FC060RyKwA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", "cpu": [ "x64" ], @@ -2260,9 +2320,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.0.tgz", - "integrity": "sha512-PCkMh7fNahWSbA0OTUQ2OpYHpjZZr0hPr8lId8twD7a7SeWrvT3xJVyza+dQwXSSq4yEQTMoXgNOfMCsn8584g==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", "cpu": [ "arm64" ], @@ -2274,9 +2334,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.0.tgz", - "integrity": "sha512-1j3stGx+qbhXql4OCDZhnK7b01s6rBKNybfsX+TNrEe9JNq4DLi1yGiR1xW+nL+FNVvI4D02PUnl6gJ/2y6WJA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", "cpu": [ "x64" ], @@ -2288,9 +2348,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.0.tgz", - "integrity": "sha512-eyrr5W08Ms9uM0mLcKfM/Uzx7hjhz2bcjv8P2uynfj0yU8GGPdz8iYrBPhiLOZqahoAMB8ZiolRZPbbU2MAi6Q==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", "cpu": [ "arm" ], @@ -2302,9 +2362,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.0.tgz", - "integrity": "sha512-Xds90ITXJCNyX9pDhqf85MKWUI4lqjiPAipJ8OLp8xqI2Ehk+TCVhF9rvOoN8xTbcafow3QOThkNnrM33uCFQA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", "cpu": [ "arm" ], @@ -2316,9 +2376,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.0.tgz", - "integrity": "sha512-Xws2KA4CLvZmXjy46SQaXSejuKPhwVdaNinldoYfqruZBaJHqVo6hnRa8SDo9z7PBW5x84SH64+izmldCgbezw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", "cpu": [ "arm64" ], @@ -2330,9 +2390,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.0.tgz", - "integrity": "sha512-hrKXKbX5FdaRJj7lTMusmvKbhMJSGWJ+w++4KmjiDhpTgNlhYobMvKfDoIWecy4O60K6yA4SnztGuNTQF+Lplw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", "cpu": [ "arm64" ], @@ -2344,9 +2404,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.0.tgz", - "integrity": "sha512-6A+nccfSDGKsPm00d3xKcrsBcbqzCTAukjwWK6rbuAnB2bHaL3r9720HBVZ/no7+FhZLz/U3GwwZZEh6tOSI8Q==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", "cpu": [ "loong64" ], @@ -2358,9 +2418,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.0.tgz", - "integrity": "sha512-4P1VyYUe6XAJtQH1Hh99THxr0GKMMwIXsRNOceLrJnaHTDgk1FTcTimDgneRJPvB3LqDQxUmroBclQ1S0cIJwQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", "cpu": [ "loong64" ], @@ -2372,9 +2432,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.0.tgz", - "integrity": "sha512-8Vv6pLuIZCMcgXre6c3nOPhE0gjz1+nZP6T+hwWjr7sVH8k0jRkH+XnfjjOTglyMBdSKBPPz54/y1gToSKwrSQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", "cpu": [ "ppc64" ], @@ -2386,9 +2446,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.0.tgz", - "integrity": "sha512-r1te1M0Sm2TBVD/RxBPC6RZVwNqUTwJTA7w+C/IW5v9Ssu6xmxWEi+iJQlpBhtUiT1raJ5b48pI8tBvEjEFnFA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", "cpu": [ "ppc64" ], @@ -2400,9 +2460,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.0.tgz", - "integrity": "sha512-say0uMU/RaPm3CDQLxUUTF2oNWL8ysvHkAjcCzV2znxBr23kFfaxocS9qJm+NdkRhF8wtdEEAJuYcLPhSPbjuQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", "cpu": [ "riscv64" ], @@ -2414,9 +2474,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.0.tgz", - "integrity": "sha512-/MU7/HizQGsnBREtRpcSbSV1zfkoxSTR7wLsRmBPQ8FwUj5sykrP1MyJTvsxP5KBq9SyE6kH8UQQQwa0ASeoQQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", "cpu": [ "riscv64" ], @@ -2428,9 +2488,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.0.tgz", - "integrity": "sha512-Q9eh+gUGILIHEaJf66aF6a414jQbDnn29zeu0eX3dHMuysnhTvsUvZTCAyZ6tJhUjnvzBKE4FtuaYxutxRZpOg==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", "cpu": [ "s390x" ], @@ -2442,9 +2502,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.0.tgz", - "integrity": "sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", "cpu": [ "x64" ], @@ -2456,9 +2516,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.0.tgz", - "integrity": "sha512-XeatKzo4lHDsVEbm1XDHZlhYZZSQYym6dg2X/Ko0kSFgio+KXLsxwJQprnR48GvdIKDOpqWqssC3iBCjoMcMpw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", "cpu": [ "x64" ], @@ -2470,9 +2530,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.0.tgz", - "integrity": "sha512-Lu71y78F5qOfYmubYLHPcJm74GZLU6UJ4THkf/a1K7Tz2ycwC2VUbsqbJAXaR6Bx70SRdlVrt2+n5l7F0agTUw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", "cpu": [ "x64" ], @@ -2484,9 +2544,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.0.tgz", - "integrity": "sha512-v5xwKDWcu7qhAEcsUubiav7r+48Uk/ENWdr82MBZZRIm7zThSxCIVDfb3ZeRRq9yqk+oIzMdDo6fCcA5DHfMyA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", "cpu": [ "arm64" ], @@ -2498,9 +2558,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.0.tgz", - "integrity": "sha512-XnaaaSMGSI6Wk8F4KK3QP7GfuuhjGchElsVerCplUuxRIzdvZ7hRBpLR0omCmw+kI2RFJB80nenhOoGXlJ5TfQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", "cpu": [ "arm64" ], @@ -2512,9 +2572,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.0.tgz", - "integrity": "sha512-3K1lP+3BXY4t4VihLw5MEg6IZD3ojSYzqzBG571W3kNQe4G4CcFpSUQVgurYgib5d+YaCjeFow8QivWp8vuSvA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", "cpu": [ "ia32" ], @@ -2526,9 +2586,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.0.tgz", - "integrity": "sha512-MDk610P/vJGc5L5ImE4k5s+GZT3en0KoK1MKPXCRgzmksAMk79j4h3k1IerxTNqwDLxsGxStEZVBqG0gIqZqoA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", "cpu": [ "x64" ], @@ -2540,9 +2600,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.0.tgz", - "integrity": "sha512-Zv7v6q6aV+VslnpwzqKAmrk5JdVkLUzok2208ZXGipjb+msxBr/fJPZyeEXiFgH7k62Ak0SLIfxQRZQvTuf7rQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", "cpu": [ "x64" ], @@ -2639,6 +2699,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2678,9 +2745,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.1.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.1.0.tgz", - "integrity": "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA==", + "version": "25.2.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz", + "integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2705,17 +2772,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz", - "integrity": "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.55.0.tgz", + "integrity": "sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.54.0", - "@typescript-eslint/type-utils": "8.54.0", - "@typescript-eslint/utils": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0", + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/type-utils": "8.55.0", + "@typescript-eslint/utils": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" @@ -2728,7 +2795,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.54.0", + "@typescript-eslint/parser": "^8.55.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } @@ -2744,16 +2811,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.54.0.tgz", - "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.55.0.tgz", + "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.54.0", - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/typescript-estree": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0", + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", "debug": "^4.4.3" }, "engines": { @@ -2769,14 +2836,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.54.0.tgz", - "integrity": "sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.55.0.tgz", + "integrity": "sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.54.0", - "@typescript-eslint/types": "^8.54.0", + "@typescript-eslint/tsconfig-utils": "^8.55.0", + "@typescript-eslint/types": "^8.55.0", "debug": "^4.4.3" }, "engines": { @@ -2791,14 +2858,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.54.0.tgz", - "integrity": "sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.55.0.tgz", + "integrity": "sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0" + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2809,9 +2876,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.54.0.tgz", - "integrity": "sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.55.0.tgz", + "integrity": "sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==", "dev": true, "license": "MIT", "engines": { @@ -2826,15 +2893,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.54.0.tgz", - "integrity": "sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.55.0.tgz", + "integrity": "sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/typescript-estree": "8.54.0", - "@typescript-eslint/utils": "8.54.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0", + "@typescript-eslint/utils": "8.55.0", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, @@ -2851,9 +2918,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.54.0.tgz", - "integrity": "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.55.0.tgz", + "integrity": "sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==", "dev": true, "license": "MIT", "engines": { @@ -2865,16 +2932,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.54.0.tgz", - "integrity": "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.55.0.tgz", + "integrity": "sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.54.0", - "@typescript-eslint/tsconfig-utils": "8.54.0", - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0", + "@typescript-eslint/project-service": "8.55.0", + "@typescript-eslint/tsconfig-utils": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", "debug": "^4.4.3", "minimatch": "^9.0.5", "semver": "^7.7.3", @@ -2919,9 +2986,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -2932,16 +2999,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.54.0.tgz", - "integrity": "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.55.0.tgz", + "integrity": "sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.54.0", - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/typescript-estree": "8.54.0" + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2956,13 +3023,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.54.0.tgz", - "integrity": "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.55.0.tgz", + "integrity": "sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/types": "8.55.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -3782,9 +3849,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001766", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz", - "integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==", + "version": "1.0.30001769", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz", + "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==", "funding": [ { "type": "opencollective", @@ -4012,9 +4079,9 @@ "license": "MIT" }, "node_modules/commander": { - "version": "14.0.2", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz", - "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==", + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", "license": "MIT", "engines": { "node": ">=20" @@ -4240,9 +4307,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.279", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.279.tgz", - "integrity": "sha512-0bblUU5UNdOt5G7XqGiJtpZMONma6WAfq9vsFmtn9x1+joAObr6x1chfqyxFSDCAFwFhCQDrqeAr6MYdpwJ9Hg==", + "version": "1.5.286", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", "license": "ISC" }, "node_modules/emoji-regex": { @@ -4252,14 +4319,14 @@ "license": "MIT" }, "node_modules/enhanced-resolve": { - "version": "5.18.4", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", - "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==", + "version": "5.19.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", + "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" + "tapable": "^2.3.0" }, "engines": { "node": ">=10.13.0" @@ -4434,9 +4501,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", - "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -4447,32 +4514,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.2", - "@esbuild/android-arm": "0.27.2", - "@esbuild/android-arm64": "0.27.2", - "@esbuild/android-x64": "0.27.2", - "@esbuild/darwin-arm64": "0.27.2", - "@esbuild/darwin-x64": "0.27.2", - "@esbuild/freebsd-arm64": "0.27.2", - "@esbuild/freebsd-x64": "0.27.2", - "@esbuild/linux-arm": "0.27.2", - "@esbuild/linux-arm64": "0.27.2", - "@esbuild/linux-ia32": "0.27.2", - "@esbuild/linux-loong64": "0.27.2", - "@esbuild/linux-mips64el": "0.27.2", - "@esbuild/linux-ppc64": "0.27.2", - "@esbuild/linux-riscv64": "0.27.2", - "@esbuild/linux-s390x": "0.27.2", - "@esbuild/linux-x64": "0.27.2", - "@esbuild/netbsd-arm64": "0.27.2", - "@esbuild/netbsd-x64": "0.27.2", - "@esbuild/openbsd-arm64": "0.27.2", - "@esbuild/openbsd-x64": "0.27.2", - "@esbuild/openharmony-arm64": "0.27.2", - "@esbuild/sunos-x64": "0.27.2", - "@esbuild/win32-arm64": "0.27.2", - "@esbuild/win32-ia32": "0.27.2", - "@esbuild/win32-x64": "0.27.2" + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" } }, "node_modules/escalade": { @@ -4498,33 +4565,30 @@ } }, "node_modules/eslint": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", - "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.0.tgz", + "integrity": "sha512-O0piBKY36YSJhlFSG8p9VUdPV/SxxS4FYDWVpr/9GJuMaepzwlf4J8I4ov1b+ySQfDTPhc3DtLaxcT1fN0yqCg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.2", - "@eslint/plugin-kit": "^0.4.1", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.0", + "@eslint/config-helpers": "^0.5.2", + "@eslint/core": "^1.1.0", + "@eslint/plugin-kit": "^0.6.0", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", - "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", + "eslint-scope": "^9.1.0", + "eslint-visitor-keys": "^5.0.0", + "espree": "^11.1.0", + "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", @@ -4534,8 +4598,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^10.1.1", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -4543,7 +4606,7 @@ "eslint": "bin/eslint.js" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" @@ -4574,9 +4637,9 @@ } }, "node_modules/eslint-compat-utils/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -4771,9 +4834,9 @@ } }, "node_modules/eslint-config-standard-with-typescript/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -4953,9 +5016,9 @@ } }, "node_modules/eslint-plugin-n/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -4985,17 +5048,19 @@ } }, "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.0.tgz", + "integrity": "sha512-CkWE42hOJsNj9FJRaoMX9waUFYhqY4jmyLFdAdzZr6VaCg3ynLYx4WnOdkaIifGfH4gsUcBTn4OZbHXkpLD0FQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -5014,52 +5079,79 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/eslint/node_modules/balanced-match": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.2.tgz", + "integrity": "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==", "dev": true, "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" + "jackspeak": "^4.2.3" }, "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": "20 || >=22" } }, - "node_modules/eslint/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/eslint/node_modules/brace-expansion": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", + "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "balanced-match": "^4.0.2" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": "20 || >=22" } }, "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.0.tgz", + "integrity": "sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/espree": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.1.0.tgz", + "integrity": "sha512-WFWYhO1fV4iYkqOOvq8FbqIhr2pYfoDY0kCotMkDeNtGpiGGkZ1iov2u8ydjtgM8yF8rzK7oaTbw2NAzbAbehw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.0.tgz", + "integrity": "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", @@ -5533,9 +5625,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", - "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", "dev": true, "license": "MIT", "dependencies": { @@ -5559,6 +5651,7 @@ "version": "9.3.5", "resolved": "https://registry.npmjs.org/glob/-/glob-9.3.5.tgz", "integrity": "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -5611,9 +5704,9 @@ } }, "node_modules/globals": { - "version": "17.2.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.2.0.tgz", - "integrity": "sha512-tovnCz/fEq+Ripoq+p/gN1u7l6A7wwkoBT9pRCzTHzsD/LvADIzXZdjmRymh5Ztf0DYC3Rwg5cZRYjxzBmzbWg==", + "version": "17.3.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.3.0.tgz", + "integrity": "sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==", "dev": true, "license": "MIT", "engines": { @@ -6416,6 +6509,22 @@ "dev": true, "license": "ISC" }, + "node_modules/jackspeak": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^9.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/jimp": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/jimp/-/jimp-1.6.0.tgz", @@ -6564,13 +6673,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, "node_modules/log-symbols": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", @@ -6980,9 +7082,9 @@ } }, "node_modules/ora": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/ora/-/ora-9.1.0.tgz", - "integrity": "sha512-53uuLsXHOAJl5zLrUrzY9/kE+uIFEx7iaH4g2BIJQK4LZjY4LpCCYZVKDWIkL+F01wAaCg93duQ1whnK/AmY1A==", + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-9.3.0.tgz", + "integrity": "sha512-lBX72MWFduWEf7v7uWf5DHp9Jn5BI8bNPGuFgtXMmr2uDz2Gz2749y3am3agSDdkhHPHYmmxEGSKH85ZLGzgXw==", "license": "MIT", "dependencies": { "chalk": "^5.6.2", @@ -6991,7 +7093,7 @@ "is-interactive": "^2.0.0", "is-unicode-supported": "^2.1.0", "log-symbols": "^7.0.1", - "stdin-discarder": "^0.2.2", + "stdin-discarder": "^0.3.1", "string-width": "^8.1.0" }, "engines": { @@ -7337,13 +7439,13 @@ } }, "node_modules/planck": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/planck/-/planck-1.4.2.tgz", - "integrity": "sha512-mNbhnV3g8X2rwGxzcesjmN8BDA6qfXgQxXVMkWau9MCRlQY0RLNEkyHlVp6yFy/X6qrzAXyNONCnZ1cGDLrNew==", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/planck/-/planck-1.4.3.tgz", + "integrity": "sha512-B+lHKhRSeg7vZOfEyEzyQVu7nx8JHcX3QgnAcHXrPW0j04XYKX5eXSiUrxH2Z5QR8OoqvjD6zKIaPMdMYAd0uA==", "license": "MIT", "optional": true, "engines": { - "node": ">=14.0" + "node": ">=24.0" }, "peerDependencies": { "stage-js": "^1.0.0-alpha.12" @@ -7666,9 +7768,9 @@ } }, "node_modules/rollup": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.0.tgz", - "integrity": "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", "dev": true, "license": "MIT", "dependencies": { @@ -7682,31 +7784,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.0", - "@rollup/rollup-android-arm64": "4.57.0", - "@rollup/rollup-darwin-arm64": "4.57.0", - "@rollup/rollup-darwin-x64": "4.57.0", - "@rollup/rollup-freebsd-arm64": "4.57.0", - "@rollup/rollup-freebsd-x64": "4.57.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.0", - "@rollup/rollup-linux-arm-musleabihf": "4.57.0", - "@rollup/rollup-linux-arm64-gnu": "4.57.0", - "@rollup/rollup-linux-arm64-musl": "4.57.0", - "@rollup/rollup-linux-loong64-gnu": "4.57.0", - "@rollup/rollup-linux-loong64-musl": "4.57.0", - "@rollup/rollup-linux-ppc64-gnu": "4.57.0", - "@rollup/rollup-linux-ppc64-musl": "4.57.0", - "@rollup/rollup-linux-riscv64-gnu": "4.57.0", - "@rollup/rollup-linux-riscv64-musl": "4.57.0", - "@rollup/rollup-linux-s390x-gnu": "4.57.0", - "@rollup/rollup-linux-x64-gnu": "4.57.0", - "@rollup/rollup-linux-x64-musl": "4.57.0", - "@rollup/rollup-openbsd-x64": "4.57.0", - "@rollup/rollup-openharmony-arm64": "4.57.0", - "@rollup/rollup-win32-arm64-msvc": "4.57.0", - "@rollup/rollup-win32-ia32-msvc": "4.57.0", - "@rollup/rollup-win32-x64-gnu": "4.57.0", - "@rollup/rollup-win32-x64-msvc": "4.57.0", + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" } }, @@ -8099,9 +8201,9 @@ "license": "MIT" }, "node_modules/stdin-discarder": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", - "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.1.tgz", + "integrity": "sha512-reExS1kSGoElkextOcPkel4NE99S0BWxjUHQeDFnR8S993JxpPX7KU4MNmO19NXhlJp+8dmdCbKQVNgLJh2teA==", "license": "MIT", "engines": { "node": ">=18" @@ -8618,16 +8720,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.54.0.tgz", - "integrity": "sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.55.0.tgz", + "integrity": "sha512-HE4wj+r5lmDVS9gdaN0/+iqNvPZwGfnJ5lZuz7s5vLlg9ODw0bIiiETaios9LvFI1U94/VBXGm3CB2Y5cNFMpw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.54.0", - "@typescript-eslint/parser": "8.54.0", - "@typescript-eslint/typescript-estree": "8.54.0", - "@typescript-eslint/utils": "8.54.0" + "@typescript-eslint/eslint-plugin": "8.55.0", + "@typescript-eslint/parser": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0", + "@typescript-eslint/utils": "8.55.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -9192,9 +9294,9 @@ } }, "node_modules/zustand": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.10.tgz", - "integrity": "sha512-U1AiltS1O9hSy3rul+Ub82ut2fqIAefiSuwECWt6jlMVUGejvf+5omLcRBSzqbRagSM3hQZbtzdeRc6QVScXTg==", + "version": "5.0.11", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz", + "integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==", "license": "MIT", "engines": { "node": ">=12.20.0" diff --git a/package.json b/package.json index e535a08..dd5f7fe 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,6 @@ "scripts": { "dev": "bun src/index.ts", "dev:nobump": "bun scripts/build.ts && npm link", - "dev:watch": "bun scripts/dev-watch.ts", - "dev:debug": "bun --inspect=localhost:6499/debug src/index.ts", - "dev:debug-brk": "bun --inspect-brk=localhost:6499/debug src/index.ts", "build": "bun scripts/build.ts", "sync-version": "bun scripts/sync-version.ts", "start": "bun src/index.ts", @@ -20,7 +17,8 @@ "lint": "bun eslint src/**/*.ts", "format": "npx prettier --write \"src/**/*.ts\"", "prettier": "npx prettier --write \"src/**/*.ts\" \"src/**/*.tsx\" \"scripts/**/*.ts\"", - "typecheck": "bun tsc --noEmit" + "typecheck": "bun tsc --noEmit", + "version": "bun scripts/sync-version.ts && git add src/version.json" }, "keywords": [ "ai", @@ -78,13 +76,13 @@ }, "devDependencies": { "@eslint/eslintrc": "^3.3.3", - "@eslint/js": "^9.39.2", + "@eslint/js": "^10.0.1", "@types/inquirer": "^9.0.7", "@types/node": "^25.0.10", "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^8.53.1", "@typescript-eslint/parser": "^8.53.1", - "eslint": "^9.39.2", + "eslint": "^10.0.0", "eslint-config-standard": "^17.1.0", "eslint-config-standard-with-typescript": "^43.0.1", "eslint-plugin-import": "^2.32.0", diff --git a/scripts/dev-watch.ts b/scripts/dev-watch.ts deleted file mode 100644 index cf8db3e..0000000 --- a/scripts/dev-watch.ts +++ /dev/null @@ -1,138 +0,0 @@ -#!/usr/bin/env bun -/** - * Development watch script for codetyper-cli - * - * Watches for file changes and restarts the TUI application properly, - * handling terminal cleanup between restarts. - */ - -import { spawn, type Subprocess } from "bun"; -import { watch } from "chokidar"; -import { join, dirname } from "path"; -import { fileURLToPath } from "url"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const ROOT_DIR = join(__dirname, ".."); - -const WATCH_PATHS = ["src/**/*.ts", "src/**/*.tsx"]; - -const IGNORE_PATTERNS = [ - "**/node_modules/**", - "**/dist/**", - "**/*.test.ts", - "**/*.spec.ts", -]; - -const DEBOUNCE_MS = 300; - -let currentProcess: Subprocess | null = null; -let restartTimeout: ReturnType | null = null; - -const clearTerminal = (): void => { - // Reset terminal and clear screen - process.stdout.write("\x1b[2J\x1b[H\x1b[3J"); -}; - -const killProcess = async (): Promise => { - if (currentProcess) { - try { - currentProcess.kill("SIGTERM"); - // Wait a bit for graceful shutdown - await new Promise((resolve) => setTimeout(resolve, 100)); - if (currentProcess.exitCode === null) { - currentProcess.kill("SIGKILL"); - } - } catch { - // Process might already be dead - } - currentProcess = null; - } -}; - -const startProcess = (): void => { - clearTerminal(); - console.log("\x1b[36m[dev-watch]\x1b[0m Starting codetyper..."); - console.log("\x1b[90m─────────────────────────────────────\x1b[0m\n"); - - currentProcess = spawn({ - cmd: ["bun", "src/index.ts"], - cwd: ROOT_DIR, - stdio: ["inherit", "inherit", "inherit"], - env: { - ...process.env, - NODE_ENV: "development", - }, - }); - - currentProcess.exited.then((code) => { - if (code !== 0 && code !== null) { - console.log( - `\n\x1b[33m[dev-watch]\x1b[0m Process exited with code ${code}`, - ); - } - }); -}; - -const scheduleRestart = (path: string): void => { - if (restartTimeout) { - clearTimeout(restartTimeout); - } - - restartTimeout = setTimeout(async () => { - console.log(`\n\x1b[33m[dev-watch]\x1b[0m Change detected: ${path}`); - console.log("\x1b[33m[dev-watch]\x1b[0m Restarting...\n"); - - await killProcess(); - startProcess(); - }, DEBOUNCE_MS); -}; - -const main = async (): Promise => { - console.log("\x1b[36m[dev-watch]\x1b[0m Watching for changes..."); - console.log(`\x1b[90mRoot: ${ROOT_DIR}\x1b[0m`); - console.log(`\x1b[90mPaths: ${WATCH_PATHS.join(", ")}\x1b[0m`); - - const watcher = watch(WATCH_PATHS, { - ignored: IGNORE_PATTERNS, - persistent: true, - ignoreInitial: true, - cwd: ROOT_DIR, - usePolling: false, - awaitWriteFinish: { - stabilityThreshold: 100, - pollInterval: 100, - }, - }); - - watcher.on("ready", () => { - console.log("\x1b[32m[dev-watch]\x1b[0m Watcher ready\n"); - }); - - watcher.on("error", (error) => { - console.error("\x1b[31m[dev-watch]\x1b[0m Watcher error:", error); - }); - - watcher.on("change", scheduleRestart); - watcher.on("add", scheduleRestart); - watcher.on("unlink", scheduleRestart); - - // Handle exit signals - const cleanup = async (): Promise => { - console.log("\n\x1b[36m[dev-watch]\x1b[0m Shutting down..."); - await watcher.close(); - await killProcess(); - process.exit(0); - }; - - process.on("SIGINT", cleanup); - process.on("SIGTERM", cleanup); - - // Start the initial process - startProcess(); -}; - -main().catch((err) => { - console.error("Error:", err); - process.exit(1); -}); diff --git a/src/api/copilot/auth/auth.ts b/src/api/copilot/auth/auth.ts index 6cff450..86b9185 100644 --- a/src/api/copilot/auth/auth.ts +++ b/src/api/copilot/auth/auth.ts @@ -1,9 +1,3 @@ -/** - * Copilot Authentication API - * - * Low-level API calls for GitHub OAuth device flow - */ - import got from "got"; import { GITHUB_CLIENT_ID, diff --git a/src/api/copilot/auth/token.ts b/src/api/copilot/auth/token.ts index ca8978b..09cd31e 100644 --- a/src/api/copilot/auth/token.ts +++ b/src/api/copilot/auth/token.ts @@ -1,16 +1,7 @@ -/** - * Copilot Token API - * - * Low-level API calls for Copilot token management - */ - import got from "got"; import { COPILOT_AUTH_URL } from "@constants/copilot"; import type { CopilotToken } from "@/types/copilot"; -/** - * Refresh Copilot access token using OAuth token - */ export const fetchCopilotToken = async ( oauthToken: string, ): Promise => { @@ -30,9 +21,6 @@ export const fetchCopilotToken = async ( return response; }; -/** - * Build standard headers for Copilot API requests - */ export const buildCopilotHeaders = ( token: CopilotToken, ): Record => ({ diff --git a/src/api/copilot/core/chat.ts b/src/api/copilot/core/chat.ts index aab6275..e9f12cb 100644 --- a/src/api/copilot/core/chat.ts +++ b/src/api/copilot/core/chat.ts @@ -1,48 +1,17 @@ -/** - * Copilot Chat API - * - * Low-level API calls for chat completions - */ - import got from "got"; import type { CopilotToken } from "@/types/copilot"; import type { Message, ChatCompletionOptions, ChatCompletionResponse, - StreamChunk, } from "@/types/providers"; + import { buildCopilotHeaders } from "@api/copilot/auth/token"; - -interface FormattedMessage { - role: string; - content: string; - tool_call_id?: string; - tool_calls?: Message["tool_calls"]; -} - -interface ChatRequestBody { - model: string; - messages: FormattedMessage[]; - max_tokens: number; - temperature: number; - stream: boolean; - tools?: ChatCompletionOptions["tools"]; - tool_choice?: string; -} - -interface ChatApiResponse { - error?: { message?: string }; - choices?: Array<{ - message?: { content?: string; tool_calls?: Message["tool_calls"] }; - finish_reason?: ChatCompletionResponse["finishReason"]; - }>; - usage?: { - prompt_tokens?: number; - completion_tokens?: number; - total_tokens?: number; - }; -} +import { + FormattedMessage, + ChatRequestBody, + ChatApiResponse, +} from "@/interfaces/api/copilot/core"; const formatMessages = (messages: Message[]): FormattedMessage[] => messages.map((msg) => { @@ -137,61 +106,3 @@ export const executeChatRequest = async ( return result; }; - -/** - * Execute streaming chat request - */ -export const executeStreamRequest = ( - endpoint: string, - token: CopilotToken, - body: ChatRequestBody, - onChunk: (chunk: StreamChunk) => void, -): Promise => - new Promise((resolve, reject) => { - const stream = got.stream.post(endpoint, { - headers: buildCopilotHeaders(token), - json: body, - }); - - let buffer = ""; - - stream.on("data", (data: Buffer) => { - buffer += data.toString(); - const lines = buffer.split("\n"); - buffer = lines.pop() ?? ""; - - for (const line of lines) { - if (line.startsWith("data: ")) { - const jsonStr = line.slice(6).trim(); - if (jsonStr === "[DONE]") { - onChunk({ type: "done" }); - return; - } - - try { - const parsed = JSON.parse(jsonStr); - const delta = parsed.choices?.[0]?.delta; - - if (delta?.content) { - onChunk({ type: "content", content: delta.content }); - } - - if (delta?.tool_calls) { - for (const tc of delta.tool_calls) { - onChunk({ type: "tool_call", toolCall: tc }); - } - } - } catch { - // Ignore parse errors in stream - } - } - } - }); - - stream.on("error", (error: Error) => { - onChunk({ type: "error", error: error.message }); - reject(error); - }); - - stream.on("end", resolve); - }); diff --git a/src/api/copilot/core/stream.ts b/src/api/copilot/core/stream.ts new file mode 100644 index 0000000..449dfe7 --- /dev/null +++ b/src/api/copilot/core/stream.ts @@ -0,0 +1,60 @@ +import got from "got"; +import type { CopilotToken } from "@/types/copilot"; +import type { StreamChunk } from "@/types/providers"; +import { buildCopilotHeaders } from "@api/copilot/auth/token"; +import { ChatRequestBody } from "@/interfaces/api/copilot/core"; + +export const executeStreamRequest = ( + endpoint: string, + token: CopilotToken, + body: ChatRequestBody, + onChunk: (chunk: StreamChunk) => void, +): Promise => + new Promise((resolve, reject) => { + const stream = got.stream.post(endpoint, { + headers: buildCopilotHeaders(token), + json: body, + }); + + let buffer = ""; + + stream.on("data", (data: Buffer) => { + buffer += data.toString(); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + if (line.startsWith("data: ")) { + const jsonStr = line.slice(6).trim(); + if (jsonStr === "[DONE]") { + onChunk({ type: "done" }); + return; + } + + try { + const parsed = JSON.parse(jsonStr); + const delta = parsed.choices?.[0]?.delta; + + if (delta?.content) { + onChunk({ type: "content", content: delta.content }); + } + + if (delta?.tool_calls) { + for (const tc of delta.tool_calls) { + onChunk({ type: "tool_call", toolCall: tc }); + } + } + } catch { + // Ignore parse errors in stream + } + } + } + }); + + stream.on("error", (error: Error) => { + onChunk({ type: "error", error: error.message }); + reject(error); + }); + + stream.on("end", resolve); + }); diff --git a/src/commands/components/chat/commands/commandsRegistry.ts b/src/commands/components/chat/commands/commandsRegistry.ts index b2524bb..b4a4aec 100644 --- a/src/commands/components/chat/commands/commandsRegistry.ts +++ b/src/commands/components/chat/commands/commandsRegistry.ts @@ -106,6 +106,17 @@ const COMMAND_REGISTRY: Map = new Map< }, ], ["mcp", async (ctx: CommandContext) => handleMCP(ctx.args)], + [ + "mode", + () => { + appStore.toggleInteractionMode(); + const { interactionMode } = appStore.getState(); + appStore.addLog({ + type: "system", + content: `Switched to ${interactionMode} mode`, + }); + }, + ], [ "logs", () => { diff --git a/src/commands/components/chat/history/show-context.ts b/src/commands/components/chat/history/show-context.ts index 83b9f5d..fb4af56 100644 --- a/src/commands/components/chat/history/show-context.ts +++ b/src/commands/components/chat/history/show-context.ts @@ -1,10 +1,11 @@ import chalk from "chalk"; +import { getMessageText } from "@/types/providers"; import type { ChatState } from "@commands/components/chat/state"; export const showContext = (state: ChatState): void => { const messageCount = state.messages.length - 1; const totalChars = state.messages.reduce( - (acc, m) => acc + m.content.length, + (acc, m) => acc + getMessageText(m.content).length, 0, ); const estimatedTokens = Math.round(totalChars / 4); diff --git a/src/commands/components/chat/history/show-history.ts b/src/commands/components/chat/history/show-history.ts index f11ed7e..0883e48 100644 --- a/src/commands/components/chat/history/show-history.ts +++ b/src/commands/components/chat/history/show-history.ts @@ -1,4 +1,5 @@ import chalk from "chalk"; +import { getMessageText } from "@/types/providers"; import type { ChatState } from "@commands/components/chat/state"; export const showHistory = (state: ChatState): void => { @@ -8,9 +9,10 @@ export const showHistory = (state: ChatState): void => { const msg = state.messages[i]; const role = msg.role === "user" ? chalk.cyan("You") : chalk.green("Assistant"); - const preview = msg.content.slice(0, 100).replace(/\n/g, " "); + const text = getMessageText(msg.content); + const preview = text.slice(0, 100).replace(/\n/g, " "); console.log( - ` ${i}. ${role}: ${preview}${msg.content.length > 100 ? "..." : ""}`, + ` ${i}. ${role}: ${preview}${text.length > 100 ? "..." : ""}`, ); } diff --git a/src/commands/components/chat/messages/handle-input.ts b/src/commands/components/chat/messages/handle-input.ts index 28f154a..ad0009c 100644 --- a/src/commands/components/chat/messages/handle-input.ts +++ b/src/commands/components/chat/messages/handle-input.ts @@ -7,7 +7,10 @@ export const handleInput = async ( state: ChatState, handleCommand: (command: string, state: ChatState) => Promise, ): Promise => { - if (input.startsWith("/")) { + // Only treat as a slash-command when it looks like one (e.g. /help, /model-gpt4) + // This prevents pasting/debugging content that starts with "/" from invoking command parsing. + const slashCommandMatch = input.match(/^\/([\w-]+)(?:\s|$)/); + if (slashCommandMatch) { await handleCommand(input, state); return; } diff --git a/src/commands/components/chat/messages/send-message.ts b/src/commands/components/chat/messages/send-message.ts index 643d43f..8dbc554 100644 --- a/src/commands/components/chat/messages/send-message.ts +++ b/src/commands/components/chat/messages/send-message.ts @@ -29,6 +29,7 @@ import { processMemoryCommand, buildRelevantMemoryPrompt, } from "@services/memory-service"; +import { getMessageText } from "@/types/providers"; import type { ChatState } from "@commands/components/chat/state"; export const sendMessage = async ( @@ -57,7 +58,7 @@ export const sendMessage = async ( // Inject debugging system message before user message if not already present const hasDebuggingPrompt = state.messages.some( - (msg) => msg.role === "system" && msg.content.includes("debugging mode"), + (msg) => msg.role === "system" && getMessageText(msg.content).includes("debugging mode"), ); if (!hasDebuggingPrompt) { @@ -83,7 +84,7 @@ export const sendMessage = async ( // Inject code review system message before user message if not already present const hasReviewPrompt = state.messages.some( (msg) => - msg.role === "system" && msg.content.includes("code review mode"), + msg.role === "system" && getMessageText(msg.content).includes("code review mode"), ); if (!hasReviewPrompt) { @@ -109,7 +110,7 @@ export const sendMessage = async ( // Inject refactoring system message before user message if not already present const hasRefactoringPrompt = state.messages.some( (msg) => - msg.role === "system" && msg.content.includes("refactoring mode"), + msg.role === "system" && getMessageText(msg.content).includes("refactoring mode"), ); if (!hasRefactoringPrompt) { diff --git a/src/commands/components/execute/execute.tsx b/src/commands/components/execute/execute.tsx index 131796d..6f7466e 100644 --- a/src/commands/components/execute/execute.tsx +++ b/src/commands/components/execute/execute.tsx @@ -99,24 +99,32 @@ const parseArgs = (argsString: string): string[] | undefined => { }; const defaultHandleMCPAdd = async (data: MCPAddFormData): Promise => { - const serverArgs = parseArgs(data.args); + // Build config based on transport type + const config: Omit = + data.type === "stdio" + ? { + type: "stdio", + command: data.command!, + args: parseArgs(data.args ?? "") ?? undefined, + enabled: true, + } + : { + type: data.type, + url: data.url!, + enabled: true, + }; - await addServer( - data.name, - { - command: data.command, - args: serverArgs, - enabled: true, - }, - data.isGlobal, - ); + await addServer(data.name, config, data.isGlobal); - // Add to store with "connecting" status + const description = + data.type === "stdio" ? data.command! : data.url!; + + // Add to store with "disconnected" status appStore.addMcpServer({ id: data.name, name: data.name, status: "disconnected", - description: data.command, + description, }); try { diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index 33de913..8264971 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -77,11 +77,14 @@ const handleList = async (_args: string[]): Promise => { const enabled = server.enabled !== false ? chalk.green("✓") : chalk.gray("○"); console.log(` ${enabled} ${chalk.cyan(name)}`); - console.log( - ` Command: ${server.command} ${(server.args || []).join(" ")}`, - ); - if (server.transport && server.transport !== "stdio") { - console.log(` Transport: ${server.transport}`); + const t = server.type ?? "stdio"; + if (t === "stdio") { + console.log( + ` Command: ${server.command ?? ""} ${(server.args || []).join(" ")}`, + ); + } else { + console.log(` Type: ${t}`); + console.log(` URL: ${server.url ?? "(none)"}`); } console.log(); } @@ -95,13 +98,18 @@ const handleAdd = async (args: string[]): Promise => { if (!name) { errorMessage("Server name required"); infoMessage( - "Usage: codetyper mcp add --command [--args ]", + "Usage: codetyper mcp add --url [--type http|sse]", + ); + infoMessage( + " codetyper mcp add --command [--args ]", ); return; } // Parse options let command = ""; + let url = ""; + let type: "stdio" | "http" | "sse" = "stdio"; const serverArgs: string[] = []; let isGlobal = false; @@ -109,8 +117,12 @@ const handleAdd = async (args: string[]): Promise => { const arg = args[i]; if (arg === "--command" || arg === "-c") { command = args[++i] || ""; + } else if (arg === "--url" || arg === "-u") { + url = args[++i] || ""; + if (!type || type === "stdio") type = "http"; + } else if (arg === "--type" || arg === "-t") { + type = (args[++i] || "stdio") as "stdio" | "http" | "sse"; } else if (arg === "--args" || arg === "-a") { - // Collect remaining args while (args[i + 1] && !args[i + 1].startsWith("--")) { serverArgs.push(args[++i]); } @@ -119,26 +131,24 @@ const handleAdd = async (args: string[]): Promise => { } } - if (!command) { - // Interactive mode - ask for command - infoMessage("Adding MCP server interactively..."); - infoMessage("Example: npx @modelcontextprotocol/server-sqlite"); - - // For now, require command flag - errorMessage("Command required. Use --command "); + if (!command && !url) { + errorMessage( + "Either --command (stdio) or --url (http/sse) is required.", + ); return; } try { - await addServer( - name, - { - command, - args: serverArgs.length > 0 ? serverArgs : undefined, - enabled: true, - }, - isGlobal, - ); + const config: Omit = url + ? { type, url, enabled: true } + : { + type: "stdio", + command, + args: serverArgs.length > 0 ? serverArgs : undefined, + enabled: true, + }; + + await addServer(name, config, isGlobal); successMessage(`Added MCP server: ${name}`); infoMessage(`Connect with: codetyper mcp connect ${name}`); diff --git a/src/constants/brain.ts b/src/constants/brain.ts index aa3d7c6..9374446 100644 --- a/src/constants/brain.ts +++ b/src/constants/brain.ts @@ -1,14 +1,3 @@ -/** - * Brain API Constants - * - * Configuration constants for the CodeTyper Brain service - */ - -/** - * Feature flag to disable all Brain functionality. - * Set to true to hide Brain menu, disable Brain API calls, - * and remove Brain-related UI elements. - */ export const BRAIN_DISABLED = true; export const BRAIN_PROVIDER_NAME = "brain" as const; diff --git a/src/constants/copilot.ts b/src/constants/copilot.ts index 525a8d5..558a639 100644 --- a/src/constants/copilot.ts +++ b/src/constants/copilot.ts @@ -20,6 +20,21 @@ export const COPILOT_MODELS_CACHE_TTL = 5 * 60 * 1000; // 5 minutes export const COPILOT_MAX_RETRIES = 3; export const COPILOT_INITIAL_RETRY_DELAY = 1000; // 1 second +// Streaming timeout and connection retry +export const COPILOT_STREAM_TIMEOUT = 120_000; // 2 minutes +export const COPILOT_CONNECTION_RETRY_DELAY = 2000; // 2 seconds + +// Connection error patterns for retry logic +export const CONNECTION_ERROR_PATTERNS = [ + /socket.*closed/i, + /ECONNRESET/i, + /ECONNREFUSED/i, + /ETIMEDOUT/i, + /network.*error/i, + /fetch.*failed/i, + /aborted/i, +] as const; + // Default model export const COPILOT_DEFAULT_MODEL = "gpt-5-mini"; diff --git a/src/constants/keybinds.ts b/src/constants/keybinds.ts new file mode 100644 index 0000000..ce23b5c --- /dev/null +++ b/src/constants/keybinds.ts @@ -0,0 +1,180 @@ +/** + * Keybind Configuration + * + * Defines all configurable keybindings with defaults. + * Modeled after OpenCode's keybind system with leader key support, + * comma-separated alternatives, and `` prefix expansion. + * + * Format: "mod+key" or "mod+key,mod+key" for alternatives + * Special: "key" expands to "${leader}+key" + * "none" disables the binding + */ + +// ============================================================================ +// Keybind Action IDs +// ============================================================================ + +/** + * All possible keybind action identifiers. + * These map 1:1 to the defaults below. + */ +export type KeybindAction = + // Application + | "app_exit" + | "app_interrupt" + + // Session / execution + | "session_interrupt" + | "session_abort_rollback" + | "session_pause_resume" + | "session_step_toggle" + | "session_step_advance" + + // Navigation & scrolling + | "messages_page_up" + | "messages_page_down" + + // Mode switching + | "mode_toggle" + + // Input area + | "input_submit" + | "input_newline" + | "input_clear" + | "input_paste" + + // Menus & pickers + | "command_menu" + | "file_picker" + | "model_list" + | "theme_list" + | "agent_list" + | "help_menu" + + // Clipboard + | "clipboard_copy" + + // Sidebar / panels + | "sidebar_toggle" + | "activity_toggle"; + +// ============================================================================ +// Default Keybinds +// ============================================================================ + +/** + * Default leader key prefix (similar to vim leader or OpenCode's ctrl+x). + */ +export const DEFAULT_LEADER = "ctrl+x"; + +/** + * Default keybindings for all actions. + * Format: comma-separated list of key combos. + * - `ctrl+c` — modifier + key + * - `escape` — single key + * - `q` — leader prefix + key (expands to e.g. `ctrl+x,q`) + * - `none` — binding disabled + */ +export const DEFAULT_KEYBINDS: Readonly> = { + // Application + app_exit: "ctrl+c", + app_interrupt: "ctrl+c", + + // Session / execution control + session_interrupt: "escape", + session_abort_rollback: "ctrl+z", + session_pause_resume: "ctrl+p", + session_step_toggle: "ctrl+shift+s", + session_step_advance: "return", + + // Navigation + messages_page_up: "pageup", + messages_page_down: "pagedown", + + // Mode switching + mode_toggle: "ctrl+e", + + // Input area + input_submit: "return", + input_newline: "shift+return,ctrl+return", + input_clear: "ctrl+c", + input_paste: "ctrl+v", + + // Menus & pickers + command_menu: "/", + file_picker: "@", + model_list: "m", + theme_list: "t", + agent_list: "a", + help_menu: "h", + + // Clipboard + clipboard_copy: "ctrl+y", + + // Sidebar / panels + sidebar_toggle: "b", + activity_toggle: "s", +} as const; + +/** + * Descriptions for each keybind action (used in help menus) + */ +export const KEYBIND_DESCRIPTIONS: Readonly> = { + app_exit: "Exit the application", + app_interrupt: "Interrupt / abort current action", + session_interrupt: "Cancel current operation", + session_abort_rollback: "Abort with rollback", + session_pause_resume: "Toggle pause/resume execution", + session_step_toggle: "Toggle step-by-step mode", + session_step_advance: "Advance one step", + messages_page_up: "Scroll messages up by one page", + messages_page_down: "Scroll messages down by one page", + mode_toggle: "Toggle interaction mode (agent/ask/code-review)", + input_submit: "Submit input", + input_newline: "Insert newline in input", + input_clear: "Clear input field", + input_paste: "Paste from clipboard", + command_menu: "Open command menu", + file_picker: "Open file picker", + model_list: "List available models", + theme_list: "List available themes", + agent_list: "List agents", + help_menu: "Show help", + clipboard_copy: "Copy selection to clipboard", + sidebar_toggle: "Toggle sidebar panel", + activity_toggle: "Toggle activity/status panel", +} as const; + +/** + * Categories for grouping keybinds in the help menu + */ +export const KEYBIND_CATEGORIES: Readonly< + Record +> = { + Application: ["app_exit", "app_interrupt"], + "Execution Control": [ + "session_interrupt", + "session_abort_rollback", + "session_pause_resume", + "session_step_toggle", + "session_step_advance", + ], + Navigation: ["messages_page_up", "messages_page_down"], + "Mode & Input": [ + "mode_toggle", + "input_submit", + "input_newline", + "input_clear", + "input_paste", + ], + "Menus & Pickers": [ + "command_menu", + "file_picker", + "model_list", + "theme_list", + "agent_list", + "help_menu", + ], + Panels: ["sidebar_toggle", "activity_toggle"], + Clipboard: ["clipboard_copy"], +} as const; diff --git a/src/constants/skills.ts b/src/constants/skills.ts index 6d0b3b3..1ec0077 100644 --- a/src/constants/skills.ts +++ b/src/constants/skills.ts @@ -25,6 +25,36 @@ export const SKILL_DIRS = { PROJECT: ".codetyper/skills", } as const; +/** + * External agent directories (relative to project root) + * These directories may contain agent definition files from + * various tools (Claude, GitHub Copilot, CodeTyper, etc.) + */ +export const EXTERNAL_AGENT_DIRS = { + CLAUDE: ".claude", + GITHUB: ".github", + CODETYPER: ".codetyper", +} as const; + +/** + * Recognized external agent file patterns + */ +export const EXTERNAL_AGENT_FILES = { + /** File extensions recognized as agent definitions */ + EXTENSIONS: [".md", ".yaml", ".yml"], + /** Known agent file names (case-insensitive) */ + KNOWN_FILES: [ + "AGENTS.md", + "agents.md", + "AGENT.md", + "agent.md", + "SKILL.md", + "copilot-instructions.md", + ], + /** Subdirectories to scan for agents */ + SUBDIRS: ["agents", "skills", "prompts"], +} as const; + /** * Skill loading configuration */ @@ -89,8 +119,196 @@ export const BUILTIN_SKILLS = { REVIEW_PR: "review-pr", EXPLAIN: "explain", FEATURE_DEV: "feature-dev", + TYPESCRIPT: "typescript", + REACT: "react", + CSS_SCSS: "css-scss", + SECURITY: "security", + CODE_AUDIT: "code-audit", + RESEARCHER: "researcher", + TESTING: "testing", + PERFORMANCE: "performance", + API_DESIGN: "api-design", + DATABASE: "database", + DEVOPS: "devops", + ACCESSIBILITY: "accessibility", + DOCUMENTATION: "documentation", + REFACTORING: "refactoring", + GIT_WORKFLOW: "git-workflow", + NODE_BACKEND: "node-backend", } as const; +/** + * Skill auto-detection keyword map. + * Maps keywords found in user prompts to skill IDs. + * Each entry: [keyword, skillId, category, weight] + */ +export const SKILL_DETECTION_KEYWORDS: ReadonlyArray< + readonly [string, string, string, number] +> = [ + // TypeScript + ["typescript", "typescript", "language", 0.9], + ["type error", "typescript", "language", 0.85], + ["ts error", "typescript", "language", 0.85], + ["generics", "typescript", "language", 0.8], + ["type system", "typescript", "language", 0.85], + ["interface", "typescript", "language", 0.5], + ["type alias", "typescript", "language", 0.8], + [".ts", "typescript", "language", 0.4], + [".tsx", "typescript", "language", 0.5], + + // React + ["react", "react", "framework", 0.9], + ["component", "react", "framework", 0.5], + ["hooks", "react", "framework", 0.7], + ["usestate", "react", "framework", 0.9], + ["useeffect", "react", "framework", 0.9], + ["jsx", "react", "framework", 0.8], + ["tsx", "react", "framework", 0.7], + ["react component", "react", "framework", 0.95], + ["props", "react", "framework", 0.5], + ["useState", "react", "framework", 0.9], + + // CSS/SCSS + ["css", "css-scss", "styling", 0.8], + ["scss", "css-scss", "styling", 0.9], + ["sass", "css-scss", "styling", 0.9], + ["styling", "css-scss", "styling", 0.6], + ["flexbox", "css-scss", "styling", 0.9], + ["grid layout", "css-scss", "styling", 0.85], + ["responsive", "css-scss", "styling", 0.6], + ["animation", "css-scss", "styling", 0.5], + ["tailwind", "css-scss", "styling", 0.7], + + // Security + ["security", "security", "domain", 0.9], + ["vulnerability", "security", "domain", 0.95], + ["xss", "security", "domain", 0.95], + ["sql injection", "security", "domain", 0.95], + ["csrf", "security", "domain", 0.95], + ["authentication", "security", "domain", 0.6], + ["authorization", "security", "domain", 0.6], + ["owasp", "security", "domain", 0.95], + ["cve", "security", "domain", 0.9], + ["penetration", "security", "domain", 0.85], + + // Code Audit + ["audit", "code-audit", "domain", 0.85], + ["code quality", "code-audit", "domain", 0.9], + ["tech debt", "code-audit", "domain", 0.9], + ["dead code", "code-audit", "domain", 0.9], + ["complexity", "code-audit", "domain", 0.6], + ["code smell", "code-audit", "domain", 0.9], + ["code review", "code-audit", "domain", 0.5], + + // Research + ["research", "researcher", "workflow", 0.8], + ["find out", "researcher", "workflow", 0.5], + ["look up", "researcher", "workflow", 0.5], + ["documentation", "researcher", "workflow", 0.5], + ["best practice", "researcher", "workflow", 0.6], + ["compare", "researcher", "workflow", 0.4], + + // Testing + ["test", "testing", "workflow", 0.5], + ["testing", "testing", "workflow", 0.8], + ["unit test", "testing", "workflow", 0.9], + ["integration test", "testing", "workflow", 0.9], + ["e2e", "testing", "workflow", 0.85], + ["tdd", "testing", "workflow", 0.9], + ["jest", "testing", "workflow", 0.85], + ["vitest", "testing", "workflow", 0.9], + ["playwright", "testing", "workflow", 0.9], + ["coverage", "testing", "workflow", 0.6], + + // Performance + ["performance", "performance", "domain", 0.8], + ["optimization", "performance", "domain", 0.7], + ["optimize", "performance", "domain", 0.7], + ["slow", "performance", "domain", 0.5], + ["bundle size", "performance", "domain", 0.9], + ["memory leak", "performance", "domain", 0.9], + ["latency", "performance", "domain", 0.7], + ["profiling", "performance", "domain", 0.85], + + // API Design + ["api", "api-design", "domain", 0.5], + ["endpoint", "api-design", "domain", 0.6], + ["rest", "api-design", "domain", 0.7], + ["graphql", "api-design", "domain", 0.9], + ["openapi", "api-design", "domain", 0.9], + ["swagger", "api-design", "domain", 0.9], + + // Database + ["database", "database", "domain", 0.9], + ["sql", "database", "domain", 0.8], + ["query", "database", "domain", 0.4], + ["migration", "database", "domain", 0.7], + ["schema", "database", "domain", 0.7], + ["orm", "database", "domain", 0.85], + ["prisma", "database", "domain", 0.9], + ["drizzle", "database", "domain", 0.9], + ["postgres", "database", "domain", 0.9], + ["mysql", "database", "domain", 0.9], + ["mongodb", "database", "domain", 0.9], + + // DevOps + ["devops", "devops", "domain", 0.9], + ["docker", "devops", "domain", 0.9], + ["ci/cd", "devops", "domain", 0.9], + ["pipeline", "devops", "domain", 0.7], + ["deploy", "devops", "domain", 0.7], + ["kubernetes", "devops", "domain", 0.95], + ["k8s", "devops", "domain", 0.95], + ["github actions", "devops", "domain", 0.9], + + // Accessibility + ["accessibility", "accessibility", "domain", 0.95], + ["a11y", "accessibility", "domain", 0.95], + ["wcag", "accessibility", "domain", 0.95], + ["aria", "accessibility", "domain", 0.85], + ["screen reader", "accessibility", "domain", 0.9], + + // Documentation + ["documentation", "documentation", "workflow", 0.7], + ["readme", "documentation", "workflow", 0.8], + ["jsdoc", "documentation", "workflow", 0.9], + ["document this", "documentation", "workflow", 0.7], + + // Refactoring + ["refactor", "refactoring", "workflow", 0.9], + ["refactoring", "refactoring", "workflow", 0.9], + ["clean up", "refactoring", "workflow", 0.6], + ["restructure", "refactoring", "workflow", 0.7], + ["simplify", "refactoring", "workflow", 0.5], + ["solid principles", "refactoring", "workflow", 0.85], + ["design pattern", "refactoring", "workflow", 0.7], + + // Git + ["git", "git-workflow", "tool", 0.5], + ["branch", "git-workflow", "tool", 0.4], + ["merge conflict", "git-workflow", "tool", 0.9], + ["rebase", "git-workflow", "tool", 0.85], + ["cherry-pick", "git-workflow", "tool", 0.9], + + // Node.js Backend + ["express", "node-backend", "framework", 0.85], + ["fastify", "node-backend", "framework", 0.9], + ["middleware", "node-backend", "framework", 0.6], + ["api server", "node-backend", "framework", 0.8], + ["backend", "node-backend", "framework", 0.5], + ["server", "node-backend", "framework", 0.4], +] as const; + +/** + * Minimum confidence for auto-detection to trigger + */ +export const SKILL_AUTO_DETECT_THRESHOLD = 0.6; + +/** + * Maximum number of skills to auto-activate per prompt + */ +export const SKILL_AUTO_DETECT_MAX = 3; + /** * Skill trigger patterns for common commands */ diff --git a/src/constants/themes.ts b/src/constants/themes.ts index cb8bef2..e5a4968 100644 --- a/src/constants/themes.ts +++ b/src/constants/themes.ts @@ -790,6 +790,62 @@ const CARGDEV_CYBERPUNK_COLORS: ThemeColors = { headerGradient: ["#ff79c6", "#bd93f9", "#8be9fd"], }; +const PINK_PURPLE_COLORS: ThemeColors = { + primary: "#ff69b4", + secondary: "#b47ee5", + accent: "#e84393", + + success: "#a3e048", + error: "#ff4757", + warning: "#ffa502", + info: "#cf6fef", + + text: "#f5e6f0", + textDim: "#9a7aa0", + textMuted: "#4a3050", + + background: "#1a0a20", + backgroundPanel: "#120818", + backgroundElement: "#2a1535", + + border: "#3d1f4e", + borderFocus: "#ff69b4", + borderWarning: "#ffa502", + borderModal: "#b47ee5", + + bgHighlight: "#2a1535", + bgCursor: "#e84393", + bgAdded: "#a3e048", + bgRemoved: "#ff4757", + + diffAdded: "#a3e048", + diffRemoved: "#ff4757", + diffContext: "#9a7aa0", + diffHeader: "#f5e6f0", + diffHunk: "#cf6fef", + diffLineBgAdded: "#1a2d1a", + diffLineBgRemoved: "#2d1a1a", + diffLineText: "#f5e6f0", + + roleUser: "#ff69b4", + roleAssistant: "#b47ee5", + roleSystem: "#ffa502", + roleTool: "#cf6fef", + + modeIdle: "#b47ee5", + modeEditing: "#ff69b4", + modeThinking: "#e84393", + modeToolExecution: "#ffa502", + modePermission: "#cf6fef", + + toolPending: "#9a7aa0", + toolRunning: "#ffa502", + toolSuccess: "#a3e048", + toolError: "#ff4757", + + headerGradient: ["#ff69b4", "#e84393", "#b47ee5"], +}; + export const THEMES: Record = { default: { name: "default", @@ -861,6 +917,11 @@ export const THEMES: Record = { displayName: "Cargdev Cyberpunk", colors: CARGDEV_CYBERPUNK_COLORS, }, + "pink-purple": { + name: "pink-purple", + displayName: "Pink Purple", + colors: PINK_PURPLE_COLORS, + }, }; export const THEME_NAMES = Object.keys(THEMES); diff --git a/src/constants/tools.ts b/src/constants/tools.ts index 5901605..edc9b62 100644 --- a/src/constants/tools.ts +++ b/src/constants/tools.ts @@ -13,6 +13,12 @@ export type SchemaSkipKey = (typeof SCHEMA_SKIP_KEYS)[number]; export const TOOL_NAMES = ["read", "glob", "grep"]; /** - * Tools that can modify files + * Tools that can modify files — used for tracking modified files in the TUI */ -export const FILE_MODIFYING_TOOLS = ["write", "edit"] as const; +export const FILE_MODIFYING_TOOLS = [ + "write", + "edit", + "multi_edit", + "apply_patch", + "bash", +] as const; diff --git a/src/interfaces/AgentResult.ts b/src/interfaces/AgentResult.ts index 5db3207..40dab98 100644 --- a/src/interfaces/AgentResult.ts +++ b/src/interfaces/AgentResult.ts @@ -4,9 +4,21 @@ import type { ToolCall, ToolResult } from "@/types/tools"; +/** Why the agent loop stopped */ +export type AgentStopReason = + | "completed" // LLM returned a final response (no more tool calls) + | "max_iterations" // Hit the iteration limit + | "consecutive_errors" // Repeated tool failures + | "aborted" // User abort + | "error" // Unrecoverable error + | "plan_approval" // Stopped to request plan approval + ; + export interface AgentResult { success: boolean; finalResponse: string; iterations: number; toolCalls: { call: ToolCall; result: ToolResult }[]; + /** Why the agent stopped — helps the user understand what happened */ + stopReason?: AgentStopReason; } diff --git a/src/interfaces/StreamCallbacksWithState.ts b/src/interfaces/StreamCallbacksWithState.ts index 31d5313..493285f 100644 --- a/src/interfaces/StreamCallbacksWithState.ts +++ b/src/interfaces/StreamCallbacksWithState.ts @@ -7,4 +7,5 @@ import type { StreamCallbacks } from "@/types/streaming"; export interface StreamCallbacksWithState { callbacks: StreamCallbacks; hasReceivedContent: () => boolean; + hasReceivedUsage: () => boolean; } diff --git a/src/interfaces/api/copilot/core.ts b/src/interfaces/api/copilot/core.ts new file mode 100644 index 0000000..8a85ebd --- /dev/null +++ b/src/interfaces/api/copilot/core.ts @@ -0,0 +1,36 @@ +import type { + Message, + MessageContent, + ChatCompletionOptions, + ChatCompletionResponse, +} from "@/types/providers"; + +export interface FormattedMessage { + role: string; + content: MessageContent; + tool_call_id?: string; + tool_calls?: Message["tool_calls"]; +} + +export interface ChatRequestBody { + model: string; + messages: FormattedMessage[]; + max_tokens: number; + temperature: number; + stream: boolean; + tools?: ChatCompletionOptions["tools"]; + tool_choice?: string; +} + +export interface ChatApiResponse { + error?: { message?: string }; + choices?: Array<{ + message?: { content?: string; tool_calls?: Message["tool_calls"] }; + finish_reason?: ChatCompletionResponse["finishReason"]; + }>; + usage?: { + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; + }; +} diff --git a/src/prompts/system/agent.ts b/src/prompts/system/agent.ts index 67c3799..1c64ce5 100644 --- a/src/prompts/system/agent.ts +++ b/src/prompts/system/agent.ts @@ -20,16 +20,6 @@ Before using ANY tools, think through: - What files might be involved? - What's my initial approach? -Output your thinking in a block: -\`\`\` - -Task: [what the user wants] -Need to find: [what information I need] -Likely files: [patterns to search for] -Approach: [my plan] - -\`\`\` - ## Step 2: EXPLORE - Gather Context Use tools to understand the codebase: - **glob** - Find relevant files by pattern diff --git a/src/providers/copilot/core/chat.ts b/src/providers/copilot/core/chat.ts index b64d649..4316443 100644 --- a/src/providers/copilot/core/chat.ts +++ b/src/providers/copilot/core/chat.ts @@ -7,6 +7,8 @@ import got from "got"; import { COPILOT_MAX_RETRIES, COPILOT_UNLIMITED_MODEL, + COPILOT_STREAM_TIMEOUT, + COPILOT_CONNECTION_RETRY_DELAY, } from "@constants/copilot"; import { refreshToken, buildHeaders } from "@providers/copilot/auth/token"; import { @@ -16,12 +18,14 @@ import { import { sleep, isRateLimitError, + isConnectionError, getRetryDelay, isQuotaExceededError, } from "@providers/copilot/utils"; import type { CopilotToken } from "@/types/copilot"; import type { Message, + MessageContent, ChatCompletionOptions, ChatCompletionResponse, StreamChunk, @@ -30,7 +34,7 @@ import { addDebugLog } from "@tui-solid/components/logs/debug-log-panel"; interface FormattedMessage { role: string; - content: string; + content: MessageContent; tool_call_id?: string; tool_calls?: Message["tool_calls"]; } @@ -39,7 +43,7 @@ const formatMessages = (messages: Message[]): FormattedMessage[] => messages.map((msg) => { const formatted: FormattedMessage = { role: msg.role, - content: msg.content, + content: msg.content, // Already string or ContentPart[] — pass through }; if (msg.tool_call_id) { @@ -61,6 +65,8 @@ interface ChatRequestBody { stream: boolean; tools?: ChatCompletionOptions["tools"]; tool_choice?: string; + /** Request usage data in stream responses (OpenAI-compatible) */ + stream_options?: { include_usage: boolean }; } // Default max tokens for requests without tools @@ -95,6 +101,11 @@ const buildRequestBody = ( stream, }; + // Request usage data when streaming + if (stream) { + body.stream_options = { include_usage: true }; + } + if (hasTools) { body.tools = options.tools; body.tool_choice = "auto"; @@ -255,6 +266,16 @@ const processStreamLine = ( } } + // Capture usage data (OpenAI sends it in the final chunk before [DONE]) + if (parsed.usage) { + const promptTokens = parsed.usage.prompt_tokens ?? 0; + const completionTokens = parsed.usage.completion_tokens ?? 0; + onChunk({ + type: "usage", + usage: { promptTokens, completionTokens }, + }); + } + // Handle truncation: if finish_reason is "length", content was cut off if (finishReason === "length") { addDebugLog("api", "Stream truncated due to max_tokens limit"); @@ -270,23 +291,43 @@ const processStreamLine = ( return false; }; -const executeStream = ( +const executeStream = async ( endpoint: string, token: CopilotToken, body: ChatRequestBody, onChunk: (chunk: StreamChunk) => void, -): Promise => - new Promise((resolve, reject) => { - const stream = got.stream.post(endpoint, { - headers: buildHeaders(token), - json: body, - }); +): Promise => { + const response = await fetch(endpoint, { + method: "POST", + headers: { + ...buildHeaders(token), + Accept: "text/event-stream", + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(COPILOT_STREAM_TIMEOUT), + }); - let buffer = ""; - let doneReceived = false; + if (!response.ok) { + throw new Error( + `Copilot API error: ${response.status} ${response.statusText}`, + ); + } - stream.on("data", (data: Buffer) => { - buffer += data.toString(); + if (!response.body) { + throw new Error("No response body from Copilot stream"); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let doneReceived = false; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() ?? ""; @@ -296,31 +337,24 @@ const executeStream = ( return; } } - }); + } + } finally { + reader.releaseLock(); + } - stream.on("error", (error: Error) => { - onChunk({ type: "error", error: error.message }); - reject(error); - }); + // Process remaining buffer + if (buffer.trim()) { + processStreamLine(buffer, onChunk); + } - stream.on("end", () => { - // Process any remaining data in buffer that didn't have trailing newline - if (buffer.trim()) { - processStreamLine(buffer, onChunk); - } - - // Ensure done is sent even if stream ended without [DONE] message - if (!doneReceived) { - addDebugLog( - "api", - "Stream ended without [DONE] message, sending done chunk", - ); - onChunk({ type: "done" }); - } - - resolve(); - }); - }); + if (!doneReceived) { + addDebugLog( + "api", + "Stream ended without [DONE] message, sending done chunk", + ); + onChunk({ type: "done" }); + } +}; export const chatStream = async ( messages: Message[], @@ -371,6 +405,16 @@ export const chatStream = async ( continue; } + if (isConnectionError(error) && attempt < COPILOT_MAX_RETRIES - 1) { + const delay = COPILOT_CONNECTION_RETRY_DELAY * Math.pow(2, attempt); + addDebugLog( + "api", + `Connection error, retrying in ${delay}ms (attempt ${attempt + 1})`, + ); + await sleep(delay); + continue; + } + if (isRateLimitError(error) && attempt < COPILOT_MAX_RETRIES - 1) { const delay = getRetryDelay(error, attempt); await sleep(delay); diff --git a/src/providers/copilot/utils.ts b/src/providers/copilot/utils.ts index a14f03c..b28302b 100644 --- a/src/providers/copilot/utils.ts +++ b/src/providers/copilot/utils.ts @@ -2,11 +2,19 @@ * Copilot provider utility functions */ -import { COPILOT_INITIAL_RETRY_DELAY } from "@constants/copilot"; +import { + COPILOT_INITIAL_RETRY_DELAY, + CONNECTION_ERROR_PATTERNS, +} from "@constants/copilot"; export const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); +export const isConnectionError = (error: unknown): boolean => { + const message = error instanceof Error ? error.message : String(error); + return CONNECTION_ERROR_PATTERNS.some((pattern) => pattern.test(message)); +}; + export const isRateLimitError = (error: unknown): boolean => { if (error && typeof error === "object" && "response" in error) { const response = (error as { response?: { statusCode?: number } }).response; diff --git a/src/providers/ollama/core/chat.ts b/src/providers/ollama/core/chat.ts index efc158d..e192da7 100644 --- a/src/providers/ollama/core/chat.ts +++ b/src/providers/ollama/core/chat.ts @@ -16,6 +16,7 @@ import type { ChatCompletionOptions, ChatCompletionResponse, ToolCall, + ContentPart, } from "@/types/providers"; import type { OllamaChatRequest, @@ -25,17 +26,53 @@ import type { OllamaMessage, } from "@/types/ollama"; +/** + * Extract text and images from multimodal content. + * Ollama uses a separate `images` array of base64 strings rather than + * inline content parts. + */ +const extractContentParts = ( + content: string | ContentPart[], +): { text: string; images: string[] } => { + if (typeof content === "string") { + return { text: content, images: [] }; + } + + const textParts: string[] = []; + const images: string[] = []; + + for (const part of content) { + if (part.type === "text") { + textParts.push(part.text); + } else if (part.type === "image_url") { + // Strip the data:image/xxx;base64, prefix if present + const url = part.image_url.url; + const base64Match = url.match(/^data:[^;]+;base64,(.+)$/); + images.push(base64Match ? base64Match[1] : url); + } + } + + return { text: textParts.join("\n"), images }; +}; + /** * Format messages for Ollama API - * Handles regular messages, assistant messages with tool_calls, and tool response messages + * Handles regular messages, assistant messages with tool_calls, and tool response messages. + * Multimodal content (images) is converted to Ollama's `images` array format. */ const formatMessages = (messages: Message[]): OllamaMessage[] => messages.map((msg) => { + const { text, images } = extractContentParts(msg.content); + const formatted: OllamaMessage = { role: msg.role, - content: msg.content, + content: text, }; + if (images.length > 0) { + formatted.images = images; + } + // Include tool_calls for assistant messages that made tool calls if (msg.tool_calls && msg.tool_calls.length > 0) { formatted.tool_calls = msg.tool_calls.map((tc) => ({ diff --git a/src/providers/ollama/stream.ts b/src/providers/ollama/stream.ts index 2514987..098e8fd 100644 --- a/src/providers/ollama/stream.ts +++ b/src/providers/ollama/stream.ts @@ -2,8 +2,6 @@ * Ollama provider streaming */ -import got from "got"; - import { OLLAMA_ENDPOINTS, OLLAMA_TIMEOUTS } from "@constants/ollama"; import { getOllamaBaseUrl } from "@providers/ollama/state"; import { buildChatRequest, mapToolCall } from "@providers/ollama/core/chat"; @@ -37,6 +35,17 @@ const parseStreamLine = ( } } + // Capture token usage from Ollama response (sent with done=true) + if (parsed.done && (parsed.prompt_eval_count || parsed.eval_count)) { + onChunk({ + type: "usage", + usage: { + promptTokens: parsed.prompt_eval_count ?? 0, + completionTokens: parsed.eval_count ?? 0, + }, + }); + } + if (parsed.done) { onChunk({ type: "done" }); } @@ -45,22 +54,6 @@ const parseStreamLine = ( } }; -const processStreamData = ( - data: Buffer, - buffer: string, - onChunk: (chunk: StreamChunk) => void, -): string => { - const combined = buffer + data.toString(); - const lines = combined.split("\n"); - const remaining = lines.pop() || ""; - - for (const line of lines) { - parseStreamLine(line, onChunk); - } - - return remaining; -}; - export const ollamaChatStream = async ( messages: Message[], options: ChatCompletionOptions | undefined, @@ -73,50 +66,65 @@ export const ollamaChatStream = async ( `Ollama stream request: ${messages.length} msgs, model=${body.model}`, ); - const stream = got.stream.post(`${baseUrl}${OLLAMA_ENDPOINTS.CHAT}`, { - json: body, - timeout: { request: OLLAMA_TIMEOUTS.CHAT }, + const response = await fetch(`${baseUrl}${OLLAMA_ENDPOINTS.CHAT}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(OLLAMA_TIMEOUTS.CHAT), }); + if (!response.ok) { + throw new Error( + `Ollama API error: ${response.status} ${response.statusText}`, + ); + } + + if (!response.body) { + throw new Error("No response body from Ollama stream"); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); let buffer = ""; let doneReceived = false; - stream.on("data", (data: Buffer) => { - buffer = processStreamData(data, buffer, (chunk) => { - if (chunk.type === "done") { - doneReceived = true; - } - onChunk(chunk); - }); - }); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; - stream.on("error", (error: Error) => { - onChunk({ type: "error", error: error.message }); - }); + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; - return new Promise((resolve, reject) => { - stream.on("end", () => { - // Process any remaining data in buffer that didn't have trailing newline - if (buffer.trim()) { - parseStreamLine(buffer, (chunk) => { + for (const line of lines) { + parseStreamLine(line, (chunk) => { if (chunk.type === "done") { doneReceived = true; } onChunk(chunk); }); } + } + } finally { + reader.releaseLock(); + } - // Ensure done is sent even if stream ended without done message - if (!doneReceived) { - addDebugLog( - "api", - "Ollama stream ended without done, sending done chunk", - ); - onChunk({ type: "done" }); + // Process remaining buffer + if (buffer.trim()) { + parseStreamLine(buffer, (chunk) => { + if (chunk.type === "done") { + doneReceived = true; } - - resolve(); + onChunk(chunk); }); - stream.on("error", reject); - }); + } + + if (!doneReceived) { + addDebugLog( + "api", + "Ollama stream ended without done, sending done chunk", + ); + onChunk({ type: "done" }); + } }; diff --git a/src/services/agent-stream.ts b/src/services/agent-stream.ts index e24ee5f..8630185 100644 --- a/src/services/agent-stream.ts +++ b/src/services/agent-stream.ts @@ -195,6 +195,12 @@ const processStreamChunk = ( } }, + usage: () => { + if (chunk.usage) { + callbacks.onUsage?.(chunk.usage); + } + }, + done: () => { // Finalize all pending tool calls for (const partial of accumulator.toolCalls.values()) { @@ -657,6 +663,7 @@ export const runAgentLoopStream = async ( finalResponse: "Execution aborted by user", iterations, toolCalls: allToolCalls, + stopReason: "aborted", }; } @@ -726,13 +733,14 @@ export const runAgentLoopStream = async ( if (allFailed) { consecutiveErrors++; if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { - const errorMsg = `Stopping: ${consecutiveErrors} consecutive tool errors. Check model compatibility with tool calling.`; + const errorMsg = `Stopping after ${consecutiveErrors} consecutive tool errors. Check model compatibility with tool calling.`; state.options.onError?.(errorMsg); return { success: false, finalResponse: errorMsg, iterations, toolCalls: allToolCalls, + stopReason: "consecutive_errors", }; } } @@ -751,12 +759,18 @@ export const runAgentLoopStream = async ( finalResponse: `Error: ${errorMessage}`, iterations, toolCalls: allToolCalls, + stopReason: "error", }; } } - if (iterations >= maxIterations) { - state.options.onWarning?.(`Reached max iterations (${maxIterations})`); + const hitMaxIterations = iterations >= maxIterations; + + if (hitMaxIterations) { + const warnMsg = `Agent reached max iterations (${maxIterations}). ` + + `Completed ${allToolCalls.length} tool call(s) across ${iterations} iteration(s). ` + + `The task may be incomplete — you can send another message to continue.`; + state.options.onWarning?.(warnMsg); } return { @@ -764,6 +778,7 @@ export const runAgentLoopStream = async ( finalResponse, iterations, toolCalls: allToolCalls, + stopReason: hitMaxIterations ? "max_iterations" : "completed", }; }; diff --git a/src/services/chat-tui/commands.ts b/src/services/chat-tui/commands.ts index 235a67d..a2d6ffb 100644 --- a/src/services/chat-tui/commands.ts +++ b/src/services/chat-tui/commands.ts @@ -4,6 +4,7 @@ import { saveSession as saveSessionSession } from "@services/core/session"; import { appStore } from "@tui-solid/context/app"; +import { getMessageText } from "@/types/providers"; import { CHAT_MESSAGES, type CommandName } from "@constants/chat-service"; import { handleLogin, handleLogout, showWhoami } from "@services/chat-tui/auth"; import { @@ -44,7 +45,7 @@ const saveSession: CommandHandler = async (_, callbacks) => { const showContext: CommandHandler = (state, callbacks) => { const tokenEstimate = state.messages.reduce( - (sum, msg) => sum + Math.ceil(msg.content.length / 4), + (sum, msg) => sum + Math.ceil(getMessageText(msg.content).length / 4), 0, ); callbacks.onLog( diff --git a/src/services/chat-tui/initialize.ts b/src/services/chat-tui/initialize.ts index a5fe7e4..d6817f0 100644 --- a/src/services/chat-tui/initialize.ts +++ b/src/services/chat-tui/initialize.ts @@ -19,6 +19,8 @@ import { buildCompletePrompt, } from "@services/prompt-builder"; import { initSuggestionService } from "@services/command-suggestion-service"; +import { initializeRegistry as initializeSkillRegistry } from "@services/skill-registry"; +import { initializeKeybinds } from "@services/keybind-resolver"; import * as brainService from "@services/brain"; import { BRAIN_DISABLED } from "@constants/brain"; import { addContextFile } from "@services/chat-tui/files"; @@ -27,6 +29,8 @@ import type { ChatSession } from "@/types/common"; import type { ChatTUIOptions } from "@interfaces/ChatTUIOptions"; import type { ChatServiceState } from "@/types/chat-service"; import type { InteractionMode } from "@/types/tui"; +import { getModelContextSize } from "@constants/copilot"; +import { getDefaultModel } from "@providers/core/chat"; const createInitialState = async ( options: ChatTUIOptions, @@ -223,6 +227,10 @@ export const initializeChatService = async ( const session = await initializeSession(state, options); + // Set context max tokens based on the resolved provider + model + const effectiveModel = state.model ?? getDefaultModel(state.provider); + appStore.setContextMaxTokens(getModelContextSize(effectiveModel).input); + if (state.messages.length === 0) { state.messages.push({ role: "system", content: state.systemPrompt }); } @@ -231,6 +239,8 @@ export const initializeChatService = async ( await Promise.all([ addInitialContextFiles(state, options.files), initializePermissions(), + initializeSkillRegistry(), + initializeKeybinds(), ]); initSuggestionService(process.cwd()); diff --git a/src/services/chat-tui/learnings.ts b/src/services/chat-tui/learnings.ts index 733c2f5..357a023 100644 --- a/src/services/chat-tui/learnings.ts +++ b/src/services/chat-tui/learnings.ts @@ -7,6 +7,7 @@ import { LEARNING_CONFIDENCE_THRESHOLD, MAX_LEARNINGS_DISPLAY, } from "@constants/chat-service"; +import { getMessageText } from "@/types/providers"; import { detectLearnings, saveLearning, @@ -35,8 +36,8 @@ export const handleRememberCommand = async ( } const candidates = detectLearnings( - lastUserMsg.content, - lastAssistantMsg.content, + getMessageText(lastUserMsg.content), + getMessageText(lastAssistantMsg.content), ); if (candidates.length === 0) { diff --git a/src/services/chat-tui/message-handler.ts b/src/services/chat-tui/message-handler.ts index b632f43..8dbb7c1 100644 --- a/src/services/chat-tui/message-handler.ts +++ b/src/services/chat-tui/message-handler.ts @@ -2,6 +2,7 @@ * Chat TUI message handling */ +import { v4 as uuidv4 } from "uuid"; import { addMessage, saveSession } from "@services/core/session"; import { createStreamingAgent, @@ -56,6 +57,7 @@ import { PROVIDER_IDS } from "@constants/provider-quality"; import { appStore } from "@tui-solid/context/app"; import type { StreamCallbacks } from "@/types/streaming"; import type { TaskType } from "@/types/provider-quality"; +import type { ContentPart, MessageContent } from "@/types/providers"; import type { ChatServiceState, ChatServiceCallbacks, @@ -69,6 +71,12 @@ import { executeDetectedCommand, } from "@services/command-detection"; import { detectSkillCommand, executeSkill } from "@services/skill-service"; +import { + buildSkillInjectionForPrompt, + getDetectedSkillsSummary, +} from "@services/skill-registry"; +import { stripMarkdown } from "@/utils/markdown/strip"; +import { createThinkingParser } from "@services/reasoning/thinking-parser"; import { getActivePlans, isApprovalMessage, @@ -105,7 +113,9 @@ export const abortCurrentOperation = async ( appStore.setMode("idle"); addDebugLog( "state", - rollback ? "Operation aborted with rollback" : "Operation aborted by user", + rollback + ? "Operation aborted with rollback" + : "Operation aborted by user", ); return true; } @@ -213,6 +223,48 @@ export const getExecutionState = (): { }; }; +/** + * Extract file path(s) from a tool call's arguments. + * + * Different tools store the path in different places: + * - write / edit / delete : `args.filePath` or `args.path` + * - multi_edit : `args.edits[].file_path` + * - apply_patch : `args.targetFile` (or parsed from patch header) + * - bash : no reliable path, skip + */ +const extractToolPaths = ( + toolName: string, + args?: Record, +): { primary?: string; all: string[] } => { + if (!args) return { all: [] }; + + // Standard single-file tools + const singlePath = + (args.filePath as string) ?? + (args.file_path as string) ?? + (args.path as string); + + if (singlePath && toolName !== "multi_edit") { + return { primary: String(singlePath), all: [String(singlePath)] }; + } + + // multi_edit: array of edits with file_path + if (toolName === "multi_edit" && Array.isArray(args.edits)) { + const paths = (args.edits as Array<{ file_path?: string }>) + .map((e) => e.file_path) + .filter((p): p is string => Boolean(p)); + const unique = [...new Set(paths)]; + return { primary: unique[0], all: unique }; + } + + // apply_patch: targetFile override or embedded in patch content + if (toolName === "apply_patch" && args.targetFile) { + return { primary: String(args.targetFile), all: [String(args.targetFile)] }; + } + + return { all: [] }; +}; + const createToolCallHandler = ( callbacks: ChatServiceCallbacks, @@ -220,11 +272,13 @@ const createToolCallHandler = ) => (call: { id: string; name: string; arguments?: Record }) => { const args = call.arguments; - if ( - (FILE_MODIFYING_TOOLS as readonly string[]).includes(call.name) && - args?.path - ) { - toolCallRef.current = { name: call.name, path: String(args.path) }; + const isModifying = (FILE_MODIFYING_TOOLS as readonly string[]).includes( + call.name, + ); + + if (isModifying) { + const { primary, all } = extractToolPaths(call.name, args); + toolCallRef.current = { name: call.name, path: primary, paths: all }; } else { toolCallRef.current = { name: call.name }; } @@ -238,6 +292,28 @@ const createToolCallHandler = }); }; +/** + * Estimate additions/deletions from tool output text + */ +const estimateChanges = ( + output: string, +): { additions: number; deletions: number } => { + let additions = 0; + let deletions = 0; + + for (const line of output.split("\n")) { + if (line.startsWith("+") && !line.startsWith("+++")) additions++; + else if (line.startsWith("-") && !line.startsWith("---")) deletions++; + } + + // Fallback estimate when no diff markers are found + if (additions === 0 && deletions === 0 && output.length > 0) { + additions = output.split("\n").length; + } + + return { additions, deletions }; +}; + const createToolResultHandler = ( callbacks: ChatServiceCallbacks, @@ -252,8 +328,33 @@ const createToolResultHandler = error?: string; }, ) => { - if (result.success && toolCallRef.current?.path) { - analyzeFileChange(toolCallRef.current.path); + const ref = toolCallRef.current; + + if (result.success && ref) { + const output = result.output ?? ""; + const paths = ref.paths?.length ? ref.paths : ref.path ? [ref.path] : []; + + if (paths.length > 0) { + const { additions, deletions } = estimateChanges(output); + + // Distribute changes across paths (or assign all to the single path) + const perFile = paths.length > 1 + ? { + additions: Math.max(1, Math.ceil(additions / paths.length)), + deletions: Math.ceil(deletions / paths.length), + } + : { additions, deletions }; + + for (const filePath of paths) { + analyzeFileChange(filePath); + appStore.addModifiedFile({ + filePath, + additions: perFile.additions, + deletions: perFile.deletions, + lastModified: Date.now(), + }); + } + } } callbacks.onToolResult( @@ -270,6 +371,34 @@ const createToolResultHandler = */ const createStreamCallbacks = (): StreamCallbacksWithState => { let chunkCount = 0; + let currentSegmentHasContent = false; + let receivedUsage = false; + const thinkingParser = createThinkingParser(); + + const emitThinking = (thinking: string | null): void => { + if (!thinking) return; + appStore.addLog({ type: "thinking", content: thinking }); + }; + + /** + * Finalize the current streaming segment (if it has content) so that + * tool logs appear below the pre-tool text and a new streaming segment + * can be started afterward for post-tool text (e.g. summary). + */ + const finalizeCurrentSegment = (): void => { + if (!currentSegmentHasContent) return; + + // Flush thinking parser before finalizing the segment + const flushed = thinkingParser.flush(); + if (flushed.visible) { + appStore.appendStreamContent(flushed.visible); + } + emitThinking(flushed.thinking); + + appStore.completeStreaming(); + currentSegmentHasContent = false; + addDebugLog("stream", "Finalized streaming segment before tool call"); + }; const callbacks: StreamCallbacks = { onContentChunk: (content: string) => { @@ -278,11 +407,30 @@ const createStreamCallbacks = (): StreamCallbacksWithState => { "stream", `Chunk #${chunkCount}: "${content.substring(0, 30)}${content.length > 30 ? "..." : ""}"`, ); - appStore.appendStreamContent(content); + + // Feed through the thinking parser — only append visible content. + // blocks are stripped and emitted separately. + const result = thinkingParser.feed(content); + if (result.visible) { + // If the previous streaming segment was finalized (e.g. before a tool call), + // start a new one so post-tool text appears after tool output logs. + if (!currentSegmentHasContent && !appStore.getState().streamingLog.isStreaming) { + appStore.startStreaming(); + addDebugLog("stream", "Started new streaming segment for post-tool content"); + } + appStore.appendStreamContent(result.visible); + currentSegmentHasContent = true; + } + emitThinking(result.thinking); }, onToolCallStart: (toolCall) => { addDebugLog("tool", `Tool start: ${toolCall.name} (${toolCall.id})`); + + // Finalize accumulated streaming text so it stays above tool output + // and the post-tool summary will appear below. + finalizeCurrentSegment(); + appStore.setCurrentToolCall({ id: toolCall.id, name: toolCall.name, @@ -308,7 +456,28 @@ const createStreamCallbacks = (): StreamCallbacksWithState => { }); }, + onUsage: (usage) => { + receivedUsage = true; + addDebugLog( + "api", + `Token usage: prompt=${usage.promptTokens}, completion=${usage.completionTokens}`, + ); + appStore.addTokens(usage.promptTokens, usage.completionTokens); + }, + onComplete: () => { + // Flush any remaining buffered content from the thinking parser + const flushed = thinkingParser.flush(); + if (flushed.visible) { + // Ensure a streaming log exists if we're flushing post-tool content + if (!currentSegmentHasContent && !appStore.getState().streamingLog.isStreaming) { + appStore.startStreaming(); + } + appStore.appendStreamContent(flushed.visible); + currentSegmentHasContent = true; + } + emitThinking(flushed.thinking); + // Note: Don't call completeStreaming() here! // The agent loop may have multiple iterations (tool calls + final response) // Streaming will be completed manually after the entire agent finishes @@ -320,6 +489,7 @@ const createStreamCallbacks = (): StreamCallbacksWithState => { onError: (error: string) => { addDebugLog("error", `Stream error: ${error}`); + thinkingParser.reset(); appStore.cancelStreaming(); appStore.addLog({ type: "error", @@ -331,6 +501,7 @@ const createStreamCallbacks = (): StreamCallbacksWithState => { return { callbacks, hasReceivedContent: () => chunkCount > 0, + hasReceivedUsage: () => receivedUsage, }; }; @@ -426,7 +597,10 @@ export const handleMessage = async ( if (isApprovalMessage(message)) { approvePlan(plan.id, message); startPlanExecution(plan.id); - callbacks.onLog("system", `Plan "${plan.title}" approved. Proceeding with implementation.`); + callbacks.onLog( + "system", + `Plan "${plan.title}" approved. Proceeding with implementation.`, + ); addDebugLog("state", `Plan ${plan.id} approved by user`); // Continue with agent execution - the agent will see the approved status @@ -438,7 +612,10 @@ export const handleMessage = async ( // Fall through to normal agent processing } else if (isRejectionMessage(message)) { rejectPlan(plan.id, message); - callbacks.onLog("system", `Plan "${plan.title}" rejected. Please provide feedback or a new approach.`); + callbacks.onLog( + "system", + `Plan "${plan.title}" rejected. Please provide feedback or a new approach.`, + ); addDebugLog("state", `Plan ${plan.id} rejected by user`); // Add rejection to messages so agent can respond @@ -449,7 +626,10 @@ export const handleMessage = async ( // Fall through to normal agent processing to get revised plan } else { // Neither approval nor rejection - treat as feedback/modification request - callbacks.onLog("system", `Plan "${plan.title}" awaiting approval. Reply 'yes' to approve or 'no' to reject.`); + callbacks.onLog( + "system", + `Plan "${plan.title}" awaiting approval. Reply 'yes' to approve or 'no' to reject.`, + ); // Show the plan again with the feedback const planDisplay = formatPlanForDisplay(plan); @@ -611,6 +791,90 @@ export const handleMessage = async ( const { enrichedMessage, issues } = await enrichMessageWithIssues(processedMessage); + // Inline @mention subagent invocation (e.g. "Find all API endpoints @explore") + try { + const mentionRegex = /@([a-zA-Z_]+)/g; + const mentionMap: Record = { + explore: "explore", + general: "implement", + plan: "plan", + }; + + const mentions: string[] = []; + let m: RegExpExecArray | null; + while ((m = mentionRegex.exec(message))) { + const key = m[1]?.toLowerCase(); + if (key && mentionMap[key]) mentions.push(key); + } + + if (mentions.length > 0) { + // Clean message to use as task prompt (remove mentions) + const cleaned = enrichedMessage.replace(/@[a-zA-Z_]+/g, "").trim(); + + // Lazy import task agent helpers (avoid circular deps) + const { executeTaskAgent, getBackgroundAgentStatus } = + await import("@/tools/task-agent/execute"); + const { v4: uuidv4 } = await import("uuid"); + + // Minimal tool context for invoking the task agent + const toolCtx = { + sessionId: uuidv4(), + messageId: uuidv4(), + workingDir: process.cwd(), + abort: new AbortController(), + autoApprove: true, + onMetadata: () => {}, + } as any; + + for (const key of mentions) { + const agentType = mentionMap[key]; + try { + const params = { + agent_type: agentType, + task: cleaned || message, + run_in_background: true, + } as any; + + const startResult = await executeTaskAgent(params, toolCtx); + + // Show started message in UI + appStore.addLog({ + type: "system", + content: `Started subagent @${key} (ID: ${startResult.metadata?.agentId ?? "?"}).`, + }); + + // Poll briefly for completion and attach result if ready + const agentId = startResult.metadata?.agentId as string | undefined; + if (agentId) { + const maxAttempts = 10; + const interval = 300; + for (let i = 0; i < maxAttempts; i++) { + // eslint-disable-next-line no-await-in-loop + const status = await getBackgroundAgentStatus(agentId); + if (status && status.success && status.output) { + // Attach assistant result to conversation + appStore.addLog({ type: "assistant", content: status.output }); + addMessage("assistant", status.output); + await saveSession(); + break; + } + // eslint-disable-next-line no-await-in-loop + await new Promise((res) => setTimeout(res, interval)); + } + } + } catch (err) { + appStore.addLog({ + type: "error", + content: `Subagent @${key} failed to start: ${String(err)}`, + }); + } + } + } + } catch (err) { + // Non-fatal - don't block main flow on subagent helpers + addDebugLog("error", `Subagent invocation error: ${String(err)}`); + } + if (issues.length > 0) { callbacks.onLog( "system", @@ -623,7 +887,35 @@ export const handleMessage = async ( const userMessage = buildContextMessage(state, enrichedMessage); - state.messages.push({ role: "user", content: userMessage }); + // Build multimodal content if there are pasted images + const { pastedImages } = appStore.getState(); + let messageContent: MessageContent = userMessage; + + if (pastedImages.length > 0) { + const parts: ContentPart[] = [ + { type: "text", text: userMessage }, + ]; + + for (const img of pastedImages) { + parts.push({ + type: "image_url", + image_url: { + url: `data:${img.mediaType};base64,${img.data}`, + detail: "auto", + }, + }); + } + + messageContent = parts; + addDebugLog( + "info", + `[images] Attached ${pastedImages.length} image(s) to user message`, + ); + // Images are consumed; clear from store + appStore.clearPastedImages(); + } + + state.messages.push({ role: "user", content: messageContent }); clearSuggestions(); @@ -707,6 +999,37 @@ export const handleMessage = async ( ? state.model : getDefaultModel(effectiveProvider); + // Auto-detect and inject relevant skills based on the user prompt. + // Skills are activated transparently and their instructions are injected + // into the conversation as a system message so the agent benefits from + // specialized knowledge (e.g., TypeScript, React, Security, etc.). + try { + const { injection, detected } = + await buildSkillInjectionForPrompt(message); + if (detected.length > 0 && injection) { + const summary = getDetectedSkillsSummary(detected); + addDebugLog("info", `[skills] ${summary}`); + callbacks.onLog("system", summary); + + // Inject skill context as a system message right before the user message + // so the agent has specialized knowledge for this prompt. + const insertIdx = Math.max(0, state.messages.length - 1); + state.messages.splice(insertIdx, 0, { + role: "system" as const, + content: injection, + }); + addDebugLog( + "info", + `[skills] Injected ${detected.length} skill(s) as system context`, + ); + } + } catch (error) { + addDebugLog( + "error", + `Skill detection failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + // Start streaming UI addDebugLog( "state", @@ -731,8 +1054,10 @@ export const handleMessage = async ( autoApprove: state.autoApprove, chatMode: isReadOnlyMode, onText: (text: string) => { + // Note: Do NOT call appStore.appendStreamContent() here. + // Streaming content is already handled by onContentChunk in streamState.callbacks. + // Calling appendStreamContent from both onText and onContentChunk causes double content. addDebugLog("info", `onText callback: "${text.substring(0, 50)}..."`); - appStore.appendStreamContent(text); }, onToolCall: createToolCallHandler(callbacks, toolCallRef), onToolResult: createToolResultHandler(callbacks, toolCallRef), @@ -758,7 +1083,10 @@ export const handleMessage = async ( onStepModeDisabled: () => { addDebugLog("state", "Step mode disabled"); }, - onWaitingForStep: (toolName: string, _toolArgs: Record) => { + onWaitingForStep: ( + toolName: string, + _toolArgs: Record, + ) => { appStore.addLog({ type: "system", content: `⏳ Step mode: Ready to execute ${toolName}. Press Enter to continue.`, @@ -766,14 +1094,20 @@ export const handleMessage = async ( addDebugLog("state", `Waiting for step: ${toolName}`); }, onAbort: (rollbackCount: number) => { - addDebugLog("state", `Abort initiated, ${rollbackCount} actions to rollback`); + addDebugLog( + "state", + `Abort initiated, ${rollbackCount} actions to rollback`, + ); }, onRollback: (action: { type: string; description: string }) => { appStore.addLog({ type: "system", content: `↩ Rolling back: ${action.description}`, }); - addDebugLog("state", `Rollback: ${action.type} - ${action.description}`); + addDebugLog( + "state", + `Rollback: ${action.type} - ${action.description}`, + ); }, onRollbackComplete: (actionsRolledBack: number) => { appStore.addLog({ @@ -788,20 +1122,33 @@ export const handleMessage = async ( // Store agent reference for abort capability currentAgent = agent; - try { - addDebugLog( - "api", - `Agent.run() started with ${state.messages.length} messages`, - ); - const result = await agent.run(state.messages); - addDebugLog( - "api", - `Agent.run() completed: success=${result.success}, iterations=${result.iterations}`, - ); - + /** + * Process the result of an agent run: finalize streaming, show stop reason, + * persist to session. + */ + const processAgentResult = async ( + result: Awaited>, + userMessage: string, + ): Promise => { // Stop thinking timer appStore.stopThinking(); + // If the stream didn't deliver API-reported usage data, estimate tokens + // from message lengths so the context counter never stays stuck at 0. + if (!streamState.hasReceivedUsage()) { + const inputEstimate = Math.ceil(userMessage.length / 4); + const outputEstimate = Math.ceil((result.finalResponse?.length ?? 0) / 4); + // Add tool I/O overhead: each tool call/result adds tokens + const toolOverhead = result.toolCalls.length * 150; // ~150 tokens per tool exchange + if (inputEstimate > 0 || outputEstimate > 0) { + appStore.addTokens(inputEstimate + toolOverhead, outputEstimate + toolOverhead); + addDebugLog( + "info", + `Token estimate (no API usage): ~${inputEstimate + toolOverhead} in, ~${outputEstimate + toolOverhead} out`, + ); + } + } + if (result.finalResponse) { addDebugLog( "info", @@ -812,7 +1159,7 @@ export const handleMessage = async ( // Run audit if cascade mode with Ollama if (shouldAudit && effectiveProvider === "ollama") { const auditResult = await runAudit( - message, + userMessage, result.finalResponse, callbacks, ); @@ -844,30 +1191,36 @@ export const handleMessage = async ( content: finalResponse, }); - // Check if streaming content was received - if not, add the response as a log - // This handles cases where streaming didn't work or content was all in final response - if (!streamState.hasReceivedContent() && finalResponse) { + // Single source of truth: decide based on whether the provider + // actually streamed visible content, not whether we asked for streaming. + const streamedContent = streamState.hasReceivedContent(); + + if (streamedContent) { + // Streaming delivered content — finalize the last streaming segment. + addDebugLog("info", "Completing streaming with received content"); + if (appStore.getState().streamingLog.isStreaming) { + appStore.completeStreaming(); + } + } else if (finalResponse) { addDebugLog( "info", "No streaming content received, adding fallback log", ); - // Streaming didn't receive content, manually add the response - appStore.cancelStreaming(); // Remove empty streaming log + if (appStore.getState().streamingLog.isStreaming) { + appStore.cancelStreaming(); + } appStore.addLog({ type: "assistant", - content: finalResponse, + content: stripMarkdown(finalResponse), }); - } else { - // Streaming received content - finalize the streaming log - addDebugLog("info", "Completing streaming with received content"); - appStore.completeStreaming(); } - addMessage("user", message); + // Persist to session + addMessage("user", userMessage); addMessage("assistant", finalResponse); await saveSession(); - await processLearningsFromExchange(message, finalResponse, callbacks); + await processLearningsFromExchange(userMessage, finalResponse, callbacks); const suggestions = getPendingSuggestions(); if (suggestions.length > 0) { @@ -875,6 +1228,130 @@ export const handleMessage = async ( callbacks.onLog("system", formatted); } } + + // Show agent stop reason to the user so they know why it ended + const stopReason = result.stopReason ?? "completed"; + const toolCount = result.toolCalls.length; + const iters = result.iterations; + + if (stopReason === "max_iterations") { + appStore.addLog({ + type: "system", + content: `Agent stopped: reached max iterations (${iters}). ` + + `${toolCount} tool call(s) completed. ` + + `Send another message to continue where it left off.`, + }); + } else if (stopReason === "consecutive_errors") { + appStore.addLog({ + type: "error", + content: `Agent stopped: repeated tool failures. ${toolCount} tool call(s) attempted across ${iters} iteration(s).`, + }); + } else if (stopReason === "aborted") { + appStore.addLog({ + type: "system", + content: `Agent aborted by user after ${iters} iteration(s) and ${toolCount} tool call(s).`, + }); + } else if (stopReason === "error") { + appStore.addLog({ + type: "error", + content: `Agent encountered an error after ${iters} iteration(s) and ${toolCount} tool call(s).`, + }); + } else if (stopReason === "completed" && toolCount > 0) { + // Only show a summary for non-trivial agent runs (with tool calls) + appStore.addLog({ + type: "system", + content: `Agent completed: ${toolCount} tool call(s) in ${iters} iteration(s).`, + }); + } + }; + + try { + addDebugLog( + "api", + `Agent.run() started with ${state.messages.length} messages`, + ); + let result = await agent.run(state.messages); + addDebugLog( + "api", + `Agent.run() completed: success=${result.success}, iterations=${result.iterations}, stopReason=${result.stopReason}`, + ); + + await processAgentResult(result, message); + + // After agent finishes, check for pending plans and auto-continue on approval + let continueAfterPlan = true; + while (continueAfterPlan) { + continueAfterPlan = false; + + const newPendingPlans = getActivePlans().filter( + (p) => p.status === "pending", + ); + if (newPendingPlans.length === 0) break; + + const plan = newPendingPlans[0]; + const planContent = formatPlanForDisplay(plan); + addDebugLog("state", `Showing plan approval modal: ${plan.id}`); + + const approved = await new Promise((resolve) => { + appStore.setMode("plan_approval"); + appStore.setPlanApprovalPrompt({ + id: uuidv4(), + planTitle: plan.title, + planSummary: plan.summary, + planContent, + resolve: (response) => { + appStore.setPlanApprovalPrompt(null); + + if (response.approved) { + approvePlan(plan.id, response.editMode); + startPlanExecution(plan.id); + addDebugLog("state", `Plan ${plan.id} approved via modal`); + appStore.addLog({ + type: "system", + content: `Plan "${plan.title}" approved. Continuing implementation...`, + }); + + state.messages.push({ + role: "user", + content: `The user approved the plan "${plan.title}". ` + + `Proceed with the full implementation — complete ALL steps in the plan. ` + + `Do not stop until every step is done or you need further user input.`, + }); + } else { + rejectPlan(plan.id, response.feedback ?? "User cancelled"); + addDebugLog("state", `Plan ${plan.id} rejected via modal`); + appStore.addLog({ + type: "system", + content: `Plan "${plan.title}" cancelled.`, + }); + } + + resolve(response.approved); + }, + }); + }); + + // If the plan was approved, re-run the agent loop so it continues working + if (approved) { + addDebugLog("api", "Re-running agent after plan approval"); + appStore.setMode("thinking"); + appStore.startThinking(); + appStore.startStreaming(); + + result = await agent.run(state.messages); + addDebugLog( + "api", + `Agent.run() (post-plan) completed: success=${result.success}, iterations=${result.iterations}, stopReason=${result.stopReason}`, + ); + + await processAgentResult(result, message); + + // Loop again to check for new pending plans from this agent run + continueAfterPlan = true; + } else { + appStore.setMode("idle"); + } + } } catch (error) { appStore.cancelStreaming(); appStore.stopThinking(); diff --git a/src/services/chat-tui/models.ts b/src/services/chat-tui/models.ts index 9a31afa..042ef14 100644 --- a/src/services/chat-tui/models.ts +++ b/src/services/chat-tui/models.ts @@ -3,6 +3,7 @@ */ import { MODEL_MESSAGES } from "@constants/chat-service"; +import { getModelContextSize } from "@constants/copilot"; import { getConfig } from "@services/core/config"; import { getProvider } from "@providers/core/registry"; import { @@ -35,6 +36,19 @@ export const loadModels = async ( } }; +/** + * Resolve the context window size for a given provider + model. + * Uses the Copilot context-size table when available, otherwise + * falls back to DEFAULT_CONTEXT_SIZE. + */ +const resolveContextMaxTokens = ( + provider: ProviderName, + modelId: string | undefined, +): number => { + const effectiveModel = modelId ?? getDefaultModel(provider); + return getModelContextSize(effectiveModel).input; +}; + export const handleModelSelect = async ( state: ChatServiceState, model: string, @@ -49,6 +63,12 @@ export const handleModelSelect = async ( } appStore.setModel(model); + // Update context max tokens for the newly selected model + const effectiveModel = model === "auto" ? undefined : model; + appStore.setContextMaxTokens( + resolveContextMaxTokens(state.provider, effectiveModel), + ); + const config = await getConfig(); config.set("model", model === "auto" ? undefined : model); await config.save(); diff --git a/src/services/chat-tui/plan-approval.ts b/src/services/chat-tui/plan-approval.ts index 95ce88d..eb72f6b 100644 --- a/src/services/chat-tui/plan-approval.ts +++ b/src/services/chat-tui/plan-approval.ts @@ -11,6 +11,7 @@ import { v4 as uuidv4 } from "uuid"; import type { PlanApprovalPromptResponse } from "@/types/tui"; import type { ImplementationPlan } from "@/types/plan-mode"; import { appStore } from "@tui-solid/context/app"; +import { formatPlanForDisplay } from "@services/plan-mode/plan-service"; export interface PlanApprovalHandlerRequest { plan: ImplementationPlan; @@ -43,6 +44,7 @@ export const createPlanApprovalHandler = (): PlanApprovalHandler => { id: uuidv4(), planTitle: request.plan.title, planSummary: request.plan.summary, + planContent: formatPlanForDisplay(request.plan), planFilePath: request.planFilePath, resolve: (response) => { appStore.setPlanApprovalPrompt(null); diff --git a/src/services/chat-tui/streaming.ts b/src/services/chat-tui/streaming.ts deleted file mode 100644 index ac1e992..0000000 --- a/src/services/chat-tui/streaming.ts +++ /dev/null @@ -1,289 +0,0 @@ -/** - * Streaming Chat TUI Integration - * - * Connects the streaming agent loop to the TUI store for real-time updates. - */ - -import type { Message } from "@/types/providers"; -import type { AgentOptions } from "@interfaces/AgentOptions"; -import type { AgentResult } from "@interfaces/AgentResult"; -import type { StreamingChatOptions } from "@interfaces/StreamingChatOptions"; -import type { - StreamCallbacks, - PartialToolCall, - ModelSwitchInfo, -} from "@/types/streaming"; -import type { ToolCall, ToolResult } from "@/types/tools"; -import { createStreamingAgent } from "@services/agent-stream"; -import { createThinkingParser } from "@services/reasoning/thinking-parser"; -import { appStore } from "@tui-solid/context/app"; - -// Re-export for convenience -export type { StreamingChatOptions } from "@interfaces/StreamingChatOptions"; - -// ============================================================================= -// TUI Streaming Callbacks -// ============================================================================= - -const createTUIStreamCallbacks = ( - options?: Partial, -): { callbacks: StreamCallbacks; resetParser: () => void } => { - const parser = createThinkingParser(); - - const emitThinking = (thinking: string | null): void => { - if (!thinking) return; - appStore.addLog({ - type: "thinking", - content: thinking, - }); - }; - - const callbacks: StreamCallbacks = { - onContentChunk: (content: string) => { - const result = parser.feed(content); - if (result.visible) { - appStore.appendStreamContent(result.visible); - } - emitThinking(result.thinking); - }, - - onToolCallStart: (toolCall: PartialToolCall) => { - appStore.setCurrentToolCall({ - id: toolCall.id, - name: toolCall.name, - description: `Calling ${toolCall.name}...`, - status: "pending", - }); - }, - - onToolCallComplete: (toolCall: ToolCall) => { - appStore.updateToolCall({ - id: toolCall.id, - name: toolCall.name, - status: "running", - }); - }, - - onModelSwitch: (info: ModelSwitchInfo) => { - appStore.addLog({ - type: "system", - content: `Model switched: ${info.from} → ${info.to} (${info.reason})`, - }); - options?.onModelSwitch?.(info); - }, - - onComplete: () => { - const flushed = parser.flush(); - if (flushed.visible) { - appStore.appendStreamContent(flushed.visible); - } - emitThinking(flushed.thinking); - appStore.completeStreaming(); - }, - - onError: (error: string) => { - parser.reset(); - appStore.cancelStreaming(); - appStore.addLog({ - type: "error", - content: error, - }); - }, - }; - - return { callbacks, resetParser: () => parser.reset() }; -}; - -// ============================================================================= -// Agent Options with TUI Integration -// ============================================================================= - -const createAgentOptionsWithTUI = ( - options: StreamingChatOptions, -): AgentOptions => ({ - ...options, - - onText: (text: string) => { - // Text is handled by streaming callbacks, but we may want to notify - options.onText?.(text); - }, - - onToolCall: (toolCall: ToolCall) => { - appStore.setMode("tool_execution"); - appStore.setCurrentToolCall({ - id: toolCall.id, - name: toolCall.name, - description: `Executing ${toolCall.name}...`, - status: "running", - }); - - appStore.addLog({ - type: "tool", - content: `${toolCall.name}`, - metadata: { - toolName: toolCall.name, - toolStatus: "running", - toolDescription: `Executing ${toolCall.name}`, - toolArgs: toolCall.arguments, - }, - }); - - options.onToolCall?.(toolCall); - }, - - onToolResult: (toolCallId: string, result: ToolResult) => { - appStore.updateToolCall({ - status: result.success ? "success" : "error", - result: result.output, - error: result.error, - }); - - appStore.addLog({ - type: "tool", - content: result.output || result.error || "", - metadata: { - toolName: appStore.getState().currentToolCall?.name, - toolStatus: result.success ? "success" : "error", - toolDescription: result.title, - }, - }); - - appStore.setCurrentToolCall(null); - appStore.setMode("thinking"); - - options.onToolResult?.(toolCallId, result); - }, - - onError: (error: string) => { - appStore.setMode("idle"); - appStore.addLog({ - type: "error", - content: error, - }); - options.onError?.(error); - }, - - onWarning: (warning: string) => { - appStore.addLog({ - type: "system", - content: warning, - }); - options.onWarning?.(warning); - }, -}); - -// ============================================================================= -// Main API -// ============================================================================= - -/** - * Run a streaming chat session with TUI integration - */ -export const runStreamingChat = async ( - messages: Message[], - options: StreamingChatOptions, -): Promise => { - // Set up TUI state - appStore.setMode("thinking"); - appStore.startThinking(); - appStore.startStreaming(); - - // Create callbacks that update the TUI - const { callbacks: streamCallbacks, resetParser } = - createTUIStreamCallbacks(options); - const agentOptions = createAgentOptionsWithTUI(options); - - // Reset parser for fresh session - resetParser(); - - // Create and run the streaming agent - const agent = createStreamingAgent( - process.cwd(), - agentOptions, - streamCallbacks, - ); - - try { - const result = await agent.run(messages); - - appStore.stopThinking(); - appStore.setMode("idle"); - - return result; - } catch (error) { - appStore.cancelStreaming(); - appStore.stopThinking(); - appStore.setMode("idle"); - - const errorMessage = error instanceof Error ? error.message : String(error); - appStore.addLog({ - type: "error", - content: errorMessage, - }); - - return { - success: false, - finalResponse: errorMessage, - iterations: 0, - toolCalls: [], - }; - } -}; - -/** - * Create a streaming chat instance with stop capability - */ -export const createStreamingChat = ( - options: StreamingChatOptions, -): { - run: (messages: Message[]) => Promise; - stop: () => void; -} => { - const { callbacks: streamCallbacks, resetParser } = - createTUIStreamCallbacks(options); - const agentOptions = createAgentOptionsWithTUI(options); - - const agent = createStreamingAgent( - process.cwd(), - agentOptions, - streamCallbacks, - ); - - return { - run: async (messages: Message[]) => { - resetParser(); - appStore.setMode("thinking"); - appStore.startThinking(); - appStore.startStreaming(); - - try { - const result = await agent.run(messages); - - appStore.stopThinking(); - appStore.setMode("idle"); - - return result; - } catch (error) { - appStore.cancelStreaming(); - appStore.stopThinking(); - appStore.setMode("idle"); - - const errorMessage = - error instanceof Error ? error.message : String(error); - return { - success: false, - finalResponse: errorMessage, - iterations: 0, - toolCalls: [], - }; - } - }, - - stop: () => { - agent.stop(); - appStore.cancelStreaming(); - appStore.stopThinking(); - appStore.setMode("idle"); - }, - }; -}; diff --git a/src/services/core/permissions.ts b/src/services/core/permissions.ts index 6a9ae48..d74e5bb 100644 --- a/src/services/core/permissions.ts +++ b/src/services/core/permissions.ts @@ -204,47 +204,86 @@ export const matchesPathPattern = ( }; /** - * Check if a Bash command is allowed + * Split a shell command into individual sub-commands on chaining operators. + * Handles &&, ||, ;, and | (pipe). + * This prevents a pattern like Bash(cd:*) from silently approving + * "cd /safe && rm -rf /dangerous". + */ +const splitChainedCommands = (command: string): string[] => { + // Split on shell chaining operators, but not inside quoted strings. + // Simple heuristic: split on &&, ||, ;, | (not ||) that are not inside quotes. + const parts: string[] = []; + let current = ""; + let inSingle = false; + let inDouble = false; + + for (let i = 0; i < command.length; i++) { + const ch = command[i]; + const next = command[i + 1]; + + // Track quoting + if (ch === "'" && !inDouble) { inSingle = !inSingle; current += ch; continue; } + if (ch === '"' && !inSingle) { inDouble = !inDouble; current += ch; continue; } + if (inSingle || inDouble) { current += ch; continue; } + + // Check for operators + if (ch === "&" && next === "&") { parts.push(current); current = ""; i++; continue; } + if (ch === "|" && next === "|") { parts.push(current); current = ""; i++; continue; } + if (ch === ";") { parts.push(current); current = ""; continue; } + if (ch === "|") { parts.push(current); current = ""; continue; } + + current += ch; + } + + if (current.trim()) parts.push(current); + return parts.map((p) => p.trim()).filter(Boolean); +}; + +/** + * Check if a Bash command is allowed. + * For chained commands (&&, ||, ;, |), EVERY sub-command must be allowed. */ export const isBashAllowed = (command: string): boolean => { + const subCommands = splitChainedCommands(command); + const allPatterns = [ ...sessionAllowPatterns, ...localAllowPatterns, ...globalAllowPatterns, ]; - for (const patternStr of allPatterns) { - const pattern = parsePattern(patternStr); - if ( - pattern && - pattern.tool === "Bash" && - matchesBashPattern(command, pattern) - ) { - return true; - } - } - - return false; + // Every sub-command must match at least one allow pattern + return subCommands.every((subCmd) => + allPatterns.some((patternStr) => { + const pattern = parsePattern(patternStr); + return ( + pattern && + pattern.tool === "Bash" && + matchesBashPattern(subCmd, pattern) + ); + }), + ); }; /** - * Check if a Bash command is denied + * Check if a Bash command is denied. + * For chained commands, if ANY sub-command is denied, the whole command is denied. */ export const isBashDenied = (command: string): boolean => { + const subCommands = splitChainedCommands(command); const denyPatterns = [...localDenyPatterns, ...globalDenyPatterns]; - for (const patternStr of denyPatterns) { - const pattern = parsePattern(patternStr); - if ( - pattern && - pattern.tool === "Bash" && - matchesBashPattern(command, pattern) - ) { - return true; - } - } - - return false; + // If any sub-command matches a deny pattern, deny the whole command + return subCommands.some((subCmd) => + denyPatterns.some((patternStr) => { + const pattern = parsePattern(patternStr); + return ( + pattern && + pattern.tool === "Bash" && + matchesBashPattern(subCmd, pattern) + ); + }), + ); }; /** @@ -273,9 +312,9 @@ export const isFileOpAllowed = ( }; /** - * Generate a pattern for the given command + * Generate a pattern for a single (non-chained) command */ -export const generateBashPattern = (command: string): string => { +const generateSingleBashPattern = (command: string): string => { const parts = command.trim().split(/\s+/); if (parts.length === 0) return `Bash(${command}:*)`; @@ -290,6 +329,33 @@ export const generateBashPattern = (command: string): string => { return `Bash(${firstWord}:*)`; }; +/** + * Generate patterns for the given command. + * For chained commands (&&, ||, ;, |), returns one pattern per sub-command. + * This prevents "Bash(cd:*)" from blanket-approving everything chained after cd. + */ +export const generateBashPattern = (command: string): string => { + const subCommands = splitChainedCommands(command); + + if (subCommands.length <= 1) { + return generateSingleBashPattern(command); + } + + // For chained commands, return all unique patterns joined so the user can see them + const patterns = [ + ...new Set(subCommands.map(generateSingleBashPattern)), + ]; + return patterns.join(", "); +}; + +/** + * Generate individual patterns for a command (used for storing) + */ +export const generateBashPatterns = (command: string): string[] => { + const subCommands = splitChainedCommands(command); + return [...new Set(subCommands.map(generateSingleBashPattern))]; +}; + /** * Add a pattern to session allow list */ @@ -385,21 +451,23 @@ export const clearSessionPatterns = (): void => { }; /** - * Handle permission scope + * Handle permission scope — stores one or more patterns */ const handlePermissionScope = async ( scope: string, - pattern: string, + patterns: string[], ): Promise => { - const scopeHandlers: Record Promise | void> = { - session: () => addSessionPattern(pattern), - local: () => addLocalPattern(pattern), - global: () => addGlobalPattern(pattern), - }; + for (const pattern of patterns) { + const scopeHandlers: Record Promise | void> = { + session: () => addSessionPattern(pattern), + local: () => addLocalPattern(pattern), + global: () => addGlobalPattern(pattern), + }; - const handler = scopeHandlers[scope]; - if (handler) { - await handler(); + const handler = scopeHandlers[scope]; + if (handler) { + await handler(); + } } }; @@ -419,6 +487,7 @@ export const promptBashPermission = async ( } const suggestedPattern = generateBashPattern(command); + const patterns = generateBashPatterns(command); // Use custom handler if set (TUI mode) if (permissionHandler) { @@ -430,7 +499,7 @@ export const promptBashPermission = async ( }); if (response.allowed && response.scope) { - await handlePermissionScope(response.scope, suggestedPattern); + await handlePermissionScope(response.scope, patterns); } return { @@ -468,55 +537,61 @@ export const promptBashPermission = async ( process.stdin.removeListener("data", handleInput); process.stdin.setRawMode?.(false); + const addAllPatterns = async ( + addFn: (p: string) => void | Promise, + ): Promise => { + for (const p of patterns) await addFn(p); + }; + const responseMap: Record Promise> = { y: async () => resolve({ allowed: true }), yes: async () => resolve({ allowed: true }), s: async () => { - addSessionPattern(suggestedPattern); + await addAllPatterns(addSessionPattern); console.log( - chalk.blue(`\n✓ Added session pattern: ${suggestedPattern}`), + chalk.blue(`\n✓ Added session patterns: ${suggestedPattern}`), ); resolve({ allowed: true, remember: "session" }); }, session: async () => { - addSessionPattern(suggestedPattern); + await addAllPatterns(addSessionPattern); console.log( - chalk.blue(`\n✓ Added session pattern: ${suggestedPattern}`), + chalk.blue(`\n✓ Added session patterns: ${suggestedPattern}`), ); resolve({ allowed: true, remember: "session" }); }, l: async () => { - await addLocalPattern(suggestedPattern); + await addAllPatterns(addLocalPattern); console.log( - chalk.cyan(`\n✓ Added project pattern: ${suggestedPattern}`), + chalk.cyan(`\n✓ Added project patterns: ${suggestedPattern}`), ); resolve({ allowed: true, remember: "local" }); }, local: async () => { - await addLocalPattern(suggestedPattern); + await addAllPatterns(addLocalPattern); console.log( - chalk.cyan(`\n✓ Added project pattern: ${suggestedPattern}`), + chalk.cyan(`\n✓ Added project patterns: ${suggestedPattern}`), ); resolve({ allowed: true, remember: "local" }); }, project: async () => { - await addLocalPattern(suggestedPattern); + await addAllPatterns(addLocalPattern); console.log( - chalk.cyan(`\n✓ Added project pattern: ${suggestedPattern}`), + chalk.cyan(`\n✓ Added project patterns: ${suggestedPattern}`), ); resolve({ allowed: true, remember: "local" }); }, g: async () => { - await addGlobalPattern(suggestedPattern); + await addAllPatterns(addGlobalPattern); console.log( - chalk.magenta(`\n✓ Added global pattern: ${suggestedPattern}`), + chalk.magenta(`\n✓ Added global patterns: ${suggestedPattern}`), ); resolve({ allowed: true, remember: "global" }); }, global: async () => { - await addGlobalPattern(suggestedPattern); + await addAllPatterns(addGlobalPattern); console.log( - chalk.magenta(`\n✓ Added global pattern: ${suggestedPattern}`), + chalk.magenta(`\n✓ Added global patterns: ${suggestedPattern}`), ); resolve({ allowed: true, remember: "global" }); }, @@ -562,7 +637,7 @@ export const promptFilePermission = async ( }); if (response.allowed && response.scope) { - await handlePermissionScope(response.scope, suggestedPattern); + await handlePermissionScope(response.scope, [suggestedPattern]); } return { diff --git a/src/services/dangerous-command-blocker.ts b/src/services/dangerous-command-blocker.ts index 7c27d7f..54dc29d 100644 --- a/src/services/dangerous-command-blocker.ts +++ b/src/services/dangerous-command-blocker.ts @@ -22,12 +22,58 @@ export interface DangerCheckResult { } /** - * Check if a command matches any blocked pattern + * Split a shell command into individual sub-commands on chaining operators. + * Handles &&, ||, ;, and | (pipe). Respects quoted strings. + */ +const splitChainedCommands = (command: string): string[] => { + const parts: string[] = []; + let current = ""; + let inSingle = false; + let inDouble = false; + + for (let i = 0; i < command.length; i++) { + const ch = command[i]; + const next = command[i + 1]; + + if (ch === "'" && !inDouble) { inSingle = !inSingle; current += ch; continue; } + if (ch === '"' && !inSingle) { inDouble = !inDouble; current += ch; continue; } + if (inSingle || inDouble) { current += ch; continue; } + + if (ch === "&" && next === "&") { parts.push(current); current = ""; i++; continue; } + if (ch === "|" && next === "|") { parts.push(current); current = ""; i++; continue; } + if (ch === ";") { parts.push(current); current = ""; continue; } + if (ch === "|") { parts.push(current); current = ""; continue; } + + current += ch; + } + + if (current.trim()) parts.push(current); + return parts.map((p) => p.trim()).filter(Boolean); +}; + +/** + * Check if a command matches any blocked pattern. + * For chained commands (&&, ||, ;, |), each sub-command is checked individually + * to prevent dangerous commands hidden behind benign ones (e.g. cd /safe && rm -rf /). */ export const checkDangerousCommand = (command: string): DangerCheckResult => { - // Normalize command for checking - const normalizedCommand = command.trim(); + const subCommands = splitChainedCommands(command); + for (const subCmd of subCommands) { + const normalized = subCmd.trim(); + for (const pattern of BLOCKED_PATTERNS) { + if (pattern.pattern.test(normalized)) { + return { + blocked: true, + pattern, + message: formatBlockedMessage(pattern), + }; + } + } + } + + // Also check the full command in case a pattern targets the chaining itself + const normalizedCommand = command.trim(); for (const pattern of BLOCKED_PATTERNS) { if (pattern.pattern.test(normalizedCommand)) { return { diff --git a/src/services/external-agent-loader.ts b/src/services/external-agent-loader.ts new file mode 100644 index 0000000..00de28d --- /dev/null +++ b/src/services/external-agent-loader.ts @@ -0,0 +1,364 @@ +/** + * External Agent Loader + * + * Loads agent definitions from .claude/, .github/, .codetyper/ + * directories in the project root. These agents are parsed from + * their respective frontmatter+markdown format and converted to + * SkillDefinition for unified handling. + */ + +import fs from "fs/promises"; +import { join, basename, extname } from "path"; +import { + EXTERNAL_AGENT_DIRS, + EXTERNAL_AGENT_FILES, + SKILL_DEFAULTS, +} from "@constants/skills"; +import type { + SkillDefinition, + SkillSource, + ExternalAgentFile, + ParsedExternalAgent, +} from "@/types/skills"; + +// ============================================================================ +// File Discovery +// ============================================================================ + +/** + * Check if a file is a recognized agent definition + */ +const isAgentFile = (filename: string): boolean => { + const lower = filename.toLowerCase(); + const ext = extname(lower); + + // Check known filenames + if (EXTERNAL_AGENT_FILES.KNOWN_FILES.some((f) => lower === f.toLowerCase())) { + return true; + } + + // Check extensions for files in agent subdirectories + return (EXTERNAL_AGENT_FILES.EXTENSIONS as readonly string[]).includes(ext); +}; + +/** + * Scan a directory for agent files (non-recursive for top level) + */ +const scanDirectory = async ( + dir: string, + source: SkillSource, +): Promise => { + const files: ExternalAgentFile[] = []; + + try { + await fs.access(dir); + } catch { + return files; // Directory doesn't exist + } + + try { + const entries = await fs.readdir(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = join(dir, entry.name); + + if (entry.isFile() && isAgentFile(entry.name)) { + try { + const content = await fs.readFile(fullPath, "utf-8"); + files.push({ + relativePath: entry.name, + absolutePath: fullPath, + source, + content, + }); + } catch { + // Skip unreadable files + } + } else if ( + entry.isDirectory() && + (EXTERNAL_AGENT_FILES.SUBDIRS as readonly string[]).includes(entry.name.toLowerCase()) + ) { + // Scan recognized subdirectories + const subFiles = await scanSubdirectory(fullPath, source); + files.push(...subFiles); + } + } + } catch { + // Directory not accessible + } + + return files; +}; + +/** + * Scan a subdirectory for agent files + */ +const scanSubdirectory = async ( + dir: string, + source: SkillSource, +): Promise => { + const files: ExternalAgentFile[] = []; + + try { + const entries = await fs.readdir(dir, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isFile()) continue; + + const ext = extname(entry.name).toLowerCase(); + if (!(EXTERNAL_AGENT_FILES.EXTENSIONS as readonly string[]).includes(ext)) continue; + + const fullPath = join(dir, entry.name); + + try { + const content = await fs.readFile(fullPath, "utf-8"); + files.push({ + relativePath: join(basename(dir), entry.name), + absolutePath: fullPath, + source, + content, + }); + } catch { + // Skip unreadable files + } + } + } catch { + // Subdirectory not accessible + } + + return files; +}; + +// ============================================================================ +// Parsing +// ============================================================================ + +/** + * Parse the frontmatter from an external agent file. + * Supports the standard --- delimited YAML-like frontmatter. + */ +const parseFrontmatter = ( + content: string, +): { frontmatter: Record; body: string } => { + const lines = content.split("\n"); + + if (lines[0]?.trim() !== "---") { + // No frontmatter — the entire content is the body + return { frontmatter: {}, body: content.trim() }; + } + + let endIndex = -1; + for (let i = 1; i < lines.length; i++) { + if (lines[i]?.trim() === "---") { + endIndex = i; + break; + } + } + + if (endIndex === -1) { + return { frontmatter: {}, body: content.trim() }; + } + + const fmLines = lines.slice(1, endIndex); + const body = lines + .slice(endIndex + 1) + .join("\n") + .trim(); + + // Simple YAML-like parsing + const fm: Record = {}; + let currentKey: string | null = null; + let currentArray: string[] | null = null; + + for (const line of fmLines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + + // Array item + if (trimmed.startsWith("- ") && currentKey) { + if (!currentArray) currentArray = []; + const value = trimmed.slice(2).trim().replace(/^["']|["']$/g, ""); + currentArray.push(value); + fm[currentKey] = currentArray; + continue; + } + + // Key-value pair + const colonIdx = trimmed.indexOf(":"); + if (colonIdx > 0) { + if (currentArray && currentKey) { + fm[currentKey] = currentArray; + } + currentArray = null; + + currentKey = trimmed.slice(0, colonIdx).trim(); + const rawValue = trimmed.slice(colonIdx + 1).trim(); + + if (!rawValue) continue; // Empty → might be array header + + // Inline array: [a, b, c] + if (rawValue.startsWith("[") && rawValue.endsWith("]")) { + const items = rawValue + .slice(1, -1) + .split(",") + .map((s) => s.trim().replace(/^["']|["']$/g, "")) + .filter(Boolean); + fm[currentKey] = items; + } else { + fm[currentKey] = rawValue.replace(/^["']|["']$/g, ""); + } + } + } + + return { frontmatter: fm, body }; +}; + +/** + * Parse an external agent file into a structured definition + */ +const parseAgentFile = (file: ExternalAgentFile): ParsedExternalAgent => { + const { frontmatter, body } = parseFrontmatter(file.content); + + // Derive ID from filename (strip extension, lowercase, kebab-case) + const nameWithoutExt = basename(file.relativePath, extname(file.relativePath)); + const id = `ext-${file.source.replace("external-", "")}-${nameWithoutExt + .toLowerCase() + .replace(/\s+/g, "-")}`; + + const description = + typeof frontmatter.description === "string" + ? frontmatter.description + : `External agent from ${file.source}: ${nameWithoutExt}`; + + const tools = Array.isArray(frontmatter.tools) + ? (frontmatter.tools as string[]) + : []; + + return { + id, + description, + tools, + body, + source: file.source, + filePath: file.absolutePath, + }; +}; + +// ============================================================================ +// Conversion +// ============================================================================ + +/** + * Convert a parsed external agent to a SkillDefinition + * so it can be used uniformly in the skill registry. + */ +const toSkillDefinition = (agent: ParsedExternalAgent): SkillDefinition => { + // Derive a human-readable name from the ID + const name = agent.id + .replace(/^ext-[a-z]+-/, "") + .split("-") + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "); + + // Extract triggers from the agent body (look for trigger patterns) + const triggers: string[] = [`/${agent.id}`]; + + return { + id: agent.id, + name, + description: agent.description, + version: SKILL_DEFAULTS.VERSION, + triggers, + triggerType: "explicit", + autoTrigger: false, + requiredTools: agent.tools, + tags: [agent.source, "external"], + source: agent.source, + systemPrompt: "", + instructions: agent.body, + loadedAt: Date.now(), + }; +}; + +// ============================================================================ +// Public API +// ============================================================================ + +/** + * Source-to-directory mapping + */ +const SOURCE_DIRS: ReadonlyArray = [ + [EXTERNAL_AGENT_DIRS.CLAUDE, "external-claude"], + [EXTERNAL_AGENT_DIRS.GITHUB, "external-github"], + [EXTERNAL_AGENT_DIRS.CODETYPER, "external-codetyper"], +]; + +/** + * Load all external agents from recognized directories + * in the current project. + */ +export const loadExternalAgents = async ( + projectRoot?: string, +): Promise => { + const root = projectRoot ?? process.cwd(); + const allAgents: SkillDefinition[] = []; + + for (const [dirName, source] of SOURCE_DIRS) { + const dir = join(root, dirName); + const files = await scanDirectory(dir, source); + + for (const file of files) { + try { + const parsed = parseAgentFile(file); + const skill = toSkillDefinition(parsed); + allAgents.push(skill); + } catch { + // Skip unparseable files + } + } + } + + return allAgents; +}; + +/** + * Load a specific external agent by source and filename + */ +export const loadExternalAgentByPath = async ( + filePath: string, + source: SkillSource, +): Promise => { + try { + const content = await fs.readFile(filePath, "utf-8"); + const file: ExternalAgentFile = { + relativePath: basename(filePath), + absolutePath: filePath, + source, + content, + }; + const parsed = parseAgentFile(file); + return toSkillDefinition(parsed); + } catch { + return null; + } +}; + +/** + * Check if any external agent directories exist + */ +export const hasExternalAgents = async ( + projectRoot?: string, +): Promise => { + const root = projectRoot ?? process.cwd(); + + for (const [dirName] of SOURCE_DIRS) { + try { + await fs.access(join(root, dirName)); + return true; + } catch { + continue; + } + } + + return false; +}; diff --git a/src/services/keybind-resolver.ts b/src/services/keybind-resolver.ts new file mode 100644 index 0000000..12a5fa9 --- /dev/null +++ b/src/services/keybind-resolver.ts @@ -0,0 +1,353 @@ +/** + * Keybind Resolver + * + * Parses keybind strings (e.g., "ctrl+c", "m", "shift+return,ctrl+return"), + * expands leader-key prefixes, and matches incoming key events against configured bindings. + * + * Keybind string format: + * - "ctrl+c" → single combo + * - "ctrl+c,ctrl+d" → two alternatives (either triggers) + * - "m" → leader prefix + key (expands based on configured leader) + * - "none" → binding disabled + * - "escape" → single key without modifiers + */ + +import fs from "fs/promises"; +import { FILES } from "@constants/paths"; +import { + DEFAULT_KEYBINDS, + DEFAULT_LEADER, + type KeybindAction, +} from "@constants/keybinds"; + +// ============================================================================ +// Types +// ============================================================================ + +/** A single parsed key combination */ +export interface ParsedCombo { + key: string; + ctrl: boolean; + alt: boolean; + shift: boolean; + meta: boolean; +} + +/** A resolved keybinding: one action → one or more alternative combos */ +export interface ResolvedKeybind { + action: KeybindAction; + combos: ParsedCombo[]; + raw: string; +} + +/** The incoming key event from the TUI framework */ +export interface KeyEvent { + name: string; + ctrl?: boolean; + alt?: boolean; + shift?: boolean; + meta?: boolean; +} + +/** User-provided overrides (partial, only the actions they want to change) */ +export type KeybindOverrides = Partial>; + +/** Full resolved keybind map */ +export type ResolvedKeybindMap = Map; + +// ============================================================================ +// Parsing +// ============================================================================ + +/** + * Expand `` references in a keybind string. + * E.g., with leader="ctrl+x": + * "m" → "ctrl+x+m" + * "q" → "ctrl+x+q" + */ +const expandLeader = (raw: string, leader: string): string => { + return raw.replace(//gi, `${leader}+`); +}; + +/** + * Parse a single key combo string like "ctrl+shift+s" into a ParsedCombo. + */ +const parseCombo = (combo: string): ParsedCombo => { + const parts = combo + .trim() + .toLowerCase() + .split("+") + .map((p) => p.trim()) + .filter(Boolean); + + const result: ParsedCombo = { + key: "", + ctrl: false, + alt: false, + shift: false, + meta: false, + }; + + for (const part of parts) { + switch (part) { + case "ctrl": + case "control": + result.ctrl = true; + break; + case "alt": + case "option": + result.alt = true; + break; + case "shift": + result.shift = true; + break; + case "meta": + case "cmd": + case "super": + case "win": + result.meta = true; + break; + default: + // Last non-modifier part is the key name + result.key = part; + break; + } + } + + return result; +}; + +/** + * Parse a full keybind string (possibly comma-separated) into an array of combos. + * Returns empty array for "none" (disabled binding). + */ +const parseKeybindString = ( + raw: string, + leader: string, +): ParsedCombo[] => { + const trimmed = raw.trim().toLowerCase(); + if (trimmed === "none" || trimmed === "") return []; + + const expanded = expandLeader(raw, leader); + const alternatives = expanded.split(","); + + return alternatives + .map((alt) => parseCombo(alt)) + .filter((combo) => combo.key !== ""); +}; + +// ============================================================================ +// Matching +// ============================================================================ + +/** + * Check if a key event matches a parsed combo. + */ +const matchesCombo = (event: KeyEvent, combo: ParsedCombo): boolean => { + const eventKey = event.name?.toLowerCase() ?? ""; + if (eventKey !== combo.key) return false; + if (!!event.ctrl !== combo.ctrl) return false; + if (!!event.alt !== combo.alt) return false; + if (!!event.shift !== combo.shift) return false; + if (!!event.meta !== combo.meta) return false; + return true; +}; + +// ============================================================================ +// Resolver State +// ============================================================================ + +let resolvedMap: ResolvedKeybindMap = new Map(); +let currentLeader: string = DEFAULT_LEADER; +let initialized = false; + +/** + * Build the resolved keybind map from defaults + overrides. + */ +const buildResolvedMap = ( + leader: string, + overrides: KeybindOverrides, +): ResolvedKeybindMap => { + const map = new Map(); + + const merged = { ...DEFAULT_KEYBINDS, ...overrides }; + + for (const [action, raw] of Object.entries(merged)) { + const combos = parseKeybindString(raw, leader); + map.set(action as KeybindAction, { + action: action as KeybindAction, + combos, + raw, + }); + } + + return map; +}; + +// ============================================================================ +// Public API +// ============================================================================ + +/** + * Initialize the keybind resolver. + * Loads user overrides from keybindings.json if it exists. + */ +export const initializeKeybinds = async (): Promise => { + let overrides: KeybindOverrides = {}; + let leader = DEFAULT_LEADER; + + try { + const data = await fs.readFile(FILES.keybindings, "utf-8"); + const parsed = JSON.parse(data) as Record; + + if (typeof parsed.leader === "string") { + leader = parsed.leader; + } + + // Extract keybind overrides (anything that's not "leader") + for (const [key, value] of Object.entries(parsed)) { + if (key === "leader") continue; + if (typeof value === "string") { + overrides[key as KeybindAction] = value; + } + } + } catch { + // File doesn't exist or is invalid — use defaults only + } + + currentLeader = leader; + resolvedMap = buildResolvedMap(leader, overrides); + initialized = true; +}; + +/** + * Re-initialize with explicit overrides (for programmatic use). + */ +export const setKeybindOverrides = ( + overrides: KeybindOverrides, + leader?: string, +): void => { + currentLeader = leader ?? currentLeader; + resolvedMap = buildResolvedMap(currentLeader, overrides); + initialized = true; +}; + +/** + * Check if a key event matches a specific action. + */ +export const matchesAction = ( + event: KeyEvent, + action: KeybindAction, +): boolean => { + if (!initialized) { + // Lazy init with defaults if not yet initialized + resolvedMap = buildResolvedMap(DEFAULT_LEADER, {}); + initialized = true; + } + + const resolved = resolvedMap.get(action); + if (!resolved) return false; + + return resolved.combos.some((combo) => matchesCombo(event, combo)); +}; + +/** + * Find which action(s) a key event matches. + * Returns all matching actions (there may be overlaps). + */ +export const findMatchingActions = (event: KeyEvent): KeybindAction[] => { + if (!initialized) { + resolvedMap = buildResolvedMap(DEFAULT_LEADER, {}); + initialized = true; + } + + const matches: KeybindAction[] = []; + + for (const [action, resolved] of resolvedMap) { + if (resolved.combos.some((combo) => matchesCombo(event, combo))) { + matches.push(action); + } + } + + return matches; +}; + +/** + * Get the resolved keybind for an action (for display in help menus). + */ +export const getKeybindDisplay = (action: KeybindAction): string => { + if (!initialized) { + resolvedMap = buildResolvedMap(DEFAULT_LEADER, {}); + initialized = true; + } + + const resolved = resolvedMap.get(action); + if (!resolved || resolved.combos.length === 0) return "none"; + + return resolved.combos + .map((combo) => formatCombo(combo)) + .join(" / "); +}; + +/** + * Format a parsed combo back to a human-readable string. + * E.g., { ctrl: true, key: "c" } → "Ctrl+C" + */ +const formatCombo = (combo: ParsedCombo): string => { + const parts: string[] = []; + if (combo.ctrl) parts.push("Ctrl"); + if (combo.alt) parts.push("Alt"); + if (combo.shift) parts.push("Shift"); + if (combo.meta) parts.push("Cmd"); + + const keyDisplay = + combo.key.length === 1 + ? combo.key.toUpperCase() + : combo.key === "return" + ? "Enter" + : combo.key === "escape" + ? "Esc" + : combo.key.charAt(0).toUpperCase() + combo.key.slice(1); + + parts.push(keyDisplay); + return parts.join("+"); +}; + +/** + * Get all resolved keybinds (for help display or debugging). + */ +export const getAllKeybinds = (): ResolvedKeybind[] => { + if (!initialized) { + resolvedMap = buildResolvedMap(DEFAULT_LEADER, {}); + initialized = true; + } + return Array.from(resolvedMap.values()); +}; + +/** + * Get the current leader key string. + */ +export const getLeader = (): string => currentLeader; + +/** + * Save current keybind overrides to keybindings.json. + */ +export const saveKeybindOverrides = async ( + overrides: KeybindOverrides, + leader?: string, +): Promise => { + const { mkdir, writeFile } = await import("fs/promises"); + const { dirname } = await import("path"); + + const filepath = FILES.keybindings; + await mkdir(dirname(filepath), { recursive: true }); + + const data: Record = {}; + if (leader) data.leader = leader; + + for (const [action, value] of Object.entries(overrides)) { + data[action] = value; + } + + await writeFile(filepath, JSON.stringify(data, null, 2), "utf-8"); +}; diff --git a/src/services/mcp/client.ts b/src/services/mcp/client.ts index 4a59cbd..6cb2021 100644 --- a/src/services/mcp/client.ts +++ b/src/services/mcp/client.ts @@ -39,6 +39,10 @@ interface JsonRpcResponse { export class MCPClient { private config: MCPServerConfig; private process: ChildProcess | null = null; + /** Base URL for http / sse transport */ + private httpUrl: string | null = null; + /** Session URL returned by the server after SSE handshake (if any) */ + private httpSessionUrl: string | null = null; private state: MCPConnectionState = "disconnected"; private tools: MCPToolDefinition[] = []; private resources: MCPResourceDefinition[] = []; @@ -71,6 +75,13 @@ export class MCPClient { }; } + /** + * Resolve effective transport: `type` takes precedence over legacy `transport` + */ + private get transport(): "stdio" | "sse" | "http" { + return this.config.type ?? "stdio"; + } + /** * Connect to the MCP server */ @@ -83,12 +94,13 @@ export class MCPClient { this.error = undefined; try { - if (this.config.transport === "stdio" || !this.config.transport) { + const t = this.transport; + if (t === "stdio") { await this.connectStdio(); + } else if (t === "http" || t === "sse") { + await this.connectHttp(); } else { - throw new Error( - `Transport type '${this.config.transport}' not yet supported`, - ); + throw new Error(`Transport type '${t}' is not supported`); } // Initialize the connection @@ -109,13 +121,17 @@ export class MCPClient { * Connect via stdio transport */ private async connectStdio(): Promise { + if (!this.config.command) { + throw new Error("Command is required for stdio transport"); + } + return new Promise((resolve, reject) => { const env = { ...process.env, ...this.config.env, }; - this.process = spawn(this.config.command, this.config.args || [], { + this.process = spawn(this.config.command!, this.config.args || [], { stdio: ["pipe", "pipe", "pipe"], env, }); @@ -146,11 +162,38 @@ export class MCPClient { } }); - // Give the process a moment to start + // Give the stdio process a moment to start setTimeout(resolve, 100); }); } + /** + * Connect via HTTP (Streamable HTTP) transport. + * The server URL is used directly for JSON-RPC over HTTP POST. + */ + private async connectHttp(): Promise { + const url = this.config.url; + if (!url) { + throw new Error("URL is required for http/sse transport"); + } + this.httpUrl = url; + + // Verify the server is reachable with a simple OPTIONS/HEAD check + try { + const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 0, method: "ping" }) }); + // Even a 4xx/5xx means the server is reachable; we'll handle errors in initialize() + if (!res.ok && res.status >= 500) { + throw new Error(`Server returned ${res.status}: ${res.statusText}`); + } + } catch (err) { + if (err instanceof TypeError) { + // Network/fetch error + throw new Error(`Cannot reach MCP server at ${url}: ${(err as Error).message}`); + } + // Other errors (like 400) are OK — the server is reachable + } + } + /** * Handle incoming data from the server */ @@ -189,11 +232,24 @@ export class MCPClient { } /** - * Send a JSON-RPC request + * Send a JSON-RPC request (dispatches to stdio or http) */ private async sendRequest( method: string, params?: unknown, + ): Promise { + if (this.httpUrl) { + return this.sendHttpRequest(method, params); + } + return this.sendStdioRequest(method, params); + } + + /** + * Send a JSON-RPC request via stdio + */ + private async sendStdioRequest( + method: string, + params?: unknown, ): Promise { if (!this.process?.stdin) { throw new Error("Not connected"); @@ -225,6 +281,72 @@ export class MCPClient { }); } + /** + * Send a JSON-RPC request via HTTP POST + */ + private async sendHttpRequest( + method: string, + params?: unknown, + ): Promise { + const url = this.httpSessionUrl ?? this.httpUrl!; + const id = ++this.requestId; + const body: JsonRpcRequest = { jsonrpc: "2.0", id, method, params }; + + const res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(30000), + }); + + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`MCP HTTP error ${res.status}: ${text || res.statusText}`); + } + + // Capture session URL from Mcp-Session header if present + const sessionHeader = res.headers.get("mcp-session"); + if (sessionHeader && !this.httpSessionUrl) { + // If it's a full URL use it; otherwise it's a session id + this.httpSessionUrl = sessionHeader.startsWith("http") + ? sessionHeader + : this.httpUrl!; + } + + const contentType = res.headers.get("content-type") ?? ""; + + // Handle SSE responses (text/event-stream) — collect the last JSON-RPC result + if (contentType.includes("text/event-stream")) { + const text = await res.text(); + let lastResult: unknown = undefined; + for (const line of text.split("\n")) { + if (line.startsWith("data: ")) { + const json = line.slice(6).trim(); + if (json && json !== "[DONE]") { + try { + const parsed = JSON.parse(json) as JsonRpcResponse; + if (parsed.error) throw new Error(parsed.error.message); + lastResult = parsed.result; + } catch { + // skip unparseable lines + } + } + } + } + return lastResult; + } + + // Standard JSON response + const json = (await res.json()) as JsonRpcResponse; + if (json.error) { + throw new Error(json.error.message); + } + return json.result; + } + /** * Initialize the MCP connection */ @@ -242,7 +364,18 @@ export class MCPClient { }); // Send initialized notification - if (this.process?.stdin) { + if (this.httpUrl) { + // For HTTP transport, send as a JSON-RPC notification (no id) + const url = this.httpSessionUrl ?? this.httpUrl; + await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "notifications/initialized", + }), + }).catch(() => { /* ignore notification failures */ }); + } else if (this.process?.stdin) { this.process.stdin.write( JSON.stringify({ jsonrpc: "2.0", @@ -344,6 +477,8 @@ export class MCPClient { this.process.kill(); this.process = null; } + this.httpUrl = null; + this.httpSessionUrl = null; this.state = "disconnected"; this.tools = []; this.resources = []; diff --git a/src/services/mcp/manager.ts b/src/services/mcp/manager.ts index 6bde2d2..3898e1f 100644 --- a/src/services/mcp/manager.ts +++ b/src/services/mcp/manager.ts @@ -37,7 +37,7 @@ interface MCPManagerState { */ const state: MCPManagerState = { clients: new Map(), - config: { servers: {} }, + config: { inputs: [], servers: {} }, initialized: false, }; @@ -53,17 +53,49 @@ const loadConfigFile = async (filePath: string): Promise => { } }; +/** + * Inject the runtime `name` field from the config key into each server entry. + * Also normalises legacy `transport` field → `type`. + */ +const hydrateServerNames = ( + servers: Record, +): Record => { + const hydrated: Record = {}; + for (const [key, cfg] of Object.entries(servers)) { + // Normalise legacy `transport` → `type` + const type = cfg.type ?? (cfg as Record).transport as MCPServerConfig["type"]; + hydrated[key] = { ...cfg, name: key, type }; + } + return hydrated; +}; + +/** + * Build a clean server config object for disk persistence. + * Strips the runtime-only `name` field so the JSON matches: + * { "servers": { "": { "type": "http", "url": "..." } } } + */ +const toStorableConfig = (config: MCPServerConfig): Omit => { + const { name: _name, ...rest } = config; + // Remove undefined fields to keep JSON clean + return Object.fromEntries( + Object.entries(rest).filter(([, v]) => v !== undefined), + ) as Omit; +}; + /** * Load MCP configuration (merges global + local) */ export const loadMCPConfig = async (): Promise => { - const globalConfig = await loadConfigFile(CONFIG_LOCATIONS.global); - const localConfig = await loadConfigFile(CONFIG_LOCATIONS.local); + const globalConfig = + (await loadConfigFile(CONFIG_LOCATIONS.global)) || { inputs: [], servers: {} }; + const localConfig = + (await loadConfigFile(CONFIG_LOCATIONS.local)) || { inputs: [], servers: {} }; const merged: MCPConfig = { + inputs: [...(globalConfig?.inputs || []), ...(localConfig?.inputs || [])], servers: { - ...(globalConfig?.servers || {}), - ...(localConfig?.servers || {}), + ...hydrateServerNames(globalConfig?.servers || {}), + ...hydrateServerNames(localConfig?.servers || {}), }, }; @@ -71,7 +103,8 @@ export const loadMCPConfig = async (): Promise => { }; /** - * Save MCP configuration + * Save MCP configuration. + * Strips runtime-only `name` fields from server entries before writing. */ export const saveMCPConfig = async ( config: MCPConfig, @@ -80,8 +113,19 @@ export const saveMCPConfig = async ( const filePath = global ? CONFIG_LOCATIONS.global : CONFIG_LOCATIONS.local; const dir = path.dirname(filePath); + // Strip runtime `name` from each server entry before persisting + const cleanServers: Record> = {}; + for (const [key, srv] of Object.entries(config.servers)) { + cleanServers[key] = toStorableConfig(srv); + } + + const output: MCPConfig = { + inputs: config.inputs ?? [], + servers: cleanServers as Record, + }; + await fs.mkdir(dir, { recursive: true }); - await fs.writeFile(filePath, JSON.stringify(config, null, 2), "utf-8"); + await fs.writeFile(filePath, JSON.stringify(output, null, 2), "utf-8"); }; /** @@ -250,14 +294,24 @@ export const addServer = async ( await initializeMCP(); const targetConfig = global - ? (await loadConfigFile(CONFIG_LOCATIONS.global)) || { servers: {} } - : (await loadConfigFile(CONFIG_LOCATIONS.local)) || { servers: {} }; + ? (await loadConfigFile(CONFIG_LOCATIONS.global)) || { inputs: [], servers: {} } + : (await loadConfigFile(CONFIG_LOCATIONS.local)) || { inputs: [], servers: {} }; - targetConfig.servers[name] = { ...config, name }; + if (targetConfig.servers[name]) { + throw new Error(`Server '${name}' already exists`); + } + + // Also check in-memory merged config for duplicates across scopes + if (state.config.servers[name]) { + throw new Error(`Server '${name}' already exists`); + } + + // Store without the `name` field — the key is the name + targetConfig.servers[name] = toStorableConfig(config as MCPServerConfig); await saveMCPConfig(targetConfig, global); - // Update in-memory config + // Update in-memory config with runtime name injected state.config.servers[name] = { ...config, name }; }; @@ -275,6 +329,7 @@ export const removeServer = async ( if (config?.servers[name]) { delete config.servers[name]; + config.inputs = config.inputs || []; await saveMCPConfig(config, global); } diff --git a/src/services/mcp/registry.ts b/src/services/mcp/registry.ts index 79f58ae..21ef41b 100644 --- a/src/services/mcp/registry.ts +++ b/src/services/mcp/registry.ts @@ -309,7 +309,7 @@ export const isServerInstalled = (serverId: string): boolean => { return Array.from(instances.values()).some( (instance) => instance.config.name === serverId || - instance.config.name.toLowerCase() === serverId.toLowerCase(), + (instance.config.name ?? "").toLowerCase() === serverId.toLowerCase(), ); }; @@ -338,16 +338,22 @@ export const installServer = async ( try { // Add server to configuration - await addServer( - server.id, - { - command: server.command, - args: customArgs || server.args, - transport: server.transport, - enabled: true, - }, - global, - ); + const serverType = server.transport ?? "stdio"; + const config: Omit = + serverType === "stdio" + ? { + type: "stdio", + command: server.command, + args: customArgs || server.args, + enabled: true, + } + : { + type: serverType, + url: server.url, + enabled: true, + }; + + await addServer(server.id, config, global); let connected = false; diff --git a/src/services/permissions/matchers/bash.ts b/src/services/permissions/matchers/bash.ts index 475b5c7..d931a97 100644 --- a/src/services/permissions/matchers/bash.ts +++ b/src/services/permissions/matchers/bash.ts @@ -50,26 +50,59 @@ export const matchesBashPattern = ( return cmdArgs === patternArgs; }; +// ============================================================================= +// Command Chaining +// ============================================================================= + +/** + * Split a shell command on chaining operators (&&, ||, ;, |). + * Respects quoted strings. Prevents pattern bypass via + * "cd /safe && rm -rf /dangerous". + */ +const splitChainedCommands = (command: string): string[] => { + const parts: string[] = []; + let current = ""; + let inSingle = false; + let inDouble = false; + + for (let i = 0; i < command.length; i++) { + const ch = command[i]; + const next = command[i + 1]; + + if (ch === "'" && !inDouble) { inSingle = !inSingle; current += ch; continue; } + if (ch === '"' && !inSingle) { inDouble = !inDouble; current += ch; continue; } + if (inSingle || inDouble) { current += ch; continue; } + + if (ch === "&" && next === "&") { parts.push(current); current = ""; i++; continue; } + if (ch === "|" && next === "|") { parts.push(current); current = ""; i++; continue; } + if (ch === ";") { parts.push(current); current = ""; continue; } + if (ch === "|") { parts.push(current); current = ""; continue; } + + current += ch; + } + + if (current.trim()) parts.push(current); + return parts.map((p) => p.trim()).filter(Boolean); +}; + // ============================================================================= // Index-Based Matching // ============================================================================= /** - * Check if a command is allowed by any pattern in the index + * Check if a command is allowed by any pattern in the index. + * For chained commands (&&, ||, ;, |), EVERY sub-command must be allowed. */ export const isBashAllowedByIndex = ( command: string, index: PatternIndex, ): boolean => { + const subCommands = splitChainedCommands(command); const bashPatterns = getPatternsForTool(index, "Bash"); - for (const entry of bashPatterns) { - if (matchesBashPattern(command, entry.parsed)) { - return true; - } - } - - return false; + return subCommands.every((subCmd) => + bashPatterns.some((entry) => matchesBashPattern(subCmd, entry.parsed)), + ); }; /** diff --git a/src/services/plan-mode/plan-service.ts b/src/services/plan-mode/plan-service.ts index 0464463..f4526b8 100644 --- a/src/services/plan-mode/plan-service.ts +++ b/src/services/plan-mode/plan-service.ts @@ -405,67 +405,77 @@ export const getActivePlans = (): ImplementationPlan[] => { }; /** - * Format a plan for display + * Risk level display icons + */ +const RISK_ICONS: Record = { + high: "!", + medium: "~", + low: " ", +}; + +/** + * Format a plan for display (terminal-friendly, no markdown) */ export const formatPlanForDisplay = (plan: ImplementationPlan): string => { const lines: string[] = []; - lines.push(`# Implementation Plan: ${plan.title}`); + lines.push(`Plan to implement`); + lines.push(""); + lines.push(plan.title); lines.push(""); - lines.push(`## Summary`); lines.push(plan.summary); lines.push(""); if (plan.context.filesAnalyzed.length > 0) { - lines.push(`## Files Analyzed`); - plan.context.filesAnalyzed.forEach(f => lines.push(`- ${f}`)); + lines.push("Files Analyzed"); + plan.context.filesAnalyzed.forEach(f => lines.push(` ${f}`)); lines.push(""); } if (plan.context.currentArchitecture) { - lines.push(`## Current Architecture`); - lines.push(plan.context.currentArchitecture); + lines.push("Current Architecture"); + lines.push(` ${plan.context.currentArchitecture}`); lines.push(""); } - lines.push(`## Implementation Steps`); - plan.steps.forEach((step, i) => { - const riskIcon = step.riskLevel === "high" ? "⚠️" : step.riskLevel === "medium" ? "⚡" : "✓"; - lines.push(`${i + 1}. ${riskIcon} **${step.title}**`); - lines.push(` ${step.description}`); - if (step.filesAffected.length > 0) { - lines.push(` Files: ${step.filesAffected.join(", ")}`); - } - }); - lines.push(""); - - if (plan.risks.length > 0) { - lines.push(`## Risks`); - plan.risks.forEach(risk => { - lines.push(`- **${risk.impact.toUpperCase()}**: ${risk.description}`); - lines.push(` Mitigation: ${risk.mitigation}`); + if (plan.steps.length > 0) { + lines.push("Implementation Steps"); + plan.steps.forEach((step, i) => { + const icon = RISK_ICONS[step.riskLevel] ?? " "; + lines.push(` ${i + 1}. [${icon}] ${step.title}`); + lines.push(` ${step.description}`); + if (step.filesAffected.length > 0) { + lines.push(` Files: ${step.filesAffected.join(", ")}`); + } }); lines.push(""); } - lines.push(`## Testing Strategy`); - lines.push(plan.testingStrategy || "TBD"); - lines.push(""); + if (plan.risks.length > 0) { + lines.push("Risks"); + plan.risks.forEach(risk => { + lines.push(` [${risk.impact.toUpperCase()}] ${risk.description}`); + lines.push(` Mitigation: ${risk.mitigation}`); + }); + lines.push(""); + } - lines.push(`## Rollback Plan`); - lines.push(plan.rollbackPlan || "TBD"); - lines.push(""); + if (plan.testingStrategy) { + lines.push("Testing Strategy"); + lines.push(` ${plan.testingStrategy}`); + lines.push(""); + } - lines.push(`## Estimated Changes`); - lines.push(`- Files to create: ${plan.estimatedChanges.filesCreated}`); - lines.push(`- Files to modify: ${plan.estimatedChanges.filesModified}`); - lines.push(`- Files to delete: ${plan.estimatedChanges.filesDeleted}`); - lines.push(""); + if (plan.rollbackPlan) { + lines.push("Rollback Plan"); + lines.push(` ${plan.rollbackPlan}`); + lines.push(""); + } - lines.push("---"); - lines.push("**Awaiting approval to proceed with implementation.**"); - lines.push("Reply with 'proceed', 'approve', or 'go ahead' to start execution."); - lines.push("Reply with 'stop', 'cancel', or provide feedback to modify the plan."); + lines.push("Estimated Changes"); + lines.push(` Files to create: ${plan.estimatedChanges.filesCreated}`); + lines.push(` Files to modify: ${plan.estimatedChanges.filesModified}`); + lines.push(` Files to delete: ${plan.estimatedChanges.filesDeleted}`); return lines.join("\n"); }; diff --git a/src/services/reasoning-agent.ts b/src/services/reasoning-agent.ts index 4d1ab25..db8d444 100644 --- a/src/services/reasoning-agent.ts +++ b/src/services/reasoning-agent.ts @@ -14,6 +14,7 @@ import { v4 as uuidv4 } from "uuid"; import type { Message } from "@/types/providers"; +import { getMessageText } from "@/types/providers"; import type { AgentOptions } from "@interfaces/AgentOptions"; import type { AgentResult } from "@interfaces/AgentResult"; import type { @@ -245,13 +246,13 @@ const convertToCompressibleMessages = ( if ("tool_calls" in msg) { role = "assistant"; - content = msg.content || JSON.stringify(msg.tool_calls); + content = (typeof msg.content === "string" ? msg.content : getMessageText(msg.content ?? "")) || JSON.stringify(msg.tool_calls); } else if ("tool_call_id" in msg) { role = "tool"; - content = msg.content; + content = typeof msg.content === "string" ? msg.content : getMessageText(msg.content); } else { role = msg.role as "user" | "assistant" | "system"; - content = msg.content; + content = typeof msg.content === "string" ? msg.content : getMessageText(msg.content); } return { @@ -322,7 +323,8 @@ export const runReasoningAgentLoop = async ( await refreshMCPTools(); let agentMessages: AgentMessage[] = [...messages]; - const originalQuery = messages.find((m) => m.role === "user")?.content || ""; + const originalQueryContent = messages.find((m) => m.role === "user")?.content; + const originalQuery = originalQueryContent ? getMessageText(originalQueryContent) : ""; const previousAttempts: AttemptRecord[] = []; while (iterations < maxIterations) { diff --git a/src/services/skill-detector.ts b/src/services/skill-detector.ts new file mode 100644 index 0000000..0c1ab8d --- /dev/null +++ b/src/services/skill-detector.ts @@ -0,0 +1,244 @@ +/** + * Skill Auto-Detector + * + * Analyzes user prompts to automatically detect and activate + * relevant skills based on keywords, file extensions, and context. + * Skills are selected AFTER plans are approved and before agent execution. + */ + +import { + SKILL_DETECTION_KEYWORDS, + SKILL_AUTO_DETECT_THRESHOLD, + SKILL_AUTO_DETECT_MAX, +} from "@constants/skills"; +import type { SkillDefinition, AutoDetectedSkill } from "@/types/skills"; + +// ============================================================================ +// Keyword Matching +// ============================================================================ + +/** + * Score a prompt against the keyword detection table. + * Returns a map of skillId → { totalScore, matchedKeywords, category }. + */ +const scorePromptKeywords = ( + prompt: string, +): Map< + string, + { totalScore: number; matchedKeywords: string[]; category: string } +> => { + const lower = prompt.toLowerCase(); + const scores = new Map< + string, + { totalScore: number; matchedKeywords: string[]; category: string } + >(); + + for (const [keyword, skillId, category, weight] of SKILL_DETECTION_KEYWORDS) { + const keyLower = keyword.toLowerCase(); + + // Check for whole-word or phrase match + const hasMatch = matchKeyword(lower, keyLower); + if (!hasMatch) continue; + + const existing = scores.get(skillId); + if (existing) { + existing.totalScore = Math.min(1, existing.totalScore + weight * 0.3); + existing.matchedKeywords.push(keyword); + } else { + scores.set(skillId, { + totalScore: weight, + matchedKeywords: [keyword], + category, + }); + } + } + + return scores; +}; + +/** + * Check if a keyword appears in text (word-boundary aware) + */ +const matchKeyword = (text: string, keyword: string): boolean => { + // For short keywords (1-3 chars), require word boundaries + if (keyword.length <= 3) { + const regex = new RegExp(`\\b${escapeRegex(keyword)}\\b`, "i"); + return regex.test(text); + } + + // For file extensions, match exactly + if (keyword.startsWith(".")) { + return text.includes(keyword); + } + + // For longer keywords/phrases, simple includes is fine + return text.includes(keyword); +}; + +/** + * Escape special regex characters + */ +const escapeRegex = (str: string): string => { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +}; + +// ============================================================================ +// Context Analysis +// ============================================================================ + +/** + * Analyze file references in the prompt for additional skill signals + */ +const analyzeFileReferences = ( + prompt: string, +): Map => { + const signals = new Map(); + + // TypeScript/JavaScript files + if (/\.(ts|tsx)\b/.test(prompt)) { + signals.set("typescript", (signals.get("typescript") ?? 0) + 0.3); + } + if (/\.(jsx)\b/.test(prompt)) { + signals.set("react", (signals.get("react") ?? 0) + 0.4); + } + + // Style files + if (/\.(css|scss|sass|less)\b/.test(prompt)) { + signals.set("css-scss", (signals.get("css-scss") ?? 0) + 0.4); + } + + // Config files + if (/docker(file|-compose)|\.dockerfile/i.test(prompt)) { + signals.set("devops", (signals.get("devops") ?? 0) + 0.5); + } + if (/\.github\/workflows/i.test(prompt)) { + signals.set("devops", (signals.get("devops") ?? 0) + 0.5); + } + + // Test files + if (/\.(test|spec)\.(ts|tsx|js|jsx)\b/.test(prompt)) { + signals.set("testing", (signals.get("testing") ?? 0) + 0.5); + } + + // Database-related files + if (/\.(sql|prisma)\b/.test(prompt) || /migration/i.test(prompt)) { + signals.set("database", (signals.get("database") ?? 0) + 0.4); + } + + return signals; +}; + +// ============================================================================ +// Public API +// ============================================================================ + +/** + * Detect which skills should be activated for a given user prompt. + * Returns up to SKILL_AUTO_DETECT_MAX skills sorted by confidence. + * + * @param prompt - The user's message + * @param availableSkills - All registered skills to match against + * @returns Detected skills with confidence scores + */ +export const detectSkillsForPrompt = ( + prompt: string, + availableSkills: SkillDefinition[], +): AutoDetectedSkill[] => { + // Step 1: Score keywords + const keywordScores = scorePromptKeywords(prompt); + + // Step 2: Analyze file references for bonus signals + const fileSignals = analyzeFileReferences(prompt); + + // Step 3: Merge file signals into keyword scores + for (const [skillId, bonus] of fileSignals) { + const existing = keywordScores.get(skillId); + if (existing) { + existing.totalScore = Math.min(1, existing.totalScore + bonus); + } else { + keywordScores.set(skillId, { + totalScore: bonus, + matchedKeywords: [`(file pattern)`], + category: "file", + }); + } + } + + // Step 4: Match against available skills and filter by threshold + const detected: AutoDetectedSkill[] = []; + + for (const [skillId, score] of keywordScores) { + if (score.totalScore < SKILL_AUTO_DETECT_THRESHOLD) continue; + + // Find the matching skill definition + const skill = availableSkills.find( + (s) => s.id === skillId && s.autoTrigger !== false, + ); + if (!skill) continue; + + detected.push({ + skill, + confidence: Math.min(1, score.totalScore), + matchedKeywords: score.matchedKeywords, + category: score.category, + }); + } + + // Step 5: Sort by confidence and limit + detected.sort((a, b) => b.confidence - a.confidence); + return detected.slice(0, SKILL_AUTO_DETECT_MAX); +}; + +/** + * Build a skill injection prompt from detected skills. + * This is appended to the system prompt to give the agent + * specialized knowledge for the current task. + */ +export const buildSkillInjection = ( + detectedSkills: AutoDetectedSkill[], +): string => { + if (detectedSkills.length === 0) return ""; + + const parts: string[] = [ + "# Activated Skills", + "", + "The following specialized skills have been activated for this task. " + + "Use their guidelines and best practices when applicable:", + "", + ]; + + for (const { skill, confidence, matchedKeywords } of detectedSkills) { + parts.push(`## Skill: ${skill.name} (confidence: ${(confidence * 100).toFixed(0)}%)`); + parts.push(`Matched: ${matchedKeywords.join(", ")}`); + parts.push(""); + + if (skill.systemPrompt) { + parts.push(skill.systemPrompt); + parts.push(""); + } + + if (skill.instructions) { + parts.push(skill.instructions); + parts.push(""); + } + + parts.push("---"); + parts.push(""); + } + + return parts.join("\n"); +}; + +/** + * Format detected skills for logging/display + */ +export const formatDetectedSkills = ( + detectedSkills: AutoDetectedSkill[], +): string => { + if (detectedSkills.length === 0) return "No skills auto-detected."; + + const names = detectedSkills.map( + (d) => `${d.skill.name} (${(d.confidence * 100).toFixed(0)}%)`, + ); + return `Skills activated: ${names.join(", ")}`; +}; diff --git a/src/services/skill-registry.ts b/src/services/skill-registry.ts index 01819b8..179180a 100644 --- a/src/services/skill-registry.ts +++ b/src/services/skill-registry.ts @@ -3,16 +3,24 @@ * * Manages skill registration, matching, and invocation. * Uses progressive disclosure to load skills on demand. + * Merges built-in skills with external agents from .claude/, .github/, .codetyper/. */ import { SKILL_MATCHING, SKILL_LOADING, SKILL_ERRORS } from "@constants/skills"; import { loadAllSkills, loadSkillById } from "@services/skill-loader"; +import { loadExternalAgents } from "@services/external-agent-loader"; +import { + detectSkillsForPrompt, + buildSkillInjection, + formatDetectedSkills, +} from "@services/skill-detector"; import type { SkillDefinition, SkillMatch, SkillContext, SkillExecutionResult, SkillRegistryState, + AutoDetectedSkill, } from "@/types/skills"; // ============================================================================ @@ -21,6 +29,7 @@ import type { let registryState: SkillRegistryState = { skills: new Map(), + externalAgents: new Map(), lastLoadedAt: null, loadErrors: [], }; @@ -30,6 +39,7 @@ let registryState: SkillRegistryState = { */ export const getRegistryState = (): SkillRegistryState => ({ skills: new Map(registryState.skills), + externalAgents: new Map(registryState.externalAgents), lastLoadedAt: registryState.lastLoadedAt, loadErrors: [...registryState.loadErrors], }); @@ -48,17 +58,33 @@ const isCacheStale = (): boolean => { /** * Initialize skill registry with all available skills + * (built-in + user + project + external agents) */ export const initializeRegistry = async (): Promise => { try { - const skills = await loadAllSkills("metadata"); + // Load built-in and user/project skills + const skills = await loadAllSkills("full"); registryState.skills.clear(); + registryState.externalAgents.clear(); registryState.loadErrors = []; for (const skill of skills) { registryState.skills.set(skill.id, skill); } + // Load external agents from .claude/, .github/, .codetyper/ + try { + const externalAgents = await loadExternalAgents(); + for (const agent of externalAgents) { + registryState.externalAgents.set(agent.id, agent); + // Also register external agents as regular skills for unified matching + registryState.skills.set(agent.id, agent); + } + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + registryState.loadErrors.push(`External agents: ${msg}`); + } + registryState.lastLoadedAt = Date.now(); } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -362,6 +388,67 @@ export const executeFromInput = async ( }); }; +// ============================================================================ +// Auto-Detection +// ============================================================================ + +/** + * Auto-detect skills relevant to a user prompt. + * Analyzes the prompt content and returns matching skills + * sorted by confidence. + */ +export const autoDetectSkills = async ( + prompt: string, +): Promise => { + await refreshIfNeeded(); + const allSkills = getAllSkills(); + return detectSkillsForPrompt(prompt, allSkills); +}; + +/** + * Build a skill injection prompt for detected skills. + * This should be appended to the system prompt or inserted + * as a system message before the agent processes the prompt. + */ +export const buildSkillInjectionForPrompt = async ( + prompt: string, +): Promise<{ injection: string; detected: AutoDetectedSkill[] }> => { + const detected = await autoDetectSkills(prompt); + const injection = buildSkillInjection(detected); + return { injection, detected }; +}; + +/** + * Get a human-readable summary of detected skills for logging + */ +export const getDetectedSkillsSummary = ( + detected: AutoDetectedSkill[], +): string => { + return formatDetectedSkills(detected); +}; + +// ============================================================================ +// External Agent Access +// ============================================================================ + +/** + * Get all loaded external agents + */ +export const getExternalAgents = (): SkillDefinition[] => { + return Array.from(registryState.externalAgents.values()); +}; + +/** + * Get external agents by source + */ +export const getExternalAgentsBySource = ( + source: string, +): SkillDefinition[] => { + return Array.from(registryState.externalAgents.values()).filter( + (agent) => agent.source === source, + ); +}; + // ============================================================================ // Utility Functions // ============================================================================ diff --git a/src/skills/accessibility/SKILL.md b/src/skills/accessibility/SKILL.md new file mode 100644 index 0000000..b452a98 --- /dev/null +++ b/src/skills/accessibility/SKILL.md @@ -0,0 +1,58 @@ +--- +id: accessibility +name: Accessibility Expert +description: 'Expert in web accessibility (a11y), WCAG compliance, ARIA patterns, and assistive technology support.' +version: 1.0.0 +triggers: + - accessibility + - a11y + - wcag + - aria + - screen reader + - keyboard navigation +triggerType: auto +autoTrigger: true +requiredTools: + - read + - edit + - grep + - glob + - bash +tags: + - accessibility + - a11y + - frontend +--- + +## System Prompt + +You are an accessibility specialist who ensures web applications are usable by everyone, including people with disabilities. You follow WCAG 2.1 AA guidelines and test with assistive technologies. + +## Instructions + +### WCAG 2.1 AA Checklist +- **Perceivable**: Text alternatives for images, captions for video, sufficient color contrast (4.5:1 for text) +- **Operable**: Keyboard-accessible, no timing traps, no seizure-inducing content +- **Understandable**: Readable text, predictable navigation, input assistance +- **Robust**: Valid HTML, ARIA used correctly, works across assistive technologies + +### Semantic HTML +- Use `