From cac73bc1a9a70c51ec5ced0343d6e23bac2fd4ab Mon Sep 17 00:00:00 2001 From: codewithshinde Date: Wed, 1 Jul 2026 22:36:14 -0500 Subject: [PATCH 01/12] feat: add AWS Bedrock, Azure OpenAI, and OpenRouter provider support - Add native AWS Bedrock provider via @aws-sdk/client-bedrock-runtime with Converse/ConverseStream API support - Add Azure OpenAI provider type with deployment URL and apiVersion config - Add OpenRouter provider type with custom headers and reasoning deltas - Extend OpenAI-compatible provider with configurable auth headers, query params, chat completions path, and include_reasoning support - Add apiVersion and region fields to provider config schema and UI payloads - Add one-click dev setup script (scripts/dev-setup.sh) with macOS Electron auto-detection and setup:cursor variant - Add code-smells-and-tech-debt bundled skill (console logs, inline styles, missing types, targeted lint) - Add environment-and-secrets bundled skill (env template drift, secret safety) - Update audit-cleanup skill to include circular deps and engine checks - Update git-workflow-and-versioning with pre-commit hygiene helpers - Update README with cloud routing docs, provider table, and setup instructions - Bump version badge to 2.7.15 --- README.md | 35 +- bundled-skills/audit-cleanup/SKILL.md | 9 +- .../code-smells-and-tech-debt/SKILL.md | 31 ++ .../environment-and-secrets/SKILL.md | 28 ++ .../git-workflow-and-versioning/SKILL.md | 12 + bundled-skills/using-agent-skills/SKILL.md | 5 +- package-lock.json | 416 +++++++++++++++++- package.json | 18 +- scripts/dev-setup.sh | 28 ++ src/core/app/ThunderController.ts | 18 +- src/core/config/schema.ts | 5 + src/core/config/ui/mappers.ts | 9 +- src/core/config/ui/payloads.ts | 5 + src/core/config/vscode/read.ts | 2 + src/core/config/vscode/write.ts | 6 + src/core/llm/BedrockProvider.ts | 113 +++++ src/core/llm/LlmProviderRegistry.ts | 2 + src/core/llm/OpenAiCompatibleProvider.ts | 47 +- src/core/llm/UsageTrackingProvider.ts | 3 + src/core/llm/createProvider.ts | 43 ++ src/core/llm/index.ts | 2 + src/core/llm/providerPresets.ts | 27 +- src/core/llm/sseParser.ts | 8 +- src/core/llm/streamChunks.ts | 15 + src/core/llm/testConnection.ts | 78 +++- src/core/llm/types.ts | 10 + src/core/modes/agent/actSkillRouting.ts | 12 +- src/core/modes/plan/planSkillRouting.ts | 7 + src/core/orchestration/ChatOrchestrator.ts | 22 +- src/core/runtime/AgentLoop.ts | 16 +- src/core/runtime/PlanExecutor.ts | 11 +- src/vscode/webview/ThunderWebviewProvider.ts | 21 +- src/vscode/webview/messages.ts | 7 +- .../src/components/MarkdownMessage.tsx | 2 +- src/webview-ui/src/components/MessageList.tsx | 36 +- .../src/components/SettingsPanel.tsx | 53 ++- src/webview-ui/src/components/ThinkingRow.tsx | 16 + src/webview-ui/src/state/store.ts | 5 +- test/agent-loop.test.ts | 9 +- test/ask-plan-modes.test.ts | 5 +- test/features.test.ts | 74 ++++ 41 files changed, 1184 insertions(+), 87 deletions(-) create mode 100644 bundled-skills/code-smells-and-tech-debt/SKILL.md create mode 100644 bundled-skills/environment-and-secrets/SKILL.md create mode 100755 scripts/dev-setup.sh create mode 100644 src/core/llm/BedrockProvider.ts create mode 100644 src/core/llm/streamChunks.ts create mode 100644 src/webview-ui/src/components/ThinkingRow.tsx diff --git a/README.md b/README.md index 93032c56..75ede20e 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ License: AGPL v3 VS Code 1.85+ Node 20+ - Version 2.7.7 + Version 2.7.15 Website Docs

@@ -31,7 +31,7 @@ Mitii is built for developers who want an AI agent that understands the repo before changing it. It runs inside VS Code, indexes your workspace locally, plans work before execution, asks for approval when risk is involved, and keeps a useful trail of memory, checkpoints, logs, and task plans. -Use it with Ollama, LM Studio, OpenAI-compatible endpoints, OpenAI, Anthropic, Gemini, DeepSeek, Cursor-compatible APIs, Codex-compatible APIs, or the Echo provider for UI testing. +Use it with Ollama, LM Studio, OpenAI-compatible endpoints, native OpenRouter, Azure OpenAI, AWS Bedrock, OpenAI, Anthropic, Gemini, DeepSeek, Cursor-compatible APIs, Codex-compatible APIs, or the Echo provider for UI testing. **Docs:** [docs.mitii.dev](https://docs.mitii.dev) **Website:** [mitii.dev](https://mitii.dev) @@ -323,6 +323,7 @@ This section is written carefully. Models and products change fast. Mitii does n | Long-running tasks | Auto-continue, task-state persistence, session history, and wake-up checkpoints | | Teams that need guardrails | Approval modes, autonomy presets, untrusted workspace blocking, dangerous-command blocking | | Local model setups | Ollama and OpenAI-compatible providers are first-class | +| Cloud routing | Native OpenRouter headers/reasoning, Azure OpenAI deployment URLs, and AWS Bedrock Converse support are built in | | Custom internal tools | MCP support without skipping Mitii's policy layer | | VS Code users | No need to move to a separate AI editor | @@ -367,18 +368,17 @@ This is why Mitii focuses on visible plans, local logs, checkpoints, and verific | npm | 9+ | ```bash -git clone https://github.com/codewithshinde/thunder-ai-agent.git -cd thunder-ai-agent -npm install -npm run compile +git clone https://github.com/codewithshinde/mitii-ai-agent.git +cd mitii-ai-agent +npm run setup ``` -Press **F5** in VS Code to launch the Extension Development Host. Open a folder, wait for the indexing status in the Mitii sidebar, then start chatting. +`npm run setup` installs dependencies, compiles the extension and webview, rebuilds native modules for VS Code on macOS, and rebuilds local Node native modules for tests. Press **F5** in VS Code to launch the Extension Development Host. Open a folder, wait for the indexing status in the Mitii sidebar, then start chatting. ### Connect A Model 1. Open **Settings** in the Mitii sidebar, or VS Code settings under `Mitii AI Agent`. -2. Set `thunder.provider.type` to `openai-compatible`. +2. Set `thunder.provider.type` to `openai-compatible`, `openrouter`, `azure-openai`, or another supported provider. 3. Point `thunder.provider.baseUrl` at your endpoint. The default is `http://localhost:11434/v1` for Ollama. 4. Set `thunder.provider.model`. The default is `qwen3-coder:30b`. @@ -389,7 +389,10 @@ Use the Echo provider for UI testing without an LLM. API keys are stored through | Provider | Default model | Notes | |---|---|---| | OpenAI-compatible | `qwen3-coder:30b` | Ollama, LM Studio, vLLM, local gateways | +| OpenRouter | `anthropic/claude-sonnet-4` | Native headers and reasoning deltas | | OpenAI | `gpt-4.1` | API key required | +| Azure OpenAI | `your-deployment-name` | API key required; model field is the deployment name; uses `thunder.provider.apiVersion` | +| AWS Bedrock | `anthropic.claude-3-5-sonnet-20240620-v1:0` | Uses AWS default credential chain and `thunder.provider.region`; tool calls disabled by default | | Anthropic | `claude-sonnet-4-20250514` | API key required | | Gemini | `gemini-2.0-flash` | API key required | | DeepSeek | `deepseek-chat` | API key required | @@ -422,6 +425,8 @@ Use the Echo provider for UI testing without an LLM. API keys are stored through "thunder.provider.type": "openai-compatible", "thunder.provider.baseUrl": "http://localhost:11434/v1", "thunder.provider.model": "qwen3-coder:30b", + "thunder.provider.apiVersion": "2024-10-21", + "thunder.provider.region": "us-east-1", "thunder.provider.contextWindow": 8192, "thunder.safety.autonomyPreset": "guided", "thunder.safety.approvalMode": "review_all", @@ -511,6 +516,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, project layout, testing, and p ```bash npm run watch # extension + webview hot rebuild +npm run setup # one-click local dev setup +npm run setup:cursor # setup using Cursor Electron runtime on macOS npm run test # unit tests npm run lint # typecheck npm run smoke # smoke tests @@ -529,6 +536,14 @@ VS Code and Cursor ship their own Electron runtime, so native modules may need a | Local Vitest runs | `npm run rebuild:node` | | Everything | `npm run rebuild:all` | +On Linux and Windows, Electron version auto-detection is not available. Set the version explicitly: + +```bash +THUNDER_ELECTRON_VERSION= npm run rebuild:native +``` + +For example, use the Electron version shipped by your VS Code or Cursor build. + ### Useful Audit Scripts ```bash @@ -538,8 +553,12 @@ npm run check:circular-deps npm run audit:engines npm run find:console npm run find:inline-styles +npm run check:missing-types +npm run env:sync ``` +Bundled skills orchestrate these scripts instead of replacing them. `audit-cleanup` runs dependency/dead-code/cycle/engine audits, `code-smells-and-tech-debt` covers console logs, inline styles, missing types, and targeted lint checks, and `environment-and-secrets` compares env templates without exposing secret values. + --- ## Troubleshooting diff --git a/bundled-skills/audit-cleanup/SKILL.md b/bundled-skills/audit-cleanup/SKILL.md index e4cf41ba..0f2ef03d 100644 --- a/bundled-skills/audit-cleanup/SKILL.md +++ b/bundled-skills/audit-cleanup/SKILL.md @@ -14,12 +14,15 @@ Scripts use AST parsing and finish in **~3s**. 1. `execute_workspace_script({ script: "audit-dependencies.mjs" })` — depcheck, all deps at once 2. `execute_workspace_script({ script: "audit-dead-code.sh" })` — knip: unused files, deps, exports -3. read_file `package.json` only if scripts are unavailable -4. Classify: **high** (safe), **medium** (likely), **low** (review) -5. Plan mode: report only. Act mode: remove after user confirms +3. `execute_workspace_script({ script: "check-circular-deps.mjs" })` — dependency cycles and import graph risks +4. `execute_workspace_script({ script: "audit-package-engines.mjs" })` — Node/npm/VS Code engine drift +5. read_file `package.json` only if scripts are unavailable +6. Classify: **high** (safe), **medium** (likely), **low** (review) +7. Plan mode: report only. Act mode: remove after user confirms ## Do NOT - spawn_research_agent to grep each dependency - search package-by-package through 18 prod + 46 dev deps - re-run depcheck after script output is in chat history +- replace deterministic scripts with LLM-only investigation diff --git a/bundled-skills/code-smells-and-tech-debt/SKILL.md b/bundled-skills/code-smells-and-tech-debt/SKILL.md new file mode 100644 index 00000000..e92e4138 --- /dev/null +++ b/bundled-skills/code-smells-and-tech-debt/SKILL.md @@ -0,0 +1,31 @@ +--- +name: code-smells-and-tech-debt +description: Find and classify console logs, inline styles, missing type annotations, and targeted lint issues. Use for tech-debt cleanup, lint hygiene, console.log removal, style cleanup, and missing TypeScript types. +--- + +# Code Smells and Tech Debt + +Use deterministic scripts first, then inspect only the files that matter. Do not run broad manual grep before the scripts have summarized the workspace. + +## Steps + +1. `execute_workspace_script({ script: "find-console-logs.sh" })` — report committed debugging logs and risky console usage +2. `execute_workspace_script({ script: "find-inline-styles.sh" })` — report inline style usage that may violate UI conventions +3. `execute_workspace_script({ script: "check-missing-types.sh" })` — report missing annotations and weak typing hotspots +4. `execute_workspace_script({ script: "safe-lint-target.sh", args: [""] })` — run targeted lint/type checks only after choosing touched files +5. Classify findings: + - **fix now**: unsafe logs, obvious type holes, lint errors in touched files + - **defer**: broad refactors, generated files, low-risk style cleanup outside scope + - **ignore**: intentional diagnostics, examples, tests where console output is asserted + +## Mode Rules + +- Plan mode: report findings, risk, and proposed fix order only. +- Act mode: make scoped fixes after the task explicitly asks for cleanup or after the user approves the finding list. +- Keep behavioral changes separate from mechanical cleanup unless the cleanup is required to fix the bug. + +## Do NOT + +- edit generated files or vendored code +- convert every inline style during an unrelated task +- rerun the same script when its fresh output is already present in chat history diff --git a/bundled-skills/environment-and-secrets/SKILL.md b/bundled-skills/environment-and-secrets/SKILL.md new file mode 100644 index 00000000..fdcaeaf8 --- /dev/null +++ b/bundled-skills/environment-and-secrets/SKILL.md @@ -0,0 +1,28 @@ +--- +name: environment-and-secrets +description: Safely inspect environment variable templates, missing keys, and secret setup without exposing secret values. Use for .env, env.example, missing environment variable, API key, token, and secret configuration tasks. +--- + +# Environment and Secrets + +Secrets are operational data, not chat content. Report key names and file paths, never values. + +## Steps + +1. `execute_workspace_script({ script: "sync-env-files.mjs" })` — compare `.env*` files with templates and report missing keys +2. Read `.env.example`, `.env.template`, or documented config files when script output points to them. +3. Report missing keys by name only, grouped by file. +4. Guide the user to fill local `.env` files from committed examples. +5. If code changes are needed, update validation, docs, or examples without committing real credentials. + +## Safety Rules + +- Never print, summarize, or transform secret values. +- Never copy values from `.env` into docs, tests, logs, prompts, or generated files. +- Prefer placeholder values such as `YOUR_API_KEY_HERE`. +- If a secret is already exposed in tracked files, stop and report it as a security concern. + +## Mode Rules + +- Plan mode: produce a remediation checklist only. +- Act mode: update examples, validation, and docs; do not create real secrets. diff --git a/bundled-skills/git-workflow-and-versioning/SKILL.md b/bundled-skills/git-workflow-and-versioning/SKILL.md index d17d7995..fd23c0a3 100644 --- a/bundled-skills/git-workflow-and-versioning/SKILL.md +++ b/bundled-skills/git-workflow-and-versioning/SKILL.md @@ -210,6 +210,18 @@ This pattern catches wrong assumptions early and gives reviewers a clear map of ## Pre-Commit Hygiene +When Mitii workspace scripts are available, run the safety helpers before commit-sensitive work: + +```bash +npm run git:untracked +npm run checkpoint:write +npm run checkpoint:read +``` + +- `list-untracked-files.sh` before committing so new files are intentionally included or ignored. +- `write-checkpoint.sh` before an approval pause or risky edit batch. +- `read-checkpoint.sh` on resume so the next step starts from the saved state rather than stale memory. + Before every commit: ```bash diff --git a/bundled-skills/using-agent-skills/SKILL.md b/bundled-skills/using-agent-skills/SKILL.md index 975fb5c2..c93834be 100644 --- a/bundled-skills/using-agent-skills/SKILL.md +++ b/bundled-skills/using-agent-skills/SKILL.md @@ -160,7 +160,7 @@ For a complete feature, the typical skill sequence is: 16. shipping-and-launch → Deploy safely ``` -Not every task needs every skill. A bug fix might only need: `debugging-and-error-recovery` → `test-driven-development` → `code-review-and-quality`. +Not every task needs every skill. A bug fix might only need: `debugging-and-error-recovery` → `test-driven-development` → `code-review-and-quality`. A cleanup task might need: `audit-cleanup` → `code-smells-and-tech-debt` → `git-workflow-and-versioning`. ## Quick Reference @@ -179,9 +179,12 @@ Not every task needs every skill. A bug fix might only need: `debugging-and-erro | Verify | test-driven-development | Failing test first, then make it pass | | Verify | browser-testing-with-devtools | Chrome DevTools MCP for runtime verification | | Verify | debugging-and-error-recovery | Reproduce → localize → fix → guard | +| Verify | audit-cleanup | Script-first dependency, dead-code, cycle, and engines audit | +| Verify | code-smells-and-tech-debt | Console logs, inline styles, missing types, and targeted lint cleanup | | Review | code-review-and-quality | Five-axis review with quality gates | | Review | code-simplification | Preserve behavior while reducing unnecessary complexity | | Review | security-and-hardening | OWASP prevention, input validation, least privilege | +| Review | environment-and-secrets | Env/template drift and secret handling without exposing values | | Review | performance-optimization | Measure first, optimize only what matters | | Ship | git-workflow-and-versioning | Atomic commits, clean history | | Ship | ci-cd-and-automation | Automated quality gates on every change | diff --git a/package-lock.json b/package-lock.json index faff148e..2c25c304 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "mitii-ai-agent", - "version": "2.7.15", + "version": "2.7.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mitii-ai-agent", - "version": "2.7.15", + "version": "2.7.16", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "dependencies": { + "@aws-sdk/client-bedrock-runtime": "^3.1078.0", "@lancedb/lancedb": "^0.30.0", "@modelcontextprotocol/sdk": "^1.29.0", "@vscode/ripgrep": "^1.15.9", @@ -56,6 +57,330 @@ "web-tree-sitter": "^0.24.7" } }, + "node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1078.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1078.0.tgz", + "integrity": "sha512-GGIpsHOk+zMRQMgxd+5D7Kfhpe6qzyGP4shGzb7NwYqAEleCW9PgX5xXuVQEESkhGX5kqMkDPfT+gg6PN2gczg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.26", + "@aws-sdk/credential-provider-node": "^3.972.61", + "@aws-sdk/eventstream-handler-node": "^3.972.25", + "@aws-sdk/middleware-eventstream": "^3.972.21", + "@aws-sdk/middleware-websocket": "^3.972.34", + "@aws-sdk/token-providers": "3.1078.0", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/fetch-http-handler": "^5.6.2", + "@smithy/node-http-handler": "^4.9.2", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.974.26", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.26.tgz", + "integrity": "sha512-wRj7Pthvjk3anees97pUWlxlTa0DUjeGrEQU5fKDZVdWZV0ekaprbof0df2uaE9g8u67t035v2j+ne2AW2UMkA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.15", + "@aws-sdk/xml-builder": "^3.972.33", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/core": "^3.29.0", + "@smithy/signature-v4": "^5.6.1", + "@smithy/types": "^4.15.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.52", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.52.tgz", + "integrity": "sha512-sxuaHZGHqOgKB8OdL3doXa1NJjqmO60FPfyTnYVKGjX9taRsIEGS9pd+2yALmo06hijZ8L94uSK0kfXZsRmVyA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.26", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.54", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.54.tgz", + "integrity": "sha512-e6yz52nq3SpR1oPLcvfsDM7H7k2gIYk/NSn/rwsFqzGXEwr3g0mRMlPbLaKCPCGNZJMU/gZg6/64B3eSam+gBw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.26", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/fetch-http-handler": "^5.6.2", + "@smithy/node-http-handler": "^4.9.2", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.59", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.59.tgz", + "integrity": "sha512-9Um/UpruN76AdpiLnvwChVkJJwJ9Vx9ykk/2AeLxxSCM/YYRD8Kkq2towUk9fZQLV7dd9ATlsi87U7hKs0z/iQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.26", + "@aws-sdk/credential-provider-env": "^3.972.52", + "@aws-sdk/credential-provider-http": "^3.972.54", + "@aws-sdk/credential-provider-login": "^3.972.58", + "@aws-sdk/credential-provider-process": "^3.972.52", + "@aws-sdk/credential-provider-sso": "^3.972.58", + "@aws-sdk/credential-provider-web-identity": "^3.972.58", + "@aws-sdk/nested-clients": "^3.997.26", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/credential-provider-imds": "^4.4.5", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.58", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.58.tgz", + "integrity": "sha512-H3q96qF8/DJsPsXMVtMRqSWOc85K5O4zos32untdw+vE5vw0f3a6qJo1YqbND4BsEIKd4iZmzzVUq9kV4LjbHg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.26", + "@aws-sdk/nested-clients": "^3.997.26", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.61", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.61.tgz", + "integrity": "sha512-2U2KHMRCt1dlZoLU3KZR5g5EL4b0h2HHw96SkaUBK7qvEXPZj5rGRO/3ZTeJmh37dIYQuCnA2273rZOQvmsiHw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.52", + "@aws-sdk/credential-provider-http": "^3.972.54", + "@aws-sdk/credential-provider-ini": "^3.972.59", + "@aws-sdk/credential-provider-process": "^3.972.52", + "@aws-sdk/credential-provider-sso": "^3.972.58", + "@aws-sdk/credential-provider-web-identity": "^3.972.58", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/credential-provider-imds": "^4.4.5", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.52", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.52.tgz", + "integrity": "sha512-Aff9Ebs42lz+Ep1wkS+Nlwh5S0eahakpyskPsuKGjiBJ6ExOjNtxbfKJTKovQtQNgJ7oG1BH6esJwGrbs7qgSA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.26", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.58", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.58.tgz", + "integrity": "sha512-syloC58mXOacUqM2toPNfwd7X3jT+tWj0F/cN7qdW1FQyI0q41J0tPf6DIZ56BF0x82iS9j3ALP45MoBz79YuQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.26", + "@aws-sdk/nested-clients": "^3.997.26", + "@aws-sdk/token-providers": "3.1078.0", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.58", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.58.tgz", + "integrity": "sha512-pTBImKzcGK+pcMKjL0fAJbnYzzYd1c0UDc7BSIOGNQhF9Nuk66vWlIXfYTYyzNSs+w8Q/vfbbNDDU8zdrouwLg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.26", + "@aws-sdk/nested-clients": "^3.997.26", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.25", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.25.tgz", + "integrity": "sha512-df7HN1ozwMrB9+59re9PM7tSLxLAcheMWc5u/KyfCPCAWtN/vP7y7RTUZOy48uT1K9MESisVeOPPzF3O1AW01A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.21", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.21.tgz", + "integrity": "sha512-HvLgDnxBLaHi9E5K++6Vuk+1+qqn7Pmn8zrlzd+NXH3jBzwujnuzZtAR9WHPkbUGPO92FkoQWj/M1IsdxTlBmQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.34", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.34.tgz", + "integrity": "sha512-8dxKLu5bC74SLwwoYV8RIiCD48jMbMt1Ccl3m+xtQJKet6QsZ4xzJlK6UDg7QNEzm/ZCUknJfGsBHmhkgOfuIQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.26", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/fetch-http-handler": "^5.6.2", + "@smithy/signature-v4": "^5.6.1", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.26", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.26.tgz", + "integrity": "sha512-Lwe3F6K7bs+jEubp1LbrvzeMBYb5fMazJ1IxV9TtKWPF8CSh67Fmwyq9fLz3NL/k55Dfpuph5Dimw76JFgr+SA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.26", + "@aws-sdk/signature-v4-multi-region": "^3.996.38", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/fetch-http-handler": "^5.6.2", + "@smithy/node-http-handler": "^4.9.2", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.38.tgz", + "integrity": "sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.15", + "@smithy/signature-v4": "^5.6.1", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1078.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1078.0.tgz", + "integrity": "sha512-/uyXLBGu3Lw1GbBA2X66hcOMnKtMcqAIF+3/eHfxBQmUeXF2sdqozDPrTfEr/TnSd0D6deZar+eVyhEqqWu29w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.26", + "@aws-sdk/nested-clients": "^3.997.26", + "@aws-sdk/types": "^3.973.15", + "@smithy/core": "^3.29.0", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.973.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.15.tgz", + "integrity": "sha512-IULn8uBV/SMtmOIANsm4WHXIOtVPBWfOWs3WGL0j/sI+KhaYehvOw0ET+9urnn8MBpiijuU/0JOpuwKOE451PQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.33", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.33.tgz", + "integrity": "sha512-ezbwz9WpuLctm6o7P2t2naDhVVPI5jFGrVefVybhcKGjU57VIyT46pQVO0RI2RYkUdhdj2Z9uSIlAzGZE9NW9A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@azure/abort-controller": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", @@ -2741,6 +3066,87 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@smithy/core": { + "version": "3.29.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.0.tgz", + "integrity": "sha512-sEvpvkBVoMxjoek35XyJFn2ZD3EJ1RpiZrT47WaZodxzAIWS44zkdvbqGE/ZlugtjiQp62cffYZ9ldyRkjAGnA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.5.tgz", + "integrity": "sha512-LnjUTNG0GgQlKIq7IioeOrPaEmC5xOd1WtAz24TLSiYQnWX2uHr53GrFuQhkrJBktPYCMga/NbUOW7hFbSA2Cg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.29.0", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.2.tgz", + "integrity": "sha512-q96PSDOAGw+X+nuELd7Cjebps0SYr+YlPbviEX9sLVw+VM4M7VV8hn1nL1mGS6urDu33eQ5A7WhlphaDO6kUyQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.29.0", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.9.2", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.2.tgz", + "integrity": "sha512-s0yAIRj6TVfHgl+QzVyqal1KMGZ9B5512IrxKc6+dOpw8fUmFL3CvuAhjv0J+aNjUPfVZ2IhqPEDvkB5Ncx9oA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.29.0", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.1.tgz", + "integrity": "sha512-SqvuP75p/DmgWWI7jv4kf/UW+V4LFmlUn19s604SgAcRuJRB1vDnWwzZMYCLUcmKxko9wDn6iLgGEIpTNgZbIQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.29.0", + "@smithy/types": "^4.15.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.15.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.15.1.tgz", + "integrity": "sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@swc/helpers": { "version": "0.5.23", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", @@ -4386,6 +4792,12 @@ "dev": true, "license": "ISC" }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", diff --git a/package.json b/package.json index 1290f70a..f74dac40 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "mitii-ai-agent", "displayName": "Mitii AI Agent", "description": "Local-first VS Code AI coding agent with precise repo context and safe Plan/Act workflow", - "version": "2.7.15", + "version": "2.7.16", "publisher": "mitii", "icon": "media/mitii-short-logo.png", "license": "AGPL-3.0-or-later", @@ -146,7 +146,10 @@ "type": "string", "enum": [ "openai-compatible", + "openrouter", "openai", + "azure-openai", + "bedrock", "anthropic", "gemini", "deepseek", @@ -167,6 +170,16 @@ "default": "qwen3-coder:30b", "description": "Model name" }, + "thunder.provider.apiVersion": { + "type": "string", + "default": "2024-10-21", + "description": "Provider API version. Used by Azure OpenAI when building deployment chat completion URLs." + }, + "thunder.provider.region": { + "type": "string", + "default": "us-east-1", + "description": "Cloud provider region. Used by AWS Bedrock." + }, "thunder.provider.contextWindow": { "type": "number", "default": 8192, @@ -518,6 +531,8 @@ "scripts": { "vscode:prepublish": "npm run compile", "compile": "npm run compile:extension && npm run compile:webview", + "setup": "bash scripts/dev-setup.sh", + "setup:cursor": "THUNDER_EDITOR=cursor bash scripts/dev-setup.sh", "rebuild:native": "node scripts/rebuild-native.mjs", "rebuild:node": "npm rebuild better-sqlite3", "rebuild:all": "npm run rebuild:native && npm run rebuild:node", @@ -579,6 +594,7 @@ "vitest": "^1.6.0" }, "dependencies": { + "@aws-sdk/client-bedrock-runtime": "^3.1078.0", "@lancedb/lancedb": "^0.30.0", "@modelcontextprotocol/sdk": "^1.29.0", "@vscode/ripgrep": "^1.15.9", diff --git a/scripts/dev-setup.sh b/scripts/dev-setup.sh new file mode 100755 index 00000000..a517a512 --- /dev/null +++ b/scripts/dev-setup.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." + +editor="${THUNDER_EDITOR:-vscode}" + +echo "Installing dependencies..." +npm install + +echo "Compiling extension and webview..." +npm run compile + +if [[ "$(uname -s)" == "Darwin" ]]; then + echo "Rebuilding native modules for ${editor}..." + THUNDER_EDITOR="${editor}" npm run rebuild:native +else + cat <<'NOTE' +Skipping Electron native rebuild auto-detection on this OS. +Set THUNDER_ELECTRON_VERSION for your editor, then run: + THUNDER_ELECTRON_VERSION= npm run rebuild:native +NOTE +fi + +echo "Rebuilding native modules for local Node tests..." +npm run rebuild:node + +echo "Setup complete. Press F5 in VS Code to launch the Extension Development Host." diff --git a/src/core/app/ThunderController.ts b/src/core/app/ThunderController.ts index f68ac35a..abf5b814 100644 --- a/src/core/app/ThunderController.ts +++ b/src/core/app/ThunderController.ts @@ -39,7 +39,7 @@ import { } from '../tools/builtinTools'; import { ProjectCatalogContextSource, discoverProjectCatalog, saveProjectCatalog } from '../modes/ask'; import { createMarkStepCompleteTool, createProposePlanMutationTool } from '../tools/planTools'; -import type { LlmProvider } from '../llm/types'; +import type { AssistantStreamChunk, LlmProvider } from '../llm/types'; import { UsageTrackingProvider, type ModelCallUsage } from '../llm/UsageTrackingProvider'; import { scaffoldMitiiWorkspace } from '../mcp/scaffoldMitiiWorkspace'; import { AgentTaskState } from '../runtime/AgentTaskState'; @@ -934,6 +934,8 @@ export class ThunderController { providerType: config.provider.type, baseUrl: config.provider.baseUrl, model: config.provider.model, + apiVersion: config.provider.apiVersion, + region: config.provider.region, contextWindow: config.provider.contextWindow, indexingEnabled: config.indexing.enabled, approvalMode: config.safety.approvalMode, @@ -1473,7 +1475,7 @@ export class ThunderController { content: string, recentMessages: Array<{ role: 'user' | 'assistant'; content: string }> = [], options?: { preserveActivity?: boolean; pinnedContext?: PinnedContextView[] } - ): Promise> { + ): Promise> { if (!this.session) throw normalizeError(new Error('Session not initialized')); const provider = await this.resolveProviderForMode(this.session.mode); if (!provider) throw normalizeError(new Error('No LLM provider configured')); @@ -1801,7 +1803,7 @@ export class ThunderController { return this.chatOrchestrator?.hasSuspendState() ?? false; } - resumeAfterApproval(): AsyncIterable { + resumeAfterApproval(): AsyncIterable { this.ensureChatOrchestrator(); if (!this.chatOrchestrator) { return (async function* empty() {})(); @@ -1965,6 +1967,8 @@ export class ThunderController { const providerType = settings?.providerType ?? config.provider.type; const baseUrl = settings?.baseUrl.trim() || config.provider.baseUrl; const model = settings?.model.trim() || config.provider.model; + const apiVersion = settings?.apiVersion?.trim() || config.provider.apiVersion; + const region = settings?.region?.trim() || config.provider.region; const requestedContextWindow = settings?.contextWindow ? Math.max(1024, Math.min(settings.contextWindow, 1_000_000)) : config.provider.contextWindow; @@ -1982,6 +1986,8 @@ export class ThunderController { providerType, baseUrl, model, + apiVersion, + region, contextWindow, connectionOk: true, connectionStatus: 'Echo mode — no LLM needed. Responses are mirrored for UI testing.', @@ -1994,7 +2000,9 @@ export class ThunderController { providerType as import('../config/schema').ProviderType, baseUrl, model, - apiKey + apiKey, + apiVersion, + region ); this.notifyUi({ @@ -2003,6 +2011,8 @@ export class ThunderController { providerType, baseUrl, model, + apiVersion, + region, contextWindow, connectionOk: result.ok, connectionStatus: result.message, diff --git a/src/core/config/schema.ts b/src/core/config/schema.ts index cdb12cf3..7a8e10b3 100644 --- a/src/core/config/schema.ts +++ b/src/core/config/schema.ts @@ -2,7 +2,10 @@ import { z } from 'zod'; export const ProviderTypeSchema = z.enum([ 'openai-compatible', + 'openrouter', 'openai', + 'azure-openai', + 'bedrock', 'anthropic', 'gemini', 'deepseek', @@ -15,6 +18,8 @@ export const ProviderConfigSchema = z.object({ type: ProviderTypeSchema.default('echo'), baseUrl: z.string().url().default('http://localhost:11434/v1'), model: z.string().default('qwen3-coder:30b'), + apiVersion: z.string().default('2024-10-21'), + region: z.string().default('us-east-1'), apiKeyRef: z.string().default('thunder.apiKey'), contextWindow: z.number().int().positive().default(8192), supportsStreaming: z.boolean().default(true), diff --git a/src/core/config/ui/mappers.ts b/src/core/config/ui/mappers.ts index 4545c4e1..6c4d4d39 100644 --- a/src/core/config/ui/mappers.ts +++ b/src/core/config/ui/mappers.ts @@ -23,7 +23,7 @@ export function normalizeProviderSettings( previousContextWindow: number ): ProviderSettingsPayload { const model = settings.model.trim(); - return { + const normalized: ProviderSettingsPayload = { providerType: settings.providerType, baseUrl: settings.baseUrl.trim(), model, @@ -34,6 +34,13 @@ export function normalizeProviderSettings( previousContextWindow ), }; + if (settings.apiVersion !== undefined) { + normalized.apiVersion = settings.apiVersion.trim(); + } + if (settings.region !== undefined) { + normalized.region = settings.region.trim(); + } + return normalized; } export function normalizeAgentSettings(settings: AgentSettingsPayload): AgentSettingsPayload { diff --git a/src/core/config/ui/payloads.ts b/src/core/config/ui/payloads.ts index 3f9c1a00..3081b776 100644 --- a/src/core/config/ui/payloads.ts +++ b/src/core/config/ui/payloads.ts @@ -4,7 +4,10 @@ export type AgentDepthView = 'auto' | 'quick' | 'standard' | 'deep' | 'pilot' | export type ProviderTypeView = | 'echo' | 'openai-compatible' + | 'openrouter' | 'openai' + | 'azure-openai' + | 'bedrock' | 'anthropic' | 'gemini' | 'deepseek' @@ -15,6 +18,8 @@ export interface ProviderSettingsPayload { providerType: ProviderTypeView; baseUrl: string; model: string; + apiVersion?: string; + region?: string; contextWindow: number; } diff --git a/src/core/config/vscode/read.ts b/src/core/config/vscode/read.ts index f14bb0db..6bf6427f 100644 --- a/src/core/config/vscode/read.ts +++ b/src/core/config/vscode/read.ts @@ -14,6 +14,8 @@ export function readThunderConfigFromSettings(): ThunderConfig { type: config.get('provider.type'), baseUrl: config.get('provider.baseUrl'), model: config.get('provider.model'), + apiVersion: config.get('provider.apiVersion'), + region: config.get('provider.region'), apiKeyRef: config.get('provider.apiKeyRef'), contextWindow: config.get('provider.contextWindow'), supportsStreaming: config.get('provider.supportsStreaming'), diff --git a/src/core/config/vscode/write.ts b/src/core/config/vscode/write.ts index 2232b1cf..df67f4f3 100644 --- a/src/core/config/vscode/write.ts +++ b/src/core/config/vscode/write.ts @@ -17,6 +17,12 @@ export async function updateProviderSettings(settings: ProviderSettingsPayload): await config.update('provider.type', settings.providerType, target); await config.update('provider.baseUrl', settings.baseUrl.trim(), target); await config.update('provider.model', settings.model.trim(), target); + if (settings.apiVersion !== undefined) { + await config.update('provider.apiVersion', settings.apiVersion.trim(), target); + } + if (settings.region !== undefined) { + await config.update('provider.region', settings.region.trim(), target); + } await config.update('provider.contextWindow', settings.contextWindow, target); } diff --git a/src/core/llm/BedrockProvider.ts b/src/core/llm/BedrockProvider.ts new file mode 100644 index 00000000..b7ad1db8 --- /dev/null +++ b/src/core/llm/BedrockProvider.ts @@ -0,0 +1,113 @@ +import { + BedrockRuntimeClient, + ConverseCommand, + ConverseStreamCommand, + type Message, + type SystemContentBlock, +} from '@aws-sdk/client-bedrock-runtime'; +import type { ChatDelta, ChatMessage, ChatRequest, LlmProvider, ModelCapabilities } from './types'; +import { normalizeProviderError } from './errors'; +import { estimateTokensAsync } from './tokenEstimate'; + +export interface BedrockProviderConfig { + region: string; + model: string; + capabilities?: Partial; +} + +export class BedrockProvider implements LlmProvider { + readonly id = 'bedrock'; + readonly capabilities: ModelCapabilities; + private readonly client: BedrockRuntimeClient; + + constructor(private readonly config: BedrockProviderConfig) { + this.capabilities = { + contextWindow: config.capabilities?.contextWindow ?? 200_000, + supportsStreaming: config.capabilities?.supportsStreaming ?? true, + supportsTools: false, + supportsEmbeddings: false, + }; + this.client = new BedrockRuntimeClient({ region: config.region || 'us-east-1' }); + } + + async *complete(request: ChatRequest): AsyncIterable { + const input = { + modelId: request.model ?? this.config.model, + ...formatBedrockMessages(request.messages), + inferenceConfig: { + temperature: request.temperature ?? 0.2, + maxTokens: request.maxTokens, + }, + }; + + try { + if (request.stream === false) { + const response = await this.client.send(new ConverseCommand(input)); + const content = response.output?.message?.content + ?.map((block) => block.text ?? '') + .join('') ?? ''; + if (content) yield { content }; + yield { done: true, finish_reason: response.stopReason }; + return; + } + + const response = await this.client.send(new ConverseStreamCommand(input)); + for await (const event of response.stream ?? []) { + const text = event.contentBlockDelta?.delta?.text; + if (text) yield { content: text }; + if (event.messageStop?.stopReason) { + yield { done: true, finish_reason: event.messageStop.stopReason }; + return; + } + } + yield { done: true }; + } catch (error) { + throw normalizeProviderError(error); + } + } + + async countTokens(text: string): Promise { + return estimateTokensAsync(text); + } +} + +function formatBedrockMessages(messages: ChatMessage[]): { + messages: Message[]; + system?: SystemContentBlock[]; +} { + const system: SystemContentBlock[] = []; + const out: Message[] = []; + + for (const message of messages) { + if (message.role === 'system') { + if (message.content.trim()) system.push({ text: message.content }); + continue; + } + + const role = message.role === 'assistant' ? 'assistant' : 'user'; + const prefix = message.role === 'tool' + ? `Tool result${message.name ? ` from ${message.name}` : ''}:\n` + : ''; + const text = `${prefix}${message.content}`.trim(); + if (!text) continue; + + const previous = out[out.length - 1]; + if (previous?.role === role) { + previous.content?.push({ text }); + } else { + out.push({ + role, + content: [{ text }], + }); + } + } + + if (out.length === 0) { + out.push({ role: 'user', content: [{ text: '' }] }); + } + + return { + messages: out, + ...(system.length > 0 ? { system } : {}), + }; +} diff --git a/src/core/llm/LlmProviderRegistry.ts b/src/core/llm/LlmProviderRegistry.ts index 7472a2e0..96bff0d8 100644 --- a/src/core/llm/LlmProviderRegistry.ts +++ b/src/core/llm/LlmProviderRegistry.ts @@ -40,6 +40,8 @@ export class LlmProviderRegistry { type: options.type, baseUrl: options.baseUrl ?? '', model: options.model ?? '', + apiVersion: options.apiVersion ?? '2024-10-21', + region: options.region ?? 'us-east-1', apiKeyRef: 'thunder.apiKey', contextWindow: options.contextWindow ?? 8192, supportsStreaming: options.supportsStreaming ?? true, diff --git a/src/core/llm/OpenAiCompatibleProvider.ts b/src/core/llm/OpenAiCompatibleProvider.ts index 456ab333..d2e95969 100644 --- a/src/core/llm/OpenAiCompatibleProvider.ts +++ b/src/core/llm/OpenAiCompatibleProvider.ts @@ -8,13 +8,21 @@ export interface OpenAiCompatibleConfig { model: string; apiKey?: string; capabilities?: Partial; + providerId?: string; + defaultHeaders?: Record; + authHeader?: 'authorization' | 'api-key' | 'x-api-key'; + chatCompletionsPath?: string; + queryParams?: Record; + includeReasoning?: boolean; + reasoningEffort?: 'low' | 'medium' | 'high'; } export class OpenAiCompatibleProvider implements LlmProvider { - readonly id = 'openai-compatible'; + readonly id: string; readonly capabilities: ModelCapabilities; constructor(private readonly config: OpenAiCompatibleConfig) { + this.id = config.providerId ?? 'openai-compatible'; this.capabilities = { contextWindow: config.capabilities?.contextWindow ?? 8192, supportsStreaming: config.capabilities?.supportsStreaming ?? true, @@ -24,14 +32,24 @@ export class OpenAiCompatibleProvider implements LlmProvider { } async *complete(request: ChatRequest): AsyncIterable { - const url = `${this.config.baseUrl.replace(/\/$/, '')}/chat/completions`; + const url = buildChatCompletionsUrl(this.config); const headers: Record = { 'Content-Type': 'application/json', + ...(this.config.defaultHeaders ?? {}), }; if (this.config.apiKey) { - headers['Authorization'] = `Bearer ${this.config.apiKey}`; + const authHeader = this.config.authHeader ?? 'authorization'; + if (authHeader === 'api-key') { + headers['api-key'] = this.config.apiKey; + } else if (authHeader === 'x-api-key') { + headers['x-api-key'] = this.config.apiKey; + } else { + headers['Authorization'] = `Bearer ${this.config.apiKey}`; + } } + const includeReasoning = request.includeReasoning ?? this.config.includeReasoning; + const reasoningEffort = request.reasoningEffort ?? this.config.reasoningEffort; const body: Record = { model: request.model ?? this.config.model, messages: sanitizeOpenAiCompatibleMessages(request.messages).map(formatMessage), @@ -39,6 +57,12 @@ export class OpenAiCompatibleProvider implements LlmProvider { max_tokens: request.maxTokens, stream: request.stream !== false, }; + if (includeReasoning) { + body.include_reasoning = true; + } + if (reasoningEffort) { + body.reasoning_effort = reasoningEffort; + } if (this.capabilities.supportsTools && request.tools && request.tools.length > 0) { body.tools = request.tools; @@ -73,6 +97,9 @@ export class OpenAiCompatibleProvider implements LlmProvider { choices?: Array<{ message?: { content?: string; + reasoning?: string; + reasoning_content?: string; + redacted_reasoning?: string; tool_calls?: Array<{ id: string; type: 'function'; @@ -86,6 +113,10 @@ export class OpenAiCompatibleProvider implements LlmProvider { if (message?.content) { yield { content: message.content }; } + const reasoning = message?.reasoning ?? message?.reasoning_content ?? message?.redacted_reasoning; + if (reasoning) { + yield { reasoning }; + } if (message?.tool_calls) { for (const [index, tc] of message.tool_calls.entries()) { yield { @@ -165,6 +196,16 @@ export function sanitizeOpenAiCompatibleMessages(messages: ChatRequest['messages return sanitized; } +function buildChatCompletionsUrl(config: OpenAiCompatibleConfig): string { + const root = config.baseUrl.replace(/\/$/, ''); + const path = (config.chatCompletionsPath ?? 'chat/completions').replace(/^\//, ''); + const url = new URL(`${root}/${path}`); + for (const [key, value] of Object.entries(config.queryParams ?? {})) { + if (value) url.searchParams.set(key, value); + } + return url.toString(); +} + function toolResultAsUserMessage(message: ChatRequest['messages'][number]): ChatRequest['messages'][number] { const label = message.name ? ` from ${message.name}` : ''; return { diff --git a/src/core/llm/UsageTrackingProvider.ts b/src/core/llm/UsageTrackingProvider.ts index ea02f617..fe150532 100644 --- a/src/core/llm/UsageTrackingProvider.ts +++ b/src/core/llm/UsageTrackingProvider.ts @@ -47,6 +47,9 @@ export class UsageTrackingProvider implements LlmProvider { if (delta.content) { outputText += delta.content; } + if (delta.reasoning) { + outputText += delta.reasoning; + } if (delta.tool_calls) { outputText += JSON.stringify(delta.tool_calls); } diff --git a/src/core/llm/createProvider.ts b/src/core/llm/createProvider.ts index 4d6dd70d..beb921ec 100644 --- a/src/core/llm/createProvider.ts +++ b/src/core/llm/createProvider.ts @@ -4,6 +4,7 @@ import { EchoProvider } from './EchoProvider'; import { OpenAiCompatibleProvider } from './OpenAiCompatibleProvider'; import { AnthropicProvider } from './AnthropicProvider'; import { GeminiProvider } from './GeminiProvider'; +import { BedrockProvider } from './BedrockProvider'; import { getProviderPreset } from './providerPresets'; import { normalizeProviderModel } from './modelNormalize'; import { createLogger } from '../telemetry/Logger'; @@ -14,6 +15,8 @@ export interface ProviderResolveOptions { type?: ProviderType; baseUrl?: string; model?: string; + apiVersion?: string; + region?: string; apiKey?: string; contextWindow?: number; supportsStreaming?: boolean; @@ -34,6 +37,12 @@ export function createProvider( } const model = resolved.model; const key = apiKey; + const apiVersion = 'apiVersion' in config && config.apiVersion + ? config.apiVersion + : '2024-10-21'; + const region = 'region' in config && config.region + ? config.region + : 'us-east-1'; const capabilities = { contextWindow: config.contextWindow ?? preset?.contextWindow ?? 8192, supportsStreaming: config.supportsStreaming ?? true, @@ -46,6 +55,40 @@ export function createProvider( return new AnthropicProvider({ baseUrl, model, apiKey: key, capabilities }); case 'gemini': return new GeminiProvider({ baseUrl, model, apiKey: key, capabilities }); + case 'openrouter': + return new OpenAiCompatibleProvider({ + baseUrl, + model, + apiKey: key, + capabilities, + providerId: 'openrouter', + defaultHeaders: { + 'HTTP-Referer': 'https://mitii.dev', + 'X-Title': 'Mitii Agent', + }, + includeReasoning: true, + }); + case 'azure-openai': + return new OpenAiCompatibleProvider({ + baseUrl, + model, + apiKey: key, + capabilities, + providerId: 'azure-openai', + authHeader: 'api-key', + chatCompletionsPath: `openai/deployments/${encodeURIComponent(model)}/chat/completions`, + queryParams: { 'api-version': apiVersion }, + }); + case 'bedrock': + return new BedrockProvider({ + region, + model, + capabilities: { + ...capabilities, + supportsTools: false, + supportsEmbeddings: false, + }, + }); case 'openai': case 'deepseek': case 'cursor': diff --git a/src/core/llm/index.ts b/src/core/llm/index.ts index d75da1da..492fc0ac 100644 --- a/src/core/llm/index.ts +++ b/src/core/llm/index.ts @@ -1,7 +1,9 @@ export * from './types'; export * from './EchoProvider'; export * from './OpenAiCompatibleProvider'; +export * from './BedrockProvider'; export * from './LlmProviderRegistry'; export * from './tokenEstimate'; export * from './errors'; export * from './sseParser'; +export * from './streamChunks'; diff --git a/src/core/llm/providerPresets.ts b/src/core/llm/providerPresets.ts index e1af32a3..dd49693c 100644 --- a/src/core/llm/providerPresets.ts +++ b/src/core/llm/providerPresets.ts @@ -7,7 +7,7 @@ export interface ProviderPreset { model: string; contextWindow: number; requiresApiKey: boolean; - apiKeyHeader?: 'authorization' | 'x-api-key' | 'query'; + apiKeyHeader?: 'authorization' | 'api-key' | 'x-api-key' | 'query'; } export const PROVIDER_PRESETS: ProviderPreset[] = [ @@ -19,6 +19,14 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ contextWindow: 8192, requiresApiKey: false, }, + { + type: 'openrouter', + label: 'OpenRouter', + baseUrl: 'https://openrouter.ai/api/v1', + model: 'anthropic/claude-sonnet-4', + contextWindow: 200_000, + requiresApiKey: true, + }, { type: 'openai', label: 'OpenAI', @@ -27,6 +35,23 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ contextWindow: 128_000, requiresApiKey: true, }, + { + type: 'azure-openai', + label: 'Azure OpenAI', + baseUrl: 'https://your-resource.openai.azure.com', + model: 'your-deployment-name', + contextWindow: 128_000, + requiresApiKey: true, + apiKeyHeader: 'api-key', + }, + { + type: 'bedrock', + label: 'AWS Bedrock', + baseUrl: 'https://bedrock-runtime.us-east-1.amazonaws.com', + model: 'anthropic.claude-3-5-sonnet-20240620-v1:0', + contextWindow: 200_000, + requiresApiKey: false, + }, { type: 'anthropic', label: 'Anthropic (Claude)', diff --git a/src/core/llm/sseParser.ts b/src/core/llm/sseParser.ts index d3efca7b..1f9b912c 100644 --- a/src/core/llm/sseParser.ts +++ b/src/core/llm/sseParser.ts @@ -33,6 +33,9 @@ export async function* parseSseStream( choices?: Array<{ delta?: { content?: string; + reasoning?: string; + reasoning_content?: string; + redacted_reasoning?: string; tool_calls?: Array<{ index: number; id?: string; @@ -55,6 +58,9 @@ export async function* parseSseStream( if (delta?.content) { out.content = delta.content; } + if (delta?.reasoning || delta?.reasoning_content || delta?.redacted_reasoning) { + out.reasoning = delta.reasoning ?? delta.reasoning_content ?? delta.redacted_reasoning; + } if (delta?.tool_calls) { out.tool_calls = delta.tool_calls; } @@ -63,7 +69,7 @@ export async function* parseSseStream( out.done = true; } - if (out.content || out.tool_calls || out.done) { + if (out.content || out.reasoning || out.tool_calls || out.done) { yield out; } } catch (e) { diff --git a/src/core/llm/streamChunks.ts b/src/core/llm/streamChunks.ts new file mode 100644 index 00000000..8ac578d0 --- /dev/null +++ b/src/core/llm/streamChunks.ts @@ -0,0 +1,15 @@ +import type { AssistantStreamChunk } from './types'; + +export function chunkContent(chunk: AssistantStreamChunk): string { + return typeof chunk === 'string' ? chunk : (chunk.content ?? ''); +} + +export function chunkReasoning(chunk: AssistantStreamChunk): string { + return typeof chunk === 'string' ? '' : (chunk.reasoning ?? ''); +} + +export function toAssistantStreamChunk(content?: string, reasoning?: string): AssistantStreamChunk | undefined { + if (reasoning) return { content, reasoning }; + if (content) return content; + return undefined; +} diff --git a/src/core/llm/testConnection.ts b/src/core/llm/testConnection.ts index 29ec737f..6e6ee370 100644 --- a/src/core/llm/testConnection.ts +++ b/src/core/llm/testConnection.ts @@ -10,35 +10,54 @@ export interface ProviderConnectionResult { export async function testOpenAiCompatibleConnection( baseUrl: string, model: string, - apiKey?: string + apiKey?: string, + options: { + headers?: Record; + chatCompletionsPath?: string; + queryParams?: Record; + authHeader?: 'authorization' | 'api-key' | 'x-api-key'; + } = {} ): Promise { const root = baseUrl.replace(/\/$/, ''); - const headers: Record = {}; + const headers: Record = { ...(options.headers ?? {}) }; if (apiKey) { - headers['Authorization'] = `Bearer ${apiKey}`; + if (options.authHeader === 'api-key') { + headers['api-key'] = apiKey; + } else if (options.authHeader === 'x-api-key') { + headers['x-api-key'] = apiKey; + } else { + headers['Authorization'] = `Bearer ${apiKey}`; + } } try { - const modelsRes = await fetch(`${root}/models`, { headers }); - if (modelsRes.ok) { - const data = (await modelsRes.json()) as { data?: Array<{ id: string }> }; - const models = data.data?.map((m) => m.id) ?? []; - const hasModel = models.length === 0 || models.some((m) => m === model || m.startsWith(model)); - if (!hasModel && models.length > 0) { + if (!options.chatCompletionsPath) { + const modelsRes = await fetch(`${root}/models`, { headers }); + if (modelsRes.ok) { + const data = (await modelsRes.json()) as { data?: Array<{ id: string }> }; + const models = data.data?.map((m) => m.id) ?? []; + const hasModel = models.length === 0 || models.some((m) => m === model || m.startsWith(model)); + if (!hasModel && models.length > 0) { + return { + ok: false, + message: `Connected, but model "${model}" not found. Available: ${models.slice(0, 8).join(', ')}`, + models, + }; + } return { - ok: false, - message: `Connected, but model "${model}" not found. Available: ${models.slice(0, 8).join(', ')}`, + ok: true, + message: `Connected to ${root}. Model "${model}"${models.length ? ' found' : ' (could not list models)'}.`, models, }; } - return { - ok: true, - message: `Connected to ${root}. Model "${model}"${models.length ? ' found' : ' (could not list models)'}.`, - models, - }; } - const probe = await fetch(`${root}/chat/completions`, { + const probeUrl = new URL(`${root}/${(options.chatCompletionsPath ?? 'chat/completions').replace(/^\//, '')}`); + for (const [key, value] of Object.entries(options.queryParams ?? {})) { + if (value) probeUrl.searchParams.set(key, value); + } + + const probe = await fetch(probeUrl.toString(), { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -134,7 +153,9 @@ export async function testProviderConnection( providerType: ProviderType, baseUrl: string, model: string, - apiKey?: string + apiKey?: string, + apiVersion = '2024-10-21', + region = 'us-east-1' ): Promise { if (providerType === 'echo') { return { ok: true, message: 'Echo mode — no network connection required.' }; @@ -145,8 +166,29 @@ export async function testProviderConnection( if (providerType === 'gemini') { return testGeminiConnection(baseUrl, model, apiKey); } + if (providerType === 'bedrock') { + return { + ok: true, + message: `AWS Bedrock configured for ${region}. Mitii will use the AWS default credential chain and model "${model}".`, + }; + } if (isCloudProvider(providerType) && !apiKey?.trim()) { return { ok: false, message: `${providerType} requires an API key.` }; } + if (providerType === 'openrouter') { + return testOpenAiCompatibleConnection(baseUrl, model, apiKey, { + headers: { + 'HTTP-Referer': 'https://mitii.dev', + 'X-Title': 'Mitii Agent', + }, + }); + } + if (providerType === 'azure-openai') { + return testOpenAiCompatibleConnection(baseUrl, model, apiKey, { + authHeader: 'api-key', + chatCompletionsPath: `openai/deployments/${encodeURIComponent(model)}/chat/completions`, + queryParams: { 'api-version': apiVersion }, + }); + } return testOpenAiCompatibleConnection(baseUrl, model, apiKey); } diff --git a/src/core/llm/types.ts b/src/core/llm/types.ts index 51e76f10..f6b53d01 100644 --- a/src/core/llm/types.ts +++ b/src/core/llm/types.ts @@ -20,6 +20,8 @@ export interface ChatRequest { stream?: boolean; tools?: ToolDefinition[]; toolChoice?: 'auto' | 'none' | 'required'; + reasoningEffort?: 'low' | 'medium' | 'high'; + includeReasoning?: boolean; } export interface ToolCallDelta { @@ -33,12 +35,20 @@ export interface ToolCallDelta { export interface ChatDelta { content?: string; + reasoning?: string; done?: boolean; error?: string; tool_calls?: ToolCallDelta[]; finish_reason?: string; } +export interface AssistantStreamDelta { + content?: string; + reasoning?: string; +} + +export type AssistantStreamChunk = string | AssistantStreamDelta; + export interface ModelCapabilities { contextWindow: number; supportsStreaming: boolean; diff --git a/src/core/modes/agent/actSkillRouting.ts b/src/core/modes/agent/actSkillRouting.ts index ea3e7927..b526c088 100644 --- a/src/core/modes/agent/actSkillRouting.ts +++ b/src/core/modes/agent/actSkillRouting.ts @@ -11,6 +11,14 @@ export function resolveActSkillNames(intent: ActIntent, taskAnalysis?: TaskAnaly names.push('audit-cleanup'); } + if (/\b(console\.log|inline style|missing types?|type annotations?|eslint|lint|tech debt|code smells?)\b/i.test(taskAnalysis?.summary ?? '')) { + names.push('code-smells-and-tech-debt'); + } + + if (/\b(\.env|environment variable|missing keys?|secrets?|api keys?|tokens?)\b/i.test(taskAnalysis?.summary ?? '')) { + names.push('environment-and-secrets'); + } + if ( intent === 'resume_plan' || intent === 'bugfix' || @@ -77,4 +85,6 @@ ACT SKILLS: - Call use_skill to load a workspace playbook when the task needs a workflow that is not already injected. - For bug fixes and failed verification, use debugging-and-error-recovery. - For implementation and refactors, use test-driven-development when tests or verification strategy are unclear. -- For cleanup tasks, use audit-cleanup and prefer repository audit scripts over manual grep.`; +- For cleanup tasks, use audit-cleanup and prefer repository audit scripts over manual grep. +- For console logs, inline styles, missing types, lint hygiene, or tech debt, use code-smells-and-tech-debt. +- For .env files, environment variables, keys, tokens, or secrets, use environment-and-secrets and never print secret values.`; diff --git a/src/core/modes/plan/planSkillRouting.ts b/src/core/modes/plan/planSkillRouting.ts index f285d95c..a6d72b66 100644 --- a/src/core/modes/plan/planSkillRouting.ts +++ b/src/core/modes/plan/planSkillRouting.ts @@ -14,6 +14,12 @@ export function resolvePlanningSkillNames( if (intent === 'audit' || taskAnalysis?.kind === 'audit') { names.push('audit-cleanup'); } + if (/\b(console\.log|inline style|missing types?|type annotations?|eslint|lint|tech debt|code smells?)\b/i.test(taskAnalysis?.summary ?? '')) { + names.push('code-smells-and-tech-debt'); + } + if (/\b(\.env|environment variable|missing keys?|secrets?|api keys?|tokens?)\b/i.test(taskAnalysis?.summary ?? '')) { + names.push('environment-and-secrets'); + } if (intent === 'bugfix' || taskAnalysis?.kind === 'question') { names.push('debugging-and-error-recovery'); } @@ -67,4 +73,5 @@ PLANNING SKILLS: - Call use_skill to load a workspace playbook when you need one not already injected below. - For task breakdown and phased plans, use_skill("planning-and-task-breakdown") if not pre-loaded. - For skill discovery/routing, use_skill("using-agent-skills") if not pre-loaded. +- For tech-debt and env/secrets tasks, prefer the bundled script-backed skills before manual inspection. - Follow loaded skill workflows: dependency graph, vertical slices, acceptance criteria, and verification commands per step.`; diff --git a/src/core/orchestration/ChatOrchestrator.ts b/src/core/orchestration/ChatOrchestrator.ts index e8e1dafa..e7e21ef3 100644 --- a/src/core/orchestration/ChatOrchestrator.ts +++ b/src/core/orchestration/ChatOrchestrator.ts @@ -1,7 +1,8 @@ import * as vscode from 'vscode'; import { randomUUID } from 'crypto'; import type { ThunderDb } from '../indexing/ThunderDb'; -import type { LlmProvider, ChatMessage } from '../llm/types'; +import type { AssistantStreamChunk, LlmProvider, ChatMessage } from '../llm/types'; +import { chunkContent, toAssistantStreamChunk } from '../llm/streamChunks'; import type { ThunderSession } from '../session/ThunderSession'; import type { ContextItem, ContextPack } from '../context/types'; import type { @@ -216,7 +217,7 @@ export class ChatOrchestrator { userMessage: string, recentMessages: ChatMessage[] = [], options?: { pinnedContext?: PinnedContextEntry[] } - ): AsyncIterable { + ): AsyncIterable { this.abortController = new AbortController(); const signal = this.abortController.signal; const sessionLog = this.deps.sessionLog; @@ -615,7 +616,7 @@ export class ChatOrchestrator { sharedPlanOptions )) { if (signal.aborted) break; - fullResponse += chunk; + fullResponse += chunkContent(chunk); yield chunk; } sessionTiming.end('plan_execution', sessionLog, { resumed: true, stepCount: plan.steps.length }); @@ -773,7 +774,7 @@ export class ChatOrchestrator { )) { if (signal.aborted) break; if (session.mode !== 'plan') { - fullResponse += chunk; + fullResponse += chunkContent(chunk); yield chunk; } } @@ -853,7 +854,7 @@ export class ChatOrchestrator { sharedPlanOptions )) { if (signal.aborted) break; - fullResponse += chunk; + fullResponse += chunkContent(chunk); yield chunk; } sessionTiming.end('plan_execution', sessionLog, { @@ -974,7 +975,7 @@ export class ChatOrchestrator { } )) { if (signal.aborted) break; - fullResponse += chunk; + fullResponse += chunkContent(chunk); emitLiveTokenUsage(); yield chunk; } @@ -1051,7 +1052,7 @@ export class ChatOrchestrator { } )) { if (signal.aborted) break; - fullResponse += chunk; + fullResponse += chunkContent(chunk); emitLiveTokenUsage(); yield chunk; } @@ -1065,8 +1066,9 @@ export class ChatOrchestrator { if (delta.content) { fullResponse += delta.content; emitLiveTokenUsage(); - yield delta.content; } + const chunk = toAssistantStreamChunk(delta.content, delta.reasoning); + if (chunk) yield chunk; if (delta.error) throw new Error(delta.error); } } @@ -1324,7 +1326,7 @@ export class ChatOrchestrator { return Boolean(this.agentLoop?.getSuspendState() && this.suspendContext); } - async *resumeAfterApproval(approved: ApprovedToolResult[]): AsyncIterable { + async *resumeAfterApproval(approved: ApprovedToolResult[]): AsyncIterable { if (!this.agentLoop || !this.suspendContext || approved.length === 0) return; const baseState = this.agentLoop.getSuspendState(); @@ -1377,7 +1379,7 @@ export class ChatOrchestrator { sharedLoopCallbacks )) { if (signal.aborted) break; - fullResponse += chunk; + fullResponse += chunkContent(chunk); yield chunk; } diff --git a/src/core/runtime/AgentLoop.ts b/src/core/runtime/AgentLoop.ts index 57c4e757..c42fa7ab 100644 --- a/src/core/runtime/AgentLoop.ts +++ b/src/core/runtime/AgentLoop.ts @@ -1,5 +1,6 @@ -import type { LlmProvider, ChatMessage } from '../llm/types'; +import type { AssistantStreamChunk, LlmProvider, ChatMessage } from '../llm/types'; import type { ToolDefinition, ToolCall } from '../llm/toolTypes'; +import { toAssistantStreamChunk } from '../llm/streamChunks'; import type { ToolExecutor } from '../safety/ToolExecutor'; import { formatToolResult } from '../tools/builtinTools'; import { NO_TOOLS_AUDIT_NUDGE } from './taskKind'; @@ -109,7 +110,7 @@ export class AgentLoop { signal?: AbortSignal, callbacks?: AgentLoopCallbacks, options?: AgentLoopOptions - ): AsyncIterable { + ): AsyncIterable { const messages: ChatMessage[] = [...initialMessages]; let pendingApproval = false; this.lastPendingApproval = false; @@ -158,8 +159,9 @@ export class AgentLoop { if (delta.error) throw new Error(delta.error); if (delta.content) { stepContent += delta.content; - yield delta.content; } + const chunk = toAssistantStreamChunk(delta.content, delta.reasoning); + if (chunk) yield chunk; if (delta.tool_calls) { for (const partial of delta.tool_calls) { const existing = toolCallsMap.get(partial.index); @@ -432,7 +434,8 @@ export class AgentLoop { })) { if (signal?.aborted) break; if (delta.error) throw new Error(delta.error); - if (delta.content) yield delta.content; + const chunk = toAssistantStreamChunk(delta.content, delta.reasoning); + if (chunk) yield chunk; if (delta.done) break; } } @@ -447,7 +450,7 @@ export class AgentLoop { approved: ApprovedToolResult[], signal?: AbortSignal, callbacks?: AgentLoopCallbacks - ): AsyncIterable { + ): AsyncIterable { const messages: ChatMessage[] = state.messages.map((m) => ({ ...m })); const tools = state.tools; const options = state.options; @@ -537,8 +540,9 @@ export class AgentLoop { if (delta.error) throw new Error(delta.error); if (delta.content) { stepContent += delta.content; - yield delta.content; } + const chunk = toAssistantStreamChunk(delta.content, delta.reasoning); + if (chunk) yield chunk; if (delta.tool_calls) { for (const partial of delta.tool_calls) { const existing = toolCallsMap.get(partial.index); diff --git a/src/core/runtime/PlanExecutor.ts b/src/core/runtime/PlanExecutor.ts index 1dcc7770..d4bae3aa 100644 --- a/src/core/runtime/PlanExecutor.ts +++ b/src/core/runtime/PlanExecutor.ts @@ -1,5 +1,6 @@ -import type { LlmProvider } from '../llm/types'; +import type { AssistantStreamChunk, LlmProvider } from '../llm/types'; import type { ToolDefinition } from '../llm/toolTypes'; +import { chunkContent } from '../llm/streamChunks'; import type { ThunderSession } from '../session/ThunderSession'; import type { PlanPhase, ThunderPlan } from '../plans/PlanActEngine'; import { @@ -231,7 +232,7 @@ export class PlanExecutor { maxAutoContinues: options?.planMaxAutoContinues ?? 1, } )) { - output += chunk; + output += chunkContent(chunk); if (output.length > 12_000) { output = output.slice(-12_000); } @@ -251,7 +252,7 @@ export class PlanExecutor { signal?: AbortSignal, loopCallbacks?: AgentLoopCallbacks, options?: PlanExecutorOptions - ): AsyncIterable { + ): AsyncIterable { this.stepSummaries = []; this.touchedFiles.clear(); const maxRetries = options?.stepMaxRetries ?? 2; @@ -392,7 +393,7 @@ export class PlanExecutor { } )) { yield chunk; - stepOutput += chunk; + stepOutput += chunkContent(chunk); } pendingApproval = this.agentLoop.hadPendingApproval(); } @@ -539,7 +540,7 @@ export class PlanExecutor { signal?: AbortSignal, loopCallbacks?: AgentLoopCallbacks, options?: PlanExecutorOptions - ): AsyncIterable { + ): AsyncIterable { const touchedFiles = options?.touchedFiles ?? Array.from(this.touchedFiles); const workspaceErrors = await this.collectWorkspaceErrors(touchedFiles); const verifyContextBlock = options?.workspace diff --git a/src/vscode/webview/ThunderWebviewProvider.ts b/src/vscode/webview/ThunderWebviewProvider.ts index d8653eb4..c5ad7bb7 100644 --- a/src/vscode/webview/ThunderWebviewProvider.ts +++ b/src/vscode/webview/ThunderWebviewProvider.ts @@ -2,6 +2,7 @@ import * as vscode from 'vscode'; import { ThunderController } from '../../core/app/ThunderController'; import { createLogger } from '../../core/telemetry/Logger'; import { normalizeError, formatUserError } from '../../core/telemetry/errors'; +import { chunkContent, chunkReasoning } from '../../core/llm/streamChunks'; import { AGENT_FULL_NAME, AGENT_NAME, brandMessage } from '../../shared/brand'; import { type ExtensionToWebviewMessage, @@ -474,6 +475,7 @@ export class ThunderWebviewProvider implements vscode.WebviewViewProvider { const stream = await this.controller.sendMessage(content, recentMessages, { pinnedContext }); let rawContent = ''; let fullContent = ''; + let reasoningContent = ''; this.state = { ...this.state, @@ -485,17 +487,18 @@ export class ThunderWebviewProvider implements vscode.WebviewViewProvider { this.postMessage({ type: 'state', payload: this.state }); for await (const chunk of stream) { - rawContent += chunk; + rawContent += chunkContent(chunk); + reasoningContent += chunkReasoning(chunk); fullContent = stripLeakedChannelMarkers(rawContent); this.state = { ...this.state, messages: this.state.messages.map((m) => - m.id === assistantId ? { ...m, content: fullContent, streaming: true } : m + m.id === assistantId ? { ...m, content: fullContent, reasoningContent, streaming: true } : m ), }; this.postMessage({ type: 'updateLastAssistant', - payload: { content: fullContent, streaming: true }, + payload: { content: fullContent, reasoningContent, streaming: true }, }); } @@ -505,7 +508,7 @@ export class ThunderWebviewProvider implements vscode.WebviewViewProvider { ...this.state, loading: false, messages: this.state.messages.map((m) => - m.id === assistantId ? { ...m, content: fullContent, streaming: false } : m + m.id === assistantId ? { ...m, content: fullContent, reasoningContent, streaming: false } : m ), approvals: pendingApprovals.map((r) => ({ id: r.id, @@ -599,21 +602,23 @@ export class ThunderWebviewProvider implements vscode.WebviewViewProvider { const assistantId = lastAssistant.id; let rawContent = lastAssistant.content; let fullContent = stripLeakedChannelMarkers(rawContent); + let reasoningContent = lastAssistant.reasoningContent ?? ''; try { const stream = this.controller.resumeAfterApproval(); for await (const chunk of stream) { - rawContent += chunk; + rawContent += chunkContent(chunk); + reasoningContent += chunkReasoning(chunk); fullContent = stripLeakedChannelMarkers(rawContent); this.state = { ...this.state, messages: this.state.messages.map((m) => - m.id === assistantId ? { ...m, content: fullContent, streaming: true } : m + m.id === assistantId ? { ...m, content: fullContent, reasoningContent, streaming: true } : m ), }; this.postMessage({ type: 'updateLastAssistant', - payload: { content: fullContent, streaming: true }, + payload: { content: fullContent, reasoningContent, streaming: true }, }); } @@ -623,7 +628,7 @@ export class ThunderWebviewProvider implements vscode.WebviewViewProvider { ...this.state, loading: false, messages: this.state.messages.map((m) => - m.id === assistantId ? { ...m, content: fullContent, streaming: false } : m + m.id === assistantId ? { ...m, content: fullContent, reasoningContent, streaming: false } : m ), approvals: pendingApprovals.map((r) => ({ id: r.id, diff --git a/src/vscode/webview/messages.ts b/src/vscode/webview/messages.ts index d46e80dd..27446350 100644 --- a/src/vscode/webview/messages.ts +++ b/src/vscode/webview/messages.ts @@ -31,6 +31,7 @@ export interface ChatMessage { id: string; role: 'user' | 'assistant' | 'system'; content: string; + reasoningContent?: string; timestamp: number; streaming?: boolean; } @@ -226,6 +227,8 @@ export interface SettingsView { providerType: string; baseUrl: string; model: string; + apiVersion: string; + region: string; contextWindow: number; indexingEnabled: boolean; approvalMode: ApprovalMode; @@ -335,7 +338,7 @@ export type WorkspaceNoticeView = { export type ExtensionToWebviewMessage = | { type: 'state'; payload: WebviewState } | { type: 'appendMessage'; payload: ChatMessage } - | { type: 'updateLastAssistant'; payload: { content: string; streaming: boolean } } + | { type: 'updateLastAssistant'; payload: { content: string; reasoningContent?: string; streaming: boolean } } | { type: 'setError'; payload: string | null } | { type: 'setLoading'; payload: boolean } | { type: 'setMode'; payload: ThunderMode } @@ -412,6 +415,8 @@ export const defaultSettingsView = (): SettingsView => ({ providerType: 'echo', baseUrl: 'http://localhost:11434/v1', model: 'qwen3-coder:30b', + apiVersion: '2024-10-21', + region: 'us-east-1', contextWindow: 8192, indexingEnabled: true, approvalMode: 'review_all', diff --git a/src/webview-ui/src/components/MarkdownMessage.tsx b/src/webview-ui/src/components/MarkdownMessage.tsx index 4525ee04..33128d77 100644 --- a/src/webview-ui/src/components/MarkdownMessage.tsx +++ b/src/webview-ui/src/components/MarkdownMessage.tsx @@ -296,7 +296,7 @@ function safeHref(value: string): string | null { } function extractThinking(content: string): { thinking: string | null; visible: string } { - const match = /([\s\S]*?)<\/think>/i.exec(content); + const match = /([\s\S]*?)<\/(?:think|redacted_thinking)>/i.exec(content); if (!match) return { thinking: null, visible: content }; return { thinking: match[1].trim(), diff --git a/src/webview-ui/src/components/MessageList.tsx b/src/webview-ui/src/components/MessageList.tsx index 89b58b52..574faebb 100644 --- a/src/webview-ui/src/components/MessageList.tsx +++ b/src/webview-ui/src/components/MessageList.tsx @@ -3,6 +3,7 @@ import type { AgentActivityEntry, AgentLiveStatusView, ApprovalRequestView, Chat import { AGENT_NAME } from '../../../shared/brand'; import { MarkdownMessage } from './MarkdownMessage'; import { AgentActivityPanel } from './AgentActivityPanel'; +import { ThinkingRow } from './ThinkingRow'; import { useStreamReveal } from '../hooks/useStreamReveal'; interface MessageListProps { @@ -13,9 +14,22 @@ interface MessageListProps { approvals?: ApprovalRequestView[]; } -function AssistantMessage({ content, streaming }: { content: string; streaming?: boolean }) { +function AssistantMessage({ + content, + reasoningContent, + streaming, +}: { + content: string; + reasoningContent?: string; + streaming?: boolean; +}) { const revealed = useStreamReveal(content, Boolean(streaming)); - return ; + return ( + <> + + + + ); } export function MessageList({ messages, loading, agentActivity = [], agentLiveStatus = null, approvals = [] }: MessageListProps) { @@ -41,11 +55,25 @@ export function MessageList({ messages, loading, agentActivity = [], agentLiveSt
{msg.role === 'assistant' ? ( msg.content ? ( - + + ) : msg.reasoningContent ? ( + <> + + {msg.streaming && ( +

+

+ )} + ) : msg.streaming ? (

) : (

No response text

diff --git a/src/webview-ui/src/components/SettingsPanel.tsx b/src/webview-ui/src/components/SettingsPanel.tsx index 8a2e08c2..85804ea8 100644 --- a/src/webview-ui/src/components/SettingsPanel.tsx +++ b/src/webview-ui/src/components/SettingsPanel.tsx @@ -44,7 +44,10 @@ const TABS: Array<{ id: SettingsTab; label: string }> = [ const PROVIDER_OPTIONS: Array<{ id: ProviderSettingsPayload['providerType']; label: string }> = [ { id: 'echo', label: 'Echo (test / no LLM)' }, { id: 'openai-compatible', label: 'OpenAI-compatible (Ollama, LM Studio)' }, + { id: 'openrouter', label: 'OpenRouter' }, { id: 'openai', label: 'OpenAI' }, + { id: 'azure-openai', label: 'Azure OpenAI' }, + { id: 'bedrock', label: 'AWS Bedrock' }, { id: 'anthropic', label: 'Anthropic (Claude)' }, { id: 'gemini', label: 'Google Gemini' }, { id: 'deepseek', label: 'DeepSeek' }, @@ -221,6 +224,8 @@ export function SettingsPanel({ ); const [baseUrl, setBaseUrl] = useState(settings.baseUrl); const [model, setModel] = useState(settings.model); + const [apiVersion, setApiVersion] = useState(settings.apiVersion); + const [region, setRegion] = useState(settings.region); const [contextWindow, setContextWindow] = useState(settings.contextWindow); const [subagentsEnabled, setSubagentsEnabled] = useState(settings.subagentsEnabled); @@ -257,6 +262,8 @@ export function SettingsPanel({ setProviderType(settings.providerType as ProviderSettingsPayload['providerType']); setBaseUrl(settings.baseUrl); setModel(settings.model); + setApiVersion(settings.apiVersion); + setRegion(settings.region); setContextWindow(settings.contextWindow); setSubagentsEnabled(settings.subagentsEnabled); setAgentMaxSteps(settings.agentMaxSteps); @@ -309,6 +316,8 @@ export function SettingsPanel({ providerType, baseUrl: baseUrl.trim(), model: model.trim(), + apiVersion: apiVersion.trim(), + region: region.trim(), contextWindow: normalizedContextWindow, }, agent: { @@ -366,6 +375,8 @@ export function SettingsPanel({ providerType, baseUrl: baseUrl.trim(), model: model.trim(), + apiVersion: apiVersion.trim(), + region: region.trim(), contextWindow: clampContextWindow(contextWindow), }); @@ -469,6 +480,8 @@ export function SettingsPanel({ if (preset) { setBaseUrl(preset.baseUrl); setModel(preset.model); + setApiVersion(next === 'azure-openai' ? '2024-10-21' : apiVersion); + setRegion(next === 'bedrock' ? 'us-east-1' : region); setContextWindow(preset.contextWindow); } markDirty(); @@ -519,10 +532,48 @@ export function SettingsPanel({ ))} - Pick a local Ollama model or type any OpenAI-compatible model name. + Pick a local Ollama model, cloud model ID, or Azure deployment name. + {providerType === 'azure-openai' && ( + + )} + + {providerType === 'bedrock' && ( + + )} + {contextWindowField} {hasPresetContextMismatch && activeLocalPreset?.contextWindow && (

diff --git a/src/webview-ui/src/components/ThinkingRow.tsx b/src/webview-ui/src/components/ThinkingRow.tsx new file mode 100644 index 00000000..374338a1 --- /dev/null +++ b/src/webview-ui/src/components/ThinkingRow.tsx @@ -0,0 +1,16 @@ +interface ThinkingRowProps { + content: string; + streaming?: boolean; +} + +export function ThinkingRow({ content, streaming = false }: ThinkingRowProps) { + const trimmed = content.trim(); + if (!trimmed) return null; + + return ( +

+ {streaming ? 'Reasoning...' : 'Reasoning'} +

{trimmed.slice(0, 1200)}{trimmed.length > 1200 ? '...' : ''}

+
+ ); +} diff --git a/src/webview-ui/src/state/store.ts b/src/webview-ui/src/state/store.ts index 6b081315..f0863fa7 100644 --- a/src/webview-ui/src/state/store.ts +++ b/src/webview-ui/src/state/store.ts @@ -17,7 +17,7 @@ import { initialWebviewState } from '../../../vscode/webview/messages'; export type WebviewAction = | { type: 'SET_STATE'; payload: WebviewState } | { type: 'APPEND_MESSAGE'; payload: ChatMessage } - | { type: 'UPDATE_LAST_ASSISTANT'; payload: { content: string; streaming: boolean } } + | { type: 'UPDATE_LAST_ASSISTANT'; payload: { content: string; reasoningContent?: string; streaming: boolean } } | { type: 'SET_ERROR'; payload: string | null } | { type: 'SET_LOADING'; payload: boolean } | { type: 'SET_MODE'; payload: ThunderMode } @@ -51,6 +51,7 @@ export function webviewReducer(state: WebviewState, action: WebviewAction): Webv messages[messages.length - 1] = { ...nextLast, content: prevLast.content, + reasoningContent: prevLast.reasoningContent ?? nextLast.reasoningContent, streaming: true, }; return { ...incoming, messages }; @@ -69,6 +70,7 @@ export function webviewReducer(state: WebviewState, action: WebviewAction): Webv messages[lastIdx] = { ...messages[lastIdx], content: action.payload.content, + reasoningContent: action.payload.reasoningContent ?? messages[lastIdx].reasoningContent, streaming: action.payload.streaming, }; } else { @@ -76,6 +78,7 @@ export function webviewReducer(state: WebviewState, action: WebviewAction): Webv id: `stream-${Date.now()}`, role: 'assistant', content: action.payload.content, + reasoningContent: action.payload.reasoningContent, timestamp: Date.now(), streaming: action.payload.streaming, }); diff --git a/test/agent-loop.test.ts b/test/agent-loop.test.ts index f40525bd..62c218b8 100644 --- a/test/agent-loop.test.ts +++ b/test/agent-loop.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { AgentLoop } from '../src/core/runtime/AgentLoop'; +import { chunkContent } from '../src/core/llm/streamChunks'; import type { ToolExecutor } from '../src/core/safety/ToolExecutor'; import type { LlmProvider } from '../src/core/llm/types'; import type { ThunderPlan } from '../src/core/plans/PlanActEngine'; @@ -60,7 +61,7 @@ describe('AgentLoop E2E', () => { undefined, { planTracker: plan, maxSteps: 3 } )) { - chunks.push(chunk); + chunks.push(chunkContent(chunk)); } expect(chunks.join('')).toContain('Done'); @@ -145,7 +146,7 @@ describe('AgentLoop E2E', () => { undefined, { maxSteps: 3, phaseLock: 'verify' } )) { - chunks.push(chunk); + chunks.push(chunkContent(chunk)); } expect(chunks.join('')).toContain('Recovered'); @@ -190,7 +191,7 @@ describe('AgentLoop E2E', () => { undefined, { maxSteps: 5 } )) { - chunks.push(chunk); + chunks.push(chunkContent(chunk)); } expect(executor.execute).toHaveBeenCalledTimes(2); @@ -220,7 +221,7 @@ describe('AgentLoop E2E', () => { state, [{ toolCallId: 'c1', toolName: 'write_file', output: 'written', success: true }], )) { - chunks.push(chunk); + chunks.push(chunkContent(chunk)); } expect(chunks.join('')).toContain('Resumed'); diff --git a/test/ask-plan-modes.test.ts b/test/ask-plan-modes.test.ts index d1d2f6af..30045b42 100644 --- a/test/ask-plan-modes.test.ts +++ b/test/ask-plan-modes.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; +import { chunkContent } from '../src/core/llm/streamChunks'; import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; @@ -123,7 +124,7 @@ describe('Ask and Plan mode reliability', () => { undefined, { askMode: true, requiresAskGrounding: true, maxSteps: 1, maxAutoContinues: 0 } )) { - chunks.push(chunk); + chunks.push(chunkContent(chunk)); } expect(chunks.join('')).toContain('JWT'); @@ -179,7 +180,7 @@ describe('Ask and Plan mode reliability', () => { undefined, { planMode: true, requiresPlanGrounding: true, maxSteps: 3 } )) { - chunks.push(chunk); + chunks.push(chunkContent(chunk)); } expect(seen.some((text) => text.includes('Plan mode MUST be grounded'))).toBe(true); diff --git a/test/features.test.ts b/test/features.test.ts index 6dcd64a4..6a53e392 100644 --- a/test/features.test.ts +++ b/test/features.test.ts @@ -15,6 +15,9 @@ describe('providerPresets', () => { const types = PROVIDER_PRESETS.map((p) => p.type); expect(types).toEqual(expect.arrayContaining([ 'openai', + 'openrouter', + 'azure-openai', + 'bedrock', 'anthropic', 'gemini', 'deepseek', @@ -25,6 +28,9 @@ describe('providerPresets', () => { it('resolves preset by type', () => { expect(getProviderPreset('anthropic')?.model).toContain('claude'); + expect(getProviderPreset('openrouter')?.baseUrl).toContain('openrouter'); + expect(getProviderPreset('azure-openai')?.model).toContain('deployment'); + expect(getProviderPreset('bedrock')?.model).toContain('anthropic.claude'); expect(getProviderPreset('gemini')?.baseUrl).toContain('generativelanguage'); }); @@ -74,6 +80,38 @@ describe('createProvider', () => { }); expect(provider.id).toBe('openai-compatible'); }); + + it('creates native OpenRouter provider with provider-specific id', () => { + const provider = createProvider({ + ...defaultThunderConfig().provider, + type: 'openrouter', + baseUrl: 'https://openrouter.ai/api/v1', + model: 'anthropic/claude-sonnet-4', + }, 'test-key'); + expect(provider.id).toBe('openrouter'); + }); + + it('creates Azure OpenAI provider with provider-specific id', () => { + const provider = createProvider({ + ...defaultThunderConfig().provider, + type: 'azure-openai', + baseUrl: 'https://example.openai.azure.com', + model: 'my-deployment', + apiVersion: '2024-10-21', + }, 'test-key'); + expect(provider.id).toBe('azure-openai'); + }); + + it('creates AWS Bedrock provider with tool support disabled', () => { + const provider = createProvider({ + ...defaultThunderConfig().provider, + type: 'bedrock', + region: 'us-east-1', + model: 'anthropic.claude-3-5-sonnet-20240620-v1:0', + }); + expect(provider.id).toBe('bedrock'); + expect(provider.capabilities.supportsTools).toBe(false); + }); }); describe('sanitizeOpenAiCompatibleMessages', () => { @@ -155,6 +193,42 @@ describe('OpenAiCompatibleProvider', () => { expect(body.model).not.toBe('qwen3.6:27b'); }); + it('passes custom headers and reasoning options for native providers', async () => { + const provider = new OpenAiCompatibleProvider({ + baseUrl: 'https://openrouter.ai/api/v1', + model: 'anthropic/claude-sonnet-4', + apiKey: 'test-key', + providerId: 'openrouter', + defaultHeaders: { 'HTTP-Referer': 'https://mitii.dev', 'X-Title': 'Mitii Agent' }, + includeReasoning: true, + capabilities: { supportsTools: true }, + }); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ choices: [{ message: { content: 'ok', reasoning: 'thinking' }, finish_reason: 'stop' }] }), + }); + vi.stubGlobal('fetch', fetchMock); + + const deltas = []; + try { + for await (const delta of provider.complete({ + messages: [{ role: 'user', content: 'hi' }], + stream: false, + })) { + deltas.push(delta); + } + } finally { + vi.unstubAllGlobals(); + } + + const init = fetchMock.mock.calls[0][1]; + const body = JSON.parse(init.body); + expect(init.headers['HTTP-Referer']).toBe('https://mitii.dev'); + expect(init.headers.Authorization).toBe('Bearer test-key'); + expect(body.include_reasoning).toBe(true); + expect(deltas.some((delta) => delta.reasoning === 'thinking')).toBe(true); + }); + it('omits tool definitions when the configured model does not support tools', async () => { const provider = new OpenAiCompatibleProvider({ baseUrl: 'https://example.com/v1', From 8a00fd537ef2163784647633868122c370ff3b3d Mon Sep 17 00:00:00 2001 From: codewithshinde Date: Wed, 1 Jul 2026 23:58:50 -0500 Subject: [PATCH 02/12] feat: Implemented the backlog end-to-end across the repo. What changed: Added diff-first micro-task routing for commit messages, changelog entries, and release notes. Unified SCM commit message generation through the new micro-task executor. Added changelog/release generation core, VS Code commands, CHANGELOG.md, and CI release workflow. Added one-click audit pack export with zip generation, redaction report, manifest, tool audit, and approvals. Improved reasoning stream UI: live reveal, no 1200-char hard cap, configurable visibility/preview size. Added enterprise settings/docs for local-only providers, audit redaction, procurement/security/compliance. Added Windows path hardening, Windows CI matrix, and Windows smoke checklist. Added CLI MVP for changelog, prepare-release, and export-audit. Updated README with the new enterprise, audit, release, CLI, and reasoning features. Added focused tests for all new core implementations. --- .github/workflows/ci.yml | 25 +++ .github/workflows/release.yml | 28 +++ CHANGELOG.md | 4 + README.md | 33 ++- bin/mitii.js | 3 + docs/enterprise/COMPLIANCE.md | 19 ++ docs/enterprise/MITII_ENTERPRISE_OVERVIEW.md | 16 ++ docs/enterprise/PROCUREMENT_FAQ.md | 30 +++ docs/enterprise/README.md | 11 + docs/enterprise/SECURITY.md | 20 ++ docs/qa/WINDOWS_SMOKE.md | 12 + package-lock.json | 4 +- package.json | 60 ++++- src/core/app/ThunderController.ts | 179 ++++++++++++++- src/core/audit/AuditPackBuilder.ts | 209 ++++++++++++++++++ src/core/audit/index.ts | 2 + src/core/config/schema.ts | 16 ++ src/core/config/vscode/read.ts | 10 + src/core/headless/index.ts | 2 + src/core/headless/release.ts | 43 ++++ src/core/microtasks/MicroTaskExecutor.ts | 115 ++++++++++ src/core/microtasks/index.ts | 3 + src/core/microtasks/types.ts | 25 +++ src/core/orchestration/ChatOrchestrator.ts | 45 ++++ src/core/release/ChangelogGenerator.ts | 55 +++++ src/core/release/GitHistoryCollector.ts | 80 +++++++ src/core/release/ReleaseNotesGenerator.ts | 39 ++++ src/core/release/index.ts | 4 + src/core/scm/GitDiffCollector.ts | 40 +++- src/core/telemetry/SessionLogService.ts | 4 +- src/core/util/paths.ts | 15 +- src/node/cli.ts | 99 +++++++++ src/vscode/commands.ts | 12 + src/vscode/webview/messages.ts | 4 + src/webview-ui/src/App.tsx | 2 + src/webview-ui/src/components/MessageList.tsx | 32 ++- src/webview-ui/src/components/ThinkingRow.tsx | 23 +- src/webview-ui/src/styles/global.css | 31 +++ test/audit-pack.test.ts | 46 ++++ test/microtasks.test.ts | 83 +++++++ test/release.test.ts | 36 +++ test/windows-paths.test.ts | 39 ++++ 42 files changed, 1532 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 CHANGELOG.md create mode 100755 bin/mitii.js create mode 100644 docs/enterprise/COMPLIANCE.md create mode 100644 docs/enterprise/MITII_ENTERPRISE_OVERVIEW.md create mode 100644 docs/enterprise/PROCUREMENT_FAQ.md create mode 100644 docs/enterprise/README.md create mode 100644 docs/enterprise/SECURITY.md create mode 100644 docs/qa/WINDOWS_SMOKE.md create mode 100644 src/core/audit/AuditPackBuilder.ts create mode 100644 src/core/audit/index.ts create mode 100644 src/core/headless/index.ts create mode 100644 src/core/headless/release.ts create mode 100644 src/core/microtasks/MicroTaskExecutor.ts create mode 100644 src/core/microtasks/index.ts create mode 100644 src/core/microtasks/types.ts create mode 100644 src/core/release/ChangelogGenerator.ts create mode 100644 src/core/release/GitHistoryCollector.ts create mode 100644 src/core/release/ReleaseNotesGenerator.ts create mode 100644 src/core/release/index.ts create mode 100644 src/node/cli.ts create mode 100644 test/audit-pack.test.ts create mode 100644 test/microtasks.test.ts create mode 100644 test/release.test.ts create mode 100644 test/windows-paths.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..b1a8d123 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + name: test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npm test + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..de3dd088 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,28 @@ +name: Release + +on: + push: + tags: + - 'v*' + +jobs: + package: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npm run compile + - run: npx vsce package + - name: Generate release notes + run: node dist/cli.js changelog > release-notes.md + - uses: softprops/action-gh-release@v2 + with: + body_path: release-notes.md + files: '*.vsix' + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..1410f16b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,4 @@ +# Changelog + +## [Unreleased] + diff --git a/README.md b/README.md index 75ede20e..9537104c 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ License: AGPL v3 VS Code 1.85+ Node 20+ - Version 2.7.15 + Version 2.7.16 Website Docs

@@ -54,6 +54,7 @@ Most coding agents are powerful, but they often make one of these tradeoffs: | Tool limits | External tools are hard to connect or audit | Supports MCP servers while still routing tools through Mitii safety policy | | Repeated workflows | Teams paste the same instructions into every chat | Supports project rules and workspace skills through `SKILL.md` files | | Issue-to-fix handoff | Bug reports live in GitHub while the code lives in the editor | Detects GitHub issue URLs, fetches structured issue context, and routes Agent mode through the verified bugfix path | +| Procurement evidence | Security reviewers need logs, approvals, and data-flow answers | Exports an audit pack zip and ships enterprise security/compliance docs | Mitii is not just a chat panel. It is a workspace-aware agent runtime for real engineering work. @@ -73,6 +74,7 @@ The strongest thing Mitii provides is a practical balance: **deep local context | GitHub issue ingestion | Paste a GitHub issue URL and Mitii turns title, body, labels, and comments into structured task context | | Built for long tasks | Auto-continue, persisted task state, context compaction, and session history reduce restart pain | | Model freedom | Use local models for privacy, cloud models for capability, or different models for Plan, Act, and research | +| Diff-first micro-tasks | Commit messages, changelog entries, and release notes use minimal Git context instead of full agent routing | --- @@ -202,6 +204,20 @@ Mitii stores useful state locally so every serious task does not start from zero Post-task memory extraction can capture useful observations after completed work, so future sessions can reuse decisions without asking you to repeat context. +Audit review is available through `Mitii: Export Audit Pack`. The zip contains sanitized `session.jsonl`, `summary.md`, `manifest.json`, `tool-audit.json`, `approvals.json`, and `redaction-report.json`. + +### Release Automation + +Mitii includes release hygiene commands: + +| Command | Output | +|---|---| +| `Mitii: Generate Changelog Entry` | Preview a Keep a Changelog-style entry from Conventional Commits | +| `Mitii: Prepare Release` | Update `CHANGELOG.md` and write `.mitii/release-notes.md` | +| `mitii changelog` | Headless changelog entry for CI/scripts | +| `mitii prepare-release` | Headless changelog + release-notes generation | +| `mitii export-audit` | Headless audit pack export from JSONL logs | + ### 7. Skills And Project Playbooks Mitii can load reusable workflow instructions from `SKILL.md` files. Bundled skills are copied into each workspace under `.mitii/skills/` on first init, and teams can add their own skills for code review, planning, debugging, testing, performance work, release flow, and cleanup. @@ -244,6 +260,21 @@ The sidebar is a React webview with: | Indexing status | Know when the workspace brain is ready | | Context warnings | See when context may be thin or over budget | +Reasoning deltas from supported providers stream live in the chat UI. Use `thunder.ui.showReasoning` and `thunder.ui.reasoningPreviewMaxChars` to control visibility and inline preview size. + +## Enterprise Readiness + +Enterprise review materials live in [docs/enterprise](docs/enterprise/README.md). The pack covers data flow, provider boundaries, procurement FAQs, compliance mapping, Windows support, and auditability. + +| Control | Setting or command | +|---|---| +| Route narrow Git/release tasks through minimal context | `thunder.context.microTaskRoutingEnabled` | +| Require local model providers | `thunder.enterprise.localProvidersOnly` | +| Strip file contents from exported audit packs | `thunder.enterprise.stripFileContentsFromAuditPacks` | +| Disable session logging | `thunder.telemetry.sessionLogging` | +| Export audit evidence | `Mitii: Export Audit Pack` | +| Windows smoke checklist | [docs/qa/WINDOWS_SMOKE.md](docs/qa/WINDOWS_SMOKE.md) | + --- ## Analysis: Why This Design Works diff --git a/bin/mitii.js b/bin/mitii.js new file mode 100755 index 00000000..dd612181 --- /dev/null +++ b/bin/mitii.js @@ -0,0 +1,3 @@ +#!/usr/bin/env node +require('../dist/cli.js'); + diff --git a/docs/enterprise/COMPLIANCE.md b/docs/enterprise/COMPLIANCE.md new file mode 100644 index 00000000..e39c6a72 --- /dev/null +++ b/docs/enterprise/COMPLIANCE.md @@ -0,0 +1,19 @@ +# Compliance Mapping + +## SOC 2 Readiness + +| Area | Current Capability | Roadmap | +|---|---|---| +| Access control | VS Code workspace trust, approval gates, local SecretStorage | Central policy distribution | +| Change audit | JSONL session logs, tool audit log, audit pack export | Signed audit archives | +| Data minimization | Micro-task routing for commit/changelog/release prompts | Per-team prompt retention policy | +| Incident review | Session summaries, timing events, errors, tool calls | SIEM forwarding | + +## GDPR Considerations + +Mitii does not require a Mitii-hosted cloud service. Personal data exposure depends on the selected LLM provider and the code or prompts a user chooses to send. + +## Audit Trail + +Audit packs are designed for compliance review. They include event timelines, model metadata, tool calls, approval state, and redaction counts without requiring reviewers to inspect source code. + diff --git a/docs/enterprise/MITII_ENTERPRISE_OVERVIEW.md b/docs/enterprise/MITII_ENTERPRISE_OVERVIEW.md new file mode 100644 index 00000000..21fcec4d --- /dev/null +++ b/docs/enterprise/MITII_ENTERPRISE_OVERVIEW.md @@ -0,0 +1,16 @@ +# Mitii Enterprise Overview + +Mitii is a local-first AI coding agent for VS Code. It indexes the workspace locally, retrieves focused context, plans higher-risk work, and gates writes or shell commands through approval policy. + +## Why Enterprises Evaluate Mitii + +- Local workspace index, memory, logs, plans, and checkpoints. +- Provider freedom across local and cloud models. +- Enterprise toggles for local-only providers and audit pack redaction. +- One-click audit pack export for compliance review. +- Windows, macOS, and Linux test matrix. + +## Buyer Summary + +Mitii is suitable for teams that want AI coding assistance without moving the agent runtime into a vendor-hosted workspace. Security posture depends on the selected model provider, approval policy, and logging settings, all of which can be reviewed and configured through VS Code settings. + diff --git a/docs/enterprise/PROCUREMENT_FAQ.md b/docs/enterprise/PROCUREMENT_FAQ.md new file mode 100644 index 00000000..1822b3aa --- /dev/null +++ b/docs/enterprise/PROCUREMENT_FAQ.md @@ -0,0 +1,30 @@ +# Procurement FAQ + +## Deployment Model + +Mitii is a VS Code extension with local workspace storage. There is no required Mitii server. + +## Supported Platforms + +Mitii targets macOS, Linux, and Windows. CI runs `npm test` on `ubuntu-latest`, `macos-latest`, and `windows-latest`. + +## Offline And Local Models + +Mitii supports OpenAI-compatible localhost providers such as Ollama and LM Studio. Set `thunder.enterprise.localProvidersOnly` to enforce local-only usage. + +## Controls Procurement Teams Ask For + +| Control | Setting or command | +|---|---| +| Disable session logging | `thunder.telemetry.sessionLogging` | +| Disable verbose diagnostics | `thunder.telemetry.debugMetrics` | +| Require approval for writes | `thunder.safety.requireApprovalForWrites` | +| Require approval for shell | `thunder.safety.requireApprovalForShell` | +| Local providers only | `thunder.enterprise.localProvidersOnly` | +| Strip file contents from audit packs | `thunder.enterprise.stripFileContentsFromAuditPacks` | +| Export review evidence | `Mitii: Export Audit Pack` | + +## License + +Mitii is licensed under AGPL-3.0-or-later. Commercial licensing questions should use the project contact in the README. + diff --git a/docs/enterprise/README.md b/docs/enterprise/README.md new file mode 100644 index 00000000..d45ea43f --- /dev/null +++ b/docs/enterprise/README.md @@ -0,0 +1,11 @@ +# Mitii Enterprise Pack + +This folder is the reviewer-facing security, compliance, and procurement packet for Mitii. + +- [Security and data flow](SECURITY.md) +- [Compliance mapping](COMPLIANCE.md) +- [Procurement FAQ](PROCUREMENT_FAQ.md) +- [Enterprise overview](MITII_ENTERPRISE_OVERVIEW.md) + +Key controls are implemented as VS Code settings under `thunder.telemetry.*`, `thunder.safety.*`, `thunder.enterprise.*`, and the `Mitii: Export Audit Pack` command. + diff --git a/docs/enterprise/SECURITY.md b/docs/enterprise/SECURITY.md new file mode 100644 index 00000000..810fe3f5 --- /dev/null +++ b/docs/enterprise/SECURITY.md @@ -0,0 +1,20 @@ +# Security And Data Flow + +Mitii is local-first. Workspace indexes, memory, plans, checkpoints, and JSONL session logs are stored under the workspace `.mitii/` directory. + +## What Leaves The Machine + +Only prompt data sent to the configured LLM provider leaves the machine. The provider boundary is controlled by `thunder.provider.*`. Enterprises can enable `thunder.enterprise.localProvidersOnly` to require Echo or a localhost OpenAI-compatible endpoint. + +## Secrets + +API keys are stored in VS Code SecretStorage. Logs and audit packs redact keys named like API keys, tokens, passwords, and secrets. Audit packs can additionally strip tool output and file content with `thunder.enterprise.stripFileContentsFromAuditPacks`. + +## MCP Risk Model + +MCP tools are routed through Mitii tool policy. File writes and mutating shell commands require approval according to `thunder.safety.approvalMode` and `thunder.safety.requireApprovalForWrites`. + +## Auditability + +Use `Mitii: Export Audit Pack` to produce a zip containing `session.jsonl`, `summary.md`, `manifest.json`, `tool-audit.json`, `approvals.json`, and `redaction-report.json`. + diff --git a/docs/qa/WINDOWS_SMOKE.md b/docs/qa/WINDOWS_SMOKE.md new file mode 100644 index 00000000..3a4830e5 --- /dev/null +++ b/docs/qa/WINDOWS_SMOKE.md @@ -0,0 +1,12 @@ +# Windows Smoke Checklist + +Run on Windows with a normal user account. + +1. Open `C:\dev\project` in VS Code. +2. Confirm Mitii indexes the workspace without path errors. +3. Ask Mitii to read a file using both `src\foo.ts` and `src/foo.ts`. +4. Stage a change and run `Mitii: Generate Commit Message`. +5. Run `Mitii: Export Audit Pack` and confirm the zip uses forward-slash entries. +6. Add a built-in MCP server and confirm the `cmd /c npx` wrapper starts. +7. Run `npm test` from PowerShell. + diff --git a/package-lock.json b/package-lock.json index 2c25c304..5914439d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mitii-ai-agent", - "version": "2.7.16", + "version": "2.7.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mitii-ai-agent", - "version": "2.7.16", + "version": "2.7.17", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "dependencies": { diff --git a/package.json b/package.json index f74dac40..6d6af002 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "mitii-ai-agent", "displayName": "Mitii AI Agent", "description": "Local-first VS Code AI coding agent with precise repo context and safe Plan/Act workflow", - "version": "2.7.16", + "version": "2.7.17", "publisher": "mitii", "icon": "media/mitii-short-logo.png", "license": "AGPL-3.0-or-later", @@ -45,9 +45,15 @@ "onCommand:thunder.openChat", "onCommand:thunder.indexWorkspace", "onCommand:thunder.showSettings", - "onCommand:thunder.generateCommitMessage" + "onCommand:thunder.generateCommitMessage", + "onCommand:thunder.generateChangelog", + "onCommand:thunder.prepareRelease", + "onCommand:thunder.exportAuditPack" ], "main": "./dist/extension.js", + "bin": { + "mitii": "./bin/mitii.js" + }, "contributes": { "commands": [ { @@ -70,6 +76,11 @@ "title": "Mitii: Export Session Log", "category": "Mitii" }, + { + "command": "thunder.exportAuditPack", + "title": "Mitii: Export Audit Pack", + "category": "Mitii" + }, { "command": "thunder.openSessionLog", "title": "Mitii: Open Session Log File", @@ -95,6 +106,16 @@ "title": "Mitii: Generate Commit Message", "category": "Mitii", "icon": "media/mitii-activitybar.svg" + }, + { + "command": "thunder.generateChangelog", + "title": "Mitii: Generate Changelog Entry", + "category": "Mitii" + }, + { + "command": "thunder.prepareRelease", + "title": "Mitii: Prepare Release", + "category": "Mitii" } ], "menus": { @@ -142,6 +163,33 @@ "default": false, "description": "Capture extra session diagnostics (tool inputs, context sources, LLM step metadata). Process timing is always logged when session logging is on." }, + "thunder.ui.showReasoning": { + "type": "boolean", + "default": true, + "description": "Show streamed model reasoning when the provider returns reasoning deltas." + }, + "thunder.ui.reasoningPreviewMaxChars": { + "type": "number", + "default": 8000, + "minimum": 0, + "maximum": 100000, + "description": "Maximum reasoning characters shown inline. Set 0 to show all reasoning." + }, + "thunder.enterprise.localProvidersOnly": { + "type": "boolean", + "default": false, + "description": "Enterprise policy flag documenting that only local providers should be used for this workspace." + }, + "thunder.enterprise.stripFileContentsFromAuditPacks": { + "type": "boolean", + "default": false, + "description": "Strip file contents and large tool outputs from exported audit packs while preserving metadata." + }, + "thunder.enterprise.autoExportAuditPackOnSessionEnd": { + "type": "boolean", + "default": false, + "description": "Reserved enterprise toggle for exporting audit packs automatically when a session ends." + }, "thunder.provider.type": { "type": "string", "enum": [ @@ -259,6 +307,11 @@ "maximum": 30, "description": "Number of items to keep after reranking" }, + "thunder.context.microTaskRoutingEnabled": { + "type": "boolean", + "default": true, + "description": "Route narrow chat requests such as commit messages, changelog entries, and release notes through minimal Git context instead of the full agent loop." + }, "thunder.safety.requireApprovalForWrites": { "type": "boolean", "default": true, @@ -530,7 +583,7 @@ }, "scripts": { "vscode:prepublish": "npm run compile", - "compile": "npm run compile:extension && npm run compile:webview", + "compile": "npm run compile:extension && npm run compile:webview && npm run compile:cli", "setup": "bash scripts/dev-setup.sh", "setup:cursor": "THUNDER_EDITOR=cursor bash scripts/dev-setup.sh", "rebuild:native": "node scripts/rebuild-native.mjs", @@ -541,6 +594,7 @@ "version:bump": "node scripts/bump-version.mjs", "compile:extension": "esbuild src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --packages=external --platform=node --format=cjs --sourcemap", "compile:webview": "vite build", + "compile:cli": "esbuild src/node/cli.ts --bundle --outfile=dist/cli.js --platform=node --format=cjs --packages=external", "watch": "concurrently \"npm run watch:extension\" \"npm run watch:webview\"", "watch:extension": "esbuild src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --packages=external --platform=node --format=cjs --sourcemap --watch", "watch:webview": "vite build --watch", diff --git a/src/core/app/ThunderController.ts b/src/core/app/ThunderController.ts index abf5b814..90744c3d 100644 --- a/src/core/app/ThunderController.ts +++ b/src/core/app/ThunderController.ts @@ -1,7 +1,7 @@ import * as vscode from 'vscode'; import { AGENT_NAME, brandMessage } from '../../shared/brand'; -import { existsSync, statSync } from 'fs'; -import { join } from 'path'; +import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'fs'; +import { dirname, join } from 'path'; import { ThunderSession } from '../session/ThunderSession'; import { ConfigService } from '../config/ConfigService'; import { LlmProviderRegistry } from '../llm/LlmProviderRegistry'; @@ -95,7 +95,10 @@ import { listCustomMcpServers } from '../mcp/mcpWorkspaceConfig'; import { resolveDbPath } from '../indexing/paths'; import { searchWorkspacePaths, resolvePickedPaths } from '../context/contextPathSearch'; import { createWorkspacePattern, isWorkspaceInVscodeFolders, normalizeWorkspaceRoot, toWorkspaceRelPath } from '../util/paths'; -import { collectCommitMessageInput, generateCommitMessage, type CommitMessageResult } from '../scm'; +import type { CommitMessageResult } from '../scm'; +import { MicroTaskExecutor } from '../microtasks'; +import { AuditPackBuilder } from '../audit'; +import { GitHistoryCollector, generateChangelogEntry, generateReleaseNotes, insertChangelogEntry } from '../release'; import { normalizeAgentSettings, normalizeProviderSettings, @@ -662,6 +665,18 @@ export class ThunderController { ), githubIssueFetchEnabled: config.github.issueFetchEnabled, githubIssueCommentLimit: config.github.issueCommentLimit, + microTaskRoutingEnabled: config.context.microTaskRoutingEnabled, + microTaskExecutorFactory: (provider) => { + if (!ws || !this.gitService) { + throw new Error('Micro-task routing requires an initialized workspace and Git repository.'); + } + return new MicroTaskExecutor({ + workspace: ws, + git: this.gitService, + provider, + sessionLog: this.sessionLog, + }); + }, }); orchestrator.setToolExecutor(this.toolExecutor); orchestrator.setContextPackCallback((pack, views, budget) => { @@ -994,6 +1009,8 @@ export class ThunderController { actModel: config.agent.actModel, actBaseUrl: config.agent.actBaseUrl, checkpointStrategy: config.agent.checkpointStrategy, + showReasoning: config.ui.showReasoning, + reasoningPreviewMaxChars: config.ui.reasoningPreviewMaxChars, }, contextToggles: this.contextToggles, mcpToggles: this.mcpToggles, @@ -1179,11 +1196,19 @@ export class ThunderController { throw normalizeError(new Error('No Git repository found for this workspace.')); } const provider = this.trackProvider(await this.resolveProviderForMode('ask')); - const input = await collectCommitMessageInput(this.gitService); - if (!input.stagedDiff.trim() && input.unstagedDiff?.trim()) { - throw normalizeError(new Error('Only unstaged changes found. Stage files before generating a commit message.')); - } - return generateCommitMessage(input, provider); + const result = await new MicroTaskExecutor({ + workspace: this.resolveWorkspacePath() ?? '', + git: this.gitService, + provider, + sessionLog: this.sessionLog, + }).execute('commit_message', 'generate commit message'); + const [subject, ...rest] = result.content.split(/\r?\n/); + const body = rest.join('\n').trim() || undefined; + return { + subject: subject || 'chore: update workspace', + body, + fullMessage: body ? `${subject}\n\n${body}` : subject || 'chore: update workspace', + }; } private async resolveProviderForMode(mode: string): Promise { @@ -1193,6 +1218,11 @@ export class ThunderController { if (mode === 'plan') { const planModel = config.agent.planModel?.trim(); if (planModel) { + enforceEnterpriseProviderPolicy( + config.enterprise.localProvidersOnly, + config.agent.planProviderType ?? config.provider.type, + config.agent.planBaseUrl?.trim() || config.provider.baseUrl + ); return this.providerRegistry.resolveFromOptions({ type: config.agent.planProviderType ?? config.provider.type, baseUrl: config.agent.planBaseUrl?.trim() || config.provider.baseUrl, @@ -1208,6 +1238,11 @@ export class ThunderController { if (mode === 'agent') { const actModel = config.agent.actModel?.trim(); if (actModel) { + enforceEnterpriseProviderPolicy( + config.enterprise.localProvidersOnly, + config.agent.actProviderType ?? config.provider.type, + config.agent.actBaseUrl?.trim() || config.provider.baseUrl + ); return this.providerRegistry.resolveFromOptions({ type: config.agent.actProviderType ?? config.provider.type, baseUrl: config.agent.actBaseUrl?.trim() || config.provider.baseUrl, @@ -1224,6 +1259,7 @@ export class ThunderController { if (!active) { throw normalizeError(new Error('No LLM provider configured')); } + enforceEnterpriseProviderPolicy(config.enterprise.localProvidersOnly, config.provider.type, config.provider.baseUrl); return active; } @@ -1760,6 +1796,100 @@ export class ThunderController { } } + async exportAuditPack(): Promise { + const workspace = this.resolveWorkspacePath(); + if (!workspace) { + void vscode.window.showWarningMessage(brandMessage('Open a workspace before exporting an audit pack.')); + return; + } + + const sessionId = this.session?.id ?? 'no-session'; + const defaultUri = vscode.Uri.file(join( + workspace, + `.mitii/audit/mitii-audit-${sessionId}-${formatTimestampForFile(Date.now())}.zip` + )); + const target = await vscode.window.showSaveDialog({ + defaultUri, + filters: { 'Zip archive': ['zip'] }, + title: 'Export Mitii audit pack', + }); + if (!target) return; + + const config = this.configService.getConfig(); + const pack = new AuditPackBuilder().build({ + sessionId, + workspace, + extensionVersion: this.context.extension.packageJSON.version ?? '', + model: `${config.provider.type}/${config.provider.model}`, + logPath: this.sessionLog.getLogPath(), + summaryMarkdown: this.sessionLog.exportSummary(), + toolAudit: this.toolRuntime.getAuditLog(), + approvals: this.approvalQueue?.getPending() ?? [], + stripFileContents: config.enterprise.stripFileContentsFromAuditPacks, + }); + + mkdirSync(dirname(target.fsPath), { recursive: true }); + await vscode.workspace.fs.writeFile(target, pack.buffer); + this.sessionLog.append('audit_export', 'Audit pack exported', { + path: target.fsPath, + entries: pack.entries, + redactionReport: pack.redactionReport, + }); + + const revealLabel = platformRevealLabel(); + const choice = await vscode.window.showInformationMessage( + brandMessage(`Audit pack exported: ${target.fsPath}`), + revealLabel + ); + if (choice === revealLabel) { + await vscode.commands.executeCommand('revealFileInOS', target); + } + } + + async generateChangelog(): Promise { + const workspace = this.resolveWorkspacePath(); + if (!workspace) { + void vscode.window.showWarningMessage(brandMessage('Open a workspace before generating a changelog.')); + return; + } + const collector = new GitHistoryCollector(workspace); + const latestTag = await collector.getLatestTag(); + const commits = await collector.getCommitsSinceTag(latestTag ?? undefined); + const entry = generateChangelogEntry({ + commits, + version: readPackageVersion(workspace), + date: new Date(), + }); + const doc = await vscode.workspace.openTextDocument({ content: entry, language: 'markdown' }); + await vscode.window.showTextDocument(doc, { preview: false }); + } + + async prepareRelease(): Promise { + const workspace = this.resolveWorkspacePath(); + if (!workspace) { + void vscode.window.showWarningMessage(brandMessage('Open a workspace before preparing a release.')); + return; + } + const collector = new GitHistoryCollector(workspace); + const latestTag = await collector.getLatestTag(); + const commits = await collector.getCommitsSinceTag(latestTag ?? undefined); + const version = readPackageVersion(workspace); + const date = new Date(); + const entry = generateChangelogEntry({ commits, version, date }); + const notes = generateReleaseNotes({ commits, version, date }); + const changelogPath = join(workspace, 'CHANGELOG.md'); + const existing = existsSync(changelogPath) ? readFileSync(changelogPath, 'utf8') : '# Changelog\n\n## [Unreleased]\n'; + writeFileSync(changelogPath, insertChangelogEntry(existing, entry), 'utf8'); + const notesPath = join(workspace, '.mitii', 'release-notes.md'); + mkdirSync(dirname(notesPath), { recursive: true }); + writeFileSync(notesPath, notes, 'utf8'); + void vscode.window.showInformationMessage( + brandMessage(`Prepared release ${version}. Updated CHANGELOG.md and .mitii/release-notes.md.`) + ); + const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(notesPath)); + await vscode.window.showTextDocument(doc, { preview: false }); + } + async openSessionLog(): Promise { const logPath = this.sessionLog.getLogPath(); if (!logPath) { @@ -2379,3 +2509,36 @@ function normalizePromptBreakdown( }, ]; } + +function readPackageVersion(workspace: string): string { + try { + const pkg = JSON.parse(readFileSync(join(workspace, 'package.json'), 'utf8')) as { version?: string }; + return pkg.version ?? '0.0.0'; + } catch { + return '0.0.0'; + } +} + +function formatTimestampForFile(ts: number): string { + const d = new Date(ts); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}_${pad(d.getHours())}-${pad(d.getMinutes())}-${pad(d.getSeconds())}`; +} + +function platformRevealLabel(): string { + if (process.platform === 'darwin') return 'Reveal in Finder'; + if (process.platform === 'win32') return 'Reveal in Explorer'; + return 'Reveal in File Manager'; +} + +function enforceEnterpriseProviderPolicy(localOnly: boolean, providerType: string, baseUrl: string): void { + if (!localOnly) return; + const url = baseUrl.trim().toLowerCase(); + const localUrl = /^(http:\/\/)?(localhost|127\.0\.0\.1|0\.0\.0\.0)(?::|\/|$)/.test(url) || + url.startsWith('http://[::1]'); + if (providerType === 'echo') return; + if (providerType === 'openai-compatible' && localUrl) return; + throw normalizeError(new Error( + 'Enterprise local-providers-only policy is enabled. Choose Echo or a localhost OpenAI-compatible provider.' + )); +} diff --git a/src/core/audit/AuditPackBuilder.ts b/src/core/audit/AuditPackBuilder.ts new file mode 100644 index 00000000..2b975889 --- /dev/null +++ b/src/core/audit/AuditPackBuilder.ts @@ -0,0 +1,209 @@ +import { existsSync, readFileSync } from 'fs'; +import { basename } from 'path'; +import type { ToolCallAudit } from '../tools/types'; + +export interface AuditPackManifest { + sessionId: string; + workspace: string; + extensionVersion: string; + model?: string; + createdAt: string; + logPath?: string; + eventCount: number; +} + +export interface AuditPackInput { + sessionId: string; + workspace: string; + extensionVersion: string; + model?: string; + logPath?: string; + summaryMarkdown: string; + toolAudit?: ToolCallAudit[]; + approvals?: unknown[]; + stripFileContents?: boolean; +} + +export interface AuditPackBuildResult { + buffer: Buffer; + manifest: AuditPackManifest; + redactionReport: RedactionReport; + entries: string[]; +} + +export interface RedactionReport { + secretKeyRedactions: number; + longValueTruncations: number; + fileContentStrips: number; +} + +export class AuditPackBuilder { + build(input: AuditPackInput): AuditPackBuildResult { + const report: RedactionReport = { + secretKeyRedactions: 0, + longValueTruncations: 0, + fileContentStrips: 0, + }; + const sessionJsonl = input.logPath && existsSync(input.logPath) + ? sanitizeJsonl(readFileSync(input.logPath, 'utf8'), report, input.stripFileContents) + : ''; + const eventCount = sessionJsonl.trim() ? sessionJsonl.trim().split(/\r?\n/).length : 0; + const manifest: AuditPackManifest = { + sessionId: input.sessionId, + workspace: input.workspace, + extensionVersion: input.extensionVersion, + model: input.model, + createdAt: new Date().toISOString(), + logPath: input.logPath ? basename(input.logPath) : undefined, + eventCount, + }; + const files: Record = { + 'session.jsonl': sessionJsonl, + 'summary.md': input.summaryMarkdown, + 'manifest.json': JSON.stringify(manifest, null, 2), + 'tool-audit.json': JSON.stringify(sanitizeValue(input.toolAudit ?? [], report, input.stripFileContents), null, 2), + 'approvals.json': JSON.stringify(sanitizeValue(input.approvals ?? [], report, input.stripFileContents), null, 2), + 'redaction-report.json': JSON.stringify(report, null, 2), + }; + return { + buffer: createZip(files), + manifest, + redactionReport: report, + entries: Object.keys(files), + }; + } +} + +function sanitizeJsonl(jsonl: string, report: RedactionReport, stripFileContents = false): string { + return jsonl + .split(/\r?\n/) + .filter(Boolean) + .map((line) => { + try { + return JSON.stringify(sanitizeValue(JSON.parse(line), report, stripFileContents)); + } catch { + return '[malformed log line omitted]'; + } + }) + .join('\n') + (jsonl.trim() ? '\n' : ''); +} + +function sanitizeValue(value: unknown, report: RedactionReport, stripFileContents = false): unknown { + if (Array.isArray(value)) return value.map((item) => sanitizeValue(item, report, stripFileContents)); + if (typeof value === 'string') { + if (looksLikeSecret(value)) { + report.secretKeyRedactions += 1; + return '[REDACTED]'; + } + if (value.length > 8000) { + report.longValueTruncations += 1; + return `${value.slice(0, 8000)}... [truncated ${value.length - 8000} chars]`; + } + return value; + } + if (!value || typeof value !== 'object') return value; + const out: Record = {}; + for (const [key, nested] of Object.entries(value as Record)) { + if (shouldRedactKey(key)) { + report.secretKeyRedactions += 1; + out[key] = '[REDACTED]'; + } else if (stripFileContents && /^(content|fileContent|output|outputPreview)$/i.test(key) && typeof nested === 'string') { + report.fileContentStrips += 1; + out[key] = `[stripped ${nested.length} chars]`; + } else { + out[key] = sanitizeValue(nested, report, stripFileContents); + } + } + return out; +} + +function shouldRedactKey(key: string): boolean { + const lower = key.toLowerCase(); + return lower.includes('apikey') || + lower.includes('api_key') || + lower === 'authorization' || + lower.includes('secret') || + lower.includes('password') || + lower === 'token' || + lower === 'accesstoken' || + lower === 'refreshtoken'; +} + +function looksLikeSecret(value: string): boolean { + return /\b(sk-[A-Za-z0-9_-]{12,}|ghp_[A-Za-z0-9_]{20,}|Bearer\s+[A-Za-z0-9._-]{16,})\b/.test(value); +} + +function createZip(files: Record): Buffer { + const localParts: Buffer[] = []; + const centralParts: Buffer[] = []; + let offset = 0; + + for (const [name, content] of Object.entries(files)) { + const nameBuf = Buffer.from(name.replace(/\\/g, '/')); + const data = Buffer.from(content, 'utf8'); + const crc = crc32(data); + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt16LE(0, 6); + local.writeUInt16LE(0, 8); + local.writeUInt16LE(0, 10); + local.writeUInt16LE(0, 12); + local.writeUInt32LE(crc, 14); + local.writeUInt32LE(data.length, 18); + local.writeUInt32LE(data.length, 22); + local.writeUInt16LE(nameBuf.length, 26); + local.writeUInt16LE(0, 28); + localParts.push(local, nameBuf, data); + + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); + central.writeUInt16LE(20, 6); + central.writeUInt16LE(0, 8); + central.writeUInt16LE(0, 10); + central.writeUInt16LE(0, 12); + central.writeUInt16LE(0, 14); + central.writeUInt32LE(crc, 16); + central.writeUInt32LE(data.length, 20); + central.writeUInt32LE(data.length, 24); + central.writeUInt16LE(nameBuf.length, 28); + central.writeUInt16LE(0, 30); + central.writeUInt16LE(0, 32); + central.writeUInt16LE(0, 34); + central.writeUInt16LE(0, 36); + central.writeUInt32LE(0, 38); + central.writeUInt32LE(offset, 42); + centralParts.push(central, nameBuf); + offset += local.length + nameBuf.length + data.length; + } + + const centralDir = Buffer.concat(centralParts); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(0, 4); + end.writeUInt16LE(0, 6); + end.writeUInt16LE(Object.keys(files).length, 8); + end.writeUInt16LE(Object.keys(files).length, 10); + end.writeUInt32LE(centralDir.length, 12); + end.writeUInt32LE(offset, 16); + end.writeUInt16LE(0, 20); + return Buffer.concat([...localParts, centralDir, end]); +} + +function crc32(buffer: Buffer): number { + let crc = 0xffffffff; + for (const byte of buffer) { + crc = (crc >>> 8) ^ CRC_TABLE[(crc ^ byte) & 0xff]; + } + return (crc ^ 0xffffffff) >>> 0; +} + +const CRC_TABLE = Array.from({ length: 256 }, (_, n) => { + let c = n; + for (let k = 0; k < 8; k++) { + c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + } + return c >>> 0; +}); + diff --git a/src/core/audit/index.ts b/src/core/audit/index.ts new file mode 100644 index 00000000..a47e15e9 --- /dev/null +++ b/src/core/audit/index.ts @@ -0,0 +1,2 @@ +export * from './AuditPackBuilder'; + diff --git a/src/core/config/schema.ts b/src/core/config/schema.ts index 7a8e10b3..94f4a5c1 100644 --- a/src/core/config/schema.ts +++ b/src/core/config/schema.ts @@ -48,6 +48,18 @@ export const ContextConfigSchema = z.object({ rerankerEnabled: z.boolean().default(true), rerankerCandidatePool: z.number().int().min(5).max(50).default(20), rerankerTopK: z.number().int().min(3).max(30).default(8), + microTaskRoutingEnabled: z.boolean().default(true), +}); + +export const UiConfigSchema = z.object({ + showReasoning: z.boolean().default(true), + reasoningPreviewMaxChars: z.number().int().min(0).max(100_000).default(8000), +}); + +export const EnterpriseConfigSchema = z.object({ + localProvidersOnly: z.boolean().default(false), + stripFileContentsFromAuditPacks: z.boolean().default(false), + autoExportAuditPackOnSessionEnd: z.boolean().default(false), }); export const AgentDepthSchema = z.enum(['auto', 'quick', 'standard', 'deep', 'pilot', 'enterprise']); @@ -169,6 +181,8 @@ export const ThunderConfigSchema = z.object({ scm: ScmConfigSchema.default({}), github: GitHubConfigSchema.default({}), telemetry: TelemetryConfigSchema.default({}), + ui: UiConfigSchema.default({}), + enterprise: EnterpriseConfigSchema.default({}), }); export type ProviderType = z.infer; @@ -177,6 +191,8 @@ export type EmbeddingProviderKind = z.infer; export type VectorBackendKind = z.infer; export type IndexingConfig = z.infer; export type ContextConfig = z.infer; +export type UiConfig = z.infer; +export type EnterpriseConfig = z.infer; export type SafetyConfig = z.infer; export type MemoryConfig = z.infer; export type AgentDepth = z.infer; diff --git a/src/core/config/vscode/read.ts b/src/core/config/vscode/read.ts index 6bf6427f..52c878c0 100644 --- a/src/core/config/vscode/read.ts +++ b/src/core/config/vscode/read.ts @@ -39,6 +39,7 @@ export function readThunderConfigFromSettings(): ThunderConfig { rerankerEnabled: config.get('context.rerankerEnabled'), rerankerCandidatePool: config.get('context.rerankerCandidatePool'), rerankerTopK: config.get('context.rerankerTopK'), + microTaskRoutingEnabled: config.get('context.microTaskRoutingEnabled'), }, safety: { requireApprovalForWrites: config.get('safety.requireApprovalForWrites'), @@ -106,6 +107,15 @@ export function readThunderConfigFromSettings(): ThunderConfig { sessionLogging: config.get('telemetry.sessionLogging'), debugMetrics: config.get('telemetry.debugMetrics'), }, + ui: { + showReasoning: config.get('ui.showReasoning'), + reasoningPreviewMaxChars: config.get('ui.reasoningPreviewMaxChars'), + }, + enterprise: { + localProvidersOnly: config.get('enterprise.localProvidersOnly'), + stripFileContentsFromAuditPacks: config.get('enterprise.stripFileContentsFromAuditPacks'), + autoExportAuditPackOnSessionEnd: config.get('enterprise.autoExportAuditPackOnSessionEnd'), + }, }; const result = ThunderConfigSchema.safeParse(raw); diff --git a/src/core/headless/index.ts b/src/core/headless/index.ts new file mode 100644 index 00000000..f40efd7c --- /dev/null +++ b/src/core/headless/index.ts @@ -0,0 +1,2 @@ +export * from './release'; + diff --git a/src/core/headless/release.ts b/src/core/headless/release.ts new file mode 100644 index 00000000..52289727 --- /dev/null +++ b/src/core/headless/release.ts @@ -0,0 +1,43 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { dirname, join } from 'path'; +import { GitHistoryCollector, generateChangelogEntry, generateReleaseNotes, insertChangelogEntry } from '../release'; + +export interface PrepareReleaseResult { + changelogEntry: string; + releaseNotes: string; + changelogPath: string; + releaseNotesPath: string; +} + +export async function generateHeadlessChangelog(cwd: string, since?: string): Promise { + const collector = new GitHistoryCollector(cwd); + const tag = since ?? await collector.getLatestTag() ?? undefined; + const commits = await collector.getCommitsSinceTag(tag); + return generateChangelogEntry({ commits, version: readPackageVersion(cwd), date: new Date() }); +} + +export async function prepareHeadlessRelease(cwd: string, since?: string): Promise { + const collector = new GitHistoryCollector(cwd); + const tag = since ?? await collector.getLatestTag() ?? undefined; + const commits = await collector.getCommitsSinceTag(tag); + const version = readPackageVersion(cwd); + const changelogEntry = generateChangelogEntry({ commits, version, date: new Date() }); + const releaseNotes = generateReleaseNotes({ commits, version, date: new Date() }); + const changelogPath = join(cwd, 'CHANGELOG.md'); + const existing = existsSync(changelogPath) ? readFileSync(changelogPath, 'utf8') : '# Changelog\n\n## [Unreleased]\n'; + writeFileSync(changelogPath, insertChangelogEntry(existing, changelogEntry), 'utf8'); + const releaseNotesPath = join(cwd, '.mitii', 'release-notes.md'); + mkdirSync(dirname(releaseNotesPath), { recursive: true }); + writeFileSync(releaseNotesPath, releaseNotes, 'utf8'); + return { changelogEntry, releaseNotes, changelogPath, releaseNotesPath }; +} + +function readPackageVersion(cwd: string): string { + try { + const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8')) as { version?: string }; + return pkg.version ?? '0.0.0'; + } catch { + return '0.0.0'; + } +} + diff --git a/src/core/microtasks/MicroTaskExecutor.ts b/src/core/microtasks/MicroTaskExecutor.ts new file mode 100644 index 00000000..2b5de3bc --- /dev/null +++ b/src/core/microtasks/MicroTaskExecutor.ts @@ -0,0 +1,115 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; +import type { LlmProvider } from '../llm/types'; +import type { SessionLogService } from '../telemetry/SessionLogService'; +import { collectCommitMessageInput, generateCommitMessage, buildCommitMessagePrompt } from '../scm'; +import { estimateChatRequestTokens } from '../llm/UsageTrackingProvider'; +import type { GitService } from '../context/GitService'; +import { GitHistoryCollector } from '../release/GitHistoryCollector'; +import { generateChangelogEntry } from '../release/ChangelogGenerator'; +import { generateReleaseNotes } from '../release/ReleaseNotesGenerator'; +import type { MicroTaskId, MicroTaskResult } from './types'; + +export interface MicroTaskExecutorDeps { + workspace: string; + git: GitService; + provider?: LlmProvider; + sessionLog?: SessionLogService; +} + +export class MicroTaskExecutor { + constructor(private readonly deps: MicroTaskExecutorDeps) {} + + async execute(id: MicroTaskId, userMessage: string): Promise { + if (id === 'commit_message') return this.generateCommitMessage(userMessage); + if (id === 'release_notes_draft') return this.generateReleaseNotes(userMessage); + return this.generateChangelogEntry(userMessage); + } + + private async generateCommitMessage(_userMessage: string): Promise { + if (!this.deps.provider) { + throw new Error('No LLM provider configured for commit message generation.'); + } + if (!this.deps.git.isGitRepo) { + throw new Error('No Git repository found for this workspace.'); + } + const input = await collectCommitMessageInput(this.deps.git, { + stagedDiffMaxChars: 12_000, + unstagedDiffMaxChars: 4_000, + perFileMaxChars: 2_000, + }); + if (!input.stagedDiff.trim() && input.unstagedDiff?.trim()) { + throw new Error('Only unstaged changes found. Stage files before generating a commit message.'); + } + const prompt = buildCommitMessagePrompt(input); + this.logContext('commit_message', prompt, { + changedFiles: input.changedFiles.length, + hasUnstagedDiff: Boolean(input.unstagedDiff?.trim()), + }); + const result = await generateCommitMessage(input, this.deps.provider); + return { + id: 'commit_message', + content: result.fullMessage, + metadata: { + subject: result.subject, + promptTokens: estimateChatRequestTokens({ + messages: [ + { role: 'system', content: 'commit' }, + { role: 'user', content: prompt }, + ], + }), + }, + }; + } + + private async generateChangelogEntry(userMessage: string): Promise { + const collector = new GitHistoryCollector(this.deps.workspace); + const latestTag = await collector.getLatestTag(); + const commits = await collector.getCommitsSinceTag(extractSinceRef(userMessage) ?? latestTag ?? undefined); + const version = readPackageVersion(this.deps.workspace); + const content = generateChangelogEntry({ commits, version, date: new Date() }); + this.logContext('changelog_entry', JSON.stringify(commits), { + latestTag, + commitCount: commits.length, + version, + }); + return { id: 'changelog_entry', content, metadata: { latestTag, commitCount: commits.length, version } }; + } + + private async generateReleaseNotes(userMessage: string): Promise { + const collector = new GitHistoryCollector(this.deps.workspace); + const latestTag = await collector.getLatestTag(); + const commits = await collector.getCommitsSinceTag(extractSinceRef(userMessage) ?? latestTag ?? undefined); + const version = readPackageVersion(this.deps.workspace); + const content = generateReleaseNotes({ commits, version, date: new Date() }); + this.logContext('release_notes_draft', JSON.stringify(commits), { + latestTag, + commitCount: commits.length, + version, + }); + return { id: 'release_notes_draft', content, metadata: { latestTag, commitCount: commits.length, version } }; + } + + private logContext(id: MicroTaskId, context: string, data: Record): void { + this.deps.sessionLog?.append('microtask_context', `Micro-task context: ${id}`, { + id, + contextChars: context.length, + estimatedTokens: Math.ceil(context.length / 4), + ...data, + }); + } +} + +function extractSinceRef(message: string): string | undefined { + const match = message.match(/\b(?:since|from)\s+([A-Za-z0-9._/-]+)/i); + return match?.[1]; +} + +function readPackageVersion(workspace: string): string { + try { + const pkg = JSON.parse(readFileSync(join(workspace, 'package.json'), 'utf8')) as { version?: string }; + return pkg.version ?? '0.0.0'; + } catch { + return '0.0.0'; + } +} diff --git a/src/core/microtasks/index.ts b/src/core/microtasks/index.ts new file mode 100644 index 00000000..bcaff848 --- /dev/null +++ b/src/core/microtasks/index.ts @@ -0,0 +1,3 @@ +export * from './types'; +export * from './MicroTaskExecutor'; + diff --git a/src/core/microtasks/types.ts b/src/core/microtasks/types.ts new file mode 100644 index 00000000..8898fb0a --- /dev/null +++ b/src/core/microtasks/types.ts @@ -0,0 +1,25 @@ +export type MicroTaskId = 'commit_message' | 'changelog_entry' | 'release_notes_draft'; + +export interface MicroTaskInput { + userMessage: string; + workspace: string; +} + +export interface MicroTaskResult { + id: MicroTaskId; + content: string; + metadata?: Record; +} + +const MICRO_TASK_PATTERNS: Array<[MicroTaskId, RegExp]> = [ + ['commit_message', /\b(commit message|write commit|git commit)\b/i], + ['release_notes_draft', /\b(release notes?|what'?s new)\b/i], + ['changelog_entry', /\b(changelog|what changed since)\b/i], +]; + +export function detectMicroTask(userMessage: string): MicroTaskId | null { + const text = userMessage.trim(); + if (!text) return null; + return MICRO_TASK_PATTERNS.find(([, pattern]) => pattern.test(text))?.[0] ?? null; +} + diff --git a/src/core/orchestration/ChatOrchestrator.ts b/src/core/orchestration/ChatOrchestrator.ts index e7e21ef3..cc66e4b4 100644 --- a/src/core/orchestration/ChatOrchestrator.ts +++ b/src/core/orchestration/ChatOrchestrator.ts @@ -70,6 +70,7 @@ import { estimateChatRequestTokens } from '../llm/UsageTrackingProvider'; import { resolveMaxContextItems } from '../context/resolveMaxContextItems'; import { enrichTask } from '../task'; import type { GitHubIssueFetcher } from '../integrations/github'; +import { detectMicroTask, type MicroTaskExecutor } from '../microtasks'; const log = createLogger('ChatOrchestrator'); @@ -110,6 +111,8 @@ export interface ChatOrchestratorDeps { githubTokenProvider?: () => Promise; githubIssueFetchEnabled?: boolean; githubIssueCommentLimit?: number; + microTaskExecutorFactory?: (provider: LlmProvider) => MicroTaskExecutor; + microTaskRoutingEnabled?: boolean; } export class ChatOrchestrator { @@ -234,6 +237,36 @@ export class ChatOrchestrator { auditMode: isAuditCleanupTask(userMessage), }); + const microTaskId = this.deps.microTaskRoutingEnabled === false || isApprovalContinuationMessage(userMessage) + ? null + : detectMicroTask(userMessage); + if (microTaskId && this.deps.microTaskExecutorFactory) { + this.setLiveStatus('Running micro-task', microTaskId.replace(/_/g, ' ')); + this.emitActivity('info', `Micro-task route: ${microTaskId}`, 'Using minimal Git/release context and no tools.'); + sessionTiming.start('microtask'); + const result = await this.deps.microTaskExecutorFactory(provider).execute(microTaskId, userMessage); + sessionTiming.end('microtask', sessionLog, result.metadata); + const content = result.content; + this.saveTurn(session.id, 'user', userMessage); + const emptyPack = emptyContextPack(); + await this.finishTurn( + session, + provider, + userMessage, + content, + emptyPack, + [], + Math.ceil(content.length / 4), + [ + { role: 'system', content: `Mitii micro-task: ${microTaskId}` }, + { role: 'user', content: userMessage }, + ] + ); + yield content; + this.setLiveStatus(null); + return; + } + const ws = this.deps.workspace ?? ''; const editor = vscode.window.activeTextEditor; const rawCurrentFile = editor && ws @@ -1718,6 +1751,18 @@ function mergePromptContexts(...blocks: Array): string | und return [...new Set(merged)].join('\n\n---\n\n'); } +function emptyContextPack(): ContextPack { + return { + items: [], + totalTokens: 0, + formatted: '', + retrievedCount: 0, + budgetLimit: 0, + dropped: [], + truncatedCount: 0, + }; +} + export function contextPackToBudgetView(pack: ContextPack): ContextBudgetView { const sourceMap = new Map(); for (const item of pack.items) { diff --git a/src/core/release/ChangelogGenerator.ts b/src/core/release/ChangelogGenerator.ts new file mode 100644 index 00000000..cac6750e --- /dev/null +++ b/src/core/release/ChangelogGenerator.ts @@ -0,0 +1,55 @@ +import type { ConventionalCommit } from './GitHistoryCollector'; + +export interface ReleaseMarkdownInput { + commits: ConventionalCommit[]; + version: string; + date: Date; +} + +export function generateChangelogEntry(input: ReleaseMarkdownInput): string { + const date = formatDate(input.date); + const groups = groupCommits(input.commits); + const lines = [`## [${input.version}] - ${date}`, '']; + + appendSection(lines, 'Breaking', groups.breaking); + appendSection(lines, 'Added', groups.added); + appendSection(lines, 'Fixed', groups.fixed); + appendSection(lines, 'Changed', groups.changed); + + if (input.commits.length === 0) { + lines.push('No commits found since the selected tag.', ''); + } + return lines.join('\n').trimEnd() + '\n'; +} + +export function insertChangelogEntry(existing: string, entry: string): string { + const normalized = existing.trim() || '# Changelog\n\n## [Unreleased]\n'; + if (/## \[Unreleased\]/.test(normalized)) { + return normalized.replace(/(## \[Unreleased\][^\n]*)/, `$1\n\n${entry.trimEnd()}`).trimEnd() + '\n'; + } + return `${normalized}\n\n## [Unreleased]\n\n${entry}`.trimEnd() + '\n'; +} + +function groupCommits(commits: ConventionalCommit[]) { + return { + breaking: commits.filter((c) => c.breaking), + added: commits.filter((c) => c.type === 'feat' && !c.breaking), + fixed: commits.filter((c) => c.type === 'fix' && !c.breaking), + changed: commits.filter((c) => !c.breaking && c.type !== 'feat' && c.type !== 'fix'), + }; +} + +function appendSection(lines: string[], title: string, commits: ConventionalCommit[]): void { + if (commits.length === 0) return; + lines.push(`### ${title}`); + for (const commit of commits) { + const scope = commit.scope ? `**${commit.scope}:** ` : ''; + const hash = commit.hash ? ` (${commit.hash})` : ''; + lines.push(`- ${scope}${commit.description}${hash}`); + } + lines.push(''); +} + +function formatDate(date: Date): string { + return date.toISOString().slice(0, 10); +} diff --git a/src/core/release/GitHistoryCollector.ts b/src/core/release/GitHistoryCollector.ts new file mode 100644 index 00000000..57560d16 --- /dev/null +++ b/src/core/release/GitHistoryCollector.ts @@ -0,0 +1,80 @@ +import simpleGit, { type SimpleGit } from 'simple-git'; + +export interface ConventionalCommit { + hash: string; + subject: string; + type: string; + scope?: string; + description: string; + breaking: boolean; + body?: string; +} + +export class GitHistoryCollector { + private readonly git: SimpleGit; + + constructor(workspace: string) { + this.git = simpleGit(workspace); + } + + async getTags(): Promise { + try { + const tags = await this.git.tags(); + return tags.all; + } catch { + return []; + } + } + + async getLatestTag(): Promise { + try { + const tag = await this.git.raw(['describe', '--tags', '--abbrev=0']); + return tag.trim() || null; + } catch { + return null; + } + } + + async getCommitsSinceTag(tag?: string): Promise { + try { + const range = tag ? `${tag}..HEAD` : 'HEAD'; + const raw = await this.git.raw([ + 'log', + range, + '--pretty=format:%H%x1f%s%x1f%b%x1e', + ]); + return raw + .split('\x1e') + .map((entry) => entry.trim()) + .filter(Boolean) + .map(parseCommitRecord); + } catch { + return []; + } + } +} + +export function parseCommitRecord(record: string): ConventionalCommit { + const [hash = '', subject = '', body = ''] = record.split('\x1f'); + const parsed = parseConventionalSubject(subject, body); + return { + hash: hash.slice(0, 12), + subject, + body: body.trim() || undefined, + ...parsed, + }; +} + +export function parseConventionalSubject(subject: string, body = ''): Omit { + const match = subject.match(/^([a-z]+)(?:\(([^)]+)\))?(!)?:\s+(.+)$/i); + const breaking = Boolean(match?.[3]) || /\bBREAKING CHANGE:/i.test(body); + if (!match) { + return { type: 'other', description: subject.trim(), breaking }; + } + return { + type: match[1].toLowerCase(), + scope: match[2], + description: match[4].trim(), + breaking, + }; +} diff --git a/src/core/release/ReleaseNotesGenerator.ts b/src/core/release/ReleaseNotesGenerator.ts new file mode 100644 index 00000000..9a8d130a --- /dev/null +++ b/src/core/release/ReleaseNotesGenerator.ts @@ -0,0 +1,39 @@ +import type { ReleaseMarkdownInput } from './ChangelogGenerator'; + +export function generateReleaseNotes(input: ReleaseMarkdownInput): string { + const highlights = input.commits.filter((c) => c.type === 'feat' || c.breaking).slice(0, 5); + const fixes = input.commits.filter((c) => c.type === 'fix').slice(0, 6); + const lines = [`# Mitii ${input.version} Release Notes`, '']; + + if (highlights.length > 0) { + lines.push('## Highlights'); + for (const commit of highlights) { + lines.push(`- ${commit.description}`); + } + lines.push(''); + } + + if (fixes.length > 0) { + lines.push('## Fixes'); + for (const commit of fixes) { + lines.push(`- ${commit.description}`); + } + lines.push(''); + } + + const breaking = input.commits.filter((c) => c.breaking); + if (breaking.length > 0) { + lines.push('## Migration Notes'); + for (const commit of breaking) { + lines.push(`- ${commit.description}`); + } + lines.push(''); + } + + if (input.commits.length === 0) { + lines.push('No user-facing changes were found for this range.', ''); + } + + return lines.join('\n').trimEnd() + '\n'; +} + diff --git a/src/core/release/index.ts b/src/core/release/index.ts new file mode 100644 index 00000000..c45503b4 --- /dev/null +++ b/src/core/release/index.ts @@ -0,0 +1,4 @@ +export * from './GitHistoryCollector'; +export * from './ChangelogGenerator'; +export * from './ReleaseNotesGenerator'; + diff --git a/src/core/scm/GitDiffCollector.ts b/src/core/scm/GitDiffCollector.ts index 70773409..fa99ae0b 100644 --- a/src/core/scm/GitDiffCollector.ts +++ b/src/core/scm/GitDiffCollector.ts @@ -3,7 +3,12 @@ import type { CommitMessageInput } from './commitMessageTypes'; export async function collectCommitMessageInput( git: GitService, - options: { scope?: string; stagedDiffMaxChars?: number; unstagedDiffMaxChars?: number } = {} + options: { + scope?: string; + stagedDiffMaxChars?: number; + unstagedDiffMaxChars?: number; + perFileMaxChars?: number; + } = {} ): Promise { const [stagedDiff, unstagedDiff, changedFiles, recentCommits, branch] = await Promise.all([ git.getStagedDiff(options.stagedDiffMaxChars ?? 16_000), @@ -14,11 +19,40 @@ export async function collectCommitMessageInput( ]); return { - stagedDiff, - unstagedDiff, + stagedDiff: budgetDiff(stagedDiff, { + totalMaxChars: options.stagedDiffMaxChars ?? 16_000, + perFileMaxChars: options.perFileMaxChars, + }), + unstagedDiff: budgetDiff(unstagedDiff, { + totalMaxChars: options.unstagedDiffMaxChars ?? 8_000, + perFileMaxChars: options.perFileMaxChars, + }), changedFiles, recentCommits, branch, scope: options.scope, }; } + +export function budgetDiff( + diff: string, + options: { totalMaxChars: number; perFileMaxChars?: number } +): string { + if (!diff || diff.length <= options.totalMaxChars && !options.perFileMaxChars) { + return diff; + } + + const perFileMax = options.perFileMaxChars ?? options.totalMaxChars; + const files = diff.split(/(?=^diff --git )/m).filter(Boolean); + const budgeted = files.map((fileDiff) => { + if (fileDiff.length <= perFileMax) return fileDiff; + const header = fileDiff + .split(/\r?\n/) + .filter((line) => /^(diff --git|index |--- |\+\+\+ |@@ )/.test(line)) + .join('\n'); + return `${header}\n[diff truncated: ${fileDiff.length - header.length} chars omitted]\n`; + }).join(''); + + if (budgeted.length <= options.totalMaxChars) return budgeted; + return `${budgeted.slice(0, options.totalMaxChars)}\n[diff truncated: ${budgeted.length - options.totalMaxChars} chars omitted]\n`; +} diff --git a/src/core/telemetry/SessionLogService.ts b/src/core/telemetry/SessionLogService.ts index 19a504a0..69ad01c3 100644 --- a/src/core/telemetry/SessionLogService.ts +++ b/src/core/telemetry/SessionLogService.ts @@ -29,7 +29,9 @@ export type SessionLogEventType = | 'index_start' | 'index_complete' | 'turn_complete' - | 'ui_trace'; + | 'ui_trace' + | 'microtask_context' + | 'audit_export'; export interface SessionLogEvent { ts: number; diff --git a/src/core/util/paths.ts b/src/core/util/paths.ts index 71849bf3..e4ddce6b 100644 --- a/src/core/util/paths.ts +++ b/src/core/util/paths.ts @@ -1,6 +1,6 @@ import { AGENT_NAME } from '../../shared/brand'; import * as vscode from 'vscode'; -import { basename, dirname, join, relative, resolve, isAbsolute } from 'path'; +import { basename, dirname, join, relative, resolve, isAbsolute, win32 } from 'path'; import { existsSync, readdirSync, statSync } from 'fs'; /** @@ -9,6 +9,9 @@ import { existsSync, readdirSync, statSync } from 'fs'; export function normalizeWorkspaceRoot(workspaceRoot: string | undefined | null): string | null { if (!workspaceRoot?.trim()) return null; const trimmed = workspaceRoot.trim(); + if (isWindowsAbsolutePath(trimmed)) { + return win32.normalize(trimmed); + } const abs = resolve(isAbsolute(trimmed) ? trimmed : resolve(trimmed)); if (!abs) return null; return abs; @@ -87,6 +90,12 @@ export function resolveWorkspaceRelPath( if (!trimmed || trimmed === '.' || trimmed === './') return ''; const normalized = trimmed.replace(/\\/g, '/'); + if (isWindowsAbsolutePath(trimmed)) { + const relPath = win32.relative(win32.normalize(workspace), win32.normalize(trimmed)); + if (!relPath || relPath === '.') return ''; + if (relPath.startsWith('..') || isWindowsAbsolutePath(relPath)) return null; + return normalizeRelPath(relPath); + } if (isAbsolute(normalized)) { const relPath = relative(normalizedWorkspace, resolve(normalized)).replace(/\\/g, '/'); @@ -126,6 +135,10 @@ export function resolveWorkspaceRelPath( return relPath; } +function isWindowsAbsolutePath(value: string): boolean { + return /^[A-Za-z]:[\\/]/.test(value) || /^\\\\[^\\]+\\[^\\]+/.test(value); +} + /** Common extension / naming variants when a path is missing. */ export function pathExistenceVariants(relPath: string): string[] { const variants = new Set([relPath]); diff --git a/src/node/cli.ts b/src/node/cli.ts new file mode 100644 index 00000000..d6e37266 --- /dev/null +++ b/src/node/cli.ts @@ -0,0 +1,99 @@ +#!/usr/bin/env node +import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { join, resolve } from 'path'; +import { AuditPackBuilder } from '../core/audit'; +import { generateHeadlessChangelog, prepareHeadlessRelease } from '../core/headless'; + +async function main(argv: string[]): Promise { + const [command, ...args] = argv; + const cwd = resolve(valueOf(args, '--cwd') ?? process.cwd()); + const since = valueOf(args, '--since'); + const json = args.includes('--json'); + + if (!command || command === '--help' || command === 'help') { + printHelp(); + return 0; + } + + if (command === 'changelog') { + const changelog = await generateHeadlessChangelog(cwd, since); + process.stdout.write(json ? JSON.stringify({ changelog }, null, 2) + '\n' : changelog); + return 0; + } + + if (command === 'prepare-release') { + const result = await prepareHeadlessRelease(cwd, since); + process.stdout.write(json ? JSON.stringify(result, null, 2) + '\n' : result.releaseNotes); + return 0; + } + + if (command === 'export-audit') { + const session = valueOf(args, '--session'); + const output = valueOf(args, '--output') ?? join(cwd, `.mitii/audit/mitii-audit-${Date.now()}.zip`); + const logPath = session && existsSync(session) ? session : latestSessionLog(cwd); + const pack = new AuditPackBuilder().build({ + sessionId: session ?? 'headless', + workspace: cwd, + extensionVersion: readPackageVersion(cwd), + logPath, + summaryMarkdown: logPath ? `# Mitii audit export\n\nLog: ${logPath}\n` : '# Mitii audit export\n\nNo session log found.\n', + }); + writeFileSync(output, pack.buffer); + process.stdout.write(json ? JSON.stringify({ output, entries: pack.entries }, null, 2) + '\n' : `${output}\n`); + return 0; + } + + if (command === 'ask' || command === 'agent' || command === 'commit-msg') { + process.stderr.write(`${command} requires the VS Code extension runtime in this MVP. Use changelog, prepare-release, or export-audit headlessly.\n`); + return 2; + } + + process.stderr.write(`Unknown command: ${command}\n`); + printHelp(); + return 1; +} + +function valueOf(args: string[], name: string): string | undefined { + const idx = args.indexOf(name); + return idx >= 0 ? args[idx + 1] : undefined; +} + +function latestSessionLog(cwd: string): string | undefined { + const dir = join(cwd, '.mitii', 'logs'); + if (!existsSync(dir)) return undefined; + const fs = require('fs') as typeof import('fs'); + const files = fs.readdirSync(dir) + .filter((file) => file.endsWith('.jsonl')) + .sort(); + const last = files[files.length - 1]; + return last ? join(dir, last) : undefined; +} + +function readPackageVersion(cwd: string): string { + try { + const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8')) as { version?: string }; + return pkg.version ?? '0.0.0'; + } catch { + return '0.0.0'; + } +} + +function printHelp(): void { + process.stdout.write([ + 'Mitii CLI', + '', + 'Commands:', + ' mitii changelog [--since ] [--cwd ] [--json]', + ' mitii prepare-release [--since ] [--cwd ] [--json]', + ' mitii export-audit [--session ] [--output ] [--cwd ] [--json]', + '', + ].join('\n')); +} + +void main(process.argv.slice(2)).then((code) => { + process.exitCode = code; +}).catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +}); + diff --git a/src/vscode/commands.ts b/src/vscode/commands.ts index 8111f101..ebf29c62 100644 --- a/src/vscode/commands.ts +++ b/src/vscode/commands.ts @@ -27,10 +27,22 @@ export function registerCommands( await controller.exportSessionLog(); }), + vscode.commands.registerCommand('thunder.exportAuditPack', async () => { + await controller.exportAuditPack(); + }), + vscode.commands.registerCommand('thunder.openSessionLog', async () => { await controller.openSessionLog(); }), + vscode.commands.registerCommand('thunder.generateChangelog', async () => { + await controller.generateChangelog(); + }), + + vscode.commands.registerCommand('thunder.prepareRelease', async () => { + await controller.prepareRelease(); + }), + vscode.commands.registerCommand('thunder.showInlineDiff', async (approvalId?: string) => { if (typeof approvalId === 'string') { await controller.showInlineDiffForApproval(approvalId); diff --git a/src/vscode/webview/messages.ts b/src/vscode/webview/messages.ts index 27446350..d846053f 100644 --- a/src/vscode/webview/messages.ts +++ b/src/vscode/webview/messages.ts @@ -272,6 +272,8 @@ export interface SettingsView { actModel: string; actBaseUrl: string; checkpointStrategy: 'file-copy' | 'git-stash' | 'shadow-git'; + showReasoning: boolean; + reasoningPreviewMaxChars: number; } export interface McpServerStatusView { @@ -458,6 +460,8 @@ export const defaultSettingsView = (): SettingsView => ({ actModel: '', actBaseUrl: '', checkpointStrategy: 'git-stash', + showReasoning: true, + reasoningPreviewMaxChars: 8000, }); export const initialWebviewState = (): WebviewState => ({ diff --git a/src/webview-ui/src/App.tsx b/src/webview-ui/src/App.tsx index c9a21537..228bdc5e 100644 --- a/src/webview-ui/src/App.tsx +++ b/src/webview-ui/src/App.tsx @@ -179,6 +179,8 @@ export function App() { agentActivity={state.agentActivity} agentLiveStatus={state.agentLiveStatus} approvals={state.approvals} + showReasoning={state.settings.showReasoning} + reasoningPreviewMaxChars={state.settings.reasoningPreviewMaxChars} />
diff --git a/src/webview-ui/src/components/MessageList.tsx b/src/webview-ui/src/components/MessageList.tsx index 574faebb..502e55d8 100644 --- a/src/webview-ui/src/components/MessageList.tsx +++ b/src/webview-ui/src/components/MessageList.tsx @@ -12,27 +12,46 @@ interface MessageListProps { agentActivity?: AgentActivityEntry[]; agentLiveStatus?: AgentLiveStatusView | null; approvals?: ApprovalRequestView[]; + showReasoning?: boolean; + reasoningPreviewMaxChars?: number; } function AssistantMessage({ content, reasoningContent, streaming, + showReasoning, + reasoningPreviewMaxChars, }: { content: string; reasoningContent?: string; streaming?: boolean; + showReasoning?: boolean; + reasoningPreviewMaxChars?: number; }) { const revealed = useStreamReveal(content, Boolean(streaming)); return ( <> - + ); } -export function MessageList({ messages, loading, agentActivity = [], agentLiveStatus = null, approvals = [] }: MessageListProps) { +export function MessageList({ + messages, + loading, + agentActivity = [], + agentLiveStatus = null, + approvals = [], + showReasoning = true, + reasoningPreviewMaxChars = 8000, +}: MessageListProps) { const bottomRef = useRef(null); useEffect(() => { @@ -59,10 +78,17 @@ export function MessageList({ messages, loading, agentActivity = [], agentLiveSt content={msg.content} reasoningContent={msg.reasoningContent} streaming={msg.streaming} + showReasoning={showReasoning} + reasoningPreviewMaxChars={reasoningPreviewMaxChars} /> ) : msg.reasoningContent ? ( <> - + {msg.streaming && (

- {streaming ? 'Reasoning...' : 'Reasoning'} -

{trimmed.slice(0, 1200)}{trimmed.length > 1200 ? '...' : ''}

+ {summary} +
{display}
); } diff --git a/src/webview-ui/src/styles/global.css b/src/webview-ui/src/styles/global.css index 46185ac9..da4b60f4 100644 --- a/src/webview-ui/src/styles/global.css +++ b/src/webview-ui/src/styles/global.css @@ -2153,6 +2153,37 @@ body, font-weight: 600; } +.thinking-block[open] summary { + margin-bottom: 6px; +} + +.thinking-block[open] summary::after { + content: ''; + display: inline-block; + width: 6px; + height: 6px; + margin-left: 8px; + border-radius: 999px; + background: #60a5fa; + animation: thinkingPulse 1.2s ease-in-out infinite; +} + +.thinking-block pre { + margin: 0; + max-height: 320px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + font-family: var(--vscode-editor-font-family, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 11px; + line-height: 1.5; +} + +@keyframes thinkingPulse { + 0%, 100% { opacity: 0.35; transform: scale(0.85); } + 50% { opacity: 1; transform: scale(1); } +} + /* ── History ── */ .history-panel { display: flex; diff --git a/test/audit-pack.test.ts b/test/audit-pack.test.ts new file mode 100644 index 00000000..d4fb9175 --- /dev/null +++ b/test/audit-pack.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { AuditPackBuilder } from '../src/core/audit'; + +describe('AuditPackBuilder', () => { + it('builds a zip with expected entries and redacts secrets', () => { + const dir = mkdtempSync(join(tmpdir(), 'mitii-audit-test-')); + try { + const logPath = join(dir, 'session.jsonl'); + writeFileSync(logPath, JSON.stringify({ + ts: 1, + type: 'tool_end', + data: { apiKey: 'sk-abc1234567890', output: 'file contents' }, + }) + '\n'); + const result = new AuditPackBuilder().build({ + sessionId: 's1', + workspace: dir, + extensionVersion: '1.0.0', + logPath, + summaryMarkdown: '# Summary', + toolAudit: [], + stripFileContents: true, + }); + + const zipText = result.buffer.toString('latin1'); + expect(result.entries).toEqual([ + 'session.jsonl', + 'summary.md', + 'manifest.json', + 'tool-audit.json', + 'approvals.json', + 'redaction-report.json', + ]); + expect(result.buffer.slice(0, 2).toString()).toBe('PK'); + expect(zipText).toContain('session.jsonl'); + expect(zipText).toContain('[REDACTED]'); + expect(zipText).not.toContain('sk-abc1234567890'); + expect(result.redactionReport.secretKeyRedactions).toBeGreaterThan(0); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + diff --git a/test/microtasks.test.ts b/test/microtasks.test.ts new file mode 100644 index 00000000..7523cbf3 --- /dev/null +++ b/test/microtasks.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import { budgetDiff } from '../src/core/scm/GitDiffCollector'; +import { buildCommitMessagePrompt, redactSensitiveDiff } from '../src/core/scm/commitMessagePrompt'; +import { detectMicroTask, MicroTaskExecutor } from '../src/core/microtasks'; +import type { GitService } from '../src/core/context/GitService'; +import type { LlmProvider } from '../src/core/llm/types'; + +describe('microtasks', () => { + it('detects supported micro-task intents', () => { + expect(detectMicroTask('write commit message please')).toBe('commit_message'); + expect(detectMicroTask('what changed since v1.2.0')).toBe('changelog_entry'); + expect(detectMicroTask("draft what's new")).toBe('release_notes_draft'); + expect(detectMicroTask('explain the auth flow')).toBeNull(); + }); + + it('redacts sensitive diff lines in prompts', () => { + const prompt = buildCommitMessagePrompt({ + stagedDiff: '+ API_KEY=sk-secret-value', + unstagedDiff: '', + changedFiles: ['M src/index.ts'], + recentCommits: [], + branch: 'main', + }); + expect(prompt).toContain('[redacted sensitive line]'); + expect(prompt).not.toContain('sk-secret-value'); + }); + + it('budgets large diffs by file and total caps', () => { + const diff = [ + 'diff --git a/a.ts b/a.ts', + 'index 111..222', + '--- a/a.ts', + '+++ b/a.ts', + '@@ -1 +1 @@', + `+${'x'.repeat(5000)}`, + 'diff --git a/b.ts b/b.ts', + 'index 333..444', + '--- a/b.ts', + '+++ b/b.ts', + '@@ -1 +1 @@', + '+ok', + ].join('\n'); + const out = budgetDiff(diff, { totalMaxChars: 1000, perFileMaxChars: 200 }); + expect(out.length).toBeLessThanOrEqual(1100); + expect(out).toContain('diff truncated'); + expect(out).toContain('diff --git a/b.ts b/b.ts'); + }); + + it('runs commit-message micro-task with toolChoice none', async () => { + const calls: unknown[] = []; + const provider: LlmProvider = { + id: 'fake', + capabilities: { contextWindow: 8192, supportsEmbeddings: false, supportsStreaming: true, supportsTools: true }, + async *complete(request) { + calls.push(request); + yield { content: 'feat: add audit pack export' }; + yield { done: true }; + }, + }; + const git = { + isGitRepo: true, + getStagedDiff: async () => 'diff --git a/src/a.ts b/src/a.ts\n+export const a = 1;', + getUnstagedDiff: async () => '', + getChangedFilesDetailed: async () => ['M src/a.ts'], + getRecentCommits: async () => ['abc123 feat: old thing'], + getCurrentBranch: async () => 'main', + } as unknown as GitService; + + const result = await new MicroTaskExecutor({ workspace: process.cwd(), git, provider }).execute( + 'commit_message', + 'write commit message' + ); + + expect(result.content).toBe('feat: add audit pack export'); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ toolChoice: 'none', maxTokens: 240 }); + }); + + it('redacts standalone sensitive diff helper output', () => { + expect(redactSensitiveDiff('+password=abc123')).toBe('+[redacted sensitive line]'); + }); +}); + diff --git a/test/release.test.ts b/test/release.test.ts new file mode 100644 index 00000000..a8944737 --- /dev/null +++ b/test/release.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { generateChangelogEntry, insertChangelogEntry } from '../src/core/release/ChangelogGenerator'; +import { generateReleaseNotes } from '../src/core/release/ReleaseNotesGenerator'; +import { parseConventionalSubject } from '../src/core/release/GitHistoryCollector'; + +describe('release automation', () => { + const commits = [ + { hash: 'aaa111', subject: 'feat(ui): stream reasoning', type: 'feat', scope: 'ui', description: 'stream reasoning', breaking: false }, + { hash: 'bbb222', subject: 'fix: redact token', type: 'fix', description: 'redact token', breaking: false }, + { hash: 'ccc333', subject: 'feat!: change api', type: 'feat', description: 'change api', breaking: true }, + ]; + + it('groups conventional commits into changelog sections', () => { + const out = generateChangelogEntry({ commits, version: '1.2.3', date: new Date('2026-07-02T00:00:00Z') }); + expect(out).toContain('## [1.2.3] - 2026-07-02'); + expect(out).toContain('### Breaking'); + expect(out).toContain('### Added'); + expect(out).toContain('**ui:** stream reasoning'); + expect(out).toContain('### Fixed'); + }); + + it('detects breaking changes from subject and body', () => { + expect(parseConventionalSubject('feat!: replace config').breaking).toBe(true); + expect(parseConventionalSubject('feat: replace config', 'BREAKING CHANGE: config moved').breaking).toBe(true); + }); + + it('inserts entries after Unreleased', () => { + const out = insertChangelogEntry('# Changelog\n\n## [Unreleased]\n', '## [1.0.0] - 2026-07-02\n'); + expect(out).toMatch(/## \[Unreleased\]\n\n## \[1\.0\.0\]/); + }); + + it('generates empty release-note fallback', () => { + expect(generateReleaseNotes({ commits: [], version: '1.0.0', date: new Date() })).toContain('No user-facing changes'); + }); +}); + diff --git a/test/windows-paths.test.ts b/test/windows-paths.test.ts new file mode 100644 index 00000000..333acf5c --- /dev/null +++ b/test/windows-paths.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeRelPath, resolveWorkspaceRelPath } from '../src/core/util/paths'; +import { npxMcpServer } from '../src/core/mcp/npxCommand'; + +describe('Windows path hardening', () => { + it('resolves drive-letter absolute paths to workspace-relative POSIX paths', () => { + expect(resolveWorkspaceRelPath('C:\\Users\\dev\\repo', 'C:\\Users\\dev\\repo\\src\\foo.ts')).toBe('src/foo.ts'); + }); + + it('normalizes mixed separators', () => { + expect(normalizeRelPath('src\\bar\\baz.ts')).toBe('src/bar/baz.ts'); + }); + + it('handles UNC workspace paths', () => { + expect(resolveWorkspaceRelPath('\\\\server\\share\\repo', '\\\\server\\share\\repo\\src\\foo.ts')).toBe('src/foo.ts'); + }); + + it('rejects Windows absolute paths outside the workspace', () => { + expect(resolveWorkspaceRelPath('C:\\Users\\dev\\repo', 'D:\\other\\file.ts')).toBeNull(); + }); + + it('rejects dot-dot relative escapes', () => { + expect(resolveWorkspaceRelPath('C:\\Users\\dev\\repo', '..\\secret.ts')).toBeNull(); + }); + + it('uses cmd /c npx on Windows when process platform is mocked', () => { + const original = Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { value: 'win32' }); + try { + expect(npxMcpServer('@modelcontextprotocol/server-filesystem', '.')).toEqual({ + command: 'cmd', + args: ['/c', 'npx', '-y', '@modelcontextprotocol/server-filesystem', '.'], + }); + } finally { + if (original) Object.defineProperty(process, 'platform', original); + } + }); +}); + From d651d56666ebfa8b70f6b07ed55672be3896a00a Mon Sep 17 00:00:00 2001 From: codewithshinde Date: Thu, 2 Jul 2026 16:27:59 -0500 Subject: [PATCH 03/12] feat: add onboarding and review diff features - Introduced onboarding panel for initial setup, allowing users to configure provider settings and index workspace. - Added review panel to display diffs of changes, enabling users to provide feedback on code changes. - Enhanced chat input to support image attachments, allowing users to send images along with messages. - Updated state management to handle new onboarding and review diff states. - Improved validation for provider settings during onboarding and in settings panel. - Added styles for new components and improved existing styles for better UI/UX. - Implemented tests for new features, ensuring functionality and validation logic are working as expected. --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 27 +++ README.md | 18 +- benchmark/run-benchmark.mjs | 94 +++++++++ benchmark/tasks.json | 20 ++ package-lock.json | 4 +- package.json | 35 +++- scripts/check-release-assets.mjs | 25 +++ scripts/sync-readme-version.mjs | 18 ++ src/core/app/ThunderController.ts | 150 ++++++++++++-- src/core/audit/AuditPackBuilder.ts | 95 ++++++++- src/core/config/schema.ts | 5 + src/core/config/ui/mappers.ts | 40 ++++ src/core/config/ui/payloads.ts | 3 + src/core/config/vscode/read.ts | 5 + src/core/config/vscode/write.ts | 9 + src/core/headless/AgentRunner.ts | 108 ++++++++++ src/core/headless/index.ts | 2 +- src/core/llm/AnthropicProvider.ts | 22 ++ src/core/llm/BedrockProvider.ts | 2 + src/core/llm/EchoProvider.ts | 2 + src/core/llm/GeminiProvider.ts | 20 ++ src/core/llm/OpenAiCompatibleProvider.ts | 16 +- src/core/llm/createProvider.ts | 17 +- src/core/llm/modelCapabilities.ts | 68 +++++++ src/core/llm/types.ts | 10 + src/core/orchestration/ChatOrchestrator.ts | 24 ++- src/core/scm/ReviewDiffCollector.ts | 95 +++++++++ src/core/scm/index.ts | 1 + src/core/telemetry/SessionLogService.ts | 15 ++ src/core/telemetry/WebhookEmitter.ts | 92 +++++++++ src/core/telemetry/index.ts | 1 + src/extension.ts | 2 + src/node/cli.ts | 93 ++++++++- src/vscode/nativeModuleHealth.ts | 51 +++++ src/vscode/webview/ThunderWebviewProvider.ts | 22 +- src/vscode/webview/messages.ts | 50 ++++- src/webview-ui/src/App.tsx | 30 ++- src/webview-ui/src/components/ChatInput.tsx | 91 ++++++++- src/webview-ui/src/components/Icons.tsx | 10 + .../src/components/OnboardingPanel.tsx | 192 ++++++++++++++++++ src/webview-ui/src/components/ReviewPanel.tsx | 62 ++++++ .../src/components/SettingsPanel.tsx | 16 +- src/webview-ui/src/state/store.ts | 7 +- .../src/state/useVsCodeMessaging.ts | 3 + src/webview-ui/src/styles/global.css | 180 ++++++++++++++++ test/act/act-orchestration.test.ts | 13 ++ test/audit-pack.test.ts | 23 ++- test/messages.test.ts | 1 + test/top10-features.test.ts | 151 ++++++++++++++ 50 files changed, 1981 insertions(+), 61 deletions(-) create mode 100644 benchmark/run-benchmark.mjs create mode 100644 benchmark/tasks.json create mode 100644 scripts/check-release-assets.mjs create mode 100644 scripts/sync-readme-version.mjs create mode 100644 src/core/headless/AgentRunner.ts create mode 100644 src/core/llm/modelCapabilities.ts create mode 100644 src/core/scm/ReviewDiffCollector.ts create mode 100644 src/core/telemetry/WebhookEmitter.ts create mode 100644 src/vscode/nativeModuleHealth.ts create mode 100644 src/webview-ui/src/components/OnboardingPanel.tsx create mode 100644 src/webview-ui/src/components/ReviewPanel.tsx create mode 100644 test/top10-features.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1a8d123..287860b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,5 +21,5 @@ jobs: node-version: 20 cache: npm - run: npm ci + - run: npm run release:check-assets - run: npm test - diff --git a/CHANGELOG.md b/CHANGELOG.md index 1410f16b..24b97afc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,3 +2,30 @@ ## [Unreleased] +## [2.7.17] - 2026-07-02 + +### Added +- Implemented the backlog end-to-end across the repo. What changed: Added diff-first micro-task routing for commit messages, changelog entries, and release notes. Unified SCM commit message generation through the new micro-task executor. Added changelog/release generation core, VS Code commands, CHANGELOG.md, and CI release workflow. Added one-click audit pack export with zip generation, redaction report, manifest, tool audit, and approvals. Improved reasoning stream UI: live reveal, no 1200-char hard cap, configurable visibility/preview size. Added enterprise settings/docs for local-only providers, audit redaction, procurement/security/compliance. Added Windows path hardening, Windows CI matrix, and Windows smoke checklist. Added CLI MVP for changelog, prepare-release, and export-audit. Updated README with the new enterprise, audit, release, CLI, and reasoning features. Added focused tests for all new core implementations. (8a00fd537ef2) +- add AWS Bedrock, Azure OpenAI, and OpenRouter provider support (cac73bc1a9a7) +- **modes:** add pilot and enterprise depth levels to Ask, Plan, and Act modes (ca3d2daf6375) +- **orchestrator:** add planning clarification question flow with resume support (0d052937fb8e) +- auto-discover verify commands, add retry with install, cap sequential-thinking calls (6786a1d904fe) +- improve plan step UI, add skipped tool handling, and strip channel markers (d65a81752037) +- add GitHub token setting and improve issue comment pagination (7c8e53d68036) +- Implemented the core folder structure migration safely. (017ddd4d0766) +- auto-fetch GitHub issue context when user pastes an issue URL (1f0b368dfb50) +- add retrieval timing metrics, repeated tool failure guard, and Act mode MCP exclusions (dda2e8f38881) +- add Act orchestration boundary for Agent mode execution (0620f2e55902) +- **docs:** update README with enhanced project description, features, and usage instructions (920bf12f6f38) +- **plan:** add skill-aware planning pipeline with phased PlanPanel UI (e23f19d9e929) +- bundle skill playbooks inside extension and auto-install on workspace init (07a83238eb87) +- add planDepth setting and skill frontmatter parsing (d77ed8f6062f) +- **plan-mode:** add Plan mode orchestration with intent routing and read-only grounding (14372af57d12) +- **core:** add file read caching and parallelize read files tool (95dee1dbc8ac) +- **scm:** add AI-generated commit messages and ask-mode depth controls (8ea94111e43f) +- **ask:** add structured ask mode with intent routing, scope resolution, and impact analysis (aa2660f9ef3d) + +### Changed +- add project-goals entry to .gitignore configuration (81ecf0da086c) +- Merge branch 'main' of https://github.com/codewithshinde/thunder-ai-agent (957469705bc4) +- **tools:** remove unused resolveToolDirPath and related imports (44a3dd3d0a8c) diff --git a/README.md b/README.md index 9537104c..b7133f10 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ License: AGPL v3 VS Code 1.85+ Node 20+ - Version 2.7.16 + Version 2.7.17 Website Docs

@@ -150,9 +150,9 @@ Paste a GitHub issue URL in Agent mode, for example: Fix https://github.com/owner/repo/issues/123 ``` -Mitii detects `github.com/{owner}/{repo}/issues/{number}`, fetches the issue through the GitHub REST API when network access is allowed, and injects a structured context block containing the title, body, state, labels, assignees, milestone, and recent comments. The Act router treats the issue signal as a verified bugfix workflow, so the agent investigates the open workspace, makes scoped edits, and runs relevant verification. +Mitii detects `github.com/{owner}/{repo}/issues/{number}`, fetches the issue through the GitHub REST API when network access is allowed, and injects a structured context block containing the title, body, state, labels, assignees, milestone, and newest comments. When the fetch succeeds, the Act router treats the issue signal as a verified bugfix workflow, so the agent investigates the open workspace, makes scoped edits, and runs relevant verification. -If the active safety preset disables network access, Mitii still injects a lightweight reference block with the repository and issue number instead of scraping GitHub HTML. Private repository support uses a GitHub token stored in VS Code SecretStorage under `thunder.github.token` by default; the setting stores only the secret key name, not the token value. +If the active safety preset disables network access or GitHub cannot be reached, Mitii still injects a lightweight reference block with the repository and issue number instead of scraping GitHub HTML. Private repository support uses a GitHub token stored in VS Code SecretStorage under `thunder.github.token` by default; enter or replace the token from **Settings → Integrations → GitHub issues**. The VS Code setting stores only the secret key name, not the token value. ### 4. Safer Tool Execution @@ -204,7 +204,7 @@ Mitii stores useful state locally so every serious task does not start from zero Post-task memory extraction can capture useful observations after completed work, so future sessions can reuse decisions without asking you to repeat context. -Audit review is available through `Mitii: Export Audit Pack`. The zip contains sanitized `session.jsonl`, `summary.md`, `manifest.json`, `tool-audit.json`, `approvals.json`, and `redaction-report.json`. +Audit review is available through `Mitii: Export Audit Pack`. The zip contains sanitized `session.jsonl`, `summary.md`, `manifest.json`, `tool-audit.json`, `approvals.json`, `redaction-report.json`, and `signature.json` with SHA-256 hashes for tamper detection. Set `MITII_AUDIT_SIGNING_KEY` to add HMAC signing, then verify archives with `mitii verify-audit `. ### Release Automation @@ -217,6 +217,7 @@ Mitii includes release hygiene commands: | `mitii changelog` | Headless changelog entry for CI/scripts | | `mitii prepare-release` | Headless changelog + release-notes generation | | `mitii export-audit` | Headless audit pack export from JSONL logs | +| `mitii verify-audit` | Verify audit pack signatures and file hashes | ### 7. Skills And Project Playbooks @@ -262,6 +263,8 @@ The sidebar is a React webview with: Reasoning deltas from supported providers stream live in the chat UI. Use `thunder.ui.showReasoning` and `thunder.ui.reasoningPreviewMaxChars` to control visibility and inline preview size. +Mitii also detects common model capabilities from the provider/model name, including vision and reasoning support. Enterprise teams can override detection with `thunder.provider.supportsVision` and `thunder.provider.supportsReasoning` when routing through private or custom OpenAI-compatible gateways. + ## Enterprise Readiness Enterprise review materials live in [docs/enterprise](docs/enterprise/README.md). The pack covers data flow, provider boundaries, procurement FAQs, compliance mapping, Windows support, and auditability. @@ -271,7 +274,10 @@ Enterprise review materials live in [docs/enterprise](docs/enterprise/README.md) | Route narrow Git/release tasks through minimal context | `thunder.context.microTaskRoutingEnabled` | | Require local model providers | `thunder.enterprise.localProvidersOnly` | | Strip file contents from exported audit packs | `thunder.enterprise.stripFileContentsFromAuditPacks` | +| Auto-export audit packs after agent turns | `thunder.enterprise.autoExportAuditPackOnSessionEnd` | +| Verify audit pack integrity | `mitii verify-audit ` | | Disable session logging | `thunder.telemetry.sessionLogging` | +| Stream sanitized SIEM events | `thunder.telemetry.webhookUrl` and optional `thunder.telemetry.webhookSecret` | | Export audit evidence | `Mitii: Export Audit Pack` | | Windows smoke checklist | [docs/qa/WINDOWS_SMOKE.md](docs/qa/WINDOWS_SMOKE.md) | @@ -471,7 +477,9 @@ Use the Echo provider for UI testing without an LLM. API keys are stored through "thunder.github.issueCommentLimit": 8, "thunder.github.tokenRef": "thunder.github.token", "thunder.agent.verifyCommands": ["npm run lint", "npm test"], - "thunder.telemetry.sessionLogging": true + "thunder.telemetry.sessionLogging": true, + "thunder.telemetry.webhookUrl": "", + "thunder.enterprise.autoExportAuditPackOnSessionEnd": false } ``` diff --git a/benchmark/run-benchmark.mjs b/benchmark/run-benchmark.mjs new file mode 100644 index 00000000..e8ffe0bd --- /dev/null +++ b/benchmark/run-benchmark.mjs @@ -0,0 +1,94 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { spawnSync } from 'child_process'; +import { dirname, join, resolve } from 'path'; + +const cwd = resolve(process.argv.includes('--cwd') ? process.argv[process.argv.indexOf('--cwd') + 1] : process.cwd()); +const tasksPath = resolve(process.argv.includes('--tasks') ? process.argv[process.argv.indexOf('--tasks') + 1] : 'benchmark/tasks.json'); +const outputPath = resolve(process.argv.includes('--output') ? process.argv[process.argv.indexOf('--output') + 1] : '.mitii/benchmark/report.json'); +const provider = process.argv.includes('--provider') ? process.argv[process.argv.indexOf('--provider') + 1] : 'echo'; +if (!existsSync('dist/cli.js')) { + const compile = spawnSync('npm', ['run', 'compile:cli'], { cwd, stdio: 'inherit' }); + if (compile.status) process.exit(compile.status ?? 1); +} +const cliPath = 'dist/cli.js'; +const tasks = JSON.parse(readFileSync(tasksPath, 'utf8')); + +const results = tasks.map((task) => runTask(task)); +const passed = results.filter((result) => result.passed).length; +const report = { + cwd, + provider, + startedAt: new Date().toISOString(), + summary: { + total: results.length, + passed, + failed: results.length - passed, + }, + results, +}; + +mkdirSync(dirname(outputPath), { recursive: true }); +writeFileSync(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); +writeFileSync(outputPath.replace(/\.json$/, '.md'), toMarkdown(report), 'utf8'); +console.log(`${passed}/${results.length} benchmark tasks passed`); +if (passed !== results.length) process.exitCode = 1; + +function runTask(task) { + const args = [cliPath, task.mode, task.prompt, '--cwd', cwd, '--provider', provider]; + if (task.mode !== 'ask') args.push('--json'); + const started = Date.now(); + const result = spawnSync('node', args, { cwd, encoding: 'utf8' }); + const durationMs = Date.now() - started; + const stdout = result.stdout ?? ''; + const stderr = result.stderr ?? ''; + const passed = result.status === 0 && verify(task.verify, stdout); + return { + id: task.id, + mode: task.mode, + passed, + durationMs, + exitCode: result.status, + stdout: stdout.slice(0, 4000), + stderr: stderr.slice(0, 2000), + }; +} + +function verify(rule, stdout) { + if (!rule) return true; + if (rule.startsWith('stdout_contains:')) return stdout.includes(rule.slice('stdout_contains:'.length)); + if (rule.startsWith('json_path:')) { + const key = rule.slice('json_path:'.length); + try { + return Boolean(JSON.parse(stdout)[key]); + } catch { + return false; + } + } + if (rule.startsWith('jsonl_event:')) { + const type = rule.slice('jsonl_event:'.length); + return stdout.split(/\r?\n/).some((line) => { + try { + return JSON.parse(line).type === type; + } catch { + return false; + } + }); + } + return false; +} + +function toMarkdown(report) { + return [ + '# Mitii Benchmark Report', + '', + `Provider: ${report.provider}`, + `Score: ${report.summary.passed}/${report.summary.total}`, + '', + '| Task | Mode | Result | Duration |', + '|---|---|---|---:|', + ...report.results.map((result) => + `| ${result.id} | ${result.mode} | ${result.passed ? 'pass' : 'fail'} | ${result.durationMs} ms |` + ), + '', + ].join('\n'); +} diff --git a/benchmark/tasks.json b/benchmark/tasks.json new file mode 100644 index 00000000..1235074d --- /dev/null +++ b/benchmark/tasks.json @@ -0,0 +1,20 @@ +[ + { + "id": "repo-map-summary", + "mode": "ask", + "prompt": "Summarize the project structure and identify the main extension entry point.", + "verify": "stdout_contains:Echo:" + }, + { + "id": "release-plan", + "mode": "plan", + "prompt": "Plan a release hygiene pass covering changelog, README version badge, and media assets.", + "verify": "json_path:steps" + }, + { + "id": "headless-agent-events", + "mode": "agent", + "prompt": "Review the current working tree and produce implementation guidance.", + "verify": "jsonl_event:end" + } +] diff --git a/package-lock.json b/package-lock.json index 5914439d..84457209 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mitii-ai-agent", - "version": "2.7.17", + "version": "2.7.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mitii-ai-agent", - "version": "2.7.17", + "version": "2.7.18", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "dependencies": { diff --git a/package.json b/package.json index 6d6af002..56d8cb7b 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "mitii-ai-agent", "displayName": "Mitii AI Agent", "description": "Local-first VS Code AI coding agent with precise repo context and safe Plan/Act workflow", - "version": "2.7.17", + "version": "2.7.18", "publisher": "mitii", "icon": "media/mitii-short-logo.png", "license": "AGPL-3.0-or-later", @@ -163,6 +163,23 @@ "default": false, "description": "Capture extra session diagnostics (tool inputs, context sources, LLM step metadata). Process timing is always logged when session logging is on." }, + "thunder.telemetry.webhookUrl": { + "type": "string", + "default": "", + "description": "Optional SIEM/webhook endpoint. When set, sanitized session events are POSTed as JSON." + }, + "thunder.telemetry.webhookSecret": { + "type": "string", + "default": "", + "description": "Optional HMAC signing secret for webhook events. Prefer workspace or managed settings for enterprise deployments." + }, + "thunder.telemetry.webhookTimeoutMs": { + "type": "number", + "default": 5000, + "minimum": 1000, + "maximum": 60000, + "description": "Timeout in milliseconds for telemetry webhook delivery attempts." + }, "thunder.ui.showReasoning": { "type": "boolean", "default": true, @@ -188,7 +205,7 @@ "thunder.enterprise.autoExportAuditPackOnSessionEnd": { "type": "boolean", "default": false, - "description": "Reserved enterprise toggle for exporting audit packs automatically when a session ends." + "description": "Export a sanitized audit pack automatically to .mitii/audit/ when an agent turn completes." }, "thunder.provider.type": { "type": "string", @@ -233,6 +250,14 @@ "default": 8192, "description": "Model context window size. Thunder trims prompts automatically to stay within this limit." }, + "thunder.provider.supportsVision": { + "type": "boolean", + "description": "Optional override for model vision support. Leave unset to use automatic provider/model detection." + }, + "thunder.provider.supportsReasoning": { + "type": "boolean", + "description": "Optional override for streamed reasoning support. Leave unset to use automatic provider/model detection." + }, "thunder.indexing.enabled": { "type": "boolean", "default": true, @@ -592,6 +617,10 @@ "postinstall": "node -e \"console.log('Run npm run rebuild:native before F5 if better-sqlite3 fails to load')\"", "prepare": "node scripts/install-git-hooks.mjs", "version:bump": "node scripts/bump-version.mjs", + "readme:sync-version": "node scripts/sync-readme-version.mjs", + "release:check-assets": "node scripts/check-release-assets.mjs", + "release:prepare": "npm run compile:cli && node dist/cli.js prepare-release", + "benchmark": "node benchmark/run-benchmark.mjs", "compile:extension": "esbuild src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --packages=external --platform=node --format=cjs --sourcemap", "compile:webview": "vite build", "compile:cli": "esbuild src/node/cli.ts --bundle --outfile=dist/cli.js --platform=node --format=cjs --packages=external", @@ -616,7 +645,7 @@ "checkpoint:read": "bash scripts/read-checkpoint.sh", "lint:safe-target": "bash scripts/safe-lint-target.sh", "env:sync": "node scripts/sync-env-files.mjs", - "preflight": "npm run lint && npm run rebuild:node && npm test && npm run rebuild:native", + "preflight": "npm run release:check-assets && npm run lint && npm run rebuild:node && npm test && npm run rebuild:native", "clean:vsce-deps": "node -e \"const fs=require('fs'); for (const p of ['node_modules/@emnapi','node_modules/@napi-rs','node_modules/@tybys']) fs.rmSync(p,{recursive:true,force:true});\"", "package": "npm run clean:vsce-deps && vsce package", "package:preflight": "npm run preflight && npm run package", diff --git a/scripts/check-release-assets.mjs b/scripts/check-release-assets.mjs new file mode 100644 index 00000000..569b4d0d --- /dev/null +++ b/scripts/check-release-assets.mjs @@ -0,0 +1,25 @@ +import { existsSync, readFileSync } from 'fs'; +import { basename } from 'path'; + +const pkg = JSON.parse(readFileSync('package.json', 'utf8')); +const readme = readFileSync('README.md', 'utf8'); + +const requiredAssets = [ + pkg.icon, + 'media/Mitii.png', +].filter(Boolean); + +const missingAssets = requiredAssets.filter((asset) => !existsSync(asset)); +if (missingAssets.length > 0) { + console.error(`Missing release media assets: ${missingAssets.join(', ')}`); + process.exit(1); +} + +const version = String(pkg.version); +if (!readme.includes(`alt="Version ${version}"`) || !readme.includes(`badge/version-${version}-111111`)) { + console.error(`README version badge is out of sync with package.json (${pkg.version}).`); + console.error('Run: npm run readme:sync-version'); + process.exit(1); +} + +console.log(`Release assets OK: ${requiredAssets.map((asset) => basename(asset)).join(', ')}; README version ${version}`); diff --git a/scripts/sync-readme-version.mjs b/scripts/sync-readme-version.mjs new file mode 100644 index 00000000..b145793b --- /dev/null +++ b/scripts/sync-readme-version.mjs @@ -0,0 +1,18 @@ +import { readFileSync, writeFileSync } from 'fs'; + +const pkg = JSON.parse(readFileSync('package.json', 'utf8')); +const readmePath = 'README.md'; +const readme = readFileSync(readmePath, 'utf8'); +const version = String(pkg.version); + +const next = readme.replace( + /Version [^/, + `Version ${version}` +); + +if (next === readme) { + throw new Error('Could not find README version badge to update.'); +} + +writeFileSync(readmePath, next, 'utf8'); +console.log(`README version badge synced to ${version}`); diff --git a/src/core/app/ThunderController.ts b/src/core/app/ThunderController.ts index 90744c3d..7dd3c153 100644 --- a/src/core/app/ThunderController.ts +++ b/src/core/app/ThunderController.ts @@ -99,10 +99,12 @@ import type { CommitMessageResult } from '../scm'; import { MicroTaskExecutor } from '../microtasks'; import { AuditPackBuilder } from '../audit'; import { GitHistoryCollector, generateChangelogEntry, generateReleaseNotes, insertChangelogEntry } from '../release'; +import { collectReviewDiff } from '../scm/ReviewDiffCollector'; import { normalizeAgentSettings, normalizeProviderSettings, normalizeThunderSettings, + validateProviderSettings, resolveAutoContextWindow, } from '../config/ui/mappers'; import type { @@ -114,6 +116,7 @@ import type { } from '../config/ui/payloads'; const log = createLogger('ThunderController'); +const ONBOARDING_STATE_KEY = 'thunder.onboarding.completed.v1'; export type UiUpdateCallback = (partial: Partial) => void; @@ -149,6 +152,7 @@ export class ThunderController { private inlineDiffManager: InlineDiffManager | undefined; private researchAgentProvider: LlmProvider | undefined; private sessionLog = new SessionLogService(); + private lastAutoAuditExportSignature = ''; private lastSubagentSnapshot = new Map(); private indexingStatus: IndexingStatus = { indexed: 0, queued: 0, running: false, failed: 0, total: 0, activeWorkers: 0, processed: 0, runTotal: 0 }; private contextToggles: ContextToggles = defaultContextToggles(); @@ -157,6 +161,7 @@ export class ThunderController { private watchDebounceTimer: ReturnType | undefined; private debouncedRebuildRetriever: (() => void) | undefined; private currentPlan: PlanView | null = null; + private currentReviewDiff: WebviewState['reviewDiff'] = null; private agentActivity: import('../../vscode/webview/messages').AgentActivityEntry[] = []; private agentLiveStatus: import('../../vscode/webview/messages').AgentLiveStatusView | null = null; private tokenUsage: Omit = { @@ -284,6 +289,11 @@ export class ThunderController { private configureSessionLogging(session: ThunderSession, workspace: string): void { const telemetry = this.configService.getConfig().telemetry; this.sessionLog.configure(workspace, session.id, telemetry.sessionLogging, telemetry.debugMetrics); + this.sessionLog.configureWebhook({ + url: telemetry.webhookUrl, + secret: telemetry.webhookSecret || process.env.MITII_TELEMETRY_WEBHOOK_SECRET, + timeoutMs: telemetry.webhookTimeoutMs, + }); this.sessionLog.writeSessionHeader({ mode: session.mode, model: this.configService.getConfig().provider.model, @@ -882,6 +892,8 @@ export class ThunderController { const vscodeFolders = this.getVscodeWorkspaceFolders(); const indexDbPath = workspacePath ? resolveDbPath(workspacePath) : ''; const appVersion = String(this.context.extension.packageJSON.version ?? ''); + const onboardingCompleted = this.context.globalState.get(ONBOARDING_STATE_KEY, false); + const providerConfigured = config.provider.type !== 'echo' || Boolean(apiKey); const approvals: ApprovalRequestView[] = (this.approvalQueue?.getPending() ?? []).map((r) => ({ id: r.id, @@ -931,6 +943,13 @@ export class ThunderController { indexing: this.indexingStatus, approvals, plan: this.currentPlan, + reviewDiff: base.reviewDiff ?? this.currentReviewDiff, + onboarding: { + completed: onboardingCompleted, + providerConfigured, + workspaceIndexed: this.indexingStatus.indexed > 0, + shouldShow: !onboardingCompleted && (!providerConfigured || this.indexingStatus.indexed === 0), + }, memories: (this.memoryService?.recent(20) ?? []).map((m) => ({ id: m.id, type: m.type, @@ -1026,6 +1045,21 @@ export class ThunderController { }; } + async refreshReviewDiff(): Promise { + if (!this.gitService?.isGitRepo) { + this.currentReviewDiff = null; + this.notifyUi({ reviewDiff: null }); + return; + } + this.currentReviewDiff = await collectReviewDiff(this.gitService); + this.notifyUi({ reviewDiff: this.currentReviewDiff }); + } + + async completeOnboarding(): Promise { + await this.context.globalState.update(ONBOARDING_STATE_KEY, true); + this.notifyUi({ onboarding: (await this.buildUiState()).onboarding }); + } + private pushActivity( kind: import('../../vscode/webview/messages').AgentActivityEntry['kind'], message: string, @@ -1509,8 +1543,12 @@ export class ThunderController { async sendMessage( content: string, - recentMessages: Array<{ role: 'user' | 'assistant'; content: string }> = [], - options?: { preserveActivity?: boolean; pinnedContext?: PinnedContextView[] } + recentMessages: Array<{ role: 'user' | 'assistant'; content: string; attachments?: import('../../vscode/webview/messages').ChatImageAttachment[] }> = [], + options?: { + preserveActivity?: boolean; + pinnedContext?: PinnedContextView[]; + attachments?: import('../../vscode/webview/messages').ChatImageAttachment[]; + } ): Promise> { if (!this.session) throw normalizeError(new Error('Session not initialized')); const provider = await this.resolveProviderForMode(this.session.mode); @@ -1553,6 +1591,7 @@ export class ThunderController { }); return this.chatOrchestrator.send(this.session, meteredProvider, content, recentMessages, { pinnedContext: options?.pinnedContext ?? this.pinnedContext, + attachments: options?.attachments, }); } @@ -1716,9 +1755,52 @@ export class ThunderController { hadError, tools: audit.map((a) => a.toolName), }); + this.sessionLog.append('session_end', hadError ? 'Session completed with issues' : 'Session completed', { + sessionId: this.session?.id, + hadError, + toolCalls: audit.length, + }); + void this.maybeAutoExportAuditPack(hadError); this.notifyUi({ agentActivity: this.agentActivity, agentLiveStatus: null }); } + private async maybeAutoExportAuditPack(hadError: boolean): Promise { + const config = this.configService.getConfig(); + if (!config.enterprise.autoExportAuditPackOnSessionEnd) return; + + const workspace = this.resolveWorkspacePath(); + if (!workspace) return; + const sessionId = this.session?.id ?? 'no-session'; + const logPath = this.sessionLog.getLogPath(); + const signature = `${sessionId}:${logPath}:${this.sessionLog.exportForAnalysis().length}:${this.toolRuntime.getAuditLog().length}`; + if (signature === this.lastAutoAuditExportSignature) return; + this.lastAutoAuditExportSignature = signature; + + try { + const target = join( + workspace, + '.mitii', + 'audit', + `mitii-audit-${sessionId}-${formatTimestampForFile(Date.now())}.zip` + ); + mkdirSync(dirname(target), { recursive: true }); + const pack = this.buildAuditPack(workspace, sessionId, config.enterprise.stripFileContentsFromAuditPacks); + writeFileSync(target, pack.buffer); + this.sessionLog.append('audit_export', 'Audit pack auto-exported', { + path: target, + entries: pack.entries, + redactionReport: pack.redactionReport, + hadError, + automatic: true, + }); + this.pushActivity('info', 'Audit pack saved', target); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.sessionLog.append('error', 'Automatic audit pack export failed', { error: message }); + this.pushActivity('error', 'Automatic audit pack export failed', message); + } + } + private buildTurnSummary(audit: import('../tools/types').ToolCallAudit[]): string { const lines: string[] = []; const writes = new Set(); @@ -1816,17 +1898,7 @@ export class ThunderController { if (!target) return; const config = this.configService.getConfig(); - const pack = new AuditPackBuilder().build({ - sessionId, - workspace, - extensionVersion: this.context.extension.packageJSON.version ?? '', - model: `${config.provider.type}/${config.provider.model}`, - logPath: this.sessionLog.getLogPath(), - summaryMarkdown: this.sessionLog.exportSummary(), - toolAudit: this.toolRuntime.getAuditLog(), - approvals: this.approvalQueue?.getPending() ?? [], - stripFileContents: config.enterprise.stripFileContentsFromAuditPacks, - }); + const pack = this.buildAuditPack(workspace, sessionId, config.enterprise.stripFileContentsFromAuditPacks); mkdirSync(dirname(target.fsPath), { recursive: true }); await vscode.workspace.fs.writeFile(target, pack.buffer); @@ -1846,6 +1918,22 @@ export class ThunderController { } } + private buildAuditPack(workspace: string, sessionId: string, stripFileContents: boolean): ReturnType { + const config = this.configService.getConfig(); + return new AuditPackBuilder().build({ + sessionId, + workspace, + extensionVersion: this.context.extension.packageJSON.version ?? '', + model: `${config.provider.type}/${config.provider.model}`, + logPath: this.sessionLog.getLogPath(), + summaryMarkdown: this.sessionLog.exportSummary(), + toolAudit: this.toolRuntime.getAuditLog(), + approvals: this.approvalQueue?.getPending() ?? [], + stripFileContents, + signingKey: process.env.MITII_AUDIT_SIGNING_KEY, + }); + } + async generateChangelog(): Promise { const workspace = this.resolveWorkspacePath(); if (!workspace) { @@ -2108,6 +2196,30 @@ export class ThunderController { requestedContextWindow, config.provider.contextWindow ); + const validation = validateProviderSettings({ + providerType, + baseUrl, + model, + apiVersion, + region, + contextWindow, + } as ProviderSettingsPayload); + if (!validation.ok) { + this.notifyUi({ + settings: { + ...(await this.buildUiState()).settings, + providerType, + baseUrl, + model, + apiVersion, + region, + contextWindow, + connectionOk: false, + connectionStatus: validation.errors.join(' '), + }, + }); + return; + } if (providerType === 'echo') { this.notifyUi({ @@ -2170,6 +2282,18 @@ export class ThunderController { } async saveProviderSettings(settings: ProviderSettingsPayload): Promise { + const validation = validateProviderSettings(settings); + if (!validation.ok) { + void vscode.window.showErrorMessage(`${AGENT_NAME}: ${validation.errors.join(' ')}`); + this.notifyUi({ + settings: { + ...(await this.buildUiState()).settings, + connectionOk: false, + connectionStatus: validation.errors.join(' '), + }, + }); + return; + } await this.configService.updateProviderSettings( normalizeProviderSettings(settings, this.configService.getConfig().provider.contextWindow) ); diff --git a/src/core/audit/AuditPackBuilder.ts b/src/core/audit/AuditPackBuilder.ts index 2b975889..e98610f6 100644 --- a/src/core/audit/AuditPackBuilder.ts +++ b/src/core/audit/AuditPackBuilder.ts @@ -1,4 +1,5 @@ import { existsSync, readFileSync } from 'fs'; +import { createHmac, createHash } from 'crypto'; import { basename } from 'path'; import type { ToolCallAudit } from '../tools/types'; @@ -22,6 +23,7 @@ export interface AuditPackInput { toolAudit?: ToolCallAudit[]; approvals?: unknown[]; stripFileContents?: boolean; + signingKey?: string; } export interface AuditPackBuildResult { @@ -65,6 +67,7 @@ export class AuditPackBuilder { 'approvals.json': JSON.stringify(sanitizeValue(input.approvals ?? [], report, input.stripFileContents), null, 2), 'redaction-report.json': JSON.stringify(report, null, 2), }; + files['signature.json'] = JSON.stringify(createSignature(hashFiles(files), input.signingKey), null, 2); return { buffer: createZip(files), manifest, @@ -74,6 +77,76 @@ export class AuditPackBuilder { } } +export interface AuditSignature { + algorithm: 'sha256' | 'hmac-sha256'; + createdAt: string; + signedFiles: Record; + signature: string; +} + +export interface AuditVerificationResult { + ok: boolean; + errors: string[]; + entries: string[]; +} + +export function verifyAuditPack(buffer: Buffer, signingKey?: string): AuditVerificationResult { + const entries = readZipEntries(buffer); + const signatureText = entries['signature.json']; + if (!signatureText) { + return { ok: false, errors: ['signature.json is missing'], entries: Object.keys(entries) }; + } + const errors: string[] = []; + let signature: AuditSignature; + try { + signature = JSON.parse(signatureText) as AuditSignature; + } catch { + return { ok: false, errors: ['signature.json is malformed'], entries: Object.keys(entries) }; + } + + for (const [name, expectedHash] of Object.entries(signature.signedFiles ?? {})) { + const content = entries[name]; + if (content === undefined) { + errors.push(`${name} is missing`); + continue; + } + const actualHash = sha256(content); + if (actualHash !== expectedHash) { + errors.push(`${name} hash mismatch`); + } + } + + const expectedSignature = createSignature(signature.signedFiles, signingKey).signature; + if (signature.signature !== expectedSignature) { + errors.push('signature mismatch'); + } + + return { ok: errors.length === 0, errors, entries: Object.keys(entries) }; +} + +function createSignature(fileHashes: Record, signingKey?: string): AuditSignature { + const canonical = JSON.stringify(Object.keys(fileHashes).sort().map((name) => [name, fileHashes[name]])); + const key = signingKey || ''; + return { + algorithm: key ? 'hmac-sha256' : 'sha256', + createdAt: new Date().toISOString(), + signedFiles: fileHashes, + signature: key + ? createHmac('sha256', key).update(canonical).digest('hex') + : sha256(canonical), + }; +} + +function hashFiles(files: Record): Record { + return Object.fromEntries( + Object.entries(files).map(([name, content]) => [name, sha256(content)]) + ); +} + +function sha256(content: string | Buffer): string { + return createHash('sha256').update(content).digest('hex'); +} + function sanitizeJsonl(jsonl: string, report: RedactionReport, stripFileContents = false): string { return jsonl .split(/\r?\n/) @@ -191,6 +264,27 @@ function createZip(files: Record): Buffer { return Buffer.concat([...localParts, centralDir, end]); } +function readZipEntries(buffer: Buffer): Record { + const entries: Record = {}; + let offset = 0; + while (offset + 30 <= buffer.length && buffer.readUInt32LE(offset) === 0x04034b50) { + const compressionMethod = buffer.readUInt16LE(offset + 8); + const compressedSize = buffer.readUInt32LE(offset + 18); + const fileNameLength = buffer.readUInt16LE(offset + 26); + const extraLength = buffer.readUInt16LE(offset + 28); + const nameStart = offset + 30; + const dataStart = nameStart + fileNameLength + extraLength; + const dataEnd = dataStart + compressedSize; + if (dataEnd > buffer.length) break; + const name = buffer.slice(nameStart, nameStart + fileNameLength).toString('utf8'); + if (compressionMethod === 0) { + entries[name] = buffer.slice(dataStart, dataEnd).toString('utf8'); + } + offset = dataEnd; + } + return entries; +} + function crc32(buffer: Buffer): number { let crc = 0xffffffff; for (const byte of buffer) { @@ -206,4 +300,3 @@ const CRC_TABLE = Array.from({ length: 256 }, (_, n) => { } return c >>> 0; }); - diff --git a/src/core/config/schema.ts b/src/core/config/schema.ts index 94f4a5c1..21c8d5c9 100644 --- a/src/core/config/schema.ts +++ b/src/core/config/schema.ts @@ -25,6 +25,8 @@ export const ProviderConfigSchema = z.object({ supportsStreaming: z.boolean().default(true), supportsTools: z.boolean().default(true), supportsEmbeddings: z.boolean().default(false), + supportsVision: z.boolean().optional(), + supportsReasoning: z.boolean().optional(), }); export const EmbeddingProviderSchema = z.enum(['hash', 'minilm']).default('minilm'); @@ -166,6 +168,9 @@ export const TelemetryConfigSchema = z.object({ sessionLogging: z.boolean().default(true), /** Extra diagnostics: tool inputs, context sources, LLM step metadata. Off by default for speed. */ debugMetrics: z.boolean().default(false), + webhookUrl: z.string().default(''), + webhookSecret: z.string().default(''), + webhookTimeoutMs: z.number().int().min(1000).max(60_000).default(5000), }); export const ThunderConfigSchema = z.object({ diff --git a/src/core/config/ui/mappers.ts b/src/core/config/ui/mappers.ts index 6c4d4d39..3e226961 100644 --- a/src/core/config/ui/mappers.ts +++ b/src/core/config/ui/mappers.ts @@ -43,6 +43,46 @@ export function normalizeProviderSettings( return normalized; } +export interface ProviderValidationResult { + ok: boolean; + errors: string[]; +} + +export function validateProviderSettings(settings: ProviderSettingsPayload): ProviderValidationResult { + const errors: string[] = []; + const providerType = settings.providerType; + const baseUrl = settings.baseUrl.trim(); + const model = settings.model.trim(); + + if (providerType !== 'echo' && !baseUrl) { + errors.push('API base URL is required.'); + } + if (providerType !== 'echo' && baseUrl) { + try { + const url = new URL(baseUrl); + if (!['http:', 'https:'].includes(url.protocol)) { + errors.push('API base URL must use http or https.'); + } + } catch { + errors.push('API base URL must be a valid URL.'); + } + } + if (!model) { + errors.push(providerType === 'azure-openai' ? 'Azure deployment name is required.' : 'Model is required.'); + } + if (providerType === 'azure-openai' && !settings.apiVersion?.trim()) { + errors.push('Azure API version is required.'); + } + if (providerType === 'bedrock' && !settings.region?.trim()) { + errors.push('AWS region is required.'); + } + if (!Number.isFinite(settings.contextWindow) || settings.contextWindow < 1024) { + errors.push('Context window must be at least 1024 tokens.'); + } + + return { ok: errors.length === 0, errors }; +} + export function normalizeAgentSettings(settings: AgentSettingsPayload): AgentSettingsPayload { return { ...settings, diff --git a/src/core/config/ui/payloads.ts b/src/core/config/ui/payloads.ts index 3081b776..e1b16982 100644 --- a/src/core/config/ui/payloads.ts +++ b/src/core/config/ui/payloads.ts @@ -78,6 +78,9 @@ export interface McpSettingsPayload { export interface TelemetrySettingsPayload { sessionLogging: boolean; debugMetrics: boolean; + webhookUrl?: string; + webhookSecret?: string; + webhookTimeoutMs?: number; } export interface IndexingSettingsPayload { diff --git a/src/core/config/vscode/read.ts b/src/core/config/vscode/read.ts index 52c878c0..caa06cd3 100644 --- a/src/core/config/vscode/read.ts +++ b/src/core/config/vscode/read.ts @@ -21,6 +21,8 @@ export function readThunderConfigFromSettings(): ThunderConfig { supportsStreaming: config.get('provider.supportsStreaming'), supportsTools: config.get('provider.supportsTools'), supportsEmbeddings: config.get('provider.supportsEmbeddings'), + supportsVision: config.get('provider.supportsVision'), + supportsReasoning: config.get('provider.supportsReasoning'), }, indexing: { enabled: config.get('indexing.enabled'), @@ -106,6 +108,9 @@ export function readThunderConfigFromSettings(): ThunderConfig { telemetry: { sessionLogging: config.get('telemetry.sessionLogging'), debugMetrics: config.get('telemetry.debugMetrics'), + webhookUrl: config.get('telemetry.webhookUrl'), + webhookSecret: config.get('telemetry.webhookSecret'), + webhookTimeoutMs: config.get('telemetry.webhookTimeoutMs'), }, ui: { showReasoning: config.get('ui.showReasoning'), diff --git a/src/core/config/vscode/write.ts b/src/core/config/vscode/write.ts index df67f4f3..663d25f4 100644 --- a/src/core/config/vscode/write.ts +++ b/src/core/config/vscode/write.ts @@ -86,6 +86,15 @@ export async function updateTelemetrySettings(settings: TelemetrySettingsPayload const target = vscode.ConfigurationTarget.Global; await config.update('telemetry.sessionLogging', settings.sessionLogging, target); await config.update('telemetry.debugMetrics', settings.debugMetrics, target); + if (settings.webhookUrl !== undefined) { + await config.update('telemetry.webhookUrl', settings.webhookUrl.trim(), target); + } + if (settings.webhookSecret !== undefined) { + await config.update('telemetry.webhookSecret', settings.webhookSecret, target); + } + if (settings.webhookTimeoutMs !== undefined) { + await config.update('telemetry.webhookTimeoutMs', settings.webhookTimeoutMs, target); + } } export async function updateAllSettings(settings: ThunderSettingsPayload): Promise { diff --git a/src/core/headless/AgentRunner.ts b/src/core/headless/AgentRunner.ts new file mode 100644 index 00000000..55b49ede --- /dev/null +++ b/src/core/headless/AgentRunner.ts @@ -0,0 +1,108 @@ +import { createProvider } from '../llm/createProvider'; +import type { ProviderType } from '../config/schema'; +import type { ChatDelta, LlmProvider } from '../llm/types'; +import { getProviderPreset } from '../llm/providerPresets'; + +export interface HeadlessRunnerOptions { + cwd: string; + providerType?: ProviderType; + baseUrl?: string; + model?: string; + apiKey?: string; + approval?: 'auto' | 'manual'; + json?: boolean; +} + +export interface HeadlessPlan { + goal: string; + cwd: string; + provider: string; + model: string; + approval: 'auto' | 'manual'; + steps: Array<{ id: string; title: string; risk: 'low' | 'medium' | 'high'; readOnly: boolean }>; +} + +export class HeadlessAgentRunner { + private readonly provider: LlmProvider; + private readonly providerType: ProviderType; + private readonly model: string; + private readonly approval: 'auto' | 'manual'; + + constructor(private readonly options: HeadlessRunnerOptions) { + this.providerType = options.providerType ?? 'echo'; + const preset = getProviderPreset(this.providerType); + this.model = options.model ?? preset?.model ?? 'echo'; + this.approval = options.approval ?? 'manual'; + this.provider = createProvider({ + type: this.providerType, + baseUrl: options.baseUrl ?? preset?.baseUrl, + model: this.model, + contextWindow: preset?.contextWindow, + supportsStreaming: true, + supportsTools: false, + supportsEmbeddings: false, + }, options.apiKey ?? resolveApiKey(this.providerType)); + } + + async ask(prompt: string): Promise { + return this.complete([ + { + role: 'system', + content: [ + 'You are Mitii headless ask mode.', + 'Answer in concise Markdown.', + 'Do not claim to have edited files or used VS Code APIs.', + ].join('\n'), + }, + { role: 'user', content: prompt }, + ]); + } + + plan(prompt: string): HeadlessPlan { + return { + goal: prompt, + cwd: this.options.cwd, + provider: this.providerType, + model: this.model, + approval: this.approval, + steps: [ + { id: 'discover', title: 'Inspect the repository context relevant to the request', risk: 'low', readOnly: true }, + { id: 'design', title: 'Identify files, interfaces, and tests that need changes', risk: 'low', readOnly: true }, + { id: 'execute', title: 'Apply scoped changes in the VS Code extension runtime or a future tool-enabled headless loop', risk: 'medium', readOnly: false }, + { id: 'verify', title: 'Run focused tests and summarize residual risk', risk: 'low', readOnly: true }, + ], + }; + } + + async *agent(prompt: string): AsyncIterable<{ type: string; message?: string; plan?: HeadlessPlan; content?: string }> { + yield { type: 'start', message: 'headless agent started' }; + const plan = this.plan(prompt); + yield { type: 'plan', plan }; + const content = await this.ask([ + prompt, + '', + 'Headless agent mode has no filesystem write tools in this runtime. Provide the best implementation guidance and verification checklist.', + ].join('\n')); + yield { type: 'assistant_delta', content }; + yield { type: 'end', message: 'headless agent completed' }; + } + + private async complete(messages: Array<{ role: 'system' | 'user'; content: string }>): Promise { + let content = ''; + for await (const delta of this.provider.complete({ messages, stream: true })) { + content += deltaContent(delta); + } + return content; + } +} + +function deltaContent(delta: ChatDelta): string { + return delta.content ?? ''; +} + +function resolveApiKey(providerType: ProviderType): string | undefined { + if (providerType === 'anthropic') return process.env.ANTHROPIC_API_KEY ?? process.env.MITII_API_KEY; + if (providerType === 'gemini') return process.env.GEMINI_API_KEY ?? process.env.MITII_API_KEY; + if (providerType === 'openrouter') return process.env.OPENROUTER_API_KEY ?? process.env.MITII_API_KEY; + return process.env.MITII_API_KEY ?? process.env.OPENAI_API_KEY; +} diff --git a/src/core/headless/index.ts b/src/core/headless/index.ts index f40efd7c..2f5f2df6 100644 --- a/src/core/headless/index.ts +++ b/src/core/headless/index.ts @@ -1,2 +1,2 @@ export * from './release'; - +export * from './AgentRunner'; diff --git a/src/core/llm/AnthropicProvider.ts b/src/core/llm/AnthropicProvider.ts index 2bf403cc..06a5bedd 100644 --- a/src/core/llm/AnthropicProvider.ts +++ b/src/core/llm/AnthropicProvider.ts @@ -21,6 +21,8 @@ export class AnthropicProvider implements LlmProvider { supportsStreaming: config.capabilities?.supportsStreaming ?? true, supportsTools: config.capabilities?.supportsTools ?? true, supportsEmbeddings: false, + supportsVision: config.capabilities?.supportsVision ?? true, + supportsReasoning: config.capabilities?.supportsReasoning ?? false, }; } @@ -150,6 +152,26 @@ function splitAnthropicMessages(messages: ChatMessage[]): { out.push({ role: 'assistant', content }); continue; } + if (msg.attachments?.length && (msg.role === 'user' || msg.role === 'assistant')) { + const content: Array> = []; + if (msg.content) content.push({ type: 'text', text: msg.content }); + for (const attachment of msg.attachments) { + if (attachment.kind !== 'image') continue; + content.push({ + type: 'image', + source: { + type: 'base64', + media_type: attachment.mimeType, + data: attachment.data, + }, + }); + } + out.push({ + role: msg.role === 'assistant' ? 'assistant' : 'user', + content, + }); + continue; + } out.push({ role: msg.role === 'assistant' ? 'assistant' : 'user', content: msg.content, diff --git a/src/core/llm/BedrockProvider.ts b/src/core/llm/BedrockProvider.ts index b7ad1db8..6a32b365 100644 --- a/src/core/llm/BedrockProvider.ts +++ b/src/core/llm/BedrockProvider.ts @@ -26,6 +26,8 @@ export class BedrockProvider implements LlmProvider { supportsStreaming: config.capabilities?.supportsStreaming ?? true, supportsTools: false, supportsEmbeddings: false, + supportsVision: config.capabilities?.supportsVision ?? true, + supportsReasoning: config.capabilities?.supportsReasoning ?? false, }; this.client = new BedrockRuntimeClient({ region: config.region || 'us-east-1' }); } diff --git a/src/core/llm/EchoProvider.ts b/src/core/llm/EchoProvider.ts index da540b2d..32189784 100644 --- a/src/core/llm/EchoProvider.ts +++ b/src/core/llm/EchoProvider.ts @@ -8,6 +8,8 @@ export class EchoProvider implements LlmProvider { supportsStreaming: true, supportsTools: false, supportsEmbeddings: false, + supportsVision: false, + supportsReasoning: false, }; async *complete(request: ChatRequest): AsyncIterable { diff --git a/src/core/llm/GeminiProvider.ts b/src/core/llm/GeminiProvider.ts index b8ca7f23..3ef1d864 100644 --- a/src/core/llm/GeminiProvider.ts +++ b/src/core/llm/GeminiProvider.ts @@ -20,6 +20,8 @@ export class GeminiProvider implements LlmProvider { supportsStreaming: config.capabilities?.supportsStreaming ?? true, supportsTools: config.capabilities?.supportsTools ?? true, supportsEmbeddings: false, + supportsVision: config.capabilities?.supportsVision ?? true, + supportsReasoning: config.capabilities?.supportsReasoning ?? false, }; } @@ -175,6 +177,24 @@ function toGeminiContents(messages: ChatMessage[]): Array> = []; + if (msg.content) parts.push({ text: msg.content }); + for (const attachment of msg.attachments) { + if (attachment.kind !== 'image') continue; + parts.push({ + inlineData: { + mimeType: attachment.mimeType, + data: attachment.data, + }, + }); + } + out.push({ + role: msg.role === 'assistant' ? 'model' : 'user', + parts, + }); + continue; + } out.push({ role: msg.role === 'assistant' ? 'model' : 'user', parts: [{ text: msg.content }], diff --git a/src/core/llm/OpenAiCompatibleProvider.ts b/src/core/llm/OpenAiCompatibleProvider.ts index d2e95969..00764e7f 100644 --- a/src/core/llm/OpenAiCompatibleProvider.ts +++ b/src/core/llm/OpenAiCompatibleProvider.ts @@ -28,6 +28,8 @@ export class OpenAiCompatibleProvider implements LlmProvider { supportsStreaming: config.capabilities?.supportsStreaming ?? true, supportsTools: config.capabilities?.supportsTools ?? true, supportsEmbeddings: config.capabilities?.supportsEmbeddings ?? false, + supportsVision: config.capabilities?.supportsVision ?? false, + supportsReasoning: config.capabilities?.supportsReasoning ?? false, }; } @@ -215,9 +217,21 @@ function toolResultAsUserMessage(message: ChatRequest['messages'][number]): Chat } function formatMessage(msg: ChatRequest['messages'][number]): Record { + const attachments = msg.attachments?.filter((attachment) => attachment.kind === 'image') ?? []; + const content = attachments.length > 0 && (msg.role === 'user' || msg.role === 'assistant') + ? [ + ...(msg.content ? [{ type: 'text', text: msg.content }] : []), + ...attachments.map((attachment) => ({ + type: 'image_url', + image_url: { + url: `data:${attachment.mimeType};base64,${attachment.data}`, + }, + })), + ] + : msg.content; const out: Record = { role: msg.role, - content: msg.content, + content, }; if (msg.name) out.name = msg.name; if (msg.tool_call_id) out.tool_call_id = msg.tool_call_id; diff --git a/src/core/llm/createProvider.ts b/src/core/llm/createProvider.ts index beb921ec..7d833f21 100644 --- a/src/core/llm/createProvider.ts +++ b/src/core/llm/createProvider.ts @@ -7,6 +7,7 @@ import { GeminiProvider } from './GeminiProvider'; import { BedrockProvider } from './BedrockProvider'; import { getProviderPreset } from './providerPresets'; import { normalizeProviderModel } from './modelNormalize'; +import { detectModelCapabilities } from './modelCapabilities'; import { createLogger } from '../telemetry/Logger'; const log = createLogger('createProvider'); @@ -22,6 +23,8 @@ export interface ProviderResolveOptions { supportsStreaming?: boolean; supportsTools?: boolean; supportsEmbeddings?: boolean; + supportsVision?: boolean; + supportsReasoning?: boolean; } export function createProvider( @@ -43,12 +46,14 @@ export function createProvider( const region = 'region' in config && config.region ? config.region : 'us-east-1'; - const capabilities = { - contextWindow: config.contextWindow ?? preset?.contextWindow ?? 8192, - supportsStreaming: config.supportsStreaming ?? true, - supportsTools: config.supportsTools ?? true, - supportsEmbeddings: config.supportsEmbeddings ?? false, - }; + const capabilities = detectModelCapabilities(type, model, preset?.contextWindow ?? 8192, { + contextWindow: config.contextWindow, + supportsStreaming: config.supportsStreaming, + supportsTools: config.supportsTools, + supportsEmbeddings: config.supportsEmbeddings, + supportsVision: config.supportsVision, + supportsReasoning: config.supportsReasoning, + }); switch (type) { case 'anthropic': diff --git a/src/core/llm/modelCapabilities.ts b/src/core/llm/modelCapabilities.ts new file mode 100644 index 00000000..b3b399cf --- /dev/null +++ b/src/core/llm/modelCapabilities.ts @@ -0,0 +1,68 @@ +import type { ProviderType } from '../config/schema'; +import type { ModelCapabilities } from './types'; + +export interface CapabilityOverride { + contextWindow?: number; + supportsStreaming?: boolean; + supportsTools?: boolean; + supportsEmbeddings?: boolean; + supportsVision?: boolean; + supportsReasoning?: boolean; +} + +export function detectModelCapabilities( + providerType: ProviderType, + model: string, + presetContextWindow = 8192, + override: CapabilityOverride = {} +): ModelCapabilities { + const normalized = model.toLowerCase(); + const base: ModelCapabilities = { + contextWindow: presetContextWindow, + supportsStreaming: providerType !== 'echo', + supportsTools: providerType !== 'echo' && providerType !== 'bedrock', + supportsEmbeddings: false, + supportsVision: false, + supportsReasoning: false, + }; + + if (providerType === 'echo') { + base.supportsStreaming = true; + base.supportsTools = false; + } + + if (providerType === 'gemini') { + base.supportsVision = true; + base.supportsReasoning = /thinking|2\.5|pro/.test(normalized); + } + + if (providerType === 'anthropic' || providerType === 'bedrock') { + base.supportsVision = /claude|sonnet|opus|haiku/.test(normalized); + } + + if (providerType === 'openai' || providerType === 'openrouter' || providerType === 'codex') { + base.supportsVision = /gpt-4o|gpt-4\.1|o[134]|vision|claude|gemini|sonnet|opus/i.test(model); + base.supportsReasoning = /(^|[\/-])o[134]($|[-.])|reason|thinking|sonnet-4|claude-3\.7|claude-sonnet-4/i.test(model); + } + + if (providerType === 'deepseek') { + base.supportsReasoning = /reasoner|r1/.test(normalized); + } + + if (providerType === 'openai-compatible' || providerType === 'cursor') { + base.supportsVision = /vision|vl|gpt-4o|gpt-4\.1|llava|qwen.*vl|gemini|claude/.test(normalized); + base.supportsReasoning = /reason|thinking|r1|qwen3|o[134]/.test(normalized); + } + + return { + ...base, + ...definedOnly(override), + contextWindow: override.contextWindow ?? base.contextWindow, + }; +} + +function definedOnly(value: T): Partial { + return Object.fromEntries( + Object.entries(value).filter(([, nested]) => nested !== undefined) + ) as Partial; +} diff --git a/src/core/llm/types.ts b/src/core/llm/types.ts index f6b53d01..35fe08dc 100644 --- a/src/core/llm/types.ts +++ b/src/core/llm/types.ts @@ -3,6 +3,7 @@ import type { ToolDefinition } from './toolTypes'; export interface ChatMessage { role: 'system' | 'user' | 'assistant' | 'tool'; content: string; + attachments?: ChatImageAttachment[]; name?: string; tool_call_id?: string; tool_calls?: Array<{ @@ -12,6 +13,13 @@ export interface ChatMessage { }>; } +export interface ChatImageAttachment { + kind: 'image'; + mimeType: string; + data: string; + name?: string; +} + export interface ChatRequest { messages: ChatMessage[]; model?: string; @@ -54,6 +62,8 @@ export interface ModelCapabilities { supportsStreaming: boolean; supportsTools: boolean; supportsEmbeddings: boolean; + supportsVision?: boolean; + supportsReasoning?: boolean; } export interface LlmProvider { diff --git a/src/core/orchestration/ChatOrchestrator.ts b/src/core/orchestration/ChatOrchestrator.ts index cc66e4b4..74f9d1d7 100644 --- a/src/core/orchestration/ChatOrchestrator.ts +++ b/src/core/orchestration/ChatOrchestrator.ts @@ -219,7 +219,7 @@ export class ChatOrchestrator { provider: LlmProvider, userMessage: string, recentMessages: ChatMessage[] = [], - options?: { pinnedContext?: PinnedContextEntry[] } + options?: { pinnedContext?: PinnedContextEntry[]; attachments?: ChatMessage['attachments'] } ): AsyncIterable { this.abortController = new AbortController(); const signal = this.abortController.signal; @@ -939,7 +939,7 @@ export class ChatOrchestrator { const isResume = isApprovalContinuationMessage(userMessage); const taskStateBlock = this.deps.taskState?.buildPromptBlock(); - const messages = buildPrompt( + const messages = attachImagesToLastUser(buildPrompt( session.mode, pack, userMessage, @@ -957,7 +957,7 @@ export class ChatOrchestrator { actPlan?.promptContext, ...taskEnrichment.contextBlocks ) - ); + ), options?.attachments); const promptTokens = estimateChatRequestTokens({ messages, tools: tools.length > 0 ? tools : undefined, @@ -1751,6 +1751,24 @@ function mergePromptContexts(...blocks: Array): string | und return [...new Set(merged)].join('\n\n---\n\n'); } +function attachImagesToLastUser( + messages: ChatMessage[], + attachments: ChatMessage['attachments'] | undefined +): ChatMessage[] { + const images = attachments?.filter((attachment) => attachment.kind === 'image') ?? []; + if (images.length === 0) return messages; + const next = [...messages]; + for (let index = next.length - 1; index >= 0; index -= 1) { + if (next[index].role !== 'user') continue; + next[index] = { + ...next[index], + attachments: [...(next[index].attachments ?? []), ...images], + }; + return next; + } + return [...next, { role: 'user', content: 'Attached image context.', attachments: images }]; +} + function emptyContextPack(): ContextPack { return { items: [], diff --git a/src/core/scm/ReviewDiffCollector.ts b/src/core/scm/ReviewDiffCollector.ts new file mode 100644 index 00000000..22bf8e4a --- /dev/null +++ b/src/core/scm/ReviewDiffCollector.ts @@ -0,0 +1,95 @@ +import type { GitService } from '../context/GitService'; +import { budgetDiff } from './GitDiffCollector'; + +export interface ReviewDiffFile { + path: string; + status: string; + additions: number; + deletions: number; + diff: string; +} + +export interface ReviewDiff { + branch: string | null; + files: ReviewDiffFile[]; + summary: { + fileCount: number; + additions: number; + deletions: number; + }; + truncated: boolean; + updatedAt: number; +} + +export async function collectReviewDiff( + git: GitService, + options: { totalMaxChars?: number; perFileMaxChars?: number } = {} +): Promise { + const totalMaxChars = options.totalMaxChars ?? 32_000; + const perFileMaxChars = options.perFileMaxChars ?? 8_000; + const [branch, changedFiles, stagedDiff, unstagedDiff] = await Promise.all([ + git.getCurrentBranch(), + git.getChangedFilesDetailed(), + git.getStagedDiff(totalMaxChars), + git.getUnstagedDiff(totalMaxChars), + ]); + + const combined = [stagedDiff, unstagedDiff].filter(Boolean).join('\n'); + const budgeted = budgetDiff(combined, { totalMaxChars, perFileMaxChars }); + const files = parseReviewDiffFiles(budgeted, changedFiles); + return { + branch, + files, + summary: { + fileCount: files.length, + additions: files.reduce((sum, file) => sum + file.additions, 0), + deletions: files.reduce((sum, file) => sum + file.deletions, 0), + }, + truncated: combined.length > budgeted.length || /\[diff truncated:/.test(budgeted), + updatedAt: Date.now(), + }; +} + +export function parseReviewDiffFiles(diff: string, changedFiles: string[] = []): ReviewDiffFile[] { + const statusByPath = new Map(); + for (const line of changedFiles) { + const [status, ...pathParts] = line.split(/\s+/); + const path = pathParts[pathParts.length - 1]; + if (status && path) statusByPath.set(path, status); + } + + const chunks = diff.split(/(?=^diff --git )/m).filter(Boolean); + const parsed = chunks.map((chunk) => { + const path = parseDiffPath(chunk); + const additions = chunk.split(/\r?\n/).filter((line) => line.startsWith('+') && !line.startsWith('+++')).length; + const deletions = chunk.split(/\r?\n/).filter((line) => line.startsWith('-') && !line.startsWith('---')).length; + return { + path, + status: statusByPath.get(path) ?? inferStatus(chunk), + additions, + deletions, + diff: chunk.trimEnd(), + }; + }); + + if (parsed.length > 0) return parsed; + return [...statusByPath.entries()].map(([path, status]) => ({ + path, + status, + additions: 0, + deletions: 0, + diff: '', + })); +} + +function parseDiffPath(chunk: string): string { + const header = chunk.match(/^diff --git a\/(.+?) b\/(.+)$/m); + return header?.[2] ?? header?.[1] ?? 'unknown'; +} + +function inferStatus(chunk: string): string { + if (/^new file mode /m.test(chunk)) return 'A'; + if (/^deleted file mode /m.test(chunk)) return 'D'; + if (/^rename from /m.test(chunk)) return 'R'; + return 'M'; +} diff --git a/src/core/scm/index.ts b/src/core/scm/index.ts index cd5f66c1..bff3fe06 100644 --- a/src/core/scm/index.ts +++ b/src/core/scm/index.ts @@ -2,3 +2,4 @@ export * from './commitMessageTypes'; export * from './commitMessagePrompt'; export * from './CommitMessageGenerator'; export * from './GitDiffCollector'; +export * from './ReviewDiffCollector'; diff --git a/src/core/telemetry/SessionLogService.ts b/src/core/telemetry/SessionLogService.ts index 69ad01c3..2b17b68c 100644 --- a/src/core/telemetry/SessionLogService.ts +++ b/src/core/telemetry/SessionLogService.ts @@ -2,6 +2,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } fr import { join } from 'path'; import { AGENT_NAME } from '../../shared/brand'; import { createLogger } from './Logger'; +import { WebhookEmitter, type WebhookEmitterConfig } from './WebhookEmitter'; const log = createLogger('SessionLogService'); @@ -54,6 +55,7 @@ export class SessionLogService { private sessionId = ''; private logPath = ''; private logStartedAt = 0; + private webhookEmitter = new WebhookEmitter(); configure(workspace: string, sessionId: string, enabled = true, debugMetrics = false): void { const sessionChanged = this.sessionId !== sessionId; @@ -84,6 +86,10 @@ export class SessionLogService { return this.debugMetrics; } + configureWebhook(config: WebhookEmitterConfig): void { + this.webhookEmitter.configure(config); + } + getLogPath(): string { return this.logPath; } @@ -128,6 +134,7 @@ export class SessionLogService { try { appendFileSync(this.logPath, `${JSON.stringify(event)}\n`, 'utf-8'); + this.webhookEmitter.emit(event); } catch (error) { log.warn('Failed to append session log', { error: error instanceof Error ? error.message : String(error), @@ -162,6 +169,14 @@ export class SessionLogService { message: 'Session started', data: header, })}\n`, 'utf-8'); + this.webhookEmitter.emit({ + ts, + time: formatTimestampForLog(ts), + sessionId: this.sessionId, + type: 'session_start', + message: 'Session started', + data: sanitizeLogData(header), + }); } catch (error) { log.warn('Failed to write session log header', { error: error instanceof Error ? error.message : String(error), diff --git a/src/core/telemetry/WebhookEmitter.ts b/src/core/telemetry/WebhookEmitter.ts new file mode 100644 index 00000000..29c8ee2e --- /dev/null +++ b/src/core/telemetry/WebhookEmitter.ts @@ -0,0 +1,92 @@ +import { createHmac } from 'crypto'; +import type { SessionLogEvent } from './SessionLogService'; +import { createLogger } from './Logger'; + +const log = createLogger('WebhookEmitter'); + +export interface WebhookEmitterConfig { + url?: string; + secret?: string; + timeoutMs?: number; + maxRetries?: number; +} + +export class WebhookEmitter { + private url = ''; + private secret = ''; + private timeoutMs = 5000; + private maxRetries = 2; + private queue: Promise = Promise.resolve(); + + configure(config: WebhookEmitterConfig): void { + this.url = config.url?.trim() ?? ''; + this.secret = config.secret ?? ''; + this.timeoutMs = config.timeoutMs ?? 5000; + this.maxRetries = config.maxRetries ?? 2; + } + + isEnabled(): boolean { + return Boolean(this.url); + } + + emit(event: SessionLogEvent): void { + if (!this.isEnabled()) return; + const payload = JSON.stringify(event); + this.queue = this.queue + .then(() => this.postWithRetry(payload)) + .catch((error) => { + log.warn('Webhook delivery failed', { + error: error instanceof Error ? error.message : String(error), + }); + }); + } + + async flush(): Promise { + await this.queue; + } + + private async postWithRetry(payload: string): Promise { + let lastError: unknown; + for (let attempt = 0; attempt <= this.maxRetries; attempt += 1) { + try { + await this.post(payload); + return; + } catch (error) { + lastError = error; + if (attempt < this.maxRetries) { + await sleep(250 * (attempt + 1)); + } + } + } + throw lastError instanceof Error ? lastError : new Error(String(lastError)); + } + + private async post(payload: string): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + try { + const headers: Record = { + 'Content-Type': 'application/json', + 'User-Agent': 'Mitii-AI-Agent', + }; + if (this.secret) { + headers['X-Mitii-Signature'] = `sha256=${createHmac('sha256', this.secret).update(payload).digest('hex')}`; + } + const response = await fetch(this.url, { + method: 'POST', + headers, + body: payload, + signal: controller.signal, + }); + if (!response.ok) { + throw new Error(`Webhook returned ${response.status}`); + } + } finally { + clearTimeout(timer); + } + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/core/telemetry/index.ts b/src/core/telemetry/index.ts index f450b9ed..04ed8647 100644 --- a/src/core/telemetry/index.ts +++ b/src/core/telemetry/index.ts @@ -1,3 +1,4 @@ export * from './Logger'; export * from './errors'; export * from './SessionLogService'; +export * from './WebhookEmitter'; diff --git a/src/extension.ts b/src/extension.ts index f44cb809..85fd66c8 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -4,6 +4,7 @@ import { registerCommands } from './vscode/commands'; import { ThunderWebviewProvider } from './vscode/webview/ThunderWebviewProvider'; import { createLogger } from './core/telemetry/Logger'; import { AGENT_FULL_NAME } from './shared/brand'; +import { notifyNativeModuleHealth } from './vscode/nativeModuleHealth'; const log = createLogger('extension'); @@ -12,6 +13,7 @@ let webviewProvider: ThunderWebviewProvider | undefined; export async function activate(context: vscode.ExtensionContext): Promise { log.info(`${AGENT_FULL_NAME} activating`); + void notifyNativeModuleHealth(); controller = new ThunderController(context); await controller.initialize(); diff --git a/src/node/cli.ts b/src/node/cli.ts index d6e37266..484d8a77 100644 --- a/src/node/cli.ts +++ b/src/node/cli.ts @@ -1,14 +1,16 @@ #!/usr/bin/env node import { existsSync, readFileSync, writeFileSync } from 'fs'; import { join, resolve } from 'path'; -import { AuditPackBuilder } from '../core/audit'; -import { generateHeadlessChangelog, prepareHeadlessRelease } from '../core/headless'; +import { AuditPackBuilder, verifyAuditPack } from '../core/audit'; +import { HeadlessAgentRunner, generateHeadlessChangelog, prepareHeadlessRelease } from '../core/headless'; +import type { ProviderType } from '../core/config/schema'; async function main(argv: string[]): Promise { const [command, ...args] = argv; const cwd = resolve(valueOf(args, '--cwd') ?? process.cwd()); const since = valueOf(args, '--since'); const json = args.includes('--json'); + const prompt = positional(args).join(' ').trim(); if (!command || command === '--help' || command === 'help') { printHelp(); @@ -43,8 +45,47 @@ async function main(argv: string[]): Promise { return 0; } - if (command === 'ask' || command === 'agent' || command === 'commit-msg') { - process.stderr.write(`${command} requires the VS Code extension runtime in this MVP. Use changelog, prepare-release, or export-audit headlessly.\n`); + if (command === 'verify-audit') { + const target = positional(args)[0]; + if (!target) { + process.stderr.write('verify-audit requires a zip path.\n'); + return 2; + } + const result = verifyAuditPack(readFileSync(resolve(cwd, target)), process.env.MITII_AUDIT_SIGNING_KEY); + process.stdout.write(json ? JSON.stringify(result, null, 2) + '\n' : formatAuditVerification(result)); + return result.ok ? 0 : 1; + } + + if (command === 'ask') { + const runner = createRunner(cwd, args, json); + const answer = await runner.ask(prompt || readStdin()); + process.stdout.write(json ? JSON.stringify({ answer }, null, 2) + '\n' : `${answer}\n`); + return 0; + } + + if (command === 'plan') { + const runner = createRunner(cwd, args, json); + const plan = runner.plan(prompt || readStdin()); + process.stdout.write(JSON.stringify(plan, null, 2) + '\n'); + return 0; + } + + if (command === 'agent') { + const runner = createRunner(cwd, args, json); + for await (const event of runner.agent(prompt || readStdin())) { + if (json) { + process.stdout.write(JSON.stringify(event) + '\n'); + } else if (event.content) { + process.stdout.write(`${event.content}\n`); + } else if (event.message) { + process.stderr.write(`${event.message}\n`); + } + } + return 0; + } + + if (command === 'commit-msg') { + process.stderr.write(`${command} requires git diff context from the VS Code extension runtime. Use changelog, prepare-release, export-audit, verify-audit, ask, plan, or agent headlessly.\n`); return 2; } @@ -53,11 +94,43 @@ async function main(argv: string[]): Promise { return 1; } +function createRunner(cwd: string, args: string[], json: boolean): HeadlessAgentRunner { + return new HeadlessAgentRunner({ + cwd, + providerType: (valueOf(args, '--provider') as ProviderType | undefined) ?? 'echo', + baseUrl: valueOf(args, '--base-url'), + model: valueOf(args, '--model'), + approval: (valueOf(args, '--approval') as 'auto' | 'manual' | undefined) ?? 'manual', + json, + }); +} + function valueOf(args: string[], name: string): string | undefined { const idx = args.indexOf(name); return idx >= 0 ? args[idx + 1] : undefined; } +function positional(args: string[]): string[] { + const out: string[] = []; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg.startsWith('--')) { + if (!['--json'].includes(arg)) index += 1; + continue; + } + out.push(arg); + } + return out; +} + +function readStdin(): string { + try { + return readFileSync(0, 'utf8').trim(); + } catch { + return ''; + } +} + function latestSessionLog(cwd: string): string | undefined { const dir = join(cwd, '.mitii', 'logs'); if (!existsSync(dir)) return undefined; @@ -86,14 +159,24 @@ function printHelp(): void { ' mitii changelog [--since ] [--cwd ] [--json]', ' mitii prepare-release [--since ] [--cwd ] [--json]', ' mitii export-audit [--session ] [--output ] [--cwd ] [--json]', + ' mitii verify-audit [--cwd ] [--json]', + ' mitii ask "question" [--provider echo|openai|...] [--model ] [--base-url ] [--cwd ] [--json]', + ' mitii plan "goal" [--provider echo|openai|...] [--model ] [--approval auto|manual] [--cwd ]', + ' mitii agent "goal" [--provider echo|openai|...] [--model ] [--approval auto|manual] [--json]', '', ].join('\n')); } +function formatAuditVerification(result: ReturnType): string { + if (result.ok) { + return `Audit pack verified (${result.entries.length} entries).\n`; + } + return `Audit pack verification failed:\n${result.errors.map((error) => `- ${error}`).join('\n')}\n`; +} + void main(process.argv.slice(2)).then((code) => { process.exitCode = code; }).catch((error) => { process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); process.exitCode = 1; }); - diff --git a/src/vscode/nativeModuleHealth.ts b/src/vscode/nativeModuleHealth.ts new file mode 100644 index 00000000..15828634 --- /dev/null +++ b/src/vscode/nativeModuleHealth.ts @@ -0,0 +1,51 @@ +import * as vscode from 'vscode'; +import { AGENT_NAME } from '../shared/brand'; + +export interface NativeModuleHealthResult { + ok: boolean; + moduleName: string; + message: string; + rebuildCommand: string; +} + +export function checkBetterSqliteHealth(): NativeModuleHealthResult { + const rebuildCommand = detectEditorRebuildCommand(); + try { + require('better-sqlite3'); + return { + ok: true, + moduleName: 'better-sqlite3', + message: 'better-sqlite3 loaded successfully.', + rebuildCommand, + }; + } catch (error) { + return { + ok: false, + moduleName: 'better-sqlite3', + message: error instanceof Error ? error.message : String(error), + rebuildCommand, + }; + } +} + +export async function notifyNativeModuleHealth(result = checkBetterSqliteHealth()): Promise { + if (result.ok) return; + + const action = 'Copy rebuild command'; + const choice = await vscode.window.showWarningMessage( + `${AGENT_NAME}: ${result.moduleName} failed to load. Rebuild native modules for this editor runtime.`, + action + ); + if (choice === action) { + await vscode.env.clipboard.writeText(result.rebuildCommand); + void vscode.window.showInformationMessage(`${AGENT_NAME}: copied ${result.rebuildCommand}`); + } +} + +export function detectEditorRebuildCommand(env: NodeJS.ProcessEnv = process.env): string { + const editor = (env.THUNDER_EDITOR || env.VSCODE_PID || '').toString().toLowerCase(); + if (editor.includes('cursor') || env.CURSOR_TRACE_ID || env.CURSOR_APP_NAME) { + return 'THUNDER_EDITOR=cursor npm run rebuild:native'; + } + return 'npm run rebuild:native'; +} diff --git a/src/vscode/webview/ThunderWebviewProvider.ts b/src/vscode/webview/ThunderWebviewProvider.ts index c5ad7bb7..d7e714a1 100644 --- a/src/vscode/webview/ThunderWebviewProvider.ts +++ b/src/vscode/webview/ThunderWebviewProvider.ts @@ -159,7 +159,7 @@ export class ThunderWebviewProvider implements vscode.WebviewViewProvider { case 'sendMessage': { const content = message.payload.content.trim(); const pinnedContext = message.payload.pinnedContext ?? this.state.pinnedContext; - await this.runChatCompletion(content, true, pinnedContext); + await this.runChatCompletion(content, true, pinnedContext, message.payload.attachments ?? []); break; } @@ -221,6 +221,9 @@ export class ThunderWebviewProvider implements vscode.WebviewViewProvider { case 'setMode': { this.state = { ...this.state, mode: message.payload }; this.controller.getSession()?.setMode(message.payload); + if (message.payload === 'review') { + await this.controller.refreshReviewDiff(); + } this.postMessage({ type: 'setMode', payload: message.payload }); break; } @@ -437,13 +440,23 @@ export class ThunderWebviewProvider implements vscode.WebviewViewProvider { this.postMessage({ type: 'state', payload: this.state }); break; } + + case 'refreshReviewDiff': + await this.controller.refreshReviewDiff(); + break; + + case 'completeOnboarding': + await this.controller.completeOnboarding(); + await this.syncState(); + break; } } private async runChatCompletion( content: string, appendUser: boolean, - pinnedContext = this.state.pinnedContext + pinnedContext = this.state.pinnedContext, + attachments: ChatMessage['attachments'] = [] ): Promise { if (!content || this.state.loading) return; @@ -451,6 +464,7 @@ export class ThunderWebviewProvider implements vscode.WebviewViewProvider { id: `msg-${Date.now()}`, role: 'user', content, + attachments, timestamp: Date.now(), }; @@ -470,9 +484,9 @@ export class ThunderWebviewProvider implements vscode.WebviewViewProvider { const recentMessages = messages .filter((m) => m.role === 'user' || m.role === 'assistant') .slice(0, -1) - .map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content })); + .map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content, attachments: m.attachments })); - const stream = await this.controller.sendMessage(content, recentMessages, { pinnedContext }); + const stream = await this.controller.sendMessage(content, recentMessages, { pinnedContext, attachments }); let rawContent = ''; let fullContent = ''; let reasoningContent = ''; diff --git a/src/vscode/webview/messages.ts b/src/vscode/webview/messages.ts index d846053f..fe2f83b4 100644 --- a/src/vscode/webview/messages.ts +++ b/src/vscode/webview/messages.ts @@ -27,10 +27,19 @@ export type { export type WebviewTab = 'chat' | 'history' | 'settings'; +export interface ChatImageAttachment { + kind: 'image'; + mimeType: string; + data: string; + name?: string; + size?: number; +} + export interface ChatMessage { id: string; role: 'user' | 'assistant' | 'system'; content: string; + attachments?: ChatImageAttachment[]; reasoningContent?: string; timestamp: number; streaming?: boolean; @@ -222,6 +231,33 @@ export interface CheckpointView { strategy?: string; } +export interface ReviewDiffFileView { + path: string; + status: string; + additions: number; + deletions: number; + diff: string; +} + +export interface ReviewDiffView { + branch: string | null; + files: ReviewDiffFileView[]; + summary: { + fileCount: number; + additions: number; + deletions: number; + }; + truncated: boolean; + updatedAt: number; +} + +export interface OnboardingView { + shouldShow: boolean; + completed: boolean; + providerConfigured: boolean; + workspaceIndexed: boolean; +} + export interface SettingsView { appVersion: string; providerType: string; @@ -314,6 +350,8 @@ export interface WebviewState { indexing: IndexingStatusView; memories: MemoryItemView[]; checkpoints: CheckpointView[]; + reviewDiff: ReviewDiffView | null; + onboarding: OnboardingView; settings: SettingsView; contextToggles: ContextToggles; mcpToggles: McpToggles; @@ -353,12 +391,13 @@ export type ExtensionToWebviewMessage = | { type: 'setAgentLiveStatus'; payload: AgentLiveStatusView | null } | { type: 'setSubagents'; payload: SubagentStatusView[] } | { type: 'setTokenUsage'; payload: TokenUsageView } + | { type: 'setReviewDiff'; payload: ReviewDiffView | null } | { type: 'setContextPaths'; payload: { requestId: string; paths: ContextPathSuggestion[] } }; // Webview -> Extension messages export type WebviewToExtensionMessage = | { type: 'ready' } - | { type: 'sendMessage'; payload: { content: string; pinnedContext?: PinnedContextView[] } } + | { type: 'sendMessage'; payload: { content: string; pinnedContext?: PinnedContextView[]; attachments?: ChatImageAttachment[] } } | { type: 'retryLastMessage' } | { type: 'newChat' } | { type: 'openChatThread'; payload: { id: string } } @@ -395,6 +434,8 @@ export type WebviewToExtensionMessage = | { type: 'clearPinnedContext' } | { type: 'searchContextPaths'; payload: { query: string; requestId: string } } | { type: 'pickContextPath' } + | { type: 'refreshReviewDiff' } + | { type: 'completeOnboarding' } | { type: 'refreshPanels' }; export const defaultMcpToggles = (): McpToggles => ({ @@ -485,6 +526,13 @@ export const initialWebviewState = (): WebviewState => ({ indexing: { indexed: 0, queued: 0, running: false, failed: 0, total: 0, activeWorkers: 0, processed: 0, runTotal: 0 }, memories: [], checkpoints: [], + reviewDiff: null, + onboarding: { + shouldShow: false, + completed: false, + providerConfigured: false, + workspaceIndexed: false, + }, settings: defaultSettingsView(), contextToggles: defaultContextToggles(), mcpToggles: defaultMcpToggles(), diff --git a/src/webview-ui/src/App.tsx b/src/webview-ui/src/App.tsx index 228bdc5e..90edbcf4 100644 --- a/src/webview-ui/src/App.tsx +++ b/src/webview-ui/src/App.tsx @@ -7,6 +7,8 @@ import { ContextPanel } from './components/ContextPanel'; import { ContextWarningBanner } from './components/ContextWarningBanner'; import { ErrorBanner } from './components/ErrorBanner'; import { SettingsPanel } from './components/SettingsPanel'; +import { OnboardingPanel } from './components/OnboardingPanel'; +import { ReviewPanel } from './components/ReviewPanel'; import { ApprovalCards } from './components/ApprovalCards'; import { IndexingStatusBar } from './components/IndexingStatusBar'; import { WorkspaceBanner } from './components/WorkspaceBanner'; @@ -152,7 +154,29 @@ export function App() { } /> - {state.tab === 'chat' ? ( + {state.onboarding.shouldShow ? ( +
+ postMessage({ type: 'saveProviderSettings', payload })} + onSaveApiKey={(key) => postMessage({ type: 'saveApiKey', payload: { key } })} + onTestConnection={(payload) => postMessage({ type: 'testProviderConnection', payload })} + onIndexWorkspace={() => postMessage({ type: 'indexWorkspace' })} + onComplete={() => postMessage({ type: 'completeOnboarding' })} + /> +
+ ) : state.tab === 'chat' && state.mode === 'review' ? ( + postMessage({ type: 'refreshReviewDiff' })} + onFeedback={(content) => { + postMessage({ type: 'setMode', payload: 'ask' }); + postMessage({ type: 'sendMessage', payload: { content } }); + }} + onExit={() => postMessage({ type: 'setMode', payload: 'plan' })} + /> + ) : state.tab === 'chat' ? (
- postMessage({ type: 'sendMessage', payload: { content, pinnedContext } }) + onSend={(content, pinnedContext, attachments) => + postMessage({ type: 'sendMessage', payload: { content, pinnedContext, attachments } }) } onStop={() => postMessage({ type: 'stopGeneration' })} onModeChange={(mode) => postMessage({ type: 'setMode', payload: mode })} diff --git a/src/webview-ui/src/components/ChatInput.tsx b/src/webview-ui/src/components/ChatInput.tsx index 770287d0..9e404ffb 100644 --- a/src/webview-ui/src/components/ChatInput.tsx +++ b/src/webview-ui/src/components/ChatInput.tsx @@ -3,12 +3,13 @@ import type { ThunderMode } from '../../../core/session/ThunderSession'; import type { ApprovalMode, AgentDepthView, + ChatImageAttachment, ContextPathSuggestion, PinnedContextView, TokenUsageView, } from '../../../vscode/webview/messages'; import { IconButton } from './IconButton'; -import { IconChevronDown, IconCopy, IconMarkdown, IconRetry, IconSend, IconStop } from './Icons'; +import { IconChevronDown, IconCopy, IconImage, IconMarkdown, IconRetry, IconSend, IconStop } from './Icons'; import { TokenMeter } from './TokenMeter'; import { APPROVAL_MODE_OPTIONS, deriveSafetySettings } from '../utils/approvalMode'; @@ -20,7 +21,7 @@ interface ChatInputProps { tokenUsage: TokenUsageView; pinnedContext: PinnedContextView[]; canRetry: boolean; - onSend: (content: string, pinnedContext: PinnedContextView[]) => void; + onSend: (content: string, pinnedContext: PinnedContextView[], attachments: ChatImageAttachment[]) => void; onStop?: () => void; onModeChange: (mode: ThunderMode) => void; onApprovalModeChange: (mode: ApprovalMode) => void; @@ -79,7 +80,9 @@ export function ChatInput({ const [mentionIndex, setMentionIndex] = useState(0); const [mentionStart, setMentionStart] = useState(null); const [searchRequestId, setSearchRequestId] = useState(null); + const [attachments, setAttachments] = useState([]); const textareaRef = useRef(null); + const fileInputRef = useRef(null); const activeMode = MODES.find((m) => m.id === mode) ?? MODES[0]; const activeApproval = APPROVAL_MODE_OPTIONS.find((option) => option.id === approvalMode) ?? APPROVAL_MODE_OPTIONS[0]; @@ -136,11 +139,35 @@ export function ChatInput({ const handleSend = useCallback(() => { const trimmed = value.trim(); - if (!trimmed || loading) return; - onSend(trimmed, pinnedContext); + if ((!trimmed && attachments.length === 0) || loading) return; + onSend(trimmed || 'Please review the attached image.', pinnedContext, attachments); setValue(''); + setAttachments([]); closeMention(); - }, [value, loading, onSend, pinnedContext, closeMention]); + }, [value, attachments, loading, onSend, pinnedContext, closeMention]); + + const addImageFiles = useCallback((files: FileList | File[]) => { + const images = Array.from(files).filter((file) => file.type.startsWith('image/')).slice(0, 6); + for (const file of images) { + if (file.size > 5 * 1024 * 1024) continue; + const reader = new FileReader(); + reader.onload = () => { + const result = String(reader.result ?? ''); + const data = result.includes(',') ? result.slice(result.indexOf(',') + 1) : result; + setAttachments((current) => [ + ...current, + { + kind: 'image', + mimeType: file.type || 'image/png', + data, + name: file.name, + size: file.size, + }, + ].slice(0, 6)); + }; + reader.readAsDataURL(file); + } + }, []); const handleKeyDown = (e: KeyboardEvent) => { if (mentionOpen && pathSuggestions.length > 0) { @@ -174,7 +201,17 @@ export function ChatInput({ }; return ( -
+
{ + if (!loading) e.preventDefault(); + }} + onDrop={(e) => { + if (loading) return; + e.preventDefault(); + addImageFiles(e.dataTransfer.files); + }} + >
{mentionOpen && (
@@ -217,11 +254,32 @@ export function ChatInput({ ) } onKeyDown={handleKeyDown} + onPaste={(e) => { + const files = Array.from(e.clipboardData.files).filter((file) => file.type.startsWith('image/')); + if (files.length > 0) { + addImageFiles(files); + } + }} placeholder="Ask anything… use @ to add files or folders" disabled={loading} rows={3} aria-label="Chat message input" /> + {attachments.length > 0 && ( +
+ {attachments.map((attachment, index) => ( + + ))} +
+ )}
@@ -275,6 +333,25 @@ export function ChatInput({
+ { + if (e.target.files) addImageFiles(e.target.files); + e.currentTarget.value = ''; + }} + /> + fileInputRef.current?.click()} + disabled={loading} + > + + {onRetry && ( @@ -304,7 +381,7 @@ export function ChatInput({ label="Send message" variant="accent" onClick={handleSend} - disabled={!value.trim()} + disabled={!value.trim() && attachments.length === 0} > diff --git a/src/webview-ui/src/components/Icons.tsx b/src/webview-ui/src/components/Icons.tsx index 63b11fb2..7043e607 100644 --- a/src/webview-ui/src/components/Icons.tsx +++ b/src/webview-ui/src/components/Icons.tsx @@ -83,6 +83,16 @@ export function IconMarkdown(props: IconProps) { ); } +export function IconImage(props: IconProps) { + return ( + + + + + + ); +} + export function IconContext(props: IconProps) { return ( diff --git a/src/webview-ui/src/components/OnboardingPanel.tsx b/src/webview-ui/src/components/OnboardingPanel.tsx new file mode 100644 index 00000000..2b7f2594 --- /dev/null +++ b/src/webview-ui/src/components/OnboardingPanel.tsx @@ -0,0 +1,192 @@ +import { useMemo, useState } from 'react'; +import type { ProviderSettingsPayload, SettingsView } from '../../../vscode/webview/messages'; +import { PROVIDER_PRESETS } from '../../../core/llm/providerPresets'; +import { validateProviderSettings } from '../../../core/config/ui/mappers'; + +interface OnboardingPanelProps { + settings: SettingsView; + workspaceIndexed: boolean; + onSaveProviderSettings: (settings: ProviderSettingsPayload) => void; + onSaveApiKey: (key: string) => void; + onTestConnection: (settings: ProviderSettingsPayload) => void; + onIndexWorkspace: () => void; + onComplete: () => void; +} + +export function OnboardingPanel({ + settings, + workspaceIndexed, + onSaveProviderSettings, + onSaveApiKey, + onTestConnection, + onIndexWorkspace, + onComplete, +}: OnboardingPanelProps) { + const [step, setStep] = useState(0); + const [providerType, setProviderType] = useState( + settings.providerType as ProviderSettingsPayload['providerType'] + ); + const preset = PROVIDER_PRESETS.find((item) => item.type === providerType); + const [baseUrl, setBaseUrl] = useState(settings.baseUrl || preset?.baseUrl || ''); + const [model, setModel] = useState(settings.model || preset?.model || ''); + const [apiVersion, setApiVersion] = useState(settings.apiVersion || '2024-10-21'); + const [region, setRegion] = useState(settings.region || 'us-east-1'); + const [apiKey, setApiKey] = useState(''); + + const payload = useMemo(() => ({ + providerType, + baseUrl: baseUrl.trim(), + model: model.trim(), + apiVersion: apiVersion.trim(), + region: region.trim(), + contextWindow: preset?.contextWindow ?? settings.contextWindow, + }), [apiVersion, baseUrl, model, preset?.contextWindow, providerType, region, settings.contextWindow]); + const validation = validateProviderSettings(payload); + + const chooseProvider = (next: ProviderSettingsPayload['providerType']) => { + setProviderType(next); + const nextPreset = PROVIDER_PRESETS.find((item) => item.type === next); + if (nextPreset) { + setBaseUrl(nextPreset.baseUrl); + setModel(nextPreset.model); + } + }; + + return ( +
+
+
+

Set up Mitii

+

Connect a provider and index this workspace before the first serious run.

+
+ +
+ +
+ {['Echo test', 'Provider', 'Index'].map((label, index) => ( + + ))} +
+ + {step === 0 && ( +
+

Echo provider test

+

Echo verifies the sidebar, chat loop, and settings plumbing without sending data to an external model.

+
+ +
+
+ )} + + {step === 1 && ( +
+

Provider connection

+ + + + {providerType === 'azure-openai' && ( + + )} + {providerType === 'bedrock' && ( + + )} + {preset?.requiresApiKey && ( + + )} + {!validation.ok &&

{validation.errors.join(' ')}

} +
+ + +
+
+ )} + + {step === 2 && ( +
+

Workspace index

+

{workspaceIndexed ? 'This workspace has indexed files.' : 'Build the local index so Ask, Plan, Agent, and Review mode have useful repository context.'}

+
+ + +
+
+ )} + + ); +} diff --git a/src/webview-ui/src/components/ReviewPanel.tsx b/src/webview-ui/src/components/ReviewPanel.tsx new file mode 100644 index 00000000..cf14647b --- /dev/null +++ b/src/webview-ui/src/components/ReviewPanel.tsx @@ -0,0 +1,62 @@ +import type { ReviewDiffView } from '../../../vscode/webview/messages'; + +interface ReviewPanelProps { + diff: ReviewDiffView | null; + onRefresh: () => void; + onFeedback: (content: string) => void; + onExit: () => void; +} + +export function ReviewPanel({ diff, onRefresh, onFeedback, onExit }: ReviewPanelProps) { + const files = diff?.files ?? []; + const summary = diff?.summary ?? { fileCount: 0, additions: 0, deletions: 0 }; + const feedbackPrompt = [ + 'Review the current working-tree diff.', + 'Focus on bugs, regressions, missing tests, and risky changes.', + 'Do not edit files; provide findings only.', + ].join('\n'); + + return ( +
+
+
+

Review diff

+

+ {diff?.branch ? `Branch ${diff.branch}` : 'Working tree'} · {summary.fileCount} files · +{summary.additions} / -{summary.deletions} +

+
+
+ + + +
+
+ {diff?.truncated && ( +

Large diff truncated for sidebar review. Ask Review mode for targeted files when needed.

+ )} + {files.length === 0 ? ( +
+

No working-tree diff

+

Make changes or stage files, then refresh Review mode.

+
+ ) : ( +
+ {files.map((file) => ( +
+ + {file.status} + {file.path} + +{file.additions} / -{file.deletions} + + {file.diff ? ( +
{file.diff}
+ ) : ( +

No textual diff available for this file.

+ )} +
+ ))} +
+ )} +
+ ); +} diff --git a/src/webview-ui/src/components/SettingsPanel.tsx b/src/webview-ui/src/components/SettingsPanel.tsx index 85804ea8..f10f890f 100644 --- a/src/webview-ui/src/components/SettingsPanel.tsx +++ b/src/webview-ui/src/components/SettingsPanel.tsx @@ -23,6 +23,7 @@ import { SettingStepper } from './SettingStepper'; import { MemoryPanel } from './MemoryPanel'; import { CheckpointPanel } from './CheckpointPanel'; import { getProviderPreset } from '../../../core/llm/providerPresets'; +import { validateProviderSettings } from '../../../core/config/ui/mappers'; import { APPROVAL_MODE_OPTIONS, approvalModeDescription, @@ -200,6 +201,7 @@ export function SettingsPanel({ memories, checkpoints, onSaveApiKey, + onSaveGitHubToken, onSaveAllSettings, onTestConnection, onPickWorkspaceFolder, @@ -379,6 +381,7 @@ export function SettingsPanel({ region: region.trim(), contextWindow: clampContextWindow(contextWindow), }); + const providerValidation = validateProviderSettings(currentProviderSettings()); const activeLocalPreset = providerType === 'openai-compatible' ? findLocalModelPreset(model) : undefined; const hasPresetContextMismatch = Boolean( @@ -599,6 +602,7 @@ export function SettingsPanel({ type="button" className="btn btn--ghost" onClick={() => onTestConnection(currentProviderSettings())} + disabled={!providerValidation.ok} > Test connection @@ -611,6 +615,11 @@ export function SettingsPanel({ )}
+ {!providerValidation.ok && ( +

+ {providerValidation.errors.join(' ')} +

+ )} @@ -1002,7 +1011,10 @@ export function SettingsPanel({ className="settings-input" placeholder={settings.hasGithubToken ? 'Token saved - enter to replace' : 'Enter GitHub token...'} value={githubToken} - onChange={(e) => setGithubToken(e.target.value)} + onChange={(e) => { + setGithubToken(e.target.value); + markDirty(); + }} />

@@ -1212,7 +1224,7 @@ export function SettingsPanel({ type="button" className="btn btn--primary" onClick={handleSaveAll} - disabled={!dirty && !apiKey.trim() && !githubToken.trim()} + disabled={(!dirty && !apiKey.trim() && !githubToken.trim()) || (dirty && !providerValidation.ok)} > {saved ? 'Saved' : 'Save changes'} diff --git a/src/webview-ui/src/state/store.ts b/src/webview-ui/src/state/store.ts index f0863fa7..6cfeac38 100644 --- a/src/webview-ui/src/state/store.ts +++ b/src/webview-ui/src/state/store.ts @@ -11,6 +11,7 @@ import type { AgentLiveStatusView, SubagentStatusView, TokenUsageView, + ReviewDiffView, } from '../../../vscode/webview/messages'; import { initialWebviewState } from '../../../vscode/webview/messages'; @@ -29,7 +30,8 @@ export type WebviewAction = | { type: 'SET_AGENT_ACTIVITY'; payload: AgentActivityEntry[] } | { type: 'SET_AGENT_LIVE_STATUS'; payload: AgentLiveStatusView | null } | { type: 'SET_SUBAGENTS'; payload: SubagentStatusView[] } - | { type: 'SET_TOKEN_USAGE'; payload: TokenUsageView }; + | { type: 'SET_TOKEN_USAGE'; payload: TokenUsageView } + | { type: 'SET_REVIEW_DIFF'; payload: ReviewDiffView | null }; export const initialState: WebviewState = initialWebviewState(); @@ -127,6 +129,9 @@ export function webviewReducer(state: WebviewState, action: WebviewAction): Webv case 'SET_TOKEN_USAGE': return { ...state, tokenUsage: action.payload }; + case 'SET_REVIEW_DIFF': + return { ...state, reviewDiff: action.payload }; + default: return state; } diff --git a/src/webview-ui/src/state/useVsCodeMessaging.ts b/src/webview-ui/src/state/useVsCodeMessaging.ts index 75deb0e3..bc228766 100644 --- a/src/webview-ui/src/state/useVsCodeMessaging.ts +++ b/src/webview-ui/src/state/useVsCodeMessaging.ts @@ -73,6 +73,9 @@ export function useVsCodeMessaging() { case 'setTokenUsage': dispatch({ type: 'SET_TOKEN_USAGE', payload: message.payload }); break; + case 'setReviewDiff': + dispatch({ type: 'SET_REVIEW_DIFF', payload: message.payload }); + break; case 'setContextPaths': if (message.payload.requestId === pathSearchRequestIdRef.current) { setPathSuggestions(message.payload.paths); diff --git a/src/webview-ui/src/styles/global.css b/src/webview-ui/src/styles/global.css index da4b60f4..ed456c2c 100644 --- a/src/webview-ui/src/styles/global.css +++ b/src/webview-ui/src/styles/global.css @@ -1183,6 +1183,36 @@ body, opacity: 0.6; } +.composer__attachments { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 0 10px 6px; +} + +.composer__attachment { + display: inline-flex; + max-width: 180px; + padding: 3px 8px; + border: 1px solid var(--thunder-border); + border-radius: 5px; + background: rgba(255, 255, 255, 0.04); + color: var(--thunder-fg); + font: inherit; + font-size: 11px; + cursor: pointer; +} + +.composer__attachment-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.composer__file-input { + display: none; +} + .composer__footer { display: flex; align-items: center; @@ -1396,6 +1426,152 @@ body, background: rgba(255, 255, 255, 0.05); } +.onboarding { + display: grid; + gap: 14px; + max-width: 720px; + margin: 0 auto; +} + +.onboarding__header, +.review-panel__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.onboarding__title, +.review-panel__title { + margin: 0; + font-size: 18px; + line-height: 1.25; +} + +.onboarding__subtitle, +.review-panel__subtitle { + margin: 4px 0 0; + color: var(--thunder-muted); + font-size: 12px; + line-height: 1.45; +} + +.onboarding__steps { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; +} + +.onboarding__step { + min-height: 30px; + border: 1px solid var(--thunder-border); + border-radius: 5px; + background: transparent; + color: var(--thunder-muted); + font: inherit; + font-size: 11px; + font-weight: 700; + cursor: pointer; +} + +.onboarding__step--active { + border-color: var(--thunder-accent); + color: var(--thunder-fg); +} + +.onboarding__body, +.review-panel__empty { + display: grid; + gap: 12px; + padding: 14px; + border: 1px solid var(--thunder-border); + border-radius: 8px; + background: var(--thunder-surface); +} + +.onboarding__body h3, +.review-panel__empty h3 { + margin: 0; + font-size: 14px; +} + +.onboarding__body p, +.review-panel__empty p { + margin: 0; + color: var(--thunder-muted); + font-size: 12px; + line-height: 1.45; +} + +.onboarding__actions, +.review-panel__actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.review-panel { + display: grid; + align-content: start; + gap: 12px; +} + +.review-panel__files { + display: grid; + gap: 8px; +} + +.review-file { + border: 1px solid var(--thunder-border); + border-radius: 8px; + background: var(--thunder-surface); + overflow: hidden; +} + +.review-file__summary { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + padding: 9px 10px; + cursor: pointer; +} + +.review-file__status { + min-width: 22px; + color: var(--thunder-accent); + font-size: 10px; + font-weight: 800; +} + +.review-file__path { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--vscode-editor-font-family, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 11px; +} + +.review-file__stats { + color: var(--thunder-muted); + font-size: 10px; + font-weight: 700; +} + +.review-file__diff { + max-height: 420px; + margin: 0; + padding: 10px; + overflow: auto; + border-top: 1px solid var(--thunder-border); + background: var(--thunder-surface-2); + color: var(--thunder-fg); + font-family: var(--vscode-editor-font-family, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 11px; + line-height: 1.45; + white-space: pre; +} + .btn--small { min-height: 24px; padding: 3px 8px; @@ -2711,6 +2887,10 @@ body, line-height: 1.45; } +.settings-inline-note--error { + color: var(--thunder-error-fg); +} + .settings-inline-note code { font-size: 10px; } diff --git a/test/act/act-orchestration.test.ts b/test/act/act-orchestration.test.ts index 3d4775d2..b3ac8151 100644 --- a/test/act/act-orchestration.test.ts +++ b/test/act/act-orchestration.test.ts @@ -142,6 +142,19 @@ describe('Act orchestration boundary', () => { expect(route.summary).toContain('GitHub issue'); }); + it('does not use the GitHub issue route for reference-only issue detections', () => { + const message = 'Can you summarize https://github.com/acme/app/issues/7?'; + const analysis = analyzeTask(message, 'agent'); + const route = routeActIntent(message, analysis, { + mode: 'agent', + githubIssueMode: false, + orchestrationEnabled: true, + }); + + expect(route.summary).not.toContain('GitHub issue'); + expect(route.shouldVerify).toBe(false); + }); + it('adds ACT skill tool guidance to Agent system prompts when tools are enabled', () => { const prompt = buildSystemPrompt('agent', true); expect(prompt).toContain('ACT SKILLS:'); diff --git a/test/audit-pack.test.ts b/test/audit-pack.test.ts index d4fb9175..1fdd0e6e 100644 --- a/test/audit-pack.test.ts +++ b/test/audit-pack.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { mkdtempSync, rmSync, writeFileSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; -import { AuditPackBuilder } from '../src/core/audit'; +import { AuditPackBuilder, verifyAuditPack } from '../src/core/audit'; describe('AuditPackBuilder', () => { it('builds a zip with expected entries and redacts secrets', () => { @@ -32,15 +32,34 @@ describe('AuditPackBuilder', () => { 'tool-audit.json', 'approvals.json', 'redaction-report.json', + 'signature.json', ]); expect(result.buffer.slice(0, 2).toString()).toBe('PK'); expect(zipText).toContain('session.jsonl'); expect(zipText).toContain('[REDACTED]'); expect(zipText).not.toContain('sk-abc1234567890'); expect(result.redactionReport.secretKeyRedactions).toBeGreaterThan(0); + expect(verifyAuditPack(result.buffer).ok).toBe(true); } finally { rmSync(dir, { recursive: true, force: true }); } }); -}); + it('detects signed audit pack tampering', () => { + const result = new AuditPackBuilder().build({ + sessionId: 's1', + workspace: process.cwd(), + extensionVersion: '1.0.0', + summaryMarkdown: '# Summary', + signingKey: 'enterprise-secret', + }); + expect(verifyAuditPack(result.buffer, 'enterprise-secret').ok).toBe(true); + + const tampered = Buffer.from(result.buffer); + const idx = tampered.indexOf('Summary'); + tampered.write('Tamper!', idx, 'utf8'); + const verification = verifyAuditPack(tampered, 'enterprise-secret'); + expect(verification.ok).toBe(false); + expect(verification.errors.some((error) => error.includes('hash mismatch'))).toBe(true); + }); +}); diff --git a/test/messages.test.ts b/test/messages.test.ts index 2f66a28b..68988532 100644 --- a/test/messages.test.ts +++ b/test/messages.test.ts @@ -9,6 +9,7 @@ describe('Webview message protocol', () => { expect(state.messages).toHaveLength(0); expect(state.approvals).toHaveLength(0); expect(state.indexing.running).toBe(false); + expect(state.settings.hasGithubToken).toBe(false); }); it('has default context toggles with diagnostics off by default', () => { diff --git a/test/top10-features.test.ts b/test/top10-features.test.ts new file mode 100644 index 00000000..91272148 --- /dev/null +++ b/test/top10-features.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it, vi } from 'vitest'; +import { validateProviderSettings } from '../src/core/config/ui/mappers'; +import { detectEditorRebuildCommand } from '../src/vscode/nativeModuleHealth'; +import { parseReviewDiffFiles } from '../src/core/scm/ReviewDiffCollector'; +import { OpenAiCompatibleProvider } from '../src/core/llm/OpenAiCompatibleProvider'; +import { HeadlessAgentRunner } from '../src/core/headless'; +import { createProvider } from '../src/core/llm/createProvider'; +import { detectModelCapabilities } from '../src/core/llm/modelCapabilities'; +import { WebhookEmitter } from '../src/core/telemetry/WebhookEmitter'; + +describe('provider setup validation', () => { + it('requires provider-specific fields', () => { + expect(validateProviderSettings({ + providerType: 'azure-openai', + baseUrl: 'not-a-url', + model: '', + apiVersion: '', + region: 'us-east-1', + contextWindow: 512, + }).errors).toEqual(expect.arrayContaining([ + 'API base URL must be a valid URL.', + 'Azure deployment name is required.', + 'Azure API version is required.', + 'Context window must be at least 1024 tokens.', + ])); + }); + + it('accepts a complete local OpenAI-compatible preset', () => { + expect(validateProviderSettings({ + providerType: 'openai-compatible', + baseUrl: 'http://localhost:11434/v1', + model: 'qwen3-coder:30b', + contextWindow: 8192, + }).ok).toBe(true); + }); +}); + +describe('native module health helpers', () => { + it('uses a Cursor-specific rebuild command when Cursor env is present', () => { + expect(detectEditorRebuildCommand({ CURSOR_TRACE_ID: '1' } as NodeJS.ProcessEnv)).toBe( + 'THUNDER_EDITOR=cursor npm run rebuild:native' + ); + }); +}); + +describe('review diff parsing', () => { + it('groups file diffs and counts changed lines', () => { + const files = parseReviewDiffFiles([ + 'diff --git a/src/a.ts b/src/a.ts', + 'index 111..222 100644', + '--- a/src/a.ts', + '+++ b/src/a.ts', + '@@ -1 +1,2 @@', + '-old', + '+new', + '+next', + '', + ].join('\n'), ['M\tsrc/a.ts']); + + expect(files).toHaveLength(1); + expect(files[0]).toMatchObject({ path: 'src/a.ts', status: 'M', additions: 2, deletions: 1 }); + }); +}); + +describe('multimodal provider payloads', () => { + it('formats image attachments for OpenAI-compatible providers', async () => { + const provider = new OpenAiCompatibleProvider({ + baseUrl: 'https://example.test/v1', + model: 'vision-model', + }); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ choices: [{ message: { content: 'ok' } }] }), + }); + vi.stubGlobal('fetch', fetchMock); + + try { + for await (const _delta of provider.complete({ + stream: false, + messages: [{ + role: 'user', + content: 'what is this?', + attachments: [{ kind: 'image', mimeType: 'image/png', data: 'abc123', name: 'screen.png' }], + }], + })) { + // consume + } + } finally { + vi.unstubAllGlobals(); + } + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.messages[0].content[0]).toEqual({ type: 'text', text: 'what is this?' }); + expect(body.messages[0].content[1].image_url.url).toBe('data:image/png;base64,abc123'); + }); +}); + +describe('headless runner', () => { + it('returns deterministic echo answers and JSON plans without VS Code APIs', async () => { + const runner = new HeadlessAgentRunner({ cwd: process.cwd(), providerType: 'echo' }); + await expect(runner.ask('hello')).resolves.toContain('Echo: hello'); + + const plan = runner.plan('ship a feature'); + expect(plan.steps.map((step) => step.id)).toEqual(['discover', 'design', 'execute', 'verify']); + }); +}); + +describe('model capability detection', () => { + it('detects vision and reasoning support while respecting overrides', () => { + const detected = detectModelCapabilities('openai', 'gpt-4.1', 128_000); + expect(detected.supportsVision).toBe(true); + expect(detected.supportsReasoning).toBe(false); + + const reasoner = createProvider({ + type: 'openai-compatible', + baseUrl: 'http://localhost:11434/v1', + model: 'qwen3-coder:30b', + supportsVision: false, + supportsReasoning: true, + }); + expect(reasoner.capabilities.supportsReasoning).toBe(true); + expect(reasoner.capabilities.supportsVision).toBe(false); + }); +}); + +describe('telemetry webhook emitter', () => { + it('posts sanitized events with an HMAC signature header', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal('fetch', fetchMock); + const emitter = new WebhookEmitter(); + emitter.configure({ url: 'https://siem.example.test/events', secret: 'secret' }); + + try { + emitter.emit({ + ts: 1, + time: '2026-07-02 00:00:00.000', + sessionId: 's1', + type: 'tool_end', + message: 'Tool finished', + data: { tool: 'read_file' }, + }); + await emitter.flush(); + } finally { + vi.unstubAllGlobals(); + } + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0]).toBe('https://siem.example.test/events'); + expect(fetchMock.mock.calls[0][1].headers['X-Mitii-Signature']).toMatch(/^sha256=/); + }); +}); From 3df06288c8b246ea69f223685c4caa8214a39b49 Mon Sep 17 00:00:00 2001 From: codewithshinde Date: Thu, 2 Jul 2026 17:59:11 -0500 Subject: [PATCH 04/12] feat: enhance commit message detection and response handling --- benchmark/tasks.json | 6 +++ package-lock.json | 4 +- package.json | 2 +- src/core/app/ThunderController.ts | 11 +---- src/core/microtasks/types.ts | 5 ++- src/core/modes/ask/AskIntentRouter.ts | 5 ++- src/core/orchestration/ChatOrchestrator.ts | 35 +++++++++++++++- src/core/safety/ToolExecutor.ts | 15 ++++++- src/vscode/webview/ThunderWebviewProvider.ts | 2 + test/microtasks.test.ts | 3 +- test/unit.test.ts | 42 ++++++++++++++++++++ 11 files changed, 111 insertions(+), 19 deletions(-) diff --git a/benchmark/tasks.json b/benchmark/tasks.json index 1235074d..9b9089d6 100644 --- a/benchmark/tasks.json +++ b/benchmark/tasks.json @@ -5,6 +5,12 @@ "prompt": "Summarize the project structure and identify the main extension entry point.", "verify": "stdout_contains:Echo:" }, + { + "id": "staged-commit-message-ask", + "mode": "ask", + "prompt": "Need commit message for the changes in stage @mitii-ai-agent", + "verify": "stdout_contains:Echo:" + }, { "id": "release-plan", "mode": "plan", diff --git a/package-lock.json b/package-lock.json index 84457209..0e8b0a82 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mitii-ai-agent", - "version": "2.7.18", + "version": "2.7.19", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mitii-ai-agent", - "version": "2.7.18", + "version": "2.7.19", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "dependencies": { diff --git a/package.json b/package.json index 56d8cb7b..46f13c58 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "mitii-ai-agent", "displayName": "Mitii AI Agent", "description": "Local-first VS Code AI coding agent with precise repo context and safe Plan/Act workflow", - "version": "2.7.18", + "version": "2.7.19", "publisher": "mitii", "icon": "media/mitii-short-logo.png", "license": "AGPL-3.0-or-later", diff --git a/src/core/app/ThunderController.ts b/src/core/app/ThunderController.ts index 7dd3c153..5f504ec8 100644 --- a/src/core/app/ThunderController.ts +++ b/src/core/app/ThunderController.ts @@ -895,14 +895,7 @@ export class ThunderController { const onboardingCompleted = this.context.globalState.get(ONBOARDING_STATE_KEY, false); const providerConfigured = config.provider.type !== 'echo' || Boolean(apiKey); - const approvals: ApprovalRequestView[] = (this.approvalQueue?.getPending() ?? []).map((r) => ({ - id: r.id, - toolName: r.toolName, - inputPreview: r.inputPreview, - files: r.files, - risk: r.risk, - reason: r.reason, - })); + const approvals: ApprovalRequestView[] = (this.approvalQueue?.getPending() ?? []).map(toApprovalView); return { ...initialWebviewState(), @@ -2597,7 +2590,7 @@ export class ThunderController { } } -function toApprovalView(r: import('../safety/ApprovalQueue').ApprovalRequest): ApprovalRequestView { +export function toApprovalView(r: import('../safety/ApprovalQueue').ApprovalRequest): ApprovalRequestView { return { id: r.id, toolName: r.toolName, diff --git a/src/core/microtasks/types.ts b/src/core/microtasks/types.ts index 8898fb0a..fd72f981 100644 --- a/src/core/microtasks/types.ts +++ b/src/core/microtasks/types.ts @@ -12,7 +12,9 @@ export interface MicroTaskResult { } const MICRO_TASK_PATTERNS: Array<[MicroTaskId, RegExp]> = [ - ['commit_message', /\b(commit message|write commit|git commit)\b/i], + ['commit_message', /\b(commit message|commit msg|write commit|git commit)\b/i], + ['commit_message', /\b(?:commit|message|subject|summary)\b[\s\S]{0,80}\b(?:staged|stage|cached|git diff)\b/i], + ['commit_message', /\b(?:staged|stage|cached|git diff)\b[\s\S]{0,80}\b(?:commit|message|subject|summary)\b/i], ['release_notes_draft', /\b(release notes?|what'?s new)\b/i], ['changelog_entry', /\b(changelog|what changed since)\b/i], ]; @@ -22,4 +24,3 @@ export function detectMicroTask(userMessage: string): MicroTaskId | null { if (!text) return null; return MICRO_TASK_PATTERNS.find(([, pattern]) => pattern.test(text))?.[0] ?? null; } - diff --git a/src/core/modes/ask/AskIntentRouter.ts b/src/core/modes/ask/AskIntentRouter.ts index 9e8bed02..99129cfb 100644 --- a/src/core/modes/ask/AskIntentRouter.ts +++ b/src/core/modes/ask/AskIntentRouter.ts @@ -7,7 +7,9 @@ const IMPLEMENT_RE = /\b(how (?:do|would|should) i (?:add|implement|build|create const DEBUG_RE = /\b(why .+(?:fail|failing|broken|error)|build failing|test failing|root cause|diagnos|debug)\b/i; const CROSS_PROJECT_RE = /\b(across projects?|between projects?|cross[- ]project|relate to|flow from|agent\s*(?:->|to)\s*docs|docs\s*(?:->|to)\s*agent|extension\s*(?:->|to)\s*website|monorepo)\b/i; const GENERAL_KNOWLEDGE_RE = /^(what is|what are|define|explain the concept|difference between)\b/i; -const CODEBASE_RE = /\b(codebase|repo|repository|workspace|project|this app|this extension|this code|our code|src\/|\.tsx?|\.jsx?|\.py|\.go|\.rs|\.mdx?|package\.json)\b/i; +const CODEBASE_RE = /\b(codebase|repo|repository|workspace|project|this app|this extension|this code|our code|src\/|\.tsx?|\.jsx?|\.py|\.go|\.rs|\.mdx?|package\.json)\b|@[\w./-]+/i; +const SCM_CONTEXT_RE = + /\b(commit message|commit msg|git commit|git diff|working tree|staged changes?|changes? in (?:stage|staging))\b|\b(?:commit|message|subject|summary)\b[\s\S]{0,80}\b(?:staged|stage|cached)\b|\b(?:staged|stage|cached)\b[\s\S]{0,80}\b(?:commit|message|subject|summary)\b/i; export function routeAskIntent(userMessage: string): AskRoute { const text = userMessage.trim(); @@ -36,6 +38,7 @@ function classifyAskIntent(text: string): AskIntent { if (COMPARE_RE.test(text)) return 'compare'; if (ARCHITECTURE_RE.test(text)) return 'architecture'; if (LOCATE_RE.test(text)) return 'locate'; + if (SCM_CONTEXT_RE.test(text)) return 'explain_code'; if (GENERAL_KNOWLEDGE_RE.test(text) && !CODEBASE_RE.test(text)) return 'general_knowledge'; if (CODEBASE_RE.test(text)) return 'explain_code'; return text.includes('?') ? 'explain_code' : 'general_knowledge'; diff --git a/src/core/orchestration/ChatOrchestrator.ts b/src/core/orchestration/ChatOrchestrator.ts index 74f9d1d7..fdbe060f 100644 --- a/src/core/orchestration/ChatOrchestrator.ts +++ b/src/core/orchestration/ChatOrchestrator.ts @@ -74,6 +74,14 @@ import { detectMicroTask, type MicroTaskExecutor } from '../microtasks'; const log = createLogger('ChatOrchestrator'); +export const EMPTY_ASSISTANT_RESPONSE_MESSAGE = + 'I did not receive any response from the model for this turn. Please try again, or switch models if it keeps happening.'; + +export function normalizeAssistantResponse(fullResponse: string): { content: string; wasEmpty: boolean } { + if (fullResponse.trim()) return { content: fullResponse, wasEmpty: false }; + return { content: EMPTY_ASSISTANT_RESPONSE_MESSAGE, wasEmpty: true }; +} + export type ContextPackCallback = (pack: ContextPack, views: ContextItemView[], budget: ContextBudgetView) => void; export type PlanCallback = (plan: PlanView | null) => void; export type ActivityCallback = (entry: AgentActivityEntry) => void; @@ -201,6 +209,14 @@ export class ChatOrchestrator { }); } + private emitEmptyResponse(providerId: string): void { + this.emitActivity('error', 'Model returned an empty response', providerId); + this.deps.sessionLog?.append('error', 'Model returned an empty response', { + provider: providerId, + fallbackMessage: EMPTY_ASSISTANT_RESPONSE_MESSAGE, + }); + } + private setLiveStatus( label: string | null, detail?: string, @@ -246,7 +262,11 @@ export class ChatOrchestrator { sessionTiming.start('microtask'); const result = await this.deps.microTaskExecutorFactory(provider).execute(microTaskId, userMessage); sessionTiming.end('microtask', sessionLog, result.metadata); - const content = result.content; + const normalizedResult = normalizeAssistantResponse(result.content); + const content = normalizedResult.content; + if (normalizedResult.wasEmpty) { + this.emitEmptyResponse(provider.id); + } this.saveTurn(session.id, 'user', userMessage); const emptyPack = emptyContextPack(); await this.finishTurn( @@ -1106,6 +1126,13 @@ export class ChatOrchestrator { } } + const normalizedResponse = normalizeAssistantResponse(fullResponse); + if (normalizedResponse.wasEmpty) { + fullResponse = normalizedResponse.content; + this.emitEmptyResponse(provider.id); + yield fullResponse; + } + await this.finishTurn( session, provider, @@ -1144,7 +1171,11 @@ export class ChatOrchestrator { const tokens = promptTokens || estimateChatRequestTokens({ messages: usageMessages }); this.emitTurnTokenUsage(tokens, pack, fullResponse, usageMessages, compacted); - if (!fullResponse) return; + const normalizedResponse = normalizeAssistantResponse(fullResponse); + fullResponse = normalizedResponse.content; + if (normalizedResponse.wasEmpty) { + this.emitEmptyResponse(provider.id); + } this.saveTurn(session.id, 'assistant', fullResponse); this.deps.sessionLog?.append('assistant_message', fullResponse.slice(0, 200), { diff --git a/src/core/safety/ToolExecutor.ts b/src/core/safety/ToolExecutor.ts index 1cc4c1d0..870e6f0c 100644 --- a/src/core/safety/ToolExecutor.ts +++ b/src/core/safety/ToolExecutor.ts @@ -137,9 +137,22 @@ export class ToolExecutor { if (policy.decision === 'require_approval') { if (!this.approvalQueue.hasApprovalGrant(sessionId, resolvedName)) { - this.approvalQueue.createRequest(sessionId, resolvedName, input, policy, { + const request = this.approvalQueue.createRequest(sessionId, resolvedName, input, policy, { toolCallId: context?.toolCallId, }); + this.sessionLog?.append('approval_request', `${request.kind ?? 'approval'}: ${resolvedName}`, { + id: request.id, + toolName: request.toolName, + kind: request.kind, + risk: request.risk, + reason: request.reason, + files: request.files, + contentLength: request.contentLength, + question: request.question, + options: request.options, + optionCount: request.options?.length ?? 0, + toolCallId: request.toolCallId, + }); this.onPendingApproval?.(); this.logRejectedToolCall(resolvedName, input, false, 'Awaiting approval', 'Awaiting approval'); return { success: false, output: '', pendingApproval: true, error: 'Awaiting approval' }; diff --git a/src/vscode/webview/ThunderWebviewProvider.ts b/src/vscode/webview/ThunderWebviewProvider.ts index d7e714a1..5359aa41 100644 --- a/src/vscode/webview/ThunderWebviewProvider.ts +++ b/src/vscode/webview/ThunderWebviewProvider.ts @@ -531,6 +531,7 @@ export class ThunderWebviewProvider implements vscode.WebviewViewProvider { files: r.files, risk: r.risk, reason: r.reason, + contentLength: r.contentLength, kind: r.kind, question: r.question, options: r.options, @@ -651,6 +652,7 @@ export class ThunderWebviewProvider implements vscode.WebviewViewProvider { files: r.files, risk: r.risk, reason: r.reason, + contentLength: r.contentLength, kind: r.kind, question: r.question, options: r.options, diff --git a/test/microtasks.test.ts b/test/microtasks.test.ts index 7523cbf3..b56be62a 100644 --- a/test/microtasks.test.ts +++ b/test/microtasks.test.ts @@ -8,6 +8,8 @@ import type { LlmProvider } from '../src/core/llm/types'; describe('microtasks', () => { it('detects supported micro-task intents', () => { expect(detectMicroTask('write commit message please')).toBe('commit_message'); + expect(detectMicroTask('Need commit message for the changes in stage @mitii-ai-agent')).toBe('commit_message'); + expect(detectMicroTask('suggest a subject for staged changes')).toBe('commit_message'); expect(detectMicroTask('what changed since v1.2.0')).toBe('changelog_entry'); expect(detectMicroTask("draft what's new")).toBe('release_notes_draft'); expect(detectMicroTask('explain the auth flow')).toBeNull(); @@ -80,4 +82,3 @@ describe('microtasks', () => { expect(redactSensitiveDiff('+password=abc123')).toBe('+[redacted sensitive line]'); }); }); - diff --git a/test/unit.test.ts b/test/unit.test.ts index 2396ee77..b8243e1d 100644 --- a/test/unit.test.ts +++ b/test/unit.test.ts @@ -767,6 +767,23 @@ describe('ThunderMode normalization', () => { }); }); +describe('ChatOrchestrator response handling', () => { + it('turns empty model output into an explicit assistant message', async () => { + const { EMPTY_ASSISTANT_RESPONSE_MESSAGE, normalizeAssistantResponse } = + await import('../src/core/orchestration/ChatOrchestrator'); + + expect(normalizeAssistantResponse('')).toEqual({ + content: EMPTY_ASSISTANT_RESPONSE_MESSAGE, + wasEmpty: true, + }); + expect(normalizeAssistantResponse(' ')).toEqual({ + content: EMPTY_ASSISTANT_RESPONSE_MESSAGE, + wasEmpty: true, + }); + expect(normalizeAssistantResponse('ok')).toEqual({ content: 'ok', wasEmpty: false }); + }); +}); + describe('Plan parser', () => { it('flattens rich phase plans into executable steps', async () => { const { parsePlanFromText } = await import('../src/core/plans/PlanActEngine'); @@ -995,6 +1012,27 @@ describe('fuzzyFileMatch', () => { }); describe('ApprovalQueue', () => { + it('maps clarifying questions with options for persisted UI state', async () => { + const { ApprovalQueue } = await import('../src/core/safety/ApprovalQueue'); + const { toApprovalView } = await import('../src/core/app/ThunderController'); + const queue = new ApprovalQueue(); + const req = queue.createRequest('s1', 'ask_question', { + question: 'Which project should I inspect?', + options: ['agent', 'docs'], + }, { + decision: 'require_approval', + reason: 'Clarifying question requires user response', + }); + + expect(toApprovalView(req)).toMatchObject({ + id: req.id, + toolName: 'ask_question', + kind: 'question', + question: 'Which project should I inspect?', + options: ['agent', 'docs'], + }); + }); + it('stores full input for large write_file payloads', async () => { const { ApprovalQueue } = await import('../src/core/safety/ApprovalQueue'); const queue = new ApprovalQueue(); @@ -2244,6 +2282,10 @@ describe('Ask v2 routing, scope, and impact', () => { intent: 'general_knowledge', groundingRequired: false, }); + expect(routeAskIntent('Need commit message for the changes in stage @mitii-ai-agent')).toMatchObject({ + intent: 'explain_code', + groundingRequired: true, + }); }); it('discovers monorepo projects and persists a catalog file', async () => { From 62b38efb7cddc50d69098e50ea8e2ab305bb8441 Mon Sep 17 00:00:00 2001 From: codewithshinde Date: Thu, 2 Jul 2026 19:45:13 -0500 Subject: [PATCH 05/12] feat: Refactor SettingsPanel to manage provider profiles and autonomy presets - Removed safety settings from SettingsPanel and integrated autonomy presets. - Added functionality to save and load provider profiles with associated settings. - Updated UI components to reflect changes in provider management. - Introduced loading states for saving settings and testing connections. - Enhanced workspace settings section with improved layout and statistics display. - Added new utility functions for handling autonomy presets and their derived safety settings. - Updated global styles to accommodate new UI elements and loading overlays. - Added unit tests for new features and refactored existing tests for consistency. --- .github/workflows/ci.yml | 2 + .vscodeignore | 1 - benchmark/README.md | 122 ++++ benchmark/fixtures/nest-api/package.json | 20 + .../fixtures/nest-api/src/app.controller.ts | 12 + benchmark/fixtures/nest-api/src/app.module.ts | 10 + .../fixtures/nest-api/src/app.service.ts | 8 + benchmark/fixtures/nest-api/src/main.ts | 9 + benchmark/fixtures/nest-api/tsconfig.json | 13 + benchmark/fixtures/next-app/app/layout.tsx | 12 + benchmark/fixtures/next-app/app/page.tsx | 8 + benchmark/fixtures/next-app/package.json | 16 + benchmark/fixtures/node-express/package.json | 14 + benchmark/fixtures/node-express/src/index.js | 17 + .../fixtures/node-express/src/routes/users.js | 14 + .../fixtures/node-express/test/users.test.js | 7 + benchmark/fixtures/react-vite/package.json | 20 + benchmark/fixtures/react-vite/src/App.tsx | 10 + .../react-vite/src/components/Button.tsx | 8 + benchmark/run-benchmark.mjs | 124 ++-- benchmark/tasks.json | 26 - benchmark/tasks/agent.json | 48 ++ benchmark/tasks/ask.json | 47 ++ benchmark/tasks/index.json | 10 + benchmark/tasks/integration.json | 12 + benchmark/tasks/plan.json | 38 ++ benchmark/tasks/regression.json | 74 +++ benchmark/tasks/smoke.json | 26 + benchmark/verify.mjs | 134 +++++ package-lock.json | 4 +- package.json | 8 +- scripts/copy-bundled-skills.mjs | 16 + scripts/sync-bundled-skills.sh | 10 +- src/core/app/ThunderController.ts | 131 ++++- src/core/config/schema.ts | 1 + src/core/config/ui/payloads.ts | 1 + src/core/headless/HeadlessAgentHost.ts | 549 ++++++++++++++++++ src/core/headless/HeadlessConfig.ts | 94 +++ .../headless/HeadlessDiagnosticsService.ts | 47 ++ src/core/headless/headlessDiscoverFiles.ts | 58 ++ src/core/headless/index.ts | 2 + src/core/mcp/builtinServers.ts | 10 + src/core/mcp/mcpToggles.ts | 6 +- src/core/plans/promptBuilder.ts | 2 +- src/core/providers/ProviderProfilesService.ts | 182 ++++++ src/core/rules/ProjectRulesService.ts | 78 +-- .../core/skills/bundled}/README.md | 0 .../skills/bundled}/audit-cleanup/SKILL.md | 0 .../browser-testing-with-devtools/SKILL.md | 39 ++ .../bundled}/code-review-and-quality/SKILL.md | 0 .../code-smells-and-tech-debt/SKILL.md | 0 .../debugging-and-error-recovery/SKILL.md | 0 .../bundled}/environment-and-secrets/SKILL.md | 0 .../git-workflow-and-versioning/SKILL.md | 0 .../performance-optimization/SKILL.md | 0 .../planning-and-task-breakdown/SKILL.md | 0 .../bundled}/test-driven-development/SKILL.md | 0 .../bundled}/using-agent-skills/SKILL.md | 0 src/core/skills/installBundledSkills.ts | 17 +- src/core/skills/resolveBundledSkillsRoot.ts | 32 + src/node/cli.ts | 70 ++- src/node/vscode-shim.ts | 54 ++ src/vscode/webview/ThunderWebviewProvider.ts | 23 + src/vscode/webview/messages.ts | 24 + src/webview-ui/src/App.tsx | 21 +- src/webview-ui/src/components/ChatInput.tsx | 26 +- .../src/components/SettingsPanel.tsx | 252 ++++---- .../components/WorkspaceSettingsSection.tsx | 138 ++--- src/webview-ui/src/styles/global.css | 71 +++ src/webview-ui/src/utils/autonomyPreset.ts | 68 +++ test/benchmark/benchmark.harness.test.ts | 86 +++ test/benchmark/headless-agent-host.test.ts | 59 ++ test/config-ui-mappers.test.ts | 2 + test/setup.ts | 30 + test/unit.test.ts | 37 +- 75 files changed, 2689 insertions(+), 421 deletions(-) create mode 100644 benchmark/README.md create mode 100644 benchmark/fixtures/nest-api/package.json create mode 100644 benchmark/fixtures/nest-api/src/app.controller.ts create mode 100644 benchmark/fixtures/nest-api/src/app.module.ts create mode 100644 benchmark/fixtures/nest-api/src/app.service.ts create mode 100644 benchmark/fixtures/nest-api/src/main.ts create mode 100644 benchmark/fixtures/nest-api/tsconfig.json create mode 100644 benchmark/fixtures/next-app/app/layout.tsx create mode 100644 benchmark/fixtures/next-app/app/page.tsx create mode 100644 benchmark/fixtures/next-app/package.json create mode 100644 benchmark/fixtures/node-express/package.json create mode 100644 benchmark/fixtures/node-express/src/index.js create mode 100644 benchmark/fixtures/node-express/src/routes/users.js create mode 100644 benchmark/fixtures/node-express/test/users.test.js create mode 100644 benchmark/fixtures/react-vite/package.json create mode 100644 benchmark/fixtures/react-vite/src/App.tsx create mode 100644 benchmark/fixtures/react-vite/src/components/Button.tsx delete mode 100644 benchmark/tasks.json create mode 100644 benchmark/tasks/agent.json create mode 100644 benchmark/tasks/ask.json create mode 100644 benchmark/tasks/index.json create mode 100644 benchmark/tasks/integration.json create mode 100644 benchmark/tasks/plan.json create mode 100644 benchmark/tasks/regression.json create mode 100644 benchmark/tasks/smoke.json create mode 100644 benchmark/verify.mjs create mode 100644 scripts/copy-bundled-skills.mjs create mode 100644 src/core/headless/HeadlessAgentHost.ts create mode 100644 src/core/headless/HeadlessConfig.ts create mode 100644 src/core/headless/HeadlessDiagnosticsService.ts create mode 100644 src/core/headless/headlessDiscoverFiles.ts create mode 100644 src/core/providers/ProviderProfilesService.ts rename {bundled-skills => src/core/skills/bundled}/README.md (100%) rename {bundled-skills => src/core/skills/bundled}/audit-cleanup/SKILL.md (100%) create mode 100644 src/core/skills/bundled/browser-testing-with-devtools/SKILL.md rename {bundled-skills => src/core/skills/bundled}/code-review-and-quality/SKILL.md (100%) rename {bundled-skills => src/core/skills/bundled}/code-smells-and-tech-debt/SKILL.md (100%) rename {bundled-skills => src/core/skills/bundled}/debugging-and-error-recovery/SKILL.md (100%) rename {bundled-skills => src/core/skills/bundled}/environment-and-secrets/SKILL.md (100%) rename {bundled-skills => src/core/skills/bundled}/git-workflow-and-versioning/SKILL.md (100%) rename {bundled-skills => src/core/skills/bundled}/performance-optimization/SKILL.md (100%) rename {bundled-skills => src/core/skills/bundled}/planning-and-task-breakdown/SKILL.md (100%) rename {bundled-skills => src/core/skills/bundled}/test-driven-development/SKILL.md (100%) rename {bundled-skills => src/core/skills/bundled}/using-agent-skills/SKILL.md (100%) create mode 100644 src/core/skills/resolveBundledSkillsRoot.ts create mode 100644 src/node/vscode-shim.ts create mode 100644 src/webview-ui/src/utils/autonomyPreset.ts create mode 100644 test/benchmark/benchmark.harness.test.ts create mode 100644 test/benchmark/headless-agent-host.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 287860b9..b9f84f2a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,3 +23,5 @@ jobs: - run: npm ci - run: npm run release:check-assets - run: npm test + - run: npm run compile:cli + - run: npm run benchmark:smoke diff --git a/.vscodeignore b/.vscodeignore index e6d12c89..bd3dd890 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -26,4 +26,3 @@ vitest.config.ts !dist/** !media/** -!bundled-skills/** diff --git a/benchmark/README.md b/benchmark/README.md new file mode 100644 index 00000000..1f00a021 --- /dev/null +++ b/benchmark/README.md @@ -0,0 +1,122 @@ +# Mitii Enterprise Benchmark + +Reproducible evaluation harness for **Ask**, **Plan**, and **Agent** modes across JavaScript/TypeScript stacks (Node, Express, React, NestJS, Next.js). + +## Architecture + +```text +benchmark/ + run-benchmark.mjs # Orchestrator + verify.mjs # Verification rules + tasks/ # Task definitions by mode + regression + fixtures/ # Pinned sample repos +``` + +The CLI uses `HeadlessAgentHost`, which wires the **real** production agent pipeline (`ChatOrchestrator`, indexing, tools, skills, MCP) outside VS Code. + +| Runtime | When | What runs | +|---------|------|-----------| +| `stub` | Smoke / echo | Fast wiring checks via `HeadlessAgentRunner` | +| `real` | Enterprise eval | Full agent: index, retrieval, tools, session logs | + +## Quick start + +```bash +cd mitii-ai-agent +npm run compile:cli +npm run benchmark:smoke # CI-friendly (3 tasks, echo/stub) +npm run benchmark:all # All tasks, real runtime +``` + +### Live model evaluation + +```bash +export MITII_API_KEY=... +node benchmark/run-benchmark.mjs --tier all --runtime real --provider openai-compatible --model gpt-4o-mini +``` + +### Browser / Puppeteer lane + +```bash +mitii agent "Verify the home page title" \ + --runtime real \ + --enable-puppeteer \ + --approval auto \ + --cwd benchmark/fixtures/react-vite +``` + +Built-in MCP: `@modelcontextprotocol/server-puppeteer` (toggle via `thunder.mcp.builtinServers.puppeteer` or `--enable-puppeteer`). + +## Task tiers + +| Tier | File | Focus | +|------|------|-------| +| `smoke` | `tasks/smoke.json` | CLI wiring | +| `ask` | `tasks/ask.json` | Retrieval & Q&A on fixtures | +| `plan` | `tasks/plan.json` | Structured planning | +| `agent` | `tasks/agent.json` | Tool loop & guidance | +| `regression` | `tasks/regression.json` | Fixed bugs from CHANGELOG | +| `all` | `tasks/index.json` | Everything | + +## Fixture repos + +| Fixture | Stack | +|---------|-------| +| `node-express` | Express API + routes | +| `react-vite` | React + TypeScript UI | +| `nest-api` | NestJS modules/controllers | +| `next-app` | Next.js App Router | + +## Verification rules + +| Rule | Meaning | +|------|---------| +| `exit_0` | CLI exit code 0 | +| `stdout_contains:` | Output includes text | +| `stdout_not_empty` | Non-empty stdout | +| `json_path:` | JSON stdout has key | +| `jsonl_event:` | Agent stream event | +| `file_exists:` | File in fixture | +| `file_contains::` | File content match | +| `skills_installed:` | `.mitii/skills` count | +| `command_exit_0:` | Shell command passes | +| `session_log_has:` | JSONL session event | + +## Reports + +Written to `.mitii/benchmark/report.json` and `report.md`: + +- Pass/fail per task +- Duration +- Verification breakdown +- Score percentage + +## Skills in core + +Bundled skills live at `src/core/skills/bundled/` (copied to `dist/core/skills/bundled` on compile). Workspace install uses `installBundledSkills()` on init. + +## Tests + +```bash +npm test -- test/benchmark +``` + +- `benchmark.harness.test.ts` — verify rules, fixtures, skills path +- `headless-agent-host.test.ts` — stub + real host initialization + +## CI + +Smoke benchmark runs in GitHub Actions after unit tests (`npm run benchmark:smoke`). + +## Regression coverage map + +| CHANGELOG area | Benchmark task | +|----------------|----------------| +| Verify command discovery | `regression-verify-command-discovery` | +| Skill-aware planning | `regression-skill-routing-plan` | +| Empty model response | `regression-empty-response-handling` | +| Phase-lock / agent loop | `regression-phase-lock-guidance` | +| Act MCP exclusions | `regression-act-mcp-exclusion-note` | +| Windows-safe paths | `regression-windows-path-safe` | +| Micro-task commit msg | `regression-microtask-commit-msg-hint` | +| Sequential-thinking cap | `regression-sequential-thinking-cap` | diff --git a/benchmark/fixtures/nest-api/package.json b/benchmark/fixtures/nest-api/package.json new file mode 100644 index 00000000..49ee7ec6 --- /dev/null +++ b/benchmark/fixtures/nest-api/package.json @@ -0,0 +1,20 @@ +{ + "name": "benchmark-nest-api", + "version": "1.0.0", + "private": true, + "scripts": { + "start": "node dist/main.js", + "build": "tsc -p tsconfig.json", + "test": "node -e \"console.log('jest placeholder')\"", + "lint": "node -e \"console.log('lint ok')\"" + }, + "dependencies": { + "@nestjs/common": "^10.3.0", + "@nestjs/core": "^10.3.0", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "typescript": "^5.5.2" + } +} diff --git a/benchmark/fixtures/nest-api/src/app.controller.ts b/benchmark/fixtures/nest-api/src/app.controller.ts new file mode 100644 index 00000000..cce879ee --- /dev/null +++ b/benchmark/fixtures/nest-api/src/app.controller.ts @@ -0,0 +1,12 @@ +import { Controller, Get } from '@nestjs/common'; +import { AppService } from './app.service'; + +@Controller() +export class AppController { + constructor(private readonly appService: AppService) {} + + @Get() + getHello(): string { + return this.appService.getHello(); + } +} diff --git a/benchmark/fixtures/nest-api/src/app.module.ts b/benchmark/fixtures/nest-api/src/app.module.ts new file mode 100644 index 00000000..86628031 --- /dev/null +++ b/benchmark/fixtures/nest-api/src/app.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { AppController } from './app.controller'; +import { AppService } from './app.service'; + +@Module({ + imports: [], + controllers: [AppController], + providers: [AppService], +}) +export class AppModule {} diff --git a/benchmark/fixtures/nest-api/src/app.service.ts b/benchmark/fixtures/nest-api/src/app.service.ts new file mode 100644 index 00000000..bcf9c059 --- /dev/null +++ b/benchmark/fixtures/nest-api/src/app.service.ts @@ -0,0 +1,8 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class AppService { + getHello(): string { + return 'Hello Nest Benchmark!'; + } +} diff --git a/benchmark/fixtures/nest-api/src/main.ts b/benchmark/fixtures/nest-api/src/main.ts new file mode 100644 index 00000000..b639ed42 --- /dev/null +++ b/benchmark/fixtures/nest-api/src/main.ts @@ -0,0 +1,9 @@ +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + await app.listen(3001); +} + +void bootstrap(); diff --git a/benchmark/fixtures/nest-api/tsconfig.json b/benchmark/fixtures/nest-api/tsconfig.json new file mode 100644 index 00000000..bced18cd --- /dev/null +++ b/benchmark/fixtures/nest-api/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2020", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true + }, + "include": ["src/**/*"] +} diff --git a/benchmark/fixtures/next-app/app/layout.tsx b/benchmark/fixtures/next-app/app/layout.tsx new file mode 100644 index 00000000..77517100 --- /dev/null +++ b/benchmark/fixtures/next-app/app/layout.tsx @@ -0,0 +1,12 @@ +export const metadata = { + title: 'Benchmark Next App', + description: 'Mitii benchmark fixture', +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/benchmark/fixtures/next-app/app/page.tsx b/benchmark/fixtures/next-app/app/page.tsx new file mode 100644 index 00000000..4046cced --- /dev/null +++ b/benchmark/fixtures/next-app/app/page.tsx @@ -0,0 +1,8 @@ +export default function HomePage() { + return ( +

+

Next.js Benchmark Home

+

Fixture for Ask, Plan, and Agent benchmarks.

+
+ ); +} diff --git a/benchmark/fixtures/next-app/package.json b/benchmark/fixtures/next-app/package.json new file mode 100644 index 00000000..49d3a374 --- /dev/null +++ b/benchmark/fixtures/next-app/package.json @@ -0,0 +1,16 @@ +{ + "name": "benchmark-next-app", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "test": "node -e \"console.log('next test placeholder')\"", + "lint": "node -e \"console.log('lint ok')\"" + }, + "dependencies": { + "next": "^14.2.5", + "react": "^18.3.1", + "react-dom": "^18.3.1" + } +} diff --git a/benchmark/fixtures/node-express/package.json b/benchmark/fixtures/node-express/package.json new file mode 100644 index 00000000..a27b7f86 --- /dev/null +++ b/benchmark/fixtures/node-express/package.json @@ -0,0 +1,14 @@ +{ + "name": "benchmark-node-express", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "node src/index.js", + "test": "node --test test/users.test.js", + "lint": "node -e \"console.log('lint ok')\"" + }, + "dependencies": { + "express": "^4.19.2" + } +} diff --git a/benchmark/fixtures/node-express/src/index.js b/benchmark/fixtures/node-express/src/index.js new file mode 100644 index 00000000..56f62462 --- /dev/null +++ b/benchmark/fixtures/node-express/src/index.js @@ -0,0 +1,17 @@ +import express from 'express'; +import usersRouter from './routes/users.js'; + +const app = express(); +app.use(express.json()); +app.use('/users', usersRouter); + +app.get('/health', (_req, res) => { + res.json({ ok: true }); +}); + +const port = process.env.PORT || 3000; +app.listen(port, () => { + console.log(`listening on ${port}`); +}); + +export default app; diff --git a/benchmark/fixtures/node-express/src/routes/users.js b/benchmark/fixtures/node-express/src/routes/users.js new file mode 100644 index 00000000..7b016c0e --- /dev/null +++ b/benchmark/fixtures/node-express/src/routes/users.js @@ -0,0 +1,14 @@ +import { Router } from 'express'; + +const router = Router(); + +router.get('/', (_req, res) => { + // BUG: wrong message for benchmark agent fix task + res.json({ users: [], message: 'pending' }); +}); + +router.get('/:id', (req, res) => { + res.json({ id: req.params.id, name: 'Sample User' }); +}); + +export default router; diff --git a/benchmark/fixtures/node-express/test/users.test.js b/benchmark/fixtures/node-express/test/users.test.js new file mode 100644 index 00000000..5fa21766 --- /dev/null +++ b/benchmark/fixtures/node-express/test/users.test.js @@ -0,0 +1,7 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +test('users route module exports router', async () => { + const mod = await import('../src/routes/users.js'); + assert.ok(mod.default); +}); diff --git a/benchmark/fixtures/react-vite/package.json b/benchmark/fixtures/react-vite/package.json new file mode 100644 index 00000000..6b1b6c19 --- /dev/null +++ b/benchmark/fixtures/react-vite/package.json @@ -0,0 +1,20 @@ +{ + "name": "benchmark-react-vite", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "test": "node -e \"console.log('vitest placeholder')\"", + "lint": "node -e \"console.log('lint ok')\"" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "typescript": "^5.5.2", + "vite": "^5.3.1" + } +} diff --git a/benchmark/fixtures/react-vite/src/App.tsx b/benchmark/fixtures/react-vite/src/App.tsx new file mode 100644 index 00000000..1480282b --- /dev/null +++ b/benchmark/fixtures/react-vite/src/App.tsx @@ -0,0 +1,10 @@ +import Button from './components/Button'; + +export default function App() { + return ( +
+

Benchmark React App

+
+ ); +} diff --git a/benchmark/fixtures/react-vite/src/components/Button.tsx b/benchmark/fixtures/react-vite/src/components/Button.tsx new file mode 100644 index 00000000..3a5a5b19 --- /dev/null +++ b/benchmark/fixtures/react-vite/src/components/Button.tsx @@ -0,0 +1,8 @@ +export interface ButtonProps { + label: string; + variant?: 'primary'; +} + +export default function Button({ label, variant = 'primary' }: ButtonProps) { + return ; +} diff --git a/benchmark/run-benchmark.mjs b/benchmark/run-benchmark.mjs index e8ffe0bd..df564fa4 100644 --- a/benchmark/run-benchmark.mjs +++ b/benchmark/run-benchmark.mjs @@ -1,93 +1,133 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; import { spawnSync } from 'child_process'; import { dirname, join, resolve } from 'path'; +import { fileURLToPath } from 'url'; +import { verifyTask, summarizeVerifications } from './verify.mjs'; -const cwd = resolve(process.argv.includes('--cwd') ? process.argv[process.argv.indexOf('--cwd') + 1] : process.cwd()); -const tasksPath = resolve(process.argv.includes('--tasks') ? process.argv[process.argv.indexOf('--tasks') + 1] : 'benchmark/tasks.json'); -const outputPath = resolve(process.argv.includes('--output') ? process.argv[process.argv.indexOf('--output') + 1] : '.mitii/benchmark/report.json'); -const provider = process.argv.includes('--provider') ? process.argv[process.argv.indexOf('--provider') + 1] : 'echo'; -if (!existsSync('dist/cli.js')) { - const compile = spawnSync('npm', ['run', 'compile:cli'], { cwd, stdio: 'inherit' }); +const benchmarkDir = dirname(fileURLToPath(import.meta.url)); +const packageRoot = resolve(benchmarkDir, '..'); +const args = process.argv.slice(2); +const cwd = resolve(valueOf(args, '--cwd') ?? process.cwd()); +const tasksPath = resolve(valueOf(args, '--tasks') ?? join(benchmarkDir, 'tasks/index.json')); +const outputPath = resolve(valueOf(args, '--output') ?? join(cwd, '.mitii/benchmark/report.json')); +const provider = valueOf(args, '--provider') ?? 'echo'; +const tier = valueOf(args, '--tier') ?? 'smoke'; +const runtime = valueOf(args, '--runtime') ?? (tier === 'smoke' && provider === 'echo' ? 'stub' : 'real'); +const approval = valueOf(args, '--approval') ?? 'auto'; +const enablePuppeteer = args.includes('--enable-puppeteer'); + +if (!existsSync(join(packageRoot, 'dist/cli.js'))) { + const compile = spawnSync('npm', ['run', 'compile:cli'], { cwd: packageRoot, stdio: 'inherit' }); if (compile.status) process.exit(compile.status ?? 1); } -const cliPath = 'dist/cli.js'; -const tasks = JSON.parse(readFileSync(tasksPath, 'utf8')); -const results = tasks.map((task) => runTask(task)); +const cliPath = join(packageRoot, 'dist/cli.js'); +const taskIndex = JSON.parse(readFileSync(tasksPath, 'utf8')); +const selectedTasks = loadTasks(taskIndex, tier); +const fixtureRoot = join(packageRoot, 'benchmark/fixtures'); + +const results = selectedTasks.map((task) => runTask(task, { cliPath, packageRoot, fixtureRoot })); const passed = results.filter((result) => result.passed).length; const report = { cwd, + packageRoot, provider, + runtime, + tier, startedAt: new Date().toISOString(), summary: { total: results.length, passed, failed: results.length - passed, + score: results.length ? Math.round((passed / results.length) * 100) : 0, }, + verificationSummary: summarizeVerifications(results), results, }; mkdirSync(dirname(outputPath), { recursive: true }); writeFileSync(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); writeFileSync(outputPath.replace(/\.json$/, '.md'), toMarkdown(report), 'utf8'); -console.log(`${passed}/${results.length} benchmark tasks passed`); +console.log(`${passed}/${results.length} benchmark tasks passed (${report.summary.score}%)`); if (passed !== results.length) process.exitCode = 1; -function runTask(task) { - const args = [cliPath, task.mode, task.prompt, '--cwd', cwd, '--provider', provider]; - if (task.mode !== 'ask') args.push('--json'); +function loadTasks(index, selectedTier) { + const baseDir = dirname(tasksPath); + const files = Array.isArray(index.includes) ? index.includes : [index.tasksFile ?? 'tasks.json']; + const all = files.flatMap((file) => { + const path = resolve(baseDir, file); + return JSON.parse(readFileSync(path, 'utf8')); + }); + return all.filter((task) => !task.tier || task.tier === selectedTier || selectedTier === 'all'); +} + +function runTask(task, ctx) { + const fixtureCwd = task.fixture ? join(ctx.fixtureRoot, task.fixture) : cwd; + const extraArgs = [ + '--cwd', fixtureCwd, + '--provider', provider, + '--runtime', runtime, + '--approval', approval, + ]; + if (enablePuppeteer || task.enablePuppeteer) extraArgs.push('--enable-puppeteer'); + if (task.model) extraArgs.push('--model', task.model); + const taskRuntime = task.runtime ?? runtime; + const runtimeIndex = extraArgs.indexOf('--runtime'); + if (runtimeIndex >= 0) extraArgs[runtimeIndex + 1] = taskRuntime; + + const cliArgs = [ctx.cliPath, task.mode, task.prompt, ...extraArgs]; + if (task.mode !== 'ask') cliArgs.push('--json'); + const started = Date.now(); - const result = spawnSync('node', args, { cwd, encoding: 'utf8' }); + const result = spawnSync('node', cliArgs, { cwd: packageRoot, encoding: 'utf8', env: process.env }); const durationMs = Date.now() - started; const stdout = result.stdout ?? ''; const stderr = result.stderr ?? ''; - const passed = result.status === 0 && verify(task.verify, stdout); + const verifications = (task.verify ?? []).map((rule) => verifyTask(rule, { + stdout, + stderr, + exitCode: result.status ?? 1, + cwd: fixtureCwd, + packageRoot: ctx.packageRoot, + mode: task.mode, + })); + const passed = result.status === 0 && verifications.every((v) => v.passed); + return { id: task.id, + category: task.category ?? 'general', mode: task.mode, + fixture: task.fixture ?? null, passed, durationMs, exitCode: result.status, + verifications, stdout: stdout.slice(0, 4000), stderr: stderr.slice(0, 2000), }; } -function verify(rule, stdout) { - if (!rule) return true; - if (rule.startsWith('stdout_contains:')) return stdout.includes(rule.slice('stdout_contains:'.length)); - if (rule.startsWith('json_path:')) { - const key = rule.slice('json_path:'.length); - try { - return Boolean(JSON.parse(stdout)[key]); - } catch { - return false; - } - } - if (rule.startsWith('jsonl_event:')) { - const type = rule.slice('jsonl_event:'.length); - return stdout.split(/\r?\n/).some((line) => { - try { - return JSON.parse(line).type === type; - } catch { - return false; - } - }); - } - return false; +function valueOf(argv, name) { + const idx = argv.indexOf(name); + return idx >= 0 ? argv[idx + 1] : undefined; } function toMarkdown(report) { return [ - '# Mitii Benchmark Report', + '# Mitii Enterprise Benchmark Report', '', `Provider: ${report.provider}`, - `Score: ${report.summary.passed}/${report.summary.total}`, + `Runtime: ${report.runtime}`, + `Tier: ${report.tier}`, + `Score: ${report.summary.passed}/${report.summary.total} (${report.summary.score}%)`, + '', + '## Verification summary', + ...Object.entries(report.verificationSummary).map(([k, v]) => `- ${k}: ${v.passed}/${v.total}`), '', - '| Task | Mode | Result | Duration |', - '|---|---|---|---:|', + '| Task | Category | Mode | Fixture | Result | Duration |', + '|---|---|---|---|---:|---:|', ...report.results.map((result) => - `| ${result.id} | ${result.mode} | ${result.passed ? 'pass' : 'fail'} | ${result.durationMs} ms |` + `| ${result.id} | ${result.category} | ${result.mode} | ${result.fixture ?? '-'} | ${result.passed ? 'pass' : 'fail'} | ${result.durationMs} ms |` ), '', ].join('\n'); diff --git a/benchmark/tasks.json b/benchmark/tasks.json deleted file mode 100644 index 9b9089d6..00000000 --- a/benchmark/tasks.json +++ /dev/null @@ -1,26 +0,0 @@ -[ - { - "id": "repo-map-summary", - "mode": "ask", - "prompt": "Summarize the project structure and identify the main extension entry point.", - "verify": "stdout_contains:Echo:" - }, - { - "id": "staged-commit-message-ask", - "mode": "ask", - "prompt": "Need commit message for the changes in stage @mitii-ai-agent", - "verify": "stdout_contains:Echo:" - }, - { - "id": "release-plan", - "mode": "plan", - "prompt": "Plan a release hygiene pass covering changelog, README version badge, and media assets.", - "verify": "json_path:steps" - }, - { - "id": "headless-agent-events", - "mode": "agent", - "prompt": "Review the current working tree and produce implementation guidance.", - "verify": "jsonl_event:end" - } -] diff --git a/benchmark/tasks/agent.json b/benchmark/tasks/agent.json new file mode 100644 index 00000000..8b25385b --- /dev/null +++ b/benchmark/tasks/agent.json @@ -0,0 +1,48 @@ +[ + { + "id": "agent-node-fix-typo", + "tier": "agent", + "category": "node", + "mode": "agent", + "fixture": "node-express", + "prompt": "Fix the bug in src/routes/users.js where the list endpoint returns the wrong status message. Apply the fix and explain verification.", + "verify": ["exit_0", "jsonl_event:end", "file_contains:src/routes/users.js:success"] + }, + { + "id": "agent-react-button-variant", + "tier": "agent", + "category": "react", + "mode": "agent", + "fixture": "react-vite", + "prompt": "Add a secondary variant to Button.tsx and use it in App.tsx. Provide a verification checklist.", + "verify": ["exit_0", "jsonl_event:end", "file_exists:src/App.tsx"] + }, + { + "id": "agent-nest-add-dto", + "tier": "agent", + "category": "nestjs", + "mode": "agent", + "fixture": "nest-api", + "prompt": "Introduce a CreateUserDto and wire validation in AppController.", + "verify": ["exit_0", "jsonl_event:end", "file_exists:src/app.controller.ts"] + }, + { + "id": "agent-next-loading-ui", + "tier": "agent", + "category": "nextjs", + "mode": "agent", + "fixture": "next-app", + "prompt": "Add a loading.tsx for the home route and document how to verify it in dev.", + "verify": ["exit_0", "jsonl_event:end", "file_exists:app/page.tsx"] + }, + { + "id": "agent-browser-skill-available", + "tier": "agent", + "category": "browser", + "mode": "agent", + "fixture": "react-vite", + "enablePuppeteer": false, + "prompt": "List available Mitii skills for browser testing and when to use puppeteer MCP.", + "verify": ["exit_0", "jsonl_event:end", "skills_installed:10"] + } +] diff --git a/benchmark/tasks/ask.json b/benchmark/tasks/ask.json new file mode 100644 index 00000000..f18dea2d --- /dev/null +++ b/benchmark/tasks/ask.json @@ -0,0 +1,47 @@ +[ + { + "id": "ask-node-entry", + "tier": "ask", + "category": "node", + "mode": "ask", + "fixture": "node-express", + "prompt": "Explain how requests flow through this Express app. Where is the users router mounted?", + "verify": ["exit_0", "stdout_not_empty", "file_exists:src/index.js"] + }, + { + "id": "ask-react-component", + "tier": "ask", + "category": "react", + "mode": "ask", + "fixture": "react-vite", + "prompt": "What does the Button component render and what props does it accept?", + "verify": ["exit_0", "stdout_not_empty", "file_exists:src/components/Button.tsx"] + }, + { + "id": "ask-nest-module", + "tier": "ask", + "category": "nestjs", + "mode": "ask", + "fixture": "nest-api", + "prompt": "Describe the NestJS module structure and where AppController is registered.", + "verify": ["exit_0", "stdout_not_empty", "file_exists:src/app.module.ts"] + }, + { + "id": "ask-next-page", + "tier": "ask", + "category": "nextjs", + "mode": "ask", + "fixture": "next-app", + "prompt": "What is rendered on the home page and how is the root layout structured?", + "verify": ["exit_0", "stdout_not_empty", "file_exists:app/page.tsx"] + }, + { + "id": "ask-retrieval-users-route", + "tier": "ask", + "category": "node", + "mode": "ask", + "fixture": "node-express", + "prompt": "Find the route handler for GET /users and summarize what it returns.", + "verify": ["exit_0", "stdout_not_empty", "file_contains:src/routes/users.js:router.get"] + } +] diff --git a/benchmark/tasks/index.json b/benchmark/tasks/index.json new file mode 100644 index 00000000..ad3da778 --- /dev/null +++ b/benchmark/tasks/index.json @@ -0,0 +1,10 @@ +{ + "includes": [ + "smoke.json", + "integration.json", + "ask.json", + "plan.json", + "agent.json", + "regression.json" + ] +} diff --git a/benchmark/tasks/integration.json b/benchmark/tasks/integration.json new file mode 100644 index 00000000..bf23045e --- /dev/null +++ b/benchmark/tasks/integration.json @@ -0,0 +1,12 @@ +[ + { + "id": "integration-real-ask-node", + "tier": "integration", + "category": "ask", + "mode": "ask", + "fixture": "node-express", + "runtime": "real", + "prompt": "Where is the Express app created and what port does it listen on?", + "verify": ["exit_0", "stdout_not_empty", "skills_installed:10"] + } +] diff --git a/benchmark/tasks/plan.json b/benchmark/tasks/plan.json new file mode 100644 index 00000000..0fb0f8fa --- /dev/null +++ b/benchmark/tasks/plan.json @@ -0,0 +1,38 @@ +[ + { + "id": "plan-react-test-coverage", + "tier": "plan", + "category": "react", + "mode": "plan", + "fixture": "react-vite", + "prompt": "Plan adding unit tests for the Button component and updating package.json scripts.", + "verify": ["exit_0", "json_path:steps", "file_exists:package.json"] + }, + { + "id": "plan-nest-health-endpoint", + "tier": "plan", + "category": "nestjs", + "mode": "plan", + "fixture": "nest-api", + "prompt": "Plan adding a /health endpoint with controller, service, and e2e test.", + "verify": ["exit_0", "json_path:steps", "file_exists:src/main.ts"] + }, + { + "id": "plan-next-seo-metadata", + "tier": "plan", + "category": "nextjs", + "mode": "plan", + "fixture": "next-app", + "prompt": "Plan improving SEO metadata in app/layout.tsx and documenting verification steps.", + "verify": ["exit_0", "json_path:steps", "file_exists:app/layout.tsx"] + }, + { + "id": "plan-node-error-middleware", + "tier": "plan", + "category": "node", + "mode": "plan", + "fixture": "node-express", + "prompt": "Plan adding centralized error middleware and updating the users route tests.", + "verify": ["exit_0", "json_path:steps", "file_contains:src/index.js:express"] + } +] diff --git a/benchmark/tasks/regression.json b/benchmark/tasks/regression.json new file mode 100644 index 00000000..a93d1a70 --- /dev/null +++ b/benchmark/tasks/regression.json @@ -0,0 +1,74 @@ +[ + { + "id": "regression-verify-command-discovery", + "tier": "regression", + "category": "verify", + "mode": "ask", + "fixture": "node-express", + "prompt": "What npm scripts exist in this project for test and lint verification?", + "verify": ["exit_0", "stdout_not_empty", "file_contains:package.json:test"] + }, + { + "id": "regression-skill-routing-plan", + "tier": "regression", + "category": "skills", + "mode": "plan", + "fixture": "react-vite", + "prompt": "Plan a test-driven development pass to add tests for the Button component.", + "verify": ["exit_0", "json_path:steps", "skills_installed:10"] + }, + { + "id": "regression-empty-response-handling", + "tier": "regression", + "category": "orchestrator", + "mode": "ask", + "fixture": "nest-api", + "prompt": "Summarize AppModule providers without making anything up.", + "verify": ["exit_0", "stdout_not_empty"] + }, + { + "id": "regression-phase-lock-guidance", + "tier": "regression", + "category": "agent-loop", + "mode": "agent", + "fixture": "node-express", + "prompt": "Review src/index.js and propose safe read-only inspection steps before any edits.", + "verify": ["exit_0", "jsonl_event:end"] + }, + { + "id": "regression-act-mcp-exclusion-note", + "tier": "regression", + "category": "mcp", + "mode": "agent", + "fixture": "next-app", + "prompt": "Explain which tools should be used for file edits in Agent mode vs MCP filesystem tools.", + "verify": ["exit_0", "jsonl_event:end"] + }, + { + "id": "regression-windows-path-safe", + "tier": "regression", + "category": "paths", + "mode": "ask", + "fixture": "react-vite", + "prompt": "List source files under src/ using workspace-relative paths.", + "verify": ["exit_0", "stdout_not_empty", "dir_has_files:src"] + }, + { + "id": "regression-microtask-commit-msg-hint", + "tier": "regression", + "category": "microtasks", + "mode": "ask", + "fixture": "node-express", + "prompt": "Need commit message for staged changes in src/routes/users.js", + "verify": ["exit_0", "stdout_not_empty"] + }, + { + "id": "regression-sequential-thinking-cap", + "tier": "regression", + "category": "mcp", + "mode": "plan", + "fixture": "nest-api", + "prompt": "Plan debugging steps for a failing health check endpoint using structured reasoning.", + "verify": ["exit_0", "json_path:steps"] + } +] diff --git a/benchmark/tasks/smoke.json b/benchmark/tasks/smoke.json new file mode 100644 index 00000000..b8c31e36 --- /dev/null +++ b/benchmark/tasks/smoke.json @@ -0,0 +1,26 @@ +[ + { + "id": "smoke-ask-echo", + "tier": "smoke", + "category": "infrastructure", + "mode": "ask", + "prompt": "Summarize the project structure and identify the main entry point.", + "verify": ["exit_0", "stdout_contains:Echo:"] + }, + { + "id": "smoke-plan-echo", + "tier": "smoke", + "category": "infrastructure", + "mode": "plan", + "prompt": "Plan a release hygiene pass covering changelog, README, and tests.", + "verify": ["exit_0", "json_path:steps"] + }, + { + "id": "smoke-agent-echo", + "tier": "smoke", + "category": "infrastructure", + "mode": "agent", + "prompt": "Review the working tree and produce implementation guidance.", + "verify": ["exit_0", "jsonl_event:end"] + } +] diff --git a/benchmark/verify.mjs b/benchmark/verify.mjs new file mode 100644 index 00000000..a3c116fc --- /dev/null +++ b/benchmark/verify.mjs @@ -0,0 +1,134 @@ +import { existsSync, readFileSync, readdirSync, statSync } from 'fs'; +import { join } from 'path'; +import { spawnSync } from 'child_process'; + +export function verifyTask(rule, ctx) { + try { + if (typeof rule === 'string') { + return verifyRule(rule, ctx); + } + if (rule.all) { + const results = rule.all.map((r) => verifyRule(r, ctx)); + return { + rule: 'all', + passed: results.every((r) => r.passed), + details: results, + }; + } + if (rule.any) { + const results = rule.any.map((r) => verifyRule(r, ctx)); + return { + rule: 'any', + passed: results.some((r) => r.passed), + details: results, + }; + } + return { rule: 'unknown', passed: false, details: 'Unsupported verify shape' }; + } catch (error) { + return { + rule: String(rule), + passed: false, + details: error instanceof Error ? error.message : String(error), + }; + } +} + +function verifyRule(rule, ctx) { + if (rule === 'exit_0') { + return { rule, passed: ctx.exitCode === 0 }; + } + if (rule.startsWith('stdout_contains:')) { + const text = rule.slice('stdout_contains:'.length); + return { rule, passed: ctx.stdout.includes(text) }; + } + if (rule.startsWith('stdout_not_empty')) { + return { rule, passed: ctx.stdout.trim().length > 0 }; + } + if (rule.startsWith('json_path:')) { + const key = rule.slice('json_path:'.length); + try { + return { rule, passed: Boolean(JSON.parse(ctx.stdout)[key]) }; + } catch { + return { rule, passed: false, details: 'Invalid JSON stdout' }; + } + } + if (rule.startsWith('jsonl_event:')) { + const type = rule.slice('jsonl_event:'.length); + const found = ctx.stdout.split(/\r?\n/).some((line) => { + try { + return JSON.parse(line).type === type; + } catch { + return false; + } + }); + return { rule, passed: found }; + } + if (rule.startsWith('file_exists:')) { + const rel = rule.slice('file_exists:'.length); + return { rule, passed: existsSync(join(ctx.cwd, rel)) }; + } + if (rule.startsWith('file_contains:')) { + const [rel, ...needleParts] = rule.slice('file_contains:'.length).split(':'); + const needle = needleParts.join(':'); + const path = join(ctx.cwd, rel); + if (!existsSync(path)) return { rule, passed: false, details: `Missing ${rel}` }; + return { rule, passed: readFileSync(path, 'utf8').includes(needle) }; + } + if (rule.startsWith('dir_has_files:')) { + const rel = rule.slice('dir_has_files:'.length); + const path = join(ctx.cwd, rel); + if (!existsSync(path)) return { rule, passed: false }; + const count = readdirSync(path).filter((f) => statSync(join(path, f)).isFile()).length; + return { rule, passed: count > 0, details: `${count} files` }; + } + if (rule.startsWith('skills_installed:')) { + const min = Number(rule.slice('skills_installed:'.length) || '1'); + const skillsDir = join(ctx.cwd, '.mitii', 'skills'); + if (!existsSync(skillsDir)) return { rule, passed: false }; + const count = readdirSync(skillsDir).filter((entry) => existsSync(join(skillsDir, entry, 'SKILL.md'))).length; + return { rule, passed: count >= min, details: `${count} skills` }; + } + if (rule.startsWith('command_exit_0:')) { + const command = rule.slice('command_exit_0:'.length); + const result = spawnSync(command, { cwd: ctx.cwd, shell: true, encoding: 'utf8' }); + return { rule, passed: result.status === 0, details: (result.stderr || result.stdout || '').slice(0, 500) }; + } + if (rule.startsWith('session_log_has:')) { + const eventType = rule.slice('session_log_has:'.length); + const logsDir = join(ctx.cwd, '.mitii', 'logs'); + if (!existsSync(logsDir)) return { rule, passed: false }; + const files = readdirSync(logsDir).filter((f) => f.endsWith('.jsonl')).sort(); + const last = files[files.length - 1]; + if (!last) return { rule, passed: false }; + const content = readFileSync(join(logsDir, last), 'utf8'); + const found = content.split('\n').some((line) => { + try { + return JSON.parse(line).type === eventType; + } catch { + return false; + } + }); + return { rule, passed: found }; + } + if (rule.startsWith('tool_registered:')) { + const toolName = rule.slice('tool_registered:'.length); + const cliCheck = spawnSync('node', ['-e', ` + const { HeadlessAgentHost } = require('${join(ctx.packageRoot, 'dist/cli.js').replace(/'/g, "\\'")}'); + `], { encoding: 'utf8' }); + return { rule, passed: ctx.stdout.includes(toolName) || cliCheck.status === 0, details: 'tool check deferred to host tests' }; + } + return { rule, passed: false, details: `Unknown rule: ${rule}` }; +} + +export function summarizeVerifications(results) { + const summary = {}; + for (const result of results) { + for (const verification of result.verifications ?? []) { + const key = typeof verification.rule === 'string' ? verification.rule.split(':')[0] : verification.rule; + if (!summary[key]) summary[key] = { passed: 0, total: 0 }; + summary[key].total += 1; + if (verification.passed) summary[key].passed += 1; + } + } + return summary; +} diff --git a/package-lock.json b/package-lock.json index 0e8b0a82..0222b366 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mitii-ai-agent", - "version": "2.7.19", + "version": "2.7.20", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mitii-ai-agent", - "version": "2.7.19", + "version": "2.7.20", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "dependencies": { diff --git a/package.json b/package.json index 46f13c58..6cec0406 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "mitii-ai-agent", "displayName": "Mitii AI Agent", "description": "Local-first VS Code AI coding agent with precise repo context and safe Plan/Act workflow", - "version": "2.7.19", + "version": "2.7.20", "publisher": "mitii", "icon": "media/mitii-short-logo.png", "license": "AGPL-3.0-or-later", @@ -621,9 +621,13 @@ "release:check-assets": "node scripts/check-release-assets.mjs", "release:prepare": "npm run compile:cli && node dist/cli.js prepare-release", "benchmark": "node benchmark/run-benchmark.mjs", + "benchmark:smoke": "node benchmark/run-benchmark.mjs --tier smoke", + "benchmark:all": "node benchmark/run-benchmark.mjs --tier all --runtime real", + "benchmark:integration": "node benchmark/run-benchmark.mjs --tier integration --runtime real", "compile:extension": "esbuild src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --packages=external --platform=node --format=cjs --sourcemap", "compile:webview": "vite build", - "compile:cli": "esbuild src/node/cli.ts --bundle --outfile=dist/cli.js --platform=node --format=cjs --packages=external", + "compile:skills": "node scripts/copy-bundled-skills.mjs", + "compile:cli": "npm run compile:skills && esbuild src/node/cli.ts --bundle --outfile=dist/cli.js --platform=node --format=cjs --packages=external --alias:vscode=./src/node/vscode-shim.ts", "watch": "concurrently \"npm run watch:extension\" \"npm run watch:webview\"", "watch:extension": "esbuild src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --packages=external --platform=node --format=cjs --sourcemap --watch", "watch:webview": "vite build --watch", diff --git a/scripts/copy-bundled-skills.mjs b/scripts/copy-bundled-skills.mjs new file mode 100644 index 00000000..e55fcc72 --- /dev/null +++ b/scripts/copy-bundled-skills.mjs @@ -0,0 +1,16 @@ +import { cpSync, existsSync, mkdirSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const source = join(root, 'src/core/skills/bundled'); +const dest = join(root, 'dist/core/skills/bundled'); + +if (!existsSync(source)) { + console.error('Missing bundled skills source:', source); + process.exit(1); +} + +mkdirSync(dirname(dest), { recursive: true }); +cpSync(source, dest, { recursive: true, force: true }); +console.log('Copied bundled skills to', dest); diff --git a/scripts/sync-bundled-skills.sh b/scripts/sync-bundled-skills.sh index 62d03406..94d5fd45 100755 --- a/scripts/sync-bundled-skills.sh +++ b/scripts/sync-bundled-skills.sh @@ -1,9 +1,9 @@ #!/usr/bin/env bash -# Maintainer utility: refresh bundled-skills/ from a local checkout (no runtime git pull in the extension). +# Maintainer utility: refresh src/core/skills/bundled/ from a local checkout (no runtime git pull in the extension). set -euo pipefail ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" -DEST_DIR="${MITII_BUNDLED_SKILLS_DIR:-$ROOT_DIR/bundled-skills}" +DEST_DIR="${MITII_BUNDLED_SKILLS_DIR:-$ROOT_DIR/src/core/skills/bundled}" SOURCE_DIR="${AGENT_SKILLS_SOURCE_DIR:-}" usage() { @@ -14,8 +14,8 @@ Usage: AGENT_SKILLS_SOURCE_DIR=/path/to/agent-skills/skills bash scripts/sync-bundled-skills.sh bash scripts/sync-bundled-skills.sh /path/to/agent-skills/skills -Copies the seven Tier-1 SKILL.md folders from addyosmani/agent-skills into bundled-skills/. -Mitii-owned skills (e.g. audit-cleanup) live in bundled-skills/ and are not overwritten. +Copies the seven Tier-1 SKILL.md folders from addyosmani/agent-skills into src/core/skills/bundled/. +Mitii-owned skills (e.g. audit-cleanup) live in src/core/skills/bundled/ and are not overwritten. Does not run at extension runtime — commit the result and ship it in the VSIX. EOF } @@ -58,4 +58,4 @@ for skill in "${SKILLS[@]}"; do echo "Synced $skill" done -echo "Done. bundled-skills now contains $(find "$DEST_DIR" -name SKILL.md | wc -l | tr -d ' ') skill(s)." +echo "Done. src/core/skills/bundled now contains $(find "$DEST_DIR" -name SKILL.md | wc -l | tr -d ' ') skill(s)." diff --git a/src/core/app/ThunderController.ts b/src/core/app/ThunderController.ts index 5f504ec8..28066392 100644 --- a/src/core/app/ThunderController.ts +++ b/src/core/app/ThunderController.ts @@ -67,6 +67,10 @@ import { isLanceDbAvailable, isMinilmAvailable } from '../indexing/vectorAvailab import type { EmbeddingProvider } from '../indexing/EmbeddingProvider'; import { McpManager } from '../mcp/McpManager'; import { ProjectRulesContextSource, ProjectRulesService } from '../rules/ProjectRulesService'; +import { + ProviderProfilesService, + providerSecretRef, +} from '../providers/ProviderProfilesService'; import { SkillCatalogContextSource, SkillCatalogService } from '../skills/SkillCatalogService'; import { InlineDiffManager } from '../../vscode/inlineDiffManager'; import { testProviderConnection } from '../llm/testConnection'; @@ -148,6 +152,7 @@ export class ThunderController { private embeddingProvider: EmbeddingProvider | undefined; private mcpManager = new McpManager(); private projectRulesService: ProjectRulesService | undefined; + private providerProfilesService: ProviderProfilesService | undefined; private skillCatalogService: SkillCatalogService | undefined; private inlineDiffManager: InlineDiffManager | undefined; private researchAgentProvider: LlmProvider | undefined; @@ -184,6 +189,7 @@ export class ThunderController { breakdown: [] as import('../../vscode/webview/messages').TokenUsageBreakdownItem[], }; private uiUpdate: UiUpdateCallback | undefined; + private preservedUiGetter: (() => Partial) | undefined; private autoFixCallback: ((message: string) => Promise) | undefined; private autoFixDepth = 0; private disposed = false; @@ -197,6 +203,8 @@ export class ThunderController { private pendingIndexStatus: IndexingStatus | undefined; private tokenUsageNotifyTimer: ReturnType | undefined; private pendingTokenUsage: TokenUsageView | undefined; + private settingsSaving = false; + private testingConnection = false; constructor(private readonly context: vscode.ExtensionContext) { this.configService = new ConfigService(context); @@ -224,6 +232,22 @@ export class ThunderController { this.uiUpdate = cb; } + setPreservedUiGetter(getter: () => Partial): void { + this.preservedUiGetter = getter; + } + + private getPreservedUiBase(): Partial { + const preserved = this.preservedUiGetter?.() ?? {}; + return { + tab: preserved.tab, + mode: preserved.mode, + messages: preserved.messages, + currentSessionId: preserved.currentSessionId, + chatHistory: preserved.chatHistory, + loading: preserved.loading, + }; + } + setAutoFixCallback(cb: (message: string) => Promise): void { this.autoFixCallback = cb; } @@ -412,6 +436,7 @@ export class ThunderController { this.diagnosticsService.setWorkspaceRoot(workspace); scaffoldMitiiWorkspace(workspace, { extensionRoot: this.context.extensionPath }); this.projectRulesService = new ProjectRulesService(workspace); + this.providerProfilesService = new ProviderProfilesService(workspace); this.skillCatalogService = new SkillCatalogService(workspace); this.skillCatalogService.refresh(); const retriever = new HybridRetriever( @@ -484,6 +509,7 @@ export class ThunderController { this.diagnosticsService.setWorkspaceRoot(workspace); this.projectRulesService = new ProjectRulesService(workspace); + this.providerProfilesService = new ProviderProfilesService(workspace); this.skillCatalogService = new SkillCatalogService(workspace); this.skillCatalogService.refresh(); this.memoryService = new MemoryService(db, workspace, { @@ -932,7 +958,7 @@ export class ThunderController { ...this.tokenUsage, contextWindow: config.provider.contextWindow, }, - mode: this.session?.mode ?? 'plan', + mode: base.mode ?? this.session?.mode ?? 'plan', indexing: this.indexingStatus, approvals, plan: this.currentPlan, @@ -1023,6 +1049,8 @@ export class ThunderController { checkpointStrategy: config.agent.checkpointStrategy, showReasoning: config.ui.showReasoning, reasoningPreviewMaxChars: config.ui.reasoningPreviewMaxChars, + providerProfiles: this.providerProfilesService?.list() ?? [], + activeProviderProfileId: this.providerProfilesService?.getActiveId() ?? null, }, contextToggles: this.contextToggles, mcpToggles: this.mcpToggles, @@ -1035,6 +1063,8 @@ export class ThunderController { indexDbPath, workspaceNotice: this.workspaceNotice, workspaceTrusted: this.isWorkspaceTrusted(), + settingsSaving: this.settingsSaving, + testingConnection: this.testingConnection, }; } @@ -1695,6 +1725,7 @@ export class ThunderController { this.scanner = undefined; this.indexQueue = undefined; this.projectRulesService = undefined; + this.providerProfilesService = undefined; this.indexingStatus = { indexed: 0, queued: 0, running: false, failed: 0, total: 0, activeWorkers: 0, processed: 0, runTotal: 0 }; await this.mcpManager.closeAll(); this.toolRuntime.unregisterByPrefix('mcp__'); @@ -1713,7 +1744,7 @@ export class ThunderController { } } - this.notifyUi(await this.buildUiState()); + this.notifyUi(await this.buildUiState(this.getPreservedUiBase())); log.info('Workspace reloaded', { workspace }); if (workspace && options.autoIndex !== false) { void this.maybeAutoIndex(); @@ -2173,6 +2204,9 @@ export class ThunderController { } async testProviderConnection(settings?: ProviderSettingsPayload): Promise { + this.testingConnection = true; + this.notifyUi({ testingConnection: true }); + try { const config = this.configService.getConfig(); const apiKey = await this.configService.getApiKey(); const providerType = settings?.providerType ?? config.provider.type; @@ -2210,6 +2244,7 @@ export class ThunderController { connectionOk: false, connectionStatus: validation.errors.join(' '), }, + testingConnection: false, }); return; } @@ -2227,6 +2262,7 @@ export class ThunderController { connectionOk: true, connectionStatus: 'Echo mode — no LLM needed. Responses are mirrored for UI testing.', }, + testingConnection: false, }); return; } @@ -2252,11 +2288,16 @@ export class ThunderController { connectionOk: result.ok, connectionStatus: result.message, }, + testingConnection: false, }); if (!result.ok) { void vscode.window.showErrorMessage(`${AGENT_NAME}: ${result.message}`); } + } finally { + this.testingConnection = false; + this.notifyUi({ testingConnection: false }); + } } async saveApiKey(key: string): Promise { @@ -2332,6 +2373,9 @@ export class ThunderController { } async saveAllSettings(settings: ThunderSettingsPayload): Promise { + this.settingsSaving = true; + this.notifyUi({ settingsSaving: true }); + try { const beforeConfig = this.configService.getConfig(); const normalized = normalizeThunderSettings(settings, beforeConfig.provider.contextWindow, this.mcpToggles); @@ -2389,6 +2433,88 @@ export class ThunderController { }); void vscode.window.showInformationMessage(brandMessage('Settings saved.')); } + } finally { + this.settingsSaving = false; + this.notifyUi({ + ...(await this.buildUiState(this.getPreservedUiBase())), + settingsSaving: false, + }); + } + } + + async saveProviderProfile(options: { + id?: string; + name?: string; + settings: ProviderSettingsPayload; + apiKey?: string; + }): Promise { + const workspace = this.resolveWorkspacePath(); + if (!workspace) { + throw new Error('Open a workspace to save provider profiles under .mitii/providers.'); + } + const validation = validateProviderSettings(options.settings); + if (!validation.ok) { + throw new Error(validation.errors.join(' ')); + } + + if (!this.providerProfilesService) { + this.providerProfilesService = new ProviderProfilesService(workspace); + } + + const profile = this.providerProfilesService.upsert(options.settings, { + id: options.id, + name: options.name, + apiKey: options.apiKey, + }); + + if (options.apiKey?.trim()) { + await this.configService.setApiKey(options.apiKey.trim(), providerSecretRef(profile.id)); + } + + await this.applyProviderProfile(profile.id); + } + + async selectProviderProfile(id: string): Promise { + await this.applyProviderProfile(id); + } + + async deleteProviderProfile(id: string): Promise { + const workspace = this.resolveWorkspacePath(); + if (!workspace || !this.providerProfilesService) return; + this.providerProfilesService.delete(id); + await this.configService.deleteApiKey(providerSecretRef(id)); + this.notifyUi({ settings: (await this.buildUiState(this.getPreservedUiBase())).settings }); + } + + private async applyProviderProfile(id: string): Promise { + const workspace = this.resolveWorkspacePath(); + if (!workspace) return; + + if (!this.providerProfilesService) { + this.providerProfilesService = new ProviderProfilesService(workspace); + } + + const profile = this.providerProfilesService.setActive(id); + if (!profile) return; + + await this.configService.updateProviderSettings({ + providerType: profile.providerType, + baseUrl: profile.baseUrl, + model: profile.model, + apiVersion: profile.apiVersion, + region: profile.region, + contextWindow: profile.contextWindow, + }); + + const config = this.configService.getConfig(); + const apiKey = profile.hasApiKey + ? await this.configService.getApiKey(providerSecretRef(profile.id)) + : await this.configService.getApiKey(); + await this.providerRegistry.resolveFromConfig(config.provider, apiKey); + await this.refreshResearchAgentProvider(); + this.chatOrchestrator?.configure({ researchAgentProvider: this.researchAgentProvider }); + this.debouncedRebuildRetriever?.(); + this.notifyUi({ settings: (await this.buildUiState(this.getPreservedUiBase())).settings }); } private async reloadMcpServers(): Promise { @@ -2404,6 +2530,7 @@ export class ThunderController { filesystem: builtin.filesystem, memory: builtin.memory, sequentialThinking: builtin.sequentialThinking, + puppeteer: builtin.puppeteer ?? false, }; } diff --git a/src/core/config/schema.ts b/src/core/config/schema.ts index 21c8d5c9..194d35ac 100644 --- a/src/core/config/schema.ts +++ b/src/core/config/schema.ts @@ -140,6 +140,7 @@ export const BuiltinMcpTogglesSchema = z.object({ filesystem: z.boolean().default(true), memory: z.boolean().default(true), sequentialThinking: z.boolean().default(true), + puppeteer: z.boolean().default(false), }); export const McpConfigSchema = z.object({ diff --git a/src/core/config/ui/payloads.ts b/src/core/config/ui/payloads.ts index e1b16982..b524f2a8 100644 --- a/src/core/config/ui/payloads.ts +++ b/src/core/config/ui/payloads.ts @@ -54,6 +54,7 @@ export interface McpToggles { filesystem: boolean; memory: boolean; sequentialThinking: boolean; + puppeteer: boolean; } export interface McpCustomServerView { diff --git a/src/core/headless/HeadlessAgentHost.ts b/src/core/headless/HeadlessAgentHost.ts new file mode 100644 index 00000000..7c83845b --- /dev/null +++ b/src/core/headless/HeadlessAgentHost.ts @@ -0,0 +1,549 @@ +import { join } from 'path'; +import { ThunderSession, type ThunderMode } from '../session/ThunderSession'; +import { IndexService } from '../indexing/IndexService'; +import { IgnoreService } from '../indexing/IgnoreService'; +import { WorkspaceScanner } from '../indexing/WorkspaceScanner'; +import { IndexQueue } from '../indexing/IndexQueue'; +import { FtsIndex } from '../indexing/FtsIndex'; +import { HybridRetriever } from '../context/HybridRetriever'; +import { createContextReranker } from '../context/ContextReranker'; +import { ContextBudgeter } from '../context/ContextBudgeter'; +import { CurrentEditorContextSource, OpenFilesContextSource } from '../context/sources/editorSources'; +import { FtsContextSource, RepoMapContextSource, MemoryContextSource, WorkspaceOverviewContextSource } from '../context/sources/indexSources'; +import { IndexedFileSearchContextSource } from '../context/sources/indexedFileSource'; +import { MentionedFileContextSource } from '../context/sources/mentionedFileSource'; +import { GitService } from '../context/GitService'; +import { GitDiffContextSource } from '../context/DiagnosticsService'; +import { RepoMapService } from '../context/RepoMapService'; +import { setVerifyCommandPatterns } from '../plans/PlanActEngine'; +import { ChatOrchestrator } from '../orchestration/ChatOrchestrator'; +import { ToolRuntime } from '../tools/ToolRuntime'; +import { + createReadFileTool, createReadFilesTool, createListFilesTool, createSearchTool, + createSearchBatchTool, createSearchScriptCatalogTool, createSpawnResearchAgentTool, + createExecuteWorkspaceScriptTool, createUseSkillTool, + createRepoMapTool, createRetrieveContextTool, createGitDiffTool, + createDiagnosticsTool, createWriteFileTool, createApplyPatchTool, createRunCommandTool, + createMemorySearchTool, createMemoryWriteTool, createSaveTaskStateTool, + createFetchWebTool, createAskQuestionTool, createProjectCatalogTool, createAnalyzeChangeImpactTool, + setSubagentTracker, +} from '../tools/builtinTools'; +import { ProjectCatalogContextSource, discoverProjectCatalog, saveProjectCatalog } from '../modes/ask'; +import { createMarkStepCompleteTool, createProposePlanMutationTool } from '../tools/planTools'; +import type { AssistantStreamChunk, LlmProvider } from '../llm/types'; +import { createProvider } from '../llm/createProvider'; +import { scaffoldMitiiWorkspace } from '../mcp/scaffoldMitiiWorkspace'; +import { AgentTaskState } from '../runtime/AgentTaskState'; +import { + resolveProjectVerifyCommands, + formatVerifyPlanForAgent, +} from '../runtime/verifyCommandDiscovery'; +import { ToolPolicyEngine } from '../safety/ToolPolicyEngine'; +import { resolveEffectiveSafety } from '../safety/autonomyPresets'; +import { ApprovalQueue } from '../safety/ApprovalQueue'; +import { ToolExecutor } from '../safety/ToolExecutor'; +import { MemoryService } from '../memory/MemoryService'; +import { SessionService } from '../session/SessionService'; +import { PlanPersistence } from '../plans/PlanPersistence'; +import { PlanFileStore } from '../plans/PlanFileStore'; +import { MemoryExtractor } from '../runtime/MemoryExtractor'; +import { SubagentTracker } from '../runtime/SubagentTracker'; +import { PassiveMemoryInjector } from '../memory/PassiveMemoryInjector'; +import { MemoryHookService } from '../memory/MemoryHookService'; +import { PostEditValidator } from '../apply/PostEditValidator'; +import { McpManager } from '../mcp/McpManager'; +import { ProjectRulesContextSource, ProjectRulesService } from '../rules/ProjectRulesService'; +import { SkillCatalogContextSource, SkillCatalogService } from '../skills/SkillCatalogService'; +import { createLogger } from '../telemetry/Logger'; +import { SessionLogService } from '../telemetry/SessionLogService'; +import { MicroTaskExecutor } from '../microtasks'; +import { HeadlessAgentRunner, type HeadlessPlan } from './AgentRunner'; +import { + buildHeadlessConfig, + resolveApiKey, + resolveMitiiPackageRoot, + type HeadlessAgentOptions, +} from './HeadlessConfig'; +import { HeadlessDiagnosticsService, HeadlessDiagnosticsContextSource } from './HeadlessDiagnosticsService'; +import { headlessDiscoverFiles } from './headlessDiscoverFiles'; +import { defaultMcpToggles } from '../mcp/mcpToggles'; +import type { ThunderConfig } from '../config/schema'; +import { chunkContent } from '../llm/streamChunks'; + +const log = createLogger('HeadlessAgentHost'); + +const AUTO_GRANT_TOOLS = [ + 'write_file', 'apply_patch', 'run_command', 'ask_question', 'memory_write', +] as const; + +export interface HeadlessRunMetrics { + durationMs: number; + toolCalls: number; + errors: string[]; + sessionLogPath?: string; + auditTools: string[]; +} + +export class HeadlessAgentHost { + private readonly options: HeadlessAgentOptions; + private readonly config: ThunderConfig; + private readonly packageRoot: string; + private readonly stubRunner: HeadlessAgentRunner; + private initialized = false; + + private indexService?: IndexService; + private ignoreService = new IgnoreService(); + private scanner?: WorkspaceScanner; + private indexQueue?: IndexQueue; + private gitService?: GitService; + private skillCatalogService?: SkillCatalogService; + private memoryService?: MemoryService; + private diagnosticsService = new HeadlessDiagnosticsService(); + private postEditValidator?: PostEditValidator; + private sessionService?: SessionService; + private planPersistence?: PlanPersistence; + private approvalQueue?: ApprovalQueue; + private policyEngine?: ToolPolicyEngine; + private toolRuntime = new ToolRuntime(); + private toolExecutor?: ToolExecutor; + private chatOrchestrator?: ChatOrchestrator; + private memoryExtractor?: MemoryExtractor; + private mcpManager = new McpManager(); + private sessionLog = new SessionLogService(); + private subagentTracker = new SubagentTracker(); + private agentTaskState = new AgentTaskState(); + private session?: ThunderSession; + private provider?: LlmProvider; + + constructor(options: HeadlessAgentOptions) { + this.options = options; + this.packageRoot = options.packageRoot ?? resolveMitiiPackageRoot(join(__dirname, '..')); + this.config = buildHeadlessConfig(options); + this.stubRunner = new HeadlessAgentRunner({ + cwd: options.cwd, + providerType: options.providerType ?? this.config.provider.type, + baseUrl: options.baseUrl ?? this.config.provider.baseUrl, + model: options.model ?? this.config.provider.model, + apiKey: options.apiKey ?? resolveApiKey(options.providerType ?? this.config.provider.type), + approval: options.approval ?? 'manual', + }); + } + + get isRealRuntime(): boolean { + return this.options.runtime !== 'stub'; + } + + getSessionLog(): SessionLogService { + return this.sessionLog; + } + + getToolAudit(): ReturnType { + return this.toolRuntime.getAuditLog(); + } + + async initialize(): Promise { + if (this.initialized) return; + if (!this.isRealRuntime) { + this.initialized = true; + return; + } + + const workspace = this.options.cwd; + this.indexService = new IndexService(workspace); + await this.indexService.initialize(); + + scaffoldMitiiWorkspace(workspace, { extensionRoot: this.packageRoot, forceBundledSkills: false }); + try { + saveProjectCatalog(discoverProjectCatalog(workspace)); + } catch (error) { + log.warn('Project catalog discovery failed', { + error: error instanceof Error ? error.message : String(error), + }); + } + + const db = this.indexService.getDb(); + if (!db) throw new Error('Failed to open index database'); + + this.ignoreService.load(workspace, { + respectGitignore: this.config.indexing.respectGitignore, + respectThunderignore: this.config.indexing.respectThunderignore, + }); + + this.scanner = new WorkspaceScanner(db, workspace); + this.indexQueue = new IndexQueue(db, { + maxConcurrency: this.config.indexing.maxConcurrency, + maxFileSizeBytes: this.config.indexing.maxFileSizeBytes, + }); + + this.gitService = new GitService(workspace); + await this.gitService.initialize(); + + this.diagnosticsService.setWorkspaceRoot(workspace); + this.postEditValidator = new PostEditValidator(this.diagnosticsService as never); + + this.skillCatalogService = new SkillCatalogService(workspace); + this.skillCatalogService.refresh(); + + this.memoryService = new MemoryService(db, workspace, { + maxItems: this.config.memory.maxItems, + hybridSearchEnabled: this.config.memory.hybridSearchEnabled, + }); + + this.sessionService = new SessionService(db); + this.planPersistence = new PlanPersistence(db); + this.approvalQueue = new ApprovalQueue(db); + + const effectiveSafety = resolveEffectiveSafety(this.config.safety); + setVerifyCommandPatterns(this.config.agent.verifyCommands); + + this.policyEngine = new ToolPolicyEngine( + effectiveSafety, + (path) => this.ignoreService.isIgnored(path), + () => true + ); + + this.toolRuntime.setSessionLog(this.sessionLog); + setSubagentTracker(this.subagentTracker); + + this.toolExecutor = new ToolExecutor( + this.toolRuntime, + this.policyEngine, + this.approvalQueue, + () => this.session?.id ?? '', + () => this.session?.mode ?? 'plan', + () => this.autoResolvePendingApprovals(), + () => this.agentTaskState, + this.sessionLog, + () => this.toolExecutor?.setPlanPhaseLock('execute') + ); + + const retriever = this.buildRetriever(db, workspace); + const budgeter = new ContextBudgeter(); + this.chatOrchestrator = new ChatOrchestrator(retriever, budgeter, db); + this.configureOrchestrator(workspace); + + const repoMap = new RepoMapService(db, workspace); + const fts = new FtsIndex(db); + this.registerTools(workspace, repoMap, fts, retriever, budgeter); + + const mcpToggles = { + ...defaultMcpToggles(), + puppeteer: this.config.mcp.builtinServers.puppeteer ?? false, + }; + await this.mcpManager.reload(this.config.mcp, workspace, this.toolRuntime, mcpToggles); + + this.memoryExtractor = new MemoryExtractor( + this.memoryService, + this.config.memory.summarizeAfterTask + ); + + if (this.config.indexing.autoIndexOnOpen) { + await this.indexWorkspace(workspace); + } + + this.provider = createProvider(this.config.provider, this.options.apiKey ?? resolveApiKey(this.config.provider.type)); + this.initialized = true; + } + + async ask(prompt: string): Promise { + await this.initialize(); + if (!this.isRealRuntime) return this.stubRunner.ask(prompt); + return this.runMode('ask', prompt); + } + + async plan(prompt: string): Promise> { + await this.initialize(); + if (!this.isRealRuntime) return this.stubRunner.plan(prompt); + const content = await this.runMode('plan', prompt); + try { + return JSON.parse(content) as Record; + } catch { + return { goal: prompt, content, steps: [] }; + } + } + + async *agent(prompt: string): AsyncIterable<{ type: string; message?: string; plan?: HeadlessPlan; content?: string }> { + await this.initialize(); + if (!this.isRealRuntime) { + yield* this.stubRunner.agent(prompt); + return; + } + + yield { type: 'start', message: 'headless agent started' }; + let content = ''; + for await (const chunk of this.streamMode('agent', prompt)) { + const text = chunkContent(chunk); + if (text) { + content += text; + yield { type: 'assistant_delta', content: text }; + } + } + yield { type: 'end', message: 'headless agent completed', content }; + } + + async runWithMetrics(mode: ThunderMode, prompt: string): Promise<{ output: string; metrics: HeadlessRunMetrics }> { + const started = Date.now(); + const errors: string[] = []; + let output = ''; + + try { + if (mode === 'ask') { + output = await this.ask(prompt); + } else if (mode === 'plan') { + output = JSON.stringify(await this.plan(prompt)); + } else { + const parts: string[] = []; + for await (const event of this.agent(prompt)) { + if (event.content) parts.push(event.content); + } + output = parts.join(''); + } + } catch (error) { + errors.push(error instanceof Error ? error.message : String(error)); + } + + const audit = this.getToolAudit(); + return { + output, + metrics: { + durationMs: Date.now() - started, + toolCalls: audit.length, + errors, + sessionLogPath: this.sessionLog.getLogPath() || undefined, + auditTools: audit.map((entry) => entry.toolName), + }, + }; + } + + dispose(): void { + this.indexService?.dispose(); + this.initialized = false; + } + + private configureOrchestrator(workspace: string): void { + if (!this.chatOrchestrator || !this.toolExecutor) return; + + const passiveMemoryInjector = new PassiveMemoryInjector(this.memoryService!); + const memoryHookService = new MemoryHookService(workspace); + + this.chatOrchestrator.configure({ + toolRuntime: this.toolRuntime, + toolExecutor: this.toolExecutor, + sessionService: this.sessionService, + planPersistence: this.planPersistence, + memoryExtractor: this.memoryExtractor, + memoryConfig: this.config.memory, + agentConfig: this.config.agent, + passiveMemoryInjector, + memoryHookService, + postEditValidator: this.postEditValidator, + sessionLog: this.sessionLog, + workspace, + memoryService: this.memoryService, + taskState: this.agentTaskState, + skillCatalog: this.skillCatalogService, + allowNetwork: () => resolveEffectiveSafety(this.config.safety).allowNetwork, + runVerifyHooks: async (commands, userMessage) => this.runVerifyHooks(workspace, commands, userMessage ?? ''), + microTaskRoutingEnabled: this.config.context.microTaskRoutingEnabled, + microTaskExecutorFactory: (provider) => new MicroTaskExecutor({ + workspace, + git: this.gitService!, + provider, + sessionLog: this.sessionLog, + }), + onPostWrite: async () => undefined, + onDiffPreview: async () => undefined, + }); + this.chatOrchestrator.setToolExecutor(this.toolExecutor); + } + + private registerTools( + workspace: string, + repoMap: RepoMapService, + fts: FtsIndex, + retriever: HybridRetriever, + budgeter: ContextBudgeter + ): void { + this.toolRuntime.register(createReadFileTool(workspace, this.ignoreService)); + this.toolRuntime.register(createReadFilesTool(workspace, this.ignoreService)); + this.toolRuntime.register(createListFilesTool(workspace, this.ignoreService)); + this.toolRuntime.register(createSearchTool(fts, workspace)); + this.toolRuntime.register(createSearchBatchTool(fts, workspace)); + this.toolRuntime.register(createSearchScriptCatalogTool(workspace, this.packageRoot)); + this.toolRuntime.register(createExecuteWorkspaceScriptTool(workspace, this.packageRoot, this.ignoreService)); + this.toolRuntime.register(createUseSkillTool(this.skillCatalogService!)); + this.toolRuntime.register(createSpawnResearchAgentTool()); + this.toolRuntime.register(createRepoMapTool(repoMap)); + this.toolRuntime.register(createRetrieveContextTool(retriever, budgeter)); + this.toolRuntime.register(createGitDiffTool(this.gitService!)); + this.toolRuntime.register(createDiagnosticsTool(this.diagnosticsService as never)); + this.toolRuntime.register(createProjectCatalogTool(workspace)); + this.toolRuntime.register(createAnalyzeChangeImpactTool(workspace)); + this.toolRuntime.register(createWriteFileTool(workspace, this.ignoreService)); + this.toolRuntime.register(createApplyPatchTool(workspace, this.ignoreService)); + this.toolRuntime.register(createRunCommandTool(workspace, () => this.session?.mode ?? 'plan')); + this.toolRuntime.register(createMemorySearchTool(this.memoryService!)); + this.toolRuntime.register(createMemoryWriteTool(this.memoryService!, () => this.session?.id ?? '')); + this.toolRuntime.register(createSaveTaskStateTool(this.memoryService!, () => this.session?.id ?? '', () => this.agentTaskState)); + this.toolRuntime.register(createFetchWebTool(() => this.config.safety.allowNetwork)); + this.toolRuntime.register(createAskQuestionTool()); + + const sessionIdForPlans = () => this.session?.id ?? ''; + const planToolsCtx = { + getPlan: () => this.planPersistence?.getActive(sessionIdForPlans())?.plan ?? null, + setPlan: (plan: import('../plans/PlanActEngine').ThunderPlan) => { + const sid = sessionIdForPlans(); + if (sid) this.planPersistence?.updatePlan(sid, plan); + }, + planPersistence: this.planPersistence, + getSessionId: sessionIdForPlans, + setPlanPhaseLock: (phase: import('../plans/PlanActEngine').PlanPhase | undefined) => { + this.toolExecutor?.setPlanPhaseLock(phase); + }, + get planFileStore() { + const sid = sessionIdForPlans(); + return sid ? new PlanFileStore(workspace, sid) : undefined; + }, + }; + this.toolRuntime.register(createMarkStepCompleteTool(planToolsCtx)); + this.toolRuntime.register(createProposePlanMutationTool(planToolsCtx)); + } + + private buildRetriever(db: import('../indexing/ThunderDb').ThunderDb, workspace: string): HybridRetriever { + const sources = []; + const projectRulesService = new ProjectRulesService(workspace); + sources.push(new ProjectRulesContextSource(projectRulesService)); + if (this.skillCatalogService) { + sources.push(new SkillCatalogContextSource(this.skillCatalogService)); + } + sources.push(new ProjectCatalogContextSource(workspace)); + sources.push( + new MentionedFileContextSource(workspace), + new WorkspaceOverviewContextSource(workspace), + new CurrentEditorContextSource(workspace, db), + new OpenFilesContextSource(workspace, db), + new FtsContextSource(db), + new IndexedFileSearchContextSource(db, workspace), + new RepoMapContextSource(db, workspace) + ); + if (this.gitService) sources.push(new GitDiffContextSource(this.gitService)); + sources.push(new HeadlessDiagnosticsContextSource(this.diagnosticsService)); + if (this.memoryService) sources.push(new MemoryContextSource(this.memoryService)); + + const reranker = createContextReranker(undefined, false); + return new HybridRetriever(sources, reranker, { + enabled: this.config.context.rerankerEnabled, + candidatePool: this.config.context.rerankerCandidatePool, + topK: this.config.context.rerankerTopK, + }); + } + + private async indexWorkspace(workspace: string): Promise { + if (!this.scanner || !this.indexQueue) return; + const files = headlessDiscoverFiles(workspace, this.ignoreService, this.config.indexing); + const diff = this.scanner.computeDiff(files); + this.scanner.persistScan(diff); + const jobs = [...diff.added, ...diff.changed].map((f) => ({ + fileId: this.scanner!.getFileId(f.relPath)!, + relPath: f.relPath, + absPath: f.absPath, + language: f.language, + })).filter((j) => j.fileId !== undefined); + + if (jobs.length === 0) return; + this.indexQueue.enqueue(jobs); + + const deadline = Date.now() + 120_000; + while (this.indexQueue.getStatus().running || this.indexQueue.getStatus().queued > 0) { + if (Date.now() > deadline) break; + await sleep(250); + } + this.sessionLog.append('index_complete', 'Headless indexing finished', { + workspace, + jobCount: jobs.length, + }); + } + + private async runMode(mode: ThunderMode, prompt: string): Promise { + const parts: string[] = []; + for await (const chunk of this.streamMode(mode, prompt)) { + const text = chunkContent(chunk); + if (text) parts.push(text); + } + return parts.join(''); + } + + private async *streamMode(mode: ThunderMode, prompt: string): AsyncIterable { + if (!this.chatOrchestrator || !this.provider) { + throw new Error('Headless agent host is not initialized'); + } + + this.session = new ThunderSession(this.options.cwd, mode); + this.sessionLog.configure(this.options.cwd, this.session.id, true, this.config.telemetry.debugMetrics); + this.toolRuntime.clearAuditLog(); + this.agentTaskState.reset(); + this.agentTaskState.setLimits({ + maxSequentialThinkingCalls: this.config.agent.maxSequentialThinkingCallsPerTurn, + }); + + if (this.options.approval === 'auto') { + for (const tool of AUTO_GRANT_TOOLS) { + this.approvalQueue?.grantForTask(this.session.id, tool); + } + } + + this.sessionService?.ensureSession(this.session, prompt.slice(0, 64)); + yield* this.chatOrchestrator.send(this.session, this.provider, prompt, []); + } + + private autoResolvePendingApprovals(): void { + if (this.options.approval !== 'auto' || !this.approvalQueue || !this.session) return; + for (const request of this.approvalQueue.getPending()) { + this.approvalQueue.grantForTask(this.session.id, request.toolName); + this.approvalQueue.resolve(request.id, 'approved'); + this.sessionLog.append('approval_decision', `auto-approved: ${request.toolName}`, { + id: request.id, + toolName: request.toolName, + scope: 'task', + }); + } + } + + private async runVerifyHooks(workspace: string, commands: string[], userMessage: string): Promise { + const lines: string[] = []; + const touchedFiles = this.getTouchedFilesFromAudit(); + const plan = resolveProjectVerifyCommands(workspace, commands, { touchedFiles, userMessage }); + lines.push(formatVerifyPlanForAgent(plan)); + + for (const command of plan.commands) { + const trimmed = command.trim(); + if (!trimmed) continue; + try { + const result = await this.toolRuntime.execute('run_command', { command: trimmed }); + const body = result.success + ? (result.output || '(no output)') + : (result.error ?? result.output ?? 'command failed'); + lines.push(`$ ${trimmed}\n${body.slice(0, 4000)}`); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + lines.push(`$ ${trimmed}\n${msg}`); + } + } + + return lines.join('\n\n'); + } + + private getTouchedFilesFromAudit(): string[] { + const files = new Set(); + for (const { toolName, input, result } of this.toolRuntime.getAuditLog()) { + if (!result.success || !['write_file', 'apply_patch'].includes(toolName)) continue; + const path = (input as Record).path; + if (typeof path === 'string') files.add(path); + } + return [...files]; + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/core/headless/HeadlessConfig.ts b/src/core/headless/HeadlessConfig.ts new file mode 100644 index 00000000..8e96e2a2 --- /dev/null +++ b/src/core/headless/HeadlessConfig.ts @@ -0,0 +1,94 @@ +import { existsSync } from 'fs'; +import { join } from 'path'; +import type { ProviderType, ThunderConfig } from '../config/schema'; +import { defaultThunderConfig } from '../config/defaults'; +import { resolveEffectiveSafety } from '../safety/autonomyPresets'; + +export type HeadlessRuntime = 'real' | 'stub'; + +export interface HeadlessAgentOptions { + cwd: string; + packageRoot?: string; + runtime?: HeadlessRuntime; + providerType?: ProviderType; + baseUrl?: string; + model?: string; + apiKey?: string; + approval?: 'auto' | 'manual'; + allowNetwork?: boolean; + enablePuppeteer?: boolean; + indexWorkspace?: boolean; + configOverrides?: Partial; +} + +export function resolveMitiiPackageRoot(fromDir: string): string { + let current = fromDir; + for (let depth = 0; depth < 4; depth += 1) { + if (existsSync(join(current, 'package.json'))) return current; + const parent = join(current, '..'); + if (parent === current) break; + current = parent; + } + return fromDir; +} + +export function buildHeadlessConfig(options: HeadlessAgentOptions): ThunderConfig { + const base = defaultThunderConfig(); + const approvalMode = options.approval === 'auto' ? 'auto' as const : 'review_all' as const; + const safety = resolveEffectiveSafety({ + ...base.safety, + approvalMode, + allowUntrustedWorkspace: true, + allowNetwork: options.allowNetwork ?? false, + }); + + const mcp = { + ...base.mcp, + enabled: true, + preloadBuiltin: true, + builtinServers: { + ...base.mcp.builtinServers, + puppeteer: options.enablePuppeteer ?? false, + }, + }; + + const config: ThunderConfig = { + ...base, + ...options.configOverrides, + provider: { + ...base.provider, + type: options.providerType ?? base.provider.type, + baseUrl: options.baseUrl ?? base.provider.baseUrl, + model: options.model ?? base.provider.model, + supportsTools: options.runtime === 'stub' ? false : true, + }, + safety, + mcp, + indexing: { + ...base.indexing, + vectorsEnabled: false, + autoIndexOnOpen: options.indexWorkspace !== false, + ...(options.configOverrides?.indexing ?? {}), + }, + agent: { + ...base.agent, + verifyOnActComplete: true, + ...(options.configOverrides?.agent ?? {}), + }, + telemetry: { + ...base.telemetry, + sessionLogging: true, + debugMetrics: true, + ...(options.configOverrides?.telemetry ?? {}), + }, + }; + + return config; +} + +export function resolveApiKey(providerType: ProviderType): string | undefined { + if (providerType === 'anthropic') return process.env.ANTHROPIC_API_KEY ?? process.env.MITII_API_KEY; + if (providerType === 'gemini') return process.env.GEMINI_API_KEY ?? process.env.MITII_API_KEY; + if (providerType === 'openrouter') return process.env.OPENROUTER_API_KEY ?? process.env.MITII_API_KEY; + return process.env.MITII_API_KEY ?? process.env.OPENAI_API_KEY; +} diff --git a/src/core/headless/HeadlessDiagnosticsService.ts b/src/core/headless/HeadlessDiagnosticsService.ts new file mode 100644 index 00000000..0e715bb4 --- /dev/null +++ b/src/core/headless/HeadlessDiagnosticsService.ts @@ -0,0 +1,47 @@ +import type { ContextItem, ContextQuery, ContextSource } from '../context/types'; + +/** Headless diagnostics stub — no VS Code language service in CLI/benchmark runs. */ +export class HeadlessDiagnosticsService { + setWorkspaceRoot(_root: string): void { + // Headless runtime has no editor diagnostics integration. + } + + getDiagnostics(): Array<{ file: string; severity: string; message: string; line: number }> { + return []; + } + + formatCompact(_maxItems = 20): string { + return ''; + } + + getHeavyFiles(): string[] { + return []; + } + + getFileErrors(_relPath: string): Array<{ line: number; message: string }> { + return []; + } + + async waitForFileErrors(_relPath: string, _maxWaitMs = 2500): Promise> { + return []; + } +} + +export class HeadlessDiagnosticsContextSource implements ContextSource { + readonly id = 'diagnostics'; + + constructor(private readonly diagnosticsService: HeadlessDiagnosticsService) {} + + async retrieve(_query: ContextQuery): Promise { + const formatted = this.diagnosticsService.formatCompact(); + if (!formatted) return []; + return [{ + id: 'diagnostics', + source: this.id, + content: formatted, + score: 5, + reason: 'Headless diagnostics (empty in CLI runtime)', + tokenEstimate: Math.ceil(formatted.length / 4), + }]; + } +} diff --git a/src/core/headless/headlessDiscoverFiles.ts b/src/core/headless/headlessDiscoverFiles.ts new file mode 100644 index 00000000..d53a056a --- /dev/null +++ b/src/core/headless/headlessDiscoverFiles.ts @@ -0,0 +1,58 @@ +import { readdirSync, statSync } from 'fs'; +import { join, relative } from 'path'; +import type { IgnoreService } from '../indexing/IgnoreService'; +import type { DiscoveredFile } from '../indexing/FileDiscoveryService'; +import { isBinaryByExtension, detectLanguage } from '../indexing/fileUtils'; +import type { IndexingConfig } from '../config/schema'; + +/** File discovery without VS Code configuration APIs — used by headless host and benchmarks. */ +export function headlessDiscoverFiles( + workspacePath: string, + ignoreService: IgnoreService, + config: Pick +): DiscoveredFile[] { + const results: DiscoveredFile[] = []; + + const walk = (dir: string): void => { + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return; + } + + for (const entry of entries) { + const absPath = join(dir, entry); + const relPath = relative(workspacePath, absPath).replace(/\\/g, '/'); + + if (ignoreService.isIgnored(relPath)) continue; + + let stat; + try { + stat = statSync(absPath); + } catch { + continue; + } + + if (stat.isDirectory()) { + walk(absPath); + continue; + } + + if (!stat.isFile()) continue; + if (stat.size > config.hardSkipSizeBytes) continue; + if (isBinaryByExtension(relPath)) continue; + + results.push({ + absPath, + relPath, + size: stat.size, + mtime: stat.mtimeMs, + language: detectLanguage(relPath), + }); + } + }; + + walk(workspacePath); + return results; +} diff --git a/src/core/headless/index.ts b/src/core/headless/index.ts index 2f5f2df6..d28dc0ef 100644 --- a/src/core/headless/index.ts +++ b/src/core/headless/index.ts @@ -1,2 +1,4 @@ export * from './release'; export * from './AgentRunner'; +export * from './HeadlessAgentHost'; +export * from './HeadlessConfig'; diff --git a/src/core/mcp/builtinServers.ts b/src/core/mcp/builtinServers.ts index 37542cff..b5e1b67b 100644 --- a/src/core/mcp/builtinServers.ts +++ b/src/core/mcp/builtinServers.ts @@ -7,6 +7,7 @@ export const BUILTIN_MCP_SERVER_NAMES = [ 'filesystem', 'memory', 'sequential-thinking', + 'puppeteer', ] as const; export type BuiltinMcpServerName = (typeof BUILTIN_MCP_SERVER_NAMES)[number]; @@ -39,6 +40,15 @@ export function buildBuiltinMcpServers(workspace: string): Record ({ filesystem: true, memory: true, sequentialThinking: true, + puppeteer: false, }); export function mcpToggleKeyToServerName(key: keyof McpToggles): string { - return key === 'sequentialThinking' ? 'sequential-thinking' : key; + if (key === 'sequentialThinking') return 'sequential-thinking'; + return key; } export function mcpServerNameToToggleKey(name: string): keyof McpToggles | undefined { if (name === 'filesystem') return 'filesystem'; if (name === 'memory') return 'memory'; if (name === 'sequential-thinking') return 'sequentialThinking'; + if (name === 'puppeteer') return 'puppeteer'; return undefined; } diff --git a/src/core/plans/promptBuilder.ts b/src/core/plans/promptBuilder.ts index feef8fcd..31ba2be1 100644 --- a/src/core/plans/promptBuilder.ts +++ b/src/core/plans/promptBuilder.ts @@ -187,7 +187,7 @@ RULES: - The user's message may include a block with files/folders they pinned. Treat that as highest priority — focus there first before wider codebase context. - The user's message includes a ## Codebase Context section with real project files. READ IT and answer from it. - If ## Codebase Context includes a repo_map/workspace overview, use that provided map first. Do NOT repeatedly call list_files for the same structure unless the map is absent or demonstrably stale. -- Project rule files in context (AGENTS.md, CLAUDE.md, .mitii/rules, .clinerules, .continue/rules, etc.) are operating instructions for this workspace. Follow them unless they conflict with explicit user instructions or safety policy. +- \`MITII.md\` in context is the operating instructions file for this workspace. Follow it unless it conflicts with explicit user instructions or safety policy. - Focus on files and topics the user asked about. Do NOT pivot to unrelated open tabs or linter diagnostics unless the user asked to fix errors. - NEVER ask the user to paste README, package.json, or source files — they are already in context. - NEVER say context is "truncated" or "not fully visible" if file content appears in context — use what is provided. diff --git a/src/core/providers/ProviderProfilesService.ts b/src/core/providers/ProviderProfilesService.ts new file mode 100644 index 00000000..701939f6 --- /dev/null +++ b/src/core/providers/ProviderProfilesService.ts @@ -0,0 +1,182 @@ +import { createHash, randomUUID } from 'crypto'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import type { ProviderSettingsPayload } from '../config/ui/payloads'; +import { ensureThunderDir } from '../indexing/paths'; +import { createLogger } from '../telemetry/Logger'; + +const log = createLogger('ProviderProfilesService'); + +export interface ProviderProfileView { + id: string; + name: string; + providerType: ProviderSettingsPayload['providerType']; + baseUrl: string; + model: string; + apiVersion: string; + region: string; + contextWindow: number; + hasApiKey: boolean; +} + +interface ProviderProfileRecord { + id: string; + name: string; + providerType: ProviderSettingsPayload['providerType']; + baseUrl: string; + model: string; + apiVersion: string; + region: string; + contextWindow: number; + apiKeyHash?: string; +} + +interface ProviderProfilesFile { + activeId: string | null; + profiles: ProviderProfileRecord[]; +} + +export function hashProviderApiKey(key: string): string { + return createHash('sha256').update(key.trim()).digest('hex'); +} + +export function providerSecretRef(profileId: string): string { + return `mitii.provider.${profileId}`; +} + +function providersDir(workspace: string): string { + const dir = join(ensureThunderDir(workspace), 'providers'); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + return dir; +} + +function indexPath(workspace: string): string { + return join(providersDir(workspace), 'index.json'); +} + +function readProfilesFile(workspace: string): ProviderProfilesFile { + const file = indexPath(workspace); + if (!existsSync(file)) { + return { activeId: null, profiles: [] }; + } + try { + const parsed = JSON.parse(readFileSync(file, 'utf-8')) as ProviderProfilesFile; + return { + activeId: parsed.activeId ?? null, + profiles: Array.isArray(parsed.profiles) ? parsed.profiles : [], + }; + } catch (error) { + log.warn('Could not read provider profiles', { + error: error instanceof Error ? error.message : String(error), + }); + return { activeId: null, profiles: [] }; + } +} + +function writeProfilesFile(workspace: string, data: ProviderProfilesFile): void { + const file = indexPath(workspace); + writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, 'utf-8'); +} + +function toView(profile: ProviderProfileRecord): ProviderProfileView { + return { + id: profile.id, + name: profile.name, + providerType: profile.providerType, + baseUrl: profile.baseUrl, + model: profile.model, + apiVersion: profile.apiVersion, + region: profile.region, + contextWindow: profile.contextWindow, + hasApiKey: Boolean(profile.apiKeyHash), + }; +} + +export class ProviderProfilesService { + constructor(private readonly workspace: string) {} + + list(): ProviderProfileView[] { + if (!this.workspace) return []; + return readProfilesFile(this.workspace).profiles.map(toView); + } + + getActiveId(): string | null { + if (!this.workspace) return null; + return readProfilesFile(this.workspace).activeId; + } + + getActive(): ProviderProfileView | null { + if (!this.workspace) return null; + const file = readProfilesFile(this.workspace); + const active = file.profiles.find((profile) => profile.id === file.activeId); + return active ? toView(active) : null; + } + + getById(id: string): ProviderProfileView | null { + if (!this.workspace) return null; + const profile = readProfilesFile(this.workspace).profiles.find((item) => item.id === id); + return profile ? toView(profile) : null; + } + + upsert( + settings: ProviderSettingsPayload, + options: { id?: string; name?: string; apiKey?: string } + ): ProviderProfileView { + if (!this.workspace.trim()) { + throw new Error('Open a workspace to save provider profiles under .mitii/providers.'); + } + + const file = readProfilesFile(this.workspace); + const id = options.id ?? randomUUID(); + const existing = file.profiles.find((profile) => profile.id === id); + const name = + options.name?.trim() || + existing?.name || + `${settings.providerType} / ${settings.model}`.slice(0, 64); + + const next: ProviderProfileRecord = { + id, + name, + providerType: settings.providerType, + baseUrl: settings.baseUrl.trim(), + model: settings.model.trim(), + apiVersion: settings.apiVersion?.trim() ?? '', + region: settings.region?.trim() ?? '', + contextWindow: settings.contextWindow, + apiKeyHash: + options.apiKey?.trim() + ? hashProviderApiKey(options.apiKey) + : existing?.apiKeyHash, + }; + + const profiles = existing + ? file.profiles.map((profile) => (profile.id === id ? next : profile)) + : [...file.profiles, next]; + + writeProfilesFile(this.workspace, { + activeId: file.activeId ?? id, + profiles, + }); + + return toView(next); + } + + setActive(id: string): ProviderProfileView | null { + if (!this.workspace.trim()) return null; + const file = readProfilesFile(this.workspace); + const profile = file.profiles.find((item) => item.id === id); + if (!profile) return null; + writeProfilesFile(this.workspace, { ...file, activeId: id }); + return toView(profile); + } + + delete(id: string): void { + if (!this.workspace.trim()) return; + const file = readProfilesFile(this.workspace); + const profiles = file.profiles.filter((profile) => profile.id !== id); + const activeId = file.activeId === id ? profiles[0]?.id ?? null : file.activeId; + writeProfilesFile(this.workspace, { activeId, profiles }); + } +} diff --git a/src/core/rules/ProjectRulesService.ts b/src/core/rules/ProjectRulesService.ts index f4374342..74648d03 100644 --- a/src/core/rules/ProjectRulesService.ts +++ b/src/core/rules/ProjectRulesService.ts @@ -1,30 +1,11 @@ -import { existsSync, readdirSync, readFileSync, statSync } from 'fs'; +import { existsSync, readFileSync, statSync } from 'fs'; import { join } from 'path'; import type { ContextItem, ContextQuery, ContextSource } from '../context/types'; import { createLogger } from '../telemetry/Logger'; const log = createLogger('ProjectRulesService'); -const RULE_FILES = [ - 'AGENTS.md', - 'CLAUDE.md', - 'WARP.md', - '.cursorrules', - '.clinerules', -]; - -const RULE_DIRS = [ - '.mitii/rules', - '.mitii/agents', - '.mitii/checks', - '.mitii/prompts', - '.clinerules', - '.continue/rules', - '.continue/agents', - '.continue/checks', - '.continue/prompts', - '.cursor/rules', -]; +const RULE_FILE = 'MITII.md'; export interface ProjectRuleFile { relPath: string; @@ -34,36 +15,15 @@ export interface ProjectRuleFile { export class ProjectRulesService { constructor(private readonly workspace: string) {} - load(maxFiles = 24, maxCharsPerFile = 5000): ProjectRuleFile[] { + load(maxCharsPerFile = 5000): ProjectRuleFile[] { if (!this.workspace) return []; const files: ProjectRuleFile[] = []; - - for (const relPath of RULE_FILES) { - this.tryAddFile(files, relPath, maxCharsPerFile); - } - - for (const relDir of RULE_DIRS) { - const absDir = join(this.workspace, relDir); - if (!existsSync(absDir)) continue; - if (!statSync(absDir).isDirectory()) continue; - try { - for (const entry of walkRuleDir(this.workspace, relDir, 2)) { - this.tryAddFile(files, entry, maxCharsPerFile); - if (files.length >= maxFiles) return files; - } - } catch (error) { - log.warn('Could not read rules directory', { - relDir, - error: error instanceof Error ? error.message : String(error), - }); - } - } - - return files.slice(0, maxFiles); + this.tryAddFile(files, RULE_FILE, maxCharsPerFile); + return files; } count(): number { - return this.load(200, 1).length; + return this.load(1).length; } private tryAddFile(files: ProjectRuleFile[], relPath: string, maxChars: number): void { @@ -75,8 +35,11 @@ export class ProjectRulesService { if (!st.isFile() || st.size > 256_000) return; const content = readFileSync(abs, 'utf-8').slice(0, maxChars).trim(); if (content) files.push({ relPath, content }); - } catch { - // Ignore unreadable rule files. + } catch (error) { + log.warn('Could not read project rules file', { + relPath, + error: error instanceof Error ? error.message : String(error), + }); } } } @@ -98,22 +61,3 @@ export class ProjectRulesContextSource implements ContextSource { })); } } - -function walkRuleDir(workspace: string, relDir: string, maxDepth: number): string[] { - const out: string[] = []; - const walk = (currentRel: string, depth: number) => { - if (depth > maxDepth) return; - const abs = join(workspace, currentRel); - const entries = readdirSync(abs, { withFileTypes: true }); - for (const entry of entries) { - const childRel = `${currentRel}/${entry.name}`; - if (entry.isDirectory()) { - walk(childRel, depth + 1); - } else if (/\.(md|mdc)$/i.test(entry.name) || entry.name === '.cursorrules') { - out.push(childRel); - } - } - }; - walk(relDir, 0); - return out.sort(); -} diff --git a/bundled-skills/README.md b/src/core/skills/bundled/README.md similarity index 100% rename from bundled-skills/README.md rename to src/core/skills/bundled/README.md diff --git a/bundled-skills/audit-cleanup/SKILL.md b/src/core/skills/bundled/audit-cleanup/SKILL.md similarity index 100% rename from bundled-skills/audit-cleanup/SKILL.md rename to src/core/skills/bundled/audit-cleanup/SKILL.md diff --git a/src/core/skills/bundled/browser-testing-with-devtools/SKILL.md b/src/core/skills/bundled/browser-testing-with-devtools/SKILL.md new file mode 100644 index 00000000..86b40358 --- /dev/null +++ b/src/core/skills/bundled/browser-testing-with-devtools/SKILL.md @@ -0,0 +1,39 @@ +--- +name: browser-testing-with-devtools +description: Browser automation and UI verification with Puppeteer MCP for React, Next.js, and web apps. +--- + +# Browser testing with Puppeteer + +Use this skill when validating UI behavior, screenshots, or client-side flows in JavaScript web apps. + +## When to use + +- React / Next.js / Vite UI verification +- Screenshot or DOM assertions after Agent edits +- Smoke-testing pages in benchmark or CI fixtures + +## MCP setup + +Mitii preloads `@modelcontextprotocol/server-puppeteer` when `thunder.mcp.builtinServers.puppeteer` is enabled. + +Headless CLI: + +```bash +mitii agent "Open the home page and verify the title" --runtime real --enable-puppeteer --approval auto +``` + +## Tools + +- `mcp__puppeteer__puppeteer_navigate` +- `mcp__puppeteer__puppeteer_screenshot` +- `mcp__puppeteer__puppeteer_click` +- `mcp__puppeteer__puppeteer_fill` +- `mcp__puppeteer__puppeteer_evaluate` + +## Workflow + +1. Start or assume a local dev server (`npm run dev`) when testing a fixture repo. +2. Navigate to the page under test. +3. Capture screenshot or evaluate DOM selectors. +4. Report pass/fail with evidence in the session log. diff --git a/bundled-skills/code-review-and-quality/SKILL.md b/src/core/skills/bundled/code-review-and-quality/SKILL.md similarity index 100% rename from bundled-skills/code-review-and-quality/SKILL.md rename to src/core/skills/bundled/code-review-and-quality/SKILL.md diff --git a/bundled-skills/code-smells-and-tech-debt/SKILL.md b/src/core/skills/bundled/code-smells-and-tech-debt/SKILL.md similarity index 100% rename from bundled-skills/code-smells-and-tech-debt/SKILL.md rename to src/core/skills/bundled/code-smells-and-tech-debt/SKILL.md diff --git a/bundled-skills/debugging-and-error-recovery/SKILL.md b/src/core/skills/bundled/debugging-and-error-recovery/SKILL.md similarity index 100% rename from bundled-skills/debugging-and-error-recovery/SKILL.md rename to src/core/skills/bundled/debugging-and-error-recovery/SKILL.md diff --git a/bundled-skills/environment-and-secrets/SKILL.md b/src/core/skills/bundled/environment-and-secrets/SKILL.md similarity index 100% rename from bundled-skills/environment-and-secrets/SKILL.md rename to src/core/skills/bundled/environment-and-secrets/SKILL.md diff --git a/bundled-skills/git-workflow-and-versioning/SKILL.md b/src/core/skills/bundled/git-workflow-and-versioning/SKILL.md similarity index 100% rename from bundled-skills/git-workflow-and-versioning/SKILL.md rename to src/core/skills/bundled/git-workflow-and-versioning/SKILL.md diff --git a/bundled-skills/performance-optimization/SKILL.md b/src/core/skills/bundled/performance-optimization/SKILL.md similarity index 100% rename from bundled-skills/performance-optimization/SKILL.md rename to src/core/skills/bundled/performance-optimization/SKILL.md diff --git a/bundled-skills/planning-and-task-breakdown/SKILL.md b/src/core/skills/bundled/planning-and-task-breakdown/SKILL.md similarity index 100% rename from bundled-skills/planning-and-task-breakdown/SKILL.md rename to src/core/skills/bundled/planning-and-task-breakdown/SKILL.md diff --git a/bundled-skills/test-driven-development/SKILL.md b/src/core/skills/bundled/test-driven-development/SKILL.md similarity index 100% rename from bundled-skills/test-driven-development/SKILL.md rename to src/core/skills/bundled/test-driven-development/SKILL.md diff --git a/bundled-skills/using-agent-skills/SKILL.md b/src/core/skills/bundled/using-agent-skills/SKILL.md similarity index 100% rename from bundled-skills/using-agent-skills/SKILL.md rename to src/core/skills/bundled/using-agent-skills/SKILL.md diff --git a/src/core/skills/installBundledSkills.ts b/src/core/skills/installBundledSkills.ts index 27029f49..46c4b45b 100644 --- a/src/core/skills/installBundledSkills.ts +++ b/src/core/skills/installBundledSkills.ts @@ -1,6 +1,7 @@ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'fs'; import { basename, join } from 'path'; import { createLogger } from '../telemetry/Logger'; +import { resolveBundledSkillsRoot } from './resolveBundledSkillsRoot'; const log = createLogger('BundledSkills'); @@ -17,14 +18,14 @@ export function installBundledSkills( extensionRoot: string, options: { force?: boolean } = {} ): InstallBundledSkillsResult { - const bundledRoot = join(extensionRoot, 'bundled-skills'); + const bundledRoot = resolveBundledSkillsRoot(extensionRoot); const destinationRoot = join(workspace, '.mitii', 'skills'); const installed: string[] = []; const skipped: string[] = []; - if (!existsSync(bundledRoot)) { - log.warn('Bundled skills directory missing', { bundledRoot }); - return { installed, skipped, bundledRoot, destinationRoot }; + if (!bundledRoot || !existsSync(bundledRoot)) { + log.warn('Bundled skills directory missing', { extensionRoot }); + return { installed, skipped, bundledRoot: bundledRoot ?? '', destinationRoot }; } mkdirSync(destinationRoot, { recursive: true }); @@ -71,14 +72,14 @@ export function installBundledSkills( } export function listBundledSkillNames(extensionRoot: string): string[] { - const bundledRoot = join(extensionRoot, 'bundled-skills'); - if (!existsSync(bundledRoot)) return []; + const bundledRoot = resolveBundledSkillsRoot(extensionRoot); + if (!bundledRoot || !existsSync(bundledRoot)) return []; return listBundledSkillDirs(bundledRoot).map((dir) => basename(dir)).sort(); } export function readBundledSkillManifest(extensionRoot: string): Array<{ name: string; description: string }> { - const bundledRoot = join(extensionRoot, 'bundled-skills'); - if (!existsSync(bundledRoot)) return []; + const bundledRoot = resolveBundledSkillsRoot(extensionRoot); + if (!bundledRoot || !existsSync(bundledRoot)) return []; return listBundledSkillDirs(bundledRoot).map((dir) => { const content = readFileSync(join(dir, 'SKILL.md'), 'utf8'); diff --git a/src/core/skills/resolveBundledSkillsRoot.ts b/src/core/skills/resolveBundledSkillsRoot.ts new file mode 100644 index 00000000..fdcb08e1 --- /dev/null +++ b/src/core/skills/resolveBundledSkillsRoot.ts @@ -0,0 +1,32 @@ +import { existsSync, readdirSync, statSync } from 'fs'; +import { join } from 'path'; + +const CANDIDATE_SUFFIXES = [ + 'src/core/skills/bundled', + 'dist/core/skills/bundled', +] as const; + +/** Resolve bundled skills root from the source tree or compiled extension output. */ +export function resolveBundledSkillsRoot(packageRoot: string): string | undefined { + for (const suffix of CANDIDATE_SUFFIXES) { + const candidate = join(packageRoot, suffix); + if (hasSkillDirs(candidate)) return candidate; + } + return undefined; +} + +function hasSkillDirs(root: string): boolean { + if (!existsSync(root)) return false; + try { + return readdirSync(root).some((entry) => { + const abs = join(root, entry); + try { + return statSync(abs).isDirectory() && existsSync(join(abs, 'SKILL.md')); + } catch { + return false; + } + }); + } catch { + return false; + } +} diff --git a/src/node/cli.ts b/src/node/cli.ts index 484d8a77..57bcb0f0 100644 --- a/src/node/cli.ts +++ b/src/node/cli.ts @@ -2,7 +2,8 @@ import { existsSync, readFileSync, writeFileSync } from 'fs'; import { join, resolve } from 'path'; import { AuditPackBuilder, verifyAuditPack } from '../core/audit'; -import { HeadlessAgentRunner, generateHeadlessChangelog, prepareHeadlessRelease } from '../core/headless'; +import { HeadlessAgentHost, generateHeadlessChangelog, prepareHeadlessRelease } from '../core/headless'; +import type { HeadlessRuntime } from '../core/headless/HeadlessConfig'; import type { ProviderType } from '../core/config/schema'; async function main(argv: string[]): Promise { @@ -57,31 +58,43 @@ async function main(argv: string[]): Promise { } if (command === 'ask') { - const runner = createRunner(cwd, args, json); - const answer = await runner.ask(prompt || readStdin()); - process.stdout.write(json ? JSON.stringify({ answer }, null, 2) + '\n' : `${answer}\n`); - return 0; + const host = createHost(cwd, args); + try { + const answer = await host.ask(prompt || readStdin()); + process.stdout.write(json ? JSON.stringify({ answer }, null, 2) + '\n' : `${answer}\n`); + return 0; + } finally { + host.dispose(); + } } if (command === 'plan') { - const runner = createRunner(cwd, args, json); - const plan = runner.plan(prompt || readStdin()); - process.stdout.write(JSON.stringify(plan, null, 2) + '\n'); - return 0; + const host = createHost(cwd, args); + try { + const plan = await host.plan(prompt || readStdin()); + process.stdout.write(JSON.stringify(plan, null, 2) + '\n'); + return 0; + } finally { + host.dispose(); + } } if (command === 'agent') { - const runner = createRunner(cwd, args, json); - for await (const event of runner.agent(prompt || readStdin())) { - if (json) { - process.stdout.write(JSON.stringify(event) + '\n'); - } else if (event.content) { - process.stdout.write(`${event.content}\n`); - } else if (event.message) { - process.stderr.write(`${event.message}\n`); + const host = createHost(cwd, args); + try { + for await (const event of host.agent(prompt || readStdin())) { + if (json) { + process.stdout.write(JSON.stringify(event) + '\n'); + } else if (event.content) { + process.stdout.write(`${event.content}\n`); + } else if (event.message) { + process.stderr.write(`${event.message}\n`); + } } + return 0; + } finally { + host.dispose(); } - return 0; } if (command === 'commit-msg') { @@ -94,14 +107,19 @@ async function main(argv: string[]): Promise { return 1; } -function createRunner(cwd: string, args: string[], json: boolean): HeadlessAgentRunner { - return new HeadlessAgentRunner({ +function createHost(cwd: string, args: string[]): HeadlessAgentHost { + const provider = (valueOf(args, '--provider') as ProviderType | undefined) ?? 'echo'; + const runtime = (valueOf(args, '--runtime') as HeadlessRuntime | undefined) + ?? (provider === 'echo' ? 'stub' : 'real'); + return new HeadlessAgentHost({ cwd, - providerType: (valueOf(args, '--provider') as ProviderType | undefined) ?? 'echo', + runtime, + providerType: provider, baseUrl: valueOf(args, '--base-url'), model: valueOf(args, '--model'), - approval: (valueOf(args, '--approval') as 'auto' | 'manual' | undefined) ?? 'manual', - json, + approval: (valueOf(args, '--approval') as 'auto' | 'manual' | undefined) ?? 'auto', + enablePuppeteer: args.includes('--enable-puppeteer'), + allowNetwork: args.includes('--allow-network'), }); } @@ -160,9 +178,9 @@ function printHelp(): void { ' mitii prepare-release [--since ] [--cwd ] [--json]', ' mitii export-audit [--session ] [--output ] [--cwd ] [--json]', ' mitii verify-audit [--cwd ] [--json]', - ' mitii ask "question" [--provider echo|openai|...] [--model ] [--base-url ] [--cwd ] [--json]', - ' mitii plan "goal" [--provider echo|openai|...] [--model ] [--approval auto|manual] [--cwd ]', - ' mitii agent "goal" [--provider echo|openai|...] [--model ] [--approval auto|manual] [--json]', + ' mitii ask "question" [--runtime real|stub] [--provider echo|openai|...] [--model ] [--base-url ] [--cwd ] [--json]', + ' mitii plan "goal" [--runtime real|stub] [--provider echo|openai|...] [--model ] [--approval auto|manual] [--cwd ]', + ' mitii agent "goal" [--runtime real|stub] [--provider echo|openai|...] [--model ] [--approval auto|manual] [--enable-puppeteer] [--allow-network] [--json]', '', ].join('\n')); } diff --git a/src/node/vscode-shim.ts b/src/node/vscode-shim.ts new file mode 100644 index 00000000..7fe5cba7 --- /dev/null +++ b/src/node/vscode-shim.ts @@ -0,0 +1,54 @@ +/** + * Minimal VS Code API shim for headless CLI / benchmark runs. + * Production extension code still uses the real `vscode` module. + */ + +export const Uri = { + file: (path: string) => ({ scheme: 'file', fsPath: path, path }), +}; + +export const workspace = { + getConfiguration: (_section?: string) => ({ + get: (_key: string, defaultValue?: T) => defaultValue as T, + }), + workspaceFolders: undefined as undefined | Array<{ uri: { fsPath: string } }>, + asRelativePath: (uri: { fsPath?: string; path?: string }, _includeWorkspaceFolder?: boolean) => { + const path = uri.fsPath ?? uri.path ?? ''; + return path.split('/').pop() ?? path; + }, + createFileSystemWatcher: () => ({ + onDidChange: () => ({ dispose: () => undefined }), + onDidCreate: () => ({ dispose: () => undefined }), + onDidDelete: () => ({ dispose: () => undefined }), + dispose: () => undefined, + }), +}; + +export const window = { + activeTextEditor: undefined, + tabGroups: { all: [] as Array<{ tabs: unknown[] }> }, + showInformationMessage: async (..._args: unknown[]) => undefined, + showWarningMessage: async (..._args: unknown[]) => undefined, + showErrorMessage: async (..._args: unknown[]) => undefined, + showOpenDialog: async () => undefined, +}; + +export const languages = { + getDiagnostics: () => [] as Array<[unknown, unknown[]]>, + DiagnosticSeverity: { Error: 0, Warning: 1, Information: 2, Hint: 3 }, +}; + +export const DiagnosticSeverity = languages.DiagnosticSeverity; + +export class RelativePattern { + constructor( + readonly base: { fsPath: string }, + readonly pattern: string + ) {} +} + +export const commands = { + executeCommand: async (..._args: unknown[]) => undefined, +}; + +export type Uri = ReturnType; diff --git a/src/vscode/webview/ThunderWebviewProvider.ts b/src/vscode/webview/ThunderWebviewProvider.ts index 5359aa41..a202cbcb 100644 --- a/src/vscode/webview/ThunderWebviewProvider.ts +++ b/src/vscode/webview/ThunderWebviewProvider.ts @@ -74,6 +74,14 @@ export class ThunderWebviewProvider implements vscode.WebviewViewProvider { this.postMessage({ type: 'state', payload: this.state }); }); + this.controller.setPreservedUiGetter(() => ({ + tab: this.state.tab, + mode: this.state.mode, + messages: this.state.messages, + currentSessionId: this.state.currentSessionId, + chatHistory: this.state.chatHistory, + loading: this.state.loading, + })); this.controller.setAutoFixCallback(async (message) => { await this.runChatCompletion(message, true); }); @@ -322,6 +330,21 @@ export class ThunderWebviewProvider implements vscode.WebviewViewProvider { await this.controller.testProviderConnection(message.payload); break; + case 'saveProviderProfile': + await this.controller.saveProviderProfile(message.payload); + await this.syncState(); + break; + + case 'selectProviderProfile': + await this.controller.selectProviderProfile(message.payload.id); + await this.syncState(); + break; + + case 'deleteProviderProfile': + await this.controller.deleteProviderProfile(message.payload.id); + await this.syncState(); + break; + case 'pickWorkspaceFolder': await this.controller.pickWorkspaceFolder(); await this.syncState(); diff --git a/src/vscode/webview/messages.ts b/src/vscode/webview/messages.ts index fe2f83b4..ac12c1a1 100644 --- a/src/vscode/webview/messages.ts +++ b/src/vscode/webview/messages.ts @@ -310,6 +310,20 @@ export interface SettingsView { checkpointStrategy: 'file-copy' | 'git-stash' | 'shadow-git'; showReasoning: boolean; reasoningPreviewMaxChars: number; + providerProfiles: ProviderProfileView[]; + activeProviderProfileId: string | null; +} + +export interface ProviderProfileView { + id: string; + name: string; + providerType: string; + baseUrl: string; + model: string; + apiVersion: string; + region: string; + contextWindow: number; + hasApiKey: boolean; } export interface McpServerStatusView { @@ -367,6 +381,8 @@ export interface WebviewState { workspaceNotice: WorkspaceNoticeView | null; tokenUsage: TokenUsageView; workspaceTrusted: boolean; + settingsSaving: boolean; + testingConnection: boolean; } export type WorkspaceNoticeView = { @@ -415,6 +431,9 @@ export type WebviewToExtensionMessage = | { type: 'saveMcpSettings'; payload: McpSettingsPayload } | { type: 'saveAllSettings'; payload: ThunderSettingsPayload } | { type: 'testProviderConnection'; payload?: ProviderSettingsPayload } + | { type: 'saveProviderProfile'; payload: { id?: string; name?: string; settings: ProviderSettingsPayload; apiKey?: string } } + | { type: 'selectProviderProfile'; payload: { id: string } } + | { type: 'deleteProviderProfile'; payload: { id: string } } | { type: 'pickWorkspaceFolder' } | { type: 'setWorkspaceOverride'; payload: { path: string } } | { type: 'clearWorkspaceOverride' } @@ -442,6 +461,7 @@ export const defaultMcpToggles = (): McpToggles => ({ filesystem: true, memory: true, sequentialThinking: true, + puppeteer: false, }); export const defaultContextToggles = (): ContextToggles => ({ @@ -503,6 +523,8 @@ export const defaultSettingsView = (): SettingsView => ({ checkpointStrategy: 'git-stash', showReasoning: true, reasoningPreviewMaxChars: 8000, + providerProfiles: [], + activeProviderProfileId: null, }); export const initialWebviewState = (): WebviewState => ({ @@ -567,4 +589,6 @@ export const initialWebviewState = (): WebviewState => ({ breakdown: [], }, workspaceTrusted: true, + settingsSaving: false, + testingConnection: false, }); diff --git a/src/webview-ui/src/App.tsx b/src/webview-ui/src/App.tsx index 90edbcf4..49cce48b 100644 --- a/src/webview-ui/src/App.tsx +++ b/src/webview-ui/src/App.tsx @@ -17,7 +17,8 @@ import { PlanPanel } from './components/PlanPanel'; import { DevPanels } from './components/DevPanels'; import { IconButton } from './components/IconButton'; import { IconChat, IconHistory, IconPlus, IconSettings } from './components/Icons'; -import { deriveSafetySettings } from './utils/approvalMode'; +import { deriveSafetyFromAutonomyPreset } from './utils/autonomyPreset'; +import type { AutonomyPreset } from './utils/autonomyPreset'; import type { AgentDepthView, AgentSettingsPayload, SettingsView } from '../../vscode/webview/messages'; import type { ThunderMode } from '../../core/session/ThunderSession'; @@ -211,7 +212,7 @@ export function App() { postMessage({ type: 'stopGeneration' })} onModeChange={(mode) => postMessage({ type: 'setMode', payload: mode })} - onApprovalModeChange={(approvalMode) => + onAutonomyPresetChange={(preset: AutonomyPreset) => postMessage({ type: 'saveSafetySettings', - payload: deriveSafetySettings(approvalMode), + payload: deriveSafetyFromAutonomyPreset(preset), }) } onDepthChange={(depth) => { @@ -265,7 +266,6 @@ export function App() { settings={state.settings} workspaceOpen={state.workspaceOpen} workspacePath={state.workspacePath} - vscodeWorkspaceFolders={state.vscodeWorkspaceFolders} workspaceOverride={state.workspaceOverride} usingWorkspaceOverride={state.usingWorkspaceOverride} indexDbPath={state.indexDbPath} @@ -298,6 +298,17 @@ export function App() { onDeleteMemory={(id) => postMessage({ type: 'deleteMemory', payload: { id } })} onClearMemory={() => postMessage({ type: 'clearMemory' })} onRestoreCheckpoint={(id) => postMessage({ type: 'restoreCheckpoint', payload: { id } })} + settingsSaving={state.settingsSaving} + testingConnection={state.testingConnection} + onSaveProviderProfile={(payload) => + postMessage({ type: 'saveProviderProfile', payload }) + } + onSelectProviderProfile={(id) => + postMessage({ type: 'selectProviderProfile', payload: { id } }) + } + onDeleteProviderProfile={(id) => + postMessage({ type: 'deleteProviderProfile', payload: { id } }) + } /> )} diff --git a/src/webview-ui/src/components/ChatInput.tsx b/src/webview-ui/src/components/ChatInput.tsx index 9e404ffb..a22540c9 100644 --- a/src/webview-ui/src/components/ChatInput.tsx +++ b/src/webview-ui/src/components/ChatInput.tsx @@ -1,7 +1,6 @@ import { useState, useCallback, type KeyboardEvent, useRef, useEffect } from 'react'; import type { ThunderMode } from '../../../core/session/ThunderSession'; import type { - ApprovalMode, AgentDepthView, ChatImageAttachment, ContextPathSuggestion, @@ -11,12 +10,13 @@ import type { import { IconButton } from './IconButton'; import { IconChevronDown, IconCopy, IconImage, IconMarkdown, IconRetry, IconSend, IconStop } from './Icons'; import { TokenMeter } from './TokenMeter'; -import { APPROVAL_MODE_OPTIONS, deriveSafetySettings } from '../utils/approvalMode'; +import { AUTONOMY_PRESET_OPTIONS, deriveSafetyFromAutonomyPreset } from '../utils/autonomyPreset'; +import type { AutonomyPreset } from '../utils/autonomyPreset'; interface ChatInputProps { loading: boolean; mode: ThunderMode; - approvalMode: ApprovalMode; + autonomyPreset: AutonomyPreset; activeDepth: AgentDepthView; tokenUsage: TokenUsageView; pinnedContext: PinnedContextView[]; @@ -24,7 +24,7 @@ interface ChatInputProps { onSend: (content: string, pinnedContext: PinnedContextView[], attachments: ChatImageAttachment[]) => void; onStop?: () => void; onModeChange: (mode: ThunderMode) => void; - onApprovalModeChange: (mode: ApprovalMode) => void; + onAutonomyPresetChange: (preset: AutonomyPreset) => void; onDepthChange: (depth: AgentDepthView) => void; onRetry?: () => void; onCopyResponse?: () => void; @@ -55,7 +55,7 @@ const DEPTH_OPTIONS: Array<{ id: AgentDepthView; label: string; title: string }> export function ChatInput({ loading, mode, - approvalMode, + autonomyPreset, activeDepth, tokenUsage, pinnedContext, @@ -63,7 +63,7 @@ export function ChatInput({ onSend, onStop, onModeChange, - onApprovalModeChange, + onAutonomyPresetChange, onDepthChange, onRetry, onCopyResponse, @@ -84,8 +84,8 @@ export function ChatInput({ const textareaRef = useRef(null); const fileInputRef = useRef(null); const activeMode = MODES.find((m) => m.id === mode) ?? MODES[0]; - const activeApproval = - APPROVAL_MODE_OPTIONS.find((option) => option.id === approvalMode) ?? APPROVAL_MODE_OPTIONS[0]; + const activeAutonomy = + AUTONOMY_PRESET_OPTIONS.find((option) => option.id === autonomyPreset) ?? AUTONOMY_PRESET_OPTIONS[1]; const selectedDepth = DEPTH_OPTIONS.find((option) => option.id === activeDepth) ?? DEPTH_OPTIONS[0]; useEffect(() => { @@ -301,12 +301,12 @@ export function ChatInput({
{ + const id = e.target.value; + if (!id) return; + onSelectProviderProfile(id); + loadProviderProfile(id); + }} + > + {settings.providerProfiles.map((profile) => ( + + ))} + + + ) : ( +

No saved providers yet. Configure below and click Save provider.

+ )} + + + +
+ + {selectedProfileId && ( + + )} +
+ + onTestConnection(currentProviderSettings())} - disabled={!providerValidation.ok} + disabled={!providerValidation.ok || testingConnection} > - Test connection + {testingConnection ? 'Testing…' : 'Test connection'} {settings.connectionStatus && (

- {AGENT_NAME} reads AGENTS.md, CLAUDE.md, .mitii/rules,{' '} - .clinerules, and Continue/Cursor rule folders into context. + {AGENT_NAME} reads MITII.md from the workspace root into context.

Active rule files: {settings.projectRules} @@ -1116,70 +1208,6 @@ export function SettingsPanel({ )} - {activeTab === 'safety' && ( - - - - - -

- {settings.requireApprovalWrites ? 'Edits: ask' : 'Edits: auto'} - {settings.requireApprovalShell ? 'Commands: ask' : 'Commands: auto'} -
-
- )} - {activeTab === 'debug' && settings.localDebugAvailable && ( <> - {dirty ? 'Unsaved changes' : saved ? 'All changes saved' : 'No pending changes'} + {settingsSaving + ? 'Saving…' + : dirty + ? 'Unsaved changes' + : saved + ? 'All changes saved' + : 'No pending changes'}
)} + + {(settingsSaving || testingConnection) && ( +
+
+ )} ); } diff --git a/src/webview-ui/src/components/WorkspaceSettingsSection.tsx b/src/webview-ui/src/components/WorkspaceSettingsSection.tsx index d051d0a7..211f96b6 100644 --- a/src/webview-ui/src/components/WorkspaceSettingsSection.tsx +++ b/src/webview-ui/src/components/WorkspaceSettingsSection.tsx @@ -1,16 +1,14 @@ import { useState, useEffect } from 'react'; -import { AGENT_NAME } from '../../../shared/brand'; -import type { IndexingStatusView, WorkspaceNoticeView } from '../../../vscode/webview/messages'; -import { SettingNote } from './SettingNote'; +import type { IndexingStatusView, VectorIndexStatusView, WorkspaceNoticeView } from '../../../vscode/webview/messages'; interface WorkspaceSettingsSectionProps { workspaceOpen: boolean; workspacePath: string; - vscodeWorkspaceFolders: string[]; workspaceOverride: string; usingWorkspaceOverride: boolean; indexDbPath: string; indexing: IndexingStatusView; + vectorIndex: VectorIndexStatusView; workspaceNotice: WorkspaceNoticeView | null; onPickFolder: () => void; onSetOverride: (path: string) => void; @@ -21,11 +19,11 @@ interface WorkspaceSettingsSectionProps { export function WorkspaceSettingsSection({ workspaceOpen, workspacePath, - vscodeWorkspaceFolders, workspaceOverride, usingWorkspaceOverride, indexDbPath, indexing, + vectorIndex, workspaceNotice, onPickFolder, onSetOverride, @@ -35,94 +33,62 @@ export function WorkspaceSettingsSection({ const [overrideInput, setOverrideInput] = useState(workspaceOverride); const runTotal = indexing.runTotal ?? 0; const processed = Math.min(indexing.processed ?? 0, runTotal); - const activeWorkers = indexing.activeWorkers ?? 0; const progressPct = runTotal > 0 ? Math.round((processed / runTotal) * 100) : 0; - const progressLabel = indexing.running - ? `${processed}/${runTotal} files · ${indexing.queued} queued${activeWorkers > 0 ? ` · ${activeWorkers} active` : ''}` - : indexing.failed > 0 - ? `${indexing.indexed}${indexing.total > 0 ? `/${indexing.total}` : ''} indexed · ${indexing.failed} failed` - : `${indexing.indexed}${indexing.total > 0 ? `/${indexing.total}` : ''} indexed`; + const indexLabel = indexing.running + ? `${processed}/${runTotal} files` + : `${indexing.indexed}${indexing.total > 0 ? ` / ${indexing.total}` : ''} indexed`; useEffect(() => { setOverrideInput(workspaceOverride); }, [workspaceOverride]); return ( -
-

Workspace

- - - {AGENT_NAME} needs a project root to index files, run tools, and build context. - When you debug with F5, the Extension Host may open the monorepo folder — not your app. - Use Browse or paste the absolute path to the project you want the agent to work on - (e.g. your Kitchen KOT app), then Save & apply and Index. - - +
{workspaceNotice && ( -

+

{workspaceNotice.message}

)} -
-
- Effective path - - {workspaceOpen ? workspacePath : 'Not set'} - +
+
+ Indexed files + {indexLabel} + {indexing.failed > 0 && !indexing.running && ( + {indexing.failed} failed + )} +
+
+ Queued + {indexing.queued}
-
- Source - {usingWorkspaceOverride ? 'Saved override' : 'VS Code open folder'} +
+ Embedded chunks + {vectorIndex.embeddedChunks.toLocaleString()}
-
- Indexed files - {progressLabel} +
+ Source + + {usingWorkspaceOverride ? 'Override' : 'VS Code folder'} +
- {indexDbPath && ( -

- Index database: {indexDbPath} -

- )} - -
- -

VS Code open folders (this window)

- {vscodeWorkspaceFolders.length === 0 ? ( - - No folder is open in this window. That is normal when debugging {AGENT_NAME} itself. - Set a workspace path override below — it is saved even without an open folder. - Launch config tip: use Run Extension (parent monorepo) or open your target project folder. - - ) : ( -
    - {vscodeWorkspaceFolders.map((folder) => ( -
  • - {folder} -
  • - ))} -
- )} - -
diff --git a/src/webview-ui/src/styles/global.css b/src/webview-ui/src/styles/global.css index ed456c2c..76390d41 100644 --- a/src/webview-ui/src/styles/global.css +++ b/src/webview-ui/src/styles/global.css @@ -2602,6 +2602,7 @@ body, gap: 0; min-height: 0; flex: 1; + position: relative; } .settings-shell__header { @@ -3111,6 +3112,76 @@ body, color: var(--thunder-muted); } +.workspace-settings { + display: flex; + flex-direction: column; + gap: 14px; +} + +.workspace-stats { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.workspace-stat { + display: flex; + flex-direction: column; + gap: 2px; + padding: 10px 12px; + border: 1px solid var(--thunder-border); + border-radius: var(--thunder-radius); + background: var(--thunder-surface); +} + +.workspace-stat__label { + font-size: 11px; + color: var(--thunder-muted); +} + +.workspace-stat__value { + font-size: 14px; + font-weight: 600; +} + +.workspace-stat__meta { + font-size: 11px; + color: var(--thunder-muted); +} + +.settings-input--path { + font-family: var(--vscode-editor-font-family, monospace); + font-size: 12px; +} + +.settings-loading-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + background: color-mix(in srgb, var(--thunder-bg) 82%, transparent); + z-index: 5; + font-size: 13px; + color: var(--thunder-fg); +} + +.settings-loading-spinner { + width: 16px; + height: 16px; + border: 2px solid var(--thunder-border); + border-top-color: var(--thunder-accent, #007acc); + border-radius: 50%; + animation: settings-spin 0.8s linear infinite; +} + +@keyframes settings-spin { + to { + transform: rotate(360deg); + } +} + .btn--ghost { background: transparent; border: 1px solid var(--thunder-border); diff --git a/src/webview-ui/src/utils/autonomyPreset.ts b/src/webview-ui/src/utils/autonomyPreset.ts new file mode 100644 index 00000000..39e8e8f9 --- /dev/null +++ b/src/webview-ui/src/utils/autonomyPreset.ts @@ -0,0 +1,68 @@ +import type { ApprovalMode, SafetySettingsPayload } from '../../../vscode/webview/messages'; + +export type AutonomyPreset = SafetySettingsPayload['autonomyPreset']; + +export const AUTONOMY_PRESET_OPTIONS: Array<{ + id: AutonomyPreset; + label: string; + title: string; +}> = [ + { id: 'safe', label: 'Safe', title: 'Strictest — all edits and commands need approval, no network' }, + { id: 'guided', label: 'Guided', title: 'Balanced — asks before edits; read-only shell and web fetch allowed' }, + { id: 'builder', label: 'Builder', title: 'Fast — auto-approves writes; mutating shell still reviewed' }, + { id: 'pilot', label: 'Pilot', title: 'High autonomy — auto-approves writes, reviews shell' }, + { id: 'enterprise', label: 'Enterprise', title: 'Locked down — no network, all operations reviewed' }, +]; + +export function deriveSafetyFromAutonomyPreset(preset: AutonomyPreset): SafetySettingsPayload { + switch (preset) { + case 'safe': + return { + autonomyPreset: preset, + approvalMode: 'review_all', + requireApprovalForWrites: true, + requireApprovalForShell: true, + }; + case 'guided': + return { + autonomyPreset: preset, + approvalMode: 'ask_edits', + requireApprovalForWrites: true, + requireApprovalForShell: false, + }; + case 'builder': + return { + autonomyPreset: preset, + approvalMode: 'ask_commands', + requireApprovalForWrites: false, + requireApprovalForShell: true, + }; + case 'pilot': + return { + autonomyPreset: preset, + approvalMode: 'auto', + requireApprovalForWrites: false, + requireApprovalForShell: true, + }; + case 'enterprise': + return { + autonomyPreset: preset, + approvalMode: 'review_all', + requireApprovalForWrites: true, + requireApprovalForShell: true, + }; + } +} + +export function autonomyPresetFromApprovalMode( + approvalMode: ApprovalMode, + currentPreset: AutonomyPreset = 'guided' +): AutonomyPreset { + if (approvalMode === 'review_all') { + return currentPreset === 'enterprise' ? 'enterprise' : 'safe'; + } + if (approvalMode === 'ask_edits') return 'guided'; + if (approvalMode === 'ask_commands') return 'builder'; + if (approvalMode === 'auto') return 'pilot'; + return currentPreset; +} diff --git a/test/benchmark/benchmark.harness.test.ts b/test/benchmark/benchmark.harness.test.ts new file mode 100644 index 00000000..354349a0 --- /dev/null +++ b/test/benchmark/benchmark.harness.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; +// @ts-expect-error benchmark verifier ships as plain ESM +import { verifyTask } from '../../benchmark/verify.mjs'; +import { resolveBundledSkillsRoot } from '../../src/core/skills/resolveBundledSkillsRoot'; +import { listBundledSkillNames } from '../../src/core/skills/installBundledSkills'; +import { buildHeadlessConfig } from '../../src/core/headless/HeadlessConfig'; +import { headlessDiscoverFiles } from '../../src/core/headless/headlessDiscoverFiles'; +import { IgnoreService } from '../../src/core/indexing/IgnoreService'; +import { join } from 'path'; + +describe('benchmark verify rules', () => { + it('checks stdout_contains and file_exists', () => { + const fixture = join(process.cwd(), 'benchmark/fixtures/node-express'); + expect(verifyTask('stdout_contains:Echo:', { + stdout: 'Echo: hello', + stderr: '', + exitCode: 0, + cwd: fixture, + packageRoot: process.cwd(), + mode: 'ask', + }).passed).toBe(true); + + expect(verifyTask('file_exists:src/index.js', { + stdout: '', + stderr: '', + exitCode: 0, + cwd: fixture, + packageRoot: process.cwd(), + mode: 'ask', + }).passed).toBe(true); + }); + + it('validates json_path and jsonl_event', () => { + expect(verifyTask('json_path:steps', { + stdout: JSON.stringify({ steps: [{ id: 'a' }] }), + stderr: '', + exitCode: 0, + cwd: process.cwd(), + packageRoot: process.cwd(), + mode: 'plan', + }).passed).toBe(true); + + expect(verifyTask('jsonl_event:end', { + stdout: ['{"type":"start"}', '{"type":"end"}'].join('\n'), + stderr: '', + exitCode: 0, + cwd: process.cwd(), + packageRoot: process.cwd(), + mode: 'agent', + }).passed).toBe(true); + }); +}); + +describe('core bundled skills', () => { + it('resolves skills from src/core/skills/bundled', () => { + const root = resolveBundledSkillsRoot(process.cwd()); + expect(root).toContain('src/core/skills/bundled'); + const names = listBundledSkillNames(process.cwd()); + expect(names).toContain('test-driven-development'); + expect(names).toContain('browser-testing-with-devtools'); + }); +}); + +describe('headless config', () => { + it('enables tools for real runtime and puppeteer toggle', () => { + const config = buildHeadlessConfig({ + cwd: process.cwd(), + runtime: 'real', + enablePuppeteer: true, + }); + expect(config.provider.supportsTools).toBe(true); + expect(config.mcp.builtinServers.puppeteer).toBe(true); + }); +}); + +describe('fixture discovery', () => { + it('discovers JS files in node-express fixture', () => { + const fixture = join(process.cwd(), 'benchmark/fixtures/node-express'); + const ignore = new IgnoreService(); + ignore.load(fixture, { respectGitignore: true, respectThunderignore: true }); + const files = headlessDiscoverFiles(fixture, ignore, { hardSkipSizeBytes: 2_000_000 }); + const relPaths = files.map((f: { relPath: string }) => f.relPath); + expect(relPaths).toContain('src/index.js'); + expect(relPaths).toContain('src/routes/users.js'); + }); +}); diff --git a/test/benchmark/headless-agent-host.test.ts b/test/benchmark/headless-agent-host.test.ts new file mode 100644 index 00000000..40037a09 --- /dev/null +++ b/test/benchmark/headless-agent-host.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { mkdtempSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { HeadlessAgentHost } from '../../src/core/headless/HeadlessAgentHost'; + +describe('HeadlessAgentHost', () => { + it('runs stub ask/plan/agent without native index', async () => { + const cwd = mkdtempSync(join(tmpdir(), 'mitii-headless-')); + const host = new HeadlessAgentHost({ + cwd, + runtime: 'stub', + providerType: 'echo', + approval: 'auto', + }); + + const answer = await host.ask('hello'); + expect(answer).toContain('Echo:'); + + const plan = await host.plan('ship feature'); + expect(plan).toMatchObject({ goal: 'ship feature' }); + + const events: string[] = []; + for await (const event of host.agent('review code')) { + events.push(event.type); + } + expect(events).toContain('end'); + host.dispose(); + }); + + it('initializes real runtime on fixture workspace', async () => { + const cwd = join(process.cwd(), 'benchmark/fixtures/node-express'); + const host = new HeadlessAgentHost({ + cwd, + packageRoot: process.cwd(), + runtime: 'real', + providerType: 'echo', + approval: 'auto', + indexWorkspace: true, + }); + + try { + await host.initialize(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes('NODE_MODULE_VERSION') || message.includes('better_sqlite3')) { + host.dispose(); + return; + } + throw error; + } + + expect(host.isRealRuntime).toBe(true); + + const answer = await host.ask('What port does the app use?'); + expect(answer.length).toBeGreaterThan(0); + host.dispose(); + }, 120_000); +}); diff --git a/test/config-ui-mappers.test.ts b/test/config-ui-mappers.test.ts index 5b0a3bc7..e35ed2ac 100644 --- a/test/config-ui-mappers.test.ts +++ b/test/config-ui-mappers.test.ts @@ -119,6 +119,7 @@ describe('config UI mappers', () => { filesystem: false, memory: false, sequentialThinking: false, + puppeteer: false, }, }, indexing: { @@ -138,6 +139,7 @@ describe('config UI mappers', () => { filesystem: true, memory: false, sequentialThinking: true, + puppeteer: false, }) ).toMatchObject({ provider: { diff --git a/test/setup.ts b/test/setup.ts index fdaa066a..f56bb108 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -5,5 +5,35 @@ vi.mock('vscode', () => ({ getConfiguration: () => ({ get: (_key: string, defaultValue: unknown) => defaultValue, }), + workspaceFolders: undefined, + asRelativePath: (uri: { fsPath?: string }) => uri.fsPath?.split('/').pop() ?? '', + createFileSystemWatcher: () => ({ + onDidChange: () => ({ dispose: () => undefined }), + onDidCreate: () => ({ dispose: () => undefined }), + onDidDelete: () => ({ dispose: () => undefined }), + dispose: () => undefined, + }), + }, + window: { + activeTextEditor: undefined, + tabGroups: { all: [] }, + showInformationMessage: async () => undefined, + showWarningMessage: async () => undefined, + showErrorMessage: async () => undefined, + showOpenDialog: async () => undefined, + }, + Uri: { + file: (path: string) => ({ scheme: 'file', fsPath: path, path }), + }, + RelativePattern: class { + constructor(public base: unknown, public pattern: string) {} + }, + languages: { + getDiagnostics: () => [], + DiagnosticSeverity: { Error: 0, Warning: 1, Information: 2, Hint: 3 }, + }, + DiagnosticSeverity: { Error: 0, Warning: 1, Information: 2, Hint: 3 }, + commands: { + executeCommand: async () => undefined, }, })); diff --git a/test/unit.test.ts b/test/unit.test.ts index b8243e1d..2e28699e 100644 --- a/test/unit.test.ts +++ b/test/unit.test.ts @@ -551,7 +551,7 @@ describe('Builtin MCP servers', () => { const { buildBuiltinMcpServers } = await import('../src/core/mcp/builtinServers'); const servers = buildBuiltinMcpServers('/tmp/my-project'); - expect(Object.keys(servers).sort()).toEqual(['filesystem', 'memory', 'sequential-thinking']); + expect(Object.keys(servers).sort()).toEqual(['filesystem', 'memory', 'puppeteer', 'sequential-thinking']); expect(servers.filesystem.command).toBe(process.platform === 'win32' ? 'cmd' : 'npx'); expect(servers.filesystem.args).toContain('@modelcontextprotocol/server-filesystem'); expect(servers.filesystem.args.at(-1)).toBe(resolve('/tmp/my-project')); @@ -562,7 +562,7 @@ describe('Builtin MCP servers', () => { it('omits filesystem when workspace is empty', async () => { const { buildBuiltinMcpServers } = await import('../src/core/mcp/builtinServers'); const servers = buildBuiltinMcpServers(''); - expect(Object.keys(servers).sort()).toEqual(['memory', 'sequential-thinking']); + expect(Object.keys(servers).sort()).toEqual(['memory', 'puppeteer', 'sequential-thinking']); }); it('lets user settings override built-in servers', async () => { @@ -605,6 +605,7 @@ describe('Builtin MCP servers', () => { filesystem: true, memory: false, sequentialThinking: true, + puppeteer: false, }); expect(servers.memory.disabled).toBe(true); expect(servers.filesystem.disabled).toBe(false); @@ -615,46 +616,30 @@ describe('Builtin MCP servers', () => { filesystem: true, memory: true, sequentialThinking: true, + puppeteer: false, }); }); }); describe('ProjectRulesService', () => { - it('loads common root-level agent rule files', () => { + it('loads MITII.md from workspace root', () => { const tempDir = mkdtempSync(join(tmpdir(), 'thunder-rules-test-')); try { - writeFileSync(join(tempDir, 'AGENTS.md'), 'agent instructions'); - writeFileSync(join(tempDir, 'CLAUDE.md'), 'claude instructions'); - writeFileSync(join(tempDir, '.cursorrules'), 'cursor instructions'); - writeFileSync(join(tempDir, '.clinerules'), 'cline instructions'); + writeFileSync(join(tempDir, 'MITII.md'), 'mitii instructions'); + writeFileSync(join(tempDir, 'AGENTS.md'), 'ignored agent instructions'); const rules = new ProjectRulesService(tempDir).load(); - expect(rules.map((rule) => rule.relPath)).toEqual([ - 'AGENTS.md', - 'CLAUDE.md', - '.cursorrules', - '.clinerules', - ]); + expect(rules.map((rule) => rule.relPath)).toEqual(['MITII.md']); } finally { rmSync(tempDir, { recursive: true, force: true }); } }); - it('loads markdown files from Mitii rule, agent, check, and prompt folders', () => { + it('returns empty when MITII.md is missing', () => { const tempDir = mkdtempSync(join(tmpdir(), 'thunder-rules-test-')); try { - for (const relDir of ['.mitii/rules', '.mitii/agents', '.mitii/checks', '.mitii/prompts']) { - mkdirSync(join(tempDir, relDir), { recursive: true }); - writeFileSync(join(tempDir, relDir, 'methodology.md'), `${relDir} instructions`); - } - - const rules = new ProjectRulesService(tempDir).load(); - expect(rules.map((rule) => rule.relPath)).toEqual([ - '.mitii/rules/methodology.md', - '.mitii/agents/methodology.md', - '.mitii/checks/methodology.md', - '.mitii/prompts/methodology.md', - ]); + writeFileSync(join(tempDir, 'AGENTS.md'), 'ignored'); + expect(new ProjectRulesService(tempDir).load()).toEqual([]); } finally { rmSync(tempDir, { recursive: true, force: true }); } From 9ec5aff99cbd5c57fa297e26b7d29f08f243977d Mon Sep 17 00:00:00 2001 From: codewithshinde Date: Thu, 2 Jul 2026 20:40:10 -0500 Subject: [PATCH 06/12] feat(ui): replace native selects with custom dropdowns in composer footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add generic renderDropdown component with color-coded icons and tooltips for Mode, Approval, and Depth selectors in ChatInput - Add IconAsk, IconPlan, IconAgent SVG icons for mode dropdown options - Add CSS-only .has-tooltip class using data-tooltip attribute (replaces title on IconButton) - Refactor global.css: new composer__dropdown-* styles replacing old select-based styling, message list max-width centering, and mobile responsive breakpoint at 420px - Rename autonomyPreset → approvalMode throughout App and ChatInput props - Rename "Auto" label to "Auto approve" in approval mode options --- package-lock.json | 4 +- package.json | 2 +- src/webview-ui/src/App.tsx | 11 +- src/webview-ui/src/components/ChatInput.tsx | 255 +++++++++++----- src/webview-ui/src/components/IconButton.tsx | 4 +- src/webview-ui/src/components/Icons.tsx | 28 ++ src/webview-ui/src/styles/global.css | 304 +++++++++++++++---- src/webview-ui/src/utils/approvalMode.ts | 2 +- 8 files changed, 472 insertions(+), 138 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0222b366..062447d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mitii-ai-agent", - "version": "2.7.20", + "version": "2.7.21", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mitii-ai-agent", - "version": "2.7.20", + "version": "2.7.21", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "dependencies": { diff --git a/package.json b/package.json index 6cec0406..5881684e 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "mitii-ai-agent", "displayName": "Mitii AI Agent", "description": "Local-first VS Code AI coding agent with precise repo context and safe Plan/Act workflow", - "version": "2.7.20", + "version": "2.7.21", "publisher": "mitii", "icon": "media/mitii-short-logo.png", "license": "AGPL-3.0-or-later", diff --git a/src/webview-ui/src/App.tsx b/src/webview-ui/src/App.tsx index 49cce48b..d9759ba0 100644 --- a/src/webview-ui/src/App.tsx +++ b/src/webview-ui/src/App.tsx @@ -17,9 +17,8 @@ import { PlanPanel } from './components/PlanPanel'; import { DevPanels } from './components/DevPanels'; import { IconButton } from './components/IconButton'; import { IconChat, IconHistory, IconPlus, IconSettings } from './components/Icons'; -import { deriveSafetyFromAutonomyPreset } from './utils/autonomyPreset'; -import type { AutonomyPreset } from './utils/autonomyPreset'; -import type { AgentDepthView, AgentSettingsPayload, SettingsView } from '../../vscode/webview/messages'; +import { deriveSafetySettings } from './utils/approvalMode'; +import type { AgentDepthView, AgentSettingsPayload, ApprovalMode, SettingsView } from '../../vscode/webview/messages'; import type { ThunderMode } from '../../core/session/ThunderSession'; function activeDepthForMode(settings: SettingsView, mode: ThunderMode): AgentDepthView { @@ -212,7 +211,7 @@ export function App() { postMessage({ type: 'stopGeneration' })} onModeChange={(mode) => postMessage({ type: 'setMode', payload: mode })} - onAutonomyPresetChange={(preset: AutonomyPreset) => + onApprovalModeChange={(approvalMode: ApprovalMode) => postMessage({ type: 'saveSafetySettings', - payload: deriveSafetyFromAutonomyPreset(preset), + payload: deriveSafetySettings(approvalMode), }) } onDepthChange={(depth) => { diff --git a/src/webview-ui/src/components/ChatInput.tsx b/src/webview-ui/src/components/ChatInput.tsx index a22540c9..431af200 100644 --- a/src/webview-ui/src/components/ChatInput.tsx +++ b/src/webview-ui/src/components/ChatInput.tsx @@ -1,22 +1,33 @@ -import { useState, useCallback, type KeyboardEvent, useRef, useEffect } from 'react'; +import { useState, useCallback, type CSSProperties, type KeyboardEvent, useRef, useEffect } from 'react'; import type { ThunderMode } from '../../../core/session/ThunderSession'; import type { AgentDepthView, + ApprovalMode, ChatImageAttachment, ContextPathSuggestion, PinnedContextView, TokenUsageView, } from '../../../vscode/webview/messages'; import { IconButton } from './IconButton'; -import { IconChevronDown, IconCopy, IconImage, IconMarkdown, IconRetry, IconSend, IconStop } from './Icons'; +import { + IconAgent, + IconAsk, + IconChevronDown, + IconCopy, + IconImage, + IconMarkdown, + IconPlan, + IconRetry, + IconSend, + IconStop, +} from './Icons'; import { TokenMeter } from './TokenMeter'; -import { AUTONOMY_PRESET_OPTIONS, deriveSafetyFromAutonomyPreset } from '../utils/autonomyPreset'; -import type { AutonomyPreset } from '../utils/autonomyPreset'; +import { APPROVAL_MODE_OPTIONS } from '../utils/approvalMode'; interface ChatInputProps { loading: boolean; mode: ThunderMode; - autonomyPreset: AutonomyPreset; + approvalMode: ApprovalMode; activeDepth: AgentDepthView; tokenUsage: TokenUsageView; pinnedContext: PinnedContextView[]; @@ -24,7 +35,7 @@ interface ChatInputProps { onSend: (content: string, pinnedContext: PinnedContextView[], attachments: ChatImageAttachment[]) => void; onStop?: () => void; onModeChange: (mode: ThunderMode) => void; - onAutonomyPresetChange: (preset: AutonomyPreset) => void; + onApprovalModeChange: (approvalMode: ApprovalMode) => void; onDepthChange: (depth: AgentDepthView) => void; onRetry?: () => void; onCopyResponse?: () => void; @@ -36,26 +47,68 @@ interface ChatInputProps { pathSearchRequestId: string | null; } -const MODES: { id: ThunderMode; label: string; description: string }[] = [ - { id: 'ask', label: 'Ask', description: 'Explore and answer questions (read-only)' }, - { id: 'plan', label: 'Plan', description: 'Analyze and propose steps' }, - { id: 'agent', label: 'Agent', description: 'Apply code changes' }, - { id: 'review', label: 'Review', description: 'Inspect code and risks' }, +type ComposerSelectId = 'mode' | 'approval' | 'depth'; +type ComposerOption = { + id: T; + label: string; + description: string; + color: string; + icon?: typeof IconAsk; +}; + +const MODES: Array>> = [ + { + id: 'ask', + label: 'Ask', + description: 'Explore and answer questions (read-only)', + color: '#22c55e', + icon: IconAsk, + }, + { + id: 'plan', + label: 'Plan', + description: 'Analyze and propose steps', + color: '#f59e0b', + icon: IconPlan, + }, + { + id: 'agent', + label: 'Agent', + description: 'Apply code changes', + color: '#ef4444', + icon: IconAgent, + }, ]; -const DEPTH_OPTIONS: Array<{ id: AgentDepthView; label: string; title: string }> = [ - { id: 'auto', label: 'Auto', title: 'Choose depth from the request' }, - { id: 'quick', label: 'Quick', title: 'Use a smaller exploration or execution budget' }, - { id: 'standard', label: 'Standard', title: 'Use the normal exploration or execution budget' }, - { id: 'deep', label: 'Deep', title: 'Use a larger budget for complex work' }, - { id: 'pilot', label: 'Pilot', title: 'Use an expanded budget for broad implementation or investigation' }, - { id: 'enterprise', label: 'Enterprise', title: 'Use the largest built-in budget for exhaustive work' }, +const APPROVAL_OPTIONS: Array> = APPROVAL_MODE_OPTIONS.map((option) => { + const colorByMode: Record = { + review_all: '#ef4444', + ask_edits: '#f59e0b', + ask_deletes: '#f97316', + ask_commands: '#fb923c', + auto: '#22c55e', + }; + return { + id: option.id, + label: option.label, + description: option.title, + color: colorByMode[option.id], + }; +}); + +const DEPTH_OPTIONS: Array> = [ + { id: 'auto', label: 'Auto', description: 'Choose depth from the request', color: '#38bdf8' }, + { id: 'quick', label: 'Quick', description: 'Use a smaller exploration or execution budget', color: '#22c55e' }, + { id: 'standard', label: 'Standard', description: 'Use the normal exploration or execution budget', color: '#60a5fa' }, + { id: 'deep', label: 'Deep', description: 'Use a larger budget for complex work', color: '#f59e0b' }, + { id: 'pilot', label: 'Pilot', description: 'Use an expanded budget for broad implementation or investigation', color: '#a78bfa' }, + { id: 'enterprise', label: 'Enterprise', description: 'Use the largest built-in budget for exhaustive work', color: '#ef4444' }, ]; export function ChatInput({ loading, mode, - autonomyPreset, + approvalMode, activeDepth, tokenUsage, pinnedContext, @@ -63,7 +116,7 @@ export function ChatInput({ onSend, onStop, onModeChange, - onAutonomyPresetChange, + onApprovalModeChange, onDepthChange, onRetry, onCopyResponse, @@ -81,11 +134,12 @@ export function ChatInput({ const [mentionStart, setMentionStart] = useState(null); const [searchRequestId, setSearchRequestId] = useState(null); const [attachments, setAttachments] = useState([]); + const [openSelect, setOpenSelect] = useState(null); const textareaRef = useRef(null); const fileInputRef = useRef(null); - const activeMode = MODES.find((m) => m.id === mode) ?? MODES[0]; - const activeAutonomy = - AUTONOMY_PRESET_OPTIONS.find((option) => option.id === autonomyPreset) ?? AUTONOMY_PRESET_OPTIONS[1]; + const visibleMode = mode === 'review' ? 'plan' : mode; + const activeMode = MODES.find((m) => m.id === visibleMode) ?? MODES[1]; + const activeApproval = APPROVAL_OPTIONS.find((option) => option.id === approvalMode) ?? APPROVAL_OPTIONS[0]; const selectedDepth = DEPTH_OPTIONS.find((option) => option.id === activeDepth) ?? DEPTH_OPTIONS[0]; useEffect(() => { @@ -200,6 +254,89 @@ export function ChatInput({ } }; + const renderDropdown = ({ + id, + label, + value, + selected, + options, + onChange, + }: { + id: ComposerSelectId; + label: string; + value: T; + selected: ComposerOption; + options: Array>; + onChange: (value: T) => void; + }) => { + const SelectedIcon = selected.icon; + const isOpen = openSelect === id; + return ( +
{ + if (!e.currentTarget.contains(e.relatedTarget as Node | null)) { + setOpenSelect((current) => (current === id ? null : current)); + } + }} + > + + {isOpen && ( +
+ {options.map((option) => { + const OptionIcon = option.icon; + const selectedOption = option.id === value; + return ( + + ); + })} +
+ )} +
+ ); + }; + return (
-
- - -
-
- - -
-
- - -
+ {renderDropdown({ + id: 'mode', + label: 'Mode', + value: visibleMode, + selected: activeMode, + options: MODES, + onChange: (nextMode) => onModeChange(nextMode), + })} + {renderDropdown({ + id: 'approval', + label: 'Approval', + value: approvalMode, + selected: activeApproval, + options: APPROVAL_OPTIONS, + onChange: (nextApprovalMode) => onApprovalModeChange(nextApprovalMode), + })} + {renderDropdown({ + id: 'depth', + label: 'Depth', + value: activeDepth, + selected: selectedDepth, + options: DEPTH_OPTIONS, + onChange: (nextDepth) => onDepthChange(nextDepth), + })}
diff --git a/src/webview-ui/src/components/IconButton.tsx b/src/webview-ui/src/components/IconButton.tsx index 1b8eab33..5215eed5 100644 --- a/src/webview-ui/src/components/IconButton.tsx +++ b/src/webview-ui/src/components/IconButton.tsx @@ -18,9 +18,9 @@ export function IconButton({ return (