diff --git a/.github/workflows/mitii-agent.yml b/.github/workflows/mitii-agent.yml new file mode 100644 index 00000000..e2ed1261 --- /dev/null +++ b/.github/workflows/mitii-agent.yml @@ -0,0 +1,28 @@ +name: mitii agent + +on: + issue_comment: + types: [created] + +jobs: + mitii: + if: contains(github.event.comment.body, '/mitii') + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + issues: write + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm run compile:cli + - run: node dist/cli.js agent "${{ github.event.comment.body }}" --provider echo --approval auto --json > mitii-events.jsonl + - uses: actions/upload-artifact@v4 + with: + name: mitii-audit + path: mitii-events.jsonl diff --git a/.github/workflows/native-binaries.yml b/.github/workflows/native-binaries.yml new file mode 100644 index 00000000..e760a735 --- /dev/null +++ b/.github/workflows/native-binaries.yml @@ -0,0 +1,38 @@ +name: native binaries + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - os: macos-14 + target: darwin-arm64 + - os: macos-13 + target: darwin-x64 + - os: ubuntu-latest + target: linux-x64 + - os: windows-latest + target: win32-x64 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm --filter mitii build:platform + env: + MITII_PLATFORM_TARGET: ${{ matrix.target }} + - uses: actions/upload-artifact@v4 + with: + name: mitii-${{ matrix.target }} + path: dist-native/${{ matrix.target }} diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml new file mode 100644 index 00000000..8ba33b61 --- /dev/null +++ b/.github/workflows/npm-publish.yml @@ -0,0 +1,32 @@ +name: npm publish + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + registry-url: https://registry.npmjs.org + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm sync:versions + - run: pnpm -r build + - run: pnpm test + - run: npm publish --workspace packages/sdk --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - run: npm publish --workspace packages/daemon --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9df92d3f..f995d8a5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,7 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm run compile - - run: pnpm exec vsce package + - run: pnpm run package - name: Generate release notes run: node dist/cli.js changelog > release-notes.md - uses: softprops/action-gh-release@v2 diff --git a/.gitignore b/.gitignore index 4c331ae6..fa703b86 100644 --- a/.gitignore +++ b/.gitignore @@ -7,8 +7,17 @@ dist/ .thunder/ .thunder V2/ .mitii +.mitii/ +.mitti/ project-goals/ mitii-debug-logs/ +vendor/ +tmp/ +logs/ +build/ +coverage/ +*.lock +*.map # Benchmark / eval runtime (local generation and reports) tools/benchmark/tasks/eval/generated/ diff --git a/.mitiiignore b/.mitiiignore new file mode 100644 index 00000000..448e81ac --- /dev/null +++ b/.mitiiignore @@ -0,0 +1,11 @@ +# Mitii workspace indexing ignores +node_modules/ +dist/ +build/ +.next/ +coverage/ +vendor/ +tmp/ +logs/ +*.lock +*.map diff --git a/README.md b/README.md index b5e186d0..1b4c2d65 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ License: AGPL v3 VS Code 1.85+ Node 20+ - Version 2.7.29 + Version 2.7.50 Website Docs

@@ -33,6 +33,49 @@ Mitii is built for developers who want an AI agent that understands the repo bef 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. +## Phase 2 Runtime + +Mitii can run as a shared HTTP/SSE daemon: + +```bash +mitii serve --cwd /path/to/project +curl http://127.0.0.1:4310/health +``` + +External clients can use `@mitii/sdk/daemon` to create sessions, stream events with replay, respond to approvals, and cancel turns. + +Phase 2 also adds typed subagents through `spawn_subagent` while preserving `spawn_research_agent` as a compatibility alias. Built-ins are `research`, `implementer`, `reviewer`, and `verifier`; workspace agents can be added under `.mitii/agents/`. + +Parallel worktree tasks are available from the CLI: + +```bash +mitii task add "Implement feature" --prompt "Implement the scoped feature and verify it" +mitii task run --parallel 2 +mitii task worktrees +``` + +See `docs/users/mitii-serve.md`, `docs/developers/mitii-serve-protocol.md`, and `docs/users/parallel-agents.md`. + +## Phase 3 Platform Maturity + +Phase 3 adds distribution, scale, external workflow, and enterprise surfaces while keeping legacy `thunder.*` compatibility. + +| Area | What is available | +|---|---| +| Native distribution | `packages/cli`, platform optional package manifests, release matrix workflow, `scripts/install.sh`, `scripts/install.ps1`, and `pnpm run sync:versions` | +| npm publishing | `@mitii/sdk`, `@mitii/daemon`, board/channels package metadata, and `.github/workflows/npm-publish.yml` | +| Brand migration | `mitii.*` settings and command IDs are primary; `thunder.*` reads still fall back and `Mitii: Migrate Settings from thunder.* to mitii.*` copies old values | +| Indexing at scale | Daemon-backed index worker API: `GET /index/status`, `POST /index/enqueue`, `POST /index/delete`, `POST /index/repair` | +| CLI index admin | `mitii index status --json`, `mitii index repair`, `mitii index enqueue [paths...]`, `mitii index watch` | +| GitHub PR flow | `GitHubPullRequestService` and `mitii pr create --title "..." --body-file .mitii/pr-body.md` | +| Async jobs | Durable `.mitii/jobs/queue.json`, `mitii job enqueue`, `mitii job list`, and `mitii worker --once` | +| Channels | Shared channel runtime plus Telegram polling connector: `mitii connect telegram --token "$TELEGRAM_BOT_TOKEN"` | +| Persistent teams | `~/.mitii/teams//manifest.json`, task board, mailbox, mission log, and `mitii team ...` commands | +| Marketplace | Open VSX publish script, native binary workflow, GitHub Actions example, Cursor env rename, and JetBrains companion workflow docs | +| Enterprise scale | Managed policy docs, channel security docs, and air-gapped bundle script | + +Default external-write surfaces remain opt-in: auto PR creation, persistent teams, and channel connectors must be explicitly enabled or started. + **Docs:** [docs.mitii.dev](https://docs.mitii.dev) **Website:** [mitii.dev](https://mitii.dev) **Discord:** [discord.gg/sa8rubf6HH](https://discord.gg/sa8rubf6HH) @@ -88,6 +131,7 @@ flowchart LR Context --> Index[SQLite + FTS5 + Symbols] Context --> Vectors[MiniLM or Hash Vectors] Context --> RepoMap[PageRank Repo Map] + Context --> CallGraph[Call Graph] Context --> Git[Git Diff + SCM] Context --> Issues[GitHub Issues] Context --> LSP[Diagnostics] @@ -114,8 +158,9 @@ Mitii creates a useful working map of your repository before it asks the model t | Full-text search | SQLite FTS5 with ripgrep fallback for paths not yet indexed | | Symbol extraction | TypeScript, JavaScript, Python, Java, Go; tree-sitter with regex fallback | | Repo map | PageRank-style scoring to surface structurally important files | +| Call graph | `CallGraphContextSource` resolves symbols and callers through the VS Code language service, with unsaved-editor content sync | | Vector search | MiniLM embeddings through `@xenova/transformers`, with hash fallback | -| Vector backend | SQLite by default, LanceDB optional | +| Vector backend | LanceDB by default, SQLite optional; embedding/vector components report runtime health and surface a "degraded" state in Settings if native modules fail to load | | Context reranking | Trims noisy candidates, for example top 20 down to top 8 | | Token budgeting | Keeps context useful without blindly flooding the model | @@ -152,7 +197,7 @@ 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 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 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. +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 `mitii.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 @@ -196,13 +241,14 @@ Mitii stores useful state locally so every serious task does not start from zero | System | What it stores | |---|---| -| Memory | Decisions, facts, observations, touched files | +| Memory | Decisions, facts, observations, touched files in SQLite | +| Markdown auto-memory | Human-readable summaries in `~/.mitii/projects//memory/` or `.mitii/auto-memory/` | | Session history | `agent_sessions` and `agent_turns` in SQLite | | Plans | `task_plans` in SQLite and `.mitii/tasks/` files | | Logs | JSONL session logs in `.mitii/logs/` | | Checkpoints | File-copy, git-stash, or shadow-git strategies before writes | -Post-task memory extraction can capture useful observations after completed work, so future sessions can reuse decisions without asking you to repeat context. +Post-task memory extraction can capture useful observations after completed work, so future sessions can reuse decisions without asking you to repeat context. Markdown auto-memory is optional, secret-filtered, and controlled by `mitii.memory.autoMemoryEnabled` plus `mitii.memory.autoMemoryScope`. 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 `. @@ -241,8 +287,10 @@ Mitii can preload keyless MCP servers: | `filesystem` | Scoped file access for the open workspace | | `memory` | Cross-session knowledge graph | | `sequential-thinking` | Structured reasoning helper | +| `puppeteer` | Optional browser automation | +| `agentmemory` | Optional enterprise memory backend at `http://localhost:3111/mcp` | -You can add custom servers with `thunder.mcp.servers`, `.mitii/mcp.json`, or `.mcp.json`. MCP tools appear as `mcp__server__tool` and still pass through the same approval policy. +You can add custom servers with `mitii.mcp.servers`, `.mitii/mcp.json`, or `.mcp.json`. MCP tools appear as `mcp__server__tool` and still pass through the same approval policy. See [docs/integrations/agentmemory.md](docs/integrations/agentmemory.md) for the optional agentmemory setup. ### 9. Developer UI @@ -260,10 +308,11 @@ The sidebar is a React webview with: | Token meter | Understand context usage | | Indexing status | Know when the workspace brain is ready | | Context warnings | See when context may be thin or over budget | +| Code block copy | One-click clipboard copy on rendered code blocks in chat | -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. +Reasoning deltas from supported providers stream live in the chat UI. Use `mitii.ui.showReasoning` and `mitii.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. +Mitii also detects common model capabilities from the provider/model name, including vision and reasoning support. Enterprise teams can override detection with `mitii.provider.supportsVision` and `mitii.provider.supportsReasoning` when routing through private or custom OpenAI-compatible gateways. ## Enterprise Readiness @@ -271,13 +320,13 @@ Enterprise review materials live in [docs/enterprise](docs/enterprise/README.md) | 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` | -| Auto-export audit packs after agent turns | `thunder.enterprise.autoExportAuditPackOnSessionEnd` | +| Route narrow Git/release tasks through minimal context | `mitii.context.microTaskRoutingEnabled` | +| Require local model providers | `mitii.enterprise.localProvidersOnly` | +| Strip file contents from exported audit packs | `mitii.enterprise.stripFileContentsFromAuditPacks` | +| Auto-export audit packs after agent turns | `mitii.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` | +| Disable session logging | `mitii.telemetry.sessionLogging` | +| Stream sanitized SIEM events | `mitii.telemetry.webhookUrl` and optional `mitii.telemetry.webhookSecret` | | Export audit evidence | `Mitii: Export Audit Pack` | | Windows smoke checklist | [docs/qa/WINDOWS_SMOKE.md](docs/qa/WINDOWS_SMOKE.md) | @@ -396,6 +445,26 @@ This is why Mitii focuses on visible plans, local logs, checkpoints, and verific ## Quick Start +### Install + +Release channels: + +```bash +curl -fsSL https://mitii.dev/install.sh | bash +npm i -g mitii +brew install mitii/tap/mitii +``` + +Editor marketplaces: + +```bash +pnpm run package # local VSIX +pnpm run publish:vsce # VS Code Marketplace +pnpm run publish:ovsx # Open VSX +``` + +Native release assets are produced by `.github/workflows/native-binaries.yml`; npm package publishing is handled by `.github/workflows/npm-publish.yml`. + **Requirements** | Tool | Version | @@ -415,9 +484,9 @@ pnpm run setup ### 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`, `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`. +2. Set `mitii.provider.type` to `openai-compatible`, `openrouter`, `azure-openai`, or another supported provider. +3. Point `mitii.provider.baseUrl` at your endpoint. The default is `http://localhost:11434/v1` for Ollama. +4. Set `mitii.provider.model`. The default is `qwen3-coder:30b`. Use the Echo provider for UI testing without an LLM. API keys are stored through VS Code SecretStorage. @@ -428,8 +497,8 @@ Use the Echo provider for UI testing without an LLM. API keys are stored through | 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 | +| Azure OpenAI | `your-deployment-name` | API key required; model field is the deployment name; uses `mitii.provider.apiVersion` | +| AWS Bedrock | `anthropic.claude-3-5-sonnet-20240620-v1:0` | Uses AWS default credential chain and `mitii.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 | @@ -452,6 +521,20 @@ Use the Echo provider for UI testing without an LLM. API keys are stored through | `Mitii: Accept Inline Diff` | Accept a pending inline diff | | `Mitii: Reject Inline Diff` | Reject a pending inline diff | | `Mitii: Generate Commit Message` | Generate a commit message from Source Control | +| `Mitii: Migrate Settings from thunder.* to mitii.*` | Copy legacy settings into the new namespace without deleting old values | + +CLI additions: + +```bash +mitii index status --json +mitii index repair +mitii pr create --title "Fix issue" --body-file .mitii/pr-body.md +mitii job enqueue "Run this later" --mode agent +mitii worker --once +mitii connect telegram --token "$TELEGRAM_BOT_TOKEN" +mitii team create sprint +mitii team status sprint +``` --- @@ -459,27 +542,37 @@ Use the Echo provider for UI testing without an LLM. API keys are stored through ```json { - "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", - "thunder.indexing.autoIndexOnOpen": true, - "thunder.indexing.vectorsEnabled": true, - "thunder.indexing.vectorBackend": "sqlite", - "thunder.context.rerankerEnabled": true, - "thunder.memory.enabled": true, - "thunder.mcp.enabled": true, - "thunder.github.issueFetchEnabled": true, - "thunder.github.issueCommentLimit": 8, - "thunder.github.tokenRef": "thunder.github.token", - "thunder.agent.verifyCommands": ["npm run lint", "npm test"], - "thunder.telemetry.sessionLogging": true, - "thunder.telemetry.webhookUrl": "", - "thunder.enterprise.autoExportAuditPackOnSessionEnd": false + "mitii.provider.type": "openai-compatible", + "mitii.provider.baseUrl": "http://localhost:11434/v1", + "mitii.provider.model": "qwen3-coder:30b", + "mitii.provider.apiVersion": "2024-10-21", + "mitii.provider.region": "us-east-1", + "mitii.provider.contextWindow": 8192, + "mitii.safety.autonomyPreset": "guided", + "mitii.safety.approvalMode": "review_all", + "mitii.indexing.autoIndexOnOpen": true, + "mitii.indexing.vectorsEnabled": true, + "mitii.indexing.vectorBackend": "lancedb", + "mitii.indexing.watchDebounceMs": 500, + "mitii.indexing.priorityPaths": [], + "mitii.context.rerankerEnabled": true, + "mitii.memory.enabled": true, + "mitii.memory.summarizeAfterTask": true, + "mitii.memory.autoMemoryEnabled": true, + "mitii.memory.autoMemoryScope": "user", + "mitii.mcp.enabled": true, + "mitii.github.issueFetchEnabled": true, + "mitii.github.issueCommentLimit": 8, + "mitii.github.tokenRef": "mitii.github.token", + "mitii.github.autoPrEnabled": false, + "mitii.github.defaultBaseBranch": "", + "mitii.agent.verifyCommands": ["npm run lint", "npm test"], + "mitii.agent.teamsEnabled": false, + "mitii.telemetry.sessionLogging": true, + "mitii.telemetry.webhookUrl": "", + "mitii.enterprise.autoExportAuditPackOnSessionEnd": false, + "mitii.enterprise.channelsDisabled": false, + "mitii.enterprise.maxParallel": 10 } ``` @@ -493,19 +586,29 @@ Mitii automatically picks up common project instruction files: | File or folder | Purpose | |---|---| -| `AGENTS.md` | Agent instructions | -| `CLAUDE.md` | Claude-style project guidance | -| `WARP.md` | Warp-style workflow guidance | -| `.cursorrules` | Cursor rules | -| `.cursor/rules` | Cursor rule directory | -| `.clinerules` | Cline rules | -| `.continue/rules` | Continue rules | -| `.mitii/rules` | Mitii project rules | -| `.mitii/agents` | Agent-specific instructions | -| `.mitii/checks` | Verification guidance | -| `.mitii/prompts` | Reusable prompts | - -Commit these files to your repo when you want every Mitii session to start with the same engineering conventions. +| `~/.mitii/MITTII.md` | Personal global instructions | +| `MITII.md` | Shared workspace instructions | +| `AGENTS.md` | Compatibility instructions | +| `CLAUDE.md` | Compatibility instructions | +| `.mitii/rules/**/*.md` | Shared Mitii rule directory | +| `.cursor/rules/**/*.md` | Cursor rule compatibility | +| `.mitii/MITTII.local.md` | Personal workspace override, loaded last | + +Commit shared files to your repo when you want every Mitii session to start with the same engineering conventions. `@path/to/file` references inside rule files are resolved relative to the rule file and inlined within the rules budget. + +## SDK And CLI + +The publishable SDK lives in `packages/sdk` and exposes `createClient()` plus `query()` for Node 20+ projects: + +```ts +import { query } from '@mitii/sdk'; + +for await (const event of query({ cwd: process.cwd(), prompt: 'Summarize the repo', mode: 'agent' })) { + if (event.type === 'assistant_delta') process.stdout.write(event.content); +} +``` + +The CLI uses the same SDK contract for one-shot commands. `mitii agent "..." --json` emits one typed event per line. Running `mitii` with no args opens an interactive terminal session; `mitii init` creates a starter `MITII.md`; `mitii auth` stores headless provider credentials in `~/.mitii/credentials.json`; `mitii memory connect agentmemory` wires the optional agentmemory MCP entry. --- @@ -564,7 +667,8 @@ mitii-ai-agent/ # VS Code extension (ships as .vsix) │ ├── fixtures/ # Pinned sample repos │ ├── tasks/enterprise/ # ~26 fixed benchmark tasks │ └── tasks/eval/ # Generated 500–1000 task shards -├── pnpm-workspace.yaml # tools/* workspace packages +├── packages/sdk/ # @mitii/sdk headless runtime package +├── pnpm-workspace.yaml # tools/* and packages/* workspace packages └── package.json ``` @@ -592,8 +696,11 @@ pnpm run eval:generate # generate 500 standard eval tasks pnpm run eval:smoke # eval wiring check (CI) pnpm run eval:standard -- --provider openai-compatible \ --base-url http://localhost:11434/v1 --model qwen3-coder:30b --limit 50 +pnpm run eval:retrieval # Recall@5/10, nDCG@10, MRR for HybridRetriever against a hand-labeled query set ``` +`eval:retrieval` runs `test/benchmark/retrieval-eval.test.ts` against the `saas-api` fixture (112 files, 17 domain modules) and 68 hand-labeled queries in `tools/benchmark/datasets/retrieval-eval.json`, writing `.mitii/benchmark/retrieval-report.{json,md}`. It is additive instrumentation only — HybridRetriever's actual ranking logic is unchanged. + ### Native Rebuilds VS Code and Cursor ship their own Electron runtime, so native modules may need a rebuild. @@ -601,14 +708,14 @@ VS Code and Cursor ship their own Electron runtime, so native modules may need a | Scenario | Command | |---|---| | VS Code Extension Development Host | `pnpm run rebuild:native` | -| Cursor Extension Development Host | `THUNDER_EDITOR=cursor pnpm run rebuild:native` | +| Cursor Extension Development Host | `MITII_EDITOR=cursor pnpm run rebuild:native` | | Local Vitest runs | `pnpm run rebuild:node` | | Everything | `pnpm run rebuild:all` | On Linux and Windows, Electron version auto-detection is not available. Set the version explicitly: ```bash -THUNDER_ELECTRON_VERSION= pnpm run rebuild:native +MITII_ELECTRON_VERSION= pnpm run rebuild:native ``` For example, use the Electron version shipped by your VS Code or Cursor build. @@ -634,7 +741,7 @@ Bundled skills orchestrate these scripts instead of replacing them. `audit-clean | Problem | Fix | |---|---| -| `better-sqlite3` fails to load | Run `pnpm run rebuild:native` for VS Code or `THUNDER_EDITOR=cursor pnpm run rebuild:native` for Cursor | +| `better-sqlite3` fails to load | Run `pnpm run rebuild:native` for VS Code or `MITII_EDITOR=cursor pnpm run rebuild:native` for Cursor | | Provider errors | Check base URL, model name, and API key. Try Echo provider to isolate UI issues | | Indexing feels empty | Check `.gitignore`, `.mitiiignore`, workspace write access, then run `Mitii: Index Workspace` | | Context feels thin | Wait for indexing, enable vectors, check context warnings, and mention important files directly | diff --git a/docs/deploy/launchd.plist b/docs/deploy/launchd.plist new file mode 100644 index 00000000..27e941ad --- /dev/null +++ b/docs/deploy/launchd.plist @@ -0,0 +1,22 @@ + + + + + Label + dev.mitii.serve + ProgramArguments + + /usr/bin/env + mitii + serve + --hostname + 127.0.0.1 + --port + 4310 + + RunAtLoad + + KeepAlive + + + diff --git a/docs/deploy/systemd-user.service b/docs/deploy/systemd-user.service new file mode 100644 index 00000000..6ddfaec9 --- /dev/null +++ b/docs/deploy/systemd-user.service @@ -0,0 +1,11 @@ +[Unit] +Description=Mitii daemon + +[Service] +Type=simple +EnvironmentFile=%h/.mitii-serve-env +ExecStart=/usr/bin/env mitii serve --cwd %h/project --hostname 127.0.0.1 --port 4310 +Restart=on-failure + +[Install] +WantedBy=default.target diff --git a/docs/developers/mitii-serve-protocol.md b/docs/developers/mitii-serve-protocol.md new file mode 100644 index 00000000..8951bc64 --- /dev/null +++ b/docs/developers/mitii-serve-protocol.md @@ -0,0 +1,75 @@ +# Mitii Serve Protocol + +`mitii serve` runs one daemon per bound workspace. It exposes a loopback-first HTTP API plus per-session SSE event streams. + +Default bind: `127.0.0.1:4310`. + +## Security + +- Loopback is the default. +- Binding to `0.0.0.0` or another non-loopback host requires `--insecure-bind`. +- Non-loopback binds also require `--token` or `MITII_SERVER_TOKEN`. +- Clients authenticate with `Authorization: Bearer `. +- CORS is denied unless `--allow-origin` is set. +- Daemon actions are appended to `.mitii/daemon/audit.jsonl`. + +## Routes + +`GET /health` + +```json +{ "ok": true, "version": "2.7.31", "cwd": "/repo", "sessions": 1 } +``` + +`GET /capabilities` + +```json +{ + "features": ["sessions", "sse", "permissions", "cancel", "subagents", "worktrees"], + "maxSessions": 5, + "supportedModes": ["ask", "plan", "agent", "review"], + "eventReplay": true +} +``` + +`POST /session` + +```json +{ "cwd": "/repo", "mode": "agent", "approval": "manual", "runtime": "real" } +``` + +`GET /sessions`, `GET /session/:id`, and `DELETE /session/:id` list, inspect, and close sessions. + +`POST /session/:id/prompt` + +```json +{ "mode": "agent", "message": "Fix the failing test", "attachments": [] } +``` + +Returns `202` once the prompt is accepted. Results stream through SSE. + +`GET /session/:id/events` + +SSE frames use `id`, `event`, and JSON `data` fields. Reconnect with `Last-Event-ID` to replay buffered missed events. + +```text +id: 7 +event: assistant_delta +data: {"type":"assistant_delta","content":"I found the issue"} +``` + +`POST /session/:id/permissions/:approvalId/respond` + +```json +{ "decision": "approved" } +``` + +`POST /session/:id/cancel` aborts an in-flight turn. + +## Error Shape + +```json +{ "error": { "code": "workspace_mismatch", "message": "Daemon is bound to ..." } } +``` + +Common statuses: `400` workspace mismatch, `401` auth failure, `404` missing session, `409` concurrent prompt, `503` session limit. diff --git a/docs/enterprise/PROCUREMENT_FAQ.md b/docs/enterprise/PROCUREMENT_FAQ.md index 1822b3aa..c19a2511 100644 --- a/docs/enterprise/PROCUREMENT_FAQ.md +++ b/docs/enterprise/PROCUREMENT_FAQ.md @@ -10,18 +10,18 @@ Mitii targets macOS, Linux, and Windows. CI runs `npm test` on `ubuntu-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. +Mitii supports OpenAI-compatible localhost providers such as Ollama and LM Studio. Set `mitii.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` | +| Disable session logging | `mitii.telemetry.sessionLogging` | +| Disable verbose diagnostics | `mitii.telemetry.debugMetrics` | +| Require approval for writes | `mitii.safety.requireApprovalForWrites` | +| Require approval for shell | `mitii.safety.requireApprovalForShell` | +| Local providers only | `mitii.enterprise.localProvidersOnly` | +| Strip file contents from audit packs | `mitii.enterprise.stripFileContentsFromAuditPacks` | | Export review evidence | `Mitii: Export Audit Pack` | ## License diff --git a/docs/enterprise/README.md b/docs/enterprise/README.md index d45ea43f..a52633ed 100644 --- a/docs/enterprise/README.md +++ b/docs/enterprise/README.md @@ -7,5 +7,5 @@ This folder is the reviewer-facing security, compliance, and procurement packet - [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. +Key controls are implemented as VS Code settings under `mitii.telemetry.*`, `mitii.safety.*`, `mitii.enterprise.*`, and the `Mitii: Export Audit Pack` command. diff --git a/docs/enterprise/SECURITY.md b/docs/enterprise/SECURITY.md index 810fe3f5..01a727e6 100644 --- a/docs/enterprise/SECURITY.md +++ b/docs/enterprise/SECURITY.md @@ -4,15 +4,15 @@ Mitii is local-first. Workspace indexes, memory, plans, checkpoints, and JSONL s ## 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. +Only prompt data sent to the configured LLM provider leaves the machine. The provider boundary is controlled by `mitii.provider.*`. Enterprises can enable `mitii.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`. +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 `mitii.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`. +MCP tools are routed through Mitii tool policy. File writes and mutating shell commands require approval according to `mitii.safety.approvalMode` and `mitii.safety.requireApprovalForWrites`. ## Auditability diff --git a/docs/enterprise/channel-security.md b/docs/enterprise/channel-security.md new file mode 100644 index 00000000..b4b8ba94 --- /dev/null +++ b/docs/enterprise/channel-security.md @@ -0,0 +1,15 @@ +# Channel Security + +Channel connectors route Slack, Telegram, Discord, and similar messages into the daemon. Treat them as external ingress. + +Baseline controls: + +| Control | Mitii behavior | +| --- | --- | +| User allowlist | Connectors can reject users not present in `allowedUsers`. | +| Thread allowlist | Connectors can restrict use to approved channel/thread IDs. | +| Read-only mode | Channel policy can downgrade `agent` requests to `plan`. | +| Secret redaction | Long token-like values are redacted from outbound replies. | +| Auditability | Channel prompts flow through daemon sessions and session logs. | + +Enterprise deployments should keep connectors bound to loopback daemons or require `MITII_SERVER_TOKEN` for non-loopback hosts. diff --git a/docs/enterprise/daemon-security.md b/docs/enterprise/daemon-security.md new file mode 100644 index 00000000..50af45ca --- /dev/null +++ b/docs/enterprise/daemon-security.md @@ -0,0 +1,12 @@ +# Daemon Security + +`mitii serve` is designed for local development first. + +- It binds to `127.0.0.1` by default. +- Non-loopback binds require both `--insecure-bind` and a bearer token. +- Bearer tokens are compared with constant-time comparison. +- CORS is opt-in through `--allow-origin`. +- Session `cwd` values are canonicalized and must match the daemon-bound workspace. +- Session lifecycle, prompts, cancellation, and permission responses are logged to `.mitii/daemon/audit.jsonl`. + +For enterprise-managed installs, prefer a per-user daemon, managed token provisioning, local provider policy enforcement, and workspace-level audit pack export. diff --git a/docs/enterprise/managed-policy.md b/docs/enterprise/managed-policy.md new file mode 100644 index 00000000..1878f90d --- /dev/null +++ b/docs/enterprise/managed-policy.md @@ -0,0 +1,27 @@ +# Managed Enterprise Policy + +Mitii can be governed with a managed `.mitii/mitii.policy.json` file distributed by MDM, base images, or repository templates. Policy values are intentionally coarse and override user-facing convenience features before a connector, worker, or team runtime starts. + +```json +{ + "localProvidersOnly": true, + "channelsDisabled": false, + "maxParallel": 10, + "autoPrEnabled": false, + "stripFileContentsFromAuditPacks": true, + "allowedChannelUsers": [], + "allowedChannelThreads": [] +} +``` + +Recommended controls: + +| Policy | Purpose | +| --- | --- | +| `localProvidersOnly` | Require local or approved private model providers. | +| `channelsDisabled` | Disable Slack, Telegram, Discord, and similar external surfaces. | +| `maxParallel` | Cap parallel sessions, workers, and teammates. | +| `autoPrEnabled` | Keep PR creation explicit unless centrally approved. | +| `stripFileContentsFromAuditPacks` | Preserve audit metadata while reducing data exposure. | + +Phase 3 treats this as the stable policy contract. Future SSO/OIDC and HA daemon work should extend this file instead of introducing a second policy surface. diff --git a/docs/integrations/agentmemory.md b/docs/integrations/agentmemory.md new file mode 100644 index 00000000..537e7c92 --- /dev/null +++ b/docs/integrations/agentmemory.md @@ -0,0 +1,42 @@ +# agentmemory Integration + +Mitii can use agentmemory as an optional MCP backend for cross-agent or team memory. The built-in Mitii memory remains the simplest option for one workspace because it uses local SQLite plus markdown auto-memory files. + +Use agentmemory when you need shared recall across tools, a long-running memory server, or knowledge-graph style memory. Mitii connects through MCP: + +```text +Mitii -> MCP streamable HTTP -> agentmemory -> iii-engine +``` + +## Enable + +Start agentmemory on port `3111`, then run: + +```bash +mitii memory connect agentmemory +``` + +This writes `.mitii/mcp.json`: + +```json +{ + "mcpServers": { + "agentmemory": { + "type": "streamable-http", + "url": "http://localhost:3111/mcp", + "disabled": false + } + } +} +``` + +You can also enable the built-in `agentmemory` toggle in Mitii settings. It is off by default and does not conflict with the built-in `memory` MCP server. + +## Verify + +```bash +curl http://localhost:3111/agentmemory/livez +mitii agent "remember this project uses pnpm" --json --approval auto +``` + +If the server is not running, Mitii shows the MCP startup error in Settings. Data sent to agentmemory leaves Mitii's local SQLite store and enters the agentmemory service, so review enterprise retention and audit policies before enabling it for sensitive workspaces. diff --git a/docs/integrations/github-actions.md b/docs/integrations/github-actions.md new file mode 100644 index 00000000..25f34674 --- /dev/null +++ b/docs/integrations/github-actions.md @@ -0,0 +1,25 @@ +# GitHub Actions + +Mitii can run headlessly in CI for comment-triggered tasks. + +```yaml +on: + issue_comment: + types: [created] + +jobs: + mitii: + if: contains(github.event.comment.body, '/mitii') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: pnpm install --frozen-lockfile + - run: pnpm run compile:cli + - run: node dist/cli.js agent "${{ github.event.comment.body }}" --provider echo --approval auto --json +``` + +For real providers, pass secrets through environment variables and keep approval mode conservative on public repositories. diff --git a/docs/logging-system.md b/docs/logging-system.md new file mode 100644 index 00000000..99208872 --- /dev/null +++ b/docs/logging-system.md @@ -0,0 +1,187 @@ +# Mitii AI Agent Logging System + +## Overview + +The Mitii AI agent uses a structured logging system to record session activities for debugging, analysis, and post-hoc evaluation. All logs are stored in JSONL (JSON Lines) format in the workspace-specific `.mitii/logs/` directory. + +## File Structure + +Logs are stored in the following location: +``` +/.mitii/logs/-.jsonl +``` + +Where: +- `` is the current working directory of the agent +- `` is a timestamp in format `YYYY-MM-DD_HH-MM-SS` +- `` is a unique identifier for the session + +## JSONL Format + +Each log entry is a single JSON object on its own line in the file. The structure includes: + +```json +{ + "ts": 1700000000000, + "time": "2023-11-15T10:00:00.000Z", + "sessionId": "session-12345", + "type": "user_message", + "message": "Hello there", + "data": { + "content": "Hello there" + } +} +``` + +### Required Fields +- `ts`: Unix timestamp of when the event occurred +- `time`: ISO formatted timestamp +- `sessionId`: Unique identifier for the session +- `type`: Type of event (see below) +- `message`: Human-readable description of the event + +### Optional Fields +- `data`: Additional structured data related to the event + +## SessionLogEvent Types + +The logging system supports 21+ event types: + +```typescript +export type SessionLogEventType = + | 'session_start' + | 'session_end' + | 'user_message' + | 'assistant_message' + | 'tool_start' + | 'tool_end' + | 'subagent_start' + | 'subagent_end' + | 'approval_request' + | 'approval_decision' + | 'plan_created' + | 'plan_step' + | 'context_pack' + | 'token_usage' + | 'process_start' + | 'process_end' + | 'timing' + | 'error' + | 'info' + | 'workspace_resolved' + | 'index_start' + | 'index_complete' + | 'turn_complete' + | 'ui_trace' + | 'microtask_context' + | 'audit_export'; +``` + +### Event Type Descriptions + +- **session_start**: Agent session begins +- **session_end**: Agent session ends +- **user_message**: User sends a message to the agent +- **assistant_message**: Agent responds to user +- **tool_start**: Tool execution begins +- **tool_end**: Tool execution completes +- **subagent_start**: Sub-agent execution begins +- **subagent_end**: Sub-agent execution completes +- **approval_request**: Approval requested from user +- **approval_decision**: User makes approval decision +- **plan_created**: Execution plan created +- **plan_step**: Plan step executed +- **context_pack**: Context pack loaded +- **token_usage**: Token usage statistics +- **process_start**: Process execution begins +- **process_end**: Process execution completes +- **timing**: Timing information for operations +- **error**: Error occurred during execution +- **info**: General informational message +- **workspace_resolved**: Workspace resolved and configured +- **index_start**: Indexing operation begins +- **index_complete**: Indexing operation completes +- **turn_complete**: Conversation turn completed +- **ui_trace**: UI trace information +- **microtask_context**: Microtask context information +- **audit_export**: Audit export operation + +## Security and Privacy Measures + +### Append-Only Mode +The logging system uses append-only file mode (`appendFileSync`) to prevent accidental overwrites. + +### Directory Creation +Directories are automatically created recursively using `{ recursive: true }` to ensure the log path exists before writing. + +### Secret Redaction +Sensitive information is automatically redacted from logs: +- API keys, passwords, tokens are removed or masked +- Token usage metrics are preserved for analysis +- No sensitive data is written to log files + +### Workspace Isolation +Each workspace maintains its own isolated logging directory structure, preventing cross-contamination between different projects. + +## Access Control and Security + +### Why Specific Log Files Cannot Be Accessed + +Several factors can prevent access to specific JSONL log files: + +1. **Missing Directory Structure**: The `.mitii` directory may not exist in the workspace +2. **File Permissions**: Process may lack write permissions to the workspace directory +3. **Workspace Path Issues**: Invalid or non-existent workspace paths passed to logging system +4. **Process Isolation**: In sandboxed environments (VS Code extensions), file access may be restricted by security policies +5. **Session Context**: Log files are only created when a session is active + +### Error Handling + +The logging system implements graceful error handling: +- File operations that fail do not crash the execution +- Errors are logged internally but execution continues +- The system attempts to create directories recursively before writing +- Path validation ensures safe construction using `join()` + +## Troubleshooting + +### Common Issues and Solutions + +1. **"No such file or directory" errors**: + - Ensure the workspace directory exists and is accessible + - Check that the process has write permissions to the workspace + +2. **Permission denied errors**: + - Verify file system permissions on the workspace directory + - Run with appropriate user privileges if needed + +3. **Log files not appearing**: + - Confirm that logging is enabled in the configuration + - Ensure a valid session ID is being used + - Check that the agent has started and is actively running + +### Verification Commands + +To verify the logging system is working: + +```bash +# Check if logs directory exists +ls -la /.mitii/logs/ + +# View recent log files +find /.mitii/logs/ -name "*.jsonl" -type f -mtime -1 | head -5 + +# Check file permissions +ls -la /.mitii/ +``` + +## Implementation Details + +The logging system is implemented in `SessionLogService.ts` and uses: +- Node.js `fs` operations (`appendFileSync`, `mkdirSync`) +- Path joining with `path.join()` for safe path construction +- Recursive directory creation with `{ recursive: true }` +- Automatic timestamp generation for unique filenames +- Session ID tracking for log file identification + +The system is designed to be lightweight and non-intrusive, with minimal performance impact on agent execution. \ No newline at end of file diff --git a/docs/users/channels/slack.md b/docs/users/channels/slack.md new file mode 100644 index 00000000..51074541 --- /dev/null +++ b/docs/users/channels/slack.md @@ -0,0 +1,5 @@ +# Slack Connector + +Slack support uses the same channel runtime contract as Telegram: channel/thread IDs map to daemon sessions, user IDs can be allowlisted, and outbound messages are redacted before posting. + +Phase 3 ships the channel architecture and Telegram polling connector first. Slack Socket Mode should use the same `ChannelRuntimeAdapter` with thread timestamp to session mapping and approval buttons for tool calls. diff --git a/docs/users/channels/telegram.md b/docs/users/channels/telegram.md new file mode 100644 index 00000000..c25c0417 --- /dev/null +++ b/docs/users/channels/telegram.md @@ -0,0 +1,25 @@ +# Telegram Connector + +Start the daemon: + +```bash +mitii serve --token "$MITII_SERVER_TOKEN" +``` + +Start the connector: + +```bash +mitii connect telegram --token "$TELEGRAM_BOT_TOKEN" --daemon-url http://127.0.0.1:4310 +``` + +Commands: + +| Command | Mode | +| --- | --- | +| `/ask` | Ask mode | +| `/plan` | Plan mode | +| `/agent` | Agent mode | +| `/status` | Session status prompt | +| `/cancel` | Reserved for cancel support | + +Session mapping is persisted under `~/.mitii/connectors/telegram-sessions.json`. diff --git a/docs/users/jetbrains-workflow.md b/docs/users/jetbrains-workflow.md new file mode 100644 index 00000000..6fd68b99 --- /dev/null +++ b/docs/users/jetbrains-workflow.md @@ -0,0 +1,9 @@ +# JetBrains Workflow + +The Phase 3 JetBrains path is a thin companion workflow backed by `mitii serve`. + +1. Run `mitii serve` from the project root. +2. Use the JetBrains terminal or HTTP client to call the daemon API. +3. Keep VS Code-specific commands disabled; daemon sessions, CLI tasks, jobs, PR creation, and indexing are editor-independent. + +A future Kotlin/JCEF companion plugin should bind the open project root to the daemon and render the existing Mitii web UI in a tool window. diff --git a/docs/users/mitii-serve.md b/docs/users/mitii-serve.md new file mode 100644 index 00000000..d371b656 --- /dev/null +++ b/docs/users/mitii-serve.md @@ -0,0 +1,36 @@ +# Running `mitii serve` + +Start a local daemon: + +```bash +mitii serve --cwd /path/to/repo +``` + +With auth: + +```bash +openssl rand -hex 32 > ~/.mitii-serve-token +MITII_SERVER_TOKEN="$(cat ~/.mitii-serve-token)" mitii serve --cwd /path/to/repo +``` + +Health check: + +```bash +curl http://127.0.0.1:4310/health +``` + +Create a session: + +```bash +curl -X POST http://127.0.0.1:4310/session \ + -H "content-type: application/json" \ + -d '{"cwd":"'"$PWD"'","mode":"agent","approval":"manual"}' +``` + +Stream events: + +```bash +curl -N http://127.0.0.1:4310/session//events +``` + +The daemon is useful when VS Code, terminal clients, SDK scripts, and future board UIs need to share one long-running runtime. diff --git a/docs/users/parallel-agents.md b/docs/users/parallel-agents.md new file mode 100644 index 00000000..b0c4e442 --- /dev/null +++ b/docs/users/parallel-agents.md @@ -0,0 +1,31 @@ +# Parallel Agents + +Phase 2 adds two delegation patterns. + +| Pattern | Use when | +| --- | --- | +| Subagents | Research, review, verification, or tightly scoped implementation in the current branch | +| Worktrees | Independent tasks that should run in isolated branches | +| Daemon + board clients | Multiple clients need to attach to the same runtime | + +Create tasks: + +```bash +mitii task add "Fix auth tests" --prompt "Fix the failing auth tests and verify them" +mitii task list +``` + +Run runnable tasks in isolated worktrees: + +```bash +mitii task run --parallel 2 +mitii task worktrees +``` + +Each worktree is registered in `.mitii/worktrees.json`, and task state is stored in `.mitii/tasks/board.json`. + +Custom subagents live in `.mitii/agents/`: + +```bash +mitii agents init +``` diff --git a/media/mitii-activitybar.svg b/media/mitii-activitybar.svg index f212731a..00eb33c7 100644 --- a/media/mitii-activitybar.svg +++ b/media/mitii-activitybar.svg @@ -1,5 +1 @@ - - - - - +AABCHWp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAAEH3anVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYToxNzcyN2YyYi1mZTY2LTRkOGItOGFkYy0xZjcyY2U0YWY5ZDgAAAAB5Gp1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAAO5qdW1iAAAAQWp1bWRjYm9yABEAEIAAAKoAOJtxE2MycGEuYWN0aW9ucy52MgAAAAAYYzJzaNhr2cDNtaFeIj4IptPL9CgAAAClY2JvcqFnYWN0aW9uc4GjZmFjdGlvbmxjMnBhLmNyZWF0ZWRtc29mdHdhcmVBZ2VudGhDYW52YSBBSXFkaWdpdGFsU291cmNlVHlwZXhTaHR0cDovL2N2LmlwdGMub3JnL25ld3Njb2Rlcy9kaWdpdGFsc291cmNldHlwZS9jb21wb3NpdGVXaXRoVHJhaW5lZEFsZ29yaXRobWljTWVkaWEAAADFanVtYgAAAEBqdW1kY2JvcgARABCAAACqADibcRNjMnBhLmhhc2guZGF0YQAAAAAYYzJzaGWEb0ZGazyye/TRlcvds1UAAAB9Y2JvcqVqZXhjbHVzaW9uc4GiZXN0YXJ0GQENZmxlbmd0aBlYKGRuYW1lbmp1bWJmIG1hbmlmZXN0Y2FsZ2ZzaGEyNTZkaGFzaFggrFHrtXmEfsGwDh5+cWpSktwMSjalKgnPSIuVkjgrC7FjcGFkSQAAAAAAAAAAAAAAAgtqdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAAB3GNib3Knamluc3RhbmNlSUR4LHhtcDppaWQ6OGZhNDE5YTQtZmIzOC00YTY3LWJiOTctZDc3MjQzYjQ5MDZkdGNsYWltX2dlbmVyYXRvcl9pbmZvo2RuYW1lZ2MycGEtcnNndmVyc2lvbmUwLjAuMHdvcmcuY29udGVudGF1dGguYzJwYV9yc2UwLjAuMGlzaWduYXR1cmV4TXNlbGYjanVtYmY9L2MycGEvdXJuOmMycGE6MTc3MjdmMmItZmU2Ni00ZDhiLThhZGMtMWY3MmNlNGFmOWQ4L2MycGEuc2lnbmF0dXJlcmNyZWF0ZWRfYXNzZXJ0aW9uc4GiY3VybHgpc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5oYXNoLmRhdGFkaGFzaFggCJW7JZzOrrYvL/6zq+ftLPmaqQYCbsERVjRujE2w+qRzZ2F0aGVyZWRfYXNzZXJ0aW9uc4GiY3VybHgqc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5hY3Rpb25zLnYyZGhhc2hYIGNIByDd4h337pRzqF1jhX7YXzGEj/+D+A6owWsW5u5HaGRjOnRpdGxlZTEuc3ZnY2FsZ2ZzaGEyNTYAAD25anVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAAD2JY2JvctKEWRKBogE4Jhghg1kFPzCCBTswggMjoAMCAQICEQDyX9WLNslr5tAXFHAybHlLMA0GCSqGSIb3DQEBDQUAMCIxIDAeBgNVBAMTF1NpZ25pbmcgSW50ZXJtZWRpYXRlIENBMB4XDTI2MDcwMzIzNTEzMloXDTI2MDcxMTIzNTEzMlowODEOMAwGA1UEChMFQ2FudmExDjAMBgNVBAsTBUNhbnZhMRYwFAYDVQQDEw1DYW52YSBTaWduaW5nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAtiTpsHZA1dYGLwsiKwQVGx+iA84Kar3ucFYarLsqznHvyg1ofFa4VC1Dhr/Ptw++4taO2gbuzyGSKDIyp5DTzgmGdquOAZiMRxb0lIceOIor/7gEAsDGPkG8/latVgGZj6u5fJ8A5OG5x29pKt6x95rkKnpKZtkVdnshwQGd+QzD3acIlYCQaDnxWES/3nU25EADKcw7w3t0Wy25pHbsZh/wIJEgrQX6F9VhBuk8tUG5aRleyt3d3Tn5s3a/Oo483KPKd/Wy16SiZ4QM/mXn71xs3bURqSdFpJzHm0FqUv8E+x4vYYm1l+gRYkZdkHoJ8cMBGjxFUj8IhThnwOqFFAic9rosJyYHx3SIMigk+4LYpZQzFIWSQ0eBtW6X6FCBXwHzOPYUW2f3/3XNHcEK8mVtVWAPteRl5URpxaFNw/oX36KKBnoYcZg4cH8wkaCRLYNsdGfwinz5XVDh/0Wmf6G7++hN+NYVz5AM+o6NwrOAXbXxC7X5GC4u7LDf5xn1oRqrTC2KjznGsf8h6oteBBRoOtK/v13mt+BnbPP3evJQ1O3BVnuq4tjvZR1CDm4FTZd0cXdaYyxTECNbS2+uX+oshMHbawgrU7IGJtfM8esPgfNZUKWk3M68tCX9/sybg0xoWaOgzLT/8NcC19H13uxeTHCBjMzPXYfxyNuy1B8CAwEAAaNWMFQwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsGAQUFBwMEMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUq4jaZojoh1/YlDrdtzaqU0BSmX4wDQYJKoZIhvcNAQENBQADggIBAHjOqS/ZxntwljO9oLir70fKRcA3IeRfXyhJOgrgabvo/yxvbVUitrFaxHA2CtWnr/bpaHA8fWITqIrOwrtw57jY37CeKxs6HyQHw4/uPHKymB3UBew6plHhDtdcDalHM+XbO/8aTXf00XXq+et0u07duAnGLxOrXDAAyAOlQZK2EMySqBwWydgu3SH7bcFj9cOvTjOIVIp0zEe6k+smKGjedzY9c6eVCI6D1YuHUVOJyp+oseiOZaHLCXaOP61KU0N/Tmm1ENxIdARC5EmQoCNNdwbzbptZCGsYn2+ZfKoP7gva4w4N49b2QRkZvc38pn2XGkV7dpwvL6U/XmoKVwN/OH64Q6vm8E+S4TC6hi3nL40rESJMJRfaohGs83nv5uEy6fM3CGJfNMetorEF9VJza6t6cYXijnZUubgBgK7tmqjDLj/CT1DhicLs/ujj3Z2bVZTdtcbe+oHbhkP3g9jsw4+HVLOp8TDvhrrV6cHmD8cRzLPDYFk+Ftmi+iVXGOR54BDr701472u7p3OS3AS6KUO+83797IAsbuJpTJZDDwAn2XmK1nddWGC1eXtPghlHPWyaxqauDFWUbSBzi4I+hogAyOhke7qYfvbTnQCKwJ+2lVyHZNCE8RY5wYd978h5fZwkpjGJgq6jJZJotG0oiJs8pM7rGSIZ9CA+P7v3WQboMIIG5DCCBMygAwIBAgIUFywTzDN9IdeA2lFDlZW+XNZ6ANkwDQYJKoZIhvcNAQELBQAwbTELMAkGA1UEBhMCQVUxDDAKBgNVBAgTA05TVzEPMA0GA1UEBxMGU3lkbmV5MRMwEQYDVQQKEwpDYW52YSBQcm9kMRAwDgYDVQQLEwdTaWduaW5nMRgwFgYDVQQDEw9TaWduaW5nIENBIFByb2QwHhcNMjYwNzAxMDUzNzU1WhcNMjYwNzMxMDUzODI1WjAiMSAwHgYDVQQDExdTaWduaW5nIEludGVybWVkaWF0ZSBDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANLWVW5TbqnPKqVhSWn/qdaHeJnSILmwrvXzy8+69zVojMQGv7FlLFjW7mD5lky7rmVoYmzfDf0Hr1Z1UY/VpmS6JsrLR6nj9mqA+izeh5OHfxvvi0OihhMP9mN87XH5msYIinxHZFhV2M1WNt0xvS5UTqSKJvbjv3ybmHrIhwAZH2JN5aCnoxvZCIlBoSRr06grK05lsecTiuZOPIiK1f3m0fpgf2clpdFq838qWYIHwclPxQRhJkQL1zLMkeQ8zuvnfDs6SAGRstO6SUuj42YcUFGvZTRwueVeBYZi/MxPtape2hljveaOaA34QpxMdXZIDZdvDeKOcQICJ3C99laxpPhQF82tXayfhrVVn16UgKVyQU7upIxbs19lDfF/PNYm6rtApbmgTP6e7mrSk+dQQDXTwdAnKIzmT8Rl9a1RKYi4i6aJkhiKx+Reww7nzfXeJvMTbLNQad1m4jm49+Mjr3AC9lxDaNERVwQHBc68w+cP+W0OziQnzE6zizHOaIY/fQFTD961MOmHxJr2BqTxYnzhOR8JVoEv0BydikKieMtZn8NrvFR52HIa8J0tL3RwCTieJM4zMco9IACVKJOyMCYi7WJp9lw64O12Gi7sySvwVA5uo4fh5SThQcKrDq+XFkNeB1oI77/1UG3iUtIbkmrC7j2PGH60pjBP9RSXAgMBAAGjggHFMIIBwTAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUq4jaZojoh1/YlDrdtzaqU0BSmX4wHwYDVR0jBBgwFoAUSlxt/ndQaL1jKWFk7YbiIcsE4x0wgdcGCCsGAQUFBwEBBIHKMIHHMEwGCCsGAQUFBzABhkBodHRwczovL3BraS5jYW52YS1pbnRlcm5hbC5jb20vdjEvcGtpL2NhbnZhLXByb2QvbDEvc2lnbmluZy9vY3NwMHcGCCsGAQUFBzAChmtodHRwczovL3BraS5jYW52YS1pbnRlcm5hbC5jb20vdjEvcGtpL2NhbnZhLXByb2QvbDEvc2lnbmluZy9pc3N1ZXIvMWRjNDVjODQtNGY5My1lMTBlLTRjMzYtZTdkZWFhMjU2MzkyL2RlcjCBgAYDVR0fBHkwdzB1oHOgcYZvaHR0cHM6Ly9wa2kuY2FudmEtaW50ZXJuYWwuY29tL3YxL3BraS9jYW52YS1wcm9kL2wxL3NpZ25pbmcvaXNzdWVyLzFkYzQ1Yzg0LTRmOTMtZTEwZS00YzM2LWU3ZGVhYTI1NjM5Mi9jcmwvZGVyMA0GCSqGSIb3DQEBCwUAA4ICAQDNfjIg6gXGHvpraWDf3zA0vWoyA0p0++hxxDULe7XR/8/R0LDBCDEHS4hX5TVmk0es6dCsuEKYpvpEtbUoHIlet5lGrIOFcNwcAcDAWoCdD+ypPaIwnC8PICtPMGxGwFG8SyFRmahDMYt3suZSpQnXnVc+dMrRbze3ZrT5740t6s8O5FeFlvLrUG3x9yJDgrbKSUsC8OmR+4aRY8HoZ5ZuCwOmCdV+7y/PGy+etJ4tQzTSoIWUguiTvCUoV/y6zp+dO2aSCNWUVbioKLLMSbDoAJMmZ+lhKvUWPoBXJwcLLo5prpV/50HP0pS0exBk2rss4rga+okUNT8HZyayQnjv3b+sYwNDULhUPRu0auY7+l8uad71K97Nzori06Tt9FDzFQ/bGk3mIKRc1Sy7mJKU62r37xs2UhyTkMTQ/boTMuU/doP2K2vsHbZHJC1yg6hFn7jrnUfjd+laXS+BlyltiN4TVQGFR6gUlovj81RzEUYp3F3V0q1xwvwRNITQbWP3KlVsMuEsonOEryDB+e+Pi7HJ0HsGKSltCYXOgz1M488sWy8cJZK1UO2lTl8p1tD2zspIfGjEdJyiusKh+Zskm48VvhMOgSNTdS4ZyubDsFYU5G8/aSbyI3sNKwLjm6ikkrtOpiGrLai5cdM/u3EurdZ/RLYMGEFy68oSOIV3tlkGSjCCBkYwggQuoAMCAQICEQDJtWzGUz0MB/MhdTvrUGHhMA0GCSqGSIb3DQEBDQUAMIGBMQswCQYDVQQGEwJBVTETMBEGA1UECgwKQ2FudmEgUHJvZDEXMBUGA1UECwwOQ2xvdWQgUGxhdGZvcm0xDDAKBgNVBAgMA05TVzElMCMGA1UEAwwcQ2FudmEgUHJvZCBSb290IENBIEdsb2JhbCBHMzEPMA0GA1UEBwwGU3lkbmV5MB4XDTI2MDUxNTAzMTI1NVoXDTI2MTExMTAzMTI1NVowbTELMAkGA1UEBhMCQVUxDDAKBgNVBAgTA05TVzEPMA0GA1UEBxMGU3lkbmV5MRMwEQYDVQQKEwpDYW52YSBQcm9kMRAwDgYDVQQLEwdTaWduaW5nMRgwFgYDVQQDEw9TaWduaW5nIENBIFByb2QwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQD0MHZe4JEmHf4WkG5+tA+VwWcyYzsYw19aFaMnh/QZp8Awkci8GjqGdN09zzOrYEp6k9nAXTT0Y7H3tIUhpDIWy0jqysBROIYvKdGkz5Lp4MH3hB6nXnIiLwXpHfq/O95Cs8Q7wrmMoB1DPMLXBNfA/qRFqQeGVgL2UlxPchxkM1l1jEhSftxgZ09KpYFqcLeDEyPB2+nrfsX9qGuYLnmJOEZs1EtmLa5qjqbdGp7mlO8QawHQUmZ2QsD7YJDoSb7wU3XOHn/6h7ps5rVhTK516ryakEKf9P109m376yyUoyfGxqQD5E4KtlaTVsH0AWon+L3Mo0cpn/QxLikF+2iR2RGxyVsMUaQLwW0UXWlOw471623wcrsvkGZ+Ys40G+QOBrIHa/bq0cA6HZvxSNQt6I7+M0GDbGz2kdD3CobNaXXNGFt4y+qXjoMbx/NLfMhC5+frDobUF8RUSkCQ+Snzi95PibxJFHaInji8LrjueMnunf851089syyrXNqBaFQbyY/1tkESEeQmFudPrDl7qwh6sHSzWLOzrmdKZzJd49+ABzlAcOGU+b6l33JyP7TDzxGsZMRyQaEeQaYELwPIeENs1cIfFRLVlfQimCg0J6db37RK7GIG4z/yrvMbz9vnkk9ckzBF+3CzTtT8z/mtinltewY4QDpPdCfbPa/3fQIDAQABo4HLMIHIMBIGA1UdEwEB/wQIMAYBAf8CAQEwHwYDVR0jBBgwFoAUuzhkeWmWJGuw/pESspQ08s+cY4swHQYDVR0OBBYEFEpcbf53UGi9YylhZO2G4iHLBOMdMA4GA1UdDwEB/wQEAwIBhjBiBgNVHR8EWzBZMFegVaBThlFodHRwOi8vZDF1YWRuMHBqc2loOG0uY2xvdWRmcm9udC5uZXQvY3JsLzVlNDliYWRkLTBhNjYtNGYzYS1iM2NmLTFhNGZkMGY2MzVkMS5jcmwwDQYJKoZIhvcNAQENBQADggIBAKqOtwanq6lfW6XuFzlw/EJHJ2wVi9XY9dTjNZSPlIr3fnJ2qz2JRZ3f5VCa/+TmA9p2wv5aBNKSJyN4/uX1/qPxFunqykKFx3mNDD09JmxMEA1kTsjEePbhf5eBBlw2yg4lC3X+E/PTnx8VXTjwbG9jcaNGbJrD1/SZtYgjbti8YKnZMOeMh93wtv6NttshgFRcxUmS6sbvvhcAKi3U/YsMnbtSBJJH1dirr6rQuGSaghdLbxawcLd1OfMnw8aKe+G+4TOuKjQ10dwvMKwlBRTcnNJuun+vBvPujqfNmt5mc+0Hu2pDwtHB+LbmpC+hx2ksSmgTCLMjC+oiJUeAFnq1EVJlB++nqKzC4uD3XayTG1jvZ8JlJeZwdCIbo16DWmDP5eUrLiDJ8t/cR46hWzlmk549H1f9UZqiTMCyODjrnO0xbwm9yynPJCMvcN98sqWjOGSWQQPUXAqsMdNMzm1hWdjNzxntt5q9uYRa9rvYcmeu8M9Ek8Lz1sWoEaVcpkdI/XOZBY1nJC/xItlK22e39DS/JQibDVZgKsfkOkZ+tXn3rlD7zxpuJMvUtBmRnhA3iqZxa50+XEk1P2fxX2Ke4A025hkk4oVJY2+JZjWgD1qYvXZ5WRHZSU8SMiwx1iiw2PXcF8H/yEeEisZlIGM21Lc4gzhUmRLxrrTsDbbvoWNwYWRZKO8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2WQIArBpfee+fDgWSadDn9HlXiNpE83dWt5TK/FbkLrwMvRUOfYcnK3+8k7ZBSWOe+74by3eW30myY6TvBcnQMsmRuNPMsUr6uyZKBvbQ0p0Lk04fdIvaloF2LfJEUFttj4ioN2FyL7oUiNe1IlDF6d95p2GhcHKV/9dfEVs80DrhCXgQNj2npbBGrJD0kfPeHCnUFu+uKP/HCcxPc3NFJiM5/Oak5MNwN4VbK/lzQgy39S1rFvfD8UnS8kQV5dNqcSXjC6WVUwWLy6iOjGZIvwtCD2tV9MkiBGUiukgZZfgX55lqUILmQAs/ZgAEFzXkB53j1Y6E/hyEDIcvjOftB8KZEAbkzTqDDCZVl+cN+bhkn7fcdTtbpAiKjiJgjghXIXfmDqyl/qwx/ZEVBk63z5rusx/XQTZvzLAcJWtLBNeEbhk4EXrxIb2G9sfksqPCl+RQYL4zK7UDP/SN5S22EHrt4+g9fLB6S+DpBAkOvO42EGAICIOs8T5E0ZONsicjn1Ebn/RC4H8urWsdhRIsCu0OY8s0tckpFuHSP5oddPHT87WsB48b75grwpwSYSM2ODkce6u/qxSqp1SJsbXekZsnEkrIf9bvpOV3xGMsu+uc7S0Qv4S5WGQkT++Xmr2AbGmGeo04PclLtQKsko5LG3KUWpWHCnNG1r2/1CWb912iJhY=Yes \ No newline at end of file diff --git a/package.json b/package.json index 7131002c..6cc20bd7 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.30", + "version": "2.7.51", "publisher": "mitii", "icon": "media/mitii-short-logo.png", "license": "AGPL-3.0-or-later", @@ -48,7 +48,15 @@ "onCommand:thunder.generateCommitMessage", "onCommand:thunder.generateChangelog", "onCommand:thunder.prepareRelease", - "onCommand:thunder.exportAuditPack" + "onCommand:thunder.exportAuditPack", + "onCommand:mitii.openChat", + "onCommand:mitii.indexWorkspace", + "onCommand:mitii.showSettings", + "onCommand:mitii.generateCommitMessage", + "onCommand:mitii.generateChangelog", + "onCommand:mitii.prepareRelease", + "onCommand:mitii.exportAuditPack", + "onCommand:mitii.migrateThunderSettings" ], "main": "./dist/extension.js", "bin": { @@ -116,6 +124,72 @@ "command": "thunder.prepareRelease", "title": "Mitii: Prepare Release", "category": "Mitii" + }, + { + "command": "mitii.openChat", + "title": "Mitii: Open Chat", + "category": "Mitii" + }, + { + "command": "mitii.indexWorkspace", + "title": "Mitii: Index Workspace", + "category": "Mitii" + }, + { + "command": "mitii.showSettings", + "title": "Mitii: Show Settings", + "category": "Mitii" + }, + { + "command": "mitii.exportSessionLog", + "title": "Mitii: Export Session Log", + "category": "Mitii" + }, + { + "command": "mitii.exportAuditPack", + "title": "Mitii: Export Audit Pack", + "category": "Mitii" + }, + { + "command": "mitii.openSessionLog", + "title": "Mitii: Open Session Log File", + "category": "Mitii" + }, + { + "command": "mitii.acceptInlineDiff", + "title": "Mitii: Accept Inline Diff", + "category": "Mitii" + }, + { + "command": "mitii.rejectInlineDiff", + "title": "Mitii: Reject Inline Diff", + "category": "Mitii" + }, + { + "command": "mitii.showInlineDiff", + "title": "Mitii: Show Inline Diff", + "category": "Mitii" + }, + { + "command": "mitii.generateCommitMessage", + "title": "Mitii: Generate Commit Message", + "category": "Mitii", + "icon": "media/mitii-activitybar.svg" + }, + { + "command": "mitii.generateChangelog", + "title": "Mitii: Generate Changelog Entry", + "category": "Mitii" + }, + { + "command": "mitii.prepareRelease", + "title": "Mitii: Prepare Release", + "category": "Mitii" + }, + { + "command": "mitii.migrateThunderSettings", + "title": "Mitii: Migrate Settings from thunder.* to mitii.*", + "category": "Mitii" } ], "menus": { @@ -124,6 +198,11 @@ "command": "thunder.generateCommitMessage", "when": "scmProvider == git && gitOpenRepositoryCount != 0", "group": "navigation" + }, + { + "command": "mitii.generateCommitMessage", + "when": "scmProvider == git && gitOpenRepositoryCount != 0", + "group": "navigation" } ] }, @@ -151,61 +230,100 @@ "thunder.debug": { "type": "boolean", "default": false, - "description": "Enable debug logging and show stack traces in UI" + "description": "Enable debug logging and show stack traces in UI", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.telemetry.sessionLogging": { "type": "boolean", "default": true, - "description": "Write structured JSONL session logs to .mitii/logs/ for debugging and analysis." + "description": "Write structured JSONL session logs to .mitii/logs/ for debugging and analysis.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.telemetry.debugMetrics": { "type": "boolean", "default": false, - "description": "Capture extra session diagnostics (tool inputs, context sources, LLM step metadata). Process timing is always logged when session logging is on." + "description": "Capture extra session diagnostics (tool inputs, context sources, LLM step metadata). Process timing is always logged when session logging is on.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.telemetry.webhookUrl": { "type": "string", "default": "", - "description": "Optional SIEM/webhook endpoint. When set, sanitized session events are POSTed as JSON." + "description": "Optional SIEM/webhook endpoint. When set, sanitized session events are POSTed as JSON.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.telemetry.webhookSecret": { "type": "string", "default": "", - "description": "Optional HMAC signing secret for webhook events. Prefer workspace or managed settings for enterprise deployments." + "description": "Optional HMAC signing secret for webhook events. Prefer workspace or managed settings for enterprise deployments.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.telemetry.webhookTimeoutMs": { "type": "number", "default": 5000, "minimum": 1000, "maximum": 60000, - "description": "Timeout in milliseconds for telemetry webhook delivery attempts." + "description": "Timeout in milliseconds for telemetry webhook delivery attempts.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.ui.showReasoning": { "type": "boolean", "default": true, - "description": "Show streamed model reasoning when the provider returns reasoning deltas." + "description": "Show streamed model reasoning when the provider returns reasoning deltas.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.ui.reasoningPreviewMaxChars": { "type": "number", "default": 8000, "minimum": 0, "maximum": 100000, - "description": "Maximum reasoning characters shown inline. Set 0 to show all reasoning." + "description": "Maximum reasoning characters shown inline. Set 0 to show all reasoning.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.enterprise.localProvidersOnly": { "type": "boolean", "default": false, - "description": "Enterprise policy flag documenting that only local providers should be used for this workspace." + "description": "Enterprise policy flag documenting that only local providers should be used for this workspace.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.enterprise.stripFileContentsFromAuditPacks": { "type": "boolean", "default": false, - "description": "Strip file contents and large tool outputs from exported audit packs while preserving metadata." + "description": "Strip file contents and large tool outputs from exported audit packs while preserving metadata.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.enterprise.autoExportAuditPackOnSessionEnd": { "type": "boolean", "default": false, - "description": "Export a sanitized audit pack automatically to .mitii/audit/ when an agent turn completes." + "description": "Export a sanitized audit pack automatically to .mitii/audit/ when an agent turn completes.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." + }, + "thunder.runtime.mode": { + "type": "string", + "enum": [ + "embedded", + "daemon" + ], + "default": "embedded", + "description": "Run the VS Code chat in-process or attach to a running mitii serve daemon.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." + }, + "thunder.runtime.daemonUrl": { + "type": "string", + "default": "http://127.0.0.1:4310", + "description": "Mitii daemon URL used when thunder.runtime.mode is daemon.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." + }, + "thunder.runtime.daemonToken": { + "type": "string", + "default": "", + "description": "Optional bearer token for mitii serve. Prefer SecretStorage or managed settings for enterprise use.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." + }, + "thunder.runtime.autoStartDaemon": { + "type": "boolean", + "default": false, + "description": "Automatically start mitii serve when a workspace opens and daemon runtime mode is selected.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.provider.type": { "type": "string", @@ -223,77 +341,92 @@ "echo" ], "default": "echo", - "description": "LLM provider type" + "description": "LLM provider type", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.provider.baseUrl": { "type": "string", "default": "http://localhost:11434/v1", - "description": "OpenAI-compatible API base URL" + "description": "OpenAI-compatible API base URL", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.provider.model": { "type": "string", "default": "qwen3-coder:30b", - "description": "Model name" + "description": "Model name", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.provider.apiVersion": { "type": "string", "default": "2024-10-21", - "description": "Provider API version. Used by Azure OpenAI when building deployment chat completion URLs." + "description": "Provider API version. Used by Azure OpenAI when building deployment chat completion URLs.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.provider.region": { "type": "string", "default": "us-east-1", - "description": "Cloud provider region. Used by AWS Bedrock." + "description": "Cloud provider region. Used by AWS Bedrock.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.provider.contextWindow": { "type": "number", "default": 8192, - "description": "Model context window size. Thunder trims prompts automatically to stay within this limit." + "description": "Model context window size. Thunder trims prompts automatically to stay within this limit.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.provider.supportsVision": { "type": "boolean", - "description": "Optional override for model vision support. Leave unset to use automatic provider/model detection." + "description": "Optional override for model vision support. Leave unset to use automatic provider/model detection.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.provider.supportsReasoning": { "type": "boolean", - "description": "Optional override for streamed reasoning support. Leave unset to use automatic provider/model detection." + "description": "Optional override for streamed reasoning support. Leave unset to use automatic provider/model detection.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.indexing.enabled": { "type": "boolean", "default": true, - "description": "Enable workspace indexing" + "description": "Enable workspace indexing", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.indexing.autoIndexOnOpen": { "type": "boolean", "default": true, - "description": "Automatically index the workspace when a folder is opened or switched." + "description": "Automatically index the workspace when a folder is opened or switched.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.indexing.maxFileSizeBytes": { "type": "number", "default": 512000, - "description": "Max file size for full indexing in bytes. Larger files get signature-only indexing." + "description": "Max file size for full indexing in bytes. Larger files get signature-only indexing.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.indexing.hardSkipSizeBytes": { "type": "number", "default": 2000000, - "description": "Skip files larger than this size entirely (e.g. lockfiles)." + "description": "Skip files larger than this size entirely (e.g. lockfiles).", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.indexing.maxConcurrency": { "type": "number", "default": 2, "minimum": 1, "maximum": 8, - "description": "Number of files to index in parallel." + "description": "Number of files to index in parallel.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.indexing.vectorsEnabled": { "type": "boolean", "default": true, - "description": "Enable semantic vector search. Uses MiniLM when @xenova/transformers is available, hash fallback otherwise." + "description": "Enable semantic vector search. Uses MiniLM when @xenova/transformers is available, hash fallback otherwise.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.indexing.treeSitterEnabled": { "type": "boolean", "default": true, - "description": "Use tree-sitter WASM for symbol extraction (regex fallback when unavailable)" + "description": "Use tree-sitter WASM for symbol extraction (regex fallback when unavailable)", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.indexing.embeddingProvider": { "type": "string", @@ -302,7 +435,8 @@ "minilm" ], "default": "minilm", - "description": "Embedding provider when vectors are enabled. minilm uses local @xenova/transformers (all-MiniLM-L6-v2); falls back to hash if unavailable." + "description": "Embedding provider when vectors are enabled. minilm uses local @xenova/transformers (all-MiniLM-L6-v2); falls back to hash if unavailable.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.indexing.vectorBackend": { "type": "string", @@ -310,42 +444,49 @@ "sqlite", "lancedb" ], - "default": "sqlite", - "description": "Vector index storage: sqlite (.mitii/mitii.sqlite) or lancedb (.mitii/lance/) via @lancedb/lancedb" + "default": "lancedb", + "description": "Vector index storage: sqlite (.mitii/mitii.sqlite) or lancedb (.mitii/lance/) via @lancedb/lancedb", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.context.rerankerEnabled": { "type": "boolean", "default": true, - "description": "Rerank top retrieval candidates for better context quality (top-20 → top-8)" + "description": "Rerank top retrieval candidates for better context quality (top-20 → top-8)", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.context.rerankerCandidatePool": { "type": "number", "default": 20, "minimum": 5, "maximum": 50, - "description": "Number of candidates to pass to the reranker" + "description": "Number of candidates to pass to the reranker", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.context.rerankerTopK": { "type": "number", "default": 8, "minimum": 3, "maximum": 30, - "description": "Number of items to keep after reranking" + "description": "Number of items to keep after reranking", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "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." + "description": "Route narrow chat requests such as commit messages, changelog entries, and release notes through minimal Git context instead of the full agent loop.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.safety.requireApprovalForWrites": { "type": "boolean", "default": true, - "description": "Require approval for file writes" + "description": "Require approval for file writes", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.safety.requireApprovalForShell": { "type": "boolean", "default": true, - "description": "Require approval for shell commands" + "description": "Require approval for shell commands", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.safety.approvalMode": { "type": "string", @@ -364,7 +505,8 @@ "Auto-approve allowed tools and commands. Dangerous commands are still blocked." ], "default": "review_all", - "description": "Approval mode for agent operations" + "description": "Approval mode for agent operations", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.safety.autonomyPreset": { "type": "string", @@ -376,58 +518,123 @@ "enterprise" ], "default": "guided", - "description": "Autonomy preset" + "description": "Autonomy preset", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.memory.enabled": { "type": "boolean", "default": true, - "description": "Enable local memory" + "description": "Enable local memory", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.memory.hybridSearchEnabled": { "type": "boolean", "default": true, - "description": "Use FTS5 + optional vector hybrid search for observations" + "description": "Use FTS5 + optional vector hybrid search for observations", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." + }, + "thunder.memory.summarizeAfterTask": { + "type": "boolean", + "default": true, + "description": "Summarize completed tasks into durable local memory.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." + }, + "thunder.memory.autoMemoryEnabled": { + "type": "boolean", + "default": true, + "description": "Write post-task memories to human-readable markdown files in addition to SQLite observations.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." + }, + "thunder.memory.autoMemoryScope": { + "type": "string", + "enum": [ + "user", + "workspace", + "both" + ], + "default": "user", + "description": "Where markdown auto-memory files are written: user profile, workspace .mitii/auto-memory, or both.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.scm.commitMessageEnabled": { "type": "boolean", "default": true, - "description": "Show Mitii commit-message generation in VS Code Source Control." + "description": "Show Mitii commit-message generation in VS Code Source Control.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.github.issueFetchEnabled": { "type": "boolean", "default": true, - "description": "Fetch structured GitHub issue context when a chat message contains a github.com owner/repo/issues URL and network access is allowed." + "description": "Fetch structured GitHub issue context when a chat message contains a github.com owner/repo/issues URL and network access is allowed.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.github.issueCommentLimit": { "type": "number", "default": 8, "minimum": 0, "maximum": 25, - "description": "Maximum number of GitHub issue comments to include in injected issue context." + "description": "Maximum number of GitHub issue comments to include in injected issue context.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.github.tokenRef": { "type": "string", "default": "thunder.github.token", - "description": "VS Code SecretStorage key for an optional GitHub token used to fetch private issues. The token itself is not stored in settings." + "description": "VS Code SecretStorage key for an optional GitHub token used to fetch private issues. The token itself is not stored in settings.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.agent.subagentsEnabled": { "type": "boolean", "default": true, - "description": "Enable the spawn_research_agent tool for parallel read-only research." + "description": "Enable subagent tools for typed delegation.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." + }, + "thunder.agent.subagentTypesEnabled": { + "type": "array", + "default": [ + "research" + ], + "items": { + "type": "string" + }, + "description": "Allowed subagent types. Built-ins are research, implementer, reviewer, and verifier; custom .mitii/agents ids may also be listed.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." + }, + "thunder.agent.maxConcurrentSubagents": { + "type": "number", + "default": 2, + "minimum": 1, + "maximum": 10, + "description": "Maximum subagents allowed to run concurrently in one session.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." + }, + "thunder.agent.implementerRequiresScope": { + "type": "boolean", + "default": true, + "description": "Require implementer subagents to receive targetFiles or scopeRoot before write tools are available.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." + }, + "thunder.agent.subagentDailyBudget": { + "type": "number", + "default": 0, + "minimum": 0, + "description": "Maximum subagent invocations per session day. 0 means unlimited.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.agent.maxSteps": { "type": "number", "default": 15, "minimum": 1, "maximum": 100, - "description": "Maximum tool-use steps before the main agent stops or auto-continues." + "description": "Maximum tool-use steps before the main agent stops or auto-continues.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.agent.askMaxSteps": { "type": "number", "default": 18, "minimum": 1, "maximum": 50, - "description": "Maximum tool-use steps per Ask-mode turn (read-only exploration). Use 16-20 for deep codebase explanations." + "description": "Maximum tool-use steps per Ask-mode turn (read-only exploration). Use 16-20 for deep codebase explanations.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.agent.askDepth": { "type": "string", @@ -440,7 +647,8 @@ "enterprise" ], "default": "auto", - "description": "Ask-mode exploration depth. Auto chooses by intent; quick is concise; deep explores more before answering." + "description": "Ask-mode exploration depth. Auto chooses by intent; quick is concise; deep explores more before answering.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.agent.planDepth": { "type": "string", @@ -453,7 +661,8 @@ "enterprise" ], "default": "auto", - "description": "Plan-mode discovery depth before structured plan compilation. Auto chooses by intent; deep explores more of the codebase." + "description": "Plan-mode discovery depth before structured plan compilation. Auto chooses by intent; deep explores more of the codebase.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.agent.actDepth": { "type": "string", @@ -466,58 +675,68 @@ "enterprise" ], "default": "auto", - "description": "Agent/Act execution depth. Auto chooses by task route; quick caps direct work, deep allows up to 16 tool steps." + "description": "Agent/Act execution depth. Auto chooses by task route; quick caps direct work, deep allows up to 16 tool steps.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.agent.askAutoContinue": { "type": "boolean", "default": true, - "description": "Allow Ask mode to continue when deep exploration reaches its per-round limit." + "description": "Allow Ask mode to continue when deep exploration reaches its per-round limit.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.agent.askMaxAutoContinues": { "type": "number", "default": 1, "minimum": 0, "maximum": 10, - "description": "Maximum automatic continuation rounds for Ask mode." + "description": "Maximum automatic continuation rounds for Ask mode.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.agent.autoContinue": { "type": "boolean", "default": true, - "description": "Allow the agent to keep working after it reaches the per-round step limit." + "description": "Allow the agent to keep working after it reaches the per-round step limit.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.agent.maxAutoContinues": { "type": "number", "default": 2, "minimum": 0, "maximum": 10, - "description": "Maximum number of automatic continuation rounds after the main step limit." + "description": "Maximum number of automatic continuation rounds after the main step limit.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.agent.researchAgentMaxSteps": { "type": "number", "default": 10, "minimum": 1, "maximum": 50, - "description": "Maximum tool-use steps for each read-only research subagent." + "description": "Maximum tool-use steps for each read-only research subagent.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.agent.researchAgentModel": { "type": "string", "default": "", - "description": "Optional faster model for read-only research subagents. Empty uses the main provider model." + "description": "Optional faster model for read-only research subagents. Empty uses the main provider model.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.agent.researchAgentBaseUrl": { "type": "string", "default": "", - "description": "Optional OpenAI-compatible base URL for the research subagent model. Empty uses thunder.provider.baseUrl." + "description": "Optional OpenAI-compatible base URL for the research subagent model. Empty uses thunder.provider.baseUrl.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.agent.orchestrationEnabled": { "type": "boolean", "default": true, - "description": "Enable the multi-step planner in Plan mode. Act mode uses a faster direct agent path by default." + "description": "Enable the multi-step planner in Plan mode. Act mode uses a faster direct agent path by default.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.agent.showDiffPreview": { "type": "boolean", "default": false, - "description": "Open VS Code diff preview tabs before write_file/apply_patch tool calls. Disabled by default to avoid editor focus changes." + "description": "Open VS Code diff preview tabs before write_file/apply_patch tool calls. Disabled by default to avoid editor focus changes.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.agent.verifyCommands": { "type": "array", @@ -525,32 +744,38 @@ "items": { "type": "string" }, - "description": "Optional shell commands to run after Act-mode completes. Leave empty to auto-discover from package.json scripts (typecheck, lint, test, build) based on touched files." + "description": "Optional shell commands to run after Act-mode completes. Leave empty to auto-discover from package.json scripts (typecheck, lint, test, build) based on touched files.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.agent.verifyOnActComplete": { "type": "boolean", "default": true, - "description": "After Act-mode runs, discover and run project-appropriate verification (package.json scripts). Uses verifyCommands when set; otherwise auto-discovers." + "description": "After Act-mode runs, discover and run project-appropriate verification (package.json scripts). Uses verifyCommands when set; otherwise auto-discovers.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.agent.planModel": { "type": "string", "default": "", - "description": "Optional model override for Plan mode. Empty uses thunder.provider.model." + "description": "Optional model override for Plan mode. Empty uses thunder.provider.model.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.agent.planBaseUrl": { "type": "string", "default": "", - "description": "Optional base URL override for Plan mode." + "description": "Optional base URL override for Plan mode.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.agent.actModel": { "type": "string", "default": "", - "description": "Optional model override for Agent (Act) mode. Empty uses thunder.provider.model." + "description": "Optional model override for Agent (Act) mode. Empty uses thunder.provider.model.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.agent.actBaseUrl": { "type": "string", "default": "", - "description": "Optional base URL override for Agent (Act) mode." + "description": "Optional base URL override for Agent (Act) mode.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.agent.checkpointStrategy": { "type": "string", @@ -560,48 +785,687 @@ "shadow-git" ], "default": "git-stash", - "description": "Checkpoint strategy before file writes. Git stash when repo available, else file copy." + "description": "Checkpoint strategy before file writes. Git stash when repo available, else file copy.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.safety.allowUntrustedWorkspace": { "type": "boolean", "default": false, - "description": "Allow writes and shell in untrusted workspaces (not recommended)." + "description": "Allow writes and shell in untrusted workspaces (not recommended).", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.mcp.enabled": { "type": "boolean", "default": true, - "description": "Enable Model Context Protocol servers. Built-in free servers start automatically unless thunder.mcp.preloadBuiltin is false." + "description": "Enable Model Context Protocol servers. Built-in free servers start automatically unless thunder.mcp.preloadBuiltin is false.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.mcp.preloadBuiltin": { "type": "boolean", "default": true, - "description": "Preload official free MCP servers (filesystem, memory, sequential-thinking) on extension startup. Toggle individual servers in the Mitii settings UI." + "description": "Preload official free MCP servers (filesystem, memory, sequential-thinking) on extension startup. Toggle individual servers in the Mitii settings UI.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.mcp.builtinServers": { "type": "object", "default": { "filesystem": true, "memory": true, - "sequentialThinking": true + "sequentialThinking": true, + "agentmemory": false }, - "description": "Default on/off state for built-in MCP servers. Per-session toggles are available in the Mitii Integrations settings tab." + "description": "Default on/off state for built-in MCP servers. Per-session toggles are available in the Mitii Integrations settings tab.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.mcp.maxConcurrentStartup": { "type": "number", "default": 4, "minimum": 1, "maximum": 20, - "description": "Maximum MCP stdio servers Mitii starts at the same time." + "description": "Maximum MCP stdio servers Mitii starts at the same time.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.mcp.servers": { "type": "object", "default": {}, - "description": "MCP server definitions keyed by server name. Supports stdio servers: { \"my-server\": { \"command\": \"npx\", \"args\": [\"-y\", \"@modelcontextprotocol/server-filesystem\", \".\"] } }" + "description": "MCP server definitions keyed by server name. Supports stdio servers: { \"my-server\": { \"command\": \"npx\", \"args\": [\"-y\", \"@modelcontextprotocol/server-filesystem\", \".\"] } }", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." }, "thunder.workspace.rootPathOverride": { "type": "string", "default": "", - "description": "Fallback workspace root when no VS Code folder is open. When a folder is open, Mitii always uses that folder and ignores this setting." + "description": "Fallback workspace root when no VS Code folder is open. When a folder is open, Mitii always uses that folder and ignores this setting.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." + }, + "mitii.debug": { + "type": "boolean", + "default": false, + "description": "Enable debug logging and show stack traces in UI" + }, + "mitii.telemetry.sessionLogging": { + "type": "boolean", + "default": true, + "description": "Write structured JSONL session logs to .mitii/logs/ for debugging and analysis." + }, + "mitii.telemetry.debugMetrics": { + "type": "boolean", + "default": false, + "description": "Capture extra session diagnostics (tool inputs, context sources, LLM step metadata). Process timing is always logged when session logging is on." + }, + "mitii.telemetry.webhookUrl": { + "type": "string", + "default": "", + "description": "Optional SIEM/webhook endpoint. When set, sanitized session events are POSTed as JSON." + }, + "mitii.telemetry.webhookSecret": { + "type": "string", + "default": "", + "description": "Optional HMAC signing secret for webhook events. Prefer workspace or managed settings for enterprise deployments." + }, + "mitii.telemetry.webhookTimeoutMs": { + "type": "number", + "default": 5000, + "minimum": 1000, + "maximum": 60000, + "description": "Timeout in milliseconds for telemetry webhook delivery attempts." + }, + "mitii.ui.showReasoning": { + "type": "boolean", + "default": true, + "description": "Show streamed model reasoning when the provider returns reasoning deltas." + }, + "mitii.ui.reasoningPreviewMaxChars": { + "type": "number", + "default": 8000, + "minimum": 0, + "maximum": 100000, + "description": "Maximum reasoning characters shown inline. Set 0 to show all reasoning." + }, + "mitii.enterprise.localProvidersOnly": { + "type": "boolean", + "default": false, + "description": "Enterprise policy flag documenting that only local providers should be used for this workspace." + }, + "mitii.enterprise.stripFileContentsFromAuditPacks": { + "type": "boolean", + "default": false, + "description": "Strip file contents and large tool outputs from exported audit packs while preserving metadata." + }, + "mitii.enterprise.autoExportAuditPackOnSessionEnd": { + "type": "boolean", + "default": false, + "description": "Export a sanitized audit pack automatically to .mitii/audit/ when an agent turn completes." + }, + "mitii.runtime.mode": { + "type": "string", + "enum": [ + "embedded", + "daemon" + ], + "default": "embedded", + "description": "Run the VS Code chat in-process or attach to a running mitii serve daemon." + }, + "mitii.runtime.daemonUrl": { + "type": "string", + "default": "http://127.0.0.1:4310", + "description": "Mitii daemon URL used when mitii.runtime.mode is daemon." + }, + "mitii.runtime.daemonToken": { + "type": "string", + "default": "", + "description": "Optional bearer token for mitii serve. Prefer SecretStorage or managed settings for enterprise use." + }, + "mitii.runtime.autoStartDaemon": { + "type": "boolean", + "default": false, + "description": "Automatically start mitii serve when a workspace opens and daemon runtime mode is selected." + }, + "mitii.provider.type": { + "type": "string", + "enum": [ + "openai-compatible", + "openrouter", + "openai", + "azure-openai", + "bedrock", + "anthropic", + "gemini", + "deepseek", + "cursor", + "codex", + "echo" + ], + "default": "echo", + "description": "LLM provider type" + }, + "mitii.provider.baseUrl": { + "type": "string", + "default": "http://localhost:11434/v1", + "description": "OpenAI-compatible API base URL" + }, + "mitii.provider.model": { + "type": "string", + "default": "qwen3-coder:30b", + "description": "Model name" + }, + "mitii.provider.apiVersion": { + "type": "string", + "default": "2024-10-21", + "description": "Provider API version. Used by Azure OpenAI when building deployment chat completion URLs." + }, + "mitii.provider.region": { + "type": "string", + "default": "us-east-1", + "description": "Cloud provider region. Used by AWS Bedrock." + }, + "mitii.provider.contextWindow": { + "type": "number", + "default": 8192, + "description": "Model context window size. Mitii trims prompts automatically to stay within this limit." + }, + "mitii.provider.supportsVision": { + "type": "boolean", + "description": "Optional override for model vision support. Leave unset to use automatic provider/model detection." + }, + "mitii.provider.supportsReasoning": { + "type": "boolean", + "description": "Optional override for streamed reasoning support. Leave unset to use automatic provider/model detection." + }, + "mitii.indexing.enabled": { + "type": "boolean", + "default": true, + "description": "Enable workspace indexing" + }, + "mitii.indexing.autoIndexOnOpen": { + "type": "boolean", + "default": true, + "description": "Automatically index the workspace when a folder is opened or switched." + }, + "mitii.indexing.maxFileSizeBytes": { + "type": "number", + "default": 512000, + "description": "Max file size for full indexing in bytes. Larger files get signature-only indexing." + }, + "mitii.indexing.hardSkipSizeBytes": { + "type": "number", + "default": 2000000, + "description": "Skip files larger than this size entirely (e.g. lockfiles)." + }, + "mitii.indexing.maxConcurrency": { + "type": "number", + "default": 2, + "minimum": 1, + "maximum": 8, + "description": "Number of files to index in parallel." + }, + "mitii.indexing.vectorsEnabled": { + "type": "boolean", + "default": true, + "description": "Enable semantic vector search. Uses MiniLM when @xenova/transformers is available, hash fallback otherwise." + }, + "mitii.indexing.treeSitterEnabled": { + "type": "boolean", + "default": true, + "description": "Use tree-sitter WASM for symbol extraction (regex fallback when unavailable)" + }, + "mitii.indexing.embeddingProvider": { + "type": "string", + "enum": [ + "hash", + "minilm" + ], + "default": "minilm", + "description": "Embedding provider when vectors are enabled. minilm uses local @xenova/transformers (all-MiniLM-L6-v2); falls back to hash if unavailable." + }, + "mitii.indexing.vectorBackend": { + "type": "string", + "enum": [ + "sqlite", + "lancedb" + ], + "default": "lancedb", + "description": "Vector index storage: sqlite (.mitii/mitii.sqlite) or lancedb (.mitii/lance/) via @lancedb/lancedb" + }, + "mitii.context.rerankerEnabled": { + "type": "boolean", + "default": true, + "description": "Rerank top retrieval candidates for better context quality (top-20 → top-8)" + }, + "mitii.context.rerankerCandidatePool": { + "type": "number", + "default": 20, + "minimum": 5, + "maximum": 50, + "description": "Number of candidates to pass to the reranker" + }, + "mitii.context.rerankerTopK": { + "type": "number", + "default": 8, + "minimum": 3, + "maximum": 30, + "description": "Number of items to keep after reranking" + }, + "mitii.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." + }, + "mitii.safety.requireApprovalForWrites": { + "type": "boolean", + "default": true, + "description": "Require approval for file writes" + }, + "mitii.safety.requireApprovalForShell": { + "type": "boolean", + "default": true, + "description": "Require approval for shell commands" + }, + "mitii.safety.approvalMode": { + "type": "string", + "enum": [ + "review_all", + "ask_edits", + "ask_deletes", + "ask_commands", + "auto" + ], + "enumDescriptions": [ + "Ask before file edits and mutating shell commands.", + "Ask before file edits and delete-like shell commands; allow other mutating shell commands.", + "Ask only before delete-like shell commands; allow file edits and other commands.", + "Ask before mutating shell commands; allow file edits.", + "Auto-approve allowed tools and commands. Dangerous commands are still blocked." + ], + "default": "review_all", + "description": "Approval mode for agent operations" + }, + "mitii.safety.autonomyPreset": { + "type": "string", + "enum": [ + "safe", + "guided", + "builder", + "pilot", + "enterprise" + ], + "default": "guided", + "description": "Autonomy preset" + }, + "mitii.memory.enabled": { + "type": "boolean", + "default": true, + "description": "Enable local memory" + }, + "mitii.memory.hybridSearchEnabled": { + "type": "boolean", + "default": true, + "description": "Use FTS5 + optional vector hybrid search for observations" + }, + "mitii.memory.summarizeAfterTask": { + "type": "boolean", + "default": true, + "description": "Summarize completed tasks into durable local memory." + }, + "mitii.memory.autoMemoryEnabled": { + "type": "boolean", + "default": true, + "description": "Write post-task memories to human-readable markdown files in addition to SQLite observations." + }, + "mitii.memory.autoMemoryScope": { + "type": "string", + "enum": [ + "user", + "workspace", + "both" + ], + "default": "user", + "description": "Where markdown auto-memory files are written: user profile, workspace .mitii/auto-memory, or both." + }, + "mitii.scm.commitMessageEnabled": { + "type": "boolean", + "default": true, + "description": "Show Mitii commit-message generation in VS Code Source Control." + }, + "mitii.github.issueFetchEnabled": { + "type": "boolean", + "default": true, + "description": "Fetch structured GitHub issue context when a chat message contains a github.com owner/repo/issues URL and network access is allowed." + }, + "mitii.github.issueCommentLimit": { + "type": "number", + "default": 8, + "minimum": 0, + "maximum": 25, + "description": "Maximum number of GitHub issue comments to include in injected issue context." + }, + "mitii.github.tokenRef": { + "type": "string", + "default": "mitii.github.token", + "description": "VS Code SecretStorage key for an optional GitHub token used to fetch private issues. The token itself is not stored in settings." + }, + "mitii.agent.subagentsEnabled": { + "type": "boolean", + "default": true, + "description": "Enable subagent tools for typed delegation." + }, + "mitii.agent.subagentTypesEnabled": { + "type": "array", + "default": [ + "research" + ], + "items": { + "type": "string" + }, + "description": "Allowed subagent types. Built-ins are research, implementer, reviewer, and verifier; custom .mitii/agents ids may also be listed." + }, + "mitii.agent.maxConcurrentSubagents": { + "type": "number", + "default": 2, + "minimum": 1, + "maximum": 10, + "description": "Maximum subagents allowed to run concurrently in one session." + }, + "mitii.agent.implementerRequiresScope": { + "type": "boolean", + "default": true, + "description": "Require implementer subagents to receive targetFiles or scopeRoot before write tools are available." + }, + "mitii.agent.subagentDailyBudget": { + "type": "number", + "default": 0, + "minimum": 0, + "description": "Maximum subagent invocations per session day. 0 means unlimited." + }, + "mitii.agent.maxSteps": { + "type": "number", + "default": 15, + "minimum": 1, + "maximum": 100, + "description": "Maximum tool-use steps before the main agent stops or auto-continues." + }, + "mitii.agent.askMaxSteps": { + "type": "number", + "default": 18, + "minimum": 1, + "maximum": 50, + "description": "Maximum tool-use steps per Ask-mode turn (read-only exploration). Use 16-20 for deep codebase explanations." + }, + "mitii.agent.askDepth": { + "type": "string", + "enum": [ + "auto", + "quick", + "standard", + "deep", + "pilot", + "enterprise" + ], + "default": "auto", + "description": "Ask-mode exploration depth. Auto chooses by intent; quick is concise; deep explores more before answering." + }, + "mitii.agent.planDepth": { + "type": "string", + "enum": [ + "auto", + "quick", + "standard", + "deep", + "pilot", + "enterprise" + ], + "default": "auto", + "description": "Plan-mode discovery depth before structured plan compilation. Auto chooses by intent; deep explores more of the codebase." + }, + "mitii.agent.actDepth": { + "type": "string", + "enum": [ + "auto", + "quick", + "standard", + "deep", + "pilot", + "enterprise" + ], + "default": "auto", + "description": "Agent/Act execution depth. Auto chooses by task route; quick caps direct work, deep allows up to 16 tool steps." + }, + "mitii.agent.askAutoContinue": { + "type": "boolean", + "default": true, + "description": "Allow Ask mode to continue when deep exploration reaches its per-round limit." + }, + "mitii.agent.askMaxAutoContinues": { + "type": "number", + "default": 1, + "minimum": 0, + "maximum": 10, + "description": "Maximum automatic continuation rounds for Ask mode." + }, + "mitii.agent.autoContinue": { + "type": "boolean", + "default": true, + "description": "Allow the agent to keep working after it reaches the per-round step limit." + }, + "mitii.agent.maxAutoContinues": { + "type": "number", + "default": 2, + "minimum": 0, + "maximum": 10, + "description": "Maximum number of automatic continuation rounds after the main step limit." + }, + "mitii.agent.researchAgentMaxSteps": { + "type": "number", + "default": 10, + "minimum": 1, + "maximum": 50, + "description": "Maximum tool-use steps for each read-only research subagent." + }, + "mitii.agent.researchAgentModel": { + "type": "string", + "default": "", + "description": "Optional faster model for read-only research subagents. Empty uses the main provider model." + }, + "mitii.agent.researchAgentBaseUrl": { + "type": "string", + "default": "", + "description": "Optional OpenAI-compatible base URL for the research subagent model. Empty uses mitii.provider.baseUrl." + }, + "mitii.agent.orchestrationEnabled": { + "type": "boolean", + "default": true, + "description": "Enable the multi-step planner in Plan mode. Act mode uses a faster direct agent path by default." + }, + "mitii.agent.showDiffPreview": { + "type": "boolean", + "default": false, + "description": "Open VS Code diff preview tabs before write_file/apply_patch tool calls. Disabled by default to avoid editor focus changes." + }, + "mitii.agent.verifyCommands": { + "type": "array", + "default": [], + "items": { + "type": "string" + }, + "description": "Optional shell commands to run after Act-mode completes. Leave empty to auto-discover from package.json scripts (typecheck, lint, test, build) based on touched files." + }, + "mitii.agent.verifyOnActComplete": { + "type": "boolean", + "default": true, + "description": "After Act-mode runs, discover and run project-appropriate verification (package.json scripts). Uses verifyCommands when set; otherwise auto-discovers." + }, + "mitii.agent.planModel": { + "type": "string", + "default": "", + "description": "Optional model override for Plan mode. Empty uses mitii.provider.model." + }, + "mitii.agent.planBaseUrl": { + "type": "string", + "default": "", + "description": "Optional base URL override for Plan mode." + }, + "mitii.agent.actModel": { + "type": "string", + "default": "", + "description": "Optional model override for Agent (Act) mode. Empty uses mitii.provider.model." + }, + "mitii.agent.actBaseUrl": { + "type": "string", + "default": "", + "description": "Optional base URL override for Agent (Act) mode." + }, + "mitii.agent.checkpointStrategy": { + "type": "string", + "enum": [ + "file-copy", + "git-stash", + "shadow-git" + ], + "default": "git-stash", + "description": "Checkpoint strategy before file writes. Git stash when repo available, else file copy." + }, + "mitii.safety.allowUntrustedWorkspace": { + "type": "boolean", + "default": false, + "description": "Allow writes and shell in untrusted workspaces (not recommended)." + }, + "mitii.mcp.enabled": { + "type": "boolean", + "default": true, + "description": "Enable Model Context Protocol servers. Built-in free servers start automatically unless mitii.mcp.preloadBuiltin is false." + }, + "mitii.mcp.preloadBuiltin": { + "type": "boolean", + "default": true, + "description": "Preload official free MCP servers (filesystem, memory, sequential-thinking) on extension startup. Toggle individual servers in the Mitii settings UI." + }, + "mitii.mcp.builtinServers": { + "type": "object", + "default": { + "filesystem": true, + "memory": true, + "sequentialThinking": true, + "agentmemory": false + }, + "description": "Default on/off state for built-in MCP servers. Per-session toggles are available in the Mitii Integrations settings tab." + }, + "mitii.mcp.maxConcurrentStartup": { + "type": "number", + "default": 4, + "minimum": 1, + "maximum": 20, + "description": "Maximum MCP stdio servers Mitii starts at the same time." + }, + "mitii.mcp.servers": { + "type": "object", + "default": {}, + "description": "MCP server definitions keyed by server name. Supports stdio servers: { \"my-server\": { \"command\": \"npx\", \"args\": [\"-y\", \"@modelcontextprotocol/server-filesystem\", \".\"] } }" + }, + "mitii.workspace.rootPathOverride": { + "type": "string", + "default": "", + "description": "Fallback workspace root when no VS Code folder is open. When a folder is open, Mitii always uses that folder and ignores this setting." + }, + "mitii.indexing.watchDebounceMs": { + "type": "number", + "default": 500, + "minimum": 100, + "maximum": 10000, + "description": "Debounce in milliseconds for single-file indexing updates." + }, + "mitii.indexing.priorityPaths": { + "type": "array", + "default": [], + "items": { + "type": "string" + }, + "description": "Relative paths that should be indexed before background backlog work." + }, + "mitii.github.autoPrEnabled": { + "type": "boolean", + "default": false, + "description": "Allow Mitii workflows to create GitHub pull requests when explicitly requested." + }, + "mitii.github.defaultBaseBranch": { + "type": "string", + "default": "", + "description": "Default target branch for Mitii-created pull requests. Empty uses CLI defaults." + }, + "mitii.github.webhookSecret": { + "type": "string", + "default": "", + "description": "Shared secret used to verify GitHub webhook requests before enqueueing async jobs." + }, + "mitii.agent.teamsEnabled": { + "type": "boolean", + "default": false, + "description": "Enable persistent team coordination features when a team name is selected." + }, + "mitii.enterprise.channelsDisabled": { + "type": "boolean", + "default": false, + "description": "Disable Slack, Telegram, Discord, and other channel connectors by policy." + }, + "mitii.enterprise.maxParallel": { + "type": "number", + "default": 10, + "minimum": 1, + "maximum": 100, + "description": "Maximum parallel Mitii sessions or teammates allowed by managed enterprise policy." + }, + "thunder.indexing.watchDebounceMs": { + "type": "number", + "default": 500, + "minimum": 100, + "maximum": 10000, + "description": "Debounce in milliseconds for single-file indexing updates.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." + }, + "thunder.indexing.priorityPaths": { + "type": "array", + "default": [], + "items": { + "type": "string" + }, + "description": "Relative paths that should be indexed before background backlog work.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." + }, + "thunder.github.autoPrEnabled": { + "type": "boolean", + "default": false, + "description": "Allow Mitii workflows to create GitHub pull requests when explicitly requested.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." + }, + "thunder.github.defaultBaseBranch": { + "type": "string", + "default": "", + "description": "Default target branch for Mitii-created pull requests. Empty uses CLI defaults.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." + }, + "thunder.github.webhookSecret": { + "type": "string", + "default": "", + "description": "Shared secret used to verify GitHub webhook requests before enqueueing async jobs.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." + }, + "thunder.agent.teamsEnabled": { + "type": "boolean", + "default": false, + "description": "Enable persistent team coordination features when a team name is selected.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." + }, + "thunder.enterprise.channelsDisabled": { + "type": "boolean", + "default": false, + "description": "Disable Slack, Telegram, Discord, and other channel connectors by policy.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." + }, + "thunder.enterprise.maxParallel": { + "type": "number", + "default": 10, + "minimum": 1, + "maximum": 100, + "description": "Maximum parallel Mitii sessions or teammates allowed by managed enterprise policy.", + "deprecationMessage": "Use the mitii.* setting. thunder.* is kept temporarily for backward compatibility and is planned for removal in v3.0." } } } @@ -610,13 +1474,15 @@ "vscode:prepublish": "pnpm run compile", "compile": "pnpm run compile:extension && pnpm run compile:webview && pnpm run compile:cli", "setup": "bash scripts/dev-setup.sh", - "setup:cursor": "THUNDER_EDITOR=cursor bash scripts/dev-setup.sh", + "setup:cursor": "MITII_EDITOR=cursor bash scripts/dev-setup.sh", "rebuild:native": "node scripts/rebuild-native.mjs", "rebuild:node": "node scripts/rebuild-node.mjs", "rebuild:all": "pnpm run rebuild:native && pnpm run rebuild:node", - "postinstall": "node -e \"console.log('Run pnpm run rebuild:native before F5 if better-sqlite3 fails to load')\"", + "postinstall": "node -e \"console.log('Run pnpm run rebuild:native before F5 if native modules or MiniLM embeddings fail to load')\"", "prepare": "node scripts/install-git-hooks.mjs", "version:bump": "node scripts/bump-version.mjs", + "sdk:sync-version": "node scripts/sync-sdk-version.mjs", + "sdk:build": "pnpm --filter @mitii/sdk build", "readme:sync-version": "node scripts/sync-readme-version.mjs", "release:check-assets": "node scripts/check-release-assets.mjs", "release:prepare": "pnpm run compile:cli && node dist/cli.js prepare-release", @@ -632,6 +1498,9 @@ "eval:aggregate": "node tools/benchmark/scripts/aggregate-results.mjs", "eval:matrix": "pnpm --filter @mitii/benchmark eval:matrix", "eval:reset-fixtures": "node tools/benchmark/scripts/reset-fixtures.mjs", + "eval:retrieval": "vitest run test/benchmark/retrieval-eval.test.ts", + "benchmark:manual": "pnpm --filter @mitii/benchmark benchmark:manual", + "benchmark:manual:validate": "pnpm --filter @mitii/benchmark benchmark:manual:validate", "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:skills": "node scripts/copy-bundled-skills.mjs", @@ -640,6 +1509,8 @@ "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", "test": "vitest run", + "test:daemon": "vitest run test/daemon.test.ts", + "test:parallel": "vitest run test/parallel-agents.test.ts", "test:watch": "vitest", "lint": "tsc --noEmit", "scripts:search": "node scripts/search-script-catalog.mjs", @@ -668,7 +1539,10 @@ "publish:patch": "pnpm run version:patch && pnpm run publish:vsce", "publish:minor": "pnpm run version:minor && pnpm run publish:vsce", "publish:major": "pnpm run version:major && pnpm run publish:vsce", - "smoke": "vitest run test/smoke" + "smoke": "vitest run test/smoke", + "publish:ovsx": "pnpm dlx ovsx publish", + "release:all": "pnpm test && pnpm run package && pnpm run publish:ovsx", + "sync:versions": "node scripts/sync-versions.mjs" }, "devDependencies": { "@electron/rebuild": "^3.7.2", diff --git a/packages/board/package.json b/packages/board/package.json new file mode 100644 index 00000000..8082dbf4 --- /dev/null +++ b/packages/board/package.json @@ -0,0 +1,20 @@ +{ + "name": "@mitii/board", + "version": "2.7.32", + "description": "Minimal task board server for Mitii parallel agents.", + "license": "AGPL-3.0-or-later", + "type": "module", + "main": "./dist/server.js", + "engines": { + "node": ">=20" + }, + "types": "./dist/server.d.ts", + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "esbuild src/server.ts --bundle --outfile=dist/server.js --platform=node --format=esm --packages=external" + } +} diff --git a/packages/board/src/server.ts b/packages/board/src/server.ts new file mode 100644 index 00000000..6cc761b9 --- /dev/null +++ b/packages/board/src/server.ts @@ -0,0 +1,102 @@ +import { createServer, type Server } from 'http'; +import { resolve } from 'path'; +import { TaskBoardService, ParallelAgentRunner, type ParallelAgentRunnerOptions } from '../../../src/core/task'; +import type { HeadlessRuntime } from '../../../src/core/headless/HeadlessConfig'; +import { writeJson } from '../../daemon/src/authMiddleware'; + +export interface BoardServerOptions { + cwd: string; + hostname?: string; + port?: number; + token?: string; +} + +export async function startMitiiBoard(options: BoardServerOptions): Promise<{ server: Server; url: string; close(): Promise }> { + const cwd = resolve(options.cwd); + const board = new TaskBoardService(cwd); + const hostname = options.hostname ?? '127.0.0.1'; + const port = options.port ?? 4311; + const token = options.token ?? process.env.MITII_BOARD_TOKEN; + const server = createServer(async (req, res) => { + const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`); + if (token && req.headers.authorization !== `Bearer ${token}`) { + writeJson(res, 401, { error: { code: 'unauthorized', message: 'Bearer token required' } }); + return; + } + if (req.method === 'GET' && url.pathname === '/') { + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); + res.end(renderBoard(board.list())); + return; + } + if (req.method === 'GET' && url.pathname === '/tasks') { + writeJson(res, 200, { tasks: board.list() }); + return; + } + if (req.method === 'POST' && url.pathname === '/tasks') { + const body = await readJson(req); + const task = board.add({ title: String(body.title ?? 'Untitled task'), prompt: String(body.prompt ?? body.title ?? '') }); + writeJson(res, 201, { task }); + return; + } + const match = url.pathname.match(/^\/tasks\/([^/]+)\/start$/); + if (req.method === 'POST' && match) { + const task = board.transition(match[1], 'running'); + writeJson(res, 200, { task }); + return; + } + if (req.method === 'POST' && url.pathname === '/tasks/run') { + const runtime = (url.searchParams.get('runtime') as HeadlessRuntime | null) ?? 'real'; + const providerType = url.searchParams.get('provider') ?? undefined; + const runner = new ParallelAgentRunner({ + workspace: cwd, + parallel: Number(url.searchParams.get('parallel') ?? 2), + runtime, + providerType: providerType as ParallelAgentRunnerOptions['providerType'], + }); + writeJson(res, 202, await runner.runRunnable()); + return; + } + writeJson(res, 404, { error: { code: 'not_found', message: 'Route not found' } }); + }); + await new Promise((resolveListen, reject) => { + server.once('error', reject); + server.listen(port, hostname, () => resolveListen()); + }); + return { + server, + url: `http://${hostname}:${port}`, + close: () => new Promise((resolveClose) => server.close(() => resolveClose())), + }; +} + +async function readJson(req: NodeJS.ReadableStream): Promise> { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + const raw = Buffer.concat(chunks).toString('utf-8').trim(); + return raw ? JSON.parse(raw) as Record : {}; +} + +function renderBoard(tasks: Array<{ id: string; title: string; status: string; prompt: string }>): string { + const columns = ['backlog', 'running', 'review', 'done', 'failed', 'cancelled']; + const body = columns.map((column) => { + const cards = tasks.filter((task) => task.status === column).map((task) => + `
${escapeHtml(task.title)}${task.id}

${escapeHtml(task.prompt)}

` + ).join(''); + return `

${column}

${cards || '

No tasks

'}
`; + }).join(''); + return ` +Mitii Board +

Mitii Task Board

${body}
`; +} + +function escapeHtml(value: string): string { + return value.replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char] ?? char)); +} diff --git a/packages/channels/package.json b/packages/channels/package.json new file mode 100644 index 00000000..696092a6 --- /dev/null +++ b/packages/channels/package.json @@ -0,0 +1,14 @@ +{ + "name": "@mitii/channels", + "version": "2.7.32", + "private": true, + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "files": [ + "src" + ], + "dependencies": { + "@mitii/sdk": "workspace:*" + } +} diff --git a/packages/channels/src/base.ts b/packages/channels/src/base.ts new file mode 100644 index 00000000..ea5a2ae8 --- /dev/null +++ b/packages/channels/src/base.ts @@ -0,0 +1,92 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { dirname } from 'path'; +import { DaemonClient, DaemonSessionClient } from '../../sdk/src'; +import type { MitiiMode } from '../../sdk/src'; + +export interface ChannelMessage { + channel: string; + threadId: string; + userId: string; + text: string; +} + +export interface ChannelSecurityPolicy { + allowedUsers?: string[]; + allowedThreads?: string[]; + readOnly?: boolean; + maxPromptChars?: number; +} + +export interface ChannelRuntimeOptions { + daemonUrl: string; + daemonToken?: string; + cwd: string; + sessionStorePath: string; + security?: ChannelSecurityPolicy; +} + +export class ChannelRuntimeAdapter { + private readonly client: DaemonClient; + private readonly sessions: Record; + + constructor(private readonly options: ChannelRuntimeOptions) { + this.client = new DaemonClient({ baseUrl: options.daemonUrl, token: options.daemonToken }); + this.sessions = readJson>(options.sessionStorePath, {}); + } + + async prompt(message: ChannelMessage, mode: MitiiMode = 'agent'): Promise { + const policy = this.options.security ?? {}; + if (policy.allowedUsers?.length && !policy.allowedUsers.includes(message.userId)) { + return 'This user is not allowed to use Mitii from this channel.'; + } + if (policy.allowedThreads?.length && !policy.allowedThreads.includes(message.threadId)) { + return 'This channel thread is not allowlisted for Mitii.'; + } + const safeMode = policy.readOnly && mode === 'agent' ? 'plan' : mode; + const text = sanitizeOutbound(message.text).slice(0, policy.maxPromptChars ?? 12_000); + const session = await this.getOrCreateSession(message.threadId, safeMode); + const events = session.events(); + await session.prompt({ mode: safeMode, message: text }); + let output = ''; + for await (const event of events) { + if (event.type === 'assistant_delta') output += event.content; + if (event.type === 'error') return `Mitii error: ${event.message}`; + if (event.type === 'approval_required') output += `\nApproval required: ${event.tool}\n`; + if (event.type === 'done') break; + } + return sanitizeOutbound(output || 'Mitii completed without assistant output.'); + } + + private async getOrCreateSession(threadId: string, mode: MitiiMode): Promise { + const existing = this.sessions[threadId]; + if (existing) { + return new DaemonSessionClient(this.client, await this.client.getSession(existing)); + } + const session = await this.client.createSession({ + cwd: this.options.cwd, + mode, + approval: 'manual', + runtime: 'real', + }); + this.sessions[threadId] = session.id; + writeJson(this.options.sessionStorePath, this.sessions); + return new DaemonSessionClient(this.client, session); + } +} + +export function sanitizeOutbound(value: string): string { + return value.replace(/\b[A-Za-z0-9_=-]{24,}\b/g, '[redacted]'); +} + +function readJson(path: string, fallback: T): T { + try { + return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) as T : fallback; + } catch { + return fallback; + } +} + +function writeJson(path: string, value: unknown): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); +} diff --git a/packages/channels/src/index.ts b/packages/channels/src/index.ts new file mode 100644 index 00000000..fdcca32a --- /dev/null +++ b/packages/channels/src/index.ts @@ -0,0 +1,2 @@ +export * from './base'; +export * from './telegram'; diff --git a/packages/channels/src/telegram.ts b/packages/channels/src/telegram.ts new file mode 100644 index 00000000..71b7979d --- /dev/null +++ b/packages/channels/src/telegram.ts @@ -0,0 +1,79 @@ +import { homedir } from 'os'; +import { join } from 'path'; +import { ChannelRuntimeAdapter, type ChannelRuntimeOptions } from './base'; +import type { MitiiMode } from '../../sdk/src'; + +export interface TelegramConnectorOptions extends Omit { + token: string; + pollTimeoutSeconds?: number; + sessionStorePath?: string; +} + +export class TelegramConnector { + private readonly runtime: ChannelRuntimeAdapter; + private offset = 0; + + constructor(private readonly options: TelegramConnectorOptions) { + this.runtime = new ChannelRuntimeAdapter({ + ...options, + sessionStorePath: options.sessionStorePath ?? join(homedir(), '.mitii', 'connectors', 'telegram-sessions.json'), + }); + } + + async start(): Promise { + while (true) { + const updates = await this.request<{ ok: boolean; result: TelegramUpdate[] }>('getUpdates', { + offset: this.offset, + timeout: this.options.pollTimeoutSeconds ?? 30, + }); + for (const update of updates.result ?? []) { + this.offset = Math.max(this.offset, update.update_id + 1); + const message = update.message; + if (!message?.text) continue; + const { mode, text } = parseTelegramCommand(message.text); + const reply = await this.runtime.prompt({ + channel: 'telegram', + threadId: String(message.chat.id), + userId: String(message.from?.id ?? message.chat.id), + text, + }, mode); + await this.request('sendMessage', { + chat_id: message.chat.id, + text: reply.slice(0, 3900), + reply_to_message_id: message.message_id, + }); + } + } + } + + private async request(method: string, body: Record): Promise { + const response = await fetch(`https://api.telegram.org/bot${this.options.token}/${method}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!response.ok) throw new Error(`Telegram ${method} failed with ${response.status}`); + return await response.json() as T; + } +} + +function parseTelegramCommand(input: string): { mode: MitiiMode; text: string } { + const trimmed = input.trim(); + const [command, ...rest] = trimmed.split(/\s+/); + if (command === '/ask') return { mode: 'ask', text: rest.join(' ') }; + if (command === '/plan') return { mode: 'plan', text: rest.join(' ') }; + if (command === '/agent') return { mode: 'agent', text: rest.join(' ') }; + if (command === '/status') return { mode: 'ask', text: 'Summarize the current Mitii session status.' }; + if (command === '/cancel') return { mode: 'ask', text: 'Cancel is not available from this connector yet.' }; + return { mode: 'agent', text: trimmed }; +} + +interface TelegramUpdate { + update_id: number; + message?: { + message_id: number; + text?: string; + chat: { id: number }; + from?: { id: number }; + }; +} diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 00000000..4a14a46b --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,3 @@ +# Mitii CLI + +This package is the npm entry point for the platform-native `mitii` binary. It resolves one of the optional `@mitii/cli--` packages at install time and falls back to the Node launcher during development builds. diff --git a/packages/cli/bin/mitii b/packages/cli/bin/mitii new file mode 100755 index 00000000..2f5285de --- /dev/null +++ b/packages/cli/bin/mitii @@ -0,0 +1,28 @@ +#!/usr/bin/env node +import { existsSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; +import { spawnSync } from 'child_process'; +import { createRequire } from 'module'; + +const here = dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); +const platformPackage = `@mitii/cli-${process.platform}-${process.arch}`; +let binary; + +try { + const pkgPath = require.resolve(`${platformPackage}/package.json`); + const pkgDir = dirname(pkgPath); + const exe = process.platform === 'win32' ? 'mitii.exe' : 'mitii'; + binary = join(pkgDir, 'bin', exe); +} catch { + binary = join(here, '..', '..', '..', 'bin', 'mitii.js'); +} + +if (!existsSync(binary)) { + console.error(`Mitii binary not found for ${process.platform}-${process.arch}.`); + process.exit(1); +} + +const result = spawnSync(binary, process.argv.slice(2), { stdio: 'inherit' }); +process.exit(result.status ?? 1); diff --git a/packages/cli/optional-packages/mitii-darwin-arm64/package.json b/packages/cli/optional-packages/mitii-darwin-arm64/package.json new file mode 100644 index 00000000..39141e24 --- /dev/null +++ b/packages/cli/optional-packages/mitii-darwin-arm64/package.json @@ -0,0 +1,15 @@ +{ + "name": "@mitii/cli-darwin-arm64", + "version": "2.7.32", + "description": "Mitii native CLI for macOS arm64", + "license": "AGPL-3.0-or-later", + "os": [ + "darwin" + ], + "cpu": [ + "arm64" + ], + "files": [ + "bin" + ] +} diff --git a/packages/cli/optional-packages/mitii-darwin-x64/package.json b/packages/cli/optional-packages/mitii-darwin-x64/package.json new file mode 100644 index 00000000..f8af7913 --- /dev/null +++ b/packages/cli/optional-packages/mitii-darwin-x64/package.json @@ -0,0 +1,15 @@ +{ + "name": "@mitii/cli-darwin-x64", + "version": "2.7.32", + "description": "Mitii native CLI for macOS x64", + "license": "AGPL-3.0-or-later", + "os": [ + "darwin" + ], + "cpu": [ + "x64" + ], + "files": [ + "bin" + ] +} diff --git a/packages/cli/optional-packages/mitii-linux-x64/package.json b/packages/cli/optional-packages/mitii-linux-x64/package.json new file mode 100644 index 00000000..a4872d73 --- /dev/null +++ b/packages/cli/optional-packages/mitii-linux-x64/package.json @@ -0,0 +1,15 @@ +{ + "name": "@mitii/cli-linux-x64", + "version": "2.7.32", + "description": "Mitii native CLI for Linux x64", + "license": "AGPL-3.0-or-later", + "os": [ + "linux" + ], + "cpu": [ + "x64" + ], + "files": [ + "bin" + ] +} diff --git a/packages/cli/optional-packages/mitii-win32-x64/package.json b/packages/cli/optional-packages/mitii-win32-x64/package.json new file mode 100644 index 00000000..f98bd459 --- /dev/null +++ b/packages/cli/optional-packages/mitii-win32-x64/package.json @@ -0,0 +1,15 @@ +{ + "name": "@mitii/cli-win32-x64", + "version": "2.7.32", + "description": "Mitii native CLI for Windows x64", + "license": "AGPL-3.0-or-later", + "os": [ + "win32" + ], + "cpu": [ + "x64" + ], + "files": [ + "bin" + ] +} diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 00000000..d158c791 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,24 @@ +{ + "name": "mitii", + "version": "2.7.32", + "description": "Mitii native CLI launcher", + "license": "AGPL-3.0-or-later", + "type": "module", + "bin": { + "mitii": "bin/mitii" + }, + "files": [ + "bin", + "README.md" + ], + "optionalDependencies": { + "@mitii/cli-darwin-arm64": "2.7.32", + "@mitii/cli-darwin-x64": "2.7.32", + "@mitii/cli-linux-x64": "2.7.32", + "@mitii/cli-win32-x64": "2.7.32" + }, + "scripts": { + "build:platform": "node scripts/build-platform.mjs", + "publish:npm": "node scripts/publish-npm.mjs" + } +} diff --git a/packages/cli/scripts/build-platform.mjs b/packages/cli/scripts/build-platform.mjs new file mode 100644 index 00000000..801df5b2 --- /dev/null +++ b/packages/cli/scripts/build-platform.mjs @@ -0,0 +1,16 @@ +import { mkdirSync, copyFileSync, chmodSync, writeFileSync } from 'fs'; +import { dirname, join, resolve } from 'path'; +import { execFileSync } from 'child_process'; +import { fileURLToPath } from 'url'; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(scriptDir, '../../..'); +const target = process.env.MITII_PLATFORM_TARGET ?? `${process.platform}-${process.arch}`; +const outDir = join(repoRoot, 'dist-native', target); +mkdirSync(outDir, { recursive: true }); + +execFileSync('pnpm', ['build'], { cwd: repoRoot, stdio: 'inherit' }); +copyFileSync(join(repoRoot, 'bin/mitii.js'), join(outDir, process.platform === 'win32' ? 'mitii.cmd' : 'mitii')); +chmodSync(join(outDir, process.platform === 'win32' ? 'mitii.cmd' : 'mitii'), 0o755); +writeFileSync(join(outDir, 'SHA256SUMS'), '# populated by release workflow after archive creation\n'); +console.log(`Prepared Mitii platform bundle in ${outDir}`); diff --git a/packages/cli/scripts/publish-npm.mjs b/packages/cli/scripts/publish-npm.mjs new file mode 100644 index 00000000..8de1e24f --- /dev/null +++ b/packages/cli/scripts/publish-npm.mjs @@ -0,0 +1,14 @@ +import { execFileSync } from 'child_process'; + +const tag = process.env.NPM_TAG ?? 'latest'; +const packages = [ + 'packages/cli/optional-packages/mitii-darwin-arm64', + 'packages/cli/optional-packages/mitii-darwin-x64', + 'packages/cli/optional-packages/mitii-linux-x64', + 'packages/cli/optional-packages/mitii-win32-x64', + 'packages/cli', +]; + +for (const dir of packages) { + execFileSync('npm', ['publish', '--access', 'public', '--tag', tag], { cwd: dir, stdio: 'inherit' }); +} diff --git a/packages/daemon/package.json b/packages/daemon/package.json new file mode 100644 index 00000000..a64a66f6 --- /dev/null +++ b/packages/daemon/package.json @@ -0,0 +1,27 @@ +{ + "name": "@mitii/daemon", + "version": "2.7.32", + "description": "HTTP/SSE daemon runtime for Mitii sessions.", + "license": "AGPL-3.0-or-later", + "type": "module", + "main": "./dist/server.js", + "types": "./dist/server.d.ts", + "exports": { + ".": { + "types": "./dist/server.d.ts", + "import": "./dist/server.js", + "default": "./dist/server.js" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "engines": { + "node": ">=20" + }, + "scripts": { + "build": "esbuild src/server.ts --bundle --outfile=dist/server.js --platform=node --format=esm --packages=external && esbuild src/cli.ts --bundle --outfile=dist/cli.js --platform=node --format=esm --packages=external" + } +} diff --git a/packages/daemon/src/authMiddleware.ts b/packages/daemon/src/authMiddleware.ts new file mode 100644 index 00000000..6a68bd89 --- /dev/null +++ b/packages/daemon/src/authMiddleware.ts @@ -0,0 +1,34 @@ +import { timingSafeEqual } from 'crypto'; +import type { IncomingMessage, ServerResponse } from 'http'; + +export interface AuthOptions { + token?: string; +} + +export function isAuthorized(req: IncomingMessage, options: AuthOptions): boolean { + if (!options.token) return true; + const header = req.headers.authorization ?? ''; + const prefix = 'Bearer '; + if (!header.startsWith(prefix)) return false; + return safeEqual(header.slice(prefix.length), options.token); +} + +export function writeUnauthorized(res: ServerResponse): void { + writeJson(res, 401, { error: { code: 'unauthorized', message: 'Missing or invalid bearer token' } }); +} + +export function safeEqual(a: string, b: string): boolean { + const left = Buffer.from(a); + const right = Buffer.from(b); + if (left.length !== right.length) return false; + return timingSafeEqual(left, right); +} + +export function writeJson(res: ServerResponse, status: number, body: unknown, headers: Record = {}): void { + res.writeHead(status, { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + ...headers, + }); + res.end(JSON.stringify(body)); +} diff --git a/packages/daemon/src/cli.ts b/packages/daemon/src/cli.ts new file mode 100644 index 00000000..1024c4e5 --- /dev/null +++ b/packages/daemon/src/cli.ts @@ -0,0 +1,37 @@ +import { startMitiiDaemon } from './server'; + +export async function serveCommand(args: string[], cwd: string): Promise { + const hostname = valueOf(args, '--hostname') ?? '127.0.0.1'; + const port = Number(valueOf(args, '--port') ?? 4310); + const token = valueOf(args, '--token') ?? process.env.MITII_SERVER_TOKEN; + const maxSessions = Number(valueOf(args, '--max-sessions') ?? 5); + const allowOrigin = valueOf(args, '--allow-origin'); + const insecureBind = args.includes('--insecure-bind'); + const server = await startMitiiDaemon({ + cwd: valueOf(args, '--cwd') ?? cwd, + hostname, + port: Number.isFinite(port) ? port : 4310, + token, + maxSessions: Number.isFinite(maxSessions) ? maxSessions : 5, + allowOrigin, + insecureBind, + }); + process.stderr.write(`Mitii daemon listening on ${server.url}\n`); + process.stderr.write(`Workspace: ${valueOf(args, '--cwd') ?? cwd}\n`); + process.stderr.write(token ? 'Auth: bearer token enabled\n' : 'Auth: disabled for loopback\n'); + + const shutdown = async () => { + process.stderr.write('Stopping Mitii daemon...\n'); + await server.close(); + process.exit(0); + }; + process.once('SIGINT', () => void shutdown()); + process.once('SIGTERM', () => void shutdown()); + await new Promise(() => undefined); + return 0; +} + +function valueOf(args: string[], name: string): string | undefined { + const idx = args.indexOf(name); + return idx >= 0 ? args[idx + 1] : undefined; +} diff --git a/packages/daemon/src/server.ts b/packages/daemon/src/server.ts new file mode 100644 index 00000000..efe9981d --- /dev/null +++ b/packages/daemon/src/server.ts @@ -0,0 +1,258 @@ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'http'; +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import { version as nodeVersion } from 'process'; +import { isAuthorized, writeJson, writeUnauthorized } from './authMiddleware'; +import { SessionConflictError, SessionLimitError, SessionManager, SessionNotFoundError } from './sessionManager'; +import { SseHub } from './sseHub'; +import { isLoopbackHost, validateWorkspace } from './workspaceBinding'; +import type { MitiiApprovalDecision, MitiiMode } from '../../../src/core/headless/events'; +import { IndexWorkerService } from '../../../src/core/indexing/IndexWorkerService'; + +export interface MitiiDaemonServerOptions { + cwd: string; + hostname?: string; + port?: number; + token?: string; + maxSessions?: number; + allowOrigin?: string; + insecureBind?: boolean; + packageRoot?: string; +} + +export interface MitiiDaemonServerHandle { + server: Server; + url: string; + close(): Promise; +} + +export async function startMitiiDaemon(options: MitiiDaemonServerOptions): Promise { + const hostname = options.hostname ?? '127.0.0.1'; + const port = options.port ?? 4310; + const token = options.token ?? process.env.MITII_SERVER_TOKEN; + if (!isLoopbackHost(hostname) && !options.insecureBind) { + throw new Error('Refusing non-loopback bind without --insecure-bind'); + } + if (!isLoopbackHost(hostname) && !token) { + throw new Error('MITII_SERVER_TOKEN or --token is required for non-loopback daemon binds'); + } + + const sseHub = new SseHub(); + const sessions = new SessionManager({ + cwd: resolve(options.cwd), + maxSessions: options.maxSessions ?? 5, + packageRoot: options.packageRoot, + sseHub, + }); + const indexWorker = new IndexWorkerService({ workspace: resolve(options.cwd) }); + await indexWorker.initialize(); + + const server = createServer(async (req, res) => { + try { + applyCors(req, res, options.allowOrigin); + if (req.method === 'OPTIONS') { + res.writeHead(204); + res.end(); + return; + } + if (!isAuthorized(req, { token })) { + writeUnauthorized(res); + return; + } + await route(req, res, sessions, sseHub, indexWorker, options); + } catch (error) { + const status = statusForError(error); + writeJson(res, status, { error: { code: codeForStatus(status), message: error instanceof Error ? error.message : String(error) } }); + } + }); + + await new Promise((resolveListen, reject) => { + server.once('error', reject); + server.listen(port, hostname, () => resolveListen()); + }); + + const close = async () => { + indexWorker.dispose(); + await sessions.dispose(); + await new Promise((resolveClose) => server.close(() => resolveClose())); + }; + + return { server, url: `http://${hostname}:${port}`, close }; +} + +async function route( + req: IncomingMessage, + res: ServerResponse, + sessions: SessionManager, + sseHub: SseHub, + indexWorker: IndexWorkerService, + options: MitiiDaemonServerOptions +): Promise { + const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`); + const parts = url.pathname.split('/').filter(Boolean); + + if (req.method === 'GET' && url.pathname === '/health') { + writeJson(res, 200, { + ok: true, + version: readPackageVersion(), + node: nodeVersion, + cwd: resolve(options.cwd), + sessions: sessions.list().length, + }); + return; + } + + if (req.method === 'GET' && url.pathname === '/capabilities') { + writeJson(res, 200, { + features: ['sessions', 'sse', 'permissions', 'cancel', 'subagents', 'worktrees', 'index-worker'], + maxSessions: options.maxSessions ?? 5, + supportedModes: ['ask', 'plan', 'agent', 'review'], + eventReplay: true, + auth: Boolean(options.token ?? process.env.MITII_SERVER_TOKEN), + }); + return; + } + + if (parts[0] === 'index') { + if (req.method === 'GET' && parts[1] === 'status') { + writeJson(res, 200, { index: indexWorker.status() }); + return; + } + if (req.method === 'POST' && parts[1] === 'enqueue') { + const body = await readJson(req); + const result = await indexWorker.enqueue( + Array.isArray(body.paths) ? body.paths.filter((path): path is string => typeof path === 'string') : undefined, + { priorityPaths: Array.isArray(body.priorityPaths) ? body.priorityPaths.filter((path): path is string => typeof path === 'string') : undefined } + ); + writeJson(res, 202, result); + return; + } + if (req.method === 'POST' && parts[1] === 'delete') { + const body = await readJson(req); + const relPath = stringValue(body.path); + if (!relPath) { + writeJson(res, 400, { error: { code: 'bad_request', message: 'path is required' } }); + return; + } + writeJson(res, 200, { removed: indexWorker.delete(relPath) }); + return; + } + if (req.method === 'POST' && parts[1] === 'repair') { + writeJson(res, 200, { repair: indexWorker.repair() }); + return; + } + } + + if (req.method === 'GET' && url.pathname === '/sessions') { + writeJson(res, 200, { sessions: sessions.list() }); + return; + } + + if (req.method === 'POST' && url.pathname === '/session') { + const body = await readJson(req); + const validation = validateWorkspace(options.cwd, stringValue(body.cwd)); + if (!validation.ok) { + writeJson(res, 400, { error: { code: 'workspace_mismatch', message: validation.message } }); + return; + } + const session = await sessions.create(body); + writeJson(res, 201, { session }); + return; + } + + if (parts[0] === 'session' && parts[1]) { + const id = parts[1]; + if (req.method === 'GET' && parts.length === 2) { + const session = sessions.get(id); + if (!session) throw new SessionNotFoundError(id); + writeJson(res, 200, { session }); + return; + } + if (req.method === 'DELETE' && parts.length === 2) { + writeJson(res, sessions.close(id) ? 200 : 404, { closed: true }); + return; + } + if (req.method === 'POST' && parts[2] === 'prompt') { + const body = await readJson(req); + const validation = validateWorkspace(options.cwd, stringValue(body.cwd)); + if (!validation.ok) { + writeJson(res, 400, { error: { code: 'workspace_mismatch', message: validation.message } }); + return; + } + const result = await sessions.prompt(id, { + mode: isMitiiMode(body.mode) ? body.mode : undefined, + message: String(body.message ?? ''), + attachments: Array.isArray(body.attachments) ? body.attachments : undefined, + }); + writeJson(res, 202, result); + return; + } + if (req.method === 'POST' && parts[2] === 'cancel') { + writeJson(res, sessions.cancel(id) ? 200 : 404, { cancelled: true }); + return; + } + if (req.method === 'GET' && parts[2] === 'events') { + const last = Number(req.headers['last-event-id'] ?? url.searchParams.get('lastEventId') ?? 0); + const unsubscribe = sseHub.subscribe(id, res, last); + req.on('close', unsubscribe); + return; + } + if (req.method === 'POST' && parts[2] === 'permissions' && parts[3] && parts[4] === 'respond') { + const body = await readJson(req); + const ok = sessions.respondToPermission(id, parts[3], body.decision as MitiiApprovalDecision); + writeJson(res, ok ? 200 : 404, { ok }); + return; + } + } + + writeJson(res, 404, { error: { code: 'not_found', message: 'Route not found' } }); +} + +function applyCors(req: IncomingMessage, res: ServerResponse, allowOrigin?: string): void { + const origin = req.headers.origin; + if (!allowOrigin || !origin) return; + if (allowOrigin === '*' || allowOrigin === origin) { + res.setHeader('access-control-allow-origin', origin); + res.setHeader('access-control-allow-headers', 'authorization, content-type, last-event-id'); + res.setHeader('access-control-allow-methods', 'GET,POST,DELETE,OPTIONS'); + } +} + +async function readJson(req: IncomingMessage): Promise> { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + const raw = Buffer.concat(chunks).toString('utf-8').trim(); + if (!raw) return {}; + return JSON.parse(raw) as Record; +} + +function readPackageVersion(): string { + try { + const pkg = JSON.parse(readFileSync(resolve('package.json'), 'utf-8')) as { version?: string }; + return pkg.version ?? '0.0.0'; + } catch { + return '0.0.0'; + } +} + +function statusForError(error: unknown): number { + if (error instanceof SessionNotFoundError) return 404; + if (error instanceof SessionLimitError) return 503; + if (error instanceof SessionConflictError) return 409; + return 500; +} + +function codeForStatus(status: number): string { + if (status === 404) return 'not_found'; + if (status === 409) return 'conflict'; + if (status === 503) return 'unavailable'; + return 'internal_error'; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +function isMitiiMode(value: unknown): value is MitiiMode { + return value === 'ask' || value === 'plan' || value === 'agent' || value === 'review'; +} diff --git a/packages/daemon/src/sessionManager.ts b/packages/daemon/src/sessionManager.ts new file mode 100644 index 00000000..b62cf47a --- /dev/null +++ b/packages/daemon/src/sessionManager.ts @@ -0,0 +1,210 @@ +import { randomUUID } from 'crypto'; +import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'fs'; +import { dirname, join } from 'path'; +import { HeadlessAgentHost } from '../../../src/core/headless/HeadlessAgentHost'; +import type { HeadlessAgentOptions } from '../../../src/core/headless/HeadlessConfig'; +import type { MitiiApprovalDecision, MitiiMode } from '../../../src/core/headless/events'; +import { SseHub } from './sseHub'; +import { canonicalWorkspace } from './workspaceBinding'; + +export interface DaemonSessionCreateOptions { + cwd?: string; + mode?: MitiiMode; + approval?: 'auto' | 'manual'; + providerType?: HeadlessAgentOptions['providerType']; + baseUrl?: string; + model?: string; + apiKey?: string; + runtime?: HeadlessAgentOptions['runtime']; + indexWorkspace?: boolean; +} + +export interface DaemonSessionInfo { + id: string; + cwd: string; + mode: MitiiMode; + approval: 'auto' | 'manual'; + createdAt: number; + updatedAt: number; + running: boolean; + closed: boolean; +} + +interface ManagedSession { + info: DaemonSessionInfo; + host: HeadlessAgentHost; + abortController?: AbortController; + promptInFlight: boolean; +} + +export interface SessionManagerOptions { + cwd: string; + maxSessions: number; + packageRoot?: string; + sseHub: SseHub; +} + +export class SessionManager { + private readonly sessions = new Map(); + private readonly cwd: string; + private readonly registryPath: string; + private readonly auditPath: string; + + constructor(private readonly options: SessionManagerOptions) { + this.cwd = canonicalWorkspace(options.cwd); + this.registryPath = join(this.cwd, '.mitii', 'daemon', 'sessions.json'); + this.auditPath = join(this.cwd, '.mitii', 'daemon', 'audit.jsonl'); + mkdirSync(dirname(this.registryPath), { recursive: true }); + } + + list(): DaemonSessionInfo[] { + return [...this.sessions.values()].map((session) => ({ ...session.info })); + } + + get(id: string): DaemonSessionInfo | undefined { + const session = this.sessions.get(id); + return session ? { ...session.info } : undefined; + } + + async create(options: DaemonSessionCreateOptions = {}): Promise { + if (this.sessions.size >= this.options.maxSessions) { + throw new SessionLimitError(`Maximum sessions reached (${this.options.maxSessions})`); + } + const id = randomUUID(); + const mode = options.mode ?? 'agent'; + const approval = options.approval ?? 'manual'; + const host = new HeadlessAgentHost({ + cwd: this.cwd, + packageRoot: this.options.packageRoot, + runtime: options.runtime, + providerType: options.providerType, + baseUrl: options.baseUrl, + model: options.model, + apiKey: options.apiKey, + approval, + indexWorkspace: options.indexWorkspace, + sessionId: id, + onEvent: (event) => this.options.sseHub.publish(id, event), + }); + await host.initialize(); + const info: DaemonSessionInfo = { + id, + cwd: this.cwd, + mode, + approval, + createdAt: Date.now(), + updatedAt: Date.now(), + running: false, + closed: false, + }; + this.sessions.set(id, { info, host, promptInFlight: false }); + this.persist(); + this.audit('session_create', { id, mode, approval }); + return { ...info }; + } + + async prompt(id: string, input: { mode?: MitiiMode; message: string; attachments?: unknown[] }): Promise<{ accepted: true }> { + const session = this.sessions.get(id); + if (!session || session.info.closed) throw new SessionNotFoundError(id); + if (session.promptInFlight) throw new SessionConflictError('A prompt is already running for this session'); + session.promptInFlight = true; + session.info.running = true; + session.info.mode = input.mode ?? session.info.mode; + session.info.updatedAt = Date.now(); + session.abortController = new AbortController(); + this.persist(); + this.audit('prompt_start', { id, mode: session.info.mode, messageLength: input.message.length }); + + void this.runPrompt(session, input.message, session.abortController.signal); + return { accepted: true }; + } + + cancel(id: string): boolean { + const session = this.sessions.get(id); + if (!session) return false; + session.abortController?.abort(); + session.host.cancel(); + this.audit('session_cancel', { id }); + return true; + } + + respondToPermission(id: string, approvalId: string, decision: MitiiApprovalDecision): boolean { + const session = this.sessions.get(id); + if (!session) return false; + const ok = session.host.resolveApproval(approvalId, decision); + this.audit('permission_response', { id, approvalId, decision, ok }); + return ok; + } + + close(id: string): boolean { + const session = this.sessions.get(id); + if (!session) return false; + session.abortController?.abort(); + void session.host.dispose(); + session.info.closed = true; + session.info.running = false; + this.sessions.delete(id); + this.options.sseHub.clear(id); + this.persist(); + this.audit('session_close', { id }); + return true; + } + + async dispose(): Promise { + for (const id of [...this.sessions.keys()]) { + this.close(id); + } + } + + loadPersistedMetadata(): DaemonSessionInfo[] { + if (!existsSync(this.registryPath)) return []; + try { + const parsed = JSON.parse(readFileSync(this.registryPath, 'utf-8')) as DaemonSessionInfo[]; + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + } + + private async runPrompt(session: ManagedSession, prompt: string, signal: AbortSignal): Promise { + try { + const mode = session.info.mode; + if (mode === 'ask') { + const content = await session.host.ask(prompt); + this.options.sseHub.publish(session.info.id, { type: 'assistant_delta', content }); + this.options.sseHub.publish(session.info.id, { type: 'done', content }); + } else if (mode === 'plan') { + const plan = await session.host.plan(prompt); + this.options.sseHub.publish(session.info.id, { type: 'plan', plan }); + this.options.sseHub.publish(session.info.id, { type: 'done', content: JSON.stringify(plan) }); + } else { + for await (const event of session.host.agent(prompt, signal)) { + this.options.sseHub.publish(session.info.id, event); + } + } + this.audit('prompt_done', { id: session.info.id }); + } catch (error) { + const message = signal.aborted ? 'Prompt cancelled' : error instanceof Error ? error.message : String(error); + this.options.sseHub.publish(session.info.id, { type: 'error', message }); + this.audit('prompt_error', { id: session.info.id, message }); + } finally { + session.promptInFlight = false; + session.info.running = false; + session.info.updatedAt = Date.now(); + this.persist(); + } + } + + private persist(): void { + writeFileSync(this.registryPath, `${JSON.stringify(this.list(), null, 2)}\n`, 'utf-8'); + } + + private audit(type: string, data: Record): void { + mkdirSync(dirname(this.auditPath), { recursive: true }); + appendFileSync(this.auditPath, `${JSON.stringify({ at: Date.now(), type, data })}\n`); + } +} + +export class SessionNotFoundError extends Error {} +export class SessionLimitError extends Error {} +export class SessionConflictError extends Error {} diff --git a/packages/daemon/src/sseHub.ts b/packages/daemon/src/sseHub.ts new file mode 100644 index 00000000..6b1e04c1 --- /dev/null +++ b/packages/daemon/src/sseHub.ts @@ -0,0 +1,81 @@ +import type { ServerResponse } from 'http'; +import type { MitiiEvent } from '../../../src/core/headless/events'; + +export interface BufferedSseEvent { + id: number; + event: string; + data: MitiiEvent; + at: number; +} + +interface Subscriber { + res: ServerResponse; +} + +export class SseHub { + private readonly buffers = new Map(); + private readonly subscribers = new Map>(); + private nextId = 1; + + constructor(private readonly maxEvents = 500) {} + + publish(sessionId: string, event: MitiiEvent): BufferedSseEvent { + const framed: BufferedSseEvent = { + id: this.nextId++, + event: event.type, + data: event, + at: Date.now(), + }; + const buffer = [...(this.buffers.get(sessionId) ?? []), framed].slice(-this.maxEvents); + this.buffers.set(sessionId, buffer); + for (const subscriber of this.subscribers.get(sessionId) ?? []) { + writeFrame(subscriber.res, framed); + } + return framed; + } + + subscribe(sessionId: string, res: ServerResponse, lastEventId?: number): () => void { + res.writeHead(200, { + 'content-type': 'text/event-stream; charset=utf-8', + 'cache-control': 'no-cache, no-transform', + connection: 'keep-alive', + 'x-accel-buffering': 'no', + }); + res.write(': connected\n\n'); + + for (const event of this.replay(sessionId, lastEventId)) { + writeFrame(res, event); + } + + const subscriber: Subscriber = { res }; + const set = this.subscribers.get(sessionId) ?? new Set(); + set.add(subscriber); + this.subscribers.set(sessionId, set); + + return () => { + set.delete(subscriber); + if (set.size === 0) this.subscribers.delete(sessionId); + res.end(); + }; + } + + replay(sessionId: string, lastEventId?: number): BufferedSseEvent[] { + const buffer = this.buffers.get(sessionId) ?? []; + if (!lastEventId || Number.isNaN(lastEventId)) return buffer; + return buffer.filter((event) => event.id > lastEventId); + } + + clear(sessionId: string): void { + this.buffers.delete(sessionId); + for (const subscriber of this.subscribers.get(sessionId) ?? []) { + subscriber.res.end(); + } + this.subscribers.delete(sessionId); + } +} + +function writeFrame(res: ServerResponse, event: BufferedSseEvent): void { + res.write(`id: ${event.id}\n`); + res.write(`event: ${event.event}\n`); + res.write(`data: ${JSON.stringify(event.data)}\n\n`); +} diff --git a/packages/daemon/src/workspaceBinding.ts b/packages/daemon/src/workspaceBinding.ts new file mode 100644 index 00000000..56b145a6 --- /dev/null +++ b/packages/daemon/src/workspaceBinding.ts @@ -0,0 +1,18 @@ +import { realpathSync } from 'fs'; +import { resolve } from 'path'; + +export function canonicalWorkspace(path: string): string { + return realpathSync(resolve(path)); +} + +export function validateWorkspace(boundCwd: string, requested?: string): { ok: true } | { ok: false; message: string } { + if (!requested) return { ok: true }; + const actual = canonicalWorkspace(requested); + const expected = canonicalWorkspace(boundCwd); + if (actual === expected) return { ok: true }; + return { ok: false, message: `Daemon is bound to ${expected}; requested cwd ${actual}` }; +} + +export function isLoopbackHost(hostname: string): boolean { + return hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '::1'; +} diff --git a/packages/sdk/README.md b/packages/sdk/README.md new file mode 100644 index 00000000..167d432d --- /dev/null +++ b/packages/sdk/README.md @@ -0,0 +1,43 @@ +# @mitii/sdk + +Run Mitii headless ask, plan, and agent sessions from Node 20+. + +```bash +npm install @mitii/sdk +``` + +```ts +import { query } from '@mitii/sdk'; + +for await (const event of query({ + cwd: process.cwd(), + prompt: 'Find the test command for this repo', + mode: 'agent', + runtime: 'real', + provider: 'openai-compatible', + baseUrl: 'http://localhost:11434/v1', + model: 'qwen3-coder:30b', + approval: 'auto', + allowNetwork: false, +})) { + if (event.type === 'assistant_delta') process.stdout.write(event.content); + if (event.type === 'tool_start') console.error('tool:', event.tool); +} +``` + +## Event Contract + +`query()` returns an async iterable of NDJSON-friendly events: + +- `session_start`: session id, mode, and workspace. +- `assistant_delta`: streamed assistant text. +- `reasoning_delta`: streamed reasoning text when the provider returns it. +- `tool_start` / `tool_end`: sanitized tool activity with path/command previews. +- `approval_required` / `approval_resolved`: manual approval lifecycle. +- `plan`: plan object for Plan mode or plan creation events. +- `metrics`: duration, tool count, session log path, and audit tool names. +- `error`: normalized runtime error. +- `done`: terminal content event. +- `log`: additional sanitized session log events. + +Tool inputs and outputs are previews only. Session log sanitization redacts API-key-like values before SDK events are emitted. diff --git a/packages/sdk/examples/daemon-client-quickstart.ts b/packages/sdk/examples/daemon-client-quickstart.ts new file mode 100644 index 00000000..dd71b408 --- /dev/null +++ b/packages/sdk/examples/daemon-client-quickstart.ts @@ -0,0 +1,25 @@ +import { DaemonClient, DaemonSessionClient } from '../src/daemon'; + +const client = new DaemonClient({ + baseUrl: process.env.MITII_DAEMON_URL ?? 'http://127.0.0.1:4310', + token: process.env.MITII_SERVER_TOKEN, +}); + +const session = await DaemonSessionClient.createOrAttach(client, { + cwd: process.cwd(), + mode: 'agent', + approval: 'manual', + runtime: 'stub', +}); + +void (async () => { + for await (const event of session.events()) { + if (event.type === 'approval_required') { + await session.respondToPermission(event.id, 'approved'); + } + console.log(JSON.stringify(event)); + if (event.type === 'done' || event.type === 'error') break; + } +})(); + +await session.prompt({ message: 'Say hello from the daemon quickstart.' }); diff --git a/packages/sdk/package.json b/packages/sdk/package.json new file mode 100644 index 00000000..b3c42c2c --- /dev/null +++ b/packages/sdk/package.json @@ -0,0 +1,48 @@ +{ + "name": "@mitii/sdk", + "version": "2.7.32", + "description": "Node SDK for running Mitii headless ask, plan, and agent sessions.", + "license": "AGPL-3.0-or-later", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./types": "./dist/types.d.ts", + "./daemon": { + "types": "./dist/daemon.d.ts", + "import": "./dist/daemon.js", + "default": "./dist/daemon.js" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "engines": { + "node": ">=20" + }, + "scripts": { + "build": "esbuild src/index.ts --bundle --outfile=dist/index.js --platform=node --format=esm --packages=external --alias:vscode=../../src/node/vscode-shim.ts && esbuild src/daemon.ts --bundle --outfile=dist/daemon.js --platform=node --format=esm --packages=external && cp src/index.d.ts dist/index.d.ts && cp src/types.d.ts dist/types.d.ts && cp src/daemon.d.ts dist/daemon.d.ts", + "test": "vitest run" + }, + "peerDependencies": { + "better-sqlite3": ">=12", + "@xenova/transformers": ">=2" + }, + "peerDependenciesMeta": { + "@xenova/transformers": { + "optional": true + } + }, + "devDependencies": { + "esbuild": "^0.21.5", + "typescript": "^5.5.2", + "vitest": "^1.6.0" + } +} diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts new file mode 100644 index 00000000..a70488b5 --- /dev/null +++ b/packages/sdk/src/client.ts @@ -0,0 +1,125 @@ +import { HeadlessAgentHost } from '../../../src/core/headless/HeadlessAgentHost'; +import type { ProviderType } from '../../../src/core/config/schema'; +import type { MitiiClientOptions, MitiiEvent, MitiiQueryOptions, MitiiResult } from './types'; + +export class MitiiClient { + private host?: HeadlessAgentHost; + private initialized = false; + + constructor(private readonly options: MitiiClientOptions) {} + + async initialize(): Promise { + if (this.initialized) return; + this.host = new HeadlessAgentHost(toHeadlessOptions(this.options)); + await this.host.initialize(); + this.initialized = true; + } + + async ask(prompt: string): Promise { + await this.initialize(); + return this.host!.ask(prompt); + } + + async plan(prompt: string): Promise> { + await this.initialize(); + return await this.host!.plan(prompt) as Record; + } + + async *agent(prompt: string, signal?: AbortSignal): AsyncIterable { + await this.initialize(); + for await (const event of this.host!.agent(prompt) as AsyncIterable) { + if (signal?.aborted) throw new Error('Mitii query aborted'); + yield event; + } + } + + async *query(options: Omit & { prompt: string }): AsyncIterable { + const mode = options.mode ?? 'agent'; + const started = Date.now(); + const events: MitiiEvent[] = []; + let content = ''; + const emit = async function* (event: MitiiEvent): AsyncIterable { + events.push(event); + yield event; + }; + + if (mode === 'ask') { + content = await this.ask(options.prompt); + yield* emit({ type: 'assistant_delta', content }); + } else if (mode === 'plan') { + const plan = await this.plan(options.prompt); + yield* emit({ type: 'plan', plan }); + content = JSON.stringify(plan); + } else { + for await (const event of this.agent(options.prompt, options.signal)) { + if (event.type === 'assistant_delta') content += event.content; + if (event.type === 'done') { + content = event.content; + continue; + } + yield* emit(event); + } + } + + yield* emit({ + type: 'metrics', + durationMs: Date.now() - started, + toolCalls: this.host?.getToolAudit().length ?? 0, + sessionLogPath: this.host?.getSessionLog().getLogPath() || undefined, + auditTools: this.host?.getToolAudit().map((entry) => entry.toolName), + }); + yield* emit({ type: 'done', content }); + } + + async run(options: Omit & { prompt: string }): Promise { + const events: MitiiEvent[] = []; + let content = ''; + for await (const event of this.query(options)) { + events.push(event); + if (event.type === 'done') content = event.content; + } + return { content, events }; + } + + resolveApproval(id: string, decision: 'approved' | 'denied'): boolean { + return this.host?.resolveApproval(id, decision) ?? false; + } + + async dispose(): Promise { + await this.host?.dispose(); + this.host = undefined; + this.initialized = false; + } +} + +export function createClient(options: MitiiClientOptions): MitiiClient { + return new MitiiClient(options); +} + +export async function* query(options: MitiiQueryOptions): AsyncIterable { + const client = createClient(options); + try { + yield* client.query(options); + } finally { + await client.dispose(); + } +} + +function toHeadlessOptions(options: MitiiClientOptions): ConstructorParameters[0] { + return { + cwd: options.cwd, + packageRoot: options.packageRoot, + runtime: options.runtime, + providerType: (options.providerType ?? options.provider) as ProviderType | undefined, + baseUrl: options.baseUrl, + model: options.model, + apiKey: options.apiKey, + approval: options.approval, + allowNetwork: options.allowNetwork, + enablePuppeteer: options.enablePuppeteer, + indexWorkspace: options.indexWorkspace, + configOverrides: options.vectors === undefined + ? undefined + : { indexing: { vectorsEnabled: options.vectors } } as never, + }; +} diff --git a/packages/sdk/src/daemon.d.ts b/packages/sdk/src/daemon.d.ts new file mode 100644 index 00000000..50aa80ac --- /dev/null +++ b/packages/sdk/src/daemon.d.ts @@ -0,0 +1,72 @@ +import type { MitiiApprovalDecision, MitiiApprovalMode, MitiiEvent, MitiiMode, MitiiRuntime } from './types'; + +export interface DaemonClientOptions { + baseUrl?: string; + token?: string; + timeoutMs?: number; + fetch?: typeof fetch; +} + +export interface DaemonSessionCreateOptions { + cwd: string; + mode?: MitiiMode; + approval?: MitiiApprovalMode; + providerType?: string; + baseUrl?: string; + model?: string; + apiKey?: string; + runtime?: MitiiRuntime; + indexWorkspace?: boolean; +} + +export interface DaemonSessionInfo { + id: string; + cwd: string; + mode: MitiiMode; + approval: MitiiApprovalMode; + createdAt: number; + updatedAt: number; + running: boolean; + closed: boolean; +} + +export interface DaemonPromptInput { + mode?: MitiiMode; + message: string; + attachments?: unknown[]; +} + +export interface ParsedSseEvent { + id: number; + event?: string; + data: T; +} + +export declare class DaemonClient { + readonly baseUrl: string; + constructor(options?: DaemonClientOptions); + health(): Promise>; + capabilities(): Promise>; + createSession(options: DaemonSessionCreateOptions): Promise; + listSessions(): Promise; + getSession(id: string): Promise; + closeSession(id: string): Promise>; + prompt(id: string, input: DaemonPromptInput): Promise>; + cancel(id: string): Promise>; + respondToPermission(id: string, approvalId: string, decision: MitiiApprovalDecision): Promise>; + events(id: string, lastSeenEventId?: number): AsyncIterable>; +} + +export declare class DaemonSessionClient { + readonly client: DaemonClient; + readonly session: DaemonSessionInfo; + lastSeenEventId: number; + constructor(client: DaemonClient, session: DaemonSessionInfo); + static createOrAttach(client: DaemonClient, options: DaemonSessionCreateOptions): Promise; + prompt(input: DaemonPromptInput): Promise>; + cancel(): Promise>; + respondToPermission(id: string, decision: MitiiApprovalDecision): Promise>; + events(): AsyncIterable; +} + +export declare function parseSseStream(responsePromise: Promise): AsyncIterable>; diff --git a/packages/sdk/src/daemon.ts b/packages/sdk/src/daemon.ts new file mode 100644 index 00000000..d3c43b70 --- /dev/null +++ b/packages/sdk/src/daemon.ts @@ -0,0 +1,216 @@ +import type { MitiiApprovalDecision, MitiiApprovalMode, MitiiEvent, MitiiMode, MitiiRuntime } from './types'; + +export interface DaemonClientOptions { + baseUrl?: string; + token?: string; + timeoutMs?: number; + fetch?: typeof fetch; +} + +export interface DaemonSessionCreateOptions { + cwd: string; + mode?: MitiiMode; + approval?: MitiiApprovalMode; + providerType?: string; + baseUrl?: string; + model?: string; + apiKey?: string; + runtime?: MitiiRuntime; + indexWorkspace?: boolean; +} + +export interface DaemonSessionInfo { + id: string; + cwd: string; + mode: MitiiMode; + approval: MitiiApprovalMode; + createdAt: number; + updatedAt: number; + running: boolean; + closed: boolean; +} + +export interface DaemonPromptInput { + mode?: MitiiMode; + message: string; + attachments?: unknown[]; +} + +export class DaemonClient { + readonly baseUrl: string; + private readonly token?: string; + private readonly timeoutMs: number; + private readonly fetchImpl: typeof fetch; + + constructor(options: DaemonClientOptions = {}) { + this.baseUrl = (options.baseUrl ?? 'http://127.0.0.1:4310').replace(/\/$/, ''); + this.token = options.token; + this.timeoutMs = options.timeoutMs ?? 30_000; + this.fetchImpl = options.fetch ?? fetch; + } + + health(): Promise> { + return this.request('GET', '/health'); + } + + capabilities(): Promise> { + return this.request('GET', '/capabilities'); + } + + async createSession(options: DaemonSessionCreateOptions): Promise { + const body = await this.request<{ session: DaemonSessionInfo }>('POST', '/session', options); + return body.session; + } + + async listSessions(): Promise { + const body = await this.request<{ sessions: DaemonSessionInfo[] }>('GET', '/sessions'); + return body.sessions; + } + + async getSession(id: string): Promise { + const body = await this.request<{ session: DaemonSessionInfo }>('GET', `/session/${encodeURIComponent(id)}`); + return body.session; + } + + closeSession(id: string): Promise> { + return this.request('DELETE', `/session/${encodeURIComponent(id)}`); + } + + prompt(id: string, input: DaemonPromptInput): Promise> { + return this.request('POST', `/session/${encodeURIComponent(id)}/prompt`, input); + } + + cancel(id: string): Promise> { + return this.request('POST', `/session/${encodeURIComponent(id)}/cancel`); + } + + respondToPermission(id: string, approvalId: string, decision: MitiiApprovalDecision): Promise> { + return this.request('POST', `/session/${encodeURIComponent(id)}/permissions/${encodeURIComponent(approvalId)}/respond`, { decision }); + } + + events(id: string, lastSeenEventId?: number): AsyncIterable> { + const headers: Record = this.headers(); + if (lastSeenEventId) headers['last-event-id'] = String(lastSeenEventId); + return parseSseStream( + this.fetchImpl(`${this.baseUrl}/session/${encodeURIComponent(id)}/events`, { headers }) + ); + } + + private async request>(method: string, path: string, body?: unknown): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + try { + const res = await this.fetchImpl(`${this.baseUrl}${path}`, { + method, + headers: { + ...this.headers(), + ...(body === undefined ? {} : { 'content-type': 'application/json' }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: controller.signal, + }); + const text = await res.text(); + const parsed = text ? JSON.parse(text) : {}; + if (!res.ok) { + const message = parsed?.error?.message ?? `${method} ${path} failed with ${res.status}`; + throw new Error(message); + } + return parsed as T; + } finally { + clearTimeout(timer); + } + } + + private headers(): Record { + return this.token ? { authorization: `Bearer ${this.token}` } : {}; + } +} + +export class DaemonSessionClient { + lastSeenEventId = 0; + + constructor(readonly client: DaemonClient, readonly session: DaemonSessionInfo) {} + + static async createOrAttach(client: DaemonClient, options: DaemonSessionCreateOptions): Promise { + const existing = (await client.listSessions()).find((session) => session.cwd === options.cwd && !session.closed); + return new DaemonSessionClient(client, existing ?? await client.createSession(options)); + } + + prompt(input: DaemonPromptInput): Promise> { + return this.client.prompt(this.session.id, input); + } + + cancel(): Promise> { + return this.client.cancel(this.session.id); + } + + respondToPermission(id: string, decision: MitiiApprovalDecision): Promise> { + return this.client.respondToPermission(this.session.id, id, decision); + } + + async *events(): AsyncIterable { + for await (const event of this.client.events(this.session.id, this.lastSeenEventId)) { + this.lastSeenEventId = event.id; + yield event.data; + } + } +} + +export interface ParsedSseEvent { + id: number; + event?: string; + data: T; +} + +export async function* parseSseStream(responsePromise: Promise): AsyncIterable> { + const response = await responsePromise; + if (!response.ok || !response.body) { + throw new Error(`SSE connection failed with ${response.status}`); + } + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let boundary = findFrameBoundary(buffer); + while (boundary >= 0) { + const raw = buffer.slice(0, boundary); + const skip = buffer.slice(boundary, boundary + 4).startsWith('\r\n\r\n') ? 4 : 2; + buffer = buffer.slice(boundary + skip); + const parsed = parseSseFrame(raw); + if (parsed) yield parsed; + boundary = findFrameBoundary(buffer); + } + } + } finally { + reader.releaseLock(); + } +} + +function parseSseFrame(frame: string): ParsedSseEvent | null { + let id = 0; + let event: string | undefined; + const data: string[] = []; + for (const line of frame.split(/\r?\n/)) { + if (!line || line.startsWith(':')) continue; + const index = line.indexOf(':'); + const field = index >= 0 ? line.slice(0, index) : line; + const value = index >= 0 ? line.slice(index + 1).replace(/^ /, '') : ''; + if (field === 'id') id = Number(value); + if (field === 'event') event = value; + if (field === 'data') data.push(value); + } + if (data.length === 0) return null; + return { id, event, data: JSON.parse(data.join('\n')) as T }; +} + +function findFrameBoundary(buffer: string): number { + const lf = buffer.indexOf('\n\n'); + const crlf = buffer.indexOf('\r\n\r\n'); + if (lf < 0) return crlf; + if (crlf < 0) return lf; + return Math.min(lf, crlf); +} diff --git a/packages/sdk/src/events.ts b/packages/sdk/src/events.ts new file mode 100644 index 00000000..ffc3d3d0 --- /dev/null +++ b/packages/sdk/src/events.ts @@ -0,0 +1,9 @@ +import type { MitiiEvent } from './types'; + +export function isMitiiEvent(value: unknown): value is MitiiEvent { + return Boolean(value && typeof value === 'object' && typeof (value as { type?: unknown }).type === 'string'); +} + +export function isTerminalEvent(event: MitiiEvent): boolean { + return event.type === 'done' || event.type === 'error'; +} diff --git a/packages/sdk/src/index.d.ts b/packages/sdk/src/index.d.ts new file mode 100644 index 00000000..dbbc1aea --- /dev/null +++ b/packages/sdk/src/index.d.ts @@ -0,0 +1,29 @@ +export type { + MitiiApprovalDecision, + MitiiApprovalMode, + MitiiClientOptions, + MitiiEvent, + MitiiMode, + MitiiQueryOptions, + MitiiResult, + MitiiRuntime, +} from './types'; + +import type { MitiiApprovalDecision, MitiiClientOptions, MitiiEvent, MitiiQueryOptions, MitiiResult } from './types'; + +export declare class MitiiClient { + constructor(options: MitiiClientOptions); + initialize(): Promise; + ask(prompt: string): Promise; + plan(prompt: string): Promise>; + agent(prompt: string, signal?: AbortSignal): AsyncIterable; + query(options: Omit & { prompt: string }): AsyncIterable; + run(options: Omit & { prompt: string }): Promise; + resolveApproval(id: string, decision: MitiiApprovalDecision): boolean; + dispose(): Promise; +} + +export declare function createClient(options: MitiiClientOptions): MitiiClient; +export declare function query(options: MitiiQueryOptions): AsyncIterable; +export declare function isMitiiEvent(value: unknown): value is MitiiEvent; +export declare function isTerminalEvent(event: MitiiEvent): boolean; diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts new file mode 100644 index 00000000..2e26cf03 --- /dev/null +++ b/packages/sdk/src/index.ts @@ -0,0 +1,13 @@ +export { MitiiClient, createClient, query } from './client'; +export { DaemonClient, DaemonSessionClient, parseSseStream } from './daemon'; +export { isMitiiEvent, isTerminalEvent } from './events'; +export type { + MitiiApprovalDecision, + MitiiApprovalMode, + MitiiClientOptions, + MitiiEvent, + MitiiMode, + MitiiQueryOptions, + MitiiResult, + MitiiRuntime, +} from './types'; diff --git a/packages/sdk/src/types.d.ts b/packages/sdk/src/types.d.ts new file mode 100644 index 00000000..c8e72a2f --- /dev/null +++ b/packages/sdk/src/types.d.ts @@ -0,0 +1,47 @@ +export type MitiiMode = 'ask' | 'plan' | 'agent' | 'review'; +export type MitiiApprovalMode = 'auto' | 'manual'; +export type MitiiRuntime = 'real' | 'stub'; + +export interface MitiiClientOptions { + cwd: string; + packageRoot?: string; + runtime?: MitiiRuntime; + provider?: string; + providerType?: string; + baseUrl?: string; + model?: string; + apiKey?: string; + approval?: MitiiApprovalMode; + allowNetwork?: boolean; + enablePuppeteer?: boolean; + vectors?: boolean; + indexWorkspace?: boolean; +} + +export interface MitiiQueryOptions extends MitiiClientOptions { + mode?: MitiiMode; + prompt: string; + sessionId?: string; + signal?: AbortSignal; +} + +export type MitiiApprovalDecision = 'approved' | 'denied'; + +export type MitiiEvent = + | { type: 'session_start'; sessionId: string; mode: string; cwd: string; data?: Record } + | { type: 'assistant_delta'; content: string } + | { type: 'reasoning_delta'; content: string } + | { type: 'tool_start'; tool: string; input?: Record; id?: string; message?: string } + | { type: 'tool_end'; tool: string; success: boolean; output?: string; error?: string; durationMs?: number; id?: string } + | { type: 'approval_required'; id: string; tool: string; input?: Record; message?: string } + | { type: 'approval_resolved'; id?: string; tool?: string; decision?: MitiiApprovalDecision | string } + | { type: 'plan'; plan: Record } + | { type: 'metrics'; durationMs: number; toolCalls: number; sessionLogPath?: string; auditTools?: string[] } + | { type: 'error'; message: string; data?: Record } + | { type: 'done'; content: string; metrics?: Record } + | { type: 'log'; event: Record }; + +export interface MitiiResult { + content: string; + events: MitiiEvent[]; +} diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts new file mode 100644 index 00000000..c8e72a2f --- /dev/null +++ b/packages/sdk/src/types.ts @@ -0,0 +1,47 @@ +export type MitiiMode = 'ask' | 'plan' | 'agent' | 'review'; +export type MitiiApprovalMode = 'auto' | 'manual'; +export type MitiiRuntime = 'real' | 'stub'; + +export interface MitiiClientOptions { + cwd: string; + packageRoot?: string; + runtime?: MitiiRuntime; + provider?: string; + providerType?: string; + baseUrl?: string; + model?: string; + apiKey?: string; + approval?: MitiiApprovalMode; + allowNetwork?: boolean; + enablePuppeteer?: boolean; + vectors?: boolean; + indexWorkspace?: boolean; +} + +export interface MitiiQueryOptions extends MitiiClientOptions { + mode?: MitiiMode; + prompt: string; + sessionId?: string; + signal?: AbortSignal; +} + +export type MitiiApprovalDecision = 'approved' | 'denied'; + +export type MitiiEvent = + | { type: 'session_start'; sessionId: string; mode: string; cwd: string; data?: Record } + | { type: 'assistant_delta'; content: string } + | { type: 'reasoning_delta'; content: string } + | { type: 'tool_start'; tool: string; input?: Record; id?: string; message?: string } + | { type: 'tool_end'; tool: string; success: boolean; output?: string; error?: string; durationMs?: number; id?: string } + | { type: 'approval_required'; id: string; tool: string; input?: Record; message?: string } + | { type: 'approval_resolved'; id?: string; tool?: string; decision?: MitiiApprovalDecision | string } + | { type: 'plan'; plan: Record } + | { type: 'metrics'; durationMs: number; toolCalls: number; sessionLogPath?: string; auditTools?: string[] } + | { type: 'error'; message: string; data?: Record } + | { type: 'done'; content: string; metrics?: Record } + | { type: 'log'; event: Record }; + +export interface MitiiResult { + content: string; + events: MitiiEvent[]; +} diff --git a/packages/sdk/tsconfig.json b/packages/sdk/tsconfig.json new file mode 100644 index 00000000..3949515f --- /dev/null +++ b/packages/sdk/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "module": "ESNext", + "moduleResolution": "bundler", + "noUnusedLocals": false + }, + "include": ["src/**/*"], + "exclude": ["dist", "test"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac05277d..b52a8bf7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -125,6 +125,37 @@ importers: specifier: ^0.24.7 version: 0.24.7 + packages/board: {} + + packages/channels: + dependencies: + '@mitii/sdk': + specifier: workspace:* + version: link:../sdk + + packages/cli: {} + + packages/daemon: {} + + packages/sdk: + dependencies: + '@xenova/transformers': + specifier: '>=2' + version: 2.17.2 + better-sqlite3: + specifier: '>=12' + version: 12.11.1 + devDependencies: + esbuild: + specifier: ^0.21.5 + version: 0.21.5 + typescript: + specifier: ^5.5.2 + version: 5.9.3 + vitest: + specifier: ^1.6.0 + version: 1.6.1(@types/node@20.19.43) + tools/benchmark: {} packages: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2cda91e2..7190d75d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,6 @@ packages: - 'tools/*' + - 'packages/*' + +onlyBuiltDependencies: + - sharp diff --git a/scripts/build-airgap-bundle.sh b/scripts/build-airgap-bundle.sh new file mode 100755 index 00000000..575a3e22 --- /dev/null +++ b/scripts/build-airgap-bundle.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +version="$(node -p "require('./package.json').version")" +out="${MITII_AIRGAP_OUT:-dist-airgap/mitii-${version}}" +mkdir -p "$out" + +pnpm run package +cp ./*.vsix "$out/" 2>/dev/null || true +cp scripts/install.sh scripts/install.ps1 "$out/" +cp README.md LICENSE "$out/" + +cat > "$out/README.md" </dev/null 2>&1; then + echo "Node.js 20+ is required." >&2 + exit 1 +fi + +NODE_MAJOR="$(node -p "process.versions.node.split('.')[0]")" +if [[ "${NODE_MAJOR}" -lt 20 ]]; then + echo "Node.js 20+ is required; found $(node -v)." >&2 + exit 1 +fi + +mkdir -p "${ROOT}/.mitii" +cat > "${ROOT}/.mitii/mcp.json" <<'JSON' +{ + "mcpServers": { + "agentmemory": { + "disabled": false, + "type": "streamable-http", + "url": "http://localhost:3111/mcp", + "headers": {}, + "timeoutMs": 30000 + } + } +} +JSON + +echo "agentmemory MCP configured at ${ROOT}/.mitii/mcp.json" +echo "Install/start agentmemory separately, then verify http://localhost:3111/agentmemory/livez" diff --git a/scripts/copy-bundled-skills.mjs b/scripts/copy-bundled-skills.mjs index e55fcc72..e450bd97 100644 --- a/scripts/copy-bundled-skills.mjs +++ b/scripts/copy-bundled-skills.mjs @@ -3,14 +3,18 @@ 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); +function copyBundledDir(relSource, relDest, label) { + const source = join(root, relSource); + const dest = join(root, relDest); + if (!existsSync(source)) { + console.error(`Missing bundled ${label} source:`, source); + process.exit(1); + } + mkdirSync(dirname(dest), { recursive: true }); + cpSync(source, dest, { recursive: true, force: true }); + console.log(`Copied bundled ${label} to`, dest); } -mkdirSync(dirname(dest), { recursive: true }); -cpSync(source, dest, { recursive: true, force: true }); -console.log('Copied bundled skills to', dest); +copyBundledDir('src/core/skills/bundled', 'dist/core/skills/bundled', 'skills'); +copyBundledDir('src/core/rules/bundled', 'dist/core/rules/bundled', 'rules'); diff --git a/scripts/dev-setup.sh b/scripts/dev-setup.sh index 3b494303..be2ec465 100755 --- a/scripts/dev-setup.sh +++ b/scripts/dev-setup.sh @@ -3,7 +3,7 @@ set -euo pipefail cd "$(dirname "$0")/.." -editor="${THUNDER_EDITOR:-vscode}" +editor="${MITII_EDITOR:-${THUNDER_EDITOR:-vscode}}" echo "Installing dependencies..." pnpm install @@ -13,12 +13,12 @@ pnpm run compile if [[ "$(uname -s)" == "Darwin" ]]; then echo "Rebuilding native modules for ${editor}..." - THUNDER_EDITOR="${editor}" pnpm run rebuild:native + MITII_EDITOR="${editor}" pnpm 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= pnpm run rebuild:native +Set MITII_ELECTRON_VERSION for your editor, then run: + MITII_ELECTRON_VERSION= pnpm run rebuild:native NOTE fi diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 00000000..1e1e09f8 --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,28 @@ +$ErrorActionPreference = "Stop" + +$Version = if ($env:MITII_VERSION) { $env:MITII_VERSION } else { "latest" } +$InstallDir = if ($env:MITII_INSTALL_DIR) { $env:MITII_INSTALL_DIR } else { Join-Path $HOME ".mitii\\bin" } +$Asset = "mitii-win32-x64.zip" +$Base = "https://github.com/codewithshinde/thunder-ai-agent/releases" +$Url = if ($Version -eq "latest") { "$Base/latest/download/$Asset" } else { "$Base/download/$Version/$Asset" } +$SumsUrl = if ($Version -eq "latest") { "$Base/latest/download/SHA256SUMS" } else { "$Base/download/$Version/SHA256SUMS" } + +New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null +$Temp = New-Item -ItemType Directory -Force -Path (Join-Path ([System.IO.Path]::GetTempPath()) "mitii-install-$([System.Guid]::NewGuid())") +try { + $Archive = Join-Path $Temp $Asset + Invoke-WebRequest -Uri $Url -OutFile $Archive + try { + $Sums = Join-Path $Temp "SHA256SUMS" + Invoke-WebRequest -Uri $SumsUrl -OutFile $Sums + $Expected = (Get-Content $Sums | Select-String " $Asset$").ToString().Split(" ")[0] + $Actual = (Get-FileHash $Archive -Algorithm SHA256).Hash.ToLowerInvariant() + if ($Expected -and $Actual -ne $Expected.ToLowerInvariant()) { throw "SHA256 mismatch" } + } catch { + Write-Warning "Checksum verification skipped: $_" + } + Expand-Archive -Force $Archive $InstallDir + Write-Host "Installed mitii to $InstallDir" +} finally { + Remove-Item -Recurse -Force $Temp +} diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 00000000..41423294 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env sh +set -eu + +VERSION="${MITII_VERSION:-latest}" +BASE_URL="${MITII_RELEASE_BASE_URL:-https://github.com/codewithshinde/thunder-ai-agent/releases/download}" +INSTALL_DIR="${MITII_INSTALL_DIR:-$HOME/.mitii/bin}" + +os="$(uname -s | tr '[:upper:]' '[:lower:]')" +arch="$(uname -m)" +case "$arch" in + arm64|aarch64) arch="arm64" ;; + x86_64|amd64) arch="x64" ;; + *) echo "Unsupported architecture: $arch" >&2; exit 1 ;; +esac + +asset="mitii-${os}-${arch}.tar.gz" +if [ "$VERSION" = "latest" ]; then + url="https://github.com/codewithshinde/thunder-ai-agent/releases/latest/download/$asset" + sums_url="https://github.com/codewithshinde/thunder-ai-agent/releases/latest/download/SHA256SUMS" +else + url="$BASE_URL/$VERSION/$asset" + sums_url="$BASE_URL/$VERSION/SHA256SUMS" +fi + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +mkdir -p "$INSTALL_DIR" +curl -fsSL "$url" -o "$tmp/$asset" +if curl -fsSL "$sums_url" -o "$tmp/SHA256SUMS"; then + if command -v sha256sum >/dev/null 2>&1; then + (cd "$tmp" && grep " $asset$" SHA256SUMS | sha256sum -c -) + else + expected="$(grep " $asset$" "$tmp/SHA256SUMS" | awk '{print $1}')" + actual="$(shasum -a 256 "$tmp/$asset" | awk '{print $1}')" + [ "$expected" = "$actual" ] || { echo "SHA256 mismatch" >&2; exit 1; } + fi +fi +tar -xzf "$tmp/$asset" -C "$INSTALL_DIR" +chmod +x "$INSTALL_DIR/mitii" +echo "Installed mitii to $INSTALL_DIR/mitii" +case ":$PATH:" in + *":$INSTALL_DIR:"*) ;; + *) echo "Add $INSTALL_DIR to PATH to run mitii from any shell." ;; +esac diff --git a/scripts/rebuild-native.mjs b/scripts/rebuild-native.mjs index bc2098b8..3e701f10 100644 --- a/scripts/rebuild-native.mjs +++ b/scripts/rebuild-native.mjs @@ -1,15 +1,35 @@ #!/usr/bin/env node /** - * Rebuild native modules (better-sqlite3) for VS Code / Cursor Electron. + * Rebuild native modules for VS Code / Cursor Electron. * A normal install compiles for Node.js; the extension host uses Electron's ABI. + * Also ensures sharp carries vendored libvips so MiniLM text embeddings do not fail + * when @xenova/transformers imports its image utility module. * - * Override: THUNDER_ELECTRON_VERSION=42.2.0 pnpm run rebuild:native - * Override editor: THUNDER_EDITOR=cursor pnpm run rebuild:native + * Override: MITII_ELECTRON_VERSION=42.2.0 pnpm run rebuild:native + * Override editor: MITII_EDITOR=cursor pnpm run rebuild:native */ import { execSync, spawnSync } from 'child_process'; import { existsSync } from 'fs'; +import { dirname, resolve } from 'path'; +import { fileURLToPath } from 'url'; const MODULES = ['better-sqlite3']; +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +function ensureSharpVendor() { + console.log('Ensuring sharp vendored libvips is installed for MiniLM embeddings…'); + const result = spawnSync( + 'pnpm', + ['rebuild', 'sharp'], + { + cwd: packageRoot, + stdio: 'inherit', + shell: process.platform === 'win32', + env: { ...process.env, SHARP_IGNORE_GLOBAL_LIBVIPS: '1' }, + } + ); + return result.status === 0; +} function readElectronFromPlist(plistPath) { if (!existsSync(plistPath)) return null; @@ -23,8 +43,8 @@ function readElectronFromPlist(plistPath) { } function detectElectronVersion() { - if (process.env.THUNDER_ELECTRON_VERSION) { - return process.env.THUNDER_ELECTRON_VERSION; + if (process.env.MITII_ELECTRON_VERSION || process.env.THUNDER_ELECTRON_VERSION) { + return process.env.MITII_ELECTRON_VERSION || process.env.THUNDER_ELECTRON_VERSION; } const editors = { @@ -38,7 +58,7 @@ function detectElectronVersion() { }, }; - const preferred = (process.env.THUNDER_EDITOR ?? 'vscode').toLowerCase(); + const preferred = (process.env.MITII_EDITOR ?? process.env.THUNDER_EDITOR ?? 'vscode').toLowerCase(); const order = preferred === 'cursor' ? ['cursor', 'vscode'] : ['vscode', 'cursor']; @@ -56,6 +76,11 @@ function detectElectronVersion() { } function main() { + if (!ensureSharpVendor()) { + console.error('\nRebuild failed for sharp/libvips.'); + process.exit(1); + } + const electronVersion = detectElectronVersion(); console.log(`Rebuilding native modules for Electron ${electronVersion}…`); @@ -72,18 +97,18 @@ function main() { '-w', ...MODULES, ], - { stdio: 'inherit', shell: true } + { cwd: packageRoot, stdio: 'inherit', shell: true } ); if (result.status !== 0) { console.error('\nRebuild failed. Try:'); - console.error(' THUNDER_ELECTRON_VERSION=42.2.0 pnpm run rebuild:native # VS Code 1.124+'); - console.error(' THUNDER_ELECTRON_VERSION=39.8.1 pnpm run rebuild:native # Cursor'); + console.error(' MITII_ELECTRON_VERSION=42.2.0 pnpm run rebuild:native # VS Code 1.124+'); + console.error(' MITII_ELECTRON_VERSION=39.8.1 pnpm run rebuild:native # Cursor'); process.exit(result.status ?? 1); } console.log('\nNative rebuild complete. Reload the Extension Development Host (F5).'); - console.log('Note: run "pnpm run rebuild:node" before CLI eval or "pnpm test" if sqlite fails under Node.'); + console.log('Note: run "pnpm run rebuild:node" before CLI eval or "pnpm test" if native modules fail under Node.'); } main(); diff --git a/scripts/rebuild-node.mjs b/scripts/rebuild-node.mjs index edfe97d1..78279c74 100644 --- a/scripts/rebuild-node.mjs +++ b/scripts/rebuild-node.mjs @@ -1,7 +1,9 @@ #!/usr/bin/env node /** - * Rebuild native modules (better-sqlite3) for system Node.js. + * Rebuild native modules for system Node.js. * Required for headless CLI eval and Vitest — distinct from Electron (rebuild:native). + * Also ensures sharp carries vendored libvips so MiniLM text embeddings do not fail + * just because the host machine lacks a matching global libvips install. */ import { spawnSync } from 'child_process'; import { createRequire } from 'module'; @@ -28,7 +30,27 @@ function rebuildModule(name) { return result.status === 0; } +function ensureSharpVendor() { + console.log('Ensuring sharp vendored libvips is installed for MiniLM embeddings…'); + const result = spawnSync( + 'pnpm', + ['rebuild', 'sharp'], + { + cwd: packageRoot, + stdio: 'inherit', + shell: process.platform === 'win32', + env: { ...process.env, SHARP_IGNORE_GLOBAL_LIBVIPS: '1' }, + } + ); + return result.status === 0; +} + function main() { + if (!ensureSharpVendor()) { + console.error('\nRebuild failed for sharp/libvips.'); + process.exit(1); + } + for (const name of MODULES) { if (!rebuildModule(name)) { console.error(`\nRebuild failed for ${name}.`); @@ -36,7 +58,7 @@ function main() { } } console.log('\nNative rebuild complete for system Node.'); - console.log('Run pnpm run rebuild:native before F5 if the VS Code extension fails to load sqlite.'); + console.log('Run pnpm run rebuild:native before F5 if the VS Code extension fails to load native modules.'); } main(); diff --git a/scripts/sync-sdk-version.mjs b/scripts/sync-sdk-version.mjs new file mode 100644 index 00000000..53526467 --- /dev/null +++ b/scripts/sync-sdk-version.mjs @@ -0,0 +1,11 @@ +import { readFileSync, writeFileSync } from 'fs'; + +const rootPackagePath = new URL('../package.json', import.meta.url); +const sdkPackagePath = new URL('../packages/sdk/package.json', import.meta.url); + +const rootPackage = JSON.parse(readFileSync(rootPackagePath, 'utf8')); +const sdkPackage = JSON.parse(readFileSync(sdkPackagePath, 'utf8')); + +sdkPackage.version = rootPackage.version; +writeFileSync(sdkPackagePath, `${JSON.stringify(sdkPackage, null, 2)}\n`); +console.log(`@mitii/sdk version synced to ${rootPackage.version}`); diff --git a/scripts/sync-versions.mjs b/scripts/sync-versions.mjs new file mode 100644 index 00000000..2cf36fe3 --- /dev/null +++ b/scripts/sync-versions.mjs @@ -0,0 +1,24 @@ +import { existsSync, readFileSync, writeFileSync } from 'fs'; + +const root = JSON.parse(readFileSync('package.json', 'utf8')); +const version = root.version; +const packageFiles = [ + 'packages/sdk/package.json', + 'packages/daemon/package.json', + 'packages/board/package.json', + 'packages/channels/package.json', + 'packages/cli/package.json', + 'packages/cli/optional-packages/mitii-darwin-arm64/package.json', + 'packages/cli/optional-packages/mitii-darwin-x64/package.json', + 'packages/cli/optional-packages/mitii-linux-x64/package.json', + 'packages/cli/optional-packages/mitii-win32-x64/package.json', +]; + +for (const file of packageFiles) { + if (!existsSync(file)) continue; + const pkg = JSON.parse(readFileSync(file, 'utf8')); + pkg.version = version; + writeFileSync(file, `${JSON.stringify(pkg, null, 2)}\n`); +} + +console.log(`Mitii packages synced to ${version}`); diff --git a/src/core/app/ThunderController.ts b/src/core/app/ThunderController.ts index 116f19e8..6b608617 100644 --- a/src/core/app/ThunderController.ts +++ b/src/core/app/ThunderController.ts @@ -10,6 +10,12 @@ import { IgnoreService } from '../indexing/IgnoreService'; import { FileDiscoveryService } from '../indexing/FileDiscoveryService'; import { WorkspaceScanner } from '../indexing/WorkspaceScanner'; import { IndexQueue } from '../indexing/IndexQueue'; +import { + AUTO_INDEX_BACKGROUND_DELAY_MS, + AUTO_INDEX_INITIAL_FILE_LIMIT, + priorityDiscoveryRoots, + sortIndexCandidates, +} from '../indexing/indexingPolicy'; import { initTreeSitter, preloadCommonLanguages } from '../indexing/TreeSitterService'; import { setTreeSitterEnabled } from '../indexing/SymbolExtractor'; import { FtsIndex } from '../indexing/FtsIndex'; @@ -28,8 +34,8 @@ import { debounce } from '../util/debounce'; import { ChatOrchestrator } from '../orchestration/ChatOrchestrator'; import { ToolRuntime } from '../tools/ToolRuntime'; import { - createReadFileTool, createReadFilesTool, createListFilesTool, createSearchTool, - createSearchBatchTool, createSearchScriptCatalogTool, createSpawnResearchAgentTool, + createReadFileTool, createReadFilesTool, createListFilesTool, createResolvePathTool, createSearchTool, + createSearchBatchTool, createSearchScriptCatalogTool, createSpawnResearchAgentTool, createSpawnSubagentTool, createExecuteWorkspaceScriptTool, createUseSkillTool, createRepoMapTool, createRetrieveContextTool, createGitDiffTool, createDiagnosticsTool, createWriteFileTool, createApplyPatchTool, createRunCommandTool, @@ -58,10 +64,14 @@ import { MemoryExtractor } from '../runtime/MemoryExtractor'; import { SubagentTracker } from '../runtime/SubagentTracker'; import { PassiveMemoryInjector } from '../memory/PassiveMemoryInjector'; import { MemoryHookService } from '../memory/MemoryHookService'; +import { AutoMemoryContextSource, AutoMemoryFileWriter } from '../memory/AutoMemoryFileWriter'; import { PostEditValidator } from '../apply/PostEditValidator'; import { VectorContextSource } from '../context/sources/VectorContextSource'; +import { CallGraphContextSource } from '../context/sources/callGraphSource'; import { VectorIndexService } from '../indexing/VectorIndex'; import { createEmbeddingProvider, describeEmbeddingProvider } from '../indexing/embeddingFactory'; +import { getOrCreateLanguageService, disposeLanguageService } from '../indexing/languageServiceFactory'; +import { isTsLikeFile, type WorkspaceLanguageService } from '../indexing/WorkspaceLanguageService'; import { createVectorIndex, describeVectorBackend } from '../indexing/vectorIndexFactory'; import { isLanceDbAvailable, isMinilmAvailable } from '../indexing/vectorAvailability'; import type { EmbeddingProvider } from '../indexing/EmbeddingProvider'; @@ -98,7 +108,7 @@ import { 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 { createWorkspacePattern, isWorkspaceInVscodeFolders, normalizeWorkspaceRoot, toWorkspaceRelPath, resolveWorkspaceRelPath } from '../util/paths'; import type { CommitMessageResult } from '../scm'; import { MicroTaskExecutor } from '../microtasks'; import { AuditPackBuilder } from '../audit'; @@ -140,6 +150,7 @@ export class ThunderController { private gitService: GitService | undefined; private diagnosticsService = new DiagnosticsService(); private memoryService: MemoryService | undefined; + private autoMemoryWriter: AutoMemoryFileWriter | undefined; private checkpointService: CheckpointService | undefined; private sessionService: SessionService | undefined; private planPersistence: PlanPersistence | undefined; @@ -150,6 +161,9 @@ export class ThunderController { private postEditValidator: PostEditValidator | undefined; private vectorIndexService: VectorIndexService | undefined; private embeddingProvider: EmbeddingProvider | undefined; + private languageService: WorkspaceLanguageService | undefined; + private languageServiceSyncDisposable: vscode.Disposable | undefined; + private languageServiceUpdateDebouncers = new Map void>(); private mcpManager = new McpManager(); private projectRulesService: ProjectRulesService | undefined; private providerProfilesService: ProviderProfilesService | undefined; @@ -164,6 +178,7 @@ export class ThunderController { private mcpToggles: McpToggles = defaultMcpToggles(); private pendingWatchJobs = new Map(); private watchDebounceTimer: ReturnType | undefined; + private backgroundIndexTimer: ReturnType | undefined; private debouncedRebuildRetriever: (() => void) | undefined; private currentPlan: PlanView | null = null; private currentReviewDiff: WebviewState['reviewDiff'] = null; @@ -389,7 +404,7 @@ export class ThunderController { this.chatOrchestrator?.configure({ researchAgentProvider: this.researchAgentProvider }); this.configDisposable = vscode.workspace.onDidChangeConfiguration((e) => { - if (e.affectsConfiguration('thunder.workspace') || e.affectsConfiguration('thunder')) { + if (e.affectsConfiguration('mitii.workspace') || e.affectsConfiguration('mitii') || e.affectsConfiguration('thunder.workspace') || e.affectsConfiguration('thunder')) { void this.reloadWorkspace(); } }); @@ -424,7 +439,7 @@ export class ThunderController { const config = this.configService.getConfig(); if (!config.indexing.enabled || !config.indexing.autoIndexOnOpen) return; try { - await this.indexWorkspace({ force: false }); + await this.indexWorkspace({ force: false, auto: true }); } catch (error) { log.warn('Auto-index on open failed', { error: error instanceof Error ? error.message : String(error), @@ -478,6 +493,7 @@ export class ThunderController { respectGitignore: config.indexing.respectGitignore, respectThunderignore: config.indexing.respectThunderignore, }); + this.languageService = getOrCreateLanguageService(workspace, this.ignoreService, config.indexing); this.scanner = new WorkspaceScanner(db, workspace); setTreeSitterEnabled(config.indexing.treeSitterEnabled); @@ -489,6 +505,7 @@ export class ThunderController { this.indexQueue = new IndexQueue(db, { maxConcurrency: config.indexing.maxConcurrency, maxFileSizeBytes: config.indexing.maxFileSizeBytes, + deferVectorWrites: true, }); this.indexQueue.setVectorService(workspace, this.vectorIndexService); this.indexQueue.onIndexingComplete(() => { @@ -516,6 +533,10 @@ export class ThunderController { maxItems: config.memory.maxItems, hybridSearchEnabled: config.memory.hybridSearchEnabled, }); + this.autoMemoryWriter = new AutoMemoryFileWriter(workspace, { + enabled: config.memory.autoMemoryEnabled, + scope: config.memory.autoMemoryScope, + }); if (config.indexing.vectorsEnabled) { this.memoryService.setEmbedder(this.embeddingProvider); } @@ -551,8 +572,11 @@ export class ThunderController { this.notifyUi({ subagents: runs.map((r) => ({ id: r.id, + type: r.type, task: r.task, focus: r.focus, + scope: r.scope, + progress: r.progress, status: r.status, startedAt: r.startedAt, finishedAt: r.finishedAt, @@ -574,7 +598,8 @@ export class ThunderController { this.policyEngine = new ToolPolicyEngine( effectiveSafety, (path) => this.ignoreService.isIgnored(path), - () => this.isWorkspaceTrusted() + () => this.isWorkspaceTrusted(), + (path) => resolveWorkspaceRelPath(workspace, path) ); this.toolExecutor = new ToolExecutor( @@ -612,14 +637,16 @@ export class ThunderController { const repoMap = new RepoMapService(db, workspace); const fts = new FtsIndex(db); - this.toolRuntime.register(createReadFileTool(workspace, this.ignoreService)); - this.toolRuntime.register(createReadFilesTool(workspace, this.ignoreService)); + this.toolRuntime.register(createReadFileTool(workspace, this.ignoreService, db)); + this.toolRuntime.register(createReadFilesTool(workspace, this.ignoreService, db)); this.toolRuntime.register(createListFilesTool(workspace, this.ignoreService)); + this.toolRuntime.register(createResolvePathTool(workspace, this.ignoreService, db)); this.toolRuntime.register(createSearchTool(fts, workspace)); this.toolRuntime.register(createSearchBatchTool(fts, workspace)); this.toolRuntime.register(createSearchScriptCatalogTool(workspace, this.context.extensionPath)); this.toolRuntime.register(createExecuteWorkspaceScriptTool(workspace, this.context.extensionPath, this.ignoreService)); this.toolRuntime.register(createUseSkillTool(this.skillCatalogService)); + this.toolRuntime.register(createSpawnSubagentTool()); this.toolRuntime.register(createSpawnResearchAgentTool()); this.toolRuntime.register(createRepoMapTool(repoMap)); this.toolRuntime.register(createRetrieveContextTool(retriever, budgeter)); @@ -659,10 +686,43 @@ export class ThunderController { this.memoryExtractor = new MemoryExtractor( this.memoryService, - config.memory.summarizeAfterTask + config.memory.summarizeAfterTask, + this.autoMemoryWriter ); this.setupFileWatcher(workspace); + this.setupLanguageServiceSync(workspace); + } + + /** Keeps the persistent language service's in-memory AST synchronized with unsaved editor + * buffers. Debounced per-file — a single shared debounce would drop updates when the user + * edits two files close together in time. */ + private setupLanguageServiceSync(workspace: string): void { + this.languageServiceSyncDisposable?.dispose(); + this.languageServiceUpdateDebouncers.clear(); + + const config = this.configService.getConfig(); + const pendingContent = new Map(); + this.languageServiceSyncDisposable = vscode.workspace.onDidChangeTextDocument((e) => { + if (!isTsLikeFile(e.document.fileName)) return; + const relPath = toWorkspaceRelPath(e.document.uri, workspace); + if (!relPath || this.ignoreService.isIgnored(relPath)) return; + + // Always capture the latest text synchronously; only the *flush* is debounced, so a + // debounced closure created on an earlier keystroke never applies stale content. + pendingContent.set(relPath, e.document.getText()); + + let scheduled = this.languageServiceUpdateDebouncers.get(relPath); + if (!scheduled) { + scheduled = debounce(() => { + const content = pendingContent.get(relPath); + if (content !== undefined) this.languageService?.updateFile(relPath, content); + }, config.indexing.watchDebounceMs); + this.languageServiceUpdateDebouncers.set(relPath, scheduled); + } + scheduled(); + }); + this.context.subscriptions.push(this.languageServiceSyncDisposable); } private createChatOrchestrator( @@ -825,9 +885,13 @@ export class ThunderController { if (this.contextToggles.gitDiff && this.gitService) sources.push(new GitDiffContextSource(this.gitService)); if (this.contextToggles.diagnostics) sources.push(new DiagnosticsContextSource(this.diagnosticsService)); if (this.contextToggles.memory) sources.push(new MemoryContextSource(this.memoryService)); + if (this.contextToggles.memory && this.autoMemoryWriter) sources.push(new AutoMemoryContextSource(this.autoMemoryWriter)); if (this.contextToggles.vectors && this.vectorIndexService) { sources.push(new VectorContextSource(this.vectorIndexService, workspace)); } + if (this.contextToggles.callGraph && this.languageService) { + sources.push(new CallGraphContextSource(db, workspace, this.languageService)); + } const config = this.configService.getConfig(); const reranker = createContextReranker( @@ -864,10 +928,11 @@ export class ThunderController { ); const enqueue = (uri: vscode.Uri) => { - if (!this.indexQueue || !this.scanner) return; if (!this.isWorkspaceTrusted()) return; const relPath = toWorkspaceRelPath(uri, workspace); if (!relPath || this.ignoreService.isIgnored(relPath)) return; + if (isTsLikeFile(relPath)) this.languageService?.syncFileFromDisk(relPath); + if (!this.indexQueue || !this.scanner) return; const fileId = this.scanner.getFileId(relPath); if (fileId) { this.pendingWatchJobs.set(relPath, { @@ -887,6 +952,13 @@ export class ThunderController { watcher.onDidChange(enqueue); watcher.onDidCreate(enqueue); + watcher.onDidDelete((uri) => { + if (!this.isWorkspaceTrusted()) return; + const relPath = toWorkspaceRelPath(uri, workspace); + if (relPath && isTsLikeFile(relPath) && !this.ignoreService.isIgnored(relPath)) { + this.languageService?.syncFileFromDisk(relPath); + } + }); this.context.subscriptions.push(watcher); const refreshSkills = () => { @@ -940,20 +1012,18 @@ export class ThunderController { agentLiveStatus: base.agentLiveStatus ?? this.agentLiveStatus, subagents: base.subagents ?? this.subagentTracker.getRuns().map((r) => ({ id: r.id, + type: r.type, task: r.task, focus: r.focus, + scope: r.scope, + progress: r.progress, status: r.status, startedAt: r.startedAt, finishedAt: r.finishedAt, summary: r.summary, error: r.error, })), - vectorIndex: { - enabled: config.indexing.vectorsEnabled, - embeddedChunks: this.vectorIndexService?.count(workspacePath) ?? 0, - provider: describeEmbeddingProvider(config.indexing), - backend: describeVectorBackend(config.indexing), - }, + vectorIndex: buildVectorIndexStatusView(config.indexing, workspacePath, this.vectorIndexService), tokenUsage: base.tokenUsage ?? { ...this.tokenUsage, contextWindow: config.provider.contextWindow, @@ -995,6 +1065,9 @@ export class ThunderController { requireApprovalWrites: config.safety.requireApprovalForWrites, requireApprovalShell: config.safety.requireApprovalForShell, memoryEnabled: config.memory.enabled, + summarizeAfterTask: config.memory.summarizeAfterTask, + autoMemoryEnabled: config.memory.autoMemoryEnabled, + autoMemoryScope: config.memory.autoMemoryScope, subagentsEnabled: config.agent.subagentsEnabled, agentMaxSteps: config.agent.maxSteps, askDepth: config.agent.askDepth, @@ -1236,6 +1309,21 @@ export class ThunderController { } getSession(): ThunderSession | undefined { return this.session; } + + /** Reset per-turn task routing state when the user switches chat modes mid-thread. */ + handleModeChange(mode: ThunderMode): void { + this.session?.setMode(mode); + this.agentTaskState.reset(); + this.agentTaskState.setLimits({ + maxSequentialThinkingCalls: this.configService.getConfig().agent.maxSequentialThinkingCallsPerTurn, + }); + this.chatOrchestrator?.clearRoutingState(); + + if (mode === 'ask' && this.session?.id) { + this.planPersistence?.complete(this.session.id); + this.currentPlan = null; + } + } restoreChatSession(sessionId: string, options: { mode?: ThunderMode } = {}): PlanView | null { const restoredId = sessionId.trim(); if (!restoredId) return this.currentPlan; @@ -1773,8 +1861,14 @@ export class ThunderController { this.configureSessionLogging(this.session, workspace); } this.chatOrchestrator = undefined; + if (this.backgroundIndexTimer) { + clearTimeout(this.backgroundIndexTimer); + this.backgroundIndexTimer = undefined; + } this.indexService?.dispose(); this.indexService = undefined; + if (previousWorkspace) disposeLanguageService(previousWorkspace); + this.languageService = undefined; this.scanner = undefined; this.indexQueue = undefined; this.projectRulesService = undefined; @@ -1816,9 +1910,11 @@ export class ThunderController { const audit = this.toolRuntime.getAuditLog(); const summary = this.buildTurnSummary(audit); - const hadActivityErrors = this.agentActivity.some((entry) => entry.kind === 'error'); - const hadToolFailures = audit.some((entry) => !entry.result.success); - const hadError = options?.hadError || hadActivityErrors || hadToolFailures; + const fatalToolFailures = findFatalToolFailures(audit); + const hadActivityErrors = this.agentActivity.some( + (entry) => entry.kind === 'error' && !isRecoveredToolActivity(entry.message, audit, fatalToolFailures) + ); + const hadError = options?.hadError || hadActivityErrors || fatalToolFailures.length > 0; const entry: import('../../vscode/webview/messages').AgentActivityEntry = { id: `act-complete-${Date.now()}`, @@ -2206,14 +2302,19 @@ export class ThunderController { const result = await this.toolExecutor.executeApproved(request.toolName, fullInput); if (result.success) { + const isExternalRead = ['read_file', 'read_files'].includes(request.toolName); const successMessage = request.toolName === 'run_command' ? 'Ran approved command' - : `Applied ${path ?? request.toolName}`; + : isExternalRead + ? `Read ${path ?? 'external file'}` + : `Applied ${path ?? request.toolName}`; this.pushActivity(request.toolName === 'run_command' ? 'tool' : 'apply', successMessage, result.output); if (request.toolName === 'run_command' && typeof fullInput.command === 'string') { this.pendingApprovalOutputs.push( `### Command\n\`${fullInput.command}\`\n\n### Output\n${result.output.slice(0, 6000)}` ); + } else if (isExternalRead) { + this.pendingApprovalOutputs.push(`Read ${request.toolName} for \`${path ?? request.files.join(', ')}\``); } else if (path) { this.pendingApprovalOutputs.push(`Applied ${request.toolName} to \`${path}\``); } @@ -2227,9 +2328,13 @@ export class ThunderController { }); } void vscode.window.showInformationMessage( - request.toolName === 'run_command' ? brandMessage('Command completed.') : `${AGENT_NAME}: Updated ${path ?? 'file'}` + request.toolName === 'run_command' + ? brandMessage('Command completed.') + : isExternalRead + ? `${AGENT_NAME}: Read ${path ?? 'external file'}` + : `${AGENT_NAME}: Updated ${path ?? 'file'}` ); - if (path) { + if (path && !isExternalRead) { const workspace = this.resolveWorkspacePath(); if (workspace) { void vscode.window.showTextDocument(vscode.Uri.file(join(workspace, path))); @@ -2397,9 +2502,14 @@ export class ThunderController { } async saveAgentSettings(settings: AgentSettingsPayload): Promise { + const previousDepth = this.configService.getConfig().agent.actDepth; await this.configService.updateAgentSettings(normalizeAgentSettings(settings)); const config = this.configService.getConfig(); + if (settings.actDepth === 'quick' || (previousDepth !== 'quick' && config.agent.actDepth === 'quick')) { + this.agentTaskState.reset(); + this.chatOrchestrator?.clearRoutingState(); + } await this.refreshResearchAgentProvider(); this.chatOrchestrator?.configure({ agentConfig: config.agent, @@ -2433,12 +2543,19 @@ export class ThunderController { try { const beforeConfig = this.configService.getConfig(); const normalized = normalizeThunderSettings(settings, beforeConfig.provider.contextWindow, this.mcpToggles); + const normalizedMemory = normalized.memory ?? { + summarizeAfterTask: true, + autoMemoryEnabled: true, + autoMemoryScope: 'user' as const, + }; const vectorConfigChanged = beforeConfig.indexing.vectorsEnabled !== normalized.indexing.vectorsEnabled || beforeConfig.indexing.embeddingProvider !== normalized.indexing.embeddingProvider || beforeConfig.indexing.vectorBackend !== normalized.indexing.vectorBackend || - beforeConfig.memory.hybridSearchEnabled !== normalized.indexing.hybridMemorySearch; + beforeConfig.memory.hybridSearchEnabled !== normalized.indexing.hybridMemorySearch || + beforeConfig.memory.autoMemoryEnabled !== normalizedMemory.autoMemoryEnabled || + beforeConfig.memory.autoMemoryScope !== normalizedMemory.autoMemoryScope; await this.configService.updateAllSettings(normalized); @@ -2586,6 +2703,7 @@ export class ThunderController { memory: builtin.memory, sequentialThinking: builtin.sequentialThinking, puppeteer: builtin.puppeteer ?? false, + agentmemory: builtin.agentmemory ?? false, }; } @@ -2663,7 +2781,7 @@ export class ThunderController { await this.showInlineDiffForPendingApprovals(approvalId); } - async indexWorkspace(options: { force?: boolean } = { force: true }): Promise { + async indexWorkspace(options: { force?: boolean; auto?: boolean; background?: boolean } = { force: true }): Promise { const workspace = this.resolveWorkspacePath(); if (!workspace) { this.setWorkspaceNotice('warn', 'Set a workspace path first (Browse or paste an absolute path).'); @@ -2693,18 +2811,31 @@ export class ThunderController { return; } - const discovery = new FileDiscoveryService(workspace, this.ignoreService, config.indexing); - const files = discovery.discover(); - if (!this.scanner || !this.indexQueue) { void vscode.window.showErrorMessage(brandMessage('Index services not initialized.')); return; } - const diff = this.scanner.computeDiff(files); + const previousStatus = this.indexQueue.getStatus(); + const firstAutoRun = Boolean(options.auto && !options.background && previousStatus.indexed === 0); + const priorityRoots = firstAutoRun ? priorityDiscoveryRoots(workspace) : []; + const isPartialDiscovery = firstAutoRun; + const discovery = new FileDiscoveryService(workspace, this.ignoreService, config.indexing); + const files = sortIndexCandidates( + await discovery.discoverAsync({ + roots: isPartialDiscovery && priorityRoots.length > 0 ? priorityRoots : undefined, + limit: isPartialDiscovery ? AUTO_INDEX_INITIAL_FILE_LIMIT : undefined, + }), + config.indexing.priorityPaths + ); + + const diff = this.scanner.computeDiff(files, { includeDeleted: !isPartialDiscovery }); this.scanner.persistScan(diff); - const filesToIndex = options.force ? files : [...diff.added, ...diff.changed]; + const filesToIndex = sortIndexCandidates( + options.force ? files : [...diff.added, ...diff.changed], + config.indexing.priorityPaths + ); const jobs = filesToIndex.map((f) => ({ fileId: this.scanner!.getFileId(f.relPath)!, relPath: f.relPath, @@ -2717,25 +2848,44 @@ export class ThunderController { this.setWorkspaceNotice('ok', 'Index is up to date'); this.sessionLog.append('index_complete', 'Index up to date', { workspace, jobCount: 0 }); this.notifyUi({ indexing: this.indexingStatus, workspaceNotice: this.workspaceNotice }); + if (firstAutoRun) this.scheduleBackgroundIndex(workspace); return; } this.indexQueue.enqueue(jobs); this.indexingStatus = this.indexQueue.getStatus(); - this.setWorkspaceNotice('ok', `${options.force ? 'Reindexing' : 'Indexing'} ${jobs.length} files…`); - this.sessionLog.append('index_start', `${options.force ? 'Reindexing' : 'Indexing'} ${jobs.length} files`, { + const label = options.background + ? 'Background indexing' + : firstAutoRun + ? 'Indexing priority files' + : options.force + ? 'Reindexing' + : 'Indexing'; + this.setWorkspaceNotice('ok', `${label} ${jobs.length} files…`); + this.sessionLog.append('index_start', `${label} ${jobs.length} files`, { workspace, added: diff.added.length, changed: diff.changed.length, removed: diff.deleted.length, forced: options.force, + partial: isPartialDiscovery, }); this.notifyUi({ indexing: this.indexingStatus, workspaceNotice: this.workspaceNotice }); - log.info('indexWorkspace', { total: jobs.length }); + log.info('indexWorkspace', { total: jobs.length, partial: isPartialDiscovery, background: options.background }); + if (firstAutoRun) this.scheduleBackgroundIndex(workspace); void this.waitForIndexingComplete(workspace, jobs.length); } + private scheduleBackgroundIndex(workspace: string): void { + if (this.backgroundIndexTimer || this.disposed) return; + this.backgroundIndexTimer = setTimeout(() => { + this.backgroundIndexTimer = undefined; + if (this.disposed || this.resolveWorkspacePath() !== workspace) return; + void this.indexWorkspace({ force: false, background: true }); + }, AUTO_INDEX_BACKGROUND_DELAY_MS); + } + private async waitForIndexingComplete(workspace: string, jobCount: number): Promise { if (!this.indexQueue || jobCount === 0) { this.sessionLog.append('index_complete', 'Index up to date', { workspace, jobCount: 0 }); @@ -2747,6 +2897,7 @@ export class ThunderController { await new Promise((resolve) => setTimeout(resolve, 500)); if (Date.now() - start > 600_000) break; } + await this.indexQueue.waitForVectorIndexing(); const status = this.indexQueue.getStatus(); this.sessionLog.append('index_complete', 'Indexing finished', { @@ -2765,8 +2916,15 @@ export class ThunderController { this.disposed = true; this.configService.dispose(); void this.mcpManager.closeAll(); + if (this.backgroundIndexTimer) clearTimeout(this.backgroundIndexTimer); + if (this.watchDebounceTimer) clearTimeout(this.watchDebounceTimer); + if (this.indexStatusNotifyTimer) clearTimeout(this.indexStatusNotifyTimer); + if (this.tokenUsageNotifyTimer) clearTimeout(this.tokenUsageNotifyTimer); this.indexService?.dispose(); this.indexQueue?.cancel(); + this.languageServiceSyncDisposable?.dispose(); + if (this.session?.workspace) disposeLanguageService(this.session.workspace); + this.languageService = undefined; this.session = undefined; log.info('ThunderController disposed'); } @@ -2787,6 +2945,30 @@ export function toApprovalView(r: import('../safety/ApprovalQueue').ApprovalRequ }; } +function buildVectorIndexStatusView( + indexingConfig: import('../config/schema').IndexingConfig, + workspace: string, + vectorIndexService: VectorIndexService | undefined +): import('../../vscode/webview/messages').VectorIndexStatusView { + const health = vectorIndexService?.getHealth(); + const degradedParts: string[] = []; + if (health?.embedder.status === 'degraded') { + degradedParts.push(`embeddings: ${health.embedder.detail ?? 'unavailable'}`); + } + if (health?.backend.status === 'degraded') { + degradedParts.push(`vector backend: ${health.backend.detail ?? 'unavailable'}`); + } + + return { + enabled: indexingConfig.vectorsEnabled, + embeddedChunks: vectorIndexService?.count(workspace) ?? 0, + provider: describeEmbeddingProvider(indexingConfig), + backend: describeVectorBackend(indexingConfig), + degraded: degradedParts.length > 0, + degradedDetail: degradedParts.length > 0 ? degradedParts.join('; ') : undefined, + }; +} + function toPlanView(plan: import('../plans/PlanActEngine').ThunderPlan | null | undefined): PlanView | null { if (!plan) return null; const stepStatus = new Map(plan.steps.map((step) => [step.id, step.status])); @@ -2827,6 +3009,55 @@ function normalizePromptBreakdown( ]; } +function findFatalToolFailures( + audit: import('../tools/types').ToolCallAudit[] +): import('../tools/types').ToolCallAudit[] { + return audit.filter((entry, index) => isFatalToolFailure(entry, index, audit)); +} + +function isFatalToolFailure( + entry: import('../tools/types').ToolCallAudit, + index: number, + audit: import('../tools/types').ToolCallAudit[] +): boolean { + if (entry.result.success || entry.result.skipped) return false; + + const later = audit.slice(index + 1); + if (isExplorationTool(entry.toolName)) { + return !later.some((candidate) => candidate.result.success && isExplorationTool(candidate.toolName)); + } + + const key = recoveryKey(entry); + return !later.some((candidate) => + candidate.result.success && + candidate.toolName === entry.toolName && + recoveryKey(candidate) === key + ); +} + +function isRecoveredToolActivity( + message: string, + audit: import('../tools/types').ToolCallAudit[], + fatalToolFailures: import('../tools/types').ToolCallAudit[] +): boolean { + if (!/\bfailed\b/i.test(message)) return false; + if (fatalToolFailures.length > 0) return false; + return audit.some((entry) => !entry.result.success || entry.result.skipped); +} + +function isExplorationTool(toolName: string): boolean { + return ['read_file', 'read_files', 'list_files', 'search', 'search_batch', 'resolve_path', 'repo_map'].includes(toolName); +} + +function recoveryKey(entry: import('../tools/types').ToolCallAudit): string { + const input = entry.input as Record; + if (typeof input.path === 'string') return `path:${input.path}`; + if (typeof input.command === 'string') return `command:${input.command}`; + if (typeof input.stepId === 'string') return `step:${input.stepId}`; + if (typeof input.script === 'string') return `script:${input.script}`; + return entry.toolName; +} + function readPackageVersion(workspace: string): string { try { const pkg = JSON.parse(readFileSync(join(workspace, 'package.json'), 'utf8')) as { version?: string }; diff --git a/src/core/apply/PatchApplyService.ts b/src/core/apply/PatchApplyService.ts index 8fce5bd6..cb3e542b 100644 --- a/src/core/apply/PatchApplyService.ts +++ b/src/core/apply/PatchApplyService.ts @@ -101,6 +101,10 @@ export class PatchApplyService { } } + if (!/\.(?:tsx?|jsx?|mjs|cjs)$/i.test(path)) { + return { success: true, proposedContent: content }; + } + const balances = countCodeDelimiters(content); const jsxBalance = path.endsWith('.tsx') ? countJsxTagBalance(content) : 0; diff --git a/src/core/config/keys.ts b/src/core/config/keys.ts index 1a91228f..c6fc76f3 100644 --- a/src/core/config/keys.ts +++ b/src/core/config/keys.ts @@ -1,5 +1,10 @@ -export const CONFIG_SECTION = 'thunder'; +export const CONFIG_SECTION = 'mitii'; +export const LEGACY_CONFIG_SECTION = 'thunder'; export function thunderConfigKey(path: string): string { return `${CONFIG_SECTION}.${path}`; } + +export function legacyThunderConfigKey(path: string): string { + return `${LEGACY_CONFIG_SECTION}.${path}`; +} diff --git a/src/core/config/schema.ts b/src/core/config/schema.ts index 194d35ac..c726b3c2 100644 --- a/src/core/config/schema.ts +++ b/src/core/config/schema.ts @@ -20,7 +20,7 @@ export const ProviderConfigSchema = z.object({ 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'), + apiKeyRef: z.string().default('mitii.apiKey'), contextWindow: z.number().int().positive().default(8192), supportsStreaming: z.boolean().default(true), supportsTools: z.boolean().default(true), @@ -30,7 +30,7 @@ export const ProviderConfigSchema = z.object({ }); export const EmbeddingProviderSchema = z.enum(['hash', 'minilm']).default('minilm'); -export const VectorBackendSchema = z.enum(['sqlite', 'lancedb']).default('sqlite'); +export const VectorBackendSchema = z.enum(['sqlite', 'lancedb']).default('lancedb'); export const IndexingConfigSchema = z.object({ enabled: z.boolean().default(true), @@ -44,6 +44,8 @@ export const IndexingConfigSchema = z.object({ vectorsEnabled: z.boolean().default(true), embeddingProvider: EmbeddingProviderSchema, vectorBackend: VectorBackendSchema, + watchDebounceMs: z.number().int().min(100).max(10_000).default(500), + priorityPaths: z.array(z.string()).default([]), }); export const ContextConfigSchema = z.object({ @@ -62,6 +64,8 @@ export const EnterpriseConfigSchema = z.object({ localProvidersOnly: z.boolean().default(false), stripFileContentsFromAuditPacks: z.boolean().default(false), autoExportAuditPackOnSessionEnd: z.boolean().default(false), + channelsDisabled: z.boolean().default(false), + maxParallel: z.number().int().min(1).max(100).default(10), }); export const AgentDepthSchema = z.enum(['auto', 'quick', 'standard', 'deep', 'pilot', 'enterprise']); @@ -81,10 +85,17 @@ export const MemoryConfigSchema = z.object({ maxItems: z.number().int().positive().default(500), summarizeAfterTask: z.boolean().default(true), hybridSearchEnabled: z.boolean().default(true), + autoMemoryEnabled: z.boolean().default(true), + autoMemoryScope: z.enum(['user', 'workspace', 'both']).default('user'), }); export const AgentConfigSchema = z.object({ subagentsEnabled: z.boolean().default(true), + teamsEnabled: z.boolean().default(false), + subagentTypesEnabled: z.array(z.string()).default(['research']), + maxConcurrentSubagents: z.number().int().min(1).max(10).default(2), + implementerRequiresScope: z.boolean().default(true), + subagentDailyBudget: z.number().int().min(0).default(0), maxSteps: z.number().int().min(1).max(100).default(15), askMaxSteps: z.number().int().min(1).max(50).default(18), askDepth: AgentDepthSchema.default('auto'), @@ -141,6 +152,7 @@ export const BuiltinMcpTogglesSchema = z.object({ memory: z.boolean().default(true), sequentialThinking: z.boolean().default(true), puppeteer: z.boolean().default(false), + agentmemory: z.boolean().default(false), }); export const McpConfigSchema = z.object({ @@ -162,7 +174,10 @@ export const ScmConfigSchema = z.object({ export const GitHubConfigSchema = z.object({ issueFetchEnabled: z.boolean().default(true), issueCommentLimit: z.number().int().min(0).max(25).default(8), - tokenRef: z.string().default('thunder.github.token'), + tokenRef: z.string().default('mitii.github.token'), + autoPrEnabled: z.boolean().default(false), + defaultBaseBranch: z.string().default(''), + webhookSecret: z.string().default(''), }); export const TelemetryConfigSchema = z.object({ diff --git a/src/core/config/settingPaths.ts b/src/core/config/settingPaths.ts new file mode 100644 index 00000000..6d02647a --- /dev/null +++ b/src/core/config/settingPaths.ts @@ -0,0 +1,92 @@ +export const MITII_SETTING_PATHS = [ + 'debug', + 'telemetry.sessionLogging', + 'telemetry.debugMetrics', + 'telemetry.webhookUrl', + 'telemetry.webhookSecret', + 'telemetry.webhookTimeoutMs', + 'ui.showReasoning', + 'ui.reasoningPreviewMaxChars', + 'enterprise.localProvidersOnly', + 'enterprise.stripFileContentsFromAuditPacks', + 'enterprise.autoExportAuditPackOnSessionEnd', + 'enterprise.channelsDisabled', + 'enterprise.maxParallel', + 'runtime.mode', + 'runtime.daemonUrl', + 'runtime.daemonToken', + 'runtime.autoStartDaemon', + 'provider.type', + 'provider.baseUrl', + 'provider.model', + 'provider.apiVersion', + 'provider.region', + 'provider.contextWindow', + 'provider.supportsVision', + 'provider.supportsReasoning', + 'indexing.enabled', + 'indexing.autoIndexOnOpen', + 'indexing.maxFileSizeBytes', + 'indexing.hardSkipSizeBytes', + 'indexing.maxConcurrency', + 'indexing.vectorsEnabled', + 'indexing.treeSitterEnabled', + 'indexing.embeddingProvider', + 'indexing.vectorBackend', + 'indexing.watchDebounceMs', + 'indexing.priorityPaths', + 'context.rerankerEnabled', + 'context.rerankerCandidatePool', + 'context.rerankerTopK', + 'context.microTaskRoutingEnabled', + 'safety.requireApprovalForWrites', + 'safety.requireApprovalForShell', + 'safety.approvalMode', + 'safety.autonomyPreset', + 'safety.allowUntrustedWorkspace', + 'memory.enabled', + 'memory.hybridSearchEnabled', + 'memory.summarizeAfterTask', + 'memory.autoMemoryEnabled', + 'memory.autoMemoryScope', + 'scm.commitMessageEnabled', + 'github.issueFetchEnabled', + 'github.issueCommentLimit', + 'github.tokenRef', + 'github.autoPrEnabled', + 'github.defaultBaseBranch', + 'github.webhookSecret', + 'agent.subagentsEnabled', + 'agent.subagentTypesEnabled', + 'agent.maxConcurrentSubagents', + 'agent.implementerRequiresScope', + 'agent.subagentDailyBudget', + 'agent.teamsEnabled', + 'agent.maxSteps', + 'agent.askMaxSteps', + 'agent.askDepth', + 'agent.planDepth', + 'agent.actDepth', + 'agent.askAutoContinue', + 'agent.askMaxAutoContinues', + 'agent.autoContinue', + 'agent.maxAutoContinues', + 'agent.researchAgentMaxSteps', + 'agent.researchAgentModel', + 'agent.researchAgentBaseUrl', + 'agent.orchestrationEnabled', + 'agent.showDiffPreview', + 'agent.verifyCommands', + 'agent.verifyOnActComplete', + 'agent.planModel', + 'agent.planBaseUrl', + 'agent.actModel', + 'agent.actBaseUrl', + 'agent.checkpointStrategy', + 'mcp.enabled', + 'mcp.preloadBuiltin', + 'mcp.builtinServers', + 'mcp.maxConcurrentStartup', + 'mcp.servers', + 'workspace.rootPathOverride', +] as const; diff --git a/src/core/config/ui/mappers.ts b/src/core/config/ui/mappers.ts index 3e226961..e4f01478 100644 --- a/src/core/config/ui/mappers.ts +++ b/src/core/config/ui/mappers.ts @@ -112,6 +112,11 @@ export function normalizeThunderSettings( builtinServers: builtinMcpToggles, }, indexing: settings.indexing, + memory: settings.memory ?? { + summarizeAfterTask: true, + autoMemoryEnabled: true, + autoMemoryScope: 'user', + }, telemetry: { sessionLogging: settings.telemetry.sessionLogging, debugMetrics: settings.telemetry.debugMetrics, diff --git a/src/core/config/ui/payloads.ts b/src/core/config/ui/payloads.ts index b524f2a8..6a7e68de 100644 --- a/src/core/config/ui/payloads.ts +++ b/src/core/config/ui/payloads.ts @@ -55,6 +55,7 @@ export interface McpToggles { memory: boolean; sequentialThinking: boolean; puppeteer: boolean; + agentmemory?: boolean; } export interface McpCustomServerView { @@ -91,11 +92,18 @@ export interface IndexingSettingsPayload { hybridMemorySearch: boolean; } +export interface MemorySettingsPayload { + summarizeAfterTask: boolean; + autoMemoryEnabled: boolean; + autoMemoryScope: 'user' | 'workspace' | 'both'; +} + export interface ThunderSettingsPayload { provider: ProviderSettingsPayload; agent: AgentSettingsPayload; safety: SafetySettingsPayload; mcp: McpSettingsPayload; indexing: IndexingSettingsPayload; + memory?: MemorySettingsPayload; telemetry: TelemetrySettingsPayload; } diff --git a/src/core/config/vscode/migrate.ts b/src/core/config/vscode/migrate.ts new file mode 100644 index 00000000..d6a64f13 --- /dev/null +++ b/src/core/config/vscode/migrate.ts @@ -0,0 +1,45 @@ +import * as vscode from 'vscode'; +import { CONFIG_SECTION, LEGACY_CONFIG_SECTION } from '../keys'; + +export interface SettingsMigrationResult { + copied: string[]; + skipped: string[]; +} + +export async function migrateThunderSettingsToMitii( + paths: string[], + target: vscode.ConfigurationTarget = vscode.ConfigurationTarget.Global +): Promise { + const current = vscode.workspace.getConfiguration(CONFIG_SECTION); + const legacy = vscode.workspace.getConfiguration(LEGACY_CONFIG_SECTION); + const copied: string[] = []; + const skipped: string[] = []; + + for (const path of paths) { + const legacyInspect = legacy.inspect(path); + if (!hasConfiguredValue(legacyInspect)) { + skipped.push(path); + continue; + } + if (hasConfiguredValue(current.inspect(path))) { + skipped.push(path); + continue; + } + await current.update(path, legacy.get(path), target); + copied.push(path); + } + + return { copied, skipped }; +} + +function hasConfiguredValue(inspect: ReturnType): boolean { + if (!inspect) return false; + return [ + inspect.globalValue, + inspect.workspaceValue, + inspect.workspaceFolderValue, + inspect.globalLanguageValue, + inspect.workspaceLanguageValue, + inspect.workspaceFolderLanguageValue, + ].some((value) => value !== undefined); +} diff --git a/src/core/config/vscode/read.ts b/src/core/config/vscode/read.ts index caa06cd3..4b58a4ad 100644 --- a/src/core/config/vscode/read.ts +++ b/src/core/config/vscode/read.ts @@ -4,10 +4,10 @@ import { type ThunderConfig, } from '../schema'; import { defaultThunderConfig } from '../defaults'; -import { CONFIG_SECTION } from '../keys'; +import { CONFIG_SECTION, LEGACY_CONFIG_SECTION } from '../keys'; export function readThunderConfigFromSettings(): ThunderConfig { - const config = vscode.workspace.getConfiguration(CONFIG_SECTION); + const config = createMitiiConfigReader(); const raw = { debug: config.get('debug'), provider: { @@ -36,6 +36,8 @@ export function readThunderConfigFromSettings(): ThunderConfig { vectorsEnabled: config.get('indexing.vectorsEnabled'), embeddingProvider: config.get('indexing.embeddingProvider'), vectorBackend: config.get('indexing.vectorBackend'), + watchDebounceMs: config.get('indexing.watchDebounceMs'), + priorityPaths: config.get('indexing.priorityPaths'), }, context: { rerankerEnabled: config.get('context.rerankerEnabled'), @@ -57,9 +59,12 @@ export function readThunderConfigFromSettings(): ThunderConfig { maxItems: config.get('memory.maxItems'), summarizeAfterTask: config.get('memory.summarizeAfterTask'), hybridSearchEnabled: config.get('memory.hybridSearchEnabled'), + autoMemoryEnabled: config.get('memory.autoMemoryEnabled'), + autoMemoryScope: config.get('memory.autoMemoryScope'), }, agent: { subagentsEnabled: config.get('agent.subagentsEnabled'), + teamsEnabled: config.get('agent.teamsEnabled'), maxSteps: config.get('agent.maxSteps'), askMaxSteps: config.get('agent.askMaxSteps'), askDepth: config.get('agent.askDepth'), @@ -104,6 +109,9 @@ export function readThunderConfigFromSettings(): ThunderConfig { issueFetchEnabled: config.get('github.issueFetchEnabled'), issueCommentLimit: config.get('github.issueCommentLimit'), tokenRef: config.get('github.tokenRef'), + autoPrEnabled: config.get('github.autoPrEnabled'), + defaultBaseBranch: config.get('github.defaultBaseBranch'), + webhookSecret: config.get('github.webhookSecret'), }, telemetry: { sessionLogging: config.get('telemetry.sessionLogging'), @@ -120,6 +128,8 @@ export function readThunderConfigFromSettings(): ThunderConfig { localProvidersOnly: config.get('enterprise.localProvidersOnly'), stripFileContentsFromAuditPacks: config.get('enterprise.stripFileContentsFromAuditPacks'), autoExportAuditPackOnSessionEnd: config.get('enterprise.autoExportAuditPackOnSessionEnd'), + channelsDisabled: config.get('enterprise.channelsDisabled'), + maxParallel: config.get('enterprise.maxParallel'), }, }; @@ -129,3 +139,32 @@ export function readThunderConfigFromSettings(): ThunderConfig { } return defaultThunderConfig(); } + +function createMitiiConfigReader(): { get(path: string): T | undefined } { + const current = vscode.workspace.getConfiguration(CONFIG_SECTION); + const legacy = vscode.workspace.getConfiguration(LEGACY_CONFIG_SECTION); + return { + get(path: string): T | undefined { + if (hasConfiguredValue(current.inspect(path))) { + return current.get(path); + } + if (hasConfiguredValue(legacy.inspect(path))) { + return legacy.get(path); + } + return current.get(path); + }, + }; +} + +function hasConfiguredValue(inspect: ReturnType): boolean { + if (!inspect) return false; + return [ + inspect.globalValue, + inspect.workspaceValue, + inspect.workspaceFolderValue, + inspect.defaultLanguageValue, + inspect.globalLanguageValue, + inspect.workspaceLanguageValue, + inspect.workspaceFolderLanguageValue, + ].some((value) => value !== undefined); +} diff --git a/src/core/config/vscode/write.ts b/src/core/config/vscode/write.ts index 663d25f4..20590587 100644 --- a/src/core/config/vscode/write.ts +++ b/src/core/config/vscode/write.ts @@ -2,6 +2,7 @@ import * as vscode from 'vscode'; import type { AgentSettingsPayload, IndexingSettingsPayload, + MemorySettingsPayload, ProviderSettingsPayload, SafetySettingsPayload, TelemetrySettingsPayload, @@ -97,11 +98,22 @@ export async function updateTelemetrySettings(settings: TelemetrySettingsPayload } } +export async function updateMemorySettings(settings: MemorySettingsPayload): Promise { + const config = vscode.workspace.getConfiguration(CONFIG_SECTION); + const target = vscode.ConfigurationTarget.Global; + await config.update('memory.summarizeAfterTask', settings.summarizeAfterTask, target); + await config.update('memory.autoMemoryEnabled', settings.autoMemoryEnabled, target); + await config.update('memory.autoMemoryScope', settings.autoMemoryScope, target); +} + export async function updateAllSettings(settings: ThunderSettingsPayload): Promise { await updateProviderSettings(settings.provider); await updateAgentSettings(settings.agent); await updateSafetySettings(settings.safety); await updateMcpSettings(settings.mcp); await updateIndexingSettings(settings.indexing); + if (settings.memory) { + await updateMemorySettings(settings.memory); + } await updateTelemetrySettings(settings.telemetry); } diff --git a/src/core/context/ContextBudgeter.ts b/src/core/context/ContextBudgeter.ts index b4538bd1..ee3df2c1 100644 --- a/src/core/context/ContextBudgeter.ts +++ b/src/core/context/ContextBudgeter.ts @@ -30,6 +30,7 @@ export class ContextBudgeter { { source: 'diagnostics', budget: maxTokens * BUDGET_SPLITS.openDiff * 0.2 }, { source: 'memory', budget: maxTokens * BUDGET_SPLITS.memory }, { source: 'vector', budget: maxTokens * 0.08 }, + { source: 'call-graph', budget: maxTokens * 0.10 }, ]; const includedIds = new Set(); diff --git a/src/core/context/ContextReranker.ts b/src/core/context/ContextReranker.ts index 70394aed..1ec5b56c 100644 --- a/src/core/context/ContextReranker.ts +++ b/src/core/context/ContextReranker.ts @@ -19,6 +19,7 @@ function tokenize(query: string): string[] { export class LexicalContextReranker implements ContextReranker { async rerank(query: string, items: ContextItem[], limit: number): Promise { const terms = tokenize(query); + log.debug('lexical:rerank', { termCount: terms.length, itemCount: items.length, limit }); if (terms.length === 0) return items.slice(0, limit); return items @@ -50,6 +51,7 @@ export class EmbeddingContextReranker implements ContextReranker { return new LexicalContextReranker().rerank(query, items, limit); } + log.debug('embedding:rerank', { itemCount: items.length, limit }); return items .map((item, idx) => { const vec = vectors[idx + 1]; diff --git a/src/core/context/HybridRetriever.ts b/src/core/context/HybridRetriever.ts index fe9da4aa..eb97ac36 100644 --- a/src/core/context/HybridRetriever.ts +++ b/src/core/context/HybridRetriever.ts @@ -49,6 +49,13 @@ export class HybridRetriever { ) {} async retrieve(query: ContextQuery): Promise { + log.debug('retrieve:start', { + queryText: query.text, + sourceCount: this.sources.length, + maxItems: query.maxItems, + scopeRoot: query.scopeRoot, + }); + const sourceById = new Map(this.sources.map((s) => [s.id, s])); const orderedSources: ContextSource[] = []; const seen = new Set(); @@ -82,6 +89,12 @@ export class HybridRetriever { if (result.status === 'fulfilled') { this.onTiming?.(result.value.timing); allItems.push(...result.value.items); + log.debug('source:complete', { + source: result.value.timing.source, + tier: result.value.timing.tier, + durationMs: result.value.timing.durationMs, + itemCount: result.value.timing.itemCount, + }); } else { const reason = result.reason as { durationMs?: unknown; error?: unknown }; const durationMs = typeof reason.durationMs === 'number' ? reason.durationMs : 0; @@ -103,9 +116,11 @@ export class HybridRetriever { } const deduped = deduplicateItems(allItems).sort((a, b) => b.score - a.score); + log.debug('dedup', { before: allItems.length, after: deduped.length }); if (this.reranker && this.rerankerConfig?.enabled) { const pool = deduped.slice(0, this.rerankerConfig.candidatePool); + log.debug('rerank:start', { candidatePoolSize: pool.length, topK: this.rerankerConfig.topK }); const startedAt = Date.now(); try { const reranked = await this.reranker.rerank( @@ -121,6 +136,7 @@ export class HybridRetriever { candidateCount: pool.length, resultCount: sliced.length, }); + log.debug('rerank:done', { durationMs: Date.now() - startedAt, resultCount: sliced.length }); return sliced; } catch (error) { this.onTiming?.({ @@ -135,7 +151,9 @@ export class HybridRetriever { } } - return deduped.slice(0, query.maxItems ?? 30); + const result = deduped.slice(0, query.maxItems ?? 30); + log.debug('retrieve:done', { returned: result.length }); + return result; } } diff --git a/src/core/context/fuzzyFileMatch.ts b/src/core/context/fuzzyFileMatch.ts index 3ddae009..70bba1dc 100644 --- a/src/core/context/fuzzyFileMatch.ts +++ b/src/core/context/fuzzyFileMatch.ts @@ -1,5 +1,5 @@ const FILE_MENTION_PATTERN = - /\b[\w./-]+\.(tsx?|jsx?|vue|svelte|py|go|rs|java|kt|swift|md|json|css|scss|html|yaml|yml|toml)\b/gi; + /\b[\w./-]+\.(tsx?|jsx?|mjs|cjs|vue|svelte|py|go|rs|java|kt|swift|mdx?|jsonl|json|log|txt|env|sh|sql|xml|ini|toml|yaml|yml|css|scss|html)\b/gi; const PACKAGE_LIKE_PATTERN = /\b[a-z][a-z0-9]*(?:-[a-z0-9]+)+\b/gi; export function extractFileMentions(text: string): string[] { diff --git a/src/core/context/sources/callGraphSource.ts b/src/core/context/sources/callGraphSource.ts new file mode 100644 index 00000000..c8b0effb --- /dev/null +++ b/src/core/context/sources/callGraphSource.ts @@ -0,0 +1,127 @@ +import type { ContextItem, ContextQuery, ContextSource } from '../types'; +import type { ThunderDb } from '../../indexing/ThunderDb'; +import type { WorkspaceLanguageService } from '../../indexing/WorkspaceLanguageService'; + +const MAX_CANDIDATE_TOKENS = 25; +const MAX_ANCHOR_SYMBOLS = 3; +const MAX_CALLERS_PER_SYMBOL = 8; + +interface SymbolRow { + name: string; + kind: string; + start_line: number; + rel_path: string; +} + +/** Precise, on-demand expansion layered on top of the heuristic repo-map: resolves query-mentioned + * symbol names to their true (re-export-resolved) definition and actual call sites via the + * persistent language service, rather than the DB's plain name-matching. Query-time only — no + * full-repo call graph is precomputed, to keep this bounded and avoid the latency this would + * otherwise add to every retrieval. */ +export class CallGraphContextSource implements ContextSource { + readonly id = 'call-graph'; + + constructor( + private readonly db: ThunderDb, + private readonly workspace: string, + private readonly languageService: WorkspaceLanguageService + ) {} + + async retrieve(query: ContextQuery): Promise { + try { + const tokens = extractCandidateTokens(query.text); + if (tokens.length === 0) return []; + + const anchors = this.findAnchorSymbols(tokens).slice(0, MAX_ANCHOR_SYMBOLS); + if (anchors.length === 0) return []; + + const items: ContextItem[] = []; + for (const anchor of anchors) { + items.push(...this.buildItemsForAnchor(anchor)); + } + return items; + } catch { + return []; + } + } + + private buildItemsForAnchor(anchor: SymbolRow): ContextItem[] { + const items: ContextItem[] = []; + + const anchorColumn = this.languageService.findColumnForName(anchor.rel_path, anchor.start_line, anchor.name); + if (anchorColumn === undefined) return items; + + const definitions = this.languageService.getDefinition(anchor.rel_path, anchor.start_line, anchorColumn); + const primaryDef = definitions[0]; + + if (primaryDef) { + items.push({ + id: `call-graph-def-${primaryDef.relPath}-${primaryDef.startLine}`, + source: this.id, + relPath: primaryDef.relPath, + startLine: primaryDef.startLine, + endLine: primaryDef.endLine, + content: `Definition of ${primaryDef.name} (resolved via language service, bypassing re-exports):\n${primaryDef.preview}`, + score: 9, + reason: `Call graph: true definition of ${primaryDef.name}`, + tokenEstimate: Math.ceil(primaryDef.preview.length / 4), + }); + } + + // Resolve callers from the *true* definition site when available, so a re-exported name + // still surfaces callers of the real implementation rather than the re-export specifier. + const defRelPath = primaryDef?.relPath ?? anchor.rel_path; + const defLine = primaryDef?.startLine ?? anchor.start_line; + const defName = primaryDef?.name ?? anchor.name; + const defColumn = primaryDef + ? this.languageService.findColumnForName(defRelPath, defLine, defName) + : anchorColumn; + if (defColumn === undefined) return items; + + const callers = this.languageService.getCallers(defRelPath, defLine, defColumn).slice(0, MAX_CALLERS_PER_SYMBOL); + if (callers.length > 0) { + const body = callers + .map((c) => `${c.relPath}:${c.line}${c.enclosingSymbol ? ` (in ${c.enclosingSymbol})` : ''} — ${c.preview}`) + .join('\n'); + items.push({ + id: `call-graph-callers-${defRelPath}-${defLine}`, + source: this.id, + relPath: defRelPath, + content: `Callers of ${defName} (${callers.length} found):\n${body}`, + score: 9, + reason: `Call graph: callers of ${defName}`, + tokenEstimate: Math.ceil(body.length / 4), + }); + } + + return items; + } + + private findAnchorSymbols(tokens: string[]): SymbolRow[] { + const placeholders = tokens.map(() => '?').join(','); + return this.db.raw + .prepare( + `SELECT s.name, s.kind, s.start_line, f.rel_path + FROM symbols s + JOIN files f ON f.id = s.file_id + WHERE f.workspace = ? AND s.kind IN ('function', 'method', 'class') AND s.name IN (${placeholders}) + LIMIT 10` + ) + .all(this.workspace, ...tokens) as SymbolRow[]; + } +} + +function extractCandidateTokens(text: string): string[] { + const matches = text.match(/\b[A-Za-z_$][A-Za-z0-9_$]*\b/g) ?? []; + const seen = new Set(); + const tokens: string[] = []; + + for (const match of matches) { + if (match.length < 3 || seen.has(match)) continue; + seen.add(match); + tokens.push(match); + if (tokens.length >= MAX_CANDIDATE_TOKENS) break; + } + + return tokens; +} diff --git a/src/core/context/sources/indexSources.ts b/src/core/context/sources/indexSources.ts index 5eee3001..aee2a61b 100644 --- a/src/core/context/sources/indexSources.ts +++ b/src/core/context/sources/indexSources.ts @@ -7,6 +7,9 @@ import { RepoMapService } from '../RepoMapService'; import type { MemoryService } from '../../memory/MemoryService'; import { isProjectOverviewQuestion } from '../fuzzyFileMatch'; import { filterItemsToScope } from '../scopeFilter'; +import { createLogger } from '../../telemetry/Logger'; + +const log = createLogger('ContextSources'); const OVERVIEW_FILES = [ 'README.md', @@ -71,6 +74,7 @@ export class FtsContextSource implements ContextSource { async retrieve(query: ContextQuery): Promise { const results = this.fts.search(query.text, query.maxItems ?? 10); + log.debug('fts:search', { queryText: query.text, matchCount: results.length }); return filterItemsToScope(results.map((r, i) => ({ id: `fts-${r.relPath}-${i}`, source: this.id, diff --git a/src/core/context/sources/indexedFileSource.ts b/src/core/context/sources/indexedFileSource.ts index 51680144..1b9aacb4 100644 --- a/src/core/context/sources/indexedFileSource.ts +++ b/src/core/context/sources/indexedFileSource.ts @@ -5,6 +5,9 @@ import type { ThunderDb } from '../../indexing/ThunderDb'; import { extractIndexedSearchTerms } from '../fuzzyFileMatch'; import { applyContentTier, getSourceContentTier, loadFileSignatures } from '../contextTier'; import { isPathInScope } from '../scopeFilter'; +import { createLogger } from '../../telemetry/Logger'; + +const log = createLogger('IndexedFileSearchContextSource'); const MAX_FILE_CHARS = 16_000; @@ -18,6 +21,7 @@ export class IndexedFileSearchContextSource implements ContextSource { async retrieve(query: ContextQuery): Promise { const terms = extractIndexedSearchTerms(query.text); + log.debug('terms:extracted', { queryText: query.text, terms }); if (terms.length === 0) return []; const paths = new Set(); @@ -65,6 +69,7 @@ export class IndexedFileSearchContextSource implements ContextSource { } } + log.debug('match:complete', { pathMatchCount: paths.size, itemCount: items.length }); return items; } } diff --git a/src/core/context/sources/mentionedFileSource.ts b/src/core/context/sources/mentionedFileSource.ts index acf93103..2c08f219 100644 --- a/src/core/context/sources/mentionedFileSource.ts +++ b/src/core/context/sources/mentionedFileSource.ts @@ -1,5 +1,5 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'fs'; -import { join, relative, resolve } from 'path'; +import { isAbsolute, join, relative, resolve } from 'path'; import * as vscode from 'vscode'; import type { ContextItem, ContextQuery, ContextSource } from '../types'; import { @@ -7,7 +7,12 @@ import { expandCamelCaseTerms, globPatternsForMention, } from '../fuzzyFileMatch'; -import { createWorkspacePattern, canUseVscodeFindFiles, toWorkspaceRelPath } from '../../util/paths'; +import { + createWorkspacePattern, + canUseVscodeFindFiles, + toWorkspaceRelPath, + isPathInsideWorkspace, +} from '../../util/paths'; import { createLogger } from '../../telemetry/Logger'; const log = createLogger('MentionedFileSource'); @@ -25,8 +30,57 @@ export class MentionedFileContextSource implements ContextSource { const items: ContextItem[] = []; const seen = new Set(); const searchedPatterns: string[] = []; + const fuzzyMentions: string[] = []; + // Exact paths the user gave verbatim are resolved directly off disk first — + // synchronous and immune to the findFiles/glob timeout below — so a literal + // path never gets silently dropped in favor of fuzzy retrieval. for (const mention of mentions.slice(0, 5)) { + if (!mention.includes('/')) { + fuzzyMentions.push(mention); + continue; + } + + const exact = resolveExactMention(this.workspace, mention); + if (!exact) { + fuzzyMentions.push(mention); + continue; + } + + if (exact.outsideWorkspace) { + items.push({ + id: `mention-external-${exact.absPath}`, + source: this.id, + content: + `The user referenced a file outside the ${this.workspace} workspace: ${exact.absPath}. ` + + `Its contents were not loaded automatically. Call read_file with this exact path if you ` + + `need to inspect it — reading external files requires user approval.`, + score: 14, + reason: `External file mentioned (outside workspace): ${exact.absPath}`, + tokenEstimate: 60, + }); + continue; + } + + if (seen.has(exact.relPath)) continue; + seen.add(exact.relPath); + try { + const content = readFileSync(exact.absPath, 'utf-8').slice(0, MAX_FILE_CHARS); + items.push({ + id: `mention-${exact.relPath}`, + source: this.id, + relPath: exact.relPath, + content, + score: 14, + reason: `File mentioned in user message: ${exact.relPath}`, + tokenEstimate: Math.ceil(content.length / 4), + }); + } catch { + // Skip unreadable files. + } + } + + for (const mention of fuzzyMentions) { const relPaths = await findMatchingFiles(this.workspace, mention, searchedPatterns); for (const relPath of relPaths) { if (!relPath || relPath === '.' || seen.has(relPath)) continue; @@ -76,6 +130,36 @@ export class MentionedFileContextSource implements ContextSource { } } +/** Runs findFiles for every candidate pattern concurrently so the total wait is + * bounded by the slowest single pattern, not the sum of all of them — a sequential + * await-per-pattern loop here was blowing past the tier's 800ms budget on repos with + * many candidate patterns per mention. */ +async function findFilesForPatterns( + workspace: string, + patterns: string[], + exclude: string +): Promise<{ uris: vscode.Uri[]; allFailed: boolean }> { + const settled = await Promise.allSettled( + patterns.map((pattern) => { + try { + return vscode.workspace.findFiles(createWorkspacePattern(workspace, pattern), exclude, 5); + } catch (error) { + return Promise.reject(error); + } + }) + ); + + const uris: vscode.Uri[] = []; + let allFailed = settled.length > 0; + for (const result of settled) { + if (result.status === 'fulfilled') { + allFailed = false; + uris.push(...result.value); + } + } + return { uris, allFailed }; +} + async function findMatchingFiles( workspace: string, mention: string, @@ -83,59 +167,43 @@ async function findMatchingFiles( ): Promise { const patterns = globPatternsForMention(mention); const exclude = '**/{node_modules,.git,dist,out,build,.mitii,.thunder}/**'; - const uris: vscode.Uri[] = []; if (!canUseVscodeFindFiles(workspace)) { return walkFindOnDisk(workspace, mention, 5); } - for (const pattern of patterns) { - searchedPatterns.push(pattern); - try { - const found = await vscode.workspace.findFiles( - createWorkspacePattern(workspace, pattern), - exclude, - 5 - ); - uris.push(...found); - } catch (error) { - log.warn('findFiles failed, using disk fallback', { - pattern, - error: error instanceof Error ? error.message : String(error), - }); - return walkFindOnDisk(workspace, mention, 5); - } - if (uris.length >= 5) break; + searchedPatterns.push(...patterns); + const first = await findFilesForPatterns(workspace, patterns, exclude); + if (first.allFailed) { + log.warn('findFiles failed, using disk fallback', { patterns }); + return walkFindOnDisk(workspace, mention, 5); } - if (uris.length > 0) { + if (first.uris.length > 0) { return [...new Set( - uris + first.uris .map((u) => toWorkspaceRelPath(u, workspace)) .filter((p): p is string => Boolean(p)) )]; } - for (const term of expandCamelCaseTerms(mention)) { - if (term.length < 4) continue; - const pattern = `**/*${term}*`; - searchedPatterns.push(pattern); - try { - const found = await vscode.workspace.findFiles( - createWorkspacePattern(workspace, pattern), - exclude, - 5 - ); - uris.push(...found); - } catch { - return walkFindOnDisk(workspace, term, 5); - } - if (uris.length >= 5) break; + const camelPatterns = expandCamelCaseTerms(mention) + .filter((term) => term.length >= 4) + .map((term) => `**/*${term}*`); + + if (camelPatterns.length === 0) { + return walkFindOnDisk(workspace, mention, 5); } - if (uris.length > 0) { + searchedPatterns.push(...camelPatterns); + const second = await findFilesForPatterns(workspace, camelPatterns, exclude); + if (second.allFailed) { + return walkFindOnDisk(workspace, mention, 5); + } + + if (second.uris.length > 0) { return [...new Set( - uris + second.uris .map((u) => toWorkspaceRelPath(u, workspace)) .filter((p): p is string => Boolean(p)) )]; @@ -144,6 +212,32 @@ async function findMatchingFiles( return walkFindOnDisk(workspace, mention, 5); } +/** Resolves a mention that already looks like a path (contains `/`) directly off + * disk — no globbing. Returns null when it's not an exact hit, so callers fall + * back to fuzzy search. */ +function resolveExactMention( + workspace: string, + mention: string +): { absPath: string; relPath: string; outsideWorkspace: false } | { absPath: string; outsideWorkspace: true } | null { + const candidate = isAbsolute(mention) ? mention : join(workspace, mention); + + let isFile: boolean; + try { + isFile = existsSync(candidate) && statSync(candidate).isFile(); + } catch { + isFile = false; + } + if (!isFile) return null; + + const absPath = resolve(candidate); + if (!isPathInsideWorkspace(absPath, workspace)) { + return { absPath, outsideWorkspace: true }; + } + + const relPath = relative(resolve(workspace), absPath).replace(/\\/g, '/'); + return { absPath, relPath, outsideWorkspace: false }; +} + function walkFindOnDisk(workspace: string, needle: string, limit: number): string[] { const root = resolve(workspace); const results: string[] = []; diff --git a/src/core/daemon/DaemonRuntimeAdapter.ts b/src/core/daemon/DaemonRuntimeAdapter.ts new file mode 100644 index 00000000..64984066 --- /dev/null +++ b/src/core/daemon/DaemonRuntimeAdapter.ts @@ -0,0 +1,51 @@ +import { DaemonClient, DaemonSessionClient } from '../../../packages/sdk/src/daemon'; +import type { MitiiApprovalDecision, MitiiEvent, MitiiMode } from '../../../packages/sdk/src/types'; + +export interface DaemonRuntimeAdapterOptions { + cwd: string; + daemonUrl?: string; + daemonToken?: string; + mode?: MitiiMode; +} + +export class DaemonRuntimeAdapter { + private readonly client: DaemonClient; + private session?: DaemonSessionClient; + + constructor(private readonly options: DaemonRuntimeAdapterOptions) { + this.client = new DaemonClient({ + baseUrl: options.daemonUrl ?? 'http://127.0.0.1:4310', + token: options.daemonToken, + }); + } + + async connect(): Promise { + this.session = await DaemonSessionClient.createOrAttach(this.client, { + cwd: this.options.cwd, + mode: this.options.mode ?? 'agent', + approval: 'manual', + }); + } + + async sendMessage(message: string, mode: MitiiMode = this.options.mode ?? 'agent'): Promise> { + if (!this.session) await this.connect(); + const session = this.session!; + const events = session.events(); + await session.prompt({ mode, message }); + return events; + } + + approve(id: string, decision: MitiiApprovalDecision): Promise> { + if (!this.session) throw new Error('Daemon session not connected'); + return this.session.respondToPermission(id, decision); + } + + async getSubagents(): Promise { + return []; + } + + cancel(): Promise> { + if (!this.session) throw new Error('Daemon session not connected'); + return this.session.cancel(); + } +} diff --git a/src/core/git/WorktreeService.ts b/src/core/git/WorktreeService.ts new file mode 100644 index 00000000..a6e75af9 --- /dev/null +++ b/src/core/git/WorktreeService.ts @@ -0,0 +1,104 @@ +import { execFile } from 'child_process'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { dirname, join, resolve } from 'path'; +import { promisify } from 'util'; +import { scaffoldMitiiWorkspace } from '../mcp/scaffoldMitiiWorkspace'; +import { branchForTask, defaultWorktreePath } from './worktreePaths'; +import type { WorktreeCreateOptions, WorktreeInfo } from './worktreeTypes'; + +const execFileAsync = promisify(execFile); + +export class WorktreeService { + private readonly registryPath: string; + + constructor(private readonly repoRoot: string, private readonly extensionRoot = repoRoot) { + this.repoRoot = resolve(repoRoot); + this.registryPath = join(this.repoRoot, '.mitii', 'worktrees.json'); + } + + list(): WorktreeInfo[] { + const entries = this.readRegistry(); + return entries.map((entry) => ({ + ...entry, + status: existsSync(entry.path) && entry.status !== 'removed' ? 'active' : entry.status === 'removed' ? 'removed' : 'orphaned', + })); + } + + getPath(taskId: string): string | undefined { + return this.list().find((entry) => entry.taskId === taskId && entry.status === 'active')?.path; + } + + async create(options: WorktreeCreateOptions): Promise { + await this.ensureGitRepo(); + const existing = this.list().find((entry) => entry.taskId === options.taskId && entry.status === 'active'); + if (existing) return existing; + + const path = defaultWorktreePath(this.repoRoot, options.taskId); + const branch = options.branch ?? branchForTask(options.taskId); + const args = ['worktree', 'add', '-b', branch, path]; + if (options.baseRef) args.push(options.baseRef); + await execFileAsync('git', args, { cwd: this.repoRoot }); + scaffoldMitiiWorkspace(path, { extensionRoot: this.extensionRoot, forceBundledSkills: false }); + + const entry: WorktreeInfo = { + taskId: options.taskId, + path, + branch, + status: 'active', + createdAt: Date.now(), + updatedAt: Date.now(), + }; + this.writeRegistry([...this.readRegistry().filter((item) => item.taskId !== options.taskId), entry]); + return entry; + } + + async remove(taskId: string, options: { force?: boolean; deleteBranch?: boolean } = {}): Promise { + const entry = this.list().find((item) => item.taskId === taskId); + if (!entry) return false; + if (entry.status === 'active') { + const dirty = await this.isDirty(entry.path); + if (dirty && !options.force) { + throw new Error(`Worktree ${taskId} has uncommitted changes. Re-run with --force to remove.`); + } + await execFileAsync('git', ['worktree', 'remove', ...(options.force ? ['--force'] : []), entry.path], { cwd: this.repoRoot }); + } else if (existsSync(entry.path) && options.force) { + rmSync(entry.path, { recursive: true, force: true }); + } + if (options.deleteBranch) { + await execFileAsync('git', ['branch', '-D', entry.branch], { cwd: this.repoRoot }).catch(() => undefined); + } + this.writeRegistry(this.readRegistry().map((item) => + item.taskId === taskId ? { ...item, status: 'removed', updatedAt: Date.now() } : item + )); + return true; + } + + prune(): WorktreeInfo[] { + const kept = this.readRegistry().filter((entry) => entry.status === 'active' && existsSync(entry.path)); + this.writeRegistry(kept); + return kept; + } + + private async ensureGitRepo(): Promise { + await execFileAsync('git', ['rev-parse', '--show-toplevel'], { cwd: this.repoRoot }); + } + + private async isDirty(path: string): Promise { + const { stdout } = await execFileAsync('git', ['status', '--porcelain'], { cwd: path }); + return stdout.trim().length > 0; + } + + private readRegistry(): WorktreeInfo[] { + try { + const parsed = JSON.parse(readFileSync(this.registryPath, 'utf-8')) as WorktreeInfo[]; + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + } + + private writeRegistry(entries: WorktreeInfo[]): void { + mkdirSync(dirname(this.registryPath), { recursive: true }); + writeFileSync(this.registryPath, `${JSON.stringify(entries, null, 2)}\n`, 'utf-8'); + } +} diff --git a/src/core/git/index.ts b/src/core/git/index.ts new file mode 100644 index 00000000..dfbd31bc --- /dev/null +++ b/src/core/git/index.ts @@ -0,0 +1,3 @@ +export { WorktreeService } from './WorktreeService'; +export { branchForTask, defaultWorktreePath, safeTaskId } from './worktreePaths'; +export type { WorktreeCreateOptions, WorktreeInfo } from './worktreeTypes'; diff --git a/src/core/git/worktreePaths.ts b/src/core/git/worktreePaths.ts new file mode 100644 index 00000000..2e9030bc --- /dev/null +++ b/src/core/git/worktreePaths.ts @@ -0,0 +1,17 @@ +import { basename, dirname, join, resolve } from 'path'; + +export function defaultWorktreePath(repoRoot: string, taskId: string): string { + const root = resolve(repoRoot); + const repo = basename(root).replace(/[^\w.-]+/g, '-'); + return join(dirname(root), `${repo}-mitii-${safeTaskId(taskId)}`); +} + +export function branchForTask(taskId: string, title?: string): string { + const slug = safeTaskId(title || taskId).slice(0, 48); + const shortId = safeTaskId(taskId).slice(0, 8); + return `mitii/task/${slug}-${shortId}`; +} + +export function safeTaskId(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'task'; +} diff --git a/src/core/git/worktreeTypes.ts b/src/core/git/worktreeTypes.ts new file mode 100644 index 00000000..af572e89 --- /dev/null +++ b/src/core/git/worktreeTypes.ts @@ -0,0 +1,14 @@ +export interface WorktreeInfo { + taskId: string; + path: string; + branch: string; + status: 'active' | 'removed' | 'orphaned'; + createdAt: number; + updatedAt: number; +} + +export interface WorktreeCreateOptions { + branch?: string; + taskId: string; + baseRef?: string; +} diff --git a/src/core/headless/AgentRunner.ts b/src/core/headless/AgentRunner.ts index 55b49ede..4e4e9139 100644 --- a/src/core/headless/AgentRunner.ts +++ b/src/core/headless/AgentRunner.ts @@ -75,7 +75,6 @@ export class HeadlessAgentRunner { } 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([ @@ -84,7 +83,7 @@ export class HeadlessAgentRunner { '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' }; + yield { type: 'done', content }; } private async complete(messages: Array<{ role: 'system' | 'user'; content: string }>): Promise { diff --git a/src/core/headless/HeadlessAgentHost.ts b/src/core/headless/HeadlessAgentHost.ts index 7c83845b..f4c0fb4d 100644 --- a/src/core/headless/HeadlessAgentHost.ts +++ b/src/core/headless/HeadlessAgentHost.ts @@ -1,4 +1,5 @@ import { join } from 'path'; +import { resolveWorkspaceRelPath } from '../util/paths'; import { ThunderSession, type ThunderMode } from '../session/ThunderSession'; import { IndexService } from '../indexing/IndexService'; import { IgnoreService } from '../indexing/IgnoreService'; @@ -6,6 +7,7 @@ import { WorkspaceScanner } from '../indexing/WorkspaceScanner'; import { IndexQueue } from '../indexing/IndexQueue'; import { FtsIndex } from '../indexing/FtsIndex'; import { HybridRetriever } from '../context/HybridRetriever'; +import type { ContextItem, ContextQuery } from '../context/types'; import { createContextReranker } from '../context/ContextReranker'; import { ContextBudgeter } from '../context/ContextBudgeter'; import { CurrentEditorContextSource, OpenFilesContextSource } from '../context/sources/editorSources'; @@ -15,12 +17,20 @@ import { MentionedFileContextSource } from '../context/sources/mentionedFileSour import { GitService } from '../context/GitService'; import { GitDiffContextSource } from '../context/DiagnosticsService'; import { RepoMapService } from '../context/RepoMapService'; +import { VectorContextSource } from '../context/sources/VectorContextSource'; +import { CallGraphContextSource } from '../context/sources/callGraphSource'; +import { VectorIndexService } from '../indexing/VectorIndex'; +import { createVectorIndex } from '../indexing/vectorIndexFactory'; +import { createEmbeddingProvider } from '../indexing/embeddingFactory'; +import { getOrCreateLanguageService, disposeLanguageService } from '../indexing/languageServiceFactory'; +import type { WorkspaceLanguageService } from '../indexing/WorkspaceLanguageService'; +import type { EmbeddingProvider } from '../indexing/EmbeddingProvider'; import { setVerifyCommandPatterns } from '../plans/PlanActEngine'; import { ChatOrchestrator } from '../orchestration/ChatOrchestrator'; import { ToolRuntime } from '../tools/ToolRuntime'; import { - createReadFileTool, createReadFilesTool, createListFilesTool, createSearchTool, - createSearchBatchTool, createSearchScriptCatalogTool, createSpawnResearchAgentTool, + createReadFileTool, createReadFilesTool, createListFilesTool, createResolvePathTool, createSearchTool, + createSearchBatchTool, createSearchScriptCatalogTool, createSpawnResearchAgentTool, createSpawnSubagentTool, createExecuteWorkspaceScriptTool, createUseSkillTool, createRepoMapTool, createRetrieveContextTool, createGitDiffTool, createDiagnosticsTool, createWriteFileTool, createApplyPatchTool, createRunCommandTool, @@ -50,6 +60,7 @@ import { MemoryExtractor } from '../runtime/MemoryExtractor'; import { SubagentTracker } from '../runtime/SubagentTracker'; import { PassiveMemoryInjector } from '../memory/PassiveMemoryInjector'; import { MemoryHookService } from '../memory/MemoryHookService'; +import { AutoMemoryContextSource, AutoMemoryFileWriter } from '../memory/AutoMemoryFileWriter'; import { PostEditValidator } from '../apply/PostEditValidator'; import { McpManager } from '../mcp/McpManager'; import { ProjectRulesContextSource, ProjectRulesService } from '../rules/ProjectRulesService'; @@ -69,6 +80,8 @@ import { headlessDiscoverFiles } from './headlessDiscoverFiles'; import { defaultMcpToggles } from '../mcp/mcpToggles'; import type { ThunderConfig } from '../config/schema'; import { chunkContent } from '../llm/streamChunks'; +import { chunkReasoning } from '../llm/streamChunks'; +import { eventFromSessionLog, type MitiiApprovalDecision, type MitiiEvent } from './events'; const log = createLogger('HeadlessAgentHost'); @@ -93,6 +106,7 @@ export class HeadlessAgentHost { private indexService?: IndexService; private ignoreService = new IgnoreService(); + private languageService?: WorkspaceLanguageService; private scanner?: WorkspaceScanner; private indexQueue?: IndexQueue; private gitService?: GitService; @@ -107,7 +121,11 @@ export class HeadlessAgentHost { private toolRuntime = new ToolRuntime(); private toolExecutor?: ToolExecutor; private chatOrchestrator?: ChatOrchestrator; + private retriever?: HybridRetriever; + private embeddingProvider?: EmbeddingProvider; + private vectorIndexService?: VectorIndexService; private memoryExtractor?: MemoryExtractor; + private autoMemoryWriter?: AutoMemoryFileWriter; private mcpManager = new McpManager(); private sessionLog = new SessionLogService(); private subagentTracker = new SubagentTracker(); @@ -127,6 +145,11 @@ export class HeadlessAgentHost { apiKey: options.apiKey ?? resolveApiKey(options.providerType ?? this.config.provider.type), approval: options.approval ?? 'manual', }); + if (options.onEvent) { + this.sessionLog.onEvent((event) => { + options.onEvent?.(eventFromSessionLog(event, this.options.cwd, this.session?.mode)); + }); + } } get isRealRuntime(): boolean { @@ -168,12 +191,20 @@ export class HeadlessAgentHost { respectGitignore: this.config.indexing.respectGitignore, respectThunderignore: this.config.indexing.respectThunderignore, }); + this.languageService = getOrCreateLanguageService(workspace, this.ignoreService, this.config.indexing); this.scanner = new WorkspaceScanner(db, workspace); + this.embeddingProvider = createEmbeddingProvider(this.config.indexing); + this.vectorIndexService = new VectorIndexService( + createVectorIndex(db, workspace, this.config.indexing), + this.embeddingProvider + ); this.indexQueue = new IndexQueue(db, { maxConcurrency: this.config.indexing.maxConcurrency, maxFileSizeBytes: this.config.indexing.maxFileSizeBytes, + deferVectorWrites: true, }); + this.indexQueue.setVectorService(workspace, this.vectorIndexService); this.gitService = new GitService(workspace); await this.gitService.initialize(); @@ -188,6 +219,10 @@ export class HeadlessAgentHost { maxItems: this.config.memory.maxItems, hybridSearchEnabled: this.config.memory.hybridSearchEnabled, }); + this.autoMemoryWriter = new AutoMemoryFileWriter(workspace, { + enabled: this.config.memory.autoMemoryEnabled, + scope: this.config.memory.autoMemoryScope, + }); this.sessionService = new SessionService(db); this.planPersistence = new PlanPersistence(db); @@ -199,7 +234,8 @@ export class HeadlessAgentHost { this.policyEngine = new ToolPolicyEngine( effectiveSafety, (path) => this.ignoreService.isIgnored(path), - () => true + () => true, + (path) => resolveWorkspaceRelPath(workspace, path) ); this.toolRuntime.setSessionLog(this.sessionLog); @@ -218,23 +254,26 @@ export class HeadlessAgentHost { ); const retriever = this.buildRetriever(db, workspace); + this.retriever = retriever; 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); + this.registerTools(workspace, db, repoMap, fts, retriever, budgeter); const mcpToggles = { ...defaultMcpToggles(), puppeteer: this.config.mcp.builtinServers.puppeteer ?? false, + agentmemory: this.config.mcp.builtinServers.agentmemory ?? false, }; await this.mcpManager.reload(this.config.mcp, workspace, this.toolRuntime, mcpToggles); this.memoryExtractor = new MemoryExtractor( this.memoryService, - this.config.memory.summarizeAfterTask + this.config.memory.summarizeAfterTask, + this.autoMemoryWriter ); if (this.config.indexing.autoIndexOnOpen) { @@ -251,6 +290,13 @@ export class HeadlessAgentHost { return this.runMode('ask', prompt); } + /** Raw retrieval results for a query, bypassing the LLM entirely. Used by retrieval evals/diagnostics. */ + async retrieveContext(query: ContextQuery): Promise { + await this.initialize(); + if (!this.retriever) throw new Error('Retriever unavailable (stub runtime)'); + return this.retriever.retrieve(query); + } + async plan(prompt: string): Promise> { await this.initialize(); if (!this.isRealRuntime) return this.stubRunner.plan(prompt); @@ -262,23 +308,58 @@ export class HeadlessAgentHost { } } - async *agent(prompt: string): AsyncIterable<{ type: string; message?: string; plan?: HeadlessPlan; content?: string }> { + async *agent(prompt: string, signal?: AbortSignal): AsyncIterable { await this.initialize(); if (!this.isRealRuntime) { - yield* this.stubRunner.agent(prompt); + for await (const event of this.stubRunner.agent(prompt)) { + if (signal?.aborted) break; + yield event as MitiiEvent; + } return; } - yield { type: 'start', message: 'headless agent started' }; + const started = Date.now(); 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 }; + const pendingLogEvents: MitiiEvent[] = []; + const unsubscribe = this.sessionLog.onEvent((event) => { + pendingLogEvents.push(eventFromSessionLog(event, this.options.cwd, this.session?.mode)); + }); + const drain = function* (): Iterable { + while (pendingLogEvents.length > 0) { + const event = pendingLogEvents.shift(); + if (event) yield event; + } + }; + + try { + if (signal) { + signal.addEventListener('abort', () => this.cancel(), { once: true }); + } + for await (const chunk of this.streamMode('agent', prompt)) { + if (signal?.aborted) break; + yield* drain(); + const text = chunkContent(chunk); + const reasoning = chunkReasoning(chunk); + if (reasoning) { + const event: MitiiEvent = { type: 'reasoning_delta', content: reasoning }; + this.options.onEvent?.(event); + yield event; + } + if (text) { + content += text; + const event: MitiiEvent = { type: 'assistant_delta', content: text }; + this.options.onEvent?.(event); + yield event; + } } + yield* drain(); + } finally { + unsubscribe(); } - yield { type: 'end', message: 'headless agent completed', content }; + const metrics = this.buildMetrics(started, []); + const done: MitiiEvent = { type: 'done', content, metrics }; + this.options.onEvent?.(done); + yield done; } async runWithMetrics(mode: ThunderMode, prompt: string): Promise<{ output: string; metrics: HeadlessRunMetrics }> { @@ -294,7 +375,7 @@ export class HeadlessAgentHost { } else { const parts: string[] = []; for await (const event of this.agent(prompt)) { - if (event.content) parts.push(event.content); + if (event.type === 'assistant_delta') parts.push(event.content); } output = parts.join(''); } @@ -315,9 +396,25 @@ export class HeadlessAgentHost { }; } - dispose(): void { + async dispose(): Promise { + this.cancel(); + await this.indexQueue?.waitForVectorIndexing(); this.indexService?.dispose(); + disposeLanguageService(this.options.cwd); + this.languageService = undefined; this.initialized = false; + await this.mcpManager.closeAll(); + } + + cancel(): void { + this.chatOrchestrator?.stop(); + } + + resolveApproval(id: string, decision: MitiiApprovalDecision): boolean { + if (!this.approvalQueue) return false; + this.approvalQueue.resolve(id, decision); + this.sessionLog.append('approval_decision', `${decision}: ${id}`, { id, decision }); + return true; } private configureOrchestrator(workspace: string): void { @@ -359,19 +456,22 @@ export class HeadlessAgentHost { private registerTools( workspace: string, + db: import('../indexing/ThunderDb').ThunderDb, 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(createReadFileTool(workspace, this.ignoreService, db)); + this.toolRuntime.register(createReadFilesTool(workspace, this.ignoreService, db)); this.toolRuntime.register(createListFilesTool(workspace, this.ignoreService)); + this.toolRuntime.register(createResolvePathTool(workspace, this.ignoreService, db)); 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(createSpawnSubagentTool()); this.toolRuntime.register(createSpawnResearchAgentTool()); this.toolRuntime.register(createRepoMapTool(repoMap)); this.toolRuntime.register(createRetrieveContextTool(retriever, budgeter)); @@ -429,8 +529,18 @@ export class HeadlessAgentHost { if (this.gitService) sources.push(new GitDiffContextSource(this.gitService)); sources.push(new HeadlessDiagnosticsContextSource(this.diagnosticsService)); if (this.memoryService) sources.push(new MemoryContextSource(this.memoryService)); + if (this.autoMemoryWriter) sources.push(new AutoMemoryContextSource(this.autoMemoryWriter)); + if (this.config.indexing.vectorsEnabled && this.vectorIndexService) { + sources.push(new VectorContextSource(this.vectorIndexService, workspace)); + } + if (this.languageService) { + sources.push(new CallGraphContextSource(db, workspace, this.languageService)); + } - const reranker = createContextReranker(undefined, false); + const reranker = createContextReranker( + this.embeddingProvider, + this.config.indexing.vectorsEnabled && this.config.indexing.embeddingProvider === 'minilm' + ); return new HybridRetriever(sources, reranker, { enabled: this.config.context.rerankerEnabled, candidatePool: this.config.context.rerankerCandidatePool, @@ -458,6 +568,7 @@ export class HeadlessAgentHost { if (Date.now() > deadline) break; await sleep(250); } + await this.indexQueue.waitForVectorIndexing(); this.sessionLog.append('index_complete', 'Headless indexing finished', { workspace, jobCount: jobs.length, @@ -479,7 +590,17 @@ export class HeadlessAgentHost { } this.session = new ThunderSession(this.options.cwd, mode); + if (this.options.sessionId) { + this.session = new ThunderSession(this.options.cwd, mode, { id: this.options.sessionId }); + } this.sessionLog.configure(this.options.cwd, this.session.id, true, this.config.telemetry.debugMetrics); + this.sessionLog.writeSessionHeader({ + mode, + workspace: this.options.cwd, + provider: this.config.provider.type, + model: this.config.provider.model, + runtime: this.options.runtime ?? 'real', + }); this.toolRuntime.clearAuditLog(); this.agentTaskState.reset(); this.agentTaskState.setLimits({ @@ -496,6 +617,17 @@ export class HeadlessAgentHost { yield* this.chatOrchestrator.send(this.session, this.provider, prompt, []); } + private buildMetrics(started: number, errors: string[]): HeadlessRunMetrics { + const audit = this.getToolAudit(); + return { + durationMs: Date.now() - started, + toolCalls: audit.length, + errors, + sessionLogPath: this.sessionLog.getLogPath() || undefined, + auditTools: audit.map((entry) => entry.toolName), + }; + } + private autoResolvePendingApprovals(): void { if (this.options.approval !== 'auto' || !this.approvalQueue || !this.session) return; for (const request of this.approvalQueue.getPending()) { diff --git a/src/core/headless/HeadlessConfig.ts b/src/core/headless/HeadlessConfig.ts index 8e96e2a2..98da930c 100644 --- a/src/core/headless/HeadlessConfig.ts +++ b/src/core/headless/HeadlessConfig.ts @@ -3,6 +3,7 @@ import { join } from 'path'; import type { ProviderType, ThunderConfig } from '../config/schema'; import { defaultThunderConfig } from '../config/defaults'; import { resolveEffectiveSafety } from '../safety/autonomyPresets'; +import type { MitiiEvent } from './events'; export type HeadlessRuntime = 'real' | 'stub'; @@ -18,6 +19,8 @@ export interface HeadlessAgentOptions { allowNetwork?: boolean; enablePuppeteer?: boolean; indexWorkspace?: boolean; + sessionId?: string; + onEvent?: (event: MitiiEvent) => void; configOverrides?: Partial; } diff --git a/src/core/headless/events.ts b/src/core/headless/events.ts new file mode 100644 index 00000000..a42d467f --- /dev/null +++ b/src/core/headless/events.ts @@ -0,0 +1,104 @@ +import type { HeadlessPlan } from './AgentRunner'; +import type { SessionLogEvent } from '../telemetry/SessionLogService'; + +export type MitiiMode = 'ask' | 'plan' | 'agent' | 'review'; + +export type MitiiApprovalDecision = 'approved' | 'denied'; + +export interface MitiiMetrics { + durationMs: number; + toolCalls: number; + errors?: string[]; + sessionLogPath?: string; + auditTools?: string[]; +} + +export type MitiiEvent = + | { type: 'session_start'; sessionId: string; mode: string; cwd: string; data?: Record } + | { type: 'assistant_delta'; content: string } + | { type: 'reasoning_delta'; content: string } + | { type: 'tool_start'; tool: string; input?: Record; id?: string; message?: string } + | { type: 'tool_end'; tool: string; success: boolean; output?: string; error?: string; durationMs?: number; id?: string } + | { type: 'approval_required'; id: string; tool: string; input?: Record; message?: string } + | { type: 'approval_resolved'; id?: string; tool?: string; decision?: MitiiApprovalDecision | string } + | { type: 'plan'; plan: HeadlessPlan | Record } + | { type: 'metrics'; durationMs: number; toolCalls: number; sessionLogPath?: string; auditTools?: string[] } + | { type: 'error'; message: string; data?: Record } + | { type: 'done'; content: string; metrics?: MitiiMetrics } + | { type: 'log'; event: SessionLogEvent }; + +export function eventFromSessionLog(event: SessionLogEvent, cwd = '', mode = ''): MitiiEvent { + const data = event.data ?? {}; + if (event.type === 'session_start') { + return { + type: 'session_start', + sessionId: event.sessionId, + mode: String(data.mode ?? mode), + cwd: String(data.workspace ?? cwd), + data, + }; + } + if (event.type === 'tool_start') { + return { + type: 'tool_start', + tool: String(data.toolName ?? data.tool ?? event.message), + id: stringValue(data.toolCallId), + input: compactInput(data), + message: event.message, + }; + } + if (event.type === 'tool_end') { + return { + type: 'tool_end', + tool: String(data.toolName ?? data.tool ?? event.message), + id: stringValue(data.toolCallId), + success: data.success !== false, + output: stringValue(data.outputPreview), + error: stringValue(data.error), + durationMs: numberValue(data.durationMs), + }; + } + if (event.type === 'approval_request') { + return { + type: 'approval_required', + id: String(data.id ?? ''), + tool: String(data.toolName ?? data.tool ?? event.message), + input: compactInput(data), + message: event.message, + }; + } + if (event.type === 'approval_decision') { + return { + type: 'approval_resolved', + id: stringValue(data.id), + tool: stringValue(data.toolName ?? data.tool), + decision: stringValue(data.decision ?? event.message.split(':')[0]), + }; + } + if (event.type === 'plan_created') { + return { type: 'plan', plan: data }; + } + if (event.type === 'turn_complete' || event.type === 'token_usage' || event.type === 'timing') { + return { type: 'log', event }; + } + if (event.type === 'error') { + return { type: 'error', message: event.message, data }; + } + return { type: 'log', event }; +} + +function compactInput(data: Record): Record | undefined { + const input: Record = {}; + for (const key of ['path', 'command', 'inputPreview', 'risk', 'reason', 'files', 'question', 'optionCount']) { + if (data[key] !== undefined) input[key] = data[key]; + } + return Object.keys(input).length > 0 ? input : undefined; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +function numberValue(value: unknown): number | undefined { + return typeof value === 'number' ? value : undefined; +} diff --git a/src/core/headless/index.ts b/src/core/headless/index.ts index d28dc0ef..35ffd219 100644 --- a/src/core/headless/index.ts +++ b/src/core/headless/index.ts @@ -2,3 +2,4 @@ export * from './release'; export * from './AgentRunner'; export * from './HeadlessAgentHost'; export * from './HeadlessConfig'; +export * from './events'; diff --git a/src/core/indexing/ComponentHealth.ts b/src/core/indexing/ComponentHealth.ts new file mode 100644 index 00000000..b19c8dcf --- /dev/null +++ b/src/core/indexing/ComponentHealth.ts @@ -0,0 +1,17 @@ +/** Runtime health of an optional/degradable subsystem (embeddings, vector backend, …). + * Distinct from static config description: this reflects what actually happened at runtime, + * not just what was requested/available at startup. */ +export interface ComponentHealth { + status: 'unknown' | 'ready' | 'degraded'; + detail?: string; +} + +export const UNKNOWN_HEALTH: ComponentHealth = { status: 'unknown' }; + +/** Native-loader errors (dlopen, etc.) dump multi-clause traces with local filesystem paths — + * fine for logs, not for a settings UI. Keep only the leading reason, capped in length. */ +export function summarizeHealthDetail(error: unknown, maxLen = 160): string { + const message = error instanceof Error ? error.message : String(error); + const firstLine = message.split('\n')[0].trim(); + return firstLine.length > maxLen ? `${firstLine.slice(0, maxLen - 1)}…` : firstLine; +} diff --git a/src/core/indexing/EmbeddingProvider.ts b/src/core/indexing/EmbeddingProvider.ts index 48afaed8..06efe165 100644 --- a/src/core/indexing/EmbeddingProvider.ts +++ b/src/core/indexing/EmbeddingProvider.ts @@ -1,6 +1,10 @@ +import type { ComponentHealth } from './ComponentHealth'; + export interface EmbeddingProvider { readonly id: string; embed(texts: string[]): Promise; + /** Runtime health (has the model actually loaded?). Providers that can't degrade may omit this. */ + getHealth?(): ComponentHealth; } export class NoOpEmbeddingProvider implements EmbeddingProvider { diff --git a/src/core/indexing/FileDiscoveryService.ts b/src/core/indexing/FileDiscoveryService.ts index aa6295ef..7c2122aa 100644 --- a/src/core/indexing/FileDiscoveryService.ts +++ b/src/core/indexing/FileDiscoveryService.ts @@ -1,6 +1,6 @@ import * as vscode from 'vscode'; -import { readdirSync, statSync } from 'fs'; -import { join, relative } from 'path'; +import { existsSync, promises as fs, readdirSync, statSync } from 'fs'; +import { join, relative, resolve } from 'path'; import { IgnoreService } from './IgnoreService'; import { isBinaryByExtension, detectLanguage } from './fileUtils'; import type { IndexingConfig } from '../config/schema'; @@ -13,6 +13,12 @@ export interface DiscoveredFile { language: string | null; } +export interface DiscoveryOptions { + roots?: string[]; + limit?: number; + yieldEvery?: number; +} + export class FileDiscoveryService { constructor( private readonly workspacePath: string, @@ -82,6 +88,70 @@ export class FileDiscoveryService { return results; } + async discoverAsync(options: DiscoveryOptions = {}): Promise { + const results: DiscoveredFile[] = []; + const exclude = this.getVsCodeExcludes(); + const roots = options.roots?.length ? options.roots : ['.']; + const limit = options.limit && options.limit > 0 ? options.limit : Number.POSITIVE_INFINITY; + const yieldEvery = options.yieldEvery && options.yieldEvery > 0 ? options.yieldEvery : 100; + let visited = 0; + + const walk = async (inputPath: string): Promise => { + if (results.length >= limit) return; + + const absInput = resolve(this.workspacePath, inputPath); + if (!isInsideWorkspace(absInput, this.workspacePath) || !existsSync(absInput)) return; + + const relInput = relative(this.workspacePath, absInput).replace(/\\/g, '/') || '.'; + if (this.ignoreService.isIgnored(relInput) || this.isVsCodeExcluded(relInput, exclude)) return; + + let stat; + try { + stat = await fs.stat(absInput); + } catch { + return; + } + + visited += 1; + if (visited % yieldEvery === 0) { + await new Promise((resolveYield) => setTimeout(resolveYield, 0)); + } + + if (stat.isDirectory()) { + let entries: string[]; + try { + entries = await fs.readdir(absInput); + } catch { + return; + } + for (const entry of entries) { + await walk(join(inputPath, entry)); + if (results.length >= limit) return; + } + return; + } + + if (!stat.isFile()) return; + if (stat.size > this.config.hardSkipSizeBytes) return; + if (isBinaryByExtension(relInput)) return; + + results.push({ + absPath: absInput, + relPath: relInput, + size: stat.size, + mtime: stat.mtimeMs, + language: detectLanguage(relInput), + }); + }; + + for (const root of roots) { + await walk(root); + if (results.length >= limit) break; + } + + return results; + } + private getVsCodeExcludes(): Record { const filesExclude = vscode.workspace.getConfiguration('files').get>('exclude', {}); const searchExclude = vscode.workspace.getConfiguration('search').get>('exclude', {}); @@ -111,3 +181,8 @@ function globToRegex(glob: string): RegExp { .replace(/\?/g, '.'); return new RegExp(`^${escaped}$`); } + +function isInsideWorkspace(absPath: string, workspacePath: string): boolean { + const rel = relative(workspacePath, absPath); + return rel === '' || (!rel.startsWith('..') && !rel.includes('..\\')); +} diff --git a/src/core/indexing/IgnoreService.ts b/src/core/indexing/IgnoreService.ts index e48cafe0..55d5df7e 100644 --- a/src/core/indexing/IgnoreService.ts +++ b/src/core/indexing/IgnoreService.ts @@ -9,12 +9,16 @@ const DEFAULT_IGNORES = [ 'node_modules/', '.git/', '.mitii/', + '.mitti/', '.thunder/', 'dist/', 'build/', 'out/', '.next/', 'coverage/', + 'vendor/', + 'tmp/', + 'logs/', '*.min.js', '*.min.css', '*.map', @@ -64,6 +68,12 @@ export class IgnoreService { if (options?.forRead && /^packages\/[^/]+\/dist\//.test(normalized)) { return false; } + // Session logs are written for the agent's own debugging/post-hoc analysis (see + // SessionLogService) — reading them back must not be blocked by the blanket .mitii/ + // and logs/ ignores below, which exist to keep the indexer/search out of internal state. + if (options?.forRead && /^\.mitii\/logs(\/[^/]+\.jsonl)?$/.test(normalized)) { + return false; + } return this.ig.ignores(normalized); } diff --git a/src/core/indexing/IndexMaintenanceService.ts b/src/core/indexing/IndexMaintenanceService.ts new file mode 100644 index 00000000..f612d713 --- /dev/null +++ b/src/core/indexing/IndexMaintenanceService.ts @@ -0,0 +1,122 @@ +import { existsSync, statSync } from 'fs'; +import type { ThunderDb } from './ThunderDb'; +import { checkDbHealth, type DbHealthReport } from './health'; +import { FtsIndex } from './FtsIndex'; + +export interface IndexStatusReport { + workspace: string; + filesIndexed: number; + filesTotal: number; + chunks: number; + symbols: number; + queued: number; + failed: number; + running: boolean; + dbPath?: string; + dbSizeBytes?: number; + lastIndexedAt?: number; + health: DbHealthReport; +} + +export interface IndexRepairReport { + removedFiles: number; + rebuiltFtsChunks: number; + vacuumed: boolean; + health: DbHealthReport; +} + +export class IndexMaintenanceService { + constructor( + private readonly db: ThunderDb, + private readonly workspace: string, + private readonly dbPath?: string + ) {} + + status(queue?: { queued?: number; failed?: number; running?: boolean }): IndexStatusReport { + const files = this.db.raw.prepare(` + SELECT + COUNT(*) as total, + SUM(CASE WHEN indexed_at IS NOT NULL THEN 1 ELSE 0 END) as indexed, + MAX(indexed_at) as lastIndexedAt + FROM files + WHERE workspace = ? + `).get(this.workspace) as { total: number; indexed: number | null; lastIndexedAt: number | null }; + const chunks = this.count('chunks'); + const symbols = this.count('symbols'); + const dbSizeBytes = this.dbPath && existsSync(this.dbPath) ? statSync(this.dbPath).size : undefined; + return { + workspace: this.workspace, + filesIndexed: files.indexed ?? 0, + filesTotal: files.total, + chunks, + symbols, + queued: queue?.queued ?? 0, + failed: queue?.failed ?? 0, + running: queue?.running ?? false, + dbPath: this.dbPath, + dbSizeBytes, + lastIndexedAt: files.lastIndexedAt ?? undefined, + health: checkDbHealth(this.db), + }; + } + + removeFile(relPath: string): boolean { + const row = this.db.raw + .prepare('SELECT id FROM files WHERE workspace = ? AND rel_path = ?') + .get(this.workspace, relPath) as { id: number } | undefined; + new FtsIndex(this.db).deleteByFile(relPath); + if (!row) return false; + this.db.raw.prepare('DELETE FROM files WHERE id = ?').run(row.id); + return true; + } + + repair(): IndexRepairReport { + let removedFiles = 0; + let rebuiltFtsChunks = 0; + this.db.transaction(() => { + const rows = this.db.raw + .prepare('SELECT id, path, rel_path FROM files WHERE workspace = ?') + .all(this.workspace) as Array<{ id: number; path: string; rel_path: string }>; + for (const row of rows) { + if (!existsSync(row.path)) { + new FtsIndex(this.db).deleteByFile(row.rel_path); + this.db.raw.prepare('DELETE FROM files WHERE id = ?').run(row.id); + removedFiles += 1; + } + } + + this.db.raw.prepare('DELETE FROM fts_chunks').run(); + const chunks = this.db.raw.prepare(` + SELECT files.rel_path as relPath, chunks.content as content + FROM chunks + JOIN files ON files.id = chunks.file_id + WHERE files.workspace = ? + `).all(this.workspace) as Array<{ relPath: string; content: string }>; + const insert = this.db.raw.prepare('INSERT INTO fts_chunks (rel_path, content) VALUES (?, ?)'); + for (const chunk of chunks) { + insert.run(chunk.relPath, chunk.content); + rebuiltFtsChunks += 1; + } + }); + + let vacuumed = false; + try { + this.db.raw.pragma('wal_checkpoint(TRUNCATE)'); + this.db.raw.exec('VACUUM'); + vacuumed = true; + } catch { + vacuumed = false; + } + + return { + removedFiles, + rebuiltFtsChunks, + vacuumed, + health: checkDbHealth(this.db), + }; + } + + private count(table: string): number { + return (this.db.raw.prepare(`SELECT COUNT(*) as c FROM ${table}`).get() as { c: number }).c; + } +} diff --git a/src/core/indexing/IndexQueue.ts b/src/core/indexing/IndexQueue.ts index 2489f21b..4d159e1f 100644 --- a/src/core/indexing/IndexQueue.ts +++ b/src/core/indexing/IndexQueue.ts @@ -33,6 +33,8 @@ export interface IndexingStatus { export interface IndexQueueOptions { maxConcurrency?: number; maxFileSizeBytes?: number; + deferVectorWrites?: boolean; + vectorBackfillBatchSize?: number; } type ProgressCallback = (status: IndexingStatus) => void; @@ -54,6 +56,10 @@ export class IndexQueue { private onComplete?: () => void; private vectorService: VectorIndexService | undefined; private workspace = ''; + private readonly pendingVectorWrites = new Set>(); + private deferVectorWrites = true; + private vectorBackfillBatchSize = 25; + private vectorBackfillRunning = false; constructor( private readonly db: ThunderDb, @@ -67,6 +73,12 @@ export class IndexQueue { if (options?.maxFileSizeBytes) { this.maxFileSizeBytes = options.maxFileSizeBytes; } + if (options?.deferVectorWrites !== undefined) { + this.deferVectorWrites = options.deferVectorWrites; + } + if (options?.vectorBackfillBatchSize) { + this.vectorBackfillBatchSize = Math.max(1, options.vectorBackfillBatchSize); + } } onStatusChange(cb: ProgressCallback): void { @@ -109,6 +121,15 @@ export class IndexQueue { this.queue = []; } + /** Waits for embedding writes still in flight — indexFile() kicks these off without awaiting + * them (so file scanning isn't bottlenecked on embedding latency), so callers that need + * embeddings to be durable before proceeding (e.g. shutdown) must drain this explicitly. */ + async waitForVectorIndexing(): Promise { + while (this.pendingVectorWrites.size > 0) { + await Promise.all(this.pendingVectorWrites); + } + } + getStatus(): IndexingStatus { const indexed = (this.db.raw .prepare('SELECT COUNT(*) as c FROM files WHERE indexed_at IS NOT NULL') @@ -140,6 +161,9 @@ export class IndexQueue { this.running = false; this.onProgress?.(this.getStatus()); this.onComplete?.(); + if (this.deferVectorWrites && this.vectorService && this.workspace) { + void this.backfillDeferredVectors(); + } } private async runWorker(): Promise { @@ -182,7 +206,11 @@ export class IndexQueue { : this.chunker.chunkFile(indexContent, job.language); this.db.transaction(() => { - this.vectorService?.deleteFileChunks(job.fileId); + if (this.vectorService) { + const del = this.vectorService.deleteFileChunks(job.fileId) + .finally(() => this.pendingVectorWrites.delete(del)); + this.pendingVectorWrites.add(del); + } this.db.raw.prepare('DELETE FROM chunks WHERE file_id = ?').run(job.fileId); this.db.raw.prepare('DELETE FROM symbols WHERE file_id = ?').run(job.fileId); this.db.raw.prepare('DELETE FROM symbol_refs WHERE file_id = ?').run(job.fileId); @@ -200,13 +228,14 @@ export class IndexQueue { chunk.content, chunk.tokenEstimate, chunk.hash ); this.fts.insertChunk(job.relPath, chunk.content); - if (this.vectorService && this.workspace) { - void this.vectorService.indexChunk( + if (this.vectorService && this.workspace && !this.deferVectorWrites) { + const write = this.vectorService.indexChunk( this.workspace, Number(result.lastInsertRowid), job.relPath, chunk.content - ); + ).finally(() => this.pendingVectorWrites.delete(write)); + this.pendingVectorWrites.add(write); } } @@ -256,4 +285,57 @@ export class IndexQueue { const rows = this.db.raw.prepare('SELECT DISTINCT name FROM symbols').all() as Array<{ name: string }>; this.knownSymbols = new Set(rows.map((r) => r.name)); } + + private async backfillDeferredVectors(): Promise { + if (!this.vectorService || !this.workspace || this.vectorBackfillRunning) return; + this.vectorBackfillRunning = true; + + try { + await this.waitForVectorIndexing(); + while (!this.cancelled) { + const rows = this.db.raw.prepare(` + SELECT c.id as chunk_id, c.content, f.rel_path + FROM chunks c + JOIN files f ON f.id = c.file_id + LEFT JOIN chunk_embeddings ve + ON ve.chunk_id = c.id AND ve.workspace = ? + WHERE f.workspace = ? AND ve.chunk_id IS NULL + ORDER BY + CASE + WHEN f.rel_path = 'package.json' THEN 0 + WHEN f.rel_path LIKE '%/package.json' THEN 1 + WHEN f.rel_path LIKE 'src/%' THEN 2 + ELSE 3 + END, + f.rel_path, + c.chunk_index + LIMIT ? + `).all(this.workspace, this.workspace, this.vectorBackfillBatchSize) as Array<{ + chunk_id: number; + content: string; + rel_path: string; + }>; + + if (rows.length === 0) break; + + for (const row of rows) { + const write = this.vectorService.indexChunk( + this.workspace, + row.chunk_id, + row.rel_path, + row.content + ).finally(() => this.pendingVectorWrites.delete(write)); + this.pendingVectorWrites.add(write); + await write; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } + } catch (error) { + log.warn('Deferred vector backfill failed', { + error: error instanceof Error ? error.message : String(error), + }); + } finally { + this.vectorBackfillRunning = false; + } + } } diff --git a/src/core/indexing/IndexWorkerService.ts b/src/core/indexing/IndexWorkerService.ts new file mode 100644 index 00000000..1d2d275f --- /dev/null +++ b/src/core/indexing/IndexWorkerService.ts @@ -0,0 +1,159 @@ +import { existsSync, readdirSync, statSync } from 'fs'; +import { join, relative, resolve } from 'path'; +import { IndexService } from './IndexService'; +import { IndexQueue, type IndexJob } from './IndexQueue'; +import { IndexMaintenanceService, type IndexRepairReport, type IndexStatusReport } from './IndexMaintenanceService'; +import { WorkspaceScanner } from './WorkspaceScanner'; +import { IgnoreService } from './IgnoreService'; +import { detectLanguage, isBinaryByExtension } from './fileUtils'; +import { defaultThunderConfig } from '../config/defaults'; +import type { IndexingConfig } from '../config/schema'; +import { resolveDbPath } from './paths'; +import { sortIndexCandidates } from './indexingPolicy'; + +type WorkerDiscoveredFile = { + absPath: string; + relPath: string; + size: number; + mtime: number; + language: string | null; +}; + +export interface IndexWorkerOptions { + workspace: string; + indexing?: Partial; +} + +export interface IndexEnqueueResult { + added: number; + changed: number; + deleted: number; + queued: number; +} + +export class IndexWorkerService { + private indexService: IndexService; + private queue?: IndexQueue; + private maintenance?: IndexMaintenanceService; + private readonly workspace: string; + private readonly config: IndexingConfig; + + constructor(options: IndexWorkerOptions) { + this.workspace = resolve(options.workspace); + this.config = { ...defaultThunderConfig().indexing, ...options.indexing }; + this.indexService = new IndexService(this.workspace); + } + + async initialize(): Promise { + await this.indexService.initialize(); + const db = this.indexService.getDb(); + if (!db) throw new Error('Index database failed to initialize'); + this.queue = new IndexQueue(db, { + maxConcurrency: this.config.maxConcurrency, + maxFileSizeBytes: this.config.maxFileSizeBytes, + deferVectorWrites: true, + }); + this.queue.setVectorService(this.workspace, undefined); + this.maintenance = new IndexMaintenanceService(db, this.workspace, resolveDbPath(this.workspace)); + } + + status(): IndexStatusReport { + const { queue, maintenance } = this.ready(); + return maintenance.status(queue.getStatus()); + } + + async enqueue(paths?: string[], options?: { priorityPaths?: string[] }): Promise { + const { queue, maintenance } = this.ready(); + const discovered = discoverFiles(this.workspace, this.config, paths); + const scanner = new WorkspaceScanner(this.indexService.getDb()!, this.workspace); + const diff = scanner.computeDiff(discovered); + scanner.persistScan(diff); + for (const relPath of diff.deleted) { + maintenance.removeFile(relPath); + } + const changed = sortIndexCandidates( + [...diff.added, ...diff.changed], + [...(options?.priorityPaths ?? []), ...this.config.priorityPaths] + ); + const jobs: IndexJob[] = changed + .map((file) => { + const fileId = scanner.getFileId(file.relPath); + return fileId ? { fileId, relPath: file.relPath, absPath: file.absPath, language: file.language } : undefined; + }) + .filter((job): job is IndexJob => Boolean(job)); + queue.enqueue(jobs); + return { + added: diff.added.length, + changed: diff.changed.length, + deleted: diff.deleted.length, + queued: jobs.length, + }; + } + + delete(relPath: string): boolean { + const { maintenance } = this.ready(); + return maintenance.removeFile(relPath); + } + + repair(): IndexRepairReport { + const { maintenance } = this.ready(); + return maintenance.repair(); + } + + dispose(): void { + this.queue?.cancel(); + this.indexService.dispose(); + } + + private ready(): { queue: IndexQueue; maintenance: IndexMaintenanceService } { + if (!this.queue || !this.maintenance) { + throw new Error('Index worker is not initialized'); + } + return { queue: this.queue, maintenance: this.maintenance }; + } +} + +function discoverFiles(workspace: string, config: IndexingConfig, paths?: string[]): WorkerDiscoveredFile[] { + const ignore = new IgnoreService(); + ignore.load(workspace, { + respectGitignore: config.respectGitignore, + respectThunderignore: config.respectThunderignore, + }); + const roots = paths && paths.length > 0 ? paths : ['.']; + const files: WorkerDiscoveredFile[] = []; + for (const input of roots) { + const absPath = resolve(workspace, input); + if (!absPath.startsWith(workspace) || !existsSync(absPath)) continue; + walk(absPath, workspace, ignore, config, files); + } + return files; +} + +function walk( + absPath: string, + workspace: string, + ignore: IgnoreService, + config: IndexingConfig, + files: WorkerDiscoveredFile[] +): void { + const relPath = relative(workspace, absPath).replace(/\\/g, '/') || '.'; + if (ignore.isIgnored(relPath)) return; + let stat; + try { + stat = statSync(absPath); + } catch { + return; + } + if (stat.isDirectory()) { + for (const entry of readdirSync(absPath)) walk(join(absPath, entry), workspace, ignore, config, files); + return; + } + if (!stat.isFile() || stat.size > config.hardSkipSizeBytes || isBinaryByExtension(relPath)) return; + files.push({ + absPath, + relPath, + size: stat.size, + mtime: stat.mtimeMs, + language: detectLanguage(relPath), + }); +} diff --git a/src/core/indexing/LanceDbVectorIndex.ts b/src/core/indexing/LanceDbVectorIndex.ts index 71e5675d..bf4a8efe 100644 --- a/src/core/indexing/LanceDbVectorIndex.ts +++ b/src/core/indexing/LanceDbVectorIndex.ts @@ -5,6 +5,7 @@ import { cosineSimilarity } from './EmbeddingProvider'; import { createLogger } from '../telemetry/Logger'; import type { VectorIndex, VectorSearchResult } from './VectorIndex'; import { resolveThunderDir } from './paths'; +import { summarizeHealthDetail, type ComponentHealth } from './ComponentHealth'; const log = createLogger('LanceDbVectorIndex'); @@ -31,7 +32,11 @@ type LanceRow = { const TABLE_NAME = 'chunk_embeddings'; export class LanceDbVectorIndex implements VectorIndex { - private tablePromise: Promise | null = null; + private connectionPromise: Promise | null = null; + private creatingPromise: Promise | null = null; + private creatingChunkId: number | null = null; + private table: LanceTable | null = null; + private health: ComponentHealth = { status: 'unknown' }; constructor( private readonly sqliteDb: ThunderDb, @@ -44,35 +49,88 @@ export class LanceDbVectorIndex implements VectorIndex { return base; } - private async getTable(): Promise { - if (!this.tablePromise) { - this.tablePromise = this.openTable(); + private async getConnection(): Promise { + if (!this.connectionPromise) { + this.connectionPromise = this.connect(); } - return this.tablePromise; + return this.connectionPromise; } - private async openTable(): Promise { + private async connect(): Promise { try { const lancedb = await import('@lancedb/lancedb'); const connect = (lancedb as unknown as { connect: (uri: string) => Promise }).connect; const db = await connect(this.lanceDir()); - try { - return await db.openTable(TABLE_NAME); - } catch { - return await db.createTable(TABLE_NAME, []); - } + this.health = { status: 'ready' }; + return db; + } catch (error) { + const fullDetail = error instanceof Error ? error.message : String(error); + this.health = { status: 'degraded', detail: summarizeHealthDetail(error) }; + log.warn('LanceDB unavailable, falling back to SQLite vector scan for this session', { error: fullDetail }); + return null; + } + } + + /** Read-only lookup: returns null if the table hasn't been created yet (no chunks embedded so + * far) — that's a normal, non-degraded state, not an error. Creation happens lazily in + * upsertLanceRow() once we have a real row to infer the schema from (LanceDB's `createTable` + * needs a non-empty seed row or an explicit Arrow schema; it can't create a table from `[]`). */ + private async getTable(): Promise { + if (this.table) return this.table; + const db = await this.getConnection(); + if (!db) return null; + + try { + this.table = await db.openTable(TABLE_NAME); + return this.table; + } catch { + return null; + } + } + + /** Returns the table plus whether `seedRow` was the one used to create it (in which case it's + * already inserted — no separate add() needed) or another concurrent caller's row won the race + * to create the table first (in which case the caller still needs to add its own row). */ + private async getOrCreateTable(seedRow: LanceRow): Promise<{ table: LanceTable; seeded: boolean } | null> { + const existing = await this.getTable(); + if (existing) return { table: existing, seeded: false }; + + // Guard against concurrent first-inserts racing to create the table twice — only one + // creation attempt runs; concurrent callers await and share its result. + if (!this.creatingPromise) { + this.creatingChunkId = seedRow.chunk_id; + this.creatingPromise = this.createTableWithSeed(seedRow); + } + const table = await this.creatingPromise; + if (!table) return null; + return { table, seeded: this.creatingChunkId === seedRow.chunk_id }; + } + + private async createTableWithSeed(seedRow: LanceRow): Promise { + const db = await this.getConnection(); + if (!db) return null; + + try { + this.table = await db.createTable(TABLE_NAME, [seedRow]); + return this.table; } catch (error) { - log.warn('LanceDB unavailable, falling back to SQLite vectors', { + log.warn('LanceDB table creation failed', { error: error instanceof Error ? error.message : String(error), }); return null; } } + getHealth(): ComponentHealth { + return this.health; + } + + /** Brute-force SQLite fallback: used only when the LanceDB native table failed to open + * (see getHealth()) or its own ANN search threw. Not the normal path when LanceDB is healthy. */ search(workspace: string, queryEmbedding: number[], limit = 10): VectorSearchResult[] { if (queryEmbedding.length === 0) return []; + log.debug('sqlite-fallback:search', { workspace, backendHealth: this.health.status }); - // LanceDB search is async; run synchronously via sqlite mirror for HybridRetriever sync callers. const rows = this.sqliteDb.raw.prepare(` SELECT ve.chunk_id, c.content, f.rel_path, ve.embedding_json FROM chunk_embeddings ve @@ -108,17 +166,22 @@ export class LanceDbVectorIndex implements VectorIndex { if (!table) return this.search(workspace, queryEmbedding, limit); try { + // Over-fetch because LanceDB's ANN search isn't filtered by workspace server-side — + // we filter client-side below, so we need extra candidates to still hit `limit` after that. + // Once filtered, we trust LanceDB's own nearest-neighbor order (that's the whole point of + // using its native index) rather than recomputing cosine similarity and re-sorting in JS. const rows = await table.search(queryEmbedding).limit(limit * 3).toArray(); - return rows + const results = rows .filter((row) => row.workspace === workspace) + .slice(0, limit) .map((row) => ({ chunkId: row.chunk_id, relPath: row.rel_path, content: row.content, score: cosineSimilarity(queryEmbedding, row.vector), - })) - .sort((a, b) => b.score - a.score) - .slice(0, limit); + })); + log.debug('lancedb:search', { workspace, candidateCount: rows.length, resultCount: results.length }); + return results; } catch (error) { log.warn('LanceDB search failed', { error: error instanceof Error ? error.message : String(error), @@ -127,7 +190,10 @@ export class LanceDbVectorIndex implements VectorIndex { } } - upsertChunk(workspace: string, chunkId: number, relPath: string, embedding: number[]): void { + /** Writes SQLite first as a durable safety net (used if LanceDB is unavailable), then awaits + * the LanceDB write itself so callers can rely on the embedding actually being searchable via + * LanceDB once this resolves, rather than racing a fire-and-forget background write. */ + async upsertChunk(workspace: string, chunkId: number, relPath: string, embedding: number[]): Promise { this.sqliteDb.raw.prepare(` INSERT INTO chunk_embeddings (chunk_id, workspace, embedding_json, updated_at) VALUES (?, ?, ?, ?) @@ -136,7 +202,7 @@ export class LanceDbVectorIndex implements VectorIndex { updated_at = excluded.updated_at `).run(chunkId, workspace, JSON.stringify(embedding), Date.now()); - void this.upsertLanceRow(workspace, chunkId, relPath, embedding); + await this.upsertLanceRow(workspace, chunkId, relPath, embedding); } private async upsertLanceRow( @@ -145,22 +211,33 @@ export class LanceDbVectorIndex implements VectorIndex { relPath: string, embedding: number[] ): Promise { - const table = await this.getTable(); - if (!table) return; - try { const contentRow = this.sqliteDb.raw .prepare('SELECT content FROM chunks WHERE id = ?') .get(chunkId) as { content: string } | undefined; - await table.delete(`chunk_id = ${chunkId}`); - await table.add([{ + const row: LanceRow = { chunk_id: chunkId, workspace, rel_path: relPath, content: contentRow?.content ?? '', vector: embedding, - }]); + }; + + const existing = await this.getTable(); + if (existing) { + await existing.delete(`chunk_id = ${chunkId}`); + await existing.add([row]); + return; + } + + // No table yet: this row seeds it (createTable() inserts it directly, no separate add()) — + // unless a concurrent upsert's row won the race to create the table first, in which case + // this row still needs to be added. + const result = await this.getOrCreateTable(row); + if (result && !result.seeded) { + await result.table.add([row]); + } } catch (error) { log.warn('LanceDB upsert failed', { chunkId, @@ -169,7 +246,7 @@ export class LanceDbVectorIndex implements VectorIndex { } } - deleteFileChunks(fileId: number): void { + async deleteFileChunks(fileId: number): Promise { const chunkIds = this.sqliteDb.raw .prepare('SELECT id FROM chunks WHERE file_id = ?') .all(fileId) as Array<{ id: number }>; @@ -179,17 +256,15 @@ export class LanceDbVectorIndex implements VectorIndex { WHERE chunk_id IN (SELECT id FROM chunks WHERE file_id = ?) `).run(fileId); - void (async () => { - const table = await this.getTable(); - if (!table) return; - for (const row of chunkIds) { - try { - await table.delete(`chunk_id = ${row.id}`); - } catch { - // Non-fatal - } + const table = await this.getTable(); + if (!table) return; + for (const row of chunkIds) { + try { + await table.delete(`chunk_id = ${row.id}`); + } catch { + // Non-fatal } - })(); + } } count(workspace: string): number { diff --git a/src/core/indexing/ThunderDb.ts b/src/core/indexing/ThunderDb.ts index 5873b02c..a1342a79 100644 --- a/src/core/indexing/ThunderDb.ts +++ b/src/core/indexing/ThunderDb.ts @@ -14,6 +14,7 @@ export class ThunderDb { } this.db = new Database(this.dbPath); this.db.pragma('journal_mode = WAL'); + this.db.pragma('busy_timeout = 5000'); this.db.pragma('foreign_keys = ON'); log.info('Database opened', { path: this.dbPath }); } diff --git a/src/core/indexing/TransformersEmbeddingProvider.ts b/src/core/indexing/TransformersEmbeddingProvider.ts index c8b94bbc..bef7a08e 100644 --- a/src/core/indexing/TransformersEmbeddingProvider.ts +++ b/src/core/indexing/TransformersEmbeddingProvider.ts @@ -1,5 +1,6 @@ import { createLogger } from '../telemetry/Logger'; -import type { EmbeddingProvider } from './EmbeddingProvider'; +import { HashEmbeddingProvider, type EmbeddingProvider } from './EmbeddingProvider'; +import { summarizeHealthDetail, type ComponentHealth } from './ComponentHealth'; const log = createLogger('TransformersEmbedding'); @@ -9,6 +10,8 @@ type FeatureExtractor = ( ) => Promise<{ tolist(): number[][] }>; let pipelinePromise: Promise | null = null; +let health: ComponentHealth = { status: 'unknown' }; +const hashFallback = new HashEmbeddingProvider(); async function loadPipeline(): Promise { if (pipelinePromise) return pipelinePromise; @@ -20,11 +23,12 @@ async function loadPipeline(): Promise { env.allowLocalModels = true; env.allowRemoteModels = true; const extractor = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2'); + health = { status: 'ready' }; return extractor as FeatureExtractor; } catch (error) { - log.warn('MiniLM embeddings unavailable', { - error: error instanceof Error ? error.message : String(error), - }); + const fullDetail = error instanceof Error ? error.message : String(error); + health = { status: 'degraded', detail: `${summarizeHealthDetail(error)}; using hash fallback` }; + log.warn('MiniLM embeddings unavailable, using hash embeddings for this session', { error: fullDetail }); return null; } })(); @@ -41,7 +45,8 @@ export class TransformersEmbeddingProvider implements EmbeddingProvider { const extractor = await loadPipeline(); if (!extractor) { - return texts.map(() => []); + log.debug('embed:fallback, MiniLM pipeline unavailable', { textCount: texts.length, detail: health.detail }); + return hashFallback.embed(texts); } const outputs: number[][] = []; @@ -54,14 +59,19 @@ export class TransformersEmbeddingProvider implements EmbeddingProvider { // Yield to event loop so the extension host stays responsive. await new Promise((resolve) => setTimeout(resolve, 5)); } catch (error) { - log.warn('MiniLM embed failed', { - error: error instanceof Error ? error.message : String(error), - }); - outputs.push([]); + const fullDetail = error instanceof Error ? error.message : String(error); + health = { status: 'degraded', detail: `${summarizeHealthDetail(error)}; using hash fallback` }; + log.warn('MiniLM embed failed, using hash embedding for this input', { error: fullDetail }); + const [fallback] = await hashFallback.embed([text]); + outputs.push(fallback); } } return outputs; } + + getHealth(): ComponentHealth { + return health; + } } export function isTransformersEmbeddingAvailable(): boolean { diff --git a/src/core/indexing/VectorIndex.ts b/src/core/indexing/VectorIndex.ts index 3264e692..ed7922b1 100644 --- a/src/core/indexing/VectorIndex.ts +++ b/src/core/indexing/VectorIndex.ts @@ -2,6 +2,7 @@ import type { ThunderDb } from './ThunderDb'; import { cosineSimilarity, type EmbeddingProvider } from './EmbeddingProvider'; import type { LanceDbVectorIndex } from './LanceDbVectorIndex'; import { createLogger } from '../telemetry/Logger'; +import type { ComponentHealth } from './ComponentHealth'; const log = createLogger('VectorIndex'); @@ -14,9 +15,11 @@ export interface VectorSearchResult { export interface VectorIndex { search(workspace: string, queryEmbedding: number[], limit?: number): VectorSearchResult[]; - upsertChunk(workspace: string, chunkId: number, relPath: string, embedding: number[]): void; - deleteFileChunks(fileId: number): void; + upsertChunk(workspace: string, chunkId: number, relPath: string, embedding: number[]): void | Promise; + deleteFileChunks(fileId: number): void | Promise; count(workspace: string): number; + /** Runtime health (e.g. did the LanceDB native table actually open?). Omit if always healthy. */ + getHealth?(): ComponentHealth; } /** SQLite-backed vector store. LanceDB can replace this when enabled later. */ @@ -101,7 +104,7 @@ export class VectorIndexService { try { const [embedding] = await this.embedder.embed([content.slice(0, 2000)]); if (embedding.length > 0) { - this.index.upsertChunk(workspace, chunkId, relPath, embedding); + await this.index.upsertChunk(workspace, chunkId, relPath, embedding); } } catch (error) { log.warn('Chunk embedding failed', { @@ -111,11 +114,19 @@ export class VectorIndexService { } } - deleteFileChunks(fileId: number): void { - this.index.deleteFileChunks(fileId); + async deleteFileChunks(fileId: number): Promise { + await this.index.deleteFileChunks(fileId); } count(workspace: string): number { return this.index.count(workspace); } + + /** Runtime health of the embedder and the vector backend, for UI/status surfacing. */ + getHealth(): { embedder: ComponentHealth; backend: ComponentHealth } { + return { + embedder: this.embedder.getHealth?.() ?? { status: 'unknown' }, + backend: this.index.getHealth?.() ?? { status: 'unknown' }, + }; + } } diff --git a/src/core/indexing/WorkspaceLanguageService.ts b/src/core/indexing/WorkspaceLanguageService.ts new file mode 100644 index 00000000..972c5247 --- /dev/null +++ b/src/core/indexing/WorkspaceLanguageService.ts @@ -0,0 +1,323 @@ +import { existsSync, readFileSync } from 'fs'; +import { join, relative } from 'path'; +import { Project, Node, ts, type Identifier, type Symbol as MorphSymbol } from 'ts-morph'; +import type { IgnoreService } from './IgnoreService'; +import type { IndexingConfig } from '../config/schema'; +import { FileDiscoveryService } from './FileDiscoveryService'; +import { UNKNOWN_HEALTH, summarizeHealthDetail, type ComponentHealth } from './ComponentHealth'; +import { createLogger } from '../telemetry/Logger'; + +const log = createLogger('WorkspaceLanguageService'); + +const TS_LIKE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts', '.mjs', '.cjs']); + +export interface DefinitionResult { + relPath: string; + startLine: number; + startColumn: number; + endLine: number; + endColumn: number; + kind: string; + name: string; + preview: string; +} + +export interface CallerResult { + relPath: string; + line: number; + column: number; + preview: string; + enclosingSymbol?: string; +} + +export function isTsLikeFile(pathOrName: string): boolean { + const dot = pathOrName.lastIndexOf('.'); + if (dot === -1) return false; + return TS_LIKE_EXTENSIONS.has(pathOrName.slice(dot).toLowerCase()); +} + +/** Persistent, per-workspace ts-morph Project backing precise cross-file symbol resolution. + * Lazily initialized on first use — constructing a full Program eagerly for every workspace + * would tax startup latency for workspaces that never need it. */ +export class WorkspaceLanguageService { + private project: Project | undefined; + private health: ComponentHealth = UNKNOWN_HEALTH; + + constructor( + private readonly workspaceRoot: string, + private readonly ignoreService: IgnoreService, + private readonly config: IndexingConfig + ) {} + + getHealth(): ComponentHealth { + return this.health; + } + + dispose(): void { + this.project = undefined; + } + + private ensureProject(): Project | undefined { + if (this.project) return this.project; + + try { + const tsConfigFilePath = join(this.workspaceRoot, 'tsconfig.json'); + if (existsSync(tsConfigFilePath)) { + this.project = new Project({ + tsConfigFilePath, + skipFileDependencyResolution: false, + skipAddingFilesFromTsConfig: false, + }); + } else { + this.project = new Project({ + compilerOptions: { + allowJs: true, + jsx: ts.JsxEmit.ReactJSX, + target: ts.ScriptTarget.ES2020, + moduleResolution: ts.ModuleResolutionKind.Bundler, + module: ts.ModuleKind.ESNext, + }, + }); + this.addWorkspaceFilesManually(this.project); + } + this.health = { status: 'ready' }; + } catch (error) { + this.health = { status: 'degraded', detail: summarizeHealthDetail(error) }; + log.warn('Failed to initialize language service project', { + error: error instanceof Error ? error.message : String(error), + }); + this.project = undefined; + } + + return this.project; + } + + private addWorkspaceFilesManually(project: Project): void { + const discovery = new FileDiscoveryService(this.workspaceRoot, this.ignoreService, this.config); + for (const file of discovery.discover()) { + if (!isTsLikeFile(file.relPath)) continue; + try { + project.addSourceFileAtPath(file.absPath); + } catch { + // Unreadable/unparsable file — skip rather than fail the whole project. + } + } + } + + private absPath(relPath: string): string { + return join(this.workspaceRoot, relPath); + } + + /** Sync unsaved editor content into the in-memory AST. Must never throw — a bad keystroke-in-flight + * parse should degrade gracefully rather than break the caller (VS Code text-change events). */ + updateFile(relPath: string, content: string): void { + const project = this.ensureProject(); + if (!project) return; + + try { + const absPath = this.absPath(relPath); + const existing = project.getSourceFile(absPath); + if (existing) { + existing.replaceWithText(content); + } else { + project.createSourceFile(absPath, content, { overwrite: true }); + } + } catch (error) { + log.debug('updateFile failed', { relPath, error: error instanceof Error ? error.message : String(error) }); + } + } + + /** Re-reads a file from disk — used for external changes (file watcher: create/change/delete). */ + syncFileFromDisk(relPath: string): void { + const project = this.ensureProject(); + if (!project) return; + + const absPath = this.absPath(relPath); + if (!existsSync(absPath)) { + const existing = project.getSourceFile(absPath); + if (existing) project.removeSourceFile(existing); + return; + } + + try { + const content = readFileSync(absPath, 'utf-8'); + this.updateFile(relPath, content); + } catch (error) { + log.debug('syncFileFromDisk failed', { relPath, error: error instanceof Error ? error.message : String(error) }); + } + } + + private getIdentifierAt(relPath: string, line: number, column: number): Identifier | undefined { + const project = this.ensureProject(); + if (!project) return undefined; + + const sourceFile = project.getSourceFile(this.absPath(relPath)); + if (!sourceFile) return undefined; + + const pos = sourceFile.compilerNode.getPositionOfLineAndCharacter(Math.max(0, line - 1), Math.max(0, column - 1)); + const descendant = sourceFile.getDescendantAtPos(pos); + if (!descendant) return undefined; + if (Node.isIdentifier(descendant)) return descendant; + + // Position may have landed just before/inside the identifier's parent — check immediate children. + const child = descendant.getChildren().find((c) => Node.isIdentifier(c) && c.getStart() <= pos && pos <= c.getEnd()); + return child && Node.isIdentifier(child) ? child : undefined; + } + + /** Finds an identifier's column on a given line by exact word match — used when only a name and + * line are known (e.g. from the DB's `symbols` table, which has no column). */ + findColumnForName(relPath: string, line: number, name: string): number | undefined { + const project = this.ensureProject(); + if (!project) return undefined; + const sourceFile = project.getSourceFile(this.absPath(relPath)); + if (!sourceFile) return undefined; + + const lineText = sourceFile.getFullText().split('\n')[line - 1]; + if (lineText === undefined) return undefined; + + const wordRegex = new RegExp(`\\b${escapeRegex(name)}\\b`); + const match = wordRegex.exec(lineText); + return match ? match.index + 1 : undefined; + } + + /** Cross-file go-to-definition, resolved through re-export/alias chains so a name-only lookup + * lands on the true declaration rather than a re-export specifier. */ + getDefinition(relPath: string, line: number, column: number): DefinitionResult[] { + try { + const identifier = this.getIdentifierAt(relPath, line, column); + if (!identifier) return []; + + const results: DefinitionResult[] = []; + const seen = new Set(); + + for (const def of identifier.getDefinitions()) { + const declNode = resolveThroughAliases(identifier, def.getDeclarationNode()); + const sourceFile = declNode?.getSourceFile() ?? def.getSourceFile(); + if (!sourceFile) continue; + + const span = declNode ? declNode.getStart() : def.getTextSpan().getStart(); + const endPos = declNode ? declNode.getEnd() : span + def.getTextSpan().getLength(); + const start = sourceFile.compilerNode.getLineAndCharacterOfPosition(span); + const end = sourceFile.compilerNode.getLineAndCharacterOfPosition(endPos); + const relDefPath = relative(this.workspaceRoot, sourceFile.getFilePath()).replace(/\\/g, '/'); + + const key = `${relDefPath}:${start.line}:${start.character}`; + if (seen.has(key)) continue; + seen.add(key); + + results.push({ + relPath: relDefPath, + startLine: start.line + 1, + startColumn: start.character + 1, + endLine: end.line + 1, + endColumn: end.character + 1, + kind: def.getKind(), + name: def.getName(), + preview: sourceFile.getFullText().split('\n').slice(start.line, start.line + 3).join('\n').slice(0, 300), + }); + } + + return results; + } catch (error) { + log.debug('getDefinition failed', { relPath, line, column, error: error instanceof Error ? error.message : String(error) }); + return []; + } + } + + /** Finds actual call sites of the symbol at this position — filters find-references down to + * references that are the callee of a CallExpression/NewExpression, excluding imports, type + * annotations, destructuring, and the definition itself. Best-effort: cannot resolve dynamic + * dispatch (`handlers[key]()`) or interface-mediated polymorphic calls to their concrete impl. */ + getCallers(relPath: string, line: number, column: number): CallerResult[] { + try { + const identifier = this.getIdentifierAt(relPath, line, column); + if (!identifier) return []; + + const callers: CallerResult[] = []; + for (const referencedSymbol of identifier.findReferences()) { + for (const entry of referencedSymbol.getReferences()) { + if (entry.isDefinition()) continue; + + const node = entry.getNode(); + if (!isCallSite(node)) continue; + + const sourceFile = entry.getSourceFile(); + const start = sourceFile.compilerNode.getLineAndCharacterOfPosition(node.getStart()); + const lineText = sourceFile.getFullText().split('\n')[start.line] ?? ''; + + callers.push({ + relPath: relative(this.workspaceRoot, sourceFile.getFilePath()).replace(/\\/g, '/'), + line: start.line + 1, + column: start.character + 1, + preview: lineText.trim().slice(0, 200), + enclosingSymbol: getEnclosingSymbolName(node), + }); + } + } + + return callers; + } catch (error) { + log.debug('getCallers failed', { relPath, line, column, error: error instanceof Error ? error.message : String(error) }); + return []; + } + } +} + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** Walks past re-export/alias indirection (e.g. `export { foo } from './impl'`) to the real + * declaration. `export *` barrels are not expanded here — a documented limitation, not a bug: + * they don't create a per-name symbol for the checker to follow. */ +function resolveThroughAliases(identifier: Identifier, declNode: Node | undefined): Node | undefined { + let symbol: MorphSymbol | undefined = identifier.getSymbol(); + if (!symbol) return declNode; + + const visited = new Set(); + while (symbol.isAlias() && !visited.has(symbol)) { + visited.add(symbol); + const aliased = symbol.getAliasedSymbol(); + if (!aliased) break; + symbol = aliased; + } + + const declarations = symbol.getDeclarations(); + return declarations[0] ?? declNode; +} + +function isCallSite(node: Node): boolean { + const parent = node.getParent(); + if (!parent) return false; + + if (Node.isCallExpression(parent) || Node.isNewExpression(parent)) { + return parent.getExpression() === node; + } + + if (Node.isPropertyAccessExpression(parent) && parent.getNameNode() === node) { + const grandParent = parent.getParent(); + if (Node.isCallExpression(grandParent) || Node.isNewExpression(grandParent)) { + return grandParent.getExpression() === parent; + } + } + + return false; +} + +function getEnclosingSymbolName(node: Node): string | undefined { + const enclosing = node.getFirstAncestor( + (n) => Node.isFunctionDeclaration(n) || Node.isMethodDeclaration(n) || Node.isClassDeclaration(n) || Node.isArrowFunction(n) || Node.isFunctionExpression(n) + ); + if (!enclosing) return undefined; + + if (Node.isFunctionDeclaration(enclosing) || Node.isMethodDeclaration(enclosing) || Node.isClassDeclaration(enclosing)) { + return enclosing.getName(); + } + + if (Node.isArrowFunction(enclosing) || Node.isFunctionExpression(enclosing)) { + const varDecl = enclosing.getFirstAncestor((n) => Node.isVariableDeclaration(n)); + return varDecl && Node.isVariableDeclaration(varDecl) ? varDecl.getName() : undefined; + } + + return undefined; +} diff --git a/src/core/indexing/WorkspaceScanner.ts b/src/core/indexing/WorkspaceScanner.ts index 62b49ec6..0bd04e7d 100644 --- a/src/core/indexing/WorkspaceScanner.ts +++ b/src/core/indexing/WorkspaceScanner.ts @@ -24,7 +24,8 @@ export class WorkspaceScanner { private readonly workspace: string ) {} - computeDiff(discovered: DiscoveredFile[]): ScanDiff { + computeDiff(discovered: DiscoveredFile[], options: { includeDeleted?: boolean } = {}): ScanDiff { + const includeDeleted = options.includeDeleted !== false; const existing = this.db.raw .prepare('SELECT rel_path, hash, mtime FROM files WHERE workspace = ?') .all(this.workspace) as Array<{ rel_path: string; hash: string; mtime: number }>; @@ -51,9 +52,11 @@ export class WorkspaceScanner { } } - for (const row of existing) { - if (!discoveredSet.has(row.rel_path)) { - deleted.push(row.rel_path); + if (includeDeleted) { + for (const row of existing) { + if (!discoveredSet.has(row.rel_path)) { + deleted.push(row.rel_path); + } } } diff --git a/src/core/indexing/embeddingFactory.ts b/src/core/indexing/embeddingFactory.ts index f8f1e8fc..db5e8441 100644 --- a/src/core/indexing/embeddingFactory.ts +++ b/src/core/indexing/embeddingFactory.ts @@ -11,21 +11,19 @@ export function createEmbeddingProvider(config: IndexingConfig): EmbeddingProvid return new NoOpEmbeddingProvider(); } - const preferMinilm = - config.embeddingProvider === 'minilm' || - (config.embeddingProvider === 'hash' && isTransformersEmbeddingAvailable()); - - if (preferMinilm && isTransformersEmbeddingAvailable()) { + if (config.embeddingProvider === 'minilm' && isTransformersEmbeddingAvailable()) { return new TransformersEmbeddingProvider(); } return new HashEmbeddingProvider(); } +/** Static description from config + package availability only — does not reflect whether the + * model has actually loaded successfully at runtime. See TransformersEmbeddingProvider.getHealth(). */ export function describeEmbeddingProvider(config: IndexingConfig): string { if (!config.vectorsEnabled) return 'none'; - if (isTransformersEmbeddingAvailable() && config.embeddingProvider !== 'hash') { - return 'minilm'; + if (config.embeddingProvider === 'minilm') { + return isTransformersEmbeddingAvailable() ? 'minilm' : 'hash-fallback'; } - return config.embeddingProvider === 'minilm' ? 'minilm' : 'hash-fallback'; + return 'hash'; } diff --git a/src/core/indexing/index.ts b/src/core/indexing/index.ts index 4b232d22..46e2c732 100644 --- a/src/core/indexing/index.ts +++ b/src/core/indexing/index.ts @@ -12,7 +12,10 @@ export * from './SymbolExtractor'; export * from './languageRegistry'; export * from './TreeSitterService'; export * from './IndexQueue'; +export * from './IndexMaintenanceService'; +export * from './IndexWorkerService'; export * from './hash'; +export * from './indexingPolicy'; export * from './embeddingFactory'; export * from './vectorIndexFactory'; export * from './vectorAvailability'; diff --git a/src/core/indexing/indexingPolicy.ts b/src/core/indexing/indexingPolicy.ts new file mode 100644 index 00000000..41145d20 --- /dev/null +++ b/src/core/indexing/indexingPolicy.ts @@ -0,0 +1,114 @@ +import { existsSync } from 'fs'; +import { join } from 'path'; + +export const DEFAULT_WORKSPACE_IGNORE_PATTERNS = [ + 'node_modules/', + 'dist/', + 'build/', + '.next/', + 'coverage/', + 'vendor/', + 'tmp/', + 'logs/', + '*.lock', + '*.map', +] as const; + +export const DEFAULT_GITIGNORE_PATTERNS = [ + '.mitii/', + '.mitti/', +] as const; + +export const AUTO_INDEX_INITIAL_FILE_LIMIT = 500; +export const AUTO_INDEX_BACKGROUND_DELAY_MS = 8_000; + +const CONFIG_FILE_NAMES = new Set([ + 'package.json', + 'tsconfig.json', + 'jsconfig.json', + 'vite.config.ts', + 'vite.config.js', + 'vite.config.mts', + 'vite.config.mjs', + 'webpack.config.js', + 'webpack.config.ts', + 'next.config.js', + 'next.config.mjs', + 'next.config.ts', + 'svelte.config.js', + 'astro.config.mjs', + 'nuxt.config.ts', + 'tailwind.config.js', + 'tailwind.config.ts', + 'eslint.config.js', + 'eslint.config.mjs', + 'eslint.config.ts', + '.eslintrc', + '.eslintrc.js', + '.eslintrc.json', + 'biome.json', + 'prettier.config.js', + '.prettierrc', + 'pnpm-workspace.yaml', + 'package-lock.json', + 'yarn.lock', + 'pnpm-lock.yaml', + 'Cargo.toml', + 'go.mod', + 'pyproject.toml', + 'requirements.txt', + 'pom.xml', + 'build.gradle', + 'settings.gradle', +]); + +const PRIORITY_ROOT_CANDIDATES = [ + 'package.json', + 'pnpm-workspace.yaml', + 'tsconfig.json', + 'jsconfig.json', + 'vite.config.ts', + 'vite.config.js', + 'next.config.js', + 'next.config.mjs', + 'src', + 'app', + 'pages', + 'packages', +] as const; + +export function priorityDiscoveryRoots(workspace: string): string[] { + return PRIORITY_ROOT_CANDIDATES.filter((candidate) => existsSync(join(workspace, candidate))); +} + +export function sortIndexCandidates( + files: T[], + priorityPaths: string[] = [] +): T[] { + return [...files].sort((a, b) => { + const scoreDelta = priorityScore(a.relPath, priorityPaths) - priorityScore(b.relPath, priorityPaths); + return scoreDelta || a.relPath.localeCompare(b.relPath); + }); +} + +export function priorityScore(relPath: string, priorityPaths: string[] = []): number { + const normalized = relPath.replace(/\\/g, '/').replace(/^\.\/+/, ''); + + if (priorityPaths.some((path) => matchesPriorityPath(normalized, path))) { + return 0; + } + + const basename = normalized.split('/').pop() ?? normalized; + if (basename === 'package.json') return 10; + if (CONFIG_FILE_NAMES.has(basename)) return 20; + if (normalized === 'src' || normalized.startsWith('src/')) return 30; + if (/^(app|pages|packages)\/[^/]+\/src\//.test(normalized)) return 40; + if (/\/src\//.test(normalized)) return 50; + return 100; +} + +function matchesPriorityPath(relPath: string, priorityPath: string): boolean { + const normalized = priorityPath.replace(/\\/g, '/').replace(/^\.\/+/, '').replace(/\/+$/, ''); + if (!normalized) return false; + return relPath === normalized || relPath.startsWith(`${normalized}/`); +} diff --git a/src/core/indexing/languageServiceFactory.ts b/src/core/indexing/languageServiceFactory.ts new file mode 100644 index 00000000..d7a20306 --- /dev/null +++ b/src/core/indexing/languageServiceFactory.ts @@ -0,0 +1,28 @@ +import type { IgnoreService } from './IgnoreService'; +import type { IndexingConfig } from '../config/schema'; +import { WorkspaceLanguageService } from './WorkspaceLanguageService'; + +/** Keyed per-workspace, mirroring RepoMapService's static cache — this is the single place both + * ThunderController (VS Code host) and HeadlessAgentHost (headless/eval host) obtain a language + * service instance, so the two independently-managed lifecycles can never diverge. */ +const registry = new Map(); + +export function getOrCreateLanguageService( + workspace: string, + ignoreService: IgnoreService, + config: IndexingConfig +): WorkspaceLanguageService { + const existing = registry.get(workspace); + if (existing) return existing; + + const service = new WorkspaceLanguageService(workspace, ignoreService, config); + registry.set(workspace, service); + return service; +} + +export function disposeLanguageService(workspace: string): void { + const existing = registry.get(workspace); + if (!existing) return; + existing.dispose(); + registry.delete(workspace); +} diff --git a/src/core/integrations/github/GitHubPullRequestService.ts b/src/core/integrations/github/GitHubPullRequestService.ts new file mode 100644 index 00000000..8352397b --- /dev/null +++ b/src/core/integrations/github/GitHubPullRequestService.ts @@ -0,0 +1,89 @@ +import { execFileSync } from 'child_process'; + +type FetchLike = typeof fetch; + +export interface GitHubPullRequestInput { + owner: string; + repo: string; + head: string; + base: string; + title: string; + body: string; + draft?: boolean; + maintainerCanModify?: boolean; +} + +export interface GitHubPullRequestResult { + number: number; + htmlUrl: string; + state: string; + draft: boolean; +} + +export class GitHubPullRequestService { + constructor( + private readonly fetchImpl: FetchLike = fetch, + private readonly userAgent = 'Mitii-AI-Agent' + ) {} + + async createPullRequest(input: GitHubPullRequestInput, token: string): Promise { + if (!token) { + throw new Error('GitHub token is required to create a pull request'); + } + const response = await this.fetchImpl(`https://api.github.com/repos/${input.owner}/${input.repo}/pulls`, { + method: 'POST', + headers: { + Accept: 'application/vnd.github+json', + 'Content-Type': 'application/json', + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': this.userAgent, + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + title: input.title, + body: input.body, + head: input.head, + base: input.base, + draft: input.draft ?? true, + maintainer_can_modify: input.maintainerCanModify ?? true, + }), + }); + if (!response.ok) { + throw new Error(`GitHub PR request failed (${response.status}): ${await safeErrorText(response)}`); + } + const body = await response.json() as { number?: number; html_url?: string; state?: string; draft?: boolean }; + return { + number: body.number ?? 0, + htmlUrl: body.html_url ?? '', + state: body.state ?? 'unknown', + draft: body.draft ?? Boolean(input.draft ?? true), + }; + } +} + +export function parseGitHubRemoteUrl(remote: string): { owner: string; repo: string } | undefined { + const trimmed = remote.trim().replace(/\.git$/, ''); + const ssh = /^git@github\.com:([^/]+)\/(.+)$/.exec(trimmed); + if (ssh) return { owner: ssh[1], repo: ssh[2] }; + const https = /^https:\/\/github\.com\/([^/]+)\/(.+)$/.exec(trimmed); + if (https) return { owner: https[1], repo: https[2] }; + return undefined; +} + +export function inferGitHubRepo(cwd: string): { owner: string; repo: string } | undefined { + try { + const remote = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd, encoding: 'utf-8' }); + return parseGitHubRemoteUrl(remote); + } catch { + return undefined; + } +} + +async function safeErrorText(response: Response): Promise { + try { + const body = await response.json() as { message?: string }; + return body.message ?? response.statusText; + } catch { + return response.statusText; + } +} diff --git a/src/core/integrations/github/index.ts b/src/core/integrations/github/index.ts index 90773712..c661fc2f 100644 --- a/src/core/integrations/github/index.ts +++ b/src/core/integrations/github/index.ts @@ -2,3 +2,4 @@ export * from './types'; export * from './parseIssueUrl'; export * from './GitHubIssueFetcher'; export * from './buildIssueContext'; +export * from './GitHubPullRequestService'; diff --git a/src/core/jobs/JobQueueService.ts b/src/core/jobs/JobQueueService.ts new file mode 100644 index 00000000..6aff5841 --- /dev/null +++ b/src/core/jobs/JobQueueService.ts @@ -0,0 +1,110 @@ +import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs'; +import { dirname, join } from 'path'; +import { randomUUID } from 'crypto'; + +export type MitiiJobStatus = 'queued' | 'running' | 'completed' | 'failed'; + +export interface MitiiJob { + id: string; + prompt: string; + cwd: string; + mode: 'ask' | 'plan' | 'agent' | 'review'; + status: MitiiJobStatus; + createdAt: number; + updatedAt: number; + leaseUntil?: number; + attempts: number; + resultPath?: string; + error?: string; +} + +export class JobQueueService { + private readonly queuePath: string; + private readonly completedDir: string; + + constructor(private readonly workspace: string) { + this.queuePath = join(workspace, '.mitii', 'jobs', 'queue.json'); + this.completedDir = join(workspace, '.mitii', 'jobs', 'completed'); + } + + enqueue(input: { prompt: string; cwd?: string; mode?: MitiiJob['mode'] }): MitiiJob { + const jobs = this.readQueue(); + const now = Date.now(); + const job: MitiiJob = { + id: randomUUID(), + prompt: input.prompt, + cwd: input.cwd ?? this.workspace, + mode: input.mode ?? 'agent', + status: 'queued', + createdAt: now, + updatedAt: now, + attempts: 0, + }; + jobs.push(job); + this.writeQueue(jobs); + return job; + } + + list(): MitiiJob[] { + return this.readQueue(); + } + + lease(workerId: string, leaseMs = 10 * 60 * 1000): MitiiJob | undefined { + const now = Date.now(); + const jobs = this.readQueue(); + const job = jobs.find((item) => + item.status === 'queued' || (item.status === 'running' && (item.leaseUntil ?? 0) < now) + ); + if (!job) return undefined; + job.status = 'running'; + job.updatedAt = now; + job.leaseUntil = now + leaseMs; + job.attempts += 1; + job.resultPath = workerId; + this.writeQueue(jobs); + return job; + } + + complete(id: string, output: string): MitiiJob | undefined { + const jobs = this.readQueue(); + const job = jobs.find((item) => item.id === id); + if (!job) return undefined; + mkdirSync(this.completedDir, { recursive: true }); + const resultPath = join(this.completedDir, `${id}.md`); + writeFileSync(resultPath, output, 'utf8'); + job.status = 'completed'; + job.updatedAt = Date.now(); + job.leaseUntil = undefined; + job.resultPath = resultPath; + this.writeQueue(jobs); + return job; + } + + fail(id: string, error: string): MitiiJob | undefined { + const jobs = this.readQueue(); + const job = jobs.find((item) => item.id === id); + if (!job) return undefined; + job.status = 'failed'; + job.updatedAt = Date.now(); + job.leaseUntil = undefined; + job.error = error; + this.writeQueue(jobs); + return job; + } + + private readQueue(): MitiiJob[] { + try { + const parsed = JSON.parse(readFileSync(this.queuePath, 'utf8')) as { jobs?: MitiiJob[] }; + return Array.isArray(parsed.jobs) ? parsed.jobs : []; + } catch { + return []; + } + } + + private writeQueue(jobs: MitiiJob[]): void { + mkdirSync(dirname(this.queuePath), { recursive: true }); + const tmp = `${this.queuePath}.${process.pid}.tmp`; + writeFileSync(tmp, `${JSON.stringify({ jobs }, null, 2)}\n`, 'utf8'); + renameSync(tmp, this.queuePath); + } +} diff --git a/src/core/jobs/index.ts b/src/core/jobs/index.ts new file mode 100644 index 00000000..fa8b5501 --- /dev/null +++ b/src/core/jobs/index.ts @@ -0,0 +1 @@ +export * from './JobQueueService'; diff --git a/src/core/llm/LlmProviderRegistry.ts b/src/core/llm/LlmProviderRegistry.ts index 96bff0d8..00da615d 100644 --- a/src/core/llm/LlmProviderRegistry.ts +++ b/src/core/llm/LlmProviderRegistry.ts @@ -42,7 +42,7 @@ export class LlmProviderRegistry { model: options.model ?? '', apiVersion: options.apiVersion ?? '2024-10-21', region: options.region ?? 'us-east-1', - apiKeyRef: 'thunder.apiKey', + apiKeyRef: 'mitii.apiKey', contextWindow: options.contextWindow ?? 8192, supportsStreaming: options.supportsStreaming ?? true, supportsTools: options.supportsTools ?? true, diff --git a/src/core/mcp/builtinServers.ts b/src/core/mcp/builtinServers.ts index b5e1b67b..9885e628 100644 --- a/src/core/mcp/builtinServers.ts +++ b/src/core/mcp/builtinServers.ts @@ -8,6 +8,7 @@ export const BUILTIN_MCP_SERVER_NAMES = [ 'memory', 'sequential-thinking', 'puppeteer', + 'agentmemory', ] as const; export type BuiltinMcpServerName = (typeof BUILTIN_MCP_SERVER_NAMES)[number]; @@ -49,9 +50,27 @@ export function buildBuiltinMcpServers(workspace: string): Record { + try { + const response = await fetch(url, { method: 'GET' }); + return response.ok; + } catch { + return false; + } +} diff --git a/src/core/mcp/mcpToggles.ts b/src/core/mcp/mcpToggles.ts index 9b4caf93..bbf9cd03 100644 --- a/src/core/mcp/mcpToggles.ts +++ b/src/core/mcp/mcpToggles.ts @@ -5,6 +5,7 @@ export interface McpToggles { memory: boolean; sequentialThinking: boolean; puppeteer: boolean; + agentmemory?: boolean; } export const defaultMcpToggles = (): McpToggles => ({ @@ -12,6 +13,7 @@ export const defaultMcpToggles = (): McpToggles => ({ memory: true, sequentialThinking: true, puppeteer: false, + agentmemory: false, }); export function mcpToggleKeyToServerName(key: keyof McpToggles): string { @@ -24,6 +26,7 @@ export function mcpServerNameToToggleKey(name: string): keyof McpToggles | undef if (name === 'memory') return 'memory'; if (name === 'sequential-thinking') return 'sequentialThinking'; if (name === 'puppeteer') return 'puppeteer'; + if (name === 'agentmemory') return 'agentmemory'; return undefined; } diff --git a/src/core/mcp/mcpWorkspaceConfig.ts b/src/core/mcp/mcpWorkspaceConfig.ts index cc8291c6..128efe14 100644 --- a/src/core/mcp/mcpWorkspaceConfig.ts +++ b/src/core/mcp/mcpWorkspaceConfig.ts @@ -75,6 +75,25 @@ export function saveCustomMcpServers( return payload; } +export function connectAgentMemoryMcp(workspace: string): Record { + const servers = loadWorkspaceMcpServers(workspace); + const next: Record = { + ...servers, + agentmemory: { + disabled: false, + type: 'streamable-http', + command: '', + args: [], + env: {}, + url: 'http://localhost:3111/mcp', + headers: {}, + timeoutMs: 30_000, + }, + }; + writeWorkspaceMcpServers(workspace, next); + return next; +} + export function loadWorkspaceMcpServers(workspace: string): Record { if (!workspace.trim()) return {}; const merged: Record = {}; diff --git a/src/core/mcp/scaffoldMitiiWorkspace.ts b/src/core/mcp/scaffoldMitiiWorkspace.ts index 00302c32..3abb409a 100644 --- a/src/core/mcp/scaffoldMitiiWorkspace.ts +++ b/src/core/mcp/scaffoldMitiiWorkspace.ts @@ -1,8 +1,10 @@ -import { existsSync, writeFileSync } from 'fs'; +import { existsSync, readFileSync, writeFileSync } from 'fs'; import { join } from 'path'; import { ensureThunderDir } from '../indexing/paths'; +import { DEFAULT_GITIGNORE_PATTERNS, DEFAULT_WORKSPACE_IGNORE_PATTERNS } from '../indexing/indexingPolicy'; import { AGENT_NAME } from '../../shared/brand'; import { installBundledSkills } from '../skills/installBundledSkills'; +import { installBundledRules } from '../rules/installBundledRules'; const MCP_TEMPLATE = { mcpServers: {}, @@ -14,7 +16,7 @@ This folder stores ${AGENT_NAME} runtime data for this workspace. ## MCP servers -Built-in servers load automatically when \`thunder.mcp.preloadBuiltin\` is enabled (default): +Built-in servers load automatically when \`mitii.mcp.preloadBuiltin\` is enabled (default): | Server | Package | |--------|---------| @@ -22,7 +24,7 @@ Built-in servers load automatically when \`thunder.mcp.preloadBuiltin\` is enabl | memory | @modelcontextprotocol/server-memory | | sequential-thinking | @modelcontextprotocol/server-sequential-thinking | -To add custom MCP servers, edit \`mcp.json\` in this folder or set \`thunder.mcp.servers\` in VS Code settings. +To add custom MCP servers, edit \`mcp.json\` in this folder or set \`mitii.mcp.servers\` in VS Code settings. Workspace \`mcp.json\` entries override built-ins with the same name. Example: @@ -45,6 +47,17 @@ Example: - \`logs/\` — session logs (when enabled) - \`tasks/\` — saved plans - \`skills/\` — bundled workspace skill playbooks (copied from the extension on first init) +- \`rules/\` — bundled workspace methodology rules (copied from the extension on first init) +- \`MITTII.local.md\` — optional personal project instructions; copy from \`MITTII.local.md.example\` +`; + +const LOCAL_RULES_EXAMPLE = `# Local Mitii Instructions + +Personal notes for this workspace. This file is intentionally not meant for git. + +- Preferred verification command: +- Local services or ports: +- Project-specific cautions: `; export interface ScaffoldMitiiWorkspaceOptions { @@ -70,9 +83,37 @@ export function scaffoldMitiiWorkspace( writeFileSync(readmePath, README, 'utf-8'); } + const localRulesExamplePath = join(dir, 'MITTII.local.md.example'); + if (!existsSync(localRulesExamplePath)) { + writeFileSync(localRulesExamplePath, LOCAL_RULES_EXAMPLE, 'utf-8'); + } + + ensureIgnoreFile(join(workspace, '.mitiiignore'), DEFAULT_WORKSPACE_IGNORE_PATTERNS, '# Mitii workspace indexing ignores'); + ensureIgnoreFile(join(workspace, '.gitignore'), DEFAULT_GITIGNORE_PATTERNS, '# Mitii local runtime data'); + if (options.extensionRoot?.trim()) { installBundledSkills(workspace, options.extensionRoot, { force: options.forceBundledSkills, }); + installBundledRules(workspace, options.extensionRoot, { + force: options.forceBundledSkills, + }); } } + +function ensureIgnoreFile(path: string, patterns: readonly string[], header: string): void { + const existing = existsSync(path) ? readFileSync(path, 'utf-8') : ''; + const existingLines = new Set( + existing + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + ); + const missing = patterns.filter((pattern) => !existingLines.has(pattern)); + if (missing.length === 0) return; + + const prefix = existing.trimEnd(); + const block = [header, ...missing].join('\n'); + const next = prefix ? `${prefix}\n\n${block}\n` : `${block}\n`; + writeFileSync(path, next, 'utf-8'); +} diff --git a/src/core/memory/AutoMemoryFileWriter.ts b/src/core/memory/AutoMemoryFileWriter.ts new file mode 100644 index 00000000..77279089 --- /dev/null +++ b/src/core/memory/AutoMemoryFileWriter.ts @@ -0,0 +1,155 @@ +import { createHash } from 'crypto'; +import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, appendFileSync, rmSync } from 'fs'; +import { basename, join } from 'path'; +import { homedir } from 'os'; +import type { ContextItem, ContextQuery, ContextSource } from '../context/types'; +import type { Observation, ObservationType } from './MemoryService'; +import { filterSecrets } from './MemoryService'; +import { createLogger } from '../telemetry/Logger'; + +const log = createLogger('AutoMemoryFileWriter'); + +export type AutoMemoryScope = 'user' | 'workspace' | 'both'; + +export interface AutoMemoryOptions { + enabled?: boolean; + scope?: AutoMemoryScope; + maxRecentFiles?: number; +} + +export class AutoMemoryFileWriter { + constructor(private readonly workspace: string, private readonly options: AutoMemoryOptions = {}) {} + + writeObservation(observation: Observation): string[] { + if (this.options.enabled === false) return []; + const cleanText = filterSecrets(observation.text); + if (!cleanText) return []; + + const paths: string[] = []; + for (const dir of this.resolveDirs()) { + try { + mkdirSync(dir, { recursive: true }); + const fileName = `${formatDate(observation.createdAt)}-${slugify(observation.type)}-${slugify(cleanText).slice(0, 48)}.md`; + const filePath = join(dir, fileName); + const body = [ + `# ${titleForType(observation.type)}`, + '', + `- Type: ${observation.type}`, + `- Session: ${observation.sessionId}`, + `- Created: ${new Date(observation.createdAt).toISOString()}`, + observation.files?.length ? `- Files: ${observation.files.join(', ')}` : '', + '', + cleanText, + '', + ].filter(Boolean).join('\n'); + writeFileSync(filePath, body, { encoding: 'utf-8', mode: 0o600 }); + this.appendIndex(dir, fileName, observation.type, cleanText); + paths.push(filePath); + } catch (error) { + log.warn('Auto-memory markdown write failed', { + error: error instanceof Error ? error.message : String(error), + }); + } + } + return paths; + } + + readRecent(maxFiles = this.options.maxRecentFiles ?? 8): Array<{ relPath: string; content: string; mtimeMs: number }> { + if (this.options.enabled === false) return []; + const out: Array<{ relPath: string; content: string; mtimeMs: number }> = []; + for (const dir of this.resolveDirs()) { + if (!existsSync(dir)) continue; + for (const file of readdirSync(dir).filter((entry) => entry.endsWith('.md') && entry !== 'MEMORY.md')) { + const abs = join(dir, file); + try { + const st = statSync(abs); + if (!st.isFile() || st.size > 64_000) continue; + out.push({ relPath: displayPath(dir, file), content: readFileSync(abs, 'utf-8').slice(0, 4000), mtimeMs: st.mtimeMs }); + } catch { + // Skip unreadable memory files. + } + } + } + return out.sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, maxFiles); + } + + prune(days = 30): number { + const cutoff = Date.now() - days * 24 * 60 * 60 * 1000; + let removed = 0; + for (const dir of this.resolveDirs()) { + if (!existsSync(dir)) continue; + for (const file of readdirSync(dir).filter((entry) => entry.endsWith('.md') && entry !== 'MEMORY.md')) { + const abs = join(dir, file); + try { + const st = statSync(abs); + if (st.isFile() && st.mtimeMs < cutoff) { + rmSync(abs, { force: true }); + removed += 1; + } + } catch { + // Skip unreadable entries. + } + } + } + return removed; + } + + private appendIndex(dir: string, fileName: string, type: ObservationType, text: string): void { + const indexPath = join(dir, 'MEMORY.md'); + if (!existsSync(indexPath)) { + writeFileSync(indexPath, '# Mitii Auto-Memory\n\n', { encoding: 'utf-8', mode: 0o600 }); + } + appendFileSync(indexPath, `- ${new Date().toISOString()} [${type}](./${fileName}) - ${text.replace(/\s+/g, ' ').slice(0, 140)}\n`, 'utf-8'); + } + + private resolveDirs(): string[] { + const scope = this.options.scope ?? 'user'; + const dirs: string[] = []; + if (scope === 'user' || scope === 'both') { + dirs.push(join(homedir(), '.mitii', 'projects', projectHash(this.workspace), 'memory')); + } + if (scope === 'workspace' || scope === 'both') { + dirs.push(join(this.workspace, '.mitii', 'auto-memory')); + } + return dirs; + } +} + +export class AutoMemoryContextSource implements ContextSource { + readonly id = 'auto-memory'; + + constructor(private readonly writer: AutoMemoryFileWriter) {} + + async retrieve(_query: ContextQuery): Promise { + return this.writer.readRecent().map((memory, index) => ({ + id: `auto-memory-${index}-${basename(memory.relPath)}`, + source: 'auto-memory', + relPath: memory.relPath, + content: memory.content, + score: 7, + reason: 'Recent auto-memory markdown', + tokenEstimate: Math.ceil(memory.content.length / 4), + })); + } +} + +function projectHash(workspace: string): string { + return createHash('sha1').update(workspace).digest('hex').slice(0, 16); +} + +function formatDate(ts: number): string { + return new Date(ts).toISOString().slice(0, 10); +} + +function slugify(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'memory'; +} + +function titleForType(type: ObservationType): string { + return type.split('_').map((part) => part[0]?.toUpperCase() + part.slice(1)).join(' '); +} + +function displayPath(dir: string, file: string): string { + if (dir.includes('/.mitii/auto-memory')) return `.mitii/auto-memory/${file}`; + return `~/.mitii/projects/.../memory/${file}`; +} diff --git a/src/core/memory/MemoryService.ts b/src/core/memory/MemoryService.ts index c7231e0b..4f9b230f 100644 --- a/src/core/memory/MemoryService.ts +++ b/src/core/memory/MemoryService.ts @@ -258,7 +258,7 @@ function reciprocalRankFusion(lists: Observation[][], limit: number): Observatio .map((e) => e.obs); } -function filterSecrets(text: string): string | null { +export function filterSecrets(text: string): string | null { for (const pattern of SECRET_PATTERNS) { if (pattern.test(text)) return null; } diff --git a/src/core/memory/index.ts b/src/core/memory/index.ts index 37d8a9cb..9fedf055 100644 --- a/src/core/memory/index.ts +++ b/src/core/memory/index.ts @@ -1 +1,2 @@ export * from './MemoryService'; +export * from './AutoMemoryFileWriter'; diff --git a/src/core/modes/agent/ActIntentRouter.ts b/src/core/modes/agent/ActIntentRouter.ts index 6a594824..083e2640 100644 --- a/src/core/modes/agent/ActIntentRouter.ts +++ b/src/core/modes/agent/ActIntentRouter.ts @@ -1,7 +1,8 @@ + import type { ThunderMode } from '../../session/ThunderSession'; import type { TaskAnalysis } from '../../runtime/TaskAnalyzer'; import { isApprovalContinuationMessage } from '../../runtime/taskMessage'; -import type { ActRoute } from './actTypes'; +import type { ActDepth, ActRoute } from './actTypes'; export interface ActRouteOptions { mode?: ThunderMode; @@ -10,14 +11,34 @@ export interface ActRouteOptions { auditMode?: boolean; mdxRepairMode?: boolean; githubIssueMode?: boolean; + actDepth?: ActDepth; } -const DOCS_HINT = /\b(docs?|documentation|docusaurus|mdx?|examples?)\b/i; +const DOCS_HINT = /\b(docs?|documentation|docusaurus|mdx?|examples?|readme|changelog)\b/i; const REFACTOR_HINT = /\b(refactor|rewrite|migrate|cleanup architecture|restructure)\b/i; const BUGFIX_HINT = /\b(fix|debug|repair|failing|failed|error|bug|regression|broken|crash|compile|test failure)\b/i; +const INFRA_HINT = /\b(ci\/cd|pipeline|workflows?|github actions|docker|config|infrastructure|deployment|terraform)\b/i; +const CREATE_HINT = /\b(write|create|build|generate|scaffold)\b/i; +// Fixed: Added '?' to make quantifiers lazy and prevent backtracking stalls const ACTIVE_PLAN_NEW_TASK = - /\b(?:new|different|separate|another)\s+task\b|\b(?:ignore|discard|cancel|drop|replace)\b[\s\S]{0,80}\b(?:the|this|that|saved|current|existing)?\s*plan\b|\b(?:do not|don't)\b[\s\S]{0,80}\b(?:use|resume|execute|follow)\b[\s\S]{0,80}\bplan\b/i; + /\b(?:new|different|separate|another)\s+task\b|\b(?:ignore|discard|cancel|drop|replace)\b[\s\S]{0,80}?\b(?:the|this|that|saved|current|existing)?\s*plan\b|\b(?:do not|don't)\b[\s\S]{0,80}?\b(?:use|resume|execute|follow)\b[\s\S]{0,80}?\bplan\b/i; + +const DIRECT_ROUTE_OVERRIDE = + /(?:^|\s)\/(?:fast|direct|no-?plan)\b|\b(?:no plan|without planning|skip (?:the )?(?:plan|planner|planning)|do not plan|don't plan|directly without (?:a )?plan|direct mode|fast mode)\b/i; + +// Fixed: Added '?' to make quantifier lazy +const EXPLICIT_PLAN_HANDOFF = + /\b(?:execute|implement|run|follow|resume|continue with)\b[\s\S]{0,40}?\b(?:the|this|saved|current|active)?\s*plan\b|\bplan looks good\b|\bexecute the plan\b/i; + +const CONTINUATION_HANDOFF = + /^(?:please\s+)?(?:go ahead|continue|proceed|do it|yes|yep|yeah|ok(?:ay)?|approved|looks good|sounds good|ship it)(?:\s+please)?[.!]*$/i; + +const REFERENTIAL_HANDOFF = + /^(?:please\s+)?(?:fix|implement|apply|do|run|execute|continue|resume)\s+(?:it|that|this)(?:\s+please)?[.!]*$/i; + +const PLANNED_WORK_REFERENCE = + /\b(?:we planned|planned work|from the plan|per the plan|according to the plan|as planned)\b/i; export function routeActIntent(userMessage: string, analysis: TaskAnalysis, options: ActRouteOptions = {}): ActRoute { const mode = options.mode ?? 'agent'; @@ -26,6 +47,7 @@ export function routeActIntent(userMessage: string, analysis: TaskAnalysis, opti const githubIssueMode = Boolean(options.githubIssueMode); const hasActivePlan = Boolean(options.hasActivePlan); const orchestrationEnabled = options.orchestrationEnabled ?? true; + const actDepth = options.actDepth ?? 'auto'; if (mode !== 'agent') { return { @@ -39,7 +61,10 @@ export function routeActIntent(userMessage: string, analysis: TaskAnalysis, opti }; } - if (!isApprovalContinuationMessage(userMessage) && shouldResumeSavedPlan(userMessage, hasActivePlan)) { + // Evaluate once and pass down to avoid redundant regex execution + const isDirectOverride = hasDirectRouteOverride(userMessage); + + if (!isApprovalContinuationMessage(userMessage) && shouldResumeSavedPlan(userMessage, hasActivePlan, isDirectOverride, { actDepth })) { return { intent: 'resume_plan', executionPath: 'resume_saved_plan', @@ -75,7 +100,10 @@ export function routeActIntent(userMessage: string, analysis: TaskAnalysis, opti }; } - const shouldUsePlanner = shouldUsePlannerForAct(analysis, orchestrationEnabled, auditMode); + const shouldUsePlanner = shouldUsePlannerForAct(analysis, orchestrationEnabled, auditMode, actDepth, { + directOverride: isDirectOverride, + }); + if (githubIssueMode) { return { intent: 'bugfix', @@ -105,32 +133,63 @@ export function routeActIntent(userMessage: string, analysis: TaskAnalysis, opti }; } -export function shouldResumeSavedPlan(userMessage: string, hasActivePlan: boolean): boolean { +export function shouldResumeSavedPlan( + userMessage: string, + hasActivePlan: boolean, + isDirectOverride = false, + options: { actDepth?: ActDepth } = {} +): boolean { if (!hasActivePlan) return false; const text = userMessage.trim(); if (!text) return false; + if (isDirectOverride) return false; // Use the boolean if (ACTIVE_PLAN_NEW_TASK.test(text)) return false; - return true; + if (options.actDepth === 'quick') { + return EXPLICIT_PLAN_HANDOFF.test(text); + } + return ( + EXPLICIT_PLAN_HANDOFF.test(text) || + CONTINUATION_HANDOFF.test(text) || + REFERENTIAL_HANDOFF.test(text) || + PLANNED_WORK_REFERENCE.test(text) + ); } export function shouldUsePlannerForAct( analysis: TaskAnalysis, orchestrationEnabled: boolean, - auditMode = false + auditMode = false, + actDepth: ActDepth = 'auto', + options: { directOverride?: boolean } = {} ): boolean { + if (analysis.kind === 'simple_edit' || analysis.kind === 'question' || analysis.kind === 'debugging') return false; + if (options.directOverride) return false; + if (actDepth === 'quick') return false; if (!analysis.shouldPlan) return false; if (!orchestrationEnabled) return false; if (auditMode) return false; return true; } +export function hasDirectRouteOverride(userMessage: string): boolean { + return DIRECT_ROUTE_OVERRIDE.test(userMessage); +} + function inferActIntent(userMessage: string, analysis: TaskAnalysis): ActRoute['intent'] { if (analysis.kind === 'audit') return 'audit'; if (analysis.kind === 'question') return 'question'; + if (analysis.kind === 'debugging') return 'diagnose'; + if (DOCS_HINT.test(userMessage)) return 'docs'; if (REFACTOR_HINT.test(userMessage)) return 'refactor'; - if (BUGFIX_HINT.test(userMessage) || analysis.kind === 'simple_edit') return 'bugfix'; + if (analysis.kind === 'implementation' || analysis.kind === 'explicit_plan') return 'feature'; + + // Catch workflows and "write/create" requests and elevate them to features + if (INFRA_HINT.test(userMessage) || CREATE_HINT.test(userMessage)) return 'feature'; + + if (BUGFIX_HINT.test(userMessage) || analysis.kind === 'simple_edit') return 'bugfix'; + return 'direct'; } diff --git a/src/core/modes/agent/ActOrchestrator.ts b/src/core/modes/agent/ActOrchestrator.ts index f31fef40..c7c4c57d 100644 --- a/src/core/modes/agent/ActOrchestrator.ts +++ b/src/core/modes/agent/ActOrchestrator.ts @@ -39,6 +39,7 @@ export class ActOrchestrator { auditMode: options.auditMode, mdxRepairMode: options.mdxRepairMode, githubIssueMode: options.githubIssueMode, + actDepth: options.actDepth, }); const catalog = options.catalog ?? (options.workspaceRoot ? loadProjectCatalog(options.workspaceRoot) : undefined); const scope = resolvePlanScope(userMessage, catalog); diff --git a/src/core/modes/agent/actPrompts.ts b/src/core/modes/agent/actPrompts.ts index 64ed7308..339b0374 100644 --- a/src/core/modes/agent/actPrompts.ts +++ b/src/core/modes/agent/actPrompts.ts @@ -31,6 +31,18 @@ export function buildActPromptContext( '- Run project-appropriate verification after implementation (discovered from package.json, not hardcoded).', ]; + if (route.intent === 'diagnose') { + lines.push( + '', + '## Diagnosis-first request', + '- Read the referenced file(s)/logs and identify the root cause before changing anything.', + '- Trace the failure to the code that actually enforces/produces it (e.g. a policy check, validator, or spawn call) — do not assume a file is the cause just because it shares a name or path with the symptom.', + '- If the user asked you to fix something, fix it. "Document the limitation" or "explain why this is restricted" is not an acceptable substitute for a fix unless the restriction is truly intentional and the user should be told to change their request instead.', + '- Report findings directly; only apply a minimal fix if the cause is obvious and scoped to what was read.', + '- Do not expand scope into a broader refactor or feature unless the user asks for one.' + ); + } + if (route.executionPath === 'resume_saved_plan') { lines.push( '', diff --git a/src/core/modes/agent/actSkillRouting.ts b/src/core/modes/agent/actSkillRouting.ts index b526c088..29701dd0 100644 --- a/src/core/modes/agent/actSkillRouting.ts +++ b/src/core/modes/agent/actSkillRouting.ts @@ -23,6 +23,7 @@ export function resolveActSkillNames(intent: ActIntent, taskAnalysis?: TaskAnaly intent === 'resume_plan' || intent === 'bugfix' || intent === 'mdx_repair' || + intent === 'diagnose' || /\b(error|failing|failed|debug|repair|fix)\b/i.test(taskAnalysis?.summary ?? '') ) { names.push('debugging-and-error-recovery'); diff --git a/src/core/modes/agent/actTypes.ts b/src/core/modes/agent/actTypes.ts index 2ad9a3df..aec6bdef 100644 --- a/src/core/modes/agent/actTypes.ts +++ b/src/core/modes/agent/actTypes.ts @@ -11,7 +11,8 @@ export type ActIntent = | 'audit' | 'mdx_repair' | 'direct' - | 'question'; + | 'question' + | 'diagnose'; export type ActExecutionPath = | 'resume_saved_plan' diff --git a/src/core/modes/ask/AskIntentRouter.ts b/src/core/modes/ask/AskIntentRouter.ts index 99129cfb..f5b88f27 100644 --- a/src/core/modes/ask/AskIntentRouter.ts +++ b/src/core/modes/ask/AskIntentRouter.ts @@ -7,7 +7,7 @@ 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|@[\w./-]+/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|how to run|what command|which command|which script|npm run|pnpm run|yarn run|npm scripts|benchmark)\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; diff --git a/src/core/modes/ask/ProjectCatalog.ts b/src/core/modes/ask/ProjectCatalog.ts index ec8d59af..c0cc5d8c 100644 --- a/src/core/modes/ask/ProjectCatalog.ts +++ b/src/core/modes/ask/ProjectCatalog.ts @@ -252,7 +252,7 @@ function isDirectory(path: string): boolean { } function isProjectScopeQuestion(text: string): boolean { - return /\b(project|package|workspace|monorepo|docs?|docusaurus|website|extension|app|library|service|scope|where is|sidebar|documentation|form-builder|ffb-mui)\b/i.test(text); + return /\b(project|package|workspace|monorepo|docs?|docusaurus|website|extension|app|library|service|scope|where is|sidebar|documentation|form-builder|ffb-mui|run|command|script|npm|pnpm|yarn|build|test|benchmark|eval|lint|compile|install|setup)\b/i.test(text); } /** Discover top-level docs content areas from sidebars and docs folders. */ diff --git a/src/core/modes/plan/PlanIntentRouter.ts b/src/core/modes/plan/PlanIntentRouter.ts index dc0966cd..de2285b8 100644 --- a/src/core/modes/plan/PlanIntentRouter.ts +++ b/src/core/modes/plan/PlanIntentRouter.ts @@ -1,6 +1,9 @@ import { routeAskIntent } from '../ask/AskIntentRouter'; import type { TaskAnalysis, TaskComplexity } from '../../runtime/TaskAnalyzer'; import type { PlanIntent, PlanRoute } from './planTypes'; +import { createLogger } from '../../telemetry/Logger'; + +const log = createLogger('PlanIntentRouter'); const GREETING_RE = /^(hi|hello|hey|thanks|thank you|ok|okay)\b/i; const DOCS_RE = /\b(docs?|documentation|docusaurus|mdx?|examples?)\b/i; @@ -24,7 +27,7 @@ export function routePlanIntent(userMessage: string, taskAnalysis?: TaskAnalysis intent === 'audit' || intent === 'spike')); - return { + const route: PlanRoute = { intent, complexity, forcePlan, @@ -33,6 +36,9 @@ export function routePlanIntent(userMessage: string, taskAnalysis?: TaskAnalysis qualityProfile: complexity === 'high' || intent === 'audit' ? 'strict' : intent === 'question' ? 'relaxed' : 'standard', summary: summarizeRoute(intent, complexity, forcePlan), }; + + log.debug('Routed plan intent', { intent, complexity, forcePlan, groundingRequired, shouldUseSubagents }); + return route; } function inferPlanIntent(text: string, taskAnalysis?: TaskAnalysis): PlanIntent { diff --git a/src/core/modes/plan/PlanOrchestrator.ts b/src/core/modes/plan/PlanOrchestrator.ts index f306c742..cb294f57 100644 --- a/src/core/modes/plan/PlanOrchestrator.ts +++ b/src/core/modes/plan/PlanOrchestrator.ts @@ -7,6 +7,9 @@ import { resolvePlanScope } from './PlanScopeResolver'; import { buildPlanPromptContext } from './planPrompts'; import { loadPlanningSkillPlaybooks, resolvePlanningSkillNames } from './planSkillRouting'; import type { PlanDepth, PlanRunPlan } from './planTypes'; +import { createLogger } from '../../telemetry/Logger'; + +const log = createLogger('PlanOrchestrator'); export interface PlanPrepareOptions { workspaceRoot?: string; @@ -21,9 +24,19 @@ export interface PlanPrepareOptions { export class PlanOrchestrator { static prepare(userMessage: string, options: PlanPrepareOptions = {}): PlanRunPlan { + log.debug('Preparing plan', { + messageLength: userMessage.length, + workspaceRoot: options.workspaceRoot, + planDepth: options.planDepth, + }); + const route = routePlanIntent(userMessage, options.taskAnalysis); + log.debug('Plan route resolved', { route }); + const catalog = options.catalog ?? (options.workspaceRoot ? loadProjectCatalog(options.workspaceRoot) : undefined); const scope = resolvePlanScope(userMessage, catalog); + log.debug('Plan scope resolved', { status: scope.status, reason: scope.reason, projectCount: scope.projects.length }); + const discoveryMaxSteps = resolvePlanDiscoveryMaxSteps( route.complexity, route.intent, @@ -36,6 +49,17 @@ export class PlanOrchestrator { suggestedSkills ); + const autoContinue = Boolean(options.planAutoContinue ?? (route.groundingRequired && route.complexity === 'high')); + const maxAutoContinues = resolvePlanMaxAutoContinues(route.complexity, route.intent, options.planMaxAutoContinues); + + log.debug('Plan prepared', { + discoveryMaxSteps, + autoContinue, + maxAutoContinues, + suggestedSkills, + appliedSkills, + }); + return { route, catalog, @@ -45,8 +69,8 @@ export class PlanOrchestrator { appliedSkills, }), discoveryMaxSteps, - autoContinue: Boolean(options.planAutoContinue ?? (route.groundingRequired && route.complexity === 'high')), - maxAutoContinues: resolvePlanMaxAutoContinues(route.complexity, route.intent, options.planMaxAutoContinues), + autoContinue, + maxAutoContinues, suggestedSkills, skillPlaybookContext, appliedSkills, diff --git a/src/core/modes/plan/PlanScopeResolver.ts b/src/core/modes/plan/PlanScopeResolver.ts index e1520ae0..236b8d14 100644 --- a/src/core/modes/plan/PlanScopeResolver.ts +++ b/src/core/modes/plan/PlanScopeResolver.ts @@ -1,6 +1,15 @@ import type { AskScopeResolution, ProjectCatalog } from '../ask/askTypes'; import { resolveAskScope } from '../ask/AskScopeResolver'; +import { createLogger } from '../../telemetry/Logger'; + +const log = createLogger('PlanScopeResolver'); export function resolvePlanScope(userMessage: string, catalog?: ProjectCatalog): AskScopeResolution { - return resolveAskScope(userMessage, catalog); + const scope = resolveAskScope(userMessage, catalog); + log.debug('Resolved plan scope', { + status: scope.status, + reason: scope.reason, + projects: scope.projects.map((project) => project.id), + }); + return scope; } diff --git a/src/core/modes/plan/planPrompts.ts b/src/core/modes/plan/planPrompts.ts index 7db5600e..fe1382c6 100644 --- a/src/core/modes/plan/planPrompts.ts +++ b/src/core/modes/plan/planPrompts.ts @@ -2,6 +2,9 @@ import type { ProjectCatalog } from '../ask/askTypes'; import { formatProjectCatalog } from '../ask/ProjectCatalog'; import type { PlanRoute } from './planTypes'; import type { AskScopeResolution } from '../ask/askTypes'; +import { createLogger } from '../../telemetry/Logger'; + +const log = createLogger('PlanPrompts'); export function buildPlanPromptContext( userMessage: string, @@ -49,5 +52,7 @@ export function buildPlanPromptContext( } lines.push('', `Original Plan request: ${userMessage}`); - return lines.join('\n'); + const context = lines.join('\n'); + log.debug('Built plan prompt context', { chars: context.length, scopeStatus: scope.status }); + return context; } diff --git a/src/core/modes/plan/planSkillRouting.ts b/src/core/modes/plan/planSkillRouting.ts index a6d72b66..fce64920 100644 --- a/src/core/modes/plan/planSkillRouting.ts +++ b/src/core/modes/plan/planSkillRouting.ts @@ -1,6 +1,9 @@ import type { TaskAnalysis } from '../../runtime/TaskAnalyzer'; import type { SkillCatalogService } from '../../skills/SkillCatalogService'; import type { PlanIntent } from './planTypes'; +import { createLogger } from '../../telemetry/Logger'; + +const log = createLogger('PlanSkillRouting'); const MAX_SKILL_CHARS = 24_000; @@ -24,7 +27,9 @@ export function resolvePlanningSkillNames( names.push('debugging-and-error-recovery'); } - return [...new Set(names)]; + const resolved = [...new Set(names)]; + log.debug('Resolved planning skill names', { intent, taskKind: taskAnalysis?.kind, resolved }); + return resolved; } export function loadPlanningSkillPlaybooks( @@ -32,16 +37,21 @@ export function loadPlanningSkillPlaybooks( skillNames: string[] ): { context: string; loaded: string[] } { if (!catalog || skillNames.length === 0) { + log.debug('Skipping planning skill playbook load', { hasCatalog: Boolean(catalog), skillNames }); return { context: '', loaded: [] }; } const loaded: string[] = []; + const skipped: string[] = []; const blocks: string[] = []; let totalChars = 0; for (const name of skillNames) { const skill = catalog.get(name); - if (!skill) continue; + if (!skill) { + skipped.push(name); + continue; + } const block = [ `### Skill: ${skill.entry.name}`, @@ -49,13 +59,25 @@ export function loadPlanningSkillPlaybooks( skill.content.trim(), ].join('\n\n'); - if (totalChars + block.length > MAX_SKILL_CHARS) break; + if (totalChars + block.length > MAX_SKILL_CHARS) { + skipped.push(name); + break; + } blocks.push(block); loaded.push(skill.entry.name); totalChars += block.length; } - if (blocks.length === 0) return { context: '', loaded: [] }; + if (skipped.length > 0) { + log.debug('Some planning skills were not loaded', { skipped, budgetChars: MAX_SKILL_CHARS }); + } + + if (blocks.length === 0) { + log.debug('No planning skill playbooks loaded'); + return { context: '', loaded: [] }; + } + + log.debug('Loaded planning skill playbooks', { loaded, totalChars }); return { context: [ diff --git a/src/core/orchestration/ChatOrchestrator.ts b/src/core/orchestration/ChatOrchestrator.ts index fdbe060f..3dbd7a9d 100644 --- a/src/core/orchestration/ChatOrchestrator.ts +++ b/src/core/orchestration/ChatOrchestrator.ts @@ -32,7 +32,7 @@ import { AgentLoop, type ApprovedToolResult, type AgentLoopSuspendState } from ' import { isSkippedToolOutput } from '../runtime/toolSkip'; import { PlanExecutor } from '../runtime/PlanExecutor'; import { analyzeTask, type TaskAnalysis } from '../runtime/TaskAnalyzer'; -import { extractOriginalTaskMessage, isApprovalContinuationMessage } from '../runtime/taskMessage'; +import { extractOriginalTaskMessage, isApprovalContinuationMessage, resolveConversationTaskMessage } from '../runtime/taskMessage'; import { compactMessagesWithLlm } from '../runtime/ContextCompaction'; import { getMaxInputTokens } from '../runtime/PromptBudget'; import { isAuditCleanupTask, AUDIT_AGENT_MAX_STEPS } from '../runtime/taskKind'; @@ -46,13 +46,19 @@ import { PlanOrchestrator } from '../modes/plan/PlanOrchestrator'; import { filterPlanModeTools, needsPlanGrounding } from '../modes/plan/planMode'; import { loadPlanningSkillPlaybooks, resolvePlanningSkillNames } from '../modes/plan/planSkillRouting'; import { routePlanIntent } from '../modes/plan/PlanIntentRouter'; -import { ActOrchestrator, filterActModeTools, shouldResumeSavedPlan, shouldUsePlannerForAct } from '../modes/agent'; +import { + ActOrchestrator, + filterActModeTools, + hasDirectRouteOverride, + shouldResumeSavedPlan, + shouldUsePlannerForAct, +} from '../modes/agent'; import { extractMdxErrorFile, isMdxRepairTask, suggestDocsVerifyCommands, } from '../runtime/mdxRepairRouting'; -import { setResearchAgentRuntime } from '../tools/builtinTools'; +import { setSubagentRuntime } from '../tools/builtinTools'; import type { SessionService } from '../session/SessionService'; import type { PlanPersistence } from '../plans/PlanPersistence'; import type { MemoryExtractor } from '../runtime/MemoryExtractor'; @@ -314,7 +320,8 @@ export class ChatOrchestrator { const agentConfig = this.deps.agentConfig; const originalTaskMessage = extractOriginalTaskMessage(userMessage) ?? userMessage; - const taskEnrichment = await enrichTask(originalTaskMessage, { + const conversationTaskMessage = resolveConversationTaskMessage(originalTaskMessage, recentMessages); + const taskEnrichment = await enrichTask(conversationTaskMessage, { github: { enabled: this.deps.githubIssueFetchEnabled ?? true, allowNetwork: Boolean(this.deps.allowNetwork?.()), @@ -355,6 +362,9 @@ export class ChatOrchestrator { askMaxAutoContinues: agentConfig?.askMaxAutoContinues, }) : undefined; + if (isPlanMode) { + log.debug('Entering plan mode', { sessionId: session.id, taskKind: taskAnalysis.kind, complexity: taskAnalysis.complexity }); + } const planPlan = isPlanMode ? PlanOrchestrator.prepare(taskForClassification, { workspaceRoot: this.deps.workspace, @@ -540,7 +550,8 @@ export class ChatOrchestrator { this.agentLoop?.clearSuspendState(); } const plannerEnabled = actPlan?.route.shouldUsePlanner - ?? shouldUsePlanner(session.mode, taskAnalysis, orchestrationEnabled, auditMode); + ?? shouldUsePlanner(session.mode, taskAnalysis, orchestrationEnabled, auditMode, agentConfig?.actDepth); + const requiresAgentWrite = shouldRequireAgentWrite(session.mode, taskAnalysis.kind, auditMode); const subagentsEnabled = (agentConfig?.subagentsEnabled ?? true) && !auditMode && @@ -551,7 +562,7 @@ export class ChatOrchestrator { : (actPlan?.route.shouldUseSubagents ?? taskAnalysis.shouldUseSubagents)); let tools = toolsEnabled ? toolsToDefinitions(this.deps.toolRuntime!.list()).filter((tool) => - subagentsEnabled || tool.function.name !== 'spawn_research_agent' + subagentsEnabled || !['spawn_research_agent', 'spawn_subagent'].includes(tool.function.name) ) : []; if (isAskMode) { @@ -561,15 +572,18 @@ export class ChatOrchestrator { } if (toolsEnabled && this.deps.toolExecutor) { - setResearchAgentRuntime({ + setSubagentRuntime({ toolExecutor: this.deps.toolExecutor, getProvider: () => this.deps.researchAgentProvider ?? provider, getTools: () => tools, maxSteps: agentConfig?.researchAgentMaxSteps, timeoutMs: agentConfig?.researchAgentTimeoutMs, + enabledTypes: agentConfig?.subagentTypesEnabled, + maxConcurrent: agentConfig?.maxConcurrentSubagents, + workspace: this.deps.workspace, }); } else { - setResearchAgentRuntime(undefined); + setSubagentRuntime(undefined); } if (auditMode) { @@ -606,6 +620,7 @@ export class ChatOrchestrator { auditMode, mdxRepairMode, toolsEnabled, + requiresAgentWrite, }); this.saveTurn(session.id, 'user', userMessage); @@ -773,6 +788,7 @@ export class ChatOrchestrator { } if (session.mode === 'plan' && this.agentLoop?.hadPendingApproval()) { + log.debug('Plan paused for clarification', { sessionId: session.id }); this.suspendContext = { session, provider, @@ -840,6 +856,7 @@ export class ChatOrchestrator { const requirementAnalysis = requirementAnalysisText.trim() || extractRequirementAnalysis(fullResponse); + let planQualityIssues: string[] = []; const plan = await this.planExecutor.generatePlan( provider, session.mode, @@ -853,6 +870,9 @@ export class ChatOrchestrator { workspace: this.deps.workspace, useIsolatedPlanning: true, ...planningSkillOptions, + onPlanQualityIssues: (issues) => { + planQualityIssues = issues; + }, } ); sessionTiming.end('plan_generation', sessionLog, { @@ -872,6 +892,13 @@ export class ChatOrchestrator { steps: plan.steps.map((s) => ({ id: s.id, title: s.title, risk: s.risk, phase: s.phase })), appliedSkills: skillContext.appliedSkills, }); + log.info('Plan ready', { + sessionId: session.id, + mode: session.mode, + goal: plan.goal, + steps: plan.steps.length, + qualityIssues: planQualityIssues, + }); if (session.mode === 'agent') { this.setLiveStatus('Executing plan', plan.goal, 1, plan.steps.length); @@ -947,14 +974,27 @@ export class ChatOrchestrator { return; } - const failureText = - '\n\n⚠️ I could not produce a plan that passed the planning quality gate. No execution was started. Please retry with a little more scope detail, or switch off orchestration for a direct answer.\n'; - fullResponse += failureText; - yield failureText; - this.emitActivity('error', 'Planning failed quality gate'); - await this.finishTurn(session, provider, userMessage, fullResponse, displayPack, compacted); - this.setLiveStatus(null); - return; + if (session.mode === 'plan') { + log.warn('Plan mode failed to produce a plan', { sessionId: session.id, issues: planQualityIssues }); + const failureText = + '\n\n⚠️ I could not produce a plan that passed the planning quality gate. Please retry with a little more scope detail.\n'; + fullResponse += failureText; + yield failureText; + this.emitActivity('error', 'Planning failed quality gate', planQualityIssues.join('; ')); + await this.finishTurn(session, provider, userMessage, fullResponse, displayPack, compacted); + this.setLiveStatus(null); + return; + } + + const fallbackText = + '\n\n⚠️ Structured planning did not pass the quality gate. Continuing with direct Agent execution instead.\n'; + fullResponse += fallbackText; + yield fallbackText; + this.emitActivity('info', 'Planning failed — falling back to direct execution', planQualityIssues.join('; ')); + this.deps.sessionLog?.append('error', 'Planning failed quality gate', { + issues: planQualityIssues, + fallback: 'direct_agent', + }); } const isResume = isApprovalContinuationMessage(userMessage); @@ -1025,6 +1065,7 @@ export class ChatOrchestrator { : isPlanMode ? (planPlan?.maxAutoContinues ?? agentConfig?.maxAutoContinues) : (actPlan?.maxAutoContinues ?? agentConfig?.maxAutoContinues), + requiresWrite: requiresAgentWrite, } )) { if (signal.aborted) break; @@ -1055,9 +1096,18 @@ export class ChatOrchestrator { this.setLiveStatus('Waiting for approval', 'Review and approve below'); this.emitActivity('approval', 'Paused — waiting for your approval', this.deps.taskState?.getPauseSummary()); } else if (!this.agentLoop.hadPendingApproval() && !signal.aborted) { + const directTouchedFiles = getTouchedFilesFromAudit(this.deps.toolRuntime); + if (requiresAgentWrite && directTouchedFiles.length === 0) { + const noWriteBlock = + '\n\nStopped because the model did not change any files for this Agent-mode edit task. No files were changed.\n'; + fullResponse += noWriteBlock; + yield noWriteBlock; + this.emitActivity('error', 'Agent stopped without edits', taskAnalysis.summary); + } if ( session.mode === 'agent' && - agentConfig?.verifyOnActComplete + agentConfig?.verifyOnActComplete && + directTouchedFiles.length > 0 ) { this.setLiveStatus('Running verify hooks'); this.emitActivity('info', 'Discovering and running project verification…'); @@ -1076,9 +1126,9 @@ export class ChatOrchestrator { taskAnalysis.shouldVerify && session.mode === 'agent' && this.planExecutor && - shouldRunDirectFinalValidation(taskAnalysis.kind, getTouchedFilesFromAudit(this.deps.toolRuntime)) + directTouchedFiles.length > 0 && + shouldRunDirectFinalValidation(taskAnalysis.kind, directTouchedFiles) ) { - const directTouchedFiles = getTouchedFilesFromAudit(this.deps.toolRuntime); this.setLiveStatus('Final validation'); this.emitActivity('info', 'Running post-task validation…'); yield '\n\n### Post-task validation\n\n'; @@ -1178,8 +1228,9 @@ export class ChatOrchestrator { } this.saveTurn(session.id, 'assistant', fullResponse); - this.deps.sessionLog?.append('assistant_message', fullResponse.slice(0, 200), { + this.deps.sessionLog?.append('assistant_message', fullResponse, { responseLength: fullResponse.length, + preview: fullResponse.slice(0, 200), }); const parsed = parsePlanFromText(fullResponse); @@ -1390,6 +1441,11 @@ export class ChatOrchestrator { return Boolean(this.agentLoop?.getSuspendState() && this.suspendContext); } + clearRoutingState(): void { + this.suspendContext = undefined; + this.agentLoop?.clearSuspendState(); + } + async *resumeAfterApproval(approved: ApprovedToolResult[]): AsyncIterable { if (!this.agentLoop || !this.suspendContext || approved.length === 0) return; @@ -1486,8 +1542,9 @@ export class ChatOrchestrator { if (fullResponse) { this.saveTurn(session.id, 'assistant', fullResponse); - this.deps.sessionLog?.append('assistant_message', fullResponse.slice(0, 200), { + this.deps.sessionLog?.append('assistant_message', fullResponse, { responseLength: fullResponse.length, + preview: fullResponse.slice(0, 200), }); } } finally { @@ -1653,10 +1710,11 @@ function describeToolActivity( case 'apply_patch': return { kind: 'apply', liveLabel: 'Applying patch', message: `Patching ${path ?? 'file'}`, detail: path }; case 'spawn_research_agent': + case 'spawn_subagent': return { kind: 'tool', liveLabel: 'Starting subagent', - message: 'Starting research subagent', + message: name === 'spawn_subagent' ? `Starting ${String(input.type ?? 'typed')} subagent` : 'Starting research subagent', detail: typeof input.task === 'string' ? input.task.slice(0, 180) : undefined, }; case 'retrieve_context': @@ -1738,11 +1796,12 @@ function shouldUsePlanner( mode: ThunderSession['mode'], taskAnalysis: ReturnType, orchestrationEnabled: boolean, - auditMode = false + auditMode = false, + actDepth: import('../config/schema').AgentDepth = 'auto' ): boolean { // Plan mode always uses the structured planner when the route requires a plan. if (mode === 'plan') return taskAnalysis.shouldPlan; - if (mode === 'agent') return shouldUsePlannerForAct(taskAnalysis, orchestrationEnabled, auditMode); + if (mode === 'agent') return shouldUsePlannerForAct(taskAnalysis, orchestrationEnabled, auditMode, actDepth); return false; } @@ -1751,9 +1810,13 @@ export { shouldUsePlanner }; export function shouldExecuteSavedPlan( mode: ThunderSession['mode'], userMessage: string, - hasActivePlan: boolean + hasActivePlan: boolean, + actDepth: import('../config/schema').AgentDepth = 'auto' ): boolean { - return mode === 'agent' && shouldResumeSavedPlan(userMessage, hasActivePlan); + return ( + mode === 'agent' && + shouldResumeSavedPlan(userMessage, hasActivePlan, hasDirectRouteOverride(userMessage), { actDepth }) + ); } function getTouchedFilesFromAudit(toolRuntime?: ToolRuntime): string[] { @@ -1767,10 +1830,18 @@ function getTouchedFilesFromAudit(toolRuntime?: ToolRuntime): string[] { return [...files]; } +function shouldRequireAgentWrite( + mode: ThunderSession['mode'], + taskKind: ReturnType['kind'], + auditMode: boolean +): boolean { + return mode === 'agent' && !auditMode && (taskKind === 'simple_edit' || taskKind === 'implementation'); +} + function touchesDocs(files: string[]): boolean { return files.some((file) => /(?:^|\/)(?:apps\/docs|docs)\/.+\.(?:mdx?|tsx?|jsx?)$/i.test(file) || - /\.(?:mdx?)$/i.test(file) + /\.(?:mdx)$/i.test(file) ); } diff --git a/src/core/paths/WorkspacePathResolver.ts b/src/core/paths/WorkspacePathResolver.ts new file mode 100644 index 00000000..b95a66c6 --- /dev/null +++ b/src/core/paths/WorkspacePathResolver.ts @@ -0,0 +1,323 @@ +import { basename, dirname, extname, join } from 'path'; +import { existsSync } from 'fs'; +import type { ThunderDb } from '../indexing/ThunderDb'; +import type { IgnoreService } from '../indexing/IgnoreService'; +import { + findSimilarWorkspacePaths, + normalizeRelPath, + normalizeWorkspaceRoot, + pathExistenceVariants, + resolveWorkspaceRelPath, +} from '../util/paths'; + +export type PathResolutionSource = + | 'exact' + | 'variant' + | 'index-basename' + | 'index-suffix' + | 'index-segment' + | 'filesystem' + | 'folder-file-pattern'; + +export interface PathResolutionCandidate { + relPath: string; + score: number; + source: PathResolutionSource; + reason: string; +} + +export interface PathResolutionResult { + requestedPath: string; + normalizedRequest: string; + resolvedPath?: string; + autoResolved: boolean; + confidence: 'high' | 'medium' | 'low' | 'none'; + candidates: PathResolutionCandidate[]; +} + +export interface WorkspacePathResolverOptions { + workspace: string; + db?: ThunderDb; + ignoreService?: IgnoreService; + scopeRoot?: string; + limit?: number; +} + +const AUTO_RESOLVE_MIN_SCORE = 820; +const AUTO_RESOLVE_GAP = 120; + +export class WorkspacePathResolver { + private readonly workspace: string; + private readonly root: string | null; + private readonly db?: ThunderDb; + private readonly ignoreService?: IgnoreService; + private readonly scopeRoot?: string; + private readonly limit: number; + + constructor(options: WorkspacePathResolverOptions) { + this.workspace = options.workspace; + this.root = normalizeWorkspaceRoot(options.workspace); + this.db = options.db; + this.ignoreService = options.ignoreService; + this.scopeRoot = options.scopeRoot?.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/$/, ''); + this.limit = options.limit ?? 8; + } + + resolve(rawPath: string): PathResolutionResult { + const normalizedRequest = resolveWorkspaceRelPath(this.workspace, rawPath) ?? ''; + const base: PathResolutionResult = { + requestedPath: rawPath, + normalizedRequest, + autoResolved: false, + confidence: 'none', + candidates: [], + }; + + if (!this.root || normalizedRequest === null) { + return base; + } + + if (normalizedRequest && this.pathExists(normalizedRequest)) { + return { + ...base, + resolvedPath: normalizedRequest, + confidence: 'high', + candidates: [{ + relPath: normalizedRequest, + score: 1000, + source: 'exact', + reason: 'Path exists on disk', + }], + }; + } + + const ranked = this.collectCandidates(normalizedRequest || normalizeRelPath(rawPath)); + base.candidates = ranked.slice(0, this.limit); + + if (ranked.length === 0) { + return base; + } + + const best = ranked[0]; + const second = ranked[1]; + const gap = second ? best.score - second.score : AUTO_RESOLVE_GAP + 1; + const autoResolved = best.score >= AUTO_RESOLVE_MIN_SCORE && gap >= AUTO_RESOLVE_GAP; + + if (autoResolved) { + return { + ...base, + resolvedPath: best.relPath, + autoResolved: true, + confidence: 'high', + candidates: ranked.slice(0, this.limit), + }; + } + + if (ranked.length === 1 && best.score >= 650) { + return { + ...base, + resolvedPath: best.relPath, + autoResolved: true, + confidence: 'medium', + candidates: ranked, + }; + } + + return { + ...base, + confidence: ranked.length === 1 ? 'medium' : 'low', + candidates: ranked.slice(0, this.limit), + }; + } + + formatUnresolvedMessage(rawPath: string, result: PathResolutionResult): string { + if (result.candidates.length === 0) { + return `File not found: ${rawPath}. Use resolve_path, search, or list_files to locate the file before reading.`; + } + const lines = [ + `File not found: ${rawPath}`, + 'Ranked workspace matches (use resolve_path or read_file with an exact path):', + ...result.candidates.map( + (c, i) => `${i + 1}. ${c.relPath} — ${c.reason} (score ${c.score}, ${c.source})` + ), + ]; + if (result.confidence === 'medium' && result.candidates[0]) { + lines.push(`Most likely: ${result.candidates[0].relPath}`); + } + return lines.join('\n'); + } + + formatAutoResolvedNote(rawPath: string, resolvedPath: string, candidate: PathResolutionCandidate): string { + return [ + `[Path auto-resolved] ${rawPath} → ${resolvedPath}`, + `Reason: ${candidate.reason} (${candidate.source}, score ${candidate.score})`, + '---', + ].join('\n'); + } + + private collectCandidates(requestedRelPath: string): PathResolutionCandidate[] { + const scored = new Map(); + + const add = (relPath: string, score: number, source: PathResolutionSource, reason: string) => { + const norm = normalizeRelPath(relPath); + if (!norm || !this.isAllowed(norm)) return; + if (!this.pathExists(norm)) return; + const existing = scored.get(norm); + if (!existing || score > existing.score) { + scored.set(norm, { relPath: norm, score, source, reason }); + } + }; + + for (const variant of pathExistenceVariants(requestedRelPath)) { + add(variant, 920, 'variant', 'Extension or index naming variant'); + } + + this.addIndexCandidates(requestedRelPath, add); + this.addFolderFilePatternCandidates(requestedRelPath, add); + + if (this.root) { + for (const relPath of findSimilarWorkspacePaths(this.workspace, requestedRelPath, this.limit * 2)) { + const score = this.scoreCandidate(requestedRelPath, relPath, 'filesystem'); + add(relPath, score, 'filesystem', 'Filesystem basename / variant walk'); + } + } + + return [...scored.values()].sort((a, b) => b.score - a.score); + } + + private addIndexCandidates( + requestedRelPath: string, + add: (relPath: string, score: number, source: PathResolutionSource, reason: string) => void + ): void { + if (!this.db?.isOpen() || !this.root) return; + + const fileName = basename(requestedRelPath); + const stem = fileName.replace(/\.[^.]+$/, ''); + const parent = dirname(requestedRelPath).replace(/\\/g, '/'); + + const queries: Array<{ sql: string; params: unknown[]; source: PathResolutionSource; reason: string }> = [ + { + sql: 'SELECT rel_path FROM files WHERE workspace = ? AND rel_path LIKE ? ORDER BY rel_path LIMIT ?', + params: [this.workspace, `%/${fileName}`, this.limit * 3], + source: 'index-basename', + reason: 'Indexed path ends with requested filename', + }, + { + sql: 'SELECT rel_path FROM files WHERE workspace = ? AND lower(rel_path) LIKE ? ORDER BY rel_path LIMIT ?', + params: [this.workspace, `%${stem.toLowerCase()}%`, this.limit * 3], + source: 'index-segment', + reason: 'Indexed path contains requested stem', + }, + ]; + + if (parent && parent !== '.') { + queries.push({ + sql: 'SELECT rel_path FROM files WHERE workspace = ? AND rel_path LIKE ? ORDER BY rel_path LIMIT ?', + params: [this.workspace, `${parent}/%`, this.limit * 3], + source: 'index-suffix', + reason: 'Indexed path under requested parent directory', + }); + } + + for (const query of queries) { + try { + const rows = this.db.raw.prepare(query.sql).all(...query.params) as Array<{ rel_path: string }>; + for (const row of rows) { + const score = this.scoreCandidate(requestedRelPath, row.rel_path, query.source); + add(row.rel_path, score, query.source, query.reason); + } + } catch { + // Index may be unavailable during early startup. + } + } + } + + private addFolderFilePatternCandidates( + requestedRelPath: string, + add: (relPath: string, score: number, source: PathResolutionSource, reason: string) => void + ): void { + const fileName = basename(requestedRelPath); + const stem = fileName.replace(/\.[^.]+$/, ''); + if (!stem || stem.length < 3) return; + + const parent = dirname(requestedRelPath).replace(/\\/g, '/'); + const nested = parent === '.' ? `${stem}/${fileName}` : `${parent}/${stem}/${fileName}`; + add(nested, 960, 'folder-file-pattern', 'Folder named like file stem (package-style field layout)'); + + const indexNested = parent === '.' ? `${stem}/index${extname(fileName)}` : `${parent}/${stem}/index${extname(fileName)}`; + add(indexNested, 880, 'folder-file-pattern', 'index file inside stem-named folder'); + } + + private scoreCandidate( + requestedRelPath: string, + candidateRelPath: string, + source: PathResolutionSource + ): number { + const req = requestedRelPath.replace(/\\/g, '/'); + const cand = candidateRelPath.replace(/\\/g, '/'); + if (req === cand) return 1000; + + let score = source === 'filesystem' ? 520 : 600; + if (basename(req) === basename(cand)) score += 120; + + const prefix = longestCommonPathPrefix(req, cand); + score += prefix.split('/').filter(Boolean).length * 35; + + score += folderFilePatternBoost(req, cand); + + if (cand.endsWith(req) || req.endsWith(basename(cand))) score += 80; + if (cand.includes(req)) score += 40; + + return score; + } + + private pathExists(relPath: string): boolean { + if (!this.root) return false; + try { + return existsSync(join(this.root, relPath)); + } catch { + return false; + } + } + + private isAllowed(relPath: string): boolean { + if (!this.scopeRoot) return !this.ignoreService?.isIgnored(relPath, { forRead: true }); + const norm = normalizeRelPath(relPath); + const scope = normalizeRelPath(this.scopeRoot); + if (!norm.startsWith(scope)) return false; + return !this.ignoreService?.isIgnored(relPath, { forRead: true }); + } +} + +function longestCommonPathPrefix(a: string, b: string): string { + const aParts = a.split('/').filter(Boolean); + const bParts = b.split('/').filter(Boolean); + const out: string[] = []; + for (let i = 0; i < Math.min(aParts.length, bParts.length); i++) { + if (aParts[i] !== bParts[i]) break; + out.push(aParts[i]); + } + return out.join('/'); +} + +function folderFilePatternBoost(requested: string, candidate: string): number { + const reqFile = basename(requested); + const reqStem = reqFile.replace(/\.[^.]+$/, ''); + const candFile = basename(candidate); + const candParent = basename(dirname(candidate)); + + if (candFile === reqFile && candParent === reqStem) { + return 260; + } + + const expected = `${dirname(requested).replace(/\\/g, '/')}/${reqStem}/${reqFile}`.replace(/^\.\//, ''); + if (candidate.replace(/\\/g, '/') === expected) { + return 280; + } + + return 0; +} + +export function createWorkspacePathResolver(options: WorkspacePathResolverOptions): WorkspacePathResolver { + return new WorkspacePathResolver(options); +} diff --git a/src/core/paths/index.ts b/src/core/paths/index.ts new file mode 100644 index 00000000..0ad1e42f --- /dev/null +++ b/src/core/paths/index.ts @@ -0,0 +1 @@ +export * from './WorkspacePathResolver'; diff --git a/src/core/plans/PlanActEngine.ts b/src/core/plans/PlanActEngine.ts index b8497f0b..24358a07 100644 --- a/src/core/plans/PlanActEngine.ts +++ b/src/core/plans/PlanActEngine.ts @@ -115,9 +115,9 @@ function isReadOnlyCommandSegment(cmd: string, extraPatterns: string[] = []): bo if (/^(npx\s+(--yes\s+)?)?tsc\s+[\s\S]*--noEmit\b/i.test(cmd)) return true; if (/^(npx\s+(--yes\s+)?)?vitest\s+(run\b|--run\b)/i.test(cmd)) return true; if (/^(npx\s+(--yes\s+)?)?jest\b/i.test(cmd)) return true; - if (/^npm\s+(ls|list|outdated|audit|run\s+(lint|test|typecheck|check|build|compile))\b/i.test(cmd)) return true; - if (/^yarn\s+(why|list|info|lint|test|build|compile|typecheck|check)\b/i.test(cmd)) return true; - if (/^pnpm\s+(why|list|lint|test|build|compile|typecheck|check)\b/i.test(cmd)) return true; + if (/^npm\s+(ls|list|outdated|audit|run\s+(lint|test|typecheck|check|build|compile|verify|validate|doctor))\b/i.test(cmd)) return true; + if (/^yarn\s+(why|list|info|lint|test|build|compile|typecheck|check|verify|validate|doctor)\b/i.test(cmd)) return true; + if (/^pnpm\s+(why|list|lint|test|build|compile|typecheck|check|verify|validate|doctor)\b/i.test(cmd)) return true; if (/^(?:\.\/mvnw|mvn)\s+test\b/i.test(cmd)) return true; if (/^(?:\.\/gradlew|gradle)\s+test\b/i.test(cmd)) return true; if (/^cargo\s+test\b/i.test(cmd)) return true; @@ -127,6 +127,8 @@ function isReadOnlyCommandSegment(cmd: string, extraPatterns: string[] = []): bo if (/^git\s+(status|diff|log|ls-files)\b/i.test(cmd)) return true; if (/^\d+>&\d+$/.test(cmd.trim())) return true; if (/^true$|^false$/.test(cmd.trim())) return true; + if (/^node\s+(--check|-c)\b/i.test(cmd)) return true; + if (/^(bash|sh)\s+-n\b/i.test(cmd)) return true; return false; } diff --git a/src/core/plans/promptBuilder.ts b/src/core/plans/promptBuilder.ts index 31ba2be1..48d5cfb0 100644 --- a/src/core/plans/promptBuilder.ts +++ b/src/core/plans/promptBuilder.ts @@ -12,7 +12,9 @@ import { ACT_SKILL_TOOL_GUIDANCE } from '../modes/agent/actSkillRouting'; const ASK_TOOL_GUIDANCE = ` ASK MODE TOOLS — read-only exploration only: +- Use resolve_path when the exact file path is uncertain; read_file auto-resolves high-confidence misses. - Use read_file/read_files/search/search_batch/list_files/repo_map/retrieve_context before stating codebase facts. +- Copy rel_path values from search results exactly — do not flatten folder/file layouts. - Batch independent reads in ONE turn (read_files max 12 paths; prefer 8-10). - Use git_diff and diagnostics when the question is about changes or errors. - Use run_command only for read-only inspection (rg, git status/diff/log, lint/test without --fix). @@ -41,7 +43,9 @@ For concise profile requests, shorten the same structure instead of using a gene const TOOL_GUIDANCE = ` TOOLS: You have tools to read files, search code, run commands, write files, and manage memory. +- Use resolve_path before read_file when unsure of the exact path; read_file auto-resolves high-confidence misses. - Use read_file/read_files/search/search_batch/list_files to gather information before editing. +- Copy rel_path values from search/resolve_path exactly — never invent flattened paths (e.g. fields/foo.tsx vs fields/foo/foo.tsx). - Tools named mcp__server__tool come from configured MCP servers. Treat them as external tools; inspect their names and arguments carefully. - Batch independent reads and searches in ONE turn (read_files, search_batch). read_files has a hard max of 12 paths per call; prefer 8-10 and split larger batches. - For audit/cleanup: use execute_workspace_script (audit-dependencies.mjs, audit-dead-code.sh) — NEVER spawn_research_agent for unused deps/imports/files. diff --git a/src/core/rules/ProjectRulesService.ts b/src/core/rules/ProjectRulesService.ts index 74648d03..547ddee1 100644 --- a/src/core/rules/ProjectRulesService.ts +++ b/src/core/rules/ProjectRulesService.ts @@ -1,11 +1,28 @@ -import { existsSync, readFileSync, statSync } from 'fs'; -import { join } from 'path'; +import { existsSync, readFileSync, readdirSync, statSync } from 'fs'; +import { homedir } from 'os'; +import { dirname, join, relative, resolve } from 'path'; import type { ContextItem, ContextQuery, ContextSource } from '../context/types'; import { createLogger } from '../telemetry/Logger'; +import { BUNDLED_DEFAULT_RULES } from './bundledDefaultRules'; + const log = createLogger('ProjectRulesService'); -const RULE_FILE = 'MITII.md'; +const BUNDLED_RULES_REL_PATH = 'mitii:defaults/path-resolution'; + +const MAX_RULE_FILE_BYTES = 256_000; +const DEFAULT_TOTAL_CHARS = 20_000; + +const RULE_LAYERS = [ + { relPath: 'MITII.md', label: 'workspace' }, + { relPath: 'AGENTS.md', label: 'compatibility' }, + { relPath: 'CLAUDE.md', label: 'compatibility' }, +] as const; + +const RULE_DIRS = [ + '.mitii/rules', + '.cursor/rules', +] as const; export interface ProjectRuleFile { relPath: string; @@ -15,10 +32,27 @@ export interface ProjectRuleFile { export class ProjectRulesService { constructor(private readonly workspace: string) {} - load(maxCharsPerFile = 5000): ProjectRuleFile[] { + load(maxCharsPerFile = 5000, maxTotalChars = DEFAULT_TOTAL_CHARS): ProjectRuleFile[] { if (!this.workspace) return []; const files: ProjectRuleFile[] = []; - this.tryAddFile(files, RULE_FILE, maxCharsPerFile); + const budget = { remaining: Math.max(0, maxTotalChars) }; + + const bundled = BUNDLED_DEFAULT_RULES.slice(0, Math.min(maxCharsPerFile, budget.remaining)).trim(); + if (bundled) { + files.push({ relPath: BUNDLED_RULES_REL_PATH, content: bundled }); + budget.remaining -= bundled.length; + } + + this.tryAddAbsFile(files, join(homedir(), '.mitii', 'MITTII.md'), '~/.mitii/MITTII.md', maxCharsPerFile, budget); + for (const layer of RULE_LAYERS) { + this.tryAddFile(files, layer.relPath, maxCharsPerFile, budget); + } + for (const dir of RULE_DIRS) { + for (const relPath of this.listMarkdownFiles(dir)) { + this.tryAddFile(files, relPath, maxCharsPerFile, budget); + } + } + this.tryAddFile(files, '.mitii/MITTII.local.md', maxCharsPerFile, budget); return files; } @@ -26,15 +60,33 @@ export class ProjectRulesService { return this.load(1).length; } - private tryAddFile(files: ProjectRuleFile[], relPath: string, maxChars: number): void { + private tryAddFile( + files: ProjectRuleFile[], + relPath: string, + maxChars: number, + budget: { remaining: number } + ): void { + this.tryAddAbsFile(files, join(this.workspace, relPath), relPath, maxChars, budget); + } + + private tryAddAbsFile( + files: ProjectRuleFile[], + abs: string, + relPath: string, + maxChars: number, + budget: { remaining: number } + ): void { if (files.some((f) => f.relPath === relPath)) return; - const abs = join(this.workspace, relPath); + if (budget.remaining <= 0) return; if (!existsSync(abs)) return; try { const st = statSync(abs); - if (!st.isFile() || st.size > 256_000) return; - const content = readFileSync(abs, 'utf-8').slice(0, maxChars).trim(); + if (!st.isFile() || st.size > MAX_RULE_FILE_BYTES) return; + const raw = readFileSync(abs, 'utf-8').slice(0, maxChars).trim(); + const expanded = this.expandFileReferences(raw, dirname(abs), relPath, Math.min(maxChars, budget.remaining)); + const content = expanded.slice(0, Math.min(maxChars, budget.remaining)).trim(); if (content) files.push({ relPath, content }); + budget.remaining -= content.length; } catch (error) { log.warn('Could not read project rules file', { relPath, @@ -42,6 +94,68 @@ export class ProjectRulesService { }); } } + + private listMarkdownFiles(relDir: string): string[] { + const root = join(this.workspace, relDir); + if (!existsSync(root)) return []; + const out: string[] = []; + const visit = (dir: string): void => { + let entries: string[]; + try { + entries = readdirSync(dir).sort(); + } catch { + return; + } + for (const entry of entries) { + const abs = join(dir, entry); + try { + const st = statSync(abs); + if (st.isDirectory()) { + visit(abs); + } else if (st.isFile() && /\.md$/i.test(entry)) { + out.push(relative(this.workspace, abs).replace(/\\/g, '/')); + } + } catch { + // skip unreadable entries + } + } + }; + visit(root); + return out; + } + + private expandFileReferences(content: string, baseDir: string, ownerRelPath: string, maxChars: number): string { + let remaining = maxChars; + return content.replace(/(^|\s)@([A-Za-z0-9_./-]+\.[A-Za-z0-9]+)/g, (match, prefix: string, ref: string) => { + if (remaining <= 0) return match; + const resolved = this.resolveReference(baseDir, ref); + if (!resolved) return match; + try { + const st = statSync(resolved.absPath); + if (!st.isFile() || st.size > MAX_RULE_FILE_BYTES) return match; + const text = readFileSync(resolved.absPath, 'utf-8') + .slice(0, Math.min(3000, remaining)) + .trim(); + if (!text) return match; + remaining -= text.length; + return `${prefix}@${ref}\n\n[Referenced file: ${resolved.relPath}]\n${text}\n[/Referenced file]\n`; + } catch (error) { + log.warn('Could not read referenced project rules file', { + ownerRelPath, + ref, + error: error instanceof Error ? error.message : String(error), + }); + return match; + } + }); + } + + private resolveReference(baseDir: string, ref: string): { absPath: string; relPath: string } | null { + const absPath = resolve(baseDir, ref); + const relPath = relative(this.workspace, absPath).replace(/\\/g, '/'); + if (!relPath || relPath.startsWith('..') || relPath === '.') return null; + return { absPath, relPath }; + } } export class ProjectRulesContextSource implements ContextSource { diff --git a/src/core/rules/bundled/path-resolution.md b/src/core/rules/bundled/path-resolution.md new file mode 100644 index 00000000..6ef9e306 --- /dev/null +++ b/src/core/rules/bundled/path-resolution.md @@ -0,0 +1,28 @@ +# Workspace path resolution (Mitii default) + +Mitii auto-resolves missing read paths using the workspace index (SQLite), filesystem walks, and folder/file layout heuristics. Follow these rules so reads stay accurate without manual pinning. + +## Before reading + +1. Prefer **resolve_path** when the exact path is uncertain. +2. Use **search** / **search_batch** with `scopeRoot` for symbols or feature names. +3. Use **list_files** on the parent directory when exploring package layout (e.g. `packages/foo/src/fields`). +4. Only pass paths returned by tools or auto-resolution — never invent flattened paths. + +## Common monorepo layouts + +- Feature folders often nest: `fields/field-slider/field-slider.tsx`, not `fields/field-slider.tsx`. +- Barrel files: `index.ts` inside a folder — read the folder listing first. +- Packages live under `packages//` — scope searches and reads to that root. + +## When read_file auto-resolves + +If you request a wrong but close path, Mitii may read the best indexed match and prefix the output with `[Path auto-resolved]`. Treat the resolved path as canonical for later edits. + +## If resolution is ambiguous + +Call **resolve_path** and pick from ranked candidates. Do not guess among multiple equally likely files. + +## Accuracy over speed + +Extra search, list, or resolve steps are expected. Do not skip grounding to save turns. diff --git a/src/core/rules/bundledDefaultRules.ts b/src/core/rules/bundledDefaultRules.ts new file mode 100644 index 00000000..31fe2e66 --- /dev/null +++ b/src/core/rules/bundledDefaultRules.ts @@ -0,0 +1,32 @@ +/** Default methodology rules always injected into agent context (shipped with the extension). */ +export const BUNDLED_PATH_RESOLUTION_RULES = `# Workspace path resolution (Mitii default) + +Mitii auto-resolves missing read paths using the workspace index (SQLite), filesystem walks, and folder/file layout heuristics. Follow these rules so reads stay accurate without manual pinning. + +## Before reading + +1. Prefer **resolve_path** when the exact path is uncertain. +2. Use **search** / **search_batch** with \`scopeRoot\` for symbols or feature names. +3. Use **list_files** on the parent directory when exploring package layout (e.g. \`packages/foo/src/fields\`). +4. Only pass paths returned by tools or auto-resolution — never invent flattened paths. + +## Common monorepo layouts + +- Feature folders often nest: \`fields/field-slider/field-slider.tsx\`, not \`fields/field-slider.tsx\`. +- Barrel files: \`index.ts\` inside a folder — read the folder listing first. +- Packages live under \`packages//\` — scope searches and reads to that root. + +## When read_file auto-resolves + +If you request a wrong but close path, Mitii may read the best indexed match and prefix the output with \`[Path auto-resolved]\`. Treat the resolved path as canonical for later edits. + +## If resolution is ambiguous + +Call **resolve_path** and pick from ranked candidates. Do not guess among multiple equally likely files. + +## Accuracy over speed + +Extra search, list, or resolve steps are expected. Do not skip grounding to save turns. +`; + +export const BUNDLED_DEFAULT_RULES = BUNDLED_PATH_RESOLUTION_RULES; diff --git a/src/core/rules/index.ts b/src/core/rules/index.ts index 18c8a00d..c502d406 100644 --- a/src/core/rules/index.ts +++ b/src/core/rules/index.ts @@ -1 +1,3 @@ export * from './ProjectRulesService'; +export * from './bundledDefaultRules'; +export * from './installBundledRules'; diff --git a/src/core/rules/installBundledRules.ts b/src/core/rules/installBundledRules.ts new file mode 100644 index 00000000..7b0fb66b --- /dev/null +++ b/src/core/rules/installBundledRules.ts @@ -0,0 +1,64 @@ +import { cpSync, existsSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import { createLogger } from '../telemetry/Logger'; + +const log = createLogger('BundledRules'); + +export interface InstallBundledRulesResult { + installed: string[]; + skipped: string[]; + bundledRoot: string; + destinationRoot: string; +} + +function resolveBundledRulesRoot(extensionRoot: string): string | undefined { + const candidates = [ + join(extensionRoot, 'dist', 'core', 'rules', 'bundled'), + join(extensionRoot, 'src', 'core', 'rules', 'bundled'), + join(extensionRoot, 'core', 'rules', 'bundled'), + ]; + return candidates.find((path) => existsSync(path)); +} + +/** Copy extension-bundled rules into `.mitii/rules` (idempotent). */ +export function installBundledRules( + workspace: string, + extensionRoot: string, + options: { force?: boolean } = {} +): InstallBundledRulesResult { + const bundledRoot = resolveBundledRulesRoot(extensionRoot); + const destinationRoot = join(workspace, '.mitii', 'rules'); + const installed: string[] = []; + const skipped: string[] = []; + + if (!bundledRoot || !existsSync(bundledRoot)) { + log.warn('Bundled rules directory missing', { extensionRoot }); + return { installed, skipped, bundledRoot: bundledRoot ?? '', destinationRoot }; + } + + mkdirSync(destinationRoot, { recursive: true }); + + const source = join(bundledRoot, 'path-resolution.md'); + const target = join(destinationRoot, 'path-resolution.md'); + if (!existsSync(source)) { + log.warn('Bundled path-resolution rule missing', { source }); + return { installed, skipped, bundledRoot, destinationRoot }; + } + + if (existsSync(target) && !options.force) { + skipped.push('path-resolution.md'); + } else { + cpSync(source, target); + installed.push('path-resolution.md'); + } + + if (installed.length > 0 || skipped.length > 0) { + log.info('Bundled rules install finished', { + installed: installed.length, + skipped: skipped.length, + destinationRoot, + }); + } + + return { installed, skipped, bundledRoot, destinationRoot }; +} diff --git a/src/core/runtime/AgentLoop.ts b/src/core/runtime/AgentLoop.ts index c42fa7ab..c8686813 100644 --- a/src/core/runtime/AgentLoop.ts +++ b/src/core/runtime/AgentLoop.ts @@ -1,7 +1,7 @@ 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 type { ToolExecutor, ToolExecutionResult } from '../safety/ToolExecutor'; import { formatToolResult } from '../tools/builtinTools'; import { NO_TOOLS_AUDIT_NUDGE } from './taskKind'; import { NO_TOOLS_ASK_NUDGE, ASK_SYNTHESIS_NUDGE, isGroundingToolCall } from './askMode'; @@ -19,15 +19,42 @@ Do NOT retry apply_patch or write_file in this step. If you finished analysis, summarize findings in plain text and stop — the orchestrator advances to the next step automatically. If edits are required now, state exactly what must change and which files are affected.`; -const PHASE_LOCK_RUN_COMMAND_ESCALATION = `SYSTEM: The previous run_command calls were blocked by the current plan phase. -Do NOT retry the same arbitrary shell command. -In Verify, use the diagnostics tool or a recognized verification command. Read package.json scripts first — do not assume npm run lint exists. For docs/MDX tasks prefer the docs build (for example cd apps/docs && npm run build). -For targeted inspection, use read_file/search instead of shell. If verification cannot proceed, summarize the blocked command and the remaining risk.`; +const PHASE_LOCK_WRITE_HARD_STOP = + 'Stopped: file writes were blocked by the read-only phase lock and the model retried after being told to stop. Findings gathered so far stand; no files were written. The orchestrator will advance to the next step, which is authorized to write.'; + +const PHASE_LOCK_RUN_COMMAND_HARD_STOP = + 'Stopped: run_command was blocked by the current plan phase and the model retried after being told to stop. Findings gathered so far stand. The orchestrator will advance to the step where this command is authorized.'; const VALIDATION_BLOCK_MESSAGE = 'Post-edit validation found errors. Fix all reported issues before marking this step complete or moving on.'; -const REPEATED_TOOL_INPUT_FAILURE_PREFIX = 'Stopped after repeated invalid tool arguments'; +const REPEATED_TOOL_INPUT_FAILURE_PREFIX = 'Stopped after repeated identical tool failure'; + +/** + * Tool filtering (e.g. filterActModeTools) only controls what's advertised to the model — + * nothing previously stopped the model from calling an excluded tool name anyway (it would + * reach ToolExecutor and execute/fail there). This keeps excluded tools truly inert. + */ +function notOfferedToolResult(toolName: string): ToolExecutionResult { + return { + success: false, + output: '', + error: `Tool "${toolName}" is not available in this mode/phase — do not call it again.`, + }; +} + +const NO_WRITE_AGENT_NUDGE = `SYSTEM: The user asked Agent mode to modify the workspace, but no file edit has been made yet. +Do not finish with a summary only. Call apply_patch or write_file now for the required change, then verify.`; + +const NO_WRITE_AGENT_STOP = + 'Stopped because the model tried to finish an Agent-mode edit task without calling apply_patch or write_file. No files were changed.'; + +const WRITE_REQUIRED_CHURN_NUDGE = `SYSTEM: You are stuck in read-only exploration for an Agent-mode edit task. +The required context is already available. In the next assistant step, call apply_patch or write_file. +Do NOT call read_file, read_files, list_files, diagnostics, memory_search, use_skill, or ask_question again before editing.`; + +const WRITE_REQUIRED_CHURN_STOP = + 'Stopped because the model kept using read-only tools for an edit task and never called apply_patch or write_file. Try a stronger coding model or reduce the prompt scope.'; export interface PostWriteValidationResult { message?: string; @@ -58,6 +85,8 @@ export interface AgentLoopOptions { /** Plan mode discovery / read-only fallback loop. */ planMode?: boolean; requiresPlanGrounding?: boolean; + /** Agent mode edit tasks: retry once if the model tries to stop before writing. */ + requiresWrite?: boolean; } export interface AgentLoopSuspendState { @@ -112,6 +141,7 @@ export class AgentLoop { options?: AgentLoopOptions ): AsyncIterable { const messages: ChatMessage[] = [...initialMessages]; + const allowedToolNames = new Set(tools.map((t) => t.function.name)); let pendingApproval = false; this.lastPendingApproval = false; this.lastSuspendState = undefined; @@ -127,8 +157,13 @@ export class AgentLoop { let totalSteps = 0; let phaseLockWriteFailures = 0; let phaseLockRunCommandFailures = 0; + let phaseLockWriteEscalated = false; let lastInputFailureKey = ''; let repeatedInputFailureCount = 0; + let writeToolCallsMade = false; + let noWriteNudgeUsed = false; + let noWriteToolRounds = 0; + let writeChurnNudgeUsed = false; const hardLimit = maxSteps + maxAutoContinues * maxSteps; const readOnlyMode = Boolean(options?.askMode || options?.planMode); @@ -191,6 +226,31 @@ export class AgentLoop { callbacks?.onLlmStepComplete?.(displayStep, Date.now() - llmStartedAt, toolCalls?.length ?? 0); if (!toolCalls || toolCalls.length === 0) { + if ( + options?.requiresWrite && + !readOnlyMode && + !auditMode && + stepContent && + !writeToolCallsMade && + !noWriteNudgeUsed + ) { + noWriteNudgeUsed = true; + messages.push({ role: 'assistant', content: stepContent }); + messages.push({ role: 'user', content: NO_WRITE_AGENT_NUDGE }); + continue; + } + if ( + options?.requiresWrite && + !readOnlyMode && + !auditMode && + stepContent && + !writeToolCallsMade && + noWriteNudgeUsed + ) { + messages.push({ role: 'assistant', content: NO_WRITE_AGENT_STOP }); + yield NO_WRITE_AGENT_STOP; + break; + } if (auditMode && stepContent && !auditNudgeUsed) { auditNudgeUsed = true; messages.push({ role: 'assistant', content: stepContent }); @@ -243,11 +303,13 @@ export class AgentLoop { } callbacks?.onToolStart?.(tc.function.name, input); const toolStartedAt = Date.now(); - const execResult = await this.toolExecutor.execute(tc.function.name, input, { - toolCallId: tc.id, - phaseLock: options?.phaseLock, - restrictRunCommandToReadOnly: auditMode || options?.restrictRunCommandToReadOnly, - }); + const execResult = allowedToolNames.has(tc.function.name) + ? await this.toolExecutor.execute(tc.function.name, input, { + toolCallId: tc.id, + phaseLock: options?.phaseLock, + restrictRunCommandToReadOnly: auditMode || options?.restrictRunCommandToReadOnly, + }) + : notOfferedToolResult(tc.function.name); return { tc, input, execResult, durationMs: Date.now() - toolStartedAt }; }) ); @@ -256,10 +318,18 @@ export class AgentLoop { let phaseLockRunCommandFailuresThisTurn = 0; let postWriteValidationFailed = false; let repeatedInputFailureStop: string | undefined; + let nonWriteOnlyTurn = true; for (const { tc, input, execResult, durationMs } of executions) { if (signal?.aborted) break; + if (['write_file', 'apply_patch'].includes(tc.function.name)) { + nonWriteOnlyTurn = false; + if (execResult.success || execResult.pendingApproval) { + writeToolCallsMade = true; + } + } + if (execResult.success && isGroundingTool(tc.function.name)) { groundingToolCallsMade = true; } @@ -332,9 +402,8 @@ export class AgentLoop { content: toolContent, }); - const inputFailureKey = !execResult.success - ? repeatedToolInputFailureKey(tc.function.name, output) - : undefined; + const inputFailureKey = + !execResult.success && !isSkipped ? repeatedToolFailureKey(tc.function.name, output) : undefined; if (inputFailureKey) { if (inputFailureKey === lastInputFailureKey) { repeatedInputFailureCount += 1; @@ -349,7 +418,7 @@ export class AgentLoop { repeatedInputFailureCount ); } - } else if (execResult.success || !isRetriableToolFailure(output)) { + } else { lastInputFailureKey = ''; repeatedInputFailureCount = 0; } @@ -359,27 +428,59 @@ export class AgentLoop { messages.push({ role: 'user', content: VALIDATION_BLOCK_MESSAGE }); } + let phaseLockHardStop: string | undefined; + if (phaseLockFailuresThisTurn > 0) { - phaseLockWriteFailures += phaseLockFailuresThisTurn; - if (phaseLockWriteFailures >= 2) { - messages.push({ role: 'user', content: PHASE_LOCK_ESCALATION }); - phaseLockWriteFailures = 0; + if (phaseLockWriteEscalated) { + phaseLockHardStop = PHASE_LOCK_WRITE_HARD_STOP; + } else { + phaseLockWriteFailures += phaseLockFailuresThisTurn; + if (phaseLockWriteFailures >= 2) { + messages.push({ role: 'user', content: PHASE_LOCK_ESCALATION }); + phaseLockWriteFailures = 0; + phaseLockWriteEscalated = true; + } } } if (phaseLockRunCommandFailuresThisTurn > 0) { phaseLockRunCommandFailures += phaseLockRunCommandFailuresThisTurn; if (phaseLockRunCommandFailures >= 2) { - messages.push({ role: 'user', content: PHASE_LOCK_RUN_COMMAND_ESCALATION }); - phaseLockRunCommandFailures = 0; + phaseLockHardStop = phaseLockHardStop ?? PHASE_LOCK_RUN_COMMAND_HARD_STOP; } } + if (phaseLockHardStop) { + messages.push({ role: 'assistant', content: phaseLockHardStop }); + yield phaseLockHardStop; + break; + } + if (repeatedInputFailureStop) { messages.push({ role: 'assistant', content: repeatedInputFailureStop }); yield repeatedInputFailureStop; break; } + if ( + options?.requiresWrite && + !readOnlyMode && + !auditMode && + !pendingApproval && + !writeToolCallsMade && + nonWriteOnlyTurn + ) { + noWriteToolRounds += 1; + if (noWriteToolRounds >= 4) { + messages.push({ role: 'assistant', content: WRITE_REQUIRED_CHURN_STOP }); + yield WRITE_REQUIRED_CHURN_STOP; + break; + } + if (noWriteToolRounds >= 2 && !writeChurnNudgeUsed) { + writeChurnNudgeUsed = true; + messages.push({ role: 'user', content: WRITE_REQUIRED_CHURN_NUDGE }); + } + } + if (pendingApproval) { const checkpoint = await createApprovalCheckpoint(provider, messages, options?.phaseLock, signal); this.lastSuspendState = { @@ -453,6 +554,7 @@ export class AgentLoop { ): AsyncIterable { const messages: ChatMessage[] = state.messages.map((m) => ({ ...m })); const tools = state.tools; + const allowedToolNames = new Set(tools.map((t) => t.function.name)); const options = state.options; let pendingApproval = false; this.lastPendingApproval = false; @@ -600,11 +702,13 @@ export class AgentLoop { } callbacks?.onToolStart?.(tc.function.name, input); const toolStartedAt = Date.now(); - const execResult = await this.toolExecutor.execute(tc.function.name, input, { - toolCallId: tc.id, - phaseLock: options.phaseLock, - restrictRunCommandToReadOnly: options.restrictRunCommandToReadOnly, - }); + const execResult = allowedToolNames.has(tc.function.name) + ? await this.toolExecutor.execute(tc.function.name, input, { + toolCallId: tc.id, + phaseLock: options.phaseLock, + restrictRunCommandToReadOnly: options.restrictRunCommandToReadOnly, + }) + : notOfferedToolResult(tc.function.name); return { tc, input, execResult, durationMs: Date.now() - toolStartedAt }; }) ); @@ -683,8 +787,9 @@ export class AgentLoop { if (phaseLockRunCommandFailuresThisTurn > 0) { phaseLockRunCommandFailures += phaseLockRunCommandFailuresThisTurn; if (phaseLockRunCommandFailures >= 2) { - messages.push({ role: 'user', content: PHASE_LOCK_RUN_COMMAND_ESCALATION }); - phaseLockRunCommandFailures = 0; + messages.push({ role: 'assistant', content: PHASE_LOCK_RUN_COMMAND_HARD_STOP }); + yield PHASE_LOCK_RUN_COMMAND_HARD_STOP; + break; } } @@ -730,6 +835,7 @@ export class AgentLoop { options?: AgentLoopOptions ): Promise { const messages: ChatMessage[] = [...initialMessages]; + const allowedToolNames = new Set(tools.map((t) => t.function.name)); let fullContent = ''; let toolCallsMade = 0; let pendingApproval = false; @@ -769,10 +875,12 @@ export class AgentLoop { } callbacks?.onToolStart?.(tc.function.name, input); const toolStartedAt = Date.now(); - const execResult = await this.toolExecutor.execute(tc.function.name, input, { - phaseLock: options?.phaseLock, - restrictRunCommandToReadOnly: options?.restrictRunCommandToReadOnly, - }); + const execResult = allowedToolNames.has(tc.function.name) + ? await this.toolExecutor.execute(tc.function.name, input, { + phaseLock: options?.phaseLock, + restrictRunCommandToReadOnly: options?.restrictRunCommandToReadOnly, + }) + : notOfferedToolResult(tc.function.name); toolCallsMade += 1; return { tc, execResult, durationMs: Date.now() - toolStartedAt }; }) @@ -827,8 +935,9 @@ export class AgentLoop { if (phaseLockRunCommandFailuresThisTurn > 0) { phaseLockRunCommandFailures += phaseLockRunCommandFailuresThisTurn; if (phaseLockRunCommandFailures >= 2) { - messages.push({ role: 'user', content: PHASE_LOCK_RUN_COMMAND_ESCALATION }); - phaseLockRunCommandFailures = 0; + messages.push({ role: 'assistant', content: PHASE_LOCK_RUN_COMMAND_HARD_STOP }); + fullContent += PHASE_LOCK_RUN_COMMAND_HARD_STOP; + break; } } @@ -1028,19 +1137,19 @@ function resolveToolOutput(execResult: import('../safety/ToolExecutor').ToolExec }; } -function repeatedToolInputFailureKey(toolName: string, output: string): string | undefined { - if (!isToolInputValidationFailure(output)) return undefined; +/** + * Keys a failed tool call by tool name + normalized error text so identical failures can be + * counted across steps. Phase-lock failures are excluded — they already have their own + * graduated escalation (nudge, then hard stop) and would otherwise get short-circuited here + * before that instructional message ever reaches the model. + */ +function repeatedToolFailureKey(toolName: string, output: string): string | undefined { + if (!output) return undefined; + if (['write_file', 'apply_patch'].includes(toolName) && isPhaseLockWriteError(output)) return undefined; + if (toolName === 'run_command' && isPhaseLockRunCommandError(output)) return undefined; return `${toolName}:${normalizeToolFailure(output)}`; } -function isToolInputValidationFailure(output: string): boolean { - return /\b(input validation error|invalid input|invalid arguments for tool|expected .* received undefined)\b/i.test(output); -} - -function isRetriableToolFailure(output: string): boolean { - return isToolInputValidationFailure(output); -} - function normalizeToolFailure(output: string): string { return output.replace(/\s+/g, ' ').trim().slice(0, 500); } @@ -1050,8 +1159,8 @@ function buildRepeatedToolInputFailureMessage(toolName: string, output: string, return [ `\n\n### ${REPEATED_TOOL_INPUT_FAILURE_PREFIX}`, '', - `The agent stopped after ${count} consecutive invalid \`${toolName}\` calls. The tool rejected the arguments before execution: ${detail}`, + `The agent stopped after ${count} consecutive \`${toolName}\` calls that failed with the same error: ${detail}`, '', - 'I will not keep retrying the same malformed tool call. The next attempt should use a registered tool with all required arguments, or explain the blocker instead.', + 'I will not keep retrying the same failing tool call. The next attempt should use a different tool, different arguments, or explain the blocker instead.', ].join('\n'); } diff --git a/src/core/runtime/AgentTaskState.ts b/src/core/runtime/AgentTaskState.ts index fc113353..97b9afa8 100644 --- a/src/core/runtime/AgentTaskState.ts +++ b/src/core/runtime/AgentTaskState.ts @@ -117,6 +117,13 @@ export class AgentTaskState { } } + /** Clear cached reads after a failed write/patch so the next read hits disk. */ + recordToolFailure(toolName: string, input: Record): void { + if (!['write_file', 'apply_patch'].includes(toolName)) return; + const editedPath = typeof input.path === 'string' ? input.path : undefined; + if (editedPath) this.invalidateReadsForPath(editedPath); + } + /** Returns block reason if this tool call should be rejected. */ checkBlocked(toolName: string, input: Record): string | null { if (this.getPhase() === 'verify') return null; diff --git a/src/core/runtime/MemoryExtractor.ts b/src/core/runtime/MemoryExtractor.ts index 309b6992..e592a7a1 100644 --- a/src/core/runtime/MemoryExtractor.ts +++ b/src/core/runtime/MemoryExtractor.ts @@ -2,6 +2,7 @@ import type { LlmProvider } from '../llm/types'; import type { MemoryService, ObservationType } from '../memory/MemoryService'; import type { ToolCallAudit } from '../tools/types'; import { PostTaskMemoryWorker } from '../memory/PostTaskMemoryWorker'; +import type { AutoMemoryFileWriter } from '../memory/AutoMemoryFileWriter'; import { createLogger } from '../telemetry/Logger'; const log = createLogger('MemoryExtractor'); @@ -11,7 +12,8 @@ export class MemoryExtractor { constructor( private readonly memoryService: MemoryService, - private readonly summarizeAfterTask: boolean + private readonly summarizeAfterTask: boolean, + private readonly autoMemoryWriter?: AutoMemoryFileWriter ) {} /** Queue post-task extraction asynchronously — does not block the UI. */ @@ -41,18 +43,20 @@ export class MemoryExtractor { } if (filesTouched.size > 0) { - this.memoryService.write( + const observation = this.memoryService.write( sessionId, 'file_fact', `Modified files: ${[...filesTouched].join(', ')}`, [...filesTouched] ); + if (observation) this.autoMemoryWriter?.writeObservation(observation); } const type = inferObservationType(userMessage, assistantResponse); const heuristic = buildHeuristicSummary(userMessage, assistantResponse, toolAudit); if (heuristic) { - this.memoryService.write(sessionId, type, heuristic, [...filesTouched]); + const observation = this.memoryService.write(sessionId, type, heuristic, [...filesTouched]); + if (observation) this.autoMemoryWriter?.writeObservation(observation); } if (this.summarizeAfterTask && provider) { @@ -91,7 +95,8 @@ export class MemoryExtractor { if (delta.content) summary += delta.content; } if (summary.trim()) { - this.memoryService.write(sessionId, 'decision', summary.trim(), files); + const observation = this.memoryService.write(sessionId, 'decision', summary.trim(), files); + if (observation) this.autoMemoryWriter?.writeObservation(observation); } } catch { // Non-fatal diff --git a/src/core/runtime/PlanExecutor.ts b/src/core/runtime/PlanExecutor.ts index d4bae3aa..9067a2e7 100644 --- a/src/core/runtime/PlanExecutor.ts +++ b/src/core/runtime/PlanExecutor.ts @@ -49,6 +49,7 @@ export interface PlanExecutorOptions { planMaxAutoContinues?: number; skillPlaybookContext?: string; onRequirementAnalysisDelta?: (text: string) => void; + onPlanQualityIssues?: (issues: string[]) => void; } export interface StepExecutionResult { @@ -78,6 +79,7 @@ export class PlanExecutor { skillPlaybookContext?: string, onDelta?: (text: string) => void ): AsyncIterable { + log.debug('Analyzing requirements', { taskKind: analysis.kind, complexity: analysis.complexity }); const messages = buildRequirementAnalysisPrompt(pack, userMessage, analysis, skillPlaybookContext); let response = ''; @@ -91,8 +93,10 @@ export class PlanExecutor { } if (!response.trim()) { + log.debug('Requirement analysis was empty, falling back to task summary'); yield analysis.summary; } + log.debug('Requirement analysis finished', { responseChars: response.length }); } async analyzeRequirements( @@ -122,7 +126,16 @@ export class PlanExecutor { let repairNotes = ''; let relaxedFallback: { plan: ThunderPlan; issues: string[] } | null = null; + log.debug('Generating plan', { + mode, + sessionId, + useIsolatedPlanning: Boolean(options?.useIsolatedPlanning), + hasDiscovery: Boolean(planningDiscovery), + taskKind: taskAnalysis?.kind, + }); + for (let attempt = 0; attempt < 2; attempt++) { + log.debug('Plan generation attempt', { attempt: attempt + 1 }); const effectiveAnalysis = repairNotes ? `${requirementAnalysis ?? ''}\n\n## Previous plan was rejected\n${repairNotes}\nRegenerate a valid, more specific plan.` : requirementAnalysis; @@ -155,6 +168,7 @@ export class PlanExecutor { const plan = parseGeneratedPlan(response, mode); if (!plan) { + log.warn('Plan response did not contain valid plan JSON', { attempt: attempt + 1, responseChars: response.length }); repairNotes = '- Response did not contain valid plan JSON with goal and steps/phases.'; continue; } @@ -166,11 +180,13 @@ export class PlanExecutor { const fileStore = new PlanFileStore(options.workspace, sessionId); fileStore.save(plan, 'planning'); } + log.info('Plan generated successfully', { attempt: attempt + 1, goal: plan.goal, steps: plan.steps.length }); return plan; } repairNotes = issues.map((issue) => `- ${issue}`).join('\n'); relaxedFallback = { plan, issues }; + options?.onPlanQualityIssues?.(issues); log.warn('Generated plan failed quality gate', { attempt: attempt + 1, issues }); } @@ -192,6 +208,7 @@ export class PlanExecutor { return plan; } + log.warn('Plan generation failed after all attempts', { mode, hadRelaxedFallback: Boolean(relaxedFallback) }); return null; } @@ -216,6 +233,13 @@ export class PlanExecutor { const readOnlyTools = tools.filter((tool) => PLANNING_DISCOVERY_TOOLS.has(tool.function.name)); let output = ''; + log.debug('Running planning discovery', { + mode, + readOnlyToolCount: readOnlyTools.length, + maxSteps: Math.min(options?.agentMaxSteps ?? 8, 12), + requiresPlanGrounding: mode === 'plan' && needsPlanGrounding(userMessage), + }); + for await (const chunk of this.agentLoop.run( provider, messages, @@ -239,6 +263,7 @@ export class PlanExecutor { if (signal?.aborted) break; } + log.debug('Planning discovery finished', { outputChars: output.length, aborted: Boolean(signal?.aborted) }); return output.trim(); } @@ -256,6 +281,9 @@ export class PlanExecutor { this.stepSummaries = []; this.touchedFiles.clear(); const maxRetries = options?.stepMaxRetries ?? 2; + let hasSuccessfulVerification = false; + + log.debug('Starting plan execution', { goal: plan.goal, steps: plan.steps.length, maxRetries }); this.planPersistence.save(session.id, plan, 'running'); onPlanUpdate?.(plan); @@ -275,6 +303,8 @@ export class PlanExecutor { if (stepIndex < 0 || step.status === 'done') continue; i = stepIndex; + log.debug('Executing step', { stepId: step.id, stepIndex: i + 1, title: step.title, phase: step.phase }); + let attempt = 0; let stepSucceeded = false; let lastValidationErrors: string[] = []; @@ -337,6 +367,7 @@ export class PlanExecutor { yield output; if (execResult.pendingApproval) { + log.debug('Step blocked pending approval', { stepId: step.id, tool: explicitToolCall.name }); plan.steps[i] = { ...plan.steps[i], status: 'blocked' }; this.planPersistence.updatePlan(session.id, plan, 'blocked'); onPlanUpdate?.(plan); @@ -348,6 +379,7 @@ export class PlanExecutor { lastValidationErrors = [`${explicitToolCall.name} failed: ${execResult.error ?? execResult.output}`]; attempt += 1; if (attempt <= maxRetries) { + log.debug('Step tool failed, retrying', { stepId: step.id, tool: explicitToolCall.name, attempt: attempt + 1 }); yield `\n\nStep tool did not complete. Retrying step ${i + 1}/${plan.steps.length} (${attempt + 1}/${maxRetries + 1})…\n`; plan.steps[i] = { ...plan.steps[i], status: 'pending' }; continue; @@ -355,6 +387,7 @@ export class PlanExecutor { plan.steps[i] = { ...plan.steps[i], status: 'failed' }; this.planPersistence.updatePlan(session.id, plan, 'running'); onPlanUpdate?.(plan); + log.warn('Step failed after max retries', { stepId: step.id, tool: explicitToolCall.name, errors: lastValidationErrors }); yield `\n\n❌ Step failed after ${maxRetries + 1} attempts. Errors:\n${lastValidationErrors.join('\n')}\n`; break; } @@ -362,6 +395,9 @@ export class PlanExecutor { if (['write_file', 'apply_patch'].includes(explicitToolCall.name)) { successfulWrites += 1; } + if (isVerifyStep && isVerificationTool(explicitToolCall.name)) { + hasSuccessfulVerification = true; + } } else { for await (const chunk of this.agentLoop.run( provider, @@ -375,6 +411,9 @@ export class PlanExecutor { if (success && ['write_file', 'apply_patch'].includes(name)) { successfulWrites += 1; } + if (isVerifyStep && success && isVerificationTool(name)) { + hasSuccessfulVerification = true; + } if ( isVerifyStep && name === 'run_command' && @@ -399,6 +438,7 @@ export class PlanExecutor { } if (pendingApproval) { + log.debug('Step blocked pending approval', { stepId: step.id }); plan.steps[i] = { ...plan.steps[i], status: 'blocked' }; this.planPersistence.updatePlan(session.id, plan, 'blocked'); onPlanUpdate?.(plan); @@ -435,6 +475,7 @@ export class PlanExecutor { plan.steps[i] = { ...plan.steps[i], status: 'failed' }; this.planPersistence.updatePlan(session.id, plan, 'running'); onPlanUpdate?.(plan); + log.warn('Step failed validation after max retries', { stepId: step.id, errors: lastValidationErrors }); yield `\n\n❌ Step failed after ${maxRetries + 1} attempts. Errors:\n${lastValidationErrors.join('\n')}\n`; break; } @@ -453,6 +494,7 @@ export class PlanExecutor { plan.steps[i] = { ...plan.steps[i], status: 'failed' }; this.planPersistence.updatePlan(session.id, plan, 'running'); onPlanUpdate?.(plan); + log.warn('Verification step failed after max retries', { stepId: step.id, failedVerifyCommands }); yield `\n\n❌ Verification step failed after ${maxRetries + 1} attempts.\n`; break; } @@ -462,6 +504,7 @@ export class PlanExecutor { this.stepSummaries.push(`Step ${i + 1} (${step.title}): ${summary}`); plan.steps[i] = { ...plan.steps[i], status: 'done' }; const stepDurationMs = Date.now() - stepStartedAt; + log.debug('Step completed', { stepId: step.id, stepIndex: i + 1, durationMs: stepDurationMs, attempt: attempt + 1 }); options?.sessionLog?.appendTiming(`plan_step:${step.id}`, stepDurationMs, { title: step.title, stepIndex: i + 1, @@ -486,7 +529,7 @@ export class PlanExecutor { const blocked = plan.steps.some((s) => s.status === 'blocked'); const allDone = plan.steps.every((s) => s.status === 'done'); - if (allDone && !blocked && options?.finalValidationEnabled !== false) { + if (allDone && !blocked && !hasSuccessfulVerification && options?.finalValidationEnabled !== false) { yield '\n\n### Final validation\n\n'; for await (const chunk of this.runFinalValidation( session, @@ -795,6 +838,10 @@ function summarizeToolExecution(toolName: string, result: ToolExecutionResult): return `\n\n${toolName} ${result.success ? 'succeeded' : 'failed'}${capped ? `:\n${capped}\n` : '.\n'}`; } +function isVerificationTool(toolName: string): boolean { + return ['run_command', 'diagnostics', 'execute_workspace_script'].includes(toolName); +} + function summarizeStepOutput(output: string, title: string): string { const trimmed = output.trim(); if (!trimmed) return `Completed: ${title}`; diff --git a/src/core/runtime/ResearchAgent.ts b/src/core/runtime/ResearchAgent.ts index b72c1baa..5e48f7dd 100644 --- a/src/core/runtime/ResearchAgent.ts +++ b/src/core/runtime/ResearchAgent.ts @@ -7,7 +7,7 @@ import { createLogger } from '../telemetry/Logger'; const log = createLogger('ResearchAgent'); const DEFAULT_RESEARCH_SYSTEM = `You are a read-only research subagent. Investigate ONLY the assigned task. -Use read_file, read_files, list_files, search, search_batch, repo_map, and read-only run_command. +Use read_file, read_files, resolve_path, list_files, search, search_batch, repo_map, and read-only run_command. Rules: - Complete in ≤4 tool rounds. Be fast and focused. @@ -22,6 +22,7 @@ If your task is to check unused dependencies, refuse and tell the main agent to const READ_ONLY_TOOL_NAMES = new Set([ 'read_file', 'read_files', + 'resolve_path', 'list_files', 'search', 'search_batch', diff --git a/src/core/runtime/SubagentTracker.ts b/src/core/runtime/SubagentTracker.ts index 6e37ecc5..37a99394 100644 --- a/src/core/runtime/SubagentTracker.ts +++ b/src/core/runtime/SubagentTracker.ts @@ -4,8 +4,11 @@ export type SubagentStatus = 'queued' | 'running' | 'done' | 'error'; export interface SubagentRun { id: string; + type: string; task: string; focus?: string; + scope?: string; + progress?: number; status: SubagentStatus; startedAt: number; finishedAt?: number; @@ -28,11 +31,14 @@ export class SubagentTracker { this.notify(); } - start(task: string, focus?: string): string { + start(task: string, focus?: string, metadata: { type?: string; scope?: string; progress?: number } = {}): string { const run: SubagentRun = { id: randomUUID(), + type: metadata.type ?? 'research', task, focus, + scope: metadata.scope, + progress: metadata.progress, status: 'running', startedAt: Date.now(), }; @@ -41,10 +47,10 @@ export class SubagentTracker { return run.id; } - finish(id: string, summary: string): void { + finish(id: string, summary: string, metadata: { progress?: number } = {}): void { this.runs = this.runs.map((r) => r.id === id - ? { ...r, status: 'done' as const, finishedAt: Date.now(), summary: summary.slice(0, 300) } + ? { ...r, status: 'done' as const, finishedAt: Date.now(), progress: metadata.progress ?? r.progress, summary: summary.slice(0, 300) } : r ); this.notify(); diff --git a/src/core/runtime/TaskAnalyzer.ts b/src/core/runtime/TaskAnalyzer.ts index 70f35df5..cea3cf41 100644 --- a/src/core/runtime/TaskAnalyzer.ts +++ b/src/core/runtime/TaskAnalyzer.ts @@ -2,7 +2,7 @@ import { extractOriginalTaskMessage, isApprovalContinuationMessage } from './tas import { routeAskIntent } from '../modes/ask/AskIntentRouter'; import { routePlanIntent } from '../modes/plan/PlanIntentRouter'; -export type TaskKind = 'question' | 'audit' | 'simple_edit' | 'implementation' | 'explicit_plan'; +export type TaskKind = 'question' | 'audit' | 'simple_edit' | 'implementation' | 'explicit_plan' | 'debugging'; export type TaskComplexity = 'low' | 'medium' | 'high'; @@ -35,12 +35,24 @@ const QUESTION = const DIRECT_ERROR_FIX = /\b(syntax error|type error|referenceerror|cannot find module|missing semicolon|unexpected token|unexpected character|parse error|compilation (?:error|failed)|mdx compilation failed|could not parse expression|is not defined|enoent|can'?t resolve|module not found|compiled with problems)\b/i; +const DIAGNOSTIC_REQUEST = + /\b(identify (?:the )?(?:issues?|problems?|bugs?|errors?)|find (?:the )?(?:issues?|problems?|bugs?|errors?)|what(?:'s| is) (?:wrong|broken)|why (?:doesn'?t|does ?n'?t|isn'?t|is ?n'?t|won'?t|can'?t|cannot)|diagnose|root cause|investigate why|figure out why|spot (?:the )?(?:issues?|bugs?|problems?)|doesn'?t (?:read|work|load|render|run|start)|does not (?:read|work|load|render|run|start))\b/i; + +const DIAGNOSTIC_SCOPE_EXPANDING = + /\b(refactor|redesign|rewrite|migrate|implement (?:a|the) new|new feature|entire codebase|whole codebase|across (?:the )?(?:whole|entire)?\s*(?:codebase|project|repo))\b/i; + const FILE_PATH_IN_TEXT = /(?:^|\s|['"`])([\w./-]+\.(?:tsx?|jsx?|py|go|rs|json|css|scss|mdx?))\b/i; const SIMPLE_EDIT = /\b(fix typo|rename|change (?:the )?(?:name|text|label)|update import|add comment|format)\b/i; +const SIMPLE_CONTENT_APPEND = + /\b(add|append|insert|extend|update)\b[\s\S]{0,100}\b(?:day|days|row|rows|line|lines|entry|entries|section|sections|module|modules)\b/i; + +const SIMPLE_FILE_TARGET = + /\b(?:to|in|into|at (?:the )?end of)\b[\s\S]{0,80}\b[\w./-]+\.(txt|md|csv|json|yaml|yml)\b|\b(?:plan|roadmap|curriculum|schedule)\b/i; + const AUDIT_CLEANUP = /\b(unus[a-z]*|dead code|orphan|cleanup|clean up|remove\s+(?:all\s+)?(?:the\s+)?(?:(?:uns[a-z]*|unused)\s+)?(?:imports?|files?|dependenc(?:y|ies)?)|depcheck|dependencies audit|dependency audit|find unused|list unused|reduce bundle|tree[- ]shake)\b/i; @@ -173,6 +185,20 @@ function classifyTask(text: string): TaskAnalysis { }; } + if (DIAGNOSTIC_REQUEST.test(text) && !DIAGNOSTIC_SCOPE_EXPANDING.test(text)) { + const fileMatch = text.match(FILE_PATH_IN_TEXT); + return { + kind: 'debugging', + complexity: 'low', + shouldPlan: false, + shouldVerify: true, + shouldUseSubagents: false, + summary: fileMatch + ? `Diagnosis request — read ${fileMatch[1]}, identify the root cause, and report or apply a minimal fix without replanning.` + : 'Diagnosis request — read the referenced file(s)/logs, identify the root cause, and report or apply a minimal fix without replanning.', + }; + } + if (SIMPLE_EDIT.test(text) && text.length < 120) { return { kind: 'simple_edit', @@ -184,6 +210,21 @@ function classifyTask(text: string): TaskAnalysis { }; } + if ( + SIMPLE_CONTENT_APPEND.test(text) && + (SIMPLE_FILE_TARGET.test(text) || text.length < 180) && + !/\b(refactor|migrate|implement|build|across|entire|whole codebase)\b/i.test(text) + ) { + return { + kind: 'simple_edit', + complexity: 'low', + shouldPlan: false, + shouldVerify: true, + shouldUseSubagents: false, + summary: 'Single-file content append — read the target file and patch directly.', + }; + } + if (DOCS_IMPLEMENTATION.test(text)) { const docsComplexity = estimateComplexity(text) === 'low' ? 'medium' : estimateComplexity(text); return { @@ -204,11 +245,14 @@ function classifyTask(text: string): TaskAnalysis { const hasImplementationHint = IMPLEMENTATION_HINTS.test(text); const isUiPolishTask = (ACTION_VERBS.test(text) || hasImplementationHint) && UI_POLISH_SCOPE.test(text); + const connectorImpliesMultiStep = + connectorCount >= 1 && (text.length > 140 || complexity !== 'low' || fileMentions >= 2); + const isImplementation = isUiPolishTask || (actionCount >= 1 && (hasImplementationHint || - connectorCount >= 1 || + connectorImpliesMultiStep || fileMentions >= 2 || text.length > 140 || complexity !== 'low')); diff --git a/src/core/runtime/askMode.ts b/src/core/runtime/askMode.ts index 960aebfd..d494da53 100644 --- a/src/core/runtime/askMode.ts +++ b/src/core/runtime/askMode.ts @@ -5,6 +5,7 @@ import { routeAskIntent } from '../modes/ask/AskIntentRouter'; export const ASK_ALLOWED_TOOLS = new Set([ 'read_file', 'read_files', + 'resolve_path', 'list_files', 'search', 'search_batch', @@ -20,6 +21,7 @@ export const ASK_ALLOWED_TOOLS = new Set([ 'fetch_web', 'ask_question', 'spawn_research_agent', + 'spawn_subagent', 'project_catalog', 'analyze_change_impact', ]); @@ -27,6 +29,7 @@ export const ASK_ALLOWED_TOOLS = new Set([ const GROUNDING_TOOLS = new Set([ 'read_file', 'read_files', + 'resolve_path', 'search', 'search_batch', 'retrieve_context', @@ -36,6 +39,7 @@ const GROUNDING_TOOLS = new Set([ 'diagnostics', 'execute_workspace_script', 'spawn_research_agent', + 'spawn_subagent', 'project_catalog', 'analyze_change_impact', ]); @@ -98,6 +102,7 @@ Provide your complete final answer NOW in plain text: export const NO_TOOLS_ASK_NUDGE = `You answered without reading or searching the codebase. For Ask mode you MUST ground factual claims in tools first. In this turn, call at least one of: +- resolve_path — confirm an exact file path before reading - read_file / read_files — inspect specific files - search / search_batch — find symbols, routes, or patterns - retrieve_context — widen context for the question diff --git a/src/core/runtime/taskMessage.ts b/src/core/runtime/taskMessage.ts index 913e96d6..89cbb241 100644 --- a/src/core/runtime/taskMessage.ts +++ b/src/core/runtime/taskMessage.ts @@ -1,5 +1,38 @@ const CONTINUATION_PREFIX = /^continue the current approved task from where it paused\b/i; +const SHORT_CONTINUATION = + /^(?:add them|do it|yes\.?|go ahead\.?|please do\.?|continue\.?|proceed\.?|fix it\.?|try again\.?|same thing\.?)$/i; + +/** Expand terse follow-ups ("add them") using the latest substantive user turn. */ +export function resolveConversationTaskMessage( + message: string, + recentMessages: Array<{ role: string; content: string }> = [] +): string { + const trimmed = message.trim(); + if (!trimmed || trimmed.length > 80 || isApprovalContinuationMessage(trimmed)) { + return trimmed; + } + + const looksLikeContinuation = + SHORT_CONTINUATION.test(trimmed) || + (trimmed.length <= 40 && + !/\b(implement|build|create|refactor|migrate|audit|cleanup|debug)\b/i.test(trimmed) && + !/\.(?:tsx?|jsx?|py|go|rs|json|md|txt|csv)\b/i.test(trimmed)); + + if (!looksLikeContinuation) return trimmed; + + for (let index = recentMessages.length - 1; index >= 0; index -= 1) { + const entry = recentMessages[index]; + if (entry.role !== 'user') continue; + const prior = entry.content.trim(); + if (prior.length >= 20 && prior !== trimmed) { + return `${trimmed}\n\n(Context from earlier request: ${prior})`; + } + } + + return trimmed; +} + /** Extract the user's real request from an approval-continuation prompt. */ export function extractOriginalTaskMessage(message: string): string | null { const trimmed = message.trim(); diff --git a/src/core/safety/ApprovalQueue.ts b/src/core/safety/ApprovalQueue.ts index 7d85c46a..967f51bb 100644 --- a/src/core/safety/ApprovalQueue.ts +++ b/src/core/safety/ApprovalQueue.ts @@ -37,6 +37,7 @@ export class ApprovalQueue { metadata?: { toolCallId?: string } ): ApprovalRequest { const path = typeof input.path === 'string' ? input.path : undefined; + const paths = Array.isArray(input.paths) ? input.paths.filter((p): p is string => typeof p === 'string') : undefined; const contentLen = typeof input.content === 'string' ? input.content.length : undefined; const request: ApprovalRequest = { @@ -44,7 +45,7 @@ export class ApprovalQueue { sessionId, toolName, inputPreview: buildDisplayPreview(toolName, input), - files: path ? [path] : [], + files: path ? [path] : paths ?? [], risk: toolName.includes('write') || toolName.includes('patch') || toolName === 'run_command' ? 'high' : 'medium', reason: policy.reason, policy, @@ -146,6 +147,12 @@ function buildDisplayPreview(toolName: string, input: Record): if (toolName === 'apply_patch' && typeof input.path === 'string') { return `Patch file: ${input.path}`; } + if (toolName === 'read_file' && typeof input.path === 'string') { + return `Read external file (outside workspace): ${input.path}`; + } + if (toolName === 'read_files' && Array.isArray(input.paths)) { + return `Read external file(s) (outside workspace):\n${input.paths.join('\n')}`; + } if (toolName === 'run_command' && typeof input.command === 'string') { return `Run: ${input.command.slice(0, 200)}`; } diff --git a/src/core/safety/ToolExecutor.ts b/src/core/safety/ToolExecutor.ts index 870e6f0c..68809f78 100644 --- a/src/core/safety/ToolExecutor.ts +++ b/src/core/safety/ToolExecutor.ts @@ -1,4 +1,5 @@ import type { ToolRuntime } from '../tools/ToolRuntime'; +import { readApprovedExternalFile, readApprovedExternalFiles } from '../tools/builtinTools'; import type { ToolPolicyEngine } from './ToolPolicyEngine'; import type { ApprovalQueue } from './ApprovalQueue'; import type { AgentTaskState } from '../runtime/AgentTaskState'; @@ -159,13 +160,15 @@ export class ToolExecutor { } } - const result = await this.toolRuntime.execute(resolvedName, input); + const result: ToolExecutionResult = await this.toolRuntime.execute(resolvedName, input); log.info('Tool executed via executor', { tool: resolvedName, success: result.success }); if (result.success) { if (['write_file', 'apply_patch'].includes(resolvedName)) { this.phaseLockWriteBlocks = 0; } this.getTaskState?.()?.recordToolSuccess(resolvedName, input, result.output); + } else if (!result.pendingApproval && !result.skipped) { + this.getTaskState?.()?.recordToolFailure(resolvedName, input); } return result; } @@ -269,6 +272,22 @@ export class ToolExecutor { } async executeApproved(toolName: string, input: Record): Promise { + // read_file/read_files only ever land here via approval when the target path is + // outside the workspace (see ToolPolicyEngine.findExternalFilePath) — the tools' + // own implementations always refuse those paths outright as a defense-in-depth + // boundary, so the actual read happens here instead, once approval is granted. + if (toolName === 'read_file' && typeof input.path === 'string') { + const result = await readApprovedExternalFile(input.path); + if (result.success) this.getTaskState?.()?.recordToolSuccess(toolName, input, result.output); + return result; + } + if (toolName === 'read_files' && Array.isArray(input.paths)) { + const paths = input.paths.filter((p): p is string => typeof p === 'string'); + const result = await readApprovedExternalFiles(paths); + if (result.success) this.getTaskState?.()?.recordToolSuccess(toolName, input, result.output); + return result; + } + const result = await this.toolRuntime.execute(toolName, input); if (result.success) { this.getTaskState?.()?.recordToolSuccess(toolName, input, result.output); diff --git a/src/core/safety/ToolPolicyEngine.ts b/src/core/safety/ToolPolicyEngine.ts index b464629d..00b6f33b 100644 --- a/src/core/safety/ToolPolicyEngine.ts +++ b/src/core/safety/ToolPolicyEngine.ts @@ -1,3 +1,5 @@ +import { existsSync, statSync } from 'fs'; +import { isAbsolute } from 'path'; import { isReadOnlyCommand } from '../plans/PlanActEngine'; export type PolicyDecision = 'allow' | 'require_approval' | 'block'; @@ -15,12 +17,16 @@ const DANGEROUS_COMMANDS = [ ]; const READ_ONLY_TOOLS = new Set([ - 'read_file', 'read_files', 'list_files', 'search', 'search_batch', 'repo_map', - 'retrieve_context', 'git_diff', 'diagnostics', 'memory_search', 'spawn_research_agent', + 'read_file', 'read_files', 'resolve_path', 'list_files', 'search', 'search_batch', 'repo_map', + 'retrieve_context', 'git_diff', 'diagnostics', 'memory_search', 'spawn_research_agent', 'spawn_subagent', 'save_task_state', 'search_script_catalog', 'execute_workspace_script', 'use_skill', 'fetch_web', 'ask_question', 'mark_step_complete', 'propose_plan_mutation', ]); +/** Read tools that take a workspace-relative path — checked against the workspace + * boundary so reaching outside it goes through approval instead of being silently allowed. */ +const PATH_READ_TOOLS = new Set(['read_file', 'read_files']); + const WRITE_TOOLS = new Set(['write_file', 'apply_patch', 'memory_write']); const SHELL_TOOLS = new Set(['run_command']); @@ -38,7 +44,9 @@ export class ToolPolicyEngine { constructor( private safetyConfig: SafetyConfig, private readonly isIgnoredPath: (path: string) => boolean, - private readonly isWorkspaceTrusted: () => boolean = () => true + private readonly isWorkspaceTrusted: () => boolean = () => true, + /** Resolves a raw path to a workspace-relative path, or null if it falls outside the workspace. */ + private readonly resolveWorkspaceRelPath: (path: string) => string | null = () => null ) {} updateSafetyConfig(safetyConfig: SafetyConfig): void { @@ -69,6 +77,15 @@ export class ToolPolicyEngine { if (toolName === 'ask_question') { return { decision: 'require_approval', reason: 'Clarifying question requires user response' }; } + if (PATH_READ_TOOLS.has(toolName)) { + const externalPath = this.findExternalFilePath(toolName, input); + if (externalPath) { + return { + decision: 'require_approval', + reason: `Reading a file outside the workspace requires approval: ${externalPath}`, + }; + } + } return { decision: 'allow', reason: 'Read-only tool' }; } @@ -103,6 +120,32 @@ export class ToolPolicyEngine { return { decision: 'require_approval', reason: 'Unknown tool requires approval' }; } + /** Returns the raw path if a read tool targets a real, existing file outside the + * workspace — null otherwise (missing/typo'd paths still fall through to the + * tool's normal "not found" error rather than prompting for approval). */ + private findExternalFilePath(toolName: string, input: Record): string | undefined { + const candidates: string[] = []; + if (toolName === 'read_file' && typeof input.path === 'string') { + candidates.push(input.path); + } + if (toolName === 'read_files' && Array.isArray(input.paths)) { + candidates.push(...input.paths.filter((p): p is string => typeof p === 'string')); + } + + for (const rawPath of candidates) { + if (!isAbsolute(rawPath)) continue; + if (this.resolveWorkspaceRelPath(rawPath) !== null) continue; + try { + if (existsSync(rawPath) && statSync(rawPath).isFile()) { + return rawPath; + } + } catch { + // Not a real file — leave it to the tool's normal not-found handling. + } + } + return undefined; + } + private requiresWriteApproval(): boolean { switch (this.safetyConfig.approvalMode) { case 'auto': diff --git a/src/core/skills/bundled/browser-testing-with-devtools/SKILL.md b/src/core/skills/bundled/browser-testing-with-devtools/SKILL.md index 86b40358..be854287 100644 --- a/src/core/skills/bundled/browser-testing-with-devtools/SKILL.md +++ b/src/core/skills/bundled/browser-testing-with-devtools/SKILL.md @@ -15,7 +15,7 @@ Use this skill when validating UI behavior, screenshots, or client-side flows in ## MCP setup -Mitii preloads `@modelcontextprotocol/server-puppeteer` when `thunder.mcp.builtinServers.puppeteer` is enabled. +Mitii preloads `@modelcontextprotocol/server-puppeteer` when `mitii.mcp.builtinServers.puppeteer` is enabled. Headless CLI: diff --git a/src/core/skills/bundled/planning-and-task-breakdown/SKILL.md b/src/core/skills/bundled/planning-and-task-breakdown/SKILL.md index 2309dbbf..c21986dc 100644 --- a/src/core/skills/bundled/planning-and-task-breakdown/SKILL.md +++ b/src/core/skills/bundled/planning-and-task-breakdown/SKILL.md @@ -1,40 +1,72 @@ --- name: planning-and-task-breakdown -description: Breaks work into ordered tasks. Use when you have a spec or clear requirements and need to break work into implementable tasks. Use when a task feels too large to start, when you need to estimate scope, or when parallel work is possible. +description: Break work into ordered, verifiable tasks at the smallest useful planning depth. Use when there is a spec or clear requirement that needs implementation tasks, when the work feels too large or risky to start directly, when scope needs to be estimated, or when parallel work is possible. For small obvious changes, use a micro-plan instead of a full plan so planning does not become the work. --- # Planning and Task Breakdown ## Overview -Decompose work into small, verifiable tasks with explicit acceptance criteria. Good task breakdown is the difference between an agent that completes work reliably and one that produces a tangled mess. Every task should be small enough to implement, test, and verify in a single focused session. +Decompose work only as much as needed to act safely. Good task breakdown turns vague or risky work into small, verifiable steps. Bad task breakdown turns obvious work into ceremony. Prefer the lightest plan that exposes dependencies, acceptance criteria, and verification. -## When to Use +Every planned task should be small enough to implement, test, and verify in a focused session. When the change is already obvious, write a micro-plan and start. -- You have a spec and need to break it into implementable units -- A task feels too large or vague to start -- Work needs to be parallelized across multiple agents or sessions -- You need to communicate scope to a human -- The implementation order isn't obvious +## Planning Depth -**When NOT to use:** Single-file changes with obvious scope, or when the spec already contains well-defined tasks. +Choose the smallest useful planning shape before writing anything else: + +| Situation | Output | Hard limit | +|---|---|---| +| **Tiny / obvious**: one file, known fix, low risk | Micro-plan | 3 bullets max | +| **Small**: 1-2 files, clear behavior, limited risk | Short task list | 2-4 tasks max | +| **Medium**: 3-5 files, multiple components, some uncertainty | Standard plan | Tasks + dependencies + verification | +| **Large / risky**: cross-cutting, migrations, ambiguous requirements, parallel agents | Full implementation plan | Phases + checkpoints + risks | + +If the plan takes longer to write than the likely code change, stop planning and execute the micro-plan. + +### Micro-Plan Format + +Use this for tiny or obvious work: + +```markdown +Plan: +- Change: [one sentence] +- Verify: [command or manual check] +- Risk: [low/medium/high and why] +``` + +Do not add phases, dependency graphs, or checkpoints to micro-plans. + +### Short Task List Format + +Use this for small work that has more than one step but does not need a full plan: + +```markdown +Tasks: +- [ ] [Small task] — verify with [command/check] +- [ ] [Small task] — verify with [command/check] + +Final check: [command or manual check] +``` + +Keep short task lists to 2-4 tasks. If that is not enough, use the standard task template. ## The Planning Process -### Step 1: Enter Plan Mode +### Step 1: Choose Planning Depth -Before writing any code, operate in read-only mode: +Before writing code, briefly operate in read-only mode: - Read the spec and relevant codebase sections - Identify existing patterns and conventions -- Map dependencies between components -- Note risks and unknowns +- Choose micro, short, standard, or full planning depth +- Note risks and unknowns that change implementation order -**Do NOT write code during planning.** The output is a plan document, not implementation. +Do not write code until the plan shape is chosen. For small obvious work, this may take less than a minute. -### Step 2: Identify the Dependency Graph +### Step 2: Identify Dependencies -Map what depends on what: +For standard and full plans, map what depends on what: ``` Database schema @@ -52,9 +84,9 @@ Database schema └── Seed data / migrations ``` -Implementation order follows the dependency graph bottom-up: build foundations first. +Implementation order follows the dependency graph bottom-up: build foundations first. For micro-plans and short task lists, name only the dependency that actually affects the next step. -### Step 3: Slice Vertically +### Step 3: Slice Vertically When Useful Instead of building all the database, then all the API, then all the UI — build one complete feature path at a time: @@ -74,11 +106,11 @@ Task 3: User can create a task (task schema + API + UI for creation) Task 4: User can view task list (query + API + UI for list view) ``` -Each vertical slice delivers working, testable functionality. +Each vertical slice delivers working, testable functionality. Do not force vertical slicing onto a small local change where a direct edit is clearer. ### Step 4: Write Tasks -Each task follows this structure: +For standard and full plans, each task follows this structure: ```markdown ## Task [N]: [Short descriptive title] @@ -96,11 +128,15 @@ Each task follows this structure: **Dependencies:** [Task numbers this depends on, or "None"] +**Can parallelize:** [Yes/No, and with which task if yes] + **Files likely touched:** - `src/path/to/file.ts` - `tests/path/to/test.ts` -**Estimated scope:** [Small: 1-2 files | Medium: 3-5 files | Large: 5+ files] +**Estimated scope:** [XS: 1 file | S: 1-2 files | M: 3-5 files | L: 5-8 files] + +**Stop condition:** Ask the human if [specific ambiguity, destructive action, or risk appears]. ``` ### Step 5: Order and Checkpoint @@ -122,6 +158,8 @@ Add explicit checkpoints: - [ ] Review with human before proceeding ``` +For micro-plans and short task lists, use a single final verification instead of phase checkpoints. + ## Task Sizing Guidelines | Size | Files | Scope | Example | @@ -140,8 +178,27 @@ If a task is L or larger, it should be broken into smaller tasks. An agent perfo - It touches two or more independent subsystems (e.g., auth and billing) - You find yourself writing "and" in the task title (a sign it is two tasks) +**When NOT to break a task down further:** +- The acceptance criteria are already obvious and testable +- The task is a local edit with one verification command +- Splitting would create sequencing overhead without reducing risk +- The next step is reversible and easy to inspect + +## Anti-Overplanning Rules + +- Prefer a micro-plan for XS work even when this skill is invoked. +- Cap small-task planning at one screen of text. +- Do not create fake phases for a one-sitting change. +- Do not require human approval for low-risk micro-plans unless the user asked for approval first. +- If the only unknown is "which exact line changes?", inspect the code and continue. +- Ask the human only when an assumption changes behavior, data, security, cost, or public API. + +Planning should reduce uncertainty. When it only increases paperwork, shrink the plan. + ## Plan Document Template +Use this only for standard or full plans: + ```markdown # Implementation Plan: [Feature/Project Name] @@ -193,14 +250,17 @@ When multiple agents or sessions are available: - **Must be sequential:** Database migrations, shared state changes, dependency chains - **Needs coordination:** Features that share an API contract (define the contract first, then parallelize) +Do not parallelize XS/S tasks unless they are truly independent. Coordination can cost more than it saves. + ## Common Rationalizations | Rationalization | Reality | |---|---| | "I'll figure it out as I go" | That's how you end up with a tangled mess and rework. 10 minutes of planning saves hours. | -| "The tasks are obvious" | Write them down anyway. Explicit tasks surface hidden dependencies and forgotten edge cases. | -| "Planning is overhead" | Planning is the task. Implementation without a plan is just typing. | +| "The tasks are obvious" | Use a micro-plan. Capture intent and verification, then move. | +| "Planning is overhead" | Oversized planning is overhead. Right-sized planning prevents rework. | | "I can hold it all in my head" | Context windows are finite. Written plans survive session boundaries and compaction. | +| "Small tasks need full plans too" | No. Small tasks need a tiny intent, a verification check, and then execution. | ## Red Flags @@ -210,18 +270,31 @@ When multiple agents or sessions are available: - All tasks are XL-sized - No checkpoints between tasks - Dependency order isn't considered +- The plan is longer than the work it describes +- The agent keeps splitting reversible local edits into separate tasks ## Verification -Before starting implementation, confirm: +Before starting implementation of a standard or full plan, confirm: - [ ] Every task has acceptance criteria - [ ] Every task has a verification step - [ ] Task dependencies are identified and ordered correctly -- [ ] No task touches more than ~5 files -- [ ] Checkpoints exist between major phases -- [ ] The human has reviewed and approved the plan +- [ ] No task touches more than ~5 files unless there is a clear reason +- [ ] Checkpoints exist between major phases when there are phases +- [ ] Human approval is requested for high-risk, ambiguous, destructive, or cross-system plans + +For a micro-plan, confirm only: + +- [ ] The intended change is clear +- [ ] There is a verification command or manual check +- [ ] The risk is low enough to proceed without a full plan ## See Also -Acceptance criteria are per-task and answer "did we build the right thing?". They sit on top of the project-wide Definition of Done, the standing bar every task clears before it counts as done. See `references/definition-of-done.md`. +Acceptance criteria are per-task and answer "did we build the right thing?". They sit on top of the project-wide Definition of Done, the standing bar every task clears before it counts as done (see `using-agent-skills`): + +- [ ] Tests pass +- [ ] No regressions introduced +- [ ] Behavior verified at runtime, not just type-checked or "looks right" +- [ ] Docs updated if behavior or interfaces changed diff --git a/src/core/skills/bundled/using-agent-skills/SKILL.md b/src/core/skills/bundled/using-agent-skills/SKILL.md index c93834be..36920f30 100644 --- a/src/core/skills/bundled/using-agent-skills/SKILL.md +++ b/src/core/skills/bundled/using-agent-skills/SKILL.md @@ -1,6 +1,6 @@ --- name: using-agent-skills -description: Discovers and invokes agent skills. Use when starting a session or when you need to discover which skill applies to the current task. This is the meta-skill that governs how all other skills are discovered and invoked. +description: Discover and invoke the bundled agent skills at the smallest useful process depth. Use when starting a session or when deciding which skill applies to the current task. This meta-skill governs skill discovery, sequencing, verification, and avoiding both under-planning and over-planning. --- # Using Agent Skills @@ -16,31 +16,22 @@ When a task arrives, identify the development phase and apply the corresponding ``` Task arrives │ - ├── Don't know what you want yet? ──────→ interview-me - ├── Have a rough concept, need variants? → idea-refine - ├── New project/feature/change? ──→ spec-driven-development ├── Have a spec, need tasks? ──────→ planning-and-task-breakdown - ├── Implementing code? ────────────→ incremental-implementation - │ ├── UI work? ─────────────────→ frontend-ui-engineering - │ ├── API work? ────────────────→ api-and-interface-design - │ ├── Need better context? ─────→ context-engineering - │ ├── Need doc-verified code? ───→ source-driven-development - │ └── Stakes high / unfamiliar code? ──→ doubt-driven-development ├── Writing/running tests? ────────→ test-driven-development │ └── Browser-based? ───────────→ browser-testing-with-devtools ├── Something broke? ──────────────→ debugging-and-error-recovery ├── Reviewing code? ───────────────→ code-review-and-quality - │ ├── Too complex? ─────────────→ code-simplification - │ ├── Security concerns? ───────→ security-and-hardening │ └── Performance concerns? ────→ performance-optimization - ├── Committing/branching? ─────────→ git-workflow-and-versioning - ├── CI/CD pipeline work? ──────────→ ci-cd-and-automation - ├── Deprecating/migrating? ────────→ deprecation-and-migration - ├── Writing docs/ADRs? ───────────→ documentation-and-adrs - ├── Adding logs/metrics/alerts? ───→ observability-and-instrumentation - └── Deploying/launching? ─────────→ shipping-and-launch + ├── Dead code / dependency audit? ─→ audit-cleanup + ├── Console logs / lint / types? ──→ code-smells-and-tech-debt + ├── Env vars / secrets? ───────────→ environment-and-secrets + └── Committing/branching? ─────────→ git-workflow-and-versioning ``` +Only the skills bundled in `.mitii/skills/` are listed above. If a task needs something this set doesn't cover (e.g. spec-writing, UI-specific guidance, CI/CD), fall back to the general operating behaviors below rather than inventing a skill name to invoke. + +Use the smallest effective workflow. A one-file fix may need only a short intent and a verification command; a cross-system feature may need a full plan, tests, review, and git hygiene. Skill use should lower risk, not add ceremony. + ## Core Operating Behaviors These behaviors apply at all times, across all skills. They are non-negotiable. @@ -110,7 +101,12 @@ Your job is surgical precision, not unsolicited renovation. Every skill includes a verification step. A task is not complete until verification passes. "Seems right" is never sufficient — there must be evidence (passing tests, build output, runtime data). -Per-skill verification is the local check. The project-wide bar that applies to *every* change, regardless of which skill is active, is the Definition of Done: tests pass, no regressions, behavior verified at runtime, docs updated. See `references/definition-of-done.md`. It complements each task's acceptance criteria rather than replacing them. +Per-skill verification is the local check. The project-wide bar that applies to *every* change, regardless of which skill is active, is the Definition of Done. It complements each task's acceptance criteria rather than replacing them: + +- [ ] Tests pass +- [ ] No regressions introduced +- [ ] Behavior verified at runtime, not just type-checked or "looks right" +- [ ] Docs updated if behavior or interfaces changed ## Failure Modes to Avoid @@ -126,6 +122,7 @@ These are the subtle errors that look like productivity but create problems: 8. Removing things you don't fully understand 9. Building without a spec because "it's obvious" 10. Skipping verification because "it looks right" +11. Applying a full workflow to a tiny, reversible task ## Skill Rules @@ -133,31 +130,24 @@ These are the subtle errors that look like productivity but create problems: 2. **Skills are workflows, not suggestions.** Follow the steps in order. Don't skip verification steps. -3. **Multiple skills can apply.** A feature implementation might involve `idea-refine` → `spec-driven-development` → `planning-and-task-breakdown` → `incremental-implementation` → `test-driven-development` → `code-review-and-quality` → `code-simplification` → `shipping-and-launch` in sequence. +3. **Multiple skills can apply.** A feature implementation might involve `planning-and-task-breakdown` → `test-driven-development` → `code-review-and-quality` → `git-workflow-and-versioning` in sequence. -4. **When in doubt, start with a spec.** If the task is non-trivial and there's no spec, begin with `spec-driven-development`. +4. **When in doubt, start with the smallest useful plan.** If the task is non-trivial and there's no task breakdown yet, begin with `planning-and-task-breakdown`. For XS/S tasks, use its micro-plan path and proceed once the change, verification, and risk are clear. ## Lifecycle Sequence For a complete feature, the typical skill sequence is: ``` -1. interview-me → Extract what the user actually wants -2. idea-refine → Refine vague ideas -3. spec-driven-development → Define what we're building -4. planning-and-task-breakdown → Break into verifiable chunks -5. context-engineering → Load the right context -6. source-driven-development → Verify against official docs -7. incremental-implementation → Build slice by slice -8. observability-and-instrumentation → Instrument as you build (runs parallel with 7-9, not after) -9. doubt-driven-development → Cross-examine non-trivial decisions in-flight -10. test-driven-development → Prove each slice works -11. code-review-and-quality → Review before merge -12. code-simplification → Reduce unnecessary complexity while preserving behavior -13. git-workflow-and-versioning → Clean commit history -14. documentation-and-adrs → Document decisions -15. deprecation-and-migration → Retire old systems and move users safely when needed -16. shipping-and-launch → Deploy safely +1. planning-and-task-breakdown → Break into verifiable chunks +2. test-driven-development → Prove each slice works + - browser-testing-with-devtools → Runtime verification for browser-based UI +3. debugging-and-error-recovery → Reproduce → localize → fix → guard, if something breaks +4. code-review-and-quality → Review before merge + - performance-optimization → Measure first, optimize only what matters +5. audit-cleanup / code-smells-and-tech-debt → Dead code, lint, and tech-debt cleanup +6. environment-and-secrets → Env/template drift and secret handling +7. git-workflow-and-versioning → Clean commit history ``` 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`. @@ -166,29 +156,13 @@ Not every task needs every skill. A bug fix might only need: `debugging-and-erro | Phase | Skill | One-Line Summary | |-------|-------|-----------------| -| Define | interview-me | Surface what the user actually wants before any plan, spec, or code exists | -| Define | idea-refine | Refine ideas through structured divergent and convergent thinking | -| Define | spec-driven-development | Requirements and acceptance criteria before code | | Plan | planning-and-task-breakdown | Decompose into small, verifiable tasks | -| Build | incremental-implementation | Thin vertical slices, test each before expanding | -| Build | source-driven-development | Verify against official docs before implementing | -| Build | doubt-driven-development | Adversarial fresh-context review of every non-trivial decision | -| Build | context-engineering | Right context at the right time | -| Build | frontend-ui-engineering | Production-quality UI with accessibility | -| Build | api-and-interface-design | Stable interfaces with clear contracts | | Verify | test-driven-development | Failing test first, then make it pass | -| Verify | browser-testing-with-devtools | Chrome DevTools MCP for runtime verification | +| Verify | browser-testing-with-devtools | Puppeteer MCP for browser automation and 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 | -| Ship | deprecation-and-migration | Remove old systems and migrate users safely | -| Ship | documentation-and-adrs | Document the why, not just the what | -| Ship | observability-and-instrumentation | Structured logs, RED metrics, traces, symptom-based alerts | -| Ship | shipping-and-launch | Pre-launch checklist, monitoring, rollback plan | diff --git a/src/core/subagents/BaseSubagent.ts b/src/core/subagents/BaseSubagent.ts new file mode 100644 index 00000000..0222fe8d --- /dev/null +++ b/src/core/subagents/BaseSubagent.ts @@ -0,0 +1,113 @@ +import { relative } from 'path'; +import { AgentLoop } from '../runtime/AgentLoop'; +import type { ChatMessage, LlmProvider } from '../llm/types'; +import type { ToolDefinition } from '../llm/toolTypes'; +import type { ToolExecutor, ToolExecutionResult, ToolExecuteContext } from '../safety/ToolExecutor'; +import type { SubagentDefinition, SubagentRunInput } from './types'; + +export class BaseSubagent { + constructor(private readonly definition: SubagentDefinition, private readonly toolExecutor: ToolExecutor) {} + + async run(provider: LlmProvider, input: SubagentRunInput, allTools: ToolDefinition[]): Promise { + if (this.definition.requiresScope && !input.scopeRoot && (!input.targetFiles || input.targetFiles.length === 0)) { + return `${this.definition.displayName} subagent refused: explicit targetFiles or scopeRoot is required.`; + } + + const tools = this.filterTools(allTools); + const executor = this.definition.writable + ? new ScopedSubagentExecutor(this.toolExecutor, input.scopeRoot, input.targetFiles) + : new ReadOnlySubagentExecutor(this.toolExecutor, new Set(this.definition.allowedTools)); + const loop = new AgentLoop(executor as unknown as ToolExecutor, this.definition.maxSteps); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.definition.timeoutMs); + input.signal?.addEventListener('abort', () => controller.abort(), { once: true }); + + const messages: ChatMessage[] = [ + { role: 'system', content: buildSystemPrompt(this.definition, input.personaInstructions) }, + { role: 'user', content: buildUserPrompt(input) }, + ]; + + try { + const result = await loop.runToCompletion(provider, messages, tools, controller.signal, undefined, false, { + maxSteps: this.definition.maxSteps, + }); + return result.fullContent || '(no subagent report)'; + } finally { + clearTimeout(timer); + } + } + + private filterTools(allTools: ToolDefinition[]): ToolDefinition[] { + const allowed = new Set(this.definition.allowedTools); + const denied = new Set(this.definition.deniedTools ?? []); + return allTools.filter((tool) => { + const name = tool.function.name; + return allowed.has(name) && !denied.has(name) && !name.startsWith('mcp__'); + }); + } +} + +class ReadOnlySubagentExecutor { + constructor(private readonly inner: ToolExecutor, private readonly allowed: Set) {} + + clearPlanPhaseLock(): void { + this.inner.clearPlanPhaseLock?.(); + } + + execute(toolName: string, input: Record, context?: ToolExecuteContext): Promise { + if (!this.allowed.has(toolName)) { + return Promise.resolve({ success: false, output: '', error: `Tool ${toolName} is not allowed for this subagent` }); + } + return this.inner.execute(toolName, input, { ...context, restrictRunCommandToReadOnly: true }); + } +} + +class ScopedSubagentExecutor { + constructor( + private readonly inner: ToolExecutor, + private readonly scopeRoot?: string, + private readonly targetFiles?: string[] + ) {} + + clearPlanPhaseLock(): void { + this.inner.clearPlanPhaseLock?.(); + } + + execute(toolName: string, input: Record, context?: ToolExecuteContext): Promise { + if ((toolName === 'write_file' || toolName === 'apply_patch') && !this.isPathInScope(input.path)) { + return Promise.resolve({ + success: false, + output: '', + error: `Write blocked: ${String(input.path ?? '')} is outside the subagent scope`, + }); + } + return this.inner.execute(toolName, input, context); + } + + private isPathInScope(path: unknown): boolean { + if (typeof path !== 'string') return false; + const normalized = path.replace(/\\/g, '/').replace(/^\.?\//, ''); + if (this.targetFiles?.some((target) => normalized === target.replace(/\\/g, '/').replace(/^\.?\//, ''))) { + return true; + } + if (!this.scopeRoot) return false; + const rel = relative(this.scopeRoot, normalized).replace(/\\/g, '/'); + return rel === '' || (!rel.startsWith('..') && !rel.startsWith('/')); + } +} + +function buildSystemPrompt(definition: SubagentDefinition, personaInstructions?: string): string { + const persona = personaInstructions?.trim() + ? `\n\nAdditional workspace/persona instructions:\n${personaInstructions.trim().slice(0, 1600)}` + : ''; + return `${definition.systemPrompt}${persona}`; +} + +function buildUserPrompt(input: SubagentRunInput): string { + const parts = [`## Task\n${input.task}`]; + if (input.focus) parts.push(`## Focus\n${input.focus}`); + if (input.scopeRoot) parts.push(`## Scope root\n${input.scopeRoot}`); + if (input.targetFiles?.length) parts.push(`## Target files\n${input.targetFiles.join('\n')}`); + if (input.commands?.length) parts.push(`## Commands\n${input.commands.join('\n')}`); + return parts.join('\n\n'); +} diff --git a/src/core/subagents/SubagentDefinition.ts b/src/core/subagents/SubagentDefinition.ts new file mode 100644 index 00000000..c4229b08 --- /dev/null +++ b/src/core/subagents/SubagentDefinition.ts @@ -0,0 +1,78 @@ +import type { SubagentDefinition } from './types'; + +const READ_TOOLS = [ + 'read_file', + 'read_files', + 'resolve_path', + 'list_files', + 'search', + 'search_batch', + 'repo_map', + 'retrieve_context', + 'git_diff', + 'diagnostics', + 'memory_search', + 'run_command', +]; + +export const BUILTIN_SUBAGENTS: SubagentDefinition[] = [ + { + id: 'research', + displayName: 'Research', + allowedTools: READ_TOOLS, + deniedTools: ['write_file', 'apply_patch', 'memory_write', 'spawn_subagent', 'spawn_research_agent'], + writable: false, + risk: 'low', + maxSteps: 6, + timeoutMs: 90_000, + systemPrompt: `You are a read-only research subagent. Investigate ONLY the assigned task. +Use batched reads/searches when possible. Complete quickly and return a concise report with findings, file paths, and confidence. +Do NOT edit files or explore unrelated areas.`, + }, + { + id: 'implementer', + displayName: 'Implementer', + allowedTools: [ + ...READ_TOOLS, + 'write_file', + 'apply_patch', + 'execute_workspace_script', + 'search_script_catalog', + ], + deniedTools: ['spawn_subagent', 'spawn_research_agent', 'memory_write'], + writable: true, + risk: 'high', + maxSteps: 8, + timeoutMs: 120_000, + requiresScope: true, + systemPrompt: `You are a scoped implementation subagent. +Implement ONLY the assigned scope. Do not refactor unrelated code. Before writing, confirm the target files or scope root. +Run diagnostics or targeted verification after edits. Return summary, files changed, and verification output.`, + }, + { + id: 'reviewer', + displayName: 'Reviewer', + allowedTools: [...READ_TOOLS, 'analyze_change_impact'], + deniedTools: ['write_file', 'apply_patch', 'memory_write', 'spawn_subagent', 'spawn_research_agent'], + writable: false, + risk: 'low', + maxSteps: 8, + timeoutMs: 120_000, + systemPrompt: `You are a read-only reviewer subagent. +Review the requested task or diff for bugs, regressions, missing tests, and maintainability risks. +Return structured sections: Critical, Major, Minor, Suggestions. Include file paths and evidence.`, + }, + { + id: 'verifier', + displayName: 'Verifier', + allowedTools: ['run_command', 'read_file', 'read_files', 'list_files', 'search', 'diagnostics', 'execute_workspace_script'], + deniedTools: ['write_file', 'apply_patch', 'memory_write', 'spawn_subagent', 'spawn_research_agent'], + writable: false, + risk: 'medium', + maxSteps: 6, + timeoutMs: 180_000, + systemPrompt: `You are a verification subagent. +Run the requested test, lint, typecheck, build, or diagnostic commands. Interpret failures without making edits. +Return pass/fail, key output excerpts, likely cause, and suggested fix surface in under 500 words.`, + }, +]; diff --git a/src/core/subagents/SubagentRegistry.ts b/src/core/subagents/SubagentRegistry.ts new file mode 100644 index 00000000..ea62b5c4 --- /dev/null +++ b/src/core/subagents/SubagentRegistry.ts @@ -0,0 +1,36 @@ +import { BUILTIN_SUBAGENTS } from './SubagentDefinition'; +import type { SubagentDefinition, SubagentType } from './types'; + +export class SubagentRegistry { + private readonly definitions = new Map(); + + constructor(definitions: SubagentDefinition[] = BUILTIN_SUBAGENTS) { + for (const definition of definitions) { + this.register(definition); + } + } + + register(definition: SubagentDefinition): void { + this.definitions.set(definition.id, definition); + } + + get(type: SubagentType): SubagentDefinition | undefined { + return this.definitions.get(type); + } + + list(): SubagentDefinition[] { + return [...this.definitions.values()]; + } + + merge(definitions: SubagentDefinition[]): void { + for (const definition of definitions) { + this.register(definition); + } + } +} + +export function createDefaultSubagentRegistry(extra: SubagentDefinition[] = []): SubagentRegistry { + const registry = new SubagentRegistry(); + registry.merge(extra); + return registry; +} diff --git a/src/core/subagents/index.ts b/src/core/subagents/index.ts new file mode 100644 index 00000000..f8edc9ad --- /dev/null +++ b/src/core/subagents/index.ts @@ -0,0 +1,5 @@ +export { BaseSubagent } from './BaseSubagent'; +export { BUILTIN_SUBAGENTS } from './SubagentDefinition'; +export { SubagentRegistry, createDefaultSubagentRegistry } from './SubagentRegistry'; +export { loadWorkspaceAgents } from './loadWorkspaceAgents'; +export type { SubagentDefinition, SubagentRunInput, SubagentRuntime, SubagentType } from './types'; diff --git a/src/core/subagents/loadWorkspaceAgents.ts b/src/core/subagents/loadWorkspaceAgents.ts new file mode 100644 index 00000000..cb9c3d8e --- /dev/null +++ b/src/core/subagents/loadWorkspaceAgents.ts @@ -0,0 +1,90 @@ +import { existsSync, readFileSync, readdirSync } from 'fs'; +import { extname, join } from 'path'; +import { z } from 'zod'; +import type { SubagentDefinition } from './types'; + +const AgentSchema = z.object({ + id: z.string().min(1), + type: z.string().optional(), + displayName: z.string().optional(), + tools: z.array(z.string()).optional(), + allowedTools: z.array(z.string()).optional(), + deniedTools: z.array(z.string()).optional(), + maxSteps: z.number().int().min(1).max(50).optional(), + timeoutMs: z.number().int().min(10_000).max(600_000).optional(), + writable: z.boolean().optional(), + risk: z.enum(['low', 'medium', 'high']).optional(), + requiresScope: z.boolean().optional(), + systemPrompt: z.string().optional(), +}); + +export interface WorkspaceAgentLoadResult { + agents: SubagentDefinition[]; + warnings: string[]; +} + +export function loadWorkspaceAgents(workspace: string): WorkspaceAgentLoadResult { + const dir = join(workspace, '.mitii', 'agents'); + if (!existsSync(dir)) return { agents: [], warnings: [] }; + const agents: SubagentDefinition[] = []; + const warnings: string[] = []; + for (const file of readdirSync(dir).filter((name) => /\.(md|json|ya?ml)$/i.test(name))) { + const path = join(dir, file); + try { + const raw = readFileSync(path, 'utf-8'); + const parsed = extname(file) === '.json' ? JSON.parse(raw) : parseMarkdownAgent(raw); + const validated = AgentSchema.parse(parsed); + const prompt = validated.systemPrompt ?? parsed.body ?? ''; + if (!prompt.trim()) throw new Error('Agent prompt/body is required'); + agents.push({ + id: validated.id, + displayName: validated.displayName ?? titleize(validated.id), + allowedTools: validated.allowedTools ?? validated.tools ?? ['read_file', 'read_files', 'search', 'search_batch', 'git_diff', 'diagnostics'], + deniedTools: validated.deniedTools, + systemPrompt: prompt, + maxSteps: validated.maxSteps ?? 8, + timeoutMs: validated.timeoutMs ?? 120_000, + writable: validated.writable ?? false, + risk: validated.risk ?? (validated.writable ? 'high' : 'low'), + requiresScope: validated.requiresScope ?? validated.writable ?? false, + }); + } catch (error) { + warnings.push(`${file}: ${error instanceof Error ? error.message : String(error)}`); + } + } + return { agents, warnings }; +} + +function parseMarkdownAgent(raw: string): Record & { body?: string } { + const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); + if (!match) { + return { id: 'custom-agent', body: raw }; + } + return { ...parseYamlLite(match[1] ?? ''), body: match[2] ?? '' }; +} + +function parseYamlLite(raw: string): Record { + const out: Record = {}; + for (const line of raw.split(/\r?\n/)) { + const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/); + if (!match) continue; + const [, key, value] = match; + out[key] = parseYamlValue(value); + } + return out; +} + +function parseYamlValue(value: string): unknown { + const trimmed = value.trim(); + if (trimmed === 'true') return true; + if (trimmed === 'false') return false; + if (/^\d+$/.test(trimmed)) return Number(trimmed); + if (trimmed.startsWith('[') && trimmed.endsWith(']')) { + return trimmed.slice(1, -1).split(',').map((item) => item.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean); + } + return trimmed.replace(/^['"]|['"]$/g, ''); +} + +function titleize(id: string): string { + return id.split(/[-_]/).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(' '); +} diff --git a/src/core/subagents/types.ts b/src/core/subagents/types.ts new file mode 100644 index 00000000..6c99b65b --- /dev/null +++ b/src/core/subagents/types.ts @@ -0,0 +1,40 @@ +import type { LlmProvider } from '../llm/types'; +import type { ToolDefinition } from '../llm/toolTypes'; +import type { ToolExecutor } from '../safety/ToolExecutor'; + +export type SubagentType = 'research' | 'implementer' | 'reviewer' | 'verifier' | string; +export type SubagentRisk = 'low' | 'medium' | 'high'; + +export interface SubagentDefinition { + id: SubagentType; + displayName: string; + allowedTools: string[]; + deniedTools?: string[]; + systemPrompt: string; + maxSteps: number; + timeoutMs: number; + writable: boolean; + risk: SubagentRisk; + requiresScope?: boolean; +} + +export interface SubagentRunInput { + task: string; + focus?: string; + targetFiles?: string[]; + scopeRoot?: string; + commands?: string[]; + personaInstructions?: string; + signal?: AbortSignal; +} + +export interface SubagentRuntime { + toolExecutor: ToolExecutor; + getProvider: () => LlmProvider | undefined; + getTools: () => ToolDefinition[]; + maxSteps?: number; + timeoutMs?: number; + enabledTypes?: string[]; + maxConcurrent?: number; + workspace?: string; +} diff --git a/src/core/task/ParallelAgentRunner.ts b/src/core/task/ParallelAgentRunner.ts new file mode 100644 index 00000000..71559b85 --- /dev/null +++ b/src/core/task/ParallelAgentRunner.ts @@ -0,0 +1,83 @@ +import { HeadlessAgentHost } from '../headless/HeadlessAgentHost'; +import { WorktreeService } from '../git'; +import { TaskBoardService } from './TaskBoardService'; +import type { MitiiTask } from './types'; + +export interface ParallelAgentRunnerOptions { + workspace: string; + parallel?: number; + runtime?: 'real' | 'stub'; + providerType?: ConstructorParameters[0]['providerType']; + baseUrl?: string; + model?: string; + apiKey?: string; +} + +export interface ParallelAgentRunResult { + started: string[]; + completed: string[]; + failed: Array<{ id: string; error: string }>; +} + +export class ParallelAgentRunner { + private readonly board: TaskBoardService; + private readonly worktrees: WorktreeService; + + constructor(private readonly options: ParallelAgentRunnerOptions) { + this.board = new TaskBoardService(options.workspace); + this.worktrees = new WorktreeService(options.workspace); + } + + async runRunnable(): Promise { + const queue = [...this.board.runnable()]; + const parallel = Math.max(1, Math.min(this.options.parallel ?? 2, 8)); + const result: ParallelAgentRunResult = { started: [], completed: [], failed: [] }; + const workers = Array.from({ length: Math.min(parallel, queue.length) }, async () => { + while (queue.length) { + const task = queue.shift(); + if (!task) return; + result.started.push(task.id); + try { + await this.runTask(task); + result.completed.push(task.id); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.board.update(task.id, { status: 'failed', error: message }); + result.failed.push({ id: task.id, error: message }); + } + } + }); + await Promise.all(workers); + return result; + } + + async runTask(task: MitiiTask): Promise { + this.board.transition(task.id, 'running'); + const worktree = await this.worktrees.create({ taskId: task.id }); + this.board.update(task.id, { worktreeId: worktree.taskId, branch: worktree.branch }); + const host = new HeadlessAgentHost({ + cwd: worktree.path, + runtime: this.options.runtime, + providerType: this.options.providerType, + baseUrl: this.options.baseUrl, + model: this.options.model, + apiKey: this.options.apiKey, + approval: 'auto', + indexWorkspace: false, + }); + const chunks: string[] = []; + try { + for await (const event of host.agent(task.prompt)) { + if (event.type === 'assistant_delta') chunks.push(event.content); + if (event.type === 'error') throw new Error(event.message); + } + const summary = chunks.join('').trim() || 'Task completed.'; + this.board.update(task.id, { + status: 'review', + result: { summary, filesChanged: [] }, + }); + } finally { + await host.dispose(); + } + } +} diff --git a/src/core/task/TaskBoardService.ts b/src/core/task/TaskBoardService.ts new file mode 100644 index 00000000..2f2f3a53 --- /dev/null +++ b/src/core/task/TaskBoardService.ts @@ -0,0 +1,97 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { dirname, join } from 'path'; +import { randomUUID } from 'crypto'; +import type { MitiiTask, MitiiTaskStatus } from './types'; + +export class TaskBoardService { + private readonly boardPath: string; + + constructor(workspace: string) { + this.boardPath = join(workspace, '.mitii', 'tasks', 'board.json'); + } + + list(): MitiiTask[] { + return this.readBoard().tasks; + } + + add(input: { title: string; prompt: string; dependsOn?: string[] }): MitiiTask { + const now = Date.now(); + const task: MitiiTask = { + id: randomUUID().slice(0, 8), + title: input.title, + prompt: input.prompt, + status: 'backlog', + dependsOn: input.dependsOn ?? [], + createdAt: now, + updatedAt: now, + }; + const board = this.readBoard(); + this.writeBoard({ tasks: [...board.tasks, task] }); + return task; + } + + update(id: string, patch: Partial): MitiiTask { + const board = this.readBoard(); + const current = board.tasks.find((task) => task.id === id); + if (!current) throw new Error(`Task not found: ${id}`); + const next = { ...current, ...patch, updatedAt: Date.now() }; + const tasks = board.tasks.map((task) => task.id === id ? next : task); + assertNoCycles(tasks); + this.writeBoard({ tasks }); + return next; + } + + transition(id: string, status: MitiiTaskStatus): MitiiTask { + if (status === 'running') { + const task = this.list().find((item) => item.id === id); + const blockedBy = (task?.dependsOn ?? []).filter((dep) => this.list().find((item) => item.id === dep)?.status !== 'done'); + if (blockedBy.length) throw new Error(`Task ${id} is blocked by: ${blockedBy.join(', ')}`); + } + return this.update(id, { status }); + } + + remove(id: string): boolean { + const board = this.readBoard(); + const tasks = board.tasks.filter((task) => task.id !== id); + if (tasks.length === board.tasks.length) return false; + this.writeBoard({ tasks }); + return true; + } + + runnable(): MitiiTask[] { + const tasks = this.list(); + return tasks.filter((task) => + task.status === 'backlog' && + (task.dependsOn ?? []).every((dep) => tasks.find((candidate) => candidate.id === dep)?.status === 'done') + ); + } + + private readBoard(): { tasks: MitiiTask[] } { + if (!existsSync(this.boardPath)) return { tasks: [] }; + const parsed = JSON.parse(readFileSync(this.boardPath, 'utf-8')) as { tasks?: MitiiTask[] }; + return { tasks: Array.isArray(parsed.tasks) ? parsed.tasks : [] }; + } + + private writeBoard(board: { tasks: MitiiTask[] }): void { + assertNoCycles(board.tasks); + mkdirSync(dirname(this.boardPath), { recursive: true }); + writeFileSync(this.boardPath, `${JSON.stringify(board, null, 2)}\n`, 'utf-8'); + } +} + +function assertNoCycles(tasks: MitiiTask[]): void { + const byId = new Map(tasks.map((task) => [task.id, task])); + const visiting = new Set(); + const visited = new Set(); + const visit = (id: string) => { + if (visited.has(id)) return; + if (visiting.has(id)) throw new Error(`Circular task dependency at ${id}`); + visiting.add(id); + for (const dep of byId.get(id)?.dependsOn ?? []) { + if (byId.has(dep)) visit(dep); + } + visiting.delete(id); + visited.add(id); + }; + for (const task of tasks) visit(task.id); +} diff --git a/src/core/task/index.ts b/src/core/task/index.ts index 4b95aec8..09197117 100644 --- a/src/core/task/index.ts +++ b/src/core/task/index.ts @@ -1,2 +1,4 @@ export * from './types'; export * from './enrichTask'; +export { TaskBoardService } from './TaskBoardService'; +export { ParallelAgentRunner, type ParallelAgentRunnerOptions } from './ParallelAgentRunner'; diff --git a/src/core/task/types.ts b/src/core/task/types.ts index 248b7915..f21e7556 100644 --- a/src/core/task/types.ts +++ b/src/core/task/types.ts @@ -16,3 +16,19 @@ export interface EnrichedTask { contextBlocks: string[]; signals: TaskSignals; } +export type MitiiTaskStatus = 'backlog' | 'running' | 'review' | 'done' | 'failed' | 'cancelled'; + +export interface MitiiTask { + id: string; + title: string; + prompt: string; + status: MitiiTaskStatus; + worktreeId?: string; + sessionId?: string; + branch?: string; + dependsOn?: string[]; + createdAt: number; + updatedAt: number; + result?: { summary: string; filesChanged: string[] }; + error?: string; +} diff --git a/src/core/teams/TeamService.ts b/src/core/teams/TeamService.ts new file mode 100644 index 00000000..e6b6e671 --- /dev/null +++ b/src/core/teams/TeamService.ts @@ -0,0 +1,149 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; +import { randomUUID } from 'crypto'; + +export interface TeamManifest { + id: string; + name: string; + createdAt: number; + updatedAt: number; + workspace?: string; + roles: string[]; +} + +export interface TeamTask { + id: string; + title: string; + prompt: string; + status: 'queued' | 'running' | 'review' | 'done' | 'failed'; + teamId: string; + assigneeRole?: string; + createdAt: number; + updatedAt: number; +} + +export interface TeamMailboxMessage { + id: string; + from: string; + to: string; + text: string; + ts: number; + read: boolean; +} + +export class TeamService { + private readonly baseDir: string; + + constructor(baseDir = join(homedir(), '.mitii', 'teams')) { + this.baseDir = baseDir; + } + + create(name: string, options: { workspace?: string; roles?: string[] } = {}): TeamManifest { + const dir = this.teamDir(name); + mkdirSync(dir, { recursive: true }); + const now = Date.now(); + const manifest: TeamManifest = { + id: slug(name), + name, + workspace: options.workspace, + roles: options.roles ?? ['planner', 'implementer', 'reviewer', 'docs'], + createdAt: now, + updatedAt: now, + }; + this.writeJson(name, 'manifest.json', manifest); + this.writeJson(name, 'task-board.json', { tasks: [] }); + this.writeJson(name, 'mailbox.json', { messages: [] }); + this.appendMission(name, `Team created: ${name}`); + return manifest; + } + + get(name: string): TeamManifest | undefined { + return this.readJson(name, 'manifest.json'); + } + + status(name: string): { manifest: TeamManifest; tasks: TeamTask[]; messages: TeamMailboxMessage[] } | undefined { + const manifest = this.get(name); + if (!manifest) return undefined; + return { + manifest, + tasks: this.tasks(name), + messages: this.messages(name), + }; + } + + addTask(name: string, input: { title: string; prompt: string; assigneeRole?: string }): TeamTask { + const manifest = this.requireTeam(name); + const tasks = this.tasks(name); + const now = Date.now(); + const task: TeamTask = { + id: randomUUID(), + title: input.title, + prompt: input.prompt, + status: 'queued', + teamId: manifest.id, + assigneeRole: input.assigneeRole, + createdAt: now, + updatedAt: now, + }; + tasks.push(task); + this.writeJson(name, 'task-board.json', { tasks }); + this.appendMission(name, `Task added: ${task.title}${task.assigneeRole ? ` -> ${task.assigneeRole}` : ''}`); + return task; + } + + sendMessage(name: string, input: { from: string; to: string; text: string }): TeamMailboxMessage { + this.requireTeam(name); + const messages = this.messages(name); + const message: TeamMailboxMessage = { + id: randomUUID(), + from: input.from, + to: input.to, + text: input.text, + ts: Date.now(), + read: false, + }; + messages.push(message); + this.writeJson(name, 'mailbox.json', { messages }); + this.appendMission(name, `Message ${message.from} -> ${message.to}`); + return message; + } + + private tasks(name: string): TeamTask[] { + return this.readJson<{ tasks?: TeamTask[] }>(name, 'task-board.json')?.tasks ?? []; + } + + private messages(name: string): TeamMailboxMessage[] { + return this.readJson<{ messages?: TeamMailboxMessage[] }>(name, 'mailbox.json')?.messages ?? []; + } + + private requireTeam(name: string): TeamManifest { + const manifest = this.get(name); + if (!manifest) throw new Error(`Team not found: ${name}`); + return manifest; + } + + private appendMission(name: string, event: string): void { + const path = join(this.teamDir(name), 'mission-log.jsonl'); + writeFileSync(path, `${JSON.stringify({ ts: Date.now(), event })}\n`, { flag: 'a' }); + } + + private readJson(name: string, file: string): T | undefined { + const path = join(this.teamDir(name), file); + if (!existsSync(path)) return undefined; + return JSON.parse(readFileSync(path, 'utf8')) as T; + } + + private writeJson(name: string, file: string, value: unknown): void { + mkdirSync(this.teamDir(name), { recursive: true }); + writeFileSync(join(this.teamDir(name), file), `${JSON.stringify(value, null, 2)}\n`, 'utf8'); + } + + private teamDir(name: string): string { + return join(this.baseDir, slug(name)); + } +} + +function slug(value: string): string { + return value.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-|-$/g, '') || 'default'; +} diff --git a/src/core/teams/index.ts b/src/core/teams/index.ts new file mode 100644 index 00000000..94155d12 --- /dev/null +++ b/src/core/teams/index.ts @@ -0,0 +1 @@ +export * from './TeamService'; diff --git a/src/core/telemetry/Logger.ts b/src/core/telemetry/Logger.ts index 60a06d8f..ed2c1d6e 100644 --- a/src/core/telemetry/Logger.ts +++ b/src/core/telemetry/Logger.ts @@ -8,14 +8,19 @@ const SECRET_PATTERNS = [ /password["\s:=]+["']?[^\s"']{4,}/gi, ]; -export type LogLevel = 'info' | 'warn' | 'error'; +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; export interface Logger { + debug(message: string, meta?: Record): void; info(message: string, meta?: Record): void; warn(message: string, meta?: Record): void; error(message: string, meta?: Record): void; } +function isDebugEnabled(): boolean { + return process.env.MITII_DEBUG === '1'; +} + function redactSecrets(value: string): string { let result = value; for (const pattern of SECRET_PATTERNS) { @@ -54,11 +59,16 @@ export function createLogger(scope: string): Logger { const prefix = `[${AGENT_NAME}:${scope}]`; function log(level: LogLevel, message: string, meta?: Record): void { + if (level === 'debug' && !isDebugEnabled()) return; + const safeMessage = redactSecrets(message); const safeMeta = sanitizeMeta(meta); const line = safeMeta ? `${prefix} ${safeMessage} ${JSON.stringify(safeMeta)}` : `${prefix} ${safeMessage}`; switch (level) { + case 'debug': + console.debug(line); + break; case 'info': console.log(line); break; @@ -72,6 +82,7 @@ export function createLogger(scope: string): Logger { } return { + debug: (message, meta) => log('debug', message, meta), info: (message, meta) => log('info', message, meta), warn: (message, meta) => log('warn', message, meta), error: (message, meta) => log('error', message, meta), diff --git a/src/core/telemetry/SessionLogService.ts b/src/core/telemetry/SessionLogService.ts index 2b17b68c..61e308ee 100644 --- a/src/core/telemetry/SessionLogService.ts +++ b/src/core/telemetry/SessionLogService.ts @@ -56,6 +56,7 @@ export class SessionLogService { private logPath = ''; private logStartedAt = 0; private webhookEmitter = new WebhookEmitter(); + private listeners = new Set<(event: SessionLogEvent) => void>(); configure(workspace: string, sessionId: string, enabled = true, debugMetrics = false): void { const sessionChanged = this.sessionId !== sessionId; @@ -99,6 +100,11 @@ export class SessionLogService { this.writeEvent(type, message, data); } + onEvent(listener: (event: SessionLogEvent) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + /** Verbose diagnostics — only written when `telemetry.debugMetrics` is enabled. */ appendDebug(type: SessionLogEventType, message: string, data?: Record): void { if (!this.isEnabled() || !this.debugMetrics) return; @@ -135,6 +141,7 @@ export class SessionLogService { try { appendFileSync(this.logPath, `${JSON.stringify(event)}\n`, 'utf-8'); this.webhookEmitter.emit(event); + this.notifyListeners(event); } catch (error) { log.warn('Failed to append session log', { error: error instanceof Error ? error.message : String(error), @@ -177,6 +184,14 @@ export class SessionLogService { message: 'Session started', data: sanitizeLogData(header), }); + this.notifyListeners({ + 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), @@ -184,6 +199,18 @@ export class SessionLogService { } } + private notifyListeners(event: SessionLogEvent): void { + for (const listener of this.listeners) { + try { + listener(event); + } catch (error) { + log.warn('Session log listener failed', { + error: error instanceof Error ? error.message : String(error), + }); + } + } + } + exportForAnalysis(): string { if (!this.logPath || !existsSync(this.logPath)) { return ''; diff --git a/src/core/tools/ToolRuntime.ts b/src/core/tools/ToolRuntime.ts index d0847295..9fe545f0 100644 --- a/src/core/tools/ToolRuntime.ts +++ b/src/core/tools/ToolRuntime.ts @@ -53,6 +53,7 @@ export class ToolRuntime { output: '', error: `Unknown tool: ${name}${resolvedName !== name ? ` (alias for ${resolvedName} not registered)` : ''}`, }; + this.auditLog.push({ toolName: resolvedName, input: normalized, result, timestamp: Date.now() }); this.logToolEnd(resolvedName, normalized, result, startedAt, toolCallId); return result; } @@ -60,6 +61,7 @@ export class ToolRuntime { const parsed = tool.inputSchema.safeParse(normalized); if (!parsed.success) { const result = { success: false, output: '', error: `Invalid input: ${parsed.error.message}` }; + this.auditLog.push({ toolName: resolvedName, input: normalized, result, timestamp: Date.now() }); this.logToolEnd(resolvedName, normalized, result, startedAt, toolCallId); return result; } diff --git a/src/core/tools/builtinTools.ts b/src/core/tools/builtinTools.ts index e94c188d..8ab1c12a 100644 --- a/src/core/tools/builtinTools.ts +++ b/src/core/tools/builtinTools.ts @@ -3,8 +3,7 @@ import { z } from 'zod'; import { mkdirSync, readFileSync, readdirSync, writeFileSync, statSync } from 'fs'; import { readFile } from 'fs/promises'; import { dirname, isAbsolute, join, relative, resolve } from 'path'; -import { exec, execFile } from 'child_process'; -import { promisify } from 'util'; +import { spawn } from 'child_process'; import type { Tool, ToolResult } from './types'; import type { IgnoreService } from '../indexing/IgnoreService'; import type { FtsIndex } from '../indexing/FtsIndex'; @@ -19,42 +18,140 @@ import { validateMdxContent } from '../apply/mdxValidation'; import { isDangerousCommand } from '../safety/ToolPolicyEngine'; import { isReadOnlyCommand, stripLeadingCd } from '../plans/PlanActEngine'; import { normalizeWorkspaceRoot, resolveWorkspaceRelPath, formatPathNotFoundHint } from '../util/paths'; -import { ResearchAgent } from '../runtime/ResearchAgent'; +import type { ThunderDb } from '../indexing/ThunderDb'; +import { createWorkspacePathResolver } from '../paths/WorkspacePathResolver'; +import { BaseSubagent, createDefaultSubagentRegistry, loadWorkspaceAgents, type SubagentRuntime } from '../subagents'; import { isAuditSubagentBlocked, buildScriptFirstAuditMessage } from '../runtime/auditRouting'; import type { SubagentTracker } from '../runtime/SubagentTracker'; -import type { LlmProvider } from '../llm/types'; -import type { ToolDefinition } from '../llm/toolTypes'; -import type { ToolExecutor } from '../safety/ToolExecutor'; import type { SkillCatalogService } from '../skills/SkillCatalogService'; import { createLogger } from '../telemetry/Logger'; import { analyzeChangeImpact, discoverProjectCatalog, formatProjectCatalog, saveProjectCatalog } from '../modes/ask'; import { filterItemsToScope, normalizeScopeRoot } from '../context/scopeFilter'; -const execAsync = promisify(exec); -const execFileAsync = promisify(execFile); const log = createLogger('BuiltinTools'); -export interface ResearchAgentRuntime { - toolExecutor: ToolExecutor; - getProvider: () => LlmProvider | undefined; - getTools: () => ToolDefinition[]; - maxSteps?: number; - timeoutMs?: number; +interface SpawnCaptureOptions { + cwd?: string; + env?: NodeJS.ProcessEnv; + timeout?: number; + maxBuffer?: number; } -let researchAgentRuntime: ResearchAgentRuntime | undefined; -let researchAgent: ResearchAgent | undefined; +interface SpawnCaptureError extends Error { + code?: number; + stdout?: string; + stderr?: string; +} + +/** + * exec/execFile always create a stdin pipe even when nothing is written to it. In some + * host environments (e.g. a VS Code extension host with no valid fd 0) creating that pipe + * fails with `spawn EBADF`. spawn() lets us set stdin to 'ignore' to avoid it entirely. + */ +function spawnCapture( + command: string, + args: string[], + options: SpawnCaptureOptions +): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { + cwd: options.cwd, + env: options.env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + const maxBuffer = options.maxBuffer ?? 4 * 1024 * 1024; + let stdout = ''; + let stderr = ''; + let stdoutBytes = 0; + let stderrBytes = 0; + let settled = false; + let timeoutId: ReturnType | undefined; + + const finish = (err: SpawnCaptureError | null, result?: { stdout: string; stderr: string }) => { + if (settled) return; + settled = true; + if (timeoutId) clearTimeout(timeoutId); + if (err) reject(err); + else resolvePromise(result!); + }; + + child.stdout?.on('data', (chunk: Buffer) => { + stdoutBytes += chunk.length; + if (stdoutBytes <= maxBuffer) stdout += chunk.toString('utf-8'); + }); + child.stderr?.on('data', (chunk: Buffer) => { + stderrBytes += chunk.length; + if (stderrBytes <= maxBuffer) stderr += chunk.toString('utf-8'); + }); + + child.on('error', (error) => { + const err = error as SpawnCaptureError; + err.stdout = stdout; + err.stderr = stderr; + finish(err); + }); + + child.on('close', (code) => { + if (code === 0 || code === null) { + finish(null, { stdout, stderr }); + } else { + const err = new Error(`Command failed with exit code ${code}`) as SpawnCaptureError; + err.code = code; + err.stdout = stdout; + err.stderr = stderr; + finish(err); + } + }); + + if (options.timeout) { + timeoutId = setTimeout(() => { + child.kill('SIGTERM'); + const err = new Error(`Command timed out after ${options.timeout}ms`) as SpawnCaptureError; + err.stdout = stdout; + err.stderr = stderr; + finish(err); + }, options.timeout); + } + }); +} + +/** Runs `command` through a shell, mirroring child_process.exec but with safe stdio. */ +function execShellSafe( + command: string, + options: SpawnCaptureOptions +): Promise<{ stdout: string; stderr: string }> { + const isWindows = process.platform === 'win32'; + const shell = isWindows ? process.env.ComSpec ?? 'cmd.exe' : '/bin/sh'; + const shellArgs = isWindows ? ['/d', '/s', '/c', command] : ['-c', command]; + return spawnCapture(shell, shellArgs, options); +} + +/** Runs `file` directly (no shell), mirroring child_process.execFile but with safe stdio. */ +function execFileSafe( + file: string, + args: string[], + options: SpawnCaptureOptions +): Promise<{ stdout: string; stderr: string }> { + return spawnCapture(file, args, options); +} + +export type ResearchAgentRuntime = SubagentRuntime; + +let subagentRuntime: SubagentRuntime | undefined; let subagentTracker: SubagentTracker | undefined; +let activeSubagents = 0; export function setSubagentTracker(tracker: SubagentTracker | undefined): void { subagentTracker = tracker; } export function setResearchAgentRuntime(runtime: ResearchAgentRuntime | undefined): void { - researchAgentRuntime = runtime; - researchAgent = runtime - ? new ResearchAgent(runtime.toolExecutor, runtime.maxSteps ?? 6, runtime.timeoutMs ?? 90_000) - : undefined; + setSubagentRuntime(runtime); +} + +export function setSubagentRuntime(runtime: SubagentRuntime | undefined): void { + subagentRuntime = runtime; } function blockedPath(relPath: string, ignoreService: IgnoreService, forRead = false): boolean { @@ -73,6 +170,13 @@ function resolveToolPath(workspace: string, rawPath: string, ignoreService: Igno const SOURCE_FILE_PATTERN = /\.(?:tsx?|jsx?|mjs|cjs|css|scss|sass|less|json|ya?ml)$/i; const READ_FILE_MAX_CHARS = 50000; const READ_FILES_MAX_PATHS = 12; + +/** Caps file content for the model and, unlike a silent slice, tells it more was cut off. */ +function truncateFileContent(content: string): string { + if (content.length <= READ_FILE_MAX_CHARS) return content; + const remaining = content.length - READ_FILE_MAX_CHARS; + return `${content.slice(0, READ_FILE_MAX_CHARS)}\n...(truncated, ${remaining} more characters — read a narrower range or a more specific file if you need the rest)`; +} const SHELL_COMMAND_CONTENT_PREFIX = /^(?:git\s+(?:checkout|restore|reset|clean|pull|push|commit|merge|rebase|switch)\b|(?:npm|yarn|pnpm|npx)\s+|rm\s+-|mv\s+|cp\s+|sed\s+-i\b|cat\s+>|echo\s+.+>|python(?:3)?\s+|node\s+|bash\s+|sh\s+)/i; @@ -110,7 +214,7 @@ function updateReadFileCache(workspace: string, relPath: string, content: string try { const st = statSync(join(workspace, relPath)); getReadFileCache(workspace).set(relPath, { - content: content.slice(0, READ_FILE_MAX_CHARS), + content: truncateFileContent(content), mtimeMs: st.mtimeMs, size: st.size, }); @@ -136,22 +240,32 @@ function validateWriteFileContent(relPath: string, content: string): string | un return undefined; } -export function createReadFileTool(workspace: string, ignoreService: IgnoreService): Tool<{ path: string }> { +export function createReadFileTool( + workspace: string, + ignoreService: IgnoreService, + db?: ThunderDb +): Tool<{ path: string }> { return { name: 'read_file', - description: 'Read one workspace file. For multiple files, prefer read_files in a single call.', + description: + 'Read one workspace file. Missing paths are auto-resolved via the workspace index when confidence is high. For multiple files, prefer read_files. Use resolve_path when unsure.', risk: 'low', inputSchema: z.object({ path: z.string() }), async execute(input): Promise { - return readSingleFile(workspace, input.path, ignoreService); + return readSingleFile(workspace, input.path, ignoreService, db); }, }; } -export function createReadFilesTool(workspace: string, ignoreService: IgnoreService): Tool<{ paths: string[] }> { +export function createReadFilesTool( + workspace: string, + ignoreService: IgnoreService, + db?: ThunderDb +): Tool<{ paths: string[] }> { return { name: 'read_files', - description: 'Read multiple workspace files in one call. Max 12 paths per call; prefer 8-10. If you need more, split into another read_files call.', + description: + 'Read multiple workspace files in one call. Max 12 paths per call; prefer 8-10. Missing paths are auto-resolved when confidence is high. Use resolve_path for uncertain paths.', risk: 'low', inputSchema: z.object({ paths: z.array(z.string()).min(1) }), parametersJsonSchema: { @@ -179,7 +293,7 @@ export function createReadFilesTool(workspace: string, ignoreService: IgnoreServ } const results = await Promise.all(paths.map(async (path) => ({ path, - result: await readSingleFile(workspace, path, ignoreService), + result: await readSingleFile(workspace, path, ignoreService, db), }))); for (const { path, result } of results) { parts.push(result.success @@ -191,10 +305,69 @@ export function createReadFilesTool(workspace: string, ignoreService: IgnoreServ }; } +async function readWorkspaceFileContent( + workspace: string, + relPath: string +): Promise<{ success: true; output: string } | { success: false; error: string }> { + try { + const fullPath = join(workspace, relPath); + const st = statSync(fullPath); + if (!st.isFile()) { + return { success: false, error: `Not a file: ${relPath}` }; + } + const cached = getReadFileCache(workspace).get(relPath); + if (cached && cached.mtimeMs === st.mtimeMs && cached.size === st.size) { + return { success: true, output: cached.content }; + } + const content = await readFile(fullPath, 'utf-8'); + const truncated = truncateFileContent(content); + getReadFileCache(workspace).set(relPath, { + content: truncated, + mtimeMs: st.mtimeMs, + size: st.size, + }); + return { success: true, output: truncated }; + } catch (e) { + return { success: false, error: String(e) }; + } +} + +/** + * Reads a file outside the workspace. Only reachable after the user has explicitly + * approved the read_file/read_files call via the approval queue (see ToolExecutor.executeApproved) + * — readSingleFile below always refuses external paths outright as a defense-in-depth boundary. + */ +export async function readApprovedExternalFile(rawPath: string): Promise { + try { + const st = statSync(rawPath); + if (!st.isFile()) { + return { success: false, output: '', error: `Not a file: ${rawPath}` }; + } + const content = await readFile(rawPath, 'utf-8'); + return { + success: true, + output: `[External file outside the workspace — read with user approval]\n${truncateFileContent(content)}`, + }; + } catch (e) { + return { success: false, output: '', error: String(e) }; + } +} + +export async function readApprovedExternalFiles(rawPaths: string[]): Promise { + const results = await Promise.all( + rawPaths.map(async (path) => ({ path, result: await readApprovedExternalFile(path) })) + ); + const parts = results.map(({ path, result }) => ( + result.success ? `### ${path}\n${result.output}` : `### ${path}\nERROR: ${result.error}` + )); + return { success: true, output: parts.join('\n\n') }; +} + async function readSingleFile( workspace: string, rawPath: string, - ignoreService: IgnoreService + ignoreService: IgnoreService, + db?: ThunderDb ): Promise { const relPath = resolveWorkspaceRelPath(workspace, rawPath); if (relPath === null) { @@ -207,31 +380,93 @@ async function readSingleFile( error: `Path is ignored: ${rawPath}. For built package exports read packages/*/src/index.ts instead of dist/.`, }; } - try { - const fullPath = join(workspace, relPath); - const st = statSync(fullPath); - const cached = getReadFileCache(workspace).get(relPath); - if (cached && cached.mtimeMs === st.mtimeMs && cached.size === st.size) { - return { success: true, output: cached.content }; - } - const content = await readFile(fullPath, 'utf-8'); - getReadFileCache(workspace).set(relPath, { - content: content.slice(0, READ_FILE_MAX_CHARS), - mtimeMs: st.mtimeMs, - size: st.size, - }); - return { success: true, output: content.slice(0, READ_FILE_MAX_CHARS) }; - } catch (e) { - const err = String(e); - if (err.includes('ENOENT')) { + + const direct = await readWorkspaceFileContent(workspace, relPath); + if (direct.success) { + return { success: true, output: direct.output }; + } + + const resolver = createWorkspacePathResolver({ workspace, db, ignoreService }); + const resolution = resolver.resolve(rawPath); + + if (resolution.autoResolved && resolution.resolvedPath && resolution.resolvedPath !== relPath) { + if (ignoreService.isIgnored(resolution.resolvedPath, { forRead: true })) { return { success: false, output: '', - error: formatPathNotFoundHint(workspace, rawPath, relPath), + error: `Resolved path is ignored: ${resolution.resolvedPath}`, }; } - return { success: false, output: '', error: err }; + const resolvedRead = await readWorkspaceFileContent(workspace, resolution.resolvedPath); + if (resolvedRead.success) { + const candidate = resolution.candidates.find((c) => c.relPath === resolution.resolvedPath) + ?? resolution.candidates[0]; + const prefix = candidate + ? `${resolver.formatAutoResolvedNote(rawPath, resolution.resolvedPath, candidate)}\n` + : `[Path auto-resolved] ${rawPath} → ${resolution.resolvedPath}\n---\n`; + updateReadFileCache(workspace, resolution.resolvedPath, resolvedRead.output); + return { success: true, output: `${prefix}${resolvedRead.output}` }; + } + } + + if (resolution.candidates.length > 0) { + return { + success: false, + output: '', + error: resolver.formatUnresolvedMessage(rawPath, resolution), + }; } + + return { + success: false, + output: '', + error: `File not found: ${rawPath}. Use resolve_path, search, or list_files before reading.`, + }; +} + +export function createResolvePathTool( + workspace: string, + ignoreService: IgnoreService, + db?: ThunderDb +): Tool<{ path: string; scopeRoot?: string }> { + return { + name: 'resolve_path', + description: + 'Resolve a workspace file path using the SQLite index, layout heuristics, and filesystem search. Returns ranked candidates and auto-resolution when confidence is high. Use before read_file when the exact path is uncertain.', + risk: 'low', + inputSchema: z.object({ + path: z.string(), + scopeRoot: z.string().optional(), + }), + async execute(input): Promise { + const resolver = createWorkspacePathResolver({ + workspace, + db, + ignoreService, + scopeRoot: input.scopeRoot, + }); + const result = resolver.resolve(input.path); + const lines = [ + `Requested: ${input.path}`, + `Normalized: ${result.normalizedRequest || '(invalid)'}`, + `Confidence: ${result.confidence}`, + ]; + if (result.autoResolved && result.resolvedPath) { + lines.push(`Auto-resolved: ${result.resolvedPath}`); + } + if (result.candidates.length === 0) { + lines.push('No indexed or filesystem matches. Try search or list_files on the parent directory.'); + } else { + lines.push('Candidates:'); + for (const [index, candidate] of result.candidates.entries()) { + lines.push( + `${index + 1}. ${candidate.relPath} (score ${candidate.score}, ${candidate.source}) — ${candidate.reason}` + ); + } + } + return { success: true, output: lines.join('\n') }; + }, + }; } export function createListFilesTool( @@ -259,7 +494,7 @@ export function createListFilesTool( const entryRel = relPath ? join(listRel, entry).replace(/\\/g, '/') : entry; try { const stat = statSync(join(base, entry)); - return !ignoreService.isIgnored(stat.isDirectory() ? `${entryRel}/` : entryRel); + return !ignoreService.isIgnored(stat.isDirectory() ? `${entryRel}/` : entryRel, { forRead: true }); } catch { return false; } @@ -328,7 +563,7 @@ async function ripgrepSearch(workspace: string, query: string, limit: number, sc const rgPath = rg.rgPath; const scope = normalizeScopeRoot(scopeRoot); const target = scope ? JSON.stringify(scope) : '.'; - const { stdout } = await execAsync( + const { stdout } = await execShellSafe( `"${rgPath}" --no-heading --line-number --max-count ${limit} --regexp ${JSON.stringify(query)} ${target}`, { cwd: workspace, maxBuffer: 2 * 1024 * 1024, timeout: 15000 } ); @@ -341,7 +576,7 @@ async function ripgrepSearch(workspace: string, query: string, limit: number, sc export function createSearchTool(fts: FtsIndex, workspace?: string): Tool<{ query: string; limit?: number; scopeRoot?: string }> { return { name: 'search', - description: 'Search code (FTS + ripgrep). For multiple patterns, use search_batch in one call. Use scopeRoot to limit to one project/package.', + description: 'Search code (FTS index + ripgrep, merged). For multiple patterns, use search_batch in one call. Use scopeRoot to limit to one project/package. Copy rel_path values exactly into read_file.', risk: 'low', inputSchema: z.object({ query: z.string(), limit: z.number().optional(), scopeRoot: z.string().optional() }), async execute(input): Promise { @@ -465,7 +700,7 @@ export function createExecuteWorkspaceScriptTool( const runner = entry.name.endsWith('.mjs') ? process.execPath : 'bash'; try { - const { stdout, stderr } = await execFileAsync(runner, args, { + const { stdout, stderr } = await execFileSafe(runner, args, { cwd: workspace, maxBuffer: 2 * 1024 * 1024, timeout: 120000, @@ -483,6 +718,14 @@ export function createExecuteWorkspaceScriptTool( if (entry.readOnly && (err.code === 1 || output.length > 0)) { return { success: true, output: output || '(no findings)' }; } + if (entry.name === 'write-checkpoint.sh') { + return { + success: false, + skipped: true, + output: output || err.message || 'Checkpoint helper failed', + error: 'Non-fatal checkpoint helper failure', + }; + } return { success: false, output, error: err.message ?? 'Script failed' }; } }, @@ -585,15 +828,31 @@ async function runSearch( scopeRoot?: string ): Promise { const ftsResults = filterItemsToScope(fts.search(query, limit), scopeRoot); - let output = ftsResults.map((r) => `${r.relPath}: ${r.snippet}`).join('\n'); + const ftsLines = ftsResults.map((r) => `${r.relPath}: ${r.snippet}`); + const seenPaths = new Set(ftsResults.map((r) => r.relPath)); - if ((!output || ftsResults.length < 3) && workspace) { + const rgLines: string[] = []; + if (workspace) { const rgOut = await ripgrepSearch(workspace, query, limit, scopeRoot); if (rgOut) { - output = output ? `${output}\n--- ripgrep ---\n${rgOut}` : rgOut; + for (const line of rgOut.split('\n')) { + const relPath = line.split(':')[0]?.trim(); + if (relPath && !seenPaths.has(relPath)) { + seenPaths.add(relPath); + } + rgLines.push(line); + } } } - return output; + + const sections: string[] = []; + if (ftsLines.length > 0) { + sections.push(ftsLines.join('\n')); + } + if (rgLines.length > 0) { + sections.push(`--- ripgrep ---\n${rgLines.join('\n')}`); + } + return sections.join('\n'); } export function createRepoMapTool(repoMap: RepoMapService): Tool<{ query?: string }> { @@ -878,7 +1137,7 @@ export function createRunCommandTool(workspace: string, getMode: () => string): if (normalized.error) { return { success: false, output: '', error: normalized.error }; } - const { stdout, stderr } = await execAsync(normalized.command, { + const { stdout, stderr } = await execShellSafe(normalized.command, { cwd: normalized.cwd, maxBuffer: 4 * 1024 * 1024, timeout: 120000, @@ -991,64 +1250,145 @@ export function createSpawnResearchAgentTool(): Tool<{ persona_instructions: z.string().optional(), }), async execute(input): Promise { - const combinedTask = [input.task, input.focus, input.persona_instructions].filter(Boolean).join('\n'); - if (isAuditSubagentBlocked(combinedTask)) { - log.warn('Blocked audit subagent', { task: input.task.slice(0, 120) }); - return { success: true, output: buildScriptFirstAuditMessage(input.task) }; - } - - if (!researchAgentRuntime || !researchAgent) { - return { success: false, output: '', error: 'Research agent not configured' }; - } + return runSubagentTool({ + type: 'research', + task: input.task, + focus: input.focus, + targetFiles: input.targetFiles, + chunkSize: input.chunkSize, + personaInstructions: input.persona_instructions, + }); + }, + }; +} - const provider = researchAgentRuntime.getProvider(); - if (!provider) { - return { success: false, output: '', error: 'No LLM provider available' }; - } - const runId = subagentTracker?.start(input.task, input.focus); - try { - const targetFiles = input.targetFiles ?? []; - let report: string; - if (targetFiles.length > 10) { - const chunkSize = input.chunkSize ?? 8; - const chunks = chunkArray(targetFiles, chunkSize); - const reports = await Promise.all( - chunks.map((chunk, index) => - researchAgent!.run( - provider, - `${input.task}\n\nTarget file chunk ${index + 1}/${chunks.length}:\n${chunk.join('\n')}`, - input.focus, - researchAgentRuntime!.getTools(), - undefined, - input.persona_instructions - ) - ) - ); - report = reports.map((r, i) => `## Chunk ${i + 1}\n${r}`).join('\n\n'); - } else { - const task = targetFiles.length - ? `${input.task}\n\nTarget files:\n${targetFiles.join('\n')}` - : input.task; - report = await researchAgent.run( - provider, - task, - input.focus, - researchAgentRuntime.getTools(), - undefined, - input.persona_instructions - ); - } - if (runId) subagentTracker?.finish(runId, report); - return { success: true, output: report }; - } catch (e) { - const err = String(e); - if (runId) subagentTracker?.fail(runId, err); - return { success: false, output: '', error: err }; - } +export function createSpawnSubagentTool(): Tool<{ + type: string; + task: string; + focus?: string; + targetFiles?: string[]; + scopeRoot?: string; + commands?: string[]; + chunkSize?: number; + persona_instructions?: string; +}> { + return { + name: 'spawn_subagent', + description: + 'Delegate scoped work to a typed subagent: research, implementer, reviewer, verifier, or a workspace custom agent from .mitii/agents. Implementer requires targetFiles or scopeRoot.', + risk: 'medium', + inputSchema: z.object({ + type: z.string(), + task: z.string(), + focus: z.string().optional(), + targetFiles: z.array(z.string()).optional(), + scopeRoot: z.string().optional(), + commands: z.array(z.string()).optional(), + chunkSize: z.number().int().min(1).max(10).optional(), + persona_instructions: z.string().optional(), + }), + async execute(input): Promise { + return runSubagentTool({ + type: input.type, + task: input.task, + focus: input.focus, + targetFiles: input.targetFiles, + scopeRoot: input.scopeRoot, + commands: input.commands, + chunkSize: input.chunkSize, + personaInstructions: input.persona_instructions, + }); }, }; } +async function runSubagentTool(input: { + type: string; + task: string; + focus?: string; + targetFiles?: string[]; + scopeRoot?: string; + commands?: string[]; + chunkSize?: number; + personaInstructions?: string; +}): Promise { + const combinedTask = [input.task, input.focus, input.personaInstructions].filter(Boolean).join('\n'); + if ((input.type === 'research' || input.type === 'reviewer') && isAuditSubagentBlocked(combinedTask)) { + log.warn('Blocked audit subagent', { task: input.task.slice(0, 120), type: input.type }); + return { success: true, output: buildScriptFirstAuditMessage(input.task) }; + } + + if (!subagentRuntime) { + return { success: false, output: '', error: 'Subagent runtime not configured' }; + } + const enabled = new Set(subagentRuntime.enabledTypes ?? ['research']); + if (!enabled.has(input.type)) { + return { success: false, output: '', error: `Subagent type ${input.type} is disabled by policy` }; + } + const maxConcurrent = Math.max(1, subagentRuntime.maxConcurrent ?? 2); + if (activeSubagents >= maxConcurrent) { + return { success: false, output: '', error: `Subagent concurrency limit reached (${maxConcurrent})` }; + } + const provider = subagentRuntime.getProvider(); + if (!provider) { + return { success: false, output: '', error: 'No LLM provider available' }; + } + + const registry = createDefaultSubagentRegistry( + subagentRuntime.workspace ? loadWorkspaceAgents(subagentRuntime.workspace).agents : [] + ); + const definition = registry.get(input.type); + if (!definition) { + return { success: false, output: '', error: `Unknown subagent type: ${input.type}` }; + } + const effectiveDefinition = { + ...definition, + maxSteps: input.type === 'research' && subagentRuntime.maxSteps ? subagentRuntime.maxSteps : definition.maxSteps, + timeoutMs: input.type === 'research' && subagentRuntime.timeoutMs ? subagentRuntime.timeoutMs : definition.timeoutMs, + }; + + const runId = subagentTracker?.start(input.task, input.focus, { + type: input.type, + scope: input.scopeRoot ?? input.targetFiles?.slice(0, 6).join(', '), + }); + activeSubagents += 1; + try { + const subagent = new BaseSubagent(effectiveDefinition, subagentRuntime.toolExecutor); + const targetFiles = input.targetFiles ?? []; + let report: string; + if (input.type === 'research' && targetFiles.length > 10) { + const chunkSize = input.chunkSize ?? 8; + const chunks = chunkArray(targetFiles, chunkSize); + const reports = await Promise.all(chunks.map((chunk, index) => + subagent.run(provider, { + task: `${input.task}\n\nTarget file chunk ${index + 1}/${chunks.length}`, + focus: input.focus, + targetFiles: chunk, + personaInstructions: input.personaInstructions, + }, subagentRuntime!.getTools()) + )); + report = reports.map((r, i) => `## Chunk ${i + 1}\n${r}`).join('\n\n'); + } else { + report = await subagent.run(provider, { + task: input.task, + focus: input.focus, + targetFiles, + scopeRoot: input.scopeRoot, + commands: input.commands, + personaInstructions: input.personaInstructions, + }, subagentRuntime.getTools()); + } + if (runId) subagentTracker?.finish(runId, report, { progress: 100 }); + return { success: true, output: report }; + } catch (e) { + const err = String(e); + if (runId) subagentTracker?.fail(runId, err); + return { success: false, output: '', error: err }; + } finally { + activeSubagents -= 1; + } +} + function chunkArray(items: T[], size: number): T[][] { const chunks: T[][] = []; for (let i = 0; i < items.length; i += size) { diff --git a/src/core/tools/coerceInput.ts b/src/core/tools/coerceInput.ts index 8f122439..91e92dfd 100644 --- a/src/core/tools/coerceInput.ts +++ b/src/core/tools/coerceInput.ts @@ -64,6 +64,9 @@ export function normalizeToolInput(toolName: string, input: unknown): unknown { obj.stepId = obj.id; delete obj.id; } + if (!obj.stepId) { + obj.stepId = 'current'; + } } if (toolName === 'search' && typeof obj.query !== 'string' && typeof obj.pattern === 'string') { diff --git a/src/core/tools/planTools.ts b/src/core/tools/planTools.ts index 0b1ede1d..dcaf9f25 100644 --- a/src/core/tools/planTools.ts +++ b/src/core/tools/planTools.ts @@ -11,6 +11,7 @@ const log = createLogger('PlanTools'); export const PLANNING_DISCOVERY_TOOLS = new Set([ 'read_file', 'read_files', + 'resolve_path', 'list_files', 'search', 'search_batch', @@ -24,6 +25,7 @@ export const PLANNING_DISCOVERY_TOOLS = new Set([ 'run_command', 'execute_workspace_script', 'spawn_research_agent', + 'spawn_subagent', 'fetch_web', 'ask_question', ]); @@ -124,7 +126,7 @@ function resolvePlanStepId( if (running) return running.id; const pending = plan.steps.find((s) => s.status === 'pending'); - if (pending && /^(execute|verify|complete|done|task_complete)$/i.test(rawStepId)) { + if (pending && /^(current|active|execute|verify|complete|done|task_complete)$/i.test(rawStepId)) { return pending.id; } diff --git a/src/core/tools/toolAliases.ts b/src/core/tools/toolAliases.ts index 01aea8da..8ebd87a0 100644 --- a/src/core/tools/toolAliases.ts +++ b/src/core/tools/toolAliases.ts @@ -4,6 +4,7 @@ export const TOOL_NAME_ALIASES: Record = { grep: 'search', ripgrep: 'search', rg: 'search', + resolve_file: 'resolve_path', read_file_batch: 'read_files', read_files_batch: 'read_files', list_directory: 'list_files', diff --git a/src/core/tools/types.ts b/src/core/tools/types.ts index 984c474d..150850dd 100644 --- a/src/core/tools/types.ts +++ b/src/core/tools/types.ts @@ -6,6 +6,8 @@ export interface ToolResult { success: boolean; output: string; error?: string; + /** Intentional dedup / non-fatal helper warning. */ + skipped?: boolean; } export interface Tool { diff --git a/src/node/cli.ts b/src/node/cli.ts index 57bcb0f0..39a1802d 100644 --- a/src/node/cli.ts +++ b/src/node/cli.ts @@ -1,10 +1,29 @@ #!/usr/bin/env node -import { existsSync, readFileSync, writeFileSync } from 'fs'; -import { join, resolve } from 'path'; +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { execFile, execFileSync } from 'child_process'; +import { createInterface } from 'readline/promises'; +import { stdin as input, stdout as output } from 'process'; +import { homedir } from 'os'; +import { dirname, join, resolve } from 'path'; import { AuditPackBuilder, verifyAuditPack } from '../core/audit'; -import { HeadlessAgentHost, generateHeadlessChangelog, prepareHeadlessRelease } from '../core/headless'; +import { generateHeadlessChangelog, prepareHeadlessRelease } from '../core/headless'; import type { HeadlessRuntime } from '../core/headless/HeadlessConfig'; import type { ProviderType } from '../core/config/schema'; +import { createClient, query, DaemonClient, DaemonSessionClient } from '../../packages/sdk/src'; +import type { MitiiMode, MitiiEvent } from '../../packages/sdk/src'; +import { connectAgentMemoryMcp } from '../core/mcp/mcpWorkspaceConfig'; +import { AutoMemoryFileWriter } from '../core/memory/AutoMemoryFileWriter'; +import { serveCommand } from '../../packages/daemon/src/cli'; +import { startMitiiBoard } from '../../packages/board/src/server'; +import { TaskBoardService, ParallelAgentRunner } from '../core/task'; +import { WorktreeService } from '../core/git'; +import { IndexWorkerService } from '../core/indexing/IndexWorkerService'; +import { GitHubPullRequestService, inferGitHubRepo } from '../core/integrations/github'; +import { JobQueueService, type MitiiJob } from '../core/jobs'; +import { TeamService } from '../core/teams'; +import { promisify } from 'util'; + +const execFileAsync = promisify(execFile); async function main(argv: string[]): Promise { const [command, ...args] = argv; @@ -13,7 +32,17 @@ async function main(argv: string[]): Promise { const json = args.includes('--json'); const prompt = positional(args).join(' ').trim(); - if (!command || command === '--help' || command === 'help') { + if (!command) { + if (process.stdin.isTTY) return interactive(cwd, args); + printHelp(); + return 0; + } + + if (command === '-i' || command === '--interactive') { + return interactive(cwd, args, prompt || valueOf(args, '-i') || valueOf(args, '--interactive')); + } + + if (command === '--help' || command === 'help') { printHelp(); return 0; } @@ -24,6 +53,46 @@ async function main(argv: string[]): Promise { return 0; } + if (command === 'serve') { + return serveCommand(args, cwd); + } + + if (command === 'board') { + return boardCommand(cwd, args); + } + + if (command === 'task') { + return taskCommand(cwd, args, json); + } + + if (command === 'index') { + return indexCommand(cwd, args, json); + } + + if (command === 'pr') { + return prCommand(cwd, args, json); + } + + if (command === 'job') { + return jobCommand(cwd, args, json); + } + + if (command === 'worker') { + return workerCommand(cwd, args, json); + } + + if (command === 'team') { + return teamCommand(cwd, args, json); + } + + if (command === 'connect') { + return connectCommand(cwd, args); + } + + if (command === 'agents' && args[0] === 'init') { + return initAgentTemplate(cwd, json); + } + if (command === 'prepare-release') { const result = await prepareHeadlessRelease(cwd, since); process.stdout.write(json ? JSON.stringify(result, null, 2) + '\n' : result.releaseNotes); @@ -58,43 +127,27 @@ async function main(argv: string[]): Promise { } if (command === 'ask') { - 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(); - } + return runOneShot('ask', cwd, args, prompt || readStdin(), json); } if (command === 'plan') { - 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(); - } + return runOneShot('plan', cwd, args, prompt || readStdin(), json); } if (command === 'agent') { - 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 runOneShot((valueOf(args, '--mode') as MitiiMode | undefined) ?? 'agent', cwd, args, prompt || readStdin(), json); + } + + if (command === 'init') { + return initProjectInstructions(cwd, args, json); + } + + if (command === 'auth') { + return authCommand(args, json); + } + + if (command === 'memory') { + return memoryCommand(cwd, args, json); } if (command === 'commit-msg') { @@ -107,20 +160,607 @@ async function main(argv: string[]): Promise { return 1; } -function createHost(cwd: string, args: string[]): HeadlessAgentHost { - const provider = (valueOf(args, '--provider') as ProviderType | undefined) ?? 'echo'; +async function runOneShot(mode: MitiiMode, cwd: string, args: string[], prompt: string, json: boolean): Promise { + const daemonUrl = valueOf(args, '--daemon-url'); + if (daemonUrl) { + return runDaemonOneShot(mode, cwd, args, prompt, json, daemonUrl); + } + const clientOptions = clientOptionsFromArgs(cwd, args); + if (mode === 'ask' && !json) { + const client = createClient(clientOptions); + try { + process.stdout.write(`${await client.ask(prompt)}\n`); + return 0; + } finally { + await client.dispose(); + } + } + if (mode === 'plan' && !json) { + const client = createClient(clientOptions); + try { + process.stdout.write(`${JSON.stringify(await client.plan(prompt), null, 2)}\n`); + return 0; + } finally { + await client.dispose(); + } + } + + for await (const event of query({ ...clientOptions, mode, prompt, sessionId: valueOf(args, '--session-id') })) { + if (json) { + process.stdout.write(JSON.stringify(event) + '\n'); + } else { + renderCliEvent(event); + } + } + return 0; +} + +async function runDaemonOneShot(mode: MitiiMode, cwd: string, args: string[], prompt: string, json: boolean, daemonUrl: string): Promise { + const client = new DaemonClient({ baseUrl: daemonUrl, token: valueOf(args, '--daemon-token') ?? process.env.MITII_SERVER_TOKEN }); + const session = await DaemonSessionClient.createOrAttach(client, { + cwd, + mode, + approval: (valueOf(args, '--approval') as 'auto' | 'manual' | undefined) ?? 'manual', + }); + const events = session.events(); + await session.prompt({ mode, message: prompt }); + for await (const event of events) { + if (json) { + process.stdout.write(JSON.stringify(event) + '\n'); + } else { + renderCliEvent(event); + } + if (event.type === 'done' || event.type === 'error') break; + } + return 0; +} + +function clientOptionsFromArgs(cwd: string, args: string[]) { + const saved = loadDefaultCredentials(); + const provider = (valueOf(args, '--provider') as ProviderType | undefined) ?? saved.provider ?? 'echo'; const runtime = (valueOf(args, '--runtime') as HeadlessRuntime | undefined) ?? (provider === 'echo' ? 'stub' : 'real'); - return new HeadlessAgentHost({ + return { cwd, runtime, - providerType: provider, - baseUrl: valueOf(args, '--base-url'), - model: valueOf(args, '--model'), + provider, + baseUrl: valueOf(args, '--base-url') ?? saved.baseUrl, + model: valueOf(args, '--model') ?? saved.model, + apiKey: valueOf(args, '--api-key') ?? saved.apiKey, approval: (valueOf(args, '--approval') as 'auto' | 'manual' | undefined) ?? 'auto', enablePuppeteer: args.includes('--enable-puppeteer'), allowNetwork: args.includes('--allow-network'), - }); + vectors: args.includes('--vectors'), + }; +} + +function renderCliEvent(event: MitiiEvent): void { + if (event.type === 'assistant_delta') process.stdout.write(event.content); + if (event.type === 'reasoning_delta') process.stderr.write(event.content); + if (event.type === 'tool_start') process.stderr.write(`\n[tool] ${event.tool}\n`); + if (event.type === 'approval_required') process.stderr.write(`\n[approval required] ${event.tool}: ${event.message ?? event.id}\n`); + if (event.type === 'error') process.stderr.write(`\n[error] ${event.message}\n`); + if (event.type === 'done') process.stdout.write(event.content.endsWith('\n') ? '' : '\n'); +} + +async function interactive(cwd: string, args: string[], initialPrompt = ''): Promise { + const rl = createInterface({ input, output }); + let mode: MitiiMode = (valueOf(args, '--mode') as MitiiMode | undefined) ?? 'agent'; + process.stdout.write(`Mitii interactive (${mode}) | ${cwd}\n`); + process.stdout.write('Slash commands: /ask /plan /agent /review /exit\n\n'); + try { + let nextPrompt = initialPrompt; + while (true) { + const line = nextPrompt || await rl.question(`${mode}> `); + nextPrompt = ''; + const trimmed = line.trim(); + if (!trimmed) continue; + if (trimmed === '/exit' || trimmed === '/quit') break; + if (['/ask', '/plan', '/agent', '/review'].includes(trimmed)) { + mode = trimmed.slice(1) as MitiiMode; + continue; + } + await runOneShot(mode, cwd, args, trimmed, false); + } + return 0; + } finally { + rl.close(); + } +} + +async function initProjectInstructions(cwd: string, args: string[], json: boolean): Promise { + const local = args.includes('--local'); + const force = args.includes('--force'); + const target = join(cwd, local ? '.mitii/MITTII.local.md' : 'MITII.md'); + if (existsSync(target) && !force) { + const message = `${target} already exists. Re-run with --force to overwrite.`; + process.stderr.write(`${message}\n`); + return 2; + } + mkdirSync(dirname(target), { recursive: true }); + const template = buildInstructionsTemplate(cwd); + writeFileSync(target, template, 'utf-8'); + process.stdout.write(json ? JSON.stringify({ path: target }) + '\n' : `Created ${target}\n`); + return 0; +} + +function buildInstructionsTemplate(cwd: string): string { + const pkgPath = join(cwd, 'package.json'); + let scripts: Record = {}; + if (existsSync(pkgPath)) { + try { + scripts = JSON.parse(readFileSync(pkgPath, 'utf-8')).scripts ?? {}; + } catch { + scripts = {}; + } + } + const scriptLines = Object.entries(scripts) + .filter(([name]) => /^(build|test|lint|typecheck|compile|smoke)/.test(name)) + .map(([name, command]) => `- ${name}: \`${command}\``); + return [ + '# MITTII.md', + '', + '## Project Commands', + scriptLines.length ? scriptLines.join('\n') : '- Add build, test, and lint commands here.', + '', + '## Architecture Notes', + '- Describe key packages, runtime boundaries, and important data flows.', + '', + '## Conventions', + '- Keep changes scoped and run the most relevant verification before finishing.', + '', + '## Safety', + '- Do not commit secrets. Prefer local-first tooling unless the task explicitly needs network access.', + '', + ].join('\n'); +} + +async function authCommand(args: string[], json: boolean): Promise { + const sub = args[0]; + if (sub === 'list' || sub === 'show') { + const saved = loadDefaultCredentials(); + const shown = { ...saved, apiKey: saved.apiKey ? maskSecret(saved.apiKey) : undefined }; + process.stdout.write(JSON.stringify(shown, null, 2) + '\n'); + return 0; + } + const provider = valueOf(args, '--provider'); + const apiKey = valueOf(args, '--apikey') ?? valueOf(args, '--api-key'); + const model = valueOf(args, '--model'); + const baseUrl = valueOf(args, '--base-url'); + + if (provider || apiKey || model || baseUrl) { + saveDefaultCredentials({ provider: provider as ProviderType | undefined, apiKey, model, baseUrl }); + process.stdout.write(json ? JSON.stringify({ saved: true }) + '\n' : 'Saved credentials to ~/.mitii/credentials.json\n'); + return 0; + } + + const rl = createInterface({ input, output }); + try { + saveDefaultCredentials({ + provider: (await rl.question('Provider: ')) as ProviderType, + baseUrl: await rl.question('Base URL: '), + model: await rl.question('Model: '), + apiKey: await rl.question('API key: '), + }); + process.stdout.write('Saved credentials to ~/.mitii/credentials.json\n'); + return 0; + } finally { + rl.close(); + } +} + +function memoryCommand(cwd: string, args: string[], json: boolean): number { + const sub = args[0]; + if (sub === 'connect' && args[1] === 'agentmemory') { + const servers = connectAgentMemoryMcp(cwd); + process.stdout.write(json ? JSON.stringify({ agentmemory: servers.agentmemory }, null, 2) + '\n' : 'Connected agentmemory MCP in .mitii/mcp.json\n'); + return 0; + } + if (sub === 'status') { + const writer = new AutoMemoryFileWriter(cwd, { scope: 'both' }); + const recent = writer.readRecent(20); + process.stdout.write(json ? JSON.stringify({ recentCount: recent.length, recent }, null, 2) + '\n' : `Auto-memory files: ${recent.length}\n`); + return 0; + } + if (sub === 'prune') { + const days = Number(valueOf(args, '--days') ?? 30); + const removed = new AutoMemoryFileWriter(cwd, { scope: 'both' }).prune(Number.isFinite(days) ? days : 30); + process.stdout.write(json ? JSON.stringify({ removed }) + '\n' : `Removed ${removed} auto-memory files.\n`); + return 0; + } + process.stderr.write('Usage: mitii memory status|prune|connect agentmemory\n'); + return 2; +} + +async function taskCommand(cwd: string, args: string[], json: boolean): Promise { + const sub = args[0]; + const board = new TaskBoardService(cwd); + if (sub === 'add') { + const title = positional(args.slice(1))[0] ?? 'Untitled task'; + const prompt = valueOf(args, '--prompt') ?? title; + const dependsOn = (valueOf(args, '--depends-on') ?? '').split(',').map((item) => item.trim()).filter(Boolean); + const task = board.add({ title, prompt, dependsOn }); + process.stdout.write(json ? JSON.stringify(task, null, 2) + '\n' : `Added ${task.id}: ${task.title}\n`); + return 0; + } + if (sub === 'list' || !sub) { + const tasks = board.list(); + process.stdout.write(json ? JSON.stringify({ tasks }, null, 2) + '\n' : formatTasks(tasks)); + return 0; + } + if (sub === 'start') { + const id = args[1]; + if (!id) { + process.stderr.write('mitii task start requires a task id.\n'); + return 2; + } + const task = board.transition(id, 'running'); + process.stdout.write(json ? JSON.stringify(task, null, 2) + '\n' : `Started ${task.id}\n`); + return 0; + } + if (sub === 'run') { + const runner = new ParallelAgentRunner({ + workspace: cwd, + parallel: Number(valueOf(args, '--parallel') ?? 2), + ...clientOptionsFromArgs(cwd, args), + }); + const result = await runner.runRunnable(); + process.stdout.write(json ? JSON.stringify(result, null, 2) + '\n' : `Started ${result.started.length}, completed ${result.completed.length}, failed ${result.failed.length}\n`); + return result.failed.length ? 1 : 0; + } + if (sub === 'worktrees') { + const worktrees = new WorktreeService(cwd).list(); + process.stdout.write(json ? JSON.stringify({ worktrees }, null, 2) + '\n' : worktrees.map((w) => `${w.taskId}\t${w.status}\t${w.branch}\t${w.path}`).join('\n') + '\n'); + return 0; + } + if (sub === 'merge') { + const id = args[1]; + if (!id) { + process.stderr.write('mitii task merge requires a task id.\n'); + return 2; + } + const result = await mergeTask(cwd, id, { + squash: args.includes('--squash'), + cleanup: args.includes('--merge-and-cleanup'), + forceCleanup: args.includes('--force'), + }); + process.stdout.write(json ? JSON.stringify(result, null, 2) + '\n' : `${result.message}\n`); + return 0; + } + process.stderr.write('Usage: mitii task add|list|start|run|worktrees|merge\n'); + return 2; +} + +async function indexCommand(cwd: string, args: string[], json: boolean): Promise { + const sub = args[0] ?? 'status'; + const worker = new IndexWorkerService({ workspace: cwd }); + await worker.initialize(); + try { + if (sub === 'status') { + const status = worker.status(); + process.stdout.write(json ? JSON.stringify(status, null, 2) + '\n' : formatIndexStatus(status)); + return 0; + } + if (sub === 'repair') { + const repair = worker.repair(); + process.stdout.write(json ? JSON.stringify(repair, null, 2) + '\n' : `Removed ${repair.removedFiles} missing files, rebuilt ${repair.rebuiltFtsChunks} FTS chunks, vacuumed: ${repair.vacuumed}\n`); + return repair.health.ok ? 0 : 1; + } + if (sub === 'enqueue' || sub === 'watch') { + const paths = positional(args.slice(1)); + const result = await worker.enqueue(paths.length ? paths : undefined); + process.stdout.write(json ? JSON.stringify(result, null, 2) + '\n' : `Queued ${result.queued} files (${result.added} added, ${result.changed} changed, ${result.deleted} deleted).\n`); + if (sub === 'watch') { + process.stderr.write('Initial enqueue complete. Keep `mitii serve` running for continuous index worker API access.\n'); + } + return 0; + } + process.stderr.write('Usage: mitii index status|repair|enqueue|watch [paths...] [--cwd ] [--json]\n'); + return 2; + } finally { + worker.dispose(); + } +} + +async function prCommand(cwd: string, args: string[], json: boolean): Promise { + const sub = args[0]; + if (sub !== 'create') { + process.stderr.write('Usage: mitii pr create --title "..." [--body "..."] [--body-file file] [--head branch] [--base main] [--draft false] [--repo owner/name]\n'); + return 2; + } + const repoArg = valueOf(args, '--repo'); + const inferred = repoArg + ? parseOwnerRepo(repoArg) + : inferGitHubRepo(cwd); + if (!inferred) { + process.stderr.write('Could not infer GitHub repo. Pass --repo owner/name.\n'); + return 2; + } + const title = valueOf(args, '--title'); + if (!title) { + process.stderr.write('mitii pr create requires --title.\n'); + return 2; + } + const body = valueOf(args, '--body') + ?? readBodyFile(cwd, valueOf(args, '--body-file')) + ?? 'Created by Mitii.'; + const head = valueOf(args, '--head') ?? currentGitBranch(cwd); + const base = valueOf(args, '--base') ?? 'main'; + const token = valueOf(args, '--token') ?? process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN; + if (!head) { + process.stderr.write('Could not infer current git branch. Pass --head.\n'); + return 2; + } + const result = await new GitHubPullRequestService().createPullRequest({ + ...inferred, + head, + base, + title, + body, + draft: valueOf(args, '--draft') !== 'false', + }, token ?? ''); + process.stdout.write(json ? JSON.stringify(result, null, 2) + '\n' : `${result.htmlUrl}\n`); + return 0; +} + +async function jobCommand(cwd: string, args: string[], json: boolean): Promise { + const sub = args[0] ?? 'list'; + const queue = new JobQueueService(cwd); + if (sub === 'enqueue') { + const prompt = positional(args.slice(1)).join(' ') || valueOf(args, '--prompt'); + if (!prompt) { + process.stderr.write('mitii job enqueue requires a prompt.\n'); + return 2; + } + const job = queue.enqueue({ + prompt, + cwd, + mode: (valueOf(args, '--mode') as MitiiJob['mode'] | undefined) ?? 'agent', + }); + process.stdout.write(json ? JSON.stringify(job, null, 2) + '\n' : `Enqueued ${job.id}\n`); + return 0; + } + if (sub === 'list' || sub === 'status') { + const jobs = queue.list(); + process.stdout.write(json ? JSON.stringify({ jobs }, null, 2) + '\n' : formatJobs(jobs)); + return 0; + } + process.stderr.write('Usage: mitii job enqueue "prompt" [--mode ask|plan|agent|review] | mitii job list\n'); + return 2; +} + +async function workerCommand(cwd: string, args: string[], json: boolean): Promise { + const once = args.includes('--once'); + const intervalMs = Number(valueOf(args, '--interval-ms') ?? 5000); + const queue = new JobQueueService(cwd); + const workerId = `worker-${process.pid}`; + do { + const job = queue.lease(workerId); + if (job) { + try { + const output = await runQueuedJob(job, args, json); + queue.complete(job.id, output); + process.stderr.write(`Completed job ${job.id}\n`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + queue.fail(job.id, message); + process.stderr.write(`Failed job ${job.id}: ${message}\n`); + } + } else if (once) { + process.stderr.write('No queued jobs.\n'); + } + if (once) break; + await sleep(Number.isFinite(intervalMs) ? intervalMs : 5000); + } while (true); + return 0; +} + +async function teamCommand(cwd: string, args: string[], json: boolean): Promise { + const sub = args[0]; + const name = valueOf(args, '--team-name') ?? args[1]; + const teams = new TeamService(); + if (sub === 'create') { + if (!name) { + process.stderr.write('mitii team create requires a name.\n'); + return 2; + } + const manifest = teams.create(name, { workspace: cwd }); + process.stdout.write(json ? JSON.stringify(manifest, null, 2) + '\n' : `Created team ${manifest.name}\n`); + return 0; + } + if (sub === 'status') { + if (!name) { + process.stderr.write('mitii team status requires a name.\n'); + return 2; + } + const status = teams.status(name); + if (!status) { + process.stderr.write(`Team not found: ${name}\n`); + return 1; + } + process.stdout.write(json ? JSON.stringify(status, null, 2) + '\n' : formatTeamStatus(status)); + return 0; + } + if (sub === 'task') { + const teamName = valueOf(args, '--team-name'); + const title = positional(args.slice(1))[0]; + if (!teamName || !title) { + process.stderr.write('Usage: mitii team task "title" --team-name [--prompt "..."] [--role implementer]\n'); + return 2; + } + const task = teams.addTask(teamName, { + title, + prompt: valueOf(args, '--prompt') ?? title, + assigneeRole: valueOf(args, '--role'), + }); + process.stdout.write(json ? JSON.stringify(task, null, 2) + '\n' : `Added team task ${task.id}\n`); + return 0; + } + if (sub === 'send') { + const teamName = valueOf(args, '--team-name'); + const text = positional(args.slice(1)).join(' '); + if (!teamName || !text) { + process.stderr.write('Usage: mitii team send "message" --team-name [--from lead] [--to implementer]\n'); + return 2; + } + const message = teams.sendMessage(teamName, { + from: valueOf(args, '--from') ?? 'lead', + to: valueOf(args, '--to') ?? 'team', + text, + }); + process.stdout.write(json ? JSON.stringify(message, null, 2) + '\n' : `Sent message ${message.id}\n`); + return 0; + } + process.stderr.write('Usage: mitii team create | status | task "title" --team-name | send "message" --team-name \n'); + return 2; +} + +async function connectCommand(cwd: string, args: string[]): Promise { + const connector = args[0]; + if (connector === 'telegram') { + const token = valueOf(args, '--token') ?? process.env.TELEGRAM_BOT_TOKEN; + if (!token) { + process.stderr.write('mitii connect telegram requires --token or TELEGRAM_BOT_TOKEN.\n'); + return 2; + } + const { TelegramConnector } = await import('../../packages/channels/src/telegram'); + process.stderr.write('Mitii Telegram connector polling. Press Ctrl+C to stop.\n'); + await new TelegramConnector({ + token, + daemonUrl: valueOf(args, '--daemon-url') ?? 'http://127.0.0.1:4310', + daemonToken: valueOf(args, '--daemon-token') ?? process.env.MITII_SERVER_TOKEN, + cwd, + }).start(); + return 0; + } + process.stderr.write('Usage: mitii connect telegram --token [--daemon-url http://127.0.0.1:4310]\n'); + return 2; +} + +async function runQueuedJob(job: MitiiJob, args: string[], json: boolean): Promise { + let output = ''; + for await (const event of query({ + ...clientOptionsFromArgs(job.cwd, args), + cwd: job.cwd, + mode: job.mode, + prompt: job.prompt, + sessionId: job.id, + })) { + if (json) process.stdout.write(JSON.stringify(event) + '\n'); + if (event.type === 'assistant_delta') output += event.content; + if (event.type === 'done') break; + if (event.type === 'error') throw new Error(event.message); + } + return output.trim() || 'Completed without assistant output.'; +} + +async function mergeTask(cwd: string, id: string, options: { squash: boolean; cleanup: boolean; forceCleanup: boolean }): Promise<{ id: string; branch: string; message: string }> { + const board = new TaskBoardService(cwd); + const task = board.list().find((item) => item.id === id); + if (!task) throw new Error(`Task not found: ${id}`); + if (task.status !== 'review' && task.status !== 'done') { + throw new Error(`Task ${id} must be in review or done status before merge`); + } + const worktrees = new WorktreeService(cwd); + const entry = worktrees.list().find((item) => item.taskId === id); + const branch = task.branch ?? entry?.branch; + if (!branch) throw new Error(`Task ${id} has no worktree branch`); + await execFileAsync('git', ['merge', '--no-commit', '--no-ff', branch], { cwd }); + if (options.squash) { + await execFileAsync('git', ['merge', '--abort'], { cwd }).catch(() => undefined); + await execFileAsync('git', ['merge', '--squash', branch], { cwd }); + } + board.update(id, { status: 'done' }); + if (options.cleanup) { + await worktrees.remove(id, { force: options.forceCleanup }); + } + return { id, branch, message: `Merged ${branch}. Review and commit the staged merge result.` }; +} + +async function boardCommand(cwd: string, args: string[]): Promise { + const hostname = valueOf(args, '--hostname') ?? '127.0.0.1'; + const port = Number(valueOf(args, '--port') ?? 4311); + const board = await startMitiiBoard({ cwd, hostname, port: Number.isFinite(port) ? port : 4311, token: valueOf(args, '--token') }); + process.stderr.write(`Mitii board listening on ${board.url}\n`); + const shutdown = async () => { + await board.close(); + process.exit(0); + }; + process.once('SIGINT', () => void shutdown()); + process.once('SIGTERM', () => void shutdown()); + await new Promise(() => undefined); + return 0; +} + +function initAgentTemplate(cwd: string, json: boolean): number { + const target = join(cwd, '.mitii', 'agents', 'security-reviewer.md'); + mkdirSync(dirname(target), { recursive: true }); + if (!existsSync(target)) { + writeFileSync(target, [ + '---', + 'id: security-reviewer', + 'type: reviewer', + 'tools: [read_file, read_files, search, search_batch, git_diff, diagnostics, run_command]', + 'maxSteps: 10', + '---', + '', + 'You are a security-focused reviewer. Check for injection, auth bypass, secret leaks, unsafe file access, and missing validation.', + '', + ].join('\n'), 'utf-8'); + } + process.stdout.write(json ? JSON.stringify({ path: target }) + '\n' : `Created ${target}\n`); + return 0; +} + +function formatTasks(tasks: import('../core/task').MitiiTask[]): string { + if (tasks.length === 0) return 'No tasks.\n'; + return tasks.map((task) => `${task.id}\t${task.status}\t${task.title}`).join('\n') + '\n'; +} + +function formatIndexStatus(status: import('../core/indexing/IndexMaintenanceService').IndexStatusReport): string { + return [ + `Workspace: ${status.workspace}`, + `Files: ${status.filesIndexed}/${status.filesTotal} indexed`, + `Chunks: ${status.chunks}`, + `Symbols: ${status.symbols}`, + `Queue: ${status.queued} queued, running: ${status.running}, failed: ${status.failed}`, + `Database: ${status.dbPath ?? 'unknown'}${status.dbSizeBytes !== undefined ? ` (${status.dbSizeBytes} bytes)` : ''}`, + `Health: ${status.health.ok ? 'ok' : `issues (${status.health.errors.join('; ') || status.health.missingTables.join(', ')})`}`, + '', + ].join('\n'); +} + +function formatJobs(jobs: MitiiJob[]): string { + if (jobs.length === 0) return 'No jobs.\n'; + return jobs.map((job) => `${job.id}\t${job.status}\t${job.mode}\t${job.prompt.slice(0, 80)}`).join('\n') + '\n'; +} + +function formatTeamStatus(status: NonNullable>): string { + return [ + `${status.manifest.name} (${status.manifest.id})`, + `Roles: ${status.manifest.roles.join(', ')}`, + `Tasks: ${status.tasks.length}`, + `Unread messages: ${status.messages.filter((message) => !message.read).length}`, + '', + ].join('\n'); +} + +function parseOwnerRepo(value: string): { owner: string; repo: string } | undefined { + const [owner, repo] = value.split('/'); + return owner && repo ? { owner, repo } : undefined; +} + +function readBodyFile(cwd: string, path: string | undefined): string | undefined { + if (!path) return undefined; + return readFileSync(resolve(cwd, path), 'utf8'); +} + +function currentGitBranch(cwd: string): string | undefined { + try { + return execFileSync('git', ['branch', '--show-current'], { cwd, encoding: 'utf8' }).trim() || undefined; + } catch { + return undefined; + } } function valueOf(args: string[], name: string): string | undefined { @@ -133,7 +773,11 @@ function positional(args: string[]): string[] { for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg.startsWith('--')) { - if (!['--json'].includes(arg)) index += 1; + if (!['--json', '--enable-puppeteer', '--allow-network', '--vectors', '--force', '--local'].includes(arg)) index += 1; + continue; + } + if (arg === '-i') { + index += 1; continue; } out.push(arg); @@ -174,17 +818,80 @@ function printHelp(): void { 'Mitii CLI', '', 'Commands:', + ' mitii [--mode ask|plan|agent|review] Start interactive terminal session', + ' mitii -i "prompt" Start interactive session with an initial prompt', + ' mitii init [--local] [--force] [--cwd ] [--json]', + ' mitii auth [--provider --base-url --model --apikey ]', + ' mitii serve [--hostname 127.0.0.1] [--port 4310] [--token ] [--max-sessions 5]', + ' mitii board [--hostname 127.0.0.1] [--port 4311]', + ' mitii agents init [--cwd ] [--json]', + ' mitii task add "title" --prompt "..." [--depends-on ] [--json]', + ' mitii task list|start |run [--parallel 2]|worktrees [--cwd ] [--json]', + ' mitii index status|repair|enqueue|watch [paths...] [--cwd ] [--json]', + ' mitii pr create --title "..." [--body-file .mitii/pr-body.md] [--repo owner/name] [--head branch] [--base main]', + ' mitii job enqueue "prompt" [--mode agent] | mitii job list [--json]', + ' mitii worker [--once] [--interval-ms 5000] [--runtime real|stub] [--provider echo|openai|...]', + ' mitii team create | status | task "title" --team-name | send "message" --team-name ', + ' mitii connect telegram --token [--daemon-url http://127.0.0.1:4310]', + ' mitii auth list', + ' mitii memory status|prune|connect agentmemory [--cwd ] [--json]', ' 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" [--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]', + ' mitii agent "goal" [--mode ask|plan|agent|review] [--runtime real|stub] [--provider echo|openai|...] [--model ] [--approval auto|manual] [--enable-puppeteer] [--allow-network] [--vectors] [--json]', + ' Add --daemon-url http://127.0.0.1:4310 to ask/plan/agent to attach to mitii serve.', '', ].join('\n')); } +type SavedCredentials = { + provider?: ProviderType; + baseUrl?: string; + model?: string; + apiKey?: string; +}; + +function credentialsPath(): string { + return join(homedir(), '.mitii', 'credentials.json'); +} + +function loadDefaultCredentials(): SavedCredentials { + try { + const parsed = JSON.parse(readFileSync(credentialsPath(), 'utf-8')) as SavedCredentials; + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + return {}; + } +} + +function saveDefaultCredentials(next: SavedCredentials): void { + const path = credentialsPath(); + mkdirSync(dirname(path), { recursive: true }); + const merged = { ...loadDefaultCredentials(), ...compact(next) }; + writeFileSync(path, `${JSON.stringify(merged, null, 2)}\n`, { encoding: 'utf-8', mode: 0o600 }); + try { + chmodSync(path, 0o600); + } catch { + // Best effort on filesystems that do not support chmod. + } +} + +function compact>(value: T): Partial { + return Object.fromEntries(Object.entries(value).filter(([, v]) => typeof v === 'string' ? v.trim().length > 0 : v !== undefined)) as Partial; +} + +function maskSecret(value: string): string { + if (value.length <= 8) return '********'; + return `${value.slice(0, 4)}...${value.slice(-4)}`; +} + +function sleep(ms: number): Promise { + return new Promise((resolveSleep) => setTimeout(resolveSleep, ms)); +} + function formatAuditVerification(result: ReturnType): string { if (result.ok) { return `Audit pack verified (${result.entries.length} entries).\n`; diff --git a/src/vscode/commands.ts b/src/vscode/commands.ts index ebf29c62..d4507ec4 100644 --- a/src/vscode/commands.ts +++ b/src/vscode/commands.ts @@ -2,6 +2,8 @@ import * as vscode from 'vscode'; import { ThunderController } from '../core/app/ThunderController'; import { ThunderWebviewProvider } from './webview/ThunderWebviewProvider'; import { registerScmContributions } from './scm/registerScmContributions'; +import { migrateThunderSettingsToMitii } from '../core/config/vscode/migrate'; +import { MITII_SETTING_PATHS } from '../core/config/settingPaths'; export function registerCommands( context: vscode.ExtensionContext, @@ -9,46 +11,68 @@ export function registerCommands( webviewProvider: ThunderWebviewProvider ): void { context.subscriptions.push( - vscode.commands.registerCommand('thunder.openChat', async () => { + registerCommandAlias('thunder.openChat', 'mitii.openChat', async () => { await vscode.commands.executeCommand('thunder.sidebar.focus'); webviewProvider.showChat(); }), - vscode.commands.registerCommand('thunder.indexWorkspace', async () => { + registerCommandAlias('thunder.indexWorkspace', 'mitii.indexWorkspace', async () => { await controller.indexWorkspace(); }), - vscode.commands.registerCommand('thunder.showSettings', async () => { + registerCommandAlias('thunder.showSettings', 'mitii.showSettings', async () => { await vscode.commands.executeCommand('thunder.sidebar.focus'); webviewProvider.showSettings(); }), - vscode.commands.registerCommand('thunder.exportSessionLog', async () => { + registerCommandAlias('thunder.exportSessionLog', 'mitii.exportSessionLog', async () => { await controller.exportSessionLog(); }), - vscode.commands.registerCommand('thunder.exportAuditPack', async () => { + registerCommandAlias('thunder.exportAuditPack', 'mitii.exportAuditPack', async () => { await controller.exportAuditPack(); }), - vscode.commands.registerCommand('thunder.openSessionLog', async () => { + registerCommandAlias('thunder.openSessionLog', 'mitii.openSessionLog', async () => { await controller.openSessionLog(); }), - vscode.commands.registerCommand('thunder.generateChangelog', async () => { + registerCommandAlias('thunder.generateChangelog', 'mitii.generateChangelog', async () => { await controller.generateChangelog(); }), - vscode.commands.registerCommand('thunder.prepareRelease', async () => { + registerCommandAlias('thunder.prepareRelease', 'mitii.prepareRelease', async () => { await controller.prepareRelease(); }), - vscode.commands.registerCommand('thunder.showInlineDiff', async (approvalId?: string) => { + registerCommandAlias('thunder.showInlineDiff', 'mitii.showInlineDiff', async (approvalId?: string) => { if (typeof approvalId === 'string') { await controller.showInlineDiffForApproval(approvalId); } + }), + + vscode.commands.registerCommand('mitii.migrateThunderSettings', async () => { + const result = await migrateThunderSettingsToMitii([...MITII_SETTING_PATHS]); + await vscode.window.showInformationMessage( + `Mitii settings migration copied ${result.copied.length} setting${result.copied.length === 1 ? '' : 's'}.` + ); }) ); registerScmContributions(context, controller); } + +function registerCommandAlias( + legacyCommand: string, + mitiiCommand: string, + handler: (...args: any[]) => unknown +): vscode.Disposable { + const legacy = vscode.commands.registerCommand(legacyCommand, handler); + const current = vscode.commands.registerCommand(mitiiCommand, handler); + return { + dispose() { + legacy.dispose(); + current.dispose(); + }, + }; +} diff --git a/src/vscode/inlineDiffManager.ts b/src/vscode/inlineDiffManager.ts index 5f6dd681..f37e523c 100644 --- a/src/vscode/inlineDiffManager.ts +++ b/src/vscode/inlineDiffManager.ts @@ -52,7 +52,9 @@ export class InlineDiffManager implements vscode.Disposable { this.disposables.push( vscode.commands.registerCommand('thunder.acceptInlineDiff', () => this.accept()), + vscode.commands.registerCommand('mitii.acceptInlineDiff', () => this.accept()), vscode.commands.registerCommand('thunder.rejectInlineDiff', () => this.reject()), + vscode.commands.registerCommand('mitii.rejectInlineDiff', () => this.reject()), vscode.window.onDidChangeActiveTextEditor(() => this.refreshDecorations()) ); } diff --git a/src/vscode/nativeModuleHealth.ts b/src/vscode/nativeModuleHealth.ts index 15828634..bd477f54 100644 --- a/src/vscode/nativeModuleHealth.ts +++ b/src/vscode/nativeModuleHealth.ts @@ -43,9 +43,9 @@ export async function notifyNativeModuleHealth(result = checkBetterSqliteHealth( } export function detectEditorRebuildCommand(env: NodeJS.ProcessEnv = process.env): string { - const editor = (env.THUNDER_EDITOR || env.VSCODE_PID || '').toString().toLowerCase(); + const editor = (env.MITII_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 'MITII_EDITOR=cursor npm run rebuild:native'; } return 'npm run rebuild:native'; } diff --git a/src/vscode/scm/registerScmContributions.ts b/src/vscode/scm/registerScmContributions.ts index 6d192272..cf04b463 100644 --- a/src/vscode/scm/registerScmContributions.ts +++ b/src/vscode/scm/registerScmContributions.ts @@ -7,41 +7,43 @@ export function registerScmContributions( context: vscode.ExtensionContext, controller: ThunderController ): void { + const handler = async () => { + const status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 1000); + status.name = 'Mitii commit message generation'; + status.text = '$(sync~spin) Mitii generating commit message'; + status.tooltip = 'Mitii is reading staged changes and asking the model for a commit message.'; + status.show(); + try { + await vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + title: 'Mitii: Generating commit message', + cancellable: false, + }, async (progress) => { + progress.report({ message: 'Reading staged changes and recent commits…' }); + const result = await controller.generateCommitMessage(); + progress.report({ message: 'Writing message to Source Control…' }); + const workspace = controller.resolveWorkspacePath(); + const applied = workspace + ? await setGitCommitInputBox(workspace, result.fullMessage) + : false; + if (!applied) { + await vscode.env.clipboard.writeText(result.fullMessage); + void vscode.window.showWarningMessage( + brandMessage('Could not find the Git input box, so the commit message was copied to clipboard.') + ); + return; + } + void vscode.window.showInformationMessage(brandMessage('Commit message added to Source Control.')); + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + void vscode.window.showErrorMessage(brandMessage(message)); + } finally { + status.dispose(); + } + }; context.subscriptions.push( - vscode.commands.registerCommand('thunder.generateCommitMessage', async () => { - const status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 1000); - status.name = 'Mitii commit message generation'; - status.text = '$(sync~spin) Mitii generating commit message'; - status.tooltip = 'Mitii is reading staged changes and asking the model for a commit message.'; - status.show(); - try { - await vscode.window.withProgress({ - location: vscode.ProgressLocation.Notification, - title: 'Mitii: Generating commit message', - cancellable: false, - }, async (progress) => { - progress.report({ message: 'Reading staged changes and recent commits…' }); - const result = await controller.generateCommitMessage(); - progress.report({ message: 'Writing message to Source Control…' }); - const workspace = controller.resolveWorkspacePath(); - const applied = workspace - ? await setGitCommitInputBox(workspace, result.fullMessage) - : false; - if (!applied) { - await vscode.env.clipboard.writeText(result.fullMessage); - void vscode.window.showWarningMessage( - brandMessage('Could not find the Git input box, so the commit message was copied to clipboard.') - ); - return; - } - void vscode.window.showInformationMessage(brandMessage('Commit message added to Source Control.')); - }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - void vscode.window.showErrorMessage(brandMessage(message)); - } finally { - status.dispose(); - } - }) + vscode.commands.registerCommand('thunder.generateCommitMessage', handler), + vscode.commands.registerCommand('mitii.generateCommitMessage', handler) ); } diff --git a/src/vscode/webview/ThunderWebviewProvider.ts b/src/vscode/webview/ThunderWebviewProvider.ts index d75fa933..db959f13 100644 --- a/src/vscode/webview/ThunderWebviewProvider.ts +++ b/src/vscode/webview/ThunderWebviewProvider.ts @@ -233,7 +233,7 @@ export class ThunderWebviewProvider implements vscode.WebviewViewProvider { case 'setMode': { this.state = { ...this.state, mode: message.payload }; - this.controller.getSession()?.setMode(message.payload); + this.controller.handleModeChange(message.payload); if (message.payload === 'review') { await this.controller.refreshReviewDiff(); } diff --git a/src/vscode/webview/messages.ts b/src/vscode/webview/messages.ts index ac12c1a1..ce0d93fd 100644 --- a/src/vscode/webview/messages.ts +++ b/src/vscode/webview/messages.ts @@ -15,6 +15,7 @@ export type { AgentDepthView, ApprovalMode, IndexingSettingsPayload, + MemorySettingsPayload, McpCustomServerView, McpSettingsPayload, McpToggles, @@ -150,8 +151,11 @@ export interface AgentLiveStatusView { export interface SubagentStatusView { id: string; + type?: string; task: string; focus?: string; + scope?: string; + progress?: number; status: 'queued' | 'running' | 'done' | 'error'; startedAt: number; finishedAt?: number; @@ -164,6 +168,11 @@ export interface VectorIndexStatusView { embeddedChunks: number; provider: string; backend?: string; + /** True when the embedder or vector backend silently degraded at runtime (e.g. MiniLM + * model failed to load, or LanceDB's native table failed to open) — distinct from `provider`/ + * `backend`, which only describe config + package availability, not live health. */ + degraded?: boolean; + degradedDetail?: string; } export interface AgentActivityEntry { @@ -271,6 +280,9 @@ export interface SettingsView { requireApprovalWrites: boolean; requireApprovalShell: boolean; memoryEnabled: boolean; + summarizeAfterTask: boolean; + autoMemoryEnabled: boolean; + autoMemoryScope: 'user' | 'workspace' | 'both'; subagentsEnabled: boolean; agentMaxSteps: number; askDepth: AgentDepthView; @@ -341,6 +353,7 @@ export interface ContextToggles { diagnostics: boolean; memory: boolean; vectors: boolean; + callGraph: boolean; } export interface WebviewState { @@ -462,6 +475,7 @@ export const defaultMcpToggles = (): McpToggles => ({ memory: true, sequentialThinking: true, puppeteer: false, + agentmemory: false, }); export const defaultContextToggles = (): ContextToggles => ({ @@ -471,6 +485,7 @@ export const defaultContextToggles = (): ContextToggles => ({ diagnostics: false, memory: true, vectors: true, + callGraph: true, }); export const defaultSettingsView = (): SettingsView => ({ @@ -486,6 +501,9 @@ export const defaultSettingsView = (): SettingsView => ({ requireApprovalWrites: true, requireApprovalShell: true, memoryEnabled: true, + summarizeAfterTask: true, + autoMemoryEnabled: true, + autoMemoryScope: 'user', subagentsEnabled: true, agentMaxSteps: 15, askDepth: 'auto', @@ -511,7 +529,7 @@ export const defaultSettingsView = (): SettingsView => ({ localDebugAvailable: false, vectorsEnabled: true, embeddingProvider: 'minilm', - vectorBackend: 'sqlite', + vectorBackend: 'lancedb', hybridMemorySearch: true, minilmAvailable: false, lancedbAvailable: false, @@ -543,7 +561,7 @@ export const initialWebviewState = (): WebviewState => ({ agentActivity: [], agentLiveStatus: null, subagents: [], - vectorIndex: { enabled: false, embeddedChunks: 0, provider: 'none', backend: 'none' }, + vectorIndex: { enabled: false, embeddedChunks: 0, provider: 'none', backend: 'none', degraded: false }, plan: null, indexing: { indexed: 0, queued: 0, running: false, failed: 0, total: 0, activeWorkers: 0, processed: 0, runTotal: 0 }, memories: [], diff --git a/src/webview-ui/src/App.tsx b/src/webview-ui/src/App.tsx index d9759ba0..713c7247 100644 --- a/src/webview-ui/src/App.tsx +++ b/src/webview-ui/src/App.tsx @@ -14,7 +14,6 @@ import { IndexingStatusBar } from './components/IndexingStatusBar'; import { WorkspaceBanner } from './components/WorkspaceBanner'; import { HistoryPanel } from './components/HistoryPanel'; 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'; @@ -55,6 +54,12 @@ export function App() { const canRetry = state.messages.some((m) => m.role === 'user'); const [contextWarningsDismissed, setContextWarningsDismissed] = useState(false); const activeDepth = activeDepthForMode(state.settings, state.mode); + const questionApprovals = state.approvals.filter( + (req) => req.kind === 'question' || req.toolName === 'ask_question' + ); + const actionApprovals = state.approvals.filter( + (req) => req.kind !== 'question' && req.toolName !== 'ask_question' + ); useEffect(() => { setContextWarningsDismissed(false); @@ -75,9 +80,7 @@ export function App() { ) : ( )} - - {state.providerLabel} - + {AGENT_NAME}